skills/accessibility-automation-expert/SKILL.md
Implement WCAG 2.2 AA/AAA compliance with automated testing, keyboard navigation, screen reader support, and focus management. Activate on: accessibility audit, WCAG compliance, keyboard navigation, screen reader, aria attributes, axe-core, focus trap. NOT for: design-level accessibility review (use design-accessibility-auditor), color contrast only (use css-in-js-architect).
npx skillsauth add curiositech/windags-skills accessibility-automation-expertInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Implement and enforce WCAG 2.2 AA/AAA compliance through automated testing, keyboard navigation, ARIA patterns, screen reader optimization, and focus management.
Activate on: accessibility audit failures, WCAG compliance requirements, keyboard navigation broken, screen reader not announcing content, axe-core violations, focus trap for modals/dialogs, aria-* attribute questions, skip navigation links.
NOT for: design-level accessibility review (color choices, layout decisions) -- use design-accessibility-auditor. Pure color contrast checking -- use css-in-js-architect with OKLCH.
npx @axe-core/cli http://localhost:3000 or integrate @axe-core/react in dev mode for console warnings.| Domain | Technologies | Key Patterns |
|--------|-------------|--------------|
| Automated Testing | axe-core, Lighthouse, jest-axe, Playwright axe | CI/CD accessibility gates |
| Keyboard Navigation | tabindex, onKeyDown, roving tabindex | Arrow key navigation, focus groups |
| Screen Readers | ARIA roles, live regions, aria-label | Announcements, state changes, descriptions |
| Focus Management | focus-visible, focus trap, inert attribute | Modal focus lock, skip links, route change focus |
| Semantic HTML | <main>, <nav>, <article>, <aside> | Landmarks, heading hierarchy, lists |
| Forms | <label>, aria-describedby, aria-invalid | Error announcement, required fields, fieldsets |
// e2e/accessibility.spec.ts (Playwright + axe-core)
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
const pages = ['/', '/products', '/checkout', '/account'];
for (const path of pages) {
test(`${path} has no accessibility violations`, async ({ page }) => {
await page.goto(path);
await page.waitForLoadState('networkidle');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag22aa']) // WCAG 2.2 AA
.analyze();
expect(results.violations).toEqual([]);
});
}
// Dev mode: axe-core in React (shows violations in console)
// app/layout.tsx
if (process.env.NODE_ENV === 'development') {
import('@axe-core/react').then((axe) => {
axe.default(React, ReactDOM, 1000);
});
}
import { useEffect, useRef, useCallback } from 'react';
function useFocusTrap(isOpen: boolean) {
const containerRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!isOpen) return;
// Save current focus to restore later
previousFocusRef.current = document.activeElement as HTMLElement;
// Focus first focusable element
const container = containerRef.current;
if (!container) return;
const focusable = container.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
focusable[0]?.focus();
// Trap focus within container
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last?.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first?.focus();
}
};
container.addEventListener('keydown', handleKeyDown);
// Use inert on background content
const mainContent = document.querySelector('main');
mainContent?.setAttribute('inert', '');
return () => {
container.removeEventListener('keydown', handleKeyDown);
mainContent?.removeAttribute('inert');
previousFocusRef.current?.focus(); // restore focus
};
}, [isOpen]);
return containerRef;
}
// Announce form errors, loading states, and updates to screen readers
function useAnnounce() {
const announce = useCallback((message: string, priority: 'polite' | 'assertive' = 'polite') => {
const el = document.getElementById(`aria-live-${priority}`);
if (el) {
el.textContent = ''; // Clear first to trigger re-announcement
requestAnimationFrame(() => { el.textContent = message; });
}
}, []);
return announce;
}
// Mount once in layout:
function AriaLiveRegions() {
return (
<>
<div id="aria-live-polite" aria-live="polite" aria-atomic="true" className="sr-only" />
<div id="aria-live-assertive" aria-live="assertive" aria-atomic="true" className="sr-only" />
</>
);
}
┌─ Accessibility Testing Pyramid ────────────────────┐
│ │
│ ▲ Manual Screen Reader Testing │
│ ╱ ╲ (VoiceOver, NVDA — quarterly) │
│ ╱───╲ │
│ ╱ ╲ Playwright + axe-core E2E │
│ ╱ E2E ╲ (every page, CI gate) │
│ ╱─────────╲ │
│ ╱ ╲ jest-axe Component Tests │
│ ╱ Component ╲ (per interactive component) │
│ ╱───────────────╲ │
│ ╱ ╲ ESLint jsx-a11y │
│╱ Static Lint ╲ (on every commit) │
│╲___________________╱ │
└─────────────────────────────────────────────────────┘
div with onClick instead of button -- divs have no keyboard interaction, no role, and no focus. Use semantic <button> or <a> elements. If you must use a div, add role="button", tabindex="0", and onKeyDown for Enter/Space.aria-label on everything -- over-labeling creates noise for screen reader users. Prefer visible text labels; use aria-label only when visible text is impossible.outline: none with no replacement makes keyboard navigation invisible. Use :focus-visible for keyboard-only focus indicators that do not appear on mouse click.<a href="#main" class="sr-only focus:not-sr-only">Skip to main content</a> as the first focusable element.aria-live="polite" regions to announce them.eslint-plugin-jsx-a11y enabled with no warningsalt text (or alt="" for decorative images)<label> elements (not just placeholder text)h1 > h2 > h3, no skipping)<main>, <nav>, <header>, <footer>:focus-visible styles defined)aria-live regionstools
Building resilient distributed systems with circuit breakers, retries with full-jitter exponential backoff, retry budgets (per-request 3-attempt + per-client 10% ratio per Google SRE), deadline propagation, and the cascading-failure math (4 layers × 3 retries = 64x amplification). Grounded in Resilience4j, Microsoft Cloud Patterns, AWS Architecture Blog (Marc Brooker), and Google SRE Book.
testing
Designing HTTP cache headers that work correctly across browsers, CDNs, and shared proxies — `Cache-Control` directives per RFC 9111, `stale-while-revalidate` and `stale-if-error` per RFC 5861, the Vary header for varying responses, and surrogate keys for tag-based purging. Grounded in IETF RFCs and Cloudflare/Fastly docs.
development
Use when designing or fixing a Content Security Policy on a real site, choosing between nonce-based and hash-based CSP, adding strict-dynamic, debugging "Refused to execute inline script" errors, deploying CSP in report-only mode first, configuring report-to / report-uri, or auditing an existing policy for unsafe-inline / unsafe-eval / wildcards. Triggers: "CSP blocks legitimate inline script", strict-dynamic, nonce-{RANDOM}, sha256-{HASH}, object-src none, base-uri none, frame-ancestors, Trusted Types, X-Content-Security-Policy obsolete, report-only vs enforced. NOT for general HTTP security headers (HSTS, COOP/COEP), Trusted Types deep dive, CORS configuration, or building a WAF.
tools
Choosing and operating an HTTP API versioning strategy that doesn't break clients — Stripe's date-based pinned versions, the Deprecation/Sunset header pair (RFC 9745 + RFC 8594), URI vs header vs media-type approaches, and the version-transformer pattern. Grounded in Stripe's published architecture and IETF RFCs.