pour-engine 1.19.0 → 1.20.0

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