refactor(insights): one shell and one rhythm for the overview sections

Seven overview sections had hand-rolled the same card literal, and the copies
had already drifted on the one thing the literal did not pin: the space
between blocks. Three used 12 px, two 8 px, one 16 px, one under the other in
the same column.

`InsightSectionCard` composes `Card`, so the frame comes from the card
contract, and fixes the inner rhythm at 12 px. The daily briefing moves onto
it as well, and the lab-changes list keeps its edge-to-edge rows through the
documented flush variant.

Visible change on the overview column: two bodies open by 4 px, one tightens
by 4 px, and all of them pick up the shadow their card neighbours already
had. Chart bodies are untouched.
This commit is contained in:
Marc-André Bombeck committed 2026-07-25 14:09:22 +02:00
1 parent b4f4cb8b55
commit 2decc37f07
10 files changed
+405 -235

No files matched your search

@@ -0,0 +1,122 @@
import { readFileSync, readdirSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { InsightSectionCard } from "../insight-section-card";
const INSIGHTS_DIR = path.join(process.cwd(), "src/components/insights");
/**
* The overview sections that compose the shared shell, by the `data-slot`
* each one keeps. A section that drifts back onto a hand-rolled literal — or
* a rename that silently drops one off the primitive — fails here instead of
* surviving until the next audit.
*/
const MIGRATED_SLOTS = [
"health-status-card",
"breathing-screening-card",
"coincident-deviation-card",
"rhythm-events-card",
"period-narrative-card",
"labs-changes-card",
"ecg-card",
"daily-briefing",
];
/** The shell literal the primitive replaces. */
function paintsHandRolledShell(line: string): boolean {
return (
/className=/.test(line) &&
/\bbg-card\b/.test(line) &&
/\brounded-xl\b/.test(line) &&
/\bborder\b/.test(line) &&
/\bp-4 md:p-6\b/.test(line)
);
}
function insightsSources(): { file: string; src: string }[] {
return readdirSync(INSIGHTS_DIR)
.filter((f) => f.endsWith(".tsx"))
.map((f) => ({
file: f,
src: readFileSync(path.join(INSIGHTS_DIR, f), "utf8"),
}));
}
describe("<InsightSectionCard>", () => {
it("composes Card with the pinned overview rhythm", () => {
const html = renderToStaticMarkup(
<InsightSectionCard>
<p>body</p>
</InsightSectionCard>,
);
expect(html).toContain('data-slot="insight-section-card"');
expect(html).toContain('data-slot="card-content"');
// Frame from the Card contract, body inset from CardContent.
expect(html).toContain("bg-card");
expect(html).toContain("rounded-xl");
expect(html).toContain("px-4");
expect(html).toContain("md:px-6");
// One inner rhythm — not the 8/12/16 px the copies had drifted to.
expect(html).toContain("gap-3");
expect(html).not.toContain("gap-2");
expect(html).not.toContain("space-y-4");
});
it("takes a slot override and merges caller classes onto the frame", () => {
const html = renderToStaticMarkup(
<InsightSectionCard slot="rhythm-events-card" className="min-h-32">
<p>body</p>
</InsightSectionCard>,
);
expect(html).toContain('data-slot="rhythm-events-card"');
expect(html).toContain("min-h-32");
expect(html).not.toContain('data-slot="insight-section-card"');
});
it("flush drops the body inset for an edge-to-edge divided list", () => {
const html = renderToStaticMarkup(
<InsightSectionCard slot="labs-changes-card" flush className="divide-y">
<div className="px-4 py-3">row</div>
</InsightSectionCard>,
);
expect(html).toContain('data-slot="labs-changes-card"');
expect(html).toContain("divide-y");
// No CardContent wrapper — the rows own their own inset.
expect(html).not.toContain('data-slot="card-content"');
expect(html).toContain("py-0");
});
it("every migrated overview section renders through the primitive", () => {
// Collapse whitespace so a prettier reflow onto multiple lines still
// matches the `<InsightSectionCard slot="…"` opening.
const sources = insightsSources()
.map(({ src }) => src.replace(/\s+/g, " "))
.join("\n");
for (const slot of MIGRATED_SLOTS) {
expect(
sources,
`${slot} should be rendered through <InsightSectionCard>`,
).toContain(`<InsightSectionCard slot="${slot}"`);
}
});
it("no migrated section re-hand-rolls the card-shell literal", () => {
const owners = insightsSources().filter(({ src }) =>
MIGRATED_SLOTS.some((slot) => src.includes(`slot="${slot}"`)),
);
// Sanity: the scan actually found the files it means to guard.
expect(owners.length).toBeGreaterThanOrEqual(7);
const offenders = owners.flatMap(({ file, src }) =>
src
.split("\n")
.filter(paintsHandRolledShell)
.map((line) => `${file}: ${line.trim()}`),
);
expect(offenders).toEqual([]);
});
});
@@ -13,6 +13,7 @@ import type {
BreathingClassification,
BreathingTrend,
} from "@/lib/insights/breathing-screening";
import { InsightSectionCard } from "./insight-section-card";
/**
* v1.25 — sleep-breathing screening card.
@@ -83,10 +84,7 @@ export function BreathingScreeningCard({
title={t("insights.breathingScreening.sectionTitle")}
subtitle={t("insights.breathingScreening.subtitle")}
/>
<div
data-slot="breathing-screening-card"
className="bg-card flex w-full min-w-0 flex-col gap-2 rounded-xl border p-4 md:p-6"
>
<InsightSectionCard slot="breathing-screening-card">
{classificationLabel ? (
<p
className="text-foreground text-sm font-medium"
@@ -131,7 +129,7 @@ export function BreathingScreeningCard({
>
{t("insights.breathingScreening.disclaimer")}
</p>
</div>
</InsightSectionCard>
</section>
);
}
@@ -14,6 +14,7 @@ import { useDerivedMetric } from "@/components/insights/derived/use-derived-metr
// (the v1.9.0 lesson, mirrored at vitals-dashboard.tsx).
import type { CoincidentDeviationValue } from "@/lib/insights/derived/coincident-deviation";
import type { DerivedProvenance } from "@/lib/insights/derived/types";
import { InsightSectionCard } from "./insight-section-card";
/**
* v1.10.3 — "Today's signal" headline card.
@@ -113,11 +114,10 @@ function CardShell({
) : undefined
}
/>
<div
data-slot="coincident-deviation-card"
<InsightSectionCard
slot="coincident-deviation-card"
data-state={state}
className={cn(
"bg-card flex w-full min-w-0 flex-col gap-2 rounded-xl border p-4 md:p-6",
// `min-h` floor only on the tallest state (fired: the named-vitals
// body + the mandatory factors line). The calmer everyday states
// (all-clear / watch / insufficient) carry a single short body, so
@@ -132,7 +132,7 @@ function CardShell({
)}
>
{children}
</div>
</InsightSectionCard>
</section>
);
}
@@ -155,14 +155,14 @@ function CardSkeleton({ className }: { className?: string }) {
icon={Activity}
title={t("insights.derived.coincident.cardTitle")}
/>
<div
data-slot="coincident-deviation-card-skeleton"
<InsightSectionCard
slot="coincident-deviation-card-skeleton"
aria-hidden="true"
className="bg-card border-border flex min-h-32 w-full min-w-0 flex-col gap-2 rounded-xl border p-4 md:p-6"
className="border-border min-h-32"
>
<div className="bg-muted/40 h-4 w-3/4 rounded" />
<div className="bg-muted/40 h-3 w-1/2 rounded" />
</div>
</InsightSectionCard>
</section>
);
}
+184 -191
View File
@@ -20,7 +20,6 @@ import {
Zap,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { ListRow } from "@/components/ui/list-row";
import { Skeleton } from "@/components/ui/skeleton";
@@ -39,6 +38,7 @@ import type {
DailyBriefingSignal,
} from "@/lib/ai/schema";
import type { BriefingFailureClass } from "@/lib/insights/briefing-failure-marker";
import { InsightSectionCard } from "./insight-section-card";
/**
* v1.4.27 MB7 / CF-68 — per-metric routing target for the briefing
@@ -416,223 +416,216 @@ export function DailyBriefing({
) : undefined
}
/>
<Card data-slot="daily-briefing" className="overflow-hidden">
<CardContent>
{loading ? (
<>
<span className="sr-only" aria-live="polite">
{t("insights.dailyBriefing.loadingLabel")}
</span>
<BriefingSkeleton />
</>
) : briefing ? (
// v1.18.10 — body block rhythm matches the sibling insights cards
// (`correlation-card` uses `space-y-3` between its chart /
// interpretation / source blocks). The briefing card carried a
// looser `space-y-4`, which read as taller than its neighbours on
// the overview. One token, no new spacing scale.
<div className="space-y-3">
{/* The v1.21.2 recall/forward-look block is gone: the card
<InsightSectionCard slot="daily-briefing" className="overflow-hidden">
{loading ? (
<>
<span className="sr-only" aria-live="polite">
{t("insights.dailyBriefing.loadingLabel")}
</span>
<BriefingSkeleton />
</>
) : briefing ? (
// v1.18.10 — body block rhythm matches the sibling insights cards
// (`correlation-card` uses `space-y-3` between its chart /
// interpretation / source blocks). The briefing card carried a
// looser `space-y-4`, which read as taller than its neighbours on
// the overview. One token, no new spacing scale.
<div className="space-y-3">
{/* The v1.21.2 recall/forward-look block is gone: the card
opens straight on "signals of the day". The narrative
memory read as a second briefing paragraph above the
structured list and pulled the card down; the server
still resolves `briefingMemory` for other consumers. */}
{/* v1.4.27 B1 — the leading narrative paragraph dropped.
{/* v1.4.27 B1 — the leading narrative paragraph dropped.
The hero strip subtitle on `/insights` already renders
the same `briefing.paragraph` text directly above this
card, so the user used to read the same string twice
within 200 px. The card now opens straight on the
structured signals + key-findings list, which is the part
the hero subtitle cannot surface. */}
{/* v1.18.7 — "Signals of the day": the present-focused lead.
{/* v1.18.7 — "Signals of the day": the present-focused lead.
Renders above the longer-horizon key-findings list when the
briefing carries fresh now-signals. */}
{briefing.signalsOfDay && briefing.signalsOfDay.length > 0 && (
<div className="space-y-2">
<p
data-slot="daily-briefing-signals-title"
className="text-foreground text-xs font-semibold tracking-wide uppercase"
>
{t("insights.dailyBriefing.signalsTitle")}
</p>
<div data-slot="daily-briefing-signals" className="space-y-2">
{briefing.signalsOfDay.map((signal, index) => (
<SignalRow
key={`${signal.sourceMetric}-${index}`}
signal={signal}
/>
))}
</div>
{briefing.signalsOfDay && briefing.signalsOfDay.length > 0 && (
<div className="space-y-2">
<p
data-slot="daily-briefing-signals-title"
className="text-foreground text-xs font-semibold tracking-wide uppercase"
>
{t("insights.dailyBriefing.signalsTitle")}
</p>
<div data-slot="daily-briefing-signals" className="space-y-2">
{briefing.signalsOfDay.map((signal, index) => (
<SignalRow
key={`${signal.sourceMetric}-${index}`}
signal={signal}
/>
))}
</div>
)}
{briefing.keyFindings.length > 0 && (
<div className="space-y-2">
<p
data-slot="daily-briefing-findings-title"
className="text-foreground text-xs font-semibold tracking-wide uppercase"
>
{t("insights.dailyBriefing.keyFindingsTitle")}
</p>
<div
data-slot="daily-briefing-findings"
className="space-y-2"
>
{briefing.keyFindings.map((finding, index) => (
<KeyFindingRow
key={`${finding.sourceMetric}-${index}`}
finding={finding}
/>
))}
</div>
</div>
)}
{briefing.keyFindings.length > 0 && (
<div className="space-y-2">
<p
data-slot="daily-briefing-findings-title"
className="text-foreground text-xs font-semibold tracking-wide uppercase"
>
{t("insights.dailyBriefing.keyFindingsTitle")}
</p>
<div data-slot="daily-briefing-findings" className="space-y-2">
{briefing.keyFindings.map((finding, index) => (
<KeyFindingRow
key={`${finding.sourceMetric}-${index}`}
finding={finding}
/>
))}
</div>
)}
{(updatedAt || noProviderStale || generationFailed) && (
<div className="border-border/60 space-y-1.5 border-t pt-3">
{updatedAt && (
<p
data-slot="daily-briefing-updated"
className="text-muted-foreground text-right text-xs"
>
{formatUpdatedLabel(
updatedAt,
t,
fmt.dateShort,
fmt.time,
displayTz,
)}
</p>
)}
{/* v1.18.9 (#4) — a stale briefing that can never refresh
</div>
)}
{(updatedAt || noProviderStale || generationFailed) && (
<div className="border-border/60 space-y-1.5 border-t pt-3">
{updatedAt && (
<p
data-slot="daily-briefing-updated"
className="text-muted-foreground text-right text-xs"
>
{formatUpdatedLabel(
updatedAt,
t,
fmt.dateShort,
fmt.time,
displayTz,
)}
</p>
)}
{/* v1.18.9 (#4) — a stale briefing that can never refresh
because no AI provider is connected. State that plainly
and point at Settings → AI, so the days-old read is
understood as held, not presented as current. */}
{noProviderStale && (
<p
data-slot="daily-briefing-stale-no-provider"
className="text-muted-foreground flex flex-wrap items-center justify-end gap-x-1.5 gap-y-1 text-right text-xs"
{noProviderStale && (
<p
data-slot="daily-briefing-stale-no-provider"
className="text-muted-foreground flex flex-wrap items-center justify-end gap-x-1.5 gap-y-1 text-right text-xs"
>
<span>
{t("insights.dailyBriefing.staleNoProviderHint")}
</span>
<Link
href="/settings/ai"
data-slot="daily-briefing-stale-no-provider-link"
className="text-foreground/80 hover:text-foreground underline underline-offset-2"
>
<span>
{t("insights.dailyBriefing.staleNoProviderHint")}
</span>
<Link
href="/settings/ai"
data-slot="daily-briefing-stale-no-provider-link"
className="text-foreground/80 hover:text-foreground underline underline-offset-2"
>
{t("insights.dailyBriefing.noProviderAction")}
</Link>
</p>
)}
{/* v1.25 — the held briefing is shown, but the last refresh
{t("insights.dailyBriefing.noProviderAction")}
</Link>
</p>
)}
{/* v1.25 — the held briefing is shown, but the last refresh
attempt failed (provider timeout / error). State it plainly
and offer a retry so the staleness reads as held, not
current. Suppressed when no provider is configured (that
hint owns the footer) — a retry would be futile there. */}
{generationFailed && !noProviderStale && (
<p
data-slot="daily-briefing-refresh-failed"
className="text-muted-foreground flex flex-wrap items-center justify-end gap-x-1.5 gap-y-1 text-right text-xs"
>
<span>
{t("insights.dailyBriefing.refreshFailedHint")}
</span>
{onRegenerate && (
<button
type="button"
onClick={onRegenerate}
disabled={regenerating}
data-slot="daily-briefing-refresh-failed-retry"
className="text-foreground/80 hover:text-foreground underline underline-offset-2 disabled:opacity-60"
>
{t("insights.dailyBriefing.retryAction")}
</button>
)}
</p>
)}
</div>
)}
</div>
) : noProvider ? (
// v1.15.20 — no provider configured anywhere: a regenerate CTA
// would 422 forever, so point at Settings → AI instead.
<EmptyState
data-slot="daily-briefing-no-provider"
variant="plain"
icon={<Sparkles className="size-5" />}
title={t("insights.dailyBriefing.noProviderTitle")}
description={t("insights.dailyBriefing.noProviderDescription")}
action={
{generationFailed && !noProviderStale && (
<p
data-slot="daily-briefing-refresh-failed"
className="text-muted-foreground flex flex-wrap items-center justify-end gap-x-1.5 gap-y-1 text-right text-xs"
>
<span>{t("insights.dailyBriefing.refreshFailedHint")}</span>
{onRegenerate && (
<button
type="button"
onClick={onRegenerate}
disabled={regenerating}
data-slot="daily-briefing-refresh-failed-retry"
className="text-foreground/80 hover:text-foreground underline underline-offset-2 disabled:opacity-60"
>
{t("insights.dailyBriefing.retryAction")}
</button>
)}
</p>
)}
</div>
)}
</div>
) : noProvider ? (
// v1.15.20 — no provider configured anywhere: a regenerate CTA
// would 422 forever, so point at Settings → AI instead.
<EmptyState
data-slot="daily-briefing-no-provider"
variant="plain"
icon={<Sparkles className="size-5" />}
title={t("insights.dailyBriefing.noProviderTitle")}
description={t("insights.dailyBriefing.noProviderDescription")}
action={
<Button
type="button"
size="sm"
variant="outline"
asChild
data-slot="daily-briefing-no-provider-cta"
>
<Link href="/settings/ai">
{t("insights.dailyBriefing.noProviderAction")}
</Link>
</Button>
}
/>
) : (
<EmptyState
data-slot="daily-briefing-empty"
variant="plain"
icon={<FileText className="size-5" />}
// v1.25 — when the last attempt FAILED and there is no last good
// text to fall back on, say so honestly ("couldn't generate")
// rather than the generic "no briefing yet". The CTA below is the
// same explicit regenerate — it retries the failed generation.
// v1.28.28 (#470) — a grounding-gate omission gets its own calm
// wording: the generation SUCCEEDED but restated an unverifiable
// figure, so the briefing was withheld. Without this the card
// read "no briefing yet" and the button looked like a no-op.
title={
omittedReason === "ungrounded"
? t("insights.dailyBriefing.omittedTitle")
: generationFailed
? t("insights.dailyBriefing.failedTitle")
: t("insights.dailyBriefing.emptyTitle")
}
description={
omittedReason === "ungrounded"
? t("insights.dailyBriefing.omittedUngroundedDescription")
: generationFailed
? t(failedDescriptionKey)
: t("insights.dailyBriefing.emptyDescription")
}
action={
onRegenerate ? (
// v1.4.28 BK-M2 — switch the empty-state CTA from
// `variant="outline"` to the default (filled) variant
// so the briefing card matches the dashboard
// empty-state CTA shape. Empty-state actions on a card
// surface read as the single primary affordance and
// earn the filled chip across surfaces in v1.4.28.
<Button
type="button"
size="sm"
variant="outline"
asChild
data-slot="daily-briefing-no-provider-cta"
onClick={onRegenerate}
disabled={regenerating}
data-slot="daily-briefing-empty-cta"
className="gap-1.5"
>
<Link href="/settings/ai">
{t("insights.dailyBriefing.noProviderAction")}
</Link>
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
<span>
{regenerating
? t("insights.heroRegenerating")
: generationFailed || omittedReason === "ungrounded"
? t("insights.dailyBriefing.retryAction")
: t("insights.dailyBriefing.emptyAction")}
</span>
</Button>
}
/>
) : (
<EmptyState
data-slot="daily-briefing-empty"
variant="plain"
icon={<FileText className="size-5" />}
// v1.25 — when the last attempt FAILED and there is no last good
// text to fall back on, say so honestly ("couldn't generate")
// rather than the generic "no briefing yet". The CTA below is the
// same explicit regenerate — it retries the failed generation.
// v1.28.28 (#470) — a grounding-gate omission gets its own calm
// wording: the generation SUCCEEDED but restated an unverifiable
// figure, so the briefing was withheld. Without this the card
// read "no briefing yet" and the button looked like a no-op.
title={
omittedReason === "ungrounded"
? t("insights.dailyBriefing.omittedTitle")
: generationFailed
? t("insights.dailyBriefing.failedTitle")
: t("insights.dailyBriefing.emptyTitle")
}
description={
omittedReason === "ungrounded"
? t("insights.dailyBriefing.omittedUngroundedDescription")
: generationFailed
? t(failedDescriptionKey)
: t("insights.dailyBriefing.emptyDescription")
}
action={
onRegenerate ? (
// v1.4.28 BK-M2 — switch the empty-state CTA from
// `variant="outline"` to the default (filled) variant
// so the briefing card matches the dashboard
// empty-state CTA shape. Empty-state actions on a card
// surface read as the single primary affordance and
// earn the filled chip across surfaces in v1.4.28.
<Button
type="button"
size="sm"
onClick={onRegenerate}
disabled={regenerating}
data-slot="daily-briefing-empty-cta"
className="gap-1.5"
>
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
<span>
{regenerating
? t("insights.heroRegenerating")
: generationFailed || omittedReason === "ungrounded"
? t("insights.dailyBriefing.retryAction")
: t("insights.dailyBriefing.emptyAction")}
</span>
</Button>
) : null
}
/>
)}
</CardContent>
</Card>
) : null
}
/>
)}
</InsightSectionCard>
</section>
);
}
+3 -5
View File
@@ -11,6 +11,7 @@ import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { SectionHeading } from "@/components/insights/section-heading";
import { EcgWaveform } from "@/components/insights/ecg-waveform";
import { InsightSectionCard } from "./insight-section-card";
/**
* v1.28.50 — ECG recording surface (list → detail).
@@ -141,10 +142,7 @@ export function EcgSection({
title={t("insights.ecg.sectionTitle")}
/>
)}
<div
data-slot="ecg-card"
className="bg-card border-border space-y-4 rounded-xl border p-4 md:p-6"
>
<InsightSectionCard slot="ecg-card" className="border-border">
{selected ? (
<EcgDetail
recording={selected}
@@ -214,7 +212,7 @@ export function EcgSection({
<EcgDisclaimer />
</>
)}
</div>
</InsightSectionCard>
</section>
);
}
@@ -14,6 +14,7 @@ import type {
HealthStatusDeviation,
HealthStatusShift,
} from "@/lib/insights/health-status";
import { InsightSectionCard } from "./insight-section-card";
/**
* v1.25 — baseline-drift card.
@@ -70,10 +71,7 @@ export function HealthStatusCard({
title={t("insights.healthStatus.sectionTitle")}
subtitle={t("insights.healthStatus.subtitle")}
/>
<div
data-slot="health-status-card"
className="bg-card flex w-full min-w-0 flex-col gap-3 rounded-xl border p-4 md:p-6"
>
<InsightSectionCard slot="health-status-card">
{data.deviations.map((d) => (
<div
key={`dev-${d.type}`}
@@ -146,7 +144,7 @@ export function HealthStatusCard({
</div>
</div>
))}
</div>
</InsightSectionCard>
</section>
);
}
@@ -0,0 +1,66 @@
import type { ReactNode } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils";
/**
* The shell every Insights overview section paints its body on.
*
* Six sections had hand-rolled the same `bg-card … rounded-xl border p-4
* md:p-6` literal, and the six copies had already drifted apart on the one
* thing the literal did not pin: the inner rhythm. Three used `gap-3`, two
* `gap-2`, one `space-y-4` — 8, 12, and 16 px between blocks, one under the
* other in the same column. This primitive composes `<Card>` so the frame
* comes from the card contract (radius, border, surface, `px-4 md:px-6`
* inset, `py-4 md:py-6`) and pins the inner rhythm at one step: `gap-3`.
*
* Outer geometry is unchanged from the literals it replaces. What changes:
* the two `gap-2` bodies open by 4 px, the `space-y-4` body tightens by
* 4 px, and all six pick up the card's `shadow-sm` — which their `<Card>`
* neighbours in the same column already had.
*
* Density lives here, never on a slot: `pt-*` on the content or `pb-*` on a
* header fights the gap-based Card contract (lint-enforced).
*
* RSC-safe: no hooks, no browser API.
*
* @see .planning/audits/2026-07-05-fable/UI-STANDARDS.md §1
*/
export function InsightSectionCard({
children,
slot,
flush = false,
className,
...props
}: {
children: ReactNode;
/** The card's `data-slot` hook. Defaults to `insight-section-card`. */
slot?: string;
/**
* The body is an edge-to-edge list whose rows own their own inset (a
* `divide-y` ledger, not a gap stack), so the card drops its own padding
* and the caller keeps the row rhythm. The frame is identical either way.
*/
flush?: boolean;
className?: string;
} & Omit<React.ComponentPropsWithoutRef<"div">, "children">) {
return (
<Card
data-slot={slot ?? "insight-section-card"}
className={cn(
"w-full min-w-0",
flush ? "gap-0 py-0" : "gap-3 py-4 md:py-6",
className,
)}
{...props}
>
{flush ? (
children
) : (
<CardContent className="flex min-w-0 flex-col gap-3">
{children}
</CardContent>
)}
</Card>
);
}
@@ -10,6 +10,7 @@ import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { SectionHeading } from "@/components/insights/section-heading";
import type { LabChange } from "@/lib/insights/labs-changes";
import { InsightSectionCard } from "./insight-section-card";
/**
* v1.25 — "what changed since your last panel" card.
@@ -74,10 +75,7 @@ export function LabsChangesCard({
title={t("insights.labsChanges.sectionTitle")}
subtitle={t("insights.labsChanges.subtitle")}
/>
<div
data-slot="labs-changes-card"
className="bg-card flex w-full min-w-0 flex-col divide-y rounded-xl border"
>
<InsightSectionCard slot="labs-changes-card" flush className="divide-y">
{data.changes.map((c) => {
const labelKey = statusLabelKey(c.status);
const signedDelta =
@@ -108,7 +106,7 @@ export function LabsChangesCard({
</div>
);
})}
</div>
</InsightSectionCard>
</section>
);
}
@@ -17,6 +17,7 @@ import { SectionHeading } from "@/components/insights/section-heading";
import { AskCoachIconButton } from "@/components/insights/ask-coach-action";
import { ProseBlocks } from "@/components/insights/prose-blocks";
import { apiGet } from "@/lib/api/api-fetch";
import { InsightSectionCard } from "./insight-section-card";
/**
* v1.11.0 W3 — period-narrative card (Pillar P1).
@@ -66,13 +67,11 @@ async function fetchNarrative(
return apiGet<NarrativeResponse>(`/api/insights/narrative?period=${period}`);
}
const SHELL =
"bg-card border-border flex w-full min-w-0 flex-col gap-3 rounded-xl border p-4 md:p-6";
// The in-flight skeleton keeps a `min-h` floor so the initial paint reserves the
// row (CLS-safe). The resolved card drops the floor and sizes to its content —
// a short narrative used to leave a large empty tail under the prose, widened by
// the footer's `mt-auto` pinning it to the bottom of the floored height.
const SKELETON_SHELL = `${SHELL} min-h-40`;
const SKELETON_FLOOR = "border-border min-h-40";
export function PeriodNarrativeCard({
enabled = true,
@@ -109,15 +108,15 @@ export function PeriodNarrativeCard({
icon={CalendarRange}
title={t("insights.narrativeTitle")}
/>
<div
data-slot="period-narrative-card-skeleton"
<InsightSectionCard
slot="period-narrative-card-skeleton"
aria-hidden="true"
className={SKELETON_SHELL}
className={SKELETON_FLOOR}
>
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-2/3" />
</div>
</InsightSectionCard>
</section>
);
}
@@ -142,10 +141,10 @@ export function PeriodNarrativeCard({
icon={CalendarRange}
title={t("insights.narrativeTitle")}
/>
<div
data-slot="period-narrative-card"
<InsightSectionCard
slot="period-narrative-card"
data-period={period}
className={SHELL}
className="border-border"
>
{/* Header row: week / month toggle on the left, the icon-only Coach
hand-off pinned top-right. Matches the assessment cards, which carry
@@ -205,7 +204,7 @@ export function PeriodNarrativeCard({
displayTz,
)}
</p>
</div>
</InsightSectionCard>
</section>
);
}
@@ -10,6 +10,7 @@ import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { SectionHeading } from "@/components/insights/section-heading";
import { InsightSectionCard } from "./insight-section-card";
/**
* v1.10.0 — device-flagged event awareness surface (categorical events,
@@ -115,10 +116,7 @@ export function RhythmEventsCard({
icon={Activity}
title={t("insights.rhythmEvents.sectionTitle")}
/>
<div
data-slot="rhythm-events-card"
className="bg-card border-border space-y-4 rounded-xl border p-4 md:p-6"
>
<InsightSectionCard slot="rhythm-events-card">
<p className="text-muted-foreground text-sm">
{t("insights.rhythmEvents.sectionIntro")}
</p>
@@ -171,7 +169,7 @@ export function RhythmEventsCard({
<Info className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
<p>{t("insights.rhythmEvents.disclaimer")}</p>
</div>
</div>
</InsightSectionCard>
</section>
);
}