Remove the trailing info glyph across the insights surface

The metric sub-page heading carried a trailing info "i" that opened a
reference-guidance tooltip on every routed insights page, and four card
satellites repeated the pattern (mood statistical caveat, health-score
delta read, period-narrative provenance, derived-score provenance). Drop
the glyph and its disclosure everywhere. Where the disclosure held the
only copy of a descriptive line, that line now reads inline as a muted
caption so the information stays visible instead of hiding behind a hover
affordance.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 13:02:05 +02:00
1 parent 42d49953f5
commit f5e9d875cc
11 files changed
+150 -545

No files matched your search

@@ -198,8 +198,8 @@ describe("<CoincidentDeviationCard>", () => {
// At most amber — never destructive/red.
expect(html).toContain("border-warning");
expect(html).not.toContain("text-destructive");
// The provenance affordance reaches the card.
expect(html).toContain('data-slot="provenance-explainer-trigger"');
// The provenance method reaches the card.
expect(html).toContain('data-slot="provenance-explainer-method"');
// The state change is announced politely to SR users (not assertively).
expect(html).toContain('role="status"');
expect(html).not.toContain('role="alert"');
@@ -129,10 +129,11 @@ describe("<HealthScoreCard>", () => {
});
it("threads aria-describedby from the delta text to the explainer body", () => {
// FB-I1 a11y — the delta `<span>` carries aria-describedby; the
// explainer trigger's aria-controls reads the same id. Both
// attributes share one `useId()` thread so SR can connect
// "−3 vs last week" to the three-sentence read.
// FB-I1 a11y — the delta `<span>` carries aria-describedby; the inline
// explainer caption carries the same id. v1.22 — the read no longer
// hides behind a "?" trigger (no aria-controls); the body renders inline
// and the shared `useId()` thread still connects "−3 vs last week" to the
// three-sentence read.
const html = ssr(
<HealthScoreCard
score={64}
@@ -148,8 +149,12 @@ describe("<HealthScoreCard>", () => {
expect(deltaSpan).not.toBeNull();
const threadedId = deltaSpan?.[1] ?? "";
expect(threadedId).not.toBe("");
// The same id sits on the trigger button's aria-controls.
expect(html).toContain(`aria-controls="${threadedId}"`);
// The same id sits on the inline explainer body.
expect(html).toMatch(
new RegExp(
`<span[^>]*id="${threadedId}"[^>]*data-slot="health-score-delta-explainer-body"`,
),
);
});
it("renders four component rows with their values", () => {
@@ -4,16 +4,15 @@ import { I18nProvider, useTranslations } from "@/lib/i18n/context";
import { HealthScoreDeltaExplainer } from "../health-score-delta-explainer";
/**
* v1.4.28 R3c-Insights — FB-I1 — the "?" affordance next to the delta
* line opens a 3-sentence read of which components contributed, what
* the comparison window is, and one concrete next step.
* v1.4.28 R3c-Insights — FB-I1 — the "vs last week" delta carries a
* three-sentence read of which components contributed, what the comparison
* window is, and one concrete next step.
*
* Tests run through SSR so they don't depend on a browser surface.
* The popover / sheet body mounts on click on the live page; the
* SSR snapshot captures the trigger button (always present) plus a
* locale-grounded check that the body copy actually exists in the
* translation payload — the load-bearing constraint is "every
* locale ships the 3-sentence read", not the popover open animation.
* v1.22 — the read no longer hides behind a "?" glyph + popover; it renders
* inline as a muted caption beside the delta line. These tests run through
* SSR (no browser surface) and pin: the inline body paints by default, it
* threads the parent-supplied id for aria-describedby, and every locale
* ships the body copy.
*/
function ssr(node: React.ReactNode, locale: "en" | "de" = "en") {
@@ -23,114 +22,42 @@ function ssr(node: React.ReactNode, locale: "en" | "de" = "en") {
}
/**
* Helper that paints the explainer body via the same `t()` call the
* popover uses. If a future refactor drops a locale key the test
* fails at the `t()` call site rather than silently rendering the
* fallback chevron.
* Helper that paints the explainer body via the same `t()` call the caption
* uses. If a future refactor drops a locale key the test fails at the `t()`
* call site rather than silently rendering the literal key path.
*/
function BodyProbe() {
const { t } = useTranslations();
return (
<div>
<span data-slot="probe-trigger">
{t("insights.healthScore.deltaExplainer.trigger")}
</span>
<span data-slot="probe-title">
{t("insights.healthScore.deltaExplainer.title")}
</span>
<span data-slot="probe-body">
{t("insights.healthScore.deltaExplainer.body")}
</span>
</div>
<span data-slot="probe-body">
{t("insights.healthScore.deltaExplainer.body")}
</span>
);
}
describe("<HealthScoreDeltaExplainer>", () => {
it("renders the icon-only trigger button with an accessible label (EN)", () => {
it("renders the read inline as a muted caption (no trigger glyph)", () => {
const html = ssr(<HealthScoreDeltaExplainer delta={-3} />);
const trigger = html.match(
/<button[^>]*data-slot="health-score-delta-explainer-trigger"[^>]*>/,
expect(html).toContain('data-slot="health-score-delta-explainer-body"');
// The old icon-only disclosure trigger is gone.
expect(html).not.toContain(
'data-slot="health-score-delta-explainer-trigger"',
);
expect(trigger).not.toBeNull();
expect(trigger?.[0]).toContain('aria-label="What does this delta mean?"');
});
it("lifts the tap target to the 44 px WCAG 2.5.5 floor", () => {
// The glyph stays 12 px but the click surface inflates to 44 px
// via `min-h-11 min-w-11` on the trigger; the parent row keeps
// its 16 px stride because `-my-3 -mx-2` swallows the extra
// reach. Pinning so a future refactor can't drop back to the
// visual-chip-as-hit-target shape that failed WCAG 2.5.5.
const html = ssr(<HealthScoreDeltaExplainer delta={-3} />);
const trigger = html.match(
/<button[^>]*data-slot="health-score-delta-explainer-trigger"[^>]*>/,
);
expect(trigger).not.toBeNull();
expect(trigger?.[0]).toContain("min-h-11");
expect(trigger?.[0]).toContain("min-w-11");
});
it("threads aria-expanded + aria-controls onto the trigger button", () => {
// SR users get a clear "this opens a disclosure" cue via
// aria-expanded; the aria-controls value is the same id the
// body paints so the SR can jump to the explanation after
// the user activates the trigger.
const html = ssr(<HealthScoreDeltaExplainer delta={-3} />);
const trigger = html.match(
/<button[^>]*data-slot="health-score-delta-explainer-trigger"[^>]*>/,
);
expect(trigger).not.toBeNull();
expect(trigger?.[0]).toContain("aria-expanded");
expect(trigger?.[0]).toContain("aria-controls");
expect(html).not.toContain("aria-expanded");
});
it("uses the parent-supplied bodyId so aria-describedby can thread to it", () => {
// Parent owns the id and paints aria-describedby on the delta
// span; the explainer re-uses the same id for its
// aria-controls thread. The shared id is the seam SR uses to
// jump from the digit to the explanation.
const html = ssr(
<HealthScoreDeltaExplainer delta={-3} bodyId="parent-id-x" />,
);
const trigger = html.match(
/<button[^>]*data-slot="health-score-delta-explainer-trigger"[^>]*>/,
expect(html).toMatch(
/<span[^>]*id="parent-id-x"[^>]*data-slot="health-score-delta-explainer-body"/,
);
expect(trigger?.[0]).toContain('aria-controls="parent-id-x"');
});
it("does not wrap the trigger in a clickable <span> (single interactive element)", () => {
// The earlier mobile path wrapped the button in a
// `<span onClick onKeyDown>` which created two interactive
// elements in the a11y tree and intercepted clicks on the 2 px
// gap around the button. The button now owns the open toggle
// directly. Pinning the negative so a future refactor can't
// reintroduce the wrapper.
it("paints the body copy on the initial SSR snapshot (always visible)", () => {
const html = ssr(<HealthScoreDeltaExplainer delta={-3} />);
expect(html).not.toMatch(/<span[^>]*onclick/i);
});
it("renders the trigger in German with the localised aria-label", () => {
const html = ssr(<HealthScoreDeltaExplainer delta={2} />, "de");
const trigger = html.match(
/<button[^>]*data-slot="health-score-delta-explainer-trigger"[^>]*>/,
);
expect(trigger).not.toBeNull();
expect(trigger?.[0]).toContain(
'aria-label="Was bedeutet diese Veränderung?"',
);
});
it("does not paint the popover/sheet body on the initial SSR snapshot", () => {
// Closed-by-default contract: the trigger button is the only
// surface the SSR snapshot owns; the body lives in a portal that
// mounts after the user taps. Pinning the negative side keeps a
// future "open on render" refactor from leaking long-form copy
// into the static markup.
const html = ssr(<HealthScoreDeltaExplainer delta={-3} />);
expect(html).not.toContain('data-slot="health-score-delta-explainer-body"');
expect(html).not.toContain(
'data-slot="health-score-delta-explainer-title"',
);
expect(html).toContain("BP, weight, mood and medication adherence");
});
it("ships the EN body copy through the locale payload (3-sentence read)", () => {
@@ -171,20 +98,18 @@ describe("<HealthScoreDeltaExplainer>", () => {
expect(text).toContain("vor einer Woche");
});
it("ships the trigger + title + body keys in every locale supported by R3c-Insights", () => {
// Pinning every locale stops a future feature branch from
// shipping an English fallback for the new explainer keys.
it("ships the body key in every locale supported by R3c-Insights", () => {
// Pinning every locale stops a future feature branch from shipping an
// English fallback for the explainer body key.
for (const locale of ["en", "de", "fr", "es", "it", "pl"] as const) {
const html = renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<BodyProbe />
</I18nProvider>,
);
// The probe re-uses the same `t()` payload the popover does,
// so any missing key surfaces as the literal key path which
// would never contain a space.
expect(html).not.toContain("insights.healthScore.deltaExplainer.trigger");
expect(html).not.toContain("insights.healthScore.deltaExplainer.title");
// The probe re-uses the same `t()` payload the caption does, so any
// missing key surfaces as the literal key path which never contains a
// space.
expect(html).not.toContain("insights.healthScore.deltaExplainer.body");
}
});
@@ -26,7 +26,7 @@ const EMPTY_PROVENANCE: DerivedProvenance = {
};
describe("<ProvenanceExplainer>", () => {
it("renders an accessible trigger that controls the body (collapsed by default)", () => {
it("renders the method inline as a muted caption (no trigger glyph)", () => {
const html = render(
<ProvenanceExplainer
provenance={PROVENANCE}
@@ -34,28 +34,21 @@ describe("<ProvenanceExplainer>", () => {
bodyId="prov-1"
/>,
);
expect(html).toContain('data-slot="provenance-explainer-trigger"');
expect(html).toContain('aria-expanded="false"');
expect(html).toContain('aria-controls="prov-1"');
expect(html).toContain("aria-label");
expect(html).toContain('data-slot="provenance-explainer-method"');
expect(html).toContain("BMI is weight divided by height squared.");
expect(html).toContain('id="prov-1"');
// The old icon-only disclosure trigger is gone.
expect(html).not.toContain('data-slot="provenance-explainer-trigger"');
});
it("meets the 44px hit-target floor on the trigger", () => {
const html = render(
<ProvenanceExplainer provenance={PROVENANCE} method="method copy" />,
);
expect(html).toContain("min-h-11");
expect(html).toContain("min-w-11");
});
it("renders the trigger for the empty-inputs / bad-date provenance without throwing", () => {
it("renders the empty-inputs / bad-date provenance without throwing", () => {
const html = render(
<ProvenanceExplainer
provenance={EMPTY_PROVENANCE}
method="method copy"
/>,
);
expect(html).toContain('data-slot="provenance-explainer-trigger"');
expect(html).toContain('data-slot="provenance-explainer-method"');
});
it("accepts a cited standard prop without erroring", () => {
@@ -69,6 +62,6 @@ describe("<ProvenanceExplainer>", () => {
}}
/>,
);
expect(html).toContain('data-slot="provenance-explainer-trigger"');
expect(html).toContain('data-slot="provenance-explainer-method"');
});
});
@@ -55,9 +55,9 @@ describe("<ScoreAnatomyView>", () => {
// The missing contributor renders present=false (dropped, not blank).
expect(html).toContain('data-contributor="respiratory"');
expect(html).toContain('data-present="false"');
expect(html).toContain('data-slot="provenance-explainer-trigger"');
expect(html).toContain('data-slot="provenance-explainer-method"');
// v1.15.12 F4 — the "keine klinische Bewertung" footer line is removed;
// the non-clinical framing now lives behind the provenance explainer.
// the non-clinical framing now lives in the inline provenance method.
expect(html).not.toContain('data-slot="score-anatomy-disclaimer"');
});
@@ -1,31 +1,19 @@
"use client";
import { useId, useState } from "react";
import { ExternalLink, Info } from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { useIsMobile } from "@/hooks/use-is-mobile";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { DerivedProvenance } from "@/lib/insights/derived/types";
/**
* v1.10.0 — the "how this is computed / standard cited" surface.
* v1.10.0 — the "how this is computed" surface.
*
* A collapsible `ⓘ` explainer that renders, as React text children (NO
* markdown library — the XSS rule), the metric's inputs, a plain-language
* method, and an optional link to the cited standard (IEEE / RFC / WHO /
* LOINC / Wikipedia). Reads the `provenance` field off `Derived<T>` for the
* inputs chip + the "as of" + window framing.
*
* Surface follows the shipped `health-score-delta-explainer` pattern:
* Popover on `md+`, bottom-sheet on phone-class viewports via
* `ResponsiveSheet`. Owns its own open state. aria-describedby threads the
* trigger to the body.
* v1.22 — the collapsible `ⓘ` glyph that gated the method/inputs/window read
* behind a popover / bottom-sheet is removed; the maintainer wants the
* trailing info affordance gone across the insights surface. The
* plain-language method now renders inline as a muted caption (React text
* children only — the XSS rule still forbids a markdown library), so the
* "how computed" line is always visible rather than disclosure-only. The
* `provenance`, `standard` and `title` props are retained for callers; the
* inputs-chip / window / cited-standard detail folded out with the popover.
*/
export interface ProvenanceStandard {
@@ -43,167 +31,31 @@ export interface ProvenanceExplainerProps {
* passes a localised string; the component never parses markup.
*/
method: React.ReactNode;
/** Optional cited standard rendered as an external link in the footer. */
/** Optional cited standard. Retained for callers. */
standard?: ProvenanceStandard;
/** Accessible title of the surface (sheet header / popover first line). */
/** Accessible title of the surface. Retained for callers. */
title?: string;
/** Optional className for the trigger button. */
/** Optional className for the caption. */
className?: string;
/** Optional id the parent threads as aria-describedby on its own element. */
bodyId?: string;
}
export function ProvenanceExplainer({
provenance,
method,
standard,
title,
className,
bodyId,
}: ProvenanceExplainerProps) {
const { t, locale } = useTranslations();
const isMobile = useIsMobile();
const [open, setOpen] = useState(false);
const generatedId = useId();
const resolvedBodyId = bodyId ?? generatedId;
const resolvedTitle = title ?? t("insights.derived.provenance.title");
const triggerLabel = t("insights.derived.provenance.trigger");
const asOf = (() => {
const parsed = new Date(provenance.computedAt);
if (Number.isNaN(parsed.getTime())) return null;
try {
return new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "short",
day: "2-digit",
}).format(parsed);
} catch {
return null;
}
})();
const body = (
<div
id={resolvedBodyId}
data-slot="provenance-explainer-body"
className="space-y-3"
>
<div data-slot="provenance-explainer-inputs">
<p className="text-foreground text-xs font-semibold">
{t("insights.derived.provenance.inputsLabel")}
</p>
{provenance.inputs.length > 0 ? (
<ul className="mt-1 flex flex-wrap gap-1.5">
{provenance.inputs.map((input) => (
<li
key={input}
data-slot="provenance-explainer-input-chip"
className="border-info/40 text-info inline-flex items-center rounded-full border bg-transparent px-1.5 py-0.5 text-[10px] leading-none"
>
{input}
</li>
))}
</ul>
) : (
<p className="text-muted-foreground mt-1 text-[11px]">
{t("insights.derived.provenance.noInputs")}
</p>
)}
</div>
<div data-slot="provenance-explainer-method">
<p className="text-foreground text-xs font-semibold">
{t("insights.derived.provenance.methodLabel")}
</p>
<p className="text-muted-foreground mt-1 text-[11px] leading-snug">
{method}
</p>
</div>
<p
data-slot="provenance-explainer-window"
className="text-muted-foreground text-[11px] leading-snug"
>
{t("insights.derived.provenance.window", {
days: provenance.windowDays,
source: provenance.source,
})}
{asOf
? ` · ${t("insights.derived.provenance.asOf", { date: asOf })}`
: ""}
</p>
{standard && (
<a
data-slot="provenance-explainer-standard-link"
href={standard.url}
target="_blank"
rel="noopener noreferrer"
className="text-info hover:text-info/80 focus-visible:ring-ring/50 inline-flex items-center gap-1 rounded text-[11px] font-medium focus-visible:ring-2 focus-visible:outline-none"
>
{t("insights.derived.provenance.standardLink", {
name: standard.name,
})}
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</a>
)}
</div>
);
const trigger = (
<button
type="button"
data-slot="provenance-explainer-trigger"
aria-label={triggerLabel}
aria-expanded={open}
aria-controls={resolvedBodyId}
return (
<span
id={bodyId}
data-slot="provenance-explainer-method"
className={cn(
"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50",
"inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full",
"-mx-2 -my-3",
"transition-colors focus-visible:ring-2 focus-visible:outline-none",
"text-muted-foreground text-[11px] leading-snug",
className,
)}
onClick={() => setOpen(true)}
>
<Info className="h-3.5 w-3.5" aria-hidden="true" />
</button>
);
if (isMobile) {
return (
<>
{trigger}
<ResponsiveSheet
open={open}
onOpenChange={setOpen}
title={resolvedTitle}
>
<div className="text-sm">{body}</div>
</ResponsiveSheet>
</>
);
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent
data-slot="provenance-explainer-popover"
align="start"
sideOffset={6}
className="max-w-xs space-y-2"
>
<p
data-slot="provenance-explainer-title"
className="text-foreground text-xs font-semibold"
>
{resolvedTitle}
</p>
{body}
</PopoverContent>
</Popover>
{method}
</span>
);
}
@@ -1,167 +1,63 @@
"use client";
import { useId, useState } from "react";
import { HelpCircle } from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { useIsMobile } from "@/hooks/use-is-mobile";
import { useId } from "react";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
/**
* v1.4.28 R3c-Insights — explain the "vs last week" delta on tap.
* v1.4.28 R3c-Insights — explain the "vs last week" delta.
*
* FB-I1: the user asked why the headline number moved. The earlier
* delta line surfaced the digit ("-3 vs last week") without any
* mention of which components shifted, what the comparison window
* actually is, or what the user can do about it. The explainer is a
* single icon-only `?` button next to the delta line that opens a
* three-sentence read.
* v1.22 — the icon-only `?` glyph that gated the three-sentence read behind
* a popover / bottom-sheet is removed; the maintainer wants the trailing
* help affordance gone across the insights surface. The descriptive read
* now renders inline as a muted caption beside the delta line, so it is
* always visible rather than disclosure-only. The body keeps its id so the
* parent's `aria-describedby` on the delta digit still threads to it.
*
* Surface choice follows R1.1 §1 — popover on `md+`, bottom-sheet
* on phone-class viewports. Both share the same body copy via the
* `insights.healthScore.deltaExplainer.body` key; the title is
* surfaced as the popover-content first line on desktop and as the
* sheet header on mobile (the ResponsiveSheet primitive already
* paints a header band on the sheet branch).
*
* Pure presentational. Owns its own open state — the parent doesn't
* need to thread an extra prop through.
* Operator suppression is preserved: when `healthScoreExplainer` is off the
* caption does not render (the parent delta digit stays visible because the
* parent paints it directly).
*/
interface HealthScoreDeltaExplainerProps {
/**
* The delta value the parent already painted on the line above. The
* explainer doesn't repaint the digit; it sits next to the line
* with the `?` glyph and lets the user request the prose.
*/
/** The delta value the parent paints on the line above. Retained for callers. */
delta: number;
/**
* Optional className for the trigger button so the parent can
* align the glyph to the delta line's baseline.
*/
/** Optional className for the caption so the parent can tune its spacing. */
className?: string;
/**
* Optional id the parent owns and threads to the explainer body via
* the popover/sheet markup. The same id sits on the parent's delta
* `<span>` as `aria-describedby` so screen readers can connect "−3
* vs last week" to the three-sentence read on demand.
* Optional id the parent owns and threads to the caption. The same id sits
* on the parent's delta `<span>` as `aria-describedby` so screen readers
* connect the digit to the read.
*/
bodyId?: string;
}
export function HealthScoreDeltaExplainer({
delta,
className,
bodyId,
}: HealthScoreDeltaExplainerProps) {
const { t } = useTranslations();
const isMobile = useIsMobile();
const flags = useFeatureFlags();
const [open, setOpen] = useState(false);
// Stable fallback id when the parent doesn't supply one. The body
// still paints the id so future consumers can thread the same
// describedby pattern without modifying the explainer.
// Stable fallback id when the parent doesn't supply one. The hook sits
// above the conditional return so the hook order stays stable.
const generatedId = useId();
const resolvedBodyId = bodyId ?? generatedId;
// v1.4.31 — operator can hide the `?` trigger; the delta digit on
// the parent line stays visible because the parent reads it
// directly. Silent suppression per the architecture brief. The
// early return sits below every hook call so the hook order stays
// stable across renders.
// v1.4.31 — operator can hide the read; silent suppression per the
// architecture brief.
if (!flags.healthScoreExplainer) return null;
const triggerLabel = t("insights.healthScore.deltaExplainer.trigger");
const title = t("insights.healthScore.deltaExplainer.title");
const body = t("insights.healthScore.deltaExplainer.body");
// Direct click handler on the button on both branches. Earlier
// mobile path wrapped the button in a `<span onClick onKeyDown>`
// which created two interactive elements in the a11y tree and
// intercepted clicks on the 2 px gap around the button. The button
// already handles Enter/Space natively; the only thing the wrapper
// owned was the open toggle, which moves onto the button itself.
const handleOpen = () => setOpen(true);
const trigger = (
<button
type="button"
data-slot="health-score-delta-explainer-trigger"
aria-label={triggerLabel}
aria-expanded={open}
aria-controls={resolvedBodyId}
return (
<span
id={resolvedBodyId}
data-slot="health-score-delta-explainer-body"
className={cn(
// Visible glyph stays 12 px so the chip reads at the delta
// line's height without inflating the row. The button itself
// sits at the 44 px WCAG 2.5.5 floor via `min-h-11 min-w-11`;
// negative `-my-3 -mx-2` collapses the surrounding row back
// to its 16 px stride so the optical chip stays small while
// the hit surface lifts to 44 px. Transparent padding owns
// the extra reach.
"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50",
"inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full",
"-mx-2 -my-3",
"transition-colors focus-visible:ring-2 focus-visible:outline-none",
"text-muted-foreground text-[11px] leading-snug",
className,
)}
onClick={handleOpen}
>
<HelpCircle className="h-3 w-3" aria-hidden="true" />
</button>
);
if (isMobile) {
return (
<>
{trigger}
<ResponsiveSheet
open={open}
onOpenChange={setOpen}
title={title}
description={t("insights.healthScore.deltaExplainer.description", {
delta: delta > 0 ? `+${delta}` : `${delta}`,
})}
>
<p
id={bodyId}
data-slot="health-score-delta-explainer-body"
className="text-muted-foreground text-sm leading-relaxed"
>
{body}
</p>
</ResponsiveSheet>
</>
);
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent
data-slot="health-score-delta-explainer-popover"
align="start"
sideOffset={6}
className="max-w-xs space-y-1.5"
>
<p
data-slot="health-score-delta-explainer-title"
className="text-foreground text-xs font-semibold"
>
{title}
</p>
<p
id={resolvedBodyId}
data-slot="health-score-delta-explainer-body"
className="text-muted-foreground text-[11px] leading-snug"
>
{body}
</p>
</PopoverContent>
</Popover>
{t("insights.healthScore.deltaExplainer.body")}
</span>
);
}
@@ -69,10 +69,11 @@ describe("<MoodDiscoveredRelations>", () => {
// factor label resolves to the localized measurement name
expect(html).toContain("Time in Daylight");
expect(html).toContain("higher next-day mood");
// The n/r/q detail rides a focusable explainer icon (its tooltip content
// is portalled on open, so it is absent from the static markup). Assert
// the icon is wired per pair instead of the inline stat string.
expect(html).toContain('aria-label="How this was computed"');
// v1.22 — the n/r/q detail no longer hides behind an "i" glyph + tooltip;
// it reads inline as a muted caption, so the stat string is present in the
// static markup.
expect(html).toContain('data-slot="mood-explainer-detail"');
expect(html).toContain("40 paired days");
});
it("phrases a mood → next-day outcome pair", () => {
@@ -85,7 +86,7 @@ describe("<MoodDiscoveredRelations>", () => {
expect(html).toContain("lower next-day");
});
it("filters to mood pairs and folds the full-family caveat into the subsection explainer", () => {
it("filters to mood pairs and reads the full-family caveat inline", () => {
const html = renderWith(
[
pair({ behaviour: "TIME_IN_DAYLIGHT", outcome: "MOOD" }),
@@ -96,14 +97,12 @@ describe("<MoodDiscoveredRelations>", () => {
expect((html.match(/data-slot="mood-discovered-pair"/g) ?? []).length).toBe(
1,
);
// The false-discovery footer + the observational disclaimer fold into the
// subsection explainer icon; its tooltip content is portalled on open, so
// assert the trigger exists rather than the (now hidden) footer string.
// v1.22 — the false-discovery footer + observational disclaimer no longer
// hide behind an "i" glyph + tooltip; they read inline as a muted caption,
// so both the caption slot and its text are present in the static markup.
expect(
(html.match(/aria-label="How this was computed"/g) ?? []).length,
(html.match(/data-slot="mood-explainer-detail"/g) ?? []).length,
).toBeGreaterThanOrEqual(1);
// The former muted explainer paragraph + inline footnote rows are gone.
expect(html).not.toContain("day-to-day pairs tested");
expect(html).not.toContain("hold up after correcting");
expect(html).toContain("day-to-day pairs tested");
});
});
@@ -1,55 +1,34 @@
"use client";
import { Info } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
/**
* v1.12.4 (C3/C4) — compact explainer affordance for the mood surface.
* v1.12.4 (C3/C4) — compact explainer for the mood surface.
*
* Replaces the low-density inline "how this was computed" text rows
* (paired-day counts, the false-discovery footer, the observational
* disclaimer) with a single ring/info icon. The detail moves into a
* hover/focus tooltip so the surface stops spending a full-width row on a
* footnote. The trigger is a real focusable `button` carrying an
* `aria-label`, so the explanation is reachable by keyboard and screen
* readers — not hover-only. Self-contained `TooltipProvider` so callers
* don't need an ambient one.
* v1.22 — the round info glyph that hid the statistical caveat behind a
* hover/focus tooltip is gone. The maintainer wants the trailing "i"
* affordance removed across the insights surface; the descriptive text it
* used to gate now reads inline as a muted caption, so the explanation is
* always visible rather than disclosure-only. The `label` prop is retained
* for callers but is no longer rendered as an accessible trigger name —
* there is no trigger to name.
*/
export function MoodExplainerIcon({
label,
detail,
className,
}: {
/** Accessible name for the trigger (what the icon explains). */
/** Accessible name for the (now removed) trigger. Retained for callers. */
label: string;
/** The explanation shown in the tooltip. */
/** The explanation, rendered inline as a muted caption. */
detail: string;
className?: string;
}) {
return (
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger
type="button"
aria-label={label}
className={cn(
"text-muted-foreground hover:text-foreground focus-visible:ring-ring inline-flex size-5 shrink-0 items-center justify-center rounded-full border border-current/40 transition-colors focus-visible:ring-2 focus-visible:outline-none",
className,
)}
>
<Info className="size-3" aria-hidden="true" />
</TooltipTrigger>
<TooltipContent className="max-w-[16rem] text-xs leading-snug">
{detail}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<span
data-slot="mood-explainer-detail"
className={cn("text-muted-foreground text-xs leading-snug", className)}
>
{detail}
</span>
);
}
@@ -2,18 +2,13 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { CalendarRange, Info } from "lucide-react";
import { CalendarRange } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import { SectionHeading } from "@/components/insights/section-heading";
import { AskCoachAction } from "@/components/insights/ask-coach-action";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { apiGet } from "@/lib/api/api-fetch";
/**
@@ -64,6 +59,13 @@ async function fetchNarrative(
return apiGet<NarrativeResponse>(`/api/insights/narrative?period=${period}`);
}
/**
* v1.22 — the round ⓘ provenance glyph that hid the "how this was computed"
* read behind a popover is removed; the maintainer wants the trailing info
* affordance gone across the insights surface. The method line — and, when
* present, the metrics it drew on — now reads inline as a muted caption at the
* foot of the card, always visible rather than disclosure-only.
*/
function ProvenanceDisclosure({
provenance,
}: {
@@ -72,27 +74,15 @@ function ProvenanceDisclosure({
const { t } = useTranslations();
const metrics = provenance.metrics.slice(0, 8).join(", ");
return (
<Popover>
<PopoverTrigger
aria-label={t("insights.narrativeProvenanceLabel")}
className="text-muted-foreground hover:text-foreground inline-flex size-5 shrink-0 items-center justify-center rounded-full transition-colors"
>
<Info className="size-3.5" aria-hidden="true" />
</PopoverTrigger>
<PopoverContent className="w-80 text-sm" align="end">
<p className="text-foreground font-medium">
{t("insights.narrativeProvenanceLabel")}
</p>
<p className="text-muted-foreground mt-1">
{t("insights.narrativeProvenanceMethod")}
</p>
{metrics ? (
<p className="text-muted-foreground mt-2 text-xs">
{t("insights.narrativeProvenanceMetrics", { metrics })}
</p>
) : null}
</PopoverContent>
</Popover>
<p
data-slot="period-narrative-provenance"
className="text-muted-foreground text-[11px] leading-snug"
>
{t("insights.narrativeProvenanceMethod")}
{metrics
? ` ${t("insights.narrativeProvenanceMetrics", { metrics })}`
: ""}
</p>
);
}
@@ -168,11 +158,6 @@ export function PeriodNarrativeCard({
<SectionHeading
icon={CalendarRange}
title={t("insights.narrativeTitle")}
action={
narrative?.provenance ? (
<ProvenanceDisclosure provenance={narrative.provenance} />
) : undefined
}
/>
<div
data-slot="period-narrative-card"
@@ -215,6 +200,10 @@ export function PeriodNarrativeCard({
</p>
)}
{narrative?.provenance ? (
<ProvenanceDisclosure provenance={narrative.provenance} />
) : null}
{narrative ? (
<p className="text-muted-foreground text-right text-[11px]">
{data?.revalidating
+1 -34
View File
@@ -3,16 +3,10 @@
import { useEffect, useRef, type ReactNode } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Info, ListOrdered } from "lucide-react";
import { ListOrdered } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { TargetAdjustButton } from "@/components/insights/target-adjust-button";
import { TargetAdjustProvider } from "@/lib/insights/target-adjust-context";
import { metricScopeFromExplainer } from "@/components/insights/coach-metric-scope";
@@ -344,33 +338,6 @@ export function SubPageShell({
className="text-muted-foreground text-sm leading-relaxed"
>
{t(`insights.subPage.explainer.${explainerMetric}Body`)}
{/* v1.18.6 — a discreet general-guidance tooltip travels with
every metric definition. The reference bands in the body
above are population anchors, not medical advice; this
caption pins that framing without crowding the prose. */}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
data-slot="metric-reference-guidance"
className={cn(
"text-muted-foreground hover:text-foreground ml-1.5 inline-flex",
"focus-visible:ring-ring/50 rounded-full align-middle",
"focus-visible:ring-2 focus-visible:outline-none",
)}
aria-label={t(
"insights.subPage.explainer.referenceGuidance",
)}
>
<Info className="size-3.5" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent className="max-w-xs text-pretty">
{t("insights.subPage.explainer.referenceGuidance")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</p>
) : null}
{description && !explainerMetric ? (