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 `` 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([]);
const [error, setError] = useState(false);
+ useDocumentTitle(t("nav.authorities"));
+
if (!isValidKind) return ;
const onCreate = (event: FormEvent) => {
@@ -45,6 +49,7 @@ export function AuthoritiesPage() {
return (
+
{t("nav.authorities")}
{KINDS.map((k) => (
+
+export default meta
+type Story = StoryObj
+
+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 (
+
+ )
+}
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(null);
+ useDocumentTitle(t("fields.title"));
+
return (
-
-
-
-
-
-
setSelected(null)}
- />
+
+
{t("fields.title")}
+
+
+
+
+
+ setSelected(null)}
+ />
+
);
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(
+
+ {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");
+});
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 {t("objects.notFound")}
;
+ return ;
+}
+
+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(null);
+ useDocumentTitle(t("objects.new"));
+
const onSubmit = async (values: ObjectFormValues) => {
setError(null);
@@ -44,6 +48,7 @@ export function ObjectNewPage() {
return (
+
{t("objects.new")}
{
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 = (
-
-
+
+
{t("nav.objects")}
+
+
+
);
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 (
-
-
-
-
-
-
+
);
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 (
-
-
-
-
-
-
+
+
{t("nav.vocabularies")}
+
);
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 (
-
+
{t("vocab.terms")}
-
+