From 6885377f35d0bd6172e8604b1558de13edfc3e7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 13:40:40 +0200 Subject: [PATCH] fix(ui): four hand-rolled page titles go through PageHeader The insights sub-page shell (text-xl sm:text-2xl semibold), the medication detail, the check-in screen and its result (semibold) each read a step lighter than every other page title. They now render through PageHeader, which gains a focusable heading with a visible ring for screens that move focus to their title, and an accessory slot beside the H1 that stays outside its accessible name. A structural test lists where a different H1 is the point (heroes, sign-in, error and prose pages, the first-run steps) and refuses any other. --- src/__tests__/page-header-h1-guard.test.ts | 85 ++++++++ src/components/insights/sub-page-shell.tsx | 181 +++++++++--------- .../medications/medication-detail-summary.tsx | 4 +- .../mental-health/assessment-result.tsx | 19 +- .../mental-health/check-in-wizard.tsx | 12 +- .../ui/__tests__/page-header-focus.test.tsx | 31 +++ src/components/ui/page-header.tsx | 40 +++- 7 files changed, 261 insertions(+), 111 deletions(-) create mode 100644 src/__tests__/page-header-h1-guard.test.ts create mode 100644 src/components/ui/__tests__/page-header-focus.test.tsx diff --git a/src/__tests__/page-header-h1-guard.test.ts b/src/__tests__/page-header-h1-guard.test.ts new file mode 100644 index 000000000..7603d1ea7 --- /dev/null +++ b/src/__tests__/page-header-h1-guard.test.ts @@ -0,0 +1,85 @@ +/** + * A module page's title is a `PageHeader` (UI standards §1, §5): the H1 on + * one tier (`text-2xl font-bold tracking-tight`), its explainer muted under + * it, its actions beside it. + * + * Four in-app headings were hand-rolled beside it — the insights sub-page + * shell (`text-xl sm:text-2xl font-semibold`), the medication detail, the + * check-in and its result (`font-semibold`) — and each read a step lighter + * than every other page title. They go through `PageHeader` now; this guard + * keeps a new `

` literal from appearing outside the places where a + * different heading is the point. + * + * Matched: an ` = { + "components/ui/page-header.tsx": "the primitive", + // Hero identity surfaces, not page titles. + "components/insights/hero-strip.tsx": "insights hero greeting", + "components/insights/coach-panel/coach-hero.tsx": "Coach hero", + "components/insights/coach-panel/coach-conversation.tsx": + "screen-reader title while the hero is folded away", + // The first-run flow: full-screen steps with their own focus management. + "components/onboarding/step-heading.tsx": "first-run step heading", + // Outside the app shell: sign-in, register, claim, invite, the share-link + // gate, the key-mismatch gate. + "app/auth/login/page.tsx": "sign-in card", + "app/auth/register/page.tsx": "register card", + "components/auth/claim-profile.tsx": "claim card", + "app/invite/[token]/page.tsx": "invalid invite screen", + "components/clinician/share-unlock-gate.tsx": "share-link unlock gate", + "app/key-mismatch-page.tsx": "key-mismatch gate", + // Error and not-found screens. + "app/not-found.tsx": "not-found screen", + "app/c/[token]/not-found.tsx": "share-link not-found screen", + "app/c/error.tsx": "share-link error screen", + "app/global-error.tsx": "renders without the stylesheet", + "components/error-details.tsx": "error boundary", + // Prose pages (the prose H1 tier, §5). + "app/about/page.tsx": "prose page", + "app/privacy/page.tsx": "prose page", + // Mention an H1 in a comment only. + "app/insights/ecg/page.tsx": "comment", + "components/admin/reminders-section.tsx": "comment", + "components/charts/health-chart.tsx": "comment", + "components/insights/coach-panel/coach-top-bar-trail.tsx": "comment", + "components/insights/ecg-section.tsx": "comment", + "components/measurement-reminders/vorsorge-section.tsx": "comment", + "components/settings/settings-shell.tsx": "comment", +}; + +describe("page titles go through PageHeader", () => { + const files = walkSourceFiles(SRC, { floor: 2500 }).filter( + (p) => + p.endsWith(".tsx") && + (p.startsWith("components/") || p.startsWith("app/")) && + !p.includes("__tests__/"), + ); + const withH1 = files.filter((p) => + readFileSync(join(SRC, p), "utf8").includes(" { + expect(withH1.length).toBeGreaterThan(10); + }); + + it("has no hand-rolled H1 outside the allowlist", () => { + expect(withH1.filter((p) => !(p in ALLOWED))).toEqual([]); + }); + + it("keeps every allowlist entry live", () => { + const set = new Set(withH1); + for (const key of Object.keys(ALLOWED)) + expect(set.has(key), key).toBe(true); + }); +}); diff --git a/src/components/insights/sub-page-shell.tsx b/src/components/insights/sub-page-shell.tsx index 77dc449b2..919e90a27 100644 --- a/src/components/insights/sub-page-shell.tsx +++ b/src/components/insights/sub-page-shell.tsx @@ -15,6 +15,7 @@ import { useScrollResetOnRoute } from "@/hooks/use-scroll-reset-on-route"; import { useTranslations } from "@/lib/i18n/context"; import { SUB_PAGE_SLUGS } from "@/lib/insights/sub-page-metric"; import { cn } from "@/lib/utils"; +import { PageHeader } from "@/components/ui/page-header"; const INSIGHT_METRIC_RETURN_PATHS: Readonly> = Object.fromEntries( @@ -333,95 +334,101 @@ export function SubPageShell({ with and without a nudge now align. `items-start` keeps the Coach icon at the top of the row when the title wraps to two lines. */} -
-
-

- {title} -

- {/* v1.8.6 — the diversity nudge rides the heading as a - `Lightbulb` glyph (the node self-gates to nothing when the - spread is healthy), replacing the old inline block. */} - {diversityNudge} - {badge ? ( - - {badge} - - ) : null} -
- {/* Header action cluster, pinned top-right at heading height by - the row's `justify-between` so it aligns with the title - regardless of badge / glyph presence or a wrapped title. + {/* The heading row is the canonical PageHeader: the H1 on the + page-title tier, a programmatic focus target after a client + navigation, with the diversity nudge and the badge beside it + (outside the heading's name) and the action cluster pinned + top-right. The row keeps a constant `min-h-10` so the baseline + holds whether or not the 44 px nudge glyph is present. */} + + {/* v1.8.6 — the diversity nudge rides the heading as a + `Lightbulb` glyph (the node self-gates to nothing when + the spread is healthy). */} + {diversityNudge} + {badge ? ( + + {badge} + + ) : null} + + ) : undefined + } + actions={ + <> + {/* Header action cluster, pinned top-right at heading height by + the row's `justify-between` so it aligns with the title + regardless of badge / glyph presence or a wrapped title. - The target-adjust gear sits to the LEFT of the Coach icon. - It self-gates on a registered editable target (a metric with - no numeric band registers nothing, so the gear stays hidden), - and the Coach icon self-gates on the Coach flag + per-user - opt-out — so the cluster collapses cleanly to one, both, or - neither control. + The target-adjust gear sits to the LEFT of the Coach icon. + It self-gates on a registered editable target (a metric with + no numeric band registers nothing, so the gear stays hidden), + and the Coach icon self-gates on the Coach flag + per-user + opt-out — so the cluster collapses cleanly to one, both, or + neither control. - `gap-3` (12 px) keeps the siblings' extended hit areas - (`before:-inset-1.5`, 6 px per edge) from overlapping — - with a tighter gap the later sibling's invisible halo sat - on top of its neighbour's clickable edge. */} -
- {headerAction} - {captureHref ? ( - - ) : null} - {/* v1.16.8 — "show all readings" rides the header cluster - as an icon button, LEFT of the target-adjust gear (it - used to be a full-width outline button at the page foot). - Same 40 px box + extended hit area as its siblings; the - label travels via aria-label + title. */} - {showAllValuesType ? ( - - ) : null} - - {/* v1.18.6 — the per-metric Coach launch icon is - gone. With the Coach FAB anchored bottom-right on every - authenticated page, a second Coach affordance in each - metric header was redundant. The `coachLaunch` prop is - still accepted (every metric page passes it) but no longer - paints a control — the FAB is the single Coach entry. */} - {/* v1.16.8 — the v1.16.4 customise cog is gone: the sticky - tab strip above already carries the same control on every - insights surface, so the header copy was a duplicate. */} -
-
+ `gap-3` (12 px) keeps the siblings' extended hit areas + (`before:-inset-1.5`, 6 px per edge) from overlapping — + with a tighter gap the later sibling's invisible halo sat + on top of its neighbour's clickable edge. */} +
+ {headerAction} + {captureHref ? ( + + ) : null} + {/* v1.16.8 — "show all readings" rides the header cluster + as an icon button, LEFT of the target-adjust gear (it + used to be a full-width outline button at the page foot). + Same 40 px box + extended hit area as its siblings; the + label travels via aria-label + title. */} + {showAllValuesType ? ( + + ) : null} + + {/* v1.18.6 — the per-metric Coach launch icon is + gone. With the Coach FAB anchored bottom-right on every + authenticated page, a second Coach affordance in each + metric header was redundant. The `coachLaunch` prop is + still accepted (every metric page passes it) but no longer + paints a control — the FAB is the single Coach entry. */} + {/* v1.16.8 — the v1.16.4 customise cog is gone: the sticky + tab strip above already carries the same control on every + insights surface, so the header copy was a duplicate. */} +
+ + } + /> {explainerMetric ? ( // v1.8.4 — surface the metric's static definition inline, // directly beneath the heading. diff --git a/src/components/medications/medication-detail-summary.tsx b/src/components/medications/medication-detail-summary.tsx index 5cabdaf00..54e4bf7ee 100644 --- a/src/components/medications/medication-detail-summary.tsx +++ b/src/components/medications/medication-detail-summary.tsx @@ -27,6 +27,7 @@ import { useDateFormatPreference, } from "@/lib/i18n/context"; import { formatDate as formatCalendarDate } from "@/lib/date-format"; +import { PageHeader } from "@/components/ui/page-header"; export interface MedicationDetailSummaryProps { name: string; @@ -109,8 +110,7 @@ export function MedicationDetailSummary({ return (
-

{name}

-

{dose}

+
-
-

- {t(`mentalHealth.instrument.${key}`)} -

-

- {t(`mentalHealth.instrumentDescription.${key}`)} -

-
+

{t("mentalHealth.saved")} diff --git a/src/components/mental-health/check-in-wizard.tsx b/src/components/mental-health/check-in-wizard.tsx index fbabe3364..4613839d2 100644 --- a/src/components/mental-health/check-in-wizard.tsx +++ b/src/components/mental-health/check-in-wizard.tsx @@ -45,6 +45,7 @@ import { UNANSWERED, } from "./check-in-nav"; import type { InstrumentId } from "./types"; +import { PageHeader } from "@/components/ui/page-header"; const FUNCTIONAL_SCALE = [0, 1, 2, 3] as const; @@ -142,13 +143,10 @@ export function CheckInWizard({ {/* In-test header — instrument title + ONE standardized explanation line. No disclaimer here (§2); the landing carries it. */}

- {/* Wizard hero H1: intentionally semibold, same hero exception as onboarding (UI-STANDARDS §5). Do not sweep to font-bold. */} -

- {t(`mentalHealth.instrument.${key}`)} -

-

- {t(`mentalHealth.instrumentDescription.${key}`)} -

+ {/* Honest translation note: the instrument is validated in its source language; the surrounding chrome stays localized. */} {!hasValidatedItems(instrument, locale) && ( diff --git a/src/components/ui/__tests__/page-header-focus.test.tsx b/src/components/ui/__tests__/page-header-focus.test.tsx new file mode 100644 index 000000000..cf1318138 --- /dev/null +++ b/src/components/ui/__tests__/page-header-focus.test.tsx @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { createRef } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { PageHeader } from "../page-header"; + +describe(" heading options", () => { + it("is a plain h1 by default, with no tab stop", () => { + const html = renderToStaticMarkup(); + expect(html).toMatch( + /]*class="[^"]*text-2xl font-bold tracking-tight/, + ); + expect(html).not.toContain("tabindex"); + }); + + it("becomes a programmatic focus target with a visible ring", () => { + const ref = createRef(); + const html = renderToStaticMarkup( + , + ); + expect(html).toMatch(/]*tabindex="-1"/); + expect(html).toContain("focus-visible:ring-2"); + }); + + it("keeps the accessory beside the heading, outside its name", () => { + const html = renderToStaticMarkup( + Beta} />, + ); + expect(html).toMatch(/]*>Pulse<\/h1>Beta<\/span>/); + }); +}); diff --git a/src/components/ui/page-header.tsx b/src/components/ui/page-header.tsx index 7b52036e7..141cd8b2c 100644 --- a/src/components/ui/page-header.tsx +++ b/src/components/ui/page-header.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from "react"; +import type { ReactNode, Ref } from "react"; import { BackLink } from "@/components/ui/back-link"; import { cn } from "@/lib/utils"; @@ -25,6 +25,14 @@ import { cn } from "@/lib/utils"; * row — and only one of the two may be a real `

`. Without this the two * shells hand-rolled the whole block to get the second copy, which is how * they drifted apart from every module page in the first place. + * + * `focusable` makes the H1 a programmatic focus target (`tabIndex={-1}` + * with a visible ring) and `headingRef` hands the caller the element: a + * sub-page or a result screen moves focus to its title after a client + * navigation. `titleAccessory` sits beside the H1 and + * wraps with it (a nudge glyph, a badge) without becoming part of the + * heading's accessible name. Both exist so the insights sub-page shell and + * the check-in screens stop hand-rolling their own heading. */ /** * A long one-word German title (the illness page was once @@ -44,6 +52,9 @@ export function PageHeader({ topSlot, actions, stackActionsOnPhone = false, + headingRef, + focusable = false, + titleAccessory, className, }: { title: ReactNode; @@ -61,6 +72,12 @@ export function PageHeader({ * the title and leave this off. */ stackActionsOnPhone?: boolean; + /** The H1 element, for a caller that moves focus to it. */ + headingRef?: Ref; + /** Makes the H1 a programmatic focus target with a visible ring. */ + focusable?: boolean; + /** Beside the H1, outside its accessible name (a nudge glyph, a badge). */ + titleAccessory?: ReactNode; className?: string; }) { const heading = @@ -69,10 +86,27 @@ export function PageHeader({ {title}

) : ( -

+

{title}

); + const titleBlock = titleAccessory ? ( +
+ {heading} + {titleAccessory} +
+ ) : ( + heading + ); // Title, description and actions share one grid. On a phone the // description spans the full row under the title + actions: a header with @@ -91,7 +125,7 @@ export function PageHeader({ actions && stackActionsOnPhone && "max-sm:grid-cols-1", )} > - {heading} + {titleBlock} {actions ? (