pour-engine 1.46.1 → 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.1',
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
@@ -610,7 +611,7 @@ export function backgroundObscured(element) {
610
611
  if (start === -1) return 'unverifiable';
611
612
 
612
613
  for (const layer of stack.slice(start + 1)) {
613
- if (layer.contains(element)) {
614
+ if (containsNode(layer, element)) {
614
615
  const style = getComputedStyle(layer);
615
616
  if (style.backgroundImage !== 'none') return layer;
616
617
  const bg = parseColor(style.backgroundColor);
@@ -1001,7 +1002,7 @@ function scrimIn(layersAbove, element, win) {
1001
1002
  const rect = element.getBoundingClientRect();
1002
1003
  const found = [];
1003
1004
  for (const layer of layersAbove) {
1004
- if (layer.contains(element) || element.contains(layer)) continue;
1005
+ if (containsNode(layer, element) || containsNode(element, layer)) continue;
1005
1006
  const style = getComputedStyle(layer);
1006
1007
  const color = parseColor(style.backgroundColor);
1007
1008
  const alpha = (color ? color.a : 0) * opacityOf(style);
@@ -1100,7 +1101,7 @@ export function paintedBackdrop(element) {
1100
1101
  }
1101
1102
  const layers = [];
1102
1103
  for (const layer of stack.slice(start + 1)) {
1103
- while (missing.length && !missing[0].contains(layer)) layers.push(missing.shift());
1104
+ while (missing.length && !containsNode(missing[0], layer)) layers.push(missing.shift());
1104
1105
  layers.push(layer);
1105
1106
  }
1106
1107
  layers.push(...missing);
@@ -1466,7 +1467,7 @@ export function beneathOpaqueAncestor(element, media) {
1466
1467
  if (inTopLayer(element) || inTopLayer(media)) return false;
1467
1468
  const box = element.getBoundingClientRect();
1468
1469
  for (let ancestor = element; ancestor; ancestor = ancestor.parentElement) {
1469
- 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
1470
1471
  if (!createsStackingContext(ancestor)) continue;
1471
1472
  const style = getComputedStyle(ancestor);
1472
1473
  const color = parseColor(style.backgroundColor);
@@ -1479,7 +1480,7 @@ export function beneathOpaqueAncestor(element, media) {
1479
1480
  if (!insideRoundedBox(box, ancestor.getBoundingClientRect(), style)) continue;
1480
1481
  // The lowest stacking context holding both.
1481
1482
  let shared = ancestor.parentElement;
1482
- while (shared && !(createsStackingContext(shared) && shared.contains(media))) shared = shared.parentElement;
1483
+ while (shared && !(createsStackingContext(shared) && containsNode(shared, media))) shared = shared.parentElement;
1483
1484
  if (!shared) return false;
1484
1485
  const over = paintRank(ancestor, shared);
1485
1486
  const under = paintRank(media, shared);
@@ -1489,7 +1490,7 @@ export function beneathOpaqueAncestor(element, media) {
1489
1490
  if (under.z !== over.z) return under.z < over.z;
1490
1491
  // Same layer and z-index: tree order, earlier paints first.
1491
1492
  return Boolean(under.node.compareDocumentPosition(over.node) & 4 /* FOLLOWING */)
1492
- && !under.node.contains(over.node);
1493
+ && !containsNode(under.node, over.node);
1493
1494
  }
1494
1495
  return false;
1495
1496
  }
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; }
@@ -7,6 +7,7 @@ import {
7
7
  paintedBackdrop, opaquePanelRects, viewportVeil, textShadowHalo, textShadowNegligible,
8
8
  pseudoBackdropForText, filmedContrastBounds, backgroundColorSource, scrimPaint, applyOverlays,
9
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);
@@ -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.1",
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",