PolicyEngine standalone tools
How to build calculators, dashboards, and visualizations that deploy independently to Vercel
and slot into policyengine.org as Next.js multizones. New tools are their own repos; the
host (policyengine-app-v2/website/) proxies a URL path to each tool's Vercel deployment.
Required stack
Scaffold: bunx create-next-app@latest my-tool --ts --app --tailwind --eslint --import-alias "@/*",
then bun add @policyengine/ui-kit and set up globals.css as above.
ui-kit components (verified 0.4.0)
Prefer these over hand-rolled equivalents; import from @policyengine/ui-kit:
| Need |
Component |
| Page shell / layout |
DashboardShell, SidebarLayout, SingleColumnLayout, InputPanel, ResultsPanel, Stack, Group, Container |
| Header + footer |
Header, Footer (Header props: navItems, logoSrc, linkComponent, countries — not variant/navLinks) |
| Inputs |
CurrencyInput, NumberInput, SelectInput, CheckboxInput, SliderInput, InputGroup |
| Primitives |
Button, Card, Badge, Tabs, Dialog, Select, Tooltip, Alert, Switch, SegmentedControl, … |
| Display |
MetricCard, SummaryText, DataTable, PolicyEngineWatermark |
| Charts |
ChartContainer, PEBarChart, PELineChart, PEAreaChart, PEWaterfallChart |
| Maps |
USDistrictChoroplethMap, UKConstituencyChoroplethMap, HexagonalMap, HouseholdGraph |
| Utils |
formatCurrency, formatPercent, formatNumber, cn, logos |
Only build custom when nothing above fits. getCssVar() does not exist — SVG accepts var().
Multizone integration
The host proxies a path to your tool via rewrites in
policyengine-app-v2/website/next.config.ts. basePath governs route URLs;
assetPrefix governs static asset URLs (_next/static/*). Pick the zone pattern from
how many public paths the zone owns:
- Path-mounted (one public path matching the kebab repo name):
basePath: '/us/my-tool'.
Server-rendered builds need no assetPrefix — basePath scopes _next/static automatically.
- Root-served (multiple public paths, e.g.
/us/api + /uk/api): no basePath; set
assetPrefix: '/_zones/<repo-name>' so assets don't collide with the host.
Host rewrite (in beforeFiles, so it beats the dynamic [slug] route), hardcoded to the
zone's production Vercel URL:
{ source: '/us/my-tool', destination: 'https://my-tool.vercel.app/us/my-tool' },
{ source: '/us/my-tool/:path*', destination: 'https://my-tool.vercel.app/us/my-tool/:path*' },
Static-export zones (output: 'export') need three coordinated pieces — omit any and
that environment 404s its JS/CSS:
- Zone
next.config.mjs exports a function and phase-gates the prefix so next dev
still resolves assets: assetPrefix: phase === PHASE_DEVELOPMENT_SERVER ? undefined : '/_zones/my-tool',
plus output: 'export', basePath, trailingSlash: true.
- Zone
vercel.json with "framework": null (the Next preset silently drops vercel.json
rewrites), explicit buildCommand/outputDirectory: "out", "trailingSlash": true, and
self-rewrites mapping basePath routes + /_zones/<repo>/_next/* onto the export root. Use
regex :path(.*) (not :path*) so trailing-slash URLs match.
- Host asset rewrite:
{ source: '/_zones/my-tool/:path*', destination: 'https://my-tool.vercel.app/_zones/my-tool/:path*' }.
Mandatory rules:
- Cross-zone navigation uses
<a>, not next/link — client-side routing breaks across zones.
- Favicon via the file convention — drop
app/icon.png (and app/apple-icon.png, PNG
only, 180×180). Next auto-prefixes the basePath. Do not use metadata.icons URLs; they
are not basePath-prefixed and 404 under multizone.
- Render shared chrome from ui-kit (
Header/Footer) so the zone looks native.
- App-specific provenance (policyengine.py version, static-estimate caveats) belongs in the
tool's methodology footnote, never the shared header/footer.
Deployment gotchas
- Vercel auto-assigns a non-deterministic URL suffix (e.g.
marriage-zeta-beryl.vercel.app).
Read the actual URL after first deploy and hardcode that in the host rewrite / apps.json —
never guess it.
- Deployment Protection returns 401. Custom aliases can inherit protection; use the
auto-assigned production
*.vercel.app URL as the rewrite/iframe source, and disable
Deployment Protection for the project if the host proxy gets 401s.
- Deploy with
vercel --prod --scope policy-engine. If package.json is in a subdir, set the
Vercel project Root Directory in the dashboard — don't fight it with a root vercel.json
cd subdir && command (the framework detector fails: "No Next.js version detected").
Legacy iframe embedding
Only for legacy tools and the obbba-iframe / custom apps.json types — new tools use
multizone. Register in policyengine-app-v2/website/src/data/apps.json (type, slug,
title, source = auto-assigned Vercel URL, countryId, and for displayWithResearch:
image, date, authors). Embedded tools read country from the injected hash
(const isEmbedded = window.self !== window.top; #country=uk) and point share URLs at
policyengine.org/{country}/{slug}, not the Vercel URL.
Data and backend patterns
Never paste ad-hoc computed numbers into source. Always: Python script → data file
(JSON/CSV) → frontend imports it. Pick by need:
- A — Precomputed JSON/CSV: finite scenarios (legislative trackers, static analyses). A
scripts/*.py microsimulation writes to public/data/; the frontend imports it. Zero
latency, works offline.
- B — v1 PolicyEngine API (
https://api.policyengine.org): household-level calc across
user-entered income/family/state. POST /{country}/calculate with a household payload, or
the stateful household/economy flow. Always up to date; network latency. See policyengine-api.
- C — Custom Modal backend: only when the v1 API can't do it (society-wide microsim,
custom reform params, non-standard entities). Pattern below.
Pattern C: Modal gateway + worker + polling
Society-wide microsims exceed Modal's ~150 s gateway timeout, so use non-blocking job submit +
poll. Two resource profiles: a cheap always-on gateway (HTTP routing only, no policyengine
dep) and expensive workers that scale to zero.
Four files keep module-level imports safe (policyengine/pydantic only exist inside the Modal
image, not at import time):
| File |
Module-level imports |
Role |
backend/_image_setup.py |
none (imports inside the fn body) |
snapshot_models() run at image build for fast cold starts |
backend/simulation.py |
top-level policyengine (population) + policyengine_us/_uk Simulation (household) — captured in the snapshot |
pure business logic, no Modal |
backend/app.py |
only modal |
worker functions |
backend/modal_app.py |
modal, fastapi, pydantic |
lightweight gateway |
Worker functions get high CPU/memory but must scale to zero — never set keep_warm or
min_containers; the image snapshot (.run_function(snapshot_models)) makes cold starts ~2 s.
# backend/app.py — workers
import modal
from _image_setup import snapshot_models
app = modal.App("my-tool-workers")
image = (modal.Image.debian_slim(python_version="3.11")
.pip_install("policyengine[us]==X.Y.Z", "pydantic") # top-level release (>=5.0.1): pins matched country model + certified data bundle
.run_function(snapshot_models)
.add_local_file("backend/simulation.py", remote_path="/root/simulation.py"))
@app.function(image=image, cpu=8.0, memory=32768, timeout=3600) # no keep_warm / min_containers
def compute_statewide(params: dict) -> dict:
from simulation import run_statewide
return run_statewide(params)
Society-wide endpoints in simulation.py compute through the managed path — import policyengine as pe; sim = pe.us.managed_microsimulation(reform=...) (same weighted
MicroSeries surface: .calc() US / .calculate() UK; provenance on
sim.policyengine_bundle) — never a directly-imported country-package Microsimulation,
whose default dataset can lag the certified bundle.
Household-level endpoints may use from policyengine_us import Simulation. Pre-cache the
certified dataset by constructing the managed microsim inside snapshot_models() (UK builds
need HUGGING_FACE_TOKEN — private dataset repo). Details: the policyengine skill.
# backend/modal_app.py — gateway (spawn + poll)
import modal
from fastapi import FastAPI
app = modal.App("my-tool")
web_app = FastAPI()
@web_app.post("/submit/{endpoint}")
def submit(endpoint: str, params: dict):
fn = modal.Function.from_name("my-tool-workers", "compute_statewide")
return {"job_id": fn.spawn(params).object_id}
@web_app.get("/status/{job_id}")
def status(job_id: str):
from modal.functions import FunctionCall
call = FunctionCall.from_id(job_id)
try:
return {"status": "ok", "result": call.get(timeout=0)}
except TimeoutError:
return {"status": "computing"}
@app.function(image=modal.Image.debian_slim().pip_install("fastapi", "pydantic"), memory=256)
@modal.asgi_app() # full FastAPI app; use @modal.fastapi_endpoint for a single route
def fastapi_app():
return web_app
The frontend submits then polls with a React Query hook (refetchInterval while
status === "computing"). Deploy footgun: unset MODAL_TOKEN_ID MODAL_TOKEN_SECRET
before modal deploy — a stale token env var silently deploys to the wrong workspace.
Deploy workers first (builds the snapshot), then the gateway; URL is
https://policyengine--my-tool-fastapi-app.modal.run. Set it as NEXT_PUBLIC_API_URL in
Vercel. Modal apps can silently disappear — if the frontend gets network errors, curl the
URL and redeploy on 404.
Charts
Prefer ui-kit's chart components (PEBarChart, PELineChart, ChartContainer) — they own
round-tick and formatting logic. When hand-rolling Recharts:
- Colors: pass
var(--chart-1) … var(--chart-5) directly to SVG fill/stroke; grid
and chrome use var(--border) / var(--foreground). Never hardcode hex.
- Round ticks: set
niceTicks="snap125" on every numeric <XAxis>/<YAxis> (recharts
≥3.8 — a PolicyEngine-contributed prop; verify your resolved recharts version first, e.g.
app-v2's lockfile still resolves 3.7.0 where the prop is silently ignored). snap125 snaps
tick steps to {1, 2, 2.5, 5}×10ⁿ for human-round labels; pair it with
domain={["auto", "auto"]} because the default [0, 'auto'] domain clamps the minimum and
breaks tick calculation for data that doesn't start at 0. Below 3.8, control ticks with
tickFormatter, tickCount, or an explicit ticks={[…]} array.
- Tooltip: set
separator=": " (the default has a leading space).
- Currency: sign before symbol —
-$31, never $-31. Use Intl.NumberFormat (or
ui-kit's formatCurrency), never string concatenation:const fmt = (v: number) => v.toLocaleString("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });
- Wrap in
ResponsiveContainer with an explicit height; dot={false} for dense lines.
See policyengine-design for the color tokens and the Plotly house style (maps/Python).
SEO (dual-mode)
Most SEO is generic; the PolicyEngine-specific part is dual-mode canonical. A tool exists
at both its *.vercel.app URL and the policyengine.org/{country}/{slug} path. Set a single
<link rel="canonical"> pointing at the policyengine.org path (the surface users, ads,
and Google should index) to avoid duplicate-content dilution. Under Next multizone the host
route is the public URL, so per-route generateMetadata (title, description, canonical, OG)
must be server-rendered — do not gate SEO tags on isEmbedded.
Verification
curl 200 does not mean the app renders — bun run build is the compile check. Check
lsof -i :<port> before claiming the dev server runs. You cannot visually verify; hand off to
the user after a green build. Sentence case on all UI text. Add PR CI (bun install, lint,
test, bun run build) before launch.
Related skills
- policyengine-app — the host site (policyengine.org) and its multizone registry
- policyengine-design — ui-kit 0.4.0 components, tokens, chart colors, Plotly style
- policyengine-api — v1/v2 API surfaces for Pattern B
- policyengine (Python) — microsimulation for Pattern A/C data scripts
1---2name: policyengine-tools3description: Load when building a standalone PolicyEngine interactive tool, calculator, or dashboard that deploys to Vercel and embeds into policyengine.org as a Next.js multizone. Covers the required stack (Next App Router + Tailwind v4 + @policyengine/ui-kit + bun), multizone zone config (basePath/assetPrefix, host rewrites, apps.json), Modal backends (gateway + worker + polling), chart patterns, and the dual-mode SEO strategy. Triggers: new tool, standalone calculator, dashboard, embed in policyengine.org, multizone, basePath, assetPrefix, appZoneRoutes, Modal backend, scale to zero, vercel --scope policy-engine, ui-kit theme.css, recharts colors. NOT for: developing policyengine.org itself (use policyengine-app) or token values (policyengine-design).4---56# PolicyEngine standalone tools78How to build calculators, dashboards, and visualizations that deploy independently to Vercel9and slot into **policyengine.org** as Next.js multizones. New tools are their own repos; the10host (`policyengine-app-v2/website/`) proxies a URL path to each tool's Vercel deployment.1112## Required stack1314- **Next.js (App Router)** — not Pages Router, not Vite as the app bundler (Vite only backs15 Vitest). `next.config.ts` + `app/` with `layout.tsx` + `page.tsx`, TypeScript.16- **Tailwind v4, CSS-first** — no `tailwind.config.{ts,js}`. `globals.css` is exactly:17 ```css18 @import "tailwindcss";19 @import "@policyengine/ui-kit/theme.css"; /* both required; Tailwind must come first */20 ```21- **PostCSS is required for Next** — create `postcss.config.mjs` with22 `{ plugins: { "@tailwindcss/postcss": {} } }` and install `@tailwindcss/postcss` + `postcss`23 as devDeps. (A Vite tool uses the `@tailwindcss/vite` plugin instead and needs no PostCSS24 config.) The theme.css header itself states `@tailwindcss/postcss` is mandatory — the older25 "no postcss.config" guidance was wrong.26- **`@policyengine/ui-kit`** (`bun add @policyengine/ui-kit`) — use its components before27 building your own. See policyengine-design for the full 0.4.0 export list.28- **bun** for everything (`bun install`, `bun run build`, `bunx`; `bun.lock` committed).29- **Vercel** deploy under the `policy-engine` scope. Static-only tools may use30 `output: 'export'`; tools with a Modal backend stay server-rendered.3132Scaffold: `bunx create-next-app@latest my-tool --ts --app --tailwind --eslint --import-alias "@/*"`,33then `bun add @policyengine/ui-kit` and set up `globals.css` as above.3435## ui-kit components (verified 0.4.0)3637Prefer these over hand-rolled equivalents; import from `@policyengine/ui-kit`:3839| Need | Component |40|---|---|41| Page shell / layout | `DashboardShell`, `SidebarLayout`, `SingleColumnLayout`, `InputPanel`, `ResultsPanel`, `Stack`, `Group`, `Container` |42| Header + footer | `Header`, `Footer` (Header props: `navItems`, `logoSrc`, `linkComponent`, `countries` — **not** `variant`/`navLinks`) |43| Inputs | `CurrencyInput`, `NumberInput`, `SelectInput`, `CheckboxInput`, `SliderInput`, `InputGroup` |44| Primitives | `Button`, `Card`, `Badge`, `Tabs`, `Dialog`, `Select`, `Tooltip`, `Alert`, `Switch`, `SegmentedControl`, … |45| Display | `MetricCard`, `SummaryText`, `DataTable`, `PolicyEngineWatermark` |46| Charts | `ChartContainer`, `PEBarChart`, `PELineChart`, `PEAreaChart`, `PEWaterfallChart` |47| Maps | `USDistrictChoroplethMap`, `UKConstituencyChoroplethMap`, `HexagonalMap`, `HouseholdGraph` |48| Utils | `formatCurrency`, `formatPercent`, `formatNumber`, `cn`, `logos` |4950Only build custom when nothing above fits. `getCssVar()` does not exist — SVG accepts `var()`.5152## Multizone integration5354The host proxies a path to your tool via `rewrites` in55`policyengine-app-v2/website/next.config.ts`. `basePath` governs **route URLs**;56`assetPrefix` governs **static asset URLs** (`_next/static/*`). Pick the zone pattern from57how many public paths the zone owns:5859- **Path-mounted** (one public path matching the kebab repo name): `basePath: '/us/my-tool'`.60 Server-rendered builds need no `assetPrefix` — `basePath` scopes `_next/static` automatically.61- **Root-served** (multiple public paths, e.g. `/us/api` + `/uk/api`): no `basePath`; set62 `assetPrefix: '/_zones/<repo-name>'` so assets don't collide with the host.6364Host rewrite (in `beforeFiles`, so it beats the dynamic `[slug]` route), hardcoded to the65zone's **production Vercel URL**:6667```ts68{ source: '/us/my-tool', destination: 'https://my-tool.vercel.app/us/my-tool' },69{ source: '/us/my-tool/:path*', destination: 'https://my-tool.vercel.app/us/my-tool/:path*' },70```7172**Static-export zones** (`output: 'export'`) need three coordinated pieces — omit any and73that environment 404s its JS/CSS:74751. Zone `next.config.mjs` exports a **function** and phase-gates the prefix so `next dev`76 still resolves assets: `assetPrefix: phase === PHASE_DEVELOPMENT_SERVER ? undefined : '/_zones/my-tool'`,77 plus `output: 'export'`, `basePath`, `trailingSlash: true`.782. Zone `vercel.json` with `"framework": null` (the Next preset silently drops `vercel.json`79 rewrites), explicit `buildCommand`/`outputDirectory: "out"`, `"trailingSlash": true`, and80 self-rewrites mapping basePath routes + `/_zones/<repo>/_next/*` onto the export root. Use81 regex `:path(.*)` (not `:path*`) so trailing-slash URLs match.823. Host asset rewrite: `{ source: '/_zones/my-tool/:path*', destination: 'https://my-tool.vercel.app/_zones/my-tool/:path*' }`.8384Mandatory rules:8586- **Cross-zone navigation uses `<a>`, not `next/link`** — client-side routing breaks across zones.87- **Favicon via the file convention** — drop `app/icon.png` (and `app/apple-icon.png`, PNG88 only, 180×180). Next auto-prefixes the basePath. Do **not** use `metadata.icons` URLs; they89 are not basePath-prefixed and 404 under multizone.90- Render shared chrome from ui-kit (`Header`/`Footer`) so the zone looks native.91- App-specific provenance (policyengine.py version, static-estimate caveats) belongs in the92 tool's methodology footnote, never the shared header/footer.9394### Deployment gotchas9596- **Vercel auto-assigns a non-deterministic URL suffix** (e.g. `marriage-zeta-beryl.vercel.app`).97 Read the actual URL after first deploy and hardcode *that* in the host rewrite / apps.json —98 never guess it.99- **Deployment Protection returns 401.** Custom aliases can inherit protection; use the100 auto-assigned production `*.vercel.app` URL as the rewrite/iframe source, and disable101 Deployment Protection for the project if the host proxy gets 401s.102- Deploy with `vercel --prod --scope policy-engine`. If `package.json` is in a subdir, set the103 Vercel project Root Directory in the dashboard — don't fight it with a root `vercel.json`104 `cd subdir &&` command (the framework detector fails: "No Next.js version detected").105106### Legacy iframe embedding107108Only for legacy tools and the `obbba-iframe` / `custom` apps.json types — new tools use109multizone. Register in `policyengine-app-v2/website/src/data/apps.json` (`type`, `slug`,110`title`, `source` = auto-assigned Vercel URL, `countryId`, and for `displayWithResearch`:111`image`, `date`, `authors`). Embedded tools read country from the injected hash112(`const isEmbedded = window.self !== window.top`; `#country=uk`) and point share URLs at113`policyengine.org/{country}/{slug}`, not the Vercel URL.114115## Data and backend patterns116117Never paste ad-hoc computed numbers into source. Always: Python script → data file118(JSON/CSV) → frontend imports it. Pick by need:119120- **A — Precomputed JSON/CSV**: finite scenarios (legislative trackers, static analyses). A121 `scripts/*.py` microsimulation writes to `public/data/`; the frontend imports it. Zero122 latency, works offline.123- **B — v1 PolicyEngine API** (`https://api.policyengine.org`): household-level calc across124 user-entered income/family/state. `POST /{country}/calculate` with a household payload, or125 the stateful household/economy flow. Always up to date; network latency. See policyengine-api.126- **C — Custom Modal backend**: only when the v1 API can't do it (society-wide microsim,127 custom reform params, non-standard entities). Pattern below.128129### Pattern C: Modal gateway + worker + polling130131Society-wide microsims exceed Modal's ~150 s gateway timeout, so use non-blocking job submit +132poll. Two resource profiles: a **cheap always-on gateway** (HTTP routing only, no policyengine133dep) and **expensive workers that scale to zero**.134135Four files keep module-level imports safe (policyengine/pydantic only exist inside the Modal136image, not at import time):137138| File | Module-level imports | Role |139|---|---|---|140| `backend/_image_setup.py` | none (imports inside the fn body) | `snapshot_models()` run at image build for fast cold starts |141| `backend/simulation.py` | top-level `policyengine` (population) + `policyengine_us`/`_uk` `Simulation` (household) — captured in the snapshot | pure business logic, no Modal |142| `backend/app.py` | only `modal` | worker functions |143| `backend/modal_app.py` | `modal`, `fastapi`, `pydantic` | lightweight gateway |144145Worker functions get high CPU/memory but **must** scale to zero — never set `keep_warm` or146`min_containers`; the image snapshot (`.run_function(snapshot_models)`) makes cold starts ~2 s.147148```python149# backend/app.py — workers150import modal151from _image_setup import snapshot_models152app = modal.App("my-tool-workers")153image = (modal.Image.debian_slim(python_version="3.11")154 .pip_install("policyengine[us]==X.Y.Z", "pydantic") # top-level release (>=5.0.1): pins matched country model + certified data bundle155 .run_function(snapshot_models)156 .add_local_file("backend/simulation.py", remote_path="/root/simulation.py"))157158@app.function(image=image, cpu=8.0, memory=32768, timeout=3600) # no keep_warm / min_containers159def compute_statewide(params: dict) -> dict:160 from simulation import run_statewide161 return run_statewide(params)162```163164Society-wide endpoints in `simulation.py` compute through the managed path — `import165policyengine as pe; sim = pe.us.managed_microsimulation(reform=...)` (same weighted166MicroSeries surface: `.calc()` US / `.calculate()` UK; provenance on167`sim.policyengine_bundle`) — never a directly-imported country-package `Microsimulation`,168whose default dataset can lag the certified bundle.169Household-level endpoints may use `from policyengine_us import Simulation`. Pre-cache the170certified dataset by constructing the managed microsim inside `snapshot_models()` (UK builds171need `HUGGING_FACE_TOKEN` — private dataset repo). Details: the `policyengine` skill.172173```python174# backend/modal_app.py — gateway (spawn + poll)175import modal176from fastapi import FastAPI177app = modal.App("my-tool")178web_app = FastAPI()179180@web_app.post("/submit/{endpoint}")181def submit(endpoint: str, params: dict):182 fn = modal.Function.from_name("my-tool-workers", "compute_statewide")183 return {"job_id": fn.spawn(params).object_id}184185@web_app.get("/status/{job_id}")186def status(job_id: str):187 from modal.functions import FunctionCall188 call = FunctionCall.from_id(job_id)189 try:190 return {"status": "ok", "result": call.get(timeout=0)}191 except TimeoutError:192 return {"status": "computing"}193194@app.function(image=modal.Image.debian_slim().pip_install("fastapi", "pydantic"), memory=256)195@modal.asgi_app() # full FastAPI app; use @modal.fastapi_endpoint for a single route196def fastapi_app():197 return web_app198```199200The frontend submits then polls with a React Query hook (`refetchInterval` while201`status === "computing"`). Deploy footgun: **`unset MODAL_TOKEN_ID MODAL_TOKEN_SECRET`202before `modal deploy`** — a stale token env var silently deploys to the wrong workspace.203Deploy workers first (builds the snapshot), then the gateway; URL is204`https://policyengine--my-tool-fastapi-app.modal.run`. Set it as `NEXT_PUBLIC_API_URL` in205Vercel. Modal apps can silently disappear — if the frontend gets network errors, `curl` the206URL and redeploy on 404.207208## Charts209210Prefer ui-kit's chart components (`PEBarChart`, `PELineChart`, `ChartContainer`) — they own211round-tick and formatting logic. When hand-rolling Recharts:212213- **Colors: pass `var(--chart-1)` … `var(--chart-5)` directly** to SVG `fill`/`stroke`; grid214 and chrome use `var(--border)` / `var(--foreground)`. Never hardcode hex.215- **Round ticks: set `niceTicks="snap125"` on every numeric `<XAxis>`/`<YAxis>`** (recharts216 ≥3.8 — a PolicyEngine-contributed prop; verify your resolved recharts version first, e.g.217 app-v2's lockfile still resolves 3.7.0 where the prop is silently ignored). `snap125` snaps218 tick steps to {1, 2, 2.5, 5}×10ⁿ for human-round labels; pair it with219 `domain={["auto", "auto"]}` because the default `[0, 'auto']` domain clamps the minimum and220 breaks tick calculation for data that doesn't start at 0. Below 3.8, control ticks with221 `tickFormatter`, `tickCount`, or an explicit `ticks={[…]}` array.222- **Tooltip**: set `separator=": "` (the default has a leading space).223- **Currency: sign before symbol** — `-$31`, never `$-31`. Use `Intl.NumberFormat` (or224 ui-kit's `formatCurrency`), never string concatenation:225 ```ts226 const fmt = (v: number) => v.toLocaleString("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });227 ```228- Wrap in `ResponsiveContainer` with an explicit height; `dot={false}` for dense lines.229230See policyengine-design for the color tokens and the Plotly house style (maps/Python).231232## SEO (dual-mode)233234Most SEO is generic; the PolicyEngine-specific part is **dual-mode canonical**. A tool exists235at both its `*.vercel.app` URL and the `policyengine.org/{country}/{slug}` path. Set a single236`<link rel="canonical">` pointing at the **policyengine.org path** (the surface users, ads,237and Google should index) to avoid duplicate-content dilution. Under Next multizone the host238route is the public URL, so per-route `generateMetadata` (title, description, canonical, OG)239must be server-rendered — do not gate SEO tags on `isEmbedded`.240241## Verification242243`curl` 200 does not mean the app renders — `bun run build` is the compile check. Check244`lsof -i :<port>` before claiming the dev server runs. You cannot visually verify; hand off to245the user after a green build. Sentence case on all UI text. Add PR CI (`bun install`, lint,246test, `bun run build`) before launch.247248## Related skills249250- policyengine-app — the host site (policyengine.org) and its multizone registry251- policyengine-design — ui-kit 0.4.0 components, tokens, chart colors, Plotly style252- policyengine-api — v1/v2 API surfaces for Pattern B253- policyengine (Python) — microsimulation for Pattern A/C data scripts