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

+3 -1
View File
@@ -8607,7 +8607,9 @@
"sleepPending": "Der Schlaf von letzter Nacht fehlt noch – der heutige Wert ist vorläufig.",
"readFullBriefing": "Vollständiges Briefing lesen",
"worthALook": "Einen Blick wert",
"allClear": "Heute braucht nichts deine Aufmerksamkeit."
"allClear": "Heute braucht nichts deine Aufmerksamkeit.",
"ringLink": "{metric}-Details öffnen",
"ringDosesAria": "{taken} von {scheduled} Dosen heute genommen"
},
"milestone": {
"metric": {
+3 -1
View File
@@ -8607,7 +8607,9 @@
"sleepPending": "Last night's sleep isn't in yet — today's read is provisional.",
"readFullBriefing": "Read the full briefing",
"worthALook": "Worth a look",
"allClear": "Nothing needs your attention today."
"allClear": "Nothing needs your attention today.",
"ringLink": "Open {metric} details",
"ringDosesAria": "{taken} of {scheduled} doses taken today"
},
"milestone": {
"metric": {
+3 -1
View File
@@ -8607,7 +8607,9 @@
"sleepPending": "El sueño de anoche aún no ha llegado; la lectura de hoy es provisional.",
"readFullBriefing": "Leer el informe completo",
"worthALook": "Merece un vistazo",
"allClear": "Hoy nada necesita tu atención."
"allClear": "Hoy nada necesita tu atención.",
"ringLink": "Abrir detalles de {metric}",
"ringDosesAria": "{taken} de {scheduled} dosis tomadas hoy"
},
"milestone": {
"metric": {
+3 -1
View File
@@ -8607,7 +8607,9 @@
"sleepPending": "Le sommeil de la nuit dernière n'est pas encore arrivé — la lecture du jour est provisoire.",
"readFullBriefing": "Lire le briefing complet",
"worthALook": "À regarder",
"allClear": "Rien ne requiert ton attention aujourd'hui."
"allClear": "Rien ne requiert ton attention aujourd'hui.",
"ringLink": "Ouvrir les détails de {metric}",
"ringDosesAria": "{taken} des {scheduled} doses prises aujourd'hui"
},
"milestone": {
"metric": {
+3 -1
View File
@@ -8607,7 +8607,9 @@
"sleepPending": "Il sonno di stanotte non è ancora arrivato: la lettura di oggi è provvisoria.",
"readFullBriefing": "Leggi il briefing completo",
"worthALook": "Da tenere d'occhio",
"allClear": "Oggi nulla richiede la tua attenzione."
"allClear": "Oggi nulla richiede la tua attenzione.",
"ringLink": "Apri i dettagli di {metric}",
"ringDosesAria": "{taken} di {scheduled} dosi assunte oggi"
},
"milestone": {
"metric": {
+3 -1
View File
@@ -8607,7 +8607,9 @@
"sleepPending": "Sen z ostatniej nocy jeszcze nie dotarł — dzisiejszy odczyt jest wstępny.",
"readFullBriefing": "Przeczytaj pełny briefing",
"worthALook": "Warto sprawdzić",
"allClear": "Dziś nic nie wymaga Twojej uwagi."
"allClear": "Dziś nic nie wymaga Twojej uwagi.",
"ringLink": "Otwórz szczegóły: {metric}",
"ringDosesAria": "Przyjęto {taken} z {scheduled} dawek dzisiaj"
},
"milestone": {
"metric": {
@@ -138,8 +138,10 @@ describe("TrendCardSkeleton", () => {
// Same outer chrome + min-height floor as the real TrendCard slot.
expect(html).toContain("rounded-xl");
expect(html).toContain("min-h-[8rem]");
// Reduced motion honoured via the Skeleton primitive.
expect(html).toContain("motion-reduce:animate-none");
// Reduced motion honoured via the Skeleton primitive's shimmer class
// (its prefers-reduced-motion guard lives beside the keyframes in
// globals.css).
expect(html).toContain("skeleton-shimmer");
});
});
+107
View File
@@ -897,6 +897,113 @@
}
}
/*
* ── v1.29.0 cohesion pass — the daily-value system's finishing layer ──
*
* Four small surface treatments, all expressed through the existing
* token/motion vocabulary (no new palette, no new easing):
*
* `.today-hero-wash` — the ONE sanctioned Today atmosphere: a faint
* `--primary` mix over the theme `--card` in the
* `.wellness-tile` pattern, leaning toward the
* ring corner. Softer than the insights
* `.hero-gradient` (8% → 0 vs 18/12) so the
* promoted hero reads as the calm first surface,
* not a banner. Static — nothing to motion-gate.
* `.metric-accent` — per-metric identity edge for rail cards and
* cross-link pointers. Reads `--tile-hue` (set
* inline from the SAME `--tile-*` vocabulary the
* wellness tiles use; `--primary` for the med /
* coach family) and paints a soft inset edge on
* the reading side via a ::before overlay, so it
* composes with the card's own `shadow-sm` and
* the semantic status washes (which keep the
* background). No layout width is added; the
* inset shadow follows the border radius.
* `.milestone-reached` — the S12 quiet celebration: a soft `--success`
* halo over the theme `--card` plus a one-shot
* spring reveal on the shared `--ring-spring`
* curve. No confetti, no loop; reduced-motion
* paints the settled card at once.
* `.skeleton-shimmer` — the loading language upgrade: a single soft
* `--foreground`-mix sweep across the muted
* skeleton base instead of the whole-block
* `animate-pulse` throb. Load-terminating by
* construction (skeletons unmount with the data);
* reduced-motion collapses to the static base.
*/
.today-hero-wash {
background: radial-gradient(
120% 150% at 100% 0%,
color-mix(in srgb, var(--primary) 8%, var(--card)) 0%,
var(--card) 60%
);
}
.metric-accent {
position: relative;
}
.metric-accent::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
box-shadow: inset 3px 0 0 0
color-mix(in srgb, var(--tile-hue, var(--primary)) 55%, transparent);
}
.milestone-reached {
background: radial-gradient(
130% 130% at 85% 0%,
color-mix(in srgb, var(--success) 14%, var(--card)) 0%,
color-mix(in srgb, var(--success) 4%, var(--card)) 60%,
var(--card) 100%
);
animation: milestone-reached-in 640ms var(--ring-spring) both;
}
@keyframes milestone-reached-in {
from {
opacity: 0;
transform: translateY(6px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.milestone-reached {
animation: none;
}
}
@keyframes skeleton-shimmer {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
.skeleton-shimmer {
background-image: linear-gradient(
100deg,
transparent 36%,
color-mix(in srgb, var(--foreground) 6%, transparent) 50%,
transparent 64%
);
background-size: 250% 100%;
background-repeat: no-repeat;
animation: skeleton-shimmer 1.8s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.skeleton-shimmer {
animation: none;
background-image: none;
}
}
/* ── Global reduced-motion backstop ──────────────────────────────────
* Defensive catch-all so any animation/transition we forget to gate
* per-component still collapses to near-instant when the user has asked
+30 -1
View File
@@ -27,8 +27,11 @@ import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
import { cn } from "@/lib/utils";
import {
resolveDashboardLayout,
resolveHeroRingOrder,
HEALTH_SCORE_RING_ID,
type DashboardLayout,
} from "@/lib/dashboard-layout";
import type { DashboardScoreRing } from "@/lib/dashboard/score-rings";
import type { DashboardAnalyticsData as AnalyticsData } from "@/types/analytics";
import dynamic from "next/dynamic";
import Link from "next/link";
@@ -232,6 +235,32 @@ export default function DashboardPageClient() {
const insightsEnabled = useModuleEnabled("insights");
const digestQuery = useDailyDigest(isAuthenticated && insightsEnabled);
// v1.29.0 — the Settings-picked hero score rings, resurfaced on the Today
// hero. The snapshot resolves the selection server-side (data- and
// module-gated, selection order preserved); `resolveHeroRingOrder`
// reconciles the user's persisted drag order against what actually
// resolved — the SAME reconciler the Settings picker uses. The
// health-score ring stays the hero's fixed anchor, so its position in the
// stored order is skipped here; the legacy non-snapshot path carries no
// resolved rings and degrades to the health ring alone.
const heroScoreRings = useMemo<DashboardScoreRing[]>(() => {
const snap = snapshotQuery.data;
const rings = snap?.scoreRings ?? [];
if (rings.length === 0) return [];
const order = resolveHeroRingOrder(
snap?.layout?.heroRingOrder,
rings.map((r) => r.id),
);
const byId = new Map(rings.map((r) => [r.id, r]));
const ordered: DashboardScoreRing[] = [];
for (const id of order) {
if (id === HEALTH_SCORE_RING_ID) continue;
const ring = byId.get(id);
if (ring) ordered.push(ring);
}
return ordered;
}, [snapshotQuery.data]);
const analyticsSlimQuery = useAnalyticsQuery({
slice: "summaries",
enabled: !snapshotEnabled && isAuthenticated,
@@ -783,7 +812,7 @@ export default function DashboardPageClient() {
(!mounted || digestQuery.isLoading ? (
<TodayHeroSkeleton />
) : digestQuery.data ? (
<TodayHero digest={digestQuery.data} />
<TodayHero digest={digestQuery.data} rings={heroScoreRings} />
) : null)}
{/* v1.18.6 — the spotlight tour launcher moved to the app-shell
@@ -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}
/>
);