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.

Plan: editor lede/excerpt field + description fallback

+691
+691
docs/superpowers/plans/2026-06-09-editor-lede-excerpt.md
··· 1 + # Editor lede / excerpt field + description fallback — 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:** Give writers a visible lede field in the editor, persist it as the document + 6 + Bluesky-card `description`, and when it's blank derive a brief excerpt from the post text at 7 + publish time so the card and `og:description` are never empty. 8 + 9 + **Architecture:** A single pure helper (`deriveExcerpt`) is the one excerpt convention, 10 + shared by the browser publisher and the server reader. The publisher computes 11 + `description = lede.trim() || deriveExcerpt(textContent)` once and writes it to both records. 12 + The editor gains a controlled `<textarea>` whose value rides the existing 13 + `PublishPanel.description → publish()` path. `og:description` already prefers the stored 14 + `description`, so no separate meta change is needed. 15 + 16 + **Tech Stack:** TypeScript, Astro (SSR reader), React 18 (editor island), vitest + jsdom, 17 + `@atproto/api`. Design spec: `docs/superpowers/specs/2026-06-09-editor-lede-excerpt-design.md`. 18 + 19 + **Conventions to match:** 20 + - Tabs for indentation; spaces inside `( … )` and `[ … ]` (see existing files). 21 + - Tests: `import { describe, expect, it } from 'vitest'`. Component tests use raw 22 + `react-dom` (`renderToStaticMarkup`, or `createRoot` + `act` with 23 + `IS_REACT_ACT_ENVIRONMENT = true`) — there is no `@testing-library/react`. 24 + - `.astro` pages can't be unit-rendered; they're pinned with source-grep tests (see 25 + `_[rkey].meta.test.ts`). Colocated `src/pages/**` tests MUST be underscore-prefixed 26 + (AGENTS.md §8). 27 + - Run a single test file with: `npx vitest run <path>`. 28 + 29 + --- 30 + 31 + ### Task 1: `deriveExcerpt` pure helper 32 + 33 + **Files:** 34 + - Create: `src/lib/publish/excerpt.ts` 35 + - Test: `src/lib/publish/excerpt.test.ts` 36 + 37 + - [ ] **Step 1: Write the failing test** 38 + 39 + Create `src/lib/publish/excerpt.test.ts`: 40 + 41 + ```ts 42 + import { describe, expect, it } from 'vitest'; 43 + import { deriveExcerpt } from './excerpt'; 44 + 45 + describe( 'deriveExcerpt', () => { 46 + it( 'returns empty string for empty or whitespace-only input', () => { 47 + expect( deriveExcerpt( '' ) ).toBe( '' ); 48 + expect( deriveExcerpt( ' \n\t ' ) ).toBe( '' ); 49 + } ); 50 + 51 + it( 'returns short text unchanged, with no ellipsis', () => { 52 + expect( deriveExcerpt( 'A short lede.' ) ).toBe( 'A short lede.' ); 53 + } ); 54 + 55 + it( 'collapses internal whitespace and newlines to single spaces', () => { 56 + expect( deriveExcerpt( 'one\n\ntwo three' ) ).toBe( 'one two three' ); 57 + } ); 58 + 59 + it( 'truncates long text on a word boundary with a trailing ellipsis', () => { 60 + const long = 'word '.repeat( 100 ).trim(); // 499 chars, all word boundaries 61 + const result = deriveExcerpt( long ); 62 + expect( result.endsWith( '…' ) ).toBe( true ); 63 + // Body (sans ellipsis) stays within the limit and never splits a word. 64 + const body = result.slice( 0, -1 ); 65 + expect( body.length ).toBeLessThanOrEqual( 200 ); 66 + expect( body.endsWith( 'word' ) ).toBe( true ); 67 + expect( body.endsWith( ' ' ) ).toBe( false ); 68 + } ); 69 + 70 + it( 'honours a custom maxChars and cuts at the last space within it', () => { 71 + expect( deriveExcerpt( 'alpha beta gamma', 10 ) ).toBe( 'alpha…' ); 72 + } ); 73 + } ); 74 + ``` 75 + 76 + - [ ] **Step 2: Run test to verify it fails** 77 + 78 + Run: `npx vitest run src/lib/publish/excerpt.test.ts` 79 + Expected: FAIL — `Failed to resolve import "./excerpt"` / `deriveExcerpt is not a function`. 80 + 81 + - [ ] **Step 3: Write minimal implementation** 82 + 83 + Create `src/lib/publish/excerpt.ts`: 84 + 85 + ```ts 86 + /** 87 + * A brief plain-text excerpt for a document/card description (the og:description fallback). 88 + * Collapses runs of whitespace to single spaces, cuts on a word boundary at or before 89 + * `maxChars`, and appends an ellipsis when it had to truncate. Returns '' for empty or 90 + * whitespace-only input. Pure + dependency-free (no `@wordpress/*`, no network) so it is 91 + * safe in BOTH the server reader and the browser publisher (AGENTS.md §3). 92 + */ 93 + export function deriveExcerpt( text: string, maxChars = 200 ): string { 94 + const normalized = text.replace( /\s+/g, ' ' ).trim(); 95 + if ( normalized.length <= maxChars ) { 96 + return normalized; 97 + } 98 + const slice = normalized.slice( 0, maxChars ); 99 + const lastSpace = slice.lastIndexOf( ' ' ); 100 + const cut = lastSpace > 0 ? slice.slice( 0, lastSpace ) : slice; 101 + return `${ cut.trimEnd() }…`; 102 + } 103 + ``` 104 + 105 + - [ ] **Step 4: Run test to verify it passes** 106 + 107 + Run: `npx vitest run src/lib/publish/excerpt.test.ts` 108 + Expected: PASS (5 tests). 109 + 110 + - [ ] **Step 5: Commit** 111 + 112 + ```bash 113 + git add src/lib/publish/excerpt.ts src/lib/publish/excerpt.test.ts 114 + git commit --no-gpg-sign -m "Add deriveExcerpt helper for description fallback" 115 + ``` 116 + 117 + --- 118 + 119 + ### Task 2: Publish-time description fallback 120 + 121 + **Files:** 122 + - Modify: `src/lib/publish/publisher.ts` (imports; `publish()` ~line 77 + the three 123 + `description:` sites; `updateDocument()` ~line 182) 124 + - Test: `src/lib/publish/publisher.test.ts` (add cases) 125 + 126 + - [ ] **Step 1: Write the failing tests** 127 + 128 + Add to the top imports of `src/lib/publish/publisher.test.ts` (alongside the existing 129 + imports): 130 + 131 + ```ts 132 + import { deriveExcerpt } from './excerpt'; 133 + import { blocksToText } from '../blocks/render'; 134 + ``` 135 + 136 + Add a new `describe` block to `src/lib/publish/publisher.test.ts`: 137 + 138 + ```ts 139 + describe( 'publish — description fallback', () => { 140 + it( 'derives the description from the post text when none is provided', async () => { 141 + const { agent, created } = mockAgent(); 142 + await publish( agent, { did: DID, handle: HANDLE }, { title: 'Hello', blocks: BLOCKS, ...TARGET } ); 143 + 144 + const expected = deriveExcerpt( blocksToText( BLOCKS ) ); 145 + expect( expected.length ).toBeGreaterThan( 0 ); 146 + const doc = created.find( ( c ) => c.collection === 'site.standard.document' )!.record; 147 + const post = created.find( ( c ) => c.collection === 'app.bsky.feed.post' )!.record as { 148 + embed: { external: { description: string } }; 149 + }; 150 + expect( doc.description ).toBe( expected ); 151 + expect( post.embed.external.description ).toBe( expected ); 152 + } ); 153 + 154 + it( 'uses the provided description verbatim (trimmed) in both records', async () => { 155 + const { agent, created } = mockAgent(); 156 + await publish( 157 + agent, 158 + { did: DID, handle: HANDLE }, 159 + { title: 'Hello', blocks: BLOCKS, description: ' Hand-written lede ', ...TARGET } 160 + ); 161 + const doc = created.find( ( c ) => c.collection === 'site.standard.document' )!.record; 162 + const post = created.find( ( c ) => c.collection === 'app.bsky.feed.post' )!.record as { 163 + embed: { external: { description: string } }; 164 + }; 165 + expect( doc.description ).toBe( 'Hand-written lede' ); 166 + expect( post.embed.external.description ).toBe( 'Hand-written lede' ); 167 + } ); 168 + 169 + it( 'omits the document description (and posts empty) when there is no text and no lede', async () => { 170 + const emptyBlocks: BlockNode[] = [ 171 + { name: 'core/paragraph', attributes: { content: '' }, innerBlocks: [] }, 172 + ]; 173 + const { agent, created } = mockAgent(); 174 + await publish( agent, { did: DID, handle: HANDLE }, { title: 'Hello', blocks: emptyBlocks, ...TARGET } ); 175 + const doc = created.find( ( c ) => c.collection === 'site.standard.document' )!.record; 176 + const post = created.find( ( c ) => c.collection === 'app.bsky.feed.post' )!.record as { 177 + embed: { external: Record< string, unknown > }; 178 + }; 179 + expect( 'description' in doc ).toBe( false ); 180 + expect( post.embed.external.description ).toBe( '' ); 181 + } ); 182 + 183 + it( 'updateDocument derives the description from the post text when none is provided', async () => { 184 + const { agent, put } = mockAgent(); 185 + await updateDocument( 186 + agent, 187 + { did: DID, handle: HANDLE }, 188 + { 189 + rkey: '3kdoc', 190 + siteUri: TARGET.publicationUri, 191 + publicationSlug: 'my-blog', 192 + publishedAt: '2026-06-08T00:00:00.000Z', 193 + title: 'Hello again', 194 + blocks: BLOCKS, 195 + } 196 + ); 197 + expect( put[ 0 ].record.description ).toBe( deriveExcerpt( blocksToText( BLOCKS ) ) ); 198 + } ); 199 + } ); 200 + ``` 201 + 202 + - [ ] **Step 2: Run tests to verify they fail** 203 + 204 + Run: `npx vitest run src/lib/publish/publisher.test.ts` 205 + Expected: FAIL — the new cases see `doc.description === undefined` / `post...description === ''` 206 + because `publisher.ts` still forwards `input.description` raw (no fallback yet). 207 + 208 + - [ ] **Step 3: Implement the fallback** 209 + 210 + In `src/lib/publish/publisher.ts`, add the import near the existing relative imports 211 + (after the `blocksToText` import line): 212 + 213 + ```ts 214 + import { deriveExcerpt } from './excerpt'; 215 + ``` 216 + 217 + In `publish()`, right after the `const textContent = blocksToText( input.blocks );` line 218 + (~line 77), add: 219 + 220 + ```ts 221 + // When the writer leaves the lede blank, derive a brief excerpt from the body so the 222 + // document description AND the Bluesky card description are never empty (the og:image's 223 + // text twin). Written into the record, not just the rendered meta. 224 + const description = input.description?.trim() || deriveExcerpt( textContent ); 225 + ``` 226 + 227 + Then replace all three `description: input.description,` occurrences inside `publish()` (the 228 + two `buildDocumentRecord` calls and the one `buildBskyPost` call) with: 229 + 230 + ```ts 231 + description, 232 + ``` 233 + 234 + In `updateDocument()`, replace the inline `textContent: blocksToText( input.blocks ),` in 235 + the `buildDocumentRecord` call with a lifted const + derived description. Add, just after 236 + `const articleUrl = canonicalArticleUrl( … );`: 237 + 238 + ```ts 239 + const textContent = blocksToText( input.blocks ); 240 + const description = input.description?.trim() || deriveExcerpt( textContent ); 241 + ``` 242 + 243 + …and in that function's `buildDocumentRecord` call, set `textContent,` (the lifted const) 244 + and replace `description: input.description,` with `description,`. 245 + 246 + - [ ] **Step 4: Run tests to verify they pass** 247 + 248 + Run: `npx vitest run src/lib/publish/publisher.test.ts` 249 + Expected: PASS — the four new cases plus all pre-existing `publish`/`updateDocument` cases. 250 + 251 + - [ ] **Step 5: Commit** 252 + 253 + ```bash 254 + git add src/lib/publish/publisher.ts src/lib/publish/publisher.test.ts 255 + git commit --no-gpg-sign -m "Derive a description excerpt at publish time when the lede is blank" 256 + ``` 257 + 258 + --- 259 + 260 + ### Task 3: Unify the reader's og:description fallback 261 + 262 + **Files:** 263 + - Modify: `src/pages/[author]/[slug]/[rkey].astro` (import; line 89) 264 + - Test: `src/pages/[author]/[slug]/_[rkey].meta.test.ts` (add source-pin cases) 265 + 266 + - [ ] **Step 1: Write the failing test** 267 + 268 + Add these cases inside the existing `describe( 'document page Open Graph wiring', … )` in 269 + `src/pages/[author]/[slug]/_[rkey].meta.test.ts`: 270 + 271 + ```ts 272 + it( 'derives the og:description fallback via deriveExcerpt (shared with publish)', () => { 273 + expect( page ).toMatch( 274 + /import\s*\{\s*deriveExcerpt\s*\}\s*from\s*'[^']*lib\/publish\/excerpt'/ 275 + ); 276 + expect( page ).toMatch( /doc\.description\s*\|\|\s*deriveExcerpt\(\s*textContent\s*\)/ ); 277 + } ); 278 + 279 + it( 'no longer hand-rolls the textContent.slice fallback', () => { 280 + expect( page ).not.toMatch( /textContent\.slice\(\s*0,\s*200\s*\)/ ); 281 + } ); 282 + ``` 283 + 284 + - [ ] **Step 2: Run test to verify it fails** 285 + 286 + Run: `npx vitest run "src/pages/[author]/[slug]/_[rkey].meta.test.ts"` 287 + Expected: FAIL — the page still imports nothing from `excerpt` and still uses 288 + `textContent.slice( 0, 200 )`. 289 + 290 + - [ ] **Step 3: Implement the change** 291 + 292 + In `src/pages/[author]/[slug]/[rkey].astro`, add to the frontmatter imports (next to the 293 + other `lib/publish` import): 294 + 295 + ```ts 296 + import { deriveExcerpt } from '../../../lib/publish/excerpt'; 297 + ``` 298 + 299 + Replace line 89: 300 + 301 + ```ts 302 + description = doc.description || textContent.slice( 0, 200 ); 303 + ``` 304 + 305 + with: 306 + 307 + ```ts 308 + description = doc.description || deriveExcerpt( textContent ); 309 + ``` 310 + 311 + - [ ] **Step 4: Run test to verify it passes** 312 + 313 + Run: `npx vitest run "src/pages/[author]/[slug]/_[rkey].meta.test.ts"` 314 + Expected: PASS. 315 + 316 + - [ ] **Step 5: Commit** 317 + 318 + ```bash 319 + git add "src/pages/[author]/[slug]/[rkey].astro" "src/pages/[author]/[slug]/_[rkey].meta.test.ts" 320 + git commit --no-gpg-sign -m "Use deriveExcerpt for the reader's og:description fallback" 321 + ``` 322 + 323 + --- 324 + 325 + ### Task 4: Thread the lede through PublishPanel 326 + 327 + **Files:** 328 + - Modify: `src/components/PublishPanel.tsx` (Props interface; destructure; `publish()` + 329 + `updateDocument()` calls) 330 + - Test: `src/components/PublishPanel.test.tsx` (create) 331 + 332 + - [ ] **Step 1: Write the failing test** 333 + 334 + Create `src/components/PublishPanel.test.tsx` (tests the editing path — it calls `run()` 335 + directly with no confirm dialog, so it's the cleanest forwarding seam): 336 + 337 + ```tsx 338 + import { describe, it, expect, vi, beforeEach } from 'vitest'; 339 + import { act, createElement } from 'react'; 340 + import { createRoot } from 'react-dom/client'; 341 + import type { Agent } from '@atproto/api'; 342 + 343 + ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; 344 + 345 + // Mock the publish layer so we can assert what PublishPanel forwards. 346 + const updateDocument = vi.fn( async () => ( { documentUri: 'at://x', articleUrl: 'https://x' } ) ); 347 + const publish = vi.fn( async () => ( { 348 + publicationUri: 'at://p', 349 + documentUri: 'at://d', 350 + postUri: 'at://post', 351 + articleUrl: 'https://x', 352 + } ) ); 353 + vi.mock( '../lib/publish/publisher', () => ( { publish, updateDocument } ) ); 354 + 355 + import PublishPanel from './PublishPanel'; 356 + 357 + const EDITING = { 358 + rkey: '3kdoc', 359 + siteUri: 'at://did:plc:me/site.standard.publication/pub1', 360 + siteSlug: 'my-blog', 361 + publishedAt: '2026-06-08T00:00:00.000Z', 362 + }; 363 + 364 + beforeEach( () => { 365 + updateDocument.mockClear(); 366 + publish.mockClear(); 367 + } ); 368 + 369 + async function clickUpdate( description: string ) { 370 + const container = document.createElement( 'div' ); 371 + document.body.appendChild( container ); 372 + const root = createRoot( container ); 373 + await act( async () => { 374 + root.render( 375 + createElement( PublishPanel, { 376 + agent: {} as Agent, 377 + identity: { did: 'did:plc:me', handle: 'me.test' }, 378 + blocks: [ { name: 'core/paragraph', attributes: { content: 'Body' }, innerBlocks: [] } ] as never, 379 + blobRegistry: new Map(), 380 + publications: [], 381 + editing: EDITING, 382 + title: 'A title', 383 + description, 384 + } ) 385 + ); 386 + } ); 387 + const button = Array.from( container.querySelectorAll( 'button' ) ).find( 388 + ( b ) => b.textContent === 'Update' 389 + )!; 390 + await act( async () => { 391 + button.dispatchEvent( new MouseEvent( 'click', { bubbles: true } ) ); 392 + } ); 393 + root.unmount(); 394 + container.remove(); 395 + } 396 + 397 + describe( 'PublishPanel', () => { 398 + it( 'forwards the lede to updateDocument as description', async () => { 399 + await clickUpdate( 'My hand-written lede' ); 400 + expect( updateDocument ).toHaveBeenCalledTimes( 1 ); 401 + expect( updateDocument.mock.calls[ 0 ][ 2 ] ).toMatchObject( { 402 + description: 'My hand-written lede', 403 + } ); 404 + } ); 405 + } ); 406 + ``` 407 + 408 + - [ ] **Step 2: Run test to verify it fails** 409 + 410 + Run: `npx vitest run src/components/PublishPanel.test.tsx` 411 + Expected: FAIL — `description` is not a prop yet (TypeScript error / `undefined` forwarded), 412 + so the `toMatchObject` assertion fails. 413 + 414 + - [ ] **Step 3: Implement the prop** 415 + 416 + In `src/components/PublishPanel.tsx`: 417 + 418 + Add to the `Props` interface (after the `title: string;` field): 419 + 420 + ```ts 421 + /** The lede / excerpt for the document + Bluesky-card description (blank → auto-derived). */ 422 + description: string; 423 + ``` 424 + 425 + Add `description` to the destructured props in the function signature (after `title,`): 426 + 427 + ```ts 428 + description, 429 + ``` 430 + 431 + In the `editing` branch of `run()`, add `description` to the `updateDocument` call object 432 + (after `title: title.trim(),`): 433 + 434 + ```ts 435 + description, 436 + ``` 437 + 438 + In the non-editing branch, add `description` to the `publish` call object (after 439 + `title: title.trim(),`): 440 + 441 + ```ts 442 + description, 443 + ``` 444 + 445 + - [ ] **Step 4: Run test to verify it passes** 446 + 447 + Run: `npx vitest run src/components/PublishPanel.test.tsx` 448 + Expected: PASS. 449 + 450 + - [ ] **Step 5: Commit** 451 + 452 + ```bash 453 + git add src/components/PublishPanel.tsx src/components/PublishPanel.test.tsx 454 + git commit --no-gpg-sign -m "Forward the lede through PublishPanel as description" 455 + ``` 456 + 457 + --- 458 + 459 + ### Task 5: The lede field in the editor (Studio + CSS) 460 + 461 + **Files:** 462 + - Modify: `src/components/Studio.tsx` (new `excerpt` state; hydrate on edit-load; reset in 463 + `startNew` + `onComplete`; auto-grow effect; render the `<textarea>`; pass 464 + `description={ excerpt }`) 465 + - Modify: `src/styles/editor-chrome.css` (`.studio__lede`, `.studio__lede-hint`, editor 466 + adjacency) 467 + 468 + > No unit test: `Studio` imports `SkyEditor`, which imports `@automattic/isolated-block-editor` 469 + > (browser-only, touches `window`/registries at import — unrenderable in jsdom, AGENTS.md §3). 470 + > The behavioural contract is already locked by Task 2 (publisher) and Task 4 (PublishPanel 471 + > forwarding). This task is verified by `npm run check` + the full suite + a manual smoke. 472 + 473 + - [ ] **Step 1: Add the `excerpt` state** 474 + 475 + In `src/components/Studio.tsx`, after `const [ title, setTitle ] = useState( '' );`: 476 + 477 + ```tsx 478 + const [ excerpt, setExcerpt ] = useState( '' ); 479 + ``` 480 + 481 + - [ ] **Step 2: Hydrate on edit-load** 482 + 483 + In the `getMyArticle( … ).then( ( article ) => { … } )` block, right after 484 + `setTitle( article.title );`: 485 + 486 + ```tsx 487 + setExcerpt( article.description ?? '' ); 488 + ``` 489 + 490 + - [ ] **Step 3: Reset on new article / after a new publish** 491 + 492 + In `startNew`, after `setTitle( '' );`: 493 + 494 + ```tsx 495 + setExcerpt( '' ); 496 + ``` 497 + 498 + In the `onComplete` handler's `if ( ! editing ) { … }` block, after `setTitle( '' );`: 499 + 500 + ```tsx 501 + setExcerpt( '' ); 502 + ``` 503 + 504 + - [ ] **Step 4: Add the auto-grow ref + effect** 505 + 506 + Add a ref near the other refs (after `const registry = useRef< BlobRegistry >( … ).current;`): 507 + 508 + ```tsx 509 + const ledeRef = useRef< HTMLTextAreaElement >( null ); 510 + ``` 511 + 512 + Add an effect (next to the other `useEffect`s) so the textarea grows to fit its content, 513 + including when hydrated from an edit-load: 514 + 515 + ```tsx 516 + // Grow the lede textarea to fit its content (and on hydrate from an edit-load). 517 + useEffect( () => { 518 + const el = ledeRef.current; 519 + if ( ! el ) { 520 + return; 521 + } 522 + el.style.height = 'auto'; 523 + el.style.height = `${ el.scrollHeight }px`; 524 + }, [ excerpt ] ); 525 + ``` 526 + 527 + (`useEffect` and `useRef` are already imported on line 1.) 528 + 529 + - [ ] **Step 5: Render the field and pass it to PublishPanel** 530 + 531 + Add `description={ excerpt }` to the `<PublishPanel … />` props (after `title={ title }`): 532 + 533 + ```tsx 534 + description={ excerpt } 535 + ``` 536 + 537 + Insert the lede field between the title `<input … />` and `<SkyEditor … />`: 538 + 539 + ```tsx 540 + <textarea 541 + ref={ ledeRef } 542 + className="studio__lede" 543 + rows={ 1 } 544 + maxLength={ 3000 } 545 + placeholder="Add a lede… (defaults to the opening of your post)" 546 + aria-label="Lede" 547 + value={ excerpt } 548 + onChange={ ( event ) => setExcerpt( event.target.value ) } 549 + /> 550 + { excerpt.length > 200 && ( 551 + <p className="studio__lede-hint"> 552 + Long ledes get truncated on the Bluesky card. 553 + </p> 554 + ) } 555 + ``` 556 + 557 + - [ ] **Step 6: Add the styles** 558 + 559 + In `src/styles/editor-chrome.css`, replace the existing editor-adjacency rule: 560 + 561 + ```css 562 + /* Tighten the gap between the title and the editor surface below it. */ 563 + .studio__title + .skypress-editor { 564 + margin-top: 0.75rem; 565 + } 566 + ``` 567 + 568 + with the lede styles + updated adjacency (the editor now follows the lede or its hint, not 569 + the title): 570 + 571 + ```css 572 + /* Italic lede under the title — echoes the title (display face, borderless) but quieter: 573 + smaller, muted, italic. Auto-grows via JS (resize/overflow off). */ 574 + .studio__lede { 575 + display: block; 576 + max-width: var(--studio-measure); 577 + margin: 0.5rem auto 0; 578 + padding: 0 var(--studio-gutter); 579 + width: 100%; 580 + box-sizing: border-box; 581 + border: 0; 582 + background: transparent; 583 + color: var(--muted); 584 + font-family: var(--font-display); 585 + font-size: 1.2rem; 586 + font-style: italic; 587 + line-height: 1.4; 588 + resize: none; 589 + overflow: hidden; 590 + } 591 + .studio__lede::placeholder { 592 + color: var(--muted); 593 + opacity: 0.6; 594 + } 595 + .studio__lede:focus { 596 + outline: none; 597 + } 598 + .studio__lede-hint { 599 + max-width: var(--studio-measure); 600 + margin: 0.25rem auto 0; 601 + padding: 0 var(--studio-gutter); 602 + font-size: 0.8rem; 603 + color: var(--muted); 604 + } 605 + /* Tighten the gap between the lede (or its hint) and the editor surface below. */ 606 + .studio__lede + .skypress-editor, 607 + .studio__lede-hint + .skypress-editor { 608 + margin-top: 0.75rem; 609 + } 610 + ``` 611 + 612 + - [ ] **Step 7: Type-check and run the full suite** 613 + 614 + Run: `npm run check` 615 + Expected: no TypeScript errors. 616 + 617 + Run: `npm test` 618 + Expected: all suites pass (including Tasks 1–4). 619 + 620 + - [ ] **Step 8: Manual smoke (real browser)** 621 + 622 + Start the dev server and verify on `http://127.0.0.1:<port>` (atproto loopback — NOT 623 + `localhost`, AGENTS.md §7): 624 + 625 + ```bash 626 + npm run dev 627 + ``` 628 + 629 + 1. Sign in, start a new article, type a title and body. A muted italic "Add a lede…" field 630 + sits directly under the title; typing in it grows it; past ~200 chars the truncation hint 631 + appears. 632 + 2. Publish with the lede **filled** → in the network tab (or pdsls) confirm the new 633 + `site.standard.document` has your `description`, and the `app.bsky.feed.post` 634 + `embed.external.description` matches. 635 + 3. Publish another with the lede **blank** → confirm both records carry the auto-derived 636 + opening of the post (not empty). 637 + 4. Open an existing article for editing → the lede field is pre-filled from the stored 638 + `description`. 639 + 640 + - [ ] **Step 9: Commit** 641 + 642 + ```bash 643 + git add src/components/Studio.tsx src/styles/editor-chrome.css 644 + git commit --no-gpg-sign -m "Add the lede field to the editor" 645 + ``` 646 + 647 + --- 648 + 649 + ### Task 6: Docs reflection + final verification 650 + 651 + **Files:** 652 + - Modify (if needed): `AGENTS.md` / a `docs/decisions/NNNN-*.md` for durable rationale 653 + 654 + - [ ] **Step 1: Decide whether the rationale graduates to a decision doc** 655 + 656 + The publish-time "blank lede → derived excerpt, written into the record" behaviour is a 657 + non-obvious product call (a future reader might expect the field to stay empty). If it 658 + warrants a durable record, add `docs/decisions/NNNN-description-excerpt-fallback.md` 659 + (context: bare Bluesky cards; options: auto-derive vs require; choice: visible lede + 660 + publish-time fallback; why) following the format of existing decision docs. Otherwise note 661 + here that the design spec is sufficient and skip. 662 + 663 + - [ ] **Step 2: Full verification** 664 + 665 + Run: `npm run check && npm test` 666 + Expected: clean type-check; all suites pass. 667 + 668 + - [ ] **Step 3: Commit any doc changes** 669 + 670 + ```bash 671 + git add docs/ AGENTS.md 672 + git commit --no-gpg-sign -m "Document the description excerpt fallback" 673 + ``` 674 + 675 + (Skip if Step 1 produced no changes.) 676 + 677 + --- 678 + 679 + ## Self-review notes 680 + 681 + - **Spec coverage:** lede field (Task 5) ✓; `deriveExcerpt` shared helper (Task 1) ✓; 682 + publish-time fallback into both records (Task 2) ✓; PublishPanel passes it through 683 + (Task 4) ✓; reader og:description prefers stored description + unified fallback (Task 3) ✓; 684 + `maxLength=3000` guard + soft ~200 hint (Task 5) ✓; edit-load hydrate / resets (Task 5) ✓. 685 + - **Out of scope (per spec):** image/thumb half (separate spec), live excerpt preview, RSS 686 + unification — none planned here, by design. 687 + - **Type consistency:** `deriveExcerpt( text: string, maxChars = 200 )` used identically in 688 + Tasks 1–3; `description` prop is `string` in PublishPanel (Task 4) and Studio passes the 689 + `string` state `excerpt` (Task 5); publisher accepts the existing optional 690 + `description?: string` on `PublishInput` / `UpdateInput` (no signature change needed). 691 + ```