pour-engine 1.40.0 → 1.42.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 (59) hide show
  1. package/config/project.config.js +1 -1
  2. package/engine/AUDIT.html +24 -690
  3. package/engine/COVERAGE.md +17 -15
  4. package/engine/README.md +10 -5
  5. package/engine/index.js +34 -8
  6. package/engine/lib/accessible-name.js +69 -63
  7. package/engine/lib/contrast.js +111 -10
  8. package/engine/lib/dom.js +78 -18
  9. package/engine/lib/roles.js +5 -2
  10. package/engine/rules/best-practice/fieldset-legend.js +11 -5
  11. package/engine/rules/best-practice/region.js +1 -1
  12. package/engine/rules/index.js +29 -0
  13. package/engine/rules/wcag/1.1.1-area-alt.js +6 -1
  14. package/engine/rules/wcag/1.1.1-image-alt.js +2 -1
  15. package/engine/rules/wcag/1.1.1-svg-img-alt.js +14 -17
  16. package/engine/rules/wcag/1.2.3-video-audio-description.js +42 -0
  17. package/engine/rules/wcag/1.3.1-aria-required-children.js +20 -8
  18. package/engine/rules/wcag/1.3.1-aria-required-parent.js +7 -10
  19. package/engine/rules/wcag/1.3.1-form-label.js +5 -29
  20. package/engine/rules/wcag/1.3.1-list-structure.js +4 -3
  21. package/engine/rules/wcag/1.3.1-table-headers.js +23 -2
  22. package/engine/rules/wcag/1.3.4-orientation.js +117 -48
  23. package/engine/rules/wcag/1.3.5-autocomplete-valid.js +21 -8
  24. package/engine/rules/wcag/1.3.6-region-purpose.js +35 -0
  25. package/engine/rules/wcag/1.4.1-link-in-text-block.js +19 -3
  26. package/engine/rules/wcag/1.4.10-reflow.js +3 -3
  27. package/engine/rules/wcag/1.4.11-non-text-contrast.js +9 -1
  28. package/engine/rules/wcag/1.4.12-text-spacing.js +15 -2
  29. package/engine/rules/wcag/1.4.3-color-contrast.js +79 -7
  30. package/engine/rules/wcag/1.4.3-control-contrast.js +35 -6
  31. package/engine/rules/wcag/1.4.4-meta-viewport.js +7 -2
  32. package/engine/rules/wcag/1.4.8-text-justified.js +49 -0
  33. package/engine/rules/wcag/2.1.1-scrollable-region-focusable.js +5 -5
  34. package/engine/rules/wcag/2.2.1-meta-refresh.js +10 -4
  35. package/engine/rules/wcag/2.2.2-pause-stop-hide.js +5 -5
  36. package/engine/rules/wcag/2.2.4-meta-refresh-no-exceptions.js +50 -0
  37. package/engine/rules/wcag/2.4.1-bypass-blocks.js +2 -2
  38. package/engine/rules/wcag/2.4.10-section-heading.js +86 -0
  39. package/engine/rules/wcag/2.4.11-focus-not-obscured.js +134 -78
  40. package/engine/rules/wcag/2.4.12-focus-not-obscured-enhanced.js +17 -0
  41. package/engine/rules/wcag/2.4.2-document-title.js +7 -8
  42. package/engine/rules/wcag/2.4.4-link-text-generic.js +6 -5
  43. package/engine/rules/wcag/2.4.6-heading-label-placeholder.js +69 -0
  44. package/engine/rules/wcag/2.4.9-link-text-generic-only.js +8 -23
  45. package/engine/rules/wcag/2.5.1-pointer-gesture-alternative.js +98 -0
  46. package/engine/rules/wcag/2.5.8-target-size.js +161 -45
  47. package/engine/rules/wcag/3.3.4-financial-form-confirmation.js +32 -0
  48. package/engine/rules/wcag/3.3.8-auth-field-obstruction.js +10 -13
  49. package/engine/rules/wcag/3.3.9-captcha-alternative.js +35 -0
  50. package/engine/rules/wcag/4.1.2-aria-attr-valid.js +4 -0
  51. package/engine/rules/wcag/4.1.2-aria-field-name.js +6 -5
  52. package/engine/rules/wcag/4.1.2-aria-valid-refs.js +8 -3
  53. package/engine/rules/wcag/4.1.2-button-name.js +1 -1
  54. package/engine/rules/wcag/4.1.2-composite-widget-name.js +1 -1
  55. package/engine/rules/wcag/4.1.2-invoker-target.js +2 -1
  56. package/engine/rules/wcag/4.1.2-label-for-valid.js +2 -2
  57. package/engine/rules/wcag/4.1.2-valid-role.js +6 -1
  58. package/engine/wcag22.js +19 -19
  59. 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 PROVE: a rule inside an @media (orientation: …)
7
- // block that hides or counter-rotates the page root is restricting the
8
- // whole page to the other orientation — the "please rotate your device"
9
- // wall (body { display: none }) and the forced-landscape hack
10
- // (body { transform: rotate(90deg) }). The rotate hack even disproves the
11
- // "essential" escape by itself: content that renders rotated demonstrably
12
- // works in both orientations.
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
- // Deliberately NOT flagged: orientation media queries that restyle or hide
15
- // individual regions — that is ordinary responsive design, not a lock.
16
- // Script-driven locks (screen.orientation.lock()) and cross-origin
17
- // stylesheets (unreadable under CORS) are invisible to a static pass; the
18
- // rule asserts only what it can see and stays silent otherwise.
19
- // The root ELEMENT only, optionally qualified by pseudo-classes. A pseudo-
20
- // element (body::before, html::backdrop) is one generated box, and hiding
21
- // it in portrait is the region restyling this rule deliberately leaves
22
- // alone; matching it asserted "hides the page" over a decorative band
23
- // (2026-08-25 overnight audit). Pseudo-classes are kept, but only assert
24
- // when the live root actually matches them (see rootSelector).
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 rootSelector(selectorText, doc) {
29
- return (selectorText ?? '').split(',').some((part) => {
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) return false;
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 { return !!root && root.matches(part.trim()); } catch { return false; }
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
- function scanRules(rules, insideOrientation, findings, doc) {
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
- const condition = rule.conditionText ?? rule.media?.mediaText;
43
- if (condition !== undefined && rule.cssRules) {
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
- scanRules(rule.cssRules, orientation ? { condition, orientation } : insideOrientation, findings, doc);
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 (insideOrientation && rule.style && rootSelector(rule.selectorText, doc)) {
84
+ if (orientationContext && rule.style) {
49
85
  const { display, visibility, transform, rotate } = rule.style;
50
- if (display === 'none' || visibility === 'hidden') {
51
- findings.push({ kind: 'hidden', selector: rule.selectorText, ...insideOrientation });
52
- } else if (LOCK_ROTATE.test(transform ?? '') || LOCK_ROTATE.test(rotate ?? '')) {
53
- findings.push({ kind: 'rotated', selector: rule.selectorText, ...insideOrientation });
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
- // The evidence is in the stylesheet, not in the root's own rendering — and
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 findings = [];
75
- for (const sheet of element.ownerDocument.styleSheets) {
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, findings, element.ownerDocument);
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 f = findings[0];
82
- const verb = f.kind === 'hidden' ? 'hides the page' : 'rotates the page to force the other orientation';
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 (${f.selector} under @media ${f.condition}) ${verb} when the device is in ${f.orientation} — the content is locked to a single display orientation.`,
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
- if (invalid.length) {
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: 'fail',
103
- message: `autocomplete contains unknown token(s): ${invalid.join(', ')} — browsers and assistive tools can't identify this field's purpose.`,
104
- fix: 'Use tokens from the HTML input-purposes list, e.g. autocomplete="email" or autocomplete="given-name".',
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: `autocomplete="${element.getAttribute('autocomplete').trim()}" is not a valid autofill value: ${fault}. Browsers discard the whole value, so no purpose is exposed.`,
112
- fix: '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".',
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
  };
@@ -0,0 +1,35 @@
1
+ // WCAG SC 1.3.6 Identify Purpose (Level AAA)
2
+ // "The purpose of user interface components, icons, and regions can be
3
+ // programmatically determined." Its sufficient technique for regions is
4
+ // ARIA11, landmarks; the purpose of components and icons has no DOM-visible
5
+ // technique at all (personalization semantics never shipped), so that half
6
+ // of the criterion stays with a person. What a page can show is the
7
+ // regions half: a document with no landmark whatsoever has identified the
8
+ // purpose of none of its regions. That is one question per page, asked
9
+ // once, and it never asserts, because the criterion lists other routes
10
+ // (microdata, other programmatic means) the DOM does not reveal.
11
+ //
12
+ // Pages that HAVE landmarks pass here; stray content outside them is the
13
+ // best-practice region rule's finding, which stays opt-in because A and AA
14
+ // never require landmarks. This rule is the AAA criterion's own question.
15
+ import { LANDMARK } from '../best-practice/region.js';
16
+
17
+ export default {
18
+ id: 'region-purpose',
19
+ name: 'Regions with a purpose',
20
+ impact: 'moderate',
21
+ tags: ['wcag21aaa', 'wcag136'],
22
+ help: 'The purpose of page regions should be programmatically determinable (AAA)',
23
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/identify-purpose.html',
24
+ selector: 'html',
25
+ visibleOnly: false,
26
+ evaluate(element) {
27
+ const doc = element.ownerDocument;
28
+ if (doc.querySelector(LANDMARK)) return { status: 'pass' };
29
+ return {
30
+ status: 'incomplete',
31
+ message: 'This page declares no landmark regions (no main, nav, header, footer, aside, search or labelled region), so the purpose of its regions is not exposed to assistive technology or personalisation tools. 1.3.6 (AAA) lists landmarks as the way to identify regions; the purpose of components and icons still needs a person to check.',
32
+ fix: 'Wrap the page\'s regions in <main>, <nav>, <header>, <footer>, <aside> and labelled <section> elements (or the matching ARIA roles).',
33
+ };
34
+ },
35
+ };
@@ -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) return { status: 'pass' };
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
- let linkColor = parseColor(style.color);
209
- let textColor = parseColor(parentStyle.color);
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
- // two-dimensional scrolling at the current width will only be worse at
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 — at the 320px reflow breakpoint it will be worse. Verify content reflows at 320 CSS px width (data tables, maps and images are exempt).`,
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) return { status: 'pass' };
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) return elements.map(() => ({ status: 'pass' }));
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
- probe.remove();
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.',