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 inlinekeeps thevar()reference, so the utility resolves it at runtime. Use it whenever a token points at a:rootvariable — that is what letsbg-primaryfollow a.darkoverride. All shadcn/ui semantic tokens use@theme inline.@themebakes a literal value into the generated CSS at build time. Use it for the fixed brand palette (teal-500is 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-xs12 ·sm14 ·base16 ·lg18 ·xl20 ·2xl24 ·3xl28 ·4xl32 (each with a matching line-height). - Radius: base
--radius6px; semantic--radius-chip2 /--radius-element4 /--radius-container8 /--radius-feature12; Tailwindrounded-sm/md/lg/xl. - Named spacing:
--spacing-header58px (h-header),--spacing-sidebar280px (w-sidebar),--spacing-content976px (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 noPolicyEngineShell/PolicyEngineHeader/PolicyEngineFooter— those names do not exist; useDashboardShell/Header/Footer. HeaderAPI (0.4.0):navItems: {label, href, children?}[],logoSrc,logoHref,linkComponent,countries,currentCountry,onCountryChange. It renders a sticky teal-gradient bar. Oldervariant/logo/navLinks/childrenprops 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)tofill/stroke; grid/chrome usevar(--border),var(--foreground),var(--font-sans). SVG resolvesvar()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 thedomain={["auto", "auto"]}pairing). Conventions: gains--chart-1; losses--chart-5or--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:
Use a local logo file copied into the repo — do not hotlink a raw GitHub URL.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
Migrating off the old design-system
<!-- stale-ok -->@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 takesvar()directly.- Old
pe-*-prefixed classes (bg-pe-primary-500) — usebg-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