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

170 lines
11 KiB
Markdown

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