diff --git a/src/components/insights/__tests__/health-score-card-geometry.test.tsx b/src/components/insights/__tests__/health-score-card-geometry.test.tsx new file mode 100644 index 000000000..69617da29 --- /dev/null +++ b/src/components/insights/__tests__/health-score-card-geometry.test.tsx @@ -0,0 +1,470 @@ +import { createRequire } from "node:module"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { Browser, Page } from "@playwright/test"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { locales, type Locale } from "@/lib/i18n/config"; +import type { + HealthScoreReport, + ScorePillarResult, +} from "@/lib/analytics/score/types"; +import { HeroStrip } from "../hero-strip"; + +/** + * The hero band's geometry, measured rather than asserted from source. + * + * Two numbers in `health-score-card.tsx` cannot be checked by reading the + * file: the height the loading reserve holds, and the width of the row's + * label column. Both were carried for a while as docblock claims with no way + * to reproduce them. This file is that way: it renders the REAL band with the + * REAL compiled stylesheet, lays it out in a browser at the real column + * width, and reads the boxes back. + * + * What it does NOT do is pretend to know the shipped typeface. The app loads + * Inter through `next/font/google` at build time; no font file is committed, + * so a headless browser here resolves the `font-sans` fallback chain instead. + * Every assertion below is therefore written to be font-independent — a + * relative comparison inside one run, or a structural property (does the row + * push, does the label ellipsise, is the bar still there) that holds in any + * typeface. The absolute pixel numbers this file prints are true of the run + * that printed them and are reported as such, never baked into a comment as + * if they were a property of the design. + * + * It needs a Chromium build. `pnpm test` on a machine that has one (any + * machine that has run `pnpm e2e`) measures for real; a machine without one + * skips loudly rather than passing quietly. The unit CI job installs no + * browsers, so treat this as a check for the person editing the panel and for + * the release audit, not as the gate. + */ + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ user: { unitPreference: "metric", glucoseUnit: "mg/dL" } }), +})); + +/** The `md` column: `md:basis-[22rem]`, i.e. 352 CSS px. */ +const COLUMN_PX = 352; +/** Wide enough for the two-column split, narrow enough to stay below `xl`. */ +const VIEWPORT_PX = 1024; +/** One `text-xs` row is a 16 px line; two lines would exceed this. */ +const ONE_LINE_PX = 20; +/** + * The row's info trigger carries `-mx-2` so its 44 px tap target does not + * inflate a 20 px row: it reaches 8 px into the card's own padding on each + * side, which is deliberate and stays inside the panel. Anything past that is + * a real push, so the row check allows exactly this much and no more. + */ +const TAP_TARGET_OVERHANG_PX = 8; + +const require = createRequire(import.meta.url); + +/** + * Compile `globals.css` the way the app does. + * + * `postcss` is not a direct dependency — it arrives under the Tailwind + * PostCSS plugin, which is. Resolving it from that plugin's own location is + * what keeps this working under pnpm's strict layout without adding a + * dependency for a check. + */ +async function compileStylesheet(): Promise { + const pluginEntry = require.resolve("@tailwindcss/postcss"); + const postcss = require(require.resolve("postcss", { paths: [pluginEntry] })); + const tailwind = require(pluginEntry); + const fs = await import("node:fs/promises"); + const source = await fs.readFile("src/app/globals.css", "utf8"); + const plugin = tailwind.default ? tailwind.default() : tailwind(); + const result = await postcss([plugin]).process(source, { + from: "src/app/globals.css", + }); + return result.css; +} + +const provenance = { + inputs: ["BLOOD_PRESSURE"], + source: "live" as const, + windowDays: 90, + computedAt: "2026-07-28T12:00:00.000Z", +}; + +const coverage = { + requiredInputs: 3, + presentInputs: 3, + historyDays: 28, + missing: [] as string[], +}; + +function scored(id: string, score: number): ScorePillarResult { + return { + id, + domain: "cardiometabolic", + result: { + status: "ok", + value: { + score, + observed: { + value: 126, + unit: "mmHg", + label: "126/78 mmHg", + asOf: "2026-07-27T08:00:00.000Z", + sources: ["MANUAL"], + }, + reference: { + kind: "clinical-threshold", + low: 120, + high: 129, + label: "120 to 129/70 to 79 mmHg", + source: "ESH 2023", + }, + noiseFloor: 1, + deltaEligible: true, + deltaIdentity: `pillar_${id}`, + }, + coverage, + confidence: { score: 100, band: "high" }, + provenance, + }, + } as ScorePillarResult; +} + +/** Every pillar id, so each locale is measured on its own longest label. */ +const ALL_PILLARS = [ + "BLOOD_PRESSURE", + "GLYCAEMIA", + "ACTIVITY", + "SLEEP", + "ADIPOSITY", + "WELLBEING", + "FITNESS", + "LIPIDS", +] as const; + +function reportWith(pillars: ScorePillarResult[]): HealthScoreReport { + return { + composite: { + status: "ok", + value: { + score: 74, + band: "green", + bandSetter: null, + composition: pillars.map((p) => p.id), + noiseFloor: 3, + scoreVersion: 2, + }, + coverage, + confidence: { score: 100, band: "high" }, + provenance, + }, + pillars, + delta: 2, + deltaReason: null, + scoreVersion: 2, + weightGoal: { + status: "insufficient", + coverage: { + requiredInputs: 2, + presentInputs: 0, + historyDays: 0, + missing: [], + }, + provenance: { ...provenance, inputs: ["WEIGHT"] }, + reason: "no_personal_goal", + }, + algorithmNotice: null, + } as HealthScoreReport; +} + +/** N scored rows, the shape the report actually produces (3 to 6, per §5). */ +function reportWithRows(rows: number): HealthScoreReport { + return reportWith( + ALL_PILLARS.slice(0, rows).map((id, i) => scored(id, 80 - i * 4)), + ); +} + +function band(node: React.ReactNode, locale: Locale = "en"): string { + return renderToStaticMarkup( + {node}, + ); +} + +let browser: Browser | null = null; +let page: Page | null = null; +let stylesheet = ""; +let skipReason: string | null = null; + +beforeAll(async () => { + try { + const { chromium } = await import("@playwright/test"); + browser = await chromium.launch(); + } catch (error) { + skipReason = `no Chromium build available (${String(error).slice(0, 120)})`; + // Loud on purpose: a geometry check that quietly skips is the same thing + // as no geometry check, and this repo has shipped that before. + console.warn(`[hero geometry] SKIPPED — ${skipReason}`); + return; + } + stylesheet = await compileStylesheet(); + page = await browser.newPage({ + viewport: { width: VIEWPORT_PX, height: 1200 }, + }); +}, 60_000); + +afterAll(async () => { + await browser?.close(); +}); + +/** Lay the rendered band out and hand the page back for measuring. */ +async function layout(html: string, width = VIEWPORT_PX): Promise { + const target = page; + if (!target) throw new Error("browser page was never created"); + await target.setContent( + ` +
${html}
+ `, + ); + await target.addStyleTag({ content: stylesheet }); + return target; +} + +const PANEL = '[data-slot="health-score-card"]'; +const RESERVE = '[data-slot="health-score-card-skeleton"]'; + +describe("hero band geometry", () => { + it("holds a reserve that sits inside the range the real report produces", async () => { + if (skipReason) return expect(skipReason).toBeTruthy(); + + const heights: Record = {}; + for (const rows of [3, 4, 5, 6]) { + const view = await layout( + band( + , + ), + ); + heights[`rows${rows}`] = await view + .locator(PANEL) + .evaluate((el) => Math.round(el.getBoundingClientRect().height)); + const width = await view + .locator(PANEL) + .evaluate((el) => Math.round(el.getBoundingClientRect().width)); + expect(width, "the panel is not on the md column width").toBe(COLUMN_PX); + } + + const view = await layout( + band( + , + ), + ); + // Measured twice: as it renders, and with the declared minimum removed. + // If the two agree, the constant is decoration and the reserve is really + // whatever its blocks happen to add up to — which is how a reserve drifts + // without anyone touching the number that claims to own it. + const { reserve, natural } = await view.locator(RESERVE).evaluate((el) => { + const rendered = Math.round(el.getBoundingClientRect().height); + const declared = el.style.minHeight; + (el as HTMLElement).style.minHeight = "0px"; + const content = Math.round(el.getBoundingClientRect().height); + (el as HTMLElement).style.minHeight = declared; + return { reserve: rendered, natural: content }; + }); + + console.info( + `[hero geometry] column ${COLUMN_PX}px · panel ${JSON.stringify(heights)} · reserve ${reserve}px (blocks alone ${natural}px)`, + ); + + expect( + natural, + `the reserve's own blocks are ${natural}px, so the declared minimum never binds and the reserve is whatever the blocks happen to be`, + ).toBeLessThan(reserve); + + // The reserve cannot match every report, so it must not be one-sided: a + // three-row account may not be pushed down, a six-row account may not be + // left with a gap that collapses. Between the two is the whole rule. + expect( + reserve, + `reserve ${reserve}px is below a three-row panel (${heights.rows3}px): a short report gets pushed`, + ).toBeGreaterThanOrEqual(heights.rows3); + expect( + reserve, + `reserve ${reserve}px is above a six-row panel (${heights.rows6}px): a long report leaves a collapsing gap`, + ).toBeLessThanOrEqual(heights.rows6); + }, 60_000); + + it("keeps every row on one line in all six locales", async () => { + if (skipReason) return expect(skipReason).toBeTruthy(); + + const widest: Record = {}; + + for (const locale of locales) { + const view = await layout( + band( + scored(id, 80 - i * 4)), + )} + />, + locale, + ), + ); + + const rows = await view.locator(`${PANEL} li[data-status="ok"]`).all(); + expect(rows.length, `${locale}: no rows to measure`).toBe( + ALL_PILLARS.length, + ); + + let localeWidest = { label: "", natural: 0 }; + for (const row of rows) { + const m = await row.evaluate((el) => { + const label = el.querySelector("span"); + const bar = el.querySelector( + '[data-slot="health-score-pillar-bar"]', + )?.parentElement; + const value = el.querySelectorAll("span")[1]; + // The natural width the label would take if nothing constrained it. + const probe = document.createElement("span"); + probe.textContent = label?.textContent ?? ""; + probe.style.cssText = + "position:absolute;visibility:hidden;white-space:nowrap;width:max-content"; + probe.className = label?.className ?? ""; + document.body.appendChild(probe); + const natural = Math.ceil(probe.getBoundingClientRect().width); + probe.remove(); + return { + id: el.getAttribute("data-pillar") ?? "", + text: label?.textContent ?? "", + natural, + labelWidth: Math.round(label?.getBoundingClientRect().width ?? 0), + labelHeight: Math.round(label?.getBoundingClientRect().height ?? 0), + labelOverflow: label + ? getComputedStyle(label).textOverflow + : "none", + barWidth: Math.round(bar?.getBoundingClientRect().width ?? 0), + valueScroll: value?.scrollWidth ?? 0, + valueClient: value?.clientWidth ?? 0, + rowScroll: el.scrollWidth, + rowClient: el.clientWidth, + }; + }); + + // The row is the invariant, not the label: a long name may ellipsise, + // but it may never widen the row or wrap it onto a second line. Drop + // `min-w-0` or `truncate` from the label and the first grid track + // grows to the longest word instead, which is exactly this. + expect( + m.rowScroll, + `${locale}/${m.id}: the row is pushed wider than its column by "${m.text}"`, + ).toBeLessThanOrEqual(m.rowClient + TAP_TARGET_OVERHANG_PX); + expect( + m.labelWidth, + `${locale}/${m.id}: the label track grew past 7rem for "${m.text}"`, + ).toBeLessThanOrEqual(112 + 1); + expect( + m.labelHeight, + `${locale}/${m.id}: "${m.text}" wrapped onto a second line`, + ).toBeLessThanOrEqual(ONE_LINE_PX); + expect( + m.labelOverflow, + `${locale}/${m.id}: "${m.text}" is cut without an ellipsis`, + ).toBe("ellipsis"); + // The label column may not eat the two things the row exists to show. + expect( + m.barWidth, + `${locale}/${m.id}: the fill bar was squeezed out by the label column`, + ).toBeGreaterThan(24); + expect( + m.valueScroll, + `${locale}/${m.id}: the score itself is truncated`, + ).toBeLessThanOrEqual(m.valueClient + 1); + + if (m.natural > localeWidest.natural) { + localeWidest = { label: m.text, natural: m.natural }; + } + } + widest[locale] = localeWidest; + } + + // Reported, never asserted: these widths are the measuring browser's + // fallback typeface, not the shipped Inter, so they say how much room the + // longest name wants in THIS run — not a pixel budget the design owns. + console.info( + `[hero geometry] longest label per locale against a 112px column: ${Object.entries( + widest, + ) + .map(([l, w]) => `${l} "${w.label}" ${w.natural}px`) + .join(" · ")}`, + ); + }, 60_000); + + it.each([ + { width: VIEWPORT_PX, where: "the md column" }, + { width: 360, where: "a 360px phone, stacked under the greeting" }, + ])( + "never scrolls sideways in $where", + async ({ width }) => { + if (skipReason) return expect(skipReason).toBeTruthy(); + + const target = page!; + await target.setViewportSize({ width, height: 1200 }); + // French carries the longest pillar names of the six, so it is the + // width-worst case rather than an arbitrary pick. + const view = await layout( + band( + scored(id, 80 - i * 4)), + )} + />, + "fr", + ), + width, + ); + + const m = await view.locator(PANEL).evaluate((el) => { + let widest = 0; + let culprit = ""; + for (const child of Array.from(el.querySelectorAll("*"))) { + const w = child.getBoundingClientRect().width; + if (w > widest) { + widest = w; + culprit = child.getAttribute("data-slot") ?? child.className; + } + } + return { + scroll: el.scrollWidth, + client: el.clientWidth, + widest: Math.round(widest), + culprit: String(culprit).slice(0, 60), + docScroll: document.documentElement.scrollWidth, + docClient: document.documentElement.clientWidth, + }; + }); + + expect( + m.scroll, + `the panel scrolls its own content: ${JSON.stringify(m)}`, + ).toBeLessThanOrEqual(m.client + 1); + expect( + m.widest, + `a child is wider than the panel: ${JSON.stringify(m)}`, + ).toBeLessThanOrEqual(m.client + 1); + // The page itself, which is what a person actually feels. + expect( + m.docScroll, + `the page scrolls sideways: ${JSON.stringify(m)}`, + ).toBeLessThanOrEqual(m.docClient + 1); + + await target.setViewportSize({ width: VIEWPORT_PX, height: 1200 }); + }, + 60_000, + ); +}); diff --git a/src/components/insights/__tests__/health-score-card.test.tsx b/src/components/insights/__tests__/health-score-card.test.tsx index 32118f471..b879bdcf3 100644 --- a/src/components/insights/__tests__/health-score-card.test.tsx +++ b/src/components/insights/__tests__/health-score-card.test.tsx @@ -350,7 +350,7 @@ describe(" pillars", () => { expect(rest).toContain('data-pillar="BLOOD_PRESSURE" data-status="ok"'); expect(rest).not.toContain('data-pillar="ACTIVITY" data-status="ok"'); // One line per row: a truncating label column, a thin bar, the score. - expect(rest).toContain("grid-cols-[minmax(0,8rem)_1fr_2rem_auto]"); + expect(rest).toContain("grid-cols-[minmax(0,7rem)_1fr_2rem_auto]"); expect(rest).toContain("width:82%"); }); @@ -839,9 +839,12 @@ describe("", () => { // A reserve that is not the panel's width is not a reserve. expect(html).toContain("md:basis-[22rem]"); expect(html).toContain("xl:basis-[26rem]"); - // 480 px: measured against the built panel, which resolves to 440 px at - // three rows and 524 px at six, so the reserve sits in the middle. - expect(html).toContain("min-h-[30rem]"); + // That it declares a floor at all is what this file can honestly check; + // whether the floor is the RIGHT height is a browser question, and + // `health-score-card-geometry.test.tsx` answers it by measuring the panel + // and the reserve side by side. Pinning the literal here would only mean + // the two places have to be edited together. + expect(html).toMatch(/min-h-\[[\d.]+rem\]/); }); it("holds the same column geometry the resolved panel does", () => { diff --git a/src/components/insights/health-score-card.tsx b/src/components/insights/health-score-card.tsx index f6076fb74..59be655b7 100644 --- a/src/components/insights/health-score-card.tsx +++ b/src/components/insights/health-score-card.tsx @@ -106,16 +106,23 @@ const PANEL_CHROME_CLASS = /** * Height the reserve holds while the payload is in flight, so the band does - * not jump when the score lands. Not a guess: the panel was rendered with the - * app's own stylesheet and typeface and measured in a browser at the `md` - * column width of 352 px. A three-row account resolves to 440 px, four rows - * 468, five rows 496, six rows 524 — so 480 px sits in the middle of the 3-to-6 - * row range the report actually produces, and the reserve above and below it - * is symmetric rather than always one-sided. The blocks in the reserve below - * mirror the panel's own, which brings its natural height to 484 px, within - * four pixels of the floor. + * not jump when the score lands. + * + * The rule, not a remembered number: the reserve sits between the shortest + * and the tallest panel the report actually produces (three to six scored + * rows), so a short report is not pushed down and a long one does not leave a + * gap that collapses. It is deliberately near the middle of that range, which + * is the best a single number can do when the panel's height depends on how + * many pillars an account scores. + * + * `health-score-card-geometry.test.tsx` measures all of it in a browser + * against the compiled stylesheet and fails if this value leaves that range — + * and also if the value stops binding at all. It did stop: the blocks below + * added up to more than the minimum declared here, so the reserve was + * whatever the blocks happened to be and this constant was decoration. The + * blocks were trimmed to sit under it again. */ -const PANEL_RESERVE_CLASS = "min-h-[30rem]"; +const PANEL_RESERVE_CLASS = "min-h-[28rem]"; /** * v1.21.2 (A5) — readiness contributor keys the Tension Verdict surfaces. @@ -638,14 +645,15 @@ function PillarRow({
  • - {/* 8rem, not 7: rendered in all six locales, 7rem clipped the longest - label in five of them — "Körperfettverteilung" and "Forma física - medida" by two pixels each, which reads as a bug rather than as a - truncation. 8rem clears every label except the French "Condition - physique mesurée", which needs 161 px and would not fit any column - width this panel can afford. */} + {/* The label truncates; the row never grows. `minmax(0,…)` plus + `min-w-0 truncate` is what makes that structural rather than a + promise — drop either and the track grows to the longest word and + pushes the row. Widening the column does not rescue the long names: + the longest label in five of the six locales overruns any column + this panel can afford, so the extra width would come off the fill + bar and buy a few more characters of one language. */} {t(PILLAR_LABEL_KEY[pillar.id])} @@ -774,8 +782,10 @@ export function HealthScoreCardSkeleton({ className }: { className?: string }) { )} > {/* Block for block, the panel above: label, number, bar, two qualifier - lines, five contributor rows, one not-contributing line, and the - foot with its meter and toggle. */} + lines, three contributor rows, one not-contributing line, and the + foot with its meter and toggle. Three rows rather than five is what + keeps these blocks under the minimum above, so the minimum is the + thing that decides the height. */} @@ -787,8 +797,6 @@ export function HealthScoreCardSkeleton({ className }: { className?: string }) { - -