pour-engine 1.46.1 → 1.46.3

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.1',
9
+ version: '1.46.3',
10
10
  },
11
11
  };
@@ -1,4 +1,5 @@
1
1
  // WCAG contrast math (WCAG 2.x, relative luminance per sRGB).
2
+ import { childrenOf, 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
@@ -135,6 +136,9 @@ export function composite(top, bottom) {
135
136
  let backgroundCache = new WeakMap();
136
137
  let opacityCache = new WeakMap();
137
138
  let opacityAnimatorsCache = null;
139
+ // Nodes read at full opacity while a verdict is re-checked as it would be
140
+ // once a scroll reveal has run (see pendingReveal, withRevealed).
141
+ const revealedNodes = new WeakSet();
138
142
  let mediaRectsCache = null;
139
143
  let panelRectsCache = null;
140
144
  let pseudoCache = new WeakMap();
@@ -476,6 +480,7 @@ export function restingOpacity(element) {
476
480
  const animators = opacityAnimators(element.ownerDocument);
477
481
  let product = 1;
478
482
  for (let node = element; node && node.nodeType === 1; node = node.parentElement ?? node.getRootNode()?.host) {
483
+ if (revealedNodes.has(node)) continue;
479
484
  if (animators.has(node)) {
480
485
  const rest = animators.get(node);
481
486
  product *= rest === null ? 1 : rest;
@@ -486,6 +491,53 @@ export function restingOpacity(element) {
486
491
  return product;
487
492
  }
488
493
 
494
+ /** The nodes holding this text dim until it scrolls into view: an element
495
+ * or flat-tree ancestor below full opacity that declares a transition on
496
+ * opacity, around text that starts outside the viewport. That is how a
497
+ * scroll reveal is built (an observer flips a class as the element enters
498
+ * and the transition fades it in), so the dim state is the one nobody who
499
+ * scrolls to the text sees. The page cannot say where the transition will
500
+ * land, so a verdict that depends on it is a question for a person. Empty
501
+ * when the text starts in view or nothing up the chain is fading.
502
+ *
503
+ * Also empty when a fading node has a sibling of its own kind that is out
504
+ * of view too and already at full opacity. Nothing has scrolled to that
505
+ * sibling, so its opacity is not a reveal's work: the dim state is one of
506
+ * the page's own states, as in a list that keeps every card but the active
507
+ * one dim, and it is seen on screen wherever the reader scrolls (a
508
+ * benchmark page, 2026-11-09: the inactive cards stayed at 46% in view). */
509
+ export function pendingReveal(element) {
510
+ const view = element.ownerDocument.defaultView;
511
+ const outOfView = (rect) => !(rect.bottom > 0 && rect.top < view.innerHeight);
512
+ if (!view || !outOfView(element.getBoundingClientRect())) return [];
513
+ const nodes = [];
514
+ for (let node = element; node && node.nodeType === 1; node = node.parentElement ?? node.getRootNode()?.host) {
515
+ const style = getComputedStyle(node);
516
+ if (!((parseFloat(style.opacity) || 0) < 1)) continue;
517
+ const properties = style.transitionProperty.split(',').map((p) => p.trim());
518
+ const durations = style.transitionDuration.split(',').map((d) => parseFloat(d) || 0);
519
+ // The duration list repeats to the length of the property list.
520
+ if (!properties.some((p, i) => (p === 'opacity' || p === 'all') && durations[i % durations.length] > 0)) continue;
521
+ const parent = node.parentElement;
522
+ const settled = parent && [...childrenOf(parent)].some((other) => other !== node && other.tagName === node.tagName
523
+ && (parseFloat(getComputedStyle(other).opacity) || 0) >= 1 && outOfView(other.getBoundingClientRect()));
524
+ if (settled) return [];
525
+ nodes.push(node);
526
+ }
527
+ return nodes;
528
+ }
529
+
530
+ /** Runs judge() with these nodes read at full opacity, the state a scroll
531
+ * reveal brings them to, and restores the reading after. */
532
+ export async function withRevealed(nodes, judge) {
533
+ for (const node of nodes) revealedNodes.add(node);
534
+ opacityCache = new WeakMap();
535
+ try { return await judge(); } finally {
536
+ for (const node of nodes) revealedNodes.delete(node);
537
+ opacityCache = new WeakMap();
538
+ }
539
+ }
540
+
489
541
  /** Product of the element's and its flat-tree ancestors' opacity, memoized
490
542
  * per audit. Near-zero means the element isn't visually presented at all. */
491
543
  export function cumulativeOpacity(element) {
@@ -493,7 +545,7 @@ export function cumulativeOpacity(element) {
493
545
  let cached = opacityCache.get(element);
494
546
  if (cached === undefined) {
495
547
  const parent = element.parentElement ?? element.getRootNode()?.host ?? null;
496
- cached = (parseFloat(getComputedStyle(element).opacity) || 0) * cumulativeOpacity(parent);
548
+ cached = (revealedNodes.has(element) ? 1 : (parseFloat(getComputedStyle(element).opacity) || 0)) * cumulativeOpacity(parent);
497
549
  opacityCache.set(element, cached);
498
550
  }
499
551
  return cached;
@@ -502,6 +554,7 @@ export function cumulativeOpacity(element) {
502
554
  /** One node's own opacity at rest: the landing value of a running
503
555
  * animation on it, otherwise its computed opacity. */
504
556
  function nodeRestingOpacity(node) {
557
+ if (revealedNodes.has(node)) return 1;
505
558
  const animators = opacityAnimators(node.ownerDocument);
506
559
  if (animators.has(node)) {
507
560
  const rest = animators.get(node);
@@ -610,7 +663,7 @@ export function backgroundObscured(element) {
610
663
  if (start === -1) return 'unverifiable';
611
664
 
612
665
  for (const layer of stack.slice(start + 1)) {
613
- if (layer.contains(element)) {
666
+ if (containsNode(layer, element)) {
614
667
  const style = getComputedStyle(layer);
615
668
  if (style.backgroundImage !== 'none') return layer;
616
669
  const bg = parseColor(style.backgroundColor);
@@ -1001,7 +1054,7 @@ function scrimIn(layersAbove, element, win) {
1001
1054
  const rect = element.getBoundingClientRect();
1002
1055
  const found = [];
1003
1056
  for (const layer of layersAbove) {
1004
- if (layer.contains(element) || element.contains(layer)) continue;
1057
+ if (containsNode(layer, element) || containsNode(element, layer)) continue;
1005
1058
  const style = getComputedStyle(layer);
1006
1059
  const color = parseColor(style.backgroundColor);
1007
1060
  const alpha = (color ? color.a : 0) * opacityOf(style);
@@ -1100,7 +1153,7 @@ export function paintedBackdrop(element) {
1100
1153
  }
1101
1154
  const layers = [];
1102
1155
  for (const layer of stack.slice(start + 1)) {
1103
- while (missing.length && !missing[0].contains(layer)) layers.push(missing.shift());
1156
+ while (missing.length && !containsNode(missing[0], layer)) layers.push(missing.shift());
1104
1157
  layers.push(layer);
1105
1158
  }
1106
1159
  layers.push(...missing);
@@ -1466,7 +1519,7 @@ export function beneathOpaqueAncestor(element, media) {
1466
1519
  if (inTopLayer(element) || inTopLayer(media)) return false;
1467
1520
  const box = element.getBoundingClientRect();
1468
1521
  for (let ancestor = element; ancestor; ancestor = ancestor.parentElement) {
1469
- if (ancestor.contains(media)) return false; // from here up the media is inside
1522
+ if (containsNode(ancestor, media)) return false; // from here up the media is inside
1470
1523
  if (!createsStackingContext(ancestor)) continue;
1471
1524
  const style = getComputedStyle(ancestor);
1472
1525
  const color = parseColor(style.backgroundColor);
@@ -1479,7 +1532,7 @@ export function beneathOpaqueAncestor(element, media) {
1479
1532
  if (!insideRoundedBox(box, ancestor.getBoundingClientRect(), style)) continue;
1480
1533
  // The lowest stacking context holding both.
1481
1534
  let shared = ancestor.parentElement;
1482
- while (shared && !(createsStackingContext(shared) && shared.contains(media))) shared = shared.parentElement;
1535
+ while (shared && !(createsStackingContext(shared) && containsNode(shared, media))) shared = shared.parentElement;
1483
1536
  if (!shared) return false;
1484
1537
  const over = paintRank(ancestor, shared);
1485
1538
  const under = paintRank(media, shared);
@@ -1489,7 +1542,7 @@ export function beneathOpaqueAncestor(element, media) {
1489
1542
  if (under.z !== over.z) return under.z < over.z;
1490
1543
  // Same layer and z-index: tree order, earlier paints first.
1491
1544
  return Boolean(under.node.compareDocumentPosition(over.node) & 4 /* FOLLOWING */)
1492
- && !under.node.contains(over.node);
1545
+ && !containsNode(under.node, over.node);
1493
1546
  }
1494
1547
  return false;
1495
1548
  }
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, lazyWithheldBackground } from '../../lib/contrast.js';
9
+ showRatio, asRgb, splitBackgroundLayers, backgroundLayerUrl, sampleGridFor, opacityGroupPaint, pseudoTextColors, BOLD_WEIGHT, hasPaintEffects, isolatedBlendBackdrop, labelReferrers, beneathOpaqueAncestor, lazyWithheldBackground, pendingReveal, withRevealed } 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);
@@ -1518,6 +1519,21 @@ export function createContrastRule({ id, name, tags, help, helpUrl, thresholds }
1518
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.',
1519
1520
  };
1520
1521
  }
1522
+ // Text held faint until it scrolls into view (see pendingReveal): when it
1523
+ // meets the minimum at the opacity the reveal fades it to, the failure
1524
+ // is a state no visitor who scrolls there sees, so it goes to a person.
1525
+ // Text that still fails at full opacity keeps its failure.
1526
+ const fading = pendingReveal(element);
1527
+ if (fading.length) {
1528
+ const revealed = await withRevealed(fading, () => judge(element, helpers));
1529
+ if (revealed.status !== 'fail') {
1530
+ const dim = Math.round(fading.reduce((product, node) => product * (parseFloat(getComputedStyle(node).opacity) || 0), 1) * 100);
1531
+ return {
1532
+ status: 'incomplete',
1533
+ message: `This text starts out of view at ${dim}% opacity under an element that transitions its opacity, the way a scroll reveal fades content in. At full opacity it meets the minimum. Scroll to it and check that it ends at full opacity.`,
1534
+ };
1535
+ }
1536
+ }
1521
1537
  // A miss on a glyph that is not words: see glyphNotWords. Every fail
1522
1538
  // path passes through here, so this is said once. A glyph still owes
1523
1539
  // 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;
@@ -43,15 +43,31 @@ function containsComposed(ancestor, element) {
43
43
  }
44
44
 
45
45
  function outsideHiddenOverflow(element, rect) {
46
- for (let parent = element.parentElement; parent; parent = parent.parentElement) {
46
+ // Where the target can be brought to. A scrollable ancestor lets the
47
+ // reader scroll it anywhere along that ancestor's own box, so past one the
48
+ // target's reach on that axis is the scroller's box: a list scrolled out of
49
+ // a panel that hides its overflow (a long table of contents, a row of tags
50
+ // scrolling sideways) is still in reach and still judged.
51
+ // The walk stops below the body: the root's and the body's overflow is the
52
+ // viewport's, and a page that hides it is locking its scroll behind a
53
+ // dialog for now (a benchmark page, 2026-11-09, under its consent banner),
54
+ // not parking everything below the fold out of reach; whether the
55
+ // document reaches a target is isHiddenFromPointer's question.
56
+ const { body, documentElement } = element.ownerDocument;
57
+ let { left, right, top, bottom } = rect;
58
+ for (let parent = element.parentElement; parent && parent !== body && parent !== documentElement; parent = parent.parentElement) {
47
59
  const style = getComputedStyle(parent);
48
60
  if (style.display === 'contents') continue; // no principal box to clip its children
49
61
  const x = /^(hidden|clip)$/.test(style.overflowX);
50
62
  const y = /^(hidden|clip)$/.test(style.overflowY);
51
- if (!x && !y) continue;
63
+ const scrollsX = /^(auto|scroll)$/.test(style.overflowX);
64
+ const scrollsY = /^(auto|scroll)$/.test(style.overflowY);
65
+ if (!x && !y && !scrollsX && !scrollsY) continue;
52
66
  const clip = parent.getBoundingClientRect();
53
- if ((x && (clip.width === 0 || rect.right <= clip.left || rect.left >= clip.right))
54
- || (y && (clip.height === 0 || rect.bottom <= clip.top || rect.top >= clip.bottom))) return true;
67
+ if ((x && (clip.width === 0 || right <= clip.left || left >= clip.right))
68
+ || (y && (clip.height === 0 || bottom <= clip.top || top >= clip.bottom))) return true;
69
+ if (scrollsX) { left = clip.left; right = clip.right; }
70
+ if (scrollsY) { top = clip.top; bottom = clip.bottom; }
55
71
  }
56
72
  return false;
57
73
  }
@@ -145,7 +161,7 @@ function reachableRects(element, rect) {
145
161
  // Anything above it that is part of the same control is not an
146
162
  // obstruction: a button's own icon paints over the button, an overlay
147
163
  // 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))) {
164
+ if (stack.slice(0, index).every((layer) => containsNode(layer, element) || containsNode(element, layer))) {
149
165
  reachable.push(fragment);
150
166
  }
151
167
  }
@@ -358,13 +374,17 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
358
374
  // Inert targets accept no pointer action at all, so they are neither
359
375
  // judged nor counted as crowders (bbc.com/news keeps 28 controls of a
360
376
  // closed menu in an inert subtree; the browser ignores every click on
361
- // them, and so must the size judgment).
377
+ // them, and so must the size judgment). Nor are targets wholly outside
378
+ // an ancestor that hides its overflow: the slides a carousel has moved
379
+ // out of its window are painted nowhere and take no click until it
380
+ // moves them back (the benchmark's Mars demo, 2026-11-06, where links
381
+ // on the off-screen slides were failed as crowded by each other).
362
382
  const laidOut = [];
363
383
  for (let i = 0; i < elements.length; i++) {
364
384
  if (i % 64 === 0) await yieldToMain();
365
385
  const r = rects[i], element = elements[i];
366
386
  laidOut.push(r.width > 0 && r.height > 0 && !element.matches(':disabled') && !isInert(element)
367
- && !isHiddenFromPointer(element, r) && !layoutSkipped(element));
387
+ && !isHiddenFromPointer(element, r) && !layoutSkipped(element) && !outsideHiddenOverflow(element, r));
368
388
  }
369
389
  const undersized = rects.map((r, i) => laidOut[i] && (r.width < min || r.height < min));
370
390
  // One rect fully inside another is one control drawn twice (stretched-
@@ -438,8 +458,8 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
438
458
  if (!win || typeof doc.elementsFromPoint !== 'function'
439
459
  || x < 0 || y < 0 || x >= win.innerWidth || y >= win.innerHeight) return false;
440
460
  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);
461
+ const inJ = elements[j] === layer || containsNode(elements[j], layer);
462
+ const inI = elements[i] === layer || containsNode(elements[i], layer);
443
463
  if (inJ && !inI) return true;
444
464
  if (inI && !inJ) return false;
445
465
  }
@@ -504,7 +524,7 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
504
524
  const calculateObscuredRect = (i) => {
505
525
  let areas = null;
506
526
  for (const j of nearbyCandidates(i)) {
507
- if (elements[j].contains(elements[i]) || elements[i].contains(elements[j])) continue; // same control, nested markup
527
+ if (containsNode(elements[j], elements[i]) || containsNode(elements[i], elements[j])) continue; // same control, nested markup
508
528
  if (encloses(rects[j], rects[i]) || encloses(rects[i], rects[j])) continue; // one control drawn twice
509
529
  // Two links to the same place are one control in two boxes (a
510
530
  // 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.1",
3
+ "version": "1.46.3",
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",