pour-engine 1.37.1 → 1.38.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.37.1',
9
+ version: '1.38.0',
10
10
  },
11
11
  };
@@ -1690,10 +1690,16 @@ export function pseudoTextColors(element, style) {
1690
1690
  return found;
1691
1691
  }
1692
1692
 
1693
+ /** The font weight from which text counts as bold for the large-scale
1694
+ * test. WCAG's glossary says "bold" and no number; CSS's bold keyword is
1695
+ * 700, and the semibold 600 that most modern faces ship reads as bold at
1696
+ * 14pt on screen, so it counts here too (David, 2026-09-02; was 700). */
1697
+ export const BOLD_WEIGHT = 600;
1698
+
1693
1699
  /** WCAG "large text": ≥24px, or ≥18.66px (14pt) bold. */
1694
1700
  export function isLargeText(style) {
1695
1701
  const size = parseFloat(style.fontSize);
1696
1702
  const weight = parseInt(style.fontWeight, 10) || 400;
1697
1703
  // 18pt and 14pt at CSS's 96dpi: 24px, and 56/3 = 18.666…px exactly.
1698
- return size >= 24 || (size >= 56 / 3 && weight >= 700);
1704
+ return size >= 24 || (size >= 56 / 3 && weight >= BOLD_WEIGHT);
1699
1705
  }
@@ -4,13 +4,19 @@
4
4
  // The override is injected and removed inside one synchronous pass — the
5
5
  // engine only yields to the renderer between rules, so the page never
6
6
  // paints the probed state.
7
- // The probe reflows the whole page for a moment. Two guards keep that
7
+ // The probe reflows the whole page for a moment. Three guards keep that
8
8
  // invisible to the user: scroll anchoring is disabled while the override
9
9
  // is in (or Chrome re-anchors during the inflated layout and leaves the
10
10
  // page scrolled somewhere else when the probe lifts — measured at 884px
11
- // of drift on a long report page), and the window scroll is restored
12
- // after removal in case the browser clamped it anyway.
13
- const OVERRIDE = `html { overflow-anchor: none !important; }
11
+ // of drift on a long report page); the document keeps at least its own
12
+ // height, because a page set with a line height above 1.5 gets SHORTER
13
+ // under the override, the browser clamps the scroll position to the new
14
+ // end, and that clamp paints (measured on rocketvalidator.com: 2500px to
15
+ // 1966px in one frame, David, 2026-09-02); and the window scroll is put
16
+ // back instantly after removal, instantly whatever the page's own
17
+ // scroll-behavior says, since a smooth restore is the visible half of the
18
+ // jump.
19
+ const override = (minHeight) => `html { overflow-anchor: none !important; min-height: ${minHeight}px !important; }
14
20
  * {
15
21
  line-height: 1.5 !important;
16
22
  letter-spacing: 0.12em !important;
@@ -56,7 +62,7 @@ export default {
56
62
  const scrollY = win?.scrollY ?? 0;
57
63
  const probe = doc.createElement('style');
58
64
  probe.dataset.pourAudit = 'probe';
59
- probe.textContent = OVERRIDE;
65
+ probe.textContent = override(doc.documentElement.scrollHeight);
60
66
  doc.documentElement.append(probe);
61
67
  let after;
62
68
  try {
@@ -68,7 +74,7 @@ export default {
68
74
  // The audit must never move the page: put the scroll back exactly
69
75
  // where the user had it, after the removal's own reflow settles.
70
76
  void doc.documentElement.offsetHeight;
71
- if (win && (win.scrollX !== scrollX || win.scrollY !== scrollY)) win.scrollTo(scrollX, scrollY);
77
+ if (win && (win.scrollX !== scrollX || win.scrollY !== scrollY)) win.scrollTo({ left: scrollX, top: scrollY, behavior: 'instant' });
72
78
  }
73
79
 
74
80
  return elements.map((element, i) => {
@@ -6,8 +6,7 @@ import {
6
6
  opacityAnimating, restingOpacity, mediaRects, inZeroClipSubtree,
7
7
  paintedBackdrop, opaquePanelRects, viewportVeil, textShadowHalo, textShadowNegligible,
8
8
  pseudoBackdropForText, filmedContrastBounds, backgroundColorSource, scrimPaint, applyOverlays,
9
- showRatio, splitBackgroundLayers, backgroundLayerUrl, sampleGridFor, opacityGroupPaint, pseudoTextColors,
10
- } from '../../lib/contrast.js';
9
+ showRatio, splitBackgroundLayers, backgroundLayerUrl, sampleGridFor, opacityGroupPaint, pseudoTextColors, BOLD_WEIGHT } from '../../lib/contrast.js';
11
10
 
12
11
  /** The first url() among a background-image list's layers, or null. */
13
12
  const firstLayerUrl = (css) => splitBackgroundLayers(css ?? '').map(backgroundLayerUrl).find(Boolean) ?? null;
@@ -407,7 +406,8 @@ async function sampledVerdict(source, foreground, required, doc, overlays = [],
407
406
  */
408
407
  export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
409
408
  // When the colour pair already clears the LARGE-scale minimum, the miss
410
- // is about scale, not colour: name the boundary (14pt bold / 18pt), or
409
+ // is about scale, not colour: name the boundary (14pt bold / 18pt, bold
410
+ // meaning BOLD_WEIGHT and up), or
411
411
  // a bold-but-just-under button reads as the checker ignoring its weight.
412
412
  const largeScaleNoteFor = (style, required, ratio) => {
413
413
  if (required !== thresholds.normal || ratio < thresholds.large) return '';
@@ -418,15 +418,16 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
418
418
  // twentieth of a pixel of a cutoff the size is shown to two decimals,
419
419
  // and when even that rounds onto the cutoff it is named as just under
420
420
  // it. (2026-08-25 overnight audit, defect 9.)
421
- const cutoff = weightNum >= 700 ? 56 / 3 : 24;
422
- const cutoffText = weightNum >= 700 ? '18.67px' : '24px';
421
+ const bold = weightNum >= BOLD_WEIGHT;
422
+ const cutoff = bold ? 56 / 3 : 24;
423
+ const cutoffText = bold ? '18.67px' : '24px';
423
424
  const nearCutoff = Math.abs(sizePx - cutoff) < 0.05;
424
425
  let shown = nearCutoff ? `${sizePx.toFixed(2)}px` : `${Math.round(sizePx * 10) / 10}px`;
425
426
  if (nearCutoff && parseFloat(shown) >= parseFloat(cutoffText)) shown = `just under ${cutoffText}`;
426
- if (weightNum >= 700 && sizePx < 56 / 3) {
427
+ if (bold && sizePx < 56 / 3) {
427
428
  return ` This text is bold at ${shown}; bold text counts as large scale from 18.67px (14pt), where the ${thresholds.large}:1 minimum would apply and these colours would pass.`;
428
429
  }
429
- if (weightNum < 700 && sizePx >= 56 / 3 && sizePx < 24) {
430
+ if (!bold && sizePx >= 56 / 3 && sizePx < 24) {
430
431
  return ` At ${shown} regular weight this is not large scale; from 24px (18pt), or bold at this size, the ${thresholds.large}:1 minimum would apply and these colours would pass.`;
431
432
  }
432
433
  return '';
@@ -8,11 +8,35 @@
8
8
  // finding goes to a human with the evidence attached, never asserted: the
9
9
  // same verdict discipline as pause-stop-hide's ticker branch.
10
10
  //
11
+ // One shape of that mechanism is stated in the markup and needs no human:
12
+ // a visible button whose aria-controls names the video, or a container
13
+ // holding it. That is what aria-controls is for, the element whose
14
+ // presence or contents the control changes, and a page that wires its
15
+ // pause button that way has answered the question the review would ask
16
+ // (pour.dev's own hero and sense videos, David, 2026-09-02). A button that
17
+ // merely sits near the video, or one that is hidden, settles nothing.
18
+ //
11
19
  // A <video controls> ships its own pause button and is out of the selector.
12
20
  // Icon-scale playback (tiny inline previews) is left alone for the same
13
21
  // reason pause-stop-hide skips spinners: flagging every small moving
14
22
  // thumbnail would bury the full-bleed hero that matters. Silence there is
15
23
  // abstaining, not clearing.
24
+ /** A visible, enabled button whose aria-controls names the video or an
25
+ * ancestor of it, looked up in the video's own document or shadow tree. */
26
+ function pauseControlFor(video, isVisible) {
27
+ const root = video.getRootNode();
28
+ if (!root.querySelectorAll) return null;
29
+ const byId = (id) => (root.getElementById ? root.getElementById(id) : root.querySelector(`#${CSS.escape(id)}`));
30
+ for (const control of root.querySelectorAll('button[aria-controls], [role="button"][aria-controls], input[type="button"][aria-controls]')) {
31
+ if (control.disabled || !isVisible(control)) continue;
32
+ for (const id of control.getAttribute('aria-controls').split(/\s+/)) {
33
+ const target = id && byId(id);
34
+ if (target && (target === video || target.contains(video))) return control;
35
+ }
36
+ }
37
+ return null;
38
+ }
39
+
16
40
  export default {
17
41
  id: 'video-loop-motion',
18
42
  name: 'Looping video control',
@@ -23,7 +47,7 @@ export default {
23
47
  selector: 'video[autoplay][loop]:not([controls])',
24
48
  // Movement is a visual matter: an aria-hidden hero still moves on screen.
25
49
  visibility: 'visual',
26
- evaluate(element) {
50
+ evaluate(element, { isVisible }) {
27
51
  // Icon scale means SMALL, not thin: a 1200×60 animated banner strip is
28
52
  // a ticker, and only a video small on both axes is spinner-class.
29
53
  const rect = element.getBoundingClientRect();
@@ -32,6 +56,7 @@ export default {
32
56
  // something on the page already paused it — in both cases nothing is
33
57
  // moving, and a paused-by-a-control video is the mechanism working.
34
58
  if (element.paused && element.readyState >= 2) return { status: 'pass' };
59
+ if (pauseControlFor(element, isVisible)) return { status: 'pass' };
35
60
  return {
36
61
  status: 'incomplete',
37
62
  message: 'This video auto-plays on a loop with no built-in controls: it starts by itself and never ends, so it runs well past the five seconds at which WCAG 2.2.2 requires a way to pause, stop or hide it. Check the page offers a pause control that reaches this video, or that the video is the only content presented.',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pour-engine",
3
- "version": "1.37.1",
3
+ "version": "1.38.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",