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.

Docs: implementation plan for the publish success pill

+605
+605
docs/superpowers/plans/2026-06-10-publish-success-pill.md
··· 1 + # Publish Success Pill 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:** Show a persistent, sunrise-themed "published" pill (with a link to the live article) after a writer publishes or updates, surviving the editor's post-publish remount. 6 + 7 + **Architecture:** Lift the success indicator out of `PublishPanel` (which is destroyed by the `<div key={editorKey}>` remount on a new publish) and up into `Studio`, which renders it *outside* the keyed div. `PublishPanel.onComplete` carries `{ articleUrl, isEditing }` up; `Studio` stores it and renders a small presentational `PublishedPill`. The in-panel `done` state is removed. 8 + 9 + **Tech Stack:** React 18, TypeScript, Astro islands, Vitest (`react-dom/client` + `act`), plain CSS. 10 + 11 + --- 12 + 13 + ## File Structure 14 + 15 + - **Create** `src/components/PublishedPill.tsx` — presentational pill: emoji + short label + "Read it →" link. No state. 16 + - **Create** `src/components/PublishedPill.test.tsx` — unit tests for both variants. 17 + - **Modify** `src/components/PublishPanel.tsx` — change `onComplete` signature to carry `{ articleUrl, isEditing }`; remove the `done`-phase JSX block and the `resultUrl` state. 18 + - **Modify** `src/components/PublishPanel.test.tsx` — assert `onComplete` receives the payload for both update and new-publish flows. 19 + - **Modify** `src/components/Studio.tsx` — add `published` state, render `PublishedPill` below the `studio__mode` bar, clear it on title/lede typing and `startNew`. 20 + - **Modify** `src/styles/editor-chrome.css` — sunrise pill styling + rise animation + reduced-motion guard. 21 + 22 + --- 23 + 24 + ### Task 1: PublishedPill component 25 + 26 + **Files:** 27 + - Create: `src/components/PublishedPill.tsx` 28 + - Test: `src/components/PublishedPill.test.tsx` 29 + 30 + - [ ] **Step 1: Write the failing test** 31 + 32 + Create `src/components/PublishedPill.test.tsx`: 33 + 34 + ```tsx 35 + import { describe, it, expect } from 'vitest'; 36 + import { act, createElement } from 'react'; 37 + import { createRoot } from 'react-dom/client'; 38 + 39 + ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; 40 + 41 + import PublishedPill from './PublishedPill'; 42 + 43 + function render( props: { url: string; isEditing: boolean } ) { 44 + const container = document.createElement( 'div' ); 45 + document.body.appendChild( container ); 46 + const root = createRoot( container ); 47 + act( () => { 48 + root.render( createElement( PublishedPill, props ) ); 49 + } ); 50 + return { 51 + container, 52 + cleanup: () => { 53 + root.unmount(); 54 + container.remove(); 55 + }, 56 + }; 57 + } 58 + 59 + describe( 'PublishedPill', () => { 60 + it( 'shows the live copy and links to the article for a new publish', () => { 61 + const { container, cleanup } = render( { 62 + url: 'https://skypress.blog/jeherve.com/my-blog/hello', 63 + isEditing: false, 64 + } ); 65 + expect( container.textContent ).toContain( "It's live" ); 66 + const link = container.querySelector( 'a' )!; 67 + expect( link.getAttribute( 'href' ) ).toBe( 68 + 'https://skypress.blog/jeherve.com/my-blog/hello' 69 + ); 70 + expect( link.textContent ).toContain( 'Read it' ); 71 + cleanup(); 72 + } ); 73 + 74 + it( 'shows the updated copy when editing', () => { 75 + const { container, cleanup } = render( { 76 + url: 'https://x', 77 + isEditing: true, 78 + } ); 79 + expect( container.textContent ).toContain( 'Updated' ); 80 + expect( container.textContent ).not.toContain( "It's live" ); 81 + cleanup(); 82 + } ); 83 + } ); 84 + ``` 85 + 86 + - [ ] **Step 2: Run test to verify it fails** 87 + 88 + Run: `npx vitest run src/components/PublishedPill.test.tsx` 89 + Expected: FAIL — cannot resolve `./PublishedPill` (module does not exist yet). 90 + 91 + - [ ] **Step 3: Write minimal implementation** 92 + 93 + Create `src/components/PublishedPill.tsx`: 94 + 95 + ```tsx 96 + interface Props { 97 + /** Public reading-page URL of the just-published / updated article. */ 98 + url: string; 99 + /** True when an existing article was updated (vs. a brand-new publish). */ 100 + isEditing: boolean; 101 + } 102 + 103 + /** 104 + * Glanceable confirmation that a publish/update finished, with a link to the 105 + * live article. Rendered by Studio ABOVE the editor's keyed remount boundary so 106 + * it survives the post-publish reset (a pill inside PublishPanel would be wiped). 107 + */ 108 + export default function PublishedPill( { url, isEditing }: Props ) { 109 + return ( 110 + <p className="studio__published" role="status"> 111 + <span className="studio__published-label"> 112 + 🌅 { isEditing ? 'Updated' : "It's live" } 113 + </span> 114 + <a 115 + className="studio__published-link" 116 + href={ url } 117 + target="_blank" 118 + rel="noopener noreferrer" 119 + > 120 + Read it → 121 + </a> 122 + </p> 123 + ); 124 + } 125 + ``` 126 + 127 + - [ ] **Step 4: Run test to verify it passes** 128 + 129 + Run: `npx vitest run src/components/PublishedPill.test.tsx` 130 + Expected: PASS (2 tests). 131 + 132 + - [ ] **Step 5: Commit** 133 + 134 + ```bash 135 + git add src/components/PublishedPill.tsx src/components/PublishedPill.test.tsx 136 + git commit --no-gpg-sign -m "Add PublishedPill component for post-publish confirmation" 137 + ``` 138 + 139 + --- 140 + 141 + ### Task 2: PublishPanel carries the result up; drop the in-panel done state 142 + 143 + **Files:** 144 + - Modify: `src/components/PublishPanel.tsx` 145 + - Test: `src/components/PublishPanel.test.tsx` 146 + 147 + - [ ] **Step 1: Write the failing tests** 148 + 149 + In `src/components/PublishPanel.test.tsx`, add an `onComplete` capture to the existing `clickUpdate` helper and add a new-publish helper + assertions. Replace the `clickUpdate` function and the `describe` block with: 150 + 151 + ```tsx 152 + async function clickUpdate( 153 + description: string, 154 + coverImage?: unknown, 155 + onComplete?: ( r: { articleUrl: string; isEditing: boolean } ) => void 156 + ) { 157 + const container = document.createElement( 'div' ); 158 + document.body.appendChild( container ); 159 + const root = createRoot( container ); 160 + await act( async () => { 161 + root.render( 162 + createElement( PublishPanel, { 163 + agent: {} as Agent, 164 + identity: { did: 'did:plc:me', handle: 'me.test' }, 165 + blocks: [ { name: 'core/paragraph', attributes: { content: 'Body' }, innerBlocks: [] } ] as never, 166 + blobRegistry: new Map(), 167 + publications: [], 168 + editing: EDITING, 169 + title: 'A title', 170 + description, 171 + coverImage: coverImage as never, 172 + onComplete, 173 + } ) 174 + ); 175 + } ); 176 + const button = Array.from( container.querySelectorAll( 'button' ) ).find( 177 + ( b ) => b.textContent === 'Update' 178 + )!; 179 + await act( async () => { 180 + button.dispatchEvent( new MouseEvent( 'click', { bubbles: true } ) ); 181 + } ); 182 + root.unmount(); 183 + container.remove(); 184 + } 185 + 186 + const PUB = { 187 + uri: 'at://did:plc:me/site.standard.publication/pub1', 188 + cid: 'bafypub', 189 + rkey: 'pub1', 190 + slug: 'my-blog', 191 + name: 'My Blog', 192 + }; 193 + 194 + async function clickPublish( 195 + onComplete: ( r: { articleUrl: string; isEditing: boolean } ) => void 196 + ) { 197 + const container = document.createElement( 'div' ); 198 + document.body.appendChild( container ); 199 + const root = createRoot( container ); 200 + await act( async () => { 201 + root.render( 202 + createElement( PublishPanel, { 203 + agent: {} as Agent, 204 + identity: { did: 'did:plc:me', handle: 'me.test' }, 205 + blocks: [ { name: 'core/paragraph', attributes: { content: 'Body' }, innerBlocks: [] } ] as never, 206 + blobRegistry: new Map(), 207 + publications: [ PUB ] as never, 208 + title: 'A title', 209 + description: 'A lede', 210 + onComplete, 211 + } ) 212 + ); 213 + } ); 214 + const find = ( label: string ) => 215 + Array.from( container.querySelectorAll( 'button' ) ).find( 216 + ( b ) => b.textContent === label 217 + )!; 218 + await act( async () => { 219 + find( 'Publish…' ).dispatchEvent( new MouseEvent( 'click', { bubbles: true } ) ); 220 + } ); 221 + await act( async () => { 222 + find( 'Publish & post to Bluesky' ).dispatchEvent( 223 + new MouseEvent( 'click', { bubbles: true } ) 224 + ); 225 + } ); 226 + root.unmount(); 227 + container.remove(); 228 + } 229 + 230 + describe( 'PublishPanel', () => { 231 + it( 'forwards the lede to updateDocument as description', async () => { 232 + await clickUpdate( 'My hand-written lede' ); 233 + expect( updateDocument ).toHaveBeenCalledTimes( 1 ); 234 + expect( updateDocument.mock.calls[ 0 ][ 2 ] ).toMatchObject( { 235 + description: 'My hand-written lede', 236 + } ); 237 + } ); 238 + 239 + it( 'forwards the coverImage to updateDocument', async () => { 240 + const cover = { 241 + $type: 'blob', 242 + ref: { $link: 'bafycover' }, 243 + mimeType: 'image/png', 244 + size: 9000, 245 + }; 246 + await clickUpdate( 'A lede', cover ); 247 + expect( updateDocument.mock.calls[ 0 ][ 2 ] ).toMatchObject( { 248 + coverImage: cover, 249 + } ); 250 + } ); 251 + 252 + it( 'reports the article URL and isEditing=true on update', async () => { 253 + const onComplete = vi.fn(); 254 + await clickUpdate( 'A lede', undefined, onComplete ); 255 + expect( onComplete ).toHaveBeenCalledWith( { 256 + articleUrl: 'https://x', 257 + isEditing: true, 258 + } ); 259 + } ); 260 + 261 + it( 'reports the article URL and isEditing=false on a new publish', async () => { 262 + const onComplete = vi.fn(); 263 + await clickPublish( onComplete ); 264 + expect( onComplete ).toHaveBeenCalledWith( { 265 + articleUrl: 'https://x', 266 + isEditing: false, 267 + } ); 268 + } ); 269 + } ); 270 + ``` 271 + 272 + - [ ] **Step 2: Run tests to verify the new ones fail** 273 + 274 + Run: `npx vitest run src/components/PublishPanel.test.tsx` 275 + Expected: the two new tests FAIL — `onComplete` is currently called with no arguments (`toHaveBeenCalledWith({...})` mismatch). The two original tests still PASS. 276 + 277 + - [ ] **Step 3: Update PublishPanel — payload + remove done state** 278 + 279 + In `src/components/PublishPanel.tsx`: 280 + 281 + (a) Change the `onComplete` prop type. Replace: 282 + 283 + ```tsx 284 + /** Called after a successful publish/update so the parent can refresh. */ 285 + onComplete?: () => void; 286 + ``` 287 + 288 + with: 289 + 290 + ```tsx 291 + /** 292 + * Called after a successful publish/update with the live article URL so the 293 + * parent (Studio) can show the success pill. `isEditing` distinguishes an 294 + * update from a brand-new publish. 295 + */ 296 + onComplete?: ( result: { articleUrl: string; isEditing: boolean } ) => void; 297 + ``` 298 + 299 + (b) Remove the `resultUrl` state line: 300 + 301 + ```tsx 302 + const [ resultUrl, setResultUrl ] = useState< string | null >( null ); 303 + ``` 304 + 305 + (c) Rewrite `run()` so each branch produces an `articleUrl`, then report it. Replace the whole `try { … }` body inside `run()` with: 306 + 307 + ```tsx 308 + try { 309 + const prepared = attachBlobRefs( normalizeBlocks( blocks ), ( url ) => 310 + blobRegistry.get( url ) 311 + ); 312 + let articleUrl: string; 313 + if ( editing ) { 314 + const res = await updateDocument( agent, identity, { 315 + rkey: editing.rkey, 316 + siteUri: editing.siteUri, 317 + publicationSlug: editing.siteSlug, 318 + publishedAt: editing.publishedAt, 319 + bskyPostRef: editing.bskyPostRef, 320 + title: title.trim(), 321 + description, 322 + blocks: prepared, 323 + coverImage, 324 + } ); 325 + articleUrl = res.articleUrl; 326 + } else { 327 + // Non-editing: the target is always a full Publication picked from `pubs`. 328 + const pub = pubs.find( ( candidate ) => candidate.uri === targetUri ); 329 + if ( ! pub ) { 330 + return; 331 + } 332 + const res = await publish( agent, identity, { 333 + title: title.trim(), 334 + description, 335 + blocks: prepared, 336 + publicationUri: pub.uri, 337 + publicationCid: pub.cid, 338 + publicationSlug: pub.slug, 339 + coverImage, 340 + } ); 341 + articleUrl = res.articleUrl; 342 + } 343 + setPhase( 'done' ); 344 + onComplete?.( { articleUrl, isEditing } ); 345 + } catch ( err ) { 346 + setError( err instanceof Error ? err.message : String( err ) ); 347 + setPhase( 'error' ); 348 + } 349 + ``` 350 + 351 + (d) Remove the entire `done`-phase result block from the JSX: 352 + 353 + ```tsx 354 + { phase === 'done' && resultUrl && ( 355 + <div className="publish__result"> 356 + <p> 357 + { isEditing 358 + ? 'Updated ✓ (same URL; the original Bluesky post stays — its preview may not refresh)' 359 + : 'Published ✓ (the reading page goes live once SkyPress is deployed)' } 360 + </p> 361 + <p> 362 + Article URL: <code>{ resultUrl }</code> 363 + </p> 364 + </div> 365 + ) } 366 + ``` 367 + 368 + Leave the `confirm`, `working`, and `error` blocks and the button (its render condition still includes `phase === 'done'`, which correctly re-enables the Update button after an edit). 369 + 370 + - [ ] **Step 4: Run tests to verify all pass** 371 + 372 + Run: `npx vitest run src/components/PublishPanel.test.tsx` 373 + Expected: PASS (4 tests). 374 + 375 + - [ ] **Step 5: Commit** 376 + 377 + ```bash 378 + git add src/components/PublishPanel.tsx src/components/PublishPanel.test.tsx 379 + git commit --no-gpg-sign -m "PublishPanel: report article URL on success, drop in-panel done state" 380 + ``` 381 + 382 + --- 383 + 384 + ### Task 3: Wire the pill into Studio 385 + 386 + **Files:** 387 + - Modify: `src/components/Studio.tsx` 388 + 389 + No unit test — `Studio` is auth-gated (`useAuth`, OAuth) with no test harness. The behavioral contract is covered by Tasks 1–2; this task is verified via type-check and the manual smoke test in Task 5. 390 + 391 + - [ ] **Step 1: Import the pill** 392 + 393 + After the existing `import PublishPanel from './PublishPanel';` line (`Studio.tsx:7`), add: 394 + 395 + ```tsx 396 + import PublishedPill from './PublishedPill'; 397 + ``` 398 + 399 + - [ ] **Step 2: Add the published state** 400 + 401 + Immediately after the `refreshKey` state line (`const [ refreshKey, setRefreshKey ] = useState( 0 );`, `Studio.tsx:33`), add: 402 + 403 + ```tsx 404 + // The just-published/updated article — drives the success pill. Lives here 405 + // (above the keyed editor div) so it survives the post-publish remount. 406 + const [ published, setPublished ] = useState< { 407 + url: string; 408 + isEditing: boolean; 409 + } | null >( null ); 410 + ``` 411 + 412 + - [ ] **Step 3: Set it from onComplete and clear it in startNew** 413 + 414 + Replace the `startNew` function body's first lines to also clear the pill. Change: 415 + 416 + ```tsx 417 + const startNew = () => { 418 + revokeBlobRegistry( registry ); 419 + setEditing( null ); 420 + ``` 421 + 422 + to: 423 + 424 + ```tsx 425 + const startNew = () => { 426 + revokeBlobRegistry( registry ); 427 + setPublished( null ); 428 + setEditing( null ); 429 + ``` 430 + 431 + Then update the `onComplete` handler. Replace: 432 + 433 + ```tsx 434 + onComplete={ () => { 435 + setRefreshKey( ( k ) => k + 1 ); 436 + ``` 437 + 438 + with: 439 + 440 + ```tsx 441 + onComplete={ ( result ) => { 442 + setPublished( { 443 + url: result.articleUrl, 444 + isEditing: result.isEditing, 445 + } ); 446 + setRefreshKey( ( k ) => k + 1 ); 447 + ``` 448 + 449 + (The rest of the handler — the `if ( ! editing ) { setTitle( '' ); … }` reset — stays unchanged.) 450 + 451 + - [ ] **Step 4: Render the pill below the mode bar** 452 + 453 + Find the `studio__mode` block (`Studio.tsx:166`) and, immediately after its closing `</div>`, add the pill so it renders on its own row, outside `<div key={editorKey}>`: 454 + 455 + ```tsx 456 + </div> 457 + 458 + { published && ( 459 + <PublishedPill url={ published.url } isEditing={ published.isEditing } /> 460 + ) } 461 + ``` 462 + 463 + (The existing `{ editLoadError && ( … ) }` block follows this.) 464 + 465 + - [ ] **Step 5: Clear the pill when the writer starts typing again** 466 + 467 + Update the title input's `onChange` (`Studio.tsx:222`). Replace: 468 + 469 + ```tsx 470 + onChange={ ( event ) => setTitle( event.target.value ) } 471 + ``` 472 + 473 + with: 474 + 475 + ```tsx 476 + onChange={ ( event ) => { 477 + setPublished( null ); 478 + setTitle( event.target.value ); 479 + } } 480 + ``` 481 + 482 + Update the lede textarea's `onChange` (`Studio.tsx:232`). Replace: 483 + 484 + ```tsx 485 + onChange={ ( event ) => setExcerpt( event.target.value ) } 486 + ``` 487 + 488 + with: 489 + 490 + ```tsx 491 + onChange={ ( event ) => { 492 + setPublished( null ); 493 + setExcerpt( event.target.value ); 494 + } } 495 + ``` 496 + 497 + - [ ] **Step 6: Type-check** 498 + 499 + Run: `npm run check` 500 + Expected: no new TypeScript errors in `Studio.tsx` / `PublishPanel.tsx` / `PublishedPill.tsx`. 501 + 502 + - [ ] **Step 7: Commit** 503 + 504 + ```bash 505 + git add src/components/Studio.tsx 506 + git commit --no-gpg-sign -m "Studio: show the publish success pill above the editor remount" 507 + ``` 508 + 509 + --- 510 + 511 + ### Task 4: Sunrise pill styling 512 + 513 + **Files:** 514 + - Modify: `src/styles/editor-chrome.css` 515 + 516 + - [ ] **Step 1: Add the pill styles** 517 + 518 + Append to `src/styles/editor-chrome.css` (after the existing `.publish__error` rules around line 123): 519 + 520 + ```css 521 + /* Publish success pill — sunrise callback, rises into view (Studio-owned) */ 522 + .studio__published { 523 + width: fit-content; 524 + max-width: var(--studio-measure); 525 + margin: 0.35rem auto 0; 526 + display: flex; 527 + align-items: center; 528 + gap: 0.6rem; 529 + padding: 0.5rem 1rem; 530 + border-radius: 999px; 531 + background: linear-gradient(100deg, var(--sun-tint), var(--sun)); 532 + color: var(--ink); 533 + font-size: 0.9rem; 534 + font-weight: 600; 535 + box-shadow: 0 1px 8px rgba(232, 146, 12, 0.3); 536 + animation: studio-published-rise 400ms ease-out; 537 + } 538 + 539 + .studio__published-link { 540 + color: var(--ink); 541 + font-weight: 700; 542 + text-decoration: underline; 543 + white-space: nowrap; 544 + } 545 + 546 + @keyframes studio-published-rise { 547 + from { 548 + opacity: 0; 549 + transform: translateY(0.5rem); 550 + } 551 + to { 552 + opacity: 1; 553 + transform: translateY(0); 554 + } 555 + } 556 + 557 + @media (prefers-reduced-motion: reduce) { 558 + .studio__published { 559 + animation: none; 560 + } 561 + } 562 + ``` 563 + 564 + - [ ] **Step 2: Verify the full test suite + check still pass** 565 + 566 + Run: `npm run test` 567 + Expected: PASS (whole suite green, including the new `PublishedPill` + `PublishPanel` tests). 568 + 569 + Run: `npm run check` 570 + Expected: no errors. 571 + 572 + - [ ] **Step 3: Commit** 573 + 574 + ```bash 575 + git add src/styles/editor-chrome.css 576 + git commit --no-gpg-sign -m "Style the publish success pill with a sunrise gradient" 577 + ``` 578 + 579 + --- 580 + 581 + ### Task 5: Manual smoke test 582 + 583 + **Files:** none (verification only). 584 + 585 + - [ ] **Step 1: Run the dev server and publish** 586 + 587 + Run: `npm run dev` and open the editor on `http://127.0.0.1:<port>/editor` (loopback, not `localhost` — atproto OAuth requirement). Sign in, write a short article, and publish. 588 + 589 + Expected: 590 + - After publishing, a rounded sunrise-gradient pill reading **🌅 It's live · Read it →** appears below the mode bar and **stays visible** while the editor resets to a blank canvas. 591 + - Clicking **Read it →** opens the live article in a new tab. 592 + - Typing a new title clears the pill. 593 + - Editing an existing article and saving shows **🌅 Updated · Read it →**. 594 + 595 + - [ ] **Step 2: Confirm reduced-motion** 596 + 597 + With OS "reduce motion" enabled, the pill appears without the rise animation. 598 + 599 + --- 600 + 601 + ## Self-Review notes 602 + 603 + - **Spec coverage:** lift-to-Studio (Tasks 2–3), `onComplete` payload (Task 2), remove in-panel done (Task 2), pill placement in `studio__mode` row (Task 3), `PublishedPill` component (Task 1), short sunrise copy (Task 1), sunrise styling + rise + reduced-motion (Task 4), persistence/clear-on-typing/startNew (Task 3), testing of pill + onComplete contract (Tasks 1–2). All covered. 604 + - **Type consistency:** `onComplete?( result: { articleUrl: string; isEditing: boolean } )` is used identically in `PublishPanel.tsx`, `PublishPanel.test.tsx`, and `Studio.tsx`. `published` state shape `{ url, isEditing }` maps `articleUrl → url` at the call site in Task 3 Step 3. 605 + - **No placeholders:** every code step shows complete code; every run step states the expected result.