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.
package/config/project.config.js
CHANGED
package/engine/lib/contrast.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 ||
|
|
54
|
-
|| (y && (clip.height === 0 ||
|
|
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