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 regionsdata-ai
license: Apache-2.0 NOT for unrelated tasks outside this domain.
development
Use when designing caching strategies (cache-aside, write-through, write-behind), implementing distributed locks, building rate limiters, leaderboards, real-time streams (XADD/consumer groups), pub/sub, or tuning eviction policies. Triggers: thundering-herd on cache miss, dogpile on key expiry, Redlock vs SET-NX-PX choice, sliding-window rate limiter, hot-key on a single cluster slot, big-key blowup, MULTI/EXEC across slots, KEYS in production. NOT for Redis Cluster operations/admin (different domain), embedded KV (SQLite, leveldb), in-process LRU caches, or Memcached.
tools
Drawing the `'use client'` boundary correctly in React Server Components apps (Next.js App Router, RSC frameworks) — leaf-pushing, slot composition, serialization rules, and environment poisoning prevention. Grounded in react.dev and Next.js 16 docs.
development
Use when designing rate limiting for an API, choosing between token bucket / sliding window / leaky bucket / fixed window, implementing it in Redis, deciding edge (Cloudflare/Upstash) vs origin enforcement, sizing per-user vs per-IP vs per-endpoint quotas, returning the right 429 response with Retry-After, or fixing the boundary-burst bug in fixed-window limiters. Triggers: 429 too many requests, INCR + EXPIRE, ZADD + ZREMRANGEBYSCORE + ZCARD, X-RateLimit-Remaining header, Cloudflare WAF rate limiting rules, Upstash @upstash/ratelimit, leaky bucket shaping vs policing, distributed rate limiter consistency. NOT for DDoS mitigation specifically (different scale), CAPTCHA / bot management, full WAF design, or per-user quota billing.