pour-engine 1.42.2 → 1.42.3

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.42.2',
9
+ version: '1.42.3',
10
10
  },
11
11
  };
@@ -150,6 +150,13 @@ function visibleContentText(element, includeHidden, inLabelledBy, visited) {
150
150
  * the generated content decorative and contributes nothing, which is how
151
151
  * the same syntax silences icon glyphs. */
152
152
  function generatedContent(element, pseudo, includeHidden) {
153
+ // SVG elements generate no ::before/::after box: the computed style still
154
+ // reports the content, but nothing is painted and Chromium's accessible
155
+ // name leaves it out (measured 2026-09-13: an icon font's ::before "h" on
156
+ // the <svg> inside an icon-only button paints 0 pixels and Chromium names
157
+ // the button ""; the same rule on a <span> paints and names it "h"). Naming
158
+ // the button "h" from it hid a nameless close button on channel4.com.
159
+ if (element.namespaceURI === 'http://www.w3.org/2000/svg') return '';
153
160
  const style = getComputedStyle(element, pseudo);
154
161
  // Generated nodes follow the same hidden-content exclusion as ordinary
155
162
  // descendants. A hover label can exist in computed content while its
@@ -318,7 +318,7 @@ const transparentImages = new Set();
318
318
  /** True when every layer of this computed background-image is a url() known
319
319
  * to paint nothing — so the layer cannot change what is behind it. Any
320
320
  * gradient, or any unsampled image, makes that unknowable. */
321
- function paintsNothing(backgroundImage) {
321
+ export function paintsNothing(backgroundImage) {
322
322
  if (backgroundImage.includes('gradient(')) return false;
323
323
  const urls = [...backgroundImage.matchAll(/url\(["']?(.*?)["']?\)/g)].map((m) => m[1]);
324
324
  return urls.length > 0 && urls.every((url) => transparentImages.has(url));
@@ -585,7 +585,7 @@ export function backgroundObscured(element) {
585
585
  * hit-testing. A Range around the first non-blank text node beats the
586
586
  * element's own rect centre: wide containers with left-aligned text would
587
587
  * hit-test empty space. Falls back to the element rect. */
588
- function textSamplePoint(element) {
588
+ export function textSamplePoint(element) {
589
589
  for (const node of element.childNodes) {
590
590
  if (node.nodeType !== 3 || !node.textContent.trim()) continue;
591
591
  const range = element.ownerDocument.createRange();
package/engine/lib/dom.js CHANGED
@@ -31,6 +31,47 @@ export function flatTreeParent(node) {
31
31
  return node.assignedSlot ?? node.parentElement ?? node.getRootNode()?.host ?? null;
32
32
  }
33
33
 
34
+ /** Every element beneath `element` in the FLAT tree: light descendants,
35
+ * open shadow trees, and what slots pull in. Closed shadow roots stay
36
+ * unreachable, as everywhere in the engine. */
37
+ export function flatDescendants(element) {
38
+ const found = [];
39
+ const seen = new Set();
40
+ const visit = (scope) => {
41
+ for (const el of scope.querySelectorAll('*')) {
42
+ if (seen.has(el)) continue;
43
+ seen.add(el);
44
+ found.push(el);
45
+ if (el.shadowRoot) visit(el.shadowRoot);
46
+ if (el.tagName === 'SLOT') {
47
+ for (const assigned of el.assignedElements({ flatten: true })) {
48
+ if (!seen.has(assigned)) { seen.add(assigned); found.push(assigned); }
49
+ visit(assigned);
50
+ }
51
+ }
52
+ }
53
+ };
54
+ if (element.shadowRoot) visit(element.shadowRoot);
55
+ visit(element);
56
+ return found;
57
+ }
58
+
59
+ /** Focusable but left out of sequential (Tab) navigation: a negative
60
+ * tabindex on the element itself, or on a shadow host between it and
61
+ * `within`. HTML leaves a focus navigation scope owner "whose tabindex value
62
+ * is a negative integer" out of the tabindex-ordered focus navigation scope,
63
+ * and its scope's contents go with it (measured in Chromium 2026-09-13: Tab
64
+ * skips the <button> in an mwc-button host with tabindex="-1", while a click
65
+ * or focus() still lands on it). */
66
+ export function outOfSequentialFocus(element, within = null) {
67
+ const negative = (el) => el.hasAttribute('tabindex') && el.tabIndex < 0;
68
+ if (negative(element)) return true;
69
+ for (let node = flatTreeParent(element); node && node !== within; node = flatTreeParent(node)) {
70
+ if (node.shadowRoot && negative(node)) return true;
71
+ }
72
+ return false;
73
+ }
74
+
34
75
  /**
35
76
  * Rendered on screen — ignores aria-hidden. For purely visual rules
36
77
  * (contrast, target size): sighted users see aria-hidden content too.
@@ -1,6 +1,6 @@
1
1
  // WCAG SC 1.4.3 Contrast (Minimum) (Level AA)
2
2
  import {
3
- parseColor, contrastRatio, composite, effectiveBackground, backgroundObscured,
3
+ parseColor, contrastRatio, composite, effectiveBackground, backgroundObscured, paintsNothing, textSamplePoint,
4
4
  backgroundImageSource, backgroundImagePaintRect, imagePaintRectInBox, imageLuminanceRange,
5
5
  sampledGradientRange, gradientStops, rangeVerdict, rangeWithBackdrop, isLargeText, cumulativeOpacity,
6
6
  opacityAnimating, restingOpacity, mediaRects, inZeroClipSubtree,
@@ -263,6 +263,75 @@ function unaccountedScrim(element, imageCarrier) {
263
263
  });
264
264
  }
265
265
 
266
+ /**
267
+ * The nearest layer in the browser's hit-test stack that is NOT an ancestor
268
+ * of the text and paints between the glyphs and the ancestor carrying the
269
+ * walked background-image. The ancestor walk (backgroundImageSource) is
270
+ * structurally blind to it: react.dev lays a light card gradient
271
+ * (position: absolute; inset: 0) inside a section whose own background is a
272
+ * dark conic gradient, and the walk sampled that dark section behind dark
273
+ * text (1.18:1) while the pixels under the glyphs read 13.8:1 (2026-09-13
274
+ * verdict crawl). Returns { image, css, overlays } when such a layer paints
275
+ * an image or gradient (the real backdrop to sample, with the translucent
276
+ * ancestor colours passed on the way as overlays), { covered } when it is an
277
+ * opaque colour or a replaced element (the walked image never shows behind
278
+ * the glyphs; the colour paths below resolve it), and null when the stack
279
+ * reaches the carrier first, cannot be read, or the text is off screen.
280
+ */
281
+ function coveringLayer(element, imageCarrier) {
282
+ const doc = element.ownerDocument;
283
+ const win = doc.defaultView;
284
+ // undefined: the hit-test cannot answer (off screen, retargeted, unread);
285
+ // null: it answered, and nothing covers the walked image.
286
+ if (typeof doc.elementsFromPoint !== 'function') return undefined;
287
+ const point = textSamplePoint(element);
288
+ if (!point || point.x < 0 || point.y < 0 || point.x >= win.innerWidth || point.y >= win.innerHeight) return undefined;
289
+ const stack = doc.elementsFromPoint(point.x, point.y);
290
+ const start = stack.indexOf(element);
291
+ if (start === -1) return undefined;
292
+ const overlays = [];
293
+ for (const layer of stack.slice(start + 1)) {
294
+ if (layer === imageCarrier) return null;
295
+ if (layer.contains(element)) {
296
+ const own = parseColor(getComputedStyle(layer).backgroundColor);
297
+ if (own && own.a > 0 && own.a < 1) overlays.push(own);
298
+ continue;
299
+ }
300
+ // Paint inside a shadow tree is retargeted to its host: unknowable here.
301
+ if (layer.shadowRoot) return undefined;
302
+ if (/^(img|video|canvas|picture)$/i.test(layer.tagName)) return { covered: layer };
303
+ const style = getComputedStyle(layer);
304
+ if (style.backgroundImage !== 'none' && !paintsNothing(style.backgroundImage)) return { image: layer, css: style.backgroundImage, overlays };
305
+ const color = parseColor(style.backgroundColor);
306
+ if (color && color.a >= 1) return { covered: layer };
307
+ }
308
+ return null;
309
+ }
310
+
311
+ /**
312
+ * Scroll-independent stand-in for coveringLayer when the hit-test is blind:
313
+ * is there a positioned element inside the image carrier, not an ancestor of
314
+ * the text, painting an image or an opaque colour over the text's sample
315
+ * point? It paints above the carrier's background, and whether it lies
316
+ * behind the glyphs or over them is paint order this engine does not model,
317
+ * so the caller asks rather than asserts the walked image.
318
+ */
319
+ function positionedPaintInside(carrier, element) {
320
+ const point = textSamplePoint(element);
321
+ if (!point) return false;
322
+ for (const node of carrier.querySelectorAll('*')) {
323
+ if (node.contains(element) || element.contains(node)) continue;
324
+ const style = getComputedStyle(node);
325
+ if (style.position !== 'absolute' && style.position !== 'fixed') continue;
326
+ const paints = (style.backgroundImage !== 'none' && !paintsNothing(style.backgroundImage))
327
+ || (parseColor(style.backgroundColor)?.a ?? 0) >= 1;
328
+ if (!paints || node.checkVisibility?.({ checkOpacity: true, checkVisibilityCSS: true }) === false) continue;
329
+ const box = node.getBoundingClientRect();
330
+ if (point.x >= box.left && point.x < box.right && point.y >= box.top && point.y < box.bottom) return true;
331
+ }
332
+ return false;
333
+ }
334
+
266
335
  /** An image layer with no opaque pixels: it covers the background without
267
336
  * changing it, so the callers step over it rather than reporting on it. */
268
337
  const PAINTS_NOTHING = Symbol('fully transparent image layer');
@@ -753,7 +822,22 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
753
822
  // A background image on an ancestor: sample its pixels and bracket —
754
823
  // but only when it's actually painted under this text. Icon-in-padding
755
824
  // images (external-link arrows etc.) don't affect the text's contrast.
756
- const imageSource = backgroundImageSource(element);
825
+ let imageSource = backgroundImageSource(element);
826
+ // Where the browser's hit-test can answer, it outranks the ancestor walk:
827
+ // a non-ancestor layer painted between the text and the walked image is
828
+ // the backdrop instead (see coveringLayer). The element's own image is
829
+ // the walk's by construction and is left alone.
830
+ if (imageSource && imageSource.element !== element) {
831
+ const cover = coveringLayer(element, imageSource.element);
832
+ if (cover?.image) imageSource = { css: cover.css, element: cover.image, overlays: cover.overlays };
833
+ else if (cover) imageSource = null;
834
+ else if (cover === undefined && positionedPaintInside(imageSource.element, element)) {
835
+ return {
836
+ status: 'incomplete',
837
+ message: 'This text sits in a section with a background image or gradient, and a positioned element inside that section paints its own background where the text is. Which of the two is behind the glyphs depends on paint order, and the text is off screen where the browser cannot be asked, so check the contrast by eye.',
838
+ };
839
+ }
840
+ }
757
841
  if (imageSource) {
758
842
  if (ownMultiply) return { status: 'incomplete', message: 'This text multiplies against an image or gradient. Its contrast depends on the pixels behind each glyph; check it by eye.' };
759
843
  const { relation, intrinsic, dimensionless, isGradient, sizedSmall, paint } = await imageVsText(imageSource, element, doc);
@@ -1,5 +1,5 @@
1
1
  // WCAG SC 4.1.2 Name, Role, Value (Level A)
2
- import { isInert } from '../../lib/dom.js';
2
+ import { isInert, flatDescendants, flatTreeParent, outOfSequentialFocus } from '../../lib/dom.js';
3
3
  // Deliberately excludes bare [tabindex]: a focusable container (e.g. a
4
4
  // scrollable region with tabindex="0") holding links is correct markup.
5
5
  const INTERACTIVE =
@@ -28,8 +28,14 @@ export default {
28
28
  const NATIVE = 'a[href], button, input, select, textarea, summary, audio[controls], video[controls]';
29
29
  // Negative tabindex removes sequential focus navigation, not focusability.
30
30
  // https://html.spec.whatwg.org/multipage/interaction.html#attr-tabindex
31
- const candidates = [...element.querySelectorAll(INTERACTIVE)].filter((el) =>
32
- !el.matches(':disabled') && !isInert(el)
31
+ // The FLAT tree: a custom element's shadow tree can hold the real
32
+ // control (material.io's cookie banner: div role="button" holding an
33
+ // <mwc-button tabindex="-1"> whose shadow tree renders a <button>).
34
+ // Sequential focusability follows the host: a negative tabindex on a
35
+ // shadow host takes its whole scope out of the tab order (HTML focus
36
+ // navigation scopes), so such a control is the negative-tabindex shape.
37
+ const candidates = flatDescendants(element).filter((el) =>
38
+ el.matches?.(INTERACTIVE) && !el.matches(':disabled') && !isInert(el)
33
39
  && !(el.tagName === 'INPUT' && el.type === 'hidden') && isRendered(el)
34
40
  // aria-hidden content that Tab reaches is aria-hidden-focus's finding.
35
41
  // aria-hidden content with a negative tabindex is not, yet a click or a
@@ -38,23 +44,29 @@ export default {
38
44
  // interaction with the user or indirectly via programmatic means", so
39
45
  // it stays a candidate here.
40
46
  // https://www.w3.org/TR/wai-aria-1.2/#aria-hidden
41
- && !(el.closest('[aria-hidden="true"]') && !(el.hasAttribute('tabindex') && el.tabIndex < 0))
47
+ && !(el.closest('[aria-hidden="true"]') && !outOfSequentialFocus(el, element))
42
48
  && (el.matches(NATIVE) || el.hasAttribute('tabindex')));
43
49
  // A child requiring review must not conceal a later definite finding.
44
- const nested = candidates.find((el) => !(el.hasAttribute('tabindex') && el.tabIndex < 0)) ?? candidates[0];
50
+ const nested = candidates.find((el) => !outOfSequentialFocus(el, element)) ?? candidates[0];
45
51
  if (!nested) return { status: 'pass' };
46
52
  // A programmatically focusable child must not silently pass. In an
47
53
  // ARIA widget its name and role can survive presentational-descendant
48
54
  // processing, so nesting alone does not prove a 4.1.2 failure.
49
55
  // Native links/buttons separately forbid interactive descendants.
50
56
  // https://www.w3.org/TR/wai-aria-1.2/#tree_exclusion
51
- if (nested.hasAttribute('tabindex') && nested.tabIndex < 0
52
- && !element.matches('a[href], button')) {
57
+ if (outOfSequentialFocus(nested, element) && !element.matches('a[href], button')) {
58
+ let host = null;
59
+ for (let node = flatTreeParent(nested); node && node !== element; node = flatTreeParent(node)) {
60
+ if (node.shadowRoot && node.hasAttribute('tabindex') && node.tabIndex < 0) { host = node; break; }
61
+ }
62
+ const child = `<${nested.tagName.toLowerCase()}>`;
53
63
  return {
54
64
  status: 'incomplete',
55
- message: nested.closest('[aria-hidden="true"]')
56
- ? `This control contains an element (<${nested.tagName.toLowerCase()}>) that is hidden from assistive technology but can still receive focus from a click or a script. Focus landing there lands on content a screen reader cannot see. Check whether it can take focus, and if it can, remove it from focus or from aria-hidden.`
57
- : `This control contains an element (<${nested.tagName.toLowerCase()}>) with a negative tabindex. It can still receive focus. Check that both controls expose the intended name and role, and that focusing and activating the child works correctly.`,
65
+ message: host
66
+ ? `This control contains a custom element (<${host.tagName.toLowerCase()}>) whose shadow tree holds another control (${child}). The host's negative tabindex keeps it out of the tab order, but a click or a script can still focus it. Check that both controls expose the intended name and role, and that focusing and activating the child works correctly.`
67
+ : nested.closest('[aria-hidden="true"]')
68
+ ? `This control contains an element (${child}) that is hidden from assistive technology but can still receive focus from a click or a script. Focus landing there lands on content a screen reader cannot see. Check whether it can take focus, and if it can, remove it from focus or from aria-hidden.`
69
+ : `This control contains an element (${child}) with a negative tabindex. It can still receive focus. Check that both controls expose the intended name and role, and that focusing and activating the child works correctly.`,
58
70
  };
59
71
  }
60
72
  // <summary> is the one outer control HTML does not forbid this on: its
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pour-engine",
3
- "version": "1.42.2",
3
+ "version": "1.42.3",
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",