@surea11y/core 1.5.0 → 1.7.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 (157) hide show
  1. package/CHANGELOG.md +240 -149
  2. package/README.md +51 -44
  3. package/docs/ACT_RULE_MAPPING.md +245 -0
  4. package/docs/API_STABILITY.md +53 -5
  5. package/docs/BINDING_AUTHORS_GUIDE.md +106 -4
  6. package/docs/DESIGN_CHALLENGES.md +367 -0
  7. package/docs/EARL.md +100 -0
  8. package/docs/ENGINE_OPTIONS.md +42 -4
  9. package/docs/I18N.md +4 -4
  10. package/docs/INTEGRATION.md +4 -2
  11. package/docs/LIMITATIONS.md +9 -5
  12. package/docs/OUTPUT_SCHEMA.md +44 -6
  13. package/docs/POLICY.md +1 -1
  14. package/docs/REPORT.md +1 -1
  15. package/docs/RULE_AUTHORING.md +63 -36
  16. package/docs/RULE_CATALOG.md +1928 -169
  17. package/docs/RULE_HELPERS.md +333 -0
  18. package/docs/RULE_TAXONOMY.md +27 -6
  19. package/docs/SARIF.md +21 -2
  20. package/docs/TROUBLESHOOTING.md +2 -2
  21. package/docs/WCAG_CONFORMANCE.md +34 -10
  22. package/package.json +11 -9
  23. package/src/baseline.js +3 -3
  24. package/src/checks/automatic/area-alt-present.js +2 -2
  25. package/src/checks/automatic/aria-allowed-attr.js +74 -10
  26. package/src/checks/automatic/aria-allowed-role.js +34 -25
  27. package/src/checks/automatic/aria-braille-equivalent.js +21 -13
  28. package/src/checks/automatic/aria-conditional-attr.js +22 -15
  29. package/src/checks/automatic/aria-deprecated-role.js +13 -1
  30. package/src/checks/automatic/aria-hidden-body.js +3 -3
  31. package/src/checks/automatic/aria-hidden-focus.js +5 -5
  32. package/src/checks/automatic/aria-prohibited-attr.js +23 -18
  33. package/src/checks/automatic/aria-prohibited-children.js +136 -43
  34. package/src/checks/automatic/aria-required-attr.js +119 -24
  35. package/src/checks/automatic/aria-required-children.js +54 -30
  36. package/src/checks/automatic/aria-required-parent.js +93 -15
  37. package/src/checks/automatic/aria-role-name-present.js +37 -23
  38. package/src/checks/automatic/aria-roles-valid.js +52 -21
  39. package/src/checks/automatic/aria-valid-attr-value.js +89 -33
  40. package/src/checks/automatic/aria-valid-attr.js +15 -10
  41. package/src/checks/automatic/autocomplete-valid.js +2 -2
  42. package/src/checks/automatic/avoid-inline-spacing.js +133 -6
  43. package/src/checks/automatic/binary-control-name-present.js +27 -5
  44. package/src/checks/automatic/button-name-present.js +92 -6
  45. package/src/checks/automatic/combobox-name-present.js +26 -6
  46. package/src/checks/automatic/contrast-computable.js +42 -0
  47. package/src/checks/automatic/contrast-enhanced.js +33 -1
  48. package/src/checks/automatic/contrast-minimum.js +33 -1
  49. package/src/checks/automatic/css-orientation-lock.js +138 -24
  50. package/src/checks/automatic/definition-list-children-valid.js +7 -8
  51. package/src/checks/automatic/deprecated-elements-not-used.js +1 -1
  52. package/src/checks/automatic/dialog-name-present.js +20 -2
  53. package/src/checks/automatic/duplicate-id-aria.js +10 -3
  54. package/src/checks/automatic/duplicate-id.js +203 -0
  55. package/src/checks/automatic/embed-text-alternative-present.js +2 -2
  56. package/src/checks/automatic/form-control-programmatic-label-present.js +19 -2
  57. package/src/checks/automatic/form-control-single-label.js +10 -1
  58. package/src/checks/automatic/identical-iframes-same-purpose.js +229 -0
  59. package/src/checks/automatic/iframe-focusable-content.js +68 -7
  60. package/src/checks/automatic/iframe-name-present.js +37 -3
  61. package/src/checks/automatic/iframe-title-unique.js +1 -1
  62. package/src/checks/automatic/img-alt-present.js +12 -4
  63. package/src/checks/automatic/label-in-name.js +204 -68
  64. package/src/checks/automatic/link-in-text-block.js +285 -29
  65. package/src/checks/automatic/link-name-present.js +22 -1
  66. package/src/checks/automatic/list-children-valid.js +6 -6
  67. package/src/checks/automatic/listbox-name-present.js +28 -8
  68. package/src/checks/automatic/listitem-parent-valid.js +4 -4
  69. package/src/checks/automatic/menuitem-name-present.js +20 -2
  70. package/src/checks/automatic/meta-refresh-no-exceptions.js +25 -14
  71. package/src/checks/automatic/meta-refresh-timing-absent.js +14 -7
  72. package/src/checks/automatic/meter-name-present.js +23 -4
  73. package/src/checks/automatic/nested-interactive-controls-absent.js +5 -5
  74. package/src/checks/automatic/option-name-present.js +23 -4
  75. package/src/checks/automatic/page-title-present.js +21 -3
  76. package/src/checks/automatic/presentational-children-focusable-absent.js +330 -0
  77. package/src/checks/automatic/progressbar-name-present.js +23 -4
  78. package/src/checks/automatic/{role-img-alt-present.js → role-img-text-alternative-present.js} +64 -16
  79. package/src/checks/automatic/searchbox-name-present.js +28 -8
  80. package/src/checks/automatic/server-side-image-map-absent.js +1 -1
  81. package/src/checks/automatic/slider-name-present.js +27 -6
  82. package/src/checks/automatic/spinbutton-name-present.js +28 -8
  83. package/src/checks/automatic/summary-name-present.js +18 -2
  84. package/src/checks/automatic/svg-image-text-alternative-present.js +1 -1
  85. package/src/checks/automatic/svg-text-alternative-present.js +13 -10
  86. package/src/checks/automatic/tab-name-present.js +21 -2
  87. package/src/checks/automatic/table-headers-attr-valid.js +43 -8
  88. package/src/checks/automatic/table-th-has-data-cells.js +61 -5
  89. package/src/checks/automatic/target-size-minimum.js +155 -58
  90. package/src/checks/automatic/td-has-header.js +24 -23
  91. package/src/checks/automatic/textbox-name-present.js +28 -8
  92. package/src/checks/automatic/tooltip-name-present.js +21 -2
  93. package/src/checks/automatic/treeitem-name-present.js +23 -4
  94. package/src/checks/automatic/valid-lang.js +92 -7
  95. package/src/checks/automatic/video-poster-text-alternative-present.js +1 -1
  96. package/src/checks/manual/accesskeys-manual.js +3 -3
  97. package/src/checks/manual/area-alt-decorative-manual.js +7 -0
  98. package/src/checks/manual/area-alt-quality-manual.js +6 -0
  99. package/src/checks/manual/aria-checked-state-mismatch-manual.js +5 -5
  100. package/src/checks/manual/aria-text-manual.js +4 -4
  101. package/src/checks/manual/bypass-blocks-present-manual.js +44 -26
  102. package/src/checks/manual/canvas-text-alternative-quality-manual.js +8 -0
  103. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +444 -0
  104. package/src/checks/manual/embed-text-alternative-quality-manual.js +8 -0
  105. package/src/checks/manual/empty-heading-manual.js +58 -11
  106. package/src/checks/manual/empty-table-header-manual.js +8 -8
  107. package/src/checks/manual/focus-order-semantics-manual.js +15 -15
  108. package/src/checks/manual/form-control-label-quality-manual.js +563 -0
  109. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -2
  110. package/src/checks/manual/heading-order-manual.js +3 -3
  111. package/src/checks/manual/heading-quality-manual.js +338 -0
  112. package/src/checks/manual/identical-links-same-purpose-manual.js +73 -12
  113. package/src/checks/manual/image-redundant-alt-manual.js +4 -4
  114. package/src/checks/manual/img-alt-decorative-manual.js +211 -52
  115. package/src/checks/manual/img-alt-quality-manual.js +7 -0
  116. package/src/checks/manual/input-image-alt-decorative-manual.js +8 -0
  117. package/src/checks/manual/input-image-alt-quality-manual.js +5 -0
  118. package/src/checks/manual/label-title-only-manual.js +4 -4
  119. package/src/checks/manual/landmark-banner-is-top-level-manual.js +7 -7
  120. package/src/checks/manual/landmark-complementary-is-top-level-manual.js +231 -0
  121. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +9 -9
  122. package/src/checks/manual/landmark-main-is-top-level-manual.js +6 -6
  123. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +6 -6
  124. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +6 -6
  125. package/src/checks/manual/landmark-no-duplicate-main-manual.js +2 -2
  126. package/src/checks/manual/landmark-one-main-manual.js +6 -6
  127. package/src/checks/manual/landmark-unique-manual.js +9 -9
  128. package/src/checks/manual/link-name-quality-manual.js +161 -32
  129. package/src/checks/manual/media-transcript-present-manual.js +2 -3
  130. package/src/checks/manual/meta-viewport-large-manual.js +2 -2
  131. package/src/checks/manual/mouse-only-event-handlers-manual.js +9 -9
  132. package/src/checks/manual/no-autoplay-audio-manual.js +3 -3
  133. package/src/checks/manual/object-text-alternative-quality-manual.js +8 -0
  134. package/src/checks/manual/p-as-heading-manual.js +4 -4
  135. package/src/checks/manual/page-has-heading-one-manual.js +6 -6
  136. package/src/checks/manual/page-title-patterns-manual.js +26 -3
  137. package/src/checks/manual/password-paste-enabled-manual.js +255 -0
  138. package/src/checks/manual/presentation-role-conflict-manual.js +55 -25
  139. package/src/checks/manual/region-manual.js +19 -19
  140. package/src/checks/manual/scope-attr-valid-manual.js +2 -2
  141. package/src/checks/manual/scrollable-region-focusable-manual.js +7 -7
  142. package/src/checks/manual/skip-link-manual.js +5 -5
  143. package/src/checks/manual/svg-text-alternative-quality-manual.js +9 -0
  144. package/src/checks/manual/tabindex-manual.js +2 -2
  145. package/src/checks/manual/table-duplicate-name-manual.js +2 -2
  146. package/src/checks/manual/table-fake-caption-manual.js +2 -2
  147. package/src/checks/manual/video-caption-manual.js +3 -3
  148. package/src/checks/manual-review.js +17 -1
  149. package/src/core.js +8880 -41883
  150. package/src/earl.js +144 -0
  151. package/src/report.js +2 -2
  152. package/src/sarif.js +22 -2
  153. package/surea11y.browser.js +10 -37882
  154. package/surea11y.i18n.de.js +2 -21
  155. package/surea11y.i18n.es.js +2 -21
  156. package/surea11y.i18n.fr.js +2 -21
  157. package/bin/surea11y-core.js +0 -20
@@ -2,11 +2,31 @@
2
2
 
3
3
  'use strict';
4
4
 
5
+ /**
6
+ * @check textbox-name-present
7
+ * @atomic true
8
+ * @summary Elements with role="textbox" must have an accessible name
9
+ * @standard WCAG 2.2
10
+ * @sc 4.1.2
11
+ * @applicability
12
+ * Applies to elements carrying role="textbox" (the attribute must name
13
+ * that role alone, not a fallback list) that are included in the
14
+ * accessibility tree. An element with the matching implicit role but no
15
+ * role attribute is out of scope.
16
+ * @expectation
17
+ * The element has a non-empty accessible name from aria-label, from an
18
+ * aria-labelledby that resolves to non-empty text, or from title.
19
+ * role="textbox" is name-from-author-only, so subtree text is never
20
+ * accepted: text sitting inside a custom textbox widget is not reliably
21
+ * exposed as its name. On a labelable element (<input role="textbox">) an
22
+ * associated <label> counts as well.
23
+ */
24
+
5
25
  const id = 'textbox-name-present';
6
26
 
7
27
  const meta = {
8
- title: 'Accessible name is present',
9
- description: 'Checks that elements expose a non-empty accessible name.',
28
+ title: 'Textboxes have an accessible name',
29
+ description: 'Checks that elements with role="textbox" expose a non-empty accessible name.',
10
30
  i18n: {
11
31
  titleKey: 'textboxNamePresent_title',
12
32
  descriptionKey: 'textboxNamePresent_description'
@@ -51,7 +71,7 @@ function runInPage(ctx) {
51
71
  }
52
72
 
53
73
  function getConservativeSubtreeText(document, container) {
54
- // "Name from content" — recurses into descendants and uses each one's
74
+ // "Name from content", recurses into descendants and uses each one's
55
75
  // own accessible name (img alt, aria-label/aria-labelledby, title) when
56
76
  // it has one, not just literal text nodes. See getContentNameInfo's
57
77
  // header comment in src/core/dom-helpers.js for the full rationale
@@ -68,7 +88,7 @@ function runInPage(ctx) {
68
88
 
69
89
  // A <label> contributes a name via its own aria-label/aria-labelledby
70
90
  // (checked first, same ARIA-over-content precedence any element's
71
- // accessible name gives — e.g. <label aria-label="Search"><svg
91
+ // accessible name gives, e.g. <label aria-label="Search"><svg
72
92
  // aria-hidden="true">...</svg></label> names its control "Search" even
73
93
  // though the label's only child content is aria-hidden) or, failing
74
94
  // that, its rendered content (getConservativeSubtreeText).
@@ -83,10 +103,10 @@ function runInPage(ctx) {
83
103
  if (content) return content;
84
104
  // Final fallback per the general accname text-alternative algorithm,
85
105
  // which applies to any element being asked for its name regardless of
86
- // why (own aria-label, an aria-labelledby reference, or — here — native
106
+ // why (own aria-label, an aria-labelledby reference, or, here, native
87
107
  // <label for> association): title, when nothing else yields a name.
88
108
  // Purely additive (only fills in a name where there was none before),
89
- // so it carries no false-positive risk — see dialog-name-present.js's
109
+ // so it carries no false-positive risk, see dialog-name-present.js's
90
110
  // identical <iframe>-title-fallback fix for the concrete real-world
91
111
  // trigger this same accname step covers elsewhere.
92
112
  return getAttr(lab, 'title');
@@ -96,7 +116,7 @@ function runInPage(ctx) {
96
116
  const raw = getAttr(el, 'aria-labelledby');
97
117
  if (!raw) return '';
98
118
  // Delegates to the shared getTextFromIdRefs helper instead of computing
99
- // name-from-content of the referenced element — see dialog-name-
119
+ // name-from-content of the referenced element, see dialog-name-
100
120
  // present.js's identical fix for the full rationale (an <iframe>
101
121
  // aria-labelledby target's only name source is its title attribute,
102
122
  // which name-from-content alone can never see).
@@ -231,7 +251,7 @@ function runInPage(ctx) {
231
251
  occurrences.push(
232
252
  helpers.reportOccurrence(el, {
233
253
  summary: 'This element has no accessible name.',
234
- hint: "Provide aria-label, aria-labelledby, or a title attribute — visible text content is not exposed as this textbox's accessible name.",
254
+ hint: "Provide aria-label, aria-labelledby, or a title attribute. Visible text content is not exposed as this textbox's accessible name.",
235
255
  i18n: {
236
256
  summaryKey: 'textboxNamePresent_summary_fail',
237
257
  hintKey: 'textboxNamePresent_hint_fail',
@@ -2,6 +2,25 @@
2
2
 
3
3
  'use strict';
4
4
 
5
+ /**
6
+ * @check tooltip-name-present
7
+ * @atomic true
8
+ * @summary Elements with role="tooltip" must have an accessible name
9
+ * @standard WCAG 2.2
10
+ * @sc 4.1.2
11
+ * @applicability
12
+ * Applies to elements carrying role="tooltip" (the attribute must name
13
+ * that role alone, not a fallback list) that are included in the
14
+ * accessibility tree. An element with the matching implicit role but no
15
+ * role attribute is out of scope.
16
+ * @expectation
17
+ * The element has a non-empty accessible name from aria-label, from an
18
+ * aria-labelledby that resolves to non-empty text, from title, or,
19
+ * role="tooltip" being name-from-content, from its own subtree text,
20
+ * where a descendant's own name (an <img alt>, aria-label or title) counts
21
+ * as that descendant's contribution rather than only its text nodes.
22
+ */
23
+
5
24
  const id = 'tooltip-name-present';
6
25
 
7
26
  const meta = {
@@ -51,7 +70,7 @@ function runInPage(ctx) {
51
70
  }
52
71
 
53
72
  function getConservativeSubtreeText(document, container) {
54
- // "Name from content" — recurses into descendants and uses each one's
73
+ // "Name from content", recurses into descendants and uses each one's
55
74
  // own accessible name (img alt, aria-label/aria-labelledby, title) when
56
75
  // it has one, not just literal text nodes. See getContentNameInfo's
57
76
  // header comment in src/core/dom-helpers.js for the full rationale
@@ -70,7 +89,7 @@ function runInPage(ctx) {
70
89
  const raw = getAttr(el, 'aria-labelledby');
71
90
  if (!raw) return '';
72
91
  // Delegates to the shared getTextFromIdRefs helper instead of computing
73
- // name-from-content of the referenced element — see dialog-name-
92
+ // name-from-content of the referenced element, see dialog-name-
74
93
  // present.js's identical fix for the full rationale (an <iframe>
75
94
  // aria-labelledby target's only name source is its title attribute,
76
95
  // which name-from-content alone can never see).
@@ -2,11 +2,30 @@
2
2
 
3
3
  'use strict';
4
4
 
5
+ /**
6
+ * @check treeitem-name-present
7
+ * @atomic true
8
+ * @summary Elements with role="treeitem" must have an accessible name
9
+ * @standard WCAG 2.2
10
+ * @sc 4.1.2
11
+ * @applicability
12
+ * Applies to elements carrying role="treeitem" (the attribute must name
13
+ * that role alone, not a fallback list) that are included in the
14
+ * accessibility tree. An element with the matching implicit role but no
15
+ * role attribute is out of scope.
16
+ * @expectation
17
+ * The element has a non-empty accessible name from aria-label, from an
18
+ * aria-labelledby that resolves to non-empty text, from title, or,
19
+ * role="treeitem" being name-from-content, from its own subtree text,
20
+ * where a descendant's own name (an <img alt>, aria-label or title) counts
21
+ * as that descendant's contribution rather than only its text nodes.
22
+ */
23
+
5
24
  const id = 'treeitem-name-present';
6
25
 
7
26
  const meta = {
8
- title: 'Accessible name is present',
9
- description: 'Checks that elements expose a non-empty accessible name.',
27
+ title: 'Tree items have an accessible name',
28
+ description: 'Checks that elements with role="treeitem" expose a non-empty accessible name.',
10
29
  i18n: {
11
30
  titleKey: 'treeitemNamePresent_title',
12
31
  descriptionKey: 'treeitemNamePresent_description'
@@ -51,7 +70,7 @@ function runInPage(ctx) {
51
70
  }
52
71
 
53
72
  function getConservativeSubtreeText(document, container) {
54
- // "Name from content" — recurses into descendants and uses each one's
73
+ // "Name from content", recurses into descendants and uses each one's
55
74
  // own accessible name (img alt, aria-label/aria-labelledby, title) when
56
75
  // it has one, not just literal text nodes. See getContentNameInfo's
57
76
  // header comment in src/core/dom-helpers.js for the full rationale
@@ -70,7 +89,7 @@ function runInPage(ctx) {
70
89
  const raw = getAttr(el, 'aria-labelledby');
71
90
  if (!raw) return '';
72
91
  // Delegates to the shared getTextFromIdRefs helper instead of computing
73
- // name-from-content of the referenced element — see dialog-name-
92
+ // name-from-content of the referenced element, see dialog-name-
74
93
  // present.js's identical fix for the full rationale (an <iframe>
75
94
  // aria-labelledby target's only name source is its title attribute,
76
95
  // which name-from-content alone can never see).
@@ -10,11 +10,23 @@
10
10
  * @sc 3.1.2
11
11
  * @applicability
12
12
  * Applies to any element other than the root <html> with a non-empty
13
- * lang attribute.
13
+ * lang attribute AND at least some non-whitespace "governed text" that
14
+ * actually inherits its language from that element, per ACT de46e4:
15
+ * - Descendant text/alt is governed by the nearest lang-carrying
16
+ * ancestor only, a nested descendant with its own non-empty lang
17
+ * re-scopes everything inside it, so that subtree no longer counts
18
+ * toward the outer element's applicability (it counts toward the
19
+ * nested element's own, if that one is also being evaluated).
20
+ * - A non-empty alt attribute on img/area/input[type=image] counts as
21
+ * governed text, the same as a text node.
22
+ * - Text (or alt) that CSS keeps out of the render tree (display:none,
23
+ * the hidden attribute, ...) does not count. aria-hidden and
24
+ * offscreen positioning do NOT exempt text, per ACT's own failed
25
+ * examples for both, only actual non-rendering does.
14
26
  * @expectation
15
27
  * The lang value matches a valid BCP47 language-tag syntax. WCAG 3.1.2
16
28
  * (Language of Parts) requires that when a passage's language differs
17
- * from the page's default, it is identified programmatically — an
29
+ * from the page's default, it is identified programmatically. An
18
30
  * invalid tag fails to identify a real language at all.
19
31
  * @implementation-notes
20
32
  * - Distinct, atomic decision from html-lang-attr-present (that
@@ -22,7 +34,7 @@
22
34
  * covers every other element, for SC 3.1.2.
23
35
  * - Same minimal BCP47 *syntax* check as html-lang-attr-present (primary
24
36
  * subtag + optional subtags), not IANA Language Subtag Registry
25
- * validation — same documented scope limitation (syntactically
37
+ * validation, same documented scope limitation (syntactically
26
38
  * well-formed but unregistered tags like "xx-ZZ" are not flagged).
27
39
  */
28
40
 
@@ -66,6 +78,78 @@ function runInPage(ctx) {
66
78
  ? helpers.isValidLanguageTag
67
79
  : (v) => BCP47_RE.test(String(v || ''));
68
80
 
81
+ function isDomVisible(node) {
82
+ if (!node) return false;
83
+ if (helpers.isDomVisibleEligible)
84
+ return !!helpers.isDomVisibleEligible(node, ctx, { targetSet: 'dom' }).eligible;
85
+ if (helpers.getEligibilityInfo)
86
+ return !!helpers.getEligibilityInfo(node, ctx, { targetSet: 'dom' }).eligible;
87
+ return true;
88
+ }
89
+
90
+ function hasOwnNonEmptyLang(node) {
91
+ try {
92
+ const v = node.getAttribute ? node.getAttribute('lang') : null;
93
+ return v != null && v.trim() !== '';
94
+ } catch {
95
+ return false;
96
+ }
97
+ }
98
+
99
+ function isAltBearing(node) {
100
+ const tag = (node.tagName || '').toLowerCase();
101
+ if (tag === 'img' || tag === 'area') return true;
102
+ if (tag !== 'input') return false;
103
+ try {
104
+ return (node.getAttribute('type') || '').toLowerCase() === 'image';
105
+ } catch {
106
+ return false;
107
+ }
108
+ }
109
+
110
+ // "Governed text": non-whitespace text (or alt) that inherits its
111
+ // language from `root`, per ACT de46e4, see @applicability above. Walks
112
+ // the flat subtree, stopping at any descendant carrying its own non-empty
113
+ // lang (that subtree governs itself, not `root`), and treating
114
+ // display:none/hidden content as absent. Bails out as soon as any
115
+ // qualifying text is found; a node-visit budget guards pathological
116
+ // markup the same way other subtree walks in this engine do.
117
+ const MAX_VISITS = 5000;
118
+ function hasGovernedText(root) {
119
+ let found = false;
120
+ let visits = 0;
121
+
122
+ function walk(node, isRoot) {
123
+ if (found || visits++ > MAX_VISITS) return;
124
+ if (!node || node.nodeType !== 1) return;
125
+ if (!isRoot && hasOwnNonEmptyLang(node)) return; // re-scoped to itself
126
+
127
+ if (isAltBearing(node)) {
128
+ const alt = node.getAttribute ? node.getAttribute('alt') : null;
129
+ if (alt != null && alt.trim() !== '' && isDomVisible(node)) found = true;
130
+ return; // alt-bearing elements have no other text to walk into
131
+ }
132
+
133
+ if (!isDomVisible(node)) return;
134
+
135
+ const kids = node.childNodes ? Array.from(node.childNodes) : [];
136
+ for (const kid of kids) {
137
+ if (found) return;
138
+ if (kid.nodeType === 3) {
139
+ if (String(kid.nodeValue || '').trim()) {
140
+ found = true;
141
+ return;
142
+ }
143
+ } else if (kid.nodeType === 1) {
144
+ walk(kid, false);
145
+ }
146
+ }
147
+ }
148
+
149
+ walk(root, true);
150
+ return found;
151
+ }
152
+
69
153
  const nodes = helpers.queryAllSmart
70
154
  ? helpers.queryAllSmart('[lang]')
71
155
  : helpers.queryAll('[lang]');
@@ -80,10 +164,11 @@ function runInPage(ctx) {
80
164
  const rawAttr = el.getAttribute('lang');
81
165
  if (rawAttr === null || rawAttr === '') continue; // ACT de46e4: empty is out of scope
82
166
 
83
- // The rule applies only where text actually inherits the language, so an
84
- // element with no text, or whose text reaches no one, has nothing to
85
- // declare a language for.
86
- if (!String(el.textContent || '').trim()) continue;
167
+ // The rule applies only where text actually inherits the language from
168
+ // THIS element specifically, not merely where the subtree has any text
169
+ // at all, which could all belong to a nested element's own (possibly
170
+ // valid) lang instead. See hasGovernedText's doc comment.
171
+ if (!hasGovernedText(el)) continue;
87
172
 
88
173
  applicableCount += 1;
89
174
 
@@ -87,7 +87,7 @@ function runInPage(ctx) {
87
87
 
88
88
  function computeNameInfo(el) {
89
89
  // <video> is not a labelable element (no browser computes an accessible
90
- // name from <label for="...">), so only ARIA naming and title count —
90
+ // name from <label for="...">), so only ARIA naming and title count,
91
91
  // do not accept native <label> associations.
92
92
  const flags = [];
93
93
  let aria = null;
@@ -6,17 +6,17 @@
6
6
  * @check accesskeys
7
7
  * @atomic true
8
8
  * @summary accesskey values must be unique on the page
9
- * @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
9
+ * @standard Best Practices (no formal WCAG Success Criterion)
10
10
  * @applicability
11
11
  * Applies whenever two or more elements share the same non-empty
12
12
  * accesskey attribute value (case-insensitive).
13
13
  * @expectation
14
14
  * Every accesskey value on the page is unique. Duplicate accesskeys
15
- * make keyboard-shortcut activation ambiguous — only one of the
15
+ * make keyboard-shortcut activation ambiguous: only one of the
16
16
  * elements sharing the key can actually be reached by it, and which
17
17
  * one is browser/platform-dependent.
18
18
  * @implementation-notes
19
- * - Not WCAG-normative — authored as an advisory, cantTell-capped
19
+ * - Not WCAG-normative, authored as an advisory, cantTell-capped
20
20
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
21
21
  * header comment for the shared rationale/precedent.
22
22
  */
@@ -9,6 +9,13 @@
9
9
  * @standard WCAG 2.2
10
10
  * @sc 1.1.1
11
11
  * @type manual
12
+ * @applicability
13
+ * Applies to <area> elements whose alt attribute is present but empty once
14
+ * trimmed, the markup that declares a hotspot decorative. The <area> must
15
+ * belong to a <map> that an <img usemap> actually references, and both
16
+ * that <img> and the <area> itself must be included in the accessibility
17
+ * tree; an <area> in an unused map is out of scope.
18
+ * role="presentation"/"none" takes an element out unless it is focusable.
12
19
  * @expectation
13
20
  * Human review is required to confirm that the provided text alternative is accurate and appropriate.
14
21
  */
@@ -9,6 +9,12 @@
9
9
  * @standard WCAG 2.2
10
10
  * @sc 1.1.1
11
11
  * @type manual
12
+ * @applicability
13
+ * Applies to <area> elements whose alt attribute is present and non-empty.
14
+ * The <area> must belong to a <map> that an <img usemap> actually
15
+ * references, and both that <img> and the <area> itself must be included
16
+ * in the accessibility tree; an <area> in an unused map is out of scope.
17
+ * role="presentation"/"none" takes an element out unless it is focusable.
12
18
  * @expectation
13
19
  * Human review is required to confirm that the provided text alternative is accurate and appropriate.
14
20
  */
@@ -14,13 +14,13 @@
14
14
  * @expectation
15
15
  * `aria-checked` is redundant on a native checkbox/radio (the role's
16
16
  * checked state is already exposed natively), but when an author sets
17
- * it explicitly it should agree with the element's actual state —
17
+ * it explicitly it should agree with the element's actual state,
18
18
  * otherwise assistive technology is told something different from what
19
19
  * a sighted user perceives.
20
20
  * @implementation-notes
21
- * - Deliberately authored as `type: 'manual'` (cantTell-capped, never
22
- * fail), unlike most ARIA-validity rules in this file family. This
23
- * engine analyzes STATIC markup only (no script execution) — `.checked`
21
+ * - Authored as `type: 'manual'` (cantTell-capped, never fail), unlike
22
+ * most ARIA-validity rules in this file family. This engine analyzes
23
+ * STATIC markup only (no script execution), so `.checked`
24
24
  * reliably reflects the static `checked` attribute for freshly-parsed
25
25
  * markup, but a very common, entirely legitimate real-world pattern is a
26
26
  * JS-hydrated widget whose server-rendered HTML intentionally ships
@@ -118,7 +118,7 @@ function runInPage(ctx) {
118
118
  helpers.reportOccurrence(el, {
119
119
  summary:
120
120
  'This element’s aria-checked value does not match its actual checked/indeterminate state.',
121
- hint: 'Set aria-checked to match the element’s real state, or remove it — a native checkbox/radio already exposes this state without it.',
121
+ hint: 'Set aria-checked to match the element’s real state, or remove it; a native checkbox/radio already exposes this state without it.',
122
122
  i18n: {
123
123
  summaryKey: 'ariaCheckedStateMismatch_summary_cantTell',
124
124
  hintKey: 'ariaCheckedStateMismatch_hint_cantTell',
@@ -6,7 +6,7 @@
6
6
  * @check aria-text
7
7
  * @atomic true
8
8
  * @summary role="text" elements should have no focusable descendants
9
- * @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
9
+ * @standard Best Practices (no formal WCAG Success Criterion)
10
10
  * @applicability
11
11
  * Elements with an explicit `role="text"`.
12
12
  * @expectation
@@ -14,10 +14,10 @@
14
14
  * subtree as a single unit of plain text (e.g. text visually split
15
15
  * across multiple `<span>`s by styling). Per the WAI-ARIA Authoring
16
16
  * Practices, this only makes sense when that subtree contains no
17
- * focusable content — a focusable descendant inside a "this is just
17
+ * focusable content: a focusable descendant inside a "this is just
18
18
  * text" region is unreachable or confusing for keyboard/AT users.
19
19
  * @implementation-notes
20
- * - Not WCAG-normative — authored as an advisory, cantTell-capped
20
+ * - Not WCAG-normative, authored as an advisory, cantTell-capped
21
21
  * `type: 'manual'` rule, matching the Tier 1b precedent (see
22
22
  * `landmark-unique`'s header comment for the shared rationale).
23
23
  * - "Focusable descendant" is a presence check (link/button/form
@@ -80,7 +80,7 @@ function runInPage(ctx) {
80
80
  selector: stableSelector,
81
81
  html,
82
82
  summary: 'This role="text" element contains a focusable descendant.',
83
- hint: 'Remove role="text" (or remove the focusable descendant) — a "plain text" region should not contain focusable content.',
83
+ hint: 'Remove role="text" (or remove the focusable descendant); a "plain text" region should not contain focusable content.',
84
84
  i18n: {
85
85
  summaryKey: 'ariaText_summary_cantTell',
86
86
  hintKey: 'ariaText_hint_cantTell',
@@ -9,31 +9,36 @@
9
9
  * @standard WCAG 2.2
10
10
  * @sc 2.4.1
11
11
  * @applicability
12
- * Always applicable to any HTML document with a <body> element —
12
+ * Always applicable to any HTML document with a <body> element:
13
13
  * "bypass blocks" is a whole-page concern, matching
14
14
  * aria-hidden-body / page-title-present's pattern of
15
15
  * evaluating the document directly rather than a scoped root.
16
16
  * @expectation
17
17
  * At least one of the following recognized WCAG 2.4.1 techniques is
18
18
  * present:
19
- * (a) a main landmark (<main> or [role="main"]) — technique ARIA11: a
19
+ * (a) a main landmark (<main> or [role="main"]), technique ARIA11: a
20
20
  * screen reader user can jump straight to it, bypassing everything
21
21
  * before it (nav, header, repeated blocks) in one step;
22
- * (b) a working same-page anchor link — technique G1/G123: an
22
+ * (b) a working same-page anchor link, technique G1/G123: an
23
23
  * <a href="#id"> (or legacy <a name="id">) whose target resolves to
24
24
  * a real element in the link's own tree (light DOM or the same shadow
25
- * root). Deliberately NOT required to be positioned before a <nav> or
26
- * be keyboard-focus-order-first — see implementation notes;
27
- * (c) at least one heading (<h1>-<h6> or [role="heading"]) — technique
28
- * H69: heading navigation is itself a standards-recognized bypass
29
- * mechanism (e.g. a screen reader's "jump by heading" command).
25
+ * root). Not required to be positioned before a <nav> or be
26
+ * keyboard-focus-order-first (see implementation notes);
27
+ * (c) at least one heading (<h1>-<h6> or [role="heading"]) that is both
28
+ * included in the accessibility tree AND visible (not off-screen,
29
+ * clipped, opacity:0, or zero-size-overflow-hidden), technique H69:
30
+ * heading navigation is itself a standards-recognized bypass
31
+ * mechanism (e.g. a screen reader's "jump by heading" command), but
32
+ * ACT 047fe0's own Expectation requires visibility too, since a
33
+ * screen-reader-only heading leaves sighted keyboard users with no
34
+ * equivalent way to locate the start of non-repeated content.
30
35
  * @implementation-notes
31
36
  * - Outcome model: this rule is `type: 'manual'` (cantTell-capped, never
32
37
  * `fail`). When a recognized mechanism is found the page has nothing to
33
38
  * review here → `notApplicable` (matching page-has-heading-one-manual /
34
39
  * skip-link-manual's "nothing to flag" convention). When none is found we
35
- * return `cantTell` — "we could not detect a bypass mechanism, please
36
- * verify" — rather than a hard `fail`. The absence of a *detectable*
40
+ * return `cantTell`: "we could not detect a bypass mechanism, please
41
+ * verify," rather than a hard `fail`. The absence of a *detectable*
37
42
  * mechanism is NOT high-confidence evidence that 2.4.1 is violated, for
38
43
  * several reasons the engine cannot resolve from a single static snapshot:
39
44
  * • Applicability itself is undecidable in-page. 2.4.1 governs blocks of
@@ -45,7 +50,7 @@
45
50
  * rest of the page is routinely made `inert` or `aria-hidden="true"`,
46
51
  * so the page's real <main>/headings are (correctly) filtered out by
47
52
  * isAccTreeEligible for the duration of that state and only the dialog
48
- * is exposed — a snapshot taken then would see "no mechanism" though
53
+ * is exposed, so a snapshot taken then would see "no mechanism" though
49
54
  * the page has one once the dialog closes. The same applies to content
50
55
  * that is display:none until revealed by script (tabs, accordions, an
51
56
  * unmounted SPA view).
@@ -54,23 +59,19 @@
54
59
  * human review instead. No ACT rule hard-fails 2.4.1 by presence alone,
55
60
  * for the same reason.
56
61
  * - This rule intentionally checks presence, not position, for the
57
- * same-page-anchor condition (b): a full bypass algorithm is heuristic
58
- * (see ROADMAP.md's Tier 1a note on why this rule was
59
- * deferred from the rest of that batch), and getting DOM-order /
60
- * keyboard-focus-order positioning exactly right without introducing
61
- * false positives is materially harder than the rest of Tier 1a. Being
62
- * lenient about condition (b) can only make us *miss* a review prompt
63
- * (a page whose only anchor link isn't a real skip mechanism, e.g. a
64
- * "back to top" link) — never raise a spurious one.
62
+ * same-page-anchor condition (b): a full bypass algorithm is heuristic,
63
+ * and getting DOM-order / keyboard-focus-order positioning exactly right
64
+ * without introducing false positives is materially harder than checking
65
+ * presence alone. Being lenient about condition (b) can only make us
66
+ * *miss* a review prompt (a page whose only anchor link isn't a real
67
+ * skip mechanism, e.g. a "back to top" link), never raising a spurious one.
65
68
  * - Shadow DOM: all three conditions use `helpers.queryAllSmart`, which is
66
69
  * shadow-DOM-aware (when the run enables includeShadowDom) and applies the
67
70
  * engine's hidden-content policy. The same-page-anchor target is resolved
68
- * in the link's own root (`getRootNode()` — the document, or the shadow
71
+ * in the link's own root (`getRootNode()`: the document, or the shadow
69
72
  * root the link lives in) before falling back to the document, so a skip
70
73
  * link encapsulated in a web component is credited the same as one in the
71
- * light DOM. (Previously the anchor path used raw
72
- * `document.querySelectorAll`/`getElementById`, which never pierced shadow
73
- * roots — a genuine gap now closed.)
74
+ * light DOM.
74
75
  */
75
76
 
76
77
  const id = 'bypass-blocks-present';
@@ -227,9 +228,26 @@ function runInPage(ctx) {
227
228
  return false;
228
229
  }
229
230
 
231
+ // ACT 047fe0's own Expectation requires the heading to be visible, not
232
+ // only included in the accessibility tree: a screen-reader-only heading
233
+ // still leaves sighted keyboard users with no way to locate the start of
234
+ // non-repeated content. "Visible" per ACT's own glossary: making it fully
235
+ // transparent would change rendered pixels, which every CSS-only hiding
236
+ // technique (off-screen positioning, clip/clip-path, opacity:0, a
237
+ // zero-size overflow:hidden box) fails.
238
+ function isCssHidden(el) {
239
+ if (!helpers || typeof helpers.getVisibilityHintsInfo !== 'function') return false;
240
+ try {
241
+ const info = helpers.getVisibilityHintsInfo(el, ctx, {});
242
+ return !!(info && Array.isArray(info.hints) && info.hints.length > 0);
243
+ } catch {
244
+ return false;
245
+ }
246
+ }
247
+
230
248
  function hasHeading() {
231
249
  for (const el of queryAll('h1, h2, h3, h4, h5, h6, [role="heading"]')) {
232
- if (el && isExposedToAt(el)) return true;
250
+ if (el && isExposedToAt(el) && !isCssHidden(el)) return true;
233
251
  }
234
252
  return false;
235
253
  }
@@ -246,8 +264,8 @@ function runInPage(ctx) {
246
264
  const occurrences = [
247
265
  helpers.reportOccurrence(body, {
248
266
  summary:
249
- 'No recognized way to bypass repeated blocks of content was detected on this page — verify a bypass mechanism exists.',
250
- hint: 'Confirm the page offers a bypass mechanism: a main landmark (<main> or role="main"), a working "skip to content" link, or heading elements that assistive technology can use to jump past repeated content. (A mechanism may be temporarily hidden — e.g. while a modal dialog makes the page inert — or provided on a per-site basis; this needs human confirmation.)',
267
+ 'No recognized way to bypass repeated blocks of content was detected on this page. Verify a bypass mechanism exists.',
268
+ hint: 'Confirm the page offers a bypass mechanism: a main landmark (<main> or role="main"), a working "skip to content" link, or heading elements that assistive technology can use to jump past repeated content. (A mechanism may be temporarily hidden, e.g. while a modal dialog makes the page inert, or provided on a per-site basis; this needs human confirmation.)',
251
269
  i18n: {
252
270
  summaryKey: 'bypassBlocksPresent_summary_cantTell',
253
271
  hintKey: 'bypassBlocksPresent_hint_cantTell',
@@ -9,6 +9,14 @@
9
9
  * @standard WCAG 2.2
10
10
  * @sc 1.1.1
11
11
  * @type manual
12
+ * @applicability
13
+ * Applies to <canvas> elements that already carry a text alternative:
14
+ * fallback content inside the element, an ARIA name, or a title. A
15
+ * <canvas> with none of those has no alternative whose quality could be
16
+ * judged; that's canvas-text-alternative-present's failure. The element
17
+ * must be included in the accessibility tree, and
18
+ * role="presentation"/"none" takes it out of scope unless it is focusable,
19
+ * which restores its role.
12
20
  * @expectation
13
21
  * Human review is required to confirm that the provided text alternative is accurate and appropriate.
14
22
  */