wcag-scanner 1.2.66 → 1.2.67

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
@@ -23,7 +23,7 @@ WCAG Scanner is a powerful accessibility testing tool that helps developers iden
23
23
  ## ✨ Features
24
24
 
25
25
  - **WCAG 2.1 Compliance Scanning**: Checks against A, AA, and AAA conformance levels
26
- - **6 Built-in Rules**: Images, contrast, forms, ARIA, structure, and keyboard accessibility
26
+ - **Fast and Full Presets**: Default fast scans plus optional heavier rules like `backgroundImages`
27
27
  - **React Dev Overlay**: Live in-browser inspector with element highlighting, pinning, and impact filtering
28
28
  - **AI Fix Suggestions**: Paste your Gemini API key in the overlay settings to get instant fix suggestions per violation
29
29
  - **Programmatic API**: Scan HTML strings or local files from Node.js
@@ -60,12 +60,31 @@ initWcagOverlay(); // auto-disabled in production
60
60
  ```ts
61
61
  initWcagOverlay({
62
62
  level: 'AA', // 'A' | 'AA' | 'AAA' — default: 'AA'
63
+ preset: 'fast', // 'fast' | 'full' — default: 'fast'
63
64
  position: 'bottom-right', // 'bottom-right' | 'bottom-left'
64
65
  debounce: 750, // ms to wait after DOM change before rescanning
65
- rules: ['images', 'contrast'], // run a subset of rules only
66
+ rules: ['images', 'backgroundImages', 'contrast'], // explicit rules override preset
66
67
  });
67
68
  ```
68
69
 
70
+ `preset: 'full'` includes the heavier optional checks such as `backgroundImages`. Use `rules` when you want an exact rule list.
71
+
72
+ **Preset Contents**
73
+
74
+ | Preset | Rules included |
75
+ | --- | --- |
76
+ | `fast` | `images`, `contrast`, `forms`, `aria`, `structure`, `keyboard` |
77
+ | `full` | `images`, `contrast`, `forms`, `aria`, `structure`, `keyboard`, `backgroundImages` |
78
+
79
+ You can also import these programmatically:
80
+
81
+ ```ts
82
+ import { RULE_PRESETS, resolveRuleNames } from 'wcag-scanner';
83
+
84
+ console.log(RULE_PRESETS.fast);
85
+ console.log(resolveRuleNames({ preset: 'full' }));
86
+ ```
87
+
69
88
  **Features:**
70
89
  - Hover over a violation to highlight the element on the page
71
90
  - Click to pin the highlight; click again to unpin
@@ -85,11 +104,20 @@ Scan HTML strings or local files from Node.js scripts, CI pipelines, or build to
85
104
  import { scanHtml, scanFile, formatReport, saveReport } from 'wcag-scanner';
86
105
 
87
106
  // Scan an HTML string
88
- const results = await scanHtml('<img src="logo.png">', { level: 'AA' });
107
+ const results = await scanHtml('<img src="logo.png">', { level: 'AA', preset: 'fast' });
89
108
  console.log(`${results.violations.length} violations found`);
90
109
 
91
110
  // Scan a local HTML file
92
- const results = await scanFile('./public/index.html', { level: 'AA' });
111
+ const results = await scanFile('./public/index.html', { level: 'AA', preset: 'full' });
112
+
113
+ // Run an exact subset of rules
114
+ const targeted = await scanHtml('<div style="background-image:url(hero.jpg)"></div>', {
115
+ rules: ['images', 'backgroundImages'],
116
+ });
117
+
118
+ // Or resolve a built-in preset yourself
119
+ // import { RULE_PRESETS } from 'wcag-scanner';
120
+ // const results = await scanHtml(html, { rules: RULE_PRESETS.full });
93
121
 
94
122
  // Generate and save a report
95
123
  const html = formatReport(results, 'html'); // 'html' | 'json' | 'console'
@@ -109,6 +137,7 @@ const app = express();
109
137
  app.use(middleware.express.createMiddleware({
110
138
  enabled: true,
111
139
  level: 'AA',
140
+ preset: 'fast',
112
141
  headerName: 'X-WCAG-Violations', // violation count added to response headers
113
142
  inlineReport: true, // inject a small widget into the HTML response
114
143
  onViolation: (results, req) => {
@@ -116,9 +145,27 @@ app.use(middleware.express.createMiddleware({
116
145
  },
117
146
  }));
118
147
 
148
+ // Switch to preset: 'full' if you also want heavier checks like backgroundImages.
149
+
119
150
  app.get('/', (req, res) => {
120
151
  res.send(`<!DOCTYPE html><html><body><h1>Hello</h1></body></html>`);
121
152
  });
122
153
 
123
154
  app.listen(3000);
124
- ```
155
+ ```
156
+
157
+ ## 📊 Profile Summary
158
+
159
+ Current local synthetic benchmark baseline from the repo profiling scripts:
160
+
161
+ | Rule | Command | Approx. duration |
162
+ | --- | --- | --- |
163
+ | `images` | `npm run profile:images` | `128ms` |
164
+ | `forms` | `npm run profile:forms` | `484ms` |
165
+ | `aria` | `npm run profile:aria` | `398ms` |
166
+ | `contrast` | `npm run profile:contrast` | `1836ms` |
167
+
168
+ Notes:
169
+ - These are synthetic local benchmarks, not production browser traces.
170
+ - `contrast` is currently the main runtime hotspot.
171
+ - `backgroundImages` is intentionally excluded from the default `fast` preset because it is a heavier optional check.
package/dist/index.d.ts CHANGED
@@ -2,6 +2,7 @@ import { WCAGScanner } from './scanner';
2
2
  import { ScannerOptions, ScanResults } from './types';
3
3
  import { ReporterFormat } from './reporters';
4
4
  import middleware from './middleware';
5
+ export { FAST_RULES, FULL_RULES, RULE_PRESETS, resolveRuleNames } from './rules/presets';
5
6
  /**
6
7
  * Scan an HTML string for WCAG violations.
7
8
  */
package/dist/index.js CHANGED
@@ -17,7 +17,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
17
17
  return (mod && mod.__esModule) ? mod : { "default": mod };
18
18
  };
19
19
  Object.defineProperty(exports, "__esModule", { value: true });
20
- exports.middleware = exports.WCAGScanner = void 0;
20
+ exports.middleware = exports.WCAGScanner = exports.resolveRuleNames = exports.RULE_PRESETS = exports.FULL_RULES = exports.FAST_RULES = void 0;
21
21
  exports.scanHtml = scanHtml;
22
22
  exports.scanFile = scanFile;
23
23
  exports.formatReport = formatReport;
@@ -29,6 +29,11 @@ const middleware_1 = __importDefault(require("./middleware"));
29
29
  exports.middleware = middleware_1.default;
30
30
  const fs_1 = __importDefault(require("fs"));
31
31
  const path_1 = __importDefault(require("path"));
32
+ var presets_1 = require("./rules/presets");
33
+ Object.defineProperty(exports, "FAST_RULES", { enumerable: true, get: function () { return presets_1.FAST_RULES; } });
34
+ Object.defineProperty(exports, "FULL_RULES", { enumerable: true, get: function () { return presets_1.FULL_RULES; } });
35
+ Object.defineProperty(exports, "RULE_PRESETS", { enumerable: true, get: function () { return presets_1.RULE_PRESETS; } });
36
+ Object.defineProperty(exports, "resolveRuleNames", { enumerable: true, get: function () { return presets_1.resolveRuleNames; } });
32
37
  /**
33
38
  * Scan an HTML string for WCAG violations.
34
39
  */
@@ -11,13 +11,13 @@ function createMiddleware(options = {}) {
11
11
  const defaultOptions = {
12
12
  enabled: process.env.NODE_ENV !== 'production', // Disable in production by default
13
13
  level: 'AA',
14
- headerName: 'X-WCAG-Violations',
14
+ headerName: undefined,
15
15
  inlineReport: false,
16
16
  ...options
17
17
  };
18
18
  return async function wcagScannerMiddleware(req, res, next) {
19
19
  // Skip if disabled or non-HTML request
20
- if (!defaultOptions.enabled || !shouldProcessRequest(req)) {
20
+ if (!defaultOptions.enabled || !shouldProcessRequest(req) || !needsScanning(defaultOptions)) {
21
21
  return next();
22
22
  }
23
23
  // Store original send method
@@ -25,32 +25,47 @@ function createMiddleware(options = {}) {
25
25
  // Override send method to intercept HTML responses
26
26
  res.send = function (body) {
27
27
  // Only process HTML responses
28
- if (typeof body === 'string' && isHtmlResponse(res)) {
28
+ if (typeof body === 'string' && isHtmlResponse(res) && looksLikeHtmlDocument(body)) {
29
29
  try {
30
30
  // Create scanner
31
31
  const scanner = new index_1.WCAGScanner(defaultOptions);
32
- // Run scan asynchronously (we can't make res.send async)
33
- scanner.loadHTML(body).then(() => {
34
- return scanner.scan();
35
- }).then((results) => {
36
- // Add violation count header
37
- res.setHeader(defaultOptions.headerName || 'X-WCAG-Violations', results.violations.length.toString());
38
- // Call violation handler if provided
32
+ const response = this;
33
+ // If we do not need to mutate the response or set headers, scan after sending.
34
+ if (!requiresBlockingScan(defaultOptions)) {
35
+ void scanner.loadHTML(body)
36
+ .then(() => scanner.scan())
37
+ .then((results) => {
38
+ if (defaultOptions.onViolation && results.violations.length > 0) {
39
+ defaultOptions.onViolation(results, req, res);
40
+ }
41
+ })
42
+ .catch((error) => {
43
+ console.error('Error in WCAG scanner middleware:', error);
44
+ });
45
+ return originalSend.call(response, body);
46
+ }
47
+ // Run scan before sending when we need to add headers or inline report.
48
+ void scanner.loadHTML(body)
49
+ .then(() => scanner.scan())
50
+ .then((results) => {
51
+ let finalBody = body;
52
+ if (defaultOptions.headerName) {
53
+ res.setHeader(defaultOptions.headerName, results.violations.length.toString());
54
+ }
39
55
  if (defaultOptions.onViolation && results.violations.length > 0) {
40
56
  defaultOptions.onViolation(results, req, res);
41
57
  }
42
- // Add inline report if enabled
43
58
  if (defaultOptions.inlineReport && results.violations.length > 0) {
44
- body = insertInlineReport(body, results);
59
+ finalBody = insertInlineReport(body, results);
45
60
  }
46
- // Send modified response
47
- originalSend.call(res, body);
48
- }).catch((error) => {
61
+ res.send = originalSend;
62
+ originalSend.call(response, finalBody);
63
+ })
64
+ .catch((error) => {
49
65
  console.error('Error in WCAG scanner middleware:', error);
50
- // Send original response if there's an error
51
- originalSend.call(res, body);
66
+ res.send = originalSend;
67
+ originalSend.call(response, body);
52
68
  });
53
- // Return a dummy response to prevent Express from sending twice
54
69
  return res;
55
70
  }
56
71
  catch (error) {
@@ -93,6 +108,15 @@ function isHtmlResponse(res) {
93
108
  const contentType = res.get('Content-Type') || '';
94
109
  return contentType.includes('html');
95
110
  }
111
+ function needsScanning(options) {
112
+ return Boolean(options.inlineReport || options.headerName || options.onViolation);
113
+ }
114
+ function requiresBlockingScan(options) {
115
+ return Boolean(options.inlineReport || options.headerName);
116
+ }
117
+ function looksLikeHtmlDocument(body) {
118
+ return /<(html|body|main|div|section|article|img|svg|form|a|button)\b/i.test(body);
119
+ }
96
120
  /**
97
121
  * Insert inline accessibility report into HTML
98
122
  * @param html Original HTML
@@ -1,6 +1,8 @@
1
1
  import React from 'react';
2
+ import { RulePreset } from '../types';
2
3
  export interface WcagDevOverlayProps {
3
4
  level?: 'A' | 'AA' | 'AAA';
5
+ preset?: RulePreset;
4
6
  rules?: string[];
5
7
  position?: 'bottom-right' | 'bottom-left';
6
8
  debounce?: number;
@@ -14,70 +14,159 @@ const IMPACT = {
14
14
  };
15
15
  const theme = (impact) => { var _a; return (_a = IMPACT[impact]) !== null && _a !== void 0 ? _a : { color: '#6b7280', bg: '#f9fafb', border: '#e5e7eb' }; };
16
16
  // ─── Highlight helpers ─────────────────────────────────────────────────────────
17
- // No scrollIntoView on hover — only on pin — to eliminate lag
17
+ const HIGHLIGHT_ID = 'wcag-dev-highlight';
18
+ function getHighlightLayer() {
19
+ let layer = document.getElementById(HIGHLIGHT_ID);
20
+ if (layer)
21
+ return layer;
22
+ layer = document.createElement('div');
23
+ layer.id = HIGHLIGHT_ID;
24
+ layer.setAttribute('aria-hidden', 'true');
25
+ Object.assign(layer.style, {
26
+ position: 'fixed',
27
+ top: '0',
28
+ left: '0',
29
+ width: '0',
30
+ height: '0',
31
+ display: 'none',
32
+ pointerEvents: 'none',
33
+ zIndex: '2147483646',
34
+ borderRadius: '10px',
35
+ boxSizing: 'border-box',
36
+ transition: 'opacity 80ms ease-out, transform 80ms ease-out',
37
+ transform: 'translateZ(0)',
38
+ });
39
+ const label = document.createElement('div');
40
+ label.setAttribute('data-wcag-highlight-label', 'true');
41
+ Object.assign(label.style, {
42
+ position: 'absolute',
43
+ top: '-30px',
44
+ left: '0',
45
+ padding: '4px 8px',
46
+ borderRadius: '999px',
47
+ color: '#fff',
48
+ fontSize: '11px',
49
+ fontWeight: '700',
50
+ letterSpacing: '0.03em',
51
+ boxShadow: '0 6px 18px rgba(15,23,42,0.22)',
52
+ whiteSpace: 'nowrap',
53
+ maxWidth: '280px',
54
+ overflow: 'hidden',
55
+ textOverflow: 'ellipsis',
56
+ });
57
+ layer.appendChild(label);
58
+ document.body.appendChild(layer);
59
+ return layer;
60
+ }
18
61
  let _hovered = null;
19
62
  let _pinned = null;
20
- let _hoverRaf = 0;
21
- function restoreEl(saved) {
22
- saved.el.style.outline = saved.outline;
23
- saved.el.style.boxShadow = saved.shadow;
63
+ let _highlightRaf = 0;
64
+ let _highlightCleanup = null;
65
+ function labelForElement(el) {
66
+ const htmlEl = el;
67
+ const parts = [el.tagName.toLowerCase()];
68
+ if (htmlEl.id)
69
+ parts.push(`#${htmlEl.id}`);
70
+ const classes = typeof htmlEl.className === 'string'
71
+ ? htmlEl.className.trim().split(/\s+/).filter(Boolean).slice(0, 2)
72
+ : [];
73
+ if (classes.length)
74
+ parts.push(`.${classes.join('.')}`);
75
+ return parts.join('');
24
76
  }
25
- function applyOutline(el, color) {
26
- el.style.outline = `2px solid ${color}`;
27
- el.style.boxShadow = `0 0 0 4px ${color}28`;
77
+ function renderHighlight(el, mode) {
78
+ const rect = el.getBoundingClientRect();
79
+ const layer = getHighlightLayer();
80
+ const label = layer.querySelector('[data-wcag-highlight-label="true"]');
81
+ if (rect.width <= 0 || rect.height <= 0) {
82
+ layer.style.display = 'none';
83
+ return;
84
+ }
85
+ const color = mode === 'pinned' ? '#7c3aed' : '#0ea5e9';
86
+ const glow = mode === 'pinned' ? 'rgba(124,58,237,0.35)' : 'rgba(14,165,233,0.28)';
87
+ Object.assign(layer.style, {
88
+ display: 'block',
89
+ top: `${Math.max(rect.top - 4, 0)}px`,
90
+ left: `${Math.max(rect.left - 4, 0)}px`,
91
+ width: `${Math.max(rect.width + 8, 12)}px`,
92
+ height: `${Math.max(rect.height + 8, 12)}px`,
93
+ border: `3px solid ${color}`,
94
+ background: `${color}0d`,
95
+ boxShadow: `0 0 0 4px ${glow}, 0 14px 34px rgba(15,23,42,0.16)`,
96
+ opacity: '1',
97
+ });
98
+ if (label) {
99
+ label.textContent = mode === 'pinned' ? `Pinned ${labelForElement(el)}` : `Inspecting ${labelForElement(el)}`;
100
+ label.style.background = color;
101
+ label.style.top = rect.top < 48 ? `${rect.height + 10}px` : '-30px';
102
+ }
28
103
  }
29
- function hoverEl(el) {
30
- cancelAnimationFrame(_hoverRaf);
31
- _hoverRaf = requestAnimationFrame(() => {
32
- if (_hovered) {
33
- restoreEl(_hovered);
34
- _hovered = null;
104
+ function syncHighlightPosition() {
105
+ cancelAnimationFrame(_highlightRaf);
106
+ _highlightRaf = requestAnimationFrame(() => {
107
+ if (_pinned) {
108
+ renderHighlight(_pinned, 'pinned');
109
+ return;
35
110
  }
36
- if (!el || (_pinned === null || _pinned === void 0 ? void 0 : _pinned.el) === el)
111
+ if (_hovered) {
112
+ renderHighlight(_hovered, 'hover');
37
113
  return;
38
- const h = el;
39
- _hovered = { el: h, outline: h.style.outline, shadow: h.style.boxShadow };
40
- applyOutline(h, '#0ea5e9');
114
+ }
115
+ const layer = document.getElementById(HIGHLIGHT_ID);
116
+ if (layer)
117
+ layer.style.display = 'none';
41
118
  });
42
119
  }
120
+ function ensureHighlightTracking() {
121
+ if (_highlightCleanup)
122
+ return;
123
+ const update = () => syncHighlightPosition();
124
+ window.addEventListener('scroll', update, true);
125
+ window.addEventListener('resize', update);
126
+ _highlightCleanup = () => {
127
+ window.removeEventListener('scroll', update, true);
128
+ window.removeEventListener('resize', update);
129
+ _highlightCleanup = null;
130
+ };
131
+ }
132
+ function maybeStopHighlightTracking() {
133
+ if (_hovered || _pinned || !_highlightCleanup)
134
+ return;
135
+ _highlightCleanup();
136
+ }
137
+ function hoverEl(el) {
138
+ _hovered = !el || _pinned === el ? null : el;
139
+ ensureHighlightTracking();
140
+ syncHighlightPosition();
141
+ }
43
142
  function clearHover() {
44
- cancelAnimationFrame(_hoverRaf);
45
- _hoverRaf = requestAnimationFrame(() => {
46
- if (_hovered) {
47
- restoreEl(_hovered);
48
- _hovered = null;
49
- }
50
- });
143
+ _hovered = null;
144
+ syncHighlightPosition();
145
+ maybeStopHighlightTracking();
51
146
  }
52
147
  /** Returns true if now pinned, false if unpinned */
53
148
  function togglePin(el) {
54
- if ((_hovered === null || _hovered === void 0 ? void 0 : _hovered.el) === el) {
55
- restoreEl(_hovered);
56
- _hovered = null;
57
- }
58
- if ((_pinned === null || _pinned === void 0 ? void 0 : _pinned.el) === el) {
59
- restoreEl(_pinned);
149
+ if (_pinned === el) {
60
150
  _pinned = null;
151
+ syncHighlightPosition();
152
+ maybeStopHighlightTracking();
61
153
  return false;
62
154
  }
63
- if (_pinned)
64
- restoreEl(_pinned);
65
- const h = el;
66
- _pinned = { el: h, outline: h.style.outline, shadow: h.style.boxShadow };
67
- applyOutline(h, '#7c3aed');
68
- h.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
155
+ _hovered = null;
156
+ _pinned = el;
157
+ ensureHighlightTracking();
158
+ syncHighlightPosition();
159
+ el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
69
160
  return true;
70
161
  }
71
162
  function clearAllHighlights() {
72
- cancelAnimationFrame(_hoverRaf);
73
- if (_hovered) {
74
- restoreEl(_hovered);
75
- _hovered = null;
76
- }
77
- if (_pinned) {
78
- restoreEl(_pinned);
79
- _pinned = null;
80
- }
163
+ cancelAnimationFrame(_highlightRaf);
164
+ _hovered = null;
165
+ _pinned = null;
166
+ const layer = document.getElementById(HIGHLIGHT_ID);
167
+ if (layer)
168
+ layer.style.display = 'none';
169
+ maybeStopHighlightTracking();
81
170
  }
82
171
  const ViolationCard = ({ item, pinned, onPin, apiKey }) => {
83
172
  const [expanded, setExpanded] = (0, react_1.useState)(false);
@@ -131,7 +220,7 @@ const ViolationCard = ({ item, pinned, onPin, apiKey }) => {
131
220
  whiteSpace: 'pre-wrap', wordBreak: 'break-all', maxHeight: 100, overflow: 'auto',
132
221
  }, children: suggestion.code })), suggestion.explanation && ((0, jsx_runtime_1.jsx)("p", { style: { margin: '5px 0 0', fontSize: 11, color: '#16a34a', fontStyle: 'italic' }, children: suggestion.explanation })), (0, jsx_runtime_1.jsx)("button", { onClick: () => { setSuggestion(null); setAiState('idle'); }, style: { marginTop: 4, fontSize: 10, color: '#94a3b8', background: 'none', border: 'none', cursor: 'pointer' }, children: "Clear" })] }))] }))] }))] }));
133
222
  };
134
- const SettingsPanel = ({ apiKey, onSave }) => {
223
+ const SettingsPanel = ({ apiKey, preset, onSave, onPresetChange }) => {
135
224
  const [draft, setDraft] = (0, react_1.useState)(apiKey);
136
225
  const [saved, setSaved] = (0, react_1.useState)(false);
137
226
  const save = () => {
@@ -149,10 +238,14 @@ const SettingsPanel = ({ apiKey, onSave }) => {
149
238
  save(); } }), (0, jsx_runtime_1.jsxs)("div", { style: { marginTop: 10, display: 'flex', gap: 8, alignItems: 'center' }, children: [(0, jsx_runtime_1.jsx)("button", { onClick: save, style: {
150
239
  background: '#7c3aed', color: '#fff', border: 'none', borderRadius: 6,
151
240
  padding: '6px 14px', fontSize: 12, fontWeight: 600, cursor: 'pointer',
152
- }, children: saved ? '✓ Saved' : 'Save key' }), draft && ((0, jsx_runtime_1.jsx)("button", { onClick: () => { setDraft(''); (0, gemini_1.setStoredApiKey)(''); onSave(''); }, style: { fontSize: 11, color: '#94a3b8', background: 'none', border: 'none', cursor: 'pointer' }, children: "Clear" }))] }), (0, jsx_runtime_1.jsxs)("p", { style: { marginTop: 16, fontSize: 10, color: '#9ca3af', lineHeight: 1.6 }, children: ["Get a free key at", ' ', (0, jsx_runtime_1.jsx)("span", { style: { color: '#7c3aed', fontWeight: 600 }, children: "aistudio.google.com" }), ' ', "\u2192 Get API key. The free tier is sufficient for development use."] })] }));
241
+ }, children: saved ? '✓ Saved' : 'Save key' }), draft && ((0, jsx_runtime_1.jsx)("button", { onClick: () => { setDraft(''); (0, gemini_1.setStoredApiKey)(''); onSave(''); }, style: { fontSize: 11, color: '#94a3b8', background: 'none', border: 'none', cursor: 'pointer' }, children: "Clear" }))] }), (0, jsx_runtime_1.jsxs)("p", { style: { marginTop: 16, fontSize: 10, color: '#9ca3af', lineHeight: 1.6 }, children: ["Get a free key at", ' ', (0, jsx_runtime_1.jsx)("span", { style: { color: '#7c3aed', fontWeight: 600 }, children: "aistudio.google.com" }), ' ', "\u2192 Get API key. The free tier is sufficient for development use."] }), (0, jsx_runtime_1.jsxs)("div", { style: { marginTop: 18, paddingTop: 14, borderTop: '1px solid #e2e8f0' }, children: [(0, jsx_runtime_1.jsx)("label", { style: { display: 'block', fontSize: 11, fontWeight: 600, color: '#374151', marginBottom: 4 }, children: "Scan Preset" }), (0, jsx_runtime_1.jsxs)("select", { value: preset, onChange: e => onPresetChange(e.target.value), style: {
242
+ width: '100%', boxSizing: 'border-box', fontSize: 12,
243
+ border: '1px solid #d1d5db', borderRadius: 6, padding: '7px 10px',
244
+ outline: 'none', background: '#f9fafb', color: '#111827',
245
+ }, children: [(0, jsx_runtime_1.jsx)("option", { value: "fast", children: "Fast" }), (0, jsx_runtime_1.jsx)("option", { value: "full", children: "Full" })] }), (0, jsx_runtime_1.jsx)("p", { style: { margin: '8px 0 0', fontSize: 10, color: '#64748b', lineHeight: 1.6 }, children: "`fast` runs the default rule set. `full` also includes heavier checks like `backgroundImages`." })] })] }));
153
246
  };
154
247
  // ─── Main Overlay ──────────────────────────────────────────────────────────────
155
- const WcagDevOverlay = ({ level = 'AA', rules, position = 'bottom-right', debounce = 750, }) => {
248
+ const WcagDevOverlay = ({ level = 'AA', preset = 'fast', rules, position = 'bottom-right', debounce = 750, }) => {
156
249
  var _a, _b, _c;
157
250
  const [open, setOpen] = (0, react_1.useState)(() => { try {
158
251
  return sessionStorage.getItem('wcag-open') === '1';
@@ -168,6 +261,7 @@ const WcagDevOverlay = ({ level = 'AA', rules, position = 'bottom-right', deboun
168
261
  const [lastScan, setLastScan] = (0, react_1.useState)(null);
169
262
  const [pinnedEl, setPinnedEl] = (0, react_1.useState)(null);
170
263
  const [apiKey, setApiKey] = (0, react_1.useState)(() => (0, gemini_1.getStoredApiKey)());
264
+ const [activePreset, setActivePreset] = (0, react_1.useState)(preset);
171
265
  // Drag
172
266
  const [pos, setPos] = (0, react_1.useState)(null);
173
267
  const dragging = (0, react_1.useRef)(false);
@@ -177,6 +271,8 @@ const WcagDevOverlay = ({ level = 'AA', rules, position = 'bottom-right', deboun
177
271
  const overlayRef = (0, react_1.useRef)(null);
178
272
  const scanningRef = (0, react_1.useRef)(false);
179
273
  const cooldownRef = (0, react_1.useRef)(false);
274
+ const pendingScanRef = (0, react_1.useRef)(false);
275
+ const scanTokenRef = (0, react_1.useRef)(0);
180
276
  // ── Persist open state ────────────────────────────────────────────────────
181
277
  (0, react_1.useEffect)(() => {
182
278
  try {
@@ -188,23 +284,38 @@ const WcagDevOverlay = ({ level = 'AA', rules, position = 'bottom-right', deboun
188
284
  }, [open]);
189
285
  // ── Scan ──────────────────────────────────────────────────────────────────
190
286
  const scan = (0, react_1.useCallback)(async () => {
191
- if (scanningRef.current)
287
+ if (timerRef.current) {
288
+ clearTimeout(timerRef.current);
289
+ timerRef.current = null;
290
+ }
291
+ if (scanningRef.current) {
292
+ pendingScanRef.current = true;
192
293
  return;
294
+ }
295
+ const token = ++scanTokenRef.current;
193
296
  // Set cooldown BEFORE clearing scanningRef to close the observer gap
194
297
  cooldownRef.current = true;
195
298
  scanningRef.current = true;
196
299
  setScanning(true);
197
300
  try {
198
- const res = await (0, browserScanner_1.scanBrowserPage)({ level, rules });
199
- setResults(res);
200
- setLastScan(new Date());
301
+ const res = await (0, browserScanner_1.scanBrowserPage)({ level, preset: activePreset, rules });
302
+ if (token === scanTokenRef.current) {
303
+ setResults(res);
304
+ setLastScan(new Date());
305
+ }
201
306
  }
202
307
  finally {
203
308
  scanningRef.current = false;
204
309
  setScanning(false);
205
- setTimeout(() => { cooldownRef.current = false; }, 1200);
310
+ window.setTimeout(() => {
311
+ cooldownRef.current = false;
312
+ if (pendingScanRef.current) {
313
+ pendingScanRef.current = false;
314
+ void scan();
315
+ }
316
+ }, 300);
206
317
  }
207
- }, [level, rules]);
318
+ }, [activePreset, level, rules]);
208
319
  (0, react_1.useEffect)(() => { scan(); }, [scan]);
209
320
  // ── MutationObserver ──────────────────────────────────────────────────────
210
321
  (0, react_1.useEffect)(() => {
@@ -215,7 +326,10 @@ const WcagDevOverlay = ({ level = 'AA', rules, position = 'bottom-right', deboun
215
326
  return;
216
327
  if (timerRef.current)
217
328
  clearTimeout(timerRef.current);
218
- timerRef.current = setTimeout(scan, debounce);
329
+ timerRef.current = setTimeout(() => {
330
+ timerRef.current = null;
331
+ void scan();
332
+ }, debounce);
219
333
  });
220
334
  observerRef.current.observe(document.body, {
221
335
  childList: true, subtree: true, attributes: true,
@@ -317,7 +431,7 @@ const WcagDevOverlay = ({ level = 'AA', rules, position = 'bottom-right', deboun
317
431
  }, onMouseDown: onDragStart, children: [(0, jsx_runtime_1.jsx)("span", { style: { fontSize: 14 }, children: "\u267F" }), (0, jsx_runtime_1.jsx)("span", { style: { fontWeight: 700, fontSize: 13, flex: 1, color: '#f1f5f9' }, children: "WCAG Inspector" }), (0, jsx_runtime_1.jsx)("span", { style: {
318
432
  fontSize: 9, fontWeight: 700, color: '#a78bfa',
319
433
  background: '#4c1d95', borderRadius: 4, padding: '2px 6px', letterSpacing: '0.05em',
320
- }, children: level }), (0, jsx_runtime_1.jsx)("button", { onClick: () => setView(v => v === 'settings' ? 'list' : 'settings'), style: viewActive('settings'), title: "Settings", children: "\u2699" }), (0, jsx_runtime_1.jsx)("button", { onClick: scan, disabled: scanning, title: "Rescan", style: iconBtnStyle, children: scanning ? '⏳' : '↻' }), (0, jsx_runtime_1.jsx)("button", { onClick: () => setOpen(false), title: "Close (Alt+Shift+W)", style: iconBtnStyle, children: "\u2715" })] }), view === 'settings' ? ((0, jsx_runtime_1.jsx)(SettingsPanel, { apiKey: apiKey, onSave: k => setApiKey(k) })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)("div", { style: { display: 'flex', gap: 6, padding: '8px 12px', borderBottom: '1px solid #f1f5f9', background: '#f8fafc', flexShrink: 0 }, children: [(0, jsx_runtime_1.jsxs)("span", { style: chip('#dc2626', vCount), children: ["\u2717 ", vCount, " violation", vCount !== 1 ? 's' : ''] }), (0, jsx_runtime_1.jsxs)("span", { style: chip('#ea580c', wCount), children: ["\u26A0 ", wCount, " warning", wCount !== 1 ? 's' : ''] }), (0, jsx_runtime_1.jsxs)("span", { style: chip('#16a34a', pCount), children: ["\u2713 ", pCount, " pass", pCount !== 1 ? 'es' : ''] }), apiKey && (0, jsx_runtime_1.jsx)("span", { style: { ...chip('#7c3aed', 1), marginLeft: 'auto' }, title: "AI suggestions enabled", children: "\u2728 AI" })] }), (0, jsx_runtime_1.jsxs)("div", { style: { display: 'flex', alignItems: 'center', borderBottom: '1px solid #f1f5f9', padding: '0 12px', gap: 2, flexShrink: 0 }, children: [(0, jsx_runtime_1.jsxs)("button", { style: tabBtn(tab === 'violations'), onClick: () => setTab('violations'), children: ["Violations (", vCount, ")"] }), (0, jsx_runtime_1.jsxs)("button", { style: tabBtn(tab === 'warnings'), onClick: () => setTab('warnings'), children: ["Warnings (", wCount, ")"] }), (0, jsx_runtime_1.jsxs)("select", { style: { marginLeft: 'auto', fontSize: 10, border: '1px solid #e2e8f0', borderRadius: 5, padding: '3px 6px', color: '#475569', background: '#fff', cursor: 'pointer', outline: 'none' }, value: filter, onChange: e => setFilter(e.target.value), children: [(0, jsx_runtime_1.jsx)("option", { value: "all", children: "All" }), (0, jsx_runtime_1.jsx)("option", { value: "critical", children: "Critical" }), (0, jsx_runtime_1.jsx)("option", { value: "serious", children: "Serious" }), (0, jsx_runtime_1.jsx)("option", { value: "moderate", children: "Moderate" }), (0, jsx_runtime_1.jsx)("option", { value: "minor", children: "Minor" })] })] }), (0, jsx_runtime_1.jsxs)("div", { style: { flex: 1, overflowY: 'auto', padding: '10px' }, children: [scanning && !results && ((0, jsx_runtime_1.jsxs)("div", { style: { textAlign: 'center', color: '#94a3b8', padding: '28px 0', fontSize: 12 }, children: [(0, jsx_runtime_1.jsx)("div", { style: { fontSize: 22, marginBottom: 8 }, children: "\u23F3" }), "Scanning\u2026"] })), !scanning && isEmpty && ((0, jsx_runtime_1.jsxs)("div", { style: { textAlign: 'center', padding: '28px 0' }, children: [(0, jsx_runtime_1.jsx)("div", { style: { fontSize: 28, marginBottom: 8 }, children: tab === 'violations' ? '✅' : '🔕' }), (0, jsx_runtime_1.jsxs)("div", { style: { color: '#64748b', fontSize: 12, fontWeight: 500 }, children: ["No ", tab, " found"] }), filter !== 'all' && ((0, jsx_runtime_1.jsx)("button", { style: { marginTop: 6, fontSize: 11, color: '#7c3aed', background: 'none', border: 'none', cursor: 'pointer' }, onClick: () => setFilter('all'), children: "Clear filter" }))] })), items === null || items === void 0 ? void 0 : items.map((item, i) => ((0, jsx_runtime_1.jsx)(ViolationCard, { item: item, pinned: pinnedEl === item.domElement && item.domElement != null, onPin: handlePin, apiKey: apiKey }, `${item.rule}-${i}`)))] }), (0, jsx_runtime_1.jsxs)("div", { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '7px 12px', borderTop: '1px solid #f1f5f9', background: '#f8fafc', fontSize: 10, color: '#94a3b8', flexShrink: 0 }, children: [(0, jsx_runtime_1.jsxs)("span", { children: [pinnedEl ? '📍 pinned · ' : '', "Scanned ", elapsed, results ? ` · ${results.duration}ms` : ''] }), (0, jsx_runtime_1.jsxs)("div", { style: { display: 'flex', gap: 6, alignItems: 'center' }, children: [pinnedEl && ((0, jsx_runtime_1.jsx)("button", { style: { ...rescanBtn, background: '#64748b' }, onClick: () => { clearAllHighlights(); setPinnedEl(null); }, children: "Unpin" })), (0, jsx_runtime_1.jsx)("button", { style: rescanBtn, onClick: scan, disabled: scanning, children: scanning ? 'Scanning…' : 'Rescan' })] })] })] }))] })), (0, jsx_runtime_1.jsxs)("button", { style: {
434
+ }, children: level }), (0, jsx_runtime_1.jsx)("button", { onClick: () => setView(v => v === 'settings' ? 'list' : 'settings'), style: viewActive('settings'), title: "Settings", children: "\u2699" }), (0, jsx_runtime_1.jsx)("button", { onClick: scan, disabled: scanning, title: "Rescan", style: iconBtnStyle, children: scanning ? '⏳' : '↻' }), (0, jsx_runtime_1.jsx)("button", { onClick: () => setOpen(false), title: "Close (Alt+Shift+W)", style: iconBtnStyle, children: "\u2715" })] }), view === 'settings' ? ((0, jsx_runtime_1.jsx)(SettingsPanel, { apiKey: apiKey, preset: activePreset, onSave: k => setApiKey(k), onPresetChange: setActivePreset })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)("div", { style: { display: 'flex', gap: 6, padding: '8px 12px', borderBottom: '1px solid #f1f5f9', background: '#f8fafc', flexShrink: 0 }, children: [(0, jsx_runtime_1.jsxs)("span", { style: chip('#dc2626', vCount), children: ["\u2717 ", vCount, " violation", vCount !== 1 ? 's' : ''] }), (0, jsx_runtime_1.jsxs)("span", { style: chip('#ea580c', wCount), children: ["\u26A0 ", wCount, " warning", wCount !== 1 ? 's' : ''] }), (0, jsx_runtime_1.jsxs)("span", { style: chip('#16a34a', pCount), children: ["\u2713 ", pCount, " pass", pCount !== 1 ? 'es' : ''] }), apiKey && (0, jsx_runtime_1.jsx)("span", { style: { ...chip('#7c3aed', 1), marginLeft: 'auto' }, title: "AI suggestions enabled", children: "\u2728 AI" })] }), (0, jsx_runtime_1.jsxs)("div", { style: { display: 'flex', alignItems: 'center', borderBottom: '1px solid #f1f5f9', padding: '0 12px', gap: 2, flexShrink: 0 }, children: [(0, jsx_runtime_1.jsxs)("button", { style: tabBtn(tab === 'violations'), onClick: () => setTab('violations'), children: ["Violations (", vCount, ")"] }), (0, jsx_runtime_1.jsxs)("button", { style: tabBtn(tab === 'warnings'), onClick: () => setTab('warnings'), children: ["Warnings (", wCount, ")"] }), (0, jsx_runtime_1.jsxs)("select", { style: { marginLeft: 'auto', fontSize: 10, border: '1px solid #e2e8f0', borderRadius: 5, padding: '3px 6px', color: '#475569', background: '#fff', cursor: 'pointer', outline: 'none' }, value: filter, onChange: e => setFilter(e.target.value), children: [(0, jsx_runtime_1.jsx)("option", { value: "all", children: "All" }), (0, jsx_runtime_1.jsx)("option", { value: "critical", children: "Critical" }), (0, jsx_runtime_1.jsx)("option", { value: "serious", children: "Serious" }), (0, jsx_runtime_1.jsx)("option", { value: "moderate", children: "Moderate" }), (0, jsx_runtime_1.jsx)("option", { value: "minor", children: "Minor" })] })] }), (0, jsx_runtime_1.jsxs)("div", { style: { flex: 1, overflowY: 'auto', padding: '10px' }, children: [scanning && !results && ((0, jsx_runtime_1.jsxs)("div", { style: { textAlign: 'center', color: '#94a3b8', padding: '28px 0', fontSize: 12 }, children: [(0, jsx_runtime_1.jsx)("div", { style: { fontSize: 22, marginBottom: 8 }, children: "\u23F3" }), "Scanning\u2026"] })), !scanning && isEmpty && ((0, jsx_runtime_1.jsxs)("div", { style: { textAlign: 'center', padding: '28px 0' }, children: [(0, jsx_runtime_1.jsx)("div", { style: { fontSize: 28, marginBottom: 8 }, children: tab === 'violations' ? '✅' : '🔕' }), (0, jsx_runtime_1.jsxs)("div", { style: { color: '#64748b', fontSize: 12, fontWeight: 500 }, children: ["No ", tab, " found"] }), filter !== 'all' && ((0, jsx_runtime_1.jsx)("button", { style: { marginTop: 6, fontSize: 11, color: '#7c3aed', background: 'none', border: 'none', cursor: 'pointer' }, onClick: () => setFilter('all'), children: "Clear filter" }))] })), items === null || items === void 0 ? void 0 : items.map((item, i) => ((0, jsx_runtime_1.jsx)(ViolationCard, { item: item, pinned: pinnedEl === item.domElement && item.domElement != null, onPin: handlePin, apiKey: apiKey }, `${item.rule}-${i}`)))] }), (0, jsx_runtime_1.jsxs)("div", { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '7px 12px', borderTop: '1px solid #f1f5f9', background: '#f8fafc', fontSize: 10, color: '#94a3b8', flexShrink: 0 }, children: [(0, jsx_runtime_1.jsxs)("span", { children: [pinnedEl ? '📍 pinned · ' : '', "Scanned ", elapsed, results ? ` · ${results.duration}ms` : ''] }), (0, jsx_runtime_1.jsxs)("div", { style: { display: 'flex', gap: 6, alignItems: 'center' }, children: [pinnedEl && ((0, jsx_runtime_1.jsx)("button", { style: { ...rescanBtn, background: '#64748b' }, onClick: () => { clearAllHighlights(); setPinnedEl(null); }, children: "Unpin" })), (0, jsx_runtime_1.jsx)("button", { style: rescanBtn, onClick: scan, disabled: scanning, children: scanning ? 'Scanning…' : 'Rescan' })] })] })] }))] })), (0, jsx_runtime_1.jsxs)("button", { style: {
321
435
  display: 'flex', alignItems: 'center', gap: 6,
322
436
  background: btnBg, color: '#fff', border: 'none', borderRadius: 22,
323
437
  padding: '8px 14px', fontSize: 12, fontWeight: 600, cursor: 'pointer',
@@ -24,3 +24,5 @@ export declare function scanBrowserPage(options?: ScannerOptions): Promise<Brows
24
24
  export declare function getNthChildSelector(el: Element): string;
25
25
  /** Build a human-readable breadcrumb label for an element. */
26
26
  export declare function getElementPath(el: Element): string;
27
+ export declare function findElement(item: Violation | Warning, doc: Document): Element | null;
28
+ export declare function findBySnippet(snippet: string, doc: Document): Element | null;