From 20d7187b0d800d153bd54cd15edf332679c2d0e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 23:40:00 +0200 Subject: [PATCH 1/9] feat(insights): premium animated wellness-score rings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuild the wellness-score strip on /insights as a signature, high-end moment instead of a flat dial. - Five distinct per-metric hues — readiness green, recovery teal, sleep indigo-blue, stress amber, strain violet — hand-tuned per theme so each holds contrast on the white light-mode card. Fixes the dark-theme collapse to a cyan/green wash (the semantic info token resolved to the same cyan as three rings) and the light-theme wash-out of the raw cyan on white. - A spring-eased arc sweep that overshoots ~6% then settles, a left-to-right staggered reveal with each number trailing its own arc, a conic specular sheen that sweeps once, a token-tinted bloom (a gentle breathing pulse only on a top-band ring), a segmented instrument-style track, and a faint film-grain tile surface for material depth. - A "vs your normal" ghost arc under the live arc for recovery, stress and strain, derived from the trailing score series already on the payload, so the user sees today against their baseline at a glance. The reveal plays once per browser session (sessionStorage-gated) so a background sync refetch never re-triggers it. All motion is CSS-first — no animation dependency, @property + a sampled linear() spring — and is fully disabled under prefers-reduced-motion. The arc stays a hand-rolled SVG gauge; the band semantic still rides data-band + the band word + the aria-label, never colour alone. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/globals.css | 235 +++++++++++++++++- src/components/insights/derived/ring-hues.ts | 92 ++++--- .../insights/derived/score-ring.tsx | 169 +++++++++---- .../insights/derived/wellness-scores.tsx | 94 ++++++- src/hooks/use-count-up.ts | 32 ++- 5 files changed, 508 insertions(+), 114 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index edb59abff..9f840bfa8 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -208,6 +208,36 @@ */ --dose-accent: oklch(0.714 0.203 305.504); + /* + * v1.14.0 — wellness-ring per-metric gradients (Dracula dark). + * Five DISTINCT single-hue two-stop arcs (from = darker, to = lighter): + * readiness green · recovery teal · sleep indigo-blue · stress amber · + * strain violet. Vivid on the dark `--card`. Each `to` doubles as the + * ring's bloom colour. The Alucard light set lives on `:root.light`. + */ + --ring-readiness-from: #1f9d57; + --ring-readiness-to: #5cf08f; + --tile-readiness: #50fa7b; + --ring-recovery-from: #0e9e94; + --ring-recovery-to: #4ff0d2; + --tile-recovery: #8be9fd; + --ring-sleep-from: #4f6ef0; + --ring-sleep-to: #8fb4ff; + --tile-sleep: #6f8bff; + --ring-stress-from: #e0922f; + --ring-stress-to: #ffce6b; + --tile-stress: #ffb86c; + --ring-strain-from: #8f6bf0; + --ring-strain-to: #c4a8ff; + --tile-strain: #bd93f9; + /* Band fallback (anatomy detail view, no per-metric hue). */ + --ring-green-from: #1f9d57; + --ring-green-to: #5cf08f; + --ring-yellow-from: #e0922f; + --ring-yellow-to: #ffce6b; + --ring-red-from: #c93a2a; + --ring-red-to: #ff7a6b; + color-scheme: dark; } @@ -253,6 +283,35 @@ --sidebar-border: #cfcfde; --sidebar-ring: #644ac9; + /* + * v1.14.0 — wellness-ring per-metric gradients (Alucard light). DEEPER + + * more saturated than the dark set so each hue holds AA-grade presence on + * the white `--card` (raw Dracula cyan/green wash out on white). Same five + * distinct hues: readiness green · recovery teal · sleep royal-blue · + * stress amber · strain violet. + */ + --ring-readiness-from: #0f7a3a; + --ring-readiness-to: #18a34e; + --tile-readiness: #14720a; + --ring-recovery-from: #07857c; + --ring-recovery-to: #12b3a3; + --tile-recovery: #0a8f86; + --ring-sleep-from: #2d5fc4; + --ring-sleep-to: #4f7ae0; + --tile-sleep: #2d5fc4; + --ring-stress-from: #b06a16; + --ring-stress-to: #d68a2a; + --tile-stress: #a34d14; + --ring-strain-from: #6b4fd0; + --ring-strain-to: #8a6ff0; + --tile-strain: #644ac9; + --ring-green-from: #0f7a3a; + --ring-green-to: #18a34e; + --ring-yellow-from: #b06a16; + --ring-yellow-to: #d68a2a; + --ring-red-from: #a3271a; + --ring-red-to: #cb3a2a; + /* * Status-pill text in light mode (Alucard). The bright Dracula * green / orange / red read 1.2–2.7:1 on a near-white tint; these @@ -399,6 +458,8 @@ */ .wellness-tile { --tile-hue: var(--dracula-purple); + position: relative; + overflow: hidden; background: linear-gradient( 140deg, color-mix(in srgb, var(--tile-hue) 14%, var(--card)) 0%, @@ -406,10 +467,180 @@ ); border: 1px solid color-mix(in srgb, var(--tile-hue) 22%, transparent); box-shadow: 0 10px 30px -26px color-mix(in srgb, var(--tile-hue) 40%, transparent); + transition: + transform 200ms cubic-bezier(0.22, 0.61, 0.36, 1), + box-shadow 200ms cubic-bezier(0.22, 0.61, 0.36, 1), + border-color 200ms cubic-bezier(0.22, 0.61, 0.36, 1); } -.wellness-tile:hover { - border-color: color-mix(in srgb, var(--tile-hue) 38%, transparent); +/* + * Faint static film-grain over the tile — lifts the flat CSS gradient to a + * "material" surface (the Oura touch). Inline SVG fractalNoise data-URI, zero + * network cost, one paint, no animation (reduced-motion-safe by construction). + * Opacity kept <=3% so it never reads as dirty on the white Alucard card. + */ +.wellness-tile::after { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); + opacity: 0.025; + mix-blend-mode: overlay; + pointer-events: none; +} + +@media (hover: hover) { + .wellness-tile:hover { + transform: translateY(-2px); + border-color: color-mix(in srgb, var(--tile-hue) 38%, transparent); + box-shadow: 0 16px 40px -24px color-mix(in srgb, var(--tile-hue) 52%, transparent); + } + .wellness-tile:hover [data-slot="score-ring"] { + transform: scale(1.015); + } +} +.wellness-tile:active { + transform: translateY(0) scale(0.985); +} + +/* + * v1.14.0 — the wellness-strip "signature reveal" motion system. + * + * The whole moment plays ONCE per session (the strip toggles + * `data-revealed="true"` once, gated on a sessionStorage flag) so a + * background Apple-Health-sync refetch never re-triggers it. Three layers: + * 1. tile rise+fade (staggered left-to-right by an inline animation-delay), + * 2. the ring's spring-fill arc sweep (in score-ring.tsx, --ring-spring), + * 3. a conic SHEEN that sweeps the arc once (the "expensive" specular touch). + * A single prefers-reduced-motion block at the bottom zeroes all of it. + */ +@property --shimmer-angle { + syntax: ""; + inherits: false; + initial-value: 0deg; +} + +:root { + /* Sampled spring (stiffness ~120, damping ~14): overshoots ~6% then settles + — the Oura/WHOOP fill feel. Falls back to the browser default on very old + engines that ignore linear() (instant fill, not broken). */ + --ring-spring: linear( + 0, 0.06, 0.23, 0.46, 0.7, 0.89, 1.02, 1.06, 1.03, 1, 0.99, 1 + ); + --ring-ease: cubic-bezier(0.22, 0.61, 0.36, 1); +} + +.wellness-tile-rise { + opacity: 0; + transform: translateY(10px); +} +[data-revealed="true"] .wellness-tile-rise { + animation: wellnessTileRise 440ms var(--ring-ease) forwards; + animation-delay: var(--reveal-delay, 0ms); +} +@keyframes wellnessTileRise { + to { + opacity: 1; + transform: translateY(0); + } +} + +/* The conic sheen overlay sits over the ring; opacity animates from 0 so a + browser without @property support (Safari <16.4 / FF <128) shows nothing + rather than a frozen highlight band. */ +.wellness-ring-sheen { + position: absolute; + inset: 6%; + border-radius: 50%; + opacity: 0; + background: radial-gradient( + circle at 50% 50%, + color-mix(in srgb, var(--card-foreground) 70%, transparent) 0%, + transparent 70% + ); + mix-blend-mode: overlay; + pointer-events: none; + -webkit-mask: conic-gradient( + from var(--shimmer-angle), + transparent 0deg, + transparent 38deg, + #000 58deg, + transparent 80deg, + transparent 360deg + ); + mask: conic-gradient( + from var(--shimmer-angle), + transparent 0deg, + transparent 38deg, + #000 58deg, + transparent 80deg, + transparent 360deg + ); +} +[data-revealed="true"] .wellness-ring-sheen { + animation: ringSheen 1500ms var(--ring-ease) forwards; + animation-delay: calc(var(--reveal-delay, 0ms) + 550ms); +} +@keyframes ringSheen { + 0% { + --shimmer-angle: 0deg; + opacity: 0; + } + 15% { + opacity: 0.5; + } + 85% { + opacity: 0.5; + } + 100% { + --shimmer-angle: 360deg; + opacity: 0; + } +} + +/* Token-tinted bloom behind the progress arc. The breathing pulse runs only + on the strongest (green-band) ring so the strip celebrates a good score + rather than jittering all five. `--ring-glow` is set inline per ring. */ +.wellness-ring-arc { + filter: drop-shadow(0 0 3px color-mix(in srgb, var(--ring-glow) 45%, transparent)); +} +.wellness-ring-arc--pulse { + animation: ringGlow 3.4s ease-in-out 2s infinite; +} +@keyframes ringGlow { + 0%, + 100% { + filter: drop-shadow(0 0 2px color-mix(in srgb, var(--ring-glow) 40%, transparent)); + } + 50% { + filter: drop-shadow(0 0 8px color-mix(in srgb, var(--ring-glow) 65%, transparent)); + } +} + +@media (prefers-reduced-motion: reduce) { + .wellness-tile, + .wellness-tile:hover, + .wellness-tile:active, + .wellness-tile:hover [data-slot="score-ring"] { + transform: none; + transition: none; + } + .wellness-tile-rise, + [data-revealed="true"] .wellness-tile-rise { + opacity: 1; + transform: none; + animation: none; + } + [data-revealed="true"] .wellness-ring-sheen { + animation: none; + opacity: 0; + } + .wellness-ring-arc, + .wellness-ring-arc--pulse { + filter: none; + animation: none; + } } /* diff --git a/src/components/insights/derived/ring-hues.ts b/src/components/insights/derived/ring-hues.ts index 01f8aff83..e94d457c8 100644 --- a/src/components/insights/derived/ring-hues.ts +++ b/src/components/insights/derived/ring-hues.ts @@ -1,18 +1,25 @@ /** - * v1.13.x — per-metric ring hues for the wellness-score strip. + * v1.14.0 — per-metric ring hues for the wellness-score strip. * - * The premium-redesign signature is Apple's: a SINGLE-hue, TWO-stop - * gradient per ring (dark→light tones of the *same* colour), never a - * multi-hue rainbow. Each metric leans toward an intentional hue (Oura's - * per-metric move): readiness greener, sleep bluer/indigo, recovery + - * strain turquoise, stress amber. + * The premium signature is a SINGLE-hue, TWO-stop gradient per ring + * (dark → light tones of the *same* colour), never a multi-hue rainbow. + * Each metric leans toward one intentional, distinct hue (Oura's per-metric + * move): readiness green, recovery teal/turquoise, sleep indigo-blue, stress + * amber, strain violet — five clearly different hues so the strip never reads + * as "a wash of green/cyan". * - * Every stop rides the SEMANTIC / `--dracula-*` tokens (not raw hex), so - * the Alucard light-mode overrides (`--success #14720a`, `--info #036a96`, - * `--warning #a34d14`, `--destructive #cb3a2a`) apply automatically and the - * arc holds contrast in both themes. The band semantic is never carried by - * the hue: it still rides `data-band` + the band word + the aria-label, so - * a red-band readiness still reads "low" in copy while its ring leans green. + * The actual colours live as CSS custom properties in `globals.css` + * (`--ring--from` / `--ring--to` / `--tile-`) with a Dracula + * dark set on `:root` and an Alucard light set on `:root.light`, so each hue + * is HAND-TUNED per theme — deep + saturated enough to hold contrast on the + * white Alucard card, vivid on the dark Dracula card. (The v1.13.x version + * mixed through ambiguous semantic tokens — `--info` == `--dracula-cyan` — + * which collapsed three of the five rings to cyan in the dark theme and + * washed recovery/strain out on white. Dedicated per-theme vars fix that.) + * + * The band semantic is never carried by the hue: it still rides `data-band` + * + the band word + the aria-label, so a red-band readiness still reads "low" + * in copy while its ring leans green. */ import type { ScoreBand } from "./band-tokens"; @@ -21,32 +28,37 @@ import type { ScoreBand } from "./band-tokens"; export type RingHue = "readiness" | "sleep" | "recovery" | "stress" | "strain"; /** - * Single-hue two-stop gradient `[from, to]` (dark → light) per metric, plus - * a band fallback for the anatomy detail view (which passes no `hue`, so the - * ring keeps its green/yellow/red band semantics there). Values are CSS - * colour strings consumed straight by the SVG `` stops. + * Single-hue two-stop gradient `[from, to]` (dark → light) per metric, plus a + * band fallback for the anatomy detail view (which passes no `hue`, so the + * ring keeps its green/yellow/red band semantics there). Values are CSS var + * references resolved per theme — consumed straight by the SVG + * `` stops. */ export const RING_GRADIENT: Record = { - // Readiness / Tagesform — a touch greener. - readiness: ["color-mix(in srgb, var(--success) 88%, #000)", "var(--success)"], - // Sleep score — bluer, leaning indigo via `--primary`. - sleep: ["color-mix(in srgb, var(--info) 65%, var(--primary))", "var(--info)"], - // Recovery — turquoise (cyan↔green). - recovery: [ - "color-mix(in srgb, var(--dracula-cyan) 78%, var(--dracula-green))", - "var(--dracula-cyan)", - ], - // Stress — amber (the "tension" metric; keeps the green/amber/red vocab honest). - stress: ["color-mix(in srgb, var(--warning) 88%, #000)", "var(--warning)"], - // Strain — turquoise leaning slightly violet to distinguish from Recovery. - strain: [ - "color-mix(in srgb, var(--dracula-cyan) 80%, var(--dracula-purple))", - "var(--dracula-cyan)", - ], + readiness: ["var(--ring-readiness-from)", "var(--ring-readiness-to)"], + recovery: ["var(--ring-recovery-from)", "var(--ring-recovery-to)"], + sleep: ["var(--ring-sleep-from)", "var(--ring-sleep-to)"], + stress: ["var(--ring-stress-from)", "var(--ring-stress-to)"], + strain: ["var(--ring-strain-from)", "var(--ring-strain-to)"], // Band fallback for the anatomy detail view (no per-metric hue passed): - green: ["color-mix(in srgb, var(--success) 88%, #000)", "var(--success)"], - yellow: ["color-mix(in srgb, var(--warning) 88%, #000)", "var(--warning)"], - red: ["color-mix(in srgb, var(--destructive) 88%, #000)", "var(--destructive)"], + green: ["var(--ring-green-from)", "var(--ring-green-to)"], + yellow: ["var(--ring-yellow-from)", "var(--ring-yellow-to)"], + red: ["var(--ring-red-from)", "var(--ring-red-to)"], +}; + +/** + * The `to` (light) stop per key — used as the ring's glow colour + * (`--ring-glow`) so the bloom matches the brighter end of the arc. + */ +export const RING_GLOW: Record = { + readiness: "var(--ring-readiness-to)", + recovery: "var(--ring-recovery-to)", + sleep: "var(--ring-sleep-to)", + stress: "var(--ring-stress-to)", + strain: "var(--ring-strain-to)", + green: "var(--ring-green-to)", + yellow: "var(--ring-yellow-to)", + red: "var(--ring-red-to)", }; /** @@ -54,9 +66,9 @@ export const RING_GRADIENT: Record = { * mixes (gently) over the theme `--card`. Set inline by each `RingTile`. */ export const TILE_HUE: Record = { - readiness: "var(--success)", - sleep: "var(--info)", - recovery: "var(--dracula-cyan)", - stress: "var(--warning)", - strain: "var(--dracula-cyan)", + readiness: "var(--tile-readiness)", + sleep: "var(--tile-sleep)", + recovery: "var(--tile-recovery)", + stress: "var(--tile-stress)", + strain: "var(--tile-strain)", }; diff --git a/src/components/insights/derived/score-ring.tsx b/src/components/insights/derived/score-ring.tsx index 98f88857f..7cbd46b4a 100644 --- a/src/components/insights/derived/score-ring.tsx +++ b/src/components/insights/derived/score-ring.tsx @@ -1,50 +1,50 @@ "use client"; -import { useId } from "react"; +import { useEffect, useId, useState } from "react"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import { useCountUp } from "@/hooks/use-count-up"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; import { bandForScore, clampScore, type ScoreBand } from "./band-tokens"; -import { RING_GRADIENT, type RingHue } from "./ring-hues"; +import { RING_GRADIENT, RING_GLOW, type RingHue } from "./ring-hues"; /** - * v1.13.x — the composite-score dial, now a hand-rolled SVG arc gauge - * (Recharts retired FROM THIS COMPONENT ONLY; it stays the default for real - * data charts elsewhere). A score dial is a fixed-geometry gauge, not a data - * chart, so a ~40-LOC SVG gives exact control over a thin round-cap arc, a - * faint same-hue track, and a single-hue two-stop gradient — the premium - * Apple/Oura signature — at 0 KB runtime and with no per-tile resize - * observer. + * v1.14.0 — the composite-score dial: a hand-rolled SVG arc gauge with a + * premium "signature reveal" (Oura/WHOOP-tier), 0 KB runtime, no animation + * library. A score dial is a fixed-geometry gauge, not a data chart, so SVG + * gives exact control over a thin round-cap arc, a segmented same-hue track, + * a single-hue two-stop gradient, a faint "vs your baseline" ghost arc, and a + * token-tinted bloom — all CSS-driven. * - * The arc is a single `` swept by `stroke-dashoffset`, - * filled with a JSX `` (NOT a `dangerouslySetInnerHTML` — - * ``/`` are real SVG JSX). The `` is rotated -90° - * so the arc starts at 12 o'clock and sweeps clockwise without per-point trig. - * The centred number is real DOM text (not SVG ``), so a long label can - * never clip under the SVG viewbox and the number uses the design tokens. + * Motion (all CSS-first, all gated on `prefers-reduced-motion`): + * • the arc sweeps from empty on mount via a `linear()` SPRING that + * overshoots ~6% then settles (`--ring-spring`); the spring is swapped for + * a plain ease-out above score 94 so a high arc never wraps past full; + * • `delayMs` staggers the sweep + the count-up so the strip reveals + * left-to-right and each number trails its arc; + * • a conic SHEEN + the tile rise + glow live in `globals.css`, gated on the + * strip's `data-revealed` flag (set once per session) so a background + * refetch never re-triggers the moment. * - * `score === null` renders the provisional/empty state: an unfilled ring with - * an em-dash and a localised "not enough data yet" caption. + * The arc/track/ghost are real SVG JSX (`` is NOT + * `dangerouslySetInnerHTML`). The `` is rotated -90° so the arc starts at + * 12 o'clock and sweeps clockwise without per-point trig. The centred number + * is real DOM text so a long label never clips under the viewBox. * - * a11y: `role="img"` + an aria-label restating the number + band, so the ring - * is never colour-only. `prefers-reduced-motion` paints the final arc offset - * with no transition and disables the count-up. - * - * `hue` selects a per-metric single-hue gradient (readiness greener, sleep - * bluer, recovery/strain turquoise, stress amber). When omitted — the anatomy - * detail view — the gradient falls back to the score's band token - * (green/yellow/red), preserving the band semantics there. The band is always - * carried by `data-band` + the band word the tile renders + the aria-label, - * regardless of the ring hue. + * `score === null` renders the provisional/empty state. a11y: `role="img"` + + * an aria-label restating number + band, so the ring is never colour-only; + * the band always rides `data-band` regardless of arc hue. */ // The SVG is a fixed 0 0 100 100 viewBox; CSS scales it to `dims.px`. const VIEW = 100; -const STROKE = 9; // ~9% of size → Apple-class thinness (was ~22%). +const STROKE = 9; // ~9% of size → Apple-class thinness. const R = (VIEW - STROKE) / 2; // radius so the stroke sits inside the box. const C = 2 * Math.PI * R; // circumference for the dash math. const CX = VIEW / 2; +// Above this score the spring overshoot (~6% of the swept distance) would push +// the arc past a full circle and wrap — use a non-overshoot ease-out instead. +const SPRING_SAFE_MAX = 94; const SIZE: Record< NonNullable, @@ -64,7 +64,7 @@ export interface ScoreRingProps { label?: string; /** Render size. `sm` in a grid tile, `md`/`lg` on the anatomy view. */ size?: "sm" | "md" | "lg"; - /** Disable the sweep + count-up (e.g. when already animated by a parent). */ + /** Disable the sweep + count-up (e.g. when already revealed this session). */ animate?: boolean; /** * Per-metric hue for the single-hue two-stop arc gradient. When omitted the @@ -72,11 +72,22 @@ export interface ScoreRingProps { * anatomy detail view keeps band semantics that way. */ hue?: RingHue; + /** + * Stagger offset (ms): delays the arc sweep + the count-up start so a row of + * rings reveals left-to-right and each number trails its own arc. + */ + delayMs?: number; + /** + * Optional "your normal" reference (0..100). When set + finite, a faint + * thinner ghost arc renders under the live arc so the user sees today vs + * their baseline at a glance. Omit (null) when no trailing series exists. + */ + baseline?: number | null; /** * Retained for API compatibility. The premium redesign drops the old * white-arc-on-dark-slab treatment in favour of the per-metric `hue` - * gradient on the gentle `.wellness-tile`, so this no longer changes the - * paint; it is kept so existing call sites + tests don't break. + * gradient, so this no longer changes the paint; kept so existing call sites + * + tests don't break. */ variant?: "band" | "onGradient"; className?: string; @@ -89,6 +100,8 @@ export function ScoreRing({ size = "md", animate = true, hue, + delayMs = 0, + baseline, className, }: ScoreRingProps) { const { t } = useTranslations(); @@ -98,19 +111,39 @@ export function ScoreRing({ const hasScore = score != null && Number.isFinite(score); const clamped = hasScore ? clampScore(score) : 0; const resolvedBand: ScoreBand = band ?? bandForScore(clamped); + const hueKey = hue ?? resolvedBand; // Single-hue two-stop gradient. The per-metric `hue` leans the colour - // (Oura's move); with no `hue` the anatomy view falls back to the band - // token so its green/yellow/red semantics survive. - const [from, to] = RING_GRADIENT[hue ?? resolvedBand]; + // (Oura's move); with no `hue` the anatomy view falls back to the band token. + const [from, to] = RING_GRADIENT[hueKey]; + const glow = RING_GLOW[hueKey]; const reduced = prefersReducedMotion(); + const shouldAnimate = animate && hasScore && !reduced; const dash = (clamped / 100) * C; - // Count-up only feeds the centred number; the arc sweeps via the CSS - // stroke-dashoffset transition (or paints final under reduced motion). + const hasBaseline = + baseline != null && Number.isFinite(baseline) && hasScore; + const baselineDash = hasBaseline ? (clampScore(baseline) / 100) * C : 0; + + // Arc starts empty (offset C) and sweeps to its fill on the next frame after + // mount, so the spring transition actually plays on first paint (transitions + // don't run on the initial commit). With motion off, it paints final at once. + const [armed, setArmed] = useState(false); + useEffect(() => { + if (!shouldAnimate) return; + const raf = requestAnimationFrame(() => setArmed(true)); + return () => cancelAnimationFrame(raf); + }, [shouldAnimate]); + const filled = !shouldAnimate || armed; + + const easing = + clamped > SPRING_SAFE_MAX ? "var(--ring-ease)" : "var(--ring-spring)"; + const displayed = useCountUp(clamped, { - enabled: animate && hasScore && !reduced, + enabled: shouldAnimate, + durationMs: 1000, + startDelayMs: delayMs + 180, }); const displayedRounded = Math.round(displayed); @@ -129,7 +162,13 @@ export function ScoreRing({ role="img" aria-label={ariaLabel} className={cn("relative shrink-0", className)} - style={{ width: dims.px, height: dims.px }} + style={ + { + width: dims.px, + height: dims.px, + "--ring-glow": glow, + } as React.CSSProperties + } > - {/* Faint same-hue track — 12% of the ring colour, never a bright - competing ring. */} + {/* Segmented same-hue tick track — reads as an instrument, not a bar. */} - {/* The progress arc — single-hue gradient, round cap. */} + {/* "vs your baseline" ghost arc — thinner, faint, under the live arc. */} + {hasBaseline ? ( + + ) : null} + {/* The progress arc — single-hue gradient, round cap, spring sweep, bloom. */} - {/* Centred number as real DOM text — easier to style with the design - tokens + tabular-nums, and no SVG-text clipping on a long label. */} + {/* Conic specular sheen — sweeps the arc once on the strip reveal. */} +