diff --git a/CHANGELOG.md b/CHANGELOG.md
index a583a8562..29a4daf39 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 971c22353..289e03009 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -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.
diff --git a/package.json b/package.json
index 407580a43..e73c58a5f 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/public/sw.js b/public/sw.js
index 385e4bc2b..c42b23c88 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -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/*`
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/app/not-found.tsx b/src/app/not-found.tsx
index 368492db0..0413a3f69 100644
--- a/src/app/not-found.tsx
+++ b/src/app/not-found.tsx
@@ -49,7 +49,7 @@ export default async function NotFound() {
{t("notFound.backToDashboard")}
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/__tests__/sleep-stage-stacked-bar.test.tsx b/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx
index 9aedff7a4..b8e48baf1 100644
--- a/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx
+++ b/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx
@@ -51,6 +51,9 @@ describe("", () => {
const html = render();
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", () => {
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
}
/>
-
+
);
}
diff --git a/src/components/insights/daily-briefing.tsx b/src/components/insights/daily-briefing.tsx
index 04c2aed48..85b586db0 100644
--- a/src/components/insights/daily-briefing.tsx
+++ b/src/components/insights/daily-briefing.tsx
@@ -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
}
/>
-
-
- {loading ? (
- <>
-
- {t("insights.dailyBriefing.loadingLabel")}
-
-
- >
- ) : 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.
-
- {/* The v1.21.2 recall/forward-look block is gone: the card
+
+ {loading ? (
+ <>
+
+ {t("insights.dailyBriefing.loadingLabel")}
+
+
+ >
+ ) : 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.
+
+ {/* 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 && (
-
+ )}
+ {/* 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 && (
-
- )}
- {/* v1.25 — the held briefing is shown, but the last refresh
+ {t("insights.dailyBriefing.noProviderAction")}
+
+
+ )}
+ {/* 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 && (
-
+ ) : noProvider ? (
+ // v1.15.20 — no provider configured anywhere: a regenerate CTA
+ // would 422 forever, so point at Settings → AI instead.
+ }
+ title={t("insights.dailyBriefing.noProviderTitle")}
+ description={t("insights.dailyBriefing.noProviderDescription")}
+ action={
+
+ }
+ />
+ ) : (
+ }
+ // 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.
- }
- />
- ) : (
- }
- // 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.
-
- ) : null
- }
- />
- )}
-
-
+ ) : null
+ }
+ />
+ )}
+
);
}
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/ecg-section.tsx b/src/components/insights/ecg-section.tsx
index 506d36655..822bf0ede 100644
--- a/src/components/insights/ecg-section.tsx
+++ b/src/components/insights/ecg-section.tsx
@@ -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")}
/>
)}
-
+
{selected ? (
>
)}
-
+
);
}
diff --git a/src/components/insights/health-status-card.tsx b/src/components/insights/health-status-card.tsx
index 4318ec413..3d7161432 100644
--- a/src/components/insights/health-status-card.tsx
+++ b/src/components/insights/health-status-card.tsx
@@ -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")}
/>
-
+
{data.deviations.map((d) => (
))}
-
+
);
}
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/insights/insight-section-card.tsx b/src/components/insights/insight-section-card.tsx
new file mode 100644
index 000000000..7f9faa3a6
--- /dev/null
+++ b/src/components/insights/insight-section-card.tsx
@@ -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 `` 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 ``
+ * 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, "children">) {
+ return (
+
+ {flush ? (
+ children
+ ) : (
+
+ {children}
+
+ )}
+
+ );
+}
diff --git a/src/components/insights/labs-changes-card.tsx b/src/components/insights/labs-changes-card.tsx
index 386924758..8e93781f9 100644
--- a/src/components/insights/labs-changes-card.tsx
+++ b/src/components/insights/labs-changes-card.tsx
@@ -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")}
/>
-
) : null}
diff --git a/src/components/insights/period-narrative-card.tsx b/src/components/insights/period-narrative-card.tsx
index dfed6d196..19264f724 100644
--- a/src/components/insights/period-narrative-card.tsx
+++ b/src/components/insights/period-narrative-card.tsx
@@ -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(`/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")}
/>
-
diff --git a/src/components/insights/rhythm-events-card.tsx b/src/components/insights/rhythm-events-card.tsx
index 078354999..9d45ca7fb 100644
--- a/src/components/insights/rhythm-events-card.tsx
+++ b/src/components/insights/rhythm-events-card.tsx
@@ -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")}
/>
-
+
{t("insights.rhythmEvents.sectionIntro")}
@@ -171,7 +169,7 @@ export function RhythmEventsCard({
{t("insights.rhythmEvents.disclaimer")}
-
+
);
}
diff --git a/src/components/insights/sleep-stage-stacked-bar.tsx b/src/components/insights/sleep-stage-stacked-bar.tsx
index 84f32fe75..0203dc2d8 100644
--- a/src/components/insights/sleep-stage-stacked-bar.tsx
+++ b/src/components/insights/sleep-stage-stacked-bar.tsx
@@ -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) {
-
-
- {t("insights.sleep.compositionTitle")}
-
+
+ {/* 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). */}
+
{t("insights.sleep.compositionSubtitle", {
nights: breakdown.nights,
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={
{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("