pour-engine 1.20.0 → 1.21.0
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
|
@@ -153,7 +153,7 @@ export function resetAuditCaches() {
|
|
|
153
153
|
*/
|
|
154
154
|
export function backgroundColorSource(element) {
|
|
155
155
|
for (let node = element; node && node.nodeType === 1;
|
|
156
|
-
node = node.parentElement ?? node.getRootNode()?.host) {
|
|
156
|
+
node = node.assignedSlot ?? node.parentElement ?? node.getRootNode()?.host) {
|
|
157
157
|
const style = getComputedStyle(node);
|
|
158
158
|
if (style.backgroundImage !== 'none' && !paintsNothing(style.backgroundImage)) return null;
|
|
159
159
|
const color = parseColor(style.backgroundColor);
|
|
@@ -209,7 +209,13 @@ function resolveBackground(element, doc) {
|
|
|
209
209
|
if (color && color.a >= 1) {
|
|
210
210
|
result = color;
|
|
211
211
|
} else {
|
|
212
|
-
|
|
212
|
+
// The FLAT-tree parent: a slotted element's backdrop chain runs
|
|
213
|
+
// through its slot into the shadow tree it renders in — walking the
|
|
214
|
+
// light parent instead skipped every background painted inside the
|
|
215
|
+
// outer component's shadow (a consent banner's blue container lived
|
|
216
|
+
// there, and its slotted copy was judged against the page white).
|
|
217
|
+
const behind = resolveBackground(
|
|
218
|
+
element.assignedSlot ?? element.parentElement ?? element.getRootNode()?.host, doc);
|
|
213
219
|
result = behind === null ? null : (color && color.a > 0 ? composite(color, behind) : behind);
|
|
214
220
|
}
|
|
215
221
|
}
|
|
@@ -531,7 +537,12 @@ function pseudoLayers(host) {
|
|
|
531
537
|
// (position: fixed, the common full-page-texture pattern) must not
|
|
532
538
|
// send every element on the page to a human.
|
|
533
539
|
const alphaBound = Math.min(1, opacityFactor * (style.backgroundImage !== 'none' ? 1 : color.a));
|
|
534
|
-
|
|
540
|
+
// Position rides along: a FIXED unplaceable pseudo is the full-page
|
|
541
|
+
// overlay pattern and dims everything below the walk; an ABSOLUTE one
|
|
542
|
+
// that defeated the geometry solver (rotation, percentage insets) is
|
|
543
|
+
// a decorative shape that nearer opaque paint covers in normal
|
|
544
|
+
// stacking — the walk needs to know which it is.
|
|
545
|
+
if (alphaBound > 0) layers.push({ film: alphaBound, fixed: style.position === 'fixed' });
|
|
535
546
|
continue;
|
|
536
547
|
}
|
|
537
548
|
if (rect.empty) continue; // placed, and covers nothing
|
|
@@ -586,11 +597,33 @@ export function pseudoBackdropForText(element) {
|
|
|
586
597
|
let image = null;
|
|
587
598
|
let settled = false; // an opaque colour or an image hit: deeper layers are covered
|
|
588
599
|
let film = 0;
|
|
600
|
+
// Paint (an opaque background-color or a background-image) on an element
|
|
601
|
+
// BETWEEN the text and a pseudo's host makes the pseudo's place in the
|
|
602
|
+
// stack unprovable from here: CSS paint order can put an out-of-flow
|
|
603
|
+
// ancestor pseudo above OR below that intermediate paint depending on
|
|
604
|
+
// positioning, z-index and tree order — the full stacking model this walk
|
|
605
|
+
// deliberately does not reimplement. Such layers are surfaced with
|
|
606
|
+
// `beyondPaint` so the caller brackets the verdict instead of asserting
|
|
607
|
+
// against paint that may never show behind the glyphs (a full-page
|
|
608
|
+
// decorative body::before wash under sections with their own solid
|
|
609
|
+
// backgrounds produced 90 false contrast failures on one university home).
|
|
610
|
+
let crossed = false;
|
|
611
|
+
let beyondPaint = false;
|
|
589
612
|
for (let node = element; node && node.nodeType === 1; node = node.parentElement ?? node.getRootNode()?.host) {
|
|
590
613
|
for (const layer of pseudoLayers(node)) {
|
|
591
|
-
// Films accumulate over the
|
|
592
|
-
//
|
|
593
|
-
|
|
614
|
+
// Films accumulate over the whole chain — an ancestor's fixed ::after
|
|
615
|
+
// (grain overlays, veils) paints above everything below it. But an
|
|
616
|
+
// ABSOLUTE pseudo whose geometry could not be solved (a rotated or
|
|
617
|
+
// percent-positioned decorative shape) sits in normal flow stacking:
|
|
618
|
+
// once opaque paint lies between the text and its host, that paint
|
|
619
|
+
// covers it, and filming the verdict anyway sent every element under
|
|
620
|
+
// a decorative page shape to the review lane (127 reviews on one
|
|
621
|
+
// charity home whose buttons carry their own solid backgrounds).
|
|
622
|
+
if (layer.film) {
|
|
623
|
+
if (!layer.fixed && crossed) continue;
|
|
624
|
+
film = 1 - (1 - film) * (1 - layer.film);
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
594
627
|
if (settled) continue;
|
|
595
628
|
const { rect, color, imageCss } = layer;
|
|
596
629
|
if (point.x < rect.left || point.x > rect.right || point.y < rect.top || point.y > rect.bottom) continue;
|
|
@@ -599,14 +632,28 @@ export function pseudoBackdropForText(element) {
|
|
|
599
632
|
// and image geometry travel with it: covering the sample point is a
|
|
600
633
|
// fact about the BOX, and a no-repeat icon may still paint nowhere
|
|
601
634
|
// near the text.
|
|
602
|
-
if (imageCss) {
|
|
635
|
+
if (imageCss) {
|
|
636
|
+
image = { css: imageCss, element: node, box: rect, meta: layer.imageMeta };
|
|
637
|
+
settled = true;
|
|
638
|
+
beyondPaint = beyondPaint || crossed;
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
603
641
|
if (!color || color.a === 0) continue;
|
|
604
642
|
acc = acc ? composite(acc, color) : color;
|
|
643
|
+
beyondPaint = beyondPaint || crossed;
|
|
605
644
|
if (acc.a >= 1) settled = true;
|
|
606
645
|
}
|
|
646
|
+
// Crossing updates AFTER the node's own pseudos: a host's ::before
|
|
647
|
+
// paints above its own background, so its own paint never makes its own
|
|
648
|
+
// pseudo ambiguous — only paint on elements nearer the text does.
|
|
649
|
+
if (!crossed) {
|
|
650
|
+
const style = getComputedStyle(node);
|
|
651
|
+
if ((style.backgroundImage !== 'none' && !paintsNothing(style.backgroundImage))
|
|
652
|
+
|| (parseColor(style.backgroundColor)?.a ?? 0) >= 1) crossed = true;
|
|
653
|
+
}
|
|
607
654
|
}
|
|
608
|
-
if (image) return film > 0
|
|
609
|
-
if (acc) return film > 0
|
|
655
|
+
if (image) return { image, ...(film > 0 && { film }), ...(beyondPaint && { beyondPaint }) };
|
|
656
|
+
if (acc) return { color: acc, ...(film > 0 && { film }), ...(beyondPaint && { beyondPaint }) };
|
|
610
657
|
return film > 0 ? { film } : null;
|
|
611
658
|
}
|
|
612
659
|
|
|
@@ -727,6 +774,13 @@ export function paintedBackdrop(element) {
|
|
|
727
774
|
return { image: layer, scrim };
|
|
728
775
|
}
|
|
729
776
|
const style = getComputedStyle(layer);
|
|
777
|
+
// A shadow HOST in the stack hides its shadow tree from the hit-test
|
|
778
|
+
// (elementsFromPoint retargets internal elements to the host), so paint
|
|
779
|
+
// inside it is invisible here: compositing the host as "transparent"
|
|
780
|
+
// and reading layers behind it judged slotted banner text against the
|
|
781
|
+
// page white while the component's shadow painted a blue container
|
|
782
|
+
// between them. Whatever renders inside is unknowable to this walk.
|
|
783
|
+
if (layer.shadowRoot) return 'unresolved';
|
|
730
784
|
// Blend modes and filters change the paint in ways flat colour math
|
|
731
785
|
// can't follow — that's a human check, not a resolution.
|
|
732
786
|
if ((style.mixBlendMode && style.mixBlendMode !== 'normal')
|
|
@@ -9,6 +9,12 @@ import {
|
|
|
9
9
|
showRatio,
|
|
10
10
|
} from '../../lib/contrast.js';
|
|
11
11
|
|
|
12
|
+
/** The strict separator set for the pure-decoration exemption: pipes,
|
|
13
|
+
* interpuncts, slashes, dashes and guillemets — one glyph, optionally
|
|
14
|
+
* repeated ("···"). Deliberately excludes brackets, colons, stars and
|
|
15
|
+
* every character that carries grouping, rating or code meaning. */
|
|
16
|
+
const SEPARATOR_GLYPHS = /^([|¦·•∙‧/⁄\\‐‑‒–—―⁃«»‹›-])\1*$/;
|
|
17
|
+
|
|
12
18
|
/** Serialise a judged colour for the report and the checker link. Composited
|
|
13
19
|
* channels keep up to two decimals: rounding them to integers changes the
|
|
14
20
|
* pair enough that the checker scores a different ratio than the audit
|
|
@@ -323,7 +329,49 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
323
329
|
// The criterion exempts "inactive user interface components".
|
|
324
330
|
if (element.closest(':disabled, [aria-disabled="true"]')) return { status: 'pass' };
|
|
325
331
|
|
|
326
|
-
|
|
332
|
+
// A shadow HOST's own text renders through its shadow tree: when a
|
|
333
|
+
// <slot> projects it, the text inherits styles from the slot's
|
|
334
|
+
// shadow-side parent, NOT from the host — a consent banner's shadow
|
|
335
|
+
// <p> painted the slotted copy near-white while the host computed
|
|
336
|
+
// black, and the host-styled pair was one no user ever sees. With no
|
|
337
|
+
// slot to project it, the host's own text does not render at all.
|
|
338
|
+
// (Closed shadow roots are undetectable; those hosts keep the old
|
|
339
|
+
// behaviour.)
|
|
340
|
+
let styleSource = element;
|
|
341
|
+
if (element.shadowRoot) {
|
|
342
|
+
const ownTextNode = [...element.childNodes]
|
|
343
|
+
.find((node) => node.nodeType === 3 && node.textContent.trim());
|
|
344
|
+
const slot = ownTextNode?.assignedSlot ?? null;
|
|
345
|
+
if (!slot) return { status: 'pass' }; // unprojected: not rendered
|
|
346
|
+
styleSource = slot;
|
|
347
|
+
}
|
|
348
|
+
const style = getComputedStyle(styleSource);
|
|
349
|
+
|
|
350
|
+
// A lone separator glyph between inline items is 1.4.3 "pure
|
|
351
|
+
// decoration": it does the job a border does (and borders carry no
|
|
352
|
+
// text-contrast requirement), and the ARIA Authoring Practices tell
|
|
353
|
+
// authors to draw exactly these separators in CSS or aria-hidden them.
|
|
354
|
+
// Strictly bounded — one repeated glyph from a fixed separator set,
|
|
355
|
+
// inline, outside code and interactive content, with rendered content
|
|
356
|
+
// on BOTH sides — so Python's `>>>` prompt, code operators, maths in
|
|
357
|
+
// prose and meaning-bearing brackets all keep their verdicts.
|
|
358
|
+
if (SEPARATOR_GLYPHS.test(ownText(element))
|
|
359
|
+
&& style.display.startsWith('inline')
|
|
360
|
+
&& !element.closest('code, pre, samp, kbd, var, a[href], button, [role="button"], [role="link"], [role="menuitem"], [role="tab"], [role="option"]')) {
|
|
361
|
+
const hasContent = (start, dir) => {
|
|
362
|
+
for (let node = start; node; node = node[dir]) {
|
|
363
|
+
if (node.nodeType === 3 && node.textContent.trim()) return true;
|
|
364
|
+
if (node.nodeType === 1 && (node.textContent.trim()
|
|
365
|
+
|| node.matches('img, svg, video, canvas, input, select, button'))) return true;
|
|
366
|
+
}
|
|
367
|
+
return false;
|
|
368
|
+
};
|
|
369
|
+
if (hasContent(element.previousSibling, 'previousSibling')
|
|
370
|
+
&& hasContent(element.nextSibling, 'nextSibling')) {
|
|
371
|
+
return { status: 'pass' };
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
327
375
|
// Controls conveying "disabled" without the attribute: pointer-events:
|
|
328
376
|
// none on an interactive element is functional inertness — the low
|
|
329
377
|
// contrast IS the disabled styling (carousel arrows at the end of
|
|
@@ -434,7 +482,11 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
434
482
|
}
|
|
435
483
|
}
|
|
436
484
|
|
|
437
|
-
|
|
485
|
+
// For slotted host text, the walk starts at the slot: its flat-tree
|
|
486
|
+
// chain (slot → shadow parents → host → light ancestors) is the one the
|
|
487
|
+
// text actually paints in; effectiveBackground crosses the shadow
|
|
488
|
+
// boundary via getRootNode().host on its own.
|
|
489
|
+
let background = effectiveBackground(styleSource);
|
|
438
490
|
|
|
439
491
|
// Browser-truth paint order at the text's own sample point: sees and
|
|
440
492
|
// RESOLVES backdrops painted by non-ancestors (positioned siblings,
|
|
@@ -524,6 +576,18 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
524
576
|
// over the pixels of every hero photo behind a cookie modal: these keep
|
|
525
577
|
// the human call they have always had.
|
|
526
578
|
if (veilPaint && (pseudoBack?.image || painted?.image)) return veiledIncomplete();
|
|
579
|
+
// A pseudo layer that sits BEYOND intermediate paint (an element between
|
|
580
|
+
// the text and the pseudo's host carries its own opaque background or
|
|
581
|
+
// image): whether the pseudo paints behind these glyphs or is covered by
|
|
582
|
+
// that nearer paint is a stacking-order question this engine deliberately
|
|
583
|
+
// does not model. Image paint can't be bracketed against the walked
|
|
584
|
+
// colour, so it stays a human call.
|
|
585
|
+
if (pseudoBack?.image && pseudoBack.beyondPaint) {
|
|
586
|
+
return {
|
|
587
|
+
status: 'incomplete',
|
|
588
|
+
message: 'An ancestor\'s pseudo-element paints an image or gradient where this text is, but other backgrounds sit between them — which one is behind the glyphs depends on paint order, so check the contrast by eye.',
|
|
589
|
+
};
|
|
590
|
+
}
|
|
527
591
|
// Gradient/image paint from a pseudo-element: bracket it by sampling,
|
|
528
592
|
// exactly as an element's own background-image is bracketed.
|
|
529
593
|
if (pseudoBack?.image) {
|
|
@@ -580,7 +644,31 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
580
644
|
}
|
|
581
645
|
}
|
|
582
646
|
}
|
|
583
|
-
if (pseudoBack?.color) {
|
|
647
|
+
if (pseudoBack?.color && pseudoBack.beyondPaint) {
|
|
648
|
+
// Paint sits between this text and the pseudo's host, so the pseudo
|
|
649
|
+
// may equally be covered by it (the common case: a decorative
|
|
650
|
+
// full-bleed wash on body under sections with their own backgrounds).
|
|
651
|
+
// Bracket instead of choosing: a verdict is only provable when the
|
|
652
|
+
// walked background and the pseudo-as-backdrop land the same side of
|
|
653
|
+
// the threshold; disagreement is a genuine human call.
|
|
654
|
+
const candidate = pseudoBack.color.a >= 1 || !background
|
|
655
|
+
? pseudoBack.color
|
|
656
|
+
: composite(pseudoBack.color, background);
|
|
657
|
+
if (!background) {
|
|
658
|
+
return { status: 'incomplete', message: 'The background could not be determined — check contrast by eye.' };
|
|
659
|
+
}
|
|
660
|
+
const overWalked = foreground.a < 1 ? composite(foreground, background) : foreground;
|
|
661
|
+
const overPseudo = foreground.a < 1 ? composite(foreground, candidate) : foreground;
|
|
662
|
+
if ((contrastRatio(overWalked, background) >= required)
|
|
663
|
+
!== (contrastRatio(overPseudo, candidate) >= required)) {
|
|
664
|
+
return {
|
|
665
|
+
status: 'incomplete',
|
|
666
|
+
message: 'An ancestor\'s pseudo-element paints where this text is, but other backgrounds sit between them — which colour is behind the glyphs depends on paint order, and the two candidates disagree on the verdict. Check the contrast by eye.',
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
// Both candidates agree: judge against the walked background below.
|
|
670
|
+
// The backdrop stays UNVERIFIED — the hazard bracket still applies.
|
|
671
|
+
} else if (pseudoBack?.color) {
|
|
584
672
|
// Opaque pill: it IS the backdrop. Translucent paint composites over
|
|
585
673
|
// whatever the walk resolved beneath it. Either way the geometry is
|
|
586
674
|
// computed, not guessed, so this counts as verified paint truth.
|
|
@@ -679,6 +767,19 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
679
767
|
const rect = element.getBoundingClientRect();
|
|
680
768
|
// Zero-area text paints nothing (collapsed badges, empty counters).
|
|
681
769
|
if (!rect.width || !rect.height) return { status: 'pass' };
|
|
770
|
+
// Text parked wholly left of or above the document is unreachable by any
|
|
771
|
+
// scroll position (skip links pre-focus, sr-only labels at left:-10000px,
|
|
772
|
+
// panels slid out at negative offsets): it is not visually presented, so
|
|
773
|
+
// 1.4.3 has nothing to judge. Scrolling can only reach coordinates >= 0
|
|
774
|
+
// in LTR documents; RTL pages legitimately extend leftward, so they keep
|
|
775
|
+
// the old behavior.
|
|
776
|
+
{
|
|
777
|
+
const win = doc.defaultView;
|
|
778
|
+
if (win && getComputedStyle(doc.documentElement).direction !== 'rtl'
|
|
779
|
+
&& (rect.right + win.scrollX <= 0 || rect.bottom + win.scrollY <= 0)) {
|
|
780
|
+
return { status: 'pass' };
|
|
781
|
+
}
|
|
782
|
+
}
|
|
682
783
|
// A text-shadow can supply the contrast (technique G18 measures the
|
|
683
784
|
// letters against their halo): white text with a solid dark outline
|
|
684
785
|
// passes however the fill compares to the backdrop. A parseable solid
|
|
@@ -27,6 +27,19 @@ export default {
|
|
|
27
27
|
const rect = element.getBoundingClientRect();
|
|
28
28
|
if (rect.width <= 1 || rect.height <= 1) return { status: 'pass' };
|
|
29
29
|
const style = getComputedStyle(element);
|
|
30
|
+
// Text rendered at font-size 0 (icon buttons whose value is a
|
|
31
|
+
// screen-reader label) is not visually presented — nothing to judge.
|
|
32
|
+
if (parseFloat(style.fontSize) === 0) return { status: 'pass' };
|
|
33
|
+
// Controls parked wholly left of or above the document are unreachable
|
|
34
|
+
// by scrolling (sr-only duplicates): same exemption as the main rule.
|
|
35
|
+
{
|
|
36
|
+
const doc = element.ownerDocument;
|
|
37
|
+
const win = doc.defaultView;
|
|
38
|
+
if (win && getComputedStyle(doc.documentElement).direction !== 'rtl'
|
|
39
|
+
&& (rect.right + win.scrollX <= 0 || rect.bottom + win.scrollY <= 0)) {
|
|
40
|
+
return { status: 'pass' };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
30
43
|
// Judge the opacity the text RESTS at, matching the main contrast rule:
|
|
31
44
|
// a control faded out by an ancestor (fade-in-on-scroll, a collapsed
|
|
32
45
|
// panel) presents no text at all, and one held at a low opacity presents
|
package/package.json
CHANGED