pour-engine 1.37.1 → 1.40.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/lib/accessible-name.js +12 -1
- package/engine/lib/contrast.js +7 -1
- package/engine/lib/roles.js +14 -2
- package/engine/rules/best-practice/landmark-unique.js +9 -1
- package/engine/rules/best-practice/region.js +10 -2
- package/engine/rules/wcag/1.3.1-definition-list.js +42 -3
- package/engine/rules/wcag/1.3.1-form-label.js +7 -0
- package/engine/rules/wcag/1.4.1-link-in-text-block.js +19 -5
- package/engine/rules/wcag/1.4.12-text-spacing.js +12 -6
- package/engine/rules/wcag/1.4.3-color-contrast.js +8 -7
- package/engine/rules/wcag/2.2.1-meta-refresh.js +31 -1
- package/engine/rules/wcag/2.2.2-video-loop-motion.js +26 -1
- package/engine/rules/wcag/2.5.8-target-size.js +40 -2
- package/engine/rules/wcag/4.1.2-nested-interactive.js +20 -8
- package/package.json +1 -1
package/config/project.config.js
CHANGED
|
@@ -115,10 +115,21 @@ function visibleContentText(element, includeHidden) {
|
|
|
115
115
|
|
|
116
116
|
/** CSS generated content contributes to name-from-contents per the accname
|
|
117
117
|
* spec — icon-font prefixes and content:"…" labels are real names. Only
|
|
118
|
-
* plain strings count: counters, url(), attr() are not resolvable here.
|
|
118
|
+
* plain strings count: counters, url(), attr() are not resolvable here.
|
|
119
|
+
*
|
|
120
|
+
* The exception is the CSS alternative text syntax, content: <image or
|
|
121
|
+
* string> / "alt", which exists precisely to give assistive technology the
|
|
122
|
+
* text for generated content and which browsers expose: an icon-only button
|
|
123
|
+
* drawn with content: url(menu.svg) / "Menu" IS named "Menu" (Chromium's
|
|
124
|
+
* accessibility tree agrees), and calling it nameless asserted a critical
|
|
125
|
+
* 4.1.2 failure against the recommended pattern. An EMPTY alternative marks
|
|
126
|
+
* the generated content decorative and contributes nothing, which is how
|
|
127
|
+
* the same syntax silences icon glyphs. */
|
|
119
128
|
function generatedContent(element, pseudo) {
|
|
120
129
|
const content = getComputedStyle(element, pseudo).content;
|
|
121
130
|
if (!content || content === 'none' || content === 'normal') return '';
|
|
131
|
+
const alt = content.match(/\/\s*"((?:[^"\\]|\\.)*)"\s*$/);
|
|
132
|
+
if (alt) return alt[1].replace(/\\(.)/g, '$1');
|
|
122
133
|
const match = content.match(/^"((?:[^"\\]|\\.)*)"$/);
|
|
123
134
|
return match ? match[1].replace(/\\(.)/g, '$1') : '';
|
|
124
135
|
}
|
package/engine/lib/contrast.js
CHANGED
|
@@ -1690,10 +1690,16 @@ export function pseudoTextColors(element, style) {
|
|
|
1690
1690
|
return found;
|
|
1691
1691
|
}
|
|
1692
1692
|
|
|
1693
|
+
/** The font weight from which text counts as bold for the large-scale
|
|
1694
|
+
* test. WCAG's glossary says "bold" and no number; CSS's bold keyword is
|
|
1695
|
+
* 700, and the semibold 600 that most modern faces ship reads as bold at
|
|
1696
|
+
* 14pt on screen, so it counts here too (David, 2026-09-02; was 700). */
|
|
1697
|
+
export const BOLD_WEIGHT = 600;
|
|
1698
|
+
|
|
1693
1699
|
/** WCAG "large text": ≥24px, or ≥18.66px (14pt) bold. */
|
|
1694
1700
|
export function isLargeText(style) {
|
|
1695
1701
|
const size = parseFloat(style.fontSize);
|
|
1696
1702
|
const weight = parseInt(style.fontWeight, 10) || 400;
|
|
1697
1703
|
// 18pt and 14pt at CSS's 96dpi: 24px, and 56/3 = 18.666…px exactly.
|
|
1698
|
-
return size >= 24 || (size >= 56 / 3 && weight >=
|
|
1704
|
+
return size >= 24 || (size >= 56 / 3 && weight >= BOLD_WEIGHT);
|
|
1699
1705
|
}
|
package/engine/lib/roles.js
CHANGED
|
@@ -208,11 +208,23 @@ export function implicitRole(element) {
|
|
|
208
208
|
* but adding tabindex or aria-describedby exposes `heading`. Note a non-global
|
|
209
209
|
* property applicable to the implicit role (aria-level on a heading) does NOT
|
|
210
210
|
* trigger it, even though a strict reading of the spec suggests it should.
|
|
211
|
+
*
|
|
212
|
+
* "Focusable" means focusable in fact, not focusable by tag. A disabled
|
|
213
|
+
* control and anything inside an inert subtree are not focusable areas, so
|
|
214
|
+
* their presentational role STANDS and the browser keeps them out of the
|
|
215
|
+
* accessibility tree: Chromium exposes `<button role="none" disabled>` as
|
|
216
|
+
* role none, with no name to owe. Reading focusability from the tag alone
|
|
217
|
+
* (or from tabIndex, which Chromium still reports as 0 on a disabled
|
|
218
|
+
* button) resurrected those elements and asserted a missing name on a
|
|
219
|
+
* control the user is never offered.
|
|
211
220
|
*/
|
|
212
221
|
function presentationDiscarded(element) {
|
|
222
|
+
// A global property is exposed whatever the element's state, so it
|
|
223
|
+
// discards the role on its own, disabled or not.
|
|
224
|
+
if ([...GLOBAL_ARIA].some((name) => element.hasAttribute(`aria-${name}`))) return true;
|
|
225
|
+
if (element.matches(':disabled') || element.closest('[inert]')) return false;
|
|
213
226
|
if (element.tabIndex >= 0) return true;
|
|
214
|
-
|
|
215
|
-
return [...GLOBAL_ARIA].some((name) => element.hasAttribute(`aria-${name}`));
|
|
227
|
+
return element.matches('a[href], button, input, select, textarea, summary, [contenteditable="true"]');
|
|
216
228
|
}
|
|
217
229
|
|
|
218
230
|
export function effectiveRole(element) {
|
|
@@ -34,10 +34,18 @@ export default {
|
|
|
34
34
|
helpUrl: 'https://www.w3.org/WAI/ARIA/apg/practices/landmark-regions/',
|
|
35
35
|
// Implicit landmarks (header, footer, labelled section/form) join the
|
|
36
36
|
// grouping — a page's <header> and a div[role="banner"] ARE duplicates.
|
|
37
|
+
// region and form need a name to BE landmarks (ARIA 1.2 gives both "Name
|
|
38
|
+
// From: author", and Chromium exposes an unnamed one as generic), which is
|
|
39
|
+
// why the native <section> and <form> above are already spelled with the
|
|
40
|
+
// name requirement. Without it, two unnamed div[role="region"] were
|
|
41
|
+
// reported as indistinguishable landmarks when neither is a landmark at
|
|
42
|
+
// all, sending the author to label something no screen reader announces.
|
|
37
43
|
selector:
|
|
38
44
|
'nav, search, aside, header, footer, section[aria-label], section[aria-labelledby], ' +
|
|
39
45
|
'form[aria-label], form[aria-labelledby], [role="navigation"], [role="complementary"], ' +
|
|
40
|
-
'[role="banner"], [role="contentinfo"], [role="region"]
|
|
46
|
+
'[role="banner"], [role="contentinfo"], [role="region"][aria-label], ' +
|
|
47
|
+
'[role="region"][aria-labelledby], [role="form"][aria-label], [role="form"][aria-labelledby], ' +
|
|
48
|
+
'[role="search"], [role="main"], main',
|
|
41
49
|
// Judged as a set: two unlabelled navs are the problem, not either alone.
|
|
42
50
|
evaluateAll(elements) {
|
|
43
51
|
const roles = elements.map(landmarkRole);
|
|
@@ -14,8 +14,16 @@ const LANDMARK =
|
|
|
14
14
|
'header:not(:is(article, aside, main, nav, section) header), ' +
|
|
15
15
|
'footer:not(:is(article, aside, main, nav, section) footer), ' +
|
|
16
16
|
'section[aria-label], section[aria-labelledby], [role="main"], [role="navigation"], ' +
|
|
17
|
-
'[role="banner"], [role="contentinfo"], [role="complementary"],
|
|
18
|
-
|
|
17
|
+
'[role="banner"], [role="contentinfo"], [role="complementary"], ' +
|
|
18
|
+
// region and form are exposed as landmarks only once they are named (ARIA
|
|
19
|
+
// 1.2 gives both "Name From: author"), which is why the native <section>
|
|
20
|
+
// and <form> above already carry that requirement. Accepting the ARIA
|
|
21
|
+
// spelling bare let a nameless div[role="region"] stand in for a landmark
|
|
22
|
+
// and hid every stray element inside it; Chromium exposes that div as
|
|
23
|
+
// generic, so screen-reader users reach none of it by region navigation.
|
|
24
|
+
'[role="region"][aria-label], [role="region"][aria-labelledby], ' +
|
|
25
|
+
'[role="form"][aria-label], [role="form"][aria-labelledby], ' +
|
|
26
|
+
'[role="search"], [role="dialog"], [role="alertdialog"], dialog[open]';
|
|
19
27
|
|
|
20
28
|
export default {
|
|
21
29
|
id: 'region',
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// WCAG SC 1.3.1 Info and Relationships (Level A)
|
|
2
|
-
import { NEVER_RENDERED } from '../../lib/dom.js';
|
|
2
|
+
import { NEVER_RENDERED, isInert } from '../../lib/dom.js';
|
|
3
3
|
|
|
4
4
|
const ALLOWED = new Set(['DT', 'DD', 'DIV']);
|
|
5
5
|
// NEVER_RENDERED cannot corrupt the term/description pairing: HTML's content
|
|
@@ -17,11 +17,14 @@ export default {
|
|
|
17
17
|
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html',
|
|
18
18
|
selector: 'dl:not([role])', // a role attribute replaces the dl semantics
|
|
19
19
|
evaluate(element, { isRendered }) {
|
|
20
|
+
const exposed = (child) => !NEVER_RENDERED.has(child.tagName)
|
|
21
|
+
&& !child.closest('[aria-hidden="true"]') && !isInert(child)
|
|
22
|
+
&& (!isRendered || isRendered(child) || getComputedStyle(child).display === 'contents');
|
|
20
23
|
// A child the browser never renders (display:none, hidden) is not in
|
|
21
24
|
// the accessibility tree either, so it cannot break the pairing a
|
|
22
25
|
// reader hears; list-structure applies the same gate.
|
|
23
26
|
const invalid = [...element.children].filter((child) =>
|
|
24
|
-
!ALLOWED.has(child.tagName) &&
|
|
27
|
+
!ALLOWED.has(child.tagName) && exposed(child));
|
|
25
28
|
if (invalid.length) {
|
|
26
29
|
const tags = [...new Set(invalid.map((child) => `<${child.tagName.toLowerCase()}>`))].join(', ');
|
|
27
30
|
return {
|
|
@@ -36,7 +39,7 @@ export default {
|
|
|
36
39
|
// out of the dl's content model, so the term/description association is
|
|
37
40
|
// no longer guaranteed to assistive technology.
|
|
38
41
|
const emptyWrappers = [...element.children].filter((child) =>
|
|
39
|
-
child.tagName === 'DIV' && ![...child.children].some((inner) => inner.tagName === 'DT' || inner.tagName === 'DD'));
|
|
42
|
+
child.tagName === 'DIV' && exposed(child) && ![...child.children].some((inner) => inner.tagName === 'DT' || inner.tagName === 'DD'));
|
|
40
43
|
if (emptyWrappers.length) {
|
|
41
44
|
return {
|
|
42
45
|
status: 'fail',
|
|
@@ -44,6 +47,42 @@ export default {
|
|
|
44
47
|
fix: 'Make each <div> child of the <dl> contain its <dt>/<dd> pair directly, or flatten the pairs into the <dl> itself.',
|
|
45
48
|
};
|
|
46
49
|
}
|
|
50
|
+
// HTML defines a group as one or more terms followed by one or more
|
|
51
|
+
// descriptions. Check each wrapper independently, so a term in one
|
|
52
|
+
// group cannot supply the missing term of another.
|
|
53
|
+
// https://html.spec.whatwg.org/multipage/grouping-content.html#the-dl-element
|
|
54
|
+
const children = [...element.children].filter(exposed);
|
|
55
|
+
const groups = [[...element.children].filter((child) => child.tagName !== 'DIV'),
|
|
56
|
+
...children.filter((child) => child.tagName === 'DIV').map((child) => [...child.children])];
|
|
57
|
+
for (const members of groups) {
|
|
58
|
+
const group = members.filter(exposed);
|
|
59
|
+
const concealedPairMember = members.some((child) => (child.tagName === 'DT' || child.tagName === 'DD') && !exposed(child));
|
|
60
|
+
const concealedResult = {
|
|
61
|
+
status: 'incomplete',
|
|
62
|
+
message: 'A term or description in this group is hidden from assistive technology. Check that any disclosure control exposes the associated content when opened, and that the term and description relationship remains available.',
|
|
63
|
+
};
|
|
64
|
+
let last = null;
|
|
65
|
+
for (const child of group) {
|
|
66
|
+
if (child.tagName !== 'DT' && child.tagName !== 'DD') continue;
|
|
67
|
+
if (child.tagName === 'DD' && last === null) {
|
|
68
|
+
if (concealedPairMember) return concealedResult;
|
|
69
|
+
return {
|
|
70
|
+
status: 'fail',
|
|
71
|
+
message: 'This description list has a description without a preceding term in its group.',
|
|
72
|
+
fix: 'Add a <dt> before the <dd>, or use a paragraph when the content is not a term and description.',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
last = child.tagName;
|
|
76
|
+
}
|
|
77
|
+
if (last === 'DT') {
|
|
78
|
+
if (concealedPairMember) return concealedResult;
|
|
79
|
+
return {
|
|
80
|
+
status: 'fail',
|
|
81
|
+
message: 'This description list ends a group with a term that has no description.',
|
|
82
|
+
fix: 'Follow the <dt> element or elements with at least one <dd> in the same group.',
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
47
86
|
return { status: 'pass' };
|
|
48
87
|
},
|
|
49
88
|
};
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// 3.3.2 mapping: a field with no label at all, or only a vanishing
|
|
4
4
|
// placeholder, is failure F82 — the classic 3.3.2 pattern.
|
|
5
5
|
import { labelledByName } from '../../lib/accessible-name.js';
|
|
6
|
+
import { effectiveRole } from '../../lib/roles.js';
|
|
6
7
|
|
|
7
8
|
export default {
|
|
8
9
|
id: 'form-label',
|
|
@@ -14,6 +15,12 @@ export default {
|
|
|
14
15
|
selector:
|
|
15
16
|
'input:not([type="hidden"]):not([type="button"]):not([type="submit"]):not([type="reset"]):not([type="image"]), select, textarea',
|
|
16
17
|
evaluate(element) {
|
|
18
|
+
// A field whose presentational role survives the conflict resolution is
|
|
19
|
+
// not in the accessibility tree at all: <select role="none" disabled> is
|
|
20
|
+
// exposed as role none, so there is no field to name under 4.1.2 and
|
|
21
|
+
// nothing to fill in under 3.3.2. The role library owns that judgment.
|
|
22
|
+
const role = effectiveRole(element);
|
|
23
|
+
if (role === 'presentation' || role === 'none') return { status: 'pass' };
|
|
17
24
|
// Only perceivable labels count: a display:none / aria-hidden label
|
|
18
25
|
// technically feeds the accessible name, but nobody can see it and
|
|
19
26
|
// that defeats the point of a label.
|
|
@@ -121,16 +121,23 @@ export default {
|
|
|
121
121
|
// embedded in prose exactly as 1.4.1 means it. Pure link lists (navs,
|
|
122
122
|
// blogrolls) are still excluded, but by the ownText guard below rather than
|
|
123
123
|
// by tag: in <li><a>Home</a></li> the item has no text of its own.
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
|
|
124
|
+
// Prose in a generic container has the same requirement. F73 does not
|
|
125
|
+
// limit colour-only links to paragraph markup. The text and shared-line
|
|
126
|
+
// checks below keep navigation and separate blocks out of this lane.
|
|
127
|
+
// https://www.w3.org/WAI/WCAG22/Techniques/failures/F73
|
|
128
|
+
selector: 'p a[href], dd a[href], blockquote a[href], td a[href], li a[href], div a[href]',
|
|
127
129
|
visibility: 'visual', // colour distinction is a purely visual concern
|
|
128
130
|
evaluate(element, { ownText }) {
|
|
129
|
-
const parent = element.closest('p, dd, blockquote, td, li');
|
|
131
|
+
const parent = element.closest('p, dd, blockquote, td, li, div');
|
|
130
132
|
if (!element.textContent.trim() || !parent) return { status: 'pass' };
|
|
131
133
|
// Needs real surrounding text to blend into (1.4.1 is about links
|
|
132
134
|
// embedded in prose, not a link that IS the content).
|
|
133
|
-
|
|
135
|
+
const surroundingText = ownText(parent).trim();
|
|
136
|
+
if (!/[\p{L}\p{N}]/u.test(surroundingText)) return { status: 'pass' };
|
|
137
|
+
// A short fragment may be prose ("See ..."), an equation or a label.
|
|
138
|
+
// F73 has no minimum character count. Keep evaluating the visual cues
|
|
139
|
+
// and ask about ambiguous context instead of silently passing it.
|
|
140
|
+
const shortContext = surroundingText.replace(/\s+/g, '').length < 10;
|
|
134
141
|
|
|
135
142
|
const style = getComputedStyle(element);
|
|
136
143
|
const parentStyle = getComputedStyle(parent);
|
|
@@ -330,6 +337,13 @@ export default {
|
|
|
330
337
|
fix: reviewFix,
|
|
331
338
|
};
|
|
332
339
|
}
|
|
340
|
+
if (shortContext) {
|
|
341
|
+
return {
|
|
342
|
+
status: 'incomplete',
|
|
343
|
+
message: `This link has no detected non-colour cue and only ${shown}:1 colour difference from nearby text. The surrounding text is a short fragment. Check whether it forms a sentence or other prose with the link; if it does, colour alone does not distinguish the link sufficiently.`,
|
|
344
|
+
fix: reviewFix,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
333
347
|
return {
|
|
334
348
|
status: 'fail',
|
|
335
349
|
// The measured fact is the ratio against G183's threshold, and that is
|
|
@@ -4,13 +4,19 @@
|
|
|
4
4
|
// The override is injected and removed inside one synchronous pass — the
|
|
5
5
|
// engine only yields to the renderer between rules, so the page never
|
|
6
6
|
// paints the probed state.
|
|
7
|
-
// The probe reflows the whole page for a moment.
|
|
7
|
+
// The probe reflows the whole page for a moment. Three guards keep that
|
|
8
8
|
// invisible to the user: scroll anchoring is disabled while the override
|
|
9
9
|
// is in (or Chrome re-anchors during the inflated layout and leaves the
|
|
10
10
|
// page scrolled somewhere else when the probe lifts — measured at 884px
|
|
11
|
-
// of drift on a long report page)
|
|
12
|
-
//
|
|
13
|
-
|
|
11
|
+
// of drift on a long report page); the document keeps at least its own
|
|
12
|
+
// height, because a page set with a line height above 1.5 gets SHORTER
|
|
13
|
+
// under the override, the browser clamps the scroll position to the new
|
|
14
|
+
// end, and that clamp paints (measured on rocketvalidator.com: 2500px to
|
|
15
|
+
// 1966px in one frame, David, 2026-09-02); and the window scroll is put
|
|
16
|
+
// back instantly after removal, instantly whatever the page's own
|
|
17
|
+
// scroll-behavior says, since a smooth restore is the visible half of the
|
|
18
|
+
// jump.
|
|
19
|
+
const override = (minHeight) => `html { overflow-anchor: none !important; min-height: ${minHeight}px !important; }
|
|
14
20
|
* {
|
|
15
21
|
line-height: 1.5 !important;
|
|
16
22
|
letter-spacing: 0.12em !important;
|
|
@@ -56,7 +62,7 @@ export default {
|
|
|
56
62
|
const scrollY = win?.scrollY ?? 0;
|
|
57
63
|
const probe = doc.createElement('style');
|
|
58
64
|
probe.dataset.pourAudit = 'probe';
|
|
59
|
-
probe.textContent =
|
|
65
|
+
probe.textContent = override(doc.documentElement.scrollHeight);
|
|
60
66
|
doc.documentElement.append(probe);
|
|
61
67
|
let after;
|
|
62
68
|
try {
|
|
@@ -68,7 +74,7 @@ export default {
|
|
|
68
74
|
// The audit must never move the page: put the scroll back exactly
|
|
69
75
|
// where the user had it, after the removal's own reflow settles.
|
|
70
76
|
void doc.documentElement.offsetHeight;
|
|
71
|
-
if (win && (win.scrollX !== scrollX || win.scrollY !== scrollY)) win.scrollTo(scrollX, scrollY);
|
|
77
|
+
if (win && (win.scrollX !== scrollX || win.scrollY !== scrollY)) win.scrollTo({ left: scrollX, top: scrollY, behavior: 'instant' });
|
|
72
78
|
}
|
|
73
79
|
|
|
74
80
|
return elements.map((element, i) => {
|
|
@@ -6,8 +6,7 @@ import {
|
|
|
6
6
|
opacityAnimating, restingOpacity, mediaRects, inZeroClipSubtree,
|
|
7
7
|
paintedBackdrop, opaquePanelRects, viewportVeil, textShadowHalo, textShadowNegligible,
|
|
8
8
|
pseudoBackdropForText, filmedContrastBounds, backgroundColorSource, scrimPaint, applyOverlays,
|
|
9
|
-
showRatio, splitBackgroundLayers, backgroundLayerUrl, sampleGridFor, opacityGroupPaint, pseudoTextColors,
|
|
10
|
-
} from '../../lib/contrast.js';
|
|
9
|
+
showRatio, splitBackgroundLayers, backgroundLayerUrl, sampleGridFor, opacityGroupPaint, pseudoTextColors, BOLD_WEIGHT } from '../../lib/contrast.js';
|
|
11
10
|
|
|
12
11
|
/** The first url() among a background-image list's layers, or null. */
|
|
13
12
|
const firstLayerUrl = (css) => splitBackgroundLayers(css ?? '').map(backgroundLayerUrl).find(Boolean) ?? null;
|
|
@@ -407,7 +406,8 @@ async function sampledVerdict(source, foreground, required, doc, overlays = [],
|
|
|
407
406
|
*/
|
|
408
407
|
export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
409
408
|
// When the colour pair already clears the LARGE-scale minimum, the miss
|
|
410
|
-
// is about scale, not colour: name the boundary (14pt bold / 18pt
|
|
409
|
+
// is about scale, not colour: name the boundary (14pt bold / 18pt, bold
|
|
410
|
+
// meaning BOLD_WEIGHT and up), or
|
|
411
411
|
// a bold-but-just-under button reads as the checker ignoring its weight.
|
|
412
412
|
const largeScaleNoteFor = (style, required, ratio) => {
|
|
413
413
|
if (required !== thresholds.normal || ratio < thresholds.large) return '';
|
|
@@ -418,15 +418,16 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
418
418
|
// twentieth of a pixel of a cutoff the size is shown to two decimals,
|
|
419
419
|
// and when even that rounds onto the cutoff it is named as just under
|
|
420
420
|
// it. (2026-08-25 overnight audit, defect 9.)
|
|
421
|
-
const
|
|
422
|
-
const
|
|
421
|
+
const bold = weightNum >= BOLD_WEIGHT;
|
|
422
|
+
const cutoff = bold ? 56 / 3 : 24;
|
|
423
|
+
const cutoffText = bold ? '18.67px' : '24px';
|
|
423
424
|
const nearCutoff = Math.abs(sizePx - cutoff) < 0.05;
|
|
424
425
|
let shown = nearCutoff ? `${sizePx.toFixed(2)}px` : `${Math.round(sizePx * 10) / 10}px`;
|
|
425
426
|
if (nearCutoff && parseFloat(shown) >= parseFloat(cutoffText)) shown = `just under ${cutoffText}`;
|
|
426
|
-
if (
|
|
427
|
+
if (bold && sizePx < 56 / 3) {
|
|
427
428
|
return ` This text is bold at ${shown}; bold text counts as large scale from 18.67px (14pt), where the ${thresholds.large}:1 minimum would apply and these colours would pass.`;
|
|
428
429
|
}
|
|
429
|
-
if (
|
|
430
|
+
if (!bold && sizePx >= 56 / 3 && sizePx < 24) {
|
|
430
431
|
return ` At ${shown} regular weight this is not large scale; from 24px (18pt), or bold at this size, the ${thresholds.large}:1 minimum would apply and these colours would pass.`;
|
|
431
432
|
}
|
|
432
433
|
return '';
|
|
@@ -23,6 +23,33 @@ function refreshDestination(content) {
|
|
|
23
23
|
return rest.trim();
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* The delay the browser would actually use, and null when the HTML "shared
|
|
28
|
+
* declarative refresh steps" bail out and schedule nothing at all. Those
|
|
29
|
+
* steps collect ASCII digits and stop: a leading "+" ends the parse before a
|
|
30
|
+
* single digit is read (content="+5; url=…" refreshes nothing), and anything
|
|
31
|
+
* other than ";", "," or whitespace directly after the digits does the same
|
|
32
|
+
* (content="1x; url=…"). parseInt accepts both and returned 5 and 1, so the
|
|
33
|
+
* rule asserted a time limit on pages the browser never moves. Verified in
|
|
34
|
+
* Chromium: both of those pages stay put while content="1; url=…" navigates.
|
|
35
|
+
*/
|
|
36
|
+
function refreshDelay(content) {
|
|
37
|
+
const isAsciiSpace = (c) => c === ' ' || c === '\t' || c === '\n' || c === '\f' || c === '\r';
|
|
38
|
+
const isDigit = (c) => c >= '0' && c <= '9';
|
|
39
|
+
let i = 0;
|
|
40
|
+
while (i < content.length && isAsciiSpace(content[i])) i++;
|
|
41
|
+
let digits = '';
|
|
42
|
+
while (i < content.length && isDigit(content[i])) digits += content[i++];
|
|
43
|
+
// No digits is only allowed when a decimal point follows, which the steps
|
|
44
|
+
// read as a fractional delay of zero seconds.
|
|
45
|
+
if (!digits && content[i] !== '.') return null;
|
|
46
|
+
const delay = digits ? Number(digits) : 0;
|
|
47
|
+
// Trailing digits and full stops are collected and thrown away.
|
|
48
|
+
while (i < content.length && (isDigit(content[i]) || content[i] === '.')) i++;
|
|
49
|
+
if (i < content.length && content[i] !== ';' && content[i] !== ',' && !isAsciiSpace(content[i])) return null;
|
|
50
|
+
return delay;
|
|
51
|
+
}
|
|
52
|
+
|
|
26
53
|
export default {
|
|
27
54
|
id: 'meta-refresh',
|
|
28
55
|
name: 'Timed page refresh',
|
|
@@ -34,7 +61,10 @@ export default {
|
|
|
34
61
|
visibleOnly: false,
|
|
35
62
|
evaluate(element) {
|
|
36
63
|
const content = element.getAttribute('content') ?? '';
|
|
37
|
-
const delay =
|
|
64
|
+
const delay = refreshDelay(content);
|
|
65
|
+
// The browser discards this directive, so no time limit is set by the
|
|
66
|
+
// content and 2.2.1 has nothing to judge.
|
|
67
|
+
if (delay === null) return { status: 'pass' };
|
|
38
68
|
// A refresh with no destination reloads THIS page. At zero seconds that
|
|
39
69
|
// is not the allowed instant redirect — it is a loop that throws the
|
|
40
70
|
// user back to the top of the page over and over (failure F41).
|
|
@@ -8,11 +8,35 @@
|
|
|
8
8
|
// finding goes to a human with the evidence attached, never asserted: the
|
|
9
9
|
// same verdict discipline as pause-stop-hide's ticker branch.
|
|
10
10
|
//
|
|
11
|
+
// One shape of that mechanism is stated in the markup and needs no human:
|
|
12
|
+
// a visible button whose aria-controls names the video, or a container
|
|
13
|
+
// holding it. That is what aria-controls is for, the element whose
|
|
14
|
+
// presence or contents the control changes, and a page that wires its
|
|
15
|
+
// pause button that way has answered the question the review would ask
|
|
16
|
+
// (pour.dev's own hero and sense videos, David, 2026-09-02). A button that
|
|
17
|
+
// merely sits near the video, or one that is hidden, settles nothing.
|
|
18
|
+
//
|
|
11
19
|
// A <video controls> ships its own pause button and is out of the selector.
|
|
12
20
|
// Icon-scale playback (tiny inline previews) is left alone for the same
|
|
13
21
|
// reason pause-stop-hide skips spinners: flagging every small moving
|
|
14
22
|
// thumbnail would bury the full-bleed hero that matters. Silence there is
|
|
15
23
|
// abstaining, not clearing.
|
|
24
|
+
/** A visible, enabled button whose aria-controls names the video or an
|
|
25
|
+
* ancestor of it, looked up in the video's own document or shadow tree. */
|
|
26
|
+
function pauseControlFor(video, isVisible) {
|
|
27
|
+
const root = video.getRootNode();
|
|
28
|
+
if (!root.querySelectorAll) return null;
|
|
29
|
+
const byId = (id) => (root.getElementById ? root.getElementById(id) : root.querySelector(`#${CSS.escape(id)}`));
|
|
30
|
+
for (const control of root.querySelectorAll('button[aria-controls], [role="button"][aria-controls], input[type="button"][aria-controls]')) {
|
|
31
|
+
if (control.disabled || !isVisible(control)) continue;
|
|
32
|
+
for (const id of control.getAttribute('aria-controls').split(/\s+/)) {
|
|
33
|
+
const target = id && byId(id);
|
|
34
|
+
if (target && (target === video || target.contains(video))) return control;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
16
40
|
export default {
|
|
17
41
|
id: 'video-loop-motion',
|
|
18
42
|
name: 'Looping video control',
|
|
@@ -23,7 +47,7 @@ export default {
|
|
|
23
47
|
selector: 'video[autoplay][loop]:not([controls])',
|
|
24
48
|
// Movement is a visual matter: an aria-hidden hero still moves on screen.
|
|
25
49
|
visibility: 'visual',
|
|
26
|
-
evaluate(element) {
|
|
50
|
+
evaluate(element, { isVisible }) {
|
|
27
51
|
// Icon scale means SMALL, not thin: a 1200×60 animated banner strip is
|
|
28
52
|
// a ticker, and only a video small on both axes is spinner-class.
|
|
29
53
|
const rect = element.getBoundingClientRect();
|
|
@@ -32,6 +56,7 @@ export default {
|
|
|
32
56
|
// something on the page already paused it — in both cases nothing is
|
|
33
57
|
// moving, and a paused-by-a-control video is the mechanism working.
|
|
34
58
|
if (element.paused && element.readyState >= 2) return { status: 'pass' };
|
|
59
|
+
if (pauseControlFor(element, isVisible)) return { status: 'pass' };
|
|
35
60
|
return {
|
|
36
61
|
status: 'incomplete',
|
|
37
62
|
message: 'This video auto-plays on a loop with no built-in controls: it starts by itself and never ends, 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 a pause control that reaches this video, or that the video is the only content presented.',
|
|
@@ -31,6 +31,31 @@ const TARGETS = 'a[href], button, input, select, [role="button"], [role="link"]'
|
|
|
31
31
|
*/
|
|
32
32
|
import { isInert } from '../../lib/dom.js';
|
|
33
33
|
|
|
34
|
+
// A web component's internal link and its enclosing light-DOM link are
|
|
35
|
+
// not independent pointer targets. DOM contains() stops at shadow roots.
|
|
36
|
+
function containsComposed(ancestor, element) {
|
|
37
|
+
if (ancestor.contains(element)) return true;
|
|
38
|
+
if (ancestor.getRootNode() === element.getRootNode() && !element.assignedSlot) return false;
|
|
39
|
+
for (let node = element; node; node = node.assignedSlot ?? node.parentElement ?? node.getRootNode()?.host) {
|
|
40
|
+
if (node === ancestor) return true;
|
|
41
|
+
}
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function outsideHiddenOverflow(element, rect) {
|
|
46
|
+
for (let parent = element.parentElement; parent; parent = parent.parentElement) {
|
|
47
|
+
const style = getComputedStyle(parent);
|
|
48
|
+
if (style.display === 'contents') continue; // no principal box to clip its children
|
|
49
|
+
const x = /^(hidden|clip)$/.test(style.overflowX);
|
|
50
|
+
const y = /^(hidden|clip)$/.test(style.overflowY);
|
|
51
|
+
if (!x && !y) continue;
|
|
52
|
+
const clip = parent.getBoundingClientRect();
|
|
53
|
+
if ((x && (clip.width === 0 || rect.right <= clip.left || rect.left >= clip.right))
|
|
54
|
+
|| (y && (clip.height === 0 || rect.bottom <= clip.top || rect.top >= clip.bottom))) return true;
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
34
59
|
function isHiddenFromPointer(element, rect) {
|
|
35
60
|
if (rect.width <= 1 || rect.height <= 1) return true;
|
|
36
61
|
if (rect.right <= 0 || rect.bottom <= 0) return true; // parked above/left of the canvas
|
|
@@ -567,7 +592,7 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
|
|
|
567
592
|
// target's circle.
|
|
568
593
|
const crowds = (other, j) => {
|
|
569
594
|
if (j === i || !laidOut[j]) return false;
|
|
570
|
-
if (other
|
|
595
|
+
if (containsComposed(other, element) || containsComposed(element, other)) return false; // same control, including shadow content
|
|
571
596
|
// "One control drawn twice" (stretched-link overlays) is a claim
|
|
572
597
|
// about PAINT, so it must be proved on a painted fragment, not on
|
|
573
598
|
// the bounding box: a two-line wrapped link's box swallows a small
|
|
@@ -576,7 +601,20 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
|
|
|
576
601
|
// the honest fragment test below. A single-fragment element's one
|
|
577
602
|
// fragment IS its box, so genuine overlays are dismissed exactly
|
|
578
603
|
// as before.
|
|
579
|
-
|
|
604
|
+
// Containment of one painted box inside another does not prove
|
|
605
|
+
// that they perform the same action. A small preview button over
|
|
606
|
+
// an image link is explicitly a spacing failure in SC 2.5.8.
|
|
607
|
+
// Only two links with the same real destination can use the
|
|
608
|
+
// equivalent-control exemption here.
|
|
609
|
+
// https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html
|
|
610
|
+
if (paintedEncloses(other, rects[j], rects[i]) || paintedEncloses(element, rects[i], rects[j])) {
|
|
611
|
+
const destination = destinationOf(element);
|
|
612
|
+
if (destination && destination === destinationOf(other)) return false;
|
|
613
|
+
// Collapsed disclosures can leave their descendants' boxes
|
|
614
|
+
// overlapping nearby controls even though the overflow clips
|
|
615
|
+
// them completely. Such boxes accept no pointer input.
|
|
616
|
+
if (outsideHiddenOverflow(element, rects[i]) || outsideHiddenOverflow(other, rects[j])) return false;
|
|
617
|
+
}
|
|
580
618
|
// Crowding across fixed/sticky contexts is scroll state, not
|
|
581
619
|
// layout, exactly as the obscured-target test reads overlap: a
|
|
582
620
|
// consent banner pinned to the bottom of the viewport lays its
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// WCAG SC 4.1.2 Name, Role, Value (Level A)
|
|
2
|
+
import { isInert } from '../../lib/dom.js';
|
|
2
3
|
// Deliberately excludes bare [tabindex]: a focusable container (e.g. a
|
|
3
4
|
// scrollable region with tabindex="0") holding links is correct markup.
|
|
4
5
|
const INTERACTIVE =
|
|
@@ -16,8 +17,7 @@ export default {
|
|
|
16
17
|
selector: INTERACTIVE,
|
|
17
18
|
evaluate(element, { isRendered }) {
|
|
18
19
|
// Only descendants a user can actually reach count: hidden inputs,
|
|
19
|
-
// disabled controls
|
|
20
|
-
// inside a control are inert markup, not competing tab stops.
|
|
20
|
+
// disabled controls and display:none templates are excluded.
|
|
21
21
|
// Two more shapes are unreachable by the same principle: aria-hidden
|
|
22
22
|
// content (AT ignores it wholesale — a decorative svg role="button"
|
|
23
23
|
// aria-hidden="true" inside a play button competes with nothing; if it
|
|
@@ -26,16 +26,28 @@ export default {
|
|
|
26
26
|
// role="button" with no tabindex is not in the tab order — keyboard
|
|
27
27
|
// users cannot reach it, so it cannot be a competing stop).
|
|
28
28
|
const NATIVE = 'a[href], button, input, select, textarea, summary, audio[controls], video[controls]';
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const nested = [...element.querySelectorAll(INTERACTIVE)].find((el) =>
|
|
34
|
-
!el.matches(':disabled') && !(el.hasAttribute('tabindex') && el.tabIndex < 0)
|
|
29
|
+
// Negative tabindex removes sequential focus navigation, not focusability.
|
|
30
|
+
// https://html.spec.whatwg.org/multipage/interaction.html#attr-tabindex
|
|
31
|
+
const candidates = [...element.querySelectorAll(INTERACTIVE)].filter((el) =>
|
|
32
|
+
!el.matches(':disabled') && !isInert(el)
|
|
35
33
|
&& !(el.tagName === 'INPUT' && el.type === 'hidden') && isRendered(el)
|
|
36
34
|
&& !el.closest('[aria-hidden="true"]')
|
|
37
35
|
&& (el.matches(NATIVE) || el.hasAttribute('tabindex')));
|
|
36
|
+
// A child requiring review must not conceal a later definite finding.
|
|
37
|
+
const nested = candidates.find((el) => !(el.hasAttribute('tabindex') && el.tabIndex < 0)) ?? candidates[0];
|
|
38
38
|
if (!nested) return { status: 'pass' };
|
|
39
|
+
// A programmatically focusable child must not silently pass. In an
|
|
40
|
+
// ARIA widget its name and role can survive presentational-descendant
|
|
41
|
+
// processing, so nesting alone does not prove a 4.1.2 failure.
|
|
42
|
+
// Native links/buttons separately forbid interactive descendants.
|
|
43
|
+
// https://www.w3.org/TR/wai-aria-1.2/#tree_exclusion
|
|
44
|
+
if (nested.hasAttribute('tabindex') && nested.tabIndex < 0
|
|
45
|
+
&& !element.matches('a[href], button')) {
|
|
46
|
+
return {
|
|
47
|
+
status: 'incomplete',
|
|
48
|
+
message: `This control contains an element (<${nested.tagName.toLowerCase()}>) with a negative tabindex. It can still receive focus. Check that both controls expose the intended name and role, and that focusing and activating the child works correctly.`,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
39
51
|
// <summary> is the one outer control HTML does not forbid this on: its
|
|
40
52
|
// content model is phrasing content with no "no interactive content
|
|
41
53
|
// descendant" clause (a and button both carry one), it maps to no ARIA
|
package/package.json
CHANGED