feat(insights): premium animated wellness-score rings

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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-05 23:40:00 +02:00
1 parent 4a31ce41b2
commit 20d7187b0d
5 files changed
+508 -114

No files matched your search

+233 -2
View File
@@ -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: "<angle>";
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;
}
}
/*
+52 -40
View File
@@ -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-<key>-from` / `--ring-<key>-to` / `--tile-<key>`) 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 `<linearGradient>` 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
* `<linearGradient>` stops.
*/
export const RING_GRADIENT: Record<RingHue | ScoreBand, [string, string]> = {
// 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<RingHue | ScoreBand, string> = {
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<RingHue | ScoreBand, [string, string]> = {
* mixes (gently) over the theme `--card`. Set inline by each `RingTile`.
*/
export const TILE_HUE: Record<RingHue, string> = {
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)",
};
+116 -53
View File
@@ -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 `<circle stroke-dasharray>` swept by `stroke-dashoffset`,
* filled with a JSX `<linearGradient>` (NOT a `dangerouslySetInnerHTML` —
* `<defs>`/`<linearGradient>` are real SVG JSX). The `<svg>` 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 `<text>`), 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 (`<linearGradient>` is NOT
* `dangerouslySetInnerHTML`). The `<svg>` 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<ScoreRingProps["size"]>,
@@ -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
}
>
<svg
viewBox={`0 0 ${VIEW} ${VIEW}`}
@@ -143,19 +182,45 @@ export function ScoreRing({
<stop offset="100%" stopColor={to} />
</linearGradient>
</defs>
{/* 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. */}
<circle
cx={CX}
cy={CX}
r={R}
fill="none"
stroke={`url(#${gid})`}
strokeOpacity={0.12}
strokeOpacity={0.16}
strokeWidth={STROKE}
strokeDasharray="0.5 4.2"
/>
{/* The progress arc — single-hue gradient, round cap. */}
{/* "vs your baseline" ghost arc — thinner, faint, under the live arc. */}
{hasBaseline ? (
<circle
cx={CX}
cy={CX}
r={R}
fill="none"
stroke={`url(#${gid})`}
strokeOpacity={0.3}
strokeWidth={STROKE * 0.42}
strokeLinecap="round"
strokeDasharray={C}
style={{
strokeDashoffset: filled ? C - baselineDash : C,
transition: shouldAnimate
? `stroke-dashoffset 420ms var(--ring-ease) ${delayMs}ms`
: undefined,
}}
/>
) : null}
{/* The progress arc — single-hue gradient, round cap, spring sweep, bloom. */}
<circle
className={cn(
"wellness-ring-arc",
shouldAnimate &&
resolvedBand === "green" &&
"wellness-ring-arc--pulse",
)}
cx={CX}
cy={CX}
r={R}
@@ -164,19 +229,17 @@ export function ScoreRing({
strokeWidth={STROKE}
strokeLinecap="round"
strokeDasharray={C}
style={
reduced
? { strokeDashoffset: C - dash }
: {
strokeDashoffset: C - dash,
transition:
"stroke-dashoffset 700ms cubic-bezier(.22,.61,.36,1)",
}
}
style={{
strokeDashoffset: filled ? C - dash : C,
transition: shouldAnimate
? `stroke-dashoffset 1150ms ${easing} ${delayMs}ms`
: undefined,
}}
/>
</svg>
{/* 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. */}
<div className="wellness-ring-sheen" aria-hidden="true" />
{/* Centred number as real DOM text — design tokens + tabular-nums. */}
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
<span
className={cn(
@@ -1,6 +1,6 @@
"use client";
import type { ComponentType } from "react";
import { useState, type ComponentType } from "react";
import Link from "next/link";
import { Activity, Flame, Gauge, HeartPulse, Moon, RefreshCw } from "lucide-react";
import { cn } from "@/lib/utils";
@@ -50,6 +50,15 @@ interface ScoreStripProps {
className?: string;
}
/** Mean of the trailing series excluding the newest point = "your normal". */
function baselineOf(series: number[] | undefined): number | null {
if (!series || series.length < 3) return null;
const past = series.slice(0, -1); // exclude today (newest)
if (past.length === 0) return null;
const mean = past.reduce((sum, v) => sum + v, 0) / past.length;
return Number.isFinite(mean) ? mean : null;
}
/** A score ring wrapped in a link to its anatomy detail page. */
function RingTile({
score,
@@ -60,6 +69,9 @@ function RingTile({
metricSlot,
hue,
icon,
index,
animate,
baseline,
}: {
score: number;
band: ScoreBand;
@@ -69,22 +81,37 @@ function RingTile({
metricSlot: string;
hue: RingHue;
icon: ComponentType<{ className?: string }>;
/** Position in the rendered strip — drives the left-to-right stagger. */
index: number;
/** Whether the once-per-session reveal plays (false = paint final). */
animate: boolean;
/** "Your normal" reference for the ghost arc, or null when no series. */
baseline: number | null;
}) {
const Icon = icon;
const delayMs = index * 75;
return (
<Link
href={href}
data-slot="wellness-score-tile"
data-metric={metricSlot}
// v1.13.x — the wellness tile drops the saturated `.score-tile-gradient`
// dark slab for the gentle, hero-family `.wellness-tile`: a low-opacity
// mix of the metric's `--tile-hue` over the theme `--card`. The surface
// is luminous in both themes, so the header/number ride `--foreground`
// (no pinned white) and the per-metric `hue` arc is the only saturated
// thing on the tile — calm, like the greeting tile. The band stays
// conveyed by the word label below + the ring's `data-band` + aria-label.
style={{ "--tile-hue": TILE_HUE[hue] } as React.CSSProperties}
className="wellness-tile focus-visible:ring-ring flex flex-col gap-4 rounded-xl p-5 transition-colors focus-visible:ring-2 focus-visible:outline-none"
// v1.14.0 — gentle, hero-family `.wellness-tile`: a low-opacity mix of
// the metric's `--tile-hue` over the theme `--card`, with a faint film
// grain (::after) for material depth. The per-metric `hue` arc is the
// only saturated thing on the tile. On a fresh session the tile rises +
// fades in staggered (`wellness-tile-rise` + `--reveal-delay`), gated by
// the strip's `data-revealed`; the band stays conveyed by the word below
// + the ring's `data-band` + aria-label.
style={
{
"--tile-hue": TILE_HUE[hue],
"--reveal-delay": `${delayMs}ms`,
} as React.CSSProperties
}
className={cn(
"wellness-tile focus-visible:ring-ring flex flex-col gap-4 rounded-xl p-5 focus-visible:ring-2 focus-visible:outline-none",
animate && "wellness-tile-rise",
)}
>
<div className="text-foreground flex items-center gap-2">
<Icon className="h-5 w-5 shrink-0" aria-hidden="true" />
@@ -97,7 +124,15 @@ function RingTile({
the header above. The per-metric `hue` gradient leans the arc
colour; the band stays conveyed by the word label below + the
ring's `data-band` + aria-label. */}
<ScoreRing score={score} band={band} size="sm" hue={hue} />
<ScoreRing
score={score}
band={band}
size="sm"
hue={hue}
animate={animate}
delayMs={delayMs}
baseline={baseline}
/>
<span
data-slot="wellness-score-band-word"
className="text-muted-foreground text-center text-xs"
@@ -118,6 +153,25 @@ export function WellnessScores({
}: ScoreStripProps) {
const { t } = useTranslations();
// The signature reveal plays ONCE per browser session. A background
// Apple-Health-sync refetch re-renders this strip repeatedly (the v1.8.7
// lesson — sync evicted the derived cache all day); replaying the cascade on
// every refetch would read as cheap + janky. The sessionStorage flag is set
// on the first Insights visit; subsequent mounts paint the final state with
// no stagger/sweep/sheen. Lazily computed once on the client (the strip only
// renders after the client-side batch resolves, so there is no SSR tile to
// mismatch).
const [play] = useState(() => {
if (typeof window === "undefined") return false;
try {
if (sessionStorage.getItem("wellness-strip-revealed")) return false;
sessionStorage.setItem("wellness-strip-revealed", "1");
return true;
} catch {
return true;
}
});
// A failed shared batch must read as an error, not as "no scores" — the
// strip is top-of-page, so the only Retry lives here (mirrors the Vitals
// error card styling; one Retry recovers both strips).
@@ -177,6 +231,9 @@ export function WellnessScores({
metricSlot="READINESS"
hue="readiness"
icon={Gauge}
index={tiles.length}
animate={play}
baseline={null}
/>,
);
}
@@ -192,6 +249,9 @@ export function WellnessScores({
metricSlot="RECOVERY_SCORE"
hue="recovery"
icon={HeartPulse}
index={tiles.length}
animate={play}
baseline={baselineOf(recovery.value.series)}
/>,
);
}
@@ -207,6 +267,9 @@ export function WellnessScores({
metricSlot="SLEEP_SCORE"
hue="sleep"
icon={Moon}
index={tiles.length}
animate={play}
baseline={null}
/>,
);
}
@@ -222,6 +285,9 @@ export function WellnessScores({
metricSlot="STRESS_SCORE"
hue="stress"
icon={Activity}
index={tiles.length}
animate={play}
baseline={baselineOf(stress.value.series)}
/>,
);
}
@@ -237,6 +303,9 @@ export function WellnessScores({
metricSlot="STRAIN_SCORE"
hue="strain"
icon={Flame}
index={tiles.length}
animate={play}
baseline={baselineOf(strain.value.series)}
/>,
);
}
@@ -270,6 +339,9 @@ export function WellnessScores({
/>
<div
data-slot="wellness-scores-grid"
// `data-revealed` triggers the CSS tile-rise + sheen keyframes on the
// fresh-session reveal; absent on subsequent mounts (paint final).
data-revealed={play ? "true" : undefined}
className={cn("grid grid-cols-2 gap-3 sm:grid-cols-3", lgCols)}
>
{tiles}
+24 -8
View File
@@ -17,9 +17,9 @@ import { useEffect, useRef, useState } from "react";
*/
export function useCountUp(
target: number,
options: { durationMs?: number; enabled?: boolean } = {},
options: { durationMs?: number; enabled?: boolean; startDelayMs?: number } = {},
): number {
const { durationMs = 600, enabled = true } = options;
const { durationMs = 600, enabled = true, startDelayMs = 0 } = options;
// Seed at the final value so the reduced-motion / SSR path is already
// correct without any setState; only the animated path drives frames,
// and those setState calls all happen inside the rAF callback (async).
@@ -60,7 +60,7 @@ export function useCountUp(
return;
}
const start = performance.now();
let start = 0;
const tick = (now: number) => {
const elapsed = now - start;
@@ -76,20 +76,36 @@ export function useCountUp(
}
};
// First frame (and every subsequent one) runs inside the rAF callback,
// so the initial 0-state set is asynchronous — no cascading render.
frameRef.current = window.requestAnimationFrame((now) => {
// Hold the number at 0 during the stagger delay so it matches the empty
// ring, then start the rAF loop. Every setState runs inside a rAF/timeout
// callback (async) — never synchronously in the effect body — so there is
// no cascading render. `startDelayMs` lets the strip stagger each tile's
// count-up so the number trails its arc, left-to-right.
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const begin = () => {
frameRef.current = window.requestAnimationFrame((now) => {
start = now;
tick(now);
});
};
const arm = window.requestAnimationFrame(() => {
setValue(0);
tick(now);
if (startDelayMs > 0) {
timeoutId = setTimeout(begin, startDelayMs);
} else {
begin();
}
});
return () => {
window.cancelAnimationFrame(arm);
if (timeoutId != null) clearTimeout(timeoutId);
if (frameRef.current != null) {
window.cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
};
}, [target, durationMs, enabled]);
}, [target, durationMs, enabled, startDelayMs]);
return value;
}