pnpm why axe-core in our repository prints this:

axe-core@4.12.0 └─┬ eslint-plugin-jsx-a11y@6.10.2 └─┬ eslint-config-next@16.2.7 └── nextjs@0.1.0 (devDependencies) 

A full accessibility engine, 572 KB of it, installed because a config package depends on a lint plugin that depends on it. Our lint script is next lint, which Next 16 removed, so the linter at the end of that chain cannot run at all, and there is no .github directory, so nothing would have run it anyway.

The engine does not need the linter. It is a browser bundle sitting on disk, so I pointed it at the live site instead:

const axeSrc = await readFile('node_modules/.pnpm/axe-core@4.12.0/node_modules/axe-core/axe.min.js', 'utf8'); await page.goto('https://pub-trivia.app/faq'); await page.evaluate(axeSrc); // defines window.axe const r = await page.evaluate(() => axe.run(document)); 

Then I ran that over every URL in our sitemap except the homepage, which redirects to the dashboard in a signed-in browser. 78 pages, in a real Chrome, with all 105 rules enabled including the 7 that ship as experimental.

The result

rule impact pages nodes
color-contrast serious 35 981
landmark-unique moderate 75 75
empty-table-header minor 6 6
heading-order moderate 1 1

Four distinct rules, 1,063 nodes. Per page, 36 to 49 rules were applicable and 47 to 60 were inapplicable, which is its own kind of description of a site: no video, no iframes, no dialogs, no <form>, no images except one logo.

Three pages are completely clean: /legal/privacy, /legal/terms and /legal/cookies. They are the three pages with no site navigation, no comparison table and no muted body text, which is the shape of everything below.

By cluster:

cluster pages contrast nodes all nodes
quiz-questions 16 939 955
guides 20 38 58
compare 8 0 14
solutions 12 0 13
features 10 0 10
tools 6 0 6
pricing 1 4 5
legal 3 0 0

981 of the 1,063 nodes are two shades of grey

Our palette's body text passes comfortably. What fails is Tailwind's slash-opacity modifier applied to small text, which axe reports with the resolved colour:

insufficient color contrast of 3.72 (foreground #727272, background #171717, 14px, normal) insufficient color contrast of 3.07 (foreground #656565, background #171717, 14px, normal) 

Those are text-muted-foreground/70 and text-muted-foreground/60 at 14 px. The base colour is fine; the transparency is what drops it under 4.5:1.

Where they land decides the counts. The 16 quiz question pages use the smaller muted text per item, so they carry 41 to 113 nodes each, and the review of the year page is the worst single page on the site at 113. The 20 guides carry exactly 2 each, and one of them is the same line on every guide:

<p class="text-muted-foreground/70 mb-12 text-sm">Last reviewed <time datetime="2026-09-14">14 September 2026</time></p> 

/pricing carries 4, two of which are the greyed-out rows in the plan comparison, the ones that say what a plan does not include.

43 of the 78 pages have no contrast failure at all. A further 135 nodes across the 15 question pages come back as incomplete, exactly 9 per page: axe refuses to judge text sitting over a gradient, which is the correct answer rather than a missing one.

The other three rules are one line of markup each

landmark-unique, on 75 of 78 pages. Every page carries 12 landmark elements: header, the centred desktop nav, main, the breadcrumb nav, an aside, footer, five footer column navs, and the toast region. Exactly one of them has an accessible name, aria-label="Breadcrumb". axe objects once per page, and it picks the centred navigation:

<nav class="hidden md:flex items-center gap-5 absolute left-1/2 -translate-x-1/2"> 

The fix is aria-label="Main" on that element and a name on each footer column. Cost: one attribute per landmark, on two shared components.

empty-table-header, on 6 pages. Our comparison tables have a corner cell above the row labels, and the corner cell is this:

<th scope="col" class="text-foreground p-4 font-semibold">&nbsp;</th> 

A column header announced to a screen reader as nothing. It is a <td> pretending to be a <th> because the row of headers was built as one loop. /compare/best-pub-quiz-apps escapes only because it uses a different table component.

heading-order, on 1 page. /solutions goes from h1 to eleven h3s before its first h2, because that hub lists its index above its sections. Known, already written about, and the engine finds it in 400 ms.

Where the engine and the browser disagree

The interesting page is /tools/round-planner, a tool with 15 real form controls. 12 of them carry no id and no name, and 12 carry an aria-label.

  • axe: label passes on 9 nodes, select-name passes on 6, form-field-multiple-labels passes on 15. Zero violations.
  • Chrome's own Issues panel on the same page: 12 FormEmptyIdAndNameAttributesForInputError and one FormLabelHasNeitherForNorNestedInputError.

Both are right about different things. axe asks whether a control has an accessible name, and every control has one. Chrome asks whether the control can participate in form semantics and autofill, and a control with neither id nor name cannot. If you run one engine and call the page audited, you will miss whatever the other one measures.

Three more things I know are wrong with this site and axe does not report, for the same reason:

  • One of the 35 tab stops on a page has no focus ring at all. There is no axe rule for a missing :focus-visible style, and I only found it by capturing two screenshots of the focused header and noticing they were byte-identical.
  • The comparison tables are clipped and unscrollable at 320 CSS px. axe runs at whatever width you give it and does not reflow.
  • The public forms carry zero autocomplete attributes. autocomplete-valid was reported inapplicable on every page, which is honest: the rule validates the values of attributes that exist. Absence is not invalid.

target-size, the experimental 24-by-24 rule, ran on all 78 pages and said nothing, which matches a measurement I did at 390 px by hand: the footer's 20 px links sit 12 px apart, so the spacing exception carries them.

What this cost and what it buys

The sweep is about four minutes of wall clock for 78 pages, with no CI, no service, and no dependency that was not already installed. The whole fix list from the run is:

  1. Drop the /60 and /70 opacity modifiers from small text, or raise the base muted colour. One decision, 981 nodes.
  2. aria-label on seven nav elements in two components.
  3. One <th> becomes a <td> in the comparison table component.
  4. Reorder two headings on one hub page.

For completeness: the homepage, which I cannot navigate to in a signed-in browser, was measured by fetching its HTML anonymously and swapping it into a live document. That reports 4 contrast nodes and the same single landmark-unique, and it is a floor rather than a number, because scripts do not run through that path. Everything else above is a real page load. If you want to repeat any of it, the engine is in your own node_modules too, and the site is public on all 79 URLs.