mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
a3692a62f8
commit
278007ce36
5 files changed
+109
-20
No files matched your search
@@ -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"');
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user