pour-engine 1.46.2 → 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.2',
9
+ version: '1.46.3',
10
10
  },
11
11
  };
@@ -1,5 +1,5 @@
1
1
  // WCAG contrast math (WCAG 2.x, relative luminance per sRGB).
2
- import { containsNode } from './dom.js';
2
+ import { childrenOf, containsNode } from './dom.js';
3
3
 
4
4
  // Computed colours do not all come back as `rgb()` any more. A colour the
5
5
  // author wrote in a modern syntax is returned in THAT syntax: Chrome and
@@ -136,6 +136,9 @@ export function composite(top, bottom) {
136
136
  let backgroundCache = new WeakMap();
137
137
  let opacityCache = new WeakMap();
138
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();
139
142
  let mediaRectsCache = null;
140
143
  let panelRectsCache = null;
141
144
  let pseudoCache = new WeakMap();
@@ -477,6 +480,7 @@ export function restingOpacity(element) {
477
480
  const animators = opacityAnimators(element.ownerDocument);
478
481
  let product = 1;
479
482
  for (let node = element; node && node.nodeType === 1; node = node.parentElement ?? node.getRootNode()?.host) {
483
+ if (revealedNodes.has(node)) continue;
480
484
  if (animators.has(node)) {
481
485
  const rest = animators.get(node);
482
486
  product *= rest === null ? 1 : rest;
@@ -487,6 +491,53 @@ export function restingOpacity(element) {
487
491
  return product;
488
492
  }
489
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
+
490
541
  /** Product of the element's and its flat-tree ancestors' opacity, memoized
491
542
  * per audit. Near-zero means the element isn't visually presented at all. */
492
543
  export function cumulativeOpacity(element) {
@@ -494,7 +545,7 @@ export function cumulativeOpacity(element) {
494
545
  let cached = opacityCache.get(element);
495
546
  if (cached === undefined) {
496
547
  const parent = element.parentElement ?? element.getRootNode()?.host ?? null;
497
- cached = (parseFloat(getComputedStyle(element).opacity) || 0) * cumulativeOpacity(parent);
548
+ cached = (revealedNodes.has(element) ? 1 : (parseFloat(getComputedStyle(element).opacity) || 0)) * cumulativeOpacity(parent);
498
549
  opacityCache.set(element, cached);
499
550
  }
500
551
  return cached;
@@ -503,6 +554,7 @@ export function cumulativeOpacity(element) {
503
554
  /** One node's own opacity at rest: the landing value of a running
504
555
  * animation on it, otherwise its computed opacity. */
505
556
  function nodeRestingOpacity(node) {
557
+ if (revealedNodes.has(node)) return 1;
506
558
  const animators = opacityAnimators(node.ownerDocument);
507
559
  if (animators.has(node)) {
508
560
  const rest = animators.get(node);
@@ -6,7 +6,7 @@ import {
6
6
  opacityAnimating, restingOpacity, mediaRects, inZeroClipSubtree,
7
7
  paintedBackdrop, opaquePanelRects, viewportVeil, textShadowHalo, textShadowNegligible,
8
8
  pseudoBackdropForText, filmedContrastBounds, backgroundColorSource, scrimPaint, applyOverlays,
9
- showRatio, 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
10
  import { childrenOf, containsNode } from '../../lib/dom.js';
11
11
 
12
12
  /** The first url() among a background-image list's layers, or null. */
@@ -1519,6 +1519,21 @@ export function createContrastRule({ id, name, tags, help, helpUrl, thresholds }
1519
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
1520
  };
1521
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
+ }
1522
1537
  // A miss on a glyph that is not words: see glyphNotWords. Every fail
1523
1538
  // path passes through here, so this is said once. A glyph still owes
1524
1539
  // 3:1 under 1.4.11 when it is needed to identify a control, so one that
@@ -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
  }
@@ -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-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pour-engine",
3
- "version": "1.46.2",
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",