ultra11y 5.41.2 → 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.
@@ -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.41.2",
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.41.2",
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.41.2",
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.41.2";
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, 8.5 and 10.1** may earn `C` from a complete silent measurement. For the other
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
- **103 of 106 criteria require adjudication to earn `C`**.
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
@@ -1,4 +1,4 @@
1
- import { S as SnapshotPayload, F as FindingLike } from './payload-DvcIA6VQ.js';
1
+ import { S as SnapshotPayload, F as FindingLike } from './payload-Bb1uQH1q.js';
2
2
 
3
3
  type On = (event: string, handler: any) => void;
4
4
  interface TaskPayload extends SnapshotPayload {
package/dist/cypress.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { C as CheckOptions } from './payload-DvcIA6VQ.js';
2
- export { s as slugify } from './payload-DvcIA6VQ.js';
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
@@ -1,4 +1,4 @@
1
- import { C as CheckOptions, A as AuditLike } from './payload-DvcIA6VQ.js';
1
+ import { C as CheckOptions, A as AuditLike } from './payload-Bb1uQH1q.js';
2
2
 
3
3
  interface PlaywrightPage {
4
4
  evaluate(script: string): Promise<unknown>;
@@ -235,7 +235,13 @@ 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
- 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])';
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])';
239
245
  // A BOX THAT IS ANIMATING CANNOT BE COMPARED ACROSS TIME, so it contributes a constant.
240
246
  //
241
247
  // The snapshot is taken before Tab and re-read after it, and the properties compared include
@@ -268,6 +274,7 @@ function focusSetupExpr(scope = "", maxFocusables = PROBE_DEFAULTS.maxFocusables
268
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'); }
269
275
  const roots = ${rootExpr};
270
276
  const focusables = [];
277
+ let total = 0;
271
278
  for (const root of Array.from(roots)) {
272
279
  if (root.matches && root.matches(sel)) focusables.push(root);
273
280
  for (const e of Array.from(root.querySelectorAll(sel))) focusables.push(e);
@@ -278,20 +285,27 @@ function focusSetupExpr(scope = "", maxFocusables = PROBE_DEFAULTS.maxFocusables
278
285
  const proxy = proxyFor(e);
279
286
  if (!proxy) continue;
280
287
  const key = 'k' + n;
288
+ total++;
289
+ if (n >= ${maxFocusables}) continue; // tagged the cap; keep COUNTING so the caller knows it was cut
281
290
  e.setAttribute('data-u11y-f', key);
282
291
  proxy.setAttribute('data-u11y-fp', key);
283
292
  window.__u11yF[key] = { rest: snap(proxy), sel: __sel(proxy), html: __html(proxy) };
284
293
  n++;
285
- if (n >= ${maxFocusables}) break;
286
294
  }
287
- return n;
295
+ return { n: n, total: total };
288
296
  })()`;
289
297
  }
290
298
  var FOCUS_CHECK_PROBE = `(() => {
291
299
  const e = document.activeElement;
300
+ // FOCUS LEFT THE DOCUMENT \u2014 the normal end of a tab ring, and nothing to measure.
292
301
  if (!e || e === document.body || e === document.documentElement) return null;
293
302
  const key = e.getAttribute && e.getAttribute('data-u11y-f');
294
- if (!key || !window.__u11yF || !window.__u11yF[key]) return null;
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: '' };
295
309
  const rec = window.__u11yF[key];
296
310
  const proxy = document.querySelector('[data-u11y-fp="' + key + '"]') || e;
297
311
  // THE SAME THREE BOXES pass 1 snapshotted, in the same order. A design system that paints
@@ -346,41 +360,70 @@ var FOCUS_OBSCURED_PROBE = `(() => { ${PRELUDE}
346
360
  var HOVER_SETUP_PROBE = `(() => { ${PRELUDE}
347
361
  const out = [];
348
362
  let n = 0;
363
+ let total = 0;
349
364
  for (const e of Array.from(document.querySelectorAll('[aria-describedby]'))) {
350
365
  const id = (e.getAttribute('aria-describedby') || '').split(/\\s+/)[0];
351
366
  if (!id) continue;
352
367
  const t = document.getElementById(id);
353
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;
354
374
  const s = getComputedStyle(t);
355
375
  const hidden = s.display === 'none' || s.visibility === 'hidden' || t.getBoundingClientRect().height === 0;
356
376
  if (!hidden) continue;
377
+ total++;
378
+ if (n >= 10) continue; // tagged ten; keep COUNTING so the caller knows what it did not see
357
379
  const key = 'h' + n;
358
380
  e.setAttribute('data-u11y-h', key);
359
381
  out.push({ key: key, target: id, selector: __sel(e) });
360
382
  n++;
361
- if (n >= 10) break;
362
383
  }
384
+ for (const o of out) o.total = total;
363
385
  return out;
364
386
  })()`;
365
387
  function hoverVisibleExpr(id, wantHidden = false) {
366
388
  const j = JSON.stringify(id);
367
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"}; })()`;
368
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
+ }
369
400
  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: [] };
401
+ const { count, capped } = readSetup(await page.evaluate(focusSetupExpr(scope, limits.maxFocusables)), limits);
402
+ if (!count) return { visible: [], obscured: [], complete: true };
372
403
  const hits = [];
373
404
  const obscured = [];
374
405
  const seen = /* @__PURE__ */ new Set();
375
406
  const limit = tabPressBudget(count, limits);
376
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;
377
410
  for (let i = 0; i < limit; i++) {
378
- if (deadline?.out()) break;
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
+ }
379
415
  await page.keyboard.press("Tab");
380
416
  const r = await page.evaluate(FOCUS_CHECK_PROBE);
381
417
  if (!r) continue;
418
+ if (r.untagged) {
419
+ untagged++;
420
+ continue;
421
+ }
382
422
  if (r.key === prevKey) continue;
383
- if (seen.has(r.key)) break;
423
+ if (seen.has(r.key)) {
424
+ cutShort = void 0;
425
+ break;
426
+ }
384
427
  seen.add(r.key);
385
428
  prevKey = r.key;
386
429
  if (!r.changed) {
@@ -400,9 +443,14 @@ async function probeFocusRing(page, scope = "", limits = PROBE_DEFAULTS, deadlin
400
443
  });
401
444
  }
402
445
  }
403
- if (hits.length >= 20 && obscured.length >= 20) break;
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
+ }
404
450
  }
405
- return { visible: hits, obscured };
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 } : {} };
406
454
  }
407
455
  var NATIVE_SEGMENT_STOPS = {
408
456
  date: 5,
@@ -422,53 +470,85 @@ var FOCUS_WHERE_PROBE = `(() => { ${PRELUDE}
422
470
  const type = e.tagName === 'INPUT' ? (e.getAttribute('type') || 'text').toLowerCase() : '';
423
471
  return { key: key || __sel(e), tagged: !!key, selector: __sel(e), html: __html(e), segments: stops[type] || 1 };
424
472
  })()`;
425
- async function probeKeyboardTrap(page, limits = PROBE_DEFAULTS, deadline) {
426
- const count = await page.evaluate(focusSetupExpr("", limits.maxFocusables));
427
- 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 };
428
476
  const hits = [];
429
477
  const seen = /* @__PURE__ */ new Set();
430
478
  const confirmPresses = 2;
431
479
  const limit = tabPressBudget(count, limits);
432
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`;
433
482
  for (let i = 0; i < limit; i++) {
434
- if (deadline?.out()) break;
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
+ }
435
487
  await page.keyboard.press("Tab");
436
488
  const now = await page.evaluate(FOCUS_WHERE_PROBE);
437
- if (!now) break;
489
+ if (!now) {
490
+ cutShort = void 0;
491
+ break;
492
+ }
438
493
  if (prev?.tagged && now.tagged && now.key === prev.key) {
439
494
  const budget = Math.max(confirmPresses, (now.segments ?? 1) - 1);
440
495
  let stuck = true;
496
+ let confirmed = true;
441
497
  for (let k = 0; k < budget && stuck; k++) {
442
- if (deadline?.out()) break;
498
+ if (deadline?.out()) {
499
+ confirmed = false;
500
+ break;
501
+ }
443
502
  await page.keyboard.press("Tab");
444
503
  const again = await page.evaluate(FOCUS_WHERE_PROBE);
445
504
  stuck = again !== null && again.tagged === true && again.key === now.key;
446
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
+ }
447
510
  if (stuck) {
448
511
  hits.push({
449
512
  selector: now.selector,
450
513
  html: now.html,
451
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).`
452
515
  });
516
+ cutShort = void 0;
453
517
  break;
454
518
  }
455
519
  }
456
520
  if (now.key !== prev?.key) {
457
- if (seen.has(now.key)) break;
521
+ if (seen.has(now.key)) {
522
+ cutShort = void 0;
523
+ break;
524
+ }
458
525
  seen.add(now.key);
459
526
  }
460
527
  prev = now;
461
528
  }
462
- return hits;
529
+ if (cutShort && seen.size >= count) cutShort = void 0;
530
+ const why = cappedRing(capped, limits) ?? cutShort;
531
+ return { hits, complete: !why, ...why ? { why } : {} };
463
532
  }
464
- async function probeHover(page, limits = PROBE_DEFAULTS, deadline) {
465
- const triggers = await page.evaluate(HOVER_SETUP_PROBE);
533
+ async function probeHoverWalk(page, limits = PROBE_DEFAULTS, deadline) {
534
+ const setup = await page.evaluate(HOVER_SETUP_PROBE);
535
+ const triggers = setup;
466
536
  const hits = [];
467
- for (const tr of triggers.slice(0, Math.max(1, limits.maxTriggers))) {
468
- if (deadline?.out()) break;
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
+ }
469
548
  try {
470
549
  await page.hover(`[data-u11y-h="${tr.key}"]`, { timeout: actionTimeout(limits, deadline) });
471
550
  } catch {
551
+ unreachable++;
472
552
  continue;
473
553
  }
474
554
  await page.waitForTimeout(150);
@@ -486,9 +566,14 @@ async function probeHover(page, limits = PROBE_DEFAULTS, deadline) {
486
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).`
487
567
  });
488
568
  }
489
- if (hits.length >= Math.min(limits.maxHits, 8)) break;
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
+ }
490
573
  }
491
- return hits;
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 } : {} };
492
577
  }
493
578
  async function runLiveProbes(page, opts = {}) {
494
579
  const limits = { ...PROBE_DEFAULTS, ...opts.limits };
@@ -586,26 +671,184 @@ async function runLiveProbes(page, opts = {}) {
586
671
  if (r) {
587
672
  out.focusVisible = r.visible;
588
673
  out.focusObscured = r.obscured;
589
- if (want("2.4.7")) out.probed.push("2.4.7");
590
- if (want("2.4.11")) out.probed.push("2.4.11");
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
+ }
591
679
  }
592
680
  }
593
681
  if (want("2.1.2") && canType) {
594
- const r = await bounded("2.1.2", () => probeKeyboardTrap(page, limits, deadline));
682
+ const r = await bounded("2.1.2", () => probeKeyboardTrapRing(page, limits, deadline));
595
683
  if (r) {
596
- out.keyboardTrap = r;
597
- 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");
598
687
  }
599
688
  }
600
689
  if (want("1.4.13") && canHover && canType) {
601
- const r = await bounded("1.4.13", () => probeHover(page, limits, deadline));
690
+ const r = await bounded("1.4.13", () => probeHoverWalk(page, limits, deadline));
691
+ if (r) {
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));
602
700
  if (r) {
603
- out.hover = r;
604
- out.probed.push("1.4.13");
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");
605
704
  }
606
705
  }
607
706
  return out;
608
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
+ }
609
852
  var REMOVE_TEXT_SPACING_STEP = `(() => {
610
853
  const sheets = Array.from(document.querySelectorAll('style'));
611
854
  for (const s of sheets) {
@@ -814,7 +1057,7 @@ async function checkA11y(page, opts = {}) {
814
1057
  let probes;
815
1058
  if (opts.probes) {
816
1059
  try {
817
- probes = await runLiveProbes(page, probeOptions(opts.probes));
1060
+ probes = await runLiveProbes(page, { ...probeOptions(opts.probes), ...opts.liveRegion ? { liveRegion: opts.liveRegion } : {} });
818
1061
  } catch (e) {
819
1062
  console.warn(
820
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.41.2",
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
- const COMPLETE_BY_SILENCE = new Set(["8.3", "8.5", "10.1"]);
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);