What the axe scan actually is, how its sample is chosen, and why tuning it is dangerous. WIP.md keeps the gate roster under The gates, and where their internals are.
check.bat runs scripts/check_tree_fresh.mjs,
which refuses a tree older than the sources that produced it, then
scripts/check_dot_fit.mjs, then
scripts/pick_a11y_sample.mjs --check --- see
Choosing the sample --- and then
scripts/check_a11y.mjs, which runs puppeteer +
axe-core over sixteen sample pages against WCAG 2.0/2.1/2.2 at Level A + AA
(plus the heading-order best-practice rule) and exits non-zero on any
violation. It requires build.bat to have produced an up-to-date _site/.
What the scan is --- the page list, the viewports, the themes, the blocked
requests and the axe run options --- lives in
scripts/lib/axe-scan.mjs, shared with the
correctness gate and the measurement rig; check_a11y.mjs itself is only the
reporting front end. All five WCAG tags --- wcag2a, wcag2aa,
wcag21a, wcag21aa, wcag22aa --- must be listed: axe matches tags literally
with no version rollup, so a rule tagged only wcag21aa does not match
wcag22aa.
Four details of that script matter and are easy to break:
- It scans
_site-offline/, not_site/. The online tree references its assets with root-absolute URLs (/assets/css/…), which resolve to nothing underfile://— every page would load unstyled and every colour-contrast result would be a meaningless black-on-white pass. The offline tree uses relative asset paths and renders for real. - It scans each page in both themes and at two viewports (
--theme,--viewport). Dark mode is a separate stylesheet with its own palette, and defects such as horizontally scrolling code blocks only appear once the layout is narrow enough to overflow. The dark half is not a formality: the dark compilation re-emits every JTD base rule underhtml[data-theme=dark], which raises its specificity from (0,0,1) to (0,1,2) -- so a root-level single-class rule incustom/custom.scssthat overrides a bare element selector applies in light mode and silently does not in dark. Prefix such rules with.main-contentto clear the bar. - It injects a patched axe bundle.
SOURCE_PATCHES['plain-color-fields']inaxe-scan.mjsreplacesColor2's six WeakMap-emulated#privatefields with plain own properties, worth -26 % across a realistic page set and -30 % on large pages. Patches need the unminified bundle, which costs ~6 ms more per page to inject. Two obligations come with it: every axe-core upgrade re-runs bothcheck_a11y_fingerprint.mjs --patches plain-color-fieldsandcheck_axe_patch_equiv.mjs(CI andtest.batrun the second for you), and if a result ever looks wrong, re-run with--stock-axefirst -- that injects the unmodified bundle and says in one command whether the patch is implicated. - It blocks the search index (
search-data.js+lunr.min.js) via request interception — seeBLOCKED_REQUESTS. Every page pulls in ~3.4 MB of index that never touches the DOM axe walks; loading it is 18.9 s of a 27.1 s run, and aborting it cuts the scan to ~9 s with byte-identical results (every rule id and node count, violations and incomplete alike, across every page/theme/viewport combination -- 60 audits today). Do not extend the block list tojust-the-docs.js— it installs the search combobox ARIA, and blocking it makes axe see less (colour-contrast nodes onSelect-Casedrop 54 → 2), silently masking coverage.
A local pass on the geometry rules is authoritative only because the face is pinned. target-size measures rendered boxes, and an inline element's measured height is its font's content area, so it moves with whatever system-ui resolves to (15--19px at the mobile h3 size; see WIP.Typography.md). Self-hosting the text face pins that number at 17px on every machine, which makes a local pass match CI's. It does not licence trimming the paddings back: font-display: swap puts the first frames of every load on the fallback, and a reader whose font request fails stays there, so the 15--19px band is still the range a fix has to clear. Give a font-dependent measurement margin against the smallest of those numbers, prefer a rule that meets the floor on declared size where the layout allows one, and check the whole site for the rule rather than the sample --- one rule over every page at one viewport takes about two minutes.
axe does not evaluate whether a focus ring is actually visible, only that focusable things are reachable and labelled --- so a ring that is drawn and then clipped away passes every rule. The aux nav is where that matters. .aux-nav is overflow-x: auto (navigation.scss:182), and the overflow spec turns the other axis from visible to auto when one axis is not visible, so the nav is a scroll container that clips at its padding box on all four sides; its items are height: 100% and the first one starts at the left content edge. An outset ring on anything in there therefore loses every side that sits on the clip edge. Only a manual keyboard pass sees it.
The theme toggle therefore gets an inset ring (outline-offset: -2px on #theme-toggle, in custom/custom.scss), which needs an id selector to out-rank the dark compilation's html[data-theme=dark] .btn-reset:focus-visible at (0,3,1).
Chrome's UA outline: auto does not rescue a link in there either. At 1280x900 in both themes, the nav's box is top 0 / bottom 59 and the "twinBASIC Home" link's is identical, so the UA ring's +1px offset puts its top and bottom segments outside the clip box exactly as the toggle's did; only the left and right bars survive, because the link starts 50px inboard of the nav's left edge. It takes an author ring at outline-offset: -2px too (.aux-nav a.site-button:focus-visible), with a dark-mode colour override -- the link is not a .btn-reset, so nothing in either compilation competes with it.
The chain icon beside every heading is deliberately aria-hidden="true" tabindex="-1", which looks like a defect and is not. With a name (for example aria-labelledby pointing at its own enclosing heading) every heading would appear a second time in a screen reader's links list, named identically, with nothing marking it as a permalink --- over 7,000 extra links across the site's 869 content pages, 172 on tB/Gloss.html alone. axe would still pass link-name, because the rule asks whether a name exists, not whether it is worth announcing.
It stays a real <a href> so the mouse affordances that people actually use to copy these -- right-click Copy Link Address, middle-click, the status-bar URL preview -- are untouched. The keyboard and screen-reader equivalent is renderSectionLinks in builder/template.mjs: one <details class="section-links"> per page, listing every heading. It sits at the top of the page footer, immediately after </main> closes (renderFooter places it), and is omitted on a page with fewer than two headings. A closed <details> subtree is notRendered -- it contributes nothing to the accessibility tree and no tab stop beyond the <summary> -- so the whole feature costs one tab stop per page and expands on demand (Gloss.html exposes 166 links closed and 338 open).
Two things about that placement are essential:
- It is emitted from the template, not from the markdown render. The PDF book assembles its chapters from
page.renderedContent(book.mjs) and the search index reads the same field (search.mjs); both are upstream of the template, so the block reaches neither and nothing has to be marked or stripped downstream. The same is true of the anchor icons themselves, and ofrenderChildrenNav. If a future page-level addition does need to reach the book, that is the field to put it in -- not a marker attribute. min-heightfor target-size belongs to.main-content summary, which is same-specificity and later incustom/custom.scss. A localmin-heighton.section-links > summarysilently loses to it. And the summary keeps the UA'sdisplay: list-item: giving a<summary>display: flexmakes Chrome drop the disclosure triangle, which is the only thing marking it as openable.
axe excludes aria-hidden subtrees from rule evaluation wholesale, so the icon vanishing from link-name and target-size is axe seeing less, not the markup being better. What makes the design sound is that the element is gone from the accessibility tree (Chrome CDP: ignored, ariaHiddenElement) and from the tab order (real Tab presses), independently of what axe reports.
A closed <details> is invisible to the scan. Its subtree is notRendered, so axe never walks it -- the same property that makes the section-links disclosure cheap is what hides it. A defect inside it (for example target-size on every link) would pass check.bat, because the state a reader sees after one click is never audited.
STATE_AUDITS in scripts/lib/axe-scan.mjs closes that: entries layered onto the page x theme x viewport matrix that apply a DOM mutation from PAGE_STATES after navigation and before the audit. Two entries, each run across both themes and both viewports --- eight extra audits on top of the 64 the sixteen sample pages produce, for the 72 the scan reports.
Three things about it are essential:
- Every
PAGE_STATESfunction must assert it found what it expected. A state that silently no-ops degrades into a second audit of the default page: slower, still green, covering nothing. Thesection-links-openapplier throws unless it finds exactly one disclosure holding at least two links, and returns the count socheck_a11y.mjscan print what was actually exposed. It throws on a page with no disclosure and on one whose disclosure has been emptied. - The gate cannot vouch for this.
check_a11y_fingerprint.mjscompares a candidate against a baseline produced by that same scheme's element set, so a change to which DOM is walked is its documented blind spot. Adding elements is the safe direction, but it still has to be argued from source and demonstrated: the open audit walks 57 more nodes than the closed one, and with the fix reverted in-page it reportstarget-sizex14 where the closed audit reports nothing. Run the A/A control (--baseline production --candidate production) after touching the matrix -- a nondeterministic state audit would surface there. - Item count buys nothing; pick the cheap host. Menu/Window hosts it at 14 items and 0.41 s per audit rather than Pipeline-Stages at 72 and 1.28 s. The defect class is per-link geometry, identical for every item -- spacing between two consecutive items does not change with how many follow, and the long labels that make the big page look like the stress case only wrap at mobile, which makes a target taller and easier to pass. Both catch a reverted fix at both viewports.
sweep_a11y.mjs still audits every page closed only; the full-site sweep does not cover the open state.
Syntax-highlight token colours are kept above 4.5:1 automatically: builder/highlight-theme.mjs clamps any colour from the vendored .theme files that fails against the code-block background, moving lightness away from the background while preserving hue and saturation. The vendored themes stay faithful to the IDE; the emitted rule carries a raised to 4.5:1 comment naming the original colour.
The scan audits sixteen pages out of ~1,160, so its page list decides what it can report at all -- and a page list that stops being representative fails silently. A hand-picked list goes stale as the site grows around it: a sample with no table, image, <details> widget or video card leaves image-alt, the table rules and scrollable-region-focusable in the run options with nothing to run on, and the gate reports a clean pass.
The full-site sweep (scripts/sweep_a11y.mjs — every page, both themes, both viewports, 3,476 audits, ~20 min) is the check on the sample. It shows which constructs the sample has to contain: table wrappers (scrollable-region-focusable), footnote back-links (link-in-text-block), the FAQ's <summary> elements at the phone viewport (target-size), heading structure (heading-order), diagrams (role-img-alt) and code-block colours (color-contrast).
So the sample is derived rather than maintained by hand. scripts/pick_a11y_sample.mjs holds a list of construct families — markup shapes some axe rule keys on, each recording the rule that would otherwise have nothing to run on — and three modes:
--check(the default, and whatcheck.batand both CI workflows run): every family the site uses is covered by at least one sample page, or exit 1 naming the gaps and the cheapest page that would close each. No browser, ~1 s.--propose: greedy set cover, seeded from the currentSAMPLE_PAGES, so it prints what to add.--freshignores the current set and covers from scratch, which is how to ask whether the existing pages still earn their place.--census: what each family is, how many pages use it, which page uses it most.
Cost is part of the choice: audit cost is super-linear in element count (k = 2.73), so the cheapest cover is not the smallest one, and --propose ranks candidates by measured per-page cost from the sweep's JSONL when one has been run. Across the full matrix, a thirteen-page sample costs 18.7 s of audit against 6.3 s for a small six-page sample -- 2.96x for a bit over twice the pages, because cost tracks element count rather than page count. Pipeline-Stages.html alone is 26 % of it, and earns that as the site's largest and most table-dense page.
When the docs start using a construct they have not used before — a new admonition shape, a figure, a widget — add a family for it in FAMILIES and let --check say whether the sample already covers it. Leaving it out is not neutral: it means the rule for that construct never runs anywhere.
axe is the site's correctness oracle, which makes it a dangerous thing to tune: a change can make axe see less and still report a clean pass. Blocking just-the-docs.js during the scan, for example, looks like a 130 ms win and quietly drops the colour-contrast node count on Select-Case from 54 to 2.
So any change to what the scan runs goes through scripts/check_a11y_fingerprint.mjs first. It runs the full page x theme x viewport matrix twice, once under each of two named configurations from the scheme registry in axe-scan.mjs, against one build in one process, and diffs the findings audit by audit -- violations by ruleId:nodeCount, incomplete by rule-id set.
node scripts/check_a11y_fingerprint.mjs --list
node scripts/check_a11y_fingerprint.mjs --candidate no-htmlIt has one blind spot: it compares a candidate against a baseline produced by that same scheme's element set, so it cannot detect a change that stops auditing elements entirely. Anything touching which DOM is walked -- viewport, visibility, request blocking -- has to be argued from source instead.
It also compares which findings axe produces, never their shape. The no-html scheme passes the gate on every audit and would still crash the reporter, because noHtml: true makes node.html null and check_a11y.mjs calls .slice() on it. Treat the gate as necessary, not sufficient.
Cost attribution for the scan lives in perf/ab-axe.mjs, with perf/probe-axe-dom.mjs for DOM operation counts and perf/probe-axe-scaling.mjs for the size curve. The investigation, builder/PLAN-axe-perf.md, concludes that one vendored source patch, SOURCE_PATCHES['plain-color-fields'] in axe-scan.mjs (see above), is worth 26 % across a realistic page set and 30 % on large pages. It is gated audit-by-audit by check_a11y_fingerprint.mjs and verified value-by-value by check_axe_patch_equiv.mjs -- both are needed, because the fingerprint gate compares incomplete as a rule-id set and would not notice a colour error that shifted ratios without flipping a pass/fail. The patch asserts an exact occurrence count at each substitution point, so an axe-core bump fails loudly rather than silently reverting. Before touching the scan:
- Audit cost is super-linear in page size --
k = 2.73on real pages, almost entirely insidecolor-contrast. The four largest pages in the site cost ~5.9x an average sample page each. - Every axe-core upgrade needs the fingerprint gate run across it -- axe ships new and revised WCAG rules between minors, and the source patches are pinned to the bundle's current text.
ab-axe.mjscannot resolve an effect under ~20 % -- its tracer overhead plus GC timing swamps the signal, and it mis-reads a real ~10 % effect as noise. Use perf/ab-axe-pages.mjs to decide anything modest, and run--scheme productionas an A/A control before believing a small delta.config-onlymeasures -10.7 % there against a -0.4 % A/A, and is declined anyway:noHtmlandselectors: falseremove thehtmlandtargetfields that say which element failed.