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

11 KiB

App Header Wayfinding — Design

Date: 2026-06-07 Status: Approved (brainstorming) — ready for implementation planning. Issue: #54.

Context

The <header> in web/src/shell/app-shell.tsx carries only a flex spacer + ThemeSwitch + LangSwitch + a Sign out button — no "where am I", no user identity, no search entry. Three further gaps: deep-linking to nested/create routes gives no header cue; the configured app_name (useConfig(), default "Collection Management System") is never shown — the sidebar brand (sidebar.tsx:76) and login heading (login-page.tsx:38) render the hardcoded t("app.name") = "Collection"; and global search requires a full route change via the sidebar.

Facts established: routes are JSX <Route> elements (no useMatches/handle), so a breadcrumb can't use react-router's match data — and the object routes carry a UUID :id, not the object_number the breadcrumb wants. useMe() (api/queries.ts:30) returns UserView | null = { email, id, role }. useLogout() (queries.ts:129) POSTs /api/admin/logout. Base UI ships a Menu primitive — import { Menu } from "@base-ui/react/menu" (namespace export) — no new dependency. #57 added useDocumentTitle(page) per page; this milestone adds a parallel page-driven breadcrumb. The full ⌘K command palette / in-place search modal is #33 (out of scope here).

Decisions (from brainstorming)

  1. Page-driven breadcrumb context (useBreadcrumb(trail)), parallel to useDocumentTitle — the header stays dumb; detail pages supply exact dynamic crumbs (object_number, vocab name).
  2. User-menu dropdown using a new ui/menu.tsx Base UI Menu wrapper; Sign out moves into it.
  3. Compact header search that navigates to /search?q=… (light entry; the palette is #33).
  4. Brand + login use useConfig().app_name; the hardcoded app.name i18n key is removed.

Components

1. app_name everywhere (kill "Collection")

  • web/src/shell/sidebar.tsx:76: t("app.name")useConfig().app_name (still hidden when the sidebar is collapsed).
  • web/src/auth/login-page.tsx: the <h1> (line 38) and the document.title effect (line 18, added in #57) → useConfig().app_name. Login is rendered inside ConfigProvider (mounted in main.tsx around everything), so useConfig() works there; if /api/config is auth-gated pre-login, it returns the default "Collection Management System" — still the correct product name, and better than "Collection".
  • Remove the now-unused app.name key from en.json and sv.json (grep confirms only the three usages above; all are migrated). useDocumentTitle already reads app_name from useConfig, not i18n, so nothing else depends on the key.

2. Breadcrumb (page-driven context)

  • web/src/shell/breadcrumb-context.tsexport type BreadcrumbItem = { label: string; to?: string } and a context { trail: BreadcrumbItem[]; setTrail: (t: BreadcrumbItem[]) => void } (default { trail: [], setTrail: () => {} }).
  • web/src/shell/breadcrumb-provider.tsx — holds useState<BreadcrumbItem[]>([]), provides { trail, setTrail }. Mounted in AppShell wrapping the header + main.
  • web/src/shell/use-breadcrumb.tsuseBreadcrumb(trail: BreadcrumbItem[]): in a useEffect calls setTrail(trail), keyed on a serialized trail string (trail.map(i => \${i.label}${i.to ?? ""}`).join("")`) to avoid re-running on every render from a fresh array literal. (No clear-on-unmount: every AppShell route sets its own trail, so the next page overwrites — avoids an empty-then-refill flash.)
  • Header rendering: a Breadcrumb element on the header's left renders the trail as crumb / crumb / crumb; non-terminal crumbs with a to are <Link>s (text-muted-foreground hover:text-foreground), the terminal crumb is text-foreground. Separator / (muted). Uses text-sm. Truncates with truncate/min-w-0 so a long object_number doesn't push the right side off. (No aria nav landmark needed beyond a simple <nav aria-label="Breadcrumb">.)

Per-page trails (reusing existing i18n keys; dynamic labels from already-loaded data):

Route / component Trail
objects-page [{label: t("nav.objects")}]
object-new-page [{label: t("nav.objects"), to: "/objects"}, {label: t("objects.new")}]
object-detail (ObjectDetailLoaded) [{label: t("nav.objects"), to: "/objects"}, {label: object.object_number}]
object-edit-form (loaded) [{nav.objects→/objects}, {object_number→/objects/:id}, {t("actions.edit")}]
vocabularies-page [{label: t("nav.vocabularies")}]
vocabulary-terms (loaded) [{nav.vocabularies→/vocabularies}, {label: <vocab name>}]
authorities-page [{label: t("nav.authorities")}]
fields-page [{label: t("nav.fields")}]
search-page [{label: t("nav.search")}]

/search/:id reuses ObjectDetail, so it shows the object's canonical Objects / {number} trail (acceptable — it identifies the record; refining to a search-relative trail is a later nicety).

3. web/src/components/ui/menu.tsx (new — Base UI Menu wrapper)

Wrap the Base UI Menu parts in the established ui/* style (data-slot, cn(), render prop where a part should be a Button). Minimum surface needed: Menu.RootMenuTrigger, and a MenuContent composing Menu.Portal + Menu.Positioner + Menu.Popup, plus MenuItem and MenuSeparator. Style the popup as a card (bg-popover text-popover-foreground border rounded-md shadow-md p-1), items as data-[highlighted] rows (mirror the combobox/alert-dialog token classes). Base UI Menu is novel in this repo → the exact part tree + props (render, positioner side/align, portal) must be validated by running (a story), as combobox/drawer/tooltip/toast were. A menu.stories.tsx renders a trigger + a few items and (play test) opens it and asserts an item.

4. web/src/shell/user-menu.tsx

  • const { data: me } = useMe(); and the useLogout() flow (moved out of the header bar).
  • Trigger: a Button variant="ghost" size="sm" with a lucide User/CircleUser icon + the email (truncated; icon-only below sm if needed). Opens MenuContent:
    • email (a non-interactive header row, label-caption or muted),
    • role (secondary muted text — raw me.role),
    • MenuSeparator,
    • MenuItem Sign out (t("auth.signOut")) → triggers logout + navigate to /login (the same logic currently in app-shell.tsx).
  • If me is null (shouldn't happen inside AppShell/RequireAuth), render nothing or just the trigger.

5. web/src/shell/header-search.tsx

  • A small <form> with an <Input> (lucide Search icon, placeholder t("search.headerPlaceholder")).
  • onSubmit: navigate("/search?q=" + encodeURIComponent(query.trim())) when non-empty; clears or keeps the field (keep). The search page already reads ?q= (search-panel.tsx), so it pre-fills + executes. Width compact (w-48 lg:w-64); hidden below sm (hidden sm:block) to keep the narrow header uncluttered (full responsive header = #58).
  • New i18n key search.headerPlaceholder (en: "Search…", sv: "Sök…") in both locales (parity).

6. Header assembly (app-shell.tsx)

<header className="flex items-center gap-4 border-b px-4 py-2">
  <Breadcrumb />            {/* left, truncates */}
  <div className="flex-1" />
  <HeaderSearch />
  <ThemeSwitch />
  <LangSwitch />
  <UserMenu />
</header>

The standalone Sign out <Button> is removed (now in UserMenu); onSignOut/logout logic moves to UserMenu. Wrap header+main in BreadcrumbProvider so both the pages (setters) and the header (reader) share it.

Data flow

Route mounts → page calls useBreadcrumb(trail) → provider state updates → header Breadcrumb renders it. UserMenu reads useMe() (cached ["me"] query). HeaderSearch submit → router navigate to /search?q=. Brand/login read useConfig().app_name.

Error handling / edges

  • useBreadcrumb effect keyed on serialized trail → no render loop, no stale array identity churn.
  • A page that forgets to set a trail would show the previous page's crumbs; all AppShell routes set one (acceptance check). Login is outside AppShell (no breadcrumb there).
  • Long object_number/email truncate (min-w-0 truncate) so the header never overflows.
  • Base UI Menu requires Portal + Positioner (like the other primitives) — validate by running.
  • me null inside AppShell is not expected (RequireAuth guards), but UserMenu guards it.

Testing

  • Breadcrumb: a page sets a trail → the header renders the crumbs; a non-leaf crumb is a working <Link> (click navigates). Test via renderApp at a nested route (e.g. /objects/new shows "Objects / New" with "Objects" linking to /objects).
  • ui/menu story (validate-by-running): open the menu, assert an item is visible.
  • UserMenu: renders the email from a mocked useMe; opening it shows Sign out; clicking Sign out triggers the logout request (MSW) and navigates to /login. (Mirror the existing app-shell signout expectations — move them here.)
  • HeaderSearch: typing a query + submit navigates to /search?q=<encoded> (assert the resulting route/?q=).
  • app_name: sidebar brand + login render useConfig().app_name (the default in tests).
  • app-shell.test.tsx: update — the Sign out button moved into UserMenu; keep/upgrade the signout assertion to go through the menu. Don't weaken.
  • Gate: pnpm typecheck && lint && test && build && check:size && check:colors; en/sv parity (one new key search.headerPlaceholder; app.name removed from both); no codename. check:size: Base UI Menu is added to the always-loaded shell — it may nudge the largest chunk; report the value (budget 250 KB gz; raise only if it genuinely exceeds, and flag to the user rather than silently bumping).

Acceptance criteria

  1. The header shows a route-driven breadcrumb on the left for every AppShell route (section for list pages; Section / New|Edit and Section / {object_number|vocab name} for nested), via a page-driven useBreadcrumb/context; non-leaf crumbs link.
  2. A user-menu dropdown on the right shows the signed-in email + role and a Sign out item (which logs out); the standalone Sign out button is gone. Built on a reusable ui/menu Base UI wrapper.
  3. A compact header search field navigates to /search?q=… (hidden below sm).
  4. The sidebar brand and the login heading + tab title render useConfig().app_name; the hardcoded app.name i18n key is removed.
  5. typecheck/lint/test/build/check:colors green; check:size reported (within budget or flagged); en/sv parity; no codename; no new npm dependency.

Out of scope → follow-ups

  • Full ⌘K command palette / in-place search-results modal (#33).
  • Broader responsive header behavior (#58) — this only hides the search field below sm.
  • User avatar images, a user/account settings page, role-name i18n mapping.
  • Refining /search/:id to a search-relative breadcrumb (currently shows the canonical object trail).