Minimal Design System
A 100-component React kit where every design rule is a record.

Every gate was made to fail before it was trusted, and debt is only allowed to shrink. Built over 98 days, with agents doing the volume and the gates doing the arguing.
The claim, proven
The README makes one promise: to rebrand, replace the Layer 1 ramps and the Layer 2 accent pair. Nothing else changes.
The docs site carries a second brand, warm, that is that promise executed. It overrides Layer 1 and the accent, touches no component, and the header toggle switches it live. A separate consumer app in CI does the same thing with a terracotta theme and runs axe on the result in both modes.


The whole rebrand
The warm brand file, condensed. Two neutral ramps re-hued, one accent pair, three radii. The comment at the top of the real file reads: “this file is that claim executed”.
/* docs/src/brand-warm.css, condensed */
:root[data-brand="warm"] {
/* Layer 1: both neutral ramps, hue ~85 */
--base-50: oklch(0.985 0.003 85);
--base-100: oklch(0.966 0.004 88);
/* ...ten more base steps... */
--ink-50: oklch(0.985 0.003 85);
/* ...ten more ink steps... */
/* Layer 2: the accent pair, lime fill, olive text */
--accent-lime: oklch(0.841 0.238 128.85);
--accent-lime-ink: oklch(0.32 0.09 131);
--button-primary: var(--accent-lime);
--text-on-accent: var(--accent-lime-ink);
/* Radius: one step up, ratio preserved */
--radius-button: 12px; --radius-card: 20px; --radius-popup: 28px;
}
/* Components touched: 0 */Same composition, two brands
The Requests block on the showcase: Card, Stat, Tabs, Table, Badge and Chart, no bespoke styling. Rendered neutral light and warm dark.


The kit itself
77 primitives from shadcn, Radix and Base UI, 22 AI Elements components, and the kit’s own MessageParts renderer, all reading the same token layer. Every example on the docs site is the real kit: imported components, live tokens.




Architecture: three layers, one inversion
In most shadcn projects the shadcn variables hold the values and the team’s own tokens alias them. Here it is reversed: the semantic layer owns the values, and shadcn’s names are the alias.
Primitives
Raw ramps. Two neutral ramps on purpose, base for surfaces and ink for content, 12 steps each, so surfaces can warm or cool without dragging text along. Six hue ramps at 11 steps. No component reads these.
90
Semantic
Surface, text, icon, border, button, accent, status and chart roles. The only place where light and dark values are decided. The accent is one pair, and five other tokens derive from it.
83
shadcn alias
--background, --primary, --muted and the rest, mapped with var() onto Layer 2. A test asserts each alias is exactly var(--target), nothing more.
23
Two neutral ramps, and what a brand does to them
Real values. The neutral brand’s ramps are hex; the warm brand’s are oklch around hue 85. Surfaces read base, content reads ink, so a brand can warm the page without warming the text, or the other way round.
Three real token chains
How a value reaches a component. The component never names Layer 1; it names Layer 3, which names Layer 2, which names the ramp step.
--base-0
--surface-card
--card
bg-card
--ink-950
--text-primary
--foreground
text-foreground
--base-900
--surface-card
--card, re-declared in .dark
bg-card
Any stock shadcn component drops in unmodified and inherits the theme. The rule the repo gives agents is one line: if a change needs to touch Layer 3, the semantic layer is missing a token.
Dark mode that nests
Dark values never live in a component. They live in Layer 2 only. Nested dark works because var() substitutes at the element that declares it, so every alias whose target flips between modes is re-declared inside the dark block with the same expression, as the third chain above shows. A section can go dark inside a light page.

Dark made honest, 2026-08-22
Dark tertiary text before and after, with the kit’s own values. One new ramp step, zero component edits.
Tertiary text, as a caption or a timestamp reads. Fails AA at any size.
Same role, same component. The surface moved to the base ramp and the text got a step that did not exist.
The first full dark audit the next day passed 279 of 279 stories in both themes. About 130 axe violations cleared by changing roughly five Layer 2 values. None of the fixes were per component.
Figma as a name contract
There is no sync pipeline. tokens.ts holds metadata only: the CSS property, its byte-exact Figma variable name, and a usage line. A mirror gate checks that every documented name exists in CSS and every CSS token is documented. Drift the other way is recorded by hand with a status per record. Storybook renders swatches from the CSS and names from the metadata, so the docs cannot disagree with the code. Since 10-21 a second set of eight rules holds the Figma components themselves to the code contracts.

Rules as records
Every design rule in the kit is one typed record: an id, a severity, how it is detected, the fix, and why. There are 30: 22 for the code and, since 10-21, 8 for the Figma components.
One command emits those records to JSON, and that JSON is what the detector, the write-time hook, the unslop audit, CI and the Storybook Rules page all read. There is no second list to drift from. The repo’s own phrasing: a ban that exists only in a document does not exist.
One record, five readers
TOK-8, as it sits in rules/tokens.ts. The fix is a required field; a ban with no substitution is refused by the schema.
- TOK-8 · blocker · detect: grep
- No opacity modifier on a foreground. The dim belongs in Layer 2.
- \b(?:text|fill|stroke|placeholder|decoration|caret)-(?!(?:xs|sm|base|lg|[2-9]?xl)/)[a-z][\w-]*/\d{1,3}\b
- src/components, src/lib, src/hooks · exempt: stories, tests, ai/sources.tsx
- Use the flat rung that owns the job (text-muted-foreground or text-text-secondary for quiet text, flat text-destructive, stroke-chart-grid for gridlines). If the dimmed value is genuinely new, declare the mix in Layer 2 the way --chart-grid does, and document it in tokens.ts. Never compose it in the component.
- A composite resolves against whatever sits behind it, so its contrast is decided by the ground, not the token. Axe only measures the grounds stories happen to use, and never measures hover states. A /50 foreground shipping an AA failure under exactly this pattern is the recorded precedent.
- Detector
- Write-time hook
- unslop skill
- CI
- Storybook Rules page
The catalogue
22 blockers, 4 warnings, 2 at review, 2 notes. STA-7 needs a rendered page, SYS-3 needs judgment, and the Figma rules need the Figma file, so CI cannot run them. They print as unchecked on every run, so “I did not check this” never looks like “this passed”.
| Id | Rule | Severity | Detect |
|---|---|---|---|
| COL-1 | Zero gradients in product UI | blocker | grep |
| COL-6 | Indigo, violet and purple exist only as chart-4 | warning | heuristic |
| COL-9 | Backdrop blur earns its content | review | heuristic |
| ICO-2 | Zero emoji in chrome | blocker | grep |
| MOT-1 | Motion only on state change, zero infinite loops outside progress indicators | blocker | grep |
| MOT-2 | Named transition properties, never transition-all | blocker | grep |
| MOT-9 | Motion reads tokens, never numeric steps | blocker | grep |
| CPY-1 | Say the thing: no generic CTAs | blocker | grep |
| CPY-2 | Banned marketing register | blocker | grep |
| CHT-1 | Chart colours are chart-1 to 5, never library defaults | blocker | grep |
| CHT-3 | Flat marks, hairline gridlines | blocker | grep |
| STA-3 | outline-none never ships without a focus-visible replacement | review | heuristic |
| STA-7 | The 375px pass: no horizontal scroll, tap targets at least 24px | blocker | rendered · unchecked |
| SYS-3 | No decoration where hierarchy was the need | blocker | judgment · unchecked |
| TOK-1 | No raw hex colour | blocker | grep |
| TOK-2 | No raw oklch() | blocker | grep |
| TOK-3 | No Tailwind palette class | blocker | grep |
| TOK-4 | Spacing only from the scale, off-scale literals ratchet | blocker | grep |
| TOK-5 | Motion only from the four durations, never a bracket literal | blocker | grep |
| TOK-6 | Radius only from the scale, off-scale literals ratchet | blocker | grep |
| TOK-7 | Type size only from the scale, off-scale literals ratchet | blocker | grep |
| TOK-8 | No opacity modifier on a foreground | blocker | grep |
| CON-1 | Every component set matches a code component | blocker | figma |
| CON-2 | Every variant value in Figma is accepted by the contract | blocker | figma |
| CON-3 | Contract values the set does not show | note | figma |
| CON-4 | Variant properties name a contract prop or a declared Figma-only axis | warning | figma |
| CON-5 | Paints bind a library variable where one resolves to the same value | blocker | figma |
| CON-6 | Layers bind the semantic token the contract names, not a primitive | warning | figma |
| CON-7 | The set's description carries the contract's purpose | warning | figma |
| CON-8 | Contract tokens are bound by some layer in the set | note | figma |

Eight rule bodies, one line each in the always-on file
The agent instructions file was 31 KB and re-injected into every session. It was split into eight rule bodies behind one-line stubs, with a byte ceiling that only ratchets down and a provenance test: a rule can leave the always-on file only if a gate or a written “ungated” reason stays behind.
- Never hardcode. No raw hex, no oklch(), no palette classes, no arbitrary values, no opacity on a foreground.
- Motion. Four durations chosen by what is moving. Because every transition reads a token, one reduced-motion block disables motion kit-wide, including components that do not exist yet.
- Usage contracts. A per-component JSON with a purpose that must say when the component is the wrong choice, anti-patterns with a why, and relationships.
- Gates and baselines. The ledger of every gate, when it landed, and what it carries.
- Conformance ladder. A vendored checker with ten stages. Which claims are met, which are unchecked by design.
- Review rounds and blockers. A round budget and a shared definition of blocker.
- Traps. Seven bugs already paid for, so nobody rediscovers them by hand.
- Worktrees and installs. One checkout per task, and a hygiene report for stranded work.
A trap, as recorded
The first entry in traps.md. Tailwind v4 has an --ease-* theme namespace and no --duration-* one, so the motion table compiled to nothing for three days.
/* 2026-08-11 to 08-14: written everywhere,
in effect nowhere */
@theme {
--duration-fast: 100ms;
--duration-base: 200ms; /* compiles to NO utility */
--duration-slow: 300ms;
}
/* every element wearing duration-base
silently fell to the default duration *//* PR #41, 2026-08-17 */
@utility duration-base {
transition-duration: var(--duration-base);
}
/* ...and MOT-9: 22 numeric durations swept
to tokens; rule record added so the hook
and CI catch the next one */The trap entry ends with the test: “compile globals.css and grep the output for the class before believing it exists.”
Four gate moments
A rule is only as real as the moment it runs. The kit checks at four points, each with a different cost and a different authority.
Hook
A pre-write hook lints what an agent is about to write against the blocker rules. Warn mode on purpose: it earns the right to block only after every grep rule has a false-positive fixture and two clean weeks.
Local suite
Lint, typecheck, check, test. Four Vitest projects: unit, gates, Storybook light, Storybook dark. Nothing is done before all four are green on the author's machine.
CI
Five file-reading gates: tokens, stories, capabilities, liveness, rules. Axe on every story in real Chromium, once per theme, zero opt-outs. The consumer harness. The conformance ladder.
Second reader
A review skill reads the diff, the touched contracts, the rule output over changed files, CI status and a report of test lines the PR removed, then writes a verdict with cited blockers. Advisory: a human approval outranks it.
The portability gate: install the kit the way a stranger would
The consumer harness, as it runs in CI. Every step is real; the screenshots are uploaded as artifacts.
- npm pack
- Install
- Re-theme
- tsc + build
- 9 probes
- axe × 2
- Screenshots
/* harness/consumer/src/theme.css, condensed */
@import "@weeeha/ui/globals.css";
:root {
/* two neutral ramps, warmed */
--base-50: #fbf8f3; /* ... */ --base-950: #0f0d0a;
--ink-50: #faf7f2; /* ... */ --ink-950: #0d0b09;
/* one brand ramp */
--accent-50: #fff7ed; /* ... */ --accent-950: #431407;
/* the pair. Everything accent-coloured derives from it */
--accent-solid: var(--accent-800);
--accent-on-solid: var(--accent-50);
}What the harness found on its first day, 2026-08-18:
- Tailwind was not declared as a peer dependency. A consumer's build failed on line 1 of globals.css.
- The @source globs were too wide: 53 story-only utility classes shipped into consumer CSS. Narrowed to zero.
- A same-named tarball reported up to date and never reinstalled, so every measurement after the first tested the old kit.
- A half-rebrand: four of six accent tokens set by hand. The accent pair now makes that impossible, because the six derive from the two.
Two contracts a component can carry
A story declares what the component can do, and owes a story per state. A usage contract declares what the component is for, and when it is the wrong choice.
Capability contract: tags owe states
A story file declares capabilities. Each tag owes named stories, and CI derives the debt. The example is alert-dialog.stories.tsx on main.
// src/components/alert-dialog.stories.tsx
const meta = {
parameters: {
capabilities: ["interactive", "text-bearing"],
},
}
// owed by "interactive"
export const Disabled: Story = { /* ... */ } // line 66
// owed by "text-bearing"
export const LongContent: Story = { /* ... */ } // line 101Every story is also an axe test, in both themes. So “owes a Disabled story” means “the disabled state gets audited for contrast”, which is where disabled states usually fail.
Usage contract: what it is for, and when it is wrong
badge.meta.json, excerpted. The schema sets minimum lengths: an 80-character purpose, a 20-character intent per variant, a 20-character why per anti-pattern. The repo calls padding to pass “worse than failing”.
- A small, non-interactive label for state, category or count, whose word carries the meaning while the variant only ranks its emphasis. It is the most over-used component in a kit: it looks decorative, so it accumulates on a screen until nothing is emphasised. It is the wrong choice for anything the reader is meant to click.
- Solid accent fill, the strongest weight in the ramp. Reserve it for the one state that needs someone to act on it now.
- No border and no fill at rest. For a state that has already resolved and no longer needs to be scanned for.
- text-on-accent: kept as one pair with button-primary so the two cannot drift apart.
- aria-label on an icon-only badge. The badge is a bare span with no implicit role, and ARIA prohibits aria-label on a generic element. Assistive tech silently drops it and axe fails.
30 of 100 components carry one. The floor is 29 and can only rise. An agent placing a component is told to read the index first and at most two full contracts, never to survey the source folder.

Ratchets and floors
A gate that demands a clean slate never gets adopted. Each gate here binds on day one by carrying the debt that already existed, in a file that is only allowed to shrink.
A baseline lists the pre-existing violations, and the gate fails only on new ones. A floor is the inverse: an untagged component owes nothing, so coverage could decay to zero without a single violation unless a minimum count holds it. Growing a baseline, widening an exempt list, or lowering a floor is a review blocker.
Where the numbers moved
| Still carried | Now |
|---|---|
| Off-scale values (spacing, radius, type) | 24 in 14 files |
| Components without a story | 8 |
| Owed capability states | 24 in 15 files |
The loops
Gates stop the same mistake twice. Loops are what turn a mistake into a gate: an agent builds, a gate fails, the instance gets fixed, and the fix is encoded as a rule, a contract or a trap, so next time the gate catches it at write time.
One lap, as it happened
- In an earlier system, a sidebar shipped text-sidebar-foreground/50 for quiet text. The precedent is cited in the record.
- Axe measured the composite on one ground and it failed AA. Hover grounds were never measured at all.
- Replace the opacity with the flat rung that owns the job.
- TOK-8, 2026-08-23: no opacity modifier on a foreground, blocker, grep, with the fix and the precedent written into the record.
- The hook warns at write time, CI fails on the pattern, the Rules page shows it, unslop audits for it.
Every gate is made to fail before it is trusted
Before a gate is adopted, someone breaks the code on purpose: append a raw hex to a component, watch the exit code, restore. The mutation is kept as a fixture test, and the fixture suites run in CI next to the gates. A gate that matched zero files used to pass. Since 2026-08-18, zero matched files is a failure.
How reviews are run
- A round budget. Rounds one and two fix everything worth fixing. From round three only blockers get fixed in the PR, and everything else becomes a linked follow-up, because each fix invites a finding about the fix. Two blocker classes were written for the agent era: contract prose the change made false, and a weakened test or gate.
- Findings are hypotheses. A review of a stale branch produced three findings. Rechecked against main, one reproduced and was repaired the same day with 23 new tests, each watched failing first. One was already fixed. One was stale. Reproduce before repairing is now a written rule.
- Open decisions stay open. A new contrast failure is a design decision, so agents propose values with ratios and wait. Never darken tokens quietly to make CI green. The five failing pairs from August were settled in two reviewed PRs, not by a script.
What the loops caught
Three things that were green until a gate learned to look, beyond the motion trap, the dark audit and the harness above.
Red for four days
The accessibility job failed on five consecutive pushes while the fix sat on an unmerged branch, next to four gate scripts nobody had wired. The rescue took 22 failing stories to zero and adopted the gates the same day. The lesson became the one-checkout-per-task rule and a hygiene report for stranded branches.
Dead tokens
The first liveness run found 31 tokens consumed by nothing, including an entire elevation family that was declared, themed and mirrored in Figma. Widening the check to every root token found 42 more dimension and weight roles. All deleted. The baseline is now empty.
The phantom font
The mirror gate's first run found font-mono documented, named in Figma and loaded by Storybook, read by nothing: one missing theme line. A token can be in every artifact except the one that matters.
What is not done
The repo states its own gaps. So does this page.
- 30 of 100 components carry a usage contract. The schema refuses placeholders, so the other 70 are owed, not faked.
- 10 components have no story. Two are declared to have no visual surface. Eight are carried, and two of those, sidebar and navigation menu, are the most accessibility-sensitive in the kit.
- 24 capability states are still owed across 15 story files.
- The write-time hook still warns. It blocks nothing until the promotion criterion is met.
- No public release. @weeeha/ui and @weeeha/tokens are versioned 0.1.0 for a restricted GitHub Packages registry.
- Figma sync is a name contract, not a pipeline. Twenty drift records are still marked undecided.
Conformance ladder
Ten stages in a vendored checker. The honest number is the highest contiguous stage, so a built stage 09 does not raise it.
| 00 | Architecture map | met, gated |
| 01 | Token contract | met, gated |
| 02 | Rule catalogue | pending |
| 03 | Gate moments | pending |
| 04 | Component meta | pending |
| 05 | Derivation | pending |
| 06 | Story coverage | pending |
| 07 | Docs generation | pending |
| 08 | Design-tool sync | pending |
| 09 | Loops and conventions | probed, not adopted |
| 10 | Runtime identity and stream rendering | met, gated |
| 11 | Component sync | met, gated |
98 days
Extracted: the token architecture, the primitives and the AI Elements set, from the Background IP of an earlier engagement. Everything client-specific stayed behind.
16 guidance pages, with a review commit after nearly every page.
First conformance gate wired: axe on every story, CI, motion tokens.
The rescue: accessibility green, gates adopted with fixtures, consumer harness built the same day.
Dark made honest. The token check becomes rule records.
Hue ramps added to Layer 1, one accent pair. Liveness widened, 42 dead roles deleted.
Agent file split into eight rule bodies under a byte ceiling. Usage-contract schema lands.
Every token read or deleted. Review round budget and blocker taxonomy. Branch hygiene report.
Stage 10 met: runtime identity, and MessageParts, the kit's own renderer for streamed AI SDK message parts.
Stage 11 met: Figma components synced against the code contracts, with eight Figma rules.
What I would do differently
Wire the gates before the components, not a month after. The kit had 95 documented components on 08-10 and its first real gate on 08-11, and the week after that went to rescuing fixes stranded on branches. Gates on day one would have cost a day and saved a week.
One checkout per task from the start. Stranded work was the biggest loss: 31 unmerged branches and 20 live worktrees when I finally measured it on 08-17.
What surprised me
How often green was a lie. The harness that never reinstalled, the gates that matched zero files, the tokens documented in three places and read by none. Each of those passed every check that existed. The useful work turned out to be breaking each check on purpose before trusting it.
What I learned
The semantic layer is where accessibility gets paid for. About 130 dark-mode violations disappeared by changing roughly five values in Layer 2, and not one component.
A rule that lives only in prose does not exist. The next agent reads stale prose as fact, so the rule has to be a record with a detector, or it has to say out loud that it is unchecked.
See it
The repository is private. The method that moved between this kit, a public registry and a client rebuild is written up separately in the Agentic Design Systems case.