From 8a2bb70dbd640cd6b4c64b410f78855030290c87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 30 Jul 2026 15:52:21 +0200 Subject: [PATCH] test(ui): gate the two card-header classes that have to agree The header fix needs two declarations to hold together: the second grid row is conditional on a description, and CardAction spans the full row set. The second is the trap. `row-span-2` against a single explicit row mints an implicit second row and brings the phantom gap straight back for every header that carries an action but no description. Reverting it broke nothing. Nothing in the suite asserts a `row-span` class, so the regression is DOM geometry the unit tests cannot see, and it would have come back silently at the next tidy-up. This is a literal-class assertion in the shape `assessment-spine-order.test.ts` already uses, because the question has an exact answer. Also finishes the compact-card tier while it is open: three cards on the metric surfaces still carried `gap-1.5` where the assessment cards carry `gap-2`. One tier, one value, no `gap-1.5 py-3` left in the tree. --- src/components/insights/metric-stat-strip.tsx | 4 +- .../insights/metric-target-summary.tsx | 2 +- .../ui/__tests__/card-header-rows.test.ts | 62 +++++++++++++++++++ 3 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 src/components/ui/__tests__/card-header-rows.test.ts diff --git a/src/components/insights/metric-stat-strip.tsx b/src/components/insights/metric-stat-strip.tsx index 0000a5008..229b8d7a0 100644 --- a/src/components/insights/metric-stat-strip.tsx +++ b/src/components/insights/metric-stat-strip.tsx @@ -258,7 +258,7 @@ export function MetricStatStrip({ data-multi-series="true" role="group" aria-label={groupLabel ?? t("insights.subPage.stats.label")} - className="gap-1.5 py-3 md:py-4" + className="gap-2 py-3 md:py-4" >
@@ -291,7 +291,7 @@ export function MetricStatStrip({ ? `${t("insights.subPage.stats.label")} — ${seriesLabel}` : t("insights.subPage.stats.label") } - className="gap-1.5 py-3 md:py-4" + className="gap-2 py-3 md:py-4" > {/* Row 1: the canonical tile header — a single-word "Ziel" / "Target" heading (the range numbers live on the range bar below, so the diff --git a/src/components/ui/__tests__/card-header-rows.test.ts b/src/components/ui/__tests__/card-header-rows.test.ts new file mode 100644 index 000000000..a8c87be42 --- /dev/null +++ b/src/components/ui/__tests__/card-header-rows.test.ts @@ -0,0 +1,62 @@ +/** + * The Card header reserves its second row only when there is one to fill. + * + * `CardHeader` is a grid. It used to declare `grid-rows-[auto_auto]` + * unconditionally, so a card with a title and no `CardDescription` carried an + * empty second track and drew the header's `gap-2` against it. Together with the + * Card's own `gap-4 md:gap-6` that made the distance from a heading to its body + * 32 px on desktop where 24 px was intended. It was visible, it was reported, and + * it affected 45 of the 48 files that mount a `CardHeader`. + * + * Two class declarations have to agree for the fix to hold: + * + * 1. the second row is conditional on a description being present, and + * 2. `CardAction` spans the full row set rather than a hard-coded two. + * + * The second one is the trap. `row-span-2` against a single explicit row mints + * an implicit second row, which brings the gap straight back for every header + * that carries an action but no description. Reverting it produces no test + * failure anywhere else in the suite, because nothing asserts a `row-span` + * class: the regression is DOM geometry, which the unit tests cannot see. + * + * So this file is the guard. It is deliberately a literal-class assertion, the + * same shape `assessment-spine-order.test.ts` already uses, because the question + * has an exact answer and an exact answer belongs in a gate rather than in a + * reviewer's memory. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const SOURCE = readFileSync( + join(process.cwd(), "src/components/ui/card.tsx"), + "utf8", +); + +describe("CardHeader row reservation", () => { + it("reserves the second row only when a description is present", () => { + expect(SOURCE).toContain( + "has-data-[slot=card-description]:grid-rows-[auto_auto]", + ); + }); + + it("never declares the second row unconditionally", () => { + // The unconditional form is what drew the phantom gap. It may only ever + // appear behind the `has-data-[slot=card-description]:` variant, so the bare + // token must not occur on its own. + const bare = SOURCE.replace( + /has-data-\[slot=card-description\]:grid-rows-\[auto_auto\]/g, + "", + ); + expect(bare).not.toContain("grid-rows-[auto_auto]"); + }); +}); + +describe("CardAction row span", () => { + it("spans the full row set rather than a hard-coded two", () => { + // `row-span-2` would mint an implicit second row in a description-less + // header and resurrect the gap the header fix just removed. + expect(SOURCE).toContain("row-span-full"); + expect(SOURCE).not.toContain("row-span-2"); + }); +});