diff --git a/src/components/insights/derived/score-anatomy-view.tsx b/src/components/insights/derived/score-anatomy-view.tsx
index 8a9e5e154..885f24d2c 100644
--- a/src/components/insights/derived/score-anatomy-view.tsx
+++ b/src/components/insights/derived/score-anatomy-view.tsx
@@ -4,6 +4,7 @@ import type { ReactNode } from "react";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
+import { LearningGate } from "@/components/ui/learning-gate";
import type {
DerivedConfidence,
DerivedCoverage,
@@ -180,13 +181,13 @@ export function ScoreAnatomyView({
{insufficient ? (
-
- {insufficientNote ??
- t("insights.derived.anatomy.insufficient")}
-
+
) : (
diff --git a/src/components/insights/derived/trajectory-forecast-card.tsx b/src/components/insights/derived/trajectory-forecast-card.tsx
index 99208be81..9a55f9f4a 100644
--- a/src/components/insights/derived/trajectory-forecast-card.tsx
+++ b/src/components/insights/derived/trajectory-forecast-card.tsx
@@ -6,6 +6,7 @@ import { TrendingUp, TrendingDown, Minus } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
+import { LearningGate } from "@/components/ui/learning-gate";
import { CoverageMeter } from "@/components/insights/derived/coverage-meter";
import { ProvenanceExplainer } from "@/components/insights/derived/provenance-explainer";
import { METRIC_PROVENANCE } from "@/components/insights/derived/standards";
@@ -237,25 +238,18 @@ export function TrajectoryForecastCard({
return (
-
- {t("insights.derived.trajectory.insufficient")}
-
- {daysNeeded > 0 ? (
-
- {t("insights.derived.trajectory.daysNeeded", {
- count: daysNeeded,
- })}
-
- ) : null}
+ 0
+ ? t("insights.derived.trajectory.daysNeeded", {
+ count: daysNeeded,
+ })
+ : undefined
+ }
+ />
diff --git a/src/components/insights/glucose/glucose-clinical-panel.tsx b/src/components/insights/glucose/glucose-clinical-panel.tsx
index 6f70e07ae..144447eff 100644
--- a/src/components/insights/glucose/glucose-clinical-panel.tsx
+++ b/src/components/insights/glucose/glucose-clinical-panel.tsx
@@ -4,6 +4,7 @@ import { Activity, Droplet, TrendingUp } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { LearningGate } from "@/components/ui/learning-gate";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
@@ -99,18 +100,19 @@ export function GlucoseClinicalPanel() {
{t("insights.bloodGlucose.clinical.learningTitle")}
-
-
- {hasAny
- ? t("insights.bloodGlucose.clinical.learningBody", {
- count: readingsLabel,
- days: daysLabel,
- })
- : t("insights.bloodGlucose.clinical.learningBodyEmpty")}
-
-
- {t("insights.bloodGlucose.clinical.spotCaveat")}
-
+
+
);
diff --git a/src/components/insights/sleep/chronotype-card.tsx b/src/components/insights/sleep/chronotype-card.tsx
index 280b0a544..1932e7b92 100644
--- a/src/components/insights/sleep/chronotype-card.tsx
+++ b/src/components/insights/sleep/chronotype-card.tsx
@@ -6,6 +6,7 @@ import { Clock, ChevronDown, ChevronUp } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
+import { LearningGate } from "@/components/ui/learning-gate";
import type { ChronotypeDto } from "./use-sleep-rhythm";
/** Minutes-of-day → "HH:MM" wall-clock label (24 h, zero-padded). */
@@ -48,12 +49,12 @@ export function ChronotypeCard({ chronotype }: { chronotype: ChronotypeDto }) {
-
- {t("insights.sleep.chronotype.learning", {
+
+ />
);
diff --git a/src/components/insights/sleep/sleep-debt-card.tsx b/src/components/insights/sleep/sleep-debt-card.tsx
index 5558feb05..6a864d9ed 100644
--- a/src/components/insights/sleep/sleep-debt-card.tsx
+++ b/src/components/insights/sleep/sleep-debt-card.tsx
@@ -4,6 +4,7 @@ import { Moon } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { LearningGate } from "@/components/ui/learning-gate";
import type { SleepDebtDto } from "./use-sleep-rhythm";
/** Whole hours + minutes from a minute total, for the headline figure. */
@@ -36,12 +37,12 @@ export function SleepDebtCard({ debt }: { debt: SleepDebtDto }) {
-
- {t("insights.sleep.debt.learning", {
+
+ />
);
diff --git a/src/components/ui/__tests__/learning-gate.test.tsx b/src/components/ui/__tests__/learning-gate.test.tsx
new file mode 100644
index 000000000..7a043e438
--- /dev/null
+++ b/src/components/ui/__tests__/learning-gate.test.tsx
@@ -0,0 +1,52 @@
+import { describe, expect, it } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { LearningGate } from "../learning-gate";
+
+describe("", () => {
+ it("renders the message in a polite live region marked as learning", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain("Still learning your sleep.");
+ expect(html).toContain('role="status"');
+ expect(html).toContain('aria-live="polite"');
+ expect(html).toContain('data-state="learning"');
+ expect(html).toContain('data-slot="learning-gate"');
+ });
+
+ it("renders an optional caveat as a secondary line", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain("About 5 more days needed.");
+ expect(html).toContain('data-slot="learning-gate-caveat"');
+ });
+
+ it("omits the caveat line when none is given", () => {
+ const html = renderToStaticMarkup();
+ expect(html).not.toContain("learning-gate-caveat");
+ });
+
+ it("supports a per-surface body slot override for existing selectors", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('data-slot="score-anatomy-insufficient"');
+ });
+
+ it("bordered variant adds the dashed standalone surface", () => {
+ const inline = renderToStaticMarkup();
+ const bordered = renderToStaticMarkup(
+ ,
+ );
+ expect(inline).not.toContain("border-dashed");
+ expect(bordered).toContain("border-dashed");
+ });
+});
diff --git a/src/components/ui/learning-gate.tsx b/src/components/ui/learning-gate.tsx
new file mode 100644
index 000000000..7befc3245
--- /dev/null
+++ b/src/components/ui/learning-gate.tsx
@@ -0,0 +1,92 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+/**
+ * v1.17.1 — the one "we're still learning your X" gate.
+ *
+ * Before this primitive, glucose, sleep-debt, chronotype, the trajectory
+ * forecast, and the composite-score anatomy each hand-rolled the warm
+ * "not enough data yet" paragraph — five copies with different markers,
+ * different `data-slot` values, and no shared a11y contract. The calm
+ * voice held only because each author copied the tone by hand, which is
+ * exactly the drift that bites the next metric author.
+ *
+ * `LearningGate` owns the *presentation and behaviour* of a learning
+ * state — the muted body paragraph, an optional secondary caveat line,
+ * the `data-state="learning"` marker, and the polite live region — while
+ * the caller passes the metric-specific localized copy and keeps its own
+ * card title / icon chrome. One look, one behaviour, copy still per-metric.
+ *
+ * Variants:
+ * - `"inline"` (default): a bare muted paragraph for use INSIDE a card
+ * that already has its own header (glucose / sleep / trajectory).
+ * - `"bordered"`: a dashed-border centered note for STANDALONE use where
+ * there is no surrounding card chrome (score anatomy).
+ */
+export interface LearningGateProps
+ extends Omit, "title"> {
+ /** Localized primary "still learning" sentence. */
+ message: React.ReactNode;
+ /** Optional secondary line — a caveat or a "N more days" nudge. */
+ caveat?: React.ReactNode;
+ variant?: "inline" | "bordered";
+ /** Compact density for tiles / narrow rails. */
+ compact?: boolean;
+ /**
+ * Optional override for the body `data-slot`, so existing per-surface
+ * test selectors (e.g. `glucose-learning-body`,
+ * `trajectory-insufficient`, `score-anatomy-insufficient`) keep
+ * resolving after the repoint.
+ */
+ bodySlot?: string;
+}
+
+export function LearningGate({
+ message,
+ caveat,
+ variant = "inline",
+ compact = false,
+ bodySlot,
+ className,
+ ...props
+}: LearningGateProps) {
+ const body = (
+
+ {message}
+
+ );
+
+ return (
+
+ {body}
+ {caveat ? (
+
+ {caveat}
+
+ ) : null}
+
+ );
+}