pour-engine 1.2.0 → 1.3.1
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/COVERAGE.md +5 -3
- package/engine/README.md +1 -1
- package/engine/lib/contrast.js +41 -3
- package/engine/rules/wcag/1.2.2-media-captions.js +8 -1
- package/engine/rules/wcag/1.3.1-form-label.js +6 -4
- package/engine/rules/wcag/1.3.4-orientation.js +4 -0
- package/engine/rules/wcag/1.4.1-link-in-text-block.js +12 -4
- package/engine/rules/wcag/1.4.3-color-contrast.js +89 -54
- package/engine/rules/wcag/2.2.1-meta-refresh.js +17 -3
- package/engine/rules/wcag/2.2.2-pause-stop-hide.js +104 -12
- package/engine/rules/wcag/2.4.2-document-title.js +53 -6
- package/engine/rules/wcag/4.1.2-aria-hidden-focus.js +6 -1
- package/engine/wcag22.js +2 -2
- package/package.json +1 -1
package/config/project.config.js
CHANGED
package/engine/COVERAGE.md
CHANGED
|
@@ -5,6 +5,8 @@ Generated by `node scripts/wcag-coverage.js` — do not edit by hand.
|
|
|
5
5
|
All 86 success criteria of WCAG 2.2, and how the native engine covers them.
|
|
6
6
|
"Partially automated" means our rules catch common failures but cannot prove conformance;
|
|
7
7
|
"Manual review" criteria need a human (most of the spec — true for every automated tool).
|
|
8
|
+
No criterion is listed as fully "Automated": every one of them ends in a question about
|
|
9
|
+
meaning that only a person can settle, even where a rule decides most real pages.
|
|
8
10
|
|
|
9
11
|
## Perceivable
|
|
10
12
|
|
|
@@ -58,7 +60,7 @@ All 86 success criteria of WCAG 2.2, and how the native engine covers them.
|
|
|
58
60
|
| 2.3.2 | [Three Flashes](https://www.w3.org/WAI/WCAG22/Understanding/three-flashes.html) | AAA | Manual review | — |
|
|
59
61
|
| 2.3.3 | [Animation from Interactions](https://www.w3.org/WAI/WCAG22/Understanding/animation-from-interactions.html) | AAA | Manual review | — |
|
|
60
62
|
| 2.4.1 | [Bypass Blocks](https://www.w3.org/WAI/WCAG22/Understanding/bypass-blocks.html) | A | Partially automated | bypass-blocks |
|
|
61
|
-
| 2.4.2 | [Page Titled](https://www.w3.org/WAI/WCAG22/Understanding/page-titled.html) | A |
|
|
63
|
+
| 2.4.2 | [Page Titled](https://www.w3.org/WAI/WCAG22/Understanding/page-titled.html) | A | Partially automated | document-title |
|
|
62
64
|
| 2.4.3 | [Focus Order](https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html) | A | Manual review | — |
|
|
63
65
|
| 2.4.4 | [Link Purpose (In Context)](https://www.w3.org/WAI/WCAG22/Understanding/link-purpose-in-context.html) | A | Partially automated | area-alt, link-name |
|
|
64
66
|
| 2.4.5 | [Multiple Ways](https://www.w3.org/WAI/WCAG22/Understanding/multiple-ways.html) | AA | Manual review | — |
|
|
@@ -83,7 +85,7 @@ All 86 success criteria of WCAG 2.2, and how the native engine covers them.
|
|
|
83
85
|
|
|
84
86
|
| SC | Name | Level | Coverage | Rules |
|
|
85
87
|
|---|---|---|---|---|
|
|
86
|
-
| 3.1.1 | [Language of Page](https://www.w3.org/WAI/WCAG22/Understanding/language-of-page.html) | A |
|
|
88
|
+
| 3.1.1 | [Language of Page](https://www.w3.org/WAI/WCAG22/Understanding/language-of-page.html) | A | Partially automated | html-lang |
|
|
87
89
|
| 3.1.2 | [Language of Parts](https://www.w3.org/WAI/WCAG22/Understanding/language-of-parts.html) | AA | Partially automated | valid-lang-parts |
|
|
88
90
|
| 3.1.3 | [Unusual Words](https://www.w3.org/WAI/WCAG22/Understanding/unusual-words.html) | AAA | Manual review | — |
|
|
89
91
|
| 3.1.4 | [Abbreviations](https://www.w3.org/WAI/WCAG22/Understanding/abbreviations.html) | AAA | Manual review | — |
|
|
@@ -112,4 +114,4 @@ All 86 success criteria of WCAG 2.2, and how the native engine covers them.
|
|
|
112
114
|
| 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
115
|
| 4.1.3 | [Status Messages](https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html) | AA | Manual review | — |
|
|
114
116
|
|
|
115
|
-
**Totals:**
|
|
117
|
+
**Totals:** 29 partially automated · 57 manual — with 72 rules.
|
package/engine/README.md
CHANGED
|
@@ -108,7 +108,7 @@ Nothing in the spec is silently skipped — that claim is the product.
|
|
|
108
108
|
|
|
109
109
|
## Honest current state
|
|
110
110
|
|
|
111
|
-
|
|
111
|
+
72 rules, hardened on real sites and the `testbeds/` pages. Known gaps:
|
|
112
112
|
shadow DOM and iframe traversal, the full accname spec (ours is a close
|
|
113
113
|
approximation), and the language subtag registry (BCP 47 shape check only).
|
|
114
114
|
Grow it rule by rule; the test beds' `expect:` annotations are the
|
package/engine/lib/contrast.js
CHANGED
|
@@ -140,7 +140,7 @@ export function backgroundColorSource(element) {
|
|
|
140
140
|
for (let node = element; node && node.nodeType === 1;
|
|
141
141
|
node = node.parentElement ?? node.getRootNode()?.host) {
|
|
142
142
|
const style = getComputedStyle(node);
|
|
143
|
-
if (style.backgroundImage !== 'none') return null;
|
|
143
|
+
if (style.backgroundImage !== 'none' && !paintsNothing(style.backgroundImage)) return null;
|
|
144
144
|
const color = parseColor(style.backgroundColor);
|
|
145
145
|
if (!color || color.a === 0) continue; // transparent: keep walking, as the resolver does
|
|
146
146
|
return color.a >= 1 ? style.backgroundColor : null; // translucent: composited, no single source
|
|
@@ -148,6 +148,24 @@ export function backgroundColorSource(element) {
|
|
|
148
148
|
return null;
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Absolute URLs of images that sampling found to have no opaque pixels.
|
|
153
|
+
* Populated by imageLuminanceRange (async) and read by the background walk
|
|
154
|
+
* (sync): by the time a rule falls back to the walk, the image over that
|
|
155
|
+
* text has already been sampled, so the fact is in hand. Lives as long as
|
|
156
|
+
* the sample cache does — it describes the image, not the page.
|
|
157
|
+
*/
|
|
158
|
+
const transparentImages = new Set();
|
|
159
|
+
|
|
160
|
+
/** True when every layer of this computed background-image is a url() known
|
|
161
|
+
* to paint nothing — so the layer cannot change what is behind it. Any
|
|
162
|
+
* gradient, or any unsampled image, makes that unknowable. */
|
|
163
|
+
function paintsNothing(backgroundImage) {
|
|
164
|
+
if (backgroundImage.includes('gradient(')) return false;
|
|
165
|
+
const urls = [...backgroundImage.matchAll(/url\(["']?(.*?)["']?\)/g)].map((m) => m[1]);
|
|
166
|
+
return urls.length > 0 && urls.every((url) => transparentImages.has(url));
|
|
167
|
+
}
|
|
168
|
+
|
|
151
169
|
function canvasColor(doc) {
|
|
152
170
|
// Pages using color-scheme: dark get a dark canvas from the browser even
|
|
153
171
|
// with no author background — assuming white there inverts every result.
|
|
@@ -169,7 +187,7 @@ function resolveBackground(element, doc) {
|
|
|
169
187
|
}
|
|
170
188
|
let result;
|
|
171
189
|
const style = getComputedStyle(element);
|
|
172
|
-
if (style.backgroundImage !== 'none') {
|
|
190
|
+
if (style.backgroundImage !== 'none' && !paintsNothing(style.backgroundImage)) {
|
|
173
191
|
result = null;
|
|
174
192
|
} else {
|
|
175
193
|
const color = parseColor(style.backgroundColor);
|
|
@@ -792,7 +810,10 @@ const overlayKey = (overlays) =>
|
|
|
792
810
|
/**
|
|
793
811
|
* Luminance range (min/max) of an image's pixels, downscaled for speed.
|
|
794
812
|
* null when the image can't be read: cross-origin without CORS headers
|
|
795
|
-
* (canvas tainting), load failure, or timeout.
|
|
813
|
+
* (canvas tainting), load failure, or timeout. `{ transparent: true }` when
|
|
814
|
+
* the image reads fine but has no opaque pixels at all — it paints nothing,
|
|
815
|
+
* which is a very different fact from "unknown" and must not be reported as
|
|
816
|
+
* one. Cached per URL.
|
|
796
817
|
*/
|
|
797
818
|
const imageRangeCache = new Map();
|
|
798
819
|
export function imageLuminanceRange(url, overlays = []) {
|
|
@@ -814,8 +835,10 @@ export function imageLuminanceRange(url, overlays = []) {
|
|
|
814
835
|
const data = ctx.getImageData(0, 0, size, size).data;
|
|
815
836
|
let min = 1;
|
|
816
837
|
let max = 0;
|
|
838
|
+
let opaquePixels = 0;
|
|
817
839
|
for (let i = 0; i < data.length; i += 4) {
|
|
818
840
|
if (data[i + 3] < 128) continue; // mostly-transparent pixels reveal what's beneath — unknowable
|
|
841
|
+
opaquePixels += 1;
|
|
819
842
|
// Composite each pixel through anything painted between the image
|
|
820
843
|
// and the text before measuring: a photo under a 75% black scrim is
|
|
821
844
|
// seen as the blend, never as the photo.
|
|
@@ -824,6 +847,21 @@ export function imageLuminanceRange(url, overlays = []) {
|
|
|
824
847
|
if (l < min) min = l;
|
|
825
848
|
if (l > max) max = l;
|
|
826
849
|
}
|
|
850
|
+
// Not one opaque pixel: a spacer, a cleared sprite, or a decorative
|
|
851
|
+
// layer left in place with nothing in it. It covers the background
|
|
852
|
+
// without changing a single one of its pixels.
|
|
853
|
+
if (!opaquePixels) {
|
|
854
|
+
transparentImages.add(url);
|
|
855
|
+
// Any background the walk already gave up on ("there's an image in
|
|
856
|
+
// the chain") may have been given up on because of THIS image.
|
|
857
|
+
// Those answers are now wrong, and which ones is not worth
|
|
858
|
+
// tracking: a page has a handful of transparent images at most, so
|
|
859
|
+
// the walk simply re-runs. Without this the verdict would depend
|
|
860
|
+
// on which rule happened to reach the element first.
|
|
861
|
+
backgroundCache = new WeakMap();
|
|
862
|
+
resolve({ transparent: true, width: img.naturalWidth, height: img.naturalHeight });
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
827
865
|
resolve(min <= max ? { min, max, width: img.naturalWidth, height: img.naturalHeight } : null);
|
|
828
866
|
} catch {
|
|
829
867
|
resolve(null); // tainted canvas: cross-origin image without CORS headers
|
|
@@ -8,7 +8,14 @@ export default {
|
|
|
8
8
|
selector: 'video',
|
|
9
9
|
visibleOnly: false,
|
|
10
10
|
evaluate(element) {
|
|
11
|
-
|
|
11
|
+
// `kind` is an enumerated attribute: its values are ASCII
|
|
12
|
+
// case-insensitive, and the MISSING value default is the subtitles
|
|
13
|
+
// state — so a bare <track> is a subtitle track and counts. (An invalid
|
|
14
|
+
// value defaults to metadata, which does not, so `kind=""` and
|
|
15
|
+
// `kind="banana"` are deliberately left out.)
|
|
16
|
+
if (element.querySelector('track[kind="captions" i], track[kind="subtitles" i], track:not([kind])')) {
|
|
17
|
+
return { status: 'pass' };
|
|
18
|
+
}
|
|
12
19
|
// Captions may be burned in or provided by the player — a human must check.
|
|
13
20
|
return {
|
|
14
21
|
status: 'incomplete',
|
|
@@ -40,10 +40,12 @@ export default {
|
|
|
40
40
|
|
|
41
41
|
const id = element.id ? ` (it already has id="${element.id}")` : ' (give it an id first)';
|
|
42
42
|
|
|
43
|
-
// A title
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
|
|
43
|
+
// A title name is published sufficient technique H65 — conformant, but
|
|
44
|
+
// invisible until hover and ignored by some tools, so it's flagged for
|
|
45
|
+
// eyes rather than failed. A placeholder alongside it is an example
|
|
46
|
+
// value, not a competing label: the title still names the field, so this
|
|
47
|
+
// check comes first and does not care whether one is present.
|
|
48
|
+
if (element.getAttribute('title')?.trim()) {
|
|
47
49
|
return {
|
|
48
50
|
status: 'incomplete',
|
|
49
51
|
message: 'This field is named only by its title attribute (technique H65) — conformant, but the label is invisible until hover and some tools skip it. Check a visible label isn’t needed here.',
|
|
@@ -51,6 +51,10 @@ export default {
|
|
|
51
51
|
help: 'Content must work in both portrait and landscape',
|
|
52
52
|
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/orientation.html',
|
|
53
53
|
selector: 'html',
|
|
54
|
+
// The evidence is in the stylesheet, not in the root's own rendering — and
|
|
55
|
+
// the lock hides the root exactly when it is in force, so a visibility
|
|
56
|
+
// filter here would silence the rule in the only state that matters.
|
|
57
|
+
visibleOnly: false,
|
|
54
58
|
evaluate(element) {
|
|
55
59
|
const findings = [];
|
|
56
60
|
for (const sheet of element.ownerDocument.styleSheets) {
|
|
@@ -54,9 +54,17 @@ export default {
|
|
|
54
54
|
// its own line (<br>-separated address blocks, one-link-per-line
|
|
55
55
|
// footers) has no surrounding words to blend into — nothing is
|
|
56
56
|
// conveyed by colour.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
57
|
+
//
|
|
58
|
+
// Measured per LINE FRAGMENT, never on the bounding box. A link that
|
|
59
|
+
// wraps has one fragment per line, and the middle of the box that
|
|
60
|
+
// encloses them falls in the leading BETWEEN the lines, where no text
|
|
61
|
+
// can ever be — so a box-midpoint test exempts every wrapped link in
|
|
62
|
+
// prose, which is the opposite of what the check is for. F73 scopes
|
|
63
|
+
// this criterion by whether a link sits within text, and says nothing
|
|
64
|
+
// about lines: one fragment sharing its line with words is enough.
|
|
65
|
+
const fragments = [...element.getClientRects()].filter((r) => r.width > 0 && r.height > 0);
|
|
66
|
+
if (fragments.length) {
|
|
67
|
+
const mids = fragments.map((r) => r.top + r.height / 2);
|
|
60
68
|
const walker = element.ownerDocument.createTreeWalker(parent, NodeFilter.SHOW_TEXT);
|
|
61
69
|
const range = element.ownerDocument.createRange();
|
|
62
70
|
let sharesLine = false;
|
|
@@ -64,7 +72,7 @@ export default {
|
|
|
64
72
|
if (!node.textContent.trim() || element.contains(node)) continue;
|
|
65
73
|
range.selectNodeContents(node);
|
|
66
74
|
for (const r of range.getClientRects()) {
|
|
67
|
-
if (r.
|
|
75
|
+
if (r.width > 0 && mids.some((mid) => r.top <= mid && r.bottom >= mid)) { sharesLine = true; break; }
|
|
68
76
|
}
|
|
69
77
|
}
|
|
70
78
|
if (!sharesLine) return { status: 'pass' };
|
|
@@ -154,10 +154,39 @@ function unaccountedScrim(element, imageCarrier) {
|
|
|
154
154
|
});
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
/** An image layer with no opaque pixels: it covers the background without
|
|
158
|
+
* changing it, so the callers step over it rather than reporting on it. */
|
|
159
|
+
const PAINTS_NOTHING = Symbol('fully transparent image layer');
|
|
160
|
+
|
|
157
161
|
// Bracket the verdict by sampling what's actually painted: image pixels
|
|
158
|
-
// (same-origin/CORS only) or gradient colour stops.
|
|
162
|
+
// (same-origin/CORS only) or gradient colour stops. Returns an outcome,
|
|
163
|
+
// PAINTS_NOTHING, or null when nothing here can be sampled at all (stacked
|
|
164
|
+
// layers, translucent gradient stops) and the caller must say so itself.
|
|
159
165
|
async function sampledVerdict(source, foreground, required, doc, overlays = []) {
|
|
160
166
|
if (!source) return null;
|
|
167
|
+
let range = null;
|
|
168
|
+
let what = 'image';
|
|
169
|
+
if (source.tagName === 'IMG') {
|
|
170
|
+
range = await imageLuminanceRange(source.currentSrc || source.src, overlays);
|
|
171
|
+
} else {
|
|
172
|
+
const css = source.css ?? getComputedStyle(source).backgroundImage;
|
|
173
|
+
if (!css || css === 'none') return null;
|
|
174
|
+
if ((css.match(/url\(/g) ?? []).length + (css.match(/gradient\(/g) ?? []).length > 1) return null; // stacked layers: too complex
|
|
175
|
+
const url = css.match(/url\(["']?(.*?)["']?\)/)?.[1];
|
|
176
|
+
if (url) {
|
|
177
|
+
let absolute;
|
|
178
|
+
try { absolute = new URL(url, doc.baseURI).href; } catch { return null; }
|
|
179
|
+
range = await imageLuminanceRange(absolute, overlays);
|
|
180
|
+
} else if (css.includes('gradient(')) {
|
|
181
|
+
range = gradientLuminanceRange(css, overlays);
|
|
182
|
+
what = 'gradient';
|
|
183
|
+
} else {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// Checked before the text's own alpha: there is no blend to reason about
|
|
188
|
+
// when the layer paints nothing.
|
|
189
|
+
if (range?.transparent) return PAINTS_NOTHING;
|
|
161
190
|
// Translucent text takes its presented colour from the very pixels
|
|
162
191
|
// beneath it — a luminance range of the backdrop cannot bracket the
|
|
163
192
|
// blend (dimmed white over a dark photo reads mid-grey, not white).
|
|
@@ -168,22 +197,15 @@ async function sampledVerdict(source, foreground, required, doc, overlays = [])
|
|
|
168
197
|
message: 'This text is translucent over an image or gradient, so its presented colour depends on the pixels beneath — contrast must be checked by eye.',
|
|
169
198
|
};
|
|
170
199
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
if (url) {
|
|
179
|
-
let absolute;
|
|
180
|
-
try { absolute = new URL(url, doc.baseURI).href; } catch { return null; }
|
|
181
|
-
return verdictFromRange(await imageLuminanceRange(absolute, overlays), foreground, required, 'image');
|
|
182
|
-
}
|
|
183
|
-
if (css.includes('gradient(')) {
|
|
184
|
-
return verdictFromRange(gradientLuminanceRange(css, overlays), foreground, required, 'gradient');
|
|
200
|
+
// An image we tried to read and couldn't: that is a specific, reportable
|
|
201
|
+
// fact, and the only one of these paths where cross-origin is the cause.
|
|
202
|
+
if (!range && what === 'image') {
|
|
203
|
+
return {
|
|
204
|
+
status: 'incomplete',
|
|
205
|
+
message: 'The image painted behind this text can’t be read: it is cross-origin, or it failed to load, so a script can’t sample its colours. Check the contrast by eye.',
|
|
206
|
+
};
|
|
185
207
|
}
|
|
186
|
-
return
|
|
208
|
+
return verdictFromRange(range, foreground, required, what);
|
|
187
209
|
}
|
|
188
210
|
|
|
189
211
|
/**
|
|
@@ -281,35 +303,40 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
281
303
|
// image and this text (hero scrims, tint panels). Judging the raw
|
|
282
304
|
// image pixels would describe a page the user never sees.
|
|
283
305
|
let sampled = await sampledVerdict(imageSource, foreground, required, doc, imageSource.overlays);
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
if (sampled
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
306
|
+
// A layer with no opaque pixels changes nothing about what is behind
|
|
307
|
+
// the glyphs, so it must not block the verdict: fall through and
|
|
308
|
+
// judge the background that actually paints.
|
|
309
|
+
if (sampled !== PAINTS_NOTHING) {
|
|
310
|
+
// Scrims the ancestor walk cannot reach (pseudo-elements, positioned
|
|
311
|
+
// siblings) leave the sampled range describing the wrong pixels, so
|
|
312
|
+
// neither verdict is safe to assert from it.
|
|
313
|
+
if (sampled && unaccountedScrim(element, imageSource.element)) {
|
|
314
|
+
sampled = {
|
|
315
|
+
status: 'incomplete',
|
|
316
|
+
message: 'This text sits over a background image with a translucent overlay painted on top of it (a pseudo-element or positioned scrim), so the colour behind the glyphs is the blend of the two — check the contrast by eye.',
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
// Icon-scale images: bullets, arrows, and decorations painted
|
|
320
|
+
// beside or beneath a corner of the text — their pixels may flag
|
|
321
|
+
// for eyes, never assert a failure. Icon-scale means: no reliable
|
|
322
|
+
// intrinsic size, an intrinsically tiny image, OR a background
|
|
323
|
+
// carrier whose own box is line-height-sized (contain/cover paint
|
|
324
|
+
// can never exceed the carrier — a 512px nominal SVG painted on an
|
|
325
|
+
// inline link is still an icon). Only backdrop-scale images earn a
|
|
326
|
+
// definite fail from sampling.
|
|
327
|
+
const carrierBox = imageSource.element.getBoundingClientRect();
|
|
328
|
+
if (sampled?.status === 'fail'
|
|
329
|
+
&& (dimensionless || (intrinsic.width <= 32 && intrinsic.height <= 32) || carrierBox.height <= 40)) {
|
|
330
|
+
return {
|
|
331
|
+
status: 'incomplete',
|
|
332
|
+
message: 'A small background icon is painted on this element — if it sits beside the text rather than under it, judge the contrast against the plain background by eye.',
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
return sampled ?? {
|
|
305
336
|
status: 'incomplete',
|
|
306
|
-
message: '
|
|
337
|
+
message: 'The background here is several stacked image or gradient layers, which a script can’t sample. Contrast must be checked by eye.',
|
|
307
338
|
};
|
|
308
339
|
}
|
|
309
|
-
return sampled ?? {
|
|
310
|
-
status: 'incomplete',
|
|
311
|
-
message: 'The background here is an image that can’t be read (cross-origin) — contrast must be checked by eye.',
|
|
312
|
-
};
|
|
313
340
|
}
|
|
314
341
|
}
|
|
315
342
|
|
|
@@ -360,17 +387,23 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
360
387
|
// exactly as an element's own background-image is bracketed.
|
|
361
388
|
if (pseudoBack?.image) {
|
|
362
389
|
const sampled = await sampledVerdict(pseudoBack.image, foreground, required, doc);
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
390
|
+
// PAINTS_NOTHING: the pseudo-element's image is fully transparent, so
|
|
391
|
+
// it never displaced the background the walk resolved.
|
|
392
|
+
if (sampled !== PAINTS_NOTHING) {
|
|
393
|
+
return sampled ?? {
|
|
394
|
+
status: 'incomplete',
|
|
395
|
+
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.',
|
|
396
|
+
};
|
|
397
|
+
}
|
|
367
398
|
}
|
|
368
399
|
if (painted?.image && !pseudoBack) {
|
|
369
400
|
const sampled = await sampledVerdict(painted.image, foreground, required, doc);
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
401
|
+
if (sampled !== PAINTS_NOTHING) {
|
|
402
|
+
return sampled ?? {
|
|
403
|
+
status: 'incomplete',
|
|
404
|
+
message: 'An image or overlapping element is painted behind this text, so its real background isn’t the computed colour — contrast must be checked by eye.',
|
|
405
|
+
};
|
|
406
|
+
}
|
|
374
407
|
}
|
|
375
408
|
// Only a hit-test-resolved backdrop is VERIFIED paint truth. Anything
|
|
376
409
|
// else (offscreen sample point, element invisible to its own hit-test —
|
|
@@ -387,10 +420,12 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
387
420
|
const obscuring = backgroundObscured(element);
|
|
388
421
|
if (obscuring && obscuring !== 'unverifiable') {
|
|
389
422
|
const sampled = await sampledVerdict(obscuring, foreground, required, doc);
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
423
|
+
if (sampled !== PAINTS_NOTHING) {
|
|
424
|
+
return sampled ?? {
|
|
425
|
+
status: 'incomplete',
|
|
426
|
+
message: 'An image or overlapping element is painted behind this text, so its real background isn’t the computed colour — contrast must be checked by eye.',
|
|
427
|
+
};
|
|
428
|
+
}
|
|
394
429
|
}
|
|
395
430
|
}
|
|
396
431
|
if (pseudoBack?.color) {
|
|
@@ -8,9 +8,23 @@ export default {
|
|
|
8
8
|
selector: 'meta[http-equiv="refresh" i]',
|
|
9
9
|
visibleOnly: false,
|
|
10
10
|
evaluate(element) {
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
const content = element.getAttribute('content') ?? '';
|
|
12
|
+
const delay = parseInt(content, 10);
|
|
13
|
+
// A refresh with no destination reloads THIS page. At zero seconds that
|
|
14
|
+
// is not the allowed instant redirect — it is a loop that throws the
|
|
15
|
+
// user back to the top of the page over and over (failure F41).
|
|
16
|
+
const hasDestination = /;\s*url\s*=/i.test(content);
|
|
17
|
+
if (delay === 0 && !hasDestination) {
|
|
18
|
+
return {
|
|
19
|
+
status: 'fail',
|
|
20
|
+
message: 'The page reloads itself immediately and has no destination to move on to, so the reload repeats for as long as the page is open. Nobody can read or complete anything on it.',
|
|
21
|
+
fix: 'Remove the timed refresh. If this is meant to be a redirect, give it a url (content="0; url=…"); if the content needs updating, update it in place instead of reloading.',
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
if (!(delay > 0)) return { status: 'pass' }; // 0 with a url = immediate redirect, allowed
|
|
25
|
+
// 2.2.1 exempts a time limit "longer than 20 hours" — 20 hours exactly
|
|
26
|
+
// is still inside the criterion.
|
|
27
|
+
if (delay > 72000) return { status: 'pass' };
|
|
14
28
|
return {
|
|
15
29
|
status: 'fail',
|
|
16
30
|
message: `The page refreshes/redirects after ${delay}s — slow readers lose their place (or the whole page) with no control.`,
|
|
@@ -1,25 +1,117 @@
|
|
|
1
1
|
// WCAG SC 2.2.2 Pause, Stop, Hide (Level A)
|
|
2
|
+
// "For any moving, blinking or scrolling information that (1) starts
|
|
3
|
+
// automatically, (2) lasts more than five seconds, and (3) is presented in
|
|
4
|
+
// parallel with other content, there is a mechanism for the user to pause,
|
|
5
|
+
// stop, or hide it…"
|
|
6
|
+
//
|
|
7
|
+
// Two shapes are checkable, and they need different verdicts.
|
|
8
|
+
//
|
|
9
|
+
// <marquee> and <blink> exist only to move, ship no control of their own,
|
|
10
|
+
// and are obsolete: a definite failure.
|
|
11
|
+
//
|
|
12
|
+
// A CSS animation set to repeat for ever is the modern ticker. Clauses 1
|
|
13
|
+
// and 2 are settled by the CSS itself — it starts with the page and never
|
|
14
|
+
// ends — but clause 3's "mechanism" may be any button anywhere on the page,
|
|
15
|
+
// and no static pass can tell a pause control from any other button. So the
|
|
16
|
+
// finding goes to a human with the evidence attached, never asserted.
|
|
17
|
+
//
|
|
18
|
+
// Only motion carrying TEXT is reported. Spinners, drifting decorative
|
|
19
|
+
// blobs and shimmer effects move too, and flagging every loading indicator
|
|
20
|
+
// on the web would bury the finding that matters. Silence there is
|
|
21
|
+
// abstaining, not clearing.
|
|
22
|
+
const MOTION_PROPERTIES = new Set([
|
|
23
|
+
'transform', 'translate', 'rotate', 'scale', 'offsetDistance', 'offsetPath', 'offsetRotate',
|
|
24
|
+
'left', 'right', 'top', 'bottom',
|
|
25
|
+
'marginLeft', 'marginRight', 'marginTop', 'marginBottom',
|
|
26
|
+
'backgroundPosition', 'backgroundPositionX', 'backgroundPositionY', 'objectPosition',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
/** Does this keyframe set move the element around, rather than merely
|
|
30
|
+
* restyle it? A hue cycle is not movement; a translate is. */
|
|
31
|
+
function keyframesMove(keyframes) {
|
|
32
|
+
return keyframes.some((frame) => Object.keys(frame).some((property) => MOTION_PROPERTIES.has(property)));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Blinking: the element is repeatedly taken away and put back. A gentle
|
|
36
|
+
* breathing pulse (0.6 → 1) is not what the criterion is about, so an
|
|
37
|
+
* opacity cycle only counts when it reaches near-invisible AND near-full. */
|
|
38
|
+
function keyframesBlink(keyframes) {
|
|
39
|
+
if (keyframes.some((frame) => frame.visibility === 'hidden')) return true;
|
|
40
|
+
const opacities = keyframes
|
|
41
|
+
.map((frame) => parseFloat(frame.opacity))
|
|
42
|
+
.filter((value) => !Number.isNaN(value));
|
|
43
|
+
return opacities.some((value) => value <= 0.25) && opacities.some((value) => value >= 0.75);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Elements that an endlessly repeating animation is currently moving,
|
|
48
|
+
* mapped to the evidence. One document-wide getAnimations() call, never one
|
|
49
|
+
* per element: per-node getAnimations() filters every animation on the page
|
|
50
|
+
* each time, which turns animation-heavy pages quadratic.
|
|
51
|
+
*/
|
|
52
|
+
function endlesslyMoving(doc) {
|
|
53
|
+
const moving = new Map();
|
|
54
|
+
if (typeof doc.getAnimations !== 'function') return moving;
|
|
55
|
+
let animations;
|
|
56
|
+
try { animations = doc.getAnimations(); } catch { return moving; }
|
|
57
|
+
for (const animation of animations) {
|
|
58
|
+
// Paused, finished, or reversed-to-a-stop animations move nothing.
|
|
59
|
+
if (animation.playState !== 'running' || !animation.playbackRate) continue;
|
|
60
|
+
// Scroll- and view-driven animations advance only as the user scrolls,
|
|
61
|
+
// so they do not "start automatically" in the sense of the criterion.
|
|
62
|
+
if (animation.timeline && animation.timeline !== doc.timeline) continue;
|
|
63
|
+
const effect = animation.effect;
|
|
64
|
+
const target = effect?.target;
|
|
65
|
+
if (!target || target.nodeType !== 1) continue;
|
|
66
|
+
const timing = effect.getComputedTiming?.() ?? {};
|
|
67
|
+
if (timing.iterations !== Infinity || !(timing.duration > 0)) continue;
|
|
68
|
+
let keyframes;
|
|
69
|
+
try { keyframes = effect.getKeyframes(); } catch { continue; }
|
|
70
|
+
const moves = keyframesMove(keyframes);
|
|
71
|
+
if (!moves && !keyframesBlink(keyframes)) continue;
|
|
72
|
+
// A pseudo-element's motion is still the host element's motion.
|
|
73
|
+
if (!moving.has(target)) {
|
|
74
|
+
moving.set(target, { name: animation.animationName, verb: moves ? 'moves' : 'blinks' });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return moving;
|
|
78
|
+
}
|
|
79
|
+
|
|
2
80
|
export default {
|
|
3
81
|
id: 'pause-stop-hide',
|
|
4
82
|
impact: 'serious',
|
|
5
83
|
tags: ['wcag2a', 'wcag222'],
|
|
6
84
|
help: 'Moving content must be pausable',
|
|
7
85
|
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/pause-stop-hide.html',
|
|
8
|
-
selector: '
|
|
86
|
+
selector: '*',
|
|
9
87
|
// The criterion is about VISIBLE movement — a display:none marquee moves
|
|
10
88
|
// nothing. aria-hidden content still moves on screen, so the rendered
|
|
11
89
|
// check (not the AT-exposure check) is the right filter.
|
|
12
90
|
visibility: 'visual',
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
91
|
+
// One getAnimations() call for the whole page, then a lookup per element.
|
|
92
|
+
evaluateAll(elements) {
|
|
93
|
+
if (!elements.length) return [];
|
|
94
|
+
const moving = endlesslyMoving(elements[0].ownerDocument);
|
|
95
|
+
return elements.map((element) => {
|
|
96
|
+
if (element.tagName === 'MARQUEE' || element.tagName === 'BLINK') {
|
|
97
|
+
// scrollamount="0" is the documented way to ship a <marquee> that
|
|
98
|
+
// doesn't actually move.
|
|
99
|
+
if (element.tagName === 'MARQUEE' && element.getAttribute('scrollamount') === '0') {
|
|
100
|
+
return { status: 'pass' };
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
status: 'fail',
|
|
104
|
+
message: `<${element.tagName.toLowerCase()}> scrolls or blinks with no way to pause it, which is unusable for people with attention or vestibular conditions.`,
|
|
105
|
+
fix: 'Replace it with static content, or a CSS animation with a pause control that honours prefers-reduced-motion.',
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
const animation = moving.get(element);
|
|
109
|
+
if (!animation || !element.textContent.trim()) return { status: 'pass' };
|
|
110
|
+
const named = animation.name ? ` (the “${animation.name}” animation)` : '';
|
|
111
|
+
return {
|
|
112
|
+
status: 'incomplete',
|
|
113
|
+
message: `This content ${animation.verb} continuously${named}: the animation repeats for ever, 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 one, and that it reaches this content.`,
|
|
114
|
+
};
|
|
115
|
+
});
|
|
24
116
|
},
|
|
25
117
|
};
|
|
@@ -1,4 +1,33 @@
|
|
|
1
1
|
// WCAG SC 2.4.2 Page Titled (Level A)
|
|
2
|
+
// "Web pages have titles that describe topic or purpose." A title that is
|
|
3
|
+
// merely PRESENT satisfies the markup, not the criterion, so the two
|
|
4
|
+
// well-known undescriptive shapes are checked as well.
|
|
5
|
+
//
|
|
6
|
+
// Tier one, failed: strings that are a stand-in under every reading.
|
|
7
|
+
// "Untitled Document" is what an editor writes when the author wrote
|
|
8
|
+
// nothing, and no page is ever about that.
|
|
9
|
+
// Tier two, sent to a human: single generic words and framework scaffold
|
|
10
|
+
// defaults. Almost always a leftover, but a gallery page really can be about
|
|
11
|
+
// an artwork called "Untitled", and "Example Domain" really is the subject
|
|
12
|
+
// of example.com. Those are somebody's call to make, not the engine's.
|
|
13
|
+
//
|
|
14
|
+
// Deliberately NOT judged: whether a real-looking title matches the page it
|
|
15
|
+
// sits on. That needs someone who can read the page, and guessing at it
|
|
16
|
+
// would produce exactly the confident-but-wrong findings this engine avoids.
|
|
17
|
+
const PLACEHOLDER = new Set([
|
|
18
|
+
'untitled document', 'untitled page', 'untitled-1', 'untitled 1',
|
|
19
|
+
'new document', 'new page', 'new tab', 'no title',
|
|
20
|
+
'insert title here', 'title here', 'page title', 'document title',
|
|
21
|
+
'web page', 'webpage',
|
|
22
|
+
]);
|
|
23
|
+
const UNDESCRIPTIVE = new Set([
|
|
24
|
+
'untitled', 'document', 'page', 'title', 'default', 'index',
|
|
25
|
+
'react app', 'create react app', 'vite app', 'vite + react', 'vite + vue', 'vite + svelte',
|
|
26
|
+
'next app', 'create next app', 'vue app', 'nuxt app', 'svelte app', 'angular app',
|
|
27
|
+
'my app', 'my site', 'my website', 'site title', 'your site title',
|
|
28
|
+
'example domain', 'hello world',
|
|
29
|
+
]);
|
|
30
|
+
|
|
2
31
|
export default {
|
|
3
32
|
id: 'document-title',
|
|
4
33
|
impact: 'serious',
|
|
@@ -8,11 +37,29 @@ export default {
|
|
|
8
37
|
selector: 'html',
|
|
9
38
|
visibleOnly: false,
|
|
10
39
|
evaluate(element) {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
40
|
+
const title = element.ownerDocument.title.trim();
|
|
41
|
+
if (!title) {
|
|
42
|
+
return {
|
|
43
|
+
status: 'fail',
|
|
44
|
+
message: 'The page has no title, so users of screen readers and browser tabs cannot tell what it is.',
|
|
45
|
+
fix: 'Add <title>Page name — Site name</title> inside <head>.',
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
// Collapsed and lowercased so "Untitled Document" is caught too.
|
|
49
|
+
const normalized = title.replace(/\s+/g, ' ').toLowerCase();
|
|
50
|
+
if (PLACEHOLDER.has(normalized) || /^untitled[\s-]*\d+$/.test(normalized)) {
|
|
51
|
+
return {
|
|
52
|
+
status: 'fail',
|
|
53
|
+
message: `“${title}” is a placeholder left by an editor or template. It gives no topic and no purpose, so a screen reader announces nothing useful when the page loads, and a row of open tabs, bookmarks or history entries becomes impossible to tell apart.`,
|
|
54
|
+
fix: 'Put this page\'s own subject in the title first, then the site name.',
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
if (UNDESCRIPTIVE.has(normalized)) {
|
|
58
|
+
return {
|
|
59
|
+
status: 'incomplete',
|
|
60
|
+
message: `“${title}” is a generic title, and usually a default nobody replaced. Unless the page really is about that, it describes neither topic nor purpose and 2.4.2 is not met. Check whether it was meant to be filled in.`,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
return { status: 'pass' };
|
|
17
64
|
},
|
|
18
65
|
};
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
// WCAG SC 4.1.2 Name, Role, Value (Level A)
|
|
2
|
+
import { cumulativeOpacity } from '../../lib/contrast.js';
|
|
3
|
+
|
|
2
4
|
const FOCUSABLE = 'a[href], area[href], button, input, select, textarea, summary, iframe, '
|
|
3
5
|
+ 'audio[controls], video[controls], [contenteditable]:not([contenteditable="false"]), [tabindex]';
|
|
4
6
|
|
|
@@ -17,12 +19,15 @@ export default {
|
|
|
17
19
|
// focus lands there, so the harm this rule describes (sighted keyboard
|
|
18
20
|
// users interacting with SR-invisible content) doesn't materialise —
|
|
19
21
|
// an invisible tab stop is a focus-order/visible-focus concern instead.
|
|
22
|
+
// Transparency is measured up the flat tree: opacity is not an inherited
|
|
23
|
+
// property, so a faded WRAPPER (the standard off-canvas drawer) leaves
|
|
24
|
+
// every control inside it computing opacity: 1 while none of them paint.
|
|
20
25
|
const focusable = [element, ...element.querySelectorAll(FOCUSABLE)].filter(
|
|
21
26
|
(el) => el.matches?.(FOCUSABLE) && el.getAttribute('tabindex') !== '-1' && !el.disabled
|
|
22
27
|
// `inert` removes focusability outright — aria-hidden + inert is
|
|
23
28
|
// the CORRECT modern pattern for off-canvas content, not a defect.
|
|
24
29
|
&& !el.closest('[inert]')
|
|
25
|
-
&& isRendered(el) &&
|
|
30
|
+
&& isRendered(el) && cumulativeOpacity(el) > 0,
|
|
26
31
|
);
|
|
27
32
|
if (!focusable.length) return { status: 'pass' };
|
|
28
33
|
// While a modal dialog is open, the page behind it is DELIBERATELY
|
package/engine/wcag22.js
CHANGED
|
@@ -68,7 +68,7 @@ export default [
|
|
|
68
68
|
sc('2.3.2', 'Three Flashes', 'AAA', 'manual'),
|
|
69
69
|
sc('2.3.3', 'Animation from Interactions', 'AAA', 'manual', '2.1'),
|
|
70
70
|
sc('2.4.1', 'Bypass Blocks', 'A', 'partial'), // partial: "repeated across pages" can't be established from one page
|
|
71
|
-
sc('2.4.2', 'Page Titled', 'A', '
|
|
71
|
+
sc('2.4.2', 'Page Titled', 'A', 'partial'), // partial: document-title proves a title exists and catches known placeholders; whether a real one DESCRIBES the page needs a reader
|
|
72
72
|
sc('2.4.3', 'Focus Order', 'A', 'manual'), // no rule automates this yet; order judgment needs humans
|
|
73
73
|
sc('2.4.4', 'Link Purpose (In Context)', 'A', 'partial'),
|
|
74
74
|
sc('2.4.5', 'Multiple Ways', 'AA', 'manual'),
|
|
@@ -89,7 +89,7 @@ export default [
|
|
|
89
89
|
sc('2.5.7', 'Dragging Movements', 'AA', 'manual', '2.2'),
|
|
90
90
|
sc('2.5.8', 'Target Size (Minimum)', 'AA', 'partial', '2.2'), // partial: size/spacing are computable; the equivalent-control and essential exceptions need judgment
|
|
91
91
|
// 3. Understandable
|
|
92
|
-
sc('3.1.1', 'Language of Page', 'A', '
|
|
92
|
+
sc('3.1.1', 'Language of Page', 'A', 'partial'), // partial: html-lang proves the tag is present and well-formed; whether it names the language actually written needs a reader
|
|
93
93
|
sc('3.1.2', 'Language of Parts', 'AA', 'partial'), // partial: invalid lang values are provable; UNMARKED foreign passages need language identification
|
|
94
94
|
sc('3.1.3', 'Unusual Words', 'AAA', 'manual'),
|
|
95
95
|
sc('3.1.4', 'Abbreviations', 'AAA', 'manual'),
|
package/package.json
CHANGED