Skip to content

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:

LayerFile
Token definitionspackages/webapp/src/index.css (:root block + @layer components)
Tailwind mappingpackages/webapp/tailwind.config.ts
Component librarypackages/webapp/src/components/ui/
Contractspackages/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.

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).

Pre-existing vocabulary; used by the shadcn primitives’ cva variant strings. Do not rename — renames break tailwind-merge variant strings.

TokenTailwind class(es)Role
--background / --foregroundbg-background, text-foregroundPage background (deep navy, 210 40% 8%) and default text (off-white, 210 40% 95%)
--card / --card-foregroundbg-card, text-card-foregroundCard chrome under-layer
--popover / --popover-foregroundbg-popoverFloating surfaces (menus, popovers)
--primary / --primary-foregroundbg-primary, text-primaryPrimary action colour (electric blue, 199 89% 48%)
--secondary / --secondary-foregroundbg-secondarySecondary action surface
--muted / --muted-foregroundbg-muted, text-muted-foregroundDe-emphasised surfaces and text
--accent / --accent-foregroundbg-accentshadcn hover/selected states (same value as --primary)
--destructive / --destructive-foregroundbg-destructiveErrors, dangerous actions
--border, --input, --ringborder-border, border-input, ring-ringRules, form borders, focus rings
--radiusrounded-lg/md/smCorner radius base (0.75rem)

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.

TokenTailwind class(es)RolePreserves (legacy)Do / Don’t
--surface-deepbg-surface-deepDeepest chrome: footer, page-end bandsnavy-950 #0f1419Do use for full-bleed dark chrome. Don’t use for cards (use bg-card / GlassCard).
--surface-raisedbg-surface-raisedRaised panels: map shells, elevated containersnavy-900 #1a202cDo use where content sits visually above the page. Don’t reach for it as a generic “darker grey”.
--accent-interactivetext-/bg-/border-/ring-/fill-accent-interactiveThe interactive accent: links, icons, buttons, focus affordances, pill tintselectric-500 #0ea5e9Do 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-hoverhover:bg-accent-interactive-hoverHover/active shift of the interactive accentelectric-600 #0284c7Do pair with accent-interactive on the same element. Don’t use as a standalone colour.
--accent-highlighttext-accent-highlightThe emphasised word in headings; big stat valueselectric-500 #0ea5e9Do use via <Highlight> / StatCard. Don’t use on interactive elements.
--foreground-secondarytext-foreground-secondaryBody copy below --foreground: lead paragraphs, descriptionsgray-300 #d1d5dbDo use for prose. Don’t drop to subtle just because text is long.
--foreground-subtletext-foreground-subtleCaptions, hints, labels, metadatagray-400 #9ca3afDo use for tertiary text. Don’t use for body copy (contrast).
--gradient-hero-from/-via/-to(via .hero-gradient)Hero band gradient endpointsnavy-950navy-800 #243b53navy-950Do 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 endpointsnavy-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)whiteDo override the hue per theme. Don’t bake alpha into the token.

Notes:

  • text-white is intentionally not tokenized or banned: --foreground is 95% lightness, not white, so headings that are genuinely white stay text-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).

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:

StepConventional use
/10Pill/callout background tint (IconBadge, GlassCard variant="accent")
/20Border tint paired with a /10 background; soft step-circle fill
/30Hover state of a /20 fill
/40Stronger tint, decorative fills
/50Mid-emphasis overlays, faded icons
/80Near-solid hover/overlay states

Don’t invent in-between steps (/15, /25) — every new step is another value a theme must look right at.

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

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.

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 .tsx change 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.

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).

ComponentFileReach for it whenVariants
PageHeader (+ PageHeaderEyebrow, PageHeaderTitle, PageHeaderDescription, PageHeaderActions, Highlight)ui/page-header.tsxTop-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.tsxFull-width page band with baked-in container; homepage preview sectionsbackground: default / gradient (.section-gradient) / muted / hero (.hero-gradient)
GlassCardui/glass-card.tsx (variants in glass-card-variants.ts)Any glass/gradient card — the dominant card treatment app-widevariant: glass (.card-gradient) / accent (accent callout panel); padding: none/sm/md/lg/xl; lift
StatCardui/stat-card.tsxBig-number stat rows (“2,847 Community Members”)size: md text-3xl / lg text-4xl; optional detail line
EmptyStateui/empty-state.tsx”No X yet” blocks: faded icon + title + description + action slotvariant: card (glass, p-12) / plain (py-12)
IconBadgeui/icon-badge.tsxAccent pill (icon + short text): eyebrows, trust badges, inline tagssize: default / sm
StepIndicatorui/step-indicator.tsxNumbered/icon step circles in “how it works” listssize: 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).

  • A bare card-gradient div is not a GlassCard. GlassCard includes 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"> lacks bg-card/shadow-sm and renders differently. If you must match the bare form exactly, pass className="bg-transparent shadow-none" — or keep the utility form. Never assume the swap is free.
  • StatCard’s label gets mb-2 only when detail is present — this matches both pre-existing hand-rolled forms; don’t “fix” it.
  • Near-variant pills/badges (odd padding, rounded instead of rounded-full) migrate with className overrides, not new variants — one consumer does not justify a variant.
  1. Justify it: a variant needs ≥2 real consumers (see the inventory’s near-variant rule above)
  2. Add the variant to the component’s cva definition (or its *-variants.ts file where one exists — keeping variants in a separate file preserves react-refresh compatibility, cf. button-variants.ts, glass-card-variants.ts)
  3. Style it with semantic tokens only — the library has no raw-palette exemption
  4. Add/extend the Storybook story and, if the variant is behavioural, a focused test in ui/__tests__/promoted-components.test.tsx

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.

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).

A raw value with no sensible semantic home is a vocabulary gap. Never leave the raw value; extend the vocabulary:

  1. 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.
  2. Define it in index.css in the :root expanded-vocabulary block, as an HSL triple (--x: H S% L%), with a comment recording the exact legacy value it preserves:
    --status-success: 142 71% 45%; /* green-500 #22c55e */
    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.
  3. Map it in tailwind.config.ts using the alpha-aware form so opacity modifiers work:
    status: {
    success: 'hsl(var(--status-success) / <alpha-value>)',
    },
  4. 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.
  5. 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.
  6. 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.