Building a design system without Figma

I built the Canarist design system without ever opening Figma. Tokens live in CSS, components are written in code, and an AI agent handles the repetitive parts.

This is design engineering: the design system and the product ship from the same codebase, owned by the same person. I built this alone in 3 days, while shipping product features in parallel.

The problem

Without token constraints, AI-generated components are a maintenance problem. Every new button, card, or alert picks whatever value is in scope — raw oklch, hex, alias tokens at random. Dark mode breaks silently. Brand drifts across components.

The fix is not better prompts. The fix is an architecture where the correct choice is the only available choice.

Three layers, one rule

The system is built on three layers:

/* ── Brand — primitives ─────────────────────────────────── */
/* Raw OKLCH values. No semantic meaning.                    */
--color-neutral-50:  oklch(97% 0.003 264);
--color-orange-500:  oklch(70% 0.18  49);

/* ── Alias — semantic ───────────────────────────────────── */
/* Intent names. Describe what a colour is for.             */
--color-bg-card:      var(--color-neutral-50);
--color-text-primary: var(--color-neutral-900);
--color-accent-base:  var(--color-orange-500);

/* ── Mapped — component ─────────────────────────────────── */
/* The only layer components are allowed to touch.          */
--surface-card:    var(--color-bg-card);
--text-heading:    var(--color-text-primary);
--surface-action:  var(--color-accent-base);

The rule is simple: components reference only Mapped tokens. No exceptions.

Swapping the entire theme — light to dark — is a single CSS block swap. No component changes required.

Enforcement

The constraint lives in design-system-tokens.md (.claude/rules/) and is automatically loaded by the AI agent on every session.

/* ✓ Allowed — Mapped tokens only */
--surface-*    --text-*    --border-*    --focus-*

/* ✗ Forbidden */
var(--color-bg-*)      /* use --surface-* instead    */
var(--color-accent*)   /* use --surface-action instead */
#ffffff                /* no raw hex values            */

0 hardcoded hex values in any component. Not by convention — by rule.

Two documented exceptions: Canvas API and Satori image generation both require raw values and can't consume CSS variables.

To verify compliance on any file, I use a Claude Code skill — check-variables — that audits token layer usage, catches hardcoded values, and offers auto-fix. Running /check-variables on a component surfaces violations in seconds.

The numbers

  • 45 components — a production-ready foundation (buttons, inputs, cards, badges, modals, tables, tabs, callouts, alerts)
  • 130+ tokens across three layers, light + dark — defined in globals.css
  • Dual theme — one CSS block swap, zero component changes required
  • 0 hardcoded hex values — enforced by rule, not convention
  • OKLCH throughout — perceptually uniform colour for semantic alert states
  • 3 days to build the initial system, shipping product features in parallel

Why OKLCH

HSL is intuitive but perceptually non-uniform. OKLCH maps lightness directly to perceived brightness — essential when colour carries semantic meaning (success, warning, error, info).

What this unlocks

Design decisions happen in code. That means they're version-controlled, machine-readable, and self-enforcing.

The system ships as a living docs site at ui.canarist.com — 47 sections, interactive component playground, every token documented. Adding a new component means generating it; the architecture validates correctness by default. No design review for token compliance. No handoff. The system does that work.

The constraints live in source control. Any developer — or AI — working in this codebase inherits them by default.