Token System — three-tier colour architecture
Token System — three-tier colour architecture
Section titled “Token System — three-tier colour architecture”Updated: 21/06/2026 (Task ID-119)
This page documents the colour-token architecture of the Warm Meridian design system — the layering that lets a reskin or rebrand happen by editing a small number of definitions rather than hundreds of hand-typed colour literals. It complements the Warm Meridian implementation spec, which catalogues the individual token values; this page describes the mechanics: how the tiers compose, where each kind of token lives, how it is consumed, and how to add or change a colour safely.
The architecture below was established by Task ID-119 (design-system config-readiness). Two follow-ups are deliberately out of scope and tracked separately:
- The full
bg-*/text-*utility-class rename (Option B, ~87 component files) is deferred to backlogbl-349, bundled with the rebrand visual pass. - Brand direction (A/B palette choice, typography identity, logo) is a design
decision deferred to
/design-sync— this page covers only the token plumbing.
The three tiers
Section titled “The three tiers”Colour flows through three layers. Each layer references the one above it, so a change at the top ripples downward without touching consumers.
Tier 1 PRIMITIVES functional ramps --<family>-<role> (e.g. --green-solid) (raw colour) categorical hues --hue-<category> (e.g. --hue-entity-person) │ ▼ referenced by Tier 2 SEMANTIC / DOMAIN bare status/domain defs (e.g. --bid-won, --status-success) (meaning) --color-* survives ONLY as @theme inline LHS (names the utility) │ ▼ consumed by Tier 3 CONSUMPTION Tailwind utility bg-bid-won / text-entity-person (call sites) inline var() bg-[var(--status-success)] dynamic var() var(--domain-${key}-bg)Dark mode is implemented by swapping the primitives (Tier 1), not by redefining the hundreds of Tier-2 values. See Dark mode.
Tier 1 — primitives (raw colour)
Section titled “Tier 1 — primitives (raw colour)”Primitives are the only place a raw colour value is written. There are two kinds.
Functional ramps — --<family>-<role>
Section titled “Functional ramps — --<family>-<role>”A small palette of status colour families, each with a few role-steps. Defined in
app/globals.css (:root for light, .dark for dark). The families and roles:
| Family | Roles available |
|---|---|
green | solid, solid-deep, border, tint |
amber | solid, border, tint, tint-strong |
red | solid, solid-deep, border, tint |
blue | solid, border, tint |
violet | solid, border, tint |
teal | solid, border, tint |
Example (light + dark):
:root { --green-solid: #008856; --green-solid-deep: #007948;}.dark { --green-solid: #44b782; --green-solid-deep: #31a773;}Tune one ramp step once and every status token that references it ripples in both modes.
Categorical hues — --hue-<category>
Section titled “Categorical hues — --hue-<category>”Entity-type, content-domain and relevance badges share a single hue wheel. Each category contributes one hue number (degrees), mode-invariant, defined once:
:root { --hue-entity-person: 350; --hue-domain-security: 220; --hue-relevance-high: 160;}A categorical token then derives its colour from the hue with a fixed
lightness/chroma formula, so light and dark differ only in L:
--entity-person-bg: oklch(0.93 0.04 var(--hue-entity-person)); /* light *//* .dark: */--entity-person-bg: oklch(0.25 0.06 var(--hue-entity-person)); /* dark */To re-hue an entire category (every bg/text/surface it owns), change its one
--hue-<category> number.
Tier 2 — semantic / domain tokens (meaning)
Section titled “Tier 2 — semantic / domain tokens (meaning)”Tier-2 tokens give a primitive a meaning. They are bare-named definitions
(no --color- prefix) that reference a Tier-1 primitive:
--bid-won: var(--green-solid-deep);--status-success: var(--green-solid);--freshness-fresh: var(--green-solid);Two stylesheets hold Tier-2 tokens, and the split is the point of the core ⊅ domain boundary:
| Stylesheet | Holds |
|---|---|
app/globals.css (core) | The reusable design system: shadcn semantic tokens, the ramps, chart/radius/type/a11y tokens, and core-status tokens. |
app/styles/domain-tokens.css | The application-domain tokens: bid, governance, entity, domain, relevance, template, phase, … — everything KH-app-specific. |
domain-tokens.css is pulled in by app/globals.css via @import placed after
@import 'tailwindcss', so Tailwind v4 concatenates its @theme inline block into the
same cascade. Import order is load-bearing — placing the domain import before
@import 'tailwindcss' silently drops every domain utility from the generated CSS.
What --color-* means now
Section titled “What --color-* means now”The --color- prefix no longer appears on token definitions. It survives in exactly
one place: as the left-hand side of a @theme inline registration, which is how
Tailwind v4 is told to generate a utility class. The LHS names the utility; the RHS is
the bare token:
@theme inline { --color-bid-won: var(--bid-won); /* → generates bg-bid-won / text-bid-won / border-bid-won */ --color-status-success: var(--status-success);}So --color-bid-won is not a colour you reference — it is a utility-name declaration.
Inline consumers always reference the bare token (var(--bid-won)), never
var(--color-bid-won).
--domain-*content-classification tokens (--domain-security-bg,--domain-security-text, …) keep their original names — they are consumed via dynamic inlinevar(--domain-${key}-bg|text|surface)and were never--color--prefixed.
Tier 3 — consumption (call sites)
Section titled “Tier 3 — consumption (call sites)”There are three ways a component reaches a colour. Prefer them in this order:
-
Tailwind utility class — the default.
@theme inlinegenerated it from the Tier-2 token:<span className="bg-bid-won text-entity-person" /> -
Inline
var()against the bare token — when a utility does not exist or a style is computed:<div className="bg-[var(--status-success)]" /> -
Dynamic
var()— for content-classification colours keyed at runtime:<div style={{ background: `var(--domain-${domainKey}-bg)` }} />
Never write a raw Tailwind colour (bg-green-600) or a raw hex in a component — always go
through a token. (Enforced by the design-system check under .claude/checks/.)
Dark mode
Section titled “Dark mode”Dark mode is a .dark class toggle, and it is implemented by swapping the primitives,
not redefining the semantic values:
- Ramps — each
--<family>-<role>has a.darkoverride. Because every status token references a ramp, the status tokens themselves need no.darkoverride; they inherit the swapped primitive. (Task ID-119 collapsed ~83 redundant per-token.darkoverrides this way.) - Categoricals — the
--hue-<category>number is mode-invariant; only theoklch(L C …)lightness changes between the light and dark formula, so a single dark formula per token covers the category.
The result: adding or retuning a colour is a one- or two-line edit in the primitive layer that ripples correctly through both modes.
How to…
Section titled “How to…”Add a new status colour
Section titled “Add a new status colour”- If an existing ramp family fits, add the Tier-2 token in the right stylesheet
(
globals.cssfor core,domain-tokens.cssfor app-domain), referencing the ramp step:--my-status: var(--amber-solid); - If you need a new ramp step, add it to the family in
:rootand.darkinapp/globals.css. - Register the utility in that file’s
@theme inlineblock:--color-my-status: var(--my-status); - Rebuild and confirm
bg-my-statusappears in the generated CSS.
Add a new categorical (entity/domain/relevance) colour
Section titled “Add a new categorical (entity/domain/relevance) colour”- Add one hue number:
--hue-<category>: <deg>;(in the relevant stylesheet,:root). - Derive its tokens with the standard formula, e.g.
--<category>-bg: oklch(0.93 0.04 var(--hue-<category>));plus the.darkformula. - Register utilities via
@theme inlineas above.
Re-hue or retune an existing colour
Section titled “Re-hue or retune an existing colour”- Change the ramp step (
--green-solid) or the hue number (--hue-entity-person) — never the per-token definitions. One edit ripples to every consumer in both modes.
Renaming a CSS custom property
Section titled “Renaming a CSS custom property”A CSS custom-property rename is a literal sweep — GitNexus does not index CSS
variables, so do not rely on gitnexus_rename. The sweep has three parts: drop/replace
the prefix on the definition, repoint the @theme inline RHS, and repoint every inline
var() consumer. Verify with a built-CSS grep that the generated utility names are
unchanged.
Branding & rebrand (config-driven)
Section titled “Branding & rebrand (config-driven)”Two things are already a config operation, not a code edit:
- Product name — sourced from
BRANDING.productName(validated inlib/client-config.ts, seeded fromlib/branding/clients/default.json). UI strings, exports and AI-prompt placeholders resolve through it, so changing the product name is a single config edit. (Task ID-119 routed the previously-hardcoded"Knowledge Hub"literals onto this; the value itself was unchanged.) - Brand primary colour —
brandPrimaryColour/brandPrimaryColourDark/brandPrimaryForegroundare OKLCH strings validated byclient-config.tsand injected into the CSS at build time by thegenerate:client-branding/generate:brandingprebuild scripts. A WCAG non-text contrast check runs against the page background.
What is not yet a config operation (and is intentionally deferred):
- The full utility-class rename to a brand-neutral namespace → backlog
bl-349. - Brand direction (palette A/B, typography, logo) →
/design-sync.
File map
Section titled “File map”| File | Role |
|---|---|
app/globals.css | Core: shadcn tokens, functional ramps, core-status tokens, @theme inline core. |
app/styles/domain-tokens.css | Application-domain Tier-2 tokens + categorical hues + their @theme inline. |
lib/client-config.ts | Zod schema for BRANDING (product name, brand colours, contrast guard). |
lib/branding/clients/default.json | Seed values for the branding config. |
.claude/checks/design-system.md | The design-system quality check (semantic-token discipline). |