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 design spec for per-article cover image picker

+131
+131
docs/superpowers/specs/2026-06-10-cover-image-picker-design.md
··· 1 + # Per-article cover image picker 2 + 3 + **Date:** 2026-06-10 4 + **Status:** Approved (brainstorm) 5 + 6 + ## Goal 7 + 8 + Give creators an explicit way to choose the image that represents an article, 9 + instead of silently using the first image found in the body. The picker lives 10 + **outside** the block editor — a distinct strip directly below it — and writes 11 + the chosen image to the standard `site.standard.document.coverImage` blob field. 12 + 13 + ## Current state 14 + 15 + - `firstImageBlobRef(blocks)` (`src/lib/media/blob.ts:45`) walks the block tree 16 + depth-first and returns the first usable uploaded `core/image` blob ref 17 + (`image/*`, `0 < size ≤ BSKY_THUMB_MAX_BYTES` = 1,000,000 bytes). 18 + - It is used in **exactly one place**: the companion Bluesky post's 19 + `embed.external.thumb` (`src/lib/publish/publisher.ts:115`). It is **not** 20 + stored on the document record. 21 + - `site.standard.document` (external *standard.site* lexicon) **already defines** 22 + an optional `coverImage` blob field (`accept: image/*`, `maxSize: 1000000`). 23 + Persisting a cover is therefore lexicon-clean — a standard field, not a 24 + SkyPress addition, no `-v2`. 25 + - Image uploads go through `createMediaUpload` (`src/lib/media/mediaUpload.ts:57`), 26 + which enforces `maxUploadFileSize`, uploads to the writer's PDS, and records 27 + `{ ref, url }` in the blob `registry` keyed by the preview `data:` URL. 28 + - The editor layout (`Studio.tsx`) stacks: PublishPanel → title → lede → SkyEditor, 29 + all within a centered `--studio-measure` column (`editor-chrome.css`). 30 + 31 + ## Decisions locked during brainstorm 32 + 33 + 1. **Scope:** per-article cover image (not a publication-level banner). 34 + 2. **Fallback:** purely additive override. No explicit cover → fall back to 35 + `firstImageBlobRef()`. Nothing regresses for existing articles. 36 + 3. **Storage:** persist to `site.standard.document.coverImage`. It survives 37 + edits and unlocks future reading-page/OG use (consuming it elsewhere stays a 38 + separate follow-up). 39 + 4. **Build approach:** a lightweight custom React picker reusing the existing 40 + `createMediaUpload` handler + blob `registry` — **not** a second 41 + `IsolatedBlockEditor` instance. 42 + 5. **Max-size in UI:** surface the 1 MB cap as static helper text *and* as the 43 + rejection message for oversize files. 44 + 6. **Remove behavior (option b):** removing a cover shows a visible hint that the 45 + first body image will be used as the fallback, honoring the "don't surprise 46 + users" guardrail. 47 + 48 + ## Approach 49 + 50 + ### 1. Data model & state 51 + 52 + - New `Studio.tsx` state: `cover: { ref: BlobRefJson; previewUrl: string } | null`. 53 + - `PublishInput` (`src/lib/publish/publisher.ts`) gains `coverImage?: BlobRefJson`. 54 + - `DocumentRecord` + `buildDocumentRecord` (`src/lib/publish/records.ts:180`) gain 55 + optional `coverImage`, spread-included only when set (same pattern as 56 + `description` / `bskyPostRef`). 57 + - Threaded through **both** publish writes (initial create and the `bskyPostRef` 58 + update) and through the edit/`updateDocument` path. 59 + 60 + ### 2. Thumb resolution 61 + 62 + `buildBskyPost` thumb argument becomes: 63 + 64 + ``` 65 + input.coverImage ?? firstImageBlobRef(input.blocks) 66 + ``` 67 + 68 + Today's behavior is the tail of this chain, so articles without an explicit 69 + cover behave exactly as before. 70 + 71 + ### 3. `CoverImagePicker` component 72 + 73 + - Rendered under `<SkyEditor>` in `Studio.tsx`, inside the same centered 74 + `--studio-measure` column, styled as a labeled "Cover image" strip distinct 75 + from the editor canvas. 76 + - States: 77 + - **empty:** drop-zone + "Upload cover image" button + helper text 78 + "PNG/JPG/GIF, max 1 MB". 79 + - **uploading:** in-progress affordance. 80 + - **set:** preview thumbnail + "Replace" + "Remove". 81 + - Upload reuses `createMediaUpload` (same PDS path as content images). On success 82 + it reads the resulting `BlobRefJson` from the `registry` by the returned 83 + preview key. 84 + - Client-side size guard at 1 MB via the handler's existing `maxUploadFileSize` 85 + check; oversize files are rejected with an inline message naming the limit. 86 + - **Remove** clears `cover` and shows the fallback hint ("No cover set — the 87 + first image in your article will be used"). 88 + 89 + ### 4. Edit reload 90 + 91 + - `getMyArticle` already returns the document. Read `coverImage` back and build a 92 + `getBlob` preview URL from the author DID + CID (same construction 93 + `resolveBlobImageUrls` uses) so an existing cover renders on edit. A 94 + committed cover blob is already referenced, so `getBlob` works directly (no 95 + `data:` URL needed for the edit-load case). 96 + 97 + ## Components / units 98 + 99 + | Unit | Responsibility | Depends on | 100 + |------|----------------|------------| 101 + | `CoverImagePicker` (component) | UI: empty/uploading/set states, size error, remove hint | `createMediaUpload`, blob `registry`, pure helpers below | 102 + | `validateCoverFile` (pure helper) | enforce `image/*` + ≤ 1 MB; return error string or ok | `BSKY_THUMB_MAX_BYTES` | 103 + | `coverPreviewUrl` (pure helper) | `BlobRefJson` + DID + PDS → `getBlob` URL for edit-load preview | existing getBlob URL builder | 104 + | `Studio.tsx` | owns `cover` state, passes `cover.ref` to PublishPanel | `CoverImagePicker`, `PublishPanel` | 105 + | `buildDocumentRecord` / `DocumentRecord` | persist `coverImage` | `BlobRefJson` | 106 + | `buildBskyPost` / `publisher` | cover-over-auto thumb resolution | `firstImageBlobRef` | 107 + 108 + ## Testing (TDD — failing tests first) 109 + 110 + - `records.test.ts`: `coverImage` included when provided, omitted when not. 111 + - `publisher.test.ts`: thumb prefers explicit cover; falls back to 112 + `firstImageBlobRef` when no cover; edit preserves the cover. 113 + - `validateCoverFile`: 1 MB boundary (just under accepted, at/over rejected), 114 + non-image rejected. 115 + - `coverPreviewUrl`: builds the expected `getBlob` URL from a ref. 116 + - `CoverImagePicker` component test: empty → upload → preview → remove flow, and 117 + oversize rejection surfaces the limit message. 118 + 119 + ## Out of scope (YAGNI) 120 + 121 + - Image cropping / OG-canvas generation. 122 + - Wiring `coverImage` into the reading-page hero, OG/Twitter tags, or RSS — the 123 + field gets *populated*; consuming it stays a separate follow-up (now possible). 124 + - Publication-level banner/cover (distinct from `publication.icon`). 125 + 126 + ## Follow-ups unlocked 127 + 128 + - Reading-page hero image from `coverImage`. 129 + - OG/Twitter `og:image` chain: `document.coverImage → publication.icon → 130 + /og-default.png` (extends the 2026-06-09 OG design, which deferred the 131 + per-article field).