From 41cfb5468ebeb562b494d7410e01f275d86ad657 Mon Sep 17 00:00:00 2001 From: Victor Garcia Date: Thu, 18 Jun 2026 01:06:06 -0600 Subject: [PATCH 1/2] perf(content): add generated lightweight state-index content/state-registry.ts (~26KB gzipped) shipped to the client via every component that imports it. Many only need {code, name, emoji} for dropdowns and labels. Add a standalone generated state-index.ts (1.4KB gzipped) so those components can stay off the full registry. Generated by scripts/gen-state-index.ts; state-index.test.ts asserts it never drifts from the registry. Co-Authored-By: Claude Opus 4.8 (1M context) --- content/state-index.test.ts | 32 ++++++++++++++++ content/state-index.ts | 73 +++++++++++++++++++++++++++++++++++++ scripts/gen-state-index.ts | 48 ++++++++++++++++++++++++ 3 files changed, 153 insertions(+) create mode 100644 content/state-index.test.ts create mode 100644 content/state-index.ts create mode 100644 scripts/gen-state-index.ts diff --git a/content/state-index.test.ts b/content/state-index.test.ts new file mode 100644 index 0000000..d7e2be2 --- /dev/null +++ b/content/state-index.test.ts @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 Steel-Tech / StructuPath +import { describe, it, expect } from "vitest"; +import { STATE_REGISTRY } from "@/content/state-registry"; +import { STATE_INDEX, STATE_LIST } from "@/content/state-index"; + +// state-index.ts is GENERATED from the registry (scripts/gen-state-index.ts). +// These tests fail if it drifts — e.g. a state added to the registry without +// regenerating the index — so the lightweight client copy can't go stale. +describe("state-index sync with state-registry", () => { + it("covers exactly the same state codes", () => { + expect(Object.keys(STATE_INDEX).sort()).toEqual( + Object.keys(STATE_REGISTRY).sort(), + ); + }); + + it("matches code/name/emoji for every state", () => { + for (const [code, data] of Object.entries(STATE_REGISTRY)) { + expect(STATE_INDEX[code]).toEqual({ + code: data.code, + name: data.name, + emoji: data.emoji, + }); + } + }); + + it("STATE_LIST holds every state, sorted by name", () => { + expect(STATE_LIST).toHaveLength(Object.keys(STATE_REGISTRY).length); + const names = STATE_LIST.map((s) => s.name); + expect(names).toEqual([...names].sort((a, b) => a.localeCompare(b))); + }); +}); diff --git a/content/state-index.ts b/content/state-index.ts new file mode 100644 index 0000000..56e847e --- /dev/null +++ b/content/state-index.ts @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 Steel-Tech / StructuPath +// +// GENERATED from content/state-registry.ts — do not edit by hand. +// A lightweight {code, name, emoji} index for dropdowns and name/emoji display. +// Components that don't need full StateData import this instead of the registry, +// keeping the ~26KB registry data out of their client bundles. +// Regenerate: npx tsx scripts/gen-state-index.ts +// content/state-index.test.ts asserts this stays in sync with the registry. + +export interface StateSummary { + code: string; + name: string; + emoji: string; +} + +export const STATE_INDEX: Record = { + AK: { code: "AK", name: "Alaska", emoji: "🐻" }, + AL: { code: "AL", name: "Alabama", emoji: "🏈" }, + AR: { code: "AR", name: "Arkansas", emoji: "💎" }, + AZ: { code: "AZ", name: "Arizona", emoji: "🌵" }, + CA: { code: "CA", name: "California", emoji: "🌴" }, + CO: { code: "CO", name: "Colorado", emoji: "⛰️" }, + CT: { code: "CT", name: "Connecticut", emoji: "🏛️" }, + DE: { code: "DE", name: "Delaware", emoji: "🦀" }, + FL: { code: "FL", name: "Florida", emoji: "🌞" }, + GA: { code: "GA", name: "Georgia", emoji: "🍑" }, + HI: { code: "HI", name: "Hawaii", emoji: "🌺" }, + IA: { code: "IA", name: "Iowa", emoji: "🌽" }, + ID: { code: "ID", name: "Idaho", emoji: "🥔" }, + IL: { code: "IL", name: "Illinois", emoji: "🏙️" }, + IN: { code: "IN", name: "Indiana", emoji: "🏎️" }, + KS: { code: "KS", name: "Kansas", emoji: "🌻" }, + KY: { code: "KY", name: "Kentucky", emoji: "🐴" }, + LA: { code: "LA", name: "Louisiana", emoji: "⚜️" }, + MA: { code: "MA", name: "Massachusetts", emoji: "🎓" }, + MD: { code: "MD", name: "Maryland", emoji: "🦀" }, + ME: { code: "ME", name: "Maine", emoji: "🦞" }, + MI: { code: "MI", name: "Michigan", emoji: "🏗️" }, + MN: { code: "MN", name: "Minnesota", emoji: "❄️" }, + MO: { code: "MO", name: "Missouri", emoji: "🏛️" }, + MS: { code: "MS", name: "Mississippi", emoji: "🎸" }, + MT: { code: "MT", name: "Montana", emoji: "🦌" }, + NC: { code: "NC", name: "North Carolina", emoji: "🏔️" }, + ND: { code: "ND", name: "North Dakota", emoji: "🦬" }, + NE: { code: "NE", name: "Nebraska", emoji: "🌾" }, + NH: { code: "NH", name: "New Hampshire", emoji: "🏔️" }, + NJ: { code: "NJ", name: "New Jersey", emoji: "🏖️" }, + NM: { code: "NM", name: "New Mexico", emoji: "🌶️" }, + NV: { code: "NV", name: "Nevada", emoji: "🎰" }, + NY: { code: "NY", name: "New York", emoji: "🗽" }, + OH: { code: "OH", name: "Ohio", emoji: "🏈" }, + OK: { code: "OK", name: "Oklahoma", emoji: "🌪️" }, + OR: { code: "OR", name: "Oregon", emoji: "🌲" }, + PA: { code: "PA", name: "Pennsylvania", emoji: "🔔" }, + RI: { code: "RI", name: "Rhode Island", emoji: "⛵" }, + SC: { code: "SC", name: "South Carolina", emoji: "🌴" }, + SD: { code: "SD", name: "South Dakota", emoji: "🏔️" }, + TN: { code: "TN", name: "Tennessee", emoji: "🎸" }, + TX: { code: "TX", name: "Texas", emoji: "🤠" }, + UT: { code: "UT", name: "Utah", emoji: "🏜️" }, + VA: { code: "VA", name: "Virginia", emoji: "🏛️" }, + VT: { code: "VT", name: "Vermont", emoji: "🍁" }, + WA: { code: "WA", name: "Washington", emoji: "🌲" }, + WI: { code: "WI", name: "Wisconsin", emoji: "🧀" }, + WV: { code: "WV", name: "West Virginia", emoji: "⛏️" }, + WY: { code: "WY", name: "Wyoming", emoji: "🤠" }, +}; + +/** All states, sorted by name — for dropdowns. */ +export const STATE_LIST: StateSummary[] = Object.values(STATE_INDEX).sort( + (a, b) => a.name.localeCompare(b.name), +); diff --git a/scripts/gen-state-index.ts b/scripts/gen-state-index.ts new file mode 100644 index 0000000..6d39570 --- /dev/null +++ b/scripts/gen-state-index.ts @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 Steel-Tech / StructuPath +// +// Generates content/state-index.ts — a standalone lightweight {code, name, +// emoji} index — from the full STATE_REGISTRY. Run: npx tsx scripts/gen-state-index.ts +import { writeFileSync } from "node:fs"; +import { STATE_REGISTRY } from "../content/state-registry"; + +const entries = Object.values(STATE_REGISTRY) + .sort((a, b) => a.code.localeCompare(b.code)) + .map( + (s) => + ` ${s.code}: { code: ${JSON.stringify(s.code)}, name: ${JSON.stringify( + s.name, + )}, emoji: ${JSON.stringify(s.emoji)} },`, + ) + .join("\n"); + +const out = `// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 Steel-Tech / StructuPath +// +// GENERATED from content/state-registry.ts — do not edit by hand. +// A lightweight {code, name, emoji} index for dropdowns and name/emoji display. +// Components that don't need full StateData import this instead of the registry, +// keeping the ~26KB registry data out of their client bundles. +// Regenerate: npx tsx scripts/gen-state-index.ts +// content/state-index.test.ts asserts this stays in sync with the registry. + +export interface StateSummary { + code: string; + name: string; + emoji: string; +} + +export const STATE_INDEX: Record = { +${entries} +}; + +/** All states, sorted by name — for dropdowns. */ +export const STATE_LIST: StateSummary[] = Object.values(STATE_INDEX).sort( + (a, b) => a.name.localeCompare(b.name), +); +`; + +writeFileSync(new URL("../content/state-index.ts", import.meta.url), out); +console.log( + `Wrote content/state-index.ts with ${Object.keys(STATE_REGISTRY).length} states`, +); From 350d2f19421477e361b020e21a69a9cbbcaa33ed Mon Sep 17 00:00:00 2001 From: Victor Garcia Date: Thu, 18 Jun 2026 01:06:06 -0600 Subject: [PATCH 2/2] perf(content): repoint light state consumers to state-index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 8 client components that only read {code, name, emoji} now import the lightweight state-index instead of the full STATE_REGISTRY, so their routes (the wizard flow, wizard/summary, capability-statement, gc-directory) drop the ~26KB registry from the client bundle. Consumers that render full StateData (home tax info, estimator calc, starter-kit, comparison, unions) intentionally keep the registry — typecheck enforces the split. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/compare/page.tsx | 6 +++--- app/wizard/summary/page.tsx | 4 ++-- components/capability/cap-statement-form.tsx | 4 ++-- components/compare/state-selector.tsx | 6 +++--- components/estimator/estimate-results.tsx | 4 ++-- components/gc-directory/gc-search-filters.tsx | 4 ++-- components/onboarding/onboarding-chat.tsx | 10 +++++----- components/wizard/progress-sidebar.tsx | 6 +++--- 8 files changed, 22 insertions(+), 22 deletions(-) diff --git a/app/compare/page.tsx b/app/compare/page.tsx index eb53e37..1e303b6 100644 --- a/app/compare/page.tsx +++ b/app/compare/page.tsx @@ -10,7 +10,7 @@ import { IBeamIcon } from "@/components/ui/ibeam-icon"; import { StateSelector } from "@/components/compare/state-selector"; import { ComparisonTable } from "@/components/compare/comparison-table"; import { ComparisonSummary } from "@/components/compare/comparison-summary"; -import { STATE_REGISTRY } from "@/content/state-registry"; +import { STATE_INDEX } from "@/content/state-index"; const LS_KEY = "ironforge_compare_states"; @@ -23,7 +23,7 @@ function loadSelected(): string[] { if (!Array.isArray(parsed)) return []; return parsed .filter((c): c is string => typeof c === "string" && /^[A-Z]{2}$/.test(c)) - .filter((c) => !!STATE_REGISTRY[c]) + .filter((c) => !!STATE_INDEX[c]) .slice(0, 3); } catch { return []; @@ -113,7 +113,7 @@ export default function ComparePage() {

Comparing:{" "} {selected - .map((c) => STATE_REGISTRY[c]?.name ?? c) + .map((c) => STATE_INDEX[c]?.name ?? c) .join(" vs. ")}

)} diff --git a/app/wizard/summary/page.tsx b/app/wizard/summary/page.tsx index 182fe9b..66d5791 100644 --- a/app/wizard/summary/page.tsx +++ b/app/wizard/summary/page.tsx @@ -22,7 +22,7 @@ import { PHASE_DEFINITIONS, getPhaseContent } from "@/content/phases"; import type { StateCode } from "@/content/phases"; import type { UserState } from "@/lib/types/wizard"; import { DEFAULT_STATE } from "@/lib/types/wizard"; -import { STATE_REGISTRY } from "@/content/state-registry"; +import { STATE_INDEX } from "@/content/state-index"; import { toProgressJson, toProgressCsv, @@ -177,7 +177,7 @@ export default function SummaryPage() { if (!loaded || !userState.profile.state || !summary) return null; const stateCode = userState.profile.state as StateCode; - const stateData = STATE_REGISTRY[stateCode]; + const stateData = STATE_INDEX[stateCode]; const buildExport = (): ProgressExport => ({ generatedAt: new Date().toISOString(), diff --git a/components/capability/cap-statement-form.tsx b/components/capability/cap-statement-form.tsx index 25de56b..12c8d73 100644 --- a/components/capability/cap-statement-form.tsx +++ b/components/capability/cap-statement-form.tsx @@ -10,7 +10,7 @@ import { SUGGESTED_NAICS, emptyPastProject, } from "@/lib/types/cap-statement"; -import { STATE_REGISTRY } from "@/content/state-registry"; +import { STATE_INDEX } from "@/content/state-index"; import { Plus, Trash2, Upload, X } from "lucide-react"; interface Props { @@ -74,7 +74,7 @@ export function CapStatementForm({ data, onChange }: Props) { reader.readAsDataURL(file); } - const states = Object.values(STATE_REGISTRY).sort((a, b) => + const states = Object.values(STATE_INDEX).sort((a, b) => a.name.localeCompare(b.name), ); diff --git a/components/compare/state-selector.tsx b/components/compare/state-selector.tsx index 9dc1309..f5a52a8 100644 --- a/components/compare/state-selector.tsx +++ b/components/compare/state-selector.tsx @@ -4,9 +4,9 @@ // Copyright (C) 2026 Steel-Tech / StructuPath import { useMemo, useState } from "react"; import { ChevronDown, Plus, X, Search } from "lucide-react"; -import { STATE_REGISTRY } from "@/content/state-registry"; +import { STATE_INDEX } from "@/content/state-index"; -const ALL_STATES = Object.values(STATE_REGISTRY).sort((a, b) => +const ALL_STATES = Object.values(STATE_INDEX).sort((a, b) => a.name.localeCompare(b.name), ); @@ -77,7 +77,7 @@ export function StateSelector({
{slots.map((code, idx) => { - const state = code ? STATE_REGISTRY[code] : null; + const state = code ? STATE_INDEX[code] : null; const isOpen = openSlot === idx; const isAddSlot = !state; const disabled = isAddSlot && idx > selected.length; diff --git a/components/estimator/estimate-results.tsx b/components/estimator/estimate-results.tsx index 1976aec..41a1e15 100644 --- a/components/estimator/estimate-results.tsx +++ b/components/estimator/estimate-results.tsx @@ -4,7 +4,7 @@ // Copyright (C) 2026 Steel-Tech / StructuPath import { formatCurrency, formatCurrencyPrecise } from "@/lib/estimator/calculate"; import type { EstimateInput, EstimateResult } from "@/lib/types/estimator"; -import { STATE_REGISTRY } from "@/content/state-registry"; +import { STATE_INDEX } from "@/content/state-index"; interface Props { input: EstimateInput; @@ -14,7 +14,7 @@ interface Props { export function EstimateResults({ input, result }: Props) { const { labor, materials, equipment, overhead, summary } = result; const stateName = input.state - ? STATE_REGISTRY[input.state]?.name || input.state + ? STATE_INDEX[input.state]?.name || input.state : "—"; // Slice percentages for the stacked bar. diff --git a/components/gc-directory/gc-search-filters.tsx b/components/gc-directory/gc-search-filters.tsx index 2010502..a299f8a 100644 --- a/components/gc-directory/gc-search-filters.tsx +++ b/components/gc-directory/gc-search-filters.tsx @@ -9,7 +9,7 @@ import { type ProjectType, type GcSize, } from "@/content/gc-directory"; -import { STATE_REGISTRY } from "@/content/state-registry"; +import { STATE_INDEX } from "@/content/state-index"; export interface GcFilters { search: string; @@ -33,7 +33,7 @@ interface GcSearchFiltersProps { resultCount: number; } -const STATES = Object.values(STATE_REGISTRY).sort((a, b) => +const STATES = Object.values(STATE_INDEX).sort((a, b) => a.name.localeCompare(b.name), ); diff --git a/components/onboarding/onboarding-chat.tsx b/components/onboarding/onboarding-chat.tsx index 40b5949..9f02243 100644 --- a/components/onboarding/onboarding-chat.tsx +++ b/components/onboarding/onboarding-chat.tsx @@ -10,7 +10,7 @@ import { MarkdownRenderer } from "@/components/ui/markdown-renderer"; import { MatrixRain } from "@/components/ui/matrix-rain"; import { TronGrid } from "@/components/ui/tron-grid"; import { DecryptedText } from "@/components/ui/decrypted-text"; -import { STATE_REGISTRY } from "@/content/state-registry"; +import { STATE_INDEX } from "@/content/state-index"; import { saveUserState } from "@/lib/store/user-profile"; import type { UserProfile } from "@/lib/types/wizard"; import { DEFAULT_PROFILE, DEFAULT_STATE } from "@/lib/types/wizard"; @@ -201,8 +201,8 @@ export function OnboardingChat({ onExit }: OnboardingChatProps) { */ function fallbackQuestionFor(nextStage: Stage, updated: UserProfile): string { const stateName = - updated.state && STATE_REGISTRY[updated.state] - ? STATE_REGISTRY[updated.state].name + updated.state && STATE_INDEX[updated.state] + ? STATE_INDEX[updated.state].name : ""; switch (nextStage) { case "experience": @@ -224,8 +224,8 @@ export function OnboardingChat({ onExit }: OnboardingChatProps) { function buildSummary(p: UserProfile): string { const stateName = - p.state && STATE_REGISTRY[p.state] - ? STATE_REGISTRY[p.state].name + p.state && STATE_INDEX[p.state] + ? STATE_INDEX[p.state].name : "—"; const lines = [ "**Here's your profile:**", diff --git a/components/wizard/progress-sidebar.tsx b/components/wizard/progress-sidebar.tsx index af72bed..cfc66be 100644 --- a/components/wizard/progress-sidebar.tsx +++ b/components/wizard/progress-sidebar.tsx @@ -5,7 +5,7 @@ import { PHASE_DEFINITIONS, getPhaseContent } from "@/content/phases"; import type { StateCode } from "@/content/phases"; import type { WizardProgress } from "@/lib/types/wizard"; -import { STATE_REGISTRY } from "@/content/state-registry"; +import { STATE_INDEX } from "@/content/state-index"; import Link from "next/link"; import { ChevronLeft, ChevronRight, Check, Circle, GitCompare } from "lucide-react"; import { IBeamIcon } from "@/components/ui/ibeam-icon"; @@ -177,8 +177,8 @@ export function ProgressSidebar({
- {STATE_REGISTRY[userState] - ? `${STATE_REGISTRY[userState].emoji} ${STATE_REGISTRY[userState].name.toUpperCase()}` + {STATE_INDEX[userState] + ? `${STATE_INDEX[userState].emoji} ${STATE_INDEX[userState].name.toUpperCase()}` : userState}