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 +52 -5
- package/dist/index.d.ts +1 -0
- package/dist/index.js +6 -1
- package/dist/middleware/express.js +42 -18
- package/dist/react/WcagDevOverlay.d.ts +2 -0
- package/dist/react/WcagDevOverlay.js +171 -57
- package/dist/react/browserScanner.d.ts +2 -0
- package/dist/react/browserScanner.js +67 -17
- package/dist/react/index.d.ts +1 -0
- package/dist/react/index.js +6 -1
- package/dist/rules/backgroundImages.d.ts +8 -0
- package/dist/rules/backgroundImages.js +116 -0
- package/dist/rules/contrast.js +52 -23
- package/dist/rules/images.js +0 -91
- package/dist/rules/presets.d.ts +8 -0
- package/dist/rules/presets.js +19 -0
- package/dist/scanner.js +14 -12
- package/dist/types/index.d.ts +3 -0
- package/package.json +15 -6
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
|
-
- **
|
|
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'], //
|
|
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:
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
59
|
+
finalBody = insertInlineReport(body, results);
|
|
45
60
|
}
|
|
46
|
-
|
|
47
|
-
originalSend.call(
|
|
48
|
-
})
|
|
61
|
+
res.send = originalSend;
|
|
62
|
+
originalSend.call(response, finalBody);
|
|
63
|
+
})
|
|
64
|
+
.catch((error) => {
|
|
49
65
|
console.error('Error in WCAG scanner middleware:', error);
|
|
50
|
-
|
|
51
|
-
originalSend.call(
|
|
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
|
|
@@ -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
|
-
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
30
|
-
cancelAnimationFrame(
|
|
31
|
-
|
|
32
|
-
if (
|
|
33
|
-
|
|
34
|
-
|
|
104
|
+
function syncHighlightPosition() {
|
|
105
|
+
cancelAnimationFrame(_highlightRaf);
|
|
106
|
+
_highlightRaf = requestAnimationFrame(() => {
|
|
107
|
+
if (_pinned) {
|
|
108
|
+
renderHighlight(_pinned, 'pinned');
|
|
109
|
+
return;
|
|
35
110
|
}
|
|
36
|
-
if (
|
|
111
|
+
if (_hovered) {
|
|
112
|
+
renderHighlight(_hovered, 'hover');
|
|
37
113
|
return;
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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 (
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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(
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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 (
|
|
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
|
-
|
|
200
|
-
|
|
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(() => {
|
|
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(
|
|
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;
|