A calm place to write long-form, and publish it to the open social web. skypress.blog/
0

Configure Feed

Select the types of activity you want to include in your feed.

Add implementation plan for per-article cover image picker

+1270
+1270
docs/superpowers/plans/2026-06-10-cover-image-picker.md
··· 1 + # Per-article cover image picker — Implementation Plan 2 + 3 + > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. 4 + 5 + **Goal:** Let creators pick an explicit per-article cover image in a strip below the editor, persisted to the standard `site.standard.document.coverImage` field and used (with a fallback to the first body image) as the Bluesky card thumb. 6 + 7 + **Architecture:** A lightweight React picker (`CoverImagePicker`) reuses the existing PDS upload path (`createMediaUpload` + blob `registry`) via a small bridge helper. The chosen blob ref flows through `Studio` → `PublishPanel` → `publish`/`updateDocument`, where it is written to the document's `coverImage` and preferred over `firstImageBlobRef()` for the post thumb. On edit, the stored `coverImage` is read back and previewed. 8 + 9 + **Tech Stack:** Astro + React 18, `@automattic/isolated-block-editor`, `@atproto/api`, Vitest (jsdom), raw `react-dom/client` for component tests. 10 + 11 + **Spec:** `docs/superpowers/specs/2026-06-10-cover-image-picker-design.md` 12 + 13 + --- 14 + 15 + ## File Structure 16 + 17 + **New files:** 18 + - `src/lib/media/cover.ts` — pure helpers + upload bridge: `COVER_MAX_BYTES`, `CoverUpload`, `validateCoverFile`, `coverPreviewUrl`, `uploadCoverViaMedia`. 19 + - `src/lib/media/cover.test.ts` — unit tests for the above. 20 + - `src/components/CoverImagePicker.tsx` — the picker UI (empty / uploading / set states). 21 + - `src/components/CoverImagePicker.test.tsx` — component test. 22 + 23 + **Modified files:** 24 + - `src/lib/publish/records.ts` — `DocumentRecord.coverImage` + builder. 25 + - `src/lib/publish/records.test.ts` — coverImage include/omit test. 26 + - `src/lib/publish/publisher.ts` — `PublishInput`/`UpdateInput.coverImage`, thumb resolution, `MyArticle.coverImage` read-back. 27 + - `src/lib/publish/publisher.test.ts` — cover-over-auto thumb, fallback, update, edit round-trip. 28 + - `src/components/PublishPanel.tsx` — `coverImage` prop forwarded to publish/update. 29 + - `src/components/PublishPanel.test.tsx` — forwards coverImage. 30 + - `src/components/Studio.tsx` — `cover` state, `uploadCover`, picker render, edit hydrate, clears. 31 + - `src/styles/editor-chrome.css` — `.studio__cover*` styles. 32 + 33 + --- 34 + 35 + ## Task 1: Cover constraints + pure helpers 36 + 37 + **Files:** 38 + - Create: `src/lib/media/cover.ts` 39 + - Test: `src/lib/media/cover.test.ts` 40 + 41 + - [ ] **Step 1: Write the failing test** 42 + 43 + Create `src/lib/media/cover.test.ts`: 44 + 45 + ```ts 46 + import { describe, it, expect } from 'vitest'; 47 + import { 48 + validateCoverFile, 49 + coverPreviewUrl, 50 + COVER_MAX_BYTES, 51 + } from './cover'; 52 + 53 + describe( 'COVER_MAX_BYTES', () => { 54 + it( 'matches the 1MB cover/thumb cap', () => { 55 + expect( COVER_MAX_BYTES ).toBe( 1_000_000 ); 56 + } ); 57 + } ); 58 + 59 + describe( 'validateCoverFile', () => { 60 + it( 'rejects non-image files', () => { 61 + const file = new File( [ 'x' ], 'notes.txt', { type: 'text/plain' } ); 62 + expect( validateCoverFile( file ) ).toMatch( /image/i ); 63 + } ); 64 + 65 + it( 'rejects images larger than the cap', () => { 66 + const big = new File( [ new Uint8Array( COVER_MAX_BYTES + 1 ) ], 'big.png', { 67 + type: 'image/png', 68 + } ); 69 + expect( validateCoverFile( big ) ).toMatch( /too large/i ); 70 + } ); 71 + 72 + it( 'accepts an image at the cap', () => { 73 + const ok = new File( [ new Uint8Array( COVER_MAX_BYTES ) ], 'ok.png', { 74 + type: 'image/png', 75 + } ); 76 + expect( validateCoverFile( ok ) ).toBeNull(); 77 + } ); 78 + } ); 79 + 80 + describe( 'coverPreviewUrl', () => { 81 + it( 'builds a getBlob URL from the stored ref', () => { 82 + const ref = { 83 + $type: 'blob' as const, 84 + ref: { $link: 'bafycid' }, 85 + mimeType: 'image/png', 86 + size: 1234, 87 + }; 88 + const url = coverPreviewUrl( ref, { 89 + pdsUrl: 'https://pds.example', 90 + did: 'did:plc:me', 91 + } ); 92 + expect( url ).toContain( 'com.atproto.sync.getBlob' ); 93 + expect( url ).toContain( 'did%3Aplc%3Ame' ); 94 + expect( url ).toContain( 'bafycid' ); 95 + } ); 96 + } ); 97 + ``` 98 + 99 + - [ ] **Step 2: Run test to verify it fails** 100 + 101 + Run: `npm test -- src/lib/media/cover.test.ts` 102 + Expected: FAIL — cannot resolve `./cover` (module not found). 103 + 104 + - [ ] **Step 3: Write minimal implementation** 105 + 106 + Create `src/lib/media/cover.ts`: 107 + 108 + ```ts 109 + /** 110 + * Per-article cover image helpers (design 2026-06-10). Pure functions + a thin bridge over 111 + * the existing media upload path — no `@atproto/*` or network here beyond the injected handler. 112 + */ 113 + import { 114 + BSKY_THUMB_MAX_BYTES, 115 + buildGetBlobUrl, 116 + type BlobRefJson, 117 + } from './blob'; 118 + import type { BlobRegistry, MediaUploadHandler } from './mediaUpload'; 119 + 120 + /** 121 + * The cover image blob constraint — `image/*`, ≤ 1,000,000 bytes. Shared with the Bluesky 122 + * card thumb (`BSKY_THUMB_MAX_BYTES`) and equal to the `site.standard.document.coverImage` 123 + * lexicon `maxSize`, so a valid cover is always a valid thumb. 124 + */ 125 + export const COVER_MAX_BYTES = BSKY_THUMB_MAX_BYTES; 126 + 127 + /** A chosen cover: the persistable blob ref + a URL to preview it in the picker. */ 128 + export interface CoverUpload { 129 + ref: BlobRefJson; 130 + previewUrl: string; 131 + } 132 + 133 + /** 134 + * Validate a candidate cover file before upload. Returns a human-readable error string, or 135 + * `null` when the file is an acceptable cover (`image/*` and ≤ `COVER_MAX_BYTES`). 136 + */ 137 + export function validateCoverFile( file: File ): string | null { 138 + if ( ! file.type.startsWith( 'image/' ) ) { 139 + return 'Choose an image file (PNG, JPG, or GIF).'; 140 + } 141 + if ( file.size > COVER_MAX_BYTES ) { 142 + const mb = COVER_MAX_BYTES / 1_000_000; 143 + return `That image is too large — cover images must be ${ mb } MB or smaller.`; 144 + } 145 + return null; 146 + } 147 + 148 + /** 149 + * Build a `getBlob` preview URL for an already-committed cover (the edit-load case), from its 150 + * stored blob ref. A committed cover blob is referenced by the document, so `getBlob` works 151 + * directly — no `data:` URL needed. 152 + */ 153 + export function coverPreviewUrl( 154 + ref: BlobRefJson, 155 + author: { pdsUrl: string; did: string } 156 + ): string { 157 + return buildGetBlobUrl( author.pdsUrl, author.did, ref.ref.$link ); 158 + } 159 + 160 + /** 161 + * Upload a cover file through the existing Gutenberg `mediaUpload` handler (same PDS path as 162 + * content images) and resolve with the persistable ref + preview URL. The handler records the 163 + * upload in `registry` keyed by the preview `data:` URL it returns via `onFileChange`; we read 164 + * the ref back out of `registry`. Rejects if the handler errors or returns nothing usable. 165 + */ 166 + export function uploadCoverViaMedia( 167 + file: File, 168 + mediaUpload: MediaUploadHandler, 169 + registry: BlobRegistry 170 + ): Promise< CoverUpload > { 171 + return new Promise( ( resolve, reject ) => { 172 + void mediaUpload( { 173 + filesList: [ file ], 174 + maxUploadFileSize: COVER_MAX_BYTES, 175 + onFileChange: ( files ) => { 176 + const previewUrl = files[ 0 ]?.url; 177 + const upload = previewUrl ? registry.get( previewUrl ) : undefined; 178 + if ( ! upload ) { 179 + reject( new Error( 'The upload did not return a usable image.' ) ); 180 + return; 181 + } 182 + resolve( { ref: upload.ref, previewUrl } ); 183 + }, 184 + onError: ( err ) => 185 + reject( err instanceof Error ? err : new Error( String( err ) ) ), 186 + } ); 187 + } ); 188 + } 189 + ``` 190 + 191 + - [ ] **Step 4: Run test to verify it passes** 192 + 193 + Run: `npm test -- src/lib/media/cover.test.ts` 194 + Expected: PASS (5 tests). 195 + 196 + - [ ] **Step 5: Commit** 197 + 198 + ```bash 199 + git add src/lib/media/cover.ts src/lib/media/cover.test.ts 200 + git commit --no-gpg-sign -m "Add cover image constraints and pure helpers" 201 + ``` 202 + 203 + --- 204 + 205 + ## Task 2: `uploadCoverViaMedia` bridge test 206 + 207 + **Files:** 208 + - Modify: `src/lib/media/cover.test.ts` 209 + - (Implementation already written in Task 1.) 210 + 211 + - [ ] **Step 1: Write the failing test** 212 + 213 + Append to `src/lib/media/cover.test.ts` (add `uploadCoverViaMedia`, `type CoverUpload` to the existing import from `./cover`, and add the imports + block below): 214 + 215 + ```ts 216 + // Add to the top imports: 217 + // import { ..., uploadCoverViaMedia, type CoverUpload } from './cover'; 218 + // import type { BlobRegistry, MediaUploadHandler } from './mediaUpload'; 219 + 220 + describe( 'uploadCoverViaMedia', () => { 221 + it( 'uploads via the media handler and returns the registry ref + preview URL', async () => { 222 + const registry: BlobRegistry = new Map(); 223 + const ref = { 224 + $type: 'blob' as const, 225 + ref: { $link: 'bafyc' }, 226 + mimeType: 'image/png', 227 + size: 10, 228 + }; 229 + const mediaUpload: MediaUploadHandler = async ( { filesList, onFileChange } ) => { 230 + expect( filesList.length ).toBe( 1 ); 231 + const previewUrl = 'data:image/png;base64,AAA'; 232 + registry.set( previewUrl, { ref, url: 'https://pds/getblob' } ); 233 + onFileChange( [ { url: previewUrl, alt: '' } ] ); 234 + }; 235 + const file = new File( [ 'x' ], 'c.png', { type: 'image/png' } ); 236 + const result: CoverUpload = await uploadCoverViaMedia( file, mediaUpload, registry ); 237 + expect( result.ref ).toEqual( ref ); 238 + expect( result.previewUrl ).toBe( 'data:image/png;base64,AAA' ); 239 + } ); 240 + 241 + it( 'rejects when the handler reports an error', async () => { 242 + const registry: BlobRegistry = new Map(); 243 + const mediaUpload: MediaUploadHandler = async ( { onError } ) => { 244 + onError?.( new Error( 'upload failed' ) ); 245 + }; 246 + const file = new File( [ 'x' ], 'c.png', { type: 'image/png' } ); 247 + await expect( 248 + uploadCoverViaMedia( file, mediaUpload, registry ) 249 + ).rejects.toThrow( 'upload failed' ); 250 + } ); 251 + } ); 252 + ``` 253 + 254 + - [ ] **Step 2: Run test to verify it passes immediately** 255 + 256 + Run: `npm test -- src/lib/media/cover.test.ts` 257 + Expected: PASS (now 7 tests). (Implementation was written in Task 1; this task locks in the bridge behavior.) 258 + 259 + - [ ] **Step 3: Commit** 260 + 261 + ```bash 262 + git add src/lib/media/cover.test.ts 263 + git commit --no-gpg-sign -m "Test cover upload bridge over media handler" 264 + ``` 265 + 266 + --- 267 + 268 + ## Task 3: Persist `coverImage` on the document record 269 + 270 + **Files:** 271 + - Modify: `src/lib/publish/records.ts:180-217` (`DocumentRecord` + `buildDocumentRecord`) 272 + - Test: `src/lib/publish/records.test.ts:214-247` (`buildDocumentRecord` describe) 273 + 274 + - [ ] **Step 1: Write the failing test** 275 + 276 + In `src/lib/publish/records.test.ts`, inside `describe( 'buildDocumentRecord', () => {` (after the `omits updatedAt…` test, before the closing `} );` of that describe), add: 277 + 278 + ```ts 279 + it( 'omits coverImage unless provided, includes it when set', () => { 280 + expect( 'coverImage' in buildDocumentRecord( base ) ).toBe( false ); 281 + const cover = { 282 + $type: 'blob' as const, 283 + ref: { $link: 'bafycover' }, 284 + mimeType: 'image/png', 285 + size: 4242, 286 + }; 287 + expect( 288 + buildDocumentRecord( { ...base, coverImage: cover } ).coverImage 289 + ).toEqual( cover ); 290 + } ); 291 + ``` 292 + 293 + - [ ] **Step 2: Run test to verify it fails** 294 + 295 + Run: `npm test -- src/lib/publish/records.test.ts` 296 + Expected: FAIL — `coverImage` does not exist on the input type / record (TS error or assertion failure). 297 + 298 + - [ ] **Step 3: Write minimal implementation** 299 + 300 + In `src/lib/publish/records.ts`, add `coverImage` to the `DocumentRecord` interface (after `content: GutenbergContent;`): 301 + 302 + ```ts 303 + export interface DocumentRecord { 304 + $type: 'site.standard.document'; 305 + site: string; 306 + title: string; 307 + path: string; 308 + publishedAt: string; 309 + textContent: string; 310 + content: GutenbergContent; 311 + /** Optional per-article cover (≤1MB, image/*) — the standard.site coverImage field. */ 312 + coverImage?: BlobRefJson; 313 + description?: string; 314 + bskyPostRef?: StrongRef; 315 + updatedAt?: string; 316 + } 317 + ``` 318 + 319 + Add `coverImage` to the `buildDocumentRecord` input type (after `publishedAt: string;`): 320 + 321 + ```ts 322 + publishedAt: string; 323 + /** Optional per-article cover blob ref to persist (design 2026-06-10). */ 324 + coverImage?: BlobRefJson; 325 + description?: string; 326 + ``` 327 + 328 + And spread it into the returned record (after the `content: buildContentObject( input.blocks ),` line): 329 + 330 + ```ts 331 + content: buildContentObject( input.blocks ), 332 + ...( input.coverImage ? { coverImage: input.coverImage } : {} ), 333 + ...( input.description ? { description: input.description } : {} ), 334 + ``` 335 + 336 + - [ ] **Step 4: Run test to verify it passes** 337 + 338 + Run: `npm test -- src/lib/publish/records.test.ts` 339 + Expected: PASS. 340 + 341 + - [ ] **Step 5: Commit** 342 + 343 + ```bash 344 + git add src/lib/publish/records.ts src/lib/publish/records.test.ts 345 + git commit --no-gpg-sign -m "Persist optional coverImage on the document record" 346 + ``` 347 + 348 + --- 349 + 350 + ## Task 4: Thumb resolution + publish/update wiring 351 + 352 + **Files:** 353 + - Modify: `src/lib/publish/publisher.ts` (imports, `PublishInput`, `publish`, `UpdateInput`, `updateDocument`) 354 + - Test: `src/lib/publish/publisher.test.ts` 355 + 356 + - [ ] **Step 1: Write the failing tests** 357 + 358 + In `src/lib/publish/publisher.test.ts`, inside `describe( 'publish', () => {` (after the `omits thumb when no usable image…` test), add: 359 + 360 + ```ts 361 + it( 'prefers an explicit coverImage over the first content image for the thumb', async () => { 362 + const auto = { 363 + $type: 'blob', 364 + ref: { $link: 'bafyauto' }, 365 + mimeType: 'image/jpeg', 366 + size: 5000, 367 + }; 368 + const cover = { 369 + $type: 'blob', 370 + ref: { $link: 'bafycover' }, 371 + mimeType: 'image/png', 372 + size: 9000, 373 + }; 374 + const blocksWithImage: BlockNode[] = [ 375 + { name: 'core/image', attributes: { url: 'getblob://x', skypressBlob: auto }, innerBlocks: [] }, 376 + ]; 377 + const { agent, created } = mockAgent(); 378 + await publish( 379 + agent, 380 + { did: DID, handle: HANDLE }, 381 + { title: 'Hello', blocks: blocksWithImage, coverImage: cover as never, ...TARGET } 382 + ); 383 + const post = created.find( ( c ) => c.collection === 'app.bsky.feed.post' )!.record as { 384 + embed: { external: { thumb?: { ref: { $link: string } } } }; 385 + }; 386 + expect( post.embed.external.thumb?.ref.$link ).toBe( 'bafycover' ); 387 + const doc = created.find( ( c ) => c.collection === 'site.standard.document' )!.record as { 388 + coverImage?: { ref: { $link: string } }; 389 + }; 390 + expect( doc.coverImage?.ref.$link ).toBe( 'bafycover' ); 391 + } ); 392 + 393 + it( 'falls back to the first content image when no cover is set', async () => { 394 + const auto = { 395 + $type: 'blob', 396 + ref: { $link: 'bafyauto' }, 397 + mimeType: 'image/jpeg', 398 + size: 5000, 399 + }; 400 + const blocksWithImage: BlockNode[] = [ 401 + { name: 'core/image', attributes: { url: 'getblob://x', skypressBlob: auto }, innerBlocks: [] }, 402 + ]; 403 + const { agent, created } = mockAgent(); 404 + await publish( 405 + agent, 406 + { did: DID, handle: HANDLE }, 407 + { title: 'Hello', blocks: blocksWithImage, ...TARGET } 408 + ); 409 + const post = created.find( ( c ) => c.collection === 'app.bsky.feed.post' )!.record as { 410 + embed: { external: { thumb?: { ref: { $link: string } } } }; 411 + }; 412 + expect( post.embed.external.thumb?.ref.$link ).toBe( 'bafyauto' ); 413 + const doc = created.find( ( c ) => c.collection === 'site.standard.document' )!.record as { 414 + coverImage?: unknown; 415 + }; 416 + expect( 'coverImage' in doc ).toBe( false ); 417 + } ); 418 + ``` 419 + 420 + In `describe( 'updateDocument', () => {` (after the existing test), add: 421 + 422 + ```ts 423 + it( 'writes the coverImage when provided', async () => { 424 + const cover = { 425 + $type: 'blob', 426 + ref: { $link: 'bafycover' }, 427 + mimeType: 'image/png', 428 + size: 9000, 429 + }; 430 + const { agent, put } = mockAgent(); 431 + await updateDocument( 432 + agent, 433 + { did: DID, handle: HANDLE }, 434 + { 435 + rkey: '3kdoc', 436 + siteUri: TARGET.publicationUri, 437 + publicationSlug: 'my-blog', 438 + publishedAt: '2026-06-08T00:00:00.000Z', 439 + title: 'Hello again', 440 + blocks: BLOCKS, 441 + coverImage: cover as never, 442 + } 443 + ); 444 + const doc = put.find( ( p ) => p.collection === 'site.standard.document' )!.record as { 445 + coverImage?: { ref: { $link: string } }; 446 + }; 447 + expect( doc.coverImage?.ref.$link ).toBe( 'bafycover' ); 448 + } ); 449 + ``` 450 + 451 + - [ ] **Step 2: Run test to verify it fails** 452 + 453 + Run: `npm test -- src/lib/publish/publisher.test.ts` 454 + Expected: FAIL — `coverImage` not accepted on `PublishInput`/`UpdateInput`; thumb still uses `firstImageBlobRef`. 455 + 456 + - [ ] **Step 3: Write minimal implementation** 457 + 458 + In `src/lib/publish/publisher.ts`: 459 + 460 + Update the blob import (line 10) to also bring in the JSON normalizer + type: 461 + 462 + ```ts 463 + import { firstImageBlobRef, normalizeBlobRefJson, type BlobRefJson } from '../media/blob'; 464 + ``` 465 + 466 + Add `coverImage` to `PublishInput` (after `description?: string;`): 467 + 468 + ```ts 469 + description?: string; 470 + /** Optional explicit per-article cover; overrides the first-content-image thumb. */ 471 + coverImage?: BlobRefJson; 472 + ``` 473 + 474 + In `publish()`, step 1 document create, add `coverImage` to the `buildDocumentRecord` args (after `publishedAt: now,`): 475 + 476 + ```ts 477 + publishedAt: now, 478 + coverImage: input.coverImage, 479 + description, 480 + ``` 481 + 482 + In `publish()`, step 2, change the thumb line: 483 + 484 + ```ts 485 + // Prefer the writer's explicit cover; else reuse the first uploaded image (Decision 0014). 486 + thumb: input.coverImage ?? firstImageBlobRef( input.blocks ), 487 + ``` 488 + 489 + In `publish()`, step 3 putRecord, add `coverImage` to the `buildDocumentRecord` args (after `publishedAt: now,`): 490 + 491 + ```ts 492 + publishedAt: now, 493 + coverImage: input.coverImage, 494 + description, 495 + bskyPostRef, 496 + ``` 497 + 498 + Add `coverImage` to `UpdateInput` (after `description?: string;`): 499 + 500 + ```ts 501 + description?: string; 502 + /** Optional explicit per-article cover (design 2026-06-10). */ 503 + coverImage?: BlobRefJson; 504 + ``` 505 + 506 + In `updateDocument()`, add `coverImage` to the `buildDocumentRecord` args (after `publishedAt: input.publishedAt,`): 507 + 508 + ```ts 509 + publishedAt: input.publishedAt, 510 + coverImage: input.coverImage, 511 + description, 512 + ``` 513 + 514 + - [ ] **Step 4: Run test to verify it passes** 515 + 516 + Run: `npm test -- src/lib/publish/publisher.test.ts` 517 + Expected: PASS (all existing + 3 new). 518 + 519 + - [ ] **Step 5: Commit** 520 + 521 + ```bash 522 + git add src/lib/publish/publisher.ts src/lib/publish/publisher.test.ts 523 + git commit --no-gpg-sign -m "Prefer explicit cover over first image and persist it on publish/update" 524 + ``` 525 + 526 + --- 527 + 528 + ## Task 5: Read `coverImage` back on edit-load 529 + 530 + **Files:** 531 + - Modify: `src/lib/publish/publisher.ts` (`MyArticle`, `RawDocValue`, `toMyArticle`) 532 + - Test: `src/lib/publish/publisher.test.ts` (`getMyArticle` describe) 533 + 534 + - [ ] **Step 1: Write the failing test** 535 + 536 + In `src/lib/publish/publisher.test.ts`, inside `describe( 'getMyArticle', () => {`, add this test (the `repo()` helper already exists in that describe): 537 + 538 + ```ts 539 + it( 'round-trips the stored coverImage as a normalized blob ref', async () => { 540 + const { agent } = mockAgent( { 541 + 'site.standard.publication': [ 542 + { 543 + uri: `at://${ DID }/site.standard.publication/pub1`, 544 + value: { 545 + $type: 'site.standard.publication', 546 + url: `${ SITE_BASE }/@${ HANDLE }/blog-a`, 547 + name: 'Blog A', 548 + }, 549 + }, 550 + ], 551 + 'site.standard.document': [ 552 + { 553 + uri: `at://${ DID }/site.standard.document/d1`, 554 + value: { 555 + title: 'In A', 556 + site: `at://${ DID }/site.standard.publication/pub1`, 557 + coverImage: { 558 + $type: 'blob', 559 + ref: { $link: 'bafycover' }, 560 + mimeType: 'image/png', 561 + size: 4242, 562 + }, 563 + }, 564 + }, 565 + ], 566 + } ); 567 + const article = await getMyArticle( agent, DID, 'd1' ); 568 + expect( article?.coverImage ).toEqual( { 569 + $type: 'blob', 570 + ref: { $link: 'bafycover' }, 571 + mimeType: 'image/png', 572 + size: 4242, 573 + } ); 574 + } ); 575 + ``` 576 + 577 + - [ ] **Step 2: Run test to verify it fails** 578 + 579 + Run: `npm test -- src/lib/publish/publisher.test.ts` 580 + Expected: FAIL — `article.coverImage` is `undefined` (not yet read). 581 + 582 + - [ ] **Step 3: Write minimal implementation** 583 + 584 + In `src/lib/publish/publisher.ts`: 585 + 586 + Add `coverImage` to the `MyArticle` interface (after `bskyPostRef?: StrongRef;`): 587 + 588 + ```ts 589 + bskyPostRef?: StrongRef; 590 + /** The stored per-article cover blob ref, normalized for re-preview on edit. */ 591 + coverImage?: BlobRefJson; 592 + blocks: BlockNode[]; 593 + ``` 594 + 595 + Add `coverImage` to `RawDocValue` (after `bskyPostRef?: StrongRef;`): 596 + 597 + ```ts 598 + bskyPostRef?: StrongRef; 599 + coverImage?: unknown; 600 + content?: { blocks?: BlockNode[] }; 601 + ``` 602 + 603 + In `toMyArticle`, add the normalized field to the returned object (after `bskyPostRef: value.bskyPostRef,`): 604 + 605 + ```ts 606 + bskyPostRef: value.bskyPostRef, 607 + coverImage: normalizeBlobRefJson( value.coverImage ), 608 + blocks: value.content?.blocks ?? [], 609 + ``` 610 + 611 + - [ ] **Step 4: Run test to verify it passes** 612 + 613 + Run: `npm test -- src/lib/publish/publisher.test.ts` 614 + Expected: PASS. 615 + 616 + - [ ] **Step 5: Commit** 617 + 618 + ```bash 619 + git add src/lib/publish/publisher.ts src/lib/publish/publisher.test.ts 620 + git commit --no-gpg-sign -m "Read coverImage back from the document on edit-load" 621 + ``` 622 + 623 + --- 624 + 625 + ## Task 6: Forward `coverImage` through PublishPanel 626 + 627 + **Files:** 628 + - Modify: `src/components/PublishPanel.tsx` 629 + - Test: `src/components/PublishPanel.test.tsx` 630 + 631 + - [ ] **Step 1: Write the failing test** 632 + 633 + In `src/components/PublishPanel.test.tsx`, replace the `clickUpdate` helper signature to accept an optional cover and pass it, then add an assertion. Change the helper to: 634 + 635 + ```ts 636 + async function clickUpdate( description: string, coverImage?: unknown ) { 637 + const container = document.createElement( 'div' ); 638 + document.body.appendChild( container ); 639 + const root = createRoot( container ); 640 + await act( async () => { 641 + root.render( 642 + createElement( PublishPanel, { 643 + agent: {} as Agent, 644 + identity: { did: 'did:plc:me', handle: 'me.test' }, 645 + blocks: [ { name: 'core/paragraph', attributes: { content: 'Body' }, innerBlocks: [] } ] as never, 646 + blobRegistry: new Map(), 647 + publications: [], 648 + editing: EDITING, 649 + title: 'A title', 650 + description, 651 + coverImage: coverImage as never, 652 + } ) 653 + ); 654 + } ); 655 + const button = Array.from( container.querySelectorAll( 'button' ) ).find( 656 + ( b ) => b.textContent === 'Update' 657 + )!; 658 + await act( async () => { 659 + button.dispatchEvent( new MouseEvent( 'click', { bubbles: true } ) ); 660 + } ); 661 + root.unmount(); 662 + container.remove(); 663 + } 664 + ``` 665 + 666 + Add a new test inside `describe( 'PublishPanel', () => {`: 667 + 668 + ```ts 669 + it( 'forwards the coverImage to updateDocument', async () => { 670 + const cover = { 671 + $type: 'blob', 672 + ref: { $link: 'bafycover' }, 673 + mimeType: 'image/png', 674 + size: 9000, 675 + }; 676 + await clickUpdate( 'A lede', cover ); 677 + expect( updateDocument.mock.calls[ 0 ][ 2 ] ).toMatchObject( { 678 + coverImage: cover, 679 + } ); 680 + } ); 681 + ``` 682 + 683 + - [ ] **Step 2: Run test to verify it fails** 684 + 685 + Run: `npm test -- src/components/PublishPanel.test.tsx` 686 + Expected: FAIL — `coverImage` is not a prop of `PublishPanel`, and `updateDocument` is not called with it. 687 + 688 + - [ ] **Step 3: Write minimal implementation** 689 + 690 + In `src/components/PublishPanel.tsx`: 691 + 692 + Add the import for the type (extend the existing records import on line 10): 693 + 694 + ```ts 695 + import { normalizeBlocks, type StrongRef } from '../lib/publish/records'; 696 + import type { BlobRefJson } from '../lib/media/blob'; 697 + ``` 698 + 699 + Add the prop to the `Props` interface (after `description: string;`): 700 + 701 + ```ts 702 + /** The lede / excerpt for the document + Bluesky-card description (blank → auto-derived). */ 703 + description: string; 704 + /** Optional explicit per-article cover blob ref to persist + use as the card thumb. */ 705 + coverImage?: BlobRefJson; 706 + /** Called after a successful publish/update so the parent can refresh. */ 707 + onComplete?: () => void; 708 + ``` 709 + 710 + Destructure it in the component signature (after `description,`): 711 + 712 + ```ts 713 + title, 714 + description, 715 + coverImage, 716 + onComplete, 717 + }: Props ) { 718 + ``` 719 + 720 + Pass it to `updateDocument` (in the `if ( editing )` branch, after `blocks: prepared,`): 721 + 722 + ```ts 723 + blocks: prepared, 724 + coverImage, 725 + } ); 726 + ``` 727 + 728 + Pass it to `publish` (in the `else` branch, after `publicationSlug: pub.slug,`): 729 + 730 + ```ts 731 + publicationSlug: pub.slug, 732 + coverImage, 733 + } ); 734 + ``` 735 + 736 + - [ ] **Step 4: Run test to verify it passes** 737 + 738 + Run: `npm test -- src/components/PublishPanel.test.tsx` 739 + Expected: PASS. 740 + 741 + - [ ] **Step 5: Commit** 742 + 743 + ```bash 744 + git add src/components/PublishPanel.tsx src/components/PublishPanel.test.tsx 745 + git commit --no-gpg-sign -m "Forward coverImage through PublishPanel to publish/update" 746 + ``` 747 + 748 + --- 749 + 750 + ## Task 7: `CoverImagePicker` component 751 + 752 + **Files:** 753 + - Create: `src/components/CoverImagePicker.tsx` 754 + - Test: `src/components/CoverImagePicker.test.tsx` 755 + 756 + - [ ] **Step 1: Write the failing test** 757 + 758 + Create `src/components/CoverImagePicker.test.tsx`: 759 + 760 + ```tsx 761 + import { describe, it, expect, vi, beforeEach } from 'vitest'; 762 + import { act, createElement } from 'react'; 763 + import { createRoot } from 'react-dom/client'; 764 + import type { CoverUpload } from '../lib/media/cover'; 765 + import CoverImagePicker from './CoverImagePicker'; 766 + 767 + ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; 768 + 769 + function mount( props: { 770 + cover: CoverUpload | null; 771 + onUpload: ( file: File ) => Promise< CoverUpload >; 772 + onChange: ( cover: CoverUpload | null ) => void; 773 + } ) { 774 + const container = document.createElement( 'div' ); 775 + document.body.appendChild( container ); 776 + const root = createRoot( container ); 777 + act( () => { 778 + root.render( createElement( CoverImagePicker, props ) ); 779 + } ); 780 + return { 781 + container, 782 + cleanup: () => { 783 + root.unmount(); 784 + container.remove(); 785 + }, 786 + }; 787 + } 788 + 789 + function setFiles( input: HTMLInputElement, files: File[] ) { 790 + Object.defineProperty( input, 'files', { value: files, configurable: true } ); 791 + input.dispatchEvent( new Event( 'change', { bubbles: true } ) ); 792 + } 793 + 794 + const onUpload = vi.fn< [ File ], Promise< CoverUpload > >(); 795 + const onChange = vi.fn(); 796 + 797 + beforeEach( () => { 798 + onUpload.mockReset(); 799 + onChange.mockReset(); 800 + } ); 801 + 802 + describe( 'CoverImagePicker', () => { 803 + it( 'shows the empty state with the fallback hint and the 1 MB cap', () => { 804 + const { container, cleanup } = mount( { cover: null, onUpload, onChange } ); 805 + expect( container.textContent ).toMatch( /first image in your article/i ); 806 + expect( container.textContent ).toMatch( /1 MB/i ); 807 + cleanup(); 808 + } ); 809 + 810 + it( 'renders a preview and a Remove control when a cover is set', () => { 811 + const cover: CoverUpload = { 812 + ref: { $type: 'blob', ref: { $link: 'bafyc' }, mimeType: 'image/png', size: 10 }, 813 + previewUrl: 'data:image/png;base64,AAA', 814 + }; 815 + const { container, cleanup } = mount( { cover, onUpload, onChange } ); 816 + const img = container.querySelector( 'img' )!; 817 + expect( img.getAttribute( 'src' ) ).toBe( 'data:image/png;base64,AAA' ); 818 + const remove = Array.from( container.querySelectorAll( 'button' ) ).find( 819 + ( b ) => b.textContent === 'Remove' 820 + )!; 821 + act( () => { 822 + remove.dispatchEvent( new MouseEvent( 'click', { bubbles: true } ) ); 823 + } ); 824 + expect( onChange ).toHaveBeenCalledWith( null ); 825 + cleanup(); 826 + } ); 827 + 828 + it( 'rejects an oversize file inline without uploading', () => { 829 + const { container, cleanup } = mount( { cover: null, onUpload, onChange } ); 830 + const input = container.querySelector( 'input[type="file"]' ) as HTMLInputElement; 831 + const big = new File( [ new Uint8Array( 1_000_001 ) ], 'big.png', { type: 'image/png' } ); 832 + act( () => { 833 + setFiles( input, [ big ] ); 834 + } ); 835 + expect( container.textContent ).toMatch( /too large/i ); 836 + expect( onUpload ).not.toHaveBeenCalled(); 837 + cleanup(); 838 + } ); 839 + 840 + it( 'uploads a valid file and reports the result via onChange', async () => { 841 + const result: CoverUpload = { 842 + ref: { $type: 'blob', ref: { $link: 'bafynew' }, mimeType: 'image/png', size: 20 }, 843 + previewUrl: 'data:image/png;base64,BBB', 844 + }; 845 + onUpload.mockResolvedValue( result ); 846 + const { container, cleanup } = mount( { cover: null, onUpload, onChange } ); 847 + const input = container.querySelector( 'input[type="file"]' ) as HTMLInputElement; 848 + const ok = new File( [ 'x' ], 'ok.png', { type: 'image/png' } ); 849 + await act( async () => { 850 + setFiles( input, [ ok ] ); 851 + } ); 852 + expect( onUpload ).toHaveBeenCalledTimes( 1 ); 853 + expect( onChange ).toHaveBeenCalledWith( result ); 854 + cleanup(); 855 + } ); 856 + } ); 857 + ``` 858 + 859 + - [ ] **Step 2: Run test to verify it fails** 860 + 861 + Run: `npm test -- src/components/CoverImagePicker.test.tsx` 862 + Expected: FAIL — cannot resolve `./CoverImagePicker`. 863 + 864 + - [ ] **Step 3: Write minimal implementation** 865 + 866 + Create `src/components/CoverImagePicker.tsx`: 867 + 868 + ```tsx 869 + import { useRef, useState } from 'react'; 870 + import { validateCoverFile, type CoverUpload } from '../lib/media/cover'; 871 + 872 + interface Props { 873 + /** The currently chosen cover, or `null` when none is set. */ 874 + cover: CoverUpload | null; 875 + /** Upload a chosen file to the PDS; resolves with the persistable ref + a preview URL. */ 876 + onUpload: ( file: File ) => Promise< CoverUpload >; 877 + /** Report the new cover (or `null` when removed) to the parent. */ 878 + onChange: ( cover: CoverUpload | null ) => void; 879 + } 880 + 881 + /** 882 + * The per-article cover image picker, shown below the editor (outside the block canvas). 883 + * Reuses the same PDS upload path as content images via `onUpload`. Surfaces the 1 MB cap as 884 + * helper text AND as the rejection message, and — when no cover is set — makes the 885 + * "first body image" fallback visible (design 2026-06-10). 886 + */ 887 + export default function CoverImagePicker( { cover, onUpload, onChange }: Props ) { 888 + const inputRef = useRef< HTMLInputElement >( null ); 889 + const [ uploading, setUploading ] = useState( false ); 890 + const [ error, setError ] = useState< string | null >( null ); 891 + 892 + async function handleFiles( files: FileList | null ) { 893 + const file = files?.[ 0 ]; 894 + if ( ! file ) { 895 + return; 896 + } 897 + const validationError = validateCoverFile( file ); 898 + if ( validationError ) { 899 + setError( validationError ); 900 + return; 901 + } 902 + setError( null ); 903 + setUploading( true ); 904 + try { 905 + const next = await onUpload( file ); 906 + onChange( next ); 907 + } catch ( err ) { 908 + setError( err instanceof Error ? err.message : String( err ) ); 909 + } finally { 910 + setUploading( false ); 911 + // Let the writer re-select the same file after a remove/replace. 912 + if ( inputRef.current ) { 913 + inputRef.current.value = ''; 914 + } 915 + } 916 + } 917 + 918 + return ( 919 + <section className="studio__cover" aria-label="Cover image"> 920 + <span className="studio__cover-label">Cover image</span> 921 + 922 + { cover ? ( 923 + <div className="studio__cover-preview"> 924 + <img className="studio__cover-image" src={ cover.previewUrl } alt="" /> 925 + <div className="studio__cover-actions"> 926 + <button 927 + type="button" 928 + onClick={ () => inputRef.current?.click() } 929 + disabled={ uploading } 930 + > 931 + { uploading ? 'Uploading…' : 'Replace' } 932 + </button> 933 + <button 934 + type="button" 935 + onClick={ () => { 936 + setError( null ); 937 + onChange( null ); 938 + } } 939 + disabled={ uploading } 940 + > 941 + Remove 942 + </button> 943 + </div> 944 + </div> 945 + ) : ( 946 + <div className="studio__cover-empty"> 947 + <button 948 + type="button" 949 + onClick={ () => inputRef.current?.click() } 950 + disabled={ uploading } 951 + > 952 + { uploading ? 'Uploading…' : 'Upload cover image' } 953 + </button> 954 + <p className="studio__cover-hint"> 955 + No cover set — the first image in your article will be used. PNG, JPG, 956 + or GIF, max 1 MB. 957 + </p> 958 + </div> 959 + ) } 960 + 961 + <input 962 + ref={ inputRef } 963 + className="studio__cover-input" 964 + type="file" 965 + accept="image/*" 966 + hidden 967 + onChange={ ( event ) => void handleFiles( event.target.files ) } 968 + /> 969 + 970 + { error && ( 971 + <p className="studio__cover-error" role="alert"> 972 + { error } 973 + </p> 974 + ) } 975 + </section> 976 + ); 977 + } 978 + ``` 979 + 980 + - [ ] **Step 4: Run test to verify it passes** 981 + 982 + Run: `npm test -- src/components/CoverImagePicker.test.tsx` 983 + Expected: PASS (4 tests). 984 + 985 + - [ ] **Step 5: Commit** 986 + 987 + ```bash 988 + git add src/components/CoverImagePicker.tsx src/components/CoverImagePicker.test.tsx 989 + git commit --no-gpg-sign -m "Add CoverImagePicker component" 990 + ``` 991 + 992 + --- 993 + 994 + ## Task 8: Wire the picker into Studio 995 + 996 + **Files:** 997 + - Modify: `src/components/Studio.tsx` 998 + 999 + This is integration wiring (no new unit test — covered by the component + publisher tests and the final smoke check). Make each edit exactly. 1000 + 1001 + - [ ] **Step 1: Add imports** 1002 + 1003 + In `src/components/Studio.tsx`, after the existing `import PublishPanel from './PublishPanel';` line, add: 1004 + 1005 + ```ts 1006 + import CoverImagePicker from './CoverImagePicker'; 1007 + ``` 1008 + 1009 + And after the `import { createMediaUpload, ... } from '../lib/media/mediaUpload';` line, add: 1010 + 1011 + ```ts 1012 + import { 1013 + coverPreviewUrl, 1014 + uploadCoverViaMedia, 1015 + type CoverUpload, 1016 + } from '../lib/media/cover'; 1017 + ``` 1018 + 1019 + - [ ] **Step 2: Add cover state** 1020 + 1021 + After the `const [ editing, setEditing ] = useState< MyArticle | null >( null );` line, add: 1022 + 1023 + ```ts 1024 + const [ cover, setCover ] = useState< CoverUpload | null >( null ); 1025 + ``` 1026 + 1027 + - [ ] **Step 3: Hydrate cover on edit-load** 1028 + 1029 + In the `getMyArticle( agent, did, rkey ).then( ( article ) => {` callback, inside the `if ( article ) {` block, after `setExcerpt( article.description ?? '' );`, add: 1030 + 1031 + ```ts 1032 + if ( article.coverImage && pdsUrl ) { 1033 + setCover( { 1034 + ref: article.coverImage, 1035 + previewUrl: coverPreviewUrl( article.coverImage, { pdsUrl, did } ), 1036 + } ); 1037 + } 1038 + ``` 1039 + 1040 + Then add `pdsUrl` to that effect's dependency array — change `}, [ agent, did ] );` (the one closing the edit-load effect) to: 1041 + 1042 + ```ts 1043 + }, [ agent, did, pdsUrl ] ); 1044 + ``` 1045 + 1046 + - [ ] **Step 4: Build the cover uploader** 1047 + 1048 + After the `const mediaUpload = useMemo( ... );` block, add: 1049 + 1050 + ```ts 1051 + const uploadCover = useMemo( () => { 1052 + if ( ! mediaUpload ) { 1053 + return undefined; 1054 + } 1055 + return ( file: File ) => uploadCoverViaMedia( file, mediaUpload, registry ); 1056 + }, [ mediaUpload, registry ] ); 1057 + ``` 1058 + 1059 + - [ ] **Step 5: Clear cover on "New article" and after a new publish** 1060 + 1061 + In the `startNew` function, after `setBlocks( [] );`, add: 1062 + 1063 + ```ts 1064 + setCover( null ); 1065 + ``` 1066 + 1067 + In the `PublishPanel` `onComplete` callback, inside the `if ( ! editing ) {` block, after `setBlocks( [] );`, add: 1068 + 1069 + ```ts 1070 + setCover( null ); 1071 + ``` 1072 + 1073 + - [ ] **Step 6: Pass coverImage to PublishPanel and render the picker** 1074 + 1075 + Add the `coverImage` prop to `<PublishPanel ... />` — after the `description={ excerpt }` line, add: 1076 + 1077 + ```tsx 1078 + description={ excerpt } 1079 + coverImage={ cover?.ref } 1080 + ``` 1081 + 1082 + After the `<SkyEditor ... />` element (before the closing `</div>` of the keyed wrapper), add: 1083 + 1084 + ```tsx 1085 + { uploadCover && ( 1086 + <CoverImagePicker 1087 + cover={ cover } 1088 + onUpload={ uploadCover } 1089 + onChange={ setCover } 1090 + /> 1091 + ) } 1092 + ``` 1093 + 1094 + - [ ] **Step 7: Type-check + full test run** 1095 + 1096 + Run: `npm run check` 1097 + Expected: no type errors. 1098 + 1099 + Run: `npm test` 1100 + Expected: all suites PASS. 1101 + 1102 + - [ ] **Step 8: Commit** 1103 + 1104 + ```bash 1105 + git add src/components/Studio.tsx 1106 + git commit --no-gpg-sign -m "Wire cover image picker into the Studio editor" 1107 + ``` 1108 + 1109 + --- 1110 + 1111 + ## Task 9: Style the cover picker strip 1112 + 1113 + **Files:** 1114 + - Modify: `src/styles/editor-chrome.css` 1115 + 1116 + - [ ] **Step 1: Add styles** 1117 + 1118 + Append to `src/styles/editor-chrome.css` (after the editor-surface rules), matching the existing centered-column convention used by `.studio__lede` and `.skypress-editor`: 1119 + 1120 + ```css 1121 + /* Cover image picker — a labeled strip below the editor, in the shared content 1122 + column. Distinct from the editor canvas; the chosen image is the article's 1123 + explicit cover (else the first body image is used). */ 1124 + .studio__cover { 1125 + max-width: var(--studio-measure); 1126 + margin: 0 auto 3rem; 1127 + padding: 0 var(--studio-gutter); 1128 + } 1129 + .studio__cover-label { 1130 + display: block; 1131 + margin-bottom: 0.5rem; 1132 + font-size: 0.85rem; 1133 + font-weight: 600; 1134 + color: var(--ink); 1135 + } 1136 + .studio__cover-empty { 1137 + display: flex; 1138 + flex-direction: column; 1139 + gap: 0.4rem; 1140 + align-items: flex-start; 1141 + } 1142 + .studio__cover-hint { 1143 + margin: 0; 1144 + font-size: 0.8rem; 1145 + color: var(--muted); 1146 + } 1147 + .studio__cover-preview { 1148 + display: flex; 1149 + flex-direction: column; 1150 + gap: 0.6rem; 1151 + align-items: flex-start; 1152 + } 1153 + .studio__cover-image { 1154 + max-width: 100%; 1155 + max-height: 18rem; 1156 + border: 1px solid var(--line-strong); 1157 + border-radius: var(--radius); 1158 + } 1159 + .studio__cover-actions { 1160 + display: flex; 1161 + gap: 0.6rem; 1162 + } 1163 + .studio__cover-error { 1164 + margin: 0.5rem 0 0; 1165 + font-size: 0.8rem; 1166 + color: var(--danger, #c0392b); 1167 + } 1168 + ``` 1169 + 1170 + - [ ] **Step 2: Verify the build still compiles** 1171 + 1172 + Run: `npm run build` 1173 + Expected: build succeeds (CSS is global; no scoping errors). 1174 + 1175 + - [ ] **Step 3: Commit** 1176 + 1177 + ```bash 1178 + git add src/styles/editor-chrome.css 1179 + git commit --no-gpg-sign -m "Style the cover image picker strip" 1180 + ``` 1181 + 1182 + --- 1183 + 1184 + ## Task 10: Decision record + final verification 1185 + 1186 + **Files:** 1187 + - Create: `docs/decisions/NNNN-cover-image-picker.md` (next number after the highest existing) 1188 + 1189 + - [ ] **Step 1: Determine the next decision number** 1190 + 1191 + Run: `ls docs/decisions | sort | tail -3` 1192 + Use the next integer after the highest `NNNN-*.md` (e.g. if `0015-*` is highest, create `0016-cover-image-picker.md`). 1193 + 1194 + - [ ] **Step 2: Write the decision record** 1195 + 1196 + Create `docs/decisions/<NNNN>-cover-image-picker.md`: 1197 + 1198 + ```markdown 1199 + # <NNNN>. Per-article cover image picker 1200 + 1201 + **Status:** Accepted 1202 + **Date:** 2026-06-10 1203 + 1204 + ## Context 1205 + 1206 + Until now the Bluesky card thumb was auto-derived from the first usable content 1207 + image (`firstImageBlobRef`, Decision 0014), and articles had no explicit cover. 1208 + The standard `site.standard.document` lexicon already defines an optional 1209 + `coverImage` blob field (`image/*`, ≤1MB) that was never populated (deferred in 1210 + Decisions 0006 and 0014). 1211 + 1212 + ## Decision 1213 + 1214 + Add a per-article cover image picker as a strip BELOW the block editor (outside 1215 + the editor canvas). It reuses the existing `mediaUpload` + blob-registry path 1216 + (no second isolated-block-editor instance) and writes the chosen blob to 1217 + `site.standard.document.coverImage`. 1218 + 1219 + Thumb resolution becomes: explicit `coverImage` → `firstImageBlobRef(blocks)` → 1220 + omit. The auto-pick is now the fallback, so nothing regresses for articles 1221 + without an explicit cover. 1222 + 1223 + The 1MB cap (equal to `BSKY_THUMB_MAX_BYTES` and the lexicon `maxSize`) is 1224 + surfaced in the picker UI as helper text and as the oversize rejection message. 1225 + 1226 + ## Consequences 1227 + 1228 + - Covers persist on the document, survive edits (re-previewed via `getBlob`), and 1229 + become available to future reading-page/OG/RSS surfaces (still unwired — 1230 + separate follow-up; see the 2026-06-09 OG design which deferred this field). 1231 + - This partially supersedes Decision 0014: the first-image pick is retained only 1232 + as the fallback when no explicit cover is set. 1233 + - Consuming `coverImage` on the reading page / OG tags remains out of scope. 1234 + ``` 1235 + 1236 + - [ ] **Step 3: Full verification** 1237 + 1238 + Run: `npm run check` 1239 + Expected: no type errors. 1240 + 1241 + Run: `npm test` 1242 + Expected: all suites PASS. 1243 + 1244 + Run: `npm run build` 1245 + Expected: build succeeds. 1246 + 1247 + - [ ] **Step 4: Manual browser smoke (per global CLAUDE.md — Chrome DevTools MCP)** 1248 + 1249 + Serve dev on `http://127.0.0.1:<port>` (atproto loopback requirement), sign in, and confirm: 1250 + - The "Cover image" strip appears below the editor with the "max 1 MB" hint. 1251 + - Uploading a small image shows a preview; "Remove" returns to the empty state with the fallback hint. 1252 + - An oversize image shows the "too large" error and does not upload. 1253 + - Publishing an article with a cover sets the document `coverImage` and the post thumb to that image; publishing without one falls back to the first body image. 1254 + - Editing a published article with a cover re-shows it in the picker. 1255 + 1256 + - [ ] **Step 5: Commit** 1257 + 1258 + ```bash 1259 + git add docs/decisions/*-cover-image-picker.md 1260 + git commit --no-gpg-sign -m "Record cover image picker decision" 1261 + ``` 1262 + 1263 + --- 1264 + 1265 + ## Self-Review notes (for the implementer) 1266 + 1267 + - **Spec coverage:** data model (Task 3), thumb fallback (Task 4), edit reload (Task 5), picker UI + max-size + remove hint (Task 7), wiring (Task 8), styling (Task 9). All spec sections map to a task. 1268 + - **Type consistency:** `CoverUpload` (cover.ts) is the single shape passed picker→Studio; only `cover.ref` (a `BlobRefJson`) crosses into PublishPanel/publisher. `coverImage` is the consistent field name across `DocumentRecord`, `PublishInput`, `UpdateInput`, `MyArticle`, and the `PublishPanel` prop. 1269 + - **Fallback chain** is written exactly once, in `publish()`: `input.coverImage ?? firstImageBlobRef( input.blocks )`. 1270 + ```