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 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
@@ -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 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.
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 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.3",
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",