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

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