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 }} />