pour-engine 1.46.0 → 1.46.1

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.46.0',
9
+ version: '1.46.1',
10
10
  },
11
11
  };
@@ -145,6 +145,7 @@ let uncoveredEffectCache = new WeakMap();
145
145
  let blendBackdropCache = new WeakMap();
146
146
  let labelReferrersCache = new WeakMap();
147
147
  let stackingContextCache = new WeakMap();
148
+ let backgroundRulesCache = new WeakMap();
148
149
  const HAS_IMAGE = Symbol('background-image in chain');
149
150
 
150
151
  export function resetAuditCaches() {
@@ -161,6 +162,7 @@ export function resetAuditCaches() {
161
162
  blendBackdropCache = new WeakMap();
162
163
  labelReferrersCache = new WeakMap();
163
164
  stackingContextCache = new WeakMap();
165
+ backgroundRulesCache = new WeakMap();
164
166
  }
165
167
 
166
168
  /**
@@ -981,6 +983,14 @@ export function filmedContrastBounds(foreground, background, film) {
981
983
  };
982
984
  }
983
985
 
986
+ /** A layer's own opacity. 0 is a real value, not a missing one: consent
987
+ * dimmers commonly fade to opacity 0 and stay in the DOM once dismissed
988
+ * (CSS Color 4, the opacity property: 0 is fully transparent). */
989
+ const opacityOf = (style) => {
990
+ const opacity = parseFloat(style.opacity);
991
+ return Number.isNaN(opacity) ? 1 : opacity;
992
+ };
993
+
984
994
  /** A layer qualifies as a scrim over `element` when it is translucent paint
985
995
  * (or a backdrop-filter) covering most of the viewport and the whole
986
996
  * element — the shape of a modal/loading veil, not a badge or header.
@@ -994,7 +1004,7 @@ function scrimIn(layersAbove, element, win) {
994
1004
  if (layer.contains(element) || element.contains(layer)) continue;
995
1005
  const style = getComputedStyle(layer);
996
1006
  const color = parseColor(style.backgroundColor);
997
- const alpha = (color ? color.a : 0) * (parseFloat(style.opacity) || 1);
1007
+ const alpha = (color ? color.a : 0) * opacityOf(style);
998
1008
  const hasBackdropFilter = style.backdropFilter && style.backdropFilter !== 'none';
999
1009
  // Fully opaque covers simply hide the text (nothing to judge through);
1000
1010
  // near-invisible tints don't meaningfully change presented contrast.
@@ -1027,7 +1037,7 @@ export function scrimPaint(layers) {
1027
1037
  if (style.backgroundImage !== 'none') return null;
1028
1038
  const color = parseColor(style.backgroundColor);
1029
1039
  if (!color) return null;
1030
- const alpha = color.a * (parseFloat(style.opacity) || 1);
1040
+ const alpha = color.a * opacityOf(style);
1031
1041
  if (alpha <= 0) continue;
1032
1042
  paints.push({ ...color, a: alpha });
1033
1043
  }
@@ -1179,7 +1189,7 @@ export function opaquePanelRects(doc) {
1179
1189
  // hazard that scrolling cannot escape.
1180
1190
  if (!panelRectsCache.veil && (style.position === 'fixed' || style.position === 'sticky')
1181
1191
  && style.visibility !== 'hidden') {
1182
- const alpha = (color ? color.a : 0) * (parseFloat(style.opacity) || 1);
1192
+ const alpha = (color ? color.a : 0) * opacityOf(style);
1183
1193
  const hasBackdropFilter = style.backdropFilter && style.backdropFilter !== 'none';
1184
1194
  const coversViewport = rect.width >= 0.9 * win.innerWidth && rect.height >= 0.9 * win.innerHeight;
1185
1195
  if (coversViewport && (hasBackdropFilter || (alpha >= 0.15 && alpha < 1))) {
@@ -1988,6 +1998,71 @@ function rootHasFirstLineRules(root) {
1988
1998
  return has;
1989
1999
  }
1990
2000
 
2001
+ /**
2002
+ * The stylesheet rules behind lazy-loaded backgrounds, per root per audit:
2003
+ * `gates` hold background images back until a loader marks the element
2004
+ * loaded (`background-image: none` on a selector that excludes a lazy-load
2005
+ * class, as in `.section:not(.lazyloaded) { background-image: none }`), and
2006
+ * `images` give an element a background image of its own. Rules under a
2007
+ * media or support condition count only while it holds; an unreadable
2008
+ * cross-origin sheet contributes nothing.
2009
+ */
2010
+ const LAZY_EXCLUSION = /:not\(\s*\.[\w-]*lazy[\w-]*\s*\)/i;
2011
+ const PAINTS_IMAGE = /url\(|gradient\(|image-set\(|var\(/i;
2012
+ function backgroundRules(root) {
2013
+ let found = backgroundRulesCache.get(root);
2014
+ if (found) return found;
2015
+ found = { gates: [], images: [] };
2016
+ const win = (root.ownerDocument ?? root).defaultView;
2017
+ const scan = (rules) => {
2018
+ for (const rule of rules) {
2019
+ if (rule.styleSheet) {
2020
+ if (rule.media?.mediaText && !win.matchMedia(rule.media.mediaText).matches) continue;
2021
+ try { scan(rule.styleSheet.cssRules); } catch { /* unreadable import */ }
2022
+ continue;
2023
+ }
2024
+ if (rule.media) {
2025
+ if (!win.matchMedia(rule.media.mediaText).matches) continue;
2026
+ } else if (win.CSSSupportsRule && rule instanceof win.CSSSupportsRule) {
2027
+ if (!win.CSS.supports(rule.conditionText)) continue;
2028
+ }
2029
+ if (rule.selectorText && rule.style) {
2030
+ const image = rule.style.getPropertyValue('background-image').trim();
2031
+ if (image === 'none' && LAZY_EXCLUSION.test(rule.selectorText)) found.gates.push(rule.selectorText);
2032
+ else if (PAINTS_IMAGE.test(image || rule.style.getPropertyValue('background'))) found.images.push(rule.selectorText);
2033
+ }
2034
+ if (rule.cssRules) scan(rule.cssRules);
2035
+ }
2036
+ };
2037
+ for (const sheet of [...(root.styleSheets ?? []), ...(root.adoptedStyleSheets ?? [])]) {
2038
+ try { scan(sheet.cssRules); } catch { /* cross-origin */ }
2039
+ }
2040
+ backgroundRulesCache.set(root, found);
2041
+ return found;
2042
+ }
2043
+
2044
+ const matchesAny = (node, selectors) => selectors.some((selector) => {
2045
+ try { return node.matches(selector); } catch { return false; }
2046
+ });
2047
+
2048
+ /**
2049
+ * Is the background behind this text still to be lazy-loaded? True when an
2050
+ * element on its chain is given a background image by the page's styles, a
2051
+ * lazy-load gate matches it, and it computes no background image now. Page
2052
+ * builders defer section backgrounds this way until the section nears the
2053
+ * viewport, so text the visitor has not scrolled to yet is measured against
2054
+ * a placeholder they never see: the loader restores the image first.
2055
+ */
2056
+ export function lazyWithheldBackground(element) {
2057
+ for (let node = element; node?.nodeType === 1; node = flatParentOf(node)) {
2058
+ const { gates, images } = backgroundRules(node.getRootNode());
2059
+ if (!gates.length || !matchesAny(node, gates)) continue;
2060
+ if (getComputedStyle(node).backgroundImage !== 'none') continue;
2061
+ if (PAINTS_IMAGE.test(node.style?.backgroundImage ?? '') || matchesAny(node, images)) return true;
2062
+ }
2063
+ return false;
2064
+ }
2065
+
1991
2066
  /**
1992
2067
  * Colours that repaint part of this element's own text through ::first-line
1993
2068
  * or ::first-letter, when they differ from the colour the element itself
@@ -6,7 +6,7 @@ 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 } from '../../lib/contrast.js';
9
+ showRatio, asRgb, splitBackgroundLayers, backgroundLayerUrl, sampleGridFor, opacityGroupPaint, pseudoTextColors, BOLD_WEIGHT, hasPaintEffects, isolatedBlendBackdrop, labelReferrers, beneathOpaqueAncestor, lazyWithheldBackground } from '../../lib/contrast.js';
10
10
 
11
11
  /** The first url() among a background-image list's layers, or null. */
12
12
  const firstLayerUrl = (css) => splitBackgroundLayers(css ?? '').map(backgroundLayerUrl).find(Boolean) ?? null;
@@ -1508,6 +1508,16 @@ export function createContrastRule({ id, name, tags, help, helpUrl, thresholds }
1508
1508
  rule.evaluate = async (element, helpers) => {
1509
1509
  const verdict = await judge(element, helpers);
1510
1510
  if (verdict.status !== 'fail') return verdict;
1511
+ // The background this was measured against is a placeholder: the page
1512
+ // holds its background image back until a lazy loader marks it loaded,
1513
+ // which happens before the visitor scrolls it into view. A failure has
1514
+ // to be shown against the paint the visitor sees.
1515
+ if (lazyWithheldBackground(element)) {
1516
+ return {
1517
+ status: 'incomplete',
1518
+ 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
+ }
1511
1521
  // A miss on a glyph that is not words: see glyphNotWords. Every fail
1512
1522
  // path passes through here, so this is said once. A glyph still owes
1513
1523
  // 3:1 under 1.4.11 when it is needed to identify a control, so one that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pour-engine",
3
- "version": "1.46.0",
3
+ "version": "1.46.1",
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",