pour-engine 1.33.0 → 1.34.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 (65) hide show
  1. package/config/project.config.js +1 -1
  2. package/engine/COVERAGE.md +5 -5
  3. package/engine/index.js +11 -4
  4. package/engine/lib/accessible-name.js +15 -4
  5. package/engine/lib/contrast.js +316 -11
  6. package/engine/lib/roles.js +30 -6
  7. package/engine/rules/best-practice/button-type.js +23 -2
  8. package/engine/rules/best-practice/fieldset-legend.js +7 -2
  9. package/engine/rules/best-practice/heading-order.js +2 -1
  10. package/engine/rules/best-practice/landmark-top-level.js +10 -0
  11. package/engine/rules/best-practice/landmark-unique.js +9 -1
  12. package/engine/rules/best-practice/new-window-link.js +34 -1
  13. package/engine/rules/best-practice/no-autofocus.js +15 -3
  14. package/engine/rules/best-practice/page-heading-one.js +15 -1
  15. package/engine/rules/best-practice/redundant-alt-phrase.js +9 -1
  16. package/engine/rules/best-practice/redundant-aria-label.js +24 -0
  17. package/engine/rules/best-practice/redundant-role.js +71 -20
  18. package/engine/rules/best-practice/region.js +6 -2
  19. package/engine/rules/index.js +13 -0
  20. package/engine/rules/wcag/1.1.1-input-image-alt.js +4 -1
  21. package/engine/rules/wcag/1.1.1-object-alt.js +17 -0
  22. package/engine/rules/wcag/1.2.1-audio-transcript.js +47 -0
  23. package/engine/rules/wcag/1.2.2-media-captions.js +16 -1
  24. package/engine/rules/wcag/1.3.1-aria-required-children.js +30 -3
  25. package/engine/rules/wcag/1.3.1-aria-required-parent.js +25 -9
  26. package/engine/rules/wcag/1.3.1-definition-list.js +15 -3
  27. package/engine/rules/wcag/1.3.1-dlitem-parent.js +14 -1
  28. package/engine/rules/wcag/1.3.1-form-label.js +39 -1
  29. package/engine/rules/wcag/1.3.1-listitem-parent.js +23 -7
  30. package/engine/rules/wcag/1.3.1-table-headers.js +34 -3
  31. package/engine/rules/wcag/1.3.2-reading-order-divergence.js +69 -0
  32. package/engine/rules/wcag/1.3.4-orientation.js +21 -7
  33. package/engine/rules/wcag/1.3.5-autocomplete-valid.js +87 -13
  34. package/engine/rules/wcag/1.4.1-link-in-text-block.js +224 -17
  35. package/engine/rules/wcag/1.4.11-non-text-contrast.js +27 -3
  36. package/engine/rules/wcag/1.4.2-audio-control.js +45 -14
  37. package/engine/rules/wcag/1.4.3-color-contrast.js +337 -47
  38. package/engine/rules/wcag/1.4.3-control-contrast.js +26 -5
  39. package/engine/rules/wcag/2.2.1-meta-refresh.js +25 -1
  40. package/engine/rules/wcag/2.2.2-pause-stop-hide.js +26 -3
  41. package/engine/rules/wcag/2.4.1-bypass-blocks.js +29 -5
  42. package/engine/rules/wcag/2.4.2-document-title.js +8 -2
  43. package/engine/rules/wcag/2.4.3-visual-order-divergence.js +6 -4
  44. package/engine/rules/wcag/2.4.4-link-name.js +11 -0
  45. package/engine/rules/wcag/2.4.4-link-text-generic.js +22 -1
  46. package/engine/rules/wcag/2.4.7-focus-visible.js +47 -10
  47. package/engine/rules/wcag/2.4.9-link-text-generic-only.js +36 -5
  48. package/engine/rules/wcag/2.5.3-label-in-name.js +14 -5
  49. package/engine/rules/wcag/2.5.5-target-size-enhanced.js +3 -1
  50. package/engine/rules/wcag/2.5.7-drag-alternative.js +38 -0
  51. package/engine/rules/wcag/2.5.8-target-size.js +122 -3
  52. package/engine/rules/wcag/3.1.1-html-lang.js +12 -1
  53. package/engine/rules/wcag/3.2.2-on-input-navigation.js +18 -8
  54. package/engine/rules/wcag/3.3.7-redundant-entry.js +55 -0
  55. package/engine/rules/wcag/3.3.8-auth-field-obstruction.js +50 -20
  56. package/engine/rules/wcag/4.1.2-aria-attr-valid.js +49 -13
  57. package/engine/rules/wcag/4.1.2-aria-hidden-focus.js +6 -1
  58. package/engine/rules/wcag/4.1.2-aria-valid-refs.js +44 -73
  59. package/engine/rules/wcag/4.1.2-button-name.js +11 -0
  60. package/engine/rules/wcag/4.1.2-composite-widget-name.js +12 -6
  61. package/engine/rules/wcag/4.1.2-label-for-valid.js +41 -8
  62. package/engine/rules/wcag/4.1.2-nested-interactive.js +18 -1
  63. package/engine/rules/wcag/4.1.2-valid-role.js +16 -0
  64. package/engine/wcag22.js +4 -4
  65. package/package.json +1 -1
@@ -1,5 +1,112 @@
1
1
  // WCAG SC 1.4.1 Use of Color (Level A)
2
- import { parseColor, contrastRatio } from '../../lib/contrast.js';
2
+ import { parseColor, contrastRatio, composite, effectiveBackground, backgroundImageSource } from '../../lib/contrast.js';
3
+
4
+ // Split a computed list value ("a, b(c, d), e") on the commas OUTSIDE
5
+ // parentheses: gradients and colour functions carry commas of their own.
6
+ const splitList = (value) => (value ?? '').split(/,(?![^(]*\))/).map((part) => part.trim());
7
+
8
+ const SIDES = ['Top', 'Right', 'Bottom', 'Left'];
9
+
10
+ // A border side paints only when it has width, a style and a visible
11
+ // colour. 2026-08-25 overnight audit: "border-bottom: 1px solid transparent"
12
+ // is the reveal-on-hover idiom and paints nothing at rest, and F73 says a cue
13
+ // "only provided on hover" still fails, so alpha is required here exactly as
14
+ // the background test already required it.
15
+ const sidePaints = (s, side) => parseFloat(s[`border${side}Width`]) > 0
16
+ && s[`border${side}Style`] !== 'none'
17
+ && (parseColor(s[`border${side}Color`])?.a ?? 0) > 0;
18
+
19
+ // A box-shadow paints only when some layer has a visible colour and a
20
+ // non-zero geometry: "0 0 0 0 currentColor" and a fully transparent shadow
21
+ // are the same hover-reveal idiom as the transparent border (2026-08-25
22
+ // overnight audit). Chrome's computed value reads "rgb(a)(...) x y blur
23
+ // spread [inset]" per layer.
24
+ function boxShadowPaints(boxShadow) {
25
+ if (!boxShadow || boxShadow === 'none') return false;
26
+ return splitList(boxShadow).some((layer) => {
27
+ const colorText = layer.match(/[a-z-]+\([^)]*\)/)?.[0];
28
+ const color = colorText ? parseColor(colorText) : null;
29
+ if (color && color.a === 0) return false;
30
+ const lengths = layer.replace(colorText ?? '', '').match(/-?[\d.]+(?=px)/g) ?? [];
31
+ return !(lengths.length && lengths.every((n) => parseFloat(n) === 0));
32
+ });
33
+ }
34
+
35
+ // Does a background-image paint anything at rest? 'no' when every layer is
36
+ // sized to nothing (the "background-size: 0 1px, grown on hover" underline
37
+ // idiom, 2026-08-25 overnight audit), 'unclear' when a no-repeat layer is
38
+ // parked outside the box so its resting geometry cannot be settled from
39
+ // computed style, 'yes' otherwise.
40
+ function backgroundImagePaints(s, rect) {
41
+ if (!s.backgroundImage || s.backgroundImage === 'none') return 'no';
42
+ const images = splitList(s.backgroundImage);
43
+ const sizes = splitList(s.backgroundSize);
44
+ const repeats = splitList(s.backgroundRepeat);
45
+ const positions = splitList(s.backgroundPosition);
46
+ let verdict = 'no';
47
+ images.forEach((image, i) => {
48
+ if (image === 'none') return;
49
+ const size = sizes[i % sizes.length] || 'auto';
50
+ if (/(^|\s)0(px|%)?(\s|$)/.test(size)) return; // a zero dimension paints nothing
51
+ const repeat = repeats[i % repeats.length] || 'repeat';
52
+ const position = positions[i % positions.length] || '0% 0%';
53
+ if (repeat.includes('no-repeat')) {
54
+ const outside = position.split(/\s+/).some((component, axis) => {
55
+ const n = parseFloat(component);
56
+ if (Number.isNaN(n)) return false;
57
+ if (component.endsWith('%')) return n < 0 || n > 100;
58
+ const extent = axis === 0 ? rect?.width : rect?.height;
59
+ return n < 0 || (Number.isFinite(extent) && n > extent);
60
+ });
61
+ if (outside) { if (verdict === 'no') verdict = 'unclear'; return; }
62
+ }
63
+ verdict = 'yes';
64
+ });
65
+ return verdict;
66
+ }
67
+
68
+ // Read one ::before / ::after box on the link. 2026-08-25 overnight audit:
69
+ // querySelectorAll never yields pseudo-elements, so the everyday "custom
70
+ // underline" (an absolutely positioned 2px bar in ::after) was invisible to
71
+ // this rule. Returns 'cue' when the pseudo provably paints a shape at rest,
72
+ // 'glyph' when it draws text or an icon (a judgment by eye), 'unclear' when
73
+ // it paints something whose box computed style cannot measure, and null when
74
+ // it paints nothing (no content, collapsed by scaleX(0), zero box, hidden).
75
+ function pseudoCue(element, which) {
76
+ let p;
77
+ try { p = getComputedStyle(element, which); } catch { return null; }
78
+ const content = p.content;
79
+ if (!content || content === 'none' || content === 'normal' || p.display === 'none') return null;
80
+ if (p.visibility !== 'visible' || parseFloat(p.opacity) === 0) return null;
81
+ const matrix = (p.transform ?? 'none').match(/^matrix(3d)?\((.*)\)$/);
82
+ if (matrix) {
83
+ const n = matrix[2].split(',').map(parseFloat);
84
+ const [a, b, c, d] = matrix[1] ? [n[0], n[1], n[4], n[5]] : n;
85
+ if ((a === 0 && b === 0) || (c === 0 && d === 0)) return null; // scaleX(0) / scaleY(0): grown on hover
86
+ }
87
+ const width = parseFloat(p.width);
88
+ const height = parseFloat(p.height);
89
+ const glyph = (/^["'].+["']$/s.test(content) && parseFloat(p.fontSize) > 0)
90
+ || /^(url|counter|counters|attr|image-set)\(/.test(content);
91
+ const paintedBackground = (parseColor(p.backgroundColor)?.a ?? 0) > 0
92
+ || backgroundImagePaints(p, Number.isNaN(width) ? null : { width, height }) === 'yes';
93
+ const paintedBorder = SIDES.some((side) => sidePaints(p, side));
94
+ if (paintedBackground || paintedBorder) {
95
+ if (Number.isNaN(width) || Number.isNaN(height)) return glyph ? 'glyph' : 'unclear';
96
+ const extra = (edges) => edges.reduce((sum, edge) => sum + (parseFloat(p[edge]) || 0), 0);
97
+ const boxWidth = width + extra(['paddingLeft', 'paddingRight', 'borderLeftWidth', 'borderRightWidth']);
98
+ const boxHeight = height + extra(['paddingTop', 'paddingBottom', 'borderTopWidth', 'borderBottomWidth']);
99
+ if (boxWidth > 0 && boxHeight > 0) return 'cue';
100
+ }
101
+ if (glyph) {
102
+ // A 1px clip box is the sr-only pattern: text for a screen reader, not a glyph anyone sees.
103
+ if (!Number.isNaN(width) && !Number.isNaN(height) && width <= 1 && height <= 1) return null;
104
+ return 'glyph';
105
+ }
106
+ return null;
107
+ }
108
+
109
+ const GRAPHIC = 'svg, img, picture, canvas, video, object, embed';
3
110
 
4
111
  export default {
5
112
  id: 'link-in-text-block',
@@ -28,28 +135,51 @@ export default {
28
135
  const style = getComputedStyle(element);
29
136
  const parentStyle = getComputedStyle(parent);
30
137
  const weight = (s) => parseInt(s.fontWeight, 10) || 400;
138
+ const slanted = (s) => /italic|oblique/.test(s.fontStyle ?? '');
139
+ const parentSize = parseFloat(parentStyle.fontSize) || 16;
31
140
  // A cue counts wherever it is painted: sites routinely reset
32
141
  // text-decoration on the anchor and underline (or embolden) an inner
33
142
  // span that carries the text, so every visible text-bearing element in
34
143
  // the link vouches, not just the anchor. The >1px rect floor keeps
35
144
  // 1×1 sr-only clip spans from vouching for a cue nobody can see.
36
- const cueBearers = [style];
145
+ // Empty descendants with a real box (an icon font's <i>, a decorative
146
+ // span) vouch for painted shapes only, never for text properties.
147
+ const cueBearers = [{ s: style, el: element, text: true }];
148
+ // 2026-08-25 overnight audit: a rendered graphic inside the link (an
149
+ // external-link arrow, a chevron, a download icon) is "some other means"
150
+ // in F73's words, but whether it reads as part of the link is a judgment
151
+ // by eye, so it demotes a colour-only verdict to review, never to pass.
152
+ let graphic = false;
37
153
  for (const el of element.querySelectorAll('*')) {
38
- if (!el.textContent.trim()) continue;
39
- if ([...el.getClientRects()].some((r) => r.width > 1 && r.height > 1)) cueBearers.push(getComputedStyle(el));
154
+ const rects = [...el.getClientRects()].filter((r) => r.width > 1 && r.height > 1);
155
+ if (!rects.length) continue;
156
+ if (el.matches(GRAPHIC)) {
157
+ if (rects.some((r) => r.width >= 4 && r.height >= 4)) graphic = true;
158
+ continue;
159
+ }
160
+ if (el.closest(GRAPHIC)) continue; // svg internals
161
+ cueBearers.push({ s: getComputedStyle(el), el, text: !!el.textContent.trim() });
40
162
  }
41
- for (const s of cueBearers) {
42
- if ((s.textDecorationLine ?? s.textDecoration ?? '').includes('underline')) return { status: 'pass' };
163
+ let unclear = false;
164
+ for (const { s, el, text } of cueBearers) {
165
+ if (text && (s.textDecorationLine ?? s.textDecoration ?? '').includes('underline')) return { status: 'pass' };
43
166
  // Underline substitutes count as non-colour cues too: border-bottom
44
167
  // "underlines", box-shadow underlines, and background chips/pills all
45
168
  // mark the link by shape, not colour alone.
46
- if (parseFloat(s.borderBottomWidth) > 0 && s.borderBottomStyle !== 'none') return { status: 'pass' };
47
- if (s.boxShadow && s.boxShadow !== 'none') return { status: 'pass' };
169
+ if (sidePaints(s, 'Bottom')) return { status: 'pass' };
170
+ if (boxShadowPaints(s.boxShadow)) return { status: 'pass' };
48
171
  const ownBackground = parseColor(s.backgroundColor);
49
172
  if (ownBackground && ownBackground.a > 0) return { status: 'pass' };
50
- if (s.backgroundImage !== 'none') return { status: 'pass' }; // gradient/image underline technique
173
+ const painted = backgroundImagePaints(s, el.getBoundingClientRect()); // gradient/image underline technique
174
+ if (painted === 'yes') return { status: 'pass' };
175
+ if (painted === 'unclear') unclear = true;
176
+ if (!text) continue;
51
177
  // A clear weight difference is a non-colour distinction too.
52
178
  if (Math.abs(weight(s) - weight(parentStyle)) >= 300) return { status: 'pass' };
179
+ // 2026-08-25 overnight audit: F73's own list of other means names
180
+ // "italicized" without qualification, so italic or oblique against
181
+ // upright prose (either way round) is a cue in its own right.
182
+ if (slanted(s) !== slanted(parentStyle)) return { status: 'pass' };
53
183
  }
54
184
  // A weight step of 200–299 (600-semibold links in 400 prose, the common
55
185
  // case) is a REAL non-colour cue, just not a clear one: whether it reads
@@ -59,11 +189,44 @@ export default {
59
189
  // passing waves through faces where the step is invisible — so it goes
60
190
  // to review. Below 200 the step is imperceptible at body sizes and earns
61
191
  // nothing.
62
- const weightStep = Math.max(...cueBearers.map((s) => Math.abs(weight(s) - weight(parentStyle))));
192
+ const textBearers = cueBearers.filter((b) => b.text).map((b) => b.s);
193
+ const weightStep = Math.max(...textBearers.map((s) => Math.abs(weight(s) - weight(parentStyle))));
194
+ // G182 also names "changes to the font size" as a cue. How large a step
195
+ // reads as one is a judgment by eye, so a step of at least 2px and a
196
+ // tenth of the prose size goes to the same review band as semibold
197
+ // (2026-08-25 overnight audit).
198
+ const sizeStep = Math.max(...textBearers.map((s) => Math.abs((parseFloat(s.fontSize) || parentSize) - parentSize)));
199
+ const sizeCue = sizeStep >= 2 && sizeStep / parentSize >= 0.1;
63
200
 
64
- const linkColor = parseColor(style.color);
65
- const textColor = parseColor(parentStyle.color);
201
+ let linkColor = parseColor(style.color);
202
+ let textColor = parseColor(parentStyle.color);
66
203
  if (!linkColor || !textColor) return { status: 'pass' };
204
+ // 2026-08-25 overnight audit: a translucent colour is presented
205
+ // composited over its backdrop (rgba(0,0,0,.3) in black prose on white
206
+ // is a light grey, about 10:1 from the prose), and G183's ratio is a
207
+ // property of the colours as presented. Composite both sides over the
208
+ // effective background before the equality test and the ratio; over an
209
+ // image there is no colour to composite against, so abstain.
210
+ if ((linkColor.a ?? 1) < 1 || (textColor.a ?? 1) < 1) {
211
+ if (backgroundImageSource(element)) {
212
+ return {
213
+ status: 'incomplete',
214
+ message: 'This link has no underline and its colour is translucent over a background image or gradient, so its colour difference from the surrounding text depends on the pixels behind it. Check by eye that something other than colour identifies it as a link.',
215
+ fix: 'Underline links inside text (text-decoration: underline), or add a non-colour indicator.',
216
+ };
217
+ }
218
+ const backdrop = effectiveBackground(element);
219
+ if (!backdrop) {
220
+ return {
221
+ status: 'incomplete',
222
+ message: 'This link has no underline and its colour is translucent, but the background it composites over could not be determined. Check by eye that something other than colour identifies it as a link.',
223
+ fix: 'Underline links inside text (text-decoration: underline), or add a non-colour indicator.',
224
+ };
225
+ }
226
+ const over = (color) => ((color.a ?? 1) < 1 ? composite(color, backdrop) : color);
227
+ linkColor = over(linkColor);
228
+ textColor = over(textColor);
229
+ }
67
230
 
68
231
  // A link styled to look NO different from the surrounding prose is not
69
232
  // a 1.4.1 failure — Understanding 1.4.1, in its own words: "a hyperlink
@@ -77,9 +240,10 @@ export default {
77
240
  // Channel equality, NOT the luminance ratio: a hue-only difference at
78
241
  // equal luminance also lands at 1.00:1, and that is the clearest F73
79
242
  // failure there is — the two cases are opposites and the ratio cannot
80
- // tell them apart.
81
- if (linkColor.r === textColor.r && linkColor.g === textColor.g
82
- && linkColor.b === textColor.b && (linkColor.a ?? 1) === (textColor.a ?? 1)) {
243
+ // tell them apart. (Compared after compositing, so both sides are the
244
+ // opaque colours actually presented.)
245
+ const same = (x, y) => Math.round(x) === Math.round(y);
246
+ if (same(linkColor.r, textColor.r) && same(linkColor.g, textColor.g) && same(linkColor.b, textColor.b)) {
83
247
  return { status: 'pass' };
84
248
  }
85
249
 
@@ -88,6 +252,26 @@ export default {
88
252
  // colour difference for identifying a link.
89
253
  if (ratio >= 3) return { status: 'pass' };
90
254
 
255
+ // 2026-08-25 overnight audit: text-decoration propagates from an
256
+ // ancestor to its inline descendants and a descendant's "none" cannot
257
+ // cancel it, so <u><a style="text-decoration:none"> is underlined. Walk
258
+ // up to (but excluding) the block: an ancestor that wraps only the link
259
+ // underlines the link; one that also wraps prose underlines the prose
260
+ // too and singles nothing out, so it earns nothing.
261
+ for (let ancestor = element.parentElement; ancestor && ancestor !== parent; ancestor = ancestor.parentElement) {
262
+ if (!(getComputedStyle(ancestor).textDecorationLine ?? '').includes('underline')) continue;
263
+ if (ancestor.textContent.replace(/\s+/g, '') === element.textContent.replace(/\s+/g, '')) return { status: 'pass' };
264
+ break;
265
+ }
266
+ // Generated content on the anchor: read only now, on the failing path,
267
+ // so passing links never pay for the two extra style reads.
268
+ for (const which of ['::after', '::before']) {
269
+ const verdict = pseudoCue(element, which);
270
+ if (verdict === 'cue') return { status: 'pass' };
271
+ if (verdict === 'glyph') graphic = true;
272
+ if (verdict === 'unclear') unclear = true;
273
+ }
274
+
91
275
  // LAST, because it's the expensive check (tree walk + line geometry)
92
276
  // and only failing candidates should ever pay for it: a link occupying
93
277
  // its own line (<br>-separated address blocks, one-link-per-line
@@ -116,13 +300,36 @@ export default {
116
300
  }
117
301
  if (!sharesLine) return { status: 'pass' };
118
302
  }
303
+ const shown = ratio.toFixed(2);
304
+ const reviewFix = 'Underline links inside text (text-decoration: underline), or add another clear non-colour indicator.';
305
+ if (graphic) {
306
+ return {
307
+ status: 'incomplete',
308
+ message: `This link has no underline and only ${shown}:1 colour difference from the surrounding text, but it carries an icon or image. Judge by eye whether the graphic reads as part of the link and marks it out from the prose; if it does not, this fails SC 1.4.1.`,
309
+ fix: reviewFix,
310
+ };
311
+ }
312
+ if (unclear) {
313
+ return {
314
+ status: 'incomplete',
315
+ message: `This link has no underline and only ${shown}:1 colour difference from the surrounding text, but a generated box or background image is painted on it whose size at rest could not be measured. Check by eye whether it draws a visible underline or shape; if nothing shows until hover, this fails SC 1.4.1.`,
316
+ fix: reviewFix,
317
+ };
318
+ }
119
319
  if (weightStep >= 200) {
120
320
  return {
121
321
  status: 'incomplete',
122
- message: `This link has no underline and only ${ratio.toFixed(2)}:1 colour difference from the surrounding text, but its font weight differs from the prose by ${weightStep}: a visible non-colour cue that falls short of a clear bold step. Judge by eye whether the weight alone identifies it as a link in this face and size; if it does not, this fails SC 1.4.1.`,
322
+ message: `This link has no underline and only ${shown}:1 colour difference from the surrounding text, but its font weight differs from the prose by ${weightStep}: a visible non-colour cue that falls short of a clear bold step. Judge by eye whether the weight alone identifies it as a link in this face and size; if it does not, this fails SC 1.4.1.`,
123
323
  fix: 'Underline links inside text (text-decoration: underline), raise the weight difference to a clear bold step, or add another non-colour indicator.',
124
324
  };
125
325
  }
326
+ if (sizeCue) {
327
+ return {
328
+ status: 'incomplete',
329
+ message: `This link has no underline and only ${shown}:1 colour difference from the surrounding text, but its font size differs from the prose by ${sizeStep.toFixed(1).replace(/\.0$/, '')}px, one of the cues G182 names. Judge by eye whether the size alone identifies it as a link; if it does not, this fails SC 1.4.1.`,
330
+ fix: reviewFix,
331
+ };
332
+ }
126
333
  return {
127
334
  status: 'fail',
128
335
  // The measured fact is the ratio against G183's threshold, and that is
@@ -132,7 +339,7 @@ export default {
132
339
  // colour blindness, where the red darkens and the gap widens.
133
340
  message: ratio < 1.01
134
341
  ? 'This link has no underline and its colour differs from the surrounding text in hue alone, with no luminance difference: the distinction disappears entirely without colour vision, the exact failure F73 describes.'
135
- : `This link has no underline and only ${ratio.toFixed(2)}:1 colour difference from the surrounding text, below the 3:1 WCAG technique G183 asks for when colour is the only thing marking a link.`,
342
+ : `This link has no underline and only ${shown}:1 colour difference from the surrounding text, below the 3:1 WCAG technique G183 asks for when colour is the only thing marking a link.`,
136
343
  fix: 'Underline links inside text (text-decoration: underline), or add a non-colour indicator.',
137
344
  };
138
345
  },
@@ -10,14 +10,25 @@ import { parseColor, contrastRatio, composite, effectiveBackground, showRatio }
10
10
 
11
11
  export default {
12
12
  id: 'non-text-contrast',
13
- name: 'Control & icon contrast',
13
+ // Named for what the rule measures: field fills and borders. It never
14
+ // examines an icon, and the Understanding document says a boundary is not
15
+ // required when the field has other visible identifiers, so the old
16
+ // "icons need a visible boundary" wording overclaimed twice (2026-08-25
17
+ // overnight audit).
18
+ name: 'Form field boundary contrast',
14
19
  impact: 'serious',
15
20
  tags: ['wcag21aa', 'wcag1411'],
16
- help: 'Form fields and icons need a 3:1 visible boundary',
21
+ help: 'Form field fills and borders below 3:1 need checking against the field\'s other identifiers',
17
22
  helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html',
23
+ // range and file are excluded: untouched, both are drawn by the user agent
24
+ // (the 1.4.11 user-agent exception), and Chrome's UA default for file is
25
+ // already appearance:none so the appearance test cannot tell an author
26
+ // restyle from the default. An author-styled range needs a track-and-thumb
27
+ // judgment this rule does not attempt, so the field-boundary message would
28
+ // be wrong for it either way (2026-08-25 overnight audit).
18
29
  selector:
19
30
  'input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]):not([type="submit"])'
20
- + ':not([type="button"]):not([type="reset"]):not([type="image"]), select, textarea',
31
+ + ':not([type="button"]):not([type="reset"]):not([type="image"]):not([type="range"]):not([type="file"]), select, textarea',
21
32
  visibility: 'visual',
22
33
  evaluate(element) {
23
34
  // Inactive controls are exempt (same carve-out 1.4.3 makes).
@@ -56,6 +67,19 @@ export default {
56
67
  boundaries.push(color.a >= 1 ? color : composite(color, surrounding));
57
68
  if (color.a < 1) translucentSeen = true;
58
69
  }
70
+ // An outline drawn at rest IS a boundary: "border: none; outline: 1px
71
+ // solid" is used in place of a border to avoid layout shift, and a 1px
72
+ // black outline on white is 21:1 with nothing left to review. Same
73
+ // tests as a border side: width, a style, a visible colour, composited
74
+ // the same way (2026-08-25 overnight audit).
75
+ if (parseFloat(style.outlineWidth) > 0 && style.outlineStyle !== 'none') {
76
+ const color = parseColor(style.outlineColor);
77
+ if (color && color.a > 0) {
78
+ hasBorder = true;
79
+ boundaries.push(color.a >= 1 ? color : composite(color, surrounding));
80
+ if (color.a < 1) translucentSeen = true;
81
+ }
82
+ }
59
83
  const best = Math.max(1, ...boundaries.map((color) => contrastRatio(color, surrounding)));
60
84
  if (best >= 3) return { status: 'pass' };
61
85
  // Box-shadow outlines and background images can also draw the boundary;
@@ -1,4 +1,17 @@
1
1
  // WCAG SC 1.4.2 Audio Control (Level A)
2
+ // "If any audio on a Web page plays automatically for more than 3 seconds,
3
+ // either a mechanism is available to pause or stop the audio, or a
4
+ // mechanism is available to control audio volume independently from the
5
+ // overall system volume level."
6
+ //
7
+ // The DOM proves autoplay, the absence of native controls and the length.
8
+ // It cannot prove the two things a failure also needs: that the media has
9
+ // an audio track at all (a video file may carry none, F93 step 1 asks for
10
+ // "an active audio track"), and that no pause, stop or volume control
11
+ // exists anywhere on the page (G170, a plain button near the top of the
12
+ // page, is a sufficient technique). So an autoplaying, unmuted, control-less
13
+ // element is a question for a human, never an assertion. (2026-08-25
14
+ // overnight audit: the old fail was asserted without either proof.)
2
15
  export default {
3
16
  id: 'audio-control',
4
17
  name: 'Auto-playing audio control',
@@ -16,26 +29,44 @@ export default {
16
29
  if (element.muted || element.volume === 0) return { status: 'pass' };
17
30
  if (element.hasAttribute('muted') || element.hasAttribute('controls')) return { status: 'pass' };
18
31
  // The criterion applies to audio playing for MORE than 3 seconds; a
19
- // short non-looping chime is exempt — but a LOOPING clip exceeds any
20
- // duration by definition, so it fails without needing metadata.
21
- if (element.loop) {
22
- return {
23
- status: 'fail',
24
- message: 'This media auto-plays on a loop with no controls and no muting — its audio talks over screen readers indefinitely.',
25
- fix: 'Add controls (or muted), or don’t autoplay.',
26
- };
32
+ // short non-looping chime is exempt, but a LOOPING clip exceeds any
33
+ // duration by definition, so it needs no metadata.
34
+ if (!element.loop) {
35
+ if (Number.isFinite(element.duration) && element.duration <= 3) return { status: 'pass' };
36
+ if (!Number.isFinite(element.duration)) {
37
+ return {
38
+ status: 'incomplete',
39
+ message: 'This media auto-plays without controls or muting; its duration isn’t loaded yet, and only audio playing longer than 3 seconds fails 1.4.2 — check whether it has sound and how long it plays.',
40
+ };
41
+ }
27
42
  }
28
- if (Number.isFinite(element.duration) && element.duration <= 3) return { status: 'pass' };
29
- if (!Number.isFinite(element.duration)) {
43
+ const span = element.loop ? 'on a loop' : 'for more than three seconds';
44
+ const fix = 'Add controls (or muted), or do not autoplay. Otherwise put a pause, stop or volume control near the start of the page, before the media.';
45
+ if (element.tagName === 'VIDEO') {
46
+ // A video without a soundtrack cannot fail an audio criterion, and the
47
+ // markup does not say whether one exists. Chromium counts decoded
48
+ // audio bytes: a count still at zero after a second of playback is
49
+ // evidence there is nothing to hear; a positive count proves sound.
50
+ const decoded = element.webkitAudioDecodedByteCount;
51
+ const started = !element.paused && !element.ended && element.currentTime >= 1;
52
+ if (started && decoded === 0) return { status: 'pass' };
53
+ if (decoded > 0) {
54
+ return {
55
+ status: 'incomplete',
56
+ message: `This video auto-plays with sound ${span}, with no controls and no muting, so its audio talks over a screen reader unless the page offers a way to stop it. Check for a pause, stop or volume control near the top of the page; if there is none, 1.4.2 is not met.`,
57
+ fix,
58
+ };
59
+ }
30
60
  return {
31
61
  status: 'incomplete',
32
- message: 'This media auto-plays without controls or muting; its duration isn’t loaded yet, and only audio playing longer than 3 seconds fails 1.4.2 — check whether it has sound and how long it plays.',
62
+ message: `This video auto-plays ${span} with no controls and no muting. If this video has a soundtrack, its audio talks over a screen reader unless the page offers a way to stop it. Check whether it has sound and, if so, that a pause, stop or volume control sits near the top of the page.`,
63
+ fix,
33
64
  };
34
65
  }
35
66
  return {
36
- status: 'fail',
37
- message: 'This media auto-plays with no controls and no muting — its audio talks over screen readers.',
38
- fix: 'Add controls (or muted), or don’t autoplay.',
67
+ status: 'incomplete',
68
+ message: `This audio auto-plays ${span} with no controls and no muting, so it talks over a screen reader unless the page offers a way to stop it. Check for a pause, stop or volume control near the top of the page; if there is none, 1.4.2 is not met.`,
69
+ fix,
39
70
  };
40
71
  },
41
72
  };