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.
- package/config/project.config.js +1 -1
- package/engine/lib/contrast.js +60 -7
- package/engine/lib/dom.js +17 -2
- package/engine/rules/best-practice/fieldset-legend.js +2 -1
- package/engine/rules/wcag/1.3.1-aria-required-children.js +2 -1
- package/engine/rules/wcag/1.4.1-link-in-text-block.js +2 -1
- package/engine/rules/wcag/1.4.3-color-contrast.js +29 -13
- package/engine/rules/wcag/2.2.2-video-loop-motion.js +3 -1
- package/engine/rules/wcag/2.4.10-section-heading.js +3 -1
- package/engine/rules/wcag/2.4.11-focus-not-obscured.js +2 -2
- package/engine/rules/wcag/2.5.8-target-size.js +32 -12
- package/engine/rules/wcag/4.1.2-aria-hidden-focus.js +2 -1
- package/package.json +1 -1
package/config/project.config.js
CHANGED
package/engine/lib/contrast.js
CHANGED
|
@@ -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
|
|
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
|
|
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]
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
|
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]
|
|
442
|
-
const inI = elements[i] === layer || elements[i]
|
|
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]
|
|
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
|
|
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