feat(engineering-team): add a11y-audit skill — WCAG 2.2 accessibility audit & fix (#376)

Built from scratch (replaces reverted PR #375 contribution).

Skill package:
- SKILL.md: 1132 lines, 3-phase workflow (scan → fix → verify),
  per-framework fix patterns (React, Next.js, Vue, Angular, Svelte, HTML),
  CI/CD integration guide, 20+ issue type coverage
- scripts/a11y_scanner.py: static scanner detecting 20+ violation types
  across HTML/JSX/TSX/Vue/Svelte/CSS — severity-ranked, CI-friendly exit codes
- scripts/contrast_checker.py: WCAG contrast calculator with AA/AAA checks,
  --suggest mode, --batch CSS scanning, named color support
- references/wcag-quick-ref.md: WCAG 2.2 Level A/AA criteria table
- references/aria-patterns.md: ARIA roles, live regions, keyboard interaction
- references/framework-a11y-patterns.md: React, Vue, Angular, Svelte fix patterns
- assets/sample-component.tsx: sample file with intentional violations
- expected_outputs/: scan report, contrast output, JSON output samples
- /a11y-audit slash command, settings.json, plugin.json, README.md

Validation: 97.6/100 (EXCELLENT), quality 73.9/100 (B-), scripts 2/2 PASS

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alireza Rezvani
2026-03-18 08:42:20 +01:00
committed by GitHub
parent 85eb7ded94
commit 920dc12a74
14 changed files with 3558 additions and 0 deletions

84
commands/a11y-audit.md Normal file
View File

@@ -0,0 +1,84 @@
---
name: a11y-audit
description: Scan a frontend project for WCAG 2.2 accessibility violations and fix them. Usage: /a11y-audit [path]
---
# /a11y-audit
Scan a frontend project for WCAG 2.2 accessibility issues, show fixes, and optionally check color contrast.
## Usage
```bash
/a11y-audit # Scan current project
/a11y-audit ./src # Scan specific directory
/a11y-audit ./src --fix # Scan and auto-fix what's possible
```
## What It Does
### Step 1: Scan
Run the a11y scanner on the target directory:
```bash
python3 {skill_path}/scripts/a11y_scanner.py {path} --json
```
Parse the JSON output. Group findings by severity (critical → serious → moderate → minor).
Display a summary:
```
A11y Audit: ./src
Critical: 3 | Serious: 7 | Moderate: 12 | Minor: 5
Files scanned: 42 | Files with issues: 15
```
### Step 2: Fix
For each finding (starting with critical):
1. Read the affected file
2. Show the violation with context (before)
3. Apply the fix from `references/framework-a11y-patterns.md`
4. Show the result (after)
**Auto-fixable issues** (apply without asking):
- Missing `alt=""` on decorative images
- Missing `lang` attribute on `<html>`
- `tabindex` values > 0 → set to 0
- Missing `type="button"` on non-submit buttons
- Outline removal without replacement → add `:focus-visible` styles
**Issues requiring user input** (show fix, ask to apply):
- Missing alt text (need description from user)
- Missing form labels (need label text)
- Heading restructuring (may affect layout)
- ARIA role changes (may affect functionality)
### Step 3: Contrast Check
If CSS files are present, run the contrast checker:
```bash
python3 {skill_path}/scripts/contrast_checker.py --batch {path}
```
For each failing color pair, suggest accessible alternatives.
### Step 4: Report
Generate a markdown report at `a11y-report.md`:
- Executive summary (pass/fail, issue counts)
- Per-file findings with before/after diffs
- Remaining manual review items
- WCAG criteria coverage
## Skill Reference
- `engineering-team/a11y-audit/SKILL.md`
- `engineering-team/a11y-audit/scripts/a11y_scanner.py`
- `engineering-team/a11y-audit/scripts/contrast_checker.py`
- `engineering-team/a11y-audit/references/wcag-quick-ref.md`
- `engineering-team/a11y-audit/references/aria-patterns.md`
- `engineering-team/a11y-audit/references/framework-a11y-patterns.md`

View File

@@ -0,0 +1,10 @@
{
"name": "a11y-audit",
"description": "WCAG 2.2 accessibility audit and fix skill for React, Next.js, Vue, Angular, Svelte, and HTML. Static scanner detecting 20+ violation types, contrast checker with suggest mode, framework-specific fix patterns, CI-friendly exit codes.",
"version": "2.1.2",
"author": "Alireza Rezvani",
"homepage": "https://github.com/alirezarezvani/claude-skills/tree/main/engineering-team/a11y-audit",
"repository": "https://github.com/alirezarezvani/claude-skills",
"license": "MIT",
"skills": "./"
}

View File

@@ -0,0 +1,46 @@
# A11y Audit — WCAG 2.2 Accessibility Audit & Fix
Audit and fix WCAG 2.2 accessibility issues in any frontend project. Covers React, Next.js, Vue, Angular, Svelte, and plain HTML.
## Quick Start
```bash
# Scan a project
/a11y-audit ./src
# Or use the scripts directly
python3 scripts/a11y_scanner.py ./src
python3 scripts/contrast_checker.py "#1a1a2e" "#ffffff"
```
## Scripts
| Script | Purpose |
|--------|---------|
| `a11y_scanner.py` | Scan HTML/JSX/TSX/Vue/Svelte/CSS for 20+ a11y violations |
| `contrast_checker.py` | WCAG contrast ratio calculator with AA/AAA checks and `--suggest` mode |
Both are stdlib-only — no pip install needed. CI-friendly exit codes (0 = pass, 1 = blocking issues).
## What It Covers
- **Images**: missing alt, empty alt on informative images
- **Forms**: missing labels, orphan labels, missing fieldset/legend
- **Headings**: skipped levels, missing h1, multiple h1s
- **Landmarks**: missing main/nav/skip link
- **Keyboard**: tabindex > 0, click without keyboard handler
- **ARIA**: invalid attributes, aria-hidden on focusable, missing aria-live
- **Color**: contrast ratios below AA thresholds
- **Links**: empty links, "click here" text
- **Tables**: missing headers, missing caption
- **Media**: missing captions, autoplay without controls
## References
- `references/wcag-quick-ref.md` — WCAG 2.2 Level A/AA criteria table
- `references/aria-patterns.md` — ARIA roles, live regions, keyboard patterns
- `references/framework-a11y-patterns.md` — React, Vue, Angular, Svelte fix patterns
## License
MIT

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,51 @@
// Sample React component with intentional a11y issues for testing
import React from 'react';
export function UserCard({ user, onEdit, onDelete }) {
return (
<div className="card" onClick={() => onEdit(user.id)}>
<img src={user.avatar} />
<div className="name">{user.name}</div>
<div className="email">{user.email}</div>
<div className="actions">
<div onClick={() => onDelete(user.id)} style={{ color: '#aaa', cursor: 'pointer' }}>
Delete
</div>
<a href="#">Edit</a>
</div>
<input placeholder="Add note" />
</div>
);
}
export function SearchBar() {
return (
<div>
<input type="text" placeholder="Search..." />
<div onClick={() => alert('searching')} tabIndex={5}>
🔍
</div>
</div>
);
}
export function DataTable({ rows }) {
return (
<table>
<tr>
<td><b>Name</b></td>
<td><b>Email</b></td>
<td><b>Status</b></td>
</tr>
{rows.map((row) => (
<tr key={row.id}>
<td>{row.name}</td>
<td>{row.email}</td>
<td style={{ color: row.active ? 'green' : 'red' }}>
{row.active ? '●' : '●'}
</td>
</tr>
))}
</table>
);
}

View File

@@ -0,0 +1,39 @@
Contrast Check: #777777 on #ffffff
Foreground: #777777 (r=119, g=119, b=119)
Background: #ffffff (r=255, g=255, b=255)
Contrast Ratio: 4.48:1
Normal text (4.5:1 required):
AA: FAIL (4.48 < 4.5)
AAA: FAIL (4.48 < 7.0)
Large text (3.0:1 required):
AA: PASS (4.48 >= 3.0)
AAA: FAIL (4.48 < 4.5)
UI components (3.0:1 required):
AA: PASS (4.48 >= 3.0)
Verdict: FAIL — does not meet AA for normal text
---
Contrast Check: #1a1a2e on #ffffff
Foreground: #1a1a2e (r=26, g=26, b=46)
Background: #ffffff (r=255, g=255, b=255)
Contrast Ratio: 17.06:1
Normal text (4.5:1 required):
AA: PASS
AAA: PASS
Large text (3.0:1 required):
AA: PASS
AAA: PASS
UI components (3.0:1 required):
AA: PASS
Verdict: PASS — meets AAA for all categories

View File

@@ -0,0 +1,34 @@
{
"summary": {
"files_scanned": 1,
"files_with_issues": 1,
"total_issues": 9,
"critical": 3,
"serious": 4,
"moderate": 2,
"minor": 0,
"verdict": "FAIL"
},
"findings": [
{
"severity": "critical",
"category": "IMG-ALT",
"file": "sample-component.tsx",
"line": 7,
"code": "<img src={user.avatar} />",
"wcag": "1.1.1",
"message": "Image missing alt attribute",
"fix": "Add alt text: alt=\"description of image\""
},
{
"severity": "critical",
"category": "KB-CLICK",
"file": "sample-component.tsx",
"line": 5,
"code": "<div className=\"card\" onClick={() => onEdit(user.id)}>",
"wcag": "2.1.1",
"message": "Click handler on non-interactive element without keyboard support",
"fix": "Use <button> or add role=\"button\", tabIndex={0}, onKeyDown"
}
]
}

View File

@@ -0,0 +1,63 @@
# A11y Audit Report — sample-component.tsx
**Scanned:** 1 file | **Issues:** 9 | **Status:** FAIL
## Critical (3)
### 1. Missing alt text on image
- **File:** sample-component.tsx:7
- **Code:** `<img src={user.avatar} />`
- **WCAG:** 1.1.1 Non-text Content (Level A)
- **Fix:** Add descriptive alt text: `<img src={user.avatar} alt={`${user.name}'s avatar`} />`
### 2. Click handler without keyboard support
- **File:** sample-component.tsx:5
- **Code:** `<div className="card" onClick={() => onEdit(user.id)}>`
- **WCAG:** 2.1.1 Keyboard (Level A)
- **Fix:** Use `<button>` or add `role="button"`, `tabIndex={0}`, and `onKeyDown`
### 3. Click handler without keyboard support
- **File:** sample-component.tsx:11
- **Code:** `<div onClick={() => onDelete(user.id)} ...>`
- **WCAG:** 2.1.1 Keyboard (Level A)
- **Fix:** Replace `<div>` with `<button>`
## Serious (4)
### 4. Missing form label
- **File:** sample-component.tsx:15
- **Code:** `<input placeholder="Add note" />`
- **WCAG:** 3.3.2 Labels or Instructions (Level A)
- **Fix:** Add `<label>` or `aria-label="Add note"`
### 5. Empty link
- **File:** sample-component.tsx:14
- **Code:** `<a href="#">Edit</a>`
- **WCAG:** 2.4.4 Link Purpose (Level A)
- **Fix:** Use a real href or replace with `<button>`
### 6. tabindex greater than 0
- **File:** sample-component.tsx:24
- **Code:** `tabIndex={5}`
- **WCAG:** 2.4.3 Focus Order (Level A)
- **Fix:** Use `tabIndex={0}` — positive values disrupt natural tab order
### 7. Missing table headers
- **File:** sample-component.tsx:30
- **Code:** `<td><b>Name</b></td>` (using td+b instead of th)
- **WCAG:** 1.3.1 Info and Relationships (Level A)
- **Fix:** Use `<th scope="col">Name</th>`
## Moderate (2)
### 8. Missing form label
- **File:** sample-component.tsx:22
- **Code:** `<input type="text" placeholder="Search..." />`
- **WCAG:** 3.3.2 Labels or Instructions (Level A)
- **Fix:** Add `aria-label="Search"` or visible label
### 9. Color as sole indicator
- **File:** sample-component.tsx:38
- **Code:** `style={{ color: row.active ? 'green' : 'red' }}`
- **WCAG:** 1.4.1 Use of Color (Level A)
- **Fix:** Add text or icon alongside color: `{row.active ? '✓ Active' : '✗ Inactive'}`

View File

@@ -0,0 +1,223 @@
# ARIA Patterns & Keyboard Interaction Reference
## Landmark Roles
Every page should have these landmarks:
```html
<header role="banner"> <!-- Site header — once per page -->
<nav role="navigation"> <!-- Navigation — can have multiple with aria-label -->
<main role="main"> <!-- Main content — once per page -->
<aside role="complementary"> <!-- Sidebar — related but not essential -->
<footer role="contentinfo"> <!-- Site footer — once per page -->
<form role="search"> <!-- Search form -->
```
**Semantic HTML equivalents:** `<header>`, `<nav>`, `<main>`, `<aside>`, `<footer>` provide implicit roles — no need to double up with explicit `role` attributes.
## Live Regions
### When to Use
| Pattern | Attribute | Use Case |
|---------|-----------|----------|
| Polite | `aria-live="polite"` | Toast notifications, status updates, search result counts |
| Assertive | `aria-live="assertive"` | Error messages, urgent alerts, form validation errors |
| Status | `role="status"` | Loading indicators, progress updates |
| Alert | `role="alert"` | Error dialogs, time-sensitive warnings |
| Log | `role="log"` | Chat messages, activity feeds |
| Timer | `role="timer"` | Countdown timers |
### Implementation
```html
<!-- Toast notifications -->
<div aria-live="polite" aria-atomic="true">
<!-- Inject toast content here dynamically -->
</div>
<!-- Form validation errors -->
<div aria-live="assertive" role="alert">
<p>Please enter a valid email address.</p>
</div>
<!-- Loading state -->
<div role="status" aria-live="polite">
Loading results...
</div>
```
**Key rule:** The live region container must exist in the DOM *before* content is injected. Adding `aria-live` to a newly created element won't announce it.
## Focus Management
### Focus Trap (Modals)
```javascript
// Trap focus inside modal
const modal = document.querySelector('[role="dialog"]');
const focusable = modal.querySelectorAll(
'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
modal.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
if (e.key === 'Escape') closeModal();
});
```
### Focus Restoration
```javascript
// Save focus before opening modal
const trigger = document.activeElement;
openModal();
// Restore focus on close
function closeModal() {
modal.hidden = true;
trigger.focus();
}
```
### Skip Link
```html
<a href="#main-content" class="skip-link">Skip to main content</a>
<!-- ... navigation ... -->
<main id="main-content" tabindex="-1">
```
```css
.skip-link {
position: absolute;
left: -9999px;
z-index: 999;
}
.skip-link:focus {
left: 10px;
top: 10px;
background: #000;
color: #fff;
padding: 8px 16px;
}
```
## Keyboard Interaction Patterns
### Tabs
```
Tab → Move to tab list, then to tab panel
Arrow Left/Right → Switch between tabs
Home → First tab
End → Last tab
```
```html
<div role="tablist" aria-label="Settings">
<button role="tab" aria-selected="true" aria-controls="panel-1" id="tab-1">General</button>
<button role="tab" aria-selected="false" aria-controls="panel-2" id="tab-2" tabindex="-1">Security</button>
</div>
<div role="tabpanel" id="panel-1" aria-labelledby="tab-1">...</div>
<div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden>...</div>
```
### Combobox / Autocomplete
```
Arrow Down → Open list / next option
Arrow Up → Previous option
Enter → Select option
Escape → Close list
Type → Filter options
```
### Menu
```
Enter/Space → Activate item
Arrow Down → Next item
Arrow Up → Previous item
Arrow Right → Open submenu
Arrow Left → Close submenu
Escape → Close menu
```
### Accordion
```
Enter/Space → Toggle section
Arrow Down → Next header
Arrow Up → Previous header
Home → First header
End → Last header
```
## Framework-Specific ARIA
### React
```jsx
// Announce route changes (SPA)
<div aria-live="polite" className="sr-only">
{`Navigated to ${pageTitle}`}
</div>
// Error boundary with accessible error
<div role="alert">
<h2>Something went wrong</h2>
<p>{error.message}</p>
</div>
```
### Vue
```vue
<!-- Announce dynamic content -->
<div aria-live="polite">
<p v-if="results.length">{{ results.length }} results found</p>
</div>
<!-- Accessible toggle -->
<button
:aria-expanded="isOpen"
:aria-controls="panelId"
@click="toggle"
>
{{ isOpen ? 'Collapse' : 'Expand' }}
</button>
```
### Angular
```html
<!-- cdkTrapFocus for modals -->
<div cdkTrapFocus cdkTrapFocusAutoCapture role="dialog" aria-labelledby="dialog-title">
<h2 id="dialog-title">Confirm Action</h2>
</div>
<!-- LiveAnnouncer service -->
<!-- In component: this.liveAnnouncer.announce('Item added to cart'); -->
```
## Common ARIA Mistakes
| Mistake | Why It's Wrong | Fix |
|---------|---------------|-----|
| `<div role="button">` without keyboard | Div doesn't get keyboard events | Use `<button>` or add `tabindex="0"` + `onkeydown` |
| `aria-hidden="true"` on focusable element | Screen reader skips it but keyboard reaches it | Remove from tab order too: `tabindex="-1"` |
| `aria-label` overriding visible text | Confusing for sighted screen reader users | Use `aria-labelledby` pointing to visible text |
| Redundant ARIA on semantic HTML | `<nav role="navigation">` is redundant | Drop the `role``<nav>` implies it |
| `aria-live` on container that already has content | Initial content gets announced on load | Add `aria-live` to empty container, inject content after |
| Missing `aria-expanded` on toggles | Screen reader can't tell if section is open | Add `aria-expanded="true/false"` |

View File

@@ -0,0 +1,323 @@
# Framework-Specific Accessibility Patterns
## React / Next.js
### Common Issues and Fixes
**Image alt text:**
```jsx
// ❌ Bad
<img src="/hero.jpg" />
<Image src="/hero.jpg" width={800} height={400} />
// ✅ Good
<img src="/hero.jpg" alt="Team collaborating in office" />
<Image src="/hero.jpg" width={800} height={400} alt="Team collaborating in office" />
// ✅ Decorative image
<img src="/divider.svg" alt="" role="presentation" />
```
**Form labels:**
```jsx
// ❌ Bad — placeholder as label
<input placeholder="Email" type="email" />
// ✅ Good — explicit label
<label htmlFor="email">Email</label>
<input id="email" type="email" placeholder="user@example.com" />
// ✅ Good — aria-label for icon-only inputs
<input type="search" aria-label="Search products" />
```
**Click handlers on divs:**
```jsx
// ❌ Bad — not keyboard accessible
<div onClick={handleClick}>Click me</div>
// ✅ Good — use button
<button onClick={handleClick}>Click me</button>
// ✅ If div is required — add keyboard support
<div
role="button"
tabIndex={0}
onClick={handleClick}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') handleClick(); }}
>
Click me
</div>
```
**SPA route announcements (Next.js App Router):**
```jsx
// Layout component — announce page changes
'use client';
import { usePathname } from 'next/navigation';
import { useEffect, useState } from 'react';
export function RouteAnnouncer() {
const pathname = usePathname();
const [announcement, setAnnouncement] = useState('');
useEffect(() => {
const title = document.title;
setAnnouncement(`Navigated to ${title}`);
}, [pathname]);
return (
<div aria-live="assertive" role="status" className="sr-only">
{announcement}
</div>
);
}
```
**Focus management after dynamic content:**
```jsx
// After adding item to list, announce it
const [items, setItems] = useState([]);
const statusRef = useRef(null);
const addItem = (item) => {
setItems([...items, item]);
// Announce to screen readers
statusRef.current.textContent = `${item.name} added to list`;
};
return (
<>
<div ref={statusRef} aria-live="polite" className="sr-only" />
{/* list content */}
</>
);
```
### React-Specific Libraries
- `@radix-ui/*` — accessible primitives (Dialog, Tabs, Select, etc.)
- `@headlessui/react` — unstyled accessible components
- `react-aria` — Adobe's accessibility hooks
- `eslint-plugin-jsx-a11y` — lint rules for JSX accessibility
## Vue 3
### Common Issues and Fixes
**Dynamic content announcements:**
```vue
<template>
<div aria-live="polite" class="sr-only">
{{ announcement }}
</div>
<button @click="search">Search</button>
<ul v-if="results.length">
<li v-for="r in results" :key="r.id">{{ r.name }}</li>
</ul>
</template>
<script setup>
import { ref } from 'vue';
const results = ref([]);
const announcement = ref('');
async function search() {
results.value = await fetchResults();
announcement.value = `${results.value.length} results found`;
}
</script>
```
**Conditional rendering with focus:**
```vue
<template>
<button @click="showForm = true">Add Item</button>
<form v-if="showForm" ref="formRef">
<label for="name">Name</label>
<input id="name" ref="nameInput" />
</form>
</template>
<script setup>
import { ref, nextTick } from 'vue';
const showForm = ref(false);
const nameInput = ref(null);
watch(showForm, async (val) => {
if (val) {
await nextTick();
nameInput.value?.focus();
}
});
</script>
```
### Vue-Specific Libraries
- `vue-announcer` — route change announcements
- `@headlessui/vue` — accessible components
- `eslint-plugin-vuejs-accessibility` — lint rules
## Angular
### Common Issues and Fixes
**CDK accessibility utilities:**
```typescript
import { LiveAnnouncer } from '@angular/cdk/a11y';
import { FocusTrapFactory } from '@angular/cdk/a11y';
@Component({...})
export class MyComponent {
constructor(
private liveAnnouncer: LiveAnnouncer,
private focusTrapFactory: FocusTrapFactory
) {}
addItem(item: Item) {
this.items.push(item);
this.liveAnnouncer.announce(`${item.name} added`);
}
openDialog(element: HTMLElement) {
const focusTrap = this.focusTrapFactory.create(element);
focusTrap.focusInitialElement();
}
}
```
**Template-driven forms:**
```html
<!-- ❌ Bad -->
<input [formControl]="email" placeholder="Email" />
<!-- ✅ Good -->
<label for="email">Email address</label>
<input id="email" [formControl]="email"
[attr.aria-invalid]="email.invalid && email.touched"
[attr.aria-describedby]="email.invalid ? 'email-error' : null" />
<div id="email-error" *ngIf="email.invalid && email.touched" role="alert">
Please enter a valid email address.
</div>
```
### Angular-Specific Tools
- `@angular/cdk/a11y``FocusTrap`, `LiveAnnouncer`, `FocusMonitor`
- `codelyzer` — a11y lint rules for Angular templates
## Svelte / SvelteKit
### Common Issues and Fixes
```svelte
<!-- ❌ Bad — on:click without keyboard -->
<div on:click={handleClick}>Action</div>
<!-- ✅ Good — Svelte a11y warning built-in -->
<button on:click={handleClick}>Action</button>
<!-- ✅ Accessible toggle -->
<button
on:click={() => isOpen = !isOpen}
aria-expanded={isOpen}
aria-controls="panel"
>
{isOpen ? 'Close' : 'Open'} Details
</button>
{#if isOpen}
<div id="panel" role="region" aria-labelledby="toggle-btn">
Panel content
</div>
{/if}
```
**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.
## Plain HTML
### Checklist for Static Sites
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Descriptive Page Title</title>
</head>
<body>
<!-- Skip link -->
<a href="#main" class="skip-link">Skip to main content</a>
<header>
<nav aria-label="Main navigation">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/about" aria-current="page">About</a></li>
</ul>
</nav>
</header>
<main id="main" tabindex="-1">
<h1>Page Heading</h1>
<!-- Only one h1 per page -->
<!-- Heading levels don't skip (h1 → h2 → h3, never h1 → h3) -->
</main>
<footer>
<p>&copy; 2026 Company Name</p>
</footer>
</body>
</html>
```
## CSS Accessibility Patterns
### Focus Indicators
```css
/* ❌ Bad — removes focus indicator entirely */
:focus { outline: none; }
/* ✅ Good — custom focus indicator */
:focus-visible {
outline: 2px solid #005fcc;
outline-offset: 2px;
}
/* ✅ Good — enhanced for high contrast mode */
@media (forced-colors: active) {
:focus-visible {
outline: 2px solid ButtonText;
}
}
```
### Reduced Motion
```css
/* ✅ Respect prefers-reduced-motion */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
```
### Screen Reader Only
```css
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
```

View File

@@ -0,0 +1,113 @@
# WCAG 2.2 Quick Reference — Level A & AA
## Perceivable
### 1.1 Text Alternatives
| Criterion | Level | Requirement | Common Violation |
|-----------|-------|-------------|------------------|
| 1.1.1 Non-text Content | A | All images have `alt` text; decorative images use `alt=""` or `role="presentation"` | `<img src="logo.png">` without alt |
### 1.2 Time-Based Media
| Criterion | Level | Requirement | Common Violation |
|-----------|-------|-------------|------------------|
| 1.2.1 Audio-only / Video-only | A | Provide transcript or audio description | Video without captions |
| 1.2.2 Captions | A | Captions for all prerecorded audio in video | Missing `<track kind="captions">` |
| 1.2.3 Audio Description | A | Audio description for prerecorded video | No descriptive track |
| 1.2.5 Audio Description (Prerecorded) | AA | Audio description for all prerecorded video | Same as 1.2.3 but stricter |
### 1.3 Adaptable
| Criterion | Level | Requirement | Common Violation |
|-----------|-------|-------------|------------------|
| 1.3.1 Info and Relationships | A | Semantic markup conveys structure | Using `<div>` instead of `<nav>`, `<main>`, `<header>` |
| 1.3.2 Meaningful Sequence | A | Reading order matches visual order | CSS flex/grid reordering without DOM reorder |
| 1.3.3 Sensory Characteristics | A | Don't rely solely on color, shape, position | "Click the red button" |
| 1.3.4 Orientation | AA | Content not restricted to portrait/landscape | CSS `orientation: portrait` lock |
| 1.3.5 Identify Input Purpose | AA | Input purpose identifiable via `autocomplete` | Missing `autocomplete="email"` on email inputs |
### 1.4 Distinguishable
| Criterion | Level | Requirement | Ratio |
|-----------|-------|-------------|-------|
| 1.4.1 Use of Color | A | Color not sole means of conveying info | Red-only error indicators |
| 1.4.2 Audio Control | A | Auto-playing audio has pause/stop | `autoplay` without `controls` |
| 1.4.3 Contrast (Minimum) | AA | Text: 4.5:1, Large text: 3:1 | Light gray text on white |
| 1.4.4 Resize Text | AA | Text resizable to 200% without loss | Fixed `px` font sizes |
| 1.4.5 Images of Text | AA | Use real text, not text in images | Logo text as PNG |
| 1.4.10 Reflow | AA | Content reflows at 320px width | Horizontal scrolling at mobile widths |
| 1.4.11 Non-text Contrast | AA | UI components and graphics: 3:1 | Low-contrast borders, icons |
| 1.4.12 Text Spacing | AA | No loss of content when spacing adjusted | Fixed-height containers clipping |
| 1.4.13 Content on Hover/Focus | AA | Dismissible, hoverable, persistent | Tooltips that disappear on mouse move |
## Operable
### 2.1 Keyboard Accessible
| Criterion | Level | Requirement | Common Violation |
|-----------|-------|-------------|------------------|
| 2.1.1 Keyboard | A | All functionality via keyboard | `onClick` without `onKeyDown` |
| 2.1.2 No Keyboard Trap | A | Focus can move away from any component | Modal without focus trap escape |
| 2.1.4 Character Key Shortcuts | A | Single-key shortcuts can be turned off | `accesskey` conflicts |
### 2.4 Navigable
| Criterion | Level | Requirement | Common Violation |
|-----------|-------|-------------|------------------|
| 2.4.1 Bypass Blocks | A | Skip navigation link | No "Skip to content" link |
| 2.4.2 Page Titled | A | Descriptive `<title>` | `<title>Untitled</title>` |
| 2.4.3 Focus Order | A | Logical tab order | `tabindex` > 0 |
| 2.4.4 Link Purpose | A | Link text describes destination | "Click here", "Read more" |
| 2.4.6 Headings and Labels | AA | Descriptive headings | Generic headings |
| 2.4.7 Focus Visible | AA | Visible focus indicator | `outline: none` without replacement |
| 2.4.11 Focus Not Obscured | AA | Focused element not hidden by sticky header | Fixed header covering focused element |
### 2.5 Input Modalities
| Criterion | Level | Requirement | Common Violation |
|-----------|-------|-------------|------------------|
| 2.5.1 Pointer Gestures | A | Multi-point gestures have single-point alternative | Pinch-to-zoom only |
| 2.5.2 Pointer Cancellation | A | Down-event doesn't trigger action | `mousedown` instead of `click` |
| 2.5.3 Label in Name | A | Visible label is in accessible name | Button shows "Submit" but `aria-label="btn1"` |
| 2.5.4 Motion Actuation | A | Motion-triggered actions have alternative | Shake-to-undo only |
| 2.5.7 Dragging Movements | AA | Drag has single-pointer alternative | Drag-and-drop only reordering |
| 2.5.8 Target Size | AA | Touch targets minimum 24x24 CSS pixels | Tiny mobile buttons |
## Understandable
### 3.1 Readable
| Criterion | Level | Requirement | Common Violation |
|-----------|-------|-------------|------------------|
| 3.1.1 Language of Page | A | `<html lang="en">` | Missing `lang` attribute |
| 3.1.2 Language of Parts | AA | `lang` on foreign-language spans | Mixed-language content without `lang` |
### 3.2 Predictable
| Criterion | Level | Requirement | Common Violation |
|-----------|-------|-------------|------------------|
| 3.2.1 On Focus | A | Focus doesn't trigger unexpected change | Auto-submitting on focus |
| 3.2.2 On Input | A | Input doesn't trigger unexpected change | Auto-navigating on select change |
| 3.2.3 Consistent Navigation | AA | Navigation consistent across pages | Menu order changes per page |
| 3.2.4 Consistent Identification | AA | Same function = same label | "Search" vs "Find" for same action |
### 3.3 Input Assistance
| Criterion | Level | Requirement | Common Violation |
|-----------|-------|-------------|------------------|
| 3.3.1 Error Identification | A | Errors described in text | Red border only, no message |
| 3.3.2 Labels or Instructions | A | Labels for required input | Placeholder as only label |
| 3.3.3 Error Suggestion | AA | Suggest corrections | "Invalid input" without guidance |
| 3.3.4 Error Prevention | AA | Reversible submissions for legal/financial | No confirmation for payment |
| 3.3.7 Redundant Entry | A | Don't ask for same info twice | Re-entering address in checkout |
| 3.3.8 Accessible Authentication | AA | No cognitive function test for login | CAPTCHA without audio alternative |
## Robust
### 4.1 Compatible
| Criterion | Level | Requirement | Common Violation |
|-----------|-------|-------------|------------------|
| 4.1.2 Name, Role, Value | A | Custom controls have accessible name and role | Custom dropdown without ARIA |
| 4.1.3 Status Messages | AA | Status updates announced without focus change | Toast without `aria-live` |

View File

@@ -0,0 +1,684 @@
#!/usr/bin/env python3
"""WCAG 2.2 Accessibility Scanner for Frontend Codebases.
Scans HTML, JSX, TSX, Vue, Svelte, and CSS files for accessibility
violations across 10 categories: images, forms, headings, landmarks,
keyboard, ARIA, color/contrast, links, tables, and media.
Usage:
python a11y_scanner.py /path/to/project
python a11y_scanner.py /path/to/project --json
python a11y_scanner.py /path/to/project --severity critical,serious
python a11y_scanner.py /path/to/project --format json
"""
import argparse
import json
import os
import re
import sys
from dataclasses import dataclass, asdict
from typing import List, Optional
@dataclass
class Finding:
"""A single accessibility finding."""
rule_id: str
category: str
severity: str
message: str
file: str
line: int
snippet: str
wcag_criterion: str
fix: str
# ---------------------------------------------------------------------------
# Rule definitions: each returns a list of Finding from a single file
# ---------------------------------------------------------------------------
VALID_ARIA_ATTRS = {
"aria-activedescendant", "aria-atomic", "aria-autocomplete", "aria-busy",
"aria-checked", "aria-colcount", "aria-colindex", "aria-colspan",
"aria-controls", "aria-current", "aria-describedby", "aria-details",
"aria-disabled", "aria-dropeffect", "aria-errormessage", "aria-expanded",
"aria-flowto", "aria-grabbed", "aria-haspopup", "aria-hidden",
"aria-invalid", "aria-keyshortcuts", "aria-label", "aria-labelledby",
"aria-level", "aria-live", "aria-modal", "aria-multiline",
"aria-multiselectable", "aria-orientation", "aria-owns", "aria-placeholder",
"aria-posinset", "aria-pressed", "aria-readonly", "aria-relevant",
"aria-required", "aria-roledescription", "aria-rowcount", "aria-rowindex",
"aria-rowspan", "aria-selected", "aria-setsize", "aria-sort",
"aria-valuemax", "aria-valuemin", "aria-valuenow", "aria-valuetext",
"aria-braillelabel", "aria-brailleroledescription", "aria-description",
}
BAD_LINK_TEXT = re.compile(
r">\s*(click here|here|read more|more|link|this)\s*<", re.IGNORECASE
)
TAG_RE = re.compile(r"<(\w[\w-]*)\b([^>]*)(/?)>", re.DOTALL)
ATTR_RE = re.compile(r"""([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+))""")
ATTR_BOOL_RE = re.compile(r"\b([\w:.-]+)(?=\s|/?>|$)")
INLINE_COLOR_RE = re.compile(
r'style\s*=\s*["\'][^"\']*\bcolor\s*:', re.IGNORECASE
)
ARIA_ATTR_RE = re.compile(r"\baria-[\w-]+")
def _attrs(attr_str: str) -> dict:
"""Parse HTML/JSX attribute string into a dict."""
result = {}
for m in ATTR_RE.finditer(attr_str):
result[m.group(1)] = m.group(2) or m.group(3) or m.group(4) or ""
# boolean attrs
cleaned = ATTR_RE.sub("", attr_str)
for m in ATTR_BOOL_RE.finditer(cleaned):
name = m.group(1)
if name not in result and not name.startswith("/"):
result[name] = True
return result
def _snippet(line_text: str) -> str:
"""Trim a line for display as a code snippet."""
s = line_text.rstrip("\n\r")
return s[:120] + "..." if len(s) > 120 else s
def _find(rule_id, cat, sev, msg, fp, ln, snip, wcag, fix):
return Finding(rule_id, cat, sev, msg, fp, ln, snip, wcag, fix)
# ---------- Images ----------------------------------------------------------
def check_img_missing_alt(tag, attrs, fp, ln, snip):
if tag == "img" and "alt" not in attrs:
return _find("img-alt-missing", "images", "critical",
"<img> missing alt attribute",
fp, ln, snip, "1.1.1 Non-text Content",
"Add alt=\"description\" or alt=\"\" for decorative images.")
def check_img_empty_alt_informative(tag, attrs, fp, ln, snip):
if tag == "img" and attrs.get("alt") == "" and attrs.get("src", ""):
src = attrs.get("src", "")
if not any(kw in src.lower() for kw in ("spacer", "border", "decorat", "bg")):
return _find("img-alt-empty-informative", "images", "serious",
"<img> has empty alt but may be informative",
fp, ln, snip, "1.1.1 Non-text Content",
"If image conveys information, add descriptive alt text.")
def check_img_decorative_has_alt(tag, attrs, fp, ln, snip):
if tag == "img" and attrs.get("role") == "presentation" and attrs.get("alt", "") != "":
return _find("img-decorative-alt", "images", "moderate",
"Decorative image (role=presentation) should have alt=\"\"",
fp, ln, snip, "1.1.1 Non-text Content",
"Set alt=\"\" on decorative images with role=presentation.")
# ---------- Forms -----------------------------------------------------------
def check_input_missing_label(tag, attrs, fp, ln, snip):
input_types = {"text", "email", "password", "search", "tel", "url", "number", "date"}
if tag == "input" and attrs.get("type", "text") in input_types:
if "aria-label" not in attrs and "aria-labelledby" not in attrs and "id" not in attrs:
return _find("form-input-no-label", "forms", "critical",
"<input> has no id, aria-label, or aria-labelledby",
fp, ln, snip, "1.3.1 Info and Relationships",
"Add id + <label for>, or aria-label attribute.")
def check_input_no_aria_label(tag, attrs, fp, ln, snip):
if tag in ("select", "textarea"):
if "aria-label" not in attrs and "aria-labelledby" not in attrs and "id" not in attrs:
return _find("form-select-no-label", "forms", "critical",
f"<{tag}> has no accessible name",
fp, ln, snip, "4.1.2 Name, Role, Value",
f"Add aria-label or id + <label for> to <{tag}>.")
def check_orphan_label(lines, fp):
"""Labels whose 'for' points to a non-existent id."""
findings = []
ids = set()
label_fors = []
for ln, line in enumerate(lines, 1):
for m in re.finditer(r'\bid\s*=\s*["\']([^"\']+)["\']', line):
ids.add(m.group(1))
for m in re.finditer(r'<label[^>]*\bfor\s*=\s*["\']([^"\']+)["\']', line):
label_fors.append((ln, m.group(1), line))
for ln, for_val, line in label_fors:
if for_val not in ids:
findings.append(_find("form-orphan-label", "forms", "serious",
f"<label for=\"{for_val}\"> references non-existent id",
fp, ln, _snippet(line), "1.3.1 Info and Relationships",
f"Ensure an element with id=\"{for_val}\" exists."))
return findings
def check_fieldset_legend(lines, fp):
"""Radio/checkbox groups without fieldset."""
findings = []
radio_lines = []
has_fieldset = any("fieldset" in l.lower() for l in lines)
for ln, line in enumerate(lines, 1):
if re.search(r'type\s*=\s*["\'](?:radio|checkbox)["\']', line, re.I):
radio_lines.append((ln, line))
if radio_lines and not has_fieldset:
ln, line = radio_lines[0]
findings.append(_find("form-missing-fieldset", "forms", "serious",
"Radio/checkbox group without <fieldset>/<legend>",
fp, ln, _snippet(line), "1.3.1 Info and Relationships",
"Wrap related radio/checkbox inputs in <fieldset> with <legend>."))
return findings
# ---------- Headings --------------------------------------------------------
def check_headings(lines, fp):
findings = []
heading_levels = []
for ln, line in enumerate(lines, 1):
for m in re.finditer(r"<[hH]([1-6])\b", line):
heading_levels.append((int(m.group(1)), ln, line))
if not heading_levels:
return findings
# Missing h1
levels_seen = {h[0] for h in heading_levels}
if 1 not in levels_seen and any(l <= 3 for l in levels_seen):
findings.append(_find("heading-missing-h1", "headings", "serious",
"Page has headings but no <h1>",
fp, heading_levels[0][1], _snippet(heading_levels[0][2]),
"1.3.1 Info and Relationships",
"Add a single <h1> as the main page heading."))
# Multiple h1s
h1_lines = [(ln, line) for lvl, ln, line in heading_levels if lvl == 1]
if len(h1_lines) > 1:
findings.append(_find("heading-multiple-h1", "headings", "moderate",
f"Page has {len(h1_lines)} <h1> elements",
fp, h1_lines[1][0], _snippet(h1_lines[1][1]),
"1.3.1 Info and Relationships",
"Use a single <h1> per page. Demote others to <h2>+."))
# Skipped levels
prev_level = 0
for lvl, ln, line in heading_levels:
if prev_level > 0 and lvl > prev_level + 1:
findings.append(_find("heading-skipped", "headings", "moderate",
f"Heading level skips from h{prev_level} to h{lvl}",
fp, ln, _snippet(line),
"1.3.1 Info and Relationships",
f"Use <h{prev_level + 1}> instead of <h{lvl}>."))
prev_level = lvl
return findings
# ---------- Landmarks -------------------------------------------------------
def check_landmarks(lines, fp):
findings = []
content = "\n".join(lines)
# Missing main landmark
if not re.search(r'<main\b|role\s*=\s*["\']main["\']', content, re.I):
findings.append(_find("landmark-no-main", "landmarks", "serious",
"Page missing <main> landmark",
fp, 1, "", "1.3.1 Info and Relationships",
"Add a <main> element to wrap primary content."))
# Missing nav
if not re.search(r'<nav\b|role\s*=\s*["\']navigation["\']', content, re.I):
findings.append(_find("landmark-no-nav", "landmarks", "moderate",
"Page missing <nav> landmark",
fp, 1, "", "1.3.1 Info and Relationships",
"Add <nav> for primary navigation blocks."))
# Missing skip link
if not re.search(r'skip.{0,10}(nav|main|content)', content, re.I):
findings.append(_find("landmark-no-skip-link", "landmarks", "serious",
"Page missing skip navigation link",
fp, 1, "", "2.4.1 Bypass Blocks",
"Add <a href=\"#main\">Skip to main content</a> as first focusable element."))
return findings
# ---------- Keyboard --------------------------------------------------------
def check_tabindex_positive(tag, attrs, fp, ln, snip):
ti = attrs.get("tabindex", "")
if isinstance(ti, str) and ti.lstrip("-").isdigit() and int(ti) > 0:
return _find("keyboard-tabindex-positive", "keyboard", "serious",
f"tabindex={ti} creates unexpected tab order",
fp, ln, snip, "2.4.3 Focus Order",
"Use tabindex=\"0\" or tabindex=\"-1\" instead of positive values.")
def check_click_no_keyboard(tag, attrs, fp, ln, snip):
has_click = "onClick" in attrs or "onclick" in attrs or "@click" in attrs or "on:click" in attrs
has_key = any(k for k in attrs if "keydown" in k.lower() or "keyup" in k.lower() or "keypress" in k.lower())
if tag in ("div", "span", "td", "li", "p", "section") and has_click and not has_key:
if attrs.get("role") not in ("button", "link", "tab", "menuitem"):
return _find("keyboard-click-no-key", "keyboard", "critical",
f"<{tag}> has click handler but no keyboard handler",
fp, ln, snip, "2.1.1 Keyboard",
f"Add onKeyDown handler or use <button> instead of <{tag}>.")
def check_autofocus_misuse(tag, attrs, fp, ln, snip):
if "autofocus" in attrs or "autoFocus" in attrs:
if tag not in ("input", "textarea", "select"):
return _find("keyboard-autofocus", "keyboard", "moderate",
f"autofocus on <{tag}> can disorient screen reader users",
fp, ln, snip, "3.2.1 On Focus",
"Avoid autofocus on non-input elements. Use focus management instead.")
# ---------- ARIA ------------------------------------------------------------
def check_invalid_aria(tag, attrs, fp, ln, snip):
findings = []
for key in attrs:
if key.startswith("aria-") and key.lower() not in VALID_ARIA_ATTRS:
findings.append(_find("aria-invalid-attr", "aria", "serious",
f"Invalid ARIA attribute: {key}",
fp, ln, snip, "4.1.2 Name, Role, Value",
f"Remove or replace \"{key}\" with a valid ARIA attribute."))
return findings
def check_aria_hidden_focusable(tag, attrs, fp, ln, snip):
if attrs.get("aria-hidden") in ("true", True):
focusable_tags = {"a", "button", "input", "select", "textarea"}
if tag in focusable_tags or (isinstance(attrs.get("tabindex", ""), str) and
attrs.get("tabindex", "-1") != "-1"):
return _find("aria-hidden-focusable", "aria", "critical",
f"aria-hidden=\"true\" on focusable <{tag}>",
fp, ln, snip, "4.1.2 Name, Role, Value",
"Remove aria-hidden or make element non-focusable (tabindex=\"-1\").")
def check_aria_live_missing(lines, fp):
"""Alert/status roles or live regions without aria-live."""
findings = []
for ln, line in enumerate(lines, 1):
if re.search(r'role\s*=\s*["\'](?:alert|status)["\']', line, re.I):
if "aria-live" not in line:
findings.append(_find("aria-live-missing", "aria", "serious",
"role=alert/status without explicit aria-live",
fp, ln, _snippet(line),
"4.1.3 Status Messages",
"Add aria-live=\"assertive\" (alert) or aria-live=\"polite\" (status)."))
return findings
# ---------- Color/Contrast --------------------------------------------------
def check_inline_color(tag, attrs, fp, ln, snip):
style = attrs.get("style", "")
if isinstance(style, str) and re.search(r"\bcolor\s*:", style, re.I):
if not re.search(r"background", style, re.I):
return _find("color-inline-no-bg", "color", "moderate",
"Inline color set without background — contrast may be insufficient",
fp, ln, snip, "1.4.3 Contrast (Minimum)",
"Ensure foreground and background colors meet 4.5:1 contrast ratio.")
def check_text_over_image(lines, fp):
"""Detects patterns where text is positioned over background images without overlay."""
findings = []
for ln, line in enumerate(lines, 1):
if re.search(r"background-image\s*:", line, re.I):
if not re.search(r"(overlay|rgba|linear-gradient)", line, re.I):
findings.append(_find("color-text-over-image", "color", "serious",
"Background image without contrast overlay for text",
fp, ln, _snippet(line),
"1.4.3 Contrast (Minimum)",
"Add a semi-transparent overlay or ensure text contrast."))
return findings
# ---------- Links -----------------------------------------------------------
def check_empty_link(tag, attrs, fp, ln, snip):
if tag == "a" and not attrs.get("aria-label") and not attrs.get("aria-labelledby"):
return None # handled by line-level check below
def check_empty_links_line(lines, fp):
findings = []
for ln, line in enumerate(lines, 1):
# <a ...></a> or <a ...> </a>
if re.search(r"<a\b[^>]*>\s*</a>", line, re.I):
if "aria-label" not in line and "aria-labelledby" not in line:
findings.append(_find("link-empty", "links", "critical",
"Empty link — no text or accessible name",
fp, ln, _snippet(line), "2.4.4 Link Purpose",
"Add link text or aria-label."))
# Bad link text
if BAD_LINK_TEXT.search(line):
findings.append(_find("link-bad-text", "links", "serious",
"Link uses vague text like 'click here'",
fp, ln, _snippet(line), "2.4.4 Link Purpose",
"Use descriptive link text that makes sense out of context."))
return findings
def check_same_page_link(tag, attrs, fp, ln, snip):
href = attrs.get("href", "")
if tag == "a" and isinstance(href, str) and href == "#":
return _find("link-empty-fragment", "links", "moderate",
"Link with href=\"#\" — use a button or valid fragment",
fp, ln, snip, "2.4.4 Link Purpose",
"Use <button> for actions or href=\"#section-id\" for anchors.")
# ---------- Tables ----------------------------------------------------------
def check_table_headers(lines, fp):
findings = []
in_table = False
table_start = 0
has_th = False
has_caption = False
has_aria_label = False
for ln, line in enumerate(lines, 1):
if re.search(r"<table\b", line, re.I):
in_table = True
table_start = ln
has_th = False
has_caption = False
has_aria_label = "aria-label" in line
if in_table:
if "<th" in line.lower():
has_th = True
if "<caption" in line.lower():
has_caption = True
if re.search(r"</table>", line, re.I):
if not has_th:
findings.append(_find("table-no-headers", "tables", "serious",
"<table> has no <th> header cells",
fp, table_start, _snippet(lines[table_start - 1]),
"1.3.1 Info and Relationships",
"Add <th> elements to identify column/row headers."))
if not has_caption and not has_aria_label:
findings.append(_find("table-no-caption", "tables", "moderate",
"<table> missing <caption> or aria-label",
fp, table_start, _snippet(lines[table_start - 1]),
"1.3.1 Info and Relationships",
"Add <caption> or aria-label to describe the table."))
in_table = False
return findings
# ---------- Media -----------------------------------------------------------
def check_media_captions(tag, attrs, fp, ln, snip):
if tag == "video":
return None # handled at block level
def check_media_captions_block(lines, fp):
findings = []
in_video = False
video_start = 0
has_track = False
has_controls = False
has_autoplay = False
for ln, line in enumerate(lines, 1):
if re.search(r"<video\b", line, re.I):
in_video = True
video_start = ln
has_track = False
has_controls = "controls" in line.lower()
has_autoplay = "autoplay" in line.lower()
if in_video:
if re.search(r'<track\b[^>]*kind\s*=\s*["\']captions["\']', line, re.I):
has_track = True
if "controls" in line.lower():
has_controls = True
if re.search(r"</video>", line, re.I) or (re.search(r"<video\b", line, re.I) and "/>" in line):
if not has_track:
findings.append(_find("media-no-captions", "media", "critical",
"<video> missing captions track",
fp, video_start, _snippet(lines[video_start - 1]),
"1.2.2 Captions (Prerecorded)",
"Add <track kind=\"captions\" src=\"...\" srclang=\"en\">."))
if has_autoplay and not has_controls:
findings.append(_find("media-autoplay-no-controls", "media", "serious",
"<video> has autoplay without controls",
fp, video_start, _snippet(lines[video_start - 1]),
"1.4.2 Audio Control",
"Add the controls attribute so users can pause/stop."))
in_video = False
# Single-line video tags
for ln, line in enumerate(lines, 1):
if re.search(r"<audio\b", line, re.I):
if "autoplay" in line.lower() and "controls" not in line.lower():
findings.append(_find("media-audio-autoplay", "media", "serious",
"<audio> has autoplay without controls",
fp, ln, _snippet(line), "1.4.2 Audio Control",
"Add the controls attribute to <audio>."))
return findings
# ---------------------------------------------------------------------------
# Scanner engine
# ---------------------------------------------------------------------------
SUPPORTED_EXTENSIONS = {".html", ".htm", ".jsx", ".tsx", ".vue", ".svelte", ".css"}
TAG_LEVEL_CHECKS = [
check_img_missing_alt,
check_img_empty_alt_informative,
check_img_decorative_has_alt,
check_input_missing_label,
check_input_no_aria_label,
check_tabindex_positive,
check_click_no_keyboard,
check_autofocus_misuse,
check_aria_hidden_focusable,
check_inline_color,
check_same_page_link,
]
TAG_LEVEL_MULTI_CHECKS = [
check_invalid_aria,
]
def scan_file(filepath: str) -> List[Finding]:
"""Scan a single file and return all findings."""
findings: List[Finding] = []
try:
with open(filepath, "r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()
except (OSError, IOError):
return findings
# Tag-level checks
for ln, line in enumerate(lines, 1):
for m in TAG_RE.finditer(line):
tag = m.group(1).lower()
attr_str = m.group(2)
attrs = _attrs(attr_str)
snip = _snippet(line)
for check in TAG_LEVEL_CHECKS:
result = check(tag, attrs, filepath, ln, snip)
if result:
findings.append(result)
for check in TAG_LEVEL_MULTI_CHECKS:
results = check(tag, attrs, filepath, ln, snip)
if results:
findings.extend(results)
# File-level / multi-line checks
findings.extend(check_orphan_label(lines, filepath))
findings.extend(check_fieldset_legend(lines, filepath))
findings.extend(check_headings(lines, filepath))
findings.extend(check_landmarks(lines, filepath))
findings.extend(check_aria_live_missing(lines, filepath))
findings.extend(check_text_over_image(lines, filepath))
findings.extend(check_empty_links_line(lines, filepath))
findings.extend(check_table_headers(lines, filepath))
findings.extend(check_media_captions_block(lines, filepath))
return findings
def collect_files(path: str) -> List[str]:
"""Recursively collect scannable files under path."""
files = []
if os.path.isfile(path):
_, ext = os.path.splitext(path)
if ext.lower() in SUPPORTED_EXTENSIONS:
files.append(path)
return files
for root, dirs, filenames in os.walk(path):
# Skip common non-source directories
dirs[:] = [d for d in dirs if d not in (
"node_modules", ".git", "dist", "build", "__pycache__",
".next", ".nuxt", "vendor", "coverage"
)]
for fname in filenames:
_, ext = os.path.splitext(fname)
if ext.lower() in SUPPORTED_EXTENSIONS:
files.append(os.path.join(root, fname))
files.sort()
return files
# ---------------------------------------------------------------------------
# Output formatting
# ---------------------------------------------------------------------------
SEVERITY_ORDER = {"critical": 0, "serious": 1, "moderate": 2, "minor": 3}
def format_human(findings: List[Finding], files_scanned: int) -> str:
"""Format findings as human-readable text report."""
if not findings:
return (f"Scanned {files_scanned} file(s) -- no accessibility issues found.\n"
"All checks passed.")
lines = []
lines.append(f"WCAG 2.2 Accessibility Scan Results")
lines.append(f"{'=' * 50}")
lines.append(f"Files scanned: {files_scanned}")
lines.append(f"Issues found: {len(findings)}")
# Summary by severity
severity_counts = {}
for f in findings:
severity_counts[f.severity] = severity_counts.get(f.severity, 0) + 1
for sev in ("critical", "serious", "moderate", "minor"):
if sev in severity_counts:
lines.append(f" {sev.upper():10s}: {severity_counts[sev]}")
lines.append("")
# Summary by category
cat_counts = {}
for f in findings:
cat_counts[f.category] = cat_counts.get(f.category, 0) + 1
lines.append("By category:")
for cat in sorted(cat_counts, key=lambda c: -cat_counts[c]):
lines.append(f" {cat:20s}: {cat_counts[cat]}")
lines.append("")
# Detailed findings sorted by severity then file
sorted_findings = sorted(findings, key=lambda f: (SEVERITY_ORDER.get(f.severity, 9), f.file, f.line))
for i, f in enumerate(sorted_findings, 1):
lines.append(f"[{f.severity.upper()}] {f.rule_id}")
lines.append(f" File: {f.file}:{f.line}")
lines.append(f" WCAG: {f.wcag_criterion}")
lines.append(f" Issue: {f.message}")
if f.snippet:
lines.append(f" Code: {f.snippet}")
lines.append(f" Fix: {f.fix}")
lines.append("")
return "\n".join(lines)
def format_json(findings: List[Finding], files_scanned: int) -> str:
"""Format findings as JSON."""
severity_counts = {}
for f in findings:
severity_counts[f.severity] = severity_counts.get(f.severity, 0) + 1
report = {
"summary": {
"files_scanned": files_scanned,
"total_issues": len(findings),
"by_severity": severity_counts,
},
"findings": [asdict(f) for f in findings],
}
return json.dumps(report, indent=2)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="a11y_scanner",
description="Scan frontend codebases for WCAG 2.2 accessibility violations.",
epilog=(
"Supported file types: .html, .htm, .jsx, .tsx, .vue, .svelte, .css\n"
"Exit codes: 0 = pass, 1 = critical/serious found, 2 = moderate/minor only"
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"path",
help="File or directory to scan",
)
parser.add_argument(
"--json", dest="json_flag", action="store_true",
help="Output results as JSON (shorthand for --format json)",
)
parser.add_argument(
"--format", dest="output_format", choices=["text", "json"],
default="text",
help="Output format: text (default) or json",
)
parser.add_argument(
"--severity", dest="severity",
default=None,
help="Comma-separated severity filter (e.g. critical,serious)",
)
return parser
def main():
parser = build_parser()
args = parser.parse_args()
path = os.path.abspath(args.path)
if not os.path.exists(path):
print(f"Error: path does not exist: {path}", file=sys.stderr)
sys.exit(1)
use_json = args.json_flag or args.output_format == "json"
# Collect and scan files
files = collect_files(path)
if not files:
print(f"No scannable files found in: {path}", file=sys.stderr)
sys.exit(0)
all_findings: List[Finding] = []
for fpath in files:
all_findings.extend(scan_file(fpath))
# Filter by severity if requested
if args.severity:
allowed = {s.strip().lower() for s in args.severity.split(",")}
all_findings = [f for f in all_findings if f.severity in allowed]
# Output
if use_json:
print(format_json(all_findings, len(files)))
else:
print(format_human(all_findings, len(files)))
# Exit code
severities = {f.severity for f in all_findings}
if severities & {"critical", "serious"}:
sys.exit(1)
elif severities & {"moderate", "minor"}:
sys.exit(2)
else:
sys.exit(0)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,499 @@
#!/usr/bin/env python3
"""WCAG 2.2 Color Contrast Checker.
Checks foreground/background color pairs against WCAG 2.2 contrast ratio
thresholds for normal text, large text, and UI components. Supports hex,
rgb(), and named CSS colors.
Usage:
python contrast_checker.py "#ffffff" "#000000"
python contrast_checker.py --suggest "#336699"
python contrast_checker.py --batch styles.css
python contrast_checker.py --demo
"""
import argparse
import json
import re
import sys
# ---------------------------------------------------------------------------
# Named CSS colors (25 common ones)
# ---------------------------------------------------------------------------
NAMED_COLORS = {
"black": (0, 0, 0),
"white": (255, 255, 255),
"red": (255, 0, 0),
"green": (0, 128, 0),
"blue": (0, 0, 255),
"yellow": (255, 255, 0),
"cyan": (0, 255, 255),
"magenta": (255, 0, 255),
"gray": (128, 128, 128),
"grey": (128, 128, 128),
"orange": (255, 165, 0),
"purple": (128, 0, 128),
"pink": (255, 192, 203),
"brown": (165, 42, 42),
"navy": (0, 0, 128),
"teal": (0, 128, 128),
"olive": (128, 128, 0),
"maroon": (128, 0, 0),
"lime": (0, 255, 0),
"aqua": (0, 255, 255),
"silver": (192, 192, 192),
"gold": (255, 215, 0),
"coral": (255, 127, 80),
"salmon": (250, 128, 114),
"tomato": (255, 99, 71),
}
# WCAG thresholds: (label, required_ratio)
WCAG_THRESHOLDS = [
("AA Normal Text", 4.5),
("AA Large Text", 3.0),
("AA UI Components", 3.0),
("AAA Normal Text", 7.0),
("AAA Large Text", 4.5),
]
# ---------------------------------------------------------------------------
# Color parsing
# ---------------------------------------------------------------------------
def parse_color(color_str: str) -> tuple:
"""Parse a color string into an (R, G, B) tuple.
Accepts:
- #RRGGBB or #RGB hex
- rgb(r, g, b) with values 0-255
- Named CSS colors
"""
s = color_str.strip().lower()
# Named color
if s in NAMED_COLORS:
return NAMED_COLORS[s]
# Hex: #RGB or #RRGGBB
hex_match = re.match(r"^#([0-9a-f]{3}|[0-9a-f]{6})$", s)
if hex_match:
h = hex_match.group(1)
if len(h) == 3:
r, g, b = int(h[0] * 2, 16), int(h[1] * 2, 16), int(h[2] * 2, 16)
else:
r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
return (r, g, b)
# rgb(r, g, b)
rgb_match = re.match(r"^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$", s)
if rgb_match:
r, g, b = int(rgb_match.group(1)), int(rgb_match.group(2)), int(rgb_match.group(3))
if not all(0 <= c <= 255 for c in (r, g, b)):
raise ValueError(f"RGB values must be 0-255, got rgb({r},{g},{b})")
return (r, g, b)
raise ValueError(
f"Invalid color format: '{color_str}'. "
"Use #RRGGBB, #RGB, rgb(r,g,b), or a named color."
)
def color_to_hex(rgb: tuple) -> str:
"""Convert an (R, G, B) tuple to #RRGGBB."""
return f"#{rgb[0]:02x}{rgb[1]:02x}{rgb[2]:02x}"
# ---------------------------------------------------------------------------
# WCAG luminance and contrast
# ---------------------------------------------------------------------------
def relative_luminance(rgb: tuple) -> float:
"""Calculate relative luminance per WCAG 2.2 (sRGB).
https://www.w3.org/TR/WCAG22/#dfn-relative-luminance
"""
channels = []
for c in rgb:
s = c / 255.0
channels.append(s / 12.92 if s <= 0.04045 else ((s + 0.055) / 1.055) ** 2.4)
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]
def contrast_ratio(rgb1: tuple, rgb2: tuple) -> float:
"""Return the WCAG contrast ratio between two colors (>= 1.0)."""
l1 = relative_luminance(rgb1)
l2 = relative_luminance(rgb2)
lighter = max(l1, l2)
darker = min(l1, l2)
return (lighter + 0.05) / (darker + 0.05)
def evaluate_contrast(ratio: float) -> list:
"""Return pass/fail results for each WCAG threshold."""
results = []
for label, threshold in WCAG_THRESHOLDS:
results.append({
"level": label,
"required": threshold,
"ratio": round(ratio, 2),
"pass": ratio >= threshold,
})
return results
# ---------------------------------------------------------------------------
# Suggest accessible backgrounds
# ---------------------------------------------------------------------------
def suggest_backgrounds(fg_rgb: tuple, target_ratio: float = 4.5, count: int = 8) -> list:
"""Given a foreground color, suggest background colors passing AA normal text.
Strategy: walk luminance in both directions (lighter / darker) from the
foreground and collect the first colors that meet the target ratio.
"""
suggestions = []
# Try a spread of grays and tinted variants
candidates = []
for v in range(0, 256, 1):
candidates.append((v, v, v)) # grays
# Also try tinted versions toward the complement
fr, fg, fb = fg_rgb
for v in range(0, 256, 2):
candidates.append((v, min(255, v + 20), min(255, v + 40)))
candidates.append((min(255, v + 40), v, min(255, v + 20)))
candidates.append((min(255, v + 20), min(255, v + 40), v))
seen = set()
scored = []
for c in candidates:
cr = contrast_ratio(fg_rgb, c)
if cr >= target_ratio and c not in seen:
seen.add(c)
scored.append((cr, c))
# Sort by ratio closest to target (prefer minimal-change backgrounds)
scored.sort(key=lambda x: x[0])
for cr, c in scored[:count]:
suggestions.append({"hex": color_to_hex(c), "rgb": list(c), "ratio": round(cr, 2)})
return suggestions
# ---------------------------------------------------------------------------
# Batch CSS parsing
# ---------------------------------------------------------------------------
_COLOR_RE = re.compile(
r"(#[0-9a-fA-F]{3,6}|rgb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\))"
)
def extract_css_pairs(css_text: str) -> list:
"""Extract color / background-color pairs from CSS declarations.
Returns a list of dicts with selector, foreground, and background strings.
"""
pairs = []
# Split into rule blocks
block_re = re.compile(r"([^{}]+)\{([^}]+)\}", re.DOTALL)
for m in block_re.finditer(css_text):
selector = m.group(1).strip()
body = m.group(2)
fg = bg = None
# Match color: ... (but not background-color)
fg_match = re.search(
r"(?<![-])color\s*:\s*([^;]+);", body, re.IGNORECASE
)
bg_match = re.search(
r"background(?:-color)?\s*:\s*([^;]+);", body, re.IGNORECASE
)
if fg_match:
val = fg_match.group(1).strip()
c = _COLOR_RE.search(val)
if c:
fg = c.group(1)
elif val.lower() in NAMED_COLORS:
fg = val.lower()
if bg_match:
val = bg_match.group(1).strip()
c = _COLOR_RE.search(val)
if c:
bg = c.group(1)
elif val.lower() in NAMED_COLORS:
bg = val.lower()
if fg and bg:
pairs.append({"selector": selector, "foreground": fg, "background": bg})
return pairs
# ---------------------------------------------------------------------------
# Output formatting
# ---------------------------------------------------------------------------
def format_result_human(fg_str: str, bg_str: str, ratio: float, results: list) -> str:
"""Format a contrast check result for the terminal."""
lines = [
f"Foreground : {fg_str}",
f"Background : {bg_str}",
f"Contrast : {ratio:.2f}:1",
"",
]
for r in results:
status = "PASS" if r["pass"] else "FAIL"
lines.append(f" [{status}] {r['level']:20s} (requires {r['required']}:1)")
return "\n".join(lines)
def format_suggestions_human(fg_str: str, suggestions: list) -> str:
"""Format suggested backgrounds for the terminal."""
lines = [f"Foreground: {fg_str}", "Suggested accessible backgrounds (AA Normal Text):"]
if not suggestions:
lines.append(" No suggestions found.")
for s in suggestions:
lines.append(f" {s['hex']} ratio={s['ratio']}:1")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Demo
# ---------------------------------------------------------------------------
DEMO_PAIRS = [
("#ffffff", "#000000"),
("#336699", "#ffffff"),
("#ff6600", "#ffffff"),
("navy", "white"),
("rgb(100,100,100)", "#eeeeee"),
]
def run_demo(as_json: bool) -> None:
"""Run demo checks and print results."""
all_results = []
for fg_str, bg_str in DEMO_PAIRS:
fg_rgb = parse_color(fg_str)
bg_rgb = parse_color(bg_str)
ratio = contrast_ratio(fg_rgb, bg_rgb)
results = evaluate_contrast(ratio)
entry = {
"foreground": fg_str,
"background": bg_str,
"foreground_hex": color_to_hex(fg_rgb),
"background_hex": color_to_hex(bg_rgb),
"ratio": round(ratio, 2),
"results": results,
}
all_results.append(entry)
if as_json:
print(json.dumps({"demo": True, "checks": all_results}, indent=2))
else:
print("=" * 60)
print("WCAG 2.2 Contrast Checker - Demo")
print("=" * 60)
for entry in all_results:
print()
print(
format_result_human(
entry["foreground"], entry["background"],
entry["ratio"], entry["results"],
)
)
print()
print("-" * 60)
print("Suggestion demo for foreground #336699:")
suggestions = suggest_backgrounds(parse_color("#336699"))
print(format_suggestions_human("#336699", suggestions))
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="WCAG 2.2 Color Contrast Checker. "
"Checks foreground/background pairs against AA and AAA thresholds.",
epilog="Examples:\n"
" %(prog)s '#ffffff' '#000000'\n"
" %(prog)s --suggest '#336699'\n"
" %(prog)s --batch styles.css\n"
" %(prog)s --demo\n",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"foreground",
nargs="?",
help="Foreground (text) color: #RRGGBB, #RGB, rgb(r,g,b), or named color",
)
parser.add_argument(
"background",
nargs="?",
help="Background color: #RRGGBB, #RGB, rgb(r,g,b), or named color",
)
parser.add_argument(
"--suggest",
metavar="COLOR",
help="Suggest accessible background colors for the given foreground color",
)
parser.add_argument(
"--batch",
metavar="CSS_FILE",
help="Extract color pairs from a CSS file and check each",
)
parser.add_argument(
"--json",
action="store_true",
dest="json_output",
help="Output results as JSON",
)
parser.add_argument(
"--demo",
action="store_true",
help="Show example output with sample color pairs",
)
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
# --demo mode
if args.demo:
run_demo(args.json_output)
return 0
# --suggest mode
if args.suggest:
try:
fg_rgb = parse_color(args.suggest)
except ValueError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
suggestions = suggest_backgrounds(fg_rgb)
if args.json_output:
print(json.dumps({
"foreground": args.suggest,
"foreground_hex": color_to_hex(fg_rgb),
"suggestions": suggestions,
}, indent=2))
else:
print(format_suggestions_human(args.suggest, suggestions))
return 0
# --batch mode
if args.batch:
try:
with open(args.batch, "r", encoding="utf-8") as fh:
css_text = fh.read()
except FileNotFoundError:
print(f"Error: file not found: {args.batch}", file=sys.stderr)
return 1
except OSError as exc:
print(f"Error reading file: {exc}", file=sys.stderr)
return 1
pairs = extract_css_pairs(css_text)
if not pairs:
msg = "No color/background-color pairs found in the CSS file."
if args.json_output:
print(json.dumps({"batch": args.batch, "pairs": [], "message": msg}, indent=2))
else:
print(msg)
return 0
all_results = []
has_failure = False
for pair in pairs:
try:
fg_rgb = parse_color(pair["foreground"])
bg_rgb = parse_color(pair["background"])
except ValueError as exc:
entry = {
"selector": pair["selector"],
"foreground": pair["foreground"],
"background": pair["background"],
"error": str(exc),
}
all_results.append(entry)
continue
ratio = contrast_ratio(fg_rgb, bg_rgb)
results = evaluate_contrast(ratio)
if not results[0]["pass"]: # AA Normal Text
has_failure = True
entry = {
"selector": pair["selector"],
"foreground": pair["foreground"],
"background": pair["background"],
"foreground_hex": color_to_hex(fg_rgb),
"background_hex": color_to_hex(bg_rgb),
"ratio": round(ratio, 2),
"results": results,
}
all_results.append(entry)
if args.json_output:
print(json.dumps({"batch": args.batch, "pairs": all_results}, indent=2))
else:
print(f"Batch check: {args.batch}")
print("=" * 60)
for entry in all_results:
print(f"\nSelector: {entry['selector']}")
if "error" in entry:
print(f" Error: {entry['error']}")
else:
print(
format_result_human(
entry["foreground"], entry["background"],
entry["ratio"], entry["results"],
)
)
print()
summary_pass = sum(1 for e in all_results if "ratio" in e and e["results"][0]["pass"])
summary_total = sum(1 for e in all_results if "ratio" in e)
print(f"Summary: {summary_pass}/{summary_total} pairs pass AA Normal Text")
return 1 if has_failure else 0
# Default: check a single pair
if not args.foreground or not args.background:
parser.error(
"Provide foreground and background colors, or use --suggest, --batch, or --demo."
)
try:
fg_rgb = parse_color(args.foreground)
except ValueError as exc:
print(f"Error (foreground): {exc}", file=sys.stderr)
return 1
try:
bg_rgb = parse_color(args.background)
except ValueError as exc:
print(f"Error (background): {exc}", file=sys.stderr)
return 1
ratio = contrast_ratio(fg_rgb, bg_rgb)
results = evaluate_contrast(ratio)
if args.json_output:
print(json.dumps({
"foreground": args.foreground,
"background": args.background,
"foreground_hex": color_to_hex(fg_rgb),
"background_hex": color_to_hex(bg_rgb),
"ratio": round(ratio, 2),
"results": results,
}, indent=2))
else:
print(format_result_human(args.foreground, args.background, ratio, results))
return 0 if results[0]["pass"] else 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,15 @@
{
"name": "a11y-audit",
"displayName": "A11y Audit",
"version": "2.1.2",
"description": "WCAG 2.2 accessibility audit and fix. Scans React, Next.js, Vue, Angular, Svelte, and HTML for 20+ violation types. Contrast checker with suggest mode. CI-friendly.",
"author": "Alireza Rezvani",
"license": "MIT",
"platforms": ["claude-code", "openclaw", "codex"],
"category": "engineering",
"tags": ["accessibility", "a11y", "wcag", "aria", "screen-reader", "keyboard-navigation", "contrast", "react", "vue", "angular", "nextjs", "svelte"],
"repository": "https://github.com/alirezarezvani/claude-skills",
"commands": {
"a11y-audit": "/a11y-audit"
}
}