pour-engine 1.26.0 → 1.28.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.26.0',
9
+ version: '1.28.0',
10
10
  },
11
11
  };
@@ -130,6 +130,7 @@ let opacityAnimatorsCache = null;
130
130
  let mediaRectsCache = null;
131
131
  let panelRectsCache = null;
132
132
  let pseudoCache = new WeakMap();
133
+ let zeroClipCache = new WeakMap();
133
134
  const HAS_IMAGE = Symbol('background-image in chain');
134
135
 
135
136
  export function resetAuditCaches() {
@@ -139,6 +140,49 @@ export function resetAuditCaches() {
139
140
  mediaRectsCache = null;
140
141
  panelRectsCache = null;
141
142
  pseudoCache = new WeakMap();
143
+ zeroClipCache = new WeakMap();
144
+ }
145
+
146
+ /**
147
+ * True when the element sits inside a subtree erased by a ZERO-AREA clip —
148
+ * the sr-only recipe (clip: rect(0,0,0,0) / clip-path: inset(50%) on an
149
+ * absolutely positioned wrapper) applied to an ANCESTOR, which is where it
150
+ * usually lives: a chart's accessible data table hides the table, and the
151
+ * judged element is a cell inside it. Only provably zero-area patterns
152
+ * count: a clip rect with no width or height (any `auto` component keeps
153
+ * the element judged), or a percentage inset consuming a full axis.
154
+ * Partial clips never hide. Verdicts cache per ancestor, so a page pays
155
+ * per container, not per descendant.
156
+ */
157
+ export function inZeroClipSubtree(element) {
158
+ for (let a = element; a; a = a.parentElement) {
159
+ let hidden = zeroClipCache.get(a);
160
+ if (hidden === undefined) {
161
+ const s = getComputedStyle(a);
162
+ hidden = false;
163
+ if (s.clip !== 'auto' && s.position !== 'static') {
164
+ const m = /rect\(([^)]+)\)/.exec(s.clip);
165
+ if (m) {
166
+ const parts = m[1].split(',').map((v) => v.trim());
167
+ if (!parts.includes('auto') && parts.length === 4) {
168
+ const [t, r, b, l] = parts.map(parseFloat);
169
+ if (r - l <= 0 || b - t <= 0) hidden = true;
170
+ }
171
+ }
172
+ }
173
+ if (!hidden && s.clipPath && s.clipPath !== 'none') {
174
+ const m = /inset\(([^)]+)\)/.exec(s.clipPath);
175
+ if (m && !m[1].includes('px')) {
176
+ const parts = m[1].trim().split(/\s+/).map(parseFloat);
177
+ const [t, r = t, b = t, l = r] = parts;
178
+ if (t + b >= 100 || l + r >= 100) hidden = true;
179
+ }
180
+ }
181
+ zeroClipCache.set(a, hidden);
182
+ }
183
+ if (hidden) return true;
184
+ }
185
+ return false;
142
186
  }
143
187
 
144
188
  /**
@@ -938,17 +982,60 @@ export function textShadowNegligible(cssText, fontSize) {
938
982
  * hit-test can't reach. Open shadow roots aren't walked here; a known
939
983
  * approximation that errs toward failing (never toward a false pass).
940
984
  */
985
+ /**
986
+ * The part of an element's border box its ancestors allow it to PAINT.
987
+ * An object-fit: cover video is routinely taller than its overflow-hidden
988
+ * crop, and the spilled region is guaranteed blank — treating the raw box
989
+ * as a possible backdrop sent captions sitting 16px past a cropped video
990
+ * to review. The walk respects positioning: an absolutely positioned
991
+ * element escapes the clips of ancestors below its containing block
992
+ * (position or transform/filter establish one), and a fixed element's
993
+ * clippers are rare enough that the walk stops rather than guess — every
994
+ * uncertain path keeps the FULL box, erring toward review, never past a
995
+ * real overlap. Returns null when the clip leaves nothing paintable.
996
+ */
997
+ export function paintableRect(element, rect) {
998
+ let { left, top, right, bottom } = rect;
999
+ let mode = getComputedStyle(element).position;
1000
+ for (let a = element.parentElement; a; a = a.parentElement) {
1001
+ if (mode === 'fixed') break; // conservative: no clip assumed above
1002
+ const s = getComputedStyle(a);
1003
+ const containingBlock = s.position !== 'static' || s.transform !== 'none' || (s.filter && s.filter !== 'none');
1004
+ if (mode === 'absolute' && !containingBlock) continue; // escapes this ancestor's clip
1005
+ // Only overflow: hidden/clip cut paint scroll-independently. A
1006
+ // scrollable ancestor (auto/scroll) clips the CURRENT scroll state —
1007
+ // a carousel slide parked outside the strip swings back under the
1008
+ // user's finger, image and its overlaid caption together, so its
1009
+ // media must keep counting as a possible backdrop (BBC's promo
1010
+ // carousel sent two white-on-photo headlines to a false pass here).
1011
+ const clips = (o) => o === 'hidden' || o === 'clip';
1012
+ if (clips(s.overflowX) || clips(s.overflowY)) {
1013
+ const b = a.getBoundingClientRect();
1014
+ if (clips(s.overflowX)) { left = Math.max(left, b.left); right = Math.min(right, b.right); }
1015
+ if (clips(s.overflowY)) { top = Math.max(top, b.top); bottom = Math.min(bottom, b.bottom); }
1016
+ if (right <= left || bottom <= top) return null;
1017
+ }
1018
+ // Above a consumed ancestor the element is clipped exactly as that
1019
+ // ancestor is: continue the walk in its shoes.
1020
+ mode = s.position;
1021
+ }
1022
+ return { left, top, right, bottom, width: right - left, height: bottom - top,
1023
+ x: left, y: top };
1024
+ }
1025
+
941
1026
  export function mediaRects(doc) {
942
1027
  if (!mediaRectsCache) {
943
1028
  mediaRectsCache = [];
944
1029
  for (const el of doc.querySelectorAll('img, video, canvas, svg')) {
945
- const rect = el.getBoundingClientRect();
1030
+ // The box an ancestor clips away can never paint, so it is not a
1031
+ // backdrop hazard however far it reaches.
1032
+ const rect = paintableRect(el, el.getBoundingClientRect());
946
1033
  // Sub-icon-sized media can't plausibly serve as a text backdrop.
947
1034
  // Decorative media with pointer-events: none (animated gradient
948
1035
  // canvases, ambient video) paints under text yet never appears in
949
1036
  // elementsFromPoint stacks — flagged so callers can distrust even a
950
1037
  // hit-test-verified backdrop where such media overlaps.
951
- if (rect.width >= 8 && rect.height >= 8) {
1038
+ if (rect && rect.width >= 8 && rect.height >= 8) {
952
1039
  mediaRectsCache.push({ element: el, rect, hitTestBlind: getComputedStyle(el).pointerEvents === 'none' });
953
1040
  }
954
1041
  }
@@ -3,7 +3,7 @@ import {
3
3
  parseColor, contrastRatio, composite, effectiveBackground, backgroundObscured,
4
4
  backgroundImageSource, backgroundImagePaintRect, imagePaintRectInBox, imageLuminanceRange,
5
5
  gradientLuminanceRange, rangeVerdict, rangeWithBackdrop, isLargeText, cumulativeOpacity,
6
- opacityAnimating, restingOpacity, mediaRects,
6
+ opacityAnimating, restingOpacity, mediaRects, inZeroClipSubtree,
7
7
  paintedBackdrop, opaquePanelRects, viewportVeil, textShadowHalo, textShadowNegligible,
8
8
  pseudoBackdropForText, filmedContrastBounds, backgroundColorSource, scrimPaint, applyOverlays,
9
9
  showRatio,
@@ -138,13 +138,15 @@ function textVisuallyHidden(element, style, foreground) {
138
138
  const indent = parseFloat(style.textIndent) || 0;
139
139
  const clipped = /hidden|clip/.test(`${style.overflow} ${style.overflowX} ${style.overflowY}`)
140
140
  || style.clipPath !== 'none' || (style.clip !== 'auto' && style.position === 'absolute');
141
- // Nothing hints at hiding: skip the getBoundingClientRect layout read —
142
- // this runs for every text element on the page, and most are plainly shown.
143
- if (!clipped && Math.abs(indent) <= 1000) return false;
141
+ // Nothing on the element itself hints at hiding: the recipe may still sit
142
+ // on a WRAPPER (a chart's sr-only data table hides the table; the judged
143
+ // cell inside computes clip: auto) — the ancestor walk is cached per
144
+ // container, so this stays affordable on every text element.
145
+ if (!clipped && Math.abs(indent) <= 1000) return inZeroClipSubtree(element);
144
146
  const rect = element.getBoundingClientRect();
145
147
  if ((rect.width <= 1 || rect.height <= 1) && clipped) return true;
146
148
  if (Math.abs(indent) > rect.width && (clipped || Math.abs(indent) > 1000)) return true;
147
- return false;
149
+ return inZeroClipSubtree(element);
148
150
  }
149
151
 
150
152
  // Turn a luminance-range bracket into a rule outcome (or null = can't tell).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pour-engine",
3
- "version": "1.26.0",
3
+ "version": "1.28.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",