pour-engine 1.33.0 → 1.34.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/config/project.config.js +1 -1
  2. package/engine/COVERAGE.md +5 -5
  3. package/engine/index.js +11 -4
  4. package/engine/lib/accessible-name.js +15 -4
  5. package/engine/lib/contrast.js +316 -11
  6. package/engine/lib/roles.js +30 -6
  7. package/engine/rules/best-practice/button-type.js +23 -2
  8. package/engine/rules/best-practice/fieldset-legend.js +7 -2
  9. package/engine/rules/best-practice/heading-order.js +2 -1
  10. package/engine/rules/best-practice/landmark-top-level.js +10 -0
  11. package/engine/rules/best-practice/landmark-unique.js +9 -1
  12. package/engine/rules/best-practice/new-window-link.js +34 -1
  13. package/engine/rules/best-practice/no-autofocus.js +15 -3
  14. package/engine/rules/best-practice/page-heading-one.js +15 -1
  15. package/engine/rules/best-practice/redundant-alt-phrase.js +9 -1
  16. package/engine/rules/best-practice/redundant-aria-label.js +24 -0
  17. package/engine/rules/best-practice/redundant-role.js +71 -20
  18. package/engine/rules/best-practice/region.js +6 -2
  19. package/engine/rules/index.js +13 -0
  20. package/engine/rules/wcag/1.1.1-input-image-alt.js +4 -1
  21. package/engine/rules/wcag/1.1.1-object-alt.js +17 -0
  22. package/engine/rules/wcag/1.2.1-audio-transcript.js +47 -0
  23. package/engine/rules/wcag/1.2.2-media-captions.js +16 -1
  24. package/engine/rules/wcag/1.3.1-aria-required-children.js +30 -3
  25. package/engine/rules/wcag/1.3.1-aria-required-parent.js +25 -9
  26. package/engine/rules/wcag/1.3.1-definition-list.js +15 -3
  27. package/engine/rules/wcag/1.3.1-dlitem-parent.js +14 -1
  28. package/engine/rules/wcag/1.3.1-form-label.js +39 -1
  29. package/engine/rules/wcag/1.3.1-listitem-parent.js +23 -7
  30. package/engine/rules/wcag/1.3.1-table-headers.js +34 -3
  31. package/engine/rules/wcag/1.3.2-reading-order-divergence.js +69 -0
  32. package/engine/rules/wcag/1.3.4-orientation.js +21 -7
  33. package/engine/rules/wcag/1.3.5-autocomplete-valid.js +87 -13
  34. package/engine/rules/wcag/1.4.1-link-in-text-block.js +224 -17
  35. package/engine/rules/wcag/1.4.11-non-text-contrast.js +27 -3
  36. package/engine/rules/wcag/1.4.2-audio-control.js +45 -14
  37. package/engine/rules/wcag/1.4.3-color-contrast.js +337 -47
  38. package/engine/rules/wcag/1.4.3-control-contrast.js +26 -5
  39. package/engine/rules/wcag/2.2.1-meta-refresh.js +25 -1
  40. package/engine/rules/wcag/2.2.2-pause-stop-hide.js +26 -3
  41. package/engine/rules/wcag/2.4.1-bypass-blocks.js +29 -5
  42. package/engine/rules/wcag/2.4.2-document-title.js +8 -2
  43. package/engine/rules/wcag/2.4.3-visual-order-divergence.js +6 -4
  44. package/engine/rules/wcag/2.4.4-link-name.js +11 -0
  45. package/engine/rules/wcag/2.4.4-link-text-generic.js +22 -1
  46. package/engine/rules/wcag/2.4.7-focus-visible.js +47 -10
  47. package/engine/rules/wcag/2.4.9-link-text-generic-only.js +36 -5
  48. package/engine/rules/wcag/2.5.3-label-in-name.js +14 -5
  49. package/engine/rules/wcag/2.5.5-target-size-enhanced.js +3 -1
  50. package/engine/rules/wcag/2.5.7-drag-alternative.js +38 -0
  51. package/engine/rules/wcag/2.5.8-target-size.js +122 -3
  52. package/engine/rules/wcag/3.1.1-html-lang.js +12 -1
  53. package/engine/rules/wcag/3.2.2-on-input-navigation.js +18 -8
  54. package/engine/rules/wcag/3.3.7-redundant-entry.js +55 -0
  55. package/engine/rules/wcag/3.3.8-auth-field-obstruction.js +50 -20
  56. package/engine/rules/wcag/4.1.2-aria-attr-valid.js +49 -13
  57. package/engine/rules/wcag/4.1.2-aria-hidden-focus.js +6 -1
  58. package/engine/rules/wcag/4.1.2-aria-valid-refs.js +109 -72
  59. package/engine/rules/wcag/4.1.2-button-name.js +11 -0
  60. package/engine/rules/wcag/4.1.2-composite-widget-name.js +12 -6
  61. package/engine/rules/wcag/4.1.2-label-for-valid.js +41 -8
  62. package/engine/rules/wcag/4.1.2-nested-interactive.js +18 -1
  63. package/engine/rules/wcag/4.1.2-valid-role.js +16 -0
  64. package/engine/wcag22.js +4 -4
  65. package/package.json +1 -1
@@ -173,6 +173,68 @@ function isInTextLine(element) {
173
173
  return false;
174
174
  }
175
175
 
176
+ // ── User Agent Control, proved from the stylesheets ──────────────────
177
+ // The exception covers a native control whose size "is determined by the
178
+ // user agent and is not modified by the author". A bare <button>Save</button>
179
+ // at 46×21 was asserted under 2.5.5, and two stacked ones under 2.5.8's
180
+ // spacing test, until this proof existed (2026-08-25 overnight audit).
181
+ // Proof: no inline style and no readable author rule that matches the
182
+ // element declares a property that can change its box. An unreadable
183
+ // (cross-origin) sheet, or a selector the browser cannot parse, leaves the
184
+ // proof open: the finding then reviews instead of asserting. The sheet walk
185
+ // happens once per root and is cached on it for the audit; media rules that
186
+ // do not apply now are skipped, since they size nothing now.
187
+ const NATIVE_CONTROL = /^(button|input|select|textarea)$/i;
188
+ 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)$/;
189
+ const sizingRulesByRoot = new WeakMap();
190
+
191
+ function declaresSizing(style) {
192
+ for (let k = 0; k < style.length; k++) if (SIZING.test(style[k])) return true;
193
+ return false;
194
+ }
195
+
196
+ function authorSizingRules(root) {
197
+ const sheets = [...(root.styleSheets ?? []), ...(root.adoptedStyleSheets ?? [])];
198
+ const key = sheets.map((s) => { try { return s.cssRules.length; } catch { return 'x'; } }).join(',');
199
+ const cached = sizingRulesByRoot.get(root);
200
+ if (cached?.key === key) return cached;
201
+ const win = (root.ownerDocument ?? root).defaultView;
202
+ const probe = (root.ownerDocument ?? root).createElement('div');
203
+ const entry = { key, selectors: [], open: false };
204
+ const walk = (rules, parent) => {
205
+ for (const rule of rules) {
206
+ if (rule.media && win?.matchMedia && !win.matchMedia(rule.media.mediaText).matches) continue;
207
+ let selector = rule.selectorText ?? parent; // nested declarations belong to the parent
208
+ // CSS nesting: "& .x" means ":is(parent) .x", and a child with no "&" is
209
+ // a descendant of the parent.
210
+ if (parent && rule.selectorText) {
211
+ selector = /&/.test(selector) ? selector.replace(/&/g, `:is(${parent})`) : `:is(${parent}) ${selector}`;
212
+ }
213
+ if (selector && rule.style && declaresSizing(rule.style)) {
214
+ try { probe.matches(selector); entry.selectors.push(selector); } catch { entry.open = true; }
215
+ }
216
+ if (rule.cssRules?.length) walk(rule.cssRules, rule.selectorText ? selector : parent);
217
+ }
218
+ };
219
+ for (const sheet of sheets) {
220
+ try { walk(sheet.cssRules, null); } catch { entry.open = true; }
221
+ }
222
+ entry.combined = entry.selectors.join(', ');
223
+ sizingRulesByRoot.set(root, entry);
224
+ return entry;
225
+ }
226
+
227
+ /** 'yes' when the browser provably sized this native control, 'no' when the
228
+ * author touched its box (or it is not a native control), 'open' when a
229
+ * sheet or selector could not be read. */
230
+ function uaSized(element) {
231
+ if (!NATIVE_CONTROL.test(element.tagName) || element.type === 'image') return 'no';
232
+ if (declaresSizing(element.style)) return 'no';
233
+ const entry = authorSizingRules(element.getRootNode());
234
+ if (entry.combined && element.matches(entry.combined)) return 'no';
235
+ return entry.open ? 'open' : 'yes';
236
+ }
237
+
176
238
  /** Nearest distance from a point to a rectangle (0 when inside it). */
177
239
  function pointToRectDistance(point, rect) {
178
240
  const dx = Math.max(rect.left - point.x, 0, point.x - rect.right);
@@ -188,6 +250,21 @@ function pointToRectDistance(point, rect) {
188
250
  * lists only equivalent/inline/user-agent/essential there)
189
251
  */
190
252
  export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingException }) {
253
+ // The verdict compares exact floats, so display must not round a failing
254
+ // dimension up to the minimum: a 43.8px-tall button failing the AAA 44px
255
+ // bar must not read "44×44px — below the 44×44px minimum" (World Bank,
256
+ // 2026-08-25). One decimal only where integer rounding would cross the line,
257
+ // and a second decimal where one decimal STILL rounds up to it: toFixed
258
+ // rounds too, so 23.98 printed "24.0" and 43.98 printed "44.0" (2026-08-25
259
+ // overnight audit). Anything closer than that reads "just under".
260
+ const px = (v) => {
261
+ if (!(Math.round(v) >= min && v < min)) return String(Math.round(v));
262
+ for (const digits of [1, 2]) {
263
+ const shown = v.toFixed(digits);
264
+ if (Number(shown) < min) return shown;
265
+ }
266
+ return `just under ${min}`;
267
+ };
191
268
  return {
192
269
  id,
193
270
  impact: 'serious',
@@ -396,6 +473,36 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
396
473
  return effective;
397
474
  };
398
475
 
476
+ // ── Equivalent targets ────────────────────────────────────────────
477
+ // Both criteria except a target whose function is available through
478
+ // another control on the same page that meets the minimum. Same
479
+ // destination is the one form of "same function" the DOM can prove,
480
+ // and obscuredRect above already reads matching hrefs that way. The
481
+ // card pattern (a 200×150 image link over a 16px title link to the
482
+ // same article) failed on the title link until the undersized path
483
+ // asked the same question (2026-08-25 overnight audit). A bare "#" or
484
+ // a javascript: URL names no destination, so it never pairs.
485
+ const destinationOf = (element) => {
486
+ if (typeof element.href !== 'string' || !element.href) return null;
487
+ const attr = (element.getAttribute('href') ?? '').trim();
488
+ if (!attr || attr === '#' || /^javascript:/i.test(attr)) return null;
489
+ return element.href;
490
+ };
491
+ let adequateDestinations = null;
492
+ const equivalentElsewhere = (i) => {
493
+ const destination = destinationOf(elements[i]);
494
+ if (!destination) return false;
495
+ if (!adequateDestinations) {
496
+ adequateDestinations = new Set();
497
+ elements.forEach((other, j) => {
498
+ if (!laidOut[j] || undersized[j] || other.ownerDocument !== elements[i].ownerDocument) return;
499
+ const d = destinationOf(other);
500
+ if (d) adequateDestinations.add(d);
501
+ });
502
+ }
503
+ return adequateDestinations.has(destination);
504
+ };
505
+
399
506
  return elements.map((element, i) => {
400
507
  if (!laidOut[i]) return { status: 'pass' }; // not laid out / not a pointer target
401
508
  if (!undersized[i]) {
@@ -432,7 +539,7 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
432
539
  const rect = rects[i];
433
540
  return {
434
541
  status: 'fail',
435
- message: `This target's box is ${Math.round(rect.width)}×${Math.round(rect.height)}px, but another target overlaps it and clicks in the overlap go there — the part that still accepts the pointer is ${Math.round(effective.width)}×${Math.round(effective.height)}px, below the ${min}×${min}px minimum.`,
542
+ message: `This target's box is ${Math.round(rect.width)}×${Math.round(rect.height)}px, but another target overlaps it and clicks in the overlap go there — the part that still accepts the pointer is ${px(effective.width)}×${px(effective.height)}px, below the ${min}×${min}px minimum.`,
436
543
  fix: 'Space the targets so they no longer overlap, or enlarge this one until the uncovered part reaches the minimum.',
437
544
  data: {
438
545
  box: { width: Math.round(rect.width), height: Math.round(rect.height) },
@@ -441,6 +548,11 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
441
548
  };
442
549
  }
443
550
  if (uaControlled(element)) return { status: 'pass' }; // UA Control exception
551
+ // The same exception for every other native control, proved from the
552
+ // sheets; an open proof (unreadable sheet) reviews at the end instead
553
+ // of asserting, and only if nothing else rescues the target first.
554
+ const uaProof = uaSized(element);
555
+ if (uaProof === 'yes') return { status: 'pass' };
444
556
  if (isInTextLine(element)) return { status: 'pass' };
445
557
  if (spacingException) {
446
558
  // Spacing exception: the min-sized circle around this target must
@@ -486,12 +598,19 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
486
598
  // dismissed, and suppressing it would hide a real defect behind a
487
599
  // transient overlay, which is the wrong way round.
488
600
  }
601
+ if (equivalentElsewhere(i)) return { status: 'pass' }; // Equivalent exception, proved by href
489
602
  const rect = rects[i];
603
+ if (uaProof === 'open') {
604
+ return {
605
+ status: 'incomplete',
606
+ message: `This native control is ${px(rect.width)}×${px(rect.height)}px, under the ${min}×${min}px minimum${spacingException ? ', and another target crowds it' : ''}. The browser sizes an unstyled control (the User Agent Control exception), but a stylesheet this audit cannot read might set its size. Check whether author CSS sizes this control; if nothing does, it passes.`,
607
+ };
608
+ }
490
609
  return {
491
610
  status: 'fail',
492
611
  message: spacingException
493
- ? `This target is ${Math.round(rect.width)}×${Math.round(rect.height)}px AND another target crowds it (within ${min}px) — small targets are only acceptable with clear space around them; crowded ones are hard to hit for users with motor impairments.`
494
- : `This target is ${Math.round(rect.width)}×${Math.round(rect.height)}px — below the ${min}×${min}px minimum, hard to hit for users with motor impairments.`,
612
+ ? `This target is ${px(rect.width)}×${px(rect.height)}px AND another target crowds it (within ${min}px) — small targets are only acceptable with clear space around them; crowded ones are hard to hit for users with motor impairments.`
613
+ : `This target is ${px(rect.width)}×${px(rect.height)}px — below the ${min}×${min}px minimum, hard to hit for users with motor impairments.`,
495
614
  fix: `Increase the element’s size or padding to at least ${min}×${min}px${spacingException ? ', or add spacing between the targets' : ''}.`,
496
615
  };
497
616
  });
@@ -21,13 +21,24 @@ export default {
21
21
  visibleOnly: false,
22
22
  evaluate(element) {
23
23
  const lang = element.getAttribute('lang')?.trim();
24
- if (!lang) {
24
+ if (!element.hasAttribute('lang')) {
25
25
  return {
26
26
  status: 'fail',
27
27
  message: 'No lang attribute: screen readers will guess the language and may mispronounce everything.',
28
28
  fix: 'Add lang to the html element, e.g. <html lang="en">.',
29
29
  };
30
30
  }
31
+ // H57 step 1 (the attribute exists) passes here and step 2 (a BCP 47
32
+ // value) fails: an empty value declares nothing. Named separately so
33
+ // the author looks at the value, not for a missing attribute
34
+ // (2026-08-25 overnight audit).
35
+ if (!lang) {
36
+ return {
37
+ status: 'fail',
38
+ message: 'The lang attribute is present but empty, so the page declares no language: screen readers will guess it and may mispronounce everything.',
39
+ fix: 'Give lang a BCP 47 value, e.g. <html lang="en">.',
40
+ };
41
+ }
31
42
  if (!LANG_PATTERN.test(lang)) {
32
43
  return {
33
44
  status: 'fail',
@@ -16,7 +16,12 @@
16
16
  // Writes and calls only: reading location (analytics, hash checks) is not
17
17
  // navigation, so the pattern demands an assignment to location or one of
18
18
  // the navigating members/calls.
19
- const NAVIGATES = /\blocation\s*(=|\.\s*(href|assign|replace|reload))|\.(submit|requestSubmit)\s*\(|window\.open\s*\(/;
19
+ // The href branch demands an assignment too (2026-08-25 overnight audit):
20
+ // `location.href` READ as an argument (track(location.href, …)) matched the
21
+ // old pattern, so an analytics handler was reported as navigation. Now
22
+ // `location =`, `location.href =` (or `+=`) write, and assign / replace /
23
+ // reload must be called.
24
+ const NAVIGATES = /\blocation\s*(\+?=(?!=)|\.\s*(href\s*\+?=(?!=)|(assign|replace|reload)\s*\())|\.(submit|requestSubmit)\s*\(|window\.open\s*\(/;
20
25
 
21
26
  export default {
22
27
  id: 'on-input-navigation',
@@ -35,13 +40,18 @@ export default {
35
40
  fix: 'Trigger navigation from activation (click/Enter), never from focus.',
36
41
  };
37
42
  }
38
- const onchange = element.getAttribute('onchange') ?? element.getAttribute('oninput');
39
- if (onchange && NAVIGATES.test(onchange)) {
40
- return {
41
- status: 'incomplete',
42
- message: 'Changing this control appears to navigate or submit (its onchange handler reaches for location/submit). WCAG 3.2.2 allows that only when users are told beforehand — check the page says so before the control.',
43
- fix: 'Describe the behaviour before the control, or navigate from an explicit Go button instead of the change event.',
44
- };
43
+ // Both change-shaped events are read on their own (2026-08-25 overnight
44
+ // audit): a harmless onchange used to hide a submitting oninput, and F36
45
+ // applies whichever event fires the submit.
46
+ for (const attr of ['onchange', 'oninput']) {
47
+ const handler = element.getAttribute(attr);
48
+ if (handler && NAVIGATES.test(handler)) {
49
+ return {
50
+ status: 'incomplete',
51
+ message: `Changing this control appears to navigate or submit (its ${attr} handler reaches for location/submit). WCAG 3.2.2 allows that only when users are told beforehand: check the page says so before the control.`,
52
+ fix: 'Describe the behaviour before the control, or navigate from an explicit Go button instead of the change event.',
53
+ };
54
+ }
45
55
  }
46
56
  return { status: 'pass' };
47
57
  },
@@ -0,0 +1,55 @@
1
+ // WCAG SC 3.3.7 Redundant Entry (Level A, new in 2.2)
2
+ //
3
+ // Information the user already gave in a process must be auto-populated or
4
+ // available to select, unless re-entry is essential, needed for security,
5
+ // or the earlier value has gone stale. A cross-page "process" is invisible
6
+ // to a one-shot scan, but one slice is visible in a single form: two
7
+ // enabled fields declaring the SAME autocomplete purpose. A form with two
8
+ // `autocomplete="email"` fields is asking for the email twice — exactly
9
+ // the shape the criterion exists for — or it is a confirmation field,
10
+ // which is the human's call (the SC's "essential" escape). So this asks,
11
+ // it never asserts.
12
+ //
13
+ // The security escape is honoured structurally: password and one-time-code
14
+ // purposes never count (re-entering a password to confirm it IS the
15
+ // documented security exception). Distinct scopes are honoured too: the
16
+ // autocomplete grammar prefixes a field token with `section-*` / shipping /
17
+ // billing, and a shipping address plus a billing address are two different
18
+ // pieces of information — only fully identical normalized token lists pair
19
+ // up. Fields must be user-reachable: hidden inputs, disabled and readonly
20
+ // fields auto-populate without the user typing, which is the SC being
21
+ // SATISFIED, not violated.
22
+ const EXEMPT = new Set(['new-password', 'current-password', 'one-time-code', 'on', 'off']);
23
+
24
+ export default {
25
+ id: 'redundant-entry',
26
+ name: 'Redundant entry',
27
+ impact: 'moderate',
28
+ tags: ['wcag22a', 'wcag337'],
29
+ help: 'A form should not ask for the same information twice',
30
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/redundant-entry.html',
31
+ selector: 'form',
32
+ evaluate(element, { isVisible }) {
33
+ const seen = new Map();
34
+ for (const field of element.querySelectorAll('input[autocomplete], select[autocomplete], textarea[autocomplete]')) {
35
+ if (field.disabled || field.readOnly) continue;
36
+ // A field the user cannot reach (a display:none branch of a
37
+ // mutually-exclusive section, a collapsed disclosure) is not
38
+ // "required to be entered again" (2026-08-25 overnight audit).
39
+ if (!isVisible(field)) continue;
40
+ if (field instanceof HTMLInputElement && (field.type === 'hidden' || field.type === 'password')) continue;
41
+ const purpose = (field.getAttribute('autocomplete') || '').trim().toLowerCase().replace(/\s+/g, ' ');
42
+ if (!purpose || EXEMPT.has(purpose)) continue;
43
+ if (seen.has(purpose)) {
44
+ return {
45
+ status: 'incomplete',
46
+ message: `This form asks for "${purpose}" twice — 3.3.7 says information the user already entered must be auto-populated or selectable, not typed again. A deliberate confirmation field can be essential (the criterion's own escape); that judgement is yours.`,
47
+ fix: 'Auto-populate the second field from the first, offer the earlier value for selection, or drop the duplicate. Keep it only if re-entry is genuinely essential here.',
48
+ data: { purpose },
49
+ };
50
+ }
51
+ seen.set(purpose, field);
52
+ }
53
+ return { status: 'pass' };
54
+ },
55
+ };
@@ -11,6 +11,22 @@
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
13
  // case worth asserting.
14
+ //
15
+ // Paste only (2026-08-25 overnight audit): an ondrop handler used to count
16
+ // as a paste blocker. It is not. Drag and drop is a different event and a
17
+ // different user action, appears nowhere in the SC, its notes or F109, and
18
+ // leaves copy and paste fully working, so ondrop is no evidence at all.
19
+ //
20
+ // Scope (2026-08-25 overnight audit): the Understanding says the criterion
21
+ // "is focused on authentication of existing users. It does not cover
22
+ // creation of a username or initiation of an account." Choosing a new
23
+ // password recalls nothing. So autocomplete="new-password" fields are out
24
+ // of the selector, and a bare type="password" is only PROVABLY a login
25
+ // credential when the author says so (autocomplete="current-password") or
26
+ // the form has the classic sign-in shape: exactly one password field beside
27
+ // a username or email field. Two password fields in one form is the shape
28
+ // 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.
14
30
  export default {
15
31
  id: 'auth-field-obstruction',
16
32
  name: 'Accessible login fields',
@@ -18,36 +34,50 @@ export default {
18
34
  tags: ['wcag22aa', 'wcag338'],
19
35
  help: 'Login fields must not block paste or password managers',
20
36
  helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/accessible-authentication-minimum.html',
21
- selector: 'input[type="password"], input[autocomplete~="current-password"], input[autocomplete~="one-time-code"], input[autocomplete~="new-password"]',
37
+ selector: 'input[type="password"]:not([autocomplete~="new-password"]), input[autocomplete~="current-password"], input[autocomplete~="one-time-code"]',
22
38
  evaluate(element) {
23
39
  // Inline handlers are the only statically-visible blockers; framework
24
40
  // listeners are invisible to a DOM audit.
25
- const blocking = ['onpaste', 'ondrop'].find((attr) => {
26
- const handler = element.getAttribute(attr) ?? '';
27
- return /return\s+false|preventDefault/.test(handler);
28
- });
29
- const autocompleteOff = (element.getAttribute('autocomplete') ?? '').trim().toLowerCase() === 'off';
30
- const action = blocking === 'onpaste' ? 'pasting' : 'dropping';
31
- if (blocking && autocompleteOff) {
32
- return {
33
- status: 'fail',
34
- message: `This authentication field blocks ${action} 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.`,
35
- fix: 'Remove the paste/drop blocking, and drop autocomplete="off" so a password manager can fill the field.',
36
- };
37
- }
38
- if (blocking) {
41
+ const blocksPaste = /return\s+false|preventDefault/.test(element.getAttribute('onpaste') ?? '');
42
+ const tokens = (element.getAttribute('autocomplete') ?? '').trim().toLowerCase().split(/\s+/).filter(Boolean);
43
+ const autocompleteOff = tokens.length === 1 && tokens[0] === 'off';
44
+ if (!blocksPaste && !autocompleteOff) return { status: 'pass' };
45
+
46
+ // Is this field provably an existing credential, i.e. inside 3.3.8?
47
+ const form = element.form ?? element.closest('form');
48
+ const passwordFields = form ? form.querySelectorAll('input[type="password"]').length : (element.type === 'password' ? 1 : 0);
49
+ const identityField = form?.querySelector('input[autocomplete~="username"], input[autocomplete~="email"], input[type="email"]');
50
+ const provenLogin = tokens.includes('current-password') || tokens.includes('one-time-code')
51
+ || (passwordFields === 1 && Boolean(identityField));
52
+ const scopeQuestion = passwordFields >= 2
53
+ ? 'This form holds more than one password field, the shape of account sign-up or a password change, which 3.3.8 does not cover unless the field takes an existing credential. Confirm whether this field is part of an authentication step at all.'
54
+ : 'Nothing here shows the field takes an existing credential (no current-password purpose, and no username or email field beside it), so confirm it is part of signing in before treating this as a failure.';
55
+ const step = provenLogin ? 'this login' : 'this step';
56
+
57
+ 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
+ }
39
65
  return {
40
66
  status: 'incomplete',
41
- message: `This authentication field blocks ${action}, 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 this login, or whether the page offers another way in such as a passkey or a federated sign-in.`,
42
- fix: `Remove the ${action === 'pasting' ? 'paste' : 'drop'} blocking, or make sure another way to sign in is available that does not rely on recalling the credential.`,
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}`,
68
+ fix: 'Remove the paste blocking, and drop autocomplete="off" so a password manager can fill the field.',
43
69
  };
44
70
  }
45
- if (autocompleteOff) {
71
+ if (blocksPaste) {
46
72
  return {
47
73
  status: 'incomplete',
48
- message: 'This authentication field sets autocomplete="off", discouraging password managers. Browsers often fill anyway — verify a password manager can complete this login without retyping.',
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}`}`,
75
+ fix: 'Remove the paste blocking, or make sure another way to sign in is available that does not rely on recalling the credential.',
49
76
  };
50
77
  }
51
- return { status: 'pass' };
78
+ return {
79
+ status: 'incomplete',
80
+ message: `This field sets autocomplete="off", discouraging password managers. Browsers often fill anyway: verify a password manager can complete ${step} without retyping.${provenLogin ? '' : ` ${scopeQuestion}`}`,
81
+ };
52
82
  },
53
83
  };
@@ -1,20 +1,24 @@
1
1
  // WCAG SC 4.1.2 Name, Role, Value (Level A)
2
2
  // ARIA 1.2 attribute names (shared vocabulary in lib/roles.js), and the
3
3
  // allowed values for enumerated ones.
4
- import { KNOWN_ARIA as KNOWN } from '../../lib/roles.js';
4
+ import { KNOWN_ARIA as KNOWN, GLOBAL_ARIA, ROLE_ARIA, effectiveRole } from '../../lib/roles.js';
5
5
 
6
6
  // Note: "undefined" is a legal token for the tristate/undefinable attributes
7
7
  // (aria-expanded, aria-selected, aria-checked, aria-pressed) per ARIA 1.2.
8
+ // aria-hidden and aria-orientation list it too, as their default token
9
+ // ("undefined (default): determined by the user agent" / "orientation is
10
+ // unknown/ambiguous"). Added 2026-08-25 overnight audit: rejecting it was a
11
+ // false assertion on conforming markup.
8
12
  const ENUMS = {
9
13
  'aria-atomic': ['true', 'false'], 'aria-busy': ['true', 'false'],
10
14
  'aria-disabled': ['true', 'false'], 'aria-expanded': ['true', 'false', 'undefined'],
11
- 'aria-hidden': ['true', 'false'], 'aria-modal': ['true', 'false'],
15
+ 'aria-hidden': ['true', 'false', 'undefined'], 'aria-modal': ['true', 'false'],
12
16
  'aria-multiline': ['true', 'false'], 'aria-multiselectable': ['true', 'false'],
13
17
  'aria-readonly': ['true', 'false'], 'aria-required': ['true', 'false'],
14
18
  'aria-selected': ['true', 'false', 'undefined'],
15
19
  'aria-checked': ['true', 'false', 'mixed', 'undefined'], 'aria-pressed': ['true', 'false', 'mixed', 'undefined'],
16
20
  'aria-live': ['off', 'polite', 'assertive'],
17
- 'aria-orientation': ['horizontal', 'vertical'],
21
+ 'aria-orientation': ['horizontal', 'vertical', 'undefined'],
18
22
  'aria-sort': ['none', 'ascending', 'descending', 'other'],
19
23
  'aria-autocomplete': ['none', 'inline', 'list', 'both'],
20
24
  'aria-invalid': ['true', 'false', 'grammar', 'spelling'],
@@ -32,20 +36,52 @@ export default {
32
36
  selector: '*',
33
37
  visibleOnly: false, // applies to hidden elements too; also avoids visibility cost on every node
34
38
  evaluate(element) {
35
- const problems = [];
39
+ // Two different findings live here (split 2026-08-25 overnight audit).
40
+ //
41
+ // An UNKNOWN attribute name is ignored outright by the browser: it can
42
+ // alter no name, role or value, so by itself it proves no 4.1.2 failure
43
+ // (the criterion that policed validity, 4.1.1 Parsing, is gone from
44
+ // WCAG 2.2). What it may hide is a real state or name the author reached
45
+ // for and mistyped; where that leaves a control nameless the name rules
46
+ // already assert. So unknown names are a review question, the same
47
+ // stance aria-allowed-attr takes for an unsupported attribute.
48
+ //
49
+ // An enumerated VALUE the browser cannot parse, on a role that supports
50
+ // the state, is different: the author wrote a state and the browser drops
51
+ // it, so the state the element has is invisible. That stays asserted.
52
+ // When the element's role is modelled and does not support the attribute
53
+ // at all, the value is moot and aria-allowed-attr already reviews the
54
+ // attribute; reporting the value too would be the same finding twice.
55
+ const unknown = [];
56
+ const invalid = [];
57
+ let role;
36
58
  for (const { name, value } of element.attributes) {
37
59
  if (!name.startsWith('aria-')) continue;
38
- if (!KNOWN.has(name.slice(5))) {
39
- problems.push(`${name} is not an ARIA attribute`);
60
+ const attr = name.slice(5);
61
+ if (!KNOWN.has(attr)) {
62
+ unknown.push(name);
40
63
  } else if (ENUMS[name] && !ENUMS[name].includes(value.trim().toLowerCase())) {
41
- problems.push(`${name}="${value}" — allowed values: ${ENUMS[name].join(', ')}`);
64
+ if (!GLOBAL_ARIA.has(attr)) {
65
+ role ??= effectiveRole(element);
66
+ if (role && ROLE_ARIA[role] && !ROLE_ARIA[role].includes(attr)) continue;
67
+ }
68
+ invalid.push(`${name}="${value}" — allowed values: ${ENUMS[name].join(', ')}`);
42
69
  }
43
70
  }
44
- if (!problems.length) return { status: 'pass' };
45
- return {
46
- status: 'fail',
47
- message: `Invalid ARIA: ${problems.join('; ')}. Assistive technology ignores attributes it doesn't recognise.`,
48
- fix: 'Correct the attribute name/value against the ARIA specification, or remove it.',
49
- };
71
+ if (invalid.length) {
72
+ return {
73
+ status: 'fail',
74
+ message: `Invalid ARIA: ${invalid.join('; ')}. Assistive technology ignores attributes it doesn't recognise.`,
75
+ fix: 'Correct the attribute name/value against the ARIA specification, or remove it.',
76
+ };
77
+ }
78
+ if (unknown.length) {
79
+ return {
80
+ status: 'incomplete',
81
+ message: `${unknown.join(', ')} is not an ARIA attribute, so the browser ignores it and assistive technology never sees it. Nothing is announced wrongly, but nothing is announced at all. Was a real ARIA state or name intended here? If it was, that state or name is missing and this is a 4.1.2 failure. If not, the attribute is stray and no one is affected. Invalid markup is not by itself a WCAG failure: the criterion that policed validity, 4.1.1 Parsing, was removed in WCAG 2.2.`,
82
+ fix: 'Correct the attribute name against the ARIA specification, or remove it.',
83
+ };
84
+ }
85
+ return { status: 'pass' };
50
86
  },
51
87
  };
@@ -23,8 +23,13 @@ export default {
23
23
  // Transparency is measured up the flat tree: opacity is not an inherited
24
24
  // property, so a faded WRAPPER (the standard off-canvas drawer) leaves
25
25
  // every control inside it computing opacity: 1 while none of them paint.
26
+ // Any NEGATIVE tabindex takes an element out of sequential focus (HTML:
27
+ // "a negative integer"), not only the literal "-1"; tabIndex parses the
28
+ // attribute the way the browser does. Guarded by hasAttribute because
29
+ // Chromium reports tabIndex -1 for a contenteditable div that has no
30
+ // attribute at all, and that one IS reachable. 2026-08-25 overnight audit.
26
31
  const focusable = [element, ...element.querySelectorAll(FOCUSABLE)].filter(
27
- (el) => el.matches?.(FOCUSABLE) && el.getAttribute('tabindex') !== '-1' && !el.matches(':disabled')
32
+ (el) => el.matches?.(FOCUSABLE) && !(el.hasAttribute('tabindex') && el.tabIndex < 0) && !el.matches(':disabled')
28
33
  // `inert` removes focusability outright — aria-hidden + inert is
29
34
  // the CORRECT modern pattern for off-canvas content, not a defect.
30
35
  && !el.closest('[inert]')