Skip to content
academia.sh

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.

Start typing to search.

↑↓ Esc navigate · open · close