PolicyEngine design system
Design tokens live as CSS custom properties in @policyengine/ui-kit/theme.css. Every
frontend imports that one file. The canonical version is @policyengine/ui-kit ^0.4.0 —
the version app-v2 pins. Ignore claims tied to 0.5/0.6/0.9; treat 0.4.0 as ground truth and
re-check node_modules/@policyengine/ui-kit when precision matters.
Setup: the two imports
/* globals.css — order matters; Tailwind must come first */
@import "tailwindcss";
@import "@policyengine/ui-kit/theme.css";
Next.js needs postcss.config.mjs = { plugins: { "@tailwindcss/postcss": {} } } (install
@tailwindcss/postcss + postcss). A Vite app uses the @tailwindcss/vite plugin instead —
no PostCSS config. Do not add postcss-import or autoprefixer (@tailwindcss/postcss
handles both), and do not add a second @import "tailwindcss" (theme.css does not import
it). If the consumer's own utility classes go missing in a monorepo, scope source detection:
@import "tailwindcss" source("./src");.
The three-layer theme.css
theme.css (source: ui-kit src/theme/tokens.css, vendored from design-system v0.3.0) has
three layers:
Layer 1 :root { --primary: #2C7A7B; --chart-1: #319795; ... } raw shadcn/ui semantic values
Layer 2 @theme inline { --color-primary: var(--primary); } bridge :root → Tailwind utilities
Layer 3 @theme { --color-teal-500: #319795; --text-sm: 14px; } brand palette, fonts, sizes, spacing
@theme vs @theme inline — the mechanism
@theme inline keeps the var() reference, so the utility resolves it at runtime.
Use it whenever a token points at a :root variable — that is what lets bg-primary follow
a .dark override. All shadcn/ui semantic tokens use @theme inline.
@theme bakes a literal value into the generated CSS at build time. Use it for the
fixed brand palette (teal-500 is always #319795), font sizes, spacing, breakpoints.
Getting these backwards is the classic bug: @theme { --color-primary: var(--primary); } bakes
the literal string var(--primary) and never re-resolves.
Consuming tokens by context
| Context |
How |
Example |
| React (semantic) |
Tailwind semantic class |
className="bg-primary text-foreground" |
| React (brand) |
Tailwind brand class |
className="bg-teal-500 text-gray-600" |
| Recharts / SVG |
var() directly in fill/stroke |
fill="var(--chart-1)" |
| Inline style |
var() |
style={{ color: "var(--primary)" }} |
| Python / Plotly |
hex + CSS-var comment |
TEAL = "#319795" # --chart-1 |
Namespace → utility mapping (Tailwind v4): --color-* → bg-*/text-*/border-*/fill-*;
--text-* → font-size utilities; --font-* → font-*; --radius-* → rounded-*;
--spacing-* → p-*/m-*/gap-*/w-*/h-*; --breakpoint-* → sm:/md:/lg:.
Never hardcode hex or font names when a token exists.
Colors (verified 0.4.0)
Teal (brand): 50 #E6FFFA · 100 #B2F5EA · 200 #81E6D9 · 300 #4FD1C5 · 400 #38B2AC
· 500 #319795 (main brand) · 600 #2C7A7B (= --primary) · 700 #285E61 · 800 #234E52
· 900 #1D4044.
Semantic (:root): --primary #2C7A7B · --background #FFFFFF · --foreground
#000000 · --muted #F2F4F7 · --muted-foreground #475569 · --border #E2E8F0 ·
--card #FFFFFF · --destructive #EF4444 · --ring #319795.
Charts (--chart-1 … --chart-5): #319795 (teal) · #0EA5E9 (blue) · #285E61
(dark teal) · #026AA2 (dark blue) · #64748B (slate). Exactly five.
Status fills: success #22C55E · warning #FEC601 · error #EF4444 · info #1890FF.
These are fills for dots/badges/tints — not guaranteed AA as text on white.
Gray is Slate-flavored: 500 #64748B, 600 #475569, 700 #344054, 900 #101828.
Typography, spacing, radius
- Two fonts only: Inter (
--font-sans, everything) and JetBrains Mono (--font-mono,
code). Load Inter via Google Fonts (family=Inter:wght@400;500;600;700&display=swap).
- Sizes:
text-xs 12 · sm 14 · base 16 · lg 18 · xl 20 · 2xl 24 · 3xl 28 ·
4xl 32 (each with a matching line-height).
- Radius: base
--radius 6px; semantic --radius-chip 2 / --radius-element 4 /
--radius-container 8 / --radius-feature 12; Tailwind rounded-sm/md/lg/xl.
- Named spacing:
--spacing-header 58px (h-header), --spacing-sidebar 280px
(w-sidebar), --spacing-content 976px (max-w-content).
- Breakpoints: xs 36rem · sm 48rem · md 62rem · lg 75rem · xl 88rem · 2xl 96rem.
- Sentence case on all UI text — capitalize only the first word and proper nouns.
ui-kit component library (0.4.0)
@policyengine/ui-kit is a shadcn/radix component library plus the theme. Tokens are consumed
via CSS only — at 0.4.0 the package exports no JS palette/colors object and no
/legacy shim. Subpath exports: ./primitives, ./layout, ./inputs, ./display,
./charts, ./visualization, ./utils, ./assets, ./theme.css, ./styles.css.
- Layout/shell:
DashboardShell, SidebarLayout, SingleColumnLayout, InputPanel,
ResultsPanel, Header, Footer, Stack, Group, Container. There is no
PolicyEngineShell/PolicyEngineHeader/PolicyEngineFooter — those names do not exist;
use DashboardShell / Header / Footer.
Header API (0.4.0): navItems: {label, href, children?}[], logoSrc, logoHref,
linkComponent, countries, currentCountry, onCountryChange. It renders a sticky
teal-gradient bar. Older variant/logo/navLinks/children props are gone.
- Primitives:
Button, Card(+parts), Badge, Tabs, Dialog, Sheet, Tooltip,
Select, DropdownMenu, Input, Alert, Popover, Switch, Checkbox, Accordion,
SegmentedControl, … Inputs: CurrencyInput, NumberInput, SelectInput,
CheckboxInput, SliderInput, InputGroup. Display: MetricCard, SummaryText,
DataTable, PolicyEngineWatermark. Utils: formatCurrency, formatPercent,
formatNumber, cn, logos (logos.tealWordmark/tealSquare/whiteWordmark/whiteSquare).
The favicon SVG ships at @policyengine/ui-kit/favicon.svg (teal square).
Dark mode (accurate 0.4.0 contract)
theme.css declares @custom-variant dark (&:is(.dark *)), so a .dark class on an ancestor
activates dark: utilities. But 0.4.0 ships no dark token values — there is no
.dark {} / :root.dark {} block redefining the semantic vars. Toggling .dark alone does
not recolor bg-primary/bg-background. A consumer that wants dark mode must define its
own overrides:
.dark { --background: #0B0E14; --foreground: #FFFFFF; --card: #111827; --border: #1F2937; }
Prefer the CSS-var form for charts (fill="var(--chart-1)") so they follow any dark override
automatically. Do not claim built-in dark mode at this version.
Charts
- Recharts / SVG: pass
var(--chart-1)…var(--chart-5) to fill/stroke; grid/chrome
use var(--border), var(--foreground), var(--font-sans). SVG resolves var() natively —
no helper. Round ticks: niceTicks="snap125" on numeric axes (recharts ≥3.8,
PolicyEngine-contributed; see the policyengine-tools skill for the version gate and the
domain={["auto", "auto"]} pairing). Conventions: gains --chart-1; losses --chart-5
or --destructive; neutral --border.
- Plotly (Python maps/charts) — house style: white
plot_bgcolor/paper_bgcolor, Inter
font, teal series, logo bottom-right. Reference tokens by hex with a CSS-var comment.
Sketch:def format_fig(fig):
fig.update_layout(font=dict(family="Inter", color="black", size=14),
plot_bgcolor="white", paper_bgcolor="white", template="plotly_white")
# add_layout_image with a CHECKED-IN local logo asset (xref/yref="paper", bottom-right).
return fig
Use a local logo file copied into the repo — do not hotlink a raw GitHub URL.
Migrating off the old design-system
@policyengine/design-system is deprecated; use @policyengine/ui-kit. The migration is a
consumption-model change, not a rename: drop the old JS token imports and consume ui-kit's
CSS vars via theme.css (there is no ui-kit JS token object to import at 0.4.0). Watch the
gray shift — the old design-system used Tailwind-3 grays (#6B7280 at 500) while ui-kit uses
Slate (#64748B at 500), so grays change visibly; migrate per-usage, don't bulk-sed.
app-v2 keeps a local @/designTokens JS shim (its own colors/spacing/typography
objects mirroring theme.css) for inline styles during its migration — that shim is app-v2's,
not an export of ui-kit.
Common mistakes
- Creating
tailwind.config.ts — Tailwind v4 is CSS-first; all config is in @theme.
getCssVar() / getComputedStyle() for chart colors — SVG takes var() directly.
- Old
pe-*-prefixed classes (bg-pe-primary-500) — use bg-teal-500 / bg-primary.
- Hardcoding hex in components — use the Tailwind class or
var().
Related skills
- policyengine-tools — building/deploying tools that consume these tokens
- policyengine-app — app-v2's ui-kit + local designTokens hybrid
1---2name: policyengine-design3description: Load when styling any PolicyEngine frontend — choosing colors, fonts, spacing, chart colors, or consuming @policyengine/ui-kit. Covers the two-import globals.css, the three-layer theme.css (@theme vs @theme inline), verified 0.4.0 token values and component exports, the dark-mode contract, and the Plotly chart house style. Triggers: PolicyEngine colors, design tokens, brand palette, teal, CSS variables, theme.css, ui-kit, @theme, @theme inline, dark mode, chart colors, fill var(--chart-1), Plotly format_fig, never hardcode hex, DashboardShell, Header. NOT for: multizone/deploy wiring (use policyengine-tools) or app-v2 internals (policyengine-app).4---56# PolicyEngine design system78Design tokens live as CSS custom properties in **`@policyengine/ui-kit/theme.css`**. Every9frontend imports that one file. The canonical version is **`@policyengine/ui-kit ^0.4.0`** —10the version app-v2 pins. Ignore claims tied to 0.5/0.6/0.9; treat 0.4.0 as ground truth and11re-check `node_modules/@policyengine/ui-kit` when precision matters.1213## Setup: the two imports1415```css16/* globals.css — order matters; Tailwind must come first */17@import "tailwindcss";18@import "@policyengine/ui-kit/theme.css";19```2021Next.js needs `postcss.config.mjs` = `{ plugins: { "@tailwindcss/postcss": {} } }` (install22`@tailwindcss/postcss` + `postcss`). A Vite app uses the `@tailwindcss/vite` plugin instead —23no PostCSS config. Do **not** add `postcss-import` or `autoprefixer` (`@tailwindcss/postcss`24handles both), and do **not** add a second `@import "tailwindcss"` (theme.css does not import25it). If the consumer's own utility classes go missing in a monorepo, scope source detection:26`@import "tailwindcss" source("./src");`.2728## The three-layer theme.css2930`theme.css` (source: ui-kit `src/theme/tokens.css`, vendored from design-system v0.3.0) has31three layers:3233```34Layer 1 :root { --primary: #2C7A7B; --chart-1: #319795; ... } raw shadcn/ui semantic values35Layer 2 @theme inline { --color-primary: var(--primary); } bridge :root → Tailwind utilities36Layer 3 @theme { --color-teal-500: #319795; --text-sm: 14px; } brand palette, fonts, sizes, spacing37```3839### `@theme` vs `@theme inline` — the mechanism4041- **`@theme inline`** keeps the `var()` reference, so the utility resolves it **at runtime**.42 Use it whenever a token points at a `:root` variable — that is what lets `bg-primary` follow43 a `.dark` override. All shadcn/ui semantic tokens use `@theme inline`.44- **`@theme`** bakes a **literal** value into the generated CSS at build time. Use it for the45 fixed brand palette (`teal-500` is always `#319795`), font sizes, spacing, breakpoints.4647Getting these backwards is the classic bug: `@theme { --color-primary: var(--primary); }` bakes48the literal string `var(--primary)` and never re-resolves.4950## Consuming tokens by context5152| Context | How | Example |53|---|---|---|54| React (semantic) | Tailwind semantic class | `className="bg-primary text-foreground"` |55| React (brand) | Tailwind brand class | `className="bg-teal-500 text-gray-600"` |56| Recharts / SVG | `var()` directly in fill/stroke | `fill="var(--chart-1)"` |57| Inline style | `var()` | `style={{ color: "var(--primary)" }}` |58| Python / Plotly | hex + CSS-var comment | `TEAL = "#319795" # --chart-1` |5960Namespace → utility mapping (Tailwind v4): `--color-*` → `bg-*`/`text-*`/`border-*`/`fill-*`;61`--text-*` → font-size utilities; `--font-*` → `font-*`; `--radius-*` → `rounded-*`;62`--spacing-*` → `p-*`/`m-*`/`gap-*`/`w-*`/`h-*`; `--breakpoint-*` → `sm:`/`md:`/`lg:`.63**Never hardcode hex or font names** when a token exists.6465## Colors (verified 0.4.0)6667**Teal (brand):** 50 `#E6FFFA` · 100 `#B2F5EA` · 200 `#81E6D9` · 300 `#4FD1C5` · 400 `#38B2AC`68· 500 `#319795` (main brand) · 600 `#2C7A7B` (= `--primary`) · 700 `#285E61` · 800 `#234E52`69· 900 `#1D4044`.7071**Semantic (`:root`):** `--primary` `#2C7A7B` · `--background` `#FFFFFF` · `--foreground`72`#000000` · `--muted` `#F2F4F7` · `--muted-foreground` `#475569` · `--border` `#E2E8F0` ·73`--card` `#FFFFFF` · `--destructive` `#EF4444` · `--ring` `#319795`.7475**Charts (`--chart-1` … `--chart-5`):** `#319795` (teal) · `#0EA5E9` (blue) · `#285E61`76(dark teal) · `#026AA2` (dark blue) · `#64748B` (slate). Exactly five.7778**Status fills:** success `#22C55E` · warning `#FEC601` · error `#EF4444` · info `#1890FF`.79These are fills for dots/badges/tints — not guaranteed AA as text on white.8081**Gray is Slate-flavored:** 500 `#64748B`, 600 `#475569`, 700 `#344054`, 900 `#101828`.8283## Typography, spacing, radius8485- **Two fonts only:** Inter (`--font-sans`, everything) and JetBrains Mono (`--font-mono`,86 code). Load Inter via Google Fonts (`family=Inter:wght@400;500;600;700&display=swap`).87- **Sizes:** `text-xs` 12 · `sm` 14 · `base` 16 · `lg` 18 · `xl` 20 · `2xl` 24 · `3xl` 28 ·88 `4xl` 32 (each with a matching line-height).89- **Radius:** base `--radius` 6px; semantic `--radius-chip` 2 / `--radius-element` 4 /90 `--radius-container` 8 / `--radius-feature` 12; Tailwind `rounded-sm/md/lg/xl`.91- **Named spacing:** `--spacing-header` 58px (`h-header`), `--spacing-sidebar` 280px92 (`w-sidebar`), `--spacing-content` 976px (`max-w-content`).93- **Breakpoints:** xs 36rem · sm 48rem · md 62rem · lg 75rem · xl 88rem · 2xl 96rem.94- **Sentence case** on all UI text — capitalize only the first word and proper nouns.9596## ui-kit component library (0.4.0)9798`@policyengine/ui-kit` is a shadcn/radix component library plus the theme. Tokens are consumed99**via CSS only** — at 0.4.0 the package exports **no JS `palette`/`colors` object** and **no100`/legacy` shim**. Subpath exports: `./primitives`, `./layout`, `./inputs`, `./display`,101`./charts`, `./visualization`, `./utils`, `./assets`, `./theme.css`, `./styles.css`.102103- **Layout/shell:** `DashboardShell`, `SidebarLayout`, `SingleColumnLayout`, `InputPanel`,104 `ResultsPanel`, `Header`, `Footer`, `Stack`, `Group`, `Container`. There is **no105 `PolicyEngineShell`/`PolicyEngineHeader`/`PolicyEngineFooter`** — those names do not exist;106 use `DashboardShell` / `Header` / `Footer`.107- **`Header` API (0.4.0):** `navItems: {label, href, children?}[]`, `logoSrc`, `logoHref`,108 `linkComponent`, `countries`, `currentCountry`, `onCountryChange`. It renders a sticky109 teal-gradient bar. Older `variant`/`logo`/`navLinks`/`children` props are gone.110- **Primitives:** `Button`, `Card`(+parts), `Badge`, `Tabs`, `Dialog`, `Sheet`, `Tooltip`,111 `Select`, `DropdownMenu`, `Input`, `Alert`, `Popover`, `Switch`, `Checkbox`, `Accordion`,112 `SegmentedControl`, … **Inputs:** `CurrencyInput`, `NumberInput`, `SelectInput`,113 `CheckboxInput`, `SliderInput`, `InputGroup`. **Display:** `MetricCard`, `SummaryText`,114 `DataTable`, `PolicyEngineWatermark`. **Utils:** `formatCurrency`, `formatPercent`,115 `formatNumber`, `cn`, `logos` (`logos.tealWordmark`/`tealSquare`/`whiteWordmark`/`whiteSquare`).116117The favicon SVG ships at `@policyengine/ui-kit/favicon.svg` (teal square).118119## Dark mode (accurate 0.4.0 contract)120121theme.css declares `@custom-variant dark (&:is(.dark *))`, so a `.dark` class on an ancestor122activates `dark:` utilities. **But 0.4.0 ships no dark token values** — there is no123`.dark {}` / `:root.dark {}` block redefining the semantic vars. Toggling `.dark` alone does124**not** recolor `bg-primary`/`bg-background`. A consumer that wants dark mode must define its125own overrides:126127```css128.dark { --background: #0B0E14; --foreground: #FFFFFF; --card: #111827; --border: #1F2937; }129```130131Prefer the CSS-var form for charts (`fill="var(--chart-1)"`) so they follow any dark override132automatically. Do not claim built-in dark mode at this version.133134## Charts135136- **Recharts / SVG:** pass `var(--chart-1)`…`var(--chart-5)` to `fill`/`stroke`; grid/chrome137 use `var(--border)`, `var(--foreground)`, `var(--font-sans)`. SVG resolves `var()` natively —138 no helper. Round ticks: `niceTicks="snap125"` on numeric axes (recharts ≥3.8,139 PolicyEngine-contributed; see the policyengine-tools skill for the version gate and the140 `domain={["auto", "auto"]}` pairing). Conventions: gains `--chart-1`; losses `--chart-5`141 or `--destructive`; neutral `--border`.142- **Plotly (Python maps/charts)** — house style: white `plot_bgcolor`/`paper_bgcolor`, Inter143 font, teal series, logo bottom-right. Reference tokens by hex with a CSS-var comment.144 Sketch:145 ```python146 def format_fig(fig):147 fig.update_layout(font=dict(family="Inter", color="black", size=14),148 plot_bgcolor="white", paper_bgcolor="white", template="plotly_white")149 # add_layout_image with a CHECKED-IN local logo asset (xref/yref="paper", bottom-right).150 return fig151 ```152 Use a local logo file copied into the repo — do not hotlink a raw GitHub URL.153154## Migrating off the old design-system155156<!-- stale-ok -->157`@policyengine/design-system` is deprecated; use `@policyengine/ui-kit`. The migration is a158consumption-model change, not a rename: drop the old JS token imports and consume ui-kit's159CSS vars via `theme.css` (there is no ui-kit JS token object to import at 0.4.0). Watch the160gray shift — the old design-system used Tailwind-3 grays (`#6B7280` at 500) while ui-kit uses161Slate (`#64748B` at 500), so grays change visibly; migrate per-usage, don't bulk-`sed`.162app-v2 keeps a **local** `@/designTokens` JS shim (its own `colors`/`spacing`/`typography`163objects mirroring theme.css) for inline styles during its migration — that shim is app-v2's,164not an export of ui-kit.165166## Common mistakes167168- Creating `tailwind.config.ts` — Tailwind v4 is CSS-first; all config is in `@theme`.169- `getCssVar()` / `getComputedStyle()` for chart colors — SVG takes `var()` directly.170- Old `pe-*`-prefixed classes (`bg-pe-primary-500`) — use `bg-teal-500` / `bg-primary`.171- Hardcoding hex in components — use the Tailwind class or `var()`.172173## Related skills174175- policyengine-tools — building/deploying tools that consume these tokens176- policyengine-app — app-v2's ui-kit + local designTokens hybrid