Skip to main content
Auric Artisan · Documentation

Color Contrast Checker Developer Reference

A source-level map of tool/contrast-checker/index.html, js/tool/contrast-checker/index.js and css/contrast-checker.css: the DOM contract, the WCAG 2.1 maths, the threshold table, the URL state, the “make it pass” search, and the seams that hold it to the rest of the site.

Updated: September 3, 2026 Route: /tool/contrast-checker/ Runtime: js/tool/contrast-checker/index.js Reading time: about 18 minutes Author: Chirag Bansal
Back to Documentation Auric Artisan Home

Overview

The Color Contrast Checker is the smallest shipping tool on the site. One route, one ES module of 162 lines, one stylesheet of 193 lines, no imports, no exports, no network calls and no storage. You give it a text colour and a background colour as hex; it prints the WCAG 2.1 contrast ratio to two decimals, grades that ratio against five thresholds, repaints a specimen panel in the pair, and writes the pair into the query string so the address bar is always a permalink.

Because it is small, its contracts are unusually explicit. The module reaches the page through eleven element ids and one [data-grade] attribute set, and nothing else. Two of those ids are on the element they are on for a reason that is commented in the markup, and moving them breaks the tool in ways that are quiet rather than loud. This reference covers the module boundary, the colour maths and its exact formulas, the threshold table, the render pipeline, the URL state, the “Make it pass AA” search, the styling seams, and the places the rest of the site links into this one.

Everything below is drawn from the shipping source. Where a signature or a constant is quoted, it is quoted verbatim.

Table of contents

  1. 1. Runtime overview and file map
  2. 2. Page shell, head and structured data
  3. 3. DOM contract
  4. 4. Colour maths
  5. 5. Thresholds and grading
  6. 6. The render pipeline
  7. 7. URL state and sharing
  8. 8. Make it pass AA
  9. 9. Event wiring and boot sequence
  10. 10. Styling contract
  11. 11. Integration with the rest of the site
  12. 12. Extending it, and what it is not

1. Runtime overview and file map

+

The tool is a static page plus one ES module. The module is loaded with <script type="module"> alongside /js/unified.js and has zero import statements and zero export statements. It runs under 'use strict' and installs no inline handlers, which is what its header comment means by “CSP-safe”.

There is no state object, no store and no framework. The DOM is the state: the two text inputs hold the pair, and every code path reads them back through currentFg() and currentBg(). Nothing is persisted; the only place the pair survives a reload is the query string.

File Lines Role
tool/contrast-checker/index.html — Route, SEO head, three JSON-LD graphs, the DOM the module binds to, the explainer, the FAQ disclosures and the next-step cards.
js/tool/contrast-checker/index.js 162 The entire runtime: hex parsing, luminance, contrast, grading, render, URL sync, the accessible-fix search, clipboard share and wiring.
css/contrast-checker.css 193 The only tool-specific stylesheet. Everything hangs off the .cc / .cc-* namespace and reuses site tokens.
js/unified.js — Site chrome. Resolves the data-include partials and boots shared modules. Not imported by the tool module.
js/generated-inline/771583026d107068e662.js — Render-blocking theme bootstrap. Sets data-theme on <html> before first paint.

What is not in the dependency graph is as informative as what is. The module does not touch js/components/color-picker.js, the workspace helpers, the share helpers, the library asset store or localStorage. It cannot be broken by a change to any of them, and it gains nothing from one either.

2. Page shell, head and structured data

+

Stylesheets and scripts

Four stylesheets load in this order: /css/main.css, /css/unified.css, /css/dark-mode.css, /css/contrast-checker.css. The tool sheet is last, so its rules win ties against the site sheets at equal specificity. Two module scripts close the body: /js/unified.js then /js/tool/contrast-checker/index.js.

Theme bootstrap and its storage keys

The page includes the shared generated-inline bootstrap as a classic, render-blocking script so the theme is resolved before paint. It reads three sources in a fixed order and falls through to the OS preference.

Order Source Accepted values
1 ?__theme= query override light | dark
2 localStorage.aa_user_settings → themeMode light | dark | system
3 localStorage.aa_theme JSON string or bare string
4 matchMedia('(prefers-color-scheme: dark)') fallback

The bootstrap sets data-theme on the document element and, when the resolved theme is dark, adds the class dark-mode-pending. Both matter to this tool: section 10 shows the badge tokens keying off [data-theme="dark"] and body.dark-mode.

Site chrome

The page pulls the shared header, the clock strip, the quick-links tool strip and the footer through data-include partials — /partials/header.html, /partials/time.html, /partials/tool.html and /partials/footer.html — which /js/unified.js resolves.

Structured data

Two JSON-LD blocks sit in the head: the managed SEO @graph, which also carries ImageObject and WebPage nodes, and a separate FAQPage block. Their content is asserted elsewhere on the page, which is the point.

  • WebApplication — applicationCategory DesignApplication, operatingSystem Any and browserRequirements “Requires JavaScript and a modern web browser”. It carries no Offer.
  • BreadcrumbList — Home → Tools (/tool/general/) → WCAG Color Contrast Checker. The visible .cc-crumb nav links Tools to /collections/ instead, so the two no longer mirror each other.
  • FAQPage — the same four questions and answers that are rendered as <details> elements in .cc-faq. A comment above the block records the rule: structured data that describes content a visitor cannot see is the thing search engines penalise.

The page is index, follow for both Googlebot and Bingbot, carries a canonical plus en and x-default hreflang all pointing at https://auricartisan.com/tool/contrast-checker/, and is listed in sitemap/sitemap-tools.xml. It also has a full entry in the client search index under the id tool/contrast-checker/index.html.

3. DOM contract

+

Every element the module needs is resolved once, at module scope, into a single els object. Those eleven references are never re-queried and there is no delegation, so an element swapped into the page after load is not picked up.

const $ = (id) => document.getElementById(id);

const els = {
  fg: $('cc-fg'), bg: $('cc-bg'),
  fgPick: $('cc-fg-picker'), bgPick: $('cc-bg-picker'),
  swap: $('cc-swap'),
  ratio: $('cc-ratio'),
  grades: $('cc-grades'),
  preview: $('cc-preview'),
  fix: $('cc-fix'), fixMsg: $('cc-fix-msg'), share: $('cc-share')
};
Key Element id Element How the module uses it
fg cc-fg input[type=text], default #1A1400 Reads .value as the foreground; writes it on swap, on fix and on link restore.
bg cc-bg input[type=text], default #F7F3E6 Same, for the background.
fgPick cc-fg-picker input[type=color], default #1a1400, data-cp-skip Mirrored from the text field; its own input event writes back.
bgPick cc-bg-picker input[type=color], default #f7f3e6, data-cp-skip Same, for the background.
swap cc-swap button, aria-label “Swap text and background colours” click exchanges the two text values, then renders.
ratio cc-ratio span.cc-ratio__num, aria-live="polite" textContent set to N.NN:1 on every render.
grades cc-grades Container of five .cc-grade rows Queried per grade with [data-grade="…"].
preview cc-preview Specimen panel Receives the --cc-fg and --cc-bg custom properties inline.
fix cc-fix button.cc-btn--primary, starts hidden .hidden toggled on every render; click runs makeAccessible().
fixMsg cc-fix-msg span, role="status" aria-live="polite" Cleared on every render; written by the fix and share actions.
share cc-share button.cc-btn click runs copyShare().

The badge sub-contract

Inside #cc-grades, each row carries a .cc-badge with a data-grade attribute. The attribute values are the keys of the THRESHOLDS table — normal-aa, normal-aaa, large-aa, large-aaa, ui — and the match is exact.

Inert on other pages

init() begins with if (!els.fg) return;. If the module is loaded on a page without #cc-fg it binds nothing and does nothing.

Two id placements that are load-bearing

Both are commented in the markup, because both fail quietly.

  • #cc-ratio is on the number span, not the .cc-ratio wrapper. The module assigns textContent to it. Moved up one level, that assignment would also erase the “Contrast ratio” label that sits beside the number.
  • #cc-fix is on the button, not the .cc-fix row. The module toggles the hidden property on it. Any author rule that sets display on that element would outrank the user-agent [hidden] rule and pin the button visible. Keeping the id on the button, which carries no display rule of its own, keeps hidden effective while the row keeps its layout class.

4. Colour maths

+

Five pure functions carry the whole numeric side. None of them touch the DOM.

Signature Returns Notes
normHex(v) string | null Canonical #RRGGBB uppercased, or null.
toRgb(hex) { r, g, b } Integers 0–255. Assumes a valid six-digit hex with a leading #.
toHex({ r, g, b }) string Clamps, rounds, pads and uppercases — accepts fractional channels.
luminance({ r, g, b }) number WCAG 2.x sRGB relative luminance, 0–1.
contrast(hexA, hexB) number Ratio, 1–21, order-independent.

normHex

function normHex(v) {
  let h = String(v || '').trim().replace(/^#/, '');
  if (/^[0-9a-fA-F]{3}$/.test(h)) h = h.split('').map((c) => c + c).join('');
  return /^[0-9a-fA-F]{6}$/.test(h) ? `#${h.toUpperCase()}` : null;
}

It trims, drops a single leading #, doubles each character of a three-digit shorthand, and returns the canonical form only if what is left is exactly six hex digits. Everything else returns null: rgb(), hsl(), named colours and eight-digit alpha hex all fail. This one function is the tool's entire input grammar.

toRgb and toHex

function toRgb(hex) {
  const n = parseInt(hex.slice(1), 16);
  return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
}
function toHex({ r, g, b }) {
  const c = (x) => Math.max(0, Math.min(255, Math.round(x))).toString(16).padStart(2, '0');
  return `#${c(r)}${c(g)}${c(b)}`.toUpperCase();
}

toRgb parses the six digits as one integer and shifts the channels out. toHex clamps to 0–255, rounds and pads, which is what lets the interpolation in section 8 hand it fractional channels and get valid hex back.

Relative luminance

function luminance({ r, g, b }) {
  const lin = (c) => { c /= 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); };
  return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
}

This is the WCAG 2.x sRGB formula verbatim. Each channel is scaled to 0–1, linearised with the piecewise transfer function — divide by 12.92 below the 0.03928 knee, otherwise ((c + 0.055) / 1.055)2.4 — and the three linear channels are weighted 0.2126, 0.7152 and 0.0722.

Contrast ratio

function contrast(hexA, hexB) {
  const L1 = luminance(toRgb(hexA));
  const L2 = luminance(toRgb(hexB));
  return (Math.max(L1, L2) + 0.05) / (Math.min(L1, L2) + 0.05);
}

(Llighter + 0.05) / (Ldarker + 0.05). Because the function picks the max and min itself, argument order does not matter, and the result is bounded between 1 (two identical colours) and 21 (pure black against pure white).

There is no alpha channel anywhere in this module, so there is no compositing over a third colour. A translucent foreground has to be flattened before it can be checked here.

5. Thresholds and grading

+

The five verdicts come from one object literal.

const THRESHOLDS = {
  'normal-aa': 4.5, 'normal-aaa': 7, 'large-aa': 3, 'large-aaa': 4.5, 'ui': 3
};
Key / data-grade Threshold Row label in the markup What it covers
normal-aa 4.5 Body text · AA Body copy at Level AA — the number most laws and contracts point at.
normal-aaa 7 Body text · AAA Body copy at Level AAA.
large-aa 3 Large text · AA At least 18.66px bold or 24px regular, at Level AA.
large-aaa 4.5 Large text · AAA The same size threshold at Level AAA.
ui 3 UI & graphics Interface components, focus indicators and meaningful graphics.

“Large” here is a size threshold, not a heading rule. The page states this twice — once in the .cc-rules list and once in the FAQ — and the FAQPage JSON-LD carries the same wording, because a large heading in a light weight still falls under the body-text requirement.

setBadge

function setBadge(grade, ratio) {
  const el = els.grades.querySelector(`[data-grade="${grade}"]`);
  if (!el) return;
  const pass = ratio >= THRESHOLDS[grade];
  el.textContent = pass ? 'Pass' : 'Fail';
  el.classList.toggle('is-pass', pass);
  el.classList.toggle('is-fail', !pass);
}

It resolves the badge by attribute, sets the word, and toggles the two state classes that section 10 colours. A missing node is a silent no-op, so a badge deleted from the markup does not throw — it just stops being graded.

The comparison is inclusive: ratio >= THRESHOLDS[grade]. Exactly 4.50 passes. Note that the displayed number is rounded to two decimals while the comparison uses the full float, so a ratio of 4.4996 prints as 4.50:1 next to a Fail badge. That is correct, and it looks like a bug the first time you see it.

6. The render pipeline

+

render() is the only function that writes to the interface. Every event handler ends in a call to it, and it always does the same eight things in the same order.

function render() {
  const fg = currentFg();
  const bg = currentBg();
  const ratio = contrast(fg, bg);

  els.ratio.textContent = `${ratio.toFixed(2)}:1`;
  for (const grade of Object.keys(THRESHOLDS)) setBadge(grade, ratio);

  els.preview.style.setProperty('--cc-fg', fg);
  els.preview.style.setProperty('--cc-bg', bg);

  // Show the fix button only when body text fails AA.
  els.fix.hidden = ratio >= 4.5;
  els.fixMsg.textContent = '';

  // Sync pickers (only with valid hex) without clobbering typing.
  if (normHex(els.fg.value)) els.fgPick.value = fg.toLowerCase();
  if (normHex(els.bg.value)) els.bgPick.value = bg.toLowerCase();

  syncUrl(fg, bg);
}
  1. Read the pair back out of the DOM.
  2. Compute the ratio once.
  3. Write N.NN:1 into #cc-ratio.
  4. Grade all five rows by iterating Object.keys(THRESHOLDS).
  5. Set --cc-fg and --cc-bg inline on #cc-preview.
  6. Hide the fix button when the ratio already clears 4.5.
  7. Clear the status line.
  8. Mirror the two native swatches, then hand the pair to syncUrl.

Input fallbacks

function currentFg() { return normHex(els.fg.value) || '#000000'; }
function currentBg() { return normHex(els.bg.value) || '#FFFFFF'; }

An unparseable field does not blank the readout. It measures against pure black or pure white instead. This keeps the tool responsive while someone is halfway through typing a hex, but it also means a half-typed value produces a plausible-looking number that is not about the colour on screen. If you ever add a validation state to the fields, this is the pair of functions it has to hang off.

The picker sync guard

The two swatch writes are guarded by if (normHex(els.fg.value)) rather than using the fallback value. Without the guard, typing #1A would drive the swatch to black on the second keystroke. With it, the swatch simply holds its last good value until the field parses again. Note the case handling: input[type=color] requires lowercase, so the value is lowercased on the way in, while the text field is kept uppercase.

No debounce on render

render() runs synchronously on every keystroke. Only the URL write is debounced. The work per call is five badge queries and two style writes, which is cheap enough that throttling it would only add latency.

7. URL state and sharing

+

The query string is the tool's only persistence layer and its only export format.

Parameter Shape Meaning
fg Six hex digits, uppercase, no leading # Text colour. Also accepts three digits on the way in, via normHex.
bg Same Background colour.

A complete link therefore looks like /tool/contrast-checker/?fg=D3AF37&bg=0D0D0D. The # is stripped because it would otherwise start the fragment.

Writing: syncUrl

let urlTimer;
function syncUrl(fg, bg) {
  clearTimeout(urlTimer);
  urlTimer = setTimeout(() => {
    const u = new URL(location.href);
    u.searchParams.set('fg', fg.slice(1));
    u.searchParams.set('bg', bg.slice(1));
    history.replaceState(null, '', u);
  }, 250);
}

One module-level timer, a 250 ms debounce, and history.replaceState — not pushState. The address bar is always current, no history entries accumulate, and nothing reloads. The trade is that back and forward will not step through a colour history; there is no undo.

Because new URL(location.href) is used as the base, any other query parameter already on the URL survives the rewrite, and so does the fragment.

Reading: initFromUrl

function initFromUrl() {
  const p = new URLSearchParams(location.search);
  const fg = normHex(p.get('fg'));
  const bg = normHex(p.get('bg'));
  if (fg) { els.fg.value = fg; els.fgPick.value = fg.toLowerCase(); }
  if (bg) { els.bg.value = bg; els.bgPick.value = bg.toLowerCase(); }
}

Each side is assigned only if it parses, independently. A partial deep link — ?fg= alone, which is exactly what the search page's single-colour answer card emits — leaves the other side on its markup default. A junk parameter is ignored rather than treated as an error.

copyShare

async function copyShare() {
  const url = location.href;
  try {
    await navigator.clipboard.writeText(url);
    els.fixMsg.textContent = 'Share link copied.';
  } catch {
    els.fixMsg.textContent = url;
  }
}

It copies location.href, which already carries the parameters because render() put them there. The bare catch covers an insecure context or a denied clipboard permission by printing the URL into the status line so it can be selected by hand. The status line is role="status" aria-live="polite", so either outcome is announced.

What you can get out of the tool

Output Mechanism
Shareable deep link “Copy link” (#cc-share) puts location.href on the clipboard; on failure the URL is printed into #cc-fix-msg.
Live permalink Every render rewrites ?fg= and ?bg=, so the address bar is always current.
Corrected hex “Make it pass AA” writes the adjusted colour into #cc-fg and reports it as text in #cc-fix-msg.

There is no CSS, JSON, SVG or token export here, and no save-to-library. If the tab closes without a copied link, the pair is gone.

8. Make it pass AA

+

makeAccessible() is the one place the tool changes a colour on the user's behalf. It is a linear search along a straight line in 8-bit sRGB space, and it is deliberately simple.

function makeAccessible() {
  const bg = currentBg();
  const start = toRgb(currentFg());
  const bgLum = luminance(toRgb(bg));
  // Move toward black if bg is light, toward white if bg is dark.
  const target = bgLum > 0.5 ? { r: 0, g: 0, b: 0 } : { r: 255, g: 255, b: 255 };

  let best = null;
  for (let t = 0; t <= 1.0001; t += 0.02) {
    const cand = {
      r: start.r + (target.r - start.r) * t,
      g: start.g + (target.g - start.g) * t,
      b: start.b + (target.b - start.b) * t
    };
    if (contrast(toHex(cand), bg) >= 4.5) { best = toHex(cand); break; }
  }
  if (!best) best = toHex(target);

  els.fg.value = best;
  els.fgPick.value = best.toLowerCase();
  render();
  els.fixMsg.textContent = `Adjusted text to ${best} (${contrast(best, bg).toFixed(2)}:1).`;
}

The algorithm, step by step

  1. Pick an endpoint. Compute the background's relative luminance. If it is above 0.5 the endpoint is pure black; otherwise pure white.
  2. Walk the line. Interpolate the current foreground toward that endpoint with t stepping from 0 to 1 in increments of 0.02 — about 51 samples. The loop bound is t <= 1.0001 rather than t <= 1 to absorb float drift in the accumulated addition.
  3. Take the first hit. The first candidate whose contrast against the unchanged background reaches 4.5 wins, and the loop breaks.
  4. Fall back. If nothing on the line qualifies, use the pure endpoint.
  5. Write and report. Set both the text field and the swatch, re-render, then write Adjusted text to #XXXXXX (N.NN:1). into the status line. Because render() clears #cc-fix-msg, the message is set after the render, not before.

What it will and will not do

  • It targets 4.5:1 and nothing else. There is no aim-for-3 and no aim-for-7.
  • It only moves the foreground. The background is never touched, which is usually what you want when the background is a brand surface.
  • The direction test is on relative luminance, not perceived lightness. #808080 has a luminance of about 0.216, so a mid-grey background counts as “dark” and the text is pushed toward white.
  • It is a coarse scan toward pure black or pure white, not a minimal-perceptual-change or hue-preserving solve. Expect a desaturated result. If you want hue preservation, that is a different search — and a different tool.

Visibility

The button is not a permanent control. render() sets els.fix.hidden = ratio >= 4.5, so it appears only while body text fails AA and disappears the moment the fix lands. That is why the id has to be on the button rather than the row — see section 3.

9. Event wiring and boot sequence

+

bindPair(textEl, pickEl)

function bindPair(textEl, pickEl) {
  textEl.addEventListener('input', render);
  textEl.addEventListener('blur', () => { const h = normHex(textEl.value); if (h) textEl.value = h; render(); });
  pickEl.addEventListener('input', () => { textEl.value = pickEl.value.toUpperCase(); render(); });
}

Three listeners per colour, and the same function is used for both pairs.

Target Event Effect
Text field input Render. No normalisation, so partial typing is left alone.
Text field blur Normalise the field to canonical #RRGGBB if it parses, then render. This is where abc becomes #AABBCC.
Swatch input Copy pickEl.value.toUpperCase() into the text field, then render. Fires continuously while the OS colour dialog is dragged.

init()

function init() {
  if (!els.fg) return;
  initFromUrl();
  bindPair(els.fg, els.fgPick);
  bindPair(els.bg, els.bgPick);
  els.swap.addEventListener('click', () => {
    const f = els.fg.value; els.fg.value = els.bg.value; els.bg.value = f;
    render();
  });
  els.fix.addEventListener('click', makeAccessible);
  els.share.addEventListener('click', copyShare);
  render();
}

if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init, { once: true });
else init();

The order matters in one place: initFromUrl() runs before the listeners are attached, so restoring a shared link does not fire an input event and does not render twice. The single trailing render() paints the restored state.

The swap handler exchanges the two raw field values, not the normalised ones. Swapping a half-typed value therefore moves the half-typed value across, which is consistent with the rest of the tool leaving unparseable text where the user put it.

The module tail runs init() immediately when the document has already parsed, which is the normal case for a deferred module at the end of the body, and defers to DOMContentLoaded with { once: true } otherwise.

10. Styling contract

+

Layout

.cc-app is a two-column grid with named areas: "controls result" over a full-width "preview". It also sets align-items: start, which is load-bearing. The result card carries five grade rows against the controls card's two inputs; without it the controls card stretches to the taller row and its align-items: flex-end sinks the inputs to the bottom of a mostly empty box.

Under 720px the grid collapses to a single column in controls / result / preview order, .cc-controls becomes a column, the swap button rotates 90° to match the stacked layout, and the next-step cards drop to one column.

The preview seam

The JavaScript never sets a concrete colour on the preview. It sets two custom properties, and the stylesheet consumes them:

.cc-preview {
  background: var(--cc-bg, #f7f3e6);
  color: var(--cc-fg, #1a1400);
}
.cc-preview .cc-preview__lg,
.cc-preview .cc-preview__sm,
.cc-preview .cc-preview__ui { color: inherit; }

The second rule is not decoration. The site's blanket body.dark-mode p rule paints every paragraph #ddd, and it beat plain inheritance inside the panel — so in dark mode the specimen showed the user's background under the theme's ink and reported a pair nobody had asked about. Re-anchoring on .cc-preview outranks the blanket rule without touching it. The interface chip's border tracks var(--cc-fg) for the same reason.

Pass and fail ink

The badge colours are a token pair, not a single hex, and they follow the standard three-state theme pattern.

Token Light Dark
--cc-pass-ink #136b39 #4ade80
--cc-fail-ink #b91c1c #fca5a5

The dark values are declared twice — once under @media (prefers-color-scheme: dark) scoped to :root:not([data-theme="light"]), and once under :root[data-theme="dark"], body.dark-mode — so an explicit toggle wins in both directions. The comment records why: the original single-hex pair sat on the dark badge surface at 3.37:1, which meant the badges on a page about contrast were failing the rule the page exists to teach, and the accessibility suite never caught it because it only runs in light mode.

Focus

The visible control is the row, not the inner text box, which has no border of its own. So the inner input drops its outline with :focus { outline: none } and .cc-input__row:focus-within draws a 2px gold outline with a 2px offset around the whole row. The indicator is moved outwards, not removed.

Tokens consumed

--color-text, --color-text-muted, --color-bg, --color-bg-card, --color-border, --color-gold, --color-gold-ink and --font-mono. Only --color-gold, --color-gold-ink and --font-mono carry a literal fallback; the rest are used bare.

11. Integration with the rest of the site

+

The custom colour picker opt-out

The site replaces every input[type=color] with its own picker. Both swatches here carry data-cp-skip so they stay native, and the guard sits on the factory:

export function createColorPicker(input) {
  if (input._aaColorPicker) return input._aaColorPicker;
  if (input.hasAttribute("data-cp-skip")) return null;
  const picker = new ColorPicker(input);
  input._aaColorPicker = picker;
  return picker;
}

initAllColorPickers(scope) maps over the factory and filters the nulls, and _replaceInput on the auto-init path returns early on the same attribute. The guard is on the factory precisely because the check used to live only in _replaceInput, while js/system/auto-mapper.js reaches these inputs through initAllColorPickers — so the documented opt-out worked down one route and was silently ignored down the other, and the other is the one that runs on a normal page load.

Keeping the native swatch is not laziness. The custom picker mirrors the native input one way only: it cannot observe a programmatic .value = write, which fires no event and mutates no attribute. This tool writes .value on swap, on “Make it pass” and on link restore, so the custom swatch would show black while the value was #B91C1C. Removing data-cp-skip reintroduces that.

Inbound deep links from search

search/client/search-answers.js maps the literal queries contrast and contrast checker to this tool in its TOOL_ANSWERS table. search/client/search-ui.js then emits two link shapes from its colour answer cards:

Answer card Link
Single colour — “Check it against a background” /tool/contrast-checker/?fg=<hex without #>
Contrast pair — “Open the checker” /tool/contrast-checker/?fg=<fg>&bg=<bg>

A comment at the first of those records why the checker was chosen: the picker route it used to open 301s to a tools index and drops the colour on the way. This tool is a real page that reads fg from the query string, and section 7 is the contract that makes that safe.

Other inbound links

Source Where
Homepage “Open the full checker” under the live demo section (index.html).
Dashboard The “Start something” launcher row in js/common/dashboard-views.js.
Offline page The route-name map in offline.html.
404 page The suggested-tools list.
Learn library Tool CTAs in fifteen articles, including the WCAG contrast article.

Outbound, the page ends with three cards pointing at /tool/analyzer/ for whole-page contrast failures, /tool/basic-tools/ for palettes and image colour extraction, and /library/learn/ for the rules themselves.

Test coverage

Spec What it asserts
tests/website/accessibility.spec.js The route is in the site accessibility sweep list, with empty desktop and mobile baselines in accessibility-baseline.json.
tests/ui/clock-strip.spec.js One of three routes the clock-strip spec exercises.
tests/ui/not-found.spec.js The 404 page offers this route as a suggestion.
tests/ui/search-page.spec.js The exact deep-link href /tool/contrast-checker/?fg=D3AF37&bg=0D0D0D.
js/common/test-lab.js The route is a one-press preset in the in-app test lab.

Note what is not covered: there is no unit test over the colour maths. If you change luminance, contrast or makeAccessible, nothing in the suite will tell you.

12. Extending it, and what it is not

+

Adding a sixth threshold

Two places, and they have to agree on the key.

  1. Add a row inside #cc-grades whose badge carries data-grade="your-key".
  2. Add 'your-key': <number> to THRESHOLDS.

render() iterates Object.keys(THRESHOLDS), so nothing else needs touching. Get the key wrong in one of the two and the failure is silent: setBadge returns early on a missing node, so the row simply never leaves its — placeholder.

Accepting another colour format

normHex is the single parser. Every entry point — the text fields, the swatches, the query string — goes through it, so widening it widens all three at once. Two things to keep in mind: currentFg/currentBg assume the return value is a six-digit hex that toRgb can slice, and syncUrl assumes it can strip one leading character to get a URL-safe token.

Changing the fix target

The 4.5 constant appears in two places that are not the threshold table: the loop test inside makeAccessible and the visibility gate in render(). The button's label in the markup, “Make it pass AA”, names the same target without repeating the number. Changing one without the other gives you a button that offers a fix it does not deliver, or a fix that runs when the button is hidden.

What this module deliberately is not

  • WCAG 2.x only. There is no APCA / Lc, no WCAG 3, no ΔE and no colour-vision simulation here. The APCA maths lives in a different tool, in js/tool/accessibility/accessibility.js, and whole-page auditing lives in /tool/analyzer/. The page's own FAQ warns that the 2.1 formula ignores font weight and the way light text on dark backgrounds appears to spread.
  • Hex only, opaque only. No rgb(), no hsl(), no named colours, no eight-digit alpha hex, and no compositing over a third colour.
  • No persistence. No localStorage, no history, no save-to-library, no eyedropper and no palette import. The query string is all there is.
  • No custom events. The module dispatches nothing and listens for nothing beyond its own nine DOM listeners. Other modules cannot observe it, and it observes nothing.

Traps worth remembering

  • An unparseable field silently measures against #000000 or #FFFFFF rather than blanking the readout.
  • Grades compare inclusively against the full float while the readout is rounded to two decimals, so 4.50:1 can sit next to a Fail badge.
  • #cc-fix is toggled with the hidden property; author CSS that sets display on that id would pin the button visible.
  • #cc-ratio is on the inner number span; moving it to the wrapper lets the textContent write erase the label beside it.
  • The preview needs its .cc-preview-anchored color: inherit rules to survive dark mode.
  • The URL is rewritten with replaceState, so back and forward do not step through colour history.
  • Removing data-cp-skip from either swatch breaks it, because the custom picker cannot see the programmatic .value = writes this tool performs.