pour-engine 1.2.0 → 1.4.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/COVERAGE.md +5 -3
- package/engine/README.md +7 -6
- package/engine/index.js +1 -1
- package/engine/lib/contrast.js +67 -9
- 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 +19 -6
- package/engine/rules/wcag/1.4.3-color-contrast.js +135 -60
- package/engine/rules/wcag/2.1.1-scrollable-region-focusable.js +19 -3
- 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.5.8-target-size.js +89 -4
- 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
|
@@ -102,14 +102,15 @@ pour engine therefore reports on **every** in-scope criterion, one of two ways:
|
|
|
102
102
|
1. **Automated/partial** — the rules in `rules/`.
|
|
103
103
|
2. **Manual review** — every audit's results include `manualReview`: the
|
|
104
104
|
in-scope criteria (filtered by the requested WCAG version and level)
|
|
105
|
-
that automation can't judge,
|
|
105
|
+
that automation can't judge, for a host to render however it wants.
|
|
106
106
|
|
|
107
107
|
Nothing in the spec is silently skipped — that claim is the product.
|
|
108
108
|
|
|
109
109
|
## Honest current state
|
|
110
110
|
|
|
111
|
-
|
|
112
|
-
shadow
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
111
|
+
72 rules, hardened on real sites and the `testbeds/` pages. Audits walk into
|
|
112
|
+
open shadow roots (targets come back as `host >>> inner` selector chains);
|
|
113
|
+
iframe traversal is the host's job, and the extension does it. Known gaps:
|
|
114
|
+
the full accname spec (ours is a close approximation) and the language
|
|
115
|
+
subtag registry (BCP 47 shape check only). Grow it rule by rule; the test
|
|
116
|
+
beds' `expect:` annotations and the spec cases are the regression suite.
|
package/engine/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Engine runner: evaluates every applicable rule against the page and
|
|
2
2
|
// returns structured results (violations / passes / incomplete /
|
|
3
3
|
// inapplicable, plus the manual-review criteria and per-rule timings).
|
|
4
4
|
import config from '../config/project.config.js';
|
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,17 +835,42 @@ 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
|
+
// The colours at those extremes, kept for the same reason the
|
|
839
|
+
// gradient range keeps them: translucent text blends with the actual
|
|
840
|
+
// pixels, and a luminance cannot be un-gamma'd back into an rgb.
|
|
841
|
+
let minColor = null;
|
|
842
|
+
let maxColor = null;
|
|
843
|
+
let opaquePixels = 0;
|
|
817
844
|
for (let i = 0; i < data.length; i += 4) {
|
|
818
845
|
if (data[i + 3] < 128) continue; // mostly-transparent pixels reveal what's beneath — unknowable
|
|
846
|
+
opaquePixels += 1;
|
|
819
847
|
// Composite each pixel through anything painted between the image
|
|
820
848
|
// and the text before measuring: a photo under a 75% black scrim is
|
|
821
849
|
// seen as the blend, never as the photo.
|
|
822
850
|
const pixel = { r: data[i], g: data[i + 1], b: data[i + 2], a: 1 };
|
|
823
|
-
const
|
|
824
|
-
|
|
825
|
-
if (l
|
|
851
|
+
const shown = overlays.length ? applyOverlays(pixel, overlays) : pixel;
|
|
852
|
+
const l = luminance(shown);
|
|
853
|
+
if (l < min) { min = l; minColor = shown; }
|
|
854
|
+
if (l > max) { max = l; maxColor = shown; }
|
|
855
|
+
}
|
|
856
|
+
// Not one opaque pixel: a spacer, a cleared sprite, or a decorative
|
|
857
|
+
// layer left in place with nothing in it. It covers the background
|
|
858
|
+
// without changing a single one of its pixels.
|
|
859
|
+
if (!opaquePixels) {
|
|
860
|
+
transparentImages.add(url);
|
|
861
|
+
// Any background the walk already gave up on ("there's an image in
|
|
862
|
+
// the chain") may have been given up on because of THIS image.
|
|
863
|
+
// Those answers are now wrong, and which ones is not worth
|
|
864
|
+
// tracking: a page has a handful of transparent images at most, so
|
|
865
|
+
// the walk simply re-runs. Without this the verdict would depend
|
|
866
|
+
// on which rule happened to reach the element first.
|
|
867
|
+
backgroundCache = new WeakMap();
|
|
868
|
+
resolve({ transparent: true, width: img.naturalWidth, height: img.naturalHeight });
|
|
869
|
+
return;
|
|
826
870
|
}
|
|
827
|
-
resolve(min <= max
|
|
871
|
+
resolve(min <= max
|
|
872
|
+
? { min, max, minColor, maxColor, width: img.naturalWidth, height: img.naturalHeight }
|
|
873
|
+
: null);
|
|
828
874
|
} catch {
|
|
829
875
|
resolve(null); // tainted canvas: cross-origin image without CORS headers
|
|
830
876
|
}
|
|
@@ -883,8 +929,20 @@ export function backgroundImagePaintRect(element, intrinsic) {
|
|
|
883
929
|
export function gradientLuminanceRange(backgroundImageCss, overlays = []) {
|
|
884
930
|
const stops = (backgroundImageCss.match(/rgba?\([^)]+\)/g) ?? []).map(parseColor).filter(Boolean);
|
|
885
931
|
if (!stops.length || stops.some((c) => c.a < 1)) return null; // translucent stops reveal what's beneath
|
|
886
|
-
const
|
|
887
|
-
|
|
932
|
+
const composited = stops.map((stop) => (overlays.length ? applyOverlays(stop, overlays) : stop));
|
|
933
|
+
const lums = composited.map(luminance);
|
|
934
|
+
const min = Math.min(...lums);
|
|
935
|
+
const max = Math.max(...lums);
|
|
936
|
+
// The COLOURS at the extremes, not just their luminances: translucent text
|
|
937
|
+
// takes its presented colour from the pixels beneath, so bracketing that
|
|
938
|
+
// blend needs the actual rgb to composite against, and luminance alone
|
|
939
|
+
// cannot be un-gamma'd back into one.
|
|
940
|
+
return {
|
|
941
|
+
min,
|
|
942
|
+
max,
|
|
943
|
+
minColor: composited[lums.indexOf(min)],
|
|
944
|
+
maxColor: composited[lums.indexOf(max)],
|
|
945
|
+
};
|
|
888
946
|
}
|
|
889
947
|
|
|
890
948
|
/** Contrast ratio from two luminances. */
|
|
@@ -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,16 +72,21 @@ 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' };
|
|
71
79
|
}
|
|
72
80
|
return {
|
|
73
81
|
status: 'fail',
|
|
82
|
+
// The measured fact is the ratio against G183's threshold, and that is
|
|
83
|
+
// all this says. Naming a group who "can't see it" overclaims: a
|
|
84
|
+
// saturated red against near-white misses 3:1 on luminance while
|
|
85
|
+
// staying perfectly distinct to most people, including most red-green
|
|
86
|
+
// colour blindness, where the red darkens and the gap widens.
|
|
74
87
|
message: ratio < 1.01
|
|
75
|
-
? 'This link has no underline and the same colour as the surrounding text
|
|
76
|
-
: `This link has no underline and only ${ratio.toFixed(2)}:1 colour difference from the surrounding text
|
|
88
|
+
? 'This link has no underline and the same colour as the surrounding text, so there is nothing at all to mark it as a link.'
|
|
89
|
+
: `This link has no underline and only ${ratio.toFixed(2)}:1 colour difference from the surrounding text, below the 3:1 WCAG technique G183 asks for when colour is the only thing marking a link.`,
|
|
77
90
|
fix: 'Underline links inside text (text-decoration: underline), or add a non-colour indicator.',
|
|
78
91
|
};
|
|
79
92
|
},
|
|
@@ -78,9 +78,26 @@ async function imageVsText(imageSource, element, doc) {
|
|
|
78
78
|
// paint rect must not use it (icon SVGs would "cover" all the text).
|
|
79
79
|
const dimensionless = !intrinsic || !intrinsic.width
|
|
80
80
|
|| (intrinsic.width === 300 && intrinsic.height === 150);
|
|
81
|
+
// A gradient has no intrinsic size and never will, but that is not the
|
|
82
|
+
// same as an unknown paint area: with the default background-size it fills
|
|
83
|
+
// its carrier's painting box exactly. Lumping it in with "we cannot tell
|
|
84
|
+
// how big this is" meant a gradient could never earn a definite verdict,
|
|
85
|
+
// however large the panel it covers. Its scale is the carrier's, unless
|
|
86
|
+
// background-size names something small, which is the one way a gradient
|
|
87
|
+
// really is icon-scale (a tiny repeated swatch).
|
|
88
|
+
const isGradient = !url && imageSource.css.includes('gradient(');
|
|
89
|
+
let sizedSmall = false;
|
|
90
|
+
if (isGradient) {
|
|
91
|
+
const size = getComputedStyle(imageSource.element).backgroundSize ?? '';
|
|
92
|
+
const px = [...size.matchAll(/(\d+(?:\.\d+)?)px/g)].map((m) => parseFloat(m[1]));
|
|
93
|
+
sizedSmall = px.length > 0 && px.every((value) => value <= 40);
|
|
94
|
+
}
|
|
81
95
|
const paint = backgroundImagePaintRect(imageSource.element, dimensionless ? null : intrinsic);
|
|
82
|
-
if (!paint) return { relation: 'unknown', intrinsic, dimensionless };
|
|
83
|
-
return {
|
|
96
|
+
if (!paint) return { relation: 'unknown', intrinsic, dimensionless, isGradient, sizedSmall };
|
|
97
|
+
return {
|
|
98
|
+
relation: textIntersects(element, paint) ? 'under' : 'clear',
|
|
99
|
+
intrinsic, dimensionless, isGradient, sizedSmall,
|
|
100
|
+
};
|
|
84
101
|
}
|
|
85
102
|
|
|
86
103
|
/**
|
|
@@ -154,36 +171,79 @@ function unaccountedScrim(element, imageCarrier) {
|
|
|
154
171
|
});
|
|
155
172
|
}
|
|
156
173
|
|
|
174
|
+
/** An image layer with no opaque pixels: it covers the background without
|
|
175
|
+
* changing it, so the callers step over it rather than reporting on it. */
|
|
176
|
+
const PAINTS_NOTHING = Symbol('fully transparent image layer');
|
|
177
|
+
|
|
157
178
|
// Bracket the verdict by sampling what's actually painted: image pixels
|
|
158
|
-
// (same-origin/CORS only) or gradient colour stops.
|
|
179
|
+
// (same-origin/CORS only) or gradient colour stops. Returns an outcome,
|
|
180
|
+
// PAINTS_NOTHING, or null when nothing here can be sampled at all (stacked
|
|
181
|
+
// layers, translucent gradient stops) and the caller must say so itself.
|
|
159
182
|
async function sampledVerdict(source, foreground, required, doc, overlays = []) {
|
|
160
183
|
if (!source) return null;
|
|
184
|
+
let range = null;
|
|
185
|
+
let what = 'image';
|
|
186
|
+
if (source.tagName === 'IMG') {
|
|
187
|
+
range = await imageLuminanceRange(source.currentSrc || source.src, overlays);
|
|
188
|
+
} else {
|
|
189
|
+
const css = source.css ?? getComputedStyle(source).backgroundImage;
|
|
190
|
+
if (!css || css === 'none') return null;
|
|
191
|
+
if ((css.match(/url\(/g) ?? []).length + (css.match(/gradient\(/g) ?? []).length > 1) return null; // stacked layers: too complex
|
|
192
|
+
const url = css.match(/url\(["']?(.*?)["']?\)/)?.[1];
|
|
193
|
+
if (url) {
|
|
194
|
+
let absolute;
|
|
195
|
+
try { absolute = new URL(url, doc.baseURI).href; } catch { return null; }
|
|
196
|
+
range = await imageLuminanceRange(absolute, overlays);
|
|
197
|
+
} else if (css.includes('gradient(')) {
|
|
198
|
+
range = gradientLuminanceRange(css, overlays);
|
|
199
|
+
what = 'gradient';
|
|
200
|
+
} else {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// Checked before the text's own alpha: there is no blend to reason about
|
|
205
|
+
// when the layer paints nothing.
|
|
206
|
+
if (range?.transparent) return PAINTS_NOTHING;
|
|
161
207
|
// Translucent text takes its presented colour from the very pixels
|
|
162
|
-
// beneath it
|
|
163
|
-
//
|
|
164
|
-
//
|
|
208
|
+
// beneath it, so a luminance range of the backdrop cannot be read straight
|
|
209
|
+
// off: dimmed white over a dark photo reads mid-grey, not white.
|
|
210
|
+
//
|
|
211
|
+
// It CAN still be bracketed, by compositing the text over each end of the
|
|
212
|
+
// backdrop's range and judging each blend against the backdrop it was
|
|
213
|
+
// blended with. That bracket is sound in one direction only. Ratio is not
|
|
214
|
+
// monotonic in backdrop luminance — as the composited text passes through
|
|
215
|
+
// the backdrop's own luminance the ratio collapses toward 1:1 — so an
|
|
216
|
+
// interior point can be WORSE than both ends but never better. Both ends
|
|
217
|
+
// failing therefore proves the whole range fails; both ends passing proves
|
|
218
|
+
// nothing about the middle, and stays a human call.
|
|
165
219
|
if (foreground.a < 1) {
|
|
220
|
+
const ends = [range?.minColor, range?.maxColor].filter(Boolean);
|
|
221
|
+
if (ends.length === 2) {
|
|
222
|
+
const ratios = ends.map((backdrop) => contrastRatio(composite(foreground, backdrop), backdrop));
|
|
223
|
+
if (ratios.every((ratio) => ratio < required)) {
|
|
224
|
+
const best = Math.max(...ratios);
|
|
225
|
+
return {
|
|
226
|
+
status: 'fail',
|
|
227
|
+
message: `This text is translucent, so it blends with what is behind it: against every part of that backdrop the blend reaches at best ${showRatio(best)}:1, below the ${required}:1 minimum.`,
|
|
228
|
+
fix: 'Raise the text opacity, change its colour, or put a solid layer between the text and the backdrop.',
|
|
229
|
+
data: { ratio: Number(showRatio(best)), required },
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
}
|
|
166
233
|
return {
|
|
167
234
|
status: 'incomplete',
|
|
168
235
|
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
236
|
};
|
|
170
237
|
}
|
|
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');
|
|
238
|
+
// An image we tried to read and couldn't: that is a specific, reportable
|
|
239
|
+
// fact, and the only one of these paths where cross-origin is the cause.
|
|
240
|
+
if (!range && what === 'image') {
|
|
241
|
+
return {
|
|
242
|
+
status: 'incomplete',
|
|
243
|
+
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.',
|
|
244
|
+
};
|
|
185
245
|
}
|
|
186
|
-
return
|
|
246
|
+
return verdictFromRange(range, foreground, required, what);
|
|
187
247
|
}
|
|
188
248
|
|
|
189
249
|
/**
|
|
@@ -275,41 +335,48 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
275
335
|
// images (external-link arrows etc.) don't affect the text's contrast.
|
|
276
336
|
const imageSource = backgroundImageSource(element);
|
|
277
337
|
if (imageSource) {
|
|
278
|
-
const { relation, intrinsic, dimensionless } = await imageVsText(imageSource, element, doc);
|
|
338
|
+
const { relation, intrinsic, dimensionless, isGradient, sizedSmall } = await imageVsText(imageSource, element, doc);
|
|
279
339
|
if (relation !== 'clear') {
|
|
280
340
|
// imageSource.overlays are the translucent layers painted between the
|
|
281
341
|
// image and this text (hero scrims, tint panels). Judging the raw
|
|
282
342
|
// image pixels would describe a page the user never sees.
|
|
283
343
|
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
|
-
|
|
344
|
+
// A layer with no opaque pixels changes nothing about what is behind
|
|
345
|
+
// the glyphs, so it must not block the verdict: fall through and
|
|
346
|
+
// judge the background that actually paints.
|
|
347
|
+
if (sampled !== PAINTS_NOTHING) {
|
|
348
|
+
// Scrims the ancestor walk cannot reach (pseudo-elements, positioned
|
|
349
|
+
// siblings) leave the sampled range describing the wrong pixels, so
|
|
350
|
+
// neither verdict is safe to assert from it.
|
|
351
|
+
if (sampled && unaccountedScrim(element, imageSource.element)) {
|
|
352
|
+
sampled = {
|
|
353
|
+
status: 'incomplete',
|
|
354
|
+
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.',
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
// Icon-scale images: bullets, arrows, and decorations painted
|
|
358
|
+
// beside or beneath a corner of the text — their pixels may flag
|
|
359
|
+
// for eyes, never assert a failure. Icon-scale means: no reliable
|
|
360
|
+
// intrinsic size, an intrinsically tiny image, OR a background
|
|
361
|
+
// carrier whose own box is line-height-sized (contain/cover paint
|
|
362
|
+
// can never exceed the carrier — a 512px nominal SVG painted on an
|
|
363
|
+
// inline link is still an icon). Only backdrop-scale images earn a
|
|
364
|
+
// definite fail from sampling.
|
|
365
|
+
const carrierBox = imageSource.element.getBoundingClientRect();
|
|
366
|
+
const iconScale = isGradient
|
|
367
|
+
? (sizedSmall || carrierBox.height <= 40)
|
|
368
|
+
: (dimensionless || (intrinsic.width <= 32 && intrinsic.height <= 32) || carrierBox.height <= 40);
|
|
369
|
+
if (sampled?.status === 'fail' && iconScale) {
|
|
370
|
+
return {
|
|
371
|
+
status: 'incomplete',
|
|
372
|
+
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.',
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
return sampled ?? {
|
|
305
376
|
status: 'incomplete',
|
|
306
|
-
message: '
|
|
377
|
+
message: 'The background here is several stacked image or gradient layers, which a script can’t sample. Contrast must be checked by eye.',
|
|
307
378
|
};
|
|
308
379
|
}
|
|
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
380
|
}
|
|
314
381
|
}
|
|
315
382
|
|
|
@@ -360,17 +427,23 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
360
427
|
// exactly as an element's own background-image is bracketed.
|
|
361
428
|
if (pseudoBack?.image) {
|
|
362
429
|
const sampled = await sampledVerdict(pseudoBack.image, foreground, required, doc);
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
430
|
+
// PAINTS_NOTHING: the pseudo-element's image is fully transparent, so
|
|
431
|
+
// it never displaced the background the walk resolved.
|
|
432
|
+
if (sampled !== PAINTS_NOTHING) {
|
|
433
|
+
return sampled ?? {
|
|
434
|
+
status: 'incomplete',
|
|
435
|
+
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.',
|
|
436
|
+
};
|
|
437
|
+
}
|
|
367
438
|
}
|
|
368
439
|
if (painted?.image && !pseudoBack) {
|
|
369
440
|
const sampled = await sampledVerdict(painted.image, foreground, required, doc);
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
441
|
+
if (sampled !== PAINTS_NOTHING) {
|
|
442
|
+
return sampled ?? {
|
|
443
|
+
status: 'incomplete',
|
|
444
|
+
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.',
|
|
445
|
+
};
|
|
446
|
+
}
|
|
374
447
|
}
|
|
375
448
|
// Only a hit-test-resolved backdrop is VERIFIED paint truth. Anything
|
|
376
449
|
// else (offscreen sample point, element invisible to its own hit-test —
|
|
@@ -387,10 +460,12 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
387
460
|
const obscuring = backgroundObscured(element);
|
|
388
461
|
if (obscuring && obscuring !== 'unverifiable') {
|
|
389
462
|
const sampled = await sampledVerdict(obscuring, foreground, required, doc);
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
463
|
+
if (sampled !== PAINTS_NOTHING) {
|
|
464
|
+
return sampled ?? {
|
|
465
|
+
status: 'incomplete',
|
|
466
|
+
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.',
|
|
467
|
+
};
|
|
468
|
+
}
|
|
394
469
|
}
|
|
395
470
|
}
|
|
396
471
|
if (pseudoBack?.color) {
|
|
@@ -39,10 +39,26 @@ export default {
|
|
|
39
39
|
// including content slotted in from the light DOM and content inside
|
|
40
40
|
// open shadow roots (carousels built as web components).
|
|
41
41
|
if (hasFocusableContent(element)) return { status: 'pass' };
|
|
42
|
+
// NOT a failure, because the browser may already have solved it.
|
|
43
|
+
//
|
|
44
|
+
// Chromium puts a scroll container in the tab order by itself when the
|
|
45
|
+
// container holds nothing focusable — and that condition is exactly the
|
|
46
|
+
// one this rule fires on, so in Chrome every element reaching this line
|
|
47
|
+
// IS keyboard reachable. Measured, not assumed: Chrome 151 tabs
|
|
48
|
+
// before → the scroller → after, while `element.tabIndex` still reports
|
|
49
|
+
// -1, which is why the check above cannot see it.
|
|
50
|
+
//
|
|
51
|
+
// It is not settled across engines though. MDN still advises the
|
|
52
|
+
// tabindex ("in some browsers, scrolling content areas are not
|
|
53
|
+
// keyboard-focusable"), and WebKit did not take focus here. So the
|
|
54
|
+
// honest report is the fact plus the split, not a verdict: asserting a
|
|
55
|
+
// Level A failure would be wrong for anyone on Chrome, and asserting a
|
|
56
|
+
// pass would be wrong for anyone who has to support the browsers that
|
|
57
|
+
// don't.
|
|
42
58
|
return {
|
|
43
|
-
status: '
|
|
44
|
-
message: 'This region scrolls
|
|
45
|
-
fix: '
|
|
59
|
+
status: 'incomplete',
|
|
60
|
+
message: 'This region scrolls and holds nothing focusable, so reaching its overflowed content depends on the browser. Chromium gives such containers keyboard focus automatically; not every engine does. Tab to it in the browsers you support, and add tabindex="0" if it cannot be reached.',
|
|
61
|
+
fix: 'If you support browsers that do not focus scroll containers, add tabindex="0" (plus role="region" and an aria-label describing it).',
|
|
46
62
|
};
|
|
47
63
|
},
|
|
48
64
|
};
|
|
@@ -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
|
};
|
|
@@ -38,6 +38,68 @@ function isHiddenFromPointer(element, rect) {
|
|
|
38
38
|
return false;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Is this element the thing a click at that point would actually reach, or
|
|
43
|
+
* is something painted over it there?
|
|
44
|
+
*
|
|
45
|
+
* The spacing exception is about targets COMPETING for pointer space, and
|
|
46
|
+
* two targets that can never be hit at the same place do not compete. On any
|
|
47
|
+
* page with a fixed header, scrolling slides ordinary content underneath it:
|
|
48
|
+
* a footer link can end up geometrically 3px from a header button while
|
|
49
|
+
* being completely unclickable there, and counting it as a crowder invents a
|
|
50
|
+
* failure out of the scroll position. That was reported on pour's own site.
|
|
51
|
+
*
|
|
52
|
+
* Only asked about targets that are ALREADY crowding an undersized one, so
|
|
53
|
+
* the hit-test cost is paid on the handful of candidate failures rather than
|
|
54
|
+
* on every target of the page.
|
|
55
|
+
*
|
|
56
|
+
* Out-of-viewport points can't be hit-tested at all: elementsFromPoint is
|
|
57
|
+
* viewport-relative and returns nothing outside it. There the honest answer
|
|
58
|
+
* is "don't know", and don't-know must not silence a finding, so it counts
|
|
59
|
+
* as reachable.
|
|
60
|
+
*/
|
|
61
|
+
function reachableRects(element, rect) {
|
|
62
|
+
const doc = element.ownerDocument;
|
|
63
|
+
const win = doc.defaultView;
|
|
64
|
+
if (!win || typeof doc.elementsFromPoint !== 'function') return [rect];
|
|
65
|
+
// Per LINE FRAGMENT, never the bounding box. An inline link that wraps has
|
|
66
|
+
// one fragment per line, and the box enclosing them is mostly space the
|
|
67
|
+
// element never paints: its middle falls in the leading BETWEEN the lines,
|
|
68
|
+
// where elementsFromPoint does not contain the element at all. A
|
|
69
|
+
// box-centre probe therefore reads "not hit-testable" for every wrapped
|
|
70
|
+
// link. (Same geometry trap as link-in-text-block's own-line test.)
|
|
71
|
+
//
|
|
72
|
+
// Fragments also make the answer usefully partial. A footer link scrolled
|
|
73
|
+
// half under a fixed header has its first line buried and its second line
|
|
74
|
+
// perfectly clickable, so the honest result is not "reachable" or
|
|
75
|
+
// "covered" but WHICH PART is reachable — and only that part can compete
|
|
76
|
+
// for pointer space with anything nearby.
|
|
77
|
+
const fragments = [...element.getClientRects()].filter((r) => r.width > 0 && r.height > 0);
|
|
78
|
+
const probes = fragments.length ? fragments : [rect];
|
|
79
|
+
const reachable = [];
|
|
80
|
+
let testable = false;
|
|
81
|
+
for (const fragment of probes) {
|
|
82
|
+
const x = fragment.left + fragment.width / 2;
|
|
83
|
+
const y = fragment.top + fragment.height / 2;
|
|
84
|
+
if (x < 0 || y < 0 || x >= win.innerWidth || y >= win.innerHeight) continue;
|
|
85
|
+
const stack = doc.elementsFromPoint(x, y);
|
|
86
|
+
const index = stack.indexOf(element);
|
|
87
|
+
if (index < 0) continue; // this fragment isn't hit-testable; try the next
|
|
88
|
+
testable = true;
|
|
89
|
+
// Anything above it that is part of the same control is not an
|
|
90
|
+
// obstruction: a button's own icon paints over the button, an overlay
|
|
91
|
+
// label over its input. Only a genuinely separate element blocks it.
|
|
92
|
+
if (stack.slice(0, index).every((layer) => layer.contains(element) || element.contains(layer))) {
|
|
93
|
+
reachable.push(fragment);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// Nothing could be probed at all (entirely out of viewport,
|
|
97
|
+
// pointer-events: none, nothing laid out): don't know, and don't-know must
|
|
98
|
+
// not silence a finding, so the whole rect stands.
|
|
99
|
+
if (!testable) return [rect];
|
|
100
|
+
return reachable;
|
|
101
|
+
}
|
|
102
|
+
|
|
41
103
|
/** Text this element contributes that is NOT inside a target — i.e. does it
|
|
42
104
|
* read as prose beside the link, or is it just another control in a list? */
|
|
43
105
|
function textOutsideTargets(element) {
|
|
@@ -146,14 +208,37 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
|
|
|
146
208
|
// Spacing exception: the min-sized circle around this target must
|
|
147
209
|
// clear every other target's rect and every other undersized
|
|
148
210
|
// target's circle.
|
|
149
|
-
const
|
|
211
|
+
const crowds = (other, j) => {
|
|
150
212
|
if (j === i || !laidOut[j]) return false;
|
|
151
213
|
if (other.contains(element) || element.contains(other)) return false; // same control, nested markup
|
|
152
214
|
if (encloses(rects[j], rects[i]) || encloses(rects[i], rects[j])) return false;
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
215
|
+
const within = (box) => {
|
|
216
|
+
if (undersized[j]) {
|
|
217
|
+
const c = { x: box.left + box.width / 2, y: box.top + box.height / 2 };
|
|
218
|
+
if (Math.hypot(c.x - centers[i].x, c.y - centers[i].y) < min) return true;
|
|
219
|
+
}
|
|
220
|
+
return pointToRectDistance(centers[i], box) < min / 2;
|
|
221
|
+
};
|
|
222
|
+
// Whole-rect test first, as a cheap superset: no part of a target
|
|
223
|
+
// can be closer than the box enclosing it, so a box that doesn't
|
|
224
|
+
// reach rules the target out without any hit-testing. Only the
|
|
225
|
+
// survivors pay for the paint-order queries, which on a page of
|
|
226
|
+
// hundreds of targets is a handful.
|
|
227
|
+
if (!within(rects[j])) return false;
|
|
228
|
+
// Then the honest test: only the parts a pointer can actually
|
|
229
|
+
// reach compete for pointer space. A link whose first line is
|
|
230
|
+
// buried under a fixed header and whose second line is clear is
|
|
231
|
+
// crowding from its second line or not at all.
|
|
232
|
+
return reachableRects(other, rects[j]).some(within);
|
|
233
|
+
};
|
|
234
|
+
const crowded = elements.some(crowds);
|
|
156
235
|
if (!crowded) return { status: 'pass' };
|
|
236
|
+
// Deliberately NOT applied to the target itself. A crowder that
|
|
237
|
+
// can't be clicked breaks the premise of the exception, because it
|
|
238
|
+
// isn't competing for anything. A target that happens to be under a
|
|
239
|
+
// cookie banner right now is still 20×20 the moment that banner is
|
|
240
|
+
// dismissed, and suppressing it would hide a real defect behind a
|
|
241
|
+
// transient overlay, which is the wrong way round.
|
|
157
242
|
}
|
|
158
243
|
const rect = rects[i];
|
|
159
244
|
return {
|
|
@@ -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