diff --git a/docs/superpowers/plans/2026-06-07-typography-page-titles.md b/docs/superpowers/plans/2026-06-07-typography-page-titles.md new file mode 100644 index 0000000..72320a5 --- /dev/null +++ b/docs/superpowers/plans/2026-06-07-typography-page-titles.md @@ -0,0 +1,374 @@ +# Typography Hierarchy + Page `

` + Per-Route `document.title` 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:** Give every AppShell route a consistent semantic page `

` and a distinct browser-tab title (`"{Page} | {AppName}"`), via a small `PageTitle` component and a `useDocumentTitle` hook, and fix the one misused `

` caption. + +**Architecture:** A presentational `PageTitle` (`ui/page-title.tsx`) renders the styled `

`. A `useDocumentTitle(page)` hook (`lib/`) composes `"{page} | {app_name}"` (app_name from `useConfig`), sets `document.title`, and restores the prior title on unmount — which lets a master-detail detail pane override the tab to the object's `object_number` and revert on close. Pages reuse existing i18n keys; no new strings, no new dependency. + +**Tech Stack:** React 19 + TS + pnpm, Tailwind v4, react-i18next, react-router 7, Vitest + RTL + MSW + Storybook. Test runner: `pnpm test` (single pass). + +**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; no codename; reuse existing i18n keys (en/sv already in parity); source double-quote/semicolon, stories single-quote/no-semicolon; token classes only; **do not restructure layout/columns — only add the heading element + title hook**; guard `document` for jsdom. + +**Spec:** `docs/superpowers/specs/2026-06-07-typography-page-titles-design.md` + +**File structure:** +- `web/src/components/ui/page-title.tsx` (new) — `` h1. +- `web/src/components/ui/page-title.stories.tsx` (new) — story. +- `web/src/lib/use-document-title.ts` (new) — the hook. +- `web/src/lib/use-document-title.test.tsx` (new) — hook test. +- Modify pages: `web/src/objects/objects-page.tsx`, `web/src/objects/object-new-page.tsx`, + `web/src/vocab/vocabularies-page.tsx`, `web/src/authorities/authorities-page.tsx`, + `web/src/fields/fields-page.tsx`, `web/src/search/search-page.tsx`. +- Modify `web/src/objects/object-detail.tsx` (detail title override). +- Modify `web/src/vocab/vocabulary-terms.tsx` (h3→div caption fix). +- Modify `web/src/auth/login-page.tsx` (document.title = app.name). + +> NOTE: exact page file paths — verify with `git ls-files web/src | grep -E 'objects-page|object-new|vocabularies-page|authorities-page|fields-page|search-page|object-detail|vocabulary-terms|login-page'` before editing; the directory names above are from exploration but confirm. + +--- + +# Task 1: `PageTitle` component + story + +**Files:** +- Create: `web/src/components/ui/page-title.tsx` +- Create: `web/src/components/ui/page-title.stories.tsx` + +- [ ] **Step 1: Implement** `web/src/components/ui/page-title.tsx`: + +```tsx +import type { ComponentProps } from "react"; + +import { cn } from "@/lib/utils"; + +export function PageTitle({ className, ...props }: ComponentProps<"h1">) { + return ( +

+ ); +} +``` + +Confirm the `cn` import path matches the other `ui/*` files (open `web/src/components/ui/button.tsx` +and copy its exact `cn` import — expected `@/lib/utils`). + +- [ ] **Step 2: Write the story** `web/src/components/ui/page-title.stories.tsx`: + +```tsx +import type { Meta, StoryObj } from '@storybook/react-vite' +import { expect } from 'storybook/test' + +import { PageTitle } from './page-title' + +const meta = { + component: PageTitle, + args: { children: 'Objects' }, + tags: ['ai-generated'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + play: async ({ canvas }) => { + await expect(canvas.getByRole('heading', { level: 1, name: 'Objects' })).toBeInTheDocument() + }, +} +``` + +(Match the house story style — single quotes, no semicolons — as in `web/src/components/label-editor.stories.tsx`. Adjust the `Meta` import if that file imports it differently.) + +- [ ] **Step 3: Run the story-as-test + typecheck + lint** + +Run: `cd web && pnpm vitest run src/components/ui/page-title.stories.tsx && pnpm typecheck && pnpm lint` +Expected: PASS, clean. + +- [ ] **Step 4: Commit** + +```bash +git add web/src/components/ui/page-title.tsx web/src/components/ui/page-title.stories.tsx +git commit -m "feat(web): PageTitle h1 component + story (#57)" +``` + +--- + +# Task 2: `useDocumentTitle` hook + test + +**Files:** +- Create: `web/src/lib/use-document-title.ts` +- Create: `web/src/lib/use-document-title.test.tsx` + +- [ ] **Step 1: Confirm the config hook.** Open `web/src/config/config-context.ts` and confirm the + exported hook name and that it returns `app_name` (expected `useConfig()` → `{ app_name, ... }`). + Use the exact import path/name in the hook below. + +- [ ] **Step 2: Write the failing test** `web/src/lib/use-document-title.test.tsx`: + +```tsx +import { afterEach, expect, test } from "vitest"; +import { render } from "@testing-library/react"; +import "../i18n"; +import { ConfigProvider } from "../config/config-provider"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useDocumentTitle } from "./use-document-title"; + +function Titled({ page }: { page: string }) { + useDocumentTitle(page); + return null; +} + +function wrap(ui: React.ReactElement) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + {ui} + , + ); +} + +afterEach(() => { + document.title = ""; +}); + +test("sets document.title to '{page} | {app_name}'", () => { + wrap(); + expect(document.title).toMatch(/^Objects \| .+/); +}); + +test("restores the previous title on unmount", () => { + document.title = "Prev"; + const { unmount } = wrap(); + expect(document.title).toMatch(/^Objects \| /); + unmount(); + expect(document.title).toBe("Prev"); +}); +``` + +NOTE: this assumes `ConfigProvider` supplies a default `app_name` synchronously (the spec says +`useConfig` defaults to `"Collection Management System"` before `/api/config` resolves). If +`ConfigProvider` needs MSW for `/api/config`, instead import `handlers` from `../test/handlers` and +set up an MSW server in the test (mirror an existing test that uses ConfigProvider). If a simpler +existing wrapper exists (check `web/src/test/render.tsx` — does `renderApp` include ConfigProvider?), +prefer that. Do NOT weaken the assertions; adapt the wrapper to provide config. + +- [ ] **Step 3: Run to verify it fails** + +Run: `cd web && pnpm vitest run src/lib/use-document-title.test.tsx` +Expected: FAIL — cannot import `useDocumentTitle`. + +- [ ] **Step 4: Implement** `web/src/lib/use-document-title.ts`: + +```ts +import { useEffect } from "react"; + +import { useConfig } from "../config/config-context"; + +export function useDocumentTitle(page: string): void { + const { app_name } = useConfig(); + useEffect(() => { + if (typeof document === "undefined") return; + const previous = document.title; + document.title = `${page} | ${app_name}`; + return () => { + document.title = previous; + }; + }, [page, app_name]); +} +``` + +(Adjust the `useConfig` import path/name to match what Step 1 found.) + +- [ ] **Step 5: Run to verify it passes** + +Run: `cd web && pnpm vitest run src/lib/use-document-title.test.tsx` +Expected: PASS (2 tests). + +- [ ] **Step 6: Commit** + +```bash +git add web/src/lib/use-document-title.ts web/src/lib/use-document-title.test.tsx +git commit -m "feat(web): useDocumentTitle hook (restores prior title on unmount) (#57)" +``` + +--- + +# Task 3: Wire `` + `useDocumentTitle` into the list/form pages + +**Files (modify):** `web/src/objects/objects-page.tsx`, `web/src/objects/object-new-page.tsx`, +`web/src/vocab/vocabularies-page.tsx`, `web/src/authorities/authorities-page.tsx`, +`web/src/fields/fields-page.tsx`, `web/src/search/search-page.tsx`. + +For EACH page: read it first, add the imports +`import { PageTitle } from "@/components/ui/page-title";` (match the file's import style) and +`import { useDocumentTitle } from "../lib/use-document-title";` (verify the relative depth), call the +hook near the top of the component, and render `` at the top of the page's content. Use the +i18n key from the table. **Do not restructure existing layout/columns** — only add the heading +element (and, if the page already has a top action row, place `` on its left). + +| File | i18n key | Notes | +|---|---|---| +| `objects-page.tsx` | `nav.objects` | Page already has a toolbar (filter, New button, pagination). Put `` at the top-left of that toolbar row, or in a small header row above the table. | +| `object-new-page.tsx` | `objects.new` | Form page; `` above the form. | +| `vocabularies-page.tsx` | `nav.vocabularies` | Two-column; `` above the columns (full width). | +| `authorities-page.tsx` | `nav.authorities` | Tabbed; `` above the tabs. | +| `fields-page.tsx` | `fields.title` | Two-column; `` above the columns. | +| `search-page.tsx` | `nav.search` | Two-column; `` above the columns. | + +- [ ] **Step 1: objects-page.tsx** — add imports, `const { t } = useTranslation()` (it likely already + has `t`), `useDocumentTitle(t("nav.objects"))`, and render `{t("nav.objects")}` + at the top of the content. Keep all existing markup. + +- [ ] **Step 2: object-new-page.tsx** — same pattern with `objects.new`. +- [ ] **Step 3: vocabularies-page.tsx** — same with `nav.vocabularies`. +- [ ] **Step 4: authorities-page.tsx** — same with `nav.authorities`. +- [ ] **Step 5: fields-page.tsx** — same with `fields.title`. +- [ ] **Step 6: search-page.tsx** — same with `nav.search`. + +- [ ] **Step 7: Add/extend a page test.** In the existing test for objects (or create + `web/src/objects/objects-page.test.tsx` if none — check first), assert the `

` and title: + +```tsx +test("renders the page heading and sets the document title", async () => { + renderApp(/* the objects page route, mirroring the existing objects test setup */); + expect(await screen.findByRole("heading", { level: 1, name: /objects/i })).toBeInTheDocument(); + await waitFor(() => expect(document.title).toMatch(/objects \| /i)); +}); +``` + +If an objects page/integration test already exists, ADD this assertion there using the same render +setup rather than duplicating the harness. Do not weaken existing assertions. + +- [ ] **Step 8: Run the affected tests + typecheck + lint** + +Run: `cd web && pnpm vitest run src/objects src/vocab src/authorities src/fields src/search && pnpm typecheck && pnpm lint` +Expected: PASS (existing tests unaffected by the added heading; the new assertion passes). If any +existing test breaks because a heading query is now ambiguous, investigate — the page `

` text +should be distinct from row/cell content; do NOT weaken the test. + +- [ ] **Step 9: Commit** + +```bash +git add web/src/objects/objects-page.tsx web/src/objects/object-new-page.tsx web/src/vocab/vocabularies-page.tsx web/src/authorities/authorities-page.tsx web/src/fields/fields-page.tsx web/src/search/search-page.tsx web/src/objects/*.test.tsx +git commit -m "feat(web): page

+ document.title on list/form routes (#57)" +``` + +--- + +# Task 4: Detail-pane title override, caption fix, login title + final gate + +**Files (modify):** `web/src/objects/object-detail.tsx`, `web/src/vocab/vocabulary-terms.tsx`, +`web/src/auth/login-page.tsx`. + +- [ ] **Step 1: Object detail title override.** In `web/src/objects/object-detail.tsx`, after the + object has loaded, call `useDocumentTitle(object.object_number)`. The hook must receive a real value + — only call it once `object` is loaded. Because hooks can't be conditional, call it unconditionally + with a guarded value, e.g.: + +```tsx +import { useDocumentTitle } from "../lib/use-document-title"; +// ... inside the component, AFTER object data is available: +useDocumentTitle(object?.object_number ?? ""); +``` + + But setting `" | App"` (empty page) while loading is undesirable. Prefer: keep the hook call + unconditional but pass the object_number only when loaded, and make the component not render/return + early before the data is present (check the existing structure — `object-detail.tsx` likely already + early-returns a loading/skeleton state). If it early-returns BEFORE the hook, that violates rules-of- + hooks. Resolve by either: (a) calling `useDocumentTitle(object_number)` only in the loaded branch by + splitting the component into an outer (fetch + loading) and an inner `ObjectDetailLoaded({ object })` + that calls the hook — RECOMMENDED; or (b) guarding inside the hook usage so loading sets nothing. + Choose (a): create an inner component that receives the loaded `object` and calls + `useDocumentTitle(object.object_number)`; the outer handles loading. Keep `object_name` as the + existing `

`. + + Verify `object_number` is the right field (read the component / the `AdminObjectView`/object type). + +- [ ] **Step 2: Caption fix.** In `web/src/vocab/vocabulary-terms.tsx` around line 52, change the + `

` to `
` (same + className/content; only the element changes). Confirm there is no CSS/test depending on the `h3`. + +- [ ] **Step 3: Login title.** In `web/src/auth/login-page.tsx`, add a small effect: + +```tsx +import { useEffect } from "react"; +// ... inside the component (t from useTranslation already present): +useEffect(() => { + document.title = t("app.name"); +}, [t]); +``` + + (Do not use `useDocumentTitle` here — login is pre-auth/standalone.) + +- [ ] **Step 4: Detail-override test.** Add (or extend an existing object-detail test) asserting the + document title reflects the object on the detail route and reverts on unmount. Mirror the existing + object-detail test's render/MSW setup (reuse `web/src/test/handlers.ts` + fixtures): + +```tsx +test("object detail sets the tab title to the object number and reverts", async () => { + document.title = "Base"; + const { unmount } = renderApp(/* object detail route, per the existing detail test */); + await waitFor(() => expect(document.title).toMatch(/ \| /)); + unmount(); + expect(document.title).toBe("Base"); +}); +``` + + Use the actual `object_number` from the existing object fixture (read `web/src/test/fixtures.ts` — + the `amphora` fixture). If reverting-on-unmount is awkward to assert through the full route, assert + the override (title contains the object_number) at minimum; do not weaken below that. + +- [ ] **Step 5: FULL GATE (single test pass — run tests exactly ONCE):** + +Run: +```bash +cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size && pnpm check:colors +``` +Expected: all green. Report test totals, largest chunk, check:colors line. + +- [ ] **Step 6: Codename + status:** + +```bash +cd /Users/olsson/Laboratory/biggus-dickus +git grep -in 'biggus\|dickus' -- web/src; echo "codename-exit=$?" +git status --short +``` +Expected: no codename matches. + +- [ ] **Step 7: Manual smoke (recommended).** `pnpm dev`: each route shows a page `

`; the tab title + reads "{Page} | {AppName}"; opening an object changes the tab to the object number and closing it + reverts; exactly one `

` per page. + +- [ ] **Step 8: Commit** + +```bash +git add web/src/objects/object-detail.tsx web/src/vocab/vocabulary-terms.tsx web/src/auth/login-page.tsx web/src/objects/*.test.tsx +git commit -m "feat(web): object-detail tab title, caption element fix, login title (#57)" +``` + +--- + +## Self-Review (completed) + +**Spec coverage:** PageTitle h1 component (T1); useDocumentTitle hook with restore-on-unmount (T2); +per-route h1 + title on objects/object-new/vocabularies/authorities/fields/search reusing existing +keys (T3); detail-pane `object_number` override + revert (T4 S1); h3→div caption fix (T4 S2); login +title (T4 S3); one h1 per page preserved (list owns h1, detail keeps h2 — T3/T4); tests for component, +hook, page, and override (T1/T2/T3/T4); gate + no codename + no new dep + no new strings (T4). All +acceptance criteria 1–6 mapped. ✓ + +**Placeholder scan:** the only non-literal spots are render-harness reuse ("mirror the existing test +setup") and the object fixture's `object_number` ("read fixtures.ts") — these are deliberate "match +the existing pattern" instructions with concrete files named, not TODOs. The rules-of-hooks resolution +for object-detail (split into inner loaded component) is spelled out. ✓ + +**Type consistency:** `PageTitle` is `ComponentProps<"h1">`; `useDocumentTitle(page: string)` defined +in T2 and called with `t(key)` (string) in T3 and `object.object_number` (string) in T4; +`useConfig().app_name` is the title's app-name source throughout. ✓ + +## Notes +- No new dependency, no new i18n strings (all keys exist in en/sv). +- The restore-on-unmount in `useDocumentTitle` is load-bearing for the master-detail override — keep it. +- Verify exact page file paths first (the NOTE under File structure); adjust import depths accordingly. diff --git a/docs/superpowers/specs/2026-06-07-typography-page-titles-design.md b/docs/superpowers/specs/2026-06-07-typography-page-titles-design.md new file mode 100644 index 0000000..9b13a26 --- /dev/null +++ b/docs/superpowers/specs/2026-06-07-typography-page-titles-design.md @@ -0,0 +1,167 @@ +# Typography Hierarchy + Page `

` + Per-Route `document.title` — Design + +**Date:** 2026-06-07 +**Status:** Approved (brainstorming) — ready for implementation planning. +**Issue:** #57. + +## Context + +The app has a flat type scale (almost everything `text-sm`/`text-xs`) and only 3 ad-hoc headings: +`login-page.tsx` `

`, `object-detail.tsx` `

` (object name), and +`vocabulary-terms.tsx:52` an `

` **misused as a caption**. The list routes +(objects, vocabularies, authorities, fields, search) have **no page `

`** and no visible title. +Separately, `web/index.html` hardcodes `Collection` and nothing ever sets +`document.title` — every tab/bookmark reads "Collection", so a curator with many object tabs can't +tell them apart. + +All authenticated routes render inside `AppShell` (login is standalone). The header has no +title/breadcrumb slot (that's #54 — out of scope here). `.label-caption` exists (from #49). +`app_name` comes from `useConfig()` (`ConfigView.app_name`). i18n already has `nav.{objects, +vocabularies,authorities,fields,search}` plus `objects.title`/`fields.title`/`objects.new`. + +### Decisions (from brainstorming) +1. **`PageTitle` component** (`ui/page-title.tsx`) rendering a semantic `

` with consistent + classes — over utility classes or inline copy-paste. No `SectionTitle` (YAGNI; one h2 exists). +2. **Tab title format `"{Page} | {AppName}"`** — page-first so truncated tabs stay distinguishable. +3. Reuse existing i18n keys for page titles — no new strings. +4. Master-detail: the list page owns the single `

`; the detail pane overrides `document.title` + to the object identifier while mounted. + +## Type scale + +| Level | Element | Classes | +|---|---|---| +| Page title | `

` | `text-2xl font-semibold tracking-tight` | +| Section title | `

` | `text-xl font-semibold` (object-detail `object_name`, already this) | +| Body | — | `text-sm` (unchanged) | +| Caption | — | `.label-caption` (exists) | + +Only the page-title level is new; the rest already exist in the codebase. No global CSS scale change +beyond the `PageTitle` component. + +## Components + +### `web/src/components/ui/page-title.tsx` (new) +Mirrors the `ui/*` style (`data-slot`, `cn()`): +```tsx +import type { ComponentProps } from "react"; +import { cn } from "@/lib/utils"; + +export function PageTitle({ className, ...props }: ComponentProps<"h1">) { + return ( +

+ ); +} +``` +A Storybook story renders it with sample text and asserts the `

` role/level. + +### `web/src/lib/use-document-title.ts` (new) +```ts +import { useEffect } from "react"; +import { useConfig } from "../config/config-context"; + +export function useDocumentTitle(page: string): void { + const { app_name } = useConfig(); + useEffect(() => { + if (typeof document === "undefined") return; + const previous = document.title; + document.title = `${page} | ${app_name}`; + return () => { + document.title = previous; + }; + }, [page, app_name]); +} +``` +- Sets `"{page} | {app_name}"`; re-runs when `page` or `app_name` changes (so it updates when the + async config resolves, or when a detail pane swaps the `page` value). +- **Restores the prior title on cleanup.** This is what makes the master-detail override revert: the + detail pane captures the list page's title (`"Objects | …"`), sets the object title, and restores + `"Objects | …"` when it unmounts. +- `useConfig()` exposes `app_name` (defaults to `"Collection Management System"` until `/api/config` + resolves); the dep on `app_name` means the title corrects itself once config loads. + +## Per-route wiring + +Each `AppShell` page renders one `` and calls `useDocumentTitle` (reusing existing keys): + +| Route | Component | `

` / title key | +|---|---|---| +| `/objects` | `ObjectsPage` | `nav.objects` | +| `/objects/new` | `ObjectNewPage` | `objects.new` | +| `/vocabularies` | `VocabulariesPage` | `nav.vocabularies` | +| `/authorities/:kind` | `AuthoritiesPage` | `nav.authorities` | +| `/fields` | `FieldsPage` | `fields.title` | +| `/search` | `SearchPage` | `nav.search` | + +Placement: the `` goes at the top of each page's content region (above the table/columns), +in a consistent wrapper (e.g. a small header row with existing actions). **Only color/typography + +the added heading element — do not restructure the existing layout/columns.** Where a page already +has a top action row (e.g. "New object" button), put the `` on the left of that row. + +### Master-detail `document.title` override +- `/objects/:id`: `ObjectsPage` still renders the visible `

` ("Objects") and sets the base title. + `ObjectDetail` (right pane) keeps `object_name` as its `

` and calls + `useDocumentTitle(object.object_number)` once the object is loaded — overriding the tab to e.g. + `"1024.1 | Collection"`, reverting to `"Objects | Collection"` on unmount (hook restore). +- `/search/:id`: `ObjectDetail` is reused in `SearchPage`'s pane — same override over the "Search" + base, automatically. +- Result: exactly **one `

` per page** (a11y), while each open object tab is distinguishable. +- Edge: `ObjectDetail` must only call the hook with a real value once loaded (guard the loading + state — don't set `"undefined | …"`). While loading, leave the base title. + +### Semantic caption fix +`web/src/vocabulary/vocabulary-terms.tsx:52`: `

` → +`
` (it's a caption, not a section heading). No style change +(class identical), just the element. + +### Login (standalone) +`login-page.tsx` keeps its `

` (app name) and sets `document.title = t("app.name")` via a small +inline `useEffect` (deps `[t]`) — **not** `useDocumentTitle`, since `/api/config` may be +unauthenticated pre-login and `useConfig`/`ConfigProvider` may not apply there. This is deterministic +(no `"{page} | {app}"` composition on the login screen), keeping login self-contained. + +## Data flow +Route mounts → page calls `useDocumentTitle(t(key))` → effect sets `"{page} | {app_name}"`. Config +resolves → `app_name` dep changes → title corrected. Detail pane mounts → overrides with +`object_number` → unmount restores. `` is purely presentational. + +## Error handling / edges +- `document` guarded (test/SSR safety). +- Detail pane must not set a title until the object is loaded (no `"undefined | …"`). +- Two `useDocumentTitle` instances active at once (list + detail) on a detail route: the detail's + effect runs after the list's (child mounts after parent), so the object title wins; restore on the + detail's unmount returns to the list title. The list's effect does not re-fire on detail + open/close (its `page`/`app_name` deps are unchanged), so it won't clobber the override. +- One `

` per page is preserved on master-detail routes (list owns the h1; detail uses h2). + +## Testing +- **`page-title` story/test:** renders `Objects`, assert + `getByRole("heading", { level: 1 })` has the text. +- **`use-document-title.test.tsx`:** render a component using the hook inside `renderApp` (which + provides config) or a small ConfigProvider wrapper; assert `document.title === "X | "`; + unmount → assert it reverts to the previous value. (Mock/confirm the config `app_name` used.) +- **Page-level:** assert `ObjectsPage` renders an `

` with the localized "Objects" and sets + `document.title`. A detail test: rendering the object route sets `document.title` to the + `object_number` and reverts when navigating away (reuse existing object MSW handlers/fixtures). +- Gate: `pnpm typecheck && lint && test && build && check:size && check:colors`; en/sv parity + (reused keys); no codename; `check:size` within 250 KB gz. + +## Acceptance criteria +1. A `PageTitle` (`

`) component exists and is rendered once per `AppShell` route (objects, + object-new, vocabularies, authorities, fields, search) using existing i18n keys. +2. `document.title` is set per route as `"{Page} | {AppName}"`; object detail routes + (`/objects/:id`, `/search/:id`) show the object's `object_number` in the tab and revert on close. +3. The misused `

` caption in `vocabulary-terms` becomes a non-heading element (`.label-caption`). +4. Exactly one `

` per page (master-detail: list owns the `

`, detail keeps `

`). +5. No layout/spacing restructure beyond adding the heading element; no new i18n strings; no new dep. +6. `typecheck`/`lint`/`test`/`build`/`check:size`/`check:colors` green; no codename. + +## Out of scope → follow-ups +- Header breadcrumb / wayfinding / global search entry (#54). +- Broader density/spacing/typography redesign per screen (the type scale here is intentionally + minimal: page title + the existing section/body/caption levels). +- A `` template via a router data API or a `<DocumentTitle>` provider (the hook is sufficient). diff --git a/web/src/auth/login-page.tsx b/web/src/auth/login-page.tsx index 6009685..0193a70 100644 --- a/web/src/auth/login-page.tsx +++ b/web/src/auth/login-page.tsx @@ -1,4 +1,4 @@ -import { useState, type FormEvent } from "react"; +import { useEffect, useState, type FormEvent } from "react"; import { useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; @@ -14,6 +14,10 @@ export function LoginPage() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); + useEffect(() => { + document.title = t("app.name"); + }, [t]); + const onSubmit = (event: FormEvent) => { event.preventDefault(); login.mutate( diff --git a/web/src/authorities/authorities-page.tsx b/web/src/authorities/authorities-page.tsx index b0696af..ec90e7f 100644 --- a/web/src/authorities/authorities-page.tsx +++ b/web/src/authorities/authorities-page.tsx @@ -6,7 +6,9 @@ import type { components } from "../api/schema"; import { useAuthorities, useCreateAuthority } from "../api/queries"; import { LabelEditor } from "../components/label-editor"; import { Button } from "@/components/ui/button"; +import { PageTitle } from "@/components/ui/page-title"; import { AuthorityRow } from "./authority-row"; +import { useDocumentTitle } from "../lib/use-document-title"; type LabelInput = components["schemas"]["LabelInput"]; @@ -26,6 +28,8 @@ export function AuthoritiesPage() { const [labels, setLabels] = useState<LabelInput[]>([]); const [error, setError] = useState(false); + useDocumentTitle(t("nav.authorities")); + if (!isValidKind) return <Navigate to="/authorities/person" replace />; const onCreate = (event: FormEvent) => { @@ -45,6 +49,7 @@ export function AuthoritiesPage() { return ( <div className="overflow-auto p-4"> + <PageTitle className="mb-3">{t("nav.authorities")}</PageTitle> <div role="tablist" className="mb-3 flex gap-2"> {KINDS.map((k) => ( <NavLink diff --git a/web/src/components/ui/page-title.stories.tsx b/web/src/components/ui/page-title.stories.tsx new file mode 100644 index 0000000..92aa8e9 --- /dev/null +++ b/web/src/components/ui/page-title.stories.tsx @@ -0,0 +1,19 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { expect } from 'storybook/test' + +import { PageTitle } from './page-title' + +const meta = { + component: PageTitle, + args: { children: 'Objects' }, + tags: ['ai-generated'], +} satisfies Meta<typeof PageTitle> + +export default meta +type Story = StoryObj<typeof meta> + +export const Default: Story = { + play: async ({ canvas }) => { + await expect(canvas.getByRole('heading', { level: 1, name: 'Objects' })).toBeInTheDocument() + }, +} diff --git a/web/src/components/ui/page-title.tsx b/web/src/components/ui/page-title.tsx new file mode 100644 index 0000000..d4507c5 --- /dev/null +++ b/web/src/components/ui/page-title.tsx @@ -0,0 +1,13 @@ +import type { ComponentProps } from "react" + +import { cn } from "@/lib/utils" + +export function PageTitle({ className, ...props }: ComponentProps<"h1">) { + return ( + <h1 + data-slot="page-title" + className={cn("text-2xl font-semibold tracking-tight", className)} + {...props} + /> + ) +} diff --git a/web/src/fields/fields-page.tsx b/web/src/fields/fields-page.tsx index afbf4b2..3027317 100644 --- a/web/src/fields/fields-page.tsx +++ b/web/src/fields/fields-page.tsx @@ -1,25 +1,34 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; import type { components } from "../api/schema"; import { FieldList } from "./field-list"; import { FieldForm } from "./field-form"; +import { useDocumentTitle } from "../lib/use-document-title"; +import { PageTitle } from "@/components/ui/page-title"; type FieldDefinitionView = components["schemas"]["FieldDefinitionView"]; export function FieldsPage() { + const { t } = useTranslation(); const [selected, setSelected] = useState<FieldDefinitionView | null>(null); + useDocumentTitle(t("fields.title")); + return ( - <div className="grid h-full grid-cols-[20rem_1fr]"> - <div className="overflow-hidden border-r"> - <FieldList selectedKey={selected?.key ?? null} onSelect={setSelected} /> - </div> - <div className="overflow-hidden"> - <FieldForm - key={selected?.key ?? "create"} - editing={selected} - onDone={() => setSelected(null)} - /> + <div className="flex h-full flex-col"> + <PageTitle className="px-4 pt-4 pb-2">{t("fields.title")}</PageTitle> + <div className="grid flex-1 grid-cols-[20rem_1fr] overflow-hidden"> + <div className="overflow-hidden border-r"> + <FieldList selectedKey={selected?.key ?? null} onSelect={setSelected} /> + </div> + <div className="overflow-hidden"> + <FieldForm + key={selected?.key ?? "create"} + editing={selected} + onDone={() => setSelected(null)} + /> + </div> </div> </div> ); diff --git a/web/src/lib/use-document-title.test.tsx b/web/src/lib/use-document-title.test.tsx new file mode 100644 index 0000000..5ffe5a6 --- /dev/null +++ b/web/src/lib/use-document-title.test.tsx @@ -0,0 +1,37 @@ +import { afterEach, expect, test } from "vitest"; +import { render } from "@testing-library/react"; +import "../i18n"; +import { ConfigProvider } from "../config/config-provider"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useDocumentTitle } from "./use-document-title"; + +function Titled({ page }: { page: string }) { + useDocumentTitle(page); + return null; +} + +function wrap(ui: React.ReactElement) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + <QueryClientProvider client={qc}> + <ConfigProvider>{ui}</ConfigProvider> + </QueryClientProvider>, + ); +} + +afterEach(() => { + document.title = ""; +}); + +test("sets document.title to '{page} | {app_name}'", () => { + wrap(<Titled page="Objects" />); + expect(document.title).toMatch(/^Objects \| .+/); +}); + +test("restores the previous title on unmount", () => { + document.title = "Prev"; + const { unmount } = wrap(<Titled page="Objects" />); + expect(document.title).toMatch(/^Objects \| /); + unmount(); + expect(document.title).toBe("Prev"); +}); diff --git a/web/src/lib/use-document-title.ts b/web/src/lib/use-document-title.ts new file mode 100644 index 0000000..e0e413f --- /dev/null +++ b/web/src/lib/use-document-title.ts @@ -0,0 +1,19 @@ +import { useEffect } from "react"; + +import { useConfig } from "../config/config-context"; + +export function useDocumentTitle(page: string): void { + const { app_name } = useConfig(); + + useEffect(() => { + if (typeof document === "undefined") return; + + const previous = document.title; + + document.title = `${page} | ${app_name}`; + + return () => { + document.title = previous; + }; + }, [page, app_name]); +} diff --git a/web/src/objects/object-detail.test.tsx b/web/src/objects/object-detail.test.tsx index 228ae36..ab05685 100644 --- a/web/src/objects/object-detail.test.tsx +++ b/web/src/objects/object-detail.test.tsx @@ -1,5 +1,5 @@ import { expect, test } from "vitest"; -import { screen, within } from "@testing-library/react"; +import { screen, waitFor, within } from "@testing-library/react"; import { http, HttpResponse } from "msw"; import { Routes, Route } from "react-router-dom"; import { server } from "../test/server"; @@ -96,6 +96,14 @@ test("shows a not-found state for a missing object", async () => { expect(await screen.findByText(/object not found/i)).toBeInTheDocument(); }); +test("sets the tab title to the object number and reverts on unmount", async () => { + document.title = "Base"; + const { unmount } = renderDetail(); + await waitFor(() => expect(document.title).toMatch(/^LM-0042 \| /)); + unmount(); + expect(document.title).toBe("Base"); +}); + test("detail shows the publish control with the current visibility stepper", async () => { // default GET /api/admin/objects/:id handler returns amphora (visibility "public") renderApp(tree(), { route: "/objects/11111111-1111-1111-1111-111111111111" }); diff --git a/web/src/objects/object-detail.tsx b/web/src/objects/object-detail.tsx index b879ae3..fc6e2ac 100644 --- a/web/src/objects/object-detail.tsx +++ b/web/src/objects/object-detail.tsx @@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next"; import type { components } from "../api/schema"; import { useObject, useFieldDefinitions } from "../api/queries"; import { formatDate } from "../lib/format-date"; +import { useDocumentTitle } from "../lib/use-document-title"; import { DeleteObjectDialog } from "./delete-object-dialog"; import { FlexibleFieldValue } from "./flexible-field-value"; import { PublishControl } from "./publish-control"; @@ -13,6 +14,7 @@ import { buttonVariants } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; type FieldDefinitionView = components["schemas"]["FieldDefinitionView"]; +type AdminObjectView = components["schemas"]["AdminObjectView"]; function Field({ label, value }: { label: string; value: ReactNode }) { const empty = value === null || value === undefined || value === ""; @@ -26,10 +28,9 @@ function Field({ label, value }: { label: string; value: ReactNode }) { } export function ObjectDetail() { - const { t, i18n } = useTranslation(); + const { t } = useTranslation(); const { id } = useParams(); const { data: object, isLoading, isError } = useObject(id!); - const { data: definitions } = useFieldDefinitions(); if (isLoading) { return ( @@ -43,6 +44,15 @@ export function ObjectDetail() { if (!object) return <p className="p-4 text-sm text-muted-foreground">{t("objects.notFound")}</p>; + return <ObjectDetailLoaded object={object} />; +} + +function ObjectDetailLoaded({ object }: { object: AdminObjectView }) { + const { t, i18n } = useTranslation(); + const { data: definitions } = useFieldDefinitions(); + + useDocumentTitle(object.object_number); + // Prefer the active locale's label, then English, then the raw key. const lang = i18n.language.startsWith("sv") ? "sv" : "en"; const labelFor = (key: string) => { diff --git a/web/src/objects/object-new-page.tsx b/web/src/objects/object-new-page.tsx index 6f69c3a..4887877 100644 --- a/web/src/objects/object-new-page.tsx +++ b/web/src/objects/object-new-page.tsx @@ -4,6 +4,8 @@ import { useTranslation } from "react-i18next"; import { ObjectForm, type ObjectFormValues } from "./object-form"; import { useCreateObject, useSetFields, FieldRejection } from "../api/queries"; +import { useDocumentTitle } from "../lib/use-document-title"; +import { PageTitle } from "@/components/ui/page-title"; export function ObjectNewPage() { const { t } = useTranslation(); @@ -12,6 +14,8 @@ export function ObjectNewPage() { const setFields = useSetFields(); const [error, setError] = useState<string | null>(null); + useDocumentTitle(t("objects.new")); + const onSubmit = async (values: ObjectFormValues) => { setError(null); @@ -44,6 +48,7 @@ export function ObjectNewPage() { return ( <div className="mx-auto max-w-2xl"> + <PageTitle className="mb-4">{t("objects.new")}</PageTitle> <ObjectForm mode="create" formError={error} diff --git a/web/src/objects/objects-page.test.tsx b/web/src/objects/objects-page.test.tsx index 0b92a05..923a8d8 100644 --- a/web/src/objects/objects-page.test.tsx +++ b/web/src/objects/objects-page.test.tsx @@ -1,5 +1,5 @@ import { afterEach, expect, test, vi } from "vitest"; -import { screen, within } from "@testing-library/react"; +import { screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { Routes, Route } from "react-router-dom"; import { renderApp } from "../test/render"; @@ -39,6 +39,16 @@ afterEach(() => { vi.restoreAllMocks(); }); +test("renders the page heading and sets the document title", async () => { + setViewport(true); + renderApp(tree(), { route: "/objects" }); + + expect( + await screen.findByRole("heading", { level: 1, name: /objects/i }), + ).toBeInTheDocument(); + await waitFor(() => expect(document.title).toMatch(/objects \| /i)); +}); + test("the table is the landing view; no detail panel until a row is opened", async () => { setViewport(true); renderApp(tree(), { route: "/objects" }); diff --git a/web/src/objects/objects-page.tsx b/web/src/objects/objects-page.tsx index e37de63..d3f490c 100644 --- a/web/src/objects/objects-page.tsx +++ b/web/src/objects/objects-page.tsx @@ -5,6 +5,8 @@ import { X } from "lucide-react"; import { ObjectsTable } from "./objects-table"; import { useMediaQuery } from "../lib/use-media-query"; +import { useDocumentTitle } from "../lib/use-document-title"; +import { PageTitle } from "@/components/ui/page-title"; const ObjectDetailDrawer = lazy(() => import("./object-detail-drawer").then((m) => ({ default: m.ObjectDetailDrawer })), @@ -23,11 +25,16 @@ export function ObjectsPage() { const open = Boolean(detailMatch ?? editMatch); const isWide = useMediaQuery("(min-width: 1024px)"); + useDocumentTitle(t("nav.objects")); + const closeDetail = () => navigate(`/objects?${searchParams}`); const table = ( - <div className="overflow-hidden"> - <ObjectsTable /> + <div className="flex h-full flex-col overflow-hidden"> + <PageTitle className="px-4 pt-4 pb-2">{t("nav.objects")}</PageTitle> + <div className="flex-1 overflow-hidden"> + <ObjectsTable /> + </div> </div> ); diff --git a/web/src/search/search-page.tsx b/web/src/search/search-page.tsx index 41ac0ed..a14706a 100644 --- a/web/src/search/search-page.tsx +++ b/web/src/search/search-page.tsx @@ -1,15 +1,25 @@ import { Outlet } from "react-router-dom"; +import { useTranslation } from "react-i18next"; import { SearchPanel } from "./search-panel"; +import { useDocumentTitle } from "../lib/use-document-title"; +import { PageTitle } from "@/components/ui/page-title"; export function SearchPage() { + const { t } = useTranslation(); + + useDocumentTitle(t("nav.search")); + return ( - <div className="grid h-full grid-cols-[24rem_1fr]"> - <div className="overflow-hidden border-r"> - <SearchPanel /> - </div> - <div className="overflow-hidden"> - <Outlet /> + <div className="flex h-full flex-col"> + <PageTitle className="px-4 pt-4 pb-2">{t("nav.search")}</PageTitle> + <div className="grid flex-1 grid-cols-[24rem_1fr] overflow-hidden"> + <div className="overflow-hidden border-r"> + <SearchPanel /> + </div> + <div className="overflow-hidden"> + <Outlet /> + </div> </div> </div> ); diff --git a/web/src/vocab/vocabularies-page.tsx b/web/src/vocab/vocabularies-page.tsx index ac24fcc..64e06d5 100644 --- a/web/src/vocab/vocabularies-page.tsx +++ b/web/src/vocab/vocabularies-page.tsx @@ -1,15 +1,25 @@ import { Outlet } from "react-router-dom"; +import { useTranslation } from "react-i18next"; import { VocabularyList } from "./vocabulary-list"; +import { useDocumentTitle } from "../lib/use-document-title"; +import { PageTitle } from "@/components/ui/page-title"; export function VocabulariesPage() { + const { t } = useTranslation(); + + useDocumentTitle(t("nav.vocabularies")); + return ( - <div className="grid h-full grid-cols-[20rem_1fr]"> - <div className="overflow-hidden border-r"> - <VocabularyList /> - </div> - <div className="overflow-hidden"> - <Outlet /> + <div className="flex h-full flex-col"> + <PageTitle className="px-4 pt-4 pb-2">{t("nav.vocabularies")}</PageTitle> + <div className="grid flex-1 grid-cols-[20rem_1fr] overflow-hidden"> + <div className="overflow-hidden border-r"> + <VocabularyList /> + </div> + <div className="overflow-hidden"> + <Outlet /> + </div> </div> </div> ); diff --git a/web/src/vocab/vocabulary-terms.tsx b/web/src/vocab/vocabulary-terms.tsx index 3900f70..4a786a2 100644 --- a/web/src/vocab/vocabulary-terms.tsx +++ b/web/src/vocab/vocabulary-terms.tsx @@ -49,9 +49,9 @@ export function VocabularyTerms() { return ( <div className="overflow-auto p-4"> - <h3 className="mb-2 label-caption"> + <div className="mb-2 label-caption"> {t("vocab.terms")} - </h3> + </div> <ul className="mb-4"> {isLoading && ( <li className="text-sm text-muted-foreground">…</li>