@surea11y/core 1.3.0 → 1.4.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 (163) hide show
  1. package/CHANGELOG.md +46 -2
  2. package/README.md +109 -35
  3. package/bin/surea11y-core.js +20 -0
  4. package/docs/API_STABILITY.md +26 -0
  5. package/docs/CI_INTEGRATIONS.md +7 -7
  6. package/docs/ENGINE_OPTIONS.md +1 -1
  7. package/docs/I18N.md +12 -9
  8. package/docs/INTEGRATION.md +1 -1
  9. package/docs/LIMITATIONS.md +1 -1
  10. package/docs/REPORT.md +1 -1
  11. package/package.json +50 -16
  12. package/src/baseline.js +0 -0
  13. package/src/checks/automatic/area-alt-present.js +4 -6
  14. package/src/checks/automatic/aria-allowed-attr.js +15 -51
  15. package/src/checks/automatic/aria-allowed-role.js +2 -0
  16. package/src/checks/automatic/aria-braille-equivalent.js +2 -0
  17. package/src/checks/automatic/aria-conditional-attr.js +8 -7
  18. package/src/checks/automatic/aria-deprecated-role.js +4 -3
  19. package/src/checks/automatic/aria-hidden-body.js +6 -4
  20. package/src/checks/automatic/aria-hidden-focus.js +12 -13
  21. package/src/checks/automatic/aria-prohibited-attr.js +98 -105
  22. package/src/checks/automatic/aria-prohibited-children.js +56 -87
  23. package/src/checks/automatic/aria-required-attr.js +6 -7
  24. package/src/checks/automatic/aria-required-children.js +7 -10
  25. package/src/checks/automatic/aria-required-parent.js +20 -25
  26. package/src/checks/automatic/aria-role-name-present.js +2 -0
  27. package/src/checks/automatic/aria-roles-valid.js +2 -0
  28. package/src/checks/automatic/aria-valid-attr-value.js +18 -15
  29. package/src/checks/automatic/aria-valid-attr.js +2 -0
  30. package/src/checks/automatic/autocomplete-valid.js +2 -0
  31. package/src/checks/automatic/avoid-inline-spacing.js +3 -2
  32. package/src/checks/automatic/binary-control-name-present.js +2 -0
  33. package/src/checks/automatic/button-name-present.js +7 -7
  34. package/src/checks/automatic/bypass-blocks-present.js +9 -7
  35. package/src/checks/automatic/canvas-text-alternative-present.js +2 -0
  36. package/src/checks/automatic/combobox-name-present.js +2 -0
  37. package/src/checks/automatic/contrast-computable.js +2 -0
  38. package/src/checks/automatic/contrast-enhanced.js +2 -0
  39. package/src/checks/automatic/contrast-minimum.js +2 -0
  40. package/src/checks/automatic/css-orientation-lock.js +21 -27
  41. package/src/checks/automatic/definition-list-children-valid.js +6 -6
  42. package/src/checks/automatic/deprecated-elements-not-used.js +4 -2
  43. package/src/checks/automatic/dialog-name-present.js +10 -10
  44. package/src/checks/automatic/dlitem-parent-valid.js +2 -0
  45. package/src/checks/automatic/duplicate-id-aria.js +4 -3
  46. package/src/checks/automatic/embed-text-alternative-present.js +2 -0
  47. package/src/checks/automatic/form-control-programmatic-label-present.js +2 -0
  48. package/src/checks/automatic/form-control-single-label.js +6 -7
  49. package/src/checks/automatic/html-xml-lang-mismatch.js +2 -0
  50. package/src/checks/automatic/iframe-focusable-content.js +246 -18
  51. package/src/checks/automatic/iframe-name-present.js +2 -0
  52. package/src/checks/automatic/iframe-title-unique.js +3 -1
  53. package/src/checks/automatic/img-alt-present.js +7 -9
  54. package/src/checks/automatic/input-image-alt-present.js +4 -6
  55. package/src/checks/automatic/label-in-name.js +15 -19
  56. package/src/checks/automatic/language-page-present.js +2 -0
  57. package/src/checks/automatic/link-in-text-block.js +2 -0
  58. package/src/checks/automatic/link-name-present.js +2 -0
  59. package/src/checks/automatic/list-children-valid.js +14 -24
  60. package/src/checks/automatic/listbox-name-present.js +2 -0
  61. package/src/checks/automatic/listitem-parent-valid.js +30 -7
  62. package/src/checks/automatic/menuitem-name-present.js +2 -0
  63. package/src/checks/automatic/meta-refresh-no-exceptions.js +4 -4
  64. package/src/checks/automatic/meta-refresh-timing-absent.js +2 -0
  65. package/src/checks/automatic/meta-viewport-zoom-enabled.js +2 -0
  66. package/src/checks/automatic/meter-name-present.js +4 -3
  67. package/src/checks/automatic/nested-interactive-controls-absent.js +27 -5
  68. package/src/checks/automatic/object-text-alternative-present.js +2 -0
  69. package/src/checks/automatic/option-name-present.js +2 -0
  70. package/src/checks/automatic/page-title-present.js +2 -0
  71. package/src/checks/automatic/progressbar-name-present.js +8 -10
  72. package/src/checks/automatic/role-img-alt-present.js +4 -4
  73. package/src/checks/automatic/searchbox-name-present.js +2 -0
  74. package/src/checks/automatic/server-side-image-map-absent.js +4 -3
  75. package/src/checks/automatic/slider-name-present.js +2 -0
  76. package/src/checks/automatic/spinbutton-name-present.js +2 -0
  77. package/src/checks/automatic/summary-name-present.js +2 -0
  78. package/src/checks/automatic/svg-image-text-alternative-present.js +2 -0
  79. package/src/checks/automatic/svg-text-alternative-present.js +17 -5
  80. package/src/checks/automatic/tab-name-present.js +2 -0
  81. package/src/checks/automatic/table-headers-attr-valid.js +3 -2
  82. package/src/checks/automatic/table-th-has-data-cells.js +2 -0
  83. package/src/checks/automatic/target-size-minimum.js +5 -0
  84. package/src/checks/automatic/td-has-header.js +24 -1
  85. package/src/checks/automatic/textbox-name-present.js +2 -0
  86. package/src/checks/automatic/tooltip-name-present.js +2 -0
  87. package/src/checks/automatic/treeitem-name-present.js +2 -0
  88. package/src/checks/automatic/valid-lang.js +2 -0
  89. package/src/checks/automatic/video-poster-text-alternative-present.js +2 -0
  90. package/src/checks/manual/accesskeys-manual.js +3 -1
  91. package/src/checks/manual/area-alt-decorative-manual.js +2 -0
  92. package/src/checks/manual/area-alt-quality-manual.js +2 -0
  93. package/src/checks/manual/aria-checked-state-mismatch-manual.js +14 -23
  94. package/src/checks/manual/aria-text-manual.js +6 -5
  95. package/src/checks/manual/canvas-text-alternative-quality-manual.js +2 -0
  96. package/src/checks/manual/css-hidden-focus.js +184 -9
  97. package/src/checks/manual/embed-text-alternative-quality-manual.js +18 -13
  98. package/src/checks/manual/empty-heading-manual.js +17 -17
  99. package/src/checks/manual/empty-table-header-manual.js +52 -25
  100. package/src/checks/manual/focus-order-semantics-manual.js +16 -4
  101. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -0
  102. package/src/checks/manual/heading-order-manual.js +28 -1
  103. package/src/checks/manual/identical-links-same-purpose-manual.js +2 -0
  104. package/src/checks/manual/image-redundant-alt-manual.js +21 -1
  105. package/src/checks/manual/img-alt-decorative-manual.js +2 -0
  106. package/src/checks/manual/img-alt-quality-manual.js +2 -0
  107. package/src/checks/manual/input-image-alt-decorative-manual.js +2 -0
  108. package/src/checks/manual/input-image-alt-quality-manual.js +2 -0
  109. package/src/checks/manual/label-title-only-manual.js +29 -22
  110. package/src/checks/manual/landmark-banner-is-top-level-manual.js +54 -47
  111. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +42 -26
  112. package/src/checks/manual/landmark-main-is-top-level-manual.js +41 -24
  113. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +16 -23
  114. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +14 -21
  115. package/src/checks/manual/landmark-no-duplicate-main-manual.js +10 -13
  116. package/src/checks/manual/landmark-one-main-manual.js +12 -23
  117. package/src/checks/manual/landmark-unique-manual.js +37 -52
  118. package/src/checks/manual/link-name-quality-manual.js +2 -0
  119. package/src/checks/manual/media-transcript-present-manual.js +2 -0
  120. package/src/checks/manual/meta-viewport-large-manual.js +3 -1
  121. package/src/checks/manual/mouse-only-event-handlers-manual.js +2 -0
  122. package/src/checks/manual/no-autoplay-audio-manual.js +2 -0
  123. package/src/checks/manual/object-text-alternative-quality-manual.js +2 -0
  124. package/src/checks/manual/p-as-heading-manual.js +2 -0
  125. package/src/checks/manual/page-has-heading-one-manual.js +12 -11
  126. package/src/checks/manual/page-title-patterns-manual.js +2 -0
  127. package/src/checks/manual/presentation-role-conflict-manual.js +51 -37
  128. package/src/checks/manual/region-manual.js +27 -36
  129. package/src/checks/manual/scope-attr-valid-manual.js +3 -1
  130. package/src/checks/manual/scrollable-region-focusable-manual.js +2 -0
  131. package/src/checks/manual/skip-link-manual.js +7 -6
  132. package/src/checks/manual/svg-text-alternative-quality-manual.js +2 -0
  133. package/src/checks/manual/tabindex-manual.js +3 -1
  134. package/src/checks/manual/table-duplicate-name-manual.js +5 -4
  135. package/src/checks/manual/table-fake-caption-manual.js +24 -3
  136. package/src/checks/manual/video-caption-manual.js +2 -0
  137. package/src/checks/manual-review.js +2 -0
  138. package/src/core.js +6772 -2158
  139. package/src/index.js +2 -0
  140. package/src/report.js +51 -9
  141. package/src/sarif.js +18 -3
  142. package/surea11y.browser.js +2731 -999
  143. package/bin/core.js +0 -473
  144. package/docs/CLI.md +0 -128
  145. package/src/catalogs/composites.wcag.js +0 -454
  146. package/src/checks/rules-and-tags.full.csv +0 -19
  147. package/src/checks/rules-and-tags.full.json +0 -259
  148. package/src/core/aria-helpers.js +0 -1211
  149. package/src/core/contrast-helpers.js +0 -1302
  150. package/src/core/dom-helpers.js +0 -4493
  151. package/src/core/dom-runner.js +0 -787
  152. package/src/core/frame-messaging.js +0 -261
  153. package/src/core/frame-scan.js +0 -190
  154. package/src/core/rollup-composites.js +0 -127
  155. package/src/core/rule-meta.js +0 -176
  156. package/src/coverage/wcag-facets.js +0 -1079
  157. package/src/coverage/wcag-version-map.js +0 -84
  158. package/src/i18n/en.js +0 -1228
  159. package/src/i18n/fr.js +0 -1185
  160. package/src/policy/contracts.js +0 -18
  161. package/src/policy/resolvePolicy.js +0 -59
  162. package/src/policy/schemas/engine-options.schema.json +0 -103
  163. package/src/policy/schemas/policy-contract.schema.json +0 -40
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -62,10 +64,141 @@ const meta = {
62
64
  };
63
65
 
64
66
  function runInPage(ctx) {
65
- const { helpers, rule } = ctx;
67
+ const { helpers, rule, document } = ctx;
68
+
69
+ // Self-contained rendering check for the embedded document (a distinct
70
+ // realm — see this rule's own header comment on why the outer
71
+ // document's shared eligibility helpers can't be reused here).
72
+ // Deliberately checks only genuine non-rendering (display:none,
73
+ // visibility:hidden, the hidden attribute) via the ancestor chain, NOT
74
+ // aria-hidden: aria-hidden alone does not remove an element from a real
75
+ // browser's native tab order (the same anti-pattern this engine's own
76
+ // aria-hidden-focus rule exists to catch), so an aria-hidden-but-
77
+ // visually-rendered focusable element inside the frame is still
78
+ // genuinely reachable by keyboard and must stay flagged.
79
+ function isRenderedInDoc(doc, el) {
80
+ try {
81
+ const view = doc.defaultView;
82
+ if (!view || typeof view.getComputedStyle !== 'function') return true;
83
+ let node = el;
84
+ while (node && node.nodeType === 1) {
85
+ if (node.hasAttribute && node.hasAttribute('hidden')) return false;
86
+ const cs = view.getComputedStyle(node);
87
+ if (cs) {
88
+ if (cs.display === 'none') return false;
89
+ if (cs.visibility === 'hidden' || cs.visibility === 'collapse') return false;
90
+ }
91
+ node = node.parentElement;
92
+ }
93
+ return true;
94
+ } catch {
95
+ return true;
96
+ }
97
+ }
98
+
99
+ function getDeepActiveElement(docRef) {
100
+ let cur = docRef && docRef.activeElement ? docRef.activeElement : null;
101
+ let guard = 0;
102
+ while (cur && cur.shadowRoot && cur.shadowRoot.activeElement && guard++ < 20) {
103
+ cur = cur.shadowRoot.activeElement;
104
+ }
105
+ return cur;
106
+ }
107
+
108
+ function focusElementSafe(el) {
109
+ if (!el || typeof el.focus !== 'function') return false;
110
+ try {
111
+ el.focus({ preventScroll: true });
112
+ return true;
113
+ } catch {
114
+ try {
115
+ el.focus();
116
+ return true;
117
+ } catch {
118
+ return false;
119
+ }
120
+ }
121
+ }
122
+
123
+ function runFocusObservationWindow(win, fn) {
124
+ if (!win || typeof fn !== 'function') return;
125
+ const originalSetTimeout =
126
+ typeof win.setTimeout === 'function' ? win.setTimeout.bind(win) : null;
127
+ const originalRequestAnimationFrame =
128
+ typeof win.requestAnimationFrame === 'function' ? win.requestAnimationFrame.bind(win) : null;
129
+ const originalQueueMicrotask =
130
+ typeof win.queueMicrotask === 'function' ? win.queueMicrotask.bind(win) : null;
131
+
132
+ const queuedMicrotasks = [];
133
+ const queuedRaf = [];
134
+ const queuedTimers = [];
135
+ let fakeTimerId = 1;
136
+
137
+ const patchedSetTimeout = function (cb, delay) {
138
+ const d = Number.isFinite(Number(delay)) ? Number(delay) : 0;
139
+ if (typeof cb === 'function' && d <= 200) {
140
+ const args = [];
141
+ for (let i = 2; i < arguments.length; i++) args.push(arguments[i]);
142
+ queuedTimers.push({ delay: d, cb: () => cb.apply(win, args) });
143
+ return fakeTimerId++;
144
+ }
145
+ if (originalSetTimeout) return originalSetTimeout.apply(win, arguments);
146
+ return fakeTimerId++;
147
+ };
148
+
149
+ const patchedRaf = function (cb) {
150
+ if (typeof cb === 'function') {
151
+ queuedRaf.push(cb);
152
+ return fakeTimerId++;
153
+ }
154
+ if (originalRequestAnimationFrame) return originalRequestAnimationFrame.apply(win, arguments);
155
+ return fakeTimerId++;
156
+ };
157
+
158
+ const patchedQueueMicrotask = function (cb) {
159
+ if (typeof cb === 'function') queuedMicrotasks.push(cb);
160
+ };
161
+
162
+ try {
163
+ if (originalSetTimeout) win.setTimeout = patchedSetTimeout;
164
+ if (originalRequestAnimationFrame) win.requestAnimationFrame = patchedRaf;
165
+ if (originalQueueMicrotask) win.queueMicrotask = patchedQueueMicrotask;
166
+ fn();
66
167
 
67
- function hasFocusableCandidate(doc) {
68
- if (!doc || !doc.querySelectorAll) return false;
168
+ let guard = 0;
169
+ while (
170
+ (queuedMicrotasks.length || queuedRaf.length || queuedTimers.length) &&
171
+ guard++ < 100
172
+ ) {
173
+ while (queuedMicrotasks.length) {
174
+ const mt = queuedMicrotasks.shift();
175
+ try {
176
+ mt();
177
+ } catch {}
178
+ }
179
+ while (queuedRaf.length) {
180
+ const rf = queuedRaf.shift();
181
+ try {
182
+ rf(16);
183
+ } catch {}
184
+ }
185
+ if (queuedTimers.length) {
186
+ queuedTimers.sort((a, b) => a.delay - b.delay);
187
+ const tt = queuedTimers.shift();
188
+ try {
189
+ tt.cb();
190
+ } catch {}
191
+ }
192
+ }
193
+ } finally {
194
+ if (originalSetTimeout) win.setTimeout = originalSetTimeout;
195
+ if (originalRequestAnimationFrame) win.requestAnimationFrame = originalRequestAnimationFrame;
196
+ if (originalQueueMicrotask) win.queueMicrotask = originalQueueMicrotask;
197
+ }
198
+ }
199
+
200
+ function getFocusableCandidates(doc) {
201
+ if (!doc || !doc.querySelectorAll) return [];
69
202
  let els;
70
203
  try {
71
204
  els = doc.querySelectorAll(
@@ -73,8 +206,9 @@ function runInPage(ctx) {
73
206
  'select:not([disabled]), textarea:not([disabled]), iframe, [contenteditable="true"], [tabindex]'
74
207
  );
75
208
  } catch {
76
- return false;
209
+ return [];
77
210
  }
211
+ const candidates = [];
78
212
  for (const el of els) {
79
213
  if (!el || !el.getAttribute) continue;
80
214
  const raw = el.getAttribute('tabindex');
@@ -82,9 +216,77 @@ function runInPage(ctx) {
82
216
  const n = Number(String(raw).trim());
83
217
  if (!Number.isNaN(n) && n < 0) continue; // explicitly removed from tab order
84
218
  }
85
- return true;
219
+ if (!isRenderedInDoc(doc, el)) continue; // display:none/visibility:hidden/[hidden]: never reachable at all
220
+ candidates.push(el);
86
221
  }
87
- return false;
222
+ return candidates;
223
+ }
224
+
225
+ function probeImmediateFocusRedirect(frameEl, embeddedDoc, candidate) {
226
+ if (!frameEl || !embeddedDoc || !candidate) return null;
227
+ const embeddedWindow = embeddedDoc.defaultView;
228
+ if (!embeddedWindow) return null;
229
+
230
+ let focusedByEvent = false;
231
+ const onFocusCapture = () => {
232
+ focusedByEvent = true;
233
+ };
234
+ try {
235
+ candidate.addEventListener('focus', onFocusCapture, true);
236
+ } catch {}
237
+
238
+ const innerFocusTrace = [];
239
+ const outerFocusTrace = [];
240
+ const onInnerFocusIn = (ev) => {
241
+ if (ev && ev.target) innerFocusTrace.push(ev.target);
242
+ };
243
+ const onOuterFocusIn = (ev) => {
244
+ if (ev && ev.target) outerFocusTrace.push(ev.target);
245
+ };
246
+ try {
247
+ embeddedDoc.addEventListener('focusin', onInnerFocusIn, true);
248
+ document.addEventListener('focusin', onOuterFocusIn, true);
249
+ } catch {}
250
+
251
+ const beforeInner = getDeepActiveElement(embeddedDoc);
252
+ const beforeOuter = getDeepActiveElement(document);
253
+ let focused = false;
254
+ runFocusObservationWindow(embeddedWindow, () => {
255
+ focused = focusElementSafe(candidate);
256
+ });
257
+ try {
258
+ embeddedDoc.removeEventListener('focusin', onInnerFocusIn, true);
259
+ document.removeEventListener('focusin', onOuterFocusIn, true);
260
+ } catch {}
261
+ try {
262
+ candidate.removeEventListener('focus', onFocusCapture, true);
263
+ } catch {}
264
+
265
+ if (!focused) return null;
266
+
267
+ const afterInner = getDeepActiveElement(embeddedDoc);
268
+ const afterOuter = getDeepActiveElement(document);
269
+ const sawRedirectedInnerTrace = innerFocusTrace.some((n) => n && n !== candidate);
270
+ const sawRedirectedOuterTrace = outerFocusTrace.some(
271
+ (n) => n && n !== frameEl && n !== candidate
272
+ );
273
+ const redirectedWithinFrame =
274
+ !!(afterInner && afterInner !== candidate) || sawRedirectedInnerTrace;
275
+ const redirectedOutOfFrame =
276
+ !!(afterOuter && afterOuter !== frameEl) || sawRedirectedOuterTrace;
277
+ const sawCandidateFocus = focusedByEvent || innerFocusTrace.some((n) => n === candidate);
278
+
279
+ if (beforeInner && beforeInner !== afterInner) focusElementSafe(beforeInner);
280
+ if (beforeOuter && beforeOuter !== afterOuter) focusElementSafe(beforeOuter);
281
+
282
+ if (!sawCandidateFocus) return null;
283
+ if (!redirectedWithinFrame && !redirectedOutOfFrame) return null;
284
+
285
+ return {
286
+ redirected: true,
287
+ redirectedWithinFrame,
288
+ redirectedOutOfFrame
289
+ };
88
290
  }
89
291
 
90
292
  function getNegativeTabIndex(el) {
@@ -98,7 +300,8 @@ function runInPage(ctx) {
98
300
  ? helpers.queryAllSmart('iframe, frame')
99
301
  : helpers.queryAll('iframe, frame');
100
302
 
101
- const occurrences = [];
303
+ const failOccurrences = [];
304
+ const cantTellOccurrences = [];
102
305
  let applicableCount = 0;
103
306
 
104
307
  for (const el of nodes) {
@@ -115,13 +318,37 @@ function runInPage(ctx) {
115
318
 
116
319
  applicableCount += 1;
117
320
 
118
- if (!hasFocusableCandidate(contentDoc)) continue;
321
+ const candidates = getFocusableCandidates(contentDoc);
322
+ if (!candidates.length) continue;
119
323
 
120
324
  const tag = el.tagName.toLowerCase();
121
325
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
122
326
  const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
327
+ const shouldProbe = candidates.length === 1;
328
+ const runtimeProbe = shouldProbe
329
+ ? probeImmediateFocusRedirect(el, contentDoc, candidates[0])
330
+ : null;
331
+
332
+ if (runtimeProbe && runtimeProbe.redirected) {
333
+ cantTellOccurrences.push({
334
+ selector: stableSelector,
335
+ html,
336
+ summary:
337
+ 'This frame has tabindex="-1" and a focusable candidate, but focus moves immediately to another target. Verify keyboard reachability in a real browser.',
338
+ hint: 'If this is an intentional focus handoff, ensure keyboard users cannot remain on hidden/intermediate frame content.',
339
+ i18n: null,
340
+ data: {
341
+ details: {
342
+ reasonCode: 'IFRAME_TABINDEX_NEGATIVE_CONTENT_RUNTIME_REDIRECT',
343
+ element: tag,
344
+ runtimeProbe
345
+ }
346
+ }
347
+ });
348
+ continue;
349
+ }
123
350
 
124
- occurrences.push({
351
+ failOccurrences.push({
125
352
  selector: stableSelector,
126
353
  html,
127
354
  summary:
@@ -141,15 +368,16 @@ function runInPage(ctx) {
141
368
  if (applicableCount === 0) {
142
369
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
143
370
  }
144
- if (occurrences.length) {
145
- return {
146
- ruleId: rule.ruleId,
147
- outcome: 'fail',
148
- severity: rule.defaultSeverity || 'moderate',
149
- occurrences
150
- };
151
- }
152
- return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
371
+ // helpers.resolveTieredOutcome is unconditionally provided by dom-helpers.js
372
+ // (see its own header comment) -- no fallback needed, matching the same
373
+ // cleanup already applied to aria-hidden-focus.js/aria-prohibited-attr.js/
374
+ // target-size-minimum.js.
375
+ const resolved = helpers.resolveTieredOutcome(
376
+ failOccurrences,
377
+ cantTellOccurrences,
378
+ rule.defaultSeverity || 'moderate'
379
+ );
380
+ return { ruleId: rule.ruleId, ...resolved };
153
381
  }
154
382
 
155
383
  module.exports = { id, meta, runInPage };
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -18,7 +20,7 @@
18
20
  * a frame can have a non-empty title while still failing uniqueness.
19
21
  * - Compares the title ATTRIBUTE specifically, not the full computed
20
22
  * accessible name (aria-label could legitimately differ in wording even
21
- * when title happens to collide) — matches a widely-used reference engine's frame-title-unique.
23
+ * when title happens to collide).
22
24
  * - Not rule-gated on isAccTreeEligible: duplicate titles are a static
23
25
  * markup property. Engine-level hidden-subtree filtering still applies
24
26
  * unless engineOptions.includeHiddenElements is true.
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -172,15 +174,11 @@ function runInPage(ctx) {
172
174
  if (ariaName && ariaName.present) continue;
173
175
  }
174
176
 
175
- // A non-empty title attribute is HTML-AAM's own next fallback naming
176
- // source for <img> once alt is entirely absent (not merely alt="",
177
- // which explicitly marks decorative and stays excluded from this
178
- // branch since hasAlt already short-circuited above) -- confirmed
179
- // against a widely-used reference engine's own image-alt rule, which lists a non-empty
180
- // title as one of its "any" satisfying conditions alongside has-alt/
181
- // aria-label/aria-labelledby. Found via a real page (AliExpress's
182
- // logo, 2026-07-23): <img src="..." title="...">, no alt attribute
183
- // at all -- a real false positive, not a missing text alternative.
177
+ // A non-empty title attribute is HTML-AAM's next fallback naming source
178
+ // for <img> once alt is entirely absent (not merely alt="", which
179
+ // explicitly marks decorative and stays excluded from this branch since
180
+ // hasAlt already short-circuited above). An `<img src="..." title="...">`
181
+ // with no alt attribute at all is not missing a text alternative.
184
182
  const title = trim(el.getAttribute('title'));
185
183
  if (title) continue;
186
184
 
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -129,12 +131,8 @@ function runInPage(ctx) {
129
131
  }
130
132
 
131
133
  // A non-empty title attribute is HTML-AAM's own next fallback naming
132
- // source once alt is entirely absent -- also accepted by a widely-used
133
- // reference engine's equivalent input-image-alt rule (non-empty-title, same "any" list
134
- // as non-empty-alt/aria-label/aria-labelledby). See img-alt-present's
135
- // sibling fix (2026-07-23, AliExpress's title-only logo <img>) for
136
- // the real page this was found via -- same gap, same fix, different
137
- // element.
134
+ // source once alt is entirely absent. Same gap img-alt-present handles
135
+ // for <img title="..."> with no alt.
138
136
  const titleRaw = (() => {
139
137
  try {
140
138
  return el.getAttribute('title');
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  const id = 'label-in-name';
@@ -90,20 +92,13 @@ function runInPage(ctx) {
90
92
  }
91
93
 
92
94
  // Unlike isDomVisible above, this also excludes aria-hidden subtrees.
93
- // Needed specifically for collectVisibleTextUnder's per-text-node check
94
- // below: an aria-hidden icon-font glyph (e.g. Material Icons' ligature
95
- // pattern, <mat-icon aria-hidden="true">format_color_fill</mat-icon>) is
96
- // technically DOM-visible pixels, but is never perceived by a sighted or
97
- // voice-control user as literal readable words the way real visible text
98
- // is — a widely-used reference engine's own label-content-name-mismatch check reaches the same
99
- // practical outcome via a canvas-based ligature-detection heuristic
100
- // (measuring rendered glyph shapes), not replicable here since this rule
101
- // runs against static markup with no real canvas/font rendering
102
- // available; excluding aria-hidden content is a cheaper, static-markup
103
- // signal that gets the common case (decorative icon fonts) right without
104
- // needing one. Confirmed via a live-DOM cross-engine run 2026-07-21
105
- // (Angular Material's theme-picker button, aria-label="Select a theme",
106
- // aria-hidden icon rendering literally as "format_color_fill").
95
+ // Needed for collectVisibleTextUnder's per-text-node check below: an
96
+ // aria-hidden icon-font glyph (e.g. an <i aria-hidden="true"> ligature
97
+ // rendering as "format_color_fill") is DOM-visible pixels but is never
98
+ // perceived as literal readable words the way real visible text is.
99
+ // Excluding aria-hidden content is a cheap static-markup signal that gets
100
+ // the common case (decorative icon fonts) right — an icon-only button
101
+ // named via aria-label shouldn't have its glyph name counted as text.
107
102
  function isAccEligible(el) {
108
103
  if (!el) return false;
109
104
  const fn =
@@ -122,11 +117,12 @@ function runInPage(ctx) {
122
117
  // than referencing the global NodeFilter object directly: runInPage must
123
118
  // have zero free vars (see docs/RULE_AUTHORING.md's free-var footgun) and
124
119
  // NodeFilter is not itself present in the execution realm this function
125
- // actually runs in, unlike window/document. Confirmed 2026-07-21 — this
126
- // silently made createTreeWalker throw on every call, falling back to
127
- // raw container.textContent (which respects none of isNonRenderedTag/
128
- // isDomVisible/isAccEligible below, since that whole per-node loop is
129
- // skipped in the fallback path). Same pattern already used correctly in
120
+ // actually runs in, unlike window/document. Referencing the global
121
+ // directly would silently make createTreeWalker throw on every call,
122
+ // falling back to raw container.textContent (which respects none of
123
+ // isNonRenderedTag/isDomVisible/isAccEligible below, since that whole
124
+ // per-node loop is skipped in the fallback path). Same pattern already
125
+ // used correctly in
130
126
  // region-manual.js's own createTreeWalker call.
131
127
  const SHOW_TEXT = 4;
132
128
 
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  const id = 'link-name-present';
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -24,28 +26,17 @@
24
26
  * inverse relationship: does a given <li> have a valid parent).
25
27
  * - Direct children that are not exposed to the accessibility tree (e.g.
26
28
  * display:none, [hidden], aria-hidden="true") are excluded from
27
- * consideration entirely — matching a widely-used reference engine's own
28
- * `only-listitems` check, which likewise skips non-AT-visible children. Confirmed against
29
- * two real sites: Spotify's newsroom has a stray `<input type="hidden">`
30
- * as a direct <ul> child (UA-stylesheet display:none by spec for
31
- * input[type=hidden]), and Stanford's main nav interleaves `<li>` with
32
- * `<span style="display:none">` hydration markers. Neither is reachable
33
- * by assistive technology, so neither breaks the list semantics a screen
34
- * reader actually announces.
35
- * - The explicit-role-overrides-tag behavior above was added after reading
36
- * a widely-used reference engine's ACTUAL `only-listitems` evaluate function
37
- * directly (`invalidChildrenEvaluate`/`getInvalidSelector` — note this is a
38
- * DIFFERENT function from the similarly-named, unused `onlyListitemsEvaluate`
39
- * elsewhere in that engine's source; don't confuse the two when re-verifying). Its
40
- * exact rule: if a child has an explicit role, only `validRoles` (here,
41
- * `['listitem']`) is consulted — the tag name is never checked. Only
42
- * without an explicit role does the tag name (`validNodeNames`) matter.
43
- * Found via two real sites: Notion's footer nav lists include a
44
- * `<li role="none">` used purely to host the list's own visually-hidden
45
- * label (referenced by the `<ul>`'s `aria-labelledby`), and LinkedIn has
46
- * both an all-`<li role="menuitem">` menu list and a footer list mixing
47
- * real `<li>`s with one `<li role="presentation">` — that reference engine
48
- * correctly flags all of these, surea11y's tag-only check missed them entirely.
29
+ * consideration entirely — an element not reachable by assistive
30
+ * technology can't break the list semantics a screen reader announces.
31
+ * Common cases: a stray `<input type="hidden">` as a direct <ul> child
32
+ * (UA-stylesheet display:none by spec), or `<span style="display:none">`
33
+ * hydration markers interleaved with real `<li>`s.
34
+ * - Explicit-role-overrides-tag: if a child has an explicit role, only
35
+ * `['listitem']` is consulted — the tag name is never checked. Only
36
+ * without an explicit role does the tag name matter. Catches cases a
37
+ * tag-only check misses: `<li role="none">` hosting a list's own
38
+ * visually-hidden label, `<li role="menuitem">` menu items, or a real
39
+ * `<li>` mixed with an `<li role="presentation">`.
49
40
  */
50
41
 
51
42
  const id = 'list-children-valid';
@@ -120,8 +111,7 @@ function runInPage(ctx) {
120
111
  const roleAttr = child.getAttribute ? String(child.getAttribute('role') || '').trim() : '';
121
112
  const explicitRole = roleAttr ? (roleAttr.split(/\s+/)[0] || '').toLowerCase() : '';
122
113
 
123
- // An explicit role always wins over the tag — see the header
124
- // comment's Notion/LinkedIn examples.
114
+ // An explicit role always wins over the tag — see header comment.
125
115
  const valid = explicitRole ? explicitRole === 'listitem' : ALLOWED_CHILD_TAGS.has(tag);
126
116
 
127
117
  if (!valid) invalidTags.push(tag);
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  const id = 'listbox-name-present';
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -25,12 +27,19 @@
25
27
  * native role is fully replaced by the explicit one — the same "any
26
28
  * explicit role overrides the element's native role" ARIA principle
27
29
  * applied elsewhere in this engine), so an <li> inside it is invalid
28
- * despite the <ul> tag — found on a real site, Nike's desktop nav
29
- * dropdown (`<ul class="desktop-category" role="menu"><li>...`).
30
- * Conversely role="presentation"/"none" on the parent is still a valid
31
- * (list-semantics-suppressing) parent, matching a widely-used reference
32
- * engine's own `listitem` check (`['presentation', 'none', 'list'].includes(parentRole)`,
33
- * confirmed by reading its source directly).
30
+ * despite the <ul> tag (e.g. `<ul role="menu"><li>...`). Conversely
31
+ * role="presentation"/"none" on the parent is still a valid
32
+ * (list-semantics-suppressing) parent — the accepted parent roles are
33
+ * presentation, none, and list.
34
+ * - The SAME "explicit role wins" principle applies to the <li> ELEMENT
35
+ * ITSELF: an <li role="tab">/role="menuitem">/role="presentation"> etc.
36
+ * is exposed to AT with that role, never "listitem" — the whole point
37
+ * of this check (list items need a valid list-container parent) doesn't
38
+ * apply when the element isn't claiming listitem semantics in the first
39
+ * place. Any `<li>` with an explicit `role` attribute is excluded from
40
+ * candidacy. `role="listitem"` itself is a no-op restatement (not an
41
+ * override), so it still falls through to the normal parent-validity
42
+ * check below.
34
43
  */
35
44
 
36
45
  const id = 'listitem-parent-valid';
@@ -75,6 +84,20 @@ function runInPage(ctx) {
75
84
  const parent = el.parentElement;
76
85
  if (!parent) continue;
77
86
 
87
+ // An explicit role on the <li> ITSELF overrides its native "listitem"
88
+ // role entirely, the same "any explicit role wins over the tag's
89
+ // native role" principle this check already applies to the PARENT
90
+ // (see the header comment). An <li role="tab">/
91
+ // role="menuitem">/role="presentation"> etc. is exposed to AT with
92
+ // THAT role, never "listitem" -- so the "list item needs a valid list
93
+ // container parent" concern this rule exists for doesn't apply to it
94
+ // at all; there's no listitem semantics being claimed to validate.
95
+ // role="listitem" itself is a no-op restatement, not an override, so
96
+ // it still falls through to the normal parent check below.
97
+ const ownRoleAttr = el.getAttribute ? String(el.getAttribute('role') || '').trim() : '';
98
+ const ownExplicitRole = ownRoleAttr ? (ownRoleAttr.split(/\s+/)[0] || '').toLowerCase() : '';
99
+ if (ownExplicitRole && ownExplicitRole !== 'listitem') continue;
100
+
78
101
  applicableCount += 1;
79
102
 
80
103
  const parentTag = parent.tagName ? parent.tagName.toLowerCase() : '';
@@ -85,7 +108,7 @@ function runInPage(ctx) {
85
108
  let valid;
86
109
  if (explicitRole) {
87
110
  // An explicit role always wins over the tag's native role, in either
88
- // direction — see the header comment's Nike example.
111
+ // direction — see the header comment.
89
112
  valid = explicitRole === 'list' || explicitRole === 'presentation' || explicitRole === 'none';
90
113
  } else {
91
114
  valid = parentTag === 'ul' || parentTag === 'ol';
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  const id = 'menuitem-name-present';
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -24,10 +26,8 @@
24
26
  * - A <meta> nested inside <noscript> is excluded: it only ever takes
25
27
  * effect when scripting is disabled, which is never the case for any
26
28
  * context capable of running accessibility tooling in the first place
27
- * (confirmed 2026-07-21 via a real-world false positive: Slack's
28
- * homepage has a <noscript><meta http-equiv="refresh" content="0;
29
- * URL=/?nojsmode=1"></noscript> JS-disabled fallback — a widely-used
30
- * reference engine's own same-named check correctly doesn't flag it either).
29
+ * (e.g. a <noscript><meta http-equiv="refresh" content="0; URL=/?nojs=1">
30
+ * </noscript> JS-disabled fallback).
31
31
  */
32
32
 
33
33
  const id = 'meta-refresh-no-exceptions';
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  const id = 'meter-name-present';
@@ -96,9 +98,8 @@ function runInPage(ctx) {
96
98
  const title = getAttr(el, 'title');
97
99
  if (title) return { ok: true, method: 'title' };
98
100
 
99
- // role="meter" is name-from-author-only per WAI-ARIA (verified against
100
- // a widely-used reference engine's own aria-meter-name check: any: ['aria-label',
101
- // 'aria-labelledby', title] — no content-based naming method at all).
101
+ // role="meter" is name-from-author-only per WAI-ARIA: aria-label,
102
+ // aria-labelledby, or title — no content-based naming method at all.
102
103
  // It must NOT fall back to subtree content — visible text near/inside a
103
104
  // custom meter widget is not reliably exposed as its accessible name.
104
105
  return { ok: false, method: 'none' };