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

29 KiB
Raw Blame History

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

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):

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 MenuItems; a play test 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 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

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 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:

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

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-pageuseBreadcrumb([{ 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-pageuseBreadcrumb([{ 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-pageuseBreadcrumb([{ label: t("nav.authorities") }]); (place before the isValidKind early return, like useDocumentTitle).

  • Step 7: fields-pageuseBreadcrumb([{ label: t("nav.fields") }]);

  • Step 8: search-pageuseBreadcrumb([{ 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

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:

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/> 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

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:
<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):

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 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).