@surea11y/core 1.6.0 → 1.8.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 (120) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/README.md +179 -90
  3. package/docs/ACT_RULE_MAPPING.md +10 -8
  4. package/docs/API_STABILITY.md +67 -6
  5. package/docs/BINDING_AUTHORS_GUIDE.md +104 -2
  6. package/docs/CI_INTEGRATIONS.md +43 -0
  7. package/docs/DESIGN_CHALLENGES.md +162 -2
  8. package/docs/EARL.md +100 -0
  9. package/docs/ENGINE_OPTIONS.md +109 -5
  10. package/docs/I18N.md +62 -20
  11. package/docs/INTEGRATION.md +4 -2
  12. package/docs/JUNIT.md +73 -0
  13. package/docs/LIMITATIONS.md +4 -1
  14. package/docs/OUTPUT_SCHEMA.md +62 -11
  15. package/docs/POLICY.md +1 -1
  16. package/docs/REPORT.md +7 -2
  17. package/docs/RULE_AUTHORING.md +83 -17
  18. package/docs/RULE_CATALOG.md +212 -139
  19. package/docs/RULE_EXAMPLES.md +2189 -0
  20. package/docs/RULE_HELPERS.md +390 -0
  21. package/docs/RULE_TAXONOMY.md +27 -6
  22. package/docs/SARIF.md +23 -3
  23. package/docs/WCAG_CONFORMANCE.md +64 -3
  24. package/package.json +41 -12
  25. package/profiles/index.js +14 -0
  26. package/src/checks/automatic/area-alt-present.js +87 -31
  27. package/src/checks/automatic/aria-allowed-attr.js +6 -0
  28. package/src/checks/automatic/aria-allowed-role.js +32 -23
  29. package/src/checks/automatic/aria-braille-equivalent.js +43 -17
  30. package/src/checks/automatic/aria-conditional-attr.js +17 -10
  31. package/src/checks/automatic/aria-deprecated-role.js +12 -0
  32. package/src/checks/automatic/aria-hidden-body.js +1 -1
  33. package/src/checks/automatic/aria-hidden-focus.js +74 -18
  34. package/src/checks/automatic/aria-prohibited-attr.js +22 -4
  35. package/src/checks/automatic/aria-prohibited-children.js +6 -6
  36. package/src/checks/automatic/aria-required-attr.js +88 -12
  37. package/src/checks/automatic/aria-required-children.js +33 -16
  38. package/src/checks/automatic/aria-required-parent.js +32 -6
  39. package/src/checks/automatic/aria-role-name-present.js +20 -3
  40. package/src/checks/automatic/aria-roles-valid.js +52 -21
  41. package/src/checks/automatic/aria-valid-attr-value.js +89 -24
  42. package/src/checks/automatic/aria-valid-attr.js +14 -9
  43. package/src/checks/automatic/autocomplete-valid.js +152 -26
  44. package/src/checks/automatic/avoid-inline-spacing.js +207 -15
  45. package/src/checks/automatic/button-name-present.js +2 -1
  46. package/src/checks/automatic/canvas-text-alternative-present.js +105 -14
  47. package/src/checks/automatic/combobox-name-present.js +34 -51
  48. package/src/checks/automatic/contrast-computable.js +45 -4
  49. package/src/checks/automatic/contrast-enhanced.js +16 -4
  50. package/src/checks/automatic/contrast-minimum.js +57 -11
  51. package/src/checks/automatic/css-orientation-lock.js +171 -12
  52. package/src/checks/automatic/definition-list-children-valid.js +67 -23
  53. package/src/checks/automatic/deprecated-elements-not-used.js +43 -38
  54. package/src/checks/automatic/dialog-name-present.js +28 -9
  55. package/src/checks/automatic/duplicate-id-aria.js +5 -0
  56. package/src/checks/automatic/duplicate-id.js +19 -10
  57. package/src/checks/automatic/form-control-single-label.js +9 -0
  58. package/src/checks/automatic/identical-iframes-same-purpose.js +229 -0
  59. package/src/checks/automatic/iframe-focusable-content.js +12 -4
  60. package/src/checks/automatic/iframe-title-unique.js +36 -81
  61. package/src/checks/automatic/input-image-alt-present.js +32 -20
  62. package/src/checks/automatic/label-in-name.js +78 -69
  63. package/src/checks/automatic/language-page-present.js +12 -6
  64. package/src/checks/automatic/link-in-text-block.js +512 -44
  65. package/src/checks/automatic/link-name-present.js +13 -5
  66. package/src/checks/automatic/list-children-valid.js +18 -1
  67. package/src/checks/automatic/listbox-name-present.js +19 -49
  68. package/src/checks/automatic/listitem-parent-valid.js +4 -3
  69. package/src/checks/automatic/meta-refresh-no-exceptions.js +3 -3
  70. package/src/checks/automatic/page-title-present.js +16 -4
  71. package/src/checks/automatic/progressbar-name-present.js +11 -1
  72. package/src/checks/automatic/{role-img-alt-present.js → role-img-text-alternative-present.js} +9 -5
  73. package/src/checks/automatic/searchbox-name-present.js +32 -49
  74. package/src/checks/automatic/server-side-image-map-absent.js +48 -28
  75. package/src/checks/automatic/slider-name-present.js +38 -52
  76. package/src/checks/automatic/spinbutton-name-present.js +32 -49
  77. package/src/checks/automatic/target-size-minimum.js +84 -16
  78. package/src/checks/automatic/td-has-header.js +60 -23
  79. package/src/checks/automatic/text-spacing-content-loss.js +548 -0
  80. package/src/checks/automatic/textbox-name-present.js +32 -49
  81. package/src/checks/automatic/valid-lang.js +15 -10
  82. package/src/checks/manual/area-alt-quality-manual.js +113 -31
  83. package/src/checks/manual/canvas-text-alternative-quality-manual.js +0 -2
  84. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +23 -10
  85. package/src/checks/manual/css-hidden-focus.js +215 -7
  86. package/src/checks/manual/form-control-label-quality-manual.js +243 -29
  87. package/src/checks/manual/heading-order-manual.js +9 -1
  88. package/src/checks/manual/heading-quality-manual.js +143 -9
  89. package/src/checks/manual/img-alt-decorative-manual.js +6 -3
  90. package/src/checks/manual/input-image-alt-quality-manual.js +81 -13
  91. package/src/checks/manual/landmark-complementary-is-top-level-manual.js +231 -0
  92. package/src/checks/manual/link-name-quality-manual.js +130 -4
  93. package/src/checks/manual/media-transcript-present-manual.js +65 -8
  94. package/src/checks/manual/mouse-only-event-handlers-manual.js +66 -5
  95. package/src/checks/manual/no-autoplay-audio-manual.js +93 -6
  96. package/src/checks/manual/p-as-heading-manual.js +89 -44
  97. package/src/checks/manual/page-title-patterns-manual.js +77 -8
  98. package/src/checks/manual/password-paste-enabled-manual.js +255 -0
  99. package/src/checks/manual/skip-link-manual.js +42 -14
  100. package/src/checks/manual/table-fake-caption-manual.js +32 -1
  101. package/src/checks/manual/video-caption-manual.js +47 -24
  102. package/src/checks/manual-review.js +0 -4
  103. package/src/core.js +18061 -46194
  104. package/src/coverage/en301549-map.js +187 -0
  105. package/src/coverage/standards.js +279 -0
  106. package/src/coverage/wcag-facets.js +1119 -0
  107. package/src/coverage/wcag-version-map.js +101 -0
  108. package/src/earl.js +144 -0
  109. package/src/en301549.js +33 -0
  110. package/src/junit.js +321 -0
  111. package/src/profile-kit.js +163 -0
  112. package/src/report.js +343 -74
  113. package/src/sarif.js +56 -5
  114. package/src/wcag.js +105 -0
  115. package/surea11y.browser.js +11 -41039
  116. package/surea11y.i18n.de.js +2 -21
  117. package/surea11y.i18n.es.js +2 -21
  118. package/surea11y.i18n.fr.js +2 -21
  119. package/surea11y.i18n.ja.js +3 -0
  120. package/src/checks/manual/area-alt-decorative-manual.js +0 -255
@@ -9,41 +9,48 @@
9
9
  * @standard WCAG 2.2
10
10
  * @sc 1.4.1
11
11
  * @applicability
12
- * Applies to <a href> elements whose immediate parent element also has
13
- * at least one direct-child text node with non-whitespace content
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).
12
+ * Applies to links (`<a href>` and elements with `role="link"`) whose
13
+ * immediate parent element also has at least one direct-child text node
14
+ * with non-whitespace content (i.e. the link sits inline within a run of
15
+ * plain text, not as a standalone item, e.g. not the sole content of a
16
+ * <li> nav item).
16
17
  * @expectation
17
18
  * A link inside a text block must be visually distinguishable from the
18
19
  * surrounding text by at least one non-color means:
19
20
  * - text-decoration: underline, OR
20
- * - a different font-weight than the surrounding text, OR
21
- * - a different font-style than the surrounding text, OR
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,
24
- * sufficient contrast alone is an accepted alternative to underline).
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,
27
- * i.e. color is demonstrably the only cue.
21
+ * - a different font-weight or font-style than the surrounding text, OR
22
+ * - another visible mark on the link itself: a border, box-shadow or
23
+ * outline, a background color different from the surrounding one, a
24
+ * background image, an image or svg inside it, or ::before/::after
25
+ * content.
26
+ * A link with none of these is distinguished by color alone. When its
27
+ * color contrasts with the surrounding text by at least 3:1, technique
28
+ * G183 is met only if hover and focus also bring a non-color cue, which
29
+ * a static scan cannot see, so the link is reported as cantTell. Below
30
+ * 3:1, with contrast confidently computable, color is demonstrably the
31
+ * only cue and the link fails.
28
32
  * @implementation-notes
29
33
  * - "Surrounding text style" is approximated as the link's immediate
30
34
  * parent element's own computed style, not a full inline-context walk
31
35
  * of the actual adjacent text node(s), a deliberate scope-down, since
32
36
  * plain text nodes inherit their rendering from the parent in the
33
37
  * 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.
38
+ * - A candidate that cannot be evaluated reports cantTell, not pass:
39
+ * contrast not confidently computable (the blockers `contrast-minimum`/
40
+ * `contrast-computable` use), or `text-decoration` unreadable in both
41
+ * the computed style and the CSSOM (see `decorationInfo`). `fail` stays
42
+ * reserved for deterministic violations; this is the computability gate
43
+ * RULE_TAXONOMY.md §1.1 allows automatic rules, as in `contrast-minimum`
44
+ * and `target-size-minimum`.
41
45
  * - Reuses the shared `helpers.contrast` subsystem (same
42
46
  * computeEffectiveForeground/Background, getComputabilityBlocker,
43
47
  * contrastRatio helpers as `contrast-minimum`), rather than re-deriving
44
48
  * color math independently.
45
- * - Scoped to `a[href]` only (not `area[href]` or `[role="link"]`),
46
- * matches the common real-world shape of this issue (prose links).
49
+ * - ::before/::after content is read from the CSSOM only (a DOM emulator
50
+ * does not compute pseudo-element styles), so content declared in a
51
+ * cross-origin stylesheet is not seen.
52
+ * - Only the resting state is evaluated. The :hover, :focus and :visited
53
+ * states are not.
47
54
  */
48
55
 
49
56
  const id = 'link-in-text-block';
@@ -52,7 +59,7 @@ const meta = {
52
59
  title:
53
60
  'Links in text blocks must be distinguishable from surrounding text without relying on color alone',
54
61
  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.',
62
+ 'Checks that a link inside a run of text is visually distinguishable from the surrounding text by a non-color cue (underline, font-weight or style, border, background, icon), and asks about links distinguished only by a >=3:1 color difference, which also need a hover and focus cue.',
56
63
  i18n: {
57
64
  titleKey: 'linkInTextBlock_title',
58
65
  descriptionKey: 'linkInTextBlock_description'
@@ -103,14 +110,324 @@ function runInPage(ctx) {
103
110
  return null;
104
111
  }
105
112
 
106
- function decorationTokens(cs) {
107
- const raw =
108
- `${(cs && cs.textDecorationLine) || ''} ${(cs && cs.textDecoration) || ''}`.toLowerCase();
109
- return raw.split(/\s+/).filter(Boolean);
113
+ // Whether the element is underlined, and whether the computed style can be
114
+ // trusted to say so. A conforming CSSOM serialises the `text-decoration`
115
+ // shorthand with the line value first, so it and `text-decoration-line`
116
+ // always agree on whether `underline` is present. jsdom does not cascade
117
+ // the property at all: the shorthand reads back as the UA's "underline"
118
+ // for every <a> whatever the author CSS says, and the longhand as "none"
119
+ // unless the author used the longhand. Either one taken alone is wrong in
120
+ // one direction, so disagreement is the signal to stop trusting both.
121
+ function decorationInfo(cs) {
122
+ if (!cs) return { underlined: false, trustworthy: false };
123
+
124
+ const lineRaw = String(cs.textDecorationLine || '')
125
+ .trim()
126
+ .toLowerCase();
127
+ const shortRaw = String(cs.textDecoration || '')
128
+ .trim()
129
+ .toLowerCase();
130
+
131
+ const lineTokens = lineRaw.split(/\s+/).filter(Boolean);
132
+ const shortTokens = shortRaw.split(/\s+/).filter(Boolean);
133
+
134
+ // Only one of the two exposed: nothing to cross-check against, take it.
135
+ if (!lineTokens.length) {
136
+ return { underlined: shortTokens.includes('underline'), trustworthy: shortTokens.length > 0 };
137
+ }
138
+ if (!shortTokens.length) {
139
+ return { underlined: lineTokens.includes('underline'), trustworthy: true };
140
+ }
141
+
142
+ const byLine = lineTokens.includes('underline');
143
+ const byShort = shortTokens.includes('underline');
144
+ return { underlined: byLine, trustworthy: byLine === byShort };
145
+ }
146
+
147
+ // Resolves `text-decoration` from the author stylesheets when the computed
148
+ // style is untrustworthy, reading the CSSOM as `css-orientation-lock` and
149
+ // `css-focus-indicator-suppressed` do. Without it the rule could not decide
150
+ // anything under a DOM emulator, which is how the CLI scans static HTML.
151
+ //
152
+ // A narrow cascade is enough: `text-decoration-line` is not inherited, so
153
+ // only declarations matching the element itself and its inline style apply,
154
+ // ordered by specificity. With no author declaration the UA default stands,
155
+ // and for a link that is an underline. Anything that would make the answer a
156
+ // guess yields `resolved: false` and the caller reports cantTell.
157
+ const CSS_STYLE_RULE = 1;
158
+ const MAX_NESTED_DEPTH = 8;
159
+
160
+ function splitSelectorList(selectorText) {
161
+ const parts = [];
162
+ let depth = 0;
163
+ let current = '';
164
+ for (const ch of String(selectorText || '')) {
165
+ if (ch === '(') depth += 1;
166
+ if (ch === ')') depth = Math.max(0, depth - 1);
167
+ if (ch === ',' && depth === 0) {
168
+ parts.push(current);
169
+ current = '';
170
+ continue;
171
+ }
172
+ current += ch;
173
+ }
174
+ parts.push(current);
175
+ return parts.map((p) => p.trim()).filter(Boolean);
176
+ }
177
+
178
+ // Approximate CSS specificity as a single sortable integer. Exactness is
179
+ // not required: this only orders declarations of one property against each
180
+ // other, and near-ties are broken by document order as the cascade does.
181
+ function specificityOf(selector) {
182
+ const s = String(selector || '');
183
+ const ids = (s.match(/#[\w-]+/g) || []).length;
184
+ const classesEtc = (s.match(/\.[\w-]+|\[[^\]]*\]|:(?!:)[\w-]+/g) || []).length;
185
+ const types = (s.match(/(^|[\s>+~])[a-z][\w-]*/gi) || []).length;
186
+ return ids * 10000 + classesEtc * 100 + types;
187
+ }
188
+
189
+ // A declaration wins if it is the last one, in (specificity, order), whose
190
+ // selector matches. `!important` outranks everything non-important.
191
+ function underlineFromDeclaration(style) {
192
+ if (!style || typeof style.getPropertyValue !== 'function') return null;
193
+ for (const prop of ['text-decoration-line', 'text-decoration']) {
194
+ const raw = String(style.getPropertyValue(prop) || '')
195
+ .trim()
196
+ .toLowerCase();
197
+ if (!raw) continue;
198
+ const important = String(style.getPropertyPriority(prop) || '') === 'important';
199
+ return { underlined: /\bunderline\b/.test(raw), important };
200
+ }
201
+ return null;
202
+ }
203
+
204
+ // The user agent underlines `a[href]`; an element with role="link" has no
205
+ // default decoration.
206
+ function uaUnderlines(el) {
207
+ return (
208
+ String(el.localName || '').toLowerCase() === 'a' &&
209
+ typeof el.hasAttribute === 'function' &&
210
+ el.hasAttribute('href')
211
+ );
212
+ }
213
+
214
+ function resolveUnderlineFromCssom(el) {
215
+ const doc = el && el.ownerDocument ? el.ownerDocument : null;
216
+ if (!doc || typeof el.matches !== 'function') return { underlined: false, resolved: false };
217
+
218
+ let best = null; // { rank, order, underlined }
219
+ let order = 0;
220
+ let unreadableSheet = false;
221
+ let unparsableSelector = false;
222
+
223
+ function consider(cssRule) {
224
+ const decl = underlineFromDeclaration(cssRule.style);
225
+ if (!decl) return;
226
+ for (const part of splitSelectorList(cssRule.selectorText)) {
227
+ // A pseudo-element rule paints a box other than the link's own text.
228
+ if (/::[a-z-]+/i.test(part)) continue;
229
+ // A state the static DOM is not in (:hover/:focus/...) does not
230
+ // describe the link's resting appearance, which is what this rule is
231
+ // about.
232
+ if (/:(hover|focus|focus-visible|focus-within|active|target|visited)\b/i.test(part)) {
233
+ continue;
234
+ }
235
+ let matched;
236
+ try {
237
+ matched = el.matches(part);
238
+ } catch {
239
+ unparsableSelector = true;
240
+ continue;
241
+ }
242
+ if (!matched) continue;
243
+ order += 1;
244
+ const rank = (decl.important ? 1e9 : 0) + specificityOf(part);
245
+ if (!best || rank >= best.rank) best = { rank, order, underlined: decl.underlined };
246
+ }
247
+ }
248
+
249
+ function walk(rules, depth) {
250
+ if (!rules || depth > MAX_NESTED_DEPTH) return;
251
+ for (const cssRule of rules) {
252
+ if (!cssRule) continue;
253
+ if (cssRule.type === CSS_STYLE_RULE && cssRule.selectorText) {
254
+ consider(cssRule);
255
+ continue;
256
+ }
257
+ let nested;
258
+ try {
259
+ nested = cssRule.cssRules || null;
260
+ } catch {
261
+ nested = null;
262
+ }
263
+ if (nested) walk(nested, depth + 1);
264
+ }
265
+ }
266
+
267
+ try {
268
+ for (const sheet of doc.styleSheets || []) {
269
+ let rules = null;
270
+ try {
271
+ rules = sheet && sheet.cssRules ? sheet.cssRules : null;
272
+ } catch {
273
+ unreadableSheet = true; // cross-origin, not inspectable
274
+ continue;
275
+ }
276
+ if (rules) walk(rules, 0);
277
+ }
278
+ } catch {
279
+ return { underlined: false, resolved: false };
280
+ }
281
+
282
+ // The inline style attribute outranks every stylesheet declaration.
283
+ const inline = underlineFromDeclaration(el.style);
284
+ if (inline) return { underlined: inline.underlined, resolved: true };
285
+
286
+ if (best) return { underlined: best.underlined, resolved: true };
287
+
288
+ // No author declaration reached this element. If a sheet or selector was
289
+ // unreadable, one of them might have, so the answer is unknown; otherwise
290
+ // the UA default stands, and for a link that means underlined.
291
+ if (unreadableSheet || unparsableSelector) return { underlined: false, resolved: false };
292
+ return { underlined: uaUnderlines(el), resolved: true };
293
+ }
294
+
295
+ // ---- Non-color cues on the link itself ----
296
+ const LINE_STYLES = /^(solid|dashed|dotted|double|groove|ridge|inset|outset|auto)$/;
297
+
298
+ function isZeroWidth(v) {
299
+ return /^0(\.0+)?[a-z%]*$/i.test(String(v || '').trim());
300
+ }
301
+
302
+ function hasBorder(cs) {
303
+ for (const side of ['Top', 'Right', 'Bottom', 'Left']) {
304
+ const style = String(cs['border' + side + 'Style'] || '')
305
+ .trim()
306
+ .toLowerCase();
307
+ if (!style || style === 'none' || style === 'hidden') continue;
308
+ if (!isZeroWidth(cs['border' + side + 'Width'])) return true;
309
+ }
310
+ return false;
311
+ }
312
+
313
+ // Some environments do not expand the `outline` shorthand into its
314
+ // longhands, so it is read too.
315
+ function hasOutline(cs) {
316
+ const style = String(cs.outlineStyle || '')
317
+ .trim()
318
+ .toLowerCase();
319
+ if (style && style !== 'none' && style !== 'hidden') return !isZeroWidth(cs.outlineWidth);
320
+ const tokens = String((cs.getPropertyValue && cs.getPropertyValue('outline')) || '')
321
+ .trim()
322
+ .toLowerCase()
323
+ .split(/\s+/)
324
+ .filter(Boolean);
325
+ return tokens.some((t) => LINE_STYLES.test(t)) && !tokens.some((t) => isZeroWidth(t));
326
+ }
327
+
328
+ function hasBoxShadow(cs) {
329
+ const v = String(cs.boxShadow || '')
330
+ .trim()
331
+ .toLowerCase();
332
+ return !!v && v !== 'none';
333
+ }
334
+
335
+ function hasBackgroundImage(cs) {
336
+ const v = String(cs.backgroundImage || '')
337
+ .trim()
338
+ .toLowerCase();
339
+ return !!v && v !== 'none';
340
+ }
341
+
342
+ function hasVisibleImageChild(el) {
343
+ let imgs;
344
+ try {
345
+ imgs = Array.from(el.querySelectorAll('img, svg, picture, canvas, [role="img"]'));
346
+ } catch {
347
+ return false;
348
+ }
349
+ return imgs.some((img) => {
350
+ if (!helpers.isDomVisibleEligible) return true;
351
+ try {
352
+ const vis = helpers.isDomVisibleEligible(img, ctx, {
353
+ visibilityMode: 'styleOnly',
354
+ disableGeometry: true
355
+ });
356
+ return !(vis && vis.eligible === false);
357
+ } catch {
358
+ return true;
359
+ }
360
+ });
361
+ }
362
+
363
+ const EMPTY_CONTENT = ['', 'none', 'normal', '""', "''"];
364
+ let pseudoContentRules = null;
365
+ // Style rules that put content in a ::before/::after box, by the selector
366
+ // of the element that box belongs to.
367
+ function getPseudoContentRules(doc) {
368
+ if (pseudoContentRules) return pseudoContentRules;
369
+ pseudoContentRules = [];
370
+ function consider(cssRule) {
371
+ const style = cssRule.style;
372
+ if (!style || typeof style.getPropertyValue !== 'function') return;
373
+ const content = String(style.getPropertyValue('content') || '').trim();
374
+ if (EMPTY_CONTENT.indexOf(content.toLowerCase()) !== -1) return;
375
+ for (const part of splitSelectorList(cssRule.selectorText)) {
376
+ if (!/::?(before|after)\s*$/i.test(part)) continue;
377
+ if (/:(hover|focus|focus-visible|focus-within|active|target|visited)\b/i.test(part)) {
378
+ continue;
379
+ }
380
+ pseudoContentRules.push(part.replace(/::?(before|after)\s*$/i, '').trim() || '*');
381
+ }
382
+ }
383
+ function walk(rules, depth) {
384
+ if (!rules || depth > MAX_NESTED_DEPTH) return;
385
+ for (const cssRule of rules) {
386
+ if (!cssRule) continue;
387
+ if (cssRule.type === CSS_STYLE_RULE && cssRule.selectorText) {
388
+ consider(cssRule);
389
+ continue;
390
+ }
391
+ let nested;
392
+ try {
393
+ nested = cssRule.cssRules || null;
394
+ } catch {
395
+ nested = null;
396
+ }
397
+ if (nested) walk(nested, depth + 1);
398
+ }
399
+ }
400
+ try {
401
+ for (const sheet of (doc && doc.styleSheets) || []) {
402
+ let rules = null;
403
+ try {
404
+ rules = sheet && sheet.cssRules ? sheet.cssRules : null;
405
+ } catch {
406
+ continue; // cross-origin, not inspectable
407
+ }
408
+ if (rules) walk(rules, 0);
409
+ }
410
+ } catch {
411
+ // no readable stylesheets
412
+ }
413
+ return pseudoContentRules;
110
414
  }
111
415
 
112
- function hasUnderline(cs) {
113
- return decorationTokens(cs).includes('underline');
416
+ function hasPseudoContent(el) {
417
+ return getPseudoContentRules(el.ownerDocument).some((base) => {
418
+ try {
419
+ return el.matches(base);
420
+ } catch {
421
+ return false;
422
+ }
423
+ });
424
+ }
425
+
426
+ function hasNonColorMark(el, cs) {
427
+ if (cs && (hasBorder(cs) || hasOutline(cs) || hasBoxShadow(cs) || hasBackgroundImage(cs))) {
428
+ return true;
429
+ }
430
+ return hasVisibleImageChild(el) || hasPseudoContent(el);
114
431
  }
115
432
 
116
433
  function hasSurroundingText(el, parent) {
@@ -135,13 +452,22 @@ function runInPage(ctx) {
135
452
 
136
453
  const c = helpers && helpers.contrast ? helpers.contrast : null;
137
454
 
138
- const selector = 'a[href]';
455
+ const selector = 'a[href], [role="link"]';
139
456
  const nodes = helpers.queryAllSmart
140
457
  ? helpers.queryAllSmart(selector)
141
458
  : helpers.queryAll(selector);
142
459
 
143
460
  const occurrences = [];
461
+ const undecided = [];
462
+ const contrastOnly = [];
144
463
  let applicableCount = 0;
464
+ let decidedCount = 0;
465
+
466
+ // Applicable, but not evaluable. Held separately so the outcome below can
467
+ // tell "checked and sound" apart from "never decided".
468
+ function markUndecided(el, reasonCode) {
469
+ undecided.push({ el, reasonCode });
470
+ }
145
471
 
146
472
  for (const el of nodes) {
147
473
  if (!el || !el.getAttribute) continue;
@@ -159,27 +485,48 @@ function runInPage(ctx) {
159
485
  const linkCs = safeComputedStyle(el);
160
486
  const parentCs = safeComputedStyle(parent);
161
487
 
162
- if (hasUnderline(linkCs)) continue;
163
-
488
+ // Cues that do not depend on `text-decoration` come first, so a link
489
+ // carrying one is decided even where decoration is unreadable.
164
490
  const linkWeight = c && linkCs ? c.normalizeFontWeight(linkCs.fontWeight) : 400;
165
491
  const parentWeight = c && parentCs ? c.normalizeFontWeight(parentCs.fontWeight) : 400;
166
- if (linkWeight !== parentWeight) continue;
492
+ if (linkWeight !== parentWeight) {
493
+ decidedCount += 1;
494
+ continue;
495
+ }
167
496
 
168
497
  const linkStyle = (linkCs && linkCs.fontStyle) || 'normal';
169
498
  const parentStyle = (parentCs && parentCs.fontStyle) || 'normal';
170
- if (linkStyle !== parentStyle) continue;
499
+ if (linkStyle !== parentStyle) {
500
+ decidedCount += 1;
501
+ continue;
502
+ }
171
503
 
172
- if (!c) continue;
504
+ // A border, box-shadow, outline, background image, icon or generated
505
+ // content marks the link without relying on color.
506
+ if (hasNonColorMark(el, linkCs)) {
507
+ decidedCount += 1;
508
+ continue;
509
+ }
510
+
511
+ if (!c) {
512
+ markUndecided(el, 'CONTRAST_HELPERS_UNAVAILABLE');
513
+ continue;
514
+ }
173
515
 
174
516
  let flagged = false;
517
+ let computed = false;
518
+ let backgroundDiffers = false;
175
519
  let ratio = null;
176
520
  let fgLinkHex = '';
177
521
  let fgParentHex = '';
522
+ let undecidedReason = 'COLOR_NOT_COMPUTABLE';
178
523
 
179
524
  try {
180
525
  const blocker = c.getComputabilityBlocker(el);
181
526
  if (blocker && blocker.ok === false) {
182
- // Not confidently computable, skip (benefit of the doubt).
527
+ // Not confidently computable: recorded below rather than skipped, so
528
+ // it cannot be mistaken for a clean result.
529
+ if (blocker.reasonCode) undecidedReason = String(blocker.reasonCode);
183
530
  } else {
184
531
  const bg = c.computeEffectiveBackground(el, {
185
532
  contrast: { mode, rootCanvasFallback },
@@ -202,15 +549,69 @@ function runInPage(ctx) {
202
549
  fgLinkHex = c.rgbToHex ? c.rgbToHex(fgLinkOpaque) : '';
203
550
  fgParentHex = c.rgbToHex ? c.rgbToHex(fgParentOpaque) : '';
204
551
 
552
+ computed = true;
205
553
  if (!(ratio >= 3)) flagged = true;
554
+
555
+ // A background color of the link's own, different from the one
556
+ // behind the surrounding text, marks it like a highlight.
557
+ const ownBg = String((linkCs && linkCs.backgroundColor) || '').replace(/\s+/g, '');
558
+ const transparentBg =
559
+ !ownBg || ownBg === 'transparent' || /^rgba\(\d+,\d+,\d+,0(\.0+)?\)$/.test(ownBg);
560
+ if (!transparentBg && c.rgbToHex) {
561
+ const parentBg = c.computeEffectiveBackground(parent, {
562
+ contrast: { mode, rootCanvasFallback },
563
+ collectStack: false
564
+ });
565
+ if (parentBg && parentBg.ok && parentBg.rgba) {
566
+ backgroundDiffers = c.rgbToHex(parentBg.rgba) !== c.rgbToHex(bg.rgba);
567
+ }
568
+ }
206
569
  }
207
- // else: not confidently computable, skip.
570
+ // else: not confidently computable, recorded below.
208
571
  }
209
572
  } catch {
210
- // no-throw: treat as not computable, skip.
573
+ // No-throw: treat as not computable and record it.
574
+ undecidedReason = 'ENGINE_EXCEPTION';
211
575
  }
212
576
 
213
- if (!flagged) continue;
577
+ if (!computed) {
578
+ markUndecided(el, undecidedReason);
579
+ continue;
580
+ }
581
+
582
+ if (backgroundDiffers) {
583
+ decidedCount += 1;
584
+ continue;
585
+ }
586
+
587
+ // An underline is the last remaining non-color cue -- and only now does
588
+ // it matter whether this environment can actually report one.
589
+ const decoration = decorationInfo(linkCs);
590
+ let underlined;
591
+ if (decoration.trustworthy) {
592
+ underlined = decoration.underlined;
593
+ } else {
594
+ const fromCssom = resolveUnderlineFromCssom(el);
595
+ if (!fromCssom.resolved) {
596
+ markUndecided(el, 'TEXT_DECORATION_NOT_RESOLVABLE');
597
+ continue;
598
+ }
599
+ underlined = fromCssom.underlined;
600
+ }
601
+
602
+ if (underlined) {
603
+ decidedCount += 1;
604
+ continue;
605
+ }
606
+
607
+ // Color is the only cue at rest. At 3:1 or more, G183 also needs a
608
+ // non-color cue on hover and focus, which a static scan cannot see.
609
+ if (!flagged) {
610
+ contrastOnly.push({ el, ratio, fgLinkHex, fgParentHex });
611
+ continue;
612
+ }
613
+
614
+ decidedCount += 1;
214
615
 
215
616
  const eligInfo = helpers.getEligibilityInfo
216
617
  ? helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' })
@@ -222,7 +623,7 @@ function runInPage(ctx) {
222
623
  helpers.reportOccurrence(el, {
223
624
  summary:
224
625
  'This link in a block of text relies on color alone to be distinguished from the surrounding text.',
225
- hint: 'Add an underline, a font-weight/style difference, or increase the color contrast between the link and surrounding text to at least 3:1.',
626
+ hint: 'Add an underline or another non-color cue (a font-weight or style difference, a border, an icon). Raising the color contrast with the surrounding text to 3:1 is enough only if hovering and focusing the link also add a non-color cue.',
226
627
  i18n: {
227
628
  summaryKey: 'linkInTextBlock_summary_fail',
228
629
  hintKey: 'linkInTextBlock_hint_fail',
@@ -243,15 +644,82 @@ function runInPage(ctx) {
243
644
  if (applicableCount === 0) {
244
645
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
245
646
  }
246
- if (occurrences.length) {
647
+
648
+ const cantTellOccurrences = undecided.map(({ el, reasonCode }) =>
649
+ helpers.reportOccurrence(el, {
650
+ occurrenceOutcome: 'cantTell',
651
+ summary:
652
+ 'Whether this link is distinguishable from the surrounding text by non-color means could not be determined.',
653
+ hint: 'Confirm by eye that the link carries an underline, a font-weight or font-style difference or another non-color mark, or at least 3:1 contrast against the surrounding text together with a non-color cue on hover and focus. Running the engine in a real browser rather than a DOM emulator resolves most cases automatically.',
654
+ i18n: {
655
+ summaryKey: 'linkInTextBlock_summary_cantTell',
656
+ hintKey: 'linkInTextBlock_hint_cantTell'
657
+ },
658
+ uncertainty: {
659
+ code: 'not-computable',
660
+ needed:
661
+ 'Whether the link carries an underline, weight or style difference, or 3:1 contrast against its surrounding text.',
662
+ evidence: { reasonCode }
663
+ },
664
+ data: {
665
+ visibilityFilter: helpers.getEligibilityInfo
666
+ ? helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' })
667
+ : { targetSet: 'acc', accEligible: null, reasons: [] },
668
+ details: { reasonCode }
669
+ }
670
+ })
671
+ );
672
+
673
+ const contrastOnlyOccurrences = contrastOnly.map(({ el, ratio, fgLinkHex, fgParentHex }) => {
674
+ const ratioStr = c && c.round2 ? c.round2(ratio) : String(ratio);
675
+ return helpers.reportOccurrence(el, {
676
+ occurrenceOutcome: 'cantTell',
677
+ summary: `This link in a block of text is distinguished from the surrounding text only by its color (contrast ${ratioStr}:1). That is enough only if hovering and focusing it also show a non-color cue, such as an underline.`,
678
+ hint: 'Hover over the link and move keyboard focus to it: confirm that each state adds a non-color cue (an underline, a border, a weight change). Otherwise underline the link at rest.',
679
+ i18n: {
680
+ summaryKey: 'linkInTextBlock_summary_cantTell_contrastOnly',
681
+ hintKey: 'linkInTextBlock_hint_cantTell_contrastOnly',
682
+ params: { ratio: String(ratioStr), threshold: '3' }
683
+ },
684
+ uncertainty: {
685
+ code: 'runtime-dependent',
686
+ needed: 'Whether hovering and focusing the link add a non-color cue.',
687
+ evidence: { reasonCode: 'LINK_COLOR_CONTRAST_ONLY', ratio }
688
+ },
689
+ data: {
690
+ visibilityFilter: helpers.getEligibilityInfo
691
+ ? helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' })
692
+ : { targetSet: 'acc', accEligible: null, reasons: [] },
693
+ details: {
694
+ reasonCode: 'LINK_COLOR_CONTRAST_ONLY',
695
+ metrics: { ratio, threshold: 3 },
696
+ colors: { linkForegroundHex: fgLinkHex, surroundingTextForegroundHex: fgParentHex }
697
+ }
698
+ }
699
+ });
700
+ });
701
+
702
+ // See helpers.resolveTieredOutcome (src/core/dom-helpers.js): a proven
703
+ // violation outranks an undecided candidate for the rule's own outcome, but
704
+ // never discards it, so an unevaluable link survives a failure elsewhere in
705
+ // the same run.
706
+ if (occurrences.length || cantTellOccurrences.length || contrastOnlyOccurrences.length) {
707
+ const resolved = helpers.resolveTieredOutcome(
708
+ occurrences,
709
+ contrastOnlyOccurrences.concat(cantTellOccurrences),
710
+ rule.defaultSeverity || 'serious'
711
+ );
247
712
  return {
248
713
  ruleId: rule.ruleId,
249
- outcome: 'fail',
250
- severity: rule.defaultSeverity || 'serious',
251
- occurrences
714
+ ...resolved,
715
+ ...(resolved.outcome === 'cantTell' ? { confidence: 'low' } : null)
252
716
  };
253
717
  }
254
- return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
718
+
719
+ if (decidedCount > 0) {
720
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
721
+ }
722
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
255
723
  }
256
724
 
257
725
  module.exports = { id, meta, runInPage };