@surea11y/core 1.2.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 (168) hide show
  1. package/CHANGELOG.md +81 -7
  2. package/LICENSE +373 -21
  3. package/README.md +175 -35
  4. package/bin/surea11y-core.js +20 -0
  5. package/docs/API_STABILITY.md +27 -1
  6. package/docs/BINDING_AUTHORS_GUIDE.md +9 -9
  7. package/docs/CI_INTEGRATIONS.md +103 -0
  8. package/docs/ENGINE_OPTIONS.md +2 -0
  9. package/docs/I18N.md +12 -9
  10. package/docs/INTEGRATION.md +19 -1
  11. package/docs/LIMITATIONS.md +1 -1
  12. package/docs/OUTPUT_SCHEMA.md +1 -1
  13. package/docs/REPORT.md +1 -1
  14. package/docs/RULE_CATALOG.md +1 -1
  15. package/docs/SARIF.md +59 -0
  16. package/package.json +63 -18
  17. package/src/baseline.js +0 -0
  18. package/src/checks/automatic/area-alt-present.js +63 -31
  19. package/src/checks/automatic/aria-allowed-attr.js +204 -80
  20. package/src/checks/automatic/aria-allowed-role.js +23 -7
  21. package/src/checks/automatic/aria-braille-equivalent.js +34 -10
  22. package/src/checks/automatic/aria-conditional-attr.js +32 -14
  23. package/src/checks/automatic/aria-deprecated-role.js +26 -11
  24. package/src/checks/automatic/aria-hidden-body.js +48 -23
  25. package/src/checks/automatic/aria-hidden-focus.js +420 -66
  26. package/src/checks/automatic/aria-prohibited-attr.js +327 -60
  27. package/src/checks/automatic/aria-prohibited-children.js +111 -103
  28. package/src/checks/automatic/aria-required-attr.js +29 -15
  29. package/src/checks/automatic/aria-required-children.js +44 -24
  30. package/src/checks/automatic/aria-required-parent.js +64 -35
  31. package/src/checks/automatic/aria-role-name-present.js +49 -21
  32. package/src/checks/automatic/aria-roles-valid.js +24 -12
  33. package/src/checks/automatic/aria-valid-attr-value.js +46 -22
  34. package/src/checks/automatic/aria-valid-attr.js +19 -5
  35. package/src/checks/automatic/autocomplete-valid.js +76 -16
  36. package/src/checks/automatic/avoid-inline-spacing.js +23 -8
  37. package/src/checks/automatic/binary-control-name-present.js +62 -50
  38. package/src/checks/automatic/button-name-present.js +54 -24
  39. package/src/checks/automatic/bypass-blocks-present.js +51 -32
  40. package/src/checks/automatic/canvas-text-alternative-present.js +59 -26
  41. package/src/checks/automatic/combobox-name-present.js +40 -45
  42. package/src/checks/automatic/contrast-computable.js +363 -341
  43. package/src/checks/automatic/contrast-enhanced.js +489 -466
  44. package/src/checks/automatic/contrast-minimum.js +488 -465
  45. package/src/checks/automatic/css-orientation-lock.js +51 -35
  46. package/src/checks/automatic/definition-list-children-valid.js +46 -25
  47. package/src/checks/automatic/deprecated-elements-not-used.js +25 -9
  48. package/src/checks/automatic/dialog-name-present.js +47 -85
  49. package/src/checks/automatic/dlitem-parent-valid.js +25 -8
  50. package/src/checks/automatic/duplicate-id-aria.js +28 -9
  51. package/src/checks/automatic/embed-text-alternative-present.js +88 -35
  52. package/src/checks/automatic/form-control-programmatic-label-present.js +81 -196
  53. package/src/checks/automatic/form-control-single-label.js +50 -14
  54. package/src/checks/automatic/html-xml-lang-mismatch.js +36 -18
  55. package/src/checks/automatic/iframe-focusable-content.js +265 -22
  56. package/src/checks/automatic/iframe-name-present.js +33 -9
  57. package/src/checks/automatic/iframe-title-unique.js +32 -9
  58. package/src/checks/automatic/img-alt-present.js +54 -52
  59. package/src/checks/automatic/input-image-alt-present.js +141 -112
  60. package/src/checks/automatic/label-in-name.js +65 -41
  61. package/src/checks/automatic/language-page-present.js +111 -109
  62. package/src/checks/automatic/link-in-text-block.js +61 -19
  63. package/src/checks/automatic/link-name-present.js +47 -14
  64. package/src/checks/automatic/list-children-valid.js +40 -33
  65. package/src/checks/automatic/listbox-name-present.js +41 -19
  66. package/src/checks/automatic/listitem-parent-valid.js +48 -13
  67. package/src/checks/automatic/menuitem-name-present.js +41 -61
  68. package/src/checks/automatic/meta-refresh-no-exceptions.js +32 -11
  69. package/src/checks/automatic/meta-refresh-timing-absent.js +22 -6
  70. package/src/checks/automatic/meta-viewport-zoom-enabled.js +26 -7
  71. package/src/checks/automatic/meter-name-present.js +40 -36
  72. package/src/checks/automatic/nested-interactive-controls-absent.js +58 -15
  73. package/src/checks/automatic/object-text-alternative-present.js +93 -39
  74. package/src/checks/automatic/option-name-present.js +40 -21
  75. package/src/checks/automatic/page-title-present.js +19 -6
  76. package/src/checks/automatic/progressbar-name-present.js +49 -44
  77. package/src/checks/automatic/role-img-alt-present.js +211 -159
  78. package/src/checks/automatic/searchbox-name-present.js +41 -19
  79. package/src/checks/automatic/server-side-image-map-absent.js +27 -11
  80. package/src/checks/automatic/slider-name-present.js +42 -47
  81. package/src/checks/automatic/spinbutton-name-present.js +41 -19
  82. package/src/checks/automatic/summary-name-present.js +39 -17
  83. package/src/checks/automatic/svg-image-text-alternative-present.js +116 -47
  84. package/src/checks/automatic/svg-text-alternative-present.js +262 -230
  85. package/src/checks/automatic/tab-name-present.js +39 -60
  86. package/src/checks/automatic/table-headers-attr-valid.js +27 -10
  87. package/src/checks/automatic/table-th-has-data-cells.js +24 -8
  88. package/src/checks/automatic/target-size-minimum.js +123 -48
  89. package/src/checks/automatic/td-has-header.js +53 -12
  90. package/src/checks/automatic/textbox-name-present.js +41 -19
  91. package/src/checks/automatic/tooltip-name-present.js +39 -18
  92. package/src/checks/automatic/treeitem-name-present.js +40 -21
  93. package/src/checks/automatic/valid-lang.js +22 -6
  94. package/src/checks/automatic/video-poster-text-alternative-present.js +81 -36
  95. package/src/checks/manual/accesskeys-manual.js +17 -6
  96. package/src/checks/manual/area-alt-decorative-manual.js +194 -193
  97. package/src/checks/manual/area-alt-quality-manual.js +184 -141
  98. package/src/checks/manual/aria-checked-state-mismatch-manual.js +48 -34
  99. package/src/checks/manual/aria-text-manual.js +20 -11
  100. package/src/checks/manual/canvas-text-alternative-quality-manual.js +151 -114
  101. package/src/checks/manual/css-hidden-focus.js +375 -169
  102. package/src/checks/manual/embed-text-alternative-quality-manual.js +178 -162
  103. package/src/checks/manual/empty-heading-manual.js +41 -24
  104. package/src/checks/manual/empty-table-header-manual.js +69 -31
  105. package/src/checks/manual/focus-order-semantics-manual.js +60 -13
  106. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +209 -246
  107. package/src/checks/manual/heading-order-manual.js +50 -8
  108. package/src/checks/manual/identical-links-same-purpose-manual.js +36 -12
  109. package/src/checks/manual/image-redundant-alt-manual.js +38 -8
  110. package/src/checks/manual/img-alt-decorative-manual.js +133 -96
  111. package/src/checks/manual/img-alt-quality-manual.js +178 -127
  112. package/src/checks/manual/input-image-alt-decorative-manual.js +127 -92
  113. package/src/checks/manual/input-image-alt-quality-manual.js +127 -92
  114. package/src/checks/manual/label-title-only-manual.js +44 -28
  115. package/src/checks/manual/landmark-banner-is-top-level-manual.js +95 -38
  116. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +85 -32
  117. package/src/checks/manual/landmark-main-is-top-level-manual.js +69 -27
  118. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +45 -33
  119. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +43 -31
  120. package/src/checks/manual/landmark-no-duplicate-main-manual.js +27 -21
  121. package/src/checks/manual/landmark-one-main-manual.js +38 -43
  122. package/src/checks/manual/landmark-unique-manual.js +78 -67
  123. package/src/checks/manual/link-name-quality-manual.js +45 -12
  124. package/src/checks/manual/media-transcript-present-manual.js +37 -22
  125. package/src/checks/manual/meta-viewport-large-manual.js +19 -6
  126. package/src/checks/manual/mouse-only-event-handlers-manual.js +40 -11
  127. package/src/checks/manual/no-autoplay-audio-manual.js +22 -6
  128. package/src/checks/manual/object-text-alternative-quality-manual.js +177 -154
  129. package/src/checks/manual/p-as-heading-manual.js +24 -7
  130. package/src/checks/manual/page-has-heading-one-manual.js +42 -32
  131. package/src/checks/manual/page-title-patterns-manual.js +80 -50
  132. package/src/checks/manual/presentation-role-conflict-manual.js +101 -47
  133. package/src/checks/manual/region-manual.js +244 -60
  134. package/src/checks/manual/scope-attr-valid-manual.js +13 -4
  135. package/src/checks/manual/scrollable-region-focusable-manual.js +39 -11
  136. package/src/checks/manual/skip-link-manual.js +42 -18
  137. package/src/checks/manual/svg-text-alternative-quality-manual.js +208 -165
  138. package/src/checks/manual/tabindex-manual.js +13 -4
  139. package/src/checks/manual/table-duplicate-name-manual.js +22 -11
  140. package/src/checks/manual/table-fake-caption-manual.js +48 -10
  141. package/src/checks/manual/video-caption-manual.js +17 -4
  142. package/src/checks/manual-review.js +58 -12
  143. package/src/core.js +41705 -29650
  144. package/src/index.js +2 -0
  145. package/src/report.js +109 -47
  146. package/src/sarif.js +190 -0
  147. package/surea11y.browser.js +37774 -0
  148. package/bin/core.js +0 -348
  149. package/docs/CLI.md +0 -75
  150. package/src/catalogs/composites.wcag.js +0 -490
  151. package/src/checks/rules-and-tags.full.csv +0 -19
  152. package/src/checks/rules-and-tags.full.json +0 -259
  153. package/src/core/aria-helpers.js +0 -970
  154. package/src/core/contrast-helpers.js +0 -1147
  155. package/src/core/dom-helpers.js +0 -4235
  156. package/src/core/dom-runner.js +0 -671
  157. package/src/core/frame-messaging.js +0 -210
  158. package/src/core/frame-scan.js +0 -178
  159. package/src/core/rollup-composites.js +0 -135
  160. package/src/core/rule-meta.js +0 -159
  161. package/src/coverage/wcag-facets.js +0 -1079
  162. package/src/coverage/wcag-version-map.js +0 -84
  163. package/src/i18n/en.js +0 -923
  164. package/src/i18n/fr.js +0 -844
  165. package/src/policy/contracts.js +0 -18
  166. package/src/policy/resolvePolicy.js +0 -55
  167. package/src/policy/schemas/engine-options.schema.json +0 -103
  168. 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
  /**
@@ -9,7 +11,12 @@
9
11
  * @applicability
10
12
  * Applies to inline <svg> elements that are exposed to assistive technologies AND appear intended to be conveyed.
11
13
  * "Intended to be conveyed" is approximated deterministically by at least one of:
12
- * - role="img"
14
+ * - role="img", role="graphics-symbol", or role="graphics-document"
15
+ * on the SVG root element itself (the WAI-ARIA Graphics Module
16
+ * roles, alongside img). Deliberately does NOT extend to arbitrary
17
+ * role="graphics-symbol" descendants nested inside an <svg> — this
18
+ * check's scope is the <svg> root only; a broader feature, not
19
+ * attempted here.
13
20
  * - aria-label / aria-labelledby present
14
21
  * - <title> or <desc> present (desc alone is an applicability signal only — see @expectation)
15
22
  * - focusable/tabbable (e.g., tabindex, native focusability)
@@ -23,276 +30,301 @@
23
30
  * - an ARIA name (aria-label / aria-labelledby).
24
31
  * A <desc> element alone does NOT satisfy this — per the SVG Accessibility
25
32
  * API Mappings spec §7.1, <desc> only ever contributes to the accessible
26
- * DESCRIPTION, never the accessible NAME (verified 2026-07-20 directly
27
- * against the SVG-AAM spec text, not assumed). An <svg> with only a
33
+ * DESCRIPTION, never the accessible NAME. An <svg> with only a
28
34
  * <desc> and no <title>/ARIA name is still "applicable" (desc signals
29
- * authorial intent) but fails, matching a widely-used reference engine's svg-img-alt.
35
+ * authorial intent) but fails.
30
36
  */
31
37
 
32
38
  const id = 'svg-text-alternative-present';
33
39
 
34
40
  const meta = {
35
- title: '<svg> must provide a text alternative',
36
- description: 'Checks that inline <svg> elements provide a text alternative via a <title> element or an ARIA name (a <desc> element alone does not count — it only contributes to the accessible description, not the name).',
37
- i18n: {
38
- titleKey: 'svg_textAltPresent_title',
39
- descriptionKey: 'svg_textAltPresent_description'
40
- },
41
- helpUrl: null,
42
- tags: ['wcag2a', 'wcag111', 'svg', 'nontext', 'images', 'atomic', 'automatic'],
43
- wcagSc: ['1.1.1'],
44
- normativeMappings: [
45
- {standard: 'WCAG', version: '2.2', requirement: '1.1.1', title: 'Non-text Content', conformanceLevel: 'A'}
46
- ],
47
- defaultSeverity: 'serious',
48
- category: 'perceivable',
49
- type: 'automatic',
50
- defaultConfidence: 'high',
51
- coverage: {
52
- facetsBySc: {
53
- '1.1.1': ['svg-text-alternative-present']
54
- }
41
+ title: '<svg> must provide a text alternative',
42
+ description:
43
+ 'Checks that inline <svg> elements provide a text alternative via a <title> element or an ARIA name (a <desc> element alone does not count — it only contributes to the accessible description, not the name).',
44
+ i18n: {
45
+ titleKey: 'svg_textAltPresent_title',
46
+ descriptionKey: 'svg_textAltPresent_description'
47
+ },
48
+ helpUrl: null,
49
+ tags: ['wcag2a', 'wcag111', 'svg', 'nontext', 'images', 'atomic', 'automatic'],
50
+ wcagSc: ['1.1.1'],
51
+ normativeMappings: [
52
+ {
53
+ standard: 'WCAG',
54
+ version: '2.2',
55
+ requirement: '1.1.1',
56
+ title: 'Non-text Content',
57
+ conformanceLevel: 'A'
55
58
  }
59
+ ],
60
+ defaultSeverity: 'serious',
61
+ category: 'perceivable',
62
+ type: 'automatic',
63
+ defaultConfidence: 'high',
64
+ coverage: {
65
+ facetsBySc: {
66
+ '1.1.1': ['svg-text-alternative-present']
67
+ }
68
+ }
56
69
  };
57
70
 
58
71
  function runInPage(ctx) {
59
- const {document, root, helpers, rule} = ctx;
60
- const safeRoot = root || document;
61
-
62
- const queryAllSmart = helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
63
- const queryAll = helpers && typeof helpers.queryAll === 'function'
64
- ? helpers.queryAll
65
- : (sel) => {
66
- try {
67
- return safeRoot && safeRoot.querySelectorAll ? Array.from(safeRoot.querySelectorAll(sel)) : [];
68
- } catch {
69
- return [];
70
- }
72
+ const { document, root, helpers, rule } = ctx;
73
+ const safeRoot = root || document;
74
+
75
+ const queryAllSmart =
76
+ helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
77
+ const queryAll =
78
+ helpers && typeof helpers.queryAll === 'function'
79
+ ? helpers.queryAll
80
+ : (sel) => {
81
+ try {
82
+ return safeRoot && safeRoot.querySelectorAll
83
+ ? Array.from(safeRoot.querySelectorAll(sel))
84
+ : [];
85
+ } catch {
86
+ return [];
87
+ }
71
88
  };
72
89
 
73
- const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
74
- ? helpers.getEligibilityInfo
75
- : null;
90
+ const getEligibilityInfo =
91
+ helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
76
92
 
77
- const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function'
78
- ? helpers.isAccTreeEligible
79
- : null;
93
+ const isAccTreeEligible =
94
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
80
95
 
81
- const getFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function'
82
- ? helpers.getFocusableInfo
83
- : null;
96
+ const getFocusableInfo =
97
+ helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
84
98
 
85
- const getAriaNameInfo = helpers && typeof helpers.getAriaNameInfo === 'function'
86
- ? helpers.getAriaNameInfo
87
- : null;
99
+ const getAriaNameInfo =
100
+ helpers && typeof helpers.getAriaNameInfo === 'function' ? helpers.getAriaNameInfo : null;
88
101
 
89
- function trim(v) {
90
- try {
91
- return (v == null ? '' : String(v)).trim();
92
- } catch {
93
- return '';
94
- }
102
+ function trim(v) {
103
+ try {
104
+ return (v == null ? '' : String(v)).trim();
105
+ } catch {
106
+ return '';
95
107
  }
96
-
97
- // Per SVG accessible-name conventions, only a <title> that is literally
98
- // the first child element is used by assistive technologies as the
99
- // SVG's accessible name; a <title> appearing later among the children
100
- // is commonly ignored by AT even though it's still a valid DOM child.
101
- function nonEmptyFirstChildTitleText(svg) {
102
- try {
103
- const first = svg.firstElementChild;
104
- const tn = first ? (first.localName || first.tagName || '').toLowerCase() : '';
105
- if (tn === 'title') {
106
- const txt = trim(first.textContent);
107
- if (txt) return txt;
108
- }
109
- } catch {
108
+ }
109
+
110
+ // Per SVG accessible-name conventions, only a <title> that is literally
111
+ // the first child element is used by assistive technologies as the
112
+ // SVG's accessible name; a <title> appearing later among the children
113
+ // is commonly ignored by AT even though it's still a valid DOM child.
114
+ function nonEmptyFirstChildTitleText(svg) {
115
+ try {
116
+ const first = svg.firstElementChild;
117
+ const tn = first ? (first.localName || first.tagName || '').toLowerCase() : '';
118
+ if (tn === 'title') {
119
+ const txt = trim(first.textContent);
120
+ if (txt) return txt;
121
+ }
122
+ } catch {}
123
+ return '';
124
+ }
125
+
126
+ // <desc> counts when it is the first child, or the second child
127
+ // immediately following a <title> — the standard <title>+<desc> pairing
128
+ // (e.g. <svg><title>...</title><desc>...</desc>...</svg>). A <desc>
129
+ // appearing later than that is not reliably read by AT.
130
+ function nonEmptyDescText(svg) {
131
+ try {
132
+ const first = svg.firstElementChild;
133
+ const firstTag = first ? (first.localName || first.tagName || '').toLowerCase() : '';
134
+ if (firstTag === 'desc') {
135
+ const txt = trim(first.textContent);
136
+ if (txt) return txt;
137
+ } else if (firstTag === 'title' && first.nextElementSibling) {
138
+ const second = first.nextElementSibling;
139
+ const secondTag = (second.localName || second.tagName || '').toLowerCase();
140
+ if (secondTag === 'desc') {
141
+ const txt = trim(second.textContent);
142
+ if (txt) return txt;
110
143
  }
111
- return '';
112
- }
113
-
114
- // <desc> counts when it is the first child, or the second child
115
- // immediately following a <title> — the standard <title>+<desc> pairing
116
- // (e.g. <svg><title>...</title><desc>...</desc>...</svg>). A <desc>
117
- // appearing later than that is not reliably read by AT.
118
- function nonEmptyDescText(svg) {
144
+ }
145
+ } catch {}
146
+ return '';
147
+ }
148
+
149
+ function isFocusable(svg) {
150
+ if (getFocusableInfo) {
151
+ const fi = (() => {
119
152
  try {
120
- const first = svg.firstElementChild;
121
- const firstTag = first ? (first.localName || first.tagName || '').toLowerCase() : '';
122
- if (firstTag === 'desc') {
123
- const txt = trim(first.textContent);
124
- if (txt) return txt;
125
- } else if (firstTag === 'title' && first.nextElementSibling) {
126
- const second = first.nextElementSibling;
127
- const secondTag = (second.localName || second.tagName || '').toLowerCase();
128
- if (secondTag === 'desc') {
129
- const txt = trim(second.textContent);
130
- if (txt) return txt;
131
- }
132
- }
153
+ return getFocusableInfo(svg, ctx);
133
154
  } catch {
155
+ return null;
134
156
  }
135
- return '';
157
+ })();
158
+ return !!(fi && fi.focusable);
136
159
  }
137
-
138
- function isFocusable(svg) {
139
- if (getFocusableInfo) {
140
- const fi = (() => {
141
- try {
142
- return getFocusableInfo(svg, ctx);
143
- } catch {
144
- return null;
145
- }
146
- })();
147
- return !!(fi && fi.focusable);
148
- }
149
- // deterministic fallback: tabindex presence/valid number
150
- try {
151
- const tabindex = svg && svg.getAttribute ? svg.getAttribute('tabindex') : null;
152
- return tabindex != null && String(tabindex).trim() !== '' && !Number.isNaN(Number(String(tabindex).trim()));
153
- } catch {
154
- return false;
155
- }
160
+ // deterministic fallback: tabindex presence/valid number
161
+ try {
162
+ const tabindex = svg && svg.getAttribute ? svg.getAttribute('tabindex') : null;
163
+ return (
164
+ tabindex != null &&
165
+ String(tabindex).trim() !== '' &&
166
+ !Number.isNaN(Number(String(tabindex).trim()))
167
+ );
168
+ } catch {
169
+ return false;
156
170
  }
171
+ }
157
172
 
158
- const svgs = (() => {
159
- try {
160
- return Array.from((queryAllSmart ? queryAllSmart('svg') : queryAll('svg')) || []);
161
- } catch {
162
- return queryAll('svg');
163
- }
164
- })();
165
-
166
- if (!svgs.length) {
167
- return {ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: []};
173
+ const svgs = (() => {
174
+ try {
175
+ return Array.from((queryAllSmart ? queryAllSmart('svg') : queryAll('svg')) || []);
176
+ } catch {
177
+ return queryAll('svg');
168
178
  }
179
+ })();
169
180
 
170
- const occurrences = [];
171
- let applicableCount = 0;
172
-
173
- for (const el of svgs) {
174
- if (!el || !el.getAttribute) continue;
175
-
176
- // Applicability step 1: only acc-tree eligible nodes (with helper exceptions)
177
- if (isAccTreeEligible) {
178
- const elig = (() => {
179
- try {
180
- return isAccTreeEligible(el, ctx);
181
- } catch {
182
- return {eligible: true, reasons: []};
183
- }
184
- })();
185
- if (elig && elig.eligible === false) continue;
186
- }
181
+ if (!svgs.length) {
182
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
183
+ }
187
184
 
188
- // Applicability step 2: role (presentation/none) exclusion only when not focusable
189
- const role = (() => {
190
- try {
191
- return String(el.getAttribute('role') || '').trim().toLowerCase();
192
- } catch {
193
- return '';
194
- }
195
- })();
185
+ const occurrences = [];
186
+ let applicableCount = 0;
196
187
 
197
- const focusable = isFocusable(el);
188
+ for (const el of svgs) {
189
+ if (!el || !el.getAttribute) continue;
198
190
 
199
- if (role === 'presentation' || role === 'none') {
200
- if (!focusable) continue;
201
- }
202
-
203
- // Applicability step 3: intent signal gating (computed once per element)
204
- let hasAriaNamingAttr = false;
191
+ // Applicability step 1: only acc-tree eligible nodes (with helper exceptions)
192
+ if (isAccTreeEligible) {
193
+ const elig = (() => {
205
194
  try {
206
- hasAriaNamingAttr = (el.getAttribute('aria-label') != null) || (el.getAttribute('aria-labelledby') != null);
195
+ return isAccTreeEligible(el, ctx);
207
196
  } catch {
197
+ return { eligible: true, reasons: [] };
208
198
  }
199
+ })();
200
+ if (elig && elig.eligible === false) continue;
201
+ }
209
202
 
210
- const titleText = nonEmptyFirstChildTitleText(el);
211
- const descText = titleText ? '' : nonEmptyDescText(el); // avoid second scan if title already passes
212
- const hasTitleOrDesc = !!(titleText || descText);
213
-
214
- const hasIntent =
215
- role === 'img' ||
216
- hasAriaNamingAttr ||
217
- hasTitleOrDesc ||
218
- focusable;
219
-
220
- if (!hasIntent) continue;
221
-
222
- applicableCount += 1;
223
-
224
- // Expectation: non-empty title/desc OR ARIA name (but only resolve name if attrs exist)
225
- let hasAriaName = false;
226
- if (hasAriaNamingAttr) {
227
- if (getAriaNameInfo) {
228
- const info = (() => {
229
- try {
230
- return getAriaNameInfo(el, ctx);
231
- } catch {
232
- return null;
233
- }
234
- })();
235
- hasAriaName = !!(info && info.present && trim(info.value));
236
- } else {
237
- // minimal deterministic fallback
238
- const ariaLabel = trim((() => {
239
- try {
240
- return el.getAttribute('aria-label');
241
- } catch {
242
- return '';
243
- }
244
- })());
245
- const ariaLabelledby = trim((() => {
246
- try {
247
- return el.getAttribute('aria-labelledby');
248
- } catch {
249
- return '';
250
- }
251
- })());
252
- hasAriaName = !!(ariaLabel || ariaLabelledby);
253
- }
254
- }
203
+ // Applicability step 2: role (presentation/none) exclusion only when not focusable
204
+ const role = (() => {
205
+ try {
206
+ return String(el.getAttribute('role') || '')
207
+ .trim()
208
+ .toLowerCase();
209
+ } catch {
210
+ return '';
211
+ }
212
+ })();
255
213
 
256
- // Per SVG-AAM §7.1: <desc> contributes only to the accessible
257
- // DESCRIPTION, never the accessible NAME — so descText does not
258
- // count here even though it does count toward applicability above.
259
- const ok = !!titleText || hasAriaName;
260
- if (ok) continue;
214
+ const focusable = isFocusable(el);
261
215
 
262
- let eligInfo = null;
263
- if (getEligibilityInfo) {
264
- try { eligInfo = getEligibilityInfo(el, ctx, { targetSet: 'acc' }); } catch { eligInfo = null; }
265
- }
216
+ if (role === 'presentation' || role === 'none') {
217
+ if (!focusable) continue;
218
+ }
266
219
 
267
- const baseOccurrence = {
268
- summary: 'Missing text alternative for <svg>.',
269
- hint: 'Provide a <title> element with text, or an ARIA name (aria-label/aria-labelledby) — a <desc> element alone does not provide an accessible name.',
270
- i18n: {
271
- summaryKey: 'svg_textAltPresent_summary_fail',
272
- hintKey: 'svg_textAltPresent_hint_fail',
273
- params: {element: 'svg'}
274
- },
275
- data: {
276
- visibilityFilter: eligInfo || {targetSet: 'acc', accEligible: null, reasons: []}
220
+ // Applicability step 3: intent signal gating (computed once per element)
221
+ let hasAriaNamingAttr = false;
222
+ try {
223
+ hasAriaNamingAttr =
224
+ el.getAttribute('aria-label') != null || el.getAttribute('aria-labelledby') != null;
225
+ } catch {}
226
+
227
+ const titleText = nonEmptyFirstChildTitleText(el);
228
+ const descText = titleText ? '' : nonEmptyDescText(el); // avoid second scan if title already passes
229
+ const hasTitleOrDesc = !!(titleText || descText);
230
+
231
+ const hasIntent =
232
+ role === 'img' ||
233
+ role === 'graphics-symbol' ||
234
+ role === 'graphics-document' ||
235
+ hasAriaNamingAttr ||
236
+ hasTitleOrDesc ||
237
+ focusable;
238
+
239
+ if (!hasIntent) continue;
240
+
241
+ applicableCount += 1;
242
+
243
+ // Expectation: non-empty title/desc OR ARIA name (but only resolve name if attrs exist)
244
+ let hasAriaName = false;
245
+ if (hasAriaNamingAttr) {
246
+ if (getAriaNameInfo) {
247
+ const info = (() => {
248
+ try {
249
+ return getAriaNameInfo(el, ctx);
250
+ } catch {
251
+ return null;
252
+ }
253
+ })();
254
+ hasAriaName = !!(info && info.present && trim(info.value));
255
+ } else {
256
+ // minimal deterministic fallback
257
+ const ariaLabel = trim(
258
+ (() => {
259
+ try {
260
+ return el.getAttribute('aria-label');
261
+ } catch {
262
+ return '';
277
263
  }
278
- };
279
-
280
- if (helpers && typeof helpers.reportOccurrence === 'function') {
281
- occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
282
- } else {
283
- occurrences.push({selector: '', html: '', ...baseOccurrence});
284
- }
264
+ })()
265
+ );
266
+ const ariaLabelledby = trim(
267
+ (() => {
268
+ try {
269
+ return el.getAttribute('aria-labelledby');
270
+ } catch {
271
+ return '';
272
+ }
273
+ })()
274
+ );
275
+ hasAriaName = !!(ariaLabel || ariaLabelledby);
276
+ }
285
277
  }
286
278
 
287
- if (applicableCount === 0) {
288
- return {ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: []};
279
+ // Per SVG-AAM §7.1: <desc> contributes only to the accessible
280
+ // DESCRIPTION, never the accessible NAME — so descText does not
281
+ // count here even though it does count toward applicability above.
282
+ const ok = !!titleText || hasAriaName;
283
+ if (ok) continue;
284
+
285
+ let eligInfo = null;
286
+ if (getEligibilityInfo) {
287
+ try {
288
+ eligInfo = getEligibilityInfo(el, ctx, { targetSet: 'acc' });
289
+ } catch {
290
+ eligInfo = null;
291
+ }
289
292
  }
290
293
 
291
- if (!occurrences.length) {
292
- return {ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: []};
294
+ const baseOccurrence = {
295
+ summary: 'Missing text alternative for <svg>.',
296
+ hint: 'Provide a <title> element with text, or an ARIA name (aria-label/aria-labelledby) — a <desc> element alone does not provide an accessible name.',
297
+ i18n: {
298
+ summaryKey: 'svg_textAltPresent_summary_fail',
299
+ hintKey: 'svg_textAltPresent_hint_fail',
300
+ params: { element: 'svg' }
301
+ },
302
+ data: {
303
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
304
+ }
305
+ };
306
+
307
+ if (helpers && typeof helpers.reportOccurrence === 'function') {
308
+ occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
309
+ } else {
310
+ occurrences.push({ selector: '', html: '', ...baseOccurrence });
293
311
  }
294
-
295
- return {ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences};
312
+ }
313
+
314
+ if (applicableCount === 0) {
315
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
316
+ }
317
+
318
+ if (!occurrences.length) {
319
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
320
+ }
321
+
322
+ return {
323
+ ruleId: rule.ruleId,
324
+ outcome: 'fail',
325
+ severity: rule.defaultSeverity || 'minor',
326
+ occurrences
327
+ };
296
328
  }
297
329
 
298
- module.exports = {id, meta, runInPage};
330
+ module.exports = { id, meta, runInPage };