pour-engine 1.41.0 → 1.42.1

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.
@@ -6,6 +6,6 @@
6
6
  export default {
7
7
  engine: {
8
8
  name: 'pour engine',
9
- version: '1.41.0',
9
+ version: '1.42.1',
10
10
  },
11
11
  };
@@ -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 | Manual review | — |
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 | Manual review | — |
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 | Manual review | — |
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 | Manual review | — |
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 | Manual review | — |
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 | Manual review | — |
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 | Manual review | — |
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 | Manual review | — |
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 | Manual review | — |
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,23 +97,23 @@ 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 | Manual review | — |
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 | 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 | 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 | Manual review | — |
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
 
114
114
  | SC | Name | Level | Coverage | Rules |
115
115
  |---|---|---|---|---|
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 |
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, 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:** 39 partially automated · 47 manual — with 88 rules.
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
- 88 rules, hardened on real sites and the `testbeds/` pages. Audits walk into
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
@@ -1,18 +1,18 @@
1
- // WCAG SC 4.1.2 Name, Role, Value (Level A)
2
- // ARIA 1.2 marks dialog/alertdialog as name-required roles, and dialogs are
3
- // NOT name-from-content — the body text is the dialog's content, never its
4
- // name. An open, unnamed modal announces as just "dialog", leaving
5
- // screen-reader users with no idea what interrupted them (cookie-consent
6
- // modals are the canonical offender).
1
+ // A dialog with no accessible name is announced as just "dialog". ARIA 1.2
2
+ // requires authors to name one, but WCAG 4.1.2 is written for user interface
3
+ // components, defined as controls, and a dialog is a container of controls
4
+ // whose own names 4.1.2 already covers. So the pattern is an ARIA authoring
5
+ // failure, not a WCAG one: demoted from 4.1.2 to best practice on 2026-09-13
6
+ // after rnib.org.uk's unnamed cookie banner was challenged.
7
7
  import { labelledByName } from '../../lib/accessible-name.js';
8
8
 
9
9
  export default {
10
10
  id: 'dialog-name',
11
11
  name: 'Dialog names',
12
12
  impact: 'serious',
13
- tags: ['wcag2a', 'wcag412'],
14
- help: 'Dialogs must have an accessible name',
15
- helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
13
+ tags: ['best-practice'],
14
+ help: 'Dialogs should have an accessible name',
15
+ helpUrl: 'https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/',
16
16
  // Default visibility filter: a closed <dialog> is display:none, so only
17
17
  // dialogs the user can actually encounter are judged.
18
18
  selector: 'dialog, [role="dialog"], [role="alertdialog"]',
@@ -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), ' +
@@ -76,7 +76,9 @@ import scrollableRegionFocusable from './wcag/2.1.1-scrollable-region-focusable.
76
76
  import tableHeaders from './wcag/1.3.1-table-headers.js';
77
77
  import contrastEnhanced from './wcag/1.4.6-contrast-enhanced.js';
78
78
  import targetSizeEnhanced from './wcag/2.5.5-target-size-enhanced.js';
79
- import dialogName from './wcag/4.1.2-dialog-name.js';
79
+ // Demoted to best-practice 2026-09-13: ARIA requires a dialog name, but 4.1.2
80
+ // covers controls and a dialog is their container (see the rule file).
81
+ import dialogName from './best-practice/dialog-name.js';
80
82
  import controlContrast from './wcag/1.4.3-control-contrast.js';
81
83
  import nonTextContrast from './wcag/1.4.11-non-text-contrast.js';
82
84
  import textSpacing from './wcag/1.4.12-text-spacing.js';
@@ -109,6 +111,25 @@ import onInputNavigation from './wcag/3.2.2-on-input-navigation.js';
109
111
  import errorMessageLinkage from './wcag/3.3.1-error-message-linkage.js';
110
112
  import compositeWidgetName from './wcag/4.1.2-composite-widget-name.js';
111
113
  import summaryName from './wcag/4.1.2-summary-name.js';
114
+ // 2026-09-12 manual-to-partial batch (reports/coverage-gaps/2026-09-12): the
115
+ // slices of twelve criteria that had no rule where the DOM shows a real
116
+ // candidate. Two AAA siblings built from existing factories (a timed refresh
117
+ // with no 20-hour allowance, any overlap of a focused element); eight
118
+ // review questions on declared facts (a video without a descriptions
119
+ // track, a declared <section> with no heading, a touch-action surface, a
120
+ // payment form, a CAPTCHA beside a credential, a page with no landmarks,
121
+ // justified running text) and one assert on template text left in a
122
+ // heading or label. Two assert, the rest ask.
123
+ import metaRefreshNoExceptions from './wcag/2.2.4-meta-refresh-no-exceptions.js';
124
+ import focusNotObscuredEnhanced from './wcag/2.4.12-focus-not-obscured-enhanced.js';
125
+ import videoAudioDescription from './wcag/1.2.3-video-audio-description.js';
126
+ import headingLabelPlaceholder from './wcag/2.4.6-heading-label-placeholder.js';
127
+ import textJustified from './wcag/1.4.8-text-justified.js';
128
+ import sectionHeading from './wcag/2.4.10-section-heading.js';
129
+ import captchaAlternative from './wcag/3.3.9-captcha-alternative.js';
130
+ import regionPurpose from './wcag/1.3.6-region-purpose.js';
131
+ import financialFormConfirmation from './wcag/3.3.4-financial-form-confirmation.js';
132
+ import pointerGestureAlternative from './wcag/2.5.1-pointer-gesture-alternative.js';
112
133
 
113
134
  // --- Best practice ---
114
135
  import headingOrder from './best-practice/heading-order.js';
@@ -210,6 +231,16 @@ export default [
210
231
  errorMessageLinkage,
211
232
  compositeWidgetName,
212
233
  summaryName,
234
+ metaRefreshNoExceptions,
235
+ focusNotObscuredEnhanced,
236
+ videoAudioDescription,
237
+ headingLabelPlaceholder,
238
+ textJustified,
239
+ sectionHeading,
240
+ captchaAlternative,
241
+ regionPurpose,
242
+ financialFormConfirmation,
243
+ pointerGestureAlternative,
213
244
  contrastEnhanced,
214
245
  targetSizeEnhanced,
215
246
  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
- export default {
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
- selector: FOCUSABLE,
42
- visibility: 'visual',
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
- tags: ['wcag22aa', 'wcag338'],
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', 'manual'),
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', 'manual'),
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', 'manual', '2.1'),
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', 'manual'),
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', 'manual'),
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', 'manual'),
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', 'manual'),
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', 'manual', '2.2'),
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', 'manual', '2.1'),
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', 'manual'),
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', 'manual'),
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', 'manual', '2.2'),
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pour-engine",
3
- "version": "1.41.0",
3
+ "version": "1.42.1",
4
4
  "description": "Accessibility (WCAG 2.2) audit engine written from the W3C specifications — runs entirely in the browser, no dependencies.",
5
5
  "type": "module",
6
6
  "main": "engine/index.js",