17 KiB
Typography Hierarchy + Page <h1> + Per-Route document.title Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Give every AppShell route a consistent semantic page <h1> and a distinct browser-tab title ("{Page} | {AppName}"), via a small PageTitle component and a useDocumentTitle hook, and fix the one misused <h3> caption.
Architecture: A presentational PageTitle (ui/page-title.tsx) renders the styled <h1>. A useDocumentTitle(page) hook (lib/) composes "{page} | {app_name}" (app_name from useConfig), sets document.title, and restores the prior title on unmount — which lets a master-detail detail pane override the tab to the object's object_number and revert on close. Pages reuse existing i18n keys; no new strings, no new dependency.
Tech Stack: React 19 + TS + pnpm, Tailwind v4, react-i18next, react-router 7, Vitest + RTL + MSW + Storybook. Test runner: pnpm test (single pass).
Conventions: pnpm; no any/eslint-disable/@ts-ignore; no codename; reuse existing i18n keys (en/sv already in parity); source double-quote/semicolon, stories single-quote/no-semicolon; token classes only; do not restructure layout/columns — only add the heading element + title hook; guard document for jsdom.
Spec: docs/superpowers/specs/2026-06-07-typography-page-titles-design.md
File structure:
web/src/components/ui/page-title.tsx(new) —<PageTitle>h1.web/src/components/ui/page-title.stories.tsx(new) — story.web/src/lib/use-document-title.ts(new) — the hook.web/src/lib/use-document-title.test.tsx(new) — hook test.- Modify pages:
web/src/objects/objects-page.tsx,web/src/objects/object-new-page.tsx,web/src/vocab/vocabularies-page.tsx,web/src/authorities/authorities-page.tsx,web/src/fields/fields-page.tsx,web/src/search/search-page.tsx. - Modify
web/src/objects/object-detail.tsx(detail title override). - Modify
web/src/vocab/vocabulary-terms.tsx(h3→div caption fix). - Modify
web/src/auth/login-page.tsx(document.title = app.name).
NOTE: exact page file paths — verify with
git ls-files web/src | grep -E 'objects-page|object-new|vocabularies-page|authorities-page|fields-page|search-page|object-detail|vocabulary-terms|login-page'before editing; the directory names above are from exploration but confirm.
Task 1: PageTitle component + story
Files:
-
Create:
web/src/components/ui/page-title.tsx -
Create:
web/src/components/ui/page-title.stories.tsx -
Step 1: Implement
web/src/components/ui/page-title.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}
/>
);
}
Confirm the cn import path matches the other ui/* files (open web/src/components/ui/button.tsx
and copy its exact cn import — expected @/lib/utils).
- Step 2: Write the story
web/src/components/ui/page-title.stories.tsx:
import type { Meta, StoryObj } from '@storybook/react-vite'
import { expect } from 'storybook/test'
import { PageTitle } from './page-title'
const meta = {
component: PageTitle,
args: { children: 'Objects' },
tags: ['ai-generated'],
} satisfies Meta<typeof PageTitle>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
play: async ({ canvas }) => {
await expect(canvas.getByRole('heading', { level: 1, name: 'Objects' })).toBeInTheDocument()
},
}
(Match the house story style — single quotes, no semicolons — as in web/src/components/label-editor.stories.tsx. Adjust the Meta import if that file imports it differently.)
- Step 3: Run the story-as-test + typecheck + lint
Run: cd web && pnpm vitest run src/components/ui/page-title.stories.tsx && pnpm typecheck && pnpm lint
Expected: PASS, clean.
- Step 4: Commit
git add web/src/components/ui/page-title.tsx web/src/components/ui/page-title.stories.tsx
git commit -m "feat(web): PageTitle h1 component + story (#57)"
Task 2: useDocumentTitle hook + test
Files:
-
Create:
web/src/lib/use-document-title.ts -
Create:
web/src/lib/use-document-title.test.tsx -
Step 1: Confirm the config hook. Open
web/src/config/config-context.tsand confirm the exported hook name and that it returnsapp_name(expecteduseConfig()→{ app_name, ... }). Use the exact import path/name in the hook below. -
Step 2: Write the failing test
web/src/lib/use-document-title.test.tsx:
import { afterEach, expect, test } from "vitest";
import { render } from "@testing-library/react";
import "../i18n";
import { ConfigProvider } from "../config/config-provider";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useDocumentTitle } from "./use-document-title";
function Titled({ page }: { page: string }) {
useDocumentTitle(page);
return null;
}
function wrap(ui: React.ReactElement) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<ConfigProvider>{ui}</ConfigProvider>
</QueryClientProvider>,
);
}
afterEach(() => {
document.title = "";
});
test("sets document.title to '{page} | {app_name}'", () => {
wrap(<Titled page="Objects" />);
expect(document.title).toMatch(/^Objects \| .+/);
});
test("restores the previous title on unmount", () => {
document.title = "Prev";
const { unmount } = wrap(<Titled page="Objects" />);
expect(document.title).toMatch(/^Objects \| /);
unmount();
expect(document.title).toBe("Prev");
});
NOTE: this assumes ConfigProvider supplies a default app_name synchronously (the spec says
useConfig defaults to "Collection Management System" before /api/config resolves). If
ConfigProvider needs MSW for /api/config, instead import handlers from ../test/handlers and
set up an MSW server in the test (mirror an existing test that uses ConfigProvider). If a simpler
existing wrapper exists (check web/src/test/render.tsx — does renderApp include ConfigProvider?),
prefer that. Do NOT weaken the assertions; adapt the wrapper to provide config.
- Step 3: Run to verify it fails
Run: cd web && pnpm vitest run src/lib/use-document-title.test.tsx
Expected: FAIL — cannot import useDocumentTitle.
- Step 4: Implement
web/src/lib/use-document-title.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]);
}
(Adjust the useConfig import path/name to match what Step 1 found.)
- Step 5: Run to verify it passes
Run: cd web && pnpm vitest run src/lib/use-document-title.test.tsx
Expected: PASS (2 tests).
- Step 6: Commit
git add web/src/lib/use-document-title.ts web/src/lib/use-document-title.test.tsx
git commit -m "feat(web): useDocumentTitle hook (restores prior title on unmount) (#57)"
Task 3: Wire <PageTitle> + useDocumentTitle into the list/form pages
Files (modify): web/src/objects/objects-page.tsx, web/src/objects/object-new-page.tsx,
web/src/vocab/vocabularies-page.tsx, web/src/authorities/authorities-page.tsx,
web/src/fields/fields-page.tsx, web/src/search/search-page.tsx.
For EACH page: read it first, add the imports
import { PageTitle } from "@/components/ui/page-title"; (match the file's import style) and
import { useDocumentTitle } from "../lib/use-document-title"; (verify the relative depth), call the
hook near the top of the component, and render <PageTitle> at the top of the page's content. Use the
i18n key from the table. Do not restructure existing layout/columns — only add the heading
element (and, if the page already has a top action row, place <PageTitle> on its left).
| File | i18n key | Notes |
|---|---|---|
objects-page.tsx |
nav.objects |
Page already has a toolbar (filter, New button, pagination). Put <PageTitle> at the top-left of that toolbar row, or in a small header row above the table. |
object-new-page.tsx |
objects.new |
Form page; <PageTitle> above the form. |
vocabularies-page.tsx |
nav.vocabularies |
Two-column; <PageTitle> above the columns (full width). |
authorities-page.tsx |
nav.authorities |
Tabbed; <PageTitle> above the tabs. |
fields-page.tsx |
fields.title |
Two-column; <PageTitle> above the columns. |
search-page.tsx |
nav.search |
Two-column; <PageTitle> above the columns. |
-
Step 1: objects-page.tsx — add imports,
const { t } = useTranslation()(it likely already hast),useDocumentTitle(t("nav.objects")), and render<PageTitle>{t("nav.objects")}</PageTitle>at the top of the content. Keep all existing markup. -
Step 2: object-new-page.tsx — same pattern with
objects.new. -
Step 3: vocabularies-page.tsx — same with
nav.vocabularies. -
Step 4: authorities-page.tsx — same with
nav.authorities. -
Step 5: fields-page.tsx — same with
fields.title. -
Step 6: search-page.tsx — same with
nav.search. -
Step 7: Add/extend a page test. In the existing test for objects (or create
web/src/objects/objects-page.test.tsxif none — check first), assert the<h1>and title:
test("renders the page heading and sets the document title", async () => {
renderApp(/* the objects page route, mirroring the existing objects test setup */);
expect(await screen.findByRole("heading", { level: 1, name: /objects/i })).toBeInTheDocument();
await waitFor(() => expect(document.title).toMatch(/objects \| /i));
});
If an objects page/integration test already exists, ADD this assertion there using the same render setup rather than duplicating the harness. Do not weaken existing assertions.
- Step 8: Run the affected tests + typecheck + lint
Run: cd web && pnpm vitest run src/objects src/vocab src/authorities src/fields src/search && pnpm typecheck && pnpm lint
Expected: PASS (existing tests unaffected by the added heading; the new assertion passes). If any
existing test breaks because a heading query is now ambiguous, investigate — the page <h1> text
should be distinct from row/cell content; do NOT weaken the test.
- Step 9: Commit
git add web/src/objects/objects-page.tsx web/src/objects/object-new-page.tsx web/src/vocab/vocabularies-page.tsx web/src/authorities/authorities-page.tsx web/src/fields/fields-page.tsx web/src/search/search-page.tsx web/src/objects/*.test.tsx
git commit -m "feat(web): page <h1> + document.title on list/form routes (#57)"
Task 4: Detail-pane title override, caption fix, login title + final gate
Files (modify): web/src/objects/object-detail.tsx, web/src/vocab/vocabulary-terms.tsx,
web/src/auth/login-page.tsx.
- Step 1: Object detail title override. In
web/src/objects/object-detail.tsx, after the object has loaded, calluseDocumentTitle(object.object_number). The hook must receive a real value — only call it onceobjectis loaded. Because hooks can't be conditional, call it unconditionally with a guarded value, e.g.:
import { useDocumentTitle } from "../lib/use-document-title";
// ... inside the component, AFTER object data is available:
useDocumentTitle(object?.object_number ?? "");
But setting " | App" (empty page) while loading is undesirable. Prefer: keep the hook call
unconditional but pass the object_number only when loaded, and make the component not render/return
early before the data is present (check the existing structure — object-detail.tsx likely already
early-returns a loading/skeleton state). If it early-returns BEFORE the hook, that violates rules-of-
hooks. Resolve by either: (a) calling useDocumentTitle(object_number) only in the loaded branch by
splitting the component into an outer (fetch + loading) and an inner ObjectDetailLoaded({ object })
that calls the hook — RECOMMENDED; or (b) guarding inside the hook usage so loading sets nothing.
Choose (a): create an inner component that receives the loaded object and calls
useDocumentTitle(object.object_number); the outer handles loading. Keep object_name as the
existing <h2>.
Verify object_number is the right field (read the component / the AdminObjectView/object type).
-
Step 2: Caption fix. In
web/src/vocab/vocabulary-terms.tsxaround line 52, change the<h3 className="mb-2 label-caption">…</h3>to<div className="mb-2 label-caption">…</div>(same className/content; only the element changes). Confirm there is no CSS/test depending on theh3. -
Step 3: Login title. In
web/src/auth/login-page.tsx, add a small effect:
import { useEffect } from "react";
// ... inside the component (t from useTranslation already present):
useEffect(() => {
document.title = t("app.name");
}, [t]);
(Do not use useDocumentTitle here — login is pre-auth/standalone.)
- Step 4: Detail-override test. Add (or extend an existing object-detail test) asserting the
document title reflects the object on the detail route and reverts on unmount. Mirror the existing
object-detail test's render/MSW setup (reuse
web/src/test/handlers.ts+ fixtures):
test("object detail sets the tab title to the object number and reverts", async () => {
document.title = "Base";
const { unmount } = renderApp(/* object detail route, per the existing detail test */);
await waitFor(() => expect(document.title).toMatch(/<expected object_number from fixture> \| /));
unmount();
expect(document.title).toBe("Base");
});
Use the actual object_number from the existing object fixture (read web/src/test/fixtures.ts —
the amphora fixture). If reverting-on-unmount is awkward to assert through the full route, assert
the override (title contains the object_number) at minimum; do not weaken below that.
- Step 5: FULL GATE (single test pass — run tests exactly ONCE):
Run:
cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size && pnpm check:colors
Expected: all green. Report test totals, largest chunk, check:colors line.
- Step 6: Codename + status:
cd /Users/olsson/Laboratory/biggus-dickus
git grep -in 'biggus\|dickus' -- web/src; echo "codename-exit=$?"
git status --short
Expected: no codename matches.
-
Step 7: Manual smoke (recommended).
pnpm dev: each route shows a page<h1>; the tab title reads "{Page} | {AppName}"; opening an object changes the tab to the object number and closing it reverts; exactly one<h1>per page. -
Step 8: Commit
git add web/src/objects/object-detail.tsx web/src/vocab/vocabulary-terms.tsx web/src/auth/login-page.tsx web/src/objects/*.test.tsx
git commit -m "feat(web): object-detail tab title, caption element fix, login title (#57)"
Self-Review (completed)
Spec coverage: PageTitle h1 component (T1); useDocumentTitle hook with restore-on-unmount (T2);
per-route h1 + title on objects/object-new/vocabularies/authorities/fields/search reusing existing
keys (T3); detail-pane object_number override + revert (T4 S1); h3→div caption fix (T4 S2); login
title (T4 S3); one h1 per page preserved (list owns h1, detail keeps h2 — T3/T4); tests for component,
hook, page, and override (T1/T2/T3/T4); gate + no codename + no new dep + no new strings (T4). All
acceptance criteria 1–6 mapped. ✓
Placeholder scan: the only non-literal spots are render-harness reuse ("mirror the existing test
setup") and the object fixture's object_number ("read fixtures.ts") — these are deliberate "match
the existing pattern" instructions with concrete files named, not TODOs. The rules-of-hooks resolution
for object-detail (split into inner loaded component) is spelled out. ✓
Type consistency: PageTitle is ComponentProps<"h1">; useDocumentTitle(page: string) defined
in T2 and called with t(key) (string) in T3 and object.object_number (string) in T4;
useConfig().app_name is the title's app-name source throughout. ✓
Notes
- No new dependency, no new i18n strings (all keys exist in en/sv).
- The restore-on-unmount in
useDocumentTitleis load-bearing for the master-detail override — keep it. - Verify exact page file paths first (the NOTE under File structure); adjust import depths accordingly.