pour-engine 1.1.9 → 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 +71 -10
- 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 +22 -8
- package/engine/rules/wcag/1.4.3-color-contrast.js +123 -47
- 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/2.4.7-focus-visible.js +35 -11
- package/engine/rules/wcag/3.1.1-html-lang.js +10 -2
- package/engine/rules/wcag/3.1.2-valid-lang-parts.js +4 -1
- 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);
|
|
@@ -762,23 +780,45 @@ export function mediaRects(doc) {
|
|
|
762
780
|
* Returns { css, element } or null.
|
|
763
781
|
*/
|
|
764
782
|
export function backgroundImageSource(element) {
|
|
783
|
+
// Translucent background colours passed on the way up are painted BETWEEN
|
|
784
|
+
// the image and the text: the scrim over a hero photo is the everyday case.
|
|
785
|
+
// They are collected nearest-text first, so folding them back over a sampled
|
|
786
|
+
// image pixel means walking this list in reverse (see applyOverlays).
|
|
787
|
+
const overlays = [];
|
|
765
788
|
for (let current = element; current; current = current.parentElement) {
|
|
766
789
|
const style = getComputedStyle(current);
|
|
767
|
-
|
|
790
|
+
// An element's own background-image paints ABOVE its own background
|
|
791
|
+
// colour, so the element carrying the image contributes no overlay.
|
|
792
|
+
if (style.backgroundImage !== 'none') return { css: style.backgroundImage, element: current, overlays };
|
|
768
793
|
const color = parseColor(style.backgroundColor);
|
|
769
794
|
if (color && color.a >= 1) return null;
|
|
795
|
+
if (color && color.a > 0) overlays.push(color);
|
|
770
796
|
}
|
|
771
797
|
return null;
|
|
772
798
|
}
|
|
773
799
|
|
|
800
|
+
/** Paint `overlays` (nearest-text first) back over a base colour. */
|
|
801
|
+
export function applyOverlays(base, overlays) {
|
|
802
|
+
let result = base;
|
|
803
|
+
for (let i = overlays.length - 1; i >= 0; i--) result = composite(overlays[i], result);
|
|
804
|
+
return result;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
const overlayKey = (overlays) =>
|
|
808
|
+
overlays.map((c) => `${c.r},${c.g},${c.b},${c.a}`).join(';');
|
|
809
|
+
|
|
774
810
|
/**
|
|
775
811
|
* Luminance range (min/max) of an image's pixels, downscaled for speed.
|
|
776
812
|
* null when the image can't be read: cross-origin without CORS headers
|
|
777
|
-
* (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.
|
|
778
817
|
*/
|
|
779
818
|
const imageRangeCache = new Map();
|
|
780
|
-
export function imageLuminanceRange(url) {
|
|
781
|
-
|
|
819
|
+
export function imageLuminanceRange(url, overlays = []) {
|
|
820
|
+
const cacheKey = overlays.length ? `${url}|${overlayKey(overlays)}` : url;
|
|
821
|
+
if (imageRangeCache.has(cacheKey)) return imageRangeCache.get(cacheKey);
|
|
782
822
|
const promise = new Promise((resolve) => {
|
|
783
823
|
const img = new Image();
|
|
784
824
|
img.crossOrigin = 'anonymous';
|
|
@@ -795,12 +835,33 @@ export function imageLuminanceRange(url) {
|
|
|
795
835
|
const data = ctx.getImageData(0, 0, size, size).data;
|
|
796
836
|
let min = 1;
|
|
797
837
|
let max = 0;
|
|
838
|
+
let opaquePixels = 0;
|
|
798
839
|
for (let i = 0; i < data.length; i += 4) {
|
|
799
840
|
if (data[i + 3] < 128) continue; // mostly-transparent pixels reveal what's beneath — unknowable
|
|
800
|
-
|
|
841
|
+
opaquePixels += 1;
|
|
842
|
+
// Composite each pixel through anything painted between the image
|
|
843
|
+
// and the text before measuring: a photo under a 75% black scrim is
|
|
844
|
+
// seen as the blend, never as the photo.
|
|
845
|
+
const pixel = { r: data[i], g: data[i + 1], b: data[i + 2], a: 1 };
|
|
846
|
+
const l = luminance(overlays.length ? applyOverlays(pixel, overlays) : pixel);
|
|
801
847
|
if (l < min) min = l;
|
|
802
848
|
if (l > max) max = l;
|
|
803
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
|
+
}
|
|
804
865
|
resolve(min <= max ? { min, max, width: img.naturalWidth, height: img.naturalHeight } : null);
|
|
805
866
|
} catch {
|
|
806
867
|
resolve(null); // tainted canvas: cross-origin image without CORS headers
|
|
@@ -809,7 +870,7 @@ export function imageLuminanceRange(url) {
|
|
|
809
870
|
img.onerror = () => { clearTimeout(timer); resolve(null); };
|
|
810
871
|
img.src = url;
|
|
811
872
|
});
|
|
812
|
-
imageRangeCache.set(
|
|
873
|
+
imageRangeCache.set(cacheKey, promise);
|
|
813
874
|
return promise;
|
|
814
875
|
}
|
|
815
876
|
|
|
@@ -857,10 +918,10 @@ export function backgroundImagePaintRect(element, intrinsic) {
|
|
|
857
918
|
* Luminance range of a CSS gradient, from its colour stops. Interpolated
|
|
858
919
|
* colours stay close enough to their stops' luminances to bracket.
|
|
859
920
|
*/
|
|
860
|
-
export function gradientLuminanceRange(backgroundImageCss) {
|
|
921
|
+
export function gradientLuminanceRange(backgroundImageCss, overlays = []) {
|
|
861
922
|
const stops = (backgroundImageCss.match(/rgba?\([^)]+\)/g) ?? []).map(parseColor).filter(Boolean);
|
|
862
923
|
if (!stops.length || stops.some((c) => c.a < 1)) return null; // translucent stops reveal what's beneath
|
|
863
|
-
const lums = stops.map(luminance);
|
|
924
|
+
const lums = stops.map((stop) => luminance(overlays.length ? applyOverlays(stop, overlays) : stop));
|
|
864
925
|
return { min: Math.min(...lums), max: Math.max(...lums) };
|
|
865
926
|
}
|
|
866
927
|
|
|
@@ -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) {
|
|
@@ -7,12 +7,18 @@ export default {
|
|
|
7
7
|
tags: ['wcag2a', 'wcag141'],
|
|
8
8
|
help: 'Links inside text must be distinguishable by more than colour',
|
|
9
9
|
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html',
|
|
10
|
-
// Only running prose counts as a "text block"
|
|
11
|
-
//
|
|
12
|
-
|
|
10
|
+
// Only running prose counts as a "text block". <li> is included because
|
|
11
|
+
// footers and credits routinely write sentences as list items ("built by
|
|
12
|
+
// the <a>team</a> with help from <a>contributors</a>"), and those links are
|
|
13
|
+
// embedded in prose exactly as 1.4.1 means it. Pure link lists (navs,
|
|
14
|
+
// blogrolls) are still excluded, but by the ownText guard below rather than
|
|
15
|
+
// by tag: in <li><a>Home</a></li> the item has no text of its own.
|
|
16
|
+
// <div> was measured too and added nothing on any test site, so it stays
|
|
17
|
+
// out rather than widening the blast radius for no gain.
|
|
18
|
+
selector: 'p a[href], dd a[href], blockquote a[href], td a[href], li a[href]',
|
|
13
19
|
visibility: 'visual', // colour distinction is a purely visual concern
|
|
14
20
|
evaluate(element, { ownText }) {
|
|
15
|
-
const parent = element.closest('p, dd, blockquote, td');
|
|
21
|
+
const parent = element.closest('p, dd, blockquote, td, li');
|
|
16
22
|
if (!element.textContent.trim() || !parent) return { status: 'pass' };
|
|
17
23
|
// Needs real surrounding text to blend into (1.4.1 is about links
|
|
18
24
|
// embedded in prose, not a link that IS the content).
|
|
@@ -48,9 +54,17 @@ export default {
|
|
|
48
54
|
// its own line (<br>-separated address blocks, one-link-per-line
|
|
49
55
|
// footers) has no surrounding words to blend into — nothing is
|
|
50
56
|
// conveyed by colour.
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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);
|
|
54
68
|
const walker = element.ownerDocument.createTreeWalker(parent, NodeFilter.SHOW_TEXT);
|
|
55
69
|
const range = element.ownerDocument.createRange();
|
|
56
70
|
let sharesLine = false;
|
|
@@ -58,7 +72,7 @@ export default {
|
|
|
58
72
|
if (!node.textContent.trim() || element.contains(node)) continue;
|
|
59
73
|
range.selectNodeContents(node);
|
|
60
74
|
for (const r of range.getClientRects()) {
|
|
61
|
-
if (r.
|
|
75
|
+
if (r.width > 0 && mids.some((mid) => r.top <= mid && r.bottom >= mid)) { sharesLine = true; break; }
|
|
62
76
|
}
|
|
63
77
|
}
|
|
64
78
|
if (!sharesLine) return { status: 'pass' };
|
|
@@ -125,10 +125,68 @@ function verdictFromRange(range, foreground, required, what) {
|
|
|
125
125
|
};
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Is there translucent paint over the background image that the ancestor walk
|
|
130
|
+
* could NOT have collected? Two shapes dominate real hero banners: a
|
|
131
|
+
* ::before/::after scrim, and an absolutely-positioned sibling laid over the
|
|
132
|
+
* photo. Neither is on the text's ancestor chain, so backgroundImageSource
|
|
133
|
+
* never sees them, and judging the raw image pixels would describe a page the
|
|
134
|
+
* user is not looking at. Ancestor scrims are excluded here because they are
|
|
135
|
+
* already composited into the sampled pixels.
|
|
136
|
+
*/
|
|
137
|
+
function unaccountedScrim(element, imageCarrier) {
|
|
138
|
+
const pseudo = pseudoBackdropForText(element);
|
|
139
|
+
if (pseudo && pseudo !== 'unresolved' && pseudo.color?.a > 0) return true;
|
|
140
|
+
const doc = element.ownerDocument;
|
|
141
|
+
if (typeof doc.elementsFromPoint !== 'function') return false;
|
|
142
|
+
const rect = element.getBoundingClientRect();
|
|
143
|
+
const win = doc.defaultView;
|
|
144
|
+
const x = rect.left + rect.width / 2;
|
|
145
|
+
const y = rect.top + rect.height / 2;
|
|
146
|
+
if (x < 0 || y < 0 || x >= win.innerWidth || y >= win.innerHeight) return false;
|
|
147
|
+
const stack = doc.elementsFromPoint(x, y);
|
|
148
|
+
const start = stack.indexOf(element);
|
|
149
|
+
if (start === -1) return false;
|
|
150
|
+
return stack.slice(start + 1).some((layer) => {
|
|
151
|
+
if (layer.contains(element) || layer === imageCarrier) return false; // ancestor chain: already composited
|
|
152
|
+
const color = parseColor(getComputedStyle(layer).backgroundColor);
|
|
153
|
+
return color && color.a > 0;
|
|
154
|
+
});
|
|
155
|
+
}
|
|
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
|
+
|
|
128
161
|
// Bracket the verdict by sampling what's actually painted: image pixels
|
|
129
|
-
// (same-origin/CORS only) or gradient colour stops.
|
|
130
|
-
|
|
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.
|
|
165
|
+
async function sampledVerdict(source, foreground, required, doc, overlays = []) {
|
|
131
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;
|
|
132
190
|
// Translucent text takes its presented colour from the very pixels
|
|
133
191
|
// beneath it — a luminance range of the backdrop cannot bracket the
|
|
134
192
|
// blend (dimmed white over a dark photo reads mid-grey, not white).
|
|
@@ -139,22 +197,15 @@ async function sampledVerdict(source, foreground, required, doc) {
|
|
|
139
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.',
|
|
140
198
|
};
|
|
141
199
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
if (url) {
|
|
150
|
-
let absolute;
|
|
151
|
-
try { absolute = new URL(url, doc.baseURI).href; } catch { return null; }
|
|
152
|
-
return verdictFromRange(await imageLuminanceRange(absolute), foreground, required, 'image');
|
|
153
|
-
}
|
|
154
|
-
if (css.includes('gradient(')) {
|
|
155
|
-
return verdictFromRange(gradientLuminanceRange(css), 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
|
+
};
|
|
156
207
|
}
|
|
157
|
-
return
|
|
208
|
+
return verdictFromRange(range, foreground, required, what);
|
|
158
209
|
}
|
|
159
210
|
|
|
160
211
|
/**
|
|
@@ -248,27 +299,44 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
248
299
|
if (imageSource) {
|
|
249
300
|
const { relation, intrinsic, dimensionless } = await imageVsText(imageSource, element, doc);
|
|
250
301
|
if (relation !== 'clear') {
|
|
251
|
-
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
302
|
+
// imageSource.overlays are the translucent layers painted between the
|
|
303
|
+
// image and this text (hero scrims, tint panels). Judging the raw
|
|
304
|
+
// image pixels would describe a page the user never sees.
|
|
305
|
+
let sampled = await sampledVerdict(imageSource, foreground, required, doc, imageSource.overlays);
|
|
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 ?? {
|
|
264
336
|
status: 'incomplete',
|
|
265
|
-
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.',
|
|
266
338
|
};
|
|
267
339
|
}
|
|
268
|
-
return sampled ?? {
|
|
269
|
-
status: 'incomplete',
|
|
270
|
-
message: 'The background here is an image that can’t be read (cross-origin) — contrast must be checked by eye.',
|
|
271
|
-
};
|
|
272
340
|
}
|
|
273
341
|
}
|
|
274
342
|
|
|
@@ -319,17 +387,23 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
319
387
|
// exactly as an element's own background-image is bracketed.
|
|
320
388
|
if (pseudoBack?.image) {
|
|
321
389
|
const sampled = await sampledVerdict(pseudoBack.image, foreground, required, doc);
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
+
}
|
|
326
398
|
}
|
|
327
399
|
if (painted?.image && !pseudoBack) {
|
|
328
400
|
const sampled = await sampledVerdict(painted.image, foreground, required, doc);
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
+
}
|
|
333
407
|
}
|
|
334
408
|
// Only a hit-test-resolved backdrop is VERIFIED paint truth. Anything
|
|
335
409
|
// else (offscreen sample point, element invisible to its own hit-test —
|
|
@@ -346,10 +420,12 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
346
420
|
const obscuring = backgroundObscured(element);
|
|
347
421
|
if (obscuring && obscuring !== 'unverifiable') {
|
|
348
422
|
const sampled = await sampledVerdict(obscuring, foreground, required, doc);
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
+
}
|
|
353
429
|
}
|
|
354
430
|
}
|
|
355
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
|
};
|
|
@@ -5,19 +5,43 @@
|
|
|
5
5
|
// rule entirely — so this flags for review, it never asserts a fail.
|
|
6
6
|
function focusSuppressors(doc) {
|
|
7
7
|
const suspects = [];
|
|
8
|
+
const replacementIn = (style) => style.getPropertyValue('box-shadow') || style.getPropertyValue('border')
|
|
9
|
+
|| style.getPropertyValue('border-color') || style.getPropertyValue('background')
|
|
10
|
+
|| style.getPropertyValue('background-color') || style.getPropertyValue('text-decoration');
|
|
11
|
+
|
|
12
|
+
const inspect = (rule) => {
|
|
13
|
+
if (!rule.selectorText || !/:focus/.test(rule.selectorText)) return;
|
|
14
|
+
// `:focus:not(:focus-visible) { outline: none }` is the PUBLISHED pattern
|
|
15
|
+
// for showing the ring to keyboard users only: it strips the indicator
|
|
16
|
+
// precisely in the state where the browser has decided not to show focus,
|
|
17
|
+
// and leaves the keyboard state alone. Flagging it fails the pattern the
|
|
18
|
+
// guidance recommends, and it ships in most modern CSS resets.
|
|
19
|
+
if (/:not\(\s*:focus-visible\s*\)/.test(rule.selectorText)) return;
|
|
20
|
+
const style = rule.style;
|
|
21
|
+
if (!style) return;
|
|
22
|
+
const outline = `${style.getPropertyValue('outline')} ${style.getPropertyValue('outline-style')} ${style.getPropertyValue('outline-width')}`;
|
|
23
|
+
const removesOutline = /\bnone\b/.test(outline) || /(^|\s)0(px)?(\s|$)/.test(outline);
|
|
24
|
+
if (!removesOutline) return;
|
|
25
|
+
// A replacement indicator may not sit in the rule's own style block: any
|
|
26
|
+
// declaration written AFTER a nested rule is parsed into a separate
|
|
27
|
+
// CSSNestedDeclarations child (a style-bearing rule with no selector), so
|
|
28
|
+
// reading only rule.style would report a suppressor that is in fact
|
|
29
|
+
// replaced two lines further down.
|
|
30
|
+
const nested = [...(rule.cssRules ?? [])]
|
|
31
|
+
.filter((child) => child.style && !child.selectorText);
|
|
32
|
+
if (replacementIn(style) || nested.some((child) => replacementIn(child.style))) return;
|
|
33
|
+
suspects.push(rule.selectorText);
|
|
34
|
+
};
|
|
35
|
+
// Inspect each rule, THEN descend into any children. Testing `rule.cssRules`
|
|
36
|
+
// first and skipping used to walk straight past every plain style rule:
|
|
37
|
+
// since CSS Nesting shipped, a CSSStyleRule carries its own cssRules list,
|
|
38
|
+
// and an empty CSSRuleList is still a truthy object, so the check meant for
|
|
39
|
+
// @media/@supports swallowed the whole sheet and the rule could never fire.
|
|
40
|
+
// A style rule can both declare and nest, so both halves have to run.
|
|
8
41
|
const scan = (rules) => {
|
|
9
42
|
for (const rule of rules) {
|
|
10
|
-
|
|
11
|
-
if (
|
|
12
|
-
const style = rule.style;
|
|
13
|
-
if (!style) continue;
|
|
14
|
-
const outline = `${style.getPropertyValue('outline')} ${style.getPropertyValue('outline-style')} ${style.getPropertyValue('outline-width')}`;
|
|
15
|
-
const removesOutline = /\bnone\b/.test(outline) || /(^|\s)0(px)?(\s|$)/.test(outline);
|
|
16
|
-
if (!removesOutline) continue;
|
|
17
|
-
const replaced = style.getPropertyValue('box-shadow') || style.getPropertyValue('border')
|
|
18
|
-
|| style.getPropertyValue('border-color') || style.getPropertyValue('background')
|
|
19
|
-
|| style.getPropertyValue('background-color') || style.getPropertyValue('text-decoration');
|
|
20
|
-
if (!replaced) suspects.push(rule.selectorText);
|
|
43
|
+
inspect(rule);
|
|
44
|
+
if (rule.cssRules?.length) scan(rule.cssRules); // @media/@supports/nesting
|
|
21
45
|
}
|
|
22
46
|
};
|
|
23
47
|
for (const sheet of doc.styleSheets) {
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
// WCAG SC 3.1.1 Language of Page (Level A)
|
|
2
|
-
// Rough BCP 47 shape: primary subtag + optional extras ("en", "en-GB",
|
|
3
|
-
|
|
2
|
+
// Rough BCP 47 shape: primary subtag + optional extras ("en", "en-GB",
|
|
3
|
+
// "zh-Hans"). Subtags may be a SINGLE character: BCP 47 extension and
|
|
4
|
+
// private-use singletons are exactly that ("de-DE-u-co-phonebk",
|
|
5
|
+
// "zh-CN-x-private"), so a 2-character floor rejects legal tags. The second
|
|
6
|
+
// branch covers private-use-only and grandfathered tags ("x-klingon",
|
|
7
|
+
// "i-klingon"), whose primary subtag is itself one character.
|
|
8
|
+
// A 5-8 letter primary subtag is legal BCP 47 but unassigned in the IANA
|
|
9
|
+
// registry, so it stays rejected: that is what catches lang="english".
|
|
10
|
+
// Keep this in step with 3.1.2-valid-lang-parts.js.
|
|
11
|
+
const LANG_PATTERN = /^([a-zA-Z]{2,3}(-[a-zA-Z0-9]{1,8})*|[xXiI](-[a-zA-Z0-9]{1,8})+)$/;
|
|
4
12
|
|
|
5
13
|
export default {
|
|
6
14
|
id: 'html-lang',
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
// WCAG SC 3.1.2 Language of Parts (Level AA)
|
|
2
|
-
|
|
2
|
+
// Same shape as 3.1.1-html-lang.js, and must stay in step with it: subtags
|
|
3
|
+
// may be a single character, because BCP 47 extension and private-use
|
|
4
|
+
// singletons ("-u-", "-t-", "-x-") are exactly one.
|
|
5
|
+
const LANG_PATTERN = /^([a-zA-Z]{2,3}(-[a-zA-Z0-9]{1,8})*|[xXiI](-[a-zA-Z0-9]{1,8})+)$/;
|
|
3
6
|
|
|
4
7
|
export default {
|
|
5
8
|
id: 'valid-lang-parts',
|
|
@@ -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