ultra11y 5.41.2 → 5.42.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/README.md +7 -3
- package/dist/cypress-plugin.d.ts +1 -1
- package/dist/cypress.d.ts +2 -2
- package/dist/{payload-DvcIA6VQ.d.ts → payload-BMFQTm5s.d.ts} +18 -0
- package/dist/playwright.d.ts +1 -1
- package/dist/playwright.mjs +305 -35
- package/package.json +1 -1
- package/scripts/build-pack-rgaa.mjs +25 -1
- package/scripts/ultra11y.mjs +956 -515
- package/skills/review-a11y/SKILL.md +1 -1
- package/skills/review-a11y/scripts/ultra11y.mjs +956 -515
- package/skills/ultra11y/SKILL.md +2 -2
- package/skills/ultra11y/references/ci.md +1 -1
- package/skills/ultra11y/references/judgment.md +1 -1
- package/skills/ultra11y/references/mcp.md +1 -1
- package/skills/ultra11y/references/pages.md +2 -2
- package/skills/ultra11y/references/rgaa-automation.md +1 -1
- package/skills/ultra11y/scripts/ultra11y.mjs +956 -515
- package/skills/ultra11y/templates/gitlab-ci.yml +1 -1
- package/src/audit.ts +23 -1
- package/src/check.ts +101 -15
- package/src/data/standards/rgaa.json +1 -2
- package/src/html-report.ts +37 -7
- package/src/integrations/payload.ts +16 -0
- package/src/integrations/playwright.ts +28 -2
- package/src/ledger.ts +168 -4
- package/src/probes.ts +494 -35
- package/src/report.ts +206 -10
- package/src/scan-local.ts +149 -153
- package/src/scan.ts +26 -3
- package/src/snapshot.ts +29 -0
- package/src/types.ts +17 -1
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"name": "ultra11y",
|
|
11
11
|
"source": "./",
|
|
12
12
|
"description": "Automatic accessibility review before commit, push and pull request, plus detailed evidenced Markdown, HTML and per-page WCAG 2.2 AA / RGAA reports in Claude Code.",
|
|
13
|
-
"version": "5.
|
|
13
|
+
"version": "5.42.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…): automatic review before commit, push and pull request, plus detailed evidenced Markdown, HTML and per-page conformance reports in interactive agent sessions.",
|
|
4
|
-
"version": "5.
|
|
4
|
+
"version": "5.42.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…): automatic review before commit, push and pull request, plus detailed evidenced Markdown, HTML and per-page conformance reports in interactive agent sessions.",
|
|
4
|
-
"version": "5.
|
|
4
|
+
"version": "5.42.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.42.1";
|
|
31
31
|
|
|
32
32
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
33
33
|
|
package/README.md
CHANGED
|
@@ -331,12 +331,16 @@ tests**:
|
|
|
331
331
|
41 receive candidate evidence, with overlap. One additional criterion receives only an
|
|
332
332
|
advisory signal. A candidate is forwarded to adjudication and never changes a verdict by
|
|
333
333
|
itself.
|
|
334
|
-
- Only **8.3
|
|
334
|
+
- Only **8.3 and 8.5** may earn `C` from a complete silent measurement. For the other
|
|
335
335
|
mechanically detectable tests, silence can mean “no subject found” or “an alternative or
|
|
336
|
-
exception still needs judging”, so it cannot prove conformity.
|
|
336
|
+
exception still needs judging”, so it cannot prove conformity. 10.1 was on that list and
|
|
337
|
+
left it: its rule tolerates `<u>`, tolerates `width`/`height` on nine tags where the
|
|
338
|
+
glossary names five, and covers “presentation built out of spaces” with two heuristics —
|
|
339
|
+
every one of them a deliberate under-report, which is the safe direction for a finding and
|
|
340
|
+
the wrong one for a conformity.
|
|
337
341
|
- The remaining **228 `judgment` tests across 92 criteria** all enter the AI worklist,
|
|
338
342
|
including an apparently absent subject whose `NA` still needs confirmation. Overall,
|
|
339
|
-
**
|
|
343
|
+
**104 of 106 criteria require adjudication to earn `C`**.
|
|
340
344
|
|
|
341
345
|
The number can grow, but the useful target is **more observed, exact failures**, not a larger
|
|
342
346
|
marketing count. The best next candidates are complete rendered probes for zoom/reflow and
|
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-BMFQTm5s.js';
|
|
2
|
+
export { s as slugify } from './payload-BMFQTm5s.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
|
|
@@ -69,6 +69,24 @@ interface CheckOptions {
|
|
|
69
69
|
* YOUR pages, and a screen with 400 focusables should be able to say so rather than being
|
|
70
70
|
* quietly cut off at 120. */
|
|
71
71
|
probes?: boolean | string[] | ProbeTuning;
|
|
72
|
+
/** Also measure 4.1.3 STATUS MESSAGES — content updated by an interaction and announced to
|
|
73
|
+
* nobody.
|
|
74
|
+
*
|
|
75
|
+
* Separate from `probes` because it is a different promise. The probes above stress the page
|
|
76
|
+
* and put it back; this one FILLS its fields and toggles its controls to see what the update
|
|
77
|
+
* lands in, and a framework that reacted to that has reacted. Restored, but observed.
|
|
78
|
+
*
|
|
79
|
+
* Off by default here and ON in a sweep, the same asymmetry `probes` carries: a sweep exists
|
|
80
|
+
* for nothing but recording the sample and asserts nothing afterwards. `{ clicks: true }`
|
|
81
|
+
* additionally presses `button[type=button]` (destructive-sounding names skipped, navigation
|
|
82
|
+
* aborts the pass) — never the default on an authenticated session, where a server mutation
|
|
83
|
+
* is invisible to any check this side of the network.
|
|
84
|
+
*
|
|
85
|
+
* PLAYWRIGHT ONLY, like `probes` beside it: the Cypress integration records a page and runs
|
|
86
|
+
* no live probe at all, so both are inert there. Stated rather than left to be discovered. */
|
|
87
|
+
liveRegion?: boolean | {
|
|
88
|
+
clicks?: boolean;
|
|
89
|
+
};
|
|
72
90
|
/** Run AXE-CORE on the page before recording it.
|
|
73
91
|
*
|
|
74
92
|
* `scan` has always driven axe; your suite never did — and your suite is the only tier that
|
package/dist/playwright.d.ts
CHANGED
package/dist/playwright.mjs
CHANGED
|
@@ -133,6 +133,7 @@ var COLLECT_SNAPSHOT = `(() => {
|
|
|
133
133
|
})()`;
|
|
134
134
|
|
|
135
135
|
// src/probes.ts
|
|
136
|
+
var PROBES_VERSION = 2;
|
|
136
137
|
var PRELUDE = `
|
|
137
138
|
const __sel = (e) => {
|
|
138
139
|
if (!e || !e.tagName) return '\u2014';
|
|
@@ -235,7 +236,23 @@ var TEXT_SPACING_PROBE = `(() => { ${PRELUDE}
|
|
|
235
236
|
function focusSetupExpr(scope = "", maxFocusables = PROBE_DEFAULTS.maxFocusables) {
|
|
236
237
|
const rootExpr = scope ? `document.querySelectorAll(${JSON.stringify(scope)})` : `[document.documentElement]`;
|
|
237
238
|
return `(() => { ${PRELUDE}
|
|
238
|
-
|
|
239
|
+
// FOCUSABLE MEANS FOCUSABLE, NOT "the six tags we thought of". This list decides which
|
|
240
|
+
// elements get tagged, and since the walk may now license a conformity, an element missing
|
|
241
|
+
// from it is not merely unmeasured -- it is silently cleared. A page whose only control is a
|
|
242
|
+
// <summary> tagged nothing at all, so the count was zero, so the ring was "vacuously whole",
|
|
243
|
+
// so 2.4.7 and 2.4.11 closed without a single Tab press.
|
|
244
|
+
//
|
|
245
|
+
// AND NOT iframe / audio[controls] / video[controls], which were here for one release and
|
|
246
|
+
// had to come back out. They are genuinely focusable, but their focus lives in another
|
|
247
|
+
// document: everything below reads the PARENT'S activeElement, which stays the host element
|
|
248
|
+
// press after press while the user tabs through the controls inside. The trap walk reads
|
|
249
|
+
// exactly that as a cage, so an ordinary page with a video player, a payment frame, a map or
|
|
250
|
+
// a support widget was reported as a bloquant 2.1.2 -- a blocker manufactured out of our own
|
|
251
|
+
// blindness, able to fail somebody else's gate. The measurement we cannot make is not a
|
|
252
|
+
// finding; Tab still crosses them, the walk still records that it crossed something it never
|
|
253
|
+
// measured, and the page is not cleared either.
|
|
254
|
+
// (No backticks in this comment: it lives inside a template literal.)
|
|
255
|
+
const sel = 'a[href],area[href],button:not([disabled]),input:not([type=hidden]):not([disabled]),select:not([disabled]),textarea:not([disabled]),summary,[contenteditable]:not([contenteditable="false"]),[tabindex]:not([tabindex="-1"]),[role=button]:not([disabled])';
|
|
239
256
|
// A BOX THAT IS ANIMATING CANNOT BE COMPARED ACROSS TIME, so it contributes a constant.
|
|
240
257
|
//
|
|
241
258
|
// The snapshot is taken before Tab and re-read after it, and the properties compared include
|
|
@@ -268,6 +285,7 @@ function focusSetupExpr(scope = "", maxFocusables = PROBE_DEFAULTS.maxFocusables
|
|
|
268
285
|
for (const el of Array.from(document.querySelectorAll('[data-u11y-f],[data-u11y-fp]'))) { el.removeAttribute('data-u11y-f'); el.removeAttribute('data-u11y-fp'); }
|
|
269
286
|
const roots = ${rootExpr};
|
|
270
287
|
const focusables = [];
|
|
288
|
+
let total = 0;
|
|
271
289
|
for (const root of Array.from(roots)) {
|
|
272
290
|
if (root.matches && root.matches(sel)) focusables.push(root);
|
|
273
291
|
for (const e of Array.from(root.querySelectorAll(sel))) focusables.push(e);
|
|
@@ -278,20 +296,27 @@ function focusSetupExpr(scope = "", maxFocusables = PROBE_DEFAULTS.maxFocusables
|
|
|
278
296
|
const proxy = proxyFor(e);
|
|
279
297
|
if (!proxy) continue;
|
|
280
298
|
const key = 'k' + n;
|
|
299
|
+
total++;
|
|
300
|
+
if (n >= ${maxFocusables}) continue; // tagged the cap; keep COUNTING so the caller knows it was cut
|
|
281
301
|
e.setAttribute('data-u11y-f', key);
|
|
282
302
|
proxy.setAttribute('data-u11y-fp', key);
|
|
283
303
|
window.__u11yF[key] = { rest: snap(proxy), sel: __sel(proxy), html: __html(proxy) };
|
|
284
304
|
n++;
|
|
285
|
-
if (n >= ${maxFocusables}) break;
|
|
286
305
|
}
|
|
287
|
-
return n;
|
|
306
|
+
return { n: n, total: total };
|
|
288
307
|
})()`;
|
|
289
308
|
}
|
|
290
309
|
var FOCUS_CHECK_PROBE = `(() => {
|
|
291
310
|
const e = document.activeElement;
|
|
311
|
+
// FOCUS LEFT THE DOCUMENT \u2014 the normal end of a tab ring, and nothing to measure.
|
|
292
312
|
if (!e || e === document.body || e === document.documentElement) return null;
|
|
293
313
|
const key = e.getAttribute && e.getAttribute('data-u11y-f');
|
|
294
|
-
|
|
314
|
+
// FOCUS IS ON SOMETHING THE SETUP NEVER TAGGED, which is a different fact entirely and used
|
|
315
|
+
// to be reported as the same one. A skip link revealed only on focus, a widget that moves
|
|
316
|
+
// focus into a node the selector does not match -- Tab crossed it, nothing measured it, and
|
|
317
|
+
// the walk went on to call itself whole. The caller needs to know the ring contained an
|
|
318
|
+
// element it cannot speak for. (No backticks here: this lives inside a template literal.)
|
|
319
|
+
if (!key || !window.__u11yF || !window.__u11yF[key]) return { untagged: true, key: '', changed: true, selector: '', html: '' };
|
|
295
320
|
const rec = window.__u11yF[key];
|
|
296
321
|
const proxy = document.querySelector('[data-u11y-fp="' + key + '"]') || e;
|
|
297
322
|
// THE SAME THREE BOXES pass 1 snapshotted, in the same order. A design system that paints
|
|
@@ -346,41 +371,70 @@ var FOCUS_OBSCURED_PROBE = `(() => { ${PRELUDE}
|
|
|
346
371
|
var HOVER_SETUP_PROBE = `(() => { ${PRELUDE}
|
|
347
372
|
const out = [];
|
|
348
373
|
let n = 0;
|
|
374
|
+
let total = 0;
|
|
349
375
|
for (const e of Array.from(document.querySelectorAll('[aria-describedby]'))) {
|
|
350
376
|
const id = (e.getAttribute('aria-describedby') || '').split(/\\s+/)[0];
|
|
351
377
|
if (!id) continue;
|
|
352
378
|
const t = document.getElementById(id);
|
|
353
379
|
if (!t) continue;
|
|
380
|
+
// THE TRIGGER ITSELF HAS TO BE THERE. 1.4.13 is about content revealed on hover or focus,
|
|
381
|
+
// and an element that is not rendered reveals nothing to anybody -- hovering it was always
|
|
382
|
+
// futile, and once an unreachable trigger started withholding the criterion, that futility
|
|
383
|
+
// would have turned into a page nobody could ever clear.
|
|
384
|
+
if (!__vis(e)) continue;
|
|
354
385
|
const s = getComputedStyle(t);
|
|
355
386
|
const hidden = s.display === 'none' || s.visibility === 'hidden' || t.getBoundingClientRect().height === 0;
|
|
356
387
|
if (!hidden) continue;
|
|
388
|
+
total++;
|
|
389
|
+
if (n >= 10) continue; // tagged ten; keep COUNTING so the caller knows what it did not see
|
|
357
390
|
const key = 'h' + n;
|
|
358
391
|
e.setAttribute('data-u11y-h', key);
|
|
359
392
|
out.push({ key: key, target: id, selector: __sel(e) });
|
|
360
393
|
n++;
|
|
361
|
-
if (n >= 10) break;
|
|
362
394
|
}
|
|
395
|
+
for (const o of out) o.total = total;
|
|
363
396
|
return out;
|
|
364
397
|
})()`;
|
|
365
398
|
function hoverVisibleExpr(id, wantHidden = false) {
|
|
366
399
|
const j = JSON.stringify(id);
|
|
367
400
|
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"}; })()`;
|
|
368
401
|
}
|
|
402
|
+
function readSetup(raw, limits) {
|
|
403
|
+
if (typeof raw === "number") return { count: raw, capped: raw >= limits.maxFocusables };
|
|
404
|
+
const o = raw ?? {};
|
|
405
|
+
const count = o.n ?? 0;
|
|
406
|
+
return { count, capped: (o.total ?? count) > count };
|
|
407
|
+
}
|
|
408
|
+
function cappedRing(capped, limits) {
|
|
409
|
+
return capped ? `the setup pass stopped tagging at ${limits.maxFocusables} focusable elements (probes.maxFocusables), so everything past that was never focused and never measured` : void 0;
|
|
410
|
+
}
|
|
369
411
|
async function probeFocusRing(page, scope = "", limits = PROBE_DEFAULTS, deadline) {
|
|
370
|
-
const count = await page.evaluate(focusSetupExpr(scope, limits.maxFocusables));
|
|
371
|
-
if (!count) return { visible: [], obscured: [] };
|
|
412
|
+
const { count, capped } = readSetup(await page.evaluate(focusSetupExpr(scope, limits.maxFocusables)), limits);
|
|
413
|
+
if (!count) return { visible: [], obscured: [], complete: true };
|
|
372
414
|
const hits = [];
|
|
373
415
|
const obscured = [];
|
|
374
416
|
const seen = /* @__PURE__ */ new Set();
|
|
375
417
|
const limit = tabPressBudget(count, limits);
|
|
376
418
|
let prevKey = null;
|
|
419
|
+
let cutShort = `the walk spent its ${limit} Tab presses without the ring ever closing, so the tail of it was never reached`;
|
|
420
|
+
let untagged = 0;
|
|
377
421
|
for (let i = 0; i < limit; i++) {
|
|
378
|
-
if (deadline?.out())
|
|
422
|
+
if (deadline?.out()) {
|
|
423
|
+
cutShort = `the probe budget of ${limits.budgetMs}ms ran out after ${seen.size} of the ${count} focusable elements \u2014 the rest of the ring was never focused`;
|
|
424
|
+
break;
|
|
425
|
+
}
|
|
379
426
|
await page.keyboard.press("Tab");
|
|
380
427
|
const r = await page.evaluate(FOCUS_CHECK_PROBE);
|
|
381
428
|
if (!r) continue;
|
|
429
|
+
if (r.untagged) {
|
|
430
|
+
untagged++;
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
382
433
|
if (r.key === prevKey) continue;
|
|
383
|
-
if (seen.has(r.key))
|
|
434
|
+
if (seen.has(r.key)) {
|
|
435
|
+
cutShort = void 0;
|
|
436
|
+
break;
|
|
437
|
+
}
|
|
384
438
|
seen.add(r.key);
|
|
385
439
|
prevKey = r.key;
|
|
386
440
|
if (!r.changed) {
|
|
@@ -400,9 +454,14 @@ async function probeFocusRing(page, scope = "", limits = PROBE_DEFAULTS, deadlin
|
|
|
400
454
|
});
|
|
401
455
|
}
|
|
402
456
|
}
|
|
403
|
-
if (hits.length >= 20 && obscured.length >= 20)
|
|
457
|
+
if (hits.length >= 20 && obscured.length >= 20) {
|
|
458
|
+
cutShort = `both recording caps filled at ${seen.size} of the ${count} focusable elements \u2014 enough was found to fail the page, not enough to clear the rest of it`;
|
|
459
|
+
break;
|
|
460
|
+
}
|
|
404
461
|
}
|
|
405
|
-
|
|
462
|
+
if (cutShort && seen.size >= count) cutShort = void 0;
|
|
463
|
+
const why = cappedRing(capped, limits) ?? cutShort ?? (untagged > 0 ? `Tab crossed ${untagged} element(s) the tagging pass never matched \u2014 a control focusable in the browser but not by this selector (a skip link revealed on focus, a widget moving focus into an untagged node). They were never compared, so this page is not cleared` : void 0);
|
|
464
|
+
return { visible: hits, obscured, complete: !why, ...why ? { why } : {} };
|
|
406
465
|
}
|
|
407
466
|
var NATIVE_SEGMENT_STOPS = {
|
|
408
467
|
date: 5,
|
|
@@ -422,53 +481,85 @@ var FOCUS_WHERE_PROBE = `(() => { ${PRELUDE}
|
|
|
422
481
|
const type = e.tagName === 'INPUT' ? (e.getAttribute('type') || 'text').toLowerCase() : '';
|
|
423
482
|
return { key: key || __sel(e), tagged: !!key, selector: __sel(e), html: __html(e), segments: stops[type] || 1 };
|
|
424
483
|
})()`;
|
|
425
|
-
async function
|
|
426
|
-
const count = await page.evaluate(focusSetupExpr("", limits.maxFocusables));
|
|
427
|
-
if (!count || count < 2) return [];
|
|
484
|
+
async function probeKeyboardTrapRing(page, limits = PROBE_DEFAULTS, deadline) {
|
|
485
|
+
const { count, capped } = readSetup(await page.evaluate(focusSetupExpr("", limits.maxFocusables)), limits);
|
|
486
|
+
if (!count || count < 2) return { hits: [], complete: true };
|
|
428
487
|
const hits = [];
|
|
429
488
|
const seen = /* @__PURE__ */ new Set();
|
|
430
489
|
const confirmPresses = 2;
|
|
431
490
|
const limit = tabPressBudget(count, limits);
|
|
432
491
|
let prev = null;
|
|
492
|
+
let cutShort = `the walk spent its ${limit} Tab presses without the ring ever closing, so the tail of it was never reached`;
|
|
433
493
|
for (let i = 0; i < limit; i++) {
|
|
434
|
-
if (deadline?.out())
|
|
494
|
+
if (deadline?.out()) {
|
|
495
|
+
cutShort = `the probe budget of ${limits.budgetMs}ms ran out after ${seen.size} of the ${count} focusable elements \u2014 the rest of the ring was never walked`;
|
|
496
|
+
break;
|
|
497
|
+
}
|
|
435
498
|
await page.keyboard.press("Tab");
|
|
436
499
|
const now = await page.evaluate(FOCUS_WHERE_PROBE);
|
|
437
|
-
if (!now)
|
|
500
|
+
if (!now) {
|
|
501
|
+
cutShort = void 0;
|
|
502
|
+
break;
|
|
503
|
+
}
|
|
438
504
|
if (prev?.tagged && now.tagged && now.key === prev.key) {
|
|
439
505
|
const budget = Math.max(confirmPresses, (now.segments ?? 1) - 1);
|
|
440
506
|
let stuck = true;
|
|
507
|
+
let confirmed = true;
|
|
441
508
|
for (let k = 0; k < budget && stuck; k++) {
|
|
442
|
-
if (deadline?.out())
|
|
509
|
+
if (deadline?.out()) {
|
|
510
|
+
confirmed = false;
|
|
511
|
+
break;
|
|
512
|
+
}
|
|
443
513
|
await page.keyboard.press("Tab");
|
|
444
514
|
const again = await page.evaluate(FOCUS_WHERE_PROBE);
|
|
445
515
|
stuck = again !== null && again.tagged === true && again.key === now.key;
|
|
446
516
|
}
|
|
517
|
+
if (!confirmed) {
|
|
518
|
+
cutShort = `the probe budget of ${limits.budgetMs}ms ran out while confirming whether focus could leave ${now.selector} \u2014 an unconfirmed suspicion is not a non-conformity`;
|
|
519
|
+
break;
|
|
520
|
+
}
|
|
447
521
|
if (stuck) {
|
|
448
522
|
hits.push({
|
|
449
523
|
selector: now.selector,
|
|
450
524
|
html: now.html,
|
|
451
525
|
detail: `Le focus reste sur cet \xE9l\xE9ment apr\xE8s ${1 + budget} appuis sur Tab, alors que la page compte ${count} \xE9l\xE9ments focalisables \u2014 pi\xE8ge au clavier (2.1.2).`
|
|
452
526
|
});
|
|
527
|
+
cutShort = void 0;
|
|
453
528
|
break;
|
|
454
529
|
}
|
|
455
530
|
}
|
|
456
531
|
if (now.key !== prev?.key) {
|
|
457
|
-
if (seen.has(now.key))
|
|
532
|
+
if (seen.has(now.key)) {
|
|
533
|
+
cutShort = void 0;
|
|
534
|
+
break;
|
|
535
|
+
}
|
|
458
536
|
seen.add(now.key);
|
|
459
537
|
}
|
|
460
538
|
prev = now;
|
|
461
539
|
}
|
|
462
|
-
|
|
540
|
+
if (cutShort && seen.size >= count) cutShort = void 0;
|
|
541
|
+
const why = cappedRing(capped, limits) ?? cutShort;
|
|
542
|
+
return { hits, complete: !why, ...why ? { why } : {} };
|
|
463
543
|
}
|
|
464
|
-
async function
|
|
465
|
-
const
|
|
544
|
+
async function probeHoverWalk(page, limits = PROBE_DEFAULTS, deadline) {
|
|
545
|
+
const setup = await page.evaluate(HOVER_SETUP_PROBE);
|
|
546
|
+
const triggers = setup;
|
|
466
547
|
const hits = [];
|
|
467
|
-
|
|
468
|
-
|
|
548
|
+
let cutShort;
|
|
549
|
+
let unreachable = 0;
|
|
550
|
+
const tried = triggers.slice(0, Math.max(1, limits.maxTriggers));
|
|
551
|
+
if (tried.length < triggers.length) {
|
|
552
|
+
cutShort = `only ${tried.length} of the ${triggers.length} hover triggers on this page were opened (probes.maxTriggers) \u2014 the rest were never asked whether Escape dismisses them`;
|
|
553
|
+
}
|
|
554
|
+
for (const tr of tried) {
|
|
555
|
+
if (deadline?.out()) {
|
|
556
|
+
cutShort = `the probe budget of ${limits.budgetMs}ms ran out with triggers left unopened`;
|
|
557
|
+
break;
|
|
558
|
+
}
|
|
469
559
|
try {
|
|
470
560
|
await page.hover(`[data-u11y-h="${tr.key}"]`, { timeout: actionTimeout(limits, deadline) });
|
|
471
561
|
} catch {
|
|
562
|
+
unreachable++;
|
|
472
563
|
continue;
|
|
473
564
|
}
|
|
474
565
|
await page.waitForTimeout(150);
|
|
@@ -486,9 +577,14 @@ async function probeHover(page, limits = PROBE_DEFAULTS, deadline) {
|
|
|
486
577
|
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).`
|
|
487
578
|
});
|
|
488
579
|
}
|
|
489
|
-
if (hits.length >= Math.min(limits.maxHits, 8))
|
|
580
|
+
if (hits.length >= Math.min(limits.maxHits, 8)) {
|
|
581
|
+
cutShort = "the recording cap filled with triggers left unopened \u2014 enough was found to fail the page, not enough to clear the rest of it";
|
|
582
|
+
break;
|
|
583
|
+
}
|
|
490
584
|
}
|
|
491
|
-
|
|
585
|
+
const capped = setup[0]?.total;
|
|
586
|
+
const why = cutShort ?? (typeof capped === "number" && capped > triggers.length ? `only ${triggers.length} of the ${capped} hover triggers on this page were tagged \u2014 the rest were never opened` : void 0) ?? (unreachable > 0 ? `${unreachable} hover trigger(s) never became actionable, so their content was never opened or dismissed` : void 0);
|
|
587
|
+
return { hits, complete: !why, ...why ? { why } : {} };
|
|
492
588
|
}
|
|
493
589
|
async function runLiveProbes(page, opts = {}) {
|
|
494
590
|
const limits = { ...PROBE_DEFAULTS, ...opts.limits };
|
|
@@ -502,6 +598,7 @@ async function runLiveProbes(page, opts = {}) {
|
|
|
502
598
|
const size = canResize ? page.viewportSize() ?? null : null;
|
|
503
599
|
const restore = size ?? { width: 1280, height: 900 };
|
|
504
600
|
const out = {
|
|
601
|
+
v: PROBES_VERSION,
|
|
505
602
|
focusVisible: [],
|
|
506
603
|
hover: [],
|
|
507
604
|
keyboardTrap: [],
|
|
@@ -586,26 +683,190 @@ async function runLiveProbes(page, opts = {}) {
|
|
|
586
683
|
if (r) {
|
|
587
684
|
out.focusVisible = r.visible;
|
|
588
685
|
out.focusObscured = r.obscured;
|
|
589
|
-
|
|
590
|
-
|
|
686
|
+
for (const sc of ["2.4.7", "2.4.11"]) {
|
|
687
|
+
if (!want(sc)) continue;
|
|
688
|
+
if (r.complete) out.probed.push(sc);
|
|
689
|
+
else skip(sc, r.why ?? "the walk of the tab ring did not cross the whole of it");
|
|
690
|
+
}
|
|
591
691
|
}
|
|
592
692
|
}
|
|
593
693
|
if (want("2.1.2") && canType) {
|
|
594
|
-
const r = await bounded("2.1.2", () =>
|
|
694
|
+
const r = await bounded("2.1.2", () => probeKeyboardTrapRing(page, limits, deadline));
|
|
595
695
|
if (r) {
|
|
596
|
-
out.keyboardTrap = r;
|
|
597
|
-
out.probed.push("2.1.2");
|
|
696
|
+
out.keyboardTrap = r.hits;
|
|
697
|
+
if (r.complete) out.probed.push("2.1.2");
|
|
698
|
+
else skip("2.1.2", r.why ?? "the walk of the tab ring did not cross the whole of it");
|
|
598
699
|
}
|
|
599
700
|
}
|
|
600
701
|
if (want("1.4.13") && canHover && canType) {
|
|
601
|
-
const r = await bounded("1.4.13", () =>
|
|
702
|
+
const r = await bounded("1.4.13", () => probeHoverWalk(page, limits, deadline));
|
|
602
703
|
if (r) {
|
|
603
|
-
out.hover = r;
|
|
604
|
-
out.probed.push("1.4.13");
|
|
704
|
+
out.hover = r.hits;
|
|
705
|
+
if (r.complete) out.probed.push("1.4.13");
|
|
706
|
+
else skip("1.4.13", r.why ?? "the hover pass did not open every trigger on the page");
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
if (want("4.1.3") && opts.liveRegion) {
|
|
710
|
+
const clicks = typeof opts.liveRegion === "object" && opts.liveRegion.clicks === true;
|
|
711
|
+
const r = await bounded("4.1.3", () => probeLiveRegion(page, opts.lang ?? "fr", clicks));
|
|
712
|
+
if (r) {
|
|
713
|
+
out.liveRegion = r.hits;
|
|
714
|
+
if (r.complete) out.probed.push("4.1.3");
|
|
715
|
+
else skip("4.1.3", r.why ?? "the live-region pass did not exercise the whole page");
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
for (const [bucket, sc] of UNREADABLE_BY_OLDER) {
|
|
719
|
+
if (out[bucket]?.length) {
|
|
720
|
+
const at = out.probed.indexOf(sc);
|
|
721
|
+
if (at >= 0) out.probed.splice(at, 1);
|
|
605
722
|
}
|
|
606
723
|
}
|
|
607
724
|
return out;
|
|
608
725
|
}
|
|
726
|
+
var DESTRUCTIVE_NAME_RE = "\\b(supprim|retir|effac|envoy|valid|confirm|pay|achet|command|delete|remove|eras|clear|send|submit|buy|order)";
|
|
727
|
+
function liveRegionExpr(detail, allowClicks) {
|
|
728
|
+
const d = JSON.stringify(detail);
|
|
729
|
+
const clickLoop = allowClicks ? `
|
|
730
|
+
// click button[type=button] only (never a submit/link), skipping destructive names
|
|
731
|
+
const dangerous = new RegExp(${JSON.stringify(DESTRUCTIVE_NAME_RE)}, 'i');
|
|
732
|
+
const nameOf = (b) => {
|
|
733
|
+
let n = (b.getAttribute('aria-label') || '') + ' ' + (b.textContent || '') + ' ' + (b.getAttribute('title') || '');
|
|
734
|
+
// ALL aria-labelledby ids (attribute trimmed): a destructive verb may sit in ANY
|
|
735
|
+
// referenced id, and the value may carry stray leading/trailing whitespace.
|
|
736
|
+
for (const id of (b.getAttribute('aria-labelledby') || '').trim().split(/\\s+/)) {
|
|
737
|
+
if (!id) continue;
|
|
738
|
+
const t = document.getElementById(id);
|
|
739
|
+
if (t) n += ' ' + (t.textContent || '');
|
|
740
|
+
}
|
|
741
|
+
// Icon-only buttons: the name lives in img[alt] (an attribute \u2014 invisible to
|
|
742
|
+
// textContent) or an svg <title> (belt-and-braces; textContent usually includes it).
|
|
743
|
+
for (const im of Array.from(b.querySelectorAll('img[alt]'))) n += ' ' + (im.getAttribute('alt') || '');
|
|
744
|
+
for (const ti of Array.from(b.querySelectorAll('svg title'))) n += ' ' + (ti.textContent || '');
|
|
745
|
+
return n;
|
|
746
|
+
};
|
|
747
|
+
for (const b of Array.from(document.querySelectorAll('button[type="button"]'))) {
|
|
748
|
+
if (count >= 20 || hits.length >= 10) { untried += 1; continue; }
|
|
749
|
+
if (b.disabled || !__vis(b)) continue;
|
|
750
|
+
if (dangerous.test(nameOf(b))) { untried += 1; continue; } // defense-in-depth: never click a destructive-named button
|
|
751
|
+
const before = location.href;
|
|
752
|
+
try { b.click(); } catch (_) {}
|
|
753
|
+
await settle();
|
|
754
|
+
if (location.href !== before) { obs.disconnect(); return { hits: hits, untried: untried, navigated: true }; }
|
|
755
|
+
drain();
|
|
756
|
+
count++;
|
|
757
|
+
}` : `
|
|
758
|
+
// CLICKS DISABLED, and the buttons are counted rather than ignored. A status message very
|
|
759
|
+
// often appears after a button press and nothing else, so a pass that never pressed one has
|
|
760
|
+
// not measured 4.1.3 on this page -- it has measured the fields. Reporting how many it
|
|
761
|
+
// declined is what lets the caller withhold the credit instead of publishing silence.
|
|
762
|
+
for (const b of Array.from(document.querySelectorAll('button[type="button"]'))) {
|
|
763
|
+
if (!b.disabled && __vis(b)) untried += 1;
|
|
764
|
+
}`;
|
|
765
|
+
return `(async () => { ${PRELUDE}
|
|
766
|
+
const isLive = (node) => {
|
|
767
|
+
let el = node && node.nodeType === 1 ? node : (node ? node.parentElement : null);
|
|
768
|
+
while (el && el !== document.documentElement) {
|
|
769
|
+
const live = (el.getAttribute && el.getAttribute('aria-live')) || '';
|
|
770
|
+
const role = (el.getAttribute && el.getAttribute('role')) || '';
|
|
771
|
+
if (live === 'polite' || live === 'assertive') return true;
|
|
772
|
+
if (role === 'status' || role === 'alert' || role === 'log') return true;
|
|
773
|
+
el = el.parentElement;
|
|
774
|
+
}
|
|
775
|
+
return false;
|
|
776
|
+
};
|
|
777
|
+
const hits = [];
|
|
778
|
+
const seen = new Set();
|
|
779
|
+
const records = [];
|
|
780
|
+
const obs = new MutationObserver((muts) => { for (const m of muts) records.push(m); });
|
|
781
|
+
obs.observe(document.body, { subtree: true, childList: true, characterData: true });
|
|
782
|
+
const settle = () => new Promise((r) => setTimeout(r, 40));
|
|
783
|
+
const drain = () => {
|
|
784
|
+
for (const m of records.splice(0)) {
|
|
785
|
+
const targets = m.type === 'characterData' ? [m.target] : Array.from(m.addedNodes);
|
|
786
|
+
for (const t of targets) {
|
|
787
|
+
if (!t || (t.textContent || '').trim().length === 0) continue;
|
|
788
|
+
if (isLive(t)) continue;
|
|
789
|
+
const host = t.nodeType === 1 ? t : t.parentElement;
|
|
790
|
+
if (!host || !__vis(host)) continue;
|
|
791
|
+
const key = __sel(host);
|
|
792
|
+
if (seen.has(key)) continue;
|
|
793
|
+
seen.add(key);
|
|
794
|
+
hits.push({ selector: key, html: __html(host), detail: ${d} });
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
};
|
|
798
|
+
let count = 0;
|
|
799
|
+
// How many candidate interactions this pass did NOT perform -- caps, a destructive-sounding
|
|
800
|
+
// name, clicks turned off. A probe that skipped half a page has not measured it, and the
|
|
801
|
+
// caller may not read its silence as conformity.
|
|
802
|
+
let untried = 0;${clickLoop}
|
|
803
|
+
// toggle checkbox/radio, then restore
|
|
804
|
+
for (const t of Array.from(document.querySelectorAll('input[type="checkbox"], input[type="radio"]'))) {
|
|
805
|
+
if (count >= 40 || hits.length >= 10) { untried += 1; continue; }
|
|
806
|
+
if (t.disabled || !__vis(t)) continue;
|
|
807
|
+
const before = location.href;
|
|
808
|
+
const prev = t.checked;
|
|
809
|
+
// A RADIO IS NOT A CHECKBOX: clicking one UNCHECKS its pair, and restoring only the one we
|
|
810
|
+
// clicked left the group with nothing selected -- a form the caller's next assertion, or
|
|
811
|
+
// the user, finds broken. Remember which member of the group was checked and put THAT back.
|
|
812
|
+
let group = null;
|
|
813
|
+
if (t.type === 'radio' && t.name) {
|
|
814
|
+
try {
|
|
815
|
+
const form = t.form || document;
|
|
816
|
+
for (const r of Array.from(form.querySelectorAll('input[type="radio"]'))) { if (r.name === t.name && r.checked) { group = r; break; } }
|
|
817
|
+
} catch (_) {}
|
|
818
|
+
}
|
|
819
|
+
try { t.click(); } catch (_) {}
|
|
820
|
+
await settle();
|
|
821
|
+
if (location.href !== before) { obs.disconnect(); return { hits: hits, untried: untried, navigated: true }; }
|
|
822
|
+
drain();
|
|
823
|
+
try {
|
|
824
|
+
if (group) { if (!group.checked) { group.checked = true; group.dispatchEvent(new Event('change', { bubbles: true })); } }
|
|
825
|
+
else if (t.checked !== prev) { t.checked = prev; t.dispatchEvent(new Event('change', { bubbles: true })); }
|
|
826
|
+
} catch (_) {}
|
|
827
|
+
count++;
|
|
828
|
+
}
|
|
829
|
+
// fill text inputs, then restore
|
|
830
|
+
for (const inp of Array.from(document.querySelectorAll('input[type="text"], input[type="email"], input[type="search"], textarea'))) {
|
|
831
|
+
if (count >= 60 || hits.length >= 10) { untried += 1; continue; }
|
|
832
|
+
if (inp.disabled || inp.readOnly || !__vis(inp)) continue;
|
|
833
|
+
const before = location.href;
|
|
834
|
+
const prev = inp.value == null ? '' : String(inp.value);
|
|
835
|
+
try { inp.value = 'test 123'; inp.dispatchEvent(new Event('input', { bubbles: true })); inp.dispatchEvent(new Event('change', { bubbles: true })); } catch (_) {}
|
|
836
|
+
await settle();
|
|
837
|
+
if (location.href !== before) { obs.disconnect(); return { hits: hits, untried: untried, navigated: true }; }
|
|
838
|
+
drain();
|
|
839
|
+
try { inp.value = prev; inp.dispatchEvent(new Event('input', { bubbles: true })); } catch (_) {}
|
|
840
|
+
count++;
|
|
841
|
+
}
|
|
842
|
+
obs.disconnect();
|
|
843
|
+
return { hits: hits.slice(0, 10), untried: untried, navigated: false };
|
|
844
|
+
})()`;
|
|
845
|
+
}
|
|
846
|
+
var LIVE_REGION_DETAIL = {
|
|
847
|
+
fr: "Mise \xE0 jour de contenu d\xE9clench\xE9e par une interaction hors d'une r\xE9gion live (aria-live / role=status|alert|log) \u2014 probablement non restitu\xE9e aux technologies d'assistance.",
|
|
848
|
+
en: "Content update triggered by an interaction outside any live region (aria-live / role=status|alert|log) \u2014 likely not announced to assistive technology."
|
|
849
|
+
};
|
|
850
|
+
async function probeLiveRegion(page, lang, allowClicks) {
|
|
851
|
+
const detail = LIVE_REGION_DETAIL[lang] ?? LIVE_REGION_DETAIL.en;
|
|
852
|
+
const r = await page.evaluate(liveRegionExpr(detail, allowClicks));
|
|
853
|
+
const hits = r?.hits ?? [];
|
|
854
|
+
if (r?.navigated) {
|
|
855
|
+
return {
|
|
856
|
+
hits,
|
|
857
|
+
complete: false,
|
|
858
|
+
why: "an interaction navigated away mid-pass \u2014 everything after it happened on another page, and this one was not finished"
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
if (r?.untried > 0) {
|
|
862
|
+
return {
|
|
863
|
+
hits,
|
|
864
|
+
complete: false,
|
|
865
|
+
why: `${r.untried} interactive element(s) were never exercised (clicks disabled, a destructive-sounding name, or a cap) \u2014 a status message that only appears after one of them would not have been seen`
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
return { hits, complete: true };
|
|
869
|
+
}
|
|
609
870
|
var REMOVE_TEXT_SPACING_STEP = `(() => {
|
|
610
871
|
const sheets = Array.from(document.querySelectorAll('style'));
|
|
611
872
|
for (const s of sheets) {
|
|
@@ -613,6 +874,11 @@ var REMOVE_TEXT_SPACING_STEP = `(() => {
|
|
|
613
874
|
}
|
|
614
875
|
return true;
|
|
615
876
|
})()`;
|
|
877
|
+
var UNREADABLE_BY_OLDER = [
|
|
878
|
+
["focusObscured", "2.4.11"],
|
|
879
|
+
["keyboardTrap", "2.1.2"],
|
|
880
|
+
["liveRegion", "4.1.3"]
|
|
881
|
+
];
|
|
616
882
|
|
|
617
883
|
// src/integrations/core.ts
|
|
618
884
|
import { spawnSync } from "child_process";
|
|
@@ -812,9 +1078,13 @@ async function checkA11y(page, opts = {}) {
|
|
|
812
1078
|
);
|
|
813
1079
|
}
|
|
814
1080
|
let probes;
|
|
815
|
-
if (opts.probes) {
|
|
1081
|
+
if (opts.probes || opts.liveRegion) {
|
|
816
1082
|
try {
|
|
817
|
-
probes = await runLiveProbes(page,
|
|
1083
|
+
probes = await runLiveProbes(page, {
|
|
1084
|
+
...probeOptions(opts.probes),
|
|
1085
|
+
...opts.probes ? {} : { only: ["4.1.3"] },
|
|
1086
|
+
...opts.liveRegion ? { liveRegion: opts.liveRegion } : {}
|
|
1087
|
+
});
|
|
818
1088
|
} catch (e) {
|
|
819
1089
|
console.warn(
|
|
820
1090
|
`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.`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ultra11y",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.42.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",
|