Open-source project
paintcheck: measuring contrast where CSS has no answer
A command-line tool that screenshots a page and reads text and background colours from the pixels the browser painted. It exists because the CSS-based checkers told me my site was fine, and it was not.
- Licence
- MIT
- Version
- 0.2.0 · 37bbe2a on GitHub · not on npm
- Language
- JavaScript (Node 20+), Turkish identifiers, English CLI
- Dependencies
- puppeteer-core, sharp
- Tests
- 57 passed locally and in CI (Ubuntu, Node 20/22)
Try the measurements
Choose a test example to compare its screenshot and recorded results. These are pre-computed benchmark measurements, not a live scan.

Scroll the image horizontally to see it in full. Measurements refer to the recorded browser capture.
The background passes through the text's own colour. There is no single CSS answer, so axe reports incomplete. paintcheck samples the glyph pixels and reports the worst point.
- Reference value
- 1.1:1
- axe-core 4.13
- incomplete
- paintcheck
- fail 1.10:1
Recorded benchmark results; reference values are shown where available. Source: benchmark/SONUC.md, revision 37bbe2a on GitHub, 2026-09-15.
Rendered contrast checks that complement axe-core. This demo documents revision 37bbe2a, which is on GitHub with a passing CI run. Methods, scope and installation are on the project page.
Output from the recorded demo
From npm run demo in the repository: two synthetic pages, no real site. The hero paragraph passes at its median and fails at its worst point; the sticky link is reported once at the worse of two scroll positions. The full HTML report with every cropped screenshot is linked above.
$ npm run demo
demo/sayfalar/landing.html
! [contrast] body > section.hero > div.kap > p.destek
"We plan, design and ship product interfaces for small teams."
15px — 4.38:1 worst (median 6.57, best 7.8, 453 samples) — needs 4.5:1
rgb(248,236,211) on rgb(168,87,60) — at 383,281 (384x45)
! [contrast] body > header.yapiskan > div.kap > a
"Home"
14px — 2.07:1 — needs 4.5:1
rgb(110,109,113) on rgb(198,155,148) — sticky — worst of 2 scroll positions (38x16)
! [contrast] body > section.bant > div.kap > p
"Trusted by teams who ship on a schedule and would rather not"
16px — 1.66:1 worst (median 2.57, best 4.11, 362 samples) — needs 4.5:1
rgb(154,154,158) on rgb(116,116,120) — at 264,453 (912x24)
· scanned 2 scroll positions
Findings: 15
--output → shots/001-we-plan-design-and-ship-product-interfac.pngInstallation from source
These commands check out the exact revision shown on this page. Node 20+ and a local Chrome, Chromium or Edge are required. No npm package has been published yet.
git clone https://github.com/hasanyahsitr/paintcheck.git
cd paintcheck
git checkout 37bbe2aa3b4bf2c37d879b020148037b57abfd82
npm ci
npm start -- https://example.comThe problem
WCAG asks for a contrast ratio between text and its background. Tools like axe-core compute it from color and background-color. When the background is a photograph, a gradient, or a semi-transparent scrim over either, the CSS has no single colour to offer; axe reports the element as incomplete and leaves it to a human.
I hit this on my own site. The hero text sat on a photo under a gradient scrim. The checkers said nothing was wrong. When I finally measured it, the real ratio at the worst point was about 3:1 against a 4.5:1 requirement.

scrim.html: a pseudo-element scrim over a striped background. No CSS value describes what the text sits on.Scroll horizontally to see the full image.
Two attempts that were wrong
The first version sampled the background next to the text. On a gradient the pixel beside a letter is a different colour from the pixel under it, so readings were confidently wrong in both directions: false alarms on passing text, clean bills of health on failing text.
The second version estimated glyph coverage from how much each pixel changed between a normal screenshot and one with the text hidden. On a gradient that change scales with background darkness, so the estimate only picked pixels in the highest-contrast region and reported 4.47:1 on text whose real worst point was 1.1:1. The test fixture gradient.html caught it; I did not.

gradient.html: the background crosses the text's own colour, so contrast approaches 1:1 at one point and exceeds 5:1 at the other end.Scroll horizontally to see the full image.
The method that held
Five screenshots of the same viewport: the page as rendered (A), the same 300 ms later to exclude anything still moving (A2), all text forced transparent (B), all text forced black (C), all text forced white (D).
C and D give the coverage of every pixel exactly, independent of the background and of the text's real colour: a pixel covered by a fraction α reads α·0 + (1−α)·bg in C and α·255 + (1−α)·bg in D, so α = (D − C)/255. Pixels near the element's maximum α are the glyph core; antialiased edges are dropped because they are blends.
Then the foreground is read from A at those pixels and the background from B at the same pixels. Nothing is computed from CSS. opacity: .5 on a parent with .8 on the child, an rgba colour, a pseudo-element scrim: Chrome already applied all of it, and the result is what gets measured.
Where glyph stems are thinner than a pixel, no pixel is fully covered, so even the densest one is a mixture. Since α is known the mixture is undone: E = B + (A − B)/α. Without that step 12 px footer links at a real 5.85:1 came out as 3.81:1.

nested-opacity.html: a checker reading only the child's opacity: .8 sees 12.6:1. The pixels say 2.85:1, because the parent is at .5 as well.Scroll horizontally to see the full image.
Two more corrections the suite forced
On Windows, Chrome's subpixel antialiasing gives each colour channel a different coverage, so pixels treated as fully covered were still blends and every reading came out about half a point low: 3.92 measured against 4.48 computed. Chrome is now launched with greyscale antialiasing.
The antialiasing correction initially divided both the element's opacity and the colour's own alpha out of the coverage map. The black and white probes override the colour but not the opacity, so only opacity belongs in that division; dividing alpha too made the correction skip thin rgba text entirely. Authored transparency stays in the result, because it genuinely reduces readability and WCAG counts it.

thin-text.html: an rgba .68 line that must pass and an rgba .5 line that must fail at 3.32:1, so neither over- nor under-correction can slip through.Scroll horizontally to see the full image.
Whole pages and sticky headers
By default only the first viewport is measured. With --full-page the document is scrolled in overlapping steps, because an element on a step boundary would otherwise be clipped in both steps and never measured.
Sticky and fixed elements are measured at every scroll position and reported once, at their worst. In the benchmark, axe-core passes the fixture header at 4.54:1 where it starts; three screens down, over a grey band, it measures 1.32:1.

sticky-header.html scrolled to the grey band. The header's CSS is identical to where it started.Scroll horizontally to see the full image.
Verification
The recorded run of revision 37bbe2a passed 57 tests locally on Windows 11 with Chrome 152. GitHub CI (Ubuntu, Node 20 and 22) passed for the same revision on 16 September 2026. Flat colours are checked against analytic ratios, while gradients and photographs use ranges. The sticky header must be reported once at its worst position, and the tall page checks --full-page coverage.
A benchmark script runs axe-core's colour-contrast rule and paintcheck on the same fixtures and compares both with the ground truth. Where the background is flat both agree to within 0.02. Where it is not, axe says incomplete and paintcheck gives a number. The table and the environment it was produced in are committed to the repository.
Revision 37bbe2a has run on Windows (local) and Ubuntu (CI), not on macOS. Font rendering can change the measured ratios; the recorded benchmark notes differences of roughly ±0.1 between machines, so results near the threshold still need a manual look.
Known limits
- Not a replacement for axe-core or Lighthouse; it covers the contrast cases they leave open, plus a few structural checks.
- Not a compliance certificate. Automated checks catch a fraction of WCAG.
- On flat backgrounds CSS arithmetic is exact and beats measuring; paintcheck earns its keep only where that arithmetic has nothing to work with.
- Static snapshot: hover states, open menus and carousel frames are not measured.
- Font rendering can change ratios between machines. Results close to the threshold need manual review.
Status
- MIT licence. Version 0.2.0, unreleased on npm.
- This page describes revision 37bbe2a, published on GitHub on 16 September 2026. No npm package yet; install from the repository.
- 57 tests passed locally on Windows 11 with Chrome 152, and GitHub CI passed on Ubuntu with Node 20 and 22 for the same revision. macOS is not covered.