mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
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"',
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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/,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user