mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Measure the hero panel instead of describing it
The reserve and the row's label column were carried as comments with pixel figures nobody could reproduce: the tool that produced them was never committed. This adds one that is, and the figures change. The reserve did not hold anything. Its declared minimum was 30rem while its own blocks added up to more than that, so the height was whatever the blocks happened to be and the constant was decoration. The blocks are back under the floor and the floor is 28rem, which sits between the shortest and the tallest panel a real report produces: three scored rows come to 410 pixels at the 352 pixel column, four 438, five 466, six 494, and the reserve is 448. A short report is no longer pushed down by 74 pixels when the score lands. The label column goes back to the 7rem the design specifies. It had been widened to 8rem on the grounds that 7rem clipped the longest label in five of six languages and 8rem cleared them; measured, the longest label overruns both widths in five of six, and the only difference is English, by a margin smaller than the error introduced by measuring in a substitute typeface. The extra width came off the fill bar for nothing. The new check renders the real band with the compiled stylesheet and reads the boxes back in a browser: the reserve against the panel it stands in for, every row in all six languages on one line with an ellipsis rather than a push, and no sideways scroll at the 352 pixel column or at 360 pixels stacked. It states plainly that it cannot know the shipped typeface, since the font is fetched at build time and no font file is committed, so every assertion in it is one that holds in any typeface.
This commit is contained in:
1 parent
06fdeb0336
commit
cc7978a573
3 files changed
+505
-24
No files matched your search
@@ -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<string> {
|
||||
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(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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<Page> {
|
||||
const target = page;
|
||||
if (!target) throw new Error("browser page was never created");
|
||||
await target.setContent(
|
||||
`<!doctype html><html class="dark"><body class="font-sans bg-background" style="margin:0">
|
||||
<div style="width:${width}px">${html}</div>
|
||||
</body></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<string, number> = {};
|
||||
for (const rows of [3, 4, 5, 6]) {
|
||||
const view = await layout(
|
||||
band(
|
||||
<HeroStrip
|
||||
briefing={null}
|
||||
now={new Date("2026-05-10T07:00:00Z")}
|
||||
healthScore={reportWithRows(rows)}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
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(
|
||||
<HeroStrip
|
||||
briefing={null}
|
||||
now={new Date("2026-05-10T07:00:00Z")}
|
||||
scorePending
|
||||
/>,
|
||||
),
|
||||
);
|
||||
// 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<string, { label: string; natural: number }> = {};
|
||||
|
||||
for (const locale of locales) {
|
||||
const view = await layout(
|
||||
band(
|
||||
<HeroStrip
|
||||
briefing={null}
|
||||
now={new Date("2026-05-10T07:00:00Z")}
|
||||
healthScore={reportWith(
|
||||
ALL_PILLARS.map((id, i) => 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(
|
||||
<HeroStrip
|
||||
briefing={null}
|
||||
now={new Date("2026-05-10T07:00:00Z")}
|
||||
healthScore={reportWith(
|
||||
ALL_PILLARS.map((id, i) => 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,
|
||||
);
|
||||
});
|
||||
@@ -350,7 +350,7 @@ describe("<HealthScoreCard> 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("<HealthScoreCardSkeleton>", () => {
|
||||
// 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", () => {
|
||||
|
||||
@@ -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({
|
||||
<li
|
||||
data-pillar={pillar.id}
|
||||
data-status="ok"
|
||||
className="grid grid-cols-[minmax(0,8rem)_1fr_2rem_auto] items-center gap-2 text-xs"
|
||||
className="grid grid-cols-[minmax(0,7rem)_1fr_2rem_auto] items-center gap-2 text-xs"
|
||||
>
|
||||
{/* 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. */}
|
||||
<span className="text-muted-foreground min-w-0 truncate">
|
||||
{t(PILLAR_LABEL_KEY[pillar.id])}
|
||||
</span>
|
||||
@@ -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. */}
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-15 w-28" />
|
||||
<Skeleton className="h-2 w-full rounded-full" />
|
||||
@@ -787,8 +797,6 @@ export function HealthScoreCardSkeleton({ className }: { className?: string }) {
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-36" />
|
||||
<div className="mt-auto space-y-3 pt-1">
|
||||
|
||||
Reference in new issue
Block a user