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.

Merge branch 'editor-first-onboarding' into trunk

+4349 -218
+37
docs/decisions/0020-writing-first-deferred-publish.md
··· 1 + # 0020 — Writing-first flow: deferred publish, no remote account creation 2 + 3 + ## Context 4 + `/editor` gates the entire editor behind OAuth. We wanted a parallel surface where writing is 5 + the first action and auth/publication selection are deferred to publish time. 6 + 7 + ## Decision 8 + - Add a parallel route `/write` mounting a new `WriteStudio` island that renders the editor for 9 + every auth status (no login gate). `/`, `/editor`, `/dashboard` are untouched. 10 + - Images are held locally as `data:` URLs and uploaded to the PDS only at publish 11 + (`src/lib/write/upload-held.ts`), via one media path regardless of auth state. 12 + - The draft (title, lede, token-skeleton blocks, cover) survives the full-page OAuth redirect: 13 + light metadata + skeleton in `localStorage`, image bytes in IndexedDB 14 + (`src/lib/write/draft-store.ts` + `asset-store.ts`). A one-shot `publishIntent` flag makes the 15 + publish flow auto-resume on return. 16 + - Publish branches on publication count: one → confirm; many → pick; zero → inline create. The 17 + single-publication case still shows a confirm — publishing also posts to Bluesky (brief §10). 18 + 19 + ## Why not remote account creation 20 + atproto OAuth authenticates an existing account; `com.atproto.server.createAccount` lives on a 21 + PDS's hosted signup (invite/email gated) and is not exposed to third-party clients. Building an 22 + inline signup would make SkyPress a hosting/signup broker, contradicting the "never a PDS/relay" 23 + guardrail. The signed-out panel therefore links out to Bluesky signup and otherwise offers 24 + sign-in only. 25 + 26 + ## Consequences 27 + - Signed-in writers lose eager upload error feedback (errors surface at publish) — accepted for a 28 + single, simpler media path. 29 + - `data:`-URL drafts can be large; bytes go to IndexedDB to avoid the `localStorage` quota. 30 + 31 + ## Update — home page leads with /write 32 + The route started as a hidden parallel experience, but it tested well, so the home page now 33 + **leads with it**: the hero's primary CTA is "Start writing →" → `/write`, with the handle 34 + sign-in (`HandleStart`) demoted to a secondary "Already have an account?" path. The masthead 35 + "Write" button and the signed-in account menu's "Write" item (`accountMenuItems`) also point at 36 + `/write` now. `/editor` still exists (edit-an-existing-article + the gated flow) but is no longer 37 + linked from the home page.
+2229
docs/superpowers/plans/2026-06-17-writing-first-flow.md
··· 1 + # Writing-first Flow 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:** Add a parallel `/write` experience where the writer lands directly in the editor, writes immediately with no login gate, and only meets authentication + publication selection at publish time — with images held locally and uploaded at publish. 6 + 7 + **Architecture:** A new client-only `WriteStudio` island at `/write` wraps the existing `AuthProvider` but renders the editor for *every* auth status. Images insert as `data:` URL previews and are never uploaded during editing. The full draft (title, lede, blocks, cover) survives the OAuth full-page redirect via a draft store that keeps light metadata + token-skeletoned blocks in `localStorage` and the heavy image bytes in IndexedDB. Clicking **Publish** persists the draft, stamps a `publishIntent`, and (if signed out) redirects to OAuth; on return the flow auto-resumes, branches on publication count (one → confirm / many → pick / zero → inline create), uploads held images, then reuses the existing `publish()` to write the document + Bluesky post. 8 + 9 + **Tech Stack:** Astro (file-router islands), React 18, `@automattic/isolated-block-editor`, `@atproto/api`, TypeScript, Vitest (jsdom; component tests via raw `react-dom/client` + `act`, **no** testing-library). 10 + 11 + ## Global Constraints 12 + 13 + - **React 18 only** — never introduce React 19. (Decision 0001) 14 + - **`@wordpress/*` is version-pinned via `overrides`** — do not bump `isolated-block-editor` here. (Decision 0003) 15 + - **Reading pages must never import `@wordpress/*`.** All new editor code is browser-only and belongs in the `client:only` island or in tests. (Decision 0003) 16 + - **Colocated tests under `src/pages/` MUST be underscore-prefixed** (e.g. `_write.meta.test.ts`) or Astro's router will execute them during prerender and 500 the build. (AGENTS.md §8) 17 + - **Don't surprise users:** publishing also creates a public Bluesky post — every confirm step must say so. (Brief §10) 18 + - **No remote account creation.** OAuth is sign-in only; the signed-out panel offers a *link out* to Bluesky signup, never an inline create-account flow. (Brief guardrail; design Q2) 19 + - **OAuth is a browser public client**; the `redirect_uri` is the current pathname, so a sign-in from `/write` returns to `/write` with no new config. (Decision 0004) 20 + - New PHP/TS files: this is a TS/Astro repo — match existing tab indentation and the `import type` style used throughout `src/`. 21 + - License header convention: follow the surrounding files (none carry per-file license headers today — do not add any). 22 + 23 + ## File Structure 24 + 25 + **New library modules (browser, unit-tested):** 26 + - `src/lib/write/deferred-media.ts` — a `mediaUpload` handler that previews via `data:` URL and uploads nothing. 27 + - `src/lib/write/held-assets.ts` — pure helpers: detect `data:` URLs, split/merge image `data:` URLs out of a block tree (+ cover) into a token→bytes map, and rebuild a `Blob` from a `data:` URL. 28 + - `src/lib/write/asset-store.ts` — `AssetStore` interface + an IndexedDB implementation + an in-memory implementation (for SSR-absent envs and tests). 29 + - `src/lib/write/draft-store.ts` — orchestrates `localStorage` (metadata + token-skeleton blocks + the publish-intent flag) and an injected `AssetStore` (image bytes) into one save/load/clear API. 30 + - `src/lib/write/upload-held.ts` — at publish: upload every held `data:` image (+ cover) to the PDS and return a publish-ready block tree + cover ref. 31 + 32 + **New components (browser island):** 33 + - `src/components/SignInPanel.tsx` — signed-out handle entry; one variant carries publish intent, one doesn't; includes the "Need an account? →" link out. 34 + - `src/components/AccountPill.tsx` — signed-in avatar/handle pill with a small menu (Manage publications, Profile, Sign out). 35 + - `src/components/WritePublishFlow.tsx` — the publish stepper (branch → confirm/pick/create → upload → publish). 36 + - `src/components/WriteStudio.tsx` — the island: editor for all auth states, persistence, redirect, resume. 37 + 38 + **New route + styles:** 39 + - `src/pages/write.astro` — mounts `WriteStudio` (`client:only`). 40 + - `src/pages/_write.meta.test.ts` — route metadata test (underscore-prefixed). 41 + - `src/styles/write-chrome.css` — corner pill / sign-in panel / publish-flow styling. 42 + 43 + **New decision doc:** 44 + - `docs/decisions/0020-writing-first-deferred-publish.md` 45 + 46 + **Untouched:** `src/pages/index.astro`, `src/pages/editor.astro`, `src/components/Studio.tsx`, `src/components/PublishPanel.tsx`, `src/components/Dashboard.tsx`, `src/lib/media/mediaUpload.ts`, `src/components/SkyEditor.tsx`. 47 + 48 + **Reused unchanged (imported):** `AuthProvider`, `useAuth`, `oauth.ts`, `SkyEditor`, `PublicationForm`, `publisher.ts` (`publish`, `Identity`), `publications.ts` (`listPublications`, `Publication`), `records.ts` (`normalizeBlocks`), `blob.ts` (`attachBlobRefs`, `buildGetBlobUrl`, `BlobRefJson`, `BlobUpload`), `profile.ts` (`authorPath`, `displayNameFor`), `PublishPanel.tsx`'s exported `computePostPreview`, `AppBar`, `PublishedPill`, `CoverImagePicker`. 49 + 50 + --- 51 + 52 + ### Task 1: Deferred media handler 53 + 54 + A Gutenberg `mediaUpload` handler that, unlike the eager one, never touches the PDS: it reads each file into a `data:` URL and hands it back for inline preview. The actual upload is deferred to publish. 55 + 56 + **Files:** 57 + - Create: `src/lib/write/deferred-media.ts` 58 + - Test: `src/lib/write/deferred-media.test.ts` 59 + 60 + **Interfaces:** 61 + - Consumes: `MediaUploadHandler`, `MediaUploadArgs` shape from `src/lib/media/mediaUpload.ts` (the handler is `(args) => Promise<void>` where `args` has `filesList`, `onFileChange`, `onError?`, `maxUploadFileSize?`). 62 + - Produces: `export function createDeferredMediaUpload(): MediaUploadHandler` 63 + 64 + - [ ] **Step 1: Write the failing test** 65 + 66 + ```ts 67 + // src/lib/write/deferred-media.test.ts 68 + import { describe, it, expect, vi } from 'vitest'; 69 + import { createDeferredMediaUpload } from './deferred-media'; 70 + 71 + describe( 'createDeferredMediaUpload', () => { 72 + it( 'previews each file as a data: URL and uploads nothing', async () => { 73 + const handler = createDeferredMediaUpload(); 74 + const file = new File( [ 'x' ], 'cat.png', { type: 'image/png' } ); 75 + const onFileChange = vi.fn(); 76 + 77 + await handler( { filesList: [ file ], onFileChange } ); 78 + 79 + expect( onFileChange ).toHaveBeenCalledTimes( 1 ); 80 + const [ media ] = onFileChange.mock.calls[ 0 ][ 0 ]; 81 + expect( media.url.startsWith( 'data:image/png;base64,' ) ).toBe( true ); 82 + expect( media.url.startsWith( 'blob:' ) ).toBe( false ); 83 + expect( 'id' in media ).toBe( false ); 84 + } ); 85 + 86 + it( 'rejects oversize files via onError without previewing them', async () => { 87 + const handler = createDeferredMediaUpload(); 88 + const big = new File( [ 'x' ], 'big.png', { type: 'image/png' } ); 89 + Object.defineProperty( big, 'size', { value: 5_000_000 } ); 90 + const onFileChange = vi.fn(); 91 + const onError = vi.fn(); 92 + 93 + await handler( { 94 + filesList: [ big ], 95 + onFileChange, 96 + onError, 97 + maxUploadFileSize: 1_000_000, 98 + } ); 99 + 100 + expect( onFileChange ).not.toHaveBeenCalled(); 101 + expect( onError ).toHaveBeenCalledTimes( 1 ); 102 + expect( onError.mock.calls[ 0 ][ 0 ] ).toBeInstanceOf( Error ); 103 + } ); 104 + } ); 105 + ``` 106 + 107 + - [ ] **Step 2: Run test to verify it fails** 108 + 109 + Run: `npx vitest run src/lib/write/deferred-media.test.ts` 110 + Expected: FAIL — cannot resolve `./deferred-media`. 111 + 112 + - [ ] **Step 3: Write the implementation** 113 + 114 + ```ts 115 + // src/lib/write/deferred-media.ts 116 + import type { MediaUploadHandler } from '../media/mediaUpload'; 117 + 118 + /** Read a file into a `data:` URL (base64) for inline preview. */ 119 + function readAsDataUrl( file: File ): Promise< string > { 120 + return new Promise( ( resolve, reject ) => { 121 + const reader = new FileReader(); 122 + reader.onload = () => resolve( reader.result as string ); 123 + reader.onerror = () => 124 + reject( reader.error ?? new Error( `Could not read "${ file.name }".` ) ); 125 + reader.readAsDataURL( file ); 126 + } ); 127 + } 128 + 129 + /** 130 + * A Gutenberg `mediaUpload` handler for the writing-first flow (design 2026-06-17): it 131 + * previews each file from a `data:` URL and uploads NOTHING to a PDS. The real upload is 132 + * deferred to publish (see `upload-held.ts`), so the editor works while signed out. 133 + * 134 + * Like the eager handler it must preview from a `data:` URL, never a `blob:` URL — the Image 135 + * block treats a `blob:` URL as still-uploading and re-runs its upload hook forever. 136 + */ 137 + export function createDeferredMediaUpload(): MediaUploadHandler { 138 + return async function deferredMediaUpload( { 139 + filesList, 140 + onFileChange, 141 + onError, 142 + maxUploadFileSize, 143 + } ): Promise< void > { 144 + for ( const file of Array.from( filesList ) ) { 145 + try { 146 + if ( maxUploadFileSize && file.size > maxUploadFileSize ) { 147 + const mb = Math.round( maxUploadFileSize / 1024 / 1024 ); 148 + throw new Error( `"${ file.name }" exceeds the ${ mb }MB limit.` ); 149 + } 150 + const previewUrl = await readAsDataUrl( file ); 151 + // No `id`: PDS blobs aren't WP attachments (mirrors the eager handler). 152 + onFileChange( [ { url: previewUrl, alt: '' } ] ); 153 + } catch ( error ) { 154 + onError?.( error instanceof Error ? error : new Error( String( error ) ) ); 155 + } 156 + } 157 + }; 158 + } 159 + ``` 160 + 161 + - [ ] **Step 4: Run test to verify it passes** 162 + 163 + Run: `npx vitest run src/lib/write/deferred-media.test.ts` 164 + Expected: PASS (both tests). 165 + 166 + - [ ] **Step 5: Commit** 167 + 168 + ```bash 169 + git add src/lib/write/deferred-media.ts src/lib/write/deferred-media.test.ts 170 + git commit --no-gpg-sign -m "Add deferred media handler for writing-first editor" 171 + ``` 172 + 173 + --- 174 + 175 + ### Task 2: Held-asset split/merge + data-URL helpers (pure) 176 + 177 + Pure logic to: recognise `data:` URLs, rebuild a `Blob` from one, and split image `data:` URLs (in `core/image` blocks at any depth, plus the cover) out of a tree into a small token-skeleton + a `token → dataUrl` map (and merge them back). This is what lets large image bytes live in IndexedDB while the block skeleton stays small in `localStorage`. 178 + 179 + **Files:** 180 + - Create: `src/lib/write/held-assets.ts` 181 + - Test: `src/lib/write/held-assets.test.ts` 182 + 183 + **Interfaces:** 184 + - Consumes: `BlockNode` from `src/lib/blocks/render.ts` (`{ name: string; attributes?: Record<string, unknown>; innerBlocks?: BlockNode[] }`). 185 + - Produces: 186 + - `export function isDataUrl( value: unknown ): value is string` 187 + - `export function dataUrlToBlob( dataUrl: string ): Blob` 188 + - `export interface AssetSkeleton { blocks: BlockNode[]; cover: string | null }` 189 + - `export function splitAssets( blocks: BlockNode[], coverDataUrl: string | null ): { skeleton: AssetSkeleton; assets: Record< string, string > }` 190 + - `export function mergeAssets( skeleton: AssetSkeleton, assets: Record< string, string > ): { blocks: BlockNode[]; coverDataUrl: string | null }` 191 + - Token format: body images `a0`, `a1`, … (allocation order is depth-first); cover token is the literal `cover`. 192 + 193 + - [ ] **Step 1: Write the failing test** 194 + 195 + ```ts 196 + // src/lib/write/held-assets.test.ts 197 + import { describe, it, expect } from 'vitest'; 198 + import { 199 + isDataUrl, 200 + dataUrlToBlob, 201 + splitAssets, 202 + mergeAssets, 203 + } from './held-assets'; 204 + import type { BlockNode } from '../blocks/render'; 205 + 206 + const DATA_A = 'data:image/png;base64,QUFB'; // "AAA" 207 + const DATA_B = 'data:image/jpeg;base64,QkJC'; // "BBB" 208 + 209 + function tree(): BlockNode[] { 210 + return [ 211 + { name: 'core/paragraph', attributes: { content: 'hi' }, innerBlocks: [] }, 212 + { name: 'core/image', attributes: { url: DATA_A }, innerBlocks: [] }, 213 + { 214 + name: 'core/columns', 215 + attributes: {}, 216 + innerBlocks: [ 217 + { name: 'core/image', attributes: { url: DATA_B }, innerBlocks: [] }, 218 + { name: 'core/image', attributes: { url: 'https://ext/img.png' }, innerBlocks: [] }, 219 + ], 220 + }, 221 + ]; 222 + } 223 + 224 + describe( 'isDataUrl', () => { 225 + it( 'matches data: URLs only', () => { 226 + expect( isDataUrl( DATA_A ) ).toBe( true ); 227 + expect( isDataUrl( 'https://x/y.png' ) ).toBe( true === false ); // not a data URL 228 + expect( isDataUrl( undefined ) ).toBe( false ); 229 + } ); 230 + } ); 231 + 232 + describe( 'dataUrlToBlob', () => { 233 + it( 'reconstructs bytes + mime from a data: URL', async () => { 234 + const blob = dataUrlToBlob( DATA_A ); 235 + expect( blob.type ).toBe( 'image/png' ); 236 + expect( await blob.text() ).toBe( 'AAA' ); 237 + } ); 238 + } ); 239 + 240 + describe( 'splitAssets / mergeAssets', () => { 241 + it( 'extracts every data: image URL (depth-first) + cover into tokens and round-trips', () => { 242 + const { skeleton, assets } = splitAssets( tree(), DATA_A ); 243 + 244 + // Body data URLs replaced by tokens; external + non-image untouched. 245 + expect( skeleton.blocks[ 1 ].attributes!.url ).toBe( 'a0' ); 246 + expect( skeleton.blocks[ 2 ].innerBlocks![ 0 ].attributes!.url ).toBe( 'a1' ); 247 + expect( skeleton.blocks[ 2 ].innerBlocks![ 1 ].attributes!.url ).toBe( 'https://ext/img.png' ); 248 + expect( skeleton.cover ).toBe( 'cover' ); 249 + expect( assets ).toEqual( { a0: DATA_A, a1: DATA_B, cover: DATA_A } ); 250 + 251 + const merged = mergeAssets( skeleton, assets ); 252 + expect( merged.blocks ).toEqual( tree() ); 253 + expect( merged.coverDataUrl ).toBe( DATA_A ); 254 + } ); 255 + 256 + it( 'leaves a null cover null and a tree with no data URLs unchanged', () => { 257 + const plain: BlockNode[] = [ 258 + { name: 'core/image', attributes: { url: 'https://ext/x.png' }, innerBlocks: [] }, 259 + ]; 260 + const { skeleton, assets } = splitAssets( plain, null ); 261 + expect( assets ).toEqual( {} ); 262 + expect( skeleton.cover ).toBe( null ); 263 + expect( mergeAssets( skeleton, assets ) ).toEqual( { blocks: plain, coverDataUrl: null } ); 264 + } ); 265 + } ); 266 + ``` 267 + 268 + > Note: the `isDataUrl( 'https://x/y.png' )` assertion above is written `toBe( true === false )` (i.e. `false`) deliberately so the test reads as "an https URL is NOT a data URL". If you prefer, replace with `.toBe( false )`. 269 + 270 + - [ ] **Step 2: Run test to verify it fails** 271 + 272 + Run: `npx vitest run src/lib/write/held-assets.test.ts` 273 + Expected: FAIL — cannot resolve `./held-assets`. 274 + 275 + - [ ] **Step 3: Write the implementation** 276 + 277 + ```ts 278 + // src/lib/write/held-assets.ts 279 + import type { BlockNode } from '../blocks/render'; 280 + 281 + /** Block names whose `url` attribute may hold a held (data:) image. Mirrors blob.ts. */ 282 + const IMAGE_BLOCKS = new Set( [ 'core/image' ] ); 283 + 284 + /** True when `value` is a `data:` URL string. */ 285 + export function isDataUrl( value: unknown ): value is string { 286 + return typeof value === 'string' && value.startsWith( 'data:' ); 287 + } 288 + 289 + /** Rebuild a `Blob` (bytes + mime type) from a base64 `data:` URL. */ 290 + export function dataUrlToBlob( dataUrl: string ): Blob { 291 + const comma = dataUrl.indexOf( ',' ); 292 + const header = dataUrl.slice( 5, comma ); // after "data:" 293 + const mimeType = header.split( ';' )[ 0 ] || 'application/octet-stream'; 294 + const base64 = dataUrl.slice( comma + 1 ); 295 + const binary = atob( base64 ); 296 + const bytes = new Uint8Array( binary.length ); 297 + for ( let i = 0; i < binary.length; i++ ) { 298 + bytes[ i ] = binary.charCodeAt( i ); 299 + } 300 + return new Blob( [ bytes ], { type: mimeType } ); 301 + } 302 + 303 + export interface AssetSkeleton { 304 + blocks: BlockNode[]; 305 + /** `'cover'` when a held cover exists, else `null`. */ 306 + cover: string | null; 307 + } 308 + 309 + /** 310 + * Replace every held (`data:`) image URL in the tree — and the cover — with a short token, 311 + * returning the lightweight skeleton plus a `token → dataUrl` map. Body tokens are `a0`, 312 + * `a1`, … allocated depth-first; the cover token is the literal `'cover'`. External image 313 + * URLs and non-image blocks are left untouched. Pure; returns new objects. 314 + */ 315 + export function splitAssets( 316 + blocks: BlockNode[], 317 + coverDataUrl: string | null 318 + ): { skeleton: AssetSkeleton; assets: Record< string, string > } { 319 + const assets: Record< string, string > = {}; 320 + let n = 0; 321 + 322 + const walk = ( nodes: BlockNode[] ): BlockNode[] => 323 + nodes.map( ( node ) => { 324 + const url = node.attributes?.url; 325 + const held = IMAGE_BLOCKS.has( node.name ) && isDataUrl( url ); 326 + let attributes = node.attributes ? { ...node.attributes } : {}; 327 + if ( held ) { 328 + const token = `a${ n++ }`; 329 + assets[ token ] = url as string; 330 + attributes = { ...attributes, url: token }; 331 + } 332 + return { 333 + name: node.name, 334 + attributes, 335 + innerBlocks: walk( node.innerBlocks ?? [] ), 336 + }; 337 + } ); 338 + 339 + const skeletonBlocks = walk( blocks ); 340 + let cover: string | null = null; 341 + if ( isDataUrl( coverDataUrl ) ) { 342 + assets.cover = coverDataUrl; 343 + cover = 'cover'; 344 + } 345 + return { skeleton: { blocks: skeletonBlocks, cover }, assets }; 346 + } 347 + 348 + /** Inverse of `splitAssets`: swap each token back to its `data:` URL. Pure. */ 349 + export function mergeAssets( 350 + skeleton: AssetSkeleton, 351 + assets: Record< string, string > 352 + ): { blocks: BlockNode[]; coverDataUrl: string | null } { 353 + const walk = ( nodes: BlockNode[] ): BlockNode[] => 354 + nodes.map( ( node ) => { 355 + const url = node.attributes?.url; 356 + const restore = 357 + IMAGE_BLOCKS.has( node.name ) && typeof url === 'string' && url in assets; 358 + return { 359 + name: node.name, 360 + attributes: restore 361 + ? { ...node.attributes, url: assets[ url as string ] } 362 + : { ...( node.attributes ?? {} ) }, 363 + innerBlocks: walk( node.innerBlocks ?? [] ), 364 + }; 365 + } ); 366 + 367 + return { 368 + blocks: walk( skeleton.blocks ), 369 + coverDataUrl: skeleton.cover ? assets[ skeleton.cover ] ?? null : null, 370 + }; 371 + } 372 + ``` 373 + 374 + - [ ] **Step 4: Run test to verify it passes** 375 + 376 + Run: `npx vitest run src/lib/write/held-assets.test.ts` 377 + Expected: PASS. 378 + 379 + - [ ] **Step 5: Commit** 380 + 381 + ```bash 382 + git add src/lib/write/held-assets.ts src/lib/write/held-assets.test.ts 383 + git commit --no-gpg-sign -m "Add pure held-asset split/merge + data-URL helpers" 384 + ``` 385 + 386 + --- 387 + 388 + ### Task 3: Asset store (IndexedDB + in-memory) 389 + 390 + A tiny key→value store for held image bytes. The interface is what `draft-store` depends on; the in-memory implementation is what tests inject (jsdom has no IndexedDB) and the SSR/quota-failure fallback. 391 + 392 + **Files:** 393 + - Create: `src/lib/write/asset-store.ts` 394 + - Test: `src/lib/write/asset-store.test.ts` 395 + 396 + **Interfaces:** 397 + - Produces: 398 + - `export interface AssetStore { put( assets: Record< string, string > ): Promise< void >; getAll(): Promise< Record< string, string > >; clear(): Promise< void >; }` 399 + - `export function createMemoryAssetStore(): AssetStore` 400 + - `export function createIndexedDbAssetStore( dbName?: string, storeName?: string ): AssetStore` 401 + 402 + - [ ] **Step 1: Write the failing test** (covers the in-memory store; the IndexedDB impl is exercised via `draft-store` integration + manual smoke) 403 + 404 + ```ts 405 + // src/lib/write/asset-store.test.ts 406 + import { describe, it, expect } from 'vitest'; 407 + import { createMemoryAssetStore } from './asset-store'; 408 + 409 + describe( 'createMemoryAssetStore', () => { 410 + it( 'puts, merges, reads all, and clears', async () => { 411 + const store = createMemoryAssetStore(); 412 + await store.put( { a0: 'data:1' } ); 413 + await store.put( { a1: 'data:2' } ); 414 + expect( await store.getAll() ).toEqual( { a0: 'data:1', a1: 'data:2' } ); 415 + await store.clear(); 416 + expect( await store.getAll() ).toEqual( {} ); 417 + } ); 418 + 419 + it( 'replaces a key on re-put', async () => { 420 + const store = createMemoryAssetStore(); 421 + await store.put( { a0: 'data:1' } ); 422 + await store.put( { a0: 'data:CHANGED' } ); 423 + expect( await store.getAll() ).toEqual( { a0: 'data:CHANGED' } ); 424 + } ); 425 + } ); 426 + ``` 427 + 428 + - [ ] **Step 2: Run test to verify it fails** 429 + 430 + Run: `npx vitest run src/lib/write/asset-store.test.ts` 431 + Expected: FAIL — cannot resolve `./asset-store`. 432 + 433 + - [ ] **Step 3: Write the implementation** 434 + 435 + ```ts 436 + // src/lib/write/asset-store.ts 437 + 438 + /** 439 + * A small async key→value store for held image bytes (data: URLs) in the writing-first flow. 440 + * Held bytes can be large, so they live here (IndexedDB) rather than in localStorage with the 441 + * draft metadata. `put` MERGES (does not replace the whole store) so adding an image keeps the 442 + * earlier ones; `clear` empties everything (called after a successful publish or a discard). 443 + */ 444 + export interface AssetStore { 445 + put( assets: Record< string, string > ): Promise< void >; 446 + getAll(): Promise< Record< string, string > >; 447 + clear(): Promise< void >; 448 + } 449 + 450 + /** In-memory store: the test/SSR/quota-failure fallback. Survives nothing past a reload. */ 451 + export function createMemoryAssetStore(): AssetStore { 452 + let map: Record< string, string > = {}; 453 + return { 454 + async put( assets ) { 455 + map = { ...map, ...assets }; 456 + }, 457 + async getAll() { 458 + return { ...map }; 459 + }, 460 + async clear() { 461 + map = {}; 462 + }, 463 + }; 464 + } 465 + 466 + const DEFAULT_DB = 'skypress-write'; 467 + const DEFAULT_STORE = 'assets'; 468 + 469 + function openDb( dbName: string, storeName: string ): Promise< IDBDatabase > { 470 + return new Promise( ( resolve, reject ) => { 471 + const req = indexedDB.open( dbName, 1 ); 472 + req.onupgradeneeded = () => { 473 + if ( ! req.result.objectStoreNames.contains( storeName ) ) { 474 + req.result.createObjectStore( storeName ); 475 + } 476 + }; 477 + req.onsuccess = () => resolve( req.result ); 478 + req.onerror = () => reject( req.error ?? new Error( 'IndexedDB open failed' ) ); 479 + } ); 480 + } 481 + 482 + /** 483 + * IndexedDB-backed asset store. Each token is one record keyed by the token string. Falls back 484 + * to an in-memory store when IndexedDB is unavailable (e.g. SSR/tests) so callers never crash. 485 + */ 486 + export function createIndexedDbAssetStore( 487 + dbName: string = DEFAULT_DB, 488 + storeName: string = DEFAULT_STORE 489 + ): AssetStore { 490 + if ( typeof indexedDB === 'undefined' ) { 491 + return createMemoryAssetStore(); 492 + } 493 + 494 + const tx = async < T >( 495 + mode: IDBTransactionMode, 496 + run: ( store: IDBObjectStore ) => IDBRequest | void, 497 + read?: ( store: IDBObjectStore ) => IDBRequest< T > 498 + ): Promise< T | void > => { 499 + const db = await openDb( dbName, storeName ); 500 + return new Promise< T | void >( ( resolve, reject ) => { 501 + const transaction = db.transaction( storeName, mode ); 502 + const store = transaction.objectStore( storeName ); 503 + let readReq: IDBRequest< T > | undefined; 504 + if ( read ) { 505 + readReq = read( store ); 506 + } else { 507 + run( store ); 508 + } 509 + transaction.oncomplete = () => { 510 + db.close(); 511 + resolve( readReq ? readReq.result : undefined ); 512 + }; 513 + transaction.onerror = () => { 514 + db.close(); 515 + reject( transaction.error ?? new Error( 'IndexedDB transaction failed' ) ); 516 + }; 517 + } ); 518 + }; 519 + 520 + return { 521 + async put( assets ) { 522 + await tx( 'readwrite', ( store ) => { 523 + for ( const [ key, value ] of Object.entries( assets ) ) { 524 + store.put( value, key ); 525 + } 526 + } ); 527 + }, 528 + async getAll() { 529 + const db = await openDb( dbName, storeName ); 530 + return new Promise< Record< string, string > >( ( resolve, reject ) => { 531 + const transaction = db.transaction( storeName, 'readonly' ); 532 + const store = transaction.objectStore( storeName ); 533 + const keysReq = store.getAllKeys(); 534 + const valsReq = store.getAll(); 535 + transaction.oncomplete = () => { 536 + db.close(); 537 + const out: Record< string, string > = {}; 538 + ( keysReq.result as IDBValidKey[] ).forEach( ( k, i ) => { 539 + out[ String( k ) ] = valsReq.result[ i ] as string; 540 + } ); 541 + resolve( out ); 542 + }; 543 + transaction.onerror = () => { 544 + db.close(); 545 + reject( transaction.error ?? new Error( 'IndexedDB read failed' ) ); 546 + }; 547 + } ); 548 + }, 549 + async clear() { 550 + await tx( 'readwrite', ( store ) => store.clear() ); 551 + }, 552 + }; 553 + } 554 + ``` 555 + 556 + - [ ] **Step 4: Run test to verify it passes** 557 + 558 + Run: `npx vitest run src/lib/write/asset-store.test.ts` 559 + Expected: PASS. 560 + 561 + - [ ] **Step 5: Commit** 562 + 563 + ```bash 564 + git add src/lib/write/asset-store.ts src/lib/write/asset-store.test.ts 565 + git commit --no-gpg-sign -m "Add asset store (IndexedDB + in-memory) for held image bytes" 566 + ``` 567 + 568 + --- 569 + 570 + ### Task 4: Draft store 571 + 572 + Combine `localStorage` (title, lede, token-skeleton blocks, cover token, `publishIntent`) and an injected `AssetStore` (image bytes) into one save/load/clear API. This is what survives the OAuth redirect. 573 + 574 + **Files:** 575 + - Create: `src/lib/write/draft-store.ts` 576 + - Test: `src/lib/write/draft-store.test.ts` 577 + 578 + **Interfaces:** 579 + - Consumes: `splitAssets`, `mergeAssets`, `AssetSkeleton` from `./held-assets`; `AssetStore`, `createMemoryAssetStore`, `createIndexedDbAssetStore` from `./asset-store`; `BlockNode`. 580 + - Produces: 581 + - `export interface WriteDraft { title: string; lede: string; blocks: BlockNode[]; coverDataUrl: string | null }` 582 + - `export interface DraftStore { save( draft: WriteDraft ): Promise< void >; load(): Promise< WriteDraft | null >; clear(): Promise< void >; setPublishIntent(): void; consumePublishIntent(): boolean }` 583 + - `export function createDraftStore( opts?: { assets?: AssetStore; storage?: Storage } ): DraftStore` 584 + - localStorage keys: meta → `skypress:write:draft`; intent → `skypress:write:publish-intent`. 585 + 586 + - [ ] **Step 1: Write the failing test** 587 + 588 + ```ts 589 + // src/lib/write/draft-store.test.ts 590 + import { describe, it, expect, beforeEach } from 'vitest'; 591 + import { createDraftStore, type WriteDraft } from './draft-store'; 592 + import { createMemoryAssetStore } from './asset-store'; 593 + import type { BlockNode } from '../blocks/render'; 594 + 595 + const DATA = 'data:image/png;base64,QUFB'; 596 + 597 + function draft(): WriteDraft { 598 + return { 599 + title: 'Hello', 600 + lede: 'A lede', 601 + blocks: [ 602 + { name: 'core/paragraph', attributes: { content: 'hi' }, innerBlocks: [] }, 603 + { name: 'core/image', attributes: { url: DATA }, innerBlocks: [] }, 604 + ] as BlockNode[], 605 + coverDataUrl: DATA, 606 + }; 607 + } 608 + 609 + beforeEach( () => window.localStorage.clear() ); 610 + 611 + function newStore() { 612 + return createDraftStore( { assets: createMemoryAssetStore(), storage: window.localStorage } ); 613 + } 614 + 615 + describe( 'draft-store', () => { 616 + it( 'round-trips a draft including held image + cover bytes', async () => { 617 + const store = newStore(); 618 + await store.save( draft() ); 619 + // Image bytes must NOT sit in localStorage (only the token skeleton). 620 + expect( window.localStorage.getItem( 'skypress:write:draft' ) ).not.toContain( 'base64' ); 621 + const loaded = await store.load(); 622 + expect( loaded ).toEqual( draft() ); 623 + } ); 624 + 625 + it( 'returns null when nothing is saved', async () => { 626 + expect( await newStore().load() ).toBe( null ); 627 + } ); 628 + 629 + it( 'clear() removes the draft and its assets', async () => { 630 + const store = newStore(); 631 + await store.save( draft() ); 632 + await store.clear(); 633 + expect( await store.load() ).toBe( null ); 634 + } ); 635 + 636 + it( 'publish intent is one-shot (set, then consumed once)', () => { 637 + const store = newStore(); 638 + expect( store.consumePublishIntent() ).toBe( false ); 639 + store.setPublishIntent(); 640 + expect( store.consumePublishIntent() ).toBe( true ); 641 + expect( store.consumePublishIntent() ).toBe( false ); 642 + } ); 643 + } ); 644 + ``` 645 + 646 + - [ ] **Step 2: Run test to verify it fails** 647 + 648 + Run: `npx vitest run src/lib/write/draft-store.test.ts` 649 + Expected: FAIL — cannot resolve `./draft-store`. 650 + 651 + - [ ] **Step 3: Write the implementation** 652 + 653 + ```ts 654 + // src/lib/write/draft-store.ts 655 + import type { BlockNode } from '../blocks/render'; 656 + import { splitAssets, mergeAssets, type AssetSkeleton } from './held-assets'; 657 + import { 658 + createIndexedDbAssetStore, 659 + createMemoryAssetStore, 660 + type AssetStore, 661 + } from './asset-store'; 662 + 663 + const DRAFT_KEY = 'skypress:write:draft'; 664 + const INTENT_KEY = 'skypress:write:publish-intent'; 665 + 666 + /** The editor content the writing-first flow persists across the OAuth redirect. */ 667 + export interface WriteDraft { 668 + title: string; 669 + lede: string; 670 + blocks: BlockNode[]; 671 + coverDataUrl: string | null; 672 + } 673 + 674 + interface StoredMeta { 675 + title: string; 676 + lede: string; 677 + skeleton: AssetSkeleton; 678 + } 679 + 680 + export interface DraftStore { 681 + save( draft: WriteDraft ): Promise< void >; 682 + load(): Promise< WriteDraft | null >; 683 + clear(): Promise< void >; 684 + setPublishIntent(): void; 685 + /** Reads the intent flag and clears it — true at most once per set. */ 686 + consumePublishIntent(): boolean; 687 + } 688 + 689 + /** 690 + * Persist the writing-first draft so it survives the full-page OAuth redirect. Light metadata 691 + * and the token-skeletoned block tree go in `localStorage`; the heavy image bytes (data: URLs) 692 + * go in the injected `AssetStore` (IndexedDB in the browser). `setPublishIntent` records that 693 + * the writer hit Publish before the redirect, so the flow auto-resumes on return. 694 + */ 695 + export function createDraftStore( opts: { assets?: AssetStore; storage?: Storage } = {} ): DraftStore { 696 + const storage = opts.storage ?? window.localStorage; 697 + const assets = opts.assets ?? createIndexedDbAssetStore(); 698 + 699 + return { 700 + async save( draft ) { 701 + const { skeleton, assets: bytes } = splitAssets( draft.blocks, draft.coverDataUrl ); 702 + await assets.clear(); 703 + await assets.put( bytes ); 704 + const meta: StoredMeta = { title: draft.title, lede: draft.lede, skeleton }; 705 + storage.setItem( DRAFT_KEY, JSON.stringify( meta ) ); 706 + }, 707 + async load() { 708 + const raw = storage.getItem( DRAFT_KEY ); 709 + if ( ! raw ) { 710 + return null; 711 + } 712 + let meta: StoredMeta; 713 + try { 714 + meta = JSON.parse( raw ) as StoredMeta; 715 + } catch { 716 + return null; 717 + } 718 + const bytes = await assets.getAll(); 719 + const { blocks, coverDataUrl } = mergeAssets( meta.skeleton, bytes ); 720 + return { title: meta.title, lede: meta.lede, blocks, coverDataUrl }; 721 + }, 722 + async clear() { 723 + storage.removeItem( DRAFT_KEY ); 724 + await assets.clear(); 725 + }, 726 + setPublishIntent() { 727 + storage.setItem( INTENT_KEY, '1' ); 728 + }, 729 + consumePublishIntent() { 730 + const had = storage.getItem( INTENT_KEY ) === '1'; 731 + storage.removeItem( INTENT_KEY ); 732 + return had; 733 + }, 734 + }; 735 + } 736 + 737 + export { createMemoryAssetStore }; 738 + ``` 739 + 740 + - [ ] **Step 4: Run test to verify it passes** 741 + 742 + Run: `npx vitest run src/lib/write/draft-store.test.ts` 743 + Expected: PASS. 744 + 745 + - [ ] **Step 5: Commit** 746 + 747 + ```bash 748 + git add src/lib/write/draft-store.ts src/lib/write/draft-store.test.ts 749 + git commit --no-gpg-sign -m "Add draft store that survives the OAuth redirect" 750 + ``` 751 + 752 + --- 753 + 754 + ### Task 5: Upload held assets at publish 755 + 756 + After sign-in, turn the held `data:` images (+ cover) into committed PDS blobs and a publish-ready block tree. Reuses `attachBlobRefs` so the persisted block shape matches the eager path exactly. 757 + 758 + **Files:** 759 + - Create: `src/lib/write/upload-held.ts` 760 + - Test: `src/lib/write/upload-held.test.ts` 761 + 762 + **Interfaces:** 763 + - Consumes: `isDataUrl`, `dataUrlToBlob` from `./held-assets`; `attachBlobRefs`, `buildGetBlobUrl`, `BlobRefJson`, `BlobUpload` from `../media/blob`; `BlockNode`; `Agent` from `@atproto/api`. 764 + - Produces: 765 + - `export interface PreparedPublishContent { blocks: BlockNode[]; coverImage?: BlobRefJson }` 766 + - `export async function uploadHeldAssets( agent: Agent, input: { blocks: BlockNode[]; coverDataUrl: string | null; did: string; pdsUrl: string } ): Promise< PreparedPublishContent >` 767 + 768 + - [ ] **Step 1: Write the failing test** 769 + 770 + ```ts 771 + // src/lib/write/upload-held.test.ts 772 + import { describe, it, expect, vi } from 'vitest'; 773 + import type { Agent } from '@atproto/api'; 774 + import { uploadHeldAssets } from './upload-held'; 775 + import type { BlockNode } from '../blocks/render'; 776 + 777 + const DATA = 'data:image/png;base64,QUFB'; // "AAA" 778 + 779 + function fakeAgent() { 780 + const uploadBlob = vi.fn( async ( _file: Blob ) => ( { 781 + data: { blob: { ref: { toString: () => 'bafyCID' }, mimeType: 'image/png', size: 3 } }, 782 + } ) ); 783 + return { agent: { uploadBlob } as unknown as Agent, uploadBlob }; 784 + } 785 + 786 + describe( 'uploadHeldAssets', () => { 787 + it( 'uploads each held image, attaches skypressBlob + a getBlob url, leaves externals alone', async () => { 788 + const { agent, uploadBlob } = fakeAgent(); 789 + const blocks: BlockNode[] = [ 790 + { name: 'core/image', attributes: { url: DATA }, innerBlocks: [] }, 791 + { name: 'core/image', attributes: { url: 'https://ext/x.png' }, innerBlocks: [] }, 792 + ]; 793 + 794 + const out = await uploadHeldAssets( agent, { 795 + blocks, 796 + coverDataUrl: null, 797 + did: 'did:plc:me', 798 + pdsUrl: 'https://pds.example.com', 799 + } ); 800 + 801 + expect( uploadBlob ).toHaveBeenCalledTimes( 1 ); 802 + expect( out.blocks[ 0 ].attributes!.skypressBlob ).toEqual( { 803 + $type: 'blob', 804 + ref: { $link: 'bafyCID' }, 805 + mimeType: 'image/png', 806 + size: 3, 807 + } ); 808 + expect( out.blocks[ 0 ].attributes!.url ).toBe( 809 + 'https://pds.example.com/xrpc/com.atproto.sync.getBlob?did=did%3Aplc%3Ame&cid=bafyCID' 810 + ); 811 + expect( out.blocks[ 1 ].attributes!.url ).toBe( 'https://ext/x.png' ); 812 + expect( out.coverImage ).toBeUndefined(); 813 + } ); 814 + 815 + it( 'uploads a held cover into a BlobRefJson', async () => { 816 + const { agent } = fakeAgent(); 817 + const out = await uploadHeldAssets( agent, { 818 + blocks: [], 819 + coverDataUrl: DATA, 820 + did: 'did:plc:me', 821 + pdsUrl: 'https://pds.example.com', 822 + } ); 823 + expect( out.coverImage ).toEqual( { 824 + $type: 'blob', 825 + ref: { $link: 'bafyCID' }, 826 + mimeType: 'image/png', 827 + size: 3, 828 + } ); 829 + } ); 830 + } ); 831 + ``` 832 + 833 + - [ ] **Step 2: Run test to verify it fails** 834 + 835 + Run: `npx vitest run src/lib/write/upload-held.test.ts` 836 + Expected: FAIL — cannot resolve `./upload-held`. 837 + 838 + - [ ] **Step 3: Write the implementation** 839 + 840 + ```ts 841 + // src/lib/write/upload-held.ts 842 + import type { Agent } from '@atproto/api'; 843 + import type { BlockNode } from '../blocks/render'; 844 + import { 845 + attachBlobRefs, 846 + buildGetBlobUrl, 847 + type BlobRefJson, 848 + type BlobUpload, 849 + } from '../media/blob'; 850 + import { isDataUrl, dataUrlToBlob } from './held-assets'; 851 + 852 + export interface PreparedPublishContent { 853 + blocks: BlockNode[]; 854 + coverImage?: BlobRefJson; 855 + } 856 + 857 + /** Upload one held `data:` URL to the PDS and return its portable blob ref + getBlob URL. */ 858 + async function uploadOne( 859 + agent: Agent, 860 + dataUrl: string, 861 + did: string, 862 + pdsUrl: string 863 + ): Promise< BlobUpload > { 864 + const blob = dataUrlToBlob( dataUrl ); 865 + const res = await agent.uploadBlob( blob, { encoding: blob.type } ); 866 + const out = res.data.blob; 867 + const cid = out.ref.toString(); 868 + return { 869 + ref: { $type: 'blob', ref: { $link: cid }, mimeType: out.mimeType, size: out.size }, 870 + url: buildGetBlobUrl( pdsUrl, did, cid ), 871 + }; 872 + } 873 + 874 + /** 875 + * Publish-time bridge for the writing-first flow: walk the block tree, upload every held 876 + * (`data:`) image to the writer's PDS, and rewrite those blocks via `attachBlobRefs` so they 877 + * carry `skypressBlob` + a portable getBlob URL (byte-identical to the eager path). External 878 + * image URLs are left untouched. A held cover is uploaded into a `BlobRefJson` for the document 879 + * record. Each distinct data URL uploads once. 880 + */ 881 + export async function uploadHeldAssets( 882 + agent: Agent, 883 + input: { blocks: BlockNode[]; coverDataUrl: string | null; did: string; pdsUrl: string } 884 + ): Promise< PreparedPublishContent > { 885 + const { blocks, coverDataUrl, did, pdsUrl } = input; 886 + 887 + // Collect every distinct held image URL in the tree (depth-first), upload once each. 888 + const registry = new Map< string, BlobUpload >(); 889 + const collect = ( nodes: BlockNode[] ): string[] => 890 + nodes.flatMap( ( node ) => { 891 + const url = node.attributes?.url; 892 + const here = node.name === 'core/image' && isDataUrl( url ) ? [ url as string ] : []; 893 + return [ ...here, ...collect( node.innerBlocks ?? [] ) ]; 894 + } ); 895 + 896 + for ( const url of new Set( collect( blocks ) ) ) { 897 + registry.set( url, await uploadOne( agent, url, did, pdsUrl ) ); 898 + } 899 + 900 + const prepared = attachBlobRefs( blocks, ( url ) => registry.get( url ) ); 901 + 902 + let coverImage: BlobRefJson | undefined; 903 + if ( isDataUrl( coverDataUrl ) ) { 904 + coverImage = ( await uploadOne( agent, coverDataUrl, did, pdsUrl ) ).ref; 905 + } 906 + 907 + return { blocks: prepared, coverImage }; 908 + } 909 + ``` 910 + 911 + - [ ] **Step 4: Run test to verify it passes** 912 + 913 + Run: `npx vitest run src/lib/write/upload-held.test.ts` 914 + Expected: PASS. 915 + 916 + - [ ] **Step 5: Commit** 917 + 918 + ```bash 919 + git add src/lib/write/upload-held.ts src/lib/write/upload-held.test.ts 920 + git commit --no-gpg-sign -m "Upload held images + cover to the PDS at publish time" 921 + ``` 922 + 923 + --- 924 + 925 + ### Task 6: WritePublishFlow component 926 + 927 + The publish stepper. Given a signed-in agent and the writer's publications, it branches: zero → inline `PublicationForm`; one → confirm; many → pick then confirm. The confirm step discloses the public Bluesky post (and notified mentions) and enforces the 300-grapheme limit. On confirm it uploads held assets then calls `publish()`. 928 + 929 + **Files:** 930 + - Create: `src/components/WritePublishFlow.tsx` 931 + - Test: `src/components/WritePublishFlow.test.tsx` 932 + 933 + **Interfaces:** 934 + - Consumes: `uploadHeldAssets` from `../lib/write/upload-held`; `publish`, `Identity` from `../lib/publish/publisher`; `Publication` from `../lib/publish/publications`; `computePostPreview` from `./PublishPanel`; `PublicationForm` from `./PublicationForm`; `BlockNode`; `Agent`. 935 + - Produces: 936 + - `interface Props { agent: Agent; identity: Identity; pdsUrl: string; blocks: BlockNode[]; coverDataUrl: string | null; title: string; description: string; publications: Publication[] | null; onReloadPublications: () => void; onPublished: ( result: { articleUrl: string } ) => void; onCancel: () => void; }` 937 + - `export default function WritePublishFlow( props: Props )` 938 + 939 + - [ ] **Step 1: Write the failing test** 940 + 941 + ```tsx 942 + // src/components/WritePublishFlow.test.tsx 943 + import { describe, it, expect, vi, beforeEach } from 'vitest'; 944 + import { act, createElement } from 'react'; 945 + import { createRoot } from 'react-dom/client'; 946 + import type { Agent } from '@atproto/api'; 947 + 948 + ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; 949 + 950 + const { publish, uploadHeldAssets } = vi.hoisted( () => ( { 951 + publish: vi.fn( async () => ( { articleUrl: 'https://x/a' } ) ), 952 + uploadHeldAssets: vi.fn( async () => ( { blocks: [], coverImage: undefined } ) ), 953 + } ) ); 954 + vi.mock( '../lib/publish/publisher', () => ( { publish } ) ); 955 + vi.mock( '../lib/write/upload-held', () => ( { uploadHeldAssets } ) ); 956 + 957 + import WritePublishFlow from './WritePublishFlow'; 958 + import type { BlockNode } from '../lib/blocks/render'; 959 + 960 + const PUB = ( uri: string, name: string ) => ( { 961 + uri, cid: 'cid', rkey: uri.split( '/' ).pop()!, slug: name, name, 962 + } ); 963 + const BLOCKS: BlockNode[] = [ { name: 'core/paragraph', attributes: { content: 'Body' }, innerBlocks: [] } ]; 964 + 965 + function mount( publications: unknown ) { 966 + const onPublished = vi.fn(); 967 + const container = document.createElement( 'div' ); 968 + document.body.appendChild( container ); 969 + const root = createRoot( container ); 970 + act( () => { 971 + root.render( 972 + createElement( WritePublishFlow, { 973 + agent: {} as Agent, 974 + identity: { did: 'did:plc:me', handle: 'me.test' }, 975 + pdsUrl: 'https://pds', 976 + blocks: BLOCKS, 977 + coverDataUrl: null, 978 + title: 'Title', 979 + description: 'Lede', 980 + publications, 981 + onReloadPublications: vi.fn(), 982 + onPublished, 983 + onCancel: vi.fn(), 984 + } as never ) 985 + ); 986 + } ); 987 + return { container, root, onPublished }; 988 + } 989 + 990 + beforeEach( () => { 991 + publish.mockClear(); 992 + uploadHeldAssets.mockClear(); 993 + } ); 994 + 995 + describe( 'WritePublishFlow', () => { 996 + it( 'single publication: shows a confirm naming it, never auto-publishes', () => { 997 + const { container } = mount( [ PUB( 'at://me/site.standard.publication/p1', 'Solo' ) ] ); 998 + expect( container.textContent ).toContain( 'Solo' ); 999 + expect( container.textContent?.toLowerCase() ).toContain( 'bluesky' ); 1000 + expect( publish ).not.toHaveBeenCalled(); 1001 + } ); 1002 + 1003 + it( 'confirming uploads held assets then publishes', async () => { 1004 + const { container, onPublished } = mount( [ PUB( 'at://me/site.standard.publication/p1', 'Solo' ) ] ); 1005 + const confirm = [ ...container.querySelectorAll( 'button' ) ].find( ( b ) => 1006 + /post to bluesky/i.test( b.textContent ?? '' ) 1007 + )!; 1008 + await act( async () => confirm.dispatchEvent( new MouseEvent( 'click', { bubbles: true } ) ) ); 1009 + expect( uploadHeldAssets ).toHaveBeenCalledTimes( 1 ); 1010 + expect( publish ).toHaveBeenCalledTimes( 1 ); 1011 + expect( onPublished ).toHaveBeenCalledWith( { articleUrl: 'https://x/a' } ); 1012 + } ); 1013 + 1014 + it( 'zero publications: renders the inline create-publication form', () => { 1015 + const { container } = mount( [] ); 1016 + expect( container.textContent ).toContain( 'New publication' ); 1017 + } ); 1018 + 1019 + it( 'many publications: renders a picker listing each', () => { 1020 + const { container } = mount( [ 1021 + PUB( 'at://me/site.standard.publication/p1', 'One' ), 1022 + PUB( 'at://me/site.standard.publication/p2', 'Two' ), 1023 + ] ); 1024 + expect( container.querySelector( 'select' ) ).not.toBe( null ); 1025 + expect( container.textContent ).toContain( 'One' ); 1026 + expect( container.textContent ).toContain( 'Two' ); 1027 + } ); 1028 + } ); 1029 + ``` 1030 + 1031 + - [ ] **Step 2: Run test to verify it fails** 1032 + 1033 + Run: `npx vitest run src/components/WritePublishFlow.test.tsx` 1034 + Expected: FAIL — cannot resolve `./WritePublishFlow`. 1035 + 1036 + - [ ] **Step 3: Write the implementation** 1037 + 1038 + ```tsx 1039 + // src/components/WritePublishFlow.tsx 1040 + import { useMemo, useState } from 'react'; 1041 + import type { Agent } from '@atproto/api'; 1042 + import { publish, type Identity } from '../lib/publish/publisher'; 1043 + import type { Publication } from '../lib/publish/publications'; 1044 + import { computePostPreview } from './PublishPanel'; 1045 + import PublicationForm from './PublicationForm'; 1046 + import { uploadHeldAssets } from '../lib/write/upload-held'; 1047 + import type { BlockNode } from '../lib/blocks/render'; 1048 + 1049 + interface Props { 1050 + agent: Agent; 1051 + identity: Identity; 1052 + pdsUrl: string; 1053 + blocks: BlockNode[]; 1054 + coverDataUrl: string | null; 1055 + title: string; 1056 + description: string; 1057 + /** `null` while still loading; `[]` when the writer has none yet. */ 1058 + publications: Publication[] | null; 1059 + /** Ask the parent to re-list publications (after an inline create). */ 1060 + onReloadPublications: () => void; 1061 + onPublished: ( result: { articleUrl: string } ) => void; 1062 + onCancel: () => void; 1063 + } 1064 + 1065 + type Phase = 'pick' | 'working' | 'error'; 1066 + 1067 + /** 1068 + * The writing-first publish stepper (design 2026-06-17). Branches on how many publications the 1069 + * writer owns: zero → inline create; one → confirm; many → pick then confirm. The confirm step 1070 + * always discloses the public Bluesky post (brief §10) and blocks an over-limit post. On confirm 1071 + * it uploads the held images/cover, then reuses `publish()` (document + Bluesky post). 1072 + */ 1073 + export default function WritePublishFlow( { 1074 + agent, 1075 + identity, 1076 + pdsUrl, 1077 + blocks, 1078 + coverDataUrl, 1079 + title, 1080 + description, 1081 + publications, 1082 + onReloadPublications, 1083 + onPublished, 1084 + onCancel, 1085 + }: Props ) { 1086 + const pubs = publications ?? []; 1087 + const [ targetUri, setTargetUri ] = useState( () => pubs[ 0 ]?.uri ?? '' ); 1088 + const [ phase, setPhase ] = useState< Phase >( 'pick' ); 1089 + const [ error, setError ] = useState< string | null >( null ); 1090 + 1091 + const target = pubs.find( ( p ) => p.uri === targetUri ) ?? pubs[ 0 ]; 1092 + 1093 + const preview = useMemo( 1094 + () => 1095 + target 1096 + ? computePostPreview( { 1097 + title, 1098 + lede: description, 1099 + blocks, 1100 + handle: identity.handle ?? identity.did, 1101 + slug: target.slug, 1102 + } ) 1103 + : null, 1104 + [ target, title, description, blocks, identity ] 1105 + ); 1106 + 1107 + if ( publications === null ) { 1108 + return ( 1109 + <section className="writeflow" aria-label="Publish"> 1110 + <p className="writeflow__status">Loading your publications…</p> 1111 + </section> 1112 + ); 1113 + } 1114 + 1115 + // Zero publications → inline "create your first publication". 1116 + if ( pubs.length === 0 ) { 1117 + return ( 1118 + <section className="writeflow" aria-label="Create your first publication"> 1119 + <p className="writeflow__lede"> 1120 + You don't have a publication yet — create one to publish into. 1121 + </p> 1122 + <PublicationForm 1123 + agent={ agent } 1124 + did={ identity.did } 1125 + pdsUrl={ pdsUrl } 1126 + handle={ identity.handle ?? identity.did } 1127 + onSaved={ ( pub ) => { 1128 + setTargetUri( pub.uri ); 1129 + onReloadPublications(); 1130 + } } 1131 + onCancel={ onCancel } 1132 + /> 1133 + </section> 1134 + ); 1135 + } 1136 + 1137 + async function run() { 1138 + if ( ! target ) { 1139 + return; 1140 + } 1141 + setPhase( 'working' ); 1142 + setError( null ); 1143 + try { 1144 + const prepared = await uploadHeldAssets( agent, { 1145 + blocks, 1146 + coverDataUrl, 1147 + did: identity.did, 1148 + pdsUrl, 1149 + } ); 1150 + const res = await publish( agent, identity, { 1151 + title: title.trim(), 1152 + description, 1153 + blocks: prepared.blocks, 1154 + publicationUri: target.uri, 1155 + publicationCid: target.cid, 1156 + publicationSlug: target.slug, 1157 + coverImage: prepared.coverImage, 1158 + } ); 1159 + onPublished( { articleUrl: res.articleUrl } ); 1160 + } catch ( err ) { 1161 + setError( err instanceof Error ? err.message : String( err ) ); 1162 + setPhase( 'error' ); 1163 + } 1164 + } 1165 + 1166 + const overLimit = Boolean( preview?.overLimit ); 1167 + 1168 + return ( 1169 + <section className="writeflow" aria-label="Publish"> 1170 + { pubs.length > 1 && ( 1171 + <label className="writeflow__target"> 1172 + <span>Publish to</span> 1173 + <select 1174 + value={ target?.uri ?? '' } 1175 + onChange={ ( e ) => setTargetUri( e.target.value ) } 1176 + disabled={ phase === 'working' } 1177 + > 1178 + { pubs.map( ( p ) => ( 1179 + <option key={ p.uri } value={ p.uri }> 1180 + { p.name } 1181 + </option> 1182 + ) ) } 1183 + </select> 1184 + </label> 1185 + ) } 1186 + 1187 + <p className="writeflow__warning"> 1188 + Publishing saves this article to <strong>your PDS</strong> and also creates a{ ' ' } 1189 + <strong>public Bluesky post</strong> linking to it 1190 + { target ? <> in <strong>{ target.name }</strong></> : null }. Everyone following you 1191 + will see it. 1192 + { preview && preview.handles.length > 0 && ( 1193 + <> It will notify <strong>{ preview.handles.join( ', ' ) }</strong>.</> 1194 + ) } 1195 + </p> 1196 + 1197 + { overLimit && ( 1198 + <p className="writeflow__count" aria-live="polite"> 1199 + Bluesky post: { preview!.graphemes }/300 — too long to publish; shorten the 1200 + subtitle or remove a mention. 1201 + </p> 1202 + ) } 1203 + 1204 + { phase === 'working' ? ( 1205 + <p className="writeflow__status">Publishing…</p> 1206 + ) : ( 1207 + <div className="writeflow__actions"> 1208 + <button 1209 + type="button" 1210 + className="writeflow__publish" 1211 + disabled={ overLimit || ! target } 1212 + onClick={ () => void run() } 1213 + > 1214 + Publish &amp; post to Bluesky 1215 + </button> 1216 + <button type="button" className="writeflow__cancel" onClick={ onCancel }> 1217 + Keep editing 1218 + </button> 1219 + </div> 1220 + ) } 1221 + 1222 + { phase === 'error' && error && ( 1223 + <p className="writeflow__error" role="alert"> 1224 + Publish failed: { error } 1225 + </p> 1226 + ) } 1227 + </section> 1228 + ); 1229 + } 1230 + ``` 1231 + 1232 + - [ ] **Step 4: Run test to verify it passes** 1233 + 1234 + Run: `npx vitest run src/components/WritePublishFlow.test.tsx` 1235 + Expected: PASS (all four). 1236 + 1237 + - [ ] **Step 5: Commit** 1238 + 1239 + ```bash 1240 + git add src/components/WritePublishFlow.tsx src/components/WritePublishFlow.test.tsx 1241 + git commit --no-gpg-sign -m "Add WritePublishFlow stepper (branch, confirm, upload, publish)" 1242 + ``` 1243 + 1244 + --- 1245 + 1246 + ### Task 7: SignInPanel + AccountPill 1247 + 1248 + The two top-right corner states. `SignInPanel` is the signed-out handle entry (with the link-out to Bluesky signup); `AccountPill` is the signed-in avatar/handle menu. Both are presentational — the island wires the behavior. 1249 + 1250 + **Files:** 1251 + - Create: `src/components/SignInPanel.tsx` 1252 + - Create: `src/components/AccountPill.tsx` 1253 + - Test: `src/components/SignInPanel.test.tsx` 1254 + - Test: `src/components/AccountPill.test.tsx` 1255 + 1256 + **Interfaces:** 1257 + - `SignInPanel`: `interface Props { forPublish: boolean; error: string | null; onSubmit: ( value: string ) => void; onCancel?: () => void; }` → `export default function SignInPanel( props: Props )` 1258 + - `AccountPill`: `interface Props { displayName: string; handle: string | null; avatar: string | null; onSignOut: () => void; }` → `export default function AccountPill( props: Props )` (the Profile link is built from `authorPath( handle )`, omitted when no handle). 1259 + 1260 + - [ ] **Step 1: Write the failing tests** 1261 + 1262 + ```tsx 1263 + // src/components/SignInPanel.test.tsx 1264 + import { describe, it, expect, vi } from 'vitest'; 1265 + import { act, createElement } from 'react'; 1266 + import { createRoot } from 'react-dom/client'; 1267 + 1268 + ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; 1269 + import SignInPanel from './SignInPanel'; 1270 + 1271 + function mount( props: Record< string, unknown > ) { 1272 + const container = document.createElement( 'div' ); 1273 + document.body.appendChild( container ); 1274 + act( () => createRoot( container ).render( createElement( SignInPanel, props as never ) ) ); 1275 + return container; 1276 + } 1277 + 1278 + describe( 'SignInPanel', () => { 1279 + it( 'for-publish variant frames the CTA around publishing and links out to signup', () => { 1280 + const c = mount( { forPublish: true, error: null, onSubmit: vi.fn() } ); 1281 + expect( c.textContent?.toLowerCase() ).toContain( 'publish' ); 1282 + const signup = c.querySelector( 'a[href*="bsky.app"]' ); 1283 + expect( signup ).not.toBe( null ); 1284 + } ); 1285 + 1286 + it( 'submits the typed handle', () => { 1287 + const onSubmit = vi.fn(); 1288 + const c = mount( { forPublish: false, error: null, onSubmit } ); 1289 + const input = c.querySelector( 'input' )!; 1290 + const form = c.querySelector( 'form' )!; 1291 + act( () => { 1292 + ( input as HTMLInputElement ).value = 'alice.bsky.social'; 1293 + input.dispatchEvent( new Event( 'input', { bubbles: true } ) ); 1294 + } ); 1295 + act( () => form.dispatchEvent( new Event( 'submit', { bubbles: true, cancelable: true } ) ) ); 1296 + expect( onSubmit ).toHaveBeenCalledWith( 'alice.bsky.social' ); 1297 + } ); 1298 + 1299 + it( 'shows an error when provided', () => { 1300 + const c = mount( { forPublish: false, error: 'Bad handle', onSubmit: vi.fn() } ); 1301 + expect( c.textContent ).toContain( 'Bad handle' ); 1302 + } ); 1303 + } ); 1304 + ``` 1305 + 1306 + ```tsx 1307 + // src/components/AccountPill.test.tsx 1308 + import { describe, it, expect, vi } from 'vitest'; 1309 + import { act, createElement } from 'react'; 1310 + import { createRoot } from 'react-dom/client'; 1311 + 1312 + ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; 1313 + import AccountPill from './AccountPill'; 1314 + 1315 + function mount( props: Record< string, unknown > ) { 1316 + const container = document.createElement( 'div' ); 1317 + document.body.appendChild( container ); 1318 + act( () => createRoot( container ).render( createElement( AccountPill, props as never ) ) ); 1319 + return container; 1320 + } 1321 + 1322 + describe( 'AccountPill', () => { 1323 + it( 'shows the handle and a Manage-publications + Profile + Sign out menu', () => { 1324 + const c = mount( { 1325 + displayName: 'Alice', handle: 'alice.test', avatar: null, onSignOut: vi.fn(), 1326 + } ); 1327 + expect( c.textContent ).toContain( 'alice.test' ); 1328 + expect( c.querySelector( 'a[href="/dashboard"]' ) ).not.toBe( null ); 1329 + expect( c.querySelector( 'a[href="/@alice.test"]' ) ).not.toBe( null ); 1330 + expect( c.textContent?.toLowerCase() ).toContain( 'sign out' ); 1331 + } ); 1332 + 1333 + it( 'invokes onSignOut when Sign out is clicked', () => { 1334 + const onSignOut = vi.fn(); 1335 + const c = mount( { displayName: 'Alice', handle: 'alice.test', avatar: null, onSignOut } ); 1336 + const btn = [ ...c.querySelectorAll( 'button' ) ].find( ( b ) => /sign out/i.test( b.textContent ?? '' ) )!; 1337 + act( () => btn.dispatchEvent( new MouseEvent( 'click', { bubbles: true } ) ) ); 1338 + expect( onSignOut ).toHaveBeenCalledTimes( 1 ); 1339 + } ); 1340 + 1341 + it( 'omits the Profile link when no handle is known', () => { 1342 + const c = mount( { displayName: 'did:plc:me', handle: null, avatar: null, onSignOut: vi.fn() } ); 1343 + expect( c.querySelector( 'a[href^="/@"]' ) ).toBe( null ); 1344 + } ); 1345 + } ); 1346 + ``` 1347 + 1348 + - [ ] **Step 2: Run tests to verify they fail** 1349 + 1350 + Run: `npx vitest run src/components/SignInPanel.test.tsx src/components/AccountPill.test.tsx` 1351 + Expected: FAIL — cannot resolve the components. 1352 + 1353 + - [ ] **Step 3: Write the implementations** 1354 + 1355 + ```tsx 1356 + // src/components/SignInPanel.tsx 1357 + import { useState } from 'react'; 1358 + 1359 + interface Props { 1360 + /** True when opened from "Publish" — the copy promises a publish on return. */ 1361 + forPublish: boolean; 1362 + error: string | null; 1363 + onSubmit: ( value: string ) => void; 1364 + onCancel?: () => void; 1365 + } 1366 + 1367 + /** 1368 + * Signed-out handle entry for the writing-first flow. OAuth is sign-in only, so this never 1369 + * creates an account — it links out to Bluesky's hosted signup instead (brief guardrail). The 1370 + * caller persists the draft + sets publish intent before the redirect. 1371 + */ 1372 + export default function SignInPanel( { forPublish, error, onSubmit, onCancel }: Props ) { 1373 + const [ value, setValue ] = useState( '' ); 1374 + 1375 + return ( 1376 + <form 1377 + className="signin-panel" 1378 + onSubmit={ ( event ) => { 1379 + event.preventDefault(); 1380 + onSubmit( value.trim() ); 1381 + } } 1382 + > 1383 + <h2 className="signin-panel__title"> 1384 + { forPublish ? 'Sign in to publish' : 'Sign in' } 1385 + </h2> 1386 + <p className="signin-panel__lede"> 1387 + { forPublish 1388 + ? "Your draft is saved. Sign in and we'll pick up right where you left off and publish it." 1389 + : 'Use your existing Bluesky / AT Protocol identity. Your work stays in your own account.' } 1390 + </p> 1391 + <label className="signin-panel__label" htmlFor="write-handle"> 1392 + Your handle, DID, or PDS URL 1393 + </label> 1394 + <input 1395 + id="write-handle" 1396 + className="signin-panel__input" 1397 + name="handle" 1398 + autoComplete="username" 1399 + autoCapitalize="none" 1400 + autoCorrect="off" 1401 + spellCheck={ false } 1402 + placeholder="alice.bsky.social" 1403 + value={ value } 1404 + onChange={ ( event ) => setValue( event.target.value ) } 1405 + /> 1406 + <div className="signin-panel__actions"> 1407 + <button className="signin-panel__submit" type="submit"> 1408 + Sign in with AT Protocol 1409 + </button> 1410 + { onCancel && ( 1411 + <button className="signin-panel__cancel" type="button" onClick={ onCancel }> 1412 + Cancel 1413 + </button> 1414 + ) } 1415 + </div> 1416 + { error && ( 1417 + <p className="signin-panel__error" role="alert"> 1418 + { error } 1419 + </p> 1420 + ) } 1421 + <p className="signin-panel__signup"> 1422 + Need an account?{ ' ' } 1423 + <a href="https://bsky.app" target="_blank" rel="noreferrer noopener"> 1424 + Create one on Bluesky → 1425 + </a> 1426 + </p> 1427 + </form> 1428 + ); 1429 + } 1430 + ``` 1431 + 1432 + ```tsx 1433 + // src/components/AccountPill.tsx 1434 + import { useEffect, useRef, useState } from 'react'; 1435 + import { authorPath } from '../lib/auth/profile'; 1436 + 1437 + interface Props { 1438 + displayName: string; 1439 + handle: string | null; 1440 + avatar: string | null; 1441 + onSignOut: () => void; 1442 + } 1443 + 1444 + /** 1445 + * Signed-in account pill for the writing-first page: avatar + handle, opening a menu with 1446 + * Manage publications (→ existing dashboard), Profile (public author page, omitted when no 1447 + * handle), and Sign out. Management lives on the dashboard by design — the editor stays the 1448 + * focus here (design 2026-06-17, Q5). 1449 + */ 1450 + export default function AccountPill( { displayName, handle, avatar, onSignOut }: Props ) { 1451 + const [ open, setOpen ] = useState( false ); 1452 + const [ avatarOk, setAvatarOk ] = useState( true ); 1453 + const rootRef = useRef< HTMLDivElement >( null ); 1454 + 1455 + useEffect( () => { 1456 + if ( ! open ) { 1457 + return; 1458 + } 1459 + function onDown( event: MouseEvent ) { 1460 + if ( rootRef.current && ! rootRef.current.contains( event.target as Node ) ) { 1461 + setOpen( false ); 1462 + } 1463 + } 1464 + document.addEventListener( 'mousedown', onDown ); 1465 + return () => document.removeEventListener( 'mousedown', onDown ); 1466 + }, [ open ] ); 1467 + 1468 + const profileHref = authorPath( handle ); 1469 + 1470 + return ( 1471 + <div className="account-pill" ref={ rootRef }> 1472 + <button 1473 + type="button" 1474 + className="account-pill__trigger" 1475 + aria-haspopup="menu" 1476 + aria-expanded={ open } 1477 + onClick={ () => setOpen( ( v ) => ! v ) } 1478 + > 1479 + { avatar && avatarOk ? ( 1480 + <img 1481 + className="account-pill__avatar" 1482 + src={ avatar } 1483 + alt="" 1484 + width={ 28 } 1485 + height={ 28 } 1486 + onError={ () => setAvatarOk( false ) } 1487 + /> 1488 + ) : ( 1489 + <span className="account-pill__avatar account-pill__avatar--fallback" aria-hidden="true"> 1490 + { displayName.charAt( 0 ).toUpperCase() } 1491 + </span> 1492 + ) } 1493 + <span className="account-pill__handle"> 1494 + { handle ? `@${ handle }` : displayName } 1495 + </span> 1496 + </button> 1497 + { open && ( 1498 + <div className="account-pill__menu" role="menu"> 1499 + <a className="account-pill__item" role="menuitem" href="/dashboard"> 1500 + Manage publications 1501 + </a> 1502 + { profileHref && ( 1503 + <a className="account-pill__item" role="menuitem" href={ profileHref }> 1504 + Profile 1505 + </a> 1506 + ) } 1507 + <button 1508 + type="button" 1509 + className="account-pill__item account-pill__item--button" 1510 + role="menuitem" 1511 + onClick={ onSignOut } 1512 + > 1513 + Sign out 1514 + </button> 1515 + </div> 1516 + ) } 1517 + </div> 1518 + ); 1519 + } 1520 + ``` 1521 + 1522 + - [ ] **Step 4: Run tests to verify they pass** 1523 + 1524 + Run: `npx vitest run src/components/SignInPanel.test.tsx src/components/AccountPill.test.tsx` 1525 + Expected: PASS. 1526 + 1527 + - [ ] **Step 5: Commit** 1528 + 1529 + ```bash 1530 + git add src/components/SignInPanel.tsx src/components/AccountPill.tsx \ 1531 + src/components/SignInPanel.test.tsx src/components/AccountPill.test.tsx 1532 + git commit --no-gpg-sign -m "Add signed-out sign-in panel and signed-in account pill" 1533 + ``` 1534 + 1535 + --- 1536 + 1537 + ### Task 8: WriteStudio island 1538 + 1539 + The island that ties it together: editor for every auth status, deferred media, draft persistence + auto-restore, the Publish action (persist + intent + redirect when signed out), and the resume-after-redirect that auto-opens `WritePublishFlow`. 1540 + 1541 + **Files:** 1542 + - Create: `src/components/WriteStudio.tsx` 1543 + - Test: `src/components/WriteStudio.test.tsx` 1544 + 1545 + **Interfaces:** 1546 + - Consumes: `AuthProvider`, `useAuth`; `SkyEditor`; `AppBar`; `PublishedPill`; `CoverImagePicker`; `SignInPanel`; `AccountPill`; `WritePublishFlow`; `createDeferredMediaUpload`; `createDraftStore`, `WriteDraft`; `listPublications`, `Publication`; `normalizeBlocks` from `../lib/publish/records`; `validateCoverFile`, `CoverUpload` from `../lib/media/cover`; `displayNameFor` from `../lib/auth/profile`; `BlockNode`. 1547 + - Produces: `export default function WriteStudio()` (default export, mounted by the route). 1548 + 1549 + > **Cover handling note:** in this flow a chosen cover is held as a `data:` URL. `CoverImagePicker` needs a `CoverUpload` (`{ ref, previewUrl }`); we pass a deferred placeholder ref (`{ $type: 'blob', ref: { $link: '' }, mimeType: file.type, size: file.size }`) and a `data:` URL `previewUrl`. Only `previewUrl` is read downstream — `WritePublishFlow` receives `coverDataUrl = cover?.previewUrl ?? null` and `uploadHeldAssets` produces the real ref at publish. 1550 + 1551 + > **Editor seeding note:** `WriteStudio` keeps content as `BlockNode[]` (`blocks`). `SkyEditor.onChange` yields `BlockInstance[]`; convert with `normalizeBlocks` before storing. On restore, pass the restored nodes once as `SkyEditor`'s `initialBlocks` (captured in a ref so a later `blocks` change doesn't remount the canvas). 1552 + 1553 + - [ ] **Step 1: Write the failing test** (mock `SkyEditor`, `draft-store`, and `listPublications` so the heavy editor and storage are out of scope; assert the auth-state surfaces + resume) 1554 + 1555 + ```tsx 1556 + // src/components/WriteStudio.test.tsx 1557 + import { describe, it, expect, vi, beforeEach } from 'vitest'; 1558 + import { act, createElement } from 'react'; 1559 + import { createRoot } from 'react-dom/client'; 1560 + 1561 + ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; 1562 + 1563 + // Stub the heavy editor: render a marker, ignore props. 1564 + vi.mock( './SkyEditor', () => ( { default: () => createElement( 'div', { 'data-testid': 'sky-editor' } ) } ) ); 1565 + // Stub AppBar to avoid pulling chrome we don't assert on. 1566 + vi.mock( './AppBar', () => ( { default: () => null } ) ); 1567 + 1568 + const auth = vi.hoisted( () => ( { value: {} as Record< string, unknown > } ) ); 1569 + vi.mock( '../lib/auth/AuthProvider', () => ( { 1570 + AuthProvider: ( { children }: { children: unknown } ) => children, 1571 + } ) ); 1572 + vi.mock( '../lib/auth/useAuth', () => ( { useAuth: () => auth.value } ) ); 1573 + 1574 + const draft = vi.hoisted( () => ( { 1575 + store: { 1576 + load: vi.fn( async () => null ), 1577 + save: vi.fn( async () => {} ), 1578 + clear: vi.fn( async () => {} ), 1579 + setPublishIntent: vi.fn(), 1580 + consumePublishIntent: vi.fn( () => false ), 1581 + }, 1582 + } ) ); 1583 + vi.mock( '../lib/write/draft-store', () => ( { 1584 + createDraftStore: () => draft.store, 1585 + } ) ); 1586 + vi.mock( '../lib/publish/publications', () => ( { 1587 + listPublications: vi.fn( async () => [] ), 1588 + } ) ); 1589 + 1590 + import WriteStudio from './WriteStudio'; 1591 + 1592 + function render() { 1593 + const container = document.createElement( 'div' ); 1594 + document.body.appendChild( container ); 1595 + const root = createRoot( container ); 1596 + return { container, root }; 1597 + } 1598 + 1599 + beforeEach( () => { 1600 + draft.store.consumePublishIntent.mockReturnValue( false ); 1601 + draft.store.load.mockResolvedValue( null ); 1602 + } ); 1603 + 1604 + describe( 'WriteStudio', () => { 1605 + it( 'signed out: renders the editor AND a sign-in affordance (no gate)', async () => { 1606 + auth.value = { status: 'signed-out', agent: null, did: null, handle: null, signIn: vi.fn(), signOut: vi.fn() }; 1607 + const { container, root } = render(); 1608 + await act( async () => { 1609 + root.render( createElement( WriteStudio ) ); 1610 + } ); 1611 + expect( container.querySelector( '[data-testid="sky-editor"]' ) ).not.toBe( null ); 1612 + expect( container.textContent?.toLowerCase() ).toContain( 'sign in' ); 1613 + } ); 1614 + 1615 + it( 'signed in: renders the editor AND the account pill', async () => { 1616 + auth.value = { 1617 + status: 'signed-in', agent: {}, did: 'did:plc:me', handle: 'me.test', 1618 + displayName: 'Me', avatar: null, pdsUrl: 'https://pds', signIn: vi.fn(), signOut: vi.fn(), 1619 + }; 1620 + const { container, root } = render(); 1621 + await act( async () => { 1622 + root.render( createElement( WriteStudio ) ); 1623 + } ); 1624 + expect( container.querySelector( '[data-testid="sky-editor"]' ) ).not.toBe( null ); 1625 + expect( container.querySelector( '.account-pill' ) ).not.toBe( null ); 1626 + } ); 1627 + 1628 + it( 'resume: a publish intent on a signed-in load opens the publish flow', async () => { 1629 + draft.store.consumePublishIntent.mockReturnValue( true ); 1630 + auth.value = { 1631 + status: 'signed-in', agent: {}, did: 'did:plc:me', handle: 'me.test', 1632 + displayName: 'Me', avatar: null, pdsUrl: 'https://pds', signIn: vi.fn(), signOut: vi.fn(), 1633 + }; 1634 + const { container, root } = render(); 1635 + await act( async () => { 1636 + root.render( createElement( WriteStudio ) ); 1637 + } ); 1638 + expect( container.querySelector( '.writeflow' ) ).not.toBe( null ); 1639 + } ); 1640 + } ); 1641 + ``` 1642 + 1643 + - [ ] **Step 2: Run test to verify it fails** 1644 + 1645 + Run: `npx vitest run src/components/WriteStudio.test.tsx` 1646 + Expected: FAIL — cannot resolve `./WriteStudio`. 1647 + 1648 + - [ ] **Step 3: Write the implementation** 1649 + 1650 + ```tsx 1651 + // src/components/WriteStudio.tsx 1652 + import { useEffect, useMemo, useRef, useState } from 'react'; 1653 + import type { BlockInstance } from '@wordpress/blocks'; 1654 + import { AuthProvider } from '../lib/auth/AuthProvider'; 1655 + import { useAuth } from '../lib/auth/useAuth'; 1656 + import SkyEditor from './SkyEditor'; 1657 + import AppBar from './AppBar'; 1658 + import PublishedPill from './PublishedPill'; 1659 + import CoverImagePicker from './CoverImagePicker'; 1660 + import SignInPanel from './SignInPanel'; 1661 + import AccountPill from './AccountPill'; 1662 + import WritePublishFlow from './WritePublishFlow'; 1663 + import { createDeferredMediaUpload } from '../lib/write/deferred-media'; 1664 + import { createDraftStore, type WriteDraft } from '../lib/write/draft-store'; 1665 + import { listPublications, type Publication } from '../lib/publish/publications'; 1666 + import { normalizeBlocks } from '../lib/publish/records'; 1667 + import { validateCoverFile, type CoverUpload } from '../lib/media/cover'; 1668 + import { displayNameFor } from '../lib/auth/profile'; 1669 + import type { BlockNode } from '../lib/blocks/render'; 1670 + import type { BlobRefJson } from '../lib/media/blob'; 1671 + 1672 + /** Read a file into a `data:` URL for a held (not-yet-uploaded) cover preview. */ 1673 + function readAsDataUrl( file: File ): Promise< string > { 1674 + return new Promise( ( resolve, reject ) => { 1675 + const reader = new FileReader(); 1676 + reader.onload = () => resolve( reader.result as string ); 1677 + reader.onerror = () => reject( reader.error ?? new Error( 'Could not read the file.' ) ); 1678 + reader.readAsDataURL( file ); 1679 + } ); 1680 + } 1681 + 1682 + /** A placeholder ref for a held cover — only `previewUrl` (a data URL) is used before publish. */ 1683 + function deferredCoverRef( file: File ): BlobRefJson { 1684 + return { $type: 'blob', ref: { $link: '' }, mimeType: file.type, size: file.size }; 1685 + } 1686 + 1687 + function WriteSurface() { 1688 + const { status, agent, did, handle, displayName, avatar, pdsUrl, error, signIn, signOut } = 1689 + useAuth(); 1690 + 1691 + const draftStore = useMemo( () => createDraftStore(), [] ); 1692 + const mediaUpload = useMemo( () => createDeferredMediaUpload(), [] ); 1693 + 1694 + const [ title, setTitle ] = useState( '' ); 1695 + const [ lede, setLede ] = useState( '' ); 1696 + const [ blocks, setBlocks ] = useState< BlockNode[] >( [] ); 1697 + const [ cover, setCover ] = useState< CoverUpload | null >( null ); 1698 + const [ publications, setPublications ] = useState< Publication[] | null >( null ); 1699 + const [ flowOpen, setFlowOpen ] = useState( false ); 1700 + const [ pendingPublish, setPendingPublish ] = useState( false ); 1701 + const [ signinOpen, setSigninOpen ] = useState( false ); 1702 + const [ published, setPublished ] = useState< { url: string } | null >( null ); 1703 + const [ editorKey, setEditorKey ] = useState( 0 ); 1704 + 1705 + // Restored content fed to the editor canvas once, captured so a later `blocks` 1706 + // change can't remount SkyEditor (which would wipe the canvas). 1707 + const initialBlocksRef = useRef< BlockNode[] >( [] ); 1708 + const restoredRef = useRef( false ); 1709 + const intentRef = useRef( false ); 1710 + 1711 + // One-shot restore + intent read on mount. 1712 + useEffect( () => { 1713 + let cancelled = false; 1714 + intentRef.current = draftStore.consumePublishIntent(); 1715 + draftStore.load().then( ( d: WriteDraft | null ) => { 1716 + if ( cancelled || ! d ) { 1717 + return; 1718 + } 1719 + restoredRef.current = true; 1720 + initialBlocksRef.current = d.blocks; 1721 + setTitle( d.title ); 1722 + setLede( d.lede ); 1723 + setBlocks( d.blocks ); 1724 + if ( d.coverDataUrl ) { 1725 + setCover( { 1726 + ref: { $type: 'blob', ref: { $link: '' }, mimeType: '', size: 0 }, 1727 + previewUrl: d.coverDataUrl, 1728 + } ); 1729 + } 1730 + setEditorKey( ( k ) => k + 1 ); 1731 + } ); 1732 + return () => { 1733 + cancelled = true; 1734 + }; 1735 + }, [ draftStore ] ); 1736 + 1737 + // Load publications once signed in. 1738 + useEffect( () => { 1739 + if ( ! agent || ! did ) { 1740 + return; 1741 + } 1742 + let cancelled = false; 1743 + listPublications( agent, did ) 1744 + .then( ( list ) => ! cancelled && setPublications( list ) ) 1745 + .catch( () => ! cancelled && setPublications( [] ) ); 1746 + return () => { 1747 + cancelled = true; 1748 + }; 1749 + }, [ agent, did ] ); 1750 + 1751 + // Resume: a publish intent + a signed-in session auto-opens the publish flow. 1752 + useEffect( () => { 1753 + if ( status === 'signed-in' && intentRef.current ) { 1754 + intentRef.current = false; 1755 + setFlowOpen( true ); 1756 + } 1757 + }, [ status ] ); 1758 + 1759 + const reloadPublications = () => { 1760 + if ( agent && did ) { 1761 + listPublications( agent, did ) 1762 + .then( setPublications ) 1763 + .catch( () => setPublications( [] ) ); 1764 + } 1765 + }; 1766 + 1767 + async function persistDraft() { 1768 + await draftStore.save( { 1769 + title, 1770 + lede, 1771 + blocks, 1772 + coverDataUrl: cover?.previewUrl ?? null, 1773 + } ); 1774 + } 1775 + 1776 + async function onSignIn( value: string ) { 1777 + await persistDraft(); 1778 + if ( pendingPublish ) { 1779 + draftStore.setPublishIntent(); 1780 + } 1781 + await signIn( value ); // full-page redirect; never resolves 1782 + } 1783 + 1784 + function onPublishClicked() { 1785 + if ( status === 'signed-in' ) { 1786 + void persistDraft(); 1787 + setFlowOpen( true ); 1788 + return; 1789 + } 1790 + // Signed out → reveal the sign-in panel framed around publishing. 1791 + setPendingPublish( true ); 1792 + setSigninOpen( true ); 1793 + } 1794 + 1795 + async function uploadCover( file: File ): Promise< CoverUpload > { 1796 + const message = validateCoverFile( file ); 1797 + if ( message ) { 1798 + throw new Error( message ); 1799 + } 1800 + const previewUrl = await readAsDataUrl( file ); 1801 + return { ref: deferredCoverRef( file ), previewUrl }; 1802 + } 1803 + 1804 + const canPublish = title.trim().length > 0 && blocks.length > 0; 1805 + const signedIn = status === 'signed-in' && agent && did; 1806 + 1807 + return ( 1808 + <> 1809 + <AppBar current="editor" /> 1810 + 1811 + <div className="write-corner"> 1812 + { signedIn ? ( 1813 + <AccountPill 1814 + displayName={ displayName ? displayNameFor( { did: did!, handle, displayName, avatar } ) : ( handle ?? did! ) } 1815 + handle={ handle } 1816 + avatar={ avatar } 1817 + onSignOut={ () => void signOut() } 1818 + /> 1819 + ) : ( 1820 + <button 1821 + type="button" 1822 + className="write-corner__signin" 1823 + onClick={ () => { 1824 + setPendingPublish( false ); 1825 + setSigninOpen( true ); 1826 + } } 1827 + > 1828 + Sign in 1829 + </button> 1830 + ) } 1831 + </div> 1832 + 1833 + { published && <PublishedPill url={ published.url } isEditing={ false } /> } 1834 + 1835 + { ! signedIn && signinOpen && ( 1836 + <div className="write-signin"> 1837 + <SignInPanel 1838 + forPublish={ pendingPublish } 1839 + error={ error } 1840 + onSubmit={ ( value ) => void onSignIn( value ) } 1841 + onCancel={ () => setSigninOpen( false ) } 1842 + /> 1843 + </div> 1844 + ) } 1845 + 1846 + { signedIn && flowOpen && ( 1847 + <WritePublishFlow 1848 + agent={ agent! } 1849 + identity={ { did: did!, handle } } 1850 + pdsUrl={ pdsUrl ?? '' } 1851 + blocks={ blocks } 1852 + coverDataUrl={ cover?.previewUrl ?? null } 1853 + title={ title } 1854 + description={ lede } 1855 + publications={ publications } 1856 + onReloadPublications={ reloadPublications } 1857 + onPublished={ ( result ) => { 1858 + setFlowOpen( false ); 1859 + setPublished( { url: result.articleUrl } ); 1860 + void draftStore.clear(); 1861 + // Reset to a fresh new article. 1862 + setTitle( '' ); 1863 + setLede( '' ); 1864 + setBlocks( [] ); 1865 + setCover( null ); 1866 + initialBlocksRef.current = []; 1867 + setEditorKey( ( k ) => k + 1 ); 1868 + } } 1869 + onCancel={ () => setFlowOpen( false ) } 1870 + /> 1871 + ) } 1872 + 1873 + <div key={ editorKey }> 1874 + <div className="write-header"> 1875 + <textarea 1876 + className="studio__title" 1877 + rows={ 1 } 1878 + placeholder="Add title" 1879 + aria-label="Article title" 1880 + value={ title } 1881 + onKeyDown={ ( event ) => event.key === 'Enter' && event.preventDefault() } 1882 + onChange={ ( event ) => { 1883 + setPublished( null ); 1884 + setTitle( event.target.value ); 1885 + } } 1886 + /> 1887 + <button 1888 + type="button" 1889 + className="write-publish" 1890 + disabled={ ! canPublish } 1891 + onClick={ onPublishClicked } 1892 + > 1893 + Publish… 1894 + </button> 1895 + </div> 1896 + <textarea 1897 + className="studio__lede" 1898 + rows={ 1 } 1899 + maxLength={ 3000 } 1900 + placeholder="Add a subtitle…" 1901 + aria-label="Subtitle" 1902 + value={ lede } 1903 + onChange={ ( event ) => setLede( event.target.value ) } 1904 + /> 1905 + <SkyEditor 1906 + onChange={ ( instances: BlockInstance[] ) => setBlocks( normalizeBlocks( instances ) ) } 1907 + mediaUpload={ mediaUpload } 1908 + initialBlocks={ initialBlocksRef.current } 1909 + /> 1910 + <CoverImagePicker cover={ cover } onUpload={ uploadCover } onChange={ setCover } /> 1911 + </div> 1912 + </> 1913 + ); 1914 + } 1915 + 1916 + /** The writing-first island: editor for every auth status, publish deferred (design 2026-06-17). */ 1917 + export default function WriteStudio() { 1918 + return ( 1919 + <AuthProvider> 1920 + <WriteSurface /> 1921 + </AuthProvider> 1922 + ); 1923 + } 1924 + ``` 1925 + 1926 + - [ ] **Step 4: Run test to verify it passes** 1927 + 1928 + Run: `npx vitest run src/components/WriteStudio.test.tsx` 1929 + Expected: PASS (all three). 1930 + 1931 + - [ ] **Step 5: Commit** 1932 + 1933 + ```bash 1934 + git add src/components/WriteStudio.tsx src/components/WriteStudio.test.tsx 1935 + git commit --no-gpg-sign -m "Add WriteStudio island: editor-first, publish deferred" 1936 + ``` 1937 + 1938 + --- 1939 + 1940 + ### Task 9: `/write` route + styles + meta test 1941 + 1942 + Mount the island at `/write` (parallel to `/editor`, both untouched) and add its chrome styles. The colocated test is underscore-prefixed per the router constraint. 1943 + 1944 + **Files:** 1945 + - Create: `src/pages/write.astro` 1946 + - Create: `src/styles/write-chrome.css` 1947 + - Test: `src/pages/_write.meta.test.ts` 1948 + 1949 + **Interfaces:** 1950 + - Consumes: `WriteStudio` default export; `Base` layout; `LoadingScene`; the shared editor stylesheets. 1951 + - Produces: a static route at `/write`. 1952 + 1953 + - [ ] **Step 1: Write the failing test** 1954 + 1955 + ```ts 1956 + // src/pages/_write.meta.test.ts 1957 + import { describe, it, expect } from 'vitest'; 1958 + import { readFileSync } from 'node:fs'; 1959 + 1960 + const src = readFileSync( new URL( './write.astro', import.meta.url ), 'utf8' ); 1961 + 1962 + describe( '/write route', () => { 1963 + it( 'mounts WriteStudio as a client:only island', () => { 1964 + expect( src ).toContain( "import WriteStudio from '../components/WriteStudio.tsx'" ); 1965 + expect( src ).toMatch( /<WriteStudio\s+client:only="react"/ ); 1966 + } ); 1967 + 1968 + it( 'ships a writing-focused title and the write chrome styles', () => { 1969 + expect( src ).toMatch( /<Base title="Write[^"]*"/ ); 1970 + expect( src ).toContain( "import '../styles/write-chrome.css'" ); 1971 + } ); 1972 + } ); 1973 + ``` 1974 + 1975 + - [ ] **Step 2: Run test to verify it fails** 1976 + 1977 + Run: `npx vitest run src/pages/_write.meta.test.ts` 1978 + Expected: FAIL — `write.astro` does not exist. 1979 + 1980 + - [ ] **Step 3: Write the route + styles** 1981 + 1982 + ```astro 1983 + --- 1984 + // src/pages/write.astro 1985 + import Base from '../layouts/Base.astro'; 1986 + import WriteStudio from '../components/WriteStudio.tsx'; 1987 + import LoadingScene from '../components/LoadingScene.astro'; 1988 + // The island is `client:only`, so Astro's scoped styles never reach its DOM — its 1989 + // chrome is styled globally from these shared stylesheets plus the write-specific one. 1990 + import '../styles/app-bar.css'; 1991 + import '../styles/editor-chrome.css'; 1992 + import '../styles/login.css'; 1993 + import '../styles/write-chrome.css'; 1994 + --- 1995 + 1996 + <Base title="Write — SkyPress"> 1997 + <main class="editor-shell"> 1998 + <!-- client:only — auth + editor run only in the browser (Decisions 0001 & 0004). 1999 + Unlike /editor this surface never gates on auth: you can write signed out. --> 2000 + <WriteStudio client:only="react"> 2001 + <LoadingScene slot="fallback" variant="editor" /> 2002 + </WriteStudio> 2003 + </main> 2004 + </Base> 2005 + ``` 2006 + 2007 + ```css 2008 + /* src/styles/write-chrome.css 2009 + * Chrome unique to the writing-first page (/write): the top-right corner (sign-in / pill), 2010 + * the sign-in panel, the publish stepper, and the in-header Publish button. The editor body 2011 + * itself reuses editor-chrome.css (.studio__title / .studio__lede / .studio__cover*). 2012 + */ 2013 + 2014 + .write-corner { 2015 + display: flex; 2016 + justify-content: flex-end; 2017 + padding: 0.5rem 1rem; 2018 + } 2019 + 2020 + .write-corner__signin, 2021 + .write-publish { 2022 + font: inherit; 2023 + cursor: pointer; 2024 + border-radius: 999px; 2025 + border: 1px solid currentColor; 2026 + padding: 0.35rem 0.9rem; 2027 + background: transparent; 2028 + } 2029 + 2030 + .write-publish[disabled] { 2031 + opacity: 0.5; 2032 + cursor: default; 2033 + } 2034 + 2035 + .write-header { 2036 + display: flex; 2037 + align-items: flex-start; 2038 + gap: 0.75rem; 2039 + } 2040 + 2041 + .write-header .studio__title { 2042 + flex: 1 1 auto; 2043 + } 2044 + 2045 + .account-pill { 2046 + position: relative; 2047 + } 2048 + 2049 + .account-pill__trigger { 2050 + display: inline-flex; 2051 + align-items: center; 2052 + gap: 0.5rem; 2053 + background: transparent; 2054 + border: 0; 2055 + cursor: pointer; 2056 + font: inherit; 2057 + } 2058 + 2059 + .account-pill__avatar { 2060 + border-radius: 999px; 2061 + } 2062 + 2063 + .account-pill__avatar--fallback { 2064 + display: inline-grid; 2065 + place-items: center; 2066 + width: 28px; 2067 + height: 28px; 2068 + border-radius: 999px; 2069 + background: rgba( 0, 0, 0, 0.1 ); 2070 + } 2071 + 2072 + .account-pill__menu { 2073 + position: absolute; 2074 + right: 0; 2075 + margin-top: 0.4rem; 2076 + min-width: 12rem; 2077 + display: flex; 2078 + flex-direction: column; 2079 + background: Canvas; 2080 + border: 1px solid rgba( 0, 0, 0, 0.15 ); 2081 + border-radius: 0.5rem; 2082 + box-shadow: 0 8px 24px rgba( 0, 0, 0, 0.12 ); 2083 + overflow: hidden; 2084 + } 2085 + 2086 + .account-pill__item { 2087 + padding: 0.6rem 0.9rem; 2088 + text-align: left; 2089 + background: transparent; 2090 + border: 0; 2091 + font: inherit; 2092 + cursor: pointer; 2093 + color: inherit; 2094 + text-decoration: none; 2095 + } 2096 + 2097 + .account-pill__item:hover { 2098 + background: rgba( 0, 0, 0, 0.06 ); 2099 + } 2100 + 2101 + .write-signin, 2102 + .writeflow { 2103 + max-width: 32rem; 2104 + margin: 1rem auto; 2105 + padding: 1rem 1.25rem; 2106 + border: 1px solid rgba( 0, 0, 0, 0.15 ); 2107 + border-radius: 0.75rem; 2108 + } 2109 + 2110 + .signin-panel__actions, 2111 + .writeflow__actions { 2112 + display: flex; 2113 + gap: 0.75rem; 2114 + margin-top: 0.75rem; 2115 + } 2116 + 2117 + .writeflow__warning { 2118 + font-size: 0.95rem; 2119 + } 2120 + 2121 + .writeflow__count, 2122 + .writeflow__error, 2123 + .signin-panel__error { 2124 + color: #b3261e; 2125 + } 2126 + ``` 2127 + 2128 + - [ ] **Step 4: Run test + a full check to verify it passes** 2129 + 2130 + Run: `npx vitest run src/pages/_write.meta.test.ts` 2131 + Expected: PASS. 2132 + 2133 + Run: `npm run check` 2134 + Expected: no new TypeScript/Astro errors from the added files. 2135 + 2136 + - [ ] **Step 5: Commit** 2137 + 2138 + ```bash 2139 + git add src/pages/write.astro src/pages/_write.meta.test.ts src/styles/write-chrome.css 2140 + git commit --no-gpg-sign -m "Add /write route and writing-first chrome styles" 2141 + ``` 2142 + 2143 + --- 2144 + 2145 + ### Task 10: Decision record + full verification 2146 + 2147 + Capture the durable rationale and verify the whole suite + build. 2148 + 2149 + **Files:** 2150 + - Create: `docs/decisions/0020-writing-first-deferred-publish.md` 2151 + 2152 + **Interfaces:** none (docs + verification). 2153 + 2154 + - [ ] **Step 1: Write the decision record** 2155 + 2156 + ```markdown 2157 + <!-- docs/decisions/0020-writing-first-deferred-publish.md --> 2158 + # 0020 — Writing-first flow: deferred publish, no remote account creation 2159 + 2160 + ## Context 2161 + `/editor` gates the entire editor behind OAuth. We wanted a parallel surface where writing is 2162 + the first action and auth/publication selection are deferred to publish time. 2163 + 2164 + ## Decision 2165 + - Add a parallel route `/write` mounting a new `WriteStudio` island that renders the editor for 2166 + every auth status (no login gate). `/`, `/editor`, `/dashboard` are untouched. 2167 + - Images are held locally as `data:` URLs and uploaded to the PDS only at publish 2168 + (`src/lib/write/upload-held.ts`), via one media path regardless of auth state. 2169 + - The draft (title, lede, token-skeleton blocks, cover) survives the full-page OAuth redirect: 2170 + light metadata + skeleton in `localStorage`, image bytes in IndexedDB 2171 + (`src/lib/write/draft-store.ts` + `asset-store.ts`). A one-shot `publishIntent` flag makes the 2172 + publish flow auto-resume on return. 2173 + - Publish branches on publication count: one → confirm; many → pick; zero → inline create. The 2174 + single-publication case still shows a confirm — publishing also posts to Bluesky (brief §10). 2175 + 2176 + ## Why not remote account creation 2177 + atproto OAuth authenticates an existing account; `com.atproto.server.createAccount` lives on a 2178 + PDS's hosted signup (invite/email gated) and is not exposed to third-party clients. Building an 2179 + inline signup would make SkyPress a hosting/signup broker, contradicting the "never a PDS/relay" 2180 + guardrail. The signed-out panel therefore links out to Bluesky signup and otherwise offers 2181 + sign-in only. 2182 + 2183 + ## Consequences 2184 + - Signed-in writers lose eager upload error feedback (errors surface at publish) — accepted for a 2185 + single, simpler media path. 2186 + - `data:`-URL drafts can be large; bytes go to IndexedDB to avoid the `localStorage` quota. 2187 + ``` 2188 + 2189 + - [ ] **Step 2: Run the full test suite** 2190 + 2191 + Run: `npm test` 2192 + Expected: PASS — all suites, including the new `src/lib/write/*` and `src/components/Write*` / `SignInPanel` / `AccountPill` tests. 2193 + 2194 + - [ ] **Step 3: Type/Astro check** 2195 + 2196 + Run: `npm run check` 2197 + Expected: no errors. 2198 + 2199 + - [ ] **Step 4: Build (prerender smoke — catches the router-executes-tests trap)** 2200 + 2201 + Run: `npm run build` 2202 + Expected: success; `/write` prerenders without 500s (confirms the `_write.meta.test.ts` underscore prefix is respected). 2203 + 2204 + - [ ] **Step 5: Commit** 2205 + 2206 + ```bash 2207 + git add docs/decisions/0020-writing-first-deferred-publish.md 2208 + git commit --no-gpg-sign -m "Record decision 0020: writing-first deferred publish" 2209 + ``` 2210 + 2211 + --- 2212 + 2213 + ## Manual verification (after the plan) 2214 + 2215 + Run `npm run dev` and open `http://127.0.0.1:<port>/write` (127.0.0.1, not localhost — atproto loopback requirement, Decision 0004): 2216 + 2217 + 1. **Signed out, write + images:** type a title/lede, insert an image — it previews inline; nothing uploads (check Network: no `uploadBlob`). 2218 + 2. **Publish → redirect → resume:** click Publish, sign in; on return the draft (incl. the image) is restored and the publish flow opens automatically. 2219 + 3. **Branch — one pub:** with exactly one publication, confirm names it and discloses the Bluesky post; confirming uploads the image then publishes. Open the live article — the image renders from `getBlob`. 2220 + 4. **Branch — zero pubs:** with no publication, the inline create form appears; creating one proceeds to publish. 2221 + 5. **Branch — many pubs:** with 2+, a picker appears before confirm. 2222 + 6. **Already signed in:** reload `/write` while signed in — the pill shows; Publish opens the flow with no redirect. 2223 + 2224 + ## Self-Review 2225 + 2226 + - **Spec coverage:** route & island (T8/T9) ✓; two corner states (T7/T8) ✓; draft persistence across redirect (T3/T4/T8) ✓; deferred one-path media (T1/T5/T8) ✓; publish branching one/many/zero with always-confirm + inline create (T6) ✓; resume-after-redirect via publishIntent (T4/T8) ✓; reuse vs build + untouched routes (file structure) ✓; new-doc-only (no `?edit` path in T8) ✓; no remote account creation (T7 link-out + T10 decision) ✓; testing constraints incl. underscore-prefixed page test (T9) ✓. 2227 + - **Placeholder scan:** none — every step ships real code/commands. 2228 + - **Type consistency:** `WriteDraft`, `AssetStore`, `AssetSkeleton`, `PreparedPublishContent`, `CoverUpload`, `BlobRefJson`, `Publication`, `Identity`, and `computePostPreview`'s input shape are used consistently across tasks; `uploadHeldAssets`/`publish`/`attachBlobRefs` signatures match their definitions in the reused modules. 2229 + ```
+153
docs/superpowers/specs/2026-06-17-writing-first-flow-design.md
··· 1 + # Writing-first flow — design 2 + 3 + **Date:** 2026-06-17 4 + **Status:** Design — approved, pending spec review 5 + **Branch:** `chicago-v2` 6 + 7 + ## Summary 8 + 9 + An alternative entry experience for SkyPress where **writing is the first thing you do**. 10 + Today the flow is login-first: `/` is a marketing landing page and the editor at `/editor` 11 + gates the entire writing surface behind `status === 'signed-in'`. This design inverts that: 12 + the writer lands directly on an editor, starts writing immediately, and only encounters 13 + auth and publication selection at publish time. 14 + 15 + This ships as a **parallel experience** at a new route — `/`, `/editor`, and `/dashboard` 16 + are left untouched so the two funnels can be compared side by side. 17 + 18 + ## Goals 19 + 20 + - A writer can land on a page and start writing with zero friction — no login gate. 21 + - Authentication is deferred to publish time and resumes the publish flow automatically 22 + after the OAuth redirect round-trip. 23 + - Image insertion works while signed out; uploads are deferred to publish. 24 + - Publishing branches sensibly on how many publications the writer owns (one / many / zero). 25 + - A returning, already-signed-in writer gets a small account pill but the editor stays the 26 + focus. 27 + 28 + ## Non-goals 29 + 30 + - **Not** a replacement for the current landing page or `/editor`. This is option B 31 + (parallel route), explicitly for comparison. 32 + - **No remote account creation.** atproto OAuth is a sign-in protocol; a third-party client 33 + cannot create accounts on a big PDS, and SkyPress must never become a PDS/signup broker 34 + (product guardrail). We surface only a lightweight "Need an account? →" link to Bluesky's 35 + hosted signup. 36 + - **New-document only.** This is a "start writing" funnel. Editing an existing article stays 37 + on `/editor` (`Studio`). No `?edit=<rkey>` load path here. 38 + - **No inline publication management.** Creating/editing publications in general stays on the 39 + existing `/dashboard`. The one exception is a focused inline "create your first publication" 40 + step inside the zero-publication publish branch. 41 + 42 + ## Decisions (from brainstorming) 43 + 44 + | # | Decision | 45 + |---|----------| 46 + | Q1 | Positioning: **parallel route** (`/write`), existing routes untouched. | 47 + | Q2 | Account creation: **sign-in only** for now, with a "Need an account? →" link to Bluesky signup. No real create-account branch. | 48 + | Q3 | Images signed-out: **insert freely, upload silently at publish** (invisible deferral). | 49 + | Q4a | Single-publication publish: **always show a lightweight confirm** ("Publish to *Name* — also posts to Bluesky"), never silent auto-publish. | 50 + | Q4b | Zero-publication publish: **inline create-publication step** (reuse `PublicationForm`), not a redirect to `/dashboard`. | 51 + | Q5 | Signed-in pill: **links out to existing `/dashboard`** for management; pill itself is the only new account surface. | 52 + | Q6 | Media: **always defer (one path)** — hold locally and upload at publish regardless of auth state. | 53 + 54 + ## User journey 55 + 56 + ### Signed-out writer 57 + 1. Lands on `/write` → clean editor (title, lede, blocks, cover). No gate. 58 + 2. Writes; inserts images (held locally, previewed inline). 59 + 3. Clicks **Publish** → draft + images persisted locally, `publishIntent` set → OAuth redirect. 60 + 4. Returns signed in → draft restored, `publishIntent` detected → publish flow auto-resumes, 61 + branching on publication count (see below). 62 + 5. Success → draft cleared, published pill/link shown. 63 + 64 + ### Already-signed-in writer 65 + 1. Lands on `/write` → same editor, plus an **account pill** (avatar + handle) top-right. 66 + 2. Clicks **Publish** → no redirect; publish flow runs in place, same branching. 67 + 68 + ### Publish branching (after auth is guaranteed) 69 + - **Exactly one publication** → "Publish to *Name* (also posts to Bluesky)" confirm → 70 + upload held images → commit document + Bluesky post. 71 + - **More than one** → publication picker step → confirm → publish. 72 + - **Zero** → inline "create your first publication" step (`PublicationForm`) → publish. 73 + 74 + ## Architecture 75 + 76 + ### Route & island 77 + - New page `src/pages/write.astro` mounting a client-only island **`WriteStudio`**. 78 + - `WriteStudio` wraps `AuthProvider` (reused) but, unlike `Studio`, **renders the editor for 79 + every auth status** (`loading` / `signed-out` / `signed-in`). There is no login gate. 80 + - The OAuth `redirect_uri` is the current pathname (existing `oauth.ts` behavior), so a 81 + sign-in initiated from `/write` returns to `/write` automatically. No new redirect config. 82 + 83 + ### Top-right corner states 84 + - **Signed out:** a "Sign in" affordance + a small "Need an account? →" link to Bluesky 85 + signup. Reuses the existing handle-input / `signIn()` path from `AuthProvider`. 86 + - **Signed in:** the **account pill** — avatar + handle opening a menu: *Manage publications* 87 + (→ `/dashboard`), *Profile*, *Sign out*. 88 + 89 + ### Draft persistence (survives the OAuth redirect) 90 + Sign-in is a full-page redirect that destroys in-memory state, so before any redirect we 91 + persist a **single draft slot**: 92 + - **localStorage** — title, lede, serialized blocks, cover ref/preview, and a `publishIntent` 93 + flag. 94 + - **IndexedDB** — held image bytes (data URLs can exceed localStorage quota), keyed so blocks 95 + can reference them on restore. 96 + 97 + On `/write` load we **auto-restore** the draft if present. After a successful publish we 98 + **clear** both stores. Abandoned drafts persist for the next visit. 99 + 100 + ### Deferred media (one path) 101 + A new **local-hold media handler** replaces the eager PDS-upload handler for this flow, used 102 + regardless of auth state: 103 + - On insert: store image bytes in IndexedDB, render an inline preview (data URL), and record 104 + the mapping (preview → held key) in the in-memory blob registry. 105 + - At publish: for every held image, `agent.uploadBlob()` → real `BlobRef`, then reuse the 106 + existing `attachBlobRefs()` registry mechanism to swap preview URLs for blob refs before the 107 + document record commits. 108 + - Errors surface at publish (acceptable trade-off vs. eager-upload early feedback). 109 + 110 + ### Publish-flow stepper 111 + A small state machine driven by `publishIntent` + publication count: 112 + - `idle` → (Publish clicked) → persist + maybe-redirect → `resolving-auth` 113 + - `resolving-auth` → (signed in) → `branch` 114 + - `branch` → one of `confirm-single` / `pick` / `create-pub` 115 + - any terminal step → upload images → `publish()` (document + post) → `done` (clear draft). 116 + 117 + Reuses `publisher.ts` (`publish`), `publications.ts` (`listPublications`, `createPublication`), 118 + `PublicationForm`, and the published-pill UI. 119 + 120 + ## Reuse vs. build 121 + 122 + **Reuse (unchanged):** `AuthProvider`, `oauth.ts`, `SkyEditor`, `PublicationForm`, 123 + `publisher.ts` (`publish`), `publications.ts`, `attachBlobRefs`, published-pill UI. 124 + 125 + **Build new:** 126 + - `src/pages/write.astro` route. 127 + - `WriteStudio` island (editor for all auth states; corner state; publish stepper host). 128 + - Local-hold media handler. 129 + - Draft-persistence module (localStorage + IndexedDB; save / restore / clear). 130 + - Account pill / signed-out corner component. 131 + - Publish-flow stepper component (confirm / pick / create-pub) driven by `publishIntent`. 132 + 133 + **Untouched:** `/` (`index.astro`), `/editor` (`Studio`), `/dashboard`. 134 + 135 + ## Testing 136 + 137 + - Colocated page tests under `src/pages/` MUST be underscore-prefixed (e.g. 138 + `_write.meta.test.ts`) per the Astro file-router constraint. 139 + - Draft persistence: save → simulate reload → restore yields identical editor state 140 + (title/lede/blocks/cover); clear empties both stores. 141 + - Deferred media: held images survive a persist/restore round-trip; at publish each held 142 + image is uploaded once and its preview URL is swapped for the blob ref before commit. 143 + - Publish branching: one / many / zero publications each route to the correct step; the 144 + single-publication case still shows a confirm (never silent). 145 + - Resume-after-redirect: a `publishIntent` present on load auto-resumes the publish flow 146 + rather than dropping into the editor. 147 + - `WriteStudio` renders the editor for `loading`, `signed-out`, and `signed-in` (no gate). 148 + 149 + ## Open questions 150 + 151 + None outstanding. Durable rationale (e.g. the deferred-media single-path choice and the 152 + no-remote-account-creation constraint) should graduate to a `docs/decisions/NNNN-*.md` during 153 + implementation.
+71
src/components/EditorCanvas.test.tsx
··· 1 + import { describe, it, expect, vi } from 'vitest'; 2 + import { act, createElement } from 'react'; 3 + import { createRoot } from 'react-dom/client'; 4 + 5 + ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; 6 + 7 + // Stub the heavy/irrelevant children with markers. 8 + vi.mock( './SkyEditor', () => ( { 9 + default: () => createElement( 'div', { 'data-testid': 'sky-editor' } ), 10 + } ) ); 11 + vi.mock( './CoverImagePicker', () => ( { 12 + default: () => createElement( 'div', { 'data-testid': 'cover-picker' } ), 13 + } ) ); 14 + 15 + import EditorCanvas from './EditorCanvas'; 16 + 17 + const base = { 18 + title: '', 19 + onTitleChange: vi.fn(), 20 + lede: '', 21 + onLedeChange: vi.fn(), 22 + onBlocksChange: vi.fn(), 23 + cover: null, 24 + onCoverChange: vi.fn(), 25 + }; 26 + 27 + function mount( props: Record< string, unknown > ) { 28 + const container = document.createElement( 'div' ); 29 + document.body.appendChild( container ); 30 + act( () => createRoot( container ).render( createElement( EditorCanvas, { ...base, ...props } as never ) ) ); 31 + return container; 32 + } 33 + 34 + // React 18 tracks a controlled value via a prototype setter; a direct `el.value = …` 35 + // is invisible to it, so set through the native setter to make onChange fire. 36 + function setValue( el: HTMLTextAreaElement, val: string ) { 37 + const proto = Object.getPrototypeOf( el ); 38 + Object.getOwnPropertyDescriptor( proto, 'value' )!.set!.call( el, val ); 39 + el.dispatchEvent( new Event( 'input', { bubbles: true } ) ); 40 + } 41 + 42 + describe( 'EditorCanvas', () => { 43 + it( 'renders the title + lede fields and the block editor', () => { 44 + const c = mount( {} ); 45 + expect( c.querySelector( 'textarea.studio__title' ) ).not.toBe( null ); 46 + expect( c.querySelector( 'textarea.studio__lede' ) ).not.toBe( null ); 47 + expect( c.querySelector( '[data-testid="sky-editor"]' ) ).not.toBe( null ); 48 + } ); 49 + 50 + it( 'reports title and lede edits to the parent', () => { 51 + const onTitleChange = vi.fn(); 52 + const onLedeChange = vi.fn(); 53 + const c = mount( { onTitleChange, onLedeChange } ); 54 + setValue( c.querySelector( 'textarea.studio__title' )!, 'My title' ); 55 + setValue( c.querySelector( 'textarea.studio__lede' )!, 'My lede' ); 56 + expect( onTitleChange ).toHaveBeenCalledWith( 'My title' ); 57 + expect( onLedeChange ).toHaveBeenCalledWith( 'My lede' ); 58 + } ); 59 + 60 + it( 'shows the Bluesky-truncation hint only for a long lede', () => { 61 + expect( mount( { lede: 'short' } ).querySelector( '.studio__lede-hint' ) ).toBe( null ); 62 + expect( mount( { lede: 'x'.repeat( 201 ) } ).querySelector( '.studio__lede-hint' ) ).not.toBe( null ); 63 + } ); 64 + 65 + it( 'shows the cover picker only when an upload handler is provided', () => { 66 + expect( mount( {} ).querySelector( '[data-testid="cover-picker"]' ) ).toBe( null ); 67 + expect( 68 + mount( { onCoverUpload: vi.fn() } ).querySelector( '[data-testid="cover-picker"]' ) 69 + ).not.toBe( null ); 70 + } ); 71 + } );
+112
src/components/EditorCanvas.tsx
··· 1 + import { useLayoutEffect, useRef } from 'react'; 2 + import type { BlockInstance } from '@wordpress/blocks'; 3 + import SkyEditor from './SkyEditor'; 4 + import CoverImagePicker from './CoverImagePicker'; 5 + import type { MediaUploadHandler } from '../lib/media/mediaUpload'; 6 + import type { CoverUpload } from '../lib/media/cover'; 7 + import type { BlockNode } from '../lib/blocks/render'; 8 + 9 + interface Props { 10 + title: string; 11 + onTitleChange: ( value: string ) => void; 12 + lede: string; 13 + onLedeChange: ( value: string ) => void; 14 + /** Live block instances on every editor change — the parent normalises/stores as it needs. */ 15 + onBlocksChange: ( blocks: BlockInstance[] ) => void; 16 + mediaUpload?: MediaUploadHandler; 17 + initialBlocks?: BlockNode[]; 18 + cover: CoverUpload | null; 19 + /** When provided, the cover picker renders and uploads through this handler (eager or deferred). */ 20 + onCoverUpload?: ( file: File ) => Promise< CoverUpload >; 21 + onCoverChange: ( cover: CoverUpload | null ) => void; 22 + } 23 + 24 + /** 25 + * The shared writing surface for both the editor (`/editor`) and the writing-first page 26 + * (`/write`): the borderless title + lede headings above the framed block canvas, plus the 27 + * optional per-article cover picker. Presentational — it owns no document state, only the 28 + * textareas' auto-grow. Each island wires the content state, the media-upload handler, and 29 + * (for the cover) the upload path that fits its flow: eager PDS upload in the editor, deferred 30 + * `data:`-URL hold in the writing-first flow. Lives only in `client:only` islands — it pulls in 31 + * `SkyEditor`, which is browser-only (Decision 0003). 32 + */ 33 + export default function EditorCanvas( { 34 + title, 35 + onTitleChange, 36 + lede, 37 + onLedeChange, 38 + onBlocksChange, 39 + mediaUpload, 40 + initialBlocks, 41 + cover, 42 + onCoverUpload, 43 + onCoverChange, 44 + }: Props ) { 45 + const titleRef = useRef< HTMLTextAreaElement >( null ); 46 + const ledeRef = useRef< HTMLTextAreaElement >( null ); 47 + 48 + // Grow the title textarea to fit its content so long titles wrap into view instead of 49 + // clipping on one line. Layout effect so it sizes before paint. 50 + useLayoutEffect( () => { 51 + const el = titleRef.current; 52 + if ( ! el ) { 53 + return; 54 + } 55 + el.style.height = 'auto'; 56 + el.style.height = `${ el.scrollHeight }px`; 57 + }, [ title ] ); 58 + 59 + // Same auto-grow for the lede (and on hydrate from an edit-load / restored draft). 60 + useLayoutEffect( () => { 61 + const el = ledeRef.current; 62 + if ( ! el ) { 63 + return; 64 + } 65 + el.style.height = 'auto'; 66 + el.style.height = `${ el.scrollHeight }px`; 67 + }, [ lede ] ); 68 + 69 + return ( 70 + <> 71 + <textarea 72 + ref={ titleRef } 73 + className="studio__title" 74 + rows={ 1 } 75 + placeholder="Add title" 76 + aria-label="Article title" 77 + value={ title } 78 + // Single-line semantically: let it wrap visually, but don't let Enter insert a 79 + // literal newline into the stored value. 80 + onKeyDown={ ( event ) => { 81 + if ( event.key === 'Enter' ) { 82 + event.preventDefault(); 83 + } 84 + } } 85 + onChange={ ( event ) => onTitleChange( event.target.value ) } 86 + /> 87 + <textarea 88 + ref={ ledeRef } 89 + className="studio__lede" 90 + rows={ 1 } 91 + maxLength={ 3000 } 92 + placeholder="Add a subtitle…" 93 + aria-label="Subtitle" 94 + value={ lede } 95 + onChange={ ( event ) => onLedeChange( event.target.value ) } 96 + /> 97 + { lede.length > 200 && ( 98 + <p className="studio__lede-hint"> 99 + Long subtitles get truncated on the Bluesky card. 100 + </p> 101 + ) } 102 + <SkyEditor 103 + onChange={ onBlocksChange } 104 + mediaUpload={ mediaUpload } 105 + initialBlocks={ initialBlocks } 106 + /> 107 + { onCoverUpload && ( 108 + <CoverImagePicker cover={ cover } onUpload={ onCoverUpload } onChange={ onCoverChange } /> 109 + ) } 110 + </> 111 + ); 112 + }
+58
src/components/SignInPanel.test.tsx
··· 1 + import { describe, it, expect, vi } from 'vitest'; 2 + import { act, createElement } from 'react'; 3 + import { createRoot } from 'react-dom/client'; 4 + 5 + ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; 6 + import SignInPanel from './SignInPanel'; 7 + 8 + function mount( props: Record< string, unknown > ) { 9 + const container = document.createElement( 'div' ); 10 + document.body.appendChild( container ); 11 + act( () => createRoot( container ).render( createElement( SignInPanel, props as never ) ) ); 12 + return container; 13 + } 14 + 15 + // React 18 tracks a controlled input's value via a private setter; assigning `.value` 16 + // directly then dispatching `input` is invisible to React and onChange never fires. Set 17 + // through the native prototype setter so React's tracker sees the change (standard idiom). 18 + const setInputValue = ( input: HTMLInputElement, value: string ) => { 19 + const setter = Object.getOwnPropertyDescriptor( 20 + window.HTMLInputElement.prototype, 21 + 'value' 22 + )!.set!; 23 + setter.call( input, value ); 24 + }; 25 + 26 + describe( 'SignInPanel', () => { 27 + it( 'for-publish variant frames the CTA around publishing and links out to signup in a new tab', () => { 28 + const c = mount( { forPublish: true, error: null, onSubmit: vi.fn() } ); 29 + expect( c.textContent?.toLowerCase() ).toContain( 'publish' ); 30 + const signup = c.querySelector( 'a[href*="bsky.app"]' ) as HTMLAnchorElement | null; 31 + expect( signup ).not.toBe( null ); 32 + // Opens in a new tab, hardened, and labelled so the new-tab behaviour is announced. 33 + expect( signup!.target ).toBe( '_blank' ); 34 + expect( signup!.rel ).toContain( 'noopener' ); 35 + expect( signup!.getAttribute( 'aria-label' )?.toLowerCase() ).toContain( 'new tab' ); 36 + // Carries the usual external-link icon (not a bare "→"). 37 + expect( signup!.querySelector( 'svg' ) ).not.toBe( null ); 38 + expect( signup!.textContent ).not.toContain( '→' ); 39 + } ); 40 + 41 + it( 'submits the typed handle', () => { 42 + const onSubmit = vi.fn(); 43 + const c = mount( { forPublish: false, error: null, onSubmit } ); 44 + const input = c.querySelector( 'input' )!; 45 + const form = c.querySelector( 'form' )!; 46 + act( () => { 47 + setInputValue( input as HTMLInputElement, 'alice.bsky.social' ); 48 + input.dispatchEvent( new Event( 'input', { bubbles: true } ) ); 49 + } ); 50 + act( () => form.dispatchEvent( new Event( 'submit', { bubbles: true, cancelable: true } ) ) ); 51 + expect( onSubmit ).toHaveBeenCalledWith( 'alice.bsky.social' ); 52 + } ); 53 + 54 + it( 'shows an error when provided', () => { 55 + const c = mount( { forPublish: false, error: 'Bad handle', onSubmit: vi.fn() } ); 56 + expect( c.textContent ).toContain( 'Bad handle' ); 57 + } ); 58 + } );
+95
src/components/SignInPanel.tsx
··· 1 + import { useState } from 'react'; 2 + 3 + interface Props { 4 + /** True when opened from "Publish" — the copy promises a publish on return. */ 5 + forPublish: boolean; 6 + error: string | null; 7 + onSubmit: ( value: string ) => void; 8 + onCancel?: () => void; 9 + } 10 + 11 + /** 12 + * Signed-out handle entry for the writing-first flow. OAuth is sign-in only, so this never 13 + * creates an account — it links out to Bluesky's hosted signup instead (brief guardrail). The 14 + * caller persists the draft + sets publish intent before the redirect. 15 + */ 16 + export default function SignInPanel( { forPublish, error, onSubmit, onCancel }: Props ) { 17 + const [ value, setValue ] = useState( '' ); 18 + 19 + return ( 20 + <form 21 + className="signin-panel" 22 + onSubmit={ ( event ) => { 23 + event.preventDefault(); 24 + onSubmit( value.trim() ); 25 + } } 26 + > 27 + <h2 className="signin-panel__title"> 28 + { forPublish ? 'Sign in to publish' : 'Sign in' } 29 + </h2> 30 + <p className="signin-panel__lede"> 31 + { forPublish 32 + ? "Your draft is saved. Sign in and we'll pick up right where you left off and publish it." 33 + : 'Use your existing Bluesky / AT Protocol identity. Your work stays in your own account.' } 34 + </p> 35 + <label className="signin-panel__label" htmlFor="write-handle"> 36 + Your handle, DID, or PDS URL 37 + </label> 38 + <input 39 + id="write-handle" 40 + className="signin-panel__input" 41 + name="handle" 42 + autoComplete="username" 43 + autoCapitalize="none" 44 + autoCorrect="off" 45 + spellCheck={ false } 46 + placeholder="alice.bsky.social" 47 + value={ value } 48 + onChange={ ( event ) => setValue( event.target.value ) } 49 + /> 50 + <div className="signin-panel__actions"> 51 + <button className="signin-panel__submit" type="submit"> 52 + Sign in with AT Protocol 53 + </button> 54 + { onCancel && ( 55 + <button className="signin-panel__cancel" type="button" onClick={ onCancel }> 56 + Cancel 57 + </button> 58 + ) } 59 + </div> 60 + { error && ( 61 + <p className="signin-panel__error" role="alert"> 62 + { error } 63 + </p> 64 + ) } 65 + <p className="signin-panel__signup"> 66 + Need an account?{ ' ' } 67 + <a 68 + className="signin-panel__signup-link" 69 + href="https://bsky.app" 70 + target="_blank" 71 + rel="noopener noreferrer" 72 + aria-label="Create an account on Bluesky (opens in a new tab)" 73 + > 74 + Create one on Bluesky 75 + { /* Standard external-link glyph — same icon as the author page's Bluesky link. */ } 76 + <svg 77 + className="signin-panel__external" 78 + width="11" 79 + height="11" 80 + viewBox="0 0 24 24" 81 + fill="none" 82 + stroke="currentColor" 83 + strokeWidth="2.5" 84 + strokeLinecap="round" 85 + strokeLinejoin="round" 86 + aria-hidden="true" 87 + > 88 + <path d="M7 17 17 7" /> 89 + <path d="M8 7h9v9" /> 90 + </svg> 91 + </a> 92 + </p> 93 + </form> 94 + ); 95 + }
+13 -71
src/components/Studio.tsx
··· 1 - import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; 1 + import { useEffect, useMemo, useRef, useState } from 'react'; 2 2 import type { BlockInstance } from '@wordpress/blocks'; 3 3 import { AuthProvider } from '../lib/auth/AuthProvider'; 4 4 import { useAuth } from '../lib/auth/useAuth'; 5 5 import LoginForm from '../lib/auth/LoginForm'; 6 - import SkyEditor from './SkyEditor'; 6 + import EditorCanvas from './EditorCanvas'; 7 7 import PublishPanel from './PublishPanel'; 8 8 import PublishedPill from './PublishedPill'; 9 - import CoverImagePicker from './CoverImagePicker'; 10 9 import AppBar from './AppBar'; 11 10 import { createMediaUpload, revokeBlobRegistry, type BlobRegistry } from '../lib/media/mediaUpload'; 12 11 import { ··· 42 41 const [ publications, setPublications ] = useState< Publication[] | null >( null ); 43 42 // Shared between mediaUpload (writes blob refs) and publish (reads them). 44 43 const registry = useRef< BlobRegistry >( new Map() ).current; 45 - const titleRef = useRef< HTMLTextAreaElement >( null ); 46 - const ledeRef = useRef< HTMLTextAreaElement >( null ); 47 44 48 45 // Load the writer's SkyPress publications (the publish targets / selector). 49 46 useEffect( () => { ··· 115 112 // Release the preview object URLs this session minted when the Studio unmounts. 116 113 useEffect( () => () => revokeBlobRegistry( registry ), [ registry ] ); 117 114 118 - // Grow the title textarea to fit its content so long titles wrap into view 119 - // instead of clipping on one line (esp. on narrow mobile viewports). Layout 120 - // effect so it sizes before paint — same reasoning as the lede below. 121 - useLayoutEffect( () => { 122 - const el = titleRef.current; 123 - if ( ! el ) { 124 - return; 125 - } 126 - el.style.height = 'auto'; 127 - el.style.height = `${ el.scrollHeight }px`; 128 - }, [ title ] ); 129 - 130 - // Grow the lede textarea to fit its content (and on hydrate from an edit-load). 131 - // Layout effect so it sizes before paint — avoids a one-row collapse flash when 132 - // opening an existing article with a multi-line lede. 133 - useLayoutEffect( () => { 134 - const el = ledeRef.current; 135 - if ( ! el ) { 136 - return; 137 - } 138 - el.style.height = 'auto'; 139 - el.style.height = `${ el.scrollHeight }px`; 140 - }, [ excerpt ] ); 141 - 142 115 const mediaUpload = useMemo( () => { 143 116 if ( ! agent || ! did || ! pdsUrl ) { 144 117 return undefined; ··· 242 215 } 243 216 } } 244 217 /> 245 - <textarea 246 - ref={ titleRef } 247 - className="studio__title" 248 - rows={ 1 } 249 - placeholder="Add title" 250 - aria-label="Article title" 251 - value={ title } 252 - // The title is a single-line string: let it wrap visually, but 253 - // don't let Enter insert a literal newline into the stored value. 254 - onKeyDown={ ( event ) => { 255 - if ( event.key === 'Enter' ) { 256 - event.preventDefault(); 257 - } 258 - } } 259 - onChange={ ( event ) => { 218 + <EditorCanvas 219 + title={ title } 220 + onTitleChange={ ( value ) => { 260 221 setPublished( null ); 261 - setTitle( event.target.value ); 222 + setTitle( value ); 262 223 } } 263 - /> 264 - <textarea 265 - ref={ ledeRef } 266 - className="studio__lede" 267 - rows={ 1 } 268 - maxLength={ 3000 } 269 - placeholder="Add a subtitle…" 270 - aria-label="Subtitle" 271 - value={ excerpt } 272 - onChange={ ( event ) => { 224 + lede={ excerpt } 225 + onLedeChange={ ( value ) => { 273 226 setPublished( null ); 274 - setExcerpt( event.target.value ); 227 + setExcerpt( value ); 275 228 } } 276 - /> 277 - { excerpt.length > 200 && ( 278 - <p className="studio__lede-hint"> 279 - Long subtitles get truncated on the Bluesky card. 280 - </p> 281 - ) } 282 - <SkyEditor 283 - onChange={ setBlocks } 229 + onBlocksChange={ setBlocks } 284 230 mediaUpload={ mediaUpload } 285 231 initialBlocks={ editing?.blocks } 232 + cover={ cover } 233 + onCoverUpload={ uploadCover } 234 + onCoverChange={ setCover } 286 235 /> 287 - { uploadCover && ( 288 - <CoverImagePicker 289 - cover={ cover } 290 - onUpload={ uploadCover } 291 - onChange={ setCover } 292 - /> 293 - ) } 294 236 </div> 295 237 </> 296 238 );
+89
src/components/WritePublishFlow.test.tsx
··· 1 + // src/components/WritePublishFlow.test.tsx 2 + import { describe, it, expect, vi, beforeEach } from 'vitest'; 3 + import { act, createElement } from 'react'; 4 + import { createRoot } from 'react-dom/client'; 5 + import type { Agent } from '@atproto/api'; 6 + 7 + ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; 8 + 9 + const { publish, uploadHeldAssets } = vi.hoisted( () => ( { 10 + publish: vi.fn( async () => ( { articleUrl: 'https://x/a' } ) ), 11 + uploadHeldAssets: vi.fn( async () => ( { blocks: [], coverImage: undefined } ) ), 12 + } ) ); 13 + vi.mock( '../lib/publish/publisher', () => ( { publish } ) ); 14 + vi.mock( '../lib/write/upload-held', () => ( { uploadHeldAssets } ) ); 15 + 16 + import WritePublishFlow from './WritePublishFlow'; 17 + import type { BlockNode } from '../lib/blocks/render'; 18 + 19 + const PUB = ( uri: string, name: string ) => ( { 20 + uri, cid: 'cid', rkey: uri.split( '/' ).pop()!, slug: name, name, 21 + } ); 22 + const BLOCKS: BlockNode[] = [ { name: 'core/paragraph', attributes: { content: 'Body' }, innerBlocks: [] } ]; 23 + 24 + function mount( publications: unknown ) { 25 + const onPublished = vi.fn(); 26 + const container = document.createElement( 'div' ); 27 + document.body.appendChild( container ); 28 + const root = createRoot( container ); 29 + act( () => { 30 + root.render( 31 + createElement( WritePublishFlow, { 32 + agent: {} as Agent, 33 + identity: { did: 'did:plc:me', handle: 'me.test' }, 34 + pdsUrl: 'https://pds', 35 + blocks: BLOCKS, 36 + coverDataUrl: null, 37 + title: 'Title', 38 + description: 'Lede', 39 + publications, 40 + onReloadPublications: vi.fn(), 41 + onPublished, 42 + onCancel: vi.fn(), 43 + } as never ) 44 + ); 45 + } ); 46 + return { container, root, onPublished }; 47 + } 48 + 49 + beforeEach( () => { 50 + publish.mockClear(); 51 + uploadHeldAssets.mockClear(); 52 + } ); 53 + 54 + describe( 'WritePublishFlow', () => { 55 + it( 'single publication: shows a confirm naming it, never auto-publishes', () => { 56 + const { container } = mount( [ PUB( 'at://me/site.standard.publication/p1', 'Solo' ) ] ); 57 + expect( container.textContent ).toContain( 'Solo' ); 58 + expect( container.textContent?.toLowerCase() ).toContain( 'bluesky' ); 59 + expect( publish ).not.toHaveBeenCalled(); 60 + // The confirm step offers only Publish — no "Keep editing" / cancel button. 61 + expect( container.textContent ).not.toContain( 'Keep editing' ); 62 + } ); 63 + 64 + it( 'confirming uploads held assets then publishes', async () => { 65 + const { container, onPublished } = mount( [ PUB( 'at://me/site.standard.publication/p1', 'Solo' ) ] ); 66 + const confirm = [ ...container.querySelectorAll( 'button' ) ].find( ( b ) => 67 + /post to bluesky/i.test( b.textContent ?? '' ) 68 + )!; 69 + await act( async () => confirm.dispatchEvent( new MouseEvent( 'click', { bubbles: true } ) ) ); 70 + expect( uploadHeldAssets ).toHaveBeenCalledTimes( 1 ); 71 + expect( publish ).toHaveBeenCalledTimes( 1 ); 72 + expect( onPublished ).toHaveBeenCalledWith( { articleUrl: 'https://x/a' } ); 73 + } ); 74 + 75 + it( 'zero publications: renders the inline create-publication form', () => { 76 + const { container } = mount( [] ); 77 + expect( container.textContent ).toContain( 'New publication' ); 78 + } ); 79 + 80 + it( 'many publications: renders a picker listing each', () => { 81 + const { container } = mount( [ 82 + PUB( 'at://me/site.standard.publication/p1', 'One' ), 83 + PUB( 'at://me/site.standard.publication/p2', 'Two' ), 84 + ] ); 85 + expect( container.querySelector( 'select' ) ).not.toBe( null ); 86 + expect( container.textContent ).toContain( 'One' ); 87 + expect( container.textContent ).toContain( 'Two' ); 88 + } ); 89 + } );
+188
src/components/WritePublishFlow.tsx
··· 1 + // src/components/WritePublishFlow.tsx 2 + import { useMemo, useState } from 'react'; 3 + import type { Agent } from '@atproto/api'; 4 + import { publish, type Identity } from '../lib/publish/publisher'; 5 + import type { Publication } from '../lib/publish/publications'; 6 + import { computePostPreview } from './PublishPanel'; 7 + import PublicationForm from './PublicationForm'; 8 + import { uploadHeldAssets } from '../lib/write/upload-held'; 9 + import type { BlockNode } from '../lib/blocks/render'; 10 + 11 + interface Props { 12 + agent: Agent; 13 + identity: Identity; 14 + pdsUrl: string; 15 + blocks: BlockNode[]; 16 + coverDataUrl: string | null; 17 + title: string; 18 + description: string; 19 + /** `null` while still loading; `[]` when the writer has none yet. */ 20 + publications: Publication[] | null; 21 + /** Ask the parent to re-list publications (after an inline create). */ 22 + onReloadPublications: () => void; 23 + onPublished: ( result: { articleUrl: string } ) => void; 24 + onCancel: () => void; 25 + } 26 + 27 + type Phase = 'pick' | 'working' | 'error'; 28 + 29 + /** 30 + * The writing-first publish stepper (design 2026-06-17). Branches on how many publications the 31 + * writer owns: zero → inline create; one → confirm; many → pick then confirm. The confirm step 32 + * always discloses the public Bluesky post (brief §10) and blocks an over-limit post. On confirm 33 + * it uploads the held images/cover, then reuses `publish()` (document + Bluesky post). 34 + */ 35 + export default function WritePublishFlow( { 36 + agent, 37 + identity, 38 + pdsUrl, 39 + blocks, 40 + coverDataUrl, 41 + title, 42 + description, 43 + publications, 44 + onReloadPublications, 45 + onPublished, 46 + onCancel, 47 + }: Props ) { 48 + const pubs = publications ?? []; 49 + const [ targetUri, setTargetUri ] = useState( () => pubs[ 0 ]?.uri ?? '' ); 50 + const [ phase, setPhase ] = useState< Phase >( 'pick' ); 51 + const [ error, setError ] = useState< string | null >( null ); 52 + 53 + const target = pubs.find( ( p ) => p.uri === targetUri ) ?? pubs[ 0 ]; 54 + 55 + const preview = useMemo( 56 + () => 57 + target 58 + ? computePostPreview( { 59 + title, 60 + lede: description, 61 + blocks, 62 + handle: identity.handle ?? identity.did, 63 + slug: target.slug, 64 + } ) 65 + : null, 66 + [ target, title, description, blocks, identity ] 67 + ); 68 + 69 + if ( publications === null ) { 70 + return ( 71 + <section className="writeflow" aria-label="Publish"> 72 + <p className="writeflow__status">Loading your publications…</p> 73 + </section> 74 + ); 75 + } 76 + 77 + // Zero publications → inline "create your first publication". 78 + if ( pubs.length === 0 ) { 79 + return ( 80 + <section className="writeflow" aria-label="Create your first publication"> 81 + <p className="writeflow__lede"> 82 + You don't have a publication yet — create one to publish into. 83 + </p> 84 + <PublicationForm 85 + agent={ agent } 86 + did={ identity.did } 87 + pdsUrl={ pdsUrl } 88 + handle={ identity.handle ?? identity.did } 89 + onSaved={ ( pub ) => { 90 + setTargetUri( pub.uri ); 91 + onReloadPublications(); 92 + } } 93 + onCancel={ onCancel } 94 + /> 95 + </section> 96 + ); 97 + } 98 + 99 + async function run() { 100 + if ( ! target ) { 101 + return; 102 + } 103 + setPhase( 'working' ); 104 + setError( null ); 105 + try { 106 + const prepared = await uploadHeldAssets( agent, { 107 + blocks, 108 + coverDataUrl, 109 + did: identity.did, 110 + pdsUrl, 111 + } ); 112 + const res = await publish( agent, identity, { 113 + title: title.trim(), 114 + description, 115 + blocks: prepared.blocks, 116 + publicationUri: target.uri, 117 + publicationCid: target.cid, 118 + publicationSlug: target.slug, 119 + coverImage: prepared.coverImage, 120 + } ); 121 + onPublished( { articleUrl: res.articleUrl } ); 122 + } catch ( err ) { 123 + setError( err instanceof Error ? err.message : String( err ) ); 124 + setPhase( 'error' ); 125 + } 126 + } 127 + 128 + const overLimit = Boolean( preview?.overLimit ); 129 + 130 + return ( 131 + <section className="writeflow" aria-label="Publish"> 132 + { pubs.length > 1 && ( 133 + <label className="writeflow__target"> 134 + <span>Publish to</span> 135 + <select 136 + value={ target?.uri ?? '' } 137 + onChange={ ( e ) => setTargetUri( e.target.value ) } 138 + disabled={ phase === 'working' } 139 + > 140 + { pubs.map( ( p ) => ( 141 + <option key={ p.uri } value={ p.uri }> 142 + { p.name } 143 + </option> 144 + ) ) } 145 + </select> 146 + </label> 147 + ) } 148 + 149 + <p className="writeflow__warning"> 150 + Publishing saves this article to <strong>your PDS</strong> and also creates a{ ' ' } 151 + <strong>public Bluesky post</strong> linking to it 152 + { target ? <> in <strong>{ target.name }</strong></> : null }. Everyone following you 153 + will see it. 154 + { preview && preview.handles.length > 0 && ( 155 + <> It will notify <strong>{ preview.handles.join( ', ' ) }</strong>.</> 156 + ) } 157 + </p> 158 + 159 + { overLimit && ( 160 + <p className="writeflow__count" aria-live="polite"> 161 + Bluesky post: { preview!.graphemes }/300 — too long to publish; shorten the 162 + subtitle or remove a mention. 163 + </p> 164 + ) } 165 + 166 + { phase === 'working' ? ( 167 + <p className="writeflow__status">Publishing…</p> 168 + ) : ( 169 + <div className="writeflow__actions"> 170 + <button 171 + type="button" 172 + className="writeflow__publish" 173 + disabled={ overLimit || ! target } 174 + onClick={ () => void run() } 175 + > 176 + Publish &amp; post to Bluesky 177 + </button> 178 + </div> 179 + ) } 180 + 181 + { phase === 'error' && error && ( 182 + <p className="writeflow__error" role="alert"> 183 + Publish failed: { error } 184 + </p> 185 + ) } 186 + </section> 187 + ); 188 + }
+95
src/components/WriteStudio.test.tsx
··· 1 + // src/components/WriteStudio.test.tsx 2 + import { describe, it, expect, vi, beforeEach } from 'vitest'; 3 + import { act, createElement } from 'react'; 4 + import { createRoot } from 'react-dom/client'; 5 + 6 + ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; 7 + 8 + // Stub the heavy editor: render a marker, ignore props. 9 + vi.mock( './SkyEditor', () => ( { default: () => createElement( 'div', { 'data-testid': 'sky-editor' } ) } ) ); 10 + // Stub AppBar to avoid pulling chrome we don't assert on. 11 + vi.mock( './AppBar', () => ( { default: () => null } ) ); 12 + 13 + const auth = vi.hoisted( () => ( { value: {} as Record< string, unknown > } ) ); 14 + vi.mock( '../lib/auth/AuthProvider', () => ( { 15 + AuthProvider: ( { children }: { children: unknown } ) => children, 16 + } ) ); 17 + vi.mock( '../lib/auth/useAuth', () => ( { useAuth: () => auth.value } ) ); 18 + 19 + const draft = vi.hoisted( () => ( { 20 + store: { 21 + load: vi.fn( async () => null ), 22 + save: vi.fn( async () => {} ), 23 + clear: vi.fn( async () => {} ), 24 + setPublishIntent: vi.fn(), 25 + consumePublishIntent: vi.fn( () => false ), 26 + }, 27 + } ) ); 28 + vi.mock( '../lib/write/draft-store', () => ( { 29 + createDraftStore: () => draft.store, 30 + } ) ); 31 + vi.mock( '../lib/publish/publications', () => ( { 32 + listPublications: vi.fn( async () => [] ), 33 + } ) ); 34 + 35 + import WriteStudio from './WriteStudio'; 36 + 37 + function render() { 38 + const container = document.createElement( 'div' ); 39 + document.body.appendChild( container ); 40 + const root = createRoot( container ); 41 + return { container, root }; 42 + } 43 + 44 + beforeEach( () => { 45 + draft.store.consumePublishIntent.mockReturnValue( false ); 46 + draft.store.load.mockResolvedValue( null ); 47 + } ); 48 + 49 + describe( 'WriteStudio', () => { 50 + it( 'signed out: no gate — renders the editor and a Publish action, with no sign-in shown until Publish is hit', async () => { 51 + auth.value = { status: 'signed-out', agent: null, did: null, handle: null, signIn: vi.fn(), signOut: vi.fn() }; 52 + const { container, root } = render(); 53 + await act( async () => { 54 + root.render( createElement( WriteStudio ) ); 55 + } ); 56 + // The editor is always present (no login gate)… 57 + expect( container.querySelector( '[data-testid="sky-editor"]' ) ).not.toBe( null ); 58 + // …and the only auth-adjacent affordance is Publish — no persistent "Sign in" button, 59 + // and no sign-in panel until the writer hits Publish. 60 + expect( container.querySelector( '.write-publish' ) ).not.toBe( null ); 61 + expect( container.querySelector( '.signin-panel' ) ).toBe( null ); 62 + expect( container.textContent?.toLowerCase() ).not.toContain( 'sign in' ); 63 + } ); 64 + 65 + it( 'signed in: renders the editor and the Publish action, and does NOT duplicate the identity pill (the app bar carries it)', async () => { 66 + auth.value = { 67 + status: 'signed-in', agent: {}, did: 'did:plc:me', handle: 'me.test', 68 + displayName: 'Me', avatar: null, pdsUrl: 'https://pds', signIn: vi.fn(), signOut: vi.fn(), 69 + }; 70 + const { container, root } = render(); 71 + await act( async () => { 72 + root.render( createElement( WriteStudio ) ); 73 + } ); 74 + expect( container.querySelector( '[data-testid="sky-editor"]' ) ).not.toBe( null ); 75 + expect( container.querySelector( '.write-publish' ) ).not.toBe( null ); 76 + // The app bar (mocked out here) is the single source of the signed-in identity — 77 + // WriteStudio must not render its own duplicate pill. 78 + expect( container.querySelector( '.account-pill' ) ).toBe( null ); 79 + } ); 80 + 81 + it( 'resume: a publish intent on a signed-in load opens the publish flow', async () => { 82 + draft.store.consumePublishIntent.mockReturnValue( true ); 83 + auth.value = { 84 + status: 'signed-in', agent: {}, did: 'did:plc:me', handle: 'me.test', 85 + displayName: 'Me', avatar: null, pdsUrl: 'https://pds', signIn: vi.fn(), signOut: vi.fn(), 86 + }; 87 + const { container, root } = render(); 88 + await act( async () => { 89 + root.render( createElement( WriteStudio ) ); 90 + } ); 91 + expect( container.querySelector( '.writeflow' ) ).not.toBe( null ); 92 + // The top Publish action gives way to the stepper — no redundant button. 93 + expect( container.querySelector( '.write-actions' ) ).toBe( null ); 94 + } ); 95 + } );
+241
src/components/WriteStudio.tsx
··· 1 + // src/components/WriteStudio.tsx 2 + import { useEffect, useMemo, useRef, useState } from 'react'; 3 + import type { BlockInstance } from '@wordpress/blocks'; 4 + import { AuthProvider } from '../lib/auth/AuthProvider'; 5 + import { useAuth } from '../lib/auth/useAuth'; 6 + import EditorCanvas from './EditorCanvas'; 7 + import AppBar from './AppBar'; 8 + import PublishedPill from './PublishedPill'; 9 + import SignInPanel from './SignInPanel'; 10 + import WritePublishFlow from './WritePublishFlow'; 11 + import { createDeferredMediaUpload } from '../lib/write/deferred-media'; 12 + import { createDraftStore, type WriteDraft } from '../lib/write/draft-store'; 13 + import { listPublications, type Publication } from '../lib/publish/publications'; 14 + import { normalizeBlocks } from '../lib/publish/records'; 15 + import { validateCoverFile, type CoverUpload } from '../lib/media/cover'; 16 + import type { BlockNode } from '../lib/blocks/render'; 17 + import type { BlobRefJson } from '../lib/media/blob'; 18 + 19 + /** Read a file into a `data:` URL for a held (not-yet-uploaded) cover preview. */ 20 + function readAsDataUrl( file: File ): Promise< string > { 21 + return new Promise( ( resolve, reject ) => { 22 + const reader = new FileReader(); 23 + reader.onload = () => resolve( reader.result as string ); 24 + reader.onerror = () => reject( reader.error ?? new Error( 'Could not read the file.' ) ); 25 + reader.readAsDataURL( file ); 26 + } ); 27 + } 28 + 29 + /** A placeholder ref for a held cover — only `previewUrl` (a data URL) is used before publish. */ 30 + function deferredCoverRef( file: File ): BlobRefJson { 31 + return { $type: 'blob', ref: { $link: '' }, mimeType: file.type, size: file.size }; 32 + } 33 + 34 + function WriteSurface() { 35 + const { status, agent, did, handle, pdsUrl, error, signIn } = useAuth(); 36 + 37 + const draftStore = useMemo( () => createDraftStore(), [] ); 38 + const mediaUpload = useMemo( () => createDeferredMediaUpload(), [] ); 39 + 40 + const [ title, setTitle ] = useState( '' ); 41 + const [ lede, setLede ] = useState( '' ); 42 + const [ blocks, setBlocks ] = useState< BlockNode[] >( [] ); 43 + const [ cover, setCover ] = useState< CoverUpload | null >( null ); 44 + const [ publications, setPublications ] = useState< Publication[] | null >( null ); 45 + const [ flowOpen, setFlowOpen ] = useState( false ); 46 + const [ signinOpen, setSigninOpen ] = useState( false ); 47 + const [ published, setPublished ] = useState< { url: string } | null >( null ); 48 + const [ editorKey, setEditorKey ] = useState( 0 ); 49 + 50 + // Restored content fed to the editor canvas once, captured so a later `blocks` 51 + // change can't remount SkyEditor (which would wipe the canvas). 52 + const initialBlocksRef = useRef< BlockNode[] >( [] ); 53 + const intentRef = useRef( false ); 54 + 55 + // One-shot restore + intent read on mount. 56 + useEffect( () => { 57 + let cancelled = false; 58 + intentRef.current = draftStore.consumePublishIntent(); 59 + draftStore.load().then( ( d: WriteDraft | null ) => { 60 + if ( cancelled || ! d ) { 61 + return; 62 + } 63 + initialBlocksRef.current = d.blocks; 64 + setTitle( d.title ); 65 + setLede( d.lede ); 66 + setBlocks( d.blocks ); 67 + if ( d.coverDataUrl ) { 68 + setCover( { 69 + ref: { $type: 'blob', ref: { $link: '' }, mimeType: '', size: 0 }, 70 + previewUrl: d.coverDataUrl, 71 + } ); 72 + } 73 + setEditorKey( ( k ) => k + 1 ); 74 + } ); 75 + return () => { 76 + cancelled = true; 77 + }; 78 + }, [ draftStore ] ); 79 + 80 + // Load publications once signed in. 81 + useEffect( () => { 82 + if ( ! agent || ! did ) { 83 + return; 84 + } 85 + let cancelled = false; 86 + listPublications( agent, did ) 87 + .then( ( list ) => ! cancelled && setPublications( list ) ) 88 + .catch( () => ! cancelled && setPublications( [] ) ); 89 + return () => { 90 + cancelled = true; 91 + }; 92 + }, [ agent, did ] ); 93 + 94 + // Resume: a publish intent + a signed-in session auto-opens the publish flow. 95 + useEffect( () => { 96 + if ( status === 'signed-in' && intentRef.current ) { 97 + intentRef.current = false; 98 + setFlowOpen( true ); 99 + } 100 + }, [ status ] ); 101 + 102 + const reloadPublications = () => { 103 + if ( agent && did ) { 104 + listPublications( agent, did ) 105 + .then( setPublications ) 106 + .catch( () => setPublications( [] ) ); 107 + } 108 + }; 109 + 110 + async function persistDraft() { 111 + await draftStore.save( { 112 + title, 113 + lede, 114 + blocks, 115 + coverDataUrl: cover?.previewUrl ?? null, 116 + } ); 117 + } 118 + 119 + async function onSignIn( value: string ) { 120 + // Sign-in is only ever reached via Publish, so always stamp the intent that 121 + // auto-resumes the publish flow when the OAuth redirect returns. 122 + await persistDraft(); 123 + draftStore.setPublishIntent(); 124 + await signIn( value ); // full-page redirect; never resolves 125 + } 126 + 127 + function onPublishClicked() { 128 + if ( status === 'signed-in' ) { 129 + void persistDraft(); 130 + setFlowOpen( true ); 131 + return; 132 + } 133 + // Signed out → reveal the sign-in panel framed around publishing. Signing in is 134 + // only offered here, on the way to publishing — never as standalone chrome. 135 + setSigninOpen( true ); 136 + } 137 + 138 + async function uploadCover( file: File ): Promise< CoverUpload > { 139 + const message = validateCoverFile( file ); 140 + if ( message ) { 141 + throw new Error( message ); 142 + } 143 + const previewUrl = await readAsDataUrl( file ); 144 + return { ref: deferredCoverRef( file ), previewUrl }; 145 + } 146 + 147 + const canPublish = title.trim().length > 0 && blocks.length > 0; 148 + const signedIn = status === 'signed-in' && agent && did; 149 + 150 + return ( 151 + <> 152 + <AppBar current="editor" /> 153 + 154 + { /* The Publish action, right-aligned in the shared content column. The signed-in 155 + identity + sign-out live in the app bar above, so there is no pill here — and 156 + no standalone "Sign in" affordance (signing in happens on the way to publish). 157 + Hidden once the publish stepper takes over, so the button isn't shown twice. */ } 158 + { ! ( signedIn && flowOpen ) && ( 159 + <div className="write-actions"> 160 + <button 161 + type="button" 162 + className="write-publish" 163 + disabled={ ! canPublish } 164 + onClick={ onPublishClicked } 165 + > 166 + Publish… 167 + </button> 168 + </div> 169 + ) } 170 + 171 + { published && <PublishedPill url={ published.url } isEditing={ false } /> } 172 + 173 + { ! signedIn && signinOpen && ( 174 + <div className="write-signin"> 175 + <SignInPanel 176 + forPublish 177 + error={ error } 178 + onSubmit={ ( value ) => void onSignIn( value ) } 179 + onCancel={ () => setSigninOpen( false ) } 180 + /> 181 + </div> 182 + ) } 183 + 184 + { signedIn && flowOpen && ( 185 + <WritePublishFlow 186 + agent={ agent! } 187 + identity={ { did: did!, handle } } 188 + pdsUrl={ pdsUrl ?? '' } 189 + blocks={ blocks } 190 + coverDataUrl={ cover?.previewUrl ?? null } 191 + title={ title } 192 + description={ lede } 193 + publications={ publications } 194 + onReloadPublications={ reloadPublications } 195 + onPublished={ ( result ) => { 196 + setFlowOpen( false ); 197 + setPublished( { url: result.articleUrl } ); 198 + void draftStore.clear(); 199 + // Reset to a fresh new article. 200 + setTitle( '' ); 201 + setLede( '' ); 202 + setBlocks( [] ); 203 + setCover( null ); 204 + initialBlocksRef.current = []; 205 + setEditorKey( ( k ) => k + 1 ); 206 + } } 207 + onCancel={ () => setFlowOpen( false ) } 208 + /> 209 + ) } 210 + 211 + <div key={ editorKey }> 212 + <EditorCanvas 213 + title={ title } 214 + onTitleChange={ ( value ) => { 215 + setPublished( null ); 216 + setTitle( value ); 217 + } } 218 + lede={ lede } 219 + onLedeChange={ setLede } 220 + onBlocksChange={ ( instances: BlockInstance[] ) => 221 + setBlocks( normalizeBlocks( instances ) ) 222 + } 223 + mediaUpload={ mediaUpload } 224 + initialBlocks={ initialBlocksRef.current } 225 + cover={ cover } 226 + onCoverUpload={ uploadCover } 227 + onCoverChange={ setCover } 228 + /> 229 + </div> 230 + </> 231 + ); 232 + } 233 + 234 + /** The writing-first island: editor for every auth status, publish deferred (design 2026-06-17). */ 235 + export default function WriteStudio() { 236 + return ( 237 + <AuthProvider> 238 + <WriteSurface /> 239 + </AuthProvider> 240 + ); 241 + }
+2 -2
src/lib/auth/profile.test.ts
··· 76 76 it( 'returns Dashboard, Write and Profile in order for a profile with a handle', () => { 77 77 expect( accountMenuItems( { ...base, handle: 'jane.bsky.social' } ) ).toEqual( [ 78 78 { label: 'Dashboard', href: '/dashboard' }, 79 - { label: 'Write', href: '/editor' }, 79 + { label: 'Write', href: '/write' }, 80 80 { label: 'Profile', href: '/@jane.bsky.social' }, 81 81 ] ); 82 82 } ); ··· 84 84 it( 'omits the Profile item when no handle is known', () => { 85 85 expect( accountMenuItems( { ...base, handle: null } ) ).toEqual( [ 86 86 { label: 'Dashboard', href: '/dashboard' }, 87 - { label: 'Write', href: '/editor' }, 87 + { label: 'Write', href: '/write' }, 88 88 ] ); 89 89 } ); 90 90 } );
+1 -1
src/lib/auth/profile.ts
··· 55 55 export function accountMenuItems( profile: ViewerProfile ): MenuItem[] { 56 56 const items: MenuItem[] = [ 57 57 { label: 'Dashboard', href: '/dashboard' }, 58 - { label: 'Write', href: '/editor' }, 58 + { label: 'Write', href: '/write' }, 59 59 ]; 60 60 const profileHref = authorPath( profile.handle ); 61 61 if ( profileHref ) {
+6 -5
src/lib/landing/landing-content.test.ts
··· 50 50 describe( 'landing hero redesign', () => { 51 51 const index = read( '../../pages/index.astro' ); 52 52 53 - it( 'mounts the HandleStart island as the primary CTA', () => { 54 - expect( index ).toMatch( /import HandleStart from '\.\.\/components\/HandleStart'/ ); 55 - expect( index ).toMatch( /<HandleStart\s+client:only="react"\s*\/>/ ); 53 + it( 'leads with a single "Start writing" CTA to /write and no on-page sign-in', () => { 54 + // Writing-first: the hero's only action starts a draft on /write. The handle sign-in 55 + // island was removed — signing in happens via Publish on /write, not from the home page. 56 + expect( index ).toMatch( /href="\/write"[^>]*>\s*Start writing/ ); 57 + expect( index ).not.toMatch( /HandleStart/ ); 56 58 } ); 57 59 58 - it( 'drops the old multi-button hero actions', () => { 59 - expect( index ).not.toMatch( /Start writing/ ); 60 + it( 'drops the old sample / lexicon / studio hero buttons', () => { 60 61 expect( index ).not.toMatch( /Read a sample/ ); 61 62 expect( index ).not.toMatch( /See the lexicon/ ); 62 63 expect( index ).not.toMatch( /href="\/editor">Studio/ );
+20
src/lib/write/asset-store.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { createMemoryAssetStore } from './asset-store'; 3 + 4 + describe( 'createMemoryAssetStore', () => { 5 + it( 'puts, merges, reads all, and clears', async () => { 6 + const store = createMemoryAssetStore(); 7 + await store.put( { a0: 'data:1' } ); 8 + await store.put( { a1: 'data:2' } ); 9 + expect( await store.getAll() ).toEqual( { a0: 'data:1', a1: 'data:2' } ); 10 + await store.clear(); 11 + expect( await store.getAll() ).toEqual( {} ); 12 + } ); 13 + 14 + it( 'replaces a key on re-put', async () => { 15 + const store = createMemoryAssetStore(); 16 + await store.put( { a0: 'data:1' } ); 17 + await store.put( { a0: 'data:CHANGED' } ); 18 + expect( await store.getAll() ).toEqual( { a0: 'data:CHANGED' } ); 19 + } ); 20 + } );
+116
src/lib/write/asset-store.ts
··· 1 + /** 2 + * A small async key→value store for held image bytes (data: URLs) in the writing-first flow. 3 + * Held bytes can be large, so they live here (IndexedDB) rather than in localStorage with the 4 + * draft metadata. `put` MERGES (does not replace the whole store) so adding an image keeps the 5 + * earlier ones; `clear` empties everything (called after a successful publish or a discard). 6 + */ 7 + export interface AssetStore { 8 + put( assets: Record< string, string > ): Promise< void >; 9 + getAll(): Promise< Record< string, string > >; 10 + clear(): Promise< void >; 11 + } 12 + 13 + /** In-memory store: the test/SSR/quota-failure fallback. Survives nothing past a reload. */ 14 + export function createMemoryAssetStore(): AssetStore { 15 + let map: Record< string, string > = {}; 16 + return { 17 + async put( assets ) { 18 + map = { ...map, ...assets }; 19 + }, 20 + async getAll() { 21 + return { ...map }; 22 + }, 23 + async clear() { 24 + map = {}; 25 + }, 26 + }; 27 + } 28 + 29 + const DEFAULT_DB = 'skypress-write'; 30 + const DEFAULT_STORE = 'assets'; 31 + 32 + function openDb( dbName: string, storeName: string ): Promise< IDBDatabase > { 33 + return new Promise( ( resolve, reject ) => { 34 + const req = indexedDB.open( dbName, 1 ); 35 + req.onupgradeneeded = () => { 36 + if ( ! req.result.objectStoreNames.contains( storeName ) ) { 37 + req.result.createObjectStore( storeName ); 38 + } 39 + }; 40 + req.onsuccess = () => resolve( req.result ); 41 + req.onerror = () => reject( req.error ?? new Error( 'IndexedDB open failed' ) ); 42 + } ); 43 + } 44 + 45 + /** 46 + * IndexedDB-backed asset store. Each token is one record keyed by the token string. Falls back 47 + * to an in-memory store when IndexedDB is unavailable (e.g. SSR/tests) so callers never crash. 48 + */ 49 + export function createIndexedDbAssetStore( 50 + dbName: string = DEFAULT_DB, 51 + storeName: string = DEFAULT_STORE 52 + ): AssetStore { 53 + if ( typeof indexedDB === 'undefined' ) { 54 + return createMemoryAssetStore(); 55 + } 56 + 57 + const tx = async < T >( 58 + mode: IDBTransactionMode, 59 + run: ( store: IDBObjectStore ) => IDBRequest | void, 60 + read?: ( store: IDBObjectStore ) => IDBRequest< T > 61 + ): Promise< T | void > => { 62 + const db = await openDb( dbName, storeName ); 63 + return new Promise< T | void >( ( resolve, reject ) => { 64 + const transaction = db.transaction( storeName, mode ); 65 + const store = transaction.objectStore( storeName ); 66 + let readReq: IDBRequest< T > | undefined; 67 + if ( read ) { 68 + readReq = read( store ); 69 + } else { 70 + run( store ); 71 + } 72 + transaction.oncomplete = () => { 73 + db.close(); 74 + resolve( readReq ? readReq.result : undefined ); 75 + }; 76 + transaction.onerror = () => { 77 + db.close(); 78 + reject( transaction.error ?? new Error( 'IndexedDB transaction failed' ) ); 79 + }; 80 + } ); 81 + }; 82 + 83 + return { 84 + async put( assets ) { 85 + await tx( 'readwrite', ( store ) => { 86 + for ( const [ key, value ] of Object.entries( assets ) ) { 87 + store.put( value, key ); 88 + } 89 + } ); 90 + }, 91 + async getAll() { 92 + const db = await openDb( dbName, storeName ); 93 + return new Promise< Record< string, string > >( ( resolve, reject ) => { 94 + const transaction = db.transaction( storeName, 'readonly' ); 95 + const store = transaction.objectStore( storeName ); 96 + const keysReq = store.getAllKeys(); 97 + const valsReq = store.getAll(); 98 + transaction.oncomplete = () => { 99 + db.close(); 100 + const out: Record< string, string > = {}; 101 + ( keysReq.result as IDBValidKey[] ).forEach( ( k, i ) => { 102 + out[ String( k ) ] = valsReq.result[ i ] as string; 103 + } ); 104 + resolve( out ); 105 + }; 106 + transaction.onerror = () => { 107 + db.close(); 108 + reject( transaction.error ?? new Error( 'IndexedDB read failed' ) ); 109 + }; 110 + } ); 111 + }, 112 + async clear() { 113 + await tx( 'readwrite', ( store ) => store.clear() ); 114 + }, 115 + }; 116 + }
+37
src/lib/write/deferred-media.test.ts
··· 1 + import { describe, it, expect, vi } from 'vitest'; 2 + import { createDeferredMediaUpload } from './deferred-media'; 3 + 4 + describe( 'createDeferredMediaUpload', () => { 5 + it( 'previews each file as a data: URL and uploads nothing', async () => { 6 + const handler = createDeferredMediaUpload(); 7 + const file = new File( [ 'x' ], 'cat.png', { type: 'image/png' } ); 8 + const onFileChange = vi.fn(); 9 + 10 + await handler( { filesList: [ file ], onFileChange } ); 11 + 12 + expect( onFileChange ).toHaveBeenCalledTimes( 1 ); 13 + const [ media ] = onFileChange.mock.calls[ 0 ][ 0 ]; 14 + expect( media.url.startsWith( 'data:image/png;base64,' ) ).toBe( true ); 15 + expect( media.url.startsWith( 'blob:' ) ).toBe( false ); 16 + expect( 'id' in media ).toBe( false ); 17 + } ); 18 + 19 + it( 'rejects oversize files via onError without previewing them', async () => { 20 + const handler = createDeferredMediaUpload(); 21 + const big = new File( [ 'x' ], 'big.png', { type: 'image/png' } ); 22 + Object.defineProperty( big, 'size', { value: 5_000_000 } ); 23 + const onFileChange = vi.fn(); 24 + const onError = vi.fn(); 25 + 26 + await handler( { 27 + filesList: [ big ], 28 + onFileChange, 29 + onError, 30 + maxUploadFileSize: 1_000_000, 31 + } ); 32 + 33 + expect( onFileChange ).not.toHaveBeenCalled(); 34 + expect( onError ).toHaveBeenCalledTimes( 1 ); 35 + expect( onError.mock.calls[ 0 ][ 0 ] ).toBeInstanceOf( Error ); 36 + } ); 37 + } );
+43
src/lib/write/deferred-media.ts
··· 1 + import type { MediaUploadHandler } from '../media/mediaUpload'; 2 + 3 + /** Read a file into a `data:` URL (base64) for inline preview. */ 4 + function readAsDataUrl( file: File ): Promise< string > { 5 + return new Promise( ( resolve, reject ) => { 6 + const reader = new FileReader(); 7 + reader.onload = () => resolve( reader.result as string ); 8 + reader.onerror = () => 9 + reject( reader.error ?? new Error( `Could not read "${ file.name }".` ) ); 10 + reader.readAsDataURL( file ); 11 + } ); 12 + } 13 + 14 + /** 15 + * A Gutenberg `mediaUpload` handler for the writing-first flow (design 2026-06-17): it 16 + * previews each file from a `data:` URL and uploads NOTHING to a PDS. The real upload is 17 + * deferred to publish (see `upload-held.ts`), so the editor works while signed out. 18 + * 19 + * Like the eager handler it must preview from a `data:` URL, never a `blob:` URL — the Image 20 + * block treats a `blob:` URL as still-uploading and re-runs its upload hook forever. 21 + */ 22 + export function createDeferredMediaUpload(): MediaUploadHandler { 23 + return async function deferredMediaUpload( { 24 + filesList, 25 + onFileChange, 26 + onError, 27 + maxUploadFileSize, 28 + } ): Promise< void > { 29 + for ( const file of Array.from( filesList ) ) { 30 + try { 31 + if ( maxUploadFileSize && file.size > maxUploadFileSize ) { 32 + const mb = Math.round( maxUploadFileSize / 1024 / 1024 ); 33 + throw new Error( `"${ file.name }" exceeds the ${ mb }MB limit.` ); 34 + } 35 + const previewUrl = await readAsDataUrl( file ); 36 + // No `id`: PDS blobs aren't WP attachments (mirrors the eager handler). 37 + onFileChange( [ { url: previewUrl, alt: '' } ] ); 38 + } catch ( error ) { 39 + onError?.( error instanceof Error ? error : new Error( String( error ) ) ); 40 + } 41 + } 42 + }; 43 + }
+54
src/lib/write/draft-store.test.ts
··· 1 + import { describe, it, expect, beforeEach } from 'vitest'; 2 + import { createDraftStore, type WriteDraft } from './draft-store'; 3 + import { createMemoryAssetStore } from './asset-store'; 4 + import type { BlockNode } from '../blocks/render'; 5 + 6 + const DATA = 'data:image/png;base64,QUFB'; 7 + 8 + function draft(): WriteDraft { 9 + return { 10 + title: 'Hello', 11 + lede: 'A lede', 12 + blocks: [ 13 + { name: 'core/paragraph', attributes: { content: 'hi' }, innerBlocks: [] }, 14 + { name: 'core/image', attributes: { url: DATA }, innerBlocks: [] }, 15 + ] as BlockNode[], 16 + coverDataUrl: DATA, 17 + }; 18 + } 19 + 20 + beforeEach( () => window.localStorage.clear() ); 21 + 22 + function newStore() { 23 + return createDraftStore( { assets: createMemoryAssetStore(), storage: window.localStorage } ); 24 + } 25 + 26 + describe( 'draft-store', () => { 27 + it( 'round-trips a draft including held image + cover bytes', async () => { 28 + const store = newStore(); 29 + await store.save( draft() ); 30 + // Image bytes must NOT sit in localStorage (only the token skeleton). 31 + expect( window.localStorage.getItem( 'skypress:write:draft' ) ).not.toContain( 'base64' ); 32 + const loaded = await store.load(); 33 + expect( loaded ).toEqual( draft() ); 34 + } ); 35 + 36 + it( 'returns null when nothing is saved', async () => { 37 + expect( await newStore().load() ).toBe( null ); 38 + } ); 39 + 40 + it( 'clear() removes the draft and its assets', async () => { 41 + const store = newStore(); 42 + await store.save( draft() ); 43 + await store.clear(); 44 + expect( await store.load() ).toBe( null ); 45 + } ); 46 + 47 + it( 'publish intent is one-shot (set, then consumed once)', () => { 48 + const store = newStore(); 49 + expect( store.consumePublishIntent() ).toBe( false ); 50 + store.setPublishIntent(); 51 + expect( store.consumePublishIntent() ).toBe( true ); 52 + expect( store.consumePublishIntent() ).toBe( false ); 53 + } ); 54 + } );
+83
src/lib/write/draft-store.ts
··· 1 + import type { BlockNode } from '../blocks/render'; 2 + import { splitAssets, mergeAssets, type AssetSkeleton } from './held-assets'; 3 + import { 4 + createIndexedDbAssetStore, 5 + createMemoryAssetStore, 6 + type AssetStore, 7 + } from './asset-store'; 8 + 9 + const DRAFT_KEY = 'skypress:write:draft'; 10 + const INTENT_KEY = 'skypress:write:publish-intent'; 11 + 12 + /** The editor content the writing-first flow persists across the OAuth redirect. */ 13 + export interface WriteDraft { 14 + title: string; 15 + lede: string; 16 + blocks: BlockNode[]; 17 + coverDataUrl: string | null; 18 + } 19 + 20 + interface StoredMeta { 21 + title: string; 22 + lede: string; 23 + skeleton: AssetSkeleton; 24 + } 25 + 26 + export interface DraftStore { 27 + save( draft: WriteDraft ): Promise< void >; 28 + load(): Promise< WriteDraft | null >; 29 + clear(): Promise< void >; 30 + setPublishIntent(): void; 31 + /** Reads the intent flag and clears it — true at most once per set. */ 32 + consumePublishIntent(): boolean; 33 + } 34 + 35 + /** 36 + * Persist the writing-first draft so it survives the full-page OAuth redirect. Light metadata 37 + * and the token-skeletoned block tree go in `localStorage`; the heavy image bytes (data: URLs) 38 + * go in the injected `AssetStore` (IndexedDB in the browser). `setPublishIntent` records that 39 + * the writer hit Publish before the redirect, so the flow auto-resumes on return. 40 + */ 41 + export function createDraftStore( opts: { assets?: AssetStore; storage?: Storage } = {} ): DraftStore { 42 + const storage = opts.storage ?? window.localStorage; 43 + const assets = opts.assets ?? createIndexedDbAssetStore(); 44 + 45 + return { 46 + async save( draft ) { 47 + const { skeleton, assets: bytes } = splitAssets( draft.blocks, draft.coverDataUrl ); 48 + await assets.clear(); 49 + await assets.put( bytes ); 50 + const meta: StoredMeta = { title: draft.title, lede: draft.lede, skeleton }; 51 + storage.setItem( DRAFT_KEY, JSON.stringify( meta ) ); 52 + }, 53 + async load() { 54 + const raw = storage.getItem( DRAFT_KEY ); 55 + if ( ! raw ) { 56 + return null; 57 + } 58 + let meta: StoredMeta; 59 + try { 60 + meta = JSON.parse( raw ) as StoredMeta; 61 + } catch { 62 + return null; 63 + } 64 + const bytes = await assets.getAll(); 65 + const { blocks, coverDataUrl } = mergeAssets( meta.skeleton, bytes ); 66 + return { title: meta.title, lede: meta.lede, blocks, coverDataUrl }; 67 + }, 68 + async clear() { 69 + storage.removeItem( DRAFT_KEY ); 70 + await assets.clear(); 71 + }, 72 + setPublishIntent() { 73 + storage.setItem( INTENT_KEY, '1' ); 74 + }, 75 + consumePublishIntent() { 76 + const had = storage.getItem( INTENT_KEY ) === '1'; 77 + storage.removeItem( INTENT_KEY ); 78 + return had; 79 + }, 80 + }; 81 + } 82 + 83 + export { createMemoryAssetStore };
+89
src/lib/write/held-assets.test.ts
··· 1 + // @vitest-environment node 2 + // Pure module — no DOM needed. Node's global Blob exposes `.text()`, which the 3 + // repo's pinned jsdom Blob does not, so run this file under the node environment. 4 + import { describe, it, expect } from 'vitest'; 5 + import { 6 + isDataUrl, 7 + dataUrlToBlob, 8 + splitAssets, 9 + mergeAssets, 10 + } from './held-assets'; 11 + import type { BlockNode } from '../blocks/render'; 12 + 13 + const DATA_A = 'data:image/png;base64,QUFB'; // "AAA" 14 + const DATA_B = 'data:image/jpeg;base64,QkJC'; // "BBB" 15 + 16 + function tree(): BlockNode[] { 17 + return [ 18 + { name: 'core/paragraph', attributes: { content: 'hi' }, innerBlocks: [] }, 19 + { name: 'core/image', attributes: { url: DATA_A }, innerBlocks: [] }, 20 + { 21 + name: 'core/columns', 22 + attributes: {}, 23 + innerBlocks: [ 24 + { name: 'core/image', attributes: { url: DATA_B }, innerBlocks: [] }, 25 + { name: 'core/image', attributes: { url: 'https://ext/img.png' }, innerBlocks: [] }, 26 + ], 27 + }, 28 + ]; 29 + } 30 + 31 + describe( 'isDataUrl', () => { 32 + it( 'matches data: URLs only', () => { 33 + expect( isDataUrl( DATA_A ) ).toBe( true ); 34 + expect( isDataUrl( 'https://x/y.png' ) ).toBe( false ); 35 + expect( isDataUrl( undefined ) ).toBe( false ); 36 + } ); 37 + } ); 38 + 39 + describe( 'dataUrlToBlob', () => { 40 + it( 'reconstructs bytes + mime from a data: URL', async () => { 41 + const blob = dataUrlToBlob( DATA_A ); 42 + expect( blob.type ).toBe( 'image/png' ); 43 + expect( await blob.text() ).toBe( 'AAA' ); 44 + } ); 45 + } ); 46 + 47 + describe( 'splitAssets / mergeAssets', () => { 48 + it( 'extracts every data: image URL (depth-first) + cover into tokens and round-trips', () => { 49 + const { skeleton, assets } = splitAssets( tree(), DATA_A ); 50 + 51 + // Body data URLs replaced by tokens; external + non-image untouched. 52 + expect( skeleton.blocks[ 1 ].attributes!.url ).toBe( 'a0' ); 53 + expect( skeleton.blocks[ 2 ].innerBlocks![ 0 ].attributes!.url ).toBe( 'a1' ); 54 + expect( skeleton.blocks[ 2 ].innerBlocks![ 1 ].attributes!.url ).toBe( 'https://ext/img.png' ); 55 + expect( skeleton.cover ).toBe( 'cover' ); 56 + expect( assets ).toEqual( { a0: DATA_A, a1: DATA_B, cover: DATA_A } ); 57 + 58 + const merged = mergeAssets( skeleton, assets ); 59 + expect( merged.blocks ).toEqual( tree() ); 60 + expect( merged.coverDataUrl ).toBe( DATA_A ); 61 + } ); 62 + 63 + it( 'drops a dangling token (held bytes missing) but keeps external image URLs', () => { 64 + // localStorage skeleton survived but the IndexedDB bytes were evicted, so the 65 + // `a0` token has no entry in `assets`. The merged image must not keep `a0` as a 66 + // broken src; an external image alongside it stays untouched. 67 + const skeleton = { 68 + blocks: [ 69 + { name: 'core/image', attributes: { url: 'a0', alt: 'gone' }, innerBlocks: [] }, 70 + { name: 'core/image', attributes: { url: 'https://ext/x.png' }, innerBlocks: [] }, 71 + ], 72 + cover: 'cover', 73 + }; 74 + const merged = mergeAssets( skeleton, {} ); 75 + expect( merged.blocks[ 0 ].attributes ).toEqual( { alt: 'gone' } ); 76 + expect( merged.blocks[ 1 ].attributes!.url ).toBe( 'https://ext/x.png' ); 77 + expect( merged.coverDataUrl ).toBe( null ); 78 + } ); 79 + 80 + it( 'leaves a null cover null and a tree with no data URLs unchanged', () => { 81 + const plain: BlockNode[] = [ 82 + { name: 'core/image', attributes: { url: 'https://ext/x.png' }, innerBlocks: [] }, 83 + ]; 84 + const { skeleton, assets } = splitAssets( plain, null ); 85 + expect( assets ).toEqual( {} ); 86 + expect( skeleton.cover ).toBe( null ); 87 + expect( mergeAssets( skeleton, assets ) ).toEqual( { blocks: plain, coverDataUrl: null } ); 88 + } ); 89 + } );
+108
src/lib/write/held-assets.ts
··· 1 + import type { BlockNode } from '../blocks/render'; 2 + 3 + /** Block names whose `url` attribute may hold a held (data:) image. Mirrors blob.ts. */ 4 + const IMAGE_BLOCKS = new Set( [ 'core/image' ] ); 5 + 6 + /** True when `value` is a `data:` URL string. */ 7 + export function isDataUrl( value: unknown ): value is string { 8 + return typeof value === 'string' && value.startsWith( 'data:' ); 9 + } 10 + 11 + /** Rebuild a `Blob` (bytes + mime type) from a base64 `data:` URL. */ 12 + export function dataUrlToBlob( dataUrl: string ): Blob { 13 + const comma = dataUrl.indexOf( ',' ); 14 + const header = dataUrl.slice( 5, comma ); // after "data:" 15 + const mimeType = header.split( ';' )[ 0 ] || 'application/octet-stream'; 16 + const base64 = dataUrl.slice( comma + 1 ); 17 + const binary = atob( base64 ); 18 + const bytes = new Uint8Array( binary.length ); 19 + for ( let i = 0; i < binary.length; i++ ) { 20 + bytes[ i ] = binary.charCodeAt( i ); 21 + } 22 + return new Blob( [ bytes ], { type: mimeType } ); 23 + } 24 + 25 + export interface AssetSkeleton { 26 + blocks: BlockNode[]; 27 + /** `'cover'` when a held cover exists, else `null`. */ 28 + cover: string | null; 29 + } 30 + 31 + /** 32 + * Replace every held (`data:`) image URL in the tree — and the cover — with a short token, 33 + * returning the lightweight skeleton plus a `token → dataUrl` map. Body tokens are `a0`, 34 + * `a1`, … allocated depth-first; the cover token is the literal `'cover'`. External image 35 + * URLs and non-image blocks are left untouched. Pure; returns new objects. 36 + */ 37 + export function splitAssets( 38 + blocks: BlockNode[], 39 + coverDataUrl: string | null 40 + ): { skeleton: AssetSkeleton; assets: Record< string, string > } { 41 + const assets: Record< string, string > = {}; 42 + let n = 0; 43 + 44 + const walk = ( nodes: BlockNode[] ): BlockNode[] => 45 + nodes.map( ( node ) => { 46 + const url = node.attributes?.url; 47 + const held = IMAGE_BLOCKS.has( node.name ) && isDataUrl( url ); 48 + let attributes = node.attributes ? { ...node.attributes } : {}; 49 + if ( held ) { 50 + const token = `a${ n++ }`; 51 + assets[ token ] = url as string; 52 + attributes = { ...attributes, url: token }; 53 + } 54 + return { 55 + name: node.name, 56 + attributes, 57 + innerBlocks: walk( node.innerBlocks ?? [] ), 58 + }; 59 + } ); 60 + 61 + const skeletonBlocks = walk( blocks ); 62 + let cover: string | null = null; 63 + if ( isDataUrl( coverDataUrl ) ) { 64 + assets.cover = coverDataUrl; 65 + cover = 'cover'; 66 + } 67 + return { skeleton: { blocks: skeletonBlocks, cover }, assets }; 68 + } 69 + 70 + /** A body asset token allocated by `splitAssets` (`a0`, `a1`, …). */ 71 + function isAssetToken( value: string ): boolean { 72 + return /^a\d+$/.test( value ); 73 + } 74 + 75 + /** Inverse of `splitAssets`: swap each token back to its `data:` URL. Pure. */ 76 + export function mergeAssets( 77 + skeleton: AssetSkeleton, 78 + assets: Record< string, string > 79 + ): { blocks: BlockNode[]; coverDataUrl: string | null } { 80 + const walk = ( nodes: BlockNode[] ): BlockNode[] => 81 + nodes.map( ( node ) => { 82 + const url = node.attributes?.url; 83 + const isImage = IMAGE_BLOCKS.has( node.name ) && typeof url === 'string'; 84 + let attributes: Record< string, unknown >; 85 + if ( isImage && ( url as string ) in assets ) { 86 + attributes = { ...node.attributes, url: assets[ url as string ] }; 87 + } else if ( isImage && isAssetToken( url as string ) ) { 88 + // Held bytes missing (e.g. IndexedDB evicted while the localStorage skeleton 89 + // survived): drop the dangling token rather than emit a broken `a0` src that 90 + // would also be published verbatim. External image URLs are not tokens, so 91 + // they fall through and are preserved. 92 + const { url: _dropped, ...rest } = node.attributes ?? {}; 93 + attributes = { ...rest }; 94 + } else { 95 + attributes = { ...( node.attributes ?? {} ) }; 96 + } 97 + return { 98 + name: node.name, 99 + attributes, 100 + innerBlocks: walk( node.innerBlocks ?? [] ), 101 + }; 102 + } ); 103 + 104 + return { 105 + blocks: walk( skeleton.blocks ), 106 + coverDataUrl: skeleton.cover ? assets[ skeleton.cover ] ?? null : null, 107 + }; 108 + }
+59
src/lib/write/upload-held.test.ts
··· 1 + import { describe, it, expect, vi } from 'vitest'; 2 + import type { Agent } from '@atproto/api'; 3 + import { uploadHeldAssets } from './upload-held'; 4 + import type { BlockNode } from '../blocks/render'; 5 + 6 + const DATA = 'data:image/png;base64,QUFB'; // "AAA" 7 + 8 + function fakeAgent() { 9 + const uploadBlob = vi.fn( async ( _file: Blob ) => ( { 10 + data: { blob: { ref: { toString: () => 'bafyCID' }, mimeType: 'image/png', size: 3 } }, 11 + } ) ); 12 + return { agent: { uploadBlob } as unknown as Agent, uploadBlob }; 13 + } 14 + 15 + describe( 'uploadHeldAssets', () => { 16 + it( 'uploads each held image, attaches skypressBlob + a getBlob url, leaves externals alone', async () => { 17 + const { agent, uploadBlob } = fakeAgent(); 18 + const blocks: BlockNode[] = [ 19 + { name: 'core/image', attributes: { url: DATA }, innerBlocks: [] }, 20 + { name: 'core/image', attributes: { url: 'https://ext/x.png' }, innerBlocks: [] }, 21 + ]; 22 + 23 + const out = await uploadHeldAssets( agent, { 24 + blocks, 25 + coverDataUrl: null, 26 + did: 'did:plc:me', 27 + pdsUrl: 'https://pds.example.com', 28 + } ); 29 + 30 + expect( uploadBlob ).toHaveBeenCalledTimes( 1 ); 31 + expect( out.blocks[ 0 ].attributes!.skypressBlob ).toEqual( { 32 + $type: 'blob', 33 + ref: { $link: 'bafyCID' }, 34 + mimeType: 'image/png', 35 + size: 3, 36 + } ); 37 + expect( out.blocks[ 0 ].attributes!.url ).toBe( 38 + 'https://pds.example.com/xrpc/com.atproto.sync.getBlob?did=did%3Aplc%3Ame&cid=bafyCID' 39 + ); 40 + expect( out.blocks[ 1 ].attributes!.url ).toBe( 'https://ext/x.png' ); 41 + expect( out.coverImage ).toBeUndefined(); 42 + } ); 43 + 44 + it( 'uploads a held cover into a BlobRefJson', async () => { 45 + const { agent } = fakeAgent(); 46 + const out = await uploadHeldAssets( agent, { 47 + blocks: [], 48 + coverDataUrl: DATA, 49 + did: 'did:plc:me', 50 + pdsUrl: 'https://pds.example.com', 51 + } ); 52 + expect( out.coverImage ).toEqual( { 53 + $type: 'blob', 54 + ref: { $link: 'bafyCID' }, 55 + mimeType: 'image/png', 56 + size: 3, 57 + } ); 58 + } ); 59 + } );
+67
src/lib/write/upload-held.ts
··· 1 + import type { Agent } from '@atproto/api'; 2 + import type { BlockNode } from '../blocks/render'; 3 + import { 4 + attachBlobRefs, 5 + buildGetBlobUrl, 6 + type BlobRefJson, 7 + type BlobUpload, 8 + } from '../media/blob'; 9 + import { isDataUrl, dataUrlToBlob } from './held-assets'; 10 + 11 + export interface PreparedPublishContent { 12 + blocks: BlockNode[]; 13 + coverImage?: BlobRefJson; 14 + } 15 + 16 + /** Upload one held `data:` URL to the PDS and return its portable blob ref + getBlob URL. */ 17 + async function uploadOne( 18 + agent: Agent, 19 + dataUrl: string, 20 + did: string, 21 + pdsUrl: string 22 + ): Promise< BlobUpload > { 23 + const blob = dataUrlToBlob( dataUrl ); 24 + const res = await agent.uploadBlob( blob, { encoding: blob.type } ); 25 + const out = res.data.blob; 26 + const cid = out.ref.toString(); 27 + return { 28 + ref: { $type: 'blob', ref: { $link: cid }, mimeType: out.mimeType, size: out.size }, 29 + url: buildGetBlobUrl( pdsUrl, did, cid ), 30 + }; 31 + } 32 + 33 + /** 34 + * Publish-time bridge for the writing-first flow: walk the block tree, upload every held 35 + * (`data:`) image to the writer's PDS, and rewrite those blocks via `attachBlobRefs` so they 36 + * carry `skypressBlob` + a portable getBlob URL (byte-identical to the eager path). External 37 + * image URLs are left untouched. A held cover is uploaded into a `BlobRefJson` for the document 38 + * record. Each distinct data URL uploads once. 39 + */ 40 + export async function uploadHeldAssets( 41 + agent: Agent, 42 + input: { blocks: BlockNode[]; coverDataUrl: string | null; did: string; pdsUrl: string } 43 + ): Promise< PreparedPublishContent > { 44 + const { blocks, coverDataUrl, did, pdsUrl } = input; 45 + 46 + // Collect every distinct held image URL in the tree (depth-first), upload once each. 47 + const registry = new Map< string, BlobUpload >(); 48 + const collect = ( nodes: BlockNode[] ): string[] => 49 + nodes.flatMap( ( node ) => { 50 + const url = node.attributes?.url; 51 + const here = node.name === 'core/image' && isDataUrl( url ) ? [ url as string ] : []; 52 + return [ ...here, ...collect( node.innerBlocks ?? [] ) ]; 53 + } ); 54 + 55 + for ( const url of new Set( collect( blocks ) ) ) { 56 + registry.set( url, await uploadOne( agent, url, did, pdsUrl ) ); 57 + } 58 + 59 + const prepared = attachBlobRefs( blocks, ( url ) => registry.get( url ) ); 60 + 61 + let coverImage: BlobRefJson | undefined; 62 + if ( isDataUrl( coverDataUrl ) ) { 63 + coverImage = ( await uploadOne( agent, coverDataUrl, did, pdsUrl ) ).ref; 64 + } 65 + 66 + return { blocks: prepared, coverImage }; 67 + }
-51
src/pages/_index.handlestart.test.ts
··· 1 - /** 2 - * Regression guard for the landing-page handle CTA overflowing on narrow viewports. 3 - * 4 - * `.handlestart__row` is a flexbox row holding the input field (`flex: 1`) and the "Start" 5 - * button. A flex item's default `min-width: auto` refuses to shrink below its content's 6 - * intrinsic width — and the `<input>` carries a sizeable intrinsic floor. Without an explicit 7 - * `min-width: 0` on the field (and the input nested inside it), the field can't shrink on a 8 - * phone-width screen, so the row grows past the container and clips the "Start" button off the 9 - * right edge (reported 2026-06-09, reproduced at <=320px CSS px). 10 - * 11 - * Rendering the page through astro/container isn't viable here (the runner is pinned to jsdom 12 - * for the WordPress block suites), so — as with _index.phase.test.ts — these asserts pin the 13 - * fix at the source level. 14 - */ 15 - import { readFileSync } from 'node:fs'; 16 - import { fileURLToPath } from 'node:url'; 17 - import { describe, expect, it } from 'vitest'; 18 - 19 - const read = ( rel: string ) => 20 - readFileSync( fileURLToPath( new URL( rel, import.meta.url ) ), 'utf8' ); 21 - 22 - /** Pull the body of a CSS rule (between its `{` and the matching `}`) for the given selector. */ 23 - const ruleBody = ( css: string, selector: string ): string => { 24 - const start = css.indexOf( selector ); 25 - if ( start === -1 ) { 26 - return ''; 27 - } 28 - const open = css.indexOf( '{', start ); 29 - const close = css.indexOf( '}', open ); 30 - return css.slice( open + 1, close ); 31 - }; 32 - 33 - describe( 'landing page handle CTA shrinks on narrow viewports', () => { 34 - const style = read( './index.astro' ).match( /<style>([\s\S]*?)<\/style>/ )?.[ 1 ] ?? ''; 35 - 36 - it( 'lets the field shrink below the input intrinsic width (min-width: 0)', () => { 37 - const body = ruleBody( style, '.handlestart__field)' ); 38 - expect( body, 'expected a .handlestart__field rule in the landing styles' ).not.toBe( '' ); 39 - expect( body, '.handlestart__field must set min-width: 0 so it can shrink in the flex row' ).toMatch( 40 - /min-width:\s*0\b/ 41 - ); 42 - } ); 43 - 44 - it( 'lets the input itself shrink inside the field (min-width: 0)', () => { 45 - const body = ruleBody( style, '.handlestart__input)' ); 46 - expect( body, 'expected a .handlestart__input rule in the landing styles' ).not.toBe( '' ); 47 - expect( body, '.handlestart__input must set min-width: 0 so the field can collapse around it' ).toMatch( 48 - /min-width:\s*0\b/ 49 - ); 50 - } ); 51 - } );
+34
src/pages/_index.write-cta.test.ts
··· 1 + /** 2 + * The home page leads with the writing-first experience (/write), not the gated editor. 3 + * 4 + * Source-level asserts (like the sibling _index.*.test.ts) — rendering the page through 5 + * astro/container isn't viable under the jsdom-pinned runner, so we pin the link targets 6 + * at the source. 7 + */ 8 + import { readFileSync } from 'node:fs'; 9 + import { dirname, join } from 'node:path'; 10 + import { fileURLToPath } from 'node:url'; 11 + import { describe, expect, it } from 'vitest'; 12 + 13 + // Resolve via `fileURLToPath` + `join` (not `new URL(rel, import.meta.url)`): under this 14 + // repo's Astro/Vite-backed Vitest the global `URL` resolves a relative specifier to an 15 + // `http://localhost` dev URL, which `readFileSync` rejects. Mirrors `_write.meta.test.ts`. 16 + const here = dirname( fileURLToPath( import.meta.url ) ); 17 + const src = readFileSync( join( here, './index.astro' ), 'utf8' ); 18 + 19 + describe( 'home page leads with the writing-first experience', () => { 20 + it( 'points the masthead Write button at /write, not the gated editor', () => { 21 + expect( src ).toMatch( /class="[^"]*masthead-write[^"]*"\s+href="\/write"/ ); 22 + // The home page no longer routes anyone to the login-gated /editor. 23 + expect( src ).not.toContain( 'href="/editor"' ); 24 + } ); 25 + 26 + it( 'gives the hero a primary "Start writing" CTA to /write', () => { 27 + expect( src ).toMatch( /href="\/write"[^>]*>\s*Start writing/ ); 28 + } ); 29 + 30 + it( 'offers no sign-in island on the home page — signing in happens via Publish on /write', () => { 31 + expect( src ).not.toContain( '<HandleStart' ); 32 + expect( src ).not.toMatch( /import HandleStart/ ); 33 + } ); 34 + } );
+24
src/pages/_write.meta.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { readFileSync } from 'node:fs'; 3 + import { dirname, join } from 'node:path'; 4 + import { fileURLToPath } from 'node:url'; 5 + 6 + // Read the page source off disk. We resolve via `fileURLToPath` + `join` rather 7 + // than handing a `URL` straight to `readFileSync` because, under this repo's 8 + // Astro/Vite-backed Vitest, the global `URL` resolves a relative specifier 9 + // against the test module to an `http://localhost` dev-server URL (not `file:`), 10 + // which `readFileSync` rejects. This mirrors the sibling `_*.meta.test.ts` pages. 11 + const here = dirname( fileURLToPath( import.meta.url ) ); 12 + const src = readFileSync( join( here, './write.astro' ), 'utf8' ); 13 + 14 + describe( '/write route', () => { 15 + it( 'mounts WriteStudio as a client:only island', () => { 16 + expect( src ).toContain( "import WriteStudio from '../components/WriteStudio.tsx'" ); 17 + expect( src ).toMatch( /<WriteStudio\s+client:only="react"/ ); 18 + } ); 19 + 20 + it( 'ships a writing-focused title and the write chrome styles', () => { 21 + expect( src ).toMatch( /<Base title="Write[^"]*"/ ); 22 + expect( src ).toContain( "import '../styles/write-chrome.css'" ); 23 + } ); 24 + } );
+11 -86
src/pages/index.astro
··· 3 3 import Logo from '../components/Logo.astro'; 4 4 import Footer from '../components/Footer.astro'; 5 5 import AccountMenu from '../components/AccountMenu.tsx'; 6 - import HandleStart from '../components/HandleStart'; 7 6 import { PHASES, DEFAULT_PHASE } from '../lib/landing/time-of-day'; 8 7 9 8 const fallback = PHASES[ DEFAULT_PHASE ]; ··· 49 48 <header class="masthead"> 50 49 <Logo /> 51 50 <div class="masthead__right"> 52 - <a class="btn btn--ghost masthead-write" href="/editor">Write</a> 51 + <a class="btn btn--ghost masthead-write" href="/write">Write</a> 53 52 <AccountMenu client:only="react" /> 54 53 </div> 55 54 </header> ··· 59 58 <h1 class="hero__title" id="headline" set:html={fallback.headlineHtml} /> 60 59 <p class="hero__lede" id="lede">{fallback.lede}</p> 61 60 <div class="hero__cta"> 62 - <HandleStart client:only="react" /> 61 + <a class="btn btn--primary hero__start" href="/write">Start writing &rarr;</a> 63 62 </div> 64 63 <p class="hero__free">Free &amp; open-source. Your words live in your account, not ours.</p> 65 64 </main> ··· 444 443 color: var(--ink-soft); 445 444 } 446 445 447 - /* ===== Handle input island (lives on the sky) ===== */ 446 + /* ===== Primary CTA (lives on the sky) ===== */ 448 447 .hero__cta { width: 100%; max-width: 26rem; margin: 2rem auto 0; } 448 + /* Primary "Start writing" action — the writing-first front door, and the home page's only 449 + CTA (sign-in happens via Publish on /write). Larger than the default .btn and lifted off 450 + the sky with a soft shadow so it reads at every phase. */ 451 + .hero__start { 452 + font-size: 1.05rem; 453 + padding: 0.8rem 1.6rem; 454 + box-shadow: 0 6px 22px rgba(20, 10, 4, 0.28); 455 + } 449 456 .hero__free { 450 457 color: var(--sky-soft); 451 458 font-size: 0.9rem; 452 459 margin-top: 1rem; 453 460 text-shadow: var(--sky-shadow); 454 461 } 455 - .hero :global(.handlestart__row) { display: flex; gap: 0.5rem; } 456 - .hero :global(.handlestart__field) { 457 - flex: 1; 458 - /* Allow the field to shrink below the input's intrinsic width so the row never 459 - overflows and clips the Start button on narrow viewports. */ 460 - min-width: 0; 461 - display: flex; 462 - align-items: center; 463 - gap: 0.15rem; 464 - background: var(--sky-chip); 465 - border: 1px solid var(--sky-line); 466 - border-radius: var(--radius-sm); 467 - padding: 0.6rem 0.75rem; 468 - backdrop-filter: blur(8px); 469 - -webkit-backdrop-filter: blur(8px); 470 - } 471 - .hero :global(.handlestart__at) { color: var(--sky-soft); } 472 - .hero :global(.handlestart__input) { 473 - flex: 1; 474 - /* Pair with the field's min-width: 0 — let the input collapse so the field can too. */ 475 - min-width: 0; 476 - border: none; 477 - background: transparent; 478 - color: var(--sky-ink); 479 - font: inherit; 480 - outline: none; 481 - } 482 - .hero :global(.handlestart__input::placeholder) { color: var(--sky-soft); opacity: 0.7; } 483 - .hero :global(.handlestart__spinner) { 484 - width: 14px; height: 14px; flex: none; 485 - border: 2px solid var(--sky-line); 486 - border-top-color: var(--sky-ink); 487 - border-radius: 50%; 488 - animation: handlestart-spin 0.7s linear infinite; 489 - } 490 - .hero :global(.handlestart__go) { 491 - border: none; 492 - border-radius: var(--radius-sm); 493 - padding: 0.6rem 1rem; 494 - font: inherit; 495 - font-weight: 650; 496 - white-space: nowrap; 497 - color: #fff; 498 - background: var(--btn-primary); 499 - cursor: pointer; 500 - transition: background 0.12s ease, box-shadow 0.12s ease; 501 - } 502 - .hero :global(.handlestart__go:hover) { background: var(--btn-primary-hover); } 503 - .hero :global(.handlestart__card) { 504 - display: flex; 505 - align-items: center; 506 - gap: 0.55rem; 507 - margin-top: 0.6rem; 508 - padding: 0.45rem 0.6rem; 509 - background: var(--sky-chip); 510 - border: 1px solid var(--sky-line); 511 - border-radius: var(--radius-sm); 512 - backdrop-filter: blur(8px); 513 - -webkit-backdrop-filter: blur(8px); 514 - text-align: left; 515 - } 516 - .hero :global(.handlestart__avatar) { 517 - width: 36px; height: 36px; border-radius: 50%; object-fit: cover; flex: none; 518 - } 519 - .hero :global(.handlestart__avatar--fallback) { 520 - display: inline-flex; align-items: center; justify-content: center; 521 - background: var(--sun-tint); color: var(--sun); font-weight: 700; 522 - } 523 - .hero :global(.handlestart__who) { display: flex; flex-direction: column; line-height: 1.15; } 524 - .hero :global(.handlestart__name) { font-weight: 680; font-size: 0.9rem; color: var(--sky-ink); } 525 - .hero :global(.handlestart__handle) { font-size: 0.74rem; color: var(--sky-soft); } 526 - .hero :global(.handlestart__check) { margin-left: auto; color: #6fce92; font-weight: 800; } 527 - .hero :global(.handlestart__hint) { 528 - color: var(--sky-soft); 529 - font-size: 0.82rem; 530 - margin: 0.6rem 0 0; 531 - text-shadow: var(--sky-shadow); 532 - } 533 - .hero :global(.handlestart__hint--error) { color: #ffd0c4; } 534 - @keyframes handlestart-spin { to { transform: rotate(360deg); } } 535 - 536 462 @media (prefers-reduced-motion: reduce) { 537 463 .shootingstar { animation: none; opacity: 0; } 538 464 .sky .stars, .sky .bloom, .sky .halo { transition: none; } 539 - .hero :global(.handlestart__spinner) { animation: none; } 540 465 .masthead :global(.account-menu__dropdown) { 541 466 animation: none; 542 467 }
+2 -2
src/pages/lexicon.astro
··· 59 59 > 60 60 <header class="lex-masthead"> 61 61 <a class="lex-home" href="/" aria-label="SkyPress home"><Logo /></a> 62 - <a class="btn btn--ghost" href="/editor">Open the studio</a> 62 + <a class="btn btn--ghost" href="/write">Start writing</a> 63 63 </header> 64 64 65 65 <main class="lex"> ··· 169 169 </section> 170 170 171 171 <div class="lex-actions"> 172 - <a class="btn btn--primary" href="/editor">Start writing</a> 172 + <a class="btn btn--primary" href="/write">Start writing</a> 173 173 </div> 174 174 </main> 175 175
+21
src/pages/write.astro
··· 1 + --- 2 + import Base from '../layouts/Base.astro'; 3 + import WriteStudio from '../components/WriteStudio.tsx'; 4 + import LoadingScene from '../components/LoadingScene.astro'; 5 + // The island is `client:only`, so Astro's scoped styles never reach its DOM — its 6 + // chrome is styled globally from these shared stylesheets plus the write-specific one. 7 + import '../styles/app-bar.css'; 8 + import '../styles/editor-chrome.css'; 9 + import '../styles/login.css'; 10 + import '../styles/write-chrome.css'; 11 + --- 12 + 13 + <Base title="Write — SkyPress"> 14 + <main class="editor-shell"> 15 + <!-- client:only — auth + editor run only in the browser (Decisions 0001 & 0004). 16 + Unlike /editor this surface never gates on auth: you can write signed out. --> 17 + <WriteStudio client:only="react"> 18 + <LoadingScene slot="fallback" variant="editor" /> 19 + </WriteStudio> 20 + </main> 21 + </Base>
+191
src/styles/write-chrome.css
··· 1 + /* src/styles/write-chrome.css 2 + * Chrome unique to the writing-first page (/write): the top actions bar (the Publish button), 3 + * the sign-in panel, and the publish stepper. The signed-in identity + sign-out live in the 4 + * shared app bar (app-bar.css), not here. The editor body itself reuses 5 + * editor-chrome.css (.studio__title / .studio__lede / .studio__cover*), so the title/lede 6 + * stay direct children of the column — never wrap them, or they lose their shared alignment. 7 + */ 8 + 9 + /* Top actions bar: right-aligned account pill + Publish, in the SAME centred content column 10 + as the app bar, title, and editor surface (var(--studio-measure)/--studio-gutter), so it 11 + lines up with everything below instead of floating at the viewport edge. */ 12 + .write-actions { 13 + display: flex; 14 + align-items: center; 15 + justify-content: flex-end; 16 + gap: 0.75rem; 17 + max-width: var(--studio-measure, 60rem); 18 + margin: 0 auto; 19 + padding: 0.75rem var(--studio-gutter, 1.25rem) 0; 20 + } 21 + 22 + /* Primary publish action — matches the editor's .publish__button (sun fill). */ 23 + .write-publish { 24 + font: inherit; 25 + font-weight: 600; 26 + cursor: pointer; 27 + border: 0; 28 + border-radius: 8px; 29 + padding: 0.5rem 1rem; 30 + background: var(--sun); 31 + color: #fff; 32 + } 33 + 34 + .write-publish:disabled { 35 + opacity: 0.5; 36 + cursor: not-allowed; 37 + } 38 + 39 + /* Sign-in panel + publish stepper: framed cards in the centred column. Use the paper-raised 40 + surface + token border so they read correctly in light and dark (not a near-invisible 41 + rgba hairline on the dark canvas). */ 42 + .write-signin, 43 + .writeflow { 44 + max-width: 32rem; 45 + margin: 1rem auto; 46 + padding: 1.25rem 1.5rem; 47 + background: var(--paper-raised); 48 + border: 1px solid var(--line-strong); 49 + border-radius: var(--radius); 50 + box-shadow: var(--shadow); 51 + } 52 + 53 + /* Sign-in panel — mirrors the editor's LoginForm (login.css) so signing in looks the same 54 + wherever a signed-out writer meets it. */ 55 + .signin-panel__title { 56 + font-family: var(--font-display); 57 + font-size: 1.5rem; 58 + margin: 0 0 0.4rem; 59 + color: var(--ink); 60 + } 61 + .signin-panel__lede { 62 + color: var(--muted); 63 + margin: 0 0 1.25rem; 64 + font-size: 0.95rem; 65 + } 66 + .signin-panel__label { 67 + display: block; 68 + font-size: 0.85rem; 69 + font-weight: 600; 70 + margin-bottom: 0.35rem; 71 + color: var(--ink); 72 + } 73 + .signin-panel__input { 74 + width: 100%; 75 + box-sizing: border-box; 76 + padding: 0.6rem 0.7rem; 77 + border: 1px solid var(--line-strong); 78 + border-radius: 8px; 79 + background: var(--paper); 80 + color: var(--ink); 81 + font: inherit; 82 + } 83 + .signin-panel__submit { 84 + padding: 0.6rem 1.1rem; 85 + border: 0; 86 + border-radius: 8px; 87 + background: var(--sun); 88 + color: #fff; 89 + font: inherit; 90 + font-weight: 600; 91 + cursor: pointer; 92 + } 93 + .signin-panel__cancel { 94 + padding: 0.6rem 1rem; 95 + border: 1px solid var(--line-strong); 96 + background: var(--paper-raised); 97 + border-radius: 8px; 98 + color: inherit; 99 + font: inherit; 100 + cursor: pointer; 101 + } 102 + .signin-panel__signup { 103 + margin: 1rem 0 0; 104 + font-size: 0.85rem; 105 + color: var(--muted); 106 + } 107 + .signin-panel__signup a { 108 + color: var(--sun); 109 + font-weight: 600; 110 + } 111 + .signin-panel__signup-link { 112 + /* Keep the external-link icon on the same line, snug against the label. */ 113 + display: inline-flex; 114 + align-items: center; 115 + gap: 0.25em; 116 + white-space: nowrap; 117 + } 118 + .signin-panel__external { 119 + flex: none; 120 + } 121 + 122 + .signin-panel__actions, 123 + .writeflow__actions { 124 + display: flex; 125 + gap: 0.75rem; 126 + margin-top: 1rem; 127 + } 128 + 129 + /* Publication picker (when the writer has more than one). Mirrors the editor's 130 + .publish__target / .publish__select so it reads like the rest of the app. */ 131 + .writeflow__target { 132 + display: flex; 133 + flex-direction: column; 134 + gap: 0.35rem; 135 + margin: 0 0 1rem; 136 + } 137 + .writeflow__target > span { 138 + font-size: 0.85rem; 139 + font-weight: 600; 140 + color: var(--ink); 141 + } 142 + .writeflow__target select { 143 + width: 100%; 144 + box-sizing: border-box; 145 + padding: 0.55rem 0.7rem; 146 + border: 1px solid var(--line-strong); 147 + border-radius: 8px; 148 + background: var(--paper); 149 + color: var(--ink); 150 + font: inherit; 151 + cursor: pointer; 152 + } 153 + .writeflow__target select:disabled { 154 + opacity: 0.6; 155 + cursor: default; 156 + } 157 + 158 + /* Primary publish action — same sun fill as the editor's publish button. */ 159 + .writeflow__publish { 160 + padding: 0.6rem 1.1rem; 161 + border: 0; 162 + border-radius: 8px; 163 + background: var(--sun); 164 + color: #fff; 165 + font: inherit; 166 + font-weight: 600; 167 + cursor: pointer; 168 + } 169 + .writeflow__publish:disabled { 170 + opacity: 0.5; 171 + cursor: not-allowed; 172 + } 173 + 174 + .writeflow__status { 175 + margin: 1rem 0 0; 176 + color: var(--muted); 177 + font-size: 0.9rem; 178 + } 179 + 180 + .writeflow__warning { 181 + font-size: 0.95rem; 182 + margin: 0 0 0.75rem; 183 + } 184 + 185 + .writeflow__count, 186 + .writeflow__error, 187 + .signin-panel__error { 188 + color: var(--ember); 189 + font-size: 0.9rem; 190 + margin: 0.75rem 0 0; 191 + }