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.
Files changed (46) hide show
  1. package/config/project.config.js +1 -1
  2. package/engine/AUDIT.html +24 -690
  3. package/engine/COVERAGE.md +4 -2
  4. package/engine/README.md +9 -4
  5. package/engine/index.js +34 -8
  6. package/engine/lib/accessible-name.js +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/wcag/1.1.1-area-alt.js +6 -1
  12. package/engine/rules/wcag/1.1.1-image-alt.js +2 -1
  13. package/engine/rules/wcag/1.1.1-svg-img-alt.js +14 -17
  14. package/engine/rules/wcag/1.3.1-aria-required-children.js +20 -8
  15. package/engine/rules/wcag/1.3.1-aria-required-parent.js +7 -10
  16. package/engine/rules/wcag/1.3.1-form-label.js +5 -29
  17. package/engine/rules/wcag/1.3.1-list-structure.js +4 -3
  18. package/engine/rules/wcag/1.3.1-table-headers.js +23 -2
  19. package/engine/rules/wcag/1.3.4-orientation.js +117 -48
  20. package/engine/rules/wcag/1.3.5-autocomplete-valid.js +21 -8
  21. package/engine/rules/wcag/1.4.1-link-in-text-block.js +19 -3
  22. package/engine/rules/wcag/1.4.10-reflow.js +3 -3
  23. package/engine/rules/wcag/1.4.11-non-text-contrast.js +9 -1
  24. package/engine/rules/wcag/1.4.12-text-spacing.js +15 -2
  25. package/engine/rules/wcag/1.4.3-color-contrast.js +79 -7
  26. package/engine/rules/wcag/1.4.3-control-contrast.js +35 -6
  27. package/engine/rules/wcag/1.4.4-meta-viewport.js +7 -2
  28. package/engine/rules/wcag/2.1.1-scrollable-region-focusable.js +5 -5
  29. package/engine/rules/wcag/2.2.1-meta-refresh.js +8 -2
  30. package/engine/rules/wcag/2.2.2-pause-stop-hide.js +5 -5
  31. package/engine/rules/wcag/2.4.1-bypass-blocks.js +2 -2
  32. package/engine/rules/wcag/2.4.2-document-title.js +7 -8
  33. package/engine/rules/wcag/2.4.4-link-text-generic.js +6 -5
  34. package/engine/rules/wcag/2.4.9-link-text-generic-only.js +8 -23
  35. package/engine/rules/wcag/2.5.8-target-size.js +161 -45
  36. package/engine/rules/wcag/3.3.8-auth-field-obstruction.js +6 -12
  37. package/engine/rules/wcag/4.1.2-aria-attr-valid.js +4 -0
  38. package/engine/rules/wcag/4.1.2-aria-field-name.js +6 -5
  39. package/engine/rules/wcag/4.1.2-aria-valid-refs.js +8 -3
  40. package/engine/rules/wcag/4.1.2-button-name.js +1 -1
  41. package/engine/rules/wcag/4.1.2-composite-widget-name.js +1 -1
  42. package/engine/rules/wcag/4.1.2-invoker-target.js +2 -1
  43. package/engine/rules/wcag/4.1.2-label-for-valid.js +2 -2
  44. package/engine/rules/wcag/4.1.2-valid-role.js +6 -1
  45. package/engine/wcag22.js +7 -7
  46. package/package.json +1 -1
@@ -3,8 +3,10 @@
3
3
  Generated by `node scripts/wcag-coverage.js` — do not edit by hand.
4
4
 
5
5
  All 86 success criteria of WCAG 2.2, and how the native engine covers them.
6
- "Partially automated" means our rules catch common failures but cannot prove conformance;
7
- "Manual review" criteria need a human (most of the spec — true for every automated tool).
6
+ "Partially automated" means rules check some aspects or identify candidates for human review.
7
+ Some rules return review findings only; a listed rule does not promise an automatic violation verdict.
8
+ "Manual review" means no active rule currently assists with that criterion. Human review is also
9
+ needed for partially automated criteria; no rule proves conformance.
8
10
  No criterion is listed as fully "Automated": every one of them ends in a question about
9
11
  meaning that only a person can settle, even where a rule decides most real pages.
10
12
 
package/engine/README.md CHANGED
@@ -7,8 +7,9 @@ the product's only engine.
7
7
  **WCAG only, by design.** pour engine tracks the WCAG spec exclusively — no
8
8
  Section 508, EN 301 549, or other standards. The full WCAG 2.2 catalog (all
9
9
  86 success criteria) lives in `wcag22.js`, and `COVERAGE.md` (generated by
10
- `node scripts/wcag-coverage.js`) shows exactly which criteria are automated,
11
- partially automated, or need human review. `AUDIT.html` is the latest full
10
+ `node scripts/wcag-coverage.js`) shows which criteria have automated assistance
11
+ and which currently rely on the manual checklist. Assistance includes checks
12
+ that only nominate candidates for human review. `AUDIT.html` is the latest full
12
13
  spec-sweep audit of every rule against the spec — over-violation,
13
14
  under-violation, and what the engine cannot test. It is a self-contained
14
15
  page: open it straight from disk in any browser. Regenerated by running
@@ -103,12 +104,16 @@ just to static analysis.
103
104
  Rules alone can never "cover WCAG" — most criteria need human judgment.
104
105
  pour engine therefore reports on **every** in-scope criterion, one of two ways:
105
106
 
106
- 1. **Automated/partial** — the rules in `rules/`.
107
+ 1. **Automated assistance**: the rules in `rules/` either establish specific
108
+ failures or nominate review candidates. A rule may return only review
109
+ findings when the DOM cannot establish a violation.
107
110
  2. **Manual review** — every audit's results include `manualReview`: the
108
111
  in-scope criteria (filtered by the requested WCAG version and level)
109
112
  that automation can't judge, for a host to render however it wants.
110
113
 
111
- Nothing in the spec is silently skipped — that claim is the product.
114
+ The checklist accounts for the criteria; it does not mean every page state
115
+ or possible failure has been tested. A snapshot audit still needs human
116
+ assessment, including interaction states and any checks reported as skipped.
112
117
 
113
118
  ## Honest current state
114
119
 
package/engine/index.js CHANGED
@@ -4,7 +4,7 @@
4
4
  import config from '../config/project.config.js';
5
5
  import rules from './rules/index.js';
6
6
  import wcagCatalog from './wcag22.js';
7
- import { isVisible, isRendered, cssPath, htmlSnippet, ownText, collectRoots } from './lib/dom.js';
7
+ import { isVisible, isRendered, cssPath, htmlSnippet, ownText, collectRoots, resetDOMCaches, releaseDOMCaches } from './lib/dom.js';
8
8
  import { accessibleName } from './lib/accessible-name.js';
9
9
  import { resetAuditCaches } from './lib/contrast.js';
10
10
 
@@ -116,18 +116,23 @@ export async function run(context = document, options = {}, onProgress) {
116
116
  // Per-audit caches (resolved backgrounds, opacities) must start fresh —
117
117
  // the page may have changed since the last run.
118
118
  resetAuditCaches();
119
+ resetDOMCaches();
119
120
  // Every queryable root — the page plus all open shadow trees — collected
120
121
  // once so rules see web-component content without re-walking per rule.
121
122
  const roots = collectRoots(context.querySelectorAll ? context : document);
122
123
  // Universal-selector rules (aria attribute checks) share one full scan
123
124
  // instead of each re-enumerating every element on the page.
124
125
  let universalScan = null;
126
+ const query = (root, selector) => [
127
+ ...(root.nodeType === 1 && root.matches(selector) ? [root] : []),
128
+ ...root.querySelectorAll(selector),
129
+ ];
125
130
  const elementsFor = (selector) => {
126
131
  if (selector === '*') {
127
- universalScan ??= roots.flatMap((root) => [...root.querySelectorAll('*')]);
132
+ universalScan ??= roots.flatMap((root) => query(root, '*'));
128
133
  return universalScan;
129
134
  }
130
- return roots.flatMap((root) => [...root.querySelectorAll(selector)]);
135
+ return roots.flatMap((root) => query(root, selector));
131
136
  };
132
137
  let completed = 0;
133
138
  // Running totals so the UI can count issues up live as rules finish —
@@ -161,6 +166,16 @@ export async function run(context = document, options = {}, onProgress) {
161
166
  lastYield = performance.now();
162
167
  };
163
168
 
169
+ const checkAbort = () => {
170
+ if (options.signal?.aborted) throw new DOMException('Audit stopped', 'AbortError');
171
+ };
172
+ // Bulk rules use the same task budget and cancellation as per-node rules.
173
+ const helpers = { ...ruleHelpers, yieldToMain: async () => {
174
+ checkAbort();
175
+ await yieldToRenderer();
176
+ checkAbort();
177
+ } };
178
+ try {
164
179
  for (const rule of activeRules) {
165
180
  // Between rules is the abort granularity we have — the same boundary
166
181
  // the renderer yield uses.
@@ -186,7 +201,7 @@ export async function run(context = document, options = {}, onProgress) {
186
201
  if (rule.evaluateAll) {
187
202
  // Awaiting a plain array is a no-op, so sync evaluateAll rules are
188
203
  // untouched — async ones (the deferred-reference probe) resolve here.
189
- outcomes = await rule.evaluateAll(elements, ruleHelpers);
204
+ outcomes = await rule.evaluateAll(elements, helpers);
190
205
  } else {
191
206
  // Evaluate sequentially with periodic yields so ONE heavy rule on a
192
207
  // huge element set can't freeze the page for its whole duration —
@@ -204,7 +219,7 @@ export async function run(context = document, options = {}, onProgress) {
204
219
  running: true,
205
220
  }));
206
221
  }
207
- outcomes[i] = await rule.evaluate(elements[i], ruleHelpers);
222
+ outcomes[i] = await rule.evaluate(elements[i], helpers);
208
223
  }
209
224
  }
210
225
 
@@ -214,14 +229,19 @@ export async function run(context = document, options = {}, onProgress) {
214
229
  // large pages.
215
230
  const buckets = { fail: [], incomplete: [] };
216
231
  let passCount = 0;
217
- elements.forEach((element, i) => {
232
+ let skippedCount = 0;
233
+ for (let i = 0; i < elements.length; i++) {
234
+ if ((i & 127) === 0) await helpers.yieldToMain();
235
+ const element = elements[i];
218
236
  const status = outcomes[i].status;
219
237
  if (status === 'pass') passCount += 1;
238
+ else if (status === 'skipped') skippedCount += 1;
220
239
  else buckets[status]?.push(toResultNode(element, outcomes[i]));
221
- });
240
+ }
222
241
 
223
242
  const ruleResult = {
224
243
  id: rule.id,
244
+ ...(skippedCount ? { skippedCount } : {}),
225
245
  // Human display name ("Form field labels"); the id stays the
226
246
  // machine handle everywhere (selectors, exports, docs anchors).
227
247
  name: rule.name ?? rule.id,
@@ -241,7 +261,8 @@ export async function run(context = document, options = {}, onProgress) {
241
261
  if (buckets.fail.length) liveTypeCounts[rule.impact] = (liveTypeCounts[rule.impact] ?? 0) + 1;
242
262
  if (buckets.incomplete.length) liveTypeCounts.review += 1;
243
263
  const ms = Math.round((performance.now() - ruleStarted) * 10) / 10;
244
- results.ruleTimings.push({ rule: rule.id, ms, elements: elements.length });
264
+ results.ruleTimings.push({ rule: rule.id, ms, elements: elements.length, ...(skippedCount ? { skippedCount } : {}) });
265
+ if (skippedCount) results.skippedChecks = (results.skippedChecks ?? 0) + skippedCount;
245
266
  onProgress?.({
246
267
  done: ++completed,
247
268
  total: activeRules.length,
@@ -263,4 +284,9 @@ export async function run(context = document, options = {}, onProgress) {
263
284
 
264
285
  results.durationMs = Math.round(performance.now() - auditStarted);
265
286
  return results;
287
+ } finally {
288
+ releaseDOMCaches();
289
+ yieldChannel?.port1.close();
290
+ yieldChannel?.port2.close();
291
+ }
266
292
  }
@@ -2,8 +2,10 @@
2
2
  // the ARIA accname spec: aria-labelledby → aria-label → native labelling
3
3
  // (alt, <label>, text content, value) → title/placeholder fallbacks.
4
4
  //
5
- // Deliberately simpler than the full spec. Good enough for name-presence
6
- // rules; grow it as rules need more.
5
+ // This DOM implementation follows the recursive naming paths used by the
6
+ // rules. Browser accessibility trees remain the behavioral check for edge
7
+ // cases; text presence alone is not a full assessment of name quality.
8
+ import { flatTreeParent } from './dom.js';
7
9
 
8
10
  /** Input types whose `.value` is user-entered text rather than a state. */
9
11
  const TEXT_INPUT = new Set(['text', 'search', 'url', 'tel', 'email', 'password', 'number', 'date',
@@ -15,7 +17,7 @@ const TEXT_INPUT = new Set(['text', 'search', 'url', 'tel', 'email', 'password',
15
17
  const LABELABLE = new Set(['input', 'select', 'textarea', 'button', 'meter', 'output', 'progress']);
16
18
 
17
19
  export function accessibleName(element) {
18
- return computeName(element, false);
20
+ return computeName(element, false, false, new Set());
19
21
  }
20
22
 
21
23
  /** The name aria-labelledby contributes, resolved the way accname requires:
@@ -24,27 +26,48 @@ export function accessibleName(element) {
24
26
  * too, and reading textContent instead reports those elements as nameless.
25
27
  * Rules that only need "is it labelled?" call this directly. */
26
28
  export function labelledByName(element) {
29
+ return referencedName(element, new Set()) ?? '';
30
+ }
31
+
32
+ /** Native label text, excluding the control being named from recursion. */
33
+ export function nativeLabelName(element, label) {
34
+ return computeName(label, false, hiddenForName(label), new Set([element]));
35
+ }
36
+
37
+ // Accname 2A: only a reference whose root is itself hidden includes its
38
+ // hidden descendants. A visible reference does not resurrect hidden text.
39
+ function hiddenForName(element) {
40
+ for (let node = element; node; node = flatTreeParent(node)) {
41
+ if (node.getAttribute?.('aria-hidden') === 'true') return true;
42
+ const style = getComputedStyle(node);
43
+ if (style.display === 'none') return true;
44
+ }
45
+ const visibility = getComputedStyle(element).visibility;
46
+ return visibility === 'hidden' || visibility === 'collapse';
47
+ }
48
+
49
+ function referencedName(element, visited) {
27
50
  const refs = element.getAttribute?.('aria-labelledby');
28
- if (!refs) return '';
51
+ if (!refs) return null;
29
52
  const root = element.getRootNode();
30
- return refs
31
- .split(/\s+/)
32
- .filter(Boolean)
33
- .map((id) => {
34
- const target = root.getElementById?.(id);
35
- return target ? computeName(target, true) : '';
36
- })
37
- .join(' ')
38
- .replace(/\s+/g, ' ')
39
- .trim();
53
+ const targets = refs.split(/\s+/).filter(Boolean).map((id) => root.getElementById?.(id)).filter(Boolean);
54
+ if (!targets.length) return null;
55
+ return targets.map((target) => {
56
+ // Self-reference can contribute the element's aria-label or content,
57
+ // but does not follow aria-labelledby again (accname 2B).
58
+ const path = new Set(visited);
59
+ if (target === element) path.delete(element);
60
+ return computeName(target, true, hiddenForName(target), path);
61
+ }).join(' ').replace(/\s+/g, ' ').trim();
40
62
  }
41
63
 
42
- function computeName(element, inLabelledBy) {
43
- // accname step 2B: a referenced element's own aria-labelledby is not
44
- // followed a second time. That is also what stops <p id="a"
45
- // aria-labelledby="a"> recursing forever.
64
+ function computeName(element, inLabelledBy, includeHidden, visited) {
65
+ if (visited.has(element)) return '';
66
+ visited.add(element);
46
67
  if (!inLabelledBy) {
47
- const fromLabelledBy = labelledByName(element);
68
+ const fromLabelledBy = referencedName(element, visited);
69
+ // Browsers retain aria-label/content fallbacks when all referenced
70
+ // text is empty. Do not assert a missing name that is actually exposed.
48
71
  if (fromLabelledBy) return fromLabelledBy;
49
72
  }
50
73
 
@@ -64,7 +87,8 @@ function computeName(element, inLabelledBy) {
64
87
  // id="b"> from the label. Widened from the three field tags 2026-08-25
65
88
  // overnight audit: button-name was asserting a label-named button nameless.
66
89
  if (LABELABLE.has(tag) && element.labels?.length) {
67
- const text = [...element.labels].map((label) => label.textContent).join(' ').trim();
90
+ const text = [...element.labels].map((label) =>
91
+ computeName(label, inLabelledBy, hiddenForName(label), visited)).join(' ').trim();
68
92
  if (text) return text;
69
93
  }
70
94
 
@@ -98,19 +122,19 @@ function computeName(element, inLabelledBy) {
98
122
  // display:none/visibility:hidden subtrees does not name an element
99
123
  // (per the accname spec). The one exception is a subtree reached THROUGH
100
124
  // aria-labelledby, where accname ignores the hidden state.
101
- const fromContents = visibleContentText(element, inLabelledBy).replace(/\s+/g, ' ').trim();
125
+ const fromContents = visibleContentText(element, includeHidden, inLabelledBy, visited).replace(/\s+/g, ' ').trim();
102
126
  if (fromContents) return fromContents;
103
127
 
104
128
  return (element.getAttribute('title') ?? element.getAttribute('placeholder') ?? '').trim();
105
129
  }
106
130
 
107
- function visibleContentText(element, includeHidden) {
131
+ function visibleContentText(element, includeHidden, inLabelledBy, visited) {
108
132
  // A custom element with a shadow root renders its shadow content — name
109
133
  // from contents follows the FLAT tree (slots inside pull light DOM back).
110
134
  const nodes = element.shadowRoot ? element.shadowRoot.childNodes : element.childNodes;
111
- return generatedContent(element, '::before')
112
- + textFromNodes(nodes, includeHidden)
113
- + generatedContent(element, '::after');
135
+ return generatedContent(element, '::before', includeHidden)
136
+ + textFromNodes(nodes, includeHidden, inLabelledBy, visited)
137
+ + generatedContent(element, '::after', includeHidden);
114
138
  }
115
139
 
116
140
  /** CSS generated content contributes to name-from-contents per the accname
@@ -125,8 +149,13 @@ function visibleContentText(element, includeHidden) {
125
149
  * 4.1.2 failure against the recommended pattern. An EMPTY alternative marks
126
150
  * the generated content decorative and contributes nothing, which is how
127
151
  * the same syntax silences icon glyphs. */
128
- function generatedContent(element, pseudo) {
129
- const content = getComputedStyle(element, pseudo).content;
152
+ function generatedContent(element, pseudo, includeHidden) {
153
+ const style = getComputedStyle(element, pseudo);
154
+ // Generated nodes follow the same hidden-content exclusion as ordinary
155
+ // descendants. A hover label can exist in computed content while its
156
+ // pseudo-element is visibility:hidden and absent from the AX name.
157
+ if (!includeHidden && (style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse')) return '';
158
+ const content = style.content;
130
159
  if (!content || content === 'none' || content === 'normal') return '';
131
160
  const alt = content.match(/\/\s*"((?:[^"\\]|\\.)*)"\s*$/);
132
161
  if (alt) return alt[1].replace(/\\(.)/g, '$1');
@@ -137,7 +166,7 @@ function generatedContent(element, pseudo) {
137
166
  /** `includeHidden` is set while resolving an aria-labelledby target: accname
138
167
  * ignores the hidden state of referenced content, which is what makes the
139
168
  * visually-hidden label span (and the display:none one) work in browsers. */
140
- function textFromNodes(nodes, includeHidden) {
169
+ function textFromNodes(nodes, includeHidden, inLabelledBy, visited) {
141
170
  let text = '';
142
171
  for (const node of nodes) {
143
172
  if (node.nodeType === 3 /* TEXT_NODE */) {
@@ -152,50 +181,27 @@ function textFromNodes(nodes, includeHidden) {
152
181
  // with lazy-load <noscript> fallbacks leaking "<img …>" into names).
153
182
  if (tag === 'script' || tag === 'style' || tag === 'noscript' || tag === 'template') continue;
154
183
  if (!includeHidden) {
155
- if (node.getAttribute('aria-hidden') === 'true' || node.hasAttribute('hidden')) continue;
184
+ if (node.getAttribute('aria-hidden') === 'true') continue;
156
185
  const style = getComputedStyle(node);
157
- if (style.display === 'none' || style.visibility === 'hidden') continue;
186
+ if (style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse') continue;
158
187
  }
159
188
  // A slot renders its assigned light-DOM nodes (fallback children when
160
189
  // nothing is slotted) — a shadow <button><slot></slot></button> is named
161
190
  // by the text the page slots in.
162
191
  if (tag === 'slot') {
163
192
  const assigned = node.assignedNodes?.() ?? [];
164
- text += textFromNodes(assigned.length ? assigned : node.childNodes, includeHidden);
193
+ text += textFromNodes(assigned.length ? assigned : node.childNodes, includeHidden, inLabelledBy, visited);
165
194
  continue;
166
195
  }
167
- if (tag === 'img' || tag === 'area') {
168
- // HTML-AAM order for an image's own name: aria-label → alt → title.
169
- // A PRESENT-but-empty alt marks the image decorative and contributes
170
- // nothing — title must not resurrect it. Only when alt is ABSENT do
171
- // the fallbacks apply (a title-only <img> inside a link names the
172
- // link; Chromium exposes exactly this, measured on a live site where
173
- // the walk's old alt-or-nothing shortcut called a named link nameless).
174
- const imgAria = node.getAttribute('aria-label')?.trim();
175
- if (imgAria) { text += ` ${imgAria} `; continue; }
176
- const alt = node.getAttribute('alt');
177
- if (alt !== null) { text += ` ${alt} `; continue; }
178
- const imgTitle = node.getAttribute('title')?.trim();
179
- if (imgTitle) text += ` ${imgTitle} `;
180
- continue;
181
- }
182
- const ariaLabel = node.getAttribute('aria-label')?.trim();
183
- if (ariaLabel) {
184
- text += ` ${ariaLabel} `;
185
- continue;
186
- }
187
- // Flat-tree descent: shadow content renders in place of a host's
188
- // light children (archive.org-style nested web components).
189
- const fromSubtree = textFromNodes(node.shadowRoot ? node.shadowRoot.childNodes : node.childNodes, includeHidden);
190
- if (fromSubtree.trim()) {
191
- text += fromSubtree;
192
- continue;
193
- }
194
- // Accname's tooltip fallback applies per traversed element: a
195
- // text-less <div title="upvote"> inside a link names it "upvote"
196
- // (browsers and screen readers both honour this).
197
- const title = node.getAttribute('title')?.trim();
198
- if (title) text += ` ${title} `;
196
+ // Every descendant re-enters the naming algorithm, including its
197
+ // references, native labels and generated content (accname 2F).
198
+ // Empty alt remains decorative rather than falling through to title.
199
+ if ((tag === 'img' || tag === 'area') && node.getAttribute('alt') === ''
200
+ && !node.getAttribute('aria-label')?.trim() && !node.getAttribute('aria-labelledby')) continue;
201
+ const childName = computeName(node, inLabelledBy, includeHidden, visited);
202
+ text += tag === 'img' || tag === 'area' || node.hasAttribute('aria-label') || node.hasAttribute('aria-labelledby')
203
+ ? ` ${childName} ` : childName;
204
+
199
205
  }
200
206
  return text;
201
207
  }
@@ -132,6 +132,9 @@ let panelRectsCache = null;
132
132
  let pseudoCache = new WeakMap();
133
133
  let zeroClipCache = new WeakMap();
134
134
  let firstLineRulesCache = new WeakMap();
135
+ let chainEffectCache = new WeakMap();
136
+ let uncoveredEffectCache = new WeakMap();
137
+ let blendBackdropCache = new WeakMap();
135
138
  const HAS_IMAGE = Symbol('background-image in chain');
136
139
 
137
140
  export function resetAuditCaches() {
@@ -143,6 +146,100 @@ export function resetAuditCaches() {
143
146
  pseudoCache = new WeakMap();
144
147
  zeroClipCache = new WeakMap();
145
148
  firstLineRulesCache = new WeakMap();
149
+ chainEffectCache = new WeakMap();
150
+ uncoveredEffectCache = new WeakMap();
151
+ blendBackdropCache = new WeakMap();
152
+ }
153
+
154
+ const flatParentOf = (node) => node.assignedSlot ?? node.parentElement ?? node.getRootNode()?.host ?? null;
155
+
156
+ /** Every filter function except drop-shadow recolours the pixels it applies
157
+ * to (blur, grayscale, invert, url() and the rest). A drop shadow paints a
158
+ * copy of the element's silhouette BEHIND it and leaves its own pixels
159
+ * alone. Parsed at nesting depth zero so the rgba() inside a shadow is not
160
+ * read as a second function. */
161
+ function colourChangingFilter(filter) {
162
+ if (!filter || filter === 'none') return false;
163
+ let depth = 0;
164
+ let name = '';
165
+ for (const ch of filter) {
166
+ if (ch === '(') {
167
+ if (depth === 0 && name && name !== 'drop-shadow') return true;
168
+ depth += 1;
169
+ name = '';
170
+ } else if (ch === ')') {
171
+ depth -= 1;
172
+ } else if (depth === 0) {
173
+ name = /[a-z-]/i.test(ch) ? name + ch.toLowerCase() : '';
174
+ }
175
+ }
176
+ return false;
177
+ }
178
+
179
+ /** A colour-changing filter or a blend mode on the element or any flat-tree
180
+ * ancestor recolours the text no matter what is painted in between: a
181
+ * greyscale group is grey throughout, and a group blend blends the whole
182
+ * group, opaque cards included, against what lies behind it. */
183
+ function chainEffect(node) {
184
+ if (!node || node.nodeType !== 1) return false;
185
+ if (chainEffectCache.has(node)) return chainEffectCache.get(node);
186
+ const style = getComputedStyle(node);
187
+ const own = colourChangingFilter(style.filter) || (style.mixBlendMode && style.mixBlendMode !== 'normal');
188
+ const result = Boolean(own || chainEffect(flatParentOf(node)));
189
+ chainEffectCache.set(node, result);
190
+ return result;
191
+ }
192
+
193
+ /** A backdrop filter and a drop shadow only reach the text through paint
194
+ * that is not opaque: the frosted-glass header (backdrop-filter: blur on a
195
+ * translucent bar) shows the blurred page through everything up to the
196
+ * first opaque background, and a drop shadow on a transparent wrapper is a
197
+ * shadow of the glyphs themselves. Opaque paint at or below the effect
198
+ * covers it, so the text's backdrop is that paint and the effect is not an
199
+ * uncertainty. Measured 2026-09-12: 72 of 184 such reviews on nine sites
200
+ * had opaque paint between the text and the effect. */
201
+ function uncoveredEffect(node) {
202
+ if (!node || node.nodeType !== 1) return false;
203
+ if (uncoveredEffectCache.has(node)) return uncoveredEffectCache.get(node);
204
+ const style = getComputedStyle(node);
205
+ let result;
206
+ if ((parseColor(style.backgroundColor)?.a ?? 0) >= 1) result = false; // everything above is covered
207
+ else if ((style.backdropFilter && style.backdropFilter !== 'none') || (style.filter && style.filter !== 'none')) result = true;
208
+ else result = uncoveredEffect(flatParentOf(node));
209
+ uncoveredEffectCache.set(node, result);
210
+ return result;
211
+ }
212
+
213
+ /** Filters and blend modes change the presented glyph/background pair.
214
+ * WCAG judges that presented pair, not the unfiltered CSS declarations.
215
+ * Cached per node along the flat tree so ordinary text pays once per
216
+ * ancestor. */
217
+ export function hasPaintEffects(element) {
218
+ if (!element || element.nodeType !== 1) return false;
219
+ return chainEffect(element) || uncoveredEffect(element);
220
+ }
221
+
222
+ /** A blend cannot reach paint outside its containing isolated group.
223
+ * Stop at opaque paint inside that group; otherwise an intervening stacking
224
+ * context means the ordinary ancestor background is not a proven backdrop. */
225
+ export function isolatedBlendBackdrop(element) {
226
+ if (!element || element.nodeType !== 1) return false;
227
+ if (blendBackdropCache.has(element)) return blendBackdropCache.get(element);
228
+ const style = getComputedStyle(element);
229
+ const background = parseColor(style.backgroundColor);
230
+ let result = false;
231
+ if (!background || background.a < 1) {
232
+ const isolated = style.isolation === 'isolate'
233
+ || ['fixed', 'sticky'].includes(style.position) || style.zIndex !== 'auto'
234
+ || ['transform', 'perspective', 'filter', 'backdropFilter', 'clipPath', 'maskImage', 'webkitMaskImage']
235
+ .some((key) => style[key] && style[key] !== 'none')
236
+ || /(?:paint|layout|strict|content)/.test(style.contain)
237
+ || (style.willChange && style.willChange !== 'auto')
238
+ || parseFloat(style.opacity) < 1;
239
+ result = isolated || isolatedBlendBackdrop(element.assignedSlot ?? element.parentElement ?? element.getRootNode()?.host);
240
+ }
241
+ blendBackdropCache.set(element, result);
242
+ return result;
146
243
  }
147
244
 
148
245
  /**
@@ -1233,11 +1330,12 @@ export function sampleGridFor(extent) {
1233
1330
  return { width, height, reduced: extent.width / width > 4.5 || extent.height / height > 4.5 };
1234
1331
  }
1235
1332
 
1236
- export function imageLuminanceRange(url, overlays = [], grid = null) {
1333
+ export function imageLuminanceRange(url, overlays = [], grid = null, under = null) {
1237
1334
  const width = grid?.width ?? 32;
1238
1335
  const height = grid?.height ?? 32;
1239
1336
  const sizeKey = width === 32 && height === 32 ? '' : `|${width}x${height}`;
1240
- const cacheKey = `${url}${overlays.length ? `|${overlayKey(overlays)}` : ''}${sizeKey}`;
1337
+ const backdrop = under?.a >= 1 ? under : null;
1338
+ const cacheKey = `${url}${overlays.length ? `|${overlayKey(overlays)}` : ''}${sizeKey}${backdrop ? `|under:${overlayKey([backdrop])}` : ''}`;
1241
1339
  if (imageRangeCache.has(cacheKey)) return imageRangeCache.get(cacheKey);
1242
1340
  const promise = new Promise((resolve) => {
1243
1341
  const img = new Image();
@@ -1252,8 +1350,8 @@ export function imageLuminanceRange(url, overlays = [], grid = null) {
1252
1350
  const ctx = canvas.getContext('2d', { willReadFrequently: true });
1253
1351
  ctx.drawImage(img, 0, 0, width, height);
1254
1352
  const data = ctx.getImageData(0, 0, width, height).data;
1255
- let min = 1;
1256
- let max = 0;
1353
+ let min = Infinity;
1354
+ let max = -Infinity;
1257
1355
  // The colours at those extremes, kept for the same reason the
1258
1356
  // gradient range keeps them: translucent text blends with the actual
1259
1357
  // pixels, and a luminance cannot be un-gamma'd back into an rgb.
@@ -1266,19 +1364,22 @@ export function imageLuminanceRange(url, overlays = [], grid = null) {
1266
1364
  // painted beneath it — the sampled range alone then describes only
1267
1365
  // the ink, not the backdrop the eye meets. Callers widen the range
1268
1366
  // with the underlying paint (rangeWithBackdrop) or abstain.
1269
- if (data[i + 3] < 255) alphaSeen = true;
1270
- if (data[i + 3] < 128) continue; // mostly-transparent pixels reveal what's beneath — unknowable
1271
- opaquePixels += 1;
1367
+ if (data[i + 3] < 255 && !backdrop) alphaSeen = true;
1368
+ if (data[i + 3] > 0) opaquePixels += 1;
1369
+ if (data[i + 3] === 0 && !backdrop) continue;
1272
1370
  // Composite each pixel through anything painted between the image
1273
1371
  // and the text before measuring: a photo under a 75% black scrim is
1274
1372
  // seen as the blend, never as the photo.
1275
- const pixel = { r: data[i], g: data[i + 1], b: data[i + 2], a: 1 };
1276
- const shown = overlays.length ? applyOverlays(pixel, overlays) : pixel;
1373
+ const pixel = { r: data[i], g: data[i + 1], b: data[i + 2], a: data[i + 3] / 255 };
1374
+ // A nonzero alpha is real paint. Composite over a known backdrop
1375
+ // before sampling; without one, keep the conservative raw range.
1376
+ const painted = backdrop ? composite(pixel, backdrop) : { ...pixel, a: 1 };
1377
+ const shown = overlays.length ? applyOverlays(painted, overlays) : painted;
1277
1378
  const l = luminance(shown);
1278
1379
  if (l < min) { min = l; minColor = shown; }
1279
1380
  if (l > max) { max = l; maxColor = shown; }
1280
1381
  }
1281
- // Not one opaque pixel: a spacer, a cleared sprite, or a decorative
1382
+ // Not one nontransparent pixel: a spacer, a cleared sprite, or a decorative
1282
1383
  // layer left in place with nothing in it. It covers the background
1283
1384
  // without changing a single one of its pixels.
1284
1385
  if (!opaquePixels) {