Skip to content

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 backlog bl-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.

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.


Primitives are the only place a raw colour value is written. There are two kinds.

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:

FamilyRoles available
greensolid, solid-deep, border, tint
ambersolid, border, tint, tint-strong
redsolid, solid-deep, border, tint
bluesolid, border, tint
violetsolid, border, tint
tealsolid, 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.

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:

app/styles/domain-tokens.css
--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:

StylesheetHolds
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.cssThe 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.

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 inline var(--domain-${key}-bg|text|surface) and were never --color--prefixed.


There are three ways a component reaches a colour. Prefer them in this order:

  1. Tailwind utility class — the default. @theme inline generated it from the Tier-2 token:

    <span className="bg-bid-won text-entity-person" />
  2. Inline var() against the bare token — when a utility does not exist or a style is computed:

    <div className="bg-[var(--status-success)]" />
  3. 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 is a .dark class toggle, and it is implemented by swapping the primitives, not redefining the semantic values:

  • Ramps — each --<family>-<role> has a .dark override. Because every status token references a ramp, the status tokens themselves need no .dark override; they inherit the swapped primitive. (Task ID-119 collapsed ~83 redundant per-token .dark overrides this way.)
  • Categoricals — the --hue-<category> number is mode-invariant; only the oklch(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.


  1. If an existing ramp family fits, add the Tier-2 token in the right stylesheet (globals.css for core, domain-tokens.css for app-domain), referencing the ramp step: --my-status: var(--amber-solid);
  2. If you need a new ramp step, add it to the family in :root and .dark in app/globals.css.
  3. Register the utility in that file’s @theme inline block: --color-my-status: var(--my-status);
  4. Rebuild and confirm bg-my-status appears in the generated CSS.

Add a new categorical (entity/domain/relevance) colour

Section titled “Add a new categorical (entity/domain/relevance) colour”
  1. Add one hue number: --hue-<category>: <deg>; (in the relevant stylesheet, :root).
  2. Derive its tokens with the standard formula, e.g. --<category>-bg: oklch(0.93 0.04 var(--hue-<category>)); plus the .dark formula.
  3. Register utilities via @theme inline as above.
  • 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.

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.


Two things are already a config operation, not a code edit:

  • Product name — sourced from BRANDING.productName (validated in lib/client-config.ts, seeded from lib/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 colourbrandPrimaryColour / brandPrimaryColourDark / brandPrimaryForeground are OKLCH strings validated by client-config.ts and injected into the CSS at build time by the generate:client-branding / generate:branding prebuild 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.

FileRole
app/globals.cssCore: shadcn tokens, functional ramps, core-status tokens, @theme inline core.
app/styles/domain-tokens.cssApplication-domain Tier-2 tokens + categorical hues + their @theme inline.
lib/client-config.tsZod schema for BRANDING (product name, brand colours, contrast guard).
lib/branding/clients/default.jsonSeed values for the branding config.
.claude/checks/design-system.mdThe design-system quality check (semantic-token discipline).