Documentdocs/tokens/README.md
SurveyStream — design tokens
Four artefacts, one source. Generated from color-system/tokens.json and
typography/type-tokens.json, so they cannot drift from the specs they encode.
| File | Format | Consume with |
|---|---|---|
design-tokens.json | W3C DTCG | Style Dictionary 4+, Tokens Studio, Figma Variables |
theme.css | CSS custom properties | Any stack. Import once, before your framework’s base layer. |
tailwind.config.js | Tailwind v3 | content + theme.extend |
tailwind-v4.css | Tailwind v4 @theme | @import after theme.css |
202 tokens · 119 aliases, all resolving · 46 primitives · 27 semantic roles per theme.
Quick start
/* 1. tokens first — they define the variables everything else reads */
@import "./tokens/theme.css";
/* 2a. Tailwind v4 */
@import "tailwindcss";
@import "./tokens/tailwind-v4.css";
// 2b. Tailwind v3
module.exports = require('./tokens/tailwind.config.js');
Then write ordinary utilities — they follow the theme with no dark: prefix, because the
semantic variables are re-declared per theme rather than duplicated per utility:
<div class="bg-canvas text-primary rounded-lg shadow-md p-6">
<h1 class="font-display text-h1">Turn feedback into value</h1>
<p class="text-body text-muted">Body copy on a 4px grid.</p>
<span class="bg-fill-payout text-on-accent border-hairline border-fill-payout-edge rounded-full px-3">
Payout cleared
</span>
</div>
Architecture
Three layers. Components may only reference layer 3.
primitive → semantic (per theme) → utility / component
color.deepwater.500 → theme.light.color.action.interactive → bg-interactive
color.stream.500 → theme.dark.color.action.interactive → bg-interactive
Every semantic token is an alias, never a literal — the generator throws if a semantic value
has no primitive to point at. That rule is why color.slate.* exists: the dark UI’s structural
values (dividers, disabled text, the sunken surface) had been authored straight into the semantic
layer. A semantic token that cannot resolve to a primitive is not a token, it is a hard-coded
value wearing a name.
// theme.light.color.text.primary → {color.deepwater.950} #04171F
// theme.dark.color.text.primary → {color.slate.100} #EDF3F4
Theming
Dark mode is applied by any of three mechanisms, and theme.css handles all three because a
real product needs all three:
| Mechanism | Selector | Use |
|---|---|---|
| OS preference | @media (prefers-color-scheme: dark) | The default for a visitor who has never chosen |
| Explicit choice | [data-theme="dark"] / [data-theme="light"] | An in-product toggle |
| Framework class | .dark / .light | Tailwind’s darkMode: 'class' |
The media query is guarded — :root:not([data-theme="light"]):not(.light) — so an explicit light
choice still wins over a dark OS. Never declare a colour only inside a media or [data-theme]
block: the un-stamped state is the one most visitors are in, and a colour defined only in the
stamped state simply will not apply to them.
tailwind.config.js sets darkMode: ['class', '[data-theme="dark"]'] so the dark: variant also
honours the attribute, for the cases where you do want to branch explicitly.
Naming: why the CSS variable and the utility differ
CSS variables keep their property prefix because that is what reads correctly in raw CSS:
background: var(--color-bg-canvas);
color: var(--color-text-primary);
Tailwind must not repeat the prefix, or you end up typing text-text-primary. In v3 the fix is
per-property theme keys (backgroundColor, textColor, borderColor); in v4, which has a single
--color-* namespace, the prefix is dropped in the @theme mapping. Either way the utility reads
as bg-canvas and text-primary.
Fills and feedback keep their prefix in both — success alone is ambiguous between the text colour
and the solid fill, and those are genuinely different values.
Token sets
Colour
46 primitives across deepwater, stream, pulse, payout, neutral, slate, success,
warn, error, base. 27 semantic roles per theme, grouped bg · text · border · action ·
feedback · fill.
Primitive utilities do not follow the theme. bg-stream-500 is a literal. Reach for those only
when a colour must be pinned regardless of mode — a specimen plate, a fixed piece of artwork.
Typography
Ten steps on a 1.200 minor third from a 16px base, eight on-scale and two flagged off-scale.
Emitted three ways: individual fontSize / lineHeight / letterSpacing tokens, DTCG composite
typography tokens, and Tailwind fontSize tuples so text-h1 carries its leading and tracking
automatically.
Spacing
A 4px grid — the same grid the type system snaps line-heights to, which is why the two sit together without half-pixel drift. The built pages had accumulated 6 / 7 / 9 / 18 / 26 / 30px values; those are rationalised here rather than enshrined.
Radii
Rationalised from what shipped (4 / 9 / 12 / 14 / 18 / 999) into
xs sm md lg xl 2xl 3xl full.
Elevation
Five steps, different values per theme. This is deliberate: in light mode lift is carried by shadow, but in dark mode shadow is nearly invisible against a dark ground, so the dark set is heavier and the surfaces themselves step up in lightness. A single shadow scale would make dark mode look flat.
Motion
The most lightly-evidenced set here, and worth saying so. 180ms is the only UI transition the
system actually shipped; the rest of the scale is derived from it, and 60s is the pattern drift
cycle. Five easing curves and five composite transition tokens are provided, but there is no
motion system yet — no choreography, no documented entrance/exit pairing per component. Treat
these as sound defaults, not as a specification.
theme.css ships a prefers-reduced-motion block that collapses all animation and transition
durations to 1ms.
Spec compliance notes
- Dimensions are strings (
"16px"), not the{ value, unit }object form in the current DTCG editor’s draft. Strings are what Style Dictionary, Tokens Studio and Figma Variables actually consume today. If your pipeline wants the object form, one transform over$type: "dimension"converts the whole file. $typeis present on every token — 202 of 202.- Composite types used:
typography,shadow,transition,cubicBezier,fontFamily,fontWeight,duration,dimension,color. - DTCG has no standardised theming mechanism yet. Themes here are a
theme.light/theme.darkgroup whose leaves are aliases into the primitives — portable, and trivially splittable into per-theme files if your tooling prefers that.
Verification
Regenerating runs these checks and fails loudly:
| Check | Result |
|---|---|
Every {alias} resolves | 119 / 119 |
Every token has $type | 202 / 202 |
| Light and dark semantic sets are identical in shape | ✓ |
| Every semantic value resolves to a primitive | ✓ (throws otherwise) |
slate ramp is monotonically darkening | ✓ |
CSS braces balanced, no undefined var() | ✓ |
| Tailwind v3 config compiles and emits every utility | ✓ |
The last one is a real compile, not a lint: the config is run through Tailwind against a fixture
using bg-canvas, text-h1, shadow-md, border-fill-payout-edge, duration-base,
ease-standard and the rest, and each utility is confirmed in the output.
Relationship to the other files
color-system/tokens.json and typography/type-tokens.json remain the domain sources — they
carry the contrast validation and the font evidence that justify the values. This folder is the
consolidated export that adds spacing, radii, elevation and motion, and is what application
code should consume. Change a value in the domain source, regenerate, and everything downstream
follows.
BRAND-GUIDELINES.md is generated from the same domain sources, so the document and the tokens
cannot disagree.