From a014e8f4a8bcdd5ec16f4e99b91a57e3a58ebad3 Mon Sep 17 00:00:00 2001 From: damonko1 Date: Sat, 29 Aug 2026 13:14:48 -0400 Subject: [PATCH] Harden final production readiness --- .github/workflows/ci.yml | 3 + package.json | 3 +- scripts/validate-launch-readiness.mjs | 6 ++ scripts/validate-performance-budget.mjs | 67 +++++++++++++++++++++++ src/app/globals.css | 4 +- src/components/Playground.module.css | 26 +++++---- src/components/Playground.tsx | 28 +--------- src/components/ShareCardPreview.tsx | 34 +----------- src/components/UnifiedScenarioSummary.tsx | 20 +++++-- src/lib/copyText.ts | 29 ++++++++++ 10 files changed, 142 insertions(+), 78 deletions(-) create mode 100644 scripts/validate-performance-budget.mjs create mode 100644 src/lib/copyText.ts 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({ Senate D ${senateScenario.controlTotals.democratic} R ${senateScenario.controlTotals.republican} - History · President + History · President D ${presidentialScenario.totals.democratic} R ${presidentialScenario.totals.republican} Custom assumptions @@ -288,9 +289,20 @@ export function UnifiedScenarioSummary({ Share, save, reset, and compare chambers
-