···11+# Masthead account menu
22+33+**Date:** 2026-06-09
44+**Status:** Approved (design)
55+66+## Summary
77+88+The home page masthead (`/`) currently shows three things on the right:
99+`<AuthorPill>` (a signed-in avatar pill that links to the public author page),
1010+a static **Dashboard** link, and a static **Studio** link (`/editor`). That is
1111+three items when signed in and two when signed out.
1212+1313+This project consolidates the right side of the masthead into a single,
1414+auth-aware control:
1515+1616+- **Signed out** (and during the pre-session loading beat, and with no JS): a
1717+ single **Write** link → `/editor`.
1818+- **Signed in**: a single **account menu** whose trigger shows the viewer's
1919+ avatar + name + `@handle`, and whose dropdown contains **Dashboard**
2020+ (`/dashboard`), **Write** (`/editor`), and **Profile** (`/@{handle}`).
2121+2222+It replaces the read-only `AuthorPill` with an interactive `AccountMenu` island
2323+that reuses the same OAuth-session + profile-fetch flow.
2424+2525+## Constraints honored
2626+2727+- **Reading pages must never import `@wordpress/*`** (AGENTS.md rule 3). The auth
2828+ chain (`createOAuthClient` → `profile.ts`) is already `@wordpress`-free;
2929+ `AccountMenu` imports only it, so the editor bundle stays off the landing page.
3030+- **OAuth is a browser public client** (AGENTS.md rule 7). The menu restores the
3131+ session client-side only; it never runs server-side.
3232+- The menu is **read-only navigation** — it does not sign in or sign out.
3333+ Sign-out stays in the editor where it already lives.
3434+3535+## Decisions (from brainstorming)
3636+3737+- **Logged-out item:** a single **Write** link → `/editor`. The old static
3838+ **Dashboard** and **Studio** links are removed from the masthead.
3939+- **Profile destination:** the public author page `/@{handle}` (same target the
4040+ current pill uses), via `authorPath()`.
4141+- **Loading / no-JS behavior (chosen Option A):** the **Write** link is
4242+ server-rendered in `index.astro`, so it appears instantly and works without
4343+ JS. Signed-in users briefly see "Write", then the island swaps it for the
4444+ account menu once the session resolves. A minor one-time morph is acceptable;
4545+ an empty top-right or a skeleton is not.
4646+- **Trigger interaction (chosen Option B):** the trigger **toggles the dropdown**
4747+ rather than navigating. Dashboard is therefore an explicit menu item, and every
4848+ destination is reachable on touch. (The brief's original "click the trigger →
4949+ /dashboard" idea was dropped in favor of full touch reachability.)
5050+- **Open/close affordances:** mouse hover (desktop enhancement), click/tap toggle
5151+ (works on touch), and keyboard (focus trigger + Enter/Space). Closes on outside
5252+ click, on `Escape`, and when focus leaves the menu.
5353+- **Implementation approach:** static SSR Write link + a client-only island that,
5454+ when signed in, renders the menu and marks its container so CSS hides the static
5555+ Write link. (Not: making the whole right side one client-only component — that
5656+ would drop the no-JS Write link. Not: a custom popover library — the project has
5757+ no dropdown component and this is small enough to build directly.)
5858+5959+## Components & files
6060+6161+### New — `src/components/AccountMenu.tsx` (`client:only="react"`)
6262+6363+Replaces `AuthorPill.tsx`.
6464+6565+- On mount: `createOAuthClient()` → `client.init()`. No session / `init()` throws
6666+ / profile fetch throws → render nothing (the static Write link stays; this is
6767+ non-critical chrome, exactly as the pill behaves today).
6868+- Session restored → build `Agent`, call `fetchViewerProfile`, render the menu.
6969+- **Trigger:** a `<button>` showing avatar (initial-letter circle fallback when
7070+ `avatar` is null), display name (`displayNameFor`), and `@handle`. Carries
7171+ `aria-haspopup="menu"` and `aria-expanded`. Styled to match today's frosted
7272+ `.authorpill` chip.
7373+- **Dropdown:** `role="menu"` panel of `role="menuitem"` links built from
7474+ `accountMenuItems()` (see helper). Opens on hover / click / keyboard; closes on
7575+ outside click, `Escape`, and focus-out.
7676+- On signed-in render, sets `data-signed-in` on `.masthead__right` (via a ref to
7777+ the parent, or a documented effect) so CSS hides the sibling static Write link.
7878+7979+### New — pure helper in `src/lib/auth/profile.ts` (or a small sibling module)
8080+8181+```ts
8282+export interface MenuItem {
8383+ label: string;
8484+ href: string;
8585+}
8686+8787+// Builds the dropdown items for a signed-in viewer:
8888+// Dashboard → /dashboard, Write → /editor, Profile → /@{handle}.
8989+// The Profile item is omitted when no handle is known (authorPath → null),
9090+// rather than rendering a broken link.
9191+export function accountMenuItems(profile: ViewerProfile): MenuItem[];
9292+```
9393+9494+Keeps the navigation model in tested pure code; the island stays thin.
9595+`displayNameFor` and `authorPath` already exist and are tested.
9696+9797+### Changed — `src/pages/index.astro`
9898+9999+- In `.masthead__right`: render a static `<a class="masthead-write" href="/editor">Write</a>`
100100+ and `<AccountMenu client:only="react" />`. Remove the old `<AuthorPill>`, the
101101+ Dashboard link, and the Studio link.
102102+- Add styles to the existing `<style>` block: the `.masthead-write` chip, the
103103+ trigger/dropdown (frosted panel consistent with `.btn--ghost` + per-phase
104104+ `--sky-*` tokens), and `.masthead__right[data-signed-in] .masthead-write { display: none }`.
105105+- Dropdown open/close transition respects `prefers-reduced-motion`.
106106+107107+### Removed — `src/components/AuthorPill.tsx`
108108+109109+Superseded by `AccountMenu`. Confirm no other importer before deleting
110110+(home masthead is the only known consumer).
111111+112112+## Data flow & states
113113+114114+**Home (`/`):**
115115+1. Static HTML renders instantly — logo + **Write** link. Works with no JS.
116116+2. `AccountMenu` hydrates → `createOAuthClient()` → `client.init()`.
117117+3. No session → renders nothing; Write link stays (signed-out state).
118118+4. Session restored → `fetchViewerProfile` → menu renders, `data-signed-in` set,
119119+ static Write link hidden (brief morph, no skeleton).
120120+121121+**Menu interaction (signed in):**
122122+- Hover trigger → open. Mouse leaves menu region → close.
123123+- Click/tap trigger → toggle.
124124+- Focus trigger + Enter/Space → open; Tab moves through items; `Escape` closes
125125+ and returns focus to the trigger.
126126+- Click outside / focus-out → close.
127127+128128+## Error handling & edge cases
129129+130130+- `getProfile` fails → `fetchViewerProfile` returns nulls (keeps `did`); name
131131+ falls back displayName → handle → DID; avatar → initial circle. Menu still works.
132132+- No handle known → `authorPath` returns null → **Profile** item omitted;
133133+ Dashboard and Write remain.
134134+- `client.init()` throws → renders nothing; static Write link stays.
135135+- Avatar `<img>` fails to load → `onError` fallback to the initial circle
136136+ (same pattern as today's pill).
137137+138138+## Testing (test-first, repo convention)
139139+140140+This repo has **no React component-test setup**: vitest (jsdom) runs only
141141+`src/**/*.test.ts` pure-logic tests; there is no `@testing-library`, and we do
142142+**not** add one. We follow the established pattern — push testable logic into
143143+pure helpers, unit-test those, and keep the React island thin (verified manually
144144++ type-check).
145145+146146+- `profile.test.ts` (extend existing):
147147+ - `accountMenuItems` returns Dashboard (`/dashboard`), Write (`/editor`), and
148148+ Profile (`/@{handle}`) in order for a profile with a handle.
149149+ - `accountMenuItems` omits the Profile item when the handle is null.
150150+- Manual verification (`npm run dev`): signed-out shows only Write → `/editor`;
151151+ signed-in shows the trigger; hover/click/keyboard open the dropdown; the three
152152+ items navigate correctly; `Escape` / outside-click close it; no-JS still shows
153153+ the Write link.
154154+- `npm run check` passes (types + lint).
155155+- Guard: no `@wordpress` import reaches the home bundle (`AccountMenu` imports
156156+ only the already-clean auth chain).
157157+158158+## Out of scope
159159+160160+- Sign-out / sign-in from the masthead (stays in the editor).
161161+- Arrow-key roving focus inside the menu (Tab is sufficient for v1).
162162+- Any change to the dashboard, editor, or public author pages themselves.
163163+- Caching the profile across pages / a shared cross-page session store.