ultra11y 5.5.4 → 5.6.1
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.opencode/plugins/ultra11y.js +1 -1
- package/dist/cypress-plugin.d.ts +1 -1
- package/dist/cypress.d.ts +2 -2
- package/dist/cypress.mjs +6 -2
- package/dist/payload-BshGXnOO.d.ts +68 -0
- package/dist/playwright.d.ts +20 -1
- package/dist/playwright.mjs +290 -3
- package/package.json +1 -1
- package/scripts/ultra11y.mjs +328 -262
- package/skills/review-a11y/SKILL.md +1 -1
- package/skills/review-a11y/scripts/ultra11y.mjs +328 -262
- package/skills/ultra11y/SKILL.md +1 -1
- package/skills/ultra11y/scripts/ultra11y.mjs +328 -262
- package/src/audit.ts +77 -1
- package/src/cli.ts +25 -2
- package/src/integrations/payload.ts +22 -0
- package/src/integrations/playwright.ts +34 -1
- package/src/probes.ts +374 -0
- package/src/scan-local.ts +18 -215
- package/src/snapshot.ts +19 -0
- package/src/types.ts +12 -1
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"name": "ultra11y",
|
|
11
11
|
"source": "./",
|
|
12
12
|
"description": "Automatic accessibility review of the code under change before every commit, push and pull request, plus full WCAG 2.2 AA / RGAA audits and conformance deliverables on demand.",
|
|
13
|
-
"version": "5.
|
|
13
|
+
"version": "5.6.1",
|
|
14
14
|
"author": { "name": "maxgfr" },
|
|
15
15
|
"homepage": "https://github.com/maxgfr/ultra11y",
|
|
16
16
|
"repository": "https://github.com/maxgfr/ultra11y",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ultra11y",
|
|
3
3
|
"description": "Accessibility for WCAG 2.2 AA and country standards (RGAA…): an automatic review of the code under change before every commit, push and pull request, plus full audits and conformance deliverables on demand.",
|
|
4
|
-
"version": "5.
|
|
4
|
+
"version": "5.6.1",
|
|
5
5
|
"author": { "name": "maxgfr" },
|
|
6
6
|
"homepage": "https://github.com/maxgfr/ultra11y",
|
|
7
7
|
"repository": "https://github.com/maxgfr/ultra11y",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ultra11y",
|
|
3
3
|
"description": "Accessibility for WCAG 2.2 AA and country standards (RGAA…): an automatic review of the code under change before every commit, push and pull request, plus full audits and conformance deliverables on demand.",
|
|
4
|
-
"version": "5.
|
|
4
|
+
"version": "5.6.1",
|
|
5
5
|
"author": { "name": "maxgfr", "url": "https://github.com/maxgfr" },
|
|
6
6
|
"homepage": "https://github.com/maxgfr/ultra11y",
|
|
7
7
|
"repository": "https://github.com/maxgfr/ultra11y",
|
|
@@ -27,7 +27,7 @@ import { fileURLToPath } from "node:url";
|
|
|
27
27
|
// Ownership marker: `ultra11y install --opencode` refuses to overwrite a plugin file that
|
|
28
28
|
// does not carry it, so a user's own ultra11y.js is never clobbered.
|
|
29
29
|
const ULTRA11Y_OPENCODE_PLUGIN = "ULTRA11Y_OPENCODE_PLUGIN";
|
|
30
|
-
const ULTRA11Y_PLUGIN_VERSION = "5.
|
|
30
|
+
const ULTRA11Y_PLUGIN_VERSION = "5.6.1";
|
|
31
31
|
|
|
32
32
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
33
33
|
|
package/dist/cypress-plugin.d.ts
CHANGED
package/dist/cypress.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { C as CheckOptions } from './payload-
|
|
2
|
-
export { s as slugify } from './payload-
|
|
1
|
+
import { C as CheckOptions } from './payload-BshGXnOO.js';
|
|
2
|
+
export { s as slugify } from './payload-BshGXnOO.js';
|
|
3
3
|
|
|
4
4
|
interface CypressCheckOptions extends CheckOptions {
|
|
5
5
|
/** Capture a viewport screenshot so the pixel tier can run. On by default — the plugin
|
package/dist/cypress.mjs
CHANGED
|
@@ -155,7 +155,7 @@ function stayedOnPage(expected, actual) {
|
|
|
155
155
|
if (a === void 0 || b === void 0) return true;
|
|
156
156
|
return a === b;
|
|
157
157
|
}
|
|
158
|
-
function buildPayload(collected, url, runner, opts, screenshot) {
|
|
158
|
+
function buildPayload(collected, url, runner, opts, screenshot, probes) {
|
|
159
159
|
const id = opts.as || slugify(url);
|
|
160
160
|
return {
|
|
161
161
|
meta: {
|
|
@@ -174,7 +174,11 @@ function buildPayload(collected, url, runner, opts, screenshot) {
|
|
|
174
174
|
styles: collected.styles,
|
|
175
175
|
boxes: collected.boxes,
|
|
176
176
|
css: collected.css,
|
|
177
|
-
...screenshot ? { screenshot } : {}
|
|
177
|
+
...screenshot ? { screenshot } : {},
|
|
178
|
+
// What the live probes measured, when the caller asked for them. It rides in the payload
|
|
179
|
+
// because `snapshot write` persists it beside the DOM — the audit that folds it runs later
|
|
180
|
+
// and in another process, so a measurement kept in memory decides nothing.
|
|
181
|
+
...probes ? { probes } : {}
|
|
178
182
|
};
|
|
179
183
|
}
|
|
180
184
|
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
interface FindingLike {
|
|
2
|
+
ruleId: string;
|
|
3
|
+
criteriaId: string;
|
|
4
|
+
file: string;
|
|
5
|
+
line: number;
|
|
6
|
+
severity: string;
|
|
7
|
+
message: string;
|
|
8
|
+
advisory?: boolean;
|
|
9
|
+
origin?: {
|
|
10
|
+
sourceFile?: string;
|
|
11
|
+
sourceLine?: number;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
interface AuditLike {
|
|
15
|
+
findings?: FindingLike[];
|
|
16
|
+
}
|
|
17
|
+
interface SnapshotPayload {
|
|
18
|
+
meta: Record<string, unknown>;
|
|
19
|
+
dom: string;
|
|
20
|
+
styles?: unknown;
|
|
21
|
+
boxes?: unknown;
|
|
22
|
+
css?: unknown;
|
|
23
|
+
screenshot?: string;
|
|
24
|
+
/** What the live probes measured, when the caller ran them. */
|
|
25
|
+
probes?: unknown;
|
|
26
|
+
}
|
|
27
|
+
/** Same slug rule as the engine (src/snapshot.ts slugifyPageId): accent-folded URL path. */
|
|
28
|
+
declare function slugify(url: string): string;
|
|
29
|
+
interface CheckOptions {
|
|
30
|
+
/** The page id (directory name). Defaults to the slugified URL path. */
|
|
31
|
+
as?: string;
|
|
32
|
+
/** Human page name for the report. Defaults to the document title. */
|
|
33
|
+
name?: string;
|
|
34
|
+
/** Severity at which the check fails, or `false` to record without ever failing. */
|
|
35
|
+
failOn?: "blocking" | "major" | "minor" | false;
|
|
36
|
+
/** Mark the page as sitting behind authentication. */
|
|
37
|
+
auth?: boolean;
|
|
38
|
+
/** Source files that rendered it — findings are then reported against YOUR code. */
|
|
39
|
+
sources?: string[];
|
|
40
|
+
/** Reproduction notes carried into the auditor ticket. */
|
|
41
|
+
notes?: string;
|
|
42
|
+
/** Capture a viewport screenshot for the pixel tier (Playwright: on by default). */
|
|
43
|
+
screenshot?: boolean;
|
|
44
|
+
/** The path you NAVIGATED to. Given it, the fixture refuses to record a page the browser
|
|
45
|
+
* did not stay on — a guarded route that redirected, a session that expired — instead of
|
|
46
|
+
* filing that other screen under this page's `as`/`name`. Nothing about such a snapshot
|
|
47
|
+
* looks wrong, which is what makes it the worst thing an accessibility report can carry.
|
|
48
|
+
* Compared by path (a query or fragment the app appends to its own route is the same
|
|
49
|
+
* page); a full URL works too. `scan --sample` applies the same rule. */
|
|
50
|
+
expectPath?: string;
|
|
51
|
+
/** Run the LIVE PROBES on this page after collecting it: 200% zoom, 320px reflow, the
|
|
52
|
+
* text-spacing override, focus visibility and content-on-hover.
|
|
53
|
+
*
|
|
54
|
+
* These are the measurements a recorded snapshot can never settle, because they are
|
|
55
|
+
* properties of a page being acted on rather than of a page as it stands. `scan` runs them
|
|
56
|
+
* in its own browser, which is exactly why they were undecidable on any screen behind a
|
|
57
|
+
* login or a state machine — the scanner arrives cold and gets a redirect. Your suite has
|
|
58
|
+
* the page in the right state; this is how it says so.
|
|
59
|
+
*
|
|
60
|
+
* Off by default: the probes press Tab, hover, resize the viewport and inject a stylesheet,
|
|
61
|
+
* and a suite should opt into that. Everything is restored before it returns, and the
|
|
62
|
+
* snapshot has already been collected, so what they measure cannot alter what was recorded.
|
|
63
|
+
* Pass an array to run only some of them (by criterion: "1.4.4", "1.4.10", "1.4.12",
|
|
64
|
+
* "1.4.13", "2.4.7"). */
|
|
65
|
+
probes?: boolean | string[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export { type AuditLike as A, type CheckOptions as C, type FindingLike as F, type SnapshotPayload as S, slugify as s };
|
package/dist/playwright.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { C as CheckOptions, A as AuditLike } from './payload-
|
|
1
|
+
import { C as CheckOptions, A as AuditLike } from './payload-BshGXnOO.js';
|
|
2
2
|
|
|
3
3
|
interface PlaywrightPage {
|
|
4
4
|
evaluate(script: string): Promise<unknown>;
|
|
@@ -6,6 +6,25 @@ interface PlaywrightPage {
|
|
|
6
6
|
fullPage: boolean;
|
|
7
7
|
}): Promise<Buffer>;
|
|
8
8
|
url(): string;
|
|
9
|
+
viewportSize?(): {
|
|
10
|
+
width: number;
|
|
11
|
+
height: number;
|
|
12
|
+
} | null;
|
|
13
|
+
setViewportSize?(size: {
|
|
14
|
+
width: number;
|
|
15
|
+
height: number;
|
|
16
|
+
}): Promise<void>;
|
|
17
|
+
addStyleTag?(opts: {
|
|
18
|
+
content: string;
|
|
19
|
+
}): Promise<unknown>;
|
|
20
|
+
hover?(selector: string): Promise<void>;
|
|
21
|
+
waitForTimeout?(ms: number): Promise<void>;
|
|
22
|
+
keyboard?: {
|
|
23
|
+
press(key: string): Promise<void>;
|
|
24
|
+
};
|
|
25
|
+
mouse?: {
|
|
26
|
+
move(x: number, y: number): Promise<void>;
|
|
27
|
+
};
|
|
9
28
|
}
|
|
10
29
|
interface PlaywrightCheckOptions extends CheckOptions {
|
|
11
30
|
/** Also write the per-page report once this page is recorded. Off by default: a report
|
package/dist/playwright.mjs
CHANGED
|
@@ -132,6 +132,279 @@ var COLLECT_SNAPSHOT = `(() => {
|
|
|
132
132
|
};
|
|
133
133
|
})()`;
|
|
134
134
|
|
|
135
|
+
// src/probes.ts
|
|
136
|
+
var PRELUDE = `
|
|
137
|
+
const __sel = (e) => {
|
|
138
|
+
if (!e || !e.tagName) return '\u2014';
|
|
139
|
+
const t = e.tagName.toLowerCase();
|
|
140
|
+
if (e.id) return t + '#' + e.id;
|
|
141
|
+
const c = typeof e.className === 'string' ? e.className.trim().split(/\\s+/)[0] : '';
|
|
142
|
+
return c ? t + '.' + c : t;
|
|
143
|
+
};
|
|
144
|
+
const __vis = (e) => {
|
|
145
|
+
const r = e.getBoundingClientRect();
|
|
146
|
+
if (r.width <= 4 || r.height <= 4) return false; // tiny / 1px sr-only boxes
|
|
147
|
+
const s = getComputedStyle(e);
|
|
148
|
+
if (s.display === 'none' || s.visibility === 'hidden' || parseFloat(s.opacity) === 0) return false;
|
|
149
|
+
// visually-hidden "screen-reader-only" pattern (clip rect / clip-path inset) \u2014 present in
|
|
150
|
+
// the a11y tree but not painted; must not be measured for clipping/target-size.
|
|
151
|
+
if (s.clip && s.clip !== 'auto' && s.clip !== 'rect(auto, auto, auto, auto)') return false;
|
|
152
|
+
if (s.clipPath && (s.clipPath.indexOf('inset(100%') >= 0 || s.clipPath.indexOf('inset(50%') >= 0)) return false;
|
|
153
|
+
return true;
|
|
154
|
+
};
|
|
155
|
+
const __html = (e) => (e.outerHTML || '').slice(0, 160);
|
|
156
|
+
`;
|
|
157
|
+
var REFLOW_PROBE = `(() => {
|
|
158
|
+
const el = document.scrollingElement || document.documentElement;
|
|
159
|
+
return { horizontalScroll: el.scrollWidth > el.clientWidth + 2 };
|
|
160
|
+
})()`;
|
|
161
|
+
var REFLOW_ZOOM_PROBE = `(() => { ${PRELUDE}
|
|
162
|
+
const root = document.documentElement;
|
|
163
|
+
const prev = root.style.fontSize;
|
|
164
|
+
root.style.fontSize = '200%';
|
|
165
|
+
const hits = [];
|
|
166
|
+
for (const e of Array.from(document.querySelectorAll('p,li,h1,h2,h3,h4,h5,h6,td,th,button,a,label,span'))) {
|
|
167
|
+
if (!__vis(e)) continue;
|
|
168
|
+
if ((e.textContent || '').trim().length < 8) continue;
|
|
169
|
+
const s = getComputedStyle(e);
|
|
170
|
+
const clip = s.overflow === 'hidden' || s.overflowY === 'hidden' || s.overflowX === 'hidden';
|
|
171
|
+
const noWrap = s.whiteSpace === 'nowrap' || s.textOverflow === 'ellipsis';
|
|
172
|
+
if ((clip || noWrap) && (e.scrollHeight > e.clientHeight + 6 || e.scrollWidth > e.clientWidth + 6)) {
|
|
173
|
+
hits.push({ selector: __sel(e), html: __html(e), detail: 'Texte tronqu\xE9/masqu\xE9 \xE0 200% (conteneur overflow:hidden / nowrap) \u2014 perte de contenu au zoom (1.4.4).' });
|
|
174
|
+
}
|
|
175
|
+
if (hits.length >= 12) break;
|
|
176
|
+
}
|
|
177
|
+
root.style.fontSize = prev;
|
|
178
|
+
return hits;
|
|
179
|
+
})()`;
|
|
180
|
+
var TEXT_SPACING_CSS = "* { line-height: 1.5 !important; letter-spacing: 0.12em !important; word-spacing: 0.16em !important; } p { margin-bottom: 2em !important; }";
|
|
181
|
+
var TEXT_SPACING_PROBE = `(() => { ${PRELUDE}
|
|
182
|
+
const hits = [];
|
|
183
|
+
for (const e of Array.from(document.querySelectorAll('p,li,span,a,button,h1,h2,h3,h4,h5,h6,td,th,label,div'))) {
|
|
184
|
+
if (!__vis(e)) continue;
|
|
185
|
+
if ((e.textContent || '').trim().length < 8) continue;
|
|
186
|
+
const s = getComputedStyle(e);
|
|
187
|
+
const clipped = (s.overflowX === 'hidden' || s.overflowY === 'hidden' || s.overflow === 'hidden') && (e.scrollHeight > e.clientHeight + 2 || e.scrollWidth > e.clientWidth + 2);
|
|
188
|
+
const ellipsis = s.textOverflow === 'ellipsis' && e.scrollWidth > e.clientWidth + 2;
|
|
189
|
+
if (clipped || ellipsis) {
|
|
190
|
+
hits.push({ selector: __sel(e), html: __html(e), detail: 'Texte tronqu\xE9/masqu\xE9 sous l\\'espacement de texte WCAG 1.4.12 \u2014 perte de contenu.' });
|
|
191
|
+
}
|
|
192
|
+
if (hits.length >= 20) break;
|
|
193
|
+
}
|
|
194
|
+
return hits;
|
|
195
|
+
})()`;
|
|
196
|
+
function focusSetupExpr(scope = "") {
|
|
197
|
+
const rootExpr = scope ? `document.querySelectorAll(${JSON.stringify(scope)})` : `[document.documentElement]`;
|
|
198
|
+
return `(() => { ${PRELUDE}
|
|
199
|
+
const sel = 'a[href],button:not([disabled]),input:not([type=hidden]):not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"]),[role=button]:not([disabled])';
|
|
200
|
+
const snap = (e) => { const s = getComputedStyle(e); return [s.outlineStyle, s.outlineWidth, s.outlineColor, s.boxShadow, s.borderColor, s.borderTopWidth, s.borderBottomWidth, s.backgroundColor, s.color, s.textDecorationLine].join('|'); };
|
|
201
|
+
// Visually-hidden radio/checkbox \u2192 measure its visible label/proxy, not the input.
|
|
202
|
+
const proxyFor = (e) => {
|
|
203
|
+
const type = (e.getAttribute('type') || '').toLowerCase();
|
|
204
|
+
const custom = e.tagName === 'INPUT' && (type === 'radio' || type === 'checkbox') && !__vis(e);
|
|
205
|
+
if (!custom) return __vis(e) ? e : null;
|
|
206
|
+
let p = null;
|
|
207
|
+
if (e.id) { try { p = document.querySelector('label[for="' + (window.CSS && CSS.escape ? CSS.escape(e.id) : e.id) + '"]'); } catch (_) {} }
|
|
208
|
+
if (!p) p = e.closest('label');
|
|
209
|
+
if (!p) { const lb = (e.getAttribute('aria-labelledby') || '').split(/\\s+/)[0]; if (lb) p = document.getElementById(lb); }
|
|
210
|
+
return (p && __vis(p)) ? p : null;
|
|
211
|
+
};
|
|
212
|
+
// Fresh authoritative pass: drop any tags a previous (whole-document or dialog) pass left.
|
|
213
|
+
for (const el of Array.from(document.querySelectorAll('[data-u11y-f],[data-u11y-fp]'))) { el.removeAttribute('data-u11y-f'); el.removeAttribute('data-u11y-fp'); }
|
|
214
|
+
const roots = ${rootExpr};
|
|
215
|
+
const focusables = [];
|
|
216
|
+
for (const root of Array.from(roots)) {
|
|
217
|
+
if (root.matches && root.matches(sel)) focusables.push(root);
|
|
218
|
+
for (const e of Array.from(root.querySelectorAll(sel))) focusables.push(e);
|
|
219
|
+
}
|
|
220
|
+
window.__u11yF = {};
|
|
221
|
+
let n = 0;
|
|
222
|
+
for (const e of focusables) {
|
|
223
|
+
const proxy = proxyFor(e);
|
|
224
|
+
if (!proxy) continue;
|
|
225
|
+
const key = 'k' + n;
|
|
226
|
+
e.setAttribute('data-u11y-f', key);
|
|
227
|
+
proxy.setAttribute('data-u11y-fp', key);
|
|
228
|
+
window.__u11yF[key] = { rest: snap(proxy), sel: __sel(proxy), html: __html(proxy) };
|
|
229
|
+
n++;
|
|
230
|
+
if (n >= 120) break;
|
|
231
|
+
}
|
|
232
|
+
return n;
|
|
233
|
+
})()`;
|
|
234
|
+
}
|
|
235
|
+
var FOCUS_CHECK_PROBE = `(() => {
|
|
236
|
+
const e = document.activeElement;
|
|
237
|
+
if (!e || e === document.body || e === document.documentElement) return null;
|
|
238
|
+
const key = e.getAttribute && e.getAttribute('data-u11y-f');
|
|
239
|
+
if (!key || !window.__u11yF || !window.__u11yF[key]) return null;
|
|
240
|
+
const rec = window.__u11yF[key];
|
|
241
|
+
const proxy = document.querySelector('[data-u11y-fp="' + key + '"]') || e;
|
|
242
|
+
const s = getComputedStyle(proxy);
|
|
243
|
+
const now = [s.outlineStyle, s.outlineWidth, s.outlineColor, s.boxShadow, s.borderColor, s.borderTopWidth, s.borderBottomWidth, s.backgroundColor, s.color, s.textDecorationLine].join('|');
|
|
244
|
+
return { key: key, changed: now !== rec.rest, selector: rec.sel, html: rec.html };
|
|
245
|
+
})()`;
|
|
246
|
+
var HOVER_SETUP_PROBE = `(() => { ${PRELUDE}
|
|
247
|
+
const out = [];
|
|
248
|
+
let n = 0;
|
|
249
|
+
for (const e of Array.from(document.querySelectorAll('[aria-describedby]'))) {
|
|
250
|
+
const id = (e.getAttribute('aria-describedby') || '').split(/\\s+/)[0];
|
|
251
|
+
if (!id) continue;
|
|
252
|
+
const t = document.getElementById(id);
|
|
253
|
+
if (!t) continue;
|
|
254
|
+
const s = getComputedStyle(t);
|
|
255
|
+
const hidden = s.display === 'none' || s.visibility === 'hidden' || t.getBoundingClientRect().height === 0;
|
|
256
|
+
if (!hidden) continue;
|
|
257
|
+
const key = 'h' + n;
|
|
258
|
+
e.setAttribute('data-u11y-h', key);
|
|
259
|
+
out.push({ key: key, target: id, selector: __sel(e) });
|
|
260
|
+
n++;
|
|
261
|
+
if (n >= 10) break;
|
|
262
|
+
}
|
|
263
|
+
return out;
|
|
264
|
+
})()`;
|
|
265
|
+
function hoverVisibleExpr(id, wantHidden = false) {
|
|
266
|
+
const j = JSON.stringify(id);
|
|
267
|
+
return `(() => { const t = document.getElementById(${j}); if (!t) return ${wantHidden ? "true" : "false"}; const s = getComputedStyle(t); const shown = s.display !== 'none' && s.visibility !== 'hidden' && t.getBoundingClientRect().height > 0; return ${wantHidden ? "!shown" : "shown"}; })()`;
|
|
268
|
+
}
|
|
269
|
+
async function probeFocusVisible(page, scope = "") {
|
|
270
|
+
const count = await page.evaluate(focusSetupExpr(scope));
|
|
271
|
+
if (!count) return [];
|
|
272
|
+
const hits = [];
|
|
273
|
+
const seen = /* @__PURE__ */ new Set();
|
|
274
|
+
const limit = Math.min(count + 2, 130);
|
|
275
|
+
for (let i = 0; i < limit; i++) {
|
|
276
|
+
await page.keyboard.press("Tab");
|
|
277
|
+
const r = await page.evaluate(FOCUS_CHECK_PROBE);
|
|
278
|
+
if (!r) continue;
|
|
279
|
+
if (seen.has(r.key)) break;
|
|
280
|
+
seen.add(r.key);
|
|
281
|
+
if (!r.changed) {
|
|
282
|
+
hits.push({
|
|
283
|
+
selector: r.selector,
|
|
284
|
+
html: r.html,
|
|
285
|
+
detail: "Le focus clavier ne produit aucun changement visible (outline/box-shadow/bordure/fond) \u2014 focus non visible (2.4.7)."
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
if (hits.length >= 20) break;
|
|
289
|
+
}
|
|
290
|
+
return hits;
|
|
291
|
+
}
|
|
292
|
+
async function probeHover(page) {
|
|
293
|
+
const triggers = await page.evaluate(HOVER_SETUP_PROBE);
|
|
294
|
+
const hits = [];
|
|
295
|
+
for (const tr of triggers) {
|
|
296
|
+
try {
|
|
297
|
+
await page.hover(`[data-u11y-h="${tr.key}"]`);
|
|
298
|
+
} catch {
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
await page.waitForTimeout(150);
|
|
302
|
+
const shown = await page.evaluate(hoverVisibleExpr(tr.target));
|
|
303
|
+
if (!shown) continue;
|
|
304
|
+
await page.keyboard.press("Escape");
|
|
305
|
+
await page.waitForTimeout(100);
|
|
306
|
+
const dismissed = await page.evaluate(hoverVisibleExpr(tr.target, true));
|
|
307
|
+
await page.mouse.move(2, 2).catch(() => {
|
|
308
|
+
});
|
|
309
|
+
if (!dismissed) {
|
|
310
|
+
hits.push({
|
|
311
|
+
selector: tr.selector,
|
|
312
|
+
html: "",
|
|
313
|
+
detail: `Le contenu r\xE9v\xE9l\xE9 au survol (aria-describedby #${tr.target}) ne se masque pas avec \xC9chap \u2014 Contenu au survol ou au focus (1.4.13).`
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
if (hits.length >= 8) break;
|
|
317
|
+
}
|
|
318
|
+
return hits;
|
|
319
|
+
}
|
|
320
|
+
async function runLiveProbes(page, opts = {}) {
|
|
321
|
+
const only = opts.only?.length ? new Set(opts.only) : null;
|
|
322
|
+
const want = (id) => only === null || only.has(id);
|
|
323
|
+
const canResize = typeof page.setViewportSize === "function" && typeof page.viewportSize === "function";
|
|
324
|
+
const canType = !!page.keyboard && typeof page.keyboard.press === "function";
|
|
325
|
+
const canHover = typeof page.hover === "function" && typeof page.waitForTimeout === "function";
|
|
326
|
+
const canStyle = typeof page.addStyleTag === "function";
|
|
327
|
+
const size = canResize ? page.viewportSize() ?? null : null;
|
|
328
|
+
const restore = size ?? { width: 1280, height: 900 };
|
|
329
|
+
const out = { focusVisible: [], hover: [], reflowZoom: [], textSpacing: [], reflow: { horizontalScroll: false }, probed: [], skipped: [] };
|
|
330
|
+
const skip = (sc, why) => {
|
|
331
|
+
out.skipped?.push({ sc, why });
|
|
332
|
+
};
|
|
333
|
+
if (!canResize) skip("1.4.10", "the page object cannot resize its viewport");
|
|
334
|
+
if (!canType) skip("2.4.7", "the page object exposes no keyboard");
|
|
335
|
+
if (!canHover) skip("1.4.13", "the page object cannot hover");
|
|
336
|
+
if (!canStyle) skip("1.4.12", "the page object cannot inject a stylesheet");
|
|
337
|
+
if (want("2.4.7") && canType) {
|
|
338
|
+
const r = await probeFocusVisible(page).catch((e) => {
|
|
339
|
+
skip("2.4.7", String(e?.message ?? e).slice(0, 160));
|
|
340
|
+
return null;
|
|
341
|
+
});
|
|
342
|
+
if (r) {
|
|
343
|
+
out.focusVisible = r;
|
|
344
|
+
out.probed.push("2.4.7");
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (want("1.4.13") && canHover && canType) {
|
|
348
|
+
const r = await probeHover(page).catch((e) => {
|
|
349
|
+
skip("1.4.13", String(e?.message ?? e).slice(0, 160));
|
|
350
|
+
return null;
|
|
351
|
+
});
|
|
352
|
+
if (r) {
|
|
353
|
+
out.hover = r;
|
|
354
|
+
out.probed.push("1.4.13");
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (want("1.4.4")) {
|
|
358
|
+
out.reflowZoom = await page.evaluate(REFLOW_ZOOM_PROBE).catch(() => []) ?? [];
|
|
359
|
+
out.probed.push("1.4.4");
|
|
360
|
+
}
|
|
361
|
+
if (want("1.4.10") && canResize) {
|
|
362
|
+
let narrowed = true;
|
|
363
|
+
await page.setViewportSize({ width: 320, height: restore.height }).catch((e) => {
|
|
364
|
+
narrowed = false;
|
|
365
|
+
skip("1.4.10", String(e?.message ?? e).slice(0, 160));
|
|
366
|
+
});
|
|
367
|
+
if (narrowed) {
|
|
368
|
+
const r = await page.evaluate(REFLOW_PROBE).catch((e) => {
|
|
369
|
+
skip("1.4.10", String(e?.message ?? e).slice(0, 160));
|
|
370
|
+
return null;
|
|
371
|
+
});
|
|
372
|
+
await page.setViewportSize(restore).catch(() => {
|
|
373
|
+
});
|
|
374
|
+
if (r) {
|
|
375
|
+
out.reflow = r;
|
|
376
|
+
out.probed.push("1.4.10");
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (want("1.4.12") && canStyle) {
|
|
381
|
+
const handle = await page.addStyleTag({ content: TEXT_SPACING_CSS }).catch((e) => {
|
|
382
|
+
skip("1.4.12", String(e?.message ?? e).slice(0, 160));
|
|
383
|
+
return null;
|
|
384
|
+
});
|
|
385
|
+
if (handle) {
|
|
386
|
+
const r = await page.evaluate(TEXT_SPACING_PROBE).catch((e) => {
|
|
387
|
+
skip("1.4.12", String(e?.message ?? e).slice(0, 160));
|
|
388
|
+
return null;
|
|
389
|
+
});
|
|
390
|
+
await page.evaluate(REMOVE_TEXT_SPACING_STEP).catch(() => {
|
|
391
|
+
});
|
|
392
|
+
if (r) {
|
|
393
|
+
out.textSpacing = r;
|
|
394
|
+
out.probed.push("1.4.12");
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return out;
|
|
399
|
+
}
|
|
400
|
+
var REMOVE_TEXT_SPACING_STEP = `(() => {
|
|
401
|
+
const sheets = Array.from(document.querySelectorAll('style'));
|
|
402
|
+
for (const s of sheets) {
|
|
403
|
+
if (s.textContent && s.textContent.indexOf('letter-spacing: 0.12em') >= 0) s.remove();
|
|
404
|
+
}
|
|
405
|
+
return true;
|
|
406
|
+
})()`;
|
|
407
|
+
|
|
135
408
|
// src/integrations/core.ts
|
|
136
409
|
import { spawnSync } from "child_process";
|
|
137
410
|
import { createRequire } from "module";
|
|
@@ -180,7 +453,7 @@ function stayedOnPage(expected, actual) {
|
|
|
180
453
|
if (a === void 0 || b === void 0) return true;
|
|
181
454
|
return a === b;
|
|
182
455
|
}
|
|
183
|
-
function buildPayload(collected, url, runner, opts, screenshot) {
|
|
456
|
+
function buildPayload(collected, url, runner, opts, screenshot, probes) {
|
|
184
457
|
const id = opts.as || slugify(url);
|
|
185
458
|
return {
|
|
186
459
|
meta: {
|
|
@@ -199,7 +472,11 @@ function buildPayload(collected, url, runner, opts, screenshot) {
|
|
|
199
472
|
styles: collected.styles,
|
|
200
473
|
boxes: collected.boxes,
|
|
201
474
|
css: collected.css,
|
|
202
|
-
...screenshot ? { screenshot } : {}
|
|
475
|
+
...screenshot ? { screenshot } : {},
|
|
476
|
+
// What the live probes measured, when the caller asked for them. It rides in the payload
|
|
477
|
+
// because `snapshot write` persists it beside the DOM — the audit that folds it runs later
|
|
478
|
+
// and in another process, so a measurement kept in memory decides nothing.
|
|
479
|
+
...probes ? { probes } : {}
|
|
203
480
|
};
|
|
204
481
|
}
|
|
205
482
|
function gate(result, pageName, failOn) {
|
|
@@ -263,7 +540,17 @@ async function checkA11y(page, opts = {}) {
|
|
|
263
540
|
`ultra11y: ${opts.expectPath} landed on ${url} \u2014 not recording it as "${opts.as ?? opts.name ?? "this page"}". The state that opens this route is not the one the test built; seed it first, or drop the page from the sample.`
|
|
264
541
|
);
|
|
265
542
|
}
|
|
266
|
-
|
|
543
|
+
let probes;
|
|
544
|
+
if (opts.probes) {
|
|
545
|
+
try {
|
|
546
|
+
probes = await runLiveProbes(page, Array.isArray(opts.probes) ? { only: opts.probes } : {});
|
|
547
|
+
} catch (e) {
|
|
548
|
+
console.warn(
|
|
549
|
+
`ultra11y: the live probes failed on this page \u2014 ${e instanceof Error ? e.message : String(e)}. The snapshot was still recorded; the criteria they decide stay to assess.`
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
const payload = buildPayload(collected, url, "playwright", opts, shot, probes);
|
|
267
554
|
const result = auditSnapshot(payload);
|
|
268
555
|
if (opts.report) writePagesReport(typeof opts.report === "object" ? opts.report : {});
|
|
269
556
|
gate(result, String(payload.meta.name), opts.failOn);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ultra11y",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.6.1",
|
|
4
4
|
"description": "Audit HTML/CSS/JSX against WCAG 2.2 AA and produce a dated compliance report, or author/review accessible markup (native-HTML-first). A skills.sh agent skill: an install-free static engine (bundled, no npm install) plus the agent's judgment, with check/verify gates against hallucinated non-conformities, and pluggable in-repo standards packs (RGAA France first; Section 508 & EN 301 549 next).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@10.33.0",
|