···11+# Per-article cover image picker
22+33+**Date:** 2026-06-10
44+**Status:** Approved (brainstorm)
55+66+## Goal
77+88+Give creators an explicit way to choose the image that represents an article,
99+instead of silently using the first image found in the body. The picker lives
1010+**outside** the block editor — a distinct strip directly below it — and writes
1111+the chosen image to the standard `site.standard.document.coverImage` blob field.
1212+1313+## Current state
1414+1515+- `firstImageBlobRef(blocks)` (`src/lib/media/blob.ts:45`) walks the block tree
1616+ depth-first and returns the first usable uploaded `core/image` blob ref
1717+ (`image/*`, `0 < size ≤ BSKY_THUMB_MAX_BYTES` = 1,000,000 bytes).
1818+- It is used in **exactly one place**: the companion Bluesky post's
1919+ `embed.external.thumb` (`src/lib/publish/publisher.ts:115`). It is **not**
2020+ stored on the document record.
2121+- `site.standard.document` (external *standard.site* lexicon) **already defines**
2222+ an optional `coverImage` blob field (`accept: image/*`, `maxSize: 1000000`).
2323+ Persisting a cover is therefore lexicon-clean — a standard field, not a
2424+ SkyPress addition, no `-v2`.
2525+- Image uploads go through `createMediaUpload` (`src/lib/media/mediaUpload.ts:57`),
2626+ which enforces `maxUploadFileSize`, uploads to the writer's PDS, and records
2727+ `{ ref, url }` in the blob `registry` keyed by the preview `data:` URL.
2828+- The editor layout (`Studio.tsx`) stacks: PublishPanel → title → lede → SkyEditor,
2929+ all within a centered `--studio-measure` column (`editor-chrome.css`).
3030+3131+## Decisions locked during brainstorm
3232+3333+1. **Scope:** per-article cover image (not a publication-level banner).
3434+2. **Fallback:** purely additive override. No explicit cover → fall back to
3535+ `firstImageBlobRef()`. Nothing regresses for existing articles.
3636+3. **Storage:** persist to `site.standard.document.coverImage`. It survives
3737+ edits and unlocks future reading-page/OG use (consuming it elsewhere stays a
3838+ separate follow-up).
3939+4. **Build approach:** a lightweight custom React picker reusing the existing
4040+ `createMediaUpload` handler + blob `registry` — **not** a second
4141+ `IsolatedBlockEditor` instance.
4242+5. **Max-size in UI:** surface the 1 MB cap as static helper text *and* as the
4343+ rejection message for oversize files.
4444+6. **Remove behavior (option b):** removing a cover shows a visible hint that the
4545+ first body image will be used as the fallback, honoring the "don't surprise
4646+ users" guardrail.
4747+4848+## Approach
4949+5050+### 1. Data model & state
5151+5252+- New `Studio.tsx` state: `cover: { ref: BlobRefJson; previewUrl: string } | null`.
5353+- `PublishInput` (`src/lib/publish/publisher.ts`) gains `coverImage?: BlobRefJson`.
5454+- `DocumentRecord` + `buildDocumentRecord` (`src/lib/publish/records.ts:180`) gain
5555+ optional `coverImage`, spread-included only when set (same pattern as
5656+ `description` / `bskyPostRef`).
5757+- Threaded through **both** publish writes (initial create and the `bskyPostRef`
5858+ update) and through the edit/`updateDocument` path.
5959+6060+### 2. Thumb resolution
6161+6262+`buildBskyPost` thumb argument becomes:
6363+6464+```
6565+input.coverImage ?? firstImageBlobRef(input.blocks)
6666+```
6767+6868+Today's behavior is the tail of this chain, so articles without an explicit
6969+cover behave exactly as before.
7070+7171+### 3. `CoverImagePicker` component
7272+7373+- Rendered under `<SkyEditor>` in `Studio.tsx`, inside the same centered
7474+ `--studio-measure` column, styled as a labeled "Cover image" strip distinct
7575+ from the editor canvas.
7676+- States:
7777+ - **empty:** drop-zone + "Upload cover image" button + helper text
7878+ "PNG/JPG/GIF, max 1 MB".
7979+ - **uploading:** in-progress affordance.
8080+ - **set:** preview thumbnail + "Replace" + "Remove".
8181+- Upload reuses `createMediaUpload` (same PDS path as content images). On success
8282+ it reads the resulting `BlobRefJson` from the `registry` by the returned
8383+ preview key.
8484+- Client-side size guard at 1 MB via the handler's existing `maxUploadFileSize`
8585+ check; oversize files are rejected with an inline message naming the limit.
8686+- **Remove** clears `cover` and shows the fallback hint ("No cover set — the
8787+ first image in your article will be used").
8888+8989+### 4. Edit reload
9090+9191+- `getMyArticle` already returns the document. Read `coverImage` back and build a
9292+ `getBlob` preview URL from the author DID + CID (same construction
9393+ `resolveBlobImageUrls` uses) so an existing cover renders on edit. A
9494+ committed cover blob is already referenced, so `getBlob` works directly (no
9595+ `data:` URL needed for the edit-load case).
9696+9797+## Components / units
9898+9999+| Unit | Responsibility | Depends on |
100100+|------|----------------|------------|
101101+| `CoverImagePicker` (component) | UI: empty/uploading/set states, size error, remove hint | `createMediaUpload`, blob `registry`, pure helpers below |
102102+| `validateCoverFile` (pure helper) | enforce `image/*` + ≤ 1 MB; return error string or ok | `BSKY_THUMB_MAX_BYTES` |
103103+| `coverPreviewUrl` (pure helper) | `BlobRefJson` + DID + PDS → `getBlob` URL for edit-load preview | existing getBlob URL builder |
104104+| `Studio.tsx` | owns `cover` state, passes `cover.ref` to PublishPanel | `CoverImagePicker`, `PublishPanel` |
105105+| `buildDocumentRecord` / `DocumentRecord` | persist `coverImage` | `BlobRefJson` |
106106+| `buildBskyPost` / `publisher` | cover-over-auto thumb resolution | `firstImageBlobRef` |
107107+108108+## Testing (TDD — failing tests first)
109109+110110+- `records.test.ts`: `coverImage` included when provided, omitted when not.
111111+- `publisher.test.ts`: thumb prefers explicit cover; falls back to
112112+ `firstImageBlobRef` when no cover; edit preserves the cover.
113113+- `validateCoverFile`: 1 MB boundary (just under accepted, at/over rejected),
114114+ non-image rejected.
115115+- `coverPreviewUrl`: builds the expected `getBlob` URL from a ref.
116116+- `CoverImagePicker` component test: empty → upload → preview → remove flow, and
117117+ oversize rejection surfaces the limit message.
118118+119119+## Out of scope (YAGNI)
120120+121121+- Image cropping / OG-canvas generation.
122122+- Wiring `coverImage` into the reading-page hero, OG/Twitter tags, or RSS — the
123123+ field gets *populated*; consuming it stays a separate follow-up (now possible).
124124+- Publication-level banner/cover (distinct from `publication.icon`).
125125+126126+## Follow-ups unlocked
127127+128128+- Reading-page hero image from `coverImage`.
129129+- OG/Twitter `og:image` chain: `document.coverImage → publication.icon →
130130+ /og-default.png` (extends the 2026-06-09 OG design, which deferred the
131131+ per-article field).