From a11y-wcag22
Internal: browser-based WCAG 2.2 accessibility verification. Called by a11y-agent, not invoked directly by users.
How this skill is triggered — by the user, by Claude, or both
Slash command
/a11y-wcag22:a11y-browserThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
You verify accessibility checks that require a running browser: keyboard interaction, computed styles, viewport behavior, and media analysis. You are called by the a11y-agent, not invoked directly by the user.
You verify accessibility checks that require a running browser: keyboard interaction, computed styles, viewport behavior, and media analysis. You are called by the a11y-agent, not invoked directly by the user.
You receive from the agent:
Before running any check, filter by the target level:
[A][A] or [AA]This applies per-check, not per-section. A section may have checks at different levels.
This skill uses browser operations via MCP. Default tool names are for @playwright/mcp:
| Operation | Tool name | Purpose |
|---|---|---|
| Navigate | browser_navigate | Load a URL |
| Snapshot | browser_snapshot | Get accessibility tree |
| Screenshot | browser_take_screenshot | Capture visual state |
| Press key | browser_press_key | Tab, Enter, Escape, Arrow keys |
| Click | browser_click | Activate elements |
| Evaluate JS | browser_evaluate | Run JavaScript in page context |
| Resize viewport | browser_resize | Change viewport dimensions |
If a different MCP server is configured with different tool names, the agent will specify the mapping when dispatching you.
For each URL:
Each section (Keyboard, Contrast, Viewport, Media) runs independently. If any step within a section fails:
manual_review with evidence: "Browser verification failed: [error]. Manual testing required."This means a flaky contrast calculation never prevents keyboard testing from running.
Every browser_evaluate call should be treated as potentially failing. When invoking browser_evaluate:
Tab walk (all pages):
Evaluate from the tab walk:
all-functionality-works [A]: Compare the focus sequence against the list of interactive elements from step 1. Every interactive element must appear in the sequence (or have a keyboard-accessible parent that delegates). Elements that are interactive but never receive focus = FAIL. Evidence: list the unreachable elements with their selectors.
no-keyboard-traps [A]: During the tab walk, if the same element receives focus 3 times consecutively without advancing, flag as FAIL. Evidence: the element selector and the focus sequence around the trap.
focus-order [A]: Check that the focus sequence follows DOM order. If positive tabindex values force a non-DOM order, flag as FAIL. Evidence: the expected vs actual focus sequence.
focus-is-clearly-visible [AA]: At each focused element during the tab walk, use browser_take_screenshot. Compare with the previous screenshot (before focus moved to this element). If there is no visible change around the focused element, flag as FAIL. Evidence: the element selector and note about missing visual indicator.
focus-not-obscured [AA]: At each focused element, use browser_evaluate to run:
(function() {
const focused = document.activeElement;
if (!focused) return null;
const rect = focused.getBoundingClientRect();
const fixed = document.querySelectorAll('[style*="position: fixed"], [style*="position: sticky"]');
const computed = [...document.querySelectorAll('*')].filter(el => {
const s = window.getComputedStyle(el);
return s.position === 'fixed' || s.position === 'sticky';
});
for (const el of computed) {
const fRect = el.getBoundingClientRect();
if (rect.top < fRect.bottom && rect.bottom > fRect.top &&
rect.left < fRect.right && rect.right > fRect.left) {
return { obscured: true, by: el.tagName + '.' + el.className, focusedElement: focused.tagName };
}
}
return { obscured: false };
})()
If obscured = true, flag as FAIL. Evidence: which element obscures which focused element.
Dialog testing (if dialogs detected):
From the accessibility tree snapshot, identify elements that trigger dialogs: buttons with aria-haspopup="dialog", buttons near <dialog> elements, or buttons whose text suggests a dialog (e.g., "Open", "Confirm", "Delete").
For each dialog trigger: a. Use browser_press_key to activate it (focus it with Tab, then press Enter) b. Use browser_snapshot to check if focus is now inside a dialog element c. focus-moves-into [A]: If focus is not inside the dialog after activation, FAIL d. Press Tab repeatedly (up to 20 times). Record the focus sequence. e. focus-is-contained [A]: If any focused element during Tab cycling is outside the dialog, FAIL f. Press Escape using browser_press_key g. Use browser_snapshot to check where focus landed h. focus-returns-to [A]: If focus is not on the original trigger element, FAIL
Composite widget testing (if detected):
Color contrast:
(function() {
function luminance(r, g, b) {
const [rs, gs, bs] = [r, g, b].map(c => {
c = c / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
});
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}
function parseColor(color) {
const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
return m ? [parseInt(m[1]), parseInt(m[2]), parseInt(m[3])] : null;
}
function getEffectiveBg(el) {
let current = el;
while (current) {
const bg = window.getComputedStyle(current).backgroundColor;
const parsed = parseColor(bg);
if (parsed && (parsed[0] !== 0 || parsed[1] !== 0 || parsed[2] !== 0 || !bg.includes('0)'))) {
if (!bg.includes('rgba') || !bg.includes(', 0)')) return parsed;
}
current = current.parentElement;
}
return [255, 255, 255];
}
function selector(el) {
if (el.id) return '#' + el.id;
const tag = el.tagName.toLowerCase();
const cls = el.className ? '.' + el.className.split(' ').join('.') : '';
return tag + cls;
}
const results = [];
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
const seen = new Set();
while (walker.nextNode()) {
const text = walker.currentNode.textContent.trim();
if (!text) continue;
const el = walker.currentNode.parentElement;
if (seen.has(el)) continue;
seen.add(el);
const style = window.getComputedStyle(el);
const fg = parseColor(style.color);
const bg = getEffectiveBg(el);
if (!fg || !bg) continue;
const l1 = luminance(...fg);
const l2 = luminance(...bg);
const ratio = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
const fontSize = parseFloat(style.fontSize);
const fontWeight = parseInt(style.fontWeight) || 400;
const isLarge = fontSize >= 24 || (fontSize >= 18.66 && fontWeight >= 700);
results.push({
selector: selector(el),
text: text.substring(0, 50),
fg: style.color,
bg: 'rgb(' + bg.join(',') + ')',
ratio: Math.round(ratio * 100) / 100,
fontSize: fontSize,
isLarge: isLarge
});
}
return results;
})()
isLarge is false and ratio < 4.5 → FAILisLarge is true and ratio < 3.0 → FAILisLarge is false and ratio < 7.0 → FAIL (only at AAA level)isLarge is true and ratio < 4.5 → FAIL (only at AAA level)Non-text contrast:
(function() {
const interactive = document.querySelectorAll('button, a, input, select, textarea, [role="button"]');
const results = [];
for (const el of interactive) {
const style = window.getComputedStyle(el);
const borderColor = style.borderColor;
const bgColor = style.backgroundColor;
// Check if element has a visible border
if (style.borderWidth && parseFloat(style.borderWidth) > 0 && borderColor !== 'rgba(0, 0, 0, 0)') {
results.push({ selector: el.tagName + (el.id ? '#' + el.id : ''), type: 'border', color: borderColor });
}
}
return results;
})()
Target size:
(function() {
const interactive = document.querySelectorAll('a, button, input, select, textarea, [tabindex]:not([tabindex="-1"]), [role="button"], [role="link"]');
const small = [];
for (const el of interactive) {
const rect = el.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) continue;
if (rect.width < 24 || rect.height < 24) {
// Inline links in text are exempt
if (el.tagName === 'A' && el.closest('p, li, td, th')) continue;
small.push({
selector: el.tagName + (el.id ? '#' + el.id : '') + (el.className ? '.' + el.className.split(' ')[0] : ''),
width: Math.round(rect.width),
height: Math.round(rect.height),
text: (el.textContent || el.getAttribute('aria-label') || '').substring(0, 30)
});
}
}
return small;
})()
All 3 checks are AA. Skip entire section if target level is A.
Reflow at 320px:
(function() {
const hasOverflow = document.documentElement.scrollWidth > document.documentElement.clientWidth;
if (!hasOverflow) return { overflow: false };
const overflowing = [];
document.querySelectorAll('body *').forEach(el => {
const rect = el.getBoundingClientRect();
if (rect.right > document.documentElement.clientWidth + 1) {
overflowing.push({ selector: el.tagName + (el.className ? '.' + el.className.split(' ')[0] : ''), width: Math.round(rect.width), right: Math.round(rect.right) });
}
});
return { overflow: true, elements: overflowing.slice(0, 10) };
})()
Text resize to 200%:
(function() {
document.documentElement.style.fontSize = '200%';
const issues = [];
document.querySelectorAll('body *').forEach(el => {
const style = window.getComputedStyle(el);
if (style.overflow === 'hidden' && el.scrollHeight > el.clientHeight + 1) {
issues.push({ selector: el.tagName + (el.className ? '.' + el.className.split(' ')[0] : ''), scrollHeight: el.scrollHeight, clientHeight: el.clientHeight });
}
});
document.documentElement.style.fontSize = '';
return { clipped: issues.length > 0, elements: issues.slice(0, 10) };
})()
Text spacing overrides:
(function() {
document.querySelectorAll('*').forEach(el => {
el.style.lineHeight = '1.5';
el.style.letterSpacing = '0.12em';
el.style.wordSpacing = '0.16em';
if (el.tagName === 'P') el.style.marginBottom = '2em';
});
const issues = [];
document.querySelectorAll('body *').forEach(el => {
const style = window.getComputedStyle(el);
if ((style.overflow === 'hidden' || style.overflow === 'clip') && el.scrollHeight > el.clientHeight + 1) {
issues.push({ selector: el.tagName + (el.className ? '.' + el.className.split(' ')[0] : ''), scrollHeight: el.scrollHeight, clientHeight: el.clientHeight });
}
});
return { clipped: issues.length > 0, elements: issues.slice(0, 10) };
})()
These checks upgrade from MANUAL to PARTIAL only. They gather evidence but still require human judgment.
Flash detection (best effort):
manual_review with evidence: "Potential flashing content detected. Review the following screenshots to verify flash rate is below 3 per second." Attach the screenshot references.pass or manual_review.Live captions:
(function() {
const media = document.querySelectorAll('video, audio');
const live = [];
for (const el of media) {
const src = el.src || el.currentSrc || '';
const isLive = el.duration === Infinity || src.includes('stream') || src.includes('live') || el.querySelector('source[type*="live"]');
if (isLive) {
const hasCaptions = el.querySelector('track[kind="captions"]') || el.querySelector('track[kind="subtitles"]');
live.push({ selector: el.tagName + (el.id ? '#' + el.id : ''), src: src.substring(0, 80), hasCaptions: !!hasCaptions });
}
}
return live;
})()
manual_review with evidence. If no live media found, PASS. If live media has caption tracks, PASS.Return structured JSON matching the static skill format:
{
"summary": {
"target_level": "AA",
"urls_tested": ["http://localhost:3000"],
"passes": 0,
"fails": 0,
"manual_review": 0
},
"findings": [
{
"check_id": "check-id-here",
"status": "pass|fail|manual_review",
"severity": "critical|major|minor",
"wcag_sc": ["1.4.3"],
"level": "A|AA|AAA",
"title": "Check description from checklist",
"url": "http://localhost:3000",
"file": null,
"line": null,
"element": "CSS selector of the affected element",
"evidence": "Specific measurement or observation",
"fix": "Suggested remediation"
}
]
}
Include ALL checks that were evaluated, including passes. The agent needs the full picture.
manual_review.npx claudepluginhub deventually/a11y-plugin --plugin a11y-wcag22Audits web pages for WCAG 2.1 compliance, checking heading structure, form labels, alt text, ARIA attributes, landmarks, and keyboard navigation.
Validates web implementations in real browsers using Chrome DevTools MCP: tests responsive breakpoints, accessibility (WCAG AA), and interactions, generating detailed reports with fixes.
Performs formal WCAG 2.2 A/AA conformance audits, assigning Pass/Fail/NT/NA per success criterion with evidence. For issue discovery, use reviewing-a11y instead.