From e7d34eb85312506710a70b21e95828465f7eaa68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 22:09:00 +0200 Subject: [PATCH] feat(insights): premium wellness-score tiles + SVG ring redesign --- messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/pl.json | 2 +- src/app/globals.css | 39 ++- .../derived/__tests__/score-ring.test.tsx | 8 +- .../__tests__/wellness-scores.test.tsx | 14 +- src/components/insights/derived/ring-hues.ts | 62 ++++ .../insights/derived/score-ring.tsx | 298 ++++++++---------- .../insights/derived/wellness-scores.tsx | 42 +-- 12 files changed, 249 insertions(+), 226 deletions(-) create mode 100644 src/components/insights/derived/ring-hues.ts diff --git a/messages/de.json b/messages/de.json index c252373ab..17ead6fb8 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2782,7 +2782,7 @@ } }, "scores": { - "sectionTitle": "Wellness-Werte", + "sectionTitle": "Deine Gesundheitswerte", "loadError": "Deine Wellness-Werte konnten gerade nicht geladen werden.", "recovery": "Erholung", "stress": "Stress", diff --git a/messages/en.json b/messages/en.json index de823f4ee..0472b7588 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2782,7 +2782,7 @@ } }, "scores": { - "sectionTitle": "Wellness scores", + "sectionTitle": "Your health scores", "loadError": "Could not load your wellness scores right now.", "recovery": "Recovery", "stress": "Stress", diff --git a/messages/es.json b/messages/es.json index 3d49d0d69..2fce32501 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2782,7 +2782,7 @@ } }, "scores": { - "sectionTitle": "Puntuaciones de bienestar", + "sectionTitle": "Tus indicadores de salud", "loadError": "No se pudieron cargar tus puntuaciones de bienestar en este momento.", "recovery": "Recuperación", "stress": "Estrés", diff --git a/messages/fr.json b/messages/fr.json index bb058fce4..4e52ce2ed 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2782,7 +2782,7 @@ } }, "scores": { - "sectionTitle": "Scores de bien-être", + "sectionTitle": "Tes indicateurs de santé", "loadError": "Impossible de charger vos scores de bien-être pour le moment.", "recovery": "Récupération", "stress": "Stress", diff --git a/messages/it.json b/messages/it.json index b061550b9..ad4a4b251 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2782,7 +2782,7 @@ } }, "scores": { - "sectionTitle": "Punteggi di benessere", + "sectionTitle": "I tuoi indicatori di salute", "loadError": "Al momento non è stato possibile caricare i tuoi punteggi di benessere.", "recovery": "Recupero", "stress": "Stress", diff --git a/messages/pl.json b/messages/pl.json index a20339a0f..edcf0b5ba 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2782,7 +2782,7 @@ } }, "scores": { - "sectionTitle": "Wyniki dobrostanu", + "sectionTitle": "Twoje wskaźniki zdrowia", "loadError": "Nie udało się teraz wczytać Twoich wyników dobrostanu.", "recovery": "Regeneracja", "stress": "Stres", diff --git a/src/app/globals.css b/src/app/globals.css index 7a88a0c7d..edb59abff 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -382,27 +382,34 @@ } /* - * `.score-tile-gradient` — the saturated purple→pink wellness-tile - * background that echoes the hero band while staying dark enough for a - * WHITE score ring + white centre number to clear WCAG AA in BOTH themes. + * `.wellness-tile` — the v1.13.x premium-redesign wellness-tile surface. * - * Unlike `.hero-gradient` (which mixes over the theme-dependent - * `var(--card)` — near-white in Alucard light, so a white ring would - * vanish), this variant mixes purple/pink over the *fixed* dark - * `var(--dracula-bg)` (#282a36). That keeps the surface identical and - * dark in light + dark mode, so white text/ring read ≥ 4.5:1 either way: - * white on the purple endpoint ≈ 5.0:1, on the pink endpoint ≈ 6.1:1. - * A subtler glow than the hero's keeps a row of five tiles from - * overwhelming the page. + * Replaces the old saturated `.score-tile-gradient` dark slab. Mirrors the + * gentle, calm `.hero-gradient` the maintainer loves: a LOW-opacity mix of + * the metric's `--tile-hue` over the *theme* `var(--card)` (not the fixed + * dark bg), so the surface stays luminous in both Dracula dark + Alucard + * light. The mix is even softer than the hero's 18/12 (14/7) so a *row* of + * five tinted tiles never overwhelms the page. Each `RingTile` sets + * `--tile-hue` inline per metric; the per-metric `hue` arc on the ring is + * the only saturated element, letting the dial be the hero. + * + * Text rides the theme `--foreground` (dark in light mode, light in dark + * mode), so AA is trivial over the `--card`-based surface in both themes — + * the old white-pinned copy + white arc are gone. */ -.score-tile-gradient { +.wellness-tile { + --tile-hue: var(--dracula-purple); background: linear-gradient( 140deg, - color-mix(in srgb, var(--dracula-purple) 55%, var(--dracula-bg)) 0%, - color-mix(in srgb, var(--dracula-pink) 45%, var(--dracula-bg)) 100% + color-mix(in srgb, var(--tile-hue) 14%, var(--card)) 0%, + color-mix(in srgb, var(--tile-hue) 7%, var(--card)) 100% ); - border: 1px solid color-mix(in srgb, var(--dracula-purple) 28%, transparent); - box-shadow: 0 12px 32px -24px color-mix(in srgb, var(--dracula-purple) 45%, transparent); + 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); +} + +.wellness-tile:hover { + border-color: color-mix(in srgb, var(--tile-hue) 38%, transparent); } /* diff --git a/src/components/insights/derived/__tests__/score-ring.test.tsx b/src/components/insights/derived/__tests__/score-ring.test.tsx index 5afbaa253..e7d19cc45 100644 --- a/src/components/insights/derived/__tests__/score-ring.test.tsx +++ b/src/components/insights/derived/__tests__/score-ring.test.tsx @@ -53,12 +53,10 @@ describe("", () => { expect(html).toContain("aria-label"); }); - it("keeps the band semantic on the white-arc onGradient variant", () => { - // The white arc drops the band colour, so the band must still ride the + it("keeps the band semantic regardless of the arc hue (per-metric or band)", () => { + // The arc paints a per-metric / band hue, so the band must still ride the // data attribute + aria-label for the colour-blind/non-visual read. - const html = render( - , - ); + const html = render(); expect(html).toContain('data-band="green"'); expect(html).toContain('role="img"'); expect(html).toContain("aria-label"); diff --git a/src/components/insights/derived/__tests__/wellness-scores.test.tsx b/src/components/insights/derived/__tests__/wellness-scores.test.tsx index 0e3ae67ac..5632a9871 100644 --- a/src/components/insights/derived/__tests__/wellness-scores.test.tsx +++ b/src/components/insights/derived/__tests__/wellness-scores.test.tsx @@ -66,7 +66,7 @@ describe("", () => { expect(html).toBe(""); }); - it("renders a ring tile with an icon + heading and a gradient surface", () => { + it("renders a ring tile with an icon + heading on the gentle wellness surface", () => { const html = render( ", () => { />, ); expect(html).toContain('data-slot="wellness-scores"'); + expect(html).toContain('data-slot="wellness-score-tile"'); expect(html).toContain('data-metric="READINESS"'); - // The tile carries the metric heading and rides the saturated - // purple→pink gradient that echoes the hero card. - expect(html).toContain("Readiness"); - expect(html).toContain("score-tile-gradient"); - // The ring carries its band on the arc + data-attribute (not the number). + // The tile rides the gentle, hero-family `.wellness-tile` surface + // (asserted by the stable class, not viewport text). + expect(html).toContain("wellness-tile"); + // The ring carries its band on the data-attribute. expect(html).toContain('data-band="green"'); - // The band word renders under the ring (semantic kept off the white arc). + // The band word renders under the ring (semantic kept off the arc hue). expect(html).toContain('data-slot="wellness-score-band-word"'); }); diff --git a/src/components/insights/derived/ring-hues.ts b/src/components/insights/derived/ring-hues.ts new file mode 100644 index 000000000..01f8aff83 --- /dev/null +++ b/src/components/insights/derived/ring-hues.ts @@ -0,0 +1,62 @@ +/** + * v1.13.x — 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. + * + * 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. + */ + +import type { ScoreBand } from "./band-tokens"; + +/** The per-metric hue keys the wellness strip passes to ``. */ +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. + */ +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)", + ], + // 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)"], +}; + +/** + * The surface tint per metric — the `--tile-hue` the `.wellness-tile` CSS + * 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)", +}; diff --git a/src/components/insights/derived/score-ring.tsx b/src/components/insights/derived/score-ring.tsx index 260a92664..98f88857f 100644 --- a/src/components/insights/derived/score-ring.tsx +++ b/src/components/insights/derived/score-ring.tsx @@ -1,62 +1,58 @@ "use client"; -import { - Label, - PolarAngleAxis, - PolarRadiusAxis, - RadialBar, - RadialBarChart, - ResponsiveContainer, -} from "recharts"; +import { useId } from "react"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import { useCountUp } from "@/hooks/use-count-up"; -import { - bandForScore, - BAND_VAR, - clampScore, - type ScoreBand, -} from "./band-tokens"; +import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; +import { bandForScore, clampScore, type ScoreBand } from "./band-tokens"; +import { RING_GRADIENT, type RingHue } from "./ring-hues"; /** - * v1.10.0 — the composite-score dial. A radial Recharts ring with the - * `tabular-nums` number centred, band-coloured, sweeping to fill on first - * paint. Rendered small in a grid tile and large on the score-anatomy - * detail view from the same component via the `size` prop — one score - * language, two intensities (the Hybrid direction). + * 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. * - * Built on raw Recharts `RadialBarChart` (the existing house pattern — 6 - * sites already use Recharts via `ResponsiveContainer`), NOT the shadcn - * `chart` primitive: the ring needs no per-series CSS-var injection, so it - * paints its fill straight from the `--dracula-*` band token and avoids - * adding a second `dangerouslySetInnerHTML` to the tree. 0 KB new runtime. + * 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. * - * `score === null` renders the provisional/empty state: an unfilled ring - * with an em-dash and a localised "not enough data yet" caption, so a - * sparse-data hero never goes blank. + * `score === null` renders the provisional/empty state: an unfilled ring with + * an em-dash and a localised "not enough data yet" caption. * - * a11y: `role="img"` + an aria-label restating the number and band, so the - * ring is never colour-only. `prefers-reduced-motion` disables the sweep - * (Recharts `isAnimationActive`) and the count-up. + * 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. * - * `variant` picks the arc palette. `"band"` (default) paints the filled - * arc in the score's band token (green/yellow/red) over a muted track — - * the legible treatment on a plain `bg-card` surface (the score-anatomy - * detail view). `"onGradient"` paints a WHITE arc over a translucent-white - * track for use on the saturated `.score-tile-gradient` wellness cards, - * where a band tint would muddy against the purple/pink and white reads at - * high contrast. The band semantic is never lost to the white arc: it - * still rides the `data-band` attribute, the aria-label, and the band-word - * label the wellness tile renders beneath the ring. + * `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. */ +// 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 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; + const SIZE: Record< NonNullable, - { px: number; numberClass: string; labelClass: string; barSize: number } + { px: number; numberClass: string; labelClass: string } > = { - sm: { px: 96, numberClass: "text-2xl", labelClass: "text-[10px]", barSize: 10 }, - md: { px: 140, numberClass: "text-4xl", labelClass: "text-xs", barSize: 14 }, - lg: { px: 200, numberClass: "text-6xl", labelClass: "text-sm", barSize: 18 }, + sm: { px: 120, numberClass: "text-3xl", labelClass: "text-[11px]" }, + md: { px: 168, numberClass: "text-5xl", labelClass: "text-xs" }, + lg: { px: 232, numberClass: "text-7xl", labelClass: "text-sm" }, }; export interface ScoreRingProps { @@ -71,11 +67,16 @@ export interface ScoreRingProps { /** Disable the sweep + count-up (e.g. when already animated by a parent). */ animate?: boolean; /** - * Arc palette. `"band"` (default) tints the arc by score band over a - * muted track — legible on a plain card. `"onGradient"` paints a white - * arc over a translucent-white track, for the saturated gradient - * wellness tiles. See the component doc for why the band semantic is not - * lost to the white arc. + * Per-metric hue for the single-hue two-stop arc gradient. When omitted the + * gradient falls back to the score's band token (green/yellow/red) — the + * anatomy detail view keeps band semantics that way. + */ + hue?: RingHue; + /** + * 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. */ variant?: "band" | "onGradient"; className?: string; @@ -87,25 +88,30 @@ export function ScoreRing({ label, size = "md", animate = true, - variant = "band", + hue, className, }: ScoreRingProps) { const { t } = useTranslations(); const dims = SIZE[size]; - const onGradient = variant === "onGradient"; + const gid = useId(); const hasScore = score != null && Number.isFinite(score); const clamped = hasScore ? clampScore(score) : 0; const resolvedBand: ScoreBand = band ?? bandForScore(clamped); - const fill = onGradient - ? "#ffffff" - : hasScore - ? BAND_VAR[resolvedBand] - : "var(--muted-foreground)"; - // Count-up only feeds the centred number; the ring arc reads the final - // value (Recharts owns its own sweep via isAnimationActive). - const displayed = useCountUp(clamped, { enabled: animate && hasScore }); + // 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]; + + const reduced = prefersReducedMotion(); + 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 displayed = useCountUp(clamped, { + enabled: animate && hasScore && !reduced, + }); const displayedRounded = Math.round(displayed); const ariaLabel = hasScore @@ -115,20 +121,6 @@ export function ScoreRing({ }) : t("insights.derived.scoreRing.ariaProvisional"); - // The RadialBar's angular sweep is governed by the `` number - // domain [0,100] below — NOT the PolarRadiusAxis (which positions the bar - // radially + anchors the centred number). Without the angle axis Recharts - // auto-scales the angular domain to the single datum, so every ring filled a - // full circle regardless of its score; pinning the domain makes a value of - // 74 sweep 74% of the track (full clockwise circle from 12 o'clock). - const data = [{ name: "score", value: hasScore ? clamped : 0, fill }]; - const startAngle = 90; - const endAngle = -270; - - // Geometry — keep the inner ring thin and the centre clear for the number. - const outerRadius = "100%"; - const innerRadius = "78%"; - return (
- - - {/* Pins the value→angle scale so the bar sweeps `value`% of the - track instead of always filling the full circle. */} - - - - - - + } + /> + + {/* Centred number as real DOM text — easier to style with the design + tokens + tabular-nums, and no SVG-text clipping on a long label. */} +
+ + {hasScore ? displayedRounded : "—"} + + {label ? ( + + {label} + + ) : null} +
{!hasScore && ( ; }) { const Icon = icon; @@ -73,19 +76,17 @@ function RingTile({ href={href} data-slot="wellness-score-tile" data-metric={metricSlot} - // v1.12.8 — the wellness tile adopts the saturated purple→pink - // `.score-tile-gradient` so the dial strip echoes the hero card. The - // gradient is dark in BOTH themes (mixed over the fixed - // `--dracula-bg`, not the theme `--card`), so the white ring + white - // copy clear WCAG AA either way (white on the purple endpoint ≈ 5.0:1, - // on the pink endpoint ≈ 6.1:1). The icon + heading + band word are - // pinned white to match (TileHeader's `text-foreground` would go - // near-black on the Alucard light card and disappear into the - // gradient), so this tile renders its own white header inline rather - // than the shared TileHeader. - className="score-tile-gradient hover:border-white/35 focus-visible:ring-ring flex flex-col gap-3 rounded-xl p-4 transition-colors focus-visible:ring-2 focus-visible:outline-none" + // 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" > -
+
{/* The ring keeps just the number centred; the metric name lives in - the header above (a long localised title would overflow the - small ring's centred SVG text and clip under recharts' - `overflow:hidden`). The white-arc `onGradient` variant reads - cleanly on the gradient; the band stays conveyed by the word - label below + the ring's `data-band` + aria-label. */} - + 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. */} + {bandWord} @@ -176,6 +175,7 @@ export function WellnessScores({ bandWord={bandWord(readiness.value.band)} href="/insights/scores/readiness" metricSlot="READINESS" + hue="readiness" icon={Gauge} />, ); @@ -190,6 +190,7 @@ export function WellnessScores({ bandWord={bandWord(recovery.value.band)} href="/insights/scores/recovery" metricSlot="RECOVERY_SCORE" + hue="recovery" icon={HeartPulse} />, ); @@ -204,6 +205,7 @@ export function WellnessScores({ bandWord={bandWord(sleep.value.band)} href="/insights/scores/sleep" metricSlot="SLEEP_SCORE" + hue="sleep" icon={Moon} />, ); @@ -218,6 +220,7 @@ export function WellnessScores({ bandWord={bandWord(stress.value.band)} href="/insights/scores/stress" metricSlot="STRESS_SCORE" + hue="stress" icon={Activity} />, ); @@ -232,6 +235,7 @@ export function WellnessScores({ bandWord={bandWord(strain.value.band)} href="/insights/scores/strain" metricSlot="STRAIN_SCORE" + hue="strain" icon={Flame} />, );