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} )}