pour-engine 1.33.0 → 1.34.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.
- package/config/project.config.js +1 -1
- package/engine/COVERAGE.md +5 -5
- package/engine/index.js +11 -4
- package/engine/lib/accessible-name.js +15 -4
- package/engine/lib/contrast.js +316 -11
- package/engine/lib/roles.js +30 -6
- package/engine/rules/best-practice/button-type.js +23 -2
- package/engine/rules/best-practice/fieldset-legend.js +7 -2
- package/engine/rules/best-practice/heading-order.js +2 -1
- package/engine/rules/best-practice/landmark-top-level.js +10 -0
- package/engine/rules/best-practice/landmark-unique.js +9 -1
- package/engine/rules/best-practice/new-window-link.js +34 -1
- package/engine/rules/best-practice/no-autofocus.js +15 -3
- package/engine/rules/best-practice/page-heading-one.js +15 -1
- package/engine/rules/best-practice/redundant-alt-phrase.js +9 -1
- package/engine/rules/best-practice/redundant-aria-label.js +24 -0
- package/engine/rules/best-practice/redundant-role.js +71 -20
- package/engine/rules/best-practice/region.js +6 -2
- package/engine/rules/index.js +13 -0
- package/engine/rules/wcag/1.1.1-input-image-alt.js +4 -1
- package/engine/rules/wcag/1.1.1-object-alt.js +17 -0
- package/engine/rules/wcag/1.2.1-audio-transcript.js +47 -0
- package/engine/rules/wcag/1.2.2-media-captions.js +16 -1
- package/engine/rules/wcag/1.3.1-aria-required-children.js +30 -3
- package/engine/rules/wcag/1.3.1-aria-required-parent.js +25 -9
- package/engine/rules/wcag/1.3.1-definition-list.js +15 -3
- package/engine/rules/wcag/1.3.1-dlitem-parent.js +14 -1
- package/engine/rules/wcag/1.3.1-form-label.js +39 -1
- package/engine/rules/wcag/1.3.1-listitem-parent.js +23 -7
- package/engine/rules/wcag/1.3.1-table-headers.js +34 -3
- package/engine/rules/wcag/1.3.2-reading-order-divergence.js +69 -0
- package/engine/rules/wcag/1.3.4-orientation.js +21 -7
- package/engine/rules/wcag/1.3.5-autocomplete-valid.js +87 -13
- package/engine/rules/wcag/1.4.1-link-in-text-block.js +224 -17
- package/engine/rules/wcag/1.4.11-non-text-contrast.js +27 -3
- package/engine/rules/wcag/1.4.2-audio-control.js +45 -14
- package/engine/rules/wcag/1.4.3-color-contrast.js +337 -47
- package/engine/rules/wcag/1.4.3-control-contrast.js +26 -5
- package/engine/rules/wcag/2.2.1-meta-refresh.js +25 -1
- package/engine/rules/wcag/2.2.2-pause-stop-hide.js +26 -3
- package/engine/rules/wcag/2.4.1-bypass-blocks.js +29 -5
- package/engine/rules/wcag/2.4.2-document-title.js +8 -2
- package/engine/rules/wcag/2.4.3-visual-order-divergence.js +6 -4
- package/engine/rules/wcag/2.4.4-link-name.js +11 -0
- package/engine/rules/wcag/2.4.4-link-text-generic.js +22 -1
- package/engine/rules/wcag/2.4.7-focus-visible.js +47 -10
- package/engine/rules/wcag/2.4.9-link-text-generic-only.js +36 -5
- package/engine/rules/wcag/2.5.3-label-in-name.js +14 -5
- package/engine/rules/wcag/2.5.5-target-size-enhanced.js +3 -1
- package/engine/rules/wcag/2.5.7-drag-alternative.js +38 -0
- package/engine/rules/wcag/2.5.8-target-size.js +122 -3
- package/engine/rules/wcag/3.1.1-html-lang.js +12 -1
- package/engine/rules/wcag/3.2.2-on-input-navigation.js +18 -8
- package/engine/rules/wcag/3.3.7-redundant-entry.js +55 -0
- package/engine/rules/wcag/3.3.8-auth-field-obstruction.js +50 -20
- package/engine/rules/wcag/4.1.2-aria-attr-valid.js +49 -13
- package/engine/rules/wcag/4.1.2-aria-hidden-focus.js +6 -1
- package/engine/rules/wcag/4.1.2-aria-valid-refs.js +109 -72
- package/engine/rules/wcag/4.1.2-button-name.js +11 -0
- package/engine/rules/wcag/4.1.2-composite-widget-name.js +12 -6
- package/engine/rules/wcag/4.1.2-label-for-valid.js +41 -8
- package/engine/rules/wcag/4.1.2-nested-interactive.js +18 -1
- package/engine/rules/wcag/4.1.2-valid-role.js +16 -0
- package/engine/wcag22.js +4 -4
- 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
|
-
|
|
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
|
-
|
|
39
|
-
if (
|
|
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
|
-
|
|
42
|
-
|
|
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 (
|
|
47
|
-
if (s.boxShadow
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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 ${
|
|
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 ${
|
|
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
|
-
|
|
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
|
|
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
|
|
20
|
-
// duration by definition, so it
|
|
21
|
-
if (element.loop) {
|
|
22
|
-
return {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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:
|
|
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: '
|
|
37
|
-
message:
|
|
38
|
-
fix
|
|
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
|
};
|