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.

Add design spec for masthead account menu

+163
+163
docs/superpowers/specs/2026-06-09-masthead-account-menu-design.md
··· 1 + # Masthead account menu 2 + 3 + **Date:** 2026-06-09 4 + **Status:** Approved (design) 5 + 6 + ## Summary 7 + 8 + The home page masthead (`/`) currently shows three things on the right: 9 + `<AuthorPill>` (a signed-in avatar pill that links to the public author page), 10 + a static **Dashboard** link, and a static **Studio** link (`/editor`). That is 11 + three items when signed in and two when signed out. 12 + 13 + This project consolidates the right side of the masthead into a single, 14 + auth-aware control: 15 + 16 + - **Signed out** (and during the pre-session loading beat, and with no JS): a 17 + single **Write** link → `/editor`. 18 + - **Signed in**: a single **account menu** whose trigger shows the viewer's 19 + avatar + name + `@handle`, and whose dropdown contains **Dashboard** 20 + (`/dashboard`), **Write** (`/editor`), and **Profile** (`/@{handle}`). 21 + 22 + It replaces the read-only `AuthorPill` with an interactive `AccountMenu` island 23 + that reuses the same OAuth-session + profile-fetch flow. 24 + 25 + ## Constraints honored 26 + 27 + - **Reading pages must never import `@wordpress/*`** (AGENTS.md rule 3). The auth 28 + chain (`createOAuthClient` → `profile.ts`) is already `@wordpress`-free; 29 + `AccountMenu` imports only it, so the editor bundle stays off the landing page. 30 + - **OAuth is a browser public client** (AGENTS.md rule 7). The menu restores the 31 + session client-side only; it never runs server-side. 32 + - The menu is **read-only navigation** — it does not sign in or sign out. 33 + Sign-out stays in the editor where it already lives. 34 + 35 + ## Decisions (from brainstorming) 36 + 37 + - **Logged-out item:** a single **Write** link → `/editor`. The old static 38 + **Dashboard** and **Studio** links are removed from the masthead. 39 + - **Profile destination:** the public author page `/@{handle}` (same target the 40 + current pill uses), via `authorPath()`. 41 + - **Loading / no-JS behavior (chosen Option A):** the **Write** link is 42 + server-rendered in `index.astro`, so it appears instantly and works without 43 + JS. Signed-in users briefly see "Write", then the island swaps it for the 44 + account menu once the session resolves. A minor one-time morph is acceptable; 45 + an empty top-right or a skeleton is not. 46 + - **Trigger interaction (chosen Option B):** the trigger **toggles the dropdown** 47 + rather than navigating. Dashboard is therefore an explicit menu item, and every 48 + destination is reachable on touch. (The brief's original "click the trigger → 49 + /dashboard" idea was dropped in favor of full touch reachability.) 50 + - **Open/close affordances:** mouse hover (desktop enhancement), click/tap toggle 51 + (works on touch), and keyboard (focus trigger + Enter/Space). Closes on outside 52 + click, on `Escape`, and when focus leaves the menu. 53 + - **Implementation approach:** static SSR Write link + a client-only island that, 54 + when signed in, renders the menu and marks its container so CSS hides the static 55 + Write link. (Not: making the whole right side one client-only component — that 56 + would drop the no-JS Write link. Not: a custom popover library — the project has 57 + no dropdown component and this is small enough to build directly.) 58 + 59 + ## Components & files 60 + 61 + ### New — `src/components/AccountMenu.tsx` (`client:only="react"`) 62 + 63 + Replaces `AuthorPill.tsx`. 64 + 65 + - On mount: `createOAuthClient()` → `client.init()`. No session / `init()` throws 66 + / profile fetch throws → render nothing (the static Write link stays; this is 67 + non-critical chrome, exactly as the pill behaves today). 68 + - Session restored → build `Agent`, call `fetchViewerProfile`, render the menu. 69 + - **Trigger:** a `<button>` showing avatar (initial-letter circle fallback when 70 + `avatar` is null), display name (`displayNameFor`), and `@handle`. Carries 71 + `aria-haspopup="menu"` and `aria-expanded`. Styled to match today's frosted 72 + `.authorpill` chip. 73 + - **Dropdown:** `role="menu"` panel of `role="menuitem"` links built from 74 + `accountMenuItems()` (see helper). Opens on hover / click / keyboard; closes on 75 + outside click, `Escape`, and focus-out. 76 + - On signed-in render, sets `data-signed-in` on `.masthead__right` (via a ref to 77 + the parent, or a documented effect) so CSS hides the sibling static Write link. 78 + 79 + ### New — pure helper in `src/lib/auth/profile.ts` (or a small sibling module) 80 + 81 + ```ts 82 + export interface MenuItem { 83 + label: string; 84 + href: string; 85 + } 86 + 87 + // Builds the dropdown items for a signed-in viewer: 88 + // Dashboard → /dashboard, Write → /editor, Profile → /@{handle}. 89 + // The Profile item is omitted when no handle is known (authorPath → null), 90 + // rather than rendering a broken link. 91 + export function accountMenuItems(profile: ViewerProfile): MenuItem[]; 92 + ``` 93 + 94 + Keeps the navigation model in tested pure code; the island stays thin. 95 + `displayNameFor` and `authorPath` already exist and are tested. 96 + 97 + ### Changed — `src/pages/index.astro` 98 + 99 + - In `.masthead__right`: render a static `<a class="masthead-write" href="/editor">Write</a>` 100 + and `<AccountMenu client:only="react" />`. Remove the old `<AuthorPill>`, the 101 + Dashboard link, and the Studio link. 102 + - Add styles to the existing `<style>` block: the `.masthead-write` chip, the 103 + trigger/dropdown (frosted panel consistent with `.btn--ghost` + per-phase 104 + `--sky-*` tokens), and `.masthead__right[data-signed-in] .masthead-write { display: none }`. 105 + - Dropdown open/close transition respects `prefers-reduced-motion`. 106 + 107 + ### Removed — `src/components/AuthorPill.tsx` 108 + 109 + Superseded by `AccountMenu`. Confirm no other importer before deleting 110 + (home masthead is the only known consumer). 111 + 112 + ## Data flow & states 113 + 114 + **Home (`/`):** 115 + 1. Static HTML renders instantly — logo + **Write** link. Works with no JS. 116 + 2. `AccountMenu` hydrates → `createOAuthClient()` → `client.init()`. 117 + 3. No session → renders nothing; Write link stays (signed-out state). 118 + 4. Session restored → `fetchViewerProfile` → menu renders, `data-signed-in` set, 119 + static Write link hidden (brief morph, no skeleton). 120 + 121 + **Menu interaction (signed in):** 122 + - Hover trigger → open. Mouse leaves menu region → close. 123 + - Click/tap trigger → toggle. 124 + - Focus trigger + Enter/Space → open; Tab moves through items; `Escape` closes 125 + and returns focus to the trigger. 126 + - Click outside / focus-out → close. 127 + 128 + ## Error handling & edge cases 129 + 130 + - `getProfile` fails → `fetchViewerProfile` returns nulls (keeps `did`); name 131 + falls back displayName → handle → DID; avatar → initial circle. Menu still works. 132 + - No handle known → `authorPath` returns null → **Profile** item omitted; 133 + Dashboard and Write remain. 134 + - `client.init()` throws → renders nothing; static Write link stays. 135 + - Avatar `<img>` fails to load → `onError` fallback to the initial circle 136 + (same pattern as today's pill). 137 + 138 + ## Testing (test-first, repo convention) 139 + 140 + This repo has **no React component-test setup**: vitest (jsdom) runs only 141 + `src/**/*.test.ts` pure-logic tests; there is no `@testing-library`, and we do 142 + **not** add one. We follow the established pattern — push testable logic into 143 + pure helpers, unit-test those, and keep the React island thin (verified manually 144 + + type-check). 145 + 146 + - `profile.test.ts` (extend existing): 147 + - `accountMenuItems` returns Dashboard (`/dashboard`), Write (`/editor`), and 148 + Profile (`/@{handle}`) in order for a profile with a handle. 149 + - `accountMenuItems` omits the Profile item when the handle is null. 150 + - Manual verification (`npm run dev`): signed-out shows only Write → `/editor`; 151 + signed-in shows the trigger; hover/click/keyboard open the dropdown; the three 152 + items navigate correctly; `Escape` / outside-click close it; no-JS still shows 153 + the Write link. 154 + - `npm run check` passes (types + lint). 155 + - Guard: no `@wordpress` import reaches the home bundle (`AccountMenu` imports 156 + only the already-clean auth chain). 157 + 158 + ## Out of scope 159 + 160 + - Sign-out / sign-in from the masthead (stays in the editor). 161 + - Arrow-key roving focus inside the menu (Tab is sufficient for v1). 162 + - Any change to the dashboard, editor, or public author pages themselves. 163 + - Caching the profile across pages / a shared cross-page session store.