pour-engine 1.1.5 → 1.1.7

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.5',
9
+ version: '1.1.7',
10
10
  },
11
11
  };
@@ -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. */
@@ -116,10 +116,34 @@ export function implicitRole(element) {
116
116
  }
117
117
 
118
118
  /** The element's effective role: explicit (first modelled token) or implicit. */
119
+ /**
120
+ * Presentational Roles Conflict Resolution: a `presentation`/`none` role is
121
+ * DISCARDED when the element is focusable or carries a global ARIA property,
122
+ * and the implicit role is exposed instead. Reporting against the written role
123
+ * in those cases names a role the browser already threw away, and sends the
124
+ * author to fix the wrong thing.
125
+ *
126
+ * The two triggers below are what browsers actually implement, verified in
127
+ * Chromium's accessibility tree: `<h2 role="presentation">` exposes StaticText,
128
+ * but adding tabindex or aria-describedby exposes `heading`. Note a non-global
129
+ * property applicable to the implicit role (aria-level on a heading) does NOT
130
+ * trigger it, even though a strict reading of the spec suggests it should.
131
+ */
132
+ function presentationDiscarded(element) {
133
+ if (element.tabIndex >= 0) return true;
134
+ if (element.matches('a[href], button, input, select, textarea, summary, [contenteditable="true"]')) return true;
135
+ return [...GLOBAL_ARIA].some((name) => element.hasAttribute(`aria-${name}`));
136
+ }
137
+
119
138
  export function effectiveRole(element) {
120
139
  const explicit = element.getAttribute('role')?.trim().split(/\s+/) ?? [];
121
140
  for (const token of explicit) {
122
- if (ROLE_ARIA[token.toLowerCase()]) return token.toLowerCase();
141
+ const role = token.toLowerCase();
142
+ if (!ROLE_ARIA[role]) continue;
143
+ if ((role === 'presentation' || role === 'none') && presentationDiscarded(element)) {
144
+ return implicitRole(element);
145
+ }
146
+ return role;
123
147
  }
124
148
  return explicit.length ? null : implicitRole(element); // unknown explicit role: don't judge
125
149
  }
@@ -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,12 +1,34 @@
1
1
  // WCAG SC 2.4.11 Focus Not Obscured (Minimum) (Level AA)
2
- // When a focusable element's box lies ENTIRELY under an opaque fixed or
3
- // sticky overlay at its resting position, focusing it shows the user
4
- // nothing — the criterion's core case (bottom cookie bars covering footer
5
- // links). Partial overlap passes 2.4.11 by definition ("not entirely
6
- // hidden"), so only full containment is judged, and only for elements
7
- // currently inside the viewport where the geometry is real.
2
+ // An opaque fixed/sticky overlay covering a focusable element is the
3
+ // criterion's core case (bottom cookie bars over footer links). Partial
4
+ // overlap passes by definition ("not entirely hidden"), so only full
5
+ // containment counts.
6
+ //
7
+ // This REVIEWS rather than fails, deliberately. What a snapshot can see is
8
+ // "element X is under the panel right now", and that is a fact about the
9
+ // current scroll position, not about the page: scrolling the same page a few
10
+ // hundred pixels names entirely different elements, or none at all. The
11
+ // criterion asks about the state focus ARRIVES in, which depends on tab order,
12
+ // on whether the browser scrolls (it won't, for an element already inside the
13
+ // viewport — merely covered), and on any author focus handler that moves the
14
+ // page. None of that is knowable from geometry alone, so the honest output is
15
+ // a pointer to the overlay and a request for a keyboard pass.
16
+ //
17
+ // Sites that apply the spec's own sufficient technique — scroll-padding for
18
+ // the overlay's edge — are not flagged at all.
8
19
  const FOCUSABLE = 'a[href], button, input:not([type="hidden"]), select, textarea, summary, [tabindex]:not([tabindex="-1"])';
9
20
 
21
+ /** Does the scroll container reserve room for an overlay on this edge?
22
+ * scroll-padding is what the Understanding doc cites as sufficient: it keeps
23
+ * the browser's own scroll-into-view clear of a sticky panel. */
24
+ function edgeReserved(doc, edge, needed) {
25
+ const scroller = doc.scrollingElement ?? doc.documentElement;
26
+ const style = getComputedStyle(scroller);
27
+ const value = edge === 'top' ? style.scrollPaddingTop : style.scrollPaddingBottom;
28
+ const px = value && value.endsWith('px') ? parseFloat(value) : 0;
29
+ return px >= needed - 1;
30
+ }
31
+
10
32
  export default {
11
33
  id: 'focus-not-obscured',
12
34
  impact: 'serious',
@@ -70,10 +92,16 @@ export default {
70
92
  !layer.contains(element) && !element.contains(layer)
71
93
  && isObscuringOverlay(layer) && covered(rect, layer.getBoundingClientRect()));
72
94
  if (!blocker) return { status: 'pass' };
95
+ // Which viewport edge is the panel pinned to? That is the edge the
96
+ // browser's scroll-into-view has to clear.
97
+ const panel = blocker.getBoundingClientRect();
98
+ const edge = panel.top <= 1 && panel.bottom < win.innerHeight ? 'top'
99
+ : panel.bottom >= win.innerHeight - 1 ? 'bottom' : null;
100
+ if (edge && edgeReserved(doc, edge, panel.height)) return { status: 'pass' };
73
101
  return {
74
- status: 'fail',
75
- message: 'This focusable element sits entirely behind an opaque fixed/sticky overlay — when keyboard focus lands on it, nothing visible changes and the user is lost.',
76
- fix: 'Add scroll-padding for the overlay height, or make the overlay dismissible/non-overlapping so focused elements stay at least partly visible.',
102
+ status: 'incomplete',
103
+ message: 'This element is currently underneath an opaque fixed panel. Whether that breaks 2.4.11 depends on where the page sits when focus reaches it — the browser does not scroll an element that is already in the viewport, merely covered, so focus can land invisibly. Tab through the page and check the focus indicator is never entirely hidden.',
104
+ fix: `Reserve room for the panel with scroll-padding-${edge ?? 'bottom'} on the scrolling container, or move focus clear of it when the panel is up.`,
77
105
  };
78
106
  });
79
107
  },
@@ -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 {
@@ -37,7 +45,12 @@ export default {
37
45
  // canonical harmless case. The failure worth reporting is an invalid
38
46
  // role MASKING real semantics or decorating an interactive element.
39
47
  const tag = element.tagName.toLowerCase();
40
- const genericFallback = tag === 'div' || tag === 'span';
48
+ // What the element falls back to when the bogus role is ignored. div/span
49
+ // expose generic; <svg> exposes image (or graphics-document) in every
50
+ // current engine, which is what an author writing role="img"/"image" on it
51
+ // wanted anyway — so a typo there costs an AT user nothing. Elements with
52
+ // real semantics to mask (headings, lists, controls) stay out of the hatch.
53
+ const genericFallback = tag === 'div' || tag === 'span' || tag === 'svg';
41
54
  const focusable = element.tabIndex >= 0
42
55
  || element.matches('a[href], button, input, select, textarea, summary');
43
56
  const hasAriaProps = [...element.attributes].some(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pour-engine",
3
- "version": "1.1.5",
3
+ "version": "1.1.7",
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",