wcag-a11y 0.4.2 → 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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![CI](https://github.com/Dannyplusplus12/WCAG-A11y/actions/workflows/ci.yml/badge.svg)](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
@@ -78,13 +78,8 @@ npm install -g wcag-a11y
78
78
  ## Quick start
79
79
 
80
80
  ```bash
81
- # 1. Configure your AI provider and framework (Gemini is free, no credit card)
82
- wcag-a11y init --framework next # Next.js
83
- wcag-a11y init --framework react # React / Vite
84
- wcag-a11y init --framework vue # Vue / Nuxt
85
- wcag-a11y init --framework angular # Angular
86
- wcag-a11y init --framework svelte # Svelte / SvelteKit
87
- wcag-a11y init # plain HTML or auto-detect
81
+ # 1. Configure your AI provider (Gemini is free, no credit card)
82
+ wcag-a11y init
88
83
 
89
84
  # 2. Start your dev server, then scan
90
85
  wcag-a11y scan -u http://localhost:3000
@@ -92,6 +87,12 @@ wcag-a11y scan -u http://localhost:3000
92
87
 
93
88
  Add `--pages / /about /contact` to scan specific routes, or `--crawl` to follow links automatically.
94
89
 
90
+ **Optional — set your framework once:** The tool auto-detects common frameworks at runtime. If detection fails (e.g. scanning a staging URL, or using Astro/SvelteKit), set it in your config so every run uses the right syntax:
91
+
92
+ ```bash
93
+ wcag-a11y init --framework next # or react, vue, angular, svelte, astro, …
94
+ ```
95
+
95
96
  ---
96
97
 
97
98
  ## Commands
@@ -120,8 +121,9 @@ wcag-a11y scan -u http://localhost:3000 --terminal --fast-mode
120
121
  | `--fast-mode` | off | Output only the raw prompts — no summaries or decoration |
121
122
  | `--group <strategy>` | `rule` | `rule`: one prompt per rule type. `none`: one prompt per element |
122
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 |
123
125
  | `--provider <name>` | from config | Override AI provider for this run |
124
- | `--framework <name>` | from config | Override framework for this run (e.g. `next`, `react`, `vue`, `angular`, `svelte`, `astro`) |
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 |
125
127
 
126
128
  ---
127
129
 
@@ -164,34 +166,65 @@ wcag-a11y fix --from-report --apply # patches files from that report, no s
164
166
  | `-c, --crawl` | off | Auto-discover pages by following same-origin links |
165
167
  | `--from-report [path]` | `a11y-report.md` | Load violations from an existing report instead of rescanning |
166
168
  | `--apply` | off | Write fixes to disk (dry-run without this flag) |
169
+ | `--force` | off | Skip the git dirty-state check when using `--apply` |
167
170
  | `--provider <name>` | from config | Override AI provider for this run |
168
- | `--framework <name>` | from config | Override framework for this run (e.g. `next`, `react`, `vue`, `angular`, `svelte`, `astro`) |
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 |
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`).
169
202
 
170
203
  ---
171
204
 
172
205
  ### `wcag-a11y init`
173
206
 
174
- Create `a11y.config.json` pre-configured for your chosen provider and framework.
207
+ Create `a11y.config.json` pre-configured for your chosen provider.
175
208
 
176
209
  ```bash
177
210
  wcag-a11y init # Gemini (free, default)
178
- wcag-a11y init --provider openai --framework next # OpenAI + Next.js
179
- wcag-a11y init --provider ollama --framework react # local Ollama + React
180
- # 12 providers total, any framework string accepted
211
+ wcag-a11y init --provider openai
212
+ wcag-a11y init --provider ollama # local no API key needed
213
+ wcag-a11y init --provider openai --framework next # optional: save framework too
181
214
  ```
182
215
 
183
216
  | Flag | Description |
184
217
  |---|---|
185
218
  | `--provider <name>` | AI provider. Default: `gemini`. See [AI Providers](#ai-providers) for all options |
186
- | `--framework <name>` | Your project framework — saved to config so every scan uses it automatically |
219
+ | `--framework <name>` | *(optional)* Your project framework — saved to config so every scan uses it automatically. The tool auto-detects common frameworks; use this flag when scanning staging URLs or using a framework not in the detection list |
187
220
 
188
- Framework is saved as `"framework"` in `a11y.config.json`. You can also edit the file directly at any time. Supported values for best results: `next`, `react`, `vue`, `nuxt`, `angular`, `svelte`, `gatsby`, `remix`, `astro` — or any free-form string.
221
+ Accepted framework values: `next`, `react`, `vue`, `nuxt`, `angular`, `svelte`, `gatsby`, `remix`, `astro` — or any free-form string. You can also add `"framework": "next"` directly to `a11y.config.json` at any time.
189
222
 
190
223
  ---
191
224
 
192
225
  ### `wcag-a11y demo`
193
226
 
194
- Scan a built-in page with 10 intentional violations. No dev server or config required — useful for trying the tool before pointing it at your own project.
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.
195
228
 
196
229
  ```bash
197
230
  wcag-a11y demo # violations + AI fix prompts (requires config)
@@ -227,6 +260,15 @@ All models are configurable. If the AI response is unparseable, the tool generat
227
260
 
228
261
  Run `wcag-a11y init` to generate `a11y.config.json`. Only fill in the fields for your chosen provider. This file is gitignored by default.
229
262
 
263
+ ```json
264
+ {
265
+ "provider": "gemini",
266
+ "apiKey": "YOUR_GEMINI_API_KEY"
267
+ }
268
+ ```
269
+
270
+ `"framework"` is optional — add it if auto-detection fails for your setup:
271
+
230
272
  ```json
231
273
  {
232
274
  "provider": "gemini",
package/dist/cli.js CHANGED
@@ -13,7 +13,7 @@ const program = new Command();
13
13
  program
14
14
  .name('wcag-a11y')
15
15
  .description('WCAG 2.1/2.2 accessibility auditor with AI-powered fixes')
16
- .version('0.4.2');
16
+ .version('0.4.3');
17
17
  program
18
18
  .command('init')
19
19
  .description('Create a11y.config.json in the current directory')
@@ -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 contrast: fails 4.5:1 -->
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. keyboard: div with click but no role/tabindex -->
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
- <!-- 4. form: input with no label -->
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
- <!-- 5. ARIA: invalid role -->
41
+ <!-- 6. ARIA: invalid role -->
35
42
  <div role="widget" id="promo-banner">Special offer!</div>
36
43
 
37
- <!-- 6. Structure: heading skips h2 → h4 -->
44
+ <!-- 7. Structure: heading skips h2 → h4 -->
38
45
  <h4>Featured Products</h4>
39
46
 
40
- <!-- 7. Link: non-descriptive text -->
47
+ <!-- 8. Link: non-descriptive text -->
41
48
  <a href="/sale">Click here</a>
42
49
 
43
- <!-- 8. Media: video without captions -->
50
+ <!-- 9. Media: video without captions -->
44
51
  <video src="promo.mp4" controls></video>
45
52
 
46
- <!-- 9. ARIA: aria-hidden but focusable -->
53
+ <!-- 10. ARIA: aria-hidden but focusable -->
47
54
  <button aria-hidden="true" tabindex="0">Hidden action</button>
48
55
 
49
- <!-- 10. Link: empty anchor -->
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 fgRaw = style.color;
41
- const bgRaw = style.backgroundColor;
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(+fgMatch[1], +fgMatch[2], +fgMatch[3]);
49
- const l2 = lum(+bgMatch[1], +bgMatch[2], +bgMatch[3]);
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 fgRaw = style.color;
97
- const bgRaw = style.backgroundColor;
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(+fgMatch[1], +fgMatch[2], +fgMatch[3]);
105
- const l2 = lum(+bgMatch[1], +bgMatch[2], +bgMatch[3]);
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.2",
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",