---
name: a11y-ally
description: "Use when running comprehensive WCAG accessibility audits with axe-core + pa11y + Lighthouse, generating context-aware remediation, or testing video accessibility. Supports 3-tier browser cascade with graceful degradation."
category: specialized-testing
priority: critical
tokenEstimate: 10000
agents: []
implementation_status: active
optimization_version: 7.0
last_optimized: 2026-01-26
dependencies: [playwright, playwright-extra, puppeteer-extra-plugin-stealth, "@axe-core/playwright", pa11y, lighthouse]
quick_reference_card: true
tags: [accessibility, wcag, a11y, video, captions, audiodesc, vtt, eu-compliance, context-aware, remediation, axe-core, pa11y, lighthouse, parallel, resilient, graceful-degradation, retry]
trust_tier: 3
validation:
schema_path: schemas/output.json
validator_path: scripts/validate-config.json
eval_path: evals/a11y-ally.yaml
---
# /a11y-ally - Comprehensive Accessibility Audit
When this skill is invoked with a URL, Claude executes ALL steps automatically without waiting for user prompts between steps.
## THIS IS AN LLM-POWERED SKILL
The value of this skill is **Claude's intelligence**, not just running automated tools:
| Automated Tools Do | Claude (This Skill) Does |
|--------------------|--------------------------|
| Flag "button has no name" | Analyze context: icon class, parent element, nearby text → generate "Add to wishlist" |
| Flag "image missing alt" | Use Vision to see the image → describe actual content |
| Flag "video has no captions" | Download video, extract frames, analyze each frame with Vision → generate real captions |
| Output generic templates | Generate context-specific, copy-paste ready fixes |
**IF YOU SKIP THE LLM ANALYSIS, THIS SKILL HAS NO VALUE.**
---
## EXECUTION MODEL
**CLAUDE EXECUTES ALL STEPS WITHOUT STOPPING.**
Do NOT wait for user prompts between steps. Execute the full pipeline:
1. **Data Collection**: Run multi-tool scan (axe-core, pa11y, Lighthouse) via Bash
2. **LLM Analysis**: Read results and analyze context for each violation
3. **Vision Pipeline**: If videos detected → download → extract frames → Read each frame → describe
4. **Intelligent Remediation**: Generate context-specific fixes using your reasoning
5. **Generate Reports**: Write all output files to `docs/accessibility-scans/{page-slug}/`
**WRONG:**
```
Claude: "I found 5 violations. Should I analyze them?"
User: "Yes"
Claude: "I see a video. Should I run the video pipeline?"
User: "Yes"
```
**RIGHT:**
```
Claude: [Runs scan] → [Analyzes violations] → [Downloads video] → [Extracts frames] →
[Reads each frame with Vision] → [Generates captions] → [Writes all files]
"Audit complete. Generated 4 files in docs/accessibility-scans/example/"
```
---
## STEP 1: BROWSER AUTOMATION - Content Fetching
Uses the **qe-browser** fleet skill as the browser engine. qe-browser wraps Vibium (WebDriver BiDi, 10MB Go binary) and provides the QE primitives we rely on. See `.claude/skills/qe-browser/SKILL.md`.
### 1.1: PRIMARY — qe-browser via Vibium CLI
```bash
# Navigate
vibium go "$TARGET_URL"
vibium wait load
# Capture accessibility tree without visual render
vibium a11y-tree --json > /tmp/a11y-work/tree.json
# Screenshot for Vision pipeline
vibium screenshot -o /tmp/a11y-work/page.png --full-page
```
If Vibium MCP tools are registered (`mcp__vibium__*`), prefer them; otherwise shell out to the `vibium` binary installed by `aqe init`.
### 1.2: Run axe-core + WCAG assertions via qe-browser
```bash
# Inject axe-core via vibium eval and collect violations
vibium eval --stdin <<'EOF'
const s = document.createElement('script');
s.src = 'https://cdnjs.cloudflare.com/ajax/libs/axe-core/4.10.2/axe.min.js';
document.head.appendChild(s);
await new Promise(r => s.onload = r);
const results = await axe.run();
JSON.stringify({ violations: results.violations.length, issues: results.violations });
EOF
# Enforce: no critical a11y violations + no failed network requests
node .claude/skills/qe-browser/scripts/assert.js --checks '[
{"kind": "no_console_errors"},
{"kind": "no_failed_requests"},
{"kind": "selector_visible", "selector": "main, [role=main]"}
]'
```
### 1.3: FALLBACK — pa11y + Lighthouse (when axe alone is insufficient)
```bash
# Only use when you need the extra rulesets, not as the primary path
pa11y "$TARGET_URL" --reporter json > /tmp/a11y-work/pa11y.json
lighthouse "$TARGET_URL" --only-categories=accessibility --output=json --output-path=/tmp/a11y-work/lighthouse.json --chrome-flags="--headless"
```
**Why we dropped playwright-extra + puppeteer-extra-plugin-stealth from the primary path:**
- 300MB+ of Node deps vs Vibium's 10MB binary
- Redundant: Vibium uses WebDriver BiDi which is less fingerprintable than raw CDP
- Simpler: one tool instead of a cascade
### 1d: PARALLEL MULTI-PAGE AUDIT (Optional)
For auditing multiple URLs simultaneously, use parallel execution:
```javascript
// /tmp/a11y-work/parallel-audit.js
const { chromium } = require('playwright-extra');
const stealth = require('puppeteer-extra-plugin-stealth')();
const { AxeBuilder } = require('@axe-core/playwright');
chromium.use(stealth);
const MAX_CONCURRENT = 6; // Maximum parallel auditors
async function auditUrl(browser, url) {
const context = await browser.newContext();
const page = await context.newPage();
try {
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.waitForTimeout(2000);
const axeResults = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'])
.analyze();
return { url, success: true, violations: axeResults.violations };
} catch (error) {
return { url, success: false, error: error.message };
} finally {
await context.close();
}
}
async function parallelAudit(urls) {
const browser = await chromium.launch({ headless: true });
const results = [];
// Process in chunks of MAX_CONCURRENT
for (let i = 0; i < urls.length; i += MAX_CONCURRENT) {
const chunk = urls.slice(i, i + MAX_CONCURRENT);
console.log(`Auditing batch ${Math.floor(i/MAX_CONCURRENT) + 1}: ${chunk.length} URLs`);
const chunkResults = await Promise.all(
chunk.map(url => auditUrl(browser, url))
);
results.push(...chunkResults);
}
await browser.close();
return results;
}
// Usage: node parallel-audit.js url1 url2 url3 ...
const urls = process.argv.slice(2);
if (urls.length > 0) {
parallelAudit(urls).then(results => {
console.log(JSON.stringify(results, null, 2));
});
}
```
**Usage for multi-page audit:**
```bash
node parallel-audit.js https://example.com https://example.com/about https://example.com/contact
```
### 1e: SITE CRAWL MODE (Optional)
For comprehensive site audits, crawl and audit all pages:
```javascript
// /tmp/a11y-work/crawl-audit.js
async function crawlAndAudit(startUrl, maxPages = 50) {
const browser = await chromium.launch({ headless: true });
const visited = new Set();
const toVisit = [startUrl];
const results = [];
const baseUrl = new URL(startUrl).origin;
while (toVisit.length > 0 && results.length < maxPages) {
const url = toVisit.shift();
if (visited.has(url)) continue;
visited.add(url);
console.log(`[${results.length + 1}/${maxPages}] Auditing: ${url}`);
const context = await browser.newContext();
const page = await context.newPage();
try {
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
// Extract same-domain links for crawling
const links = await page.evaluate((base) => {
return [...document.querySelectorAll('a[href]')]
.map(a => a.href)
.filter(href => href.startsWith(base) && !href.includes('#'))
.filter(href => !href.match(/\.(pdf|jpg|png|gif|css|js)$/i));
}, baseUrl);
// Add new links to queue
links.forEach(link => {
if (!visited.has(link) && !toVisit.includes(link)) {
toVisit.push(link);
}
});
// Run accessibility audit
const axeResults = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag22aa'])
.analyze();
results.push({ url, violations: axeResults.violations });
} catch (e) {
results.push({ url, error: e.message });
}
await context.close();
}
await browser.close();
return { pagesAudited: results.length, results };
}
// Usage: node crawl-audit.js https://example.com 50
const [startUrl, maxPages] = process.argv.slice(2);
crawlAndAudit(startUrl, parseInt(maxPages) || 50).then(r => console.log(JSON.stringify(r, null, 2)));
```
---
## STEP 2: COMPREHENSIVE WCAG SCAN (Multi-Tool, Parallel, Resilient)
**IMPORTANT:** This step uses THREE accessibility testing tools for maximum coverage:
- **axe-core**: Industry standard, excellent for ARIA and semantic issues
- **pa11y**: Strong on contrast, links, and HTML validation
- **Lighthouse**: Google's accessibility scoring with performance correlation
Combined detection rate is ~15% higher than any single tool.
### 2.0: RESILIENCE ARCHITECTURE (v7.0 Enhancement)
**Key improvements over v6.0:**
| Feature | v6.0 (Old) | v7.0 (New) |
|---------|------------|------------|
| Tool execution | Sequential | **Parallel (Promise.allSettled)** |
| Timeout handling | Global 60s | **Per-tool (60s/60s/90s)** |
| Failure mode | All-or-nothing | **Graceful degradation** |
| Retry logic | None | **Exponential backoff (3 retries)** |
| Output style | Wait for all | **Progressive (stream as ready)** |
| Minimum tools | 3 required | **1 of 3 sufficient** |
**Coverage by tools succeeded:**
- 3/3 tools: ~95% detection (optimal)
- 2/3 tools: ~85% detection (good)
- 1/3 tools: ~70% detection (acceptable)
- 0/3 tools: FAIL - retry with different strategy
### 2.1: Run Multi-Tool Analysis (PARALLEL + RESILIENT)
Create and run `/tmp/a11y-work/multi-tool-scan.js`:
```javascript
const { chromium } = require('playwright-extra');
const stealth = require('puppeteer-extra-plugin-stealth')();
const { AxeBuilder } = require('@axe-core/playwright');
const pa11y = require('pa11y');
const lighthouse = require('lighthouse').default || require('lighthouse');
const { launch: launchChrome } = require('chrome-launcher');
const fs = require('fs');
chromium.use(stealth);
const TARGET_URL = process.argv[2] || 'TARGET_URL';
const OUTPUT_FILE = '/tmp/a11y-work/scan-results.json';
const SYSTEM_CHROMIUM = '/usr/bin/chromium';
// ========== RESILIENCE UTILITIES ==========
// Timeout wrapper - wraps any promise with a timeout
function withTimeout(promise, ms, name) {
return Promise.race([
promise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`${name} timed out after ${ms}ms`)), ms)
)
]);
}
// Retry wrapper - retries with exponential backoff
async function withRetry(fn, name, maxRetries = 3, baseDelay = 2000) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
const isLastAttempt = attempt === maxRetries;
console.log(`[${name}] Attempt ${attempt}/${maxRetries} failed: ${error.message}`);
if (isLastAttempt) throw error;
const delay = baseDelay * Math.pow(2, attempt - 1); // Exponential backoff
console.log(`[${name}] Retrying in ${delay}ms...`);
await new Promise(r => setTimeout(r, delay));
}
}
}
// Sleep utility
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
// Progressive output - append results as they arrive
function progressiveOutput(tool, data) {
console.log(`\n=== ${tool.toUpperCase()} COMPLETE ===`);
console.log(JSON.stringify(data, null, 2));
// Append to results file for progressive access
try {
let results = {};
if (fs.existsSync(OUTPUT_FILE)) {
results = JSON.parse(fs.readFileSync(OUTPUT_FILE, 'utf8'));
}
results[tool] = data;
results.lastUpdated = new Date().toISOString();
fs.writeFileSync(OUTPUT_FILE, JSON.stringify(results, null, 2));
} catch (e) { /* ignore file errors */ }
}
// ========== TOOL RUNNERS ==========
// TOOL 1: Axe-core (with page info extraction)
async function runAxeCore(url) {
console.log('[axe-core] Starting...');
const browser = await chromium.launch({
headless: true,
executablePath: SYSTEM_CHROMIUM,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-blink-features=AutomationControlled'
]
});
const context = await browser.newContext({
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
locale: 'en-US',
viewport: { width: 1920, height: 1080 }
});
const page = await context.newPage();
try {
// Use domcontentloaded (faster, more reliable than networkidle)
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
// Random delay to appear human
await sleep(2000 + Math.random() * 2000);
// Try to dismiss cookie banners
try {
const cookieSelectors = [
'button:has-text("Accept")', 'button:has-text("Akzeptieren")',
'button:has-text("Alle akzeptieren")', '[data-testid="cookie-accept"]',
'#onetrust-accept-btn-handler', '.cookie-consent-accept'
];
for (const selector of cookieSelectors) {
const btn = await page.$(selector);
if (btn) { await btn.click(); await sleep(500); break; }
}
} catch (e) { /* ignore cookie errors */ }
// Run axe-core analysis
const axeResults = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'])
.analyze();
// Extract comprehensive page info
const pageInfo = await page.evaluate(() => ({
title: document.title,
url: window.location.href,
lang: document.documentElement.lang,
images: {
total: document.querySelectorAll('img').length,
withAlt: document.querySelectorAll('img[alt]').length,
withoutAlt: document.querySelectorAll('img:not([alt])').length,
emptyAlt: document.querySelectorAll('img[alt=""]').length
},
headings: {
h1: Array.from(document.querySelectorAll('h1')).map(h => h.textContent.trim().slice(0,60)),
h2: document.querySelectorAll('h2').length,
h3: document.querySelectorAll('h3').length,
total: document.querySelectorAll('h1,h2,h3,h4,h5,h6').length
},
forms: {
total: document.querySelectorAll('form').length,
inputs: document.querySelectorAll('input, select, textarea').length,
buttons: document.querySelectorAll('button').length
},
links: { total: document.querySelectorAll('a').length },
aria: {
ariaLabels: document.querySelectorAll('[aria-label]').length,
roles: document.querySelectorAll('[role]').length
},
landmarks: {
main: document.querySelectorAll('main').length,
nav: document.querySelectorAll('nav').length,
header: document.querySelectorAll('header').length,
footer: document.querySelectorAll('footer').length
},
media: {
videos: document.querySelectorAll('video').length,
iframes: document.querySelectorAll('iframe').length,
videoUrls: Array.from(document.querySelectorAll('video')).map(v => {
const src = v.src || (v.querySelector('source') ? v.querySelector('source').src : '');
return {
src: src,
hasCaptions: !!v.querySelector('track[kind="captions"]')
};
})
}
}));
const violations = axeResults.violations.map(v => ({
tool: 'axe-core',
id: v.id,
impact: v.impact,
description: v.description,
help: v.help,
helpUrl: v.helpUrl,
tags: v.tags,
nodeCount: v.nodes.length,
nodes: v.nodes.slice(0, 5).map(n => ({
html: n.html.slice(0, 200),
target: n.target,
failureSummary: n.failureSummary
}))
}));
return {
success: true,
pageInfo,
violations,
passesCount: axeResults.passes.length
};
} finally {
await context.close();
await browser.close();
}
}
// TOOL 2: Pa11y
async function runPa11y(url) {
console.log('[pa11y] Starting...');
const results = await pa11y(url, {
standard: 'WCAG2AA',
timeout: 45000,
wait: 2000,
chromeLaunchConfig: {
executablePath: SYSTEM_CHROMIUM,
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage']
}
});
const violations = results.issues.map(issue => ({
tool: 'pa11y',
id: issue.code,
impact: issue.type === 'error' ? 'serious' : issue.type === 'warning' ? 'moderate' : 'minor',
description: issue.message,
selector: issue.selector,
context: (issue.context || '').slice(0, 200)
}));
return { success: true, violations, total: results.issues.length };
}
// TOOL 3: Lighthouse
async function runLighthouse(url) {
console.log('[lighthouse] Starting...');
const chrome = await launchChrome({
chromePath: SYSTEM_CHROMIUM,
chromeFlags: ['--headless', '--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage']
});
try {
const result = await lighthouse(url, {
port: chrome.port,
onlyCategories: ['accessibility'],
output: 'json'
});
const lhr = result.lhr;
const score = Math.round(lhr.categories.accessibility.score * 100);
const violations = Object.values(lhr.audits)
.filter(audit => audit.score !== null && audit.score < 1)
.map(audit => ({
tool: 'lighthouse',
id: audit.id,
impact: audit.score === 0 ? 'critical' : audit.score < 0.5 ? 'serious' : 'moderate',
score: audit.score,
description: audit.title
}));
return { success: true, score, violations };
} finally {
await chrome.kill();
}
}
// ========== MAIN: PARALLEL EXECUTION WITH GRACEFUL DEGRADATION ==========
(async () => {
console.log('=== MULTI-TOOL ACCESSIBILITY SCAN (v7.0 PARALLEL + RESILIENT) ===');
console.log('Target:', TARGET_URL);
console.log('Strategy: Promise.allSettled with per-tool timeouts\n');
const startTime = Date.now();
// Run ALL tools in PARALLEL with individual timeouts
const [axeResult, pa11yResult, lighthouseResult] = await Promise.allSettled([
withTimeout(
withRetry(() => runAxeCore(TARGET_URL), 'axe-core', 2, 3000),
60000, 'axe-core'
),
withTimeout(
withRetry(() => runPa11y(TARGET_URL), 'pa11y', 2, 3000),
60000, 'pa11y'
),
withTimeout(
withRetry(() => runLighthouse(TARGET_URL), 'lighthouse', 2, 3000),
90000, 'lighthouse'
)
]);
// ========== PROCESS RESULTS (Graceful Degradation) ==========
const results = {
url: TARGET_URL,
timestamp: new Date().toISOString(),
duration: `${((Date.now() - startTime) / 1000).toFixed(1)}s`,
toolsSucceeded: 0,
toolsFailed: 0,
pageInfo: null,
violations: [],
byTool: {}
};
// Process axe-core results
if (axeResult.status === 'fulfilled') {
results.toolsSucceeded++;
results.pageInfo = axeResult.value.pageInfo;
results.violations.push(...axeResult.value.violations);
results.byTool['axe-core'] = {
success: true,
count: axeResult.value.violations.length,
passes: axeResult.value.passesCount
};
progressiveOutput('axe-core', axeResult.value);
} else {
results.toolsFailed++;
results.byTool['axe-core'] = { success: false, error: axeResult.reason.message };
console.log('\n[axe-core] FAILED:', axeResult.reason.message);
}
// Process pa11y results
if (pa11yResult.status === 'fulfilled') {
results.toolsSucceeded++;
results.violations.push(...pa11yResult.value.violations);
results.byTool['pa11y'] = {
success: true,
count: pa11yResult.value.violations.length
};
progressiveOutput('pa11y', pa11yResult.value);
} else {
results.toolsFailed++;
results.byTool['pa11y'] = { success: false, error: pa11yResult.reason.message };
console.log('\n[pa11y] FAILED:', pa11yResult.reason.message);
}
// Process lighthouse results
if (lighthouseResult.status === 'fulfilled') {
results.toolsSucceeded++;
results.violations.push(...lighthouseResult.value.violations);
results.byTool['lighthouse'] = {
success: true,
score: lighthouseResult.value.score,
count: lighthouseResult.value.violations.length
};
progressiveOutput('lighthouse', lighthouseResult.value);
} else {
results.toolsFailed++;
results.byTool['lighthouse'] = { success: false, error: lighthouseResult.reason.message };
console.log('\n[lighthouse] FAILED:', lighthouseResult.reason.message);
}
// ========== DEDUPLICATE VIOLATIONS ==========
const seen = new Set();
const uniqueViolations = [];
for (const v of results.violations) {
const key = (v.description || '').toLowerCase().slice(0, 50);
if (!seen.has(key)) {
seen.add(key);
uniqueViolations.push(v);
}
}
results.uniqueViolations = uniqueViolations;
results.totalUnique = uniqueViolations.length;
// ========== FINAL OUTPUT ==========
console.log('\n' + '='.repeat(60));
console.log('=== SCAN COMPLETE ===');
console.log('='.repeat(60));
console.log(`Tools succeeded: ${results.toolsSucceeded}/3`);
console.log(`Tools failed: ${results.toolsFailed}/3`);
console.log(`Duration: ${results.duration}`);
console.log(`Total unique violations: ${results.totalUnique}`);
if (results.toolsSucceeded === 0) {
console.log('\n⚠️ ALL TOOLS FAILED - Consider:');
console.log(' 1. Site may have strong bot protection');
console.log(' 2. Try Vibium MCP browser instead');
console.log(' 3. Check network connectivity');
} else if (results.toolsSucceeded < 3) {
console.log(`\n⚠️ Partial coverage (${results.toolsSucceeded}/3 tools) - Results still usable`);
} else {
console.log('\n✅ Full coverage achieved (3/3 tools)');
}
console.log('\n=== PAGE INFO ===');
console.log(JSON.stringify(results.pageInfo, null, 2));
console.log('\n=== VIOLATIONS BY TOOL ===');
console.log(JSON.stringify(results.byTool, null, 2));
console.log('\n=== UNIQUE VIOLATIONS ===');
console.log(JSON.stringify(results.uniqueViolations, null, 2));
// Save final results
fs.writeFileSync(OUTPUT_FILE, JSON.stringify(results, null, 2));
console.log(`\nResults saved to: ${OUTPUT_FILE}`);
})();
```
### 2.2: Read Scan Results
After running the scan, read the results file:
```bash
cat /tmp/a11y-work/scan-results.json
```
The results include:
- **pageInfo**: Page structure, images, headings, media
- **violations**: All violations from all tools (deduplicated)
- **byTool**: Success/failure status per tool
- **toolsSucceeded**: Number of tools that completed (1-3)
### 2.3: Graceful Degradation Decision Tree
| Tools Succeeded | Action |
|-----------------|--------|
| **3/3** | ✅ Full coverage - proceed with all results |
| **2/3** | ⚠️ Good coverage - note which tool failed in report |
| **1/3** | ⚠️ Basic coverage - proceed but flag limited confidence |
| **0/3** | ❌ Retry with Vibium MCP, or document failure |
### 2.4: MANDATORY - Check for Videos and Trigger Pipeline
After reading scan results, check `pageInfo.media.videoUrls`:
```javascript
// Check scan-results.json for videos
const results = JSON.parse(fs.readFileSync('/tmp/a11y-work/scan-results.json'));
if (results.pageInfo && results.pageInfo.media.videoUrls.length > 0) {
console.log('=== VIDEOS DETECTED - TRIGGERING VIDEO PIPELINE ===');
for (const video of results.pageInfo.media.videoUrls) {
console.log(`Video: ${video.src}`);
console.log(` Has captions: ${video.hasCaptions}`);
}
// PROCEED TO STEP 7 IMMEDIATELY
}
```
**IF videos detected AND hasCaptions=false → STEP 7 is MANDATORY before generating reports.**
---
## STEP 3: CONTEXT-AWARE REMEDIATION (LLM-POWERED)
**THIS IS WHERE CLAUDE'S INTELLIGENCE MATTERS.**
Generic tools output: `aria-label="[DESCRIPTION]"`
You output: `aria-label="Add to shopping cart"` because you understand context.
### 3.1: Context Analysis (Use Your Reasoning)
For EACH violation, Claude must:
1. **READ THE HTML CONTEXT** - Don't just see `
---
## Quick Reference Card
### Usage
```
/a11y-ally https://example.com
```
### v7.0 Resilience Features
| Feature | Description |
|---------|-------------|
| **Parallel Execution** | All 3 tools run simultaneously via Promise.allSettled |
| **Per-Tool Timeouts** | axe: 60s, pa11y: 60s, Lighthouse: 90s |
| **Retry with Backoff** | 2 retries per tool with exponential backoff |
| **Graceful Degradation** | Continue if 1+ tools succeed |
| **Progressive Output** | Results stream as tools complete |
| **Bot Protection** | Stealth mode, random delays, cookie dismissal |
### Expected Output Structure
```
docs/accessibility-scans/{page-slug}/
├── audit-summary.md # Executive summary with scores
├── remediation.md # ALL copy-paste code fixes
├── violations.json # Machine-readable data
├── implementation.md # Video integration (if videos)
├── video-*-captions.vtt # Captions (if videos)
└── video-*-audiodesc.vtt # Audio descriptions (if videos)
```
### Compliance Thresholds
| Level | Min Score | Critical | Serious |
|-------|-----------|----------|---------|
| A | 70% | 0 | ≤5 |
| AA | 85% | 0 | ≤3 |
| AAA | 95% | 0 | 0 |
### Tool Coverage by Success
| Tools Succeeded | Detection Rate | Status |
|-----------------|---------------|--------|
| 3/3 | ~95% | ✅ Optimal |
| 2/3 | ~85% | ⚠️ Good |
| 1/3 | ~70% | ⚠️ Acceptable |
| 0/3 | — | ❌ Retry needed |
### ROI Formula
```
ROI = (Impact × Users%) / Effort_Hours
```
---
## EU Compliance Mapping
| WCAG | EN 301 549 | EU Accessibility Act |
|------|------------|---------------------|
| 1.1.1 | 9.1.1.1 | EAA-I.1 Perceivable |
| 1.4.3 | 9.1.4.3 | EAA-I.1 Perceivable |
| 2.1.1 | 9.2.1.1 | EAA-I.2 Operable |
| 2.4.7 | 9.2.4.7 | EAA-I.2 Operable |
| 3.3.2 | 9.3.3.2 | EAA-I.3 Understandable |
| 4.1.2 | 9.4.1.2 | EAA-I.4 Robust |
---
## Critical Rules
### Execution Rules (v7.0)
1. **ALWAYS** run multi-tool scan with **parallel execution** (Promise.allSettled)
2. **ALWAYS** continue if at least **1 of 3 tools** succeeds (graceful degradation)
3. **ALWAYS** document which tools failed and why in audit-summary.md
4. **ALWAYS** use per-tool timeouts (60s/60s/90s) not global timeout
5. **ALWAYS** retry failed tools with exponential backoff before giving up
### Quality Rules
6. **ALWAYS** analyze context before generating fixes
7. **ALWAYS** include confidence scores with remediation
8. **ALWAYS** calculate user impact and ROI
9. **ALWAYS** generate copy-paste ready code
10. **NEVER** generate placeholder/template fixes
11. **NEVER** skip video pipeline if videos detected
12. **NEVER** complete without remediation.md
13. **NEVER** fail audit just because 1-2 tools failed (use graceful degradation)
## Gotchas
- axe-core catches ~30% of WCAG issues — automated tools miss keyboard navigation, reading order, and cognitive issues
- Agent runs Lighthouse only and reports "accessible" — Lighthouse alone is insufficient, always run axe-core + pa11y too
- Screen reader testing requires actual screen reader interaction, not just ARIA attribute checks
- Video accessibility (captions, audio descriptions) is frequently skipped — check every `