ultra11y 5.41.1 → 5.42.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/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-Bb1uQH1q.d.ts} +15 -0
- package/dist/playwright.d.ts +1 -1
- package/dist/playwright.mjs +296 -36
- package/package.json +1 -1
- package/scripts/build-pack-rgaa.mjs +25 -1
- package/scripts/ultra11y.mjs +625 -182
- package/skills/review-a11y/SKILL.md +1 -1
- package/skills/review-a11y/scripts/ultra11y.mjs +625 -182
- 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 +625 -182
- package/skills/ultra11y/templates/gitlab-ci.yml +1 -1
- package/src/audit.ts +15 -0
- 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 +13 -0
- package/src/integrations/playwright.ts +15 -1
- package/src/ledger.ts +168 -4
- package/src/probes.ts +457 -37
- package/src/report.ts +206 -10
- package/src/scan-local.ts +149 -153
- package/src/scan.ts +22 -3
- package/src/snapshot.ts +22 -0
- package/src/types.ts +15 -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.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…): 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.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…): 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.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.42.0";
|
|
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-Bb1uQH1q.js';
|
|
2
|
+
export { s as slugify } from './payload-Bb1uQH1q.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,21 @@ 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
|
+
liveRegion?: boolean | {
|
|
85
|
+
clicks?: boolean;
|
|
86
|
+
};
|
|
72
87
|
/** Run AXE-CORE on the page before recording it.
|
|
73
88
|
*
|
|
74
89
|
* `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
|
@@ -235,8 +235,29 @@ var TEXT_SPACING_PROBE = `(() => { ${PRELUDE}
|
|
|
235
235
|
function focusSetupExpr(scope = "", maxFocusables = PROBE_DEFAULTS.maxFocusables) {
|
|
236
236
|
const rootExpr = scope ? `document.querySelectorAll(${JSON.stringify(scope)})` : `[document.documentElement]`;
|
|
237
237
|
return `(() => { ${PRELUDE}
|
|
238
|
-
|
|
239
|
-
|
|
238
|
+
// FOCUSABLE MEANS FOCUSABLE, NOT "the six tags we thought of". This list decides which
|
|
239
|
+
// elements get tagged, and since the walk may now license a conformity, an element missing
|
|
240
|
+
// from it is not merely unmeasured -- it is silently cleared. A page whose only control is a
|
|
241
|
+
// <summary> tagged nothing at all, so the count was zero, so the ring was "vacuously whole",
|
|
242
|
+
// so 2.4.7 and 2.4.11 closed without a single Tab press.
|
|
243
|
+
// (No backticks in this comment: it lives inside a template literal.)
|
|
244
|
+
const sel = 'a[href],area[href],button:not([disabled]),input:not([type=hidden]):not([disabled]),select:not([disabled]),textarea:not([disabled]),summary,iframe,audio[controls],video[controls],[contenteditable]:not([contenteditable="false"]),[tabindex]:not([tabindex="-1"]),[role=button]:not([disabled])';
|
|
245
|
+
// A BOX THAT IS ANIMATING CANNOT BE COMPARED ACROSS TIME, so it contributes a constant.
|
|
246
|
+
//
|
|
247
|
+
// The snapshot is taken before Tab and re-read after it, and the properties compared include
|
|
248
|
+
// background-color and colour \u2014 so a pseudo-element pulsing on a keyframe animation differs
|
|
249
|
+
// between the two reads for a reason that has nothing to do with focus. That reads as \xAB the
|
|
250
|
+
// focus is visible \xBB on a control that has no indicator at all: a MISSED non-conformity,
|
|
251
|
+
// strictly worse than the false positive the pseudo-element read was added to remove.
|
|
252
|
+
//
|
|
253
|
+
// A transition is not affected and must not be excluded: it only moves on a state change,
|
|
254
|
+
// so its at-rest value is stable and a focus transition is exactly what we want to see.
|
|
255
|
+
//
|
|
256
|
+
// When every part of an element is animated, both sides collapse to the same constant and
|
|
257
|
+
// the probe reports \xAB no visible change \xBB \u2014 a finding rather than a silent pass. That is the
|
|
258
|
+
// safe direction: this tool would rather name something a human can dismiss than clear
|
|
259
|
+
// something nobody will look at again.
|
|
260
|
+
const __style = (e, pseudo) => { const s = getComputedStyle(e, pseudo); if (s.animationName && s.animationName !== 'none') return 'animated'; return [s.outlineStyle, s.outlineWidth, s.outlineColor, s.boxShadow, s.borderColor, s.borderTopWidth, s.borderBottomWidth, s.backgroundColor, s.color, s.textDecorationLine].join('|'); };
|
|
240
261
|
const snap = (e) => [__style(e, null), __style(e, '::before'), __style(e, '::after')].join('#');
|
|
241
262
|
// Visually-hidden radio/checkbox \u2192 measure its visible label/proxy, not the input.
|
|
242
263
|
const proxyFor = (e) => {
|
|
@@ -253,6 +274,7 @@ function focusSetupExpr(scope = "", maxFocusables = PROBE_DEFAULTS.maxFocusables
|
|
|
253
274
|
for (const el of Array.from(document.querySelectorAll('[data-u11y-f],[data-u11y-fp]'))) { el.removeAttribute('data-u11y-f'); el.removeAttribute('data-u11y-fp'); }
|
|
254
275
|
const roots = ${rootExpr};
|
|
255
276
|
const focusables = [];
|
|
277
|
+
let total = 0;
|
|
256
278
|
for (const root of Array.from(roots)) {
|
|
257
279
|
if (root.matches && root.matches(sel)) focusables.push(root);
|
|
258
280
|
for (const e of Array.from(root.querySelectorAll(sel))) focusables.push(e);
|
|
@@ -263,27 +285,36 @@ function focusSetupExpr(scope = "", maxFocusables = PROBE_DEFAULTS.maxFocusables
|
|
|
263
285
|
const proxy = proxyFor(e);
|
|
264
286
|
if (!proxy) continue;
|
|
265
287
|
const key = 'k' + n;
|
|
288
|
+
total++;
|
|
289
|
+
if (n >= ${maxFocusables}) continue; // tagged the cap; keep COUNTING so the caller knows it was cut
|
|
266
290
|
e.setAttribute('data-u11y-f', key);
|
|
267
291
|
proxy.setAttribute('data-u11y-fp', key);
|
|
268
292
|
window.__u11yF[key] = { rest: snap(proxy), sel: __sel(proxy), html: __html(proxy) };
|
|
269
293
|
n++;
|
|
270
|
-
if (n >= ${maxFocusables}) break;
|
|
271
294
|
}
|
|
272
|
-
return n;
|
|
295
|
+
return { n: n, total: total };
|
|
273
296
|
})()`;
|
|
274
297
|
}
|
|
275
298
|
var FOCUS_CHECK_PROBE = `(() => {
|
|
276
299
|
const e = document.activeElement;
|
|
300
|
+
// FOCUS LEFT THE DOCUMENT \u2014 the normal end of a tab ring, and nothing to measure.
|
|
277
301
|
if (!e || e === document.body || e === document.documentElement) return null;
|
|
278
302
|
const key = e.getAttribute && e.getAttribute('data-u11y-f');
|
|
279
|
-
|
|
303
|
+
// FOCUS IS ON SOMETHING THE SETUP NEVER TAGGED, which is a different fact entirely and used
|
|
304
|
+
// to be reported as the same one. A skip link revealed only on focus, a widget that moves
|
|
305
|
+
// focus into a node the selector does not match -- Tab crossed it, nothing measured it, and
|
|
306
|
+
// the walk went on to call itself whole. The caller needs to know the ring contained an
|
|
307
|
+
// element it cannot speak for. (No backticks here: this lives inside a template literal.)
|
|
308
|
+
if (!key || !window.__u11yF || !window.__u11yF[key]) return { untagged: true, key: '', changed: true, selector: '', html: '' };
|
|
280
309
|
const rec = window.__u11yF[key];
|
|
281
310
|
const proxy = document.querySelector('[data-u11y-fp="' + key + '"]') || e;
|
|
282
311
|
// THE SAME THREE BOXES pass 1 snapshotted, in the same order. A design system that paints
|
|
283
312
|
// its control in label::before -- DSFR, GOV.UK, USWDS, Bootstrap -- puts the focus ring
|
|
284
313
|
// there too, and reading only the element would report every one of them as unfocusable.
|
|
285
314
|
// (No backticks in this comment: it lives inside a template literal.)
|
|
286
|
-
|
|
315
|
+
// Same three boxes, same animation exclusion as pass 1 -- an animating box contributes a
|
|
316
|
+
// constant on both sides, so it can never fabricate a difference (nor hide a real one).
|
|
317
|
+
const st = (pseudo) => { const s = getComputedStyle(proxy, pseudo); if (s.animationName && s.animationName !== 'none') return 'animated'; return [s.outlineStyle, s.outlineWidth, s.outlineColor, s.boxShadow, s.borderColor, s.borderTopWidth, s.borderBottomWidth, s.backgroundColor, s.color, s.textDecorationLine].join('|'); };
|
|
287
318
|
const now = [st(null), st('::before'), st('::after')].join('#');
|
|
288
319
|
return { key: key, changed: now !== rec.rest, selector: rec.sel, html: rec.html };
|
|
289
320
|
})()`;
|
|
@@ -329,41 +360,70 @@ var FOCUS_OBSCURED_PROBE = `(() => { ${PRELUDE}
|
|
|
329
360
|
var HOVER_SETUP_PROBE = `(() => { ${PRELUDE}
|
|
330
361
|
const out = [];
|
|
331
362
|
let n = 0;
|
|
363
|
+
let total = 0;
|
|
332
364
|
for (const e of Array.from(document.querySelectorAll('[aria-describedby]'))) {
|
|
333
365
|
const id = (e.getAttribute('aria-describedby') || '').split(/\\s+/)[0];
|
|
334
366
|
if (!id) continue;
|
|
335
367
|
const t = document.getElementById(id);
|
|
336
368
|
if (!t) continue;
|
|
369
|
+
// THE TRIGGER ITSELF HAS TO BE THERE. 1.4.13 is about content revealed on hover or focus,
|
|
370
|
+
// and an element that is not rendered reveals nothing to anybody -- hovering it was always
|
|
371
|
+
// futile, and once an unreachable trigger started withholding the criterion, that futility
|
|
372
|
+
// would have turned into a page nobody could ever clear.
|
|
373
|
+
if (!__vis(e)) continue;
|
|
337
374
|
const s = getComputedStyle(t);
|
|
338
375
|
const hidden = s.display === 'none' || s.visibility === 'hidden' || t.getBoundingClientRect().height === 0;
|
|
339
376
|
if (!hidden) continue;
|
|
377
|
+
total++;
|
|
378
|
+
if (n >= 10) continue; // tagged ten; keep COUNTING so the caller knows what it did not see
|
|
340
379
|
const key = 'h' + n;
|
|
341
380
|
e.setAttribute('data-u11y-h', key);
|
|
342
381
|
out.push({ key: key, target: id, selector: __sel(e) });
|
|
343
382
|
n++;
|
|
344
|
-
if (n >= 10) break;
|
|
345
383
|
}
|
|
384
|
+
for (const o of out) o.total = total;
|
|
346
385
|
return out;
|
|
347
386
|
})()`;
|
|
348
387
|
function hoverVisibleExpr(id, wantHidden = false) {
|
|
349
388
|
const j = JSON.stringify(id);
|
|
350
389
|
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"}; })()`;
|
|
351
390
|
}
|
|
391
|
+
function readSetup(raw, limits) {
|
|
392
|
+
if (typeof raw === "number") return { count: raw, capped: raw >= limits.maxFocusables };
|
|
393
|
+
const o = raw ?? {};
|
|
394
|
+
const count = o.n ?? 0;
|
|
395
|
+
return { count, capped: (o.total ?? count) > count };
|
|
396
|
+
}
|
|
397
|
+
function cappedRing(capped, limits) {
|
|
398
|
+
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;
|
|
399
|
+
}
|
|
352
400
|
async function probeFocusRing(page, scope = "", limits = PROBE_DEFAULTS, deadline) {
|
|
353
|
-
const count = await page.evaluate(focusSetupExpr(scope, limits.maxFocusables));
|
|
354
|
-
if (!count) return { visible: [], obscured: [] };
|
|
401
|
+
const { count, capped } = readSetup(await page.evaluate(focusSetupExpr(scope, limits.maxFocusables)), limits);
|
|
402
|
+
if (!count) return { visible: [], obscured: [], complete: true };
|
|
355
403
|
const hits = [];
|
|
356
404
|
const obscured = [];
|
|
357
405
|
const seen = /* @__PURE__ */ new Set();
|
|
358
406
|
const limit = tabPressBudget(count, limits);
|
|
359
407
|
let prevKey = null;
|
|
408
|
+
let cutShort = `the walk spent its ${limit} Tab presses without the ring ever closing, so the tail of it was never reached`;
|
|
409
|
+
let untagged = 0;
|
|
360
410
|
for (let i = 0; i < limit; i++) {
|
|
361
|
-
if (deadline?.out())
|
|
411
|
+
if (deadline?.out()) {
|
|
412
|
+
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`;
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
362
415
|
await page.keyboard.press("Tab");
|
|
363
416
|
const r = await page.evaluate(FOCUS_CHECK_PROBE);
|
|
364
417
|
if (!r) continue;
|
|
418
|
+
if (r.untagged) {
|
|
419
|
+
untagged++;
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
365
422
|
if (r.key === prevKey) continue;
|
|
366
|
-
if (seen.has(r.key))
|
|
423
|
+
if (seen.has(r.key)) {
|
|
424
|
+
cutShort = void 0;
|
|
425
|
+
break;
|
|
426
|
+
}
|
|
367
427
|
seen.add(r.key);
|
|
368
428
|
prevKey = r.key;
|
|
369
429
|
if (!r.changed) {
|
|
@@ -383,9 +443,14 @@ async function probeFocusRing(page, scope = "", limits = PROBE_DEFAULTS, deadlin
|
|
|
383
443
|
});
|
|
384
444
|
}
|
|
385
445
|
}
|
|
386
|
-
if (hits.length >= 20 && obscured.length >= 20)
|
|
446
|
+
if (hits.length >= 20 && obscured.length >= 20) {
|
|
447
|
+
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`;
|
|
448
|
+
break;
|
|
449
|
+
}
|
|
387
450
|
}
|
|
388
|
-
|
|
451
|
+
if (cutShort && seen.size >= count) cutShort = void 0;
|
|
452
|
+
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);
|
|
453
|
+
return { visible: hits, obscured, complete: !why, ...why ? { why } : {} };
|
|
389
454
|
}
|
|
390
455
|
var NATIVE_SEGMENT_STOPS = {
|
|
391
456
|
date: 5,
|
|
@@ -405,53 +470,85 @@ var FOCUS_WHERE_PROBE = `(() => { ${PRELUDE}
|
|
|
405
470
|
const type = e.tagName === 'INPUT' ? (e.getAttribute('type') || 'text').toLowerCase() : '';
|
|
406
471
|
return { key: key || __sel(e), tagged: !!key, selector: __sel(e), html: __html(e), segments: stops[type] || 1 };
|
|
407
472
|
})()`;
|
|
408
|
-
async function
|
|
409
|
-
const count = await page.evaluate(focusSetupExpr("", limits.maxFocusables));
|
|
410
|
-
if (!count || count < 2) return [];
|
|
473
|
+
async function probeKeyboardTrapRing(page, limits = PROBE_DEFAULTS, deadline) {
|
|
474
|
+
const { count, capped } = readSetup(await page.evaluate(focusSetupExpr("", limits.maxFocusables)), limits);
|
|
475
|
+
if (!count || count < 2) return { hits: [], complete: true };
|
|
411
476
|
const hits = [];
|
|
412
477
|
const seen = /* @__PURE__ */ new Set();
|
|
413
478
|
const confirmPresses = 2;
|
|
414
479
|
const limit = tabPressBudget(count, limits);
|
|
415
480
|
let prev = null;
|
|
481
|
+
let cutShort = `the walk spent its ${limit} Tab presses without the ring ever closing, so the tail of it was never reached`;
|
|
416
482
|
for (let i = 0; i < limit; i++) {
|
|
417
|
-
if (deadline?.out())
|
|
483
|
+
if (deadline?.out()) {
|
|
484
|
+
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`;
|
|
485
|
+
break;
|
|
486
|
+
}
|
|
418
487
|
await page.keyboard.press("Tab");
|
|
419
488
|
const now = await page.evaluate(FOCUS_WHERE_PROBE);
|
|
420
|
-
if (!now)
|
|
489
|
+
if (!now) {
|
|
490
|
+
cutShort = void 0;
|
|
491
|
+
break;
|
|
492
|
+
}
|
|
421
493
|
if (prev?.tagged && now.tagged && now.key === prev.key) {
|
|
422
494
|
const budget = Math.max(confirmPresses, (now.segments ?? 1) - 1);
|
|
423
495
|
let stuck = true;
|
|
496
|
+
let confirmed = true;
|
|
424
497
|
for (let k = 0; k < budget && stuck; k++) {
|
|
425
|
-
if (deadline?.out())
|
|
498
|
+
if (deadline?.out()) {
|
|
499
|
+
confirmed = false;
|
|
500
|
+
break;
|
|
501
|
+
}
|
|
426
502
|
await page.keyboard.press("Tab");
|
|
427
503
|
const again = await page.evaluate(FOCUS_WHERE_PROBE);
|
|
428
504
|
stuck = again !== null && again.tagged === true && again.key === now.key;
|
|
429
505
|
}
|
|
506
|
+
if (!confirmed) {
|
|
507
|
+
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`;
|
|
508
|
+
break;
|
|
509
|
+
}
|
|
430
510
|
if (stuck) {
|
|
431
511
|
hits.push({
|
|
432
512
|
selector: now.selector,
|
|
433
513
|
html: now.html,
|
|
434
514
|
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).`
|
|
435
515
|
});
|
|
516
|
+
cutShort = void 0;
|
|
436
517
|
break;
|
|
437
518
|
}
|
|
438
519
|
}
|
|
439
520
|
if (now.key !== prev?.key) {
|
|
440
|
-
if (seen.has(now.key))
|
|
521
|
+
if (seen.has(now.key)) {
|
|
522
|
+
cutShort = void 0;
|
|
523
|
+
break;
|
|
524
|
+
}
|
|
441
525
|
seen.add(now.key);
|
|
442
526
|
}
|
|
443
527
|
prev = now;
|
|
444
528
|
}
|
|
445
|
-
|
|
529
|
+
if (cutShort && seen.size >= count) cutShort = void 0;
|
|
530
|
+
const why = cappedRing(capped, limits) ?? cutShort;
|
|
531
|
+
return { hits, complete: !why, ...why ? { why } : {} };
|
|
446
532
|
}
|
|
447
|
-
async function
|
|
448
|
-
const
|
|
533
|
+
async function probeHoverWalk(page, limits = PROBE_DEFAULTS, deadline) {
|
|
534
|
+
const setup = await page.evaluate(HOVER_SETUP_PROBE);
|
|
535
|
+
const triggers = setup;
|
|
449
536
|
const hits = [];
|
|
450
|
-
|
|
451
|
-
|
|
537
|
+
let cutShort;
|
|
538
|
+
let unreachable = 0;
|
|
539
|
+
const tried = triggers.slice(0, Math.max(1, limits.maxTriggers));
|
|
540
|
+
if (tried.length < triggers.length) {
|
|
541
|
+
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`;
|
|
542
|
+
}
|
|
543
|
+
for (const tr of tried) {
|
|
544
|
+
if (deadline?.out()) {
|
|
545
|
+
cutShort = `the probe budget of ${limits.budgetMs}ms ran out with triggers left unopened`;
|
|
546
|
+
break;
|
|
547
|
+
}
|
|
452
548
|
try {
|
|
453
549
|
await page.hover(`[data-u11y-h="${tr.key}"]`, { timeout: actionTimeout(limits, deadline) });
|
|
454
550
|
} catch {
|
|
551
|
+
unreachable++;
|
|
455
552
|
continue;
|
|
456
553
|
}
|
|
457
554
|
await page.waitForTimeout(150);
|
|
@@ -469,9 +566,14 @@ async function probeHover(page, limits = PROBE_DEFAULTS, deadline) {
|
|
|
469
566
|
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).`
|
|
470
567
|
});
|
|
471
568
|
}
|
|
472
|
-
if (hits.length >= Math.min(limits.maxHits, 8))
|
|
569
|
+
if (hits.length >= Math.min(limits.maxHits, 8)) {
|
|
570
|
+
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";
|
|
571
|
+
break;
|
|
572
|
+
}
|
|
473
573
|
}
|
|
474
|
-
|
|
574
|
+
const capped = setup[0]?.total;
|
|
575
|
+
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);
|
|
576
|
+
return { hits, complete: !why, ...why ? { why } : {} };
|
|
475
577
|
}
|
|
476
578
|
async function runLiveProbes(page, opts = {}) {
|
|
477
579
|
const limits = { ...PROBE_DEFAULTS, ...opts.limits };
|
|
@@ -569,26 +671,184 @@ async function runLiveProbes(page, opts = {}) {
|
|
|
569
671
|
if (r) {
|
|
570
672
|
out.focusVisible = r.visible;
|
|
571
673
|
out.focusObscured = r.obscured;
|
|
572
|
-
|
|
573
|
-
|
|
674
|
+
for (const sc of ["2.4.7", "2.4.11"]) {
|
|
675
|
+
if (!want(sc)) continue;
|
|
676
|
+
if (r.complete) out.probed.push(sc);
|
|
677
|
+
else skip(sc, r.why ?? "the walk of the tab ring did not cross the whole of it");
|
|
678
|
+
}
|
|
574
679
|
}
|
|
575
680
|
}
|
|
576
681
|
if (want("2.1.2") && canType) {
|
|
577
|
-
const r = await bounded("2.1.2", () =>
|
|
682
|
+
const r = await bounded("2.1.2", () => probeKeyboardTrapRing(page, limits, deadline));
|
|
578
683
|
if (r) {
|
|
579
|
-
out.keyboardTrap = r;
|
|
580
|
-
out.probed.push("2.1.2");
|
|
684
|
+
out.keyboardTrap = r.hits;
|
|
685
|
+
if (r.complete) out.probed.push("2.1.2");
|
|
686
|
+
else skip("2.1.2", r.why ?? "the walk of the tab ring did not cross the whole of it");
|
|
581
687
|
}
|
|
582
688
|
}
|
|
583
689
|
if (want("1.4.13") && canHover && canType) {
|
|
584
|
-
const r = await bounded("1.4.13", () =>
|
|
690
|
+
const r = await bounded("1.4.13", () => probeHoverWalk(page, limits, deadline));
|
|
585
691
|
if (r) {
|
|
586
|
-
out.hover = r;
|
|
587
|
-
out.probed.push("1.4.13");
|
|
692
|
+
out.hover = r.hits;
|
|
693
|
+
if (r.complete) out.probed.push("1.4.13");
|
|
694
|
+
else skip("1.4.13", r.why ?? "the hover pass did not open every trigger on the page");
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
if (want("4.1.3") && opts.liveRegion) {
|
|
698
|
+
const clicks = typeof opts.liveRegion === "object" && opts.liveRegion.clicks === true;
|
|
699
|
+
const r = await bounded("4.1.3", () => probeLiveRegion(page, opts.lang ?? "fr", clicks));
|
|
700
|
+
if (r) {
|
|
701
|
+
out.liveRegion = r.hits;
|
|
702
|
+
if (r.complete) out.probed.push("4.1.3");
|
|
703
|
+
else skip("4.1.3", r.why ?? "the live-region pass did not exercise the whole page");
|
|
588
704
|
}
|
|
589
705
|
}
|
|
590
706
|
return out;
|
|
591
707
|
}
|
|
708
|
+
var DESTRUCTIVE_NAME_RE = "\\b(supprim|retir|effac|envoy|valid|confirm|pay|achet|command|delete|remove|eras|clear|send|submit|buy|order)";
|
|
709
|
+
function liveRegionExpr(detail, allowClicks) {
|
|
710
|
+
const d = JSON.stringify(detail);
|
|
711
|
+
const clickLoop = allowClicks ? `
|
|
712
|
+
// click button[type=button] only (never a submit/link), skipping destructive names
|
|
713
|
+
const dangerous = new RegExp(${JSON.stringify(DESTRUCTIVE_NAME_RE)}, 'i');
|
|
714
|
+
const nameOf = (b) => {
|
|
715
|
+
let n = (b.getAttribute('aria-label') || '') + ' ' + (b.textContent || '') + ' ' + (b.getAttribute('title') || '');
|
|
716
|
+
// ALL aria-labelledby ids (attribute trimmed): a destructive verb may sit in ANY
|
|
717
|
+
// referenced id, and the value may carry stray leading/trailing whitespace.
|
|
718
|
+
for (const id of (b.getAttribute('aria-labelledby') || '').trim().split(/\\s+/)) {
|
|
719
|
+
if (!id) continue;
|
|
720
|
+
const t = document.getElementById(id);
|
|
721
|
+
if (t) n += ' ' + (t.textContent || '');
|
|
722
|
+
}
|
|
723
|
+
// Icon-only buttons: the name lives in img[alt] (an attribute \u2014 invisible to
|
|
724
|
+
// textContent) or an svg <title> (belt-and-braces; textContent usually includes it).
|
|
725
|
+
for (const im of Array.from(b.querySelectorAll('img[alt]'))) n += ' ' + (im.getAttribute('alt') || '');
|
|
726
|
+
for (const ti of Array.from(b.querySelectorAll('svg title'))) n += ' ' + (ti.textContent || '');
|
|
727
|
+
return n;
|
|
728
|
+
};
|
|
729
|
+
for (const b of Array.from(document.querySelectorAll('button[type="button"]'))) {
|
|
730
|
+
if (count >= 20 || hits.length >= 10) { untried += 1; continue; }
|
|
731
|
+
if (b.disabled || !__vis(b)) continue;
|
|
732
|
+
if (dangerous.test(nameOf(b))) { untried += 1; continue; } // defense-in-depth: never click a destructive-named button
|
|
733
|
+
const before = location.href;
|
|
734
|
+
try { b.click(); } catch (_) {}
|
|
735
|
+
await settle();
|
|
736
|
+
if (location.href !== before) { obs.disconnect(); return { hits: hits, untried: untried, navigated: true }; }
|
|
737
|
+
drain();
|
|
738
|
+
count++;
|
|
739
|
+
}` : `
|
|
740
|
+
// CLICKS DISABLED, and the buttons are counted rather than ignored. A status message very
|
|
741
|
+
// often appears after a button press and nothing else, so a pass that never pressed one has
|
|
742
|
+
// not measured 4.1.3 on this page -- it has measured the fields. Reporting how many it
|
|
743
|
+
// declined is what lets the caller withhold the credit instead of publishing silence.
|
|
744
|
+
for (const b of Array.from(document.querySelectorAll('button[type="button"]'))) {
|
|
745
|
+
if (!b.disabled && __vis(b)) untried += 1;
|
|
746
|
+
}`;
|
|
747
|
+
return `(async () => { ${PRELUDE}
|
|
748
|
+
const isLive = (node) => {
|
|
749
|
+
let el = node && node.nodeType === 1 ? node : (node ? node.parentElement : null);
|
|
750
|
+
while (el && el !== document.documentElement) {
|
|
751
|
+
const live = (el.getAttribute && el.getAttribute('aria-live')) || '';
|
|
752
|
+
const role = (el.getAttribute && el.getAttribute('role')) || '';
|
|
753
|
+
if (live === 'polite' || live === 'assertive') return true;
|
|
754
|
+
if (role === 'status' || role === 'alert' || role === 'log') return true;
|
|
755
|
+
el = el.parentElement;
|
|
756
|
+
}
|
|
757
|
+
return false;
|
|
758
|
+
};
|
|
759
|
+
const hits = [];
|
|
760
|
+
const seen = new Set();
|
|
761
|
+
const records = [];
|
|
762
|
+
const obs = new MutationObserver((muts) => { for (const m of muts) records.push(m); });
|
|
763
|
+
obs.observe(document.body, { subtree: true, childList: true, characterData: true });
|
|
764
|
+
const settle = () => new Promise((r) => setTimeout(r, 40));
|
|
765
|
+
const drain = () => {
|
|
766
|
+
for (const m of records.splice(0)) {
|
|
767
|
+
const targets = m.type === 'characterData' ? [m.target] : Array.from(m.addedNodes);
|
|
768
|
+
for (const t of targets) {
|
|
769
|
+
if (!t || (t.textContent || '').trim().length === 0) continue;
|
|
770
|
+
if (isLive(t)) continue;
|
|
771
|
+
const host = t.nodeType === 1 ? t : t.parentElement;
|
|
772
|
+
if (!host || !__vis(host)) continue;
|
|
773
|
+
const key = __sel(host);
|
|
774
|
+
if (seen.has(key)) continue;
|
|
775
|
+
seen.add(key);
|
|
776
|
+
hits.push({ selector: key, html: __html(host), detail: ${d} });
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
};
|
|
780
|
+
let count = 0;
|
|
781
|
+
// How many candidate interactions this pass did NOT perform -- caps, a destructive-sounding
|
|
782
|
+
// name, clicks turned off. A probe that skipped half a page has not measured it, and the
|
|
783
|
+
// caller may not read its silence as conformity.
|
|
784
|
+
let untried = 0;${clickLoop}
|
|
785
|
+
// toggle checkbox/radio, then restore
|
|
786
|
+
for (const t of Array.from(document.querySelectorAll('input[type="checkbox"], input[type="radio"]'))) {
|
|
787
|
+
if (count >= 40 || hits.length >= 10) { untried += 1; continue; }
|
|
788
|
+
if (t.disabled || !__vis(t)) continue;
|
|
789
|
+
const before = location.href;
|
|
790
|
+
const prev = t.checked;
|
|
791
|
+
// A RADIO IS NOT A CHECKBOX: clicking one UNCHECKS its pair, and restoring only the one we
|
|
792
|
+
// clicked left the group with nothing selected -- a form the caller's next assertion, or
|
|
793
|
+
// the user, finds broken. Remember which member of the group was checked and put THAT back.
|
|
794
|
+
let group = null;
|
|
795
|
+
if (t.type === 'radio' && t.name) {
|
|
796
|
+
try {
|
|
797
|
+
const form = t.form || document;
|
|
798
|
+
for (const r of Array.from(form.querySelectorAll('input[type="radio"]'))) { if (r.name === t.name && r.checked) { group = r; break; } }
|
|
799
|
+
} catch (_) {}
|
|
800
|
+
}
|
|
801
|
+
try { t.click(); } catch (_) {}
|
|
802
|
+
await settle();
|
|
803
|
+
if (location.href !== before) { obs.disconnect(); return { hits: hits, untried: untried, navigated: true }; }
|
|
804
|
+
drain();
|
|
805
|
+
try {
|
|
806
|
+
if (group) { if (!group.checked) { group.checked = true; group.dispatchEvent(new Event('change', { bubbles: true })); } }
|
|
807
|
+
else if (t.checked !== prev) { t.checked = prev; t.dispatchEvent(new Event('change', { bubbles: true })); }
|
|
808
|
+
} catch (_) {}
|
|
809
|
+
count++;
|
|
810
|
+
}
|
|
811
|
+
// fill text inputs, then restore
|
|
812
|
+
for (const inp of Array.from(document.querySelectorAll('input[type="text"], input[type="email"], input[type="search"], textarea'))) {
|
|
813
|
+
if (count >= 60 || hits.length >= 10) { untried += 1; continue; }
|
|
814
|
+
if (inp.disabled || inp.readOnly || !__vis(inp)) continue;
|
|
815
|
+
const before = location.href;
|
|
816
|
+
const prev = inp.value == null ? '' : String(inp.value);
|
|
817
|
+
try { inp.value = 'test 123'; inp.dispatchEvent(new Event('input', { bubbles: true })); inp.dispatchEvent(new Event('change', { bubbles: true })); } catch (_) {}
|
|
818
|
+
await settle();
|
|
819
|
+
if (location.href !== before) { obs.disconnect(); return { hits: hits, untried: untried, navigated: true }; }
|
|
820
|
+
drain();
|
|
821
|
+
try { inp.value = prev; inp.dispatchEvent(new Event('input', { bubbles: true })); } catch (_) {}
|
|
822
|
+
count++;
|
|
823
|
+
}
|
|
824
|
+
obs.disconnect();
|
|
825
|
+
return { hits: hits.slice(0, 10), untried: untried, navigated: false };
|
|
826
|
+
})()`;
|
|
827
|
+
}
|
|
828
|
+
var LIVE_REGION_DETAIL = {
|
|
829
|
+
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.",
|
|
830
|
+
en: "Content update triggered by an interaction outside any live region (aria-live / role=status|alert|log) \u2014 likely not announced to assistive technology."
|
|
831
|
+
};
|
|
832
|
+
async function probeLiveRegion(page, lang, allowClicks) {
|
|
833
|
+
const detail = LIVE_REGION_DETAIL[lang] ?? LIVE_REGION_DETAIL.en;
|
|
834
|
+
const r = await page.evaluate(liveRegionExpr(detail, allowClicks));
|
|
835
|
+
const hits = r?.hits ?? [];
|
|
836
|
+
if (r?.navigated) {
|
|
837
|
+
return {
|
|
838
|
+
hits,
|
|
839
|
+
complete: false,
|
|
840
|
+
why: "an interaction navigated away mid-pass \u2014 everything after it happened on another page, and this one was not finished"
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
if (r?.untried > 0) {
|
|
844
|
+
return {
|
|
845
|
+
hits,
|
|
846
|
+
complete: false,
|
|
847
|
+
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`
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
return { hits, complete: true };
|
|
851
|
+
}
|
|
592
852
|
var REMOVE_TEXT_SPACING_STEP = `(() => {
|
|
593
853
|
const sheets = Array.from(document.querySelectorAll('style'));
|
|
594
854
|
for (const s of sheets) {
|
|
@@ -797,7 +1057,7 @@ async function checkA11y(page, opts = {}) {
|
|
|
797
1057
|
let probes;
|
|
798
1058
|
if (opts.probes) {
|
|
799
1059
|
try {
|
|
800
|
-
probes = await runLiveProbes(page, probeOptions(opts.probes));
|
|
1060
|
+
probes = await runLiveProbes(page, { ...probeOptions(opts.probes), ...opts.liveRegion ? { liveRegion: opts.liveRegion } : {} });
|
|
801
1061
|
} catch (e) {
|
|
802
1062
|
console.warn(
|
|
803
1063
|
`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.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",
|
|
@@ -395,7 +395,31 @@ const CANDIDATE_RULE_TESTS = {
|
|
|
395
395
|
"13.8|axe:marquee": ["1"],
|
|
396
396
|
};
|
|
397
397
|
|
|
398
|
-
|
|
398
|
+
// WHICH CRITERIA MAY BE CONCLUDED FROM A CLEAN ENGINE PASS — the shortest list in this file,
|
|
399
|
+
// and the one that decides whether silence is evidence.
|
|
400
|
+
//
|
|
401
|
+
// 10.1 LEFT IT, and the correction goes the expensive way round: this criterion now reaches an
|
|
402
|
+
// adjudicator on every run it did not reach before. It is there because the rule behind it
|
|
403
|
+
// (src/rules/presentation.ts) is deliberately NARROWER than the criterion it serves, and each
|
|
404
|
+
// narrowing was the right call on its own:
|
|
405
|
+
//
|
|
406
|
+
// • `<u>` is excluded wholesale, because the RGAA forbids it only outside HTML5 and flagging
|
|
407
|
+
// it would manufacture a non-conformity on conforming markup;
|
|
408
|
+
// • `width`/`height` are tolerated on nine tags where the glossary names five, because
|
|
409
|
+
// `<video width>` and `<iframe width>` are conforming HTML5 and red-flagging them would
|
|
410
|
+
// fail ordinary accessible markup;
|
|
411
|
+
// • test 10.1.3 — presentation built out of spaces — is TWO heuristics (a word spelled one
|
|
412
|
+
// letter at a time, three or more non-breaking spaces), chosen because they are
|
|
413
|
+
// distinctive enough not to guess.
|
|
414
|
+
//
|
|
415
|
+
// Every one of those is a deliberate under-report, which is the safe direction for a FINDING
|
|
416
|
+
// and the wrong one for a CONFORMITY. « This rule found nothing » and « this criterion is
|
|
417
|
+
// satisfied » are not the same claim when the rule was written to look at less than the
|
|
418
|
+
// criterion asks about.
|
|
419
|
+
//
|
|
420
|
+
// Nothing was added to this list to compensate. The budget lever is the verdict ledger
|
|
421
|
+
// (src/ledger.ts), not a wider definition of what silence proves.
|
|
422
|
+
const COMPLETE_BY_SILENCE = new Set(["8.3", "8.5"]);
|
|
399
423
|
const renderedTier = (ruleId) =>
|
|
400
424
|
ruleId.startsWith("rendered-") || ruleId.startsWith("dyn-") || ruleId.startsWith("axe:") ? "rendered" : "static";
|
|
401
425
|
const mergeTestTier = (current, next) => (current === "static" || next === "static" ? "static" : next);
|