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

17 KiB
Raw Blame History

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

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

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, 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.:
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:

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