pour-engine 1.9.0 → 1.10.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.9.0',
9
+ version: '1.10.0',
10
10
  },
11
11
  };
@@ -373,9 +373,10 @@ function textSamplePoint(element) {
373
373
  * 'unresolved' — in-viewport but unanswerable (shadow retargeting hid
374
374
  * the element from the stack, blend/filter layers…)
375
375
  *
376
- * `scrim` (when present) is a translucent viewport-scale layer painted ON TOP
377
- * of the text — a modal veil, loading overlay, consent dimmer. The text is
378
- * presented through it, so the resting colours are not what the user sees.
376
+ * `scrim` (when present) is the list of translucent viewport-scale layers
377
+ * painted ON TOP of the text — a modal veil, loading overlay, consent dimmer.
378
+ * The text is presented through them, so the resting colours are not what the
379
+ * user sees; scrimPaint() turns them into the paint that dims it.
379
380
  *
380
381
  * Known approximation: pointer-events:none layers paint but never appear in
381
382
  * hit-test stacks, so a decorative tint above the resolved colour can be
@@ -564,9 +565,13 @@ export function pseudoBackdropForText(element) {
564
565
 
565
566
  /** A layer qualifies as a scrim over `element` when it is translucent paint
566
567
  * (or a backdrop-filter) covering most of the viewport and the whole
567
- * element — the shape of a modal/loading veil, not a badge or header. */
568
+ * element — the shape of a modal/loading veil, not a badge or header.
569
+ * Returns EVERY such layer, topmost first: consent flows routinely stack a
570
+ * dimmer under a banner container, and the dimmed state the user sees is
571
+ * all of them composited, not just the first one found. */
568
572
  function scrimIn(layersAbove, element, win) {
569
573
  const rect = element.getBoundingClientRect();
574
+ const found = [];
570
575
  for (const layer of layersAbove) {
571
576
  if (layer.contains(element) || element.contains(layer)) continue;
572
577
  const style = getComputedStyle(layer);
@@ -582,9 +587,33 @@ function scrimIn(layersAbove, element, win) {
582
587
  >= 0.6 * win.innerWidth * win.innerHeight;
583
588
  const coversElement = r.left <= rect.left + 1 && r.top <= rect.top + 1
584
589
  && r.right >= rect.right - 1 && r.bottom >= rect.bottom - 1;
585
- if (coversViewport && coversElement) return layer;
590
+ if (coversViewport && coversElement) found.push(layer);
586
591
  }
587
- return null;
592
+ return found.length ? found : null;
593
+ }
594
+
595
+ /**
596
+ * The flat paint of scrim layers (topmost first, as `scrimIn` returns them),
597
+ * ready for applyOverlays — or null when the dimmed state can't be computed
598
+ * as flat colour at all. A backdrop-filter resamples the pixels beneath it
599
+ * (blur, saturate, invert) and a blend mode or filter rewrites them: no
600
+ * colour arithmetic reproduces either, so those veils stay a human call.
601
+ */
602
+ export function scrimPaint(layers) {
603
+ const paints = [];
604
+ for (const layer of layers) {
605
+ const style = getComputedStyle(layer);
606
+ if (style.backdropFilter && style.backdropFilter !== 'none') return null;
607
+ if (style.filter && style.filter !== 'none') return null;
608
+ if (style.mixBlendMode && style.mixBlendMode !== 'normal') return null;
609
+ if (style.backgroundImage !== 'none') return null;
610
+ const color = parseColor(style.backgroundColor);
611
+ if (!color) return null;
612
+ const alpha = color.a * (parseFloat(style.opacity) || 1);
613
+ if (alpha <= 0) continue;
614
+ paints.push({ ...color, a: alpha });
615
+ }
616
+ return paints.length ? paints : null;
588
617
  }
589
618
 
590
619
  export function paintedBackdrop(element) {
@@ -4,13 +4,25 @@ import {
4
4
  backgroundImageSource, backgroundImagePaintRect, imageLuminanceRange, gradientLuminanceRange,
5
5
  rangeVerdict, isLargeText, cumulativeOpacity, opacityAnimating, restingOpacity, mediaRects,
6
6
  paintedBackdrop, opaquePanelRects, viewportVeil, textShadowHalo, textShadowNegligible,
7
- pseudoBackdropForText, backgroundColorSource,
7
+ pseudoBackdropForText, backgroundColorSource, scrimPaint, applyOverlays,
8
8
  } from '../../lib/contrast.js';
9
9
 
10
10
  /** Display a ratio truncated (never rounded up): 4.495 must read "4.49",
11
11
  * because "4.50:1 — below the 4.5:1 minimum" reads as a contradiction. */
12
12
  const showRatio = (ratio) => (Math.floor(ratio * 100) / 100).toFixed(2);
13
13
 
14
+ /** The one case a full-page veil still can't be judged for the author: the
15
+ * resting page and the dimmed one land on opposite sides of the threshold,
16
+ * so the verdict depends on whether the veil is transient or permanent. */
17
+ const veiledIncomplete = (resting, veiled) => ({
18
+ status: 'incomplete',
19
+ message: 'This text sits behind a translucent full-page overlay (a modal or loading veil), which changes its presented contrast'
20
+ + (resting && veiled
21
+ ? `: ${showRatio(resting)}:1 at rest, ${showRatio(veiled)}:1 as presented through the overlay, which straddles the threshold`
22
+ : '')
23
+ + ' — judge the page with the overlay dismissed, or the dimmed state by eye if the overlay is permanent.',
24
+ });
25
+
14
26
  /**
15
27
  * Is a duplicate copy of the same text painted exactly on top of this
16
28
  * element? The gradient-text pattern ships two copies — a plain-colour
@@ -388,28 +400,30 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
388
400
  // of merely flagging them for a human.
389
401
  const painted = paintedBackdrop(element);
390
402
  // A translucent viewport-scale veil is painted ON TOP of this text
391
- // (modal scrim, loading overlay, consent dimmer): the user sees the
392
- // text through it, so the resting colours are not the presented ones —
393
- // and if the veil is transient, the resting state is what should be
394
- // judged. Either way this is a human call, in both verdict directions.
395
- if (painted?.scrim) {
396
- return {
397
- status: 'incomplete',
398
- message: 'This text sits behind a translucent full-page overlay (a modal or loading veil), which changes its presented contrast — judge the page with the overlay dismissed, or the dimmed state by eye if the overlay is permanent.',
399
- };
400
- }
403
+ // (modal scrim, loading overlay, consent dimmer): the user sees the text
404
+ // through it, so the resting colours are not the presented ones — and if
405
+ // the veil is transient, the resting state is what should be judged.
406
+ // TWO states, then, and the honest question is whether they disagree.
407
+ // A consent dimmer over failing body text fails dimmed AND at rest; the
408
+ // veil changes nothing a human could rule differently, so a verdict is
409
+ // owed. Abstaining on sight instead silenced contrast across every page
410
+ // that greets a visitor with a cookie modal — which is most of the web.
411
+ // Only genuine disagreement between the two states is a human call.
412
+ let veilPaint = null;
413
+ let scrimLayers = painted?.scrim ?? null;
401
414
  // Offscreen text can't be hit-tested against the veil — but a FIXED
402
415
  // translucent overlay follows the viewport, so it dims scrolled-away
403
416
  // content just the same. (The veil's own contents are fixed too, hence
404
417
  // in-viewport, hence never routed through this branch.)
405
- if (painted === 'offscreen') {
418
+ if (!scrimLayers && painted === 'offscreen') {
406
419
  const veil = viewportVeil(doc);
407
- if (veil && !veil.contains(element)) {
408
- return {
409
- status: 'incomplete',
410
- message: 'This text sits behind a translucent full-page overlay (a modal or loading veil), which changes its presented contrast — judge the page with the overlay dismissed, or the dimmed state by eye if the overlay is permanent.',
411
- };
412
- }
420
+ if (veil && !veil.contains(element)) scrimLayers = [veil];
421
+ }
422
+ if (scrimLayers) {
423
+ veilPaint = scrimPaint(scrimLayers);
424
+ // A veil whose paint can't be reduced to flat colour (backdrop-filter,
425
+ // blend mode) leaves the dimmed state uncomputable: still a human call.
426
+ if (!veilPaint) return veiledIncomplete();
413
427
  }
414
428
  // Pseudo-element paint sits above everything the hit-test and the walk
415
429
  // can report, and neither can see it — so it is resolved before them and
@@ -423,6 +437,11 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
423
437
  message: 'A pseudo-element with its own background paints in this element\'s chain, but its position can\'t be computed — so whether it sits behind this text is unknown. Check the contrast by eye.',
424
438
  };
425
439
  }
440
+ // An image backdrop under a veil would have to be sampled a second time,
441
+ // dimmed, to answer whether the two states agree. Not worth a second pass
442
+ // over the pixels of every hero photo behind a cookie modal: these keep
443
+ // the human call they have always had.
444
+ if (veilPaint && (pseudoBack?.image || painted?.image)) return veiledIncomplete();
426
445
  // Gradient/image paint from a pseudo-element: bracket it by sampling,
427
446
  // exactly as an element's own background-image is bracketed.
428
447
  if (pseudoBack?.image) {
@@ -526,6 +545,17 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
526
545
  };
527
546
 
528
547
  const ratio = contrastRatio(foreground, background);
548
+ // The dimmed state, when a veil is up: it paints over the glyphs and
549
+ // their background alike, so both composite through the same layers.
550
+ // Dimming is not monotonic on ratio (it can rescue or ruin a pairing),
551
+ // which is exactly why both states are measured rather than assumed.
552
+ const veiled = (fg, bg) => contrastRatio(applyOverlays(fg, veilPaint), applyOverlays(bg, veilPaint));
553
+ if (veilPaint) {
554
+ const veiledRatio = veiled(foreground, background);
555
+ if ((veiledRatio >= required) !== (ratio >= required)) {
556
+ return veiledIncomplete(ratio, veiledRatio);
557
+ }
558
+ }
529
559
  if (ratio >= required) {
530
560
  return backdropHazard('pass', backgroundVerified) ?? { status: 'pass' };
531
561
  }
@@ -545,6 +575,14 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
545
575
  const halo = textShadowHalo(style.textShadow, fontSize);
546
576
  if (halo) {
547
577
  const haloRatio = contrastRatio(foreground, halo);
578
+ // A veil dims the glyphs and their own outline together: the halo
579
+ // verdict has to survive both states, exactly as the background one does.
580
+ if (veilPaint) {
581
+ const veiledHalo = veiled(foreground, halo);
582
+ if ((veiledHalo >= required) !== (haloRatio >= required)) {
583
+ return veiledIncomplete(haloRatio, veiledHalo);
584
+ }
585
+ }
548
586
  if (haloRatio >= required) return { status: 'pass' };
549
587
  return {
550
588
  status: 'fail',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pour-engine",
3
- "version": "1.9.0",
3
+ "version": "1.10.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",