pour-engine 1.27.0 → 1.29.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.27.0',
9
+ version: '1.29.0',
10
10
  },
11
11
  };
package/engine/index.js CHANGED
@@ -185,7 +185,9 @@ export async function run(context = document, options = {}, onProgress) {
185
185
 
186
186
  let outcomes;
187
187
  if (rule.evaluateAll) {
188
- outcomes = rule.evaluateAll(elements, ruleHelpers);
188
+ // Awaiting a plain array is a no-op, so sync evaluateAll rules are
189
+ // untouched — async ones (the deferred-reference probe) resolve here.
190
+ outcomes = await rule.evaluateAll(elements, ruleHelpers);
189
191
  } else {
190
192
  // Evaluate sequentially with periodic yields so ONE heavy rule on a
191
193
  // huge element set can't freeze the page for its whole duration —
@@ -130,6 +130,7 @@ let opacityAnimatorsCache = null;
130
130
  let mediaRectsCache = null;
131
131
  let panelRectsCache = null;
132
132
  let pseudoCache = new WeakMap();
133
+ let zeroClipCache = new WeakMap();
133
134
  const HAS_IMAGE = Symbol('background-image in chain');
134
135
 
135
136
  export function resetAuditCaches() {
@@ -139,6 +140,49 @@ export function resetAuditCaches() {
139
140
  mediaRectsCache = null;
140
141
  panelRectsCache = null;
141
142
  pseudoCache = new WeakMap();
143
+ zeroClipCache = new WeakMap();
144
+ }
145
+
146
+ /**
147
+ * True when the element sits inside a subtree erased by a ZERO-AREA clip —
148
+ * the sr-only recipe (clip: rect(0,0,0,0) / clip-path: inset(50%) on an
149
+ * absolutely positioned wrapper) applied to an ANCESTOR, which is where it
150
+ * usually lives: a chart's accessible data table hides the table, and the
151
+ * judged element is a cell inside it. Only provably zero-area patterns
152
+ * count: a clip rect with no width or height (any `auto` component keeps
153
+ * the element judged), or a percentage inset consuming a full axis.
154
+ * Partial clips never hide. Verdicts cache per ancestor, so a page pays
155
+ * per container, not per descendant.
156
+ */
157
+ export function inZeroClipSubtree(element) {
158
+ for (let a = element; a; a = a.parentElement) {
159
+ let hidden = zeroClipCache.get(a);
160
+ if (hidden === undefined) {
161
+ const s = getComputedStyle(a);
162
+ hidden = false;
163
+ if (s.clip !== 'auto' && s.position !== 'static') {
164
+ const m = /rect\(([^)]+)\)/.exec(s.clip);
165
+ if (m) {
166
+ const parts = m[1].split(',').map((v) => v.trim());
167
+ if (!parts.includes('auto') && parts.length === 4) {
168
+ const [t, r, b, l] = parts.map(parseFloat);
169
+ if (r - l <= 0 || b - t <= 0) hidden = true;
170
+ }
171
+ }
172
+ }
173
+ if (!hidden && s.clipPath && s.clipPath !== 'none') {
174
+ const m = /inset\(([^)]+)\)/.exec(s.clipPath);
175
+ if (m && !m[1].includes('px')) {
176
+ const parts = m[1].trim().split(/\s+/).map(parseFloat);
177
+ const [t, r = t, b = t, l = r] = parts;
178
+ if (t + b >= 100 || l + r >= 100) hidden = true;
179
+ }
180
+ }
181
+ zeroClipCache.set(a, hidden);
182
+ }
183
+ if (hidden) return true;
184
+ }
185
+ return false;
142
186
  }
143
187
 
144
188
  /**
@@ -3,7 +3,7 @@ import {
3
3
  parseColor, contrastRatio, composite, effectiveBackground, backgroundObscured,
4
4
  backgroundImageSource, backgroundImagePaintRect, imagePaintRectInBox, imageLuminanceRange,
5
5
  gradientLuminanceRange, rangeVerdict, rangeWithBackdrop, isLargeText, cumulativeOpacity,
6
- opacityAnimating, restingOpacity, mediaRects,
6
+ opacityAnimating, restingOpacity, mediaRects, inZeroClipSubtree,
7
7
  paintedBackdrop, opaquePanelRects, viewportVeil, textShadowHalo, textShadowNegligible,
8
8
  pseudoBackdropForText, filmedContrastBounds, backgroundColorSource, scrimPaint, applyOverlays,
9
9
  showRatio,
@@ -138,13 +138,15 @@ function textVisuallyHidden(element, style, foreground) {
138
138
  const indent = parseFloat(style.textIndent) || 0;
139
139
  const clipped = /hidden|clip/.test(`${style.overflow} ${style.overflowX} ${style.overflowY}`)
140
140
  || style.clipPath !== 'none' || (style.clip !== 'auto' && style.position === 'absolute');
141
- // Nothing hints at hiding: skip the getBoundingClientRect layout read —
142
- // this runs for every text element on the page, and most are plainly shown.
143
- if (!clipped && Math.abs(indent) <= 1000) return false;
141
+ // Nothing on the element itself hints at hiding: the recipe may still sit
142
+ // on a WRAPPER (a chart's sr-only data table hides the table; the judged
143
+ // cell inside computes clip: auto) — the ancestor walk is cached per
144
+ // container, so this stays affordable on every text element.
145
+ if (!clipped && Math.abs(indent) <= 1000) return inZeroClipSubtree(element);
144
146
  const rect = element.getBoundingClientRect();
145
147
  if ((rect.width <= 1 || rect.height <= 1) && clipped) return true;
146
148
  if (Math.abs(indent) > rect.width && (clipped || Math.abs(indent) > 1000)) return true;
147
- return false;
149
+ return inZeroClipSubtree(element);
148
150
  }
149
151
 
150
152
  // Turn a luminance-range bracket into a rule outcome (or null = can't tell).
@@ -6,6 +6,105 @@ const REF_ATTRIBUTES = [
6
6
  'aria-activedescendant', 'aria-owns', 'aria-errormessage', 'aria-details',
7
7
  ];
8
8
 
9
+ // Deferred-tooltip pattern: libraries (Deque's own docs component included)
10
+ // write aria-describedby="tooltip7" at rest and only CREATE #tooltip7 when
11
+ // the trigger is hovered or focused — which is exactly when a screen reader
12
+ // announces the description, so the pattern works. Only DESCRIPTIVE refs
13
+ // get this benefit of the doubt: a NAME that exists only on hover is broken
14
+ // for rotor navigation even when the deferral works, so aria-labelledby
15
+ // (and the rest) keep failing at rest.
16
+ const PROBEABLE = new Set(['aria-describedby', 'aria-details']);
17
+ // A handful of probes covers any real page; a template bug that dangles
18
+ // fifty refs is broken enough that the un-probed remainder failing as
19
+ // before is the right answer.
20
+ const PROBE_BUDGET = 8;
21
+ const PROBE_WAIT_MS = 250;
22
+
23
+ /** Fire the events a tooltip library listens for, wait for it to react,
24
+ * check whether the ids materialised, then put the page back. */
25
+ async function probeDeferred(element, ids) {
26
+ const over = ['pointerover', 'mouseover', 'mouseenter'];
27
+ const out = ['pointerout', 'mouseout', 'mouseleave'];
28
+ for (const type of over) element.dispatchEvent(new MouseEvent(type, { bubbles: type !== 'mouseenter' }));
29
+ element.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
30
+ try { element.focus?.({ preventScroll: true }); } catch { /* non-focusable */ }
31
+ await new Promise((resolve) => setTimeout(resolve, PROBE_WAIT_MS));
32
+ const root = element.getRootNode();
33
+ const appeared = ids.every((id) => root.getElementById?.(id));
34
+ // Restore: the audit must leave the page as it found it, and later rules
35
+ // must not meet a tooltip that only exists because we hovered.
36
+ for (const type of out) element.dispatchEvent(new MouseEvent(type, { bubbles: type !== 'mouseleave' }));
37
+ element.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
38
+ try { element.blur?.(); } catch { /* ignore */ }
39
+ await new Promise((resolve) => setTimeout(resolve, 50));
40
+ return appeared;
41
+ }
42
+
43
+ function inspect(element) {
44
+ // ARIA id references cannot cross shadow boundaries: they resolve only
45
+ // within the element's own root (document or shadow tree).
46
+ const root = element.getRootNode();
47
+ // Collapsed-disclosure pattern: a trigger with aria-expanded="false"
48
+ // whose aria-controls panel doesn't exist YET (rendered on expand) is
49
+ // ubiquitous and harmless while collapsed — name computation doesn't
50
+ // touch aria-controls, so nothing resolves wrongly today.
51
+ const collapsed = element.getAttribute('aria-expanded') === 'false';
52
+ const missing = [];
53
+ const ambiguous = [];
54
+ for (const attr of REF_ATTRIBUTES) {
55
+ for (const id of (element.getAttribute(attr) ?? '').split(/\s+/).filter(Boolean)) {
56
+ if (!root.getElementById?.(id)) {
57
+ if (attr === 'aria-controls' && collapsed) continue;
58
+ missing.push({ attr, id });
59
+ continue;
60
+ }
61
+ const count = root.querySelectorAll(`[id="${CSS.escape(id)}"]`).length;
62
+ if (count > 1) {
63
+ ambiguous.push(`${attr}="${id}" — ${count} elements share this id; the reference binds to the FIRST one in the DOM`);
64
+ }
65
+ }
66
+ }
67
+ return { missing, ambiguous };
68
+ }
69
+
70
+ function outcome({ missing, ambiguous }, probed, element, accessibleName) {
71
+ if (missing.length) {
72
+ // A dangling ref is skipped by the accname computation, so the HARM
73
+ // depends on what remains. A labelledby that dangles while the element
74
+ // still names itself (contents, aria-label) is inert clutter — real,
75
+ // fragile, validator-flagged, but announced correctly today. The same
76
+ // dangle on an otherwise nameless element is the 4.1.2 failure proper:
77
+ // names are consumed at REST (element lists, rotor), so the finding
78
+ // says which of the two this is instead of one blanket accusation.
79
+ const restingName = (accessibleName?.(element) ?? '').trim();
80
+ const detail = missing
81
+ .map(({ attr, id }) => {
82
+ if (attr === 'aria-labelledby') {
83
+ return restingName
84
+ ? `aria-labelledby="${id}" points to nothing — the reference is ignored and this element's name ("${restingName.slice(0, 40)}") currently comes from elsewhere; announced correctly today, but the dead reference is fragile`
85
+ : `aria-labelledby="${id}" points to nothing and leaves this element without an accessible name`;
86
+ }
87
+ return `${attr}="${id}" points to nothing — assistive technology silently ignores it`;
88
+ })
89
+ .join('; ');
90
+ return {
91
+ status: 'fail',
92
+ message: `Broken ARIA references: ${detail}.${probed ? ' Probed with focus and hover in case the target is created on demand — it never appeared.' : ''}`,
93
+ fix: 'Correct or remove the reference, or give the target element that id.',
94
+ };
95
+ }
96
+ if (ambiguous.length) {
97
+ // Duplicate ids resolve deterministically to the first copy — often
98
+ // the right one (desktop/mobile double-renders). Real, but a human
99
+ // must judge whether the first copy is the wrong copy.
100
+ return {
101
+ status: 'incomplete',
102
+ message: `Ambiguous ARIA references: ${ambiguous.join('; ')}. If the first copy in the DOM is the intended target, this works today — but it's fragile.`,
103
+ };
104
+ }
105
+ return { status: 'pass' };
106
+ }
107
+
9
108
  export default {
10
109
  id: 'aria-valid-refs',
11
110
  // Moderate, not critical: with 4.1.1 Parsing retired in WCAG 2.2 these
@@ -16,46 +115,26 @@ export default {
16
115
  help: 'ARIA id references must point to elements that exist',
17
116
  helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
18
117
  selector: REF_ATTRIBUTES.map((attr) => `[${attr}]`).join(', '),
19
- evaluate(element) {
20
- // ARIA id references cannot cross shadow boundaries: they resolve only
21
- // within the element's own root (document or shadow tree).
22
- const root = element.getRootNode();
23
- // Collapsed-disclosure pattern: a trigger with aria-expanded="false"
24
- // whose aria-controls panel doesn't exist YET (rendered on expand) is
25
- // ubiquitous and harmless while collapsed — name computation doesn't
26
- // touch aria-controls, so nothing resolves wrongly today.
27
- const collapsed = element.getAttribute('aria-expanded') === 'false';
28
- const missing = [];
29
- const ambiguous = [];
30
- for (const attr of REF_ATTRIBUTES) {
31
- for (const id of (element.getAttribute(attr) ?? '').split(/\s+/).filter(Boolean)) {
32
- if (!root.getElementById?.(id)) {
33
- if (attr === 'aria-controls' && collapsed) continue;
34
- missing.push(`${attr}="${id}" points to nothing — assistive technology silently ignores it`);
118
+ async evaluateAll(elements, { accessibleName } = {}) {
119
+ let budget = PROBE_BUDGET;
120
+ const outcomes = new Array(elements.length);
121
+ for (let i = 0; i < elements.length; i++) {
122
+ const element = elements[i];
123
+ const found = inspect(element);
124
+ const deferredOnly = found.missing.length > 0
125
+ && found.missing.every(({ attr }) => PROBEABLE.has(attr));
126
+ if (deferredOnly && budget > 0) {
127
+ budget--;
128
+ if (await probeDeferred(element, found.missing.map(({ id }) => id))) {
129
+ // The description materialises exactly when it would be announced.
130
+ outcomes[i] = { status: 'pass' };
35
131
  continue;
36
132
  }
37
- const count = root.querySelectorAll(`[id="${CSS.escape(id)}"]`).length;
38
- if (count > 1) {
39
- ambiguous.push(`${attr}="${id}" — ${count} elements share this id; the reference binds to the FIRST one in the DOM`);
40
- }
133
+ outcomes[i] = outcome(found, true, element, accessibleName);
134
+ continue;
41
135
  }
136
+ outcomes[i] = outcome(found, false, element, accessibleName);
42
137
  }
43
- if (missing.length) {
44
- return {
45
- status: 'fail',
46
- message: `Broken ARIA references: ${missing.join('; ')}.`,
47
- fix: 'Correct or remove the reference, or give the target element that id.',
48
- };
49
- }
50
- if (ambiguous.length) {
51
- // Duplicate ids resolve deterministically to the first copy — often
52
- // the right one (desktop/mobile double-renders). Real, but a human
53
- // must judge whether the first copy is the wrong copy.
54
- return {
55
- status: 'incomplete',
56
- message: `Ambiguous ARIA references: ${ambiguous.join('; ')}. If the first copy in the DOM is the intended target, this works today — but it's fragile.`,
57
- };
58
- }
59
- return { status: 'pass' };
138
+ return outcomes;
60
139
  },
61
140
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pour-engine",
3
- "version": "1.27.0",
3
+ "version": "1.29.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",