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)
- Page-driven breadcrumb context (
useBreadcrumb(trail)), parallel touseDocumentTitle— the header stays dumb; detail pages supply exact dynamic crumbs (object_number, vocab name). - User-menu dropdown using a new
ui/menu.tsxBase UI Menu wrapper; Sign out moves into it. - Compact header search that navigates to
/search?q=…(light entry; the palette is #33). - Brand + login use
useConfig().app_name; the hardcodedapp.namei18n 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 thedocument.titleeffect (line 18, added in #57) →useConfig().app_name. Login is rendered insideConfigProvider(mounted inmain.tsxaround everything), souseConfig()works there; if/api/configis 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.namekey fromen.jsonandsv.json(grep confirms only the three usages above; all are migrated).useDocumentTitlealready readsapp_namefromuseConfig, not i18n, so nothing else depends on the key.
2. Breadcrumb (page-driven context)
web/src/shell/breadcrumb-context.ts—export type BreadcrumbItem = { label: string; to?: string }and a context{ trail: BreadcrumbItem[]; setTrail: (t: BreadcrumbItem[]) => void }(default{ trail: [], setTrail: () => {} }).web/src/shell/breadcrumb-provider.tsx— holdsuseState<BreadcrumbItem[]>([]), provides{ trail, setTrail }. Mounted inAppShellwrapping the header + main.web/src/shell/use-breadcrumb.ts—useBreadcrumb(trail: BreadcrumbItem[]): in auseEffectcallssetTrail(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
Breadcrumbelement on the header's left renders the trail ascrumb / crumb / crumb; non-terminal crumbs with atoare<Link>s (text-muted-foreground hover:text-foreground), the terminal crumb istext-foreground. Separator/(muted). Usestext-sm. Truncates withtruncate/min-w-0so a longobject_numberdoesn't push the right side off. (Noarianav 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.Root → MenuTrigger, 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 theuseLogout()flow (moved out of the header bar).- Trigger: a
Button variant="ghost" size="sm"with a lucideUser/CircleUsericon + the email (truncated; icon-only belowsmif needed). OpensMenuContent:- email (a non-interactive header row,
label-captionor muted), - role (secondary muted text — raw
me.role), MenuSeparator,MenuItemSign out (t("auth.signOut")) → triggers logout + navigate to/login(the same logic currently inapp-shell.tsx).
- email (a non-interactive header row,
- If
meis null (shouldn't happen insideAppShell/RequireAuth), render nothing or just the trigger.
5. web/src/shell/header-search.tsx
- A small
<form>with an<Input>(lucideSearchicon, placeholdert("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 belowsm(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
useBreadcrumbeffect 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.
menull inside AppShell is not expected (RequireAuth guards), butUserMenuguards it.
Testing
- Breadcrumb: a page sets a trail → the header renders the crumbs; a non-leaf crumb is a working
<Link>(click navigates). Test viarenderAppat a nested route (e.g./objects/newshows "Objects / New" with "Objects" linking to/objects). ui/menustory (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 keysearch.headerPlaceholder;app.nameremoved 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
- The header shows a route-driven breadcrumb on the left for every AppShell route (section for list
pages;
Section / New|EditandSection / {object_number|vocab name}for nested), via a page-drivenuseBreadcrumb/context; non-leaf crumbs link. - 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/menuBase UI wrapper. - A compact header search field navigates to
/search?q=…(hidden belowsm). - The sidebar brand and the login heading + tab title render
useConfig().app_name; the hardcodedapp.namei18n key is removed. typecheck/lint/test/build/check:colorsgreen;check:sizereported (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/:idto a search-relative breadcrumb (currently shows the canonical object trail).