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