Files
HealthLog/src/components/insights/section-heading.tsx
T
Marc-André Bombeck 29188b2262 fix(insights): let a section heading's trailing caption wrap instead of widening the page
The Insights overview panned sideways at every phone width. SectionHeading
pinned its trailing action slot with shrink-0, which sizes a flex item to its
max-content width. That slot was designed for a compact control, but every
caller passes prose today — the provenance method caption, the device-score
subtitle, the sleep-quality subtitle. A sentence at max-content measures
1229 px in English and 1464 px in German, and since the row sits inside the
AuthShell <main> — whose overflow-y:auto makes overflow-x compute to auto —
that width turned the page into a horizontal scroller. min-w-0 lifts the flex
min-width:auto floor so the caption wraps to the row instead; the parent's
flex-wrap still moves the slot to its own line before anything is squeezed.

The existing width guards could not see this. Both measured
documentElement.scrollWidth, which never budges when <main> absorbs the
overflow into its own scroll area, and the insights case ran against an empty
mocked payload that fits any viewport by construction.

- Add e2e/utils/horizontal-overflow.ts: asserts the document AND the <main>
  scroll container, and names the widest offending element on failure.
- Add e2e/mobile-horizontal-overflow.spec.ts: sweeps /insights, /, /measurements
  and /medications at 360/390/393/402/430 px against populated fixtures, plus a
  canary proving the measurement can see through <main>, and a check that the
  insights fixture really populates the page.
- Rewrite the cycle spec's empty payload as a populated one and sweep every
  phone width; its /insights case moves to the new spec.
- Point mobile-density.spec.ts at the shared assertion — it carried the same
  document-only blind spot.
2026-07-26 17:44:15 +02:00

103 lines
4.1 KiB
TypeScript

import type { ComponentType, ReactNode } from "react";
import { cn } from "@/lib/utils";
/**
* v1.15.10 — the one section heading for the Insights overview.
*
* Every top-level overview section (health scores, daily briefing, vitals,
* trends, period-in-review, cycle summary, signals of the day) leads with
* this identical heading ABOVE its card: a leading Lucide icon and an `<h2>`
* title, both in the foreground colour (white in dark mode — never a per-
* section accent hue), at one size + weight. An optional `action` slot pins a
* trailing affordance (a subtitle, a meta control) to the right edge without
* breaking the row's alignment.
*
* The overview used to mix three heading dialects: a `text-lg` `<h2>` with a
* purple Sparkles glyph (briefing), a `text-lg` `<h2>` with NO icon (trends),
* a `text-base` `TileHeader` with a white icon (scores / vitals), and in-card
* titles buried inside the period-narrative, cycle-summary and signals cards.
* Side by side they read as four different surfaces. `SectionHeading` pins the
* single contract so the system can't drift: icon `h-5 w-5 shrink-0
* text-foreground`, title `<h2>` `text-base font-semibold`, `gap-2` between
* icon and title. The caller owns the `space-y-3` gap to the card below.
*
* RSC-safe: no hooks, no browser API.
*/
interface SectionHeadingProps {
/**
* Leading glyph. The caller passes the Lucide component itself
* (`icon={Sparkles}`), not a pre-sized node — `SectionHeading` owns the
* size and colour so every overview heading matches.
*/
icon: ComponentType<{ className?: string }>;
/** Heading text rendered inside the `<h2>`. */
title: ReactNode;
/**
* Optional one-line description rendered directly under the `<h2>`, in the
* same muted typography the detail pages use beneath their headings
* (`text-muted-foreground text-sm`). Opt-in per section so the overview can
* carry the "descriptive line under every heading" rule without forcing a
* subtitle where none reads well.
*/
subtitle?: ReactNode;
/** Optional trailing affordance pinned to the right edge of the row. */
action?: ReactNode;
/** Optional id on the `<h2>`, e.g. for an `aria-labelledby` link. */
id?: string;
className?: string;
}
export function SectionHeading({
icon: Icon,
title,
subtitle,
action,
id,
className,
}: SectionHeadingProps) {
return (
<div
data-slot="section-heading"
className={cn(
"flex flex-wrap items-center justify-between gap-2",
className,
)}
>
<div className="flex min-w-0 items-center gap-2">
<Icon className="text-foreground h-5 w-5 shrink-0" aria-hidden="true" />
<div className="min-w-0">
<h2 id={id} className="text-base font-semibold">
{title}
</h2>
{subtitle ? (
<p
data-slot="section-heading-subtitle"
className="text-muted-foreground text-sm"
>
{subtitle}
</p>
) : null}
</div>
</div>
{action ? (
// The action slot must be able to SHRINK. It was `shrink-0`, which
// pins a flex item at its max-content width — fine for the compact
// control the slot was designed for, wrong for the prose every real
// caller passes today (the provenance method caption, the
// device-score subtitle, the sleep-quality subtitle). A sentence at
// max-content is ~1.2-1.5k px, and because the row sits inside the
// AuthShell `<main>` — whose `overflow-y:auto` makes `overflow-x`
// compute to `auto` — that width became a horizontally scrollable
// page at every phone width, growing with the locale's string length.
// `min-w-0` lifts the flex `min-width:auto` floor so the text can wrap
// to the row width instead. The parent's `flex-wrap` still moves the
// slot onto its own line before anything is squeezed, so a compact
// control keeps its size.
<div className="flex min-w-0 items-center gap-2">{action}</div>
) : null}
</div>
);
}