pour-engine 1.28.0 → 1.30.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 +18 -17
  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 +1 -0
  9. package/engine/rules/best-practice/landmark-one-main.js +1 -0
  10. package/engine/rules/best-practice/landmark-top-level.js +1 -0
  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 +1 -0
  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 +1 -0
  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 +1 -0
  77. package/engine/rules/wcag/4.1.2-aria-valid-refs.js +117 -37
  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
@@ -3,6 +3,7 @@ import { collectRoots, isEmbeddedDocument } from '../../lib/dom.js';
3
3
 
4
4
  export default {
5
5
  id: 'bypass-blocks',
6
+ name: 'Skip to content',
6
7
  impact: 'serious',
7
8
  tags: ['wcag2a', 'wcag241'],
8
9
  help: 'The page must offer a way to skip repeated blocks',
@@ -31,6 +31,7 @@ function edgeReserved(doc, edge, needed) {
31
31
 
32
32
  export default {
33
33
  id: 'focus-not-obscured',
34
+ name: 'Unobscured focus',
34
35
  impact: 'serious',
35
36
  tags: ['wcag22aa', 'wcag2411'],
36
37
  help: 'Focused elements must not be fully hidden behind overlays',
@@ -32,6 +32,7 @@ const UNDESCRIPTIVE = new Set([
32
32
 
33
33
  export default {
34
34
  id: 'document-title',
35
+ name: 'Page title',
35
36
  impact: 'serious',
36
37
  tags: ['wcag2a', 'wcag242'],
37
38
  help: 'The page must have a title that describes its topic or purpose',
@@ -48,6 +48,7 @@ function visualSequence(entries, rightToLeft) {
48
48
 
49
49
  export default {
50
50
  id: 'visual-order-divergence',
51
+ name: 'Focus order vs visual order',
51
52
  impact: 'moderate',
52
53
  tags: ['wcag2a', 'wcag243'],
53
54
  help: 'CSS reordering should not make Tab jump against the visual order',
@@ -1,6 +1,7 @@
1
1
  // WCAG SC 2.4.4 Link Purpose (In Context) (Level A) · SC 4.1.2 Name, Role, Value (Level A)
2
2
  export default {
3
3
  id: 'link-name',
4
+ name: 'Link names',
4
5
  impact: 'serious',
5
6
  tags: ['wcag2a', 'wcag244', 'wcag412'],
6
7
  help: 'Links must have an accessible name',
@@ -78,6 +78,7 @@ export function createLinkPurposeRule({ id, impact, tags, help, helpUrl, verdict
78
78
 
79
79
  export default createLinkPurposeRule({
80
80
  id: 'link-text-generic',
81
+ name: 'Generic link text',
81
82
  impact: 'moderate',
82
83
  tags: ['wcag2a', 'wcag244'],
83
84
  help: 'Link text should describe where the link goes',
@@ -52,6 +52,7 @@ function focusSuppressors(doc) {
52
52
 
53
53
  export default {
54
54
  id: 'focus-visible',
55
+ name: 'Visible focus',
55
56
  impact: 'serious',
56
57
  tags: ['wcag2aa', 'wcag247'],
57
58
  help: 'Keyboard focus must remain visible',
@@ -14,6 +14,7 @@ import { createLinkPurposeRule } from './2.4.4-link-text-generic.js';
14
14
 
15
15
  export default createLinkPurposeRule({
16
16
  id: 'link-text-generic-only',
17
+ name: 'Self-sufficient link text',
17
18
  impact: 'moderate',
18
19
  tags: ['wcag2aaa', 'wcag249'],
19
20
  help: 'Link text alone must identify where the link goes (AAA)',
@@ -71,6 +71,7 @@ function visibleText(nodes) {
71
71
 
72
72
  export default {
73
73
  id: 'label-in-name',
74
+ name: 'Label in accessible name',
74
75
  impact: 'serious',
75
76
  tags: ['wcag21a', 'wcag253'],
76
77
  help: 'The visible label must be part of the accessible name',
@@ -6,6 +6,7 @@ import { createTargetSizeRule } from './2.5.8-target-size.js';
6
6
 
7
7
  export default createTargetSizeRule({
8
8
  id: 'target-size-enhanced',
9
+ name: 'Enhanced target size',
9
10
  tags: ['wcag2aaa', 'wcag255'],
10
11
  help: 'Interactive targets must be at least 44×44 pixels (AAA)',
11
12
  helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/target-size-enhanced.html',
@@ -467,6 +467,7 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
467
467
 
468
468
  export default createTargetSizeRule({
469
469
  id: 'target-size',
470
+ name: 'Target size',
470
471
  tags: ['wcag22aa', 'wcag258'],
471
472
  help: 'Interactive targets must be at least 24×24 pixels',
472
473
  helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html',
@@ -12,6 +12,7 @@ const LANG_PATTERN = /^([a-zA-Z]{2,3}(-[a-zA-Z0-9]{1,8})*|[xXiI](-[a-zA-Z0-9]{1,
12
12
 
13
13
  export default {
14
14
  id: 'html-lang',
15
+ name: 'Page language',
15
16
  impact: 'serious',
16
17
  tags: ['wcag2a', 'wcag311'],
17
18
  help: 'The <html> element must declare a valid language',
@@ -6,6 +6,7 @@ const LANG_PATTERN = /^([a-zA-Z]{2,3}(-[a-zA-Z0-9]{1,8})*|[xXiI](-[a-zA-Z0-9]{1,
6
6
 
7
7
  export default {
8
8
  id: 'valid-lang-parts',
9
+ name: 'Part language tags',
9
10
  impact: 'serious',
10
11
  tags: ['wcag2aa', 'wcag312'],
11
12
  help: 'lang attributes on page parts must be valid',
@@ -20,6 +20,7 @@ const NAVIGATES = /\blocation\s*(=|\.\s*(href|assign|replace|reload))|\.(submit|
20
20
 
21
21
  export default {
22
22
  id: 'on-input-navigation',
23
+ name: 'Unexpected context change',
23
24
  impact: 'serious',
24
25
  tags: ['wcag2a', 'wcag322', 'wcag321'],
25
26
  help: 'Changing or focusing a control must not unexpectedly change context',
@@ -25,6 +25,7 @@
25
25
  // value except false/undefined — "spelling" and "grammar" count.
26
26
  export default {
27
27
  id: 'error-message-linkage',
28
+ name: 'Error message linkage',
28
29
  impact: 'serious',
29
30
  tags: ['wcag2a', 'wcag331'],
30
31
  help: 'Fields marked invalid need their error described in text',
@@ -13,6 +13,7 @@
13
13
  // case worth asserting.
14
14
  export default {
15
15
  id: 'auth-field-obstruction',
16
+ name: 'Accessible login fields',
16
17
  impact: 'serious',
17
18
  tags: ['wcag22aa', 'wcag338'],
18
19
  help: 'Login fields must not block paste or password managers',
@@ -39,6 +39,7 @@ const HANDLED_ELSEWHERE = new Set(['label', 'labelledby']);
39
39
 
40
40
  export default {
41
41
  id: 'aria-allowed-attr',
42
+ name: 'Allowed ARIA attributes',
42
43
  impact: 'moderate',
43
44
  tags: ['wcag2a', 'wcag412'],
44
45
  help: 'ARIA attributes must be supported by the element’s role',
@@ -24,6 +24,7 @@ const ENUMS = {
24
24
 
25
25
  export default {
26
26
  id: 'aria-attr-valid',
27
+ name: 'Valid ARIA values',
27
28
  impact: 'critical',
28
29
  tags: ['wcag2a', 'wcag412'],
29
30
  help: 'aria attributes must exist and have valid values',
@@ -23,6 +23,7 @@ function authorName(element) {
23
23
 
24
24
  export default {
25
25
  id: 'aria-field-name',
26
+ name: 'ARIA field names',
26
27
  impact: 'serious',
27
28
  tags: ['wcag2a', 'wcag412'],
28
29
  help: 'ARIA fields and value widgets must have an accessible name',
@@ -6,6 +6,7 @@ const FOCUSABLE = 'a[href], area[href], button, input, select, textarea, summary
6
6
 
7
7
  export default {
8
8
  id: 'aria-hidden-focus',
9
+ name: 'Focusable hidden content',
9
10
  impact: 'serious',
10
11
  tags: ['wcag2a', 'wcag412'],
11
12
  help: 'Nothing focusable may sit inside aria-hidden="true"',
@@ -42,6 +42,7 @@ const NAME_FROM_CONTENT = 'a[href], button, summary, h1, h2, h3, h4, h5, h6, th,
42
42
 
43
43
  export default {
44
44
  id: 'aria-label-misuse',
45
+ name: 'aria-label placement',
45
46
  impact: 'moderate',
46
47
  tags: ['wcag2a', 'wcag412'],
47
48
  help: 'aria-label on a plain container is prohibited by ARIA, so its announcement is not guaranteed',
@@ -6,8 +6,108 @@ const REF_ATTRIBUTES = [
6
6
  'aria-activedescendant', 'aria-owns', 'aria-errormessage', 'aria-details',
7
7
  ];
8
8
 
9
+ // Deferred-tooltip pattern: libraries (Deque's own docs component included)
10
+ // write aria-describedby="tooltip7" at rest and only CREATE #tooltip7 when
11
+ // the trigger is hovered or focused — which is exactly when a screen reader
12
+ // announces the description, so the pattern works. Only DESCRIPTIVE refs
13
+ // get this benefit of the doubt: a NAME that exists only on hover is broken
14
+ // for rotor navigation even when the deferral works, so aria-labelledby
15
+ // (and the rest) keep failing at rest.
16
+ const PROBEABLE = new Set(['aria-describedby', 'aria-details']);
17
+ // A handful of probes covers any real page; a template bug that dangles
18
+ // fifty refs is broken enough that the un-probed remainder failing as
19
+ // before is the right answer.
20
+ const PROBE_BUDGET = 8;
21
+ const PROBE_WAIT_MS = 250;
22
+
23
+ /** Fire the events a tooltip library listens for, wait for it to react,
24
+ * check whether the ids materialised, then put the page back. */
25
+ async function probeDeferred(element, ids) {
26
+ const over = ['pointerover', 'mouseover', 'mouseenter'];
27
+ const out = ['pointerout', 'mouseout', 'mouseleave'];
28
+ for (const type of over) element.dispatchEvent(new MouseEvent(type, { bubbles: type !== 'mouseenter' }));
29
+ element.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
30
+ try { element.focus?.({ preventScroll: true }); } catch { /* non-focusable */ }
31
+ await new Promise((resolve) => setTimeout(resolve, PROBE_WAIT_MS));
32
+ const root = element.getRootNode();
33
+ const appeared = ids.every((id) => root.getElementById?.(id));
34
+ // Restore: the audit must leave the page as it found it, and later rules
35
+ // must not meet a tooltip that only exists because we hovered.
36
+ for (const type of out) element.dispatchEvent(new MouseEvent(type, { bubbles: type !== 'mouseleave' }));
37
+ element.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
38
+ try { element.blur?.(); } catch { /* ignore */ }
39
+ await new Promise((resolve) => setTimeout(resolve, 50));
40
+ return appeared;
41
+ }
42
+
43
+ function inspect(element) {
44
+ // ARIA id references cannot cross shadow boundaries: they resolve only
45
+ // within the element's own root (document or shadow tree).
46
+ const root = element.getRootNode();
47
+ // Collapsed-disclosure pattern: a trigger with aria-expanded="false"
48
+ // whose aria-controls panel doesn't exist YET (rendered on expand) is
49
+ // ubiquitous and harmless while collapsed — name computation doesn't
50
+ // touch aria-controls, so nothing resolves wrongly today.
51
+ const collapsed = element.getAttribute('aria-expanded') === 'false';
52
+ const missing = [];
53
+ const ambiguous = [];
54
+ for (const attr of REF_ATTRIBUTES) {
55
+ for (const id of (element.getAttribute(attr) ?? '').split(/\s+/).filter(Boolean)) {
56
+ if (!root.getElementById?.(id)) {
57
+ if (attr === 'aria-controls' && collapsed) continue;
58
+ missing.push({ attr, id });
59
+ continue;
60
+ }
61
+ const count = root.querySelectorAll(`[id="${CSS.escape(id)}"]`).length;
62
+ if (count > 1) {
63
+ ambiguous.push(`${attr}="${id}" — ${count} elements share this id; the reference binds to the FIRST one in the DOM`);
64
+ }
65
+ }
66
+ }
67
+ return { missing, ambiguous };
68
+ }
69
+
70
+ function outcome({ missing, ambiguous }, probed, element, accessibleName) {
71
+ if (missing.length) {
72
+ // A dangling ref is skipped by the accname computation, so the HARM
73
+ // depends on what remains. A labelledby that dangles while the element
74
+ // still names itself (contents, aria-label) is inert clutter — real,
75
+ // fragile, validator-flagged, but announced correctly today. The same
76
+ // dangle on an otherwise nameless element is the 4.1.2 failure proper:
77
+ // names are consumed at REST (element lists, rotor), so the finding
78
+ // says which of the two this is instead of one blanket accusation.
79
+ const restingName = (accessibleName?.(element) ?? '').trim();
80
+ const detail = missing
81
+ .map(({ attr, id }) => {
82
+ if (attr === 'aria-labelledby') {
83
+ return restingName
84
+ ? `aria-labelledby="${id}" points to nothing — the reference is ignored and this element's name ("${restingName.slice(0, 40)}") currently comes from elsewhere; announced correctly today, but the dead reference is fragile`
85
+ : `aria-labelledby="${id}" points to nothing and leaves this element without an accessible name`;
86
+ }
87
+ return `${attr}="${id}" points to nothing — assistive technology silently ignores it`;
88
+ })
89
+ .join('; ');
90
+ return {
91
+ status: 'fail',
92
+ message: `Broken ARIA references: ${detail}.${probed ? ' Probed with focus and hover in case the target is created on demand — it never appeared.' : ''}`,
93
+ fix: 'Correct or remove the reference, or give the target element that id.',
94
+ };
95
+ }
96
+ if (ambiguous.length) {
97
+ // Duplicate ids resolve deterministically to the first copy — often
98
+ // the right one (desktop/mobile double-renders). Real, but a human
99
+ // must judge whether the first copy is the wrong copy.
100
+ return {
101
+ status: 'incomplete',
102
+ message: `Ambiguous ARIA references: ${ambiguous.join('; ')}. If the first copy in the DOM is the intended target, this works today — but it's fragile.`,
103
+ };
104
+ }
105
+ return { status: 'pass' };
106
+ }
107
+
9
108
  export default {
10
109
  id: 'aria-valid-refs',
110
+ name: 'ARIA id references',
11
111
  // Moderate, not critical: with 4.1.1 Parsing retired in WCAG 2.2 these
12
112
  // are 4.1.2 name/state defects, and aria-controls in particular has weak
13
113
  // assistive-technology support — real, but rarely blocking.
@@ -16,46 +116,26 @@ export default {
16
116
  help: 'ARIA id references must point to elements that exist',
17
117
  helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
18
118
  selector: REF_ATTRIBUTES.map((attr) => `[${attr}]`).join(', '),
19
- evaluate(element) {
20
- // ARIA id references cannot cross shadow boundaries: they resolve only
21
- // within the element's own root (document or shadow tree).
22
- const root = element.getRootNode();
23
- // Collapsed-disclosure pattern: a trigger with aria-expanded="false"
24
- // whose aria-controls panel doesn't exist YET (rendered on expand) is
25
- // ubiquitous and harmless while collapsed — name computation doesn't
26
- // touch aria-controls, so nothing resolves wrongly today.
27
- const collapsed = element.getAttribute('aria-expanded') === 'false';
28
- const missing = [];
29
- const ambiguous = [];
30
- for (const attr of REF_ATTRIBUTES) {
31
- for (const id of (element.getAttribute(attr) ?? '').split(/\s+/).filter(Boolean)) {
32
- if (!root.getElementById?.(id)) {
33
- if (attr === 'aria-controls' && collapsed) continue;
34
- missing.push(`${attr}="${id}" points to nothing — assistive technology silently ignores it`);
119
+ async evaluateAll(elements, { accessibleName } = {}) {
120
+ let budget = PROBE_BUDGET;
121
+ const outcomes = new Array(elements.length);
122
+ for (let i = 0; i < elements.length; i++) {
123
+ const element = elements[i];
124
+ const found = inspect(element);
125
+ const deferredOnly = found.missing.length > 0
126
+ && found.missing.every(({ attr }) => PROBEABLE.has(attr));
127
+ if (deferredOnly && budget > 0) {
128
+ budget--;
129
+ if (await probeDeferred(element, found.missing.map(({ id }) => id))) {
130
+ // The description materialises exactly when it would be announced.
131
+ outcomes[i] = { status: 'pass' };
35
132
  continue;
36
133
  }
37
- const count = root.querySelectorAll(`[id="${CSS.escape(id)}"]`).length;
38
- if (count > 1) {
39
- ambiguous.push(`${attr}="${id}" — ${count} elements share this id; the reference binds to the FIRST one in the DOM`);
40
- }
134
+ outcomes[i] = outcome(found, true, element, accessibleName);
135
+ continue;
41
136
  }
137
+ outcomes[i] = outcome(found, false, element, accessibleName);
42
138
  }
43
- if (missing.length) {
44
- return {
45
- status: 'fail',
46
- message: `Broken ARIA references: ${missing.join('; ')}.`,
47
- fix: 'Correct or remove the reference, or give the target element that id.',
48
- };
49
- }
50
- if (ambiguous.length) {
51
- // Duplicate ids resolve deterministically to the first copy — often
52
- // the right one (desktop/mobile double-renders). Real, but a human
53
- // must judge whether the first copy is the wrong copy.
54
- return {
55
- status: 'incomplete',
56
- message: `Ambiguous ARIA references: ${ambiguous.join('; ')}. If the first copy in the DOM is the intended target, this works today — but it's fragile.`,
57
- };
58
- }
59
- return { status: 'pass' };
139
+ return outcomes;
60
140
  },
61
141
  };
@@ -1,6 +1,7 @@
1
1
  // WCAG SC 4.1.2 Name, Role, Value (Level A)
2
2
  export default {
3
3
  id: 'button-name',
4
+ name: 'Button names',
4
5
  impact: 'critical',
5
6
  tags: ['wcag2a', 'wcag412'],
6
7
  help: 'Buttons must have an accessible name',
@@ -10,6 +10,7 @@
10
10
  // where the binary, assertable failure lives.
11
11
  export default {
12
12
  id: 'composite-widget-name',
13
+ name: 'Widget item names',
13
14
  impact: 'serious',
14
15
  tags: ['wcag2a', 'wcag412'],
15
16
  help: 'Tabs, menu items, options and tree items need an accessible name',
@@ -8,6 +8,7 @@ import { labelledByName } from '../../lib/accessible-name.js';
8
8
 
9
9
  export default {
10
10
  id: 'dialog-name',
11
+ name: 'Dialog names',
11
12
  impact: 'serious',
12
13
  tags: ['wcag2a', 'wcag412'],
13
14
  help: 'Dialogs must have an accessible name',
@@ -1,6 +1,7 @@
1
1
  // WCAG SC 4.1.2 Name, Role, Value (Level A)
2
2
  export default {
3
3
  id: 'frame-title',
4
+ name: 'Frame titles',
4
5
  impact: 'serious',
5
6
  tags: ['wcag2a', 'wcag412'],
6
7
  help: 'Frames must have a title describing their content',
@@ -3,6 +3,7 @@ const LABELABLE = 'input, select, textarea, button, meter, output, progress';
3
3
 
4
4
  export default {
5
5
  id: 'label-for-valid',
6
+ name: 'Label target references',
6
7
  impact: 'serious',
7
8
  tags: ['wcag2a', 'wcag412'],
8
9
  help: 'label[for] must reference a form control that exists',
@@ -8,6 +8,7 @@ const INTERACTIVE =
8
8
 
9
9
  export default {
10
10
  id: 'nested-interactive',
11
+ name: 'Nested controls',
11
12
  impact: 'serious',
12
13
  tags: ['wcag2a', 'wcag412'],
13
14
  help: 'A control must not contain another control',
@@ -11,6 +11,7 @@ const REQUIRED = {
11
11
 
12
12
  export default {
13
13
  id: 'role-required-aria',
14
+ name: 'Required role states',
14
15
  impact: 'serious',
15
16
  tags: ['wcag2a', 'wcag412'],
16
17
  help: 'Some roles require a state attribute to work',
@@ -11,6 +11,7 @@
11
11
  // label ("Details").
12
12
  export default {
13
13
  id: 'summary-name',
14
+ name: 'Disclosure names',
14
15
  impact: 'serious',
15
16
  tags: ['wcag2a', 'wcag412'],
16
17
  help: 'Disclosure controls (<summary>) need an accessible name',
@@ -24,6 +24,7 @@ const VALID_ROLES = new Set([
24
24
 
25
25
  export default {
26
26
  id: 'valid-role',
27
+ name: 'Valid roles',
27
28
  impact: 'critical',
28
29
  tags: ['wcag2a', 'wcag412'],
29
30
  help: 'role attributes must use valid ARIA roles',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pour-engine",
3
- "version": "1.28.0",
3
+ "version": "1.30.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",
@@ -1,149 +0,0 @@
1
- // The WCAG 3.0 guideline catalog — derived from the W3C Working DRAFT of
2
- // 3 March 2026 (https://www.w3.org/TR/wcag-3.0/). WCAG 3.0 is a work in
3
- // progress: the W3C says it "may be updated, replaced, or obsoleted" and it
4
- // is not appropriate for conformance claims. Everything this module exports
5
- // is therefore marked draft, and every UI that shows it must say so.
6
- //
7
- // The draft has no A/AA/AAA levels and no success criteria; it organizes
8
- // outcome-style guidelines (with core/supplemental requirements below them)
9
- // under twelve functional groups. Our automated rules are written against
10
- // WCAG 2.x; `scToGuideline` maps each 2.x criterion we automate to the
11
- // draft guideline that covers the same user need, judged by reading both
12
- // specs side by side. Revisit the mapping with every new draft.
13
-
14
- export const draftInfo = {
15
- id: 'wcag30-draft',
16
- name: 'WCAG 3.0',
17
- status: 'W3C Working Draft',
18
- dated: '2026-03-03',
19
- draft: true,
20
- url: 'https://www.w3.org/TR/wcag-3.0/',
21
- note: 'A draft under active development: findings are WCAG 2.x rule results mapped to draft guidelines. Not suitable for conformance claims.',
22
- };
23
-
24
- const g = (num, name, group, anchor) => ({
25
- num,
26
- name,
27
- group,
28
- url: `https://www.w3.org/TR/wcag-3.0/#${anchor}`,
29
- });
30
-
31
- // All 44 guidelines of the 2026-03-03 draft, in document order.
32
- export const guidelines = [
33
- // 2.1 Images and media
34
- g('2.1.1', 'Image alternatives', 'Images and media', 'image-alternatives'),
35
- g('2.1.2', 'Media alternatives', 'Images and media', 'media-alternatives'),
36
- g('2.1.3', 'Non-text alternatives', 'Images and media', 'non-text-alternatives'),
37
- g('2.1.4', 'Captions', 'Images and media', 'captions'),
38
- g('2.1.5', 'Audio descriptions', 'Images and media', 'audio-descriptions'),
39
- g('2.1.6', 'Figure captions', 'Images and media', 'figure-captions'),
40
- g('2.1.7', 'Single sense', 'Images and media', 'single-sense'),
41
- // 2.2 Text and wording
42
- g('2.2.1', 'Text appearance', 'Text and wording', 'text-appearance'),
43
- g('2.2.2', 'Text-to-speech', 'Text and wording', 'text-to-speech'),
44
- g('2.2.3', 'Clear language', 'Text and wording', 'clear-language'),
45
- // 2.3 Interactive components
46
- g('2.3.1', 'Keyboard focus appearance', 'Interactive components', 'keyboard-focus-appearance'),
47
- g('2.3.2', 'Pointer focus appearance', 'Interactive components', 'pointer-focus-appearance'),
48
- g('2.3.3', 'Navigating content', 'Interactive components', 'navigating-content'),
49
- g('2.3.4', 'Expected behavior', 'Interactive components', 'expected-behavior'),
50
- g('2.3.5', 'Control information', 'Interactive components', 'control-information'),
51
- // 2.4 Input / operation
52
- g('2.4.1', 'Keyboard interface input', 'Input and operation', 'keyboard-interface-input'),
53
- g('2.4.2', 'Physical or cognitive effort when using keyboard', 'Input and operation', 'physical-or-cognitive-effort-when-using-keyboard'),
54
- g('2.4.3', 'Pointer input', 'Input and operation', 'pointer-input'),
55
- g('2.4.4', 'Speech and voice input', 'Input and operation', 'speech-and-voice-input'),
56
- g('2.4.5', 'Input operation', 'Input and operation', 'input-operation-0'),
57
- g('2.4.6', 'Authentication', 'Input and operation', 'authentication'),
58
- // 2.5 Error handling
59
- g('2.5.1', 'Correct errors', 'Error handling', 'correct-errors'),
60
- g('2.5.2', 'Prevent errors', 'Error handling', 'prevent-errors'),
61
- // 2.6 Animation and movement
62
- g('2.6.1', 'Avoid physical harm', 'Animation and movement', 'avoid-physical-harm'),
63
- // 2.7 Layout
64
- g('2.7.1', 'Recognizable layouts', 'Layout', 'recognizable-layouts'),
65
- g('2.7.2', 'User orientation', 'Layout', 'user-orientation'),
66
- g('2.7.3', 'Structure', 'Layout', 'structure'),
67
- g('2.7.4', 'No obstruction', 'Layout', 'no-obstruction'),
68
- // 2.8 Consistency across views
69
- g('2.8.1', 'Consistency', 'Consistency across views', 'consistency'),
70
- // 2.9 Process and task completion
71
- g('2.9.1', 'Avoid exclusionary cognitive tasks', 'Process and task completion', 'avoid-exclusionary-cognitive-tasks'),
72
- g('2.9.2', 'Adequate time', 'Process and task completion', 'adequate-time'),
73
- g('2.9.3', 'Avoid deception', 'Process and task completion', 'avoid-deception'),
74
- g('2.9.4', 'Retain information', 'Process and task completion', 'retain-information'),
75
- g('2.9.5', 'Complete tasks', 'Process and task completion', 'complete-tasks'),
76
- g('2.9.6', 'Unnecessary steps', 'Process and task completion', 'unnecessary-steps'),
77
- // 2.10 Policy and protection
78
- g('2.10.1', 'Risk', 'Policy and protection', 'risk'),
79
- g('2.10.2', 'Algorithms', 'Policy and protection', 'algorithms'),
80
- // 2.11 Help and feedback
81
- g('2.11.1', 'Help available', 'Help and feedback', 'help-available'),
82
- g('2.11.2', 'Feedback', 'Help and feedback', 'feedback'),
83
- // 2.12 User control
84
- g('2.12.1', 'Assistive technology control', 'User control', 'assistive-technology-control'),
85
- g('2.12.2', 'Control text', 'User control', 'control-text'),
86
- g('2.12.3', 'Adjustable viewport', 'User control', 'adjustable-viewport'),
87
- g('2.12.4', 'Media control', 'User control', 'media-control'),
88
- g('2.12.5', 'Content changes', 'User control', 'content-changes'),
89
- ];
90
-
91
- // WCAG 2.x criterion (as tagged on rules: wcag111 = SC 1.1.1) → the draft
92
- // guideline covering the same user need. Only criteria with automated rules
93
- // appear; the judgment is ours, made by reading both documents.
94
- export const scToGuideline = {
95
- 111: '2.1.1', // Non-text Content → Image alternatives
96
- 122: '2.1.4', // Captions (Prerecorded) → Captions
97
- 131: '2.7.3', // Info and Relationships → Structure
98
- 135: '2.4.5', // Identify Input Purpose → Input operation
99
- 141: '2.2.1', // Use of Color (links in text) → Text appearance
100
- 142: '2.12.4', // Audio Control → Media control
101
- 143: '2.2.1', // Contrast (Minimum) → Text appearance
102
- 144: '2.12.3', // Resize Text (viewport zoom) → Adjustable viewport
103
- 146: '2.2.1', // Contrast (Enhanced) → Text appearance
104
- 211: '2.4.1', // Keyboard → Keyboard interface input
105
- 221: '2.9.2', // Timing Adjustable → Adequate time
106
- 222: '2.12.4', // Pause, Stop, Hide → Media control
107
- 241: '2.3.3', // Bypass Blocks → Navigating content
108
- 242: '2.7.2', // Page Titled → User orientation
109
- 244: '2.3.5', // Link Purpose (In Context) → Control information
110
- 255: '2.4.3', // Target Size (Enhanced) → Pointer input
111
- 258: '2.4.3', // Target Size (Minimum) → Pointer input
112
- 311: '2.2.2', // Language of Page → Text-to-speech
113
- 312: '2.2.2', // Language of Parts → Text-to-speech
114
- 332: '2.3.5', // Labels or Instructions → Control information
115
- 412: '2.12.1', // Name, Role, Value → Assistive technology control
116
- };
117
-
118
- const guidelineByNum = new Map(guidelines.map((entry) => [entry.num, entry]));
119
- const partlyAutomated = new Set(Object.values(scToGuideline));
120
-
121
- /** The draft guideline (num/name/group/url) a rule's wcagNNN tag maps to. */
122
- export function guidelineForTags(tags) {
123
- for (const tag of tags ?? []) {
124
- const sc = tag.match(/^wcag(\d{3,4})$/)?.[1];
125
- if (sc && scToGuideline[sc]) return guidelineByNum.get(scToGuideline[sc]);
126
- }
127
- return null;
128
- }
129
-
130
- /**
131
- * The whole draft as a review checklist. Every guideline is listed — the
132
- * draft is too young for any of it to be silently skipped — shaped like the
133
- * 2.2 manual-review entries so existing UIs render it unchanged: `level` has
134
- * no meaning in the draft (no conformance levels yet) and `principle`
135
- * carries the draft's functional group.
136
- */
137
- export function manualReviewChecklist() {
138
- return guidelines.map(({ num, name, group, url }) => ({
139
- num,
140
- name,
141
- level: 'draft',
142
- principle: group,
143
- url,
144
- automation: partlyAutomated.has(num) ? 'partial' : 'manual',
145
- draft: true,
146
- }));
147
- }
148
-
149
- export default { draftInfo, guidelines, scToGuideline, guidelineForTags, manualReviewChecklist };