···11+# Editor lede / excerpt field + description fallback — design
22+33+- **Date:** 2026-06-09
44+- **Scope:** The editor writing surface (`Studio.tsx`), the publish flow
55+ (`PublishPanel.tsx` → `publisher.ts` → `records.ts`), and the single-post reader's
66+ `og:description` (`[author]/[slug]/[rkey].astro`).
77+- **Related:** `docs/superpowers/specs/2026-06-09-bsky-post-thumb-design.md` (the *image*
88+ half of the same bare-card bug — tracked separately, not in scope here).
99+1010+## Problem
1111+1212+A published article's companion Bluesky post (and its standard.site link card) shows an
1313+**empty description** whenever the writer didn't supply one. The editor has **no field**
1414+for a description/excerpt at all, so `PublishPanel` never passes `description` to
1515+`publish()`. The result: `site.standard.document.description` is omitted, and the
1616+`app.bsky.embed.external.description` is written as `""` — a bare card.
1717+1818+Observed live: `at://did:plc:e6grv4cc6jxdhnjo22szkayq/app.bsky.feed.post/3mnur47pyns27`
1919+has `"description": ""` and no `thumb`.
2020+2121+## What the lexicons say (research, 2026-06-09)
2222+2323+- **Bluesky** (`app.bsky.embed.external#external.description`): plain `string`, **no
2424+ `maxLength` / `maxGraphemes`**. The bsky composer doesn't let users edit it — it
2525+ auto-fills from the link's `og:description`. The card UI truncates visually (~2–3 lines).
2626+- **standard.site** (`site.standard.document.description`): optional `string`,
2727+ **`maxLength: 30000` / `maxGraphemes: 3000`**, guidance *"A brief description or excerpt
2828+ from the document."* The 3000-grapheme cap is a hard ceiling, not a style recommendation.
2929+- **House convention** is the real signal: SkyPress already derives a ~200-char excerpt
3030+ everywhere it needs one — the single-post reader uses `textContent.slice(0, 200)`
3131+ (`[rkey].astro:89`) and the RSS feed uses a "200-char `textContent` fallback" (sp11).
3232+3333+Neither spec recommends a display length; "brief" + the ~200-char house convention guide
3434+the field design.
3535+3636+## What's already wired (no change needed)
3737+3838+The single-post page already prefers the document's stored description for its social meta:
3939+4040+```js
4141+// [author]/[slug]/[rkey].astro:89
4242+description = doc.description || textContent.slice( 0, 200 );
4343+// → buildMetaTags({ description, … }) → og:description + twitter:description
4444+```
4545+4646+So **`og:description` uses the stored `description` automatically** the moment we write the
4747+lede into the PDS record. The two requirements ("og:description uses the field" + "auto-
4848+generate when empty") therefore collapse into one real change: **do the empty fallback at
4949+publish time, persisted into the record**, rather than leaving it to the reader's ad-hoc
5050+slice.
5151+5252+## Decision
5353+5454+1. Add a visible **lede** field to the editor.
5555+2. Persist it as the document's `description` and the Bluesky embed's `description`.
5656+3. When it's empty, **derive an excerpt from the post text at publish time** and write that
5757+ derived value into *both* records (so the stored record — not just the rendered meta —
5858+ carries a description). One shared helper, ~200 chars, used by the publisher and the
5959+ reader's legacy fallback so the convention never diverges.
6060+6161+## Components
6262+6363+### 1. `src/lib/publish/excerpt.ts` — `deriveExcerpt` (pure, new)
6464+6565+```ts
6666+/**
6767+ * A brief plain-text excerpt for a document/card description. Collapses whitespace, cuts on
6868+ * a word boundary at or before `maxChars`, and appends an ellipsis when truncated. Returns
6969+ * '' for empty/whitespace input. Pure + dependency-free — safe for the server reader and the
7070+ * browser publisher alike (read path must not pull browser-only deps, AGENTS.md §3).
7171+ */
7272+export function deriveExcerpt( text: string, maxChars = 200 ): string;
7373+```
7474+7575+- `200` matches the house convention. No `@wordpress/*` / network — unit-testable.
7676+7777+### 2. `src/lib/publish/publisher.ts` — publish-time fallback (single source of truth)
7878+7979+`publish()` already computes `const textContent = blocksToText( input.blocks )`. Add:
8080+8181+```ts
8282+const description = input.description?.trim() || deriveExcerpt( textContent );
8383+```
8484+8585+Pass this one `description` into **both** `buildDocumentRecord` and `buildBskyPost`. Same
8686+treatment in `updateDocument()` (it recomputes `textContent`), so an edited article's stored
8787+`description` stays populated even if the writer clears the lede. (Update never creates a
8888+post — the original card keeps its old description, as the panel already warns.)
8989+9090+`records.ts` is unchanged: `buildDocumentRecord` still conditionally includes `description`
9191+(now near-always truthy), and `buildBskyPost` still writes `description ?? ''` (now near-
9292+always non-empty). When `textContent` is itself empty (e.g. image-only post), the derived
9393+excerpt is `''` and behaviour matches today.
9494+9595+### 3. `src/components/PublishPanel.tsx` — pass the lede through
9696+9797+- New prop `description: string` (the raw lede from Studio). Passed verbatim into
9898+ `publish()` / `updateDocument()` as `description` — the publisher owns trim + fallback, so
9999+ the panel stays dumb. No validation gate (an empty lede publishes fine, as today).
100100+101101+### 4. `src/components/Studio.tsx` — the lede field
102102+103103+- New state `const [ excerpt, setExcerpt ] = useState( '' )`.
104104+- Hydrate on edit-load: `setExcerpt( article.description ?? '' )` (currently
105105+ `MyArticle.description` is fetched but dropped).
106106+- Reset to `''` in `startNew()` and in the new-publish branch of `onComplete`.
107107+- Render a `<textarea class="studio__lede">` **between** the title `<input>` and `<SkyEditor>`.
108108+ Pass `excerpt` to `<PublishPanel description={ excerpt } />`.
109109+- `maxLength={ 3000 }` as a cheap guard against standard.site's `maxGraphemes: 3000` ceiling
110110+ (a publish-time lexicon rejection otherwise). A lede never approaches this; the soft hint
111111+ keeps writers near ~200.
112112+113113+### 5. `src/components/Studio.tsx` (cont.) / `editor-chrome.css` — styling + auto-grow
114114+115115+- `.studio__lede`: same column/width/gutter as `.studio__title`; `--font-display`,
116116+ borderless, transparent background; smaller (`~1.2rem`), **italic**, `--muted` colour;
117117+ `resize: none; overflow: hidden`. Placeholder *"Add a lede… (defaults to the opening of
118118+ your post)"* makes the auto-fill discoverable.
119119+- Auto-grow: a tiny ref + handler setting `el.style.height = 'auto'; el.style.height =
120120+ el.scrollHeight + 'px'` on input and on hydrate.
121121+- A soft, non-blocking hint shown when length exceeds ~200 (e.g. *"Long ledes get truncated
122122+ on the Bluesky card."*). `aria`-friendly; never disables publish.
123123+124124+### 6. `[author]/[slug]/[rkey].astro` — unify the reader's fallback
125125+126126+Replace `textContent.slice( 0, 200 )` (line 89) with `deriveExcerpt( textContent )`, so the
127127+legacy-document fallback (docs published before this feature carry no `description`) matches
128128+the new publish-time logic — one excerpt convention, word-boundary + ellipsis everywhere.
129129+130130+## Data flow
131131+132132+```
133133+Studio.excerpt ──prop──▶ PublishPanel.description ──▶ publish()/updateDocument()
134134+ │
135135+ description = excerpt.trim() || deriveExcerpt(textContent)
136136+ ├──▶ buildDocumentRecord → site.standard.document.description
137137+ └──▶ buildBskyPost → app.bsky.embed.external.description
138138+ │
139139+reader [rkey].astro: doc.description (||deriveExcerpt for legacy) ──▶ og:/twitter:description
140140+```
141141+142142+## Testing (TDD — failing test first)
143143+144144+- **`excerpt.test.ts` / `deriveExcerpt`:** empty/whitespace → `''`; short text returned
145145+ whole, no ellipsis; long text cut on a word boundary `≤ maxChars` with a trailing ellipsis;
146146+ whitespace collapsed; custom `maxChars` honoured.
147147+- **`publisher.test.ts` / `publish`:** empty `description` → both the document record and the
148148+ created post carry `deriveExcerpt(textContent)`; non-empty `description` → both carry the
149149+ trimmed lede verbatim; empty text + empty lede → no document `description`, post
150150+ `description: ''`.
151151+- **`publisher.test.ts` / `updateDocument`:** empty `description` → updated record carries the
152152+ derived excerpt.
153153+- **Reader meta** (`_[rkey].meta.test.ts`, colocated + underscore-prefixed per AGENTS.md §8):
154154+ `og:description` uses `doc.description` when present, else `deriveExcerpt(textContent)`.
155155+- `records.test.ts` / `meta.test.ts`: unchanged contracts still pass.
156156+157157+## Out of scope / follow-ups
158158+159159+- The **image/thumb** half of the bare-card bug (1.9 MB content image > the 1 MB
160160+ `embed.external.thumb` cap) — see `2026-06-09-bsky-post-thumb-design.md`.
161161+- **Live preview** of the auto-derived excerpt in the editor — the placeholder sets the
162162+ expectation; a live preview would need Studio to hold live `textContent`.
163163+- **RSS** already has its own 200-char `textContent` fallback (sp11). Left as-is; a candidate
164164+ to re-point at `deriveExcerpt` later for full convention unity.
165165+- No SkyPress lexicon change: `description` already exists on `site.standard.document` and
166166+ `app.bsky.embed.external`.