docs(specs): typography hierarchy + page <h1> + per-route document.title (#57)
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
# Typography Hierarchy + Page `<h1>` + 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` `<h1 text-2xl>`, `object-detail.tsx` `<h2 text-xl>` (object name), and
|
||||
`vocabulary-terms.tsx:52` an `<h3 class="label-caption">` **misused as a caption**. The list routes
|
||||
(objects, vocabularies, authorities, fields, search) have **no page `<h1>`** and no visible title.
|
||||
Separately, `web/index.html` hardcodes `<title>Collection</title>` 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 `<h1>` 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 `<h1>`; the detail pane overrides `document.title`
|
||||
to the object identifier while mounted.
|
||||
|
||||
## Type scale
|
||||
|
||||
| Level | Element | Classes |
|
||||
|---|---|---|
|
||||
| Page title | `<h1>` | `text-2xl font-semibold tracking-tight` |
|
||||
| Section title | `<h2>` | `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 (
|
||||
<h1
|
||||
data-slot="page-title"
|
||||
className={cn("text-2xl font-semibold tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
A Storybook story renders it with sample text and asserts the `<h1>` 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 `<PageTitle>` and calls `useDocumentTitle` (reusing existing keys):
|
||||
|
||||
| Route | Component | `<h1>` / 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 `<PageTitle>` 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 `<PageTitle>` on the left of that row.
|
||||
|
||||
### Master-detail `document.title` override
|
||||
- `/objects/:id`: `ObjectsPage` still renders the visible `<h1>` ("Objects") and sets the base title.
|
||||
`ObjectDetail` (right pane) keeps `object_name` as its `<h2>` 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 `<h1>` 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`: `<h3 className="mb-2 label-caption">` →
|
||||
`<div className="mb-2 label-caption">` (it's a caption, not a section heading). No style change
|
||||
(class identical), just the element.
|
||||
|
||||
### Login (standalone)
|
||||
`login-page.tsx` keeps its `<h1>` (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. `<PageTitle>` 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 `<h1>` per page is preserved on master-detail routes (list owns the h1; detail uses h2).
|
||||
|
||||
## Testing
|
||||
- **`page-title` story/test:** renders `<PageTitle>Objects</PageTitle>`, 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 | <app_name>"`;
|
||||
unmount → assert it reverts to the previous value. (Mock/confirm the config `app_name` used.)
|
||||
- **Page-level:** assert `ObjectsPage` renders an `<h1>` 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` (`<h1>`) 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 `<h3>` caption in `vocabulary-terms` becomes a non-heading element (`.label-caption`).
|
||||
4. Exactly one `<h1>` per page (master-detail: list owns the `<h1>`, detail keeps `<h2>`).
|
||||
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 `<title>` template via a router data API or a `<DocumentTitle>` provider (the hook is sufficient).
|
||||
Reference in New Issue
Block a user