pour-engine 1.33.0 → 1.34.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 -5
- package/engine/index.js +11 -4
- package/engine/lib/accessible-name.js +15 -4
- package/engine/lib/contrast.js +316 -11
- package/engine/lib/roles.js +30 -6
- package/engine/rules/best-practice/button-type.js +23 -2
- package/engine/rules/best-practice/fieldset-legend.js +7 -2
- package/engine/rules/best-practice/heading-order.js +2 -1
- package/engine/rules/best-practice/landmark-top-level.js +10 -0
- package/engine/rules/best-practice/landmark-unique.js +9 -1
- package/engine/rules/best-practice/new-window-link.js +34 -1
- package/engine/rules/best-practice/no-autofocus.js +15 -3
- package/engine/rules/best-practice/page-heading-one.js +15 -1
- package/engine/rules/best-practice/redundant-alt-phrase.js +9 -1
- package/engine/rules/best-practice/redundant-aria-label.js +24 -0
- package/engine/rules/best-practice/redundant-role.js +71 -20
- package/engine/rules/best-practice/region.js +6 -2
- package/engine/rules/index.js +13 -0
- package/engine/rules/wcag/1.1.1-input-image-alt.js +4 -1
- package/engine/rules/wcag/1.1.1-object-alt.js +17 -0
- package/engine/rules/wcag/1.2.1-audio-transcript.js +47 -0
- package/engine/rules/wcag/1.2.2-media-captions.js +16 -1
- package/engine/rules/wcag/1.3.1-aria-required-children.js +30 -3
- package/engine/rules/wcag/1.3.1-aria-required-parent.js +25 -9
- package/engine/rules/wcag/1.3.1-definition-list.js +15 -3
- package/engine/rules/wcag/1.3.1-dlitem-parent.js +14 -1
- package/engine/rules/wcag/1.3.1-form-label.js +39 -1
- package/engine/rules/wcag/1.3.1-listitem-parent.js +23 -7
- package/engine/rules/wcag/1.3.1-table-headers.js +34 -3
- package/engine/rules/wcag/1.3.2-reading-order-divergence.js +69 -0
- package/engine/rules/wcag/1.3.4-orientation.js +21 -7
- package/engine/rules/wcag/1.3.5-autocomplete-valid.js +87 -13
- package/engine/rules/wcag/1.4.1-link-in-text-block.js +224 -17
- package/engine/rules/wcag/1.4.11-non-text-contrast.js +27 -3
- package/engine/rules/wcag/1.4.2-audio-control.js +45 -14
- package/engine/rules/wcag/1.4.3-color-contrast.js +337 -47
- package/engine/rules/wcag/1.4.3-control-contrast.js +26 -5
- package/engine/rules/wcag/2.2.1-meta-refresh.js +25 -1
- package/engine/rules/wcag/2.2.2-pause-stop-hide.js +26 -3
- package/engine/rules/wcag/2.4.1-bypass-blocks.js +29 -5
- package/engine/rules/wcag/2.4.2-document-title.js +8 -2
- package/engine/rules/wcag/2.4.3-visual-order-divergence.js +6 -4
- package/engine/rules/wcag/2.4.4-link-name.js +11 -0
- package/engine/rules/wcag/2.4.4-link-text-generic.js +22 -1
- package/engine/rules/wcag/2.4.7-focus-visible.js +47 -10
- package/engine/rules/wcag/2.4.9-link-text-generic-only.js +36 -5
- package/engine/rules/wcag/2.5.3-label-in-name.js +14 -5
- package/engine/rules/wcag/2.5.5-target-size-enhanced.js +3 -1
- package/engine/rules/wcag/2.5.7-drag-alternative.js +38 -0
- package/engine/rules/wcag/2.5.8-target-size.js +122 -3
- package/engine/rules/wcag/3.1.1-html-lang.js +12 -1
- package/engine/rules/wcag/3.2.2-on-input-navigation.js +18 -8
- package/engine/rules/wcag/3.3.7-redundant-entry.js +55 -0
- package/engine/rules/wcag/3.3.8-auth-field-obstruction.js +50 -20
- package/engine/rules/wcag/4.1.2-aria-attr-valid.js +49 -13
- package/engine/rules/wcag/4.1.2-aria-hidden-focus.js +6 -1
- package/engine/rules/wcag/4.1.2-aria-valid-refs.js +44 -73
- package/engine/rules/wcag/4.1.2-button-name.js +11 -0
- package/engine/rules/wcag/4.1.2-composite-widget-name.js +12 -6
- package/engine/rules/wcag/4.1.2-label-for-valid.js +41 -8
- package/engine/rules/wcag/4.1.2-nested-interactive.js +18 -1
- package/engine/rules/wcag/4.1.2-valid-role.js +16 -0
- package/engine/wcag22.js +4 -4
- package/package.json +1 -1
|
@@ -1,4 +1,28 @@
|
|
|
1
1
|
// WCAG SC 2.2.1 Timing Adjustable (Level A)
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The destination the browser would navigate to, read the way the HTML
|
|
5
|
+
* "shared declarative refresh steps" read it: after the time and its
|
|
6
|
+
* separator (";", "," or whitespace) everything that remains is the URL,
|
|
7
|
+
* with or without a "url" keyword, an "=" and quotes, none of which the
|
|
8
|
+
* parser requires. Only a bare number, or a number followed by nothing,
|
|
9
|
+
* points the refresh back at the page itself. (2026-08-25 overnight audit:
|
|
10
|
+
* the old test demanded the literal "url=", so content="0;https://…" was
|
|
11
|
+
* asserted as a reload loop while the browser redirected from it.)
|
|
12
|
+
*/
|
|
13
|
+
function refreshDestination(content) {
|
|
14
|
+
let rest = content.replace(/^\s*[\d.]*\s*(?:[;,]\s*)?/, '');
|
|
15
|
+
// "url" is consumed letter by letter, then optional whitespace and "=".
|
|
16
|
+
rest = rest.replace(/^url\s*(?:=\s*)?/i, '');
|
|
17
|
+
const quote = rest[0] === '"' || rest[0] === "'" ? rest[0] : '';
|
|
18
|
+
if (quote) {
|
|
19
|
+
rest = rest.slice(1);
|
|
20
|
+
const close = rest.indexOf(quote);
|
|
21
|
+
if (close !== -1) rest = rest.slice(0, close);
|
|
22
|
+
}
|
|
23
|
+
return rest.trim();
|
|
24
|
+
}
|
|
25
|
+
|
|
2
26
|
export default {
|
|
3
27
|
id: 'meta-refresh',
|
|
4
28
|
name: 'Timed page refresh',
|
|
@@ -14,7 +38,7 @@ export default {
|
|
|
14
38
|
// A refresh with no destination reloads THIS page. At zero seconds that
|
|
15
39
|
// is not the allowed instant redirect — it is a loop that throws the
|
|
16
40
|
// user back to the top of the page over and over (failure F41).
|
|
17
|
-
const hasDestination =
|
|
41
|
+
const hasDestination = refreshDestination(content) !== '';
|
|
18
42
|
if (delay === 0 && !hasDestination) {
|
|
19
43
|
return {
|
|
20
44
|
status: 'fail',
|
|
@@ -43,6 +43,17 @@ function keyframesBlink(keyframes) {
|
|
|
43
43
|
return opacities.some((value) => value <= 0.25) && opacities.some((value) => value >= 0.75);
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/** Does a pseudo-element's generated content put TEXT on the screen? A
|
|
47
|
+
* quoted string with something in it, an attr() or counter() value, or
|
|
48
|
+
* quotes do; "none", "normal", the empty string and url() images do not. */
|
|
49
|
+
function generatedText(content) {
|
|
50
|
+
if (!content || content === 'none' || content === 'normal') return false;
|
|
51
|
+
const withoutImages = content.replace(/url\([^)]*\)/g, '');
|
|
52
|
+
const strings = withoutImages.match(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g) ?? [];
|
|
53
|
+
if (strings.some((string) => string.slice(1, -1).trim())) return true;
|
|
54
|
+
return /\b(?:attr|counters?)\(|(?:open|close)-quote/.test(withoutImages);
|
|
55
|
+
}
|
|
56
|
+
|
|
46
57
|
/**
|
|
47
58
|
* Elements that an endlessly repeating animation is currently moving,
|
|
48
59
|
* mapped to the evidence. One document-wide getAnimations() call, never one
|
|
@@ -69,9 +80,21 @@ function endlesslyMoving(doc) {
|
|
|
69
80
|
try { keyframes = effect.getKeyframes(); } catch { continue; }
|
|
70
81
|
const moves = keyframesMove(keyframes);
|
|
71
82
|
if (!moves && !keyframesBlink(keyframes)) continue;
|
|
72
|
-
// A pseudo-element's motion is still the host element's motion
|
|
83
|
+
// A pseudo-element's motion is still the host element's motion, but
|
|
84
|
+
// the text gate belongs to the pseudo-element: a ::after spinner on a
|
|
85
|
+
// button labelled "Saving" carries no text of its own, so it is the
|
|
86
|
+
// text-free indicator this rule leaves alone, whatever the host says.
|
|
87
|
+
// One getComputedStyle per endless pseudo animation, never per element
|
|
88
|
+
// (2026-08-25 overnight audit).
|
|
89
|
+
let pseudoText = false;
|
|
90
|
+
if (effect.pseudoElement) {
|
|
91
|
+
let content = '';
|
|
92
|
+
try { content = target.ownerDocument.defaultView.getComputedStyle(target, effect.pseudoElement).content; } catch { /* detached */ }
|
|
93
|
+
if (!generatedText(content)) continue;
|
|
94
|
+
pseudoText = true;
|
|
95
|
+
}
|
|
73
96
|
if (!moving.has(target)) {
|
|
74
|
-
moving.set(target, { name: animation.animationName, verb: moves ? 'moves' : 'blinks' });
|
|
97
|
+
moving.set(target, { name: animation.animationName, verb: moves ? 'moves' : 'blinks', pseudoText });
|
|
75
98
|
}
|
|
76
99
|
}
|
|
77
100
|
return moving;
|
|
@@ -107,7 +130,7 @@ export default {
|
|
|
107
130
|
};
|
|
108
131
|
}
|
|
109
132
|
const animation = moving.get(element);
|
|
110
|
-
if (!animation || !element.textContent.trim()) return { status: 'pass' };
|
|
133
|
+
if (!animation || !(animation.pseudoText || element.textContent.trim())) return { status: 'pass' };
|
|
111
134
|
const named = animation.name ? ` (the “${animation.name}” animation)` : '';
|
|
112
135
|
return {
|
|
113
136
|
status: 'incomplete',
|
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
// WCAG SC 2.4.1 Bypass Blocks (Level A)
|
|
2
2
|
import { collectRoots, isEmbeddedDocument } from '../../lib/dom.js';
|
|
3
|
+
import { effectiveRole } from '../../lib/roles.js';
|
|
4
|
+
|
|
5
|
+
// ARIA11 lists the landmark roles: banner, complementary, contentinfo,
|
|
6
|
+
// form, main, navigation, region, search. Judged by EFFECTIVE role, not tag
|
|
7
|
+
// (2026-08-25 overnight audit): HTML-AAM makes a <header>/<footer> inside
|
|
8
|
+
// article, aside, main, nav or section generic rather than a landmark, and
|
|
9
|
+
// a named <section> or <form> IS one. The tag selector counted the first
|
|
10
|
+
// and missed the second. region and form are landmarks only when named
|
|
11
|
+
// (ARIA 1.2 requires an accessible name for region; HTML-AAM maps form to
|
|
12
|
+
// the form landmark only with one), so the name is checked here.
|
|
13
|
+
const LANDMARK_ROLES = new Set(['banner', 'complementary', 'contentinfo', 'form', 'main', 'navigation', 'region', 'search']);
|
|
14
|
+
const NAMED_ONLY = new Set(['form', 'region']);
|
|
15
|
+
const LANDMARK_CANDIDATES = 'main, header, footer, nav, aside, section, form, [role]';
|
|
16
|
+
const hasName = (el) => Boolean(
|
|
17
|
+
el.getAttribute('aria-label')?.trim() || el.getAttribute('aria-labelledby')?.trim() || el.getAttribute('title')?.trim());
|
|
18
|
+
const isLandmark = (el) => {
|
|
19
|
+
const role = effectiveRole(el);
|
|
20
|
+
return LANDMARK_ROLES.has(role) && (!NAMED_ONLY.has(role) || hasName(el));
|
|
21
|
+
};
|
|
3
22
|
|
|
4
23
|
export default {
|
|
5
24
|
id: 'bypass-blocks',
|
|
@@ -10,7 +29,7 @@ export default {
|
|
|
10
29
|
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/bypass-blocks.html',
|
|
11
30
|
selector: 'html',
|
|
12
31
|
visibleOnly: false,
|
|
13
|
-
evaluate(element) {
|
|
32
|
+
evaluate(element, { isRendered }) {
|
|
14
33
|
// 2.4.1 governs blocks "repeated on multiple Web pages", and a WCAG
|
|
15
34
|
// Web page is a non-embedded resource: an embedded frame's document
|
|
16
35
|
// owes no skip link of its own (the frame's title is itself the bypass
|
|
@@ -28,17 +47,22 @@ export default {
|
|
|
28
47
|
// banner and a navigation region are exactly what landmark navigation
|
|
29
48
|
// skips between. Requiring `main` failed pages that were already using
|
|
30
49
|
// the technique the spec names.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
50
|
+
if (roots.some((root) => [...(root.querySelectorAll?.(LANDMARK_CANDIDATES) ?? [])].some(isLandmark))) {
|
|
51
|
+
return { status: 'pass' };
|
|
52
|
+
}
|
|
34
53
|
if (inAnyRoot('h1, h2, h3, h4, h5, h6, [role="heading"]')) return { status: 'pass' };
|
|
35
54
|
// G1/G123/G124: ANY in-page link that resolves is a bypass mechanism.
|
|
36
55
|
// Reading only the first one hid a real skip link behind the href="#"
|
|
37
|
-
// that scripts use as a button.
|
|
56
|
+
// that scripts use as a button. The link must be RENDERED to count:
|
|
57
|
+
// G1's test is that it is visible, or visible on keyboard focus, and
|
|
58
|
+
// a display:none link is never in the tab order, so nobody can use it.
|
|
59
|
+
// Clip/sr-only skip links are rendered and still count (2026-08-25
|
|
60
|
+
// overnight audit).
|
|
38
61
|
for (const root of roots) {
|
|
39
62
|
for (const link of root.querySelectorAll?.('a[href^="#"]') ?? []) {
|
|
40
63
|
const id = link.getAttribute('href').slice(1);
|
|
41
64
|
if (!id) continue;
|
|
65
|
+
if (isRendered && !isRendered(link)) continue;
|
|
42
66
|
if (roots.some((r) => r.getElementById?.(id) || r.querySelector?.(`[id="${CSS.escape(id)}"]`))) {
|
|
43
67
|
return { status: 'pass' };
|
|
44
68
|
}
|
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
// defaults. Almost always a leftover, but a gallery page really can be about
|
|
11
11
|
// an artwork called "Untitled", and "Example Domain" really is the subject
|
|
12
12
|
// of example.com. Those are somebody's call to make, not the engine's.
|
|
13
|
+
// "New document", "New page" and "New tab" sit in tier two as well: a
|
|
14
|
+
// document editor's creation screen, a wiki's new-page form or a
|
|
15
|
+
// browser-style app's new-tab page is about exactly that, so only the
|
|
16
|
+
// numbered editor default ("New Page 1", F25's example) is a certain
|
|
17
|
+
// placeholder (2026-08-25 overnight audit).
|
|
13
18
|
//
|
|
14
19
|
// Deliberately NOT judged: whether a real-looking title matches the page it
|
|
15
20
|
// sits on. That needs someone who can read the page, and guessing at it
|
|
@@ -18,12 +23,13 @@ import { isEmbeddedDocument } from '../../lib/dom.js';
|
|
|
18
23
|
|
|
19
24
|
const PLACEHOLDER = new Set([
|
|
20
25
|
'untitled document', 'untitled page', 'untitled-1', 'untitled 1',
|
|
21
|
-
'
|
|
26
|
+
'no title',
|
|
22
27
|
'insert title here', 'title here', 'page title', 'document title',
|
|
23
28
|
'web page', 'webpage',
|
|
24
29
|
]);
|
|
25
30
|
const UNDESCRIPTIVE = new Set([
|
|
26
31
|
'untitled', 'document', 'page', 'title', 'default', 'index',
|
|
32
|
+
'new document', 'new page', 'new tab',
|
|
27
33
|
'react app', 'create react app', 'vite app', 'vite + react', 'vite + vue', 'vite + svelte',
|
|
28
34
|
'next app', 'create next app', 'vue app', 'nuxt app', 'svelte app', 'angular app',
|
|
29
35
|
'my app', 'my site', 'my website', 'site title', 'your site title',
|
|
@@ -55,7 +61,7 @@ export default {
|
|
|
55
61
|
}
|
|
56
62
|
// Collapsed and lowercased so "Untitled Document" is caught too.
|
|
57
63
|
const normalized = title.replace(/\s+/g, ' ').toLowerCase();
|
|
58
|
-
if (PLACEHOLDER.has(normalized) || /^untitled[\s-]*\d+$/.test(normalized)) {
|
|
64
|
+
if (PLACEHOLDER.has(normalized) || /^(?:untitled|new (?:document|page|tab))[\s-]*\d+$/.test(normalized)) {
|
|
59
65
|
return {
|
|
60
66
|
status: 'fail',
|
|
61
67
|
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.`,
|
|
@@ -22,17 +22,19 @@
|
|
|
22
22
|
// grid-auto-flow: dense) reorders without `order` or `*-reverse` and is not
|
|
23
23
|
// treated as evidence. Both are honest gaps to widen later with measured
|
|
24
24
|
// cases, not silently.
|
|
25
|
-
const FLEXGRID = /^(inline-)?(flex|grid)$/;
|
|
25
|
+
export const FLEXGRID = /^(inline-)?(flex|grid)$/;
|
|
26
26
|
|
|
27
27
|
/** DOM-order focusable children grouped per flex/grid parent. */
|
|
28
|
-
function tabbable(element) {
|
|
28
|
+
export function tabbable(element) {
|
|
29
29
|
if (element.disabled || element.tabIndex < 0) return false;
|
|
30
30
|
return true;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
/** Visual reading order of sibling rects: group into rows by vertical
|
|
34
|
-
* overlap, rows top-to-bottom, within a row along the inline direction.
|
|
35
|
-
|
|
34
|
+
* overlap, rows top-to-bottom, within a row along the inline direction.
|
|
35
|
+
* Shared with 1.3.2's reading-order rule — same geometry, different
|
|
36
|
+
* audience (Tab order there, screen-reader reading order here). */
|
|
37
|
+
export function visualSequence(entries, rightToLeft) {
|
|
36
38
|
const rows = [];
|
|
37
39
|
for (const entry of [...entries].sort((a, b) => a.rect.top - b.rect.top)) {
|
|
38
40
|
const row = rows.find((candidates) => {
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
// WCAG SC 2.4.4 Link Purpose (In Context) (Level A) · SC 4.1.2 Name, Role, Value (Level A)
|
|
2
|
+
import { effectiveRole, implicitRole } from '../../lib/roles.js';
|
|
3
|
+
|
|
2
4
|
export default {
|
|
3
5
|
id: 'link-name',
|
|
4
6
|
name: 'Link names',
|
|
@@ -10,6 +12,15 @@ export default {
|
|
|
10
12
|
// (Links without href have no link role and are correctly excluded.)
|
|
11
13
|
selector: 'a[href], [role="link"]',
|
|
12
14
|
evaluate(element, { accessibleName }) {
|
|
15
|
+
// Route by EFFECTIVE role (2026-08-25 overnight audit): an <a href
|
|
16
|
+
// role="button"> is exposed as a button, not a link (ARIA 1.2 section
|
|
17
|
+
// 9.1, the role attribute overrides the host role), so its missing name
|
|
18
|
+
// is button-name's finding, worded for a button; <a href role="tab"> is
|
|
19
|
+
// composite-widget-name's. One nameless control, one finding. An
|
|
20
|
+
// unknown explicit role is ignored by the browser and the native link
|
|
21
|
+
// role survives, which is what the implicitRole fallback answers.
|
|
22
|
+
const role = effectiveRole(element) ?? implicitRole(element);
|
|
23
|
+
if (role !== 'link') return { status: 'pass' };
|
|
13
24
|
if (accessibleName(element)) return { status: 'pass' };
|
|
14
25
|
return {
|
|
15
26
|
status: 'fail',
|
|
@@ -55,6 +55,22 @@ export function normalizeName(name) {
|
|
|
55
55
|
return name.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').replace(/\s+/g, ' ').trim();
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
// A name WRAPPED in a paired bracket or quote reads as a literal token —
|
|
59
|
+
// "<details>" names an element, and the brackets are the signal — so the
|
|
60
|
+
// phrase inside it is not provably generic. One-sided decoration cannot
|
|
61
|
+
// change a phrase's meaning and stays provable: python.org's links are
|
|
62
|
+
// named ">>>More" because a CSS-generated REPL prompt joins the accessible
|
|
63
|
+
// name, and "Read more →" identifies no more than "Read more". Apostrophes
|
|
64
|
+
// fold to spaces so the French entries compare in their listed form.
|
|
65
|
+
function provablyGeneric(name) {
|
|
66
|
+
const trimmed = name.trim();
|
|
67
|
+
if (/^[<\[({"'«‹「『].*[>\])}"'»›」』]$/su.test(trimmed)) return false;
|
|
68
|
+
const plain = trimmed.toLowerCase().replace(/[’']/gu, ' ')
|
|
69
|
+
.replace(/^[^\p{L}\p{N}\s]+\s*/u, '').replace(/[^\p{L}\p{N}\s]+\s*$/u, '')
|
|
70
|
+
.replace(/\s+/g, ' ').trim();
|
|
71
|
+
return GENERIC.has(plain);
|
|
72
|
+
}
|
|
73
|
+
|
|
58
74
|
export function createLinkPurposeRule({ id, impact, tags, help, helpUrl, verdict }) {
|
|
59
75
|
return {
|
|
60
76
|
id,
|
|
@@ -71,7 +87,12 @@ export function createLinkPurposeRule({ id, impact, tags, help, helpUrl, verdict
|
|
|
71
87
|
if (!name) return { status: 'pass' }; // nameless: link-name reports it
|
|
72
88
|
const normalized = normalizeName(name);
|
|
73
89
|
if (!GENERIC.has(normalized)) return { status: 'pass' };
|
|
74
|
-
|
|
90
|
+
// The link's declared language (closest lang attribute, falling back
|
|
91
|
+
// to the document's), so a verdict can tell whether a French-only
|
|
92
|
+
// entry sits on a French page. Reached only for list matches, so the
|
|
93
|
+
// ancestor walk costs nothing on the page as a whole (2026-08-25).
|
|
94
|
+
const lang = (element.closest('[lang]')?.getAttribute('lang') ?? element.ownerDocument.documentElement.getAttribute('lang') ?? '').trim();
|
|
95
|
+
return verdict(name, { provable: provablyGeneric(name), normalized, lang });
|
|
75
96
|
},
|
|
76
97
|
};
|
|
77
98
|
}
|
|
@@ -3,12 +3,35 @@
|
|
|
3
3
|
// indicator is the classic 2.4.7 failure (F78). Cross-origin sheets are
|
|
4
4
|
// unreadable and skipped; a replacement indicator can live in a different
|
|
5
5
|
// rule entirely — so this flags for review, it never asserts a fail.
|
|
6
|
+
// The focus pseudo-classes themselves, and nothing else: :focus-within is a
|
|
7
|
+
// PARENT's state and stripping ":focus" out of it would leave "-within".
|
|
8
|
+
const FOCUS_TOKEN = /:focus(?:-visible)?(?![\w-])/g;
|
|
9
|
+
const HAS_FOCUS = /:focus(?:-visible)?(?![\w-])/;
|
|
10
|
+
|
|
6
11
|
function focusSuppressors(doc) {
|
|
7
12
|
const suspects = [];
|
|
8
13
|
const replacementIn = (style) => style.getPropertyValue('box-shadow') || style.getPropertyValue('border')
|
|
9
14
|
|| style.getPropertyValue('border-color') || style.getPropertyValue('background')
|
|
10
15
|
|| style.getPropertyValue('background-color') || style.getPropertyValue('text-decoration');
|
|
11
|
-
|
|
16
|
+
const outlineOf = (style) => `${style.getPropertyValue('outline')} ${style.getPropertyValue('outline-style')} ${style.getPropertyValue('outline-width')}`;
|
|
17
|
+
const removesOutline = (outline) => /\bnone\b/.test(outline) || /(^|\s)0(px)?(\s|$)/.test(outline);
|
|
18
|
+
// The elements a focus selector reaches, with the focus state itself
|
|
19
|
+
// taken off, so `a:focus` and `a:focus-visible` both read "a".
|
|
20
|
+
const subjects = (selectorText) => selectorText.split(',')
|
|
21
|
+
.map((part) => part.replace(FOCUS_TOKEN, '').replace(/\s+/g, ' ').trim());
|
|
22
|
+
// Per sheet: the suppressors found, and the later rules that give the
|
|
23
|
+
// same elements an indicator back. A suspect is dismissed only when a
|
|
24
|
+
// LATER rule in the SAME sheet, keyed on :focus or :focus-visible, covers
|
|
25
|
+
// every subject it names and declares a visible outline or a replacement.
|
|
26
|
+
// That is the keyboard-only pattern in its other spelling
|
|
27
|
+
// (`a:focus{outline:0}` then `a:focus-visible{outline:3px solid}`) and
|
|
28
|
+
// the plain two-rule split (`a:focus{outline:none}` then
|
|
29
|
+
// `a:focus{box-shadow:...}`): both provably provide the mode of operation
|
|
30
|
+
// the criterion asks for, and both reviewed until 2026-08-25 (overnight
|
|
31
|
+
// audit). A replacement on a DIFFERENT element (a :focus-within parent)
|
|
32
|
+
// stays a review; whether that box reads as the focused control is a
|
|
33
|
+
// human question.
|
|
34
|
+
let sheet = null;
|
|
12
35
|
const inspect = (rule) => {
|
|
13
36
|
if (!rule.selectorText || !/:focus/.test(rule.selectorText)) return;
|
|
14
37
|
// `:focus:not(:focus-visible) { outline: none }` is the PUBLISHED pattern
|
|
@@ -19,18 +42,29 @@ function focusSuppressors(doc) {
|
|
|
19
42
|
if (/:not\(\s*:focus-visible\s*\)/.test(rule.selectorText)) return;
|
|
20
43
|
const style = rule.style;
|
|
21
44
|
if (!style) return;
|
|
22
|
-
const outline = `${style.getPropertyValue('outline')} ${style.getPropertyValue('outline-style')} ${style.getPropertyValue('outline-width')}`;
|
|
23
|
-
const removesOutline = /\bnone\b/.test(outline) || /(^|\s)0(px)?(\s|$)/.test(outline);
|
|
24
|
-
if (!removesOutline) return;
|
|
25
45
|
// A replacement indicator may not sit in the rule's own style block: any
|
|
26
46
|
// declaration written AFTER a nested rule is parsed into a separate
|
|
27
47
|
// CSSNestedDeclarations child (a style-bearing rule with no selector), so
|
|
28
48
|
// reading only rule.style would report a suppressor that is in fact
|
|
29
49
|
// replaced two lines further down.
|
|
30
|
-
const
|
|
31
|
-
.filter((child) => child.style && !child.selectorText);
|
|
32
|
-
|
|
33
|
-
|
|
50
|
+
const styles = [style, ...[...(rule.cssRules ?? [])]
|
|
51
|
+
.filter((child) => child.style && !child.selectorText).map((child) => child.style)];
|
|
52
|
+
const replaced = styles.some(replacementIn);
|
|
53
|
+
const outlines = styles.map(outlineOf).filter((o) => o.trim());
|
|
54
|
+
const showsOutline = outlines.some((o) => !removesOutline(o));
|
|
55
|
+
if (HAS_FOCUS.test(rule.selectorText) && (replaced || showsOutline)) {
|
|
56
|
+
sheet.providers.push({ order: sheet.order, subjects: subjects(rule.selectorText) });
|
|
57
|
+
}
|
|
58
|
+
if (!outlines.some(removesOutline) || replaced) return;
|
|
59
|
+
sheet.suspects.push({ order: sheet.order, selector: rule.selectorText, subjects: subjects(rule.selectorText) });
|
|
60
|
+
};
|
|
61
|
+
const covered = (subject, provider) => provider.subjects.some((p) => p === subject || p === '*' || p === '');
|
|
62
|
+
const settle = () => {
|
|
63
|
+
for (const suspect of sheet.suspects) {
|
|
64
|
+
const restored = suspect.subjects.every((subject) => sheet.providers.some((provider) =>
|
|
65
|
+
provider.order > suspect.order && covered(subject, provider)));
|
|
66
|
+
if (!restored) suspects.push(suspect.selector);
|
|
67
|
+
}
|
|
34
68
|
};
|
|
35
69
|
// Inspect each rule, THEN descend into any children. Testing `rule.cssRules`
|
|
36
70
|
// first and skipping used to walk straight past every plain style rule:
|
|
@@ -40,12 +74,15 @@ function focusSuppressors(doc) {
|
|
|
40
74
|
// A style rule can both declare and nest, so both halves have to run.
|
|
41
75
|
const scan = (rules) => {
|
|
42
76
|
for (const rule of rules) {
|
|
77
|
+
sheet.order++;
|
|
43
78
|
inspect(rule);
|
|
44
79
|
if (rule.cssRules?.length) scan(rule.cssRules); // @media/@supports/nesting
|
|
45
80
|
}
|
|
46
81
|
};
|
|
47
|
-
for (const
|
|
48
|
-
|
|
82
|
+
for (const styleSheet of doc.styleSheets) {
|
|
83
|
+
sheet = { order: 0, suspects: [], providers: [] };
|
|
84
|
+
try { scan(styleSheet.cssRules); } catch { /* cross-origin sheet */ }
|
|
85
|
+
settle();
|
|
49
86
|
}
|
|
50
87
|
return suspects;
|
|
51
88
|
}
|
|
@@ -10,8 +10,32 @@
|
|
|
10
10
|
// everyone — which is the exception failing to apply rather than rescuing
|
|
11
11
|
// it. Nothing about the surrounding page can change that reading, so there
|
|
12
12
|
// is nothing here for a human to settle.
|
|
13
|
+
//
|
|
14
|
+
// Two matches stay in the review lane because the failure is not provable
|
|
15
|
+
// (2026-08-25 corpus adjudication):
|
|
16
|
+
// · a name wrapped in a paired bracket or quote — "<details>" is a code
|
|
17
|
+
// term that names its destination exactly (one-sided decoration like
|
|
18
|
+
// python.org's CSS-generated ">>>More" prompt still asserts);
|
|
19
|
+
// · a name that is exactly "go" — every corpus hit was a brand logo
|
|
20
|
+
// ("Go" is the language's name), and a brand name identifies the link.
|
|
21
|
+
//
|
|
22
|
+
// A third, beside "go" (2026-08-25 overnight audit): the French entries
|
|
23
|
+
// "plus" and "suite" are ordinary English words and, more to the point,
|
|
24
|
+
// common English plan and product names (a "Plus" tier, a "Suite"). A
|
|
25
|
+
// pricing link named "Plus" identifies its destination the way the Go logo
|
|
26
|
+
// does, so outside a page declared French they review rather than assert.
|
|
27
|
+
// On a link whose closest lang is French the generic reading ("more",
|
|
28
|
+
// "continued") is the only one and the assertion stands.
|
|
13
29
|
import { createLinkPurposeRule } from './2.4.4-link-text-generic.js';
|
|
14
30
|
|
|
31
|
+
const FRENCH_ONLY_ENGLISH_NAMES = new Set(['plus', 'suite']);
|
|
32
|
+
const FRENCH = /^fr(-|$)/i;
|
|
33
|
+
|
|
34
|
+
function mayBeBrandName(normalized, lang) {
|
|
35
|
+
if (normalized === 'go') return true;
|
|
36
|
+
return FRENCH_ONLY_ENGLISH_NAMES.has(normalized) && !FRENCH.test(lang);
|
|
37
|
+
}
|
|
38
|
+
|
|
15
39
|
export default createLinkPurposeRule({
|
|
16
40
|
id: 'link-text-generic-only',
|
|
17
41
|
name: 'Self-sufficient link text',
|
|
@@ -19,9 +43,16 @@ export default createLinkPurposeRule({
|
|
|
19
43
|
tags: ['wcag2aaa', 'wcag249'],
|
|
20
44
|
help: 'Link text alone must identify where the link goes (AAA)',
|
|
21
45
|
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/link-purpose-link-only.html',
|
|
22
|
-
verdict: (name) =>
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
46
|
+
verdict: (name, { provable, normalized, lang }) => {
|
|
47
|
+
if (!provable || mayBeBrandName(normalized, lang)) return {
|
|
48
|
+
status: 'incomplete',
|
|
49
|
+
message: `“${name}” matches a generic link phrase, but may identify its destination anyway: a code term keeps its meaning through the punctuation this comparison drops, and a brand or product name (a logo link named “Go”, a plan named “Plus”) is the destination. Check whether this name, read alone in a list of the page's links, says where it goes.`,
|
|
50
|
+
fix: 'If the name really is generic here, put the destination in the link text, e.g. "Read more about the 2026 budget".',
|
|
51
|
+
};
|
|
52
|
+
return {
|
|
53
|
+
status: 'fail',
|
|
54
|
+
message: `“${name}” gives no purpose on its own. 2.4.9 asks that the link text alone identify where a link goes, without relying on the sentence or heading around it, so anyone reading the page's links as a list can tell them apart.`,
|
|
55
|
+
fix: 'Name the destination in the link text, e.g. "Read more about the 2026 budget".',
|
|
56
|
+
};
|
|
57
|
+
},
|
|
27
58
|
});
|
|
@@ -50,21 +50,30 @@ function visuallyErased(element) {
|
|
|
50
50
|
return false;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
// Form controls a wrapping <label> may contain. Their subtrees are the
|
|
54
|
+
// control's VALUES (a <select>'s options, a <button>'s caption), not the
|
|
55
|
+
// label's text, so a label walk skips them (2026-08-25 overnight audit:
|
|
56
|
+
// "Country <select>" used to read as "Country United KingdomFrance").
|
|
57
|
+
const CONTROL_TAGS = new Set(['input', 'select', 'textarea', 'button', 'meter', 'progress', 'output']);
|
|
58
|
+
|
|
59
|
+
/** The text a sighted user actually sees inside the component (slot-aware).
|
|
60
|
+
* With skipControls, form controls inside the walk are left out: that is
|
|
61
|
+
* the wrapping-<label> case, where only the label's own text is the label. */
|
|
62
|
+
function visibleText(nodes, skipControls = false) {
|
|
55
63
|
let text = '';
|
|
56
64
|
for (const node of nodes) {
|
|
57
65
|
if (node.nodeType === 3 /* TEXT_NODE */) { text += node.textContent; continue; }
|
|
58
66
|
if (node.nodeType !== 1 /* ELEMENT_NODE */) continue;
|
|
59
67
|
const tag = node.tagName.toLowerCase();
|
|
60
68
|
if (tag === 'script' || tag === 'style' || tag === 'noscript' || tag === 'template') continue;
|
|
69
|
+
if (skipControls && CONTROL_TAGS.has(tag)) continue;
|
|
61
70
|
if (node.hasAttribute('hidden') || visuallyErased(node)) continue;
|
|
62
71
|
if (tag === 'slot') {
|
|
63
72
|
const assigned = node.assignedNodes?.() ?? [];
|
|
64
|
-
text += visibleText(assigned.length ? assigned : node.childNodes);
|
|
73
|
+
text += visibleText(assigned.length ? assigned : node.childNodes, skipControls);
|
|
65
74
|
continue;
|
|
66
75
|
}
|
|
67
|
-
text += visibleText(node.childNodes);
|
|
76
|
+
text += visibleText(node.childNodes, skipControls);
|
|
68
77
|
}
|
|
69
78
|
return text;
|
|
70
79
|
}
|
|
@@ -90,7 +99,7 @@ export default {
|
|
|
90
99
|
// form-label's finding, not a 2.5.3 mismatch.
|
|
91
100
|
rawVisible = [...(element.labels ?? [])]
|
|
92
101
|
.filter((label) => !visuallyErased(label))
|
|
93
|
-
.map((label) => visibleText(label.childNodes))
|
|
102
|
+
.map((label) => visibleText(label.childNodes, true))
|
|
94
103
|
.join(' ');
|
|
95
104
|
} else {
|
|
96
105
|
rawVisible = visibleText(element.childNodes);
|
|
@@ -7,7 +7,9 @@ import { createTargetSizeRule } from './2.5.8-target-size.js';
|
|
|
7
7
|
export default createTargetSizeRule({
|
|
8
8
|
id: 'target-size-enhanced',
|
|
9
9
|
name: 'Enhanced target size',
|
|
10
|
-
|
|
10
|
+
// 2.5.5 was introduced in WCAG 2.1, so the versioned AAA tag is the true
|
|
11
|
+
// one (retagged 2026-08-25 overnight audit).
|
|
12
|
+
tags: ['wcag21aaa', 'wcag255'],
|
|
11
13
|
help: 'Interactive targets must be at least 44×44 pixels (AAA)',
|
|
12
14
|
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/target-size-enhanced.html',
|
|
13
15
|
min: 44,
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// WCAG SC 2.5.7 Dragging Movements (Level AA, new in 2.2)
|
|
2
|
+
//
|
|
3
|
+
// Functionality operated by dragging must also work through single-pointer
|
|
4
|
+
// clicks or taps, unless dragging is essential or the behaviour is the
|
|
5
|
+
// user agent's. Custom drag logic is script and mostly invisible to a
|
|
6
|
+
// scan, but one author signal is explicit in the DOM: draggable="true"
|
|
7
|
+
// written on an element. Whether an equivalent no-drag path exists (an
|
|
8
|
+
// up/down button beside a sortable row, a picker beside a slider) is not
|
|
9
|
+
// decidable from markup, so this asks, it never asserts.
|
|
10
|
+
//
|
|
11
|
+
// Scoped to keep the question honest:
|
|
12
|
+
// - only the EXPLICIT attribute counts. Images, links and selections
|
|
13
|
+
// are draggable by browser default (drag-to-desktop, drag-to-text) —
|
|
14
|
+
// that is user-agent behaviour, which the SC itself exempts — so <img>
|
|
15
|
+
// and <a href> are skipped even with the attribute written out: authors
|
|
16
|
+
// write draggable="true" there to re-state or fine-tune a UA default,
|
|
17
|
+
// not to build an interaction.
|
|
18
|
+
// - native range inputs are UA controls: a single click on the track
|
|
19
|
+
// moves the thumb without dragging, which is the alternative already
|
|
20
|
+
// existing. They never carried the attribute anyway.
|
|
21
|
+
// Drag interactions wired purely through pointer events with no draggable
|
|
22
|
+
// attribute are a DISCLOSED gap: listeners are not enumerable from a page.
|
|
23
|
+
export default {
|
|
24
|
+
id: 'drag-alternative',
|
|
25
|
+
name: 'Dragging alternative',
|
|
26
|
+
impact: 'moderate',
|
|
27
|
+
tags: ['wcag22aa', 'wcag257'],
|
|
28
|
+
help: 'Dragging must have a single-pointer alternative',
|
|
29
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/dragging-movements.html',
|
|
30
|
+
selector: '[draggable="true" i]:not(img):not(a[href])',
|
|
31
|
+
evaluate() {
|
|
32
|
+
return {
|
|
33
|
+
status: 'incomplete',
|
|
34
|
+
message: 'This element declares itself draggable, and 2.5.7 requires everything achievable by dragging to also work with single clicks or taps — tremors, switch access and head pointers can press but not drag. Check an equivalent no-drag path exists (buttons to reorder, a menu to move, a field to set the value), unless dragging itself is essential here.',
|
|
35
|
+
fix: 'Add a single-pointer way to do the same thing, e.g. up/down buttons beside a sortable item or a "move to" action in a menu.',
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
};
|