On the twenty first of August we pointed an accessibility scanner at every page of the dojo, in both of its themes, and it came back with three faults. By the afternoon all three were fixed, each one pinned by a test that fails if it comes back. On the twenty second we deployed and ran the scanner again on the live site. It found nothing. Thirty two page and theme pairs, zero violations.
That second run is the one this post is about, because zero was the wrong number to stop at.
What the scanner found
The tool is axe-core, the engine behind most browser accessibility audits. We drove it from a headless Chromium with Playwright, the same browser our end to end tests already use, and asked it for everything under WCAG 2.1 level AA plus its own best practice rules. WCAG is the web accessibility standard; AA is the level most laws and procurement rules ask for. Three things came back, on every page.
Contrast. Every button on the dark theme was white lettering on our green, and that pair measures 2.7:1. The standard wants 4.5:1 for text. On the light theme the green and the amber failed as text on the page, between 2.3 and 3.9:1 depending on the surface. And our muted text, the handles, the dates, the units, was set at 50% opacity, which on a light card comes out at 3.6:1.
Landmarks. A screen reader user moves through a page by landmark and by heading before they read a word. Our navbar was a div, so the logo, the menu and the Pricing button sat inside no landmark at all. The footer held three navigations with no names, which a screen reader announces as "navigation, navigation, navigation". And the footer titles were h6 on pages whose deepest heading is h3, a jump that reads as missing structure.
One leftover from the component library. DaisyUI paints an inactive tab at 50% of the text colour. Same 3.6:1, on the profile page, in the light theme.
Fixing colour with arithmetic
The contrast fault is the interesting one, because the obvious fix is to squint. Make the green a bit darker, reload, see if it looks right. We wanted the judgement out of our eyes and into the build.
Our colours live as oklch tokens in one CSS file, a block per theme. Oklch is a colour space where lightness, chroma and hue are separate numbers, which makes a palette easy to reason about and a contrast ratio easy to compute: convert each token to linear RGB, take the relative luminance, divide. Seventy lines of TypeScript, no dependency. The test reads the CSS file, finds the block for each theme, and checks every pairing the markup can produce: body text on every surface, each colour's lettering on its own fill (which is every button and badge), each colour used as text on the page and on a card, and muted text at its floor.
One subtlety earned its own function. Text at 60% opacity is composited by the browser in gamma encoded sRGB. Mix the linear values and you get a ratio that is wrong by a visible margin, so the helper encodes, mixes, and decodes, and the number it produces is the one the browser paints.
The test went in red. Then the tokens moved:
- On the dark theme every bright fill now carries navy lettering. White on the green was 2.7:1; the navy is 6.8:1. It is the brand pairing anyway.
- On the light theme the green deepened from 55% to 48% lightness, the amber from 70% to 52%, the blue from 60% to 50%, so each reads as text on a card and carries white lettering as a fill.
- The dark theme's red lifted from 60% to 63%, because as text on the page it was 4.3:1, a hair under.
- The bronze step of the leaderboard podium, which had been a translucent orange, went solid.
Then the muted text. We settled on 60% as the floor: the test proves base text at 60% clears 4.5:1 on every surface of both themes, and 50% measured 3.6:1 on a light card. A second test walks the source tree and fails on any text-base-content/N with N below 60. It listed 116 of them across 24 files. They all moved to the floor, including the grey flame on a streak of zero days, which had been at 30%. The inactive tab got a one line override beside the theme tokens, and the test checks the override carries the same 60% as the floor, so the two numbers cannot drift apart.
The landmarks were plain markup: a header around the navbar, an aria-label on every nav (Primary, Product, Reach us, Legal), and h2 on the footer titles. The test for it counts the navigations, checks every one has a name, and checks the names are distinct.
Three red commits, three green commits, two seconds added to a suite that runs on every push.
The clean run
Deployed, scanned again. Zero violations, both themes, every page. Before believing it we checked the run was real: the theme attribute was set on the document, each page reported around forty rules passed, and the home page's primary button computed to navy on green. It was real.
It also reported 214 nodes as "incomplete" under the colour contrast rule. Incomplete is the scanner's word for a node it looked at and declined to judge.
Grouping them by reason took one line: 202 "background colour could not be determined due to a background image", 8 due to a gradient, 4 because another element overlapped. The 202 were every button on the site and the navbar brand. DaisyUI sets background-image: none, none on every .btn, two empty layers, and a faint noise texture on filled buttons. The scanner sees a background image declared, and stops. The gradients were our hero banner and the little avatar with a member's initials on it.
So the scanner's verdict on every button we had just recoloured was: no opinion. A report of zero violations was sitting on top of two hundred undecided buttons, and a reader who stopped at the headline would have called the site accessible on the scanner's authority when the scanner had abstained on the part that mattered.
Measuring what it would not
The fix was to measure them ourselves, in the browser, where the real numbers are. For each undecided node: take the computed text colour, walk up the ancestors compositing backgrounds until one is opaque, and where a gradient is in the stack, test against every stop and keep the worst. Chromium returns computed colours as oklch strings, which our contrast code handles, but there is a shorter way inside a page: paint the colour onto a one pixel canvas and read the pixel back. Whatever the format, the canvas hands you sRGB.
212 of the 214 cleared 4.5:1. The lowest was an outline button on the light theme at 4.92:1; every filled button on the light theme sits at 5.34:1 and on the dark theme at 6.85:1.
The remaining two were one element, the avatar initials, where the script reported 1:1. That is the ratio of a colour against itself, which meant our parser had failed rather than the avatar. The gradient's second stop is a color-mix() with nested brackets, and a regular expression had bitten off half of it. We resolved both stops through the browser's own CSS engine instead: 6.85 and 7.77:1 on the dark theme, 5.34 and 5.40:1 on the light. Passes.
What we would tell a team
The scanner is a witness. It told us where to look on the first day, and its findings were exact. The test is the gate. The scanner will never judge a DaisyUI button, so the thing that guards every button's contrast on every future commit is seventy lines of arithmetic over the token file, and it runs in the time it takes to read this sentence.
Read the whole report. A tool that says zero violations and 214 incomplete has told you two things, and the second one is the larger number. Undecided is a verdict too, and it is yours to settle.
And keep the fix where the fault was. The colours are tokens in one file, so the proof is a test over that file. The markup uses a floor, so the proof walks the markup. The component library paints one thing below the floor, so the override lives beside the tokens and the test reads both. Every rule the site now obeys has a failing test that names it, which is the only kind of knowing that survives the next pull request.

