pour-engine 1.1.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/LICENSE +21 -0
- package/README.md +99 -0
- package/config/project.config.js +11 -0
- package/engine/COVERAGE.md +115 -0
- package/engine/README.md +115 -0
- package/engine/index.js +258 -0
- package/engine/lib/accessible-name.js +126 -0
- package/engine/lib/contrast.js +628 -0
- package/engine/lib/dom.js +116 -0
- package/engine/lib/roles.js +125 -0
- package/engine/rules/best-practice/accesskeys.js +29 -0
- package/engine/rules/best-practice/aria-label-misuse.js +22 -0
- package/engine/rules/best-practice/button-type.js +15 -0
- package/engine/rules/best-practice/empty-heading.js +19 -0
- package/engine/rules/best-practice/fieldset-legend.js +35 -0
- package/engine/rules/best-practice/heading-order.js +30 -0
- package/engine/rules/best-practice/landmark-one-main.js +22 -0
- package/engine/rules/best-practice/landmark-top-level.js +46 -0
- package/engine/rules/best-practice/landmark-unique.js +84 -0
- package/engine/rules/best-practice/multiple-labels.js +23 -0
- package/engine/rules/best-practice/new-window-link.js +21 -0
- package/engine/rules/best-practice/no-autofocus.js +16 -0
- package/engine/rules/best-practice/page-heading-one.js +17 -0
- package/engine/rules/best-practice/positive-tabindex.js +17 -0
- package/engine/rules/best-practice/redundant-alt-phrase.js +22 -0
- package/engine/rules/best-practice/redundant-aria-label.js +18 -0
- package/engine/rules/best-practice/redundant-aria.js +32 -0
- package/engine/rules/best-practice/redundant-image-alt.js +20 -0
- package/engine/rules/best-practice/redundant-role.js +62 -0
- package/engine/rules/best-practice/redundant-tabindex.js +15 -0
- package/engine/rules/best-practice/region.js +73 -0
- package/engine/rules/index.js +179 -0
- package/engine/rules/wcag/1.1.1-area-alt.js +18 -0
- package/engine/rules/wcag/1.1.1-image-alt.js +48 -0
- package/engine/rules/wcag/1.1.1-input-image-alt.js +17 -0
- package/engine/rules/wcag/1.1.1-object-alt.js +17 -0
- package/engine/rules/wcag/1.1.1-svg-img-alt.js +34 -0
- package/engine/rules/wcag/1.2.2-media-captions.js +19 -0
- package/engine/rules/wcag/1.3.1-aria-required-children.js +66 -0
- package/engine/rules/wcag/1.3.1-aria-required-parent.js +49 -0
- package/engine/rules/wcag/1.3.1-definition-list.js +21 -0
- package/engine/rules/wcag/1.3.1-dlitem-parent.js +17 -0
- package/engine/rules/wcag/1.3.1-form-label.js +81 -0
- package/engine/rules/wcag/1.3.1-list-structure.js +59 -0
- package/engine/rules/wcag/1.3.1-listitem-parent.js +33 -0
- package/engine/rules/wcag/1.3.1-p-as-heading.js +25 -0
- package/engine/rules/wcag/1.3.1-table-headers.js +61 -0
- package/engine/rules/wcag/1.3.5-autocomplete-valid.js +38 -0
- package/engine/rules/wcag/1.4.1-link-in-text-block.js +74 -0
- package/engine/rules/wcag/1.4.10-reflow.js +25 -0
- package/engine/rules/wcag/1.4.11-non-text-contrast.js +109 -0
- package/engine/rules/wcag/1.4.12-text-spacing.js +71 -0
- package/engine/rules/wcag/1.4.2-audio-control.js +40 -0
- package/engine/rules/wcag/1.4.3-color-contrast.js +462 -0
- package/engine/rules/wcag/1.4.3-control-contrast.js +72 -0
- package/engine/rules/wcag/1.4.4-meta-viewport.js +22 -0
- package/engine/rules/wcag/1.4.6-contrast-enhanced.js +11 -0
- package/engine/rules/wcag/2.1.1-scrollable-region-focusable.js +48 -0
- package/engine/rules/wcag/2.2.1-meta-refresh.js +20 -0
- package/engine/rules/wcag/2.2.2-pause-stop-hide.js +25 -0
- package/engine/rules/wcag/2.4.1-bypass-blocks.js +36 -0
- package/engine/rules/wcag/2.4.11-focus-not-obscured.js +80 -0
- package/engine/rules/wcag/2.4.2-document-title.js +18 -0
- package/engine/rules/wcag/2.4.4-link-name.js +19 -0
- package/engine/rules/wcag/2.4.7-focus-visible.js +46 -0
- package/engine/rules/wcag/2.5.3-label-in-name.js +88 -0
- package/engine/rules/wcag/2.5.5-target-size-enhanced.js +14 -0
- package/engine/rules/wcag/2.5.8-target-size.js +159 -0
- package/engine/rules/wcag/3.1.1-html-lang.js +31 -0
- package/engine/rules/wcag/3.1.2-valid-lang-parts.js +20 -0
- package/engine/rules/wcag/3.3.8-auth-field-obstruction.js +37 -0
- package/engine/rules/wcag/4.1.2-aria-allowed-attr.js +44 -0
- package/engine/rules/wcag/4.1.2-aria-attr-valid.js +50 -0
- package/engine/rules/wcag/4.1.2-aria-field-name.js +54 -0
- package/engine/rules/wcag/4.1.2-aria-hidden-focus.js +48 -0
- package/engine/rules/wcag/4.1.2-aria-valid-refs.js +61 -0
- package/engine/rules/wcag/4.1.2-button-name.js +17 -0
- package/engine/rules/wcag/4.1.2-dialog-name.js +35 -0
- package/engine/rules/wcag/4.1.2-frame-title.js +17 -0
- package/engine/rules/wcag/4.1.2-label-for-valid.js +25 -0
- package/engine/rules/wcag/4.1.2-nested-interactive.js +30 -0
- package/engine/rules/wcag/4.1.2-role-required-aria.js +36 -0
- package/engine/rules/wcag/4.1.2-valid-role.js +52 -0
- package/engine/wcag22.js +116 -0
- package/engine/wcag30-draft.js +149 -0
- package/package.json +34 -0
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
// WCAG SC 1.4.3 Contrast (Minimum) (Level AA)
|
|
2
|
+
import {
|
|
3
|
+
parseColor, contrastRatio, composite, effectiveBackground, backgroundObscured,
|
|
4
|
+
backgroundImageSource, backgroundImagePaintRect, imageLuminanceRange, gradientLuminanceRange,
|
|
5
|
+
rangeVerdict, isLargeText, cumulativeOpacity, opacityAnimating, restingOpacity, mediaRects,
|
|
6
|
+
paintedBackdrop, opaquePanelRects, viewportVeil, textShadowHalo, textShadowNegligible,
|
|
7
|
+
} from '../../lib/contrast.js';
|
|
8
|
+
|
|
9
|
+
/** Display a ratio truncated (never rounded up): 4.495 must read "4.49",
|
|
10
|
+
* because "4.50:1 — below the 4.5:1 minimum" reads as a contradiction. */
|
|
11
|
+
const showRatio = (ratio) => (Math.floor(ratio * 100) / 100).toFixed(2);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Is a duplicate copy of the same text painted exactly on top of this
|
|
15
|
+
* element? The gradient-text pattern ships two copies — a plain-colour
|
|
16
|
+
* fallback underneath and a background-clip:text copy above it — and only
|
|
17
|
+
* the top copy is ever seen; the covered one must not be judged.
|
|
18
|
+
*/
|
|
19
|
+
function coveredByTextTwin(element) {
|
|
20
|
+
const doc = element.ownerDocument;
|
|
21
|
+
if (typeof doc.elementsFromPoint !== 'function') return false;
|
|
22
|
+
const text = element.textContent.trim();
|
|
23
|
+
if (!text) return false;
|
|
24
|
+
const rect = element.getBoundingClientRect();
|
|
25
|
+
const win = doc.defaultView;
|
|
26
|
+
const x = rect.left + rect.width / 2;
|
|
27
|
+
const y = rect.top + rect.height / 2;
|
|
28
|
+
if (x < 0 || y < 0 || x >= win.innerWidth || y >= win.innerHeight) return false;
|
|
29
|
+
const stack = doc.elementsFromPoint(x, y);
|
|
30
|
+
const index = stack.indexOf(element);
|
|
31
|
+
if (index <= 0) return false;
|
|
32
|
+
return stack.slice(0, index).some((layer) =>
|
|
33
|
+
!layer.contains(element) && !element.contains(layer) && layer.textContent.trim() === text);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Do any of the element's own text boxes MEANINGFULLY overlap the given
|
|
37
|
+
* rect? Slivers under 3px per axis don't count: wrapped text reports
|
|
38
|
+
* hanging trailing spaces as part of the line box, which "overlaps"
|
|
39
|
+
* right-edge icons without a single glyph being anywhere near them. */
|
|
40
|
+
function textIntersects(element, rect) {
|
|
41
|
+
// Bounding-box prefilter: this now also runs for PASSING text on the
|
|
42
|
+
// unverified-backdrop path, i.e. for most elements on long pages — the
|
|
43
|
+
// Range machinery below must only spin up for genuine near-overlaps.
|
|
44
|
+
const box = element.getBoundingClientRect();
|
|
45
|
+
if (Math.min(box.right, rect.right) - Math.max(box.left, rect.left) < 3
|
|
46
|
+
|| Math.min(box.bottom, rect.bottom) - Math.max(box.top, rect.top) < 3) return false;
|
|
47
|
+
const range = element.ownerDocument.createRange();
|
|
48
|
+
for (const node of element.childNodes) {
|
|
49
|
+
if (node.nodeType !== Node.TEXT_NODE || !node.textContent.trim()) continue;
|
|
50
|
+
range.selectNodeContents(node);
|
|
51
|
+
for (const r of range.getClientRects()) {
|
|
52
|
+
const overlapX = Math.min(r.right, rect.right) - Math.max(r.left, rect.left);
|
|
53
|
+
const overlapY = Math.min(r.bottom, rect.bottom) - Math.max(r.top, rect.top);
|
|
54
|
+
if (overlapX >= 3 && overlapY >= 3) return true;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Where does this background image stand relative to the element's text?
|
|
62
|
+
* 'clear' — provably NOT under any text box (single no-repeat image
|
|
63
|
+
* whose paint rect misses them, e.g. an icon in padding)
|
|
64
|
+
* 'under' — provably overlapping a text box
|
|
65
|
+
* 'unknown' — geometry not statically computable (repeats, cover,
|
|
66
|
+
* stacked layers) — judged conservatively by the caller
|
|
67
|
+
* `intrinsic` rides along so the caller can reason about icon-sized images.
|
|
68
|
+
*/
|
|
69
|
+
async function imageVsText(imageSource, element, doc) {
|
|
70
|
+
const url = imageSource.css.match(/url\(["']?(.*?)["']?\)/)?.[1];
|
|
71
|
+
let intrinsic = null;
|
|
72
|
+
if (url) {
|
|
73
|
+
try { intrinsic = await imageLuminanceRange(new URL(url, doc.baseURI).href); } catch { /* unknowable */ }
|
|
74
|
+
}
|
|
75
|
+
// Browsers report 300×150 for SVGs with no intrinsic dimensions — a
|
|
76
|
+
// sentinel, not a size. Geometry computed from it is fiction, so the
|
|
77
|
+
// paint rect must not use it (icon SVGs would "cover" all the text).
|
|
78
|
+
const dimensionless = !intrinsic || !intrinsic.width
|
|
79
|
+
|| (intrinsic.width === 300 && intrinsic.height === 150);
|
|
80
|
+
const paint = backgroundImagePaintRect(imageSource.element, dimensionless ? null : intrinsic);
|
|
81
|
+
if (!paint) return { relation: 'unknown', intrinsic, dimensionless };
|
|
82
|
+
return { relation: textIntersects(element, paint) ? 'under' : 'clear', intrinsic, dimensionless };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Text that screen readers announce but sighted users never see — 1.4.3 only
|
|
87
|
+
* applies to visually presented text, so these patterns are exempt:
|
|
88
|
+
* - image-replacement: text-indent flings the line box off-canvas
|
|
89
|
+
* (e.g. GOV.UK search buttons use text-indent: -5000px over an icon)
|
|
90
|
+
* - sr-only: a ≤1px box with the text clipped away
|
|
91
|
+
* - font-size: 0, fully transparent text, opacity: 0
|
|
92
|
+
*/
|
|
93
|
+
function textVisuallyHidden(element, style, foreground) {
|
|
94
|
+
if (foreground?.a === 0) return true;
|
|
95
|
+
if (parseFloat(style.fontSize) === 0) return true;
|
|
96
|
+
if (parseFloat(style.opacity) === 0) return true;
|
|
97
|
+
const indent = parseFloat(style.textIndent) || 0;
|
|
98
|
+
const clipped = /hidden|clip/.test(`${style.overflow} ${style.overflowX} ${style.overflowY}`)
|
|
99
|
+
|| style.clipPath !== 'none' || (style.clip !== 'auto' && style.position === 'absolute');
|
|
100
|
+
// Nothing hints at hiding: skip the getBoundingClientRect layout read —
|
|
101
|
+
// this runs for every text element on the page, and most are plainly shown.
|
|
102
|
+
if (!clipped && Math.abs(indent) <= 1000) return false;
|
|
103
|
+
const rect = element.getBoundingClientRect();
|
|
104
|
+
if ((rect.width <= 1 || rect.height <= 1) && clipped) return true;
|
|
105
|
+
if (Math.abs(indent) > rect.width && (clipped || Math.abs(indent) > 1000)) return true;
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Turn a luminance-range bracket into a rule outcome (or null = can't tell).
|
|
110
|
+
function verdictFromRange(range, foreground, required, what) {
|
|
111
|
+
if (!range) return null;
|
|
112
|
+
const { verdict, worst, best } = rangeVerdict(foreground, range, required);
|
|
113
|
+
if (verdict === 'pass') return { status: 'pass' };
|
|
114
|
+
if (verdict === 'fail') {
|
|
115
|
+
return {
|
|
116
|
+
status: 'fail',
|
|
117
|
+
message: `Contrast against the ${what} behind this text is at best ${showRatio(best)}:1 — below the ${required}:1 minimum against every part of it.`,
|
|
118
|
+
fix: 'Change the text colour, or place a solid overlay between the text and the image.',
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
status: 'incomplete',
|
|
123
|
+
message: `The ${what} behind this text has both light and dark areas (contrast ranges ${showRatio(worst)}–${showRatio(best)}:1 for this text) — whether it passes depends on the exact overlap, check by eye.`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Bracket the verdict by sampling what's actually painted: image pixels
|
|
128
|
+
// (same-origin/CORS only) or gradient colour stops.
|
|
129
|
+
async function sampledVerdict(source, foreground, required, doc) {
|
|
130
|
+
if (!source) return null;
|
|
131
|
+
// Translucent text takes its presented colour from the very pixels
|
|
132
|
+
// beneath it — a luminance range of the backdrop cannot bracket the
|
|
133
|
+
// blend (dimmed white over a dark photo reads mid-grey, not white).
|
|
134
|
+
// Asserting either way from sampling would be a coin toss.
|
|
135
|
+
if (foreground.a < 1) {
|
|
136
|
+
return {
|
|
137
|
+
status: 'incomplete',
|
|
138
|
+
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.',
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
if (source.tagName === 'IMG') {
|
|
142
|
+
return verdictFromRange(await imageLuminanceRange(source.currentSrc || source.src), foreground, required, 'image');
|
|
143
|
+
}
|
|
144
|
+
const css = source.css ?? getComputedStyle(source).backgroundImage;
|
|
145
|
+
if (!css || css === 'none') return null;
|
|
146
|
+
if ((css.match(/url\(/g) ?? []).length + (css.match(/gradient\(/g) ?? []).length > 1) return null; // stacked layers: too complex
|
|
147
|
+
const url = css.match(/url\(["']?(.*?)["']?\)/)?.[1];
|
|
148
|
+
if (url) {
|
|
149
|
+
let absolute;
|
|
150
|
+
try { absolute = new URL(url, doc.baseURI).href; } catch { return null; }
|
|
151
|
+
return verdictFromRange(await imageLuminanceRange(absolute), foreground, required, 'image');
|
|
152
|
+
}
|
|
153
|
+
if (css.includes('gradient(')) {
|
|
154
|
+
return verdictFromRange(gradientLuminanceRange(css), foreground, required, 'gradient');
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Both contrast criteria run the same machinery with different thresholds:
|
|
161
|
+
* 1.4.3 Contrast (Minimum), AA — 4.5:1, or 3:1 for large text
|
|
162
|
+
* 1.4.6 Contrast (Enhanced), AAA — 7:1, or 4.5:1 for large text
|
|
163
|
+
*/
|
|
164
|
+
export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
165
|
+
return {
|
|
166
|
+
id,
|
|
167
|
+
impact: 'serious',
|
|
168
|
+
tags,
|
|
169
|
+
help,
|
|
170
|
+
helpUrl,
|
|
171
|
+
// div included deliberately: real-world layouts put lots of text in bare
|
|
172
|
+
// divs; the own-text check below keeps wrapper divs out of the results.
|
|
173
|
+
selector:
|
|
174
|
+
'p, span, a, li, td, th, dt, dd, h1, h2, h3, h4, h5, h6, label, button, legend, caption, ' +
|
|
175
|
+
'figcaption, div, blockquote, small, strong, em, b, i, code, pre, time, cite, q, summary',
|
|
176
|
+
visibility: 'visual', // contrast is seen by sighted users even in aria-hidden content
|
|
177
|
+
async evaluate(element, { ownText }) {
|
|
178
|
+
if (!ownText(element)) return { status: 'pass' }; // no text of its own to judge
|
|
179
|
+
|
|
180
|
+
// The criterion exempts "inactive user interface components".
|
|
181
|
+
if (element.closest(':disabled, [aria-disabled="true"]')) return { status: 'pass' };
|
|
182
|
+
|
|
183
|
+
const style = getComputedStyle(element);
|
|
184
|
+
// Controls conveying "disabled" without the attribute: pointer-events:
|
|
185
|
+
// none on an interactive element is functional inertness — the low
|
|
186
|
+
// contrast IS the disabled styling (carousel arrows at the end of
|
|
187
|
+
// their range, steppers at their limit). But only for COMPACT widgets:
|
|
188
|
+
// a card-sized inert link full of prose (an inactive carousel slide) is
|
|
189
|
+
// content someone is expected to read, and whether "temporarily
|
|
190
|
+
// non-interactive" makes it an exempt inactive component under 1.4.3 is
|
|
191
|
+
// a human judgement — never a silent pass.
|
|
192
|
+
const control = element.closest('button, a[href], input, select, [role="button"], [role="link"], [role="option"], [role="tab"]');
|
|
193
|
+
if (control && getComputedStyle(control).pointerEvents === 'none') {
|
|
194
|
+
if ((control.textContent || '').trim().length <= 80) return { status: 'pass' };
|
|
195
|
+
return {
|
|
196
|
+
status: 'incomplete',
|
|
197
|
+
message: 'This text sits inside a non-interactive (pointer-events: none) control with substantial content — a disabled control\'s dimmed text is exempt from contrast, but an inactive content slide is not. Judge which this is by eye.',
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// The colour that actually paints the glyphs. -webkit-text-fill-color
|
|
202
|
+
// overrides color when set; background-clip: text paints the text with
|
|
203
|
+
// the background layers instead — judged by their colours, not color.
|
|
204
|
+
const fill = parseColor(style.webkitTextFillColor);
|
|
205
|
+
const clipsText = /\btext\b/.test(style.webkitBackgroundClip ?? '') || /\btext\b/.test(style.backgroundClip ?? '');
|
|
206
|
+
if (clipsText) {
|
|
207
|
+
const behind = effectiveBackground(element.parentElement ?? element);
|
|
208
|
+
const fgRange = style.backgroundImage !== 'none' ? gradientLuminanceRange(style.backgroundImage) : null;
|
|
209
|
+
if (fgRange && behind) {
|
|
210
|
+
// Symmetric bracket: text luminance spans the gradient's stops.
|
|
211
|
+
const required = isLargeText(style) ? thresholds.large : thresholds.normal;
|
|
212
|
+
return verdictFromRange(fgRange, behind, required, 'gradient painting this text') ?? {
|
|
213
|
+
status: 'incomplete',
|
|
214
|
+
message: 'This text is painted with its background layer (background-clip: text) — contrast must be checked by eye.',
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
status: 'incomplete',
|
|
219
|
+
message: 'This text is painted with its background layer (background-clip: text) — contrast must be checked by eye.',
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
let foreground = fill && style.webkitTextFillColor !== style.color ? fill : parseColor(style.color);
|
|
223
|
+
// Visually hidden text (sr-only, image replacement) has no visual
|
|
224
|
+
// presentation for contrast to apply to — screen readers still read it.
|
|
225
|
+
if (textVisuallyHidden(element, style, foreground)) return { status: 'pass' };
|
|
226
|
+
if (!foreground) {
|
|
227
|
+
return { status: 'incomplete', message: 'The text colour could not be parsed — check contrast by eye.' };
|
|
228
|
+
}
|
|
229
|
+
const required = isLargeText(style) ? thresholds.large : thresholds.normal;
|
|
230
|
+
const doc = element.ownerDocument;
|
|
231
|
+
|
|
232
|
+
// Judge the opacity the text RESTS at, not a transient frame. Elements
|
|
233
|
+
// with a RUNNING opacity animation anywhere up the flat tree are judged
|
|
234
|
+
// at the animation's landing value (a fill-forwards entrance ending at
|
|
235
|
+
// full is judged at full; one parking at 30% is judged dim — inactive
|
|
236
|
+
// carousel slides). Unknowable landings (infinite loops, no-fill) are
|
|
237
|
+
// judged at full — never failing text on a frame that won't persist.
|
|
238
|
+
// Near-zero resting opacity means the text isn't visually presented.
|
|
239
|
+
const opacity = opacityAnimating(element) ? restingOpacity(element) : cumulativeOpacity(element);
|
|
240
|
+
if (opacity < 0.05) return { status: 'pass' };
|
|
241
|
+
if (opacity < 1) foreground = { ...foreground, a: foreground.a * opacity };
|
|
242
|
+
|
|
243
|
+
// A background image on an ancestor: sample its pixels and bracket —
|
|
244
|
+
// but only when it's actually painted under this text. Icon-in-padding
|
|
245
|
+
// images (external-link arrows etc.) don't affect the text's contrast.
|
|
246
|
+
const imageSource = backgroundImageSource(element);
|
|
247
|
+
if (imageSource) {
|
|
248
|
+
const { relation, intrinsic, dimensionless } = await imageVsText(imageSource, element, doc);
|
|
249
|
+
if (relation !== 'clear') {
|
|
250
|
+
const sampled = await sampledVerdict(imageSource, foreground, required, doc);
|
|
251
|
+
// Icon-scale images: bullets, arrows, and decorations painted
|
|
252
|
+
// beside or beneath a corner of the text — their pixels may flag
|
|
253
|
+
// for eyes, never assert a failure. Icon-scale means: no reliable
|
|
254
|
+
// intrinsic size, an intrinsically tiny image, OR a background
|
|
255
|
+
// carrier whose own box is line-height-sized (contain/cover paint
|
|
256
|
+
// can never exceed the carrier — a 512px nominal SVG painted on an
|
|
257
|
+
// inline link is still an icon). Only backdrop-scale images earn a
|
|
258
|
+
// definite fail from sampling.
|
|
259
|
+
const carrierBox = imageSource.element.getBoundingClientRect();
|
|
260
|
+
if (sampled?.status === 'fail'
|
|
261
|
+
&& (dimensionless || (intrinsic.width <= 32 && intrinsic.height <= 32) || carrierBox.height <= 40)) {
|
|
262
|
+
return {
|
|
263
|
+
status: 'incomplete',
|
|
264
|
+
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.',
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
return sampled ?? {
|
|
268
|
+
status: 'incomplete',
|
|
269
|
+
message: 'The background here is an image that can’t be read (cross-origin) — contrast must be checked by eye.',
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
let background = effectiveBackground(element);
|
|
275
|
+
|
|
276
|
+
// Browser-truth paint order at the text's own sample point: sees and
|
|
277
|
+
// RESOLVES backdrops painted by non-ancestors (positioned siblings,
|
|
278
|
+
// hero panels, overlay scrims) that the ancestor walk cannot — instead
|
|
279
|
+
// of merely flagging them for a human.
|
|
280
|
+
const painted = paintedBackdrop(element);
|
|
281
|
+
// A translucent viewport-scale veil is painted ON TOP of this text
|
|
282
|
+
// (modal scrim, loading overlay, consent dimmer): the user sees the
|
|
283
|
+
// text through it, so the resting colours are not the presented ones —
|
|
284
|
+
// and if the veil is transient, the resting state is what should be
|
|
285
|
+
// judged. Either way this is a human call, in both verdict directions.
|
|
286
|
+
if (painted?.scrim) {
|
|
287
|
+
return {
|
|
288
|
+
status: 'incomplete',
|
|
289
|
+
message: 'This text sits behind a translucent full-page overlay (a modal or loading veil), which changes its presented contrast — judge the page with the overlay dismissed, or the dimmed state by eye if the overlay is permanent.',
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
// Offscreen text can't be hit-tested against the veil — but a FIXED
|
|
293
|
+
// translucent overlay follows the viewport, so it dims scrolled-away
|
|
294
|
+
// content just the same. (The veil's own contents are fixed too, hence
|
|
295
|
+
// in-viewport, hence never routed through this branch.)
|
|
296
|
+
if (painted === 'offscreen') {
|
|
297
|
+
const veil = viewportVeil(doc);
|
|
298
|
+
if (veil && !veil.contains(element)) {
|
|
299
|
+
return {
|
|
300
|
+
status: 'incomplete',
|
|
301
|
+
message: 'This text sits behind a translucent full-page overlay (a modal or loading veil), which changes its presented contrast — judge the page with the overlay dismissed, or the dimmed state by eye if the overlay is permanent.',
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (painted?.image) {
|
|
306
|
+
const sampled = await sampledVerdict(painted.image, foreground, required, doc);
|
|
307
|
+
return sampled ?? {
|
|
308
|
+
status: 'incomplete',
|
|
309
|
+
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.',
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
// Only a hit-test-resolved backdrop is VERIFIED paint truth. Anything
|
|
313
|
+
// else (offscreen sample point, element invisible to its own hit-test —
|
|
314
|
+
// stretched-link overlays do this) leaves the walked-up colour a guess
|
|
315
|
+
// that overlapping media or panels may contradict, in either direction.
|
|
316
|
+
let backgroundVerified = false;
|
|
317
|
+
if (painted?.color) {
|
|
318
|
+
background = painted.color;
|
|
319
|
+
backgroundVerified = true;
|
|
320
|
+
} else if (painted === 'unresolved') {
|
|
321
|
+
// The hit-test couldn't see the element (shadow retargeting, hidden
|
|
322
|
+
// mid-audit…): keep the previous overlay detection so the known
|
|
323
|
+
// hazards still reach a human instead of a wrong assertion.
|
|
324
|
+
const obscuring = backgroundObscured(element);
|
|
325
|
+
if (obscuring && obscuring !== 'unverifiable') {
|
|
326
|
+
const sampled = await sampledVerdict(obscuring, foreground, required, doc);
|
|
327
|
+
return sampled ?? {
|
|
328
|
+
status: 'incomplete',
|
|
329
|
+
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.',
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (!background) {
|
|
334
|
+
return { status: 'incomplete', message: 'The background could not be determined — check contrast by eye.' };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Semi-transparent text is really text-colour blended into the background.
|
|
338
|
+
if (foreground.a < 1) foreground = composite(foreground, background);
|
|
339
|
+
|
|
340
|
+
// The scroll-independent hazard bracket for UNVERIFIED backgrounds:
|
|
341
|
+
// could something painted where this text is make the walked-up colour
|
|
342
|
+
// the wrong one? Media (photos, video) makes the real backdrop
|
|
343
|
+
// unknowable in both directions — a dark card colour under a pale photo
|
|
344
|
+
// must not assert a pass any more than a fail. Opaque non-ancestor
|
|
345
|
+
// panels only demote the verdict when one would actually flip it.
|
|
346
|
+
// `blindOnly`: a hit-test-VERIFIED backdrop is still wrong when
|
|
347
|
+
// pointer-events:none media or panels paint where the text is — those
|
|
348
|
+
// layers never appear in elementsFromPoint stacks (Stripe's animated
|
|
349
|
+
// gradient canvas under its hero heading is exactly this shape), so
|
|
350
|
+
// even verified verdicts must be bracketed against them.
|
|
351
|
+
const backdropHazard = (direction, blindOnly) => {
|
|
352
|
+
// One box read for the element, then pure rect math per candidate —
|
|
353
|
+
// this runs for every unverified element on the page.
|
|
354
|
+
const box = element.getBoundingClientRect();
|
|
355
|
+
const near = (r) => Math.min(box.right, r.right) - Math.max(box.left, r.left) >= 3
|
|
356
|
+
&& Math.min(box.bottom, r.bottom) - Math.max(box.top, r.top) >= 3;
|
|
357
|
+
const overMedia = mediaRects(doc).some(({ element: media, rect: mediaRect, hitTestBlind }) =>
|
|
358
|
+
(!blindOnly || hitTestBlind) && near(mediaRect)
|
|
359
|
+
&& !media.contains(element) && !element.contains(media)
|
|
360
|
+
&& textIntersects(element, mediaRect));
|
|
361
|
+
if (overMedia) {
|
|
362
|
+
return {
|
|
363
|
+
status: 'incomplete',
|
|
364
|
+
message: 'This text overlaps an image or video, so its real background can’t be computed — contrast must be checked by eye.',
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
const flipping = opaquePanelRects(doc).some(({ element: panel, rect, color, hitTestBlind }) =>
|
|
368
|
+
(!blindOnly || hitTestBlind) && near(rect)
|
|
369
|
+
&& !panel.contains(element) && !element.contains(panel)
|
|
370
|
+
&& textIntersects(element, rect)
|
|
371
|
+
&& (contrastRatio(foreground, color) >= required) !== (direction === 'pass'));
|
|
372
|
+
if (flipping) {
|
|
373
|
+
return {
|
|
374
|
+
status: 'incomplete',
|
|
375
|
+
message: 'This text overlaps a coloured block that isn’t its DOM ancestor, so its real background is ambiguous — check contrast by eye.',
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
return null;
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
const ratio = contrastRatio(foreground, background);
|
|
382
|
+
if (ratio >= required) {
|
|
383
|
+
return backdropHazard('pass', backgroundVerified) ?? { status: 'pass' };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// About to assert a fail — make sure the resolved colour is what the
|
|
387
|
+
// user actually sees. Cheap layout reads run only on this path.
|
|
388
|
+
const rect = element.getBoundingClientRect();
|
|
389
|
+
// Zero-area text paints nothing (collapsed badges, empty counters).
|
|
390
|
+
if (!rect.width || !rect.height) return { status: 'pass' };
|
|
391
|
+
// A text-shadow can supply the contrast (technique G18 measures the
|
|
392
|
+
// letters against their halo): white text with a solid dark outline
|
|
393
|
+
// passes however the fill compares to the backdrop. A parseable solid
|
|
394
|
+
// halo gets a verdict; only genuinely ambiguous shadows go to a human,
|
|
395
|
+
// and faint diffuse glows stop blocking the verdict entirely.
|
|
396
|
+
if (style.textShadow && style.textShadow !== 'none') {
|
|
397
|
+
const fontSize = parseFloat(style.fontSize) || 16;
|
|
398
|
+
const halo = textShadowHalo(style.textShadow, fontSize);
|
|
399
|
+
if (halo) {
|
|
400
|
+
const haloRatio = contrastRatio(foreground, halo);
|
|
401
|
+
if (haloRatio >= required) return { status: 'pass' };
|
|
402
|
+
return {
|
|
403
|
+
status: 'fail',
|
|
404
|
+
message: `Contrast is ${showRatio(haloRatio)}:1 against this text's own outline and ${showRatio(ratio)}:1 against the background — below the ${required}:1 WCAG minimum for this text size.`,
|
|
405
|
+
fix: `Increase the difference between the text colour and its text-shadow outline (or the background) until the ratio reaches ${required}:1.`,
|
|
406
|
+
data: {
|
|
407
|
+
foreground: style.color,
|
|
408
|
+
background: `rgb(${Math.round(halo.r)}, ${Math.round(halo.g)}, ${Math.round(halo.b)})`,
|
|
409
|
+
ratio: Number(showRatio(haloRatio)),
|
|
410
|
+
required,
|
|
411
|
+
},
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
if (!textShadowNegligible(style.textShadow, fontSize)) {
|
|
415
|
+
return {
|
|
416
|
+
status: 'incomplete',
|
|
417
|
+
message: `Contrast against the background is ${showRatio(ratio)}:1, but this text has a text-shadow — if the shadow forms a solid halo, contrast may be measured against it instead. Check by eye.`,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
// Negligible glow: judged like plain text on its background.
|
|
421
|
+
}
|
|
422
|
+
// Text in EXACTLY its background colour is an author hiding technique
|
|
423
|
+
// (icon links with colour-matched labels) at least as often as it is a
|
|
424
|
+
// defect — either way nothing is "low contrast": it's invisible.
|
|
425
|
+
if (Math.round(foreground.r) === Math.round(background.r)
|
|
426
|
+
&& Math.round(foreground.g) === Math.round(background.g)
|
|
427
|
+
&& Math.round(foreground.b) === Math.round(background.b)) {
|
|
428
|
+
return {
|
|
429
|
+
status: 'incomplete',
|
|
430
|
+
message: 'This text is exactly the same colour as its background — invisible. If that\'s a hiding technique for screen-reader-only labels, use the clip pattern instead; if the text is meant to be seen, this is a serious defect.',
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
// A duplicate of the same text painted on top (gradient-text fallback
|
|
434
|
+
// copies): the user reads the top copy, which is judged on its own.
|
|
435
|
+
if (coveredByTextTwin(element)) return { status: 'pass' };
|
|
436
|
+
{
|
|
437
|
+
const hazard = backdropHazard('fail', backgroundVerified);
|
|
438
|
+
if (hazard) return hazard;
|
|
439
|
+
}
|
|
440
|
+
return {
|
|
441
|
+
status: 'fail',
|
|
442
|
+
message: `Contrast is ${showRatio(ratio)}:1 — below the ${required}:1 WCAG minimum for this text size.`,
|
|
443
|
+
fix: `Darken the text or lighten the background until the ratio reaches ${required}:1 (currently ${style.color} on this background).`,
|
|
444
|
+
// The exact pair, for the UIs to link out to an interactive checker.
|
|
445
|
+
data: {
|
|
446
|
+
foreground: style.color,
|
|
447
|
+
background: `rgb(${Math.round(background.r)}, ${Math.round(background.g)}, ${Math.round(background.b)})`,
|
|
448
|
+
ratio: Number(showRatio(ratio)),
|
|
449
|
+
required,
|
|
450
|
+
},
|
|
451
|
+
};
|
|
452
|
+
},
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export default createContrastRule({
|
|
457
|
+
id: 'color-contrast',
|
|
458
|
+
tags: ['wcag2aa', 'wcag143'],
|
|
459
|
+
help: 'Text must have sufficient contrast against its background',
|
|
460
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html',
|
|
461
|
+
thresholds: { normal: 4.5, large: 3 },
|
|
462
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// WCAG SC 1.4.3 Contrast (Minimum) (Level AA) — form-control text.
|
|
2
|
+
// The main contrast rule judges flowed text; the text INSIDE controls
|
|
3
|
+
// (typed values, select options, and above all placeholder text — the
|
|
4
|
+
// endemic ~2.8:1 gray-on-white) was previously never measured by any rule.
|
|
5
|
+
import {
|
|
6
|
+
parseColor, contrastRatio, composite, effectiveBackground, isLargeText,
|
|
7
|
+
} from '../../lib/contrast.js';
|
|
8
|
+
|
|
9
|
+
const showRatio = (ratio) => (Math.floor(ratio * 100) / 100).toFixed(2);
|
|
10
|
+
|
|
11
|
+
export default {
|
|
12
|
+
id: 'control-contrast',
|
|
13
|
+
impact: 'serious',
|
|
14
|
+
tags: ['wcag2aa', 'wcag143'],
|
|
15
|
+
help: 'Text inside form controls must have sufficient contrast',
|
|
16
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html',
|
|
17
|
+
selector:
|
|
18
|
+
'input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]):not([type="range"])'
|
|
19
|
+
+ ':not([type="color"]):not([type="file"]):not([type="image"]), select, textarea',
|
|
20
|
+
visibility: 'visual',
|
|
21
|
+
evaluate(element) {
|
|
22
|
+
// 1.4.3 exempts inactive controls.
|
|
23
|
+
if (element.disabled || element.closest('[aria-disabled="true"]')) return { status: 'pass' };
|
|
24
|
+
// Zero-area controls (JS-widget hidden duplicates) present no text.
|
|
25
|
+
const rect = element.getBoundingClientRect();
|
|
26
|
+
if (rect.width <= 1 || rect.height <= 1) return { status: 'pass' };
|
|
27
|
+
const style = getComputedStyle(element);
|
|
28
|
+
// The control's own background wins; else whatever shows through it.
|
|
29
|
+
const own = parseColor(style.backgroundColor);
|
|
30
|
+
const background = own && own.a >= 1
|
|
31
|
+
? own
|
|
32
|
+
: (() => {
|
|
33
|
+
const behind = effectiveBackground(element);
|
|
34
|
+
return behind && own && own.a > 0 ? composite(own, behind) : behind;
|
|
35
|
+
})();
|
|
36
|
+
if (!background) {
|
|
37
|
+
return { status: 'incomplete', message: 'The control’s background could not be determined — check its text contrast by eye.' };
|
|
38
|
+
}
|
|
39
|
+
const required = isLargeText(style) ? 3 : 4.5;
|
|
40
|
+
|
|
41
|
+
const judge = (color, what) => {
|
|
42
|
+
const parsed = parseColor(color);
|
|
43
|
+
if (!parsed || parsed.a === 0) return null;
|
|
44
|
+
const fg = parsed.a < 1 ? composite(parsed, background) : parsed;
|
|
45
|
+
const ratio = contrastRatio(fg, background);
|
|
46
|
+
if (ratio >= required) return null;
|
|
47
|
+
return { what, ratio };
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const failures = [];
|
|
51
|
+
const valueVerdict = judge(style.color, 'value text');
|
|
52
|
+
if (valueVerdict) failures.push(valueVerdict);
|
|
53
|
+
// Placeholder text is real text users must read; browsers expose its
|
|
54
|
+
// computed colour via the ::placeholder pseudo-element.
|
|
55
|
+
if (element.getAttribute('placeholder')?.trim()) {
|
|
56
|
+
let placeholderColor = null;
|
|
57
|
+
try { placeholderColor = getComputedStyle(element, '::placeholder').color; } catch { /* unsupported */ }
|
|
58
|
+
if (placeholderColor && placeholderColor !== style.color) {
|
|
59
|
+
const verdict = judge(placeholderColor, 'placeholder text');
|
|
60
|
+
if (verdict) failures.push(verdict);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (!failures.length) return { status: 'pass' };
|
|
64
|
+
const worst = failures.sort((a, b) => a.ratio - b.ratio)[0];
|
|
65
|
+
return {
|
|
66
|
+
status: 'fail',
|
|
67
|
+
message: `This field's ${worst.what} has ${showRatio(worst.ratio)}:1 contrast against the field background — below the ${required}:1 minimum.`,
|
|
68
|
+
fix: `Darken the ${worst.what.includes('placeholder') ? 'placeholder colour (::placeholder)' : 'text colour'} until it reaches ${required}:1 against the field background.`,
|
|
69
|
+
data: { ratio: Number(showRatio(worst.ratio)), required },
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// WCAG SC 1.4.4 Resize Text (Level AA)
|
|
2
|
+
export default {
|
|
3
|
+
id: 'meta-viewport',
|
|
4
|
+
impact: 'critical',
|
|
5
|
+
tags: ['wcag2aa', 'wcag144'],
|
|
6
|
+
help: 'The viewport must let users zoom to at least 200%',
|
|
7
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/resize-text.html',
|
|
8
|
+
selector: 'meta[name="viewport"]',
|
|
9
|
+
visibleOnly: false,
|
|
10
|
+
evaluate(element) {
|
|
11
|
+
const content = element.getAttribute('content') ?? '';
|
|
12
|
+
const disablesZoom = /user-scalable\s*=\s*(no|0)/i.test(content);
|
|
13
|
+
const maxScale = content.match(/maximum-scale\s*=\s*([\d.]+)/i);
|
|
14
|
+
const cappedZoom = maxScale && parseFloat(maxScale[1]) < 2;
|
|
15
|
+
if (!disablesZoom && !cappedZoom) return { status: 'pass' };
|
|
16
|
+
return {
|
|
17
|
+
status: 'fail',
|
|
18
|
+
message: 'This viewport meta tag stops low-vision users from zooming the page.',
|
|
19
|
+
fix: 'Remove user-scalable=no and any maximum-scale below 2 from the viewport meta tag.',
|
|
20
|
+
};
|
|
21
|
+
},
|
|
22
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// WCAG SC 1.4.6 Contrast (Enhanced) (Level AAA)
|
|
2
|
+
// Same machinery as 1.4.3, at the AAA thresholds: 7:1, or 4.5:1 for large text.
|
|
3
|
+
import { createContrastRule } from './1.4.3-color-contrast.js';
|
|
4
|
+
|
|
5
|
+
export default createContrastRule({
|
|
6
|
+
id: 'color-contrast-enhanced',
|
|
7
|
+
tags: ['wcag2aaa', 'wcag146'],
|
|
8
|
+
help: 'Text must have enhanced contrast against its background (AAA)',
|
|
9
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/contrast-enhanced.html',
|
|
10
|
+
thresholds: { normal: 7, large: 4.5 },
|
|
11
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// WCAG SC 2.1.1 Keyboard (Level A) · SC 2.1.3 Keyboard (No Exception) (Level AAA)
|
|
2
|
+
const FOCUSABLE = 'a[href], button, input:not([type="hidden"]), select, textarea, summary, [tabindex]';
|
|
3
|
+
|
|
4
|
+
/** Focusable content in the FLAT tree: the node's own descendants, nodes
|
|
5
|
+
* slotted in from the light DOM, and content of open shadow roots. */
|
|
6
|
+
function hasFocusableContent(node) {
|
|
7
|
+
if (node.querySelector?.(FOCUSABLE)) return true;
|
|
8
|
+
for (const slot of node.querySelectorAll?.('slot') ?? []) {
|
|
9
|
+
for (const assigned of slot.assignedElements?.() ?? []) {
|
|
10
|
+
if (assigned.matches?.(FOCUSABLE) || assigned.querySelector?.(FOCUSABLE)) return true;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
for (const el of node.querySelectorAll?.('*') ?? []) {
|
|
14
|
+
if (el.shadowRoot && hasFocusableContent(el.shadowRoot)) return true;
|
|
15
|
+
}
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default {
|
|
20
|
+
id: 'scrollable-region-focusable',
|
|
21
|
+
impact: 'serious',
|
|
22
|
+
tags: ['wcag2a', 'wcag211', 'wcag213'], // 2.1.3 is 2.1.1's AAA twin — same check satisfies both
|
|
23
|
+
help: 'Scrollable regions must be reachable by keyboard',
|
|
24
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html',
|
|
25
|
+
selector: 'div, section, article, main, aside, ul, ol, dl, pre, output, figure',
|
|
26
|
+
evaluate(element) {
|
|
27
|
+
const style = getComputedStyle(element);
|
|
28
|
+
// Judged PER AXIS: an overflow the user can't scroll on its own axis
|
|
29
|
+
// (overflow-x: hidden marquees whose overflow-y merely computes to
|
|
30
|
+
// auto) is not a scrollable region — content moves by animation, not
|
|
31
|
+
// by user scrolling.
|
|
32
|
+
const scrollableX = /(auto|scroll)/.test(style.overflowX)
|
|
33
|
+
&& element.scrollWidth > element.clientWidth + 1;
|
|
34
|
+
const scrollableY = /(auto|scroll)/.test(style.overflowY)
|
|
35
|
+
&& element.scrollHeight > element.clientHeight + 1;
|
|
36
|
+
if (!scrollableX && !scrollableY) return { status: 'pass' };
|
|
37
|
+
if (element.tabIndex >= 0) return { status: 'pass' };
|
|
38
|
+
// Focusable content inside lets keyboard users scroll by moving focus —
|
|
39
|
+
// including content slotted in from the light DOM and content inside
|
|
40
|
+
// open shadow roots (carousels built as web components).
|
|
41
|
+
if (hasFocusableContent(element)) return { status: 'pass' };
|
|
42
|
+
return {
|
|
43
|
+
status: 'fail',
|
|
44
|
+
message: 'This region scrolls, but contains nothing focusable and isn’t focusable itself — keyboard users can never see the overflowed content.',
|
|
45
|
+
fix: 'Add tabindex="0" to the scrollable element (plus role="region" and an aria-label describing it).',
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// WCAG SC 2.2.1 Timing Adjustable (Level A)
|
|
2
|
+
export default {
|
|
3
|
+
id: 'meta-refresh',
|
|
4
|
+
impact: 'critical',
|
|
5
|
+
tags: ['wcag2a', 'wcag221'],
|
|
6
|
+
help: 'The page must not use a timed refresh',
|
|
7
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/timing-adjustable.html',
|
|
8
|
+
selector: 'meta[http-equiv="refresh" i]',
|
|
9
|
+
visibleOnly: false,
|
|
10
|
+
evaluate(element) {
|
|
11
|
+
const delay = parseInt(element.getAttribute('content') ?? '', 10);
|
|
12
|
+
if (!(delay > 0)) return { status: 'pass' }; // 0 = immediate redirect, allowed
|
|
13
|
+
if (delay >= 72000) return { status: 'pass' }; // 2.2.1 exempts limits over 20 hours
|
|
14
|
+
return {
|
|
15
|
+
status: 'fail',
|
|
16
|
+
message: `The page refreshes/redirects after ${delay}s — slow readers lose their place (or the whole page) with no control.`,
|
|
17
|
+
fix: 'Remove the timed refresh; let users act in their own time.',
|
|
18
|
+
};
|
|
19
|
+
},
|
|
20
|
+
};
|