From 933dfcfdfbd3adbfcb08c95c4e60d76df8f57433 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 25 Jul 2026 14:00:25 +0200 Subject: [PATCH 1/5] refactor(ui): mint the text-2xs token and the neutral TagChip Three integration cards carried the same hand-copied chip literal in the card-header accessory slot, each free to drift on the next touch; the size itself was an arbitrary value repeated at three slightly different spellings across the tree. - Mint `--text-2xs` (0.6875rem with a matched line-height) as the one sanctioned step below `text-xs`. - Mint `TagChip` and adopt it on the Fitbit, Google Health, and Nightscout cards. It stays distinct from `Badge`, which carries status meaning. - Sweep the arbitrary micro sizes: the stepper label and the two dose-ledger outline badges move onto the token; the muted target caption and the muted ledger meta row move up to the `text-xs` muted floor. No visible change beyond the two muted rows gaining a fraction of a pixel. --- src/app/globals.css | 8 ++++ .../insights/metric-target-summary.tsx | 2 +- .../medications/dose-history-ledger.tsx | 6 +-- .../medications/wizard/wizard-stepper.tsx | 2 +- .../settings/integrations/fitbit-card.tsx | 5 +-- .../integrations/google-health-card.tsx | 5 +-- .../settings/integrations/nightscout-card.tsx | 7 +--- src/components/ui/__tests__/tag-chip.test.tsx | 30 ++++++++++++++ src/components/ui/tag-chip.tsx | 41 +++++++++++++++++++ 9 files changed, 90 insertions(+), 16 deletions(-) create mode 100644 src/components/ui/__tests__/tag-chip.test.tsx create mode 100644 src/components/ui/tag-chip.tsx diff --git a/src/app/globals.css b/src/app/globals.css index 441b14be6..3e1e61a6d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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); diff --git a/src/components/insights/metric-target-summary.tsx b/src/components/insights/metric-target-summary.tsx index 2718c3b1b..199711035 100644 --- a/src/components/insights/metric-target-summary.tsx +++ b/src/components/insights/metric-target-summary.tsx @@ -354,7 +354,7 @@ function TargetReferencePanel({ } /> {heading ? ( -

+

{heading}

) : null} diff --git a/src/components/medications/dose-history-ledger.tsx b/src/components/medications/dose-history-ledger.tsx index 9b3724a91..ef6567479 100644 --- a/src/components/medications/dose-history-ledger.tsx +++ b/src/components/medications/dose-history-ledger.tsx @@ -623,7 +623,7 @@ function LedgerRowItem({ {t(`medications.detail.verlauf.status.${row.status}`)} {row.kind === "ad_hoc" && ( - + {t("medications.detail.verlauf.adHocTag")} )} @@ -634,7 +634,7 @@ function LedgerRowItem({ {row.kind === "slot" && row.pinned && ( {t("medications.detail.verlauf.pin.pinnedTag")} @@ -862,7 +862,7 @@ function LedgerSummaryChart({ style={{ width: `${pct(missed)}%` }} /> -
+
{t("medications.detail.verlauf.status.taken_on_time")}: {takenOnTime} diff --git a/src/components/medications/wizard/wizard-stepper.tsx b/src/components/medications/wizard/wizard-stepper.tsx index 765453fbc..b2477320e 100644 --- a/src/components/medications/wizard/wizard-stepper.tsx +++ b/src/components/medications/wizard/wizard-stepper.tsx @@ -162,7 +162,7 @@ export function WizardStepper({ /> - - {t("settings.fitbitTag")} - + {t("settings.fitbitTag")} - - {t("settings.googleHealthTag")} - + {t("settings.googleHealthTag")} - {t("settings.nightscoutTag")} - - } + titleAccessory={{t("settings.nightscoutTag")}} description={ ", () => { + it("renders the neutral chip shape on the text-2xs token", () => { + const html = renderToStaticMarkup(Glucose); + 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( + + Steps + , + ); + expect(html).toContain("ml-2"); + expect(html).toContain('title="source label"'); + // The base shape survives the merge. + expect(html).toContain("rounded-full"); + }); +}); diff --git a/src/components/ui/tag-chip.tsx b/src/components/ui/tag-chip.tsx new file mode 100644 index 000000000..cd8676330 --- /dev/null +++ b/src/components/ui/tag-chip.tsx @@ -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, "children">) { + return ( + + {children} + + ); +} From b4f4cb8b5531bb3a61579ab0e265526332acfd2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 25 Jul 2026 14:02:59 +0200 Subject: [PATCH 2/5] refactor(ui): promote the compact read-failure row to a primitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HealthKit metric sub-pages and the vitals dashboard had each hand-rolled the same failed-read row, byte for byte. The tall centred error card is the right shape for a list surface but dominates a dense metric strip, so the row stays — as a named primitive rather than a third copy. `QueryErrorRow` carries the shipped shape with one correction: the failure message renders in the foreground tier. An alert is the content of a failed surface, not meta about it, and both copies had it muted. The retry glyph matches its tall sibling. Both converted surfaces pass their existing slot names through, so their hooks are unchanged. --- .../insights/derived/vitals-dashboard.tsx | 28 ++----- .../insights/healthkit-metric-page.tsx | 27 ++---- .../ui/__tests__/query-error-row.test.tsx | 61 ++++++++++++++ src/components/ui/query-error-row.tsx | 84 +++++++++++++++++++ 4 files changed, 161 insertions(+), 39 deletions(-) create mode 100644 src/components/ui/__tests__/query-error-row.test.tsx create mode 100644 src/components/ui/query-error-row.tsx diff --git a/src/components/insights/derived/vitals-dashboard.tsx b/src/components/insights/derived/vitals-dashboard.tsx index a643b3c67..a1bfb06cd 100644 --- a/src/components/insights/derived/vitals-dashboard.tsx +++ b/src/components/insights/derived/vitals-dashboard.tsx @@ -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")} /> -
- {t("insights.derived.vitals.loadError")} - -
+ refetch()} + />
); diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx index e551109f1..093cb3129 100644 --- a/src/components/insights/healthkit-metric-page.tsx +++ b/src/components/insights/healthkit-metric-page.tsx @@ -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} > -
- {t("insights.subPage.loadError")} - -
+ refetch()} + /> ); } diff --git a/src/components/ui/__tests__/query-error-row.test.tsx b/src/components/ui/__tests__/query-error-row.test.tsx new file mode 100644 index 000000000..6e30b88dc --- /dev/null +++ b/src/components/ui/__tests__/query-error-row.test.tsx @@ -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( + {node}, + ); +} + +describe("", () => { + it("renders the compact alert row with foreground message text", () => { + const html = ssr(); + 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 ``. + expect(html).toContain("p-4"); + expect(html).not.toContain("py-10"); + }); + + it("falls back to the generic load-failed copy", () => { + expect(ssr()).toContain("Could not load"); + }); + + it("only renders the retry affordance when a handler is wired", () => { + expect(ssr()).not.toContain(" {}} />); + expect(withRetry).toContain(" { + const html = ssr( + {}} + 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( + {}} retryLabel="Again" />, + ), + ).toContain("Again"); + }); +}); diff --git a/src/components/ui/query-error-row.tsx b/src/components/ui/query-error-row.tsx new file mode 100644 index 000000000..d278c6c25 --- /dev/null +++ b/src/components/ui/query-error-row.tsx @@ -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 ``. + * + * 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 ( +
+ + + {onRetry ? ( + + ) : null} +
+ ); +} From 2decc37f07d42cd49773f22b3ac01119aabd7693 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 25 Jul 2026 14:09:22 +0200 Subject: [PATCH 3/5] 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. --- .../__tests__/insight-section-card.test.tsx | 122 ++++++ .../insights/breathing-screening-card.tsx | 8 +- .../insights/coincident-deviation-card.tsx | 16 +- src/components/insights/daily-briefing.tsx | 375 +++++++++--------- src/components/insights/ecg-section.tsx | 8 +- .../insights/health-status-card.tsx | 8 +- .../insights/insight-section-card.tsx | 66 +++ src/components/insights/labs-changes-card.tsx | 8 +- .../insights/period-narrative-card.tsx | 21 +- .../insights/rhythm-events-card.tsx | 8 +- 10 files changed, 405 insertions(+), 235 deletions(-) create mode 100644 src/components/insights/__tests__/insight-section-card.test.tsx create mode 100644 src/components/insights/insight-section-card.tsx diff --git a/src/components/insights/__tests__/insight-section-card.test.tsx b/src/components/insights/__tests__/insight-section-card.test.tsx new file mode 100644 index 000000000..487e93b63 --- /dev/null +++ b/src/components/insights/__tests__/insight-section-card.test.tsx @@ -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("", () => { + it("composes Card with the pinned overview rhythm", () => { + const html = renderToStaticMarkup( + +

body

+
, + ); + 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( + +

body

+
, + ); + 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( + +
row
+
, + ); + 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 ` src.replace(/\s+/g, " ")) + .join("\n"); + + for (const slot of MIGRATED_SLOTS) { + expect( + sources, + `${slot} should be rendered through `, + ).toContain(` { + 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([]); + }); +}); diff --git a/src/components/insights/breathing-screening-card.tsx b/src/components/insights/breathing-screening-card.tsx index 0da2d22fc..4ef705c67 100644 --- a/src/components/insights/breathing-screening-card.tsx +++ b/src/components/insights/breathing-screening-card.tsx @@ -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")} /> -
+ {classificationLabel ? (

{t("insights.breathingScreening.disclaimer")}

-
+
); } diff --git a/src/components/insights/coincident-deviation-card.tsx b/src/components/insights/coincident-deviation-card.tsx index d7ddcbd24..74fc12075 100644 --- a/src/components/insights/coincident-deviation-card.tsx +++ b/src/components/insights/coincident-deviation-card.tsx @@ -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 } /> -
{children} -
+
); } @@ -155,14 +155,14 @@ function CardSkeleton({ className }: { className?: string }) { icon={Activity} title={t("insights.derived.coincident.cardTitle")} /> -