pour-engine 1.19.0 → 1.20.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/AUDIT.html +690 -0
- package/engine/COVERAGE.md +8 -8
- package/engine/README.md +5 -1
- package/engine/rules/index.js +18 -0
- package/engine/rules/wcag/1.1.1-canvas-alt.js +43 -0
- package/engine/rules/wcag/1.1.1-embed-alt.js +42 -0
- package/engine/rules/wcag/2.2.2-video-loop-motion.js +40 -0
- package/engine/rules/wcag/2.4.3-visual-order-divergence.js +92 -0
- package/engine/rules/wcag/3.2.2-on-input-navigation.js +47 -0
- package/engine/rules/wcag/3.3.1-error-message-linkage.js +69 -0
- package/engine/rules/wcag/4.1.2-composite-widget-name.js +32 -0
- package/engine/rules/wcag/4.1.2-summary-name.js +31 -0
- package/engine/wcag22.js +4 -4
- package/package.json +1 -1
package/engine/COVERAGE.md
CHANGED
|
@@ -12,7 +12,7 @@ meaning that only a person can settle, even where a rule decides most real pages
|
|
|
12
12
|
|
|
13
13
|
| SC | Name | Level | Coverage | Rules |
|
|
14
14
|
|---|---|---|---|---|
|
|
15
|
-
| 1.1.1 | [Non-text Content](https://www.w3.org/WAI/WCAG22/Understanding/non-text-content.html) | A | Partially automated | area-alt, image-alt, input-image-alt, object-alt, svg-img-alt |
|
|
15
|
+
| 1.1.1 | [Non-text Content](https://www.w3.org/WAI/WCAG22/Understanding/non-text-content.html) | A | Partially automated | area-alt, canvas-alt, embed-alt, image-alt, input-image-alt, object-alt, svg-img-alt |
|
|
16
16
|
| 1.2.1 | [Audio-only and Video-only (Prerecorded)](https://www.w3.org/WAI/WCAG22/Understanding/audio-only-and-video-only-prerecorded.html) | A | Manual review | — |
|
|
17
17
|
| 1.2.2 | [Captions (Prerecorded)](https://www.w3.org/WAI/WCAG22/Understanding/captions-prerecorded.html) | A | Partially automated | media-captions |
|
|
18
18
|
| 1.2.3 | [Audio Description or Media Alternative (Prerecorded)](https://www.w3.org/WAI/WCAG22/Understanding/audio-description-or-media-alternative-prerecorded.html) | A | Manual review | — |
|
|
@@ -51,7 +51,7 @@ meaning that only a person can settle, even where a rule decides most real pages
|
|
|
51
51
|
| 2.1.3 | [Keyboard (No Exception)](https://www.w3.org/WAI/WCAG22/Understanding/keyboard-no-exception.html) | AAA | Partially automated | scrollable-region-focusable |
|
|
52
52
|
| 2.1.4 | [Character Key Shortcuts](https://www.w3.org/WAI/WCAG22/Understanding/character-key-shortcuts.html) | A | Manual review | — |
|
|
53
53
|
| 2.2.1 | [Timing Adjustable](https://www.w3.org/WAI/WCAG22/Understanding/timing-adjustable.html) | A | Partially automated | meta-refresh |
|
|
54
|
-
| 2.2.2 | [Pause, Stop, Hide](https://www.w3.org/WAI/WCAG22/Understanding/pause-stop-hide.html) | A | Partially automated | pause-stop-hide |
|
|
54
|
+
| 2.2.2 | [Pause, Stop, Hide](https://www.w3.org/WAI/WCAG22/Understanding/pause-stop-hide.html) | A | Partially automated | pause-stop-hide, video-loop-motion |
|
|
55
55
|
| 2.2.3 | [No Timing](https://www.w3.org/WAI/WCAG22/Understanding/no-timing.html) | AAA | Manual review | — |
|
|
56
56
|
| 2.2.4 | [Interruptions](https://www.w3.org/WAI/WCAG22/Understanding/interruptions.html) | AAA | Manual review | — |
|
|
57
57
|
| 2.2.5 | [Re-authenticating](https://www.w3.org/WAI/WCAG22/Understanding/re-authenticating.html) | AAA | Manual review | — |
|
|
@@ -61,7 +61,7 @@ meaning that only a person can settle, even where a rule decides most real pages
|
|
|
61
61
|
| 2.3.3 | [Animation from Interactions](https://www.w3.org/WAI/WCAG22/Understanding/animation-from-interactions.html) | AAA | Manual review | — |
|
|
62
62
|
| 2.4.1 | [Bypass Blocks](https://www.w3.org/WAI/WCAG22/Understanding/bypass-blocks.html) | A | Partially automated | bypass-blocks |
|
|
63
63
|
| 2.4.2 | [Page Titled](https://www.w3.org/WAI/WCAG22/Understanding/page-titled.html) | A | Partially automated | document-title |
|
|
64
|
-
| 2.4.3 | [Focus Order](https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html) | A |
|
|
64
|
+
| 2.4.3 | [Focus Order](https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html) | A | Partially automated | visual-order-divergence |
|
|
65
65
|
| 2.4.4 | [Link Purpose (In Context)](https://www.w3.org/WAI/WCAG22/Understanding/link-purpose-in-context.html) | A | Partially automated | area-alt, link-name, link-text-generic |
|
|
66
66
|
| 2.4.5 | [Multiple Ways](https://www.w3.org/WAI/WCAG22/Understanding/multiple-ways.html) | AA | Manual review | — |
|
|
67
67
|
| 2.4.6 | [Headings and Labels](https://www.w3.org/WAI/WCAG22/Understanding/headings-and-labels.html) | AA | Manual review | — |
|
|
@@ -91,13 +91,13 @@ meaning that only a person can settle, even where a rule decides most real pages
|
|
|
91
91
|
| 3.1.4 | [Abbreviations](https://www.w3.org/WAI/WCAG22/Understanding/abbreviations.html) | AAA | Manual review | — |
|
|
92
92
|
| 3.1.5 | [Reading Level](https://www.w3.org/WAI/WCAG22/Understanding/reading-level.html) | AAA | Manual review | — |
|
|
93
93
|
| 3.1.6 | [Pronunciation](https://www.w3.org/WAI/WCAG22/Understanding/pronunciation.html) | AAA | Manual review | — |
|
|
94
|
-
| 3.2.1 | [On Focus](https://www.w3.org/WAI/WCAG22/Understanding/on-focus.html) | A |
|
|
95
|
-
| 3.2.2 | [On Input](https://www.w3.org/WAI/WCAG22/Understanding/on-input.html) | A |
|
|
94
|
+
| 3.2.1 | [On Focus](https://www.w3.org/WAI/WCAG22/Understanding/on-focus.html) | A | Partially automated | on-input-navigation |
|
|
95
|
+
| 3.2.2 | [On Input](https://www.w3.org/WAI/WCAG22/Understanding/on-input.html) | A | Partially automated | on-input-navigation |
|
|
96
96
|
| 3.2.3 | [Consistent Navigation](https://www.w3.org/WAI/WCAG22/Understanding/consistent-navigation.html) | AA | Manual review | — |
|
|
97
97
|
| 3.2.4 | [Consistent Identification](https://www.w3.org/WAI/WCAG22/Understanding/consistent-identification.html) | AA | Manual review | — |
|
|
98
98
|
| 3.2.5 | [Change on Request](https://www.w3.org/WAI/WCAG22/Understanding/change-on-request.html) | AAA | Manual review | — |
|
|
99
99
|
| 3.2.6 | [Consistent Help](https://www.w3.org/WAI/WCAG22/Understanding/consistent-help.html) | A | Manual review | — |
|
|
100
|
-
| 3.3.1 | [Error Identification](https://www.w3.org/WAI/WCAG22/Understanding/error-identification.html) | A |
|
|
100
|
+
| 3.3.1 | [Error Identification](https://www.w3.org/WAI/WCAG22/Understanding/error-identification.html) | A | Partially automated | error-message-linkage |
|
|
101
101
|
| 3.3.2 | [Labels or Instructions](https://www.w3.org/WAI/WCAG22/Understanding/labels-or-instructions.html) | A | Partially automated | form-label |
|
|
102
102
|
| 3.3.3 | [Error Suggestion](https://www.w3.org/WAI/WCAG22/Understanding/error-suggestion.html) | AA | Manual review | — |
|
|
103
103
|
| 3.3.4 | [Error Prevention (Legal, Financial, Data)](https://www.w3.org/WAI/WCAG22/Understanding/error-prevention-legal-financial-data.html) | AA | Manual review | — |
|
|
@@ -111,7 +111,7 @@ meaning that only a person can settle, even where a rule decides most real pages
|
|
|
111
111
|
|
|
112
112
|
| SC | Name | Level | Coverage | Rules |
|
|
113
113
|
|---|---|---|---|---|
|
|
114
|
-
| 4.1.2 | [Name, Role, Value](https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html) | A | Partially automated | input-image-alt, form-label, link-name, aria-allowed-attr, aria-attr-valid, aria-field-name, aria-hidden-focus, aria-valid-refs, button-name, dialog-name, frame-title, label-for-valid, nested-interactive, role-required-aria, valid-role |
|
|
114
|
+
| 4.1.2 | [Name, Role, Value](https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html) | A | Partially automated | input-image-alt, form-label, link-name, aria-allowed-attr, aria-attr-valid, aria-field-name, aria-hidden-focus, aria-label-misuse, aria-valid-refs, button-name, composite-widget-name, dialog-name, frame-title, label-for-valid, nested-interactive, role-required-aria, summary-name, valid-role |
|
|
115
115
|
| 4.1.3 | [Status Messages](https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html) | AA | Manual review | — |
|
|
116
116
|
|
|
117
|
-
**Totals:**
|
|
117
|
+
**Totals:** 34 partially automated · 52 manual — with 82 rules.
|
package/engine/README.md
CHANGED
|
@@ -8,7 +8,11 @@ the product's only engine.
|
|
|
8
8
|
Section 508, EN 301 549, or other standards. The full WCAG 2.2 catalog (all
|
|
9
9
|
86 success criteria) lives in `wcag22.js`, and `COVERAGE.md` (generated by
|
|
10
10
|
`node scripts/wcag-coverage.js`) shows exactly which criteria are automated,
|
|
11
|
-
partially automated, or need human review.
|
|
11
|
+
partially automated, or need human review. `AUDIT.html` is the latest full
|
|
12
|
+
spec-sweep audit of every rule against the spec — over-violation,
|
|
13
|
+
under-violation, and what the engine cannot test. It is a self-contained
|
|
14
|
+
page: open it straight from disk in any browser. Regenerated by running
|
|
15
|
+
the `spec-sweep` skill.
|
|
12
16
|
|
|
13
17
|
## Provenance
|
|
14
18
|
|
package/engine/rules/index.js
CHANGED
|
@@ -84,6 +84,16 @@ import reflow from './wcag/1.4.10-reflow.js';
|
|
|
84
84
|
import focusVisible from './wcag/2.4.7-focus-visible.js';
|
|
85
85
|
import focusNotObscured from './wcag/2.4.11-focus-not-obscured.js';
|
|
86
86
|
import authFieldObstruction from './wcag/3.3.8-auth-field-obstruction.js';
|
|
87
|
+
// 2026-08-10 spec-sweep batch: the six missing failure families plus the two
|
|
88
|
+
// state-dependent heuristics the sweep judged automatable (see COVERAGE.md).
|
|
89
|
+
import embedAlt from './wcag/1.1.1-embed-alt.js';
|
|
90
|
+
import canvasAlt from './wcag/1.1.1-canvas-alt.js';
|
|
91
|
+
import videoLoopMotion from './wcag/2.2.2-video-loop-motion.js';
|
|
92
|
+
import visualOrderDivergence from './wcag/2.4.3-visual-order-divergence.js';
|
|
93
|
+
import onInputNavigation from './wcag/3.2.2-on-input-navigation.js';
|
|
94
|
+
import errorMessageLinkage from './wcag/3.3.1-error-message-linkage.js';
|
|
95
|
+
import compositeWidgetName from './wcag/4.1.2-composite-widget-name.js';
|
|
96
|
+
import summaryName from './wcag/4.1.2-summary-name.js';
|
|
87
97
|
|
|
88
98
|
// --- Best practice ---
|
|
89
99
|
import headingOrder from './best-practice/heading-order.js';
|
|
@@ -171,6 +181,14 @@ export default [
|
|
|
171
181
|
focusVisible,
|
|
172
182
|
focusNotObscured,
|
|
173
183
|
authFieldObstruction,
|
|
184
|
+
embedAlt,
|
|
185
|
+
canvasAlt,
|
|
186
|
+
videoLoopMotion,
|
|
187
|
+
visualOrderDivergence,
|
|
188
|
+
onInputNavigation,
|
|
189
|
+
errorMessageLinkage,
|
|
190
|
+
compositeWidgetName,
|
|
191
|
+
summaryName,
|
|
174
192
|
contrastEnhanced,
|
|
175
193
|
targetSizeEnhanced,
|
|
176
194
|
headingOrder,
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// WCAG SC 1.1.1 Non-text Content (Level A)
|
|
2
|
+
// A roleless <canvas> with no name and no fallback content is the default
|
|
3
|
+
// output of every chart library, and it reaches assistive technology as
|
|
4
|
+
// nothing at all. Whether that is a failure is exactly the question the
|
|
5
|
+
// roleless-<svg> branch of svg-img-alt already models: a WebGL backdrop is
|
|
6
|
+
// decorative, a revenue chart is the content of the page, and no static
|
|
7
|
+
// pass can tell them apart. Same shape, same verdict — review, with the
|
|
8
|
+
// same escape hatches (fallback content, a named enclosing control, or an
|
|
9
|
+
// explicit decorative/labelled role).
|
|
10
|
+
//
|
|
11
|
+
// <canvas role="img"> without a name is already a hard fail via
|
|
12
|
+
// svg-img-alt's [role="img"] arm; this rule only owns the silent default.
|
|
13
|
+
// A size floor keeps sparkline-and-effect noise out: tiny canvases are
|
|
14
|
+
// bullets and flourishes, and flagging them all would bury the chart that
|
|
15
|
+
// matters.
|
|
16
|
+
export default {
|
|
17
|
+
id: 'canvas-alt',
|
|
18
|
+
impact: 'moderate',
|
|
19
|
+
tags: ['wcag2a', 'wcag111'],
|
|
20
|
+
help: 'Meaningful <canvas> graphics need an accessible name or fallback',
|
|
21
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/non-text-content.html',
|
|
22
|
+
selector: 'canvas:not([role])',
|
|
23
|
+
evaluate(element, { accessibleName }) {
|
|
24
|
+
if (accessibleName(element)) return { status: 'pass' };
|
|
25
|
+
// Fallback content inside <canvas> is what AT reads — but chart
|
|
26
|
+
// libraries put their bootstrap <script> inside the canvas too, and
|
|
27
|
+
// script text is not fallback anyone hears.
|
|
28
|
+
const fallback = [...element.childNodes]
|
|
29
|
+
.filter((node) => !/^(SCRIPT|STYLE|TEMPLATE)$/.test(node.nodeName))
|
|
30
|
+
.map((node) => node.textContent).join('').trim();
|
|
31
|
+
if (fallback) return { status: 'pass' };
|
|
32
|
+
// Icon-work inside a control the name already covers.
|
|
33
|
+
const control = element.closest('a[href], button, [role="button"], [role="link"]');
|
|
34
|
+
if (control && accessibleName(control)) return { status: 'pass' };
|
|
35
|
+
const rect = element.getBoundingClientRect();
|
|
36
|
+
if (rect.width < 32 || rect.height < 32) return { status: 'pass' };
|
|
37
|
+
return {
|
|
38
|
+
status: 'incomplete',
|
|
39
|
+
message: 'This canvas has no accessible name, no fallback content, and is not marked decorative — if it draws a chart or diagram, screen reader users get nothing at all.',
|
|
40
|
+
fix: 'If decorative, add aria-hidden="true". If meaningful, add role="img" with aria-label="…", or put a text alternative inside the <canvas> as fallback content.',
|
|
41
|
+
};
|
|
42
|
+
},
|
|
43
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// WCAG SC 1.1.1 Non-text Content (Level A)
|
|
2
|
+
// <embed> is the last uncovered plugin-content element: img, svg, area,
|
|
3
|
+
// object and input[type=image] all have name rules, and a nameless <embed>
|
|
4
|
+
// is the same provable gap — assistive technology can tell users nothing
|
|
5
|
+
// about what the plugin renders.
|
|
6
|
+
//
|
|
7
|
+
// One honest asymmetry with object-alt: an embed that declares TEXT content
|
|
8
|
+
// (type="text/…") embeds a document, not non-text content, so 1.1.1's
|
|
9
|
+
// applicability is itself in question there. That case asks instead of
|
|
10
|
+
// asserting — the user still deserves to know what the embedded document
|
|
11
|
+
// is, but no criterion provably fails.
|
|
12
|
+
export default {
|
|
13
|
+
id: 'embed-alt',
|
|
14
|
+
impact: 'serious',
|
|
15
|
+
tags: ['wcag2a', 'wcag111'],
|
|
16
|
+
help: '<embed> needs an accessible name',
|
|
17
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/non-text-content.html',
|
|
18
|
+
selector: 'embed',
|
|
19
|
+
evaluate(element, { accessibleName }) {
|
|
20
|
+
if (accessibleName(element)) return { status: 'pass' };
|
|
21
|
+
const type = (element.getAttribute('type') ?? '').trim().toLowerCase();
|
|
22
|
+
const src = (element.getAttribute('src') ?? '').split(/[?#]/)[0].toLowerCase();
|
|
23
|
+
// Document formats: text/*, PDF, XHTML — and typeless embeds whose src
|
|
24
|
+
// extension says document. All carry text, so 1.1.1's applicability is
|
|
25
|
+
// the question, and the question goes to a human.
|
|
26
|
+
const isDocument = type.startsWith('text/')
|
|
27
|
+
|| type === 'application/pdf' || type === 'application/xhtml+xml'
|
|
28
|
+
|| (!type && /\.(html?|xhtml|pdf|txt)$/.test(src));
|
|
29
|
+
if (isDocument) {
|
|
30
|
+
return {
|
|
31
|
+
status: 'incomplete',
|
|
32
|
+
message: 'This <embed> renders a text document but has no accessible name — assistive technology users get no indication of what the embedded document is. Check it is identified in the surrounding context.',
|
|
33
|
+
fix: 'Add title="…" naming the embedded document.',
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
status: 'fail',
|
|
38
|
+
message: 'This <embed> has no accessible name — screen readers cannot tell users what the embedded content is.',
|
|
39
|
+
fix: 'Add title="…" (or aria-label) describing what the embed shows.',
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// WCAG SC 2.2.2 Pause, Stop, Hide (Level A)
|
|
2
|
+
// The muted background hero video. audio-control (1.4.2) rightly passes it:
|
|
3
|
+
// that criterion is about SOUND, and a muted element makes none. But
|
|
4
|
+
// autoplay + loop is moving content that starts automatically and never
|
|
5
|
+
// ends, which settles clauses 1 and 2 of 2.2.2 from the markup alone —
|
|
6
|
+
// exactly as an infinite CSS animation does. Clause 3's pause mechanism may
|
|
7
|
+
// be any control anywhere on the page (a hero often has one), so the
|
|
8
|
+
// finding goes to a human with the evidence attached, never asserted: the
|
|
9
|
+
// same verdict discipline as pause-stop-hide's ticker branch.
|
|
10
|
+
//
|
|
11
|
+
// A <video controls> ships its own pause button and is out of the selector.
|
|
12
|
+
// Icon-scale playback (tiny inline previews) is left alone for the same
|
|
13
|
+
// reason pause-stop-hide skips spinners: flagging every small moving
|
|
14
|
+
// thumbnail would bury the full-bleed hero that matters. Silence there is
|
|
15
|
+
// abstaining, not clearing.
|
|
16
|
+
export default {
|
|
17
|
+
id: 'video-loop-motion',
|
|
18
|
+
impact: 'serious',
|
|
19
|
+
tags: ['wcag2a', 'wcag222'],
|
|
20
|
+
help: 'Looping autoplay video must be pausable',
|
|
21
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/pause-stop-hide.html',
|
|
22
|
+
selector: 'video[autoplay][loop]:not([controls])',
|
|
23
|
+
// Movement is a visual matter: an aria-hidden hero still moves on screen.
|
|
24
|
+
visibility: 'visual',
|
|
25
|
+
evaluate(element) {
|
|
26
|
+
// Icon scale means SMALL, not thin: a 1200×60 animated banner strip is
|
|
27
|
+
// a ticker, and only a video small on both axes is spinner-class.
|
|
28
|
+
const rect = element.getBoundingClientRect();
|
|
29
|
+
if (rect.width < 64 && rect.height < 64) return { status: 'pass' };
|
|
30
|
+
// Loaded but not playing: either the browser refused the autoplay or
|
|
31
|
+
// something on the page already paused it — in both cases nothing is
|
|
32
|
+
// moving, and a paused-by-a-control video is the mechanism working.
|
|
33
|
+
if (element.paused && element.readyState >= 2) return { status: 'pass' };
|
|
34
|
+
return {
|
|
35
|
+
status: 'incomplete',
|
|
36
|
+
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.',
|
|
37
|
+
fix: 'Add controls, wire a visible pause button to it, or drop autoplay — and honour prefers-reduced-motion.',
|
|
38
|
+
};
|
|
39
|
+
},
|
|
40
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// WCAG SC 2.4.3 Focus Order (Level A) — the CSS-reordering slice
|
|
2
|
+
// Whether a focus order "preserves meaning and operability" is a human
|
|
3
|
+
// judgment over the whole page, and stays in the manual checklist. But one
|
|
4
|
+
// documented failure shape is pure geometry: focusable siblings laid out by
|
|
5
|
+
// flex/grid where CSS `order` or a reversed flow direction makes the VISUAL
|
|
6
|
+
// order diverge from the DOM order that Tab actually follows. F44's
|
|
7
|
+
// substance — sequences that jump against the layout — with the evidence
|
|
8
|
+
// measurable.
|
|
9
|
+
//
|
|
10
|
+
// Deliberately narrow so it cannot fire on ordinary pages:
|
|
11
|
+
// - only siblings in the SAME flex/grid container are compared, so
|
|
12
|
+
// legitimate responsive reflow between separate regions never trips it;
|
|
13
|
+
// - only when the container/children actually use `order` or `*-reverse`
|
|
14
|
+
// — plain wrapping, floats and absolute positioning are not evidence;
|
|
15
|
+
// - the verdict is review, not violation: a reversed row of equivalent
|
|
16
|
+
// chips may preserve meaning perfectly well; that call is the human's.
|
|
17
|
+
//
|
|
18
|
+
// The narrowness has a known cost, accepted deliberately: focusables
|
|
19
|
+
// wrapped one level deep (cards — flex container > div > a) never meet the
|
|
20
|
+
// container as direct siblings and are not examined, and grid's native
|
|
21
|
+
// reordering (explicit grid-row/column placement, grid-template-areas,
|
|
22
|
+
// grid-auto-flow: dense) reorders without `order` or `*-reverse` and is not
|
|
23
|
+
// treated as evidence. Both are honest gaps to widen later with measured
|
|
24
|
+
// cases, not silently.
|
|
25
|
+
const FLEXGRID = /^(inline-)?(flex|grid)$/;
|
|
26
|
+
|
|
27
|
+
/** DOM-order focusable children grouped per flex/grid parent. */
|
|
28
|
+
function tabbable(element) {
|
|
29
|
+
if (element.disabled || element.tabIndex < 0) return false;
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
|
|
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
|
+
function visualSequence(entries, rightToLeft) {
|
|
36
|
+
const rows = [];
|
|
37
|
+
for (const entry of [...entries].sort((a, b) => a.rect.top - b.rect.top)) {
|
|
38
|
+
const row = rows.find((candidates) => {
|
|
39
|
+
const first = candidates[0].rect;
|
|
40
|
+
const overlap = Math.min(first.bottom, entry.rect.bottom) - Math.max(first.top, entry.rect.top);
|
|
41
|
+
return overlap > Math.min(first.height, entry.rect.height) / 2;
|
|
42
|
+
});
|
|
43
|
+
if (row) row.push(entry); else rows.push([entry]);
|
|
44
|
+
}
|
|
45
|
+
return rows.flatMap((row) => row.sort((a, b) =>
|
|
46
|
+
rightToLeft ? b.rect.right - a.rect.right : a.rect.left - b.rect.left));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export default {
|
|
50
|
+
id: 'visual-order-divergence',
|
|
51
|
+
impact: 'moderate',
|
|
52
|
+
tags: ['wcag2a', 'wcag243'],
|
|
53
|
+
help: 'CSS reordering should not make Tab jump against the visual order',
|
|
54
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html',
|
|
55
|
+
selector: 'a[href], button, input:not([type="hidden"]), select, textarea, summary, [tabindex]',
|
|
56
|
+
evaluateAll(elements) {
|
|
57
|
+
const outcomes = new Map();
|
|
58
|
+
const groups = new Map();
|
|
59
|
+
for (const element of elements) {
|
|
60
|
+
if (!tabbable(element)) continue;
|
|
61
|
+
const parent = element.parentElement;
|
|
62
|
+
if (!parent) continue;
|
|
63
|
+
if (!groups.has(parent)) groups.set(parent, []);
|
|
64
|
+
groups.get(parent).push(element);
|
|
65
|
+
}
|
|
66
|
+
for (const [parent, children] of groups) {
|
|
67
|
+
if (children.length < 2) continue;
|
|
68
|
+
const parentStyle = getComputedStyle(parent);
|
|
69
|
+
if (!FLEXGRID.test(parentStyle.display)) continue;
|
|
70
|
+
const reversed = /-reverse$/.test(parentStyle.flexDirection);
|
|
71
|
+
const entries = children
|
|
72
|
+
.map((element) => ({ element, style: getComputedStyle(element) }))
|
|
73
|
+
// Out-of-flow children have no place in the container's sequence.
|
|
74
|
+
.filter(({ style }) => style.position !== 'absolute' && style.position !== 'fixed')
|
|
75
|
+
.map((entry) => ({ ...entry, rect: entry.element.getBoundingClientRect() }))
|
|
76
|
+
.filter(({ rect }) => rect.width > 0 && rect.height > 0);
|
|
77
|
+
if (entries.length < 2) continue;
|
|
78
|
+
const usesOrder = entries.some(({ style }) => style.order !== '0');
|
|
79
|
+
if (!reversed && !usesOrder) continue;
|
|
80
|
+
const visual = visualSequence(entries, parentStyle.direction === 'rtl');
|
|
81
|
+
const diverges = visual.some((entry, i) => entry !== entries[i]);
|
|
82
|
+
if (!diverges) continue;
|
|
83
|
+
const cause = reversed ? `flex-direction: ${parentStyle.flexDirection}` : 'CSS order';
|
|
84
|
+
outcomes.set(entries[0].element, {
|
|
85
|
+
status: 'incomplete',
|
|
86
|
+
message: `Keyboard focus moves through these ${entries.length} controls in DOM order, but ${cause} arranges them differently on screen — Tab will jump against the visual layout. Check the focus sequence still preserves meaning and operability.`,
|
|
87
|
+
fix: 'Reorder the source to match the visual order instead of reordering with CSS, or confirm the divergence is harmless here.',
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return elements.map((element) => outcomes.get(element) ?? { status: 'pass' });
|
|
91
|
+
},
|
|
92
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// WCAG SC 3.2.2 On Input (Level A) — plus the inline slice of 3.2.1 On Focus
|
|
2
|
+
// The classic jump menu: <select onchange="location.href=this.value">.
|
|
3
|
+
// Changing a setting must not change the user's context unless they were
|
|
4
|
+
// told beforehand (3.2.2), and merely receiving focus must never do so at
|
|
5
|
+
// all (3.2.1).
|
|
6
|
+
//
|
|
7
|
+
// Only INLINE handlers are visible to a DOM audit — addEventListener
|
|
8
|
+
// registrations can't be enumerated — and only a navigation-shaped handler
|
|
9
|
+
// body is evidence. That makes this a rare, legacy, high-precision
|
|
10
|
+
// signature: pages that never use inline handlers produce zero findings.
|
|
11
|
+
// Both branches ask rather than assert: the handler string proves a
|
|
12
|
+
// navigation call EXISTS in the code path, not that it runs unconditionally
|
|
13
|
+
// (it may sit behind a confirm(), a flag, or a selected-value check), and
|
|
14
|
+
// for 3.2.2 whether the user "has been advised beforehand" is text on the
|
|
15
|
+
// page only a human can weigh.
|
|
16
|
+
// Writes and calls only: reading location (analytics, hash checks) is not
|
|
17
|
+
// navigation, so the pattern demands an assignment to location or one of
|
|
18
|
+
// the navigating members/calls.
|
|
19
|
+
const NAVIGATES = /\blocation\s*(=|\.\s*(href|assign|replace|reload))|\.(submit|requestSubmit)\s*\(|window\.open\s*\(/;
|
|
20
|
+
|
|
21
|
+
export default {
|
|
22
|
+
id: 'on-input-navigation',
|
|
23
|
+
impact: 'serious',
|
|
24
|
+
tags: ['wcag2a', 'wcag322', 'wcag321'],
|
|
25
|
+
help: 'Changing or focusing a control must not unexpectedly change context',
|
|
26
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/on-input.html',
|
|
27
|
+
selector: 'select[onchange], input[onchange], textarea[onchange], select[oninput], input[oninput], textarea[oninput], [onfocus]',
|
|
28
|
+
evaluate(element) {
|
|
29
|
+
const onfocus = element.getAttribute('onfocus');
|
|
30
|
+
if (onfocus && NAVIGATES.test(onfocus)) {
|
|
31
|
+
return {
|
|
32
|
+
status: 'incomplete',
|
|
33
|
+
message: 'Focusing this element appears to navigate or submit (its onfocus handler reaches for location/submit). A change of context on focus alone fails WCAG 3.2.1 — check what the handler actually does when focus lands.',
|
|
34
|
+
fix: 'Trigger navigation from activation (click/Enter), never from focus.',
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const onchange = element.getAttribute('onchange') ?? element.getAttribute('oninput');
|
|
38
|
+
if (onchange && NAVIGATES.test(onchange)) {
|
|
39
|
+
return {
|
|
40
|
+
status: 'incomplete',
|
|
41
|
+
message: 'Changing this control appears to navigate or submit (its onchange handler reaches for location/submit). WCAG 3.2.2 allows that only when users are told beforehand — check the page says so before the control.',
|
|
42
|
+
fix: 'Describe the behaviour before the control, or navigate from an explicit Go button instead of the change event.',
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
return { status: 'pass' };
|
|
46
|
+
},
|
|
47
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// WCAG SC 3.3.1 Error Identification (Level A)
|
|
2
|
+
// "If an input error is automatically detected, the item that is in error
|
|
3
|
+
// is identified and the error is described to the user in text."
|
|
4
|
+
//
|
|
5
|
+
// A snapshot audit only ever sees this criterion in force when the page is
|
|
6
|
+
// captured mid-error — a pristine form has no detected error and nothing to
|
|
7
|
+
// judge. That makes the rule state-dependent and quiet by construction:
|
|
8
|
+
// aria-invalid="true" IS the page saying "an error was detected here", and
|
|
9
|
+
// from that admission two things become checkable.
|
|
10
|
+
//
|
|
11
|
+
// - aria-errormessage must point at an element that exists, is visible,
|
|
12
|
+
// and says something (ARIA 1.2 requires the target to be displayed and
|
|
13
|
+
// pertinent while the field is invalid). A broken or empty target is a
|
|
14
|
+
// real authoring defect — the reference AT follows leads nowhere — but
|
|
15
|
+
// it is NOT a provable 3.3.1 failure: the Understanding document is
|
|
16
|
+
// explicit that programmatic association is not required, so adjacent
|
|
17
|
+
// visible text (which a machine cannot tie to the field) may satisfy
|
|
18
|
+
// the criterion on its own. Both shapes therefore go to review, with
|
|
19
|
+
// the broken reference named when present.
|
|
20
|
+
//
|
|
21
|
+
// aria-errormessage on a field that is NOT in an invalid state is inert:
|
|
22
|
+
// user agents expose it only while the field is invalid, so a dangling
|
|
23
|
+
// reference there breaks nothing a user receives (the engine's standing
|
|
24
|
+
// inertness doctrine says pass, not assert). Invalid means any aria-invalid
|
|
25
|
+
// value except false/undefined — "spelling" and "grammar" count.
|
|
26
|
+
export default {
|
|
27
|
+
id: 'error-message-linkage',
|
|
28
|
+
impact: 'serious',
|
|
29
|
+
tags: ['wcag2a', 'wcag331'],
|
|
30
|
+
help: 'Fields marked invalid need their error described in text',
|
|
31
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/error-identification.html',
|
|
32
|
+
selector: '[aria-invalid="true"], [aria-invalid="spelling"], [aria-invalid="grammar"], [aria-errormessage]',
|
|
33
|
+
evaluate(element, { isVisible, accessibleName }) {
|
|
34
|
+
const root = element.getRootNode();
|
|
35
|
+
const state = element.getAttribute('aria-invalid');
|
|
36
|
+
const invalid = !!state && state !== 'false' && state !== 'undefined';
|
|
37
|
+
const targetsOf = (attribute) => (element.getAttribute(attribute) ?? '')
|
|
38
|
+
.trim().split(/\s+/).filter(Boolean)
|
|
39
|
+
.map((id) => root.getElementById?.(id))
|
|
40
|
+
.filter(Boolean);
|
|
41
|
+
// An image-of-text error message still describes the error; textContent
|
|
42
|
+
// alone would miss it, so fall back to the accessible-name walk.
|
|
43
|
+
const hasText = (targets) => targets.some((target) =>
|
|
44
|
+
isVisible(target) && (target.textContent.trim() || accessibleName(target)));
|
|
45
|
+
|
|
46
|
+
if (element.hasAttribute('aria-errormessage')) {
|
|
47
|
+
if (!invalid) return { status: 'pass' }; // inert until the field is invalid
|
|
48
|
+
const targets = targetsOf('aria-errormessage');
|
|
49
|
+
if (hasText(targets)) return { status: 'pass' };
|
|
50
|
+
return {
|
|
51
|
+
status: 'incomplete',
|
|
52
|
+
message: (targets.length
|
|
53
|
+
? 'This field is marked invalid, but the element its aria-errormessage points at is empty or hidden — screen readers follow the reference and find no text.'
|
|
54
|
+
: 'This field is marked invalid, but its aria-errormessage points at nothing — screen readers follow the reference and find no text.')
|
|
55
|
+
+ ' The error must be described in text: check a visible message exists, and fix the reference so it reaches assistive technology too.',
|
|
56
|
+
fix: 'Point aria-errormessage at the visible element containing the error text, and keep that element populated while the field is invalid.',
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
// aria-invalid="true" with no aria-errormessage: a described-by message
|
|
60
|
+
// satisfies the criterion; with no linkage at all, the error may still
|
|
61
|
+
// sit in adjacent text only a human can connect to the field.
|
|
62
|
+
if (hasText(targetsOf('aria-describedby'))) return { status: 'pass' };
|
|
63
|
+
return {
|
|
64
|
+
status: 'incomplete',
|
|
65
|
+
message: 'This field is marked invalid, but no error text is programmatically linked to it. The error must be described to the user in text — check a message exists, and associate it with aria-describedby or aria-errormessage so screen readers announce it with the field.',
|
|
66
|
+
fix: 'Add the error text near the field and reference it with aria-describedby (or aria-errormessage).',
|
|
67
|
+
};
|
|
68
|
+
},
|
|
69
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// WCAG SC 4.1.2 Name, Role, Value (Level A)
|
|
2
|
+
// The icon-only tab strip. Items of composite widgets — tabs, menu items,
|
|
3
|
+
// options, tree items — take their name from their contents per ARIA 1.2,
|
|
4
|
+
// and every one of these roles requires an accessible name. So an item
|
|
5
|
+
// whose contents render no text and whose author supplied no label is
|
|
6
|
+
// provably announced as an unnamed control: "tab", selected, of what?
|
|
7
|
+
//
|
|
8
|
+
// The container roles (tablist, menu, listbox, tree) are name-recommended
|
|
9
|
+
// rather than required and are deliberately not judged here; the items are
|
|
10
|
+
// where the binary, assertable failure lives.
|
|
11
|
+
export default {
|
|
12
|
+
id: 'composite-widget-name',
|
|
13
|
+
impact: 'serious',
|
|
14
|
+
tags: ['wcag2a', 'wcag412'],
|
|
15
|
+
help: 'Tabs, menu items, options and tree items need an accessible name',
|
|
16
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
|
|
17
|
+
selector: '[role="tab"], [role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], [role="option"], [role="treeitem"]',
|
|
18
|
+
evaluate(element, { accessibleName }) {
|
|
19
|
+
// <button role="tab"> and <a href role="tab"> are already selected by
|
|
20
|
+
// button-name/link-name (their selectors match the tag regardless of
|
|
21
|
+
// the role override) — one nameless control must not become two
|
|
22
|
+
// findings, so this rule owns only the div/span/li-style hosts.
|
|
23
|
+
if (element.matches('button, a[href], input, select, textarea, summary')) return { status: 'pass' };
|
|
24
|
+
if (accessibleName(element)) return { status: 'pass' };
|
|
25
|
+
const role = element.getAttribute('role');
|
|
26
|
+
return {
|
|
27
|
+
status: 'fail',
|
|
28
|
+
message: `This ${role} has no accessible name — a screen reader announces an unnamed ${role}, and users cannot tell what choosing it does.`,
|
|
29
|
+
fix: 'Put text inside the element, or add aria-label="…" for icon-only items.',
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// WCAG SC 4.1.2 Name, Role, Value (Level A)
|
|
2
|
+
// <summary> is the interactive disclosure control of <details> — focusable,
|
|
3
|
+
// activatable, exposed to AT as a button-like widget — and button-name's
|
|
4
|
+
// selector never reaches it. An icon-only summary (chevron glyph, no text,
|
|
5
|
+
// no label) announces as an unnamed control.
|
|
6
|
+
//
|
|
7
|
+
// Only the FIRST summary child of a <details> is the control: the HTML spec
|
|
8
|
+
// gives any further summary elements, and summaries outside <details>, no
|
|
9
|
+
// special behaviour — they are plain content and owe no name. A <details>
|
|
10
|
+
// with no summary at all also passes: the browser supplies its own default
|
|
11
|
+
// label ("Details").
|
|
12
|
+
export default {
|
|
13
|
+
id: 'summary-name',
|
|
14
|
+
impact: 'serious',
|
|
15
|
+
tags: ['wcag2a', 'wcag412'],
|
|
16
|
+
help: 'Disclosure controls (<summary>) need an accessible name',
|
|
17
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
|
|
18
|
+
selector: 'summary',
|
|
19
|
+
evaluate(element, { accessibleName }) {
|
|
20
|
+
const details = element.parentElement;
|
|
21
|
+
const isControl = details?.tagName === 'DETAILS'
|
|
22
|
+
&& details.querySelector(':scope > summary') === element;
|
|
23
|
+
if (!isControl) return { status: 'pass' };
|
|
24
|
+
if (accessibleName(element)) return { status: 'pass' };
|
|
25
|
+
return {
|
|
26
|
+
status: 'fail',
|
|
27
|
+
message: 'This disclosure control has no accessible name — a screen reader announces an unnamed button, and users cannot tell what it expands.',
|
|
28
|
+
fix: 'Put text inside the <summary>, or add aria-label="…" for icon-only disclosures.',
|
|
29
|
+
};
|
|
30
|
+
},
|
|
31
|
+
};
|
package/engine/wcag22.js
CHANGED
|
@@ -69,7 +69,7 @@ export default [
|
|
|
69
69
|
sc('2.3.3', 'Animation from Interactions', 'AAA', 'manual', '2.1'),
|
|
70
70
|
sc('2.4.1', 'Bypass Blocks', 'A', 'partial'), // partial: "repeated across pages" can't be established from one page
|
|
71
71
|
sc('2.4.2', 'Page Titled', 'A', 'partial'), // partial: document-title proves a title exists and catches known placeholders; whether a real one DESCRIBES the page needs a reader
|
|
72
|
-
sc('2.4.3', 'Focus Order', 'A', '
|
|
72
|
+
sc('2.4.3', 'Focus Order', 'A', 'partial'), // visual-order-divergence flags CSS order/*-reverse against DOM order; whether an order preserves MEANING stays human
|
|
73
73
|
sc('2.4.4', 'Link Purpose (In Context)', 'A', 'partial'),
|
|
74
74
|
sc('2.4.5', 'Multiple Ways', 'AA', 'manual'),
|
|
75
75
|
sc('2.4.6', 'Headings and Labels', 'AA', 'manual'),
|
|
@@ -95,13 +95,13 @@ export default [
|
|
|
95
95
|
sc('3.1.4', 'Abbreviations', 'AAA', 'manual'),
|
|
96
96
|
sc('3.1.5', 'Reading Level', 'AAA', 'manual'),
|
|
97
97
|
sc('3.1.6', 'Pronunciation', 'AAA', 'manual'),
|
|
98
|
-
sc('3.2.1', 'On Focus', 'A', '
|
|
99
|
-
sc('3.2.2', 'On Input', 'A', '
|
|
98
|
+
sc('3.2.1', 'On Focus', 'A', 'partial'), // on-input-navigation flags inline onfocus handlers that navigate; addEventListener handlers are invisible to a DOM audit
|
|
99
|
+
sc('3.2.2', 'On Input', 'A', 'partial'), // on-input-navigation flags the inline jump-menu signature; "advised beforehand" stays a human call
|
|
100
100
|
sc('3.2.3', 'Consistent Navigation', 'AA', 'manual'),
|
|
101
101
|
sc('3.2.4', 'Consistent Identification', 'AA', 'manual'),
|
|
102
102
|
sc('3.2.5', 'Change on Request', 'AAA', 'manual'),
|
|
103
103
|
sc('3.2.6', 'Consistent Help', 'A', 'manual', '2.2'),
|
|
104
|
-
sc('3.3.1', 'Error Identification', 'A', '
|
|
104
|
+
sc('3.3.1', 'Error Identification', 'A', 'partial'), // error-message-linkage proves broken aria-errormessage targets; only in force when the page is captured mid-error
|
|
105
105
|
sc('3.3.2', 'Labels or Instructions', 'A', 'partial'),
|
|
106
106
|
sc('3.3.3', 'Error Suggestion', 'AA', 'manual'),
|
|
107
107
|
sc('3.3.4', 'Error Prevention (Legal, Financial, Data)', 'AA', 'manual'),
|
package/package.json
CHANGED