wcag-a11y 0.4.3 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -2
- package/dist/cli.js +4 -1
- package/dist/crawler.js +1 -1
- package/dist/demo.js +17 -10
- package/dist/engine/rules/color-contrast.js +72 -14
- package/dist/fixer.js +14 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/Dannyplusplus12/WCAG-A11y/actions/workflows/ci.yml)
|
|
4
4
|
|
|
5
|
-
Most accessibility auditors stop at detection — they tell you *what* is broken and leave the rest to you. `wcag-a11y` crawls your running dev server with Playwright, runs 40+ WCAG 2.1/2.2 checks, and uses AI to generate ready-to-paste fix prompts **or write the fixes directly into your source files**.
|
|
5
|
+
Most accessibility auditors stop at detection — they tell you *what* is broken and leave the rest to you. `wcag-a11y` crawls your running dev server with Playwright, runs 40+ WCAG 2.1/2.2 checks, and uses AI to generate ready-to-paste fix prompts **or write the fixes directly into your source files**. Works with authenticated apps via Playwright session state.
|
|
6
6
|
|
|
7
7
|
Two modes:
|
|
8
8
|
- **`scan`** — find violations + get AI prompts you paste into Cursor, Copilot, or Claude
|
|
@@ -121,6 +121,7 @@ wcag-a11y scan -u http://localhost:3000 --terminal --fast-mode
|
|
|
121
121
|
| `--fast-mode` | off | Output only the raw prompts — no summaries or decoration |
|
|
122
122
|
| `--group <strategy>` | `rule` | `rule`: one prompt per rule type. `none`: one prompt per element |
|
|
123
123
|
| `--ci` | off | Exit with code `1` if any violations are found |
|
|
124
|
+
| `--auth-state <path>` | — | Path to a Playwright `storageState` JSON. Loads cookies and localStorage so you can scan pages behind a login wall |
|
|
124
125
|
| `--provider <name>` | from config | Override AI provider for this run |
|
|
125
126
|
| `--framework <name>` | from config | *(optional)* Override framework for this run. Auto-detected by default; use this when scanning staging URLs or for frameworks outside the detection list |
|
|
126
127
|
|
|
@@ -165,9 +166,40 @@ wcag-a11y fix --from-report --apply # patches files from that report, no s
|
|
|
165
166
|
| `-c, --crawl` | off | Auto-discover pages by following same-origin links |
|
|
166
167
|
| `--from-report [path]` | `a11y-report.md` | Load violations from an existing report instead of rescanning |
|
|
167
168
|
| `--apply` | off | Write fixes to disk (dry-run without this flag) |
|
|
169
|
+
| `--force` | off | Skip the git dirty-state check when using `--apply` |
|
|
168
170
|
| `--provider <name>` | from config | Override AI provider for this run |
|
|
169
171
|
| `--framework <name>` | from config | *(optional)* Override framework for this run. Auto-detected by default; use this when scanning staging URLs or for frameworks outside the detection list |
|
|
170
172
|
|
|
173
|
+
**Git safety:** `wcag-a11y fix --apply` checks for uncommitted changes before writing anything. If the working tree is dirty it exits with an error — commit or stash first, or pass `--force` to override.
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
### Scanning authenticated pages
|
|
178
|
+
|
|
179
|
+
Most real apps require a login. Save your session with Playwright once, then reuse it on every scan:
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
# 1. Save session (run this once after logging in)
|
|
183
|
+
node -e "
|
|
184
|
+
const { chromium } = require('playwright');
|
|
185
|
+
(async () => {
|
|
186
|
+
const browser = await chromium.launch({ headless: false });
|
|
187
|
+
const context = await browser.newContext();
|
|
188
|
+
const page = await context.newPage();
|
|
189
|
+
await page.goto('http://localhost:3000/login');
|
|
190
|
+
// log in manually in the browser window that opens
|
|
191
|
+
await page.waitForTimeout(30000);
|
|
192
|
+
await context.storageState({ path: 'auth.json' });
|
|
193
|
+
await browser.close();
|
|
194
|
+
})();
|
|
195
|
+
"
|
|
196
|
+
|
|
197
|
+
# 2. Scan with your saved session
|
|
198
|
+
wcag-a11y scan -u http://localhost:3000 --auth-state auth.json --pages / /dashboard /settings
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
`auth.json` captures cookies and `localStorage`. Keep it out of source control (add `auth.json` to `.gitignore`).
|
|
202
|
+
|
|
171
203
|
---
|
|
172
204
|
|
|
173
205
|
### `wcag-a11y init`
|
|
@@ -192,7 +224,7 @@ Accepted framework values: `next`, `react`, `vue`, `nuxt`, `angular`, `svelte`,
|
|
|
192
224
|
|
|
193
225
|
### `wcag-a11y demo`
|
|
194
226
|
|
|
195
|
-
Scan a built-in page with
|
|
227
|
+
Scan a built-in page with 11 intentional violations. No dev server or config required — useful for trying the tool before pointing it at your own project.
|
|
196
228
|
|
|
197
229
|
```bash
|
|
198
230
|
wcag-a11y demo # violations + AI fix prompts (requires config)
|
package/dist/cli.js
CHANGED
|
@@ -35,12 +35,13 @@ program
|
|
|
35
35
|
.option('--fast-mode', 'Output only AI fix prompts — no summaries or explanations', false)
|
|
36
36
|
.option('--group <strategy>', 'Group violations by rule or show individually (rule|none)', 'rule')
|
|
37
37
|
.option('--ci', 'Exit with code 1 if any violations are found (for CI/CD pipelines)', false)
|
|
38
|
+
.option('--auth-state <path>', 'Path to Playwright storageState JSON for authenticated sessions (e.g. auth.json)')
|
|
38
39
|
.option('--provider <name>', 'Override the AI provider from config (gemini|openai|ollama|anthropic|mistral|groq|cohere|xai|deepseek|together|perplexity|azure-openai)')
|
|
39
40
|
.option('--framework <name>', 'Override framework detection for this run (e.g. next, react, vue, angular, svelte, astro)')
|
|
40
41
|
.action(async (opts) => {
|
|
41
42
|
try {
|
|
42
43
|
console.log(`\nScanning ${opts.url}...`);
|
|
43
|
-
const result = await crawl({ url: opts.url, pages: opts.pages, crawl: opts.crawl, framework: opts.framework });
|
|
44
|
+
const result = await crawl({ url: opts.url, pages: opts.pages, crawl: opts.crawl, framework: opts.framework, authState: opts.authState });
|
|
44
45
|
if (opts.terminal && !opts.fastMode) {
|
|
45
46
|
printTerminalReport(result);
|
|
46
47
|
}
|
|
@@ -85,6 +86,7 @@ program
|
|
|
85
86
|
.option('-c, --crawl', 'Auto-discover pages by following same-origin links', false)
|
|
86
87
|
.option('--from-report [path]', 'Use an existing report instead of scanning (default: a11y-report.md)')
|
|
87
88
|
.option('--apply', 'Write fixes to source files (default: dry-run, shows diff only)', false)
|
|
89
|
+
.option('--force', 'Skip git dirty-state check when using --apply', false)
|
|
88
90
|
.option('--provider <name>', 'Override the AI provider from config (gemini|openai|ollama|anthropic|mistral|groq|cohere|xai|deepseek|together|perplexity|azure-openai)')
|
|
89
91
|
.option('--framework <name>', 'Override framework detection for this run (e.g. next, react, vue, angular, svelte, astro)')
|
|
90
92
|
.action(async (opts) => {
|
|
@@ -106,6 +108,7 @@ program
|
|
|
106
108
|
crawl: opts.crawl,
|
|
107
109
|
reportPath,
|
|
108
110
|
apply: opts.apply,
|
|
111
|
+
force: opts.force,
|
|
109
112
|
provider,
|
|
110
113
|
srcDir: resolve(process.cwd(), 'src'),
|
|
111
114
|
framework: opts.framework ?? config.framework,
|
package/dist/crawler.js
CHANGED
|
@@ -43,7 +43,7 @@ export async function crawl(options) {
|
|
|
43
43
|
const baseUrl = url.replace(/\/$/, '');
|
|
44
44
|
const browser = await chromium.launch({ headless: true });
|
|
45
45
|
try {
|
|
46
|
-
const context = await browser.newContext();
|
|
46
|
+
const context = await browser.newContext(options.authState ? { storageState: options.authState } : {});
|
|
47
47
|
let pagesToVisit = pages.map((p) => `${baseUrl}${p}`);
|
|
48
48
|
if (autoCrawl) {
|
|
49
49
|
const discoveredPage = await context.newPage();
|
package/dist/demo.js
CHANGED
|
@@ -17,36 +17,43 @@ const DEMO_HTML = `<!DOCTYPE html>
|
|
|
17
17
|
<!-- 1. img-alt: missing alt -->
|
|
18
18
|
<img src="banner.jpg">
|
|
19
19
|
|
|
20
|
-
<!-- 2. color
|
|
20
|
+
<!-- 2. color-contrast: low contrast on solid background (fails 4.5:1) -->
|
|
21
21
|
<p style="color:#aaa;background:#fff;font-size:14px;">
|
|
22
22
|
Summer sale — up to 50% off selected items.
|
|
23
23
|
</p>
|
|
24
24
|
|
|
25
|
-
<!-- 3.
|
|
25
|
+
<!-- 3. color-contrast: rgba(0,0,0,0) transparent bg — walks up to dark parent -->
|
|
26
|
+
<div style="background:#1a1a2e;padding:8px;">
|
|
27
|
+
<p style="color:#888;background:rgba(0,0,0,0);font-size:14px;">
|
|
28
|
+
Free shipping on orders over $50.
|
|
29
|
+
</p>
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
<!-- 4. keyboard: div with click but no role/tabindex -->
|
|
26
33
|
<div onclick="addToCart()">Add to Cart</div>
|
|
27
34
|
|
|
28
|
-
<!--
|
|
35
|
+
<!-- 5. form: input with no label -->
|
|
29
36
|
<form id="newsletter">
|
|
30
37
|
<input type="email" placeholder="your@email.com">
|
|
31
38
|
<button type="submit"></button>
|
|
32
39
|
</form>
|
|
33
40
|
|
|
34
|
-
<!--
|
|
41
|
+
<!-- 6. ARIA: invalid role -->
|
|
35
42
|
<div role="widget" id="promo-banner">Special offer!</div>
|
|
36
43
|
|
|
37
|
-
<!--
|
|
44
|
+
<!-- 7. Structure: heading skips h2 → h4 -->
|
|
38
45
|
<h4>Featured Products</h4>
|
|
39
46
|
|
|
40
|
-
<!--
|
|
47
|
+
<!-- 8. Link: non-descriptive text -->
|
|
41
48
|
<a href="/sale">Click here</a>
|
|
42
49
|
|
|
43
|
-
<!--
|
|
50
|
+
<!-- 9. Media: video without captions -->
|
|
44
51
|
<video src="promo.mp4" controls></video>
|
|
45
52
|
|
|
46
|
-
<!--
|
|
53
|
+
<!-- 10. ARIA: aria-hidden but focusable -->
|
|
47
54
|
<button aria-hidden="true" tabindex="0">Hidden action</button>
|
|
48
55
|
|
|
49
|
-
<!--
|
|
56
|
+
<!-- 11. Link: empty anchor -->
|
|
50
57
|
<a href="/about"></a>
|
|
51
58
|
|
|
52
59
|
</body>
|
|
@@ -66,7 +73,7 @@ function startDemoServer() {
|
|
|
66
73
|
export async function runDemo(opts) {
|
|
67
74
|
const { server, url } = await startDemoServer();
|
|
68
75
|
try {
|
|
69
|
-
console.log('\nRunning demo scan against a built-in page with intentional WCAG violations...\n');
|
|
76
|
+
console.log('\nRunning demo scan against a built-in page with 11 intentional WCAG violations...\n');
|
|
70
77
|
const result = await crawl({ url, pages: ['/'] });
|
|
71
78
|
printTerminalReport(result);
|
|
72
79
|
if (opts.ai && result.totalViolations > 0) {
|
|
@@ -26,6 +26,37 @@ export const colorContrastRules = [
|
|
|
26
26
|
}
|
|
27
27
|
return parts.join(' > ') || el.tagName.toLowerCase();
|
|
28
28
|
};
|
|
29
|
+
const parseRgba = (raw) => {
|
|
30
|
+
const m = raw.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
|
|
31
|
+
if (!m)
|
|
32
|
+
return null;
|
|
33
|
+
return [+m[1], +m[2], +m[3], m[4] !== undefined ? parseFloat(m[4]) : 1];
|
|
34
|
+
};
|
|
35
|
+
const getOpaqueBg = (el) => {
|
|
36
|
+
let node = el;
|
|
37
|
+
while (node) {
|
|
38
|
+
const rgba = parseRgba(window.getComputedStyle(node).backgroundColor);
|
|
39
|
+
if (rgba && rgba[3] > 0.01)
|
|
40
|
+
return [rgba[0], rgba[1], rgba[2]];
|
|
41
|
+
node = node.parentElement;
|
|
42
|
+
}
|
|
43
|
+
return [255, 255, 255];
|
|
44
|
+
};
|
|
45
|
+
const resolveBackground = (bgRaw, el) => {
|
|
46
|
+
const bgRgba = parseRgba(bgRaw);
|
|
47
|
+
if (!bgRgba || bgRgba[3] <= 0.01)
|
|
48
|
+
return getOpaqueBg(el.parentElement ?? el);
|
|
49
|
+
if (bgRgba[3] < 0.99) {
|
|
50
|
+
const parent = getOpaqueBg(el.parentElement ?? el);
|
|
51
|
+
const a = bgRgba[3];
|
|
52
|
+
return [
|
|
53
|
+
Math.round(bgRgba[0] * a + parent[0] * (1 - a)),
|
|
54
|
+
Math.round(bgRgba[1] * a + parent[1] * (1 - a)),
|
|
55
|
+
Math.round(bgRgba[2] * a + parent[2] * (1 - a)),
|
|
56
|
+
];
|
|
57
|
+
}
|
|
58
|
+
return [bgRgba[0], bgRgba[1], bgRgba[2]];
|
|
59
|
+
};
|
|
29
60
|
const elements = Array.from(document.querySelectorAll('p, span, li, td, th, h1, h2, h3, h4, h5, h6, a, label'));
|
|
30
61
|
const violations = [];
|
|
31
62
|
for (const el of elements) {
|
|
@@ -37,16 +68,14 @@ export const colorContrastRules = [
|
|
|
37
68
|
const isLargeText = fontSize >= 24 || (fontSize >= 18.67 && (fontWeight === 'bold' || parseInt(fontWeight) >= 700));
|
|
38
69
|
if (isLargeText)
|
|
39
70
|
continue;
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
const fgMatch = fgRaw.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
|
|
43
|
-
const bgMatch = bgRaw.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
|
|
44
|
-
if (!fgMatch || !bgMatch)
|
|
71
|
+
const fgRgba = parseRgba(style.color);
|
|
72
|
+
if (!fgRgba)
|
|
45
73
|
continue;
|
|
74
|
+
const [bgR, bgG, bgB] = resolveBackground(style.backgroundColor, el);
|
|
46
75
|
const toLinear = (c) => { const s = c / 255; return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); };
|
|
47
76
|
const lum = (r, g, b) => 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
|
|
48
|
-
const l1 = lum(
|
|
49
|
-
const l2 = lum(
|
|
77
|
+
const l1 = lum(fgRgba[0], fgRgba[1], fgRgba[2]);
|
|
78
|
+
const l2 = lum(bgR, bgG, bgB);
|
|
50
79
|
const ratio = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
|
51
80
|
if (ratio < 4.5) {
|
|
52
81
|
violations.push({ selector: getCssPath(el), html: el.outerHTML.slice(0, 200) });
|
|
@@ -82,6 +111,37 @@ export const colorContrastRules = [
|
|
|
82
111
|
}
|
|
83
112
|
return parts.join(' > ') || el.tagName.toLowerCase();
|
|
84
113
|
};
|
|
114
|
+
const parseRgba = (raw) => {
|
|
115
|
+
const m = raw.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
|
|
116
|
+
if (!m)
|
|
117
|
+
return null;
|
|
118
|
+
return [+m[1], +m[2], +m[3], m[4] !== undefined ? parseFloat(m[4]) : 1];
|
|
119
|
+
};
|
|
120
|
+
const getOpaqueBg = (el) => {
|
|
121
|
+
let node = el;
|
|
122
|
+
while (node) {
|
|
123
|
+
const rgba = parseRgba(window.getComputedStyle(node).backgroundColor);
|
|
124
|
+
if (rgba && rgba[3] > 0.01)
|
|
125
|
+
return [rgba[0], rgba[1], rgba[2]];
|
|
126
|
+
node = node.parentElement;
|
|
127
|
+
}
|
|
128
|
+
return [255, 255, 255];
|
|
129
|
+
};
|
|
130
|
+
const resolveBackground = (bgRaw, el) => {
|
|
131
|
+
const bgRgba = parseRgba(bgRaw);
|
|
132
|
+
if (!bgRgba || bgRgba[3] <= 0.01)
|
|
133
|
+
return getOpaqueBg(el.parentElement ?? el);
|
|
134
|
+
if (bgRgba[3] < 0.99) {
|
|
135
|
+
const parent = getOpaqueBg(el.parentElement ?? el);
|
|
136
|
+
const a = bgRgba[3];
|
|
137
|
+
return [
|
|
138
|
+
Math.round(bgRgba[0] * a + parent[0] * (1 - a)),
|
|
139
|
+
Math.round(bgRgba[1] * a + parent[1] * (1 - a)),
|
|
140
|
+
Math.round(bgRgba[2] * a + parent[2] * (1 - a)),
|
|
141
|
+
];
|
|
142
|
+
}
|
|
143
|
+
return [bgRgba[0], bgRgba[1], bgRgba[2]];
|
|
144
|
+
};
|
|
85
145
|
const elements = Array.from(document.querySelectorAll('p, span, h1, h2, h3, h4, h5, h6'));
|
|
86
146
|
const violations = [];
|
|
87
147
|
for (const el of elements) {
|
|
@@ -93,16 +153,14 @@ export const colorContrastRules = [
|
|
|
93
153
|
const isLargeText = fontSize >= 24 || (fontSize >= 18.67 && (fontWeight === 'bold' || parseInt(fontWeight) >= 700));
|
|
94
154
|
if (!isLargeText)
|
|
95
155
|
continue;
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
const fgMatch = fgRaw.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
|
|
99
|
-
const bgMatch = bgRaw.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
|
|
100
|
-
if (!fgMatch || !bgMatch)
|
|
156
|
+
const fgRgba = parseRgba(style.color);
|
|
157
|
+
if (!fgRgba)
|
|
101
158
|
continue;
|
|
159
|
+
const [bgR, bgG, bgB] = resolveBackground(style.backgroundColor, el);
|
|
102
160
|
const toLinear = (c) => { const s = c / 255; return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); };
|
|
103
161
|
const lum = (r, g, b) => 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
|
|
104
|
-
const l1 = lum(
|
|
105
|
-
const l2 = lum(
|
|
162
|
+
const l1 = lum(fgRgba[0], fgRgba[1], fgRgba[2]);
|
|
163
|
+
const l2 = lum(bgR, bgG, bgB);
|
|
106
164
|
const ratio = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
|
107
165
|
if (ratio < 3) {
|
|
108
166
|
violations.push({ selector: getCssPath(el), html: el.outerHTML.slice(0, 200) });
|
package/dist/fixer.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, readdirSync, existsSync } from 'fs';
|
|
2
|
+
import { execSync } from 'child_process';
|
|
2
3
|
import { join, resolve } from 'path';
|
|
3
4
|
import chalk from 'chalk';
|
|
4
5
|
import { crawl } from './crawler.js';
|
|
@@ -54,6 +55,19 @@ export async function runFix(opts) {
|
|
|
54
55
|
console.log(`Grouped ${locatedCount} violation(s) across ${fileGroups.size} file(s).\n`);
|
|
55
56
|
if (!opts.apply)
|
|
56
57
|
console.log(chalk.gray('Dry-run mode — use --apply to write changes.\n'));
|
|
58
|
+
if (opts.apply && !opts.force) {
|
|
59
|
+
try {
|
|
60
|
+
const dirty = execSync('git status --porcelain', { encoding: 'utf8' }).trim();
|
|
61
|
+
if (dirty) {
|
|
62
|
+
console.error(chalk.red('Error: uncommitted changes detected. Commit or stash them first, or run with --force to override.\n'));
|
|
63
|
+
console.error(chalk.gray(dirty.split('\n').slice(0, 5).join('\n')));
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// not a git repo or git unavailable — proceed without guard
|
|
69
|
+
}
|
|
70
|
+
}
|
|
57
71
|
let modifiedFiles = 0;
|
|
58
72
|
let modifiedViolations = 0;
|
|
59
73
|
for (const [filePath, violations] of fileGroups) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wcag-a11y",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "WCAG 2.1/2.2 accessibility auditor with AI-powered fixes. Crawls your dev server with Playwright, runs 40+ checks, and uses AI (12 providers) to generate fix prompts or patch source files directly.",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "WCAG 2.1/2.2 accessibility auditor with AI-powered fixes. Crawls your dev server with Playwright, runs 40+ checks, and uses AI (12 providers) to generate fix prompts or patch source files directly. Supports authenticated sessions via Playwright storageState.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"wcag",
|
|
7
7
|
"accessibility",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"gemini",
|
|
19
19
|
"openai",
|
|
20
20
|
"anthropic",
|
|
21
|
+
"auth",
|
|
21
22
|
"screen-reader"
|
|
22
23
|
],
|
|
23
24
|
"type": "module",
|