Skip to content
academia.sh

Lesson 11 / 19

Theme Management

Deriving themes from a source table with a single rule, the contracts mirroring preserves and misses, keeping the correction as an exception list, testing the brand theme with a single family, and measuring role–theme coverage.

Contents

The foundation layer was completed in the previous lesson: color, typography, spacing, size, and layout decisions are named and derivable. What remains is the gap left over from the Color Tokens lesson, where only eight of twenty roles had a dark-theme counterpart and the missing cells were waiting to be filled by hand.

Filling it by hand does not work, for two reasons. First, every new role has to be written once more in every theme, and this work grows as the product of the role count and the theme count. Second, a hand-written value carries no rationale — why a role lands on that step in the dark theme is not recorded anywhere. This lesson builds a theme not as a value list, but as a table derived from a source theme, and measures where the derivation falls short.

A Theme Is a Derivation Rule

A theme is the table binding every role in the semantic layer to a primitive value, and the difference between two themes is the rule for that binding. The simplest rule for the dark theme is step mirroring: the two ends of the lightness ramp swap places, 000 with 900, 050 with 800. Surface moves from lightest to darkest, text from darkest to lightest; role names and the contract stay the same.

The rule’s value is that a single line carries every role — when a new role is added, its dark counterpart is not written separately, the rule already covers it. The rule’s limit is found by measuring: mirroring does not preserve the contrast ratio, because the lightness ramp is not symmetric on the axis of perceived brightness.

The script below runs both contracts in every theme: the threshold contract (the minimum contrast ratio a foreground–ground pair must carry) and the state separation contract (the smallest ratio for a state role to be distinguishable from its base role, adopted as 1.2 in the Interaction States lesson).

// theme.mjs — deriving themes from a single rule and auditing the contract in every theme

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 (the ramp from the Color Tokens 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] };

// Semantic layer: role -> [family, step]. The light theme is the source.
const LIGHT = {
  surface: ["neutral", "000"], "surface-secondary": ["neutral", "050"], "surface-selected": ["primary", "100"],
  border: ["neutral", "500"], "text-primary": ["neutral", "900"], "text-secondary": ["neutral", "600"],
  "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"],
};

// Contract: foreground, ground, threshold.
const CONTRACT = [
  ["text-primary", "surface", 4.5], ["text-primary", "surface-secondary", 4.5],
  ["text-secondary", "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], ["focus-ring", "surface", 3.0],
  ["success-text", "success-ground", 4.5], ["warning-text", "warning-ground", 4.5],
  ["error-text", "error-ground", 4.5],
];

// Derivation rule 1: step mirroring. The lightness ramp is reversed.
const MIRROR = { "000": "900", "050": "800", 100: "700", 200: "600", 300: "500", 400: "400", 500: "300", 600: "200", 700: "100", 800: "050", 900: "000" };
const mirror = (source) =>
  Object.fromEntries(Object.entries(source).map(([role, [family, b]]) => [role, [family, MIRROR[b]]]));

// Derivation rule 2: brand theme. Only the "primary" family's hue and saturation change.
const brandFamily = (hue, saturation) => ({ ...FAMILY, primary: [hue, saturation] });

function audit(roleTable, familyTable) {
  const toColor = (role) => {
    const [family, b] = roleTable[role];
    return hslRgb(familyTable[family][0], familyTable[family][1], LIGHTNESS[b]);
  };
  return CONTRACT.map(([fg, ground, threshold]) => {
    const k = contrast(toColor(fg), toColor(ground));
    return { fg, ground, threshold, k, passed: k >= threshold };
  });
}
const failing = (result) => result.filter((s) => !s.passed);
const printContract = (title, result) => {
  console.log(`\n${title}`);
  console.log("foreground               ground                 ratio  threshold  result");
  for (const s of result) {
    console.log(
      `${s.fg.padEnd(24)} ${s.ground.padEnd(22)} ${s.k.toFixed(2).padStart(5)} ${s.threshold.toFixed(1).padStart(6)}  ${s.passed ? "passed" : "FAILED"}`
    );
  }
  console.log(`missing the threshold: ${failing(result).length} / ${result.length}`);
};

// Second contract: state derivatives must be distinguishable from the base (Interaction States: 1.2).
const SEPARATION = [
  ["action-primary-hover", "action-primary"], ["action-primary-active", "action-primary-hover"],
  ["surface-secondary", "surface"], ["action-disabled", "surface"],
];
const SEPARATION_THRESHOLD = 1.2;
function auditSeparation(roleTable, familyTable) {
  const toColor = (role) => {
    const [family, b] = roleTable[role];
    return hslRgb(familyTable[family][0], familyTable[family][1], LIGHTNESS[b]);
  };
  return SEPARATION.map(([state, base]) => {
    const k = contrast(toColor(state), toColor(base));
    return { state, base, k, passed: k >= SEPARATION_THRESHOLD };
  });
}
const printSeparation = (title, result) => {
  console.log(`\n${title}`);
  console.log("state role               base role              ratio  threshold  result");
  for (const s of result) {
    console.log(
      `${s.state.padEnd(24)} ${s.base.padEnd(22)} ${s.k.toFixed(3).padStart(5)} ${SEPARATION_THRESHOLD.toFixed(1).padStart(6)}  ${s.passed ? "passed" : "FAILED"}`
    );
  }
  console.log(`not distinguishable: ${result.filter((s) => !s.passed).length} / ${result.length}`);
};

console.log("--- 1. light theme (source) ---");
printContract("light theme contract", audit(LIGHT, FAMILY));
printSeparation("light theme separation", auditSeparation(LIGHT, FAMILY));

console.log("\n--- 2. dark theme derived by mirroring ---");
const DARK = mirror(LIGHT);
printContract("dark theme contract (raw derivation)", audit(DARK, FAMILY));
printSeparation("dark theme separation (raw derivation)", auditSeparation(DARK, FAMILY));

console.log("\n--- 3. correction in the dark theme ---");
// Mirroring only shifts the step of the pairs that are left failing; it is a correction, not a rule.
const DARK_CORRECTION = {
  border: ["neutral", "400"],
  "action-primary": ["primary", "300"],
  "action-primary-on": ["neutral", "900"],
  "action-primary-hover": ["primary", "200"],
  "action-primary-active": ["primary", "100"],
  "focus-ring": ["primary", "300"],
};
const DARK2 = { ...DARK, ...DARK_CORRECTION };
printContract("dark theme contract (after correction)", audit(DARK2, FAMILY));
printSeparation("dark theme separation (after correction)", auditSeparation(DARK2, FAMILY));
console.log(`correction count: ${Object.keys(DARK_CORRECTION).length} roles / ${Object.keys(LIGHT).length}`);

console.log("\n--- 4. brand theme: only the primary family's hue changes ---");
for (const [name, hue, saturation] of [["purple", 276, 62], ["green", 145, 62], ["orange", 28, 62]]) {
  const families = brandFamily(hue, saturation);
  const light = failing(audit(LIGHT, families));
  const dark = failing(audit(DARK2, families));
  console.log(
    `${name.padEnd(8)} hue ${String(hue).padStart(3)}  light theme failing: ${light.length}  dark theme failing: ${dark.length}` +
      (light.length ? "  (" + light.map((s) => s.fg + "/" + s.ground).join(", ") + ")" : "")
  );
}

console.log("\n--- 5. coverage: role x theme cells ---");
const THEMES = { light: LIGHT, dark: DARK2 };
let missing = 0;
for (const role of Object.keys(LIGHT)) {
  for (const [name, table] of Object.entries(THEMES)) if (!(role in table)) { missing++; console.log(`  missing: ${role} / ${name}`); }
}
console.log(`defined cells: ${Object.keys(LIGHT).length * Object.keys(THEMES).length - missing} / ${Object.keys(LIGHT).length * Object.keys(THEMES).length}`);

console.log("\n--- 6. style output (first four roles) ---");
const styleOutput = (selector, table, families) =>
  [`${selector} {`]
    .concat(Object.entries(table).slice(0, 4).map(([role, [family, b]]) =>
      `  --color-${role}: ${hex(hslRgb(families[family][0], families[family][1], LIGHTNESS[b]))};`))
    .concat(["}"]).join("\n");
console.log(styleOutput(":root", LIGHT, FAMILY));
console.log(styleOutput('[data-theme="dark"]', DARK2, FAMILY));
--- 1. light theme (source) ---

light theme contract
foreground               ground                 ratio  threshold  result
text-primary             surface                15.74    4.5  passed
text-primary             surface-secondary      14.70    4.5  passed
text-secondary           surface                 5.89    4.5  passed
text-primary             surface-selected       12.82    4.5  passed
border                   surface                 4.11    3.0  passed
action-primary           surface                 6.50    3.0  passed
action-primary-on        action-primary          6.50    4.5  passed
action-primary-on        action-primary-hover    9.05    4.5  passed
action-primary-on        action-primary-active  12.68    4.5  passed
focus-ring               surface                 6.50    3.0  passed
success-text             success-ground          7.54    4.5  passed
warning-text             warning-ground          7.57    4.5  passed
error-text               error-ground            7.21    4.5  passed
missing the threshold: 0 / 13

light theme separation
state role               base role              ratio  threshold  result
action-primary-hover     action-primary         1.393    1.2  passed
action-primary-active    action-primary-hover   1.401    1.2  passed
surface-secondary        surface                1.071    1.2  FAILED
action-disabled          surface                1.459    1.2  passed
not distinguishable: 1 / 4

--- 2. dark theme derived by mirroring ---

dark theme contract (raw derivation)
foreground               ground                 ratio  threshold  result
text-primary             surface                15.74    4.5  passed
text-primary             surface-secondary      11.80    4.5  passed
text-secondary           surface                10.78    4.5  passed
text-primary             surface-selected        9.05    4.5  passed
border                   surface                 8.23    3.0  passed
action-primary           surface                10.29    3.0  passed
action-primary-on        action-primary         10.29    4.5  passed
action-primary-on        action-primary-hover   12.82    4.5  passed
action-primary-on        action-primary-active  14.63    4.5  passed
focus-ring               surface                10.29    3.0  passed
success-text             success-ground          5.02    4.5  passed
warning-text             warning-ground          5.09    4.5  passed
error-text               error-ground            7.21    4.5  passed
missing the threshold: 0 / 13

dark theme separation (raw derivation)
state role               base role              ratio  threshold  result
action-primary-hover     action-primary         1.246    1.2  passed
action-primary-active    action-primary-hover   1.141    1.2  FAILED
surface-secondary        surface                1.333    1.2  passed
action-disabled          surface                2.670    1.2  passed
not distinguishable: 1 / 4

--- 3. correction in the dark theme ---

dark theme contract (after correction)
foreground               ground                 ratio  threshold  result
text-primary             surface                15.74    4.5  passed
text-primary             surface-secondary      11.80    4.5  passed
text-secondary           surface                10.78    4.5  passed
text-primary             surface-selected        9.05    4.5  passed
border                   surface                 5.75    3.0  passed
action-primary           surface                 7.63    3.0  passed
action-primary-on        action-primary          7.63    4.5  passed
action-primary-on        action-primary-hover   10.29    4.5  passed
action-primary-on        action-primary-active  12.82    4.5  passed
focus-ring               surface                 7.63    3.0  passed
success-text             success-ground          5.02    4.5  passed
warning-text             warning-ground          5.09    4.5  passed
error-text               error-ground            7.21    4.5  passed
missing the threshold: 0 / 13

dark theme separation (after correction)
state role               base role              ratio  threshold  result
action-primary-hover     action-primary         1.349    1.2  passed
action-primary-active    action-primary-hover   1.246    1.2  passed
surface-secondary        surface                1.333    1.2  passed
action-disabled          surface                2.670    1.2  passed
not distinguishable: 0 / 4
correction count: 6 roles / 19

--- 4. brand theme: only the primary family's hue changes ---
purple   hue 276  light theme failing: 0  dark theme failing: 0
green    hue 145  light theme failing: 1  dark theme failing: 0  (action-primary-on/action-primary)
orange   hue  28  light theme failing: 0  dark theme failing: 0

--- 5. coverage: role x theme cells ---
defined cells: 38 / 38

--- 6. style output (first four roles) ---
:root {
  --color-surface: #ffffff;
  --color-surface-secondary: #f7f7f8;
  --color-surface-selected: #dee9f7;
  --color-border: #757e8a;
}
[data-theme="dark"] {
  --color-surface: #212327;
  --color-surface-secondary: #34383d;
  --color-surface-selected: #1e4980;
  --color-border: #969da6;
}

What Mirroring Preserves and What It Misses

The first block repeats the light theme’s state: all thirteen threshold-contract pairs pass, while in state separation the secondary surface fails at a 1.071 ratio — the defect identified in the Color Tokens lesson, kept here deliberately, since the derivation’s source is not a flawless table but the real one.

The second block is the result of raw mirroring, and it shows two things. The entire threshold contract passes in the dark theme too, and the ratios mostly rise. The separation threshold the secondary surface missed in the light theme passes in the dark theme at 1.333: same rule, same roles, different result — this is why assuming a decision validated in one theme holds in another is wrong.

The same block shows where the rule misses too. The ratio between the active state and the hover state drops to 1.141 in the dark theme and stays below the threshold. The reason is the ramp’s structure: mirroring carries steps 700 and 800 to 100 and 050, and the lightness difference between these two steps is smaller than at the other end. The rule is not broken; its assumption — that the ramp is symmetric — is only approximately true.

The Correction Is an Exception List

The third block applies the correction, which does not change the rule; it is a six-line exception list written on top of the table the rule produces. Six roles are about a third of nineteen, and that ratio is proof the rule works: thirteen roles got the right value with no intervention at all.

Keeping the exception as a list does two things: the audit shows the exceptions also clear the contract — after the correction, both contracts close with zero violations — and a reviewer, by looking at the list, sees exactly where the rule falls short, which feeds the next ramp adjustment. Writing the values into the table one by one would lose this distinction: which value came from the rule and which from a decision would no longer be visible.

Brand Theme: Testing the One Family That Changes

The fourth block applies the second derivation rule. A brand theme changes only the primary family’s hue and saturation, leaving the role table untouched: role names, step bindings, and the contract stay the same, and the only change is two numbers in the primitive layer.

The result shows that a brand color is not a free choice. Purple and orange hues clear the contract in both themes; green fails one pairing in the light theme, where the text on top of the primary action cannot hit the 4.5 threshold against the green 600 step. This is not a design preference, it is a measurable constraint, and it has two routes — either the action role binds to a darker step, or the on-action text is chosen from the family’s own light end. Whichever is chosen, the decision is written into the brand theme’s token, and the audit confirms it on the next run.

Coverage and Output

The fifth block counts coverage: nineteen roles times two themes is thirty-eight cells, and all are defined. The twelve empty cells from the Color Tokens lesson were closed not by filling them in by hand, but by writing a rule. Coverage measurement works the same way once a theme is added — the moment a new theme’s rule is written, every role gets its counterpart.

The sixth block generates style output from the token source. The difference between themes is gathered in one place: the light theme’s values on the root selector, the dark theme’s on the selector carrying the theme attribute. Components carry no theme information at all, only the role name. This is the system-level counterpart of the custom-property pattern established under Style Architecture.

Matching the operating system’s preference is added on top of this with a single media query; the preference itself was covered in the User Preferences lesson. The system’s responsibility is not reading the preference, it is making sure the table answering the preference is complete and audited.

Summary

  • A theme is not a value list written role by role, it is a mapping derived from a source table; the rule is written once, and new roles follow it automatically.
  • The mirroring rule preserves the threshold contract but not the contrast ratios: the secondary surface, which misses the separation threshold in the light theme, passes in the dark theme, while the active state drops to 1.141 and fails instead. Every contract is rerun in every theme.
  • The correction does not change the rule; it is an exception list written on top of it. Six of nineteen roles were corrected, thirteen came from the rule, and which came from where is recorded.
  • A brand theme changes only one family’s hue and saturation; these two numbers can drop the contract — at the green hue, the text on top of the primary action cannot hit the threshold in the light theme.
  • Coverage is measured through the product of the role set and the theme set: all thirty-eight cells are defined. In the style output, the difference between themes is gathered in a single selector, and components read only the role name.

Next Step

The foundation layer closes with this lesson: values are named, bound to scales, and themes are derived from a single rule and audited. But a system’s user works with the component, not the token. Whoever writes the Borrow button searches not for the --color-action-primary value but for the “primary button” component; failing to find it, they write their own, and the system’s coverage quietly narrows. The next topic takes up this layer, and its first lesson starts with the catalog itself: which components enter the system, how a component’s maturity level is measured, and how a need the catalog does not cover gets met.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close