diff --git a/src/components/charts/trend-card-skeleton.tsx b/src/components/charts/trend-card-skeleton.tsx
index 67f75d5fe..7527983dc 100644
--- a/src/components/charts/trend-card-skeleton.tsx
+++ b/src/components/charts/trend-card-skeleton.tsx
@@ -1,5 +1,6 @@
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
+import { TrendTile } from "@/components/ui/trend-tile";
/**
* v1.16.0 — structured loading silhouette for one dashboard trend tile.
@@ -23,15 +24,12 @@ import { cn } from "@/lib/utils";
*/
export function TrendCardSkeleton({ className }: { className?: string }) {
return (
-
` plus the `min-h-[8rem]`
- // floor the legacy empty silhouette reserved.
- "bg-card border-border flex h-full min-h-[8rem] w-full min-w-0 flex-col overflow-hidden rounded-xl border p-4 md:p-6",
- className,
- )}
+ // The frame `
` uses, plus the `min-h-[8rem]` floor the
+ // legacy empty silhouette reserved.
+ className={cn("min-h-[8rem]", className)}
>
{/* Heading row — label + icon (TrendCard pins this row at h-5). */}
@@ -51,6 +49,6 @@ export function TrendCardSkeleton({ className }: { className?: string }) {
-
+
);
}
diff --git a/src/components/charts/trend-card.tsx b/src/components/charts/trend-card.tsx
index 58c9800e7..24a13cacb 100644
--- a/src/components/charts/trend-card.tsx
+++ b/src/components/charts/trend-card.tsx
@@ -16,6 +16,7 @@ import {
sentimentColorClass,
type TrendDirectionSentiment,
} from "@/lib/insights/trend-sentiment";
+import { TrendTile } from "@/components/ui/trend-tile";
interface SecondaryMetric {
/** Sub-value latest reading (e.g. diastolic when latest is systolic). */
@@ -204,7 +205,7 @@ export function TrendCard({
};
return (
-
+
{/* v1.4.25 — single-line discipline + deterministic height for
the heading row so the value row below sits at the same baseline
across every tile. The label is locale-abbreviated upstream (see
@@ -465,6 +466,6 @@ export function TrendCard({
sub-rows. */}
-
+
);
}
diff --git a/src/components/insights/derived/sparkline-delta-tile.tsx b/src/components/insights/derived/sparkline-delta-tile.tsx
index ed738f0e6..9adccc5e5 100644
--- a/src/components/insights/derived/sparkline-delta-tile.tsx
+++ b/src/components/insights/derived/sparkline-delta-tile.tsx
@@ -10,6 +10,7 @@ import {
type TrendDirectionSentiment,
} from "@/lib/insights/trend-sentiment";
import { TileHeader } from "@/components/insights/tile-header";
+import { TrendTile } from "@/components/ui/trend-tile";
/**
* v1.10.0 — the Apple-Health-Highlights grid tile.
@@ -153,13 +154,7 @@ export function SparklineDeltaTile({
: "var(--muted-foreground)";
return (
-
+
{/* The canonical tile-header primitive (was a hand-rolled copy of it):
leading foreground icon + foreground `CardTitle` heading, provenance
in the `right` slot. Routing through the primitive keeps this dense
@@ -269,6 +264,6 @@ export function SparklineDeltaTile({
{footer}
) : null}
-
+
);
}
diff --git a/src/components/insights/derived/vitals-dashboard.tsx b/src/components/insights/derived/vitals-dashboard.tsx
index e86fe7efd..562d96733 100644
--- a/src/components/insights/derived/vitals-dashboard.tsx
+++ b/src/components/insights/derived/vitals-dashboard.tsx
@@ -1,7 +1,13 @@
"use client";
-import { useMemo, type ReactNode } from "react";
-import { Footprints, Gauge, HeartPulse, Scale } from "lucide-react";
+import { useMemo, type ComponentProps, type ReactNode } from "react";
+import {
+ Footprints,
+ Gauge,
+ HeartPulse,
+ Scale,
+ type LucideIcon,
+} from "lucide-react";
import { cn } from "@/lib/utils";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Skeleton } from "@/components/ui/skeleton";
@@ -37,6 +43,7 @@ import type { BmiValue } from "@/lib/insights/derived/bmi";
import type { SixMinuteWalkValue } from "@/lib/insights/derived/six-minute-walk";
import type { SameTimeBaselineValue } from "@/lib/insights/derived/same-time-baseline";
import type { DerivedProvenance } from "@/lib/insights/derived/types";
+import { TrendTile } from "@/components/ui/trend-tile";
/**
* v1.10.0 — the Vitals dashboard surface (Apple-Health-Highlights grid).
@@ -240,29 +247,19 @@ function BaselineTile({
// Provisional — building the band; show value-less coverage state.
if (data.status === "insufficient") {
return (
-
-
-
- {t("insights.derived.vitals.building", {
- count: data.coverage.historyDays,
- target: 7,
- })}
-
-
-
+
);
}
@@ -349,29 +346,19 @@ function SameTimeBaselineTile({
// occupying the grid with an apology.
if (data.reason !== "learning_usual_day") return null;
return (
-
-
-
- {t("insights.derived.sameTime.learning", {
- count: data.coverage.historyDays,
- target: SAME_TIME_BASELINE_MIN_HISTORY_DAYS,
- })}
-
-
-
+
);
}
@@ -556,26 +543,19 @@ function HrvBalanceTile({ read, isLoading }: TileProps) {
}
if (data.status === "insufficient") {
return (
-
-
-
- {t("insights.derived.vitals.building", {
- count: data.coverage.historyDays,
- target: 7,
- })}
-
-
-
+
);
}
const v = data.value!;
@@ -625,6 +605,51 @@ function BmiTile({ read, isLoading }: TileProps) {
);
}
+/**
+ * A tile whose band is still being built: the header, one sentence saying how
+ * far the history has come, and the coverage meter. The baseline, same-time
+ * and HRV tiles each spelled this out by hand.
+ */
+function BuildingTile({
+ slot,
+ metric,
+ state,
+ icon,
+ title,
+ message,
+ messageSlot,
+ coverage,
+}: {
+ slot: string;
+ metric: string;
+ state: string;
+ icon: LucideIcon;
+ title: string;
+ message: string;
+ messageSlot: string;
+ coverage: ComponentProps["coverage"];
+}) {
+ return (
+
+
+
+ {message}
+
+
+
+ );
+}
+
/**
* A loading placeholder occupying the resolved tile footprint so the grid
* reserves its final height and the real tiles drop in without a layout
@@ -633,10 +658,10 @@ function BmiTile({ read, isLoading }: TileProps) {
*/
function VitalsTileSkeleton() {
return (
-
@@ -644,7 +669,7 @@ function VitalsTileSkeleton() {
-
+
);
}
diff --git a/src/components/ui/__tests__/trend-tile.test.tsx b/src/components/ui/__tests__/trend-tile.test.tsx
new file mode 100644
index 000000000..9938367dc
--- /dev/null
+++ b/src/components/ui/__tests__/trend-tile.test.tsx
@@ -0,0 +1,39 @@
+import { describe, expect, it } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { TrendTile } from "../trend-tile";
+
+describe("", () => {
+ it("paints the tile frame on the card inset and clips its content", () => {
+ const html = renderToStaticMarkup(x);
+ for (const c of [
+ "bg-card",
+ "border",
+ "rounded-xl",
+ "p-4",
+ "md:p-6",
+ "h-full",
+ "min-w-0",
+ "overflow-hidden",
+ ]) {
+ expect(html).toContain(c);
+ }
+ expect(html).toContain('data-slot="trend-tile"');
+ expect(html).not.toContain("shadow");
+ });
+
+ it("keeps the caller's slot, state and rhythm", () => {
+ const html = renderToStaticMarkup(
+
+ x
+ ,
+ );
+ expect(html).toContain('data-slot="vitals-tile"');
+ expect(html).toContain('data-state="provisional"');
+ expect(html).toContain("gap-3");
+ });
+});
diff --git a/src/components/ui/trend-tile.tsx b/src/components/ui/trend-tile.tsx
new file mode 100644
index 000000000..242465951
--- /dev/null
+++ b/src/components/ui/trend-tile.tsx
@@ -0,0 +1,33 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+/**
+ * The frame of a metric trend tile: the dashboard KPI strip (`TrendCard`),
+ * the derived-insight tiles (`SparklineDeltaTile`), their skeletons and the
+ * "still building" states of the vitals board.
+ *
+ * Seven copies spelled this frame out by hand, and they had started to
+ * differ (some clipped their content, some did not). The frame is a dense
+ * tile of its own rather than a `Card`: it sits in a grid of equal-height
+ * tiles (`h-full`), insets on the card padding (`p-4 md:p-6`, UI standards
+ * §1), carries no shadow, and clips a value that would otherwise push the
+ * grid cell wider (`min-w-0 overflow-hidden`).
+ *
+ * The vertical rhythm inside belongs to the tile: pass `gap-3` for a stacked
+ * state, or let the children set their own `mt-*` steps as the loaded tiles do.
+ */
+function TrendTile({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+export { TrendTile };