docs(plans): app header wayfinding — 6-task plan (#54)
This commit is contained in:
@@ -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 && <span className="font-semibold">{t("app.name")}</span>}` →
|
||||||
|
`{!collapsed && <span className="font-semibold">{app_name}</span>}`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Login.** In `web/src/auth/login-page.tsx`: add `import { useConfig } from "../config/config-context";`, `const { app_name } = useConfig();`. Change the `<h1>` (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 <MenuPrimitive.Root data-slot="menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
|
||||||
|
return <MenuPrimitive.Trigger data-slot="menu-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 6,
|
||||||
|
align = "end",
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.Popup.Props & { sideOffset?: number; align?: MenuPrimitive.Positioner.Props["align"] }) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Portal>
|
||||||
|
<MenuPrimitive.Positioner sideOffset={sideOffset} align={align} className="z-50">
|
||||||
|
<MenuPrimitive.Popup
|
||||||
|
data-slot="menu-content"
|
||||||
|
className={cn(
|
||||||
|
"min-w-44 rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none",
|
||||||
|
"data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</MenuPrimitive.Positioner>
|
||||||
|
</MenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuItem({ className, ...props }: MenuPrimitive.Item.Props) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Item
|
||||||
|
data-slot="menu-item"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none",
|
||||||
|
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Separator
|
||||||
|
data-slot="menu-separator"
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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<typeof Menu>
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
render: () => (
|
||||||
|
<Menu>
|
||||||
|
<MenuTrigger render={<Button variant="ghost">Open</Button>} />
|
||||||
|
<MenuContent>
|
||||||
|
<MenuItem>First</MenuItem>
|
||||||
|
<MenuSeparator />
|
||||||
|
<MenuItem>Second</MenuItem>
|
||||||
|
</MenuContent>
|
||||||
|
</Menu>
|
||||||
|
),
|
||||||
|
play: async ({ canvas, userEvent }) => {
|
||||||
|
await userEvent.click(canvas.getByRole('button', { name: 'Open' }))
|
||||||
|
await expect(await canvas.findByText('First')).toBeInTheDocument()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
If `MenuTrigger render={<Button/>}` isn't the right composition for Base UI Menu, use the pattern that works (e.g. `<MenuTrigger><Button/></MenuTrigger>` or `render` per the alert-dialog usage). The story passing IS the validation.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the story-as-test + typecheck + lint.**
|
||||||
|
`cd web && pnpm vitest run src/components/ui/menu.stories.tsx && pnpm typecheck && pnpm lint`
|
||||||
|
Expected: PASS. If the menu doesn't open / portal isn't found, fix the part tree until the play test passes (this is the validate-by-running step). The portal renders to document.body — `findByText` on the canvas/body should find it; if the addon's `canvas` is scoped, query `within(document.body)` or use the screen — match how other portal-using stories (drawer/combobox/toast) assert.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
```bash
|
||||||
|
git add web/src/components/ui/menu.tsx web/src/components/ui/menu.stories.tsx
|
||||||
|
git commit -m "feat(web): ui/menu Base UI dropdown wrapper + story (#54)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task 3: Breadcrumb infrastructure + mount in header + wire objects-page
|
||||||
|
|
||||||
|
**Files:** `web/src/shell/breadcrumb-context.ts` (new), `breadcrumb-provider.tsx` (new), `use-breadcrumb.ts` (new), `breadcrumb.tsx` (new), `web/src/shell/app-shell.tsx` (modify), `web/src/objects/objects-page.tsx` (modify), `web/src/shell/breadcrumb.test.tsx` (new).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Context** `web/src/shell/breadcrumb-context.ts`:
|
||||||
|
```ts
|
||||||
|
import { createContext, useContext } from "react";
|
||||||
|
|
||||||
|
export type BreadcrumbItem = { label: string; to?: string };
|
||||||
|
|
||||||
|
type BreadcrumbContextValue = {
|
||||||
|
trail: BreadcrumbItem[];
|
||||||
|
setTrail: (trail: BreadcrumbItem[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BreadcrumbContext = createContext<BreadcrumbContextValue>({
|
||||||
|
trail: [],
|
||||||
|
setTrail: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useBreadcrumbTrail(): BreadcrumbItem[] {
|
||||||
|
return useContext(BreadcrumbContext).trail;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Provider** `web/src/shell/breadcrumb-provider.tsx`:
|
||||||
|
```tsx
|
||||||
|
import { useState, type ReactNode } from "react";
|
||||||
|
|
||||||
|
import { BreadcrumbContext, type BreadcrumbItem } from "./breadcrumb-context";
|
||||||
|
|
||||||
|
export function BreadcrumbProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [trail, setTrail] = useState<BreadcrumbItem[]>([]);
|
||||||
|
return (
|
||||||
|
<BreadcrumbContext.Provider value={{ trail, setTrail }}>
|
||||||
|
{children}
|
||||||
|
</BreadcrumbContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Hook** `web/src/shell/use-breadcrumb.ts`:
|
||||||
|
```ts
|
||||||
|
import { useContext, useEffect } from "react";
|
||||||
|
|
||||||
|
import { BreadcrumbContext, type BreadcrumbItem } from "./breadcrumb-context";
|
||||||
|
|
||||||
|
export function useBreadcrumb(trail: BreadcrumbItem[]): void {
|
||||||
|
const { setTrail } = useContext(BreadcrumbContext);
|
||||||
|
const key = trail.map((i) => `${i.label} | ||||||