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. */} +