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).