pour-engine 1.41.0 → 1.42.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 +13 -13
- package/engine/README.md +1 -1
- package/engine/rules/best-practice/region.js +1 -1
- package/engine/rules/index.js +29 -0
- package/engine/rules/wcag/1.2.3-video-audio-description.js +42 -0
- package/engine/rules/wcag/1.3.6-region-purpose.js +35 -0
- package/engine/rules/wcag/1.4.8-text-justified.js +49 -0
- package/engine/rules/wcag/2.2.1-meta-refresh.js +2 -2
- package/engine/rules/wcag/2.2.4-meta-refresh-no-exceptions.js +50 -0
- package/engine/rules/wcag/2.4.10-section-heading.js +86 -0
- package/engine/rules/wcag/2.4.11-focus-not-obscured.js +134 -78
- package/engine/rules/wcag/2.4.12-focus-not-obscured-enhanced.js +17 -0
- package/engine/rules/wcag/2.4.6-heading-label-placeholder.js +69 -0
- package/engine/rules/wcag/2.5.1-pointer-gesture-alternative.js +98 -0
- package/engine/rules/wcag/3.3.4-financial-form-confirmation.js +32 -0
- package/engine/rules/wcag/3.3.8-auth-field-obstruction.js +4 -1
- package/engine/rules/wcag/3.3.9-captcha-alternative.js +35 -0
- package/engine/wcag22.js +12 -12
- package/package.json +1 -1
package/config/project.config.js
CHANGED
package/engine/COVERAGE.md
CHANGED
|
@@ -17,9 +17,9 @@ meaning that only a person can settle, even where a rule decides most real pages
|
|
|
17
17
|
| 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 |
|
|
18
18
|
| 1.2.1 | [Audio-only and Video-only (Prerecorded)](https://www.w3.org/WAI/WCAG22/Understanding/audio-only-and-video-only-prerecorded.html) | A | Partially automated | audio-transcript |
|
|
19
19
|
| 1.2.2 | [Captions (Prerecorded)](https://www.w3.org/WAI/WCAG22/Understanding/captions-prerecorded.html) | A | Partially automated | media-captions |
|
|
20
|
-
| 1.2.3 | [Audio Description or Media Alternative (Prerecorded)](https://www.w3.org/WAI/WCAG22/Understanding/audio-description-or-media-alternative-prerecorded.html) | A |
|
|
20
|
+
| 1.2.3 | [Audio Description or Media Alternative (Prerecorded)](https://www.w3.org/WAI/WCAG22/Understanding/audio-description-or-media-alternative-prerecorded.html) | A | Partially automated | video-audio-description |
|
|
21
21
|
| 1.2.4 | [Captions (Live)](https://www.w3.org/WAI/WCAG22/Understanding/captions-live.html) | AA | Manual review | — |
|
|
22
|
-
| 1.2.5 | [Audio Description (Prerecorded)](https://www.w3.org/WAI/WCAG22/Understanding/audio-description-prerecorded.html) | AA |
|
|
22
|
+
| 1.2.5 | [Audio Description (Prerecorded)](https://www.w3.org/WAI/WCAG22/Understanding/audio-description-prerecorded.html) | AA | Partially automated | video-audio-description |
|
|
23
23
|
| 1.2.6 | [Sign Language (Prerecorded)](https://www.w3.org/WAI/WCAG22/Understanding/sign-language-prerecorded.html) | AAA | Manual review | — |
|
|
24
24
|
| 1.2.7 | [Extended Audio Description (Prerecorded)](https://www.w3.org/WAI/WCAG22/Understanding/extended-audio-description-prerecorded.html) | AAA | Manual review | — |
|
|
25
25
|
| 1.2.8 | [Media Alternative (Prerecorded)](https://www.w3.org/WAI/WCAG22/Understanding/media-alternative-prerecorded.html) | AAA | Manual review | — |
|
|
@@ -29,7 +29,7 @@ meaning that only a person can settle, even where a rule decides most real pages
|
|
|
29
29
|
| 1.3.3 | [Sensory Characteristics](https://www.w3.org/WAI/WCAG22/Understanding/sensory-characteristics.html) | A | Manual review | — |
|
|
30
30
|
| 1.3.4 | [Orientation](https://www.w3.org/WAI/WCAG22/Understanding/orientation.html) | AA | Partially automated | orientation-lock |
|
|
31
31
|
| 1.3.5 | [Identify Input Purpose](https://www.w3.org/WAI/WCAG22/Understanding/identify-input-purpose.html) | AA | Partially automated | autocomplete-valid |
|
|
32
|
-
| 1.3.6 | [Identify Purpose](https://www.w3.org/WAI/WCAG22/Understanding/identify-purpose.html) | AAA |
|
|
32
|
+
| 1.3.6 | [Identify Purpose](https://www.w3.org/WAI/WCAG22/Understanding/identify-purpose.html) | AAA | Partially automated | region-purpose |
|
|
33
33
|
| 1.4.1 | [Use of Color](https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html) | A | Partially automated | link-in-text-block |
|
|
34
34
|
| 1.4.2 | [Audio Control](https://www.w3.org/WAI/WCAG22/Understanding/audio-control.html) | A | Partially automated | audio-control |
|
|
35
35
|
| 1.4.3 | [Contrast (Minimum)](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html) | AA | Partially automated | color-contrast, control-contrast |
|
|
@@ -37,7 +37,7 @@ meaning that only a person can settle, even where a rule decides most real pages
|
|
|
37
37
|
| 1.4.5 | [Images of Text](https://www.w3.org/WAI/WCAG22/Understanding/images-of-text.html) | AA | Manual review | — |
|
|
38
38
|
| 1.4.6 | [Contrast (Enhanced)](https://www.w3.org/WAI/WCAG22/Understanding/contrast-enhanced.html) | AAA | Partially automated | color-contrast-enhanced |
|
|
39
39
|
| 1.4.7 | [Low or No Background Audio](https://www.w3.org/WAI/WCAG22/Understanding/low-or-no-background-audio.html) | AAA | Manual review | — |
|
|
40
|
-
| 1.4.8 | [Visual Presentation](https://www.w3.org/WAI/WCAG22/Understanding/visual-presentation.html) | AAA |
|
|
40
|
+
| 1.4.8 | [Visual Presentation](https://www.w3.org/WAI/WCAG22/Understanding/visual-presentation.html) | AAA | Partially automated | text-justified |
|
|
41
41
|
| 1.4.9 | [Images of Text (No Exception)](https://www.w3.org/WAI/WCAG22/Understanding/images-of-text-no-exception.html) | AAA | Manual review | — |
|
|
42
42
|
| 1.4.10 | [Reflow](https://www.w3.org/WAI/WCAG22/Understanding/reflow.html) | AA | Partially automated | reflow |
|
|
43
43
|
| 1.4.11 | [Non-text Contrast](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html) | AA | Partially automated | non-text-contrast |
|
|
@@ -55,7 +55,7 @@ meaning that only a person can settle, even where a rule decides most real pages
|
|
|
55
55
|
| 2.2.1 | [Timing Adjustable](https://www.w3.org/WAI/WCAG22/Understanding/timing-adjustable.html) | A | Partially automated | meta-refresh |
|
|
56
56
|
| 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 |
|
|
57
57
|
| 2.2.3 | [No Timing](https://www.w3.org/WAI/WCAG22/Understanding/no-timing.html) | AAA | Manual review | — |
|
|
58
|
-
| 2.2.4 | [Interruptions](https://www.w3.org/WAI/WCAG22/Understanding/interruptions.html) | AAA |
|
|
58
|
+
| 2.2.4 | [Interruptions](https://www.w3.org/WAI/WCAG22/Understanding/interruptions.html) | AAA | Partially automated | meta-refresh-no-exceptions |
|
|
59
59
|
| 2.2.5 | [Re-authenticating](https://www.w3.org/WAI/WCAG22/Understanding/re-authenticating.html) | AAA | Manual review | — |
|
|
60
60
|
| 2.2.6 | [Timeouts](https://www.w3.org/WAI/WCAG22/Understanding/timeouts.html) | AAA | Manual review | — |
|
|
61
61
|
| 2.3.1 | [Three Flashes or Below Threshold](https://www.w3.org/WAI/WCAG22/Understanding/three-flashes-or-below-threshold.html) | A | Manual review | — |
|
|
@@ -66,15 +66,15 @@ meaning that only a person can settle, even where a rule decides most real pages
|
|
|
66
66
|
| 2.4.3 | [Focus Order](https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html) | A | Partially automated | visual-order-divergence |
|
|
67
67
|
| 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 |
|
|
68
68
|
| 2.4.5 | [Multiple Ways](https://www.w3.org/WAI/WCAG22/Understanding/multiple-ways.html) | AA | Manual review | — |
|
|
69
|
-
| 2.4.6 | [Headings and Labels](https://www.w3.org/WAI/WCAG22/Understanding/headings-and-labels.html) | AA |
|
|
69
|
+
| 2.4.6 | [Headings and Labels](https://www.w3.org/WAI/WCAG22/Understanding/headings-and-labels.html) | AA | Partially automated | heading-label-placeholder |
|
|
70
70
|
| 2.4.7 | [Focus Visible](https://www.w3.org/WAI/WCAG22/Understanding/focus-visible.html) | AA | Partially automated | focus-visible |
|
|
71
71
|
| 2.4.8 | [Location](https://www.w3.org/WAI/WCAG22/Understanding/location.html) | AAA | Manual review | — |
|
|
72
72
|
| 2.4.9 | [Link Purpose (Link Only)](https://www.w3.org/WAI/WCAG22/Understanding/link-purpose-link-only.html) | AAA | Partially automated | link-text-generic-only |
|
|
73
|
-
| 2.4.10 | [Section Headings](https://www.w3.org/WAI/WCAG22/Understanding/section-headings.html) | AAA |
|
|
73
|
+
| 2.4.10 | [Section Headings](https://www.w3.org/WAI/WCAG22/Understanding/section-headings.html) | AAA | Partially automated | section-heading |
|
|
74
74
|
| 2.4.11 | [Focus Not Obscured (Minimum)](https://www.w3.org/WAI/WCAG22/Understanding/focus-not-obscured-minimum.html) | AA | Partially automated | focus-not-obscured |
|
|
75
|
-
| 2.4.12 | [Focus Not Obscured (Enhanced)](https://www.w3.org/WAI/WCAG22/Understanding/focus-not-obscured-enhanced.html) | AAA |
|
|
75
|
+
| 2.4.12 | [Focus Not Obscured (Enhanced)](https://www.w3.org/WAI/WCAG22/Understanding/focus-not-obscured-enhanced.html) | AAA | Partially automated | focus-not-obscured-enhanced |
|
|
76
76
|
| 2.4.13 | [Focus Appearance](https://www.w3.org/WAI/WCAG22/Understanding/focus-appearance.html) | AAA | Manual review | — |
|
|
77
|
-
| 2.5.1 | [Pointer Gestures](https://www.w3.org/WAI/WCAG22/Understanding/pointer-gestures.html) | A |
|
|
77
|
+
| 2.5.1 | [Pointer Gestures](https://www.w3.org/WAI/WCAG22/Understanding/pointer-gestures.html) | A | Partially automated | pointer-gesture-alternative |
|
|
78
78
|
| 2.5.2 | [Pointer Cancellation](https://www.w3.org/WAI/WCAG22/Understanding/pointer-cancellation.html) | A | Manual review | — |
|
|
79
79
|
| 2.5.3 | [Label in Name](https://www.w3.org/WAI/WCAG22/Understanding/label-in-name.html) | A | Manual review | — |
|
|
80
80
|
| 2.5.4 | [Motion Actuation](https://www.w3.org/WAI/WCAG22/Understanding/motion-actuation.html) | A | Manual review | — |
|
|
@@ -97,17 +97,17 @@ meaning that only a person can settle, even where a rule decides most real pages
|
|
|
97
97
|
| 3.2.2 | [On Input](https://www.w3.org/WAI/WCAG22/Understanding/on-input.html) | A | Partially automated | on-input-navigation |
|
|
98
98
|
| 3.2.3 | [Consistent Navigation](https://www.w3.org/WAI/WCAG22/Understanding/consistent-navigation.html) | AA | Manual review | — |
|
|
99
99
|
| 3.2.4 | [Consistent Identification](https://www.w3.org/WAI/WCAG22/Understanding/consistent-identification.html) | AA | Manual review | — |
|
|
100
|
-
| 3.2.5 | [Change on Request](https://www.w3.org/WAI/WCAG22/Understanding/change-on-request.html) | AAA |
|
|
100
|
+
| 3.2.5 | [Change on Request](https://www.w3.org/WAI/WCAG22/Understanding/change-on-request.html) | AAA | Partially automated | meta-refresh-no-exceptions |
|
|
101
101
|
| 3.2.6 | [Consistent Help](https://www.w3.org/WAI/WCAG22/Understanding/consistent-help.html) | A | Manual review | — |
|
|
102
102
|
| 3.3.1 | [Error Identification](https://www.w3.org/WAI/WCAG22/Understanding/error-identification.html) | A | Partially automated | error-message-linkage |
|
|
103
103
|
| 3.3.2 | [Labels or Instructions](https://www.w3.org/WAI/WCAG22/Understanding/labels-or-instructions.html) | A | Partially automated | form-label |
|
|
104
104
|
| 3.3.3 | [Error Suggestion](https://www.w3.org/WAI/WCAG22/Understanding/error-suggestion.html) | AA | Manual review | — |
|
|
105
|
-
| 3.3.4 | [Error Prevention (Legal, Financial, Data)](https://www.w3.org/WAI/WCAG22/Understanding/error-prevention-legal-financial-data.html) | AA |
|
|
105
|
+
| 3.3.4 | [Error Prevention (Legal, Financial, Data)](https://www.w3.org/WAI/WCAG22/Understanding/error-prevention-legal-financial-data.html) | AA | Partially automated | financial-form-confirmation |
|
|
106
106
|
| 3.3.5 | [Help](https://www.w3.org/WAI/WCAG22/Understanding/help.html) | AAA | Manual review | — |
|
|
107
107
|
| 3.3.6 | [Error Prevention (All)](https://www.w3.org/WAI/WCAG22/Understanding/error-prevention-all.html) | AAA | Manual review | — |
|
|
108
108
|
| 3.3.7 | [Redundant Entry](https://www.w3.org/WAI/WCAG22/Understanding/redundant-entry.html) | A | Partially automated | redundant-entry |
|
|
109
109
|
| 3.3.8 | [Accessible Authentication (Minimum)](https://www.w3.org/WAI/WCAG22/Understanding/accessible-authentication-minimum.html) | AA | Partially automated | auth-field-obstruction |
|
|
110
|
-
| 3.3.9 | [Accessible Authentication (Enhanced)](https://www.w3.org/WAI/WCAG22/Understanding/accessible-authentication-enhanced.html) | AAA |
|
|
110
|
+
| 3.3.9 | [Accessible Authentication (Enhanced)](https://www.w3.org/WAI/WCAG22/Understanding/accessible-authentication-enhanced.html) | AAA | Partially automated | auth-field-obstruction, captcha-alternative |
|
|
111
111
|
|
|
112
112
|
## Robust
|
|
113
113
|
|
|
@@ -116,4 +116,4 @@ meaning that only a person can settle, even where a rule decides most real pages
|
|
|
116
116
|
| 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, invoker-target, label-for-valid, nested-interactive, role-required-aria, summary-name, valid-role |
|
|
117
117
|
| 4.1.3 | [Status Messages](https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html) | AA | Manual review | — |
|
|
118
118
|
|
|
119
|
-
**Totals:**
|
|
119
|
+
**Totals:** 51 partially automated · 35 manual — with 98 rules.
|
package/engine/README.md
CHANGED
|
@@ -117,7 +117,7 @@ assessment, including interaction states and any checks reported as skipped.
|
|
|
117
117
|
|
|
118
118
|
## Honest current state
|
|
119
119
|
|
|
120
|
-
|
|
120
|
+
98 rules, hardened on real sites and the `testbeds/` pages. Audits walk into
|
|
121
121
|
open shadow roots (targets come back as `host >>> inner` selector chains);
|
|
122
122
|
iframe traversal is the host's job, and the extension does it. Known gaps:
|
|
123
123
|
the full accname spec (ours is a close approximation) and the language
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// showing one (the open attribute is set by show() and showModal()) counts,
|
|
10
10
|
// so a closed dialog cannot make a landmark-less page look landmarked
|
|
11
11
|
// (2026-08-25 overnight audit).
|
|
12
|
-
const LANDMARK =
|
|
12
|
+
export const LANDMARK =
|
|
13
13
|
'main, nav, aside, search, form[aria-label], form[aria-labelledby], ' +
|
|
14
14
|
'header:not(:is(article, aside, main, nav, section) header), ' +
|
|
15
15
|
'footer:not(:is(article, aside, main, nav, section) footer), ' +
|
package/engine/rules/index.js
CHANGED
|
@@ -109,6 +109,25 @@ import onInputNavigation from './wcag/3.2.2-on-input-navigation.js';
|
|
|
109
109
|
import errorMessageLinkage from './wcag/3.3.1-error-message-linkage.js';
|
|
110
110
|
import compositeWidgetName from './wcag/4.1.2-composite-widget-name.js';
|
|
111
111
|
import summaryName from './wcag/4.1.2-summary-name.js';
|
|
112
|
+
// 2026-09-12 manual-to-partial batch (reports/coverage-gaps/2026-09-12): the
|
|
113
|
+
// slices of twelve criteria that had no rule where the DOM shows a real
|
|
114
|
+
// candidate. Two AAA siblings built from existing factories (a timed refresh
|
|
115
|
+
// with no 20-hour allowance, any overlap of a focused element); eight
|
|
116
|
+
// review questions on declared facts (a video without a descriptions
|
|
117
|
+
// track, a declared <section> with no heading, a touch-action surface, a
|
|
118
|
+
// payment form, a CAPTCHA beside a credential, a page with no landmarks,
|
|
119
|
+
// justified running text) and one assert on template text left in a
|
|
120
|
+
// heading or label. Two assert, the rest ask.
|
|
121
|
+
import metaRefreshNoExceptions from './wcag/2.2.4-meta-refresh-no-exceptions.js';
|
|
122
|
+
import focusNotObscuredEnhanced from './wcag/2.4.12-focus-not-obscured-enhanced.js';
|
|
123
|
+
import videoAudioDescription from './wcag/1.2.3-video-audio-description.js';
|
|
124
|
+
import headingLabelPlaceholder from './wcag/2.4.6-heading-label-placeholder.js';
|
|
125
|
+
import textJustified from './wcag/1.4.8-text-justified.js';
|
|
126
|
+
import sectionHeading from './wcag/2.4.10-section-heading.js';
|
|
127
|
+
import captchaAlternative from './wcag/3.3.9-captcha-alternative.js';
|
|
128
|
+
import regionPurpose from './wcag/1.3.6-region-purpose.js';
|
|
129
|
+
import financialFormConfirmation from './wcag/3.3.4-financial-form-confirmation.js';
|
|
130
|
+
import pointerGestureAlternative from './wcag/2.5.1-pointer-gesture-alternative.js';
|
|
112
131
|
|
|
113
132
|
// --- Best practice ---
|
|
114
133
|
import headingOrder from './best-practice/heading-order.js';
|
|
@@ -210,6 +229,16 @@ export default [
|
|
|
210
229
|
errorMessageLinkage,
|
|
211
230
|
compositeWidgetName,
|
|
212
231
|
summaryName,
|
|
232
|
+
metaRefreshNoExceptions,
|
|
233
|
+
focusNotObscuredEnhanced,
|
|
234
|
+
videoAudioDescription,
|
|
235
|
+
headingLabelPlaceholder,
|
|
236
|
+
textJustified,
|
|
237
|
+
sectionHeading,
|
|
238
|
+
captchaAlternative,
|
|
239
|
+
regionPurpose,
|
|
240
|
+
financialFormConfirmation,
|
|
241
|
+
pointerGestureAlternative,
|
|
213
242
|
contrastEnhanced,
|
|
214
243
|
targetSizeEnhanced,
|
|
215
244
|
headingOrder,
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// WCAG SC 1.2.3 Audio Description or Media Alternative (Prerecorded)
|
|
2
|
+
// (Level A) and SC 1.2.5 Audio Description (Prerecorded) (Level AA)
|
|
3
|
+
//
|
|
4
|
+
// Prerecorded video in synchronized media needs audio description of the
|
|
5
|
+
// visual track (1.2.5), or at Level A alternatively a full text alternative
|
|
6
|
+
// for the media (1.2.3). Whether either exists is not decidable from the
|
|
7
|
+
// DOM: description can be a second audio track chosen in the player, a
|
|
8
|
+
// separately produced version, narration mixed into the soundtrack, or a
|
|
9
|
+
// transcript beside the player. And the Understanding of 1.2.5 says no
|
|
10
|
+
// description is needed when the soundtrack already carries every
|
|
11
|
+
// important thing the picture shows (a talking head). So this rule never
|
|
12
|
+
// asserts. It asks, once per video that can actually present sound to
|
|
13
|
+
// someone, and stays silent where the page shows the one thing the DOM can
|
|
14
|
+
// show: a <track kind="descriptions"> (technique H96).
|
|
15
|
+
//
|
|
16
|
+
// Same media gating as media-captions, for the same reasons: no resource
|
|
17
|
+
// means nothing is presented, and muted-with-no-controls is the decorative
|
|
18
|
+
// loop nobody can hear, which is not synchronized media for a viewer.
|
|
19
|
+
// Video-only files (no audio stream at all) are 1.2.1 territory and not
|
|
20
|
+
// visible from the DOM either; the message says so.
|
|
21
|
+
export default {
|
|
22
|
+
id: 'video-audio-description',
|
|
23
|
+
name: 'Video audio description',
|
|
24
|
+
impact: 'serious',
|
|
25
|
+
tags: ['wcag2a', 'wcag123', 'wcag125'],
|
|
26
|
+
help: 'Video content needs audio description or a text alternative',
|
|
27
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/audio-description-prerecorded.html',
|
|
28
|
+
selector: 'video',
|
|
29
|
+
visibleOnly: false,
|
|
30
|
+
evaluate(element) {
|
|
31
|
+
if (element.querySelector('track[kind="descriptions" i]')) return { status: 'pass' };
|
|
32
|
+
if (!element.currentSrc && !element.getAttribute('src') && !element.querySelector('source')) {
|
|
33
|
+
return { status: 'pass' };
|
|
34
|
+
}
|
|
35
|
+
if (element.muted && !element.controls) return { status: 'pass' };
|
|
36
|
+
return {
|
|
37
|
+
status: 'incomplete',
|
|
38
|
+
message: 'This video has no audio-description track. If it shows something its soundtrack does not say (text on screen, actions, charts, who is speaking), blind and low-vision users miss it. 1.2.5 asks for audio description; at Level A, 1.2.3 is also met by a full text alternative describing the visuals, linked near the player. A talking-head video whose soundtrack already carries everything needs neither, and a video with no soundtrack at all is 1.2.1\'s question instead: check which this is, and where the description or alternative lives.',
|
|
39
|
+
fix: 'Provide a described audio track or a described version of the video (a <track kind="descriptions"> file works for text-based description), or publish a full transcript that describes the visuals next to the player.',
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// WCAG SC 1.3.6 Identify Purpose (Level AAA)
|
|
2
|
+
// "The purpose of user interface components, icons, and regions can be
|
|
3
|
+
// programmatically determined." Its sufficient technique for regions is
|
|
4
|
+
// ARIA11, landmarks; the purpose of components and icons has no DOM-visible
|
|
5
|
+
// technique at all (personalization semantics never shipped), so that half
|
|
6
|
+
// of the criterion stays with a person. What a page can show is the
|
|
7
|
+
// regions half: a document with no landmark whatsoever has identified the
|
|
8
|
+
// purpose of none of its regions. That is one question per page, asked
|
|
9
|
+
// once, and it never asserts, because the criterion lists other routes
|
|
10
|
+
// (microdata, other programmatic means) the DOM does not reveal.
|
|
11
|
+
//
|
|
12
|
+
// Pages that HAVE landmarks pass here; stray content outside them is the
|
|
13
|
+
// best-practice region rule's finding, which stays opt-in because A and AA
|
|
14
|
+
// never require landmarks. This rule is the AAA criterion's own question.
|
|
15
|
+
import { LANDMARK } from '../best-practice/region.js';
|
|
16
|
+
|
|
17
|
+
export default {
|
|
18
|
+
id: 'region-purpose',
|
|
19
|
+
name: 'Regions with a purpose',
|
|
20
|
+
impact: 'moderate',
|
|
21
|
+
tags: ['wcag21aaa', 'wcag136'],
|
|
22
|
+
help: 'The purpose of page regions should be programmatically determinable (AAA)',
|
|
23
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/identify-purpose.html',
|
|
24
|
+
selector: 'html',
|
|
25
|
+
visibleOnly: false,
|
|
26
|
+
evaluate(element) {
|
|
27
|
+
const doc = element.ownerDocument;
|
|
28
|
+
if (doc.querySelector(LANDMARK)) return { status: 'pass' };
|
|
29
|
+
return {
|
|
30
|
+
status: 'incomplete',
|
|
31
|
+
message: 'This page declares no landmark regions (no main, nav, header, footer, aside, search or labelled region), so the purpose of its regions is not exposed to assistive technology or personalisation tools. 1.3.6 (AAA) lists landmarks as the way to identify regions; the purpose of components and icons still needs a person to check.',
|
|
32
|
+
fix: 'Wrap the page\'s regions in <main>, <nav>, <header>, <footer>, <aside> and labelled <section> elements (or the matching ARIA roles).',
|
|
33
|
+
};
|
|
34
|
+
},
|
|
35
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// WCAG SC 1.4.8 Visual Presentation (Level AAA) — the justified-text slice
|
|
2
|
+
//
|
|
3
|
+
// 1.4.8 lists five things a reader must be able to achieve for blocks of
|
|
4
|
+
// text; the third is "text is not justified (aligned to both the left and
|
|
5
|
+
// the right margins)". Justified text is the one of the five that the page
|
|
6
|
+
// itself imposes and that shows in a computed style; F88 ("using text that
|
|
7
|
+
// is justified") is its failure technique. The criterion is met when a
|
|
8
|
+
// mechanism lets the reader undo it, and the Understanding says the
|
|
9
|
+
// browser may provide that mechanism, so a justified block is asked about,
|
|
10
|
+
// not failed: the DOM cannot show whether a style switcher or a reader
|
|
11
|
+
// mode is on offer. The other four requirements (colour selection, 80
|
|
12
|
+
// character measure, line spacing, 200% resize) are met by browser
|
|
13
|
+
// mechanisms on ordinary pages and are not judged here.
|
|
14
|
+
//
|
|
15
|
+
// Only blocks with real running text count: the block's own text and the
|
|
16
|
+
// text of its inline children, at least 120 characters, so a justified
|
|
17
|
+
// wrapper is reported once at the paragraph that carries the words, not at
|
|
18
|
+
// every ancestor that inherits the alignment.
|
|
19
|
+
const BLOCK = /^(?:block|list-item|table-cell|flow-root)$/;
|
|
20
|
+
|
|
21
|
+
function inlineText(element) {
|
|
22
|
+
let text = '';
|
|
23
|
+
for (const node of element.childNodes) {
|
|
24
|
+
if (node.nodeType === 3) text += node.textContent;
|
|
25
|
+
else if (node.nodeType === 1 && getComputedStyle(node).display.startsWith('inline')) text += node.textContent;
|
|
26
|
+
}
|
|
27
|
+
return text.replace(/\s+/g, ' ').trim();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export default {
|
|
31
|
+
id: 'text-justified',
|
|
32
|
+
name: 'Justified text',
|
|
33
|
+
impact: 'moderate',
|
|
34
|
+
tags: ['wcag2aaa', 'wcag148'],
|
|
35
|
+
help: 'Blocks of text should not be justified to both margins (AAA)',
|
|
36
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/visual-presentation.html',
|
|
37
|
+
selector: 'p, li, dd, dt, td, th, blockquote, figcaption, summary, address, div, section, article, main, aside, header, footer',
|
|
38
|
+
evaluate(element) {
|
|
39
|
+
const style = getComputedStyle(element);
|
|
40
|
+
if (style.textAlign !== 'justify' && style.textAlign !== 'justify-all') return { status: 'pass' };
|
|
41
|
+
if (!BLOCK.test(style.display)) return { status: 'pass' };
|
|
42
|
+
if (inlineText(element).length < 120) return { status: 'pass' };
|
|
43
|
+
return {
|
|
44
|
+
status: 'incomplete',
|
|
45
|
+
message: 'This block of text is justified to both margins. The uneven spaces between words form "rivers of white" down the page that some readers with dyslexia or low vision cannot read across (failure F88). 1.4.8 allows justified text only where a mechanism lets the reader switch it off; check that one exists, in the page or the browser.',
|
|
46
|
+
fix: 'Set text-align: start (or left) on running text, or offer a control that turns justification off.',
|
|
47
|
+
};
|
|
48
|
+
},
|
|
49
|
+
};
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* the old test demanded the literal "url=", so content="0;https://…" was
|
|
11
11
|
* asserted as a reload loop while the browser redirected from it.)
|
|
12
12
|
*/
|
|
13
|
-
function refreshDestination(content) {
|
|
13
|
+
export function refreshDestination(content) {
|
|
14
14
|
let rest = content.replace(/^\s*[\d.]*\s*(?:[;,]\s*)?/, '');
|
|
15
15
|
// "url" is consumed letter by letter, then optional whitespace and "=".
|
|
16
16
|
rest = rest.replace(/^url\s*(?:=\s*)?/i, '');
|
|
@@ -33,7 +33,7 @@ function refreshDestination(content) {
|
|
|
33
33
|
* rule asserted a time limit on pages the browser never moves. Verified in
|
|
34
34
|
* Chromium: both of those pages stay put while content="1; url=…" navigates.
|
|
35
35
|
*/
|
|
36
|
-
function refreshDelay(content) {
|
|
36
|
+
export function refreshDelay(content) {
|
|
37
37
|
const isAsciiSpace = (c) => c === ' ' || c === '\t' || c === '\n' || c === '\f' || c === '\r';
|
|
38
38
|
const isDigit = (c) => c >= '0' && c <= '9';
|
|
39
39
|
let i = 0;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// WCAG SC 2.2.4 Interruptions (Level AAA) and SC 3.2.5 Change on Request
|
|
2
|
+
// (Level AAA) — the timed meta refresh, with none of 2.2.1's escapes.
|
|
3
|
+
//
|
|
4
|
+
// Both criteria list the same two failure techniques: F40 (a meta redirect
|
|
5
|
+
// with a time limit) and F41 (a meta refresh that reloads the page). 2.2.1
|
|
6
|
+
// excuses a limit longer than 20 hours; neither AAA criterion does. An
|
|
7
|
+
// interruption the reader cannot postpone or suppress breaks 2.2.4, and a
|
|
8
|
+
// change of context the reader did not ask for breaks 3.2.5, however long
|
|
9
|
+
// the timer. The instant redirect (zero seconds with a destination) stays
|
|
10
|
+
// allowed: G110 and H76 are sufficient techniques of 3.2.5 itself.
|
|
11
|
+
//
|
|
12
|
+
// One rule, two tags, on the twin-tag precedent (2.1.1/2.1.3): the same
|
|
13
|
+
// fact fails both. It runs beside 2.2.1's rule at AAA scope, so a refresh
|
|
14
|
+
// inside 20 hours is reported by both; each names its own criterion.
|
|
15
|
+
// 2.2.4 exempts emergencies, which a meta tag cannot show, so the message
|
|
16
|
+
// names that assumption the way the 2.2.1 rule names its own.
|
|
17
|
+
import { refreshDelay, refreshDestination } from './2.2.1-meta-refresh.js';
|
|
18
|
+
|
|
19
|
+
export default {
|
|
20
|
+
id: 'meta-refresh-no-exceptions',
|
|
21
|
+
name: 'Timed refresh (AAA)',
|
|
22
|
+
impact: 'serious',
|
|
23
|
+
tags: ['wcag2aaa', 'wcag224', 'wcag325'],
|
|
24
|
+
help: 'The page must not refresh or redirect itself on any timer (AAA)',
|
|
25
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/interruptions.html',
|
|
26
|
+
selector: 'meta[http-equiv="refresh" i]',
|
|
27
|
+
visibleOnly: false,
|
|
28
|
+
evaluate(element) {
|
|
29
|
+
const content = element.getAttribute('content') ?? '';
|
|
30
|
+
const delay = refreshDelay(content);
|
|
31
|
+
// The browser discards the directive: nothing is scheduled.
|
|
32
|
+
if (delay === null) return { status: 'pass' };
|
|
33
|
+
const hasDestination = refreshDestination(content) !== '';
|
|
34
|
+
// Zero seconds with a destination is the instant client-side redirect
|
|
35
|
+
// that G110 and H76 permit.
|
|
36
|
+
if (delay === 0 && hasDestination) return { status: 'pass' };
|
|
37
|
+
if (!hasDestination) {
|
|
38
|
+
return {
|
|
39
|
+
status: 'fail',
|
|
40
|
+
message: `The page reloads itself${delay > 0 ? ` every ${delay}s` : ' immediately, over and over'} (failure F41). The reader cannot postpone or suppress the reload, which 2.2.4 requires, and it changes the page without being asked, which 3.2.5 forbids. Neither AAA criterion has 2.2.1's 20-hour allowance.`,
|
|
41
|
+
fix: 'Remove the timed refresh and update the content in place, or let the reader request an update with a control.',
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
status: 'fail',
|
|
46
|
+
message: `The page redirects itself after ${delay}s (failure F40). The reader cannot postpone or suppress the move, which 2.2.4 requires, and the change of context is not requested, which 3.2.5 forbids; the AAA criteria allow no 20-hour exemption. 2.2.4 excuses only an emergency, which the tag cannot show, so this finding assumes there is none.`,
|
|
47
|
+
fix: 'Redirect instantly (content="0; url=…") or on the server, or give the reader a link and let them choose when to move.',
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// WCAG SC 2.4.10 Section Headings (Level AAA)
|
|
2
|
+
// "Section headings are used to organize the content." The Understanding
|
|
3
|
+
// defines a section as a self-contained portion of written content, says
|
|
4
|
+
// the criterion covers sections within writing rather than UI components,
|
|
5
|
+
// and gives H69 (a heading element at the beginning of each section) as
|
|
6
|
+
// the technique. Where a page's sections begin is a judgement about the
|
|
7
|
+
// writing, except in one case the author has made for us: an HTML
|
|
8
|
+
// <section> element is a declared section. A declared section that holds
|
|
9
|
+
// running text and starts with no heading is the honest candidate; whether
|
|
10
|
+
// it is really a section of writing or a layout wrapper stays the
|
|
11
|
+
// reader's call, so this asks and never asserts.
|
|
12
|
+
//
|
|
13
|
+
// A section has its heading when one of its own sits anywhere inside it
|
|
14
|
+
// (a nested section's or article's heading is that element's), when it
|
|
15
|
+
// BEGINS with one however the markup nests it (inside a header, a div or a
|
|
16
|
+
// nested article), or when the element immediately before it is that
|
|
17
|
+
// heading or ends with it: the blog pattern of an article's title followed by a
|
|
18
|
+
// section holding the post body (measured on the corpus: 292 such bodies
|
|
19
|
+
// on one feed page, none of them a missing heading). A section named by
|
|
20
|
+
// aria-labelledby or aria-label has a title by another route and is left
|
|
21
|
+
// alone. Sections with little text, or none, are the UI-component case the
|
|
22
|
+
// criterion excludes.
|
|
23
|
+
const HEADING = 'h1, h2, h3, h4, h5, h6, [role="heading"]';
|
|
24
|
+
const SUBSTANTIVE = 'img, svg, video, audio, canvas, iframe, input, select, textarea, button';
|
|
25
|
+
const SKIP = 'script, style, template, noscript';
|
|
26
|
+
|
|
27
|
+
/** The first (or, reversed, the last) substantive thing inside an element,
|
|
28
|
+
* in document order: a heading, or text or media that is not one. */
|
|
29
|
+
function edgeIsHeading(element, fromEnd) {
|
|
30
|
+
const children = (node) => (fromEnd ? [...node.childNodes].reverse() : [...node.childNodes]);
|
|
31
|
+
const stack = children(element).reverse();
|
|
32
|
+
while (stack.length) {
|
|
33
|
+
const node = stack.pop();
|
|
34
|
+
if (node.nodeType === 3) { if (node.textContent.trim()) return false; continue; }
|
|
35
|
+
if (node.nodeType !== 1) continue;
|
|
36
|
+
if (node.matches(HEADING)) return true;
|
|
37
|
+
if (node.matches(SKIP)) continue;
|
|
38
|
+
if (node.matches(SUBSTANTIVE)) return false;
|
|
39
|
+
for (const child of children(node).reverse()) stack.push(child);
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export default {
|
|
45
|
+
id: 'section-heading',
|
|
46
|
+
name: 'Section headings',
|
|
47
|
+
impact: 'moderate',
|
|
48
|
+
tags: ['wcag2aaa', 'wcag2410'],
|
|
49
|
+
help: 'Sections of written content should begin with a heading (AAA)',
|
|
50
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/section-headings.html',
|
|
51
|
+
selector: 'section',
|
|
52
|
+
evaluate(element) {
|
|
53
|
+
if (element.hasAttribute('aria-label') || element.hasAttribute('aria-labelledby')) return { status: 'pass' };
|
|
54
|
+
const role = element.getAttribute('role');
|
|
55
|
+
if (role && role !== 'region') return { status: 'pass' };
|
|
56
|
+
// Any heading of the section's own (not a nested section's or article's)
|
|
57
|
+
// organises it, wherever it sits; a section that BEGINS with a heading,
|
|
58
|
+
// however nested, is headed too.
|
|
59
|
+
const ownHeading = [...element.querySelectorAll(HEADING)]
|
|
60
|
+
.some((heading) => heading.closest('section, article') === element);
|
|
61
|
+
if (ownHeading || edgeIsHeading(element, false)) return { status: 'pass' };
|
|
62
|
+
const before = element.previousElementSibling;
|
|
63
|
+
if (before && (before.matches(HEADING) || (before.matches('header, hgroup') && before.querySelector(HEADING)) || edgeIsHeading(before, true))) {
|
|
64
|
+
return { status: 'pass' };
|
|
65
|
+
}
|
|
66
|
+
// A feed of untitled notes: the section is the body of an article with
|
|
67
|
+
// no heading of its own, in a run of at least three such items. Whether
|
|
68
|
+
// the notes of a feed need headings is one question about the feed, for
|
|
69
|
+
// the reviewer to ask once; asking it per item put 292 findings on one
|
|
70
|
+
// corpus page and helped nobody.
|
|
71
|
+
const item = element.closest('article');
|
|
72
|
+
if (item && item.parentElement && !item.querySelector(HEADING)) {
|
|
73
|
+
const items = (node) => [...node.children].filter((child) => child.matches('article') || child.querySelector(':scope > article')).length;
|
|
74
|
+
const container = item.parentElement;
|
|
75
|
+
if (items(container) >= 3 || (container.parentElement && items(container.parentElement) >= 3)) return { status: 'pass' };
|
|
76
|
+
}
|
|
77
|
+
const text = (element.textContent ?? '').replace(/\s+/g, ' ').trim();
|
|
78
|
+
const paragraphs = element.querySelectorAll('p').length;
|
|
79
|
+
if (text.length < 200 && paragraphs < 2) return { status: 'pass' };
|
|
80
|
+
return {
|
|
81
|
+
status: 'incomplete',
|
|
82
|
+
message: 'This section holds written content but begins with no heading of its own, so readers scanning by headings cannot find or skip it. 2.4.10 asks for a heading at the start of each section of writing; check whether this is a section of content that needs one, or a purely structural wrapper.',
|
|
83
|
+
fix: 'Start the section with a heading that names its topic, at the level below its parent heading, or name it with aria-labelledby pointing at its visible title.',
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
};
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// An opaque fixed/sticky overlay covering a focusable element is the
|
|
3
3
|
// criterion's core case (bottom cookie bars over footer links). Partial
|
|
4
4
|
// overlap passes by definition ("not entirely hidden"), so only full
|
|
5
|
-
// containment counts.
|
|
5
|
+
// containment counts. The AAA sibling built from the same factory (2.4.12,
|
|
6
|
+
// "no part of the component is hidden") counts any overlap at all.
|
|
6
7
|
//
|
|
7
8
|
// This REVIEWS rather than fails, deliberately. What a snapshot can see is
|
|
8
9
|
// "element X is under the panel right now", and that is a fact about the
|
|
@@ -31,85 +32,140 @@ function edgeReserved(doc, edge, needed) {
|
|
|
31
32
|
return px >= needed - 1;
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Build the minimum (full containment) or enhanced (any overlap) rule.
|
|
37
|
+
* `partial: false` is 2.4.11 exactly as it has always run: one hit-test at
|
|
38
|
+
* the element's centre, and a blocker only counts when its box contains the
|
|
39
|
+
* whole element. `partial: true` is 2.4.12: the centre plus the four corners
|
|
40
|
+
* and four edge midpoints are hit-tested, and a blocker counts when it paints
|
|
41
|
+
* above the element at any of them and overlaps its box at all.
|
|
42
|
+
*/
|
|
43
|
+
export function createFocusObscuredRule({ id, name, tags, help, helpUrl, partial }) {
|
|
44
|
+
return {
|
|
45
|
+
id,
|
|
46
|
+
name,
|
|
47
|
+
impact: 'serious',
|
|
48
|
+
tags,
|
|
49
|
+
help,
|
|
50
|
+
helpUrl,
|
|
51
|
+
selector: FOCUSABLE,
|
|
52
|
+
visibility: 'visual',
|
|
53
|
+
evaluateAll(elements) {
|
|
54
|
+
const doc = elements[0]?.ownerDocument ?? document;
|
|
55
|
+
const win = doc.defaultView;
|
|
56
|
+
// While a modal (cookie-consent dialog etc.) is open, the page behind
|
|
57
|
+
// it is INTENTIONALLY covered and focus is supposed to be trapped in
|
|
58
|
+
// the modal — a snapshot in that state proves nothing about the
|
|
59
|
+
// criterion. The backdrop is often a separate sibling of the dialog,
|
|
60
|
+
// so the modal state is checked document-wide, not per overlay.
|
|
61
|
+
const modal = [...doc.querySelectorAll('dialog[open], [role="dialog"], [role="alertdialog"], [aria-modal="true"]')]
|
|
62
|
+
.some((el) => {
|
|
63
|
+
const rect = el.getBoundingClientRect();
|
|
64
|
+
return rect.width > 0 && rect.height > 0 && getComputedStyle(el).visibility !== 'hidden';
|
|
65
|
+
});
|
|
66
|
+
if (modal) return elements.map(() => ({ status: 'pass' }));
|
|
67
|
+
|
|
68
|
+
// Hit-test per IN-VIEWPORT focusable instead of style-scanning the
|
|
69
|
+
// whole document for overlays: elementsFromPoint at the target's
|
|
70
|
+
// centre surfaces whatever paints above it, and only the handful of
|
|
71
|
+
// on-screen targets pay any cost — a 200k-node spec page costs the
|
|
72
|
+
// same as a landing page. Per-overlay verdicts are memoized.
|
|
73
|
+
const overlayVerdict = new Map();
|
|
74
|
+
const isObscuringOverlay = (layer) => {
|
|
75
|
+
if (overlayVerdict.has(layer)) return overlayVerdict.get(layer);
|
|
76
|
+
let verdict = false;
|
|
77
|
+
const style = getComputedStyle(layer);
|
|
78
|
+
if (style.position === 'fixed' || style.position === 'sticky') {
|
|
79
|
+
const bg = style.backgroundColor.match(/rgba?\(([^)]+)\)/)?.[1]?.split(',');
|
|
80
|
+
const alpha = bg?.[3] === undefined ? 1 : parseFloat(bg[3]);
|
|
81
|
+
const rect = layer.getBoundingClientRect();
|
|
82
|
+
verdict = alpha >= 0.9 && rect.width >= 40 && rect.height >= 24;
|
|
83
|
+
}
|
|
84
|
+
overlayVerdict.set(layer, verdict);
|
|
85
|
+
return verdict;
|
|
86
|
+
};
|
|
87
|
+
const contained = (rect, overlay) =>
|
|
88
|
+
rect.left >= overlay.left && rect.right <= overlay.right
|
|
89
|
+
&& rect.top >= overlay.top && rect.bottom <= overlay.bottom;
|
|
90
|
+
const overlaps = (rect, overlay) =>
|
|
91
|
+
Math.min(rect.right, overlay.right) - Math.max(rect.left, overlay.left) > 0
|
|
92
|
+
&& Math.min(rect.bottom, overlay.bottom) - Math.max(rect.top, overlay.top) > 0;
|
|
93
|
+
const clampX = (x) => Math.min(Math.max(x, 0), win.innerWidth - 1);
|
|
94
|
+
const clampY = (y) => Math.min(Math.max(y, 0), win.innerHeight - 1);
|
|
95
|
+
// The layer painting above the element at one point, if it is an
|
|
96
|
+
// obscuring overlay whose box relates to the element's as the mode
|
|
97
|
+
// demands. elementsFromPoint lists the whole stack, covered elements
|
|
98
|
+
// included, so an element ABSENT from it does not paint at that point
|
|
99
|
+
// at all (a rounded corner, a clip-path): nothing is hidden there.
|
|
100
|
+
// Measured on the corpus: consent-dialog buttons with rounded corners
|
|
101
|
+
// over their own opaque backdrop were flagged from exactly that.
|
|
102
|
+
const blockerAt = (element, rect, x, y) => {
|
|
103
|
+
const stack = doc.elementsFromPoint(clampX(x), clampY(y));
|
|
104
|
+
const index = stack.indexOf(element);
|
|
105
|
+
if (index <= 0) return null;
|
|
106
|
+
const above = stack.slice(0, index);
|
|
107
|
+
return above.find((layer) =>
|
|
108
|
+
!layer.contains(element) && !element.contains(layer)
|
|
109
|
+
&& isObscuringOverlay(layer)
|
|
110
|
+
&& (partial ? overlaps : contained)(rect, layer.getBoundingClientRect())) ?? null;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
return elements.map((element) => {
|
|
114
|
+
// `:disabled` catches controls disabled by an ancestor fieldset, which
|
|
115
|
+
// the `disabled` property does not reflect — those never take focus.
|
|
116
|
+
if (element.matches(':disabled')) return { status: 'pass' };
|
|
117
|
+
// Inert content cannot be focused, so nothing can obscure its focus.
|
|
118
|
+
if (isInert(element)) return { status: 'pass' };
|
|
119
|
+
const rect = element.getBoundingClientRect();
|
|
120
|
+
if (!rect.width || !rect.height) return { status: 'pass' };
|
|
121
|
+
// Only in-viewport geometry is trustworthy (or cheap).
|
|
122
|
+
if (rect.bottom < 0 || rect.right < 0 || rect.top > win.innerHeight || rect.left > win.innerWidth) {
|
|
123
|
+
return { status: 'pass' };
|
|
124
|
+
}
|
|
125
|
+
const cx = rect.left + rect.width / 2;
|
|
126
|
+
const cy = rect.top + rect.height / 2;
|
|
127
|
+
let blocker = blockerAt(element, rect, cx, cy);
|
|
128
|
+
if (!blocker && partial) {
|
|
129
|
+
// A strip of cookie bar over the bottom of a link leaves its
|
|
130
|
+
// centre clear; the corners and edge midpoints see it.
|
|
131
|
+
const inset = 1;
|
|
132
|
+
const xs = [rect.left + inset, cx, rect.right - inset];
|
|
133
|
+
const ys = [rect.top + inset, cy, rect.bottom - inset];
|
|
134
|
+
for (const x of xs) {
|
|
135
|
+
for (const y of ys) {
|
|
136
|
+
if (x === cx && y === cy) continue;
|
|
137
|
+
blocker = blockerAt(element, rect, x, y);
|
|
138
|
+
if (blocker) break;
|
|
139
|
+
}
|
|
140
|
+
if (blocker) break;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (!blocker) return { status: 'pass' };
|
|
144
|
+
// Which viewport edge is the panel pinned to? That is the edge the
|
|
145
|
+
// browser's scroll-into-view has to clear.
|
|
146
|
+
const panel = blocker.getBoundingClientRect();
|
|
147
|
+
const edge = panel.top <= 1 && panel.bottom < win.innerHeight ? 'top'
|
|
148
|
+
: panel.bottom >= win.innerHeight - 1 ? 'bottom' : null;
|
|
149
|
+
if (edge && edgeReserved(doc, edge, panel.height)) return { status: 'pass' };
|
|
150
|
+
return partial ? {
|
|
151
|
+
status: 'incomplete',
|
|
152
|
+
message: 'Part of this element is currently underneath an opaque fixed panel. 2.4.12 (AAA) allows no part of a focused component to be hidden by author content, and whether that happens depends on where the page sits when focus reaches it — the browser does not scroll an element that is already in the viewport, merely overlapped, so focus can land half-hidden. Tab through the page and check the whole element, indicator included, stays clear of the panel.',
|
|
153
|
+
fix: `Reserve room for the panel with scroll-padding-${edge ?? 'bottom'} on the scrolling container, or move focus clear of it when the panel is up.`,
|
|
154
|
+
} : {
|
|
155
|
+
status: 'incomplete',
|
|
156
|
+
message: 'This element is currently underneath an opaque fixed panel. Whether that breaks 2.4.11 depends on where the page sits when focus reaches it — the browser does not scroll an element that is already in the viewport, merely covered, so focus can land invisibly. Tab through the page and check the focus indicator is never entirely hidden.',
|
|
157
|
+
fix: `Reserve room for the panel with scroll-padding-${edge ?? 'bottom'} on the scrolling container, or move focus clear of it when the panel is up.`,
|
|
158
|
+
};
|
|
159
|
+
});
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export default createFocusObscuredRule({
|
|
35
165
|
id: 'focus-not-obscured',
|
|
36
166
|
name: 'Unobscured focus',
|
|
37
|
-
impact: 'serious',
|
|
38
167
|
tags: ['wcag22aa', 'wcag2411'],
|
|
39
168
|
help: 'Focused elements must not be fully hidden behind overlays',
|
|
40
169
|
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/focus-not-obscured-minimum.html',
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
evaluateAll(elements) {
|
|
44
|
-
const doc = elements[0]?.ownerDocument ?? document;
|
|
45
|
-
const win = doc.defaultView;
|
|
46
|
-
// While a modal (cookie-consent dialog etc.) is open, the page behind
|
|
47
|
-
// it is INTENTIONALLY covered and focus is supposed to be trapped in
|
|
48
|
-
// the modal — a snapshot in that state proves nothing about 2.4.11.
|
|
49
|
-
// The backdrop is often a separate sibling of the dialog, so the modal
|
|
50
|
-
// state is checked document-wide, not per overlay.
|
|
51
|
-
const modal = [...doc.querySelectorAll('dialog[open], [role="dialog"], [role="alertdialog"], [aria-modal="true"]')]
|
|
52
|
-
.some((el) => {
|
|
53
|
-
const rect = el.getBoundingClientRect();
|
|
54
|
-
return rect.width > 0 && rect.height > 0 && getComputedStyle(el).visibility !== 'hidden';
|
|
55
|
-
});
|
|
56
|
-
if (modal) return elements.map(() => ({ status: 'pass' }));
|
|
57
|
-
|
|
58
|
-
// Hit-test per IN-VIEWPORT focusable instead of style-scanning the
|
|
59
|
-
// whole document for overlays: elementsFromPoint at the target's
|
|
60
|
-
// centre surfaces whatever paints above it, and only the handful of
|
|
61
|
-
// on-screen targets pay any cost — a 200k-node spec page costs the
|
|
62
|
-
// same as a landing page. Per-overlay verdicts are memoized.
|
|
63
|
-
const overlayVerdict = new Map();
|
|
64
|
-
const isObscuringOverlay = (layer) => {
|
|
65
|
-
if (overlayVerdict.has(layer)) return overlayVerdict.get(layer);
|
|
66
|
-
let verdict = false;
|
|
67
|
-
const style = getComputedStyle(layer);
|
|
68
|
-
if (style.position === 'fixed' || style.position === 'sticky') {
|
|
69
|
-
const bg = style.backgroundColor.match(/rgba?\(([^)]+)\)/)?.[1]?.split(',');
|
|
70
|
-
const alpha = bg?.[3] === undefined ? 1 : parseFloat(bg[3]);
|
|
71
|
-
const rect = layer.getBoundingClientRect();
|
|
72
|
-
verdict = alpha >= 0.9 && rect.width >= 40 && rect.height >= 24;
|
|
73
|
-
}
|
|
74
|
-
overlayVerdict.set(layer, verdict);
|
|
75
|
-
return verdict;
|
|
76
|
-
};
|
|
77
|
-
const covered = (rect, overlay) =>
|
|
78
|
-
rect.left >= overlay.left && rect.right <= overlay.right
|
|
79
|
-
&& rect.top >= overlay.top && rect.bottom <= overlay.bottom;
|
|
80
|
-
|
|
81
|
-
return elements.map((element) => {
|
|
82
|
-
// `:disabled` catches controls disabled by an ancestor fieldset, which
|
|
83
|
-
// the `disabled` property does not reflect — those never take focus.
|
|
84
|
-
if (element.matches(':disabled')) return { status: 'pass' };
|
|
85
|
-
// Inert content cannot be focused, so nothing can obscure its focus.
|
|
86
|
-
if (isInert(element)) return { status: 'pass' };
|
|
87
|
-
const rect = element.getBoundingClientRect();
|
|
88
|
-
if (!rect.width || !rect.height) return { status: 'pass' };
|
|
89
|
-
// Only in-viewport geometry is trustworthy (or cheap).
|
|
90
|
-
if (rect.bottom < 0 || rect.right < 0 || rect.top > win.innerHeight || rect.left > win.innerWidth) {
|
|
91
|
-
return { status: 'pass' };
|
|
92
|
-
}
|
|
93
|
-
const x = Math.min(Math.max(rect.left + rect.width / 2, 0), win.innerWidth - 1);
|
|
94
|
-
const y = Math.min(Math.max(rect.top + rect.height / 2, 0), win.innerHeight - 1);
|
|
95
|
-
const stack = doc.elementsFromPoint(x, y);
|
|
96
|
-
const index = stack.indexOf(element);
|
|
97
|
-
if (index <= 0) return { status: 'pass' }; // topmost, or not hit-testable
|
|
98
|
-
const blocker = stack.slice(0, index).find((layer) =>
|
|
99
|
-
!layer.contains(element) && !element.contains(layer)
|
|
100
|
-
&& isObscuringOverlay(layer) && covered(rect, layer.getBoundingClientRect()));
|
|
101
|
-
if (!blocker) return { status: 'pass' };
|
|
102
|
-
// Which viewport edge is the panel pinned to? That is the edge the
|
|
103
|
-
// browser's scroll-into-view has to clear.
|
|
104
|
-
const panel = blocker.getBoundingClientRect();
|
|
105
|
-
const edge = panel.top <= 1 && panel.bottom < win.innerHeight ? 'top'
|
|
106
|
-
: panel.bottom >= win.innerHeight - 1 ? 'bottom' : null;
|
|
107
|
-
if (edge && edgeReserved(doc, edge, panel.height)) return { status: 'pass' };
|
|
108
|
-
return {
|
|
109
|
-
status: 'incomplete',
|
|
110
|
-
message: 'This element is currently underneath an opaque fixed panel. Whether that breaks 2.4.11 depends on where the page sits when focus reaches it — the browser does not scroll an element that is already in the viewport, merely covered, so focus can land invisibly. Tab through the page and check the focus indicator is never entirely hidden.',
|
|
111
|
-
fix: `Reserve room for the panel with scroll-padding-${edge ?? 'bottom'} on the scrolling container, or move focus clear of it when the panel is up.`,
|
|
112
|
-
};
|
|
113
|
-
});
|
|
114
|
-
},
|
|
115
|
-
};
|
|
170
|
+
partial: false,
|
|
171
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// WCAG SC 2.4.12 Focus Not Obscured (Enhanced) (Level AAA)
|
|
2
|
+
// 2.4.11's geometry with the containment requirement relaxed to any
|
|
3
|
+
// overlap: the enhanced criterion allows NO part of a focused component to
|
|
4
|
+
// be hidden by author-created content, and its Understanding names the
|
|
5
|
+
// sticky footer that clips the bottom of a focused link as the failure.
|
|
6
|
+
// Same review-not-fail discipline as the minimum rule: the snapshot proves
|
|
7
|
+
// where an element sits now, not where focus will find it.
|
|
8
|
+
import { createFocusObscuredRule } from './2.4.11-focus-not-obscured.js';
|
|
9
|
+
|
|
10
|
+
export default createFocusObscuredRule({
|
|
11
|
+
id: 'focus-not-obscured-enhanced',
|
|
12
|
+
name: 'Fully unobscured focus',
|
|
13
|
+
tags: ['wcag22aaa', 'wcag2412'],
|
|
14
|
+
help: 'No part of a focused element may be hidden behind overlays (AAA)',
|
|
15
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/focus-not-obscured-enhanced.html',
|
|
16
|
+
partial: true,
|
|
17
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// WCAG SC 2.4.6 Headings and Labels (Level AA)
|
|
2
|
+
// "Headings and labels describe topic or purpose." The criterion does not
|
|
3
|
+
// require a heading or label to exist; it requires that one that IS there
|
|
4
|
+
// says what its section or field is about. Whether real words do that
|
|
5
|
+
// needs a reader. What the DOM can show is the opposite case: a heading or
|
|
6
|
+
// label whose text is a page builder's scaffold string ("Add Your Heading
|
|
7
|
+
// Text Here", "Lorem ipsum", "Heading 1") left in place, which describes
|
|
8
|
+
// nothing by construction. Same two tiers as the page-title rule:
|
|
9
|
+
// - scaffold strings and lorem ipsum FAIL: no page is about them;
|
|
10
|
+
// - a single generic word ("Heading", "Title", "Label") is REVIEWED: an
|
|
11
|
+
// article can genuinely be titled "Untitled" and a design-system page
|
|
12
|
+
// can be about headings.
|
|
13
|
+
// Empty headings belong to the best-practice empty-heading rule; missing
|
|
14
|
+
// labels belong to 3.3.2 and 4.1.2. This rule reads visible text only and
|
|
15
|
+
// never judges the meaning of ordinary words.
|
|
16
|
+
const SCAFFOLD = new Set([
|
|
17
|
+
'add your heading text here', 'add a heading', 'add heading', 'your heading here', 'heading here',
|
|
18
|
+
'your title goes here', 'your title here', 'title goes here', 'title here', 'insert title here',
|
|
19
|
+
'insert heading here', 'heading text', 'heading goes here', 'section title here',
|
|
20
|
+
'placeholder', 'placeholder text', 'placeholder heading', 'label text', 'your label here',
|
|
21
|
+
'text here', 'enter text here', 'your text here', 'type here', 'sample heading', 'sample text',
|
|
22
|
+
'dummy text', 'dummy heading', 'todo', 'tbd', 'xxx', 'xxxx', 'asdf',
|
|
23
|
+
'this is a heading', 'this is a title', 'this is the heading', 'this is a label',
|
|
24
|
+
'new heading', 'new label', 'new section',
|
|
25
|
+
]);
|
|
26
|
+
const GENERIC = new Set([
|
|
27
|
+
'heading', 'title', 'subtitle', 'headline', 'label', 'untitled', 'header', 'text', 'default',
|
|
28
|
+
'section', 'section title', 'section heading', 'page title', 'field', 'input',
|
|
29
|
+
]);
|
|
30
|
+
// "Heading 1" … "Heading 6", "h1" … "h6", "Title 2": the numbered defaults
|
|
31
|
+
// editors and builders insert.
|
|
32
|
+
const NUMBERED = /^(?:heading|title|label|h)\s*[1-6]$/;
|
|
33
|
+
|
|
34
|
+
export default {
|
|
35
|
+
id: 'heading-label-placeholder',
|
|
36
|
+
name: 'Placeholder headings and labels',
|
|
37
|
+
impact: 'moderate',
|
|
38
|
+
tags: ['wcag2aa', 'wcag246'],
|
|
39
|
+
help: 'Headings and labels must describe their topic or purpose, not be template text',
|
|
40
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/headings-and-labels.html',
|
|
41
|
+
selector: 'h1, h2, h3, h4, h5, h6, [role="heading"], label, legend',
|
|
42
|
+
evaluate(element) {
|
|
43
|
+
const role = element.getAttribute('role');
|
|
44
|
+
if (/^h[1-6]$/i.test(element.tagName) && role && role !== 'heading') return { status: 'pass' };
|
|
45
|
+
const text = (element.textContent ?? '').replace(/\s+/g, ' ').trim();
|
|
46
|
+
if (!text) return { status: 'pass' };
|
|
47
|
+
const normalized = text.toLowerCase().replace(/[\s.:!…-]+$/, '');
|
|
48
|
+
const kind = /^h[1-6]$/i.test(element.tagName) || role === 'heading' ? 'heading' : 'label';
|
|
49
|
+
if (SCAFFOLD.has(normalized) || normalized.startsWith('lorem ipsum') || NUMBERED.test(normalized)) {
|
|
50
|
+
return {
|
|
51
|
+
status: 'fail',
|
|
52
|
+
message: `“${text}” is template text left in a ${kind}. It describes nothing, so a screen-reader user scanning by ${kind}s, or anyone reading the form, learns nothing from it.`,
|
|
53
|
+
fix: kind === 'heading'
|
|
54
|
+
? 'Replace it with words that name what the section is about.'
|
|
55
|
+
: 'Replace it with words that name what the field asks for.',
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (GENERIC.has(normalized)) {
|
|
59
|
+
return {
|
|
60
|
+
status: 'incomplete',
|
|
61
|
+
message: `“${text}” is a generic ${kind}. Check whether it describes the topic of its section or the purpose of its field; the same word can also be a real subject or name.`,
|
|
62
|
+
fix: kind === 'heading'
|
|
63
|
+
? 'Name the section\'s actual topic in the heading.'
|
|
64
|
+
: 'Name what the field asks for in the label.',
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return { status: 'pass' };
|
|
68
|
+
},
|
|
69
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// WCAG SC 2.5.1 Pointer Gestures (Level A)
|
|
2
|
+
// Functionality operated by a path-based gesture (a swipe, a drag along a
|
|
3
|
+
// track, a drawn shape) or a multipoint gesture (a pinch) must also work
|
|
4
|
+
// with a single pointer and no path, unless the gesture is essential. The
|
|
5
|
+
// gesture logic itself lives in script, which a scan cannot read. But a
|
|
6
|
+
// page that implements its own swipe, pan or pinch has to tell the
|
|
7
|
+
// browser to stand aside first, and it does that in one visible place: the
|
|
8
|
+
// CSS touch-action property. `touch-action: none` hands every touch to
|
|
9
|
+
// the script; `pan-y` keeps vertical scrolling native and hands horizontal
|
|
10
|
+
// swipes (the carousel) to the script; `pan-x` the reverse; a value
|
|
11
|
+
// without `pinch-zoom` hands the pinch (the map) to the script. Those
|
|
12
|
+
// elements are the honest candidates. Whether the swipe or pinch has a
|
|
13
|
+
// single-pointer twin (next and previous buttons, zoom buttons) is not
|
|
14
|
+
// decidable from markup, so this asks and never asserts.
|
|
15
|
+
//
|
|
16
|
+
// Kept out, and why:
|
|
17
|
+
// - `auto` and `manipulation`: nothing is handed to script beyond the
|
|
18
|
+
// double-tap zoom delay.
|
|
19
|
+
// - `pan-x pan-y` (both axes native, only the pinch withheld): measured
|
|
20
|
+
// on the corpus as a scroll-snap carousel and "no pinch zoom" wrappers,
|
|
21
|
+
// never a pinch handler; the browser still does all the panning.
|
|
22
|
+
// - range inputs and role="slider": the Understanding says a slider that
|
|
23
|
+
// drifts off its track is not path-based, and G216 covers it.
|
|
24
|
+
// - draggable="true" elements: 2.5.7's question (drag-alternative).
|
|
25
|
+
// - elements under 100 by 60 px: a gesture surface that small is not one.
|
|
26
|
+
// Only the outermost candidate in a subtree is reported, once; descendants
|
|
27
|
+
// that inherit or repeat the declaration ride with it. Stylesheets a
|
|
28
|
+
// cross-origin policy hides are not a problem here: the computed value on
|
|
29
|
+
// the element is read, whatever sheet set it.
|
|
30
|
+
const HANDLED = new Set(['auto', 'manipulation']);
|
|
31
|
+
|
|
32
|
+
function handsGestureToScript(value) {
|
|
33
|
+
if (!value || HANDLED.has(value)) return false;
|
|
34
|
+
const tokens = value.split(/\s+/);
|
|
35
|
+
if (tokens.includes('none')) return true;
|
|
36
|
+
const panX = tokens.some((t) => /^pan-(x|left|right)$/.test(t));
|
|
37
|
+
const panY = tokens.some((t) => /^pan-(y|up|down)$/.test(t));
|
|
38
|
+
// One axis withheld is a swipe handler (pan-y is the carousel; pan-x the
|
|
39
|
+
// vertical one); both axes native is the browser scrolling as usual.
|
|
40
|
+
return !panX || !panY;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export default {
|
|
44
|
+
id: 'pointer-gesture-alternative',
|
|
45
|
+
name: 'Pointer gesture alternative',
|
|
46
|
+
impact: 'serious',
|
|
47
|
+
tags: ['wcag21a', 'wcag251'],
|
|
48
|
+
help: 'Swipe, pan and pinch gestures need a single-pointer alternative',
|
|
49
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/pointer-gestures.html',
|
|
50
|
+
selector: '*',
|
|
51
|
+
visibleOnly: false,
|
|
52
|
+
evaluateAll(elements, { isRendered }) {
|
|
53
|
+
const candidates = new Set();
|
|
54
|
+
for (const element of elements) {
|
|
55
|
+
const tag = element.tagName;
|
|
56
|
+
if (tag === 'HTML' || tag === 'BODY' || tag === 'SCRIPT' || tag === 'STYLE' || tag === 'LINK' || tag === 'META' || tag === 'TEMPLATE') continue;
|
|
57
|
+
// Cheap gates first: the style read is paid only by rendered boxes
|
|
58
|
+
// that could be a gesture surface at all.
|
|
59
|
+
if (element.getAttribute('draggable') === 'true' || element.getAttribute('role') === 'slider' || (tag === 'INPUT' && element.type === 'range')) continue;
|
|
60
|
+
const rect = element.getBoundingClientRect();
|
|
61
|
+
if (rect.width < 100 || rect.height < 60) continue;
|
|
62
|
+
if (!isRendered(element)) continue;
|
|
63
|
+
const value = getComputedStyle(element).touchAction;
|
|
64
|
+
if (!handsGestureToScript(value)) continue;
|
|
65
|
+
candidates.add(element);
|
|
66
|
+
}
|
|
67
|
+
// Outermost only, and one per row of siblings: a carousel's track,
|
|
68
|
+
// slides and images all inherit the same declaration (and a cloned
|
|
69
|
+
// slide strip repeats it eleven times, measured on a corpus home), and
|
|
70
|
+
// one question per gesture surface is enough.
|
|
71
|
+
const outermost = new Set();
|
|
72
|
+
const reportedParents = new Set();
|
|
73
|
+
for (const element of candidates) {
|
|
74
|
+
let nested = false;
|
|
75
|
+
for (let parent = element.parentElement; parent; parent = parent.parentElement) {
|
|
76
|
+
if (candidates.has(parent)) { nested = true; break; }
|
|
77
|
+
}
|
|
78
|
+
if (nested || reportedParents.has(element.parentElement)) continue;
|
|
79
|
+
reportedParents.add(element.parentElement);
|
|
80
|
+
outermost.add(element);
|
|
81
|
+
}
|
|
82
|
+
return elements.map((element) => {
|
|
83
|
+
if (!outermost.has(element)) return { status: 'pass' };
|
|
84
|
+
const value = getComputedStyle(element).touchAction;
|
|
85
|
+
const tokens = value.split(/\s+/);
|
|
86
|
+
const handled = tokens.includes('none') ? 'every touch gesture'
|
|
87
|
+
: [
|
|
88
|
+
!tokens.some((t) => /^pan-(x|left|right)$/.test(t)) && 'horizontal swipes',
|
|
89
|
+
!tokens.some((t) => /^pan-(y|up|down)$/.test(t)) && 'vertical swipes',
|
|
90
|
+
].filter(Boolean).join(' and ');
|
|
91
|
+
return {
|
|
92
|
+
status: 'incomplete',
|
|
93
|
+
message: `touch-action: ${value} tells the browser to hand ${handled} on this element to the page's own script, which is how custom swipe, pan and pinch interactions are built. 2.5.1 requires whatever those gestures do to also work with a single tap or click (previous and next buttons, zoom in and out, a choose-a-value control), unless the gesture is essential. Check a single-pointer way exists for everything the gesture does.`,
|
|
94
|
+
fix: 'Add plain buttons for the same actions (previous/next, zoom in/out), or make the element operable by simple clicks as well as gestures.',
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
},
|
|
98
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// WCAG SC 3.3.4 Error Prevention (Legal, Financial, Data) (Level AA)
|
|
2
|
+
// For a page that causes a financial transaction, a legal commitment or a
|
|
3
|
+
// change to stored user data, a submission must be reversible, checked
|
|
4
|
+
// with a chance to correct, or reviewable and confirmable before it is
|
|
5
|
+
// final. Which of those a form offers is process, invisible to a scan; but
|
|
6
|
+
// one class of page names itself: a form that collects card details
|
|
7
|
+
// (autocomplete cc-number, cc-csc, cc-exp and the rest, or a
|
|
8
|
+
// transaction-amount) is a payment form, and a payment is the financial
|
|
9
|
+
// transaction the criterion describes. That form is asked about, once,
|
|
10
|
+
// never failed: a review step may sit on the next page, and a purchase
|
|
11
|
+
// may be refundable within a stated period (G164).
|
|
12
|
+
//
|
|
13
|
+
// Deletion of user data and legal commitments have no markup signature
|
|
14
|
+
// and stay with the reviewer.
|
|
15
|
+
export default {
|
|
16
|
+
id: 'financial-form-confirmation',
|
|
17
|
+
name: 'Payment form safeguards',
|
|
18
|
+
impact: 'serious',
|
|
19
|
+
tags: ['wcag2aa', 'wcag334'],
|
|
20
|
+
help: 'A form that takes a payment must be reversible, checked or confirmed before submission',
|
|
21
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/error-prevention-legal-financial-data.html',
|
|
22
|
+
selector: 'form',
|
|
23
|
+
visibleOnly: false,
|
|
24
|
+
evaluate(element) {
|
|
25
|
+
if (!element.querySelector('input[autocomplete*="cc-" i], input[autocomplete~="transaction-amount" i]')) return { status: 'pass' };
|
|
26
|
+
return {
|
|
27
|
+
status: 'incomplete',
|
|
28
|
+
message: 'This form collects card details, so submitting it makes a financial transaction. 3.3.4 requires at least one of: the submission can be reversed, the entries are checked for errors with a chance to correct them, or a review-and-confirm step comes before the final submit. Check which applies to this checkout.',
|
|
29
|
+
fix: 'Add a review page before the final submit, validate the entries and let the user correct them, or state a period in which the order can be amended or cancelled.',
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
};
|
|
@@ -32,7 +32,10 @@ export default {
|
|
|
32
32
|
id: 'auth-field-obstruction',
|
|
33
33
|
name: 'Accessible login fields',
|
|
34
34
|
impact: 'serious',
|
|
35
|
-
|
|
35
|
+
// 3.3.9 (AAA) drops 3.3.8's object-recognition and personal-content
|
|
36
|
+
// exceptions and keeps everything this rule looks at: F109 is listed as a
|
|
37
|
+
// failure of both. Twin-tagged on the 2.1.1/2.1.3 precedent.
|
|
38
|
+
tags: ['wcag22aa', 'wcag338', 'wcag339'],
|
|
36
39
|
help: 'Login fields must not block paste or password managers',
|
|
37
40
|
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/accessible-authentication-minimum.html',
|
|
38
41
|
selector: 'input[type="password"]:not([autocomplete~="new-password"]), input[autocomplete~="current-password"], input[autocomplete~="one-time-code"]',
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// WCAG SC 3.3.9 Accessible Authentication (Enhanced) (Level AAA)
|
|
2
|
+
// 3.3.8 lets an authentication step use object recognition ("select every
|
|
3
|
+
// bus") and personal content; 3.3.9 removes both exceptions, so a picture
|
|
4
|
+
// puzzle in a login step needs an alternative method or a mechanism that
|
|
5
|
+
// helps the user through it. The one CAPTCHA fact the DOM shows is the
|
|
6
|
+
// widget itself: the reCAPTCHA, hCaptcha and Turnstile embeds and their
|
|
7
|
+
// site-key hosts. Whether that widget will ever show a picture puzzle
|
|
8
|
+
// (risk-based and invisible modes often do not) and whether another way in
|
|
9
|
+
// exists are not visible, so this asks and never asserts.
|
|
10
|
+
//
|
|
11
|
+
// Scope: the criterion covers steps of an AUTHENTICATION process. A
|
|
12
|
+
// CAPTCHA on a contact form or a comment box is outside it, so only a
|
|
13
|
+
// widget inside a form that also takes a credential (a password or a
|
|
14
|
+
// one-time code) is asked about.
|
|
15
|
+
const CREDENTIAL = 'input[type="password"], input[autocomplete~="current-password"], input[autocomplete~="one-time-code"]';
|
|
16
|
+
|
|
17
|
+
export default {
|
|
18
|
+
id: 'captcha-alternative',
|
|
19
|
+
name: 'CAPTCHA in a login step',
|
|
20
|
+
impact: 'serious',
|
|
21
|
+
tags: ['wcag22aaa', 'wcag339'],
|
|
22
|
+
help: 'A picture-puzzle CAPTCHA in an authentication step needs an alternative (AAA)',
|
|
23
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/accessible-authentication-enhanced.html',
|
|
24
|
+
selector: 'iframe[src*="recaptcha" i], iframe[src*="hcaptcha" i], iframe[src*="turnstile" i], .g-recaptcha, .h-captcha, .cf-turnstile, [data-sitekey]',
|
|
25
|
+
visibleOnly: false,
|
|
26
|
+
evaluate(element) {
|
|
27
|
+
const form = element.closest('form');
|
|
28
|
+
if (!form || !form.querySelector(CREDENTIAL)) return { status: 'pass' };
|
|
29
|
+
return {
|
|
30
|
+
status: 'incomplete',
|
|
31
|
+
message: 'This login form carries a CAPTCHA widget. If the challenge can escalate to a picture puzzle, that is object recognition, which 3.3.8 allows but 3.3.9 (AAA) does not: a cognitive function test in an authentication step then needs another way in (a passkey, an emailed link, a federated sign-in) or a mechanism that helps the user through it. Check what the widget can show and what the alternatives are.',
|
|
32
|
+
fix: 'Use a risk-based or invisible challenge that never shows a puzzle, or offer another authentication method beside it.',
|
|
33
|
+
};
|
|
34
|
+
},
|
|
35
|
+
};
|
package/engine/wcag22.js
CHANGED
|
@@ -27,9 +27,9 @@ export default [
|
|
|
27
27
|
sc('1.1.1', 'Non-text Content', 'A', 'partial'),
|
|
28
28
|
sc('1.2.1', 'Audio-only and Video-only (Prerecorded)', 'A', 'partial'), // partial: the audio-only transcript question is askable; silent-video detection is not
|
|
29
29
|
sc('1.2.2', 'Captions (Prerecorded)', 'A', 'partial'),
|
|
30
|
-
sc('1.2.3', 'Audio Description or Media Alternative (Prerecorded)', 'A', '
|
|
30
|
+
sc('1.2.3', 'Audio Description or Media Alternative (Prerecorded)', 'A', 'partial'), // partial: video-audio-description asks per sound-capable video without a descriptions track; whether description or a text alternative exists, or is needed at all, is the reviewer's
|
|
31
31
|
sc('1.2.4', 'Captions (Live)', 'AA', 'manual'),
|
|
32
|
-
sc('1.2.5', 'Audio Description (Prerecorded)', 'AA', '
|
|
32
|
+
sc('1.2.5', 'Audio Description (Prerecorded)', 'AA', 'partial'), // partial: same question as 1.2.3 (video-audio-description); H96 descriptions track is the one DOM-visible pass
|
|
33
33
|
sc('1.2.6', 'Sign Language (Prerecorded)', 'AAA', 'manual'),
|
|
34
34
|
sc('1.2.7', 'Extended Audio Description (Prerecorded)', 'AAA', 'manual'),
|
|
35
35
|
sc('1.2.8', 'Media Alternative (Prerecorded)', 'AAA', 'manual'),
|
|
@@ -39,7 +39,7 @@ export default [
|
|
|
39
39
|
sc('1.3.3', 'Sensory Characteristics', 'A', 'manual'),
|
|
40
40
|
sc('1.3.4', 'Orientation', 'AA', 'partial', '2.1'), // orientation-lock asserts CSS root hides/rotations as ACT b33eff does, essential exception assumed absent; script locks stay a human check
|
|
41
41
|
sc('1.3.5', 'Identify Input Purpose', 'AA', 'partial', '2.1'), // partial: wrong tokens are asserted as ACT 73f2c2 does (search boxes asked); MISSING autocomplete on identity fields needs judgment
|
|
42
|
-
sc('1.3.6', 'Identify Purpose', 'AAA', '
|
|
42
|
+
sc('1.3.6', 'Identify Purpose', 'AAA', 'partial', '2.1'), // partial: region-purpose asks once per page with no landmark at all (ARIA11 is the regions technique); component and icon purpose stays human
|
|
43
43
|
sc('1.4.1', 'Use of Color', 'A', 'partial'), // link-in-text-block automates the link case
|
|
44
44
|
sc('1.4.2', 'Audio Control', 'A', 'partial'), // declarative autoplay identifies candidates; audibility and alternative controls need review
|
|
45
45
|
sc('1.4.3', 'Contrast (Minimum)', 'AA', 'partial'),
|
|
@@ -47,7 +47,7 @@ export default [
|
|
|
47
47
|
sc('1.4.5', 'Images of Text', 'AA', 'manual'),
|
|
48
48
|
sc('1.4.6', 'Contrast (Enhanced)', 'AAA', 'partial'),
|
|
49
49
|
sc('1.4.7', 'Low or No Background Audio', 'AAA', 'manual'),
|
|
50
|
-
sc('1.4.8', 'Visual Presentation', 'AAA', '
|
|
50
|
+
sc('1.4.8', 'Visual Presentation', 'AAA', 'partial'), // partial: text-justified asks about justified running text (F88); the other four requirements are met by browser mechanisms and not judged
|
|
51
51
|
sc('1.4.9', 'Images of Text (No Exception)', 'AAA', 'manual'),
|
|
52
52
|
sc('1.4.10', 'Reflow', 'AA', 'partial', '2.1'), // partial: reflow (current-viewport overflow heuristic); full test is at 320px
|
|
53
53
|
sc('1.4.11', 'Non-text Contrast', 'AA', 'partial', '2.1'), // partial: non-text-contrast covers field boundaries; icons/focus indicators need eyes
|
|
@@ -61,7 +61,7 @@ export default [
|
|
|
61
61
|
sc('2.2.1', 'Timing Adjustable', 'A', 'partial'), // meta-refresh automates the redirect/refresh case
|
|
62
62
|
sc('2.2.2', 'Pause, Stop, Hide', 'A', 'partial'),
|
|
63
63
|
sc('2.2.3', 'No Timing', 'AAA', 'manual'),
|
|
64
|
-
sc('2.2.4', 'Interruptions', 'AAA', '
|
|
64
|
+
sc('2.2.4', 'Interruptions', 'AAA', 'partial'), // partial: meta-refresh-no-exceptions asserts any timed refresh or redirect (F40, F41); scripted interruptions are invisible
|
|
65
65
|
sc('2.2.5', 'Re-authenticating', 'AAA', 'manual'),
|
|
66
66
|
sc('2.2.6', 'Timeouts', 'AAA', 'manual', '2.1'),
|
|
67
67
|
sc('2.3.1', 'Three Flashes or Below Threshold', 'A', 'manual'),
|
|
@@ -72,15 +72,15 @@ export default [
|
|
|
72
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
|
-
sc('2.4.6', 'Headings and Labels', 'AA', '
|
|
75
|
+
sc('2.4.6', 'Headings and Labels', 'AA', 'partial'), // partial: heading-label-placeholder asserts template text left in a heading or label and asks about single generic words; whether real words describe the topic needs a reader
|
|
76
76
|
sc('2.4.7', 'Focus Visible', 'AA', 'partial'), // partial: focus-visible flags outline suppression; the indicator itself needs eyes
|
|
77
77
|
sc('2.4.8', 'Location', 'AAA', 'manual'),
|
|
78
78
|
sc('2.4.9', 'Link Purpose (Link Only)', 'AAA', 'partial'), // generic wording nominates candidates; whether wording explains the destination needs a reader
|
|
79
|
-
sc('2.4.10', 'Section Headings', 'AAA', '
|
|
79
|
+
sc('2.4.10', 'Section Headings', 'AAA', 'partial'), // partial: section-heading asks about a declared <section> of running text with no heading of its own; where sections begin in undeclared writing stays human
|
|
80
80
|
sc('2.4.11', 'Focus Not Obscured (Minimum)', 'AA', 'partial', '2.2'), // covered resting targets are candidates; actual focus/scroll behavior needs review
|
|
81
|
-
sc('2.4.12', 'Focus Not Obscured (Enhanced)', 'AAA', '
|
|
81
|
+
sc('2.4.12', 'Focus Not Obscured (Enhanced)', 'AAA', 'partial', '2.2'), // partial: focus-not-obscured-enhanced reviews any overlap by an opaque fixed panel, 2.4.11's geometry without the containment requirement
|
|
82
82
|
sc('2.4.13', 'Focus Appearance', 'AAA', 'manual', '2.2'),
|
|
83
|
-
sc('2.5.1', 'Pointer Gestures', 'A', '
|
|
83
|
+
sc('2.5.1', 'Pointer Gestures', 'A', 'partial', '2.1'), // partial: pointer-gesture-alternative asks about surfaces whose touch-action hands swipes or every touch to script (carousels, maps, canvases); pointer-event gesture logic with no declaration is invisible
|
|
84
84
|
sc('2.5.2', 'Pointer Cancellation', 'A', 'manual', '2.1'),
|
|
85
85
|
sc('2.5.3', 'Label in Name', 'A', 'manual', '2.1'), // manual WHILE label-in-name is parked (re-parked 2026-08-01, see rules/index.js) — 'auto' with no active rule would make this SC vanish from results AND the manual checklist
|
|
86
86
|
sc('2.5.4', 'Motion Actuation', 'A', 'manual', '2.1'),
|
|
@@ -99,17 +99,17 @@ export default [
|
|
|
99
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
|
-
sc('3.2.5', 'Change on Request', 'AAA', '
|
|
102
|
+
sc('3.2.5', 'Change on Request', 'AAA', 'partial'), // partial: meta-refresh-no-exceptions (F40, F41); scripted context changes and pop-ups on load are invisible
|
|
103
103
|
sc('3.2.6', 'Consistent Help', 'A', 'manual', '2.2'),
|
|
104
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
|
-
sc('3.3.4', 'Error Prevention (Legal, Financial, Data)', 'AA', '
|
|
107
|
+
sc('3.3.4', 'Error Prevention (Legal, Financial, Data)', 'AA', 'partial'), // partial: financial-form-confirmation asks once per form that takes card details; legal commitments and data deletion have no markup signature
|
|
108
108
|
sc('3.3.5', 'Help', 'AAA', 'manual'),
|
|
109
109
|
sc('3.3.6', 'Error Prevention (All)', 'AAA', 'manual'),
|
|
110
110
|
sc('3.3.7', 'Redundant Entry', 'A', 'partial', '2.2'), // partial: duplicate autocomplete purposes in one form are visible; cross-page processes are not
|
|
111
111
|
sc('3.3.8', 'Accessible Authentication (Minimum)', 'AA', 'partial', '2.2'), // partial: auth-field-obstruction catches paste blocking; alternatives need judgment
|
|
112
|
-
sc('3.3.9', 'Accessible Authentication (Enhanced)', 'AAA', '
|
|
112
|
+
sc('3.3.9', 'Accessible Authentication (Enhanced)', 'AAA', 'partial', '2.2'), // partial: auth-field-obstruction's F109 findings apply unchanged (twin tag) and captcha-alternative asks about a CAPTCHA beside a credential, the object-recognition exception 3.3.9 removes
|
|
113
113
|
// 4. Robust (4.1.1 Parsing was removed in WCAG 2.2)
|
|
114
114
|
sc('4.1.2', 'Name, Role, Value', 'A', 'partial'),
|
|
115
115
|
sc('4.1.3', 'Status Messages', 'AA', 'manual', '2.1'),
|
package/package.json
CHANGED