From d92b3b9efd5b3057685a122cdccff1afb93a7e00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 19 Jun 2026 19:45:23 +0200 Subject: [PATCH] style(insights): calm and flatten the health-score rings The score dials wore a token-tinted drop-shadow bloom, a conic specular sheen, a breathing pulse on the green ring, and a dark-to-neon two-stop arc gradient. Against the flat 2-D Recharts charts that read as a glowing 3-D gauge and made the overview restless. Make the calm treatment the default everywhere, not only behind the `flat` prop: - Drop the bloom, the sheen, and the pulse for every ring. `.wellness-ring-arc` is now a plain flat stroke; the sheen overlay paints nothing. The shared cycle ring inherits the same quiet. - Mute the arc colours toward the chart palette: the two gradient stops sit a hair apart for a whisper of depth instead of a neon sweep, pulled down and desaturated per hue in both the Dracula and Alucard sets. Band semantics (green/yellow/red thresholds) are unchanged. - `flat` now only drops the entrance sweep + count-up, so the dashboard hero ring paints its value at once. The /insights strip keeps the subtle spring fill; prefers-reduced-motion still zeroes it. Score and band logic, thresholds, and the data-band/aria contract are untouched. --- src/app/globals.css | 223 ++++++------------ src/components/insights/derived/ring-hues.ts | 48 ++-- .../insights/derived/score-ring.tsx | 67 +++--- 3 files changed, 116 insertions(+), 222 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 80d94a203..4696df20e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -235,38 +235,38 @@ --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 cyan · sleep purple · stress orange · - * strain pink. Every `to` stop IS the matching Dracula palette colour - * and the `from` stop is a hand-darkened tone of the same hue, so the - * arcs read as Dracula rather than generic chart colours (the v1.14.0 - * set leaned indigo/teal/violet, which sat next to the palette instead - * of on it). Each `to` doubles as the ring's bloom colour. The Alucard - * light set lives on `:root.light`. + * v1.18.7 — wellness-ring per-metric arc colours (Dracula dark), CALM pass. + * Five DISTINCT but MUTED single-hue arcs: readiness green · recovery cyan · + * sleep purple · stress orange · strain pink. The earlier set ran a + * dark→neon two-stop sweep (`to` == the raw Dracula palette colour) plus a + * bloom + sheen, which read as glowing/3-D against the flat Recharts charts. + * The arc is now a near-flat single tone per hue: `from`/`to` sit a hair + * apart for a whisper of depth, both pulled down from the neon palette and + * desaturated toward the chart colours, so the ring reads as quiet as a + * chart series. The Alucard light set lives on `:root.light`. */ - --ring-readiness-from: #1fa854; - --ring-readiness-to: #50fa7b; + --ring-readiness-from: #3fb86b; + --ring-readiness-to: #46c476; --tile-readiness: #50fa7b; - --ring-recovery-from: #2fa8c4; - --ring-recovery-to: #8be9fd; + --ring-recovery-from: #4fb6cc; + --ring-recovery-to: #58c0d6; --tile-recovery: #8be9fd; - --ring-sleep-from: #8655d6; - --ring-sleep-to: #bd93f9; + --ring-sleep-from: #9a73da; + --ring-sleep-to: #a37fe0; --tile-sleep: #bd93f9; - --ring-stress-from: #d9842e; - --ring-stress-to: #ffb86c; + --ring-stress-from: #e09a52; + --ring-stress-to: #e8a55e; --tile-stress: #ffb86c; - --ring-strain-from: #d6429b; - --ring-strain-to: #ff79c6; + --ring-strain-from: #e06aa8; + --ring-strain-to: #ea74b1; --tile-strain: #ff79c6; /* 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; + --ring-green-from: #3fb868; + --ring-green-to: #48c473; + --ring-yellow-from: #e0a050; + --ring-yellow-to: #e8ab5c; + --ring-red-from: #e0594a; + --ring-red-to: #e96456; /* * v1.15.0 — cycle-phase ring hues (Dracula dark). Four calm, distinct @@ -332,33 +332,35 @@ --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 - * Dracula hue families as the dark set: readiness green · recovery cyan · - * sleep purple · stress orange · strain pink. + * v1.18.7 — wellness-ring per-metric arc colours (Alucard light), CALM pass. + * Deep + saturated enough to hold AA presence on the white `--card` (raw + * Dracula cyan/green wash out on white), but the dark→light two-stop SWEEP + * is collapsed to a near-flat single tone (`from`/`to` a hair apart) so the + * arc reads as quiet as a chart series rather than a glowing gradient. Same + * five hue families as the dark set: readiness green · recovery cyan · sleep + * purple · stress orange · strain pink. */ - --ring-readiness-from: #0f7a3a; - --ring-readiness-to: #18a34e; + --ring-readiness-from: #138043; + --ring-readiness-to: #168a48; --tile-readiness: #14720a; - --ring-recovery-from: #067f96; - --ring-recovery-to: #0d95b0; + --ring-recovery-from: #07849c; + --ring-recovery-to: #098ba3; --tile-recovery: #08869e; - --ring-sleep-from: #5b2db8; - --ring-sleep-to: #7a45d6; + --ring-sleep-from: #6638c2; + --ring-sleep-to: #6c3fc8; --tile-sleep: #6233c0; - --ring-stress-from: #b06a16; - --ring-stress-to: #c47a1e; + --ring-stress-from: #b27018; + --ring-stress-to: #b8771c; --tile-stress: #a34d14; - --ring-strain-from: #ad1e6f; - --ring-strain-to: #cc2e88; + --ring-strain-from: #b62478; + --ring-strain-to: #bd2a7e; --tile-strain: #b0277d; - --ring-green-from: #0f7a3a; - --ring-green-to: #18a34e; - --ring-yellow-from: #b06a16; - --ring-yellow-to: #d68a2a; - --ring-red-from: #a3271a; - --ring-red-to: #cb3a2a; + --ring-green-from: #138043; + --ring-green-to: #168a48; + --ring-yellow-from: #b27018; + --ring-yellow-to: #bd7a1e; + --ring-red-from: #b22e20; + --ring-red-to: #ba3526; /* * v1.15.0 — cycle-phase ring hues (Alucard light). Deeper + more @@ -606,22 +608,13 @@ } /* - * 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. + * v1.18.7 — the wellness-strip reveal, calmed. What remains is a quiet + * left-to-right tile rise+fade (gated once per session via `data-revealed`) + * and the arc's spring-fill sweep (in score-ring.tsx, `--ring-spring`). The + * conic specular sheen + the bloom + the breathing pulse were removed so the + * strip settles as flat as the surrounding charts. A single + * prefers-reduced-motion block at the bottom zeroes the tile rise. */ -@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 @@ -658,93 +651,26 @@ } } -/* 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. */ +/* v1.18.7 — the conic specular SHEEN is retired. It swept a white highlight + across the arc once on reveal, which was the most overtly "premium / 3-D" + touch and clashed hardest with the flat charts. The class is kept as a + no-op so the cycle-ring + score-ring call sites that still render the + overlay div simply paint nothing. */ .wellness-ring-sheen { - position: absolute; - inset: 6%; - border-radius: 50%; - opacity: 0; - /* A specular highlight must always LIGHTEN. Under mix-blend overlay a - near-black light-mode `--card-foreground` would darken the tile into a - grey band, so the sheen rides a fixed white in both themes. */ - background: radial-gradient( - circle at 50% 50%, - rgba(255, 255, 255, 0.72) 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; - } + display: none; } -/* 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) - ); -} -/* Flat 2-D arc — no bloom, no pulse. The dashboard hero ring uses this so it - reads as calmly as the chart + tile surfaces around it. */ -.wellness-ring-arc--flat { - filter: none; -} +/* v1.18.7 — the arc is FLAT by default. The earlier treatment painted a + token-tinted drop-shadow bloom behind every arc plus a breathing pulse on + the green-band ring; against the flat 2-D Recharts charts that read as a + glowing 3-D dial. Both are gone — the arc is a plain stroke, the same + weight a chart series carries. `.wellness-ring-arc--flat` is kept as an + alias so the dashboard-hero call site (and any other) stays valid. */ +.wellness-ring-arc, +.wellness-ring-arc--flat, .wellness-ring-arc--pulse { - /* Breathe a few times to celebrate a strong score, then settle — an - infinite loop would repaint the (non-compositable) drop-shadow filter - forever. Motion happens, then the surface goes calm. */ - animation: ringGlow 3.4s ease-in-out 2s 3; -} -@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) - ); - } + filter: none; + animation: none; } @media (prefers-reduced-motion: reduce) { @@ -762,15 +688,6 @@ 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 cbbbd48e2..0bba33290 100644 --- a/src/components/insights/derived/ring-hues.ts +++ b/src/components/insights/derived/ring-hues.ts @@ -1,22 +1,19 @@ /** * v1.14.0 — per-metric ring hues for the wellness-score strip. * - * 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 cyan, sleep purple, stress orange, - * strain pink — five clearly different hues so the strip never reads as - * "a wash of green/cyan". In the dark theme every `to` stop sits exactly - * on the matching Dracula palette colour. + * Each metric leans toward one intentional, distinct hue: readiness green, + * recovery cyan, sleep purple, stress orange, strain pink — five clearly + * different hues so the strip never reads as "a wash of green/cyan". * - * The actual colours live as CSS custom properties in `globals.css` + * v1.18.7 — CALM. The arc was a dark→neon two-stop gradient (the `to` stop + * sat on the raw Dracula palette colour) plus a bloom + sheen, which glowed + * against the flat charts. The two stops are now MUTED and sit a hair apart, + * so each arc reads as a near-flat single tone — quiet as a chart series. 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.) + * is HAND-TUNED per theme — deep enough to hold contrast on the white Alucard + * card without going neon on the dark Dracula card. * * 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" @@ -29,11 +26,11 @@ 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 var - * references resolved per theme — consumed straight by the SVG - * `` stops. + * Near-flat single-hue arc `[from, to]` 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). The two stops are a hair apart for a + * whisper of depth, not a neon sweep. Values are CSS var references resolved + * per theme — consumed straight by the SVG `` stops. */ export const RING_GRADIENT: Record = { readiness: ["var(--ring-readiness-from)", "var(--ring-readiness-to)"], @@ -47,21 +44,6 @@ export const RING_GRADIENT: Record = { 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)", -}; - /** * The surface tint per metric — the `--tile-hue` the `.wellness-tile` CSS * mixes (gently) over the theme `--card`. Set inline by each `RingTile`. diff --git a/src/components/insights/derived/score-ring.tsx b/src/components/insights/derived/score-ring.tsx index b7bbfe337..50d281e8f 100644 --- a/src/components/insights/derived/score-ring.tsx +++ b/src/components/insights/derived/score-ring.tsx @@ -6,25 +6,31 @@ 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, RING_GLOW, type RingHue } from "./ring-hues"; +import { RING_GRADIENT, type RingHue } from "./ring-hues"; /** - * 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. + * v1.14.0 — the composite-score dial: a hand-rolled SVG arc gauge, 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 near-flat single-hue arc, and a faint "vs your + * baseline" ghost arc. * - * Motion (all CSS-first, all gated on `prefers-reduced-motion`): + * v1.18.7 — CALM by default. The earlier dial wore a token-tinted bloom, a + * conic specular sheen, and a breathing pulse on the green ring; against the + * flat 2-D Recharts charts that read as a glowing 3-D gauge. All three are + * gone everywhere — the ring now paints a plain stroke in a muted single hue, + * as quiet as a chart series. The only motion left is a subtle spring fill on + * first reveal + a count-up, both gated on `prefers-reduced-motion`. + * + * Motion (CSS-first, 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. + * • the quiet tile rise lives in `globals.css`, gated on the strip's + * `data-revealed` flag (set once per session) so a background refetch + * never re-triggers it. * * The arc/track/ghost are real SVG JSX (`` is NOT * `dangerouslySetInnerHTML`). The `` is rotated -90° so the arc starts at @@ -78,10 +84,10 @@ export interface ScoreRingProps { */ delayMs?: number; /** - * Flat 2-D treatment: drops the drop-shadow bloom, the breathing pulse, the - * conic sheen, and the sweep/count-up entrance. Used on the dashboard hero so - * the ring sits as calmly as the surrounding chart + tile surfaces. The - * /insights wellness strip + anatomy view keep the full premium reveal. + * v1.18.7 — the bloom / sheen / pulse are gone for every ring, so the only + * thing `flat` still does is drop the spring-fill sweep + the count-up: the + * dashboard-hero ring paints its final value at once, with no entrance + * motion. The /insights strip + anatomy view keep the subtle sweep. */ flat?: boolean; /** @@ -121,10 +127,10 @@ export function ScoreRing({ 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. + // Near-flat single-hue arc. The two stops sit a hair apart for a whisper of + // depth; the per-metric `hue` leans the colour, with no `hue` the anatomy + // view falls back to the muted band token. const [from, to] = RING_GRADIENT[hueKey]; - const glow = RING_GLOW[hueKey]; const reduced = prefersReducedMotion(); // `flat` forces the calm static treatment regardless of the `animate` prop: @@ -175,18 +181,15 @@ export function ScoreRing({ { width: dims.px, height: dims.px, - "--ring-glow": glow, } as React.CSSProperties } > - {/* `overflow-visible`: the arc's drop-shadow bloom (`.wellness-ring-arc`) - paints outside the viewBox — the arc fills the 0 0 100 100 box, so the - default `overflow: hidden` on SVG clipped the glow flat at the circle's - edge. The -90° rotation is unaffected (the bloom is radially symmetric) - and the parent div never clips, so the glow now feathers out fully. */} + {/* The -90° rotation starts the arc at 12 o'clock so it sweeps clockwise + without per-point trig. The arc now carries no bloom, so the SVG needs + no `overflow-visible`. */} - {/* Conic specular sheen — sweeps the arc once on the strip reveal. - Omitted entirely in the flat treatment (dashboard hero). */} - {flat ? null :