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 (