Files
biggus-dickus/docs/superpowers/plans/2026-06-07-header-wayfinding.md
T

522 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# App Header Wayfinding Implementation Plan
> **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.
**Goal:** Fill the empty app header with wayfinding — a route-driven breadcrumb (left), a signed-in user menu + compact global search (right) — and render the configured `app_name` for the brand + login.
**Architecture:** A page-driven breadcrumb (a `BreadcrumbProvider` context + `useBreadcrumb(trail)` hook, parallel to #57's `useDocumentTitle`) that each route sets and the header renders. A reusable `ui/menu.tsx` Base UI Menu wrapper powers a `UserMenu` (email/role + Sign out). A `HeaderSearch` input navigates to `/search?q=`. Brand + login read `useConfig().app_name`. No new dependency.
**Tech Stack:** React 19 + TS + pnpm, Tailwind v4, react-router 7, react-i18next, Base UI (`@base-ui/react/menu` — namespace `Menu`), lucide-react, Vitest + RTL + MSW + Storybook. Test runner: `pnpm test` (single pass).
**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; no codename; en/sv parity; **ui/ files = no-semicolon base-nova style** (match `alert-dialog.tsx`); **app source (shell/, lib/, pages) = double-quote + semicolon**; stories = single-quote + no-semicolon; token classes only (`check:colors`); guard DOM globals.
**Spec:** `docs/superpowers/specs/2026-06-07-header-wayfinding-design.md`
**Key facts (verified):** `useMe()` (`api/queries.ts:30`) → `UserView | null` = `{ email, id, role }`. `useLogout()` (`queries.ts:129`). `useVocabularies()` (`queries.ts:258`) → `VocabularyView[]` with `.key` (the display name). Current logout flow in `app-shell.tsx`: `logout.mutate(undefined, { onSuccess: () => navigate("/login", { replace: true }) })`. Base UI render-prop pattern: see `ui/alert-dialog.tsx` (namespace import, `data-slot`, `cn()`).
**File structure:**
- `web/src/components/ui/menu.tsx` (new) + `menu.stories.tsx` (new)
- `web/src/shell/breadcrumb-context.ts` (new), `breadcrumb-provider.tsx` (new), `use-breadcrumb.ts` (new), `breadcrumb.tsx` (new render component)
- `web/src/shell/user-menu.tsx` (new), `header-search.tsx` (new)
- Modify: `web/src/shell/app-shell.tsx`, `sidebar.tsx`, `auth/login-page.tsx`, the 9 page/detail components, `i18n/en.json`, `i18n/sv.json`, `shell/app-shell.test.tsx`, `auth/login-page.test.tsx`.
---
# Task 1: Render `app_name` for brand + login; remove dead `app.name` key
**Files:** `web/src/shell/sidebar.tsx`, `web/src/auth/login-page.tsx`, `web/src/i18n/en.json`, `web/src/i18n/sv.json`, `web/src/auth/login-page.test.tsx`.
- [ ] **Step 1: Sidebar brand.** In `web/src/shell/sidebar.tsx` add `import { useConfig } from "../config/config-context";`, get `const { app_name } = useConfig();` in the component, and change line ~76:
`{!collapsed && <span className="font-semibold">{t("app.name")}</span>}`
`{!collapsed && <span className="font-semibold">{app_name}</span>}`.
- [ ] **Step 2: Login.** In `web/src/auth/login-page.tsx`: add `import { useConfig } from "../config/config-context";`, `const { app_name } = useConfig();`. Change the `<h1>` (line ~38) to `{app_name}` and the title effect (line ~18) to `document.title = app_name;` with deps `[app_name]`. Remove the now-unused `t` for that purpose only if `t` is otherwise unused (check — login uses `t` for field labels/errors, so keep the `useTranslation` import).
- [ ] **Step 3: Remove the dead i18n key.** Delete the `"app": { "name": "..." }` entry from BOTH `web/src/i18n/en.json` and `web/src/i18n/sv.json` (grep first: `grep -rn 'app\.name\|"app"' web/src` — confirm no remaining `t("app.name")` after Steps 12). en/sv must stay in parity (remove from both).
- [ ] **Step 4: Update login test if needed.** Read `web/src/auth/login-page.test.tsx`. If it asserts the heading text via `t("app.name")` / "Collection", update it to the config default `"Collection Management System"` (the value `useConfig` returns in tests via `DEFAULTS`). Do NOT weaken; just match the new source.
- [ ] **Step 5: Verify (run vitest once for these files).**
`cd web && pnpm vitest run src/auth src/shell/app-shell.test.tsx && pnpm typecheck && pnpm lint`
Expected: PASS. The sidebar brand + login now show "Collection Management System" (config default) in tests.
- [ ] **Step 6: Commit**
```bash
git add web/src/shell/sidebar.tsx web/src/auth/login-page.tsx web/src/i18n/en.json web/src/i18n/sv.json web/src/auth/login-page.test.tsx
git commit -m "feat(web): render configured app_name for brand + login; drop hardcoded app.name (#54)"
```
---
# Task 2: `ui/menu.tsx` Base UI Menu wrapper + story (validate by running)
**Files:** `web/src/components/ui/menu.tsx` (new), `web/src/components/ui/menu.stories.tsx` (new).
- [ ] **Step 1: Read the reference** `web/src/components/ui/alert-dialog.tsx` for the exact house pattern (namespace import, `data-slot`, `cn()`, no semicolons, token classes). The Base UI Menu API is `import { Menu } from "@base-ui/react/menu"` then `Menu.Root`, `Menu.Trigger`, `Menu.Portal`, `Menu.Positioner`, `Menu.Popup`, `Menu.Item`, `Menu.Separator`. **This is novel — you MUST validate the exact part tree by running the story (Step 3).**
- [ ] **Step 2: Implement** `web/src/components/ui/menu.tsx` (no-semicolon style). Export: `Menu` (Root re-export with data-slot), `MenuTrigger`, `MenuContent` (composes Portal + Positioner + Popup), `MenuItem`, `MenuSeparator`. Skeleton (adapt class/props to what runs):
```tsx
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@/lib/utils"
function Menu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="menu" {...props} />
}
function MenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="menu-trigger" {...props} />
}
function MenuContent({
className,
sideOffset = 6,
align = "end",
...props
}: MenuPrimitive.Popup.Props & { sideOffset?: number; align?: MenuPrimitive.Positioner.Props["align"] }) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner sideOffset={sideOffset} align={align} className="z-50">
<MenuPrimitive.Popup
data-slot="menu-content"
className={cn(
"min-w-44 rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none",
"data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function MenuItem({ className, ...props }: MenuPrimitive.Item.Props) {
return (
<MenuPrimitive.Item
data-slot="menu-item"
className={cn(
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none",
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground",
className
)}
{...props}
/>
)
}
function MenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
export { Menu, MenuTrigger, MenuContent, MenuItem, MenuSeparator }
```
IMPORTANT: the exact prop names (`sideOffset`, `align`, `Popup` vs `Popup`+`Positioner` arrangement) MUST be confirmed against the installed `@base-ui/react` types — open `web/node_modules/@base-ui/react/menu/` or check via the editor/types and adjust. Do not guess; if a prop/part errors at typecheck or runtime, fix it to match the real API. No `data-[highlighted]` raw colors — `bg-accent`/`text-accent-foreground` are tokens (OK).
- [ ] **Step 3: Story** `web/src/components/ui/menu.stories.tsx` (single-quote, no-semicolon). Render a `Menu` with a `MenuTrigger` (a Button via `render` or as child) + `MenuContent` with two `MenuItem`s; a `play` test that opens the menu (click the trigger) and asserts an item is visible:
```tsx
import type { Meta, StoryObj } from '@storybook/react-vite'
import { expect } from 'storybook/test'
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from './menu'
import { Button } from './button'
const meta = {
component: Menu,
tags: ['ai-generated'],
} satisfies Meta<typeof Menu>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => (
<Menu>
<MenuTrigger render={<Button variant="ghost">Open</Button>} />
<MenuContent>
<MenuItem>First</MenuItem>
<MenuSeparator />
<MenuItem>Second</MenuItem>
</MenuContent>
</Menu>
),
play: async ({ canvas, userEvent }) => {
await userEvent.click(canvas.getByRole('button', { name: 'Open' }))
await expect(await canvas.findByText('First')).toBeInTheDocument()
},
}
```
If `MenuTrigger render={<Button/>}` isn't the right composition for Base UI Menu, use the pattern that works (e.g. `<MenuTrigger><Button/></MenuTrigger>` or `render` per the alert-dialog usage). The story passing IS the validation.
- [ ] **Step 4: Run the story-as-test + typecheck + lint.**
`cd web && pnpm vitest run src/components/ui/menu.stories.tsx && pnpm typecheck && pnpm lint`
Expected: PASS. If the menu doesn't open / portal isn't found, fix the part tree until the play test passes (this is the validate-by-running step). The portal renders to document.body — `findByText` on the canvas/body should find it; if the addon's `canvas` is scoped, query `within(document.body)` or use the screen — match how other portal-using stories (drawer/combobox/toast) assert.
- [ ] **Step 5: Commit**
```bash
git add web/src/components/ui/menu.tsx web/src/components/ui/menu.stories.tsx
git commit -m "feat(web): ui/menu Base UI dropdown wrapper + story (#54)"
```
---
# Task 3: Breadcrumb infrastructure + mount in header + wire objects-page
**Files:** `web/src/shell/breadcrumb-context.ts` (new), `breadcrumb-provider.tsx` (new), `use-breadcrumb.ts` (new), `breadcrumb.tsx` (new), `web/src/shell/app-shell.tsx` (modify), `web/src/objects/objects-page.tsx` (modify), `web/src/shell/breadcrumb.test.tsx` (new).
- [ ] **Step 1: Context** `web/src/shell/breadcrumb-context.ts`:
```ts
import { createContext, useContext } from "react";
export type BreadcrumbItem = { label: string; to?: string };
type BreadcrumbContextValue = {
trail: BreadcrumbItem[];
setTrail: (trail: BreadcrumbItem[]) => void;
};
export const BreadcrumbContext = createContext<BreadcrumbContextValue>({
trail: [],
setTrail: () => {},
});
export function useBreadcrumbTrail(): BreadcrumbItem[] {
return useContext(BreadcrumbContext).trail;
}
```
- [ ] **Step 2: Provider** `web/src/shell/breadcrumb-provider.tsx`:
```tsx
import { useState, type ReactNode } from "react";
import { BreadcrumbContext, type BreadcrumbItem } from "./breadcrumb-context";
export function BreadcrumbProvider({ children }: { children: ReactNode }) {
const [trail, setTrail] = useState<BreadcrumbItem[]>([]);
return (
<BreadcrumbContext.Provider value={{ trail, setTrail }}>
{children}
</BreadcrumbContext.Provider>
);
}
```
- [ ] **Step 3: Hook** `web/src/shell/use-breadcrumb.ts`:
```ts
import { useContext, useEffect } from "react";
import { BreadcrumbContext, type BreadcrumbItem } from "./breadcrumb-context";
export function useBreadcrumb(trail: BreadcrumbItem[]): void {
const { setTrail } = useContext(BreadcrumbContext);
const key = trail.map((i) => `${i.label}${i.to ?? ""}`).join("");
useEffect(() => {
setTrail(trail);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, setTrail]);
}
```
NOTE on the disable: the effect intentionally depends on the serialized `key` (stable) instead of the `trail` array identity. **Do NOT add `eslint-disable` if the linter doesn't require it** — first try `[key, setTrail]` with no comment; only if `react-hooks/exhaustive-deps` errors on the missing `trail` dep, prefer refactoring (e.g. build the trail inside the effect from primitive args) over disabling. If a clean form isn't possible, a single scoped disable on that line is acceptable here (the serialization is the correct dep). Use judgment; document the choice in the commit.
- [ ] **Step 4: Render component** `web/src/shell/breadcrumb.tsx`:
```tsx
import { Fragment } from "react";
import { Link } from "react-router-dom";
import { useBreadcrumbTrail } from "./breadcrumb-context";
export function Breadcrumb() {
const trail = useBreadcrumbTrail();
if (trail.length === 0) return <div className="min-w-0 flex-1" />;
return (
<nav aria-label="Breadcrumb" className="flex min-w-0 flex-1 items-center gap-1 text-sm">
{trail.map((item, i) => {
const last = i === trail.length - 1;
return (
<Fragment key={`${item.label}-${i}`}>
{i > 0 && <span className="text-muted-foreground">/</span>}
{item.to && !last ? (
<Link to={item.to} className="truncate text-muted-foreground hover:text-foreground">
{item.label}
</Link>
) : (
<span className={last ? "truncate text-foreground" : "truncate text-muted-foreground"}>
{item.label}
</span>
)}
</Fragment>
);
})}
</nav>
);
}
```
(The empty-trail branch renders the `flex-1` spacer so the right-side controls stay right-aligned.)
- [ ] **Step 5: Mount in app-shell.** In `web/src/shell/app-shell.tsx`: wrap the inner `<div className="flex flex-1 flex-col">…</div>` (header+main) — actually wrap the whole returned tree's header+main region — in `<BreadcrumbProvider>`. Simplest: wrap the `return (<div className="flex min-h-screen">…)` content's right column. Concretely, import `BreadcrumbProvider` and `Breadcrumb`, and render `<BreadcrumbProvider>` around the `<div className="flex flex-1 flex-col">` (so both header and `<Outlet/>` are inside it). Replace the header's leading `<div className="flex-1" />` with `<Breadcrumb />` (which itself provides the flex-1). Leave ThemeSwitch/LangSwitch/Sign out as-is for now (Task 5/6 handle the right side).
- [ ] **Step 6: Wire objects-page** (proof of the pipe). In `web/src/objects/objects-page.tsx` add `import { useBreadcrumb } from "../shell/use-breadcrumb";` and call `useBreadcrumb([{ label: t("nav.objects") }]);` near the top (alongside the existing `useDocumentTitle`).
- [ ] **Step 7: Test** `web/src/shell/breadcrumb.test.tsx` — render the provider + a setter component + the Breadcrumb, assert it renders the crumbs and a non-leaf links:
```tsx
import { expect, test } from "vitest";
import { screen } from "@testing-library/react";
import { renderApp } from "../test/render";
import { BreadcrumbProvider } from "./breadcrumb-provider";
import { Breadcrumb } from "./breadcrumb";
import { useBreadcrumb } from "./use-breadcrumb";
function Setter() {
useBreadcrumb([
{ label: "Objects", to: "/objects" },
{ label: "LM-0042" },
]);
return null;
}
test("renders the trail with a link on non-leaf crumbs", async () => {
renderApp(
<BreadcrumbProvider>
<Breadcrumb />
<Setter />
</BreadcrumbProvider>,
);
const link = await screen.findByRole("link", { name: "Objects" });
expect(link).toHaveAttribute("href", "/objects");
expect(screen.getByText("LM-0042")).toBeInTheDocument();
});
```
(`renderApp` provides the Router so `<Link>` works.)
- [ ] **Step 8: Verify (vitest once).**
`cd web && pnpm vitest run src/shell src/objects/objects-page.test.tsx && pnpm typecheck && pnpm lint`
Expected: PASS (breadcrumb test + existing shell/objects tests). The objects-page test from #57 still passes; optionally assert the header crumb there too.
- [ ] **Step 9: Commit**
```bash
git add web/src/shell/breadcrumb-context.ts web/src/shell/breadcrumb-provider.tsx web/src/shell/use-breadcrumb.ts web/src/shell/breadcrumb.tsx web/src/shell/app-shell.tsx web/src/objects/objects-page.tsx web/src/shell/breadcrumb.test.tsx
git commit -m "feat(web): page-driven breadcrumb context + header render + objects wiring (#54)"
```
---
# Task 4: Wire `useBreadcrumb` into the remaining routes
**Files (modify):** `web/src/objects/object-new-page.tsx`, `web/src/objects/object-detail.tsx`, `web/src/objects/object-edit-form.tsx`, `web/src/vocab/vocabularies-page.tsx`, `web/src/vocab/vocabulary-terms.tsx`, `web/src/authorities/authorities-page.tsx`, `web/src/fields/fields-page.tsx`, `web/src/search/search-page.tsx`.
For each: add `import { useBreadcrumb } from "../shell/use-breadcrumb";` (verify depth: all these dirs are one level under `src/`, so `../shell/...` is correct) and call it near the top (after `useTranslation`). Reuse existing i18n keys.
- [ ] **Step 1: object-new-page**`useBreadcrumb([{ label: t("nav.objects"), to: "/objects" }, { label: t("objects.new") }]);`
- [ ] **Step 2: object-detail** — in the inner `ObjectDetailLoaded({ object })` component (added in #57), add `useBreadcrumb([{ label: t("nav.objects"), to: "/objects" }, { label: object.object_number }]);` (it has `t` via `useTranslation` — add if missing). This covers `/objects/:id` AND `/search/:id` (reused).
- [ ] **Step 3: object-edit-form** — read the file; if it loads the object (has `object_number` + the `:id`), add `useBreadcrumb([{ label: t("nav.objects"), to: "/objects" }, { label: object.object_number, to: \`/objects/${id}\` }, { label: t("actions.edit") }]);` in the loaded branch (split like ObjectDetail if it early-returns before data — same rules-of-hooks care). If it does NOT have the object loaded (only the form), use `[{ label: t("nav.objects"), to: "/objects" }, { label: t("actions.edit") }]`. Choose based on what the component actually has; don't add a fetch just for this.
- [ ] **Step 4: vocabularies-page**`useBreadcrumb([{ label: t("nav.vocabularies") }]);`
- [ ] **Step 5: vocabulary-terms** — it has only `id` (UUID). Add the vocab name via the cached list:
```tsx
import { useVocabularies } from "../api/queries";
// inside, after const { id } = useParams():
const { data: vocabularies } = useVocabularies();
const vocabKey = vocabularies?.find((v) => v.id === id)?.key;
useBreadcrumb(
vocabKey
? [{ label: t("nav.vocabularies"), to: "/vocabularies" }, { label: vocabKey }]
: [{ label: t("nav.vocabularies"), to: "/vocabularies" }],
);
```
(`useVocabularies()` is cache-shared with the vocabularies list — no extra request. `.key` is the display name, per `vocabulary-list.tsx`.) Place the hook BEFORE the existing `if (!id) return null;` early return.
- [ ] **Step 6: authorities-page**`useBreadcrumb([{ label: t("nav.authorities") }]);` (place before the `isValidKind` early return, like `useDocumentTitle`).
- [ ] **Step 7: fields-page**`useBreadcrumb([{ label: t("nav.fields") }]);`
- [ ] **Step 8: search-page**`useBreadcrumb([{ label: t("nav.search") }]);`
- [ ] **Step 9: Integration test.** Add a test (in `breadcrumb.test.tsx` or `objects-page.test.tsx`) that rendering a nested route shows the breadcrumb in the header. Easiest reliable one: at `/objects/new`, the header shows "Objects" (link → /objects) and "New". Use the existing app-shell/objects render setup (the route must render inside `AppShell` so the header + provider are present). If wiring a full-route render is heavy, assert via the objects route that the header `<nav aria-label="Breadcrumb">` contains the section label. Do not weaken; pick a route that reliably mounts AppShell.
- [ ] **Step 10: Verify (vitest once).**
`cd web && pnpm vitest run src/objects src/vocab src/authorities src/fields src/search src/shell && pnpm typecheck && pnpm lint`
Expected: PASS. Existing tests unaffected (breadcrumb context default is a no-op when no provider; inside AppShell the provider is present).
- [ ] **Step 11: Commit**
```bash
git add web/src/objects/object-new-page.tsx web/src/objects/object-detail.tsx web/src/objects/object-edit-form.tsx web/src/vocab/vocabularies-page.tsx web/src/vocab/vocabulary-terms.tsx web/src/authorities/authorities-page.tsx web/src/fields/fields-page.tsx web/src/search/search-page.tsx web/src/shell/breadcrumb.test.tsx
git commit -m "feat(web): set breadcrumb trails on all AppShell routes (#54)"
```
---
# Task 5: `UserMenu` + `HeaderSearch` components
**Files:** `web/src/shell/user-menu.tsx` (new), `web/src/shell/header-search.tsx` (new), `web/src/i18n/en.json`, `web/src/i18n/sv.json`, plus tests `web/src/shell/user-menu.test.tsx` (new), `web/src/shell/header-search.test.tsx` (new).
- [ ] **Step 1: i18n** — add `"headerPlaceholder": "Search…"` to the `search` namespace in `en.json` and `"headerPlaceholder": "Sök…"` in `sv.json` (parity). (Confirm a `search` namespace exists; if not, add it in both.)
- [ ] **Step 2: UserMenu** `web/src/shell/user-menu.tsx`:
```tsx
import { CircleUser } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useLogout, useMe } from "../api/queries";
import { Button } from "@/components/ui/button";
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "@/components/ui/menu";
export function UserMenu() {
const { t } = useTranslation();
const navigate = useNavigate();
const { data: me } = useMe();
const logout = useLogout();
const onSignOut = () =>
logout.mutate(undefined, {
onSuccess: () => navigate("/login", { replace: true }),
});
if (!me) return null;
return (
<Menu>
<MenuTrigger
render={
<Button variant="ghost" size="sm" className="max-w-44">
<CircleUser className="h-4 w-4" aria-hidden />
<span className="truncate">{me.email}</span>
</Button>
}
/>
<MenuContent>
<div className="px-2 py-1.5">
<div className="truncate text-sm font-medium">{me.email}</div>
<div className="text-xs text-muted-foreground">{me.role}</div>
</div>
<MenuSeparator />
<MenuItem onClick={onSignOut}>{t("auth.signOut")}</MenuItem>
</MenuContent>
</Menu>
);
}
```
Adjust `MenuTrigger`/`render` to the form Task 2 validated. The `MenuItem` action prop may be `onClick` or Base UI's `onClick`/`render` — match the wrapper. Ensure clicking it triggers `onSignOut`.
- [ ] **Step 3: HeaderSearch** `web/src/shell/header-search.tsx`:
```tsx
import { Search } from "lucide-react";
import { useState, type FormEvent } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Input } from "@/components/ui/input";
export function HeaderSearch() {
const { t } = useTranslation();
const navigate = useNavigate();
const [q, setQ] = useState("");
const onSubmit = (e: FormEvent) => {
e.preventDefault();
const query = q.trim();
if (query) navigate(`/search?q=${encodeURIComponent(query)}`);
};
return (
<form onSubmit={onSubmit} className="hidden sm:block">
<div className="relative">
<Search className="pointer-events-none absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
<Input
type="search"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder={t("search.headerPlaceholder")}
aria-label={t("nav.search")}
className="w-48 pl-8 lg:w-64"
/>
</div>
</form>
);
}
```
- [ ] **Step 4: Tests.**
- `web/src/shell/user-menu.test.tsx`: render `<UserMenu/>` via `renderApp` with MSW returning a `me` user (reuse `web/src/test/handlers.ts`; if `/api/admin/me` isn't in handlers, add a handler or override per-test). Assert the email shows; open the menu; click Sign out → assert the logout POST fired (MSW) / navigation. Mirror how the existing `app-shell.test.tsx` tested sign-out. If asserting navigation is awkward, assert the logout request was made.
- `web/src/shell/header-search.test.tsx`: render `<HeaderSearch/>` via `renderApp`; type "amphora" + submit (Enter); assert navigation to `/search?q=amphora` (use a `MemoryRouter` location probe or render a small route tree that shows the location — mirror existing navigation tests; if none, render with a `*` route echoing `useLocation().search`).
- [ ] **Step 5: Verify (vitest once).**
`cd web && pnpm vitest run src/shell/user-menu.test.tsx src/shell/header-search.test.tsx && pnpm typecheck && pnpm lint`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add web/src/shell/user-menu.tsx web/src/shell/header-search.tsx web/src/shell/user-menu.test.tsx web/src/shell/header-search.test.tsx web/src/i18n/en.json web/src/i18n/sv.json
git commit -m "feat(web): UserMenu (email/role + sign out) + HeaderSearch components (#54)"
```
---
# Task 6: Header assembly + app-shell test + final gate
**Files:** `web/src/shell/app-shell.tsx`, `web/src/shell/app-shell.test.tsx`.
- [ ] **Step 1: Assemble the header.** In `web/src/shell/app-shell.tsx`:
- Import `HeaderSearch` and `UserMenu`.
- Remove the standalone Sign out `<Button>` and the now-unused `onSignOut`/`useLogout`/`navigate`/`t` (the logout flow now lives in `UserMenu`). Keep imports only if still used.
- Header becomes:
```tsx
<header className="flex items-center gap-4 border-b px-4 py-2">
<Breadcrumb />
<HeaderSearch />
<ThemeSwitch />
<LangSwitch />
<UserMenu />
</header>
```
(`<Breadcrumb />` provides the `flex-1`; if both Breadcrumb's flex-1 and a spacer fight, ensure exactly one flex-1 between left and right — Breadcrumb already has `flex-1`, so no extra spacer.) Keep `BreadcrumbProvider` wrapping header+main (from Task 3).
- [ ] **Step 2: Update `app-shell.test.tsx`.** The Sign out button moved into `UserMenu` (a menu). Update the existing sign-out test: it must now open the user menu first, then click Sign out. Ensure `useMe` resolves a user in the test (MSW handler for `/api/admin/me`). If the test renders `AppShell` directly, the header now needs `me` + breadcrumb provider (provider is inside AppShell, fine). Don't weaken; adapt to the menu interaction. Keep the language-switch + nav-links tests.
- [ ] **Step 3: FULL GATE (single test pass — run tests EXACTLY ONCE):**
```bash
cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size && pnpm check:colors
```
Expected: all green. **Report the `check:size` value** — adding Base UI Menu to the always-loaded shell may increase the largest chunk. If it EXCEEDS 250 KB gz, STOP and report to the controller (do not raise the budget yourself). If under, report the number.
- [ ] **Step 4: Codename + status:**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git grep -in 'biggus\|dickus' -- web/src; echo "codename-exit=$?"
git status --short
```
Expected: no codename matches.
- [ ] **Step 5: Manual smoke (recommended).** `pnpm dev`: header shows breadcrumb (left) that updates per route (Objects / New, Objects / {number}, Vocabularies / {key}); the user menu shows email/role + Sign out works; the search box navigates to /search?q=; brand + login show the configured app name; search hidden below sm.
- [ ] **Step 6: Commit**
```bash
git add web/src/shell/app-shell.tsx web/src/shell/app-shell.test.tsx
git commit -m "feat(web): assemble header — breadcrumb, search, user menu; remove standalone sign out (#54)"
```
---
## Self-Review (completed)
**Spec coverage:** app_name brand+login + dead-key removal (T1); ui/menu Base UI wrapper + validate-by-running (T2); breadcrumb context/provider/hook/render + header mount (T3) + all routes wired incl. object_number & vocab .key (T3/T4); UserMenu email/role/sign-out (T5) + HeaderSearch → /search?q= (T5); header assembly removing the standalone Sign out (T6); check:size reported/flagged (T6); tests for breadcrumb, menu story, user-menu, header-search, app-shell update; en/sv parity (one new key, one removed); no new dep. Acceptance criteria 15 mapped. ✓
**Placeholder scan:** the Base UI Menu part tree/props are "confirm against installed types + validate by running" — a deliberate validation step (the primitive is novel), not a TODO; concrete skeleton + reference file given. object-edit-form trail is conditional on what the component already has (explicit branch). No vague steps. ✓
**Type consistency:** `BreadcrumbItem = { label: string; to?: string }` defined in T3, used by the hook (T3), render (T3), and all page trails (T3/T4); `useBreadcrumb(trail)` signature consistent; `useMe()``{email, role}` used in UserMenu (T5); `useVocabularies().key` used in T4. Menu exports (`Menu/MenuTrigger/MenuContent/MenuItem/MenuSeparator`) defined in T2, consumed in T5. ✓
## Notes
- No new dependency (Base UI + lucide already present); one new i18n key (`search.headerPlaceholder`), one removed (`app.name`).
- The breadcrumb mirrors #57's page-driven title pattern — pages now call both `useDocumentTitle` and `useBreadcrumb`; a future consolidation into one `usePageMeta` is possible but out of scope.
- `check:size` is the one budget risk (Menu in the shell) — measured and flagged in T6, not silently bumped.
- Validate-by-running (T2/T4) is mandatory for the novel Base UI Menu, per the established repo pattern (combobox/drawer/tooltip/toast).