···11+# Editor & dashboard rework — shared app bar, cleaner writing surface
22+33+**Date:** 2026-06-09
44+**Status:** Approved (design)
55+66+## Summary
77+88+The `/editor` page does too much: it lists every published article, shows a
99+separate bordered title input, frames the block editor in a short box, and keeps
1010+account identity + sign-out in a bar *below* the page header. This project
1111+reworks the editor into a focused writing surface and introduces a single,
1212+auth-aware top bar shared with `/dashboard`.
1313+1414+Five changes:
1515+1616+1. **Shared `AppBar`** across `/editor` and `/dashboard` — logo on the left;
1717+ contextual nav + account identity + sign-out on the right.
1818+2. **Drop the published-articles list** from the editor. Editing moves to the
1919+ dashboard's Posts tab, which gains an **Edit** action that opens
2020+ `/editor?edit=<rkey>`.
2121+3. **Restyle the title** as a large, borderless heading (block-editor post-title
2222+ feel) sitting *above* the editor canvas.
2323+4. **Taller editor canvas** by default (~70vh) for long-form drafting.
2424+5. **Contextual nav icon**: on `/dashboard`, a feather icon → `/editor`; on
2525+ `/editor`, a "Publications" link → `/dashboard`.
2626+2727+## Decisions (from brainstorming)
2828+2929+- **One shared bar, two contexts.** A single `AppBar` React component renders at
3030+ the top of both islands. The old static Astro headers (`editor-shell__bar`,
3131+ `dash-shell__bar`) and the in-island `studio__account` bar are removed.
3232+- **Contextual action is symmetric** — icon + label on both pages: feather +
3333+ "Write" → `/editor` on the dashboard; book/Publications icon + "Publications"
3434+ → `/dashboard` on the editor.
3535+- **The account block is the profile link.** Avatar + name + `@handle` together
3636+ form a single link to the public author page (`authorPath(handle)`). This
3737+ replaces the separate "View my public page" link. Sign-out stays a distinct
3838+ button beside it.
3939+- **Editing is URL-driven (Option A from brainstorming).** The editor's article
4040+ list is removed entirely; the dashboard becomes the single place to manage and
4141+ open articles. An `?edit=<rkey>` param is sufficient because `rkey` uniquely
4242+ identifies a document within the writer's repo, and it makes edit links
4343+ shareable/bookmarkable.
4444+- **Title above the canvas, borderless.** Not inside the canvas (rejected in
4545+ brainstorming) — it sits above the framed editor as a large serif heading on
4646+ the page background.
4747+- **Editor min-height ≈ 70vh.** Comfortable for long-form without forcing
4848+ fill-to-bottom.
4949+- **Publish controls in a slim row above the title** (target selector + Publish
5050+ button + editing indicator + "+ New article"). Not pinned to the bottom.
5151+5252+## Components & files
5353+5454+### New — `src/components/AppBar.tsx` (`client:only` via its host island)
5555+5656+Rendered inside `AuthProvider` (it consumes `useAuth`), at the top of both
5757+`StudioGate` and `DashboardGate`, in **every** auth state.
5858+5959+- **Props:** `current: 'editor' | 'dashboard'`.
6060+- **Left:** SkyPress `Logo` → `/`.
6161+- **Right, by state:**
6262+ - *loading* → nothing but the logo.
6363+ - *signed-out* → contextual nav link only (no account/sign-out).
6464+ - *signed-in* → contextual nav · account block · Sign out.
6565+- **Contextual nav** comes from a pure helper (below) so the page→destination
6666+ mapping is tested in isolation; the component just renders the returned
6767+ `{ href, label, icon }`.
6868+- **Account block:** a single `<a href={authorPath(handle)}>` wrapping avatar
6969+ (initial-letter fallback on null/`onError`, matching today's pattern) + name
7070+ (`displayNameFor`) + `@handle`. Omitted-handle → still renders name/avatar but
7171+ not as a profile link (no broken `/@` link).
7272+- **Sign out:** `<button onClick={signOut}>`.
7373+7474+### New — pure helper in `src/lib/auth/nav.ts` (small new module)
7575+7676+```ts
7777+export interface AppBarNav {
7878+ href: string;
7979+ label: string;
8080+ icon: 'feather' | 'publications';
8181+}
8282+8383+// The contextual nav action for the bar, given the page it renders on:
8484+// editor → { href: '/dashboard', label: 'Publications', icon: 'publications' }
8585+// dashboard → { href: '/editor', label: 'Write', icon: 'feather' }
8686+export function appBarNav( current: 'editor' | 'dashboard' ): AppBarNav;
8787+```
8888+8989+Keeps the navigation model in tested pure code; the island stays thin. The two
9090+inline SVGs (feather, publications) live in `AppBar.tsx` keyed by `icon`.
9191+9292+### New — `getMyArticle` in `src/lib/publish/publisher.ts`
9393+9494+```ts
9595+// Fetch a single SkyPress document by rkey for the editor's ?edit load.
9696+// Returns the matching MyArticle, or null when the record is missing or its
9797+// `site` isn't one of the writer's SkyPress publications (foreign / orphan doc).
9898+export async function getMyArticle(
9999+ agent: Agent,
100100+ did: string,
101101+ rkey: string
102102+): Promise< MyArticle | null >;
103103+```
104104+105105+Implemented by reusing the already-tested `listAllMyArticles` and selecting by
106106+`rkey` (`.find(...) ?? null`). This inherits its slug-annotation and
107107+foreign/orphan-doc filtering for free, rather than re-implementing a `getRecord`
108108++ slug-resolution path.
109109+110110+### Changed — `src/components/Studio.tsx`
111111+112112+- Render `<AppBar current="editor" />` at the top of `StudioGate` in all states.
113113+- **Remove** the `studio__account` bar and the `<MyArticles>` section.
114114+- **URL load:** on mount, read `?edit=<rkey>` (via `URLSearchParams`). If present
115115+ and signed in, `getMyArticle(agent, did, rkey)` → on success seed `editing` +
116116+ `blocks` (same as today's `startEdit`); on null, ignore and start fresh.
117117+- **Title lifts up:** `title` state moves from `PublishPanel` to `StudioGate`.
118118+ `StudioGate` renders the borderless `<input class="studio__title">` above the
119119+ editor and passes `title` / `onTitleChange` to `PublishPanel`.
120120+- **"+ New article"** clears the param (navigate to `/editor`) and resets state.
121121+- The existing `studio__mode` indicator (`Editing: …` + "+ New article") is
122122+ retained and sits in the publish row; `StudioGate` keeps ownership of it (it
123123+ drives navigation/reset), rendered adjacent to `PublishPanel`.
124124+- Layout order: `AppBar` → publish row (mode indicator + `PublishPanel`) → title
125125+ input → `SkyEditor`.
126126+127127+### Changed — `src/components/PublishPanel.tsx`
128128+129129+- No longer renders the title `<input>` or owns title state. Receives
130130+ `title: string` and `onTitleChange: (v: string) => void` as props (title still
131131+ drives `canSubmit` and the publish/update calls).
132132+- Keeps the target selector + Publish button + confirm flow. (The `Editing: …`
133133+ indicator stays in `StudioGate`'s mode bar, rendered beside it.)
134134+135135+### Changed — `src/components/Dashboard.tsx`
136136+137137+- Render `<AppBar current="dashboard" />` at the top of `DashboardGate`.
138138+- Remove the old `dash__bar` right-side actions (`Studio` link + Sign out) — now
139139+ in `AppBar`. The **"Your publications" breadcrumb** stays as in-page nav.
140140+- **Posts tab:** add an **Edit** action per article →
141141+ `<a href={`/editor?edit=${article.rkey}`}>`. View link + Unpublish button stay.
142142+143143+### Changed — styles
144144+145145+- **New `src/styles/app-bar.css`** (global; imported by both `editor.astro` and
146146+ `dashboard.astro`) holds all `.app-bar*` rules.
147147+- `editor.astro` / `dashboard.astro`: drop the static `<header>` markup and its
148148+ scoped styles; import `app-bar.css`.
149149+- `editor-chrome.css`: remove `.studio__account*` and `.myarticles*` rules; add
150150+ `.studio__title` (borderless serif heading) and bump the editor surface to
151151+ `min-height: ~70vh` (exact node verified in-browser during implementation —
152152+ likely the `.iso-editor` content region).
153153+154154+### Removed
155155+156156+- `src/components/MyArticles.tsx` and `src/components/MyArticles.test.tsx`
157157+ (no remaining importer once the editor list is gone — confirm before deleting).
158158+159159+## Data flow & states
160160+161161+**Editor (`/editor`):**
162162+1. `StudioGate` renders `AppBar` immediately (logo; account once session loads).
163163+2. `?edit=<rkey>` present + signed in → `getMyArticle` → seed editor; else new.
164164+3. Title typed above the canvas; publish row drives publish/update via lifted
165165+ `title` state.
166166+167167+**Dashboard (`/dashboard`):**
168168+1. `AppBar` with feather → `/editor`.
169169+2. Posts tab Edit → `/editor?edit=<rkey>` (full navigation; editor re-fetches).
170170+171171+## Error handling & edge cases
172172+173173+- `getMyArticle`: record missing / `getRecord` throws / foreign `site` → returns
174174+ `null`; the editor silently starts a new article (no crash, no error banner —
175175+ a stale/bad edit link just opens a blank editor).
176176+- No handle known → account block renders without a profile link (no broken
177177+ `/@` href), consistent with the masthead `AccountMenu` rule.
178178+- Avatar `<img>` fails → initial-letter fallback (existing `onError` pattern).
179179+- Signed-out on `/editor` → `AppBar` shows logo + "Publications" link only; the
180180+ page body shows the existing `LoginForm`.
181181+182182+## Testing (test-first, repo convention)
183183+184184+The repo favors **pure helpers unit-tested + thin islands** (per the masthead
185185+spec); component-level `react-dom`/`act` tests exist (e.g.
186186+`PublicationForm.test.tsx`) and are used only where behavior is genuinely
187187+component-bound.
188188+189189+- **`nav.test.ts`** (new): `appBarNav('editor')` → Publications/`/dashboard`;
190190+ `appBarNav('dashboard')` → Write/`/editor`.
191191+- **`publisher.test.ts`** (extend): `getMyArticle` returns a `MyArticle` for an
192192+ owned SkyPress doc; returns `null` for a missing rkey and for a doc whose
193193+ `site` is foreign/unknown. (Mirrors existing `listAllMyArticles` test setup.)
194194+- **`edit-link.test.ts`** (new): `editRkeyFromSearch` returns the rkey for
195195+ `?edit=<rkey>`, `null` for an absent/empty param; `editLinkFor(rkey)` returns
196196+ `/editor?edit=<rkey>`. (The fetch+seed wiring in `StudioGate` is thin glue,
197197+ verified manually — `StudioGate` can't be unit-rendered because it pulls in the
198198+ browser-only `isolated-block-editor`.)
199199+- Manual verification (`npm run dev`): shared bar on both pages; contextual icon
200200+ per page; account block → public page; sign-out; borderless title; taller
201201+ canvas; dashboard Edit → editor loads the article.
202202+- `npm run check` passes (types + lint). No new `@wordpress/*` import outside the
203203+ editor island (`AppBar` / `nav.ts` / `getMyArticle` are `@wordpress`-free).
204204+205205+## Constraints honored
206206+207207+- **Reading pages never import `@wordpress/*`** (AGENTS.md rule 3): `AppBar`,
208208+ `nav.ts`, and `getMyArticle` are `@wordpress`-free and live in the auth/editor
209209+ islands only.
210210+- **OAuth is a browser public client** (rule 7): `AppBar` consumes the existing
211211+ client-only `useAuth`; nothing runs server-side.
212212+- **No block/render changes** — render fidelity (rule 4) untouched.
213213+214214+## Out of scope
215215+216216+- The home-page masthead (`AccountMenu`) — separate, already specced.
217217+- Autosave/draft changes, new block types, publish-flow behavior.
218218+- A shared cross-page session/profile store.
219219+- Arrow-key roving focus or a dropdown menu in the `AppBar` (it's flat links).
···3434 publications: Publication[] | null;
3535 /** When set, the panel updates an existing article instead of publishing a new one. */
3636 editing?: EditingTarget;
3737- initialTitle?: string;
3737+ /** Controlled title (lifted to the editor so it can render as a heading above the canvas). */
3838+ title: string;
3839 /** Called after a successful publish/update so the parent can refresh. */
3940 onComplete?: () => void;
4041}
···5253 blobRegistry,
5354 publications,
5455 editing,
5555- initialTitle,
5656+ title,
5657 onComplete,
5758}: Props ) {
5859 const pubs = publications ?? [];
5959- const [ title, setTitle ] = useState( initialTitle ?? '' );
6060 const [ targetUri, setTargetUri ] = useState(
6161 () => editing?.siteUri ?? pubs[ 0 ]?.uri ?? ''
6262 );
···150150151151 return (
152152 <section className="publish" aria-label={ isEditing ? 'Update article' : 'Publish' }>
153153- <input
154154- className="publish__title"
155155- type="text"
156156- placeholder="Article title"
157157- value={ title }
158158- onChange={ ( event ) => setTitle( event.target.value ) }
159159- disabled={ phase === 'working' }
160160- />
161161-162153 { isEditing ? (
163154 <span className="publish__target publish__target--fixed">
164155 In <strong>{ editingPubName ?? 'this publication' }</strong>
+99-74
src/components/Studio.tsx
···55import LoginForm from '../lib/auth/LoginForm';
66import SkyEditor from './SkyEditor';
77import PublishPanel from './PublishPanel';
88-import MyArticles from './MyArticles';
88+import AppBar from './AppBar';
99import { createMediaUpload, revokeBlobRegistry, type BlobRegistry } from '../lib/media/mediaUpload';
1010-import { displayNameFor, authorPath } from '../lib/auth/profile';
1111-import type { MyArticle } from '../lib/publish/publisher';
1010+import { getMyArticle, type MyArticle } from '../lib/publish/publisher';
1111+import { editRkeyFromSearch } from '../lib/editor/edit-link';
1212import { listPublications, type Publication } from '../lib/publish/publications';
13131414/**
1515 * The authenticated writing surface. Gates the editor behind atproto OAuth:
1616- * loading → (signed-out: login form) | (signed-in: account bar + editor).
1616+ * loading → (signed-out: login form) | (signed-in: editor).
1717 */
1818function StudioGate() {
1919- const { status, agent, handle, displayName, avatar, did, pdsUrl, error, signOut } = useAuth();
1919+ const { status, agent, handle, did, pdsUrl, error } = useAuth();
2020 const [ blocks, setBlocks ] = useState< BlockInstance[] >( [] );
2121+ const [ title, setTitle ] = useState( '' );
2122 const [ editing, setEditing ] = useState< MyArticle | null >( null );
2323+ // Set when an `?edit=<rkey>` load fails to fetch (vs. simply not found).
2424+ const [ editLoadError, setEditLoadError ] = useState< string | null >( null );
2225 const [ refreshKey, setRefreshKey ] = useState( 0 );
2323- const [ avatarOk, setAvatarOk ] = useState( true );
2426 // `null` = still loading; `[]` = loaded, none exist. PublishPanel needs the distinction.
2527 const [ publications, setPublications ] = useState< Publication[] | null >( null );
2628 // Shared between mediaUpload (writes blob refs) and publish (reads them).
···4042 };
4143 }, [ agent, did, refreshKey ] );
42444545+ // One-shot: if the page was opened as /editor?edit=<rkey>, load that article.
4646+ const editLoadedRef = useRef( false );
4747+ useEffect( () => {
4848+ if ( editLoadedRef.current || ! agent || ! did ) {
4949+ return;
5050+ }
5151+ const rkey = editRkeyFromSearch( window.location.search );
5252+ if ( ! rkey ) {
5353+ editLoadedRef.current = true;
5454+ return;
5555+ }
5656+ editLoadedRef.current = true;
5757+ let cancelled = false;
5858+ getMyArticle( agent, did, rkey )
5959+ .then( ( article ) => {
6060+ if ( cancelled ) {
6161+ return;
6262+ }
6363+ if ( article ) {
6464+ setEditing( article );
6565+ setBlocks( article.blocks as unknown as BlockInstance[] );
6666+ setTitle( article.title );
6767+ }
6868+ // `article === null` → no owned document has this rkey (stale/bad edit
6969+ // link). Silently start a new article, as before.
7070+ } )
7171+ .catch( ( err ) => {
7272+ // The fetch itself failed (network/auth) — distinct from a stale link.
7373+ // Surface it so the blank "New article" editor isn't mistaken for the
7474+ // requested article having loaded.
7575+ if ( ! cancelled ) {
7676+ setEditLoadError(
7777+ err instanceof Error ? err.message : String( err )
7878+ );
7979+ }
8080+ } );
8181+ return () => {
8282+ cancelled = true;
8383+ };
8484+ }, [ agent, did ] );
8585+4386 // Release the preview object URLs this session minted when the Studio unmounts.
4487 useEffect( () => () => revokeBlobRegistry( registry ), [ registry ] );
4588···5194 }, [ agent, did, pdsUrl, registry ] );
52955396 if ( status === 'loading' ) {
5454- return <p className="studio__loading">Connecting to your identity…</p>;
9797+ return (
9898+ <>
9999+ <AppBar current="editor" />
100100+ <p className="studio__loading">Connecting to your identity…</p>
101101+ </>
102102+ );
55103 }
5610457105 if ( status === 'signed-in' && agent && did ) {
5858- // Re-mount the editor + panel when switching article so onLoad + the title reset.
106106+ // Re-mount the editor when switching article (or after a new publish) so the
107107+ // SkyEditor canvas resets via onLoad/initialBlocks. The title is Studio-owned
108108+ // state now, so it doesn't reset on remount — the title/blocks reset for a new
109109+ // publish happens in PublishPanel's `onComplete` below.
59110 const editorKey = editing ? `edit-${ editing.rkey }` : `new-${ refreshKey }`;
6060- const viewerName = displayNameFor( { did, handle, displayName, avatar } );
6161- const publicPath = authorPath( handle );
621116363- // Switching articles re-mounts the editor, so the current previews leave the DOM —
6464- // safe to release the object URLs they held before loading the next article.
6565- const startEdit = ( article: MyArticle ) => {
6666- revokeBlobRegistry( registry );
6767- setEditing( article );
6868- setBlocks( article.blocks as unknown as BlockInstance[] );
6969- };
70112 const startNew = () => {
71113 revokeBlobRegistry( registry );
72114 setEditing( null );
73115 setBlocks( [] );
116116+ setTitle( '' );
117117+ setEditLoadError( null );
74118 };
7511976120 return (
77121 <>
7878- <div className="studio__account">
7979- <span className="studio__identity">
8080- { avatar && avatarOk ? (
8181- <img
8282- className="studio__avatar"
8383- src={ avatar }
8484- alt=""
8585- width={ 38 }
8686- height={ 38 }
8787- onError={ () => setAvatarOk( false ) }
8888- />
8989- ) : (
9090- <span className="studio__avatar studio__avatar--fallback" aria-hidden="true">
9191- { viewerName.charAt( 0 ).toUpperCase() }
9292- </span>
9393- ) }
9494- <span className="studio__who">
9595- <strong className="studio__name">{ viewerName }</strong>
9696- { handle && <span className="studio__handle">@{ handle }</span> }
9797- </span>
9898- </span>
9999- <span className="studio__account-actions">
100100- <a className="studio__viewpage" href="/dashboard">
101101- Dashboard
102102- </a>
103103- { publicPath && (
104104- <a className="studio__viewpage" href={ publicPath }>
105105- View my public page
106106- </a>
107107- ) }
108108- <button
109109- type="button"
110110- className="studio__signout"
111111- onClick={ () => void signOut() }
112112- >
113113- Sign out
114114- </button>
115115- </span>
116116- </div>
117117-118118- <MyArticles
119119- agent={ agent }
120120- did={ did }
121121- handle={ handle }
122122- refreshKey={ refreshKey }
123123- onEdit={ startEdit }
124124- />
122122+ <AppBar current="editor" />
125123126124 <div className="studio__mode">
127125 <span>{ editing ? `Editing: ${ editing.title }` : 'New article' }</span>
···132130 ) }
133131 </div>
134132133133+ { editLoadError && (
134134+ <p className="studio__error studio__error--banner" role="alert">
135135+ Couldn't open that article for editing: { editLoadError }. You can
136136+ retry from your dashboard, or start a new article below.
137137+ </p>
138138+ ) }
139139+135140 <div key={ editorKey }>
136141 <PublishPanel
137142 agent={ agent }
···150155 }
151156 : undefined
152157 }
153153- initialTitle={ editing?.title }
154154- onComplete={ () => setRefreshKey( ( k ) => k + 1 ) }
158158+ title={ title }
159159+ onComplete={ () => {
160160+ setRefreshKey( ( k ) => k + 1 );
161161+ // A new publish leaves the editor on a fresh "new article": clear the
162162+ // title + blocks (the editorKey bump remounts SkyEditor empty). On an
163163+ // update we stay on the same article, so keep its content in place.
164164+ if ( ! editing ) {
165165+ setTitle( '' );
166166+ setBlocks( [] );
167167+ }
168168+ } }
169169+ />
170170+ <input
171171+ className="studio__title"
172172+ type="text"
173173+ placeholder="Add title"
174174+ aria-label="Article title"
175175+ value={ title }
176176+ onChange={ ( event ) => setTitle( event.target.value ) }
155177 />
156178 <SkyEditor
157179 onChange={ setBlocks }
···165187166188 // signed-out or error
167189 return (
168168- <div className="studio__login">
169169- <LoginForm />
170170- { status === 'error' && error && (
171171- <p className="studio__error" role="alert">
172172- Couldn't start the auth client: { error }
173173- </p>
174174- ) }
175175- </div>
190190+ <>
191191+ <AppBar current="editor" />
192192+ <div className="studio__login">
193193+ <LoginForm />
194194+ { status === 'error' && error && (
195195+ <p className="studio__error" role="alert">
196196+ Couldn't start the auth client: { error }
197197+ </p>
198198+ ) }
199199+ </div>
200200+ </>
176201 );
177202}
178203
+20
src/lib/auth/nav.test.ts
···11+import { describe, expect, it } from 'vitest';
22+import { appBarNav } from './nav';
33+44+describe( 'appBarNav', () => {
55+ it( 'links the editor bar back to the dashboard (Publications)', () => {
66+ expect( appBarNav( 'editor' ) ).toEqual( {
77+ href: '/dashboard',
88+ label: 'Publications',
99+ icon: 'publications',
1010+ } );
1111+ } );
1212+1313+ it( 'links the dashboard bar into the editor (Write, feather)', () => {
1414+ expect( appBarNav( 'dashboard' ) ).toEqual( {
1515+ href: '/editor',
1616+ label: 'Write',
1717+ icon: 'feather',
1818+ } );
1919+ } );
2020+} );
+20
src/lib/auth/nav.ts
···11+/** The page an AppBar renders on; selects its contextual nav action. */
22+export type AppBarContext = 'editor' | 'dashboard';
33+44+export interface AppBarNav {
55+ href: string;
66+ label: string;
77+ icon: 'feather' | 'publications';
88+}
99+1010+/**
1111+ * The AppBar's single contextual nav action, by the page it renders on:
1212+ * editor → back to your publications (the dashboard)
1313+ * dashboard → into the editor (a feather "Write")
1414+ */
1515+export function appBarNav( current: AppBarContext ): AppBarNav {
1616+ if ( current === 'dashboard' ) {
1717+ return { href: '/editor', label: 'Write', icon: 'feather' };
1818+ }
1919+ return { href: '/dashboard', label: 'Publications', icon: 'publications' };
2020+}
+23
src/lib/editor/edit-link.test.ts
···11+import { describe, expect, it } from 'vitest';
22+import { editLinkFor, editRkeyFromSearch } from './edit-link';
33+44+describe( 'editLinkFor', () => {
55+ it( 'builds the editor edit URL for an rkey', () => {
66+ expect( editLinkFor( '3kabc123' ) ).toBe( '/editor?edit=3kabc123' );
77+ } );
88+} );
99+1010+describe( 'editRkeyFromSearch', () => {
1111+ it( 'reads the rkey from the edit param', () => {
1212+ expect( editRkeyFromSearch( '?edit=3kabc123' ) ).toBe( '3kabc123' );
1313+ } );
1414+1515+ it( 'returns null when the param is absent', () => {
1616+ expect( editRkeyFromSearch( '?foo=bar' ) ).toBeNull();
1717+ expect( editRkeyFromSearch( '' ) ).toBeNull();
1818+ } );
1919+2020+ it( 'returns null when the param is present but empty', () => {
2121+ expect( editRkeyFromSearch( '?edit=' ) ).toBeNull();
2222+ } );
2323+} );
+18
src/lib/editor/edit-link.ts
···11+/**
22+ * The dashboard→editor "edit this article" link, and the editor-side parser for
33+ * it. Kept together so the `?edit=` param name has a single source of truth.
44+ * `rkey` uniquely identifies a document within the writer's repo, so it is all
55+ * the editor needs to re-fetch the article on load.
66+ */
77+const EDIT_PARAM = 'edit';
88+99+/** The editor URL that opens an existing article for editing. */
1010+export function editLinkFor( rkey: string ): string {
1111+ return `/editor?${ EDIT_PARAM }=${ rkey }`;
1212+}
1313+1414+/** The rkey to edit, parsed from a URL search string (e.g. `window.location.search`). Null when absent or empty. */
1515+export function editRkeyFromSearch( search: string ): string | null {
1616+ const rkey = new URLSearchParams( search ).get( EDIT_PARAM );
1717+ return rkey && rkey.length > 0 ? rkey : null;
1818+}