pour-engine 1.1.4 → 1.1.6

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.1.4',
9
+ version: '1.1.6',
10
10
  },
11
11
  };
@@ -72,7 +72,7 @@ All 86 success criteria of WCAG 2.2, and how the native engine covers them.
72
72
  | 2.4.13 | [Focus Appearance](https://www.w3.org/WAI/WCAG22/Understanding/focus-appearance.html) | AAA | Manual review | — |
73
73
  | 2.5.1 | [Pointer Gestures](https://www.w3.org/WAI/WCAG22/Understanding/pointer-gestures.html) | A | Manual review | — |
74
74
  | 2.5.2 | [Pointer Cancellation](https://www.w3.org/WAI/WCAG22/Understanding/pointer-cancellation.html) | A | Manual review | — |
75
- | 2.5.3 | [Label in Name](https://www.w3.org/WAI/WCAG22/Understanding/label-in-name.html) | A | Partially automated | label-in-name |
75
+ | 2.5.3 | [Label in Name](https://www.w3.org/WAI/WCAG22/Understanding/label-in-name.html) | A | Manual review | — |
76
76
  | 2.5.4 | [Motion Actuation](https://www.w3.org/WAI/WCAG22/Understanding/motion-actuation.html) | A | Manual review | — |
77
77
  | 2.5.5 | [Target Size (Enhanced)](https://www.w3.org/WAI/WCAG22/Understanding/target-size-enhanced.html) | AAA | Partially automated | target-size-enhanced |
78
78
  | 2.5.6 | [Concurrent Input Mechanisms](https://www.w3.org/WAI/WCAG22/Understanding/concurrent-input-mechanisms.html) | AAA | Manual review | — |
@@ -112,4 +112,4 @@ All 86 success criteria of WCAG 2.2, and how the native engine covers them.
112
112
  | 4.1.2 | [Name, Role, Value](https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html) | A | Partially automated | input-image-alt, form-label, link-name, aria-allowed-attr, aria-attr-valid, aria-field-name, aria-hidden-focus, aria-valid-refs, button-name, dialog-name, frame-title, label-for-valid, nested-interactive, role-required-aria, valid-role |
113
113
  | 4.1.3 | [Status Messages](https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html) | AA | Manual review | — |
114
114
 
115
- **Totals:** 2 automated · 28 partially automated · 56 manual — with 73 rules.
115
+ **Totals:** 2 automated · 27 partially automated · 57 manual — with 72 rules.
@@ -52,6 +52,7 @@ let opacityCache = new WeakMap();
52
52
  let opacityAnimatorsCache = null;
53
53
  let mediaRectsCache = null;
54
54
  let panelRectsCache = null;
55
+ let pseudoCache = new WeakMap();
55
56
  const HAS_IMAGE = Symbol('background-image in chain');
56
57
 
57
58
  export function resetAuditCaches() {
@@ -60,6 +61,7 @@ export function resetAuditCaches() {
60
61
  opacityAnimatorsCache = null;
61
62
  mediaRectsCache = null;
62
63
  panelRectsCache = null;
64
+ pseudoCache = new WeakMap();
63
65
  }
64
66
 
65
67
  function canvasColor(doc) {
@@ -278,6 +280,186 @@ function textSamplePoint(element) {
278
280
  * missed. The ancestor walk (which callers still run) covers the common
279
281
  * cases of that shape.
280
282
  */
283
+ const px = (value) => (typeof value === 'string' && value.endsWith('px') ? parseFloat(value) : NaN);
284
+
285
+ /**
286
+ * The viewport rect a positioned pseudo-element paints into, or null when
287
+ * its geometry isn't statically resolvable.
288
+ *
289
+ * An absolutely positioned box resolves against its containing block's
290
+ * PADDING box, which is the nearest ancestor that establishes one — usually
291
+ * the host itself (authors nearly always set position: relative for exactly
292
+ * this), but not always, so the chain is walked rather than assumed.
293
+ * Non-px insets, rotations and scales return null, which makes callers
294
+ * abstain rather than guess a rect.
295
+ */
296
+ function containingBlockFor(host) {
297
+ for (let node = host; node && node.nodeType === 1; node = node.parentElement) {
298
+ const style = getComputedStyle(node);
299
+ // Transforms, filters and containment make an element a containing block
300
+ // for its absolutely positioned descendants just as positioning does.
301
+ if (style.position !== 'static' || style.transform !== 'none'
302
+ || style.filter !== 'none' || /paint|layout|strict|content/.test(style.contain ?? '')) return node;
303
+ }
304
+ return null; // the initial containing block — viewport-relative, not resolved here
305
+ }
306
+
307
+ function pseudoRect(host, style) {
308
+ const block = containingBlockFor(host);
309
+ if (!block) return null;
310
+ const hostStyle = getComputedStyle(block);
311
+ // A zero-extent containing block is still a valid ORIGIN — a collapsed
312
+ // positioned wrapper whose pseudo paints outside it is ordinary CSS. Only
313
+ // the opposing-inset derivation needs a real extent, and the degenerate
314
+ // boxes that produces are caught by the sanity check at the end.
315
+ const hostRect = block.getBoundingClientRect();
316
+
317
+ // Absolute positioning resolves against the containing block's PADDING box.
318
+ const borderLeft = px(hostStyle.borderLeftWidth) || 0;
319
+ const borderTop = px(hostStyle.borderTopWidth) || 0;
320
+ const cbLeft = hostRect.left + borderLeft;
321
+ const cbTop = hostRect.top + borderTop;
322
+ const cbWidth = hostRect.width - borderLeft - (px(hostStyle.borderRightWidth) || 0);
323
+ const cbHeight = hostRect.height - borderTop - (px(hostStyle.borderBottomWidth) || 0);
324
+
325
+ // getComputedStyle reports the CONTENT box, so padding and border have to
326
+ // be added back to get the extent that actually paints.
327
+ const extraX = (px(style.paddingLeft) || 0) + (px(style.paddingRight) || 0)
328
+ + (px(style.borderLeftWidth) || 0) + (px(style.borderRightWidth) || 0);
329
+ const extraY = (px(style.paddingTop) || 0) + (px(style.paddingBottom) || 0)
330
+ + (px(style.borderTopWidth) || 0) + (px(style.borderBottomWidth) || 0);
331
+
332
+ // An opposing inset pair pins both edges directly; otherwise one inset plus
333
+ // the used size does. `auto` on both sides leaves the box where static flow
334
+ // would have put it — not resolvable here.
335
+ const span = (startValue, endValue, sizeValue, origin, extent, extra) => {
336
+ const start = px(startValue);
337
+ const end = px(endValue);
338
+ const size = px(sizeValue);
339
+ if (Number.isFinite(start) && Number.isFinite(end)) return [origin + start, origin + extent - end];
340
+ if (Number.isFinite(start) && Number.isFinite(size)) return [origin + start, origin + start + size + extra];
341
+ if (Number.isFinite(end) && Number.isFinite(size)) {
342
+ const far = origin + extent - end;
343
+ return [far - size - extra, far];
344
+ }
345
+ return null;
346
+ };
347
+ const x = span(style.left, style.right, style.width, cbLeft, cbWidth, extraX);
348
+ const y = span(style.top, style.bottom, style.height, cbTop, cbHeight, extraY);
349
+ if (!x || !y) return null;
350
+
351
+ let [left, right] = x;
352
+ let [top, bottom] = y;
353
+ if (style.transform && style.transform !== 'none') {
354
+ // Only a pure translation keeps the box axis-aligned and the same size;
355
+ // rotation/scale/skew would need real geometry, so they stay unresolved.
356
+ const matrix = /^matrix\(([^)]+)\)$/.exec(style.transform);
357
+ if (!matrix) return null;
358
+ const [a, b, c, d, e, f] = matrix[1].split(',').map(Number);
359
+ if (a !== 1 || b !== 0 || c !== 0 || d !== 1) return null;
360
+ left += e; right += e; top += f; bottom += f;
361
+ }
362
+ // A degenerate box is not unknowable — it is a box that paints nothing
363
+ // (collapsed separator rules are usually exactly this: 1px wide, 0 tall).
364
+ // Callers skip it rather than abstaining on the whole element.
365
+ return { left, top, right, bottom, empty: !(right > left) || !(bottom > top) };
366
+ }
367
+
368
+ /**
369
+ * The backdrop a host's own ::before/::after paints over `point`.
370
+ *
371
+ * This is the one resolution path that can see pseudo-element paint at all:
372
+ * they carry no DOM node, so the ancestor walk (which reads element computed
373
+ * styles) and elementsFromPoint (which retargets them to their host) are both
374
+ * structurally blind to them. The sliding indicator pill behind a selected
375
+ * segmented-control option is the canonical case — the text really does sit
376
+ * on the pill, not on the track colour both other paths report.
377
+ *
378
+ * Returns { color } (opaque or translucent paint over the point),
379
+ * 'unresolved' when something paints there that flat colour math can't
380
+ * follow, or null when no pseudo of this host paints over the point.
381
+ */
382
+ /** The paint a host's pseudo-elements contribute, resolved once per audit.
383
+ * Two extra getComputedStyle calls per host would otherwise land on every
384
+ * text element on the page; cached, the per-point test below is rect math. */
385
+ function pseudoLayers(host) {
386
+ let layers = pseudoCache.get(host);
387
+ if (layers !== undefined) return layers;
388
+ layers = [];
389
+ // Paint order within a host: background, ::before, content, ::after — so a
390
+ // later entry covers an earlier one, and both sit above the host's own
391
+ // background but below its text.
392
+ for (const which of ['::before', '::after']) {
393
+ const style = getComputedStyle(host, which);
394
+ if (style.content === 'none' || style.display === 'none' || style.visibility === 'hidden') continue;
395
+ const paints = style.backgroundImage !== 'none' || (parseColor(style.backgroundColor)?.a ?? 0) > 0;
396
+ if (!paints) continue;
397
+ // In-flow pseudos (the overwhelming majority: bullets, icons, rules,
398
+ // badges) occupy their own box in the content flow beside the text
399
+ // rather than painting behind it. Only out-of-flow ones can be the
400
+ // backdrop, so everything else is simply not a layer here — treating
401
+ // them as unknowable would abstain on half the web.
402
+ if (style.position !== 'absolute' && style.position !== 'fixed') continue;
403
+ const rect = style.position === 'absolute' ? pseudoRect(host, style) : null;
404
+ // Paint we can't place can't be ruled in or out — say so rather than
405
+ // silently leaving it out of the stack.
406
+ if (!rect) { layers = 'unresolved'; break; }
407
+ if (rect.empty) continue; // placed, and covers nothing
408
+ let color = parseColor(style.backgroundColor);
409
+ const opacity = parseFloat(style.opacity);
410
+ if (color && Number.isFinite(opacity) && opacity < 1) color = { ...color, a: color.a * opacity };
411
+ // Gradient and image paint rides along as CSS so callers can sample it,
412
+ // the same way they sample an element's background-image — a gradient
413
+ // scrim over a card headline is a bracketable backdrop, not a mystery.
414
+ const imageCss = style.backgroundImage !== 'none' ? style.backgroundImage : null;
415
+ layers.push({ rect, color, imageCss });
416
+ }
417
+ pseudoCache.set(host, layers);
418
+ return layers;
419
+ }
420
+
421
+ /**
422
+ * The nearest pseudo-element paint covering this element's text, walking the
423
+ * flat tree upward: an out-of-flow ::before/::after paints above its host's
424
+ * background but below its host's descendants, so the first one that covers
425
+ * the text is the real backdrop.
426
+ *
427
+ * This is the only path that can see pseudo-element paint at all — they carry
428
+ * no DOM node, so the ancestor walk (element computed styles) and
429
+ * elementsFromPoint (which retargets them to their host) are both
430
+ * structurally blind. The sliding indicator behind a selected
431
+ * segmented-control option is the canonical case: the text really does sit on
432
+ * the pill, not on the track colour both other paths report.
433
+ *
434
+ * Pure geometry, deliberately: unlike the hit-test it needs no viewport, so
435
+ * it answers for text scrolled far off screen too.
436
+ *
437
+ * Returns { color } (opaque, or translucent paint to composite over the
438
+ * walked background), { image } for gradient/image paint the caller should
439
+ * sample, 'unresolved' when paint in the chain can't be placed, or null when
440
+ * no pseudo covers the text.
441
+ */
442
+ export function pseudoBackdropForText(element) {
443
+ const point = textSamplePoint(element);
444
+ if (!point) return null;
445
+ let acc = null;
446
+ for (let node = element; node && node.nodeType === 1; node = node.parentElement ?? node.getRootNode()?.host) {
447
+ const layers = pseudoLayers(node);
448
+ // Paint we can't place can't be ruled in or out, in either direction.
449
+ if (layers === 'unresolved') return 'unresolved';
450
+ for (const { rect, color, imageCss } of layers) {
451
+ if (point.x < rect.left || point.x > rect.right || point.y < rect.top || point.y > rect.bottom) continue;
452
+ // Gradient/image paint is bracketable by sampling, so hand it to the
453
+ // caller rather than giving up on the whole element.
454
+ if (imageCss) return { image: { css: imageCss, element: node } };
455
+ if (!color || color.a === 0) continue;
456
+ acc = acc ? composite(acc, color) : color;
457
+ if (acc.a >= 1) return { color: acc };
458
+ }
459
+ }
460
+ return acc ? { color: acc } : null;
461
+ }
462
+
281
463
  /** A layer qualifies as a scrim over `element` when it is translucent paint
282
464
  * (or a backdrop-filter) covering most of the viewport and the whole
283
465
  * element — the shape of a modal/loading veil, not a badge or header. */
@@ -15,11 +15,14 @@ import imageAlt from './wcag/1.1.1-image-alt.js';
15
15
  import svgImgAlt from './wcag/1.1.1-svg-img-alt.js';
16
16
  import buttonName from './wcag/4.1.2-button-name.js';
17
17
  import linkName from './wcag/2.4.4-link-name.js';
18
- // Un-parked 2026-08-01 (was parked 2026-07-29): benchmarking against other
19
- // engines showed real 2.5.3 misses on mainstream sites, and the rule now
20
- // also covers labelable fields whose associated <label> text is missing
21
- // from an overriding aria-name.
22
- import labelInName from './wcag/2.5.3-label-in-name.js';
18
+ // Re-parked by product decision (2026-08-01, David, after a one-day
19
+ // un-parking): 2.5.3 is normative Level A but chiefly serves speech-input
20
+ // users, and the findings volume isn't wanted in results for now. The SC is
21
+ // classed 'manual' in wcag22.js so it lands in the human checklist instead
22
+ // of being silently skipped. The rule itself (incl. the labelled-field
23
+ // widening) stays maintained here — restore the import + registry entry to
24
+ // bring it back.
25
+ // import labelInName from './wcag/2.5.3-label-in-name.js';
23
26
  import formLabel from './wcag/1.3.1-form-label.js';
24
27
  import autocompleteValid from './wcag/1.3.5-autocomplete-valid.js';
25
28
  import orientationLock from './wcag/1.3.4-orientation.js';
@@ -110,7 +113,7 @@ export default [
110
113
  svgImgAlt,
111
114
  buttonName,
112
115
  linkName,
113
- labelInName,
116
+ // labelInName, — re-parked 2026-08-01, see note at the import
114
117
  formLabel,
115
118
  autocompleteValid,
116
119
  orientationLock,
@@ -4,6 +4,7 @@ import {
4
4
  backgroundImageSource, backgroundImagePaintRect, imageLuminanceRange, gradientLuminanceRange,
5
5
  rangeVerdict, isLargeText, cumulativeOpacity, opacityAnimating, restingOpacity, mediaRects,
6
6
  paintedBackdrop, opaquePanelRects, viewportVeil, textShadowHalo, textShadowNegligible,
7
+ pseudoBackdropForText,
7
8
  } from '../../lib/contrast.js';
8
9
 
9
10
  /** Display a ratio truncated (never rounded up): 4.495 must read "4.49",
@@ -302,7 +303,28 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
302
303
  };
303
304
  }
304
305
  }
305
- if (painted?.image) {
306
+ // Pseudo-element paint sits above everything the hit-test and the walk
307
+ // can report, and neither can see it — so it is resolved before them and
308
+ // wins. Geometry-only, so it settles offscreen text too, where the
309
+ // hit-test is blind and the walk would otherwise assert the track colour
310
+ // of a control the text visibly is not sitting on.
311
+ const pseudoBack = pseudoBackdropForText(element);
312
+ if (pseudoBack === 'unresolved') {
313
+ return {
314
+ status: 'incomplete',
315
+ 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.',
316
+ };
317
+ }
318
+ // Gradient/image paint from a pseudo-element: bracket it by sampling,
319
+ // exactly as an element's own background-image is bracketed.
320
+ if (pseudoBack?.image) {
321
+ const sampled = await sampledVerdict(pseudoBack.image, foreground, required, doc);
322
+ return sampled ?? {
323
+ status: 'incomplete',
324
+ 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.',
325
+ };
326
+ }
327
+ if (painted?.image && !pseudoBack) {
306
328
  const sampled = await sampledVerdict(painted.image, foreground, required, doc);
307
329
  return sampled ?? {
308
330
  status: 'incomplete',
@@ -330,6 +352,15 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
330
352
  };
331
353
  }
332
354
  }
355
+ if (pseudoBack?.color) {
356
+ // Opaque pill: it IS the backdrop. Translucent paint composites over
357
+ // whatever the walk resolved beneath it. Either way the geometry is
358
+ // computed, not guessed, so this counts as verified paint truth.
359
+ background = pseudoBack.color.a >= 1 || !background
360
+ ? pseudoBack.color
361
+ : composite(pseudoBack.color, background);
362
+ backgroundVerified = true;
363
+ }
333
364
  if (!background) {
334
365
  return { status: 'incomplete', message: 'The background could not be determined — check contrast by eye.' };
335
366
  }
@@ -1,5 +1,5 @@
1
1
  // WCAG SC 4.1.2 Name, Role, Value (Level A)
2
- // ARIA 1.2 roles (concrete, usable roles — abstract roles are invalid in markup).
2
+ // Concrete, usable roles — abstract roles are invalid in markup.
3
3
  const VALID_ROLES = new Set([
4
4
  'alert', 'alertdialog', 'application', 'article', 'banner', 'blockquote', 'button',
5
5
  'caption', 'cell', 'checkbox', 'code', 'columnheader', 'combobox', 'complementary',
@@ -12,6 +12,14 @@ const VALID_ROLES = new Set([
12
12
  'slider', 'spinbutton', 'status', 'strong', 'subscript', 'superscript', 'switch', 'tab',
13
13
  'table', 'tablist', 'tabpanel', 'term', 'textbox', 'time', 'timer', 'toolbar', 'tooltip',
14
14
  'tree', 'treegrid', 'treeitem',
15
+ // ARIA 1.3 additions. `image` is the spec's own primary spelling with
16
+ // `img` kept as the synonym — img is the one ARIA role that isn't a whole
17
+ // word, which is why authors reach for `image`, and Safari (2021),
18
+ // Firefox (116) and Chromium all map it. Flagging it was a false positive
19
+ // on real sites; verified against Chromium's accessibility tree, where
20
+ // role="image" resolves to image and only a genuinely bogus role falls
21
+ // back to generic.
22
+ 'image', 'comment', 'mark', 'suggestion',
15
23
  ]);
16
24
 
17
25
  export default {
package/engine/wcag22.js CHANGED
@@ -82,7 +82,7 @@ export default [
82
82
  sc('2.4.13', 'Focus Appearance', 'AAA', 'manual', '2.2'),
83
83
  sc('2.5.1', 'Pointer Gestures', 'A', 'manual', '2.1'),
84
84
  sc('2.5.2', 'Pointer Cancellation', 'A', 'manual', '2.1'),
85
- sc('2.5.3', 'Label in Name', 'A', 'partial', '2.1'), // label-in-name un-parked 2026-08-01: catches aria-name/visible-label mismatches on controls and labelled fields; can't prove every naming path, so partial
85
+ sc('2.5.3', 'Label in Name', 'A', 'manual', '2.1'), // manual WHILE label-in-name is parked (re-parked 2026-08-01, see rules/index.js) — 'auto' with no active rule would make this SC vanish from results AND the manual checklist
86
86
  sc('2.5.4', 'Motion Actuation', 'A', 'manual', '2.1'),
87
87
  sc('2.5.5', 'Target Size (Enhanced)', 'AAA', 'partial', '2.1'),
88
88
  sc('2.5.6', 'Concurrent Input Mechanisms', 'AAA', 'manual', '2.1'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pour-engine",
3
- "version": "1.1.4",
3
+ "version": "1.1.6",
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",