fix(dashboard): reserve two label lines on trend tiles instead of truncating

The strip tile pinned its label to one line so the value rows share a
baseline, and cut anything longer: uppercase compounds and glucose
contexts ("HERZFREQUENZVARIABILITAET", "GLUKOSE, VOR DEM SCHLAFEN")
ended in an ellipsis. The heading row now reserves two lines at a fixed
height, so the baseline promise holds and the label wraps at a syllable;
a label that would need a third line clamps with its full text in title.
The skeleton reserves the same height.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 15:17:37 +02:00
1 parent 7af2e3d29a
commit e312d99162
3 files changed
+53 -29

No files matched your search

@@ -11,10 +11,13 @@ import { I18nProvider } from "@/lib/i18n/context";
* Maintainer directive 2026-05-14 named three issues with the 5-tile top row:
*
* 1. Headings wrapped to two lines at narrow viewports (e.g. "Blood
* Pressure" on Pixel 5 / 393 px / 2-col grid). Locked the heading
* row to a deterministic `h-5` with `whitespace-nowrap` so even an
* unexpectedly long string never breaks the baseline of the value
* row below.
* Pressure" on Pixel 5 / 393 px / 2-col grid), moving the value row.
* The heading row is a deterministic height so the value row keeps
* its baseline. It reserves TWO lines (`h-10`, v1.43.8): pinned to one
* line it could only keep that promise by cutting the label, and long
* German compounds and glucose contexts ended in an ellipsis on every
* dashboard. A label wraps (hyphenated) inside the reserve; the rare
* third line clamps with the full text in `title`.
* 2. Trend arrow rendered as a wrapping flex child after the unit —
* sometimes wrapped to its own line, sometimes missing entirely
* when slope30 was null. Now sits in a fixed `h-4 w-4` slot with
@@ -27,8 +30,8 @@ import { I18nProvider } from "@/lib/i18n/context";
* tile in the strip to share the same baseline.
*
* These tests pin the load-bearing class names so a future refactor
* that drops `h-5` / `items-baseline` / `whitespace-nowrap` triggers a
* red CI before it reaches the dashboard.
* that drops `h-10` / `items-baseline` / `line-clamp-2` triggers a red CI
* before it reaches the dashboard.
*/
const RISING = { slope: 0.5, direction: "up" as const, confidence: 0.8 };
@@ -53,13 +56,14 @@ describe("<TrendCard> W20a baseline alignment", () => {
/>,
);
// h-5 is the load-bearing class — keeps the value row below at the
// same y-coordinate regardless of the label string's actual width.
expect(html).toContain("h-5");
expect(html).toContain('data-slot="trend-card-label"');
// h-10 is the load-bearing class — keeps the value row below at the
// same y-coordinate whether the label takes one line or two.
expect(html).toMatch(
/class="flex h-10 [^"]*"[^>]*><span[^>]*data-slot="trend-card-label"/,
);
});
it("forbids wrapping on the heading row even with a long label", () => {
it("wraps a long label inside the reserve instead of cutting it", () => {
const html = render(
<TrendCard
// Stress test — the live tile uses dashboard.*Short keys but
@@ -75,8 +79,19 @@ describe("<TrendCard> W20a baseline alignment", () => {
/>,
);
expect(html).toContain("whitespace-nowrap");
expect(html).toContain("truncate");
const label = html.match(
/<span class="([^"]*)" data-slot="trend-card-label" title="([^"]*)"/,
);
expect(label, "label span not found").not.toBeNull();
const [, classes, title] = label!;
// Wraps at a syllable, clamps only past the two reserved lines, and the
// clamp keeps the whole name reachable.
expect(classes).toContain("hyphens-auto");
expect(classes).toContain("break-words");
expect(classes).toContain("line-clamp-2");
expect(classes).not.toContain("truncate");
expect(classes).not.toContain("whitespace-nowrap");
expect(title).toBe("A really long blood pressure metric name");
});
it("renders the value row with baseline alignment", () => {
@@ -166,9 +181,10 @@ describe("<TrendCard> W20a baseline alignment", () => {
/>,
);
// Same heading-row height contract — `h-5` + `whitespace-nowrap`.
expect(html).toContain("h-5");
expect(html).toContain("whitespace-nowrap");
// Same heading-row height contract — two reserved label lines.
expect(html).toMatch(
/class="flex h-10 [^"]*"[^>]*><span[^>]*data-slot="trend-card-label"/,
);
// Same value-row baseline contract — `items-baseline` + `text-3xl`
// + `leading-none`. Identical font geometry across all five tiles
// means the digits land at the same y-coordinate inside the grid.
@@ -31,10 +31,11 @@ export function TrendCardSkeleton({ className }: { className?: string }) {
// legacy empty silhouette reserved.
className={cn("min-h-[8rem]", className)}
>
{/* Heading row — label + icon (TrendCard pins this row at h-5). */}
<div className="flex h-5 items-center justify-between gap-2">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-4 w-4 shrink-0" />
{/* Heading row — label + icon (TrendCard reserves two label lines,
h-10, so the loaded tile does not move the value row). */}
<div className="flex h-10 items-start justify-between gap-2">
<Skeleton className="mt-1 h-3 w-16" />
<Skeleton className="mt-0.5 h-4 w-4 shrink-0" />
</div>
{/* Headline value row — `text-3xl leading-none` ≈ 30 px, plus the
unit slot, baseline-aligned exactly like the loaded tile. */}
+16 -9
View File
@@ -206,20 +206,27 @@ export function TrendCard({
return (
<TrendTile data-slot="trend-card">
{/* v1.4.25 — single-line discipline + deterministic height for
the heading row so the value row below sits at the same baseline
across every tile. The label is locale-abbreviated upstream (see
`dashboard.*Short` keys) so `truncate` is a layout safeguard
rather than the primary defence against wraps. `h-5` matches
`text-xs leading-5` ≈ 20 px; identical across every tile. */}
<div className="flex h-5 min-w-0 items-center justify-between gap-2">
{/* The heading row has a deterministic height so the value row below
sits at the same baseline across every tile in the strip. It
reserves two lines, not one: a one-line row could only keep that
promise by cutting the label, and the label is the only thing that
names the figure. An uppercase German compound
("HERZFREQUENZVARIABILITÄT") or a glucose context ("GLUKOSE, VOR
DEM SCHLAFEN") does not fit a strip tile on one line in any width
the grid hands out, and ended in an ellipsis. `hyphens-auto` splits
a compound at a syllable (driven by the document's `lang`),
`break-words` is the floor, and the rare label that needs a third
line clamps with its full text in `title`. `h-10` is two
`text-xs leading-5` lines; identical across every tile. */}
<div className="flex h-10 min-w-0 items-start justify-between gap-2">
<span
className="text-muted-foreground min-w-0 flex-1 truncate text-xs leading-5 font-medium tracking-wide whitespace-nowrap uppercase"
className="text-muted-foreground line-clamp-2 min-w-0 flex-1 text-xs leading-5 font-medium tracking-wide break-words hyphens-auto uppercase"
data-slot="trend-card-label"
title={label}
>
{label}
</span>
<Icon className="text-muted-foreground h-4 w-4 shrink-0" />
<Icon className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0" />
</div>
{/* v1.4.25 — baseline-aligned value row. The headline value,
unit, and trend arrow share a single flex line with