pour-engine 1.40.0 → 1.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/config/project.config.js +1 -1
  2. package/engine/AUDIT.html +24 -690
  3. package/engine/COVERAGE.md +17 -15
  4. package/engine/README.md +10 -5
  5. package/engine/index.js +34 -8
  6. package/engine/lib/accessible-name.js +69 -63
  7. package/engine/lib/contrast.js +111 -10
  8. package/engine/lib/dom.js +78 -18
  9. package/engine/lib/roles.js +5 -2
  10. package/engine/rules/best-practice/fieldset-legend.js +11 -5
  11. package/engine/rules/best-practice/region.js +1 -1
  12. package/engine/rules/index.js +29 -0
  13. package/engine/rules/wcag/1.1.1-area-alt.js +6 -1
  14. package/engine/rules/wcag/1.1.1-image-alt.js +2 -1
  15. package/engine/rules/wcag/1.1.1-svg-img-alt.js +14 -17
  16. package/engine/rules/wcag/1.2.3-video-audio-description.js +42 -0
  17. package/engine/rules/wcag/1.3.1-aria-required-children.js +20 -8
  18. package/engine/rules/wcag/1.3.1-aria-required-parent.js +7 -10
  19. package/engine/rules/wcag/1.3.1-form-label.js +5 -29
  20. package/engine/rules/wcag/1.3.1-list-structure.js +4 -3
  21. package/engine/rules/wcag/1.3.1-table-headers.js +23 -2
  22. package/engine/rules/wcag/1.3.4-orientation.js +117 -48
  23. package/engine/rules/wcag/1.3.5-autocomplete-valid.js +21 -8
  24. package/engine/rules/wcag/1.3.6-region-purpose.js +35 -0
  25. package/engine/rules/wcag/1.4.1-link-in-text-block.js +19 -3
  26. package/engine/rules/wcag/1.4.10-reflow.js +3 -3
  27. package/engine/rules/wcag/1.4.11-non-text-contrast.js +9 -1
  28. package/engine/rules/wcag/1.4.12-text-spacing.js +15 -2
  29. package/engine/rules/wcag/1.4.3-color-contrast.js +79 -7
  30. package/engine/rules/wcag/1.4.3-control-contrast.js +35 -6
  31. package/engine/rules/wcag/1.4.4-meta-viewport.js +7 -2
  32. package/engine/rules/wcag/1.4.8-text-justified.js +49 -0
  33. package/engine/rules/wcag/2.1.1-scrollable-region-focusable.js +5 -5
  34. package/engine/rules/wcag/2.2.1-meta-refresh.js +10 -4
  35. package/engine/rules/wcag/2.2.2-pause-stop-hide.js +5 -5
  36. package/engine/rules/wcag/2.2.4-meta-refresh-no-exceptions.js +50 -0
  37. package/engine/rules/wcag/2.4.1-bypass-blocks.js +2 -2
  38. package/engine/rules/wcag/2.4.10-section-heading.js +86 -0
  39. package/engine/rules/wcag/2.4.11-focus-not-obscured.js +134 -78
  40. package/engine/rules/wcag/2.4.12-focus-not-obscured-enhanced.js +17 -0
  41. package/engine/rules/wcag/2.4.2-document-title.js +7 -8
  42. package/engine/rules/wcag/2.4.4-link-text-generic.js +6 -5
  43. package/engine/rules/wcag/2.4.6-heading-label-placeholder.js +69 -0
  44. package/engine/rules/wcag/2.4.9-link-text-generic-only.js +8 -23
  45. package/engine/rules/wcag/2.5.1-pointer-gesture-alternative.js +98 -0
  46. package/engine/rules/wcag/2.5.8-target-size.js +161 -45
  47. package/engine/rules/wcag/3.3.4-financial-form-confirmation.js +32 -0
  48. package/engine/rules/wcag/3.3.8-auth-field-obstruction.js +10 -13
  49. package/engine/rules/wcag/3.3.9-captcha-alternative.js +35 -0
  50. package/engine/rules/wcag/4.1.2-aria-attr-valid.js +4 -0
  51. package/engine/rules/wcag/4.1.2-aria-field-name.js +6 -5
  52. package/engine/rules/wcag/4.1.2-aria-valid-refs.js +8 -3
  53. package/engine/rules/wcag/4.1.2-button-name.js +1 -1
  54. package/engine/rules/wcag/4.1.2-composite-widget-name.js +1 -1
  55. package/engine/rules/wcag/4.1.2-invoker-target.js +2 -1
  56. package/engine/rules/wcag/4.1.2-label-for-valid.js +2 -2
  57. package/engine/rules/wcag/4.1.2-valid-role.js +6 -1
  58. package/engine/wcag22.js +19 -19
  59. package/package.json +1 -1
package/engine/lib/dom.js CHANGED
@@ -13,7 +13,9 @@ export const NEVER_RENDERED = new Set(['SCRIPT', 'TEMPLATE', 'STYLE', 'LINK', 'M
13
13
  * design — like every DOM tool, we can only see what the page exposes.
14
14
  */
15
15
  export function collectRoots(context) {
16
- const roots = [context];
16
+ // querySelectorAll excludes the context itself, including its own open
17
+ // shadow root when a host element is the requested audit scope.
18
+ const roots = context.shadowRoot ? [context, context.shadowRoot] : [context];
17
19
  for (let i = 0; i < roots.length; i++) {
18
20
  if (!roots[i].querySelectorAll) continue;
19
21
  for (const el of roots[i].querySelectorAll('*')) {
@@ -25,8 +27,8 @@ export function collectRoots(context) {
25
27
 
26
28
  /** Nearest ancestor in the FLAT tree: parent element, or the shadow host
27
29
  * when the walk reaches the top of a shadow tree. */
28
- function flatTreeParent(node) {
29
- return node.parentElement ?? node.getRootNode()?.host ?? null;
30
+ export function flatTreeParent(node) {
31
+ return node.assignedSlot ?? node.parentElement ?? node.getRootNode()?.host ?? null;
30
32
  }
31
33
 
32
34
  /**
@@ -46,9 +48,75 @@ export function isRendered(element) {
46
48
  * hides its shadow content too. */
47
49
  export function isVisible(element) {
48
50
  for (let node = element; node; node = flatTreeParent(node)) {
49
- if (node.getAttribute?.('aria-hidden') === 'true') return false;
51
+ if (node.getAttribute?.('aria-hidden') === 'true' || node.hasAttribute?.('inert')) return false;
50
52
  }
51
- return isRendered(element);
53
+ if (isRendered(element)) return true;
54
+ // Boxless semantic containers can still be exposed to AT. Keep the
55
+ // visual helper box-based, but do not skip their names/relationships.
56
+ const style = getComputedStyle(element);
57
+ if (style.display !== 'contents' || style.visibility === 'hidden' || style.visibility === 'collapse') return false;
58
+ for (let node = flatTreeParent(element); node; node = flatTreeParent(node)) {
59
+ if (getComputedStyle(node).display === 'none') return false;
60
+ }
61
+ return true;
62
+ }
63
+
64
+ let pathRoots = new WeakMap();
65
+ const pathObservers = new Set();
66
+
67
+ /** Release observers after an audit, including an aborted audit. */
68
+ export function releaseDOMCaches() {
69
+ for (const observer of pathObservers) observer.disconnect();
70
+ pathObservers.clear();
71
+ pathRoots = new WeakMap();
72
+ }
73
+
74
+ export const resetDOMCaches = releaseDOMCaches;
75
+
76
+ function pathIndex(root) {
77
+ let index = pathRoots.get(root);
78
+ if (!index) {
79
+ const observer = typeof MutationObserver === 'function' ? new MutationObserver(() => {
80
+ index.ids = null;
81
+ index.parents = new WeakMap();
82
+ }) : null;
83
+ index = { ids: null, parents: new WeakMap(), observer };
84
+ if (observer) {
85
+ observer.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ['id'] });
86
+ pathObservers.add(observer);
87
+ }
88
+ pathRoots.set(root, index);
89
+ }
90
+ // Synchronous mutations may precede the observer callback. A selector
91
+ // must describe the current tree even in the same JavaScript task.
92
+ if (index.observer?.takeRecords().length) {
93
+ index.ids = null;
94
+ index.parents = new WeakMap();
95
+ }
96
+ if (!index.ids) {
97
+ index.ids = new Map();
98
+ for (const el of root.querySelectorAll('[id]')) {
99
+ index.ids.set(el.id, (index.ids.get(el.id) ?? 0) + 1);
100
+ }
101
+ }
102
+ return index;
103
+ }
104
+
105
+ function siblingPosition(element, index) {
106
+ const parent = element.parentElement;
107
+ let positions = index.parents.get(parent);
108
+ if (!positions) {
109
+ const counts = new Map();
110
+ positions = new WeakMap();
111
+ for (const child of parent.children) {
112
+ const position = (counts.get(child.tagName) ?? 0) + 1;
113
+ counts.set(child.tagName, position);
114
+ positions.set(child, { position, repeated: false });
115
+ }
116
+ for (const child of parent.children) positions.get(child).repeated = counts.get(child.tagName) > 1;
117
+ index.parents.set(parent, positions);
118
+ }
119
+ return positions.get(element);
52
120
  }
53
121
 
54
122
  /** Selector for an element within its own root (document or shadow root). */
@@ -58,24 +126,16 @@ function cssPathInRoot(element) {
58
126
  // bare #id selector would round-trip every one of them to the first,
59
127
  // collapsing distinct findings onto a single element.
60
128
  const root = element.getRootNode();
61
- const uniqueId = (el) => el.id && root.querySelectorAll(`#${CSS.escape(el.id)}`).length === 1;
129
+ const index = pathIndex(root);
130
+ const uniqueId = (el) => el.id && index.ids.get(el.id) === 1;
62
131
  if (uniqueId(element)) return `#${CSS.escape(element.id)}`;
63
132
  const parts = [];
64
133
  let current = element;
65
134
  while (current && current.nodeType === Node.ELEMENT_NODE && current !== document.documentElement) {
66
135
  let part = current.tagName.toLowerCase();
67
136
  if (current.parentElement) {
68
- // Sibling walk without materializing children arrays — this runs for
69
- // every reported element at every ancestor level.
70
- let index = 1;
71
- for (let sib = current.previousElementSibling; sib; sib = sib.previousElementSibling) {
72
- if (sib.tagName === current.tagName) index += 1;
73
- }
74
- let repeated = index > 1;
75
- for (let sib = current.nextElementSibling; !repeated && sib; sib = sib.nextElementSibling) {
76
- if (sib.tagName === current.tagName) repeated = true;
77
- }
78
- if (repeated) part += `:nth-of-type(${index})`;
137
+ const { position, repeated } = siblingPosition(current, index);
138
+ if (repeated) part += `:nth-of-type(${position})`;
79
139
  }
80
140
  parts.unshift(part);
81
141
  if (current.parentElement && uniqueId(current.parentElement)) {
@@ -152,7 +212,7 @@ export function isEmbeddedDocument(doc) {
152
212
  * presence is what counts, whatever the value.
153
213
  */
154
214
  export function isInert(element) {
155
- for (let node = element; node; node = node.parentElement ?? node.getRootNode?.()?.host ?? null) {
215
+ for (let node = element; node; node = flatTreeParent(node)) {
156
216
  if (node.nodeType === 1 && node.hasAttribute('inert')) return true;
157
217
  }
158
218
  return false;
@@ -1,6 +1,7 @@
1
1
  // ARIA 1.2 role model: implicit roles for HTML elements, and which aria-*
2
2
  // attributes each role supports (flattened with inheritance). Attribute
3
3
  // names are stored without the "aria-" prefix.
4
+ import { isInert } from './dom.js';
4
5
 
5
6
  /** Every attribute name in the ARIA 1.2 vocabulary (without the prefix).
6
7
  * aria-attr-valid judges membership; aria-allowed-attr judges role fit —
@@ -222,7 +223,7 @@ function presentationDiscarded(element) {
222
223
  // A global property is exposed whatever the element's state, so it
223
224
  // discards the role on its own, disabled or not.
224
225
  if ([...GLOBAL_ARIA].some((name) => element.hasAttribute(`aria-${name}`))) return true;
225
- if (element.matches(':disabled') || element.closest('[inert]')) return false;
226
+ if (element.matches(':disabled') || isInert(element)) return false;
226
227
  if (element.tabIndex >= 0) return true;
227
228
  return element.matches('a[href], button, input, select, textarea, summary, [contenteditable="true"]');
228
229
  }
@@ -231,11 +232,13 @@ export function effectiveRole(element) {
231
232
  const explicit = element.getAttribute('role')?.trim().split(/\s+/) ?? [];
232
233
  for (const token of explicit) {
233
234
  const role = token.toLowerCase();
235
+ if (role === 'image') return 'img'; // supported synonym for the image role
234
236
  if (!ROLE_ARIA[role]) continue;
235
237
  if ((role === 'presentation' || role === 'none') && presentationDiscarded(element)) {
236
238
  return implicitRole(element);
237
239
  }
238
240
  return role;
239
241
  }
240
- return explicit.length ? null : implicitRole(element); // unknown explicit role: don't judge
242
+ // ARIA 9.1: unrecognized tokens leave native semantics intact.
243
+ return implicitRole(element);
241
244
  }
@@ -12,13 +12,19 @@ export default {
12
12
  // Judged as a set: only same-name groups of 2+ need grouping, and one
13
13
  // finding per group is enough.
14
14
  evaluateAll(elements) {
15
- const groups = {};
15
+ const groups = new Map();
16
16
  elements.forEach((element, index) => {
17
- const key = `${element.type}::${element.form?.id ?? ''}::${element.name}`;
18
- (groups[key] ??= []).push(index);
17
+ // Form identity matters: two idless forms can use the same field name
18
+ // without making one group. Group controls within their own tree too.
19
+ const owner = element.form ?? element.getRootNode();
20
+ if (!groups.has(owner)) groups.set(owner, new Map());
21
+ const byName = groups.get(owner);
22
+ const key = `${element.type}::${element.name}`;
23
+ if (!byName.has(key)) byName.set(key, []);
24
+ byName.get(key).push(index);
19
25
  });
20
26
  const outcomes = elements.map(() => ({ status: 'pass' }));
21
- for (const indexes of Object.values(groups)) {
27
+ for (const indexes of [...groups.values()].flatMap((byName) => [...byName.values()])) {
22
28
  if (indexes.length < 2) continue;
23
29
  const first = elements[indexes[0]];
24
30
  // A <fieldset> IS a group; it does not need role="group" written on it
@@ -35,7 +41,7 @@ export default {
35
41
  // it empty (2026-08-25 overnight audit).
36
42
  const group = first.closest('fieldset, [role="group"], [role="radiogroup"]');
37
43
  const legend = group?.querySelector(':scope > legend');
38
- const grouped = group && (
44
+ const grouped = group && indexes.every((index) => group.contains(elements[index])) && (
39
45
  (legend && accessibleName(legend))
40
46
  || labelledByName(group)
41
47
  || group.getAttribute('aria-label')?.trim()
@@ -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), ' +
@@ -109,6 +109,25 @@ import onInputNavigation from './wcag/3.2.2-on-input-navigation.js';
109
109
  import errorMessageLinkage from './wcag/3.3.1-error-message-linkage.js';
110
110
  import compositeWidgetName from './wcag/4.1.2-composite-widget-name.js';
111
111
  import summaryName from './wcag/4.1.2-summary-name.js';
112
+ // 2026-09-12 manual-to-partial batch (reports/coverage-gaps/2026-09-12): the
113
+ // slices of twelve criteria that had no rule where the DOM shows a real
114
+ // candidate. Two AAA siblings built from existing factories (a timed refresh
115
+ // with no 20-hour allowance, any overlap of a focused element); eight
116
+ // review questions on declared facts (a video without a descriptions
117
+ // track, a declared <section> with no heading, a touch-action surface, a
118
+ // payment form, a CAPTCHA beside a credential, a page with no landmarks,
119
+ // justified running text) and one assert on template text left in a
120
+ // heading or label. Two assert, the rest ask.
121
+ import metaRefreshNoExceptions from './wcag/2.2.4-meta-refresh-no-exceptions.js';
122
+ import focusNotObscuredEnhanced from './wcag/2.4.12-focus-not-obscured-enhanced.js';
123
+ import videoAudioDescription from './wcag/1.2.3-video-audio-description.js';
124
+ import headingLabelPlaceholder from './wcag/2.4.6-heading-label-placeholder.js';
125
+ import textJustified from './wcag/1.4.8-text-justified.js';
126
+ import sectionHeading from './wcag/2.4.10-section-heading.js';
127
+ import captchaAlternative from './wcag/3.3.9-captcha-alternative.js';
128
+ import regionPurpose from './wcag/1.3.6-region-purpose.js';
129
+ import financialFormConfirmation from './wcag/3.3.4-financial-form-confirmation.js';
130
+ import pointerGestureAlternative from './wcag/2.5.1-pointer-gesture-alternative.js';
112
131
 
113
132
  // --- Best practice ---
114
133
  import headingOrder from './best-practice/heading-order.js';
@@ -210,6 +229,16 @@ export default [
210
229
  errorMessageLinkage,
211
230
  compositeWidgetName,
212
231
  summaryName,
232
+ metaRefreshNoExceptions,
233
+ focusNotObscuredEnhanced,
234
+ videoAudioDescription,
235
+ headingLabelPlaceholder,
236
+ textJustified,
237
+ sectionHeading,
238
+ captchaAlternative,
239
+ regionPurpose,
240
+ financialFormConfirmation,
241
+ pointerGestureAlternative,
213
242
  contrastEnhanced,
214
243
  targetSizeEnhanced,
215
244
  headingOrder,
@@ -8,7 +8,12 @@ export default {
8
8
  helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/non-text-content.html',
9
9
  selector: 'map area[href]',
10
10
  visibleOnly: false,
11
- evaluate(element, { accessibleName }) {
11
+ evaluate(element, { accessibleName, isVisible }) {
12
+ const map = element.closest('map');
13
+ const images = [...element.getRootNode().querySelectorAll('img[usemap], object[usemap]')];
14
+ const active = map?.name && images.some((image) =>
15
+ image.getAttribute('usemap') === `#${map.name}` && isVisible(image));
16
+ if (!active) return { status: 'pass' };
12
17
  if (accessibleName(element)) return { status: 'pass' };
13
18
  return {
14
19
  status: 'fail',
@@ -11,6 +11,7 @@
11
11
  // nothing (F30/F39) — flagged for eyes.
12
12
  const FILENAME_ALT = /\.(png|jpe?g|gif|webp|svg|avif|bmp|ico)([?#].*)?$/i;
13
13
  const GENERIC_ALT = /^(image|img|photo|photograph|picture|graphic|icon|untitled|placeholder|spacer|\d+)$/i;
14
+ import { effectiveRole } from '../../lib/roles.js';
14
15
 
15
16
  export default {
16
17
  id: 'image-alt',
@@ -21,7 +22,7 @@ export default {
21
22
  helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/non-text-content.html',
22
23
  selector: 'img',
23
24
  evaluate(element, { accessibleName }) {
24
- const role = element.getAttribute('role');
25
+ const role = effectiveRole(element);
25
26
  if (role === 'presentation' || role === 'none') return { status: 'pass' };
26
27
  if (element.hasAttribute('alt')) {
27
28
  const alt = element.getAttribute('alt');
@@ -1,5 +1,6 @@
1
1
  // WCAG SC 1.1.1 Non-text Content (Level A)
2
2
  import { labelledByName } from '../../lib/accessible-name.js';
3
+ import { effectiveRole } from '../../lib/roles.js';
3
4
 
4
5
  export default {
5
6
  id: 'svg-img-alt',
@@ -11,24 +12,20 @@ export default {
11
12
  // <img> itself is handled by image-alt; this covers SVG (with or without a
12
13
  // role) and role="img" divs/spans. Top-level plain <svg> is included
13
14
  // because browsers expose it to AT as a graphic even without a role.
14
- selector: 'svg[role="img"], svg[role="graphics-document"], svg[role="graphics-symbol"], [role="img"]:not(img):not(svg), svg:not([role])',
15
+ selector: 'svg, [role]',
15
16
  evaluate(element, { accessibleName }) {
16
- // A non-svg role="img" host names from author only, and the role makes
17
- // its subtree presentational (ARIA: children presentational) — so a
18
- // descendant <svg><title> or text content never reaches AT as a name,
19
- // even though a generic contents walk would find it.
20
- if (element.tagName.toLowerCase() !== 'svg' && element.getAttribute('role') === 'img') {
21
- const authorName = labelledByName(element)
22
- || element.getAttribute('aria-label')?.trim()
23
- || element.getAttribute('title')?.trim();
24
- if (authorName) return { status: 'pass' };
25
- return {
26
- status: 'fail',
27
- message: 'This element is marked as an image but has no accessible name — for role="img", content inside it (including an inner SVG’s <title>) does not count as a name.',
28
- fix: 'Add aria-label="…" or aria-labelledby pointing at visible text.',
29
- };
30
- }
31
- if (accessibleName(element)) return { status: 'pass' };
17
+ if (element.tagName === 'IMG') return { status: 'pass' }; // image-alt owns native images
18
+ const svg = element.tagName.toLowerCase() === 'svg';
19
+ const explicitGraphic = effectiveRole(element) === 'img'
20
+ || ['graphics-document', 'graphics-symbol'].includes(element.getAttribute('role')?.trim());
21
+ if (!svg && !explicitGraphic) return { status: 'pass' };
22
+ if (svg && element.hasAttribute('role') && !explicitGraphic) return { status: 'pass' };
23
+ // Graphic roles name from author, not arbitrary descendant text.
24
+ // SVG adds its own direct title as a native naming source.
25
+ const nativeTitle = svg && [...element.children].find((child) => child.tagName.toLowerCase() === 'title');
26
+ const name = labelledByName(element) || element.getAttribute('aria-label')?.trim()
27
+ || (nativeTitle && accessibleName(nativeTitle)) || element.getAttribute('title')?.trim();
28
+ if (name) return { status: 'pass' };
32
29
  if (!element.getAttribute('role') && element.tagName.toLowerCase() === 'svg') {
33
30
  if (element.parentNode instanceof SVGElement) return { status: 'pass' }; // nested svg: part of the outer graphic
34
31
  // Inside a control that already has a name, the svg is effectively
@@ -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
+ };
@@ -1,7 +1,7 @@
1
1
  // WCAG SC 1.3.1 Info and Relationships (Level A)
2
2
  // Composite roles are meaningless without their required child roles —
3
3
  // role="list" with no listitems announces as an empty list.
4
- import { implicitRole } from '../../lib/roles.js';
4
+ import { effectiveRole } from '../../lib/roles.js';
5
5
  // Every row of ARIA 1.2's Required Owned Elements table; verify:aria-tables
6
6
  // diffs this map against the spec (2026-09-01, after feed and row were
7
7
  // found missing).
@@ -21,9 +21,7 @@ export const REQUIRED_CHILDREN = {
21
21
  rowgroup: ['row'],
22
22
  };
23
23
 
24
- const roleOf = (element) =>
25
- element.getAttribute('role')?.trim().split(/\s+/)[0]?.toLowerCase() ??
26
- implicitRole(element);
24
+ const roleOf = effectiveRole;
27
25
 
28
26
  /**
29
27
  * Every element in the COMPOSED subtree of `element`: its light-DOM
@@ -88,6 +86,19 @@ function candidateDescendants(element) {
88
86
  return found;
89
87
  }
90
88
 
89
+ function exposedChild(element, isVisible) {
90
+ if (isVisible(element)) return true;
91
+ // A collapsed native select still exposes its options through the
92
+ // platform accessibility API, although those options have no CSS boxes.
93
+ // Chromium also retains individually hidden options in that native popup
94
+ // tree. Only the select's exposure gates this narrow native exception;
95
+ // applying CSS-box visibility to its options would assert an empty widget
96
+ // even while the browser exposes its choices.
97
+ if (element.tagName !== 'OPTION') return false;
98
+ const select = element.closest('select');
99
+ return !!select && isVisible(select);
100
+ }
101
+
91
102
  export default {
92
103
  id: 'aria-required-children',
93
104
  name: 'Required ARIA children',
@@ -95,17 +106,18 @@ export default {
95
106
  tags: ['wcag2a', 'wcag131'],
96
107
  help: 'Composite ARIA roles must contain their required children',
97
108
  helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html',
98
- selector: Object.keys(REQUIRED_CHILDREN).map((role) => `[role="${role}"]`).join(', '),
99
- evaluate(element) {
109
+ selector: '[role]',
110
+ evaluate(element, { isVisible }) {
100
111
  if (element.getAttribute('aria-busy') === 'true') return { status: 'pass' }; // still loading
101
- const role = element.getAttribute('role').trim().split(/\s+/)[0].toLowerCase();
112
+ const role = effectiveRole(element);
102
113
  const required = REQUIRED_CHILDREN[role];
114
+ if (!required) return { status: 'pass' };
103
115
  const children = [...element.children, ...(element.shadowRoot?.children ?? [])]
104
116
  .filter((c) => !c.matches('script, style, template'));
105
117
  // A completely empty container is a lazy-load placeholder more often
106
118
  // than a defect — it announces as an empty list, which is accurate.
107
119
  if (!children.length && !element.hasAttribute('aria-owns')) return { status: 'pass' };
108
- if (candidateDescendants(element).some((child) => required.includes(roleOf(child)))) {
120
+ if (candidateDescendants(element).some((child) => required.includes(roleOf(child)) && exposedChild(child, isVisible))) {
109
121
  return { status: 'pass' };
110
122
  }
111
123
  return {
@@ -1,5 +1,6 @@
1
1
  // WCAG SC 1.3.1 Info and Relationships (Level A)
2
2
  // Child roles that only make sense inside a specific container role.
3
+ import { effectiveRole } from '../../lib/roles.js';
3
4
  // Explicit roles only — native elements (li outside ul) are covered by
4
5
  // listitem-parent/dlitem-parent.
5
6
  //
@@ -33,7 +34,6 @@ export const REQUIRED_PARENT = {
33
34
  gridcell: ['row'],
34
35
  };
35
36
 
36
- const IMPLICIT_CONTAINER = { ul: 'list', ol: 'list', menu: 'list', table: 'table', tbody: 'rowgroup', thead: 'rowgroup', tfoot: 'rowgroup', tr: 'row', figure: 'figure' };
37
37
 
38
38
  export default {
39
39
  id: 'aria-required-parent',
@@ -42,10 +42,11 @@ export default {
42
42
  tags: ['wcag2a', 'wcag131'],
43
43
  help: 'ARIA child roles must be inside their required container role',
44
44
  helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html',
45
- selector: Object.keys(REQUIRED_PARENT).map((role) => `[role="${role}"]`).join(', '),
45
+ selector: '[role]',
46
46
  evaluate(element) {
47
- const role = element.getAttribute('role').trim().split(/\s+/)[0].toLowerCase();
47
+ const role = effectiveRole(element);
48
48
  const containers = REQUIRED_PARENT[role];
49
+ if (!containers) return { status: 'pass' };
49
50
  // The container named in the message and fix: the structure's root, not
50
51
  // the group that may sit between it and the item.
51
52
  const named = containers.filter((container) => container !== 'group');
@@ -54,9 +55,7 @@ export default {
54
55
  // Judging by DOM ancestry alone failed every remotely-owned child.
55
56
  if (element.id) {
56
57
  const owner = element.getRootNode().querySelector?.(`[aria-owns~="${CSS.escape(element.id)}"]`);
57
- const ownerRole = owner && (
58
- owner.getAttribute('role')?.trim().split(/\s+/)[0]?.toLowerCase()
59
- ?? IMPLICIT_CONTAINER[owner.tagName.toLowerCase()]);
58
+ const ownerRole = owner && effectiveRole(owner);
60
59
  if (ownerRole && containers.includes(ownerRole)) return { status: 'pass' };
61
60
  }
62
61
  // FLAT-TREE ancestry, not light-DOM ancestry: assistive technology sees
@@ -72,9 +71,7 @@ export default {
72
71
  for (let parent = flatParent(element); parent; parent = flatParent(parent)) {
73
72
  // A <slot> is rendering plumbing with no tree presence of its own.
74
73
  if (parent.tagName === 'SLOT' && !parent.hasAttribute('role')) continue;
75
- const parentRole =
76
- parent.getAttribute('role')?.trim().split(/\s+/)[0]?.toLowerCase() ??
77
- IMPLICIT_CONTAINER[parent.tagName.toLowerCase()];
74
+ const parentRole = effectiveRole(parent);
78
75
  // A permitted context (the structure's root, or a group for the roles
79
76
  // whose ARIA context lists it) satisfies the requirement outright.
80
77
  if (containers.includes(parentRole)) return { status: 'pass' };
@@ -83,7 +80,7 @@ export default {
83
80
  // children owned by the next real ancestor — li[role=presentation]
84
81
  // between a tablist and its tabs is the canonical pattern.
85
82
  if (parentRole === 'presentation' || parentRole === 'none') continue;
86
- if (parentRole) break; // a different role interrupts the required structure
83
+ if (parentRole && parentRole !== 'generic') break; // a different role interrupts the required structure
87
84
  }
88
85
  return {
89
86
  status: 'fail',
@@ -2,7 +2,7 @@
2
2
  // Instructions (Level A) · SC 4.1.2 Name, Role, Value (Level A)
3
3
  // 3.3.2 mapping: a field with no label at all, or only a vanishing
4
4
  // placeholder, is failure F82 — the classic 3.3.2 pattern.
5
- import { labelledByName } from '../../lib/accessible-name.js';
5
+ import { labelledByName, nativeLabelName } from '../../lib/accessible-name.js';
6
6
  import { effectiveRole } from '../../lib/roles.js';
7
7
 
8
8
  export default {
@@ -29,34 +29,10 @@ export default {
29
29
  const style = getComputedStyle(label);
30
30
  return style.display !== 'none' && style.visibility !== 'hidden';
31
31
  });
32
- // A label's contribution is its ACCESSIBLE text, not its raw textContent:
33
- // aria-hidden subtrees inside the label are excluded from the name
34
- // computation (a custom radio whose only visible text sits in an
35
- // aria-hidden styled twin names NOTHING — the browser's tree computes an
36
- // empty name), while aria-label on the label itself or on things inside
37
- // it (icon-only labels) and image alts DO feed the name per accname.
38
- //
39
- // An UNRENDERED descendant is excluded the same way: accname step 2A
40
- // returns the empty string for a hidden node that is not the root of
41
- // the label traversal, and hidden covers display:none and
42
- // visibility:hidden (the hidden attribute is display:none). A label
43
- // whose only text sits in a display:none span names nothing (Chromium
44
- // computes ""), the mirror of the aria-hidden twin above (2026-08-25
45
- // overnight audit). Computed style rather than checkVisibility: an SVG
46
- // <title> has no box yet still names its graphic.
47
- const accessibleText = (node) => {
48
- if (node.nodeType === Node.TEXT_NODE) return node.textContent;
49
- if (node.nodeType !== Node.ELEMENT_NODE) return '';
50
- if (node.getAttribute('aria-hidden') === 'true') return '';
51
- const nodeStyle = getComputedStyle(node);
52
- if (nodeStyle.display === 'none' || nodeStyle.visibility === 'hidden') return '';
53
- const aria = node.getAttribute('aria-label');
54
- if (aria?.trim()) return aria;
55
- const alt = (node.tagName === 'IMG' || node.tagName === 'AREA') ? node.getAttribute('alt') : null;
56
- if (alt?.trim()) return alt;
57
- return [...node.childNodes].map(accessibleText).join(' ');
58
- };
59
- const labelsText = visibleLabels.map(accessibleText).join(' ').trim();
32
+ // Share the recursive native-label computation with other name rules,
33
+ // including generated text and image alternatives. Exclude this field
34
+ // from its wrapping label's traversal to avoid self-naming cycles.
35
+ const labelsText = visibleLabels.map((label) => nativeLabelName(element, label)).join(' ').trim();
60
36
  const ariaLabel = element.getAttribute('aria-label')?.trim();
61
37
  const labelledby = labelledByName(element);
62
38
  if (labelsText || ariaLabel || labelledby) return { status: 'pass' };
@@ -1,5 +1,6 @@
1
1
  // WCAG SC 1.3.1 Info and Relationships (Level A)
2
2
  import { NEVER_RENDERED } from '../../lib/dom.js';
3
+ import { effectiveRole } from '../../lib/roles.js';
3
4
 
4
5
  const ITEM_ROLES = ['listitem', 'presentation', 'none'];
5
6
 
@@ -14,7 +15,7 @@ export default {
14
15
  evaluate(element, { isRendered }) {
15
16
  // A role attribute replaces the HTML list semantics — a <ul role="tablist">
16
17
  // is judged by ARIA structure rules, not HTML list rules.
17
- if (element.hasAttribute('role') && element.getAttribute('role') !== 'list') return { status: 'pass' };
18
+ if (effectiveRole(element) !== 'list') return { status: 'pass' };
18
19
  // Flat-tree children: a <slot> renders as its assigned elements (or its
19
20
  // fallback content), so a shadow <ul><slot></slot></ul> receiving <li>s
20
21
  // is a perfectly valid list.
@@ -39,7 +40,7 @@ export default {
39
40
  // listitems, so the list announces broken/empty. Only a role-less
40
41
  // <li> (or an explicit listitem/presentation/none anywhere) keeps
41
42
  // the structure intact.
42
- const role = child.getAttribute('role') ?? '';
43
+ const role = effectiveRole(child);
43
44
  if (child.tagName === 'LI') return !role || ITEM_ROLES.includes(role);
44
45
  if (ITEM_ROLES.includes(role)) return true;
45
46
  const genericWrapper = !child.hasAttribute('role')
@@ -62,7 +63,7 @@ export default {
62
63
  const renderedItems = effectiveChildren(element).filter((child) =>
63
64
  child.tagName === 'LI' && (!isRendered || isRendered(child)));
64
65
  const neutralised = renderedItems.filter((child) =>
65
- ['presentation', 'none'].includes(child.getAttribute('role') ?? ''));
66
+ ['presentation', 'none'].includes(effectiveRole(child)));
66
67
  if (renderedItems.length && neutralised.length === renderedItems.length) {
67
68
  return {
68
69
  status: 'fail',
@@ -65,7 +65,7 @@ export default {
65
65
  const headers = cell.getAttribute('headers');
66
66
  return headers && headers.trim().split(/\s+/).filter(Boolean).some((id) => {
67
67
  const target = element.getRootNode().getElementById?.(id);
68
- return !target || !cells.includes(target) || !(/^T[HD]$/.test(target.tagName) || isHeaderCell(target));
68
+ return !target || !cells.includes(target) || !isHeaderCell(target);
69
69
  });
70
70
  });
71
71
  if (badRefs.length) {
@@ -84,13 +84,34 @@ export default {
84
84
  message: 'This table has no header cells and its cells hold block content (headings, paragraphs, lists or forms), which is a layout-table signature. If it really presents data, mark its header cells with <th>; if it is layout scaffolding, add role="presentation".',
85
85
  };
86
86
  }
87
+ // A grid whose every filled cell is nothing but a link or a button is a
88
+ // navigation block laid out as a table, not data with labels: asked,
89
+ // not asserted (2026-09-12).
90
+ const filled = cells.filter((cell) => cell.textContent.trim());
91
+ const navigational = filled.every((cell) => {
92
+ const controls = [...cell.querySelectorAll('a, button')];
93
+ return controls.length
94
+ && controls.map((control) => control.textContent.trim()).join(' ').replace(/\s+/g, ' ')
95
+ === cell.textContent.trim().replace(/\s+/g, ' ');
96
+ });
97
+ if (navigational) {
98
+ return {
99
+ status: 'incomplete',
100
+ message: 'This table has no header cells and every cell holds only a link or a button, which is the shape of a navigation block laid out as a table. If it presents data, mark its header cells with <th>; if it is layout, add role="presentation".',
101
+ };
102
+ }
103
+ // Everything that remains is a grid of at least four short text cells
104
+ // with no nested table, no block content and no layout role: a data
105
+ // table by every signal the markup gives, which is the determination
106
+ // F91 asks for before it applies. Asserted, as the shipped engine
107
+ // asserted it; the message says what to do if it is layout after all.
87
108
  // A two-column table is a list of label-and-value pairs; its first
88
109
  // column is the row headers, and that is the whole fix (gov.uk
89
110
  // inheritance tax guidance, 2026-09-01: five such tables, all td).
90
111
  const twoColumns = Math.max(0, ...rows.map((row) => row.cells.length)) === 2;
91
112
  return {
92
113
  status: 'fail',
93
- message: 'This looks like a data table but has no header cells — screen reader users get the data with no way to tell what each row/column means.',
114
+ message: 'This looks like a data table but has no header cells, so screen reader users get the data with no way to tell what each row or column means. If it only arranges content, mark it role="presentation" and dismiss this finding.',
94
115
  fix: twoColumns
95
116
  ? 'Mark the first cell of each row as <th scope="row">: in a two-column table the first column names what the second holds.'
96
117
  : 'Mark header cells with <th> (add scope="col" or scope="row" when the table has both).',