feat(dashboard): cohesion polish — hero score rings return, per-metric accents, quiet milestone moment

- Resurface the selected hero score rings on the Today hero: the Settings
  picker's selection and drag order render again as a calm ring cluster
  beneath the health ring, each ring in its wellness hue, flat-painted,
  and linking to its existing detail surface (score anatomy pages,
  medications). The health ring now opens the Insights overview like its
  cluster siblings. Empty selection keeps the hero exactly as before.
- Per-metric identity accents on the daily rail via the existing --tile-*
  hue vocabulary: a soft inset edge (stress for tension windows, strain
  for ECG pointers, primary for dose and coach cards); the same mark ties
  the ECG cross-link and the intraday pulse card shell to their rail
  siblings. Utility items (sync, preventive care) stay accent-less.
- Quiet "reached" treatment for the milestone card: a soft success halo
  over the theme card plus a one-shot spring reveal on --ring-spring,
  with its reduced-motion fallback beside the keyframes.
- The Today hero carries the one sanctioned atmosphere wash (a faint
  primary mix over the card in the wellness-tile pattern); the loading
  skeleton shares it so the swap changes content, never the surface.
- Skeleton primitive upgraded from the whole-block pulse to a single soft
  shimmer sweep, reduced-motion guarded in globals.css; dependent test
  assertions repointed.
- Ring link / dose-tally aria strings restored in all six locales.
This commit is contained in:
Marc-André Bombeck committed 2026-07-17 09:05:24 +02:00
1 parent 8ac38926c4
commit cb883e1470
21 files changed
+427 -48

No files matched your search

@@ -122,6 +122,6 @@ describe("<HostMetricsChart>", () => {
const html = render(<HostMetricsChart />);
expect(html).toContain("admin-host-metrics-heading");
expect(html).toContain("Host load");
expect(html).toContain("animate-pulse");
expect(html).toContain("skeleton-shimmer");
});
});
@@ -60,6 +60,31 @@ describe("<PriorityCard>", () => {
expect(info).toContain("bg-info/10");
});
it("carries the metric-family accent for metric-flavoured kinds only", () => {
const tension = render(
<PriorityCard item={item({ kind: "tension_window" })} />,
);
expect(tension).toContain("metric-accent");
expect(tension).toContain("--tile-hue:var(--tile-stress)");
const ecg = render(
<PriorityCard item={item({ kind: "ecg_new_recording" })} />,
);
expect(ecg).toContain("--tile-hue:var(--tile-strain)");
// Utility kinds carry no metric identity — status alone speaks.
const sync = render(<PriorityCard item={item({ kind: "sync_issue" })} />);
expect(sync).not.toContain("metric-accent");
});
it("gives the milestone card the quiet reached treatment instead of the generic fade-in", () => {
const html = render(
<PriorityCard item={item({ kind: "milestone", status: "success" })} />,
);
expect(html).toContain("milestone-reached");
expect(html).not.toContain("animate-insight-in");
// The success wash still carries the semantic border.
expect(html).toContain("border-success/25");
});
it("renders a navigation action as a link carrying the href", () => {
const html = render(
<PriorityCard
@@ -6,6 +6,7 @@ import { I18nProvider } from "@/lib/i18n/context";
import { TodayHero } from "../today-hero";
import type { DailyDigest } from "@/lib/daily/digest";
import type { PriorityItem } from "@/lib/daily/priority-item";
import type { DashboardScoreRing } from "@/lib/dashboard/score-rings";
// The hero now wires the coach check-in card's keep / let-go taps through
// `useCoachCheckinAction`, so it needs a QueryClient in the tree.
@@ -140,6 +141,38 @@ describe("<TodayHero>", () => {
expect(html).toBe("");
});
it("renders the selected score rings as a cluster in the caller's order", () => {
const rings: DashboardScoreRing[] = [
{ id: "SLEEP_SCORE", score: 74, band: "yellow" },
{
id: "MED_COMPLIANCE",
score: 33,
band: "yellow",
doses: { taken: 1, scheduled: 3 },
},
];
const html = render(<TodayHero digest={digest()} rings={rings} />);
expect(html).toContain('data-slot="today-hero-ring-cluster"');
// Caller order (= the user's resolved hero ring order) is preserved.
const sleepAt = html.indexOf('data-ring="SLEEP_SCORE"');
const medsAt = html.indexOf('data-ring="MED_COMPLIANCE"');
expect(sleepAt).toBeGreaterThan(-1);
expect(medsAt).toBeGreaterThan(sleepAt);
// Each ring links to its existing detail surface.
expect(html).toContain('href="/insights/scores/sleep"');
expect(html).toContain('href="/medications"');
// The dose ring shows today's tally, not a mystery percentage.
expect(html).toContain("1/3");
expect(html).toContain("1 of 3 doses taken today");
});
it("renders no ring cluster when the selection is empty", () => {
const html = render(<TodayHero digest={digest()} />);
expect(html).not.toContain('data-slot="today-hero-ring-cluster"');
// The health-score ring alone still paints, exactly as before.
expect(html).toContain('data-slot="today-hero-score"');
});
it("shows the first-class all-clear line when a score is present but nothing is notable", () => {
const html = render(
<TodayHero
+33 -1
View File
@@ -64,6 +64,27 @@ const STATUS_WASH: Record<PriorityItemStatus, string> = {
destructive: "bg-destructive/10 border-destructive/25",
};
/**
* v1.29.0 cohesion — per-kind metric identity, drawn from the SAME `--tile-*`
* hue vocabulary the wellness tiles paint (plus `--primary` for the med and
* coach families, the tone every medication / coach surface in Insights
* already carries). Rendered as the `.metric-accent` inset edge — a quiet
* identity mark, never a fill — so a pulse card, an ECG card, and a dose card
* each read as their metric family while staying one card system.
*
* Deliberately partial: `preventive_care` and `sync_issue` are utility items
* with no metric family, and `milestone` belongs to the celebration layer
* (`.milestone-reached`, `--success`) rather than to one metric — those three
* stay accent-less. Values are token references only (`var(--…)`), the
* `RingTile` / `TILE_HUE` pattern.
*/
const KIND_HUE: Partial<Record<PriorityItemKind, string>> = {
dose_window: "var(--primary)",
coach_checkin: "var(--primary)",
ecg_new_recording: "var(--tile-strain)",
tension_window: "var(--tile-stress)",
};
export interface PriorityCardProps {
item: PriorityItem;
/**
@@ -82,16 +103,27 @@ export function PriorityCard({ item, onAction, className }: PriorityCardProps) {
const { t } = useTranslations();
const Icon = KIND_ICON[item.kind];
const actions = item.actions.slice(0, 3);
const hue = KIND_HUE[item.kind];
// S12 — the quiet "reached" moment: the milestone card swaps the generic
// fade-in for the `.milestone-reached` treatment (a soft `--success` halo
// over the theme card + a one-shot spring reveal on `--ring-spring`;
// reduced-motion fallback lives beside the keyframes in globals.css). The
// halo's background wins over the status wash's flat `bg-success/10`
// (unlayered CSS beats utilities); the wash keeps the success border.
const isMilestone = item.kind === "milestone";
return (
<Card
data-slot="priority-card"
data-kind={item.kind}
className={cn(
"animate-insight-in gap-2 py-3 md:gap-2 md:py-4",
isMilestone ? "milestone-reached" : "animate-insight-in",
"gap-2 py-3 md:gap-2 md:py-4",
item.status ? STATUS_WASH[item.status] : null,
hue ? "metric-accent" : null,
className,
)}
style={hue ? ({ "--tile-hue": hue } as React.CSSProperties) : undefined}
>
<CardContent className="flex flex-col gap-2">
<TileHeader icon={Icon} title={item.title} size="sm" />
+7 -6
View File
@@ -9,18 +9,19 @@ import { Skeleton } from "@/components/ui/skeleton";
* two-card rail row — so the swap to the loaded hero happens in place
* with zero layout shift.
*
* The shimmer rides the `<Skeleton>` primitive (`animate-pulse`), which is
* reduced-motion safe by construction (`motion-reduce:animate-none`).
* Always `aria-hidden`: the tile-strip skeleton alongside carries the
* page's loading semantics, so a second announcement here would double up
* for screen readers.
* The shimmer rides the `<Skeleton>` primitive (`skeleton-shimmer`), which
* is reduced-motion safe via its own CSS guard in `globals.css`. The shell
* carries the same `.today-hero-wash` atmosphere as the loaded hero so the
* swap changes content, never the surface. Always `aria-hidden`: the
* tile-strip skeleton alongside carries the page's loading semantics, so a
* second announcement here would double up for screen readers.
*/
export function TodayHeroSkeleton() {
return (
<div
aria-hidden="true"
data-slot="today-hero-skeleton"
className="bg-card border-border relative isolate overflow-hidden rounded-xl border p-4 md:p-6"
className="bg-card today-hero-wash border-border relative isolate overflow-hidden rounded-xl border p-4 md:p-6"
>
<div className="flex flex-col gap-4 md:gap-5">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between md:gap-6">
+140 -15
View File
@@ -14,6 +14,10 @@
* - the day's read: the health `ScoreRing` (`flat`, md) with its
* server-computed band and an honest provisional/final face — a null
* score paints the ring's own provisional state, never a zero;
* - the selected score rings (v1.29.0): the user's Settings-picked hero
* rings (max 3), resolved + gated by the dashboard snapshot and ordered
* by `resolveHeroRingOrder`, as a calm sm-ring cluster beneath the
* health ring — the picker configures the web hero again;
* - the briefing lead in plain language (via `ProseBlocks`, no markdown)
* with a "read the full briefing" affordance, plus the top signal's
* present-tense headline + delta when the digest carries one;
@@ -34,17 +38,55 @@ import { Moon } from "lucide-react";
import { ScoreRing } from "@/components/insights/derived/score-ring";
import type { ScoreBand } from "@/components/insights/derived/band-tokens";
import type { RingHue } from "@/components/insights/derived/ring-hues";
import { ProseBlocks } from "@/components/insights/prose-blocks";
import { PriorityCard } from "@/components/daily/priority-card";
import { useCoachCheckinAction } from "@/hooks/use-coach-checkin";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { ScoreRingId } from "@/lib/dashboard-layout";
import type { DashboardScoreRing } from "@/lib/dashboard/score-rings";
import {
COACH_CHECKIN_KEEP_INTENT,
COACH_CHECKIN_LETGO_INTENT,
type DailyDigest,
} from "@/lib/daily/digest";
/**
* v1.29.0 — the selected score rings return to the hero. The maps mirror the
* retired legacy-hero renderer verbatim so every ring paints EXACTLY the hue,
* label, and destination its insights sibling owns:
*
* - hue: the wellness-strip vocabulary (readiness green / recovery cyan /
* sleep purple); `MED_COMPLIANCE` rides `meds` = `--primary`, the one
* constant tone every medication surface paints — never a band gradient
* that would flash yellow over pending morning doses.
* - label: the existing score labels; the dose ring names today's tally.
* - href: each ring opens the SAME detail surface the wellness strip /
* medications page already own — the picker configures real navigation,
* not decoration.
*/
const RING_HUE_BY_ID: Partial<Record<ScoreRingId, RingHue>> = {
READINESS: "readiness",
RECOVERY_SCORE: "recovery",
SLEEP_SCORE: "sleep",
MED_COMPLIANCE: "meds",
};
const RING_LABEL_KEY: Record<ScoreRingId, string> = {
READINESS: "insights.derived.composite.READINESS.title",
RECOVERY_SCORE: "insights.derived.scores.recovery",
SLEEP_SCORE: "insights.derived.composite.SLEEP_SCORE.title",
MED_COMPLIANCE: "dashboard.hero.ringDoses",
};
const RING_HREF: Record<ScoreRingId, string> = {
READINESS: "/insights/scores/readiness",
RECOVERY_SCORE: "/insights/scores/recovery",
SLEEP_SCORE: "/insights/scores/sleep",
MED_COMPLIANCE: "/medications",
};
/** Format the server-computed score delta as a signed, muted chip string. */
function formatDelta(
delta: number,
@@ -56,7 +98,22 @@ function formatDelta(
return t("daily.today.deltaVsBaseline", { delta: signed });
}
export function TodayHero({ digest }: { digest: DailyDigest }) {
export function TodayHero({
digest,
rings = [],
}: {
digest: DailyDigest;
/**
* v1.29.0 — the user's selected hero score rings (Settings → Dashboard),
* resolved server-side by the dashboard snapshot (data- and module-gated)
* and ordered by the caller via `resolveHeroRingOrder`, so the Settings
* picker's selection + drag order configure the web hero again. The
* health-score ring stays the hero's fixed anchor; this cluster renders
* beneath it. Optional and additive — an empty selection (or the legacy
* non-snapshot path) keeps the hero exactly as before.
*/
rings?: DashboardScoreRing[];
}) {
const { t } = useTranslations();
const { keep, letGo } = useCoachCheckinAction();
@@ -95,11 +152,12 @@ export function TodayHero({ digest }: { digest: DailyDigest }) {
data-slot="today-hero"
data-phase={digest.phase}
className={cn(
// Same surface as the tile strip + chart cards: plain `bg-card`
// with the shared border/radius/padding — the calm token system,
// no gradient, no glow. The hero reads as one of the dashboard's
// own tiles, just larger.
"bg-card border-border relative isolate overflow-hidden rounded-xl border",
// The tile strip's surface plus the ONE sanctioned Today atmosphere:
// `.today-hero-wash` leans a faint `--primary` mix over the theme
// `--card` toward the ring corner (the `.wellness-tile` color-mix
// pattern, softer than the insights hero) so the promoted day's read
// carries a quiet identity without a banner gradient or glow.
"bg-card today-hero-wash border-border relative isolate overflow-hidden rounded-xl border",
"p-4 md:p-6",
)}
>
@@ -150,15 +208,26 @@ export function TodayHero({ digest }: { digest: DailyDigest }) {
at the same footprint, so the column never collapses. */}
<div className="flex shrink-0 flex-col items-center gap-1 md:items-end">
<div data-slot="today-hero-score">
<ScoreRing
score={digest.score?.value ?? null}
band={
digest.score ? (digest.score.band as ScoreBand) : undefined
}
size="md"
flat
label={t("daily.today.scoreLabel")}
/>
{/* The ring opens the Insights overview — the destination the
health-score card owns — matching the cluster rings' tap
behaviour, so every hero ring is a door, not a poster. */}
<Link
href="/insights"
aria-label={t("daily.today.ringLink", {
metric: t("daily.today.scoreLabel"),
})}
className="focus-visible:ring-ring/50 block rounded-full focus-visible:ring-2 focus-visible:outline-none"
>
<ScoreRing
score={digest.score?.value ?? null}
band={
digest.score ? (digest.score.band as ScoreBand) : undefined
}
size="md"
flat
label={t("daily.today.scoreLabel")}
/>
</Link>
</div>
{digest.score && digest.score.delta !== null ? (
<span
@@ -171,6 +240,62 @@ export function TodayHero({ digest }: { digest: DailyDigest }) {
</div>
</div>
{/* v1.29.0 — the selected score rings, resurfaced. A calm inline
cluster beneath the health ring (right-aligned with it on md+,
centred on mobile), honouring the user's Settings selection and
drag order. Each ring is the shared `ScoreRing` primitive in its
wellness hue, `flat` (no sweep) so the cluster paints at once,
and links to the metric's existing detail surface. Selection
empty → the row simply isn't there — the hero reads as before. */}
{rings.length > 0 ? (
<div
data-slot="today-hero-ring-cluster"
className="flex flex-wrap items-start justify-center gap-x-4 gap-y-3 md:justify-end"
>
{rings.map((ring) => (
<Link
key={ring.id}
href={RING_HREF[ring.id]}
data-slot="today-hero-ring"
data-ring={ring.id}
aria-label={t("daily.today.ringLink", {
metric: t(RING_LABEL_KEY[ring.id]),
})}
className="focus-visible:ring-ring/50 rounded-full focus-visible:ring-2 focus-visible:outline-none"
>
{ring.id === "MED_COMPLIANCE" && ring.doses ? (
// Dose ring — today's tally ("1/3") over the constant
// med-family arc; the arc sweeps on the 0..100 progress.
// `band="green"` stays the stable data-band anchor — a
// pending morning dose is not an alert state.
<ScoreRing
score={ring.score}
band="green"
hue="meds"
valueText={`${ring.doses.taken}/${ring.doses.scheduled}`}
ariaLabel={t("daily.today.ringDosesAria", {
taken: ring.doses.taken,
scheduled: ring.doses.scheduled,
})}
size="sm"
flat
label={t(RING_LABEL_KEY[ring.id])}
/>
) : (
<ScoreRing
score={ring.score}
band={ring.band}
size="sm"
flat
hue={RING_HUE_BY_ID[ring.id]}
label={t(RING_LABEL_KEY[ring.id])}
/>
)}
</Link>
))}
</div>
) : null}
{/* Freshness note (plan §2.4) — provisional day, last night's sleep
not yet folded in. Muted, non-blocking, refreshes in place when
the morning job lands. */}
@@ -247,7 +247,7 @@ describe("<DailyBriefing>", () => {
it("renders the shimmer skeleton when loading", () => {
const html = render(<DailyBriefing briefing={null} loading />);
expect(html).toMatch(/data-slot="daily-briefing-skeleton"/);
expect(html).toContain("animate-pulse");
expect(html).toContain("skeleton-shimmer");
// Empty state must NOT render while loading.
expect(html).not.toContain('data-slot="daily-briefing-empty"');
});
@@ -127,7 +127,7 @@ describe("<TrendAnnotation>", () => {
<TrendAnnotation metric="bp" annotation={null} status="pending" />,
);
expect(html).toMatch(/data-slot="trend-annotation-pending"/);
expect(html).toContain("animate-pulse");
expect(html).toContain("skeleton-shimmer");
// Empty hint and filled prose MUST be absent during pending.
expect(html).not.toContain('data-slot="trend-annotation-empty"');
expect(html).not.toContain('data-slot="trend-annotation"');
+6 -1
View File
@@ -89,9 +89,14 @@ export function EcgCrossLink({ enabled = true, className }: EcgCrossLinkProps) {
href="/insights#ecg"
data-slot="ecg-cross-link"
className={cn(
"bg-card hover:bg-accent/40 focus-visible:ring-ring/50 block space-y-1.5 rounded-xl border p-4 transition-colors focus-visible:ring-2 focus-visible:outline-none",
// `.metric-accent` — the heart-family identity edge (`--tile-strain`,
// the wellness vocabulary's cardiovascular hue), the same mark the
// `ecg_new_recording` rail card carries, so the two ECG pointers
// read as one family.
"bg-card hover:bg-accent/40 focus-visible:ring-ring/50 metric-accent block space-y-1.5 rounded-xl border p-4 transition-colors focus-visible:ring-2 focus-visible:outline-none",
className,
)}
style={{ "--tile-hue": "var(--tile-strain)" } as React.CSSProperties}
>
<TileHeader icon={HeartPulse} title={t("insights.ecg.crossLink.title")} />
<span className="text-muted-foreground block text-xs leading-snug">
@@ -90,7 +90,13 @@ export function IntradayPulseChart({
return (
<div
data-slot="intraday-pulse-chart"
className="bg-card space-y-1.5 rounded-xl border p-4"
// `.metric-accent` — the tension layer's identity edge on the CARD
// shell only (`--tile-stress`, the wellness vocabulary's autonomic
// hue, per the plan's §3.6 hue-family call); the chart inside stays
// untouched. The same hue marks the `tension_window` rail card, so
// the two S11 surfaces read as one family.
className="bg-card metric-accent space-y-1.5 rounded-xl border p-4"
style={{ "--tile-hue": "var(--tile-stress)" } as React.CSSProperties}
>
<TileHeader icon={Activity} title={t("insights.intradayPulse.title")} />
<p className="text-muted-foreground text-xs leading-snug">{caption}</p>
@@ -10,13 +10,13 @@ describe("<Skeleton>", () => {
expect(html).toContain('aria-hidden="true"');
});
it("preserves caller classes alongside the pulse animation", () => {
it("preserves caller classes alongside the shimmer animation", () => {
const html = renderToStaticMarkup(
<Skeleton className="h-8 rounded-full" />,
);
// The pulse class drives the visual shimmer; honour reduce-motion.
expect(html).toContain("animate-pulse");
expect(html).toContain("motion-reduce:animate-none");
// The shimmer class drives the loading sweep; its reduced-motion
// fallback lives in globals.css next to the keyframes.
expect(html).toContain("skeleton-shimmer");
// Caller's classes should survive the merge.
expect(html).toContain("h-8");
expect(html).toContain("rounded-full");
+10 -8
View File
@@ -3,10 +3,15 @@ import * as React from "react";
import { cn } from "@/lib/utils";
/**
* Pulsing placeholder used while data is loading. Wrap text-equivalent
* shapes so the page does not jump when real content arrives. Honours
* `prefers-reduced-motion` automatically because Tailwind's
* `animate-pulse` is disabled by `motion-reduce:animate-none`.
* Shimmering placeholder used while data is loading. Wrap text-equivalent
* shapes so the page does not jump when real content arrives.
*
* v1.29.0 — the whole-block `animate-pulse` throb is upgraded to a single
* soft `skeleton-shimmer` sweep (a `--foreground`-mix gradient gliding
* across the muted base, defined in `globals.css`), so every loading
* surface shares one calm loading language. Honours
* `prefers-reduced-motion` via the shimmer's own CSS guard, which
* collapses it to the static muted block.
*
* Pattern: render the same layout the loaded UI will render, with each
* dynamic block replaced by a `<Skeleton className="h-X w-Y" />`.
@@ -20,10 +25,7 @@ function Skeleton({
data-slot="skeleton"
role="presentation"
aria-hidden="true"
className={cn(
"bg-muted/60 animate-pulse rounded-md motion-reduce:animate-none",
className,
)}
className={cn("bg-muted/60 skeleton-shimmer rounded-md", className)}
{...props}
/>
);