mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
Reference in new issue
Block a user