# Policyengine App

> Developing policyengine-app-v2 — the main React frontend for policyengine.org

- Skill: `tools-only/policyengine-app` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds add tools-only/policyengine-app`
- Raw SKILL.md: https://api.skillmd.com/api/skills/tools-only/policyengine-app/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: tools-only (https://skillmd.com/u/tools-only)
- Updated: 2026-09-09
- Page: https://skillmd.com/skills/tools-only/policyengine-app

---


# PolicyEngine app (v2)

Architecture and patterns for developing the main PolicyEngine web application at `policyengine.org`.

**Repository:** `PolicyEngine/policyengine-app-v2`

## Architecture

### Monorepo

```
policyengine-app-v2/
├── packages/
│   └── design-system/          # @policyengine/design-system (npm)
├── app/                        # Main Vite application
│   ├── src/
│   │   ├── pages/              # Page components (*.page.tsx)
│   │   ├── components/         # Shared UI (charts, layouts, modals)
│   │   ├── routing/            # Guards, router config
│   │   ├── hooks/              # Custom React hooks
│   │   ├── designTokens/       # Re-exports from design-system
│   │   ├── styles/             # Mantine theme, global PostCSS
│   │   ├── data/               # Static data (apps.json, posts/)
│   │   ├── adapters/           # API fetch wrappers
│   │   ├── api/                # React Query hooks
│   │   ├── contexts/           # React Context providers
│   │   ├── types/              # TypeScript interfaces
│   │   └── utils/              # Formatters, helpers
│   ├── public/                 # Static assets (logos, post images)
│   └── vite.config.mjs
├── turbo.json
└── package.json                # Bun workspaces root
```

### Tech stack

| Layer | Technology |
|-------|-----------|
| Package manager | **Bun** (primary), npm fallback |
| Build | Vite + Turbo |
| UI framework | **Mantine v8** |
| Routing | React Router v7 (`createBrowserRouter`) |
| Charts | **Recharts** (standard), Plotly (maps only) |
| Server state | React Query |
| Design tokens | `@policyengine/design-system` |
| Language | TypeScript |
| Formatting | Prettier + ESLint |
| Testing | Vitest |

### Dual SPA mode

`VITE_APP_MODE` controls which entry point builds:
- `website` — Full policyengine.org (pages, blog, research, embedded tools)
- `calculator` — Standalone calculator at app.policyengine.org

## Development

```bash
bun install                          # Install dependencies
bun run dev                          # Dev server (builds design-system first)
cd app && bun run prettier -- --write .  # Format before committing
bun run lint                         # Lint (CI uses --max-warnings 0)
bun run build                        # Production build
bun run test                         # Tests
```

## Design tokens

Import from `@/designTokens` (convenience layer that re-exports from the design-system package):

```tsx
import { colors, spacing, typography } from '@/designTokens';
```

**Never hardcode values:**
```tsx
// Wrong
style={{ color: '#319795', marginBottom: '16px' }}

// Correct
style={{ color: colors.primary[500], marginBottom: spacing.lg }}
```

See `policyengine-design-skill` for the full token reference.

## Mantine components

All UI uses Mantine v8. Key components:

```tsx
import { Stack, Group, Text, Button, Paper } from '@mantine/core';
import { colors, spacing } from '@/designTokens';

function PolicyCard({ title, description, onEdit }) {
  return (
    <Paper p={spacing.lg} withBorder>
      <Stack gap={spacing.sm}>
        <Text fw={600}>{title}</Text>
        <Text c={colors.text.secondary} fz="sm">{description}</Text>
        <Button variant="outline" onClick={onEdit}>Edit</Button>
      </Stack>
    </Paper>
  );
}
```

| Component | Usage |
|-----------|-------|
| `Stack`, `Group`, `Box` | Layout |
| `Text`, `Title` | Typography |
| `Button`, `ActionIcon` | Controls |
| `Paper`, `Card` | Containers |
| `Table`, `Modal`, `Tooltip` | Complex UI |
| `TextInput`, `Select`, `NumberInput` | Forms |

## Charts

### Recharts (standard for all new charts)

```tsx
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
import { ChartContainer, ChartWatermark, ImpactTooltip } from '@/components/charts';
import { colors } from '@/designTokens';

function RevenueChart({ data }) {
  return (
    <ChartContainer title="Revenue impact" csvData={data}>
      <ResponsiveContainer width="100%" height={400}>
        <BarChart data={data}>
          <XAxis dataKey="name" />
          <YAxis tickFormatter={(v) => v.toLocaleString('en-US', {
            style: 'currency', currency: 'USD', notation: 'compact', maximumFractionDigits: 1,
          })} />
          <Tooltip content={<ImpactTooltip />} />
          <Bar dataKey="value" fill={colors.primary[500]} />
        </BarChart>
      </ResponsiveContainer>
      <ChartWatermark />
    </ChartContainer>
  );
}
```

### Semantic chart colors

| Meaning | Token |
|---------|-------|
| Primary data | `colors.primary[500]` |
| Secondary | `colors.gray[400]` |
| Positive/gains | `colors.success` |
| Negative/losses | `colors.gray[600]` |
| Error | `colors.error` |

### Plotly (maps only)

Plotly is only used for geographic visualizations (choropleths, hex maps). All other charts use Recharts.

### Chart components

| Component | Purpose |
|-----------|---------|
| `ChartContainer` | Card wrapper with title, CSV download |
| `ChartWatermark` | PolicyEngine logo below chart |
| `ImpactTooltip` | Formatted hover tooltip |
| `ImpactBarLabel` | Values above/below bars |

## Routing

Routes in `app/src/WebsiteRouter.tsx`:

```
/:countryId/
  ├── (StaticLayout)
  │   ├── home (index)
  │   ├── research/:slug (blog posts)
  │   ├── brand/*, team, donate
  │   └── model
  ├── (AppLayout)
  │   └── :slug → AppPage.tsx (apps.json)
  └── (full-page embeds)
```

### Country guards

- `CountryGuardSimple` — Validates countryId, redirects to default
- `CountryAppGuard` — Validates slug+countryId for apps

### Adding a new page

1. Create `app/src/pages/MyPage.page.tsx`
2. Add route in `WebsiteRouter.tsx` under appropriate layout
3. Use `useParams()` for `countryId`

## Embedded apps (apps.json)

Interactive tools register in `app/src/data/apps/apps.json` and render via `AppPage.tsx`:

```json
{
  "type": "iframe",
  "slug": "marriage",
  "title": "Marriage calculator",
  "source": "https://marriage-zeta-beryl.vercel.app/",
  "countryId": "us",
  "displayWithResearch": true
}
```

See `policyengine-interactive-tools-skill` for the full embedding pattern.

## Ingredient CRUD pages

Policies, Reports, Simulations, and Populations follow a shared pattern using `IngredientReadView` and `RenameIngredientModal`. See `app/.claude/skills/ingredient-patterns.md` for details.

## Blog posts

Markdown files in `app/src/data/posts/articles/`. Metadata in `posts.json`.

## Sentence case

Strictly enforced everywhere:

```tsx
// Correct
<Title order={2}>Your saved policies</Title>

// Wrong
<Title order={2}>Your Saved Policies</Title>
```

Exceptions: proper nouns (PolicyEngine), acronyms (IRS), official names (Child Tax Credit).

## Deployment

- Automatic on push to `main` via Vercel
- Domain: `policyengine.org` (website), `app.policyengine.org` (calculator)
- Team: `policy-engine` scope

## Key files

| File | Purpose |
|------|---------|
| `app/src/WebsiteRouter.tsx` | Main routes |
| `app/src/pages/AppPage.tsx` | Renders embedded apps |
| `app/src/data/apps/apps.json` | Tool registry |
| `app/src/designTokens/` | Token imports |
| `app/src/components/charts/` | Chart components |
| `packages/design-system/` | Token source of truth |
| `app/.claude/skills/` | Local skills (design-tokens, chart-standards, ingredient-patterns) |

## URL patterns

### Production domains

| Domain | Purpose |
|--------|---------|
| `policyengine.org` | Marketing website, research, blog |
| `app.policyengine.org` | Calculator app (policies, households, reports) |

### Calculator URLs (app.policyengine.org)

```
app.policyengine.org/:countryId/                             # Dashboard
app.policyengine.org/:countryId/policies                     # Saved policies
app.policyengine.org/:countryId/policies/create               # Policy builder
app.policyengine.org/:countryId/households                    # Saved households
app.policyengine.org/:countryId/households/create             # Household builder
app.policyengine.org/:countryId/reports                       # Saved reports
app.policyengine.org/:countryId/reports/create                # Report builder
app.policyengine.org/:countryId/simulations                   # Saved simulations
app.policyengine.org/:countryId/simulations/create            # Simulation builder
app.policyengine.org/:countryId/report-output/:reportId       # Report output (overview)
app.policyengine.org/:countryId/report-output/:reportId/:subpage/:view  # Specific chart
```

**Report output subpages and views:**
```
/report-output/:reportId/budget                               # Budget overview
/report-output/:reportId/distributional/incomeDecile          # Distributional by income
/report-output/:reportId/distributional/wealthDecile          # Distributional by wealth
/report-output/:reportId/winners-losers/incomeDecile          # Winners/losers by income
/report-output/:reportId/winners-losers/wealthDecile          # Winners/losers by wealth
/report-output/:reportId/poverty/age                          # Poverty by age
/report-output/:reportId/poverty/gender                       # Poverty by gender
/report-output/:reportId/poverty/race                         # Poverty by race (US only)
/report-output/:reportId/deep-poverty/age                     # Deep poverty by age
/report-output/:reportId/deep-poverty/gender                  # Deep poverty by gender
/report-output/:reportId/inequality                           # Inequality measures
```

**Country IDs:** `us`, `uk`, `ca`, `ng`, `il`

### Website URLs (policyengine.org)

```
policyengine.org/:countryId/                     # Country home
policyengine.org/:countryId/research             # Research index
policyengine.org/:countryId/research/:slug       # Research article
policyengine.org/:countryId/blog                 # Blog index
policyengine.org/:countryId/blog/:postName       # Blog post
policyengine.org/:countryId/model                # Policy model explorer
policyengine.org/:countryId/:slug                # Embedded app (from apps.json)
```

### Legacy v1 URLs (DO NOT USE)

The old `policyengine-app` (v1) used a different URL pattern that no longer works:
```
# WRONG — v1 format, returns "App not found"
policyengine.org/us/policy?reform=73278&baseline=2&region=enhanced_us&timePeriod=2025
policyengine.org/us/reform/2/280039/over/2/us?focus=policyOutput.winnersAndLosers.incomeDecile
```

Always use `app.policyengine.org` for calculator functionality.

## Related skills

- `policyengine-design-skill` — Full token reference
- `policyengine-interactive-tools-skill` — Building standalone tools
- `policyengine-vercel-deployment-skill` — Deployment patterns
- `policyengine-writing-skill` — Content style
- `policyengine-api-skill` — Backend API

