Our tokens were perfect. Every color came from the file, every radius matched, the type scale was clean.
And the app still looked wrong.
Not wrong in a way you could point at in a code review. Wrong in the way where three screens built by an agent in the same week feel like they came from three different products. Same colors, same fonts, completely different rhythm.
This is where it gets interesting, because the fix is not more tokens.
What tokens can and cannot do
A token answers "which value." Is this grey #8d8b86 or #6f6c65? Is this radius 10px or 14px?
Agents are excellent at that question once you give them the list. In my experience token compliance is close to solved by a decent DESIGN.md and a rule that says use only these values.
What tokens cannot answer is "how much." How much space between a section heading and the first row? How dense is a table by default? When does a card get a border versus a background shade?
Those are ratio and rhythm questions, and a color list says nothing about them.
Add a density contract
The single highest-value addition I have made to a design contract is a density section.
## Density
Default density is comfortable. Compact is opt-in for data tables only.
Vertical rhythm:
- Section to section: 64px
- Heading to first content: 24px
- Between related rows: 12px
- Inside a card, edge to content: 20px
Controls:
- Button height 40px, 32px in toolbars
- Input height 40px, label 6px above
- Table row height 44px comfortable, 36px compactBefore this section, an agent asked to "add a filter bar" would pick reasonable-looking padding, and reasonable meant different every time. After it, the filter bar matches the toolbar next to it.
Notice these are numbers, not adjectives. "Generous spacing" produced variance. 64px did not.
Add state tables
The second gap is states. A component looks right at rest and improvises everywhere else.
Write them as a table, because a table forces you to fill every cell:
## Button states
| State | Background | Text | Border |
| --- | --- | --- | --- |
| Default | primary | on-primary | none |
| Hover | primary-hover | on-primary | none |
| Active | primary-active | on-primary | none |
| Focus | primary | on-primary | 2px focus-ring, 2px offset |
| Disabled | primary-disabled | on-primary at 60% | none |
| Loading | primary | on-primary | spinner replaces label, width locked |The loading row is the one that keeps paying off. Agents almost never invent a loading state that keeps the button width stable, and a button that changes width mid-click looks broken in a way users notice immediately.
Add layout archetypes
Individual components were never really the problem. Page composition is.
Describe your two or three page shapes explicitly:
## Page archetypes
### List page
Header bar (title left, primary action right), filter row, table, pagination.
Max width 1240px. Table is full bleed inside the container.
### Detail page
Breadcrumb, title block with metadata line, then two columns at 1024px and up:
main content 2fr, sidebar 1fr, 32px gap. Single column below 1024px.
### Form page
Single column, max width 640px. Labels above inputs. Actions pinned to a
bottom bar with a top border, primary action right.This is the section that stopped the "three screens, three products" problem for us. An agent given an archetype builds the fourth list page like the first three. Without one, it composes from scratch every time.
Add the negative rules
Positive rules describe what to do. Negative rules are what actually prevent drift, because they close the doors the model would otherwise wander through.
## Never
- No drop shadows. Elevation is communicated with 1px borders and surface shade.
- No gradients except the one hero wash defined above.
- No icon-only buttons without an accessible label.
- No new font family. Two families exist, that is the set.
- No animation longer than 200ms.
- No centred body text.Short, absolute, checkable. I have found these get followed more reliably than any equivalent positive phrasing, probably because there is no judgement call involved.
Watch the budget
Here is the trade-off nobody mentions. Every section you add costs tokens on every single request.
A 40 KB design document loaded into every conversation is real spend, and worse, it competes with your actual code for the model's attention. Past a certain size, adding rules makes compliance worse rather than better.
What has worked for me:
- Keep the always-loaded contract tight. Tokens, density, states, archetypes, never-rules.
- Move the long prose, the rationale, and the history to a separate document that nobody loads automatically.
- Scope by path where the tool supports it. Cursor globs, Copilot
applyTo, KirofileMatch. The backend does not need your type scale.
If your design contract is longer than the component being built, the ratio is wrong.
The audit habit
Rules only hold if something checks them. Once a week, one pass:
Audit every component in src/components against DESIGN.md.
List each value that does not match a token or violates a never-rule.
Include file and line. Do not change anything yet.Read the list first. Some entries will be legitimate exceptions worth adding to the file, and some will be drift worth fixing. Approving a blind auto-fix is how you turn a small inconsistency into a large diff.
What this means for you
If your agent gets colors right and layouts wrong, you do not have a token problem. You have a rhythm problem, and rhythm needs its own section.
Start with density, because it is the cheapest to write and the most visible in the result. Add state tables next, then archetypes. Keep the whole thing shorter than you think it needs to be.
Then run the audit and see what you learn. In my experience the first audit is humbling and the third one is boring, which is exactly the direction you want.