From 3d494697f71e14c5bdfb67094d747fa40f047342 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 15:17:37 +0200 Subject: [PATCH] fix(health-score): wrap a long pillar name instead of cutting it "Koerperfettverteilung" ended in an ellipsis in the score panel. The label now wraps inside its fixed track with hyphenation, so the row grows a line and the fill bar keeps its width. The geometry check claimed to measure every locale but rendered each one with the English fallback catalog, so no German name was ever laid out. It now primes every catalog and asserts that a name is never cut, takes at most two lines and never widens its track. --- .../health-score-card-geometry.test.tsx | 33 ++++++++++++++----- src/components/insights/health-score-card.tsx | 16 ++++----- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/src/components/insights/__tests__/health-score-card-geometry.test.tsx b/src/components/insights/__tests__/health-score-card-geometry.test.tsx index 448e53764..500824677 100644 --- a/src/components/insights/__tests__/health-score-card-geometry.test.tsx +++ b/src/components/insights/__tests__/health-score-card-geometry.test.tsx @@ -11,6 +11,7 @@ import type { } from "@/lib/analytics/score/types"; import { SCORE_VERSION } from "@/lib/analytics/score/types"; import { HeroStrip } from "../hero-strip"; +import { primeLocales } from "@/__tests__/helpers/prime-locales"; /** * The hero band's geometry, measured rather than asserted from source. @@ -42,6 +43,11 @@ import { HeroStrip } from "../hero-strip"; * chromium` once to measure for real. */ +// Every locale's own catalog, not the English fallback: without it the +// per-locale sweep below measured the English names eight times over and +// never saw a German compound. +primeLocales(...locales); + vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ user: { unitPreference: "metric", glucoseUnit: "mg/dL" } }), })); @@ -52,6 +58,8 @@ const COLUMN_PX = 352; const VIEWPORT_PX = 1024; /** One `text-xs` row is a 16 px line; two lines would exceed this. */ const ONE_LINE_PX = 20; +/** Two `text-xs` lines; a third would mean the name no longer fits. */ +const TWO_LINES_PX = 2 * ONE_LINE_PX; /** * 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 @@ -358,7 +366,7 @@ describe("hero band geometry", () => { ).toBeLessThanOrEqual(heights.rows6); }, 60_000); - it("keeps every row on one line in every locale", async (ctx) => { + it("keeps every row inside its column and every name whole in every locale", async (ctx) => { if (skipReason) ctx.skip(skipReason); const widest: Record = {}; @@ -408,6 +416,8 @@ describe("hero band geometry", () => { labelOverflow: label ? getComputedStyle(label).textOverflow : "none", + labelScroll: label?.scrollWidth ?? 0, + labelClient: label?.clientWidth ?? 0, barWidth: Math.round(bar?.getBoundingClientRect().width ?? 0), valueScroll: value?.scrollWidth ?? 0, valueClient: value?.clientWidth ?? 0, @@ -416,10 +426,11 @@ describe("hero band geometry", () => { }; }); - // 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. + // The row's width is the invariant, and the name is read whole: a + // long name breaks onto a second line at a syllable, it is never cut. + // Drop `min-w-0` from the label and the first grid track grows to the + // longest word instead; drop `hyphens-auto` and a German compound + // overflows its track. expect( m.rowScroll, `${locale}/${m.id}: the row is pushed wider than its column by "${m.text}"`, @@ -428,14 +439,18 @@ describe("hero band geometry", () => { m.labelWidth, `${locale}/${m.id}: the label track grew past 7rem for "${m.text}"`, ).toBeLessThanOrEqual(112 + 1); + expect( + m.labelScroll, + `${locale}/${m.id}: "${m.text}" is cut off inside its track`, + ).toBeLessThanOrEqual(m.labelClient + 1); expect( m.labelHeight, - `${locale}/${m.id}: "${m.text}" wrapped onto a second line`, - ).toBeLessThanOrEqual(ONE_LINE_PX); + `${locale}/${m.id}: "${m.text}" needs more than two lines`, + ).toBeLessThanOrEqual(TWO_LINES_PX); expect( m.labelOverflow, - `${locale}/${m.id}: "${m.text}" is cut without an ellipsis`, - ).toBe("ellipsis"); + `${locale}/${m.id}: "${m.text}" ends in an ellipsis`, + ).not.toBe("ellipsis"); // The label column may not eat the two things the row exists to show. expect( m.barWidth, diff --git a/src/components/insights/health-score-card.tsx b/src/components/insights/health-score-card.tsx index 182082e0e..bb29f39fe 100644 --- a/src/components/insights/health-score-card.tsx +++ b/src/components/insights/health-score-card.tsx @@ -860,14 +860,14 @@ function PillarRow({ data-status="ok" className="grid grid-cols-[minmax(0,7rem)_1fr_2rem_auto] items-center gap-2 text-xs" > - {/* 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. */} - + {/* The label wraps inside a fixed track; the row grows a line rather + than cutting the name. `minmax(0,…)` plus `min-w-0` keeps the track + from growing to the longest word, and `hyphens-auto` splits a + compound ("Körperfett-verteilung") at a syllable instead of ending + it in an ellipsis. Widening the column does not rescue the long + names: the longest label in most locales overruns any column this + panel can afford, and the width would come off the fill bar. */} + {t(PILLAR_LABEL_KEY[pillar.id])}