Skip to content

Latest commit

 

History

34 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Color Contrast Token Auditor

Compute the WCAG 2.x contrast ratio for every colour token pair a policy names, and compare it against the threshold that policy gives for the pair's role and size.

What it does

It reads two documents:

  • a token document — a colour token export: a list of { name, value } entries, where a value is a hexadecimal colour, a comma-separated rgb()/rgba() value, or an alias to another token;
  • a policy document — the pairs to check, the threshold for each role and size, and every limit the run enforces.

For each pair it resolves both tokens to colours, settles any alpha against a known backdrop, computes the ratio, and compares it with the policy threshold. Text failures and non-text failures are separate rules with separate counts, so a build can treat a label and a control differently.

Why it exists

A design system that states its contrast promises in tokens can check them before anything is built, in the place where the values actually live. A page-level scanner tells you that one rendered component failed today; a token pair tells you that the promise itself is wrong, everywhere it is used, before it ships.

The harder half is what such a tool does when it cannot tell. A palette contains values in notations a small parser does not read, aliases that go nowhere, and translucent layers whose backdrop is decided by a page this tool never sees. Every one of those is a chance to report a clean run over evidence that was never obtained — so none of them is allowed to. They exit 2.

Non-goals

These are behaviour, not caveats. Each one is enforced by a test.

  • It does not open a browser, resolve a host, or look at a screen. The token document is an input somebody exported. Nothing here observes a colour as a person would see it, and a finding that claimed otherwise cannot be constructed: the tool's own message literals are checked against a list of words it is not entitled to use, at construction time.
  • It is not a CSS parser and does not claim to be one. It reads four hexadecimal forms, two functional forms and one alias form, listed exactly below. Everything else is refused by name.
  • It is not a WCAG conformance verdict for a page. A pair of tokens is not a rendered element: real text may sit on an image, be composited by an ancestor's opacity, be recoloured by a filter, a forced-colours mode or a user stylesheet, or be a different size than the pair assumes. A pass here says the declared pair meets the declared threshold, and nothing more.
  • It does not model human vision, a display profile, or any colour space other than sRGB. The WCAG 2.x relative-luminance formula is the whole model, including its well-documented disagreements with perception at the dark end.
  • It writes no file. The report goes to stdout and the summary to stderr. There is no --out, no cache and no auto-fix, so there is no destination for it to overwrite.
  • It touches no network at any time, including in its tests. No source file imports a network module or calls fetch. That is asserted by reading the source rather than by watching a socket, and what it proves is exactly that narrow: nothing in the module graph can open one.

Quick start

# a clean run: every pair resolves and every ratio meets its threshold
node bin/color-contrast-token-auditor.mjs \
  --tokens examples/clean/tokens.json \
  --policy examples/clean/contrast.policy.json \
  --now 2026-09-18
# exit 0, status "pass"

# a failing run: one text pair and one control pair below their thresholds
node bin/color-contrast-token-auditor.mjs \
  --tokens examples/failing/tokens.json \
  --policy examples/failing/contrast.policy.json \
  --now 2026-09-18
# exit 1, status "fail"

# an undetermined run: a value this tool does not read, and a scrim over nothing known
node bin/color-contrast-token-auditor.mjs \
  --tokens examples/undetermined/tokens.json \
  --policy examples/undetermined/contrast.policy.json \
  --now 2026-09-18
# exit 2, status "incomplete"

--now is optional and defaults to the system clock. Supply it to make a run that checks token age reproducible; every example above is pinned to one instant for that reason.

Input: the token document

{
  "schemaVersion": "1",
  "source": "design-tokens-build",
  "generatedAt": "2026-09-12",
  "tokens": [
    { "name": "color.surface.default", "value": "#ffffff" },
    { "name": "color.text.default", "value": "#1b1b1b" },
    { "name": "color.text.body", "value": "{color.text.default}" },
    { "name": "color.overlay.scrim", "value": "rgba(17, 17, 17, 0.8)" }
  ]
}

tokens is an array, not an object, so that a name declared twice is visible: JSON object semantics would silently keep the last value and the ambiguity would never be reported. A duplicated name is reported and neither of its values is used.

Unknown keys on the document and on an entry are ignored — it is an export from somebody else's build and may carry metadata. Unknown keys in the policy are rejected, because that is configuration.

generatedAt is YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ. A document that does not say when it was generated is reported as token-age-unknown, which makes the run incomplete: a run that cannot tell how old its evidence is cannot claim the palette it checked is the palette that ships.

Input: the policy document

{
  "schemaVersion": "1",
  "thresholds": { "text-normal": 4.5, "text-large": 3, "non-text": 3 },
  "limits": {
    "maxAliasDepth": 8,
    "maxPairs": 500,
    "maxTokenAgeDays": 180,
    "maxTokenBytes": 1048576,
    "maxTokens": 5000
  },
  "pairs": [
    { "id": "body-text", "role": "text", "size": "normal",
      "foreground": "color.text.default", "background": "color.surface.default" },
    { "id": "control-border", "role": "non-text",
      "foreground": "color.border.control", "background": "color.surface.default" },
    { "id": "scrim-label", "role": "text", "size": "large",
      "foreground": "color.text.inverse", "background": "color.overlay.scrim",
      "backdrop": "color.surface.default" }
  ]
}
  • role is text or non-text. A text pair must declare size as normal or large; a non-text pair must not declare one.
  • The threshold key is text-normal, text-large or non-text. All three must be present, each a finite number between 1 and 21.
  • backdrop is optional and only matters when the background is translucent.

There is no built-in threshold anywhere in this tool. The figures in the example are the ones WCAG 2.2 states, and they are in the policy because that is where a threshold belongs: a team writing to a different standard is judged by the standard it wrote down. A policy that omits a threshold is a configuration error, not a fallback to a number this tool invented.

The colour values this tool reads

Form Example Read
3-digit hexadecimal #abc yes
4-digit hexadecimal with alpha #abcd yes
6-digit hexadecimal #aabbcc yes
8-digit hexadecimal with alpha #aabbccdd yes
rgb(), comma-separated, all integers rgb(17, 17, 17) yes
rgb(), comma-separated, all percentages rgb(0%, 50%, 100%) yes
rgba(), alpha as number or percentage rgba(17, 17, 17, 0.8) yes
either function with an upper-case name RGB(17, 17, 17) yes
alias to another token {color.text.default} yes
rgb() space- and slash-separated rgb(0 0 0 / 50%) no
mixed integer and percentage components rgb(10, 50%, 0) no
hsl(), hwb(), lab(), lch(), oklab(), oklch(), color() oklch(0.7 0.1 200) no
named colours, including transparent and currentColor white no
var(), calc(), color-mix(), gradients, images, inherit var(--brand) no

A value in the second half of that table is not guessed at. It is a value this tool does not know the colour of, and it is treated as exactly that.

Hexadecimal digits and function names are both matched ASCII case-insensitively, because a token value is CSS text and CSS matches a function name the way it matches every keyword. A token name, by contrast, is an identifier the document chose and is matched exactly.

The distinction that keeps the tool usable: a structurally malformed entry — no name, no string value, a name declared twice — is reported wherever it sits, because the document cannot be trusted to say what it means. A well-formed entry whose value is outside the subset is reported only when a pair depends on it. A palette may legitimately hold colours in notations this tool does not read; what it may not do is let one of them silently stand in for a colour in a comparison.

Alpha, and the backdrop rule

A translucent foreground composites over the background, which is what a browser does with text.

A translucent background composites over the pair's backdrop, but only when that backdrop resolves to an opaque colour in the token document. With no backdrop declared, or with one that is itself translucent, the stack does not end in a known colour and no ratio is computed: the pair is reported as background-unknown and the run is incomplete.

This is not caution for its own sake. rgba(255, 255, 255, 0.5) over white composites to #ffffff, and #767676 on it reaches 4.5422 — a pass at 4.5. The identical scrim over black composites to #808080, and the same foreground reaches 1.1422 — a plain failure. A tool that assumed a backdrop would be right half the time and confidently wrong the other half.

How the ratio is computed

WCAG 2.x relative luminance over sRGB, ratio (L1 + 0.05) / (L2 + 0.05), taking the lighter colour as L1. Reference points, to two decimals: #000000 on #ffffff is 21.00, #767676 on #ffffff is 4.54, #949494 on #ffffff is 3.03, #ff0000 on #ffffff is 4.00, #0000ff on #ffffff is 8.59.

The ratio is rounded to four decimal places, and the rounded value is both reported and compared. That is part of the contract rather than a display convenience: exponentiation is not guaranteed to agree in the last bit between engine builds, and an unrounded comparison could put a borderline pair on either side of a threshold depending on the machine that ran it. The consequence is stated rather than hidden — a pair whose exact ratio is 4.49999 rounds to 4.5 and meets a 4.5 threshold; one whose exact ratio is 4.4999 does not.

Rules

missing in the third column means the rule is in the evidence-missing set: it makes the whole report incomplete and the process exit 2, whatever its own severity is. The two warning rules are the reason that set is not decoration — nothing about their severity would stop the report reading pass.

Two entries the report cannot tell apart

token-duplicate compares names as this report writes them, not as the document spells them. A token name reaches location.pointer through the sanitiser, which strips C0, DEL, C1, U+2028, U+2029 and the bidi controls and collapses runs of whitespace — so c a and c<U+200E>a are two different strings that arrive as one pointer. Which value that name carries is then exactly as ambiguous as a name declared twice, and neither entry is used.

Pair ids are configuration rather than evidence, so the same collision is refused before the run starts, with the message on stderr and stdout empty. Without that refusal a finding against the second pair was written at the first pair's pointer, naming a pair whose tokens had resolved perfectly well as the one that could not be read.

The rule reaches a reference as well as a declaration. A pair may name a token the document does not declare while the document declares one this report writes the same way — c<U+200E>a against a declared c a. The verdict is unchanged, token-missing at error severity and the run incomplete, because the named token genuinely is not declared; the sentence says which of the two happened, rather than reporting that the document does not declare a name the reader can see in it.

Rule Severity Evidence Meaning
background-unknown error missing A translucent background with no backdrop, or with a backdrop that is itself translucent. No ratio was computed.
no-pairs-checked error missing No pair produced a ratio and no other rule said why. A backstop against a pass on no evidence.
non-text-contrast-insufficient error — A non-text pair fell below the policy's non-text threshold.
text-contrast-insufficient error — A text pair fell below the policy's text-normal or text-large threshold.
token-age-unknown warning missing The token document does not say when it was generated, or is dated after the run's instant.
token-alias-cycle error missing An alias trail returns to a token it already visited.
token-alias-depth-exceeded error missing An alias trail is longer than limits.maxAliasDepth.
token-duplicate error missing More than one token entry reaches the report under one name, so which value that name carries is ambiguous. None of them was used.
token-invalid error missing A token entry is not an object, has no usable name, or has no string value.
token-limit-exceeded error missing The document holds more entries than limits.maxTokens. Nothing was read.
token-missing error missing A pair names a token the document does not declare.
token-value-unsupported error missing A pair depends on a token whose value is outside the supported subset.
tokens-invalid error missing The token document is not an object, or has no tokens array.
tokens-not-utf8 error missing The token document's bytes are not valid UTF-8.
tokens-schema-unsupported error missing The token document declares a schemaVersion this tool does not read.
tokens-stale warning missing The token document is older than limits.maxTokenAgeDays.
tokens-too-large error missing The token document is larger than limits.maxTokenBytes.
tokens-unparsable error missing The token document is not valid JSON.
tokens-unreadable error missing The token document could not be read at all.

Exit codes

Code Meaning
0 Every declared pair produced a ratio and every ratio met its threshold.
1 The check completed and at least one pair fell below its threshold.
2 Invalid configuration, or evidence the check could not obtain.

Exit 2 has two shapes, and a consumer that pipes stdout must handle both:

Situation stdout stderr status
A problem with the policy document, an unknown option, bad usage empty the message no report
A token document that could not be read, decoded, parsed or resolved a report optional diagnostics incomplete

The reasoning is the report contract's: a configuration error means the run never had a subject, so there is nothing to report about. Unreadable evidence means the run had a subject and failed to obtain evidence about it, which is what incomplete exists to say — and a consumer needs that report to know which input was not read.

Limits

Every limit is declared by the policy and every one is enforced. There are no defaults: a policy that omits a limit is rejected, so a documented bound cannot quietly stop being applied.

Limit Meaning Ceiling
maxAliasDepth Alias hops followed before the trail is abandoned 64
maxPairs Pairs one policy may declare 5000
maxTokenAgeDays How old the token document may be, measured against --now 3650
maxTokenBytes Size of the token document 33554432
maxTokens Entries in the token document 50000

A pair id, a token name and every other identifier is bounded at 128 characters: a longer one is not usable, and it is reported as token-invalid or refused by the policy rather than shortened. An evidence string is bounded at 200 characters and IS shortened with an ellipsis when it is longer, because it is an excerpt rather than a value anything depends on. Exceeding one of the document limits in the table above is an incomplete result naming the limit — never a silent truncation and never a pass.

Determinism

  • Findings sort by (location.file, location.pointer, ruleId, message), each compared by UTF-16 code unit. No localeCompare, no Intl.Collator: collation depends on ICU data that varies between Node builds, and it disagrees with code-unit order about Z versus a and about - versus _.
  • No wall clock is read except through --now, whose default is the only reading.
  • Running the tool twice over identical inputs produces byte-identical stdout.
  • Every untrusted string that reaches output — token names, pair ids, values, paths, messages, evidence — is stripped of C0, DEL, C1, U+2028, U+2029 and the bidi controls, then bounded. U+2028 and U+2029 are additionally escaped in the emitted JSON.

Verification

npm run check     # lint, tests, the runnable example, and a packaging dry run
npm test          # the test suite alone

The suite covers the acceptance criteria item by item in test/acceptance.test.mjs, and each guarantee in this README is pinned by a test that fails when the guarantee is removed. test/catalog.test.mjs asserts the rule table above against the code in both directions, so a rule cannot be added, renamed or re-graded without this document changing with it.

License

MIT. See LICENSE.

About

Check contrast tokens for text, controls, borders and non-text indicators.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages