pour-engine 1.29.0 → 1.31.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 (88) hide show
  1. package/README.md +1 -6
  2. package/config/project.config.js +1 -1
  3. package/engine/index.js +15 -16
  4. package/engine/rules/best-practice/accesskeys.js +1 -0
  5. package/engine/rules/best-practice/button-type.js +1 -0
  6. package/engine/rules/best-practice/empty-heading.js +1 -0
  7. package/engine/rules/best-practice/fieldset-legend.js +1 -0
  8. package/engine/rules/best-practice/heading-order.js +15 -0
  9. package/engine/rules/best-practice/landmark-one-main.js +11 -2
  10. package/engine/rules/best-practice/landmark-top-level.js +20 -5
  11. package/engine/rules/best-practice/landmark-unique.js +1 -0
  12. package/engine/rules/best-practice/multiple-labels.js +1 -0
  13. package/engine/rules/best-practice/new-window-link.js +1 -0
  14. package/engine/rules/best-practice/no-autofocus.js +1 -0
  15. package/engine/rules/best-practice/page-heading-one.js +1 -0
  16. package/engine/rules/best-practice/positive-tabindex.js +1 -0
  17. package/engine/rules/best-practice/redundant-alt-phrase.js +1 -0
  18. package/engine/rules/best-practice/redundant-aria-label.js +1 -0
  19. package/engine/rules/best-practice/redundant-aria.js +1 -0
  20. package/engine/rules/best-practice/redundant-image-alt.js +51 -7
  21. package/engine/rules/best-practice/redundant-role.js +1 -0
  22. package/engine/rules/best-practice/redundant-tabindex.js +1 -0
  23. package/engine/rules/best-practice/region.js +17 -3
  24. package/engine/rules/wcag/1.1.1-area-alt.js +1 -0
  25. package/engine/rules/wcag/1.1.1-canvas-alt.js +1 -0
  26. package/engine/rules/wcag/1.1.1-embed-alt.js +1 -0
  27. package/engine/rules/wcag/1.1.1-image-alt.js +1 -0
  28. package/engine/rules/wcag/1.1.1-input-image-alt.js +1 -0
  29. package/engine/rules/wcag/1.1.1-object-alt.js +1 -0
  30. package/engine/rules/wcag/1.1.1-svg-img-alt.js +1 -0
  31. package/engine/rules/wcag/1.2.2-media-captions.js +12 -5
  32. package/engine/rules/wcag/1.3.1-aria-required-children.js +1 -0
  33. package/engine/rules/wcag/1.3.1-aria-required-parent.js +1 -0
  34. package/engine/rules/wcag/1.3.1-definition-list.js +1 -0
  35. package/engine/rules/wcag/1.3.1-dlitem-parent.js +1 -0
  36. package/engine/rules/wcag/1.3.1-form-label.js +1 -0
  37. package/engine/rules/wcag/1.3.1-list-structure.js +1 -0
  38. package/engine/rules/wcag/1.3.1-listitem-parent.js +1 -0
  39. package/engine/rules/wcag/1.3.1-p-as-heading.js +1 -0
  40. package/engine/rules/wcag/1.3.1-table-headers.js +1 -0
  41. package/engine/rules/wcag/1.3.4-orientation.js +1 -0
  42. package/engine/rules/wcag/1.3.5-autocomplete-valid.js +1 -0
  43. package/engine/rules/wcag/1.4.1-link-in-text-block.js +1 -0
  44. package/engine/rules/wcag/1.4.10-reflow.js +1 -0
  45. package/engine/rules/wcag/1.4.11-non-text-contrast.js +1 -0
  46. package/engine/rules/wcag/1.4.12-text-spacing.js +1 -0
  47. package/engine/rules/wcag/1.4.2-audio-control.js +1 -0
  48. package/engine/rules/wcag/1.4.3-color-contrast.js +1 -0
  49. package/engine/rules/wcag/1.4.3-control-contrast.js +1 -0
  50. package/engine/rules/wcag/1.4.4-meta-viewport.js +1 -0
  51. package/engine/rules/wcag/1.4.6-contrast-enhanced.js +1 -0
  52. package/engine/rules/wcag/2.1.1-scrollable-region-focusable.js +1 -0
  53. package/engine/rules/wcag/2.2.1-meta-refresh.js +1 -0
  54. package/engine/rules/wcag/2.2.2-pause-stop-hide.js +1 -0
  55. package/engine/rules/wcag/2.2.2-video-loop-motion.js +1 -0
  56. package/engine/rules/wcag/2.4.1-bypass-blocks.js +1 -0
  57. package/engine/rules/wcag/2.4.11-focus-not-obscured.js +1 -0
  58. package/engine/rules/wcag/2.4.2-document-title.js +1 -0
  59. package/engine/rules/wcag/2.4.3-visual-order-divergence.js +1 -0
  60. package/engine/rules/wcag/2.4.4-link-name.js +1 -0
  61. package/engine/rules/wcag/2.4.4-link-text-generic.js +1 -0
  62. package/engine/rules/wcag/2.4.7-focus-visible.js +1 -0
  63. package/engine/rules/wcag/2.4.9-link-text-generic-only.js +1 -0
  64. package/engine/rules/wcag/2.5.3-label-in-name.js +1 -0
  65. package/engine/rules/wcag/2.5.5-target-size-enhanced.js +1 -0
  66. package/engine/rules/wcag/2.5.8-target-size.js +1 -0
  67. package/engine/rules/wcag/3.1.1-html-lang.js +1 -0
  68. package/engine/rules/wcag/3.1.2-valid-lang-parts.js +1 -0
  69. package/engine/rules/wcag/3.2.2-on-input-navigation.js +1 -0
  70. package/engine/rules/wcag/3.3.1-error-message-linkage.js +1 -0
  71. package/engine/rules/wcag/3.3.8-auth-field-obstruction.js +1 -0
  72. package/engine/rules/wcag/4.1.2-aria-allowed-attr.js +1 -0
  73. package/engine/rules/wcag/4.1.2-aria-attr-valid.js +1 -0
  74. package/engine/rules/wcag/4.1.2-aria-field-name.js +1 -0
  75. package/engine/rules/wcag/4.1.2-aria-hidden-focus.js +1 -0
  76. package/engine/rules/wcag/4.1.2-aria-label-misuse.js +19 -3
  77. package/engine/rules/wcag/4.1.2-aria-valid-refs.js +1 -0
  78. package/engine/rules/wcag/4.1.2-button-name.js +1 -0
  79. package/engine/rules/wcag/4.1.2-composite-widget-name.js +1 -0
  80. package/engine/rules/wcag/4.1.2-dialog-name.js +1 -0
  81. package/engine/rules/wcag/4.1.2-frame-title.js +1 -0
  82. package/engine/rules/wcag/4.1.2-label-for-valid.js +1 -0
  83. package/engine/rules/wcag/4.1.2-nested-interactive.js +1 -0
  84. package/engine/rules/wcag/4.1.2-role-required-aria.js +1 -0
  85. package/engine/rules/wcag/4.1.2-summary-name.js +1 -0
  86. package/engine/rules/wcag/4.1.2-valid-role.js +1 -0
  87. package/package.json +1 -1
  88. package/engine/wcag30-draft.js +0 -149
package/README.md CHANGED
@@ -21,10 +21,6 @@ can verify are returned as a `manualReview` checklist instead of being
21
21
  silently skipped, and findings the engine can't judge conclusively land in
22
22
  `incomplete` rather than being guessed at.
23
23
 
24
- An experimental WCAG 3.0 draft mode (`engine/wcag30-draft.js`) reframes
25
- results against the draft's guidelines — clearly stamped as a draft, because
26
- no tool can honestly claim conformance to an unfinished standard.
27
-
28
24
  ## Getting started
29
25
 
30
26
  ```sh
@@ -39,7 +35,6 @@ const results = await run(document, {
39
35
  // optional: filter rules by tag. Tags are cumulative — WCAG 2.2 AA means
40
36
  // every A/AA rule from 2.0 through 2.2. Omit to run everything.
41
37
  tags: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22a', 'wcag22aa'],
42
- // standard: 'wcag30-draft', // optional: experimental WCAG 3.0 draft mode
43
38
  });
44
39
 
45
40
  console.log(results.violations, results.passes, results.incomplete);
@@ -89,7 +84,7 @@ static HTML strings. For Node-based testing, drive it through a real browser
89
84
  (`1.1.1-image-alt.js`), plus the catalog index.
90
85
  - `engine/lib/` — shared helpers: DOM traversal, accessible-name
91
86
  computation, contrast math, role model.
92
- - `engine/wcag22.js`, `engine/wcag30-draft.js` — the WCAG catalogs.
87
+ - `engine/wcag22.js` — the WCAG catalog.
93
88
  - `config/project.config.js` — package-local stand-in for the pour
94
89
  monorepo's project config (engine name + version only).
95
90
 
@@ -6,6 +6,6 @@
6
6
  export default {
7
7
  engine: {
8
8
  name: 'pour engine',
9
- version: '1.29.0',
9
+ version: '1.31.0',
10
10
  },
11
11
  };
package/engine/index.js CHANGED
@@ -4,7 +4,6 @@
4
4
  import config from '../config/project.config.js';
5
5
  import rules from './rules/index.js';
6
6
  import wcagCatalog from './wcag22.js';
7
- import wcag30Draft from './wcag30-draft.js';
8
7
  import { isVisible, isRendered, cssPath, htmlSnippet, ownText, collectRoots } from './lib/dom.js';
9
8
  import { accessibleName } from './lib/accessible-name.js';
10
9
  import { resetAuditCaches } from './lib/contrast.js';
@@ -79,16 +78,11 @@ function toResultNode(element, outcome) {
79
78
  /**
80
79
  * Run the audit.
81
80
  * @param {Document|Element} context - what to scan
82
- * @param {{ tags?: string[], exclude?: string, standard?: string, signal?: AbortSignal }} options -
81
+ * @param {{ tags?: string[], exclude?: string, signal?: AbortSignal }} options -
83
82
  * rule selection by tag (wcag2a…wcag22aa, best-practice), empty selects
84
83
  * every rule; exclude is a CSS selector — elements matching it, or inside
85
84
  * a match (including across shadow boundaries), are left out of every
86
- * rule; standard 'wcag30-draft' reframes the report against the WCAG 3.0
87
- * Working Draft (results.standard is stamped draft, the manual checklist
88
- * becomes the draft guidelines, and each rule result carries the draft
89
- * guideline it maps to) — the rules themselves stay WCAG 2.x, which is
90
- * the only honest claim a tool can make about a draft;
91
- * signal aborts the run between rules (throws AbortError)
85
+ * rule; signal aborts the run between rules (throws AbortError)
92
86
  * @param {(progress: object) => void} [onProgress] - called before each rule
93
87
  * starts ({rule, running}), during a rule at each renderer yield with a
94
88
  * fractional done (completed rules + this rule's evaluated-element share,
@@ -97,17 +91,15 @@ function toResultNode(element, outcome) {
97
91
  */
98
92
  export async function run(context = document, options = {}, onProgress) {
99
93
  const auditStarted = performance.now();
100
- const draft30 = options.standard === 'wcag30-draft';
101
94
  const results = {
102
95
  testEngine: { name, version },
103
96
  timestamp: new Date().toISOString(),
104
97
  url: context.location?.href ?? context.ownerDocument?.location?.href ?? '',
105
- standard: draft30 ? wcag30Draft.draftInfo : null,
106
98
  violations: [],
107
99
  passes: [],
108
100
  incomplete: [],
109
101
  inapplicable: [],
110
- manualReview: draft30 ? wcag30Draft.manualReviewChecklist() : manualReviewCriteria(options.tags),
102
+ manualReview: manualReviewCriteria(options.tags),
111
103
  ruleTimings: [],
112
104
  };
113
105
 
@@ -223,17 +215,15 @@ export async function run(context = document, options = {}, onProgress) {
223
215
 
224
216
  const ruleResult = {
225
217
  id: rule.id,
218
+ // Human display name ("Form field labels"); the id stays the
219
+ // machine handle everywhere (selectors, exports, docs anchors).
220
+ name: rule.name ?? rule.id,
226
221
  impact: rule.impact,
227
222
  tags: rule.tags,
228
223
  help: rule.help,
229
224
  description: rule.help,
230
225
  helpUrl: rule.helpUrl,
231
226
  };
232
- if (draft30) {
233
- // Which draft guideline this 2.x rule speaks to (null for rules with
234
- // no draft counterpart, e.g. best-practice extras).
235
- ruleResult.wcag30 = wcag30Draft.guidelineForTags(rule.tags);
236
- }
237
227
  if (!elements.length) results.inapplicable.push({ ...ruleResult, nodes: [] });
238
228
  if (buckets.fail.length) results.violations.push({ ...ruleResult, nodes: buckets.fail });
239
229
  if (buckets.incomplete.length) results.incomplete.push({ ...ruleResult, nodes: buckets.incomplete });
@@ -252,6 +242,15 @@ export async function run(context = document, options = {}, onProgress) {
252
242
  ms,
253
243
  counts: { ...liveCounts },
254
244
  typeCounts: { ...liveTypeCounts },
245
+ // The completed rule's actual findings, so a UI can BUILD its list
246
+ // as rules finish rather than delivering everything at the end.
247
+ // Present only when the rule found something.
248
+ ...(buckets.fail.length || buckets.incomplete.length ? {
249
+ findings: {
250
+ ...(buckets.fail.length ? { violation: { ...ruleResult, nodes: buckets.fail } } : {}),
251
+ ...(buckets.incomplete.length ? { incomplete: { ...ruleResult, nodes: buckets.incomplete } } : {}),
252
+ },
253
+ } : {}),
255
254
  });
256
255
  }
257
256
 
@@ -1,5 +1,6 @@
1
1
  export default {
2
2
  id: 'accesskeys',
3
+ name: 'Duplicate access keys',
3
4
  impact: 'serious',
4
5
  tags: ['best-practice'],
5
6
  help: 'The same accesskey must not be assigned twice',
@@ -1,5 +1,6 @@
1
1
  export default {
2
2
  id: 'button-type',
3
+ name: 'Button types in forms',
3
4
  impact: 'minor',
4
5
  tags: ['best-practice'],
5
6
  help: 'Buttons inside forms should declare their type',
@@ -1,5 +1,6 @@
1
1
  export default {
2
2
  id: 'empty-heading',
3
+ name: 'Empty headings',
3
4
  impact: 'minor',
4
5
  tags: ['best-practice'],
5
6
  help: 'Headings must contain text',
@@ -3,6 +3,7 @@
3
3
  import { labelledByName } from '../../lib/accessible-name.js';
4
4
  export default {
5
5
  id: 'fieldset-legend',
6
+ name: 'Grouped field labels',
6
7
  impact: 'moderate',
7
8
  tags: ['best-practice'],
8
9
  help: 'Radio and checkbox groups should be grouped with a label',
@@ -1,10 +1,24 @@
1
+ import { effectiveRole } from '../../lib/roles.js';
2
+
1
3
  const level = (el) =>
2
4
  el.hasAttribute('aria-level')
3
5
  ? parseInt(el.getAttribute('aria-level'), 10)
4
6
  : parseInt(el.tagName[1], 10) || 2; // role="heading" without aria-level defaults to 2
5
7
 
8
+ // An honoured presentational role (or any other role override, e.g. role="tab")
9
+ // removes the element from the page's heading outline: Chromium exposes no
10
+ // heading node for <h3 role="none">, so it can neither skip a level nor set
11
+ // the level the next heading is judged against. A null effective role means an
12
+ // unknown explicit role, which browsers ignore in favour of the implicit one —
13
+ // still a heading for everything this selector matches.
14
+ const inOutline = (el) => {
15
+ const role = effectiveRole(el);
16
+ return role === 'heading' || role === null;
17
+ };
18
+
6
19
  export default {
7
20
  id: 'heading-order',
21
+ name: 'Heading order',
8
22
  impact: 'moderate',
9
23
  tags: ['best-practice'],
10
24
  help: 'Headings should step down one level at a time',
@@ -14,6 +28,7 @@ export default {
14
28
  evaluateAll(elements) {
15
29
  let previous = 0;
16
30
  return elements.map((element) => {
31
+ if (!inOutline(element)) return { status: 'pass' };
17
32
  const current = level(element);
18
33
  const skipped = previous > 0 && current > previous + 1;
19
34
  const outcome = skipped
@@ -1,5 +1,8 @@
1
+ import { collectRoots } from '../../lib/dom.js';
2
+
1
3
  export default {
2
4
  id: 'landmark-one-main',
5
+ name: 'One main landmark',
3
6
  impact: 'moderate',
4
7
  tags: ['best-practice'],
5
8
  help: 'The page should have exactly one main landmark',
@@ -8,8 +11,14 @@ export default {
8
11
  visibleOnly: false,
9
12
  evaluate(element, { isVisible }) {
10
13
  // Hidden mains (responsive/SPA double-renders kept display:none) are
11
- // not exposed and must not count as duplicates.
12
- const count = [...element.ownerDocument.querySelectorAll('main, [role="main"]')].filter(isVisible).length;
14
+ // not exposed and must not count as duplicates. The count spans every
15
+ // open shadow root: a web-component SPA whose only <main> renders
16
+ // inside its app shell's shadow tree HAS a main (measured on a live
17
+ // archive home, where the light-DOM-only count reported a false
18
+ // "no main" on a page whose shadow main is plainly exposed).
19
+ const count = collectRoots(element.ownerDocument)
20
+ .flatMap((root) => [...root.querySelectorAll('main, [role="main"]')])
21
+ .filter(isVisible).length;
13
22
  if (count === 1) return { status: 'pass' };
14
23
  return {
15
24
  status: 'fail',
@@ -1,5 +1,8 @@
1
- // ARIA APG landmark pattern: banner, main, and contentinfo are page-level
2
- // regions — nesting them inside other landmarks breaks the page outline.
1
+ // ARIA APG landmark pattern: banner, main, contentinfo and complementary
2
+ // are page-level regions — nesting them inside other landmarks breaks the
3
+ // page outline. Complementary joins via the aside demotion semantics in
4
+ // lib/roles.js: only an aside that actually reaches the accessibility tree
5
+ // as complementary owes top-levelness.
3
6
  import { implicitRole } from '../../lib/roles.js';
4
7
 
5
8
  const LANDMARK_ROLES = new Set([
@@ -22,11 +25,12 @@ function landmarkAncestor(element) {
22
25
 
23
26
  export default {
24
27
  id: 'landmark-top-level',
28
+ name: 'Top-level landmarks',
25
29
  impact: 'moderate',
26
30
  tags: ['best-practice'],
27
- help: 'banner, main and contentinfo landmarks must be top level',
31
+ help: 'banner, main, contentinfo and complementary landmarks must be top level',
28
32
  helpUrl: 'https://www.w3.org/WAI/ARIA/apg/practices/landmark-regions/',
29
- selector: 'header, footer, main, [role="banner"], [role="contentinfo"], [role="main"]',
33
+ selector: 'header, footer, main, aside, [role="banner"], [role="contentinfo"], [role="main"], [role="complementary"]',
30
34
  evaluate(element) {
31
35
  const tag = element.tagName.toLowerCase();
32
36
  // <header>/<footer> inside sectioning content are not landmarks at all.
@@ -34,9 +38,20 @@ export default {
34
38
  element.closest('article, aside, main, nav, section')) {
35
39
  return { status: 'pass' };
36
40
  }
41
+ // An <aside> is complementary only when implicitRole says so: unnamed
42
+ // inside sectioning content it is generic (Chromium exposes NO
43
+ // complementary node for a news home full of related-content asides,
44
+ // measured live) — there is no landmark to be top level. An aside
45
+ // directly inside <main> is NOT demoted (main is not sectioning
46
+ // content), so an ad-slot aside nested in main is a real finding.
47
+ if (tag === 'aside' && !element.hasAttribute('role') &&
48
+ implicitRole(element) !== 'complementary') {
49
+ return { status: 'pass' };
50
+ }
37
51
  const container = landmarkAncestor(element);
38
52
  if (!container) return { status: 'pass' };
39
- const role = element.getAttribute('role') ?? { header: 'banner', footer: 'contentinfo', main: 'main' }[tag];
53
+ const role = element.getAttribute('role') ??
54
+ { header: 'banner', footer: 'contentinfo', main: 'main', aside: 'complementary' }[tag];
40
55
  return {
41
56
  status: 'fail',
42
57
  message: `This ${role} landmark is nested inside a ${container.role} landmark (<${container.element.tagName.toLowerCase()}>) — page-level regions lose their meaning when nested.`,
@@ -22,6 +22,7 @@ const landmarkName = (element) =>
22
22
 
23
23
  export default {
24
24
  id: 'landmark-unique',
25
+ name: 'Distinguishable landmarks',
25
26
  impact: 'moderate',
26
27
  tags: ['best-practice'],
27
28
  help: 'Landmarks of the same type need distinguishing labels',
@@ -5,6 +5,7 @@
5
5
  // pair usually reads better as label + aria-describedby.
6
6
  export default {
7
7
  id: 'multiple-labels',
8
+ name: 'Multiple labels',
8
9
  impact: 'minor',
9
10
  tags: ['best-practice'],
10
11
  help: 'Form fields should not have multiple labels',
@@ -4,6 +4,7 @@ const MENTIONS_NEW_WINDOW = /new (window|tab)|opens? in/i;
4
4
 
5
5
  export default {
6
6
  id: 'new-window-link',
7
+ name: 'New-window warnings',
7
8
  impact: 'minor',
8
9
  tags: ['best-practice'],
9
10
  help: 'Links opening a new window should say so',
@@ -1,5 +1,6 @@
1
1
  export default {
2
2
  id: 'no-autofocus',
3
+ name: 'Autofocus on load',
3
4
  impact: 'minor',
4
5
  tags: ['best-practice'],
5
6
  help: 'autofocus disorients assistive-technology users',
@@ -1,5 +1,6 @@
1
1
  export default {
2
2
  id: 'page-heading-one',
3
+ name: 'Leading page heading',
3
4
  impact: 'moderate',
4
5
  tags: ['best-practice'],
5
6
  help: 'Every page should start its outline with an <h1>',
@@ -1,5 +1,6 @@
1
1
  export default {
2
2
  id: 'positive-tabindex',
3
+ name: 'Positive tabindex',
3
4
  impact: 'serious',
4
5
  tags: ['best-practice'],
5
6
  help: 'Positive tabindex values disrupt the natural focus order',
@@ -5,6 +5,7 @@ const REDUNDANT_PHRASE = /^(image|picture|graphic|icon)\b( of\b)?/i;
5
5
 
6
6
  export default {
7
7
  id: 'redundant-alt-phrase',
8
+ name: 'Redundant alt phrasing',
8
9
  impact: 'minor',
9
10
  tags: ['best-practice'],
10
11
  help: 'alt text should not start with "image of" or similar',
@@ -11,6 +11,7 @@ const NAME_FROM_CONTENT = new Set(['button', 'link', 'menuitem', 'menuitemcheckb
11
11
 
12
12
  export default {
13
13
  id: 'redundant-aria-label',
14
+ name: 'Redundant aria-label',
14
15
  impact: 'minor',
15
16
  tags: ['best-practice'],
16
17
  help: 'aria-label should not repeat the visible text',
@@ -10,6 +10,7 @@ const NATIVE_PAIRS = [
10
10
 
11
11
  export default {
12
12
  id: 'redundant-aria',
13
+ name: 'Redundant ARIA attributes',
13
14
  impact: 'minor',
14
15
  tags: ['best-practice'],
15
16
  help: 'aria attributes should not duplicate native HTML attributes',
@@ -1,20 +1,64 @@
1
+ // The question this rule answers: does the image's text alternative
2
+ // duplicate text the control ALREADY announces beside it? Both halves of
3
+ // that must be measured in accname terms, not raw DOM text:
4
+ // - The comparison text is the container's AT-exposed text (aria-hidden,
5
+ // hidden, display:none and visibility:hidden subtrees never reach the
6
+ // name — a display:none "Search" span does not make the alt redundant,
7
+ // it makes the alt the button's ONLY name, and advising alt="" there
8
+ // would delete it. Measured on a live banking home + Chromium AX tree.)
9
+ // - The alternative is alt when present, else title: HTML-AAM's fallback
10
+ // chain names an alt-less image from its title, so a card thumbnail
11
+ // whose title repeats the headline doubles the link name just the same
12
+ // (measured on a live news home where every card did exactly this).
13
+ const HIDDEN_TAGS = new Set(['script', 'style', 'noscript', 'template']);
14
+
15
+ /** The container's name-contributing text EXCLUDING image alternatives:
16
+ * mirrors the accname content walk's hidden-content rules. */
17
+ function exposedText(root) {
18
+ let text = '';
19
+ for (const node of root.childNodes) {
20
+ if (node.nodeType === Node.TEXT_NODE) { text += node.textContent; continue; }
21
+ if (node.nodeType !== Node.ELEMENT_NODE) continue;
22
+ const tag = node.tagName.toLowerCase();
23
+ if (HIDDEN_TAGS.has(tag) || tag === 'img' || tag === 'area' || tag === 'svg') continue;
24
+ if (node.getAttribute('aria-hidden') === 'true' || node.hasAttribute('hidden')) continue;
25
+ const style = getComputedStyle(node);
26
+ if (style.display === 'none' || style.visibility === 'hidden') continue;
27
+ text += exposedText(node);
28
+ }
29
+ return text;
30
+ }
31
+
32
+ const norm = (s) => (s ?? '').replace(/\s+/g, ' ').trim().toLowerCase();
33
+
1
34
  export default {
2
35
  id: 'redundant-image-alt',
36
+ name: 'Redundant image alt',
3
37
  impact: 'minor',
4
38
  tags: ['best-practice'],
5
39
  help: 'Image alt should not repeat adjacent link or button text',
6
40
  helpUrl: 'https://www.w3.org/WAI/tutorials/images/functional/',
7
- selector: 'a[href] img[alt], button img[alt]',
41
+ selector: 'a[href] img[alt], button img[alt], a[href] img[title]:not([alt]), button img[title]:not([alt])',
8
42
  evaluate(element) {
9
- const alt = element.getAttribute('alt').replace(/\s+/g, ' ').trim().toLowerCase();
10
- if (!alt) return { status: 'pass' };
43
+ const alt = element.getAttribute('alt');
44
+ const usesTitle = alt === null;
45
+ const alternative = norm(usesTitle ? element.getAttribute('title') : alt);
46
+ if (!alternative) return { status: 'pass' };
11
47
  const container = element.closest('a[href], button');
12
- const text = container.textContent.replace(/\s+/g, ' ').trim().toLowerCase();
13
- if (alt !== text) return { status: 'pass' };
48
+ const text = norm(exposedText(container));
49
+ // No exposed text: the image's alternative is the control's only name.
50
+ // Nothing is announced twice — and removing it would unname the control.
51
+ if (!text) return { status: 'pass' };
52
+ if (alternative !== text) return { status: 'pass' };
53
+ const kind = container.tagName === 'A' ? 'link' : 'button';
14
54
  return {
15
55
  status: 'fail',
16
- message: `The image alt repeats the ${container.tagName === 'A' ? 'link' : 'button'} text — screen readers announce "${element.getAttribute('alt')}" twice.`,
17
- fix: 'Use alt="" on the image; the visible text already names the control.',
56
+ message: usesTitle
57
+ ? `The image title repeats the ${kind} text — the title names the image, so screen readers announce "${element.getAttribute('title')}" twice.`
58
+ : `The image alt repeats the ${kind} text — screen readers announce "${element.getAttribute('alt')}" twice.`,
59
+ fix: usesTitle
60
+ ? 'Add alt="" to the image (and drop the title if it is not needed as a tooltip); the visible text already names the control.'
61
+ : 'Use alt="" on the image; the visible text already names the control.',
18
62
  };
19
63
  },
20
64
  };
@@ -28,6 +28,7 @@ function implicitRole(element) {
28
28
 
29
29
  export default {
30
30
  id: 'redundant-role',
31
+ name: 'Redundant roles',
31
32
  impact: 'minor',
32
33
  tags: ['best-practice'],
33
34
  help: 'role attributes should not repeat the element’s built-in role',
@@ -1,5 +1,6 @@
1
1
  export default {
2
2
  id: 'redundant-tabindex',
3
+ name: 'Redundant tabindex',
3
4
  impact: 'minor',
4
5
  tags: ['best-practice'],
5
6
  help: 'tabindex="0" is unnecessary on natively focusable elements',
@@ -1,11 +1,21 @@
1
+ // header/footer are banner/contentinfo ONLY outside sectioning content
2
+ // (HTML-AAM; mirrored from lib/roles.js implicitRole): a footer buried in an
3
+ // <article> is generic, so treating it as a landmark hid its stray content.
4
+ // Divs do NOT demote — a header wrapped in plain divs is still a banner.
5
+ // role="alertdialog" joins dialog: both are modal containers whose content
6
+ // region navigation reaches through the dialog itself, and consent banners
7
+ // (measured on a live news home) are exactly this shape.
1
8
  const LANDMARK =
2
- 'main, nav, header, footer, aside, form[aria-label], form[aria-labelledby], ' +
9
+ 'main, nav, aside, form[aria-label], form[aria-labelledby], ' +
10
+ 'header:not(:is(article, aside, main, nav, section) header), ' +
11
+ 'footer:not(:is(article, aside, main, nav, section) footer), ' +
3
12
  'section[aria-label], section[aria-labelledby], [role="main"], [role="navigation"], ' +
4
13
  '[role="banner"], [role="contentinfo"], [role="complementary"], [role="region"], ' +
5
- '[role="search"], [role="form"], [role="dialog"]';
14
+ '[role="search"], [role="form"], [role="dialog"], [role="alertdialog"]';
6
15
 
7
16
  export default {
8
17
  id: 'region',
18
+ name: 'Content outside landmarks',
9
19
  impact: 'moderate',
10
20
  tags: ['best-practice'],
11
21
  help: 'All readable content belongs inside a landmark region',
@@ -28,8 +38,12 @@ export default {
28
38
  // Any perceivable content counts, not just text — but only content in
29
39
  // the accessibility tree: decorative images (alt="", presentation) and
30
40
  // unnamed inline svg are invisible to AT, so they can't be "unreachable".
41
+ // Bare form controls belong here too: an input has no text of its own,
42
+ // yet it is exactly the content a region-navigating user needs to reach
43
+ // (a booking form outside every landmark was invisible to this rule).
31
44
  const isMedia = element.matches(
32
- 'img:not([alt=""]):not([role="presentation"]):not([role="none"]), svg[role="img"], video, audio, canvas, iframe',
45
+ 'img:not([alt=""]):not([role="presentation"]):not([role="none"]), svg[role="img"], video, audio, canvas, iframe, ' +
46
+ 'input:not([type="hidden"]), select, textarea, button',
33
47
  );
34
48
  if (!ownText(element) && !isMedia) return { status: 'pass' };
35
49
  if (element.closest(LANDMARK)) return { status: 'pass' };
@@ -1,6 +1,7 @@
1
1
  // WCAG SC 1.1.1 Non-text Content (Level A) · SC 2.4.4 Link Purpose (In Context) (Level A)
2
2
  export default {
3
3
  id: 'area-alt',
4
+ name: 'Image map alt text',
4
5
  impact: 'critical',
5
6
  tags: ['wcag2a', 'wcag111', 'wcag244'],
6
7
  help: 'Image map areas must have alternative text',
@@ -15,6 +15,7 @@
15
15
  // matters.
16
16
  export default {
17
17
  id: 'canvas-alt',
18
+ name: 'Canvas accessible name',
18
19
  impact: 'moderate',
19
20
  tags: ['wcag2a', 'wcag111'],
20
21
  help: 'Meaningful <canvas> graphics need an accessible name or fallback',
@@ -11,6 +11,7 @@
11
11
  // is, but no criterion provably fails.
12
12
  export default {
13
13
  id: 'embed-alt',
14
+ name: 'Embed accessible name',
14
15
  impact: 'serious',
15
16
  tags: ['wcag2a', 'wcag111'],
16
17
  help: '<embed> needs an accessible name',
@@ -10,6 +10,7 @@ const GENERIC_ALT = /^(image|img|photo|photograph|picture|graphic|icon|untitled|
10
10
 
11
11
  export default {
12
12
  id: 'image-alt',
13
+ name: 'Image alt text',
13
14
  impact: 'critical',
14
15
  tags: ['wcag2a', 'wcag111'],
15
16
  help: 'Every <img> needs a text alternative',
@@ -1,6 +1,7 @@
1
1
  // WCAG SC 1.1.1 Non-text Content (Level A) · SC 4.1.2 Name, Role, Value (Level A)
2
2
  export default {
3
3
  id: 'input-image-alt',
4
+ name: 'Image button alt text',
4
5
  impact: 'critical',
5
6
  tags: ['wcag2a', 'wcag111', 'wcag412'],
6
7
  help: 'Image inputs need alt text describing their action',
@@ -1,6 +1,7 @@
1
1
  // WCAG SC 1.1.1 Non-text Content (Level A)
2
2
  export default {
3
3
  id: 'object-alt',
4
+ name: 'Object alt text',
4
5
  impact: 'serious',
5
6
  tags: ['wcag2a', 'wcag111'],
6
7
  help: '<object> embeds must have a text alternative',
@@ -3,6 +3,7 @@ import { labelledByName } from '../../lib/accessible-name.js';
3
3
 
4
4
  export default {
5
5
  id: 'svg-img-alt',
6
+ name: 'SVG accessible name',
6
7
  impact: 'serious',
7
8
  tags: ['wcag2a', 'wcag111'],
8
9
  help: 'Inline SVG and role="img" graphics need an accessible name',
@@ -1,6 +1,7 @@
1
1
  // WCAG SC 1.2.2 Captions (Prerecorded) (Level A)
2
2
  export default {
3
3
  id: 'media-captions',
4
+ name: 'Video captions',
4
5
  impact: 'critical',
5
6
  tags: ['wcag2a', 'wcag122'],
6
7
  help: 'Video content must have captions',
@@ -21,17 +22,23 @@ export default {
21
22
  if (!element.currentSrc && !element.getAttribute('src') && !element.querySelector('source')) {
22
23
  return { status: 'pass' };
23
24
  }
24
- // The decorative no-audio-path signature — the same boundary
25
- // video-loop-motion draws for 2.2.2: muted, autoplaying, no controls.
26
- // With no controls there is no user path to unmute, so whatever audio
27
- // the file may carry is never presented to anyone; audio that cannot be
25
+ // The decorative no-audio-path signature: muted with no controls. With
26
+ // no controls there is no user path to unmute, so whatever audio the
27
+ // file may carry is never presented to anyone; audio that cannot be
28
28
  // presented is not audio content under 1.2.2, and asking a human to
29
29
  // verify captions for it is noise (measured on the benchmark corpus:
30
30
  // our own site's aria-hidden hero loops, ffprobe-confirmed to carry no
31
31
  // audio stream, plus the same family on four news/university homes).
32
+ // HOW playback starts is deliberately not part of the signature: an
33
+ // earlier autoplay condition missed the two other members of the same
34
+ // family, measured on our own site — a crossfade B-roll take that only
35
+ // ever plays via script (no autoplay attribute, so the review fired
36
+ // exactly when the script had wired its src), and heroes whose
37
+ // autoplay attribute a reduced-motion handler removes. Muteness and
38
+ // controls decide whether audio can reach anyone; autoplay never did.
32
39
  // Properties, not attributes: script-muted heroes count too. A muted
33
40
  // video WITH controls keeps its review — one click unmutes it.
34
- if (element.muted && element.autoplay && !element.controls) {
41
+ if (element.muted && !element.controls) {
35
42
  return { status: 'pass' };
36
43
  }
37
44
  // Captions may be burned in or provided by the player — a human must check.
@@ -47,6 +47,7 @@ function candidateDescendants(element) {
47
47
 
48
48
  export default {
49
49
  id: 'aria-required-children',
50
+ name: 'Required ARIA children',
50
51
  impact: 'critical',
51
52
  tags: ['wcag2a', 'wcag131'],
52
53
  help: 'Composite ARIA roles must contain their required children',
@@ -20,6 +20,7 @@ const IMPLICIT_CONTAINER = { ul: 'list', ol: 'list', menu: 'list', table: 'table
20
20
 
21
21
  export default {
22
22
  id: 'aria-required-parent',
23
+ name: 'Required ARIA parent',
23
24
  impact: 'critical',
24
25
  tags: ['wcag2a', 'wcag131'],
25
26
  help: 'ARIA child roles must be inside their required container role',
@@ -3,6 +3,7 @@ const ALLOWED = new Set(['DT', 'DD', 'DIV', 'SCRIPT', 'TEMPLATE']);
3
3
 
4
4
  export default {
5
5
  id: 'definition-list',
6
+ name: 'Definition list structure',
6
7
  impact: 'serious',
7
8
  tags: ['wcag2a', 'wcag131'],
8
9
  help: '<dl> must be structured as term/description pairs',
@@ -1,6 +1,7 @@
1
1
  // WCAG SC 1.3.1 Info and Relationships (Level A)
2
2
  export default {
3
3
  id: 'dlitem-parent',
4
+ name: 'Definition item placement',
4
5
  impact: 'serious',
5
6
  tags: ['wcag2a', 'wcag131'],
6
7
  help: '<dt> and <dd> must be inside a <dl>',
@@ -6,6 +6,7 @@ import { labelledByName } from '../../lib/accessible-name.js';
6
6
 
7
7
  export default {
8
8
  id: 'form-label',
9
+ name: 'Form field labels',
9
10
  impact: 'critical',
10
11
  tags: ['wcag2a', 'wcag131', 'wcag332', 'wcag412'],
11
12
  help: 'Every form field needs a label',
@@ -8,6 +8,7 @@ const ITEM_ROLES = ['listitem', 'presentation', 'none'];
8
8
 
9
9
  export default {
10
10
  id: 'list-structure',
11
+ name: 'List structure',
11
12
  impact: 'serious',
12
13
  tags: ['wcag2a', 'wcag131'],
13
14
  help: 'Lists must only contain list items',
@@ -1,6 +1,7 @@
1
1
  // WCAG SC 1.3.1 Info and Relationships (Level A)
2
2
  export default {
3
3
  id: 'listitem-parent',
4
+ name: 'List item placement',
4
5
  impact: 'serious',
5
6
  tags: ['wcag2a', 'wcag131'],
6
7
  help: '<li> must be inside a list',