# Project export: PolicyPulse — see the effects of a law before it passes

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: UC Berkeley AI Hackathon 2026
- Tagline: See the real laws moving around you, simulate who they help and hurt on a Census-grounded model of your city, and contact the people who vote on them.
- Devpost: https://devpost.com/software/policypulse-see-the-effects-of-a-law-before-it-passes
- GitHub: https://github.com/lukeeskinner/PolicyPulse
- Video: https://www.youtube.com/embed/PUahmkfhUOw?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 5 GitHub contributor(s) — Devin (12 commits), Zablon Tesfaye (11 commits), Luke Skinner (10 commits), kunjrathod2005 (5 commits), Abrham Dawit (3 commits)

## Devpost submission (written by the team)

### Inspiration

When a city debates rent control, a minimum wage, or an upzoning plan, nobody actually knows who it will help and who it will hurt until years later — after it's law, and the damage or benefit is already done. Policy gets made on ideology and vibes, and the people hurt most are usually the ones with the least voice in the room. Even when someone does care, they have no easy way to turn that concern into action. We wanted to flip the order of operations: see the distributional consequences of a policy before anyone votes — on a model of your own community — and then act on it.

### What it does

PolicyPulse has two halves that flow into each other. The Pulse Map finds your area and surfaces the real bills moving around you right now — live federal legislation from Congress.gov and state bills from OpenStates — plotted as markers on a 3D map, alongside a rail of real local policy news. Every marker is a real bill; nothing is mock data. The Simulator lets you click any bill (or paste your own) and watch it play out across a statistically representative population of synthetic residents built from live U.S. Census (ACS) data for that state — real population, income, rent, racial composition, and housing tenure. The residents live through three years. Second-order effects cascade: a landlord facing rent control sells the building, so tenants get displaced; a wage floor rises, so a small business cuts hours. An inequality spotlight then reveals exactly who gets hurt and who benefits — broken down by race, income, and housing status — with the Gini coefficient before and after. Then we close the civic loop three ways. First, hear the people: click any resident to read — and now hear — their AI-generated, first-person story, voiced aloud with a voice matched to that resident. Second, find your fit: a personalized mode simulates whether a specific location is a good fit for you in particular, based on your own situation, rather than the community in aggregate. Third, act on it: "Email your representative" looks up your real federal and state legislators, then drafts a respectful constituent email citing the exact bill and the simulation's findings about who it affects — opening a prefilled email for state legislators, or the official contact form with the draft copied for members of Congress. Two more surfaces back up the credibility. A /validate page runs real, studied policies (like San Francisco's 1994 rent-control expansion) through the same engine and checks the predicted direction of effects against published research. And a /lab runs Monte Carlo uncertainty bands and sensitivity analysis, so you see the range of outcomes rather than a single cherry-picked run.

### How we built it

Our stack is Next.js (App Router, React Server Components, server-sent-event streaming), React, TypeScript, and Tailwind on the front end. Agent orchestration runs on Mastra with multiple Anthropic Claude agents. Deepgram Aura powers the resident voices. Mapbox GL + deck.gl render the 3D map and geocoding. Live civic data comes from U.S. Census ACS, Congress.gov, OpenStates, and GNews, with optional Redis mirroring every run. The engine is the technical core. We ground a population in live Census ACS data, then spawn individual residents whose joint distribution of race × income × housing tenure × neighborhood matches the real community — so a low-income renter of a given background appears at the real-world rate, not by independent chance, and spatial segregation emerges naturally. A Claude PolicyAnalyst agent parses a free-text bill into a structured impact model: mechanism, intensity, who benefits, who pays, and likely unintended consequences. The simulation then runs in two passes per round. In the first pass, actor agents — small landlords and business owners — make probabilistic decisions that generate shocks. In the second, those shocks ripple out to every other resident across Month 1 → Month 6 → Year 1 → Year 3, tracking rent burden, displacement, wellbeing, and whether each person had to leave. The entire run streams to the dashboard in real time over SSE, backed by a durable event log so a shared link can replay the whole run from scratch. Graceful degradation is built in: with zero API keys the app still runs end to end on heuristic parsing, template stories, and clearly-labeled fallback datasets. Every live surface degrades to an honest empty state rather than ever inventing data.

### Challenges we ran into

The first challenge was making synthetic residents representative, not just random. Matching each demographic on its own is easy; matching the joint distribution — so income–tenure correlation and segregation emerge naturally — is what makes the inequality analysis meaningful rather than decorative. That took real care in the spawning logic. The second was a subtle, invisible metric bug. Per-group impact was being read before each run finalized, so the headline "net impact by demographic" panel silently showed zero for every group, on every policy. We caught it by testing deterministically across policy types, confirmed the real differentiated numbers existed, and fixed where they were surfaced rather than touching the numbers themselves. The third was keeping the model honest. It would have been easy to make every bill "show displacement" for drama. We made the engine principled instead: displacement only appears where a real housing mechanism exists, so a privacy law correctly shows ~0% while rent control and upzoning move the needle. The fourth was voice without overreach. We match Deepgram voices to resident gender (inferred from our name pools) and a slight age lean — deliberately not race, to avoid stereotyping — and keep it deterministic so a resident always sounds the same.

### Accomplishments we're proud of

We built a simulation grounded in real U.S. Census data that produces genuinely differentiated, defensible distributional outcomes rather than random noise dressed up as analysis. We wired seven real external data and AI integrations — Census, Congress.gov, OpenStates, GNews, Mapbox, Anthropic, and Deepgram — into one coherent product, each with an honest fallback. We landed an emotional payload that actually works: hearing a displaced resident tell their own story in a human voice turns an abstract statistic into something you feel. We treated intellectual honesty as a design principle, with directional validation against published studies, visible uncertainty bands, and honest empty states instead of fabricated data. And we closed the loop from awareness to action: you don't just learn who a bill hurts, you can email the person who votes on it in two clicks.

### What we learned

The hard part of "AI for policy" isn't the AI — it's earning trust. Real Census grounding, directional validation, transparent assumptions, and honest empty states matter more than any single model output. PolicyPulse is a tool for building intuition about who's affected, not a forecast, and stating that clearly makes it more credible, not less. We also learned how much rigor it takes to make a simulation defensible: the difference between "the numbers look plausible" and "we can show you why each number is what it is" is most of the work.

### What's next

for PolicyPulse We want deeper validation against more published studies, additional policy mechanisms beyond housing and labor, and finer-grained Census grounding at the county and city level. We also want to grow the personalized "find your fit" mode into a full planner that answers a bigger question: where should I live, and how would these policies actually treat someone like me?

## README (from the GitHub repository)

# PolicyPulse

**See the law moving around you — then stress-test it on a digital twin of your own community.**

PolicyPulse has two halves that flow into each other:

1. **The Pulse Map** (homepage) — a live, tilted 3D map of the United States that finds your area and surfaces the *real* federal and state bills moving around you right now, alongside a rail of local policy news. No mock data: every marker is a real bill from Congress.gov / OpenStates, every story is real local news.
2. **The Simulator** (`/simulate`) — click any bill (or paste your own) and watch it play out across a statistically representative population of AI residents **built from live U.S. Census (ACS) data** for that state. Residents live through three years; second-order effects cascade (a landlord sells → tenants get displaced; a wage floor rises → a small business cuts hours), and an inequality spotlight reveals exactly **who gets hurt** — before anyone votes.

---

## What it does

### The Pulse Map (homepage)

- **Finds your area** from the browser's geolocation (or a ZIP / city search), reverse-geocoded with Mapbox.
- **Pulls the real bills around you** — live federal legislation from the Congress.gov API and state legislation from OpenStates — and plots them as glowing markers on a 3D map that flies to your state.
- **Streams local policy news** for your area from GNews in an auto-scrolling rail.
- Click a marker to read the bill, then **“Simulate this policy”** hands it off to the simulator.

### The Simulator (`/simulate`)

1. **A bill arrives from the map** (or you paste your own and pick a state).
2. **Ingestion** grounds the population in **live U.S. Census ACS data** for that state — real population, median income, rent, racial composition, income distribution, tenure, and industry mix. Without a Census key it falls back to grounded, clearly-labeled datasets.
3. A **Mastra PolicyAnalyst agent** parses the free-text bill into a structured *impact model* (mechanism, intensity, who benefits, who pays, likely unintended consequences).
4. **Proportional spawning** creates individual residents whose joint distribution of race × neighborhood × income × tenure × employment matches the real community.
5. Residents **live through Month 1 → Year 3**. Each round layers market drift, direct policy effects, and **cascading shocks** between agents — streamed live to the dashboard.
6. The **Inequality Spotlight** quantifies disparities (e.g., displacement by race), the Gini shift, materialized unintended consequences, and ranked *who-gets-hurt / who-benefits* segments.
7. Click any resident to read their **AI-generated first-person story** (a Mastra Resident agent on Claude Haiku).

There's also a **/validate** page that runs real, studied policies (e.g., SF's 1994 rent-control expansion) through the same engine and compares the predicted *direction* of effects against the documented findings of published research.

## Tech & sponsor integrations

| Layer | Tech |
| --- | --- |
| Framework | **Next.js 16** (App Router, RSC, SSE streaming), React 19, TypeScript, Tailwind v4 |
| Agents & orchestration | **Mastra** — `PolicyAnalyst` + `Resident` agents, a jurisdiction-ingestion tool, and a 2-step simulation workflow |
| LLM | **Anthropic Claude Haiku** via Mastra's model router |
| Nervous system | **Redis** — Streams (event log), JSON (snapshots), TimeSeries (metric trends), Pub/Sub |
| Live civic data | **U.S. Census ACS** (population), **Congress.gov** (federal bills), **OpenStates** (state bills), **GNews** (local news), **Mapbox** (3D map + geocoding) |
| Live ingestion | **Browserbase** — optional headless session to verify community data |
| Viz | **Mapbox GL + deck.gl** (3D map), Recharts, Framer Motion, Lucide |

**Graceful degradation is a feature:** with *no API keys at all*, the app still runs end-to-end — heuristic policy parsing, template resident stories, grounded datasets, and an in-memory event bus. Each live surface degrades to an **honest empty state** (“connect this key” / “no results”) rather than ever inventing data: no Mapbox token shows a connect-token prompt, no Census key falls back to labeled datasets, and no Congress / OpenStates / GNews key shows an empty rail. Add keys to light up the live integrations.

## Getting started

```bash
npm install
npm run dev
```

Open the printed URL (e.g. http://localhost:3000). With no keys you'll get the Pulse Map's “connect Mapbox” prompt and the `/simulate` dashboard running on labeled datasets — fully functional. Add keys (below) to go fully live, then restart `npm run dev` (env vars load on boot).

### Environment variables

Copy `.env.example` to `.env.local` and add what you have — **everything is optional** and each key lights up one surface:

**Live Pulse Map + Census-grounded population:**

- `NEXT_PUBLIC_MAPBOX_TOKEN` — renders the 3D map and powers geolocation / search ([account.mapbox.com](https://account.mapbox.com/access-tokens/)).
- `CENSUS_API_KEY` — grounds the simulated population in real ACS data ([api.census.gov](https://api.census.gov/data/key_signup.html)).
- `CONGRESS_API_KEY` — real federal bills ([api.congress.gov](https://api.congress.gov/sign-up/)).
- `OPENSTATES_API_KEY` — real state-legislature bills ([open.pluralpolicy.com](https://open.pluralpolicy.com/accounts/profile/)).
- `GNEWS_API_KEY` — real local policy news ([gnews.io](https://gnews.io/)).

**Simulator agents + nervous system:**

- `ANTHROPIC_API_KEY` — enables the Mastra Haiku agents (policy analysis + resident stories).
- `REDIS_URL` — mirrors every run into Redis Stack (Streams/JSON/TimeSeries/Pub-Sub).
- `BROWSERBASE_API_KEY` + `BROWSERBASE_PROJECT_ID` + `ENABLE_BROWSERBASE=1` — optional live ingestion (also `npm i playwright-core`).

## Architecture

```
src/
  app/
    page.tsx                 Pulse Map landing (live bills near you, client)
    simulate/page.tsx        The simulator dashboard (client)
    validate/page.tsx        Historical validation
    api/
      geo/                   Reverse/forward geocoding (Mapbox)
      policies/              Real federal (Congress.gov) + state (OpenStates) bills
      news/                  Real local policy news (GNews)
      census/                Live ACS demographic profile for a state
      simulate/              POST: start a run
      stream/[runId]/        SSE: live event stream (backlog replay + live tail)
      run/[runId]/           Full run snapshot
      agent/[runId]/[id]/    Resident narrative (Haiku) + trajectory
      runs/ · health/ · validate/
  lib/
    civic.ts                 Pulse Map types + geometry (markers, arcs)
    usePulse.ts              Homepage hook (locate -> fetch bills/news/census)
    states.ts                US state metadata (FIPS, centroids)
    cache.ts                 TTL cache for live API calls (quota-friendly)
    sources/                 census · congress · openstates · news · geocode
    engine.ts                Simulation engine (rounds, cascades, second-order effects)
    metrics.ts               Aggregation + inequality analysis (disparities, segments, Gini)
    personas.ts              Proportional agent spawning
    demographics.ts          Grounded Census/ACS/BLS-shaped fallback datasets
    policy.ts                Heuristic policy parser (offline brain)
    orchestrator.ts          Paced live run driver -> bus events
    bus.ts · runStore.ts · redis.ts   The "nervous system"
    ingest.ts                Jurisdiction ingestion (live Census, + optional Browserbase)
    historical.ts            Validation cases vs. published studies
    useSimulation.ts         Simulator client hook (SSE consumption, rAF-batched)
  mastra/
    index.ts                 Mastra instance
    agents/                  policy-analyst, resident
    tools/                   ingest-jurisdiction
    workflows/               simulation
  components/                Pulse Map (PulseMap, NewsRail, PolicyDetail, LocationBadge) + dashboard UI
```

## A note on hon

[README truncated for size]

## Detected evidence (automated analysis)

Indexed codebase: 144 recognized source files, 839 KB.
- CSS (language) — detected in the code
- Next.js (technology) — detected in the code
- React (technology) — detected in the code
- Redis (technology) — detected in the code
- Tailwind CSS (technology) — detected in the code
- TypeScript (language) — detected in the code
- Anthropic (technology) — claimed on Devpost, not found in the code
- Node.js (technology) — claimed on Devpost, not found in the code
- AI coding agent: Claude Code — evidence: config files committed to the repository
- AI coding agent: Codex — evidence: config files committed to the repository

## Codebase structure (from repository index)

### Files (120 of 153)

```
.claude/skills/frontend-design/SKILL.md
.env.example
.gitignore
AGENTS.md
CLAUDE.md
docs/presentation/PolicyPulse.md
eslint.config.mjs
NEXT_STEPS.md
next.config.ts
package.json
postcss.config.mjs
README.md
scripts/shots.mjs
scripts/shots2.mjs
scripts/shots3.mjs
src/app/api/agent/[runId]/[agentId]/route.ts
src/app/api/census/route.ts
src/app/api/compare/route.ts
src/app/api/council/route.ts
src/app/api/council/stream/[runId]/route.ts
src/app/api/draft-email/route.ts
src/app/api/geo/route.ts
src/app/api/ghost/health/route.ts
src/app/api/ghost/narrate/route.ts
src/app/api/ghost/route.ts
src/app/api/ghost/run/[runId]/route.ts
src/app/api/ghost/stream/[runId]/route.ts
src/app/api/health/route.ts
src/app/api/impact/route.ts
src/app/api/montecarlo/route.ts
src/app/api/news/route.ts
src/app/api/policies/route.ts
src/app/api/representatives/route.ts
src/app/api/run/[runId]/route.ts
src/app/api/runs/route.ts
src/app/api/sensitivity/route.ts
src/app/api/simulate/route.ts
src/app/api/speak/route.ts
src/app/api/stream/[runId]/route.ts
src/app/api/validate/route.ts
src/app/council/page.tsx
src/app/ghost/page.tsx
src/app/globals.css
src/app/lab/page.tsx
src/app/layout.tsx
src/app/me/page.tsx
src/app/page.tsx
src/app/runs/page.tsx
src/app/simulate/page.tsx
src/app/validate/page.tsx
src/components/AgentDrawer.tsx
src/components/AgentGrid.tsx
src/components/AppHeader.tsx
src/components/Brand.tsx
src/components/ContactRep.tsx
src/components/council/AmendmentImpact.tsx
src/components/council/CouncilBench.tsx
src/components/council/CouncilConsole.tsx
src/components/council/DebateStream.tsx
src/components/council/DeliberationFlow.tsx
src/components/council/GroundingBrief.tsx
src/components/council/IntegrationRail.tsx
src/components/council/NarrationFeed.tsx
src/components/council/Verdict.tsx
src/components/EventTicker.tsx
src/components/ghost/AgentPanels.tsx
src/components/ghost/CrisisHud.tsx
src/components/ghost/IntegrationRail.tsx
src/components/ghost/NarrationFeed.tsx
src/components/ghost/NegotiationBus.tsx
src/components/ghost/PostMortem.tsx
src/components/ghost/ScenarioConsole.tsx
src/components/ghost/WorkflowViz.tsx
src/components/ghost/WorldGraph.tsx
src/components/InequalitySpotlight.tsx
src/components/IngestionPanel.tsx
src/components/lab/CompareView.tsx
src/components/lab/labFormat.ts
src/components/lab/MonteCarloView.tsx
src/components/lab/SensitivityView.tsx
src/components/LocationBadge.tsx
src/components/MetricsTimeline.tsx
src/components/NewsRail.tsx
src/components/persona/PersonaForm.tsx
src/components/persona/PersonalImpactCard.tsx
src/components/PolicyConsole.tsx
src/components/PolicyDetail.tsx
src/components/PulseMap.tsx
src/components/ThemeProvider.tsx
src/components/ThemeToggle.tsx
src/lib/bus.ts
src/lib/cache.ts
src/lib/civic.ts
src/lib/council/brain.ts
src/lib/council/bus.ts
src/lib/council/grounding.ts
src/lib/council/orchestrator.ts
src/lib/council/stakeholders.ts
src/lib/council/types.ts
src/lib/council/ui.ts
src/lib/council/useCouncil.ts
src/lib/demographics.ts
src/lib/engine.ts
src/lib/ghost/agents.ts
src/lib/ghost/brain.ts
src/lib/ghost/bus.ts
src/lib/ghost/engine.ts
src/lib/ghost/grounding.ts
src/lib/ghost/integrations.ts
src/lib/ghost/observability.ts
src/lib/ghost/orchestrator.ts
src/lib/ghost/orkes.ts
src/lib/ghost/redis.ts
src/lib/ghost/runStore.ts
src/lib/ghost/scenarios.ts
src/lib/ghost/types.ts
src/lib/ghost/ui.ts
src/lib/ghost/useGhost.ts
src/lib/headless.ts
src/lib/historical.ts
[33 more files omitted for size]
```

### Dependencies

- package.json: @browserbasehq/sdk@^2.14.1, @deck.gl/core@^9.3.4, @deck.gl/layers@^9.3.4, @deck.gl/mapbox@^9.3.4, @deck.gl/react@^9.3.4, @mastra/core@^1.45.0, @tailwindcss/postcss@^4, @types/node@^20, @types/react@^19, @types/react-dom@^19, clsx@^2.1.1, deck.gl@^9.3.4, eslint@^9, eslint-config-next@16.2.9, framer-motion@^12.40.0, lucide-react@^1.21.0, mapbox-gl@^3.25.0, next@16.2.9, playwright-core@^1.61.0, react@19.2.4, react-dom@19.2.4, react-map-gl@^8.1.1, recharts@^3.8.1, redis@^6.0.0, tailwind-merge@^3.6.0, tailwindcss@^4, typescript@^5, zod@^4.4.3

### Recent commits (newest first)

- fix ui
- Merge remote-tracking branch 'origin/main'
- personalization is nice
- Merge origin/main (5 teammate PRs) into local work
- Add Stakeholder Council, presentation docs, and agent/data refinements
- fix outline for input
- Merge pull request #5 from lukeeskinner/abrham/homepage-polish-perf
- Zoom header logo mark
- Merge pull request #4 from lukeeskinner/zablon/sim-fixes
- Homepage: click-to-locate, logo, faster first load
- Merge remote-tracking branch 'origin/main' into zablon/sim-fixes
- feat: enhance AgentGrid with donut visualization and group stats
- Fix all-zero per-group net impact + flesh out results page
- Keep map dark in both themes (demo-safe)
- Fix zoning displacement gap + fill Live Feed post-run
- Merge light/dark theme + point-to-locate map dwell into main
- Suppress hydration warning from browser-extension body attributes
- Add polished light/dark theme with toggle
- Merge pull request #3 from lukeeskinner/zablon/resident-voice
- Add point-to-locate map dwell UX

## Key source files (fetched from GitHub, selected and truncated for size)

### CLAUDE.md

```markdown
@AGENTS.md

```

### AGENTS.md

```markdown
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know

This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. do not manufacture dat, NO FAKE MOCK DATA OR IMPLEMENTATIONS ST
<!-- END:nextjs-agent-rules -->

```

### package.json

```
{
  "name": "policypulse",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "eslint"
  },
  "dependencies": {
    "@browserbasehq/sdk": "^2.14.1",
    "@deck.gl/core": "^9.3.4",
    "@deck.gl/layers": "^9.3.4",
    "@deck.gl/mapbox": "^9.3.4",
    "@deck.gl/react": "^9.3.4",
    "@mastra/core": "^1.45.0",
    "clsx": "^2.1.1",
    "deck.gl": "^9.3.4",
    "framer-motion": "^12.40.0",
    "lucide-react": "^1.21.0",
    "mapbox-gl": "^3.25.0",
    "next": "16.2.9",
    "playwright-core": "^1.61.0",
    "react": "19.2.4",
    "react-dom": "19.2.4",
    "react-map-gl": "^8.1.1",
    "recharts": "^3.8.1",
    "redis": "^6.0.0",
    "tailwind-merge": "^3.6.0",
    "zod": "^4.4.3"
  },
  "devDependencies": {
    "@tailwindcss/postcss": "^4",
    "@types/node": "^20",
    "@types/react": "^19",
    "@types/react-dom": "^19",
    "eslint": "^9",
    "eslint-config-next": "16.2.9",
    "tailwindcss": "^4",
    "typescript": "^5"
  }
}

```

### src/mastra/index.ts

```typescript
import { Mastra } from "@mastra/core/mastra";
import { advocateAgent } from "./agents/advocate";
import { policyAnalystAgent } from "./agents/policy-analyst";
import { residentAgent } from "./agents/resident";
import { ghostReasoningAgent } from "./agents/ghost";
import { ingestJurisdictionTool } from "./tools/ingest-jurisdiction";
import { simulationWorkflow } from "./workflows/simulation";

// The Mastra instance: policy analysis + resident narration for PolicyPulse,
// the Ghost Protocol crisis-reasoning agent, the jurisdiction ingestion tool,
// and the simulation workflow.
export const mastra = new Mastra({
  agents: {
    policyAnalyst: policyAnalystAgent,
    resident: residentAgent,
    advocate: advocateAgent,
    ghostReasoner: ghostReasoningAgent,
  },
  workflows: {
    simulation: simulationWorkflow,
  },
  tools: {
    ingestJurisdiction: ingestJurisdictionTool,
  },
});

```

### src/app/layout.tsx

```typescript
import type { Metadata } from "next";
import { Space_Grotesk, Inter, Instrument_Serif, JetBrains_Mono } from "next/font/google";
import { ThemeProvider } from "@/components/ThemeProvider";
import "./globals.css";

// Applied before paint so there's no flash and no hydration mismatch: dark is
// the default, a stored choice wins, otherwise we respect the system setting.
const themeScript = `(function(){try{var t=localStorage.getItem('pp-theme');if(t!=='light'&&t!=='dark'){t=window.matchMedia('(prefers-color-scheme: light)').matches?'light':'dark';}document.documentElement.setAttribute('data-theme',t);}catch(e){document.documentElement.setAttribute('data-theme','dark');}})();`;

const display = Space_Grotesk({
  variable: "--font-grotesk",
  subsets: ["latin"],
  weight: ["400", "500", "600", "700"],
});

const sans = Inter({
  variable: "--font-inter",
  subsets: ["latin"],
});

const serif = Instrument_Serif({
  variable: "--font-instrument",
  subsets: ["latin"],
  weight: "400",
  style: ["normal", "italic"],
});

const mono = JetBrains_Mono({
  variable: "--font-jetbrains",
  subsets: ["latin"],
});

export const metadata: Metadata = {
  title: "PolicyPulse — See the law land before it passes",
  description:
    "A live civic map of the bills moving around you, paired with a demographic digital twin that shows exactly who each policy helps and who it hurts.",
  icons: {
    icon: "/policypulse-icon.png",
    shortcut: "/policypulse-icon.png",
    apple: "/policypulse-icon.png",
  },
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html
      lang="en"
      suppressHydrationWarning
      className={`${display.variable} ${sans.variable} ${serif.variable} ${mono.variable} h-full antialiased`}
    >
      <body className="min-h-full flex flex-col" suppressHydrationWarning>
        <script dangerouslySetInnerHTML={{ __html: themeScript }} />
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  );
}

```

### src/app/page.tsx

```typescript
"use client";

import dynamic from "next/dynamic";
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { motion } from "framer-motion";
import { AlertTriangle, ArrowRight, Crosshair, FlaskRound, Gavel, Ghost, Landmark, Layers, Loader2, Radar, Search, UserRound } from "lucide-react";
import { PulseLine } from "@/components/Brand";
import { LocationBadge } from "@/components/LocationBadge";
import { ThemeToggle } from "@/components/ThemeToggle";
import { NewsRail } from "@/components/NewsRail";
import type { Bill, LocationError, SourceState, UserArea } from "@/lib/civic";
import { usePulse, type PulseSnapshot } from "@/lib/usePulse";

const HAS_MAP = !!process.env.NEXT_PUBLIC_MAPBOX_TOKEN;

const reveal = (delay = 0) => ({
  initial: { opacity: 0, y: 12 },
  animate: { opacity: 1, y: 0 },
  transition: { duration: 0.45, delay, ease: [0.22, 1, 0.36, 1] as const },
});

const PulseMap = dynamic(() => import("@/components/PulseMap").then((m) => m.PulseMap), {
  ssr: false,
  loading: () => <MapLoading />,
});

// Deferred: pulls in framer-motion and only renders once a marker is opened,
// so it stays out of the initial homepage bundle.
const PolicyDetail = dynamic(() => import("@/components/PolicyDetail").then((m) => m.PolicyDetail), {
  ssr: false,
});

export default function Home() {
  const { snapshot, search, locate, locatePoint } = usePulse();
  const [selectedId, setSelectedId] = useState<string | null>(null);
  const router = useRouter();

  // Derive the open marker from current geometry so the detail auto-closes when
  // the area (and thus the markers) change — no synchronizing effect needed.
  const selected = snapshot.geo.markers.find((m) => m.id === selectedId) ?? null;

  const simulate = (bill: Bill) => {
    const area = snapshot.area;
    const qs = new URLSearchParams({ policy: `${bill.identifier} — ${bill.title}` });
    if (area) {
      qs.set("jurisdiction", area.region);
      qs.set("state", area.regionCode);
      qs.set("label", area.label);
      // Pass coordinates so the simulator can look up the user's specific
      // state legislators (OpenStates geo) for the "email your rep" flow.
      if (Number.isFinite(area.lat) && Number.isFinite(area.lng)) {
        qs.set("lat", String(area.lat));
        qs.set("lng", String(area.lng));
      }
    }
    router.push(`/simulate?${qs.toString()}`);
  };

  const totalBills = snapshot.federal.length + snapshot.state.length;

  return (
    <div className="min-h-screen flex flex-col">
      <header className="relative border-b border-line backdrop-blur sticky top-0 z-30 bg-ink/80">
        <div className="max-w-[1600px] mx-auto px-4 lg:px-6 py-3 flex items-center justify-between gap-3">
          <div className="flex items-center gap-3">
            <div className="relative w-9 h-9 overflow-hidden rounded-xl ring-1 ring-line shadow-[0_0_18px_rgba(110,139,255,0.28)] bg-surface">
              <Image
                src="/policypulse-icon.png"
                alt="PolicyPulse logo"
                width={54}
                height={54}
                priority
                className="absolute left-1/2 top-1/2 w-[54px] h-[54px] max-w-none -translate-x-1/2 -translate-y-1/2 object-cover"
              />
            </div>
            <div>
              <h1 className="font-display text-base font-semibold tracking-tight text-slate-50 leading-none">
                Policy<span className="text-signal-bright">Pulse</span>
              </h1>
              <p className="eyebrow mt-1.5">The law, moving around you</p>
            </div>
          </div>
          <div className="flex items-center gap-3">
            <LocationBadge
              area={snapshot.area}
              locating={snapshot.locating}
              error={snapshot.locationError}
              onSearch={search}
              onUseLocation={locate}
            />
            <Link
              href="/me"
              className="hidden sm:flex items-center gap-1.5 text-xs text-slate-300 hover:text-signal-bright border border-line hover:border-signal/50 rounded-full px-3 py-1.5 transition-colors"
            >
              <UserRound className="w-3.5 h-3.5" /> My Pulse
            </Link>
            <Link
              href="/ghost"
              className="hidden sm:flex items-center gap-1.5 text-xs text-slate-300 hover:text-signal-bright border border-line hover:border-signal/50 rounded-full px-3 py-1.5 transition-colors"
            >
              <Ghost className="w-3.5 h-3.5" /> Ghost Protocol
            </Link>
            <Link
              href="/council"
              className="hidden sm:flex items-center gap-1.5 text-xs text-slate-300 hover:text-signal-bright border border-line hover:border-signal/50 rounded-full px-3 py-1.5 transition-colors"
            >
              <Gavel className="w-3.5 h-3.5" /> Council
            </Link>
            <Link
              href="/lab"
              className="hidden sm:flex items-center gap-1.5 text-xs text-slate-300 hover:text-signal-bright border border-line hover:border-signal/50 rounded-full px-3 py-1.5 transition-colors"
            >
              <Layers className="w-3.5 h-3.5" /> Lab
            </Link>
            <Link
              href="/runs"
              className="hidden sm:flex items-center gap-1.5 text-xs text-slate-300 hover:text-signal-bright border border-line hover:border-signal/50 rounded-full px-3 py-1.5 transition-colors"
            >
              <FlaskRound className="w-3.5 h-3.5" /> Runs
            </Link>
            <Link
              href="/simulate"
              className="hidden sm:flex items-center gap-1.5 text-xs text-slate-300 hover:text-signal-bright border border-line hover:border-signal/50 rounded-full px-3 py-1.5 transition-colors"
            >
              Open simulator <ArrowRight className="w-3.5 h-3.5" />
            </Link>
            <ThemeToggle />
   
[truncated — 7944 more characters]
```

### src/app/runs/page.tsx

```typescript
"use client";

import { useEffect, useState } from "react";
import Link from "next/link";
import { motion } from "framer-motion";
import { ArrowRight, Database, FlaskConical, Gavel, Ghost, Layers, Map as MapIcon, Plus } from "lucide-react";
import { AppHeader, NavPill } from "@/components/AppHeader";
import type { RunMeta } from "@/lib/types";
import { cn } from "@/lib/utils";

interface RedisHealth {
  configured: boolean;
  connected: boolean;
}

function relativeTime(ts: number): string {
  if (!ts) return "";
  const diff = Date.now() - ts;
  const m = Math.round(diff / 60000);
  if (m < 1) return "just now";
  if (m < 60) return `${m}m ago`;
  const h = Math.round(m / 60);
  if (h < 24) return `${h}h ago`;
  const d = Math.round(h / 24);
  return `${d}d ago`;
}

const STATUS_STYLE: Record<string, string> = {
  complete: "bg-emerald-500/15 text-emerald-300",
  running: "bg-signal/15 text-signal-bright",
  error: "bg-rose-500/15 text-rose-300",
};

const container = { hidden: {}, show: { transition: { staggerChildren: 0.05 } } };
const item = { hidden: { opacity: 0, y: 12 }, show: { opacity: 1, y: 0, transition: { duration: 0.4, ease: [0.22, 1, 0.36, 1] as const } } };

export default function RunsPage() {
  const [runs, setRuns] = useState<RunMeta[] | null>(null);
  const [redis, setRedis] = useState<RedisHealth | null>(null);

  useEffect(() => {
    fetch("/api/runs")
      .then((r) => r.json())
      .then((d) => {
        setRuns(d.runs ?? []);
        setRedis(d.redis ?? null);
      })
      .catch(() => setRuns([]));
  }, []);

  return (
    <div className="min-h-screen">
      <AppHeader section="Runs" subtitle="Replay & share saved simulations">
        <NavPill href="/" icon={<MapIcon className="w-3.5 h-3.5" />} label="Pulse Map" />
        <NavPill href="/ghost" icon={<Ghost className="w-3.5 h-3.5" />} label="Ghost" />
        <NavPill href="/council" icon={<Gavel className="w-3.5 h-3.5" />} label="Council" />
        <NavPill href="/lab" icon={<Layers className="w-3.5 h-3.5" />} label="Lab" />
        <NavPill href="/validate" icon={<FlaskConical className="w-3.5 h-3.5" />} label="Validation" />
      </AppHeader>

      <main className="max-w-[1200px] mx-auto px-4 lg:px-6 py-6 pb-12 flex flex-col min-h-[calc(100vh-92px)]">
        <div className="flex items-end justify-between gap-4 mb-5 shrink-0">
          <div>
            <h2 className="font-display text-xl text-slate-100">Run gallery</h2>
            <p className="text-sm text-slate-400 max-w-2xl mt-1">
              Every simulation is saved here. Open one to replay its outcome, or copy its link to share a specific result.
            </p>
          </div>
          {redis && (
            <div className="hidden sm:flex items-center gap-1.5 text-[11px] text-slate-500 shrink-0">
              <Database className="w-3.5 h-3.5" />
              {redis.connected ? "Mirrored to Redis" : "In-memory · last 40 runs"}
            </div>
          )}
        </div>

        <div className="flex-1 flex flex-col justify-center">
          {runs === null ? (
            <div className="text-slate-600 text-sm py-16 text-center">Loading runs…</div>
          ) : (
            <motion.div variants={container} initial="hidden" animate="show" className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3">
              {runs.map((r) => (
                <motion.div key={r.runId} variants={item}>
                  <Link href={`/simulate?runId=${r.runId}`} className="group glass rounded-2xl p-4 hover:border-signal/50 hover:-translate-y-0.5 transition-all duration-200 flex flex-col h-full min-h-[150px]">
                    <div className="flex items-center justify-between gap-2 mb-2">
                      <span className="font-data text-[11px] text-slate-400">{r.jurisdiction}</span>
                      <span className={cn("text-[10px] px-1.5 py-0.5 rounded", STATUS_STYLE[r.status] ?? "bg-slate-800 text-slate-400")}>{r.status}</span>
                    </div>
                    <p className="text-sm font-medium text-slate-100 leading-snug line-clamp-2">{r.headline ?? r.policy}</p>
                    <p className="text-[12px] text-slate-500 mt-1.5 line-clamp-2 leading-relaxed">{r.policy}</p>
                    <div className="flex items-center justify-between mt-auto pt-3 text-[11px] text-slate-500">
                      <span>{r.agentCount > 0 ? `${r.agentCount} residents` : "snapshot"} · {relativeTime(r.createdAt)}</span>
                      <span className="flex items-center gap-1 text-signal-bright opacity-0 group-hover:opacity-100 transition-opacity">Open <ArrowRight className="w-3 h-3" /></span>
                    </div>
                  </Link>
                </motion.div>
              ))}
              <motion.div variants={item}>
                <Link href="/simulate" className="group glass rounded-2xl p-4 border-dashed hover:border-signal/50 hover:-translate-y-0.5 transition-all duration-200 flex flex-col items-center justify-center text-center h-full min-h-[150px]">
                  <span className="w-9 h-9 rounded-xl bg-signal/12 text-signal-bright flex items-center justify-center group-hover:bg-signal/20 transition-colors">
                    <Plus className="w-4 h-4" />
                  </span>
                  <p className="text-sm font-medium text-slate-200 mt-3">New simulation</p>
                  <p className="text-[11px] text-slate-500 mt-1">Stress-test a bill on a digital twin</p>
                </Link>
              </motion.div>
            </motion.div>
          )}
        </div>
      </main>
    </div>
  );
}

```

### src/app/validate/page.tsx

```typescript
"use client";

import { useEffect, useState } from "react";
import { motion } from "framer-motion";
import { ArrowDown, ArrowUp, ExternalLink, FlaskRound, Gavel, Ghost, Layers, Map as MapIcon, Minus, Play } from "lucide-react";
import { AppHeader, NavPill } from "@/components/AppHeader";
import { ThemeToggle } from "@/components/ThemeToggle";
import type { ActualMetric, HistoricalCase } from "@/lib/historical";
import { cn } from "@/lib/utils";

const container = { hidden: {}, show: { transition: { staggerChildren: 0.06 } } };
const item = { hidden: { opacity: 0, y: 14 }, show: { opacity: 1, y: 0, transition: { duration: 0.45, ease: [0.22, 1, 0.36, 1] as const } } };

type Direction = "up" | "down" | "mixed";
interface Predicted { value: number; display: string; direction: Direction }
type PredictedMap = Record<string, Predicted>;

export default function ValidatePage() {
  const [cases, setCases] = useState<HistoricalCase[]>([]);
  const [results, setResults] = useState<Record<string, PredictedMap>>({});
  const [loading, setLoading] = useState<Record<string, boolean>>({});

  useEffect(() => {
    fetch("/api/validate").then((r) => r.json()).then((d) => setCases(d.cases ?? [])).catch(() => {});
  }, []);

  const run = async (id: string) => {
    setLoading((l) => ({ ...l, [id]: true }));
    try {
      const res = await fetch("/api/validate", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ caseId: id }),
      });
      const data = await res.json();
      if (data.predicted) setResults((r) => ({ ...r, [id]: data.predicted }));
    } finally {
      setLoading((l) => ({ ...l, [id]: false }));
    }
  };

  return (
    <div className="min-h-screen">
      <AppHeader section="Validation" subtitle="Does the model match reality?">
        <NavPill href="/" icon={<MapIcon className="w-3.5 h-3.5" />} label="Pulse Map" />
        <NavPill href="/ghost" icon={<Ghost className="w-3.5 h-3.5" />} label="Ghost" />
        <NavPill href="/council" icon={<Gavel className="w-3.5 h-3.5" />} label="Council" />
        <NavPill href="/lab" icon={<Layers className="w-3.5 h-3.5" />} label="Lab" />
        <NavPill href="/runs" icon={<FlaskRound className="w-3.5 h-3.5" />} label="Runs" />
        <ThemeToggle />
      </AppHeader>

      <main className="max-w-[1100px] mx-auto px-4 lg:px-6 py-6 pb-16">
        <h2 className="font-display text-xl text-slate-100">Historical validation</h2>
        <p className="text-sm text-slate-400 max-w-2xl mb-6 mt-1">
          Does the model get reality right? Each card runs a real, studied policy through the same PolicyPulse engine and compares the predicted <em>direction</em> of effects against the documented findings of a published study. Figures are approximate reference points for directional validation.
        </p>

        <motion.div variants={container} initial="hidden" animate="show" className="space-y-4">
          {cases.map((c) => (
            <motion.div key={c.id} variants={item} className="glass rounded-2xl p-5 hover:border-line/80 transition-colors">
              <div className="flex items-start justify-between gap-4">
                <div>
                  <div className="flex items-center gap-2">
                    <h2 className="font-display text-base font-semibold text-slate-100">{c.title}</h2>
                    <span className="font-data text-[11px] text-slate-500">{c.jurisdiction} · {c.year}</span>
                  </div>
                  <p className="text-sm text-slate-400 mt-1.5 leading-relaxed max-w-3xl">{c.summary}</p>
                  <a href={c.sourceUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-[11px] text-signal-bright hover:text-signal mt-2">
                    {c.source} <ExternalLink className="w-3 h-3" />
                  </a>
                </div>
                <button
                  onClick={() => run(c.id)}
                  disabled={loading[c.id]}
                  className="shrink-0 flex items-center gap-2 rounded-xl px-3.5 py-2 text-sm font-medium bg-signal text-ink hover:bg-signal-bright hover:shadow-[0_0_20px_rgba(110,139,255,0.3)] disabled:opacity-50"
                >
                  <Play className="w-3.5 h-3.5" />
                  {loading[c.id] ? "Running…" : results[c.id] ? "Re-run" : "Validate"}
                </button>
              </div>

              <div className="mt-4 space-y-1.5">
                <div className="grid grid-cols-12 gap-2 font-data text-[10px] uppercase tracking-[0.12em] text-slate-500 px-1">
                  <span className="col-span-4">Metric</span>
                  <span className="col-span-4">Documented (actual)</span>
                  <span className="col-span-3">PolicyPulse predicted</span>
                  <span className="col-span-1 text-right">Match</span>
                </div>
                {c.actuals.map((m) => (
                  <Row key={m.key} actual={m} predicted={results[c.id]?.[m.key]} />
                ))}
              </div>
            </motion.div>
          ))}
        </motion.div>
      </main>
    </div>
  );
}

function Row({ actual, predicted }: { actual: ActualMetric; predicted?: Predicted }) {
  const verdict = !predicted
    ? null
    : actual.direction === "mixed" || predicted.direction === "mixed"
      ? { label: "Consistent", cls: "bg-amber-500/15 text-amber-300" }
      : actual.direction === predicted.direction
        ? { label: "Match", cls: "bg-emerald-500/15 text-emerald-300" }
        : { label: "Diverges", cls: "bg-rose-500/15 text-rose-300" };
  return (
    <div className="grid grid-cols-12 gap-2 items-center rounded-lg bg-ink/40 border border-line px-3 py-2 text-xs">
      <span className="col-span-4 text-slate-200">{actual.label}</span>
      <span className="col-span-4 font-data text-slate-400 flex items-center gap-1.5 tabular-nums">
        <Arrow dir={actual.direction} /> {actual.actual}
      </span>
      <
[truncated — 695 more characters]
```

### src/app/council/page.tsx

```typescript
"use client";

import { Fragment, useState } from "react";
import Link from "next/link";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { Activity, ArrowRight, Ban, FileEdit, FlaskConical, Gavel, Ghost, Handshake, Map as MapIcon, MessagesSquare, Radio, Scale } from "lucide-react";
import { PulseLine, PulseMark } from "@/components/Brand";
import { AmendmentImpact } from "@/components/council/AmendmentImpact";
import { CouncilBench } from "@/components/council/CouncilBench";
import { CouncilConsole } from "@/components/council/CouncilConsole";
import { DebateStream } from "@/components/council/DebateStream";
import { DeliberationFlow } from "@/components/council/DeliberationFlow";
import { GroundingBrief } from "@/components/council/GroundingBrief";
import { IntegrationRail } from "@/components/council/IntegrationRail";
import { NarrationFeed } from "@/components/council/NarrationFeed";
import { Verdict } from "@/components/council/Verdict";
import { useCouncil, type CouncilPhase } from "@/lib/council/useCouncil";
import { PRESETS } from "@/lib/ui";
import { cn } from "@/lib/utils";

const container = { hidden: {}, show: { transition: { staggerChildren: 0.07, delayChildren: 0.03 } } };
const item = {
  hidden: { opacity: 0, y: 12 },
  show: { opacity: 1, y: 0, transition: { duration: 0.45, ease: [0.22, 1, 0.36, 1] as const } },
};

export default function CouncilPage() {
  const { state, start, reset, voice, setVoice } = useCouncil(true);
  const [policy, setPolicy] = useState(PRESETS[0].policy);
  const [jurisdiction, setJurisdiction] = useState(PRESETS[0].jurisdiction);

  const active = state.status !== "idle";
  const running = state.status === "running";
  const voiceLabel = state.integrations?.deepgram ? "Deepgram Aura" : "Browser voice";
  const seatsLite = state.stakeholders.map((s) => ({ id: s.id, name: s.name, color: s.color }));
  const showVerdict = !!state.verdict;

  const consoleProps = {
    policy,
    onPolicyChange: setPolicy,
    jurisdiction,
    onJurisdictionChange: setJurisdiction,
    onConvene: (p: string, j: string) => start({ policy: p, jurisdiction: j, voice }),
    onReset: () => {
      reset();
      setPolicy(PRESETS[0].policy);
      setJurisdiction(PRESETS[0].jurisdiction);
    },
    running,
    voice,
    onToggleVoice: () => setVoice(!voice),
  };

  return (
    <div className="min-h-screen relative">
      <Atmosphere phase={state.phase} outcome={state.verdict?.outcome} />

      <header className="relative z-20 border-b border-line backdrop-blur sticky top-0 bg-ink/80">
        <div className="max-w-[1560px] mx-auto px-4 lg:px-6 py-3 flex items-center justify-between gap-3">
          <Link href="/" className="flex items-center gap-3 group shrink-0">
            <PulseMark className="w-9 h-9" live={running} />
            <div>
              <h1 className="font-display text-base font-semibold tracking-tight text-slate-50 leading-none group-hover:text-white flex items-center gap-1.5">
                <Gavel className="w-4 h-4 text-signal-bright" /> Stakeholder<span className="text-signal-bright">Council</span>
              </h1>
              <p className="eyebrow mt-1.5">A bill, debated by the people it touches — live</p>
            </div>
          </Link>
          <div className="flex items-center gap-2.5">
            <PhasePill phase={state.phase} />
            <Link href="/simulate" className="hidden md:flex items-center gap-1.5 text-xs text-slate-300 hover:text-signal-bright border border-line hover:border-signal/50 rounded-full px-3 py-1.5 transition-colors">
              <FlaskConical className="w-3.5 h-3.5" /> Simulator
            </Link>
            <Link href="/ghost" className="hidden md:flex items-center gap-1.5 text-xs text-slate-300 hover:text-signal-bright border border-line hover:border-signal/50 rounded-full px-3 py-1.5 transition-colors">
              <Ghost className="w-3.5 h-3.5" /> Ghost
            </Link>
            <Link href="/" className="hidden md:flex items-center gap-1.5 text-xs text-slate-300 hover:text-signal-bright border border-line hover:border-signal/50 rounded-full px-3 py-1.5 transition-colors">
              <MapIcon className="w-3.5 h-3.5" /> Pulse Map
            </Link>
          </div>
        </div>
        <PulseLine width={2200} height={20} className="absolute inset-x-0 -bottom-px h-5 opacity-70" />
      </header>

      <main className="relative z-10 max-w-[1560px] mx-auto px-4 lg:px-6 py-4">
        {!active ? (
          <Idle consoleProps={consoleProps} integrations={state.integrations} />
        ) : (
          <Cockpit state={state} consoleProps={consoleProps} seatsLite={seatsLite} voice={voice} setVoice={setVoice} voiceLabel={voiceLabel} running={running} />
        )}

        <AnimatePresence>
          {showVerdict && state.verdict && (
            <motion.div initial={{ opacity: 0, y: 18 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5 }} className="mt-4">
              <Verdict verdict={state.verdict} stakeholders={state.stakeholders} />
            </motion.div>
          )}
        </AnimatePresence>

        {state.status === "error" && (
          <motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} className="mt-4 glass rounded-2xl p-4 border border-rose-500/30 text-rose-300 text-sm">
            {state.error ?? "The council failed to convene."}
          </motion.div>
        )}
      </main>
    </div>
  );
}

function Atmosphere({ phase, outcome }: { phase: CouncilPhase; outcome?: string }) {
  const reduce = useReducedMotion();
  const live = phase === "debating" || phase === "retesting" || phase === "voting";
  const passed = outcome === "passed" || outcome === "passed_amended";
  const stuck = outcome === "deadlocked" || outcome === "failed";
  return (
    <div aria-hidden className="pointer-events-none fixed inset-0 z-0 overflow-hidden">
      <motion.div
        className="absolute inset-0"
        
[truncated — 8311 more characters]
```

### src/app/me/page.tsx

```typescript
"use client";

import Image from "next/image";
import Link from "next/link";
import { useCallback, useEffect, useRef, useState } from "react";
import {
  ArrowRight,
  FlaskRound,
  Ghost,
  Landmark,
  Layers,
  Loader2,
  Map as MapIcon,
  Pencil,
  Radar,
  Sparkles,
  UserRound,
} from "lucide-react";
import { PulseLine } from "@/components/Brand";
import { ThemeToggle } from "@/components/ThemeToggle";
import { PersonaForm } from "@/components/persona/PersonaForm";
import { PersonalImpactCard } from "@/components/persona/PersonalImpactCard";
import type { Bill, SourceState, UserArea } from "@/lib/civic";
import { roleShort } from "@/lib/ui";
import type { PersonalImpact, PersonalPolicyDigest, UserPersona } from "@/lib/types";
import { usePersona } from "@/lib/usePersona";
import { cn, fmtUSD } from "@/lib/utils";

interface ImpactResult {
  model: PersonalPolicyDigest;
  impact: PersonalImpact;
}

interface Selected {
  // Rich text sent to the analyst (title + summary); link text kept short.
  impactText: string;
  linkText: string;
  label: string;
}

export default function MyPulsePage() {
  const { persona, area, hydrated, save, clear } = usePersona();
  const [editing, setEditing] = useState(false);

  // Default to the editor only once we know there's no saved persona.
  useEffect(() => {
    if (hydrated && !persona) setEditing(true);
  }, [hydrated, persona]);

  const onSave = (p: UserPersona, a: UserArea | null) => {
    save(p, a);
    setEditing(false);
  };

  return (
    <div className="min-h-screen flex flex-col">
      <Header />
      <main className="flex-1 w-full max-w-[1400px] mx-auto px-4 lg:px-6 py-4 w-full">
        <Intro hasPersona={!!persona} />

        {!hydrated ? (
          <div className="glass rounded-2xl h-64 flex items-center justify-center">
            <Loader2 className="w-6 h-6 text-signal-bright animate-spin" />
          </div>
        ) : (
          <div className="grid grid-cols-12 gap-4 mt-4">
            <div className="col-span-12 lg:col-span-5 xl:col-span-4">
              {editing || !persona ? (
                <PersonaForm initial={persona} initialArea={area} onSave={onSave} />
              ) : (
                <PersonaSummary persona={persona} area={area} onEdit={() => setEditing(true)} onClear={clear} />
              )}
            </div>

            <div className="col-span-12 lg:col-span-7 xl:col-span-8">
              {persona && !editing ? (
                <PolicyTester persona={persona} area={area} />
              ) : (
                <PlaceholderPanel />
              )}
            </div>
          </div>
        )}
      </main>
    </div>
  );
}

// --- policy tester (bills near you + custom) + impact -----------------------

function PolicyTester({ persona, area }: { persona: UserPersona; area: UserArea | null }) {
  const [selected, setSelected] = useState<Selected | null>(null);
  const [result, setResult] = useState<ImpactResult | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const reqId = useRef(0);

  const test = useCallback(
    async (sel: Selected) => {
      const id = ++reqId.current;
      setSelected(sel);
      setResult(null);
      setError(null);
      setLoading(true);
      try {
        const res = await fetch("/api/impact", {
          method: "POST",
          headers: { "content-type": "application/json" },
          body: JSON.stringify({
            persona,
            policy: sel.impactText,
            jurisdiction: area?.label ?? area?.region,
            stateCode: area?.regionCode,
          }),
        });
        if (id !== reqId.current) return; // a newer request superseded this one
        if (!res.ok) {
          setError("Couldn’t assess that policy. Try again.");
          return;
        }
        setResult((await res.json()) as ImpactResult);
      } catch {
        if (id === reqId.current) setError("Couldn’t reach the impact service. Try again.");
      } finally {
        if (id === reqId.current) setLoading(false);
      }
    },
    [persona, area],
  );

  return (
    <div className="space-y-4">
      <PolicyPicker area={area} selectedLabel={selected?.label ?? null} onPick={test} />

      {loading && (
        <div className="glass rounded-2xl h-48 flex flex-col items-center justify-center gap-3 grid-bg">
          <Loader2 className="w-6 h-6 text-signal-bright animate-spin" />
          <p className="text-sm text-slate-400">Measuring how it lands on you…</p>
        </div>
      )}

      {error && !loading && (
        <div className="glass rounded-2xl p-5 text-sm text-amber-300">{error}</div>
      )}

      {result && !loading && (
        <PersonalImpactCard
          impact={result.impact}
          model={result.model}
          personaName={persona.name?.trim() || "You"}
          policyText={selected?.linkText ?? ""}
          area={area}
        />
      )}
    </div>
  );
}

function PolicyPicker({
  area,
  selectedLabel,
  onPick,
}: {
  area: UserArea | null;
  selectedLabel: string | null;
  onPick: (sel: Selected) => void;
}) {
  const [federal, setFederal] = useState<Bill[]>([]);
  const [state, setState] = useState<Bill[]>([]);
  const [status, setStatus] = useState<"idle" | "loading" | "ready" | "empty">("idle");
  const [custom, setCustom] = useState("");

  useEffect(() => {
    if (!area) {
      setStatus("idle");
      return;
    }
    let cancelled = false;
    setStatus("loading");
    fetch(`/api/policies?state=${area.regionCode}`)
      .then((r) => r.json())
      .then((d: { federal: Bill[]; state: Bill[]; sources: { congress: SourceState; openstates: SourceState } }) => {
        if (cancelled) return;
        setFederal(d.federal ?? []);
        setState(d.state ?? []);
        setStatus((d.federal?.length ?? 0) + (d.state?.length ?? 0) > 0 ? "ready" : "empty");
      })
      .catch(() => !cancelled && setStatus("empty"));
    return () => {
      cancelle
[truncated — 10472 more characters]
```

[134 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]