8.8 KiB
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)
PageTitlecomponent (ui/page-title.tsx) rendering a semantic<h1>with consistent classes — over utility classes or inline copy-paste. NoSectionTitle(YAGNI; one h2 exists).- Tab title format
"{Page} | {AppName}"— page-first so truncated tabs stay distinguishable. - Reuse existing i18n keys for page titles — no new strings.
- Master-detail: the list page owns the single
<h1>; the detail pane overridesdocument.titleto 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()):
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)
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 whenpageorapp_namechanges (so it updates when the async config resolves, or when a detail pane swaps thepagevalue). - 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()exposesapp_name(defaults to"Collection Management System"until/api/configresolves); the dep onapp_namemeans 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:ObjectsPagestill renders the visible<h1>("Objects") and sets the base title.ObjectDetail(right pane) keepsobject_nameas its<h2>and callsuseDocumentTitle(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:ObjectDetailis reused inSearchPage's pane — same override over the "Search" base, automatically.- Result: exactly one
<h1>per page (a11y), while each open object tab is distinguishable. - Edge:
ObjectDetailmust 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
documentguarded (test/SSR safety).- Detail pane must not set a title until the object is loaded (no
"undefined | …"). - Two
useDocumentTitleinstances 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 (itspage/app_namedeps 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-titlestory/test: renders<PageTitle>Objects</PageTitle>, assertgetByRole("heading", { level: 1 })has the text.use-document-title.test.tsx: render a component using the hook insiderenderApp(which provides config) or a small ConfigProvider wrapper; assertdocument.title === "X | <app_name>"; unmount → assert it reverts to the previous value. (Mock/confirm the configapp_nameused.)- Page-level: assert
ObjectsPagerenders an<h1>with the localized "Objects" and setsdocument.title. A detail test: rendering the object route setsdocument.titleto theobject_numberand 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:sizewithin 250 KB gz.
Acceptance criteria
- A
PageTitle(<h1>) component exists and is rendered once perAppShellroute (objects, object-new, vocabularies, authorities, fields, search) using existing i18n keys. document.titleis set per route as"{Page} | {AppName}"; object detail routes (/objects/:id,/search/:id) show the object'sobject_numberin the tab and revert on close.- The misused
<h3>caption invocabulary-termsbecomes a non-heading element (.label-caption). - Exactly one
<h1>per page (master-detail: list owns the<h1>, detail keeps<h2>). - No layout/spacing restructure beyond adding the heading element; no new i18n strings; no new dep.
typecheck/lint/test/build/check:size/check:colorsgreen; 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).