pour-engine 1.34.1 → 1.35.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.
@@ -6,6 +6,6 @@
6
6
  export default {
7
7
  engine: {
8
8
  name: 'pour engine',
9
- version: '1.34.1',
9
+ version: '1.35.0',
10
10
  },
11
11
  };
@@ -58,7 +58,7 @@ meaning that only a person can settle, even where a rule decides most real pages
58
58
  | 2.2.6 | [Timeouts](https://www.w3.org/WAI/WCAG22/Understanding/timeouts.html) | AAA | Manual review | — |
59
59
  | 2.3.1 | [Three Flashes or Below Threshold](https://www.w3.org/WAI/WCAG22/Understanding/three-flashes-or-below-threshold.html) | A | Manual review | — |
60
60
  | 2.3.2 | [Three Flashes](https://www.w3.org/WAI/WCAG22/Understanding/three-flashes.html) | AAA | Manual review | — |
61
- | 2.3.3 | [Animation from Interactions](https://www.w3.org/WAI/WCAG22/Understanding/animation-from-interactions.html) | AAA | Manual review | — |
61
+ | 2.3.3 | [Animation from Interactions](https://www.w3.org/WAI/WCAG22/Understanding/animation-from-interactions.html) | AAA | Partially automated | reduced-motion |
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
64
  | 2.4.3 | [Focus Order](https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html) | A | Partially automated | visual-order-divergence |
@@ -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-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 |
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, invoker-target |
115
115
  | 4.1.3 | [Status Messages](https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html) | AA | Manual review | — |
116
116
 
117
- **Totals:** 38 partially automated · 48 manual — with 86 rules.
117
+ **Totals:** 39 partially automated · 47 manual — with 88 rules.
package/engine/lib/dom.js CHANGED
@@ -124,6 +124,22 @@ export function isEmbeddedDocument(doc) {
124
124
  }
125
125
 
126
126
  /** The element's own (not descendant-element) text, trimmed. */
127
+ /**
128
+ * Is this element inside an `inert` subtree? The attribute makes a whole
129
+ * subtree unreachable: nothing in it takes focus, nothing in it accepts a
130
+ * click, and assistive technology skips it (HTML §6.6.7). A rule that
131
+ * reasons about focus order, reachability or pointer targets must treat
132
+ * such elements as absent. Walks up through shadow hosts, because inert on
133
+ * a host inerts its shadow tree too. `inert` is a boolean attribute:
134
+ * presence is what counts, whatever the value.
135
+ */
136
+ export function isInert(element) {
137
+ for (let node = element; node; node = node.parentElement ?? node.getRootNode?.()?.host ?? null) {
138
+ if (node.nodeType === 1 && node.hasAttribute('inert')) return true;
139
+ }
140
+ return false;
141
+ }
142
+
127
143
  export function ownText(element) {
128
144
  return [...element.childNodes]
129
145
  .filter((node) => node.nodeType === Node.TEXT_NODE)
@@ -133,7 +133,7 @@ const DATALIST_TYPES = new Set(['text', 'search', 'tel', 'url', 'email']);
133
133
 
134
134
  const TAG_ROLES = {
135
135
  button: 'button', textarea: 'textbox', img: 'img', article: 'article',
136
- aside: 'complementary', nav: 'navigation', main: 'main',
136
+ aside: 'complementary', nav: 'navigation', main: 'main', search: 'search',
137
137
  h1: 'heading', h2: 'heading', h3: 'heading', h4: 'heading', h5: 'heading', h6: 'heading',
138
138
  ul: 'list', ol: 'list', menu: 'list', li: 'listitem',
139
139
  table: 'table', thead: 'rowgroup', tbody: 'rowgroup', tfoot: 'rowgroup',
@@ -38,7 +38,7 @@ export default {
38
38
  // Implicit landmarks (header, footer, labelled section/form) join the
39
39
  // grouping — a page's <header> and a div[role="banner"] ARE duplicates.
40
40
  selector:
41
- 'nav, aside, header, footer, section[aria-label], section[aria-labelledby], ' +
41
+ 'nav, search, aside, header, footer, section[aria-label], section[aria-labelledby], ' +
42
42
  'form[aria-label], form[aria-labelledby], [role="navigation"], [role="complementary"], ' +
43
43
  '[role="banner"], [role="contentinfo"], [role="region"], [role="search"], [role="form"], [role="main"], main',
44
44
  // Judged as a set: two unlabelled navs are the problem, not either alone.
@@ -10,7 +10,7 @@
10
10
  // so a closed dialog cannot make a landmark-less page look landmarked
11
11
  // (2026-08-25 overnight audit).
12
12
  const LANDMARK =
13
- 'main, nav, aside, form[aria-label], form[aria-labelledby], ' +
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), ' +
16
16
  'section[aria-label], section[aria-labelledby], [role="main"], [role="navigation"], ' +
@@ -99,6 +99,12 @@ import audioTranscript from './wcag/1.2.1-audio-transcript.js';
99
99
  import readingOrderDivergence from './wcag/1.3.2-reading-order-divergence.js';
100
100
  import dragAlternative from './wcag/2.5.7-drag-alternative.js';
101
101
  import redundantEntry from './wcag/3.3.7-redundant-entry.js';
102
+ // 2026-08-27 platform batch: the HTML invoker attributes (popovertarget,
103
+ // commandfor) get the dangling-reference assert aria-controls already has,
104
+ // and 2.3.3 gains its review question about interaction-driven motion that
105
+ // no stylesheet lets the reader turn off.
106
+ import invokerTarget from './wcag/4.1.2-invoker-target.js';
107
+ import reducedMotion from './wcag/2.3.3-reduced-motion.js';
102
108
  import onInputNavigation from './wcag/3.2.2-on-input-navigation.js';
103
109
  import errorMessageLinkage from './wcag/3.3.1-error-message-linkage.js';
104
110
  import compositeWidgetName from './wcag/4.1.2-composite-widget-name.js';
@@ -198,6 +204,8 @@ export default [
198
204
  readingOrderDivergence,
199
205
  dragAlternative,
200
206
  redundantEntry,
207
+ invokerTarget,
208
+ reducedMotion,
201
209
  onInputNavigation,
202
210
  errorMessageLinkage,
203
211
  compositeWidgetName,
@@ -6,6 +6,8 @@
6
6
  // (found on carbondesignsystem.com: a scrollable <nav> the rule never saw).
7
7
  // The cost stays flat because the overwhelming majority of elements exit on
8
8
  // two integer reads before any style or visibility work happens.
9
+ import { isInert } from '../../lib/dom.js';
10
+
9
11
  const FOCUSABLE = 'a[href], button, input:not([type="hidden"]), select, textarea, summary, [tabindex]';
10
12
 
11
13
  /** Reachable with the Tab key — what actually lets a keyboard user move
@@ -15,6 +17,8 @@ const FOCUSABLE = 'a[href], button, input:not([type="hidden"]), select, textarea
15
17
  * nothing reachable (it auto-focuses the scroller, same as when empty). */
16
18
  function isTabbable(element) {
17
19
  if (element.tabIndex < 0 || element.disabled) return false;
20
+ // Inert content never takes focus, so it cannot make a region reachable.
21
+ if (isInert(element)) return false;
18
22
  if (typeof element.checkVisibility === 'function') {
19
23
  return element.checkVisibility({ visibilityProperty: true });
20
24
  }
@@ -28,7 +28,7 @@ const MOTION_PROPERTIES = new Set([
28
28
 
29
29
  /** Does this keyframe set move the element around, rather than merely
30
30
  * restyle it? A hue cycle is not movement; a translate is. */
31
- function keyframesMove(keyframes) {
31
+ export function keyframesMove(keyframes) {
32
32
  return keyframes.some((frame) => Object.keys(frame).some((property) => MOTION_PROPERTIES.has(property)));
33
33
  }
34
34
 
@@ -0,0 +1,170 @@
1
+ // WCAG SC 2.3.3 Animation from Interactions (Level AAA)
2
+ // "Motion animation triggered by interaction can be disabled, unless the
3
+ // animation is essential to the functionality or the information being
4
+ // conveyed."
5
+ //
6
+ // The criterion is about movement a person's own action sets off: the page
7
+ // scrolls and things glide, a pointer arrives and a card lifts, a link is
8
+ // followed and the viewport slides. The web's one standard way to let a
9
+ // reader turn such motion off is the prefers-reduced-motion media feature
10
+ // (technique C39): a page that honours it anywhere has a mechanism; one
11
+ // that animates on interaction and never mentions it has none.
12
+ //
13
+ // Three kinds of evidence are readable from the loaded page:
14
+ // - scroll- and view-driven animations (animation-timeline: scroll() /
15
+ // view()), read from the Web Animations API as animations on a timeline
16
+ // other than the document's, whose keyframes move the element;
17
+ // - smooth scrolling (scroll-behavior: smooth) on the root or the body,
18
+ // which turns every in-page link and anchor jump into a glide;
19
+ // - style rules keyed to :hover, :focus, :focus-visible, :focus-within or
20
+ // :active that set a motion property (transform, translate, offset
21
+ // path, position) on a subject that transitions or animates it: an
22
+ // instant snap on hover is a change of place, not motion.
23
+ // Whether any of it is essential is the criterion's own test and a human
24
+ // call, so the rule reviews, never asserts. One question per page, on the
25
+ // root: the finding is about the page's motion policy, not about each
26
+ // element that moves. A stylesheet this audit cannot read (cross-origin
27
+ // without CORS) might be the one carrying the media rule, so the question
28
+ // says so when that is the case, and a page whose only motion sits in
29
+ // unreadable sheets is not asked at all.
30
+ import { keyframesMove } from './2.2.2-pause-stop-hide.js';
31
+
32
+ // Properties whose change on hover or focus is movement. Deliberately
33
+ // narrower than 2.2.2's list: `background` and `margin` shorthands expand
34
+ // to background-position and margin-* in the CSSOM, so a plain hover
35
+ // colour change would read as motion if those were counted.
36
+ const MOTION_CSS = [
37
+ 'transform', 'translate', 'rotate', 'scale', 'offset-path', 'offset-distance', 'offset-rotate',
38
+ 'left', 'right', 'top', 'bottom',
39
+ ];
40
+ const INTERACTION = /:(?:hover|focus-visible|focus-within|focus|active)\b/;
41
+ const INTERACTION_ALL = /:(?:hover|focus-visible|focus-within|focus|active)\b/g;
42
+ const ANIMATES = /\b(?:all|transform|translate|rotate|scale|offset-path|offset-distance|offset-rotate|left|right|top|bottom|inset)\b/;
43
+
44
+ /** Does a hover/focus rule that sets a motion property actually ANIMATE
45
+ * its subject? 2.3.3 is about animation: a transform that snaps into
46
+ * place on hover is an instant change, not motion. The rule itself may
47
+ * declare the transition or animation; more often the resting rule does,
48
+ * so the elements the selector addresses at rest are sampled for a
49
+ * transition that covers a motion property (or `all`) with a duration.
50
+ * An interaction selector that matches nothing on this page moves
51
+ * nothing. Measured on the benchmark corpus: counting every hover
52
+ * transform asked the question of two pages in five, mostly 1px button
53
+ * nudges with no transition; this keeps it to pages that glide. */
54
+ /** Is the movement this rule declares more than a nudge? A transform that
55
+ * shifts its subject under 8px, or scales it within 15%, gives no sense of
56
+ * travel across the page (the criterion's own examples are parallax and
57
+ * elements flying in); counting those asked the question of a page in
58
+ * four for a hover lift on a button. Percentage translates, rotations,
59
+ * offset paths and inset changes always count: their size is unbounded
60
+ * or unknowable from the rule alone. */
61
+ function movesFar(rule) {
62
+ const style = rule.style;
63
+ if (['offset-path', 'offset-distance', 'offset-rotate', 'rotate', 'left', 'right', 'top', 'bottom']
64
+ .some((property) => style.getPropertyValue(property))) return true;
65
+ const values = ['transform', 'translate', 'scale'].map((property) => style.getPropertyValue(property)).join(' ');
66
+ if (/rotate|skew|matrix|%|em|rem|vw|vh/.test(values)) return true;
67
+ const px = [...values.matchAll(/(-?\d*\.?\d+)px/g)].map((m) => Math.abs(parseFloat(m[1])));
68
+ if (px.some((n) => n >= 8)) return true;
69
+ const scales = [...values.matchAll(/scale[XYZ3d]*\(([^)]*)\)/gi)].flatMap((m) => m[1].split(',').map((v) => parseFloat(v)));
70
+ const bareScale = style.getPropertyValue('scale').split(/\s+/).map((v) => parseFloat(v)).filter((v) => !Number.isNaN(v));
71
+ return [...scales, ...bareScale].some((v) => !Number.isNaN(v) && (v < 0.85 || v > 1.15));
72
+ }
73
+
74
+ function rulesAnimate(doc, rule) {
75
+ const own = rule.style.getPropertyValue('transition-duration') || rule.style.getPropertyValue('animation-name');
76
+ if (own && own !== 'none' && own !== '0s') return true;
77
+ const resting = rule.selectorText.replace(INTERACTION_ALL, '').trim();
78
+ if (!resting || /^[,\s]*$/.test(resting)) return false;
79
+ let subjects;
80
+ try { subjects = doc.querySelectorAll(resting); } catch { return false; }
81
+ let seen = 0;
82
+ for (const subject of subjects) {
83
+ if (seen++ >= 5) break;
84
+ const style = doc.defaultView.getComputedStyle(subject);
85
+ const durations = style.transitionDuration.split(',').map((d) => parseFloat(d) || 0);
86
+ if (!durations.some((d) => d > 0)) continue;
87
+ if (ANIMATES.test(style.transitionProperty)) return true;
88
+ }
89
+ return false;
90
+ }
91
+
92
+ /** Walk every rule of every readable stylesheet (nested rules and imports
93
+ * included), collecting the two facts this rule needs. */
94
+ function readStyles(doc) {
95
+ const facts = { honours: false, hoverMotion: 0, hoverSelectors: [], unreadable: 0 };
96
+ const scan = (rules) => {
97
+ for (const rule of rules) {
98
+ const media = rule.media?.mediaText;
99
+ if (media && /prefers-reduced-motion/i.test(media)) facts.honours = true;
100
+ if (rule.selectorText && INTERACTION.test(rule.selectorText) && rule.style
101
+ && MOTION_CSS.some((property) => rule.style.getPropertyValue(property))
102
+ && movesFar(rule) && rulesAnimate(doc, rule)) {
103
+ facts.hoverMotion += 1;
104
+ if (facts.hoverSelectors.length < 3) facts.hoverSelectors.push(rule.selectorText.slice(0, 80));
105
+ }
106
+ const inner = rule.cssRules ?? rule.styleSheet?.cssRules;
107
+ if (inner) {
108
+ try { scan(inner); } catch { facts.unreadable += 1; }
109
+ }
110
+ }
111
+ };
112
+ const sheets = [...(doc.styleSheets ?? []), ...(doc.adoptedStyleSheets ?? [])];
113
+ for (const sheet of sheets) {
114
+ try { scan(sheet.cssRules); } catch { facts.unreadable += 1; }
115
+ }
116
+ return facts;
117
+ }
118
+
119
+ /** Animations that advance with scrolling rather than with time, and move. */
120
+ function scrollDrivenMotion(doc) {
121
+ if (typeof doc.getAnimations !== 'function') return 0;
122
+ let animations;
123
+ try { animations = doc.getAnimations(); } catch { return 0; }
124
+ let count = 0;
125
+ for (const animation of animations) {
126
+ if (!animation.timeline || animation.timeline === doc.timeline) continue;
127
+ let keyframes;
128
+ try { keyframes = animation.effect?.getKeyframes?.() ?? []; } catch { continue; }
129
+ if (keyframesMove(keyframes)) count += 1;
130
+ }
131
+ return count;
132
+ }
133
+
134
+ export default {
135
+ id: 'reduced-motion',
136
+ name: 'Motion honours reduced-motion',
137
+ impact: 'moderate',
138
+ tags: ['wcag21aaa', 'wcag233'],
139
+ help: 'Motion set off by scrolling, hovering or focusing should be switchable off',
140
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/animation-from-interactions.html',
141
+ selector: 'html',
142
+ visibleOnly: false,
143
+ evaluate(element) {
144
+ const doc = element.ownerDocument;
145
+ const win = doc.defaultView;
146
+ const styles = readStyles(doc);
147
+ if (styles.honours) return { status: 'pass' };
148
+
149
+ const evidence = [];
150
+ const scrolling = scrollDrivenMotion(doc);
151
+ if (scrolling) evidence.push(`${scrolling} scroll-driven animation${scrolling === 1 ? '' : 's'} that move${scrolling === 1 ? 's' : ''} content`);
152
+ const smooth = [doc.documentElement, doc.body].some((node) =>
153
+ node && win.getComputedStyle(node).scrollBehavior === 'smooth');
154
+ if (smooth) evidence.push('smooth scrolling on in-page navigation (scroll-behavior: smooth)');
155
+ if (styles.hoverMotion) {
156
+ const more = styles.hoverMotion > styles.hoverSelectors.length ? ', …' : '';
157
+ evidence.push(`${styles.hoverMotion} style rule${styles.hoverMotion === 1 ? '' : 's'} animating movement on hover or focus (${styles.hoverSelectors.join(', ')}${more})`);
158
+ }
159
+ if (!evidence.length) return { status: 'pass' };
160
+
161
+ const unread = styles.unreadable
162
+ ? ` (${styles.unreadable} stylesheet${styles.unreadable === 1 ? '' : 's'} could not be read and might carry one)`
163
+ : '';
164
+ return {
165
+ status: 'incomplete',
166
+ message: `This page moves content when people interact with it (${evidence.join('; ')}), and none of the stylesheets this audit could read honours prefers-reduced-motion${unread}. 2.3.3 asks that such motion can be turned off unless it is essential.`,
167
+ fix: 'Put the motion inside @media (prefers-reduced-motion: no-preference), or remove it under @media (prefers-reduced-motion: reduce), keeping only motion that is essential to the function or the information.',
168
+ };
169
+ },
170
+ };
@@ -16,6 +16,8 @@
16
16
  //
17
17
  // Sites that apply the spec's own sufficient technique — scroll-padding for
18
18
  // the overlay's edge — are not flagged at all.
19
+ import { isInert } from '../../lib/dom.js';
20
+
19
21
  const FOCUSABLE = 'a[href], button, input:not([type="hidden"]), select, textarea, summary, [tabindex]:not([tabindex="-1"])';
20
22
 
21
23
  /** Does the scroll container reserve room for an overlay on this edge?
@@ -80,6 +82,8 @@ export default {
80
82
  // `:disabled` catches controls disabled by an ancestor fieldset, which
81
83
  // the `disabled` property does not reflect — those never take focus.
82
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' };
83
87
  const rect = element.getBoundingClientRect();
84
88
  if (!rect.width || !rect.height) return { status: 'pass' };
85
89
  // Only in-viewport geometry is trustworthy (or cheap).
@@ -25,8 +25,12 @@
25
25
  export const FLEXGRID = /^(inline-)?(flex|grid)$/;
26
26
 
27
27
  /** DOM-order focusable children grouped per flex/grid parent. */
28
+ import { isInert } from '../../lib/dom.js';
29
+
28
30
  export function tabbable(element) {
29
31
  if (element.disabled || element.tabIndex < 0) return false;
32
+ // An inert subtree is skipped by Tab entirely: no sequence to compare.
33
+ if (isInert(element)) return false;
30
34
  return true;
31
35
  }
32
36
 
@@ -29,6 +29,8 @@ const TARGETS = 'a[href], button, input, select, [role="button"], [role="link"]'
29
29
  * These are keyboard affordances, not pointer targets — and their phantom
30
30
  * rects must not count as "crowding" for the real targets around them.
31
31
  */
32
+ import { isInert } from '../../lib/dom.js';
33
+
32
34
  function isHiddenFromPointer(element, rect) {
33
35
  if (rect.width <= 1 || rect.height <= 1) return true;
34
36
  if (rect.right <= 0 || rect.bottom <= 0) return true; // parked above/left of the canvas
@@ -321,8 +323,13 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
321
323
  && !element.checkVisibility({ contentVisibilityAuto: true, visibilityProperty: true });
322
324
  } catch { return false; }
323
325
  };
326
+ // Inert targets accept no pointer action at all, so they are neither
327
+ // judged nor counted as crowders (bbc.com/news keeps 28 controls of a
328
+ // closed menu in an inert subtree; the browser ignores every click on
329
+ // them, and so must the size judgment).
324
330
  const laidOut = rects.map((r, i) =>
325
- r.width > 0 && r.height > 0 && !isHiddenFromPointer(elements[i], r) && !layoutSkipped(elements[i]));
331
+ r.width > 0 && r.height > 0 && !isInert(elements[i])
332
+ && !isHiddenFromPointer(elements[i], r) && !layoutSkipped(elements[i]));
326
333
  const undersized = rects.map((r, i) => laidOut[i] && (r.width < min || r.height < min));
327
334
  // One rect fully inside another is one control drawn twice (stretched-
328
335
  // link cards, overlay + inner button) — not two targets crowding.
@@ -570,6 +577,15 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
570
577
  // fragment IS its box, so genuine overlays are dismissed exactly
571
578
  // as before.
572
579
  if (paintedEncloses(other, rects[j], rects[i]) || paintedEncloses(element, rects[i], rects[j])) return false;
580
+ // Crowding across fixed/sticky contexts is scroll state, not
581
+ // layout, exactly as the obscured-target test reads overlap: a
582
+ // consent banner pinned to the bottom of the viewport lays its
583
+ // "Privacy Policy" link on whichever headline happens to sit
584
+ // under it right now, and that headline has clear space the
585
+ // moment the page scrolls. Asserting it as crowded invents a
586
+ // defect the layout does not have (measured live on two news
587
+ // homes carrying the same consent banner, 2026-08-27).
588
+ if (fixedContextOf(other) !== fixedContextOf(element)) return false;
573
589
  const within = (box) => {
574
590
  if (undersized[j]) {
575
591
  const c = { x: box.left + box.width / 2, y: box.top + box.height / 2 };
@@ -0,0 +1,71 @@
1
+ // WCAG SC 4.1.2 Name, Role, Value (Level A) — the HTML invoker slice
2
+ //
3
+ // HTML's invoker attributes wire a button to the element it acts on:
4
+ // `popovertarget` opens, closes or toggles a popover, and `commandfor` with
5
+ // a `command` drives a popover or a <dialog> (or a custom `--` command the
6
+ // page handles itself). The browser builds the accessibility relationship
7
+ // from that wiring on its own: the button exposes its expanded state and a
8
+ // relation to what it controls, with no ARIA written by the author.
9
+ //
10
+ // That relationship exists only when the reference resolves. An id that
11
+ // names nothing, a popovertarget aimed at an element with no popover
12
+ // attribute, or show-modal aimed at a <div>, leaves a button that does
13
+ // nothing when activated and exposes no state: the same defect as a
14
+ // dangling aria-controls, which aria-valid-refs asserts, except that here
15
+ // it is the browser's own semantics that go missing. Decidable from the
16
+ // DOM, so asserted.
17
+ //
18
+ // Unknown command keywords are NOT judged: the command vocabulary is still
19
+ // growing (new built-ins ship every few releases), and a keyword this
20
+ // engine has not heard of is not evidence of a fault.
21
+ const POPOVER_COMMANDS = new Set(['toggle-popover', 'show-popover', 'hide-popover']);
22
+ const DIALOG_COMMANDS = new Set(['show-modal', 'close', 'request-close']);
23
+
24
+ export default {
25
+ id: 'invoker-target',
26
+ name: 'Popover and command targets',
27
+ impact: 'serious',
28
+ tags: ['wcag2a', 'wcag412'],
29
+ help: 'popovertarget and commandfor must point at an element they can act on',
30
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
31
+ selector: '[popovertarget], [commandfor]',
32
+ evaluate(element) {
33
+ // Ids resolve within the button's own tree scope (a shadow root's ids
34
+ // are its own), exactly as the browser resolves them.
35
+ const root = element.getRootNode();
36
+ const lookup = (id) => (id && root.getElementById?.(id)) || null;
37
+ const problems = [];
38
+
39
+ if (element.hasAttribute('popovertarget')) {
40
+ const id = element.getAttribute('popovertarget').trim();
41
+ const target = lookup(id);
42
+ if (!target) {
43
+ problems.push(`popovertarget="${id}" names no element in this document`);
44
+ } else if (!target.hasAttribute('popover')) {
45
+ problems.push(`popovertarget="${id}" points at a <${target.tagName.toLowerCase()}> with no popover attribute, which the button cannot open`);
46
+ }
47
+ }
48
+
49
+ if (element.hasAttribute('commandfor')) {
50
+ const id = element.getAttribute('commandfor').trim();
51
+ const command = (element.getAttribute('command') ?? '').trim().toLowerCase();
52
+ const target = lookup(id);
53
+ if (!target) {
54
+ problems.push(`commandfor="${id}" names no element in this document`);
55
+ } else if (!command) {
56
+ problems.push('commandfor has no command attribute, so activating the button does nothing');
57
+ } else if (POPOVER_COMMANDS.has(command) && !target.hasAttribute('popover')) {
58
+ problems.push(`command="${command}" needs a popover, and "${id}" has no popover attribute`);
59
+ } else if (DIALOG_COMMANDS.has(command) && target.tagName !== 'DIALOG') {
60
+ problems.push(`command="${command}" needs a <dialog>, and "${id}" is a <${target.tagName.toLowerCase()}>`);
61
+ }
62
+ }
63
+
64
+ if (!problems.length) return { status: 'pass' };
65
+ return {
66
+ status: 'fail',
67
+ message: `${problems.join('; ')}. Activating this button does nothing, and the expanded state and control relationship the browser would expose for it are missing.`,
68
+ fix: 'Point the attribute at the id of the popover or dialog it drives (give that element the popover attribute, or make it a <dialog>), and keep the id unique in this tree.',
69
+ };
70
+ },
71
+ };
@@ -22,6 +22,38 @@ const VALID_ROLES = new Set([
22
22
  'image', 'comment', 'mark', 'suggestion',
23
23
  ]);
24
24
 
25
+ /** Edit distance between two short tokens (Levenshtein). */
26
+ function editDistance(a, b) {
27
+ const rows = Array.from({ length: a.length + 1 }, (_, i) => [i]);
28
+ for (let j = 1; j <= b.length; j++) rows[0][j] = j;
29
+ for (let i = 1; i <= a.length; i++) {
30
+ for (let j = 1; j <= b.length; j++) {
31
+ rows[i][j] = Math.min(
32
+ rows[i - 1][j] + 1,
33
+ rows[i][j - 1] + 1,
34
+ rows[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
35
+ );
36
+ }
37
+ }
38
+ return rows[a.length][b.length];
39
+ }
40
+
41
+ /** The valid role closest to any of the written tokens, when one sits
42
+ * within two edits of it; null when nothing is that close. */
43
+ function nearestRole(tokens) {
44
+ let best = null;
45
+ let bestDistance = 3;
46
+ for (const token of tokens) {
47
+ const t = token.toLowerCase();
48
+ for (const role of VALID_ROLES) {
49
+ if (Math.abs(role.length - t.length) > 2) continue;
50
+ const distance = editDistance(t, role);
51
+ if (distance > 0 && distance < bestDistance) { best = role; bestDistance = distance; }
52
+ }
53
+ }
54
+ return best;
55
+ }
56
+
25
57
  export default {
26
58
  id: 'valid-role',
27
59
  name: 'Valid roles',
@@ -46,6 +78,15 @@ export default {
46
78
  // canonical harmless case. The failure worth reporting is an invalid
47
79
  // role MASKING real semantics or decorating an interactive element.
48
80
  const tag = element.tagName.toLowerCase();
81
+ // A near-miss is almost always a typo: name the role that is one or two
82
+ // edits away, so "buton" reads as the misspelling it is rather than as
83
+ // a mystery ("role="buton" isn't really… what?" was the first reaction
84
+ // to the bare message on a seeded fault, 2026-08-27).
85
+ const nearest = nearestRole(tokens);
86
+ const hint = nearest ? ` (did you mean role="${nearest}"?)` : '';
87
+ const fix = nearest
88
+ ? `Correct the spelling to role="${nearest}", or remove the attribute to keep the element’s native role.`
89
+ : 'Use a valid role from the ARIA specification, or remove the attribute to keep the element’s native role.';
49
90
  // What the element falls back to when the bogus role is ignored. div/span
50
91
  // expose generic; <svg> exposes image (or graphics-document) in every
51
92
  // current engine, which is what an author writing role="img"/"image" on it
@@ -69,14 +110,14 @@ export default {
69
110
  if (!focusable && !hasAriaProps) {
70
111
  return {
71
112
  status: 'incomplete',
72
- message: `role="${element.getAttribute('role')}" is not a valid ARIA role, so assistive technology ignores it and exposes the element's native <${tag}> semantics. Nothing is announced wrongly, but the author reached for a role that does not exist. Is the native <${tag}> role the right one here? If a different role was intended, that role is missing.`,
73
- fix: 'Use a valid role from the ARIA specification, or remove the attribute to keep the element’s native role.',
113
+ message: `role="${element.getAttribute('role')}" is not a valid ARIA role${hint}, so assistive technology ignores it and exposes the element's native <${tag}> semantics. Nothing is announced wrongly, but the author reached for a role that does not exist. Is the native <${tag}> role the right one here? If a different role was intended, that role is missing.`,
114
+ fix,
74
115
  };
75
116
  }
76
117
  return {
77
118
  status: 'fail',
78
- message: `role="${element.getAttribute('role')}" is not a valid ARIA role, so assistive technology ignores it${genericFallback ? '' : ` and falls back to the element’s native <${tag}> semantics`}.`,
79
- fix: 'Use a valid role from the ARIA specification, or remove the attribute to keep the element’s native role.',
119
+ message: `role="${element.getAttribute('role')}" is not a valid ARIA role${hint}, so assistive technology ignores it${genericFallback ? '' : ` and falls back to the element’s native <${tag}> semantics`}.`,
120
+ fix,
80
121
  };
81
122
  },
82
123
  };
package/engine/wcag22.js CHANGED
@@ -66,7 +66,7 @@ export default [
66
66
  sc('2.2.6', 'Timeouts', 'AAA', 'manual', '2.1'),
67
67
  sc('2.3.1', 'Three Flashes or Below Threshold', 'A', 'manual'),
68
68
  sc('2.3.2', 'Three Flashes', 'AAA', 'manual'),
69
- sc('2.3.3', 'Animation from Interactions', 'AAA', 'manual', '2.1'),
69
+ sc('2.3.3', 'Animation from Interactions', 'AAA', 'partial', '2.1'), // partial: reduced-motion reads scroll-driven, smooth-scroll and animated hover/focus motion against the page's prefers-reduced-motion rules; whether the motion is essential stays human
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
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pour-engine",
3
- "version": "1.34.1",
3
+ "version": "1.35.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",