@surea11y/core 1.5.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/CHANGELOG.md +240 -149
  2. package/README.md +51 -44
  3. package/docs/ACT_RULE_MAPPING.md +245 -0
  4. package/docs/API_STABILITY.md +53 -5
  5. package/docs/BINDING_AUTHORS_GUIDE.md +106 -4
  6. package/docs/DESIGN_CHALLENGES.md +367 -0
  7. package/docs/EARL.md +100 -0
  8. package/docs/ENGINE_OPTIONS.md +42 -4
  9. package/docs/I18N.md +4 -4
  10. package/docs/INTEGRATION.md +4 -2
  11. package/docs/LIMITATIONS.md +9 -5
  12. package/docs/OUTPUT_SCHEMA.md +44 -6
  13. package/docs/POLICY.md +1 -1
  14. package/docs/REPORT.md +1 -1
  15. package/docs/RULE_AUTHORING.md +63 -36
  16. package/docs/RULE_CATALOG.md +1928 -169
  17. package/docs/RULE_HELPERS.md +333 -0
  18. package/docs/RULE_TAXONOMY.md +27 -6
  19. package/docs/SARIF.md +21 -2
  20. package/docs/TROUBLESHOOTING.md +2 -2
  21. package/docs/WCAG_CONFORMANCE.md +34 -10
  22. package/package.json +11 -9
  23. package/src/baseline.js +3 -3
  24. package/src/checks/automatic/area-alt-present.js +2 -2
  25. package/src/checks/automatic/aria-allowed-attr.js +74 -10
  26. package/src/checks/automatic/aria-allowed-role.js +34 -25
  27. package/src/checks/automatic/aria-braille-equivalent.js +21 -13
  28. package/src/checks/automatic/aria-conditional-attr.js +22 -15
  29. package/src/checks/automatic/aria-deprecated-role.js +13 -1
  30. package/src/checks/automatic/aria-hidden-body.js +3 -3
  31. package/src/checks/automatic/aria-hidden-focus.js +5 -5
  32. package/src/checks/automatic/aria-prohibited-attr.js +23 -18
  33. package/src/checks/automatic/aria-prohibited-children.js +136 -43
  34. package/src/checks/automatic/aria-required-attr.js +119 -24
  35. package/src/checks/automatic/aria-required-children.js +54 -30
  36. package/src/checks/automatic/aria-required-parent.js +93 -15
  37. package/src/checks/automatic/aria-role-name-present.js +37 -23
  38. package/src/checks/automatic/aria-roles-valid.js +52 -21
  39. package/src/checks/automatic/aria-valid-attr-value.js +89 -33
  40. package/src/checks/automatic/aria-valid-attr.js +15 -10
  41. package/src/checks/automatic/autocomplete-valid.js +2 -2
  42. package/src/checks/automatic/avoid-inline-spacing.js +133 -6
  43. package/src/checks/automatic/binary-control-name-present.js +27 -5
  44. package/src/checks/automatic/button-name-present.js +92 -6
  45. package/src/checks/automatic/combobox-name-present.js +26 -6
  46. package/src/checks/automatic/contrast-computable.js +42 -0
  47. package/src/checks/automatic/contrast-enhanced.js +33 -1
  48. package/src/checks/automatic/contrast-minimum.js +33 -1
  49. package/src/checks/automatic/css-orientation-lock.js +138 -24
  50. package/src/checks/automatic/definition-list-children-valid.js +7 -8
  51. package/src/checks/automatic/deprecated-elements-not-used.js +1 -1
  52. package/src/checks/automatic/dialog-name-present.js +20 -2
  53. package/src/checks/automatic/duplicate-id-aria.js +10 -3
  54. package/src/checks/automatic/duplicate-id.js +203 -0
  55. package/src/checks/automatic/embed-text-alternative-present.js +2 -2
  56. package/src/checks/automatic/form-control-programmatic-label-present.js +19 -2
  57. package/src/checks/automatic/form-control-single-label.js +10 -1
  58. package/src/checks/automatic/identical-iframes-same-purpose.js +229 -0
  59. package/src/checks/automatic/iframe-focusable-content.js +68 -7
  60. package/src/checks/automatic/iframe-name-present.js +37 -3
  61. package/src/checks/automatic/iframe-title-unique.js +1 -1
  62. package/src/checks/automatic/img-alt-present.js +12 -4
  63. package/src/checks/automatic/label-in-name.js +204 -68
  64. package/src/checks/automatic/link-in-text-block.js +285 -29
  65. package/src/checks/automatic/link-name-present.js +22 -1
  66. package/src/checks/automatic/list-children-valid.js +6 -6
  67. package/src/checks/automatic/listbox-name-present.js +28 -8
  68. package/src/checks/automatic/listitem-parent-valid.js +4 -4
  69. package/src/checks/automatic/menuitem-name-present.js +20 -2
  70. package/src/checks/automatic/meta-refresh-no-exceptions.js +25 -14
  71. package/src/checks/automatic/meta-refresh-timing-absent.js +14 -7
  72. package/src/checks/automatic/meter-name-present.js +23 -4
  73. package/src/checks/automatic/nested-interactive-controls-absent.js +5 -5
  74. package/src/checks/automatic/option-name-present.js +23 -4
  75. package/src/checks/automatic/page-title-present.js +21 -3
  76. package/src/checks/automatic/presentational-children-focusable-absent.js +330 -0
  77. package/src/checks/automatic/progressbar-name-present.js +23 -4
  78. package/src/checks/automatic/{role-img-alt-present.js → role-img-text-alternative-present.js} +64 -16
  79. package/src/checks/automatic/searchbox-name-present.js +28 -8
  80. package/src/checks/automatic/server-side-image-map-absent.js +1 -1
  81. package/src/checks/automatic/slider-name-present.js +27 -6
  82. package/src/checks/automatic/spinbutton-name-present.js +28 -8
  83. package/src/checks/automatic/summary-name-present.js +18 -2
  84. package/src/checks/automatic/svg-image-text-alternative-present.js +1 -1
  85. package/src/checks/automatic/svg-text-alternative-present.js +13 -10
  86. package/src/checks/automatic/tab-name-present.js +21 -2
  87. package/src/checks/automatic/table-headers-attr-valid.js +43 -8
  88. package/src/checks/automatic/table-th-has-data-cells.js +61 -5
  89. package/src/checks/automatic/target-size-minimum.js +155 -58
  90. package/src/checks/automatic/td-has-header.js +24 -23
  91. package/src/checks/automatic/textbox-name-present.js +28 -8
  92. package/src/checks/automatic/tooltip-name-present.js +21 -2
  93. package/src/checks/automatic/treeitem-name-present.js +23 -4
  94. package/src/checks/automatic/valid-lang.js +92 -7
  95. package/src/checks/automatic/video-poster-text-alternative-present.js +1 -1
  96. package/src/checks/manual/accesskeys-manual.js +3 -3
  97. package/src/checks/manual/area-alt-decorative-manual.js +7 -0
  98. package/src/checks/manual/area-alt-quality-manual.js +6 -0
  99. package/src/checks/manual/aria-checked-state-mismatch-manual.js +5 -5
  100. package/src/checks/manual/aria-text-manual.js +4 -4
  101. package/src/checks/manual/bypass-blocks-present-manual.js +44 -26
  102. package/src/checks/manual/canvas-text-alternative-quality-manual.js +8 -0
  103. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +444 -0
  104. package/src/checks/manual/embed-text-alternative-quality-manual.js +8 -0
  105. package/src/checks/manual/empty-heading-manual.js +58 -11
  106. package/src/checks/manual/empty-table-header-manual.js +8 -8
  107. package/src/checks/manual/focus-order-semantics-manual.js +15 -15
  108. package/src/checks/manual/form-control-label-quality-manual.js +563 -0
  109. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -2
  110. package/src/checks/manual/heading-order-manual.js +3 -3
  111. package/src/checks/manual/heading-quality-manual.js +338 -0
  112. package/src/checks/manual/identical-links-same-purpose-manual.js +73 -12
  113. package/src/checks/manual/image-redundant-alt-manual.js +4 -4
  114. package/src/checks/manual/img-alt-decorative-manual.js +211 -52
  115. package/src/checks/manual/img-alt-quality-manual.js +7 -0
  116. package/src/checks/manual/input-image-alt-decorative-manual.js +8 -0
  117. package/src/checks/manual/input-image-alt-quality-manual.js +5 -0
  118. package/src/checks/manual/label-title-only-manual.js +4 -4
  119. package/src/checks/manual/landmark-banner-is-top-level-manual.js +7 -7
  120. package/src/checks/manual/landmark-complementary-is-top-level-manual.js +231 -0
  121. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +9 -9
  122. package/src/checks/manual/landmark-main-is-top-level-manual.js +6 -6
  123. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +6 -6
  124. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +6 -6
  125. package/src/checks/manual/landmark-no-duplicate-main-manual.js +2 -2
  126. package/src/checks/manual/landmark-one-main-manual.js +6 -6
  127. package/src/checks/manual/landmark-unique-manual.js +9 -9
  128. package/src/checks/manual/link-name-quality-manual.js +161 -32
  129. package/src/checks/manual/media-transcript-present-manual.js +2 -3
  130. package/src/checks/manual/meta-viewport-large-manual.js +2 -2
  131. package/src/checks/manual/mouse-only-event-handlers-manual.js +9 -9
  132. package/src/checks/manual/no-autoplay-audio-manual.js +3 -3
  133. package/src/checks/manual/object-text-alternative-quality-manual.js +8 -0
  134. package/src/checks/manual/p-as-heading-manual.js +4 -4
  135. package/src/checks/manual/page-has-heading-one-manual.js +6 -6
  136. package/src/checks/manual/page-title-patterns-manual.js +26 -3
  137. package/src/checks/manual/password-paste-enabled-manual.js +255 -0
  138. package/src/checks/manual/presentation-role-conflict-manual.js +55 -25
  139. package/src/checks/manual/region-manual.js +19 -19
  140. package/src/checks/manual/scope-attr-valid-manual.js +2 -2
  141. package/src/checks/manual/scrollable-region-focusable-manual.js +7 -7
  142. package/src/checks/manual/skip-link-manual.js +5 -5
  143. package/src/checks/manual/svg-text-alternative-quality-manual.js +9 -0
  144. package/src/checks/manual/tabindex-manual.js +2 -2
  145. package/src/checks/manual/table-duplicate-name-manual.js +2 -2
  146. package/src/checks/manual/table-fake-caption-manual.js +2 -2
  147. package/src/checks/manual/video-caption-manual.js +3 -3
  148. package/src/checks/manual-review.js +17 -1
  149. package/src/core.js +8880 -41883
  150. package/src/earl.js +144 -0
  151. package/src/report.js +2 -2
  152. package/src/sarif.js +22 -2
  153. package/surea11y.browser.js +10 -37882
  154. package/surea11y.i18n.de.js +2 -21
  155. package/surea11y.i18n.es.js +2 -21
  156. package/surea11y.i18n.fr.js +2 -21
  157. package/bin/surea11y-core.js +0 -20
@@ -12,7 +12,7 @@
12
12
  * Applies to <a href> elements whose immediate parent element also has
13
13
  * at least one direct-child text node with non-whitespace content
14
14
  * (i.e. the link sits inline within a run of plain text, not as a
15
- * standalone item — e.g. not the sole content of a <li> nav item).
15
+ * standalone item, e.g. not the sole content of a <li> nav item).
16
16
  * @expectation
17
17
  * A link inside a text block must be visually distinguishable from the
18
18
  * surrounding text by at least one non-color means:
@@ -20,29 +20,29 @@
20
20
  * - a different font-weight than the surrounding text, OR
21
21
  * - a different font-style than the surrounding text, OR
22
22
  * - a contrast ratio of at least 3:1 between the link's text color and
23
- * the surrounding text's color (WCAG technique G183's threshold —
23
+ * the surrounding text's color (WCAG technique G183's threshold,
24
24
  * sufficient contrast alone is an accepted alternative to underline).
25
25
  * Fails only when none of the above hold AND the color contrast between
26
- * link and surrounding text is confidently computable and below 3:1 —
26
+ * link and surrounding text is confidently computable and below 3:1,
27
27
  * i.e. color is demonstrably the only cue.
28
28
  * @implementation-notes
29
29
  * - "Surrounding text style" is approximated as the link's immediate
30
30
  * parent element's own computed style, not a full inline-context walk
31
- * of the actual adjacent text node(s) — a deliberate scope-down, since
31
+ * of the actual adjacent text node(s), a deliberate scope-down, since
32
32
  * plain text nodes inherit their rendering from the parent in the
33
33
  * overwhelming majority of real markup.
34
- * - When contrast is not confidently computable (background image/
35
- * gradient, blend mode, filter, non-opaque ancestor — same blockers
36
- * `contrast-minimum`/`contrast-computable` use), the link is silently
37
- * skipped rather than flagged or reported as cantTell, to keep `fail`
38
- * reserved for deterministic, high-confidence violations. This means
39
- * the rule never emits cantTell — outcome is notApplicable/pass/fail
40
- * only, matching this repo's other Tier 2 mechanical rules.
34
+ * - A candidate that cannot be evaluated reports cantTell, not pass:
35
+ * contrast not confidently computable (the blockers `contrast-minimum`/
36
+ * `contrast-computable` use), or `text-decoration` unreadable in both
37
+ * the computed style and the CSSOM (see `decorationInfo`). `fail` stays
38
+ * reserved for deterministic violations; this is the computability gate
39
+ * RULE_TAXONOMY.md §1.1 allows automatic rules, as in `contrast-minimum`
40
+ * and `target-size-minimum`.
41
41
  * - Reuses the shared `helpers.contrast` subsystem (same
42
42
  * computeEffectiveForeground/Background, getComputabilityBlocker,
43
43
  * contrastRatio helpers as `contrast-minimum`), rather than re-deriving
44
44
  * color math independently.
45
- * - Scoped to `a[href]` only (not `area[href]` or `[role="link"]`) —
45
+ * - Scoped to `a[href]` only (not `area[href]` or `[role="link"]`),
46
46
  * matches the common real-world shape of this issue (prose links).
47
47
  */
48
48
 
@@ -52,7 +52,7 @@ const meta = {
52
52
  title:
53
53
  'Links in text blocks must be distinguishable from surrounding text without relying on color alone',
54
54
  description:
55
- 'Checks that a link inside a run of text is visually distinguishable from the surrounding text by underline, a font-weight/style difference, or a sufficient (>=3:1) color-contrast difference — not by color alone.',
55
+ 'Checks that a link inside a run of text is visually distinguishable from the surrounding text by underline, a font-weight/style difference, or a sufficient (>=3:1) color-contrast difference, not by color alone.',
56
56
  i18n: {
57
57
  titleKey: 'linkInTextBlock_title',
58
58
  descriptionKey: 'linkInTextBlock_description'
@@ -103,14 +103,176 @@ function runInPage(ctx) {
103
103
  return null;
104
104
  }
105
105
 
106
- function decorationTokens(cs) {
107
- const raw =
108
- `${(cs && cs.textDecorationLine) || ''} ${(cs && cs.textDecoration) || ''}`.toLowerCase();
109
- return raw.split(/\s+/).filter(Boolean);
106
+ // Whether the element is underlined, and whether the computed style can be
107
+ // trusted to say so. A conforming CSSOM serialises the `text-decoration`
108
+ // shorthand with the line value first, so it and `text-decoration-line`
109
+ // always agree on whether `underline` is present. jsdom does not cascade
110
+ // the property at all: the shorthand reads back as the UA's "underline"
111
+ // for every <a> whatever the author CSS says, and the longhand as "none"
112
+ // unless the author used the longhand. Either one taken alone is wrong in
113
+ // one direction, so disagreement is the signal to stop trusting both.
114
+ function decorationInfo(cs) {
115
+ if (!cs) return { underlined: false, trustworthy: false };
116
+
117
+ const lineRaw = String(cs.textDecorationLine || '')
118
+ .trim()
119
+ .toLowerCase();
120
+ const shortRaw = String(cs.textDecoration || '')
121
+ .trim()
122
+ .toLowerCase();
123
+
124
+ const lineTokens = lineRaw.split(/\s+/).filter(Boolean);
125
+ const shortTokens = shortRaw.split(/\s+/).filter(Boolean);
126
+
127
+ // Only one of the two exposed: nothing to cross-check against, take it.
128
+ if (!lineTokens.length) {
129
+ return { underlined: shortTokens.includes('underline'), trustworthy: shortTokens.length > 0 };
130
+ }
131
+ if (!shortTokens.length) {
132
+ return { underlined: lineTokens.includes('underline'), trustworthy: true };
133
+ }
134
+
135
+ const byLine = lineTokens.includes('underline');
136
+ const byShort = shortTokens.includes('underline');
137
+ return { underlined: byLine, trustworthy: byLine === byShort };
138
+ }
139
+
140
+ // Resolves `text-decoration` from the author stylesheets when the computed
141
+ // style is untrustworthy, reading the CSSOM as `css-orientation-lock` and
142
+ // `css-focus-indicator-suppressed` do. Without it the rule could not decide
143
+ // anything under a DOM emulator, which is how the CLI scans static HTML.
144
+ //
145
+ // A narrow cascade is enough: `text-decoration-line` is not inherited, so
146
+ // only declarations matching the element itself and its inline style apply,
147
+ // ordered by specificity. With no author declaration the UA default stands,
148
+ // and for a link that is an underline. Anything that would make the answer a
149
+ // guess yields `resolved: false` and the caller reports cantTell.
150
+ const CSS_STYLE_RULE = 1;
151
+ const MAX_NESTED_DEPTH = 8;
152
+
153
+ function splitSelectorList(selectorText) {
154
+ const parts = [];
155
+ let depth = 0;
156
+ let current = '';
157
+ for (const ch of String(selectorText || '')) {
158
+ if (ch === '(') depth += 1;
159
+ if (ch === ')') depth = Math.max(0, depth - 1);
160
+ if (ch === ',' && depth === 0) {
161
+ parts.push(current);
162
+ current = '';
163
+ continue;
164
+ }
165
+ current += ch;
166
+ }
167
+ parts.push(current);
168
+ return parts.map((p) => p.trim()).filter(Boolean);
110
169
  }
111
170
 
112
- function hasUnderline(cs) {
113
- return decorationTokens(cs).includes('underline');
171
+ // Approximate CSS specificity as a single sortable integer. Exactness is
172
+ // not required: this only orders declarations of one property against each
173
+ // other, and near-ties are broken by document order as the cascade does.
174
+ function specificityOf(selector) {
175
+ const s = String(selector || '');
176
+ const ids = (s.match(/#[\w-]+/g) || []).length;
177
+ const classesEtc = (s.match(/\.[\w-]+|\[[^\]]*\]|:(?!:)[\w-]+/g) || []).length;
178
+ const types = (s.match(/(^|[\s>+~])[a-z][\w-]*/gi) || []).length;
179
+ return ids * 10000 + classesEtc * 100 + types;
180
+ }
181
+
182
+ // A declaration wins if it is the last one, in (specificity, order), whose
183
+ // selector matches. `!important` outranks everything non-important.
184
+ function underlineFromDeclaration(style) {
185
+ if (!style || typeof style.getPropertyValue !== 'function') return null;
186
+ for (const prop of ['text-decoration-line', 'text-decoration']) {
187
+ const raw = String(style.getPropertyValue(prop) || '')
188
+ .trim()
189
+ .toLowerCase();
190
+ if (!raw) continue;
191
+ const important = String(style.getPropertyPriority(prop) || '') === 'important';
192
+ return { underlined: /\bunderline\b/.test(raw), important };
193
+ }
194
+ return null;
195
+ }
196
+
197
+ function resolveUnderlineFromCssom(el) {
198
+ const doc = el && el.ownerDocument ? el.ownerDocument : null;
199
+ if (!doc || typeof el.matches !== 'function') return { underlined: false, resolved: false };
200
+
201
+ let best = null; // { rank, order, underlined }
202
+ let order = 0;
203
+ let unreadableSheet = false;
204
+ let unparsableSelector = false;
205
+
206
+ function consider(cssRule) {
207
+ const decl = underlineFromDeclaration(cssRule.style);
208
+ if (!decl) return;
209
+ for (const part of splitSelectorList(cssRule.selectorText)) {
210
+ // A pseudo-element rule paints a box other than the link's own text.
211
+ if (/::[a-z-]+/i.test(part)) continue;
212
+ // A state the static DOM is not in (:hover/:focus/...) does not
213
+ // describe the link's resting appearance, which is what this rule is
214
+ // about.
215
+ if (/:(hover|focus|focus-visible|focus-within|active|target|visited)\b/i.test(part)) {
216
+ continue;
217
+ }
218
+ let matched;
219
+ try {
220
+ matched = el.matches(part);
221
+ } catch {
222
+ unparsableSelector = true;
223
+ continue;
224
+ }
225
+ if (!matched) continue;
226
+ order += 1;
227
+ const rank = (decl.important ? 1e9 : 0) + specificityOf(part);
228
+ if (!best || rank >= best.rank) best = { rank, order, underlined: decl.underlined };
229
+ }
230
+ }
231
+
232
+ function walk(rules, depth) {
233
+ if (!rules || depth > MAX_NESTED_DEPTH) return;
234
+ for (const cssRule of rules) {
235
+ if (!cssRule) continue;
236
+ if (cssRule.type === CSS_STYLE_RULE && cssRule.selectorText) {
237
+ consider(cssRule);
238
+ continue;
239
+ }
240
+ let nested;
241
+ try {
242
+ nested = cssRule.cssRules || null;
243
+ } catch {
244
+ nested = null;
245
+ }
246
+ if (nested) walk(nested, depth + 1);
247
+ }
248
+ }
249
+
250
+ try {
251
+ for (const sheet of doc.styleSheets || []) {
252
+ let rules = null;
253
+ try {
254
+ rules = sheet && sheet.cssRules ? sheet.cssRules : null;
255
+ } catch {
256
+ unreadableSheet = true; // cross-origin, not inspectable
257
+ continue;
258
+ }
259
+ if (rules) walk(rules, 0);
260
+ }
261
+ } catch {
262
+ return { underlined: false, resolved: false };
263
+ }
264
+
265
+ // The inline style attribute outranks every stylesheet declaration.
266
+ const inline = underlineFromDeclaration(el.style);
267
+ if (inline) return { underlined: inline.underlined, resolved: true };
268
+
269
+ if (best) return { underlined: best.underlined, resolved: true };
270
+
271
+ // No author declaration reached this element. If a sheet or selector was
272
+ // unreadable, one of them might have, so the answer is unknown; otherwise
273
+ // the UA default stands, and for a link that means underlined.
274
+ if (unreadableSheet || unparsableSelector) return { underlined: false, resolved: false };
275
+ return { underlined: true, resolved: true };
114
276
  }
115
277
 
116
278
  function hasSurroundingText(el, parent) {
@@ -141,7 +303,15 @@ function runInPage(ctx) {
141
303
  : helpers.queryAll(selector);
142
304
 
143
305
  const occurrences = [];
306
+ const undecided = [];
144
307
  let applicableCount = 0;
308
+ let decidedCount = 0;
309
+
310
+ // Applicable, but not evaluable. Held separately so the outcome below can
311
+ // tell "checked and sound" apart from "never decided".
312
+ function markUndecided(el, reasonCode) {
313
+ undecided.push({ el, reasonCode });
314
+ }
145
315
 
146
316
  for (const el of nodes) {
147
317
  if (!el || !el.getAttribute) continue;
@@ -159,27 +329,40 @@ function runInPage(ctx) {
159
329
  const linkCs = safeComputedStyle(el);
160
330
  const parentCs = safeComputedStyle(parent);
161
331
 
162
- if (hasUnderline(linkCs)) continue;
163
-
332
+ // Cues that do not depend on `text-decoration` come first, so a link
333
+ // carrying one is decided even where decoration is unreadable.
164
334
  const linkWeight = c && linkCs ? c.normalizeFontWeight(linkCs.fontWeight) : 400;
165
335
  const parentWeight = c && parentCs ? c.normalizeFontWeight(parentCs.fontWeight) : 400;
166
- if (linkWeight !== parentWeight) continue;
336
+ if (linkWeight !== parentWeight) {
337
+ decidedCount += 1;
338
+ continue;
339
+ }
167
340
 
168
341
  const linkStyle = (linkCs && linkCs.fontStyle) || 'normal';
169
342
  const parentStyle = (parentCs && parentCs.fontStyle) || 'normal';
170
- if (linkStyle !== parentStyle) continue;
343
+ if (linkStyle !== parentStyle) {
344
+ decidedCount += 1;
345
+ continue;
346
+ }
171
347
 
172
- if (!c) continue;
348
+ if (!c) {
349
+ markUndecided(el, 'CONTRAST_HELPERS_UNAVAILABLE');
350
+ continue;
351
+ }
173
352
 
174
353
  let flagged = false;
354
+ let computed = false;
175
355
  let ratio = null;
176
356
  let fgLinkHex = '';
177
357
  let fgParentHex = '';
358
+ let undecidedReason = 'COLOR_NOT_COMPUTABLE';
178
359
 
179
360
  try {
180
361
  const blocker = c.getComputabilityBlocker(el);
181
362
  if (blocker && blocker.ok === false) {
182
- // Not confidently computable — skip (benefit of the doubt).
363
+ // Not confidently computable: recorded below rather than skipped, so
364
+ // it cannot be mistaken for a clean result.
365
+ if (blocker.reasonCode) undecidedReason = String(blocker.reasonCode);
183
366
  } else {
184
367
  const bg = c.computeEffectiveBackground(el, {
185
368
  contrast: { mode, rootCanvasFallback },
@@ -202,15 +385,49 @@ function runInPage(ctx) {
202
385
  fgLinkHex = c.rgbToHex ? c.rgbToHex(fgLinkOpaque) : '';
203
386
  fgParentHex = c.rgbToHex ? c.rgbToHex(fgParentOpaque) : '';
204
387
 
388
+ computed = true;
205
389
  if (!(ratio >= 3)) flagged = true;
206
390
  }
207
- // else: not confidently computable — skip.
391
+ // else: not confidently computable, recorded below.
208
392
  }
209
393
  } catch {
210
- // no-throw: treat as not computable, skip.
394
+ // No-throw: treat as not computable and record it.
395
+ undecidedReason = 'ENGINE_EXCEPTION';
396
+ }
397
+
398
+ if (!computed) {
399
+ markUndecided(el, undecidedReason);
400
+ continue;
401
+ }
402
+
403
+ // Contrast alone is an accepted alternative to an underline (G183), so a
404
+ // link clearing 3:1 is distinguishable regardless of decoration.
405
+ if (!flagged) {
406
+ decidedCount += 1;
407
+ continue;
408
+ }
409
+
410
+ // Below 3:1, an underline is the last remaining cue -- and only now does
411
+ // it matter whether this environment can actually report one.
412
+ const decoration = decorationInfo(linkCs);
413
+ let underlined;
414
+ if (decoration.trustworthy) {
415
+ underlined = decoration.underlined;
416
+ } else {
417
+ const fromCssom = resolveUnderlineFromCssom(el);
418
+ if (!fromCssom.resolved) {
419
+ markUndecided(el, 'TEXT_DECORATION_NOT_RESOLVABLE');
420
+ continue;
421
+ }
422
+ underlined = fromCssom.underlined;
423
+ }
424
+
425
+ if (underlined) {
426
+ decidedCount += 1;
427
+ continue;
211
428
  }
212
429
 
213
- if (!flagged) continue;
430
+ decidedCount += 1;
214
431
 
215
432
  const eligInfo = helpers.getEligibilityInfo
216
433
  ? helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' })
@@ -243,6 +460,9 @@ function runInPage(ctx) {
243
460
  if (applicableCount === 0) {
244
461
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
245
462
  }
463
+
464
+ // A proven violation outranks an undecided candidate, which outranks a clean
465
+ // one. The middle step keeps an unevaluable link out of `pass`.
246
466
  if (occurrences.length) {
247
467
  return {
248
468
  ruleId: rule.ruleId,
@@ -251,7 +471,43 @@ function runInPage(ctx) {
251
471
  occurrences
252
472
  };
253
473
  }
254
- return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
474
+
475
+ if (undecided.length) {
476
+ return {
477
+ ruleId: rule.ruleId,
478
+ outcome: 'cantTell',
479
+ severity: rule.defaultSeverity || 'serious',
480
+ confidence: 'low',
481
+ occurrences: undecided.map(({ el, reasonCode }) =>
482
+ helpers.reportOccurrence(el, {
483
+ summary:
484
+ 'Whether this link is distinguishable from the surrounding text by non-color means could not be determined.',
485
+ hint: 'Confirm by eye that the link carries an underline, a font-weight or font-style difference, or at least 3:1 contrast against the surrounding text. Running the engine in a real browser rather than a DOM emulator resolves most cases automatically.',
486
+ i18n: {
487
+ summaryKey: 'linkInTextBlock_summary_cantTell',
488
+ hintKey: 'linkInTextBlock_hint_cantTell'
489
+ },
490
+ uncertainty: {
491
+ code: 'not-computable',
492
+ needed:
493
+ 'Whether the link carries an underline, weight or style difference, or 3:1 contrast against its surrounding text.',
494
+ evidence: { reasonCode }
495
+ },
496
+ data: {
497
+ visibilityFilter: helpers.getEligibilityInfo
498
+ ? helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' })
499
+ : { targetSet: 'acc', accEligible: null, reasons: [] },
500
+ details: { reasonCode }
501
+ }
502
+ })
503
+ )
504
+ };
505
+ }
506
+
507
+ if (decidedCount > 0) {
508
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
509
+ }
510
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
255
511
  }
256
512
 
257
513
  module.exports = { id, meta, runInPage };
@@ -2,6 +2,27 @@
2
2
 
3
3
  'use strict';
4
4
 
5
+ /**
6
+ * @check link-name-present
7
+ * @atomic true
8
+ * @summary Links must have an accessible name
9
+ * @standard WCAG 2.2
10
+ * @sc 4.1.2
11
+ * @applicability
12
+ * Applies to <a href>, <area href> and elements with role="link" that are
13
+ * included in the accessibility tree. An <a> without an href is not a link
14
+ * and is not matched.
15
+ * @expectation
16
+ * The element has a non-empty accessible name. A programmatic name is
17
+ * taken first (aria-labelledby, aria-label, an associated <label>, title),
18
+ * and failing that the element falls back to its own subtree text,
19
+ * counting each descendant's own name (an <img alt>, aria-label or title),
20
+ * the shape behind the common <a><img alt="..."></a> logo link. The
21
+ * content fallback is suppressed when an explicit, known role that is not
22
+ * name-from-content is present; an unrecognized role token falls back to
23
+ * the implicit role.
24
+ */
25
+
5
26
  const id = 'link-name-present';
6
27
 
7
28
  const meta = {
@@ -38,7 +59,7 @@ function runInPage(ctx) {
38
59
  let applicableCount = 0;
39
60
 
40
61
  function getConservativeSubtreeText(container) {
41
- // "Name from content" — recurses into descendants and uses each one's
62
+ // "Name from content", recurses into descendants and uses each one's
42
63
  // own accessible name (img alt, aria-label/aria-labelledby, title) when
43
64
  // it has one, not just literal text nodes. See getContentNameInfo's
44
65
  // header comment in src/core/dom-helpers.js for the full rationale
@@ -12,7 +12,7 @@
12
12
  * Applies to <ul>/<ol> elements that have at least one direct element
13
13
  * child.
14
14
  * @expectation
15
- * Every direct element child is <li>, <script>, or <template> — UNLESS it
15
+ * Every direct element child is <li>, <script>, or <template>. UNLESS it
16
16
  * has an explicit `role` attribute, in which case the explicit role wins
17
17
  * over the tag entirely: a child is valid iff that role is "listitem"
18
18
  * (so `<li role="presentation">`/`<li role="menuitem">` are invalid
@@ -20,19 +20,19 @@
20
20
  * given `role="listitem"` is valid). A wrapper <div> used for styling
21
21
  * (no role at all) still breaks list semantics the same as before.
22
22
  * @implementation-notes
23
- * - Checked via el.children, which already excludes text/comment nodes —
23
+ * - Checked via el.children, which already excludes text/comment nodes,
24
24
  * no whitespace-node filtering needed.
25
25
  * - Distinct, atomic decision from listitem-parent-valid (the
26
26
  * inverse relationship: does a given <li> have a valid parent).
27
27
  * - Direct children that are not exposed to the accessibility tree (e.g.
28
28
  * display:none, [hidden], aria-hidden="true") are excluded from
29
- * consideration entirely — an element not reachable by assistive
29
+ * consideration entirely. An element not reachable by assistive
30
30
  * technology can't break the list semantics a screen reader announces.
31
31
  * Common cases: a stray `<input type="hidden">` as a direct <ul> child
32
32
  * (UA-stylesheet display:none by spec), or `<span style="display:none">`
33
33
  * hydration markers interleaved with real `<li>`s.
34
34
  * - Explicit-role-overrides-tag: if a child has an explicit role, only
35
- * `['listitem']` is consulted — the tag name is never checked. Only
35
+ * `['listitem']` is consulted, the tag name is never checked. Only
36
36
  * without an explicit role does the tag name matter. Catches cases a
37
37
  * tag-only check misses: `<li role="none">` hosting a list's own
38
38
  * visually-hidden label, `<li role="menuitem">` menu items, or a real
@@ -71,7 +71,7 @@ const meta = {
71
71
  function runInPage(ctx) {
72
72
  const { helpers, rule } = ctx;
73
73
 
74
- // Declared inside runInPage — see scripts/build-core.js header
74
+ // Declared inside runInPage, see scripts/build-core.js header
75
75
  // ("runInPage MUST be self-contained").
76
76
  const ALLOWED_CHILD_TAGS = new Set(['li', 'script', 'template']);
77
77
 
@@ -111,7 +111,7 @@ function runInPage(ctx) {
111
111
  const roleAttr = child.getAttribute ? String(child.getAttribute('role') || '').trim() : '';
112
112
  const explicitRole = roleAttr ? (roleAttr.split(/\s+/)[0] || '').toLowerCase() : '';
113
113
 
114
- // An explicit role always wins over the tag — see header comment.
114
+ // An explicit role always wins over the tag, see header comment.
115
115
  const valid = explicitRole ? explicitRole === 'listitem' : ALLOWED_CHILD_TAGS.has(tag);
116
116
 
117
117
  if (!valid) invalidTags.push(tag);
@@ -2,11 +2,31 @@
2
2
 
3
3
  'use strict';
4
4
 
5
+ /**
6
+ * @check listbox-name-present
7
+ * @atomic true
8
+ * @summary Elements with role="listbox" must have an accessible name
9
+ * @standard WCAG 2.2
10
+ * @sc 4.1.2
11
+ * @applicability
12
+ * Applies to elements carrying role="listbox" (the attribute must name
13
+ * that role alone, not a fallback list) that are included in the
14
+ * accessibility tree. An element with the matching implicit role but no
15
+ * role attribute is out of scope.
16
+ * @expectation
17
+ * The element has a non-empty accessible name from aria-label, from an
18
+ * aria-labelledby that resolves to non-empty text, or from title.
19
+ * role="listbox" is name-from-author-only, so subtree text is never
20
+ * accepted: text sitting inside a custom listbox widget is not reliably
21
+ * exposed as its name. On a labelable element (<select multiple
22
+ * role="listbox">) an associated <label> counts as well.
23
+ */
24
+
5
25
  const id = 'listbox-name-present';
6
26
 
7
27
  const meta = {
8
- title: 'Accessible name is present',
9
- description: 'Checks that elements expose a non-empty accessible name.',
28
+ title: 'Listboxes have an accessible name',
29
+ description: 'Checks that elements with role="listbox" expose a non-empty accessible name.',
10
30
  i18n: {
11
31
  titleKey: 'listboxNamePresent_title',
12
32
  descriptionKey: 'listboxNamePresent_description'
@@ -51,7 +71,7 @@ function runInPage(ctx) {
51
71
  }
52
72
 
53
73
  function getConservativeSubtreeText(document, container) {
54
- // "Name from content" — recurses into descendants and uses each one's
74
+ // "Name from content", recurses into descendants and uses each one's
55
75
  // own accessible name (img alt, aria-label/aria-labelledby, title) when
56
76
  // it has one, not just literal text nodes. See getContentNameInfo's
57
77
  // header comment in src/core/dom-helpers.js for the full rationale
@@ -68,7 +88,7 @@ function runInPage(ctx) {
68
88
 
69
89
  // A <label> contributes a name via its own aria-label/aria-labelledby
70
90
  // (checked first, same ARIA-over-content precedence any element's
71
- // accessible name gives — e.g. <label aria-label="Search"><svg
91
+ // accessible name gives, e.g. <label aria-label="Search"><svg
72
92
  // aria-hidden="true">...</svg></label> names its control "Search" even
73
93
  // though the label's only child content is aria-hidden) or, failing
74
94
  // that, its rendered content (getConservativeSubtreeText).
@@ -83,10 +103,10 @@ function runInPage(ctx) {
83
103
  if (content) return content;
84
104
  // Final fallback per the general accname text-alternative algorithm,
85
105
  // which applies to any element being asked for its name regardless of
86
- // why (own aria-label, an aria-labelledby reference, or — here — native
106
+ // why (own aria-label, an aria-labelledby reference, or, here, native
87
107
  // <label for> association): title, when nothing else yields a name.
88
108
  // Purely additive (only fills in a name where there was none before),
89
- // so it carries no false-positive risk — see dialog-name-present.js's
109
+ // so it carries no false-positive risk, see dialog-name-present.js's
90
110
  // identical <iframe>-title-fallback fix for the concrete real-world
91
111
  // trigger this same accname step covers elsewhere.
92
112
  return getAttr(lab, 'title');
@@ -96,7 +116,7 @@ function runInPage(ctx) {
96
116
  const raw = getAttr(el, 'aria-labelledby');
97
117
  if (!raw) return '';
98
118
  // Delegates to the shared getTextFromIdRefs helper instead of computing
99
- // name-from-content of the referenced element — see dialog-name-
119
+ // name-from-content of the referenced element, see dialog-name-
100
120
  // present.js's identical fix for the full rationale (an <iframe>
101
121
  // aria-labelledby target's only name source is its title attribute,
102
122
  // which name-from-content alone can never see).
@@ -231,7 +251,7 @@ function runInPage(ctx) {
231
251
  occurrences.push(
232
252
  helpers.reportOccurrence(el, {
233
253
  summary: 'This element has no accessible name.',
234
- hint: "Provide aria-label, aria-labelledby, or a title attribute — visible text content is not exposed as this listbox's accessible name.",
254
+ hint: "Provide aria-label, aria-labelledby, or a title attribute. Visible text content is not exposed as this listbox's accessible name.",
235
255
  i18n: {
236
256
  summaryKey: 'listboxNamePresent_summary_fail',
237
257
  hintKey: 'listboxNamePresent_hint_fail',
@@ -24,16 +24,16 @@
24
24
  * e.g. aria-helpers.js getExplicitRole).
25
25
  * - An explicit role on the parent WINS over its tag name, in either
26
26
  * direction: a <ul role="menu"> no longer exposes role "list" (its own
27
- * native role is fully replaced by the explicit one — the same "any
27
+ * native role is fully replaced by the explicit one, the same "any
28
28
  * explicit role overrides the element's native role" ARIA principle
29
29
  * applied elsewhere in this engine), so an <li> inside it is invalid
30
30
  * despite the <ul> tag (e.g. `<ul role="menu"><li>...`). Conversely
31
31
  * role="presentation"/"none" on the parent is still a valid
32
- * (list-semantics-suppressing) parent — the accepted parent roles are
32
+ * (list-semantics-suppressing) parent, the accepted parent roles are
33
33
  * presentation, none, and list.
34
34
  * - The SAME "explicit role wins" principle applies to the <li> ELEMENT
35
35
  * ITSELF: an <li role="tab">/role="menuitem">/role="presentation"> etc.
36
- * is exposed to AT with that role, never "listitem" — the whole point
36
+ * is exposed to AT with that role, never "listitem". The whole point
37
37
  * of this check (list items need a valid list-container parent) doesn't
38
38
  * apply when the element isn't claiming listitem semantics in the first
39
39
  * place. Any `<li>` with an explicit `role` attribute is excluded from
@@ -108,7 +108,7 @@ function runInPage(ctx) {
108
108
  let valid;
109
109
  if (explicitRole) {
110
110
  // An explicit role always wins over the tag's native role, in either
111
- // direction — see the header comment.
111
+ // direction, see the header comment.
112
112
  valid = explicitRole === 'list' || explicitRole === 'presentation' || explicitRole === 'none';
113
113
  } else {
114
114
  valid = parentTag === 'ul' || parentTag === 'ol';
@@ -2,6 +2,24 @@
2
2
 
3
3
  'use strict';
4
4
 
5
+ /**
6
+ * @check menuitem-name-present
7
+ * @atomic true
8
+ * @summary Menu items must have an accessible name
9
+ * @standard WCAG 2.2
10
+ * @sc 4.1.2
11
+ * @applicability
12
+ * Applies to elements carrying role="menuitem", role="menuitemcheckbox" or
13
+ * role="menuitemradio" (the attribute must name one of those roles alone,
14
+ * not a fallback list) that are included in the accessibility tree.
15
+ * @expectation
16
+ * The element has a non-empty accessible name from aria-label, from an
17
+ * aria-labelledby that resolves to non-empty text, from title, or, all
18
+ * three roles being name-from-content, from its own subtree text, where a
19
+ * descendant's own name (an <img alt>, aria-label or title) counts as that
20
+ * descendant's contribution rather than only its text nodes.
21
+ */
22
+
5
23
  const id = 'menuitem-name-present';
6
24
 
7
25
  const meta = {
@@ -52,7 +70,7 @@ function runInPage(ctx) {
52
70
  }
53
71
 
54
72
  function getConservativeSubtreeText(document, container) {
55
- // "Name from content" — recurses into descendants and uses each one's
73
+ // "Name from content", recurses into descendants and uses each one's
56
74
  // own accessible name (img alt, aria-label/aria-labelledby, title) when
57
75
  // it has one, not just literal text nodes. See getContentNameInfo's
58
76
  // header comment in src/core/dom-helpers.js for the full rationale
@@ -71,7 +89,7 @@ function runInPage(ctx) {
71
89
  const raw = getAttr(el, 'aria-labelledby');
72
90
  if (!raw) return '';
73
91
  // Delegates to the shared getTextFromIdRefs helper instead of computing
74
- // name-from-content of the referenced element — see dialog-name-
92
+ // name-from-content of the referenced element, see dialog-name-
75
93
  // present.js's identical fix for the full rationale (an <iframe>
76
94
  // aria-labelledby target's only name source is its title attribute,
77
95
  // which name-from-content alone can never see).