@surea11y/core 1.7.0 → 1.8.1

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 (103) hide show
  1. package/CHANGELOG.md +103 -1
  2. package/README.md +157 -54
  3. package/docs/ACT_RULE_MAPPING.md +8 -7
  4. package/docs/API_STABILITY.md +18 -5
  5. package/docs/BINDING_AUTHORS_GUIDE.md +2 -2
  6. package/docs/CI_INTEGRATIONS.md +43 -0
  7. package/docs/DESIGN_CHALLENGES.md +97 -3
  8. package/docs/EARL.md +2 -2
  9. package/docs/ENGINE_OPTIONS.md +81 -3
  10. package/docs/I18N.md +62 -20
  11. package/docs/JUNIT.md +73 -0
  12. package/docs/LIMITATIONS.md +1 -0
  13. package/docs/OUTPUT_SCHEMA.md +19 -6
  14. package/docs/REPORT.md +7 -2
  15. package/docs/RULE_AUTHORING.md +73 -6
  16. package/docs/RULE_CATALOG.md +139 -116
  17. package/docs/RULE_EXAMPLES.md +2189 -0
  18. package/docs/RULE_HELPERS.md +62 -5
  19. package/docs/RULE_TAXONOMY.md +2 -2
  20. package/docs/SARIF.md +2 -1
  21. package/docs/WCAG_CONFORMANCE.md +56 -3
  22. package/package.json +34 -11
  23. package/profiles/index.js +14 -0
  24. package/src/checks/automatic/area-alt-present.js +87 -31
  25. package/src/checks/automatic/aria-braille-equivalent.js +25 -7
  26. package/src/checks/automatic/aria-hidden-focus.js +74 -18
  27. package/src/checks/automatic/aria-prohibited-attr.js +17 -4
  28. package/src/checks/automatic/aria-required-attr.js +29 -0
  29. package/src/checks/automatic/aria-role-name-present.js +19 -2
  30. package/src/checks/automatic/aria-valid-attr-value.js +28 -16
  31. package/src/checks/automatic/autocomplete-valid.js +26 -11
  32. package/src/checks/automatic/avoid-inline-spacing.js +105 -40
  33. package/src/checks/automatic/button-name-present.js +2 -1
  34. package/src/checks/automatic/canvas-text-alternative-present.js +105 -14
  35. package/src/checks/automatic/combobox-name-present.js +34 -51
  36. package/src/checks/automatic/contrast-computable.js +35 -4
  37. package/src/checks/automatic/contrast-enhanced.js +4 -4
  38. package/src/checks/automatic/contrast-minimum.js +45 -11
  39. package/src/checks/automatic/css-orientation-lock.js +152 -30
  40. package/src/checks/automatic/definition-list-children-valid.js +67 -23
  41. package/src/checks/automatic/deprecated-elements-not-used.js +43 -38
  42. package/src/checks/automatic/dialog-name-present.js +28 -9
  43. package/src/checks/automatic/duplicate-id.js +6 -2
  44. package/src/checks/automatic/identical-iframes-same-purpose.js +4 -4
  45. package/src/checks/automatic/iframe-focusable-content.js +7 -4
  46. package/src/checks/automatic/iframe-title-unique.js +36 -81
  47. package/src/checks/automatic/input-image-alt-present.js +32 -20
  48. package/src/checks/automatic/label-in-name.js +40 -13
  49. package/src/checks/automatic/language-page-present.js +12 -6
  50. package/src/checks/automatic/link-in-text-block.js +272 -60
  51. package/src/checks/automatic/link-name-present.js +13 -5
  52. package/src/checks/automatic/list-children-valid.js +18 -1
  53. package/src/checks/automatic/listbox-name-present.js +19 -49
  54. package/src/checks/automatic/listitem-parent-valid.js +4 -3
  55. package/src/checks/automatic/meta-refresh-no-exceptions.js +3 -3
  56. package/src/checks/automatic/page-title-present.js +16 -4
  57. package/src/checks/automatic/progressbar-name-present.js +11 -1
  58. package/src/checks/automatic/role-img-text-alternative-present.js +9 -5
  59. package/src/checks/automatic/searchbox-name-present.js +32 -49
  60. package/src/checks/automatic/server-side-image-map-absent.js +48 -28
  61. package/src/checks/automatic/slider-name-present.js +38 -52
  62. package/src/checks/automatic/spinbutton-name-present.js +32 -49
  63. package/src/checks/automatic/target-size-minimum.js +0 -11
  64. package/src/checks/automatic/td-has-header.js +41 -5
  65. package/src/checks/automatic/text-spacing-content-loss.js +548 -0
  66. package/src/checks/automatic/textbox-name-present.js +32 -49
  67. package/src/checks/automatic/valid-lang.js +15 -10
  68. package/src/checks/manual/area-alt-quality-manual.js +113 -31
  69. package/src/checks/manual/canvas-text-alternative-quality-manual.js +0 -2
  70. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +23 -10
  71. package/src/checks/manual/css-hidden-focus.js +215 -7
  72. package/src/checks/manual/form-control-label-quality-manual.js +109 -5
  73. package/src/checks/manual/heading-order-manual.js +9 -1
  74. package/src/checks/manual/heading-quality-manual.js +143 -9
  75. package/src/checks/manual/img-alt-decorative-manual.js +6 -3
  76. package/src/checks/manual/input-image-alt-quality-manual.js +81 -13
  77. package/src/checks/manual/link-name-quality-manual.js +130 -4
  78. package/src/checks/manual/media-transcript-present-manual.js +65 -8
  79. package/src/checks/manual/mouse-only-event-handlers-manual.js +66 -5
  80. package/src/checks/manual/no-autoplay-audio-manual.js +93 -6
  81. package/src/checks/manual/p-as-heading-manual.js +89 -44
  82. package/src/checks/manual/page-title-patterns-manual.js +77 -8
  83. package/src/checks/manual/skip-link-manual.js +42 -14
  84. package/src/checks/manual/table-fake-caption-manual.js +32 -1
  85. package/src/checks/manual/video-caption-manual.js +47 -24
  86. package/src/checks/manual-review.js +0 -4
  87. package/src/core.js +14285 -2219
  88. package/src/coverage/en301549-map.js +187 -0
  89. package/src/coverage/standards.js +279 -0
  90. package/src/coverage/wcag-facets.js +1119 -0
  91. package/src/coverage/wcag-version-map.js +101 -0
  92. package/src/en301549.js +33 -0
  93. package/src/junit.js +321 -0
  94. package/src/profile-kit.js +163 -0
  95. package/src/report.js +343 -74
  96. package/src/sarif.js +34 -3
  97. package/src/wcag.js +105 -0
  98. package/surea11y.browser.js +5 -4
  99. package/surea11y.i18n.de.js +1 -1
  100. package/surea11y.i18n.es.js +1 -1
  101. package/surea11y.i18n.fr.js +1 -1
  102. package/surea11y.i18n.ja.js +3 -0
  103. package/src/checks/manual/area-alt-decorative-manual.js +0 -255
@@ -9,22 +9,26 @@
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
@@ -42,8 +46,11 @@
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'
@@ -194,6 +201,16 @@ function runInPage(ctx) {
194
201
  return null;
195
202
  }
196
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
+
197
214
  function resolveUnderlineFromCssom(el) {
198
215
  const doc = el && el.ownerDocument ? el.ownerDocument : null;
199
216
  if (!doc || typeof el.matches !== 'function') return { underlined: false, resolved: false };
@@ -272,7 +289,145 @@ function runInPage(ctx) {
272
289
  // unreadable, one of them might have, so the answer is unknown; otherwise
273
290
  // the UA default stands, and for a link that means underlined.
274
291
  if (unreadableSheet || unparsableSelector) return { underlined: false, resolved: false };
275
- return { underlined: true, resolved: true };
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;
414
+ }
415
+
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);
276
431
  }
277
432
 
278
433
  function hasSurroundingText(el, parent) {
@@ -297,13 +452,14 @@ function runInPage(ctx) {
297
452
 
298
453
  const c = helpers && helpers.contrast ? helpers.contrast : null;
299
454
 
300
- const selector = 'a[href]';
455
+ const selector = 'a[href], [role="link"]';
301
456
  const nodes = helpers.queryAllSmart
302
457
  ? helpers.queryAllSmart(selector)
303
458
  : helpers.queryAll(selector);
304
459
 
305
460
  const occurrences = [];
306
461
  const undecided = [];
462
+ const contrastOnly = [];
307
463
  let applicableCount = 0;
308
464
  let decidedCount = 0;
309
465
 
@@ -345,6 +501,13 @@ function runInPage(ctx) {
345
501
  continue;
346
502
  }
347
503
 
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
+
348
511
  if (!c) {
349
512
  markUndecided(el, 'CONTRAST_HELPERS_UNAVAILABLE');
350
513
  continue;
@@ -352,6 +515,7 @@ function runInPage(ctx) {
352
515
 
353
516
  let flagged = false;
354
517
  let computed = false;
518
+ let backgroundDiffers = false;
355
519
  let ratio = null;
356
520
  let fgLinkHex = '';
357
521
  let fgParentHex = '';
@@ -387,6 +551,21 @@ function runInPage(ctx) {
387
551
 
388
552
  computed = true;
389
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
+ }
390
569
  }
391
570
  // else: not confidently computable, recorded below.
392
571
  }
@@ -400,14 +579,12 @@ function runInPage(ctx) {
400
579
  continue;
401
580
  }
402
581
 
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) {
582
+ if (backgroundDiffers) {
406
583
  decidedCount += 1;
407
584
  continue;
408
585
  }
409
586
 
410
- // Below 3:1, an underline is the last remaining cue -- and only now does
587
+ // An underline is the last remaining non-color cue -- and only now does
411
588
  // it matter whether this environment can actually report one.
412
589
  const decoration = decorationInfo(linkCs);
413
590
  let underlined;
@@ -427,6 +604,13 @@ function runInPage(ctx) {
427
604
  continue;
428
605
  }
429
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
+
430
614
  decidedCount += 1;
431
615
 
432
616
  const eligInfo = helpers.getEligibilityInfo
@@ -439,7 +623,7 @@ function runInPage(ctx) {
439
623
  helpers.reportOccurrence(el, {
440
624
  summary:
441
625
  'This link in a block of text relies on color alone to be distinguished from the surrounding text.',
442
- 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.',
443
627
  i18n: {
444
628
  summaryKey: 'linkInTextBlock_summary_fail',
445
629
  hintKey: 'linkInTextBlock_hint_fail',
@@ -461,46 +645,74 @@ function runInPage(ctx) {
461
645
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
462
646
  }
463
647
 
464
- // A proven violation outranks an undecided candidate, which outranks a clean
465
- // one. The middle step keeps an unevaluable link out of `pass`.
466
- if (occurrences.length) {
467
- return {
468
- ruleId: rule.ruleId,
469
- outcome: 'fail',
470
- severity: rule.defaultSeverity || 'serious',
471
- occurrences
472
- };
473
- }
474
-
475
- if (undecided.length) {
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
+ );
476
712
  return {
477
713
  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
- )
714
+ ...resolved,
715
+ ...(resolved.outcome === 'cantTell' ? { confidence: 'low' } : null)
504
716
  };
505
717
  }
506
718
 
@@ -7,7 +7,7 @@
7
7
  * @atomic true
8
8
  * @summary Links must have an accessible name
9
9
  * @standard WCAG 2.2
10
- * @sc 4.1.2
10
+ * @sc 2.4.4, 4.1.2
11
11
  * @applicability
12
12
  * Applies to <a href>, <area href> and elements with role="link" that are
13
13
  * included in the accessibility tree. An <a> without an href is not a link
@@ -33,9 +33,18 @@ const meta = {
33
33
  descriptionKey: 'linkNamePresent_description'
34
34
  },
35
35
  helpUrl: null,
36
- tags: ['wcag2a', 'wcag412', 'navigation', 'atomic', 'automatic', 'links', 'name'],
37
- wcagSc: ['4.1.2'],
36
+ tags: ['wcag2a', 'wcag244', 'wcag412', 'navigation', 'atomic', 'automatic', 'links', 'name'],
37
+ wcagSc: ['2.4.4', '4.1.2'],
38
+ // 2.4.4 as well as 4.1.2, as ACT rule c487ae maps it: a link with no name
39
+ // has no purpose to determine, in context or otherwise.
38
40
  normativeMappings: [
41
+ {
42
+ standard: 'WCAG',
43
+ version: '2.2',
44
+ requirement: '2.4.4',
45
+ title: 'Link Purpose (In Context)',
46
+ conformanceLevel: 'A'
47
+ },
39
48
  {
40
49
  standard: 'WCAG',
41
50
  version: '2.2',
@@ -48,8 +57,7 @@ const meta = {
48
57
  category: 'robust',
49
58
  type: 'automatic',
50
59
  defaultConfidence: 'high',
51
- // IMPORTANT: update your facets registry to include "link-name-present" under 4.1.2
52
- coverage: { facetsBySc: { '4.1.2': ['link-name-present'] } }
60
+ coverage: { facetsBySc: { '2.4.4': ['link-name-present'], '4.1.2': ['link-name-present'] } }
53
61
  };
54
62
 
55
63
  function runInPage(ctx) {
@@ -10,7 +10,10 @@
10
10
  * @sc 1.3.1
11
11
  * @applicability
12
12
  * Applies to <ul>/<ol> elements that have at least one direct element
13
- * child.
13
+ * child and whose role is list: no role attribute, role="list", or a role
14
+ * attribute naming no concrete ARIA role. A <ul>/<ol> given another role
15
+ * (listbox, menubar, tablist, none, ...) is not a list, so its children
16
+ * follow that role's rules instead.
14
17
  * @expectation
15
18
  * Every direct element child is <li>, <script>, or <template>. UNLESS it
16
19
  * has an explicit `role` attribute, in which case the explicit role wins
@@ -96,9 +99,23 @@ function runInPage(ctx) {
96
99
  const occurrences = [];
97
100
  let applicableCount = 0;
98
101
 
102
+ // The first role token that names a concrete ARIA role, or '' when none
103
+ // does (the element keeps its native list role).
104
+ const aria = helpers && helpers.aria;
105
+ function resolvedExplicitRole(el) {
106
+ const tokens = String((el.getAttribute && el.getAttribute('role')) || '')
107
+ .toLowerCase()
108
+ .split(/\s+/)
109
+ .filter(Boolean);
110
+ if (!aria || typeof aria.isValidConcreteRole !== 'function') return tokens[0] || '';
111
+ return tokens.find((t) => aria.isValidConcreteRole(t)) || '';
112
+ }
113
+
99
114
  for (const el of nodes) {
100
115
  if (!el || !el.children) continue;
101
116
  if (!el.children.length) continue;
117
+ const listRole = resolvedExplicitRole(el);
118
+ if (listRole && listRole !== 'list') continue;
102
119
 
103
120
  applicableCount += 1;
104
121
 
@@ -149,21 +149,6 @@ function runInPage(ctx) {
149
149
  }
150
150
  }
151
151
 
152
- function buildLabelForMap(doc) {
153
- const map = new Map(); // id -> label element (first)
154
- try {
155
- const labels = doc && doc.getElementsByTagName ? doc.getElementsByTagName('label') : [];
156
- for (let i = 0; i < labels.length; i += 1) {
157
- const lab = labels[i];
158
- if (!lab || !lab.getAttribute) continue;
159
- const f = normalizeWs(lab.getAttribute('for'));
160
- if (!f) continue;
161
- if (!map.has(f)) map.set(f, lab);
162
- }
163
- } catch {}
164
- return map;
165
- }
166
-
167
152
  const occurrences = [];
168
153
  let applicableCount = 0;
169
154
 
@@ -172,42 +157,27 @@ function runInPage(ctx) {
172
157
  ? helpers.queryAllSmart(selector)
173
158
  : helpers.queryAll(selector);
174
159
 
175
- // Precompute label[for] map for listbox elements that are labelable
176
- // native form controls (e.g. <select multiple role="listbox">).
177
- const labelForMap = buildLabelForMap(document);
178
-
160
+ // Delegates to the shared, spec-guarded lookup (dom-helpers.js's
161
+ // getAssociatedLabelElements): a <label> -- wrapping or via `for` --
162
+ // only ever associates with a genuinely labelable element, so this
163
+ // correctly returns nothing for role-only widgets a <label> merely sits
164
+ // next to, and the real thing for a labelable element like
165
+ // <select multiple role="listbox">.
179
166
  function getNativeLabelText(el) {
167
+ if (!helpers || typeof helpers.getAssociatedLabelElements !== 'function') return '';
168
+ let labels;
180
169
  try {
181
- if ('labels' in el && el.labels && el.labels.length) {
182
- const parts = [];
183
- const max = Math.min(4, el.labels.length);
184
- for (let i = 0; i < max; i += 1) {
185
- const lab = el.labels[i];
186
- const t = lab ? getLabelText(lab) : '';
187
- if (t) parts.push(t);
188
- }
189
- const joined = normalizeWs(parts.join(' '));
190
- if (joined) return joined;
191
- }
192
- } catch {}
193
- try {
194
- if (el.closest) {
195
- const wrap = el.closest('label');
196
- if (wrap) {
197
- const t = getLabelText(wrap);
198
- if (t) return t;
199
- }
200
- }
201
- } catch {}
202
- try {
203
- const idAttr = getAttr(el, 'id');
204
- if (idAttr && labelForMap.has(idAttr)) {
205
- const lab = labelForMap.get(idAttr);
206
- const t = lab ? getLabelText(lab) : '';
207
- if (t) return t;
208
- }
209
- } catch {}
210
- return '';
170
+ labels = helpers.getAssociatedLabelElements(el) || [];
171
+ } catch {
172
+ labels = [];
173
+ }
174
+ const parts = [];
175
+ const max = Math.min(4, labels.length);
176
+ for (let i = 0; i < max; i += 1) {
177
+ const t = getLabelText(labels[i]);
178
+ if (t) parts.push(t);
179
+ }
180
+ return normalizeWs(parts.join(' '));
211
181
  }
212
182
 
213
183
  function hasName(el) {
@@ -11,7 +11,8 @@
11
11
  * @applicability
12
12
  * Applies to <li> elements that have a parent element.
13
13
  * @expectation
14
- * The parent is <ul>/<ol> with no role override, or an element with an
14
+ * The parent is <ul>/<ol>/<menu> with no role override (all three have
15
+ * the implicit role list), or an element with an
15
16
  * explicit role of "list", "presentation", or "none". An <li> used
16
17
  * outside a real list container (e.g. as a generic flex/grid item under
17
18
  * a <div>) is not exposed as a list item to assistive technologies.
@@ -47,7 +48,7 @@ const id = 'listitem-parent-valid';
47
48
  const meta = {
48
49
  title: 'List items must be inside a list container',
49
50
  description:
50
- 'Checks that <li> elements are contained by <ul>, <ol>, or an element with role="list".',
51
+ 'Checks that <li> elements are contained by <ul>, <ol>, <menu>, or an element with role="list".',
51
52
  i18n: {
52
53
  titleKey: 'listitemParentValid_title',
53
54
  descriptionKey: 'listitemParentValid_description'
@@ -111,7 +112,7 @@ function runInPage(ctx) {
111
112
  // direction, see the header comment.
112
113
  valid = explicitRole === 'list' || explicitRole === 'presentation' || explicitRole === 'none';
113
114
  } else {
114
- valid = parentTag === 'ul' || parentTag === 'ol';
115
+ valid = parentTag === 'ul' || parentTag === 'ol' || parentTag === 'menu';
115
116
  }
116
117
 
117
118
  if (valid) continue;
@@ -5,7 +5,7 @@
5
5
  /**
6
6
  * @check meta-refresh-no-exceptions
7
7
  * @atomic true
8
- * @summary At the AAA level, a meta refresh must not be used at all
8
+ * @summary At the AAA level, a meta refresh must not use a positive delay, however long
9
9
  * @standard WCAG 2.2
10
10
  * @sc 2.2.4, 3.2.5
11
11
  * @applicability
@@ -37,9 +37,9 @@
37
37
  const id = 'meta-refresh-no-exceptions';
38
38
 
39
39
  const meta = {
40
- title: 'Page must not use a meta refresh at all (AAA)',
40
+ title: 'Page must not use a timed meta refresh (AAA)',
41
41
  description:
42
- 'Checks that <meta http-equiv="refresh"> is not present at all, regardless of delay. This is the stricter AAA-level counterpart of the A-level positive-delay-only check.',
42
+ 'Checks that <meta http-equiv="refresh"> does not set a positive delay, however long; an immediate redirect (delay 0) passes. This is the stricter AAA-level counterpart of the A-level check, which exempts delays over 20 hours.',
43
43
  i18n: {
44
44
  titleKey: 'metaRefreshNoExceptions_title',
45
45
  descriptionKey: 'metaRefreshNoExceptions_description'