@surea11y/core 1.4.1 → 1.6.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 (151) hide show
  1. package/CHANGELOG.md +212 -128
  2. package/README.md +46 -9
  3. package/docs/ACT_RULE_MAPPING.md +243 -0
  4. package/docs/API_STABILITY.md +4 -4
  5. package/docs/BINDING_AUTHORS_GUIDE.md +3 -3
  6. package/docs/DESIGN_CHALLENGES.md +301 -0
  7. package/docs/ENGINE_OPTIONS.md +30 -14
  8. package/docs/I18N.md +176 -20
  9. package/docs/INTEGRATION.md +29 -7
  10. package/docs/LIMITATIONS.md +6 -4
  11. package/docs/OUTPUT_SCHEMA.md +13 -3
  12. package/docs/REPORT.md +3 -1
  13. package/docs/RULE_AUTHORING.md +104 -23
  14. package/docs/RULE_CATALOG.md +1878 -169
  15. package/docs/RULE_TAXONOMY.md +2 -2
  16. package/docs/TROUBLESHOOTING.md +4 -4
  17. package/docs/WCAG_CONFORMANCE.md +25 -9
  18. package/package.json +8 -7
  19. package/src/baseline.js +3 -3
  20. package/src/checks/automatic/area-alt-present.js +2 -2
  21. package/src/checks/automatic/aria-allowed-attr.js +95 -40
  22. package/src/checks/automatic/aria-allowed-role.js +16 -18
  23. package/src/checks/automatic/aria-braille-equivalent.js +19 -21
  24. package/src/checks/automatic/aria-conditional-attr.js +22 -24
  25. package/src/checks/automatic/aria-deprecated-role.js +63 -50
  26. package/src/checks/automatic/aria-hidden-body.js +4 -11
  27. package/src/checks/automatic/aria-hidden-focus.js +104 -23
  28. package/src/checks/automatic/aria-prohibited-attr.js +71 -72
  29. package/src/checks/automatic/aria-prohibited-children.js +154 -61
  30. package/src/checks/automatic/aria-required-attr.js +74 -29
  31. package/src/checks/automatic/aria-required-children.js +38 -34
  32. package/src/checks/automatic/aria-required-parent.js +78 -29
  33. package/src/checks/automatic/aria-role-name-present.js +36 -22
  34. package/src/checks/automatic/aria-roles-valid.js +37 -23
  35. package/src/checks/automatic/aria-valid-attr-value.js +33 -33
  36. package/src/checks/automatic/aria-valid-attr.js +15 -18
  37. package/src/checks/automatic/autocomplete-valid.js +17 -19
  38. package/src/checks/automatic/avoid-inline-spacing.js +14 -16
  39. package/src/checks/automatic/binary-control-name-present.js +46 -26
  40. package/src/checks/automatic/button-name-present.js +115 -34
  41. package/src/checks/automatic/combobox-name-present.js +40 -22
  42. package/src/checks/automatic/contrast-computable.js +32 -0
  43. package/src/checks/automatic/contrast-enhanced.js +21 -1
  44. package/src/checks/automatic/contrast-minimum.js +21 -1
  45. package/src/checks/automatic/css-orientation-lock.js +118 -41
  46. package/src/checks/automatic/definition-list-children-valid.js +25 -29
  47. package/src/checks/automatic/deprecated-elements-not-used.js +15 -17
  48. package/src/checks/automatic/dialog-name-present.js +36 -20
  49. package/src/checks/automatic/dlitem-parent-valid.js +15 -17
  50. package/src/checks/automatic/duplicate-id-aria.js +50 -40
  51. package/src/checks/automatic/duplicate-id.js +198 -0
  52. package/src/checks/automatic/embed-text-alternative-present.js +2 -2
  53. package/src/checks/automatic/form-control-programmatic-label-present.js +19 -2
  54. package/src/checks/automatic/form-control-single-label.js +39 -41
  55. package/src/checks/automatic/html-xml-lang-mismatch.js +2 -9
  56. package/src/checks/automatic/iframe-focusable-content.js +92 -38
  57. package/src/checks/automatic/iframe-name-present.js +53 -21
  58. package/src/checks/automatic/iframe-title-unique.js +19 -24
  59. package/src/checks/automatic/img-alt-present.js +12 -4
  60. package/src/checks/automatic/label-in-name.js +198 -49
  61. package/src/checks/automatic/link-in-text-block.js +29 -31
  62. package/src/checks/automatic/link-name-present.js +47 -31
  63. package/src/checks/automatic/list-children-valid.js +21 -23
  64. package/src/checks/automatic/listbox-name-present.js +42 -24
  65. package/src/checks/automatic/listitem-parent-valid.js +18 -21
  66. package/src/checks/automatic/menuitem-name-present.js +36 -20
  67. package/src/checks/automatic/meta-refresh-no-exceptions.js +39 -31
  68. package/src/checks/automatic/meta-refresh-timing-absent.js +29 -25
  69. package/src/checks/automatic/meta-viewport-zoom-enabled.js +14 -17
  70. package/src/checks/automatic/meter-name-present.js +38 -21
  71. package/src/checks/automatic/nested-interactive-controls-absent.js +22 -24
  72. package/src/checks/automatic/option-name-present.js +39 -22
  73. package/src/checks/automatic/page-title-present.js +21 -3
  74. package/src/checks/automatic/presentational-children-focusable-absent.js +330 -0
  75. package/src/checks/automatic/progressbar-name-present.js +41 -24
  76. package/src/checks/automatic/role-img-alt-present.js +64 -16
  77. package/src/checks/automatic/searchbox-name-present.js +46 -24
  78. package/src/checks/automatic/server-side-image-map-absent.js +16 -19
  79. package/src/checks/automatic/slider-name-present.js +42 -23
  80. package/src/checks/automatic/spinbutton-name-present.js +46 -24
  81. package/src/checks/automatic/summary-name-present.js +34 -20
  82. package/src/checks/automatic/svg-image-text-alternative-present.js +1 -1
  83. package/src/checks/automatic/svg-text-alternative-present.js +13 -10
  84. package/src/checks/automatic/tab-name-present.js +37 -20
  85. package/src/checks/automatic/table-headers-attr-valid.js +57 -24
  86. package/src/checks/automatic/table-th-has-data-cells.js +76 -24
  87. package/src/checks/automatic/target-size-minimum.js +172 -131
  88. package/src/checks/automatic/td-has-header.js +20 -25
  89. package/src/checks/automatic/textbox-name-present.js +42 -24
  90. package/src/checks/automatic/tooltip-name-present.js +37 -20
  91. package/src/checks/automatic/treeitem-name-present.js +39 -22
  92. package/src/checks/automatic/valid-lang.js +107 -24
  93. package/src/checks/automatic/video-poster-text-alternative-present.js +1 -1
  94. package/src/checks/manual/accesskeys-manual.js +21 -22
  95. package/src/checks/manual/area-alt-decorative-manual.js +7 -0
  96. package/src/checks/manual/area-alt-quality-manual.js +6 -0
  97. package/src/checks/manual/aria-checked-state-mismatch-manual.js +23 -26
  98. package/src/checks/manual/aria-text-manual.js +4 -4
  99. package/src/checks/manual/bypass-blocks-present-manual.js +48 -38
  100. package/src/checks/manual/canvas-text-alternative-quality-manual.js +8 -0
  101. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +444 -0
  102. package/src/checks/manual/embed-text-alternative-quality-manual.js +8 -0
  103. package/src/checks/manual/empty-heading-manual.js +73 -28
  104. package/src/checks/manual/empty-table-header-manual.js +33 -36
  105. package/src/checks/manual/focus-order-semantics-manual.js +15 -15
  106. package/src/checks/manual/form-control-label-quality-manual.js +453 -0
  107. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +4 -11
  108. package/src/checks/manual/heading-order-manual.js +20 -25
  109. package/src/checks/manual/heading-quality-manual.js +338 -0
  110. package/src/checks/manual/identical-links-same-purpose-manual.js +73 -12
  111. package/src/checks/manual/image-redundant-alt-manual.js +18 -21
  112. package/src/checks/manual/img-alt-decorative-manual.js +211 -52
  113. package/src/checks/manual/img-alt-quality-manual.js +7 -0
  114. package/src/checks/manual/input-image-alt-decorative-manual.js +8 -0
  115. package/src/checks/manual/input-image-alt-quality-manual.js +5 -0
  116. package/src/checks/manual/label-title-only-manual.js +19 -21
  117. package/src/checks/manual/landmark-banner-is-top-level-manual.js +21 -24
  118. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +23 -26
  119. package/src/checks/manual/landmark-main-is-top-level-manual.js +20 -23
  120. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +8 -13
  121. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +8 -13
  122. package/src/checks/manual/landmark-no-duplicate-main-manual.js +4 -9
  123. package/src/checks/manual/landmark-one-main-manual.js +8 -15
  124. package/src/checks/manual/landmark-unique-manual.js +31 -36
  125. package/src/checks/manual/link-name-quality-manual.js +162 -35
  126. package/src/checks/manual/media-transcript-present-manual.js +2 -3
  127. package/src/checks/manual/meta-viewport-large-manual.js +16 -19
  128. package/src/checks/manual/mouse-only-event-handlers-manual.js +26 -28
  129. package/src/checks/manual/no-autoplay-audio-manual.js +3 -3
  130. package/src/checks/manual/object-text-alternative-quality-manual.js +8 -0
  131. package/src/checks/manual/p-as-heading-manual.js +4 -4
  132. package/src/checks/manual/page-has-heading-one-manual.js +8 -15
  133. package/src/checks/manual/page-title-patterns-manual.js +26 -3
  134. package/src/checks/manual/presentation-role-conflict-manual.js +74 -46
  135. package/src/checks/manual/region-manual.js +32 -25
  136. package/src/checks/manual/scope-attr-valid-manual.js +16 -19
  137. package/src/checks/manual/scrollable-region-focusable-manual.js +7 -7
  138. package/src/checks/manual/skip-link-manual.js +44 -52
  139. package/src/checks/manual/svg-text-alternative-quality-manual.js +9 -0
  140. package/src/checks/manual/tabindex-manual.js +16 -19
  141. package/src/checks/manual/table-duplicate-name-manual.js +16 -19
  142. package/src/checks/manual/table-fake-caption-manual.js +2 -2
  143. package/src/checks/manual/video-caption-manual.js +3 -3
  144. package/src/checks/manual-review.js +17 -1
  145. package/src/core.js +13086 -5169
  146. package/src/report.js +16 -2
  147. package/surea11y.browser.js +5665 -4219
  148. package/surea11y.i18n.de.js +22 -0
  149. package/surea11y.i18n.es.js +22 -0
  150. package/surea11y.i18n.fr.js +22 -0
  151. package/bin/surea11y-core.js +0 -20
@@ -5,20 +5,34 @@
5
5
  /**
6
6
  * @check img-alt-decorative
7
7
  * @atomic true
8
- * @summary Manual review: text alternative appropriateness (WCAG 1.1.1)
8
+ * @summary Manual review: an image/canvas/svg excluded from the accessibility tree must be purely decorative
9
9
  * @standard WCAG 2.2
10
10
  * @sc 1.1.1
11
11
  * @type manual
12
+ * @applicability
13
+ * Applies to visible <img>, <canvas> or <svg> elements excluded from the
14
+ * accessibility tree by any of: an aria-hidden ancestor-or-self, an
15
+ * explicit role="none"/"presentation" not overridden by focusability, an
16
+ * <img alt=""> (the native decorative marker, same focusability
17
+ * override), an unlabeled <svg> whose implicit role is graphics-document
18
+ * (no img/graphics-symbol role restatement, aria-name, <title>/<desc>, or
19
+ * focusability), or an unlabeled <canvas> with no explicit role at all.
20
+ * Per ACT e88epe, an element is skipped entirely when any ancestor
21
+ * already has an author-supplied name (aria-label, aria-labelledby,
22
+ * title, or an associated <label>), that ancestor's name is what
23
+ * matters, not this element's exclusion (the common real case: an
24
+ * icon-only button already named via aria-label).
12
25
  * @expectation
13
- * Human review is required to confirm that the provided text alternative is accurate and appropriate.
26
+ * Human review is required to confirm the excluded element is purely
27
+ * decorative and conveys no information a user would otherwise miss.
14
28
  */
15
29
 
16
30
  const id = 'img-alt-decorative';
17
31
 
18
32
  const meta = {
19
- title: '<img> with alt="" must be decorative (manual review)',
33
+ title: 'Excluded <img>/<canvas>/<svg> must be decorative (manual review)',
20
34
  description:
21
- 'Flags <img> elements with empty alt for human review that they are purely decorative.',
35
+ 'Flags <img>, <canvas> and <svg> elements excluded from the accessibility tree (aria-hidden, role="none"/"presentation", empty alt, or an unlabeled svg/canvas) for human review that they are purely decorative.',
22
36
  i18n: {
23
37
  titleKey: 'img_altDecorative_title',
24
38
  descriptionKey: 'img_altDecorative_description'
@@ -65,56 +79,207 @@ function runInPage(ctx) {
65
79
  }
66
80
  };
67
81
 
68
- const isAccTreeEligible =
69
- helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
82
+ const isIncludedInAccessibilityTree =
83
+ helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
84
+ ? helpers.isIncludedInAccessibilityTree
85
+ : helpers && typeof helpers.isAccTreeEligible === 'function'
86
+ ? helpers.isAccTreeEligible
87
+ : null;
70
88
 
71
89
  const getFocusableInfo =
72
90
  helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
73
91
 
74
- function isRolePresentationExcluded(el) {
75
- const role = (() => {
92
+ const getAccessibleNameInfo =
93
+ helpers && typeof helpers.getAccessibleNameInfo === 'function'
94
+ ? helpers.getAccessibleNameInfo
95
+ : null;
96
+
97
+ const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
98
+
99
+ function trim(v) {
100
+ try {
101
+ return (v == null ? '' : String(v)).trim();
102
+ } catch {
103
+ return '';
104
+ }
105
+ }
106
+
107
+ function getExplicitRole(el) {
108
+ if (ariaHelpers && typeof ariaHelpers.getExplicitRole === 'function') {
76
109
  try {
77
- return String(el.getAttribute('role') || '')
78
- .trim()
79
- .toLowerCase();
110
+ return ariaHelpers.getExplicitRole(el) || '';
80
111
  } catch {
81
112
  return '';
82
113
  }
83
- })();
84
- if (role !== 'presentation' && role !== 'none') return false;
114
+ }
115
+ try {
116
+ const raw = trim(el.getAttribute('role'));
117
+ return raw ? raw.split(/\s+/)[0].toLowerCase() : '';
118
+ } catch {
119
+ return '';
120
+ }
121
+ }
85
122
 
86
- // Exclude only when NOT focusable (mirrors img-alt-present policy)
87
- let focusable;
123
+ function isFocusable(el) {
88
124
  if (getFocusableInfo) {
89
- const fi = (() => {
90
- try {
91
- return getFocusableInfo(el, ctx);
92
- } catch {
93
- return null;
94
- }
95
- })();
96
- focusable = !!(fi && fi.focusable);
97
- } else {
125
+ try {
126
+ const fi = getFocusableInfo(el, ctx);
127
+ return !!(fi && fi.focusable);
128
+ } catch {
129
+ return false;
130
+ }
131
+ }
132
+ try {
98
133
  const tabindex = el.getAttribute('tabindex');
99
- focusable =
134
+ return (
100
135
  tabindex != null &&
101
136
  String(tabindex).trim() !== '' &&
102
- !Number.isNaN(Number(String(tabindex).trim()));
137
+ !Number.isNaN(Number(String(tabindex).trim()))
138
+ );
139
+ } catch {
140
+ return false;
141
+ }
142
+ }
143
+
144
+ function isDomVisible(el) {
145
+ if (!el) return false;
146
+ if (helpers && helpers.isDomVisibleEligible)
147
+ return !!helpers.isDomVisibleEligible(el, ctx, { targetSet: 'dom' }).eligible;
148
+ if (helpers && helpers.getEligibilityInfo)
149
+ return !!helpers.getEligibilityInfo(el, ctx, { targetSet: 'dom' }).eligible;
150
+ return true;
151
+ }
152
+
153
+ // Same offscreen-hint pattern as avoid-inline-spacing.js.
154
+ function isOffscreen(el) {
155
+ if (!helpers || typeof helpers.getVisibilityHintsInfo !== 'function') return false;
156
+ try {
157
+ const info = helpers.getVisibilityHintsInfo(el, ctx, {});
158
+ return !!(info && Array.isArray(info.hints) && info.hints.indexOf('offscreen') !== -1);
159
+ } catch {
160
+ return false;
103
161
  }
104
- return !focusable;
162
+ }
163
+
164
+ function hasAriaNamingAttr(el) {
165
+ try {
166
+ return el.getAttribute('aria-label') != null || el.getAttribute('aria-labelledby') != null;
167
+ } catch {
168
+ return false;
169
+ }
170
+ }
171
+
172
+ // Same first-child <title>/<desc> convention as svg-text-alternative-present.js.
173
+ function hasNonEmptyFirstChildTitleOrDesc(svg) {
174
+ try {
175
+ const first = svg.firstElementChild;
176
+ const tn = first ? (first.localName || first.tagName || '').toLowerCase() : '';
177
+ if (tn === 'title' || tn === 'desc') return !!trim(first.textContent);
178
+ } catch {
179
+ // ignore
180
+ }
181
+ return false;
182
+ }
183
+
184
+ // General exclusion: aria-hidden, inert, or otherwise not included per the
185
+ // shared eligibility model. Visibility is checked separately beforehand,
186
+ // so a display:none/hidden element never reaches this path.
187
+ function isGenerallyExcluded(el) {
188
+ if (!isIncludedInAccessibilityTree) return false;
189
+ try {
190
+ const r = isIncludedInAccessibilityTree(el, ctx);
191
+ if (typeof r === 'boolean') return !r;
192
+ return !(r && r.eligible !== false);
193
+ } catch {
194
+ return false;
195
+ }
196
+ }
197
+
198
+ // Presentational exclusion: explicit role="none"/"presentation", or (img
199
+ // only) the native alt="" marker. Both are overridden by focusability, per
200
+ // ARIA conflict resolution (a focusable element is never presentational).
201
+ function isPresentationallyExcluded(el, tag) {
202
+ const role = getExplicitRole(el);
203
+ let presentational = role === 'presentation' || role === 'none';
204
+ if (!presentational && tag === 'img') {
205
+ const alt = el.getAttribute('alt');
206
+ presentational = alt != null && trim(alt) === '';
207
+ }
208
+ if (!presentational) return false;
209
+ return !isFocusable(el);
210
+ }
211
+
212
+ // ACT e88epe's "ignored svg": an implicit graphics-document role (no
213
+ // explicit role, or role explicitly restated as graphics-document) with
214
+ // no accessible name and not focusable. An svg explicitly given role="img"
215
+ // /"graphics-symbol", an aria-name, a <title>/<desc>, or a tab stop is
216
+ // "included", a naming question for svg-text-alternative-present, not
217
+ // this rule's "is it decorative" question.
218
+ function isIgnoredSvg(el) {
219
+ const role = getExplicitRole(el);
220
+ if (role && role !== 'graphics-document') return false;
221
+ if (hasAriaNamingAttr(el)) return false;
222
+ if (hasNonEmptyFirstChildTitleOrDesc(el)) return false;
223
+ if (isFocusable(el)) return false;
224
+ return true;
225
+ }
226
+
227
+ // ACT e88epe's "ignored canvas": no explicit role at all and no
228
+ // accessible name. Canvas fallback content as a naming mechanism is not
229
+ // modeled here (a separate, narrower question than this rule needs to
230
+ // settle; see docs/DESIGN_CHALLENGES.md).
231
+ function isIgnoredCanvas(el) {
232
+ const role = getExplicitRole(el);
233
+ if (role) return false;
234
+ if (hasAriaNamingAttr(el)) return false;
235
+ return true;
236
+ }
237
+
238
+ const AUTHOR_NAME_MECHANISMS = new Set(['aria-label', 'aria-labelledby', 'title', 'label']);
239
+
240
+ // ACT e88epe's own exception: never applies under an ancestor already
241
+ // named by the author. The common real case is an icon-only button
242
+ // (<button aria-label="Close"><svg>...</svg></button>) that already has a
243
+ // correct name from the button itself. Whether the svg "is decorative"
244
+ // is moot there, and flagging it would just be noise on ordinary icon usage.
245
+ function hasAncestorNamedFromAuthor(el) {
246
+ if (!getAccessibleNameInfo) return false;
247
+ const getComposedParent =
248
+ helpers && typeof helpers.composedParent === 'function'
249
+ ? helpers.composedParent
250
+ : (n) => (n && n.parentElement ? n.parentElement : null);
251
+
252
+ let cur = getComposedParent(el);
253
+ let guard = 0;
254
+ while (cur && guard++ < 200) {
255
+ if (cur.nodeType === 1) {
256
+ try {
257
+ const info = getAccessibleNameInfo(cur, ctx, { maxRefs: 8 });
258
+ if (
259
+ info &&
260
+ info.present &&
261
+ trim(info.value) &&
262
+ AUTHOR_NAME_MECHANISMS.has(info.mechanism)
263
+ )
264
+ return true;
265
+ } catch {
266
+ // ignore
267
+ }
268
+ }
269
+ cur = getComposedParent(cur);
270
+ }
271
+ return false;
105
272
  }
106
273
 
107
274
  const els = (() => {
108
- // Only likely candidates:
109
- // - alt="" (exact)
110
- // - alt that starts/ends with space (to catch whitespace-only like " ")
111
- const sel = 'img[alt=""], img[alt^=" "], img[alt$=" "]';
275
+ const sel = 'img, canvas, svg';
112
276
  try {
113
277
  return Array.from((queryAllSmart ? queryAllSmart(sel) : queryAll(sel)) || []);
114
278
  } catch {
115
279
  return queryAll(sel);
116
280
  }
117
281
  })();
282
+
118
283
  const uniqueEls = [];
119
284
  const seen = new Set();
120
285
  for (const el of els) {
@@ -124,7 +289,7 @@ function runInPage(ctx) {
124
289
  }
125
290
  }
126
291
 
127
- if (!els.length) {
292
+ if (!uniqueEls.length) {
128
293
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
129
294
  }
130
295
 
@@ -132,39 +297,33 @@ function runInPage(ctx) {
132
297
  let applicableCount = 0;
133
298
 
134
299
  for (const el of uniqueEls) {
135
- if (!el || !el.getAttribute) continue;
300
+ if (!el || !el.tagName) continue;
301
+ const tag = el.tagName.toLowerCase();
136
302
 
137
- if (isAccTreeEligible) {
138
- const elig = (() => {
139
- try {
140
- return isAccTreeEligible(el, ctx);
141
- } catch {
142
- return { eligible: true, reasons: [] };
143
- }
144
- })();
145
- if (elig && elig.eligible === false) continue;
146
- }
303
+ if (!isDomVisible(el)) continue;
304
+ if (isOffscreen(el)) continue;
147
305
 
148
- if (isRolePresentationExcluded(el)) continue;
306
+ let excluded = isGenerallyExcluded(el);
307
+ if (!excluded) excluded = isPresentationallyExcluded(el, tag);
308
+ if (!excluded && tag === 'svg') excluded = isIgnoredSvg(el);
309
+ if (!excluded && tag === 'canvas') excluded = isIgnoredCanvas(el);
149
310
 
150
- // Rule-specific applicability (only elements that already have a text alternative mechanism)
151
- const rawAlt = el.getAttribute('alt');
152
- if (rawAlt == null) continue;
153
- if (String(rawAlt).trim() !== '') continue;
311
+ if (!excluded) continue;
312
+ if (hasAncestorNamedFromAuthor(el)) continue;
154
313
 
155
314
  applicableCount += 1;
156
315
 
157
316
  const baseOccurrence = {
158
- summary: 'Review whether <img> is decorative (alt="").',
159
- hint: 'Confirm the image is purely decorative. If it conveys information or function, provide meaningful alt text.',
317
+ summary: `Review whether this <${tag}> is decorative.`,
318
+ hint: 'Confirm the element is purely decorative. If it conveys information or function, give it a real text alternative (or an accessible name) instead of excluding it.',
160
319
  i18n: {
161
320
  summaryKey: 'img_altDecorative_summary_cantTell',
162
321
  hintKey: 'img_altDecorative_hint_cantTell',
163
- params: { element: 'img' }
322
+ params: { element: tag }
164
323
  },
165
324
  data: {
166
325
  visibilityFilter: { targetSet: 'acc', accEligible: null, reasons: [] },
167
- details: null
326
+ details: { reasonCode: 'EXCLUDED_ELEMENT_REVIEW', element: tag }
168
327
  }
169
328
  };
170
329
 
@@ -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 <img> elements whose alt attribute is present and non-empty.
14
+ * The element must be included in the accessibility tree, and
15
+ * role="presentation"/"none" takes it out of scope unless it is focusable,
16
+ * which restores its role. An <img> with no alt at all is
17
+ * img-alt-present's failure, and one with alt="" is img-alt-decorative's
18
+ * review.
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,14 @@
9
9
  * @standard WCAG 2.2
10
10
  * @sc 1.1.1
11
11
  * @type manual
12
+ * @applicability
13
+ * Applies to <input type="image"> elements whose alt attribute is present
14
+ * but empty once trimmed, and which still carry a name from another
15
+ * source: an ARIA name resolving to non-empty text, or a title. Without
16
+ * that other name there is nothing to weigh the empty alt against, and the
17
+ * control is input-image-alt-present's failure instead. The element must
18
+ * be included in the accessibility tree, and role="presentation"/"none"
19
+ * takes it out of scope unless it is focusable, 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
  */
@@ -9,6 +9,11 @@
9
9
  * @standard WCAG 2.2
10
10
  * @sc 1.1.1
11
11
  * @type manual
12
+ * @applicability
13
+ * Applies to <input type="image"> elements whose alt attribute is present
14
+ * and non-empty: an image button whose alt is its label. The element must
15
+ * be included in the accessibility tree, and role="presentation"/"none"
16
+ * takes it out of scope unless it is focusable, which restores its role.
12
17
  * @expectation
13
18
  * Human review is required to confirm that the provided text alternative is accurate and appropriate.
14
19
  */
@@ -6,19 +6,19 @@
6
6
  * @check label-title-only
7
7
  * @atomic true
8
8
  * @summary Form controls should not rely on the title attribute as their only label
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 to labelable form controls (input, excluding
12
12
  * hidden/submit/reset/button/image; select; textarea) that have a
13
13
  * non-empty title attribute.
14
14
  * @expectation
15
- * The control also has a real label — a wrapping/associated <label>,
16
- * aria-label, or aria-labelledby — rather than depending on the title
15
+ * The control also has a real label (a wrapping/associated <label>,
16
+ * aria-label, or aria-labelledby), rather than depending on the title
17
17
  * attribute alone. A title-only tooltip is not reliably exposed by all
18
18
  * assistive technology and is not visible at all until hover/focus,
19
19
  * unlike a persistent visible label.
20
20
  * @implementation-notes
21
- * - Not WCAG-normative — authored as an advisory, cantTell-capped
21
+ * - Not WCAG-normative, authored as an advisory, cantTell-capped
22
22
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
23
23
  * header comment for the shared rationale/precedent.
24
24
  * - Distinct from form-control-programmatic-label-present (that
@@ -94,23 +94,21 @@ function runInPage(ctx) {
94
94
  if (!nameInfo || nameInfo.mechanism !== 'title') continue;
95
95
 
96
96
  const tag = el.tagName.toLowerCase();
97
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
98
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
99
-
100
- occurrences.push({
101
- selector: stableSelector,
102
- html,
103
- summary: 'This form control relies on the title attribute as its only label.',
104
- hint: 'Add a visible <label> (or aria-label/aria-labelledby) in addition to, or instead of, the title attribute.',
105
- i18n: {
106
- summaryKey: 'labelTitleOnly_summary_cantTell',
107
- hintKey: 'labelTitleOnly_hint_cantTell',
108
- params: { element: tag }
109
- },
110
- data: {
111
- details: { reasonCode: 'LABEL_TITLE_ONLY', element: tag }
112
- }
113
- });
97
+
98
+ occurrences.push(
99
+ helpers.reportOccurrence(el, {
100
+ summary: 'This form control relies on the title attribute as its only label.',
101
+ hint: 'Add a visible <label> (or aria-label/aria-labelledby) in addition to, or instead of, the title attribute.',
102
+ i18n: {
103
+ summaryKey: 'labelTitleOnly_summary_cantTell',
104
+ hintKey: 'labelTitleOnly_hint_cantTell',
105
+ params: { element: tag }
106
+ },
107
+ data: {
108
+ details: { reasonCode: 'LABEL_TITLE_ONLY', element: tag }
109
+ }
110
+ })
111
+ );
114
112
  }
115
113
 
116
114
  if (applicableCount === 0) {
@@ -6,20 +6,20 @@
6
6
  * @check landmark-banner-is-top-level
7
7
  * @atomic true
8
8
  * @summary The banner landmark must not be nested inside another landmark
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 the page contains at least one banner candidate:
12
12
  * explicit role="banner", OR a <header> with NO role attribute at all,
13
13
  * regardless of nesting (see implementation notes on why candidate
14
- * selection is deliberately unconditional).
14
+ * selection is unconditional on purpose).
15
15
  * @expectation
16
16
  * No banner candidate has an ancestor that is itself any landmark
17
17
  * region. A banner nested inside another landmark is not a top-level,
18
18
  * whole-page banner and confuses landmark-based navigation for
19
19
  * assistive technology users.
20
20
  * @implementation-notes
21
- * - Not WCAG-normative — authored as an advisory, cantTell-capped
22
- * `type: 'manual'` rule per ROADMAP.md Tier 1b and the design doc's policy model
21
+ * - Not WCAG-normative, authored as an advisory, cantTell-capped
22
+ * `type: 'manual'` rule per the design doc's policy model
23
23
  * ("Advisory / best-practice rules may exist, but must not produce
24
24
  * `fail`"). Matches the existing `page-title-patterns-manual.js`
25
25
  * precedent: deterministic DOM analysis, no human required, but
@@ -61,7 +61,7 @@ const meta = {
61
61
  function runInPage(ctx) {
62
62
  const { document, root, helpers, rule } = ctx;
63
63
 
64
- // Declared inside runInPage — see scripts/build-core.js header
64
+ // Declared inside runInPage; see scripts/build-core.js header
65
65
  // ("runInPage MUST be self-contained").
66
66
  function normalizeWs(s) {
67
67
  return String(s || '')
@@ -91,7 +91,7 @@ function runInPage(ctx) {
91
91
 
92
92
  // Delegates to the shared helpers.hasLandmarkScopingAncestor for the
93
93
  // question "does this element sit inside a sectioning-content/<main>
94
- // ancestor that suppresses its conditional implicit role" — role-aware
94
+ // ancestor that suppresses its conditional implicit role": role-aware
95
95
  // (an ancestor's bare TAG only counts when it carries no role attribute
96
96
  // at all; an explicit role="dialog"-style override no longer suppresses)
97
97
  // rather than a local tag-only copy. See that function's header comment
@@ -110,7 +110,7 @@ function runInPage(ctx) {
110
110
  if (tag === 'main') return 'main';
111
111
  if (tag === 'nav') return 'navigation';
112
112
  if (tag === 'aside') {
113
- // A named <aside> is never suppressed, even when nested — it keeps
113
+ // A named <aside> is never suppressed, even when nested. It keeps
114
114
  // "complementary" when it has an accessible name, even inside
115
115
  // sectioning content. Matches landmark-unique's precedent.
116
116
  if (!hasSectioningAncestor(el, false)) return 'complementary';
@@ -209,23 +209,20 @@ function runInPage(ctx) {
209
209
  for (const el of banners) {
210
210
  if (!hasLandmarkAncestor(el)) continue;
211
211
 
212
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
213
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
214
-
215
- occurrences.push({
216
- selector: stableSelector,
217
- html,
218
- summary: 'This banner landmark is nested inside another landmark region.',
219
- hint: 'Move the banner landmark (header/role="banner") so it is not contained by another landmark; a banner should be a top-level region of the page.',
220
- i18n: {
221
- summaryKey: 'landmarkBannerIsTopLevel_summary_cantTell',
222
- hintKey: 'landmarkBannerIsTopLevel_hint_cantTell',
223
- params: {}
224
- },
225
- data: {
226
- details: { reasonCode: 'LANDMARK_BANNER_NOT_TOP_LEVEL' }
227
- }
228
- });
212
+ occurrences.push(
213
+ helpers.reportOccurrence(el, {
214
+ summary: 'This banner landmark is nested inside another landmark region.',
215
+ hint: 'Move the banner landmark (header/role="banner") so it is not contained by another landmark; a banner should be a top-level region of the page.',
216
+ i18n: {
217
+ summaryKey: 'landmarkBannerIsTopLevel_summary_cantTell',
218
+ hintKey: 'landmarkBannerIsTopLevel_hint_cantTell',
219
+ params: {}
220
+ },
221
+ data: {
222
+ details: { reasonCode: 'LANDMARK_BANNER_NOT_TOP_LEVEL' }
223
+ }
224
+ })
225
+ );
229
226
  }
230
227
 
231
228
  if (occurrences.length) {
@@ -6,25 +6,25 @@
6
6
  * @check landmark-contentinfo-is-top-level
7
7
  * @atomic true
8
8
  * @summary The contentinfo landmark must not be nested inside another landmark
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 the page contains at least one contentinfo
12
12
  * candidate: explicit role="contentinfo", OR a <footer> with NO role
13
13
  * attribute at all, regardless of nesting (see implementation notes on
14
- * why candidate selection is deliberately unconditional).
14
+ * why candidate selection is unconditional on purpose).
15
15
  * @expectation
16
16
  * No contentinfo candidate has an ancestor that is itself any landmark
17
17
  * region. A contentinfo nested inside another landmark is not a
18
18
  * top-level, whole-page footer region and confuses landmark-based
19
19
  * navigation for assistive technology users.
20
20
  * @implementation-notes
21
- * - Not WCAG-normative — authored as an advisory, cantTell-capped
21
+ * - Not WCAG-normative, authored as an advisory, cantTell-capped
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
25
  * - Candidate selection (`isContentinfoCandidate`) requires the element to
26
26
  * really carry the contentinfo role, via the suppression-aware
27
- * `getLandmarkRole` — same reasoning as landmark-banner-is-top-level.
27
+ * `getLandmarkRole`, same reasoning as landmark-banner-is-top-level.
28
28
  */
29
29
 
30
30
  const id = 'landmark-contentinfo-is-top-level';
@@ -79,7 +79,7 @@ function runInPage(ctx) {
79
79
 
80
80
  // Delegates to the shared helpers.hasLandmarkScopingAncestor for the
81
81
  // question "does this element sit inside a sectioning-content/<main>
82
- // ancestor that suppresses its conditional implicit role" — role-aware
82
+ // ancestor that suppresses its conditional implicit role": role-aware
83
83
  // (an ancestor's bare TAG only counts when it carries no role attribute
84
84
  // at all; an explicit role="dialog"-style override no longer suppresses)
85
85
  // rather than a local tag-only copy. See that function's header comment
@@ -98,7 +98,7 @@ function runInPage(ctx) {
98
98
  if (tag === 'main') return 'main';
99
99
  if (tag === 'nav') return 'navigation';
100
100
  if (tag === 'aside') {
101
- // A named <aside> is never suppressed, even when nested — it keeps
101
+ // A named <aside> is never suppressed, even when nested. It keeps
102
102
  // "complementary" when it has an accessible name, even inside
103
103
  // sectioning content. Matches landmark-unique's precedent.
104
104
  if (!hasSectioningAncestor(el, false)) return 'complementary';
@@ -127,12 +127,12 @@ function runInPage(ctx) {
127
127
  return getImplicitLandmarkRole(el);
128
128
  }
129
129
 
130
- // Candidate selection is deliberately NOT the same as getLandmarkRole()
131
- // === 'contentinfo' — see the header comment above. A <footer> is
130
+ // Candidate selection is NOT the same as getLandmarkRole()
131
+ // === 'contentinfo'; see the header comment above. A <footer> is
132
132
  // a candidate purely by tag + absence of any role attribute, independent
133
133
  // of whether sectioning-ancestor nesting would currently suppress its
134
134
  // implicit role; an explicit role="contentinfo" is always a candidate too.
135
- // A candidate must actually have the contentinfo role — a <footer> inside
135
+ // A candidate must actually have the contentinfo role: a <footer> inside
136
136
  // article/aside/main/nav/section is not one, so flagging it as nested
137
137
  // would report a landmark that does not exist.
138
138
  function isContentinfoCandidate(el) {
@@ -202,23 +202,20 @@ function runInPage(ctx) {
202
202
  for (const el of contentinfos) {
203
203
  if (!hasLandmarkAncestor(el)) continue;
204
204
 
205
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
206
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
207
-
208
- occurrences.push({
209
- selector: stableSelector,
210
- html,
211
- summary: 'This contentinfo landmark is nested inside another landmark region.',
212
- 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.',
213
- i18n: {
214
- summaryKey: 'landmarkContentinfoIsTopLevel_summary_cantTell',
215
- hintKey: 'landmarkContentinfoIsTopLevel_hint_cantTell',
216
- params: {}
217
- },
218
- data: {
219
- details: { reasonCode: 'LANDMARK_CONTENTINFO_NOT_TOP_LEVEL' }
220
- }
221
- });
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
+ );
222
219
  }
223
220
 
224
221
  if (occurrences.length) {