Lesson 07 / 19
Color Tokens
Writing color roles as a contract, declaring legal foreground–ground pairs, testing state derivatives with the separation threshold, and auditing the contract automatically in every theme.
Contents
The previous lesson established token layers and cross-layer reference rules. In that
architecture, a color token was only a name and a reference: border was bound to the
neutral-500 step. That is only part of what a color carries.
The rest of a color token is where it can be used: which grounds the text-secondary
role can fall on, which threshold it must clear, and which derivative it switches to in
which state. Without this written down, every component checks on its own or not at all.
This lesson defines color tokens together with their pairings and turns the definition
into an auditable contract.
A Contract Is More Than a Role List
In the Color System lesson, roles were bound to steps, and pairings were listed and checked by hand. At the system level, two things change.
First, the pairing list becomes a contract: a component may use only a pair written into it. A foreground–ground combination outside the contract is not a preference, it is a violation. Second, the contract reruns for every theme; passing once is not enough.
The role set also grows: three more sets join the ten roles from the Color System lesson — state derivatives (hover, active, disabled), semantic color pairs, and the focus ring. The Interaction States lesson established that states must be measurably separated at the 1.2 contrast ratio this course adopted; the contract checks this threshold too.
The third part of the contract is pairs with no criterion. A disabled control’s text
is exempt from the accessibility criteria’s contrast requirement, and the exemption
appears in the contract not as an absent threshold but as one explicitly written as
null: the decision is made and recorded.
// color.mjs — writing color roles as a contract and auditing the contract function hslRgb(h, s, l) { s /= 100; l /= 100; const k = (n) => (n + h / 30) % 12; const a = s * Math.min(l, 1 - l); const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))); return [f(0), f(8), f(4)].map((v) => Math.round(v * 255)); } const hex = (rgb) => "#" + rgb.map((v) => v.toString(16).padStart(2, "0")).join(""); function channel(v) { const s = v / 255; return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); } const luminance = ([r, g, b]) => 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); function contrast(a, b) { const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p); return (x + 0.05) / (y + 0.05); } // Primitive layer: families and step lightness values (the ramp from the Color System lesson). const LIGHTNESS = { "000": 100, "050": 97, 100: 92, 200: 84, 300: 74, 400: 62, 500: 50, 600: 40, 700: 31, 800: 22, 900: 14 }; const FAMILY = { neutral: [214, 8], primary: [214, 62], success: [145, 55], warning: [38, 85], error: [8, 68] }; const color = (family, step) => hslRgb(FAMILY[family][0], FAMILY[family][1], LIGHTNESS[step]); // Semantic layer: each role names a job and binds to a primitive step. const ROLE = { surface: ["neutral", "000"], "surface-secondary": ["neutral", "050"], "surface-selected": ["primary", "100"], border: ["neutral", "500"], "text-primary": ["neutral", "900"], "text-secondary": ["neutral", "600"], "text-tertiary": ["neutral", "500"], "action-primary": ["primary", "600"], "action-primary-on": ["neutral", "000"], "action-primary-hover": ["primary", "700"], "action-primary-active": ["primary", "800"], "action-disabled": ["neutral", "200"], "action-disabled-on": ["neutral", "500"], "focus-ring": ["primary", "600"], "success-ground": ["success", "100"], "success-text": ["success", "800"], "warning-ground": ["warning", "100"], "warning-text": ["warning", "800"], "error-ground": ["error", "100"], "error-text": ["error", "700"], }; const roleColor = (role) => color(...ROLE[role]); console.log("role family step hex"); for (const [name, [family, b]] of Object.entries(ROLE)) { console.log(`${name.padEnd(24)} ${family.padEnd(7)} ${b.padStart(7)} ${hex(roleColor(name))}`); } // Contract: which foreground can fall on which ground, and which threshold applies. // A null threshold means the criterion does not apply; the reason must be written into the contract. const CONTRACT = [ ["text-primary", "surface", 4.5], ["text-primary", "surface-secondary", 4.5], ["text-secondary", "surface", 4.5], ["text-tertiary", "surface", 4.5], ["text-primary", "surface-selected", 4.5], ["border", "surface", 3.0], ["action-primary", "surface", 3.0], ["action-primary-on", "action-primary", 4.5], ["action-primary-on", "action-primary-hover", 4.5], ["action-primary-on", "action-primary-active", 4.5], ["action-disabled-on", "action-disabled", null], ["focus-ring", "surface", 3.0], ["success-text", "success-ground", 4.5], ["warning-text", "warning-ground", 4.5], ["error-text", "error-ground", 4.5], ]; console.log("\nforeground ground contrast threshold result"); let failing = 0; for (const [fg, ground, threshold] of CONTRACT) { const k = contrast(roleColor(fg), roleColor(ground)); const result = threshold === null ? "exempt" : k >= threshold ? "passed" : "FAILED"; if (threshold !== null && k < threshold) failing++; console.log( `${fg.padEnd(24)} ${ground.padEnd(24)} ${k.toFixed(2).padStart(7)}:1 ${(threshold === null ? "-" : threshold.toFixed(1)).padStart(6)} ${result}` ); } console.log(`pairs that miss the threshold: ${failing} / ${CONTRACT.filter((s) => s[2] !== null).length}`); // State derivatives: every state role must be distinguishable from its base role. // The criterion is the 1.2 contrast ratio adopted in the Interaction States lesson. const SEPARATION_THRESHOLD = 1.2; const STATES = [ ["action-primary-hover", "action-primary"], ["action-primary-active", "action-primary-hover"], ["action-disabled", "surface"], ["surface-selected", "surface"], ["surface-secondary", "surface"], ]; console.log("\nstate role base role contrast threshold result"); for (const [state, base] of STATES) { const k = contrast(roleColor(state), roleColor(base)); console.log( `${state.padEnd(24)} ${base.padEnd(22)} ${k.toFixed(3).padStart(8)} ${SEPARATION_THRESHOLD.toFixed(1).padStart(5)} ${k >= SEPARATION_THRESHOLD ? "passed" : "FAILED"}` ); } // For the text-tertiary role: which neutral step first clears the 4.5 threshold? console.log("\nneutral step against surface 4.5 threshold 3.0 threshold (large text)"); for (const b of ["300", "400", "500", "600", "700"]) { const k = contrast(color("neutral", b), roleColor("surface")); console.log( `${b.padStart(12)} ${k.toFixed(2).padStart(13)} ${(k >= 4.5 ? "passed" : "FAILED").padStart(10)} ${(k >= 3.0 ? "passed" : "FAILED").padStart(23)}` ); } // For surface-secondary: which neutral step first clears the 1.2 separation threshold? console.log("\nneutral step against surface 1.2 separation threshold text-secondary on it"); for (const b of ["050", "100", "200", "300"]) { const z = color("neutral", b); const sep = contrast(z, roleColor("surface")); const mt = contrast(roleColor("text-secondary"), z); console.log( `${b.padStart(12)} ${sep.toFixed(3).padStart(13)} ${(sep >= 1.2 ? "passed" : "FAILED").padStart(16)} ` + `${(mt.toFixed(2) + ":1 " + (mt >= 4.5 ? "passed" : "FAILED")).padStart(24)}` ); } // Dark-theme counterpart: values derived with the preservation calculation from the Dark Theme lesson. const DARK = { surface: "#212327", "surface-secondary": "#34383d", "text-primary": "#ffffff", "text-secondary": "#989fa8", border: "#7b848f", "action-primary": "#89a9d2", "action-primary-on": "#212327", "error-text": "#dd9589", }; const missing = Object.keys(ROLE).filter((r) => !(r in DARK)); console.log(`\nroles with a dark-theme counterpart: ${Object.keys(DARK).length} / ${Object.keys(ROLE).length}`); console.log("roles missing a dark counterpart:"); for (const r of missing) console.log(` ${r}`); // The part of the contract that can be audited in the dark theme const rgb = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)); console.log("\npairs auditable in the dark theme"); let audited = 0; for (const [fg, ground, threshold] of CONTRACT) { if (threshold === null || !(fg in DARK) || !(ground in DARK)) continue; audited++; const k = contrast(rgb(DARK[fg]), rgb(DARK[ground])); console.log( ` ${fg.padEnd(24)} ${ground.padEnd(20)} ${k.toFixed(2).padStart(6)}:1 ${threshold.toFixed(1).padStart(5)} ${k >= threshold ? "passed" : "FAILED"}` ); } console.log(`audited: ${audited} / ${CONTRACT.filter((s) => s[2] !== null).length}`);
role family step hex
surface neutral 000 #ffffff
surface-secondary neutral 050 #f7f7f8
surface-selected primary 100 #dee9f7
border neutral 500 #757e8a
text-primary neutral 900 #212327
text-secondary neutral 600 #5e656e
text-tertiary neutral 500 #757e8a
action-primary primary 600 #275ea5
action-primary-on neutral 000 #ffffff
action-primary-hover primary 700 #1e4980
action-primary-active primary 800 #15335b
action-disabled neutral 200 #d3d6d9
action-disabled-on neutral 500 #757e8a
focus-ring primary 600 #275ea5
success-ground success 100 #dff6e9
success-text success 800 #195733
warning-ground warning 100 #fcefd9
warning-text warning 800 #684508
error-ground error 100 #f8e0dd
error-text error 700 #852819
foreground ground contrast threshold result
text-primary surface 15.74:1 4.5 passed
text-primary surface-secondary 14.70:1 4.5 passed
text-secondary surface 5.89:1 4.5 passed
text-tertiary surface 4.11:1 4.5 FAILED
text-primary surface-selected 12.82:1 4.5 passed
border surface 4.11:1 3.0 passed
action-primary surface 6.50:1 3.0 passed
action-primary-on action-primary 6.50:1 4.5 passed
action-primary-on action-primary-hover 9.05:1 4.5 passed
action-primary-on action-primary-active 12.68:1 4.5 passed
action-disabled-on action-disabled 2.82:1 - exempt
focus-ring surface 6.50:1 3.0 passed
success-text success-ground 7.54:1 4.5 passed
warning-text warning-ground 7.57:1 4.5 passed
error-text error-ground 7.21:1 4.5 passed
pairs that miss the threshold: 1 / 14
state role base role contrast threshold result
action-primary-hover action-primary 1.393 1.2 passed
action-primary-active action-primary-hover 1.401 1.2 passed
action-disabled surface 1.459 1.2 passed
surface-selected surface 1.228 1.2 passed
surface-secondary surface 1.071 1.2 FAILED
neutral step against surface 4.5 threshold 3.0 threshold (large text)
300 1.91 FAILED FAILED
400 2.74 FAILED FAILED
500 4.11 FAILED passed
600 5.89 passed passed
700 8.39 passed passed
neutral step against surface 1.2 separation threshold text-secondary on it
050 1.071 FAILED 5.51:1 passed
100 1.204 passed 4.90:1 passed
200 1.459 passed 4.04:1 FAILED
300 1.912 passed 3.08:1 FAILED
roles with a dark-theme counterpart: 8 / 20
roles missing a dark counterpart:
surface-selected
text-tertiary
action-primary-hover
action-primary-active
action-disabled
action-disabled-on
focus-ring
success-ground
success-text
warning-ground
warning-text
error-ground
pairs auditable in the dark theme
text-primary surface 15.74:1 4.5 passed
text-primary surface-secondary 11.80:1 4.5 passed
text-secondary surface 5.89:1 4.5 passed
border surface 4.15:1 3.0 passed
action-primary surface 6.50:1 3.0 passed
action-primary-on action-primary 6.50:1 4.5 passed
audited: 6 / 14
The Third Text Level Cannot Be Built in Color
The contract audit fails one of the fourteen criteria-bearing pairs: the text-tertiary
role produces a 4.11:1 contrast against the surface and cannot clear the 4.5 threshold.
The third table gives the options for a fix. The first neutral step that clears the 4.5
threshold is 600; but 600 is already the step for the text-secondary role. So the third
text level cannot exist on this scale without sitting on the same color as the second
level.
This finding is a direct consequence of the Visual Hierarchy lesson’s two-channel rule. If the color channel cannot carry three levels, the third level moves to another channel: size, weight, or position. The Typographic Scale lesson already separated the metadata level on both size and contrast, so color needs no third shade.
The table’s last column shows one more route: the neutral 500 step clears the 3.0 threshold that applies to large text. If the third level will be used only in large text, the role can be kept, but that requires attaching a condition to the role’s name, and a conditional role should not be used unless that condition is written into the contract — the component using it does not otherwise know it.
The Secondary Surface Produces No Separation
The state-derivative table gives the second finding, and this finding is quieter than the
first. Four state roles clear the separation threshold: hover at 1.393, active at 1.401,
disabled at 1.459, selected at 1.228. The fifth does not: the surface-secondary role
produces only a 1.071 contrast against the surface.
This means every rule written on the assumption of a second surface level goes unbacked. Wherever a side panel, a table header, or a card’s surface is set apart with neutral 050, the separation does not actually exist — a scale step is spent for no visual result. This is the color-side counterpart of the unbacked-variety concept from the Repetition and Consistency lesson.
The last table measures the fix and shows a trade-off. Neutral 100 is the first step that clears the separation threshold (1.204), and the secondary text on top of it still clears 4.90:1. Moving to neutral 200 strengthens the surface separation (1.459) but drops the secondary text to 4.04, losing the threshold.
A system-level rule follows from this: darkening a surface role requires every text role on top of it to be re-audited. A surface is not a decision on its own; it is a decision together with every role that falls on it. The contract’s job is exactly to make this dependency visible.
A Pair with No Criterion Is Also a Decision
The disabled action’s text carries a 2.82:1 contrast against its ground, and the table reads “exempt.” This row is the most misunderstood part of the contract.
The absence of a criterion is not an oversight, it is a written decision: a disabled control is closed to interaction, and the accessibility criteria exempt it from the contrast requirement. Writing the exemption explicitly into the contract does two jobs — the audit tool does not flag that row as an error, and the reader sees it was not a forgotten check.
Its cost is recorded too: why a disabled button is disabled cannot be understood through color alone, and low contrast by itself carries no information. The Microcopy lesson’s rule repeats here — the state’s reason is given through text. Where the contract writes no criterion, another contract takes over.
The Contract Reruns in Every Theme
The last two blocks give this lesson’s main system-level finding. Only eight of the twenty roles have a defined dark-theme counterpart; twelve are missing. Of the contract’s fourteen criteria-bearing pairs, only six can be audited in the dark theme.
All six auditable pairs pass — expected, since they were derived with the Dark Theme lesson’s preservation calculation. The problem is not the pairs that pass; it is the eight pairs that cannot be audited. The dark theme has no selected-surface role, so components using it reference an undefined token — the undefined-reference defect from the previous lesson.
This also shows how to find the gap: through the product of the role set and the theme set. Twenty roles and two themes make forty cells; twelve are empty. Without a report, these gaps surface only once a user switches to the dark theme.
That every semantic color is missing stands out on its own. The success, warning, and error pairs are defined and audited in the light theme; none exist in the dark theme. Semantic colors are the least-used roles but appear at the most critical moments, so their absence is noticed latest.
The Contract’s Written Form
Roles and pairings live in separate files. Roles are written in style as custom properties; pairings are written as a data file the audit reads.
:root { /* primitive */ --neutral-000: #ffffff; --neutral-100: #e9eaec; --neutral-600: #5e656e; --neutral-900: #212327; --primary-600: #275ea5; --primary-700: #1e4980; --primary-800: #15335b; /* semantic */ --surface: var(--neutral-000); --surface-secondary: var(--neutral-100); --text-primary: var(--neutral-900); --text-secondary: var(--neutral-600); --action-primary: var(--primary-600); --action-primary-hover: var(--primary-700); --action-primary-active: var(--primary-800); --action-primary-on: var(--neutral-000); }
The pairing contract has no counterpart in style, and it should not. CSS cannot compute a contrast ratio; only an audit running at build time or in a test stage can read the contract. This is the main reason the token system is a data layer independent of style: values turn into style, rules turn into audits.
The second detail is that surface-secondary has been moved from neutral 050 to neutral
100. The audit made this fix mandatory; the written form carries the corrected version.
Leaving a defect the audit found unfixed in the source reduces the audit to a report
generator.
Summary
- A color token is not just a name and a value; which grounds it can fall on and which threshold it must clear are part of the definition.
- The pairing list is a contract: a foreground–ground combination that is not in the contract is a violation, not a preference, and the contract is rerun for every theme.
- The scale cannot serve every role: the third text level cannot be built on this scale (neutral 500 does not clear the threshold, neutral 600 collides with the second level), and the secondary surface cannot be separated at neutral 050 with a 1.071 contrast. An unserviceable role moves to the size or weight channel; darkening a surface requires every text role on it to be re-audited.
- A pair with no criterion is a written decision; the exemption is stated explicitly in the contract, and the information it lacks is supplied through text.
- Only eight of the twenty roles have a defined dark-theme counterpart; six of the fourteen criteria-bearing pairs can be audited. Missing cells are found through the product of the role set and the theme set.
- Values turn into style, rules turn into audits; because CSS cannot compute a contrast ratio, the contract lives outside style.
Next Step
On the color side, the contract was built on the pairing concept: a role’s correctness was measured together with another role, not on its own. On the typography side, dependency takes another form. A text level is not a single number: size, line height, weight, and letter spacing together form one decision, and spreading these four across separate tokens lets wrong combinations get assembled. The next lesson defines typography tokens as composite tokens, fits line-box heights onto the spacing grid, and audits the levels’ distinction threshold.
To keep your progress and take notes, Log in
My notes
Log in to take notes.