feat(insights): premium wellness-score tiles + SVG ring redesign

This commit is contained in:
Marc-André Bombeck committed 2026-06-05 22:09:06 +02:00
1 parent 874794e0e4
commit e7d34eb853
12 files changed
+249 -226

No files matched your search

+1 -1
View File
@@ -2782,7 +2782,7 @@
}
},
"scores": {
"sectionTitle": "Wellness-Werte",
"sectionTitle": "Deine Gesundheitswerte",
"loadError": "Deine Wellness-Werte konnten gerade nicht geladen werden.",
"recovery": "Erholung",
"stress": "Stress",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+23 -16
View File
@@ -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);
}
/*
@@ -53,12 +53,10 @@ describe("<ScoreRing>", () => {
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(
<ScoreRing score={82} variant="onGradient" label="/100" />,
);
const html = render(<ScoreRing score={82} hue="readiness" 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 an icon + heading and a gradient surface", () => {
it("renders a ring tile with an icon + heading on the gentle wellness surface", () => {
const html = render(
<WellnessScores
read={readFrom({ READINESS: ok({ score: 82, band: "green" }) })}
@@ -74,14 +74,14 @@ describe("<WellnessScores>", () => {
/>,
);
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"');
});
@@ -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 `<ScoreRing>`. */
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 `<linearGradient>` stops.
*/
export const RING_GRADIENT: Record<RingHue | ScoreBand, [string, string]> = {
// 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<RingHue, string> = {
readiness: "var(--success)",
sleep: "var(--info)",
recovery: "var(--dracula-cyan)",
stress: "var(--warning)",
strain: "var(--dracula-cyan)",
};
+125 -173
View File
@@ -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 `<circle stroke-dasharray>` swept by `stroke-dashoffset`,
* filled with a JSX `<linearGradient>` (NOT a `dangerouslySetInnerHTML` —
* `<defs>`/`<linearGradient>` are real SVG JSX). The `<svg>` 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 `<text>`), 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<ScoreRingProps["size"]>,
{ 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 `<PolarAngleAxis>` 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 (
<div
data-slot="score-ring"
@@ -139,108 +131,68 @@ export function ScoreRing({
className={cn("relative shrink-0", className)}
style={{ width: dims.px, height: dims.px }}
>
<ResponsiveContainer width="100%" height="100%">
<RadialBarChart
data={data}
startAngle={startAngle}
endAngle={endAngle}
innerRadius={innerRadius}
outerRadius={outerRadius}
barSize={dims.barSize}
>
{/* Pins the value→angle scale so the bar sweeps `value`% of the
track instead of always filling the full circle. */}
<PolarAngleAxis
type="number"
domain={[0, 100]}
tick={false}
axisLine={false}
/>
<RadialBar
dataKey="value"
background={
onGradient
? { fill: "#ffffff", opacity: 0.18 }
: { fill: "var(--muted)", opacity: 0.4 }
}
cornerRadius={dims.barSize}
isAnimationActive={animate && hasScore}
animationDuration={600}
aria-hidden
/>
<PolarRadiusAxis
type="number"
domain={[0, 100]}
tick={false}
tickLine={false}
axisLine={false}
>
<Label
content={({ viewBox }) => {
if (!viewBox || !("cx" in viewBox) || !("cy" in viewBox)) {
return null;
<svg
viewBox={`0 0 ${VIEW} ${VIEW}`}
className="h-full w-full -rotate-90"
aria-hidden="true"
>
<defs>
{/* Single-hue two-stop gradient — JSX SVG, not dangerouslySetInnerHTML. */}
<linearGradient id={gid} x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor={from} />
<stop offset="100%" stopColor={to} />
</linearGradient>
</defs>
{/* Faint same-hue track — 12% of the ring colour, never a bright
competing ring. */}
<circle
cx={CX}
cy={CX}
r={R}
fill="none"
stroke={`url(#${gid})`}
strokeOpacity={0.12}
strokeWidth={STROKE}
/>
{/* The progress arc — single-hue gradient, round cap. */}
<circle
cx={CX}
cy={CX}
r={R}
fill="none"
stroke={`url(#${gid})`}
strokeWidth={STROKE}
strokeLinecap="round"
strokeDasharray={C}
style={
reduced
? { strokeDashoffset: C - dash }
: {
strokeDashoffset: C - dash,
transition:
"stroke-dashoffset 700ms cubic-bezier(.22,.61,.36,1)",
}
const cx = viewBox.cx ?? 0;
const cy = viewBox.cy ?? 0;
return (
<text
x={cx}
y={cy}
textAnchor="middle"
dominantBaseline="middle"
>
<tspan
x={cx}
y={cy}
className={cn(
"font-semibold tabular-nums",
dims.numberClass,
// v1.12.6 — the centred number reads in the
// foreground colour (white on the dark wellness
// card, near-black on the light card), never the
// band tint. The band-coloured number cleared only
// ~1.5:1 on `--card` and read as illegible. The
// band semantic is carried by the ring arc
// (`fill` = BAND_VAR) + the `data-band` attribute +
// the aria-label, so dropping it from the number
// costs no information while clearing AA.
// 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 : "—"}
</tspan>
{label ? (
<tspan
x={cx}
y={cy + (size === "lg" ? 30 : size === "md" ? 22 : 16)}
className={cn(
// 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,
)}
>
{label}
</tspan>
) : null}
</text>
);
}}
/>
</PolarRadiusAxis>
</RadialBarChart>
</ResponsiveContainer>
}
/>
</svg>
{/* Centred number as real DOM text — easier to style with the design
tokens + tabular-nums, and no SVG-text clipping on a long label. */}
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
<span
className={cn(
"font-semibold tracking-tight tabular-nums",
dims.numberClass,
hasScore ? "text-foreground" : "text-muted-foreground",
)}
>
{hasScore ? displayedRounded : "—"}
</span>
{label ? (
<span className={cn("text-muted-foreground", dims.labelClass)}>
{label}
</span>
) : null}
</div>
{!hasScore && (
<span
data-slot="score-ring-provisional"
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { TileHeader } from "@/components/insights/tile-header";
import { ScoreRing } from "./score-ring";
import { TILE_HUE, type RingHue } from "./ring-hues";
import type { DerivedBatchRead } from "./use-derived-metric";
import type { ScoreBand } from "./band-tokens";
// Type-only — the compute payloads never drag the server graph into the bundle.
@@ -57,6 +58,7 @@ function RingTile({
bandWord,
href,
metricSlot,
hue,
icon,
}: {
score: number;
@@ -65,6 +67,7 @@ function RingTile({
bandWord: string;
href: string;
metricSlot: string;
hue: RingHue;
icon: ComponentType<{ className?: string }>;
}) {
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"
>
<div className="flex items-center gap-2 text-white">
<div className="text-foreground flex items-center gap-2">
<Icon className="h-5 w-5 shrink-0" aria-hidden="true" />
<span className="truncate text-base leading-none font-semibold">
{label}
@@ -93,15 +94,13 @@ function RingTile({
</div>
<div className="flex flex-col items-center gap-1.5">
{/* 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. */}
<ScoreRing score={score} band={band} size="sm" variant="onGradient" />
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. */}
<ScoreRing score={score} band={band} size="sm" hue={hue} />
<span
data-slot="wellness-score-band-word"
className="text-center text-xs text-white/80"
className="text-muted-foreground text-center text-xs"
>
{bandWord}
</span>
@@ -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}
/>,
);