pour-engine 1.9.0 → 1.11.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.11.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) {
@@ -621,7 +650,24 @@ export function paintedBackdrop(element) {
621
650
  if ((style.mixBlendMode && style.mixBlendMode !== 'normal')
622
651
  || (style.filter && style.filter !== 'none')
623
652
  || (style.backdropFilter && style.backdropFilter !== 'none')) return 'unresolved';
624
- if (style.backgroundImage !== 'none') return { image: layer, scrim };
653
+ // An ancestor carrying a background-image is only this text's backdrop if
654
+ // that image actually paints where the text is. The element's own image is
655
+ // adjudicated upstream against its paint rect; ancestors were not, so a
656
+ // decorative 4px accent stripe down a callout's left edge counted as the
657
+ // backdrop for text sitting 44px to its right, and the stripe's colour
658
+ // then failed the whole paragraph. Adjudicate at the same point the hit
659
+ // test used: if the rect resolves and does not cover it, this image is not
660
+ // what is behind the glyphs, so fall through to the layer's own colour.
661
+ // An unresolvable rect keeps the old behaviour, which errs toward asking a
662
+ // human rather than asserting from geometry we could not compute.
663
+ if (style.backgroundImage !== 'none') {
664
+ const rects = backgroundImagePaintRects(layer);
665
+ // An unresolvable layer (null) counts as covering: not knowing where a
666
+ // layer paints is a reason to ask a human, never to rule it out.
667
+ const covers = (r) => !r
668
+ || (point.x >= r.left && point.x < r.right && point.y >= r.top && point.y < r.bottom);
669
+ if (!rects.length || rects.some(covers)) return { image: layer, scrim };
670
+ }
625
671
  let color = parseColor(style.backgroundColor);
626
672
  if (!color) return 'unresolved';
627
673
  // A layer's own opacity thins its paint. (Group opacity shared with the
@@ -889,6 +935,100 @@ export function imageLuminanceRange(url, overlays = []) {
889
935
  * clear text that never overlaps the image — e.g. external-link icons
890
936
  * painted in a link's right padding (Wikipedia-style).
891
937
  */
938
+ /**
939
+ * Paint rect for EVERY layer of a background-image list, one entry per layer,
940
+ * null where a layer's geometry cannot be resolved.
941
+ *
942
+ * background-size/position/repeat are comma-separated lists that pair up with
943
+ * the image list, so a four-layer background can be four separate 1px lines —
944
+ * which is how design systems draw a tile's border without a border. Judging
945
+ * such a background as one blob meant text in the middle of a tile was ruled
946
+ * unjudgeable because of four hairlines around its edge.
947
+ *
948
+ * A layer resolves to null (unknown, treat as covering) when it tiles, when
949
+ * cover/contain hands sizing to an intrinsic this function does not have, or
950
+ * when its position is not a plain length or percentage.
951
+ */
952
+ export function backgroundImagePaintRects(element) {
953
+ const style = getComputedStyle(element);
954
+ const images = splitLayers(style.backgroundImage);
955
+ if (!images.length) return [];
956
+ const box = element.getBoundingClientRect();
957
+ const sizes = splitLayers(style.backgroundSize);
958
+ const positions = splitLayers(style.backgroundPosition);
959
+ const repeats = splitLayers(style.backgroundRepeat);
960
+ const per = (list, i) => (list.length ? list[i % list.length] : undefined);
961
+
962
+ return images.map((image, i) => {
963
+ if (image === 'none') return { left: 0, top: 0, right: 0, bottom: 0 }; // paints nothing
964
+ const repeat = per(repeats, i) ?? 'repeat';
965
+ if (repeat !== 'no-repeat') return null; // may tile under the text
966
+ const size = per(sizes, i) ?? 'auto';
967
+ if (size === 'cover' || size === 'contain') return null;
968
+ const parts = splitParts(size);
969
+ const extent = (value, total) => {
970
+ if (value?.endsWith('px')) return parseFloat(value);
971
+ if (value?.endsWith('%')) return (parseFloat(value) / 100) * total;
972
+ return null; // 'auto' on a gradient means the full box, but only for a
973
+ // single-layer background; per-layer we decline rather than guess.
974
+ };
975
+ const width = extent(parts[0], box.width);
976
+ const height = extent(parts[1] ?? parts[0], box.height);
977
+ if (width === null || height === null
978
+ || Number.isNaN(width) || Number.isNaN(height)) return null;
979
+ const pos = splitParts(per(positions, i) ?? '0% 0%');
980
+ const offset = (value, total, span) => {
981
+ if (value?.endsWith('%')) return (parseFloat(value) / 100) * (total - span);
982
+ if (value?.endsWith('px')) return parseFloat(value);
983
+ return null;
984
+ };
985
+ const offsetX = offset(pos[0], box.width, width);
986
+ const offsetY = offset(pos[1] ?? '50%', box.height, height);
987
+ // NaN, not just null: `calc(100% - 1px)` is a real position a design
988
+ // system writes, and a NaN that escapes as a rect is worse than an
989
+ // unresolved one — it compares false against everything, so a genuine
990
+ // image backdrop would be silently ruled clear of the text.
991
+ if (offsetX === null || offsetY === null
992
+ || Number.isNaN(offsetX) || Number.isNaN(offsetY)) return null;
993
+ const left = box.left + offsetX;
994
+ const top = box.top + offsetY;
995
+ return { left, top, right: left + width, bottom: top + height };
996
+ });
997
+ }
998
+
999
+ /** Split a space-separated CSS value, keeping function calls whole. Splitting
1000
+ * `calc(100% - 1px)` on whitespace yields `calc(100%`, which parseFloat reads
1001
+ * as 100 and every guard downstream then trusts. */
1002
+ function splitParts(value) {
1003
+ const out = [];
1004
+ let depth = 0;
1005
+ let current = '';
1006
+ for (const ch of value) {
1007
+ if (ch === '(') depth += 1;
1008
+ if (ch === ')') depth -= 1;
1009
+ if (/\s/.test(ch) && depth === 0) { if (current) out.push(current); current = ''; continue; }
1010
+ current += ch;
1011
+ }
1012
+ if (current) out.push(current);
1013
+ return out;
1014
+ }
1015
+
1016
+ /** Split a comma-separated CSS list, ignoring commas inside parentheses. */
1017
+ function splitLayers(value) {
1018
+ if (!value || value === 'none') return value === 'none' ? ['none'] : [];
1019
+ const out = [];
1020
+ let depth = 0;
1021
+ let current = '';
1022
+ for (const ch of value) {
1023
+ if (ch === '(') depth += 1;
1024
+ if (ch === ')') depth -= 1;
1025
+ if (ch === ',' && depth === 0) { out.push(current.trim()); current = ''; continue; }
1026
+ current += ch;
1027
+ }
1028
+ if (current.trim()) out.push(current.trim());
1029
+ return out;
1030
+ }
1031
+
892
1032
  export function backgroundImagePaintRect(element, intrinsic) {
893
1033
  const style = getComputedStyle(element);
894
1034
  if ((style.backgroundImage.match(/url\(|gradient\(/g) ?? []).length !== 1) return null;
@@ -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.11.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",