pour-engine 1.38.0 → 1.41.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.
- package/config/project.config.js +1 -1
- package/engine/AUDIT.html +24 -690
- package/engine/COVERAGE.md +4 -2
- package/engine/README.md +9 -4
- package/engine/index.js +34 -8
- package/engine/lib/accessible-name.js +81 -64
- package/engine/lib/contrast.js +111 -10
- package/engine/lib/dom.js +78 -18
- package/engine/lib/roles.js +18 -3
- package/engine/rules/best-practice/fieldset-legend.js +11 -5
- package/engine/rules/best-practice/landmark-unique.js +9 -1
- package/engine/rules/best-practice/region.js +10 -2
- package/engine/rules/wcag/1.1.1-area-alt.js +6 -1
- package/engine/rules/wcag/1.1.1-image-alt.js +2 -1
- package/engine/rules/wcag/1.1.1-svg-img-alt.js +14 -17
- package/engine/rules/wcag/1.3.1-aria-required-children.js +20 -8
- package/engine/rules/wcag/1.3.1-aria-required-parent.js +7 -10
- package/engine/rules/wcag/1.3.1-definition-list.js +42 -3
- package/engine/rules/wcag/1.3.1-form-label.js +12 -29
- package/engine/rules/wcag/1.3.1-list-structure.js +4 -3
- package/engine/rules/wcag/1.3.1-table-headers.js +23 -2
- package/engine/rules/wcag/1.3.4-orientation.js +117 -48
- package/engine/rules/wcag/1.3.5-autocomplete-valid.js +21 -8
- package/engine/rules/wcag/1.4.1-link-in-text-block.js +38 -8
- package/engine/rules/wcag/1.4.10-reflow.js +3 -3
- package/engine/rules/wcag/1.4.11-non-text-contrast.js +9 -1
- package/engine/rules/wcag/1.4.12-text-spacing.js +15 -2
- package/engine/rules/wcag/1.4.3-color-contrast.js +79 -7
- package/engine/rules/wcag/1.4.3-control-contrast.js +35 -6
- package/engine/rules/wcag/1.4.4-meta-viewport.js +7 -2
- package/engine/rules/wcag/2.1.1-scrollable-region-focusable.js +5 -5
- package/engine/rules/wcag/2.2.1-meta-refresh.js +39 -3
- package/engine/rules/wcag/2.2.2-pause-stop-hide.js +5 -5
- package/engine/rules/wcag/2.4.1-bypass-blocks.js +2 -2
- package/engine/rules/wcag/2.4.2-document-title.js +7 -8
- package/engine/rules/wcag/2.4.4-link-text-generic.js +6 -5
- package/engine/rules/wcag/2.4.9-link-text-generic-only.js +8 -23
- package/engine/rules/wcag/2.5.8-target-size.js +201 -47
- package/engine/rules/wcag/3.3.8-auth-field-obstruction.js +6 -12
- package/engine/rules/wcag/4.1.2-aria-attr-valid.js +4 -0
- package/engine/rules/wcag/4.1.2-aria-field-name.js +6 -5
- package/engine/rules/wcag/4.1.2-aria-valid-refs.js +8 -3
- package/engine/rules/wcag/4.1.2-button-name.js +1 -1
- package/engine/rules/wcag/4.1.2-composite-widget-name.js +1 -1
- package/engine/rules/wcag/4.1.2-invoker-target.js +2 -1
- package/engine/rules/wcag/4.1.2-label-for-valid.js +2 -2
- package/engine/rules/wcag/4.1.2-nested-interactive.js +20 -8
- package/engine/rules/wcag/4.1.2-valid-role.js +6 -1
- package/engine/wcag22.js +7 -7
- package/package.json +1 -1
package/engine/COVERAGE.md
CHANGED
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
Generated by `node scripts/wcag-coverage.js` — do not edit by hand.
|
|
4
4
|
|
|
5
5
|
All 86 success criteria of WCAG 2.2, and how the native engine covers them.
|
|
6
|
-
"Partially automated" means
|
|
7
|
-
|
|
6
|
+
"Partially automated" means rules check some aspects or identify candidates for human review.
|
|
7
|
+
Some rules return review findings only; a listed rule does not promise an automatic violation verdict.
|
|
8
|
+
"Manual review" means no active rule currently assists with that criterion. Human review is also
|
|
9
|
+
needed for partially automated criteria; no rule proves conformance.
|
|
8
10
|
No criterion is listed as fully "Automated": every one of them ends in a question about
|
|
9
11
|
meaning that only a person can settle, even where a rule decides most real pages.
|
|
10
12
|
|
package/engine/README.md
CHANGED
|
@@ -7,8 +7,9 @@ the product's only engine.
|
|
|
7
7
|
**WCAG only, by design.** pour engine tracks the WCAG spec exclusively — no
|
|
8
8
|
Section 508, EN 301 549, or other standards. The full WCAG 2.2 catalog (all
|
|
9
9
|
86 success criteria) lives in `wcag22.js`, and `COVERAGE.md` (generated by
|
|
10
|
-
`node scripts/wcag-coverage.js`) shows
|
|
11
|
-
|
|
10
|
+
`node scripts/wcag-coverage.js`) shows which criteria have automated assistance
|
|
11
|
+
and which currently rely on the manual checklist. Assistance includes checks
|
|
12
|
+
that only nominate candidates for human review. `AUDIT.html` is the latest full
|
|
12
13
|
spec-sweep audit of every rule against the spec — over-violation,
|
|
13
14
|
under-violation, and what the engine cannot test. It is a self-contained
|
|
14
15
|
page: open it straight from disk in any browser. Regenerated by running
|
|
@@ -103,12 +104,16 @@ just to static analysis.
|
|
|
103
104
|
Rules alone can never "cover WCAG" — most criteria need human judgment.
|
|
104
105
|
pour engine therefore reports on **every** in-scope criterion, one of two ways:
|
|
105
106
|
|
|
106
|
-
1. **Automated
|
|
107
|
+
1. **Automated assistance**: the rules in `rules/` either establish specific
|
|
108
|
+
failures or nominate review candidates. A rule may return only review
|
|
109
|
+
findings when the DOM cannot establish a violation.
|
|
107
110
|
2. **Manual review** — every audit's results include `manualReview`: the
|
|
108
111
|
in-scope criteria (filtered by the requested WCAG version and level)
|
|
109
112
|
that automation can't judge, for a host to render however it wants.
|
|
110
113
|
|
|
111
|
-
|
|
114
|
+
The checklist accounts for the criteria; it does not mean every page state
|
|
115
|
+
or possible failure has been tested. A snapshot audit still needs human
|
|
116
|
+
assessment, including interaction states and any checks reported as skipped.
|
|
112
117
|
|
|
113
118
|
## Honest current state
|
|
114
119
|
|
package/engine/index.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import config from '../config/project.config.js';
|
|
5
5
|
import rules from './rules/index.js';
|
|
6
6
|
import wcagCatalog from './wcag22.js';
|
|
7
|
-
import { isVisible, isRendered, cssPath, htmlSnippet, ownText, collectRoots } from './lib/dom.js';
|
|
7
|
+
import { isVisible, isRendered, cssPath, htmlSnippet, ownText, collectRoots, resetDOMCaches, releaseDOMCaches } from './lib/dom.js';
|
|
8
8
|
import { accessibleName } from './lib/accessible-name.js';
|
|
9
9
|
import { resetAuditCaches } from './lib/contrast.js';
|
|
10
10
|
|
|
@@ -116,18 +116,23 @@ export async function run(context = document, options = {}, onProgress) {
|
|
|
116
116
|
// Per-audit caches (resolved backgrounds, opacities) must start fresh —
|
|
117
117
|
// the page may have changed since the last run.
|
|
118
118
|
resetAuditCaches();
|
|
119
|
+
resetDOMCaches();
|
|
119
120
|
// Every queryable root — the page plus all open shadow trees — collected
|
|
120
121
|
// once so rules see web-component content without re-walking per rule.
|
|
121
122
|
const roots = collectRoots(context.querySelectorAll ? context : document);
|
|
122
123
|
// Universal-selector rules (aria attribute checks) share one full scan
|
|
123
124
|
// instead of each re-enumerating every element on the page.
|
|
124
125
|
let universalScan = null;
|
|
126
|
+
const query = (root, selector) => [
|
|
127
|
+
...(root.nodeType === 1 && root.matches(selector) ? [root] : []),
|
|
128
|
+
...root.querySelectorAll(selector),
|
|
129
|
+
];
|
|
125
130
|
const elementsFor = (selector) => {
|
|
126
131
|
if (selector === '*') {
|
|
127
|
-
universalScan ??= roots.flatMap((root) =>
|
|
132
|
+
universalScan ??= roots.flatMap((root) => query(root, '*'));
|
|
128
133
|
return universalScan;
|
|
129
134
|
}
|
|
130
|
-
return roots.flatMap((root) =>
|
|
135
|
+
return roots.flatMap((root) => query(root, selector));
|
|
131
136
|
};
|
|
132
137
|
let completed = 0;
|
|
133
138
|
// Running totals so the UI can count issues up live as rules finish —
|
|
@@ -161,6 +166,16 @@ export async function run(context = document, options = {}, onProgress) {
|
|
|
161
166
|
lastYield = performance.now();
|
|
162
167
|
};
|
|
163
168
|
|
|
169
|
+
const checkAbort = () => {
|
|
170
|
+
if (options.signal?.aborted) throw new DOMException('Audit stopped', 'AbortError');
|
|
171
|
+
};
|
|
172
|
+
// Bulk rules use the same task budget and cancellation as per-node rules.
|
|
173
|
+
const helpers = { ...ruleHelpers, yieldToMain: async () => {
|
|
174
|
+
checkAbort();
|
|
175
|
+
await yieldToRenderer();
|
|
176
|
+
checkAbort();
|
|
177
|
+
} };
|
|
178
|
+
try {
|
|
164
179
|
for (const rule of activeRules) {
|
|
165
180
|
// Between rules is the abort granularity we have — the same boundary
|
|
166
181
|
// the renderer yield uses.
|
|
@@ -186,7 +201,7 @@ export async function run(context = document, options = {}, onProgress) {
|
|
|
186
201
|
if (rule.evaluateAll) {
|
|
187
202
|
// Awaiting a plain array is a no-op, so sync evaluateAll rules are
|
|
188
203
|
// untouched — async ones (the deferred-reference probe) resolve here.
|
|
189
|
-
outcomes = await rule.evaluateAll(elements,
|
|
204
|
+
outcomes = await rule.evaluateAll(elements, helpers);
|
|
190
205
|
} else {
|
|
191
206
|
// Evaluate sequentially with periodic yields so ONE heavy rule on a
|
|
192
207
|
// huge element set can't freeze the page for its whole duration —
|
|
@@ -204,7 +219,7 @@ export async function run(context = document, options = {}, onProgress) {
|
|
|
204
219
|
running: true,
|
|
205
220
|
}));
|
|
206
221
|
}
|
|
207
|
-
outcomes[i] = await rule.evaluate(elements[i],
|
|
222
|
+
outcomes[i] = await rule.evaluate(elements[i], helpers);
|
|
208
223
|
}
|
|
209
224
|
}
|
|
210
225
|
|
|
@@ -214,14 +229,19 @@ export async function run(context = document, options = {}, onProgress) {
|
|
|
214
229
|
// large pages.
|
|
215
230
|
const buckets = { fail: [], incomplete: [] };
|
|
216
231
|
let passCount = 0;
|
|
217
|
-
|
|
232
|
+
let skippedCount = 0;
|
|
233
|
+
for (let i = 0; i < elements.length; i++) {
|
|
234
|
+
if ((i & 127) === 0) await helpers.yieldToMain();
|
|
235
|
+
const element = elements[i];
|
|
218
236
|
const status = outcomes[i].status;
|
|
219
237
|
if (status === 'pass') passCount += 1;
|
|
238
|
+
else if (status === 'skipped') skippedCount += 1;
|
|
220
239
|
else buckets[status]?.push(toResultNode(element, outcomes[i]));
|
|
221
|
-
}
|
|
240
|
+
}
|
|
222
241
|
|
|
223
242
|
const ruleResult = {
|
|
224
243
|
id: rule.id,
|
|
244
|
+
...(skippedCount ? { skippedCount } : {}),
|
|
225
245
|
// Human display name ("Form field labels"); the id stays the
|
|
226
246
|
// machine handle everywhere (selectors, exports, docs anchors).
|
|
227
247
|
name: rule.name ?? rule.id,
|
|
@@ -241,7 +261,8 @@ export async function run(context = document, options = {}, onProgress) {
|
|
|
241
261
|
if (buckets.fail.length) liveTypeCounts[rule.impact] = (liveTypeCounts[rule.impact] ?? 0) + 1;
|
|
242
262
|
if (buckets.incomplete.length) liveTypeCounts.review += 1;
|
|
243
263
|
const ms = Math.round((performance.now() - ruleStarted) * 10) / 10;
|
|
244
|
-
results.ruleTimings.push({ rule: rule.id, ms, elements: elements.length });
|
|
264
|
+
results.ruleTimings.push({ rule: rule.id, ms, elements: elements.length, ...(skippedCount ? { skippedCount } : {}) });
|
|
265
|
+
if (skippedCount) results.skippedChecks = (results.skippedChecks ?? 0) + skippedCount;
|
|
245
266
|
onProgress?.({
|
|
246
267
|
done: ++completed,
|
|
247
268
|
total: activeRules.length,
|
|
@@ -263,4 +284,9 @@ export async function run(context = document, options = {}, onProgress) {
|
|
|
263
284
|
|
|
264
285
|
results.durationMs = Math.round(performance.now() - auditStarted);
|
|
265
286
|
return results;
|
|
287
|
+
} finally {
|
|
288
|
+
releaseDOMCaches();
|
|
289
|
+
yieldChannel?.port1.close();
|
|
290
|
+
yieldChannel?.port2.close();
|
|
291
|
+
}
|
|
266
292
|
}
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
// the ARIA accname spec: aria-labelledby → aria-label → native labelling
|
|
3
3
|
// (alt, <label>, text content, value) → title/placeholder fallbacks.
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
// rules
|
|
5
|
+
// This DOM implementation follows the recursive naming paths used by the
|
|
6
|
+
// rules. Browser accessibility trees remain the behavioral check for edge
|
|
7
|
+
// cases; text presence alone is not a full assessment of name quality.
|
|
8
|
+
import { flatTreeParent } from './dom.js';
|
|
7
9
|
|
|
8
10
|
/** Input types whose `.value` is user-entered text rather than a state. */
|
|
9
11
|
const TEXT_INPUT = new Set(['text', 'search', 'url', 'tel', 'email', 'password', 'number', 'date',
|
|
@@ -15,7 +17,7 @@ const TEXT_INPUT = new Set(['text', 'search', 'url', 'tel', 'email', 'password',
|
|
|
15
17
|
const LABELABLE = new Set(['input', 'select', 'textarea', 'button', 'meter', 'output', 'progress']);
|
|
16
18
|
|
|
17
19
|
export function accessibleName(element) {
|
|
18
|
-
return computeName(element, false);
|
|
20
|
+
return computeName(element, false, false, new Set());
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
/** The name aria-labelledby contributes, resolved the way accname requires:
|
|
@@ -24,27 +26,48 @@ export function accessibleName(element) {
|
|
|
24
26
|
* too, and reading textContent instead reports those elements as nameless.
|
|
25
27
|
* Rules that only need "is it labelled?" call this directly. */
|
|
26
28
|
export function labelledByName(element) {
|
|
29
|
+
return referencedName(element, new Set()) ?? '';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Native label text, excluding the control being named from recursion. */
|
|
33
|
+
export function nativeLabelName(element, label) {
|
|
34
|
+
return computeName(label, false, hiddenForName(label), new Set([element]));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Accname 2A: only a reference whose root is itself hidden includes its
|
|
38
|
+
// hidden descendants. A visible reference does not resurrect hidden text.
|
|
39
|
+
function hiddenForName(element) {
|
|
40
|
+
for (let node = element; node; node = flatTreeParent(node)) {
|
|
41
|
+
if (node.getAttribute?.('aria-hidden') === 'true') return true;
|
|
42
|
+
const style = getComputedStyle(node);
|
|
43
|
+
if (style.display === 'none') return true;
|
|
44
|
+
}
|
|
45
|
+
const visibility = getComputedStyle(element).visibility;
|
|
46
|
+
return visibility === 'hidden' || visibility === 'collapse';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function referencedName(element, visited) {
|
|
27
50
|
const refs = element.getAttribute?.('aria-labelledby');
|
|
28
|
-
if (!refs) return
|
|
51
|
+
if (!refs) return null;
|
|
29
52
|
const root = element.getRootNode();
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
.trim();
|
|
53
|
+
const targets = refs.split(/\s+/).filter(Boolean).map((id) => root.getElementById?.(id)).filter(Boolean);
|
|
54
|
+
if (!targets.length) return null;
|
|
55
|
+
return targets.map((target) => {
|
|
56
|
+
// Self-reference can contribute the element's aria-label or content,
|
|
57
|
+
// but does not follow aria-labelledby again (accname 2B).
|
|
58
|
+
const path = new Set(visited);
|
|
59
|
+
if (target === element) path.delete(element);
|
|
60
|
+
return computeName(target, true, hiddenForName(target), path);
|
|
61
|
+
}).join(' ').replace(/\s+/g, ' ').trim();
|
|
40
62
|
}
|
|
41
63
|
|
|
42
|
-
function computeName(element, inLabelledBy) {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
// aria-labelledby="a"> recursing forever.
|
|
64
|
+
function computeName(element, inLabelledBy, includeHidden, visited) {
|
|
65
|
+
if (visited.has(element)) return '';
|
|
66
|
+
visited.add(element);
|
|
46
67
|
if (!inLabelledBy) {
|
|
47
|
-
const fromLabelledBy =
|
|
68
|
+
const fromLabelledBy = referencedName(element, visited);
|
|
69
|
+
// Browsers retain aria-label/content fallbacks when all referenced
|
|
70
|
+
// text is empty. Do not assert a missing name that is actually exposed.
|
|
48
71
|
if (fromLabelledBy) return fromLabelledBy;
|
|
49
72
|
}
|
|
50
73
|
|
|
@@ -64,7 +87,8 @@ function computeName(element, inLabelledBy) {
|
|
|
64
87
|
// id="b"> from the label. Widened from the three field tags 2026-08-25
|
|
65
88
|
// overnight audit: button-name was asserting a label-named button nameless.
|
|
66
89
|
if (LABELABLE.has(tag) && element.labels?.length) {
|
|
67
|
-
const text = [...element.labels].map((label) =>
|
|
90
|
+
const text = [...element.labels].map((label) =>
|
|
91
|
+
computeName(label, inLabelledBy, hiddenForName(label), visited)).join(' ').trim();
|
|
68
92
|
if (text) return text;
|
|
69
93
|
}
|
|
70
94
|
|
|
@@ -98,27 +122,43 @@ function computeName(element, inLabelledBy) {
|
|
|
98
122
|
// display:none/visibility:hidden subtrees does not name an element
|
|
99
123
|
// (per the accname spec). The one exception is a subtree reached THROUGH
|
|
100
124
|
// aria-labelledby, where accname ignores the hidden state.
|
|
101
|
-
const fromContents = visibleContentText(element, inLabelledBy).replace(/\s+/g, ' ').trim();
|
|
125
|
+
const fromContents = visibleContentText(element, includeHidden, inLabelledBy, visited).replace(/\s+/g, ' ').trim();
|
|
102
126
|
if (fromContents) return fromContents;
|
|
103
127
|
|
|
104
128
|
return (element.getAttribute('title') ?? element.getAttribute('placeholder') ?? '').trim();
|
|
105
129
|
}
|
|
106
130
|
|
|
107
|
-
function visibleContentText(element, includeHidden) {
|
|
131
|
+
function visibleContentText(element, includeHidden, inLabelledBy, visited) {
|
|
108
132
|
// A custom element with a shadow root renders its shadow content — name
|
|
109
133
|
// from contents follows the FLAT tree (slots inside pull light DOM back).
|
|
110
134
|
const nodes = element.shadowRoot ? element.shadowRoot.childNodes : element.childNodes;
|
|
111
|
-
return generatedContent(element, '::before')
|
|
112
|
-
+ textFromNodes(nodes, includeHidden)
|
|
113
|
-
+ generatedContent(element, '::after');
|
|
135
|
+
return generatedContent(element, '::before', includeHidden)
|
|
136
|
+
+ textFromNodes(nodes, includeHidden, inLabelledBy, visited)
|
|
137
|
+
+ generatedContent(element, '::after', includeHidden);
|
|
114
138
|
}
|
|
115
139
|
|
|
116
140
|
/** CSS generated content contributes to name-from-contents per the accname
|
|
117
141
|
* spec — icon-font prefixes and content:"…" labels are real names. Only
|
|
118
|
-
* plain strings count: counters, url(), attr() are not resolvable here.
|
|
119
|
-
|
|
120
|
-
|
|
142
|
+
* plain strings count: counters, url(), attr() are not resolvable here.
|
|
143
|
+
*
|
|
144
|
+
* The exception is the CSS alternative text syntax, content: <image or
|
|
145
|
+
* string> / "alt", which exists precisely to give assistive technology the
|
|
146
|
+
* text for generated content and which browsers expose: an icon-only button
|
|
147
|
+
* drawn with content: url(menu.svg) / "Menu" IS named "Menu" (Chromium's
|
|
148
|
+
* accessibility tree agrees), and calling it nameless asserted a critical
|
|
149
|
+
* 4.1.2 failure against the recommended pattern. An EMPTY alternative marks
|
|
150
|
+
* the generated content decorative and contributes nothing, which is how
|
|
151
|
+
* the same syntax silences icon glyphs. */
|
|
152
|
+
function generatedContent(element, pseudo, includeHidden) {
|
|
153
|
+
const style = getComputedStyle(element, pseudo);
|
|
154
|
+
// Generated nodes follow the same hidden-content exclusion as ordinary
|
|
155
|
+
// descendants. A hover label can exist in computed content while its
|
|
156
|
+
// pseudo-element is visibility:hidden and absent from the AX name.
|
|
157
|
+
if (!includeHidden && (style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse')) return '';
|
|
158
|
+
const content = style.content;
|
|
121
159
|
if (!content || content === 'none' || content === 'normal') return '';
|
|
160
|
+
const alt = content.match(/\/\s*"((?:[^"\\]|\\.)*)"\s*$/);
|
|
161
|
+
if (alt) return alt[1].replace(/\\(.)/g, '$1');
|
|
122
162
|
const match = content.match(/^"((?:[^"\\]|\\.)*)"$/);
|
|
123
163
|
return match ? match[1].replace(/\\(.)/g, '$1') : '';
|
|
124
164
|
}
|
|
@@ -126,7 +166,7 @@ function generatedContent(element, pseudo) {
|
|
|
126
166
|
/** `includeHidden` is set while resolving an aria-labelledby target: accname
|
|
127
167
|
* ignores the hidden state of referenced content, which is what makes the
|
|
128
168
|
* visually-hidden label span (and the display:none one) work in browsers. */
|
|
129
|
-
function textFromNodes(nodes, includeHidden) {
|
|
169
|
+
function textFromNodes(nodes, includeHidden, inLabelledBy, visited) {
|
|
130
170
|
let text = '';
|
|
131
171
|
for (const node of nodes) {
|
|
132
172
|
if (node.nodeType === 3 /* TEXT_NODE */) {
|
|
@@ -141,50 +181,27 @@ function textFromNodes(nodes, includeHidden) {
|
|
|
141
181
|
// with lazy-load <noscript> fallbacks leaking "<img …>" into names).
|
|
142
182
|
if (tag === 'script' || tag === 'style' || tag === 'noscript' || tag === 'template') continue;
|
|
143
183
|
if (!includeHidden) {
|
|
144
|
-
if (node.getAttribute('aria-hidden') === 'true'
|
|
184
|
+
if (node.getAttribute('aria-hidden') === 'true') continue;
|
|
145
185
|
const style = getComputedStyle(node);
|
|
146
|
-
if (style.display === 'none' || style.visibility === 'hidden') continue;
|
|
186
|
+
if (style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse') continue;
|
|
147
187
|
}
|
|
148
188
|
// A slot renders its assigned light-DOM nodes (fallback children when
|
|
149
189
|
// nothing is slotted) — a shadow <button><slot></slot></button> is named
|
|
150
190
|
// by the text the page slots in.
|
|
151
191
|
if (tag === 'slot') {
|
|
152
192
|
const assigned = node.assignedNodes?.() ?? [];
|
|
153
|
-
text += textFromNodes(assigned.length ? assigned : node.childNodes, includeHidden);
|
|
193
|
+
text += textFromNodes(assigned.length ? assigned : node.childNodes, includeHidden, inLabelledBy, visited);
|
|
154
194
|
continue;
|
|
155
195
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const alt = node.getAttribute('alt');
|
|
166
|
-
if (alt !== null) { text += ` ${alt} `; continue; }
|
|
167
|
-
const imgTitle = node.getAttribute('title')?.trim();
|
|
168
|
-
if (imgTitle) text += ` ${imgTitle} `;
|
|
169
|
-
continue;
|
|
170
|
-
}
|
|
171
|
-
const ariaLabel = node.getAttribute('aria-label')?.trim();
|
|
172
|
-
if (ariaLabel) {
|
|
173
|
-
text += ` ${ariaLabel} `;
|
|
174
|
-
continue;
|
|
175
|
-
}
|
|
176
|
-
// Flat-tree descent: shadow content renders in place of a host's
|
|
177
|
-
// light children (archive.org-style nested web components).
|
|
178
|
-
const fromSubtree = textFromNodes(node.shadowRoot ? node.shadowRoot.childNodes : node.childNodes, includeHidden);
|
|
179
|
-
if (fromSubtree.trim()) {
|
|
180
|
-
text += fromSubtree;
|
|
181
|
-
continue;
|
|
182
|
-
}
|
|
183
|
-
// Accname's tooltip fallback applies per traversed element: a
|
|
184
|
-
// text-less <div title="upvote"> inside a link names it "upvote"
|
|
185
|
-
// (browsers and screen readers both honour this).
|
|
186
|
-
const title = node.getAttribute('title')?.trim();
|
|
187
|
-
if (title) text += ` ${title} `;
|
|
196
|
+
// Every descendant re-enters the naming algorithm, including its
|
|
197
|
+
// references, native labels and generated content (accname 2F).
|
|
198
|
+
// Empty alt remains decorative rather than falling through to title.
|
|
199
|
+
if ((tag === 'img' || tag === 'area') && node.getAttribute('alt') === ''
|
|
200
|
+
&& !node.getAttribute('aria-label')?.trim() && !node.getAttribute('aria-labelledby')) continue;
|
|
201
|
+
const childName = computeName(node, inLabelledBy, includeHidden, visited);
|
|
202
|
+
text += tag === 'img' || tag === 'area' || node.hasAttribute('aria-label') || node.hasAttribute('aria-labelledby')
|
|
203
|
+
? ` ${childName} ` : childName;
|
|
204
|
+
|
|
188
205
|
}
|
|
189
206
|
return text;
|
|
190
207
|
}
|
package/engine/lib/contrast.js
CHANGED
|
@@ -132,6 +132,9 @@ let panelRectsCache = null;
|
|
|
132
132
|
let pseudoCache = new WeakMap();
|
|
133
133
|
let zeroClipCache = new WeakMap();
|
|
134
134
|
let firstLineRulesCache = new WeakMap();
|
|
135
|
+
let chainEffectCache = new WeakMap();
|
|
136
|
+
let uncoveredEffectCache = new WeakMap();
|
|
137
|
+
let blendBackdropCache = new WeakMap();
|
|
135
138
|
const HAS_IMAGE = Symbol('background-image in chain');
|
|
136
139
|
|
|
137
140
|
export function resetAuditCaches() {
|
|
@@ -143,6 +146,100 @@ export function resetAuditCaches() {
|
|
|
143
146
|
pseudoCache = new WeakMap();
|
|
144
147
|
zeroClipCache = new WeakMap();
|
|
145
148
|
firstLineRulesCache = new WeakMap();
|
|
149
|
+
chainEffectCache = new WeakMap();
|
|
150
|
+
uncoveredEffectCache = new WeakMap();
|
|
151
|
+
blendBackdropCache = new WeakMap();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const flatParentOf = (node) => node.assignedSlot ?? node.parentElement ?? node.getRootNode()?.host ?? null;
|
|
155
|
+
|
|
156
|
+
/** Every filter function except drop-shadow recolours the pixels it applies
|
|
157
|
+
* to (blur, grayscale, invert, url() and the rest). A drop shadow paints a
|
|
158
|
+
* copy of the element's silhouette BEHIND it and leaves its own pixels
|
|
159
|
+
* alone. Parsed at nesting depth zero so the rgba() inside a shadow is not
|
|
160
|
+
* read as a second function. */
|
|
161
|
+
function colourChangingFilter(filter) {
|
|
162
|
+
if (!filter || filter === 'none') return false;
|
|
163
|
+
let depth = 0;
|
|
164
|
+
let name = '';
|
|
165
|
+
for (const ch of filter) {
|
|
166
|
+
if (ch === '(') {
|
|
167
|
+
if (depth === 0 && name && name !== 'drop-shadow') return true;
|
|
168
|
+
depth += 1;
|
|
169
|
+
name = '';
|
|
170
|
+
} else if (ch === ')') {
|
|
171
|
+
depth -= 1;
|
|
172
|
+
} else if (depth === 0) {
|
|
173
|
+
name = /[a-z-]/i.test(ch) ? name + ch.toLowerCase() : '';
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** A colour-changing filter or a blend mode on the element or any flat-tree
|
|
180
|
+
* ancestor recolours the text no matter what is painted in between: a
|
|
181
|
+
* greyscale group is grey throughout, and a group blend blends the whole
|
|
182
|
+
* group, opaque cards included, against what lies behind it. */
|
|
183
|
+
function chainEffect(node) {
|
|
184
|
+
if (!node || node.nodeType !== 1) return false;
|
|
185
|
+
if (chainEffectCache.has(node)) return chainEffectCache.get(node);
|
|
186
|
+
const style = getComputedStyle(node);
|
|
187
|
+
const own = colourChangingFilter(style.filter) || (style.mixBlendMode && style.mixBlendMode !== 'normal');
|
|
188
|
+
const result = Boolean(own || chainEffect(flatParentOf(node)));
|
|
189
|
+
chainEffectCache.set(node, result);
|
|
190
|
+
return result;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** A backdrop filter and a drop shadow only reach the text through paint
|
|
194
|
+
* that is not opaque: the frosted-glass header (backdrop-filter: blur on a
|
|
195
|
+
* translucent bar) shows the blurred page through everything up to the
|
|
196
|
+
* first opaque background, and a drop shadow on a transparent wrapper is a
|
|
197
|
+
* shadow of the glyphs themselves. Opaque paint at or below the effect
|
|
198
|
+
* covers it, so the text's backdrop is that paint and the effect is not an
|
|
199
|
+
* uncertainty. Measured 2026-09-12: 72 of 184 such reviews on nine sites
|
|
200
|
+
* had opaque paint between the text and the effect. */
|
|
201
|
+
function uncoveredEffect(node) {
|
|
202
|
+
if (!node || node.nodeType !== 1) return false;
|
|
203
|
+
if (uncoveredEffectCache.has(node)) return uncoveredEffectCache.get(node);
|
|
204
|
+
const style = getComputedStyle(node);
|
|
205
|
+
let result;
|
|
206
|
+
if ((parseColor(style.backgroundColor)?.a ?? 0) >= 1) result = false; // everything above is covered
|
|
207
|
+
else if ((style.backdropFilter && style.backdropFilter !== 'none') || (style.filter && style.filter !== 'none')) result = true;
|
|
208
|
+
else result = uncoveredEffect(flatParentOf(node));
|
|
209
|
+
uncoveredEffectCache.set(node, result);
|
|
210
|
+
return result;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Filters and blend modes change the presented glyph/background pair.
|
|
214
|
+
* WCAG judges that presented pair, not the unfiltered CSS declarations.
|
|
215
|
+
* Cached per node along the flat tree so ordinary text pays once per
|
|
216
|
+
* ancestor. */
|
|
217
|
+
export function hasPaintEffects(element) {
|
|
218
|
+
if (!element || element.nodeType !== 1) return false;
|
|
219
|
+
return chainEffect(element) || uncoveredEffect(element);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** A blend cannot reach paint outside its containing isolated group.
|
|
223
|
+
* Stop at opaque paint inside that group; otherwise an intervening stacking
|
|
224
|
+
* context means the ordinary ancestor background is not a proven backdrop. */
|
|
225
|
+
export function isolatedBlendBackdrop(element) {
|
|
226
|
+
if (!element || element.nodeType !== 1) return false;
|
|
227
|
+
if (blendBackdropCache.has(element)) return blendBackdropCache.get(element);
|
|
228
|
+
const style = getComputedStyle(element);
|
|
229
|
+
const background = parseColor(style.backgroundColor);
|
|
230
|
+
let result = false;
|
|
231
|
+
if (!background || background.a < 1) {
|
|
232
|
+
const isolated = style.isolation === 'isolate'
|
|
233
|
+
|| ['fixed', 'sticky'].includes(style.position) || style.zIndex !== 'auto'
|
|
234
|
+
|| ['transform', 'perspective', 'filter', 'backdropFilter', 'clipPath', 'maskImage', 'webkitMaskImage']
|
|
235
|
+
.some((key) => style[key] && style[key] !== 'none')
|
|
236
|
+
|| /(?:paint|layout|strict|content)/.test(style.contain)
|
|
237
|
+
|| (style.willChange && style.willChange !== 'auto')
|
|
238
|
+
|| parseFloat(style.opacity) < 1;
|
|
239
|
+
result = isolated || isolatedBlendBackdrop(element.assignedSlot ?? element.parentElement ?? element.getRootNode()?.host);
|
|
240
|
+
}
|
|
241
|
+
blendBackdropCache.set(element, result);
|
|
242
|
+
return result;
|
|
146
243
|
}
|
|
147
244
|
|
|
148
245
|
/**
|
|
@@ -1233,11 +1330,12 @@ export function sampleGridFor(extent) {
|
|
|
1233
1330
|
return { width, height, reduced: extent.width / width > 4.5 || extent.height / height > 4.5 };
|
|
1234
1331
|
}
|
|
1235
1332
|
|
|
1236
|
-
export function imageLuminanceRange(url, overlays = [], grid = null) {
|
|
1333
|
+
export function imageLuminanceRange(url, overlays = [], grid = null, under = null) {
|
|
1237
1334
|
const width = grid?.width ?? 32;
|
|
1238
1335
|
const height = grid?.height ?? 32;
|
|
1239
1336
|
const sizeKey = width === 32 && height === 32 ? '' : `|${width}x${height}`;
|
|
1240
|
-
const
|
|
1337
|
+
const backdrop = under?.a >= 1 ? under : null;
|
|
1338
|
+
const cacheKey = `${url}${overlays.length ? `|${overlayKey(overlays)}` : ''}${sizeKey}${backdrop ? `|under:${overlayKey([backdrop])}` : ''}`;
|
|
1241
1339
|
if (imageRangeCache.has(cacheKey)) return imageRangeCache.get(cacheKey);
|
|
1242
1340
|
const promise = new Promise((resolve) => {
|
|
1243
1341
|
const img = new Image();
|
|
@@ -1252,8 +1350,8 @@ export function imageLuminanceRange(url, overlays = [], grid = null) {
|
|
|
1252
1350
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
|
1253
1351
|
ctx.drawImage(img, 0, 0, width, height);
|
|
1254
1352
|
const data = ctx.getImageData(0, 0, width, height).data;
|
|
1255
|
-
let min =
|
|
1256
|
-
let max =
|
|
1353
|
+
let min = Infinity;
|
|
1354
|
+
let max = -Infinity;
|
|
1257
1355
|
// The colours at those extremes, kept for the same reason the
|
|
1258
1356
|
// gradient range keeps them: translucent text blends with the actual
|
|
1259
1357
|
// pixels, and a luminance cannot be un-gamma'd back into an rgb.
|
|
@@ -1266,19 +1364,22 @@ export function imageLuminanceRange(url, overlays = [], grid = null) {
|
|
|
1266
1364
|
// painted beneath it — the sampled range alone then describes only
|
|
1267
1365
|
// the ink, not the backdrop the eye meets. Callers widen the range
|
|
1268
1366
|
// with the underlying paint (rangeWithBackdrop) or abstain.
|
|
1269
|
-
if (data[i + 3] < 255) alphaSeen = true;
|
|
1270
|
-
if (data[i + 3]
|
|
1271
|
-
|
|
1367
|
+
if (data[i + 3] < 255 && !backdrop) alphaSeen = true;
|
|
1368
|
+
if (data[i + 3] > 0) opaquePixels += 1;
|
|
1369
|
+
if (data[i + 3] === 0 && !backdrop) continue;
|
|
1272
1370
|
// Composite each pixel through anything painted between the image
|
|
1273
1371
|
// and the text before measuring: a photo under a 75% black scrim is
|
|
1274
1372
|
// seen as the blend, never as the photo.
|
|
1275
|
-
const pixel = { r: data[i], g: data[i + 1], b: data[i + 2], a:
|
|
1276
|
-
|
|
1373
|
+
const pixel = { r: data[i], g: data[i + 1], b: data[i + 2], a: data[i + 3] / 255 };
|
|
1374
|
+
// A nonzero alpha is real paint. Composite over a known backdrop
|
|
1375
|
+
// before sampling; without one, keep the conservative raw range.
|
|
1376
|
+
const painted = backdrop ? composite(pixel, backdrop) : { ...pixel, a: 1 };
|
|
1377
|
+
const shown = overlays.length ? applyOverlays(painted, overlays) : painted;
|
|
1277
1378
|
const l = luminance(shown);
|
|
1278
1379
|
if (l < min) { min = l; minColor = shown; }
|
|
1279
1380
|
if (l > max) { max = l; maxColor = shown; }
|
|
1280
1381
|
}
|
|
1281
|
-
// Not one
|
|
1382
|
+
// Not one nontransparent pixel: a spacer, a cleared sprite, or a decorative
|
|
1282
1383
|
// layer left in place with nothing in it. It covers the background
|
|
1283
1384
|
// without changing a single one of its pixels.
|
|
1284
1385
|
if (!opaquePixels) {
|