pour-engine 1.1.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 (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -0
  3. package/config/project.config.js +11 -0
  4. package/engine/COVERAGE.md +115 -0
  5. package/engine/README.md +115 -0
  6. package/engine/index.js +258 -0
  7. package/engine/lib/accessible-name.js +126 -0
  8. package/engine/lib/contrast.js +628 -0
  9. package/engine/lib/dom.js +116 -0
  10. package/engine/lib/roles.js +125 -0
  11. package/engine/rules/best-practice/accesskeys.js +29 -0
  12. package/engine/rules/best-practice/aria-label-misuse.js +22 -0
  13. package/engine/rules/best-practice/button-type.js +15 -0
  14. package/engine/rules/best-practice/empty-heading.js +19 -0
  15. package/engine/rules/best-practice/fieldset-legend.js +35 -0
  16. package/engine/rules/best-practice/heading-order.js +30 -0
  17. package/engine/rules/best-practice/landmark-one-main.js +22 -0
  18. package/engine/rules/best-practice/landmark-top-level.js +46 -0
  19. package/engine/rules/best-practice/landmark-unique.js +84 -0
  20. package/engine/rules/best-practice/multiple-labels.js +23 -0
  21. package/engine/rules/best-practice/new-window-link.js +21 -0
  22. package/engine/rules/best-practice/no-autofocus.js +16 -0
  23. package/engine/rules/best-practice/page-heading-one.js +17 -0
  24. package/engine/rules/best-practice/positive-tabindex.js +17 -0
  25. package/engine/rules/best-practice/redundant-alt-phrase.js +22 -0
  26. package/engine/rules/best-practice/redundant-aria-label.js +18 -0
  27. package/engine/rules/best-practice/redundant-aria.js +32 -0
  28. package/engine/rules/best-practice/redundant-image-alt.js +20 -0
  29. package/engine/rules/best-practice/redundant-role.js +62 -0
  30. package/engine/rules/best-practice/redundant-tabindex.js +15 -0
  31. package/engine/rules/best-practice/region.js +73 -0
  32. package/engine/rules/index.js +179 -0
  33. package/engine/rules/wcag/1.1.1-area-alt.js +18 -0
  34. package/engine/rules/wcag/1.1.1-image-alt.js +48 -0
  35. package/engine/rules/wcag/1.1.1-input-image-alt.js +17 -0
  36. package/engine/rules/wcag/1.1.1-object-alt.js +17 -0
  37. package/engine/rules/wcag/1.1.1-svg-img-alt.js +34 -0
  38. package/engine/rules/wcag/1.2.2-media-captions.js +19 -0
  39. package/engine/rules/wcag/1.3.1-aria-required-children.js +66 -0
  40. package/engine/rules/wcag/1.3.1-aria-required-parent.js +49 -0
  41. package/engine/rules/wcag/1.3.1-definition-list.js +21 -0
  42. package/engine/rules/wcag/1.3.1-dlitem-parent.js +17 -0
  43. package/engine/rules/wcag/1.3.1-form-label.js +81 -0
  44. package/engine/rules/wcag/1.3.1-list-structure.js +59 -0
  45. package/engine/rules/wcag/1.3.1-listitem-parent.js +33 -0
  46. package/engine/rules/wcag/1.3.1-p-as-heading.js +25 -0
  47. package/engine/rules/wcag/1.3.1-table-headers.js +61 -0
  48. package/engine/rules/wcag/1.3.5-autocomplete-valid.js +38 -0
  49. package/engine/rules/wcag/1.4.1-link-in-text-block.js +74 -0
  50. package/engine/rules/wcag/1.4.10-reflow.js +25 -0
  51. package/engine/rules/wcag/1.4.11-non-text-contrast.js +109 -0
  52. package/engine/rules/wcag/1.4.12-text-spacing.js +71 -0
  53. package/engine/rules/wcag/1.4.2-audio-control.js +40 -0
  54. package/engine/rules/wcag/1.4.3-color-contrast.js +462 -0
  55. package/engine/rules/wcag/1.4.3-control-contrast.js +72 -0
  56. package/engine/rules/wcag/1.4.4-meta-viewport.js +22 -0
  57. package/engine/rules/wcag/1.4.6-contrast-enhanced.js +11 -0
  58. package/engine/rules/wcag/2.1.1-scrollable-region-focusable.js +48 -0
  59. package/engine/rules/wcag/2.2.1-meta-refresh.js +20 -0
  60. package/engine/rules/wcag/2.2.2-pause-stop-hide.js +25 -0
  61. package/engine/rules/wcag/2.4.1-bypass-blocks.js +36 -0
  62. package/engine/rules/wcag/2.4.11-focus-not-obscured.js +80 -0
  63. package/engine/rules/wcag/2.4.2-document-title.js +18 -0
  64. package/engine/rules/wcag/2.4.4-link-name.js +19 -0
  65. package/engine/rules/wcag/2.4.7-focus-visible.js +46 -0
  66. package/engine/rules/wcag/2.5.3-label-in-name.js +88 -0
  67. package/engine/rules/wcag/2.5.5-target-size-enhanced.js +14 -0
  68. package/engine/rules/wcag/2.5.8-target-size.js +159 -0
  69. package/engine/rules/wcag/3.1.1-html-lang.js +31 -0
  70. package/engine/rules/wcag/3.1.2-valid-lang-parts.js +20 -0
  71. package/engine/rules/wcag/3.3.8-auth-field-obstruction.js +37 -0
  72. package/engine/rules/wcag/4.1.2-aria-allowed-attr.js +44 -0
  73. package/engine/rules/wcag/4.1.2-aria-attr-valid.js +50 -0
  74. package/engine/rules/wcag/4.1.2-aria-field-name.js +54 -0
  75. package/engine/rules/wcag/4.1.2-aria-hidden-focus.js +48 -0
  76. package/engine/rules/wcag/4.1.2-aria-valid-refs.js +61 -0
  77. package/engine/rules/wcag/4.1.2-button-name.js +17 -0
  78. package/engine/rules/wcag/4.1.2-dialog-name.js +35 -0
  79. package/engine/rules/wcag/4.1.2-frame-title.js +17 -0
  80. package/engine/rules/wcag/4.1.2-label-for-valid.js +25 -0
  81. package/engine/rules/wcag/4.1.2-nested-interactive.js +30 -0
  82. package/engine/rules/wcag/4.1.2-role-required-aria.js +36 -0
  83. package/engine/rules/wcag/4.1.2-valid-role.js +52 -0
  84. package/engine/wcag22.js +116 -0
  85. package/engine/wcag30-draft.js +149 -0
  86. package/package.json +34 -0
@@ -0,0 +1,50 @@
1
+ // WCAG SC 4.1.2 Name, Role, Value (Level A)
2
+ // ARIA 1.2 attribute names (shared vocabulary in lib/roles.js), and the
3
+ // allowed values for enumerated ones.
4
+ import { KNOWN_ARIA as KNOWN } from '../../lib/roles.js';
5
+
6
+ // Note: "undefined" is a legal token for the tristate/undefinable attributes
7
+ // (aria-expanded, aria-selected, aria-checked, aria-pressed) per ARIA 1.2.
8
+ const ENUMS = {
9
+ 'aria-atomic': ['true', 'false'], 'aria-busy': ['true', 'false'],
10
+ 'aria-disabled': ['true', 'false'], 'aria-expanded': ['true', 'false', 'undefined'],
11
+ 'aria-hidden': ['true', 'false'], 'aria-modal': ['true', 'false'],
12
+ 'aria-multiline': ['true', 'false'], 'aria-multiselectable': ['true', 'false'],
13
+ 'aria-readonly': ['true', 'false'], 'aria-required': ['true', 'false'],
14
+ 'aria-selected': ['true', 'false', 'undefined'],
15
+ 'aria-checked': ['true', 'false', 'mixed', 'undefined'], 'aria-pressed': ['true', 'false', 'mixed', 'undefined'],
16
+ 'aria-live': ['off', 'polite', 'assertive'],
17
+ 'aria-orientation': ['horizontal', 'vertical'],
18
+ 'aria-sort': ['none', 'ascending', 'descending', 'other'],
19
+ 'aria-autocomplete': ['none', 'inline', 'list', 'both'],
20
+ 'aria-invalid': ['true', 'false', 'grammar', 'spelling'],
21
+ 'aria-haspopup': ['true', 'false', 'menu', 'listbox', 'tree', 'grid', 'dialog'],
22
+ 'aria-current': ['true', 'false', 'page', 'step', 'location', 'date', 'time'],
23
+ };
24
+
25
+ export default {
26
+ id: 'aria-attr-valid',
27
+ impact: 'critical',
28
+ tags: ['wcag2a', 'wcag412'],
29
+ help: 'aria attributes must exist and have valid values',
30
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
31
+ selector: '*',
32
+ visibleOnly: false, // applies to hidden elements too; also avoids visibility cost on every node
33
+ evaluate(element) {
34
+ const problems = [];
35
+ for (const { name, value } of element.attributes) {
36
+ if (!name.startsWith('aria-')) continue;
37
+ if (!KNOWN.has(name.slice(5))) {
38
+ problems.push(`${name} is not an ARIA attribute`);
39
+ } else if (ENUMS[name] && !ENUMS[name].includes(value.trim().toLowerCase())) {
40
+ problems.push(`${name}="${value}" — allowed values: ${ENUMS[name].join(', ')}`);
41
+ }
42
+ }
43
+ if (!problems.length) return { status: 'pass' };
44
+ return {
45
+ status: 'fail',
46
+ message: `Invalid ARIA: ${problems.join('; ')}. Assistive technology ignores attributes it doesn't recognise.`,
47
+ fix: 'Correct the attribute name/value against the ARIA specification, or remove it.',
48
+ };
49
+ },
50
+ };
@@ -0,0 +1,54 @@
1
+ // WCAG SC 4.1.2 Name, Role, Value (Level A)
2
+ // ARIA-built form fields (role="textbox" divs etc.) need names just like
3
+ // native inputs — native fields are handled by form-label.
4
+ //
5
+ // Spec note (accname): input-like roles are NOT "name from content" — the
6
+ // text inside a listbox/textbox does not name it, only author-provided
7
+ // naming counts. Toggle roles (checkbox/radio/switch) DO name from content.
8
+ const AUTHOR_ONLY = ['textbox', 'searchbox', 'combobox', 'listbox', 'spinbutton', 'slider'];
9
+ const FROM_CONTENT = ['checkbox', 'radio', 'switch'];
10
+
11
+ function authorName(element) {
12
+ const labelledby = element.getAttribute('aria-labelledby');
13
+ if (labelledby) {
14
+ const text = labelledby
15
+ .split(/\s+/)
16
+ .map((id) => element.getRootNode().getElementById?.(id)?.textContent ?? '')
17
+ .join(' ')
18
+ .trim();
19
+ if (text) return text;
20
+ }
21
+ return element.getAttribute('aria-label')?.trim() || element.getAttribute('title')?.trim() || '';
22
+ }
23
+
24
+ export default {
25
+ id: 'aria-field-name',
26
+ impact: 'serious',
27
+ tags: ['wcag2a', 'wcag412'],
28
+ help: 'ARIA form fields must have an accessible name',
29
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
30
+ selector: [...AUTHOR_ONLY, ...FROM_CONTENT]
31
+ .map((role) => `[role="${role}"]:not(input):not(select):not(textarea)`)
32
+ .join(', '),
33
+ evaluate(element, { accessibleName }) {
34
+ const role = element.getAttribute('role');
35
+ const name = FROM_CONTENT.includes(role) ? accessibleName(element) : authorName(element);
36
+ if (name) return { status: 'pass' };
37
+ // ARIA 1.1 combobox pattern: a non-focusable wrapper div carries the
38
+ // role while the real, focusable input inside carries the label. The
39
+ // wrapper's missing name is spec-untidy but the field users actually
40
+ // reach IS named — not a 4.1.2 failure in practice.
41
+ if (role === 'combobox' && element.tabIndex < 0) {
42
+ const input = element.querySelector('input, [role="textbox"], [role="searchbox"]');
43
+ if (input && (input.labels?.length || input.getAttribute('aria-label') || input.getAttribute('aria-labelledby'))) {
44
+ return { status: 'pass' };
45
+ }
46
+ }
47
+ return {
48
+ status: 'fail',
49
+ message: `This role="${role}" field has no accessible name — screen readers announce the role with no idea what it's for.${
50
+ AUTHOR_ONLY.includes(role) ? ' (Content inside it does not count as a name for this role.)' : ''}`,
51
+ fix: 'Add aria-label="…" or aria-labelledby pointing at its visible label.',
52
+ };
53
+ },
54
+ };
@@ -0,0 +1,48 @@
1
+ // WCAG SC 4.1.2 Name, Role, Value (Level A)
2
+ const FOCUSABLE = 'a[href], area[href], button, input, select, textarea, summary, iframe, '
3
+ + 'audio[controls], video[controls], [contenteditable]:not([contenteditable="false"]), [tabindex]';
4
+
5
+ export default {
6
+ id: 'aria-hidden-focus',
7
+ impact: 'serious',
8
+ tags: ['wcag2a', 'wcag412'],
9
+ help: 'Nothing focusable may sit inside aria-hidden="true"',
10
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
11
+ selector: '[aria-hidden="true"]',
12
+ visibleOnly: false, // these elements are hidden from AT by definition
13
+ evaluate(element, { isRendered }) {
14
+ // Only elements that are actually rendered can receive focus — an
15
+ // aria-hidden menu that is ALSO display:none is fine until it opens.
16
+ // Fully transparent controls are excluded too: nothing is SEEN when
17
+ // focus lands there, so the harm this rule describes (sighted keyboard
18
+ // users interacting with SR-invisible content) doesn't materialise —
19
+ // an invisible tab stop is a focus-order/visible-focus concern instead.
20
+ const focusable = [element, ...element.querySelectorAll(FOCUSABLE)].filter(
21
+ (el) => el.matches?.(FOCUSABLE) && el.getAttribute('tabindex') !== '-1' && !el.disabled
22
+ // `inert` removes focusability outright — aria-hidden + inert is
23
+ // the CORRECT modern pattern for off-canvas content, not a defect.
24
+ && !el.closest('[inert]')
25
+ && isRendered(el) && getComputedStyle(el).opacity !== '0',
26
+ );
27
+ if (!focusable.length) return { status: 'pass' };
28
+ // While a modal dialog is open, the page behind it is DELIBERATELY
29
+ // aria-hidden and its focusability is script-managed (focus traps).
30
+ // A static snapshot can't tell a correct trap from a broken one —
31
+ // that's a human check, not an assertable failure.
32
+ const doc = element.ownerDocument;
33
+ let modal = null;
34
+ try { modal = doc.querySelector('dialog:modal'); } catch { /* older engine */ }
35
+ modal ??= [...doc.querySelectorAll('[aria-modal="true"]')].find(isRendered) ?? null;
36
+ if (modal && !element.contains(modal) && !modal.contains(element)) {
37
+ return {
38
+ status: 'incomplete',
39
+ message: `This aria-hidden element contains ${focusable.length} focusable element(s) while a modal dialog is open — fine if the modal traps keyboard focus, a failure if Tab can reach them. Check the trap by keyboard.`,
40
+ };
41
+ }
42
+ return {
43
+ status: 'fail',
44
+ message: `This aria-hidden element contains ${focusable.length} focusable element(s) — keyboard users can Tab into content that screen readers cannot see.`,
45
+ fix: 'Add tabindex="-1" (or disabled) to focusable elements inside aria-hidden regions, or remove aria-hidden.',
46
+ };
47
+ },
48
+ };
@@ -0,0 +1,61 @@
1
+ // WCAG SC 4.1.2 Name, Role, Value (Level A)
2
+ // aria-activedescendant and aria-owns matter most: a dangling
3
+ // activedescendant breaks composite-widget focus reporting entirely.
4
+ const REF_ATTRIBUTES = [
5
+ 'aria-labelledby', 'aria-describedby', 'aria-controls',
6
+ 'aria-activedescendant', 'aria-owns', 'aria-errormessage', 'aria-details',
7
+ ];
8
+
9
+ export default {
10
+ id: 'aria-valid-refs',
11
+ // Moderate, not critical: with 4.1.1 Parsing retired in WCAG 2.2 these
12
+ // are 4.1.2 name/state defects, and aria-controls in particular has weak
13
+ // assistive-technology support — real, but rarely blocking.
14
+ impact: 'moderate',
15
+ tags: ['wcag2a', 'wcag412'],
16
+ help: 'ARIA id references must point to elements that exist',
17
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
18
+ 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`);
35
+ continue;
36
+ }
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
+ }
41
+ }
42
+ }
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' };
60
+ },
61
+ };
@@ -0,0 +1,17 @@
1
+ // WCAG SC 4.1.2 Name, Role, Value (Level A)
2
+ export default {
3
+ id: 'button-name',
4
+ impact: 'critical',
5
+ tags: ['wcag2a', 'wcag412'],
6
+ help: 'Buttons must have an accessible name',
7
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
8
+ selector: 'button, [role="button"], input[type="button"], input[type="submit"], input[type="reset"]',
9
+ evaluate(element, { accessibleName }) {
10
+ if (accessibleName(element)) return { status: 'pass' };
11
+ return {
12
+ status: 'fail',
13
+ message: 'This button has no accessible name — a screen reader announces just "button".',
14
+ fix: 'Add visible text inside the button, or aria-label="What it does" for icon-only buttons.',
15
+ };
16
+ },
17
+ };
@@ -0,0 +1,35 @@
1
+ // WCAG SC 4.1.2 Name, Role, Value (Level A)
2
+ // ARIA 1.2 marks dialog/alertdialog as name-required roles, and dialogs are
3
+ // NOT name-from-content — the body text is the dialog's content, never its
4
+ // name. An open, unnamed modal announces as just "dialog", leaving
5
+ // screen-reader users with no idea what interrupted them (cookie-consent
6
+ // modals are the canonical offender).
7
+ export default {
8
+ id: 'dialog-name',
9
+ impact: 'serious',
10
+ tags: ['wcag2a', 'wcag412'],
11
+ help: 'Dialogs must have an accessible name',
12
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
13
+ // Default visibility filter: a closed <dialog> is display:none, so only
14
+ // dialogs the user can actually encounter are judged.
15
+ selector: 'dialog, [role="dialog"], [role="alertdialog"]',
16
+ evaluate(element) {
17
+ const labelledby = element.getAttribute('aria-labelledby');
18
+ if (labelledby) {
19
+ const text = labelledby
20
+ .split(/\s+/)
21
+ .map((id) => element.getRootNode().getElementById?.(id)?.textContent ?? '')
22
+ .join(' ')
23
+ .trim();
24
+ if (text) return { status: 'pass' };
25
+ }
26
+ if (element.getAttribute('aria-label')?.trim() || element.getAttribute('title')?.trim()) {
27
+ return { status: 'pass' };
28
+ }
29
+ return {
30
+ status: 'fail',
31
+ message: 'This dialog has no accessible name — screen readers announce just "dialog" with no hint of what it is or why it appeared.',
32
+ fix: 'Point aria-labelledby at the dialog\'s heading, or add aria-label="What this dialog is".',
33
+ };
34
+ },
35
+ };
@@ -0,0 +1,17 @@
1
+ // WCAG SC 4.1.2 Name, Role, Value (Level A)
2
+ export default {
3
+ id: 'frame-title',
4
+ impact: 'serious',
5
+ tags: ['wcag2a', 'wcag412'],
6
+ help: 'Frames must have a title describing their content',
7
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
8
+ selector: 'iframe, frame',
9
+ evaluate(element, { accessibleName }) {
10
+ if (element.getAttribute('title')?.trim() || accessibleName(element)) return { status: 'pass' };
11
+ return {
12
+ status: 'fail',
13
+ message: 'This frame has no title — screen-reader users cannot tell what it contains before entering it.',
14
+ fix: `Add title="…" describing the embedded content, e.g. <iframe title="Newsletter signup" src="${element.getAttribute('src') ?? ''}">.`,
15
+ };
16
+ },
17
+ };
@@ -0,0 +1,25 @@
1
+ // WCAG SC 4.1.2 Name, Role, Value (Level A)
2
+ const LABELABLE = 'input, select, textarea, button, meter, output, progress';
3
+
4
+ export default {
5
+ id: 'label-for-valid',
6
+ impact: 'serious',
7
+ tags: ['wcag2a', 'wcag412'],
8
+ help: 'label[for] must reference a form control that exists',
9
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
10
+ selector: 'label[for]',
11
+ evaluate(element) {
12
+ const id = element.getAttribute('for');
13
+ // id references resolve within the element's own root — a label in a
14
+ // shadow tree can only point at controls in that same tree.
15
+ const target = element.getRootNode().getElementById?.(id);
16
+ if (target?.matches(LABELABLE)) return { status: 'pass' };
17
+ return {
18
+ status: 'fail',
19
+ message: target
20
+ ? `for="${id}" points at a <${target.tagName.toLowerCase()}>, which cannot be labelled — the association is ignored.`
21
+ : `for="${id}" points at nothing — the label is not associated with any field.`,
22
+ fix: 'Point the for attribute at the id of the input/select/textarea it labels.',
23
+ };
24
+ },
25
+ };
@@ -0,0 +1,30 @@
1
+ // WCAG SC 4.1.2 Name, Role, Value (Level A)
2
+ // Deliberately excludes bare [tabindex]: a focusable container (e.g. a
3
+ // scrollable region with tabindex="0") holding links is correct markup.
4
+ const INTERACTIVE =
5
+ 'a[href], button, input, select, textarea, summary, audio[controls], video[controls], ' +
6
+ '[role="button"], [role="link"], [role="checkbox"], [role="radio"], [role="switch"], ' +
7
+ '[role="menuitem"], [role="tab"], [role="option"]';
8
+
9
+ export default {
10
+ id: 'nested-interactive',
11
+ impact: 'serious',
12
+ tags: ['wcag2a', 'wcag412'],
13
+ help: 'A control must not contain another control',
14
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
15
+ selector: INTERACTIVE,
16
+ evaluate(element, { isRendered }) {
17
+ // Only descendants a user can actually reach count: hidden inputs,
18
+ // disabled controls, display:none templates, and tabindex="-1" nodes
19
+ // inside a control are inert markup, not competing tab stops.
20
+ const nested = [...element.querySelectorAll(INTERACTIVE)].find((el) =>
21
+ !el.disabled && el.getAttribute('tabindex') !== '-1'
22
+ && !(el.tagName === 'INPUT' && el.type === 'hidden') && isRendered(el));
23
+ if (!nested) return { status: 'pass' };
24
+ return {
25
+ status: 'fail',
26
+ message: `This ${element.tagName.toLowerCase()} contains another interactive element (<${nested.tagName.toLowerCase()}>) — focus order and announcements become unpredictable.`,
27
+ fix: 'Restructure so interactive elements are siblings, not ancestors of each other.',
28
+ };
29
+ },
30
+ };
@@ -0,0 +1,36 @@
1
+ // WCAG SC 4.1.2 Name, Role, Value (Level A)
2
+ // Roles that are meaningless without a state attribute alongside them.
3
+ const REQUIRED = {
4
+ checkbox: 'aria-checked',
5
+ switch: 'aria-checked',
6
+ radio: 'aria-checked',
7
+ slider: 'aria-valuenow',
8
+ scrollbar: 'aria-valuenow',
9
+ combobox: 'aria-expanded',
10
+ };
11
+
12
+ export default {
13
+ id: 'role-required-aria',
14
+ impact: 'serious',
15
+ tags: ['wcag2a', 'wcag412'],
16
+ help: 'Some roles require a state attribute to work',
17
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
18
+ selector: Object.keys(REQUIRED).map((role) => `[role="${role}"]`).join(', '),
19
+ evaluate(element) {
20
+ const role = element.getAttribute('role');
21
+ const required = REQUIRED[role];
22
+ if (element.hasAttribute(required)) return { status: 'pass' };
23
+ // A redundant explicit role on the native element (input type=checkbox
24
+ // role=checkbox — common framework output) needs no ARIA state: the
25
+ // host language supplies checkedness/value natively.
26
+ const type = element.tagName === 'INPUT' ? element.type : null;
27
+ const NATIVE_STATE = { checkbox: 'checkbox', radio: 'radio', range: 'slider' };
28
+ if (type && NATIVE_STATE[type] === role) return { status: 'pass' };
29
+ if (element.tagName === 'SELECT' && role === 'combobox') return { status: 'pass' };
30
+ return {
31
+ status: 'fail',
32
+ message: `role="${role}" without ${required} — screen readers can't announce the control's state.`,
33
+ fix: `Add ${required} and keep it updated from your script, or use the native HTML element instead.`,
34
+ };
35
+ },
36
+ };
@@ -0,0 +1,52 @@
1
+ // WCAG SC 4.1.2 Name, Role, Value (Level A)
2
+ // ARIA 1.2 roles (concrete, usable roles — abstract roles are invalid in markup).
3
+ const VALID_ROLES = new Set([
4
+ 'alert', 'alertdialog', 'application', 'article', 'banner', 'blockquote', 'button',
5
+ 'caption', 'cell', 'checkbox', 'code', 'columnheader', 'combobox', 'complementary',
6
+ 'contentinfo', 'definition', 'deletion', 'dialog', 'directory', 'document', 'emphasis',
7
+ 'feed', 'figure', 'form', 'generic', 'grid', 'gridcell', 'group', 'heading', 'img',
8
+ 'insertion', 'link', 'list', 'listbox', 'listitem', 'log', 'main', 'marquee', 'math',
9
+ 'menu', 'menubar', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'meter', 'navigation',
10
+ 'none', 'note', 'option', 'paragraph', 'presentation', 'progressbar', 'radio', 'radiogroup',
11
+ 'region', 'row', 'rowgroup', 'rowheader', 'scrollbar', 'search', 'searchbox', 'separator',
12
+ 'slider', 'spinbutton', 'status', 'strong', 'subscript', 'superscript', 'switch', 'tab',
13
+ 'table', 'tablist', 'tabpanel', 'term', 'textbox', 'time', 'timer', 'toolbar', 'tooltip',
14
+ 'tree', 'treegrid', 'treeitem',
15
+ ]);
16
+
17
+ export default {
18
+ id: 'valid-role',
19
+ impact: 'critical',
20
+ tags: ['wcag2a', 'wcag412'],
21
+ help: 'role attributes must use valid ARIA roles',
22
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
23
+ selector: '[role]',
24
+ evaluate(element) {
25
+ // A role attribute may list fallbacks; the first valid token wins.
26
+ // doc-* (DPub-ARIA) and graphics-* (Graphics-ARIA) modules are valid too.
27
+ const tokens = element.getAttribute('role').trim().split(/\s+/).filter(Boolean);
28
+ const isValid = (token) => {
29
+ const t = token.toLowerCase();
30
+ return VALID_ROLES.has(t) || t.startsWith('doc-') || t.startsWith('graphics-');
31
+ };
32
+ if (tokens.some(isValid)) return { status: 'pass' };
33
+ // An invalid role is ignored and the element falls back to its NATIVE
34
+ // role. When that fallback is generic anyway (plain div/span, not
35
+ // focusable, no ARIA states riding on the bogus role), nothing an AT
36
+ // user receives is wrong — role="text" on presentational spans is the
37
+ // canonical harmless case. The failure worth reporting is an invalid
38
+ // role MASKING real semantics or decorating an interactive element.
39
+ const tag = element.tagName.toLowerCase();
40
+ const genericFallback = tag === 'div' || tag === 'span';
41
+ const focusable = element.tabIndex >= 0
42
+ || element.matches('a[href], button, input, select, textarea, summary');
43
+ const hasAriaProps = [...element.attributes].some(
44
+ (attr) => attr.name.startsWith('aria-') && attr.name !== 'aria-hidden');
45
+ if (genericFallback && !focusable && !hasAriaProps) return { status: 'pass' };
46
+ return {
47
+ status: 'fail',
48
+ message: `role="${element.getAttribute('role')}" is not a valid ARIA role, so assistive technology ignores it${genericFallback ? '' : ` and falls back to the element’s native <${tag}> semantics`}.`,
49
+ fix: 'Use a valid role from the ARIA specification, or remove the attribute to keep the element’s native role.',
50
+ };
51
+ },
52
+ };
@@ -0,0 +1,116 @@
1
+ // The complete WCAG 2.2 success-criteria catalog — all 86 criteria (2.2
2
+ // removed 4.1.1 Parsing). This is the engine's source of truth for what
3
+ // exists in the spec, independent of what we can automate.
4
+ //
5
+ // `automation` is an honest assessment:
6
+ // 'auto' — rules can meaningfully test this
7
+ // 'partial' — rules catch some failures; humans must still review
8
+ // 'manual' — requires human judgment (most of the spec!)
9
+ // The coverage report (scripts/wcag-coverage.js → src/engine/COVERAGE.md) joins
10
+ // this catalog with the rule registry to show real coverage.
11
+
12
+ const sc = (num, name, level, automation, since = '2.0') => ({
13
+ num,
14
+ name,
15
+ level,
16
+ automation,
17
+ since, // WCAG version that introduced this criterion
18
+ principle: { 1: 'Perceivable', 2: 'Operable', 3: 'Understandable', 4: 'Robust' }[num[0]],
19
+ url: `https://www.w3.org/WAI/WCAG22/Understanding/${name
20
+ .toLowerCase()
21
+ .replace(/[(),]/g, '')
22
+ .replace(/\s+/g, '-')}.html`,
23
+ });
24
+
25
+ export default [
26
+ // 1. Perceivable
27
+ sc('1.1.1', 'Non-text Content', 'A', 'partial'),
28
+ sc('1.2.1', 'Audio-only and Video-only (Prerecorded)', 'A', 'manual'),
29
+ sc('1.2.2', 'Captions (Prerecorded)', 'A', 'partial'),
30
+ sc('1.2.3', 'Audio Description or Media Alternative (Prerecorded)', 'A', 'manual'),
31
+ sc('1.2.4', 'Captions (Live)', 'AA', 'manual'),
32
+ sc('1.2.5', 'Audio Description (Prerecorded)', 'AA', 'manual'),
33
+ sc('1.2.6', 'Sign Language (Prerecorded)', 'AAA', 'manual'),
34
+ sc('1.2.7', 'Extended Audio Description (Prerecorded)', 'AAA', 'manual'),
35
+ sc('1.2.8', 'Media Alternative (Prerecorded)', 'AAA', 'manual'),
36
+ sc('1.2.9', 'Audio-only (Live)', 'AAA', 'manual'),
37
+ sc('1.3.1', 'Info and Relationships', 'A', 'partial'),
38
+ sc('1.3.2', 'Meaningful Sequence', 'A', 'manual'),
39
+ sc('1.3.3', 'Sensory Characteristics', 'A', 'manual'),
40
+ sc('1.3.4', 'Orientation', 'AA', 'manual', '2.1'),
41
+ sc('1.3.5', 'Identify Input Purpose', 'AA', 'partial', '2.1'), // partial: wrong tokens are provable; MISSING autocomplete on identity fields needs judgment
42
+ sc('1.3.6', 'Identify Purpose', 'AAA', 'manual', '2.1'),
43
+ sc('1.4.1', 'Use of Color', 'A', 'partial'), // link-in-text-block automates the link case
44
+ sc('1.4.2', 'Audio Control', 'A', 'partial'), // partial: declarative autoplay is provable; JS-initiated audio is not
45
+ sc('1.4.3', 'Contrast (Minimum)', 'AA', 'partial'),
46
+ sc('1.4.4', 'Resize Text', 'AA', 'partial'),
47
+ sc('1.4.5', 'Images of Text', 'AA', 'manual'),
48
+ sc('1.4.6', 'Contrast (Enhanced)', 'AAA', 'partial'),
49
+ sc('1.4.7', 'Low or No Background Audio', 'AAA', 'manual'),
50
+ sc('1.4.8', 'Visual Presentation', 'AAA', 'manual'),
51
+ sc('1.4.9', 'Images of Text (No Exception)', 'AAA', 'manual'),
52
+ sc('1.4.10', 'Reflow', 'AA', 'partial', '2.1'), // partial: reflow (current-viewport overflow heuristic); full test is at 320px
53
+ sc('1.4.11', 'Non-text Contrast', 'AA', 'partial', '2.1'), // partial: non-text-contrast covers field boundaries; icons/focus indicators need eyes
54
+ sc('1.4.12', 'Text Spacing', 'AA', 'partial', '2.1'), // partial: text-spacing probes the SC's own override test for clipping
55
+ sc('1.4.13', 'Content on Hover or Focus', 'AA', 'manual', '2.1'),
56
+ // 2. Operable
57
+ sc('2.1.1', 'Keyboard', 'A', 'partial'),
58
+ sc('2.1.2', 'No Keyboard Trap', 'A', 'manual'),
59
+ sc('2.1.3', 'Keyboard (No Exception)', 'AAA', 'partial'), // 2.1.1's rules apply; no-exception scope needs humans
60
+ sc('2.1.4', 'Character Key Shortcuts', 'A', 'manual', '2.1'),
61
+ sc('2.2.1', 'Timing Adjustable', 'A', 'partial'), // meta-refresh automates the redirect/refresh case
62
+ sc('2.2.2', 'Pause, Stop, Hide', 'A', 'partial'),
63
+ sc('2.2.3', 'No Timing', 'AAA', 'manual'),
64
+ sc('2.2.4', 'Interruptions', 'AAA', 'manual'),
65
+ sc('2.2.5', 'Re-authenticating', 'AAA', 'manual'),
66
+ sc('2.2.6', 'Timeouts', 'AAA', 'manual', '2.1'),
67
+ sc('2.3.1', 'Three Flashes or Below Threshold', 'A', 'manual'),
68
+ sc('2.3.2', 'Three Flashes', 'AAA', 'manual'),
69
+ sc('2.3.3', 'Animation from Interactions', 'AAA', 'manual', '2.1'),
70
+ sc('2.4.1', 'Bypass Blocks', 'A', 'partial'), // partial: "repeated across pages" can't be established from one page
71
+ sc('2.4.2', 'Page Titled', 'A', 'auto'),
72
+ sc('2.4.3', 'Focus Order', 'A', 'manual'), // no rule automates this yet; order judgment needs humans
73
+ sc('2.4.4', 'Link Purpose (In Context)', 'A', 'partial'),
74
+ sc('2.4.5', 'Multiple Ways', 'AA', 'manual'),
75
+ sc('2.4.6', 'Headings and Labels', 'AA', 'manual'),
76
+ sc('2.4.7', 'Focus Visible', 'AA', 'partial'), // partial: focus-visible flags outline suppression; the indicator itself needs eyes
77
+ sc('2.4.8', 'Location', 'AAA', 'manual'),
78
+ sc('2.4.9', 'Link Purpose (Link Only)', 'AAA', 'manual'),
79
+ sc('2.4.10', 'Section Headings', 'AAA', 'manual'),
80
+ sc('2.4.11', 'Focus Not Obscured (Minimum)', 'AA', 'partial', '2.2'), // partial: focus-not-obscured catches fully-covered targets at rest
81
+ sc('2.4.12', 'Focus Not Obscured (Enhanced)', 'AAA', 'manual', '2.2'),
82
+ sc('2.4.13', 'Focus Appearance', 'AAA', 'manual', '2.2'),
83
+ sc('2.5.1', 'Pointer Gestures', 'A', 'manual', '2.1'),
84
+ sc('2.5.2', 'Pointer Cancellation', 'A', 'manual', '2.1'),
85
+ sc('2.5.3', 'Label in Name', 'A', 'manual', '2.1'), // manual WHILE the label-in-name rule is parked (see rules/index.js) — 'auto' with no active rule made this SC vanish from both the results AND the manual checklist
86
+ sc('2.5.4', 'Motion Actuation', 'A', 'manual', '2.1'),
87
+ sc('2.5.5', 'Target Size (Enhanced)', 'AAA', 'partial', '2.1'),
88
+ sc('2.5.6', 'Concurrent Input Mechanisms', 'AAA', 'manual', '2.1'),
89
+ sc('2.5.7', 'Dragging Movements', 'AA', 'manual', '2.2'),
90
+ sc('2.5.8', 'Target Size (Minimum)', 'AA', 'partial', '2.2'), // partial: size/spacing are computable; the equivalent-control and essential exceptions need judgment
91
+ // 3. Understandable
92
+ sc('3.1.1', 'Language of Page', 'A', 'auto'),
93
+ sc('3.1.2', 'Language of Parts', 'AA', 'partial'), // partial: invalid lang values are provable; UNMARKED foreign passages need language identification
94
+ sc('3.1.3', 'Unusual Words', 'AAA', 'manual'),
95
+ sc('3.1.4', 'Abbreviations', 'AAA', 'manual'),
96
+ sc('3.1.5', 'Reading Level', 'AAA', 'manual'),
97
+ sc('3.1.6', 'Pronunciation', 'AAA', 'manual'),
98
+ sc('3.2.1', 'On Focus', 'A', 'manual'),
99
+ sc('3.2.2', 'On Input', 'A', 'manual'),
100
+ sc('3.2.3', 'Consistent Navigation', 'AA', 'manual'),
101
+ sc('3.2.4', 'Consistent Identification', 'AA', 'manual'),
102
+ sc('3.2.5', 'Change on Request', 'AAA', 'manual'),
103
+ sc('3.2.6', 'Consistent Help', 'A', 'manual', '2.2'),
104
+ sc('3.3.1', 'Error Identification', 'A', 'manual'),
105
+ sc('3.3.2', 'Labels or Instructions', 'A', 'partial'),
106
+ sc('3.3.3', 'Error Suggestion', 'AA', 'manual'),
107
+ sc('3.3.4', 'Error Prevention (Legal, Financial, Data)', 'AA', 'manual'),
108
+ sc('3.3.5', 'Help', 'AAA', 'manual'),
109
+ sc('3.3.6', 'Error Prevention (All)', 'AAA', 'manual'),
110
+ sc('3.3.7', 'Redundant Entry', 'A', 'manual', '2.2'),
111
+ sc('3.3.8', 'Accessible Authentication (Minimum)', 'AA', 'partial', '2.2'), // partial: auth-field-obstruction catches paste blocking; alternatives need judgment
112
+ sc('3.3.9', 'Accessible Authentication (Enhanced)', 'AAA', 'manual', '2.2'),
113
+ // 4. Robust (4.1.1 Parsing was removed in WCAG 2.2)
114
+ sc('4.1.2', 'Name, Role, Value', 'A', 'partial'),
115
+ sc('4.1.3', 'Status Messages', 'AA', 'manual', '2.1'),
116
+ ];