From 160da978e698544c33d8e162df8cd028f83d229d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 16:38:29 +0200 Subject: [PATCH] fix(insights): keep grid tiles on one baseline when a title wraps Removing truncate from the grid tiles let a long title take a second line and push its value and sparkline below the neighbours'. TileHeader takes reserveLines={2}, a fixed two-line row with a clamp and the full title in title, as the dashboard trend strip already does. The vitals tiles, their building state and skeleton, the device score tiles and the resilience tile use it, and a test holds those files to it. --- .../__tests__/tile-header-wrap.test.tsx | 76 +++++++++++++++++++ .../insights/derived/sparkline-delta-tile.tsx | 1 + .../insights/derived/vitals-dashboard.tsx | 10 ++- src/components/insights/device-score-tile.tsx | 1 + .../insights/recovery/resilience-tile.tsx | 1 + src/components/insights/tile-header.tsx | 30 +++++++- 6 files changed, 113 insertions(+), 6 deletions(-) diff --git a/src/components/insights/__tests__/tile-header-wrap.test.tsx b/src/components/insights/__tests__/tile-header-wrap.test.tsx index e578f4879..111fced1a 100644 --- a/src/components/insights/__tests__/tile-header-wrap.test.tsx +++ b/src/components/insights/__tests__/tile-header-wrap.test.tsx @@ -97,3 +97,79 @@ describe("no card title is cut", () => { expect(found).toHaveLength(1); }); }); + +/** + * A tile in a grid row reserves two title lines, so a wrapping title does not + * push its value and sparkline below the neighbours'. Proven on the header + * row's fixed height, and held for the grid tiles by a source check. + */ +describe("", () => { + function headerRow(html: string): string { + const match = html.match(/]*data-slot="tile-header"[^>]*>/); + expect(match, "tile header not rendered").not.toBeNull(); + return match![0]; + } + + it("gives a one-line and a two-line title the same row height", () => { + const short = headerRow( + renderToStaticMarkup( + , + ), + ); + const long = headerRow( + renderToStaticMarkup( + , + ), + ); + expect(short).toBe(long); + expect(short).toContain("h-10"); + expect(short).toContain("items-start"); + const sm = headerRow( + renderToStaticMarkup( + , + ), + ); + expect(sm).toContain("h-9"); + }); + + it("clamps a third line and keeps the full title readable", () => { + const html = renderToStaticMarkup( + , + ); + expect(titleClasses(html)).toContain("line-clamp-2"); + expect(html).toContain('title="Herzfrequenzvariabilität"'); + }); + + it("counter-check: without the reserve the row has no fixed height", () => { + const row = headerRow( + renderToStaticMarkup(), + ); + expect(row).not.toMatch(/\bh-(9|10)\b/); + expect(row).toContain("items-center"); + }); + + const GRID_TILES = [ + "components/insights/derived/sparkline-delta-tile.tsx", + "components/insights/derived/vitals-dashboard.tsx", + "components/insights/device-score-tile.tsx", + "components/insights/recovery/resilience-tile.tsx", + ]; + + it.each(GRID_TILES)("%s reserves the title lines", (rel) => { + const source = stripComments( + readFileSync(join(process.cwd(), "src", rel), "utf8"), + ); + const headers = (source.match(/ - +

{message}

@@ -664,9 +664,11 @@ function VitalsTileSkeleton() { aria-hidden="true" className="gap-3" > -
- - + {/* The same two-line reserve the tile headers keep, so the real + tiles drop in without moving the value row. */} +
+ +
diff --git a/src/components/insights/device-score-tile.tsx b/src/components/insights/device-score-tile.tsx index ca7ca2323..a74915df8 100644 --- a/src/components/insights/device-score-tile.tsx +++ b/src/components/insights/device-score-tile.tsx @@ -117,6 +117,7 @@ export function DeviceScoreTile({ size="sm" icon={Icon} title={title} + reserveLines={2} className="min-w-0" right={ latest != null ? ( diff --git a/src/components/insights/recovery/resilience-tile.tsx b/src/components/insights/recovery/resilience-tile.tsx index a52224f02..4bb004158 100644 --- a/src/components/insights/recovery/resilience-tile.tsx +++ b/src/components/insights/recovery/resilience-tile.tsx @@ -97,6 +97,7 @@ export function ResilienceTile() { size="sm" icon={ShieldCheck} title={t("measurements.typeResilience")} + reserveLines={2} className="min-w-0" right={ bandKey != null ? ( diff --git a/src/components/insights/tile-header.tsx b/src/components/insights/tile-header.tsx index 17621fd20..f2071730c 100644 --- a/src/components/insights/tile-header.tsx +++ b/src/components/insights/tile-header.tsx @@ -61,6 +61,17 @@ interface TileHeaderProps { * section). Default (unset) keeps every other tile a plain CardTitle. */ titleAs?: "h2" | "h3"; + /** + * Reserve two title lines at a fixed height, for a tile that sits in a + * grid row beside others. A title that wraps ("Herzfrequenzvariabilität" + * on a narrow tile) would otherwise push its value and sparkline below + * the neighbours' and the row stops reading as one line; with the reserve + * every tile's body starts at the same height whether its title takes one + * line or two. A title that would need a third line clamps, with its full + * text in `title` when it is a string. The dashboard trend strip does the + * same for its labels (`trend-card.tsx`). + */ + reserveLines?: 2; } export function TileHeader({ @@ -71,6 +82,7 @@ export function TileHeader({ className, id, titleAs, + reserveLines, }: TileHeaderProps) { const Heading = titleAs ?? "h2"; // The title wraps; it never truncates. A tile title is the only thing that @@ -83,14 +95,26 @@ export function TileHeader({ // line clear of the first; on one line it matches the 20 px icon box, so a // single-line header is the height it was. There is no class override: // every caller used one to add `truncate`. + const reserve = reserveLines === 2; const titleClassNames = cn( "min-w-0 leading-tight break-words hyphens-auto", size === "sm" ? "text-sm" : "text-base", + reserve && "line-clamp-2", ); + const fullTitle = reserve && typeof title === "string" ? title : undefined; return (
{/* Icon and heading share the foreground colour — the calm, high-contrast read the "Einschätzung" card established. */} @@ -99,6 +123,7 @@ export function TileHeader({ className={cn( "text-foreground shrink-0", size === "sm" ? "h-4 w-4" : "h-5 w-5", + reserve && size === "sm" && "mt-px", )} aria-hidden="true" /> @@ -109,12 +134,13 @@ export function TileHeader({ {title} ) : ( - + {title} )}