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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 16:38:29 +02:00
1 parent 024620a7f1
commit 160da978e6
6 files changed
+113 -6

No files matched your search

@@ -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("<TileHeader reserveLines>", () => {
function headerRow(html: string): string {
const match = html.match(/<div[^>]*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(
<TileHeader icon={Heart} title="Puls" reserveLines={2} />,
),
);
const long = headerRow(
renderToStaticMarkup(
<TileHeader
icon={Heart}
title="Herzfrequenzvariabilität im Schlaf"
reserveLines={2}
/>,
),
);
expect(short).toBe(long);
expect(short).toContain("h-10");
expect(short).toContain("items-start");
const sm = headerRow(
renderToStaticMarkup(
<TileHeader size="sm" icon={Heart} title="Puls" reserveLines={2} />,
),
);
expect(sm).toContain("h-9");
});
it("clamps a third line and keeps the full title readable", () => {
const html = renderToStaticMarkup(
<TileHeader
icon={Heart}
title="Herzfrequenzvariabilität"
reserveLines={2}
/>,
);
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(<TileHeader icon={Heart} title="Puls" />),
);
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(/<TileHeader\b/g) ?? []).length;
const reserved = (source.match(/\breserveLines=\{2\}/g) ?? []).length;
expect(headers, rel).toBeGreaterThan(0);
expect(reserved, rel).toBe(headers);
});
});
@@ -163,6 +163,7 @@ export function SparklineDeltaTile({
icon={Icon}
title={label}
titleAs="h2"
reserveLines={2}
className="min-w-0"
right={
provenance ? (
@@ -642,7 +642,7 @@ function BuildingTile({
data-state={state}
className="gap-3"
>
<TileHeader icon={icon} title={title} titleAs="h2" />
<TileHeader icon={icon} title={title} titleAs="h2" reserveLines={2} />
<p className="text-muted-foreground text-sm" data-slot={messageSlot}>
{message}
</p>
@@ -664,9 +664,11 @@ function VitalsTileSkeleton() {
aria-hidden="true"
className="gap-3"
>
<div className="flex items-center justify-between gap-2">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-4 w-4 rounded" />
{/* The same two-line reserve the tile headers keep, so the real
tiles drop in without moving the value row. */}
<div className="flex h-10 items-start justify-between gap-2">
<Skeleton className="mt-1 h-3 w-24" />
<Skeleton className="mt-0.5 h-4 w-4 rounded" />
</div>
<Skeleton className="h-8 w-20" />
<Skeleton className="h-3 w-3/4" />
@@ -117,6 +117,7 @@ export function DeviceScoreTile({
size="sm"
icon={Icon}
title={title}
reserveLines={2}
className="min-w-0"
right={
latest != null ? (
@@ -97,6 +97,7 @@ export function ResilienceTile() {
size="sm"
icon={ShieldCheck}
title={t("measurements.typeResilience")}
reserveLines={2}
className="min-w-0"
right={
bandKey != null ? (
+28 -2
View File
@@ -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 (
<div
data-slot="tile-header"
className={cn("flex items-center gap-2", className)}
data-reserve-lines={reserve ? 2 : undefined}
className={cn(
"flex gap-2",
// Two `leading-tight` lines: 2 × 20 px at `text-base`, 2 × 17.5 px
// at `text-sm`. The icon then sits on the first line.
reserve
? cn("items-start", size === "sm" ? "h-9" : "h-10")
: "items-center",
className,
)}
>
{/* 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({
<Heading
id={id}
data-slot="card-title"
title={fullTitle}
className={cn("font-semibold", titleClassNames)}
>
{title}
</Heading>
) : (
<CardTitle id={id} className={titleClassNames}>
<CardTitle id={id} title={fullTitle} className={titleClassNames}>
{title}
</CardTitle>
)}