From e4badbdefcc5aeb33e967efca32854ab9f918a66 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Sun, 7 Jun 2026 18:58:04 +0200 Subject: [PATCH] =?UTF-8?q?docs(plans):=20app=20header=20wayfinding=20?= =?UTF-8?q?=E2=80=94=206-task=20plan=20(#54)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-06-07-header-wayfinding.md | 521 ++++++++++++++++++ 1 file changed, 521 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-07-header-wayfinding.md diff --git a/docs/superpowers/plans/2026-06-07-header-wayfinding.md b/docs/superpowers/plans/2026-06-07-header-wayfinding.md new file mode 100644 index 0000000..ef94858 --- /dev/null +++ b/docs/superpowers/plans/2026-06-07-header-wayfinding.md @@ -0,0 +1,521 @@ +# 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 && {t("app.name")}}` → + `{!collapsed && {app_name}}`. + +- [ ] **Step 2: Login.** In `web/src/auth/login-page.tsx`: add `import { useConfig } from "../config/config-context";`, `const { app_name } = useConfig();`. Change the `

` (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 1–2). 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** +```bash +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): +```tsx +import { Menu as MenuPrimitive } from "@base-ui/react/menu" + +import { cn } from "@/lib/utils" + +function Menu({ ...props }: MenuPrimitive.Root.Props) { + return +} + +function MenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) { + return +} + +function MenuContent({ + className, + sideOffset = 6, + align = "end", + ...props +}: MenuPrimitive.Popup.Props & { sideOffset?: number; align?: MenuPrimitive.Positioner.Props["align"] }) { + return ( + + + + + + ) +} + +function MenuItem({ className, ...props }: MenuPrimitive.Item.Props) { + return ( + + ) +} + +function MenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props) { + return ( + + ) +} + +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 `MenuItem`s; a `play` test that opens the menu (click the trigger) and asserts an item is visible: +```tsx +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 + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => ( + + Open} /> + + First + + Second + + + ), + play: async ({ canvas, userEvent }) => { + await userEvent.click(canvas.getByRole('button', { name: 'Open' })) + await expect(await canvas.findByText('First')).toBeInTheDocument() + }, +} +``` +If `MenuTrigger render={ + } + /> + +
+
{me.email}
+
{me.role}
+
+ + {t("auth.signOut")} +
+ + ); +} +``` +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`: +```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 ( +
+
+ + setQ(e.target.value)} + placeholder={t("search.headerPlaceholder")} + aria-label={t("nav.search")} + className="w-48 pl-8 lg:w-64" + /> +
+
+ ); +} +``` + +- [ ] **Step 4: Tests.** + - `web/src/shell/user-menu.test.tsx`: render `` 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 `` 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** +```bash +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 `