---
title: "axe 'Needs Review' Is a To-Do List, Not a Verdict"
description: "axe reported 1 violation and 107 nodes it would not judge on my own site. The 107 were not noise and not failures; they were unmeasured. What it took to measure them, what the one real miss looked like, and the CI rule I run now."
author: "Filipe Brito Ferreira (Senior Front-End Platform Engineer)"
canonical: https://www.fbritoferreira.com/blog/axe-needs-review-is-a-to-do-list-not-a-verdict/
published: 2026-09-27
tags: ["accessibility", "axe", "frontend-platform", "ci-cd", "testing", "web-performance"]
image: https://cdn.fbritoferreira.com/images/axe-needs-review-is-a-to-do-list-not-a-verdict-1280.webp
---

On 20 September I pointed axe-core at my own site. axe runs each of its rules against every element on a page and sorts the results into three buckets: violations, passes, and a third it calls incomplete, for checks it couldn't finish. Mine came back with one violation, 107 nodes marked incomplete, 42 rules passed. The one violation took two minutes to fix. This post is about the 107.

It's my site, the CV on it lists web performance and platform engineering, and it had been deploying with that output unread for as long as it has existed. I'd never run the tool against it in CI. I'll come back to that. The more useful lesson is what the 107 turned out to be.

There are two lazy readings of a number like that: it's noise the engine couldn't decide on, or it's 107 failures wearing a softer label. Both are wrong in a way that only shows up once you measure.

## Incomplete means the tool stopped, not that you passed

The incomplete bucket exists because of axe's first rule: [no false positives](https://dequeuniversity.com/rules/axe/4.10/color-contrast). When the engine can't compute a contrast ratio it won't guess. It files the node with a reason, Deque's tooling surfaces the bucket as ["needs review"](https://docs.deque.com/devtools-for-web/4/en/devtools-color-contrast-resolver/), and Deque's own guidance has said for years that needs review is not a pass. None of that was news. The gap was that nothing in my pipeline enforced it. On my CV page the 54 incomplete nodes came back with three reasons between them.

"Element content contains only non-text characters." That's the ○ and ● markers beside the nav items and the → arrows in the footer: glyphs doing the job of bullets. WCAG 1.4.3 exempts text that conveys no meaning, and some of these qualify. Which ones is a per-node judgment: a bullet that separates list items is doing a job, and the engine can't make that call any more than it can measure a gradient. Neither does my check, as it turns out; more on that below.

"Element's background color could not be determined due to a background gradient." The body of every page carries a repeating 45-degree hairline, one pixel of yellow at 3% alpha every 30 pixels, over black. Each section adds a 56-pixel grid of the same yellow at 4% to 8%. A person sees black with a faint texture. The engine sees a gradient and stops.

"Element content is too short to determine if it is actual text content." Two-character labels, years, the odd bracket.

None of those is a measurement. They mark where the DOM-side computation ran out of road, and its limits are real rather than hypothetical. axe-core's tracker has a [2021 issue on background colours computed wrongly through stacked non-opaque layers](https://github.com/dequelabs/axe-core/issues/2924), closed that November, and a [May 2023 issue on `oklch()` parsing](https://github.com/dequelabs/axe-core/issues/4007) that was fixed in ten days and reopened in August 2025: [Tailwind v4](https://tailwindcss.com/docs/colors), which moved the whole palette to oklch in January 2025, emits `none` for missing channels, and the colour library axe pins turns that into NaN. In May 2026 a commenter on that thread reported turning contrast checks off entirely. This site is on Tailwind v4, so every colour on it is oklch or an alpha over oklch. That is the limit of what computed styles alone can tell it.

## The painted pixel is the only ground truth

Computed styles are formulas. The browser resolves them once, at paint. So the way to measure what the engine couldn't is to ask the browser to paint.

A 2D canvas context, which is sRGB unless you ask for something else, will take any colour the browser can render, oklch included, fill a pixel with it, and [hand back sRGB bytes](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/getImageData). Fill the background colour, fill the foreground over it so alpha composites for free, read the one pixel. Walk up the tree to the first opaque background colour, compute WCAG relative luminance for both, divide, and compare against [4.5:1 or 3:1](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html) depending on font size and weight.

```js
const ctx = document.createElement("canvas").getContext("2d");
const rgb = (css, over) => {
  ctx.clearRect(0, 0, 1, 1);
  if (over) {
    ctx.fillStyle = over;
    ctx.fillRect(0, 0, 1, 1);
  }
  ctx.fillStyle = css;
  ctx.fillRect(0, 0, 1, 1);
  return [...ctx.getImageData(0, 0, 1, 1).data];
};
const lum = ([r, g, b]) => {
  const f = (c) =>
    (c /= 255) <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
  return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
};
const ratio = (fg, bg) => {
  const [a, b] = [lum(rgb(fg, bg)), lum(rgb(bg))];
  return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
};
```

That's the whole trick. The rest is walking leaf elements that hold text and reading `font-size` and `font-weight`. The [version that runs in this site's CI](https://gist.github.com/fbritoferreira/3670acde8ecd8df8838953fb393f2c7b) is under 100 lines with the walking included, and it has blind spots I know about: it doesn't descend into shadow roots or read `::before` and `::after` content, it skips any text shorter than two characters, which drops the glyph markers along with everything else that short (a blunt rule, not a 1.4.3 judgment, and the one exemption baked into the code rather than listed), and it runs against an explicit list of seven routes, so a page nobody added to that list gets no coverage at all.

It isn't a perfect measurement either. It reads the nearest opaque background colour, not the painted composite, so the hairlines are ignored. I measured what that costs on the built site: a label that reads 5.55:1 on black reads 5.39:1 on the 8% grid line, and white text at 10.0:1 drops to 9.6:1. Nothing flipped a verdict here; on a design nearer the floor it could.

Across the home page, the CV and the blog index, 339 text nodes between them, one group failed: the footer's contact-type labels and the closing "$ end-of-document" line. Yellow at 60% alpha, 10 pixels, on black: `text-yellow-500/60`, which is `oklch(79.5% .184 86.047)` at 60%. 4.25:1 against a 4.5:1 floor, in six places. Seventy percent measures 5.55:1.

The six failing labels were in the pile, filed under "background gradient", next to a hundred nodes that were fine. Same reason, same bucket. Nothing about the pile told me which was which.

## The one real violation was the boring one

The single violation was the rule axe calls `link-in-text-block`: the company links in the experience list and the institution links under education were distinguishable from the surrounding text by colour alone. One violation in axe's count, since it counts per rule, with five nodes listed under it. [WCAG 1.4.1](https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html) has said since 2008 that colour can't be the only signal. I'd read the checklists that repeat it and still shipped yellow-on-grey links whose underline only appeared on hover.

The fix is an underline in the same yellow at half strength, offset below the baseline, going to full strength on hover. Two minutes and no design argument. The pile of unknowns took the rest of the afternoon.

## A green pipeline was running zero tests

While wiring the axe suite into CI I found the site's one existing Playwright spec hadn't run in months. `@playwright/test` sat at 1.61.1 in `package.json` and `playwright` at 1.62.1. The CLI from one loaded the runner from the other, warned about two copies, reported "No tests found", and exited 0. Green, because nothing ran.

One version bump fixed it. Different bug from the 107, same symptom: a green result that didn't come from a check. The same afternoon produced a third: `astro preview` detaches into a background daemon when its output isn't a terminal, so the test runner's server exited at once and a stale daemon from an earlier build answered every request. Two runs passed against the wrong site before I noticed.

## What I'd do differently

Run the suite before writing the skill on the CV, not after. The accessibility line came off my profile this week for exactly that reason. It goes back when this suite has caught a regression in a real pull request. And keep the measurement in the repo from day one. The contrast script lived in a scratch directory for most of an afternoon, and a check outside the pipeline is a check that runs once.

## The CI rule

The rule I run now: needs review fails the build until someone measures it, unless WCAG exempts the text. In practice that's two assertions per route:

```ts
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
expect(await collectContrastFailures(page)).toEqual([]);
```

Anything the engine can't judge, the second line measures. Anything the second line can't measure either, or that 1.4.3 exempts, gets an entry in an exclusion list in the test file, and an entry is three fields: `{ selector, reason, added }`, the last one a date, applied on every route the selector matches. That list is empty today; the six labels got fixed rather than excused.

The cost is real. A new component with a gradient background fails the build the day it lands, and someone has to either fix the colour or write that entry. When we consolidated twelve streaming apps at Sky, [performance budgets gave every cutover a definition of done](/blog/consolidating-12-streaming-apps-into-2/), and a number nobody had measured didn't count then either.
