Five Vocabulary Words and a Resolution Algorithm
The token schema is small on purpose. Five groups, one reference syntax, two depth limits, and a cycle detector. That is the entire machine. The boundedness is not a constraint of the parser — it is a constraint of the format's design philosophy.
Key Takeaways
- The token schema has exactly five top-level groups:
colors,typography,rounded,spacing,components. Each is a small, fixed map. - Token references use one syntax —
{path.to.token}— and the resolver walks a graph with two depth limits (max_reference_depth: 10,max_token_nesting_depth: 20). - References are mostly restricted to primitive values. Composite references (e.g., a whole typography preset) are allowed only inside
components. - The linter treats broken references as errors, not warnings, because a broken reference is a file that the agent cannot faithfully render.
The Reference That Started This Chapter
I was reading examples/paws-and-paths/DESIGN.md when a single line stopped me.
button-primary-hover:
backgroundColor: "{colors.primary-container}"
textColor: "{colors.on-primary-container}"
button-primary-hover is a component. Its backgroundColor is set to the string "{colors.primary-container}". The braces are the entire escape syntax. The linter, when it sees this string, treats it as a token reference, walks the YAML tree, finds colors.primary-container, substitutes the value, and uses the substituted value to run the WCAG contrast check against the resolved textColor on the same component.
That is the entire resolution algorithm. I had assumed the format would be more elaborate than this — schemas usually are. I was wrong. The walk is short, the limits are explicit, and the cycle detection is in the same file. Let me show you what the walk actually does, because once you see it, the smallness of the schema stops feeling like an accident.
The Five Groups, In One View
The schema is defined in linter/spec-config.yaml and rendered into docs/spec.md. Five top-level groups, no more. The recommended-but-not-required names in the spec are exactly what the linter suggests, but the format accepts arbitrary keys.
graph TB
R[DESIGN.md root]
R --> C[colors]
R --> T[typography]
R --> RO[rounded]
R --> SP[spacing]
R --> CO[components]
C --> C1[primary / secondary /<br/>tertiary / neutral /<br/>on-surface / error / ...]
T --> T1[headline-* / body-* /<br/>label-* / display / ...]
RO --> R1[sm / md / lg / xl / full / ...]
SP --> S1[unit / base / xs / sm /<br/>md / lg / xl / gutter / margin / ...]
CO --> CO1[button-primary /<br/>button-primary-hover /<br/>card-profile / ...]
style R fill:#1A1C1E,color:#F7F5F2
style C fill:#2d3449,color:#dae2fd
style T fill:#2d3449,color:#dae2fd
style RO fill:#2d3449,color:#dae2fd
style SP fill:#2d3449,color:#dae2fd
style CO fill:#2d3449,color:#dae2fd
The graph has a fixed shape. Every DESIGN.md file is a tree of this shape (with arbitrary keys at the leaves). Anything that does not fit this shape is either accepted with a warning or rejected with an error, and the rules for that decision are in the linter — not in the schema. The schema is small; the *behavior* of the linter on the schema is where the format's design judgment lives.
The Reference Syntax, In Two Lines
A token reference is a string that begins with {, ends with }, and contains a dotted path. The path is a key-walk through the YAML tree:
{colors.primary} → #1A1C1E
{typography.body-md.fontSize} → 16px
{spacing.md} → 16px
{components.button-primary.backgroundColor} → (a reference, walk continues)
The walk is recursive. If a reference points to another reference, the linter follows. The limits are in linter/spec-config.yaml:
limits:
max_token_nesting_depth: 20
max_reference_depth: 10
Two limits, doing two different jobs. max_token_nesting_depth: 20 is how deep a single reference can reach into the tree (so {a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t} is the deepest legal reference). max_reference_depth: 10 is how many chained references the resolver will follow (so a reference that points to a reference that points to a reference, ten times, is the deepest legal chain).
The cycle detection sits on top of these limits. A reference that points to itself, directly or transitively, is a cycle. The resolver catches cycles, emits an error, and stops. There is no infinite-loop guard, but the depth limits make a long-but-finite loop impossible, and the cycle detector makes a short loop impossible. The two together are the safety story.
What The Resolver Does With The Result
The resolver's output is a DesignSystemState — an in-memory map of every group, with every reference resolved to a primitive value. The linter then walks the resolved state to check nine rules. The contrast rule is the one that uses the resolved state directly:
// packages/cli/src/linter/linter/rules/contrast-ratio.ts
for (const [compName, comp] of state.components) {
const bgValue = comp.properties.get('backgroundColor');
const textValue = comp.properties.get('textColor');
if (!bgValue || !textValue) continue;
const bgColor = resolveToColor(bgValue);
const textColor = resolveToColor(textValue);
if (!bgColor || !textColor) continue;
const ratio = contrastRatio(bgColor, textColor);
if (ratio < WCAG_AA_MINIMUM) {
findings.push({...});
}
}
This is a real WCAG 2.1 contrast ratio calculation, run on the *resolved* color. The resolver has already substituted {colors.primary-container} for #F59E0B; the contrast rule compares the resulting hex pair against the 4.5:1 AA minimum. The point of the reference syntax is not aesthetics. The point is that the linter can resolve a string, do math on the result, and report a finding that points back to a *path in the source file*. A finding that says "textColor (#FFFFFF) on backgroundColor (#F59E0B) has contrast ratio 4.42:1, below WCAG AA minimum of 4.5:1" is meaningful. A finding that says "textColor is white and backgroundColor is the primary container" is not.
The reference syntax is the bridge between the human-readable design and the machine-checkable design. The bridge is the schema.
The Restriction Most People Miss
There is one restriction on the reference syntax that the spec calls out explicitly and that the README does not surface:
"Token references... must point to a primitive value (e.g.,colors.primary-60), not a group (e.g.,colors). Within thecomponentssection, references to composite values (e.g.,{typography.label-md}) are permitted."
This is not a parser limitation. It is a design choice. The resolver is allowed to substitute a whole typography object into a component's typography: field, because that is a common pattern — button-primary: { typography: "{typography.label-md}" } — and the resolver would be doing more work, not less, if it had to flatten the object. But the resolver is *not* allowed to substitute colors (the whole group) into a field, because the result is meaningless: there is no field whose value is "all the colors."
This is the format telling you, in the form of a parser rule, that tokens are leaves and groups are not. The five groups are categories. The things inside them are values. References walk from leaves to leaves, and they do not pass through categories on the way.
I started this chapter expecting the token schema to be the heavy part of the spec. It is not. The five groups, the reference syntax, and the depth limits are the smallest interesting thing the format could have shipped. The reason they are small is the topic of the next chapter.
Where This Will Hurt You, In Three Specific Places
You are going to ship a DESIGN.md. You are going to hit these three failure modes, in this order, and the resolver is going to be the thing that tells you.
1. The chained reference that does not resolve. You write {colors.surface.dim} somewhere, because you wanted the dimmer surface. The resolver walks, finds no dim under surface, and emits an error. The linter's broken-ref rule, severity error. Build fails. The fix is to look at the source YAML and add the missing key, or fix the typo. The error path that the linter gives you points at components.{compName}.{propName}, so you can find it.
2. The cycle you did not notice. You write colors.surface.dim: "{colors.surface.dim}" somewhere, because you were copying a pattern. The resolver walks, finds the self-reference, and emits an error. The linter's broken-ref rule again, same severity. The fix is to delete the cycle. The depth limits are not the safety net here; the cycle detector is.
3. The contrast ratio that drops when you change a token. You write button-primary-hover: { backgroundColor: "{colors.primary-container}" } and the linter passes. You change colors.primary-container from #F59E0B to #FBBF24 because you wanted a brighter hover, and now the linter emits a contrast-ratio warning. The text color, which used to have 4.6:1 against the container, now has 4.3:1. The fix is to either change the text color, change the container back, or accept the warning and document why. The point is: the linter catches this. The linter catches it because the resolver resolves the reference before the contrast check runs. The reference syntax is what makes the check possible.
You can prevent all three of these by reading the resolved output of the linter before you trust a DESIGN.md. The linter is not a nice-to-have. It is the only tool that knows what the references actually mean.
What Comes Next
We have a small schema. We have a small resolver. We have a small linter that runs nine rules on the resolved state. The schema is the smallest interesting thing.
The question the next chapter answers is: if the schema is so small, where does the actual specification of a design system live? The answer is going to be that it lives in the prose — and that the format's most important idea is that the prose is not decoration on top of the tokens, but the other way around.
---
References:
- docs/spec.md — token schema and reference syntax
- linter/spec-config.yaml — depth limits and recommended names
- packages/cli/src/linter/linter/rules/broken-ref.ts — the rule that catches broken references and cycles
- packages/cli/src/linter/linter/rules/contrast-ratio.ts — the rule that uses the resolved state for WCAG math
---
A small schema forces the prose to do the heavy lifting. The next chapter is about why that is a feature, not a bug.