pour-engine 1.46.0 → 1.46.2

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.
@@ -6,6 +6,6 @@
6
6
  export default {
7
7
  engine: {
8
8
  name: 'pour engine',
9
- version: '1.46.0',
9
+ version: '1.46.2',
10
10
  },
11
11
  };
@@ -1,4 +1,5 @@
1
1
  // WCAG contrast math (WCAG 2.x, relative luminance per sRGB).
2
+ import { containsNode } from './dom.js';
2
3
 
3
4
  // Computed colours do not all come back as `rgb()` any more. A colour the
4
5
  // author wrote in a modern syntax is returned in THAT syntax: Chrome and
@@ -145,6 +146,7 @@ let uncoveredEffectCache = new WeakMap();
145
146
  let blendBackdropCache = new WeakMap();
146
147
  let labelReferrersCache = new WeakMap();
147
148
  let stackingContextCache = new WeakMap();
149
+ let backgroundRulesCache = new WeakMap();
148
150
  const HAS_IMAGE = Symbol('background-image in chain');
149
151
 
150
152
  export function resetAuditCaches() {
@@ -161,6 +163,7 @@ export function resetAuditCaches() {
161
163
  blendBackdropCache = new WeakMap();
162
164
  labelReferrersCache = new WeakMap();
163
165
  stackingContextCache = new WeakMap();
166
+ backgroundRulesCache = new WeakMap();
164
167
  }
165
168
 
166
169
  /**
@@ -608,7 +611,7 @@ export function backgroundObscured(element) {
608
611
  if (start === -1) return 'unverifiable';
609
612
 
610
613
  for (const layer of stack.slice(start + 1)) {
611
- if (layer.contains(element)) {
614
+ if (containsNode(layer, element)) {
612
615
  const style = getComputedStyle(layer);
613
616
  if (style.backgroundImage !== 'none') return layer;
614
617
  const bg = parseColor(style.backgroundColor);
@@ -981,6 +984,14 @@ export function filmedContrastBounds(foreground, background, film) {
981
984
  };
982
985
  }
983
986
 
987
+ /** A layer's own opacity. 0 is a real value, not a missing one: consent
988
+ * dimmers commonly fade to opacity 0 and stay in the DOM once dismissed
989
+ * (CSS Color 4, the opacity property: 0 is fully transparent). */
990
+ const opacityOf = (style) => {
991
+ const opacity = parseFloat(style.opacity);
992
+ return Number.isNaN(opacity) ? 1 : opacity;
993
+ };
994
+
984
995
  /** A layer qualifies as a scrim over `element` when it is translucent paint
985
996
  * (or a backdrop-filter) covering most of the viewport and the whole
986
997
  * element — the shape of a modal/loading veil, not a badge or header.
@@ -991,10 +1002,10 @@ function scrimIn(layersAbove, element, win) {
991
1002
  const rect = element.getBoundingClientRect();
992
1003
  const found = [];
993
1004
  for (const layer of layersAbove) {
994
- if (layer.contains(element) || element.contains(layer)) continue;
1005
+ if (containsNode(layer, element) || containsNode(element, layer)) continue;
995
1006
  const style = getComputedStyle(layer);
996
1007
  const color = parseColor(style.backgroundColor);
997
- const alpha = (color ? color.a : 0) * (parseFloat(style.opacity) || 1);
1008
+ const alpha = (color ? color.a : 0) * opacityOf(style);
998
1009
  const hasBackdropFilter = style.backdropFilter && style.backdropFilter !== 'none';
999
1010
  // Fully opaque covers simply hide the text (nothing to judge through);
1000
1011
  // near-invisible tints don't meaningfully change presented contrast.
@@ -1027,7 +1038,7 @@ export function scrimPaint(layers) {
1027
1038
  if (style.backgroundImage !== 'none') return null;
1028
1039
  const color = parseColor(style.backgroundColor);
1029
1040
  if (!color) return null;
1030
- const alpha = color.a * (parseFloat(style.opacity) || 1);
1041
+ const alpha = color.a * opacityOf(style);
1031
1042
  if (alpha <= 0) continue;
1032
1043
  paints.push({ ...color, a: alpha });
1033
1044
  }
@@ -1090,7 +1101,7 @@ export function paintedBackdrop(element) {
1090
1101
  }
1091
1102
  const layers = [];
1092
1103
  for (const layer of stack.slice(start + 1)) {
1093
- while (missing.length && !missing[0].contains(layer)) layers.push(missing.shift());
1104
+ while (missing.length && !containsNode(missing[0], layer)) layers.push(missing.shift());
1094
1105
  layers.push(layer);
1095
1106
  }
1096
1107
  layers.push(...missing);
@@ -1179,7 +1190,7 @@ export function opaquePanelRects(doc) {
1179
1190
  // hazard that scrolling cannot escape.
1180
1191
  if (!panelRectsCache.veil && (style.position === 'fixed' || style.position === 'sticky')
1181
1192
  && style.visibility !== 'hidden') {
1182
- const alpha = (color ? color.a : 0) * (parseFloat(style.opacity) || 1);
1193
+ const alpha = (color ? color.a : 0) * opacityOf(style);
1183
1194
  const hasBackdropFilter = style.backdropFilter && style.backdropFilter !== 'none';
1184
1195
  const coversViewport = rect.width >= 0.9 * win.innerWidth && rect.height >= 0.9 * win.innerHeight;
1185
1196
  if (coversViewport && (hasBackdropFilter || (alpha >= 0.15 && alpha < 1))) {
@@ -1456,7 +1467,7 @@ export function beneathOpaqueAncestor(element, media) {
1456
1467
  if (inTopLayer(element) || inTopLayer(media)) return false;
1457
1468
  const box = element.getBoundingClientRect();
1458
1469
  for (let ancestor = element; ancestor; ancestor = ancestor.parentElement) {
1459
- if (ancestor.contains(media)) return false; // from here up the media is inside
1470
+ if (containsNode(ancestor, media)) return false; // from here up the media is inside
1460
1471
  if (!createsStackingContext(ancestor)) continue;
1461
1472
  const style = getComputedStyle(ancestor);
1462
1473
  const color = parseColor(style.backgroundColor);
@@ -1469,7 +1480,7 @@ export function beneathOpaqueAncestor(element, media) {
1469
1480
  if (!insideRoundedBox(box, ancestor.getBoundingClientRect(), style)) continue;
1470
1481
  // The lowest stacking context holding both.
1471
1482
  let shared = ancestor.parentElement;
1472
- while (shared && !(createsStackingContext(shared) && shared.contains(media))) shared = shared.parentElement;
1483
+ while (shared && !(createsStackingContext(shared) && containsNode(shared, media))) shared = shared.parentElement;
1473
1484
  if (!shared) return false;
1474
1485
  const over = paintRank(ancestor, shared);
1475
1486
  const under = paintRank(media, shared);
@@ -1479,7 +1490,7 @@ export function beneathOpaqueAncestor(element, media) {
1479
1490
  if (under.z !== over.z) return under.z < over.z;
1480
1491
  // Same layer and z-index: tree order, earlier paints first.
1481
1492
  return Boolean(under.node.compareDocumentPosition(over.node) & 4 /* FOLLOWING */)
1482
- && !under.node.contains(over.node);
1493
+ && !containsNode(under.node, over.node);
1483
1494
  }
1484
1495
  return false;
1485
1496
  }
@@ -1988,6 +1999,71 @@ function rootHasFirstLineRules(root) {
1988
1999
  return has;
1989
2000
  }
1990
2001
 
2002
+ /**
2003
+ * The stylesheet rules behind lazy-loaded backgrounds, per root per audit:
2004
+ * `gates` hold background images back until a loader marks the element
2005
+ * loaded (`background-image: none` on a selector that excludes a lazy-load
2006
+ * class, as in `.section:not(.lazyloaded) { background-image: none }`), and
2007
+ * `images` give an element a background image of its own. Rules under a
2008
+ * media or support condition count only while it holds; an unreadable
2009
+ * cross-origin sheet contributes nothing.
2010
+ */
2011
+ const LAZY_EXCLUSION = /:not\(\s*\.[\w-]*lazy[\w-]*\s*\)/i;
2012
+ const PAINTS_IMAGE = /url\(|gradient\(|image-set\(|var\(/i;
2013
+ function backgroundRules(root) {
2014
+ let found = backgroundRulesCache.get(root);
2015
+ if (found) return found;
2016
+ found = { gates: [], images: [] };
2017
+ const win = (root.ownerDocument ?? root).defaultView;
2018
+ const scan = (rules) => {
2019
+ for (const rule of rules) {
2020
+ if (rule.styleSheet) {
2021
+ if (rule.media?.mediaText && !win.matchMedia(rule.media.mediaText).matches) continue;
2022
+ try { scan(rule.styleSheet.cssRules); } catch { /* unreadable import */ }
2023
+ continue;
2024
+ }
2025
+ if (rule.media) {
2026
+ if (!win.matchMedia(rule.media.mediaText).matches) continue;
2027
+ } else if (win.CSSSupportsRule && rule instanceof win.CSSSupportsRule) {
2028
+ if (!win.CSS.supports(rule.conditionText)) continue;
2029
+ }
2030
+ if (rule.selectorText && rule.style) {
2031
+ const image = rule.style.getPropertyValue('background-image').trim();
2032
+ if (image === 'none' && LAZY_EXCLUSION.test(rule.selectorText)) found.gates.push(rule.selectorText);
2033
+ else if (PAINTS_IMAGE.test(image || rule.style.getPropertyValue('background'))) found.images.push(rule.selectorText);
2034
+ }
2035
+ if (rule.cssRules) scan(rule.cssRules);
2036
+ }
2037
+ };
2038
+ for (const sheet of [...(root.styleSheets ?? []), ...(root.adoptedStyleSheets ?? [])]) {
2039
+ try { scan(sheet.cssRules); } catch { /* cross-origin */ }
2040
+ }
2041
+ backgroundRulesCache.set(root, found);
2042
+ return found;
2043
+ }
2044
+
2045
+ const matchesAny = (node, selectors) => selectors.some((selector) => {
2046
+ try { return node.matches(selector); } catch { return false; }
2047
+ });
2048
+
2049
+ /**
2050
+ * Is the background behind this text still to be lazy-loaded? True when an
2051
+ * element on its chain is given a background image by the page's styles, a
2052
+ * lazy-load gate matches it, and it computes no background image now. Page
2053
+ * builders defer section backgrounds this way until the section nears the
2054
+ * viewport, so text the visitor has not scrolled to yet is measured against
2055
+ * a placeholder they never see: the loader restores the image first.
2056
+ */
2057
+ export function lazyWithheldBackground(element) {
2058
+ for (let node = element; node?.nodeType === 1; node = flatParentOf(node)) {
2059
+ const { gates, images } = backgroundRules(node.getRootNode());
2060
+ if (!gates.length || !matchesAny(node, gates)) continue;
2061
+ if (getComputedStyle(node).backgroundImage !== 'none') continue;
2062
+ if (PAINTS_IMAGE.test(node.style?.backgroundImage ?? '') || matchesAny(node, images)) return true;
2063
+ }
2064
+ return false;
2065
+ }
2066
+
1991
2067
  /**
1992
2068
  * Colours that repaint part of this element's own text through ::first-line
1993
2069
  * or ::first-letter, when they differ from the colour the element itself
package/engine/lib/dom.js CHANGED
@@ -149,12 +149,12 @@ function siblingPosition(element, index) {
149
149
  if (!positions) {
150
150
  const counts = new Map();
151
151
  positions = new WeakMap();
152
- for (const child of parent.children) {
152
+ for (const child of childrenOf(parent)) {
153
153
  const position = (counts.get(child.tagName) ?? 0) + 1;
154
154
  counts.set(child.tagName, position);
155
155
  positions.set(child, { position, repeated: false });
156
156
  }
157
- for (const child of parent.children) positions.get(child).repeated = counts.get(child.tagName) > 1;
157
+ for (const child of childrenOf(parent)) positions.get(child).repeated = counts.get(child.tagName) > 1;
158
158
  index.parents.set(parent, positions);
159
159
  }
160
160
  return positions.get(element);
@@ -215,6 +215,21 @@ export function attributesOf(element) {
215
215
  return attributesGetter ? attributesGetter.call(element) : [];
216
216
  }
217
217
 
218
+ /** An element's child elements and a node's contains(), read through the
219
+ * prototype for the same reason: a booking form's field named "children"
220
+ * (jetblue.com) or a word search's field named "contains" (buchstaben.com)
221
+ * takes the place of the form's own property, and the audit threw. A
222
+ * select in that place is iterable, so its options cannot be told from
223
+ * the children by shape; the prototype is always asked. */
224
+ const childrenGetter = typeof Element !== 'undefined' ? Object.getOwnPropertyDescriptor(Element.prototype, 'children')?.get : null;
225
+ export function childrenOf(element) {
226
+ return childrenGetter ? childrenGetter.call(element) : element.children;
227
+ }
228
+ const nodeContains = typeof Node !== 'undefined' ? Node.prototype.contains : null;
229
+ export function containsNode(ancestor, node) {
230
+ return nodeContains ? nodeContains.call(ancestor, node) : ancestor.contains(node);
231
+ }
232
+
218
233
  /** Element's opening markup (attributes) plus a snippet of its own text —
219
234
  * enough to recognise it in a table. Built WITHOUT element.outerHTML, which
220
235
  * serializes the whole subtree and turns audits quadratic on huge pages
@@ -1,6 +1,7 @@
1
1
  // WAI forms tutorial: radio/checkbox groups need a group label — each
2
2
  // input's own label isn't enough to convey what the group asks.
3
3
  import { accessibleName, labelledByName } from '../../lib/accessible-name.js';
4
+ import { containsNode } from '../../lib/dom.js';
4
5
  export default {
5
6
  id: 'fieldset-legend',
6
7
  name: 'Grouped field labels',
@@ -41,7 +42,7 @@ export default {
41
42
  // it empty (2026-08-25 overnight audit).
42
43
  const group = first.closest('fieldset, [role="group"], [role="radiogroup"]');
43
44
  const legend = group?.querySelector(':scope > legend');
44
- const grouped = group && indexes.every((index) => group.contains(elements[index])) && (
45
+ const grouped = group && indexes.every((index) => containsNode(group, elements[index])) && (
45
46
  (legend && accessibleName(legend))
46
47
  || labelledByName(group)
47
48
  || group.getAttribute('aria-label')?.trim()
@@ -2,6 +2,7 @@
2
2
  // Composite roles are meaningless without their required child roles —
3
3
  // role="list" with no listitems announces as an empty list.
4
4
  import { effectiveRole } from '../../lib/roles.js';
5
+ import { childrenOf } from '../../lib/dom.js';
5
6
  // Every row of ARIA 1.2's Required Owned Elements table; verify:aria-tables
6
7
  // diffs this map against the spec (2026-09-01, after feed and row were
7
8
  // found missing).
@@ -112,7 +113,7 @@ export default {
112
113
  const role = effectiveRole(element);
113
114
  const required = REQUIRED_CHILDREN[role];
114
115
  if (!required) return { status: 'pass' };
115
- const children = [...element.children, ...(element.shadowRoot?.children ?? [])]
116
+ const children = [...childrenOf(element), ...(element.shadowRoot?.children ?? [])]
116
117
  .filter((c) => !c.matches('script, style, template'));
117
118
  // A completely empty container is a lazy-load placeholder more often
118
119
  // than a defect — it announces as an empty list, which is accurate.
@@ -1,5 +1,6 @@
1
1
  // WCAG SC 1.4.1 Use of Color (Level A)
2
2
  import { parseColor, contrastRatio, composite, effectiveBackground, backgroundImageSource, asRgb } from '../../lib/contrast.js';
3
+ import { containsNode } from '../../lib/dom.js';
3
4
 
4
5
  // Split a computed list value ("a, b(c, d), e") on the commas OUTSIDE
5
6
  // parentheses: gradients and colour functions carry commas of their own.
@@ -326,7 +327,7 @@ export default {
326
327
  const range = element.ownerDocument.createRange();
327
328
  let sharesLine = false;
328
329
  for (let node = walker.nextNode(); node && !sharesLine; node = walker.nextNode()) {
329
- if (!node.textContent.trim() || element.contains(node)) continue;
330
+ if (!node.textContent.trim() || containsNode(element, node)) continue;
330
331
  range.selectNodeContents(node);
331
332
  for (const r of range.getClientRects()) {
332
333
  if (r.width > 0 && mids.some((mid) => r.top <= mid && r.bottom >= mid)) { sharesLine = true; break; }
@@ -6,7 +6,8 @@ import {
6
6
  opacityAnimating, restingOpacity, mediaRects, inZeroClipSubtree,
7
7
  paintedBackdrop, opaquePanelRects, viewportVeil, textShadowHalo, textShadowNegligible,
8
8
  pseudoBackdropForText, filmedContrastBounds, backgroundColorSource, scrimPaint, applyOverlays,
9
- showRatio, asRgb, splitBackgroundLayers, backgroundLayerUrl, sampleGridFor, opacityGroupPaint, pseudoTextColors, BOLD_WEIGHT, hasPaintEffects, isolatedBlendBackdrop, labelReferrers, beneathOpaqueAncestor } from '../../lib/contrast.js';
9
+ showRatio, asRgb, splitBackgroundLayers, backgroundLayerUrl, sampleGridFor, opacityGroupPaint, pseudoTextColors, BOLD_WEIGHT, hasPaintEffects, isolatedBlendBackdrop, labelReferrers, beneathOpaqueAncestor, lazyWithheldBackground } from '../../lib/contrast.js';
10
+ import { childrenOf, containsNode } from '../../lib/dom.js';
10
11
 
11
12
  /** The first url() among a background-image list's layers, or null. */
12
13
  const firstLayerUrl = (css) => splitBackgroundLayers(css ?? '').map(backgroundLayerUrl).find(Boolean) ?? null;
@@ -215,7 +216,7 @@ function coveredByTextTwin(element) {
215
216
  const index = stack.indexOf(element);
216
217
  if (index <= 0) return false;
217
218
  return stack.slice(0, index).some((layer) =>
218
- !layer.contains(element) && !element.contains(layer) && layer.textContent.trim() === text);
219
+ !containsNode(layer, element) && !containsNode(element, layer) && layer.textContent.trim() === text);
219
220
  }
220
221
 
221
222
  /** Do any of the element's own text boxes MEANINGFULLY overlap the given
@@ -361,7 +362,7 @@ function unaccountedScrim(element, imageCarrier) {
361
362
  const start = stack.indexOf(element);
362
363
  if (start === -1) return false;
363
364
  return stack.slice(start + 1).some((layer) => {
364
- if (layer.contains(element) || layer === imageCarrier) return false; // ancestor chain: already composited
365
+ if (containsNode(layer, element) || layer === imageCarrier) return false; // ancestor chain: already composited
365
366
  const color = parseColor(getComputedStyle(layer).backgroundColor);
366
367
  return color && color.a > 0;
367
368
  });
@@ -396,7 +397,7 @@ function coveringLayer(element, imageCarrier) {
396
397
  const overlays = [];
397
398
  for (const layer of stack.slice(start + 1)) {
398
399
  if (layer === imageCarrier) return null;
399
- if (layer.contains(element)) {
400
+ if (containsNode(layer, element)) {
400
401
  const own = parseColor(getComputedStyle(layer).backgroundColor);
401
402
  if (own && own.a > 0 && own.a < 1) overlays.push(own);
402
403
  continue;
@@ -424,7 +425,7 @@ function positionedPaintInside(carrier, element) {
424
425
  const point = textSamplePoint(element);
425
426
  if (!point) return false;
426
427
  for (const node of carrier.querySelectorAll('*')) {
427
- if (node.contains(element) || element.contains(node)) continue;
428
+ if (containsNode(node, element) || containsNode(element, node)) continue;
428
429
  const style = getComputedStyle(node);
429
430
  if (style.position !== 'absolute' && style.position !== 'fixed') continue;
430
431
  const paints = (style.backgroundImage !== 'none' && !paintsNothing(style.backgroundImage))
@@ -744,7 +745,7 @@ export function createContrastRule({ id, name, tags, help, helpUrl, thresholds }
744
745
  // Level 1: B(Cb, Cs) = Cb * Cs. Keep groups, filters and generated paint
745
746
  // outside this narrow path; its backdrop is resolved below as usual.
746
747
  const ownMultiply = style.mixBlendMode === 'multiply'
747
- && styleSource === element && !element.children.length && !element.shadowRoot
748
+ && styleSource === element && !childrenOf(element).length && !element.shadowRoot
748
749
  && parseColor(style.backgroundColor)?.a === 0 && style.backgroundImage === 'none'
749
750
  && style.filter === 'none' && (!style.backdropFilter || style.backdropFilter === 'none')
750
751
  && (!style.textShadow || style.textShadow === 'none')
@@ -871,7 +872,7 @@ export function createContrastRule({ id, name, tags, help, helpUrl, thresholds }
871
872
  const near = (r) => Math.min(groupRect.right, r.right) - Math.max(groupRect.left, r.left) >= 3
872
873
  && Math.min(groupRect.bottom, r.bottom) - Math.max(groupRect.top, r.top) >= 3;
873
874
  const overMedia = mediaRects(doc).some(({ element: media, rect: mediaRect }) =>
874
- near(mediaRect) && !media.contains(element) && !element.contains(media) && textIntersects(element, mediaRect));
875
+ near(mediaRect) && !containsNode(media, element) && !containsNode(element, media) && textIntersects(element, mediaRect));
875
876
  if (overMedia) {
876
877
  return {
877
878
  status: 'incomplete',
@@ -879,7 +880,7 @@ export function createContrastRule({ id, name, tags, help, helpUrl, thresholds }
879
880
  };
880
881
  }
881
882
  const flipping = opaquePanelRects(doc).some(({ element: panel, rect, color }) =>
882
- near(rect) && !panel.contains(element) && !element.contains(panel) && textIntersects(element, rect)
883
+ near(rect) && !containsNode(panel, element) && !containsNode(element, panel) && textIntersects(element, rect)
883
884
  && (contrastRatio(composite(group.text, color), composite(group.paint, color)) >= required) !== (groupRatio >= required));
884
885
  if (flipping) {
885
886
  return {
@@ -898,7 +899,7 @@ export function createContrastRule({ id, name, tags, help, helpUrl, thresholds }
898
899
  let groupScrim = groupPainted?.scrim ?? null;
899
900
  if (!groupScrim && groupPainted === 'offscreen') {
900
901
  const veil = viewportVeil(doc);
901
- if (veil && !veil.contains(element)) groupScrim = [veil];
902
+ if (veil && !containsNode(veil, element)) groupScrim = [veil];
902
903
  }
903
904
  if (groupScrim) {
904
905
  const groupVeil = scrimPaint(groupScrim);
@@ -1034,7 +1035,7 @@ export function createContrastRule({ id, name, tags, help, helpUrl, thresholds }
1034
1035
  // in-viewport, hence never routed through this branch.)
1035
1036
  if (!scrimLayers && painted === 'offscreen') {
1036
1037
  const veil = viewportVeil(doc);
1037
- if (veil && !veil.contains(element)) scrimLayers = [veil];
1038
+ if (veil && !containsNode(veil, element)) scrimLayers = [veil];
1038
1039
  }
1039
1040
  if (scrimLayers) {
1040
1041
  veilPaint = scrimPaint(scrimLayers);
@@ -1264,7 +1265,7 @@ export function createContrastRule({ id, name, tags, help, helpUrl, thresholds }
1264
1265
  // to a person: can paint order prove an opaque ancestor lies over it?
1265
1266
  const overMedia = mediaRects(doc).some(({ element: media, rect: mediaRect, hitTestBlind }) =>
1266
1267
  (!blindOnly || hitTestBlind) && near(mediaRect)
1267
- && !media.contains(element) && !element.contains(media)
1268
+ && !containsNode(media, element) && !containsNode(element, media)
1268
1269
  && textIntersects(element, mediaRect)
1269
1270
  && !beneathOpaqueAncestor(element, media));
1270
1271
  if (overMedia) {
@@ -1275,7 +1276,7 @@ export function createContrastRule({ id, name, tags, help, helpUrl, thresholds }
1275
1276
  }
1276
1277
  const flipping = opaquePanelRects(doc).some(({ element: panel, rect, color, hitTestBlind }) =>
1277
1278
  (!blindOnly || hitTestBlind) && near(rect)
1278
- && !panel.contains(element) && !element.contains(panel)
1279
+ && !containsNode(panel, element) && !containsNode(element, panel)
1279
1280
  && textIntersects(element, rect)
1280
1281
  && (ownMultiply || (contrastRatio(foreground, color) >= required) !== (direction === 'pass')));
1281
1282
  if (flipping) {
@@ -1308,7 +1309,7 @@ export function createContrastRule({ id, name, tags, help, helpUrl, thresholds }
1308
1309
  const inside = block.querySelectorAll('*');
1309
1310
  if (inside.length <= 2000) {
1310
1311
  for (const sibling of inside) {
1311
- if (sibling === element || sibling.contains(element) || element.contains(sibling)) continue;
1312
+ if (sibling === element || containsNode(sibling, element) || containsNode(element, sibling)) continue;
1312
1313
  const siblingStyle = getComputedStyle(sibling);
1313
1314
  if (siblingStyle.position !== 'static' || siblingStyle.visibility === 'hidden') continue;
1314
1315
  const paint = parseColor(siblingStyle.backgroundColor);
@@ -1508,6 +1509,16 @@ export function createContrastRule({ id, name, tags, help, helpUrl, thresholds }
1508
1509
  rule.evaluate = async (element, helpers) => {
1509
1510
  const verdict = await judge(element, helpers);
1510
1511
  if (verdict.status !== 'fail') return verdict;
1512
+ // The background this was measured against is a placeholder: the page
1513
+ // holds its background image back until a lazy loader marks it loaded,
1514
+ // which happens before the visitor scrolls it into view. A failure has
1515
+ // to be shown against the paint the visitor sees.
1516
+ if (lazyWithheldBackground(element)) {
1517
+ return {
1518
+ status: 'incomplete',
1519
+ message: 'This text sits in a section whose background image is lazy-loaded and has not loaded yet, so it was measured against a background visitors never see. Scroll the page to this text and run the audit again, or check it by eye.',
1520
+ };
1521
+ }
1511
1522
  // A miss on a glyph that is not words: see glyphNotWords. Every fail
1512
1523
  // path passes through here, so this is said once. A glyph still owes
1513
1524
  // 3:1 under 1.4.11 when it is needed to identify a control, so one that
@@ -21,6 +21,8 @@
21
21
  // reason pause-stop-hide skips spinners: flagging every small moving
22
22
  // thumbnail would bury the full-bleed hero that matters. Silence there is
23
23
  // abstaining, not clearing.
24
+ import { containsNode } from '../../lib/dom.js';
25
+
24
26
  /** A visible, enabled button whose aria-controls names the video or an
25
27
  * ancestor of it, looked up in the video's own document or shadow tree. */
26
28
  function pauseControlFor(video, isVisible) {
@@ -31,7 +33,7 @@ function pauseControlFor(video, isVisible) {
31
33
  if (control.disabled || !isVisible(control)) continue;
32
34
  for (const id of control.getAttribute('aria-controls').split(/\s+/)) {
33
35
  const target = id && byId(id);
34
- if (target && (target === video || target.contains(video))) return control;
36
+ if (target && (target === video || containsNode(target, video))) return control;
35
37
  }
36
38
  }
37
39
  return null;
@@ -20,6 +20,8 @@
20
20
  // aria-labelledby or aria-label has a title by another route and is left
21
21
  // alone. Sections with little text, or none, are the UI-component case the
22
22
  // criterion excludes.
23
+ import { childrenOf } from '../../lib/dom.js';
24
+
23
25
  const HEADING = 'h1, h2, h3, h4, h5, h6, [role="heading"]';
24
26
  const SUBSTANTIVE = 'img, svg, video, audio, canvas, iframe, input, select, textarea, button';
25
27
  const SKIP = 'script, style, template, noscript';
@@ -70,7 +72,7 @@ export default {
70
72
  // corpus page and helped nobody.
71
73
  const item = element.closest('article');
72
74
  if (item && item.parentElement && !item.querySelector(HEADING)) {
73
- const items = (node) => [...node.children].filter((child) => child.matches('article') || child.querySelector(':scope > article')).length;
75
+ const items = (node) => [...childrenOf(node)].filter((child) => child.matches('article') || child.querySelector(':scope > article')).length;
74
76
  const container = item.parentElement;
75
77
  if (items(container) >= 3 || (container.parentElement && items(container.parentElement) >= 3)) return { status: 'pass' };
76
78
  }
@@ -17,7 +17,7 @@
17
17
  //
18
18
  // Sites that apply the spec's own sufficient technique — scroll-padding for
19
19
  // the overlay's edge — are not flagged at all.
20
- import { isInert } from '../../lib/dom.js';
20
+ import { isInert, containsNode } from '../../lib/dom.js';
21
21
 
22
22
  const FOCUSABLE = 'a[href], button, input:not([type="hidden"]), select, textarea, summary, [tabindex]:not([tabindex="-1"])';
23
23
 
@@ -105,7 +105,7 @@ export function createFocusObscuredRule({ id, name, tags, help, helpUrl, partial
105
105
  if (index <= 0) return null;
106
106
  const above = stack.slice(0, index);
107
107
  return above.find((layer) =>
108
- !layer.contains(element) && !element.contains(layer)
108
+ !containsNode(layer, element) && !containsNode(element, layer)
109
109
  && isObscuringOverlay(layer)
110
110
  && (partial ? overlaps : contained)(rect, layer.getBoundingClientRect())) ?? null;
111
111
  };
@@ -29,12 +29,12 @@ const TARGETS = 'a[href], button, input, select, [role="button"], [role="link"]'
29
29
  * These are keyboard affordances, not pointer targets — and their phantom
30
30
  * rects must not count as "crowding" for the real targets around them.
31
31
  */
32
- import { isInert } from '../../lib/dom.js';
32
+ import { isInert, containsNode } from '../../lib/dom.js';
33
33
 
34
34
  // A web component's internal link and its enclosing light-DOM link are
35
35
  // not independent pointer targets. DOM contains() stops at shadow roots.
36
36
  function containsComposed(ancestor, element) {
37
- if (ancestor.contains(element)) return true;
37
+ if (containsNode(ancestor, element)) return true;
38
38
  if (ancestor.getRootNode() === element.getRootNode() && !element.assignedSlot) return false;
39
39
  for (let node = element; node; node = node.assignedSlot ?? node.parentElement ?? node.getRootNode()?.host) {
40
40
  if (node === ancestor) return true;
@@ -145,7 +145,7 @@ function reachableRects(element, rect) {
145
145
  // Anything above it that is part of the same control is not an
146
146
  // obstruction: a button's own icon paints over the button, an overlay
147
147
  // label over its input. Only a genuinely separate element blocks it.
148
- if (stack.slice(0, index).every((layer) => layer.contains(element) || element.contains(layer))) {
148
+ if (stack.slice(0, index).every((layer) => containsNode(layer, element) || containsNode(element, layer))) {
149
149
  reachable.push(fragment);
150
150
  }
151
151
  }
@@ -438,8 +438,8 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
438
438
  if (!win || typeof doc.elementsFromPoint !== 'function'
439
439
  || x < 0 || y < 0 || x >= win.innerWidth || y >= win.innerHeight) return false;
440
440
  for (const layer of doc.elementsFromPoint(x, y)) {
441
- const inJ = elements[j] === layer || elements[j].contains(layer);
442
- const inI = elements[i] === layer || elements[i].contains(layer);
441
+ const inJ = elements[j] === layer || containsNode(elements[j], layer);
442
+ const inI = elements[i] === layer || containsNode(elements[i], layer);
443
443
  if (inJ && !inI) return true;
444
444
  if (inI && !inJ) return false;
445
445
  }
@@ -504,7 +504,7 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
504
504
  const calculateObscuredRect = (i) => {
505
505
  let areas = null;
506
506
  for (const j of nearbyCandidates(i)) {
507
- if (elements[j].contains(elements[i]) || elements[i].contains(elements[j])) continue; // same control, nested markup
507
+ if (containsNode(elements[j], elements[i]) || containsNode(elements[i], elements[j])) continue; // same control, nested markup
508
508
  if (encloses(rects[j], rects[i]) || encloses(rects[i], rects[j])) continue; // one control drawn twice
509
509
  // Two links to the same place are one control in two boxes (a
510
510
  // card's image link over its title link): clicks in the overlap
@@ -1,5 +1,6 @@
1
1
  // WCAG SC 4.1.2 Name, Role, Value (Level A)
2
2
  import { cumulativeOpacity } from '../../lib/contrast.js';
3
+ import { containsNode } from '../../lib/dom.js';
3
4
 
4
5
  const FOCUSABLE = 'a[href], area[href], button, input, select, textarea, summary, iframe, '
5
6
  + 'audio[controls], video[controls], [contenteditable]:not([contenteditable="false"]), [tabindex]';
@@ -44,7 +45,7 @@ export default {
44
45
  let modal = null;
45
46
  try { modal = doc.querySelector('dialog:modal'); } catch { /* older engine */ }
46
47
  modal ??= [...doc.querySelectorAll('[aria-modal="true"]')].find(isRendered) ?? null;
47
- if (modal && !element.contains(modal) && !modal.contains(element)) {
48
+ if (modal && !containsNode(element, modal) && !containsNode(modal, element)) {
48
49
  return {
49
50
  status: 'incomplete',
50
51
  message: `This aria-hidden element contains ${focusable.length} focusable element(s) while a modal dialog is open — fine if the modal traps keyboard focus, a failure if Tab can reach them. Check the trap by keyboard.`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pour-engine",
3
- "version": "1.46.0",
3
+ "version": "1.46.2",
4
4
  "description": "Accessibility (WCAG 2.2) audit engine written from the W3C specifications — runs entirely in the browser, no dependencies.",
5
5
  "type": "module",
6
6
  "main": "engine/index.js",