pour-engine 1.40.0 → 1.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.
Files changed (59) hide show
  1. package/config/project.config.js +1 -1
  2. package/engine/AUDIT.html +24 -690
  3. package/engine/COVERAGE.md +17 -15
  4. package/engine/README.md +10 -5
  5. package/engine/index.js +34 -8
  6. package/engine/lib/accessible-name.js +69 -63
  7. package/engine/lib/contrast.js +111 -10
  8. package/engine/lib/dom.js +78 -18
  9. package/engine/lib/roles.js +5 -2
  10. package/engine/rules/best-practice/fieldset-legend.js +11 -5
  11. package/engine/rules/best-practice/region.js +1 -1
  12. package/engine/rules/index.js +29 -0
  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.2.3-video-audio-description.js +42 -0
  17. package/engine/rules/wcag/1.3.1-aria-required-children.js +20 -8
  18. package/engine/rules/wcag/1.3.1-aria-required-parent.js +7 -10
  19. package/engine/rules/wcag/1.3.1-form-label.js +5 -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.3.6-region-purpose.js +35 -0
  25. package/engine/rules/wcag/1.4.1-link-in-text-block.js +19 -3
  26. package/engine/rules/wcag/1.4.10-reflow.js +3 -3
  27. package/engine/rules/wcag/1.4.11-non-text-contrast.js +9 -1
  28. package/engine/rules/wcag/1.4.12-text-spacing.js +15 -2
  29. package/engine/rules/wcag/1.4.3-color-contrast.js +79 -7
  30. package/engine/rules/wcag/1.4.3-control-contrast.js +35 -6
  31. package/engine/rules/wcag/1.4.4-meta-viewport.js +7 -2
  32. package/engine/rules/wcag/1.4.8-text-justified.js +49 -0
  33. package/engine/rules/wcag/2.1.1-scrollable-region-focusable.js +5 -5
  34. package/engine/rules/wcag/2.2.1-meta-refresh.js +10 -4
  35. package/engine/rules/wcag/2.2.2-pause-stop-hide.js +5 -5
  36. package/engine/rules/wcag/2.2.4-meta-refresh-no-exceptions.js +50 -0
  37. package/engine/rules/wcag/2.4.1-bypass-blocks.js +2 -2
  38. package/engine/rules/wcag/2.4.10-section-heading.js +86 -0
  39. package/engine/rules/wcag/2.4.11-focus-not-obscured.js +134 -78
  40. package/engine/rules/wcag/2.4.12-focus-not-obscured-enhanced.js +17 -0
  41. package/engine/rules/wcag/2.4.2-document-title.js +7 -8
  42. package/engine/rules/wcag/2.4.4-link-text-generic.js +6 -5
  43. package/engine/rules/wcag/2.4.6-heading-label-placeholder.js +69 -0
  44. package/engine/rules/wcag/2.4.9-link-text-generic-only.js +8 -23
  45. package/engine/rules/wcag/2.5.1-pointer-gesture-alternative.js +98 -0
  46. package/engine/rules/wcag/2.5.8-target-size.js +161 -45
  47. package/engine/rules/wcag/3.3.4-financial-form-confirmation.js +32 -0
  48. package/engine/rules/wcag/3.3.8-auth-field-obstruction.js +10 -13
  49. package/engine/rules/wcag/3.3.9-captcha-alternative.js +35 -0
  50. package/engine/rules/wcag/4.1.2-aria-attr-valid.js +4 -0
  51. package/engine/rules/wcag/4.1.2-aria-field-name.js +6 -5
  52. package/engine/rules/wcag/4.1.2-aria-valid-refs.js +8 -3
  53. package/engine/rules/wcag/4.1.2-button-name.js +1 -1
  54. package/engine/rules/wcag/4.1.2-composite-widget-name.js +1 -1
  55. package/engine/rules/wcag/4.1.2-invoker-target.js +2 -1
  56. package/engine/rules/wcag/4.1.2-label-for-valid.js +2 -2
  57. package/engine/rules/wcag/4.1.2-valid-role.js +6 -1
  58. package/engine/wcag22.js +19 -19
  59. package/package.json +1 -1
@@ -213,14 +213,13 @@ function isInTextLine(element) {
213
213
  // do not apply now are skipped, since they size nothing now.
214
214
  const NATIVE_CONTROL = /^(button|input|select|textarea)$/i;
215
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)$/;
216
- const sizingRulesByRoot = new WeakMap();
217
216
 
218
217
  function declaresSizing(style) {
219
218
  for (let k = 0; k < style.length; k++) if (SIZING.test(style[k])) return true;
220
219
  return false;
221
220
  }
222
221
 
223
- function authorSizingRules(root) {
222
+ function authorSizingRules(root, sizingRulesByRoot) {
224
223
  const sheets = [...(root.styleSheets ?? []), ...(root.adoptedStyleSheets ?? [])];
225
224
  const key = sheets.map((s) => { try { return s.cssRules.length; } catch { return 'x'; } }).join(',');
226
225
  const cached = sizingRulesByRoot.get(root);
@@ -254,10 +253,10 @@ function authorSizingRules(root) {
254
253
  /** 'yes' when the browser provably sized this native control, 'no' when the
255
254
  * author touched its box (or it is not a native control), 'open' when a
256
255
  * sheet or selector could not be read. */
257
- function uaSized(element) {
256
+ function uaSized(element, sizingRulesByRoot) {
258
257
  if (!NATIVE_CONTROL.test(element.tagName) || element.type === 'image') return 'no';
259
258
  if (declaresSizing(element.style)) return 'no';
260
- const entry = authorSizingRules(element.getRootNode());
259
+ const entry = authorSizingRules(element.getRootNode(), sizingRulesByRoot);
261
260
  if (entry.combined && element.matches(entry.combined)) return 'no';
262
261
  return entry.open ? 'open' : 'yes';
263
262
  }
@@ -301,7 +300,11 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
301
300
  selector: 'button, a[href], input:not([type="hidden"]), select, [role="button"], [role="link"]',
302
301
  visibility: 'visual', // pointer targets are visual regardless of aria-hidden
303
302
  // Judged as a set: the spacing exception needs the other targets' positions.
304
- 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());
305
308
  // A control's <label> is part of its activation area — clicking the
306
309
  // label activates the control, so the TARGET is the union of both
307
310
  // boxes. Without this, every label-wrapped checkbox list fails on the
@@ -323,7 +326,11 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
323
326
  const uaControlled = (element) => element.tagName === 'INPUT'
324
327
  && (element.type === 'checkbox' || element.type === 'radio')
325
328
  && getComputedStyle(element).appearance !== 'none';
326
- 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
+ }
327
334
  const centers = rects.map((r) => ({ x: r.left + r.width / 2, y: r.top + r.height / 2 }));
328
335
  // Zero-area rects are degenerate: either not laid out, or an empty
329
336
  // control no pointer can hit at all — its real defect (an empty name,
@@ -352,9 +359,13 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
352
359
  // judged nor counted as crowders (bbc.com/news keeps 28 controls of a
353
360
  // closed menu in an inert subtree; the browser ignores every click on
354
361
  // them, and so must the size judgment).
355
- const laidOut = rects.map((r, i) =>
356
- r.width > 0 && r.height > 0 && !isInert(elements[i])
357
- && !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
+ }
358
369
  const undersized = rects.map((r, i) => laidOut[i] && (r.width < min || r.height < min));
359
370
  // One rect fully inside another is one control drawn twice (stretched-
360
371
  // link cards, overlay + inner button) — not two targets crowding.
@@ -434,9 +445,10 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
434
445
  }
435
446
  return false; // neither reachable there — a third element's problem, not j's
436
447
  };
437
- /** Largest uncovered sub-rectangle after removing the overlap: the best
438
- * of the four slabs beside/above/below it (exact for one obscurer,
439
- * 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. */
440
452
  const uncovered = (rect, overlap) => {
441
453
  const slabs = [
442
454
  { left: rect.left, top: rect.top, right: overlap.left, bottom: rect.bottom },
@@ -445,20 +457,20 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
445
457
  { left: rect.left, top: overlap.bottom, right: rect.right, bottom: rect.bottom },
446
458
  ].map((s) => ({ ...s, width: s.right - s.left, height: s.bottom - s.top }))
447
459
  .filter((s) => s.width >= 1 && s.height >= 1);
448
- // Note: rect may be a DOMRect, whose properties do not survive object
449
- // spread — the zero-rect is built from explicit reads.
450
- if (!slabs.length) {
451
- return { left: rect.left, top: rect.top, right: rect.left, bottom: rect.top, width: 0, height: 0 };
452
- }
453
- return slabs.reduce((best, s) => (s.width * s.height > best.width * best.height ? s : best));
460
+ return slabs;
454
461
  };
455
462
  // Spatial hash so finding overlap candidates stays near-linear on pages
456
463
  // with tens of thousands of targets: only targets sharing a cell can
457
464
  // intersect, so each target is tested against its cell-mates only.
458
465
  const CELL = 256;
459
- let cellIndex = null;
466
+ const cellIndex = new Map();
467
+ const wideTargets = [];
460
468
  const cellsOf = (r) => {
461
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;
462
474
  for (let cx = Math.floor(r.left / CELL); cx <= Math.floor(r.right / CELL); cx++) {
463
475
  for (let cy = Math.floor(r.top / CELL); cy <= Math.floor(r.bottom / CELL); cy++) {
464
476
  keys.push(`${cx}:${cy}`);
@@ -466,19 +478,21 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
466
478
  }
467
479
  return keys;
468
480
  };
469
- const overlapCandidates = (i) => {
470
- if (!cellIndex) {
471
- cellIndex = new Map();
472
- rects.forEach((r, j) => {
473
- if (!laidOut[j]) return;
474
- for (const key of cellsOf(r)) {
475
- if (!cellIndex.has(key)) cellIndex.set(key, []);
476
- cellIndex.get(key).push(j);
477
- }
478
- });
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);
479
489
  }
480
- const seen = new Set();
481
- 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) {
482
496
  for (const j of cellIndex.get(key) ?? []) if (j !== i) seen.add(j);
483
497
  }
484
498
  return seen;
@@ -486,9 +500,10 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
486
500
  /** The pointer-accepting rect of a box-sized-fine target, reduced by
487
501
  * every overlapping target painted above it — or null when nothing
488
502
  * qualifying overlaps. */
489
- const obscuredRect = (i) => {
490
- let effective = null;
491
- 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)) {
492
507
  if (elements[j].contains(elements[i]) || elements[i].contains(elements[j])) continue; // same control, nested markup
493
508
  if (encloses(rects[j], rects[i]) || encloses(rects[i], rects[j])) continue; // one control drawn twice
494
509
  // Two links to the same place are one control in two boxes (a
@@ -496,13 +511,28 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
496
511
  // land on the same destination, which is the criterion's
497
512
  // "equivalent" case, provable here because the hrefs match.
498
513
  if (typeof elements[i].href === 'string' && elements[i].href === elements[j].href) continue;
499
- const overlap = overlapOf(effective ?? rects[i], rects[j]);
514
+ const overlap = overlapOf(rects[i], rects[j]);
500
515
  if (!overlap) continue;
501
516
  if (fixedContextOf(elements[j]) !== fixedContextOf(elements[i])) continue; // scroll-state, not layout
502
517
  if (!paintsOver(j, i, overlap)) continue;
503
- 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 };
504
527
  }
505
- 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);
506
536
  };
507
537
 
508
538
  // ── Equivalent targets ────────────────────────────────────────────
@@ -525,21 +555,86 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
525
555
  const destination = destinationOf(elements[i]);
526
556
  if (!destination) return false;
527
557
  if (!adequateDestinations) {
528
- adequateDestinations = new Set();
558
+ adequateDestinations = new Map();
529
559
  elements.forEach((other, j) => {
530
560
  if (!laidOut[j] || undersized[j] || other.ownerDocument !== elements[i].ownerDocument) return;
531
561
  const d = destinationOf(other);
532
- if (d) adequateDestinations.add(d);
562
+ if (d) {
563
+ if (!adequateDestinations.has(d)) adequateDestinations.set(d, []);
564
+ adequateDestinations.get(d).push(j);
565
+ }
533
566
  });
534
567
  }
535
- 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;
536
579
  };
537
580
 
538
- 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) => {
539
630
  if (!laidOut[i]) return { status: 'pass' }; // not laid out / not a pointer target
540
631
  if (!undersized[i]) {
541
632
  // Box is big enough — but the pointer only gets the uncovered part.
542
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
+ };
543
638
  if (!effective || (effective.width >= min && effective.height >= min)) return { status: 'pass' };
544
639
  if (isInTextLine(element)) return { status: 'pass' };
545
640
  // A target the pointer never actually hits is a keyboard
@@ -565,6 +660,10 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
565
660
  return !tested; // nothing probe-able → don't know → don't silence
566
661
  })();
567
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();
568
667
  // The spacing exception cannot apply: the obscuring target is at
569
668
  // distance zero. UA-control cannot either: the browser sized the
570
669
  // box, but the author's layout took part of it away.
@@ -583,7 +682,7 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
583
682
  // The same exception for every other native control, proved from the
584
683
  // sheets; an open proof (unreadable sheet) reviews at the end instead
585
684
  // of asserting, and only if nothing else rescues the target first.
586
- const uaProof = uaSized(element);
685
+ const uaProof = uaSized(element, sizingRulesByRoot);
587
686
  if (uaProof === 'yes') return { status: 'pass' };
588
687
  if (isInTextLine(element)) return { status: 'pass' };
589
688
  if (spacingException) {
@@ -643,7 +742,15 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
643
742
  // crowding from its second line or not at all.
644
743
  return reachableRects(other, rects[j]).some(within);
645
744
  };
646
- 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));
647
754
  if (!crowded) return { status: 'pass' };
648
755
  // Deliberately NOT applied to the target itself. A crowder that
649
756
  // can't be clicked breaks the premise of the exception, because it
@@ -652,7 +759,10 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
652
759
  // dismissed, and suppressing it would hide a real defect behind a
653
760
  // transient overlay, which is the wrong way round.
654
761
  }
655
- 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();
656
766
  const rect = rects[i];
657
767
  if (uaProof === 'open') {
658
768
  return {
@@ -667,7 +777,13 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
667
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.`,
668
778
  fix: `Increase the element’s size or padding to at least ${min}×${min}px${spacingException ? ', or add spacing between the targets' : ''}.`,
669
779
  };
670
- });
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;
671
787
  },
672
788
  };
673
789
  }
@@ -0,0 +1,32 @@
1
+ // WCAG SC 3.3.4 Error Prevention (Legal, Financial, Data) (Level AA)
2
+ // For a page that causes a financial transaction, a legal commitment or a
3
+ // change to stored user data, a submission must be reversible, checked
4
+ // with a chance to correct, or reviewable and confirmable before it is
5
+ // final. Which of those a form offers is process, invisible to a scan; but
6
+ // one class of page names itself: a form that collects card details
7
+ // (autocomplete cc-number, cc-csc, cc-exp and the rest, or a
8
+ // transaction-amount) is a payment form, and a payment is the financial
9
+ // transaction the criterion describes. That form is asked about, once,
10
+ // never failed: a review step may sit on the next page, and a purchase
11
+ // may be refundable within a stated period (G164).
12
+ //
13
+ // Deletion of user data and legal commitments have no markup signature
14
+ // and stay with the reviewer.
15
+ export default {
16
+ id: 'financial-form-confirmation',
17
+ name: 'Payment form safeguards',
18
+ impact: 'serious',
19
+ tags: ['wcag2aa', 'wcag334'],
20
+ help: 'A form that takes a payment must be reversible, checked or confirmed before submission',
21
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/error-prevention-legal-financial-data.html',
22
+ selector: 'form',
23
+ visibleOnly: false,
24
+ evaluate(element) {
25
+ if (!element.querySelector('input[autocomplete*="cc-" i], input[autocomplete~="transaction-amount" i]')) return { status: 'pass' };
26
+ return {
27
+ status: 'incomplete',
28
+ message: 'This form collects card details, so submitting it makes a financial transaction. 3.3.4 requires at least one of: the submission can be reversed, the entries are checked for errors with a chance to correct them, or a review-and-confirm step comes before the final submit. Check which applies to this checkout.',
29
+ fix: 'Add a review page before the final submit, validate the entries and let the user correct them, or state a period in which the order can be amended or cancelled.',
30
+ };
31
+ },
32
+ };
@@ -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,12 +27,15 @@
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',
33
34
  impact: 'serious',
34
- tags: ['wcag22aa', 'wcag338'],
35
+ // 3.3.9 (AAA) drops 3.3.8's object-recognition and personal-content
36
+ // exceptions and keeps everything this rule looks at: F109 is listed as a
37
+ // failure of both. Twin-tagged on the 2.1.1/2.1.3 precedent.
38
+ tags: ['wcag22aa', 'wcag338', 'wcag339'],
35
39
  help: 'Login fields must not block paste or password managers',
36
40
  helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/accessible-authentication-minimum.html',
37
41
  selector: 'input[type="password"]:not([autocomplete~="new-password"]), input[autocomplete~="current-password"], input[autocomplete~="one-time-code"]',
@@ -43,7 +47,7 @@ export default {
43
47
  const autocompleteOff = tokens.length === 1 && tokens[0] === 'off';
44
48
  if (!blocksPaste && !autocompleteOff) return { status: 'pass' };
45
49
 
46
- // Is this field provably an existing credential, i.e. inside 3.3.8?
50
+ // Markup suggests an existing credential; it does not prove the process.
47
51
  const form = element.form ?? element.closest('form');
48
52
  const passwordFields = form ? form.querySelectorAll('input[type="password"]').length : (element.type === 'password' ? 1 : 0);
49
53
  const identityField = form?.querySelector('input[autocomplete~="username"], input[autocomplete~="email"], input[type="email"]');
@@ -55,23 +59,16 @@ export default {
55
59
  const step = provenLogin ? 'this login' : 'this step';
56
60
 
57
61
  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
62
  return {
66
63
  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}`,
64
+ 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
65
  fix: 'Remove the paste blocking, and drop autocomplete="off" so a password manager can fill the field.',
69
66
  };
70
67
  }
71
68
  if (blocksPaste) {
72
69
  return {
73
70
  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}`}`,
71
+ 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
72
  fix: 'Remove the paste blocking, or make sure another way to sign in is available that does not rely on recalling the credential.',
76
73
  };
77
74
  }
@@ -0,0 +1,35 @@
1
+ // WCAG SC 3.3.9 Accessible Authentication (Enhanced) (Level AAA)
2
+ // 3.3.8 lets an authentication step use object recognition ("select every
3
+ // bus") and personal content; 3.3.9 removes both exceptions, so a picture
4
+ // puzzle in a login step needs an alternative method or a mechanism that
5
+ // helps the user through it. The one CAPTCHA fact the DOM shows is the
6
+ // widget itself: the reCAPTCHA, hCaptcha and Turnstile embeds and their
7
+ // site-key hosts. Whether that widget will ever show a picture puzzle
8
+ // (risk-based and invisible modes often do not) and whether another way in
9
+ // exists are not visible, so this asks and never asserts.
10
+ //
11
+ // Scope: the criterion covers steps of an AUTHENTICATION process. A
12
+ // CAPTCHA on a contact form or a comment box is outside it, so only a
13
+ // widget inside a form that also takes a credential (a password or a
14
+ // one-time code) is asked about.
15
+ const CREDENTIAL = 'input[type="password"], input[autocomplete~="current-password"], input[autocomplete~="one-time-code"]';
16
+
17
+ export default {
18
+ id: 'captcha-alternative',
19
+ name: 'CAPTCHA in a login step',
20
+ impact: 'serious',
21
+ tags: ['wcag22aaa', 'wcag339'],
22
+ help: 'A picture-puzzle CAPTCHA in an authentication step needs an alternative (AAA)',
23
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/accessible-authentication-enhanced.html',
24
+ selector: 'iframe[src*="recaptcha" i], iframe[src*="hcaptcha" i], iframe[src*="turnstile" i], .g-recaptcha, .h-captcha, .cf-turnstile, [data-sitekey]',
25
+ visibleOnly: false,
26
+ evaluate(element) {
27
+ const form = element.closest('form');
28
+ if (!form || !form.querySelector(CREDENTIAL)) return { status: 'pass' };
29
+ return {
30
+ status: 'incomplete',
31
+ message: 'This login form carries a CAPTCHA widget. If the challenge can escalate to a picture puzzle, that is object recognition, which 3.3.8 allows but 3.3.9 (AAA) does not: a cognitive function test in an authentication step then needs another way in (a passkey, an emailed link, a federated sign-in) or a mechanism that helps the user through it. Check what the widget can show and what the alternatives are.',
32
+ fix: 'Use a risk-based or invisible challenge that never shows a puzzle, or offer another authentication method beside it.',
33
+ };
34
+ },
35
+ };
@@ -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.`,
@@ -1,5 +1,6 @@
1
1
  // WCAG SC 4.1.2 Name, Role, Value (Level A)
2
2
  import { attributesOf } from '../../lib/dom.js';
3
+ import { implicitRole } from '../../lib/roles.js';
3
4
  // Concrete, usable roles — abstract roles are invalid in markup.
4
5
  export const VALID_ROLES = new Set([
5
6
  'alert', 'alertdialog', 'application', 'article', 'banner', 'blockquote', 'button',
@@ -108,7 +109,11 @@ export default {
108
109
  // this is a question, not an assertion. Focusable or state-bearing
109
110
  // elements keep failing: a focusable element left generic, or a state
110
111
  // riding on a role the browser threw away, is the real 4.1.2 harm.
111
- if (!focusable && !hasAriaProps) {
112
+ // An invalid role does not erase a native control's semantics. A
113
+ // misspelling on a button is a review question; its actual missing
114
+ // name or state, if any, belongs to the corresponding semantic rule.
115
+ const nativeRole = implicitRole(element);
116
+ if ((!focusable && !hasAriaProps) || (nativeRole && nativeRole !== 'generic')) {
112
117
  return {
113
118
  status: 'incomplete',
114
119
  message: `role="${element.getAttribute('role')}" is not a valid ARIA role${hint}, so assistive technology ignores it and exposes the element's native <${tag}> semantics. Nothing is announced wrongly, but the author reached for a role that does not exist. Is the native <${tag}> role the right one here? If a different role was intended, that role is missing.`,