fix(ui): card density is a Card prop, and compact is compact on desktop

Nineteen cards spelled the compact tier by hand as gap-2 py-3 md:py-4,
which left the base md:gap-6 in place: a compact card opened a 24 px
gap between header and body on a desktop. Card takes density="compact"
now and pins the gap on both breakpoints; every hand-written compact
card, the priority card, the document card and the sample briefing
move onto it, and a test refuses the class list on a Card.

The precision guard on the steps page now reads rendered text only:
the page header's grid template (minmax(0,1fr)) matched its pattern.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 13:49:25 +02:00
1 parent 6885377f35
commit 3cc3829fba
27 files changed
+111 -47

No files matched your search

@@ -181,13 +181,11 @@ describe("assessment presentation contract", () => {
"utf8",
);
expect(cardSource).toContain(
'className="animate-insight-in gap-2 py-3 md:py-4"',
);
expect(cardSource).toContain('density="compact"');
expect(cardSource).toContain('className="animate-insight-in"');
expect(cardSource).toContain('className="text-foreground text-sm"');
expect(moodSource).toContain(
'className="animate-insight-in gap-2 py-3 md:py-4"',
);
expect(moodSource).toContain('density="compact"');
expect(moodSource).toContain('className="animate-insight-in"');
expect(moodSource).toContain('data-slot="insight-assessment"');
expect(moodBodySource).toContain(
'className="text-foreground mb-2 text-sm"',
+1 -1
View File
@@ -204,7 +204,7 @@ export default function InsightsBlutdruckPage() {
// edge, ~16–24 px left of every neighbouring card's text) — and
// foreground, not muted: multi-sentence explainer prose is
// content, muted stays reserved for meta.
<Card data-slot="bp-derived-caption" className="gap-2 py-3 md:py-4">
<Card data-slot="bp-derived-caption" density="compact">
<CardContent>
<p className="text-sm leading-relaxed">
{t("insights.subPage.bpDerived.pulsePressure", {
+1 -5
View File
@@ -351,11 +351,7 @@ export default function MetricCatalogPage() {
// it ever probes for rows, so a module-off account never saw
// the in-context enable CTA; this row is the fix).
return (
<Card
key={group}
data-slot="catalog-group"
className="gap-2 py-3 md:py-4"
>
<Card key={group} data-slot="catalog-group" density="compact">
<CardHeader>
<TileHeader
icon={Icon}
+1 -1
View File
@@ -248,7 +248,7 @@ export default function InsightsMedikamentePage() {
med.categoryLabel,
);
return (
<Card key={med.id} className="gap-2 py-3 md:py-4">
<Card key={med.id} density="compact">
<CardHeader>
<TileHeader icon={Pill} title={med.name} />
<div className="text-muted-foreground mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
+1 -1
View File
@@ -138,7 +138,7 @@ export function AiConsentRequest({
};
return (
<Card data-slot="ai-consent-request" className="gap-2 py-3 md:py-4">
<Card data-slot="ai-consent-request" density="compact">
<CardHeader>
<TileHeader
icon={ShieldCheck}
@@ -268,7 +268,7 @@ function VisitRow({ t, visit }: { t: Translate; visit: EncounterDTO }) {
const site = bodySiteText(t, visit.bodySite, visit.laterality);
return (
<Card
className="gap-2 py-3 md:py-4"
density="compact"
data-slot="body-site-visit"
data-encounter-id={visit.id}
>
@@ -326,7 +326,7 @@ function ConditionRow({
const site = bodySiteText(t, condition.bodySite, condition.laterality);
return (
<Card
className="gap-2 py-3 md:py-4"
density="compact"
data-slot="body-site-condition"
data-episode-id={condition.id}
>
+1 -1
View File
@@ -163,9 +163,9 @@ export function PriorityCard({
<Card
data-slot="priority-card"
data-kind={item.kind}
density="compact"
className={cn(
isMilestone ? "milestone-reached" : "animate-insight-in",
"gap-2 py-3 md:gap-2 md:py-4",
item.status ? STATUS_WASH[item.status] : null,
hue ? "metric-accent" : null,
className,
+2 -2
View File
@@ -200,7 +200,7 @@ export function SinceLastVisit({
// and the reader still sees what could not be loaded.
if (window.isError && rows.length === 0) {
return (
<Card className="gap-2 py-3 md:py-4">
<Card density="compact">
<CardHeader>
<TileHeader
size="sm"
@@ -229,7 +229,7 @@ export function SinceLastVisit({
const nextDay = dateKeyOfInstant(next.occurredAt, timeZone);
return (
<Card className="gap-2 py-3 md:py-4">
<Card density="compact">
<CardHeader>
<TileHeader
size="sm"
+2 -1
View File
@@ -217,8 +217,9 @@ export function DocumentCard({
data-slot="document-card"
data-variant={variant}
data-document-id={document.id}
density="compact"
className={cn(
"group relative h-full gap-2 py-3 transition-shadow md:py-4",
"group relative h-full transition-shadow",
"hover:shadow-sm",
highlighted && "ring-primary ring-2",
)}
@@ -308,9 +308,7 @@ describe("<VisitsSection> since the last visit", () => {
html.indexOf("since-last-visit-"),
),
);
expect(card).toMatch(
/^<div data-slot="card" class="[^"]*gap-2 py-3 md:py-4/,
);
expect(card).toMatch(/^<div data-slot="card" data-density="compact"/);
expect(card).toContain('data-slot="tile-header"');
expect(card).toMatch(
/<h3 id="since-last-visit-[^"]+" data-slot="card-title" class="[^"]*text-sm/,
+1 -1
View File
@@ -94,7 +94,7 @@ export function VisitCard({
<Card
data-slot="visit-card"
data-encounter-id={encounter.id}
className="gap-2 py-3 md:py-4"
density="compact"
>
<CardContent className="space-y-2">
<Body onOpen={onOpen} encounter={encounter}>
@@ -173,7 +173,9 @@ describe("integer-metric precision guard", () => {
const html = render(<InsightsStepsPage />);
expect(html).toContain('data-slot="metric-stat-strip"');
expect(html).not.toMatch(FRACTIONAL_GERMAN);
// Class lists carry grid templates such as `minmax(0,1fr)`; only the
// rendered text is under test.
expect(html.replace(/class="[^"]*"/g, "")).not.toMatch(FRACTIONAL_GERMAN);
// The numbers themselves survive, thousands-grouped and whole.
expect(html).toContain("11.762");
expect(html).toContain("104");
+1 -1
View File
@@ -101,7 +101,7 @@ export function AiSetupHint({
}
return (
<Card data-slot="ai-setup-hint" className="gap-2 py-3 md:py-4">
<Card data-slot="ai-setup-hint" density="compact">
<CardHeader>
<TileHeader
icon={Sparkles}
@@ -118,7 +118,7 @@ export function CoachReadStrip({
data-slot="coach-read-strip"
data-state={isError ? "error" : "loading"}
aria-busy={isError ? undefined : "true"}
className="gap-2 py-3 md:py-4"
density="compact"
>
<CardHeader>
<TileHeader
@@ -243,7 +243,7 @@ export function CoachReadStrip({
// text edge ~8 px left of the card norm on md+. Body prose in the
// regular foreground; muted stays reserved for meta lines.
return (
<Card data-slot="coach-read-strip" className="gap-2 py-3 md:py-4">
<Card data-slot="coach-read-strip" density="compact">
<CardHeader>
<TileHeader
icon={Sparkles}
@@ -57,7 +57,7 @@ export function Glp1PlateauNote() {
const dose = `${plateau.drug} ${plateau.doseValue} ${plateau.doseUnit}`;
return (
<Card className="gap-2 py-3 md:py-4" data-slot="glp1-plateau-note">
<Card density="compact" data-slot="glp1-plateau-note">
<CardHeader>
<TileHeader
size="sm"
@@ -136,7 +136,7 @@ export function InsightStatusCard({
aria-busy="true"
aria-live="polite"
data-testid="insight-status-card-preparing"
className="gap-2 py-3 md:py-4"
density="compact"
>
<CardHeader>
<TileHeader icon={nodeIcon(icon)} title={title} />
@@ -172,7 +172,7 @@ export function InsightStatusCard({
// Compact card density — the same tier the populated / preparing /
// empty states use, so the heading and the first skeleton line sit
// on the same baseline across every assessment-card state.
className="gap-2 py-3 md:py-4"
density="compact"
>
<CardHeader>
<div className="flex items-center gap-2">
@@ -209,7 +209,7 @@ export function InsightStatusCard({
aria-live="polite"
role="status"
data-testid={`insight-status-card-${terminalKind}`}
className="gap-2 py-3 md:py-4"
density="compact"
>
<CardHeader>
<TileHeader icon={nodeIcon(icon)} title={title} />
@@ -223,7 +223,7 @@ export function InsightStatusCard({
if (!displayText) {
return (
<Card className="gap-2 py-3 md:py-4">
<Card density="compact">
<CardHeader>
<TileHeader icon={nodeIcon(icon)} title={title} />
</CardHeader>
@@ -247,7 +247,7 @@ export function InsightStatusCard({
// v1.12.2 — stable hook for the spine guard test, which asserts the
// assessment is the LAST content block on every bespoke metric page.
data-slot="insight-assessment"
// Compact card density (`gap-2 py-3 md:py-4`) — the sanctioned tight
// Compact card density — the sanctioned tight
// tier, so the assessment shares one header→body rhythm with the
// target summary, the read strip, and the stat strip on the same
// spine. Earlier bespoke values (`gap-1.5` + `md:py-5`) predate the
@@ -255,7 +255,8 @@ export function InsightStatusCard({
// lands on every status variant so the gap is consistent across
// states. (The one-time purple left accent stays dropped — the plain
// card border carries enough separation.)
className="animate-insight-in gap-2 py-3 md:py-4"
density="compact"
className="animate-insight-in"
>
<CardHeader>
{/* v1.11.5 — the top-right "cached" label was removed: it surfaced
@@ -286,7 +286,7 @@ export function MetricStatStrip({
data-multi-series="true"
role="group"
aria-label={groupLabel ?? t("insights.subPage.stats.label")}
className="gap-2 py-3 md:py-4"
density="compact"
>
<CardContent>
<div className="grid grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2">
@@ -319,7 +319,7 @@ export function MetricStatStrip({
? `${t("insights.subPage.stats.label")}: ${seriesLabel}`
: t("insights.subPage.stats.label")
}
className="gap-2 py-3 md:py-4"
density="compact"
>
<CardContent>
<SeriesBlock
@@ -356,7 +356,7 @@ export function MetricStatStripSkeleton({
<Card
data-slot="metric-stat-strip-skeleton"
aria-hidden="true"
className="gap-2 py-3 md:py-4"
density="compact"
>
<CardContent>
<div
@@ -333,7 +333,7 @@ function TargetReferencePanel({
<Card
data-slot="metric-target-summary"
data-target-type={target.type}
className="gap-2 py-3 md:py-4"
density="compact"
>
{/* Row 1: the canonical tile header — a single-word "Ziel" / "Target"
heading (the range numbers live on the range bar below, so the
@@ -205,7 +205,8 @@ export function MoodInsightsSections({
// v1.13.1 — match the canonical `gap-2` + `pb-1` heading-to-body
// rhythm the other assessment cards use, so the heading sits tight
// above its body instead of floating ~16-24 px above it.
className="animate-insight-in gap-2 py-3 md:py-4"
density="compact"
className="animate-insight-in"
>
<CardHeader>
<TileHeader
@@ -106,7 +106,7 @@ export function MoodPrognosisView({ reading }: { reading: PrognosisReading }) {
if (!data.present && data.reason === "no-output-yet") return null;
return (
<Card data-slot="mood-prognosis" className="gap-2 py-3 md:py-4">
<Card data-slot="mood-prognosis" density="compact">
<CardHeader>
<TileHeader icon={Compass} title={t("insights.mood.prognosis.title")} />
</CardHeader>
@@ -107,7 +107,7 @@ export function WorkoutDetailDayContext({
initialDateKey={workout.dayKey}
/>
<Card className="gap-2 py-3 md:py-4" data-slot="workout-detail-day-card">
<Card density="compact" data-slot="workout-detail-day-card">
<CardContent>
<div className="divide-border divide-y">
{sleepEnabled ? (
@@ -31,7 +31,8 @@ export function SampleBriefingCard({ className }: { className?: string }) {
<Card
data-slot="sample-briefing-card"
aria-label={t("onboarding.ai.sampleAriaLabel")}
className={cn("bg-muted/30 gap-2 py-3 md:py-4", className)}
density="compact"
className={cn("bg-muted/30", className)}
>
<CardHeader>
<TileHeader
@@ -170,7 +170,7 @@ export function PractitionerList({ enabled = true }: { enabled?: boolean }) {
<ul className="space-y-2">
{groupRows.map((row) => (
<li key={row.id}>
<Card className="gap-2 py-3 md:py-4">
<Card density="compact">
<CardContent className="flex items-center gap-3">
<div className="min-w-0 flex-1">
<p className="text-foreground truncate text-sm font-medium">
+1 -1
View File
@@ -41,7 +41,7 @@ export function ReadinessCard({
: t("timeline.readiness.meter", { carrying, total });
return (
<Card className="gap-2 py-3 md:py-4" data-slot="timeline-readiness-card">
<Card density="compact" data-slot="timeline-readiness-card">
<CardHeader>
<TileHeader
size="sm"
@@ -222,7 +222,7 @@ export function TimelineChronicle({
return (
<div className="space-y-4" data-slot="timeline-chronicle">
{chips.length > 0 && (
<Card className="gap-2 py-3" data-slot="timeline-standing">
<Card density="compact" data-slot="timeline-standing">
<button
type="button"
aria-expanded={standingOpen}
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { Card } from "../card";
function classes(html: string): string[] {
return (html.match(/class="([^"]*)"/)?.[1] ?? "").split(" ");
}
describe("<Card density>", () => {
it("ships the default rhythm", () => {
const c = classes(renderToStaticMarkup(<Card />));
expect(c).toEqual(
expect.arrayContaining(["gap-4", "py-4", "md:gap-6", "md:py-6"]),
);
});
it("pins the compact gap on both breakpoints", () => {
const html = renderToStaticMarkup(<Card density="compact" />);
const c = classes(html);
expect(c).toEqual(
expect.arrayContaining(["gap-2", "py-3", "md:gap-2", "md:py-4"]),
);
// The hand-written tier kept the base desktop gap; the prop must not.
expect(c).not.toContain("md:gap-6");
expect(c).not.toContain("md:py-6");
expect(html).toContain('data-density="compact"');
});
});
describe("compact density is the prop, not a class list", () => {
it("leaves no hand-written compact tier on a Card", async () => {
const { readFileSync } = await import("node:fs");
const { join } = await import("node:path");
const { walkSourceFiles } =
await import("@/__tests__/helpers/source-files");
const src = join(__dirname, "../../..");
const hits = walkSourceFiles(src, { floor: 2500 })
.filter((p) => p.endsWith(".tsx") && !p.includes("__tests__/"))
.filter((p) => !p.startsWith("components/ui/"))
.filter((p) =>
/<Card\b[^>]*className=(?:"[^"]*|\{cn\([^)]*)\bgap-2 py-3\b/.test(
readFileSync(join(src, p), "utf8"),
),
);
expect(hits).toEqual([]);
});
});
+19 -1
View File
@@ -15,12 +15,30 @@ import { cn } from "@/lib/utils";
*
* Refs `.planning/round-v1433-audit-polish.md` §4.1 + Win 4.
*/
function Card({ className, ...props }: React.ComponentProps<"div">) {
/**
* The two card densities (UI standards §1) live here, not at the call site:
* `default` is the card as shipped, `compact` is the stat-strip and
* small-tile rhythm. Spelled by hand as `gap-2 py-3 md:py-4`, the compact
* tier kept the base `md:gap-6`, so nineteen "compact" cards opened a 24 px
* gap between header and body on a desktop. The prop pins both breakpoints.
*/
const CARD_DENSITY = {
default: "",
compact: "gap-2 py-3 md:gap-2 md:py-4",
} as const;
function Card({
className,
density = "default",
...props
}: React.ComponentProps<"div"> & { density?: keyof typeof CARD_DENSITY }) {
return (
<div
data-slot="card"
data-density={density === "default" ? undefined : density}
className={cn(
"bg-card text-card-foreground flex flex-col gap-4 rounded-xl border py-4 shadow-sm md:gap-6 md:py-6",
CARD_DENSITY[density],
className,
)}
{...props}