Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,6 @@ jobs:

- name: Build static production export
run: npm run build

- name: Enforce production performance budget
run: npm run validate:performance
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions scripts/validate-launch-readiness.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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") {
Expand All @@ -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",
Expand Down
67 changes: 67 additions & 0 deletions scripts/validate-performance-budget.mjs
Original file line number Diff line number Diff line change
@@ -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",
});
4 changes: 1 addition & 3 deletions src/app/globals.css
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
26 changes: 14 additions & 12 deletions src/components/Playground.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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%),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -5685,6 +5685,8 @@
.appSidebar {
gap: 9px;
padding: 10px;
background: var(--surface-raised);
backdrop-filter: none;
}

.brandLockup {
Expand Down Expand Up @@ -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;
Expand Down
28 changes: 2 additions & 26 deletions src/components/Playground.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
34 changes: 2 additions & 32 deletions src/components/ShareCardPreview.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -20,37 +21,6 @@ function createEmbedCode(shareUrl: string) {
return `<iframe title="Election Scenario Playground scenario" src="${shareUrl}" width="100%" height="720" loading="lazy"></iframe>`;
}

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,
Expand All @@ -75,7 +45,7 @@ export function ShareCardPreview({
}

try {
await copyText(embedCode);
await copyTextToClipboard(embedCode);
setCopyStatus("copied");
} catch {
setCopyStatus("failed");
Expand Down
20 changes: 16 additions & 4 deletions src/components/UnifiedScenarioSummary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { getMatchingScenarioPreset } from "@/data/scenarioPresets";
import {
formatSwing,
} from "@/lib/format";
import { copyTextToClipboard } from "@/lib/copyText";
import type {
LegislativeScenarioResult,
HistoricalElectionYear,
Expand Down Expand Up @@ -167,7 +168,7 @@ export function UnifiedScenarioSummary({

async function copySnapshot() {
try {
await navigator.clipboard.writeText(snapshotText);
await copyTextToClipboard(snapshotText);
setShareStatus("copied");
} catch {
setShareStatus("failed");
Expand All @@ -187,7 +188,7 @@ export function UnifiedScenarioSummary({
<text x="455" y="270" fill="#123442" font-family="system-ui,sans-serif" font-size="34" font-weight="800">Senate</text>
<text x="455" y="320" fill="#1976c9" font-family="system-ui,sans-serif" font-size="30">D ${senateScenario.controlTotals.democratic}</text>
<text x="635" y="320" fill="#d84452" font-family="system-ui,sans-serif" font-size="30">R ${senateScenario.controlTotals.republican}</text>
<text x="815" y="270" fill="#123442" font-family="system-ui,sans-serif" font-size="34" font-weight="800">History · President</text>
<text x="815" y="270" fill="#123442" font-family="system-ui,sans-serif" font-size="30" font-weight="800">History · President</text>
<text x="815" y="320" fill="#1976c9" font-family="system-ui,sans-serif" font-size="30">D ${presidentialScenario.totals.democratic}</text>
<text x="985" y="320" fill="#d84452" font-family="system-ui,sans-serif" font-size="30">R ${presidentialScenario.totals.republican}</text>
<text x="92" y="440" fill="#123442" font-family="system-ui,sans-serif" font-size="25" font-weight="700">Custom assumptions</text>
Expand Down Expand Up @@ -288,9 +289,20 @@ export function UnifiedScenarioSummary({
<summary>Share, save, reset, and compare chambers</summary>
<div className={styles.scenarioToolsBody}>
<div className={styles.simulationDockActions}>
<button disabled={!shareUrl} onClick={copySnapshot} type="button">
<button
aria-label={shareStatus === "failed" ? "Copy summary failed" : "Copy summary"}
disabled={!shareUrl}
onClick={copySnapshot}
type="button"
>
{shareStatus === "copied" ? <Check size={14} /> : <Copy size={14} />}
{shareStatus === "copied" ? "Copied" : "Copy summary"}
<span aria-live="polite">
{shareStatus === "copied"
? "Copied"
: shareStatus === "failed"
? "Copy failed"
: "Copy summary"}
</span>
</button>
<button onClick={() => void exportSnapshotCard("svg")} type="button">
{shareStatus === "saved" ? <Check size={14} /> : <Download size={14} />}
Expand Down
Loading