29 KiB
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.tsxaddimport { useConfig } from "../config/config-context";, getconst { 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: addimport { useConfig } from "../config/config-context";,const { app_name } = useConfig();. Change the<h1>(line ~38) to{app_name}and the title effect (line ~18) todocument.title = app_name;with deps[app_name]. Remove the now-unusedtfor that purpose only iftis otherwise unused (check — login usestfor field labels/errors, so keep theuseTranslationimport). -
Step 3: Remove the dead i18n key. Delete the
"app": { "name": "..." }entry from BOTHweb/src/i18n/en.jsonandweb/src/i18n/sv.json(grep first:grep -rn 'app\.name\|"app"' web/src— confirm no remainingt("app.name")after Steps 1–2). 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 viat("app.name")/ "Collection", update it to the config default"Collection Management System"(the valueuseConfigreturns in tests viaDEFAULTS). 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 lintExpected: PASS. The sidebar brand + login now show "Collection Management System" (config default) in tests. -
Step 6: Commit
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.tsxfor the exact house pattern (namespace import,data-slot,cn(), no semicolons, token classes). The Base UI Menu API isimport { Menu } from "@base-ui/react/menu"thenMenu.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):
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 aMenuwith aMenuTrigger(a Button viarenderor as child) +MenuContentwith twoMenuItems; aplaytest that opens the menu (click the trigger) and asserts an item is visible:
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 lintExpected: 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 —findByTexton the canvas/body should find it; if the addon'scanvasis scoped, querywithin(document.body)or use the screen — match how other portal-using stories (drawer/combobox/toast) assert. -
Step 5: Commit
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:
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:
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:
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:
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 thereturn (<div className="flex min-h-screen">…)content's right column. Concretely, importBreadcrumbProviderandBreadcrumb, 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.tsxaddimport { useBreadcrumb } from "../shell/use-breadcrumb";and calluseBreadcrumb([{ label: t("nav.objects") }]);near the top (alongside the existinguseDocumentTitle). -
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:
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 lintExpected: 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
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), adduseBreadcrumb([{ label: t("nav.objects"), to: "/objects" }, { label: object.object_number }]);(it hastviauseTranslation— add if missing). This covers/objects/:idAND/search/:id(reused). -
Step 3: object-edit-form — read the file; if it loads the object (has
object_number+ the:id), adduseBreadcrumb([{ 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:
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 theisValidKindearly return, likeuseDocumentTitle). -
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.tsxorobjects-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 insideAppShellso 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 lintExpected: PASS. Existing tests unaffected (breadcrumb context default is a no-op when no provider; inside AppShell the provider is present). -
Step 11: Commit
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 thesearchnamespace inen.jsonand"headerPlaceholder": "Sök…"insv.json(parity). (Confirm asearchnamespace exists; if not, add it in both.) -
Step 2: UserMenu
web/src/shell/user-menu.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:
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/>viarenderAppwith MSW returning ameuser (reuseweb/src/test/handlers.ts; if/api/admin/meisn'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 existingapp-shell.test.tsxtested sign-out. If asserting navigation is awkward, assert the logout request was made.web/src/shell/header-search.test.tsx: render<HeaderSearch/>viarenderApp; type "amphora" + submit (Enter); assert navigation to/search?q=amphora(use aMemoryRouterlocation probe or render a small route tree that shows the location — mirror existing navigation tests; if none, render with a*route echoinguseLocation().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 lintExpected: PASS. -
Step 6: Commit
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
HeaderSearchandUserMenu. - Remove the standalone Sign out
<Button>and the now-unusedonSignOut/useLogout/navigate/t(the logout flow now lives inUserMenu). Keep imports only if still used. - Header becomes:
- Import
<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 intoUserMenu(a menu). Update the existing sign-out test: it must now open the user menu first, then click Sign out. EnsureuseMeresolves a user in the test (MSW handler for/api/admin/me). If the test rendersAppShelldirectly, the header now needsme+ 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):
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:
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
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 1–5 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
useDocumentTitleanduseBreadcrumb; a future consolidation into oneusePageMetais possible but out of scope. check:sizeis 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).