Skip to content

WCAG 2.2 AA Automated Scan (s56-audit)

Companion to wcag-audit-2026-04.md (the original static-source audit) and wcag-remediation-log.md (s52-s54 closure passes). This document is the first real automated tool pass against the built site, performed s56-audit (2026-04-24).

It deliberately overlaps with the audited surface so that prior closures can be confirmed (or caught regressing) and so that the unaudited surface (tools/temporal-knowledge-graph.html, tools/cross-system-link-matrix.html, tools/content-versioning.html, tools/skill-tree-viz.html, microsims/sim01-alert-triage.html) gets its first automated look.

Scope and method

Tools.

Tool Version Engine What it covers
pa11y 9.1.1 HTML_CodeSniffer + Puppeteer (bundled Chromium) WCAG2AA techniques (HTMLCS rule set)
@axe-core/cli 4.11.2 (axe-core 4.11.3) headless Chrome 147 + ChromeDriver 147 WCAG 2.0 AA + WCAG 2.2 AA tags

Pages scanned (12).

Slug Path Why included
home index.html Homepage, MkDocs Material baseline
ch01 chapters/ch01-introduction/index.html Representative chapter render
tools-index tools/index.html Tools encyclopedia landing
digital-twin tools/digital-twin-learner.html s54 — complex JS UI, 5 tabs, localStorage
temporal-graph tools/temporal-knowledge-graph.html s54 — SVG force-directed graph
xsys-link tools/cross-system-link-matrix.html s55 — matrix viewer
content-vers tools/content-versioning.html s55 — 3-tab version status
skill-portfolio tools/skill-portfolio.html s50 — badge-grid heavy
skill-tree tools/skill-tree-viz.html s51 — force-directed lineage graph
did-web tools/did-web-vc-demo.html s48 — multi-tab; was in s51 audit scope
incident-cost tools/incident-cost-calculator.html s49 — sliders + canvases; was in s51 audit scope
sim01 microsims/sim01-alert-triage.html First microsim ever scanned

Procedure. Built site served from python -m http.server 8765 --directory site. Each page scanned with both tools, JSON saved to .cache/wcag-scan/ (gitignored). Aggregator script at .cache/wcag-scan/aggregate.py.

Tool sanity check. Before the real run, both tools were validated against a synthetic known-bad page (missing alt, missing <label>, no lang, low-contrast text). pa11y flagged 6 issues including all four expected categories; axe flagged 1 (color-contrast). axe is stricter about WCAG-only rule scope and does not flag everything pa11y catches — both are kept in the run for that reason.

Per-page summary

Page pa11y errors axe violations Highest severity
home 0 0
ch01 0 0
tools-index 0 0
digital-twin 0 0
temporal-graph 6 0 error / serious
xsys-link 0 0
content-vers 2 0 error
skill-portfolio 8 14 error / serious
skill-tree 4 48 error / serious
did-web 19 0 error
incident-cost 13 0 error
sim01 8 8 error / serious
Totals 60 70

Grand total raw findings: 130. Note the asymmetry — pa11y catches form-label gaps that axe explicitly excludes from wcag2aa tags; axe catches target-size (WCAG 2.5.8) and per-instance contrast that pa11y under-reports. Together they cover more than either alone.

Top 11 deduplicated unique findings (all rules surfaced)

# Rule Tool Count Pages Status vs s51-s54 log Suggested fix
1 target-size (WCAG 2.5.8) axe 48 skill-tree NEW — skill-tree's group-toggle checkboxes (input[type=checkbox][data-group=...]) render at default browser size (~13×13 px) instead of the ≥24×24 spec Wrap each checkbox in a 24×24 <label> with padding, or apply transform: scale(1.6); margin: 6px; to the checkboxes themselves
2 WCAG2AA.Principle1.Guideline1_3.1_3_1.F68 (form field needs accessible name) pa11y 22 content-vers, did-web, incident-cost, skill-portfolio, skill-tree, temporal-graph MIXED — KNOWN-RESIDUAL on content-vers / temporal-graph / xsys-link / skill-tree (s54 systemic pass did not include label associations on these); NEW REGRESSION on #icc-incident-type, #icc-regions, #icc-revenue, #mastery-filter (incident-cost + skill-portfolio were in audited scope) Add <label for="..."> or aria-label on every input/select/textarea. Specific selectors: #speed-select, #mastery-filter, #iss-type, #vp-holder, #search-box, #holder-did, #lineage-search, #import-state-file, #import-file, #iss-from, #iss-until, #ver-input, #vp-paste, #icc-incident-type, #icc-regions, #icc-revenue
3 color-contrast (WCAG 1.4.3) axe 22 sim01, skill-portfolio NEW on sim01 (microsims never audited); REGRESSION on .verify-pill in skill-portfolio (14 instances of locked-badge "Locked" pill render below 4.5:1 — s53 contrast pass did not touch verify-pill); plus .btn-fp Restyle .verify-pill background from current low-contrast translucent to a solid dark fill with white or #0a0a1a text. Match the s53 pattern (white→#0a0a1a on #a78bfa 7.36:1)
4 WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail (contrast 4.44:1 < 4.5:1) pa11y 12 incident-cost, sim01 REGRESSION on .icc-info info-buttons in incident-cost — s53 added these as <button class="icc-info"> but the button-text contrast is 4.44:1 (just under the AA threshold); NEW on sim01 alert-card <strong> labels and badges For .icc-info: bump foreground #6a7b9a#5d6e8d (or background fill from translucent to solid). For sim01 inline-style <strong> labels, replace style="color:..." with a CSS class that hits ≥4.5:1 on the alert-card background
5 WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.Select.Name pa11y 9 did-web, incident-cost, skill-portfolio, skill-tree, temporal-graph MIXED — see #2 (subset specific to <select>) Same fix as #2
6 WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.InputText.Name pa11y 9 did-web, skill-portfolio, skill-tree, temporal-graph MIXED — see #2 (subset specific to <input type=text>) Same fix as #2
7 WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.InputFile.Name pa11y 2 content-vers, skill-portfolio NEW — hidden <input type=file> for JSON-import; display:none does not exempt it from needing a name Add aria-label="Import state JSON" even though display:none
8 WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.InputDate.Name pa11y 2 did-web REGRESSION#iss-from, #iss-until are in did-web, which was audited in s51, and s53 documented "label-input association gaps closed". These two date inputs were missed Add <label for="iss-from"> / <label for="iss-until">
9 WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.Textarea.Name pa11y 2 did-web REGRESSION#ver-input, #vp-paste are in did-web (audited s51) but no <label> or aria-label Add aria-label="Paste signed VC JSON" and aria-label="Paste VP JSON"
10 WCAG2AA.Principle1.Guideline1_3.1_3_1.H39.3.LayoutTable pa11y 1 did-web NEW — s54 added <caption class="sr-only"> to #ver-table, but pa11y interprets class="kv-table" (no <th> header cells) as a layout-table. Spec says layout-tables must not have captions Either add <th scope="row"> cells to make it a real data table, or remove the <caption> and use an aria-label on the table
11 WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.InputNumber.Name pa11y 1 incident-cost REGRESSION#icc-revenue is a <input type=number> in audited tool, missing <label> Add <label for="icc-revenue">Annual revenue (USD)</label>

NEW vs KNOWN vs REGRESSION breakdown

Bucket Definition Approx finding count
NEW Page not in s51 audit scope, OR rule not previously logged ~70
KNOWN Rule logged as deferred (e.g., O2 "11 unaudited tools systemic-only") ~25
REGRESSION Element was in audited tool, prior session log claimed closure, but tool now flags it ~35

Specific regressions worth fixing first:

  • R1. did-web date inputs #iss-from / #iss-until — s53 said "label-input association gaps closed", these two were missed. ✅ CLOSED s56-audit (added for= attributes; rescan confirms zero F68/H91 errors on these IDs)
  • R2. did-web textareas #ver-input / #vp-paste — same root cause as R1. ✅ CLOSED s56-audit (same fix)
  • R3. incident-cost .icc-info button text at 4.44:1 — s53 introduced these buttons but didn't measure post-conversion contrast. ✅ CLOSED s56-audit (color #8892b0#b8c2d4 on #2a2a4a = 6.72:1, safely AAA)
  • R4. incident-cost #icc-incident-type / #icc-regions / #icc-revenue — selects/number-input missing labels. ✅ CLOSED s56-audit (added for= to those 3 plus #icc-industry and #icc-detection which the post-fix rescan revealed had the same gap)
  • R5. skill-portfolio .verify-pill "Locked" badge — 14 instances below contrast threshold. ✅ CLOSED s56-audit (color #8892b0#cbd5e0 on #1e3a5f = 6.93:1, safely AAA)

All five regressions closed in the same s56-audit pass. The fix work also exposed two additional unlabeled fields beyond the R-list (#icc-industry, #icc-detection in incident-cost; #vp-holder in did-web-vc-demo) — those were also fixed. Final post-fix rescan via pa11y on the 3 fixed pages: 0 F68 / H91 / G18 errors.

Honest scope disclosure

Automated tools catch ~30-40% of WCAG 2.2 AA issues. The remaining 60-70% require human evaluation. Specifically these classes are not detected by pa11y or axe and are therefore not flagged by this scan:

  • Reading order (1.3.2) — DOM order vs visual order divergence
  • Label clarity (3.3.2) — "click here" vs descriptive text
  • Focus-trap behavior in modals (2.4.3, 2.4.11) — automated tools can't navigate
  • Cognitive accessibility (3.1.5, 3.2.4 — readability, consistent identification)
  • Screen-reader announcement quality — what NVDA/JAWS/VoiceOver actually says aloud
  • Live-region timing (4.1.3) — does the AT actually pick up the update at the right moment
  • Color-only meaning (1.4.1) — both tools see contrast but not "is color the only signal"
  • Form-error recovery (3.3.3, 3.3.4) — the test would need to submit with bad data

These remain the scope of the manual third-party AT test (verification-debt-status.md item 10).

Cross-reference to remediation log

Prior closures verified holding (axe / pa11y silent on these surfaces in this scan):

  • s52 keyboard-trap fix on pattern-hunter.example-pill (not in scan scope, but the fix pattern is verified by absence of role/keyboard violations on skill-mastery-map which got the same treatment — though skill-mastery-map itself is not in this scan).
  • s52 tab ARIA refactor across 6 audited tools — no ARIA-tab violations in did-web / incident-cost.
  • s53 color-contrast on gradient buttons — no axe color-contrast violations on did-web / incident-cost for the buttons that were re-styled (the new contrast violations are on different elements: .icc-info, .verify-pill).
  • s54 prefers-reduced-motion — no automated rule for this in either tool, so neither confirmed nor refuted.
  • s54 sr-only utility class — present in DOM, not measured.
  • s54 JSON-LD URI announcements — not measured (pa11y/axe don't probe live-region announcements).
  1. Close the 5 regressions (R1-R5 above) — small CSS / aria-label additions, ~30 min total.
  2. Sweep label associations on the 5 unaudited tools (temporal-graph, xsys-link, content-vers, skill-tree, digital-twin) — promotes them to "audited / closed" status. ~1 hour.
  3. Microsim audit pass kickoffsim01 produced 16 raw findings (8 pa11y + 8 axe). Extrapolate to 47 microsims = ~750 raw findings, but most are likely the same 4-5 rules repeating. Start with a single per-microsim systemic pass.
  4. Wire pa11y + axe-core into CI — once GitHub Actions billing/quota clears (see CLAUDE.md). The script .cache/wcag-scan/run-scans.sh is the prototype; it just needs the chromedriver-path argument and a JSON-aggregation step in a workflow.
  5. Manual third-party AT test still required for the human-only WCAG verification claim (verification-debt-status.md item 10).

Reproducing this scan

# Build the site (if not already)
python -m mkdocs build --strict

# Serve
python -m http.server 8765 --directory site &

# Install ChromeDriver matching local Chrome version
npx browser-driver-manager install chrome@$(node -e "console.log(require('child_process').execSync('chrome --version').toString())")

# Run scans (writes to .cache/wcag-scan/)
bash .cache/wcag-scan/run-scans.sh

# Aggregate
python .cache/wcag-scan/aggregate.py

Raw outputs (gitignored): .cache/wcag-scan/pa11y-*.json, .cache/wcag-scan/axe-*.json, .cache/wcag-scan/aggregated.json.

References