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,66 @@
1
+ // WCAG SC 1.3.1 Info and Relationships (Level A)
2
+ // Composite roles are meaningless without their required child roles —
3
+ // role="list" with no listitems announces as an empty list.
4
+ const REQUIRED_CHILDREN = {
5
+ list: ['listitem'],
6
+ listbox: ['option', 'group'],
7
+ menu: ['menuitem', 'menuitemcheckbox', 'menuitemradio', 'group'],
8
+ menubar: ['menuitem', 'menuitemcheckbox', 'menuitemradio', 'group'],
9
+ radiogroup: ['radio'],
10
+ tablist: ['tab'],
11
+ tree: ['treeitem', 'group'],
12
+ table: ['row', 'rowgroup'],
13
+ grid: ['row', 'rowgroup'],
14
+ treegrid: ['row', 'rowgroup'],
15
+ rowgroup: ['row'],
16
+ };
17
+
18
+ const IMPLICIT = { li: 'listitem', option: 'option', tr: 'row', tbody: 'rowgroup', thead: 'rowgroup', tfoot: 'rowgroup' };
19
+
20
+ const roleOf = (element) =>
21
+ element.getAttribute('role')?.trim().split(/\s+/)[0]?.toLowerCase() ??
22
+ IMPLICIT[element.tagName.toLowerCase()];
23
+
24
+ /**
25
+ * The children a composite role OWNS in the accessibility tree — not the
26
+ * DOM children. role="presentation"/"none" removes an element but leaves
27
+ * its children in place, and role-less div/span wrappers are generic nodes
28
+ * the required-ownership relation passes straight through (tabs inside a
29
+ * styling wrapper inside a tablist are still the tablist's tabs).
30
+ */
31
+ function ownedChildren(element) {
32
+ const owned = [];
33
+ for (const child of element.children) {
34
+ if (child.matches('script, style, template')) continue;
35
+ const role = child.getAttribute('role')?.trim().split(/\s+/)[0]?.toLowerCase();
36
+ const passThrough = role === 'presentation' || role === 'none'
37
+ || (!role && (child.tagName === 'DIV' || child.tagName === 'SPAN'));
38
+ if (passThrough) owned.push(...ownedChildren(child));
39
+ else owned.push(child);
40
+ }
41
+ return owned;
42
+ }
43
+
44
+ export default {
45
+ id: 'aria-required-children',
46
+ impact: 'critical',
47
+ tags: ['wcag2a', 'wcag131'],
48
+ help: 'Composite ARIA roles must contain their required children',
49
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html',
50
+ selector: Object.keys(REQUIRED_CHILDREN).map((role) => `[role="${role}"]`).join(', '),
51
+ evaluate(element) {
52
+ if (element.getAttribute('aria-busy') === 'true') return { status: 'pass' }; // still loading
53
+ const role = element.getAttribute('role').trim().split(/\s+/)[0].toLowerCase();
54
+ const required = REQUIRED_CHILDREN[role];
55
+ const children = [...element.children].filter((c) => !c.matches('script, style, template'));
56
+ // A completely empty container is a lazy-load placeholder more often
57
+ // than a defect — it announces as an empty list, which is accurate.
58
+ if (!children.length) return { status: 'pass' };
59
+ if (ownedChildren(element).some((child) => required.includes(roleOf(child)))) return { status: 'pass' };
60
+ return {
61
+ status: 'fail',
62
+ message: `role="${role}" contains no ${required.join('/')} children (not even through wrapper elements) — screen readers announce a broken, empty ${role}.`,
63
+ fix: `Give the item elements role="${required[0]}", or remove role="${role}" from the container.`,
64
+ };
65
+ },
66
+ };
@@ -0,0 +1,49 @@
1
+ // WCAG SC 1.3.1 Info and Relationships (Level A)
2
+ // Child roles that only make sense inside a specific container role.
3
+ // Explicit roles only — native elements (li outside ul) are covered by
4
+ // listitem-parent/dlitem-parent.
5
+ const REQUIRED_PARENT = {
6
+ listitem: ['list'],
7
+ option: ['listbox'],
8
+ menuitem: ['menu', 'menubar'],
9
+ menuitemcheckbox: ['menu', 'menubar'],
10
+ menuitemradio: ['menu', 'menubar'],
11
+ tab: ['tablist'],
12
+ treeitem: ['tree'],
13
+ row: ['table', 'grid', 'treegrid', 'rowgroup'],
14
+ rowgroup: ['table', 'grid', 'treegrid'],
15
+ cell: ['row'],
16
+ gridcell: ['row'],
17
+ };
18
+
19
+ const IMPLICIT_CONTAINER = { ul: 'list', ol: 'list', menu: 'list', table: 'table', tbody: 'rowgroup', thead: 'rowgroup', tfoot: 'rowgroup', tr: 'row' };
20
+
21
+ export default {
22
+ id: 'aria-required-parent',
23
+ impact: 'critical',
24
+ tags: ['wcag2a', 'wcag131'],
25
+ help: 'ARIA child roles must be inside their required container role',
26
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html',
27
+ selector: Object.keys(REQUIRED_PARENT).map((role) => `[role="${role}"]`).join(', '),
28
+ evaluate(element) {
29
+ const role = element.getAttribute('role').trim().split(/\s+/)[0].toLowerCase();
30
+ const containers = REQUIRED_PARENT[role];
31
+ for (let parent = element.parentElement; parent; parent = parent.parentElement) {
32
+ const parentRole =
33
+ parent.getAttribute('role')?.trim().split(/\s+/)[0]?.toLowerCase() ??
34
+ IMPLICIT_CONTAINER[parent.tagName.toLowerCase()];
35
+ if (parentRole === 'group') continue; // group is transparent in these structures
36
+ // presentation/none removes the wrapper from the tree but leaves its
37
+ // children owned by the next real ancestor — li[role=presentation]
38
+ // between a tablist and its tabs is the canonical pattern.
39
+ if (parentRole === 'presentation' || parentRole === 'none') continue;
40
+ if (containers.includes(parentRole)) return { status: 'pass' };
41
+ if (parentRole) break; // a different role interrupts the required structure
42
+ }
43
+ return {
44
+ status: 'fail',
45
+ message: `role="${role}" is not inside a ${containers.join('/')} — assistive technology loses the structure entirely.`,
46
+ fix: `Wrap it in an element with role="${containers[0]}", or fix the intervening roles.`,
47
+ };
48
+ },
49
+ };
@@ -0,0 +1,21 @@
1
+ // WCAG SC 1.3.1 Info and Relationships (Level A)
2
+ const ALLOWED = new Set(['DT', 'DD', 'DIV', 'SCRIPT', 'TEMPLATE']);
3
+
4
+ export default {
5
+ id: 'definition-list',
6
+ impact: 'serious',
7
+ tags: ['wcag2a', 'wcag131'],
8
+ help: '<dl> must be structured as term/description pairs',
9
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html',
10
+ selector: 'dl:not([role])', // a role attribute replaces the dl semantics
11
+ evaluate(element) {
12
+ const invalid = [...element.children].filter((child) => !ALLOWED.has(child.tagName));
13
+ if (!invalid.length) return { status: 'pass' };
14
+ const tags = [...new Set(invalid.map((child) => `<${child.tagName.toLowerCase()}>`))].join(', ');
15
+ return {
16
+ status: 'fail',
17
+ message: `This <dl> contains ${tags} directly — only <dt>, <dd> (optionally grouped in <div>) are allowed, otherwise the term/description pairing breaks.`,
18
+ fix: 'Restructure the list into <dt>/<dd> pairs, or use a different element.',
19
+ };
20
+ },
21
+ };
@@ -0,0 +1,17 @@
1
+ // WCAG SC 1.3.1 Info and Relationships (Level A)
2
+ export default {
3
+ id: 'dlitem-parent',
4
+ impact: 'serious',
5
+ tags: ['wcag2a', 'wcag131'],
6
+ help: '<dt> and <dd> must be inside a <dl>',
7
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html',
8
+ selector: 'dt:not([role]), dd:not([role])', // role overrides the dt/dd semantics
9
+ evaluate(element) {
10
+ if (element.closest('dl')) return { status: 'pass' };
11
+ return {
12
+ status: 'fail',
13
+ message: `<${element.tagName.toLowerCase()}> outside a <dl> has no term/description semantics.`,
14
+ fix: 'Wrap the terms and descriptions in a <dl>, or use different elements.',
15
+ };
16
+ },
17
+ };
@@ -0,0 +1,81 @@
1
+ // WCAG SC 1.3.1 Info and Relationships (Level A) · SC 3.3.2 Labels or
2
+ // Instructions (Level A) · SC 4.1.2 Name, Role, Value (Level A)
3
+ // 3.3.2 mapping: a field with no label at all, or only a vanishing
4
+ // placeholder, is failure F82 — the classic 3.3.2 pattern.
5
+ export default {
6
+ id: 'form-label',
7
+ impact: 'critical',
8
+ tags: ['wcag2a', 'wcag131', 'wcag332', 'wcag412'],
9
+ help: 'Every form field needs a visible label',
10
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html',
11
+ selector:
12
+ 'input:not([type="hidden"]):not([type="button"]):not([type="submit"]):not([type="reset"]):not([type="image"]), select, textarea',
13
+ evaluate(element) {
14
+ // Only perceivable labels count: a display:none / aria-hidden label
15
+ // technically feeds the accessible name, but nobody can see it and
16
+ // that defeats the point of a label.
17
+ const visibleLabels = [...(element.labels ?? [])].filter((label) => {
18
+ if (label.closest('[aria-hidden="true"]')) return false;
19
+ const style = getComputedStyle(label);
20
+ return style.display !== 'none' && style.visibility !== 'hidden';
21
+ });
22
+ // Label content includes aria-labels on things inside it (icon-only
23
+ // labels like <label><svg aria-label="Dark mode"/></label> still name)
24
+ // and image alts (<label><img alt="…"></label> is a visible, named
25
+ // label — alt text feeds name-from-contents per accname).
26
+ const labelsText = visibleLabels
27
+ .map((l) =>
28
+ `${l.textContent} ${[...l.querySelectorAll('[aria-label], img[alt], area[alt]')]
29
+ .map((e) => e.getAttribute('aria-label') ?? e.getAttribute('alt')).join(' ')}`)
30
+ .join(' ')
31
+ .trim();
32
+ const ariaLabel = element.getAttribute('aria-label')?.trim();
33
+ const labelledby = (element.getAttribute('aria-labelledby') ?? '')
34
+ .split(/\s+/)
35
+ .filter(Boolean)
36
+ .map((id) => element.getRootNode().getElementById?.(id)?.textContent ?? '')
37
+ .join(' ')
38
+ .trim();
39
+ if (labelsText || ariaLabel || labelledby) return { status: 'pass' };
40
+
41
+ const id = element.id ? ` (it already has id="${element.id}")` : ' (give it an id first)';
42
+
43
+ // A title-only name is published sufficient technique H65 — conformant,
44
+ // but invisible until hover and ignored by some tools, so it's flagged
45
+ // for eyes rather than failed.
46
+ if (element.getAttribute('title') && !element.getAttribute('placeholder')) {
47
+ return {
48
+ status: 'incomplete',
49
+ message: 'This field is named only by its title attribute (technique H65) — conformant, but the label is invisible until hover and some tools skip it. Check a visible label isn’t needed here.',
50
+ };
51
+ }
52
+ // A placeholder-only "label" vanishes as soon as the user types (F82
53
+ // territory) — that one stays a failure.
54
+ if (element.getAttribute('placeholder')) {
55
+ return {
56
+ status: 'fail',
57
+ message: 'This field relies on its placeholder as the only label — the name vanishes as soon as the user types.',
58
+ fix: `Add <label for="…">Field name</label> pointing at this field${id}; keep the placeholder as an example value only.`,
59
+ };
60
+ }
61
+ if (element.labels?.length && !visibleLabels.length) {
62
+ return {
63
+ status: 'fail',
64
+ message: 'This field\'s only <label> is hidden (display:none or aria-hidden) — no one can see or rely on it.',
65
+ fix: 'Make the label visible, or use a visually-hidden-but-rendered technique (clip/sr-only) if it must not show.',
66
+ };
67
+ }
68
+ if (element.labels?.length) {
69
+ return {
70
+ status: 'fail',
71
+ message: 'This field has a <label>, but the label is empty — it announces nothing.',
72
+ fix: 'Put visible text inside the label.',
73
+ };
74
+ }
75
+ return {
76
+ status: 'fail',
77
+ message: 'This form field has no label — users cannot tell what to enter.',
78
+ fix: `Add <label for="…">Field name</label> pointing at this field${id}.`,
79
+ };
80
+ },
81
+ };
@@ -0,0 +1,59 @@
1
+ // WCAG SC 1.3.1 Info and Relationships (Level A)
2
+ const NEVER_RENDERED = new Set(['SCRIPT', 'TEMPLATE']);
3
+ const ITEM_ROLES = ['listitem', 'presentation', 'none'];
4
+
5
+ export default {
6
+ id: 'list-structure',
7
+ impact: 'serious',
8
+ tags: ['wcag2a', 'wcag131'],
9
+ help: 'Lists must only contain list items',
10
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html',
11
+ selector: 'ul, ol',
12
+ evaluate(element) {
13
+ // A role attribute replaces the HTML list semantics — a <ul role="tablist">
14
+ // is judged by ARIA structure rules, not HTML list rules.
15
+ if (element.hasAttribute('role') && element.getAttribute('role') !== 'list') return { status: 'pass' };
16
+ // Flat-tree children: a <slot> renders as its assigned elements (or its
17
+ // fallback content), so a shadow <ul><slot></slot></ul> receiving <li>s
18
+ // is a perfectly valid list.
19
+ const effectiveChildren = (parent) => [...parent.children].flatMap((child) =>
20
+ child.tagName === 'SLOT'
21
+ ? (child.assignedElements?.().length ? child.assignedElements() : [...child.children])
22
+ : [child]);
23
+ // Role-less div/span wrappers are generic nodes the accessibility tree
24
+ // passes list ownership through — a <ul><div><li>…</li></div></ul>
25
+ // (framework grouping wrappers) still announces size and structure
26
+ // correctly, so it's judged by what the wrapper holds, recursively.
27
+ // Only PURE wrappers qualify: direct text of its own means the element
28
+ // is content, not plumbing — that text sits outside any list item and
29
+ // genuinely corrupts the list.
30
+ const isValidChild = (child) => {
31
+ if (NEVER_RENDERED.has(child.tagName)) return true;
32
+ // An explicit role REPLACES an <li>'s listitem semantics: a carousel
33
+ // built as <ul> with <li role="group"> slides no longer owns any
34
+ // listitems, so the list announces broken/empty. Only a role-less
35
+ // <li> (or an explicit listitem/presentation/none anywhere) keeps
36
+ // the structure intact.
37
+ const role = child.getAttribute('role') ?? '';
38
+ if (child.tagName === 'LI') return !role || ITEM_ROLES.includes(role);
39
+ if (ITEM_ROLES.includes(role)) return true;
40
+ const genericWrapper = !child.hasAttribute('role')
41
+ && (child.tagName === 'DIV' || child.tagName === 'SPAN');
42
+ if (!genericWrapper) return false;
43
+ const hasStrayText = [...child.childNodes].some(
44
+ (node) => node.nodeType === Node.TEXT_NODE && node.textContent.trim());
45
+ return !hasStrayText && effectiveChildren(child).every(isValidChild);
46
+ };
47
+ const invalid = effectiveChildren(element).filter((child) => !isValidChild(child));
48
+ if (!invalid.length) return { status: 'pass' };
49
+ const tags = [...new Set(invalid.map((child) => {
50
+ const role = child.getAttribute('role');
51
+ return `<${child.tagName.toLowerCase()}${role ? ` role="${role}"` : ''}>`;
52
+ }))].join(', ');
53
+ return {
54
+ status: 'fail',
55
+ message: `This list has ${invalid.length} direct child(ren) without listitem semantics (${tags}) — screen readers misreport the list's size and structure.`,
56
+ fix: 'Use plain <li> children (an explicit role like role="group" replaces the listitem role), or move non-item elements outside the list.',
57
+ };
58
+ },
59
+ };
@@ -0,0 +1,33 @@
1
+ // WCAG SC 1.3.1 Info and Relationships (Level A)
2
+ export default {
3
+ id: 'listitem-parent',
4
+ impact: 'serious',
5
+ tags: ['wcag2a', 'wcag131'],
6
+ help: '<li> must be inside a list',
7
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html',
8
+ selector: 'li:not([role])', // a role attribute replaces the listitem semantics
9
+ evaluate(element) {
10
+ // Judge the FLAT-tree parent — what assistive technology actually sees:
11
+ // a slotted <li> belongs to the <ul> around its slot, and an <li> at the
12
+ // top of a shadow tree belongs to its host element.
13
+ let parent = element.assignedSlot?.parentElement ?? element.parentElement ?? element.getRootNode()?.host;
14
+ while (parent?.tagName === 'SLOT') parent = parent.assignedSlot?.parentElement ?? parent.parentElement;
15
+ // List ownership passes through presentation/none wrappers and
16
+ // role-less div/span grouping wrappers (generic nodes) — an <li> in a
17
+ // framework's wrapping div inside a <ul> is still that list's item.
18
+ while (parent && (
19
+ ['presentation', 'none'].includes(parent.getAttribute('role') ?? '')
20
+ || (!parent.hasAttribute('role') && (parent.tagName === 'DIV' || parent.tagName === 'SPAN'))
21
+ )) {
22
+ parent = parent.assignedSlot?.parentElement ?? parent.parentElement ?? parent.getRootNode()?.host;
23
+ }
24
+ if (parent && (parent.matches('ul, ol, menu') || parent.getAttribute('role') === 'list')) {
25
+ return { status: 'pass' };
26
+ }
27
+ return {
28
+ status: 'fail',
29
+ message: `This <li> sits inside <${parent?.tagName.toLowerCase() ?? 'nothing'}> — outside a list, screen readers lose the item's list context entirely.`,
30
+ fix: 'Wrap it in a <ul> or <ol>, or change it to a <div>/<p> if it isn’t really a list item.',
31
+ };
32
+ },
33
+ };
@@ -0,0 +1,25 @@
1
+ // WCAG SC 1.3.1 Info and Relationships (Level A)
2
+ export default {
3
+ id: 'p-as-heading',
4
+ impact: 'serious',
5
+ tags: ['wcag2a', 'wcag131'],
6
+ help: 'Bold paragraphs should not stand in for headings',
7
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html',
8
+ selector: 'p',
9
+ evaluate(element) {
10
+ const text = element.textContent.trim();
11
+ const only = element.children.length === 1 && element.children[0];
12
+ const looksLikeHeading =
13
+ text && text.length < 60 &&
14
+ only && only.matches('b, strong') && only.textContent.trim() === text &&
15
+ element.nextElementSibling?.matches('p');
16
+ if (!looksLikeHeading) return { status: 'pass' };
17
+ // A heuristic, not proof — bold lead-ins are a legitimate style, so this
18
+ // asks for review instead of asserting a violation.
19
+ return {
20
+ status: 'incomplete',
21
+ message: `"${text}" is a short bold paragraph followed by text — if it's acting as a heading, it's invisible to heading navigation. Verify.`,
22
+ fix: 'If it is a heading, use a real heading element (<h2>…</h2>) at the appropriate level.',
23
+ };
24
+ },
25
+ };
@@ -0,0 +1,61 @@
1
+ // WCAG SC 1.3.1 Info and Relationships (Level A)
2
+ // WCAG 1.3.1: data tables must expose their header/data relationships in
3
+ // markup. Two failure modes, reported per TABLE (one fix point), not per
4
+ // cell:
5
+ // - a clear data table with no header cells at all (no th, scope, headers)
6
+ // - headers="" attributes referencing ids that aren't cells of this table
7
+ // Layout tables are skipped via a conservative heuristic (role=presentation,
8
+ // or too small/text-sparse to be a data grid) — a missed layout table is
9
+ // better than false positives on real pages.
10
+ export default {
11
+ id: 'table-headers',
12
+ impact: 'serious',
13
+ tags: ['wcag2a', 'wcag131'],
14
+ help: 'Data tables must have properly associated header cells',
15
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html',
16
+ selector: 'table',
17
+ evaluate(element) {
18
+ const role = element.getAttribute('role');
19
+ if (role && !['table', 'grid'].includes(role)) return { status: 'pass' }; // presentation/none/other widget
20
+ const rows = [...element.rows];
21
+ const cells = rows.flatMap((row) => [...row.cells]);
22
+ // Data-grid heuristic: at least a 2×2 grid with mostly-text cells.
23
+ const isGrid = rows.length >= 2 && Math.max(0, ...rows.map((row) => row.cells.length)) >= 2;
24
+ const textCells = cells.filter((cell) => cell.textContent.trim()).length;
25
+ if (!isGrid || textCells < 4) return { status: 'pass' }; // likely layout or trivial
26
+ // Nested tables are the signature of legacy LAYOUT markup, not data —
27
+ // asserting "missing headers" there flags email-style scaffolding. A
28
+ // human can tell in a glance; the machine can't.
29
+ if (!element.querySelector('th') && element.querySelector('table')) {
30
+ return {
31
+ status: 'incomplete',
32
+ message: 'This table has no header cells but contains nested tables — a layout-table signature. If it really presents data, mark its header cells with <th>; if it is layout scaffolding, add role="presentation".',
33
+ };
34
+ }
35
+
36
+ const badRefs = cells.filter((cell) => {
37
+ const headers = cell.getAttribute('headers');
38
+ return headers && headers.trim().split(/\s+/).filter(Boolean).some((id) => {
39
+ const target = element.getRootNode().getElementById?.(id);
40
+ return !target || !element.contains(target) || !/^T[HD]$/.test(target.tagName);
41
+ });
42
+ });
43
+ if (badRefs.length) {
44
+ return {
45
+ status: 'fail',
46
+ message: `${badRefs.length} cell(s) have headers="…" referencing ids that are not header cells of this table — screen readers cannot relate the data to its headers.`,
47
+ fix: 'Make each headers attribute list the id(s) of <th> cells in the same table.',
48
+ };
49
+ }
50
+
51
+ const hasHeaders = element.querySelector('th, [scope], td[headers]');
52
+ if (!hasHeaders) {
53
+ return {
54
+ status: 'fail',
55
+ message: 'This looks like a data table but has no header cells — screen reader users get the data with no way to tell what each row/column means.',
56
+ fix: 'Mark header cells with <th> (add scope="col" or scope="row" when the table has both).',
57
+ };
58
+ }
59
+ return { status: 'pass' };
60
+ },
61
+ };
@@ -0,0 +1,38 @@
1
+ // WCAG SC 1.3.5 Identify Input Purpose (Level AA)
2
+ // WCAG 1.3.5: autocomplete tokens from the HTML spec's input-purposes list.
3
+ const TOKENS = new Set([
4
+ 'on', 'off', 'name', 'honorific-prefix', 'given-name', 'additional-name', 'family-name',
5
+ 'honorific-suffix', 'nickname', 'organization-title', 'username', 'new-password',
6
+ 'current-password', 'one-time-code', 'organization', 'street-address', 'address-line1',
7
+ 'address-line2', 'address-line3', 'address-level1', 'address-level2', 'address-level3',
8
+ 'address-level4', 'country', 'country-name', 'postal-code', 'cc-name', 'cc-given-name',
9
+ 'cc-additional-name', 'cc-family-name', 'cc-number', 'cc-exp', 'cc-exp-month', 'cc-exp-year',
10
+ 'cc-csc', 'cc-type', 'transaction-currency', 'transaction-amount', 'language', 'bday',
11
+ 'bday-day', 'bday-month', 'bday-year', 'sex', 'url', 'photo', 'tel', 'tel-country-code',
12
+ 'tel-national', 'tel-area-code', 'tel-local', 'tel-local-prefix', 'tel-local-suffix',
13
+ 'tel-extension', 'email', 'impp', 'webauthn', 'shipping', 'billing', 'home', 'work',
14
+ 'mobile', 'fax', 'pager',
15
+ ]);
16
+
17
+ export default {
18
+ id: 'autocomplete-valid',
19
+ impact: 'serious',
20
+ tags: ['wcag21aa', 'wcag135'],
21
+ help: 'autocomplete attributes must use valid tokens',
22
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/identify-input-purpose.html',
23
+ selector: 'input[autocomplete], select[autocomplete], textarea[autocomplete]',
24
+ evaluate(element) {
25
+ const invalid = element
26
+ .getAttribute('autocomplete')
27
+ .trim()
28
+ .toLowerCase()
29
+ .split(/\s+/)
30
+ .filter((token) => token && !TOKENS.has(token) && !token.startsWith('section-'));
31
+ if (!invalid.length) return { status: 'pass' };
32
+ return {
33
+ status: 'fail',
34
+ message: `autocomplete contains unknown token(s): ${invalid.join(', ')} — browsers and assistive tools can't identify this field's purpose.`,
35
+ fix: 'Use tokens from the HTML input-purposes list, e.g. autocomplete="email" or autocomplete="given-name".',
36
+ };
37
+ },
38
+ };
@@ -0,0 +1,74 @@
1
+ // WCAG SC 1.4.1 Use of Color (Level A)
2
+ import { parseColor, contrastRatio } from '../../lib/contrast.js';
3
+
4
+ export default {
5
+ id: 'link-in-text-block',
6
+ impact: 'serious',
7
+ tags: ['wcag2a', 'wcag141'],
8
+ help: 'Links inside text must be distinguishable by more than colour',
9
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html',
10
+ // Only running prose counts as a "text block" — lists of links (navs,
11
+ // blogrolls) are identified by their structure, not by colour.
12
+ selector: 'p a[href], dd a[href], blockquote a[href], td a[href]',
13
+ visibility: 'visual', // colour distinction is a purely visual concern
14
+ evaluate(element, { ownText }) {
15
+ const parent = element.closest('p, dd, blockquote, td');
16
+ if (!element.textContent.trim() || !parent) return { status: 'pass' };
17
+ // Needs real surrounding text to blend into (1.4.1 is about links
18
+ // embedded in prose, not a link that IS the content).
19
+ if (ownText(parent).replace(/\s+/g, '').length < 10) return { status: 'pass' };
20
+
21
+ const style = getComputedStyle(element);
22
+ if ((style.textDecorationLine ?? style.textDecoration ?? '').includes('underline')) return { status: 'pass' };
23
+ // Underline substitutes count as non-colour cues too: border-bottom
24
+ // "underlines", box-shadow underlines, and background chips/pills all
25
+ // mark the link by shape, not colour alone.
26
+ if (parseFloat(style.borderBottomWidth) > 0 && style.borderBottomStyle !== 'none') return { status: 'pass' };
27
+ if (style.boxShadow && style.boxShadow !== 'none') return { status: 'pass' };
28
+ const ownBackground = parseColor(style.backgroundColor);
29
+ if (ownBackground && ownBackground.a > 0) return { status: 'pass' };
30
+ if (style.backgroundImage !== 'none') return { status: 'pass' }; // gradient/image underline technique
31
+
32
+ // A clear weight difference is a non-colour distinction too.
33
+ const parentStyle = getComputedStyle(parent);
34
+ const weight = (s) => parseInt(s.fontWeight, 10) || 400;
35
+ if (Math.abs(weight(style) - weight(parentStyle)) >= 300) return { status: 'pass' };
36
+
37
+ const linkColor = parseColor(style.color);
38
+ const textColor = parseColor(parentStyle.color);
39
+ if (!linkColor || !textColor) return { status: 'pass' };
40
+
41
+ const ratio = contrastRatio(linkColor, textColor);
42
+ // WCAG technique G183: 3:1 against the surrounding text is a sufficient
43
+ // colour difference for identifying a link.
44
+ if (ratio >= 3) return { status: 'pass' };
45
+
46
+ // LAST, because it's the expensive check (tree walk + line geometry)
47
+ // and only failing candidates should ever pay for it: a link occupying
48
+ // its own line (<br>-separated address blocks, one-link-per-line
49
+ // footers) has no surrounding words to blend into — nothing is
50
+ // conveyed by colour.
51
+ const rect = element.getBoundingClientRect();
52
+ if (rect.height) {
53
+ const mid = rect.top + rect.height / 2;
54
+ const walker = element.ownerDocument.createTreeWalker(parent, NodeFilter.SHOW_TEXT);
55
+ const range = element.ownerDocument.createRange();
56
+ let sharesLine = false;
57
+ for (let node = walker.nextNode(); node && !sharesLine; node = walker.nextNode()) {
58
+ if (!node.textContent.trim() || element.contains(node)) continue;
59
+ range.selectNodeContents(node);
60
+ for (const r of range.getClientRects()) {
61
+ if (r.top <= mid && r.bottom >= mid && r.width > 0) { sharesLine = true; break; }
62
+ }
63
+ }
64
+ if (!sharesLine) return { status: 'pass' };
65
+ }
66
+ return {
67
+ status: 'fail',
68
+ message: ratio < 1.01
69
+ ? 'This link has no underline and the same colour as the surrounding text — sighted users can’t tell it’s a link.'
70
+ : `This link has no underline and only ${ratio.toFixed(2)}:1 colour difference from the surrounding text — colour-blind users can’t spot it.`,
71
+ fix: 'Underline links inside text (text-decoration: underline), or add a non-colour indicator.',
72
+ };
73
+ },
74
+ };
@@ -0,0 +1,25 @@
1
+ // WCAG SC 1.4.10 Reflow (Level AA)
2
+ // The full test renders at 320 CSS px; an audit runs at whatever viewport
3
+ // the page is in. What IS knowable here: content that already forces
4
+ // two-dimensional scrolling at the current width will only be worse at
5
+ // 320px. Abstain-over-assert: this flags, it never fails — tables, maps,
6
+ // and images are exempt content the geometry can't distinguish.
7
+ export default {
8
+ id: 'reflow',
9
+ impact: 'moderate',
10
+ tags: ['wcag21aa', 'wcag1410'],
11
+ help: 'Content should reflow to one-dimensional scrolling',
12
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/reflow.html',
13
+ selector: 'html',
14
+ visibleOnly: false,
15
+ evaluate(element) {
16
+ const doc = element.ownerDocument;
17
+ const scroller = doc.scrollingElement ?? doc.documentElement;
18
+ const overflow = scroller.scrollWidth - scroller.clientWidth;
19
+ if (overflow <= 1) return { status: 'pass' };
20
+ return {
21
+ status: 'incomplete',
22
+ message: `The page already scrolls horizontally by ${Math.round(overflow)}px at the current viewport — at the 320px reflow breakpoint it will be worse. Verify content reflows at 320 CSS px width (data tables, maps and images are exempt).`,
23
+ };
24
+ },
25
+ };