pour-engine 1.38.0 → 1.41.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 (50) hide show
  1. package/config/project.config.js +1 -1
  2. package/engine/AUDIT.html +24 -690
  3. package/engine/COVERAGE.md +4 -2
  4. package/engine/README.md +9 -4
  5. package/engine/index.js +34 -8
  6. package/engine/lib/accessible-name.js +81 -64
  7. package/engine/lib/contrast.js +111 -10
  8. package/engine/lib/dom.js +78 -18
  9. package/engine/lib/roles.js +18 -3
  10. package/engine/rules/best-practice/fieldset-legend.js +11 -5
  11. package/engine/rules/best-practice/landmark-unique.js +9 -1
  12. package/engine/rules/best-practice/region.js +10 -2
  13. package/engine/rules/wcag/1.1.1-area-alt.js +6 -1
  14. package/engine/rules/wcag/1.1.1-image-alt.js +2 -1
  15. package/engine/rules/wcag/1.1.1-svg-img-alt.js +14 -17
  16. package/engine/rules/wcag/1.3.1-aria-required-children.js +20 -8
  17. package/engine/rules/wcag/1.3.1-aria-required-parent.js +7 -10
  18. package/engine/rules/wcag/1.3.1-definition-list.js +42 -3
  19. package/engine/rules/wcag/1.3.1-form-label.js +12 -29
  20. package/engine/rules/wcag/1.3.1-list-structure.js +4 -3
  21. package/engine/rules/wcag/1.3.1-table-headers.js +23 -2
  22. package/engine/rules/wcag/1.3.4-orientation.js +117 -48
  23. package/engine/rules/wcag/1.3.5-autocomplete-valid.js +21 -8
  24. package/engine/rules/wcag/1.4.1-link-in-text-block.js +38 -8
  25. package/engine/rules/wcag/1.4.10-reflow.js +3 -3
  26. package/engine/rules/wcag/1.4.11-non-text-contrast.js +9 -1
  27. package/engine/rules/wcag/1.4.12-text-spacing.js +15 -2
  28. package/engine/rules/wcag/1.4.3-color-contrast.js +79 -7
  29. package/engine/rules/wcag/1.4.3-control-contrast.js +35 -6
  30. package/engine/rules/wcag/1.4.4-meta-viewport.js +7 -2
  31. package/engine/rules/wcag/2.1.1-scrollable-region-focusable.js +5 -5
  32. package/engine/rules/wcag/2.2.1-meta-refresh.js +39 -3
  33. package/engine/rules/wcag/2.2.2-pause-stop-hide.js +5 -5
  34. package/engine/rules/wcag/2.4.1-bypass-blocks.js +2 -2
  35. package/engine/rules/wcag/2.4.2-document-title.js +7 -8
  36. package/engine/rules/wcag/2.4.4-link-text-generic.js +6 -5
  37. package/engine/rules/wcag/2.4.9-link-text-generic-only.js +8 -23
  38. package/engine/rules/wcag/2.5.8-target-size.js +201 -47
  39. package/engine/rules/wcag/3.3.8-auth-field-obstruction.js +6 -12
  40. package/engine/rules/wcag/4.1.2-aria-attr-valid.js +4 -0
  41. package/engine/rules/wcag/4.1.2-aria-field-name.js +6 -5
  42. package/engine/rules/wcag/4.1.2-aria-valid-refs.js +8 -3
  43. package/engine/rules/wcag/4.1.2-button-name.js +1 -1
  44. package/engine/rules/wcag/4.1.2-composite-widget-name.js +1 -1
  45. package/engine/rules/wcag/4.1.2-invoker-target.js +2 -1
  46. package/engine/rules/wcag/4.1.2-label-for-valid.js +2 -2
  47. package/engine/rules/wcag/4.1.2-nested-interactive.js +20 -8
  48. package/engine/rules/wcag/4.1.2-valid-role.js +6 -1
  49. package/engine/wcag22.js +7 -7
  50. package/package.json +1 -1
@@ -6,7 +6,7 @@ import {
6
6
  opacityAnimating, restingOpacity, mediaRects, inZeroClipSubtree,
7
7
  paintedBackdrop, opaquePanelRects, viewportVeil, textShadowHalo, textShadowNegligible,
8
8
  pseudoBackdropForText, filmedContrastBounds, backgroundColorSource, scrimPaint, applyOverlays,
9
- showRatio, splitBackgroundLayers, backgroundLayerUrl, sampleGridFor, opacityGroupPaint, pseudoTextColors, BOLD_WEIGHT } from '../../lib/contrast.js';
9
+ showRatio, splitBackgroundLayers, backgroundLayerUrl, sampleGridFor, opacityGroupPaint, pseudoTextColors, BOLD_WEIGHT, hasPaintEffects, isolatedBlendBackdrop } from '../../lib/contrast.js';
10
10
 
11
11
  /** The first url() among a background-image list's layers, or null. */
12
12
  const firstLayerUrl = (css) => splitBackgroundLayers(css ?? '').map(backgroundLayerUrl).find(Boolean) ?? null;
@@ -297,7 +297,7 @@ async function sampledVerdict(source, foreground, required, doc, overlays = [],
297
297
  let what = 'image';
298
298
  const grid = sampleGridFor(extent);
299
299
  if (source.tagName === 'IMG') {
300
- range = await imageLuminanceRange(source.currentSrc || source.src, overlays, grid);
300
+ range = await imageLuminanceRange(source.currentSrc || source.src, overlays, grid, under);
301
301
  } else {
302
302
  const css = source.css ?? getComputedStyle(source).backgroundImage;
303
303
  if (!css || css === 'none') return null;
@@ -314,7 +314,7 @@ async function sampledVerdict(source, foreground, required, doc, overlays = [],
314
314
  if (url) {
315
315
  let absolute;
316
316
  try { absolute = new URL(url, doc.baseURI).href; } catch { return null; }
317
- range = await imageLuminanceRange(absolute, overlays, grid);
317
+ range = await imageLuminanceRange(absolute, overlays, grid, under);
318
318
  } else if (layer.includes('gradient(')) {
319
319
  what = 'gradient';
320
320
  const stops = gradientStops(layer);
@@ -539,6 +539,10 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
539
539
  const fill = parseColor(style.webkitTextFillColor);
540
540
  const clipsText = /\btext\b/.test(style.webkitBackgroundClip ?? '') || /\btext\b/.test(style.backgroundClip ?? '');
541
541
  if (clipsText) {
542
+ if (hasPaintEffects(styleSource)) {
543
+ return { status: 'incomplete', message: 'A filter or blend mode changes the colours presented by this text and its background. Check the resulting contrast by eye.' };
544
+ }
545
+
542
546
  const behind = effectiveBackground(element.parentElement ?? element);
543
547
  const fgRange = style.backgroundImage !== 'none'
544
548
  ? sampledGradientRange(splitBackgroundLayers(style.backgroundImage)[0] ?? '') : null;
@@ -559,6 +563,38 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
559
563
  // Visually hidden text (sr-only, image replacement) has no visual
560
564
  // presentation for contrast to apply to — screen readers still read it.
561
565
  if (textVisuallyHidden(element, style, foreground)) return { status: 'pass' };
566
+ // A leaf's own multiply blend over flat paint is exact per Compositing
567
+ // Level 1: B(Cb, Cs) = Cb * Cs. Keep groups, filters and generated paint
568
+ // outside this narrow path; its backdrop is resolved below as usual.
569
+ const ownMultiply = style.mixBlendMode === 'multiply'
570
+ && styleSource === element && !element.children.length && !element.shadowRoot
571
+ && parseColor(style.backgroundColor)?.a === 0 && style.backgroundImage === 'none'
572
+ && style.filter === 'none' && (!style.backdropFilter || style.backdropFilter === 'none')
573
+ && (!style.textShadow || style.textShadow === 'none')
574
+ && !hasPaintEffects(element.assignedSlot ?? element.parentElement ?? element.getRootNode()?.host)
575
+ && !isolatedBlendBackdrop(element.assignedSlot ?? element.parentElement ?? element.getRootNode()?.host)
576
+ && !['::before', '::after'].some((pseudo) => {
577
+ const pseudoStyle = getComputedStyle(element, pseudo);
578
+ const content = pseudoStyle.content;
579
+ // U+2060 only controls line breaking and has no glyph. A plain
580
+ // inline joiner with no independent box paint does not add a
581
+ // painted source to this leaf's multiply group.
582
+ const bareJoiner = (content === '"\u2060"' || content === "'\u2060'")
583
+ && pseudoStyle.display === 'inline' && pseudoStyle.position === 'static'
584
+ && pseudoStyle.cssFloat === 'none'
585
+ && parseColor(pseudoStyle.backgroundColor)?.a === 0
586
+ && ['backgroundImage', 'boxShadow', 'textShadow', 'transform', 'filter', 'backdropFilter', 'maskImage', 'webkitMaskImage']
587
+ .every((key) => !pseudoStyle[key] || pseudoStyle[key] === 'none')
588
+ && ['Top', 'Right', 'Bottom', 'Left'].every((side) =>
589
+ parseFloat(pseudoStyle[`padding${side}`]) === 0 && parseFloat(pseudoStyle[`border${side}Width`]) === 0)
590
+ && pseudoStyle.outlineStyle === 'none'
591
+ && (!pseudoStyle.mixBlendMode || pseudoStyle.mixBlendMode === 'normal');
592
+ if (bareJoiner) return false;
593
+ return content && content !== 'none' && content !== 'normal' && content !== '""';
594
+ });
595
+ if (hasPaintEffects(styleSource) && !ownMultiply) {
596
+ return { status: 'incomplete', message: 'A filter or blend mode changes the colours presented by this text and its background. Check the resulting contrast by eye.' };
597
+ }
562
598
  if (!foreground) {
563
599
  return { status: 'incomplete', message: 'The text colour could not be parsed — check contrast by eye.' };
564
600
  }
@@ -582,6 +618,26 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
582
618
  .map(({ pseudo, color, style: pseudoStyle }) => ({
583
619
  origin: pseudo, color, required: isLargeText(pseudoStyle) ? thresholds.large : thresholds.normal,
584
620
  }));
621
+ // A first-line override can paint every glyph. With no element
622
+ // descendants and one actual line, the base colour paints nothing and
623
+ // must not manufacture a failure. Wrapped text retains its base colour.
624
+ let basePaints = true;
625
+ if (!element.childElementCount && alternates.some((a) => a.origin === '::first-line')) {
626
+ const range = doc.createRange();
627
+ range.selectNodeContents(element);
628
+ const lines = [...range.getClientRects()].filter((r) => r.width > 0 && r.height > 0);
629
+ if (lines.length && lines.every((r) => Math.abs(r.top - lines[0].top) < 1)) {
630
+ const generated = ['::before', '::after'].some((pseudo) => {
631
+ const generatedStyle = getComputedStyle(element, pseudo);
632
+ return generatedStyle.display !== 'none'
633
+ && !['none', 'normal'].includes(generatedStyle.content);
634
+ });
635
+ // Range does not include generated content. A generated block can
636
+ // occupy the first formatted line while the own text sits below it.
637
+ if (generated) return { status: 'incomplete', message: 'Generated content may occupy this element’s first line, so the colours that actually paint its text cannot be identified from text ranges alone. Check contrast by eye.' };
638
+ basePaints = false;
639
+ }
640
+ }
585
641
  const worstCandidate = (candidates, backdrop) => candidates.reduce((worst, candidate) => {
586
642
  const shown = candidate.color.a < 1 ? composite(candidate.color, backdrop) : candidate.color;
587
643
  const margin = contrastRatio(shown, backdrop) / candidate.required;
@@ -589,7 +645,7 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
589
645
  }, null);
590
646
  if (alternates.length) {
591
647
  const estimate = effectiveBackground(styleSource) ?? { r: 255, g: 255, b: 255, a: 1 };
592
- const pick = worstCandidate([{ origin: null, color: foreground, required }, ...alternates], estimate);
648
+ const pick = worstCandidate([...(basePaints ? [{ origin: null, color: foreground, required }] : []), ...alternates], estimate);
593
649
  foreground = pick.color;
594
650
  required = pick.required;
595
651
  foregroundOrigin = pick.origin;
@@ -604,6 +660,9 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
604
660
  // Near-zero resting opacity means the text isn't visually presented.
605
661
  const opacity = opacityAnimating(element) ? restingOpacity(element) : cumulativeOpacity(element);
606
662
  if (opacity < 0.05) return { status: 'pass' };
663
+ if (ownMultiply && (opacity < 1 || alternates.length)) {
664
+ return { status: 'incomplete', message: 'This text blends as a group or uses additional text paint. Check its presented contrast by eye.' };
665
+ }
607
666
  if (opacity < 1) {
608
667
  // Group opacity with a background INSIDE the group: the browser blends
609
668
  // text and background together over what lies behind the carrier, so
@@ -696,6 +755,7 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
696
755
  // images (external-link arrows etc.) don't affect the text's contrast.
697
756
  const imageSource = backgroundImageSource(element);
698
757
  if (imageSource) {
758
+ if (ownMultiply) return { status: 'incomplete', message: 'This text multiplies against an image or gradient. Its contrast depends on the pixels behind each glyph; check it by eye.' };
699
759
  const { relation, intrinsic, dimensionless, isGradient, sizedSmall, paint } = await imageVsText(imageSource, element, doc);
700
760
  if (relation !== 'clear') {
701
761
  // imageSource.overlays are the translucent layers painted between the
@@ -761,6 +821,9 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
761
821
  // hero panels, overlay scrims) that the ancestor walk cannot — instead
762
822
  // of merely flagging them for a human.
763
823
  const painted = paintedBackdrop(element);
824
+ if (ownMultiply && painted?.image) {
825
+ return { status: 'incomplete', message: 'This text multiplies against image paint. Check its presented contrast by eye.' };
826
+ }
764
827
  // A translucent viewport-scale veil is painted ON TOP of this text
765
828
  // (modal scrim, loading overlay, consent dimmer): the user sees the text
766
829
  // through it, so the resting colours are not the presented ones — and if
@@ -801,6 +864,9 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
801
864
  // otherwise sent every element on the page to the review lane, and a
802
865
  // genuine 4.1:1 failure drowned among 163 siblings that were fine.
803
866
  const pseudoResolved = pseudoBackdropForText(element);
867
+ if (ownMultiply && pseudoResolved) {
868
+ return { status: 'incomplete', message: 'Pseudo-element paint affects the backdrop of this blended text. Check its presented contrast by eye.' };
869
+ }
804
870
  const film = pseudoResolved?.film ?? 0;
805
871
  let pseudoBack = pseudoResolved?.color || pseudoResolved?.image ? pseudoResolved : null;
806
872
  // The pseudo's BOX covering the text says nothing about its IMAGE doing
@@ -959,12 +1025,18 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
959
1025
  if (alternates.length) {
960
1026
  const dim = (color) => (opacity < 1 ? { ...color, a: color.a * opacity } : color);
961
1027
  const base = { origin: null, color: dim(baseForeground), required: baseRequired };
962
- const pick = worstCandidate([base, ...alternates.map((a) => ({ ...a, color: dim(a.color) }))], background);
1028
+ const pick = worstCandidate([...(basePaints ? [base] : []), ...alternates.map((a) => ({ ...a, color: dim(a.color) }))], background);
963
1029
  foreground = pick.color;
964
1030
  required = pick.required;
965
1031
  foregroundOrigin = pick.origin;
966
1032
  }
967
1033
  // Semi-transparent text is really text-colour blended into the background.
1034
+ if (ownMultiply) {
1035
+ foreground = { ...foreground,
1036
+ r: foreground.r * background.r / 255,
1037
+ g: foreground.g * background.g / 255,
1038
+ b: foreground.b * background.b / 255 };
1039
+ }
968
1040
  if (foreground.a < 1) foreground = composite(foreground, background);
969
1041
 
970
1042
  // The scroll-independent hazard bracket for UNVERIFIED backgrounds:
@@ -1008,7 +1080,7 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
1008
1080
  (!blindOnly || hitTestBlind) && near(rect)
1009
1081
  && !panel.contains(element) && !element.contains(panel)
1010
1082
  && textIntersects(element, rect)
1011
- && (contrastRatio(foreground, color) >= required) !== (direction === 'pass'));
1083
+ && (ownMultiply || (contrastRatio(foreground, color) >= required) !== (direction === 'pass')));
1012
1084
  if (flipping) {
1013
1085
  return {
1014
1086
  status: 'incomplete',
@@ -1046,7 +1118,7 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
1046
1118
  if (!paint || paint.a < 1) continue;
1047
1119
  const siblingRect = sibling.getBoundingClientRect();
1048
1120
  if (siblingRect.width < 24 || siblingRect.height < 12 || !near(siblingRect) || !textIntersects(element, siblingRect)) continue;
1049
- if ((contrastRatio(foreground, paint) >= required) !== (direction === 'pass')) {
1121
+ if (ownMultiply || (contrastRatio(foreground, paint) >= required) !== (direction === 'pass')) {
1050
1122
  return {
1051
1123
  status: 'incomplete',
1052
1124
  message: 'This text is positioned over a coloured box that isn’t its DOM ancestor, so its real background is ambiguous — check contrast by eye.',
@@ -5,6 +5,7 @@
5
5
  import {
6
6
  parseColor, contrastRatio, composite, effectiveBackground, isLargeText,
7
7
  backgroundImageSource, cumulativeOpacity, opacityAnimating, restingOpacity, showRatio,
8
+ hasPaintEffects, opacityGroupPaint, splitBackgroundLayers, sampledGradientRange,
8
9
  } from '../../lib/contrast.js';
9
10
 
10
11
  export default {
@@ -56,10 +57,26 @@ export default {
56
57
  // far less contrast than its declared colours suggest.
57
58
  const opacity = opacityAnimating(element) ? restingOpacity(element) : cumulativeOpacity(element);
58
59
  if (opacity < 0.05) return { status: 'pass' };
60
+ if (hasPaintEffects(element)) {
61
+ return { status: 'incomplete', message: 'A filter or blend mode changes the colours presented inside this field. Check the resulting text contrast by eye.' };
62
+ }
59
63
  // The control's own background wins; else whatever shows through it.
60
64
  const own = parseColor(style.backgroundColor);
61
65
  let background;
62
- if (own && own.a >= 1) {
66
+ if (style.backgroundImage !== 'none') {
67
+ const layers = splitBackgroundLayers(style.backgroundImage);
68
+ const range = layers.length === 1 && layers[0].includes('gradient(')
69
+ ? sampledGradientRange(layers[0]) : null;
70
+ // A constant opaque gradient has exactly one painted background.
71
+ // Other image paint needs spatial sampling, not its fallback colour.
72
+ const fullBox = /^(auto|auto auto)$/.test(style.backgroundSize)
73
+ && style.backgroundPosition === '0% 0%'
74
+ && ['repeat', 'no-repeat'].includes(style.backgroundRepeat);
75
+ if (!range || range.min !== range.max || opacity < 1 || !fullBox) {
76
+ return { status: 'incomplete', message: 'An image or gradient paints this field background. Check its text against the pixels behind it.' };
77
+ }
78
+ background = range.minColor;
79
+ } else if (own && own.a >= 1) {
63
80
  background = own;
64
81
  } else {
65
82
  // An image or gradient painted under a see-through control is a
@@ -73,18 +90,27 @@ export default {
73
90
  };
74
91
  }
75
92
  const behind = effectiveBackground(element);
76
- background = behind && own && own.a > 0 ? composite(own, behind) : behind;
93
+ background = behind; // effectiveBackground already composites the field fill
77
94
  }
78
95
  if (!background) {
79
96
  return { status: 'incomplete', message: 'The control’s background could not be determined — check its text contrast by eye.' };
80
97
  }
81
98
  const required = isLargeText(style) ? 3 : 4.5;
82
99
 
100
+ let unresolvedGroup = false;
83
101
  const judge = (color, what, ownOpacity = 1) => {
84
102
  const parsed = parseColor(color);
85
103
  if (!parsed || parsed.a === 0) return null;
86
104
  // Same treatment the main rule gives faded text: what reaches the eye
87
105
  // is the declared colour thinned by the opacity it is painted at.
106
+ if (opacity < 1) {
107
+ const group = opacityGroupPaint(element, { ...parsed, a: parsed.a * ownOpacity });
108
+ if (group?.unresolved) { unresolvedGroup = true; return null; }
109
+ if (group) {
110
+ const ratio = contrastRatio(group.foreground, group.background);
111
+ return ratio >= required ? null : { what, ratio };
112
+ }
113
+ }
88
114
  const painted = opacity * ownOpacity;
89
115
  const faded = painted < 1 ? { ...parsed, a: parsed.a * painted } : parsed;
90
116
  const fg = faded.a < 1 ? composite(faded, background) : faded;
@@ -94,16 +120,18 @@ export default {
94
120
  };
95
121
 
96
122
  const failures = [];
97
- const valueVerdict = judge(style.color, 'value text');
123
+ const valueVerdict = judge(style.webkitTextFillColor || style.color, 'value text');
98
124
  if (valueVerdict) failures.push(valueVerdict);
99
125
  // Placeholder text is real text users must read; browsers expose its
100
- // computed colour via the ::placeholder pseudo-element.
101
- if (element.getAttribute('placeholder')?.trim()) {
126
+ // computed colour via the ::placeholder pseudo-element. Selectors 4
127
+ // defines :placeholder-shown for controls actually showing that text;
128
+ // a populated field's unpainted hint must not create a violation.
129
+ if (element.getAttribute('placeholder')?.trim() && element.matches(':placeholder-shown')) {
102
130
  let placeholderColor = null;
103
131
  let placeholderOpacity = 1;
104
132
  try {
105
133
  const placeholderStyle = getComputedStyle(element, '::placeholder');
106
- placeholderColor = placeholderStyle.color;
134
+ placeholderColor = placeholderStyle.webkitTextFillColor || placeholderStyle.color;
107
135
  // ::placeholder { opacity: .3 } is a widespread idiom (framework
108
136
  // resets, Firefox's own UA default of 0.54), and the criterion is on
109
137
  // the visual presentation of the text, which is the faded colour.
@@ -117,6 +145,7 @@ export default {
117
145
  if (verdict) failures.push(verdict);
118
146
  }
119
147
  }
148
+ if (unresolvedGroup) return { status: 'incomplete', message: 'The field and its text fade together over an unresolved background. Check the presented text contrast by eye.' };
120
149
  if (!failures.length) return { status: 'pass' };
121
150
  const worst = failures.sort((a, b) => a.ratio - b.ratio)[0];
122
151
  return {
@@ -20,10 +20,15 @@ export default {
20
20
  const maxScale = content.match(/maximum-scale\s*=\s*([\d.]+)/i);
21
21
  const cappedZoom = maxScale && parseFloat(maxScale[1]) < 2;
22
22
  if (!disablesZoom && !cappedZoom) return { status: 'pass' };
23
+ // Asserted the way ACT rule b4f0c3 asserts it, under its assumption that
24
+ // the page provides no other way to enlarge the text (technique G178).
25
+ // The message names that assumption so a reviewer who knows the page
26
+ // has its own text-size control can dismiss the finding.
27
+ // https://www.w3.org/WAI/standards-guidelines/act/rules/b4f0c3/
23
28
  return {
24
29
  status: 'fail',
25
- message: 'This viewport meta tag stops low-vision users from zooming the page.',
26
- fix: 'Remove user-scalable=no and any maximum-scale below 2 from the viewport meta tag.',
30
+ message: 'This viewport meta tag stops users zooming the page, so text cannot be enlarged with the browser. 1.4.4 is met only if the page provides its own control that enlarges all text to 200% (technique G178); this finding assumes it does not, since the tag cannot show it.',
31
+ fix: 'Remove user-scalable=no and any maximum-scale below 2 from the viewport meta tag. If the page has its own text-size control that reaches 200%, record that and dismiss this finding.',
27
32
  };
28
33
  },
29
34
  };
@@ -92,13 +92,13 @@ export default {
92
92
  // An explicit negative tabindex is the author OPTING OUT of the tab
93
93
  // order, and the browser honours it: measured in Chrome 151, a scroller
94
94
  // with tabindex="-1" is skipped by Tab even though the same scroller
95
- // without the attribute would be auto-focused. Nothing inside is
96
- // tabbable either, so the overflowed content is unreachable by keyboard
97
- // in every engine — assertable, not browser-dependent.
95
+ // without the attribute would be auto-focused. That does not prove
96
+ // keyboard inoperability: external controls may scroll the region or
97
+ // move focus into it programmatically.
98
98
  if (element.hasAttribute('tabindex')) {
99
99
  return {
100
- status: 'fail',
101
- message: 'This region scrolls, holds nothing tabbable, and its tabindex="-1" removes the region itself from the tab order — keyboard users cannot reach the overflowed content in any browser.',
100
+ status: 'incomplete',
101
+ message: 'This region scrolls, has no detected tabbable content and is removed from sequential focus by a negative tabindex. Check whether keyboard-operable controls elsewhere scroll it or move focus into it; without such a mechanism, the overflowed content may be unreachable.',
102
102
  fix: 'Change tabindex="-1" to tabindex="0" (plus role="region" and an aria-label describing it), or make something inside it tabbable.',
103
103
  };
104
104
  }
@@ -23,6 +23,33 @@ function refreshDestination(content) {
23
23
  return rest.trim();
24
24
  }
25
25
 
26
+ /**
27
+ * The delay the browser would actually use, and null when the HTML "shared
28
+ * declarative refresh steps" bail out and schedule nothing at all. Those
29
+ * steps collect ASCII digits and stop: a leading "+" ends the parse before a
30
+ * single digit is read (content="+5; url=…" refreshes nothing), and anything
31
+ * other than ";", "," or whitespace directly after the digits does the same
32
+ * (content="1x; url=…"). parseInt accepts both and returned 5 and 1, so the
33
+ * rule asserted a time limit on pages the browser never moves. Verified in
34
+ * Chromium: both of those pages stay put while content="1; url=…" navigates.
35
+ */
36
+ function refreshDelay(content) {
37
+ const isAsciiSpace = (c) => c === ' ' || c === '\t' || c === '\n' || c === '\f' || c === '\r';
38
+ const isDigit = (c) => c >= '0' && c <= '9';
39
+ let i = 0;
40
+ while (i < content.length && isAsciiSpace(content[i])) i++;
41
+ let digits = '';
42
+ while (i < content.length && isDigit(content[i])) digits += content[i++];
43
+ // No digits is only allowed when a decimal point follows, which the steps
44
+ // read as a fractional delay of zero seconds.
45
+ if (!digits && content[i] !== '.') return null;
46
+ const delay = digits ? Number(digits) : 0;
47
+ // Trailing digits and full stops are collected and thrown away.
48
+ while (i < content.length && (isDigit(content[i]) || content[i] === '.')) i++;
49
+ if (i < content.length && content[i] !== ';' && content[i] !== ',' && !isAsciiSpace(content[i])) return null;
50
+ return delay;
51
+ }
52
+
26
53
  export default {
27
54
  id: 'meta-refresh',
28
55
  name: 'Timed page refresh',
@@ -34,7 +61,10 @@ export default {
34
61
  visibleOnly: false,
35
62
  evaluate(element) {
36
63
  const content = element.getAttribute('content') ?? '';
37
- const delay = parseInt(content, 10);
64
+ const delay = refreshDelay(content);
65
+ // The browser discards this directive, so no time limit is set by the
66
+ // content and 2.2.1 has nothing to judge.
67
+ if (delay === null) return { status: 'pass' };
38
68
  // A refresh with no destination reloads THIS page. At zero seconds that
39
69
  // is not the allowed instant redirect — it is a loop that throws the
40
70
  // user back to the top of the page over and over (failure F41).
@@ -50,10 +80,16 @@ export default {
50
80
  // 2.2.1 exempts a time limit "longer than 20 hours" — 20 hours exactly
51
81
  // is still inside the criterion.
52
82
  if (delay > 72000) return { status: 'pass' };
83
+ // Asserted the way ACT rule bc659a asserts it: a meta refresh gives the
84
+ // user no way to turn the limit off, adjust it or extend it, so the only
85
+ // escapes are the real-time and essential exceptions, which the tag
86
+ // cannot show either way. The message names that assumption so a
87
+ // reviewer who knows the page offers a control can dismiss the finding.
88
+ // https://www.w3.org/WAI/standards-guidelines/act/rules/bc659a/
53
89
  return {
54
90
  status: 'fail',
55
- message: `The page refreshes/redirects after ${delay}s — slow readers lose their place (or the whole page) with no control.`,
56
- fix: 'Remove the timed refresh; let users act in their own time.',
91
+ message: `The page refreshes or redirects itself after ${delay}s. Slow readers lose their place, or the whole page, and a meta refresh offers no way to turn that off, adjust it or extend it. 2.2.1 excuses a time limit only where it is part of a real-time event or essential; this finding assumes neither applies, since the tag cannot show it.`,
92
+ fix: 'Remove the timed refresh; let users act in their own time. If the page offers a control that turns the refresh off, or the refresh is essential, record that and dismiss this finding.',
57
93
  };
58
94
  },
59
95
  };
@@ -6,8 +6,8 @@
6
6
  //
7
7
  // Two shapes are checkable, and they need different verdicts.
8
8
  //
9
- // <marquee> and <blink> exist only to move, ship no control of their own,
10
- // and are obsolete: a definite failure.
9
+ // Legacy markup is not proof of motion: blink has no native animation in
10
+ // modern browsers, and a marquee may be stopped or controlled by script.
11
11
  //
12
12
  // A CSS animation set to repeat for ever is the modern ticker. Clauses 1
13
13
  // and 2 are settled by the CSS itself — it starts with the page and never
@@ -117,15 +117,15 @@ export default {
117
117
  if (!elements.length) return [];
118
118
  const moving = endlesslyMoving(elements[0].ownerDocument);
119
119
  return elements.map((element) => {
120
- if (element.tagName === 'MARQUEE' || element.tagName === 'BLINK') {
120
+ if (element.tagName === 'MARQUEE') {
121
121
  // scrollamount="0" is the documented way to ship a <marquee> that
122
122
  // doesn't actually move.
123
123
  if (element.tagName === 'MARQUEE' && element.getAttribute('scrollamount') === '0') {
124
124
  return { status: 'pass' };
125
125
  }
126
126
  return {
127
- status: 'fail',
128
- message: `<${element.tagName.toLowerCase()}> scrolls or blinks with no way to pause it, which is unusable for people with attention or vestibular conditions.`,
127
+ status: 'incomplete',
128
+ message: 'This marquee can scroll content. Check whether it actually starts automatically, continues for more than five seconds alongside other content, and has a working pause, stop or hide mechanism. The element alone does not establish those conditions.',
129
129
  fix: 'Replace it with static content, or a CSS animation with a pause control that honours prefers-reduced-motion.',
130
130
  };
131
131
  }
@@ -80,8 +80,8 @@ export default {
80
80
  const linkCount = roots.reduce((n, root) => n + (root.querySelectorAll?.('a[href], button').length ?? 0), 0);
81
81
  if (linkCount < 4) return { status: 'pass' };
82
82
  return {
83
- status: 'fail',
84
- message: `This page has ${linkCount} links and buttons but no landmark, heading, skip link, or titled frame — keyboard and screen-reader users must go through the whole header and nav to reach anything.`,
83
+ status: 'incomplete',
84
+ message: `This page has ${linkCount} links and buttons but no detected landmark, heading, skip link or titled frame. Check whether it contains blocks repeated on other pages and, if so, whether a mechanism lets users bypass them. A single page cannot establish repetition.`,
85
85
  fix: 'Add a skip link like <a href="#content">Skip to content</a>, wrap primary content in <main>, or structure the page with headings.',
86
86
  };
87
87
  },
@@ -3,18 +3,17 @@
3
3
  // merely PRESENT satisfies the markup, not the criterion, so the two
4
4
  // well-known undescriptive shapes are checked as well.
5
5
  //
6
- // Tier one, failed: strings that are a stand-in under every reading.
7
- // "Untitled Document" is what an editor writes when the author wrote
8
- // nothing, and no page is ever about that.
6
+ // Familiar template strings are review candidates, not proof of a missing
7
+ // topic: a document can genuinely describe the HTML document title or an
8
+ // artwork named Untitled 1.
9
9
  // Tier two, sent to a human: single generic words and framework scaffold
10
10
  // defaults. Almost always a leftover, but a gallery page really can be about
11
11
  // an artwork called "Untitled", and "Example Domain" really is the subject
12
12
  // of example.com. Those are somebody's call to make, not the engine's.
13
13
  // "New document", "New page" and "New tab" sit in tier two as well: a
14
14
  // document editor's creation screen, a wiki's new-page form or a
15
- // browser-style app's new-tab page is about exactly that, so only the
16
- // numbered editor default ("New Page 1", F25's example) is a certain
17
- // placeholder (2026-08-25 overnight audit).
15
+ // browser-style app's new-tab page is about exactly that. Even numbered
16
+ // editor-style titles remain a question about the actual subject.
18
17
  //
19
18
  // Deliberately NOT judged: whether a real-looking title matches the page it
20
19
  // sits on. That needs someone who can read the page, and guessing at it
@@ -63,8 +62,8 @@ export default {
63
62
  const normalized = title.replace(/\s+/g, ' ').toLowerCase();
64
63
  if (PLACEHOLDER.has(normalized) || /^(?:untitled|new (?:document|page|tab))[\s-]*\d+$/.test(normalized)) {
65
64
  return {
66
- status: 'fail',
67
- message: `“${title}” is a placeholder left by an editor or template. It gives no topic and no purpose, so a screen reader announces nothing useful when the page loads, and a row of open tabs, bookmarks or history entries becomes impossible to tell apart.`,
65
+ status: 'incomplete',
66
+ message: `“${title}” resembles a template title. Check whether it describes this page's actual topic or purpose; the same words can also be a legitimate subject or name.`,
68
67
  fix: 'Put this page\'s own subject in the title first, then the site name.',
69
68
  };
70
69
  }
@@ -13,11 +13,9 @@
13
13
  // it sits in. A "Read more" under a headline naming the
14
14
  // article is a judgment call about whether that context really
15
15
  // reaches the user, so this REVIEWS. It never asserts.
16
- // 2.4.9 (AAA) purpose must come from the link text ALONE, with the only
17
- // exception being links whose purpose "would be ambiguous to
18
- // users in general". A link named nothing but "Read more" is
19
- // ambiguous to everyone, which is the exception failing to
20
- // apply rather than saving it, so this FAILS.
16
+ // 2.4.9 (AAA) requires a mechanism for purpose to be available from link
17
+ // text alone. A page may let users expand its link names,
18
+ // so a generic name in this snapshot still needs review.
21
19
  //
22
20
  // Judged on the ACCESSIBLE NAME, not the text content: a link written as
23
21
  // <a href="…" aria-label="Read more about the budget">Read more</a> is
@@ -31,6 +29,8 @@
31
29
  // normalizeName below): apostrophes become spaces, so "plus d informations"
32
30
  // is how "plus d'informations" — straight or typographic — must be listed.
33
31
 
32
+ import { effectiveRole, implicitRole } from '../../lib/roles.js';
33
+
34
34
  const GENERIC = new Set([
35
35
  // English
36
36
  'read more', 'read more about this', 'more', 'much more', 'learn more',
@@ -83,6 +83,7 @@ export function createLinkPurposeRule({ id, impact, tags, help, helpUrl, verdict
83
83
  // link-name's finding, not this one.
84
84
  selector: 'a[href], [role="link"]',
85
85
  evaluate(element, { accessibleName }) {
86
+ if ((effectiveRole(element) ?? implicitRole(element)) !== 'link') return { status: 'pass' };
86
87
  const name = accessibleName(element);
87
88
  if (!name) return { status: 'pass' }; // nameless: link-name reports it
88
89
  const normalized = normalizeName(name);
@@ -4,28 +4,13 @@
4
4
  // threshold: the link text ALONE has to identify the purpose, and context
5
5
  // is not allowed to make up the difference.
6
6
  //
7
- // This one asserts where the AA rule reviews. The criterion's only escape is
8
- // links "where the purpose of the link would be ambiguous to users in
9
- // general", and a link named nothing but "Read more" is ambiguous to
10
- // everyone — which is the exception failing to apply rather than rescuing
11
- // it. Nothing about the surrounding page can change that reading, so there
12
- // is nothing here for a human to settle.
7
+ // The criterion allows a mechanism that expands link names. F84 requires
8
+ // checking that no such mechanism exists, which a name-only snapshot cannot
9
+ // prove. Semantic ambiguity and alternative presentation remain review.
13
10
  //
14
- // Two matches stay in the review lane because the failure is not provable
15
- // (2026-08-25 corpus adjudication):
16
- // · a name wrapped in a paired bracket or quote — "<details>" is a code
17
- // term that names its destination exactly (one-sided decoration like
18
- // python.org's CSS-generated ">>>More" prompt still asserts);
19
- // · a name that is exactly "go" — every corpus hit was a brand logo
20
- // ("Go" is the language's name), and a brand name identifies the link.
21
- //
22
- // A third, beside "go" (2026-08-25 overnight audit): the French entries
23
- // "plus" and "suite" are ordinary English words and, more to the point,
24
- // common English plan and product names (a "Plus" tier, a "Suite"). A
25
- // pricing link named "Plus" identifies its destination the way the Go logo
26
- // does, so outside a page declared French they review rather than assert.
27
- // On a link whose closest lang is French the generic reading ("more",
28
- // "continued") is the only one and the assertion stands.
11
+ // The review wording distinguishes recognizable proper-name ambiguities
12
+ // (Go, Plus, Suite and quoted code terms) from ordinary generic phrases.
13
+ // Neither branch treats a phrase-list match as semantic proof.
29
14
  import { createLinkPurposeRule } from './2.4.4-link-text-generic.js';
30
15
 
31
16
  const FRENCH_ONLY_ENGLISH_NAMES = new Set(['plus', 'suite']);
@@ -50,8 +35,8 @@ export default createLinkPurposeRule({
50
35
  fix: 'If the name really is generic here, put the destination in the link text, e.g. "Read more about the 2026 budget".',
51
36
  };
52
37
  return {
53
- status: 'fail',
54
- message: `“${name}” gives no purpose on its own. 2.4.9 asks that the link text alone identify where a link goes, without relying on the sentence or heading around it, so anyone reading the page's links as a list can tell them apart.`,
38
+ status: 'incomplete',
39
+ message: `“${name}” appears generic when read alone. Check whether it identifies the destination in this context or whether a mechanism makes the link text descriptive. 2.4.9 permits such a mechanism; the initial link name alone cannot establish a failure.`,
55
40
  fix: 'Name the destination in the link text, e.g. "Read more about the 2026 budget".',
56
41
  };
57
42
  },