
11 — Token discipline
When to read this: Once you have DESIGN.md and you're about to start UI work. Or whenever your token check is failing and you don't know why.
What "tokens" means here#
A design token is a named reference to a design value: bg-background, text-foreground, border-border, text-accent. The token is bound to an actual color (or font, or spacing value) in DESIGN.md and the project's CSS. Components consume the token, not the underlying value.
Compare:
❌ Raw literals. Component hardcodes the value:
<button className="bg-zinc-900 text-white border-zinc-800">The button's color is fixed. To change it, you have to find every hardcoded reference. The component doesn't know about your brand. It knows about specific Tailwind classes.
✅ Semantic tokens. Component references the token:
<button className="bg-card text-foreground border-border">The button consumes the token. The token's value lives in DESIGN.md and CSS. To change the brand color, change the token — every consumer updates.
Why this matters more for AI-built code#
A human engineer writing UI by hand gets bored typing bg-[#1a1a1a] and starts using a token. An AI agent has no fatigue. It will ship bg-zinc-900 on every component because that's what the training data is full of.
Without enforcement, a project accumulates raw literals across hundreds of files. By month 3, "change the brand accent color" requires touching 80 components. DESIGN.md becomes decorative. The source of truth for color is now whatever the agent typed.
The fix: make raw literals fail the build. That's what scripts/check-tokens.sh does.
scripts/check-tokens.sh#
The kit ships a 79-line bash script that fails if it finds any of:
- Raw Tailwind color utilities.
bg-red-500,text-blue-700,border-zinc-800,ring-purple-400. The script knows the full Tailwind palette: red, blue, green, yellow, purple, pink, indigo, gray, zinc, slate, stone, neutral, orange, amber, lime, emerald, teal, cyan, sky, violet, fuchsia, rose. - Hex literals in className.
className="bg-[#1a1a1a]". Hex anywhere inside a className string fails. - Inline hex via the
styleprop.style={{ color: "#0080ff" }}. - Forbidden default fonts.
Inter,Roboto,Open Sans,Lato,Arialreferenced viafont-familyorfontFamily. (The kit's universal forbidden defaults include these by name.) - Material default easing.
cubic-bezier(0.4, 0, 0.2, 1)is the dead giveaway of generic Material motion.
Each violation prints with file:line for easy fixing.
Running it#
bash scripts/check-tokens.shBy default, it scans components/, app/, and src/. Pass paths to scan elsewhere:
bash scripts/check-tokens.sh src/components src/libThe script targets *.tsx, *.jsx, *.ts, and *.css files.
What success looks like#
✓ Token check passed.Exit code 0.
What failure looks like#
✗ Hardcoded Tailwind color background. Use semantic tokens (bg-background, bg-card, bg-accent, etc.).
src/components/Button.tsx:23: <button className="bg-zinc-900 text-white">
✗ Forbidden default font (Inter, Roboto, Open Sans, Lato, Arial). Use the brand fonts from DESIGN.md.
src/styles/globals.css:7: font-family: 'Inter', sans-serif;
❌ Token check failed. 2 violation type(s) found.
Fix the above, or update DESIGN.md if the rule itself is wrong.Exit code 1.
The fix is one of:
- Replace the literal with a semantic token. Add the token to DESIGN.md and CSS if it doesn't already exist.
- Change the rule. If your project genuinely needs
Roboto(e.g., shipping for the Android Auto market), update DESIGN.md to override the universal forbid. The script doesn't know about your DESIGN.md overrides directly. Edit the script to remove the check for that font, or wrap the font name in a way the regex doesn't catch (e.g., via a CSS variable). Most projects don't need this.
Wiring check-tokens.sh to the Stop hook#
The kit's example settings (templates/claude-settings.example.json) wires check-tokens.sh to fire on every Stop event:
{
"hooks": {
"Stop": [
{
"matcher": "",
"hooks": [
{ "type": "command", "command": "bash scripts/check-tokens.sh" },
{ "type": "command", "command": "bun run .claude/hooks/continual-learning-stop.ts" }
]
}
]
}
}Bootstrap copies this to .claude/settings.json only if no settings.json already exists. If you have an existing settings.json, merge the hooks.Stop block manually. The kit refuses to overwrite settings.json even with --force.
Wired this way, the token check runs after every Claude Code turn. If the agent introduces a violation in a turn, the next Stop fails the check and the agent sees the failure in its next message. The tightest feedback loop available. The agent can't ship a token violation past you because it can't even cleanly finish a turn that contains one.
Running manually#
You can also run it explicitly during a /scaffold-component invocation, in CI, or as a pre-commit git hook. The script has no dependencies beyond bash and grep, so it runs anywhere.
/scaffold-component and token discipline#
The /scaffold-component slash command builds new UI primitives. It scaffolds three files for each component:
components/ui/<name>.tsx— the component, usingclass-variance-authorityfor variants, forwarding refs on interactive primitives, with full state coverage (hover, focus-visible, active, disabled, loading, error, empty).components/ui/<name>.stories.tsx— one story per variant, one story per state, plus a Matrix story for visual regression.components/ui/<name>.md— README documenting purpose, variant matrix, props, composition examples, accessibility notes.
The command's hard rules:
- No raw color literals.
- No raw spacing literals outside the scale.
- Token-mapped utilities only.
- Every variant has a story.
- Every state is reachable in Storybook.
- Forward refs on interactive elements.
After scaffolding, the command runs bash scripts/check-tokens.sh components/ui/<name>.tsx automatically. If the check fails, the agent fixes the violations or stops to ask whether DESIGN.md should be updated.
Every new primitive ships token-disciplined from the start. No fixup pass needed later.
How DESIGN.md tokens map to your CSS#
The kit assumes Tailwind v4 by default (the @theme inline block syntax with OKLCH custom properties). The general pattern:
/* app/globals.css or src/styles/tokens.css */
@theme inline {
/* Color */
--color-background: oklch(0.98 0.01 80);
--color-foreground: oklch(0.15 0.02 270);
--color-card: oklch(0.96 0.005 80);
--color-border: oklch(0.92 0.005 80);
--color-muted-foreground: oklch(0.55 0.02 270);
--color-accent: oklch(0.65 0.18 250);
/* Type */
--font-display: "Newsreader", serif;
--font-body: "Cabinet Grotesk", sans-serif;
--font-mono: "JetBrains Mono", monospace;
/* Motion */
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--duration-fast: 150ms;
--duration-normal: 250ms;
--duration-slow: 400ms;
}Tailwind generates utility classes for these automatically: bg-background, bg-card, bg-accent, text-foreground, text-muted-foreground, etc.
When you run design-md-builder, it asks for these values in OKLCH and writes them into DESIGN.md. You copy the values into your CSS. Some projects automate this with a build step that reads DESIGN.md and writes the CSS. The kit doesn't ship that step. It's a 30-line script if you want it.
If you're using Tailwind v3 instead of v4, the format differs (tailwind.config.ts theme.extend.colors etc.). Same principle: tokens in DESIGN.md → CSS variables / Tailwind config → semantic utility classes → consumed by components.
The accessibility test#
The kit ships tests/a11y.spec.ts, a Playwright + axe-core test that verifies WCAG 2.1 AA compliance and visible focus indicators across mobile/tablet/desktop viewports.
What it does:
- For each route in
ROUTES, at each viewport (375 / 768 / 1280), navigate to the route, wait fornetworkidle, run axe-core withwcag2a,wcag2aa,wcag21a,wcag21aatags. Fail if any violations. - Tab through up to 25 elements, verify each focused element has a visible focus indicator (outline, box shadow, or border change). Fail on the first focusable element that has no visible indicator.
To run:
npx playwright test tests/a11y.spec.tsSetup required:
npm i -D @axe-core/playwright @playwright/test
npx playwright installEdit the ROUTES array at the top of the file to match your project's routes. The kit ships it with ['/'] as a starter.
Wiring tokens + a11y into CI#
Both checks are designed to run in CI without any kit-specific tooling. Sample GitHub Actions snippet:
name: ci
on: [pull_request]
jobs:
token-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: bash scripts/check-tokens.sh
a11y:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npx playwright install --with-deps
- run: npm run build
- run: npm run start &
- run: npx wait-on http://localhost:3000
- run: npx playwright test tests/a11y.spec.tsMark both as required checks in your branch protection rules. Token violations and a11y regressions can't merge to main, regardless of who (or what) wrote the change.
When the token check is the wrong gate#
A few cases where you might legitimately need to bypass it:
- Generated code or third-party components that use raw literals you can't easily fix. Add a path exclusion, or scope check-tokens to your own source directory only.
- Email templates that need inline styles for client compatibility. Skip them in the scan path.
- Logo or brand asset components that hardcode specific brand values (e.g., an SVG logo with a brand-pink fill). Acceptable. Document the exception in DESIGN.md.
The check-tokens script is intentionally simple. A regex-based bash script. If your project needs more nuance, edit it. The script lives in your project under scripts/check-tokens.sh. You own it.
Common stumbles#
| Symptom | Fix |
|---|---|
| Token check fails on a color you "need" | Add the token to DESIGN.md + CSS. Don't bypass. Adding a token costs 30 seconds. Bypassing costs months of consistency |
| Stop hook isn't running the check | settings.json doesn't have the hook wired. Copy the example block from templates/claude-settings.example.json |
agent-workflow --force overwrote my settings.json | It didn't. Bootstrap never overwrites settings.json, even with --force. If the file is gone, you deleted it. Restore from git |
| The check is failing on generated code (shadcn primitives) | Either fix the primitives to use tokens (the kit's /scaffold-component does this from the start), or scope the check to your custom dirs |
| You want a stricter check (catch more patterns) | Edit scripts/check-tokens.sh. Add new check '<pattern>' '<message>' lines |
| You want a looser check | Same. Edit the script. Remove or relax the patterns you don't want |
| Playwright tests time out in CI | Increase the wait-on timeout, or use webServer config in playwright.config.ts instead of starting the server in a separate step |
| axe-core flags violations you can't fix (third-party component) | Use axe's disableRules option for that specific test. Don't ignore globally |
Continue#
Token-and-a11y gates are mechanical. The next layer of project intelligence is learned. Next: Chapter 12: Continual learning.