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.
@@ -6,6 +6,6 @@
6
6
  export default {
7
7
  engine: {
8
8
  name: 'pour engine',
9
- version: '1.20.0',
9
+ version: '1.21.0',
10
10
  },
11
11
  };
@@ -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
- const behind = resolveBackground(element.parentElement ?? element.getRootNode()?.host, doc);
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
- if (alphaBound > 0) layers.push({ film: alphaBound });
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 WHOLE chain — even past an opaque hit,
592
- // because an ancestor's ::after can paint above a descendant's pill.
593
- if (layer.film) { film = 1 - (1 - film) * (1 - layer.film); continue; }
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) { image = { css: imageCss, element: node, box: rect, meta: layer.imageMeta }; settled = true; continue; }
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 ? { image, film } : { image };
609
- if (acc) return film > 0 ? { color: acc, film } : { color: acc };
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
- const style = getComputedStyle(element);
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
- let background = effectiveBackground(element);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pour-engine",
3
- "version": "1.20.0",
3
+ "version": "1.21.0",
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",