Generating Accessible Colour Pairs with APCA and WCAG
Part of Colour Palette Architecture. Rather than checking contrast where it happens to be noticed, generate the full set of pairings a design system permits and score every one of them. The output is a table that answers “may I put this text on this surface” before anyone renders anything.
Prerequisites Link to this section
- A token set with text roles and surface roles distinguishable by name or by an explicit classification.
- Resolved values per theme — the output of the reference resolver, not the authored source.
- A declared list of which text roles are permitted on which surfaces; generating the full cross product is a useful first step but produces pairs nobody intends.
- Node 20 or later. The maths below needs no dependencies.
Step-by-Step Implementation Link to this section
Step 1 — Classify roles into text and surface Link to this section
Intent: the pair table is a product of two sets, so both have to be identifiable.
// scripts/pairs/classify.mjs
export function classify(manifest) {
const text = [], surface = [];
for (const [name, token] of Object.entries(manifest)) {
if (token.$type !== 'color') continue;
const role = token.$extensions?.['com.ds.role'];
if (role === 'text' || /-text-|-on-/.test(name)) text.push(name);
else if (role === 'surface' || /-surface-|-bg-/.test(name)) surface.push(name);
}
return { text, surface };
}
Why this works: the explicit $extensions role is authoritative and the name pattern is a fallback, which means a system that has not yet annotated its tokens gets useful output immediately while the annotation is added incrementally. Relying on the name pattern alone breaks the first time a token is named unconventionally, which is why it is the fallback rather than the rule.
Step 2 — Declare which pairs are intended Link to this section
Intent: most of the cross product is meaningless, and scoring it produces a report nobody reads.
{
"pairs": [
{ "text": "--ds-color-text-primary", "on": ["--ds-color-surface-default", "--ds-color-surface-raised"] },
{ "text": "--ds-color-text-secondary", "on": ["--ds-color-surface-default", "--ds-color-surface-raised"] },
{ "text": "--ds-color-text-danger", "on": ["--ds-color-surface-default", "--ds-color-surface-danger"] },
{ "text": "--ds-color-on-action", "on": ["--ds-color-action-primary", "--ds-color-action-hover"] }
]
}
Why this works: the declaration is the design intent, written down. It doubles as documentation — a designer asking “can I put danger text on a raised surface” has an authoritative answer — and it is the thing that makes the check exhaustive rather than incidental. Adding a text role without adding it to this list should be treated as an incomplete change.
Step 3 — Score with WCAG 2 contrast Link to this section
Intent: implement the ratio that the accessibility standard and every audit tool use.
// scripts/pairs/wcag.mjs
const srgbToLinear = (c) =>
c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
export function relativeLuminance([r, g, b]) {
const [R, G, B] = [r, g, b].map((v) => srgbToLinear(v / 255));
return 0.2126 * R + 0.7152 * G + 0.0722 * B;
}
export function contrastRatio(fg, bg) {
const L1 = relativeLuminance(fg), L2 = relativeLuminance(bg);
const [hi, lo] = L1 > L2 ? [L1, L2] : [L2, L1];
return (hi + 0.05) / (lo + 0.05);
}
export const wcagVerdict = (ratio, { large = false } = {}) => ({
ratio: Math.round(ratio * 100) / 100,
aa: ratio >= (large ? 3 : 4.5),
aaa: ratio >= (large ? 4.5 : 7),
});
Why this works: this is the exact formula from the specification, and matching it exactly matters — a check that disagrees with the audit tool a client uses is worse than no check, because it produces arguments rather than fixes. The large-text threshold is a separate parameter because it depends on the role’s rendered size, which the token set knows and the colour maths does not.
Step 4 — Score with APCA as a second signal Link to this section
Intent: WCAG 2’s formula is known to misjudge some mid-tone pairs; a perceptual model gives a second opinion.
// scripts/pairs/apca.mjs — simplified lightness contrast, sufficient for ranking
const Y = ([r, g, b]) =>
0.2126729 * Math.pow(r / 255, 2.4) +
0.7151522 * Math.pow(g / 255, 2.4) +
0.0721750 * Math.pow(b / 255, 2.4);
export function lightnessContrast(text, bg) {
const Ytext = Y(text), Ybg = Y(bg);
const polarity = Ybg > Ytext ? 1 : -1; // dark text on light, or the reverse
const raw = Math.abs(Math.pow(Ybg, 0.56) - Math.pow(Ytext, 0.57));
return Math.round(raw * 1.14 * 100 * polarity);
}
Why this works as a signal, not a gate: APCA models polarity — dark-on-light and light-on-dark are genuinely different perceptual problems, which the WCAG ratio treats identically. Using it as an advisory column alongside the ratio surfaces pairs that pass at 4.6 and read poorly, which is the class WCAG 2 is weakest on. It should not replace the WCAG gate, because the WCAG threshold is what conformance is measured against.
Step 5 — Emit the table and gate on it Link to this section
Intent: one artefact that is both the report and the gate.
// scripts/pairs/run.mjs
import { readFileSync, writeFileSync } from 'node:fs';
import { contrastRatio, wcagVerdict } from './wcag.mjs';
import { lightnessContrast } from './apca.mjs';
const hexToRgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
const manifest = JSON.parse(readFileSync('dist/token-manifest.json', 'utf8'));
const intents = JSON.parse(readFileSync('tokens/pairs.json', 'utf8')).pairs;
const rows = [];
for (const theme of ['light', 'dark']) {
for (const { text, on } of intents) {
for (const surface of on) {
const fg = hexToRgb(manifest[theme][text].value);
const bg = hexToRgb(manifest[theme][surface].value);
const wcag = wcagVerdict(contrastRatio(fg, bg));
rows.push({ theme, text, surface, ...wcag, lc: lightnessContrast(fg, bg) });
}
}
}
writeFileSync('dist/contrast-pairs.json', JSON.stringify(rows, null, 2));
const failures = rows.filter((r) => !r.aa);
for (const f of failures) {
console.error(`FAIL [${f.theme}] ${f.text} on ${f.surface} — ${f.ratio}:1 (need 4.5)`);
}
process.exit(failures.length ? 1 : 0);
Why this works: the JSON output feeds the documentation site, so designers see the same table CI enforces. Failing on WCAG only, while reporting the APCA column, keeps the gate aligned with the conformance standard while still surfacing the perceptual signal for a human to weigh.
Step 6 — Publish the table where designers work Link to this section
Intent: a check that only exists in CI changes behaviour after the fact; a published table changes it before.
Render contrast-pairs.json as a grid on the design system documentation site, with each cell showing the ratio and coloured by verdict. The value is not the enforcement — CI already does that — but the answer it gives at the moment a designer is choosing a colour. Most contrast failures are prevented by a designer being able to see, in two seconds, that the pairing they were considering sits at 3.9.
Verification Link to this section
Confirm the maths against a known reference before trusting the output. Black on white must produce exactly 21, white on white exactly 1, and a mid grey against white should match whatever an established checker reports to two decimal places. A formula error of a few percent is invisible in the output and fatal to the gate’s credibility.
Then confirm coverage: every text role in the token set should appear in at least one declared pair. A role with no pairing is either unused or unchecked, and both are worth knowing. Emitting that as a warning in the same run costs three lines and catches the drift that would otherwise accumulate as new roles are added.
Troubleshooting Link to this section
| Symptom | Likely cause | Fix |
|---|---|---|
| Ratios disagree with an external checker | Luminance is computed on gamma-encoded values | Linearise each channel before weighting; check black on white returns exactly 21 |
| A pair passes in the report and fails in the browser | The rendered colour is not the token value — an opacity or overlay intervenes | Composite the overlay before scoring, or score the composited result |
| Semi-transparent surfaces score oddly | Alpha is being ignored | Blend against the surface underneath before computing luminance |
| The report has hundreds of rows | The full cross product is being scored rather than declared pairs | Score only declared intents; the cross product is not a design |
| APCA and WCAG disagree constantly on dark themes | Expected — polarity is modelled by one and not the other | Treat APCA as advisory; investigate only where it is markedly worse |
Migration Note Link to this section
Introducing the pair table into a system that has been relying on spot checks reliably finds two or three failures, and they are almost always in the same places: secondary text on a raised surface, and disabled text anywhere. Both are cases where a designer chose a value against the page background and it was later placed on a slightly different one.
Fix by adjusting the role rather than the pairing where possible. Lightening text-secondary by one ramp step fixes it on every surface at once; declaring that secondary text is not permitted on raised surfaces fixes it by prohibition and pushes the problem into a design review later. The first is nearly always the better trade.
Keeping the Pair List Honest Link to this section
The pair list is the only part of this system that has to be maintained by hand, so it is where the check will decay if it decays anywhere. Two habits keep it accurate: adding the pairing in the same change that adds a role, and reviewing the list whenever a surface is added — since a new surface multiplies the pairings every existing text role needs.
Related Link to this section
- Colour Palette Architecture — where the ramp and the semantic roles this scores are designed
- How to Structure Semantic Colour Tokens for Accessibility — the role structure the pair list depends on
- Building Perceptually Uniform Colour Scales with OKLCH — generating a ramp whose steps land predictably against these thresholds