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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-05 14:49:12 +02:00
1 parent a3692a62f8
commit 278007ce36
5 files changed
+109 -20

No files matched your search

+24
View File
@@ -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 ... }`.
@@ -52,4 +52,15 @@ describe("<ScoreRing>", () => {
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(
<ScoreRing score={82} variant="onGradient" label="/100" />,
);
expect(html).toContain('data-band="green"');
expect(html).toContain('role="img"');
expect(html).toContain("aria-label");
});
});
@@ -66,7 +66,7 @@ describe("<WellnessScores>", () => {
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(
<WellnessScores
read={readFrom({ READINESS: ok({ score: 82, band: "green" }) })}
@@ -75,14 +75,13 @@ describe("<WellnessScores>", () => {
);
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"');
});
+45 -4
View File
@@ -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({
>
<RadialBar
dataKey="value"
background={{ fill: "var(--muted)", opacity: 0.4 }}
background={
onGradient
? { fill: "#ffffff", opacity: 0.18 }
: { fill: "var(--muted)", opacity: 0.4 }
}
cornerRadius={dims.barSize}
isAnimationActive={animate && hasScore}
animationDuration={600}
@@ -164,7 +192,15 @@ export function ScoreRing({
// (`fill` = BAND_VAR) + the `data-band` attribute +
// the aria-label, so dropping it from the number
// costs no information while clearing AA.
hasScore ? "fill-foreground" : "fill-muted-foreground",
// On the saturated gradient tile the number is
// pinned white in both themes (the dark gradient
// clears AA either way); `fill-foreground` would go
// near-black on the Alucard light card and vanish.
onGradient
? "fill-white"
: hasScore
? "fill-foreground"
: "fill-muted-foreground",
)}
>
{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,
)}
>
@@ -67,27 +67,41 @@ function RingTile({
metricSlot: string;
icon: ComponentType<{ className?: string }>;
}) {
const Icon = icon;
return (
<Link
href={href}
data-slot="wellness-score-tile"
data-metric={metricSlot}
className="bg-card border-border hover:border-foreground/20 focus-visible:ring-ring flex flex-col gap-3 rounded-xl border p-4 transition-colors focus-visible:ring-2 focus-visible:outline-none"
// 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.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. */}
<TileHeader icon={icon} title={label} titleClassName="truncate" />
<div className="flex items-center gap-2 text-white">
<Icon className="h-5 w-5 shrink-0" aria-hidden="true" />
<span className="truncate text-base leading-none font-semibold">
{label}
</span>
</div>
<div className="flex flex-col items-center gap-1.5">
{/* 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`). */}
<ScoreRing score={score} band={band} size="sm" />
`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. */}
<ScoreRing score={score} band={band} size="sm" variant="onGradient" />
<span
data-slot="wellness-score-band-word"
className="text-muted-foreground text-center text-xs"
className="text-center text-xs text-white/80"
>
{bandWord}
</span>