- name
- audit-accessibility
- description
- WCAG 2.2 AA audit via playwright-cli: axe-core, keyboard, contrast, target size, focus, ARIA. Use when "audit accessibility", "check a11y", "WCAG audit", "check keyboard nav", or "test screen reader".
- license
- MIT
- effort
- high
# Accessibility Audit
**Degree of freedom: MIXED** — Phases 0–1, 5 `[HIGH freedom]`; Phases 2–3
playwright/axe/Tab sequences `[LOW freedom — run exactly]`. Read
`protocol-browser-anti-stall` before any browser step.
## How to reason
1. **Observe** — quote axe rule+selector, heading list, or Tab snapshot
2. **Interpret** — can a keyboard or AT user complete the task?
3. **Classify** — WCAG fail / axe-false-positive / manual-only / correct-as-is
4. **Severity** — Level A = P0; Level AA = P1; best-practice = P2
## Worked example
> **Observe:** axe `label` on `/signup`; `<input name="email">` has no `id`/`aria-label`.
> **Interpret:** AT users cannot tell what to type (1.3.1 / 4.1.2).
> **Classify:** WCAG fail (not an axe false positive).
> **Severity:** P0 — Level A, blocks signup.
> **Finding:** `/signup` | 1.3.1+4.1.2 | P0 | unlabeled email | `<label for>`.
## Self-critique before reporting [LOW freedom — do not skip]
1. **WCAG SC + numbers** — not "contrast is bad"; quote ratio or selector
2. **Keyboard, not axe-only** — 2.1.1 fails even if axe is clean
3. **Every reachable page** — obscure routes still count
4. **Severity justified** — Level A = P0
5. **Axe is ~30–40%** — heading/focus/link-text/reading-order always manual
6. **2.2 additions probed** — 2.4.11, 2.5.7, 2.5.8, 3.3.8 each have a probe result, not "axe clean"
---
## Phase 0: Auto-Detect Pages and Components
### 0a. Discover Route Structure
```
Glob("**/app/**/page.{tsx,jsx,ts,js}")
Glob("**/pages/**/*.{tsx,jsx,ts,js}")
Glob("**/src/routes/**/*.{tsx,jsx,svelte,vue}")
```
### 0b. Find the App URL
```
Grep(pattern: "NEXT_PUBLIC_APP_URL|NEXT_PUBLIC_BASE_URL|VITE_APP_URL|APP_URL|localhost", glob: ".env*")
```
If no production URL, default to `http://localhost:3000` (or detected dev server port).
### 0c. Detect CSS Framework and Component Library
```
Grep(pattern: "tailwindcss|@chakra-ui|@mui|@radix-ui|shadcn|bootstrap|bulma", glob: "package.json")
```
### 0d. Detect Existing a11y Tooling
```
Grep(pattern: "axe-core|@axe-core|eslint-plugin-jsx-a11y|pa11y|lighthouse|@testing-library", glob: "package.json")
Grep(pattern: "aria-|role=|tabIndex|sr-only|visually-hidden", glob: "*.{tsx,jsx,vue,svelte}", output_mode: "count")
```
Record: `APP_URL`, `PAGES`, `CSS_FRAMEWORK`, `COMPONENT_LIB`, `EXISTING_A11Y_TOOLS`, `ARIA_USAGE_COUNT`.
---
## Phase 1: Research Current WCAG Guidelines
### 1a. Firecrawl Research
```json
firecrawl:firecrawl_search
{
"query": "WCAG 2.2 success criteria checklist web accessibility [current year]",
"limit": 5
}
```
```json
firecrawl:firecrawl_search
{
"query": "axe-core automated accessibility testing best practices common false positives",
"limit": 5
}
```
```json
firecrawl:firecrawl_search
{
"query": "<CSS_FRAMEWORK> <COMPONENT_LIB> accessibility best practices common issues",
"limit": 5
}
```
Scrape 2–3 authoritative results:
```json
firecrawl:firecrawl_scrape
{
"url": "<BEST_RESULT_URL>",
"formats": ["markdown"],
"onlyMainContent": true
}
```
### 1b. Framework-Specific a11y Docs (Context7)
```json
context7:resolve-library-id
{
"libraryName": "<COMPONENT_LIB e.g. radix-ui or chakra-ui>"
}
```
```json
context7:query-docs
{
"libraryId": "<RESOLVED_ID>",
"query": "accessibility ARIA keyboard navigation focus management"
}
```
---
## Phase 2: Automated Scanning (axe-core via Playwright)
### 2a. Navigate to Each Page
For each page discovered in Phase 0a:
```bash
PW="npx --yes @playwright/cli@latest"
$PW -s=a11y open --headed "<APP_URL><ROUTE>" # first page; use `goto` for subsequent ones
```
Apply the `protocol-browser-anti-stall` protocol: wait 2s, snapshot, verify page loaded.
### 2b. Inject and Run axe-core
Use `run-code` to inject axe-core from CDN and run a full scan. The URL pins
a version so results are reproducible; if the CDN returns 404, look up the
current axe-core 4.x on cdnjs and substitute it — do not skip the scan:
```bash
$PW -s=a11y run-code 'async (page) => { await page.addScriptTag({ url: "https://cdnjs.cloudflare.com/ajax/libs/axe-core/4.10.2/axe.min.js" }); return await page.evaluate(async () => { const r = await axe.run(); return { violations: r.violations.map(v => ({ id: v.id, impact: v.impact, description: v.description, help: v.help, helpUrl: v.helpUrl, nodes: v.nodes.length, targets: v.nodes.slice(0, 3).map(n => n.target[0]) })), passes: r.passes.length, incomplete: r.incomplete.length, inapplicable: r.inapplicable.length }; }); }'
```
For each violation returned, record:
- **Rule ID** (e.g., `color-contrast`, `image-alt`, `label`)
- **Impact** (critical, serious, moderate, minor)
- **WCAG criteria** mapped from the rule
- **Affected elements** (CSS selectors)
- **Count** of affected nodes
### 2c. Check Specific WCAG Categories Manually
**Heading Hierarchy (WCAG 1.3.1):**
```bash
$PW -s=a11y eval '() => Array.from(document.querySelectorAll("h1,h2,h3,h4,h5,h6")).map(h => ({ tag: h.tagName, text: h.textContent.trim().substring(0, 50) }))'
```
Verify: only one `h1`, headings don't skip levels (h1 -> h3 without h2).
**Image Alt Text (WCAG 1.1.1):**
```bash
$PW -s=a11y eval '() => Array.from(document.querySelectorAll("img")).map(img => ({ src: img.src.split("/").pop(), alt: img.alt, hasAlt: img.hasAttribute("alt"), decorative: img.getAttribute("role") === "presentation" || img.alt === "" }))'
```
Verify: all informative images have descriptive alt text, decorative images have `alt=""` or `role="presentation"`.
**Link Text (WCAG 2.4.4):**
```bash
$PW -s=a11y eval '() => Array.from(document.querySelectorAll("a")).filter(a => ["click here", "here", "read more", "learn more", "", "link"].includes((a.textContent || "").trim().toLowerCase())).map(a => ({ href: a.href, text: (a.textContent || "").trim(), ariaLabel: a.getAttribute("aria-label") }))'
```
Flag generic link text ("click here", "read more", empty links without aria-label).
**Form Labels (WCAG 1.3.1, 4.1.2):**
```bash
$PW -s=a11y eval '() => Array.from(document.querySelectorAll("input,select,textarea")).map(el => ({ type: el.type, name: el.name, id: el.id, hasLabel: !!el.id && !!document.querySelector(`label[for="${el.id}"]`), ariaLabel: el.getAttribute("aria-label"), ariaLabelledBy: el.getAttribute("aria-labelledby"), placeholder: el.placeholder }))'
```
Verify: every form control has an associated `<label>`, `aria-label`, or `aria-labelledby`.
**Language Attribute (WCAG 3.1.1):**
```bash
$PW -s=a11y eval '() => ({ htmlLang: document.documentElement.lang, htmlDir: document.documentElement.dir })'
```
Verify: `<html>` has a valid `lang` attribute.
**WCAG 2.2 additions (all Level AA; axe does not settle them):** run each at a
phone viewport (`$PW -s=a11y resize 390 844`) and at desktop width.
Target Size (2.5.8) — list controls under 24×24 CSS px:
```bash
$PW -s=a11y eval '() => [...document.querySelectorAll("a[href],button,input,select,textarea,[role=button],[role=link],[tabindex]")].filter(el => el.tabIndex >= 0 && el.type !== "hidden").map(el => { const r = el.getBoundingClientRect(); return { el: el.tagName.toLowerCase() + (el.id ? "#" + el.id : ""), label: (el.innerText || el.getAttribute("aria-label") || "").trim().slice(0, 30), w: Math.round(r.width), h: Math.round(r.height), inline: getComputedStyle(el).display === "inline" }; }).filter(t => t.w > 0 && (t.w < 24 || t.h < 24))'
```
Each hit fails unless an exception holds: a 24px-diameter circle centered on it touches
no other target (spacing), an equivalent control elsewhere on the page meets the size,
it sits inline in a sentence, or the browser draws it unstyled.
Focus Not Obscured (2.4.11) — after each Tab press in Phase 3a, with sticky headers,
footers, cookie banners, and chat launchers visible:
```bash
$PW -s=a11y eval '() => { const el = document.activeElement; const r = el.getBoundingClientRect(); const pts = [[r.left + r.width / 2, r.top + r.height / 2], [r.left + 2, r.top + 2], [r.right - 2, r.bottom - 2]]; const covered = pts.filter(([x, y]) => { const hit = document.elementFromPoint(x, y); return hit && hit !== el && !el.contains(hit); }).length; return { el: el.tagName + (el.id ? "#" + el.id : ""), covered: covered + "/3" }; }'
```
`3/3` is a fail (the focused control is entirely hidden; F110). Fix with
`scroll-padding-top` / `scroll-padding-bottom` equal to the sticky bar's height (C43).
Dragging Movements (2.5.7) — `rg -n "draggable|onDrag|useDrag|dnd-kit|Sortable|swipe" src`;
every drag or swipe action needs a single-tap alternative (buttons, menu, arrows).
Accessible Authentication (3.3.8) — on each sign-in and sign-up page:
```bash
$PW -s=a11y eval '() => [...document.querySelectorAll("input[type=password],input[autocomplete=one-time-code]")].map(i => ({ name: i.name, autocomplete: i.getAttribute("autocomplete"), pasteBlocked: i.hasAttribute("onpaste") }))'
```
Pass: `autocomplete` is `current-password` / `new-password` / `one-time-code`, paste is
not blocked (also `rg -n "onPaste.*preventDefault" src`), and any CAPTCHA has a
non-puzzle alternative.
Native shells (Capacitor, Expo, RN): the font-scale, reduced-motion, and back probes run
on a device in `mobile-emulator-test`; record them here as their own rows.
### 2d. Take Evidence Screenshots
For each page, capture a screenshot as visual evidence:
```bash
$PW -s=a11y screenshot --filename ".playwright-mcp/a11y-<route>.png"
View on GitHub