diff --git a/.gitignore b/.gitignore
index ab4f43e..b3b938d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,3 +26,7 @@ dist-ssr
# Cloned repos used for research
policyengine-uk-data
+
+# Pre-built metadata (generated at build time, ~58MB each)
+public/metadata-*.json
+public/param-tree-*.json
diff --git a/package.json b/package.json
index 0f585b0..acbf3e1 100644
--- a/package.json
+++ b/package.json
@@ -5,7 +5,8 @@
"type": "module",
"scripts": {
"dev": "vite",
- "build": "tsc -b && vite build",
+ "fetch-metadata": "node scripts/fetch-metadata.js",
+ "build": "node scripts/fetch-metadata.js && tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest run",
diff --git a/scripts/fetch-metadata.js b/scripts/fetch-metadata.js
new file mode 100644
index 0000000..10833b2
--- /dev/null
+++ b/scripts/fetch-metadata.js
@@ -0,0 +1,111 @@
+/**
+ * Pre-fetches metadata from the PolicyEngine API and the GitHub repo file tree,
+ * writing them to public/ so the app loads from CDN instead of hitting APIs at runtime.
+ *
+ * Run: node scripts/fetch-metadata.js
+ * Called automatically during `bun run build`.
+ */
+
+import { writeFileSync, mkdirSync } from 'fs';
+import { join, dirname } from 'path';
+import { fileURLToPath } from 'url';
+import { execSync } from 'child_process';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const PUBLIC_DIR = join(__dirname, '..', 'public');
+
+const COUNTRIES = ['us', 'uk'];
+const API_BASE = 'https://api.policyengine.org';
+
+const REPO_MAP = {
+ us: 'policyengine-us',
+ uk: 'policyengine-uk',
+};
+
+async function fetchCountryMetadata(country) {
+ const url = `${API_BASE}/${country}/metadata`;
+ console.log(`Fetching ${country} metadata from ${url}...`);
+
+ const start = Date.now();
+ const res = await fetch(url);
+ if (!res.ok) throw new Error(`${country}: API returned ${res.status}`);
+
+ const data = await res.json();
+ const result = data.result ?? data;
+ const elapsed = ((Date.now() - start) / 1000).toFixed(1);
+ console.log(` ${country}: fetched in ${elapsed}s`);
+
+ return result;
+}
+
+/**
+ * Fetch the list of .yaml files under parameters/ in the repo.
+ * Uses `gh` CLI → Git Trees API (fetches only the parameters subtree).
+ */
+function fetchParameterYamlPaths(country) {
+ const repo = REPO_MAP[country];
+ const repoDir = repo.replace('-', '_');
+ console.log(` ${country}: fetching parameter file tree from ${repo}...`);
+
+ try {
+ // Get the SHA of policyengine_{country}/parameters/ subtree
+ const topTree = execSync(
+ `gh api repos/PolicyEngine/${repo}/git/trees/main -q '.tree[] | select(.path == "${repoDir}") | .sha'`,
+ { encoding: 'utf-8' },
+ ).trim();
+
+ const paramsTree = execSync(
+ `gh api "repos/PolicyEngine/${repo}/git/trees/${topTree}" -q '.tree[] | select(.path == "parameters") | .sha'`,
+ { encoding: 'utf-8' },
+ ).trim();
+
+ // Fetch recursive tree of parameters/ and filter to .yaml files
+ const output = execSync(
+ `gh api "repos/PolicyEngine/${repo}/git/trees/${paramsTree}?recursive=1" --paginate -q '.tree[] | select(.type == "blob") | select(.path | endswith(".yaml")) | .path'`,
+ { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 },
+ );
+
+ const paths = output.trim().split('\n')
+ .filter(p => p && !p.startsWith('calibration/'));
+
+ console.log(` ${country}: found ${paths.length} parameter YAML files`);
+ return paths;
+ } catch (err) {
+ console.error(` ${country}: gh CLI failed — ${err.message}`);
+ return null;
+ }
+}
+
+async function main() {
+ mkdirSync(PUBLIC_DIR, { recursive: true });
+
+ for (const country of COUNTRIES) {
+ try {
+ const metadata = await fetchCountryMetadata(country);
+
+ const outPath = join(PUBLIC_DIR, `metadata-${country}.json`);
+ writeFileSync(outPath, JSON.stringify(metadata));
+
+ const sizeMB = (Buffer.byteLength(JSON.stringify(metadata)) / 1024 / 1024).toFixed(1);
+ console.log(` ${country}: wrote ${outPath} (${sizeMB} MB)`);
+ } catch (err) {
+ console.error(` ${country}: metadata FAILED — ${err.message}`);
+ }
+
+ // Fetch parameter YAML file tree for direct source links
+ try {
+ const yamlPaths = fetchParameterYamlPaths(country);
+ if (yamlPaths) {
+ const treePath = join(PUBLIC_DIR, `param-tree-${country}.json`);
+ writeFileSync(treePath, JSON.stringify(yamlPaths));
+ console.log(` ${country}: wrote ${treePath} (${yamlPaths.length} files)`);
+ }
+ } catch (err) {
+ console.error(` ${country}: param tree FAILED — ${err.message}`);
+ }
+ }
+
+ console.log('Done.');
+}
+
+main();
diff --git a/src/components/parameters/ParameterCard.tsx b/src/components/parameters/ParameterCard.tsx
new file mode 100644
index 0000000..5dce81b
--- /dev/null
+++ b/src/components/parameters/ParameterCard.tsx
@@ -0,0 +1,141 @@
+import type { ParameterLeaf, ParameterNode } from '../../types/Variable';
+import { colors, typography, spacing } from '../../designTokens';
+
+interface ParameterCardProps {
+ parameter: ParameterLeaf | ParameterNode;
+ isSelected: boolean;
+ onClick: () => void;
+}
+
+function getCurrentValue(param: ParameterLeaf): { text: string; isList: boolean; count: number } {
+ const entries = Object.entries(param.values).sort(([a], [b]) => b.localeCompare(a));
+ if (entries.length === 0) return { text: '—', isList: false, count: 0 };
+ const [, val] = entries[0];
+ // Handle arrays (list-type parameters)
+ if (Array.isArray(val)) {
+ if (val.length === 0) return { text: '(empty)', isList: true, count: 0 };
+ return { text: val.join(', '), isList: true, count: val.length };
+ }
+ if (typeof val === 'boolean') return { text: val ? 'true' : 'false', isList: false, count: 0 };
+ if (typeof val === 'number') {
+ if (param.unit === '/1') return { text: `${(val * 100).toFixed(1)}%`, isList: false, count: 0 };
+ return { text: val.toLocaleString(), isList: false, count: 0 };
+ }
+ const str = String(val);
+ if (str.includes(',')) {
+ const items = str.split(',').map(s => s.trim()).filter(Boolean);
+ return { text: str, isList: true, count: items.length };
+ }
+ return { text: str, isList: false, count: 0 };
+}
+
+export default function ParameterCard({ parameter: param, isSelected, onClick }: ParameterCardProps) {
+ const isLeaf = param.type === 'parameter';
+
+ return (
+
+ );
+}
diff --git a/src/components/parameters/ParameterDetail.tsx b/src/components/parameters/ParameterDetail.tsx
new file mode 100644
index 0000000..c9ca9f0
--- /dev/null
+++ b/src/components/parameters/ParameterDetail.tsx
@@ -0,0 +1,353 @@
+import { useState, useEffect } from 'react';
+import { motion } from 'framer-motion';
+import { IconExternalLink, IconChevronDown, IconChevronRight } from '@tabler/icons-react';
+import type { Parameter, ParameterLeaf } from '../../types/Variable';
+import { colors, typography, spacing } from '../../designTokens';
+
+interface ParameterDetailProps {
+ parameter: Parameter;
+ country: string;
+}
+
+function formatUnit(unit: string | null): string {
+ if (!unit) return 'none';
+ if (unit === 'currency-USD') return 'USD ($)';
+ if (unit === 'currency-GBP') return 'GBP (£)';
+ if (unit === '/1') return 'ratio (0–1)';
+ return unit;
+}
+
+function MetaRow({ label, value }: { label: string; value: string }) {
+ return (
+
+
+ {label}
+
+
+ {value}
+
+
+ );
+}
+
+function formatValue(val: number | string | boolean | string[], unit: string | null): { text: string; items: string[] | null } {
+ // Handle arrays (list-type parameters)
+ if (Array.isArray(val)) {
+ if (val.length === 0) return { text: '(empty)', items: [] };
+ return { text: val.join(', '), items: val };
+ }
+ if (typeof val === 'boolean') return { text: val ? 'true' : 'false', items: null };
+ if (typeof val === 'number') {
+ if (unit === '/1') return { text: `${(val * 100).toFixed(2)}%`, items: null };
+ if (unit?.startsWith('currency-')) return { text: val.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }), items: null };
+ return { text: val.toLocaleString(), items: null };
+ }
+ const str = String(val);
+ if (str.includes(',')) {
+ const items = str.split(',').map(s => s.trim()).filter(Boolean);
+ return { text: str, items };
+ }
+ return { text: str, items: null };
+}
+
+const PROJECTION_CUTOFF = '2026-01-01';
+
+function ValueRow({ date, val, nextDate, unit, isProjected }: {
+ date: string;
+ val: number | string | boolean | string[];
+ nextDate: string | null;
+ unit: string | null;
+ isProjected: boolean;
+ isLast?: boolean;
+}) {
+ const { text, items } = formatValue(val, unit);
+ const dateLabel = nextDate ? `${date} → ${nextDate}` : `${date} → present`;
+
+ return (
+
+
+
+ {dateLabel}
+
+ {!items && (
+
+ {text}
+
+ )}
+ {items && (
+
+ {items.length} items
+
+ )}
+
+ {items && (
+
+ {items.map((item) => (
+
+ {item.replace(/_/g, ' ')}
+
+ ))}
+
+ )}
+
+ );
+}
+
+function ValueTimeline({ param }: { param: ParameterLeaf }) {
+ const allEntries = Object.entries(param.values).sort(([a], [b]) => a.localeCompare(b));
+ if (allEntries.length === 0) return null;
+
+ const legislated = allEntries.filter(([d]) => d < PROJECTION_CUTOFF);
+ const projected = allEntries.filter(([d]) => d >= PROJECTION_CUTOFF);
+ const hasProjected = projected.length > 1; // Only collapse if there are multiple projected entries
+
+ const [showProjected, setShowProjected] = useState(false);
+
+ return (
+
+
+ Value history
+
+
+ {/* Legislated values */}
+ {legislated.map(([date, val], i) => {
+ const nextDate = i < legislated.length - 1
+ ? legislated[i + 1][0]
+ : projected.length > 0 ? projected[0][0] : null;
+ return (
+
+
+
+ );
+ })}
+
+ {/* Projected values section */}
+ {hasProjected && (
+ <>
+
+ {showProjected && projected.map(([date, val], i) => {
+ const nextDate = i < projected.length - 1 ? projected[i + 1][0] : null;
+ return (
+
+
+
+ );
+ })}
+ >
+ )}
+
+ {/* Single projected entry (no collapse needed) */}
+ {projected.length === 1 && (
+
+
+
+ )}
+
+
+ );
+}
+
+// ─── Parameter tree cache (loaded once per country) ──────────────────────────
+
+const treeCache = new Map | null>();
+
+async function loadParamTree(country: string): Promise | null> {
+ if (treeCache.has(country)) return treeCache.get(country)!;
+ try {
+ const res = await fetch(`${import.meta.env.BASE_URL}param-tree-${country}.json`);
+ if (!res.ok) throw new Error('not found');
+ const paths: string[] = await res.json();
+ const set = new Set(paths);
+ treeCache.set(country, set);
+ return set;
+ } catch {
+ treeCache.set(country, null);
+ return null;
+ }
+}
+
+/**
+ * Find the exact YAML file for a parameter path by trying progressively
+ * shorter path prefixes against the pre-built file tree.
+ */
+function findYamlFile(paramPath: string, yamlFiles: Set): string | null {
+ const parts = paramPath.replace(/\./g, '/').split('/');
+ for (let i = parts.length; i > 0; i--) {
+ const candidate = parts.slice(0, i).join('/') + '.yaml';
+ if (yamlFiles.has(candidate)) return candidate;
+ }
+ return null;
+}
+
+function getGitHubUrl(paramPath: string, githubRepo: string, yamlFile: string | null): string {
+ const repoDir = githubRepo.replace('-', '_');
+ if (yamlFile) {
+ return `https://github.com/PolicyEngine/${githubRepo}/blob/main/${repoDir}/parameters/${yamlFile}`;
+ }
+ // Fallback: link to parent directory
+ const parts = paramPath.split('.');
+ while (parts.length > 1 && (/^[A-Z_]+$/.test(parts[parts.length - 1]) || /^\d+$/.test(parts[parts.length - 1]))) {
+ parts.pop();
+ }
+ const dirParts = parts.slice(0, Math.max(parts.length - 2, 1));
+ return `https://github.com/PolicyEngine/${githubRepo}/tree/main/${repoDir}/parameters/${dirParts.join('/')}`;
+}
+
+export default function ParameterDetail({ parameter: param, country }: ParameterDetailProps) {
+ const isLeaf = param.type === 'parameter';
+ const githubRepo = country === 'uk' ? 'policyengine-uk' : 'policyengine-us';
+
+ const [yamlFile, setYamlFile] = useState(null);
+ const [treeLoaded, setTreeLoaded] = useState(false);
+
+ useEffect(() => {
+ loadParamTree(country).then((tree) => {
+ if (tree) {
+ setYamlFile(findYamlFile(param.parameter, tree));
+ }
+ setTreeLoaded(true);
+ });
+ }, [country, param.parameter]);
+
+ const sourceUrl = treeLoaded ? getGitHubUrl(param.parameter, githubRepo, yamlFile) : '#';
+
+ return (
+
+
+ {/* Description */}
+ {param.description && (
+
+ {param.description}
+
+ )}
+
+ {/* Metadata */}
+
+
+
+ {isLeaf && }
+ {isLeaf && (param as ParameterLeaf).period && (
+
+ )}
+
+
+ {/* Value history */}
+ {isLeaf &&
}
+
+ {/* Links */}
+
+
+
+ );
+}
diff --git a/src/components/parameters/ParameterExplorer.tsx b/src/components/parameters/ParameterExplorer.tsx
new file mode 100644
index 0000000..14034f5
--- /dev/null
+++ b/src/components/parameters/ParameterExplorer.tsx
@@ -0,0 +1,1161 @@
+import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { IconSearch, IconArrowLeft, IconFolder, IconChevronLeft, IconChevronRight } from '@tabler/icons-react';
+import type { Parameter, ParameterLeaf } from '../../types/Variable';
+import { colors, typography, spacing } from '../../designTokens';
+import { useDebounce } from '../../hooks/useDebounce';
+import {
+ getLevel, getSubGroup, getSubGroupLabel, getSubGroupDescription,
+ LEVEL_CONFIG, LEVELS_ORDERED,
+ type Level,
+} from '../shared/categoryUtils';
+import ParameterCard from './ParameterCard';
+import ParameterDetail from './ParameterDetail';
+
+const PAGE_SIZE = 50;
+
+// ─── Pagination ─────────────────────────────────────────────────────────────
+
+function Pagination({ page, totalPages, onPageChange }: {
+ page: number;
+ totalPages: number;
+ onPageChange: (page: number) => void;
+}) {
+ if (totalPages <= 1) return null;
+
+ // Build page numbers: always show first, last, current ± 2, with ellipsis gaps
+ const pages: (number | '...')[] = [];
+ const addPage = (p: number) => {
+ if (p >= 1 && p <= totalPages && !pages.includes(p)) pages.push(p);
+ };
+
+ addPage(1);
+ for (let i = Math.max(2, page - 2); i <= Math.min(totalPages - 1, page + 2); i++) {
+ addPage(i);
+ }
+ addPage(totalPages);
+
+ // Insert ellipsis where there are gaps
+ const withEllipsis: (number | '...')[] = [];
+ for (let i = 0; i < pages.length; i++) {
+ const p = pages[i];
+ if (i > 0 && typeof p === 'number' && typeof pages[i - 1] === 'number' && p - (pages[i - 1] as number) > 1) {
+ withEllipsis.push('...');
+ }
+ withEllipsis.push(p);
+ }
+
+ const btnBase: React.CSSProperties = {
+ minWidth: '32px',
+ height: '32px',
+ borderRadius: spacing.radius.md,
+ border: `1px solid ${colors.border.light}`,
+ backgroundColor: colors.white,
+ fontFamily: typography.fontFamily.primary,
+ fontSize: typography.fontSize.xs,
+ fontWeight: typography.fontWeight.medium,
+ cursor: 'pointer',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: `0 ${spacing.sm}`,
+ };
+
+ return (
+
+
+ {withEllipsis.map((p, i) =>
+ p === '...' ? (
+
+ ...
+
+ ) : (
+
+ ),
+ )}
+
+
+ );
+}
+
+// ─── Breadcrumb navigation ──────────────────────────────────────────────────
+
+function BreadcrumbNav({ items, onBack }: {
+ items: { label: string; onClick: () => void }[];
+ onBack: () => void;
+}) {
+ return (
+
+
+ {items.map((item, i) => {
+ const isLast = i === items.length - 1;
+ return (
+
+ {i > 0 && (
+ /
+ )}
+ {isLast ? (
+
+ {item.label}
+
+ ) : (
+
+ )}
+
+ );
+ })}
+
+ );
+}
+
+interface ParameterExplorerProps {
+ parameters: Record;
+ country: string;
+}
+
+/** Count unique file-level parameter groups in a list of leaves. */
+function countUniqueGroups(params: ParameterLeaf[]): number {
+ const seen = new Set();
+ for (const p of params) seen.add(getParameterGroup(p.parameter));
+ return seen.size;
+}
+
+/** Map a parameter path to its logical file-level group.
+ * Strips bracket indices (e.g. [0].amount), trailing ALL_CAPS enum values,
+ * and trailing pure-numeric segments so that all leaves from one YAML file
+ * collapse into a single group. */
+function getParameterGroup(path: string): string {
+ // Strip bracket indices and everything after them
+ const clean = path.replace(/\[\d+\].*/g, '');
+ const parts = clean.split('.');
+ // Strip trailing ALL_CAPS enum values and pure-numeric segments
+ while (parts.length > 1 && (/^[A-Z][A-Z_0-9]+$/.test(parts[parts.length - 1]) || /^\d+$/.test(parts[parts.length - 1]))) {
+ parts.pop();
+ }
+ return parts.join('.');
+}
+
+/** Get a display label for a parameter group or folder, using the parameterNode if available. */
+function getGroupLabel(groupKey: string, allParameters: Record): string {
+ const node = allParameters[groupKey];
+ if (node && node.label) return node.label;
+ const parts = groupKey.split('.');
+ return parts[parts.length - 1].replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
+}
+
+function getGroupDescription(groupKey: string, allParameters: Record): string | null {
+ const node = allParameters[groupKey];
+ return node?.description || null;
+}
+
+// ─── Folder tree helpers ────────────────────────────────────────────────────
+
+/** Find the longest common dot-separated prefix of a list of paths. */
+function commonPrefix(paths: string[]): string {
+ if (paths.length === 0) return '';
+ const parts0 = paths[0].split('.');
+ let len = parts0.length;
+ for (const p of paths.slice(1)) {
+ const parts = p.split('.');
+ len = Math.min(len, parts.length);
+ for (let i = 0; i < len; i++) {
+ if (parts[i] !== parts0[i]) { len = i; break; }
+ }
+ }
+ return parts0.slice(0, len).join('.');
+}
+
+/**
+ * At a given folder prefix, compute sub-folders and direct parameter groups.
+ * If a segment appears both as a direct group and has deeper groups, it becomes a folder.
+ */
+function getFolderContents(
+ groups: [string, ParameterLeaf[]][],
+ prefix: string,
+): {
+ folders: { segment: string; fullPath: string; paramCount: number; groupCount: number }[];
+ directGroups: [string, ParameterLeaf[]][];
+} {
+ const prefixDot = prefix + '.';
+
+ // Collect all groups by their next segment
+ const bySegment = new Map();
+
+ for (const [groupKey, params] of groups) {
+ if (!groupKey.startsWith(prefixDot)) continue;
+ const remaining = groupKey.slice(prefixDot.length);
+ const segments = remaining.split('.');
+ const segment = segments[0];
+
+ if (!bySegment.has(segment)) bySegment.set(segment, { direct: null, deeper: [] });
+ const entry = bySegment.get(segment)!;
+
+ if (segments.length === 1) {
+ entry.direct = [groupKey, params];
+ } else {
+ entry.deeper.push([groupKey, params]);
+ }
+ }
+
+ const folders: { segment: string; fullPath: string; paramCount: number; groupCount: number }[] = [];
+ const directGroups: [string, ParameterLeaf[]][] = [];
+
+ // Also include a group that exactly matches the prefix (params at this node level)
+ const exactGroup = groups.find(([k]) => k === prefix);
+ if (exactGroup) {
+ directGroups.push(exactGroup);
+ }
+
+ for (const [segment, entry] of [...bySegment.entries()].sort(([a], [b]) => a.localeCompare(b))) {
+ const fullPath = `${prefix}.${segment}`;
+
+ if (entry.deeper.length > 0) {
+ // It's a folder — include any direct group params in the count
+ let paramCount = entry.deeper.reduce((sum, [, p]) => sum + p.length, 0);
+ let groupCount = entry.deeper.length;
+ if (entry.direct) {
+ paramCount += entry.direct[1].length;
+ groupCount += 1;
+ }
+ folders.push({ segment, fullPath, paramCount, groupCount });
+ } else if (entry.direct) {
+ directGroups.push(entry.direct);
+ }
+ }
+
+ return { folders, directGroups };
+}
+
+/** Auto-collapse: skip folder levels that have exactly 1 sub-folder and 0 direct groups. */
+function autoCollapseFolder(
+ groups: [string, ParameterLeaf[]][],
+ prefix: string,
+): string {
+ let current = prefix;
+ for (let i = 0; i < 10; i++) {
+ const { folders, directGroups } = getFolderContents(groups, current);
+ if (folders.length === 1 && directGroups.length === 0) {
+ current = folders[0].fullPath;
+ } else {
+ break;
+ }
+ }
+ return current;
+}
+
+// ─── Parameter list (shown after selecting a group) ─────────────────────────
+
+function ParameterList({
+ params,
+ country,
+ selectedParam,
+ onSelect,
+}: {
+ params: Parameter[];
+ country: string;
+ selectedParam: string | null;
+ onSelect: (name: string) => void;
+}) {
+ const [page, setPage] = useState(1);
+ const totalPages = Math.ceil(params.length / PAGE_SIZE);
+ const visible = params.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
+
+ return (
+
+
+ {visible.map((p) => (
+
+
onSelect(p.parameter)}
+ />
+
+ {selectedParam === p.parameter && (
+
+ )}
+
+
+ ))}
+
+
+
+ );
+}
+
+// ─── State tile grid ─────────────────────────────────────────────────────────
+
+function StateTileGrid({
+ subGroups,
+ levelColor,
+ onSelect,
+}: {
+ subGroups: [string, ParameterLeaf[]][];
+ levelColor: string;
+ onSelect: (key: string) => void;
+}) {
+ const states = subGroups.filter(([k]) => k !== 'Cross-state' && k.length === 2);
+ const other = subGroups.filter(([k]) => k === 'Cross-state' || k.length !== 2);
+ return (
+
+
+ {states.map(([key, params]) => {
+ return (
+
onSelect(key)}
+ initial={{ opacity: 0, scale: 0.9 }}
+ animate={{ opacity: 1, scale: 1 }}
+ transition={{ duration: 0.2 }}
+ className="tw:cursor-pointer tw:text-center"
+ style={{
+ padding: spacing.md,
+ borderRadius: spacing.radius.lg,
+ border: `1px solid ${colors.border.light}`,
+ backgroundColor: colors.white,
+ fontFamily: typography.fontFamily.primary,
+ transition: 'border-color 0.15s ease, box-shadow 0.15s ease',
+ }}
+ whileHover={{
+ borderColor: levelColor,
+ boxShadow: `0 0 0 1px ${levelColor}25`,
+ }}
+ >
+
+ {key}
+
+
+ {countUniqueGroups(params).toLocaleString()}
+
+
+ );
+ })}
+
+ {other.length > 0 && (
+
+ {other.map(([key, params]) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+// ─── Sub-group card grid ──────────────────────────────────────────────────────
+
+function SubGroupCardGrid({
+ subGroups,
+ level,
+ levelColor,
+ onSelect,
+}: {
+ subGroups: [string, ParameterLeaf[]][];
+ level: Level;
+ levelColor: string;
+ onSelect: (key: string) => void;
+}) {
+ return (
+
+ {subGroups.map(([key, params], i) => {
+ const label = getSubGroupLabel(key, level);
+ const desc = getSubGroupDescription(key, level);
+ return (
+
onSelect(key)}
+ initial={{ opacity: 0, y: 8 }}
+ animate={{ opacity: 1, y: 0 }}
+ transition={{ duration: 0.2, delay: i * 0.03 }}
+ className="tw:text-left tw:cursor-pointer"
+ style={{
+ padding: spacing.lg,
+ borderRadius: spacing.radius.xl,
+ border: `1px solid ${colors.border.light}`,
+ backgroundColor: colors.white,
+ fontFamily: typography.fontFamily.primary,
+ transition: 'border-color 0.15s ease, box-shadow 0.15s ease',
+ }}
+ whileHover={{
+ borderColor: levelColor,
+ boxShadow: `0 0 0 1px ${levelColor}25`,
+ }}
+ >
+
+ {label}
+
+
+ {countUniqueGroups(params).toLocaleString()}
+
+ {desc && (
+
+ {desc}
+
+ )}
+
+ );
+ })}
+
+ );
+}
+
+// ─── Folder contents grid (sub-folders + direct parameter groups) ───────────
+
+function FolderContentsGrid({
+ folders,
+ directGroups,
+ levelColor,
+ allParameters,
+ onFolderSelect,
+ onGroupSelect,
+}: {
+ folders: { segment: string; fullPath: string; paramCount: number; groupCount: number }[];
+ directGroups: [string, ParameterLeaf[]][];
+ levelColor: string;
+ allParameters: Record;
+ onFolderSelect: (fullPath: string) => void;
+ onGroupSelect: (key: string) => void;
+}) {
+ const [page, setPage] = useState(1);
+ const totalItems = folders.length + directGroups.length;
+ const totalPages = Math.ceil(totalItems / PAGE_SIZE);
+ const allItems: ({ kind: 'folder' } & typeof folders[number] | { kind: 'group'; key: string; params: ParameterLeaf[] })[] = [
+ ...folders.map(f => ({ kind: 'folder' as const, ...f })),
+ ...directGroups.map(([key, params]) => ({ kind: 'group' as const, key, params })),
+ ];
+ const visible = allItems.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
+
+ return (
+
+
+ {visible.map((item, i) => {
+ if (item.kind === 'folder') {
+ const label = getGroupLabel(item.fullPath, allParameters);
+ const desc = getGroupDescription(item.fullPath, allParameters);
+ return (
+
onFolderSelect(item.fullPath)}
+ initial={{ opacity: 0, y: 6 }}
+ animate={{ opacity: 1, y: 0 }}
+ transition={{ duration: 0.15, delay: i * 0.01 }}
+ className="tw:text-left tw:cursor-pointer"
+ style={{
+ padding: `${spacing.md} ${spacing.lg}`,
+ borderRadius: spacing.radius.lg,
+ border: `1px solid ${colors.border.light}`,
+ backgroundColor: colors.white,
+ fontFamily: typography.fontFamily.primary,
+ transition: 'border-color 0.15s ease, box-shadow 0.15s ease',
+ }}
+ whileHover={{
+ borderColor: levelColor,
+ boxShadow: `0 0 0 1px ${levelColor}25`,
+ }}
+ >
+
+
+
+ {desc && (
+
+ {desc}
+
+ )}
+
+
+ {item.groupCount} params
+
+
+
+ );
+ } else {
+ const label = getGroupLabel(item.key, allParameters);
+ const desc = getGroupDescription(item.key, allParameters);
+ const isSingle = item.params.length === 1;
+ return (
+
onGroupSelect(item.key)}
+ initial={{ opacity: 0, y: 6 }}
+ animate={{ opacity: 1, y: 0 }}
+ transition={{ duration: 0.15, delay: i * 0.01 }}
+ className="tw:text-left tw:cursor-pointer"
+ style={{
+ padding: `${spacing.md} ${spacing.lg}`,
+ borderRadius: spacing.radius.lg,
+ border: `1px solid ${colors.border.light}`,
+ backgroundColor: colors.white,
+ fontFamily: typography.fontFamily.primary,
+ transition: 'border-color 0.15s ease, box-shadow 0.15s ease',
+ }}
+ whileHover={{
+ borderColor: levelColor,
+ boxShadow: `0 0 0 1px ${levelColor}25`,
+ }}
+ >
+
+
+
+ {label}
+
+
+ {item.key}
+
+ {desc && (
+
+ {desc}
+
+ )}
+
+ {!isSingle && (
+
+ {item.params.length} values
+
+ )}
+
+
+ );
+ }
+ })}
+
+
+
+ );
+}
+
+// ─── Main explorer ───────────────────────────────────────────────────────────
+
+export default function ParameterExplorer({ parameters, country }: ParameterExplorerProps) {
+ const [search, setSearch] = useState('');
+ const [activeLevel, setActiveLevel] = useState(null);
+ const [activeSubGroup, setActiveSubGroup] = useState(null);
+ const [folderStack, setFolderStack] = useState([]);
+ const [activeGroup, setActiveGroup] = useState(null);
+ const [selectedParam, setSelectedParam] = useState(null);
+ const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
+ const sentinelRef = useRef(null);
+
+ const debouncedSearch = useDebounce(search, 200);
+ const isSearching = !!debouncedSearch;
+
+ // Only show leaf parameters (actual values), exclude nodes and abolitions
+ const allParameters = useMemo(() => {
+ return Object.values(parameters)
+ .filter((p): p is ParameterLeaf => p.type === 'parameter' && !p.parameter.includes('.abolition'))
+ .sort((a, b) => (a.label ?? '').localeCompare(b.label ?? ''));
+ }, [parameters]);
+
+ // Count unique parameter groups (files) per level
+ const levelCounts = useMemo(() => {
+ const seen: Record> = { federal: new Set(), state: new Set(), local: new Set(), territory: new Set(), reform: new Set(), household: new Set() };
+ for (const p of allParameters) {
+ seen[getLevel(p.parameter)].add(getParameterGroup(p.parameter));
+ }
+ return Object.fromEntries(Object.entries(seen).map(([k, v]) => [k, v.size])) as Record;
+ }, [allParameters]);
+
+ // Filtered list
+ const filtered = useMemo(() => {
+ let result: ParameterLeaf[] = allParameters;
+
+ if (debouncedSearch) {
+ const words = debouncedSearch.toLowerCase().split(/\s+/).filter(Boolean);
+ result = result.filter((p) => {
+ const haystack = [p.parameter, p.label, p.description || '', p.unit || ''].join(' ').toLowerCase();
+ return words.every((w) => haystack.includes(w));
+ });
+ }
+
+ if (activeLevel) result = result.filter((p) => getLevel(p.parameter) === activeLevel);
+
+ return result;
+ }, [allParameters, debouncedSearch, activeLevel]);
+
+ // Sub-groups for the active level
+ const subGroups = useMemo(() => {
+ if (!activeLevel) return [];
+ const map = new Map();
+ for (const p of filtered) {
+ const key = getSubGroup(p.parameter);
+ if (!map.has(key)) map.set(key, []);
+ map.get(key)!.push(p);
+ }
+ return [...map.entries()].sort(([a], [b]) => a.localeCompare(b));
+ }, [filtered, activeLevel]);
+
+ // Parameters in active sub-group
+ const subGroupParams = useMemo(() => {
+ if (!activeSubGroup) return [];
+ const entry = subGroups.find(([k]) => k === activeSubGroup);
+ return entry ? entry[1] : [];
+ }, [subGroups, activeSubGroup]);
+
+ // Parameter groups within the active sub-group
+ const parameterGroups = useMemo(() => {
+ if (!activeSubGroup) return [];
+ const map = new Map();
+ for (const p of subGroupParams) {
+ const key = getParameterGroup(p.parameter);
+ if (!map.has(key)) map.set(key, []);
+ map.get(key)!.push(p);
+ }
+ return [...map.entries()].sort(([a], [b]) => a.localeCompare(b));
+ }, [subGroupParams, activeSubGroup]);
+
+ // Root folder prefix: common prefix of all groups, auto-collapsed
+ const rootFolderPrefix = useMemo(() => {
+ if (parameterGroups.length === 0) return '';
+ const prefix = commonPrefix(parameterGroups.map(([k]) => k));
+ return autoCollapseFolder(parameterGroups, prefix);
+ }, [parameterGroups]);
+
+ // Current folder: last in stack, or root
+ const currentFolder = folderStack.length > 0 ? folderStack[folderStack.length - 1] : rootFolderPrefix;
+
+ // Folder contents at current level
+ const folderContents = useMemo(() => {
+ if (!activeSubGroup || !currentFolder) return { folders: [], directGroups: [] };
+ return getFolderContents(parameterGroups, currentFolder);
+ }, [parameterGroups, currentFolder, activeSubGroup]);
+
+ // Parameters in active group
+ const activeGroupParams = useMemo(() => {
+ if (!activeGroup) return [];
+ const entry = parameterGroups.find(([k]) => k === activeGroup);
+ return entry ? entry[1] : [];
+ }, [parameterGroups, activeGroup]);
+
+ // Reset folder stack when sub-group changes
+ useEffect(() => {
+ setFolderStack([]);
+ setActiveGroup(null);
+ setSelectedParam(null);
+ }, [activeSubGroup]);
+
+ // Reset on filter changes
+ useEffect(() => {
+ setVisibleCount(PAGE_SIZE);
+ }, [debouncedSearch, activeLevel, activeSubGroup, activeGroup]);
+
+ // Infinite scroll for search mode
+ useEffect(() => {
+ if (!isSearching) return;
+ const sentinel = sentinelRef.current;
+ if (!sentinel) return;
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries[0].isIntersecting) {
+ setVisibleCount((prev) => Math.min(prev + PAGE_SIZE, filtered.length));
+ }
+ },
+ { rootMargin: '200px' },
+ );
+ observer.observe(sentinel);
+ return () => observer.disconnect();
+ }, [filtered.length, isSearching]);
+
+ const handleSelect = useCallback((name: string) => {
+ setSelectedParam((prev) => (prev === name ? null : name));
+ }, []);
+
+ const visibleFlat = filtered.slice(0, visibleCount);
+
+ // Navigation helpers
+ const goBack = () => {
+ if (activeGroup) {
+ setActiveGroup(null);
+ setSelectedParam(null);
+ } else if (folderStack.length > 0) {
+ setFolderStack((prev) => prev.slice(0, -1));
+ } else if (activeSubGroup) {
+ setActiveSubGroup(null);
+ setSelectedParam(null);
+ } else {
+ setActiveLevel(null);
+ setSelectedParam(null);
+ }
+ };
+
+ const handleFolderSelect = (fullPath: string) => {
+ const collapsed = autoCollapseFolder(parameterGroups, fullPath);
+ setFolderStack((prev) => [...prev, collapsed]);
+ };
+
+ const handleGroupSelect = (key: string) => {
+ const entry = parameterGroups.find(([k]) => k === key);
+ if (entry && entry[1].length === 1) {
+ setActiveGroup(key);
+ setSelectedParam(entry[1][0].parameter);
+ } else {
+ setActiveGroup(key);
+ setSelectedParam(null);
+ }
+ };
+
+ // Breadcrumb: each entry has a label and navigation action
+ const breadcrumbItems = useMemo(() => {
+ const items: { label: string; onClick: () => void }[] = [];
+
+ if (activeLevel) {
+ items.push({
+ label: LEVEL_CONFIG[activeLevel].label,
+ onClick: () => { setActiveLevel(activeLevel); setActiveSubGroup(null); setFolderStack([]); setActiveGroup(null); setSelectedParam(null); },
+ });
+ }
+ if (activeSubGroup && activeLevel) {
+ items.push({
+ label: getSubGroupLabel(activeSubGroup, activeLevel),
+ onClick: () => { setFolderStack([]); setActiveGroup(null); setSelectedParam(null); },
+ });
+ }
+ for (let i = 0; i < folderStack.length; i++) {
+ const stackIndex = i;
+ items.push({
+ label: getGroupLabel(folderStack[i], parameters),
+ onClick: () => { setFolderStack((prev) => prev.slice(0, stackIndex + 1)); setActiveGroup(null); setSelectedParam(null); },
+ });
+ }
+ if (activeGroup) {
+ items.push({
+ label: getGroupLabel(activeGroup, parameters),
+ onClick: () => {},
+ });
+ }
+ return items;
+ }, [activeLevel, activeSubGroup, folderStack, activeGroup, parameters]);
+
+ // Current heading label
+ const currentHeadingLabel = activeGroup
+ ? getGroupLabel(activeGroup, parameters)
+ : folderStack.length > 0
+ ? getGroupLabel(currentFolder, parameters)
+ : activeSubGroup && activeLevel
+ ? getSubGroupLabel(activeSubGroup, activeLevel)
+ : '';
+
+ // Param count summary for folder view (count groups, not individual leaves)
+ const currentFolderTotalParams = folderContents.folders.reduce((s, f) => s + f.groupCount, 0)
+ + folderContents.directGroups.length;
+ const currentFolderTotalItems = folderContents.folders.length + folderContents.directGroups.length;
+
+ return (
+
+ {/* Search */}
+
+
+ setSearch(e.target.value)}
+ style={{
+ width: '100%',
+ padding: `${spacing.md} ${spacing.lg} ${spacing.md} ${spacing['3xl']}`,
+ borderRadius: spacing.radius.lg,
+ border: `1px solid ${colors.border.light}`,
+ fontSize: typography.fontSize.sm,
+ fontFamily: typography.fontFamily.primary,
+ outline: 'none',
+ boxSizing: 'border-box',
+ }}
+ />
+
+
+ {/* Search filter pills */}
+ {isSearching && (
+
+
+ {([null, ...LEVELS_ORDERED] as (Level | null)[]).map((level) => {
+ const label = level ? LEVEL_CONFIG[level].label : 'All';
+ const clr = level ? LEVEL_CONFIG[level].color : colors.text.secondary;
+ const isActive = activeLevel === level;
+ return (
+
+ );
+ })}
+
+
+ {countUniqueGroups(filtered).toLocaleString()} parameter{countUniqueGroups(filtered) !== 1 ? 's' : ''}
+
+
+ )}
+
+ {/* ─── View: Search results (flat) ─── */}
+ {isSearching && (
+ <>
+
+ {visibleFlat.map((p) => (
+
+
handleSelect(p.parameter)} />
+
+ {selectedParam === p.parameter && (
+
+ )}
+
+
+ ))}
+
+ {visibleCount < filtered.length &&
}
+ {filtered.length === 0 && (
+
+ No parameters match your search.
+
+ )}
+ >
+ )}
+
+ {/* ─── View: Level overview cards (default) ─── */}
+ {!isSearching && !activeLevel && (
+
+ {LEVELS_ORDERED.map((level) => {
+ const config = LEVEL_CONFIG[level];
+ const count = levelCounts[level];
+ if (!count) return null;
+ return (
+
{ setActiveLevel(level); setActiveSubGroup(null); setActiveGroup(null); setFolderStack([]); }}
+ initial={{ opacity: 0, y: 10 }}
+ animate={{ opacity: 1, y: 0 }}
+ transition={{ duration: 0.3, delay: config.order * 0.05 }}
+ className="tw:text-left tw:cursor-pointer"
+ style={{
+ padding: spacing.xl,
+ borderRadius: spacing.radius.xl,
+ border: `1px solid ${colors.border.light}`,
+ backgroundColor: colors.white,
+ fontFamily: typography.fontFamily.primary,
+ transition: 'border-color 0.15s ease, box-shadow 0.15s ease',
+ }}
+ whileHover={{
+ borderColor: config.color,
+ boxShadow: `0 0 0 1px ${config.color}25`,
+ }}
+ >
+
+
+ {config.label}
+
+
+
+ {count.toLocaleString()}
+
+
+ {config.description}
+
+
+ );
+ })}
+
+ )}
+
+ {/* ─── View: Drilled into a level ─── */}
+ {!isSearching && activeLevel && !activeSubGroup && (
+
+
+
+
+
+ {LEVEL_CONFIG[activeLevel].label}
+
+
+ {countUniqueGroups(filtered).toLocaleString()} parameters across {subGroups.length} groups
+
+
+
+ {activeLevel === 'state' ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+ {/* ─── View: Folder navigation (sub-group drill-in) ─── */}
+ {!isSearching && activeLevel && activeSubGroup && !activeGroup && (
+
+
+
+
+
+ {currentHeadingLabel}
+
+
+ {currentFolderTotalParams.toLocaleString()} parameters
+ {currentFolderTotalItems > 1 && ` across ${currentFolderTotalItems} ${folderContents.folders.length > 0 ? 'programs' : 'groups'}`}
+
+
+
+
+
+ )}
+
+ {/* ─── View: Drilled into a parameter group (leaf list) ─── */}
+ {!isSearching && activeLevel && activeSubGroup && activeGroup && (
+
+
+
+
+
+ {getGroupLabel(activeGroup, parameters)}
+
+ {getGroupDescription(activeGroup, parameters) && (
+
+ {getGroupDescription(activeGroup, parameters)}
+
+ )}
+
+ {activeGroupParams.length} parameter{activeGroupParams.length !== 1 ? 's' : ''}
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/components/shared/categoryUtils.ts b/src/components/shared/categoryUtils.ts
new file mode 100644
index 0000000..c5311ba
--- /dev/null
+++ b/src/components/shared/categoryUtils.ts
@@ -0,0 +1,144 @@
+export type Level = 'federal' | 'state' | 'local' | 'territory' | 'household' | 'reform';
+
+/** Categorize an item by its path prefix (moduleName for variables, parameter path for parameters). */
+export function getLevel(path: string | null): Level {
+ if (!path) return 'household';
+ if (path.startsWith('gov.local')) return 'local';
+ if (path.startsWith('gov.states')) return 'state';
+ if (path.startsWith('gov.territories')) return 'territory';
+ if (path.startsWith('gov.contrib') || path.startsWith('contrib')) return 'reform';
+ if (path.startsWith('household') || path.startsWith('input')) return 'household';
+ if (path.startsWith('gov.')) return 'federal';
+ return 'household';
+}
+
+/** Get a sub-group key from a path. */
+export function getSubGroup(path: string | null): string {
+ if (!path) return 'Other';
+ const parts = path.split('.');
+
+ if (path.startsWith('gov.states.tax') || path.startsWith('gov.states.general') ||
+ path.startsWith('gov.states.unemployment') || path.startsWith('gov.states.workers')) {
+ return 'Cross-state';
+ }
+ if (path.startsWith('gov.states.') && parts.length >= 3 && parts[2].length === 2) {
+ return parts[2].toUpperCase();
+ }
+ if (path.startsWith('gov.local.') && parts.length >= 4) {
+ return `${parts[2].toUpperCase()}-${parts[3]}`;
+ }
+ if (path.startsWith('gov.territories.') && parts.length >= 3) {
+ return parts[2].toUpperCase();
+ }
+ if (path.startsWith('gov.contrib.') && parts.length >= 3) {
+ return parts[2];
+ }
+ if (path.startsWith('contrib.') && parts.length >= 2) {
+ return parts[1];
+ }
+ if (path.startsWith('gov.')) {
+ return parts[1];
+ }
+ if (path.startsWith('household.')) {
+ return parts[1];
+ }
+ return 'Other';
+}
+
+/** Get a display label for a sub-group key. */
+export function getSubGroupLabel(key: string, level: Level): string {
+ if (level === 'federal') {
+ const agencyNames: Record = {
+ irs: 'Internal Revenue Service (IRS)', hhs: 'Health & Human Services (HHS)',
+ usda: 'USDA', ssa: 'Social Security Administration (SSA)',
+ hud: 'Housing & Urban Development (HUD)', ed: 'Dept. of Education',
+ aca: 'Affordable Care Act (ACA)', doe: 'Dept. of Energy',
+ fcc: 'Federal Communications Commission (FCC)', puf: 'IRS Public Use File',
+ simulation: 'Simulation',
+ };
+ return agencyNames[key] || key.toUpperCase();
+ }
+ if (level === 'state') {
+ const stateNames: Record = {
+ AL:'Alabama',AK:'Alaska',AZ:'Arizona',AR:'Arkansas',CA:'California',CO:'Colorado',
+ CT:'Connecticut',DE:'Delaware',DC:'District of Columbia',FL:'Florida',GA:'Georgia',
+ HI:'Hawaii',ID:'Idaho',IL:'Illinois',IN:'Indiana',IA:'Iowa',KS:'Kansas',KY:'Kentucky',
+ LA:'Louisiana',ME:'Maine',MD:'Maryland',MA:'Massachusetts',MI:'Michigan',MN:'Minnesota',
+ MS:'Mississippi',MO:'Missouri',MT:'Montana',NE:'Nebraska',NV:'Nevada',NH:'New Hampshire',
+ NJ:'New Jersey',NM:'New Mexico',NY:'New York',NC:'North Carolina',ND:'North Dakota',
+ OH:'Ohio',OK:'Oklahoma',OR:'Oregon',PA:'Pennsylvania',RI:'Rhode Island',SC:'South Carolina',
+ SD:'South Dakota',TN:'Tennessee',TX:'Texas',UT:'Utah',VT:'Vermont',VA:'Virginia',
+ WA:'Washington',WV:'West Virginia',WI:'Wisconsin',WY:'Wyoming',
+ };
+ if (key === 'Cross-state') return 'Cross-state';
+ return stateNames[key] || key;
+ }
+ if (level === 'local') {
+ const [state, locality] = key.split('-');
+ const localityNames: Record = {
+ la: 'Los Angeles', riv: 'Riverside', nyc: 'New York City',
+ ala: 'Alameda', sf: 'San Francisco', denver: 'Denver',
+ harris: 'Harris County', montgomery: 'Montgomery County',
+ multnomah_county: 'Multnomah County', tax: 'Local taxes',
+ };
+ return `${localityNames[locality] || locality} (${state})`;
+ }
+ if (level === 'territory') {
+ const codes: Record = { PR: 'Puerto Rico', GU: 'Guam', VI: 'US Virgin Islands' };
+ return codes[key] || key;
+ }
+ if (level === 'reform') {
+ // Reform sub-groups are typically program names
+ return key.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
+ }
+ if (level === 'household') {
+ const names: Record = {
+ demographic: 'Demographics', income: 'Income', expense: 'Expenses',
+ assets: 'Assets', marginal_tax_rate: 'Marginal tax rates', cliff: 'Cliff analysis',
+ };
+ return names[key] || key;
+ }
+ return key;
+}
+
+/** Description for sub-groups. */
+export function getSubGroupDescription(key: string, level: Level): string | null {
+ if (level === 'federal') {
+ const desc: Record = {
+ irs: 'Income tax, credits, deductions, and filing rules',
+ hhs: 'Medicaid, CHIP, ACA subsidies, and poverty guidelines',
+ usda: 'SNAP, school meals, WIC, and CSFP',
+ ssa: 'Social Security benefits and payroll taxes',
+ hud: 'Housing assistance and rental subsidies',
+ ed: 'Pell grants and student aid',
+ aca: 'Marketplace eligibility and premium subsidies',
+ doe: 'Energy efficiency rebates and credits',
+ fcc: 'Affordable Connectivity Program',
+ simulation: 'Behavioral responses, labor supply elasticities, and simulation settings',
+ };
+ return desc[key] || null;
+ }
+ if (level === 'household') {
+ const desc: Record = {
+ demographic: 'Age, marital status, disability, and household composition',
+ income: 'Employment, self-employment, investment, and other income sources',
+ expense: 'Childcare, medical, housing, and other deductible expenses',
+ assets: 'Savings, property, and other asset holdings',
+ marginal_tax_rate: 'Effective marginal rates on additional income',
+ cliff: 'Benefit cliffs from income changes',
+ };
+ return desc[key] || null;
+ }
+ return null;
+}
+
+export const LEVEL_CONFIG: Record = {
+ federal: { label: 'Federal', description: 'IRS, HHS, USDA, SSA, HUD, and other federal agencies', color: '#1D4ED8', order: 0 },
+ state: { label: 'State', description: 'State-level tax and benefit programs across all 50 states + DC', color: '#7C3AED', order: 1 },
+ local: { label: 'Local', description: 'City and county programs', color: '#059669', order: 2 },
+ territory: { label: 'Territories', description: 'Puerto Rico and other US territories', color: '#0891B2', order: 3 },
+ reform: { label: 'Reforms', description: 'Contributed reform proposals and policy experiments', color: '#D97706', order: 4 },
+ household: { label: 'Household inputs', description: 'Demographics, income, expenses, and geographic inputs', color: '#6B7280', order: 5 },
+};
+
+export const LEVELS_ORDERED: Level[] = ['federal', 'state', 'local', 'territory', 'reform', 'household'];
diff --git a/src/components/variables/ComputationTree.tsx b/src/components/variables/ComputationTree.tsx
new file mode 100644
index 0000000..d61dde2
--- /dev/null
+++ b/src/components/variables/ComputationTree.tsx
@@ -0,0 +1,292 @@
+import { useState } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { IconPlus, IconMinus, IconChevronRight, IconChevronDown } from '@tabler/icons-react';
+import type { Variable, Parameter } from '../../types/Variable';
+import { colors, typography, spacing } from '../../designTokens';
+
+const MAX_DEPTH = 5;
+
+/** Normalize adds/subtracts which may be an array, object, or null. */
+function toArray(val: unknown): string[] {
+ if (Array.isArray(val)) return val;
+ if (val && typeof val === 'object') return Object.keys(val);
+ return [];
+}
+
+interface ComputationTreeProps {
+ variableName: string;
+ variables: Record;
+ parameters: Record;
+ country: string;
+ depth?: number;
+ visited?: Set;
+}
+
+interface TreeNodeProps {
+ varName: string;
+ variables: Record;
+ parameters: Record;
+ country: string;
+ depth: number;
+ visited: Set;
+ sign: '+' | '-';
+}
+
+function TreeNode({
+ varName,
+ variables,
+ parameters,
+ country,
+ depth,
+ visited,
+ sign,
+}: TreeNodeProps) {
+ const [expanded, setExpanded] = useState(false);
+ const variable = variables[varName];
+
+ if (!variable) {
+ return (
+
+
+ {varName}
+
+ (not found)
+
+ );
+ }
+
+ const adds = toArray(variable.adds);
+ const subtracts = toArray(variable.subtracts);
+ const hasChildren = adds.length > 0 || subtracts.length > 0;
+ const isCircular = visited.has(varName);
+ const atMaxDepth = depth >= MAX_DEPTH;
+
+ return (
+ 0 ? spacing.lg : 0 }}>
+
+
+
+ {expanded && (
+
+ {adds.map((child) => (
+
+ ))}
+ {subtracts.map((child) => (
+
+ ))}
+
+ )}
+
+
+ {atMaxDepth && hasChildren && !isCircular && (
+
+ View full tree in flowchart →
+
+ )}
+
+ );
+}
+
+export default function ComputationTree({
+ variableName,
+ variables,
+ parameters,
+ country,
+ depth = 0,
+ visited = new Set(),
+}: ComputationTreeProps) {
+ const variable = variables[variableName];
+ if (!variable) return null;
+
+ const adds = toArray(variable.adds);
+ const subtracts = toArray(variable.subtracts);
+
+ if (adds.length === 0 && subtracts.length === 0) return null;
+
+ const nextVisited = new Set([...visited, variableName]);
+
+ return (
+
+
+ Computation tree
+
+
+ {adds.map((child) => (
+
+ ))}
+ {subtracts.map((child) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/variables/ParameterTimeline.tsx b/src/components/variables/ParameterTimeline.tsx
new file mode 100644
index 0000000..076e74e
--- /dev/null
+++ b/src/components/variables/ParameterTimeline.tsx
@@ -0,0 +1,126 @@
+import type { Parameter, ParameterLeaf } from '../../types/Variable';
+import { colors, typography, spacing } from '../../designTokens';
+
+interface ParameterTimelineProps {
+ parameters: Record;
+ moduleName: string | null;
+ country: string;
+}
+
+function formatValue(val: number | string | boolean, unit: string | null): string {
+ if (typeof val === 'boolean') return val ? 'Yes' : 'No';
+ if (typeof val === 'number') {
+ if (unit?.startsWith('currency-')) {
+ const currency = unit === 'currency-GBP' ? '£' : '$';
+ return val >= 1000
+ ? `${currency}${val.toLocaleString()}`
+ : `${currency}${val}`;
+ }
+ if (unit === '/1') return `${(val * 100).toFixed(1)}%`;
+ return val.toLocaleString();
+ }
+ return String(val);
+}
+
+function isLeaf(p: Parameter): p is ParameterLeaf {
+ return p.type === 'parameter';
+}
+
+export default function ParameterTimeline({ parameters, moduleName, country }: ParameterTimelineProps) {
+ if (!moduleName) return null;
+ void country;
+
+ // Find parameters that match this variable's module path
+ const prefix = moduleName.split('.').slice(0, -1).join('.');
+ const matching = Object.values(parameters).filter(
+ (p) => isLeaf(p) && p.parameter.startsWith(prefix) && Object.keys(p.values).length > 0,
+ ) as ParameterLeaf[];
+
+ if (matching.length === 0) return null;
+
+ return (
+
+
+ Parameters ({matching.length})
+
+
+ {matching.slice(0, 10).map((p) => {
+ const entries = Object.entries(p.values).sort(
+ ([a], [b]) => a.localeCompare(b),
+ );
+ return (
+
+
+ {p.label || p.parameter.split('.').pop()}
+
+ {p.description && (
+
+ {p.description}
+
+ )}
+
+ {entries.map(([date, val]) => (
+
+ {date.slice(0, 4)}: {formatValue(val, p.unit)}
+
+ ))}
+
+
+ );
+ })}
+ {matching.length > 10 && (
+
+ + {matching.length - 10} more parameters
+
+ )}
+
+
+ );
+}
diff --git a/src/components/variables/VariableCard.tsx b/src/components/variables/VariableCard.tsx
new file mode 100644
index 0000000..1a8f699
--- /dev/null
+++ b/src/components/variables/VariableCard.tsx
@@ -0,0 +1,129 @@
+import type { Variable } from '../../types/Variable';
+import { colors, typography, spacing } from '../../designTokens';
+
+interface VariableCardProps {
+ variable: Variable;
+ isSelected: boolean;
+ onClick: () => void;
+}
+
+const entityColors: Record = {
+ person: '#7C3AED',
+ tax_unit: '#2563EB',
+ spm_unit: '#0891B2',
+ household: '#059669',
+ family: '#D97706',
+ marital_unit: '#DC2626',
+ benunit: '#0891B2',
+};
+
+export const entityLabels: Record = {
+ person: 'Person',
+ tax_unit: 'Tax Unit',
+ spm_unit: 'SPM Unit',
+ household: 'Household',
+ family: 'Family',
+ marital_unit: 'Marital Unit',
+ benunit: 'Benefit Unit',
+};
+
+export default function VariableCard({ variable, isSelected, onClick }: VariableCardProps) {
+ const entityColor = entityColors[variable.entity] || colors.gray[500];
+
+ return (
+
+ );
+}
diff --git a/src/components/variables/VariableDetail.tsx b/src/components/variables/VariableDetail.tsx
new file mode 100644
index 0000000..fadcb8e
--- /dev/null
+++ b/src/components/variables/VariableDetail.tsx
@@ -0,0 +1,185 @@
+import { motion } from 'framer-motion';
+import { IconExternalLink } from '@tabler/icons-react';
+import type { Variable, Parameter } from '../../types/Variable';
+import { colors, typography, spacing } from '../../designTokens';
+import ComputationTree from './ComputationTree';
+import { entityLabels } from './VariableCard';
+
+interface VariableDetailProps {
+ variable: Variable;
+ variables: Record;
+ parameters: Record;
+ country: string;
+ onViewFlowchart?: (varName: string) => void;
+}
+
+function formatUnit(unit: string | null, country: string): string {
+ if (!unit) return 'none';
+ if (unit === 'currency-USD') return 'USD ($)';
+ if (unit === 'currency-GBP') return 'GBP (£)';
+ if (unit === '/1') return 'ratio (0–1)';
+ void country;
+ return unit;
+}
+
+function MetaRow({ label, value }: { label: string; value: string }) {
+ return (
+
+
+ {label}
+
+
+ {value}
+
+
+ );
+}
+
+export default function VariableDetail({ variable, variables, parameters, country, onViewFlowchart }: VariableDetailProps) {
+ const adds = Array.isArray(variable.adds) ? variable.adds : (variable.adds ? Object.keys(variable.adds) : []);
+ const subtracts = Array.isArray(variable.subtracts) ? variable.subtracts : (variable.subtracts ? Object.keys(variable.subtracts) : []);
+ const hasTree = adds.length > 0 || subtracts.length > 0;
+ const githubRepo = country === 'uk' ? 'policyengine-uk' : 'policyengine-us';
+ // moduleName is e.g. "gov.states.al.tax.income.al_agi"
+ // maps to variables/gov/states/al/tax/income/al_agi.py
+ const modulePath = variable.moduleName?.replace(/\./g, '/');
+
+ return (
+
+
+ {/* Documentation */}
+ {variable.documentation && (
+
+ {variable.documentation}
+
+ )}
+
+ {/* Metadata */}
+
+
+
+
+
+
+ {variable.moduleName && }
+
+
+ {/* Enum possible values */}
+ {variable.possibleValues && variable.possibleValues.length > 0 && (
+
+
+ Possible values
+
+
+ {variable.possibleValues.map((pv) => (
+
+ {pv.label || pv.value}
+
+ ))}
+
+
+ )}
+
+ {/* Links */}
+
+ {variable.moduleName && (
+
+ View source
+
+ )}
+
+
+
+ {/* Computation tree */}
+ {hasTree && (
+
+ )}
+
+
+ );
+}
diff --git a/src/components/variables/VariableExplorer.tsx b/src/components/variables/VariableExplorer.tsx
new file mode 100644
index 0000000..d605531
--- /dev/null
+++ b/src/components/variables/VariableExplorer.tsx
@@ -0,0 +1,755 @@
+import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { IconSearch, IconArrowLeft, IconFolder, IconChevronLeft, IconChevronRight } from '@tabler/icons-react';
+import type { Variable, Parameter } from '../../types/Variable';
+import { colors, typography, spacing } from '../../designTokens';
+import { useDebounce } from '../../hooks/useDebounce';
+import {
+ getLevel as getLevelFromPath, getSubGroup as getSubGroupFromPath,
+ getSubGroupLabel, getSubGroupDescription,
+ LEVEL_CONFIG, LEVELS_ORDERED,
+ type Level,
+} from '../shared/categoryUtils';
+import VariableCard from './VariableCard';
+import VariableDetail from './VariableDetail';
+
+const PAGE_SIZE = 50;
+
+// ─── Pagination ─────────────────────────────────────────────────────────────
+
+function Pagination({ page, totalPages, onPageChange }: {
+ page: number;
+ totalPages: number;
+ onPageChange: (page: number) => void;
+}) {
+ if (totalPages <= 1) return null;
+
+ const pages: (number | '...')[] = [];
+ const addPage = (p: number) => {
+ if (p >= 1 && p <= totalPages && !pages.includes(p)) pages.push(p);
+ };
+ addPage(1);
+ for (let i = Math.max(2, page - 2); i <= Math.min(totalPages - 1, page + 2); i++) addPage(i);
+ addPage(totalPages);
+
+ const withEllipsis: (number | '...')[] = [];
+ for (let i = 0; i < pages.length; i++) {
+ const p = pages[i];
+ if (i > 0 && typeof p === 'number' && typeof pages[i - 1] === 'number' && p - (pages[i - 1] as number) > 1) {
+ withEllipsis.push('...');
+ }
+ withEllipsis.push(p);
+ }
+
+ const btnBase: React.CSSProperties = {
+ minWidth: '32px', height: '32px', borderRadius: spacing.radius.md,
+ border: `1px solid ${colors.border.light}`, backgroundColor: colors.white,
+ fontFamily: typography.fontFamily.primary, fontSize: typography.fontSize.xs,
+ fontWeight: typography.fontWeight.medium, cursor: 'pointer',
+ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: `0 ${spacing.sm}`,
+ };
+
+ return (
+
+
+ {withEllipsis.map((p, i) =>
+ p === '...' ? (
+ ...
+ ) : (
+
+ ),
+ )}
+
+
+ );
+}
+
+// ─── Breadcrumb navigation ──────────────────────────────────────────────────
+
+function BreadcrumbNav({ items, onBack }: {
+ items: { label: string; onClick: () => void }[];
+ onBack: () => void;
+}) {
+ return (
+
+
+ {items.map((item, i) => {
+ const isLast = i === items.length - 1;
+ return (
+
+ {i > 0 && /}
+ {isLast ? (
+
+ {item.label}
+
+ ) : (
+
+ )}
+
+ );
+ })}
+
+ );
+}
+
+// ─── Helpers ────────────────────────────────────────────────────────────────
+
+interface VariableExplorerProps {
+ variables: Record;
+ parameters: Record;
+ country: string;
+ onViewFlowchart?: (varName: string) => void;
+}
+
+function getLevel(v: Variable): Level {
+ return getLevelFromPath(v.moduleName);
+}
+
+function getSubGroup(v: Variable): string {
+ return getSubGroupFromPath(v.moduleName);
+}
+
+function getFolderLabel(fullPath: string): string {
+ const parts = fullPath.split('.');
+ return parts[parts.length - 1].replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
+}
+
+/** Find the longest common dot-separated prefix. */
+function commonPrefix(paths: string[]): string {
+ if (paths.length === 0) return '';
+ const parts0 = paths[0].split('.');
+ let len = parts0.length;
+ for (const p of paths.slice(1)) {
+ const parts = p.split('.');
+ len = Math.min(len, parts.length);
+ for (let i = 0; i < len; i++) {
+ if (parts[i] !== parts0[i]) { len = i; break; }
+ }
+ }
+ return parts0.slice(0, len).join('.');
+}
+
+/** At a given folder prefix, compute sub-folders and direct variables. */
+function getVarFolderContents(
+ vars: Variable[],
+ prefix: string,
+): {
+ folders: { segment: string; fullPath: string; varCount: number }[];
+ directVars: Variable[];
+} {
+ const prefixDot = prefix + '.';
+ const folderCounts = new Map();
+ const directVars: Variable[] = [];
+
+ for (const v of vars) {
+ const path = v.moduleName || '';
+ if (!path.startsWith(prefixDot)) continue;
+ const remaining = path.slice(prefixDot.length);
+ const segments = remaining.split('.');
+ if (segments.length === 1) {
+ directVars.push(v);
+ } else {
+ const folder = segments[0];
+ folderCounts.set(folder, (folderCounts.get(folder) || 0) + 1);
+ }
+ }
+
+ const folders = [...folderCounts.entries()]
+ .map(([segment, varCount]) => ({ segment, fullPath: `${prefix}.${segment}`, varCount }))
+ .sort((a, b) => a.segment.localeCompare(b.segment));
+
+ return { folders, directVars: directVars.sort((a, b) => a.label.localeCompare(b.label)) };
+}
+
+/** Auto-collapse single-child folder chains. */
+function autoCollapseVarFolder(vars: Variable[], prefix: string): string {
+ let current = prefix;
+ for (let i = 0; i < 10; i++) {
+ const { folders, directVars } = getVarFolderContents(vars, current);
+ if (folders.length === 1 && directVars.length === 0) {
+ current = folders[0].fullPath;
+ } else {
+ break;
+ }
+ }
+ return current;
+}
+
+// ─── State tile grid ─────────────────────────────────────────────────────────
+
+function StateTileGrid({
+ subGroups,
+ levelColor,
+ onSelect,
+}: {
+ subGroups: [string, Variable[]][];
+ levelColor: string;
+ onSelect: (key: string) => void;
+}) {
+ const states = subGroups.filter(([k]) => k !== 'Cross-state' && k.length === 2);
+ const other = subGroups.filter(([k]) => k === 'Cross-state' || k.length !== 2);
+
+ return (
+
+
+ {states.map(([key, vars]) => (
+
onSelect(key)}
+ initial={{ opacity: 0, scale: 0.9 }}
+ animate={{ opacity: 1, scale: 1 }}
+ transition={{ duration: 0.2 }}
+ className="tw:cursor-pointer tw:text-center"
+ style={{
+ padding: spacing.md, borderRadius: spacing.radius.lg,
+ border: `1px solid ${colors.border.light}`, backgroundColor: colors.white,
+ fontFamily: typography.fontFamily.primary,
+ transition: 'border-color 0.15s ease, box-shadow 0.15s ease',
+ }}
+ whileHover={{ borderColor: levelColor, boxShadow: `0 0 0 1px ${levelColor}25` }}
+ >
+
+ {key}
+
+
+ {vars.length}
+
+
+ ))}
+
+ {other.length > 0 && (
+
+ {other.map(([key, vars]) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+// ─── Sub-group card grid ──────────────────────────────────────────────────────
+
+function SubGroupCardGrid({
+ subGroups,
+ level,
+ levelColor,
+ onSelect,
+}: {
+ subGroups: [string, Variable[]][];
+ level: Level;
+ levelColor: string;
+ onSelect: (key: string) => void;
+}) {
+ return (
+
+ {subGroups.map(([key, vars], i) => {
+ const label = getSubGroupLabel(key, level);
+ const desc = getSubGroupDescription(key, level);
+ return (
+
onSelect(key)}
+ initial={{ opacity: 0, y: 8 }}
+ animate={{ opacity: 1, y: 0 }}
+ transition={{ duration: 0.2, delay: i * 0.03 }}
+ className="tw:text-left tw:cursor-pointer"
+ style={{
+ padding: spacing.lg, borderRadius: spacing.radius.xl,
+ border: `1px solid ${colors.border.light}`, backgroundColor: colors.white,
+ fontFamily: typography.fontFamily.primary,
+ transition: 'border-color 0.15s ease, box-shadow 0.15s ease',
+ }}
+ whileHover={{ borderColor: levelColor, boxShadow: `0 0 0 1px ${levelColor}25` }}
+ >
+
+ {label}
+
+
+ {vars.length}
+
+ {desc && (
+
+ {desc}
+
+ )}
+
+ );
+ })}
+
+ );
+}
+
+// ─── Folder contents (sub-folders + direct variables) ───────────────────────
+
+function FolderContentsView({
+ folders,
+ directVars,
+ levelColor,
+ allVariables,
+ parameters,
+ country,
+ selectedVar,
+ onFolderSelect,
+ onVarSelect,
+ onViewFlowchart,
+}: {
+ folders: { segment: string; fullPath: string; varCount: number }[];
+ directVars: Variable[];
+ levelColor: string;
+ allVariables: Record;
+ parameters: Record;
+ country: string;
+ selectedVar: string | null;
+ onFolderSelect: (fullPath: string) => void;
+ onVarSelect: (name: string) => void;
+ onViewFlowchart?: (varName: string) => void;
+}) {
+ const [folderPage, setFolderPage] = useState(1);
+ const folderTotalPages = Math.ceil(folders.length / PAGE_SIZE);
+ const visibleFolders = folders.slice((folderPage - 1) * PAGE_SIZE, folderPage * PAGE_SIZE);
+
+ const [varPage, setVarPage] = useState(1);
+ const varTotalPages = Math.ceil(directVars.length / PAGE_SIZE);
+ const visibleVars = directVars.slice((varPage - 1) * PAGE_SIZE, varPage * PAGE_SIZE);
+
+ return (
+
+ {/* Folder cards */}
+ {folders.length > 0 && (
+
+
+ {visibleFolders.map((folder, i) => {
+ const label = getFolderLabel(folder.fullPath);
+ return (
+
onFolderSelect(folder.fullPath)}
+ initial={{ opacity: 0, y: 6 }}
+ animate={{ opacity: 1, y: 0 }}
+ transition={{ duration: 0.15, delay: i * 0.01 }}
+ className="tw:text-left tw:cursor-pointer"
+ style={{
+ padding: `${spacing.md} ${spacing.lg}`, borderRadius: spacing.radius.lg,
+ border: `1px solid ${colors.border.light}`, backgroundColor: colors.white,
+ fontFamily: typography.fontFamily.primary,
+ transition: 'border-color 0.15s ease, box-shadow 0.15s ease',
+ }}
+ whileHover={{ borderColor: levelColor, boxShadow: `0 0 0 1px ${levelColor}25` }}
+ >
+
+
+
+ {folder.varCount} vars
+
+
+
+ );
+ })}
+
+
+
+ )}
+
+ {/* Direct variables */}
+ {directVars.length > 0 && (
+
0 ? spacing.xl : 0 }}>
+ {folders.length > 0 && (
+
+ Variables ({directVars.length})
+
+ )}
+
+ {visibleVars.map((v) => (
+
+
onVarSelect(v.name)} />
+
+ {selectedVar === v.name && (
+
+ )}
+
+
+ ))}
+
+
+
+ )}
+
+ );
+}
+
+// ─── Main explorer ───────────────────────────────────────────────────────────
+
+export default function VariableExplorer({ variables, parameters, country, onViewFlowchart }: VariableExplorerProps) {
+ const [search, setSearch] = useState('');
+ const [activeLevel, setActiveLevel] = useState(null);
+ const [activeSubGroup, setActiveSubGroup] = useState(null);
+ const [folderStack, setFolderStack] = useState([]);
+ const [selectedVar, setSelectedVar] = useState(null);
+ const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
+ const sentinelRef = useRef(null);
+
+ const debouncedSearch = useDebounce(search, 200);
+ const isSearching = !!debouncedSearch;
+
+ // All variables (hidden_input + contrib excluded), sorted
+ const allVariables = useMemo(() => {
+ return Object.values(variables)
+ .filter((v) => !v.hidden_input && !v.moduleName?.startsWith('contrib') && !v.moduleName?.startsWith('gov.puf'))
+ .sort((a, b) => a.label.localeCompare(b.label));
+ }, [variables]);
+
+ // Count per level
+ const levelCounts = useMemo(() => {
+ const counts: Record = { federal: 0, state: 0, local: 0, territory: 0, reform: 0, household: 0 };
+ for (const v of allVariables) counts[getLevel(v)]++;
+ return counts;
+ }, [allVariables]);
+
+ // Filtered list
+ const filtered = useMemo(() => {
+ let result = allVariables;
+ if (debouncedSearch) {
+ const words = debouncedSearch.toLowerCase().split(/\s+/).filter(Boolean);
+ result = result.filter((v) => {
+ const haystack = [v.name, v.label, v.documentation || '', v.moduleName || '', v.entity, v.valueType, v.unit || ''].join(' ').toLowerCase();
+ return words.every((w) => haystack.includes(w));
+ });
+ }
+ if (activeLevel) result = result.filter((v) => getLevel(v) === activeLevel);
+ return result;
+ }, [allVariables, debouncedSearch, activeLevel]);
+
+ // Sub-groups for the active level
+ const subGroups = useMemo(() => {
+ if (!activeLevel) return [];
+ const map = new Map();
+ for (const v of filtered) {
+ const key = getSubGroup(v);
+ if (!map.has(key)) map.set(key, []);
+ map.get(key)!.push(v);
+ }
+ return [...map.entries()].sort(([a], [b]) => a.localeCompare(b));
+ }, [filtered, activeLevel]);
+
+ // Variables in active sub-group
+ const subGroupVars = useMemo(() => {
+ if (!activeSubGroup) return [];
+ const entry = subGroups.find(([k]) => k === activeSubGroup);
+ return entry ? entry[1] : [];
+ }, [subGroups, activeSubGroup]);
+
+ // Root folder prefix: common prefix of all module paths, auto-collapsed
+ const rootFolderPrefix = useMemo(() => {
+ if (subGroupVars.length === 0) return '';
+ const paths = subGroupVars.map((v) => v.moduleName || '').filter(Boolean);
+ if (paths.length === 0) return '';
+ const prefix = commonPrefix(paths);
+ // Strip the last segment (which would be partial variable name overlap)
+ const parts = prefix.split('.');
+ // Walk back until we find a prefix that's a proper directory (not a variable name)
+ while (parts.length > 0) {
+ const candidate = parts.join('.');
+ const hasChildren = subGroupVars.some((v) => (v.moduleName || '').startsWith(candidate + '.') && (v.moduleName || '') !== candidate);
+ if (hasChildren) break;
+ parts.pop();
+ }
+ const dirPrefix = parts.join('.');
+ return autoCollapseVarFolder(subGroupVars, dirPrefix);
+ }, [subGroupVars]);
+
+ // Current folder
+ const currentFolder = folderStack.length > 0 ? folderStack[folderStack.length - 1] : rootFolderPrefix;
+
+ // Folder contents at current level
+ const folderContents = useMemo(() => {
+ if (!activeSubGroup || !currentFolder) return { folders: [], directVars: [] };
+ return getVarFolderContents(subGroupVars, currentFolder);
+ }, [subGroupVars, currentFolder, activeSubGroup]);
+
+ // Reset folder stack when sub-group changes
+ useEffect(() => {
+ setFolderStack([]);
+ setSelectedVar(null);
+ }, [activeSubGroup]);
+
+ // Reset on filter changes
+ useEffect(() => {
+ setVisibleCount(PAGE_SIZE);
+ }, [debouncedSearch, activeLevel, activeSubGroup]);
+
+ // Infinite scroll for search mode
+ useEffect(() => {
+ if (!isSearching) return;
+ const sentinel = sentinelRef.current;
+ if (!sentinel) return;
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries[0].isIntersecting) {
+ setVisibleCount((prev) => Math.min(prev + PAGE_SIZE, filtered.length));
+ }
+ },
+ { rootMargin: '200px' },
+ );
+ observer.observe(sentinel);
+ return () => observer.disconnect();
+ }, [filtered.length, isSearching]);
+
+ const handleSelect = useCallback((name: string) => {
+ setSelectedVar((prev) => (prev === name ? null : name));
+ }, []);
+
+ const visibleFlat = filtered.slice(0, visibleCount);
+
+ // Navigation
+ const goBack = () => {
+ if (folderStack.length > 0) {
+ setFolderStack((prev) => prev.slice(0, -1));
+ setSelectedVar(null);
+ } else if (activeSubGroup) {
+ setActiveSubGroup(null);
+ setSelectedVar(null);
+ } else {
+ setActiveLevel(null);
+ setSelectedVar(null);
+ }
+ };
+
+ const handleFolderSelect = (fullPath: string) => {
+ const collapsed = autoCollapseVarFolder(subGroupVars, fullPath);
+ setFolderStack((prev) => [...prev, collapsed]);
+ setSelectedVar(null);
+ };
+
+ // Breadcrumb items
+ const breadcrumbItems = useMemo(() => {
+ const items: { label: string; onClick: () => void }[] = [];
+ if (activeLevel) {
+ items.push({
+ label: LEVEL_CONFIG[activeLevel].label,
+ onClick: () => { setActiveLevel(activeLevel); setActiveSubGroup(null); setFolderStack([]); setSelectedVar(null); },
+ });
+ }
+ if (activeSubGroup && activeLevel) {
+ items.push({
+ label: getSubGroupLabel(activeSubGroup, activeLevel),
+ onClick: () => { setFolderStack([]); setSelectedVar(null); },
+ });
+ }
+ for (let i = 0; i < folderStack.length; i++) {
+ const stackIndex = i;
+ items.push({
+ label: getFolderLabel(folderStack[i]),
+ onClick: () => { setFolderStack((prev) => prev.slice(0, stackIndex + 1)); setSelectedVar(null); },
+ });
+ }
+ return items;
+ }, [activeLevel, activeSubGroup, folderStack]);
+
+ // Current heading
+ const currentHeadingLabel = folderStack.length > 0
+ ? getFolderLabel(currentFolder)
+ : activeSubGroup && activeLevel
+ ? getSubGroupLabel(activeSubGroup, activeLevel)
+ : '';
+
+
+ return (
+
+ {/* Search */}
+
+
+ setSearch(e.target.value)}
+ style={{
+ width: '100%', padding: `${spacing.md} ${spacing.lg} ${spacing.md} ${spacing['3xl']}`,
+ borderRadius: spacing.radius.lg, border: `1px solid ${colors.border.light}`,
+ fontSize: typography.fontSize.sm, fontFamily: typography.fontFamily.primary,
+ outline: 'none', boxSizing: 'border-box',
+ }}
+ />
+
+
+ {/* Search filter pills */}
+ {isSearching && (
+
+
+ {([null, ...LEVELS_ORDERED] as (Level | null)[]).map((level) => {
+ const label = level ? LEVEL_CONFIG[level].label : 'All';
+ const clr = level ? LEVEL_CONFIG[level].color : colors.text.secondary;
+ const isActive = activeLevel === level;
+ return (
+
+ );
+ })}
+
+
+ {filtered.length.toLocaleString()} variable{filtered.length !== 1 ? 's' : ''}
+
+
+ )}
+
+ {/* ─── View: Search results (flat) ─── */}
+ {isSearching && (
+ <>
+
+ {visibleFlat.map((v) => (
+
+
handleSelect(v.name)} />
+
+ {selectedVar === v.name && (
+
+ )}
+
+
+ ))}
+
+ {visibleCount < filtered.length &&
}
+ {filtered.length === 0 && (
+
+ No variables match your search.
+
+ )}
+ >
+ )}
+
+ {/* ─── View: Level overview cards ─── */}
+ {!isSearching && !activeLevel && (
+
+ {LEVELS_ORDERED.map((level) => {
+ const config = LEVEL_CONFIG[level];
+ const count = levelCounts[level];
+ if (!count) return null;
+ return (
+
{ setActiveLevel(level); setActiveSubGroup(null); setFolderStack([]); }}
+ initial={{ opacity: 0, y: 10 }}
+ animate={{ opacity: 1, y: 0 }}
+ transition={{ duration: 0.3, delay: config.order * 0.05 }}
+ className="tw:text-left tw:cursor-pointer"
+ style={{
+ padding: spacing.xl, borderRadius: spacing.radius.xl,
+ border: `1px solid ${colors.border.light}`, backgroundColor: colors.white,
+ fontFamily: typography.fontFamily.primary,
+ transition: 'border-color 0.15s ease, box-shadow 0.15s ease',
+ }}
+ whileHover={{ borderColor: config.color, boxShadow: `0 0 0 1px ${config.color}25` }}
+ >
+
+
+ {config.label}
+
+
+
+ {count.toLocaleString()}
+
+
+ {config.description}
+
+
+ );
+ })}
+
+ )}
+
+ {/* ─── View: Drilled into a level ─── */}
+ {!isSearching && activeLevel && !activeSubGroup && (
+
+
+
+
+ {LEVEL_CONFIG[activeLevel].label}
+
+
+ {filtered.length.toLocaleString()} variables across {subGroups.length} groups
+
+
+ {activeLevel === 'state' ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+ {/* ─── View: Folder navigation (sub-group drill-in) ─── */}
+ {!isSearching && activeLevel && activeSubGroup && (
+
+
+
+
+ {currentHeadingLabel}
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/data/fetchMetadata.ts b/src/data/fetchMetadata.ts
new file mode 100644
index 0000000..924e4c6
--- /dev/null
+++ b/src/data/fetchMetadata.ts
@@ -0,0 +1,53 @@
+import type { Metadata } from '../types/Variable';
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const rawCache = new Map();
+const metadataCache = new Map();
+
+/** Try loading pre-built static metadata first, fall back to live API. */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+async function fetchFromStaticOrAPI(country: string): Promise {
+ // Try the pre-built static file (generated at build time by scripts/fetch-metadata.js)
+ try {
+ const staticUrl = `${import.meta.env.BASE_URL}metadata-${country}.json`;
+ const res = await fetch(staticUrl);
+ if (res.ok) {
+ const data = await res.json();
+ // Verify it has the expected shape
+ if (data.variables && data.parameters) return data;
+ }
+ } catch {
+ // Static file not available, fall through to API
+ }
+
+ // Fall back to live API
+ const res = await fetch(`https://api.policyengine.org/${country}/metadata`);
+ if (!res.ok) throw new Error(`API returned ${res.status}`);
+ const data = await res.json();
+ return data.result ?? data;
+}
+
+/** Fetch and cache the raw API response (shared with fetchPrograms). */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export async function fetchMetadataRaw(country: string = 'us'): Promise {
+ if (rawCache.has(country)) return rawCache.get(country)!;
+
+ const result = await fetchFromStaticOrAPI(country);
+ rawCache.set(country, result);
+ return result;
+}
+
+/** Fetch and cache structured metadata (variables + parameters). */
+export async function fetchMetadata(country: string = 'us'): Promise {
+ if (metadataCache.has(country)) return metadataCache.get(country)!;
+
+ const raw = await fetchMetadataRaw(country);
+
+ const result: Metadata = {
+ variables: raw.variables ?? {},
+ parameters: raw.parameters ?? {},
+ };
+
+ metadataCache.set(country, result);
+ return result;
+}
diff --git a/src/data/fetchPrograms.ts b/src/data/fetchPrograms.ts
index 4917f86..5fef2ba 100644
--- a/src/data/fetchPrograms.ts
+++ b/src/data/fetchPrograms.ts
@@ -1,7 +1,7 @@
import type { Program, CoverageStatus, StateImplementation } from '../types/Program';
-const GITHUB_BASE = 'https://github.com/PolicyEngine/policyengine-us/tree/master/policyengine_us';
-const TESTS_BASE = 'https://github.com/PolicyEngine/policyengine-us/tree/master/policyengine_us/tests';
+const GITHUB_BASE = 'https://github.com/PolicyEngine/policyengine-us/tree/main/policyengine_us';
+const TESTS_BASE = 'https://github.com/PolicyEngine/policyengine-us/tree/main/policyengine_us/tests';
interface ApiProgram {
id: string;
@@ -103,10 +103,10 @@ export async function fetchPrograms(country: string = 'us'): Promise
if (cache.has(country)) return cache.get(country)!;
try {
- const res = await fetch(`https://api.policyengine.org/${country}/metadata`);
- if (!res.ok) throw new Error(`API returned ${res.status}`);
- const data = await res.json();
- const apiPrograms: ApiProgram[] = data.result?.modelled_policies?.programs;
+ // Use the shared metadata fetch to avoid duplicate API calls
+ const { fetchMetadataRaw } = await import('./fetchMetadata');
+ const data = await fetchMetadataRaw(country);
+ const apiPrograms: ApiProgram[] = data.modelled_policies?.programs;
if (!apiPrograms || !Array.isArray(apiPrograms)) {
throw new Error('No programs array in API response');
}
diff --git a/src/data/programs.ts b/src/data/programs.ts
index 5502245..0adfb25 100644
--- a/src/data/programs.ts
+++ b/src/data/programs.ts
@@ -1,7 +1,7 @@
import type { Program } from '../types/Program';
-const GITHUB_BASE = 'https://github.com/PolicyEngine/policyengine-us/tree/master/policyengine_us';
-const TESTS_BASE = 'https://github.com/PolicyEngine/policyengine-us/tree/master/policyengine_us/tests';
+const GITHUB_BASE = 'https://github.com/PolicyEngine/policyengine-us/tree/main/policyengine_us';
+const TESTS_BASE = 'https://github.com/PolicyEngine/policyengine-us/tree/main/policyengine_us/tests';
export const programs: Program[] = [
// Tax Programs
diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts
new file mode 100644
index 0000000..2a8f85f
--- /dev/null
+++ b/src/hooks/useDebounce.ts
@@ -0,0 +1,10 @@
+import { useState, useEffect } from 'react';
+
+export function useDebounce(value: T, delay: number): T {
+ const [debounced, setDebounced] = useState(value);
+ useEffect(() => {
+ const timer = setTimeout(() => setDebounced(value), delay);
+ return () => clearTimeout(timer);
+ }, [value, delay]);
+ return debounced;
+}
diff --git a/src/pages/rules/ParametersPage.tsx b/src/pages/rules/ParametersPage.tsx
index d9e7479..a6a93f9 100644
--- a/src/pages/rules/ParametersPage.tsx
+++ b/src/pages/rules/ParametersPage.tsx
@@ -1,12 +1,48 @@
-import ProgramListPage from './ProgramListPage';
+import { useState, useEffect } from 'react';
+import PageHeader from '../../components/layout/PageHeader';
+import ParameterExplorer from '../../components/parameters/ParameterExplorer';
+import { fetchMetadata } from '../../data/fetchMetadata';
+import type { Metadata } from '../../types/Variable';
+import { colors, typography, spacing } from '../../designTokens';
export default function ParametersPage({ country }: { country: string }) {
+ const [metadata, setMetadata] = useState(null);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ fetchMetadata(country)
+ .then(setMetadata)
+ .catch((err) => setError(err.message));
+ }, [country]);
+
return (
-
+
+
+
+ {error && (
+
+ Failed to load metadata: {error}
+
+ )}
+
+ {!metadata && !error && (
+
+
+ Loading parameters...
+
+
+ )}
+
+ {metadata && (
+
+ )}
+
);
}
diff --git a/src/pages/rules/VariablesPage.tsx b/src/pages/rules/VariablesPage.tsx
index 3444877..8b4741b 100644
--- a/src/pages/rules/VariablesPage.tsx
+++ b/src/pages/rules/VariablesPage.tsx
@@ -1,12 +1,156 @@
-import ProgramListPage from './ProgramListPage';
+import { useState, useEffect, useRef } from 'react';
+import { IconExternalLink } from '@tabler/icons-react';
+import PageHeader from '../../components/layout/PageHeader';
+import VariableExplorer from '../../components/variables/VariableExplorer';
+import { fetchMetadata } from '../../data/fetchMetadata';
+import type { Metadata } from '../../types/Variable';
+import { colors, typography, spacing } from '../../designTokens';
+
+const FLOWCHART_BASE = 'https://policyengine.github.io/flowchart';
+const DEFAULT_VAR = 'household_net_income';
+
+function FlowchartPreview({ country, variable, sectionRef }: {
+ country: string;
+ variable: string;
+ sectionRef: React.RefObject;
+}) {
+ const flowchartUrl = `${FLOWCHART_BASE}/?variable=${variable}&country=${country.toUpperCase()}`;
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ setLoading(true);
+ }, [flowchartUrl]);
+
+ return (
+
+
+
+
+ Computation flowchart
+
+
+ {variable === DEFAULT_VAR
+ ? 'See how any variable is calculated from its dependencies'
+ : <>Showing {variable}>
+ }
+
+
+
+ Open in new tab
+
+
+
+ {loading && (
+
+
+ Loading flowchart...
+
+
+ )}
+
+
+ );
+}
export default function VariablesPage({ country }: { country: string }) {
+ const [metadata, setMetadata] = useState(null);
+ const [error, setError] = useState(null);
+ const [flowchartVar, setFlowchartVar] = useState(DEFAULT_VAR);
+ const flowchartRef = useRef(null);
+
+ useEffect(() => {
+ fetchMetadata(country)
+ .then(setMetadata)
+ .catch((err) => setError(err.message));
+ }, [country]);
+
+ const handleViewFlowchart = (varName: string) => {
+ setFlowchartVar(varName);
+ // Small delay to let the iframe key change, then scroll
+ setTimeout(() => {
+ flowchartRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ }, 50);
+ };
+
return (
-
+
+
+
+ {error && (
+
+ Failed to load metadata: {error}
+
+ )}
+
+ {!metadata && !error && (
+
+
+ Loading variables...
+
+
+ )}
+
+ {metadata && (
+
+ )}
+
+
+
);
}
diff --git a/src/types/Variable.ts b/src/types/Variable.ts
new file mode 100644
index 0000000..b854198
--- /dev/null
+++ b/src/types/Variable.ts
@@ -0,0 +1,42 @@
+export interface Variable {
+ name: string;
+ label: string;
+ documentation: string | null;
+ entity: string;
+ valueType: 'float' | 'int' | 'bool' | 'Enum' | 'str';
+ definitionPeriod: string;
+ unit: string | null;
+ moduleName: string | null;
+ indexInModule: number;
+ isInputVariable: boolean;
+ defaultValue: number | string | boolean | null;
+ adds: string[] | null;
+ subtracts: string[] | null;
+ hidden_input: boolean;
+ category: string | null;
+ possibleValues?: Array<{ value: string; label: string }>;
+}
+
+export interface ParameterLeaf {
+ type: 'parameter';
+ parameter: string;
+ description: string | null;
+ label: string;
+ unit: string | null;
+ period: string | null;
+ values: Record;
+}
+
+export interface ParameterNode {
+ type: 'parameterNode';
+ parameter: string;
+ description: string | null;
+ label: string;
+}
+
+export type Parameter = ParameterLeaf | ParameterNode;
+
+export interface Metadata {
+ variables: Record;
+ parameters: Record;
+}
diff --git a/vercel.json b/vercel.json
index 0d199b2..0f2ca8e 100644
--- a/vercel.json
+++ b/vercel.json
@@ -1,4 +1,7 @@
{
+ "framework": "vite",
+ "buildCommand": "bun run build",
+ "outputDirectory": "dist",
"rewrites": [
{ "source": "/(.*)", "destination": "/index.html" }
]