From 278007ce366af4f318e9221b3bfd1f1592912fcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 14:48:41 +0200 Subject: [PATCH] feat(insights): give wellness dials a white ring on a gradient tile Echo the hero card on the wellness-scores strip: each ring tile now rides a saturated purple-pink gradient (.score-tile-gradient) and the dial paints a white arc over a translucent-white track with rounded ends. The gradient mixes over the fixed dark --dracula-bg rather than the theme --card, so it stays dark in both themes and white copy clears WCAG AA either way (white on the purple endpoint ~5.0:1, pink endpoint ~6.1:1). ScoreRing gains a variant prop: onGradient (white arc, white number) for the tile, band (band-coloured arc) by default so the score-anatomy detail view stays legible on its plain card. The band semantic is never lost to the white arc - it still rides data-band, the aria-label, and the band-word label under the ring. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/globals.css | 24 +++++++++ .../derived/__tests__/score-ring.test.tsx | 11 +++++ .../__tests__/wellness-scores.test.tsx | 11 ++--- .../insights/derived/score-ring.tsx | 49 +++++++++++++++++-- .../insights/derived/wellness-scores.tsx | 34 +++++++++---- 5 files changed, 109 insertions(+), 20 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index a10abb382..7a88a0c7d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -381,6 +381,30 @@ 0 18px 48px -28px color-mix(in srgb, var(--dracula-purple) 50%, transparent); } +/* + * `.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. + * + * 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. + */ +.score-tile-gradient { + 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% + ); + 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); +} + /* * `pulseDot` keyframes — the "live, refreshed N min ago" green dot in * the hero meta row. Used as `.pulse-dot { animation: pulseDot ... }`. diff --git a/src/components/insights/derived/__tests__/score-ring.test.tsx b/src/components/insights/derived/__tests__/score-ring.test.tsx index dffccf055..5afbaa253 100644 --- a/src/components/insights/derived/__tests__/score-ring.test.tsx +++ b/src/components/insights/derived/__tests__/score-ring.test.tsx @@ -52,4 +52,15 @@ describe("", () => { expect(html).toContain('role="img"'); 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 + // data attribute + aria-label for the colour-blind/non-visual read. + 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 54e66eb52..0e3ae67ac 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 a canonical TileHeader icon + heading", () => { + it("renders a ring tile with an icon + heading and a gradient surface", () => { const html = render( ", () => { ); expect(html).toContain('data-slot="wellness-scores"'); expect(html).toContain('data-metric="READINESS"'); - // Each tile leads with the shared TileHeader (icon + heading row), with - // the metric name in the foreground-colour CardTitle. - expect(html).toContain('data-slot="tile-header"'); - expect(html).toContain('data-slot="card-title"'); + // 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). expect(html).toContain('data-band="green"'); - // The band word renders under the ring. + // The band word renders under the ring (semantic kept off the white arc). expect(html).toContain('data-slot="wellness-score-band-word"'); }); diff --git a/src/components/insights/derived/score-ring.tsx b/src/components/insights/derived/score-ring.tsx index ccd2e647c..63b01bd27 100644 --- a/src/components/insights/derived/score-ring.tsx +++ b/src/components/insights/derived/score-ring.tsx @@ -37,6 +37,16 @@ import { * 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. + * + * `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. */ const SIZE: Record< @@ -59,6 +69,14 @@ export interface ScoreRingProps { size?: "sm" | "md" | "lg"; /** 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. + */ + variant?: "band" | "onGradient"; className?: string; } @@ -68,15 +86,21 @@ export function ScoreRing({ label, size = "md", animate = true, + variant = "band", className, }: ScoreRingProps) { const { t } = useTranslations(); const dims = SIZE[size]; + const onGradient = variant === "onGradient"; const hasScore = score != null && Number.isFinite(score); const clamped = hasScore ? clampScore(score) : 0; const resolvedBand: ScoreBand = band ?? bandForScore(clamped); - const fill = hasScore ? BAND_VAR[resolvedBand] : "var(--muted-foreground)"; + 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). @@ -122,7 +146,11 @@ export function ScoreRing({ > {hasScore ? displayedRounded : "—"} @@ -174,7 +210,12 @@ export function ScoreRing({ x={cx} y={cy + (size === "lg" ? 30 : size === "md" ? 22 : 16)} className={cn( - "fill-muted-foreground", + // On the gradient tile a translucent white reads + // cleanly; on a plain card the muted-foreground + // token keeps the sub-label quiet. + onGradient + ? "fill-white/80" + : "fill-muted-foreground", dims.labelClass, )} > diff --git a/src/components/insights/derived/wellness-scores.tsx b/src/components/insights/derived/wellness-scores.tsx index f99f0cf49..53628480d 100644 --- a/src/components/insights/derived/wellness-scores.tsx +++ b/src/components/insights/derived/wellness-scores.tsx @@ -67,27 +67,41 @@ function RingTile({ metricSlot: string; icon: ComponentType<{ className?: string }>; }) { + const Icon = icon; return ( - {/* v1.12.6 — every tile leads with the canonical icon + heading - (foreground colour, matching the Einschätzung reference) so the - wellness strip reads as the same card language as the rest of - Insights. The metric name no longer rides an under-ring caption. */} - +
+
{/* The ring keeps just the number centred; the metric name lives in - the TileHeader above (a long localised title would overflow the + the header above (a long localised title would overflow the small ring's centred SVG text and clip under recharts' - `overflow:hidden`). */} - + `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. */} + {bandWord}