Accessibility Audit WCAG 2.2 Accessibility Audit and Remediation Skill Description The a11y-audit skill provides a complete accessibility audit pipeline for modern web applications. It implements a three-phase workflow -- Scan, Fix, Verify -- that identifies WCAG 2.2 Level A and AA violations, generates exact fix code per framework, and produces stakeholder-ready compliance reports. For every violation it finds, it provides the precise before/after code fix tailored to your framework (React, Next.js, Vue, Angular, Svelte, or plain HTML). What this skill does: 1. Scans your codebase for every…

)\n\nif [ -n \"$STAGED_FILES\" ]; then\n echo \"Running accessibility audit on staged files...\"\n for file in $STAGED_FILES; do\n python scripts/a11y_scanner.py \"$file\" --severity critical --quiet\n if [ $? -ne 0 ]; then\n echo \"A11y audit FAILED for $file. Fix critical issues before committing.\"\n exit 1\n fi\n done\n echo \"A11y audit passed.\"\nfi\n```\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":3346,"content_sha256":"b1c25d7792173520ed25d4da741f445d05c771f9772256b8cf77e910b762692c"},{"filename":"references/color-contrast-guide.md","content":"# Color Contrast Guide\n\n## Contrast Checker Usage\n\nThe `contrast_checker.py` script validates color pairs against WCAG 2.2 contrast requirements.\n\n```bash\n# Check a single color pair\npython scripts/contrast_checker.py --fg \"#777777\" --bg \"#ffffff\"\n\n# Output:\n# Foreground: #777777 | Background: #ffffff\n# Contrast Ratio: 4.48:1\n# AA Normal Text (4.5:1): FAIL\n# AA Large Text (3.0:1): PASS\n# AAA Normal Text (7.0:1): FAIL\n# Suggested alternative: #767676 (4.54:1 - passes AA)\n\n# Scan a CSS file for all color pairs\npython scripts/contrast_checker.py --file src/styles/globals.css\n\n# Scan Tailwind classes in components\npython scripts/contrast_checker.py --tailwind src/components/\n```\n\n## Common Contrast Fixes\n\n| Original Color | Contrast on White | Fix | New Contrast |\n|----------------|------------------|-----|--------------|\n| `#aaaaaa` | 2.32:1 | `#767676` | 4.54:1 (AA) |\n| `#999999` | 2.85:1 | `#767676` | 4.54:1 (AA) |\n| `#888888` | 3.54:1 | `#767676` | 4.54:1 (AA) |\n| `#777777` | 4.48:1 | `#757575` | 4.60:1 (AA) |\n| `#66bb6a` | 3.06:1 | `#2e7d32` | 5.87:1 (AA) |\n| `#42a5f5` | 2.81:1 | `#1565c0` | 6.08:1 (AA) |\n| `#ef5350` | 3.13:1 | `#c62828` | 5.57:1 (AA) |\n\n## Tailwind CSS Accessible Palette Mapping\n\n| Inaccessible Class | Contrast on White | Accessible Alternative | Contrast |\n|---------------------|------------------|----------------------|----------|\n| `text-gray-400` | 2.68:1 | `text-gray-600` | 5.74:1 |\n| `text-blue-400` | 2.81:1 | `text-blue-700` | 5.96:1 |\n| `text-green-400` | 2.12:1 | `text-green-700` | 5.18:1 |\n| `text-red-400` | 3.04:1 | `text-red-700` | 6.05:1 |\n| `text-yellow-500` | 1.47:1 | `text-yellow-800` | 7.34:1 |\n\n## Screen Reader Utility Class\n\nEvery project should include this utility class for visually hiding content while keeping it accessible to screen readers:\n\n```css\n/* Visually hidden but accessible to screen readers */\n.sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n padding: 0;\n margin: -1px;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n white-space: nowrap;\n border-width: 0;\n}\n\n/* Allow the element to be focusable when navigated to via keyboard */\n.sr-only-focusable:focus,\n.sr-only-focusable:active {\n position: static;\n width: auto;\n height: auto;\n padding: inherit;\n margin: inherit;\n overflow: visible;\n clip: auto;\n white-space: inherit;\n}\n```\n\nTailwind CSS includes this as `sr-only` by default. For other frameworks:\n- **Angular**: Add to `styles.scss`\n- **Vue**: Add to `assets/global.css`\n- **Svelte**: Add to `app.css`\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":2521,"content_sha256":"5cae29f643f7e064a36b4b8e8ca2ca5d62db5c5d7861571e9e4e95d77b273cea"},{"filename":"references/examples-by-framework.md","content":"# Accessibility Audit Examples by Framework\n\n## Example 1: Vue SFC Form Audit\n\n```vue\n\u003c!-- BEFORE: src/components/LoginForm.vue -->\n\u003ctemplate>\n \u003cform @submit=\"handleLogin\">\n \u003cinput type=\"text\" placeholder=\"Email\" v-model=\"email\" />\n \u003cinput type=\"password\" placeholder=\"Password\" v-model=\"password\" />\n \u003cdiv v-if=\"error\" style=\"color: red\">{{ error }}\u003c/div>\n \u003cdiv @click=\"handleLogin\">Sign In\u003c/div>\n \u003c/form>\n\u003c/template>\n```\n\n**Violations detected:**\n\n| # | WCAG | Severity | Issue |\n|---|------|----------|-------|\n| 1 | 1.3.1 | Critical | Inputs missing associated `\u003clabel>` elements |\n| 2 | 3.3.2 | Major | Placeholder text used as only label (disappears on input) |\n| 3 | 2.1.1 | Critical | `\u003cdiv @click>` not keyboard accessible |\n| 4 | 4.1.3 | Major | Error message not announced to screen readers |\n| 5 | 3.3.1 | Major | Error not programmatically associated with input |\n\n```vue\n\u003c!-- AFTER: src/components/LoginForm.vue -->\n\u003ctemplate>\n \u003cform @submit.prevent=\"handleLogin\" aria-label=\"Sign in to your account\">\n \u003cdiv class=\"field\">\n \u003clabel for=\"login-email\">Email\u003c/label>\n \u003cinput\n id=\"login-email\"\n type=\"email\"\n v-model=\"email\"\n autocomplete=\"email\"\n required\n :aria-describedby=\"emailError ? 'email-error' : undefined\"\n :aria-invalid=\"!!emailError\"\n />\n \u003cspan v-if=\"emailError\" id=\"email-error\" role=\"alert\">\n {{ emailError }}\n \u003c/span>\n \u003c/div>\n \u003cdiv class=\"field\">\n \u003clabel for=\"login-password\">Password\u003c/label>\n \u003cinput\n id=\"login-password\"\n type=\"password\"\n v-model=\"password\"\n autocomplete=\"current-password\"\n required\n :aria-describedby=\"passwordError ? 'password-error' : undefined\"\n :aria-invalid=\"!!passwordError\"\n />\n \u003cspan v-if=\"passwordError\" id=\"password-error\" role=\"alert\">\n {{ passwordError }}\n \u003c/span>\n \u003c/div>\n \u003cdiv v-if=\"error\" role=\"alert\" aria-live=\"assertive\" class=\"form-error\">\n {{ error }}\n \u003c/div>\n \u003cbutton type=\"submit\">Sign In\u003c/button>\n \u003c/form>\n\u003c/template>\n```\n\n## Example 2: Angular Template Audit\n\n```html\n\u003c!-- BEFORE: src/app/dashboard/dashboard.component.html -->\n\u003cdiv class=\"tabs\">\n \u003cdiv *ngFor=\"let tab of tabs\"\n (click)=\"selectTab(tab)\"\n [class.active]=\"tab.active\">\n {{ tab.label }}\n \u003c/div>\n\u003c/div>\n\u003cdiv class=\"tab-content\">\n \u003cdiv *ngIf=\"selectedTab\">{{ selectedTab.content }}\u003c/div>\n\u003c/div>\n```\n\n**Violations detected:**\n\n| # | WCAG | Severity | Issue |\n|---|------|----------|-------|\n| 1 | 4.1.2 | Critical | Tab widget missing ARIA roles (`tablist`, `tab`, `tabpanel`) |\n| 2 | 2.1.1 | Critical | Tabs not keyboard navigable (arrow keys, Home, End) |\n| 3 | 2.4.11 | Major | No visible focus indicator on active tab |\n\n```html\n\u003c!-- AFTER: src/app/dashboard/dashboard.component.html -->\n\u003cdiv class=\"tabs\" role=\"tablist\" aria-label=\"Dashboard sections\">\n \u003cbutton\n *ngFor=\"let tab of tabs; let i = index\"\n role=\"tab\"\n [id]=\"'tab-' + tab.id\"\n [attr.aria-selected]=\"tab.active\"\n [attr.aria-controls]=\"'panel-' + tab.id\"\n [attr.tabindex]=\"tab.active ? 0 : -1\"\n (click)=\"selectTab(tab)\"\n (keydown)=\"handleTabKeydown($event, i)\"\n class=\"tab-button\"\n [class.active]=\"tab.active\">\n {{ tab.label }}\n \u003c/button>\n\u003c/div>\n\u003cdiv\n *ngIf=\"selectedTab\"\n role=\"tabpanel\"\n [id]=\"'panel-' + selectedTab.id\"\n [attr.aria-labelledby]=\"'tab-' + selectedTab.id\"\n tabindex=\"0\"\n class=\"tab-content\">\n {{ selectedTab.content }}\n\u003c/div>\n```\n\n**Supporting TypeScript for keyboard navigation:**\n\n```typescript\n// dashboard.component.ts\nhandleTabKeydown(event: KeyboardEvent, index: number): void {\n const tabCount = this.tabs.length;\n let newIndex = index;\n\n switch (event.key) {\n case 'ArrowRight':\n newIndex = (index + 1) % tabCount;\n break;\n case 'ArrowLeft':\n newIndex = (index - 1 + tabCount) % tabCount;\n break;\n case 'Home':\n newIndex = 0;\n break;\n case 'End':\n newIndex = tabCount - 1;\n break;\n default:\n return;\n }\n\n event.preventDefault();\n this.selectTab(this.tabs[newIndex]);\n // Move focus to the new tab button\n const tabElement = document.getElementById(`tab-${this.tabs[newIndex].id}`);\n tabElement?.focus();\n}\n```\n\n## Example 3: Next.js Page-Level Audit\n\n```tsx\n// BEFORE: src/app/page.tsx\nexport default function Home() {\n return (\n \u003cmain>\n \u003cdiv className=\"text-4xl font-bold\">Welcome to Acme\u003c/div>\n \u003cdiv className=\"mt-4\">\n Build better products with our platform.\n \u003c/div>\n \u003cdiv className=\"mt-8 bg-blue-600 text-white px-6 py-3 rounded cursor-pointer\"\n onClick={() => router.push('/signup')}>\n Get Started\n \u003c/div>\n \u003c/main>\n );\n}\n```\n\n**Violations detected:**\n\n| # | WCAG | Severity | Issue |\n|---|------|----------|-------|\n| 1 | 1.3.1 | Major | Heading uses `\u003cdiv>` instead of `\u003ch1>` -- no semantic structure |\n| 2 | 2.4.2 | Major | Page missing `\u003ctitle>` (Next.js metadata) |\n| 3 | 2.1.1 | Critical | CTA uses `\u003cdiv onClick>` -- not keyboard accessible |\n| 4 | 3.1.1 | Minor | `\u003chtml>` missing `lang` attribute (check `layout.tsx`) |\n\n```tsx\n// AFTER: src/app/page.tsx\nimport type { Metadata } from 'next';\nimport Link from 'next/link';\n\nexport const metadata: Metadata = {\n title: 'Acme - Build Better Products',\n description: 'Build better products with the Acme platform.',\n};\n\nexport default function Home() {\n return (\n \u003cmain>\n \u003ch1 className=\"text-4xl font-bold\">Welcome to Acme\u003c/h1>\n \u003cp className=\"mt-4\">\n Build better products with our platform.\n \u003c/p>\n \u003cLink\n href=\"/signup\"\n className=\"mt-8 inline-block bg-blue-600 text-white px-6 py-3 rounded\n hover:bg-blue-700 focus-visible:outline focus-visible:outline-2\n focus-visible:outline-offset-2 focus-visible:outline-blue-600\"\n >\n Get Started\n \u003c/Link>\n \u003c/main>\n );\n}\n```\n\n```tsx\n// Also fix: src/app/layout.tsx\nexport default function RootLayout({ children }: { children: React.ReactNode }) {\n return (\n \u003chtml lang=\"en\">\n \u003cbody>{children}\u003c/body>\n \u003c/html>\n );\n}\n```\n\n## Example 4: Svelte Component Audit\n\n```svelte\n\u003c!-- BEFORE: src/lib/components/Accordion.svelte -->\n\u003cscript>\n let openIndex = -1;\n function toggle(i) { openIndex = openIndex === i ? -1 : i; }\n\u003c/script>\n\n{#each items as item, i}\n \u003cdiv class=\"header\" on:click={() => toggle(i)}>\n {item.title}\n \u003c/div>\n {#if openIndex === i}\n \u003cdiv class=\"content\">{item.body}\u003c/div>\n {/if}\n{/each}\n```\n\n**Violations detected:**\n\n| # | WCAG | Severity | Issue |\n|---|------|----------|-------|\n| 1 | 4.1.2 | Critical | Accordion missing ARIA roles and properties |\n| 2 | 2.1.1 | Critical | Headers not keyboard accessible |\n| 3 | 2.5.8 | Minor | Click targets may be smaller than 24x24px (NEW in WCAG 2.2) |\n\n```svelte\n\u003c!-- AFTER: src/lib/components/Accordion.svelte -->\n\u003cscript>\n export let items = [];\n let openIndex = -1;\n\n function toggle(i) {\n openIndex = openIndex === i ? -1 : i;\n }\n\n function handleKeydown(event, i) {\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault();\n toggle(i);\n }\n }\n\u003c/script>\n\n\u003cdiv class=\"accordion\">\n {#each items as item, i}\n \u003ch3>\n \u003cbutton\n class=\"accordion-header\"\n aria-expanded={openIndex === i}\n aria-controls=\"panel-{i}\"\n id=\"header-{i}\"\n on:click={() => toggle(i)}\n on:keydown={(e) => handleKeydown(e, i)}\n >\n {item.title}\n \u003cspan class=\"icon\" aria-hidden=\"true\">\n {openIndex === i ? '−' : '+'}\n \u003c/span>\n \u003c/button>\n \u003c/h3>\n \u003cdiv\n id=\"panel-{i}\"\n role=\"region\"\n aria-labelledby=\"header-{i}\"\n class=\"accordion-content\"\n class:open={openIndex === i}\n hidden={openIndex !== i}\n >\n {item.body}\n \u003c/div>\n {/each}\n\u003c/div>\n\n\u003cstyle>\n .accordion-header {\n min-height: 44px; /* WCAG 2.5.8 Target Size */\n width: 100%;\n padding: 12px 16px;\n cursor: pointer;\n text-align: left;\n }\n .accordion-header:focus-visible {\n outline: 2px solid #005fcc;\n outline-offset: 2px;\n }\n\u003c/style>\n```\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":8159,"content_sha256":"50c1ba5a69ce4312f86b9161b7f0c3774f025a3ddf7214dce8b8aee35d9c8a02"},{"filename":"references/framework-a11y-patterns.md","content":"# Framework-Specific Accessibility Patterns\n\n## React / Next.js\n\n### Common Issues and Fixes\n\n**Image alt text:**\n```jsx\n// ❌ Bad\n\u003cimg src=\"/hero.jpg\" />\n\u003cImage src=\"/hero.jpg\" width={800} height={400} />\n\n// ✅ Good\n\u003cimg src=\"/hero.jpg\" alt=\"Team collaborating in office\" />\n\u003cImage src=\"/hero.jpg\" width={800} height={400} alt=\"Team collaborating in office\" />\n\n// ✅ Decorative image\n\u003cimg src=\"/divider.svg\" alt=\"\" role=\"presentation\" />\n```\n\n**Form labels:**\n```jsx\n// ❌ Bad — placeholder as label\n\u003cinput placeholder=\"Email\" type=\"email\" />\n\n// ✅ Good — explicit label\n\u003clabel htmlFor=\"email\">Email\u003c/label>\n\u003cinput id=\"email\" type=\"email\" placeholder=\"[email protected]\" />\n\n// ✅ Good — aria-label for icon-only inputs\n\u003cinput type=\"search\" aria-label=\"Search products\" />\n```\n\n**Click handlers on divs:**\n```jsx\n// ❌ Bad — not keyboard accessible\n\u003cdiv onClick={handleClick}>Click me\u003c/div>\n\n// ✅ Good — use button\n\u003cbutton onClick={handleClick}>Click me\u003c/button>\n\n// ✅ If div is required — add keyboard support\n\u003cdiv\n role=\"button\"\n tabIndex={0}\n onClick={handleClick}\n onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') handleClick(); }}\n>\n Click me\n\u003c/div>\n```\n\n**SPA route announcements (Next.js App Router):**\n```jsx\n// Layout component — announce page changes\n'use client';\nimport { usePathname } from 'next/navigation';\nimport { useEffect, useState } from 'react';\n\nexport function RouteAnnouncer() {\n const pathname = usePathname();\n const [announcement, setAnnouncement] = useState('');\n\n useEffect(() => {\n const title = document.title;\n setAnnouncement(`Navigated to ${title}`);\n }, [pathname]);\n\n return (\n \u003cdiv aria-live=\"assertive\" role=\"status\" className=\"sr-only\">\n {announcement}\n \u003c/div>\n );\n}\n```\n\n**Focus management after dynamic content:**\n```jsx\n// After adding item to list, announce it\nconst [items, setItems] = useState([]);\nconst statusRef = useRef(null);\n\nconst addItem = (item) => {\n setItems([...items, item]);\n // Announce to screen readers\n statusRef.current.textContent = `${item.name} added to list`;\n};\n\nreturn (\n \u003c>\n \u003cdiv ref={statusRef} aria-live=\"polite\" className=\"sr-only\" />\n {/* list content */}\n \u003c/>\n);\n```\n\n### React-Specific Libraries\n- `@radix-ui/*` — accessible primitives (Dialog, Tabs, Select, etc.)\n- `@headlessui/react` — unstyled accessible components\n- `react-aria` — Adobe's accessibility hooks\n- `eslint-plugin-jsx-a11y` — lint rules for JSX accessibility\n\n## Vue 3\n\n### Common Issues and Fixes\n\n**Dynamic content announcements:**\n```vue\n\u003ctemplate>\n \u003cdiv aria-live=\"polite\" class=\"sr-only\">\n {{ announcement }}\n \u003c/div>\n \u003cbutton @click=\"search\">Search\u003c/button>\n \u003cul v-if=\"results.length\">\n \u003cli v-for=\"r in results\" :key=\"r.id\">{{ r.name }}\u003c/li>\n \u003c/ul>\n\u003c/template>\n\n\u003cscript setup>\nimport { ref } from 'vue';\nconst results = ref([]);\nconst announcement = ref('');\n\nasync function search() {\n results.value = await fetchResults();\n announcement.value = `${results.value.length} results found`;\n}\n\u003c/script>\n```\n\n**Conditional rendering with focus:**\n```vue\n\u003ctemplate>\n \u003cbutton @click=\"showForm = true\">Add Item\u003c/button>\n \u003cform v-if=\"showForm\" ref=\"formRef\">\n \u003clabel for=\"name\">Name\u003c/label>\n \u003cinput id=\"name\" ref=\"nameInput\" />\n \u003c/form>\n\u003c/template>\n\n\u003cscript setup>\nimport { ref, nextTick } from 'vue';\nconst showForm = ref(false);\nconst nameInput = ref(null);\n\nwatch(showForm, async (val) => {\n if (val) {\n await nextTick();\n nameInput.value?.focus();\n }\n});\n\u003c/script>\n```\n\n### Vue-Specific Libraries\n- `vue-announcer` — route change announcements\n- `@headlessui/vue` — accessible components\n- `eslint-plugin-vuejs-accessibility` — lint rules\n\n## Angular\n\n### Common Issues and Fixes\n\n**CDK accessibility utilities:**\n```typescript\nimport { LiveAnnouncer } from '@angular/cdk/a11y';\nimport { FocusTrapFactory } from '@angular/cdk/a11y';\n\n@Component({...})\nexport class MyComponent {\n constructor(\n private liveAnnouncer: LiveAnnouncer,\n private focusTrapFactory: FocusTrapFactory\n ) {}\n\n addItem(item: Item) {\n this.items.push(item);\n this.liveAnnouncer.announce(`${item.name} added`);\n }\n\n openDialog(element: HTMLElement) {\n const focusTrap = this.focusTrapFactory.create(element);\n focusTrap.focusInitialElement();\n }\n}\n```\n\n**Template-driven forms:**\n```html\n\u003c!-- ❌ Bad -->\n\u003cinput [formControl]=\"email\" placeholder=\"Email\" />\n\n\u003c!-- ✅ Good -->\n\u003clabel for=\"email\">Email address\u003c/label>\n\u003cinput id=\"email\" [formControl]=\"email\"\n [attr.aria-invalid]=\"email.invalid && email.touched\"\n [attr.aria-describedby]=\"email.invalid ? 'email-error' : null\" />\n\u003cdiv id=\"email-error\" *ngIf=\"email.invalid && email.touched\" role=\"alert\">\n Please enter a valid email address.\n\u003c/div>\n```\n\n### Angular-Specific Tools\n- `@angular/cdk/a11y` — `FocusTrap`, `LiveAnnouncer`, `FocusMonitor`\n- `codelyzer` — a11y lint rules for Angular templates\n\n## Svelte / SvelteKit\n\n### Common Issues and Fixes\n\n```svelte\n\u003c!-- ❌ Bad — on:click without keyboard -->\n\u003cdiv on:click={handleClick}>Action\u003c/div>\n\n\u003c!-- ✅ Good — Svelte a11y warning built-in -->\n\u003cbutton on:click={handleClick}>Action\u003c/button>\n\n\u003c!-- ✅ Accessible toggle -->\n\u003cbutton\n on:click={() => isOpen = !isOpen}\n aria-expanded={isOpen}\n aria-controls=\"panel\"\n>\n {isOpen ? 'Close' : 'Open'} Details\n\u003c/button>\n\n{#if isOpen}\n \u003cdiv id=\"panel\" role=\"region\" aria-labelledby=\"toggle-btn\">\n Panel content\n \u003c/div>\n{/if}\n```\n\n**Note:** Svelte has built-in a11y warnings in the compiler — it flags missing alt text, click-without-keyboard, and other common issues at build time.\n\n## Plain HTML\n\n### Checklist for Static Sites\n\n```html\n\u003c!DOCTYPE html>\n\u003chtml lang=\"en\">\n\u003chead>\n \u003cmeta charset=\"UTF-8\">\n \u003cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n \u003ctitle>Descriptive Page Title\u003c/title>\n\u003c/head>\n\u003cbody>\n \u003c!-- Skip link -->\n \u003ca href=\"#main\" class=\"skip-link\">Skip to main content\u003c/a>\n\n \u003cheader>\n \u003cnav aria-label=\"Main navigation\">\n \u003cul>\n \u003cli>\u003ca href=\"/\">Home\u003c/a>\u003c/li>\n \u003cli>\u003ca href=\"/about\" aria-current=\"page\">About\u003c/a>\u003c/li>\n \u003c/ul>\n \u003c/nav>\n \u003c/header>\n\n \u003cmain id=\"main\" tabindex=\"-1\">\n \u003ch1>Page Heading\u003c/h1>\n \u003c!-- Only one h1 per page -->\n \u003c!-- Heading levels don't skip (h1 → h2 → h3, never h1 → h3) -->\n \u003c/main>\n\n \u003cfooter>\n \u003cp>© 2026 Company Name\u003c/p>\n \u003c/footer>\n\u003c/body>\n\u003c/html>\n```\n\n## CSS Accessibility Patterns\n\n### Focus Indicators\n\n```css\n/* ❌ Bad — removes focus indicator entirely */\n:focus { outline: none; }\n\n/* ✅ Good — custom focus indicator */\n:focus-visible {\n outline: 2px solid #005fcc;\n outline-offset: 2px;\n}\n\n/* ✅ Good — enhanced for high contrast mode */\n@media (forced-colors: active) {\n :focus-visible {\n outline: 2px solid ButtonText;\n }\n}\n```\n\n### Reduced Motion\n\n```css\n/* ✅ Respect prefers-reduced-motion */\n@media (prefers-reduced-motion: reduce) {\n *, *::before, *::after {\n animation-duration: 0.01ms !important;\n animation-iteration-count: 1 !important;\n transition-duration: 0.01ms !important;\n }\n}\n```\n\n### Screen Reader Only\n\n```css\n.sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n padding: 0;\n margin: -1px;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n white-space: nowrap;\n border-width: 0;\n}\n```\n\n## Fix Patterns Catalog\n\n### React / Next.js Fix Patterns\n\n#### Missing Alt Text (1.1.1)\n\n```tsx\n// BEFORE\n\u003cimg src={hero} />\n\n// AFTER - Informational image\n\u003cimg src={hero} alt=\"Team collaborating around a whiteboard\" />\n\n// AFTER - Decorative image\n\u003cimg src={divider} alt=\"\" role=\"presentation\" />\n```\n\n#### Non-Interactive Element with Click Handler (2.1.1)\n\n```tsx\n// BEFORE\n\u003cdiv onClick={handleClick}>Click me\u003c/div>\n\n// AFTER - If it navigates\n\u003cLink href=\"/destination\">Click me\u003c/Link>\n\n// AFTER - If it performs an action\n\u003cbutton type=\"button\" onClick={handleClick}>Click me\u003c/button>\n```\n\n#### Missing Focus Management in Modals (2.4.3)\n\n```tsx\n// BEFORE\nfunction Modal({ isOpen, onClose, children }) {\n if (!isOpen) return null;\n return \u003cdiv className=\"modal-overlay\">{children}\u003c/div>;\n}\n\n// AFTER\nimport { useEffect, useRef } from 'react';\n\nfunction Modal({ isOpen, onClose, children, title }) {\n const modalRef = useRef(null);\n const previousFocus = useRef(null);\n\n useEffect(() => {\n if (isOpen) {\n previousFocus.current = document.activeElement;\n modalRef.current?.focus();\n } else {\n previousFocus.current?.focus();\n }\n }, [isOpen]);\n\n useEffect(() => {\n if (!isOpen) return;\n const handleKeydown = (e) => {\n if (e.key === 'Escape') onClose();\n if (e.key === 'Tab') {\n const focusable = modalRef.current?.querySelectorAll(\n 'button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])'\n );\n if (!focusable?.length) return;\n const first = focusable[0];\n const last = focusable[focusable.length - 1];\n if (e.shiftKey && document.activeElement === first) {\n e.preventDefault();\n last.focus();\n } else if (!e.shiftKey && document.activeElement === last) {\n e.preventDefault();\n first.focus();\n }\n }\n };\n document.addEventListener('keydown', handleKeydown);\n return () => document.removeEventListener('keydown', handleKeydown);\n }, [isOpen, onClose]);\n\n if (!isOpen) return null;\n\n return (\n \u003cdiv className=\"modal-overlay\" onClick={onClose} aria-hidden=\"true\">\n \u003cdiv\n ref={modalRef}\n role=\"dialog\"\n aria-modal=\"true\"\n aria-label={title}\n tabIndex={-1}\n onClick={(e) => e.stopPropagation()}\n >\n \u003cbutton\n onClick={onClose}\n aria-label=\"Close dialog\"\n className=\"modal-close\"\n >\n ×\n \u003c/button>\n {children}\n \u003c/div>\n \u003c/div>\n );\n}\n```\n\n#### Focus Appearance (2.4.11 -- NEW in WCAG 2.2)\n\n```css\n/* BEFORE */\nbutton:focus {\n outline: none; /* Removes default focus indicator */\n}\n\n/* AFTER - Meets WCAG 2.2 Focus Appearance */\nbutton:focus-visible {\n outline: 2px solid #005fcc;\n outline-offset: 2px;\n}\n```\n\n```tsx\n// Tailwind CSS pattern\n\u003cbutton className=\"focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600\">\n Submit\n\u003c/button>\n```\n\n### Vue Fix Patterns\n\n#### Missing Form Labels (1.3.1)\n\n```vue\n\u003c!-- BEFORE -->\n\u003cinput type=\"text\" v-model=\"name\" placeholder=\"Name\" />\n\n\u003c!-- AFTER -->\n\u003clabel for=\"user-name\">Name\u003c/label>\n\u003cinput id=\"user-name\" type=\"text\" v-model=\"name\" autocomplete=\"name\" />\n```\n\n#### Dynamic Content Without Live Region (4.1.3)\n\n```vue\n\u003c!-- BEFORE -->\n\u003cdiv v-if=\"status\">{{ statusMessage }}\u003c/div>\n\n\u003c!-- AFTER -->\n\u003cdiv aria-live=\"polite\" aria-atomic=\"true\">\n \u003cp v-if=\"status\">{{ statusMessage }}\u003c/p>\n\u003c/div>\n```\n\n#### Vue Router Navigation Announcements (2.4.2)\n\n```typescript\n// router/index.ts\nrouter.afterEach((to) => {\n const title = to.meta.title || 'Page';\n document.title = `${title} | My App`;\n\n // Announce route change to screen readers\n const announcer = document.getElementById('route-announcer');\n if (announcer) {\n announcer.textContent = `Navigated to ${title}`;\n }\n});\n```\n\n```vue\n\u003c!-- App.vue - Add announcer element -->\n\u003cdiv\n id=\"route-announcer\"\n role=\"status\"\n aria-live=\"assertive\"\n aria-atomic=\"true\"\n class=\"sr-only\"\n>\u003c/div>\n```\n\n### Angular Fix Patterns\n\n#### Missing ARIA on Custom Components (4.1.2)\n\n```typescript\n// BEFORE\n@Component({\n selector: 'app-dropdown',\n template: `\n \u003cdiv (click)=\"toggle()\">{{ selected }}\u003c/div>\n \u003cdiv *ngIf=\"isOpen\">\n \u003cdiv *ngFor=\"let opt of options\" (click)=\"select(opt)\">{{ opt }}\u003c/div>\n \u003c/div>\n `\n})\n\n// AFTER\n@Component({\n selector: 'app-dropdown',\n template: `\n \u003cbutton\n role=\"combobox\"\n [attr.aria-expanded]=\"isOpen\"\n aria-haspopup=\"listbox\"\n [attr.aria-label]=\"label\"\n (click)=\"toggle()\"\n (keydown)=\"handleKeydown($event)\"\n >\n {{ selected }}\n \u003c/button>\n \u003cul *ngIf=\"isOpen\" role=\"listbox\" [attr.aria-label]=\"label + ' options'\">\n \u003cli\n *ngFor=\"let opt of options; let i = index\"\n role=\"option\"\n [attr.aria-selected]=\"opt === selected\"\n [attr.id]=\"'option-' + i\"\n (click)=\"select(opt)\"\n (keydown)=\"handleOptionKeydown($event, opt, i)\"\n tabindex=\"-1\"\n >\n {{ opt }}\n \u003c/li>\n \u003c/ul>\n `\n})\n```\n\n#### Angular CDK A11y Module Integration\n\n```typescript\n// Use Angular CDK for focus trap in dialogs\nimport { A11yModule } from '@angular/cdk/a11y';\n\n@Component({\n template: `\n \u003cdiv cdkTrapFocus cdkTrapFocusAutoCapture>\n \u003ch2 id=\"dialog-title\">Edit Profile\u003c/h2>\n \u003c!-- dialog content -->\n \u003c/div>\n `\n})\n```\n\n### Svelte Fix Patterns\n\n#### Accessible Announcements (4.1.3)\n\n```svelte\n\u003c!-- BEFORE -->\n{#if message}\n \u003cp class=\"toast\">{message}\u003c/p>\n{/if}\n\n\u003c!-- AFTER -->\n\u003cdiv aria-live=\"polite\" class=\"sr-only\">\n {#if message}\n \u003cp>{message}\u003c/p>\n {/if}\n\u003c/div>\n\u003cdiv class=\"toast\" aria-hidden=\"true\">\n {#if message}\n \u003cp>{message}\u003c/p>\n {/if}\n\u003c/div>\n```\n\n#### SvelteKit Page Titles (2.4.2)\n\n```svelte\n\u003c!-- +page.svelte -->\n\u003csvelte:head>\n \u003ctitle>Dashboard | My App\u003c/title>\n\u003c/svelte:head>\n```\n\n### Plain HTML Fix Patterns\n\n#### Skip Navigation Link (2.4.1)\n\n```html\n\u003c!-- BEFORE -->\n\u003cbody>\n \u003cnav>\u003c!-- long navigation -->\u003c/nav>\n \u003cmain>\u003c!-- content -->\u003c/main>\n\u003c/body>\n\n\u003c!-- AFTER -->\n\u003cbody>\n \u003ca href=\"#main-content\" class=\"skip-link\">Skip to main content\u003c/a>\n \u003cnav aria-label=\"Main navigation\">\u003c!-- long navigation -->\u003c/nav>\n \u003cmain id=\"main-content\" tabindex=\"-1\">\u003c!-- content -->\u003c/main>\n\u003c/body>\n```\n\n```css\n.skip-link {\n position: absolute;\n top: -40px;\n left: 0;\n padding: 8px 16px;\n background: #005fcc;\n color: #fff;\n z-index: 1000;\n transition: top 0.2s;\n}\n.skip-link:focus {\n top: 0;\n}\n```\n\n#### Accessible Data Table (1.3.1)\n\n```html\n\u003c!-- BEFORE -->\n\u003ctable>\n \u003ctr>\u003ctd>Name\u003c/td>\u003ctd>Email\u003c/td>\u003ctd>Role\u003c/td>\u003c/tr>\n \u003ctr>\u003ctd>Alice\u003c/td>\u003ctd>[email protected]\u003c/td>\u003ctd>Admin\u003c/td>\u003c/tr>\n\u003c/table>\n\n\u003c!-- AFTER -->\n\u003ctable aria-label=\"Team members\">\n \u003ccaption class=\"sr-only\">List of team members and their roles\u003c/caption>\n \u003cthead>\n \u003ctr>\n \u003cth scope=\"col\">Name\u003c/th>\n \u003cth scope=\"col\">Email\u003c/th>\n \u003cth scope=\"col\">Role\u003c/th>\n \u003c/tr>\n \u003c/thead>\n \u003ctbody>\n \u003ctr>\n \u003cth scope=\"row\">Alice\u003c/th>\n \u003ctd>[email protected]\u003c/td>\n \u003ctd>Admin\u003c/td>\n \u003c/tr>\n \u003c/tbody>\n\u003c/table>\n```\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":14416,"content_sha256":"e2694afd26dc7e0e1cc4f4b47571268c2a213e830159e83d050fb9b926193274"},{"filename":"references/testing-checklist.md","content":"# Accessibility Testing Checklist\n\nUse this checklist after applying fixes to verify accessibility manually.\n\n## Keyboard Navigation\n- [ ] All interactive elements reachable via Tab key\n- [ ] Tab order follows visual/logical reading order\n- [ ] Focus indicator visible on every focusable element (2px+ outline)\n- [ ] Modals trap focus and return focus on close\n- [ ] Escape key closes modals, dropdowns, and popups\n- [ ] Arrow keys navigate within composite widgets (tabs, menus, listboxes)\n- [ ] No keyboard traps (user can always Tab away)\n\n## Screen Reader\n- [ ] All images have appropriate alt text (or `alt=\"\"` for decorative)\n- [ ] Headings create logical document outline (h1 -> h2 -> h3)\n- [ ] Form inputs have associated labels\n- [ ] Error messages announced via `aria-live` or `role=\"alert\"`\n- [ ] Page title updates on navigation (SPA)\n- [ ] Dynamic content changes announced appropriately\n\n## Visual\n- [ ] Text contrast meets 4.5:1 for normal text, 3:1 for large text\n- [ ] UI component contrast meets 3:1 against background\n- [ ] Content reflows without horizontal scrolling at 320px width\n- [ ] Text resizable to 200% without loss of content\n- [ ] No information conveyed by color alone\n- [ ] Focus indicators meet 2.4.11 Focus Appearance criteria\n\n## Motion and Media\n- [ ] Animations respect `prefers-reduced-motion`\n- [ ] No auto-playing media with audio\n- [ ] No content flashing more than 3 times per second\n- [ ] Video has captions; audio has transcripts\n\n## Forms\n- [ ] All inputs have visible labels\n- [ ] Required fields indicated (not by color alone)\n- [ ] Error messages specific and associated with input via `aria-describedby`\n- [ ] Autocomplete attributes present on common fields (name, email, etc.)\n- [ ] No CAPTCHA without alternative method (WCAG 2.2 3.3.8)\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":1790,"content_sha256":"5bf71ecc3ed2e080ec37e62460863539e886981cf864466000fa72d2acfbae47"},{"filename":"references/wcag-22-new-criteria.md","content":"# WCAG 2.2 New Success Criteria Reference\n\nThese criteria were added in WCAG 2.2 and are commonly missed.\n\n## 2.4.11 Focus Appearance (Level AA)\n\nThe focus indicator must have a minimum area of a 2px perimeter around the component and a contrast ratio of at least 3:1 against adjacent colors.\n\n**Pattern:**\n```css\n:focus-visible {\n outline: 2px solid #005fcc;\n outline-offset: 2px;\n}\n```\n\n## 2.5.7 Dragging Movements (Level AA)\n\nAny functionality that uses dragging must have a single-pointer alternative (click, tap).\n\n**Pattern:**\n```tsx\n// Sortable list: support both drag and button-based reorder\n\u003cli draggable onDragStart={handleDrag}>\n {item.name}\n \u003cbutton onClick={() => moveUp(index)} aria-label={`Move ${item.name} up`}>\n Move Up\n \u003c/button>\n \u003cbutton onClick={() => moveDown(index)} aria-label={`Move ${item.name} down`}>\n Move Down\n \u003c/button>\n\u003c/li>\n```\n\n## 2.5.8 Target Size (Level AA)\n\nInteractive targets must be at least 24x24 CSS pixels, with exceptions for inline text links and elements where the spacing provides equivalent clearance.\n\n**Pattern:**\n```css\nbutton, a, input, select, textarea {\n min-height: 24px;\n min-width: 24px;\n}\n\n/* Recommended: 44x44px for touch targets */\n@media (pointer: coarse) {\n button, a, input[type=\"checkbox\"], input[type=\"radio\"] {\n min-height: 44px;\n min-width: 44px;\n }\n}\n```\n\n## 3.3.7 Redundant Entry (Level A)\n\nInformation previously entered by the user must be auto-populated or available for selection when needed again in the same process.\n\n**Pattern:**\n```tsx\n// Multi-step form: persist data across steps\nconst [formData, setFormData] = useState({});\n\n// Step 2 pre-fills shipping address from billing\n\u003cinput\n defaultValue={formData.billingAddress || ''}\n autoComplete=\"shipping street-address\"\n/>\n```\n\n## 3.3.8 Accessible Authentication (Level AA)\n\nAuthentication must not require cognitive function tests (e.g., remembering a password, solving a puzzle) unless an alternative is provided.\n\n**Pattern:**\n- Support password managers (`autocomplete=\"current-password\"`)\n- Offer passkey / biometric authentication\n- Allow copy-paste in password fields (never block paste)\n- Provide email/SMS OTP as alternative to CAPTCHA\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":2201,"content_sha256":"4aa02091a952ed22c9e5baf7a97598632164f7cd5a37a3c588482ec3e187051f"},{"filename":"references/wcag-quick-ref.md","content":"# WCAG 2.2 Quick Reference — Level A & AA\n\n## Perceivable\n\n### 1.1 Text Alternatives\n\n| Criterion | Level | Requirement | Common Violation |\n|-----------|-------|-------------|------------------|\n| 1.1.1 Non-text Content | A | All images have `alt` text; decorative images use `alt=\"\"` or `role=\"presentation\"` | `\u003cimg src=\"logo.png\">` without alt |\n\n### 1.2 Time-Based Media\n\n| Criterion | Level | Requirement | Common Violation |\n|-----------|-------|-------------|------------------|\n| 1.2.1 Audio-only / Video-only | A | Provide transcript or audio description | Video without captions |\n| 1.2.2 Captions | A | Captions for all prerecorded audio in video | Missing `\u003ctrack kind=\"captions\">` |\n| 1.2.3 Audio Description | A | Audio description for prerecorded video | No descriptive track |\n| 1.2.5 Audio Description (Prerecorded) | AA | Audio description for all prerecorded video | Same as 1.2.3 but stricter |\n\n### 1.3 Adaptable\n\n| Criterion | Level | Requirement | Common Violation |\n|-----------|-------|-------------|------------------|\n| 1.3.1 Info and Relationships | A | Semantic markup conveys structure | Using `\u003cdiv>` instead of `\u003cnav>`, `\u003cmain>`, `\u003cheader>` |\n| 1.3.2 Meaningful Sequence | A | Reading order matches visual order | CSS flex/grid reordering without DOM reorder |\n| 1.3.3 Sensory Characteristics | A | Don't rely solely on color, shape, position | \"Click the red button\" |\n| 1.3.4 Orientation | AA | Content not restricted to portrait/landscape | CSS `orientation: portrait` lock |\n| 1.3.5 Identify Input Purpose | AA | Input purpose identifiable via `autocomplete` | Missing `autocomplete=\"email\"` on email inputs |\n\n### 1.4 Distinguishable\n\n| Criterion | Level | Requirement | Ratio |\n|-----------|-------|-------------|-------|\n| 1.4.1 Use of Color | A | Color not sole means of conveying info | Red-only error indicators |\n| 1.4.2 Audio Control | A | Auto-playing audio has pause/stop | `autoplay` without `controls` |\n| 1.4.3 Contrast (Minimum) | AA | Text: 4.5:1, Large text: 3:1 | Light gray text on white |\n| 1.4.4 Resize Text | AA | Text resizable to 200% without loss | Fixed `px` font sizes |\n| 1.4.5 Images of Text | AA | Use real text, not text in images | Logo text as PNG |\n| 1.4.10 Reflow | AA | Content reflows at 320px width | Horizontal scrolling at mobile widths |\n| 1.4.11 Non-text Contrast | AA | UI components and graphics: 3:1 | Low-contrast borders, icons |\n| 1.4.12 Text Spacing | AA | No loss of content when spacing adjusted | Fixed-height containers clipping |\n| 1.4.13 Content on Hover/Focus | AA | Dismissible, hoverable, persistent | Tooltips that disappear on mouse move |\n\n## Operable\n\n### 2.1 Keyboard Accessible\n\n| Criterion | Level | Requirement | Common Violation |\n|-----------|-------|-------------|------------------|\n| 2.1.1 Keyboard | A | All functionality via keyboard | `onClick` without `onKeyDown` |\n| 2.1.2 No Keyboard Trap | A | Focus can move away from any component | Modal without focus trap escape |\n| 2.1.4 Character Key Shortcuts | A | Single-key shortcuts can be turned off | `accesskey` conflicts |\n\n### 2.4 Navigable\n\n| Criterion | Level | Requirement | Common Violation |\n|-----------|-------|-------------|------------------|\n| 2.4.1 Bypass Blocks | A | Skip navigation link | No \"Skip to content\" link |\n| 2.4.2 Page Titled | A | Descriptive `\u003ctitle>` | `\u003ctitle>Untitled\u003c/title>` |\n| 2.4.3 Focus Order | A | Logical tab order | `tabindex` > 0 |\n| 2.4.4 Link Purpose | A | Link text describes destination | \"Click here\", \"Read more\" |\n| 2.4.6 Headings and Labels | AA | Descriptive headings | Generic headings |\n| 2.4.7 Focus Visible | AA | Visible focus indicator | `outline: none` without replacement |\n| 2.4.11 Focus Not Obscured | AA | Focused element not hidden by sticky header | Fixed header covering focused element |\n\n### 2.5 Input Modalities\n\n| Criterion | Level | Requirement | Common Violation |\n|-----------|-------|-------------|------------------|\n| 2.5.1 Pointer Gestures | A | Multi-point gestures have single-point alternative | Pinch-to-zoom only |\n| 2.5.2 Pointer Cancellation | A | Down-event doesn't trigger action | `mousedown` instead of `click` |\n| 2.5.3 Label in Name | A | Visible label is in accessible name | Button shows \"Submit\" but `aria-label=\"btn1\"` |\n| 2.5.4 Motion Actuation | A | Motion-triggered actions have alternative | Shake-to-undo only |\n| 2.5.7 Dragging Movements | AA | Drag has single-pointer alternative | Drag-and-drop only reordering |\n| 2.5.8 Target Size | AA | Touch targets minimum 24x24 CSS pixels | Tiny mobile buttons |\n\n## Understandable\n\n### 3.1 Readable\n\n| Criterion | Level | Requirement | Common Violation |\n|-----------|-------|-------------|------------------|\n| 3.1.1 Language of Page | A | `\u003chtml lang=\"en\">` | Missing `lang` attribute |\n| 3.1.2 Language of Parts | AA | `lang` on foreign-language spans | Mixed-language content without `lang` |\n\n### 3.2 Predictable\n\n| Criterion | Level | Requirement | Common Violation |\n|-----------|-------|-------------|------------------|\n| 3.2.1 On Focus | A | Focus doesn't trigger unexpected change | Auto-submitting on focus |\n| 3.2.2 On Input | A | Input doesn't trigger unexpected change | Auto-navigating on select change |\n| 3.2.3 Consistent Navigation | AA | Navigation consistent across pages | Menu order changes per page |\n| 3.2.4 Consistent Identification | AA | Same function = same label | \"Search\" vs \"Find\" for same action |\n\n### 3.3 Input Assistance\n\n| Criterion | Level | Requirement | Common Violation |\n|-----------|-------|-------------|------------------|\n| 3.3.1 Error Identification | A | Errors described in text | Red border only, no message |\n| 3.3.2 Labels or Instructions | A | Labels for required input | Placeholder as only label |\n| 3.3.3 Error Suggestion | AA | Suggest corrections | \"Invalid input\" without guidance |\n| 3.3.4 Error Prevention | AA | Reversible submissions for legal/financial | No confirmation for payment |\n| 3.3.7 Redundant Entry | A | Don't ask for same info twice | Re-entering address in checkout |\n| 3.3.8 Accessible Authentication | AA | No cognitive function test for login | CAPTCHA without audio alternative |\n\n## Robust\n\n### 4.1 Compatible\n\n| Criterion | Level | Requirement | Common Violation |\n|-----------|-------|-------------|------------------|\n| 4.1.2 Name, Role, Value | A | Custom controls have accessible name and role | Custom dropdown without ARIA |\n| 4.1.3 Status Messages | AA | Status updates announced without focus change | Toast without `aria-live` |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":6528,"content_sha256":"481b32dc92e7bd5286795fff1cde31bea4936451bd15b8b812be3fc76d549b6e"},{"filename":"scripts/a11y_scanner.py","content":"#!/usr/bin/env python3\n\"\"\"WCAG 2.2 Accessibility Scanner for Frontend Codebases.\n\nScans HTML, JSX, TSX, Vue, Svelte, and CSS files for accessibility\nviolations across 10 categories: images, forms, headings, landmarks,\nkeyboard, ARIA, color/contrast, links, tables, and media.\n\nUsage:\n python a11y_scanner.py /path/to/project\n python a11y_scanner.py /path/to/project --json\n python a11y_scanner.py /path/to/project --severity critical,serious\n python a11y_scanner.py /path/to/project --format json\n\"\"\"\n\nimport argparse\nimport json\nimport os\nimport re\nimport sys\nfrom dataclasses import dataclass, asdict\nfrom typing import List, Optional\n\n\n@dataclass\nclass Finding:\n \"\"\"A single accessibility finding.\"\"\"\n rule_id: str\n category: str\n severity: str\n message: str\n file: str\n line: int\n snippet: str\n wcag_criterion: str\n fix: str\n\n\n# ---------------------------------------------------------------------------\n# Rule definitions: each returns a list of Finding from a single file\n# ---------------------------------------------------------------------------\n\nVALID_ARIA_ATTRS = {\n \"aria-activedescendant\", \"aria-atomic\", \"aria-autocomplete\", \"aria-busy\",\n \"aria-checked\", \"aria-colcount\", \"aria-colindex\", \"aria-colspan\",\n \"aria-controls\", \"aria-current\", \"aria-describedby\", \"aria-details\",\n \"aria-disabled\", \"aria-dropeffect\", \"aria-errormessage\", \"aria-expanded\",\n \"aria-flowto\", \"aria-grabbed\", \"aria-haspopup\", \"aria-hidden\",\n \"aria-invalid\", \"aria-keyshortcuts\", \"aria-label\", \"aria-labelledby\",\n \"aria-level\", \"aria-live\", \"aria-modal\", \"aria-multiline\",\n \"aria-multiselectable\", \"aria-orientation\", \"aria-owns\", \"aria-placeholder\",\n \"aria-posinset\", \"aria-pressed\", \"aria-readonly\", \"aria-relevant\",\n \"aria-required\", \"aria-roledescription\", \"aria-rowcount\", \"aria-rowindex\",\n \"aria-rowspan\", \"aria-selected\", \"aria-setsize\", \"aria-sort\",\n \"aria-valuemax\", \"aria-valuemin\", \"aria-valuenow\", \"aria-valuetext\",\n \"aria-braillelabel\", \"aria-brailleroledescription\", \"aria-description\",\n}\n\nBAD_LINK_TEXT = re.compile(\n r\">\\s*(click here|here|read more|more|link|this)\\s*\u003c\", re.IGNORECASE\n)\n\nTAG_RE = re.compile(r\"\u003c(\\w[\\w-]*)\\b([^>]*)(/?)>\", re.DOTALL)\nATTR_RE = re.compile(r\"\"\"([\\w:.-]+)\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)'|(\\S+))\"\"\")\nATTR_BOOL_RE = re.compile(r\"\\b([\\w:.-]+)(?=\\s|/?>|$)\")\nINLINE_COLOR_RE = re.compile(\n r'style\\s*=\\s*[\"\\'][^\"\\']*\\bcolor\\s*:', re.IGNORECASE\n)\nARIA_ATTR_RE = re.compile(r\"\\baria-[\\w-]+\")\n\n\ndef _attrs(attr_str: str) -> dict:\n \"\"\"Parse HTML/JSX attribute string into a dict.\"\"\"\n result = {}\n for m in ATTR_RE.finditer(attr_str):\n result[m.group(1)] = m.group(2) or m.group(3) or m.group(4) or \"\"\n # boolean attrs\n cleaned = ATTR_RE.sub(\"\", attr_str)\n for m in ATTR_BOOL_RE.finditer(cleaned):\n name = m.group(1)\n if name not in result and not name.startswith(\"/\"):\n result[name] = True\n return result\n\n\ndef _snippet(line_text: str) -> str:\n \"\"\"Trim a line for display as a code snippet.\"\"\"\n s = line_text.rstrip(\"\\n\\r\")\n return s[:120] + \"...\" if len(s) > 120 else s\n\n\ndef _find(rule_id, cat, sev, msg, fp, ln, snip, wcag, fix):\n return Finding(rule_id, cat, sev, msg, fp, ln, snip, wcag, fix)\n\n\n# ---------- Images ----------------------------------------------------------\n\ndef check_img_missing_alt(tag, attrs, fp, ln, snip):\n if tag == \"img\" and \"alt\" not in attrs:\n return _find(\"img-alt-missing\", \"images\", \"critical\",\n \"\u003cimg> missing alt attribute\",\n fp, ln, snip, \"1.1.1 Non-text Content\",\n \"Add alt=\\\"description\\\" or alt=\\\"\\\" for decorative images.\")\n\ndef check_img_empty_alt_informative(tag, attrs, fp, ln, snip):\n if tag == \"img\" and attrs.get(\"alt\") == \"\" and attrs.get(\"src\", \"\"):\n src = attrs.get(\"src\", \"\")\n if not any(kw in src.lower() for kw in (\"spacer\", \"border\", \"decorat\", \"bg\")):\n return _find(\"img-alt-empty-informative\", \"images\", \"serious\",\n \"\u003cimg> has empty alt but may be informative\",\n fp, ln, snip, \"1.1.1 Non-text Content\",\n \"If image conveys information, add descriptive alt text.\")\n\ndef check_img_decorative_has_alt(tag, attrs, fp, ln, snip):\n if tag == \"img\" and attrs.get(\"role\") == \"presentation\" and attrs.get(\"alt\", \"\") != \"\":\n return _find(\"img-decorative-alt\", \"images\", \"moderate\",\n \"Decorative image (role=presentation) should have alt=\\\"\\\"\",\n fp, ln, snip, \"1.1.1 Non-text Content\",\n \"Set alt=\\\"\\\" on decorative images with role=presentation.\")\n\n\n# ---------- Forms -----------------------------------------------------------\n\ndef check_input_missing_label(tag, attrs, fp, ln, snip):\n input_types = {\"text\", \"email\", \"password\", \"search\", \"tel\", \"url\", \"number\", \"date\"}\n if tag == \"input\" and attrs.get(\"type\", \"text\") in input_types:\n if \"aria-label\" not in attrs and \"aria-labelledby\" not in attrs and \"id\" not in attrs:\n return _find(\"form-input-no-label\", \"forms\", \"critical\",\n \"\u003cinput> has no id, aria-label, or aria-labelledby\",\n fp, ln, snip, \"1.3.1 Info and Relationships\",\n \"Add id + \u003clabel for>, or aria-label attribute.\")\n\ndef check_input_no_aria_label(tag, attrs, fp, ln, snip):\n if tag in (\"select\", \"textarea\"):\n if \"aria-label\" not in attrs and \"aria-labelledby\" not in attrs and \"id\" not in attrs:\n return _find(\"form-select-no-label\", \"forms\", \"critical\",\n f\"\u003c{tag}> has no accessible name\",\n fp, ln, snip, \"4.1.2 Name, Role, Value\",\n f\"Add aria-label or id + \u003clabel for> to \u003c{tag}>.\")\n\ndef check_orphan_label(lines, fp):\n \"\"\"Labels whose 'for' points to a non-existent id.\"\"\"\n findings = []\n ids = set()\n label_fors = []\n for ln, line in enumerate(lines, 1):\n for m in re.finditer(r'\\bid\\s*=\\s*[\"\\']([^\"\\']+)[\"\\']', line):\n ids.add(m.group(1))\n for m in re.finditer(r'\u003clabel[^>]*\\bfor\\s*=\\s*[\"\\']([^\"\\']+)[\"\\']', line):\n label_fors.append((ln, m.group(1), line))\n for ln, for_val, line in label_fors:\n if for_val not in ids:\n findings.append(_find(\"form-orphan-label\", \"forms\", \"serious\",\n f\"\u003clabel for=\\\"{for_val}\\\"> references non-existent id\",\n fp, ln, _snippet(line), \"1.3.1 Info and Relationships\",\n f\"Ensure an element with id=\\\"{for_val}\\\" exists.\"))\n return findings\n\ndef check_fieldset_legend(lines, fp):\n \"\"\"Radio/checkbox groups without fieldset.\"\"\"\n findings = []\n radio_lines = []\n has_fieldset = any(\"fieldset\" in l.lower() for l in lines)\n for ln, line in enumerate(lines, 1):\n if re.search(r'type\\s*=\\s*[\"\\'](?:radio|checkbox)[\"\\']', line, re.I):\n radio_lines.append((ln, line))\n if radio_lines and not has_fieldset:\n ln, line = radio_lines[0]\n findings.append(_find(\"form-missing-fieldset\", \"forms\", \"serious\",\n \"Radio/checkbox group without \u003cfieldset>/\u003clegend>\",\n fp, ln, _snippet(line), \"1.3.1 Info and Relationships\",\n \"Wrap related radio/checkbox inputs in \u003cfieldset> with \u003clegend>.\"))\n return findings\n\n\n# ---------- Headings --------------------------------------------------------\n\ndef check_headings(lines, fp):\n findings = []\n heading_levels = []\n for ln, line in enumerate(lines, 1):\n for m in re.finditer(r\"\u003c[hH]([1-6])\\b\", line):\n heading_levels.append((int(m.group(1)), ln, line))\n if not heading_levels:\n return findings\n # Missing h1\n levels_seen = {h[0] for h in heading_levels}\n if 1 not in levels_seen and any(l \u003c= 3 for l in levels_seen):\n findings.append(_find(\"heading-missing-h1\", \"headings\", \"serious\",\n \"Page has headings but no \u003ch1>\",\n fp, heading_levels[0][1], _snippet(heading_levels[0][2]),\n \"1.3.1 Info and Relationships\",\n \"Add a single \u003ch1> as the main page heading.\"))\n # Multiple h1s\n h1_lines = [(ln, line) for lvl, ln, line in heading_levels if lvl == 1]\n if len(h1_lines) > 1:\n findings.append(_find(\"heading-multiple-h1\", \"headings\", \"moderate\",\n f\"Page has {len(h1_lines)} \u003ch1> elements\",\n fp, h1_lines[1][0], _snippet(h1_lines[1][1]),\n \"1.3.1 Info and Relationships\",\n \"Use a single \u003ch1> per page. Demote others to \u003ch2>+.\"))\n # Skipped levels\n prev_level = 0\n for lvl, ln, line in heading_levels:\n if prev_level > 0 and lvl > prev_level + 1:\n findings.append(_find(\"heading-skipped\", \"headings\", \"moderate\",\n f\"Heading level skips from h{prev_level} to h{lvl}\",\n fp, ln, _snippet(line),\n \"1.3.1 Info and Relationships\",\n f\"Use \u003ch{prev_level + 1}> instead of \u003ch{lvl}>.\"))\n prev_level = lvl\n return findings\n\n\n# ---------- Landmarks -------------------------------------------------------\n\ndef check_landmarks(lines, fp):\n findings = []\n content = \"\\n\".join(lines)\n # Missing main landmark\n if not re.search(r'\u003cmain\\b|role\\s*=\\s*[\"\\']main[\"\\']', content, re.I):\n findings.append(_find(\"landmark-no-main\", \"landmarks\", \"serious\",\n \"Page missing \u003cmain> landmark\",\n fp, 1, \"\", \"1.3.1 Info and Relationships\",\n \"Add a \u003cmain> element to wrap primary content.\"))\n # Missing nav\n if not re.search(r'\u003cnav\\b|role\\s*=\\s*[\"\\']navigation[\"\\']', content, re.I):\n findings.append(_find(\"landmark-no-nav\", \"landmarks\", \"moderate\",\n \"Page missing \u003cnav> landmark\",\n fp, 1, \"\", \"1.3.1 Info and Relationships\",\n \"Add \u003cnav> for primary navigation blocks.\"))\n # Missing skip link\n if not re.search(r'skip.{0,10}(nav|main|content)', content, re.I):\n findings.append(_find(\"landmark-no-skip-link\", \"landmarks\", \"serious\",\n \"Page missing skip navigation link\",\n fp, 1, \"\", \"2.4.1 Bypass Blocks\",\n \"Add \u003ca href=\\\"#main\\\">Skip to main content\u003c/a> as first focusable element.\"))\n return findings\n\n\n# ---------- Keyboard --------------------------------------------------------\n\ndef check_tabindex_positive(tag, attrs, fp, ln, snip):\n ti = attrs.get(\"tabindex\", \"\")\n if isinstance(ti, str) and ti.lstrip(\"-\").isdigit() and int(ti) > 0:\n return _find(\"keyboard-tabindex-positive\", \"keyboard\", \"serious\",\n f\"tabindex={ti} creates unexpected tab order\",\n fp, ln, snip, \"2.4.3 Focus Order\",\n \"Use tabindex=\\\"0\\\" or tabindex=\\\"-1\\\" instead of positive values.\")\n\ndef check_click_no_keyboard(tag, attrs, fp, ln, snip):\n has_click = \"onClick\" in attrs or \"onclick\" in attrs or \"@click\" in attrs or \"on:click\" in attrs\n has_key = any(k for k in attrs if \"keydown\" in k.lower() or \"keyup\" in k.lower() or \"keypress\" in k.lower())\n if tag in (\"div\", \"span\", \"td\", \"li\", \"p\", \"section\") and has_click and not has_key:\n if attrs.get(\"role\") not in (\"button\", \"link\", \"tab\", \"menuitem\"):\n return _find(\"keyboard-click-no-key\", \"keyboard\", \"critical\",\n f\"\u003c{tag}> has click handler but no keyboard handler\",\n fp, ln, snip, \"2.1.1 Keyboard\",\n f\"Add onKeyDown handler or use \u003cbutton> instead of \u003c{tag}>.\")\n\ndef check_autofocus_misuse(tag, attrs, fp, ln, snip):\n if \"autofocus\" in attrs or \"autoFocus\" in attrs:\n if tag not in (\"input\", \"textarea\", \"select\"):\n return _find(\"keyboard-autofocus\", \"keyboard\", \"moderate\",\n f\"autofocus on \u003c{tag}> can disorient screen reader users\",\n fp, ln, snip, \"3.2.1 On Focus\",\n \"Avoid autofocus on non-input elements. Use focus management instead.\")\n\n\n# ---------- ARIA ------------------------------------------------------------\n\ndef check_invalid_aria(tag, attrs, fp, ln, snip):\n findings = []\n for key in attrs:\n if key.startswith(\"aria-\") and key.lower() not in VALID_ARIA_ATTRS:\n findings.append(_find(\"aria-invalid-attr\", \"aria\", \"serious\",\n f\"Invalid ARIA attribute: {key}\",\n fp, ln, snip, \"4.1.2 Name, Role, Value\",\n f\"Remove or replace \\\"{key}\\\" with a valid ARIA attribute.\"))\n return findings\n\ndef check_aria_hidden_focusable(tag, attrs, fp, ln, snip):\n if attrs.get(\"aria-hidden\") in (\"true\", True):\n focusable_tags = {\"a\", \"button\", \"input\", \"select\", \"textarea\"}\n if tag in focusable_tags or (isinstance(attrs.get(\"tabindex\", \"\"), str) and\n attrs.get(\"tabindex\", \"-1\") != \"-1\"):\n return _find(\"aria-hidden-focusable\", \"aria\", \"critical\",\n f\"aria-hidden=\\\"true\\\" on focusable \u003c{tag}>\",\n fp, ln, snip, \"4.1.2 Name, Role, Value\",\n \"Remove aria-hidden or make element non-focusable (tabindex=\\\"-1\\\").\")\n\ndef check_aria_live_missing(lines, fp):\n \"\"\"Alert/status roles or live regions without aria-live.\"\"\"\n findings = []\n for ln, line in enumerate(lines, 1):\n if re.search(r'role\\s*=\\s*[\"\\'](?:alert|status)[\"\\']', line, re.I):\n if \"aria-live\" not in line:\n findings.append(_find(\"aria-live-missing\", \"aria\", \"serious\",\n \"role=alert/status without explicit aria-live\",\n fp, ln, _snippet(line),\n \"4.1.3 Status Messages\",\n \"Add aria-live=\\\"assertive\\\" (alert) or aria-live=\\\"polite\\\" (status).\"))\n return findings\n\n\n# ---------- Color/Contrast --------------------------------------------------\n\ndef check_inline_color(tag, attrs, fp, ln, snip):\n style = attrs.get(\"style\", \"\")\n if isinstance(style, str) and re.search(r\"\\bcolor\\s*:\", style, re.I):\n if not re.search(r\"background\", style, re.I):\n return _find(\"color-inline-no-bg\", \"color\", \"moderate\",\n \"Inline color set without background — contrast may be insufficient\",\n fp, ln, snip, \"1.4.3 Contrast (Minimum)\",\n \"Ensure foreground and background colors meet 4.5:1 contrast ratio.\")\n\ndef check_text_over_image(lines, fp):\n \"\"\"Detects patterns where text is positioned over background images without overlay.\"\"\"\n findings = []\n for ln, line in enumerate(lines, 1):\n if re.search(r\"background-image\\s*:\", line, re.I):\n if not re.search(r\"(overlay|rgba|linear-gradient)\", line, re.I):\n findings.append(_find(\"color-text-over-image\", \"color\", \"serious\",\n \"Background image without contrast overlay for text\",\n fp, ln, _snippet(line),\n \"1.4.3 Contrast (Minimum)\",\n \"Add a semi-transparent overlay or ensure text contrast.\"))\n return findings\n\n\n# ---------- Links -----------------------------------------------------------\n\ndef check_empty_link(tag, attrs, fp, ln, snip):\n if tag == \"a\" and not attrs.get(\"aria-label\") and not attrs.get(\"aria-labelledby\"):\n return None # handled by line-level check below\n\ndef check_empty_links_line(lines, fp):\n findings = []\n for ln, line in enumerate(lines, 1):\n # \u003ca ...>\u003c/a> or \u003ca ...> \u003c/a>\n if re.search(r\"\u003ca\\b[^>]*>\\s*\u003c/a>\", line, re.I):\n if \"aria-label\" not in line and \"aria-labelledby\" not in line:\n findings.append(_find(\"link-empty\", \"links\", \"critical\",\n \"Empty link — no text or accessible name\",\n fp, ln, _snippet(line), \"2.4.4 Link Purpose\",\n \"Add link text or aria-label.\"))\n # Bad link text\n if BAD_LINK_TEXT.search(line):\n findings.append(_find(\"link-bad-text\", \"links\", \"serious\",\n \"Link uses vague text like 'click here'\",\n fp, ln, _snippet(line), \"2.4.4 Link Purpose\",\n \"Use descriptive link text that makes sense out of context.\"))\n return findings\n\ndef check_same_page_link(tag, attrs, fp, ln, snip):\n href = attrs.get(\"href\", \"\")\n if tag == \"a\" and isinstance(href, str) and href == \"#\":\n return _find(\"link-empty-fragment\", \"links\", \"moderate\",\n \"Link with href=\\\"#\\\" — use a button or valid fragment\",\n fp, ln, snip, \"2.4.4 Link Purpose\",\n \"Use \u003cbutton> for actions or href=\\\"#section-id\\\" for anchors.\")\n\n\n# ---------- Tables ----------------------------------------------------------\n\ndef check_table_headers(lines, fp):\n findings = []\n in_table = False\n table_start = 0\n has_th = False\n has_caption = False\n has_aria_label = False\n for ln, line in enumerate(lines, 1):\n if re.search(r\"\u003ctable\\b\", line, re.I):\n in_table = True\n table_start = ln\n has_th = False\n has_caption = False\n has_aria_label = \"aria-label\" in line\n if in_table:\n if \"\u003cth\" in line.lower():\n has_th = True\n if \"\u003ccaption\" in line.lower():\n has_caption = True\n if re.search(r\"\u003c/table>\", line, re.I):\n if not has_th:\n findings.append(_find(\"table-no-headers\", \"tables\", \"serious\",\n \"\u003ctable> has no \u003cth> header cells\",\n fp, table_start, _snippet(lines[table_start - 1]),\n \"1.3.1 Info and Relationships\",\n \"Add \u003cth> elements to identify column/row headers.\"))\n if not has_caption and not has_aria_label:\n findings.append(_find(\"table-no-caption\", \"tables\", \"moderate\",\n \"\u003ctable> missing \u003ccaption> or aria-label\",\n fp, table_start, _snippet(lines[table_start - 1]),\n \"1.3.1 Info and Relationships\",\n \"Add \u003ccaption> or aria-label to describe the table.\"))\n in_table = False\n return findings\n\n\n# ---------- Media -----------------------------------------------------------\n\ndef check_media_captions(tag, attrs, fp, ln, snip):\n if tag == \"video\":\n return None # handled at block level\n\ndef check_media_captions_block(lines, fp):\n findings = []\n in_video = False\n video_start = 0\n has_track = False\n has_controls = False\n has_autoplay = False\n for ln, line in enumerate(lines, 1):\n if re.search(r\"\u003cvideo\\b\", line, re.I):\n in_video = True\n video_start = ln\n has_track = False\n has_controls = \"controls\" in line.lower()\n has_autoplay = \"autoplay\" in line.lower()\n if in_video:\n if re.search(r'\u003ctrack\\b[^>]*kind\\s*=\\s*[\"\\']captions[\"\\']', line, re.I):\n has_track = True\n if \"controls\" in line.lower():\n has_controls = True\n if re.search(r\"\u003c/video>\", line, re.I) or (re.search(r\"\u003cvideo\\b\", line, re.I) and \"/>\" in line):\n if not has_track:\n findings.append(_find(\"media-no-captions\", \"media\", \"critical\",\n \"\u003cvideo> missing captions track\",\n fp, video_start, _snippet(lines[video_start - 1]),\n \"1.2.2 Captions (Prerecorded)\",\n \"Add \u003ctrack kind=\\\"captions\\\" src=\\\"...\\\" srclang=\\\"en\\\">.\"))\n if has_autoplay and not has_controls:\n findings.append(_find(\"media-autoplay-no-controls\", \"media\", \"serious\",\n \"\u003cvideo> has autoplay without controls\",\n fp, video_start, _snippet(lines[video_start - 1]),\n \"1.4.2 Audio Control\",\n \"Add the controls attribute so users can pause/stop.\"))\n in_video = False\n # Single-line video tags\n for ln, line in enumerate(lines, 1):\n if re.search(r\"\u003caudio\\b\", line, re.I):\n if \"autoplay\" in line.lower() and \"controls\" not in line.lower():\n findings.append(_find(\"media-audio-autoplay\", \"media\", \"serious\",\n \"\u003caudio> has autoplay without controls\",\n fp, ln, _snippet(line), \"1.4.2 Audio Control\",\n \"Add the controls attribute to \u003caudio>.\"))\n return findings\n\n\n# ---------------------------------------------------------------------------\n# Scanner engine\n# ---------------------------------------------------------------------------\n\nSUPPORTED_EXTENSIONS = {\".html\", \".htm\", \".jsx\", \".tsx\", \".vue\", \".svelte\", \".css\"}\n\nTAG_LEVEL_CHECKS = [\n check_img_missing_alt,\n check_img_empty_alt_informative,\n check_img_decorative_has_alt,\n check_input_missing_label,\n check_input_no_aria_label,\n check_tabindex_positive,\n check_click_no_keyboard,\n check_autofocus_misuse,\n check_aria_hidden_focusable,\n check_inline_color,\n check_same_page_link,\n]\n\nTAG_LEVEL_MULTI_CHECKS = [\n check_invalid_aria,\n]\n\n\ndef scan_file(filepath: str) -> List[Finding]:\n \"\"\"Scan a single file and return all findings.\"\"\"\n findings: List[Finding] = []\n try:\n with open(filepath, \"r\", encoding=\"utf-8\", errors=\"replace\") as f:\n lines = f.readlines()\n except (OSError, IOError):\n return findings\n\n # Tag-level checks\n for ln, line in enumerate(lines, 1):\n for m in TAG_RE.finditer(line):\n tag = m.group(1).lower()\n attr_str = m.group(2)\n attrs = _attrs(attr_str)\n snip = _snippet(line)\n for check in TAG_LEVEL_CHECKS:\n result = check(tag, attrs, filepath, ln, snip)\n if result:\n findings.append(result)\n for check in TAG_LEVEL_MULTI_CHECKS:\n results = check(tag, attrs, filepath, ln, snip)\n if results:\n findings.extend(results)\n\n # File-level / multi-line checks\n findings.extend(check_orphan_label(lines, filepath))\n findings.extend(check_fieldset_legend(lines, filepath))\n findings.extend(check_headings(lines, filepath))\n findings.extend(check_landmarks(lines, filepath))\n findings.extend(check_aria_live_missing(lines, filepath))\n findings.extend(check_text_over_image(lines, filepath))\n findings.extend(check_empty_links_line(lines, filepath))\n findings.extend(check_table_headers(lines, filepath))\n findings.extend(check_media_captions_block(lines, filepath))\n\n return findings\n\n\ndef collect_files(path: str) -> List[str]:\n \"\"\"Recursively collect scannable files under path.\"\"\"\n files = []\n if os.path.isfile(path):\n _, ext = os.path.splitext(path)\n if ext.lower() in SUPPORTED_EXTENSIONS:\n files.append(path)\n return files\n for root, dirs, filenames in os.walk(path):\n # Skip common non-source directories\n dirs[:] = [d for d in dirs if d not in (\n \"node_modules\", \".git\", \"dist\", \"build\", \"__pycache__\",\n \".next\", \".nuxt\", \"vendor\", \"coverage\"\n )]\n for fname in filenames:\n _, ext = os.path.splitext(fname)\n if ext.lower() in SUPPORTED_EXTENSIONS:\n files.append(os.path.join(root, fname))\n files.sort()\n return files\n\n\n# ---------------------------------------------------------------------------\n# Output formatting\n# ---------------------------------------------------------------------------\n\nSEVERITY_ORDER = {\"critical\": 0, \"serious\": 1, \"moderate\": 2, \"minor\": 3}\n\n\ndef format_human(findings: List[Finding], files_scanned: int) -> str:\n \"\"\"Format findings as human-readable text report.\"\"\"\n if not findings:\n return (f\"Scanned {files_scanned} file(s) -- no accessibility issues found.\\n\"\n \"All checks passed.\")\n\n lines = []\n lines.append(f\"WCAG 2.2 Accessibility Scan Results\")\n lines.append(f\"{'=' * 50}\")\n lines.append(f\"Files scanned: {files_scanned}\")\n lines.append(f\"Issues found: {len(findings)}\")\n\n # Summary by severity\n severity_counts = {}\n for f in findings:\n severity_counts[f.severity] = severity_counts.get(f.severity, 0) + 1\n for sev in (\"critical\", \"serious\", \"moderate\", \"minor\"):\n if sev in severity_counts:\n lines.append(f\" {sev.upper():10s}: {severity_counts[sev]}\")\n lines.append(\"\")\n\n # Summary by category\n cat_counts = {}\n for f in findings:\n cat_counts[f.category] = cat_counts.get(f.category, 0) + 1\n lines.append(\"By category:\")\n for cat in sorted(cat_counts, key=lambda c: -cat_counts[c]):\n lines.append(f\" {cat:20s}: {cat_counts[cat]}\")\n lines.append(\"\")\n\n # Detailed findings sorted by severity then file\n sorted_findings = sorted(findings, key=lambda f: (SEVERITY_ORDER.get(f.severity, 9), f.file, f.line))\n for i, f in enumerate(sorted_findings, 1):\n lines.append(f\"[{f.severity.upper()}] {f.rule_id}\")\n lines.append(f\" File: {f.file}:{f.line}\")\n lines.append(f\" WCAG: {f.wcag_criterion}\")\n lines.append(f\" Issue: {f.message}\")\n if f.snippet:\n lines.append(f\" Code: {f.snippet}\")\n lines.append(f\" Fix: {f.fix}\")\n lines.append(\"\")\n\n return \"\\n\".join(lines)\n\n\ndef format_json(findings: List[Finding], files_scanned: int) -> str:\n \"\"\"Format findings as JSON.\"\"\"\n severity_counts = {}\n for f in findings:\n severity_counts[f.severity] = severity_counts.get(f.severity, 0) + 1\n\n report = {\n \"summary\": {\n \"files_scanned\": files_scanned,\n \"total_issues\": len(findings),\n \"by_severity\": severity_counts,\n },\n \"findings\": [asdict(f) for f in findings],\n }\n return json.dumps(report, indent=2)\n\n\n# ---------------------------------------------------------------------------\n# CLI\n# ---------------------------------------------------------------------------\n\ndef build_parser() -> argparse.ArgumentParser:\n parser = argparse.ArgumentParser(\n prog=\"a11y_scanner\",\n description=\"Scan frontend codebases for WCAG 2.2 accessibility violations.\",\n epilog=(\n \"Supported file types: .html, .htm, .jsx, .tsx, .vue, .svelte, .css\\n\"\n \"Exit codes: 0 = pass, 1 = critical/serious found, 2 = moderate/minor only\"\n ),\n formatter_class=argparse.RawDescriptionHelpFormatter,\n )\n parser.add_argument(\n \"path\",\n help=\"File or directory to scan\",\n )\n parser.add_argument(\n \"--json\", dest=\"json_flag\", action=\"store_true\",\n help=\"Output results as JSON (shorthand for --format json)\",\n )\n parser.add_argument(\n \"--format\", dest=\"output_format\", choices=[\"text\", \"json\"],\n default=\"text\",\n help=\"Output format: text (default) or json\",\n )\n parser.add_argument(\n \"--severity\", dest=\"severity\",\n default=None,\n help=\"Comma-separated severity filter (e.g. critical,serious)\",\n )\n return parser\n\n\ndef main():\n parser = build_parser()\n args = parser.parse_args()\n\n path = os.path.abspath(args.path)\n if not os.path.exists(path):\n print(f\"Error: path does not exist: {path}\", file=sys.stderr)\n sys.exit(1)\n\n use_json = args.json_flag or args.output_format == \"json\"\n\n # Collect and scan files\n files = collect_files(path)\n if not files:\n print(f\"No scannable files found in: {path}\", file=sys.stderr)\n sys.exit(0)\n\n all_findings: List[Finding] = []\n for fpath in files:\n all_findings.extend(scan_file(fpath))\n\n # Filter by severity if requested\n if args.severity:\n allowed = {s.strip().lower() for s in args.severity.split(\",\")}\n all_findings = [f for f in all_findings if f.severity in allowed]\n\n # Output\n if use_json:\n print(format_json(all_findings, len(files)))\n else:\n print(format_human(all_findings, len(files)))\n\n # Exit code\n severities = {f.severity for f in all_findings}\n if severities & {\"critical\", \"serious\"}:\n sys.exit(1)\n elif severities & {\"moderate\", \"minor\"}:\n sys.exit(2)\n else:\n sys.exit(0)\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":29650,"content_sha256":"a641c73d02c58b38c51c00bd6c8f89eac0f20cb6f98c1d97cb2c8f9b829f2217"},{"filename":"scripts/contrast_checker.py","content":"#!/usr/bin/env python3\n\"\"\"WCAG 2.2 Color Contrast Checker.\n\nChecks foreground/background color pairs against WCAG 2.2 contrast ratio\nthresholds for normal text, large text, and UI components. Supports hex,\nrgb(), and named CSS colors.\n\nUsage:\n python contrast_checker.py \"#ffffff\" \"#000000\"\n python contrast_checker.py --suggest \"#336699\"\n python contrast_checker.py --batch styles.css\n python contrast_checker.py --demo\n\"\"\"\n\nimport argparse\nimport json\nimport re\nimport sys\n\n# ---------------------------------------------------------------------------\n# Named CSS colors (25 common ones)\n# ---------------------------------------------------------------------------\nNAMED_COLORS = {\n \"black\": (0, 0, 0),\n \"white\": (255, 255, 255),\n \"red\": (255, 0, 0),\n \"green\": (0, 128, 0),\n \"blue\": (0, 0, 255),\n \"yellow\": (255, 255, 0),\n \"cyan\": (0, 255, 255),\n \"magenta\": (255, 0, 255),\n \"gray\": (128, 128, 128),\n \"grey\": (128, 128, 128),\n \"orange\": (255, 165, 0),\n \"purple\": (128, 0, 128),\n \"pink\": (255, 192, 203),\n \"brown\": (165, 42, 42),\n \"navy\": (0, 0, 128),\n \"teal\": (0, 128, 128),\n \"olive\": (128, 128, 0),\n \"maroon\": (128, 0, 0),\n \"lime\": (0, 255, 0),\n \"aqua\": (0, 255, 255),\n \"silver\": (192, 192, 192),\n \"gold\": (255, 215, 0),\n \"coral\": (255, 127, 80),\n \"salmon\": (250, 128, 114),\n \"tomato\": (255, 99, 71),\n}\n\n# WCAG thresholds: (label, required_ratio)\nWCAG_THRESHOLDS = [\n (\"AA Normal Text\", 4.5),\n (\"AA Large Text\", 3.0),\n (\"AA UI Components\", 3.0),\n (\"AAA Normal Text\", 7.0),\n (\"AAA Large Text\", 4.5),\n]\n\n\n# ---------------------------------------------------------------------------\n# Color parsing\n# ---------------------------------------------------------------------------\ndef parse_color(color_str: str) -> tuple:\n \"\"\"Parse a color string into an (R, G, B) tuple.\n\n Accepts:\n - #RRGGBB or #RGB hex\n - rgb(r, g, b) with values 0-255\n - Named CSS colors\n \"\"\"\n s = color_str.strip().lower()\n\n # Named color\n if s in NAMED_COLORS:\n return NAMED_COLORS[s]\n\n # Hex: #RGB or #RRGGBB\n hex_match = re.match(r\"^#([0-9a-f]{3}|[0-9a-f]{6})$\", s)\n if hex_match:\n h = hex_match.group(1)\n if len(h) == 3:\n r, g, b = int(h[0] * 2, 16), int(h[1] * 2, 16), int(h[2] * 2, 16)\n else:\n r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)\n return (r, g, b)\n\n # rgb(r, g, b)\n rgb_match = re.match(r\"^rgb\\(\\s*(\\d{1,3})\\s*,\\s*(\\d{1,3})\\s*,\\s*(\\d{1,3})\\s*\\)$\", s)\n if rgb_match:\n r, g, b = int(rgb_match.group(1)), int(rgb_match.group(2)), int(rgb_match.group(3))\n if not all(0 \u003c= c \u003c= 255 for c in (r, g, b)):\n raise ValueError(f\"RGB values must be 0-255, got rgb({r},{g},{b})\")\n return (r, g, b)\n\n raise ValueError(\n f\"Invalid color format: '{color_str}'. \"\n \"Use #RRGGBB, #RGB, rgb(r,g,b), or a named color.\"\n )\n\n\ndef color_to_hex(rgb: tuple) -> str:\n \"\"\"Convert an (R, G, B) tuple to #RRGGBB.\"\"\"\n return f\"#{rgb[0]:02x}{rgb[1]:02x}{rgb[2]:02x}\"\n\n\n# ---------------------------------------------------------------------------\n# WCAG luminance and contrast\n# ---------------------------------------------------------------------------\ndef relative_luminance(rgb: tuple) -> float:\n \"\"\"Calculate relative luminance per WCAG 2.2 (sRGB).\n\n https://www.w3.org/TR/WCAG22/#dfn-relative-luminance\n \"\"\"\n channels = []\n for c in rgb:\n s = c / 255.0\n channels.append(s / 12.92 if s \u003c= 0.04045 else ((s + 0.055) / 1.055) ** 2.4)\n return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]\n\n\ndef contrast_ratio(rgb1: tuple, rgb2: tuple) -> float:\n \"\"\"Return the WCAG contrast ratio between two colors (>= 1.0).\"\"\"\n l1 = relative_luminance(rgb1)\n l2 = relative_luminance(rgb2)\n lighter = max(l1, l2)\n darker = min(l1, l2)\n return (lighter + 0.05) / (darker + 0.05)\n\n\ndef evaluate_contrast(ratio: float) -> list:\n \"\"\"Return pass/fail results for each WCAG threshold.\"\"\"\n results = []\n for label, threshold in WCAG_THRESHOLDS:\n results.append({\n \"level\": label,\n \"required\": threshold,\n \"ratio\": round(ratio, 2),\n \"pass\": ratio >= threshold,\n })\n return results\n\n\n# ---------------------------------------------------------------------------\n# Suggest accessible backgrounds\n# ---------------------------------------------------------------------------\ndef suggest_backgrounds(fg_rgb: tuple, target_ratio: float = 4.5, count: int = 8) -> list:\n \"\"\"Given a foreground color, suggest background colors passing AA normal text.\n\n Strategy: walk luminance in both directions (lighter / darker) from the\n foreground and collect the first colors that meet the target ratio.\n \"\"\"\n suggestions = []\n\n # Try a spread of grays and tinted variants\n candidates = []\n for v in range(0, 256, 1):\n candidates.append((v, v, v)) # grays\n\n # Also try tinted versions toward the complement\n fr, fg, fb = fg_rgb\n for v in range(0, 256, 2):\n candidates.append((v, min(255, v + 20), min(255, v + 40)))\n candidates.append((min(255, v + 40), v, min(255, v + 20)))\n candidates.append((min(255, v + 20), min(255, v + 40), v))\n\n seen = set()\n scored = []\n for c in candidates:\n cr = contrast_ratio(fg_rgb, c)\n if cr >= target_ratio and c not in seen:\n seen.add(c)\n scored.append((cr, c))\n\n # Sort by ratio closest to target (prefer minimal-change backgrounds)\n scored.sort(key=lambda x: x[0])\n for cr, c in scored[:count]:\n suggestions.append({\"hex\": color_to_hex(c), \"rgb\": list(c), \"ratio\": round(cr, 2)})\n return suggestions\n\n\n# ---------------------------------------------------------------------------\n# Batch CSS parsing\n# ---------------------------------------------------------------------------\n_COLOR_RE = re.compile(\n r\"(#[0-9a-fA-F]{3,6}|rgb\\(\\s*\\d{1,3}\\s*,\\s*\\d{1,3}\\s*,\\s*\\d{1,3}\\s*\\))\"\n)\n\n\ndef extract_css_pairs(css_text: str) -> list:\n \"\"\"Extract color / background-color pairs from CSS declarations.\n\n Returns a list of dicts with selector, foreground, and background strings.\n \"\"\"\n pairs = []\n # Split into rule blocks\n block_re = re.compile(r\"([^{}]+)\\{([^}]+)\\}\", re.DOTALL)\n for m in block_re.finditer(css_text):\n selector = m.group(1).strip()\n body = m.group(2)\n\n fg = bg = None\n # Match color: ... (but not background-color)\n fg_match = re.search(\n r\"(?\u003c![-])color\\s*:\\s*([^;]+);\", body, re.IGNORECASE\n )\n bg_match = re.search(\n r\"background(?:-color)?\\s*:\\s*([^;]+);\", body, re.IGNORECASE\n )\n\n if fg_match:\n val = fg_match.group(1).strip()\n c = _COLOR_RE.search(val)\n if c:\n fg = c.group(1)\n elif val.lower() in NAMED_COLORS:\n fg = val.lower()\n\n if bg_match:\n val = bg_match.group(1).strip()\n c = _COLOR_RE.search(val)\n if c:\n bg = c.group(1)\n elif val.lower() in NAMED_COLORS:\n bg = val.lower()\n\n if fg and bg:\n pairs.append({\"selector\": selector, \"foreground\": fg, \"background\": bg})\n\n return pairs\n\n\n# ---------------------------------------------------------------------------\n# Output formatting\n# ---------------------------------------------------------------------------\ndef format_result_human(fg_str: str, bg_str: str, ratio: float, results: list) -> str:\n \"\"\"Format a contrast check result for the terminal.\"\"\"\n lines = [\n f\"Foreground : {fg_str}\",\n f\"Background : {bg_str}\",\n f\"Contrast : {ratio:.2f}:1\",\n \"\",\n ]\n for r in results:\n status = \"PASS\" if r[\"pass\"] else \"FAIL\"\n lines.append(f\" [{status}] {r['level']:20s} (requires {r['required']}:1)\")\n return \"\\n\".join(lines)\n\n\ndef format_suggestions_human(fg_str: str, suggestions: list) -> str:\n \"\"\"Format suggested backgrounds for the terminal.\"\"\"\n lines = [f\"Foreground: {fg_str}\", \"Suggested accessible backgrounds (AA Normal Text):\"]\n if not suggestions:\n lines.append(\" No suggestions found.\")\n for s in suggestions:\n lines.append(f\" {s['hex']} ratio={s['ratio']}:1\")\n return \"\\n\".join(lines)\n\n\n# ---------------------------------------------------------------------------\n# Demo\n# ---------------------------------------------------------------------------\nDEMO_PAIRS = [\n (\"#ffffff\", \"#000000\"),\n (\"#336699\", \"#ffffff\"),\n (\"#ff6600\", \"#ffffff\"),\n (\"navy\", \"white\"),\n (\"rgb(100,100,100)\", \"#eeeeee\"),\n]\n\n\ndef run_demo(as_json: bool) -> None:\n \"\"\"Run demo checks and print results.\"\"\"\n all_results = []\n for fg_str, bg_str in DEMO_PAIRS:\n fg_rgb = parse_color(fg_str)\n bg_rgb = parse_color(bg_str)\n ratio = contrast_ratio(fg_rgb, bg_rgb)\n results = evaluate_contrast(ratio)\n entry = {\n \"foreground\": fg_str,\n \"background\": bg_str,\n \"foreground_hex\": color_to_hex(fg_rgb),\n \"background_hex\": color_to_hex(bg_rgb),\n \"ratio\": round(ratio, 2),\n \"results\": results,\n }\n all_results.append(entry)\n\n if as_json:\n print(json.dumps({\"demo\": True, \"checks\": all_results}, indent=2))\n else:\n print(\"=\" * 60)\n print(\"WCAG 2.2 Contrast Checker - Demo\")\n print(\"=\" * 60)\n for entry in all_results:\n print()\n print(\n format_result_human(\n entry[\"foreground\"], entry[\"background\"],\n entry[\"ratio\"], entry[\"results\"],\n )\n )\n print()\n print(\"-\" * 60)\n print(\"Suggestion demo for foreground #336699:\")\n suggestions = suggest_backgrounds(parse_color(\"#336699\"))\n print(format_suggestions_human(\"#336699\", suggestions))\n\n\n# ---------------------------------------------------------------------------\n# CLI\n# ---------------------------------------------------------------------------\ndef build_parser() -> argparse.ArgumentParser:\n parser = argparse.ArgumentParser(\n description=\"WCAG 2.2 Color Contrast Checker. \"\n \"Checks foreground/background pairs against AA and AAA thresholds.\",\n epilog=\"Examples:\\n\"\n \" %(prog)s '#ffffff' '#000000'\\n\"\n \" %(prog)s --suggest '#336699'\\n\"\n \" %(prog)s --batch styles.css\\n\"\n \" %(prog)s --demo\\n\",\n formatter_class=argparse.RawDescriptionHelpFormatter,\n )\n parser.add_argument(\n \"foreground\",\n nargs=\"?\",\n help=\"Foreground (text) color: #RRGGBB, #RGB, rgb(r,g,b), or named color\",\n )\n parser.add_argument(\n \"background\",\n nargs=\"?\",\n help=\"Background color: #RRGGBB, #RGB, rgb(r,g,b), or named color\",\n )\n parser.add_argument(\n \"--suggest\",\n metavar=\"COLOR\",\n help=\"Suggest accessible background colors for the given foreground color\",\n )\n parser.add_argument(\n \"--batch\",\n metavar=\"CSS_FILE\",\n help=\"Extract color pairs from a CSS file and check each\",\n )\n parser.add_argument(\n \"--json\",\n action=\"store_true\",\n dest=\"json_output\",\n help=\"Output results as JSON\",\n )\n parser.add_argument(\n \"--demo\",\n action=\"store_true\",\n help=\"Show example output with sample color pairs\",\n )\n parser.add_argument(\n \"--sample\",\n action=\"store_true\",\n help=\"Run with embedded sample color pairs (alias for --demo)\",\n )\n return parser\n\n\ndef main() -> int:\n parser = build_parser()\n args = parser.parse_args()\n\n # --demo / --sample mode\n if args.demo or args.sample:\n run_demo(args.json_output)\n return 0\n\n # --suggest mode\n if args.suggest:\n try:\n fg_rgb = parse_color(args.suggest)\n except ValueError as exc:\n print(f\"Error: {exc}\", file=sys.stderr)\n return 1\n\n suggestions = suggest_backgrounds(fg_rgb)\n if args.json_output:\n print(json.dumps({\n \"foreground\": args.suggest,\n \"foreground_hex\": color_to_hex(fg_rgb),\n \"suggestions\": suggestions,\n }, indent=2))\n else:\n print(format_suggestions_human(args.suggest, suggestions))\n return 0\n\n # --batch mode\n if args.batch:\n try:\n with open(args.batch, \"r\", encoding=\"utf-8\") as fh:\n css_text = fh.read()\n except FileNotFoundError:\n print(f\"Error: file not found: {args.batch}\", file=sys.stderr)\n return 1\n except OSError as exc:\n print(f\"Error reading file: {exc}\", file=sys.stderr)\n return 1\n\n pairs = extract_css_pairs(css_text)\n if not pairs:\n msg = \"No color/background-color pairs found in the CSS file.\"\n if args.json_output:\n print(json.dumps({\"batch\": args.batch, \"pairs\": [], \"message\": msg}, indent=2))\n else:\n print(msg)\n return 0\n\n all_results = []\n has_failure = False\n for pair in pairs:\n try:\n fg_rgb = parse_color(pair[\"foreground\"])\n bg_rgb = parse_color(pair[\"background\"])\n except ValueError as exc:\n entry = {\n \"selector\": pair[\"selector\"],\n \"foreground\": pair[\"foreground\"],\n \"background\": pair[\"background\"],\n \"error\": str(exc),\n }\n all_results.append(entry)\n continue\n\n ratio = contrast_ratio(fg_rgb, bg_rgb)\n results = evaluate_contrast(ratio)\n if not results[0][\"pass\"]: # AA Normal Text\n has_failure = True\n entry = {\n \"selector\": pair[\"selector\"],\n \"foreground\": pair[\"foreground\"],\n \"background\": pair[\"background\"],\n \"foreground_hex\": color_to_hex(fg_rgb),\n \"background_hex\": color_to_hex(bg_rgb),\n \"ratio\": round(ratio, 2),\n \"results\": results,\n }\n all_results.append(entry)\n\n if args.json_output:\n print(json.dumps({\"batch\": args.batch, \"pairs\": all_results}, indent=2))\n else:\n print(f\"Batch check: {args.batch}\")\n print(\"=\" * 60)\n for entry in all_results:\n print(f\"\\nSelector: {entry['selector']}\")\n if \"error\" in entry:\n print(f\" Error: {entry['error']}\")\n else:\n print(\n format_result_human(\n entry[\"foreground\"], entry[\"background\"],\n entry[\"ratio\"], entry[\"results\"],\n )\n )\n print()\n summary_pass = sum(1 for e in all_results if \"ratio\" in e and e[\"results\"][0][\"pass\"])\n summary_total = sum(1 for e in all_results if \"ratio\" in e)\n print(f\"Summary: {summary_pass}/{summary_total} pairs pass AA Normal Text\")\n\n return 1 if has_failure else 0\n\n # Default: check a single pair\n if not args.foreground or not args.background:\n parser.error(\n \"Provide foreground and background colors, or use --suggest, --batch, or --demo.\"\n )\n\n try:\n fg_rgb = parse_color(args.foreground)\n except ValueError as exc:\n print(f\"Error (foreground): {exc}\", file=sys.stderr)\n return 1\n\n try:\n bg_rgb = parse_color(args.background)\n except ValueError as exc:\n print(f\"Error (background): {exc}\", file=sys.stderr)\n return 1\n\n ratio = contrast_ratio(fg_rgb, bg_rgb)\n results = evaluate_contrast(ratio)\n\n if args.json_output:\n print(json.dumps({\n \"foreground\": args.foreground,\n \"background\": args.background,\n \"foreground_hex\": color_to_hex(fg_rgb),\n \"background_hex\": color_to_hex(bg_rgb),\n \"ratio\": round(ratio, 2),\n \"results\": results,\n }, indent=2))\n else:\n print(format_result_human(args.foreground, args.background, ratio, results))\n\n return 0 if results[0][\"pass\"] else 1\n\n\nif __name__ == \"__main__\":\n sys.exit(main())\n","content_type":"text/x-python; charset=utf-8","language":"python","size":16581,"content_sha256":"7297629494559a6e07c7e6067b30f639df4290d9595faf5e261ecbf37d1a9030"}],"content_json":{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"text":"Accessibility Audit","type":"text"}]},{"type":"paragraph","content":[{"text":"WCAG 2.2 Accessibility Audit and Remediation Skill","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Description","type":"text"}]},{"type":"paragraph","content":[{"text":"The a11y-audit skill provides a complete accessibility audit pipeline for modern web applications. It implements a three-phase workflow -- Scan, Fix, Verify -- that identifies WCAG 2.2 Level A and AA violations, generates exact fix code per framework, and produces stakeholder-ready compliance reports.","type":"text"}]},{"type":"paragraph","content":[{"text":"For every violation it finds, it provides the precise before/after code fix tailored to your framework (React, Next.js, Vue, Angular, Svelte, or plain HTML).","type":"text"}]},{"type":"paragraph","content":[{"text":"What this skill does:","type":"text","marks":[{"type":"strong"}]}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Scans","type":"text","marks":[{"type":"strong"}]},{"text":" your codebase for every WCAG 2.2 Level A and AA violation, categorized by severity (Critical, Major, Minor)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Fixes","type":"text","marks":[{"type":"strong"}]},{"text":" each violation with framework-specific before/after code patterns","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Verifies","type":"text","marks":[{"type":"strong"}]},{"text":" that fixes resolve the original violations and introduces no regressions","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Reports","type":"text","marks":[{"type":"strong"}]},{"text":" findings in a structured format suitable for developers, PMs, and compliance stakeholders","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Integrates","type":"text","marks":[{"type":"strong"}]},{"text":" into CI/CD pipelines to prevent accessibility regressions","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Features","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Feature","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Description","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Full WCAG 2.2 Scan","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Checks all Level A and AA success criteria across your codebase","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Framework Detection","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Auto-detects React, Next.js, Vue, Angular, Svelte, or plain HTML","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Severity Classification","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Categorizes each violation as Critical, Major, or Minor","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Fix Code Generation","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Produces before/after code diffs for every issue","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Color Contrast Checker","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Validates foreground/background pairs against AA and AAA ratios","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Compliance Reporting","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Generates stakeholder reports with pass/fail summaries","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"CI/CD Integration","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"GitHub Actions, GitLab CI, Azure DevOps pipeline configs","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Keyboard Navigation Audit","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Detects missing focus management and tab order issues","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"ARIA Validation","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Checks for incorrect, redundant, or missing ARIA attributes","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Severity Definitions","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Severity","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Definition","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Example","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"SLA","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Critical","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Blocks access for entire user groups","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Missing alt text, no keyboard access to navigation","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Fix before release","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Major","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Significant barrier that degrades experience","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Insufficient color contrast, missing form labels","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Fix within current sprint","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Minor","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Usability issue that causes friction","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Redundant ARIA roles, suboptimal heading hierarchy","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Fix within next 2 sprints","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Usage","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Quick Start","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# Scan entire project\npython scripts/a11y_scanner.py /path/to/project\n\n# Scan with JSON output for tooling\npython scripts/a11y_scanner.py /path/to/project --json\n\n# Check color contrast for specific values\npython scripts/contrast_checker.py --fg \"#777777\" --bg \"#ffffff\"\n\n# Check contrast across a CSS/Tailwind file\npython scripts/contrast_checker.py --file /path/to/styles.css","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Slash Command","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"/a11y-audit # Audit current project\n/a11y-audit --scope src/ # Audit specific directory\n/a11y-audit --fix # Audit and auto-apply fixes\n/a11y-audit --report # Generate stakeholder report\n/a11y-audit --ci # Output CI-compatible results","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Three-Phase Workflow","type":"text"}]},{"type":"paragraph","content":[{"text":"Phase 1: Scan","type":"text","marks":[{"type":"strong"}]},{"text":" -- Walk the source tree, detect framework, apply rule set.","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"python scripts/a11y_scanner.py /path/to/project --format table","type":"text"}]},{"type":"paragraph","content":[{"text":"Phase 2: Fix","type":"text","marks":[{"type":"strong"}]},{"text":" -- Apply framework-specific fixes for each violation.","type":"text"}]},{"type":"blockquote","content":[{"type":"paragraph","content":[{"text":"See ","type":"text"},{"text":"references/framework-a11y-patterns.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/framework-a11y-patterns.md","title":null}}]},{"text":" for the complete fix patterns catalog.","type":"text"}]}]},{"type":"paragraph","content":[{"text":"Phase 3: Verify","type":"text","marks":[{"type":"strong"}]},{"text":" -- Re-run the scanner to confirm fixes and check for regressions.","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"python scripts/a11y_scanner.py /path/to/project --baseline audit-baseline.json","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Example: React Component Audit","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"tsx"},"content":[{"text":"// BEFORE: src/components/ProductCard.tsx\nfunction ProductCard({ product }) {\n return (\n \u003cdiv onClick={() => navigate(`/product/${product.id}`)}>\n \u003cimg src={product.image} />\n \u003cdiv style={{ color: '#aaa', fontSize: '12px' }}>{product.name}\u003c/div>\n \u003cspan style={{ color: '#999' }}>${product.price}\u003c/span>\n \u003c/div>\n );\n}","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"#","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"WCAG","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Severity","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Issue","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"1","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"1.1.1","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Critical","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"\u003cimg>","type":"text","marks":[{"type":"code_inline"}]},{"text":" missing ","type":"text"},{"text":"alt","type":"text","marks":[{"type":"code_inline"}]},{"text":" attribute","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"2","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"2.1.1","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Critical","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"\u003cdiv onClick>","type":"text","marks":[{"type":"code_inline"}]},{"text":" not keyboard accessible","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"3","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"1.4.3","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Major","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Color ","type":"text"},{"text":"#aaa","type":"text","marks":[{"type":"code_inline"}]},{"text":" on white fails contrast (2.32:1, needs 4.5:1)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"4","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"1.4.3","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Major","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Color ","type":"text"},{"text":"#999","type":"text","marks":[{"type":"code_inline"}]},{"text":" on white fails contrast (2.85:1, needs 4.5:1)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"5","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"4.1.2","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Major","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Interactive element missing role and accessible name","type":"text"}]}]}]}]},{"type":"code_block","attrs":{"wrap":false,"language":"tsx"},"content":[{"text":"// AFTER: src/components/ProductCard.tsx\nfunction ProductCard({ product }) {\n return (\n \u003ca href={`/product/${product.id}`} className=\"product-card\"\n aria-label={`View ${product.name} - ${product.price}`}>\n \u003cimg src={product.image} alt={product.imageAlt || product.name} />\n \u003cdiv style={{ color: '#595959', fontSize: '12px' }}>{product.name}\u003c/div>\n \u003cspan style={{ color: '#767676' }}>${product.price}\u003c/span>\n \u003c/a>\n );\n}","type":"text"}]},{"type":"blockquote","content":[{"type":"paragraph","content":[{"text":"See ","type":"text"},{"text":"references/examples-by-framework.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/examples-by-framework.md","title":null}}]},{"text":" for Vue, Angular, Next.js, and Svelte examples.","type":"text"}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Tools Reference","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"a11y_scanner.py","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"Usage: python scripts/a11y_scanner.py \u003cpath> [options]\n\nOptions:\n --json Output results as JSON\n --format {table,csv} Output format (default: table)\n --severity {critical,major,minor} Filter by minimum severity\n --framework {react,vue,angular,svelte,html,auto} Force framework (default: auto)\n --baseline FILE Compare against previous scan results\n --report Generate stakeholder report\n --output FILE Write results to file\n --quiet Suppress output, exit code only\n --ci CI mode: non-zero exit on critical issues","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"contrast_checker.py","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"Usage: python scripts/contrast_checker.py [options]\n\nOptions:\n --fg COLOR Foreground color (hex)\n --bg COLOR Background color (hex)\n --file FILE Scan CSS file for color pairs\n --tailwind DIR Scan directory for Tailwind color classes\n --json Output results as JSON\n --suggest Suggest accessible alternatives for failures\n --level {aa,aaa} Target conformance level (default: aa)","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Common Pitfalls","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Pitfall","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Correct Approach","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"role=\"button\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" on a ","type":"text"},{"text":"\u003cdiv>","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Use native ","type":"text"},{"text":"\u003cbutton>","type":"text","marks":[{"type":"code_inline"}]},{"text":" -- includes keyboard handling for free","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"tabindex=\"0\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" on everything","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Only interactive elements need focus; use native elements","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"aria-label","type":"text","marks":[{"type":"code_inline"}]},{"text":" on non-interactive elements","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Use ","type":"text"},{"text":"aria-labelledby","type":"text","marks":[{"type":"code_inline"}]},{"text":" pointing to visible text","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"display: none","type":"text","marks":[{"type":"code_inline"}]},{"text":" for screen reader hiding","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Use ","type":"text"},{"text":".sr-only","type":"text","marks":[{"type":"code_inline"}]},{"text":" class instead","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Color alone to convey meaning","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Add icons, text labels, or patterns alongside color","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Placeholder as only label","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Always provide a visible ","type":"text"},{"text":"\u003clabel>","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"outline: none","type":"text","marks":[{"type":"code_inline"}]},{"text":" without replacement","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Always provide a visible focus indicator via ","type":"text"},{"text":"focus-visible","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Empty ","type":"text"},{"text":"alt=\"\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" on informational images","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Informational images need descriptive alt text","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Skipping heading levels (h1 -> h3)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Heading levels must be sequential","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"onClick","type":"text","marks":[{"type":"code_inline"}]},{"text":" without ","type":"text"},{"text":"onKeyDown","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Add keyboard support or prefer native elements","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Ignoring ","type":"text"},{"text":"prefers-reduced-motion","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Wrap animations in ","type":"text"},{"text":"@media (prefers-reduced-motion: no-preference)","type":"text","marks":[{"type":"code_inline"}]}]}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Related Skills","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Skill","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Relationship","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"senior-frontend","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Frontend patterns used in a11y fixes","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"code-reviewer","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Include a11y checks in code review workflows","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"senior-qa","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Integration of a11y testing into QA processes","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"playwright-pro","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Automated browser testing with accessibility assertions","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"epic-design","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"WCAG 2.1 AA compliant animations and scroll storytelling","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"tdd-guide","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Test-driven development patterns for a11y test cases","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Reference Documentation","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Reference","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Description","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"wcag-quick-ref.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/wcag-quick-ref.md","title":null}}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"WCAG 2.2 Level A & AA criteria quick reference","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"wcag-22-new-criteria.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/wcag-22-new-criteria.md","title":null}}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"New WCAG 2.2 success criteria (Focus Appearance, Target Size, etc.)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"aria-patterns.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/aria-patterns.md","title":null}}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"ARIA patterns, keyboard interaction, and live regions","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"framework-a11y-patterns.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/framework-a11y-patterns.md","title":null}}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Framework-specific fix patterns (React, Vue, Angular, Svelte, HTML)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"color-contrast-guide.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/color-contrast-guide.md","title":null}}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Color contrast checker details, Tailwind palette mapping, sr-only class","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"ci-cd-integration.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/ci-cd-integration.md","title":null}}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"GitHub Actions, GitLab CI, Azure DevOps, pre-commit hook configs","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"audit-report-template.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/audit-report-template.md","title":null}}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Stakeholder-ready audit report template","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"testing-checklist.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/testing-checklist.md","title":null}}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Manual testing checklist (keyboard, screen reader, visual, forms)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"examples-by-framework.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/examples-by-framework.md","title":null}}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Full audit examples for Vue, Angular, Next.js, and Svelte","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Resources","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"WCAG 2.2 Specification","type":"text","marks":[{"type":"link","attrs":{"href":"https://www.w3.org/TR/WCAG22/","title":null}}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"WAI-ARIA Authoring Practices 1.2","type":"text","marks":[{"type":"link","attrs":{"href":"https://www.w3.org/WAI/ARIA/apg/","title":null}}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Deque axe-core Rules","type":"text","marks":[{"type":"link","attrs":{"href":"https://github.com/dequelabs/axe-core/blob/develop/doc/rule-descriptions.md","title":null}}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"eslint-plugin-jsx-a11y","type":"text","marks":[{"type":"link","attrs":{"href":"https://github.com/jsx-eslint/eslint-plugin-jsx-a11y","title":null}}]}]}]}]}]},"metadata":{"date":"2026-11-02","name":"a11y-audit","author":"@skillopedia","source":{"stars":29164,"repo_name":"claude-skills","origin_url":"https://github.com/alirezarezvani/claude-skills/blob/HEAD/engineering-team/a11y-audit/skills/a11y-audit/SKILL.md","repo_owner":"alirezarezvani","body_sha256":"2bb916d7d7273810dd58958155e91c36e2288dc774c598695015d7f8b623c457","cluster_key":"8af533f03cc47aa4a39a9f1710485a8ff1e1ed19e83a6c98a38a7bee4797418d","clean_bundle":{"format":"clean-skill-bundle-v1","source":"alirezarezvani/claude-skills/engineering-team/a11y-audit/skills/a11y-audit/SKILL.md","text_bytes":97693,"source_commit":"9ef0c3c213932e645cc5cbeeb3c35c3a762a2437","skills_sh_rank":13210,"attachment_count":15,"text_attachments":15,"attachment_storage":"skill_attachments","binary_attachments":0},"cluster_size":1,"skill_md_path":"engineering-team/a11y-audit/skills/a11y-audit/SKILL.md","import_metadata":{"date":"2026-11-02","author":"@skillopedia","version":"v1","category":"testing-qa","category_label":"Testing"},"exact_dupes_collapsed_into_this":0},"version":"v1","category":"testing-qa","import_tag":"skills-sh-top20k-2026-11","description":"Use for any accessibility audit or a11y fix (WCAG 2.2 A/AA, color contrast, compliance report, CI check) in React, Next.js, Vue, Angular, Svelte or HTML. Scans with a11y_scanner.py and checks contrast with contrast_checker.py, then fixes and re-verifies."}},"renderedAt":1793666589182}

Accessibility Audit WCAG 2.2 Accessibility Audit and Remediation Skill Description The a11y-audit skill provides a complete accessibility audit pipeline for modern web applications. It implements a three-phase workflow -- Scan, Fix, Verify -- that identifies WCAG 2.2 Level A and AA violations, generates exact fix code per framework, and produces stakeholder-ready compliance reports. For every violation it finds, it provides the precise before/after code fix tailored to your framework (React, Next.js, Vue, Angular, Svelte, or plain HTML). What this skill does: 1. Scans your codebase for every…