# 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.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`** — holds `useState<BreadcrumbItem[]>([])`, provides
  `{ trail, setTrail }`. Mounted in `AppShell` wrapping the header + main.
- **`web/src/shell/use-breadcrumb.ts`** — `useBreadcrumb(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.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 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`)
```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).
