pour-engine 1.15.0 → 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.15.0',
9
+ version: '1.16.0',
10
10
  },
11
11
  };
@@ -525,8 +525,14 @@ function pseudoLayers(host) {
525
525
  // Gradient and image paint rides along as CSS so callers can sample it,
526
526
  // the same way they sample an element's background-image — a gradient
527
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.
528
531
  const imageCss = style.backgroundImage !== 'none' ? style.backgroundImage : null;
529
- layers.push({ rect, color: layerColor, 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 });
530
536
  }
531
537
  pseudoCache.set(host, layers);
532
538
  return layers;
@@ -574,8 +580,11 @@ export function pseudoBackdropForText(element) {
574
580
  const { rect, color, imageCss } = layer;
575
581
  if (point.x < rect.left || point.x > rect.right || point.y < rect.top || point.y > rect.bottom) continue;
576
582
  // Gradient/image paint is bracketable by sampling, so hand it to the
577
- // caller rather than giving up on the whole element.
578
- if (imageCss) { image = { css: imageCss, element: node }; settled = true; continue; }
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; }
579
588
  if (!color || color.a === 0) continue;
580
589
  acc = acc ? composite(acc, color) : color;
581
590
  if (acc.a >= 1) settled = true;
@@ -945,7 +954,13 @@ export function imageLuminanceRange(url, overlays = []) {
945
954
  let minColor = null;
946
955
  let maxColor = null;
947
956
  let opaquePixels = 0;
957
+ let alphaSeen = false;
948
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;
949
964
  if (data[i + 3] < 128) continue; // mostly-transparent pixels reveal what's beneath — unknowable
950
965
  opaquePixels += 1;
951
966
  // Composite each pixel through anything painted between the image
@@ -973,7 +988,7 @@ export function imageLuminanceRange(url, overlays = []) {
973
988
  return;
974
989
  }
975
990
  resolve(min <= max
976
- ? { min, max, minColor, maxColor, width: img.naturalWidth, height: img.naturalHeight }
991
+ ? { min, max, minColor, maxColor, hasAlpha: alphaSeen, width: img.naturalWidth, height: img.naturalHeight }
977
992
  : null);
978
993
  } catch {
979
994
  resolve(null); // tainted canvas: cross-origin image without CORS headers
@@ -1090,17 +1105,34 @@ function splitLayers(value) {
1090
1105
  export function backgroundImagePaintRect(element, intrinsic) {
1091
1106
  const style = getComputedStyle(element);
1092
1107
  if ((style.backgroundImage.match(/url\(|gradient\(/g) ?? []).length !== 1) return null;
1093
- if (style.backgroundRepeat !== 'no-repeat') return null; // may tile under the text
1094
- if (style.backgroundSize === 'cover' || style.backgroundSize === 'contain') return null;
1095
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;
1096
1128
  const dimension = (value, total, auto) => {
1097
1129
  if (value?.endsWith('px')) return parseFloat(value);
1098
1130
  if (value?.endsWith('%')) return (parseFloat(value) / 100) * total;
1099
1131
  return auto;
1100
1132
  };
1101
- const size = style.backgroundSize.split(' ');
1102
- const width = dimension(size[0], box.width, intrinsic?.width);
1103
- 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);
1104
1136
  if (!Number.isFinite(width) || !Number.isFinite(height)) return null;
1105
1137
  // position N% aligns the image's N% point with the container's N% point.
1106
1138
  // Anything else (calc() from three-value syntax, keywords) is not
@@ -1111,15 +1143,41 @@ export function backgroundImagePaintRect(element, intrinsic) {
1111
1143
  if (value?.endsWith('px')) return parseFloat(value);
1112
1144
  return null;
1113
1145
  };
1114
- const pos = style.backgroundPosition.split(' ');
1115
- const offsetX = offset(pos[0], box.width, width);
1116
- 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);
1117
1149
  if (offsetX === null || offsetY === null || Number.isNaN(offsetX) || Number.isNaN(offsetY)) return null;
1118
1150
  const left = box.left + offsetX;
1119
1151
  const top = box.top + offsetY;
1120
1152
  return { left, top, right: left + width, bottom: top + height };
1121
1153
  }
1122
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
+
1123
1181
  /**
1124
1182
  * Luminance range of a CSS gradient, from its colour stops. Interpolated
1125
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]
@@ -1,8 +1,9 @@
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
8
  pseudoBackdropForText, filmedContrastBounds, backgroundColorSource, scrimPaint, applyOverlays,
8
9
  } from '../../lib/contrast.js';
@@ -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.
@@ -440,7 +478,32 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
440
478
  // genuine 4.1:1 failure drowned among 163 siblings that were fine.
441
479
  const pseudoResolved = pseudoBackdropForText(element);
442
480
  const film = pseudoResolved?.film ?? 0;
443
- const pseudoBack = pseudoResolved?.color || pseudoResolved?.image ? pseudoResolved : null;
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
+ }
444
507
  const filmIncomplete = {
445
508
  status: 'incomplete',
446
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.',
@@ -460,10 +523,21 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
460
523
  // Gradient/image paint from a pseudo-element: bracket it by sampling,
461
524
  // exactly as an element's own background-image is bracketed.
462
525
  if (pseudoBack?.image) {
463
- 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));
464
532
  // PAINTS_NOTHING: the pseudo-element's image is fully transparent, so
465
533
  // it never displaced the background the walk resolved.
466
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
+ }
467
541
  return sampled ?? {
468
542
  status: 'incomplete',
469
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.',
@@ -530,6 +604,16 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
530
604
  // gradient canvas under its hero heading is exactly this shape), so
531
605
  // even verified verdicts must be bracketed against them.
532
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;
533
617
  // One box read for the element, then pure rect math per candidate —
534
618
  // this runs for every unverified element on the page.
535
619
  const box = element.getBoundingClientRect();
@@ -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.15.0",
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",