Token Anatomy
The six disambiguated concepts that describe every BDS token — Anatomy, Tier, Library, Layer, Mode, Tenet — and the four-tier abstraction stack every token belongs to.
Every BDS token answers six independent questions. Without distinct vocabulary for each, agents and humans invent parallel taxonomies and the system drifts (portal #512 / #553, retired --theme-brik-*, retired --brand-{tier}). This page locks the vocabulary so the discipline lives in the words themselves.
Six concepts, six words
| Concept | Term | Question it answers | Values |
|---|---|---|---|
| Naming structure | Anatomy | How is it named? | --{purpose}-{role}[-{state}] |
| Abstraction level | Tier | What kind of token is it? | Raw · Primitive · Semantic · Component |
| Source of truth | Library | Where is it defined? | Foundations Library · [Client] Brand Kit Library |
| CSS cascade origin | Layer | Which @layer carries its value? | bds-tokens · bds-components · client-theme · client-overrides |
| Value axis | Mode | Which axis varies its value? | color light/dark · borderwidth thin/bold · spacing compact/spacious / etc. |
| System pillar | Tenet | Which system pillar governs it? | Foundation · Theming · Motion · Content |
These are orthogonal. A token has one answer for each. Mixing them — calling --brand-tertiary a "tier" because it sits at the top level — is the failure mode the cleanup retired.
Before referencing a token name, you should be able to answer all six. If you can't, you're guessing — verify against the canonical registry (dist/tokens.css) first.
Anatomy
Color tokens follow one of two parallel formulas — an intent form and a scoped-intent form. Everything that is not a color follows a third: see Non-color anatomy.
Intent: --{purpose}-{role}[-{modifier}][-{state}]
Scoped intent: --{purpose}-{scope}-{scope-value}[-{modifier}][-{state}]Each segment is fixed vocabulary:
| Segment | Allowed values | Example |
|---|---|---|
purpose | page, surface, background, text, border, color (primitives only) | --background-… |
role | Closed list — see Color foundations vocabulary | --background-brand-primary |
scope / scope-value | A named context that carries its own hue — service-{line}, presence-{state} | --background-service-marketing |
modifier (optional) | Context / tone / hierarchy — see Color modifiers | --background-service-brand-on-light |
state (optional) | hover, pressed, disabled | --background-brand-primary-hover |
Status, service-line, and presence semantic tokens use the scoped-intent form — the scope (service-marketing, presence-online) already names the hue, so they never also take a -brand/-primary role segment. Everything else uses the plain intent form.
Token names without a purpose prefix are drift. Examples retired by brik-bds#712: --brand-primary (missing purpose; legitimate form is --{purpose}-brand-primary), --theme-brik-* (entire namespace violates the anatomy). If you see something shaped --brand-X or --theme-X-Y in old code, it's drift and should not be referenced.
When to add a hierarchy modifier
Never preemptively. A role starts as a single-level token set — one token, no hierarchy modifier (--text-service-marketing, which ships with no -primary / -secondary sibling). Add a -primary / -secondary pair only when the design carries a genuine second emphasis level within that role, distinct from the generic --text-secondary / --text-muted that already exist for de-emphasis.
/* ✅ single-level — one emphasis, no hierarchy modifier (real token) */
--text-service-marketing
/* ❌ preemptive — no --text-service-marketing-secondary exists to pair it,
so the -primary suffix is just a longer name for the single-level token */
--text-service-marketing-primaryThe pair earns its place only when a second emphasis tier actually ships alongside the first; until then, --text-secondary / --text-muted already cover generic de-emphasis. Scoped tokens never stack a hierarchy modifier under a context modifier either — the (text, surface) pairing carries emphasis implicitly, so --text-service-brand-on-dark is complete without a -primary.
Non-color anatomy
Half the registry is not a color. 325 of the 701 token names in dist/tokens.css sit outside the color purpose list above — --font-size-100, --gap-md, --ease-spring. They are a second legitimate formula, not drift. The distinction the color form draws between purpose and role does not apply, because there is no hue to place: a length, a duration, or a weight is fully described by the property it feeds and the step on that property's scale.
Primitive: --{property}-{step} --font-size-100, --border-radius-400
Semantic: --{role}-{step} --gap-md, --heading-xl, --ease-springThe first slot is a CSS property name at the Primitive tier and a semantic role at the Semantic tier. Both may be hyphenated (font-line-height, content-width) — the slot is the whole property or role name, not the first segment.
Primitive tier property scales
| Slot | Step vocabulary | Feeds |
|---|---|---|
--font-size-* | numeric (100 = 16px) | font-size |
--font-weight-* | word (semibold) | font-weight |
--font-family-* | role (body, heading) | font-family |
--font-line-height-* | word (normal, snug) | line-height |
--font-casing-* | word (uppercase) | text-transform |
--letter-spacing-* | word (wide) | letter-spacing |
--space-* | numeric (400 = 16px) | any spacing property |
--size-* | numeric + pill / circle | width / height |
--border-radius-* | numeric + t-shirt | border-radius |
--border-width-* | numeric + t-shirt | border-width |
--shadow-blur-* · --shadow-offset-* · --shadow-spread-* | numeric | the parts of a box-shadow |
--blur-radius-* | t-shirt | filter: blur() |
--duration-* · --delay-* · --iteration-* | numeric | transition / animation |
--breakpoint-* | word (desktop) | media queries |
--aspect-* | ratio (16-9) | aspect-ratio |
Semantic tier roles
| Slot | Step vocabulary | What it names |
|---|---|---|
--gap-* · --padding-* · --gutter-* | t-shirt | spacing by intent, not by pixel step |
--heading-* · --display-* · --body-* · --label-* · --subtitle-* | t-shirt | the type ramp per text role |
--icon-* | t-shirt | icon sizes, aligned to the type ramp |
--shadow-* · --box-shadow-* | t-shirt | elevation |
--ease-* | word (spring) | easing curves |
--content-width-* · --measure-* | t-shirt | layout and line-length ceilings |
--state-* | state (hover, disabled) | interaction overlays and opacities |
The Component tier is unchanged — --bds-{component}-{property}, per ADR-014.
Two first segments carry both formulas. --border-* is 20 color tokens and 38 length tokens (--border-radius-*, --border-width-*); --shadow-* is elevation and the primitive parts (--shadow-blur-*, --shadow-offset-*, --shadow-spread-*). Match on the longest slot, never the first segment — --border-radius-100 is a length, not a color.
Named exceptions
Two kinds of thing live outside the tables above. Both are in the registry today; neither should be referenced in new work.
Renames owed — tracked on #1910, sequenced behind the naming ADR.
| Shipping name | Why it's drift | Use instead |
|---|---|---|
--tooltip-background, --tooltip-text | Component tier without the mandatory --bds- prefix — the retired --{component}-{prop} pattern. | --bds-tooltip-* |
--easing-ease-in, --easing-ease-out, --easing-ease-in-out | Style Dictionary's primitive export. A second name for one concept, at different curve values, and the easing-ease- stutter is the tell. Zero references in components/; --ease-* carries all 70. | --ease-in / --ease-out / --ease-in-out |
Slotless by exception — --web, --tablet, --mobile (tokens/figma-tokens.css:319-321) carry no purpose slot and are a deliberate carve-out, not a rename target: they are unitless Figma primitives, and the --breakpoint-* family that would receive a rename has no consumers either. Measured on 2026-08-20, both families are unconsumed — zero var() references to --web/--tablet/--mobile or --breakpoint-* anywhere in brik-bds, brik-client-portal, or brikdesigns. Whether BDS should ship breakpoint tokens at all is an open question for the naming ADR; until it answers, renaming one dead family into another buys nothing. Mirrored in EXCEPTIONS at scripts/__tests__/inspect-widget-tokens.test.mjs.
Step vocabularies are not settled here. Nine slots draw from more than one — --border-width-* carries numeric (100), t-shirt (md), and thin/standard/bold; --duration-* carries both 200 and fast. A second kind of split the census does not flag, because both halves are t-shirt: --icon-* spells the same step tiny in one place and 2xs in another. Which scale is canonical per family is #1910 axis 5, decided in the naming ADR. Run npm run lint-token-purpose-slots -- --census for the current measurement.
This section is gated: npm run lint-token-purpose-slots fails when a slot ships in dist/tokens.css that is neither named above nor dispositioned as drift. That is the only thing keeping the tables from going stale — a color-only formula describing half a system is exactly how #1910 started.
Color modifiers
Within the color role/modifier vocabulary, four concepts share light/dark wording but answer different questions. Pick the wrong one and it compiles — silently wrong.
on-color
The legible foreground for content resting on a colored, branded, or dark surface. Grayscale, brand-agnostic, consumed by component variants (Button on-color, the Tab-bar onColor mode).
--text-on-color-light /* dark FG — for a light colored fill */
--text-on-color-dark /* white FG — for a dark colored fill */on-color is the whole role; light/dark names the fill tone it targets. It never carries a hue.
-on-light and -on-dark
For a token whose hue is already named (a service line, a brand scope), pins the value for a known, fixed backdrop tone.
--background-service-brand-on-light /* brand fill, on a light surface */
--text-service-brand-on-dark /* brand text, on a dark surface */The -color- infix is dropped because the scope (service-brand) already implies color. The leading on- marks context — distinct from tone.
Two on forms, on purpose. on-color-* is a complete neutral role; -on-light/-on-dark is a context modifier on a colored scope. Same word, different slot in the formula — not an inconsistency, and not a rename candidate.
-inverse
The hard reverse across light/dark mode (lightest ↔ darkest), mirroring the reverse of a -primary token. A theme-level concept — --background-inverse, --text-inverse. A service line normally adapts to a known backdrop through its -on-light/-on-dark context tokens, not a per-line inverse — with one carve-out: --surface-service-{line}-inverse (surface purpose only) flips white in light → {hue}-darkest in dark, for service-identified cards that must read as neutral chrome in light mode but carry the line's deep tint in dark. See ADR-020. No per-line inverse exists on background/text/border.
-light and -dark
The pastel or deep shade of a surface (--surface-service-brand-light = pastel). Tone shifts the shade; context targets the backdrop. Tone is why scoped context had to be spelled -on-light/-on-dark — -light/-dark was already taken.
| You want… | Token |
|---|---|
| Legible text/icon on any colored or dark fill | --{purpose}-on-color-{light|dark} |
| A service hue tuned for a known backdrop | --{purpose}-service-{line}-on-{light|dark} |
| An element that hard-flips with the theme | --{purpose}-inverse |
| A pastel/deep variant of a service surface | --surface-service-{line}-{light|dark} |
| A service card surface: neutral white in light, deep tint in dark | --surface-service-{line}-inverse |
See Color for the full registry.
Tier
Every token sits at exactly one Tier. Higher tiers reference lower tiers via var(). Components only consume the upper tiers.
┌─────────────────────────────────────────────────────────┐
│ Tier 4 — Component (component-scoped knobs) │
│ --bds-{component}-{property}: var(<Semantic>) │
│ Example: --bds-toast-shadow: var(--shadow-md) │
└─────────────────────────────────────────────────────────┘
▲
│ var()
┌─────────────────────────────────────────────────────────┐
│ Tier 3 — Semantic (what components consume) │
│ --{purpose}-{role}: var(--color-{family}-{step}) │
│ Example: --background-brand-primary: │
│ var(--color-poppy-500) │
└─────────────────────────────────────────────────────────┘
▲
│ var()
┌─────────────────────────────────────────────────────────┐
│ Tier 2 — Primitive (named raw values) │
│ --color-{family}-{step}: #hex │
│ Example: --color-poppy-500: #e35335 │
└─────────────────────────────────────────────────────────┘
▲
│
┌─────────────────────────────────────────────────────────┐
│ Tier 1 — Raw (literal hex / numeric value) │
│ Not a CSS variable — the literal value before naming. │
│ Example: #e35335 │
└─────────────────────────────────────────────────────────┘What lives at each Tier
| Tier | Naming | Source | Components reference it? |
|---|---|---|---|
| Raw | Literal #e35335 | Figma swatch / hex chosen by design | No — never in component CSS |
| Primitive | --color-{family}-{step} (color), --font-size-{step} (type), --space-{step} (spacing), etc. | Auto-generated from Figma Variables → Style Dictionary | No — never in component CSS (use Semantic) |
| Semantic | --{purpose}-{role} — Color foundations vocabulary | Auto-generated; client themes override values with same names | Yes — this is the contract |
| Component | --bds-{component}-{property} | Authored in component / blueprint CSS; runtime values set by the component's JS/TSX | Yes — but only as a knob: it must resolve to a Semantic token (see below) |
Components consume Semantic tokens, never Primitives directly. A button's CSS references --background-brand-primary, not --color-poppy-500. Why: Semantic is the layer that varies per brand. Bypassing it locks the component to one brand.
About Tier 4
The Component tier is --bds-{component}-{property} — a component- or blueprint-scoped custom property, where {component} is the component/blueprint identifier and {property} is the styled aspect. Examples: --bds-toast-shadow, --bds-slider-thumb-shadow, --bds-hero-img-card-bg. This is the only sanctioned Component-tier namespace (ADR-014).
A Tier 4 token serves one of two roles, both under the same name:
-
Override knob — a hook a component reads so a consumer can re-skin one instance without forking the CSS. It must resolve to a Semantic token, never a raw value:
/* ✅ knob resolves to a Semantic token */ box-shadow: var(--bds-toast-shadow, var(--shadow-md)); /* ❌ raw literal in the fallback — reintroduces an off-token value that ships silently if the token fails to resolve, and lint can't see it */ box-shadow: var(--bds-toast-shadow, 0 4px 12px rgba(0, 0, 0, 0.08)); -
Runtime binding — a value the component's JS/TSX sets at render (e.g.
--bds-slider-percent), read by the CSS to position or size an element.
Tier 4 never terminates at a raw value. A knob's fallback is a Semantic token; a runtime binding is computed from one. A literal inside a var() fallback on a tokenized property is Tier-1 leakage and is flagged by scripts/lint-tokens.js. The --bds- prefix is mandatory — --bp-* and bare --{component}-* are retired (see Drift patterns below).
How the six concepts map to a real token
Take --background-brand-primary (the hot path):
| Concept | Answer |
|---|---|
| Anatomy | --{purpose: background}-{role: brand-primary} |
| Tier | Semantic |
| Library | Defined in Foundations Library as the canonical name; the value is overridden by each Brand Kit Library |
| Layer | bds-tokens for the canonical default; client-theme for the per-client override |
| Mode | Varies by color mode (light / dark) |
| Tenet | Foundation (token canon) + Theming (brand override) |
Every legitimate token answers all six. If a token doesn't fit any one of them, it's drift.
Drift patterns to avoid
| Drift pattern | Why it's wrong | Retired in |
|---|---|---|
--theme-{name}-* (--theme-brik-green, --theme-blue-blue-light) | Doesn't match Anatomy — no purpose prefix. Duplicate alias of a --color-{family}-{step} primitive. | brik-bds#712 / #727 |
--brand-{tier} (--brand-primary, --brand-tertiary) | Brand-tier names without purpose prefix. Legitimate composed form is --{purpose}-brand-{tier}. | brik-bds#712 / #727 |
--surface-paper, --text-on-ink, --surface-soft, --surface-warm | Invented modifiers or invented role values that aren't in the closed Color foundations vocabulary. Always parallel taxonomy. | portal #512 / #553 (rolled back) |
Component CSS reading --color-{family}-{step} directly | Bypasses the Semantic tier. Locks the component to one brand. | Caught by scripts/lint-tokens.js |
--bp-{blueprint}-{slot}-{prop} (--bp-hero-img-card-bg) | Parallel Component-tier namespace that shipped ahead of canon. Invisible to lint because it's an undefined reference, not a real token. | #1043 / ADR-014 → migrate to --bds-* |
--{component}-{prop} (--toast-shadow, --select-chevron-color) | Component knob without the --bds- prefix. Collides with the global namespace and isn't lint-recognized. | #1043 / ADR-014 → migrate to --bds-* |
Raw literal inside a var() fallback on a tokenized property (var(--shadow-md, 0 4px 12px …)) | Reintroduces a Tier-1 value that ships silently if the token fails to resolve; lint sees only the canonical name and passes. | Caught by scripts/lint-tokens.js (fallback-literal rule, #1043) |
Inventing parallel taxonomy is the failure mode this vocabulary exists to prevent. Three rollbacks (portal #512, portal #553, brik-bds#712 cleanup) trace to agents inventing names that almost fit. When in doubt, verify in dist/tokens.css — if the name isn't there, it isn't real.
Verifying a token before referencing it
# Is this name in the canonical registry?
rg '\-\-{token-name}' node_modules/@brikdesigns/bds/dist/tokens.css
# Or run the consumer-side gate
./scripts/token-audit.sh --canonical-onlyIf the gate is missing or the name isn't found, stop. Don't ship a var() call against a name that doesn't exist.
Related
- The Cascade — Layer ordering, modes, color foundations vocabulary
- Theming — how a client brand overrides these Semantic tokens per the four Theming Dimensions; the "How Content feeds Theming" section separates theming layers from content vocabularies
- Composition Layers — the pedagogical Section → Layout → Container → Block or Control → Component model that composes tokenized primitives
- Figma Library architecture — Foundations Library vs Brand Kit Library
- Color — full canonical color registry
- Color Pairings — the accessible foreground/background pairing system the
on-colorand-on-light/-on-darkmodifiers feed into, with the contrast matrix and CI gate - Color primitive tiers — the closed
tiervocabulary used by the portal theme generator - Slot Vocabulary — BEM slot vocabulary (different domain — component class names, not token names)
- Style Dictionary build pipeline — how Figma → Library → Layer → CSS happens