From cb883e1470b01b2c70c9b1337eef900804bad0b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 17 Jul 2026 09:05:24 +0200 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20cohesion=20polish=20?= =?UTF-8?q?=E2=80=94=20hero=20score=20rings=20return,=20per-metric=20accen?= =?UTF-8?q?ts,=20quiet=20milestone=20moment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- messages/de.json | 4 +- messages/en.json | 4 +- messages/es.json | 4 +- messages/fr.json | 4 +- messages/it.json | 4 +- messages/pl.json | 4 +- .../__tests__/dashboard-chart-reveal.test.tsx | 6 +- src/app/globals.css | 107 ++++++++++++ src/app/page-client.tsx | 31 +++- .../__tests__/host-metrics-chart.test.tsx | 2 +- .../daily/__tests__/priority-card.test.tsx | 25 +++ .../daily/__tests__/today-hero.test.tsx | 33 ++++ src/components/daily/priority-card.tsx | 34 +++- src/components/daily/today-hero-skeleton.tsx | 13 +- src/components/daily/today-hero.tsx | 155 ++++++++++++++++-- .../__tests__/daily-briefing.test.tsx | 2 +- .../__tests__/trend-annotation.test.tsx | 2 +- src/components/insights/ecg-cross-link.tsx | 7 +- .../insights/intraday-pulse-chart.tsx | 8 +- src/components/ui/__tests__/skeleton.test.tsx | 8 +- src/components/ui/skeleton.tsx | 18 +- 21 files changed, 427 insertions(+), 48 deletions(-) diff --git a/messages/de.json b/messages/de.json index 6052505f5..43373eedb 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index c709430cd..937805e02 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index f9149ecbf..e56efdbf9 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index f39b20aff..9f16448a4 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index fca2f5bf6..b3a7cc930 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/pl.json b/messages/pl.json index c5c238c92..d1d1ae6ea 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/src/app/__tests__/dashboard-chart-reveal.test.tsx b/src/app/__tests__/dashboard-chart-reveal.test.tsx index 454c7dc9c..9bc8ebada 100644 --- a/src/app/__tests__/dashboard-chart-reveal.test.tsx +++ b/src/app/__tests__/dashboard-chart-reveal.test.tsx @@ -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"); }); }); diff --git a/src/app/globals.css b/src/app/globals.css index 68abd833c..067640131 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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 diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index 3ac4cbde1..e05e2a2dd 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -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(() => { + 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 ? ( ) : digestQuery.data ? ( - + ) : null)} {/* v1.18.6 — the spotlight tour launcher moved to the app-shell diff --git a/src/components/admin/__tests__/host-metrics-chart.test.tsx b/src/components/admin/__tests__/host-metrics-chart.test.tsx index b436736fa..00c3143ca 100644 --- a/src/components/admin/__tests__/host-metrics-chart.test.tsx +++ b/src/components/admin/__tests__/host-metrics-chart.test.tsx @@ -122,6 +122,6 @@ describe("", () => { const html = render(); expect(html).toContain("admin-host-metrics-heading"); expect(html).toContain("Host load"); - expect(html).toContain("animate-pulse"); + expect(html).toContain("skeleton-shimmer"); }); }); diff --git a/src/components/daily/__tests__/priority-card.test.tsx b/src/components/daily/__tests__/priority-card.test.tsx index 9ba0dd84c..7db766635 100644 --- a/src/components/daily/__tests__/priority-card.test.tsx +++ b/src/components/daily/__tests__/priority-card.test.tsx @@ -60,6 +60,31 @@ describe("", () => { expect(info).toContain("bg-info/10"); }); + it("carries the metric-family accent for metric-flavoured kinds only", () => { + const tension = render( + , + ); + expect(tension).toContain("metric-accent"); + expect(tension).toContain("--tile-hue:var(--tile-stress)"); + const ecg = render( + , + ); + expect(ecg).toContain("--tile-hue:var(--tile-strain)"); + // Utility kinds carry no metric identity — status alone speaks. + const sync = render(); + expect(sync).not.toContain("metric-accent"); + }); + + it("gives the milestone card the quiet reached treatment instead of the generic fade-in", () => { + const html = render( + , + ); + 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( ", () => { 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(); + 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(); + 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( = { 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> = { + 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 ( diff --git a/src/components/daily/today-hero-skeleton.tsx b/src/components/daily/today-hero-skeleton.tsx index 6a9a63eb7..1abae7136 100644 --- a/src/components/daily/today-hero-skeleton.tsx +++ b/src/components/daily/today-hero-skeleton.tsx @@ -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 `` 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 `` 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 (