@surea11y/core 1.5.0 → 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 (145) hide show
  1. package/CHANGELOG.md +193 -149
  2. package/README.md +27 -6
  3. package/docs/ACT_RULE_MAPPING.md +243 -0
  4. package/docs/API_STABILITY.md +2 -2
  5. package/docs/BINDING_AUTHORS_GUIDE.md +3 -3
  6. package/docs/DESIGN_CHALLENGES.md +301 -0
  7. package/docs/ENGINE_OPTIONS.md +16 -4
  8. package/docs/I18N.md +4 -4
  9. package/docs/INTEGRATION.md +1 -1
  10. package/docs/LIMITATIONS.md +6 -4
  11. package/docs/REPORT.md +1 -1
  12. package/docs/RULE_AUTHORING.md +53 -25
  13. package/docs/RULE_CATALOG.md +1878 -169
  14. package/docs/RULE_TAXONOMY.md +2 -2
  15. package/docs/TROUBLESHOOTING.md +2 -2
  16. package/docs/WCAG_CONFORMANCE.md +25 -9
  17. package/package.json +3 -7
  18. package/src/baseline.js +3 -3
  19. package/src/checks/automatic/area-alt-present.js +2 -2
  20. package/src/checks/automatic/aria-allowed-attr.js +68 -10
  21. package/src/checks/automatic/aria-allowed-role.js +2 -2
  22. package/src/checks/automatic/aria-braille-equivalent.js +3 -3
  23. package/src/checks/automatic/aria-conditional-attr.js +5 -5
  24. package/src/checks/automatic/aria-deprecated-role.js +1 -1
  25. package/src/checks/automatic/aria-hidden-body.js +2 -2
  26. package/src/checks/automatic/aria-hidden-focus.js +5 -5
  27. package/src/checks/automatic/aria-prohibited-attr.js +18 -18
  28. package/src/checks/automatic/aria-prohibited-children.js +130 -37
  29. package/src/checks/automatic/aria-required-attr.js +60 -12
  30. package/src/checks/automatic/aria-required-children.js +21 -14
  31. package/src/checks/automatic/aria-required-parent.js +61 -9
  32. package/src/checks/automatic/aria-role-name-present.js +36 -22
  33. package/src/checks/automatic/aria-valid-attr-value.js +15 -12
  34. package/src/checks/automatic/aria-valid-attr.js +1 -1
  35. package/src/checks/automatic/autocomplete-valid.js +2 -2
  36. package/src/checks/automatic/binary-control-name-present.js +27 -5
  37. package/src/checks/automatic/button-name-present.js +92 -6
  38. package/src/checks/automatic/combobox-name-present.js +26 -6
  39. package/src/checks/automatic/contrast-computable.js +32 -0
  40. package/src/checks/automatic/contrast-enhanced.js +21 -1
  41. package/src/checks/automatic/contrast-minimum.js +21 -1
  42. package/src/checks/automatic/css-orientation-lock.js +96 -19
  43. package/src/checks/automatic/definition-list-children-valid.js +7 -8
  44. package/src/checks/automatic/deprecated-elements-not-used.js +1 -1
  45. package/src/checks/automatic/dialog-name-present.js +20 -2
  46. package/src/checks/automatic/duplicate-id-aria.js +5 -3
  47. package/src/checks/automatic/duplicate-id.js +198 -0
  48. package/src/checks/automatic/embed-text-alternative-present.js +2 -2
  49. package/src/checks/automatic/form-control-programmatic-label-present.js +19 -2
  50. package/src/checks/automatic/form-control-single-label.js +1 -1
  51. package/src/checks/automatic/iframe-focusable-content.js +63 -7
  52. package/src/checks/automatic/iframe-name-present.js +37 -3
  53. package/src/checks/automatic/iframe-title-unique.js +1 -1
  54. package/src/checks/automatic/img-alt-present.js +12 -4
  55. package/src/checks/automatic/label-in-name.js +172 -18
  56. package/src/checks/automatic/link-in-text-block.js +10 -10
  57. package/src/checks/automatic/link-name-present.js +22 -1
  58. package/src/checks/automatic/list-children-valid.js +6 -6
  59. package/src/checks/automatic/listbox-name-present.js +28 -8
  60. package/src/checks/automatic/listitem-parent-valid.js +4 -4
  61. package/src/checks/automatic/menuitem-name-present.js +20 -2
  62. package/src/checks/automatic/meta-refresh-no-exceptions.js +25 -14
  63. package/src/checks/automatic/meta-refresh-timing-absent.js +14 -7
  64. package/src/checks/automatic/meter-name-present.js +23 -4
  65. package/src/checks/automatic/nested-interactive-controls-absent.js +5 -5
  66. package/src/checks/automatic/option-name-present.js +23 -4
  67. package/src/checks/automatic/page-title-present.js +21 -3
  68. package/src/checks/automatic/presentational-children-focusable-absent.js +330 -0
  69. package/src/checks/automatic/progressbar-name-present.js +23 -4
  70. package/src/checks/automatic/role-img-alt-present.js +64 -16
  71. package/src/checks/automatic/searchbox-name-present.js +28 -8
  72. package/src/checks/automatic/server-side-image-map-absent.js +1 -1
  73. package/src/checks/automatic/slider-name-present.js +27 -6
  74. package/src/checks/automatic/spinbutton-name-present.js +28 -8
  75. package/src/checks/automatic/summary-name-present.js +18 -2
  76. package/src/checks/automatic/svg-image-text-alternative-present.js +1 -1
  77. package/src/checks/automatic/svg-text-alternative-present.js +13 -10
  78. package/src/checks/automatic/tab-name-present.js +21 -2
  79. package/src/checks/automatic/table-headers-attr-valid.js +43 -8
  80. package/src/checks/automatic/table-th-has-data-cells.js +61 -5
  81. package/src/checks/automatic/target-size-minimum.js +71 -53
  82. package/src/checks/automatic/td-has-header.js +5 -5
  83. package/src/checks/automatic/textbox-name-present.js +28 -8
  84. package/src/checks/automatic/tooltip-name-present.js +21 -2
  85. package/src/checks/automatic/treeitem-name-present.js +23 -4
  86. package/src/checks/automatic/valid-lang.js +92 -7
  87. package/src/checks/automatic/video-poster-text-alternative-present.js +1 -1
  88. package/src/checks/manual/accesskeys-manual.js +3 -3
  89. package/src/checks/manual/area-alt-decorative-manual.js +7 -0
  90. package/src/checks/manual/area-alt-quality-manual.js +6 -0
  91. package/src/checks/manual/aria-checked-state-mismatch-manual.js +5 -5
  92. package/src/checks/manual/aria-text-manual.js +4 -4
  93. package/src/checks/manual/bypass-blocks-present-manual.js +44 -26
  94. package/src/checks/manual/canvas-text-alternative-quality-manual.js +8 -0
  95. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +444 -0
  96. package/src/checks/manual/embed-text-alternative-quality-manual.js +8 -0
  97. package/src/checks/manual/empty-heading-manual.js +58 -11
  98. package/src/checks/manual/empty-table-header-manual.js +8 -8
  99. package/src/checks/manual/focus-order-semantics-manual.js +15 -15
  100. package/src/checks/manual/form-control-label-quality-manual.js +453 -0
  101. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -2
  102. package/src/checks/manual/heading-order-manual.js +3 -3
  103. package/src/checks/manual/heading-quality-manual.js +338 -0
  104. package/src/checks/manual/identical-links-same-purpose-manual.js +73 -12
  105. package/src/checks/manual/image-redundant-alt-manual.js +4 -4
  106. package/src/checks/manual/img-alt-decorative-manual.js +211 -52
  107. package/src/checks/manual/img-alt-quality-manual.js +7 -0
  108. package/src/checks/manual/input-image-alt-decorative-manual.js +8 -0
  109. package/src/checks/manual/input-image-alt-quality-manual.js +5 -0
  110. package/src/checks/manual/label-title-only-manual.js +4 -4
  111. package/src/checks/manual/landmark-banner-is-top-level-manual.js +7 -7
  112. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +9 -9
  113. package/src/checks/manual/landmark-main-is-top-level-manual.js +6 -6
  114. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +6 -6
  115. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +6 -6
  116. package/src/checks/manual/landmark-no-duplicate-main-manual.js +2 -2
  117. package/src/checks/manual/landmark-one-main-manual.js +6 -6
  118. package/src/checks/manual/landmark-unique-manual.js +9 -9
  119. package/src/checks/manual/link-name-quality-manual.js +161 -32
  120. package/src/checks/manual/media-transcript-present-manual.js +2 -3
  121. package/src/checks/manual/meta-viewport-large-manual.js +2 -2
  122. package/src/checks/manual/mouse-only-event-handlers-manual.js +9 -9
  123. package/src/checks/manual/no-autoplay-audio-manual.js +3 -3
  124. package/src/checks/manual/object-text-alternative-quality-manual.js +8 -0
  125. package/src/checks/manual/p-as-heading-manual.js +4 -4
  126. package/src/checks/manual/page-has-heading-one-manual.js +6 -6
  127. package/src/checks/manual/page-title-patterns-manual.js +26 -3
  128. package/src/checks/manual/presentation-role-conflict-manual.js +55 -25
  129. package/src/checks/manual/region-manual.js +19 -19
  130. package/src/checks/manual/scope-attr-valid-manual.js +2 -2
  131. package/src/checks/manual/scrollable-region-focusable-manual.js +7 -7
  132. package/src/checks/manual/skip-link-manual.js +5 -5
  133. package/src/checks/manual/svg-text-alternative-quality-manual.js +9 -0
  134. package/src/checks/manual/tabindex-manual.js +2 -2
  135. package/src/checks/manual/table-duplicate-name-manual.js +2 -2
  136. package/src/checks/manual/table-fake-caption-manual.js +2 -2
  137. package/src/checks/manual/video-caption-manual.js +3 -3
  138. package/src/checks/manual-review.js +17 -1
  139. package/src/core.js +8965 -1647
  140. package/src/report.js +2 -2
  141. package/surea11y.browser.js +3768 -611
  142. package/surea11y.i18n.de.js +1 -1
  143. package/surea11y.i18n.es.js +1 -1
  144. package/surea11y.i18n.fr.js +1 -1
  145. 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
@@ -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';
@@ -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) {
@@ -6,7 +6,7 @@
6
6
  * @check landmark-main-is-top-level
7
7
  * @atomic true
8
8
  * @summary The main 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 main landmark
12
12
  * (explicit role="main", or an implicit <main> element).
@@ -16,15 +16,15 @@
16
16
  * whole-page main content area and confuses landmark-based navigation
17
17
  * for assistive technology users.
18
18
  * @implementation-notes
19
- * - Not WCAG-normative — authored as an advisory, cantTell-capped
19
+ * - Not WCAG-normative, authored as an advisory, cantTell-capped
20
20
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
21
21
  * header comment for the shared rationale/precedent (this rule mirrors
22
22
  * its structure with main in place of banner/header).
23
23
  * - Unlike landmark-banner-is-top-level/landmark-contentinfo-is-top-level
24
24
  * (see that file's header comment), candidate selection here doesn't
25
25
  * need to be unconditional: `<main>`'s implicit role is unconditional
26
- * per HTML-AAM — unlike `<header>`/`<footer>`, nesting never suppresses
27
- * it — so `getImplicitLandmarkRole`'s `main` branch is never subject to
26
+ * per HTML-AAM. Unlike `<header>`/`<footer>`, nesting never suppresses
27
+ * it, so `getImplicitLandmarkRole`'s `main` branch is never subject to
28
28
  * the self-defeating candidate-selection problem those two rules guard
29
29
  * against.
30
30
  */
@@ -81,7 +81,7 @@ function runInPage(ctx) {
81
81
 
82
82
  // Delegates to the shared helpers.hasLandmarkScopingAncestor for the
83
83
  // question "does this element sit inside a sectioning-content/<main>
84
- // ancestor that suppresses its conditional implicit role" — role-aware
84
+ // ancestor that suppresses its conditional implicit role": role-aware
85
85
  // (an ancestor's bare TAG only counts when it carries no role attribute
86
86
  // at all; an explicit role="dialog"-style override no longer suppresses)
87
87
  // rather than a local tag-only copy. See that function's header comment
@@ -100,7 +100,7 @@ function runInPage(ctx) {
100
100
  if (tag === 'main') return 'main';
101
101
  if (tag === 'nav') return 'navigation';
102
102
  if (tag === 'aside') {
103
- // A named <aside> is never suppressed, even when nested — it keeps
103
+ // A named <aside> is never suppressed, even when nested. It keeps
104
104
  // "complementary" when it has an accessible name, even inside
105
105
  // sectioning content. Matches landmark-unique's precedent.
106
106
  if (!hasSectioningAncestor(el, false)) return 'complementary';
@@ -6,19 +6,19 @@
6
6
  * @check landmark-no-duplicate-banner
7
7
  * @atomic true
8
8
  * @summary A page must not have more than one banner 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 landmark
12
- * (explicit role="banner", or an implicit, non-nested <header> — see
12
+ * (explicit role="banner", or an implicit, non-nested <header>; see
13
13
  * landmark-banner-is-top-level's implementation notes for the
14
14
  * shared landmark-detection model).
15
15
  * @expectation
16
16
  * At most one banner landmark exists on the page. Per WAI-ARIA
17
17
  * Authoring Practices, the banner landmark represents site-oriented
18
- * content that identifies the page as a whole — having more than one
18
+ * content that identifies the page as a whole, so having more than one
19
19
  * is ambiguous for assistive technology users navigating by landmark.
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
  * - Flags every banner instance (not just the "extra" ones) when more
@@ -81,11 +81,11 @@ function runInPage(ctx) {
81
81
 
82
82
  // Delegates to the shared helpers.hasLandmarkScopingAncestor for the
83
83
  // question "does this element sit inside a sectioning-content/<main>
84
- // ancestor that suppresses its conditional implicit role" — role-aware
84
+ // ancestor that suppresses its conditional implicit role": role-aware
85
85
  // (an ancestor's bare TAG only counts when it carries no role attribute
86
86
  // at all; an explicit role="dialog"-style override no longer suppresses)
87
87
  // rather than a local tag-only copy. See that function's header comment
88
- // in src/core/aria-helpers.js for the full algorithm — e.g. an
88
+ // in src/core/aria-helpers.js for the full algorithm, e.g. an
89
89
  // <aside role="dialog"> containing its own <header>, where the <header>
90
90
  // keeps its banner role.
91
91
  function hasSectioningAncestor(el, includeMain) {
@@ -6,18 +6,18 @@
6
6
  * @check landmark-no-duplicate-contentinfo
7
7
  * @atomic true
8
8
  * @summary A page must not have more than one contentinfo 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 landmark
12
12
  * (explicit role="contentinfo", or an implicit, non-nested <footer>).
13
13
  * @expectation
14
- * At most one contentinfo landmark exists on the page — mirrors
14
+ * At most one contentinfo landmark exists on the page, mirroring
15
15
  * landmark-no-duplicate-banner's rationale for contentinfo.
16
16
  * @implementation-notes
17
- * - Not WCAG-normative — authored as an advisory, cantTell-capped
17
+ * - Not WCAG-normative, authored as an advisory, cantTell-capped
18
18
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
19
19
  * header comment for the shared rationale/precedent.
20
- * - Only landmarks actually exposed to assistive technology can collide —
20
+ * - Only landmarks actually exposed to assistive technology can collide,
21
21
  * same as the sibling banner/main rules, avoiding hidden-duplicate false
22
22
  * positives.
23
23
  */
@@ -74,7 +74,7 @@ function runInPage(ctx) {
74
74
 
75
75
  // Delegates to the shared helpers.hasLandmarkScopingAncestor for the
76
76
  // question "does this element sit inside a sectioning-content/<main>
77
- // ancestor that suppresses its conditional implicit role" — role-aware
77
+ // ancestor that suppresses its conditional implicit role": role-aware
78
78
  // (an ancestor's bare TAG only counts when it carries no role attribute
79
79
  // at all; an explicit role="dialog"-style override no longer suppresses)
80
80
  // rather than a local tag-only copy. See that function's header comment
@@ -93,7 +93,7 @@ function runInPage(ctx) {
93
93
  if (tag === 'main') return 'main';
94
94
  if (tag === 'nav') return 'navigation';
95
95
  if (tag === 'aside') {
96
- // A named <aside> is never suppressed, even when nested — it keeps
96
+ // A named <aside> is never suppressed, even when nested. It keeps
97
97
  // "complementary" when it has an accessible name, even inside
98
98
  // sectioning content. Matches landmark-unique's precedent.
99
99
  if (!hasSectioningAncestor(el, false)) return 'complementary';