Merge pull request #637 from MBombeck/release/v1.32.34

release/v1.32.34 — one shell and one rhythm for the insights overview
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-25 14:40:16 +02:00
commit 24beaff4b7
31 files changed
+697 -301

No files matched your search

+22
View File
@@ -2,6 +2,28 @@
## [Unreleased]
## [1.32.34] — 2026-07-25
Housekeeping on the interface. Most of it you will not see.
The sections on the insights overview had each been given their own card frame
by hand, and the copies had drifted apart on the one thing nobody thought to
write down: how much room to leave between the blocks inside them. Some used 8
pixels, some 12, one 16, all sitting one under the other in the same column.
They now share a single frame with a single rhythm, so a few of those cards
open or tighten very slightly and all of them pick up the same faint shadow
their neighbours already had.
When a metric page cannot load, the message now reads in the normal text
colour with a warning glyph beside it, instead of the grey reserved for
timestamps and units. A failure is what that surface has to say at that moment,
so it should not look like a footnote.
The rest is invisible from the outside. The sleep composition card uses the
same title row as every other card, a handful of one-off text sizes and
paddings went back onto the scale the rest of the app uses, and the spacing
rule that was already enforced in the build now has nothing left to catch.
## [1.32.33] — 2026-07-25
The moodLog integration is gone. It bridged mood entries to and from a separate
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.32.33
version: 1.32.34
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.32.33",
"version": "1.32.34",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.32.33";
/* @sw-version-fallback */ "v1.32.34";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
+8
View File
@@ -71,6 +71,14 @@
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
/* The one sanctioned step below `text-xs` (12px). Minted so badges,
tag chips, and tabular-nums micro-labels stop hand-rolling
`text-[0.6875rem]` / `text-[0.65rem]` arbitrary values that drift by a
third of a pixel per site. UI-STANDARDS §5 pins the usage: never for
muted body or meta text (the muted floor stays `text-xs`) — only for
chips, badges, and tabular micro-labels with verified contrast. */
--text-2xs: 0.6875rem;
--text-2xs--line-height: 1rem;
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
+1 -1
View File
@@ -49,7 +49,7 @@ export default async function NotFound() {
</div>
<Link
href="/"
className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex min-h-11 items-center justify-center rounded-md px-5 text-sm font-medium transition-colors"
className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex min-h-11 items-center justify-center rounded-md px-4 text-sm font-medium transition-colors"
>
{t("notFound.backToDashboard")}
</Link>
@@ -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([]);
});
});
@@ -51,6 +51,9 @@ describe("<SleepStageStackedBar>", () => {
const html = render(<SleepStageStackedBar breakdown={breakdown} />);
expect(html).toContain("Stage composition");
expect(html).toContain("Last 23 nights");
// The title routes through the canonical tile header rather than a
// hand-rolled title row (UI-STANDARDS §1 / §8).
expect(html).toContain('data-slot="tile-header"');
});
it("exposes an accessible label that announces the nights covered", () => {
@@ -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>
);
}
@@ -1,9 +1,9 @@
"use client";
import { useMemo, type ReactNode } from "react";
import { Footprints, Gauge, HeartPulse, RefreshCw, Scale } from "lucide-react";
import { Footprints, Gauge, HeartPulse, Scale } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { getUnitForType } from "@/lib/validations/measurement";
@@ -744,24 +744,12 @@ export function VitalsDashboard({ batch, layout, className }: DashboardProps) {
title={t("insights.derived.vitals.sectionTitle")}
subtitle={t("insights.derived.vitals.sectionSubtitle")}
/>
<div
data-slot="vitals-dashboard-error"
role="alert"
className="bg-card border-border text-muted-foreground flex flex-col items-start gap-3 rounded-xl border p-4 text-sm sm:flex-row sm:items-center sm:justify-between"
>
<span>{t("insights.derived.vitals.loadError")}</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => refetch()}
data-slot="vitals-dashboard-retry"
className="gap-1.5"
>
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("common.retry")}</span>
</Button>
</div>
<QueryErrorRow
slot="vitals-dashboard-error"
retrySlot="vitals-dashboard-retry"
message={t("insights.derived.vitals.loadError")}
onRetry={() => refetch()}
/>
</section>
</div>
);
+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>
);
}
@@ -3,7 +3,7 @@
import Link from "next/link";
import type { ComponentProps, ComponentType, ReactNode } from "react";
import { RefreshCw, Sparkles } from "lucide-react";
import { Sparkles } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useUnitDisplay } from "@/hooks/use-unit-display";
@@ -15,6 +15,7 @@ import type { InsightMetric } from "@/lib/insights/metric-availability";
import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry";
import type { ChartOverlayKey } from "@/lib/dashboard-layout";
import { Button } from "@/components/ui/button";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
@@ -342,24 +343,12 @@ export function HealthKitMetricPage({
description={description}
explainerMetric={explainerMetric}
>
<div
data-slot="healthkit-metric-error"
role="alert"
className="bg-card border-border text-muted-foreground flex flex-col items-start gap-3 rounded-xl border p-4 text-sm sm:flex-row sm:items-center sm:justify-between"
>
<span>{t("insights.subPage.loadError")}</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => refetch()}
data-slot="healthkit-metric-retry"
className="gap-1.5"
>
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("common.retry")}</span>
</Button>
</div>
<QueryErrorRow
slot="healthkit-metric-error"
retrySlot="healthkit-metric-retry"
message={t("insights.subPage.loadError")}
onRetry={() => refetch()}
/>
</SubPageShell>
);
}
@@ -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>
);
}
@@ -354,7 +354,7 @@ function TargetReferencePanel({
}
/>
{heading ? (
<p className="text-muted-foreground text-[0.6875rem] font-medium tracking-[0.06em] uppercase">
<p className="text-muted-foreground text-xs font-medium tracking-[0.06em] uppercase">
{heading}
</p>
) : null}
@@ -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>
);
}
@@ -240,7 +240,7 @@ function RationaleCard({
return (
<div
data-slot="rec-rationale-card"
className="bg-muted/30 mt-3 space-y-3 rounded-md px-3 py-2.5"
className="bg-muted/30 mt-3 space-y-3 rounded-md p-3"
>
<div className="space-y-1.5">
<RationaleRow
@@ -358,7 +358,7 @@ export function RecommendationCard({
<div
data-slot="rec-card"
data-index={index}
className="border-border/40 bg-card/20 rounded-lg border px-3 py-2.5"
className="border-border/40 bg-card/20 rounded-lg border p-3"
>
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 flex-1 items-start gap-2">
@@ -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>
);
}
@@ -14,7 +14,8 @@ import {
} from "recharts";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { useTranslations } from "@/lib/i18n/context";
import { formatDurationMinutes } from "@/lib/i18n/duration";
import { resolveIntlLocale } from "@/lib/format-locale";
@@ -205,10 +206,14 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) {
<Card data-slot="sleep-stage-stacked-bar">
<CardHeader>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-0.5">
<CardTitle className="text-base font-semibold">
{t("insights.sleep.compositionTitle")}
</CardTitle>
<div className="flex min-w-0 flex-col gap-0.5">
{/* The canonical tile header — the title used to be a bare
`CardTitle` in a hand-rolled row. The window toggles stay a
sibling of the title+subtitle pair rather than riding the
header's `right` slot: the subtitle belongs under the title,
and the toggles need to drop to their own row below `sm`
(the sanctioned inline-action-row shape). */}
<TileHeader title={t("insights.sleep.compositionTitle")} />
<span className="text-muted-foreground text-xs">
{t("insights.sleep.compositionSubtitle", {
nights: breakdown.nights,
@@ -623,7 +623,7 @@ function LedgerRowItem({
{t(`medications.detail.verlauf.status.${row.status}`)}
</span>
{row.kind === "ad_hoc" && (
<Badge variant="outline" className="text-[0.65rem]">
<Badge variant="outline" className="text-2xs">
{t("medications.detail.verlauf.adHocTag")}
</Badge>
)}
@@ -634,7 +634,7 @@ function LedgerRowItem({
{row.kind === "slot" && row.pinned && (
<Badge
variant="outline"
className="text-[0.65rem]"
className="text-2xs"
data-slot="ledger-pinned-badge"
>
{t("medications.detail.verlauf.pin.pinnedTag")}
@@ -862,7 +862,7 @@ function LedgerSummaryChart({
style={{ width: `${pct(missed)}%` }}
/>
</div>
<div className="text-muted-foreground flex flex-wrap gap-x-3 gap-y-0.5 text-[0.7rem]">
<div className="text-muted-foreground flex flex-wrap gap-x-3 gap-y-0.5 text-xs">
<span className="text-success">
{t("medications.detail.verlauf.status.taken_on_time")}: {takenOnTime}
</span>
@@ -162,7 +162,7 @@ export function WizardStepper({
/>
<span
className={cn(
"max-w-[6rem] truncate text-[0.6875rem] leading-none",
"text-2xs max-w-[6rem] truncate leading-none",
isActive
? "text-foreground font-medium"
: "text-muted-foreground",
@@ -36,6 +36,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
import { TagChip } from "@/components/ui/tag-chip";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
@@ -204,9 +205,7 @@ export function FitbitCard({
title={t("settings.fitbit")}
titleAccessory={
<>
<span className="bg-muted text-foreground rounded-full px-2 py-0.5 text-[0.6875rem] font-medium">
{t("settings.fitbitTag")}
</span>
<TagChip>{t("settings.fitbitTag")}</TagChip>
<Badge
variant="outline"
data-testid="fitbit-experimental-badge"
@@ -44,6 +44,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
import { TagChip } from "@/components/ui/tag-chip";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
@@ -212,9 +213,7 @@ export function GoogleHealthCard({
title={t("settings.googleHealth")}
titleAccessory={
<>
<span className="bg-muted text-foreground rounded-full px-2 py-0.5 text-[0.6875rem] font-medium">
{t("settings.googleHealthTag")}
</span>
<TagChip>{t("settings.googleHealthTag")}</TagChip>
<Badge
variant="outline"
data-testid="google-health-beta-badge"
@@ -37,6 +37,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
import { Switch } from "@/components/ui/switch";
import { TagChip } from "@/components/ui/tag-chip";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
@@ -186,11 +187,7 @@ export function NightscoutCard({
<SettingsCardHeader
icon={Droplet}
title={t("settings.nightscout")}
titleAccessory={
<span className="bg-muted text-foreground rounded-full px-2 py-0.5 text-[0.6875rem] font-medium">
{t("settings.nightscoutTag")}
</span>
}
titleAccessory={<TagChip>{t("settings.nightscoutTag")}</TagChip>}
description={
<IntegrationCardDescription
i18nPrefix="settings.nightscout"
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { QueryErrorRow } from "../query-error-row";
function ssr(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
describe("<QueryErrorRow>", () => {
it("renders the compact alert row with foreground message text", () => {
const html = ssr(<QueryErrorRow message="Could not load vitals" />);
expect(html).toContain('role="alert"');
expect(html).toContain('data-slot="query-error-row"');
expect(html).toContain("Could not load vitals");
// §3 — an alert IS the surface's content, so it is foreground. Both
// hand-rolled copies this primitive replaces put it in the muted tier.
expect(html).toContain("text-foreground");
// The compact row shape, not the tall centred `<QueryErrorCard>`.
expect(html).toContain("p-4");
expect(html).not.toContain("py-10");
});
it("falls back to the generic load-failed copy", () => {
expect(ssr(<QueryErrorRow />)).toContain("Could not load");
});
it("only renders the retry affordance when a handler is wired", () => {
expect(ssr(<QueryErrorRow message="boom" />)).not.toContain("<button");
const withRetry = ssr(<QueryErrorRow message="boom" onRetry={() => {}} />);
expect(withRetry).toContain("<button");
expect(withRetry).toContain("Retry");
expect(withRetry).toContain('data-slot="query-error-row-retry"');
});
it("honours the slot overrides the converted surfaces depend on", () => {
const html = ssr(
<QueryErrorRow
message="boom"
onRetry={() => {}}
slot="healthkit-metric-error"
retrySlot="healthkit-metric-retry"
/>,
);
expect(html).toContain('data-slot="healthkit-metric-error"');
expect(html).toContain('data-slot="healthkit-metric-retry"');
expect(html).not.toContain('data-slot="query-error-row"');
});
it("takes a custom retry label", () => {
expect(
ssr(
<QueryErrorRow message="boom" onRetry={() => {}} retryLabel="Again" />,
),
).toContain("Again");
});
});
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { TagChip } from "../tag-chip";
describe("<TagChip>", () => {
it("renders the neutral chip shape on the text-2xs token", () => {
const html = renderToStaticMarkup(<TagChip>Glucose</TagChip>);
expect(html).toContain("Glucose");
expect(html).toContain('data-slot="tag-chip"');
expect(html).toContain("bg-muted");
expect(html).toContain("text-foreground");
expect(html).toContain("rounded-full");
expect(html).toContain("text-2xs");
// The arbitrary value the three hand-copied sites carried is gone.
expect(html).not.toContain("text-[0.6875rem]");
});
it("merges caller classes and forwards span props", () => {
const html = renderToStaticMarkup(
<TagChip className="ml-2" title="source label">
Steps
</TagChip>,
);
expect(html).toContain("ml-2");
expect(html).toContain('title="source label"');
// The base shape survives the merge.
expect(html).toContain("rounded-full");
});
});
+84
View File
@@ -0,0 +1,84 @@
"use client";
import type { ReactNode } from "react";
import { AlertTriangle, RotateCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
/**
* Read-failure row — the compact sibling of `<QueryErrorCard>`.
*
* Two dense metric surfaces (the HealthKit metric sub-pages and the vitals
* dashboard) had each hand-rolled the identical row: a one-line message with
* a Retry button on the same baseline. The tall centred card is the right
* shape for a primary list surface, but on a strip of tiles it dominates the
* geometry and pushes everything the user came for below the fold — so this
* shape stays, promoted to a primitive instead of being copied a third time.
*
* Pick by surface (UI-STANDARDS §6):
* - primary / list surface → `QueryErrorCard`
* - dense metric strip or tile where the tall card would dominate →
* `QueryErrorRow`
* - chart body → `ChartErrorState`
*
* One deliberate change from the copies it replaces: the message renders in
* `text-foreground`. An alert IS the content of a failed surface, and §3
* reserves muted for meta — both copies had it muted.
*/
export function QueryErrorRow({
message,
onRetry,
retryLabel,
slot,
retrySlot,
className,
}: {
/** The failure line. Defaults to the generic `common.loadFailed`. */
message?: ReactNode;
onRetry?: () => void;
retryLabel?: string;
/**
* Overrides the row's `data-slot`. The two converted surfaces keep their
* shipped hooks (`healthkit-metric-error`, `vitals-dashboard-error`) so
* their tests and any e2e selector stay stable.
*/
slot?: string;
/** Same, for the Retry button's own `data-slot`. */
retrySlot?: string;
className?: string;
}) {
const { t } = useTranslations();
return (
<div
data-slot={slot ?? "query-error-row"}
role="alert"
className={cn(
"bg-card border-border text-foreground flex flex-col items-start gap-3 rounded-xl border p-4 text-sm sm:flex-row sm:items-center sm:justify-between",
className,
)}
>
<span className="flex items-start gap-2 sm:items-center">
<AlertTriangle
className="text-muted-foreground mt-0.5 size-4 shrink-0 sm:mt-0"
aria-hidden="true"
/>
<span>{message ?? t("common.loadFailed")}</span>
</span>
{onRetry ? (
<Button
type="button"
size="sm"
variant="outline"
onClick={onRetry}
data-slot={retrySlot ?? "query-error-row-retry"}
className="gap-1.5"
>
<RotateCw className="size-3.5" aria-hidden="true" />
<span>{retryLabel ?? t("common.retry")}</span>
</Button>
) : null}
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
/**
* The small neutral tag chip that labels a card's subject line.
*
* Three Settings integration cards carried the same hand-copied literal
* (`bg-muted text-foreground rounded-full px-2 py-0.5 text-[0.6875rem]
* font-medium`) in `SettingsCardHeader`'s `titleAccessory` slot, each free to
* drift on the next touch. This is that literal, named — on the `text-2xs`
* token rather than an arbitrary value.
*
* It is deliberately NOT a `Badge`: `Badge` carries status meaning (variant →
* semantic colour), while a tag chip is a neutral noun ("Glucose", "Steps").
* Keeping them apart is what stops a neutral label from creeping into the
* status vocabulary. A chip that means a state is a `Badge`.
*
* RSC-safe: no hooks, no browser API.
*/
export function TagChip({
children,
className,
...props
}: {
children: ReactNode;
className?: string;
} & Omit<React.ComponentPropsWithoutRef<"span">, "children">) {
return (
<span
data-slot="tag-chip"
className={cn(
"bg-muted text-foreground text-2xs rounded-full px-2 py-0.5 font-medium",
className,
)}
{...props}
>
{children}
</span>
);
}