▸ featured · long read — № 015 · jun 13 '26 · 12 min · series: claude-driven monorepo · pt.1
Design systems as invariants: building a token pipeline from scratch
part 1 of the claude-driven monorepo series — a design layer whose rules make the wrong thing impossible, not just discouraged.
this is the first piece in a series on the principles behind a claude-driven monorepo. the repo bills itself as a "spec-first, agent-friendly monorepo boilerplate" — a production-shaped starter for teams building in the Claude Design → Claude Code loop: working backend / web / mobile, one shared design-token pipeline, a single source of truth for every contract, and drift-guards that make agent-driven work safe "without a human over every keystroke". today is the design layer — in detail, step by step, so you can build the same pipeline yourself. the stack (NestJS / Nuxt / Flutter) is just a worked example; the approach ports to any of them.
why invariants at all
greenfield projects with an AI assistant fail in predictable ways: the agent drops a hardcoded color into a component, renames a field in one client and forgets the other, changes something "in place". by the time you review, the diff is already dozens of files deep, and the human catches the drift too late.
the conclusion the whole approach rests on:
a design system isn't a component library — it's a set of machine-checkable invariants. you make the wrong thing impossible, not "discouraged". then it doesn't matter who writes the code — human or agent: the rules hold both the same.
here's how to build one, step by step.
the whole picture
one neutral format in the center, with both inputs and outputs converging on it:
design sources stack targets
────────────── ─────────────
Claude Design ┐ ┌─► CSS / SCSS variables
Figma ├─► [ TOKENS: W3C DTCG ] ──► emitters ─┼─► TypeScript constants
Penpot ├─► single source (per target) ├─► Flutter / Dart constants
by hand (JSON) ┘ └─► …any other
│
▼
primitives library on tokens ──► product screens
│
▼
machine rules (linters · audit · CI drift)
now each block, one at a time.
step 1 · the source: tokens in a neutral format
the source of truth is semantic tokens in the W3C Design Tokens (DTCG) format. the format deliberately knows nothing about any framework: a leaf is a $value / $type pair, plus theme pairs.
// specs/design/tokens/color.json
"accent": {
"light": { "$value": "#635bff", "$type": "color" },
"dark": { "$value": "#7b75ff", "$type": "color" }
}
two principles are baked in right here:
- single source of truth. every design decision (color, type, spacing, radii, shadows, motion, z-index) lives in one place — the token files. everything else is either generated from them or references them.
- name = intent, not value. a token is called
accent,surface.raised,radius.xl— by its role, not#635bff/16px. that's the superpower: a rebrand becomes a one-line edit. that's exactly what happened in this repo — the accent migrated#5c16c5 → #635bffand not one component changed, because components reference the role, not the hex.
where the source itself comes from is up to you, and there are plenty of options (anything that can emit DTCG):
- Claude Design / claude.ai — describe the brand in a brief, Claude generates the token JSON and the mockups. the repo ships a ready copy-paste brief for exactly this. this is "AI as a design source".
- Figma — export a token plugin to JSON, lay it out across
*.json. - Penpot (the open-source Figma alternative) — the same plugin export.
- by hand — just write the JSON.
→ source and format:
specs/design(itsREADME.mddocuments importing from Claude Design / Figma / Penpot / by hand) · andspecs/design/tokens.
step 2 · the emitter: one source → many targets
this is the heart of portability. take one token model and compile it for each platform into that platform's native format. no manual "duplicate the color in three places" — parity is structural.
the CSS emitter walks the tokens and lays them out by theme (camelCase names → kebab-case):
// simplified from packages/design-tokens/src/emit-scss.ts
function themedBlock(theme, tokens) {
const selector = theme === 'light' ? `:root, [data-theme='light']` : `[data-theme='${theme}']`;
const lines = [`${selector} {`];
for (const [key, leaf] of Object.entries(tokens.color.brand)) {
const value = (leaf[theme] ?? leaf.light).$value; // fall back to light
lines.push(` --brand-${kebab(key)}: ${value};`);
}
lines.push('}');
return lines.join('\n');
}
the result is plain CSS variables with a theme cascade (light is also the :root default, the rest override):
:root,
[data-theme='light'] {
--brand-accent: #635bff;
}
[data-theme='dark'],
.dark {
--brand-accent: #7b75ff;
}
from one source the emitter rolls out every theme at once (here: light / dark / sepia / forest) — so dark mode and rebrand come "for free".
a fan-out build writes the artifacts for each platform in one command:
// simplified from packages/design-tokens/src/build.ts
const targets = [
{ file: 'apps/web/app/assets/css/tokens.generated.css', contents: emitScss(tokens) },
{ file: 'packages/ui/src/tokens.generated.css', contents: emitScss(tokens) },
{ file: 'apps/web/app/design-tokens.generated.ts', contents: emitTypescript(tokens) },
{ file: 'packages/ui/src/design-tokens.generated.ts', contents: emitTypescript(tokens) },
{ file: 'packages/ui_flutter/lib/src/theme/tokens.g.dart', contents: emitDart(tokens) },
];
here's the portability proof — the same token on two unrelated runtimes:
/* CSS / SCSS (browser) */
--brand-accent: #635bff;
// Flutter / Dart (mobile runtime)
static const Color brandAccent = Color(0xFF635BFF);
what ports is the approach: want a new stack? add an emitter for the new target — you don't touch the source. the whole principle: generate into each platform's native format.
→ the emitters in full:
packages/design-tokens· the same tokens after they reach the mobile runtime:packages/ui_flutter.
step 3 · primitives on tokens, not wrappers around someone else's kit
the temptation is to grab a ready-made UI kit and wrap its button. but then the styles live inside someone else's code, and you can't pin rules on them. so the base primitives are your own, styled with tokens only.
each component is a folder with the full set (more on that in step 4):
packages/ui/src/components/AppButton/
AppButton.vue # markup + token styles
AppButton.stories.ts # showcase of every variant
AppButton.spec.ts # render + a11y + variants
index.ts
styles reference only var(--*), classes are BEM with a prefix, no raw values:
.app-button {
height: var(--space-10);
padding: 0 var(--space-4);
border-radius: var(--radius-lg);
font-size: var(--text-md);
transition: background var(--dur-base) var(--ease);
&--primary {
// BEM modifier
background: var(--brand-primary);
color: var(--brand-primary-fg);
}
}
the component API reads as intent: color="primary", not a color. so the rebrand from step 1 passes through the entire UI without touching a single signature.
you can't fully escape someone else's kit — complex behavioral widgets (modal, dropdown, table, calendar) are cheaper to take ready-made. they're not banned, they're isolated: the foreign symbol is imported in exactly one place — inside its own wrapper-primitive — and only your component faces outward.
the principle: own your primitives — you can only check rules on code you own.
→ primitives library:
packages/ui.
step 4 · teeth: machine rules instead of a guideline
this is the step the whole thing was for. the rules aren't lines in a wiki — they're configs that fail the build.
the linter bans hardcoding. the actual rules:
// simplified from stylelint.config.mjs
rules: {
'declaration-no-important': true, // no !important
'color-no-hex': true, // no #635bff — only var(--brand-*)
'color-named': 'never', // no red / blue
// plus: z-index only via var(--z-*), durations via var(--dur-*),
// spacing ≥3px via var(--space-*); classes — BEM with an app-/health-/brand- prefix
}
hardcode #F26A1F instead of accent and lint rejects it. not an abstract agreement — a refusal.
the audit demands the full set per component. a script walks the folders and fails if a component is missing its story or spec:
// the gist of packages/ui/scripts/audit-components.ts
// every component folder must contain:
// <Name>.vue, <Name>.stories.ts, <Name>.spec.ts, index.ts
// otherwise process.exit(1) — "component audit failed".
this audit actually runs in CI (the ui-quality job), so a "component with no showcase and no test" physically can't reach main.
CI catches generated-code drift. the artifacts are committed; CI regenerates them and diffs against the tree — a mismatch fails the PR. in this repo the drift-gate currently sits on the generated API clients (spec:codegen → git diff --exit-code); for design tokens the same trick is the next obvious step (see "what's next"). for now the tokens are held by a ban on hand-editing generated files plus regenerating in place.
the principle: rules are machine-enforced, not in a guideline. a guideline is hope; a failing pipeline is a guarantee. and that's what turns "the agent writes features" from a risk into a managed process.
→ the real rules config:
stylelint.config.mjs.
step 5 · changes flow one way only
tie it all into a loop. any design change runs strictly top-down:
- edit a token —
specs/design/tokens/*.json. - regenerate —
pnpm design:build. the web / Storybook / mobile artifacts rebuild together. - update or create the component in the library (with story and spec).
- consume it in the apps — pages stay thin: data, props, routing.
the rule: never edit "downstream" to fix a value "upstream". if a component renders the old color, it's using a literal instead of a token; you fix the component, you don't bend the token to match. the reverse edit is a bug, not flexibility.
the honest trade-offs
the approach isn't free — and that's fine:
- regeneration is a real step, and the artifacts are committed. forget to rebuild and you get drift. for now a ban on hand-editing generated files holds the tokens; the CI drift-gate (already covering generated clients) doesn't extend to tokens yet — see "what's next".
- a token is a contract. renaming or deleting one = a breaking change for every consumer on every platform. only deliberately (in this repo — via an ADR).
- your own primitives cost more up front than
install-ing a ready kit. - you can't fully escape someone else's kit — complex widgets still ride on it, just behind a wrapper.
- a strictness tax. "no magic numbers" slows down "just throw something together fast".
what's next / what could be better
one direction: every next step narrows the gap between intent and check, or shortens the loop from source → product.
- close the designer↔code loop — pull tokens straight from the design tool through the same standard (DTCG was built for exactly this), so the source stops being hand-written JSON.
- make token regeneration un-forgettable — a CI drift-gate (as already done for generated clients via
spec:codegen) that regenerates tokens and fails on a mismatch. closes trade-off #1. - token aliases — semantic tokens reference primitives (
{group.token}per DTCG), killing duplication and making rebrands even cheaper. - a rename codemod — turn the "token = contract" cost from manual into mechanical.
- wider machine checks — a contract test for "every role × every theme has a value"; visual baseline snapshots per component.
the takeaway
the design layer here isn't a library — it's an invariant machine: one neutral source, generation for any stack, and a set of linters and audits that make breaking the rules technically impossible. many design sources converge on one standard format; out of it, many targets — and the rules keep the system whole no matter who writes the code.
→ see it all live: the showcase repo · the token-format standard — designtokens.org.
next in the series — the spec-first loop: how OpenAPI + codegen keep the backend and the clients from drifting apart on the contract.