Files
biggus-dickus/docs/superpowers/specs/2026-06-07-typography-page-titles-design.md

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)

  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()):

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