@surea11y/core 1.4.0 → 1.5.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 (115) hide show
  1. package/CHANGELOG.md +88 -7
  2. package/README.md +19 -3
  3. package/bin/surea11y-core.js +0 -0
  4. package/docs/API_STABILITY.md +2 -2
  5. package/docs/ARIA_DEPRECATION.md +95 -0
  6. package/docs/ENGINE_OPTIONS.md +14 -10
  7. package/docs/I18N.md +176 -20
  8. package/docs/INTEGRATION.md +28 -6
  9. package/docs/LIMITATIONS.md +1 -1
  10. package/docs/OUTPUT_SCHEMA.md +13 -3
  11. package/docs/REPORT.md +2 -0
  12. package/docs/RULE_AUTHORING.md +53 -0
  13. package/docs/RULE_CATALOG.md +6 -6
  14. package/docs/TROUBLESHOOTING.md +2 -2
  15. package/package.json +8 -1
  16. package/src/checks/automatic/aria-allowed-attr.js +661 -99
  17. package/src/checks/automatic/aria-allowed-role.js +14 -16
  18. package/src/checks/automatic/aria-braille-equivalent.js +17 -19
  19. package/src/checks/automatic/aria-conditional-attr.js +17 -19
  20. package/src/checks/automatic/aria-deprecated-role.js +122 -41
  21. package/src/checks/automatic/aria-hidden-body.js +2 -9
  22. package/src/checks/automatic/aria-hidden-focus.js +99 -18
  23. package/src/checks/automatic/aria-prohibited-attr.js +54 -55
  24. package/src/checks/automatic/aria-prohibited-children.js +26 -26
  25. package/src/checks/automatic/aria-required-attr.js +14 -17
  26. package/src/checks/automatic/aria-required-children.js +17 -20
  27. package/src/checks/automatic/aria-required-parent.js +17 -20
  28. package/src/checks/automatic/aria-roles-valid.js +66 -27
  29. package/src/checks/automatic/aria-valid-attr-value.js +18 -21
  30. package/src/checks/automatic/aria-valid-attr.js +14 -17
  31. package/src/checks/automatic/autocomplete-valid.js +52 -18
  32. package/src/checks/automatic/avoid-inline-spacing.js +192 -34
  33. package/src/checks/automatic/binary-control-name-present.js +30 -24
  34. package/src/checks/automatic/button-name-present.js +73 -45
  35. package/src/checks/automatic/canvas-text-alternative-present.js +15 -8
  36. package/src/checks/automatic/combobox-name-present.js +23 -18
  37. package/src/checks/automatic/css-orientation-lock.js +22 -22
  38. package/src/checks/automatic/definition-list-children-valid.js +18 -21
  39. package/src/checks/automatic/deprecated-elements-not-used.js +14 -16
  40. package/src/checks/automatic/dialog-name-present.js +24 -19
  41. package/src/checks/automatic/dlitem-parent-valid.js +15 -17
  42. package/src/checks/automatic/duplicate-id-aria.js +45 -37
  43. package/src/checks/automatic/form-control-programmatic-label-present.js +48 -4
  44. package/src/checks/automatic/form-control-single-label.js +109 -43
  45. package/src/checks/automatic/html-xml-lang-mismatch.js +2 -9
  46. package/src/checks/automatic/iframe-focusable-content.js +29 -31
  47. package/src/checks/automatic/iframe-name-present.js +15 -17
  48. package/src/checks/automatic/iframe-title-unique.js +18 -23
  49. package/src/checks/automatic/img-alt-present.js +16 -9
  50. package/src/checks/automatic/input-image-alt-present.js +99 -48
  51. package/src/checks/automatic/label-in-name.js +102 -33
  52. package/src/checks/automatic/language-page-present.js +5 -1
  53. package/src/checks/automatic/link-in-text-block.js +19 -21
  54. package/src/checks/automatic/link-name-present.js +75 -47
  55. package/src/checks/automatic/list-children-valid.js +15 -17
  56. package/src/checks/automatic/listbox-name-present.js +23 -18
  57. package/src/checks/automatic/listitem-parent-valid.js +14 -17
  58. package/src/checks/automatic/menuitem-name-present.js +24 -19
  59. package/src/checks/automatic/meta-refresh-no-exceptions.js +44 -18
  60. package/src/checks/automatic/meta-refresh-timing-absent.js +44 -21
  61. package/src/checks/automatic/meta-viewport-zoom-enabled.js +51 -32
  62. package/src/checks/automatic/meter-name-present.js +24 -19
  63. package/src/checks/automatic/nested-interactive-controls-absent.js +179 -51
  64. package/src/checks/automatic/object-text-alternative-present.js +14 -7
  65. package/src/checks/automatic/option-name-present.js +24 -19
  66. package/src/checks/automatic/progressbar-name-present.js +27 -22
  67. package/src/checks/automatic/searchbox-name-present.js +27 -18
  68. package/src/checks/automatic/server-side-image-map-absent.js +15 -18
  69. package/src/checks/automatic/slider-name-present.js +26 -19
  70. package/src/checks/automatic/spinbutton-name-present.js +27 -18
  71. package/src/checks/automatic/summary-name-present.js +24 -19
  72. package/src/checks/automatic/tab-name-present.js +24 -19
  73. package/src/checks/automatic/table-headers-attr-valid.js +15 -17
  74. package/src/checks/automatic/table-th-has-data-cells.js +82 -25
  75. package/src/checks/automatic/target-size-minimum.js +104 -81
  76. package/src/checks/automatic/td-has-header.js +15 -20
  77. package/src/checks/automatic/textbox-name-present.js +23 -18
  78. package/src/checks/automatic/tooltip-name-present.js +24 -19
  79. package/src/checks/automatic/treeitem-name-present.js +24 -19
  80. package/src/checks/automatic/valid-lang.js +31 -20
  81. package/src/checks/manual/accesskeys-manual.js +18 -19
  82. package/src/checks/manual/aria-checked-state-mismatch-manual.js +19 -22
  83. package/src/checks/{automatic/bypass-blocks-present.js → manual/bypass-blocks-present-manual.js} +98 -44
  84. package/src/checks/manual/empty-heading-manual.js +15 -17
  85. package/src/checks/manual/empty-table-header-manual.js +27 -30
  86. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -9
  87. package/src/checks/manual/heading-order-manual.js +17 -22
  88. package/src/checks/manual/image-redundant-alt-manual.js +14 -17
  89. package/src/checks/manual/input-image-alt-decorative-manual.js +24 -0
  90. package/src/checks/manual/label-title-only-manual.js +15 -17
  91. package/src/checks/manual/landmark-banner-is-top-level-manual.js +25 -39
  92. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +21 -27
  93. package/src/checks/manual/landmark-main-is-top-level-manual.js +14 -17
  94. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +2 -7
  95. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +2 -7
  96. package/src/checks/manual/landmark-no-duplicate-main-manual.js +2 -7
  97. package/src/checks/manual/landmark-one-main-manual.js +2 -9
  98. package/src/checks/manual/landmark-unique-manual.js +22 -27
  99. package/src/checks/manual/link-name-quality-manual.js +15 -17
  100. package/src/checks/manual/meta-viewport-large-manual.js +14 -17
  101. package/src/checks/manual/mouse-only-event-handlers-manual.js +17 -19
  102. package/src/checks/manual/page-has-heading-one-manual.js +2 -9
  103. package/src/checks/manual/presentation-role-conflict-manual.js +19 -21
  104. package/src/checks/manual/region-manual.js +13 -6
  105. package/src/checks/manual/scope-attr-valid-manual.js +14 -17
  106. package/src/checks/manual/skip-link-manual.js +39 -47
  107. package/src/checks/manual/tabindex-manual.js +14 -17
  108. package/src/checks/manual/table-duplicate-name-manual.js +14 -17
  109. package/src/core.js +8818 -4330
  110. package/src/report.js +14 -0
  111. package/src/sarif.js +2 -2
  112. package/surea11y.browser.js +4023 -3911
  113. package/surea11y.i18n.de.js +22 -0
  114. package/surea11y.i18n.es.js +22 -0
  115. package/surea11y.i18n.fr.js +22 -0
@@ -22,12 +22,9 @@
22
22
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
23
23
  * header comment for the shared rationale/precedent (this rule mirrors
24
24
  * its structure with contentinfo/footer in place of banner/header).
25
- * - Candidate selection (`isContentinfoCandidate` below) is deliberately
26
- * unconditional, instead of reusing the sectioning-ancestor suppression
27
- * that the violation check itself depends on — same
28
- * self-defeating-candidate-selection reasoning as
29
- * landmark-banner-is-top-level (see that file's header comment for the
30
- * full root cause).
25
+ * - Candidate selection (`isContentinfoCandidate`) requires the element to
26
+ * really carry the contentinfo role, via the suppression-aware
27
+ * `getLandmarkRole` — same reasoning as landmark-banner-is-top-level.
31
28
  */
32
29
 
33
30
  const id = 'landmark-contentinfo-is-top-level';
@@ -135,11 +132,11 @@ function runInPage(ctx) {
135
132
  // a candidate purely by tag + absence of any role attribute, independent
136
133
  // of whether sectioning-ancestor nesting would currently suppress its
137
134
  // implicit role; an explicit role="contentinfo" is always a candidate too.
135
+ // A candidate must actually have the contentinfo role — a <footer> inside
136
+ // article/aside/main/nav/section is not one, so flagging it as nested
137
+ // would report a landmark that does not exist.
138
138
  function isContentinfoCandidate(el) {
139
- if (!el || !el.getAttribute) return false;
140
- const explicit = getExplicitRoleToken(el);
141
- if (explicit) return explicit === 'contentinfo';
142
- return !!(el.tagName && el.tagName.toLowerCase() === 'footer');
139
+ return getLandmarkRole(el) === 'contentinfo';
143
140
  }
144
141
 
145
142
  function hasLandmarkAncestor(el) {
@@ -205,23 +202,20 @@ function runInPage(ctx) {
205
202
  for (const el of contentinfos) {
206
203
  if (!hasLandmarkAncestor(el)) continue;
207
204
 
208
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
209
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
210
-
211
- occurrences.push({
212
- selector: stableSelector,
213
- html,
214
- summary: 'This contentinfo landmark is nested inside another landmark region.',
215
- hint: 'Move the contentinfo landmark (footer/role="contentinfo") so it is not contained by another landmark; contentinfo should be a top-level region of the page.',
216
- i18n: {
217
- summaryKey: 'landmarkContentinfoIsTopLevel_summary_cantTell',
218
- hintKey: 'landmarkContentinfoIsTopLevel_hint_cantTell',
219
- params: {}
220
- },
221
- data: {
222
- details: { reasonCode: 'LANDMARK_CONTENTINFO_NOT_TOP_LEVEL' }
223
- }
224
- });
205
+ occurrences.push(
206
+ helpers.reportOccurrence(el, {
207
+ summary: 'This contentinfo landmark is nested inside another landmark region.',
208
+ hint: 'Move the contentinfo landmark (footer/role="contentinfo") so it is not contained by another landmark; contentinfo should be a top-level region of the page.',
209
+ i18n: {
210
+ summaryKey: 'landmarkContentinfoIsTopLevel_summary_cantTell',
211
+ hintKey: 'landmarkContentinfoIsTopLevel_hint_cantTell',
212
+ params: {}
213
+ },
214
+ data: {
215
+ details: { reasonCode: 'LANDMARK_CONTENTINFO_NOT_TOP_LEVEL' }
216
+ }
217
+ })
218
+ );
225
219
  }
226
220
 
227
221
  if (occurrences.length) {
@@ -192,23 +192,20 @@ function runInPage(ctx) {
192
192
  for (const el of mains) {
193
193
  if (!hasLandmarkAncestor(el)) continue;
194
194
 
195
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
196
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
197
-
198
- occurrences.push({
199
- selector: stableSelector,
200
- html,
201
- summary: 'This main landmark is nested inside another landmark region.',
202
- hint: 'Move the main landmark (<main>/role="main") so it is not contained by another landmark; main should be a top-level region of the page.',
203
- i18n: {
204
- summaryKey: 'landmarkMainIsTopLevel_summary_cantTell',
205
- hintKey: 'landmarkMainIsTopLevel_hint_cantTell',
206
- params: {}
207
- },
208
- data: {
209
- details: { reasonCode: 'LANDMARK_MAIN_NOT_TOP_LEVEL' }
210
- }
211
- });
195
+ occurrences.push(
196
+ helpers.reportOccurrence(el, {
197
+ summary: 'This main landmark is nested inside another landmark region.',
198
+ hint: 'Move the main landmark (<main>/role="main") so it is not contained by another landmark; main should be a top-level region of the page.',
199
+ i18n: {
200
+ summaryKey: 'landmarkMainIsTopLevel_summary_cantTell',
201
+ hintKey: 'landmarkMainIsTopLevel_hint_cantTell',
202
+ params: {}
203
+ },
204
+ data: {
205
+ details: { reasonCode: 'LANDMARK_MAIN_NOT_TOP_LEVEL' }
206
+ }
207
+ })
208
+ );
212
209
  }
213
210
 
214
211
  if (occurrences.length) {
@@ -171,12 +171,7 @@ function runInPage(ctx) {
171
171
  }
172
172
 
173
173
  const occurrences = banners.map((el) => {
174
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
175
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
176
-
177
- return {
178
- selector: stableSelector,
179
- html,
174
+ return helpers.reportOccurrence(el, {
180
175
  summary: 'This page has more than one banner landmark.',
181
176
  hint: 'Keep only one banner landmark (header/role="banner") per page.',
182
177
  i18n: {
@@ -187,7 +182,7 @@ function runInPage(ctx) {
187
182
  data: {
188
183
  details: { reasonCode: 'LANDMARK_DUPLICATE_BANNER', count: banners.length }
189
184
  }
190
- };
185
+ });
191
186
  });
192
187
 
193
188
  return {
@@ -163,12 +163,7 @@ function runInPage(ctx) {
163
163
  }
164
164
 
165
165
  const occurrences = contentinfos.map((el) => {
166
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
167
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
168
-
169
- return {
170
- selector: stableSelector,
171
- html,
166
+ return helpers.reportOccurrence(el, {
172
167
  summary: 'This page has more than one contentinfo landmark.',
173
168
  hint: 'Keep only one contentinfo landmark (footer/role="contentinfo") per page.',
174
169
  i18n: {
@@ -179,7 +174,7 @@ function runInPage(ctx) {
179
174
  data: {
180
175
  details: { reasonCode: 'LANDMARK_DUPLICATE_CONTENTINFO', count: contentinfos.length }
181
176
  }
182
- };
177
+ });
183
178
  });
184
179
 
185
180
  return {
@@ -108,12 +108,7 @@ function runInPage(ctx) {
108
108
  }
109
109
 
110
110
  const occurrences = mains.map((el) => {
111
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
112
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
113
-
114
- return {
115
- selector: stableSelector,
116
- html,
111
+ return helpers.reportOccurrence(el, {
117
112
  summary: 'This page has more than one main landmark.',
118
113
  hint: 'Keep only one main landmark (<main>/role="main") per page.',
119
114
  i18n: {
@@ -124,7 +119,7 @@ function runInPage(ctx) {
124
119
  data: {
125
120
  details: { reasonCode: 'LANDMARK_DUPLICATE_MAIN', count: mains.length }
126
121
  }
127
- };
122
+ });
128
123
  });
129
124
 
130
125
  return {
@@ -123,19 +123,12 @@ function runInPage(ctx) {
123
123
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
124
124
  }
125
125
 
126
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(body) : 'body';
127
- const html = helpers.getOuterHtmlSnippet
128
- ? helpers.getOuterHtmlSnippet(body)
129
- : (body.outerHTML || '').slice(0, 200);
130
-
131
126
  return {
132
127
  ruleId: rule.ruleId,
133
128
  outcome: 'cantTell',
134
129
  severity: rule.defaultSeverity || 'minor',
135
130
  occurrences: [
136
- {
137
- selector: stableSelector,
138
- html,
131
+ helpers.reportOccurrence(body, {
139
132
  summary: 'This page has no main landmark.',
140
133
  hint: 'Add a main landmark (<main> or role="main") around the page\'s primary content.',
141
134
  i18n: {
@@ -146,7 +139,7 @@ function runInPage(ctx) {
146
139
  data: {
147
140
  details: { reasonCode: 'LANDMARK_MAIN_MISSING' }
148
141
  }
149
- }
142
+ })
150
143
  ]
151
144
  };
152
145
  }
@@ -219,34 +219,29 @@ function runInPage(ctx) {
219
219
  if (group.length <= 1) continue;
220
220
 
221
221
  for (const { el } of group) {
222
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
223
- const html = helpers.getOuterHtmlSnippet
224
- ? helpers.getOuterHtmlSnippet(el)
225
- : el.outerHTML || '';
226
-
227
- occurrences.push({
228
- selector: stableSelector,
229
- html,
230
- summary: normalizedName
231
- ? `This ${role} landmark shares its accessible name with another ${role} landmark.`
232
- : `This ${role} landmark has no accessible name, and more than one unnamed ${role} landmark exists on this page.`,
233
- hint: `Give each ${role} landmark a distinct name via aria-label or aria-labelledby.`,
234
- i18n: {
235
- summaryKey: normalizedName
236
- ? 'landmarkUnique_summary_cantTell_duplicateName'
237
- : 'landmarkUnique_summary_cantTell_bothUnnamed',
238
- hintKey: 'landmarkUnique_hint_cantTell',
239
- params: { role }
240
- },
241
- data: {
242
- details: {
243
- reasonCode: 'LANDMARK_NOT_UNIQUE',
244
- role,
245
- name: normalizedName,
246
- groupSize: group.length
222
+ occurrences.push(
223
+ helpers.reportOccurrence(el, {
224
+ summary: normalizedName
225
+ ? `This ${role} landmark shares its accessible name with another ${role} landmark.`
226
+ : `This ${role} landmark has no accessible name, and more than one unnamed ${role} landmark exists on this page.`,
227
+ hint: `Give each ${role} landmark a distinct name via aria-label or aria-labelledby.`,
228
+ i18n: {
229
+ summaryKey: normalizedName
230
+ ? 'landmarkUnique_summary_cantTell_duplicateName'
231
+ : 'landmarkUnique_summary_cantTell_bothUnnamed',
232
+ hintKey: 'landmarkUnique_hint_cantTell',
233
+ params: { role }
234
+ },
235
+ data: {
236
+ details: {
237
+ reasonCode: 'LANDMARK_NOT_UNIQUE',
238
+ role,
239
+ name: normalizedName,
240
+ groupSize: group.length
241
+ }
247
242
  }
248
- }
249
- });
243
+ })
244
+ );
250
245
  }
251
246
  }
252
247
  }
@@ -133,27 +133,25 @@ function runInPage(ctx) {
133
133
 
134
134
  if (!GENERIC_LINK_TEXT.has(normalized)) continue;
135
135
 
136
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
137
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
138
136
  const eligInfo = helpers.getEligibilityInfo
139
137
  ? helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' })
140
138
  : null;
141
139
 
142
- occurrences.push({
143
- selector: stableSelector,
144
- html,
145
- summary: `This link's accessible name ("${rawName.trim()}") is a generic, non-descriptive phrase.`,
146
- hint: 'Make the link text itself describe its destination/purpose (e.g. "Download the 2026 pricing guide" instead of "Download"), or confirm the surrounding context already makes the purpose clear.',
147
- i18n: {
148
- summaryKey: 'linkNameQuality_summary_cantTell',
149
- hintKey: 'linkNameQuality_hint_cantTell',
150
- params: { name: rawName.trim() }
151
- },
152
- data: {
153
- details: { reasonCode: 'GENERIC_LINK_TEXT', normalizedName: normalized },
154
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
155
- }
156
- });
140
+ occurrences.push(
141
+ helpers.reportOccurrence(el, {
142
+ summary: `This link's accessible name ("${rawName.trim()}") is a generic, non-descriptive phrase.`,
143
+ hint: 'Make the link text itself describe its destination/purpose (e.g. "Download the 2026 pricing guide" instead of "Download"), or confirm the surrounding context already makes the purpose clear.',
144
+ i18n: {
145
+ summaryKey: 'linkNameQuality_summary_cantTell',
146
+ hintKey: 'linkNameQuality_hint_cantTell',
147
+ params: { name: rawName.trim() }
148
+ },
149
+ data: {
150
+ details: { reasonCode: 'GENERIC_LINK_TEXT', normalizedName: normalized },
151
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
152
+ }
153
+ })
154
+ );
157
155
  }
158
156
 
159
157
  if (applicableCount === 0) {
@@ -104,23 +104,20 @@ function runInPage(ctx) {
104
104
 
105
105
  if (!reasons.length) continue;
106
106
 
107
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
108
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
109
-
110
- occurrences.push({
111
- selector: stableSelector,
112
- html,
113
- summary: 'This viewport meta tag restricts zoom below the 500% best-practice target.',
114
- hint: 'Remove user-scalable=no and raise maximum-scale to at least 5 (500%) if possible.',
115
- i18n: {
116
- summaryKey: 'metaViewportLarge_summary_cantTell',
117
- hintKey: 'metaViewportLarge_hint_cantTell',
118
- params: { reasons: reasons.join(', ') }
119
- },
120
- data: {
121
- details: { reasonCode: 'VIEWPORT_ZOOM_BELOW_500', reasons }
122
- }
123
- });
107
+ occurrences.push(
108
+ helpers.reportOccurrence(el, {
109
+ summary: 'This viewport meta tag restricts zoom below the 500% best-practice target.',
110
+ hint: 'Remove user-scalable=no and raise maximum-scale to at least 5 (500%) if possible.',
111
+ i18n: {
112
+ summaryKey: 'metaViewportLarge_summary_cantTell',
113
+ hintKey: 'metaViewportLarge_hint_cantTell',
114
+ params: { reasons: reasons.join(', ') }
115
+ },
116
+ data: {
117
+ details: { reasonCode: 'VIEWPORT_ZOOM_BELOW_500', reasons }
118
+ }
119
+ })
120
+ );
124
121
  }
125
122
 
126
123
  if (applicableCount === 0) {
@@ -117,30 +117,28 @@ function runInPage(ctx) {
117
117
  const hasKeyboardEquiv = KEYBOARD_EQUIV_ATTRS.some((a) => trim(el.getAttribute(a)));
118
118
  if (hasKeyboardEquiv) continue;
119
119
 
120
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
121
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
122
120
  const eligInfo = helpers.getEligibilityInfo
123
121
  ? helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' })
124
122
  : null;
125
123
 
126
- occurrences.push({
127
- selector: stableSelector,
128
- html,
129
- summary: `This element has ${presentMouseAttrs.join(', ')} but no keyboard-reachable equivalent handler.`,
130
- hint: 'Add onkeydown/onkeyup/onkeypress (or onfocus/onblur for hover-triggered behavior) so this functionality is also reachable by keyboard.',
131
- i18n: {
132
- summaryKey: 'mouseOnlyEventHandlers_summary_cantTell',
133
- hintKey: 'mouseOnlyEventHandlers_hint_cantTell',
134
- params: { attrs: presentMouseAttrs.join(', ') }
135
- },
136
- data: {
137
- details: {
138
- reasonCode: 'MOUSE_ONLY_HANDLER_NO_KEYBOARD_EQUIVALENT',
139
- mouseAttrs: presentMouseAttrs
124
+ occurrences.push(
125
+ helpers.reportOccurrence(el, {
126
+ summary: `This element has ${presentMouseAttrs.join(', ')} but no keyboard-reachable equivalent handler.`,
127
+ hint: 'Add onkeydown/onkeyup/onkeypress (or onfocus/onblur for hover-triggered behavior) so this functionality is also reachable by keyboard.',
128
+ i18n: {
129
+ summaryKey: 'mouseOnlyEventHandlers_summary_cantTell',
130
+ hintKey: 'mouseOnlyEventHandlers_hint_cantTell',
131
+ params: { attrs: presentMouseAttrs.join(', ') }
140
132
  },
141
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
142
- }
143
- });
133
+ data: {
134
+ details: {
135
+ reasonCode: 'MOUSE_ONLY_HANDLER_NO_KEYBOARD_EQUIVALENT',
136
+ mouseAttrs: presentMouseAttrs
137
+ },
138
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
139
+ }
140
+ })
141
+ );
144
142
  }
145
143
 
146
144
  if (applicableCount === 0) {
@@ -126,19 +126,12 @@ function runInPage(ctx) {
126
126
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
127
127
  }
128
128
 
129
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(body) : 'body';
130
- const html = helpers.getOuterHtmlSnippet
131
- ? helpers.getOuterHtmlSnippet(body)
132
- : (body.outerHTML || '').slice(0, 200);
133
-
134
129
  return {
135
130
  ruleId: rule.ruleId,
136
131
  outcome: 'cantTell',
137
132
  severity: rule.defaultSeverity || 'minor',
138
133
  occurrences: [
139
- {
140
- selector: stableSelector,
141
- html,
134
+ helpers.reportOccurrence(body, {
142
135
  summary: 'This page has no level-one heading.',
143
136
  hint: 'Add a level-one heading (<h1> or role="heading" aria-level="1") that identifies the page\'s main content.',
144
137
  i18n: {
@@ -149,7 +142,7 @@ function runInPage(ctx) {
149
142
  data: {
150
143
  details: { reasonCode: 'HEADING_ONE_MISSING' }
151
144
  }
152
- }
145
+ })
153
146
  ]
154
147
  };
155
148
  }
@@ -171,28 +171,26 @@ function runInPage(ctx) {
171
171
  String(el.getAttribute('role') || '')
172
172
  .trim()
173
173
  .toLowerCase() || 'presentation';
174
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
175
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
176
-
177
- occurrences.push({
178
- selector: stableSelector,
179
- html,
180
- summary: `This role="${role}" element also has a conflicting condition (${parts.join(', ')}), which restores its implicit role and cancels the presentational intent.`,
181
- hint: 'Remove the conflicting naming attribute(s) and/or focusability (tabindex/native) if the element should stay presentational, or remove role="presentation"/"none" if it should be exposed to assistive technology.',
182
- i18n: {
183
- summaryKey: 'presentationRoleConflict_summary_cantTell',
184
- hintKey: 'presentationRoleConflict_hint_cantTell',
185
- params: { role, attrs: parts.join(', ') }
186
- },
187
- data: {
188
- details: {
189
- reasonCode: 'PRESENTATION_ROLE_CONFLICT',
190
- role,
191
- conflictingAttrs: present,
192
- focusable: isFocusable
174
+
175
+ occurrences.push(
176
+ helpers.reportOccurrence(el, {
177
+ summary: `This role="${role}" element also has a conflicting condition (${parts.join(', ')}), which restores its implicit role and cancels the presentational intent.`,
178
+ hint: 'Remove the conflicting naming attribute(s) and/or focusability (tabindex/native) if the element should stay presentational, or remove role="presentation"/"none" if it should be exposed to assistive technology.',
179
+ i18n: {
180
+ summaryKey: 'presentationRoleConflict_summary_cantTell',
181
+ hintKey: 'presentationRoleConflict_hint_cantTell',
182
+ params: { role, attrs: parts.join(', ') }
183
+ },
184
+ data: {
185
+ details: {
186
+ reasonCode: 'PRESENTATION_ROLE_CONFLICT',
187
+ role,
188
+ conflictingAttrs: present,
189
+ focusable: isFocusable
190
+ }
193
191
  }
194
- }
195
- });
192
+ })
193
+ );
196
194
  }
197
195
 
198
196
  if (applicableCount === 0) {
@@ -352,14 +352,11 @@ function runInPage(ctx) {
352
352
  }
353
353
  }
354
354
 
355
+ // Report the element itself: without a node reference the engine re-finds
356
+ // each one with document.querySelector to build its structuralPath.
355
357
  const occurrences = collapsed.map((el) => {
356
358
  const tag = el.tagName ? lower(el.tagName) : '';
357
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
358
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
359
-
360
- return {
361
- selector: stableSelector,
362
- html,
359
+ const partial = {
363
360
  summary: 'This content is not contained within a landmark region.',
364
361
  hint: 'Move this content inside a landmark region (main, nav, aside, a labeled section, etc.).',
365
362
  i18n: {
@@ -371,6 +368,16 @@ function runInPage(ctx) {
371
368
  details: { reasonCode: 'CONTENT_OUTSIDE_LANDMARK', element: tag }
372
369
  }
373
370
  };
371
+
372
+ if (helpers && typeof helpers.reportOccurrence === 'function') {
373
+ return helpers.reportOccurrence(el, partial);
374
+ }
375
+
376
+ return {
377
+ selector: helpers.buildSelector ? helpers.buildSelector(el) : 'html',
378
+ html: helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '',
379
+ ...partial
380
+ };
374
381
  });
375
382
 
376
383
  if (occurrences.length === 0) {
@@ -61,23 +61,20 @@ function runInPage(ctx) {
61
61
 
62
62
  if (VALID_SCOPES.has(raw.toLowerCase())) continue;
63
63
 
64
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
65
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
66
-
67
- occurrences.push({
68
- selector: stableSelector,
69
- html,
70
- summary: 'This scope attribute value is not recognized.',
71
- hint: 'Use one of row, col, rowgroup, or colgroup for the scope attribute.',
72
- i18n: {
73
- summaryKey: 'scopeAttrValid_summary_cantTell',
74
- hintKey: 'scopeAttrValid_hint_cantTell',
75
- params: { value: raw }
76
- },
77
- data: {
78
- details: { reasonCode: 'SCOPE_ATTR_INVALID', value: raw }
79
- }
80
- });
64
+ occurrences.push(
65
+ helpers.reportOccurrence(el, {
66
+ summary: 'This scope attribute value is not recognized.',
67
+ hint: 'Use one of row, col, rowgroup, or colgroup for the scope attribute.',
68
+ i18n: {
69
+ summaryKey: 'scopeAttrValid_summary_cantTell',
70
+ hintKey: 'scopeAttrValid_hint_cantTell',
71
+ params: { value: raw }
72
+ },
73
+ data: {
74
+ details: { reasonCode: 'SCOPE_ATTR_INVALID', value: raw }
75
+ }
76
+ })
77
+ );
81
78
  }
82
79
 
83
80
  if (applicableCount === 0) {