Design System
Design System
Section titled “Design System”Reference for the themeable design system (spec: agent-os/specs/2026-06-12-themeable-design-system). Tailwind remains the styling engine; all colour flows through semantic tokens defined as CSS custom properties.
Source of truth:
| Layer | File |
|---|---|
| Token definitions | packages/webapp/src/index.css (:root block + @layer components) |
| Tailwind mapping | packages/webapp/tailwind.config.ts |
| Component library | packages/webapp/src/components/ui/ |
| Contracts | packages/webapp/src/__tests__/design-tokens.test.ts, raw-palette-guard.test.ts |
The one rule: component and page code never uses raw palette classes (navy-*, electric-*) or literal colour values. Colour is expressed through semantic token classes; structure is expressed through library components.
1. Token Vocabulary
Section titled “1. Token Vocabulary”Tokens are HSL triples (--x: H S% L%) consumed as hsl(var(--x)). Every token’s default value is the exact HSL equivalent of the legacy raw value it replaced — the default theme is value-preserving by contract (pinned in design-tokens.test.ts; visual-regression baselines must pass without regeneration).
Base tokens (shadcn-conventional)
Section titled “Base tokens (shadcn-conventional)”Pre-existing vocabulary; used by the shadcn primitives’ cva variant strings. Do not rename — renames break tailwind-merge variant strings.
| Token | Tailwind class(es) | Role |
|---|---|---|
--background / --foreground | bg-background, text-foreground | Page background (deep navy, 210 40% 8%) and default text (off-white, 210 40% 95%) |
--card / --card-foreground | bg-card, text-card-foreground | Card chrome under-layer |
--popover / --popover-foreground | bg-popover … | Floating surfaces (menus, popovers) |
--primary / --primary-foreground | bg-primary, text-primary … | Primary action colour (electric blue, 199 89% 48%) |
--secondary / --secondary-foreground | bg-secondary … | Secondary action surface |
--muted / --muted-foreground | bg-muted, text-muted-foreground | De-emphasised surfaces and text |
--accent / --accent-foreground | bg-accent … | shadcn hover/selected states (same value as --primary) |
--destructive / --destructive-foreground | bg-destructive … | Errors, dangerous actions |
--border, --input, --ring | border-border, border-input, ring-ring | Rules, form borders, focus rings |
--radius | rounded-lg/md/sm | Corner radius base (0.75rem) |
Expanded tokens (spec 2026-06-12)
Section titled “Expanded tokens (spec 2026-06-12)”Each row records the legacy value the default preserves. The “Do / Don’t” column is the migration mapping in reverse: if you are about to type the legacy class, use the token instead.
| Token | Tailwind class(es) | Role | Preserves (legacy) | Do / Don’t |
|---|---|---|---|---|
--surface-deep | bg-surface-deep | Deepest chrome: footer, page-end bands | navy-950 #0f1419 | Do use for full-bleed dark chrome. Don’t use for cards (use bg-card / GlassCard). |
--surface-raised | bg-surface-raised | Raised panels: map shells, elevated containers | navy-900 #1a202c | Do use where content sits visually above the page. Don’t reach for it as a generic “darker grey”. |
--accent-interactive | text-/bg-/border-/ring-/fill-accent-interactive | The interactive accent: links, icons, buttons, focus affordances, pill tints | electric-500 #0ea5e9 | Do use for anything a user acts on or that signals interactivity. Don’t use for the emphasised word in a heading — that is accent-highlight. |
--accent-interactive-hover | hover:bg-accent-interactive-hover … | Hover/active shift of the interactive accent | electric-600 #0284c7 | Do pair with accent-interactive on the same element. Don’t use as a standalone colour. |
--accent-highlight | text-accent-highlight | The emphasised word in headings; big stat values | electric-500 #0ea5e9 | Do use via <Highlight> / StatCard. Don’t use on interactive elements. |
--foreground-secondary | text-foreground-secondary | Body copy below --foreground: lead paragraphs, descriptions | gray-300 #d1d5db | Do use for prose. Don’t drop to subtle just because text is long. |
--foreground-subtle | text-foreground-subtle | Captions, hints, labels, metadata | gray-400 #9ca3af | Do use for tertiary text. Don’t use for body copy (contrast). |
--gradient-hero-from/-via/-to | (via .hero-gradient) | Hero band gradient endpoints | navy-950 → navy-800 #243b53 → navy-950 | Do theme by overriding the variables. Don’t hand-roll from-/via-/to- hero gradients in components. |
--gradient-section-from/-to | (via .section-gradient) | Section band gradient endpoints | navy-950 → legacy rgb(24,59,83) | Same as above. |
--glass-tint | (via .card-gradient) | Frosted-glass tint colour; alpha applied at use site (/0.05, /0.02, border /0.1) | white | Do override the hue per theme. Don’t bake alpha into the token. |
Notes:
text-whiteis intentionally not tokenized or banned:--foregroundis 95% lightness, not white, so headings that are genuinely white staytext-white.- Status colours (success/warning difficulty pills etc.) currently use generic Tailwind scales (
green-500/20,amber-400, …). They are not part of the banned raw palette and have no semantic tokens yet; extend the vocabulary before inventing per-page status styling (see Adding a new token).
Opacity-modifier ladder
Section titled “Opacity-modifier ladder”All expanded tokens are mapped in tailwind.config.ts with the hsl(var(--x) / <alpha-value>) form, so Tailwind opacity modifiers work: bg-accent-interactive/20, border-accent-interactive/20, bg-muted/20, etc.
Stick to the established ladder — these are the steps in use across the app:
| Step | Conventional use |
|---|---|
/10 | Pill/callout background tint (IconBadge, GlassCard variant="accent") |
/20 | Border tint paired with a /10 background; soft step-circle fill |
/30 | Hover state of a /20 fill |
/40 | Stronger tint, decorative fills |
/50 | Mid-emphasis overlays, faded icons |
/80 | Near-solid hover/overlay states |
Don’t invent in-between steps (/15, /25) — every new step is another value a theme must look right at.
accent-interactive vs accent-highlight
Section titled “accent-interactive vs accent-highlight”Today both resolve to electric-500, and that is deliberate, not redundant. They are separate roles so a theme can pull them apart: the “Neighbourhood Ledger” proposal (design-proposal/neighbourhood-ledger.html — reference target, not implemented) keeps a restrained interactive accent while giving headline-emphasis words a different editorial treatment. Code that conflates the two (e.g. text-accent-interactive on a heading word) forecloses that path. Classify by role:
- A user can click/focus/act on it, or it signals an interactive affordance →
accent-interactive - It is pure emphasis in display text (heading word, hero stat) →
accent-highlight
Accessibility layers
Section titled “Accessibility layers”The high-contrast (.high-contrast), screen-reader-optimized (.screen-reader-optimized), and reduced-motion layers in index.css work by reassigning token values (e.g. high-contrast sets --background: 0 0% 0%). This is the proof the token-override theming model works, and it imposes a constraint: new tokens must be plain reassignable custom properties in the same base layer — no token may be defined in a way an accessibility class can’t override. design-tokens.test.ts asserts the layers remain intact.
2. Theming Model
Section titled “2. Theming Model”A theme is one CSS file of custom-property overrides. Nothing else.
/* a theme overrides token values only — zero .tsx changes */.theme-example { --accent-interactive: 24 95% 53%; --accent-highlight: 43 96% 56%; --surface-deep: 20 14% 4%; /* …only the tokens the theme wants to change */}What makes this possible is the value-preserving contract: the default theme’s token values are pinned to the exact legacy palette values in design-tokens.test.ts, and visual-regression baselines pass without regeneration. The migration changed where colours are defined, never what renders. Because every colour in component/page code resolves through a variable, scoping a class that reassigns those variables restyles the entire app.
Rules for theme files:
- Override token values only — a theme that needs a
.tsxchange is a design-system gap; fix the gap (new token or component variant), don’t fork the page - Must compose with the accessibility layers (high-contrast etc. reassign the same variables and must still win within their scope)
- Keep alpha out of theme values — opacity is applied at use sites via the modifier ladder
Shipped themes & the registry: themes live in packages/webapp/src/themes/ — one CSS file of custom-property overrides per theme plus an entry in themes/registry.ts (Midnight is the class-less default; Ember, .theme-ember, is the warm amber/coral identity that began life as the spec’s proof-of-concept). Theme CSS is imported by index.css (deliberately unlayered, so it wins against the @layer base defaults). ThemeProvider (contexts/ThemeContext.tsx) applies the selected theme’s class to <html> and persists the choice under the bsn-theme localStorage key; main.tsx re-applies it before first paint, and users switch in Profile → Appearance. Contracts are pinned by src/__tests__/themes.test.ts (every registered theme is token-overrides-only by construction, registry ↔ files consistency) and e2e-tests/theming.spec.ts (computed styles change with zero DOM changes; the accent roles theme independently; high-contrast’s filter composes; the profile switcher round-trips and persists across reload).
Adding a theme: write themes/<id>.css containing exactly one .theme-<id> rule of --token declarations, @import it from index.css alongside ember.css, and add the registry entry — the switcher, persistence, and contract tests pick it up automatically. This is the .theme-ledger path.
3. Component Library
Section titled “3. Component Library”packages/webapp/src/components/ui/ is the official component library: ~50 shadcn/Radix primitives (cva + tailwind-merge, composable slots) plus the promoted app-level patterns below. Pages compose library components; long inline utility strings are the exception, not the norm. The catalogue is Storybook: make storybook (story format follows HeroSection.stories.tsx; every promoted component has a co-located *.stories.tsx).
Promoted components
Section titled “Promoted components”| Component | File | Reach for it when | Variants |
|---|---|---|---|
PageHeader (+ PageHeaderEyebrow, PageHeaderTitle, PageHeaderDescription, PageHeaderActions, Highlight) | ui/page-header.tsx | Top-of-page h1 block with accent word + lead paragraph (~20 pages use it) | align: center/left; spacing: default mb-16 / compact mb-12 / none; title size: lg/md; description layout: contained/plain |
Section (+ SectionHeader, SectionTitle, SectionDescription) | ui/section.tsx | Full-width page band with baked-in container; homepage preview sections | background: default / gradient (.section-gradient) / muted / hero (.hero-gradient) |
GlassCard | ui/glass-card.tsx (variants in glass-card-variants.ts) | Any glass/gradient card — the dominant card treatment app-wide | variant: glass (.card-gradient) / accent (accent callout panel); padding: none/sm/md/lg/xl; lift |
StatCard | ui/stat-card.tsx | Big-number stat rows (“2,847 Community Members”) | size: md text-3xl / lg text-4xl; optional detail line |
EmptyState | ui/empty-state.tsx | ”No X yet” blocks: faded icon + title + description + action slot | variant: card (glass, p-12) / plain (py-12) |
IconBadge | ui/icon-badge.tsx | Accent pill (icon + short text): eyebrows, trust badges, inline tags | size: default / sm |
StepIndicator | ui/step-indicator.tsx | Numbered/icon step circles in “how it works” lists | size: sm w-8 / md w-12; tone: soft / solid |
Patterns deliberately not promoted (and why) are recorded in agent-os/specs/2026-06-12-themeable-design-system/planning/component-inventory.md: hero block (single consumer), status pills (no status tokens yet), wizard option buttons (flow-specific), page shell (structural), footer link (two-class swap).
Pixel-identity cautions
Section titled “Pixel-identity cautions”- A bare
card-gradientdiv is not aGlassCard.GlassCardincludes Card’s under-layer (rounded-lg border bg-card text-card-foreground shadow-sm) beneath the gradient. A hand-rolled<div className="card-gradient p-8 rounded-lg">lacksbg-card/shadow-smand renders differently. If you must match the bare form exactly, passclassName="bg-transparent shadow-none"— or keep the utility form. Never assume the swap is free. StatCard’s label getsmb-2only whendetailis present — this matches both pre-existing hand-rolled forms; don’t “fix” it.- Near-variant pills/badges (odd padding,
roundedinstead ofrounded-full) migrate withclassNameoverrides, not new variants — one consumer does not justify a variant.
Adding a cva variant
Section titled “Adding a cva variant”- Justify it: a variant needs ≥2 real consumers (see the inventory’s near-variant rule above)
- Add the variant to the component’s
cvadefinition (or its*-variants.tsfile where one exists — keeping variants in a separate file preserves react-refresh compatibility, cf.button-variants.ts,glass-card-variants.ts) - Style it with semantic tokens only — the library has no raw-palette exemption
- Add/extend the Storybook story and, if the variant is behavioural, a focused test in
ui/__tests__/promoted-components.test.tsx
4. CSS-Modules Escape Hatch
Section titled “4. CSS-Modules Escape Hatch”Some styling genuinely doesn’t belong in utility classes. The escape hatch is a co-located CSS file next to the component that owns it.
Acceptable:
- Complex keyframe-heavy animation that would be unreadable as Tailwind arbitrary values
- Genuinely one-off art-directed sections (e.g. a bespoke map “night” treatment)
- Styling third-party DOM you don’t render (Leaflet controls, popups, marker clusters)
Not acceptable:
- Anything expressible with tokens + library components — colours, spacing, typography, the standard card/section/header patterns
- Using a CSS file to smuggle raw colour values back into webapp components: even inside an escape-hatch file, prefer
hsl(var(--token))over literals wherever a token exists
Mechanics: name the file *.module.css, co-located with its component, imported by it alone. Vite supports CSS modules out of the box — no build configuration changes are needed or permitted for this.
Standing example: packages/mapper/src/components/BurglaryMap.css — the bespoke map treatment (hardcoded hex chrome for Leaflet controls, popups, and cluster markers). Decision recorded in migration batch 3: it lives in the mapper workspace as co-located plain CSS, outside packages/webapp/src and therefore outside the token/guard/lint scope; no token was forced onto it. The webapp-side wrapper (components/InteractiveBurglaryMap.tsx) is fully tokenized. This is the template for the policy: the escape hatch is for the genuinely bespoke leaf, while everything that surrounds it goes through tokens.
Note the guard test (next section) scans .css files under packages/webapp/src too — a webapp escape-hatch file may not reference the banned scales either.
5. Enforcement
Section titled “5. Enforcement”Three layers keep the system from eroding:
The guard test. packages/webapp/src/__tests__/raw-palette-guard.test.ts statically scans all of src (every .ts/.tsx/.css, comments stripped, index.css token layer exempt) and fails on any navy-*/electric-* utility class in any form — prefixed (hover:, group-hover:, md:), shaded (-500), or opacity-modified (/20), across bg-/text-/border-/from-/to-/via-/ring-/fill-/stroke- etc. It runs in the unit suite (make test, make test-watch).
The ESLint rule. Error-level no-restricted-syntax entries in packages/webapp/eslint.config.js ban, in className/cn/cva/clsx/twMerge/tv string contexts:
- raw
navy-*/electric-*classes (all variant/modifier forms) - arbitrary literal colour values (e.g.
bg-[#0ea5e9],[rgb(…)]) — which the guard test does not catch;var()-driven arbitrary values stay legal
It is wired into make lint / make qa via the turbo lint task, so a violation fails CI rather than accumulating. The exemption boundary is the token-definition layer only (index.css, which ESLint never parses); src/components/ui/ gets no exemption — the library is styled exclusively with semantic tokens. The rule’s behaviour is pinned by src/__tests__/lint-raw-palette.test.ts, which lints fixtures through the real project config.
The compile-time layer. The raw navy (50–950) and electric (DEFAULT, 50–900) scales are quarantined out of tailwind.config.ts entirely — the banned classes no longer exist, so even un-linted code cannot render them. Their exact values live on only as the semantic token defaults in index.css.
If either layer flags you, the answer is never to whitelist: either an existing token covers the role, or you’ve found a vocabulary gap — extend it (next section).
6. Adding a New Token
Section titled “6. Adding a New Token”A raw value with no sensible semantic home is a vocabulary gap. Never leave the raw value; extend the vocabulary:
- Derive from usage, not speculation. Grep for the pattern; name the token by role (what it does), not appearance (what it looks like). Prefer fewer, role-based tokens —
--surface-raised, not--dark-grey-2. If a near-miss token already exists, use it rather than minting a sibling. - Define it in
index.cssin the:rootexpanded-vocabulary block, as an HSL triple (--x: H S% L%), with a comment recording the exact legacy value it preserves:Exact-value discipline: compute the precise HSL equivalent of the value being replaced — “close enough” fails visual regression. The default theme stays value-preserving; new values (rather than new names for existing values) need design sign-off.--status-success: 142 71% 45%; /* green-500 #22c55e */ - Map it in
tailwind.config.tsusing the alpha-aware form so opacity modifiers work:status: {success: 'hsl(var(--status-success) / <alpha-value>)',}, - Extend the contract in
src/__tests__/design-tokens.test.ts: add the token → legacy-RGB pair to the value-preserving table, and the variable name to the<alpha-value>mapping check. - Check accessibility composition: if the token carries meaning under high-contrast or screen-reader-optimized modes, add the override to those layers in
index.css. - Verify: run the design-token tests, then the chromium visual-regression suite (
cd packages/webapp && npx playwright test --project=chromium visual-regression) — baselines must pass without regeneration.