Accessibility Testing Skill
Browser Tooling Routing (read first)
Pick the right execution mode from the routing table before running anything (the table is the source of truth — don't trust remembered mode counts):
| Task | Tool | Why |
|---|
| Codified CI keyboard tests, visual regression, axe-core scans, WCAG compliance suites | with files | Real keyboard events, CI-runnable, version-controlled, reproducible. Primary path — all mandatory rules below still apply. |
| Baseline sweep across a list of URLs — machine-readable axe-core evidence per page, no authoring, not CI-embedded | references/baseline-url-scan.mjs
(in-repo reference script; peer deps + ) | Sequential per-page axe scan + summary JSON for baseline/regression evidence across many pages in one run. adds DOM-census heuristics (empty paragraphs, autocomplete-absence, duplicate ids); writes a diffable per-page alt-text map. Detector output, not a conformance verdict — axe-detectable subset (plus heuristics) only. See "Baseline URL-list scan" below. |
| Interactive agent-driven reconnaissance: snapshot ARIA structure, navigate a SPA to reach the page under test, verify a fix in place, capture annotated screenshots, probe a disclosure/menu/modal without writing a test file | CLI (snapshot+ref pattern, persistent CDP daemon, real keyboard events) | One shell call per action, no test-file overhead, returns -style refs that map directly to actions. See "Interactive reconnaissance with agent-browser" below. |
| Generate a test script from a prose spec ("test that this modal traps focus and Escape closes it") | or (Claude Code plugin) | LLM generates complete Python Playwright script. Review before trusting. Also captures for deep ARIA tree inspection. See "Test script generation with Webwright" below. |
| Goal-driven journey audit of a live URL — "can a keyboard-only or screen-reader user complete this task?" — with evidence artifacts | (external clone, pinned release ; deterministic runner + agent-driven serve/step loop) | URL + goal in, evidence-linked WCAG findings out — no test file needed. Emulated screen-reader announcements, live-region capture, and focus-indicator measurement at the page/journey level that no other mode provides. See "Goal-driven journey audits with keyboard-a11y-tester" below. |
| Component/unit-level screen-reader assertions — accessible names, reading order, live-region announcements — in the project's own test suite (Vitest/Jest+jsdom, Storybook play functions, or a browser page), no URL or journey needed | @guidepup/virtual-screen-reader
(npm devDependency, exact-pinned ) | Per-component, per-PR spoken-output evidence in milliseconds — the implement→test layer keyboard-a11y-tester can't reach (it needs a deployed URL). Synthetic interactions: never keyboard-operability evidence. See "Component screen-reader assertions with virtual-screen-reader" below. |
| Visual inspection, DOM queries from a conversational session | / agent-browser screenshot --annotate
/ | Same daemon, no test runner needed. |
| Anything requiring real keyboard event delivery through an MCP wrapper | DO NOT USE Playwright MCP. Its calls are silently dropped for most interactive widgets. Use or instead. | |
Decision flowchart:
Do you have a prose description of what to test, but no test script yet?
YES → /webwright:run (one-shot) or /webwright:craft (reusable parameterized tool)
NO, you need to run an existing test → npx playwright test
NO, you have a list of URLs and need machine-readable axe evidence across all of them, no test file → references/baseline-url-scan.mjs (or pa11y-ci --sitemap for sitemap-wide sweeps)
NO, you need to audit a live URL against a user goal (journey, announcements, focus indicators) → keyboard-a11y-tester
NO, you need to assert component announcements, names, or reading order in unit tests (no URL yet) → virtual-screen-reader
NO, you need to explore interactively → agent-browser
CDP keyboard event delivery for has been verified end-to-end on both a vanilla JS disclosure widget (WAI-ARIA APG disclosure-faq example:
focus → press Enter → aria-expanded: false → true
) and a React state-driven modal (react.dev DocSearch:
→ React global keydown listener → state-mounted searchbox). The MCP keyboard delivery bug does not apply to
because it calls CDP
directly rather than through an MCP wrapper.
Verification evidence contract
Evidence type must match the failing condition. A screenshot is never evidence for an interaction-class fix (keyboard operability, focus behavior, or a status-message announcement) — it shows what a sighted mouse user sees, not what a keyboard or screen-reader user experiences. When a fix's evidence doesn't match its defect class, the fix ships labeled partial, naming which defect classes still lack matching evidence.
| Defect class | Evidence REQUIRED before "verified" | Mode |
|---|
| Keyboard operability (reachable, operable with Tab/Enter/Space/Escape/arrows) | Real-keyboard Playwright transcript — actual calls, never ARIA-attribute inspection alone | |
| Focus order & focus-visible sufficiency | Journey-level focus trace evidence | |
| Accessible name/role/state; status-message announcements | Assertion output against actual computed screen-reader output | |
| Machine-detectable semantics, contrast, alt-presence (a rule fires or stops firing) | Re-scan of the touched page(s) after the fix | (axe-core violations; / for the heuristic classes) |
| Visual-only classes (layout, spacing, color/swatch correctness) | Screenshot comparison | screenshots ( / Playwright screenshot) |
This table is what
Phase 0 checks a remediation's attached evidence against, and what
's "Verification evidence" field cites.
Interactive reconnaissance with agent-browser
For ad-hoc a11y probing inside a conversational session — before writing a
file, when verifying a single fix, or when exploring the ARIA structure of an unfamiliar component — use
. The snapshot+ref pattern eliminates locator hunting:
bash
agent-browser open https://example.com/component-under-test
agent-browser snapshot -i # Returns interactive elements with refs: [ref=e1], [ref=e2]...
agent-browser focus @e1 # Focus by ref
agent-browser press Enter # Real CDP keyboard event
agent-browser get attr @e1 aria-expanded # Verify state mutation
agent-browser screenshot --annotate # Numbered overlays mapping to refs (useful for multimodal review)
agent-browser close
Key flags:
(reuse the user's Chrome login state for authenticated sites),
(isolated browser per parallel agent),
(parseable output for programmatic checks),
(safety).
Keyboard-driving discipline (applies to all interactive modes, this one included): never send a pre-counted sequence of Tabs. Snapshot/observe, then act on what is actually focused — "Tab until the focused control is named X" is right; "Tab 6 times" is wrong. Confirm success by state change (attribute flip, URL change, announcement), not assumption.
When to escalate to : when the verification needs to live in CI, run across PR builds, or exercise the 12 APG widget pattern templates below. Reconnaissance with
is for interactive probing; codified regression still belongs in
files.
Test script generation with Webwright
When to use: You have a prose a11y requirement (from the planner or a ticket) and need a runnable test script, without hand-writing it.
What it produces: A Python Playwright script with navigation, keyboard interactions, ARIA state assertions, and screenshots. Webwright generates
scripts by default — if you need async for an existing test harness, specify in the prompt.
Language mismatch warning: Webwright generates Python. Existing CI is Node.js/.spec.js. Generated scripts are starting points — for CI, port logic to .spec.js using the APG templates below, or run Python directly if a Python test runner is available.
Example (actual prompt that produced a passing dialog focus trap test in benchmark):
/webwright:run Navigate to https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/examples/dialog/.
Open the modal by clicking the trigger button.
Verify focus moves into the modal.
Tab through all focusable elements and verify focus wraps (focus trap).
Press Escape and verify the modal closes and focus returns to the trigger.
Quality gate: The operator must review generated scripts before trusting results. Check that:
- Keyboard events use or , NOT synthetic
- Assertions verify state changes (before/after), not just attribute presence
- No > 5 seconds or hardcoded waits that mask timing issues
ARIA snapshot capability: Webwright's Playwright environment captures
page.locator("body").aria_snapshot()
— the full accessibility tree with roles, states, and relationships as structured YAML. Richer than
agent-browser snapshot -i
for structural analysis (captures all 4 tab→panel relationships via aria-controls/aria-labelledby cross-references, vs. agent-browser which shows only interactive element refs).
Limitations:
- No built-in axe-core — the LLM must write injection code (it does this correctly; see benchmark task 3c)
- May miss a11y-specific patterns unless the prompt is specific about what to check
- Python scripts don't run in JS CI without a Python runner
- Requires Claude Code plugin install — not available in Codex CLI
- Do not run simultaneously with agent-browser — both launch Chrome instances that may conflict on ports
Benchmark results (2026-05-26): 25/25 across 5 WAI-ARIA APG tasks (dialog focus trap, tabs ARIA state, axe-core injection, menu keyboard navigation, ARIA tree inspection). All scripts used real
calls. Full results in
evals/suites/webwright-benchmark/
.
Installation
Prerequisites: Python 3.10+, Playwright Python (
pip install playwright && playwright install chromium
)
Two-step install:
/plugin marketplace add microsoft/Webwright
/plugin install webwright@webwright
If marketplace fails: git clone https://github.com/microsoft/Webwright && /plugin install ./Webwright
Platform note: Claude Code plugin only. Not available in Codex CLI. From Codex, the usable browser automation options are
and
(both plain CLIs). Generated
scripts can be executed from Codex via
.
Goal-driven journey audits with keyboard-a11y-tester
When to use: you have a live URL and a task in plain words ("can a keyboard-only or screen-reader user complete X?") and need evidence-linked WCAG findings without writing a test file — discovery audits, before/after patch evidence, whole-journey reviews.
When NOT to use: widget CI regression (→
+ the APG templates), quick probing or authenticated Chrome-profile flows (→
), rule scans (→ axe-core, §4).
What it is: ezufelt/keyboard-a11y-tester — an external tool, adopted at release
(commit
, MIT; originally adopted at
, bumped 2026-07-11 after upstream merged our PR #7 and began tagging releases — re-verify on every upgrade). Two layers: a deterministic Playwright/CDP runner (real keyboard events only, never
; machine-decidable WCAG checks; dual-signal focus-indicator measurement) and an emulated screen-reader persona (
@guidepup/virtual-screen-reader
: announcement capture, live-region monitoring, reading-order census). Runs both W3C personas (keyboard "Ade", screen-reader "Lakshmi") in one pass. As of 0.5.0 it also detects broken ARIA ID references and keyboard-focusable controls missing from the accessibility tree, includes our 3.3.2 UA-default-name check, and supports authenticated runs via
--storage-state <playwright-storageState.json>
(agent-browser remains the route when you want to reuse the user's real Chrome profile instead of exporting state). Cross-validated against this repo's 33 critic fixtures on 2026-07-10 — agreement record:
evals/results/keyboard-a11y-tester/README.md
.
Install (clone path — verified; Node ≥ 20)
bash
git clone https://github.com/ezufelt/keyboard-a11y-tester && cd keyboard-a11y-tester
git checkout 0.5.0 # adopted pin (tagged release)
npm install && node scripts/setup-check.mjs # npx playwright install chromium only if browser_available=false
The upstream Claude Code plugin flow (
/plugin marketplace add ezufelt/keyboard-a11y-tester
) exists but is unverified here; the clone path works from both Claude Code and Codex.
Run
bash
# Batch blind Tab-crawl (unattended; never presses Enter/Space) — per viewport:
node scripts/runner.mjs --url https://site --viewport desktop --max-steps 40 --out <dir>
# Driven session (the value center — the agent decides every keystroke):
node scripts/runner.mjs serve --url https://site --goal "find and submit the contact form" \
--viewport desktop --port 9333 # default port is 9333 (9400 in upstream examples is just an example)
# prints: READY <session-dir>
node scripts/runner.mjs observe <session-dir>
node scripts/runner.mjs step <session-dir> --press Tab # one key → AX name/role/state, focus style, sr_announcement
node scripts/runner.mjs step <session-dir> --type "hello@example.com"
node scripts/runner.mjs finish <session-dir> && node scripts/runner.mjs stop <session-dir>
Core discipline: observe → decide → act (see the keyboard-driving rule in the agent-browser section — it originated here). Read
sr_announcement.live_announcements
after any action that visibly changes the page: an entry proves the update reaches a screen reader; its absence after a visible change is 4.1.3 failure evidence.
Artifacts (temp dir, or )
- — per step: keystroke, selector, CDP AX name/role/state, computed focus style, , screenshot ref,
deterministic-findings.json
— {wcag, persona, conformance_level, confidence, severity, url, locations, persona_impact, evidence[]}
screen-reader-census.json
— whole-page reading order (spoken phrase, role, selector) + declared live regions
screenshots/step_NNNN.png
— focused-region crops
These are measured test evidence for a11y-critic reviews (formal Phase 0 tier wiring lands with assessment Phase 3).
Calibration rules (measured on our 33 fixtures, 2026-07-10)
- Batch-mode 4.1.3 "silent live region" findings are never failure evidence. A blind crawl never operates anything, so correctly-wired regions look silent (confidence 0.35–0.4 vs 0.7+ elsewhere). They are prompts to run a driven session and judge from .
- UA-intrinsic names mask missing labels. An unlabeled reports AX name "Choose File", so the unnamed-control check stays quiet. Label association still needs axe/static/judgment review.
- Component-scale pages ≠ full pages. Skip-link (2.4.1) and landmark findings assume a whole page; on component targets treat them as granularity artifacts.
- AA vs AAA honesty. 2.4.13 focus-appearance findings are AAA-informative by design — never report them as 2.4.7 failures. The AAA pixel measurement is also rendering-environment-sensitive (macOS locally can emit AAA-informative findings that Linux CI does not, observed at both and ) — one more reason never to gate on it.
- Emulated SR ≠ real AT. Findings are spec-compliant-announcement evidence; the §6 manual NVDA/VoiceOver protocol still applies before shipping.
- is the check's gate, not the SC's WCAG level (code-read at , not fixture-measured — upstream #27, filed 2026-08-04). Only the 2.4.13 check emits ; every other finding falls through to , mislabeling the nine Level A SCs the checks cover (1.1.1, 1.3.1, 1.4.1, 2.1.2, 2.4.1, 2.4.3, 3.2.1, 3.3.2, 4.1.2) — 2.4.7 and 4.1.3 are coincidentally correct. Read the field only as pass-fail () vs informative () and derive the SC's true level from the SC number. Delete this rule when the pin advances past a fix for #27.
Mapping findings → A11y Evidence Finding Contract
- severity: → MAJOR (CRITICAL if it blocks the goal); → MINOR or MAJOR by user impact; /AAA-informative → ENHANCEMENT
- fingerprint: derive from selector + wcag + check kind (the tool's embeds the viewport and is run-scoped — don't use it)
- persona → perspective_alarms: → ; →
- evidence: cite step ids + measured values (e.g.
trace.json step_0003: outline 3px solid; AAA contrast 2.34
)
Cautions
- Client production sites: on pages with a CAPTCHA the runner suppresses (page-scoped) so the CAPTCHA can initialize — automation-signal spoofing that can trip a WAF or security review. Get explicit client sign-off before pointing it at client production infrastructure; prefer staging.
- Don't run concurrently with agent-browser or Webwright sessions (Chrome instance/port contention). Chromium-only — cross-browser coverage stays with .
- Run desktop and mobile viewports separately; navigation often collapses behind a disclosure on mobile.
Component screen-reader assertions with virtual-screen-reader
When to use: you're implementing or fixing a component and need to assert what a screen reader computes and announces — accessible names, reading order, live-region announcements — in the project's own test suite, per PR, with no URL, journey, or deployed page. This is the
layer: the cheapest point to catch the toast/async-status defect class.
When NOT to use: keyboard operability (its interactions are synthetic
events — real-key evidence stays with
, agent-browser, or keyboard-a11y-tester), anything visual (jsdom has no layout), page/journey audits (→ keyboard-a11y-tester), rule scans (→ axe-core, §4), open-shadow-DOM components (invisible to it — upstream #182),
states (unsupported — upstream #36).
What it is: guidepup/virtual-screen-reader — a screen-reader simulator as a library (MIT; adopted at npm
, exact-pinned). Walks the accessibility tree of any DOM, emits spoken phrases (
), captures live-region announcements with politeness prefixes (
), and exposes SR quick-key emulation via
virtual.perform(virtual.commands.*)
—
,
, per-level heading jumps,
jumpToErrorMessageElement
(aria-errormessage), aria-flowto reading-order commands. Spec-anchored (ACCNAME 1.2, CORE-AAM, HTML-AAM, WAI-ARIA 1.2) and WPT-tested upstream. It is already this stack's transitive SR engine inside keyboard-a11y-tester; this lane uses it directly. Validated in-repo 2026-07-11 in three environments — plain Node+jsdom, Vitest 4 jsdom environment, real Chromium via its ESM build — plus the Storybook lane 2026-07-13 (10.4.6 play functions via
browser mode, 12/12):
docs/virtual-screen-reader-adoption-assessment.md
. Jest+jsdom is expected to match Vitest but was not run — treat it as unvalidated until someone runs it.
Install (Node ≥ 20)
bash
npm install --save-dev @guidepup/virtual-screen-reader@0.32.1 # exact pin; re-verify calibration rules on any bump
Core patterns (Vitest, jsdom environment — the verified harness)
js
import { afterEach, expect, test } from "vitest";
import { virtual } from "@guidepup/virtual-screen-reader";
afterEach(async () => {
await virtual.stop().catch(() => {}); // stateful singleton — mandatory, or phrase logs leak across tests
document.body.innerHTML = "";
});
// 1. Announcement assertion — persistent-container pattern (the only reliable shape; see rule 3)
test("saving announces to screen reader users", async () => {
document.body.innerHTML = `
<main><button>Save</button></main>
<div role="status" id="app-status"></div> <!-- persistent, empty at mount -->
`;
await virtual.start({ container: document.body });
document.getElementById("app-status").textContent = "Item saved";
await Promise.resolve(); // microtask flush suffices (measured) — no arbitrary sleep needed
expect(await virtual.spokenPhraseLog()).toContain("polite: Item saved");
});
// 2. Reading-order / name assertions — bounded walk (never while-true; see rule 1)
test("reads the expected sequence", async () => {
document.body.innerHTML = `<h2>Cart</h2><p>$29.00</p><button>Buy now</button>`;
await virtual.start({ container: document.body });
const phrases = [];
for (let i = 0; i < 40; i++) { // max-step guard: aria-modal traps the cursor by design
await virtual.next();
const p = await virtual.lastSpokenPhrase();
phrases.push(p);
if (p === "end of document") break;
}
expect(phrases.indexOf("$29.00")).toBeLessThan(phrases.indexOf("button, Buy now"));
});
Storybook stories as SR tests (verified 2026-07-13: Storybook 10.4.6 + browser mode, Chromium)
Component libraries that live in Storybook can make announcement assertions part of the stories themselves — CI-shaped via
npx vitest run --project=storybook
, and visible in the dev-mode Interactions panel:
js
import { expect, userEvent, waitFor } from "storybook/test";
import { virtual } from "@guidepup/virtual-screen-reader";
export const AnnouncesOnSave = {
play: async ({ canvasElement, canvas }) => {
await virtual.start({ container: canvasElement }); // scope to the story canvas
try {
await userEvent.click(await canvas.findByRole("button", { name: "Save order" }));
await waitFor(async () =>
expect(await virtual.spokenPhraseLog()).toContain("polite: Item saved"));
} finally {
await virtual.stop(); // mandatory — measured: the phrase log SURVIVES a missing stop and bleeds into the next story (start() itself recovers; the leak is the hazard)
}
},
};
Calibration rules 1–5 below apply unchanged in this lane (rule 3 re-verified in it). Upstream's own Storybook example omits the
— don't copy that shape. Storybook 8
setups are expected to work but were not run here. Validation record:
evals/results/virtual-screen-reader/harness/storybook/
.
Calibration rules (measured 2026-07-11 at 0.32.1; environments noted)
- Never walk-to-end-of-document when is present (jsdom). The cursor traps inside the modal by design (upstream #54) — the walk never terminates. Scope to the component and bound every walk with a max-step guard.
- A silent or short walk is not a clean pass — check for shadow roots first (jsdom). Open shadow DOM is invisible (upstream #182). If exists anywhere under the container, VSR evidence is partial: route to keyboard-a11y-tester or browser testing instead.
- Mount-with-content live regions read as silent — including correctly-fixed ones (jsdom AND Chromium — engine behavior, not a jsdom artifact). VSR announces mutations inside existing live regions (text changes, child insertions, mount-empty-then-fill) but not insertion of a pre-populated element. Removal splits on (measured, fixture sweep): clearing a non-atomic region is silent; clearing an region announces an empty phrase — an empty polite entry is a region-clear marker, not noise. Interpretive context (domain knowledge, not probed here): real screen readers are also inconsistent on pre-populated alert insertion, which is why robust toast guidance uses a persistent container. Consequences: assert via the persistent-container pattern; a silent log after mount-with-content of an alert is inconclusive, not proof the fix failed; buggy-component silence is defect evidence only alongside the structural fact (no role/aria-live present).
- Fake timers wedge the singleton — hard incompatibility (Vitest; Jest unmeasured, mechanism harness-independent). Under fake timers, resolves but log reads hang forever, and the wedged state cascades hangs into every later test in the file, teardown included. Never enable fake timers in a file that runs VSR. Components needing fake timers (auto-dismiss toasts) get announcement assertions in a separate real-timer file — natural with the persistent-container pattern (assert the announcement on show; the timed dismiss is a separate concern).
- Cite the VSR version in every piece of evidence (). Both consumption routes are frozen (this exact pin; keyboard-a11y-tester's committed lockfile), so skew arises only on deliberate upgrade — the citation makes it detectable. Re-verify rules 1–4 on any version bump.
Evidence
The artifacts are spoken-phrase logs plus the asserting test file — a11y-critic Phase 0 hard evidence (gate passed 2026-07-11:
evals/results/virtual-screen-reader/
; contract mapping in
docs/a11y-evidence-finding-contract.md
). Cite tool version + test file + the exact phrase or its absence, and pair silence with the structural fact (no role/aria-live present). Platform note: plain npm library — works from Claude Code and Codex.
Baseline URL-list scan with references/baseline-url-scan.mjs
When to use: you have a list of URLs — a spot-check set, a sampled route list from a discovery or audit-scope engagement, a client's page inventory — and need machine-readable axe-core evidence across all of them in one run, without authoring a
file per page or wiring CI. Promoted 2026-08-14 from a one-off evidence harness written for the 2026-08-13 EPA public-sites engagement (the zivtech/a11y-audits repo (private),
2026-08-13-epa-public-sites/evidence/harness/audit-pages.mjs
, run against 40 views) into a reusable, generalized reference script:
references/baseline-url-scan.mjs.
When NOT to use: a single page or component you're actively developing against (→
+ the APG templates, or
for quick probing); keyboard operability or screen-reader announcement evidence — this mode never presses a key or captures an announcement (→ keyboard-a11y-tester or virtual-screen-reader); a sitemap-wide sweep where a maintained, hosted CI tool fits better than an in-repo script (→ pa11y-ci, below).
What it is: a plain Node script depending only on
and
— peer dependencies installed in your own project, never in this repo (this bundle stays prompt-only; see this repo's
). It launches one Chromium browser, visits each URL sequentially with a polite delay between requests, scans with axe-core scoped to this bundle's WCAG 2.2 AA default tag set at each configured viewport — default
, matching the EPA harness's desktop+narrow lineage, override with
— and writes a per-URL JSON result keyed by viewport (rule id, impact, node count, up to 3 sample selectors per viewport) plus an aggregated
(violation counts by impact, violations by rule across all pages and viewports). It deliberately drops the EPA harness's other engagement-specific extras — full structure inventory, ARIA snapshot, keyboard tab-trace, text-spacing reflow probe, per-node XPath — to stay a focused baseline tool; reach for
or
when you need those.
Two opt-in flags add non-axe signals, implemented in the sibling references/census.mjs:
- — three DOM-census heuristics, reported under a key on every viewport record, always separate from axe's / and always labeled detector heuristics, never a conformance verdict: empty paragraphs (no text, no element children), autocomplete-absence (inputs whose type/name/label suggest a WCAG 1.3.5 known purpose but carry no attribute), and duplicate ids (an id used on more than one element).
- — writes : one entry per URL, each a sorted list of every /'s selector, , and . Captured once per page (first viewport only — alt text doesn't vary by viewport).
Install and run
bash
npm install -D playwright @axe-core/playwright # peer deps — install in your own project, never in this repo
npx playwright install chromium
node .claude/skills/a11y-test/references/baseline-url-scan.mjs --urls-file urls.txt --out ./baseline-scan-output
# or pass URLs directly, with a custom viewport list:
node .claude/skills/a11y-test/references/baseline-url-scan.mjs --out ./out --viewports 1280x800,320x800 https://example.com https://example.org/page
# with the DOM-census heuristics and an alt-text snapshot:
node .claude/skills/a11y-test/references/baseline-url-scan.mjs --out ./out --census --alt-snapshot https://example.com
: one absolute
URL per line; blank lines and
-prefixed lines are ignored. Raise
(default 500ms) for rate-limited or robots-restricted targets — confirm you're authorized to test the target and check its robots.txt/terms of service before scanning a third party's production site. Exact-pin
in your own project's
, since rule availability is per-axe-core-version — the resolved version is recorded as
in
for exactly this reason.
Catching silent alt-text regressions: run with
before and after a change, then diff the two
files —
bash
node references/baseline-url-scan.mjs --out ./before --alt-snapshot https://example.com/page
# ...make your change...
node references/baseline-url-scan.mjs --out ./after --alt-snapshot https://example.com/page
diff before/alt-snapshot.json after/alt-snapshot.json
Any diff line is either an intentional content update or a silent regression — a human confirms which.
What this mode IS and IS NOT
- IS for: baseline and regression sweeps across many pages in one run; machine-readable evidence (rule id, impact, node count, sample selectors) that can feed a11y-critic Phase 0 or the Optional A11y Evidence Finding Contract (§4 below); trend baselines across repeated runs of the same URL list — rerun and diff (or for alt-text specifically).
- IS NOT: keyboard-operability or screen-reader evidence of any kind — it never presses a key or captures an announcement (route those to keyboard-a11y-tester or virtual-screen-reader). Axe-core is a detector, not a verdict authority here, same as every other automated lane in this bundle: it covers roughly 30-40% of WCAG 2.2 issue classes (the same axe-detectable-subset ceiling as the §4 in-spec-file scans below), and its rules are heuristics, not the standard itself. A clean scan is not a conformance claim — it means axe found nothing in its rule set on the URLs scanned, nothing more. Treat the output as candidate findings for human review. The checks are heuristics one level below even axe's rules — pattern-matches on markup shape and naming, not accessibility-tree computation — so their false-positive rate is higher by design; triage every hit by hand before filing it.
pa11y-ci for sitemap-wide sweeps (routed, not vendored)
For a sweep driven by a sitemap rather than a hand-maintained URL list, route to
instead of adding sitemap discovery to this script:
bash
npx pa11y-ci --sitemap https://example.com/sitemap.xml --runner axe --runner htmlcs
Same adoption boundary as keyboard-a11y-tester and virtual-screen-reader: a routed external tool the operator installs in their own project, never vendored into this repo.
1. Keyboard Accessibility Tests
MANDATORY: All keyboard tests MUST use real Playwright keyboard interactions against a live or local site. Never check ARIA attributes alone and claim a keyboard test passed — you must actually press keys and verify the result.
Required Testing Method
- Use
page.keyboard.press('Enter')
, page.keyboard.press('Tab')
, page.keyboard.press('Escape')
, page.keyboard.press('Space')
for single keys
- Use
page.keyboard.press('Shift+ArrowRight')
, page.keyboard.press('Control+Enter')
, page.keyboard.press('Meta+Enter')
for key combos
- Use
page.keyboard.down('Shift')
/ page.keyboard.up('Shift')
with page.keyboard.press('ArrowRight')
for held-key sequences (e.g., text selection)
- Use then verify with or
document.activeElement === element
- NEVER use synthetic
dispatchEvent(new KeyboardEvent(...))
to test keyboard features — that bypasses the real browser keyboard path and proves nothing
- NEVER claim a keyboard test passed by only reading DOM attributes (aria-expanded, aria-pressed, etc.) without actually pressing a key and observing the state change
What to Test (with real key presses)
- Tab order: Press Tab repeatedly and verify focus moves to each interactive element in logical order
- Enter/Space activation: Focus a button/link, press Enter or Space, verify the expected action occurred (panel opened, state toggled, navigation happened)
- Escape to dismiss: Open a modal/popup/sidebar, press Escape, verify it closed
- Arrow key navigation: For tablists, menus, and custom widgets — press Arrow keys and verify focus/selection moves
- Keyboard text selection: For content areas — use Shift+Arrow to select text, verify selection was created via
- Modifier combos: Test Ctrl+Enter, Meta+Enter, and other app-specific shortcuts
- Focus management: After opening/closing panels, verify focus moves to the correct element (e.g., CKEditor gets focus when annotation form opens, focus returns to trigger after modal closes)
State Verification Pattern
Every keyboard test must follow this pattern:
1. Record initial state (aria-expanded, aria-pressed, visibility, activeElement)
2. Perform real keyboard action (page.keyboard.press)
3. Wait for UI to update (waitForTimeout or waitForFunction)
4. Verify state actually changed (attribute toggled, element visible/hidden, focus moved)
Example — testing a toggle button:
js
const btn = page.locator('button[aria-expanded]');
const initialExpanded = await btn.getAttribute('aria-expanded');
await btn.focus();
await page.keyboard.press('Enter');
await page.waitForTimeout(300);
const afterExpanded = await btn.getAttribute('aria-expanded');
expect(initialExpanded).not.toBe(afterExpanded); // State MUST change
WAI-ARIA APG Keyboard Test Templates
Reusable Playwright templates for common widget patterns. Each uses real
calls — never synthetic events.
1. Tree View
Interactions: ArrowDown/Up move
; ArrowRight expands closed node or moves to first child; ArrowLeft collapses open node or moves to parent; Home/End jump to first/last visible treeitem; Enter activates.
js
const tree = page.locator('[role="tree"]');
await tree.focus();
const before = await tree.getAttribute('aria-activedescendant');
await page.keyboard.press('ArrowDown');
await page.waitForTimeout(200);
const after = await tree.getAttribute('aria-activedescendant');
expect(after).not.toBe(before);
expect(after).toBeTruthy(); // must reference a [role="treeitem"] id
2. Roving Tabindex (Tabs)
Interactions: ArrowRight/Left move focus between
elements and update tabindex; active tab keeps
, others get
; only one
per
.
js
const activeTab = page.locator('[role="tab"][tabindex="0"]');
await activeTab.focus();
await page.keyboard.press('ArrowRight');
await page.waitForTimeout(200);
const newActive = page.locator('[role="tab"][tabindex="0"]');
await expect(newActive).toHaveAttribute('aria-selected', 'true');
expect(await page.locator('[role="tab"][aria-selected="true"]').count()).toBe(1);
3. Dialog Focus Trap
Interactions: Tab/Shift+Tab cycle within
(last focusable→first, first→last); Escape closes; focus returns to trigger after close.
js
await triggerButton.click();
const dialog = page.locator('[role="dialog"]');
// Tab past last focusable item — should wrap to first
const focusables = dialog.locator('button, [href], input, [tabindex="0"]');
const count = await focusables.count();
for (let i = 0; i < count; i++) await page.keyboard.press('Tab');
await expect(focusables.first()).toBeFocused();
await page.keyboard.press('Escape');
await expect(triggerButton).toBeFocused();
4. Sidebar/Panel Focus Management
Interactions: Close button receives focus on panel open; Escape closes panel and returns focus to trigger.
js
await triggerButton.click();
const panel = page.locator('[role="region"]'); // or your panel selector
await expect(panel.locator('button[aria-label*="Close"]')).toBeFocused();
await page.keyboard.press('Escape');
await page.waitForTimeout(150); // allow React unmount + setTimeout(0)
await expect(triggerButton).toBeFocused();
5. Disclosure Widget
Interactions: Enter/Space toggle
between "true"/"false";
references the panel id; panel visibility matches expanded state.
js
const btn = page.locator('button[aria-expanded]');
await btn.focus();
const initial = await btn.getAttribute('aria-expanded');
await page.keyboard.press('Enter');
await page.waitForTimeout(200);
const toggled = await btn.getAttribute('aria-expanded');
expect(toggled).not.toBe(initial);
const panelId = await btn.getAttribute('aria-controls');
const panel = page.locator(`#${panelId}`);
await expect(panel).toBeVisible(); // when expanded=true
6. Menu Button / Dropdown
Interactions: Enter/Space opens menu, focus moves to first item; Arrow keys navigate with wrapping; Escape closes and returns focus to trigger; Home/End jump to first/last; type-ahead jumps to matching item.
js
const trigger = page.locator('button[aria-haspopup="menu"]');
await trigger.focus();
await page.keyboard.press('Enter');
await page.waitForTimeout(200);
const menu = page.locator('[role="menu"]');
await expect(menu).toBeVisible();
await expect(menu.locator('[role="menuitem"]').first()).toBeFocused();
await page.keyboard.press('End');
await expect(menu.locator('[role="menuitem"]').last()).toBeFocused();
await page.keyboard.press('Escape');
await expect(trigger).toBeFocused();
7. Combobox / Autocomplete
Interactions: typing shows listbox with filtered options; ArrowDown focuses first option; Enter selects and closes; Escape closes without selection;
and
update.
js
const input = page.locator('[role="combobox"]');
await input.focus();
await input.type('ap');
await page.waitForTimeout(300);
await expect(input).toHaveAttribute('aria-expanded', 'true');
const listbox = page.locator('[role="listbox"]');
await page.keyboard.press('ArrowDown');
expect(await input.getAttribute('aria-activedescendant')).toBeTruthy();
await page.keyboard.press('Enter');
await expect(listbox).toBeHidden();
8. Listbox (single and multi-select)
Interactions: Arrow keys move selection in single-select; Space toggles in multi-select; Shift+Arrow extends range; Home/End jump to first/last; type-ahead navigation.
js
const listbox = page.locator('[role="listbox"]');
await listbox.focus();
await expect(listbox.locator('[role="option"]').first()).toHaveAttribute('aria-selected', 'true');
await page.keyboard.press('ArrowDown');
await page.waitForTimeout(150);
await expect(listbox.locator('[role="option"]').nth(1)).toHaveAttribute('aria-selected', 'true');
await page.keyboard.press('End');
await expect(listbox.locator('[role="option"]').last()).toBeFocused();
9. Slider
Interactions: ArrowLeft/Right adjust by step; PageUp/Down by larger increment; Home/End set to min/max;
,
,
update.
js
const slider = page.locator('[role="slider"]');
await slider.focus();
const before = Number(await slider.getAttribute('aria-valuenow'));
await page.keyboard.press('ArrowRight');
await page.waitForTimeout(150);
expect(Number(await slider.getAttribute('aria-valuenow'))).toBeGreaterThan(before);
await page.keyboard.press('Home');
expect(await slider.getAttribute('aria-valuenow')).toBe(await slider.getAttribute('aria-valuemin'));
await page.keyboard.press('End');
expect(await slider.getAttribute('aria-valuenow')).toBe(await slider.getAttribute('aria-valuemax'));
10. Date Picker
Interactions: Arrow keys navigate days; PageUp/Down navigate months; Shift+PageUp/Down navigate years; Enter selects and closes; Escape closes without selection and returns focus to input.
js
const input = page.locator('[aria-label*="date" i]');
await input.focus();
await page.keyboard.press('Enter');
const grid = page.locator('[role="grid"]');
await expect(grid).toBeVisible();
await page.keyboard.press('ArrowRight');
await page.keyboard.press('PageDown');
await page.keyboard.press('Enter');
await expect(grid).toBeHidden();
expect(await input.inputValue()).not.toBe('');
11. Accordion
Interactions: Enter/Space on header toggles panel;
reflects state; Arrow keys move between headers; Home/End jump to first/last header.
js
const headers = page.locator('[role="button"][aria-expanded]');
await headers.first().focus();
const initial = await headers.first().getAttribute('aria-expanded');
await page.keyboard.press('Enter');
await page.waitForTimeout(200);
expect(await headers.first().getAttribute('aria-expanded')).not.toBe(initial);
await page.keyboard.press('ArrowDown');
await expect(headers.nth(1)).toBeFocused();
await page.keyboard.press('End');
await expect(headers.last()).toBeFocused();
12. Radio Group
Interactions: Arrow keys move selection within group (roving tabindex); Tab moves to/from group as a whole; first or checked radio receives initial focus;
updates with selection.
js
const radios = page.locator('[role="radiogroup"] [role="radio"]');
await radios.first().focus();
await page.keyboard.press('ArrowDown');
await page.waitForTimeout(150);
await expect(radios.nth(1)).toHaveAttribute('aria-checked', 'true');
await expect(radios.first()).toHaveAttribute('aria-checked', 'false');
await page.keyboard.press('Tab');
await expect(radios.nth(1)).not.toBeFocused();
Live Site Requirement
Keyboard tests MUST run against a real site (local dev environment like Lando/DDEV, or staging). Guard against accidental use of mocks:
js
if (!BASE_URL || !BASE_URL.match(/https?:\/\/.+/)) {
throw new Error('Keyboard tests require a real site. Set BASE_URL.');
}
SPA-Specific Testing Patterns
React and other SPA frameworks introduce gotchas that break naive Playwright tests:
-
No direct URL navigation: SPA routes (e.g.,
/book/truth-lending/2460032
) return 404 from the server — the server has no route for them. Navigate WITHIN the app by clicking menu items and waiting for React to render. Use
to confirm content has loaded before interacting.
-
Duplicate DOM (mobile + desktop): Many React apps render the same component twice — once for desktop, once for mobile. Playwright strict mode throws when a selector matches both. Fix by scoping to a container (
nav.left-sidebar [role="tree"]
) or appending
/
to your locator.
-
React state waits: After
, React state updates are async — the DOM may not reflect the new state for tens of milliseconds. Add
or
waitForFunction(() => ...)
before asserting on ARIA attributes that change via React state.
-
React 16 for focus-after-unmount: In React 16, focus calls issued inside async callbacks do not survive component unmount. Production code must wrap the focus call in
setTimeout(() => el.focus(), 0)
. Tests must account for this by allowing 100–200ms after a panel closes before checking
.
-
DOMPurify stripping attributes: A bare
call strips
attributes by default. If tests find click handlers broken after sanitization, the fix is to route sanitization through a wrapper component that calls sanitize at render time (not as a pre-processing step that discards needed attributes).
-
Playwright MCP cannot deliver keyboard events: The Playwright MCP browser integration CANNOT forward keyboard events —
calls are silently dropped for most interactive widgets. Always run keyboard a11y tests with
using
files. Use the MCP browser only for visual inspection and DOM queries.
CSS Anti-patterns That Break Keyboard Access
Never use
on elements that are supposed to become visible when a parent receives keyboard focus via
. The pattern creates an impossible state for keyboard users:
- removes the element from the tab order entirely
- Because the element can't receive focus, is never triggered on the parent
- Result: keyboard users can never reach the element at all
css
/* ❌ BROKEN — keyboard users can never trigger :focus-within on the parent */
.annotation-block-edit {
opacity: 0;
visibility: hidden; /* removes from tab order → :focus-within never fires */
}
.annotation-block:focus-within .annotation-block-edit {
opacity: 1;
visibility: visible;
}
/* ✅ CORRECT — opacity keeps element in tab order; :focus-within works */
.annotation-block-edit {
opacity: 0; /* visually hidden but still focusable */
}
.annotation-block:hover .annotation-block-edit,
.annotation-block:focus-within .annotation-block-edit {
opacity: 1;
}
This applies to any "reveal on hover/focus" pattern: edit buttons, delete buttons, action menus inside cards. Use
only (not
) when the element must remain keyboard-reachable.
ARIA Attribute Checks (supplement, not substitute)
After verifying keyboard operability, also check:
- Buttons have or visible text
- Toggle buttons have or
- Tab widgets have , ,
- SVGs inside buttons have
- Close buttons have descriptive
- Only one tab has per tablist
Section 5: Time-Based Media Tests
Run these tests when
,
, or media player components are present.
Caption Infrastructure
- Verify exists on every with speech
- Verify has valid pointing to caption file
- Verify caption toggle control exists and is keyboard-accessible
Transcript Availability
- Verify transcript exists adjacent to media OR a visible link to it
- For audio-only content: verify full text transcript is available
Media Player Keyboard Access
- Tab: focus enters player controls; all controls have visible focus indicators
- Space: play/pause toggle
- Arrow keys: seek forward/backward; Up/Down: volume control
- C or CC button: caption toggle; Escape: exit fullscreen
Audio Auto-play
- Verify no audio auto-plays on page load
- If auto-play exists: verify pause/stop control is the first focusable element
Section 6: Screen Reader Test Protocol
Test Matrix
| Screen Reader | Browser | Mode |
|---|
| NVDA | Chrome | Browse mode + Focus mode |
| VoiceOver | Safari (macOS) | Web rotor + standard navigation |
| (Optional) JAWS | Chrome/Edge | Virtual cursor + Forms mode |
Landmark Navigation Test
- Use landmark navigation (NVDA: D key, VoiceOver: Web rotor)
- Verify: , , , announced correctly
- Verify: multiple elements have distinguishing
Heading Navigation Test
- Navigate by headings (NVDA: H key, VoiceOver: Web rotor); verify hierarchy is logical, no skipped levels; present
Form Mode Test
- Tab into form (NVDA enters focus mode automatically)
- Verify: each input announces its label and "required" if applicable
- Verify: error messages announce when field is focused; reads after label
Live Region Test
- Trigger dynamic content changes (form submission, async updates, notifications)
- Verify: announces after current speech
- Verify: interrupts; toast content announced without focus moving
SPA Route Change Test
- Navigate between routes; verify page title updates and is announced
- Verify: focus moves to main content or heading; back button restores expected focus
2. Visual Regression Tests (REQUIRED)
Visual regression tests ensure accessibility fixes don't introduce unintended visual changes. Supports Playwright and optionally BackstopJS for side-by-side HTML reports.
Baseline Strategy
- Preferred: Use
npx playwright test --update-snapshots
on the current branch to establish baselines, then run tests after further changes to detect regressions.
- CRITICAL — build must be complete first: Only run after any build (React, webpack, etc.) has fully finished. Running it during a concurrent build captures mixed pre/post-build screenshots — some pages reflect old code, some new. The resulting baseline is internally inconsistent and will fail on the next clean run. Wait for the build to complete, then run , then run the tests.
- Cross-branch comparison: Only when explicitly requested. Requires branch switching, cache clearing, and potential config sync — avoid unless necessary.
- Never assume branch-switching is safe without checking with the user first.
Playwright Screenshot Configuration
Use
with the correct options:
- (0 to 1): Maximum ratio of different pixels to total pixels. Use (1%) for element screenshots, (3%) for full-page screenshots. This is the primary control for flakiness.
- (0 to 1): Per-pixel color distance tolerance (0 = exact, 1 = any color). Default is fine for most cases. This is NOT the overall diff threshold.
- : Absolute count of allowed different pixels. Alternative to .
js
// Element screenshot — tight tolerance
await expect(element).toHaveScreenshot('name.png', {
maxDiffPixelRatio: 0.01,
});
// Full-page screenshot — looser for dynamic content
await expect(page).toHaveScreenshot('name.png', {
fullPage: true,
maxDiffPixelRatio: 0.03,
mask: [page.locator('.dynamic-region')],
});
BackstopJS (Optional)
BackstopJS provides an HTML report with side-by-side visual diffs — useful for manual review. It can run alongside Playwright tests.
Setup:
bash
npm install --save-dev backstopjs
- Use for shared settings (delay, misMatchThreshold, removeSelectors)
- Use
"selectors": ["document"]
for full-page, or class/tag selectors for elements
- Avoid attribute selectors with quoted values (e.g. ) — they cause parse errors in Puppeteer engine
- Use
requireSameDimensions: false
for pages with dynamic heights
- Full-page scenarios need higher (15-20%) due to dynamic content
- Element scenarios can use tighter thresholds (5-10%)
Popup/overlay handling:
Create an
engine script (use
extension if project has
in package.json):
js
const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms));
module.exports = async (page, scenario, vp) => {
await wait(2000);
await page.evaluate(() => {
document.querySelectorAll('dialog, [role="dialog"], .modal, .popup').forEach(el => el.remove());
});
await wait(300);
};
Workflow:
bash
npx backstop reference --config=path/to/backstop.json # Capture baseline
npx backstop test --config=path/to/backstop.json # Compare against baseline
npx backstop approve --config=path/to/backstop.json # Promote test -> reference
npx backstop openReport --config=path/to/backstop.json # View HTML report
Handling Dynamic Content
CMS pages often contain dynamic elements (timestamps, session blocks, popups). These cause false failures.
- Prefer element-level screenshots over full-page — more stable and more useful for a11y regression detection.
- Mask dynamic regions: Playwright uses , BackstopJS uses or .
- Common elements to mask/remove: , , , , , , time/date elements.
- Dismiss popups before capture: Use Playwright's helper or BackstopJS .
- Use
waitForLoadState('networkidle')
and a short wait to let JS behaviors settle before capture.
Contrast Verification
- Use browser DevTools (Chrome: CSS Overview, Firefox: Accessibility Inspector) to audit all text contrast
- Run axe-core with rule enabled (catches most but not all cases)
- Manually check: text over images/gradients (axe-core misses these)
- Manually check: focus indicator contrast against both focused and unfocused backgrounds
- Check non-text contrast: UI component borders, icons, form control outlines (WCAG 1.4.11)
- Test with forced-colors mode: verify all interactive elements remain distinguishable
Zoom and Reflow Verification
- Set viewport to 1280px, zoom to 400% (equivalent to 320px)
- Verify: no horizontal scrollbar, content reflows to single column
- Verify: no text truncation, overlap, or content hidden behind other elements
- Test text spacing override: 1.5x line height, 2x paragraph spacing, 0.12em letter spacing
- Verify: all interactive elements remain visible and operable at 200% zoom
Elements to Test
- Focus indicators (links, buttons, inputs in :focus state)
- Breadcrumbs (structure and current page indicator)
- Navigation menus (default, hover, active states)
- Form inputs (borders, focus states)
- Link underlines in content areas
- External link icons
- Skip links (when visible)
- Progress bars and loading indicators
Viewport Sizes
- Desktop: 1280x800
- Tablet: 768x1024
- Mobile: 320x568
Reporting
- Playwright:
npx playwright show-report
for HTML report with side-by-side diffs
- BackstopJS: for visual comparison dashboard
3. WCAG Compliance Checks
- 1.1.1 Non-text Content (alt text, aria-labels)
- 1.4.1 Use of Color (link underlines)
- 1.4.3 Contrast Minimum (4.5:1 normal text, 3:1 large text — note: text inside UI components like buttons uses TEXT thresholds, not the 3:1 UI component boundary threshold)
- 1.4.10 Reflow (320px viewport)
- 1.4.11 Non-text Contrast (form borders, focus indicators)
- 2.4.4 Link Purpose (contextual aria-labels)
- 2.4.6 Headings and Labels (no empty headings)
- 2.4.7 Focus Visible (outline visibility)
- 2.4.8 Location (breadcrumbs with aria-current)
- 2.4.11 Focus Not Obscured (focused element not hidden by sticky headers/footers/banners) [WCAG 2.2]
- 2.4.13 Focus Appearance (focus indicator ≥2px perimeter, 3:1 contrast change) [WCAG 2.2]
- 2.5.7 Dragging Movements (drag ops have single-pointer alternative) [WCAG 2.2]
- 2.5.8 Target Size (interactive targets ≥24x24 CSS pixels) [WCAG 2.2]
- 3.3.7 Redundant Entry (don't re-ask for info already provided) [WCAG 2.2]
- 3.3.8 Accessible Authentication (no cognitive function tests for login, paste/autofill supported) [WCAG 2.2]
4. Automated Scanning (axe-core via Playwright)
Inject axe-core into live pages via Playwright for automated WCAG violation detection. This catches issues that manual review misses (computed contrast through CSS layers, missing ARIA on dynamically rendered content, landmark coverage).
axe-core Injection Pattern
js
// In a Playwright test file (.spec.js)
const { test, expect } = require('@playwright/test');
const fs = require('fs');
test('axe-core accessibility scan', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForLoadState('networkidle');
// Inject axe-core
const axeSource = fs.readFileSync(
require.resolve('axe-core/axe.min.js'), 'utf-8'
);
await page.evaluate(axeSource);
// Run audit
const results = await page.evaluate(() =>
axe.run(document, {
runOnly: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'best-practice']
})
);
// Report violations
const violations = results.violations;
if (violations.length > 0) {
const report = violations.map(v => ({
id: v.id,
impact: v.impact,
description: v.description,
helpUrl: v.helpUrl,
nodes: v.nodes.length
}));
console.log('axe violations:', JSON.stringify(report, null, 2));
}
expect(violations.length).toBe(0);
});
Multi-Page Scanning
For sites with multiple routes, scan each page variant:
- Default state (no interactions)
- Loading state (if applicable — trigger a load and scan before it completes)
- Error state (submit an invalid form, then scan)
- Expanded state (open all disclosures/tabs, then scan)
Dynamic Test Prioritization
After the axe-core scan, use findings to prioritize manual testing effort:
- axe found ARIA violations → prioritize screen reader testing (Section 1 keyboard + ARIA checks)
- axe found color-contrast violations → prioritize visual inspection (Section 2 focus indicators, link underlines)
- axe found heading/structure violations → prioritize keyboard navigation order testing
- axe found no form violations → deprioritize form testing with a note that automated checks passed
- Always test regardless: focus indicators at zoom, reduced-motion, skip links
Scale and Sampling (>15 pages)
For large sites, classify pages into template groups and scan one representative per group:
- Run phase: list all routes, group by template (list page, detail page, form page, etc.)
- Select 1-2 pages per template group
- Scan representatives, report which templates were covered
- Document sampling strategy in the test report
For audit-scope runs (conformance audits, pre-VPAT work), extend template sampling with WCAG-EM sampling discipline (Steps 3–4 of
WCAG-EM 2.0; verified reference:
docs/wcag-em-2-reference.md
):
- Random sample: add a randomly selected sample of 10% of the structured (template-based) sample, on top, drawn from routes not already selected; record the selection method in the test report
- Representativeness check: if the random sample surfaces content types or violation patterns the structured sample missed, the template classification was not representative — expand the structured sample, re-classify, and repeat until the random sample stops surfacing new finding types
- Complete processes: multi-view journeys (checkout, multi-step applications, auth) are tested end-to-end, never as isolated pages — include every view in the process (the default sequence plus completion-critical branches) and route them to keyboard-a11y-tester driven sessions (see "Goal-driven journey audits" above); per-page scans do not count as process evidence
- State coverage: each sampled page is evaluated in its states (default, loading, error, expanded — the Multi-Page Scanning list above); name the state coverage in the sampling documentation
Output Format
Report axe-core results alongside keyboard and visual regression results:
## axe-core Scan Results
Pages scanned: [count]
Total violations: [count]
Critical: [n] | Serious: [n] | Moderate: [n] | Minor: [n]
### Violations by Rule
| Rule ID | Impact | Description | Pages | Elements |
|---------|--------|-------------|-------|----------|
| color-contrast | serious | Elements must meet color contrast | 3 | 12 |
This output feeds directly into the a11y-critic's Phase 0 (Consume Test Evidence) — measured violations become hard evidence in the design review.
Optional A11y Evidence Finding Contract
When a test produces a failing keyboard, axe-core, visual, static-analysis, or manual finding, include an
block for each issue that should be handed to
or
. Do not emit placeholder contracts for passing checks or clean fixtures.
Use these fields when evidence exists:
### A11y Evidence Finding
finding_id: stable lowercase id, e.g. a11y_form_error_describedby
fingerprint: stable 8-64 char hex hash derived from component/target/rule, not the crawl URL alone
source: test command, test file, axe rule id, agent-browser ref, or observed artifact
wcag_or_apg: WCAG 2.2 criterion or WAI-ARIA APG pattern citation
section_508_fpc_context: Section 508 context only when applicable; Revised Section 508 maps web conformance to WCAG 2.0 Level A/AA
severity: CRITICAL | MAJOR | MINOR | ENHANCEMENT
perspective_alarms: screen_reader_semantic=LOW|MEDIUM|HIGH; keyboard_motor=LOW|MEDIUM|HIGH; etc.
evidence: file:line, DOM excerpt, axe node, screenshot, keyboard trace, or measured value
reproduction_steps: commands or user steps needed to reproduce
expected_behavior: what the user or assistive technology should experience
actual_behavior: what the test observed
trend: new | persistent | worsening | improving | resolved
Guidelines:
- Treat WCAG 2.2 AA as the current planning and testing target. Treat Section 508 as regulatory context only when the project scope explicitly requires it.
- Use stable fingerprints to support trend language across reruns. Prefer component name + selector/accessibility target + rule/pattern + criterion over route-only fingerprints.
- Mark perspective alarms only when the evidence suggests a perspective-specific access risk. Any MEDIUM or HIGH alarm can feed .
- Do not copy scanner/runtime code or generated dashboard state from external projects into this skill. This contract is a reporting discipline, not a crawler product boundary.
5. Static Analysis (eslint-plugin-jsx-a11y) — React/Vue/JSX only
Use when the project uses React, Next.js, Vue, or other JSX/TSX framework. Catches missing alt text, invalid ARIA, and inaccessible element nesting at build time — no running server needed.
Setup
bash
# Install as dev dependency
pnpm add -D eslint-plugin-jsx-a11y # or npm/yarn
# Create temporary standalone config (avoids ESLint 9 flat config issues)
cat > eslint.a11y.mjs << 'EOF'
import jsxA11y from "eslint-plugin-jsx-a11y";
import tseslint from "typescript-eslint";
export default [{
files: ["src/**/*.tsx", "src/**/*.jsx"],
plugins: { "jsx-a11y": jsxA11y },
languageOptions: {
parser: tseslint.parser,
parserOptions: { ecmaFeatures: { jsx: true } },
},
rules: { ...jsxA11y.flatConfigs.recommended.rules },
}];
EOF
# Run
npx eslint --config eslint.a11y.mjs src/
# Clean up temp config (keep the plugin installed)
rm eslint.a11y.mjs
Known False Positives
Custom component
props, ARIA passed via spread, dynamic content loaded post-render, Next.js
components (render valid anchors at runtime).
ICT Testing Baseline coverage crosswalk (declared Section 508 audits only)
When an audit-scope engagement declares Revised Section 508, its baseline-coverage statement is sourced from
references/ict-baseline-crosswalk.yaml: a hand-built map of all 62 active web baseline tests (pinned at
@
, 2026-08-12) to the execution modes above and the evidence artifact each produces — 22 covered / 26 partial / 13 not-covered / 1 always-passes. The gate is the planner federal profile's conformance floor declaration in the engagement's audit-scope plan: no floor declaration → no baseline citations in any output, and a baseline citation in a component-scope review is a finding against the output. One exemption: an engagement-independent capability statement quoting the crosswalk verbatim ("designed to cover N of 62; gaps: ...") may answer a pre-award/procurement capability question with no floor declaration — findings, reviews, and reports stay gated.
Rules:
- The not-covered and partial rows are the deliverable. They name what gets assigned to manual/AT methods in the evaluation plan's sampling and coverage boundary. Never imply stack coverage the crosswalk doesn't grant; a test classified judgment-only in the manifest is never .
- Every baseline test ID cited must exist in
docs/ict-baseline-test-id-manifest.yaml
for the web baseline. IDs are valid per-baseline ( is web-only; is documents-only), and baseline IDs are the exact-ID class models fabricate — hand-verify every one; never generate them.
- Phrasing: "designed to cover N of 62; gaps: ..." — never "baseline-aligned" or "baseline-conformant" (alignment recognition is an external review of a test process), and never any Trusted Tester certification claim (a DHS credential held by humans).
- always passes by upstream design (WCAG 2.0 Errata 13) — execute nothing for it; report real markup consequences under the SCs they actually break.
- The Electronic Documents baseline is out of measurement scope entirely (web-only stack): document samples go to the report's coverage boundary with a manual/AT method, never to these execution modes.
- Maintenance: the crosswalk is rebuilt by hand against
docs/ict-testing-baseline-reference.md
on that reference's recheck triggers — a regenerated crosswalk without value-checking is the fabrication failure mode by construction.
Test Execution Order
- Static analysis (§5) — fast, no server needed
- Keyboard accessibility tests (§1)
- Visual regression tests (§2)
- axe-core automated scans (§4)
- WCAG compliance checks (§3)
- Time-based media tests (§5-media) — if applicable
- Screen reader tests (§6) — if applicable
- Report consolidated results with pass/fail counts per section
Lifecycle integration: These test results feed into a11y-critic reviews. The full a11y lifecycle is:
plan → [generate test scripts] → critique plan → revise → implement → test (this skill) → critique implementation → fix → re-test
Webwright script generation fits between "plan" and "critique plan" — use it to generate test scripts from the planner's output before running them. Generated scripts are inputs to the test phase, not a replacement for it.