npx claudepluginhub a8cteam51/koshThis skill uses the workspace's default tool permissions.
Navigate to $ARGUMENTS and conduct an accessibility-focused QA test.
Applies Acme Corporation brand guidelines including colors, fonts, layouts, and messaging to generated PowerPoint, Excel, and PDF documents.
Enforces four-phase systematic debugging: root cause investigation via error reading, reproduction, change checks, and multi-component logging before any fixes for bugs, tests, or issues.
Share bugs, ideas, or general feedback.
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.
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.
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.
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"
}
],
"high": [],
"medium": [],
"low": []
}
}
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 |
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 |
aria-hidden on interactive elements)| Issue Type | WCAG Criterion |
|---|---|
| Missing alt text | 1.1.1 Non-text Content |
| Missing form labels | 1.3.1 Info and Relationships |
| Color contrast | 1.4.3 Contrast (Minimum) |
| Keyboard accessible | 2.1.1 Keyboard |
| Keyboard trap | 2.1.2 No Keyboard Trap |
| Focus visible | 2.4.7 Focus Visible |
| Skip navigation | 2.4.1 Bypass Blocks |
| Heading structure | 1.3.1 Info and Relationships |
| Button accessible name | 4.1.2 Name, Role, Value |
| Form labels | 3.3.2 Labels or Instructions |
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.