@surea11y/core 1.3.0 → 1.4.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 (166) hide show
  1. package/CHANGELOG.md +87 -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/ARIA_DEPRECATION.md +95 -0
  6. package/docs/CI_INTEGRATIONS.md +7 -7
  7. package/docs/ENGINE_OPTIONS.md +1 -1
  8. package/docs/I18N.md +12 -9
  9. package/docs/INTEGRATION.md +1 -1
  10. package/docs/LIMITATIONS.md +1 -1
  11. package/docs/REPORT.md +1 -1
  12. package/docs/RULE_CATALOG.md +6 -6
  13. package/package.json +52 -16
  14. package/src/baseline.js +0 -0
  15. package/src/checks/automatic/area-alt-present.js +4 -6
  16. package/src/checks/automatic/aria-allowed-attr.js +663 -134
  17. package/src/checks/automatic/aria-allowed-role.js +2 -0
  18. package/src/checks/automatic/aria-braille-equivalent.js +2 -0
  19. package/src/checks/automatic/aria-conditional-attr.js +8 -7
  20. package/src/checks/automatic/aria-deprecated-role.js +107 -38
  21. package/src/checks/automatic/aria-hidden-body.js +6 -4
  22. package/src/checks/automatic/aria-hidden-focus.js +12 -13
  23. package/src/checks/automatic/aria-prohibited-attr.js +98 -105
  24. package/src/checks/automatic/aria-prohibited-children.js +56 -87
  25. package/src/checks/automatic/aria-required-attr.js +6 -7
  26. package/src/checks/automatic/aria-required-children.js +7 -10
  27. package/src/checks/automatic/aria-required-parent.js +20 -25
  28. package/src/checks/automatic/aria-role-name-present.js +2 -0
  29. package/src/checks/automatic/aria-roles-valid.js +33 -6
  30. package/src/checks/automatic/aria-valid-attr-value.js +18 -15
  31. package/src/checks/automatic/aria-valid-attr.js +2 -0
  32. package/src/checks/automatic/autocomplete-valid.js +39 -1
  33. package/src/checks/automatic/avoid-inline-spacing.js +181 -20
  34. package/src/checks/automatic/binary-control-name-present.js +13 -3
  35. package/src/checks/automatic/button-name-present.js +54 -21
  36. package/src/checks/automatic/canvas-text-alternative-present.js +17 -8
  37. package/src/checks/automatic/combobox-name-present.js +10 -1
  38. package/src/checks/automatic/contrast-computable.js +2 -0
  39. package/src/checks/automatic/contrast-enhanced.js +2 -0
  40. package/src/checks/automatic/contrast-minimum.js +2 -0
  41. package/src/checks/automatic/css-orientation-lock.js +21 -27
  42. package/src/checks/automatic/definition-list-children-valid.js +6 -6
  43. package/src/checks/automatic/deprecated-elements-not-used.js +4 -2
  44. package/src/checks/automatic/dialog-name-present.js +18 -11
  45. package/src/checks/automatic/dlitem-parent-valid.js +2 -0
  46. package/src/checks/automatic/duplicate-id-aria.js +4 -3
  47. package/src/checks/automatic/embed-text-alternative-present.js +2 -0
  48. package/src/checks/automatic/form-control-programmatic-label-present.js +50 -4
  49. package/src/checks/automatic/form-control-single-label.js +110 -43
  50. package/src/checks/automatic/html-xml-lang-mismatch.js +2 -0
  51. package/src/checks/automatic/iframe-focusable-content.js +246 -18
  52. package/src/checks/automatic/iframe-name-present.js +2 -0
  53. package/src/checks/automatic/iframe-title-unique.js +3 -1
  54. package/src/checks/automatic/img-alt-present.js +23 -18
  55. package/src/checks/automatic/input-image-alt-present.js +101 -52
  56. package/src/checks/automatic/label-in-name.js +97 -27
  57. package/src/checks/automatic/language-page-present.js +7 -1
  58. package/src/checks/automatic/link-in-text-block.js +2 -0
  59. package/src/checks/automatic/link-name-present.js +52 -17
  60. package/src/checks/automatic/list-children-valid.js +14 -24
  61. package/src/checks/automatic/listbox-name-present.js +10 -1
  62. package/src/checks/automatic/listitem-parent-valid.js +30 -7
  63. package/src/checks/automatic/menuitem-name-present.js +10 -1
  64. package/src/checks/automatic/meta-refresh-no-exceptions.js +33 -4
  65. package/src/checks/automatic/meta-refresh-timing-absent.js +32 -4
  66. package/src/checks/automatic/meta-viewport-zoom-enabled.js +39 -15
  67. package/src/checks/automatic/meter-name-present.js +12 -4
  68. package/src/checks/automatic/nested-interactive-controls-absent.js +177 -25
  69. package/src/checks/automatic/object-text-alternative-present.js +16 -7
  70. package/src/checks/automatic/option-name-present.js +10 -1
  71. package/src/checks/automatic/page-title-present.js +2 -0
  72. package/src/checks/automatic/progressbar-name-present.js +16 -11
  73. package/src/checks/automatic/role-img-alt-present.js +4 -4
  74. package/src/checks/automatic/searchbox-name-present.js +10 -1
  75. package/src/checks/automatic/server-side-image-map-absent.js +4 -3
  76. package/src/checks/automatic/slider-name-present.js +13 -2
  77. package/src/checks/automatic/spinbutton-name-present.js +10 -1
  78. package/src/checks/automatic/summary-name-present.js +10 -1
  79. package/src/checks/automatic/svg-image-text-alternative-present.js +2 -0
  80. package/src/checks/automatic/svg-text-alternative-present.js +17 -5
  81. package/src/checks/automatic/tab-name-present.js +10 -1
  82. package/src/checks/automatic/table-headers-attr-valid.js +3 -2
  83. package/src/checks/automatic/table-th-has-data-cells.js +69 -6
  84. package/src/checks/automatic/target-size-minimum.js +5 -0
  85. package/src/checks/automatic/td-has-header.js +24 -1
  86. package/src/checks/automatic/textbox-name-present.js +10 -1
  87. package/src/checks/automatic/tooltip-name-present.js +10 -1
  88. package/src/checks/automatic/treeitem-name-present.js +10 -1
  89. package/src/checks/automatic/valid-lang.js +18 -3
  90. package/src/checks/automatic/video-poster-text-alternative-present.js +2 -0
  91. package/src/checks/manual/accesskeys-manual.js +3 -1
  92. package/src/checks/manual/area-alt-decorative-manual.js +2 -0
  93. package/src/checks/manual/area-alt-quality-manual.js +2 -0
  94. package/src/checks/manual/aria-checked-state-mismatch-manual.js +14 -23
  95. package/src/checks/manual/aria-text-manual.js +6 -5
  96. package/src/checks/manual/bypass-blocks-present-manual.js +279 -0
  97. package/src/checks/manual/canvas-text-alternative-quality-manual.js +2 -0
  98. package/src/checks/manual/css-hidden-focus.js +184 -9
  99. package/src/checks/manual/embed-text-alternative-quality-manual.js +18 -13
  100. package/src/checks/manual/empty-heading-manual.js +17 -17
  101. package/src/checks/manual/empty-table-header-manual.js +52 -25
  102. package/src/checks/manual/focus-order-semantics-manual.js +16 -4
  103. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -0
  104. package/src/checks/manual/heading-order-manual.js +28 -1
  105. package/src/checks/manual/identical-links-same-purpose-manual.js +2 -0
  106. package/src/checks/manual/image-redundant-alt-manual.js +21 -1
  107. package/src/checks/manual/img-alt-decorative-manual.js +2 -0
  108. package/src/checks/manual/img-alt-quality-manual.js +2 -0
  109. package/src/checks/manual/input-image-alt-decorative-manual.js +26 -0
  110. package/src/checks/manual/input-image-alt-quality-manual.js +2 -0
  111. package/src/checks/manual/label-title-only-manual.js +29 -22
  112. package/src/checks/manual/landmark-banner-is-top-level-manual.js +51 -55
  113. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +44 -31
  114. package/src/checks/manual/landmark-main-is-top-level-manual.js +41 -24
  115. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +16 -23
  116. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +14 -21
  117. package/src/checks/manual/landmark-no-duplicate-main-manual.js +10 -13
  118. package/src/checks/manual/landmark-one-main-manual.js +12 -23
  119. package/src/checks/manual/landmark-unique-manual.js +37 -52
  120. package/src/checks/manual/link-name-quality-manual.js +2 -0
  121. package/src/checks/manual/media-transcript-present-manual.js +2 -0
  122. package/src/checks/manual/meta-viewport-large-manual.js +3 -1
  123. package/src/checks/manual/mouse-only-event-handlers-manual.js +2 -0
  124. package/src/checks/manual/no-autoplay-audio-manual.js +2 -0
  125. package/src/checks/manual/object-text-alternative-quality-manual.js +2 -0
  126. package/src/checks/manual/p-as-heading-manual.js +2 -0
  127. package/src/checks/manual/page-has-heading-one-manual.js +12 -11
  128. package/src/checks/manual/page-title-patterns-manual.js +2 -0
  129. package/src/checks/manual/presentation-role-conflict-manual.js +51 -37
  130. package/src/checks/manual/region-manual.js +27 -36
  131. package/src/checks/manual/scope-attr-valid-manual.js +3 -1
  132. package/src/checks/manual/scrollable-region-focusable-manual.js +2 -0
  133. package/src/checks/manual/skip-link-manual.js +7 -6
  134. package/src/checks/manual/svg-text-alternative-quality-manual.js +2 -0
  135. package/src/checks/manual/tabindex-manual.js +3 -1
  136. package/src/checks/manual/table-duplicate-name-manual.js +5 -4
  137. package/src/checks/manual/table-fake-caption-manual.js +24 -3
  138. package/src/checks/manual/video-caption-manual.js +2 -0
  139. package/src/checks/manual-review.js +2 -0
  140. package/src/core.js +11820 -3317
  141. package/src/index.js +2 -0
  142. package/src/report.js +51 -9
  143. package/src/sarif.js +20 -5
  144. package/surea11y.browser.js +4943 -1388
  145. package/bin/core.js +0 -473
  146. package/docs/CLI.md +0 -128
  147. package/src/catalogs/composites.wcag.js +0 -454
  148. package/src/checks/automatic/bypass-blocks-present.js +0 -215
  149. package/src/checks/rules-and-tags.full.csv +0 -19
  150. package/src/checks/rules-and-tags.full.json +0 -259
  151. package/src/core/aria-helpers.js +0 -1211
  152. package/src/core/contrast-helpers.js +0 -1302
  153. package/src/core/dom-helpers.js +0 -4493
  154. package/src/core/dom-runner.js +0 -787
  155. package/src/core/frame-messaging.js +0 -261
  156. package/src/core/frame-scan.js +0 -190
  157. package/src/core/rollup-composites.js +0 -127
  158. package/src/core/rule-meta.js +0 -176
  159. package/src/coverage/wcag-facets.js +0 -1079
  160. package/src/coverage/wcag-version-map.js +0 -84
  161. package/src/i18n/en.js +0 -1228
  162. package/src/i18n/fr.js +0 -1185
  163. package/src/policy/contracts.js +0 -18
  164. package/src/policy/resolvePolicy.js +0 -59
  165. package/src/policy/schemas/engine-options.schema.json +0 -103
  166. 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
  const id = 'label-in-name';
@@ -50,6 +52,56 @@ function runInPage(ctx) {
50
52
  return v.replace(/\s+/g, ' ').trim().toLowerCase();
51
53
  }
52
54
 
55
+ // WCAG 2.5.3's label-in-name comparison is over words, not characters: drop
56
+ // parenthesised text, case-fold and NFKD-normalise, then reduce every
57
+ // non-letter/digit to a space. `hyphensJoin` deletes hyphens instead of
58
+ // splitting on them, which distinguishes a real mismatch from one that is
59
+ // only a hyphenation difference.
60
+ function tokenize(s, hyphensJoin) {
61
+ let v = (s == null ? '' : String(s)).replace(/\([^)]*\)/g, ' ').toLowerCase();
62
+ try {
63
+ v = v.normalize('NFKD');
64
+ } catch {
65
+ // Realm without String#normalize: the word comparison below still holds.
66
+ }
67
+ if (hyphensJoin) v = v.replace(/[-‐-―−]/g, '');
68
+ return v
69
+ .replace(/[^\p{L}\p{N}]+/gu, ' ')
70
+ .split(' ')
71
+ .filter(Boolean);
72
+ }
73
+
74
+ // The label's words must appear adjacent and in order inside the name, so a
75
+ // scattered subsequence does not count. Words in `prefixable` may match a
76
+ // longer name word they start.
77
+ function containsWordRun(needle, hay, prefixable) {
78
+ if (!needle.length) return true;
79
+ for (let i = 0; i + needle.length <= hay.length; i++) {
80
+ let ok = true;
81
+ for (let j = 0; j < needle.length; j++) {
82
+ const want = needle[j];
83
+ const got = hay[i + j];
84
+ if (got === want) continue;
85
+ if (prefixable && prefixable.has(want) && got.indexOf(want) === 0) continue;
86
+ ok = false;
87
+ break;
88
+ }
89
+ if (ok) return true;
90
+ }
91
+ return false;
92
+ }
93
+
94
+ // A trailing period marks a word the author may have shortened ("Ave." for
95
+ // "Avenue"). Tokenizing removes the period, so collect these beforehand.
96
+ function abbreviatedWords(s) {
97
+ const out = new Set();
98
+ for (const w of (s == null ? '' : String(s)).split(/\s+/)) {
99
+ const m = /^([\p{L}\p{N}]+)\.$/u.exec(w);
100
+ if (m) out.add(m[1].toLowerCase());
101
+ }
102
+ return out;
103
+ }
104
+
53
105
  function getElementDescriptor(el) {
54
106
  const tag = el && el.tagName ? String(el.tagName).toLowerCase() : 'element';
55
107
  let role;
@@ -90,20 +142,13 @@ function runInPage(ctx) {
90
142
  }
91
143
 
92
144
  // 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").
145
+ // Needed for collectVisibleTextUnder's per-text-node check below: an
146
+ // aria-hidden icon-font glyph (e.g. an <i aria-hidden="true"> ligature
147
+ // rendering as "format_color_fill") is DOM-visible pixels but is never
148
+ // perceived as literal readable words the way real visible text is.
149
+ // Excluding aria-hidden content is a cheap static-markup signal that gets
150
+ // the common case (decorative icon fonts) right — an icon-only button
151
+ // named via aria-label shouldn't have its glyph name counted as text.
107
152
  function isAccEligible(el) {
108
153
  if (!el) return false;
109
154
  const fn =
@@ -122,11 +167,12 @@ function runInPage(ctx) {
122
167
  // than referencing the global NodeFilter object directly: runInPage must
123
168
  // have zero free vars (see docs/RULE_AUTHORING.md's free-var footgun) and
124
169
  // 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
170
+ // actually runs in, unlike window/document. Referencing the global
171
+ // directly would silently make createTreeWalker throw on every call,
172
+ // falling back to raw container.textContent (which respects none of
173
+ // isNonRenderedTag/isDomVisible/isAccEligible below, since that whole
174
+ // per-node loop is skipped in the fallback path). Same pattern already
175
+ // used correctly in
130
176
  // region-manual.js's own createTreeWalker call.
131
177
  const SHOW_TEXT = 4;
132
178
 
@@ -301,7 +347,23 @@ function runInPage(ctx) {
301
347
  const accName = acc && acc.value != null ? String(acc.value) : '';
302
348
  const accNorm = norm(accName);
303
349
 
304
- const contains = !!(accNorm && visibleNorm && accNorm.indexOf(visibleNorm) !== -1);
350
+ const labelTokens = tokenize(visibleLabel, false);
351
+ const nameTokens = tokenize(accName, false);
352
+ const contains = containsWordRun(labelTokens, nameTokens, null);
353
+
354
+ // An abbreviation, or a word hyphenated differently in the two places, is
355
+ // not something markup settles: the author may have meant either. Report
356
+ // without asserting a defect instead of failing or staying silent.
357
+ let uncertainty = '';
358
+ if (!contains) {
359
+ if (containsWordRun(tokenize(visibleLabel, true), tokenize(accName, true), null)) {
360
+ uncertainty = 'HYPHENATION_DIFFERS';
361
+ } else {
362
+ const abbreviated = abbreviatedWords(visibleLabel);
363
+ if (abbreviated.size && containsWordRun(labelTokens, nameTokens, abbreviated))
364
+ uncertainty = 'POSSIBLE_ABBREVIATION';
365
+ }
366
+ }
305
367
 
306
368
  if (!contains) {
307
369
  const selectorOut = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
@@ -312,11 +374,16 @@ function runInPage(ctx) {
312
374
  occurrences.push({
313
375
  selector: selectorOut,
314
376
  html,
315
- summary: 'Accessible name does not contain the visible label text.',
316
- hint: 'Ensure the accessible name includes the visible text label (e.g., update aria-label/aria-labelledby to include the visible wording).',
377
+ ...(uncertainty ? { outcome: 'cantTell' } : null),
378
+ summary: uncertainty
379
+ ? 'Accessible name may not contain the visible label text.'
380
+ : 'Accessible name does not contain the visible label text.',
381
+ hint: uncertainty
382
+ ? 'Check by hand: the two differ only by an abbreviation or by hyphenation, which markup cannot settle.'
383
+ : 'Ensure the accessible name includes the visible text label (e.g., update aria-label/aria-labelledby to include the visible wording).',
317
384
  i18n: {
318
- summaryKey: 'labelInName_summary_fail',
319
- hintKey: 'labelInName_hint_fail',
385
+ summaryKey: uncertainty ? 'labelInName_summary_cantTell' : 'labelInName_summary_fail',
386
+ hintKey: uncertainty ? 'labelInName_hint_cantTell' : 'labelInName_hint_fail',
320
387
  params: {
321
388
  element: getElementDescriptor(el),
322
389
  visibleLabel: clipForSummary(visibleLabel),
@@ -326,10 +393,11 @@ function runInPage(ctx) {
326
393
  },
327
394
  data: {
328
395
  details: {
329
- reasonCode: 'VISIBLE_LABEL_NOT_IN_ACCESSIBLE_NAME',
396
+ reasonCode: uncertainty || 'VISIBLE_LABEL_NOT_IN_ACCESSIBLE_NAME',
330
397
  visibleLabel,
331
398
  accessibleName: accName,
332
399
  normalized: { visibleLabel: visibleNorm, accessibleName: accNorm },
400
+ tokenized: { visibleLabel: labelTokens, accessibleName: nameTokens },
333
401
  labelSource: labelInfo && labelInfo.source ? labelInfo.source : 'none',
334
402
  nameMechanism: acc && acc.mechanism ? acc.mechanism : 'none'
335
403
  }
@@ -340,13 +408,15 @@ function runInPage(ctx) {
340
408
 
341
409
  if (applicableCount === 0)
342
410
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
343
- if (occurrences.length)
411
+ if (occurrences.length) {
412
+ const anyFail = occurrences.some((o) => o.outcome !== 'cantTell');
344
413
  return {
345
414
  ruleId: rule.ruleId,
346
- outcome: 'fail',
415
+ outcome: anyFail ? 'fail' : 'cantTell',
347
416
  severity: rule.defaultSeverity || 'minor',
348
417
  occurrences
349
418
  };
419
+ }
350
420
  return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
351
421
  }
352
422
 
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -142,7 +144,11 @@ function runInPage(ctx) {
142
144
  }
143
145
 
144
146
  // Minimal BCP47 primary subtag check
145
- if (!/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/.test(lang)) {
147
+ const isValidTag =
148
+ helpers && typeof helpers.isValidLanguageTag === 'function'
149
+ ? helpers.isValidLanguageTag
150
+ : (v) => /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/.test(String(v || ''));
151
+ if (!isValidTag(lang)) {
146
152
  return {
147
153
  ruleId: rule.ruleId,
148
154
  outcome: 'fail',
@@ -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';
@@ -57,7 +59,14 @@ function runInPage(ctx) {
57
59
 
58
60
  for (const el of nodes) {
59
61
  // isAccTreeEligible returns { eligible, reasons }, not a boolean.
60
- const eligResult = helpers.isAccTreeEligible ? helpers.isAccTreeEligible(el, ctx) : true;
62
+ // Naming rules apply only to elements included in the accessibility tree
63
+ // (ACT c487ae), which excludes focusable aria-hidden content;
64
+ // aria-hidden-focus (ACT 6cfa84) covers that markup instead.
65
+ const eligResult = helpers.isIncludedInAccessibilityTree
66
+ ? helpers.isIncludedInAccessibilityTree(el, ctx)
67
+ : helpers.isAccTreeEligible
68
+ ? helpers.isAccTreeEligible(el, ctx)
69
+ : true;
61
70
  const eligible =
62
71
  typeof eligResult === 'boolean' ? eligResult : !!(eligResult && eligResult.eligible);
63
72
  if (!eligible) continue;
@@ -67,33 +76,59 @@ function runInPage(ctx) {
67
76
  const nameInfo = helpers.getAccessibleNameInfo ? helpers.getAccessibleNameInfo(el, ctx) : null;
68
77
  const programmaticName = nameInfo && typeof nameInfo.value === 'string' ? nameInfo.value : '';
69
78
 
70
- // A native <a href>/<area href> (or [role="link"]) whose role has been
71
- // overridden to one of these roles is no longer semantically a link —
72
- // per the WAI-ARIA Accessible Name and Description Computation spec
73
- // these roles are name-from-author-only, and their rendered content
74
- // represents a VALUE, not a NAME (see button-name-present.js's identical
75
- // fix for a real-world example — <button role="combobox">List</button> —
76
- // of the same class of bug on a different host tag).
77
79
  const role = el.getAttribute ? el.getAttribute('role') : null;
78
80
  const roleNorm = String(role || '')
79
81
  .replace(/\s+/g, ' ')
80
82
  .trim()
81
83
  .toLowerCase();
82
- const VALUE_ROLES = [
83
- 'textbox',
84
- 'progressbar',
85
- 'scrollbar',
86
- 'slider',
87
- 'spinbutton',
88
- 'combobox',
89
- 'listbox'
84
+ // ARIA 1.2 "Name From: author, contents". Every other known role is
85
+ // name-from-author-only. An unknown role falls back to the implicit role.
86
+ // <generated:aria-name-from-content>
87
+ const NAME_FROM_CONTENT_ROLES = [
88
+ 'button',
89
+ 'cell',
90
+ 'checkbox',
91
+ 'columnheader',
92
+ 'doc-backlink',
93
+ 'doc-biblioref',
94
+ 'doc-glossref',
95
+ 'doc-noteref',
96
+ 'graphics-object',
97
+ 'gridcell',
98
+ 'heading',
99
+ 'link',
100
+ 'menuitem',
101
+ 'menuitemcheckbox',
102
+ 'menuitemradio',
103
+ 'option',
104
+ 'radio',
105
+ 'row',
106
+ 'rowgroup',
107
+ 'rowheader',
108
+ 'switch',
109
+ 'tab',
110
+ 'tooltip',
111
+ 'treeitem'
90
112
  ];
91
- const isContentNameCandidate = !VALUE_ROLES.includes(roleNorm);
113
+ // </generated:aria-name-from-content>
114
+ const isKnownRoleToken =
115
+ helpers && helpers.aria && typeof helpers.aria.isKnownRole === 'function'
116
+ ? (() => {
117
+ try {
118
+ return !!helpers.aria.isKnownRole(roleNorm);
119
+ } catch {
120
+ return false;
121
+ }
122
+ })()
123
+ : false;
124
+ const isContentNameCandidate =
125
+ !roleNorm || !isKnownRoleToken || NAME_FROM_CONTENT_ROLES.includes(roleNorm);
92
126
 
93
127
  const contentName =
94
128
  programmaticName.trim().length === 0 && isContentNameCandidate
95
129
  ? getConservativeSubtreeText(el)
96
130
  : '';
131
+
97
132
  const finalName = (programmaticName.trim().length ? programmaticName : contentName).trim();
98
133
 
99
134
  if (finalName.length === 0) {
@@ -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';
@@ -107,9 +109,16 @@ function runInPage(ctx) {
107
109
  return '';
108
110
  }
109
111
 
112
+ // Naming rules apply only to elements included in the accessibility tree
113
+ // (ACT c487ae and siblings), which excludes focusable aria-hidden content.
114
+ // aria-hidden-focus (ACT 6cfa84) covers that markup instead.
110
115
  function isEligibleAcc(helpers, el, ctx) {
111
116
  const fn =
112
- helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
117
+ helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
118
+ ? helpers.isIncludedInAccessibilityTree
119
+ : helpers && typeof helpers.isAccTreeEligible === 'function'
120
+ ? helpers.isAccTreeEligible
121
+ : null;
113
122
  if (!fn) return true;
114
123
  try {
115
124
  const r = fn(el, ctx);
@@ -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';
@@ -82,9 +84,16 @@ function runInPage(ctx) {
82
84
  return '';
83
85
  }
84
86
 
87
+ // Naming rules apply only to elements included in the accessibility tree
88
+ // (ACT c487ae and siblings), which excludes focusable aria-hidden content.
89
+ // aria-hidden-focus (ACT 6cfa84) covers that markup instead.
85
90
  function isEligibleAcc(helpers, el, ctx) {
86
91
  const fn =
87
- helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
92
+ helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
93
+ ? helpers.isIncludedInAccessibilityTree
94
+ : helpers && typeof helpers.isAccTreeEligible === 'function'
95
+ ? helpers.isAccTreeEligible
96
+ : null;
88
97
  if (!fn) return true;
89
98
  try {
90
99
  const r = fn(el, ctx);
@@ -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';
@@ -87,6 +87,34 @@ function runInPage(ctx) {
87
87
  : [];
88
88
 
89
89
  const occurrences = [];
90
+ // HTML's shared declarative refresh steps. Returns the delay in seconds, or
91
+ // null when the value is not a valid refresh directive, in which case the
92
+ // browser never refreshes and there is nothing to report. Rejects a leading
93
+ // sign ("+72001"), a non-numeric time ("foo"), and a separator other than
94
+ // "," or ";" ("0:1").
95
+ function parseRefreshDelay(input) {
96
+ const s = String(input == null ? '' : input);
97
+ const isSpace = (c) => c === ' ' || c === '\t' || c === '\n' || c === '\f' || c === '\r';
98
+ let i = 0;
99
+ while (i < s.length && isSpace(s[i])) i++;
100
+ let digits = '';
101
+ while (i < s.length && s[i] >= '0' && s[i] <= '9') digits += s[i++];
102
+ if (!digits) return null;
103
+ if (s[i] === '.') {
104
+ i++;
105
+ while (i < s.length && s[i] >= '0' && s[i] <= '9') i++;
106
+ }
107
+ if (i >= s.length) return parseInt(digits, 10);
108
+ let sawSpace = false;
109
+ while (i < s.length && isSpace(s[i])) {
110
+ sawSpace = true;
111
+ i++;
112
+ }
113
+ if (i >= s.length) return parseInt(digits, 10);
114
+ if (s[i] === ';' || s[i] === ',' || sawSpace) return parseInt(digits, 10);
115
+ return null;
116
+ }
117
+
90
118
  let applicableCount = 0;
91
119
 
92
120
  for (const el of nodes) {
@@ -94,6 +122,7 @@ function runInPage(ctx) {
94
122
  if (el.closest && el.closest('noscript')) continue; // never applies with scripting enabled
95
123
  const raw = String(el.getAttribute('content') || '').trim();
96
124
  if (!raw) continue;
125
+ if (parseRefreshDelay(raw) === null) continue;
97
126
 
98
127
  applicableCount += 1;
99
128
 
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -77,16 +79,42 @@ function runInPage(ctx) {
77
79
  const occurrences = [];
78
80
  let applicableCount = 0;
79
81
 
82
+ // HTML's shared declarative refresh steps. Returns the delay in seconds, or
83
+ // null when the value is not a valid refresh directive, in which case the
84
+ // browser never refreshes and there is nothing to report. Rejects a leading
85
+ // sign ("+72001"), a non-numeric time ("foo"), and a separator other than
86
+ // "," or ";" ("0:1").
87
+ function parseRefreshDelay(input) {
88
+ const s = String(input == null ? '' : input);
89
+ const isSpace = (c) => c === ' ' || c === '\t' || c === '\n' || c === '\f' || c === '\r';
90
+ let i = 0;
91
+ while (i < s.length && isSpace(s[i])) i++;
92
+ let digits = '';
93
+ while (i < s.length && s[i] >= '0' && s[i] <= '9') digits += s[i++];
94
+ if (!digits) return null;
95
+ if (s[i] === '.') {
96
+ i++;
97
+ while (i < s.length && s[i] >= '0' && s[i] <= '9') i++;
98
+ }
99
+ if (i >= s.length) return parseInt(digits, 10);
100
+ let sawSpace = false;
101
+ while (i < s.length && isSpace(s[i])) {
102
+ sawSpace = true;
103
+ i++;
104
+ }
105
+ if (i >= s.length) return parseInt(digits, 10);
106
+ if (s[i] === ';' || s[i] === ',' || sawSpace) return parseInt(digits, 10);
107
+ return null;
108
+ }
109
+
80
110
  for (const el of nodes) {
81
111
  if (!el || !el.getAttribute) continue;
82
112
  if (el.closest && el.closest('noscript')) continue; // never applies with scripting enabled — see meta-refresh-no-exceptions.js's header comment
83
113
  const raw = String(el.getAttribute('content') || '').trim();
84
114
  if (!raw) continue;
85
115
 
86
- const match = raw.match(/^([0-9]*\.?[0-9]+)/);
87
- if (!match) continue;
88
- const delay = parseFloat(match[1]);
89
- if (Number.isNaN(delay)) continue;
116
+ const delay = parseRefreshDelay(raw);
117
+ if (delay === null) continue;
90
118
 
91
119
  applicableCount += 1;
92
120