pour-engine 1.40.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.
- package/config/project.config.js +1 -1
- package/engine/AUDIT.html +24 -690
- package/engine/COVERAGE.md +4 -2
- package/engine/README.md +9 -4
- package/engine/index.js +34 -8
- package/engine/lib/accessible-name.js +69 -63
- package/engine/lib/contrast.js +111 -10
- package/engine/lib/dom.js +78 -18
- package/engine/lib/roles.js +5 -2
- package/engine/rules/best-practice/fieldset-legend.js +11 -5
- package/engine/rules/wcag/1.1.1-area-alt.js +6 -1
- package/engine/rules/wcag/1.1.1-image-alt.js +2 -1
- package/engine/rules/wcag/1.1.1-svg-img-alt.js +14 -17
- package/engine/rules/wcag/1.3.1-aria-required-children.js +20 -8
- package/engine/rules/wcag/1.3.1-aria-required-parent.js +7 -10
- package/engine/rules/wcag/1.3.1-form-label.js +5 -29
- package/engine/rules/wcag/1.3.1-list-structure.js +4 -3
- package/engine/rules/wcag/1.3.1-table-headers.js +23 -2
- package/engine/rules/wcag/1.3.4-orientation.js +117 -48
- package/engine/rules/wcag/1.3.5-autocomplete-valid.js +21 -8
- package/engine/rules/wcag/1.4.1-link-in-text-block.js +19 -3
- package/engine/rules/wcag/1.4.10-reflow.js +3 -3
- package/engine/rules/wcag/1.4.11-non-text-contrast.js +9 -1
- package/engine/rules/wcag/1.4.12-text-spacing.js +15 -2
- package/engine/rules/wcag/1.4.3-color-contrast.js +79 -7
- package/engine/rules/wcag/1.4.3-control-contrast.js +35 -6
- package/engine/rules/wcag/1.4.4-meta-viewport.js +7 -2
- package/engine/rules/wcag/2.1.1-scrollable-region-focusable.js +5 -5
- package/engine/rules/wcag/2.2.1-meta-refresh.js +8 -2
- package/engine/rules/wcag/2.2.2-pause-stop-hide.js +5 -5
- package/engine/rules/wcag/2.4.1-bypass-blocks.js +2 -2
- package/engine/rules/wcag/2.4.2-document-title.js +7 -8
- package/engine/rules/wcag/2.4.4-link-text-generic.js +6 -5
- package/engine/rules/wcag/2.4.9-link-text-generic-only.js +8 -23
- package/engine/rules/wcag/2.5.8-target-size.js +161 -45
- package/engine/rules/wcag/3.3.8-auth-field-obstruction.js +6 -12
- package/engine/rules/wcag/4.1.2-aria-attr-valid.js +4 -0
- package/engine/rules/wcag/4.1.2-aria-field-name.js +6 -5
- package/engine/rules/wcag/4.1.2-aria-valid-refs.js +8 -3
- package/engine/rules/wcag/4.1.2-button-name.js +1 -1
- package/engine/rules/wcag/4.1.2-composite-widget-name.js +1 -1
- package/engine/rules/wcag/4.1.2-invoker-target.js +2 -1
- package/engine/rules/wcag/4.1.2-label-for-valid.js +2 -2
- package/engine/rules/wcag/4.1.2-valid-role.js +6 -1
- package/engine/wcag22.js +7 -7
- package/package.json +1 -1
|
@@ -3,58 +3,108 @@
|
|
|
3
3
|
// orientation, such as portrait or landscape, unless a specific display
|
|
4
4
|
// orientation is essential."
|
|
5
5
|
//
|
|
6
|
-
// What a stylesheet can
|
|
7
|
-
// block that hides or counter-rotates the page root
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// (body { transform: rotate(90deg) }).
|
|
11
|
-
//
|
|
12
|
-
//
|
|
6
|
+
// What a stylesheet can show: a rule inside an @media (orientation: …)
|
|
7
|
+
// block that hides or counter-rotates the page root restricts the whole
|
|
8
|
+
// page to the other orientation, the "please rotate your device" wall
|
|
9
|
+
// (body { display: none }) and the forced-landscape hack
|
|
10
|
+
// (body { transform: rotate(90deg) }). Asserted the way ACT rule b33eff
|
|
11
|
+
// asserts a quarter turn between orientations, under its stated assumption
|
|
12
|
+
// that no orientation is essential; the message names that exception. Both
|
|
13
|
+
// orientations count: a rule for the orientation the device is not in right
|
|
14
|
+
// now is still a restriction, and ACT evaluates both.
|
|
13
15
|
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
16
|
+
// What the live page adds (2026-09-12): when the block IS active, computed
|
|
17
|
+
// style shows whether the declaration is in force, so an overridden or
|
|
18
|
+
// cancelled declaration is not a failure. When the block is dormant, a
|
|
19
|
+
// later declaration for the same root in the same orientation undoes an
|
|
20
|
+
// earlier one, in cascade order (display: block after display: none,
|
|
21
|
+
// transform: none after a rotation). Unsupported @supports branches and
|
|
22
|
+
// disabled sheets are skipped. Script locks (screen.orientation.lock()) and
|
|
23
|
+
// cross-origin sheets stay invisible to a static pass.
|
|
24
|
+
//
|
|
25
|
+
// Deliberately NOT flagged: orientation queries that restyle or hide
|
|
26
|
+
// individual regions (ordinary responsive design), and a root pseudo-element
|
|
27
|
+
// (body::before is one generated box). Root pseudo-class selectors only
|
|
28
|
+
// count when the live root matches them (body:not(.ready) once .ready is on
|
|
29
|
+
// locks nothing).
|
|
30
|
+
// https://www.w3.org/WAI/WCAG22/Understanding/orientation.html
|
|
31
|
+
// https://www.w3.org/WAI/standards-guidelines/act/rules/b33eff/
|
|
25
32
|
const ROOT_PART = /^(html|body|:root)((?::[a-z-]+(?:\([^()]*\))?)*)$/i;
|
|
26
33
|
const LOCK_ROTATE = /rotate(?:3d\([^)]*,\s*)?\(?\s*(?:-?(?:90|270)deg|0\.25turn|-0\.25turn|100grad|-100grad)/i;
|
|
27
34
|
|
|
28
|
-
function
|
|
29
|
-
|
|
35
|
+
function matchingRoots(selectorText, doc) {
|
|
36
|
+
const roots = new Set();
|
|
37
|
+
for (const part of (selectorText ?? '').split(',')) {
|
|
30
38
|
const match = ROOT_PART.exec(part.trim());
|
|
31
|
-
if (!match)
|
|
32
|
-
if (!match[2]) return true; // bare root: always in force
|
|
33
|
-
// body:not(.ready) { visibility: hidden } locks nothing once the class
|
|
34
|
-
// is on; the selector must match the root as it stands to be a lock.
|
|
39
|
+
if (!match) continue;
|
|
35
40
|
const root = /^body$/i.test(match[1]) ? doc.body : doc.documentElement;
|
|
36
|
-
try {
|
|
37
|
-
}
|
|
41
|
+
try { if (root?.matches(part.trim())) roots.add(root); } catch { /* invalid selector */ }
|
|
42
|
+
}
|
|
43
|
+
return [...roots];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// CSS transforms and the independent rotate property compose. A 90-degree
|
|
47
|
+
// declaration can be overridden, or cancelled by an opposite rotation.
|
|
48
|
+
function quarterTurn(style, win) {
|
|
49
|
+
let angle = 0;
|
|
50
|
+
if (style.transform && style.transform !== 'none') {
|
|
51
|
+
try {
|
|
52
|
+
const matrix = new win.DOMMatrixReadOnly(style.transform);
|
|
53
|
+
if (!matrix.is2D) return null;
|
|
54
|
+
angle += Math.atan2(matrix.b, matrix.a) * 180 / Math.PI;
|
|
55
|
+
} catch { return null; }
|
|
56
|
+
}
|
|
57
|
+
if (style.rotate && style.rotate !== 'none') {
|
|
58
|
+
const rotation = /^(?:(?:z|0\s+0\s+1)\s+)?(-?[\d.]+)(deg|rad|grad|turn)$/.exec(style.rotate);
|
|
59
|
+
if (!rotation) return null;
|
|
60
|
+
const scale = { deg: 1, rad: 180 / Math.PI, grad: 0.9, turn: 360 }[rotation[2]];
|
|
61
|
+
angle += Number(rotation[1]) * scale;
|
|
62
|
+
}
|
|
63
|
+
return Math.abs(((angle % 180) + 180) % 180 - 90) < 0.01;
|
|
38
64
|
}
|
|
39
65
|
|
|
40
|
-
|
|
66
|
+
/** Walk the sheet in cascade order, keeping the LAST relevant declaration
|
|
67
|
+
* per root, orientation and property, so a later declaration in the same
|
|
68
|
+
* orientation block undoes an earlier lock. */
|
|
69
|
+
function scanRules(rules, orientationContext, active, state, doc) {
|
|
70
|
+
const win = doc.defaultView;
|
|
41
71
|
for (const rule of rules ?? []) {
|
|
42
|
-
|
|
43
|
-
|
|
72
|
+
if (rule.type === win.CSSRule.SUPPORTS_RULE) {
|
|
73
|
+
// An unsupported branch cannot apply in either orientation.
|
|
74
|
+
if (win.CSS.supports(rule.conditionText)) scanRules(rule.cssRules, orientationContext, active, state, doc);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (rule.type === win.CSSRule.MEDIA_RULE) {
|
|
78
|
+
const condition = rule.conditionText ?? rule.media.mediaText;
|
|
44
79
|
const orientation = /orientation\s*:\s*(portrait|landscape)/i.exec(condition)?.[1];
|
|
45
|
-
|
|
80
|
+
const context = orientation ? { condition, orientation } : orientationContext;
|
|
81
|
+
scanRules(rule.cssRules, context, active && win.matchMedia(condition).matches, state, doc);
|
|
46
82
|
continue;
|
|
47
83
|
}
|
|
48
|
-
if (
|
|
84
|
+
if (orientationContext && rule.style) {
|
|
49
85
|
const { display, visibility, transform, rotate } = rule.style;
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
86
|
+
const declared = {
|
|
87
|
+
display: display ? (display === 'none' ? 'hidden' : null) : undefined,
|
|
88
|
+
visibility: visibility ? (visibility === 'hidden' ? 'hidden' : null) : undefined,
|
|
89
|
+
transform: transform ? (LOCK_ROTATE.test(transform) ? 'rotated' : null) : undefined,
|
|
90
|
+
rotate: rotate ? (LOCK_ROTATE.test(`rotate(${rotate})`) ? 'rotated' : null) : undefined,
|
|
91
|
+
};
|
|
92
|
+
if (Object.values(declared).some((value) => value !== undefined)) {
|
|
93
|
+
for (const root of matchingRoots(rule.selectorText, doc)) {
|
|
94
|
+
const key = `${orientationContext.orientation.toLowerCase()}|${root === doc.body ? 'body' : 'html'}`;
|
|
95
|
+
if (!state.has(key)) state.set(key, {});
|
|
96
|
+
const entry = state.get(key);
|
|
97
|
+
for (const [property, kind] of Object.entries(declared)) {
|
|
98
|
+
if (kind === undefined) continue;
|
|
99
|
+
entry[property] = kind ? { kind, active, selector: rule.selectorText, ...orientationContext } : null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
54
102
|
}
|
|
55
|
-
} else if (rule.cssRules) {
|
|
56
|
-
scanRules(rule.cssRules, insideOrientation, findings);
|
|
57
103
|
}
|
|
104
|
+
// Layers and other grouping rules retain the surrounding media state.
|
|
105
|
+
// Unknown conditional groups cannot establish an active restriction.
|
|
106
|
+
if (rule.cssRules) scanRules(rule.cssRules, orientationContext,
|
|
107
|
+
active && rule.conditionText === undefined, state, doc);
|
|
58
108
|
}
|
|
59
109
|
}
|
|
60
110
|
|
|
@@ -66,24 +116,43 @@ export default {
|
|
|
66
116
|
help: 'Content must work in both portrait and landscape',
|
|
67
117
|
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/orientation.html',
|
|
68
118
|
selector: 'html',
|
|
69
|
-
|
|
70
|
-
// the lock hides the root exactly when it is in force, so a visibility
|
|
71
|
-
// filter here would silence the rule in the only state that matters.
|
|
72
|
-
visibleOnly: false,
|
|
119
|
+
visibleOnly: false, // a restriction can hide the root itself
|
|
73
120
|
evaluate(element) {
|
|
74
|
-
const
|
|
75
|
-
|
|
121
|
+
const doc = element.ownerDocument;
|
|
122
|
+
const win = doc.defaultView;
|
|
123
|
+
const state = new Map();
|
|
124
|
+
for (const sheet of doc.styleSheets) {
|
|
125
|
+
if (sheet.disabled) continue;
|
|
126
|
+
const media = sheet.media?.mediaText;
|
|
127
|
+
if (media && !win.matchMedia(media).matches) continue;
|
|
76
128
|
let rules;
|
|
77
129
|
try { rules = sheet.cssRules; } catch { continue; } // cross-origin: unreadable, skip
|
|
78
|
-
scanRules(rules, null,
|
|
130
|
+
scanRules(rules, null, true, state, doc);
|
|
131
|
+
}
|
|
132
|
+
const findings = [];
|
|
133
|
+
for (const [key, entry] of state) {
|
|
134
|
+
const root = key.endsWith('|body') ? doc.body : doc.documentElement;
|
|
135
|
+
for (const finding of Object.values(entry)) {
|
|
136
|
+
if (!finding) continue;
|
|
137
|
+
if (finding.active) {
|
|
138
|
+
// The block applies right now, so the computed style is the truth:
|
|
139
|
+
// an overridden or cancelled declaration locks nothing.
|
|
140
|
+
const computed = win.getComputedStyle(root);
|
|
141
|
+
const effective = finding.kind === 'hidden'
|
|
142
|
+
? computed.display === 'none' || computed.visibility === 'hidden'
|
|
143
|
+
: quarterTurn(computed, win);
|
|
144
|
+
if (effective === false) continue;
|
|
145
|
+
}
|
|
146
|
+
findings.push(finding);
|
|
147
|
+
}
|
|
79
148
|
}
|
|
80
149
|
if (!findings.length) return { status: 'pass' };
|
|
81
|
-
const
|
|
82
|
-
const verb =
|
|
150
|
+
const finding = findings.find((candidate) => candidate.active) ?? findings[0];
|
|
151
|
+
const verb = finding.kind === 'hidden' ? 'hides the page' : 'rotates the page to force the other orientation';
|
|
83
152
|
return {
|
|
84
153
|
status: 'fail',
|
|
85
|
-
message: `A stylesheet rule (${
|
|
86
|
-
fix: 'Let the layout adapt to both orientations instead of hiding or rotating the page. If a single orientation is genuinely essential (rare), document why.',
|
|
154
|
+
message: `A stylesheet rule (${finding.selector} under @media ${finding.condition}) ${verb} when the device is in ${finding.orientation}${finding.active ? ', and it is in effect now' : ''}: the content is locked to a single display orientation. 1.3.4 allows that only where one orientation is essential, which a stylesheet cannot show; this finding assumes it is not.`,
|
|
155
|
+
fix: 'Let the layout adapt to both orientations instead of hiding or rotating the page. If a single orientation is genuinely essential (rare), document why and dismiss this finding.',
|
|
87
156
|
};
|
|
88
157
|
},
|
|
89
158
|
};
|
|
@@ -97,19 +97,32 @@ export default {
|
|
|
97
97
|
.split(/\s+/)
|
|
98
98
|
.filter(Boolean);
|
|
99
99
|
const invalid = tokens.filter((token) => !isKnown(token));
|
|
100
|
-
|
|
100
|
+
const fault = invalid.length ? null : (tokens.length ? grammarFault(tokens) : null);
|
|
101
|
+
if (!invalid.length && !fault) return { status: 'pass' };
|
|
102
|
+
// Asserted the way ACT rule 73f2c2 asserts it, under its assumption that
|
|
103
|
+
// an autocomplete attribute sits on a field collecting information about
|
|
104
|
+
// the user, which is what the attribute is for. A search box is the one
|
|
105
|
+
// common field that carries the attribute and collects nothing about the
|
|
106
|
+
// user, so it is asked about rather than failed. The message names the
|
|
107
|
+
// assumption so a reviewer can dismiss the finding on a field 1.3.5 does
|
|
108
|
+
// not cover. https://www.w3.org/WAI/standards-guidelines/act/rules/73f2c2/
|
|
109
|
+
const problem = invalid.length
|
|
110
|
+
? `autocomplete contains unknown token(s): ${invalid.join(', ')}, so browsers and assistive tools cannot identify this field's purpose from it`
|
|
111
|
+
: `autocomplete="${element.getAttribute('autocomplete').trim()}" is not a valid autofill value: ${fault}. Browsers discard the whole value, so no purpose is exposed`;
|
|
112
|
+
const fix = invalid.length
|
|
113
|
+
? 'Use tokens from the HTML input-purposes list, e.g. autocomplete="email" or autocomplete="given-name".'
|
|
114
|
+
: 'Use one field name from the HTML autofill list, optionally preceded by section-*, then shipping or billing, and (for tel, email and impp only) a contact type, e.g. autocomplete="shipping tel" or autocomplete="home email".';
|
|
115
|
+
if (element.type === 'search') {
|
|
101
116
|
return {
|
|
102
|
-
status: '
|
|
103
|
-
message:
|
|
104
|
-
fix
|
|
117
|
+
status: 'incomplete',
|
|
118
|
+
message: `${problem}. 1.3.5 covers fields that collect information about the user, which a search box usually does not; if this one does, correct the value.`,
|
|
119
|
+
fix,
|
|
105
120
|
};
|
|
106
121
|
}
|
|
107
|
-
const fault = tokens.length ? grammarFault(tokens) : null;
|
|
108
|
-
if (!fault) return { status: 'pass' };
|
|
109
122
|
return {
|
|
110
123
|
status: 'fail',
|
|
111
|
-
message:
|
|
112
|
-
fix
|
|
124
|
+
message: `${problem}. 1.3.5 covers fields that collect information about the user, which is what an autocomplete attribute is for; if this field collects nothing about the user, the criterion does not apply and this finding can be dismissed.`,
|
|
125
|
+
fix,
|
|
113
126
|
};
|
|
114
127
|
},
|
|
115
128
|
};
|
|
@@ -176,7 +176,14 @@ export default {
|
|
|
176
176
|
if (sidePaints(s, 'Bottom')) return { status: 'pass' };
|
|
177
177
|
if (boxShadowPaints(s.boxShadow)) return { status: 'pass' };
|
|
178
178
|
const ownBackground = parseColor(s.backgroundColor);
|
|
179
|
-
if (ownBackground && ownBackground.a > 0)
|
|
179
|
+
if (ownBackground && ownBackground.a > 0) {
|
|
180
|
+
const surroundings = effectiveBackground(parent);
|
|
181
|
+
if (!surroundings) unclear = true;
|
|
182
|
+
else {
|
|
183
|
+
const shown = composite(ownBackground, surroundings);
|
|
184
|
+
if (['r', 'g', 'b'].some((channel) => Math.round(shown[channel]) !== Math.round(surroundings[channel]))) return { status: 'pass' };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
180
187
|
const painted = backgroundImagePaints(s, el.getBoundingClientRect()); // gradient/image underline technique
|
|
181
188
|
if (painted === 'yes') return { status: 'pass' };
|
|
182
189
|
if (painted === 'unclear') unclear = true;
|
|
@@ -205,8 +212,17 @@ export default {
|
|
|
205
212
|
const sizeStep = Math.max(...textBearers.map((s) => Math.abs((parseFloat(s.fontSize) || parentSize) - parentSize)));
|
|
206
213
|
const sizeCue = sizeStep >= 2 && sizeStep / parentSize >= 0.1;
|
|
207
214
|
|
|
208
|
-
|
|
209
|
-
|
|
215
|
+
// A wrapper's colour need not paint any glyphs: inline spans commonly
|
|
216
|
+
// override it. Compare actual own-text bearers, and review multicolour
|
|
217
|
+
// text rather than attributing an unpainted wrapper colour to it.
|
|
218
|
+
const glyphColours = cueBearers.filter(({ el }) => ownText(el))
|
|
219
|
+
.map(({ s }) => s.webkitTextFillColor || s.color);
|
|
220
|
+
const distinctColours = [...new Set(glyphColours)];
|
|
221
|
+
if (distinctColours.length > 1) {
|
|
222
|
+
return { status: 'incomplete', message: 'This link presents text in several colours without a detected non-colour cue. Check that each part is distinguishable from the surrounding text.' };
|
|
223
|
+
}
|
|
224
|
+
let linkColor = parseColor(distinctColours[0] || style.webkitTextFillColor || style.color);
|
|
225
|
+
let textColor = parseColor(parentStyle.webkitTextFillColor || parentStyle.color);
|
|
210
226
|
if (!linkColor || !textColor) return { status: 'pass' };
|
|
211
227
|
// 2026-08-25 overnight audit: a translucent colour is presented
|
|
212
228
|
// composited over its backdrop (rgba(0,0,0,.3) in black prose on white
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// WCAG SC 1.4.10 Reflow (Level AA)
|
|
2
2
|
// The full test renders at 320 CSS px; an audit runs at whatever viewport
|
|
3
3
|
// the page is in. What IS knowable here: content that already forces
|
|
4
|
-
//
|
|
5
|
-
// 320px. Abstain-over-assert: this flags, it never fails — tables, maps,
|
|
4
|
+
// horizontal scrolling at the current width is a candidate to check at
|
|
5
|
+
// 320px; responsive breakpoints can change the layout. Abstain-over-assert: this flags, it never fails — tables, maps,
|
|
6
6
|
// and images are exempt content the geometry can't distinguish.
|
|
7
7
|
export default {
|
|
8
8
|
id: 'reflow',
|
|
@@ -20,7 +20,7 @@ export default {
|
|
|
20
20
|
if (overflow <= 1) return { status: 'pass' };
|
|
21
21
|
return {
|
|
22
22
|
status: 'incomplete',
|
|
23
|
-
message: `The page already scrolls horizontally by ${Math.round(overflow)}px at the current viewport
|
|
23
|
+
message: `The page already scrolls horizontally by ${Math.round(overflow)}px at the current viewport. Verify content reflows at 320 CSS px width (data tables, maps and images are exempt).`,
|
|
24
24
|
};
|
|
25
25
|
},
|
|
26
26
|
};
|
|
@@ -114,7 +114,15 @@ export default {
|
|
|
114
114
|
const wrapperHasBorder = ['Top', 'Right', 'Bottom', 'Left'].some((side) =>
|
|
115
115
|
parseFloat(wrapperStyle[`border${side}Width`]) > 0 && wrapperStyle[`border${side}Style`] !== 'none'
|
|
116
116
|
&& (parseColor(wrapperStyle[`border${side}Color`])?.a ?? 0) > 0);
|
|
117
|
-
if (wrapperHasBorder)
|
|
117
|
+
if (wrapperHasBorder) {
|
|
118
|
+
const around = effectiveBackground(wrapper.parentElement ?? wrapper);
|
|
119
|
+
const contrastingBorder = around && ['Top', 'Right', 'Bottom', 'Left'].some((side) => {
|
|
120
|
+
if (!(parseFloat(wrapperStyle[`border${side}Width`]) > 0) || wrapperStyle[`border${side}Style`] === 'none') return false;
|
|
121
|
+
const colour = parseColor(wrapperStyle[`border${side}Color`]);
|
|
122
|
+
return colour && contrastRatio(composite(colour, around), around) >= 3;
|
|
123
|
+
});
|
|
124
|
+
if (contrastingBorder) return { status: 'pass' };
|
|
125
|
+
}
|
|
118
126
|
const wrapperFill = parseColor(wrapperStyle.backgroundColor);
|
|
119
127
|
if (wrapperFill && wrapperFill.a >= 1) {
|
|
120
128
|
const around = effectiveBackground(wrapper.parentElement ?? wrapper);
|
|
@@ -44,7 +44,11 @@ export default {
|
|
|
44
44
|
// ruinous on 200k-node single-page specs, whose plain flowing text is
|
|
45
45
|
// also the least likely content on the web to clip. Past a scale where
|
|
46
46
|
// the probe's cost dwarfs its yield, abstain wholesale.
|
|
47
|
-
if (elements.length > 20_000)
|
|
47
|
+
if (elements.length > 20_000) {
|
|
48
|
+
return elements.map((_, index) => index === 0
|
|
49
|
+
? { status: 'incomplete', message: `The text-spacing probe was not run because this page has ${elements.length} candidate elements, exceeding its 20,000-element budget. Check the page with the WCAG spacing overrides applied.` }
|
|
50
|
+
: { status: 'skipped' });
|
|
51
|
+
}
|
|
48
52
|
// Only containers that hide overflow and hold text can lose content.
|
|
49
53
|
// Collapsed containers (accordion panels at height 0) show nothing —
|
|
50
54
|
// there is no visible text for the override to clip.
|
|
@@ -64,13 +68,22 @@ export default {
|
|
|
64
68
|
probe.dataset.pourAudit = 'probe';
|
|
65
69
|
probe.textContent = override(doc.documentElement.scrollHeight);
|
|
66
70
|
doc.documentElement.append(probe);
|
|
71
|
+
// Document selectors cannot cross a shadow boundary. Each candidate
|
|
72
|
+
// root receives the same override so the measured text is really probed.
|
|
73
|
+
const probes = [probe];
|
|
74
|
+
for (const root of new Set(candidates.filter(Boolean).map((element) => element.getRootNode()))) {
|
|
75
|
+
if (root === doc || !root.host) continue;
|
|
76
|
+
const scoped = probe.cloneNode(true);
|
|
77
|
+
root.append(scoped);
|
|
78
|
+
probes.push(scoped);
|
|
79
|
+
}
|
|
67
80
|
let after;
|
|
68
81
|
try {
|
|
69
82
|
// Force the reflow the measurements depend on.
|
|
70
83
|
void doc.documentElement.offsetHeight;
|
|
71
84
|
after = candidates.map((element) => element && clipped(element));
|
|
72
85
|
} finally {
|
|
73
|
-
|
|
86
|
+
for (const applied of probes) applied.remove();
|
|
74
87
|
// The audit must never move the page: put the scroll back exactly
|
|
75
88
|
// where the user had it, after the removal's own reflow settles.
|
|
76
89
|
void doc.documentElement.offsetHeight;
|
|
@@ -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.',
|