diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index cf3a9ab..1bbba8f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -51,3 +51,6 @@ jobs:
- name: Build static production export
run: npm run build
+
+ - name: Enforce production performance budget
+ run: npm run validate:performance
diff --git a/package.json b/package.json
index 937f81a..6063d15 100644
--- a/package.json
+++ b/package.json
@@ -18,7 +18,8 @@
"validate:house-map": "node scripts/validate-house-district-map.mjs",
"validate:state-map": "node scripts/validate-state-map.mjs",
"validate:legislative-data": "node scripts/validate-legislative-data.mjs",
- "validate:launch": "node scripts/validate-launch-readiness.mjs"
+ "validate:launch": "node scripts/validate-launch-readiness.mjs",
+ "validate:performance": "node scripts/validate-performance-budget.mjs"
},
"dependencies": {
"lucide-react": "1.22.0",
diff --git a/scripts/validate-launch-readiness.mjs b/scripts/validate-launch-readiness.mjs
index d23e633..c5ca5d6 100644
--- a/scripts/validate-launch-readiness.mjs
+++ b/scripts/validate-launch-readiness.mjs
@@ -33,6 +33,7 @@ function requireOrder(source, values, label) {
const playground = read("src/components/Playground.tsx");
const unifiedSummary = read("src/components/UnifiedScenarioSummary.tsx");
+const copyText = read("src/lib/copyText.ts");
const chamberCounter = read("src/components/ChamberCounter.tsx");
const electoralCounter = read("src/components/ElectoralCounter.tsx");
const houseMap = read("src/components/HouseDistrictMap.tsx");
@@ -92,13 +93,17 @@ for (const source of [
requireText(unifiedSummary, 'exportSnapshotCard("svg")', "SVG scenario-card export");
requireText(unifiedSummary, 'exportSnapshotCard("png")', "PNG scenario-card export");
+requireText(copyText, "fallbackCopy", "Clipboard-denial fallback");
+requireText(unifiedSummary, '"Copy failed"', "Clipboard failure feedback");
requireText(globalStyles, "prefers-reduced-motion: reduce", "Reduced-motion support");
+rejectText(globalStyles, "fonts.googleapis.com", "Render-blocking third-party fonts");
requireText(globalStyles, ":focus-visible", "Visible keyboard focus");
requireText(playgroundStyles, '.shell summary {', "Mobile disclosure targets");
requireText(playgroundStyles, "min-height: 44px", "Minimum touch targets");
requireText(playgroundStyles, '"tools-actions"', "Scenario tools row layout");
requireText(playgroundStyles, '"tools-results"', "Scenario results row layout");
requireText(playgroundStyles, '"tools-saved"', "Saved scenarios row layout");
+requireText(playgroundStyles, "backdrop-filter: none", "Mobile compositing fallback");
requireText(metadata, 'applicationName: "Election Scenario Playground"', "Metadata identity");
if (packageJson.name !== "election-scenario-playground") {
@@ -112,6 +117,7 @@ for (const workflow of [ci, deploy]) {
requireText(deploy, "actions/configure-pages@v6", "Supported Pages configuration action");
requireText(deploy, "actions/upload-pages-artifact@v5", "Supported artifact action");
requireText(deploy, "actions/deploy-pages@v5", "Supported deploy action");
+requireText(ci, "npm run validate:performance", "Performance budget gate");
for (const documentationPath of [
"docs/data-accuracy.md",
diff --git a/scripts/validate-performance-budget.mjs b/scripts/validate-performance-budget.mjs
new file mode 100644
index 0000000..d7011d1
--- /dev/null
+++ b/scripts/validate-performance-budget.mjs
@@ -0,0 +1,67 @@
+import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
+import { extname, join, relative } from "node:path";
+import { gzipSync } from "node:zlib";
+
+const root = process.cwd();
+const outputDirectory = join(root, "out");
+
+if (!existsSync(outputDirectory)) {
+ throw new Error("Performance budget requires a completed static build in out/.");
+}
+
+function listFiles(directory) {
+ return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
+ const path = join(directory, entry.name);
+ return entry.isDirectory() ? listFiles(path) : [path];
+ });
+}
+
+const files = listFiles(outputDirectory);
+
+function totalRawSize(selectedFiles) {
+ return selectedFiles.reduce((total, file) => total + statSync(file).size, 0);
+}
+
+function totalGzipSize(selectedFiles) {
+ return selectedFiles.reduce(
+ (total, file) => total + gzipSync(readFileSync(file)).length,
+ 0,
+ );
+}
+
+function filesWithExtension(extension) {
+ return files.filter((file) => extname(file) === extension);
+}
+
+function assertBudget(label, actual, maximum) {
+ if (actual > maximum) {
+ throw new Error(`${label} exceeded: ${actual} bytes > ${maximum} bytes`);
+ }
+}
+
+const javascriptFiles = filesWithExtension(".js");
+const stylesheetFiles = filesWithExtension(".css");
+const dataFiles = filesWithExtension(".json");
+const largestDataFile = dataFiles.reduce(
+ (largest, file) => (statSync(file).size > statSync(largest).size ? file : largest),
+ dataFiles[0],
+);
+
+const measurements = {
+ totalStaticBytes: totalRawSize(files),
+ javascriptGzipBytes: totalGzipSize(javascriptFiles),
+ stylesheetGzipBytes: totalGzipSize(stylesheetFiles),
+ largestDataBytes: largestDataFile ? statSync(largestDataFile).size : 0,
+};
+
+assertBudget("Static export", measurements.totalStaticBytes, 3_000_000);
+assertBudget("Gzipped JavaScript", measurements.javascriptGzipBytes, 350_000);
+assertBudget("Gzipped CSS", measurements.stylesheetGzipBytes, 30_000);
+assertBudget("Largest JSON asset", measurements.largestDataBytes, 550_000);
+
+console.log("Performance budget validated.", {
+ ...measurements,
+ largestDataFile: largestDataFile
+ ? relative(outputDirectory, largestDataFile)
+ : "none",
+});
diff --git a/src/app/globals.css b/src/app/globals.css
index 9b41b85..1d665dd 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -1,5 +1,3 @@
-@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@500;600;700&family=Space+Grotesk:wght@500;600;700;800&display=swap");
-
:root {
--background: #e9fbff;
--foreground: #123442;
@@ -35,7 +33,7 @@ body {
auto;
color: var(--foreground);
font-family:
- "Space Grotesk", Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
+ Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-variant-numeric: tabular-nums;
}
diff --git a/src/components/Playground.module.css b/src/components/Playground.module.css
index 5077bd7..88f13b8 100644
--- a/src/components/Playground.module.css
+++ b/src/components/Playground.module.css
@@ -4742,7 +4742,7 @@
--aero-green: #00a878;
color-scheme: light;
accent-color: var(--accent);
- font-family: "Space Grotesk", Inter, ui-sans-serif, system-ui, sans-serif;
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background:
linear-gradient(135deg, rgba(240, 95, 59, 0.08), transparent 36%),
linear-gradient(225deg, rgba(0, 168, 120, 0.08), transparent 42%),
@@ -4785,7 +4785,7 @@
.sliderTicks,
.demographicTicks,
.presetButton small {
- font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.appSidebar,
@@ -4893,7 +4893,7 @@
border-radius: 999px;
color: #007a59;
background: rgba(0, 168, 120, 0.1);
- font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
@@ -4932,7 +4932,7 @@
padding: 0 10px;
color: var(--muted-strong);
background: var(--surface-soft);
- font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
@@ -5000,7 +5000,7 @@
.houseDistrictLabel,
.stateLabel {
- font-family: "Space Grotesk", Inter, ui-sans-serif, system-ui, sans-serif;
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.houseDistrictMap[data-zoom-mode="state"] .houseDistrictLabel {
@@ -5242,7 +5242,7 @@
align-items: center;
border-radius: 999px;
padding: 4px 8px;
- font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 9px;
font-weight: 800;
line-height: 1;
@@ -5272,7 +5272,7 @@
min-height: 28px;
padding: 6px 9px;
color: var(--muted-strong);
- font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 10px;
font-weight: 800;
cursor: pointer;
@@ -5308,7 +5308,7 @@
.modelSummaryText small {
color: var(--muted);
- font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 9px;
font-weight: 700;
}
@@ -5329,7 +5329,7 @@
.modelFormula > span,
.modelResourceCard > span {
color: var(--muted);
- font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 8px;
font-weight: 850;
letter-spacing: 0.04em;
@@ -5422,7 +5422,7 @@
.summaryDeepDive > summary {
padding: 10px 12px;
color: var(--muted-strong);
- font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 10px;
font-weight: 800;
cursor: pointer;
@@ -5439,7 +5439,7 @@
.detailDisclosure > summary {
padding: 10px 12px;
color: var(--muted-strong);
- font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 10px;
font-weight: 800;
cursor: pointer;
@@ -5685,6 +5685,8 @@
.appSidebar {
gap: 9px;
padding: 10px;
+ background: var(--surface-raised);
+ backdrop-filter: none;
}
.brandLockup {
@@ -6021,7 +6023,7 @@
.advancedAnalysisDisclosure > summary {
padding: 7px 2px 2px;
color: var(--muted-strong);
- font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 10px;
font-weight: 800;
cursor: pointer;
diff --git a/src/components/Playground.tsx b/src/components/Playground.tsx
index 630f7b1..24e3693 100644
--- a/src/components/Playground.tsx
+++ b/src/components/Playground.tsx
@@ -52,6 +52,7 @@ import {
simulationTabFromSearchParams,
} from "@/lib/scenarioUrl";
import { hasSeatOverride, hasStateOverride } from "@/lib/localOverrides";
+import { copyTextToClipboard } from "@/lib/copyText";
import { resetBaselinePresetId } from "@/data/scenarioPresets";
import type {
DemographicAssumptions,
@@ -347,32 +348,7 @@ export function Playground() {
});
const shareUrl = new URL(relativeUrl, window.location.origin).href;
- const textArea = document.createElement("textarea");
- textArea.value = shareUrl;
- textArea.setAttribute("readonly", "");
- textArea.style.position = "fixed";
- textArea.style.opacity = "0";
- function copyWithFallback() {
- document.body.append(textArea);
- textArea.select();
- const copied = document.execCommand("copy");
- textArea.remove();
-
- if (!copied) {
- throw new Error("Scenario link could not be copied");
- }
- }
-
- if (!navigator.clipboard?.writeText) {
- copyWithFallback();
- return;
- }
-
- try {
- await navigator.clipboard.writeText(shareUrl);
- } catch {
- copyWithFallback();
- }
+ await copyTextToClipboard(shareUrl);
}, [
activeTab,
baselineYear,
diff --git a/src/components/ShareCardPreview.tsx b/src/components/ShareCardPreview.tsx
index 7226deb..c16fe85 100644
--- a/src/components/ShareCardPreview.tsx
+++ b/src/components/ShareCardPreview.tsx
@@ -1,6 +1,7 @@
import { Check, Copy, Share2 } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { formatSwing } from "@/lib/format";
+import { copyTextToClipboard } from "@/lib/copyText";
import type { HistoricalElectionYear, ScenarioResult } from "@/types/election";
import styles from "@/components/Playground.module.css";
@@ -20,37 +21,6 @@ function createEmbedCode(shareUrl: string) {
return ``;
}
-function fallbackCopy(text: string) {
- const textArea = document.createElement("textarea");
-
- textArea.value = text;
- textArea.setAttribute("readonly", "");
- textArea.style.position = "fixed";
- textArea.style.opacity = "0";
- document.body.append(textArea);
- textArea.select();
-
- const copied = document.execCommand("copy");
- textArea.remove();
-
- if (!copied) {
- throw new Error("Copy failed");
- }
-}
-
-async function copyText(text: string) {
- if (!navigator.clipboard?.writeText) {
- fallbackCopy(text);
- return;
- }
-
- try {
- await navigator.clipboard.writeText(text);
- } catch {
- fallbackCopy(text);
- }
-}
-
export function ShareCardPreview({
baselineYear,
scenario,
@@ -75,7 +45,7 @@ export function ShareCardPreview({
}
try {
- await copyText(embedCode);
+ await copyTextToClipboard(embedCode);
setCopyStatus("copied");
} catch {
setCopyStatus("failed");
diff --git a/src/components/UnifiedScenarioSummary.tsx b/src/components/UnifiedScenarioSummary.tsx
index 0fa1609..1bbae95 100644
--- a/src/components/UnifiedScenarioSummary.tsx
+++ b/src/components/UnifiedScenarioSummary.tsx
@@ -12,6 +12,7 @@ import { getMatchingScenarioPreset } from "@/data/scenarioPresets";
import {
formatSwing,
} from "@/lib/format";
+import { copyTextToClipboard } from "@/lib/copyText";
import type {
LegislativeScenarioResult,
HistoricalElectionYear,
@@ -167,7 +168,7 @@ export function UnifiedScenarioSummary({
async function copySnapshot() {
try {
- await navigator.clipboard.writeText(snapshotText);
+ await copyTextToClipboard(snapshotText);
setShareStatus("copied");
} catch {
setShareStatus("failed");
@@ -187,7 +188,7 @@ export function UnifiedScenarioSummary({