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.