ultra11y 5.5.4 → 5.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.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 +244 -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 +27 -1
- package/src/probes.ts +320 -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.0",
|
|
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.0",
|
|
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.0",
|
|
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.0";
|
|
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,236 @@ 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 size = page.viewportSize?.() ?? null;
|
|
324
|
+
const restore = size ?? { width: 1280, height: 900 };
|
|
325
|
+
const out = { focusVisible: [], hover: [], reflowZoom: [], textSpacing: [], reflow: { horizontalScroll: false }, probed: [] };
|
|
326
|
+
if (want("2.4.7")) {
|
|
327
|
+
out.focusVisible = await probeFocusVisible(page).catch(() => []);
|
|
328
|
+
out.probed.push("2.4.7");
|
|
329
|
+
}
|
|
330
|
+
if (want("1.4.13")) {
|
|
331
|
+
out.hover = await probeHover(page).catch(() => []);
|
|
332
|
+
out.probed.push("1.4.13");
|
|
333
|
+
}
|
|
334
|
+
if (want("1.4.4")) {
|
|
335
|
+
out.reflowZoom = await page.evaluate(REFLOW_ZOOM_PROBE).catch(() => []) ?? [];
|
|
336
|
+
out.probed.push("1.4.4");
|
|
337
|
+
}
|
|
338
|
+
if (want("1.4.10")) {
|
|
339
|
+
await page.setViewportSize({ width: 320, height: restore.height }).catch(() => {
|
|
340
|
+
});
|
|
341
|
+
out.reflow = await page.evaluate(REFLOW_PROBE).catch(() => ({ horizontalScroll: false })) ?? {
|
|
342
|
+
horizontalScroll: false
|
|
343
|
+
};
|
|
344
|
+
await page.setViewportSize(restore).catch(() => {
|
|
345
|
+
});
|
|
346
|
+
out.probed.push("1.4.10");
|
|
347
|
+
}
|
|
348
|
+
if (want("1.4.12")) {
|
|
349
|
+
const handle = await page.addStyleTag({ content: TEXT_SPACING_CSS }).catch(() => null);
|
|
350
|
+
out.textSpacing = await page.evaluate(TEXT_SPACING_PROBE).catch(() => []) ?? [];
|
|
351
|
+
if (handle) await page.evaluate(REMOVE_TEXT_SPACING_STEP).catch(() => {
|
|
352
|
+
});
|
|
353
|
+
out.probed.push("1.4.12");
|
|
354
|
+
}
|
|
355
|
+
return out;
|
|
356
|
+
}
|
|
357
|
+
var REMOVE_TEXT_SPACING_STEP = `(() => {
|
|
358
|
+
const sheets = Array.from(document.querySelectorAll('style'));
|
|
359
|
+
for (const s of sheets) {
|
|
360
|
+
if (s.textContent && s.textContent.indexOf('letter-spacing: 0.12em') >= 0) s.remove();
|
|
361
|
+
}
|
|
362
|
+
return true;
|
|
363
|
+
})()`;
|
|
364
|
+
|
|
135
365
|
// src/integrations/core.ts
|
|
136
366
|
import { spawnSync } from "child_process";
|
|
137
367
|
import { createRequire } from "module";
|
|
@@ -180,7 +410,7 @@ function stayedOnPage(expected, actual) {
|
|
|
180
410
|
if (a === void 0 || b === void 0) return true;
|
|
181
411
|
return a === b;
|
|
182
412
|
}
|
|
183
|
-
function buildPayload(collected, url, runner, opts, screenshot) {
|
|
413
|
+
function buildPayload(collected, url, runner, opts, screenshot, probes) {
|
|
184
414
|
const id = opts.as || slugify(url);
|
|
185
415
|
return {
|
|
186
416
|
meta: {
|
|
@@ -199,7 +429,11 @@ function buildPayload(collected, url, runner, opts, screenshot) {
|
|
|
199
429
|
styles: collected.styles,
|
|
200
430
|
boxes: collected.boxes,
|
|
201
431
|
css: collected.css,
|
|
202
|
-
...screenshot ? { screenshot } : {}
|
|
432
|
+
...screenshot ? { screenshot } : {},
|
|
433
|
+
// What the live probes measured, when the caller asked for them. It rides in the payload
|
|
434
|
+
// because `snapshot write` persists it beside the DOM — the audit that folds it runs later
|
|
435
|
+
// and in another process, so a measurement kept in memory decides nothing.
|
|
436
|
+
...probes ? { probes } : {}
|
|
203
437
|
};
|
|
204
438
|
}
|
|
205
439
|
function gate(result, pageName, failOn) {
|
|
@@ -263,7 +497,14 @@ async function checkA11y(page, opts = {}) {
|
|
|
263
497
|
`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
498
|
);
|
|
265
499
|
}
|
|
266
|
-
|
|
500
|
+
let probes;
|
|
501
|
+
if (opts.probes) {
|
|
502
|
+
try {
|
|
503
|
+
probes = await runLiveProbes(page, Array.isArray(opts.probes) ? { only: opts.probes } : {});
|
|
504
|
+
} catch {
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
const payload = buildPayload(collected, url, "playwright", opts, shot, probes);
|
|
267
508
|
const result = auditSnapshot(payload);
|
|
268
509
|
if (opts.report) writePagesReport(typeof opts.report === "object" ? opts.report : {});
|
|
269
510
|
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.0",
|
|
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",
|