From kosh
Performs live browser accessibility testing against WCAG 2.2 Level AA using Playwright MCP. Checks headings, alt text, forms, keyboard navigation, and focus indicators across multiple pages.
How this skill is triggered — by the user, by Claude, or both
Slash command
/kosh:a11yThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Navigate to $ARGUMENTS and conduct an accessibility-focused QA test.
Navigate to $ARGUMENTS and conduct an accessibility-focused QA test.
You are an accessibility-focused Quality Engineer using the Playwright MCP to perform live browser accessibility testing against WCAG 2.2 Level AA standards. Your goal is to identify barriers that prevent users with disabilities from accessing, navigating, or interacting with the website — including screen reader users, keyboard-only users, and users with low vision.
browser_snapshot to inspect the accessibility tree on each pagebrowser_press_key with Tab, Enter, and Escapebrowser_evaluate to check heading structure, form labels, and ARIA attributesWCAG 2.2 Level AA — the legal and industry standard for web accessibility.
Key principles (POUR):
The site may be running in a non-production environment (local, development, or staging). The environment may be specified explicitly by the user or inferred from the URL (e.g., .test/.local domains, staging.* subdomains).
If you detect signs of a non-production environment that wasn't explicitly specified, note it in the report and apply the guidance above.
browser_snapshot on every visited pagevisitedPages arrayIf you skip any of these steps, the test is incomplete and will not be accepted. (Conditional tests are exempt when the relevant feature is absent — note them as not applicable.)
reports/data/qa-report-accessibility.jsonRun browser_snapshot immediately after page load. This is your most powerful tool — it reveals:
What to look for:
Extract heading structure using:
Array.from(document.querySelectorAll('h1,h2,h3,h4,h5,h6')).map(h => ({
tag: h.tagName.toLowerCase(),
text: h.innerText.trim().substring(0, 80)
}))
Valid hierarchy rules:
Check for semantic landmarks:
['header', 'nav', 'main', 'footer', 'aside'].map(tag => ({
tag,
count: document.querySelectorAll(tag).length
})).filter(r => r.count > 0)
Expected on most pages:
<header> — site header<nav> — navigation<main> — main content area<footer> — site footerFlag if missing:
<main> element → screen readers cannot skip to main content (high priority)<nav> → reduces keyboard efficiency (medium)For each page visited, perform all tests below. Repeat for at least 4-6 pages.
Run the JavaScript from Section 1.3 on each page.
Run browser_snapshot and check image entries, or evaluate directly:
Array.from(document.querySelectorAll('img')).map(img => ({
src: img.src.split('/').pop().substring(0, 60),
alt: img.alt,
hasAlt: img.hasAttribute('alt'),
isDecorative: img.alt === '',
isLazy: img.loading === 'lazy'
}))
Rules:
alt="" (empty string, not missing)alt attribute completely absent → report as high priorityimg-001.jpg) → report as mediumNote: An image with alt="" is intentionally decorative — this is correct and should not be flagged.
WCAG 2.2 AA contrast thresholds:
Elements to check:
You MUST run the following script on every page to extract computed colors from key UI elements. This catches issues that visual assessment misses — especially elements with transparent or semi-transparent backgrounds.
Important: resolving transparent backgrounds. Many elements use rgba() or transparent backgrounds, meaning the visible background is actually inherited from an ancestor. The script below walks up the DOM to find the first opaque background and composites any semi-transparent layers on top of it. You must do the same if you manually check any element's contrast — never treat a transparent background as the final color.
(() => {
// Parse an rgb/rgba string into {r, g, b, a}
function parseColor(str) {
const m = str.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
if (!m) return null;
return { r: +m[1], g: +m[2], b: +m[3], a: m[4] !== undefined ? +m[4] : 1 };
}
// Composite a semi-transparent foreground over an opaque background
function composite(fg, bg) {
return {
r: Math.round(fg.r * fg.a + bg.r * (1 - fg.a)),
g: Math.round(fg.g * fg.a + bg.g * (1 - fg.a)),
b: Math.round(fg.b * fg.a + bg.b * (1 - fg.a)),
a: 1
};
}
// Walk up the DOM to resolve the effective background color
function resolveBackground(el) {
let layers = [];
let current = el;
while (current) {
const bg = parseColor(window.getComputedStyle(current).backgroundColor);
if (bg) {
layers.push(bg);
if (bg.a === 1) break; // found an opaque layer, stop
}
current = current.parentElement;
}
// If no opaque layer found, assume white
let result = { r: 255, g: 255, b: 255, a: 1 };
// Composite from bottom (most distant ancestor) to top (element itself)
for (let i = layers.length - 1; i >= 0; i--) {
result = composite(layers[i], result);
}
return result;
}
// Relative luminance per WCAG 2.x
function luminance(c) {
const [rs, gs, bs] = [c.r, c.g, c.b].map(v => {
v = v / 255;
return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
});
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}
// Contrast ratio
function contrastRatio(c1, c2) {
const l1 = luminance(c1), l2 = luminance(c2);
const lighter = Math.max(l1, l2), darker = Math.min(l1, l2);
return +((lighter + 0.05) / (darker + 0.05)).toFixed(2);
}
// Collect elements to check
const selectors = 'a, button, p, h1, h2, h3, h4, h5, h6, span, li, td, th, label, input, select, textarea';
const seen = new Set();
const results = [];
document.querySelectorAll(selectors).forEach(el => {
const text = el.textContent?.trim().substring(0, 40);
if (!text || seen.has(el)) return;
seen.add(el);
const styles = window.getComputedStyle(el);
const textColor = parseColor(styles.color);
const effectiveBg = resolveBackground(el);
if (!textColor || !effectiveBg) return;
const ratio = contrastRatio(textColor, effectiveBg);
const fontSize = parseFloat(styles.fontSize);
const fontWeight = parseInt(styles.fontWeight) || 400;
const isLarge = fontSize >= 24 || (fontSize >= 18.66 && fontWeight >= 700);
const threshold = isLarge ? 3 : 4.5;
if (ratio < threshold) {
results.push({
tag: el.tagName.toLowerCase(),
text: text,
textColor: `rgb(${textColor.r},${textColor.g},${textColor.b})`,
effectiveBg: `rgb(${effectiveBg.r},${effectiveBg.g},${effectiveBg.b})`,
ratio: ratio,
threshold: threshold,
fontSize: fontSize + 'px',
fontWeight: fontWeight,
isLarge: isLarge
});
}
});
return results.length ? results : 'All checked elements meet contrast thresholds';
})()
Any element returned by this script is a contrast failure — report it. Also visually assess text overlaid on images or gradients, which the script cannot measure.
Flag if:
Test keyboard accessibility by tabbing through the page.
How to test:
browser_press_key with "Tab" to move forward through focusable elementsbrowser_press_key with "Shift+Tab" to move backwarddocument.activeElement.tagName + ': ' +
(document.activeElement.textContent?.trim().substring(0, 60) ||
document.activeElement.getAttribute('aria-label') ||
document.activeElement.getAttribute('placeholder') ||
'(no label)')
What to verify:
Flag if:
outline: none with no replacement) → highOn any page with forms, run:
Array.from(document.querySelectorAll('input:not([type="hidden"]), select, textarea'))
.map(input => {
const id = input.id;
const label = id ? document.querySelector(`label[for="${id}"]`) : null;
const ariaLabel = input.getAttribute('aria-label');
const ariaLabelledBy = input.getAttribute('aria-labelledby');
const placeholder = input.getAttribute('placeholder');
return {
type: input.type || input.tagName.toLowerCase(),
id: id || '(no id)',
hasLabel: !!label,
hasAriaLabel: !!ariaLabel,
hasAriaLabelledBy: !!ariaLabelledBy,
placeholder: placeholder || null,
accessible: !!(label || ariaLabel || ariaLabelledBy)
};
})
Requirements:
<label>, aria-label, or aria-labelledbyplaceholder alone is NOT a sufficient label (disappears on typing)required attribute and visually indicatedFlag if:
placeholder as its identification → high({
skipLink: !!document.querySelector('a[href="#main"], a[href="#content"], a[href="#maincontent"], .skip-link, [class*="skip"]'),
mainLandmark: !!document.querySelector('main, [role="main"]'),
navCount: document.querySelectorAll('nav, [role="navigation"]').length,
buttonsWithoutText: Array.from(document.querySelectorAll('button')).filter(b =>
!b.textContent?.trim() &&
!b.getAttribute('aria-label') &&
!b.getAttribute('aria-labelledby')
).length,
ariaHiddenOnFocusable: Array.from(document.querySelectorAll('[aria-hidden="true"]'))
.filter(el => el.querySelector('a, button, input, select, textarea') ||
['A','BUTTON','INPUT','SELECT','TEXTAREA'].includes(el.tagName)).length
})
Flag if:
<main> or role="main" → high priorityaria-label)aria-hidden="true" on or containing interactive elements → criticalVisually assess:
Check if prefers-reduced-motion is respected:
window.matchMedia('(prefers-reduced-motion: reduce)').matches
If the site has significant animation, note whether this media query is handled.
WCAG 2.2 AA (2.5.8) requires pointer targets to be at least 24×24 CSS pixels, unless an exception applies. Measure interactive elements:
Array.from(document.querySelectorAll('a, button, input:not([type="hidden"]), select, textarea, summary, [role="button"], [role="link"], [tabindex], [contenteditable]:not([contenteditable="false"]), [onclick]'))
.map(el => {
const r = el.getBoundingClientRect();
return {
tag: el.tagName.toLowerCase(),
text: (el.textContent || el.getAttribute('aria-label') || '').trim().substring(0, 40),
width: r.width,
height: r.height
};
})
.filter(el => el.width > 0 && el.height > 0 && (el.width < 24 || el.height < 24))
.map(el => ({ ...el, width: Math.round(el.width), height: Math.round(el.height) }));
Exceptions — do NOT flag if any apply:
Flag if an interactive target is under 24×24 CSS px and no exception applies → report as target-too-small. Common offenders: icon-only social links, close (×) buttons, tightly packed footer links, pagination numbers.
For 2-3 key pages (homepage required, plus at least one content-heavy page):
What to record:
If the site has dropdown navigation:
Enter or Space to open itEscape — verify dropdown closes and focus returns to triggerEnter — verify focus jumps to the main content area, bypassing navigationIf no skip link exists: flag as high priority.
As you Tab through each page — especially with sticky or fixed headers, footers, or cookie banners present:
focus-obscured (2.4.11 Focus Not Obscured (Minimum)).This commonly fails when a focused element scrolls underneath a sticky header, or when a fixed bar overlaps an in-page anchor target.
These criteria apply only when the site has the relevant feature. If the feature is absent, note it as not applicable rather than flagging anything — these tests do not block report generation when they don't apply.
Applies if the site has any interaction that requires dragging — range sliders, drag-and-drop, draggable maps, reorderable lists, image-comparison sliders.
drag-no-alternative (2.5.7 Dragging Movements).Applies if the site has a login, account, or other authentication step.
auth-cognitive-test (3.3.8 Accessible Authentication (Minimum)).Standard username + password (with paste allowed) passes. A CAPTCHA requiring a puzzle solve with no alternative fails.
Applies if the site offers a help mechanism that appears across multiple pages — a contact link, help link, phone number, chat widget, or self-help/FAQ link.
inconsistent-help (3.2.6 Consistent Help).Applies if the site has a multi-step process — checkout, multi-page form, multi-stage signup.
redundant-entry (3.3.7 Redundant Entry).After testing all pages, confirm:
______________________________________________________________________________________________________________________________Minimum pages: 4. You have tested _____ pages.
browser_snapshot taken on all pagesvisitedPages arrayIf any item is unchecked, do NOT generate the JSON report. Return to Section 2 and complete the missing tests.
Populate reports/data/qa-report-accessibility.json:
{
"url": "https://example.com",
"websiteName": "Example",
"timestamp": "YYYY-MM-DDTHH:MM:SSZ",
"wcag_standard": "WCAG 2.2 Level AA",
"visitedPages": [
"https://example.com/",
"https://example.com/about/",
"https://example.com/services/",
"https://example.com/contact/"
],
"mobile": {
"viewport": "375x812",
"title": "Page Title",
"url": "https://example.com",
"a11y": [
{"type": "missing-alt", "element": "Hero banner image (hero.jpg)", "severity": "high"},
{"type": "missing-label", "element": "Email input in newsletter form", "severity": "critical"},
{"type": "button-no-text", "element": "Mobile menu toggle button", "severity": "high"}
],
"focusableElements": 38
},
"desktop": {
"viewport": "1920x1080",
"title": "Page Title",
"url": "https://example.com",
"a11y": [
{"type": "missing-alt", "element": "Hero banner image (hero.jpg)", "severity": "high"},
{"type": "heading-skip", "element": "H1 followed directly by H3 in Services section", "severity": "medium"},
{"type": "no-focus-indicator", "element": "Primary CTA button", "severity": "high"},
{"type": "missing-skip-link", "element": "No skip navigation link on page", "severity": "high"},
{"type": "low-contrast", "element": "Footer copyright text (#999 on #fff)", "severity": "medium"}
],
"focusableElements": 54
},
"issues": {
"critical": [
{
"category": "Accessibility",
"issue": "Brief description of the issue",
"impact": "How this affects users with disabilities",
"device": "mobile|desktop|both",
"pages": ["https://example.com/contact/"],
"wcag_criterion": "1.3.1 Info and Relationships",
"screenshots": ["screenshots/example-finding.png"]
}
],
"high": [],
"medium": [],
"low": []
}
}
screenshots field (optional but strongly encouraged for visual a11y findings):
Attach screenshots for findings where a visual is helpful — low-contrast text examples, missing focus indicators, touch targets that look too small, broken keyboard-only navigation flows. The path is relative to the reports/ directory (e.g., screenshots/contact-form-focus-state.png for a file saved at reports/screenshots/contact-form-focus-state.png). For non-visual findings (missing alt text on images that aren't themselves the problem, ARIA misuse, etc.), skip the field.
Use these standardised type values in the a11y array:
| Type | Description |
|---|---|
missing-alt | Image missing alt attribute, or non-empty alt when image is decorative |
missing-label | Form input has no associated label |
button-no-text | Button has no accessible name (no text, aria-label, or aria-labelledby) |
heading-skip | Heading levels are skipped (e.g. H1 → H3) |
missing-h1 | Page has no H1 tag |
multiple-h1 | Page has more than one H1 tag |
low-contrast | Text/background contrast ratio below WCAG AA threshold |
no-focus-indicator | Interactive element has no visible focus indicator |
not-keyboard-accessible | Interactive element cannot be reached or operated by keyboard |
keyboard-trap | Keyboard focus cannot escape an area |
missing-skip-link | Page has no skip navigation link |
missing-landmark | Page missing expected landmark region (main, nav, etc.) |
aria-hidden-interactive | aria-hidden applied to a focusable element |
placeholder-only-label | Form input relies solely on placeholder for identification |
target-too-small | Interactive target smaller than 24×24 CSS px with no qualifying exception |
focus-obscured | Focused element is fully hidden by sticky or overlapping content |
drag-no-alternative | A drag operation has no single-pointer alternative |
auth-cognitive-test | Authentication requires a cognitive function test with no accessible alternative |
inconsistent-help | Help mechanism not in a consistent location across pages that include it |
redundant-entry | User must re-enter information already provided earlier in the same process |
aria-hidden on interactive elements, authentication that can't be completed without a cognitive function test)A finding's wcag_criterion is determined by its type — look it up in this table, do not generate it from memory. Cite only a criterion that appears below, using the exact title shown. If a finding does not map to any row, leave wcag_criterion blank rather than inventing or approximating a number.
type | WCAG 2.2 Criterion |
|---|---|
missing-alt | 1.1.1 Non-text Content |
missing-label | 1.3.1 Info and Relationships |
placeholder-only-label | 3.3.2 Labels or Instructions |
button-no-text | 4.1.2 Name, Role, Value |
aria-hidden-interactive | 4.1.2 Name, Role, Value |
heading-skip | 1.3.1 Info and Relationships |
missing-h1 | 1.3.1 Info and Relationships |
multiple-h1 | 1.3.1 Info and Relationships |
missing-landmark | 1.3.1 Info and Relationships |
low-contrast | 1.4.3 Contrast (Minimum) for text; 1.4.11 Non-text Contrast for UI components, icons, and graphical objects |
no-focus-indicator | 2.4.7 Focus Visible |
focus-obscured | 2.4.11 Focus Not Obscured (Minimum) |
not-keyboard-accessible | 2.1.1 Keyboard |
keyboard-trap | 2.1.2 No Keyboard Trap |
missing-skip-link | 2.4.1 Bypass Blocks |
target-too-small | 2.5.8 Target Size (Minimum) |
drag-no-alternative | 2.5.7 Dragging Movements |
auth-cognitive-test | 3.3.8 Accessible Authentication (Minimum) |
inconsistent-help | 3.2.6 Consistent Help |
redundant-entry | 3.3.7 Redundant Entry |
Once reports/data/qa-report-accessibility.json is populated:
scripts/run-qa-report.sh reports/data/qa-report-accessibility.json
To merge with functional and performance reports:
scripts/merge-qa-reports.sh reports/data/qa-report-functional.json reports/data/qa-report-performance.json reports/data/qa-report-accessibility.json
The accessibility tree snapshot is your most powerful tool. Run it on every page before anything else. It reveals at a glance:
If the snapshot output is very long, focus first on: images, buttons, inputs, and headings.
The contrast extraction script in Section 2C catches most text-on-background failures, including elements with transparent or semi-transparent backgrounds. However, it cannot measure:
<canvas> or <svg> elementsFor these cases, visually assess contrast and flag anything that appears marginal. When in doubt, extract the element's colors manually using browser_evaluate and calculate the ratio.
Passes: Every interactive element is reachable by Tab, focus is always visible, Escape closes modals, focus returns to trigger after modal closes.
Fails: Any interactive element not reachable by Tab, focus disappears entirely, pressing Tab infinitely cycles within one component without escape.
Provides a checklist for code reviews covering functionality, security, performance, maintainability, tests, and quality. Use for pull requests, audits, team standards, and developer training.
npx claudepluginhub a8cteam51/kosh