Version 1.0.0

Contrast & Target Audit

Product price:
$0.00 USD
0Runtime dependencies
4Checks per run
22+Node version for the runner
MITLicence

About Contrast & Target Audit

A single JavaScript file that measures the page in front of it and hands back JSON: text below the WCAG AA contrast threshold, interactive targets under 24px, links that go nowhere, and horizontal overflow with the elements causing it.

No dependencies. No build step. No browser download. Nothing to configure before the first run.

The reason it exists

Most contrast checkers read background-color off the element holding the text — or off the nearest ancestor that has one — and compare it to color. On a flat design that is right. On a layered one it is wrong, and it is wrong quietly. You get a number, the number looks plausible, and it describes a colour that is not on the screen.

Take a translucent panel:

.panel   { background: rgba(0, 0, 0, 0.04); }
.panel p { color: #a8a8a8; }

The paragraph’s own background is transparent. Its parent is 4% black — over what? The answer is: over everything above it, all the way to the canvas the browser paints.

This tool walks from the element up to <html>, collects every non-transparent background-color on the way, and composites them back down onto the canvas in paint order with source-over alpha blending. On the example above that gives #f5f5f5, and #a8a8a8 on #f5f5f5 is 2.18:1 — a failure a declared-background checker cannot see, because it never computes #f5f5f5 at all.

Every finding carries the composited foreground and background it used. Paste both into any contrast calculator and you get the same ratio back. That is the point: a finding you can check is a finding you can argue with.

Where it stops instead of guessing

A gradient or an image cannot be reduced to one colour, and pretending otherwise produces confident nonsense — a white label on a blue gradient pill reported as 1.04:1 against a transparent parent, then “fixed” into near-black on blue.

So when a pair fails and something between it and the first opaque layer paints a background-image, the finding goes into a separate unmeasured array naming the element that painted it. It tells you where to look. It does not tell you the answer, because it does not have one. An empty unmeasured array means every failing pair was measured on real colours.

The four checks

Contrast. Every element with its own text node, color against the composited background, WCAG 2.x relative luminance. Large text (24px, or 18.66px at weight 700+) is held to 3:1 at AA; everything else to 4.5:1. --level AAA raises both. A translucent color is composited over its background first, so rgba(255,255,255,.46) is measured as what it renders as, not as white.

Target size. a button input select textarea summary under 24px, the WCAG 2.2 SC 2.5.8 minimum. Three deliberate exclusions, each of which exists because the in-house version got it wrong first:

  • An inline <a> inside a sentence is exempt, and the spec says so.
  • A checkbox or radio inside a <label> is measured as the label, because clicking the label activates the input. Without this, a 44px filter row full of 20px checkboxes fails on every run, forever. measuredVia records which rect was used.
  • aria-hidden, [hidden] and [disabled] controls are skipped — a 1×1 honeypot is not a target anyone has to hit. This does not carry over to the contrast check: an aria-hidden glyph is still visible, and still has to be readable.

Dead links. href that is empty, #, a javascript: no-op, or a fragment whose target id is not in the document. Each one comes back with its reason.

Horizontal overflow. When scrollWidth beats clientWidth, every element whose right edge lands past the viewport is listed with that edge and its width. The first entry is usually the culprit; the rest are its parents.

Three ways to run it

Paste it into a console and call contrastTargetAudit(). This is the route for a page behind a login, or a state you can only reach by opening a drawer and clicking twice. Nothing else here can measure that.

Build a bookmarklet with node tools/build-bookmarklet.mjs. One click runs the audit, logs the object, copies the JSON to the clipboard and shows a summary line.

Run the Node runner across several URLs and widths at once:

node runner.mjs https://example.com/ https://example.com/pricing 
  --width 375 --width 768 --width 1440 
  --out report.json --pretty --fail-on-findings

The runner drives an installed Chromium — Chrome, Edge, Brave, Chromium — over the Chrome DevTools Protocol, using Node 22’s built-in global WebSocket and fetch. There is no Puppeteer here and no Playwright, so there is no browser download at install time and no dependency tree to audit. --fail-on-findings gives you an exit code for CI.

There is one option worth reading about before you use it. --init runs a script on every new document before the page’s own scripts. If your theme is picked at runtime, set it there. Setting it after load means measuring mid-transition, and a button with a 260ms background transition will hand you a ratio for a colour pair that exists for a quarter of a second and never again. That is not hypothetical — see below.

Where it comes from

This is the generalised form of tools/audit.js from the bineret.com theme, where it was the instrument for a front-end rebuild rather than a thing shipped to users. The rebuild is written up at https://bineret.com/work/rebuilding-bineret-com/.

What that means concretely, from the rebuild log:

  • The final audit covered /, /packages/, /contact/, /products/, /blog/ and /404 across light and dark at 375, 768, 1280 and 1440, and came back with 0 contrast failures, 0 dead links and 0 horizontal overflow.
  • Three accessibility bugs that predated the rebuild were found by this script, not by looking: footer links with 19–21px targets, a full-width “Apply filters” button that inherited no padding inside the mobile drawer and collapsed to a 17px bar, and a form honeypot that reported as an undersized target on every run.
  • Two regressions were caught the same way, in a later round: a directory heading link at 63×21px, and a desktop <summary> at 171×20px — a summary is focusable and clickable at every width, not only where it is styled to look like a control.
  • It also produced a false positive, repeatedly: the shop’s filter checkboxes, 20×20 inside 44px labels. That is why label-aware measurement is in this version.
  • And once, it produced a number that was real but meaningless. A dark-mode run reported three CTA buttons at 2.29:1. The theme was being switched after load, and the audit measured mid-transition — the light fill with the dark ink already applied. Set before load, the same pair measures 6.13:1. That is where --init comes from.

The site-specific parts are gone. The hardcoded admin-bar skip is now the ignore option, the two hardcoded canvas colours are now pageBackground, and the attribute name it reads for colour mode is now themeAttribute. Nothing in it knows anything about WordPress, or about any particular class name.

What it does not check

It is a spot check, not a WCAG audit, and the README says so at length. It does not look at alt text, accessible names or roles, heading order, landmarks, keyboard operability, focus order or focus visibility, non-text contrast (SC 1.4.11), motion, or the HTTP status of any link. It does not model mix-blend-mode, filter, or opacity on an ancestor — the compositing is source-over alpha only, so a parent at opacity: 0.5 changes the screen in a way the maths here does not follow. It cannot see text painted onto a canvas or a video. And it only sees what is in the DOM when you run it, so a closed modal or a second tab panel is invisible until you open it.

For conformance you want axe-core or IBM Equal Access, plus a person for the manual criteria. What you get here is four classes of problem found fast, with contrast numbers that are correct on layered designs where the usual tools are not, in a form you can diff between two runs.

Included

audit.js (the whole tool), runner.mjs (the CDP runner), tools/build-bookmarklet.mjs, two fixtures, and a test that drives a real browser against both: broken.html carries exactly one failing contrast pair, one 20px button, one href="#" and one 2000px div — all four must be found — and clean.html is the same page corrected, where every count must be zero.

MIT licensed. Node 22+ for the runner only; the console and bookmarklet routes need nothing but a browser.

Contrast & Target Audit

One JavaScript file that measures contrast against the background actually behind the text.

0Runtime dependencies
4Checks per run
22+Node version for the runner
MITLicence

Key capabilities

Composited background, not declared

Walks from the text to <html>, collects every non-transparent background-color, and composites them back onto the canvas with source-over alpha. A paragraph on a 4% black wash over white is measured against #f5f5f5, which is what the eye receives — not against the 'transparent' the element declares.

It refuses to guess

A gradient cannot be reduced to one colour. When a failing pair sits over a background-image, the finding goes into a separate 'unmeasured' array naming the element that painted it. You get 'look here', not a confident wrong number.

Target size that knows about labels

A checkbox inside a <label> is measured as the label, because clicking the label activates the input. Inline links inside a sentence are exempt per SC 2.5.8. aria-hidden and disabled controls are skipped — but never for contrast, where an aria-hidden glyph is still visible.

Runs where the bug is

Paste it into a console to audit a page behind a login or a drawer you had to open by hand. Build it into a bookmarklet for one click. Or run the Node runner across several URLs and widths in CI, with --fail-on-findings for the exit code.

No dependency chain to audit

The runner speaks the Chrome DevTools Protocol directly using Node 22's built-in global WebSocket and fetch. No puppeteer, no playwright, no post-install browser download. It drives the Chrome, Edge or Chromium you already have.

An honest boundary

The README lists what it does not check — alt text, focus order, non-text contrast, heading structure, link HTTP status, blend modes, ancestor opacity. It finds four classes of problem well. For conformance you still want axe-core and a person.

Questions & Answers

How is this different from a normal contrast checker?

Most read background-color off the element holding the text, or off the nearest ancestor that has one. On a flat design that is correct. On a layered one it is wrong and the failure is silent — you get a plausible number for a colour that is not on screen. This walks to the root, collects every non-transparent background, and composites them back down onto the canvas.

What happens when the text sits on a gradient?

It says so and stops. That finding goes into an unmeasured array with the element that painted the gradient, rather than into contrast. Guessing there is how a white label on a blue pill gets reported as 1.04:1 and then 'fixed' into near-black on blue.

Does it need Puppeteer or Playwright?

No. The runner speaks the Chrome DevTools Protocol directly over Node's built-in global WebSocket, and drives a Chromium you already have installed. There are no runtime dependencies and no post-install download.

Is this a WCAG audit?

No, and the README is explicit about it. It checks four things. It does not look at alt text, names, roles, heading order, keyboard operability, focus visibility, non-text contrast, or the HTTP status of a link. For conformance, run axe-core and have a person do the manual criteria.

Why did my checkbox stop being reported?

Because it is inside a <label> that is 44px tall, and clicking the label activates it — so the effective target passes. The measuredVia field says "label" when that happened. The in-house version of this script reported that same filter panel as failing on every single run.

Can I run it on a page behind a login?

Yes — that is what the console and bookmarklet routes are for. The Node runner starts a fresh browser profile each time and has no session.

Will it find a link pointing at a 404?

No. It makes no network requests. It finds links that go nowhere structurally: empty, #, a javascript: no-op, or a fragment whose target is not in the document. Feed the collected hrefs to curl if you need status codes.

Does it work on Firefox or Safari?

The script itself is plain DOM and runs in any modern browser via the console or the bookmarklet. The Node runner is CDP, so it drives Chromium browsers only.

Tutorials

Install

Unzip it. That is the install — there is nothing to fetch.

unzip contrast-target-audit-js.zip
cd contrast-target-audit-js

The console and bookmarklet routes need only a browser. The Node runner needs Node 22 or newer (it uses the built-in global WebSocket) and any installed Chromium — Chrome, Edge, Brave or Chromium itself.

1 · Console

Open the page, open DevTools, paste the whole of audit.js, then run:

contrastTargetAudit()

Use this route when the state you want to measure needs a login, an open drawer, or a click to reach.

2 · Bookmarklet

node tools/build-bookmarklet.mjs

Paste the contents of the generated bookmarklet.txt into a new bookmark's URL field. Clicking it logs the full report to the console, copies the JSON to the clipboard, and shows a one-line summary.

3 · Node runner

node runner.mjs https://example.com --width 1280 --pretty

URLs and widths form a cross product, and a bare path is turned into a file:// URL:

node runner.mjs https://example.com/ https://example.com/pricing 
  --width 375 --width 768 --width 1440 
  --ignore "#wpadminbar" 
  --out report.json --pretty --fail-on-findings

Auditing a theme that is chosen at runtime

Set the theme before the document runs, with --init. Setting it after load measures mid-transition and gives you a number that is not real:

// theme.js
localStorage.setItem('theme', 'dark');
document.documentElement.setAttribute('data-theme', 'dark');
node runner.mjs https://example.com --init theme.js

Run the tests

node test/run-tests.mjs

It drives a real browser against two fixtures: one with four deliberate faults that must all be found, and the corrected version of the same page that must come back empty.

Support

This is free, MIT-licensed source. What that gets you and what it does not:

What is included. The complete source with no minified or obfuscated parts, the fixtures and the test that proves the four checks fire and the clean page comes back empty, and a README that documents the JSON shape field by field and lists what the tool does not check. If it does not do what the README says it does, that is a defect and worth reporting.

What is not included. No installation service, no scheduled updates, no SLA, and no response-time commitment — none has been measured, so none is promised. There is no support ticketing system behind this product.

Reaching us. Use the contact page on bineret.com. Include the report JSON and the URL or a reduced page that reproduces it; a finding without the composited foreground and background values from the report cannot be checked.

Modifying it. MIT — fork it, vendor it into a monorepo, change the thresholds, add a check. No attribution required in your output, though the licence header should stay in the source.

Hot Products

SupportIncluded
Money-backGuaranteed
DocumentationFull guide
Easy installOne-click
Original100% authentic
$0.00USD