From aab1bb37dcfb39cffdcca4d2bba33c893b75a3b5 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Sun, 7 Jun 2026 16:59:37 +0200 Subject: [PATCH] docs(specs): typography hierarchy + page

+ per-route document.title (#57) --- ...026-06-07-typography-page-titles-design.md | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-07-typography-page-titles-design.md 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).