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.

Design: editor lede/excerpt field + description fallback

+166
+166
docs/superpowers/specs/2026-06-09-editor-lede-excerpt-design.md
··· 1 + # Editor lede / excerpt field + description fallback — design 2 + 3 + - **Date:** 2026-06-09 4 + - **Scope:** The editor writing surface (`Studio.tsx`), the publish flow 5 + (`PublishPanel.tsx` → `publisher.ts` → `records.ts`), and the single-post reader's 6 + `og:description` (`[author]/[slug]/[rkey].astro`). 7 + - **Related:** `docs/superpowers/specs/2026-06-09-bsky-post-thumb-design.md` (the *image* 8 + half of the same bare-card bug — tracked separately, not in scope here). 9 + 10 + ## Problem 11 + 12 + A published article's companion Bluesky post (and its standard.site link card) shows an 13 + **empty description** whenever the writer didn't supply one. The editor has **no field** 14 + for a description/excerpt at all, so `PublishPanel` never passes `description` to 15 + `publish()`. The result: `site.standard.document.description` is omitted, and the 16 + `app.bsky.embed.external.description` is written as `""` — a bare card. 17 + 18 + Observed live: `at://did:plc:e6grv4cc6jxdhnjo22szkayq/app.bsky.feed.post/3mnur47pyns27` 19 + has `"description": ""` and no `thumb`. 20 + 21 + ## What the lexicons say (research, 2026-06-09) 22 + 23 + - **Bluesky** (`app.bsky.embed.external#external.description`): plain `string`, **no 24 + `maxLength` / `maxGraphemes`**. The bsky composer doesn't let users edit it — it 25 + auto-fills from the link's `og:description`. The card UI truncates visually (~2–3 lines). 26 + - **standard.site** (`site.standard.document.description`): optional `string`, 27 + **`maxLength: 30000` / `maxGraphemes: 3000`**, guidance *"A brief description or excerpt 28 + from the document."* The 3000-grapheme cap is a hard ceiling, not a style recommendation. 29 + - **House convention** is the real signal: SkyPress already derives a ~200-char excerpt 30 + everywhere it needs one — the single-post reader uses `textContent.slice(0, 200)` 31 + (`[rkey].astro:89`) and the RSS feed uses a "200-char `textContent` fallback" (sp11). 32 + 33 + Neither spec recommends a display length; "brief" + the ~200-char house convention guide 34 + the field design. 35 + 36 + ## What's already wired (no change needed) 37 + 38 + The single-post page already prefers the document's stored description for its social meta: 39 + 40 + ```js 41 + // [author]/[slug]/[rkey].astro:89 42 + description = doc.description || textContent.slice( 0, 200 ); 43 + // → buildMetaTags({ description, … }) → og:description + twitter:description 44 + ``` 45 + 46 + So **`og:description` uses the stored `description` automatically** the moment we write the 47 + lede into the PDS record. The two requirements ("og:description uses the field" + "auto- 48 + generate when empty") therefore collapse into one real change: **do the empty fallback at 49 + publish time, persisted into the record**, rather than leaving it to the reader's ad-hoc 50 + slice. 51 + 52 + ## Decision 53 + 54 + 1. Add a visible **lede** field to the editor. 55 + 2. Persist it as the document's `description` and the Bluesky embed's `description`. 56 + 3. When it's empty, **derive an excerpt from the post text at publish time** and write that 57 + derived value into *both* records (so the stored record — not just the rendered meta — 58 + carries a description). One shared helper, ~200 chars, used by the publisher and the 59 + reader's legacy fallback so the convention never diverges. 60 + 61 + ## Components 62 + 63 + ### 1. `src/lib/publish/excerpt.ts` — `deriveExcerpt` (pure, new) 64 + 65 + ```ts 66 + /** 67 + * A brief plain-text excerpt for a document/card description. Collapses whitespace, cuts on 68 + * a word boundary at or before `maxChars`, and appends an ellipsis when truncated. Returns 69 + * '' for empty/whitespace input. Pure + dependency-free — safe for the server reader and the 70 + * browser publisher alike (read path must not pull browser-only deps, AGENTS.md §3). 71 + */ 72 + export function deriveExcerpt( text: string, maxChars = 200 ): string; 73 + ``` 74 + 75 + - `200` matches the house convention. No `@wordpress/*` / network — unit-testable. 76 + 77 + ### 2. `src/lib/publish/publisher.ts` — publish-time fallback (single source of truth) 78 + 79 + `publish()` already computes `const textContent = blocksToText( input.blocks )`. Add: 80 + 81 + ```ts 82 + const description = input.description?.trim() || deriveExcerpt( textContent ); 83 + ``` 84 + 85 + Pass this one `description` into **both** `buildDocumentRecord` and `buildBskyPost`. Same 86 + treatment in `updateDocument()` (it recomputes `textContent`), so an edited article's stored 87 + `description` stays populated even if the writer clears the lede. (Update never creates a 88 + post — the original card keeps its old description, as the panel already warns.) 89 + 90 + `records.ts` is unchanged: `buildDocumentRecord` still conditionally includes `description` 91 + (now near-always truthy), and `buildBskyPost` still writes `description ?? ''` (now near- 92 + always non-empty). When `textContent` is itself empty (e.g. image-only post), the derived 93 + excerpt is `''` and behaviour matches today. 94 + 95 + ### 3. `src/components/PublishPanel.tsx` — pass the lede through 96 + 97 + - New prop `description: string` (the raw lede from Studio). Passed verbatim into 98 + `publish()` / `updateDocument()` as `description` — the publisher owns trim + fallback, so 99 + the panel stays dumb. No validation gate (an empty lede publishes fine, as today). 100 + 101 + ### 4. `src/components/Studio.tsx` — the lede field 102 + 103 + - New state `const [ excerpt, setExcerpt ] = useState( '' )`. 104 + - Hydrate on edit-load: `setExcerpt( article.description ?? '' )` (currently 105 + `MyArticle.description` is fetched but dropped). 106 + - Reset to `''` in `startNew()` and in the new-publish branch of `onComplete`. 107 + - Render a `<textarea class="studio__lede">` **between** the title `<input>` and `<SkyEditor>`. 108 + Pass `excerpt` to `<PublishPanel description={ excerpt } />`. 109 + - `maxLength={ 3000 }` as a cheap guard against standard.site's `maxGraphemes: 3000` ceiling 110 + (a publish-time lexicon rejection otherwise). A lede never approaches this; the soft hint 111 + keeps writers near ~200. 112 + 113 + ### 5. `src/components/Studio.tsx` (cont.) / `editor-chrome.css` — styling + auto-grow 114 + 115 + - `.studio__lede`: same column/width/gutter as `.studio__title`; `--font-display`, 116 + borderless, transparent background; smaller (`~1.2rem`), **italic**, `--muted` colour; 117 + `resize: none; overflow: hidden`. Placeholder *"Add a lede… (defaults to the opening of 118 + your post)"* makes the auto-fill discoverable. 119 + - Auto-grow: a tiny ref + handler setting `el.style.height = 'auto'; el.style.height = 120 + el.scrollHeight + 'px'` on input and on hydrate. 121 + - A soft, non-blocking hint shown when length exceeds ~200 (e.g. *"Long ledes get truncated 122 + on the Bluesky card."*). `aria`-friendly; never disables publish. 123 + 124 + ### 6. `[author]/[slug]/[rkey].astro` — unify the reader's fallback 125 + 126 + Replace `textContent.slice( 0, 200 )` (line 89) with `deriveExcerpt( textContent )`, so the 127 + legacy-document fallback (docs published before this feature carry no `description`) matches 128 + the new publish-time logic — one excerpt convention, word-boundary + ellipsis everywhere. 129 + 130 + ## Data flow 131 + 132 + ``` 133 + Studio.excerpt ──prop──▶ PublishPanel.description ──▶ publish()/updateDocument() 134 + 135 + description = excerpt.trim() || deriveExcerpt(textContent) 136 + ├──▶ buildDocumentRecord → site.standard.document.description 137 + └──▶ buildBskyPost → app.bsky.embed.external.description 138 + 139 + reader [rkey].astro: doc.description (||deriveExcerpt for legacy) ──▶ og:/twitter:description 140 + ``` 141 + 142 + ## Testing (TDD — failing test first) 143 + 144 + - **`excerpt.test.ts` / `deriveExcerpt`:** empty/whitespace → `''`; short text returned 145 + whole, no ellipsis; long text cut on a word boundary `≤ maxChars` with a trailing ellipsis; 146 + whitespace collapsed; custom `maxChars` honoured. 147 + - **`publisher.test.ts` / `publish`:** empty `description` → both the document record and the 148 + created post carry `deriveExcerpt(textContent)`; non-empty `description` → both carry the 149 + trimmed lede verbatim; empty text + empty lede → no document `description`, post 150 + `description: ''`. 151 + - **`publisher.test.ts` / `updateDocument`:** empty `description` → updated record carries the 152 + derived excerpt. 153 + - **Reader meta** (`_[rkey].meta.test.ts`, colocated + underscore-prefixed per AGENTS.md §8): 154 + `og:description` uses `doc.description` when present, else `deriveExcerpt(textContent)`. 155 + - `records.test.ts` / `meta.test.ts`: unchanged contracts still pass. 156 + 157 + ## Out of scope / follow-ups 158 + 159 + - The **image/thumb** half of the bare-card bug (1.9 MB content image > the 1 MB 160 + `embed.external.thumb` cap) — see `2026-06-09-bsky-post-thumb-design.md`. 161 + - **Live preview** of the auto-derived excerpt in the editor — the placeholder sets the 162 + expectation; a live preview would need Studio to hold live `textContent`. 163 + - **RSS** already has its own 200-char `textContent` fallback (sp11). Left as-is; a candidate 164 + to re-point at `deriveExcerpt` later for full convention unity. 165 + - No SkyPress lexicon change: `description` already exists on `site.standard.document` and 166 + `app.bsky.embed.external`.