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

375 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`:
```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`:
```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**
```bash
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.ts` and confirm the
exported hook name and that it returns `app_name` (expected `useConfig()``{ 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`:
```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`:
```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**
```bash
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
has `t`), `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.tsx` if none — check first), assert the `<h1>` and title:
```tsx
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**
```bash
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, call `useDocumentTitle(object.object_number)`. The hook must receive a real value
— only call it once `object` is loaded. Because hooks can't be conditional, call it unconditionally
with a guarded value, e.g.:
```tsx
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.tsx` around 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 the `h3`.
- [ ] **Step 3: Login title.** In `web/src/auth/login-page.tsx`, add a small effect:
```tsx
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):
```tsx
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:
```bash
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:**
```bash
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**
```bash
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 16 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 `useDocumentTitle` is load-bearing for the master-detail override — keep it.
- Verify exact page file paths first (the NOTE under File structure); adjust import depths accordingly.