mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
feat(insights): premium wellness-score tiles + SVG ring redesign
This commit is contained in:
1 parent
874794e0e4
commit
e7d34eb853
12 files changed
+249
-226
No files matched your search
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)",
|
||||
};
|
||||
@@ -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}
|
||||
/>,
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user