revert(charts): remove gradient background fills (clean lines only)

The Apple-Health-style gradient area underneath each chart line muddied
the visual. Drop the linearGradient defs and the area-fill primitives on
every chart wrapper and let the line stroke speak for itself. Tooltip,
animation, and Dracula tokens stay as they were.

Also delete the chart-gradient module — there are no remaining call
sites once the fills come out.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 10:19:46 +02:00
1 parent 874878fdc3
commit 008a8fba3e
8 files changed
+38 -381

No files matched your search

@@ -1,67 +0,0 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import {
ChartLinearGradient,
chartGradientFill,
} from "../chart-gradient";
/**
* v1.4.16 B1a — pin the contract for the gradient primitive that every
* chart wrapper now consumes:
*
* - emits an SVG `<defs><linearGradient>` block with the requested id,
* - top stop carries the metric colour at ~35 % alpha by default,
* - bottom stop fades to 0 %,
* - the helper `chartGradientFill()` builds the matching `url(#id)`
* reference Recharts expects on `<Area fill=...>`.
*/
describe("<ChartLinearGradient>", () => {
it("renders a vertical linearGradient with the supplied id and colour token", () => {
// Wrap in <svg> because <defs> outside an SVG triggers a React
// hydration warning under jsdom; here we render to static markup so
// the wrapper just keeps the output well-formed.
const html = renderToStaticMarkup(
<svg>
<ChartLinearGradient id="bp-gradient" colorVar="--dracula-purple" />
</svg>,
);
expect(html).toContain('id="bp-gradient"');
// Vertical gradient — top to bottom.
expect(html).toContain('x1="0"');
expect(html).toContain('y1="0"');
expect(html).toContain('y2="1"');
// The colour token round-trips into a `var(...)` reference.
expect(html).toContain("var(--dracula-purple)");
// Default opacity: 0.35 at top, 0 at bottom.
expect(html).toContain('stop-opacity="0.35"');
expect(html).toContain('stop-opacity="0"');
// Marker used by visual-regression / e2e smoke tests.
expect(html).toContain('data-slot="chart-linear-gradient"');
expect(html).toContain('data-color-var="--dracula-purple"');
});
it("respects custom topOpacity / bottomOpacity overrides", () => {
const html = renderToStaticMarkup(
<svg>
<ChartLinearGradient
id="weight-gradient"
colorVar="--dracula-cyan"
topOpacity={0.5}
bottomOpacity={0.05}
/>
</svg>,
);
expect(html).toContain('stop-opacity="0.5"');
expect(html).toContain('stop-opacity="0.05"');
});
});
describe("chartGradientFill()", () => {
it("builds a Recharts-shaped url() reference", () => {
expect(chartGradientFill("bp-gradient")).toBe("url(#bp-gradient)");
expect(chartGradientFill("mood")).toBe("url(#mood)");
});
});
@@ -112,15 +112,13 @@ describe("computePersonalBaseline()", () => {
});
});
describe("<HealthChart> v1.4.16 B1a polish", () => {
it("emits a sibling-SVG <defs> gradient block addressable by Recharts via url(#id)", async () => {
// ResponsiveContainer renders empty during SSR (its dimensions
// depend on the live DOM), so Recharts' own SSR output ships no
// <defs>. To keep the gradient SSR-discoverable (and so visual
// regressions can target it without a browser), the wrapper emits
// an external `<svg width=0 height=0>` next to ResponsiveContainer
// containing the gradient. Recharts picks it up via `url(#id)`
// because SVG IDs are document-scoped.
describe("<HealthChart> v1.4.18 clean-line revert", () => {
it("does NOT paint a gradient fill under the line (clean line only)", async () => {
// v1.4.18 reverts B1a's gradient-area treatment. Marc explicitly
// rejected the soft-color fill below the line: the line itself is
// the chart, no painted background under it. We assert the SSR
// output ships no `<linearGradient>` defs and no `chart-gradient`
// primitive markers.
const { I18nProvider } = await import("@/lib/i18n/context");
const { HealthChart } = await import("../health-chart");
@@ -134,22 +132,8 @@ describe("<HealthChart> v1.4.16 B1a polish", () => {
</I18nProvider>,
);
expect(html).toContain('data-slot="chart-linear-gradient"');
});
it("namespaces the gradient id by metric type so multi-metric charts don't collide", async () => {
const { I18nProvider } = await import("@/lib/i18n/context");
const { HealthChart } = await import("../health-chart");
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<HealthChart
types={["BLOOD_PRESSURE_SYS"]}
title="Blood Pressure"
unit="mmHg"
/>
</I18nProvider>,
);
expect(html).toMatch(/id="chart-gradient-BLOOD_PRESSURE_SYS"/);
expect(html).not.toContain("data-slot=\"chart-linear-gradient\"");
expect(html).not.toContain("chart-gradient-BLOOD_PRESSURE_SYS");
expect(html).not.toContain("linearGradient");
});
});
@@ -2,12 +2,11 @@ import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* v1.4.16 B1a — Medication compliance chart polish contract.
* v1.4.18 — Medication compliance chart clean-line revert.
*
* The medication chart already has 7-day-trend + target lines from A6;
* this commit adds the rest of the v1.4.16 visual leap so the surface
* matches BP/weight/pulse/mood: gradient fill, animated first render,
* rich tooltip primitive.
* Rolls back B1a's gradient fill. The animation, target / threshold
* lines, rich tooltip, and 7-day trend chip stay (those weren't
* rejected).
*/
const sampleData = vi.hoisted(() =>
@@ -26,8 +25,8 @@ vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ isAuthenticated: true, user: null, isLoading: false }),
}));
describe("<MedicationComplianceChart> v1.4.16 B1a polish", () => {
it("emits the gradient defs sibling-SVG block", async () => {
describe("<MedicationComplianceChart> v1.4.18 clean-line revert", () => {
it("does NOT paint a gradient fill under the line", async () => {
const { I18nProvider } = await import("@/lib/i18n/context");
const { MedicationComplianceChart } = await import(
"../medication-compliance-chart"
@@ -39,7 +38,8 @@ describe("<MedicationComplianceChart> v1.4.16 B1a polish", () => {
</I18nProvider>,
);
expect(html).toContain('data-slot="chart-linear-gradient"');
expect(html).toContain('id="chart-gradient-medication"');
expect(html).not.toContain("data-slot=\"chart-linear-gradient\"");
expect(html).not.toContain("chart-gradient-medication");
expect(html).not.toContain("linearGradient");
});
});
@@ -2,14 +2,12 @@ import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* v1.4.16 B1a — MoodChart Apple-Health-style polish contract.
* v1.4.18 — MoodChart clean-line revert (gradient + emoji rolled back).
*
* The mood chart picks up the same gradient-fill + animation polish
* the rest of the dashboard charts get; the differentiator is per-
* point emoji glyphs (a smiley, frown, etc.) instead of plain dots so
* the user can scan a week at a glance — Apple's Health app uses the
* same affordance for "Mindfulness Minutes" and similar mood-adjacent
* surfaces.
* Marc rejected B1a's coloured area fill below the chart line and the
* smiley/emoji glyphs at every data point. The line stroke + Dracula
* tokens stay; the chart now paints a clean monotone line with simple
* Recharts dots.
*/
const sampleMoodData = vi.hoisted(() => {
@@ -43,8 +41,8 @@ vi.mock("@/hooks/use-auth", () => ({
}),
}));
describe("<MoodChart> v1.4.16 B1a polish", () => {
it("emits the gradient defs sibling-SVG block", async () => {
describe("<MoodChart> v1.4.18 clean-line revert", () => {
it("does NOT paint a gradient fill under the line", async () => {
const { I18nProvider } = await import("@/lib/i18n/context");
const { MoodChart } = await import("../mood-chart");
@@ -54,7 +52,8 @@ describe("<MoodChart> v1.4.16 B1a polish", () => {
</I18nProvider>,
);
expect(html).toContain('data-slot="chart-linear-gradient"');
expect(html).toContain('id="chart-gradient-mood"');
expect(html).not.toContain("data-slot=\"chart-linear-gradient\"");
expect(html).not.toContain("chart-gradient-mood");
expect(html).not.toContain("linearGradient");
});
});
-87
View File
@@ -1,87 +0,0 @@
"use client";
import type { ReactElement } from "react";
/**
* Reusable Apple-Health-style linear gradient for area / line fills.
*
* Recharts ships an Area component that accepts a `fill="url(#id)"` prop
* pointing at a `<linearGradient>` defined inside an SVG `<defs>` block.
* v1.4.16 B1a paints a soft Dracula-token gradient under the line so the
* chart reads like the Apple Health "Trends" cards: the metric colour at
* the top of the area fades down to nearly transparent at the bottom.
*
* Why a wrapper component:
* 1. The gradient lives in `<defs>` and must be sibling-rendered to
* `<Area fill="url(#id)" />`. Recharts allows arbitrary children in
* `LineChart` / `AreaChart` and renders them inside its `<svg>`, so
* a small named primitive keeps the call sites readable
* (`<ChartLinearGradient id="bp" colorVar="--dracula-purple" />`)
* instead of inlining 8 lines of `<defs><linearGradient>...` per
* chart.
* 2. Tokens stay canonical — only the `colorVar` prop is exposed; the
* stop offsets / opacities are baked in to enforce a consistent
* look across all 5 charts (BP, weight, pulse, mood, medication).
* 3. SSR safe — pure SVG, no DOM APIs touched. The unit test renders
* it via `renderToStaticMarkup`.
*
* Defaults follow the Apple Health pattern: ~35 % alpha at the top of
* the area, 0 % at the bottom. The pinned stops also stay clear of the
* grid lines so a domain like "60 .. 110 mmHg" still reads as a
* gradient, not a flat colour wash.
*/
export interface ChartLinearGradientProps {
/** SVG id used by Recharts: `<Area fill="url(#${id})" />`. */
id: string;
/**
* CSS variable name (e.g. `--dracula-purple`). The component wraps it
* in `var(...)` so callers can pass the token verbatim.
*/
colorVar: string;
/** Alpha at the top of the area. Defaults to 0.35. */
topOpacity?: number;
/** Alpha at the bottom of the area. Defaults to 0.0. */
bottomOpacity?: number;
}
/**
* Renders a `<defs><linearGradient .../></defs>` block.
*
* The gradient runs vertically (x1=0,x2=0,y1=0,y2=1) so it shades the
* area top-down, matching how Apple Health paints the metric colour
* over the chart background.
*/
export function ChartLinearGradient({
id,
colorVar,
topOpacity = 0.35,
bottomOpacity = 0,
}: ChartLinearGradientProps): ReactElement {
const fill = `var(${colorVar})`;
return (
<defs>
<linearGradient
id={id}
x1="0"
y1="0"
x2="0"
y2="1"
data-slot="chart-linear-gradient"
data-color-var={colorVar}
>
<stop offset="0%" stopColor={fill} stopOpacity={topOpacity} />
<stop offset="100%" stopColor={fill} stopOpacity={bottomOpacity} />
</linearGradient>
</defs>
);
}
/**
* Build the `fill` attribute string Recharts expects when wiring an
* `<Area>` to a `<ChartLinearGradient id="x">` defined alongside.
*/
export function chartGradientFill(id: string): string {
return `url(#${id})`;
}
+5 -86
View File
@@ -5,7 +5,6 @@ import { useAuth } from "@/hooks/use-auth";
import {
ResponsiveContainer,
ComposedChart,
Area,
Line,
XAxis,
YAxis,
@@ -17,10 +16,6 @@ import {
} from "recharts";
import { Loader2 } from "lucide-react";
import { useState, useMemo, useId } from "react";
import {
ChartLinearGradient,
chartGradientFill,
} from "./chart-gradient";
import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip";
import { ChartEmptyState } from "./chart-empty-state";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
@@ -987,73 +982,15 @@ export function HealthChart({
</div>
) : null}
{/* v1.4.16 B1a — sibling SVG <defs> block.
ResponsiveContainer is empty during SSR so a gradient
defined inside its child <svg> isn't discoverable in
static-rendered markup. Emitting the gradient in a
standalone hidden <svg> next to the chart keeps it
available document-wide; Recharts' <Area fill="url(#id)">
looks the gradient up by id at paint time and finds it
regardless of which SVG it lives in. */}
<svg
width={0}
height={0}
aria-hidden="true"
style={{ position: "absolute", pointerEvents: "none" }}
data-slot="chart-gradient-defs"
>
{types.map((type, i) => (
<ChartLinearGradient
key={`grad-${type}`}
id={`chart-gradient-${type}`}
colorVar={
// Best-effort mapping from the dot colour back to a
// Dracula token — the chart already accepts an
// arbitrary `colors` array (defaults to purple/pink/
// cyan), so we mirror that here.
colors[i % colors.length]?.startsWith("var(")
? colors[i % colors.length]
.replace("var(", "")
.replace(")", "")
: "--dracula-purple"
}
/>
))}
</svg>
{/* v1.4.18 — gradient fill removed per Marc's feedback.
The clean line is the chart; no painted background under
it. */}
<div className="relative z-10 h-full touch-pan-y">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={chartDataWithCompare ?? chartData}
margin={{ top: 10, right: 8, bottom: 8, left: 8 }}
>
{/* Inline gradient defs as Recharts children — same id
space as the sibling SVG above, so even browsers
that scope SVG-id resolution to the local document
fragment (Safari edge cases) still find a match. */}
<defs>
{types.map((type, i) => (
<linearGradient
key={`inline-grad-${type}`}
id={`chart-gradient-inline-${type}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor={colors[i % colors.length]}
stopOpacity={0.35}
/>
<stop
offset="100%"
stopColor={colors[i % colors.length]}
stopOpacity={0}
/>
</linearGradient>
))}
</defs>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
@@ -1323,26 +1260,8 @@ export function HealthChart({
}}
/>
)}
{/* v1.4.16 B1a — gradient-filled Area painted *under*
the line. Each type gets its own gradient
referenced by id. The Area's stroke is set to
transparent so only the fill paints; the line on
top of it carries the actual stroke. */}
{types.map((type) => (
<Area
key={`area-${type}`}
type="monotone"
dataKey={type}
stroke="transparent"
fill={chartGradientFill(`chart-gradient-inline-${type}`)}
fillOpacity={1}
isAnimationActive={animationsEnabled}
animationDuration={animationsEnabled ? 600 : 0}
animationEasing="ease-out"
connectNulls
legendType="none"
/>
))}
{/* v1.4.18 — gradient Area removed; only the clean
line below paints the metric. */}
{types.map((type, i) => (
<Line
key={type}
@@ -37,7 +37,6 @@ import { useMemo, useState } from "react";
import {
ResponsiveContainer,
ComposedChart,
Area,
Line,
XAxis,
YAxis,
@@ -51,7 +50,6 @@ import { useAuth } from "@/hooks/use-auth";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { formatDateShort } from "@/lib/format";
import { cn } from "@/lib/utils";
import { ChartLinearGradient, chartGradientFill } from "./chart-gradient";
import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip";
import { ChartEmptyState } from "./chart-empty-state";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
@@ -294,21 +292,7 @@ export function MedicationComplianceChart({
</p>
)}
{/* v1.4.16 B1a — sibling SVG <defs> block for SSR-discoverable
gradient. */}
<svg
width={0}
height={0}
aria-hidden="true"
style={{ position: "absolute", pointerEvents: "none" }}
data-slot="chart-gradient-defs"
>
<ChartLinearGradient
id="chart-gradient-medication"
colorVar="--dracula-purple"
/>
</svg>
{/* v1.4.18 — gradient defs removed; clean line only. */}
{isLoading ? (
<div className="flex h-48 items-center justify-center">
<Loader2 className="text-primary h-6 w-6 animate-spin" />
@@ -331,18 +315,6 @@ export function MedicationComplianceChart({
data={chartData}
margin={{ top: 10, right: 8, bottom: 8, left: 8 }}
>
<defs>
<linearGradient
id="chart-gradient-medication-inline"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop offset="0%" stopColor={COLOR_LINE} stopOpacity={0.35} />
<stop offset="100%" stopColor={COLOR_LINE} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
@@ -430,21 +402,7 @@ export function MedicationComplianceChart({
strokeOpacity={0.85}
data-slot="medication-goal-line"
/>
{/* v1.4.16 B1a — gradient-filled Area painted under the
line. Animated on first render unless the user opts
for reduced motion. */}
<Area
type="monotone"
dataKey="rate"
stroke="transparent"
fill={chartGradientFill("chart-gradient-medication-inline")}
fillOpacity={1}
isAnimationActive={animationsEnabled}
animationDuration={animationsEnabled ? 600 : 0}
animationEasing="ease-out"
connectNulls
legendType="none"
/>
{/* v1.4.18 — gradient Area removed; clean line only. */}
<Line
type="monotone"
dataKey="rate"
+2 -51
View File
@@ -5,7 +5,6 @@ import { useAuth } from "@/hooks/use-auth";
import {
ResponsiveContainer,
ComposedChart,
Area,
Line,
XAxis,
YAxis,
@@ -28,7 +27,6 @@ import {
pickBucket,
type ChartBucketType,
} from "@/lib/charts/bucket-time-series";
import { ChartLinearGradient, chartGradientFill } from "./chart-gradient";
import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip";
import { ChartEmptyState } from "./chart-empty-state";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
@@ -603,21 +601,7 @@ export function MoodChart({
)}
</CardHeader>
<CardContent>
{/* v1.4.16 B1a — sibling SVG <defs> block for the gradient
primitive. SSR-discoverable; Recharts also picks up the id
client-side via the inline <defs> below. */}
<svg
width={0}
height={0}
aria-hidden="true"
style={{ position: "absolute", pointerEvents: "none" }}
data-slot="chart-gradient-defs"
>
<ChartLinearGradient
id="chart-gradient-mood"
colorVar="--dracula-lavender"
/>
</svg>
{/* v1.4.18 — gradient defs removed; clean line only. */}
{isLoading ? (
<div className="flex h-48 items-center justify-center">
<Loader2 className="text-primary h-6 w-6 animate-spin" />
@@ -641,26 +625,6 @@ export function MoodChart({
data={chartDataWithCompare ?? chartData}
margin={{ top: 10, right: 8, bottom: 8, left: 8 }}
>
<defs>
<linearGradient
id="chart-gradient-mood-inline"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor={COLOR_MAIN}
stopOpacity={0.4}
/>
<stop
offset="100%"
stopColor={COLOR_MAIN}
stopOpacity={0}
/>
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
stroke="hsl(var(--border))"
@@ -841,20 +805,7 @@ export function MoodChart({
);
}}
/>
{/* v1.4.16 B1a — gradient-filled Area painted under
the line. */}
<Area
type="monotone"
dataKey="score"
stroke="transparent"
fill={chartGradientFill("chart-gradient-mood-inline")}
fillOpacity={1}
isAnimationActive={animationsEnabled}
animationDuration={animationsEnabled ? 600 : 0}
animationEasing="ease-out"
connectNulls
legendType="none"
/>
{/* v1.4.18 — gradient Area removed; clean line only. */}
<Line
type="monotone"
dataKey="score"