From 307d278293c8fcef7b4b9aeaf25bd194b31f9ed3 Mon Sep 17 00:00:00 2001 From: aurph Date: Mon, 3 Aug 2026 21:03:19 -0400 Subject: [PATCH] Charts: honour prefers-reduced-motion across every Recharts series Recharts animates every series by default and never consults the user's motion preference, so all 27 series on the site grew from zero regardless of what the visitor asked their OS for. The same default has a second effect that is easy to miss. Recharts builds series geometry inside react-smooth, which only emits the actual on its first requestAnimationFrame tick. A chart that never receives a frame renders structurally complete but visually empty: the groups are all there with nothing inside them. Nothing errors and nothing logs. That is what made these charts impossible to verify in a headless browser, and it is the same failure any visitor hits if rAF is throttled. - chart-theme.ts exports prefersReducedMotion and seriesAnimation, matching the existing axisProps/gridProps convention of a plain spreadable const. - Every Recharts , , , and spreads it: 27 series across 9 files. - @visx series are deliberately excluded. PriceHistoryChart's comes from @visx/shape, which is a pure path generator with no animation and no such prop; passing it would be meaningless. The guard test walks client/src and fails if any Recharts series is added without the prop, because the failure mode is a silently blank chart rather than an error. Verified with Chrome --force-prefers-reduced-motion: the Compute Frontier operator bars now render real geometry, and their widths match the API to within rounding (132/88/83/71 px against 16245/11043/10460/8902 MW, ratios 1.00/0.67/0.63/0.54 vs 1.00/0.68/0.64/0.55). 303 tests pass, tsc clean, client build passes. --- .gitignore | 1 + client/src/components/home/DemandChart.tsx | 9 ++- client/src/lib/__tests__/chart-motion.test.ts | 73 +++++++++++++++++++ client/src/lib/chart-theme.ts | 28 +++++++ client/src/pages/PortfolioOverlay.tsx | 3 +- client/src/pages/StockPage.tsx | 4 +- client/src/pages/TheTrade.tsx | 16 ++-- client/src/pages/TiltOverview.tsx | 14 ++-- client/src/pages/compute-frontier.tsx | 12 +-- client/src/pages/gpu-economics.tsx | 9 ++- client/src/pages/my-grid.tsx | 4 +- client/src/pages/power-deals.tsx | 4 +- 12 files changed, 138 insertions(+), 39 deletions(-) create mode 100644 client/src/lib/__tests__/chart-motion.test.ts diff --git a/.gitignore b/.gitignore index 70e48a1..d0b3597 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,4 @@ attached_assets/Pasted-* /tmp/ coverage/ .nyc_output/ +.card-preview/ diff --git a/client/src/components/home/DemandChart.tsx b/client/src/components/home/DemandChart.tsx index 621ae25..a609fc1 100644 --- a/client/src/components/home/DemandChart.tsx +++ b/client/src/components/home/DemandChart.tsx @@ -9,6 +9,7 @@ import { ReferenceLine, } from "recharts"; import { electricityData, demandAnnotations } from "@/data/electricity-demand"; +import { seriesAnimation } from "@/lib/chart-theme"; const HORIZ_PAD = "clamp(24px, 5vw, 96px)"; const DATA = "#F07800"; @@ -116,10 +117,10 @@ export function DemandChart() { }} /> - - - - + + + + { + const src = readFileSync(CHART_THEME, "utf-8"); + assert.match(src, /isAnimationActive:\s*!prefersReducedMotion/, "seriesAnimation must invert the media query"); + assert.match(src, /prefers-reduced-motion:\s*reduce/, "must query the reduce preference"); + // Node has no window; the guard is what keeps this importable outside a browser. + assert.match(src, /typeof window !== "undefined"/, "must guard window access"); +}); + +// In a Node test there is no window, so the module-load evaluation must land on +// "no preference" and leave animation enabled rather than throwing. +test("chart-theme imports cleanly without a DOM and defaults to animation on", async () => { + const mod = await import("../chart-theme"); + assert.equal(mod.prefersReducedMotion, false, "no window means no stated preference"); + assert.equal(mod.seriesAnimation.isAnimationActive, true); +}); + +/** + * The real regression risk is a new chart being added without the prop. Recharts + * fails silently here: an animated series that never receives a frame renders an + * empty with no path inside, so the chart just + * looks blank instead of erroring. Walk the source and require every animated + * series to opt in. + */ +test("every animated Recharts series spreads seriesAnimation", () => { + const SERIES = ["Bar", "Line", "Area", "Pie", "Radar"]; + const files: string[] = []; + const walk = (dir: string) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name !== "node_modules" && entry.name !== "__tests__") walk(p); + } else if (entry.name.endsWith(".tsx")) { + files.push(p); + } + } + }; + walk(join(ROOT, "client", "src")); + + const offenders: string[] = []; + for (const file of files) { + const src = readFileSync(file, "utf-8"); + // Only Recharts series take isAnimationActive. @visx exports a too, + // but it is a pure path generator with no animation and no such prop. + if (!/from "recharts"/.test(src)) continue; + for (const tag of SERIES) { + // Opening tags of the series itself, never //etc. + const re = new RegExp(`<${tag}(?![A-Za-z])[\\s/>][^>]*`, "g"); + for (const m of src.match(re) ?? []) { + if (!m.includes("...seriesAnimation")) { + offenders.push(`${file.replace(ROOT + "/", "")}: <${tag} ...`); + } + } + } + } + + assert.deepEqual( + offenders, + [], + `these series animate regardless of prefers-reduced-motion; spread {...seriesAnimation}:\n${offenders.join("\n")}`, + ); +}); diff --git a/client/src/lib/chart-theme.ts b/client/src/lib/chart-theme.ts index 04c0cbb..ab177ab 100644 --- a/client/src/lib/chart-theme.ts +++ b/client/src/lib/chart-theme.ts @@ -61,6 +61,34 @@ export const axisProps = { axisLine: { stroke: chartTheme.axis.stroke }, }; +/** + * True when the visitor has asked their OS to reduce motion. + * + * Evaluated once at module load, matching how axisProps/gridProps are used. + * The app is a client-rendered SPA, so there is no SSR window to guard beyond + * the typeof check, and nobody toggles this setting mid-session. + */ +export const prefersReducedMotion: boolean = + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + +/** + * Spread into any animated Recharts series: . + * + * Recharts animates every series by default and does not consult + * prefers-reduced-motion on its own, so charts used to grow from zero no + * matter what the visitor asked for. It also means the series geometry only + * exists after react-smooth's first rAF tick: with animation on, a chart that + * never gets a frame renders empty groups + * with no path inside. Turning animation off makes the chart render its final + * geometry synchronously, which is both what reduced-motion users want and + * what makes these charts screenshot-testable. + */ +export const seriesAnimation = { + isAnimationActive: !prefersReducedMotion, +}; + /** Spread into Recharts : {...gridProps} */ export const gridProps = { stroke: chartTheme.grid.stroke, diff --git a/client/src/pages/PortfolioOverlay.tsx b/client/src/pages/PortfolioOverlay.tsx index 31598e3..2743167 100644 --- a/client/src/pages/PortfolioOverlay.tsx +++ b/client/src/pages/PortfolioOverlay.tsx @@ -24,6 +24,7 @@ import { apiRequest } from "@/lib/queryClient"; import { Info, BarChart3, Search, Loader2, AlertCircle, Plus, Share2, Check } from "lucide-react"; import { useToast } from "@/hooks/use-toast"; import { BORDER, BRAND, CATEGORY_COLORS, CHART_CHROME, FONT, INK, SEMANTIC, SERIES } from "@/lib/tokens"; +import { seriesAnimation } from "@/lib/chart-theme"; interface PortfolioResult { ticker: string; @@ -463,7 +464,7 @@ export default function PortfolioOverlay({ embedded = false }: { embedded?: bool tickCount={4} /> } /> - [`$${val.toFixed(2)}`, "Price"]} /> - + diff --git a/client/src/pages/TheTrade.tsx b/client/src/pages/TheTrade.tsx index f1e52d0..8c620d4 100644 --- a/client/src/pages/TheTrade.tsx +++ b/client/src/pages/TheTrade.tsx @@ -32,13 +32,7 @@ import { } from "lucide-react"; import { SrChartTable } from "@/components/Freshness"; import { BORDER, CATEGORY_COLORS, SERIES } from "@/lib/tokens"; -import { - axisProps, - gridProps, - tooltipContentStyle, - tooltipItemStyle, - tooltipLabelStyle, -} from "@/lib/chart-theme"; +import { axisProps, gridProps, tooltipContentStyle, tooltipItemStyle, tooltipLabelStyle, seriesAnimation } from "@/lib/chart-theme"; const BASE_POWER_TWH = 4490; const BASE_YEAR = 2025; @@ -575,10 +569,10 @@ export default function TheTrade({ embedded = false }: { embedded?: boolean; par ); }} /> - - - - + + + + - } /> - - - - - truncateLabel(v, 16)} /> [`${v.toLocaleString()} MW`, `${p.payload.count} clusters`]} cursor={{ fill: BRAND.glow }} /> - + ) : } @@ -306,7 +306,7 @@ export default function ComputeFrontier() { `${(v / 1000).toFixed(0)}`} /> [`${v.toLocaleString()} MW`, "planned"]} cursor={{ fill: BRAND.glow }} /> - + ) : } @@ -330,7 +330,7 @@ export default function ComputeFrontier() { `${(v / 1000).toFixed(0)}`} /> [`${v.toLocaleString()} MW`, `${p.payload.count} clusters`]} cursor={{ fill: BRAND.glow }} /> - + {metrics.byStatus.map((s) => )} @@ -356,7 +356,7 @@ export default function ComputeFrontier() { [`${v} GW`, "planned online"]} cursor={{ fill: BRAND.glow }} /> - + {timeline.map((t) => )} @@ -382,7 +382,7 @@ export default function ComputeFrontier() { `${(v / 1000).toFixed(0)}`} /> [`${v.toLocaleString()} MW`, `${p.payload.count} clusters`]} cursor={{ fill: BRAND.glow }} /> - + {metrics.byEnergySource.map((e) => )} diff --git a/client/src/pages/gpu-economics.tsx b/client/src/pages/gpu-economics.tsx index f332e22..5c92875 100644 --- a/client/src/pages/gpu-economics.tsx +++ b/client/src/pages/gpu-economics.tsx @@ -21,6 +21,7 @@ import { } from "recharts"; import { trainingEstimate, trainingSensitivity } from "@/lib/gpu-economics-series"; import { FONT, INK } from "@/lib/tokens"; +import { seriesAnimation } from "@/lib/chart-theme"; interface EconRow { model: string; @@ -236,7 +237,7 @@ export default function GpuEconomics({ embedded = false }: { embedded?: boolean labelStyle={{ color: "#9ca3af", fontFamily: FONT.mono, fontSize: 10, marginBottom: 4 }} formatter={(value: number) => [`$${Number(value).toFixed(2)} / PFLOP-hr`, "Compute cost"]} /> - + {efficiencyRows.map((row) => ( [usdBig(Number(value)), "Modeled cost"]} labelFormatter={(value: number) => `${value}% MFU`} /> - `${Math.round(v)}`} formatter={(value: number, key: string, item: { payload?: Record }) => [`$${value} / M input`, item?.payload?.[key + "Name"] ?? key]} /> - + (v != null ? fmtTraj(v) : "")} fill="#e5e7eb" fontFamily={FONT.mono} fontSize={9} /> - + (v != null ? fmtTraj(v) : "")} fill={AMBER} fontFamily={FONT.mono} fontSize={9} /> diff --git a/client/src/pages/my-grid.tsx b/client/src/pages/my-grid.tsx index 9a2ef3e..63e9223 100644 --- a/client/src/pages/my-grid.tsx +++ b/client/src/pages/my-grid.tsx @@ -20,7 +20,7 @@ import { PageHeader } from "@/components/PageHeader"; import { RTO_CONFIG, RTO_SOURCE_NOTE, type RTOConfig } from "@/data/rto-config"; import { STATE_GRID, STATE_GRID_SOURCE } from "@/data/state-grid"; import { BORDER, BRAND, FONT, INK, SEMANTIC, STATUS_COLORS, SURFACE } from "@/lib/tokens"; -import { axisProps, gridProps, tooltipContentStyle, tooltipItemStyle, tooltipLabelStyle } from "@/lib/chart-theme"; +import { axisProps, gridProps, tooltipContentStyle, tooltipItemStyle, tooltipLabelStyle, seriesAnimation } from "@/lib/chart-theme"; // US state boundaries: US Census cartographic boundary file (public domain), // via the widely used us-states GeoJSON distribution. import statesGeoRaw from "@/data/us-states.geo.json"; @@ -550,7 +550,7 @@ export default function MyGrid() { labelFormatter={(m: string) => fmtMonth(m)} formatter={(v: number) => [`${v.toFixed(2)}¢/kWh`, "Residential average"]} /> - + [`${v} GW across ${p.payload.count} deal${p.payload.count === 1 ? "" : "s"}`, p.payload.buyer]} /> - `${v}`, fill: INK.muted, fontSize: 10, fontFamily: FONT.mono }} />