pour-engine 1.38.0 → 1.41.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.
Files changed (50) hide show
  1. package/config/project.config.js +1 -1
  2. package/engine/AUDIT.html +24 -690
  3. package/engine/COVERAGE.md +4 -2
  4. package/engine/README.md +9 -4
  5. package/engine/index.js +34 -8
  6. package/engine/lib/accessible-name.js +81 -64
  7. package/engine/lib/contrast.js +111 -10
  8. package/engine/lib/dom.js +78 -18
  9. package/engine/lib/roles.js +18 -3
  10. package/engine/rules/best-practice/fieldset-legend.js +11 -5
  11. package/engine/rules/best-practice/landmark-unique.js +9 -1
  12. package/engine/rules/best-practice/region.js +10 -2
  13. package/engine/rules/wcag/1.1.1-area-alt.js +6 -1
  14. package/engine/rules/wcag/1.1.1-image-alt.js +2 -1
  15. package/engine/rules/wcag/1.1.1-svg-img-alt.js +14 -17
  16. package/engine/rules/wcag/1.3.1-aria-required-children.js +20 -8
  17. package/engine/rules/wcag/1.3.1-aria-required-parent.js +7 -10
  18. package/engine/rules/wcag/1.3.1-definition-list.js +42 -3
  19. package/engine/rules/wcag/1.3.1-form-label.js +12 -29
  20. package/engine/rules/wcag/1.3.1-list-structure.js +4 -3
  21. package/engine/rules/wcag/1.3.1-table-headers.js +23 -2
  22. package/engine/rules/wcag/1.3.4-orientation.js +117 -48
  23. package/engine/rules/wcag/1.3.5-autocomplete-valid.js +21 -8
  24. package/engine/rules/wcag/1.4.1-link-in-text-block.js +38 -8
  25. package/engine/rules/wcag/1.4.10-reflow.js +3 -3
  26. package/engine/rules/wcag/1.4.11-non-text-contrast.js +9 -1
  27. package/engine/rules/wcag/1.4.12-text-spacing.js +15 -2
  28. package/engine/rules/wcag/1.4.3-color-contrast.js +79 -7
  29. package/engine/rules/wcag/1.4.3-control-contrast.js +35 -6
  30. package/engine/rules/wcag/1.4.4-meta-viewport.js +7 -2
  31. package/engine/rules/wcag/2.1.1-scrollable-region-focusable.js +5 -5
  32. package/engine/rules/wcag/2.2.1-meta-refresh.js +39 -3
  33. package/engine/rules/wcag/2.2.2-pause-stop-hide.js +5 -5
  34. package/engine/rules/wcag/2.4.1-bypass-blocks.js +2 -2
  35. package/engine/rules/wcag/2.4.2-document-title.js +7 -8
  36. package/engine/rules/wcag/2.4.4-link-text-generic.js +6 -5
  37. package/engine/rules/wcag/2.4.9-link-text-generic-only.js +8 -23
  38. package/engine/rules/wcag/2.5.8-target-size.js +201 -47
  39. package/engine/rules/wcag/3.3.8-auth-field-obstruction.js +6 -12
  40. package/engine/rules/wcag/4.1.2-aria-attr-valid.js +4 -0
  41. package/engine/rules/wcag/4.1.2-aria-field-name.js +6 -5
  42. package/engine/rules/wcag/4.1.2-aria-valid-refs.js +8 -3
  43. package/engine/rules/wcag/4.1.2-button-name.js +1 -1
  44. package/engine/rules/wcag/4.1.2-composite-widget-name.js +1 -1
  45. package/engine/rules/wcag/4.1.2-invoker-target.js +2 -1
  46. package/engine/rules/wcag/4.1.2-label-for-valid.js +2 -2
  47. package/engine/rules/wcag/4.1.2-nested-interactive.js +20 -8
  48. package/engine/rules/wcag/4.1.2-valid-role.js +6 -1
  49. package/engine/wcag22.js +7 -7
  50. package/package.json +1 -1
@@ -31,6 +31,31 @@ const TARGETS = 'a[href], button, input, select, [role="button"], [role="link"]'
31
31
  */
32
32
  import { isInert } from '../../lib/dom.js';
33
33
 
34
+ // A web component's internal link and its enclosing light-DOM link are
35
+ // not independent pointer targets. DOM contains() stops at shadow roots.
36
+ function containsComposed(ancestor, element) {
37
+ if (ancestor.contains(element)) return true;
38
+ if (ancestor.getRootNode() === element.getRootNode() && !element.assignedSlot) return false;
39
+ for (let node = element; node; node = node.assignedSlot ?? node.parentElement ?? node.getRootNode()?.host) {
40
+ if (node === ancestor) return true;
41
+ }
42
+ return false;
43
+ }
44
+
45
+ function outsideHiddenOverflow(element, rect) {
46
+ for (let parent = element.parentElement; parent; parent = parent.parentElement) {
47
+ const style = getComputedStyle(parent);
48
+ if (style.display === 'contents') continue; // no principal box to clip its children
49
+ const x = /^(hidden|clip)$/.test(style.overflowX);
50
+ const y = /^(hidden|clip)$/.test(style.overflowY);
51
+ if (!x && !y) continue;
52
+ const clip = parent.getBoundingClientRect();
53
+ if ((x && (clip.width === 0 || rect.right <= clip.left || rect.left >= clip.right))
54
+ || (y && (clip.height === 0 || rect.bottom <= clip.top || rect.top >= clip.bottom))) return true;
55
+ }
56
+ return false;
57
+ }
58
+
34
59
  function isHiddenFromPointer(element, rect) {
35
60
  if (rect.width <= 1 || rect.height <= 1) return true;
36
61
  if (rect.right <= 0 || rect.bottom <= 0) return true; // parked above/left of the canvas
@@ -188,14 +213,13 @@ function isInTextLine(element) {
188
213
  // do not apply now are skipped, since they size nothing now.
189
214
  const NATIVE_CONTROL = /^(button|input|select|textarea)$/i;
190
215
  const SIZING = /^(width|height|(min|max)-(width|height|inline-size|block-size)|inline-size|block-size|padding(-.+)?|border(-.+)?|font(-.+)?|line-height|box-sizing|transform|scale|zoom|appearance|all)$/;
191
- const sizingRulesByRoot = new WeakMap();
192
216
 
193
217
  function declaresSizing(style) {
194
218
  for (let k = 0; k < style.length; k++) if (SIZING.test(style[k])) return true;
195
219
  return false;
196
220
  }
197
221
 
198
- function authorSizingRules(root) {
222
+ function authorSizingRules(root, sizingRulesByRoot) {
199
223
  const sheets = [...(root.styleSheets ?? []), ...(root.adoptedStyleSheets ?? [])];
200
224
  const key = sheets.map((s) => { try { return s.cssRules.length; } catch { return 'x'; } }).join(',');
201
225
  const cached = sizingRulesByRoot.get(root);
@@ -229,10 +253,10 @@ function authorSizingRules(root) {
229
253
  /** 'yes' when the browser provably sized this native control, 'no' when the
230
254
  * author touched its box (or it is not a native control), 'open' when a
231
255
  * sheet or selector could not be read. */
232
- function uaSized(element) {
256
+ function uaSized(element, sizingRulesByRoot) {
233
257
  if (!NATIVE_CONTROL.test(element.tagName) || element.type === 'image') return 'no';
234
258
  if (declaresSizing(element.style)) return 'no';
235
- const entry = authorSizingRules(element.getRootNode());
259
+ const entry = authorSizingRules(element.getRootNode(), sizingRulesByRoot);
236
260
  if (entry.combined && element.matches(entry.combined)) return 'no';
237
261
  return entry.open ? 'open' : 'yes';
238
262
  }
@@ -276,7 +300,11 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
276
300
  selector: 'button, a[href], input:not([type="hidden"]), select, [role="button"], [role="link"]',
277
301
  visibility: 'visual', // pointer targets are visual regardless of aria-hidden
278
302
  // Judged as a set: the spacing exception needs the other targets' positions.
279
- evaluateAll(elements) {
303
+ async evaluateAll(elements, helpers = {}) {
304
+ // Styles can change between audits without changing a sheet's rule
305
+ // count. This cache belongs to this evaluation, not the page lifetime.
306
+ const sizingRulesByRoot = new WeakMap();
307
+ const yieldToMain = helpers.yieldToMain ?? (() => Promise.resolve());
280
308
  // A control's <label> is part of its activation area — clicking the
281
309
  // label activates the control, so the TARGET is the union of both
282
310
  // boxes. Without this, every label-wrapped checkbox list fails on the
@@ -298,7 +326,11 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
298
326
  const uaControlled = (element) => element.tagName === 'INPUT'
299
327
  && (element.type === 'checkbox' || element.type === 'radio')
300
328
  && getComputedStyle(element).appearance !== 'none';
301
- const rects = elements.map(targetRect);
329
+ const rects = [];
330
+ for (let i = 0; i < elements.length; i++) {
331
+ if (i % 64 === 0) await yieldToMain();
332
+ rects.push(targetRect(elements[i]));
333
+ }
302
334
  const centers = rects.map((r) => ({ x: r.left + r.width / 2, y: r.top + r.height / 2 }));
303
335
  // Zero-area rects are degenerate: either not laid out, or an empty
304
336
  // control no pointer can hit at all — its real defect (an empty name,
@@ -327,9 +359,13 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
327
359
  // judged nor counted as crowders (bbc.com/news keeps 28 controls of a
328
360
  // closed menu in an inert subtree; the browser ignores every click on
329
361
  // them, and so must the size judgment).
330
- const laidOut = rects.map((r, i) =>
331
- r.width > 0 && r.height > 0 && !isInert(elements[i])
332
- && !isHiddenFromPointer(elements[i], r) && !layoutSkipped(elements[i]));
362
+ const laidOut = [];
363
+ for (let i = 0; i < elements.length; i++) {
364
+ if (i % 64 === 0) await yieldToMain();
365
+ const r = rects[i], element = elements[i];
366
+ laidOut.push(r.width > 0 && r.height > 0 && !element.matches(':disabled') && !isInert(element)
367
+ && !isHiddenFromPointer(element, r) && !layoutSkipped(element));
368
+ }
333
369
  const undersized = rects.map((r, i) => laidOut[i] && (r.width < min || r.height < min));
334
370
  // One rect fully inside another is one control drawn twice (stretched-
335
371
  // link cards, overlay + inner button) — not two targets crowding.
@@ -409,9 +445,10 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
409
445
  }
410
446
  return false; // neither reachable there — a third element's problem, not j's
411
447
  };
412
- /** Largest uncovered sub-rectangle after removing the overlap: the best
413
- * of the four slabs beside/above/below it (exact for one obscurer,
414
- * a safe under-approximation applied sequentially for several). */
448
+ /** All remaining rectangular areas. The largest area is not necessarily
449
+ * the one that contains the required square: 100x23 is larger than
450
+ * 24x50 but only the latter satisfies the AA size requirement.
451
+ * Keep the alternatives until every obscurer has been subtracted. */
415
452
  const uncovered = (rect, overlap) => {
416
453
  const slabs = [
417
454
  { left: rect.left, top: rect.top, right: overlap.left, bottom: rect.bottom },
@@ -420,20 +457,20 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
420
457
  { left: rect.left, top: overlap.bottom, right: rect.right, bottom: rect.bottom },
421
458
  ].map((s) => ({ ...s, width: s.right - s.left, height: s.bottom - s.top }))
422
459
  .filter((s) => s.width >= 1 && s.height >= 1);
423
- // Note: rect may be a DOMRect, whose properties do not survive object
424
- // spread — the zero-rect is built from explicit reads.
425
- if (!slabs.length) {
426
- return { left: rect.left, top: rect.top, right: rect.left, bottom: rect.top, width: 0, height: 0 };
427
- }
428
- return slabs.reduce((best, s) => (s.width * s.height > best.width * best.height ? s : best));
460
+ return slabs;
429
461
  };
430
462
  // Spatial hash so finding overlap candidates stays near-linear on pages
431
463
  // with tens of thousands of targets: only targets sharing a cell can
432
464
  // intersect, so each target is tested against its cell-mates only.
433
465
  const CELL = 256;
434
- let cellIndex = null;
466
+ const cellIndex = new Map();
467
+ const wideTargets = [];
435
468
  const cellsOf = (r) => {
436
469
  const keys = [];
470
+ // Huge transformed boxes must not allocate millions of cells. They
471
+ // remain in a separate candidate list, so the bound drops no target.
472
+ if ((Math.floor(r.right / CELL) - Math.floor(r.left / CELL) + 1)
473
+ * (Math.floor(r.bottom / CELL) - Math.floor(r.top / CELL) + 1) > 2048) return null;
437
474
  for (let cx = Math.floor(r.left / CELL); cx <= Math.floor(r.right / CELL); cx++) {
438
475
  for (let cy = Math.floor(r.top / CELL); cy <= Math.floor(r.bottom / CELL); cy++) {
439
476
  keys.push(`${cx}:${cy}`);
@@ -441,19 +478,21 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
441
478
  }
442
479
  return keys;
443
480
  };
444
- const overlapCandidates = (i) => {
445
- if (!cellIndex) {
446
- cellIndex = new Map();
447
- rects.forEach((r, j) => {
448
- if (!laidOut[j]) return;
449
- for (const key of cellsOf(r)) {
450
- if (!cellIndex.has(key)) cellIndex.set(key, []);
451
- cellIndex.get(key).push(j);
452
- }
453
- });
481
+ for (let j = 0; j < rects.length; j++) {
482
+ if (j % 64 === 0) await yieldToMain();
483
+ if (!laidOut[j]) continue;
484
+ const keys = cellsOf(rects[j]);
485
+ if (!keys) { wideTargets.push(j); continue; }
486
+ for (const key of keys) {
487
+ if (!cellIndex.has(key)) cellIndex.set(key, []);
488
+ cellIndex.get(key).push(j);
454
489
  }
455
- const seen = new Set();
456
- for (const key of cellsOf(rects[i])) {
490
+ }
491
+ const nearbyCandidates = (i, box = rects[i]) => {
492
+ const keys = cellsOf(box);
493
+ if (!keys) return elements.map((_, j) => j).filter((j) => j !== i && laidOut[j]);
494
+ const seen = new Set(wideTargets.filter((j) => j !== i));
495
+ for (const key of keys) {
457
496
  for (const j of cellIndex.get(key) ?? []) if (j !== i) seen.add(j);
458
497
  }
459
498
  return seen;
@@ -461,9 +500,10 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
461
500
  /** The pointer-accepting rect of a box-sized-fine target, reduced by
462
501
  * every overlapping target painted above it — or null when nothing
463
502
  * qualifying overlaps. */
464
- const obscuredRect = (i) => {
465
- let effective = null;
466
- for (const j of overlapCandidates(i)) {
503
+ const obscuredRects = new Map();
504
+ const calculateObscuredRect = (i) => {
505
+ let areas = null;
506
+ for (const j of nearbyCandidates(i)) {
467
507
  if (elements[j].contains(elements[i]) || elements[i].contains(elements[j])) continue; // same control, nested markup
468
508
  if (encloses(rects[j], rects[i]) || encloses(rects[i], rects[j])) continue; // one control drawn twice
469
509
  // Two links to the same place are one control in two boxes (a
@@ -471,13 +511,28 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
471
511
  // land on the same destination, which is the criterion's
472
512
  // "equivalent" case, provable here because the hrefs match.
473
513
  if (typeof elements[i].href === 'string' && elements[i].href === elements[j].href) continue;
474
- const overlap = overlapOf(effective ?? rects[i], rects[j]);
514
+ const overlap = overlapOf(rects[i], rects[j]);
475
515
  if (!overlap) continue;
476
516
  if (fixedContextOf(elements[j]) !== fixedContextOf(elements[i])) continue; // scroll-state, not layout
477
517
  if (!paintsOver(j, i, overlap)) continue;
478
- effective = uncovered(effective ?? rects[i], overlap);
518
+ const next = (areas ?? [rects[i]]).flatMap((area) => {
519
+ const cut = overlapOf(area, rects[j]);
520
+ return cut ? uncovered(area, cut) : [area];
521
+ });
522
+ // Remove only rectangles wholly covered by another available area.
523
+ // No useful square is lost by this pruning.
524
+ areas = next.filter((area, k) => !next.some((other, n) => n !== k
525
+ && encloses(other, area) && (n < k || !encloses(area, other))));
526
+ if (areas.length > 128) return { uncertain: true };
479
527
  }
480
- return effective;
528
+ if (areas === null) return null;
529
+ return areas.find((area) => area.width >= min && area.height >= min)
530
+ ?? areas.reduce((best, area) => area.width * area.height > best.width * best.height ? area : best,
531
+ { width: 0, height: 0 });
532
+ };
533
+ const obscuredRect = (i) => {
534
+ if (!obscuredRects.has(i)) obscuredRects.set(i, calculateObscuredRect(i));
535
+ return obscuredRects.get(i);
481
536
  };
482
537
 
483
538
  // ── Equivalent targets ────────────────────────────────────────────
@@ -500,21 +555,86 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
500
555
  const destination = destinationOf(elements[i]);
501
556
  if (!destination) return false;
502
557
  if (!adequateDestinations) {
503
- adequateDestinations = new Set();
558
+ adequateDestinations = new Map();
504
559
  elements.forEach((other, j) => {
505
560
  if (!laidOut[j] || undersized[j] || other.ownerDocument !== elements[i].ownerDocument) return;
506
561
  const d = destinationOf(other);
507
- if (d) adequateDestinations.add(d);
562
+ if (d) {
563
+ if (!adequateDestinations.has(d)) adequateDestinations.set(d, []);
564
+ adequateDestinations.get(d).push(j);
565
+ }
508
566
  });
509
567
  }
510
- return adequateDestinations.has(destination);
568
+ let uncertain = false;
569
+ for (const j of adequateDestinations.get(destination) ?? []) {
570
+ if (j === i) continue;
571
+ // An original bounding box cannot prove a usable alternative.
572
+ // Otherwise two separately obscured links rescue one another even
573
+ // when neither retains the required square for pointer activation.
574
+ const area = obscuredRect(j);
575
+ if (area?.uncertain) { uncertain = true; continue; }
576
+ if (!area || (area.width >= min && area.height >= min)) return 'pass';
577
+ }
578
+ return uncertain ? 'incomplete' : null;
511
579
  };
512
580
 
513
- return elements.map((element, i) => {
581
+ // Scripted functions have no URL identity. Matching names, declared
582
+ // controls or handler text identify plausible alternatives, not proven
583
+ // equivalence: a human must verify that both controls do the same thing.
584
+ // Build these indexes only when a failing scripted control needs them.
585
+ let alternativeKeys = null;
586
+ const names = new Map();
587
+ const nameOf = (element) => {
588
+ if (!names.has(element)) names.set(element, helpers.accessibleName?.(element)?.trim() ?? '');
589
+ return names.get(element);
590
+ };
591
+ const signatures = (element) => {
592
+ const keys = [];
593
+ const name = nameOf(element);
594
+ if (name) keys.push(`name:${name}`);
595
+ const handler = element.getAttribute('onclick')?.trim();
596
+ if (handler) keys.push(`handler:${handler}`);
597
+ const controls = element.getAttribute('aria-controls')?.trim().split(/\s+/).sort().join(' ');
598
+ if (controls) keys.push(`controls:${controls}`);
599
+ return keys;
600
+ };
601
+ const plausibleAlternative = (i) => {
602
+ if (destinationOf(elements[i])) return false; // URL equivalence was judged above
603
+ const own = signatures(elements[i]);
604
+ if (!own.length) return false;
605
+ if (!alternativeKeys) {
606
+ alternativeKeys = new Map();
607
+ elements.forEach((other, j) => {
608
+ if (!laidOut[j] || undersized[j] || destinationOf(other)) return;
609
+ const area = obscuredRect(j);
610
+ if (area && !area.uncertain && (area.width < min || area.height < min)) return;
611
+ for (const key of signatures(other)) {
612
+ if (!alternativeKeys.has(key)) alternativeKeys.set(key, []);
613
+ alternativeKeys.get(key).push(j);
614
+ }
615
+ });
616
+ }
617
+ return own.some((key) => alternativeKeys.get(key)?.some((j) => j !== i));
618
+ };
619
+ const alternativeReview = () => ({
620
+ status: 'incomplete',
621
+ message: `This control's measured pointer area is below ${min}×${min}px, but a larger control has a matching name, handler or declared target. Check whether it provides the same function and meets the target-size requirement; if so, the Equivalent exception applies.`,
622
+ fix: 'Verify the larger alternative, or enlarge or separate this target.',
623
+ });
624
+ const equivalentReview = () => ({
625
+ status: 'incomplete',
626
+ message: `Another link reaches the same destination, but its unobscured pointer area could not be established. Check whether that alternative meets ${min}×${min}px before applying the Equivalent exception.`,
627
+ });
628
+
629
+ const judge = (element, i) => {
514
630
  if (!laidOut[i]) return { status: 'pass' }; // not laid out / not a pointer target
515
631
  if (!undersized[i]) {
516
632
  // Box is big enough — but the pointer only gets the uncovered part.
517
633
  const effective = obscuredRect(i);
634
+ if (effective?.uncertain) return {
635
+ status: 'incomplete',
636
+ message: 'Several targets overlap this control. The remaining pointer area could not be resolved within the geometry budget; check whether it contains the required target size.',
637
+ };
518
638
  if (!effective || (effective.width >= min && effective.height >= min)) return { status: 'pass' };
519
639
  if (isInTextLine(element)) return { status: 'pass' };
520
640
  // A target the pointer never actually hits is a keyboard
@@ -540,6 +660,10 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
540
660
  return !tested; // nothing probe-able → don't know → don't silence
541
661
  })();
542
662
  if (!everHit) return { status: 'pass' };
663
+ const equivalent = equivalentElsewhere(i);
664
+ if (equivalent === 'pass') return { status: 'pass' };
665
+ if (equivalent === 'incomplete') return equivalentReview();
666
+ if (plausibleAlternative(i)) return alternativeReview();
543
667
  // The spacing exception cannot apply: the obscuring target is at
544
668
  // distance zero. UA-control cannot either: the browser sized the
545
669
  // box, but the author's layout took part of it away.
@@ -558,7 +682,7 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
558
682
  // The same exception for every other native control, proved from the
559
683
  // sheets; an open proof (unreadable sheet) reviews at the end instead
560
684
  // of asserting, and only if nothing else rescues the target first.
561
- const uaProof = uaSized(element);
685
+ const uaProof = uaSized(element, sizingRulesByRoot);
562
686
  if (uaProof === 'yes') return { status: 'pass' };
563
687
  if (isInTextLine(element)) return { status: 'pass' };
564
688
  if (spacingException) {
@@ -567,7 +691,7 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
567
691
  // target's circle.
568
692
  const crowds = (other, j) => {
569
693
  if (j === i || !laidOut[j]) return false;
570
- if (other.contains(element) || element.contains(other)) return false; // same control, nested markup
694
+ if (containsComposed(other, element) || containsComposed(element, other)) return false; // same control, including shadow content
571
695
  // "One control drawn twice" (stretched-link overlays) is a claim
572
696
  // about PAINT, so it must be proved on a painted fragment, not on
573
697
  // the bounding box: a two-line wrapped link's box swallows a small
@@ -576,7 +700,20 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
576
700
  // the honest fragment test below. A single-fragment element's one
577
701
  // fragment IS its box, so genuine overlays are dismissed exactly
578
702
  // as before.
579
- if (paintedEncloses(other, rects[j], rects[i]) || paintedEncloses(element, rects[i], rects[j])) return false;
703
+ // Containment of one painted box inside another does not prove
704
+ // that they perform the same action. A small preview button over
705
+ // an image link is explicitly a spacing failure in SC 2.5.8.
706
+ // Only two links with the same real destination can use the
707
+ // equivalent-control exemption here.
708
+ // https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html
709
+ if (paintedEncloses(other, rects[j], rects[i]) || paintedEncloses(element, rects[i], rects[j])) {
710
+ const destination = destinationOf(element);
711
+ if (destination && destination === destinationOf(other)) return false;
712
+ // Collapsed disclosures can leave their descendants' boxes
713
+ // overlapping nearby controls even though the overflow clips
714
+ // them completely. Such boxes accept no pointer input.
715
+ if (outsideHiddenOverflow(element, rects[i]) || outsideHiddenOverflow(other, rects[j])) return false;
716
+ }
580
717
  // Crowding across fixed/sticky contexts is scroll state, not
581
718
  // layout, exactly as the obscured-target test reads overlap: a
582
719
  // consent banner pinned to the bottom of the viewport lays its
@@ -605,7 +742,15 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
605
742
  // crowding from its second line or not at all.
606
743
  return reachableRects(other, rects[j]).some(within);
607
744
  };
608
- const crowded = elements.some(crowds);
745
+ // A target can crowd this circle only if its rectangle reaches the
746
+ // center's min-radius neighborhood. The larger radius also covers
747
+ // the circle of any undersized neighbor, whose center lies in its
748
+ // rectangle. This index is a superset, retaining the exact tests.
749
+ const center = centers[i];
750
+ const crowded = [...nearbyCandidates(i, {
751
+ left: center.x - min, right: center.x + min,
752
+ top: center.y - min, bottom: center.y + min,
753
+ })].some((j) => crowds(elements[j], j));
609
754
  if (!crowded) return { status: 'pass' };
610
755
  // Deliberately NOT applied to the target itself. A crowder that
611
756
  // can't be clicked breaks the premise of the exception, because it
@@ -614,7 +759,10 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
614
759
  // dismissed, and suppressing it would hide a real defect behind a
615
760
  // transient overlay, which is the wrong way round.
616
761
  }
617
- if (equivalentElsewhere(i)) return { status: 'pass' }; // Equivalent exception, proved by href
762
+ const equivalent = equivalentElsewhere(i);
763
+ if (equivalent === 'pass') return { status: 'pass' }; // Same destination and adequate usable area
764
+ if (equivalent === 'incomplete') return equivalentReview();
765
+ if (plausibleAlternative(i)) return alternativeReview();
618
766
  const rect = rects[i];
619
767
  if (uaProof === 'open') {
620
768
  return {
@@ -629,7 +777,13 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
629
777
  : `This target is ${px(rect.width)}×${px(rect.height)}px — below the ${min}×${min}px minimum, hard to hit for users with motor impairments.`,
630
778
  fix: `Increase the element’s size or padding to at least ${min}×${min}px${spacingException ? ', or add spacing between the targets' : ''}.`,
631
779
  };
632
- });
780
+ };
781
+ const outcomes = [];
782
+ for (let i = 0; i < elements.length; i++) {
783
+ if (i % 64 === 0) await yieldToMain();
784
+ outcomes.push(judge(elements[i], i));
785
+ }
786
+ return outcomes;
633
787
  },
634
788
  };
635
789
  }
@@ -10,7 +10,8 @@
10
10
  // page may also offer an Alternative (a passkey, a federated sign-in, an
11
11
  // emailed link) that this rule cannot see. Blocked paste is therefore
12
12
  // reported for REVIEW, not failed. Both mechanisms obstructed at once is the
13
- // case worth asserting.
13
+ // case worth reviewing together. Neither attribute proves that every
14
+ // alternative mechanism is unavailable.
14
15
  //
15
16
  // Paste only (2026-08-25 overnight audit): an ondrop handler used to count
16
17
  // as a paste blocker. It is not. Drag and drop is a different event and a
@@ -26,7 +27,7 @@
26
27
  // the form has the classic sign-in shape: exactly one password field beside
27
28
  // a username or email field. Two password fields in one form is the shape
28
29
  // of sign-up or a password change, and that field is asked about, never
29
- // failed. The fail branch fires only on a provable login credential.
30
+ // failed. Even a likely login credential still needs its behavior checked.
30
31
  export default {
31
32
  id: 'auth-field-obstruction',
32
33
  name: 'Accessible login fields',
@@ -43,7 +44,7 @@ export default {
43
44
  const autocompleteOff = tokens.length === 1 && tokens[0] === 'off';
44
45
  if (!blocksPaste && !autocompleteOff) return { status: 'pass' };
45
46
 
46
- // Is this field provably an existing credential, i.e. inside 3.3.8?
47
+ // Markup suggests an existing credential; it does not prove the process.
47
48
  const form = element.form ?? element.closest('form');
48
49
  const passwordFields = form ? form.querySelectorAll('input[type="password"]').length : (element.type === 'password' ? 1 : 0);
49
50
  const identityField = form?.querySelector('input[autocomplete~="username"], input[autocomplete~="email"], input[type="email"]');
@@ -55,23 +56,16 @@ export default {
55
56
  const step = provenLogin ? 'this login' : 'this step';
56
57
 
57
58
  if (blocksPaste && autocompleteOff) {
58
- if (provenLogin) {
59
- return {
60
- status: 'fail',
61
- message: `This authentication field blocks pasting AND sets autocomplete="off", so both of the mechanisms 3.3.8 names are obstructed at once: copy and paste, and password-manager entry. What is left is typing the credential from memory.`,
62
- fix: 'Remove the paste blocking, and drop autocomplete="off" so a password manager can fill the field.',
63
- };
64
- }
65
59
  return {
66
60
  status: 'incomplete',
67
- message: `This password field blocks pasting AND sets autocomplete="off". If it is part of signing in, both of the mechanisms 3.3.8 names are obstructed at once (copy and paste, and password-manager entry) and it fails. ${scopeQuestion}`,
61
+ message: `This field has a paste handler containing cancellation code and sets autocomplete="off". Check whether the handler actually blocks pasting, whether a password manager can fill the field, and whether another authentication method is available. Conditional code may leave pasting enabled, and autocomplete="off" does not establish that password-manager entry is blocked.${provenLogin ? '' : ` ${scopeQuestion}`}`,
68
62
  fix: 'Remove the paste blocking, and drop autocomplete="off" so a password manager can fill the field.',
69
63
  };
70
64
  }
71
65
  if (blocksPaste) {
72
66
  return {
73
67
  status: 'incomplete',
74
- message: `This field blocks pasting, which removes copy and paste, one of the two mechanisms 3.3.8 names. A password manager fills the field directly and is unaffected, so this is only a failure if nothing else here helps. Check whether a password manager can complete ${step}, or whether the page offers another way in such as a passkey or a federated sign-in.${provenLogin ? '' : ` ${scopeQuestion}`}`,
68
+ message: `This field has a paste handler containing cancellation code. Check whether pasting is actually blocked; the code may run only conditionally. Also check whether a password manager can complete ${step}, or whether the page offers another way in such as a passkey or a federated sign-in.${provenLogin ? '' : ` ${scopeQuestion}`}`,
75
69
  fix: 'Remove the paste blocking, or make sure another way to sign in is available that does not rely on recalling the credential.',
76
70
  };
77
71
  }
@@ -68,6 +68,10 @@ export default {
68
68
  for (const { name, value } of attributesOf(element)) {
69
69
  if (!name.startsWith('aria-')) continue;
70
70
  const attr = name.slice(5);
71
+ // ARIA defines unknown current/invalid tokens as true, not discarded.
72
+ // Empty/undefined current means false. Syntax alone cannot show a
73
+ // state mismatch here. https://www.w3.org/TR/wai-aria-1.2/#aria-current
74
+ if (name === 'aria-current' || name === 'aria-invalid') continue;
71
75
  if (!KNOWN.has(attr)) {
72
76
  unknown.push(name);
73
77
  } else if (ENUMS[name] && value.trim() === '') {
@@ -6,6 +6,7 @@
6
6
  // text inside a listbox/textbox does not name it, only author-provided
7
7
  // naming counts. Toggle roles (checkbox/radio/switch) DO name from content.
8
8
  import { labelledByName } from '../../lib/accessible-name.js';
9
+ import { effectiveRole } from '../../lib/roles.js';
9
10
 
10
11
  // progressbar and meter belong here too: ARIA 1.2 marks both roles
11
12
  // "Accessible Name Required: True", they never name from content, and a
@@ -28,12 +29,12 @@ export default {
28
29
  tags: ['wcag2a', 'wcag412'],
29
30
  help: 'ARIA fields and value widgets must have an accessible name',
30
31
  helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
31
- selector: [...AUTHOR_ONLY, ...FROM_CONTENT]
32
- .map((role) => `[role="${role}"]:not(input):not(select):not(textarea)`)
33
- .join(', '),
32
+ selector: '[role]:not(input):not(select):not(textarea)',
34
33
  evaluate(element, { accessibleName }) {
35
- const role = element.getAttribute('role');
36
- const name = FROM_CONTENT.includes(role) ? accessibleName(element) : authorName(element);
34
+ const role = effectiveRole(element);
35
+ if (![...AUTHOR_ONLY, ...FROM_CONTENT].includes(role)) return { status: 'pass' };
36
+ const name = FROM_CONTENT.includes(role) || element.labels?.length
37
+ ? accessibleName(element) : authorName(element);
37
38
  if (name) return { status: 'pass' };
38
39
  // ARIA 1.1 combobox pattern: a non-focusable wrapper div carries the
39
40
  // role while the real, focusable input inside carries the label. The
@@ -76,6 +76,10 @@ function inspect(element) {
76
76
  const missing = [];
77
77
  const ambiguous = [];
78
78
  for (const attr of REF_ATTRIBUTES) {
79
+ // ARIA requires the error relation to remain unexposed while valid.
80
+ // A lazy error node not yet inserted cannot lose an active message.
81
+ if (attr === 'aria-errormessage' && (!element.hasAttribute('aria-invalid')
82
+ || ['false', ''].includes(element.getAttribute('aria-invalid').trim().toLowerCase()))) continue;
79
83
  for (const id of (element.getAttribute(attr) ?? '').split(/\s+/).filter(Boolean)) {
80
84
  if (!root.getElementById?.(id)) {
81
85
  if (attr === 'aria-controls' && collapsed) continue;
@@ -110,8 +114,8 @@ function outcome({ missing, ambiguous }, element, accessibleName) {
110
114
  // not name, role or a user-set value, and tooltip libraries create
111
115
  // the target on hover or focus, so the DOM proves an authoring
112
116
  // error at most. Asked, never asserted;
113
- // - aria-controls, aria-owns, aria-details, aria-errormessage: the
114
- // standing policy (asserted; the comparator asserts these too).
117
+ // - other relationships: review unless a missing name or active
118
+ // item above independently demonstrates the component's failure.
115
119
  const component = isUserInterfaceComponent(element);
116
120
  // The resting name for a component is the full computation (a button
117
121
  // names itself from its contents). A landmark, list or group is not
@@ -126,7 +130,8 @@ function outcome({ missing, ambiguous }, element, accessibleName) {
126
130
  const line = ({ attr, id }) => attr === 'aria-labelledby'
127
131
  ? `aria-labelledby="${id}" points to nothing and leaves this element without an accessible name`
128
132
  : `${attr}="${id}" points to nothing — assistive technology silently ignores it`;
129
- const asserted = relevant.filter(({ attr }) => attr !== 'aria-describedby' && (attr !== 'aria-labelledby' || component));
133
+ const asserted = relevant.filter(({ attr }) => component
134
+ && (attr === 'aria-labelledby' || attr === 'aria-activedescendant'));
130
135
  if (asserted.length) {
131
136
  return {
132
137
  status: 'fail',
@@ -8,7 +8,7 @@ export default {
8
8
  tags: ['wcag2a', 'wcag412'],
9
9
  help: 'Buttons must have an accessible name',
10
10
  helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
11
- selector: 'button, [role="button"], input[type="button"], input[type="submit"], input[type="reset"]',
11
+ selector: 'button, [role], input[type="button"], input[type="submit"], input[type="reset"]',
12
12
  evaluate(element, { accessibleName }) {
13
13
  // Route by EFFECTIVE role (2026-08-25 overnight audit): a <button
14
14
  // role="tab"> or <button role="link"> is exposed under that role, so its
@@ -19,7 +19,7 @@ export default {
19
19
  tags: ['wcag2a', 'wcag412'],
20
20
  help: 'Tabs, menu items, options and tree items need an accessible name',
21
21
  helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
22
- selector: '[role="tab"], [role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], [role="option"], [role="treeitem"]',
22
+ selector: '[role]',
23
23
  evaluate(element, { accessibleName }) {
24
24
  // This rule owns every element whose EFFECTIVE role is one of the six,
25
25
  // <button role="tab"> and <a href role="tab"> included: button-name and
@@ -30,6 +30,7 @@ export default {
30
30
  helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
31
31
  selector: '[popovertarget], [commandfor]',
32
32
  evaluate(element) {
33
+ if (element.matches(':disabled') || !element.matches('button, input[type="button"]')) return { status: 'pass' };
33
34
  // Ids resolve within the button's own tree scope (a shadow root's ids
34
35
  // are its own), exactly as the browser resolves them.
35
36
  const root = element.getRootNode();
@@ -46,7 +47,7 @@ export default {
46
47
  }
47
48
  }
48
49
 
49
- if (element.hasAttribute('commandfor')) {
50
+ if (element.tagName === 'BUTTON' && element.hasAttribute('commandfor')) {
50
51
  const id = element.getAttribute('commandfor').trim();
51
52
  const command = (element.getAttribute('command') ?? '').trim().toLowerCase();
52
53
  const target = lookup(id);
@@ -29,7 +29,7 @@ export default {
29
29
  help: 'label[for] must reference a form control that exists',
30
30
  helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
31
31
  selector: 'label[for]',
32
- evaluate(element) {
32
+ evaluate(element, { accessibleName }) {
33
33
  const id = element.getAttribute('for');
34
34
  // `control` is the browser's own for-resolution: null when the id is
35
35
  // missing or names something that is not labelable. It resolves within
@@ -41,7 +41,7 @@ export default {
41
41
  // a label that points at one (standing policy: this shape passes).
42
42
  if (target && target.tagName === 'INPUT' && target.type === 'hidden') return { status: 'pass' };
43
43
  const wrapped = element.querySelector(WRAPPABLE);
44
- if (wrapped) {
44
+ if (wrapped && !accessibleName(wrapped)) {
45
45
  return {
46
46
  status: 'fail',
47
47
  message: `This label wraps a <${wrapped.tagName.toLowerCase()}> but its for="${id}" ${target ? `points at a <${target.tagName.toLowerCase()}>, which is not labelable` : 'points at nothing'}. The for attribute overrides the wrapping, so the wrapped control is not labelled by this text.`,