diff --git a/messages/de.json b/messages/de.json
index da0e2d9ae..0e7bda121 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -3397,6 +3397,7 @@
"sectionSubtitle": "Ruhewerte von Herz-Kreislauf und Atmung im Vergleich zu deiner eigenen Baseline",
"loadingLabel": "Vitalwerte werden geladen",
"loadError": "Deine Vitalwerte konnten gerade nicht geladen werden.",
+ "infoLabel": "Wie wird das berechnet?",
"building": "Typischer Bereich entsteht — {count} von {target} Tagen",
"typicalRange": "Typisch für dich: {low}–{high}",
"fitnessYounger": "Ausdauer wie ~{years} J. jünger",
diff --git a/messages/en.json b/messages/en.json
index 6d7047b16..c2195f3dc 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -3397,6 +3397,7 @@
"sectionSubtitle": "Resting cardiovascular and respiratory signals against your own baseline",
"loadingLabel": "Loading your vitals",
"loadError": "Could not load your vitals right now.",
+ "infoLabel": "How is this calculated?",
"building": "Building your typical range — {count} of {target} days",
"typicalRange": "Typical for you: {low}–{high}",
"fitnessYounger": "Cardio fitness of someone ~{years} yr younger",
diff --git a/messages/es.json b/messages/es.json
index 79e7d5692..a29101256 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -3397,6 +3397,7 @@
"sectionSubtitle": "Señales cardiovasculares y respiratorias en reposo frente a tu propia línea base",
"loadingLabel": "Cargando tus constantes",
"loadError": "No se pudieron cargar tus constantes en este momento.",
+ "infoLabel": "¿Cómo se calcula esto?",
"building": "Creando tu rango habitual — {count} de {target} días",
"typicalRange": "Habitual para ti: {low}–{high}",
"fitnessYounger": "Forma cardiovascular de alguien ~{years} años más joven",
diff --git a/messages/fr.json b/messages/fr.json
index 5d723b8ee..d3726d425 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -3397,6 +3397,7 @@
"sectionSubtitle": "Signaux cardiovasculaires et respiratoires au repos par rapport à ta propre référence",
"loadingLabel": "Chargement de vos constantes",
"loadError": "Impossible de charger vos constantes pour le moment.",
+ "infoLabel": "Comment ce résultat est-il calculé ?",
"building": "Construction de votre plage habituelle — {count} sur {target} jours",
"typicalRange": "Habituel pour vous : {low}–{high}",
"fitnessYounger": "Forme cardio d'une personne ~{years} ans plus jeune",
diff --git a/messages/it.json b/messages/it.json
index 02336401e..b07a3cf88 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -3397,6 +3397,7 @@
"sectionSubtitle": "Segnali cardiovascolari e respiratori a riposo rispetto alla tua baseline personale",
"loadingLabel": "Caricamento dei tuoi parametri vitali",
"loadError": "Al momento non è stato possibile caricare i tuoi parametri vitali.",
+ "infoLabel": "Come viene calcolato questo dato?",
"building": "Creazione del tuo intervallo abituale — {count} di {target} giorni",
"typicalRange": "Abituale per te: {low}–{high}",
"fitnessYounger": "Forma cardio di chi ha ~{years} anni in meno",
diff --git a/messages/pl.json b/messages/pl.json
index eddaff186..592f41484 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -3397,6 +3397,7 @@
"sectionSubtitle": "Spoczynkowe sygnały sercowo-naczyniowe i oddechowe na tle Twojej własnej linii bazowej",
"loadingLabel": "Wczytywanie parametrów życiowych",
"loadError": "Nie udało się teraz wczytać Twoich parametrów życiowych.",
+ "infoLabel": "Jak to jest obliczane?",
"building": "Budowanie Twojego typowego zakresu — {count} z {target} dni",
"typicalRange": "Typowe dla Ciebie: {low}–{high}",
"fitnessYounger": "Kondycja jak u osoby ~{years} lat młodszej",
diff --git a/src/components/insights/derived/vitals-dashboard.tsx b/src/components/insights/derived/vitals-dashboard.tsx
index 02c8dfe51..57aa57ddf 100644
--- a/src/components/insights/derived/vitals-dashboard.tsx
+++ b/src/components/insights/derived/vitals-dashboard.tsx
@@ -16,7 +16,7 @@ import { SectionHeading } from "@/components/insights/section-heading";
import { SparklineDeltaTile } from "./sparkline-delta-tile";
import { LearnMoreLink } from "@/components/ui/learn-more-link";
import { CoverageMeter } from "./coverage-meter";
-import { ProvenanceExplainer } from "./provenance-explainer";
+import { VitalsInfoTip } from "./vitals-info-tip";
import { METRIC_PROVENANCE } from "./standards";
import { type DerivedBatchRead } from "./use-derived-metric";
import {
@@ -169,10 +169,16 @@ function tileOrder(layout: InsightsLayout | undefined, tileId: string): number {
return resolveTileLayout(layout, tileId).order;
}
-/** The provenance ⓘ explainer for one derived metric, wired from the map. */
+/**
+ * The (i) info-tip trigger for one derived metric, wired from the map.
+ * `provenance` (inputs / source / window / asOf) isn't rendered here —
+ * `VitalsInfoTip` carries only the method/caveat + cited standard, mirroring
+ * what `ProvenanceExplainer` itself renders today — but the param is kept so
+ * every call site (which already reads `data.provenance` off the derived
+ * batch) doesn't need touching if a future pass restores the detail.
+ */
function MetricProvenance({
metric,
- provenance,
}: {
metric: keyof typeof METRIC_PROVENANCE;
provenance: DerivedProvenance;
@@ -189,13 +195,7 @@ function MetricProvenance({
{t(meta.methodKey)}
>
);
- return (
-
- );
+ return ;
}
interface TileProps {
@@ -288,14 +288,14 @@ function BaselineTile({
framing={framing}
directionSentiment={vitalSentiment(type)}
provenance={
- // v1.29.1 — the VITALS_BASELINE "typical range = median ± MAD" method
- // caption is suppressed on its own tiles: it repeated across every
- // vital baseline tile and read as out-of-place clutter at the top of
- // the vitals surface. The dedicated bands (wrist-temp, stair speed,
- // 6-min walk) keep their method copy — only VITALS_BASELINE is muted.
- metric === "VITALS_BASELINE" ? undefined : (
-
- )
+ // v1.29.1 muted this on VITALS_BASELINE tiles because the always-on
+ // "typical range = median ± MAD" caption repeated across every vital
+ // baseline tile and read as clutter. v1.29.2 restores the context —
+ // now behind the compact (i) trigger (`VitalsInfoTip`) instead of a
+ // full-width caption, so every baseline tile carries it again
+ // without the repetition or the header-row squeeze that clipped the
+ // cardio-fitness heading.
+
}
footer={}
/>
diff --git a/src/components/insights/derived/vitals-info-tip.tsx b/src/components/insights/derived/vitals-info-tip.tsx
new file mode 100644
index 000000000..753ab3fbc
--- /dev/null
+++ b/src/components/insights/derived/vitals-info-tip.tsx
@@ -0,0 +1,90 @@
+"use client";
+
+import { useState } from "react";
+import { Info } from "lucide-react";
+
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { useTranslations } from "@/lib/i18n/context";
+import { cn } from "@/lib/utils";
+import type { ProvenanceStandard } from "./provenance-explainer";
+
+/**
+ * v1.29.2 QoL — the vitals-dashboard tile's (i) info affordance.
+ *
+ * v1.29.1 dropped the always-on "your personal normal range is the
+ * median…" method caption from every `VITALS_BASELINE` tile because it
+ * repeated verbatim across up to six tiles and read as clutter at the top
+ * of the vitals surface — and, on the narrower tiles, squeezed the heading
+ * itself (the VO₂max/cardio-fitness clip). Rather than reintroduce the
+ * caption at full length, the SAME method/caveat/standard content moves
+ * behind a small (i) trigger: a click-opened `Popover` (not a hover
+ * tooltip — the vitals grid sees most of its use on touch), so the context
+ * is one tap away instead of permanently occupying header space.
+ *
+ * This is a DELIBERATE, narrowly-scoped exception to the v1.22 "no
+ * icon-only disclosure" direction (`ProvenanceExplainer`'s own docstring):
+ * that decision targeted the always-visible-caption pattern on OTHER
+ * surfaces (coincident-deviation, the score-anatomy pages), which stay
+ * unchanged. The vitals grid is dense enough, and the caption repetitive
+ * enough, that the same content is better one tap away here.
+ */
+export interface VitalsInfoTipProps {
+ /** Plain-language method (+ optional caveat) description — text children only. */
+ method: React.ReactNode;
+ /** Optional cited standard, rendered as a plain external link. */
+ standard?: ProvenanceStandard;
+ className?: string;
+}
+
+export function VitalsInfoTip({
+ method,
+ standard,
+ className,
+}: VitalsInfoTipProps) {
+ const { t } = useTranslations();
+ const [open, setOpen] = useState(false);
+
+ return (
+
+
+
+
+
+