pour-engine 1.14.1 → 1.16.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.14.1',
9
+ version: '1.16.0',
10
10
  },
11
11
  };
@@ -479,8 +479,9 @@ function pseudoRect(host, style) {
479
479
  * on the pill, not on the track colour both other paths report.
480
480
  *
481
481
  * Returns { color } (opaque or translucent paint over the point),
482
- * 'unresolved' when something paints there that flat colour math can't
483
- * follow, or null when no pseudo of this host paints over the point.
482
+ * or null when no pseudo of this host paints over the point. Paint that
483
+ * can't be placed becomes a { film } entry — an upper bound on its alpha —
484
+ * so callers can bracket its effect instead of giving up on the element.
484
485
  */
485
486
  /** The paint a host's pseudo-elements contribute, resolved once per audit.
486
487
  * Two extra getComputedStyle calls per host would otherwise land on every
@@ -495,7 +496,8 @@ function pseudoLayers(host) {
495
496
  for (const which of ['::before', '::after']) {
496
497
  const style = getComputedStyle(host, which);
497
498
  if (style.content === 'none' || style.display === 'none' || style.visibility === 'hidden') continue;
498
- const paints = style.backgroundImage !== 'none' || (parseColor(style.backgroundColor)?.a ?? 0) > 0;
499
+ const color = parseColor(style.backgroundColor);
500
+ const paints = style.backgroundImage !== 'none' || (color?.a ?? 0) > 0;
499
501
  if (!paints) continue;
500
502
  // In-flow pseudos (the overwhelming majority: bullets, icons, rules,
501
503
  // badges) occupy their own box in the content flow beside the text
@@ -503,19 +505,34 @@ function pseudoLayers(host) {
503
505
  // backdrop, so everything else is simply not a layer here — treating
504
506
  // them as unknowable would abstain on half the web.
505
507
  if (style.position !== 'absolute' && style.position !== 'fixed') continue;
508
+ const opacity = parseFloat(style.opacity);
509
+ const opacityFactor = Number.isFinite(opacity) ? opacity : 1;
506
510
  const rect = style.position === 'absolute' ? pseudoRect(host, style) : null;
507
- // Paint we can't place can't be ruled in or out — say so rather than
508
- // silently leaving it out of the stack.
509
- if (!rect) { layers = 'unresolved'; break; }
511
+ if (!rect) {
512
+ // Paint we can't place still can't paint MORE than its alpha allows:
513
+ // whatever colour it is and wherever it sits, it moves any channel
514
+ // beneath it by at most alpha × 255. Record that bound as a "film"
515
+ // so callers can bracket the verdict — a 3.5%-opacity grain overlay
516
+ // (position: fixed, the common full-page-texture pattern) must not
517
+ // send every element on the page to a human.
518
+ const alphaBound = Math.min(1, opacityFactor * (style.backgroundImage !== 'none' ? 1 : color.a));
519
+ if (alphaBound > 0) layers.push({ film: alphaBound });
520
+ continue;
521
+ }
510
522
  if (rect.empty) continue; // placed, and covers nothing
511
- let color = parseColor(style.backgroundColor);
512
- const opacity = parseFloat(style.opacity);
513
- if (color && Number.isFinite(opacity) && opacity < 1) color = { ...color, a: color.a * opacity };
523
+ let layerColor = color;
524
+ if (layerColor && opacityFactor < 1) layerColor = { ...layerColor, a: layerColor.a * opacityFactor };
514
525
  // Gradient and image paint rides along as CSS so callers can sample it,
515
526
  // the same way they sample an element's background-image — a gradient
516
527
  // scrim over a card headline is a bracketable backdrop, not a mystery.
528
+ // Repeat/size/position ride along too: a no-repeat url() paints its
529
+ // intrinsic rect inside this box, not the whole of it, and only the
530
+ // caller (which knows where the text is) can rule on that geometry.
517
531
  const imageCss = style.backgroundImage !== 'none' ? style.backgroundImage : null;
518
- layers.push({ rect, color, imageCss });
532
+ const imageMeta = imageCss
533
+ ? { repeat: style.backgroundRepeat, size: style.backgroundSize, position: style.backgroundPosition }
534
+ : null;
535
+ layers.push({ rect, color: layerColor, imageCss, imageMeta });
519
536
  }
520
537
  pseudoCache.set(host, layers);
521
538
  return layers;
@@ -539,28 +556,78 @@ function pseudoLayers(host) {
539
556
  *
540
557
  * Returns { color } (opaque, or translucent paint to composite over the
541
558
  * walked background), { image } for gradient/image paint the caller should
542
- * sample, 'unresolved' when paint in the chain can't be placed, or null when
543
- * no pseudo covers the text.
559
+ * sample, or null when no pseudo covers the text. Whenever the chain also
560
+ * holds paint that CAN'T be placed, the result carries `film`: an upper
561
+ * bound on that paint's alpha, accumulated across every unplaceable layer.
562
+ * The caller brackets the verdict against it — a film of alpha a moves any
563
+ * channel by at most a × 255 whether it sits behind the text or over it,
564
+ * and paint hidden beneath an opaque layer moves nothing, so the bound
565
+ * holds regardless of paint order.
544
566
  */
545
567
  export function pseudoBackdropForText(element) {
546
568
  const point = textSamplePoint(element);
547
569
  if (!point) return null;
548
570
  let acc = null;
571
+ let image = null;
572
+ let settled = false; // an opaque colour or an image hit: deeper layers are covered
573
+ let film = 0;
549
574
  for (let node = element; node && node.nodeType === 1; node = node.parentElement ?? node.getRootNode()?.host) {
550
- const layers = pseudoLayers(node);
551
- // Paint we can't place can't be ruled in or out, in either direction.
552
- if (layers === 'unresolved') return 'unresolved';
553
- for (const { rect, color, imageCss } of layers) {
575
+ for (const layer of pseudoLayers(node)) {
576
+ // Films accumulate over the WHOLE chain — even past an opaque hit,
577
+ // because an ancestor's ::after can paint above a descendant's pill.
578
+ if (layer.film) { film = 1 - (1 - film) * (1 - layer.film); continue; }
579
+ if (settled) continue;
580
+ const { rect, color, imageCss } = layer;
554
581
  if (point.x < rect.left || point.x > rect.right || point.y < rect.top || point.y > rect.bottom) continue;
555
582
  // Gradient/image paint is bracketable by sampling, so hand it to the
556
- // caller rather than giving up on the whole element.
557
- if (imageCss) return { image: { css: imageCss, element: node } };
583
+ // caller rather than giving up on the whole element. The pseudo's box
584
+ // and image geometry travel with it: covering the sample point is a
585
+ // fact about the BOX, and a no-repeat icon may still paint nowhere
586
+ // near the text.
587
+ if (imageCss) { image = { css: imageCss, element: node, box: rect, meta: layer.imageMeta }; settled = true; continue; }
558
588
  if (!color || color.a === 0) continue;
559
589
  acc = acc ? composite(acc, color) : color;
560
- if (acc.a >= 1) return { color: acc };
590
+ if (acc.a >= 1) settled = true;
561
591
  }
562
592
  }
563
- return acc ? { color: acc } : null;
593
+ if (image) return film > 0 ? { image, film } : { image };
594
+ if (acc) return film > 0 ? { color: acc, film } : { color: acc };
595
+ return film > 0 ? { film } : null;
596
+ }
597
+
598
+ /**
599
+ * The range the contrast ratio can occupy once an unplaceable translucent
600
+ * layer (a "film" of alpha ≤ film) may be painting somewhere in the stack.
601
+ *
602
+ * Two physical scenarios bound everything: the film behind the glyphs
603
+ * (shifting only the background) or over the whole element (shifting text
604
+ * and background alike, as t3-style full-page grain overlays do). Within
605
+ * each, luminance is monotone per colour channel and the ratio's derivative
606
+ * carries one sign across channels, so the extremes sit at an all-black or
607
+ * all-white film — five evaluations bound the true range.
608
+ *
609
+ * `crossed` flags the one case the endpoints can't bound from below: a film
610
+ * behind the text whose extremes land the background's luminance on OPPOSITE
611
+ * sides of the text's, where an intermediate colour collapses the ratio
612
+ * toward 1:1. Callers must not assert a pass when it is set; a fail needs
613
+ * only `max`, which the endpoints do bound.
614
+ */
615
+ export function filmedContrastBounds(foreground, background, film) {
616
+ const ratios = [contrastRatio(foreground, background)];
617
+ const textLum = luminance(foreground);
618
+ const sides = [];
619
+ for (const channel of [0, 255]) {
620
+ const paint = { r: channel, g: channel, b: channel, a: film };
621
+ const shiftedBackground = composite(paint, background);
622
+ ratios.push(contrastRatio(foreground, shiftedBackground)); // film behind the text
623
+ ratios.push(contrastRatio(composite(paint, foreground), shiftedBackground)); // film over text and backdrop alike
624
+ sides.push(Math.sign(textLum - luminance(shiftedBackground)));
625
+ }
626
+ return {
627
+ min: Math.min(...ratios),
628
+ max: Math.max(...ratios),
629
+ crossed: sides[0] !== sides[1],
630
+ };
564
631
  }
565
632
 
566
633
  /** A layer qualifies as a scrim over `element` when it is translucent paint
@@ -887,7 +954,13 @@ export function imageLuminanceRange(url, overlays = []) {
887
954
  let minColor = null;
888
955
  let maxColor = null;
889
956
  let opaquePixels = 0;
957
+ let alphaSeen = false;
890
958
  for (let i = 0; i < data.length; i += 4) {
959
+ // Any see-through pixel means part of this layer shows whatever is
960
+ // painted beneath it — the sampled range alone then describes only
961
+ // the ink, not the backdrop the eye meets. Callers widen the range
962
+ // with the underlying paint (rangeWithBackdrop) or abstain.
963
+ if (data[i + 3] < 255) alphaSeen = true;
891
964
  if (data[i + 3] < 128) continue; // mostly-transparent pixels reveal what's beneath — unknowable
892
965
  opaquePixels += 1;
893
966
  // Composite each pixel through anything painted between the image
@@ -915,7 +988,7 @@ export function imageLuminanceRange(url, overlays = []) {
915
988
  return;
916
989
  }
917
990
  resolve(min <= max
918
- ? { min, max, minColor, maxColor, width: img.naturalWidth, height: img.naturalHeight }
991
+ ? { min, max, minColor, maxColor, hasAlpha: alphaSeen, width: img.naturalWidth, height: img.naturalHeight }
919
992
  : null);
920
993
  } catch {
921
994
  resolve(null); // tainted canvas: cross-origin image without CORS headers
@@ -1032,17 +1105,34 @@ function splitLayers(value) {
1032
1105
  export function backgroundImagePaintRect(element, intrinsic) {
1033
1106
  const style = getComputedStyle(element);
1034
1107
  if ((style.backgroundImage.match(/url\(|gradient\(/g) ?? []).length !== 1) return null;
1035
- if (style.backgroundRepeat !== 'no-repeat') return null; // may tile under the text
1036
- if (style.backgroundSize === 'cover' || style.backgroundSize === 'contain') return null;
1037
1108
  const box = element.getBoundingClientRect();
1109
+ return imagePaintRectInBox(
1110
+ { left: box.left, top: box.top, right: box.right, bottom: box.bottom },
1111
+ { repeat: style.backgroundRepeat, size: style.backgroundSize, position: style.backgroundPosition },
1112
+ intrinsic,
1113
+ );
1114
+ }
1115
+
1116
+ /**
1117
+ * The same paint-rect math for any painting box a caller already has —
1118
+ * above all a pseudo-element's resolved box, which no element rect can
1119
+ * describe. Returns null when the geometry is not statically knowable
1120
+ * (tiling, cover/contain, keyword positions).
1121
+ */
1122
+ export function imagePaintRectInBox(box, meta, intrinsic) {
1123
+ if (!box || !meta) return null;
1124
+ if (meta.repeat !== 'no-repeat') return null; // may tile under the text
1125
+ if (meta.size === 'cover' || meta.size === 'contain') return null;
1126
+ const boxWidth = box.right - box.left;
1127
+ const boxHeight = box.bottom - box.top;
1038
1128
  const dimension = (value, total, auto) => {
1039
1129
  if (value?.endsWith('px')) return parseFloat(value);
1040
1130
  if (value?.endsWith('%')) return (parseFloat(value) / 100) * total;
1041
1131
  return auto;
1042
1132
  };
1043
- const size = style.backgroundSize.split(' ');
1044
- const width = dimension(size[0], box.width, intrinsic?.width);
1045
- const height = dimension(size[1] ?? size[0], box.height, intrinsic?.height);
1133
+ const size = meta.size.split(' ');
1134
+ const width = dimension(size[0], boxWidth, intrinsic?.width);
1135
+ const height = dimension(size[1] ?? size[0], boxHeight, intrinsic?.height);
1046
1136
  if (!Number.isFinite(width) || !Number.isFinite(height)) return null;
1047
1137
  // position N% aligns the image's N% point with the container's N% point.
1048
1138
  // Anything else (calc() from three-value syntax, keywords) is not
@@ -1053,15 +1143,41 @@ export function backgroundImagePaintRect(element, intrinsic) {
1053
1143
  if (value?.endsWith('px')) return parseFloat(value);
1054
1144
  return null;
1055
1145
  };
1056
- const pos = style.backgroundPosition.split(' ');
1057
- const offsetX = offset(pos[0], box.width, width);
1058
- const offsetY = offset(pos[1] ?? '50%', box.height, height);
1146
+ const pos = meta.position.split(' ');
1147
+ const offsetX = offset(pos[0], boxWidth, width);
1148
+ const offsetY = offset(pos[1] ?? '50%', boxHeight, height);
1059
1149
  if (offsetX === null || offsetY === null || Number.isNaN(offsetX) || Number.isNaN(offsetY)) return null;
1060
1150
  const left = box.left + offsetX;
1061
1151
  const top = box.top + offsetY;
1062
1152
  return { left, top, right: left + width, bottom: top + height };
1063
1153
  }
1064
1154
 
1155
+ /**
1156
+ * Widen a sampled luminance range with the layer painted beneath the image,
1157
+ * for images that do not cover their box with opaque pixels (hasAlpha).
1158
+ * A semi-transparent pixel blends per channel between its own colour and
1159
+ * the underlying paint, and luminance is monotone in each channel, so a
1160
+ * range extended with the underlying colour bounds every blend the eye can
1161
+ * meet. Returns null when the underlying paint is unknown or itself
1162
+ * see-through: no bound exists then, and the caller must not assert either
1163
+ * verdict from the ink pixels alone — a mostly-transparent illustration
1164
+ * sampled dark was asserting failures for text that really sat on the white
1165
+ * showing through it.
1166
+ */
1167
+ export function rangeWithBackdrop(range, under, overlays = []) {
1168
+ if (!range || !range.hasAlpha) return range;
1169
+ if (!under || under.a < 1) return null;
1170
+ const shown = overlays.length ? applyOverlays(under, overlays) : under;
1171
+ const underLum = luminance(shown);
1172
+ return {
1173
+ ...range,
1174
+ min: Math.min(range.min, underLum),
1175
+ max: Math.max(range.max, underLum),
1176
+ minColor: underLum < range.min ? shown : range.minColor,
1177
+ maxColor: underLum > range.max ? shown : range.maxColor,
1178
+ };
1179
+ }
1180
+
1065
1181
  /**
1066
1182
  * Luminance range of a CSS gradient, from its colour stops. Interpolated
1067
1183
  * colours stay close enough to their stops' luminances to bracket.
package/engine/lib/dom.js CHANGED
@@ -106,6 +106,17 @@ export function htmlSnippet(element, maxLength = 300) {
106
106
  return html.length > maxLength ? `${html.slice(0, maxLength)}…` : html;
107
107
  }
108
108
 
109
+ /** True when the document renders embedded inside another page (iframe or
110
+ * frame). WCAG defines a "Web page" as a NON-embedded resource, so the
111
+ * page-level criteria (2.4.2 Page Titled, 2.4.1 Bypass Blocks) judge only
112
+ * the document the user actually opened: an embedded frame owes an
113
+ * accessible name under 4.1.2, not its own <title> or skip link. The
114
+ * reference comparison is safe cross-origin; frameElement is not. */
115
+ export function isEmbeddedDocument(doc) {
116
+ const win = doc?.defaultView;
117
+ return !!(win && win.top && win !== win.top);
118
+ }
119
+
109
120
  /** The element's own (not descendant-element) text, trimmed. */
110
121
  export function ownText(element) {
111
122
  return [...element.childNodes]
@@ -61,10 +61,20 @@ export default {
61
61
  // Only NEW clipping counts: content already truncated by design
62
62
  // (ellipsis patterns) is judged as-authored, not under the probe.
63
63
  if (before[i] || !after[i]) return { status: 'pass' };
64
+ // Review, not fail: the probe proves the box OVERFLOWS under the
65
+ // override, not that glyphs are lost. The genuine violation is a
66
+ // fixed-height overflow-hidden container that doesn't grow with its
67
+ // text, hiding whole lines (measured live: a 2-line card clamped at
68
+ // 37px whose text needs 61px spaced — the last line vanishes). But
69
+ // the same scroll-size delta also fires on benign overflow: the
70
+ // 0.12em letter-spacing lands AFTER the final glyph, so a snug box
71
+ // can overflow by trailing empty spacing that hides no ink, and a
72
+ // clamp pattern that swaps hidden words for an ellipsis is arguably
73
+ // truncation-by-design. Proving ink loss would need per-glyph rects
74
+ // against the clip box under the probe; until then, a human call.
64
75
  return {
65
- status: 'fail',
66
- message: 'With the WCAG text-spacing overrides applied (line height 1.5, letter/word spacing bumps), text in this element gets cut off — users who need wider spacing lose content.',
67
- fix: 'Let the container grow (min-height instead of height, avoid overflow:hidden on text), so spacing changes reflow instead of clip.',
76
+ status: 'incomplete',
77
+ message: 'With the WCAG text-spacing overrides applied (line height 1.5, letter/word spacing bumps), this container overflows instead of growing — if that hides text rather than empty trailing spacing, users who need wider spacing lose content. Check with the overrides applied; to be safe, let the container grow (min-height instead of height, avoid overflow:hidden on text).',
68
78
  };
69
79
  });
70
80
  },
@@ -1,10 +1,11 @@
1
1
  // WCAG SC 1.4.3 Contrast (Minimum) (Level AA)
2
2
  import {
3
3
  parseColor, contrastRatio, composite, effectiveBackground, backgroundObscured,
4
- backgroundImageSource, backgroundImagePaintRect, imageLuminanceRange, gradientLuminanceRange,
5
- rangeVerdict, isLargeText, cumulativeOpacity, opacityAnimating, restingOpacity, mediaRects,
4
+ backgroundImageSource, backgroundImagePaintRect, imagePaintRectInBox, imageLuminanceRange,
5
+ gradientLuminanceRange, rangeVerdict, rangeWithBackdrop, isLargeText, cumulativeOpacity,
6
+ opacityAnimating, restingOpacity, mediaRects,
6
7
  paintedBackdrop, opaquePanelRects, viewportVeil, textShadowHalo, textShadowNegligible,
7
- pseudoBackdropForText, backgroundColorSource, scrimPaint, applyOverlays,
8
+ pseudoBackdropForText, filmedContrastBounds, backgroundColorSource, scrimPaint, applyOverlays,
8
9
  } from '../../lib/contrast.js';
9
10
 
10
11
  /** Display a ratio truncated (never rounded up): 4.495 must read "4.49",
@@ -165,7 +166,7 @@ function verdictFromRange(range, foreground, required, what) {
165
166
  */
166
167
  function unaccountedScrim(element, imageCarrier) {
167
168
  const pseudo = pseudoBackdropForText(element);
168
- if (pseudo && pseudo !== 'unresolved' && pseudo.color?.a > 0) return true;
169
+ if (pseudo?.color?.a > 0) return true;
169
170
  const doc = element.ownerDocument;
170
171
  if (typeof doc.elementsFromPoint !== 'function') return false;
171
172
  const rect = element.getBoundingClientRect();
@@ -187,11 +188,29 @@ function unaccountedScrim(element, imageCarrier) {
187
188
  * changing it, so the callers step over it rather than reporting on it. */
188
189
  const PAINTS_NOTHING = Symbol('fully transparent image layer');
189
190
 
191
+ /** The flat paint directly beneath a carrier's background-image: the
192
+ * carrier's own background-color composited over whatever resolves behind
193
+ * the carrier. This is what shows through the image's transparent pixels.
194
+ * Null when it is itself unknowable (another image or an unresolvable
195
+ * chain behind) — the sampling then has no floor to extend its range with. */
196
+ function paintUnderImage(carrier) {
197
+ const color = parseColor(getComputedStyle(carrier).backgroundColor);
198
+ if (color && color.a >= 1) return color;
199
+ const parent = carrier.parentElement ?? carrier.getRootNode()?.host ?? null;
200
+ const behind = parent ? effectiveBackground(parent) : null;
201
+ if (!behind) return null;
202
+ return color && color.a > 0 ? composite(color, behind) : behind;
203
+ }
204
+
190
205
  // Bracket the verdict by sampling what's actually painted: image pixels
191
206
  // (same-origin/CORS only) or gradient colour stops. Returns an outcome,
192
207
  // PAINTS_NOTHING, or null when nothing here can be sampled at all (stacked
193
208
  // layers, translucent gradient stops) and the caller must say so itself.
194
- async function sampledVerdict(source, foreground, required, doc, overlays = []) {
209
+ // `under` is the colour painted beneath the sampled layer, when the caller
210
+ // knows it: an image with see-through pixels shows that colour wherever the
211
+ // ink is absent, so the judged range must include it — and without it no
212
+ // verdict can be asserted from such an image at all.
213
+ async function sampledVerdict(source, foreground, required, doc, overlays = [], under = null) {
195
214
  if (!source) return null;
196
215
  let range = null;
197
216
  let what = 'image';
@@ -216,6 +235,19 @@ async function sampledVerdict(source, foreground, required, doc, overlays = [])
216
235
  // Checked before the text's own alpha: there is no blend to reason about
217
236
  // when the layer paints nothing.
218
237
  if (range?.transparent) return PAINTS_NOTHING;
238
+ // See-through pixels reveal the layer beneath: widen the range with it
239
+ // when it is known; when it isn't, the ink pixels alone prove nothing —
240
+ // a mostly-transparent illustration sampled dark was failing text that
241
+ // really sat on the white showing through it.
242
+ if (range?.hasAlpha) {
243
+ range = rangeWithBackdrop(range, under, overlays);
244
+ if (!range) {
245
+ return {
246
+ status: 'incomplete',
247
+ message: `The ${what} behind this text has transparent regions, so parts of the text sit on whatever is painted beneath it, and that layer could not be resolved — check the contrast by eye.`,
248
+ };
249
+ }
250
+ }
219
251
  // Translucent text takes its presented colour from the very pixels
220
252
  // beneath it, so a luminance range of the backdrop cannot be read straight
221
253
  // off: dimmed white over a dark photo reads mid-grey, not white.
@@ -270,11 +302,16 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
270
302
  tags,
271
303
  help,
272
304
  helpUrl,
273
- // div included deliberately: real-world layouts put lots of text in bare
274
- // divs; the own-text check below keeps wrapper divs out of the results.
305
+ // Every element except the ones whose text is judged elsewhere or isn't
306
+ // CSS-colour text at all: a tag allow-list silently skipped visible text
307
+ // in anything it forgot (form, center, font, custom elements — Hacker
308
+ // News writes "Search:" directly inside <form>), and the own-text check
309
+ // below keeps the empty wrappers out of the results either way. Form
310
+ // controls stay out because control-contrast judges their text; SVG/MathML
311
+ // stay out because their glyphs are painted by fill, not color.
275
312
  selector:
276
- 'p, span, a, li, td, th, dt, dd, h1, h2, h3, h4, h5, h6, label, button, legend, caption, ' +
277
- 'figcaption, div, blockquote, small, strong, em, b, i, code, pre, time, cite, q, summary',
313
+ '*:not(script):not(style):not(noscript):not(title):not(option):not(optgroup)' +
314
+ ':not(input):not(textarea):not(select):not(svg):not(svg *):not(math):not(math *)',
278
315
  visibility: 'visual', // contrast is seen by sighted users even in aria-hidden content
279
316
  async evaluate(element, { ownText }) {
280
317
  if (!ownText(element)) return { status: 'pass' }; // no text of its own to judge
@@ -352,7 +389,8 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
352
389
  // imageSource.overlays are the translucent layers painted between the
353
390
  // image and this text (hero scrims, tint panels). Judging the raw
354
391
  // image pixels would describe a page the user never sees.
355
- let sampled = await sampledVerdict(imageSource, foreground, required, doc, imageSource.overlays);
392
+ let sampled = await sampledVerdict(imageSource, foreground, required, doc, imageSource.overlays,
393
+ paintUnderImage(imageSource.element));
356
394
  // A layer with no opaque pixels changes nothing about what is behind
357
395
  // the glyphs, so it must not block the verdict: fall through and
358
396
  // judge the background that actually paints.
@@ -430,12 +468,52 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
430
468
  // wins. Geometry-only, so it settles offscreen text too, where the
431
469
  // hit-test is blind and the walk would otherwise assert the track colour
432
470
  // of a control the text visibly is not sitting on.
433
- const pseudoBack = pseudoBackdropForText(element);
434
- if (pseudoBack === 'unresolved') {
435
- return {
436
- status: 'incomplete',
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.',
438
- };
471
+ //
472
+ // Unplaceable pseudo paint no longer abstains on sight: it arrives as
473
+ // `film`, an upper bound on its alpha, and the flat-colour verdict below
474
+ // is bracketed against it — asserted only when black and white films,
475
+ // behind the text or over it, all land the same side of the threshold.
476
+ // A 3.5%-opacity fixed grain overlay (the full-page texture pattern)
477
+ // otherwise sent every element on the page to the review lane, and a
478
+ // genuine 4.1:1 failure drowned among 163 siblings that were fine.
479
+ const pseudoResolved = pseudoBackdropForText(element);
480
+ const film = pseudoResolved?.film ?? 0;
481
+ let pseudoBack = pseudoResolved?.color || pseudoResolved?.image ? pseudoResolved : null;
482
+ // The pseudo's BOX covering the text says nothing about its IMAGE doing
483
+ // so: a no-repeat url() paints its intrinsic rect inside that box (a
484
+ // menu item's ::before arrow icon in a box that spans the whole item).
485
+ // When that rect is computable and provably misses every text box, the
486
+ // icon sits beside the text, not behind it — the layer is not a
487
+ // backdrop at all. And an icon-sized image that IS under a text corner
488
+ // may flag for eyes, never assert: the same discipline the element
489
+ // background-image path has always applied.
490
+ let pseudoIconScale = false;
491
+ if (pseudoBack?.image?.meta && pseudoBack.image.box) {
492
+ const pseudoUrl = pseudoBack.image.css.match(/url\(["']?(.*?)["']?\)/)?.[1];
493
+ if (pseudoUrl && pseudoBack.image.meta.repeat === 'no-repeat') {
494
+ let pseudoIntrinsic = null;
495
+ try { pseudoIntrinsic = await imageLuminanceRange(new URL(pseudoUrl, doc.baseURI).href); } catch { /* unknowable */ }
496
+ const dimensionless = !pseudoIntrinsic || !pseudoIntrinsic.width
497
+ || (pseudoIntrinsic.width === 300 && pseudoIntrinsic.height === 150);
498
+ const paint = imagePaintRectInBox(pseudoBack.image.box, pseudoBack.image.meta, dimensionless ? null : pseudoIntrinsic);
499
+ if (paint && !paint.empty && !textIntersects(element, paint)) {
500
+ pseudoBack = null; // paints clear of the glyphs — judge what's beneath
501
+ } else {
502
+ pseudoIconScale = dimensionless
503
+ || (pseudoIntrinsic.width <= 32 && pseudoIntrinsic.height <= 32);
504
+ }
505
+ }
506
+ }
507
+ const filmIncomplete = {
508
+ status: 'incomplete',
509
+ 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.',
510
+ };
511
+ // Sampled pixels, veils and text-shadow halos can't be film-bracketed
512
+ // (the film may sit between the sample and the glyphs): those verdicts
513
+ // keep the human call they had when any unplaceable paint abstained.
514
+ if (film > 0 && (pseudoBack?.image || painted?.image || painted === 'unresolved'
515
+ || veilPaint || (style.textShadow && style.textShadow !== 'none'))) {
516
+ return filmIncomplete;
439
517
  }
440
518
  // An image backdrop under a veil would have to be sampled a second time,
441
519
  // dimmed, to answer whether the two states agree. Not worth a second pass
@@ -445,10 +523,21 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
445
523
  // Gradient/image paint from a pseudo-element: bracket it by sampling,
446
524
  // exactly as an element's own background-image is bracketed.
447
525
  if (pseudoBack?.image) {
448
- const sampled = await sampledVerdict(pseudoBack.image, foreground, required, doc);
526
+ // Beneath the pseudo's paint sits its own host's background — a
527
+ // ::before draws above the host's background and below everything
528
+ // else, so the host's resolved colour is what shows through any
529
+ // transparent pixels the image has.
530
+ const sampled = await sampledVerdict(pseudoBack.image, foreground, required, doc, [],
531
+ effectiveBackground(pseudoBack.image.element));
449
532
  // PAINTS_NOTHING: the pseudo-element's image is fully transparent, so
450
533
  // it never displaced the background the walk resolved.
451
534
  if (sampled !== PAINTS_NOTHING) {
535
+ if (sampled?.status === 'fail' && pseudoIconScale) {
536
+ return {
537
+ status: 'incomplete',
538
+ message: 'A small icon is painted by a pseudo-element on this element — if it sits beside the text rather than under it, judge the contrast against the plain background by eye.',
539
+ };
540
+ }
452
541
  return sampled ?? {
453
542
  status: 'incomplete',
454
543
  message: 'A pseudo-element paints an image or gradient behind this text, so its real background isn’t the computed colour — contrast must be checked by eye.',
@@ -515,6 +604,16 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
515
604
  // gradient canvas under its hero heading is exactly this shape), so
516
605
  // even verified verdicts must be bracketed against them.
517
606
  const backdropHazard = (direction, blindOnly) => {
607
+ // An element's OWN opaque background sits directly beneath its own
608
+ // text: no non-descendant layer can paint between the two, and
609
+ // descendant media is already excluded below — so however the page
610
+ // stacks around it, the resolved colour is the presented one. Without
611
+ // this, every below-the-fold category chip painted over a card
612
+ // thumbnail (white-on-red at a flat 3.99:1) sat in the review lane
613
+ // because the offscreen hit-test could not verify what geometry
614
+ // already proves.
615
+ const ownPaint = parseColor(style.backgroundColor);
616
+ if (ownPaint && ownPaint.a >= 1) return null;
518
617
  // One box read for the element, then pure rect math per candidate —
519
618
  // this runs for every unverified element on the page.
520
619
  const box = element.getBoundingClientRect();
@@ -545,6 +644,17 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
545
644
  };
546
645
 
547
646
  const ratio = contrastRatio(foreground, background);
647
+ // Bracket the verdict against any unplaceable pseudo paint: only when
648
+ // every colour and placement the film could take leaves the ratio on one
649
+ // side of the threshold is the verdict provable. A 3.5% grain film can
650
+ // never rescue 4.1:1, so that fails outright; a 50% veil could rescue or
651
+ // ruin anything, so it stays the human call it always was.
652
+ if (film > 0) {
653
+ const bounds = filmedContrastBounds(foreground, background, film);
654
+ const decided = (bounds.min >= required) === (bounds.max >= required)
655
+ && !(bounds.crossed && bounds.min >= required);
656
+ if (!decided) return filmIncomplete;
657
+ }
548
658
  // The dimmed state, when a veil is up: it paints over the glyphs and
549
659
  // their background alike, so both composite through the same layers.
550
660
  // Dimming is not monotonic on ratio (it can rescue or ruin a pairing),
@@ -1,4 +1,6 @@
1
1
  // WCAG SC 1.4.4 Resize Text (Level AA)
2
+ import { isEmbeddedDocument } from '../../lib/dom.js';
3
+
2
4
  export default {
3
5
  id: 'meta-viewport',
4
6
  impact: 'critical',
@@ -8,6 +10,10 @@ export default {
8
10
  selector: 'meta[name="viewport"]',
9
11
  visibleOnly: false,
10
12
  evaluate(element) {
13
+ // Browsers read the viewport meta of the TOP document only; one inside
14
+ // an embedded frame is ignored wholesale, so it cannot stop anyone
15
+ // zooming and must not be reported as if it did.
16
+ if (isEmbeddedDocument(element.ownerDocument)) return { status: 'pass' };
11
17
  const content = element.getAttribute('content') ?? '';
12
18
  const disablesZoom = /user-scalable\s*=\s*(no|0)/i.test(content);
13
19
  const maxScale = content.match(/maximum-scale\s*=\s*([\d.]+)/i);
@@ -1,5 +1,5 @@
1
1
  // WCAG SC 2.4.1 Bypass Blocks (Level A)
2
- import { collectRoots } from '../../lib/dom.js';
2
+ import { collectRoots, isEmbeddedDocument } from '../../lib/dom.js';
3
3
 
4
4
  export default {
5
5
  id: 'bypass-blocks',
@@ -10,6 +10,11 @@ export default {
10
10
  selector: 'html',
11
11
  visibleOnly: false,
12
12
  evaluate(element) {
13
+ // 2.4.1 governs blocks "repeated on multiple Web pages", and a WCAG
14
+ // Web page is a non-embedded resource: an embedded frame's document
15
+ // owes no skip link of its own (the frame's title is itself the bypass
16
+ // the spec names in H64).
17
+ if (isEmbeddedDocument(element.ownerDocument)) return { status: 'pass' };
13
18
  // 2.4.1's sufficient techniques: a skip link, landmarks (ARIA11), or
14
19
  // headings (H69) — any one mechanism satisfies the criterion. Modern
15
20
  // component pages keep ALL of these inside open shadow roots (the
@@ -14,6 +14,8 @@
14
14
  // Deliberately NOT judged: whether a real-looking title matches the page it
15
15
  // sits on. That needs someone who can read the page, and guessing at it
16
16
  // would produce exactly the confident-but-wrong findings this engine avoids.
17
+ import { isEmbeddedDocument } from '../../lib/dom.js';
18
+
17
19
  const PLACEHOLDER = new Set([
18
20
  'untitled document', 'untitled page', 'untitled-1', 'untitled 1',
19
21
  'new document', 'new page', 'new tab', 'no title',
@@ -37,6 +39,11 @@ export default {
37
39
  selector: 'html',
38
40
  visibleOnly: false,
39
41
  evaluate(element) {
42
+ // 2.4.2 applies to "Web pages", which the spec defines as non-embedded
43
+ // resources. A widget or ad iframe is part of the page, not a page:
44
+ // demanding a <title> of it invents failures on every embedded frame
45
+ // (its name comes from the iframe's title attribute, judged by 4.1.2).
46
+ if (isEmbeddedDocument(element.ownerDocument)) return { status: 'pass' };
40
47
  const title = element.ownerDocument.title.trim();
41
48
  if (!title) {
42
49
  return {
@@ -32,6 +32,20 @@ const TARGETS = 'a[href], button, input, select, [role="button"], [role="link"]'
32
32
  function isHiddenFromPointer(element, rect) {
33
33
  if (rect.width <= 1 || rect.height <= 1) return true;
34
34
  if (rect.right <= 0 || rect.bottom <= 0) return true; // parked above/left of the canvas
35
+ // Parked BEYOND the document's scrollable extent on the other two sides:
36
+ // the slide-in drawer pattern (basket/menu panels held at
37
+ // translateX(100%) until opened). A fixed drawer does not extend the
38
+ // document's scroll range, so no scroll position ever brings the pointer
39
+ // to it — its dozen stacked links are not pointer targets in this state,
40
+ // and they must not crowd the page's real targets either. In-flow content
41
+ // that genuinely overflows DOES extend scrollWidth/scrollHeight and keeps
42
+ // being judged.
43
+ const win = element.ownerDocument.defaultView;
44
+ const scroller = element.ownerDocument.scrollingElement ?? element.ownerDocument.documentElement;
45
+ if (win && scroller) {
46
+ if (rect.left + win.scrollX >= scroller.scrollWidth - 1) return true;
47
+ if (rect.top + win.scrollY >= scroller.scrollHeight - 1) return true;
48
+ }
35
49
  const style = getComputedStyle(element);
36
50
  if ((parseFloat(style.opacity) || 0) === 0) return true;
37
51
  if (style.clipPath !== 'none' || (style.clip !== 'auto' && style.position === 'absolute')) return true;
@@ -2,19 +2,27 @@
2
2
  // element does not support name from author. Authors MUST NOT use the
3
3
  // aria-label or aria-labelledby attributes to name the element." A label on
4
4
  // a plain div/span/p breaks that MUST, so it is a real, provable authoring
5
- // error — and browsers respond by dropping the prohibited name, which is
6
- // exactly what makes the WCAG verdict undecidable from the DOM.
5
+ // error.
7
6
  //
8
- // It is not, on its own, a 4.1.2 failure. A static text container is not a
9
- // "user interface component", and the dropped name leaves whatever name,
10
- // role and value the element legitimately has untouched. Nothing is
11
- // announced wrongly; nothing the criterion requires is provably absent.
7
+ // What browsers DO with the prohibited name was measured, not assumed
8
+ // (2026-08-09, Chromium accessibility tree + VoiceOver on a live product
9
+ // page): both expose aria-label on paragraph/generic/strong as the
10
+ // element's accessible name, and VoiceOver announces it. So the label is
11
+ // NOT reliably dropped — the earlier wording here claimed it was, and a
12
+ // user who tested with a screen reader rightly called it misleading. But
13
+ // prohibited means unguaranteed: exposure of a name the spec forbids is
14
+ // browser goodwill, varies across AT pairings, and may change without
15
+ // notice. A page can neither rely on the label being announced nor on it
16
+ // being ignored — which cuts in both directions and is exactly what makes
17
+ // the WCAG verdict undecidable from the DOM.
12
18
  //
13
- // What CAN fail is the thing underneath: if the author reached for a label
14
- // because assistive technology genuinely should announce this element — a
15
- // hand-rolled widget missing its role, a region missing its landmark — then
16
- // the name they wrote is invisible and the missing role is the defect.
17
- // Whether that is so is exactly what the DOM cannot establish.
19
+ // It is not, on its own, a 4.1.2 failure. A static text container is not a
20
+ // "user interface component", and whatever name, role and value the element
21
+ // legitimately has stays untouched. Where the label IS announced, it
22
+ // replaces the element's visible text for AT users (sometimes deliberately:
23
+ // a price written for the ear over a struck-through visual price); where it
24
+ // is ignored, users get the visible text alone. The human question is
25
+ // whether either presentation is wrong for this element.
18
26
  //
19
27
  // This rule has lived on both sides of the line. It asserted under WCAG
20
28
  // scope originally, was demoted to best-practice when an independent audit
@@ -36,7 +44,7 @@ export default {
36
44
  id: 'aria-label-misuse',
37
45
  impact: 'moderate',
38
46
  tags: ['wcag2a', 'wcag412'],
39
- help: 'aria-label on a generic element is dropped by the browser',
47
+ help: 'aria-label on a plain container is prohibited by ARIA, so its announcement is not guaranteed',
40
48
  helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
41
49
  selector: 'div[aria-label]:not([role]):not([tabindex]), span[aria-label]:not([role]):not([tabindex]), p[aria-label]:not([role]):not([tabindex])',
42
50
  evaluate(element) {
@@ -48,8 +56,8 @@ export default {
48
56
  if (element.closest(NAME_FROM_CONTENT)) return { status: 'pass' };
49
57
  return {
50
58
  status: 'incomplete',
51
- message: `aria-label="${element.getAttribute('aria-label')}" on a plain <${element.tagName.toLowerCase()}> is prohibited by ARIA and dropped by the browser, so assistive technology never sees it. Nothing is announced wrongly, but nothing is announced at all. Should this element be announced? If it plays a role worth naming — a widget, a region, a group — the missing role is the defect and this is a 4.1.2 failure. If it is just a styled container, the attribute is stray and no one is affected.`,
52
- fix: 'If the element should be announced, give it the role it is playing (a landmark, group, or widget role) so the name is legal and exposed. If it should not, remove the aria-label. Do not add a role solely to legalise the label.',
59
+ message: `aria-label="${element.getAttribute('aria-label')}" on a plain <${element.tagName.toLowerCase()}> is prohibited by ARIA: this element's role does not support naming, so although many browser and screen reader pairings announce the label today, that support is not guaranteed anywhere and can differ between assistive technologies. Where it is announced it replaces the element's visible text; where it is not, users get the visible text alone. Check both presentations read correctly, and that nothing here relies on the label being heard.`,
60
+ fix: 'If this element must reliably announce something of its own, give it the role it is playing (a landmark, group, or widget role) so the name is legal and exposure is guaranteed — or move the spoken text into visible or visually-hidden real text. If the label is stray, remove it. Do not add a role solely to legalise the label.',
53
61
  };
54
62
  },
55
63
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pour-engine",
3
- "version": "1.14.1",
3
+ "version": "1.16.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",