Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,4 @@ attached_assets/Pasted-*
/tmp/
coverage/
.nyc_output/
.card-preview/
9 changes: 5 additions & 4 deletions client/src/components/home/DemandChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -116,10 +117,10 @@ export function DemandChart() {
}}
/>

<Line yAxisId="total" type="monotone" dataKey="demand" stroke={DATA_MUTED} strokeWidth={1.5} dot={false} connectNulls={false} name="Total" />
<Line yAxisId="total" type="monotone" dataKey="projected" stroke={DATA_MUTED} strokeWidth={1.5} strokeDasharray="5 3" dot={false} connectNulls={false} name="Total proj." />
<Line yAxisId="dc" type="monotone" dataKey="dcDemand" stroke={DATA} strokeWidth={2} dot={false} connectNulls={false} name="Data centers" />
<Line yAxisId="dc" type="monotone" dataKey="dcProjected" stroke={DATA} strokeWidth={2} strokeDasharray="5 3" dot={false} connectNulls={false} name="DC proj." />
<Line {...seriesAnimation} yAxisId="total" type="monotone" dataKey="demand" stroke={DATA_MUTED} strokeWidth={1.5} dot={false} connectNulls={false} name="Total" />
<Line {...seriesAnimation} yAxisId="total" type="monotone" dataKey="projected" stroke={DATA_MUTED} strokeWidth={1.5} strokeDasharray="5 3" dot={false} connectNulls={false} name="Total proj." />
<Line {...seriesAnimation} yAxisId="dc" type="monotone" dataKey="dcDemand" stroke={DATA} strokeWidth={2} dot={false} connectNulls={false} name="Data centers" />
<Line {...seriesAnimation} yAxisId="dc" type="monotone" dataKey="dcProjected" stroke={DATA} strokeWidth={2} strokeDasharray="5 3" dot={false} connectNulls={false} name="DC proj." />

<ReferenceLine
yAxisId="total"
Expand Down
73 changes: 73 additions & 0 deletions client/src/lib/__tests__/chart-motion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Recharts animates every series by default and never consults
// prefers-reduced-motion, so charts grew from zero regardless of what the
// visitor asked their OS for. seriesAnimation is the single switch every
// animated series spreads; these tests pin the contract.
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync, readdirSync } from "fs";
import { join } from "path";

const ROOT = process.cwd();
const CHART_THEME = join(ROOT, "client", "src", "lib", "chart-theme.ts");

test("seriesAnimation is derived from prefersReducedMotion, not hardcoded", () => {
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 <g class="recharts-bar-rectangle"> 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 <Area> 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 <BarChart>/<LineChart>/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")}`,
);
});
28 changes: 28 additions & 0 deletions client/src/lib/chart-theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: <Bar {...seriesAnimation} />.
*
* 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 <g class="recharts-bar-rectangle"> 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 <CartesianGrid>: {...gridProps} */
export const gridProps = {
stroke: chartTheme.grid.stroke,
Expand Down
3 changes: 2 additions & 1 deletion client/src/pages/PortfolioOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -463,7 +464,7 @@ export default function PortfolioOverlay({ embedded = false }: { embedded?: bool
tickCount={4}
/>
<Tooltip content={<CustomRadarTooltip />} />
<Radar
<Radar {...seriesAnimation}
name="Exposure"
dataKey="value"
stroke={BRAND.secondary}
Expand Down
4 changes: 2 additions & 2 deletions client/src/pages/StockPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { ArrowLeft, ExternalLink, TrendingUp, TrendingDown, AlertTriangle, Share
import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, Tooltip as RTooltip } from "recharts";
import { useToast } from "@/hooks/use-toast";
import { SEMANTIC } from "@/lib/tokens";
import { axisProps, tooltipContentStyle } from "@/lib/chart-theme";
import { axisProps, tooltipContentStyle, seriesAnimation } from "@/lib/chart-theme";

interface StockInfo {
ticker: string;
Expand Down Expand Up @@ -235,7 +235,7 @@ export default function StockPage() {
labelStyle={{ display: "none" }}
formatter={(val: number) => [`$${val.toFixed(2)}`, "Price"]}
/>
<Line type="monotone" dataKey="price" stroke={isUp ? SEMANTIC.positiveDeep : SEMANTIC.negativeDeep} strokeWidth={2} dot={false} />
<Line {...seriesAnimation} type="monotone" dataKey="price" stroke={isUp ? SEMANTIC.positiveDeep : SEMANTIC.negativeDeep} strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
</Card>
Expand Down
16 changes: 5 additions & 11 deletions client/src/pages/TheTrade.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -575,10 +569,10 @@ export default function TheTrade({ embedded = false }: { embedded?: boolean; par
);
}}
/>
<Bar dataKey="gas" name="Natural Gas" stackId="a" fill={CATEGORY_COLORS.gas} radius={[0,0,0,0]} />
<Bar dataKey="nuclear" name="Nuclear" stackId="a" fill={CATEGORY_COLORS.nuclear} radius={[0,0,0,0]} />
<Bar dataKey="renewables" name="Renewables" stackId="a" fill={CATEGORY_COLORS.renewables} radius={[0,0,0,0]} />
<Bar dataKey="grid" name="Grid" stackId="a" fill={CATEGORY_COLORS.grid} radius={[2,2,0,0]} />
<Bar {...seriesAnimation} dataKey="gas" name="Natural Gas" stackId="a" fill={CATEGORY_COLORS.gas} radius={[0,0,0,0]} />
<Bar {...seriesAnimation} dataKey="nuclear" name="Nuclear" stackId="a" fill={CATEGORY_COLORS.nuclear} radius={[0,0,0,0]} />
<Bar {...seriesAnimation} dataKey="renewables" name="Renewables" stackId="a" fill={CATEGORY_COLORS.renewables} radius={[0,0,0,0]} />
<Bar {...seriesAnimation} dataKey="grid" name="Grid" stackId="a" fill={CATEGORY_COLORS.grid} radius={[2,2,0,0]} />
</BarChart>
</ResponsiveContainer>
<SrChartTable
Expand Down
14 changes: 7 additions & 7 deletions client/src/pages/TiltOverview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { AsOf, ErrorState, SrChartTable } from "@/components/Freshness";
import {
BRAND, CATEGORY_COLORS as TOKEN_CATEGORY_COLORS, CHART_CHROME, DATA_QUALITY, FONT, INK, SEMANTIC, SERIES,
} from "@/lib/tokens";
import { axisProps, gridProps, timeTicks, tooltipContentStyle, tooltipItemStyle, tooltipLabelStyle } from "@/lib/chart-theme";
import { axisProps, gridProps, timeTicks, tooltipContentStyle, tooltipItemStyle, tooltipLabelStyle, seriesAnimation } from "@/lib/chart-theme";
import { RTO_CONFIG, RTO_SOURCE_NOTE } from "@/data/rto-config";
import { STAGE_COLORS } from "@/data/catalyst-config";
import {
Expand Down Expand Up @@ -501,7 +501,7 @@ function BuildoutHistoryCard({
return [`${fmtGW(value)}${detail}`, name === "online" ? "Operational" : "Pipeline"];
}}
/>
<Area
<Area {...seriesAnimation}
type="stepAfter"
dataKey="online"
name="online"
Expand All @@ -512,7 +512,7 @@ function BuildoutHistoryCard({
connectNulls
label={(props: any) => <EndLabel {...props} data={series} field="online" color={BRAND.primary} />}
/>
<Line
<Line {...seriesAnimation}
type="stepAfter"
dataKey="pipeline"
name="pipeline"
Expand Down Expand Up @@ -1096,7 +1096,7 @@ export default function TiltOverview() {
stroke={alpha(INK.muted, 0.2)}
/>

<Area
<Area {...seriesAnimation}
yAxisId="total"
type="monotone"
dataKey="demand"
Expand All @@ -1108,7 +1108,7 @@ export default function TiltOverview() {
activeDot={{ r: 4, fill: SERIES[0] }}
connectNulls={false}
/>
<Area
<Area {...seriesAnimation}
yAxisId="total"
type="monotone"
dataKey="projected"
Expand All @@ -1121,7 +1121,7 @@ export default function TiltOverview() {
activeDot={{ r: 4, fill: BRAND.secondary }}
connectNulls={false}
/>
<Area
<Area {...seriesAnimation}
yAxisId="dc"
type="monotone"
dataKey="dcDemand"
Expand All @@ -1132,7 +1132,7 @@ export default function TiltOverview() {
dot={false}
connectNulls={false}
/>
<Line
<Line {...seriesAnimation}
yAxisId="dc"
type="monotone"
dataKey="dcProjected"
Expand Down
12 changes: 6 additions & 6 deletions client/src/pages/compute-frontier.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
import { ArrowUpDown, Atom } from "lucide-react";
import { PageHeader, HeaderStat } from "@/components/PageHeader";
import { BRAND, INK, SURFACE, CATEGORY_COLORS, STATUS_COLORS } from "@/lib/tokens";
import { axisProps, gridProps } from "@/lib/chart-theme";
import { axisProps, gridProps, seriesAnimation } from "@/lib/chart-theme";

// ─── Types (mirror /api/clusters and /api/clusters/metrics) ────────────────

Expand Down Expand Up @@ -282,7 +282,7 @@ export default function ComputeFrontier() {
tickFormatter={(v: string) => truncateLabel(v, 16)}
/>
<RTooltip formatter={(v: number, _n, p: any) => [`${v.toLocaleString()} MW`, `${p.payload.count} clusters`]} cursor={{ fill: BRAND.glow }} />
<Bar dataKey="plannedMW" fill={BRAND.primary} radius={[0, 2, 2, 0]} />
<Bar {...seriesAnimation} dataKey="plannedMW" fill={BRAND.primary} radius={[0, 2, 2, 0]} />
</BarChart>
</ResponsiveContainer>
) : <ChartSkeleton />}
Expand All @@ -306,7 +306,7 @@ export default function ComputeFrontier() {
<XAxis {...axisProps} dataKey="iso" />
<YAxis {...axisProps} tickFormatter={(v) => `${(v / 1000).toFixed(0)}`} />
<RTooltip formatter={(v: number) => [`${v.toLocaleString()} MW`, "planned"]} cursor={{ fill: BRAND.glow }} />
<Bar dataKey="plannedMW" fill={BRAND.primary} radius={[2, 2, 0, 0]} />
<Bar {...seriesAnimation} dataKey="plannedMW" fill={BRAND.primary} radius={[2, 2, 0, 0]} />
</BarChart>
</ResponsiveContainer>
) : <ChartSkeleton />}
Expand All @@ -330,7 +330,7 @@ export default function ComputeFrontier() {
<XAxis {...axisProps} dataKey="status" />
<YAxis {...axisProps} tickFormatter={(v) => `${(v / 1000).toFixed(0)}`} />
<RTooltip formatter={(v: number, _n, p: any) => [`${v.toLocaleString()} MW`, `${p.payload.count} clusters`]} cursor={{ fill: BRAND.glow }} />
<Bar dataKey="plannedMW" radius={[2, 2, 0, 0]}>
<Bar {...seriesAnimation} dataKey="plannedMW" radius={[2, 2, 0, 0]}>
{metrics.byStatus.map((s) => <Cell key={s.status} fill={STATUS_COLOR[s.status] ?? INK.muted} />)}
</Bar>
</BarChart>
Expand All @@ -356,7 +356,7 @@ export default function ComputeFrontier() {
<XAxis {...axisProps} dataKey="year" />
<YAxis {...axisProps} />
<RTooltip formatter={(v: number) => [`${v} GW`, "planned online"]} cursor={{ fill: BRAND.glow }} />
<Bar dataKey="gw" radius={[2, 2, 0, 0]}>
<Bar {...seriesAnimation} dataKey="gw" radius={[2, 2, 0, 0]}>
{timeline.map((t) => <Cell key={t.year} fill={t.year === "n/a" ? INK.faint : BRAND.primary} />)}
</Bar>
</BarChart>
Expand All @@ -382,7 +382,7 @@ export default function ComputeFrontier() {
<XAxis {...axisProps} type="number" tickFormatter={(v) => `${(v / 1000).toFixed(0)}`} />
<YAxis {...axisProps} type="category" dataKey="source" width={80} interval={0} />
<RTooltip formatter={(v: number, _n, p: any) => [`${v.toLocaleString()} MW`, `${p.payload.count} clusters`]} cursor={{ fill: BRAND.glow }} />
<Bar dataKey="plannedMW" radius={[0, 2, 2, 0]}>
<Bar {...seriesAnimation} dataKey="plannedMW" radius={[0, 2, 2, 0]}>
{metrics.byEnergySource.map((e) => <Cell key={e.source} fill={ENERGY_COLOR[e.source] ?? INK.faint} />)}
</Bar>
</BarChart>
Expand Down
9 changes: 5 additions & 4 deletions client/src/pages/gpu-economics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"]}
/>
<Bar dataKey="usdPerPflopHr" radius={[0, 3, 3, 0]} isAnimationActive={false}>
<Bar {...seriesAnimation} dataKey="usdPerPflopHr" radius={[0, 3, 3, 0]} isAnimationActive={false}>
{efficiencyRows.map((row) => (
<Cell
key={row.model}
Expand Down Expand Up @@ -405,7 +406,7 @@ export default function GpuEconomics({ embedded = false }: { embedded?: boolean
formatter={(value: number) => [usdBig(Number(value)), "Modeled cost"]}
labelFormatter={(value: number) => `${value}% MFU`}
/>
<Line
<Line {...seriesAnimation}
type="linear"
dataKey="usdCost"
stroke={NVIDIA_COLOR}
Expand Down Expand Up @@ -632,10 +633,10 @@ function TrajectoryChart({ traj, sources }: { traj?: InfPriceView["trajectory"];
labelFormatter={(v: number) => `${Math.round(v)}`}
formatter={(value: number, key: string, item: { payload?: Record<string, string> }) => [`$${value} / M input`, item?.payload?.[key + "Name"] ?? key]}
/>
<Line type="linear" dataKey="flagship" stroke={NVIDIA_COLOR} strokeWidth={2} connectNulls dot={{ r: 3, fill: NVIDIA_COLOR }} isAnimationActive={false}>
<Line {...seriesAnimation} type="linear" dataKey="flagship" stroke={NVIDIA_COLOR} strokeWidth={2} connectNulls dot={{ r: 3, fill: NVIDIA_COLOR }} isAnimationActive={false}>
<LabelList dataKey="flagship" position="top" formatter={(v: number) => (v != null ? fmtTraj(v) : "")} fill="#e5e7eb" fontFamily={FONT.mono} fontSize={9} />
</Line>
<Line type="linear" dataKey="efficient" stroke={AMBER} strokeWidth={2} strokeDasharray="4 3" connectNulls dot={{ r: 3, fill: AMBER }} isAnimationActive={false}>
<Line {...seriesAnimation} type="linear" dataKey="efficient" stroke={AMBER} strokeWidth={2} strokeDasharray="4 3" connectNulls dot={{ r: 3, fill: AMBER }} isAnimationActive={false}>
<LabelList dataKey="efficient" position="bottom" formatter={(v: number) => (v != null ? fmtTraj(v) : "")} fill={AMBER} fontFamily={FONT.mono} fontSize={9} />
</Line>
</LineChart>
Expand Down
Loading