···11+# 0020 — Writing-first flow: deferred publish, no remote account creation
22+33+## Context
44+`/editor` gates the entire editor behind OAuth. We wanted a parallel surface where writing is
55+the first action and auth/publication selection are deferred to publish time.
66+77+## Decision
88+- Add a parallel route `/write` mounting a new `WriteStudio` island that renders the editor for
99+ every auth status (no login gate). `/`, `/editor`, `/dashboard` are untouched.
1010+- Images are held locally as `data:` URLs and uploaded to the PDS only at publish
1111+ (`src/lib/write/upload-held.ts`), via one media path regardless of auth state.
1212+- The draft (title, lede, token-skeleton blocks, cover) survives the full-page OAuth redirect:
1313+ light metadata + skeleton in `localStorage`, image bytes in IndexedDB
1414+ (`src/lib/write/draft-store.ts` + `asset-store.ts`). A one-shot `publishIntent` flag makes the
1515+ publish flow auto-resume on return.
1616+- Publish branches on publication count: one → confirm; many → pick; zero → inline create. The
1717+ single-publication case still shows a confirm — publishing also posts to Bluesky (brief §10).
1818+1919+## Why not remote account creation
2020+atproto OAuth authenticates an existing account; `com.atproto.server.createAccount` lives on a
2121+PDS's hosted signup (invite/email gated) and is not exposed to third-party clients. Building an
2222+inline signup would make SkyPress a hosting/signup broker, contradicting the "never a PDS/relay"
2323+guardrail. The signed-out panel therefore links out to Bluesky signup and otherwise offers
2424+sign-in only.
2525+2626+## Consequences
2727+- Signed-in writers lose eager upload error feedback (errors surface at publish) — accepted for a
2828+ single, simpler media path.
2929+- `data:`-URL drafts can be large; bytes go to IndexedDB to avoid the `localStorage` quota.
3030+3131+## Update — home page leads with /write
3232+The route started as a hidden parallel experience, but it tested well, so the home page now
3333+**leads with it**: the hero's primary CTA is "Start writing →" → `/write`, with the handle
3434+sign-in (`HandleStart`) demoted to a secondary "Already have an account?" path. The masthead
3535+"Write" button and the signed-in account menu's "Write" item (`accountMenuItems`) also point at
3636+`/write` now. `/editor` still exists (edit-an-existing-article + the gated flow) but is no longer
3737+linked from the home page.
···11+# Writing-first flow — design
22+33+**Date:** 2026-06-17
44+**Status:** Design — approved, pending spec review
55+**Branch:** `chicago-v2`
66+77+## Summary
88+99+An alternative entry experience for SkyPress where **writing is the first thing you do**.
1010+Today the flow is login-first: `/` is a marketing landing page and the editor at `/editor`
1111+gates the entire writing surface behind `status === 'signed-in'`. This design inverts that:
1212+the writer lands directly on an editor, starts writing immediately, and only encounters
1313+auth and publication selection at publish time.
1414+1515+This ships as a **parallel experience** at a new route — `/`, `/editor`, and `/dashboard`
1616+are left untouched so the two funnels can be compared side by side.
1717+1818+## Goals
1919+2020+- A writer can land on a page and start writing with zero friction — no login gate.
2121+- Authentication is deferred to publish time and resumes the publish flow automatically
2222+ after the OAuth redirect round-trip.
2323+- Image insertion works while signed out; uploads are deferred to publish.
2424+- Publishing branches sensibly on how many publications the writer owns (one / many / zero).
2525+- A returning, already-signed-in writer gets a small account pill but the editor stays the
2626+ focus.
2727+2828+## Non-goals
2929+3030+- **Not** a replacement for the current landing page or `/editor`. This is option B
3131+ (parallel route), explicitly for comparison.
3232+- **No remote account creation.** atproto OAuth is a sign-in protocol; a third-party client
3333+ cannot create accounts on a big PDS, and SkyPress must never become a PDS/signup broker
3434+ (product guardrail). We surface only a lightweight "Need an account? →" link to Bluesky's
3535+ hosted signup.
3636+- **New-document only.** This is a "start writing" funnel. Editing an existing article stays
3737+ on `/editor` (`Studio`). No `?edit=<rkey>` load path here.
3838+- **No inline publication management.** Creating/editing publications in general stays on the
3939+ existing `/dashboard`. The one exception is a focused inline "create your first publication"
4040+ step inside the zero-publication publish branch.
4141+4242+## Decisions (from brainstorming)
4343+4444+| # | Decision |
4545+|---|----------|
4646+| Q1 | Positioning: **parallel route** (`/write`), existing routes untouched. |
4747+| Q2 | Account creation: **sign-in only** for now, with a "Need an account? →" link to Bluesky signup. No real create-account branch. |
4848+| Q3 | Images signed-out: **insert freely, upload silently at publish** (invisible deferral). |
4949+| Q4a | Single-publication publish: **always show a lightweight confirm** ("Publish to *Name* — also posts to Bluesky"), never silent auto-publish. |
5050+| Q4b | Zero-publication publish: **inline create-publication step** (reuse `PublicationForm`), not a redirect to `/dashboard`. |
5151+| Q5 | Signed-in pill: **links out to existing `/dashboard`** for management; pill itself is the only new account surface. |
5252+| Q6 | Media: **always defer (one path)** — hold locally and upload at publish regardless of auth state. |
5353+5454+## User journey
5555+5656+### Signed-out writer
5757+1. Lands on `/write` → clean editor (title, lede, blocks, cover). No gate.
5858+2. Writes; inserts images (held locally, previewed inline).
5959+3. Clicks **Publish** → draft + images persisted locally, `publishIntent` set → OAuth redirect.
6060+4. Returns signed in → draft restored, `publishIntent` detected → publish flow auto-resumes,
6161+ branching on publication count (see below).
6262+5. Success → draft cleared, published pill/link shown.
6363+6464+### Already-signed-in writer
6565+1. Lands on `/write` → same editor, plus an **account pill** (avatar + handle) top-right.
6666+2. Clicks **Publish** → no redirect; publish flow runs in place, same branching.
6767+6868+### Publish branching (after auth is guaranteed)
6969+- **Exactly one publication** → "Publish to *Name* (also posts to Bluesky)" confirm →
7070+ upload held images → commit document + Bluesky post.
7171+- **More than one** → publication picker step → confirm → publish.
7272+- **Zero** → inline "create your first publication" step (`PublicationForm`) → publish.
7373+7474+## Architecture
7575+7676+### Route & island
7777+- New page `src/pages/write.astro` mounting a client-only island **`WriteStudio`**.
7878+- `WriteStudio` wraps `AuthProvider` (reused) but, unlike `Studio`, **renders the editor for
7979+ every auth status** (`loading` / `signed-out` / `signed-in`). There is no login gate.
8080+- The OAuth `redirect_uri` is the current pathname (existing `oauth.ts` behavior), so a
8181+ sign-in initiated from `/write` returns to `/write` automatically. No new redirect config.
8282+8383+### Top-right corner states
8484+- **Signed out:** a "Sign in" affordance + a small "Need an account? →" link to Bluesky
8585+ signup. Reuses the existing handle-input / `signIn()` path from `AuthProvider`.
8686+- **Signed in:** the **account pill** — avatar + handle opening a menu: *Manage publications*
8787+ (→ `/dashboard`), *Profile*, *Sign out*.
8888+8989+### Draft persistence (survives the OAuth redirect)
9090+Sign-in is a full-page redirect that destroys in-memory state, so before any redirect we
9191+persist a **single draft slot**:
9292+- **localStorage** — title, lede, serialized blocks, cover ref/preview, and a `publishIntent`
9393+ flag.
9494+- **IndexedDB** — held image bytes (data URLs can exceed localStorage quota), keyed so blocks
9595+ can reference them on restore.
9696+9797+On `/write` load we **auto-restore** the draft if present. After a successful publish we
9898+**clear** both stores. Abandoned drafts persist for the next visit.
9999+100100+### Deferred media (one path)
101101+A new **local-hold media handler** replaces the eager PDS-upload handler for this flow, used
102102+regardless of auth state:
103103+- On insert: store image bytes in IndexedDB, render an inline preview (data URL), and record
104104+ the mapping (preview → held key) in the in-memory blob registry.
105105+- At publish: for every held image, `agent.uploadBlob()` → real `BlobRef`, then reuse the
106106+ existing `attachBlobRefs()` registry mechanism to swap preview URLs for blob refs before the
107107+ document record commits.
108108+- Errors surface at publish (acceptable trade-off vs. eager-upload early feedback).
109109+110110+### Publish-flow stepper
111111+A small state machine driven by `publishIntent` + publication count:
112112+- `idle` → (Publish clicked) → persist + maybe-redirect → `resolving-auth`
113113+- `resolving-auth` → (signed in) → `branch`
114114+- `branch` → one of `confirm-single` / `pick` / `create-pub`
115115+- any terminal step → upload images → `publish()` (document + post) → `done` (clear draft).
116116+117117+Reuses `publisher.ts` (`publish`), `publications.ts` (`listPublications`, `createPublication`),
118118+`PublicationForm`, and the published-pill UI.
119119+120120+## Reuse vs. build
121121+122122+**Reuse (unchanged):** `AuthProvider`, `oauth.ts`, `SkyEditor`, `PublicationForm`,
123123+`publisher.ts` (`publish`), `publications.ts`, `attachBlobRefs`, published-pill UI.
124124+125125+**Build new:**
126126+- `src/pages/write.astro` route.
127127+- `WriteStudio` island (editor for all auth states; corner state; publish stepper host).
128128+- Local-hold media handler.
129129+- Draft-persistence module (localStorage + IndexedDB; save / restore / clear).
130130+- Account pill / signed-out corner component.
131131+- Publish-flow stepper component (confirm / pick / create-pub) driven by `publishIntent`.
132132+133133+**Untouched:** `/` (`index.astro`), `/editor` (`Studio`), `/dashboard`.
134134+135135+## Testing
136136+137137+- Colocated page tests under `src/pages/` MUST be underscore-prefixed (e.g.
138138+ `_write.meta.test.ts`) per the Astro file-router constraint.
139139+- Draft persistence: save → simulate reload → restore yields identical editor state
140140+ (title/lede/blocks/cover); clear empties both stores.
141141+- Deferred media: held images survive a persist/restore round-trip; at publish each held
142142+ image is uploaded once and its preview URL is swapped for the blob ref before commit.
143143+- Publish branching: one / many / zero publications each route to the correct step; the
144144+ single-publication case still shows a confirm (never silent).
145145+- Resume-after-redirect: a `publishIntent` present on load auto-resumes the publish flow
146146+ rather than dropping into the editor.
147147+- `WriteStudio` renders the editor for `loading`, `signed-out`, and `signed-in` (no gate).
148148+149149+## Open questions
150150+151151+None outstanding. Durable rationale (e.g. the deferred-media single-path choice and the
152152+no-remote-account-creation constraint) should graduate to a `docs/decisions/NNNN-*.md` during
153153+implementation.
+71
src/components/EditorCanvas.test.tsx
···11+import { describe, it, expect, vi } from 'vitest';
22+import { act, createElement } from 'react';
33+import { createRoot } from 'react-dom/client';
44+55+( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true;
66+77+// Stub the heavy/irrelevant children with markers.
88+vi.mock( './SkyEditor', () => ( {
99+ default: () => createElement( 'div', { 'data-testid': 'sky-editor' } ),
1010+} ) );
1111+vi.mock( './CoverImagePicker', () => ( {
1212+ default: () => createElement( 'div', { 'data-testid': 'cover-picker' } ),
1313+} ) );
1414+1515+import EditorCanvas from './EditorCanvas';
1616+1717+const base = {
1818+ title: '',
1919+ onTitleChange: vi.fn(),
2020+ lede: '',
2121+ onLedeChange: vi.fn(),
2222+ onBlocksChange: vi.fn(),
2323+ cover: null,
2424+ onCoverChange: vi.fn(),
2525+};
2626+2727+function mount( props: Record< string, unknown > ) {
2828+ const container = document.createElement( 'div' );
2929+ document.body.appendChild( container );
3030+ act( () => createRoot( container ).render( createElement( EditorCanvas, { ...base, ...props } as never ) ) );
3131+ return container;
3232+}
3333+3434+// React 18 tracks a controlled value via a prototype setter; a direct `el.value = …`
3535+// is invisible to it, so set through the native setter to make onChange fire.
3636+function setValue( el: HTMLTextAreaElement, val: string ) {
3737+ const proto = Object.getPrototypeOf( el );
3838+ Object.getOwnPropertyDescriptor( proto, 'value' )!.set!.call( el, val );
3939+ el.dispatchEvent( new Event( 'input', { bubbles: true } ) );
4040+}
4141+4242+describe( 'EditorCanvas', () => {
4343+ it( 'renders the title + lede fields and the block editor', () => {
4444+ const c = mount( {} );
4545+ expect( c.querySelector( 'textarea.studio__title' ) ).not.toBe( null );
4646+ expect( c.querySelector( 'textarea.studio__lede' ) ).not.toBe( null );
4747+ expect( c.querySelector( '[data-testid="sky-editor"]' ) ).not.toBe( null );
4848+ } );
4949+5050+ it( 'reports title and lede edits to the parent', () => {
5151+ const onTitleChange = vi.fn();
5252+ const onLedeChange = vi.fn();
5353+ const c = mount( { onTitleChange, onLedeChange } );
5454+ setValue( c.querySelector( 'textarea.studio__title' )!, 'My title' );
5555+ setValue( c.querySelector( 'textarea.studio__lede' )!, 'My lede' );
5656+ expect( onTitleChange ).toHaveBeenCalledWith( 'My title' );
5757+ expect( onLedeChange ).toHaveBeenCalledWith( 'My lede' );
5858+ } );
5959+6060+ it( 'shows the Bluesky-truncation hint only for a long lede', () => {
6161+ expect( mount( { lede: 'short' } ).querySelector( '.studio__lede-hint' ) ).toBe( null );
6262+ expect( mount( { lede: 'x'.repeat( 201 ) } ).querySelector( '.studio__lede-hint' ) ).not.toBe( null );
6363+ } );
6464+6565+ it( 'shows the cover picker only when an upload handler is provided', () => {
6666+ expect( mount( {} ).querySelector( '[data-testid="cover-picker"]' ) ).toBe( null );
6767+ expect(
6868+ mount( { onCoverUpload: vi.fn() } ).querySelector( '[data-testid="cover-picker"]' )
6969+ ).not.toBe( null );
7070+ } );
7171+} );
+112
src/components/EditorCanvas.tsx
···11+import { useLayoutEffect, useRef } from 'react';
22+import type { BlockInstance } from '@wordpress/blocks';
33+import SkyEditor from './SkyEditor';
44+import CoverImagePicker from './CoverImagePicker';
55+import type { MediaUploadHandler } from '../lib/media/mediaUpload';
66+import type { CoverUpload } from '../lib/media/cover';
77+import type { BlockNode } from '../lib/blocks/render';
88+99+interface Props {
1010+ title: string;
1111+ onTitleChange: ( value: string ) => void;
1212+ lede: string;
1313+ onLedeChange: ( value: string ) => void;
1414+ /** Live block instances on every editor change — the parent normalises/stores as it needs. */
1515+ onBlocksChange: ( blocks: BlockInstance[] ) => void;
1616+ mediaUpload?: MediaUploadHandler;
1717+ initialBlocks?: BlockNode[];
1818+ cover: CoverUpload | null;
1919+ /** When provided, the cover picker renders and uploads through this handler (eager or deferred). */
2020+ onCoverUpload?: ( file: File ) => Promise< CoverUpload >;
2121+ onCoverChange: ( cover: CoverUpload | null ) => void;
2222+}
2323+2424+/**
2525+ * The shared writing surface for both the editor (`/editor`) and the writing-first page
2626+ * (`/write`): the borderless title + lede headings above the framed block canvas, plus the
2727+ * optional per-article cover picker. Presentational — it owns no document state, only the
2828+ * textareas' auto-grow. Each island wires the content state, the media-upload handler, and
2929+ * (for the cover) the upload path that fits its flow: eager PDS upload in the editor, deferred
3030+ * `data:`-URL hold in the writing-first flow. Lives only in `client:only` islands — it pulls in
3131+ * `SkyEditor`, which is browser-only (Decision 0003).
3232+ */
3333+export default function EditorCanvas( {
3434+ title,
3535+ onTitleChange,
3636+ lede,
3737+ onLedeChange,
3838+ onBlocksChange,
3939+ mediaUpload,
4040+ initialBlocks,
4141+ cover,
4242+ onCoverUpload,
4343+ onCoverChange,
4444+}: Props ) {
4545+ const titleRef = useRef< HTMLTextAreaElement >( null );
4646+ const ledeRef = useRef< HTMLTextAreaElement >( null );
4747+4848+ // Grow the title textarea to fit its content so long titles wrap into view instead of
4949+ // clipping on one line. Layout effect so it sizes before paint.
5050+ useLayoutEffect( () => {
5151+ const el = titleRef.current;
5252+ if ( ! el ) {
5353+ return;
5454+ }
5555+ el.style.height = 'auto';
5656+ el.style.height = `${ el.scrollHeight }px`;
5757+ }, [ title ] );
5858+5959+ // Same auto-grow for the lede (and on hydrate from an edit-load / restored draft).
6060+ useLayoutEffect( () => {
6161+ const el = ledeRef.current;
6262+ if ( ! el ) {
6363+ return;
6464+ }
6565+ el.style.height = 'auto';
6666+ el.style.height = `${ el.scrollHeight }px`;
6767+ }, [ lede ] );
6868+6969+ return (
7070+ <>
7171+ <textarea
7272+ ref={ titleRef }
7373+ className="studio__title"
7474+ rows={ 1 }
7575+ placeholder="Add title"
7676+ aria-label="Article title"
7777+ value={ title }
7878+ // Single-line semantically: let it wrap visually, but don't let Enter insert a
7979+ // literal newline into the stored value.
8080+ onKeyDown={ ( event ) => {
8181+ if ( event.key === 'Enter' ) {
8282+ event.preventDefault();
8383+ }
8484+ } }
8585+ onChange={ ( event ) => onTitleChange( event.target.value ) }
8686+ />
8787+ <textarea
8888+ ref={ ledeRef }
8989+ className="studio__lede"
9090+ rows={ 1 }
9191+ maxLength={ 3000 }
9292+ placeholder="Add a subtitle…"
9393+ aria-label="Subtitle"
9494+ value={ lede }
9595+ onChange={ ( event ) => onLedeChange( event.target.value ) }
9696+ />
9797+ { lede.length > 200 && (
9898+ <p className="studio__lede-hint">
9999+ Long subtitles get truncated on the Bluesky card.
100100+ </p>
101101+ ) }
102102+ <SkyEditor
103103+ onChange={ onBlocksChange }
104104+ mediaUpload={ mediaUpload }
105105+ initialBlocks={ initialBlocks }
106106+ />
107107+ { onCoverUpload && (
108108+ <CoverImagePicker cover={ cover } onUpload={ onCoverUpload } onChange={ onCoverChange } />
109109+ ) }
110110+ </>
111111+ );
112112+}
+58
src/components/SignInPanel.test.tsx
···11+import { describe, it, expect, vi } from 'vitest';
22+import { act, createElement } from 'react';
33+import { createRoot } from 'react-dom/client';
44+55+( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true;
66+import SignInPanel from './SignInPanel';
77+88+function mount( props: Record< string, unknown > ) {
99+ const container = document.createElement( 'div' );
1010+ document.body.appendChild( container );
1111+ act( () => createRoot( container ).render( createElement( SignInPanel, props as never ) ) );
1212+ return container;
1313+}
1414+1515+// React 18 tracks a controlled input's value via a private setter; assigning `.value`
1616+// directly then dispatching `input` is invisible to React and onChange never fires. Set
1717+// through the native prototype setter so React's tracker sees the change (standard idiom).
1818+const setInputValue = ( input: HTMLInputElement, value: string ) => {
1919+ const setter = Object.getOwnPropertyDescriptor(
2020+ window.HTMLInputElement.prototype,
2121+ 'value'
2222+ )!.set!;
2323+ setter.call( input, value );
2424+};
2525+2626+describe( 'SignInPanel', () => {
2727+ it( 'for-publish variant frames the CTA around publishing and links out to signup in a new tab', () => {
2828+ const c = mount( { forPublish: true, error: null, onSubmit: vi.fn() } );
2929+ expect( c.textContent?.toLowerCase() ).toContain( 'publish' );
3030+ const signup = c.querySelector( 'a[href*="bsky.app"]' ) as HTMLAnchorElement | null;
3131+ expect( signup ).not.toBe( null );
3232+ // Opens in a new tab, hardened, and labelled so the new-tab behaviour is announced.
3333+ expect( signup!.target ).toBe( '_blank' );
3434+ expect( signup!.rel ).toContain( 'noopener' );
3535+ expect( signup!.getAttribute( 'aria-label' )?.toLowerCase() ).toContain( 'new tab' );
3636+ // Carries the usual external-link icon (not a bare "→").
3737+ expect( signup!.querySelector( 'svg' ) ).not.toBe( null );
3838+ expect( signup!.textContent ).not.toContain( '→' );
3939+ } );
4040+4141+ it( 'submits the typed handle', () => {
4242+ const onSubmit = vi.fn();
4343+ const c = mount( { forPublish: false, error: null, onSubmit } );
4444+ const input = c.querySelector( 'input' )!;
4545+ const form = c.querySelector( 'form' )!;
4646+ act( () => {
4747+ setInputValue( input as HTMLInputElement, 'alice.bsky.social' );
4848+ input.dispatchEvent( new Event( 'input', { bubbles: true } ) );
4949+ } );
5050+ act( () => form.dispatchEvent( new Event( 'submit', { bubbles: true, cancelable: true } ) ) );
5151+ expect( onSubmit ).toHaveBeenCalledWith( 'alice.bsky.social' );
5252+ } );
5353+5454+ it( 'shows an error when provided', () => {
5555+ const c = mount( { forPublish: false, error: 'Bad handle', onSubmit: vi.fn() } );
5656+ expect( c.textContent ).toContain( 'Bad handle' );
5757+ } );
5858+} );
+95
src/components/SignInPanel.tsx
···11+import { useState } from 'react';
22+33+interface Props {
44+ /** True when opened from "Publish" — the copy promises a publish on return. */
55+ forPublish: boolean;
66+ error: string | null;
77+ onSubmit: ( value: string ) => void;
88+ onCancel?: () => void;
99+}
1010+1111+/**
1212+ * Signed-out handle entry for the writing-first flow. OAuth is sign-in only, so this never
1313+ * creates an account — it links out to Bluesky's hosted signup instead (brief guardrail). The
1414+ * caller persists the draft + sets publish intent before the redirect.
1515+ */
1616+export default function SignInPanel( { forPublish, error, onSubmit, onCancel }: Props ) {
1717+ const [ value, setValue ] = useState( '' );
1818+1919+ return (
2020+ <form
2121+ className="signin-panel"
2222+ onSubmit={ ( event ) => {
2323+ event.preventDefault();
2424+ onSubmit( value.trim() );
2525+ } }
2626+ >
2727+ <h2 className="signin-panel__title">
2828+ { forPublish ? 'Sign in to publish' : 'Sign in' }
2929+ </h2>
3030+ <p className="signin-panel__lede">
3131+ { forPublish
3232+ ? "Your draft is saved. Sign in and we'll pick up right where you left off and publish it."
3333+ : 'Use your existing Bluesky / AT Protocol identity. Your work stays in your own account.' }
3434+ </p>
3535+ <label className="signin-panel__label" htmlFor="write-handle">
3636+ Your handle, DID, or PDS URL
3737+ </label>
3838+ <input
3939+ id="write-handle"
4040+ className="signin-panel__input"
4141+ name="handle"
4242+ autoComplete="username"
4343+ autoCapitalize="none"
4444+ autoCorrect="off"
4545+ spellCheck={ false }
4646+ placeholder="alice.bsky.social"
4747+ value={ value }
4848+ onChange={ ( event ) => setValue( event.target.value ) }
4949+ />
5050+ <div className="signin-panel__actions">
5151+ <button className="signin-panel__submit" type="submit">
5252+ Sign in with AT Protocol
5353+ </button>
5454+ { onCancel && (
5555+ <button className="signin-panel__cancel" type="button" onClick={ onCancel }>
5656+ Cancel
5757+ </button>
5858+ ) }
5959+ </div>
6060+ { error && (
6161+ <p className="signin-panel__error" role="alert">
6262+ { error }
6363+ </p>
6464+ ) }
6565+ <p className="signin-panel__signup">
6666+ Need an account?{ ' ' }
6767+ <a
6868+ className="signin-panel__signup-link"
6969+ href="https://bsky.app"
7070+ target="_blank"
7171+ rel="noopener noreferrer"
7272+ aria-label="Create an account on Bluesky (opens in a new tab)"
7373+ >
7474+ Create one on Bluesky
7575+ { /* Standard external-link glyph — same icon as the author page's Bluesky link. */ }
7676+ <svg
7777+ className="signin-panel__external"
7878+ width="11"
7979+ height="11"
8080+ viewBox="0 0 24 24"
8181+ fill="none"
8282+ stroke="currentColor"
8383+ strokeWidth="2.5"
8484+ strokeLinecap="round"
8585+ strokeLinejoin="round"
8686+ aria-hidden="true"
8787+ >
8888+ <path d="M7 17 17 7" />
8989+ <path d="M8 7h9v9" />
9090+ </svg>
9191+ </a>
9292+ </p>
9393+ </form>
9494+ );
9595+}
+13-71
src/components/Studio.tsx
···11-import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
11+import { useEffect, useMemo, useRef, useState } from 'react';
22import type { BlockInstance } from '@wordpress/blocks';
33import { AuthProvider } from '../lib/auth/AuthProvider';
44import { useAuth } from '../lib/auth/useAuth';
55import LoginForm from '../lib/auth/LoginForm';
66-import SkyEditor from './SkyEditor';
66+import EditorCanvas from './EditorCanvas';
77import PublishPanel from './PublishPanel';
88import PublishedPill from './PublishedPill';
99-import CoverImagePicker from './CoverImagePicker';
109import AppBar from './AppBar';
1110import { createMediaUpload, revokeBlobRegistry, type BlobRegistry } from '../lib/media/mediaUpload';
1211import {
···4241 const [ publications, setPublications ] = useState< Publication[] | null >( null );
4342 // Shared between mediaUpload (writes blob refs) and publish (reads them).
4443 const registry = useRef< BlobRegistry >( new Map() ).current;
4545- const titleRef = useRef< HTMLTextAreaElement >( null );
4646- const ledeRef = useRef< HTMLTextAreaElement >( null );
47444845 // Load the writer's SkyPress publications (the publish targets / selector).
4946 useEffect( () => {
···115112 // Release the preview object URLs this session minted when the Studio unmounts.
116113 useEffect( () => () => revokeBlobRegistry( registry ), [ registry ] );
117114118118- // Grow the title textarea to fit its content so long titles wrap into view
119119- // instead of clipping on one line (esp. on narrow mobile viewports). Layout
120120- // effect so it sizes before paint — same reasoning as the lede below.
121121- useLayoutEffect( () => {
122122- const el = titleRef.current;
123123- if ( ! el ) {
124124- return;
125125- }
126126- el.style.height = 'auto';
127127- el.style.height = `${ el.scrollHeight }px`;
128128- }, [ title ] );
129129-130130- // Grow the lede textarea to fit its content (and on hydrate from an edit-load).
131131- // Layout effect so it sizes before paint — avoids a one-row collapse flash when
132132- // opening an existing article with a multi-line lede.
133133- useLayoutEffect( () => {
134134- const el = ledeRef.current;
135135- if ( ! el ) {
136136- return;
137137- }
138138- el.style.height = 'auto';
139139- el.style.height = `${ el.scrollHeight }px`;
140140- }, [ excerpt ] );
141141-142115 const mediaUpload = useMemo( () => {
143116 if ( ! agent || ! did || ! pdsUrl ) {
144117 return undefined;
···242215 }
243216 } }
244217 />
245245- <textarea
246246- ref={ titleRef }
247247- className="studio__title"
248248- rows={ 1 }
249249- placeholder="Add title"
250250- aria-label="Article title"
251251- value={ title }
252252- // The title is a single-line string: let it wrap visually, but
253253- // don't let Enter insert a literal newline into the stored value.
254254- onKeyDown={ ( event ) => {
255255- if ( event.key === 'Enter' ) {
256256- event.preventDefault();
257257- }
258258- } }
259259- onChange={ ( event ) => {
218218+ <EditorCanvas
219219+ title={ title }
220220+ onTitleChange={ ( value ) => {
260221 setPublished( null );
261261- setTitle( event.target.value );
222222+ setTitle( value );
262223 } }
263263- />
264264- <textarea
265265- ref={ ledeRef }
266266- className="studio__lede"
267267- rows={ 1 }
268268- maxLength={ 3000 }
269269- placeholder="Add a subtitle…"
270270- aria-label="Subtitle"
271271- value={ excerpt }
272272- onChange={ ( event ) => {
224224+ lede={ excerpt }
225225+ onLedeChange={ ( value ) => {
273226 setPublished( null );
274274- setExcerpt( event.target.value );
227227+ setExcerpt( value );
275228 } }
276276- />
277277- { excerpt.length > 200 && (
278278- <p className="studio__lede-hint">
279279- Long subtitles get truncated on the Bluesky card.
280280- </p>
281281- ) }
282282- <SkyEditor
283283- onChange={ setBlocks }
229229+ onBlocksChange={ setBlocks }
284230 mediaUpload={ mediaUpload }
285231 initialBlocks={ editing?.blocks }
232232+ cover={ cover }
233233+ onCoverUpload={ uploadCover }
234234+ onCoverChange={ setCover }
286235 />
287287- { uploadCover && (
288288- <CoverImagePicker
289289- cover={ cover }
290290- onUpload={ uploadCover }
291291- onChange={ setCover }
292292- />
293293- ) }
294236 </div>
295237 </>
296238 );
···5050describe( 'landing hero redesign', () => {
5151 const index = read( '../../pages/index.astro' );
52525353- it( 'mounts the HandleStart island as the primary CTA', () => {
5454- expect( index ).toMatch( /import HandleStart from '\.\.\/components\/HandleStart'/ );
5555- expect( index ).toMatch( /<HandleStart\s+client:only="react"\s*\/>/ );
5353+ it( 'leads with a single "Start writing" CTA to /write and no on-page sign-in', () => {
5454+ // Writing-first: the hero's only action starts a draft on /write. The handle sign-in
5555+ // island was removed — signing in happens via Publish on /write, not from the home page.
5656+ expect( index ).toMatch( /href="\/write"[^>]*>\s*Start writing/ );
5757+ expect( index ).not.toMatch( /HandleStart/ );
5658 } );
57595858- it( 'drops the old multi-button hero actions', () => {
5959- expect( index ).not.toMatch( /Start writing/ );
6060+ it( 'drops the old sample / lexicon / studio hero buttons', () => {
6061 expect( index ).not.toMatch( /Read a sample/ );
6162 expect( index ).not.toMatch( /See the lexicon/ );
6263 expect( index ).not.toMatch( /href="\/editor">Studio/ );
···11+import type { Agent } from '@atproto/api';
22+import type { BlockNode } from '../blocks/render';
33+import {
44+ attachBlobRefs,
55+ buildGetBlobUrl,
66+ type BlobRefJson,
77+ type BlobUpload,
88+} from '../media/blob';
99+import { isDataUrl, dataUrlToBlob } from './held-assets';
1010+1111+export interface PreparedPublishContent {
1212+ blocks: BlockNode[];
1313+ coverImage?: BlobRefJson;
1414+}
1515+1616+/** Upload one held `data:` URL to the PDS and return its portable blob ref + getBlob URL. */
1717+async function uploadOne(
1818+ agent: Agent,
1919+ dataUrl: string,
2020+ did: string,
2121+ pdsUrl: string
2222+): Promise< BlobUpload > {
2323+ const blob = dataUrlToBlob( dataUrl );
2424+ const res = await agent.uploadBlob( blob, { encoding: blob.type } );
2525+ const out = res.data.blob;
2626+ const cid = out.ref.toString();
2727+ return {
2828+ ref: { $type: 'blob', ref: { $link: cid }, mimeType: out.mimeType, size: out.size },
2929+ url: buildGetBlobUrl( pdsUrl, did, cid ),
3030+ };
3131+}
3232+3333+/**
3434+ * Publish-time bridge for the writing-first flow: walk the block tree, upload every held
3535+ * (`data:`) image to the writer's PDS, and rewrite those blocks via `attachBlobRefs` so they
3636+ * carry `skypressBlob` + a portable getBlob URL (byte-identical to the eager path). External
3737+ * image URLs are left untouched. A held cover is uploaded into a `BlobRefJson` for the document
3838+ * record. Each distinct data URL uploads once.
3939+ */
4040+export async function uploadHeldAssets(
4141+ agent: Agent,
4242+ input: { blocks: BlockNode[]; coverDataUrl: string | null; did: string; pdsUrl: string }
4343+): Promise< PreparedPublishContent > {
4444+ const { blocks, coverDataUrl, did, pdsUrl } = input;
4545+4646+ // Collect every distinct held image URL in the tree (depth-first), upload once each.
4747+ const registry = new Map< string, BlobUpload >();
4848+ const collect = ( nodes: BlockNode[] ): string[] =>
4949+ nodes.flatMap( ( node ) => {
5050+ const url = node.attributes?.url;
5151+ const here = node.name === 'core/image' && isDataUrl( url ) ? [ url as string ] : [];
5252+ return [ ...here, ...collect( node.innerBlocks ?? [] ) ];
5353+ } );
5454+5555+ for ( const url of new Set( collect( blocks ) ) ) {
5656+ registry.set( url, await uploadOne( agent, url, did, pdsUrl ) );
5757+ }
5858+5959+ const prepared = attachBlobRefs( blocks, ( url ) => registry.get( url ) );
6060+6161+ let coverImage: BlobRefJson | undefined;
6262+ if ( isDataUrl( coverDataUrl ) ) {
6363+ coverImage = ( await uploadOne( agent, coverDataUrl, did, pdsUrl ) ).ref;
6464+ }
6565+6666+ return { blocks: prepared, coverImage };
6767+}
-51
src/pages/_index.handlestart.test.ts
···11-/**
22- * Regression guard for the landing-page handle CTA overflowing on narrow viewports.
33- *
44- * `.handlestart__row` is a flexbox row holding the input field (`flex: 1`) and the "Start"
55- * button. A flex item's default `min-width: auto` refuses to shrink below its content's
66- * intrinsic width — and the `<input>` carries a sizeable intrinsic floor. Without an explicit
77- * `min-width: 0` on the field (and the input nested inside it), the field can't shrink on a
88- * phone-width screen, so the row grows past the container and clips the "Start" button off the
99- * right edge (reported 2026-06-09, reproduced at <=320px CSS px).
1010- *
1111- * Rendering the page through astro/container isn't viable here (the runner is pinned to jsdom
1212- * for the WordPress block suites), so — as with _index.phase.test.ts — these asserts pin the
1313- * fix at the source level.
1414- */
1515-import { readFileSync } from 'node:fs';
1616-import { fileURLToPath } from 'node:url';
1717-import { describe, expect, it } from 'vitest';
1818-1919-const read = ( rel: string ) =>
2020- readFileSync( fileURLToPath( new URL( rel, import.meta.url ) ), 'utf8' );
2121-2222-/** Pull the body of a CSS rule (between its `{` and the matching `}`) for the given selector. */
2323-const ruleBody = ( css: string, selector: string ): string => {
2424- const start = css.indexOf( selector );
2525- if ( start === -1 ) {
2626- return '';
2727- }
2828- const open = css.indexOf( '{', start );
2929- const close = css.indexOf( '}', open );
3030- return css.slice( open + 1, close );
3131-};
3232-3333-describe( 'landing page handle CTA shrinks on narrow viewports', () => {
3434- const style = read( './index.astro' ).match( /<style>([\s\S]*?)<\/style>/ )?.[ 1 ] ?? '';
3535-3636- it( 'lets the field shrink below the input intrinsic width (min-width: 0)', () => {
3737- const body = ruleBody( style, '.handlestart__field)' );
3838- expect( body, 'expected a .handlestart__field rule in the landing styles' ).not.toBe( '' );
3939- expect( body, '.handlestart__field must set min-width: 0 so it can shrink in the flex row' ).toMatch(
4040- /min-width:\s*0\b/
4141- );
4242- } );
4343-4444- it( 'lets the input itself shrink inside the field (min-width: 0)', () => {
4545- const body = ruleBody( style, '.handlestart__input)' );
4646- expect( body, 'expected a .handlestart__input rule in the landing styles' ).not.toBe( '' );
4747- expect( body, '.handlestart__input must set min-width: 0 so the field can collapse around it' ).toMatch(
4848- /min-width:\s*0\b/
4949- );
5050- } );
5151-} );
+34
src/pages/_index.write-cta.test.ts
···11+/**
22+ * The home page leads with the writing-first experience (/write), not the gated editor.
33+ *
44+ * Source-level asserts (like the sibling _index.*.test.ts) — rendering the page through
55+ * astro/container isn't viable under the jsdom-pinned runner, so we pin the link targets
66+ * at the source.
77+ */
88+import { readFileSync } from 'node:fs';
99+import { dirname, join } from 'node:path';
1010+import { fileURLToPath } from 'node:url';
1111+import { describe, expect, it } from 'vitest';
1212+1313+// Resolve via `fileURLToPath` + `join` (not `new URL(rel, import.meta.url)`): under this
1414+// repo's Astro/Vite-backed Vitest the global `URL` resolves a relative specifier to an
1515+// `http://localhost` dev URL, which `readFileSync` rejects. Mirrors `_write.meta.test.ts`.
1616+const here = dirname( fileURLToPath( import.meta.url ) );
1717+const src = readFileSync( join( here, './index.astro' ), 'utf8' );
1818+1919+describe( 'home page leads with the writing-first experience', () => {
2020+ it( 'points the masthead Write button at /write, not the gated editor', () => {
2121+ expect( src ).toMatch( /class="[^"]*masthead-write[^"]*"\s+href="\/write"/ );
2222+ // The home page no longer routes anyone to the login-gated /editor.
2323+ expect( src ).not.toContain( 'href="/editor"' );
2424+ } );
2525+2626+ it( 'gives the hero a primary "Start writing" CTA to /write', () => {
2727+ expect( src ).toMatch( /href="\/write"[^>]*>\s*Start writing/ );
2828+ } );
2929+3030+ it( 'offers no sign-in island on the home page — signing in happens via Publish on /write', () => {
3131+ expect( src ).not.toContain( '<HandleStart' );
3232+ expect( src ).not.toMatch( /import HandleStart/ );
3333+ } );
3434+} );
+24
src/pages/_write.meta.test.ts
···11+import { describe, it, expect } from 'vitest';
22+import { readFileSync } from 'node:fs';
33+import { dirname, join } from 'node:path';
44+import { fileURLToPath } from 'node:url';
55+66+// Read the page source off disk. We resolve via `fileURLToPath` + `join` rather
77+// than handing a `URL` straight to `readFileSync` because, under this repo's
88+// Astro/Vite-backed Vitest, the global `URL` resolves a relative specifier
99+// against the test module to an `http://localhost` dev-server URL (not `file:`),
1010+// which `readFileSync` rejects. This mirrors the sibling `_*.meta.test.ts` pages.
1111+const here = dirname( fileURLToPath( import.meta.url ) );
1212+const src = readFileSync( join( here, './write.astro' ), 'utf8' );
1313+1414+describe( '/write route', () => {
1515+ it( 'mounts WriteStudio as a client:only island', () => {
1616+ expect( src ).toContain( "import WriteStudio from '../components/WriteStudio.tsx'" );
1717+ expect( src ).toMatch( /<WriteStudio\s+client:only="react"/ );
1818+ } );
1919+2020+ it( 'ships a writing-focused title and the write chrome styles', () => {
2121+ expect( src ).toMatch( /<Base title="Write[^"]*"/ );
2222+ expect( src ).toContain( "import '../styles/write-chrome.css'" );
2323+ } );
2424+} );
···11+---
22+import Base from '../layouts/Base.astro';
33+import WriteStudio from '../components/WriteStudio.tsx';
44+import LoadingScene from '../components/LoadingScene.astro';
55+// The island is `client:only`, so Astro's scoped styles never reach its DOM — its
66+// chrome is styled globally from these shared stylesheets plus the write-specific one.
77+import '../styles/app-bar.css';
88+import '../styles/editor-chrome.css';
99+import '../styles/login.css';
1010+import '../styles/write-chrome.css';
1111+---
1212+1313+<Base title="Write — SkyPress">
1414+ <main class="editor-shell">
1515+ <!-- client:only — auth + editor run only in the browser (Decisions 0001 & 0004).
1616+ Unlike /editor this surface never gates on auth: you can write signed out. -->
1717+ <WriteStudio client:only="react">
1818+ <LoadingScene slot="fallback" variant="editor" />
1919+ </WriteStudio>
2020+ </main>
2121+</Base>
+191
src/styles/write-chrome.css
···11+/* src/styles/write-chrome.css
22+ * Chrome unique to the writing-first page (/write): the top actions bar (the Publish button),
33+ * the sign-in panel, and the publish stepper. The signed-in identity + sign-out live in the
44+ * shared app bar (app-bar.css), not here. The editor body itself reuses
55+ * editor-chrome.css (.studio__title / .studio__lede / .studio__cover*), so the title/lede
66+ * stay direct children of the column — never wrap them, or they lose their shared alignment.
77+ */
88+99+/* Top actions bar: right-aligned account pill + Publish, in the SAME centred content column
1010+ as the app bar, title, and editor surface (var(--studio-measure)/--studio-gutter), so it
1111+ lines up with everything below instead of floating at the viewport edge. */
1212+.write-actions {
1313+ display: flex;
1414+ align-items: center;
1515+ justify-content: flex-end;
1616+ gap: 0.75rem;
1717+ max-width: var(--studio-measure, 60rem);
1818+ margin: 0 auto;
1919+ padding: 0.75rem var(--studio-gutter, 1.25rem) 0;
2020+}
2121+2222+/* Primary publish action — matches the editor's .publish__button (sun fill). */
2323+.write-publish {
2424+ font: inherit;
2525+ font-weight: 600;
2626+ cursor: pointer;
2727+ border: 0;
2828+ border-radius: 8px;
2929+ padding: 0.5rem 1rem;
3030+ background: var(--sun);
3131+ color: #fff;
3232+}
3333+3434+.write-publish:disabled {
3535+ opacity: 0.5;
3636+ cursor: not-allowed;
3737+}
3838+3939+/* Sign-in panel + publish stepper: framed cards in the centred column. Use the paper-raised
4040+ surface + token border so they read correctly in light and dark (not a near-invisible
4141+ rgba hairline on the dark canvas). */
4242+.write-signin,
4343+.writeflow {
4444+ max-width: 32rem;
4545+ margin: 1rem auto;
4646+ padding: 1.25rem 1.5rem;
4747+ background: var(--paper-raised);
4848+ border: 1px solid var(--line-strong);
4949+ border-radius: var(--radius);
5050+ box-shadow: var(--shadow);
5151+}
5252+5353+/* Sign-in panel — mirrors the editor's LoginForm (login.css) so signing in looks the same
5454+ wherever a signed-out writer meets it. */
5555+.signin-panel__title {
5656+ font-family: var(--font-display);
5757+ font-size: 1.5rem;
5858+ margin: 0 0 0.4rem;
5959+ color: var(--ink);
6060+}
6161+.signin-panel__lede {
6262+ color: var(--muted);
6363+ margin: 0 0 1.25rem;
6464+ font-size: 0.95rem;
6565+}
6666+.signin-panel__label {
6767+ display: block;
6868+ font-size: 0.85rem;
6969+ font-weight: 600;
7070+ margin-bottom: 0.35rem;
7171+ color: var(--ink);
7272+}
7373+.signin-panel__input {
7474+ width: 100%;
7575+ box-sizing: border-box;
7676+ padding: 0.6rem 0.7rem;
7777+ border: 1px solid var(--line-strong);
7878+ border-radius: 8px;
7979+ background: var(--paper);
8080+ color: var(--ink);
8181+ font: inherit;
8282+}
8383+.signin-panel__submit {
8484+ padding: 0.6rem 1.1rem;
8585+ border: 0;
8686+ border-radius: 8px;
8787+ background: var(--sun);
8888+ color: #fff;
8989+ font: inherit;
9090+ font-weight: 600;
9191+ cursor: pointer;
9292+}
9393+.signin-panel__cancel {
9494+ padding: 0.6rem 1rem;
9595+ border: 1px solid var(--line-strong);
9696+ background: var(--paper-raised);
9797+ border-radius: 8px;
9898+ color: inherit;
9999+ font: inherit;
100100+ cursor: pointer;
101101+}
102102+.signin-panel__signup {
103103+ margin: 1rem 0 0;
104104+ font-size: 0.85rem;
105105+ color: var(--muted);
106106+}
107107+.signin-panel__signup a {
108108+ color: var(--sun);
109109+ font-weight: 600;
110110+}
111111+.signin-panel__signup-link {
112112+ /* Keep the external-link icon on the same line, snug against the label. */
113113+ display: inline-flex;
114114+ align-items: center;
115115+ gap: 0.25em;
116116+ white-space: nowrap;
117117+}
118118+.signin-panel__external {
119119+ flex: none;
120120+}
121121+122122+.signin-panel__actions,
123123+.writeflow__actions {
124124+ display: flex;
125125+ gap: 0.75rem;
126126+ margin-top: 1rem;
127127+}
128128+129129+/* Publication picker (when the writer has more than one). Mirrors the editor's
130130+ .publish__target / .publish__select so it reads like the rest of the app. */
131131+.writeflow__target {
132132+ display: flex;
133133+ flex-direction: column;
134134+ gap: 0.35rem;
135135+ margin: 0 0 1rem;
136136+}
137137+.writeflow__target > span {
138138+ font-size: 0.85rem;
139139+ font-weight: 600;
140140+ color: var(--ink);
141141+}
142142+.writeflow__target select {
143143+ width: 100%;
144144+ box-sizing: border-box;
145145+ padding: 0.55rem 0.7rem;
146146+ border: 1px solid var(--line-strong);
147147+ border-radius: 8px;
148148+ background: var(--paper);
149149+ color: var(--ink);
150150+ font: inherit;
151151+ cursor: pointer;
152152+}
153153+.writeflow__target select:disabled {
154154+ opacity: 0.6;
155155+ cursor: default;
156156+}
157157+158158+/* Primary publish action — same sun fill as the editor's publish button. */
159159+.writeflow__publish {
160160+ padding: 0.6rem 1.1rem;
161161+ border: 0;
162162+ border-radius: 8px;
163163+ background: var(--sun);
164164+ color: #fff;
165165+ font: inherit;
166166+ font-weight: 600;
167167+ cursor: pointer;
168168+}
169169+.writeflow__publish:disabled {
170170+ opacity: 0.5;
171171+ cursor: not-allowed;
172172+}
173173+174174+.writeflow__status {
175175+ margin: 1rem 0 0;
176176+ color: var(--muted);
177177+ font-size: 0.9rem;
178178+}
179179+180180+.writeflow__warning {
181181+ font-size: 0.95rem;
182182+ margin: 0 0 0.75rem;
183183+}
184184+185185+.writeflow__count,
186186+.writeflow__error,
187187+.signin-panel__error {
188188+ color: var(--ember);
189189+ font-size: 0.9rem;
190190+ margin: 0.75rem 0 0;
191191+}