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:
84
commands/a11y-audit.md
Normal file
84
commands/a11y-audit.md
Normal 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`
|
||||||
10
engineering-team/a11y-audit/.claude-plugin/plugin.json
Normal file
10
engineering-team/a11y-audit/.claude-plugin/plugin.json
Normal 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": "./"
|
||||||
|
}
|
||||||
46
engineering-team/a11y-audit/README.md
Normal file
46
engineering-team/a11y-audit/README.md
Normal 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
|
||||||
1374
engineering-team/a11y-audit/SKILL.md
Normal file
1374
engineering-team/a11y-audit/SKILL.md
Normal file
File diff suppressed because it is too large
Load Diff
51
engineering-team/a11y-audit/assets/sample-component.tsx
Normal file
51
engineering-team/a11y-audit/assets/sample-component.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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'}`
|
||||||
223
engineering-team/a11y-audit/references/aria-patterns.md
Normal file
223
engineering-team/a11y-audit/references/aria-patterns.md
Normal 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"` |
|
||||||
@@ -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>© 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;
|
||||||
|
}
|
||||||
|
```
|
||||||
113
engineering-team/a11y-audit/references/wcag-quick-ref.md
Normal file
113
engineering-team/a11y-audit/references/wcag-quick-ref.md
Normal 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` |
|
||||||
684
engineering-team/a11y-audit/scripts/a11y_scanner.py
Normal file
684
engineering-team/a11y-audit/scripts/a11y_scanner.py
Normal 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()
|
||||||
499
engineering-team/a11y-audit/scripts/contrast_checker.py
Normal file
499
engineering-team/a11y-audit/scripts/contrast_checker.py
Normal 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())
|
||||||
15
engineering-team/a11y-audit/settings.json
Normal file
15
engineering-team/a11y-audit/settings.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user