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.
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.
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 —
applicationCategoryDesignApplication,operatingSystemAnyandbrowserRequirements“Requires JavaScript and a modern web browser”. It carries noOffer. - BreadcrumbList — Home → Tools (
/tool/general/) → WCAG Color Contrast Checker. The visible.cc-crumbnav 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-ratiois on the number span, not the.cc-ratiowrapper. The module assignstextContentto it. Moved up one level, that assignment would also erase the “Contrast ratio” label that sits beside the number.#cc-fixis on the button, not the.cc-fixrow. The module toggles thehiddenproperty on it. Any author rule that setsdisplayon that element would outrank the user-agent[hidden]rule and pin the button visible. Keeping the id on the button, which carries nodisplayrule of its own, keepshiddeneffective 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);
}
- Read the pair back out of the DOM.
- Compute the ratio once.
- Write
N.NN:1into#cc-ratio. - Grade all five rows by iterating
Object.keys(THRESHOLDS). - Set
--cc-fgand--cc-bginline on#cc-preview. - Hide the fix button when the ratio already clears 4.5.
- Clear the status line.
- 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
- Pick an endpoint. Compute the background's relative luminance. If it is above 0.5 the endpoint is pure black; otherwise pure white.
- Walk the line. Interpolate the current foreground toward that endpoint with
tstepping from 0 to 1 in increments of 0.02 — about 51 samples. The loop bound ist <= 1.0001rather thant <= 1to absorb float drift in the accumulated addition. - Take the first hit. The first candidate whose contrast against the unchanged background reaches 4.5 wins, and the loop breaks.
- Fall back. If nothing on the line qualifies, use the pure endpoint.
- 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. Becauserender()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.
#808080has 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.
- Add a row inside
#cc-gradeswhose badge carriesdata-grade="your-key". - Add
'your-key': <number>toTHRESHOLDS.
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(), nohsl(), 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
#000000or#FFFFFFrather than blanking the readout. - Grades compare inclusively against the full float while the readout is rounded to two decimals,
so
4.50:1can sit next to a Fail badge. #cc-fixis toggled with thehiddenproperty; author CSS that setsdisplayon that id would pin the button visible.#cc-ratiois on the inner number span; moving it to the wrapper lets thetextContentwrite erase the label beside it.- The preview needs its
.cc-preview-anchoredcolor: inheritrules to survive dark mode. - The URL is rewritten with
replaceState, so back and forward do not step through colour history. - Removing
data-cp-skipfrom either swatch breaks it, because the custom picker cannot see the programmatic.value =writes this tool performs.