pour-engine 1.33.0 → 1.34.1

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 (65) hide show
  1. package/config/project.config.js +1 -1
  2. package/engine/COVERAGE.md +5 -5
  3. package/engine/index.js +11 -4
  4. package/engine/lib/accessible-name.js +15 -4
  5. package/engine/lib/contrast.js +316 -11
  6. package/engine/lib/roles.js +30 -6
  7. package/engine/rules/best-practice/button-type.js +23 -2
  8. package/engine/rules/best-practice/fieldset-legend.js +7 -2
  9. package/engine/rules/best-practice/heading-order.js +2 -1
  10. package/engine/rules/best-practice/landmark-top-level.js +10 -0
  11. package/engine/rules/best-practice/landmark-unique.js +9 -1
  12. package/engine/rules/best-practice/new-window-link.js +34 -1
  13. package/engine/rules/best-practice/no-autofocus.js +15 -3
  14. package/engine/rules/best-practice/page-heading-one.js +15 -1
  15. package/engine/rules/best-practice/redundant-alt-phrase.js +9 -1
  16. package/engine/rules/best-practice/redundant-aria-label.js +24 -0
  17. package/engine/rules/best-practice/redundant-role.js +71 -20
  18. package/engine/rules/best-practice/region.js +6 -2
  19. package/engine/rules/index.js +13 -0
  20. package/engine/rules/wcag/1.1.1-input-image-alt.js +4 -1
  21. package/engine/rules/wcag/1.1.1-object-alt.js +17 -0
  22. package/engine/rules/wcag/1.2.1-audio-transcript.js +47 -0
  23. package/engine/rules/wcag/1.2.2-media-captions.js +16 -1
  24. package/engine/rules/wcag/1.3.1-aria-required-children.js +30 -3
  25. package/engine/rules/wcag/1.3.1-aria-required-parent.js +25 -9
  26. package/engine/rules/wcag/1.3.1-definition-list.js +15 -3
  27. package/engine/rules/wcag/1.3.1-dlitem-parent.js +14 -1
  28. package/engine/rules/wcag/1.3.1-form-label.js +39 -1
  29. package/engine/rules/wcag/1.3.1-listitem-parent.js +23 -7
  30. package/engine/rules/wcag/1.3.1-table-headers.js +34 -3
  31. package/engine/rules/wcag/1.3.2-reading-order-divergence.js +69 -0
  32. package/engine/rules/wcag/1.3.4-orientation.js +21 -7
  33. package/engine/rules/wcag/1.3.5-autocomplete-valid.js +87 -13
  34. package/engine/rules/wcag/1.4.1-link-in-text-block.js +224 -17
  35. package/engine/rules/wcag/1.4.11-non-text-contrast.js +27 -3
  36. package/engine/rules/wcag/1.4.2-audio-control.js +45 -14
  37. package/engine/rules/wcag/1.4.3-color-contrast.js +337 -47
  38. package/engine/rules/wcag/1.4.3-control-contrast.js +26 -5
  39. package/engine/rules/wcag/2.2.1-meta-refresh.js +25 -1
  40. package/engine/rules/wcag/2.2.2-pause-stop-hide.js +26 -3
  41. package/engine/rules/wcag/2.4.1-bypass-blocks.js +29 -5
  42. package/engine/rules/wcag/2.4.2-document-title.js +8 -2
  43. package/engine/rules/wcag/2.4.3-visual-order-divergence.js +6 -4
  44. package/engine/rules/wcag/2.4.4-link-name.js +11 -0
  45. package/engine/rules/wcag/2.4.4-link-text-generic.js +22 -1
  46. package/engine/rules/wcag/2.4.7-focus-visible.js +47 -10
  47. package/engine/rules/wcag/2.4.9-link-text-generic-only.js +36 -5
  48. package/engine/rules/wcag/2.5.3-label-in-name.js +14 -5
  49. package/engine/rules/wcag/2.5.5-target-size-enhanced.js +3 -1
  50. package/engine/rules/wcag/2.5.7-drag-alternative.js +38 -0
  51. package/engine/rules/wcag/2.5.8-target-size.js +122 -3
  52. package/engine/rules/wcag/3.1.1-html-lang.js +12 -1
  53. package/engine/rules/wcag/3.2.2-on-input-navigation.js +18 -8
  54. package/engine/rules/wcag/3.3.7-redundant-entry.js +55 -0
  55. package/engine/rules/wcag/3.3.8-auth-field-obstruction.js +50 -20
  56. package/engine/rules/wcag/4.1.2-aria-attr-valid.js +49 -13
  57. package/engine/rules/wcag/4.1.2-aria-hidden-focus.js +6 -1
  58. package/engine/rules/wcag/4.1.2-aria-valid-refs.js +109 -72
  59. package/engine/rules/wcag/4.1.2-button-name.js +11 -0
  60. package/engine/rules/wcag/4.1.2-composite-widget-name.js +12 -6
  61. package/engine/rules/wcag/4.1.2-label-for-valid.js +41 -8
  62. package/engine/rules/wcag/4.1.2-nested-interactive.js +18 -1
  63. package/engine/rules/wcag/4.1.2-valid-role.js +16 -0
  64. package/engine/wcag22.js +4 -4
  65. package/package.json +1 -1
@@ -2,14 +2,25 @@
2
2
  // Child roles that only make sense inside a specific container role.
3
3
  // Explicit roles only — native elements (li outside ul) are covered by
4
4
  // listitem-parent/dlitem-parent.
5
+ //
6
+ // ARIA 1.2 Required Context Role, verbatim: group is a real context for
7
+ // option, the menuitem family and treeitem (listbox > group > option,
8
+ // menu > group > menuitem, tree > treeitem > group > treeitem is the APG
9
+ // Tree View pattern itself). Treating group as transparent for those roles
10
+ // walked past the group onto the parent treeitem, which is neither a
11
+ // container nor transparent, and asserted every multi-level tree built to
12
+ // the pattern (2026-08-25 overnight audit; Chromium exposes tree > treeitem
13
+ // > group > treeitem exactly as authored). For the roles whose context does
14
+ // not list group (listitem, tab, row, cell), group stays transparent as
15
+ // before.
5
16
  const REQUIRED_PARENT = {
6
17
  listitem: ['list'],
7
- option: ['listbox'],
8
- menuitem: ['menu', 'menubar'],
9
- menuitemcheckbox: ['menu', 'menubar'],
10
- menuitemradio: ['menu', 'menubar'],
18
+ option: ['listbox', 'group'],
19
+ menuitem: ['menu', 'menubar', 'group'],
20
+ menuitemcheckbox: ['menu', 'menubar', 'group'],
21
+ menuitemradio: ['menu', 'menubar', 'group'],
11
22
  tab: ['tablist'],
12
- treeitem: ['tree'],
23
+ treeitem: ['tree', 'group'],
13
24
  row: ['table', 'grid', 'treegrid', 'rowgroup'],
14
25
  rowgroup: ['table', 'grid', 'treegrid'],
15
26
  cell: ['row'],
@@ -29,6 +40,9 @@ export default {
29
40
  evaluate(element) {
30
41
  const role = element.getAttribute('role').trim().split(/\s+/)[0].toLowerCase();
31
42
  const containers = REQUIRED_PARENT[role];
43
+ // The container named in the message and fix: the structure's root, not
44
+ // the group that may sit between it and the item.
45
+ const named = containers.filter((container) => container !== 'group');
32
46
  // aria-owns makes the relationship without containment: the owner can sit
33
47
  // anywhere in the document and the browser still builds the structure.
34
48
  // Judging by DOM ancestry alone failed every remotely-owned child.
@@ -55,18 +69,20 @@ export default {
55
69
  const parentRole =
56
70
  parent.getAttribute('role')?.trim().split(/\s+/)[0]?.toLowerCase() ??
57
71
  IMPLICIT_CONTAINER[parent.tagName.toLowerCase()];
58
- if (parentRole === 'group') continue; // group is transparent in these structures
72
+ // A permitted context (the structure's root, or a group for the roles
73
+ // whose ARIA context lists it) satisfies the requirement outright.
74
+ if (containers.includes(parentRole)) return { status: 'pass' };
75
+ if (parentRole === 'group') continue; // group is transparent in the remaining structures
59
76
  // presentation/none removes the wrapper from the tree but leaves its
60
77
  // children owned by the next real ancestor — li[role=presentation]
61
78
  // between a tablist and its tabs is the canonical pattern.
62
79
  if (parentRole === 'presentation' || parentRole === 'none') continue;
63
- if (containers.includes(parentRole)) return { status: 'pass' };
64
80
  if (parentRole) break; // a different role interrupts the required structure
65
81
  }
66
82
  return {
67
83
  status: 'fail',
68
- message: `role="${role}" is not inside a ${containers.join('/')} — assistive technology loses the structure entirely.`,
69
- fix: `Wrap it in an element with role="${containers[0]}", or fix the intervening roles.`,
84
+ message: `role="${role}" is not inside a ${named.join('/')} — assistive technology loses the structure entirely.`,
85
+ fix: `Wrap it in an element with role="${named[0]}", or fix the intervening roles.`,
70
86
  };
71
87
  },
72
88
  };
@@ -1,5 +1,13 @@
1
1
  // WCAG SC 1.3.1 Info and Relationships (Level A)
2
- const ALLOWED = new Set(['DT', 'DD', 'DIV', 'SCRIPT', 'TEMPLATE']);
2
+ const ALLOWED = new Set(['DT', 'DD', 'DIV']);
3
+ // Nothing here reaches the accessibility tree, so none of it can corrupt
4
+ // the term/description pairing: the same set list-structure keeps, for the
5
+ // same reason (CSS-in-JS injects <style> right beside the markup). HTML's
6
+ // content model does exclude style from dl, but validity is not the
7
+ // criterion; 1.3.1 is about relationships assistive technology can
8
+ // determine, and every dt/dd pair stays intact around an element that has
9
+ // no tree presence (2026-08-25 overnight audit).
10
+ const NEVER_RENDERED = new Set(['SCRIPT', 'TEMPLATE', 'STYLE', 'LINK', 'META']);
3
11
 
4
12
  export default {
5
13
  id: 'definition-list',
@@ -9,8 +17,12 @@ export default {
9
17
  help: '<dl> must be structured as term/description pairs',
10
18
  helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html',
11
19
  selector: 'dl:not([role])', // a role attribute replaces the dl semantics
12
- evaluate(element) {
13
- const invalid = [...element.children].filter((child) => !ALLOWED.has(child.tagName));
20
+ evaluate(element, { isRendered }) {
21
+ // A child the browser never renders (display:none, hidden) is not in
22
+ // the accessibility tree either, so it cannot break the pairing a
23
+ // reader hears; list-structure applies the same gate.
24
+ const invalid = [...element.children].filter((child) =>
25
+ !ALLOWED.has(child.tagName) && !NEVER_RENDERED.has(child.tagName) && (!isRendered || isRendered(child)));
14
26
  if (invalid.length) {
15
27
  const tags = [...new Set(invalid.map((child) => `<${child.tagName.toLowerCase()}>`))].join(', ');
16
28
  return {
@@ -8,7 +8,20 @@ export default {
8
8
  helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html',
9
9
  selector: 'dt:not([role]), dd:not([role])', // role overrides the dt/dd semantics
10
10
  evaluate(element) {
11
- if (element.closest('dl')) return { status: 'pass' };
11
+ // Judge the FLAT-tree ancestry, what assistive technology actually
12
+ // sees: a <dt>/<dd> slotted into a host whose shadow root holds
13
+ // <dl><slot></slot></dl> belongs to that <dl> (Chromium exposes
14
+ // DescriptionList > term > definition for it), and an item at the top
15
+ // of a shadow tree continues through its host. A light-DOM closest('dl')
16
+ // asserted every slotted pair as orphaned (2026-08-25 overnight audit;
17
+ // same walk as listitem-parent).
18
+ const flatParent = (node) => node.assignedSlot?.parentElement
19
+ ?? node.parentElement
20
+ ?? node.getRootNode()?.host
21
+ ?? null;
22
+ for (let parent = flatParent(element); parent; parent = flatParent(parent)) {
23
+ if (parent.tagName === 'DL') return { status: 'pass' };
24
+ }
12
25
  return {
13
26
  status: 'fail',
14
27
  message: `<${element.tagName.toLowerCase()}> outside a <dl> has no term/description semantics.`,
@@ -28,10 +28,21 @@ export default {
28
28
  // aria-hidden styled twin names NOTHING — the browser's tree computes an
29
29
  // empty name), while aria-label on the label itself or on things inside
30
30
  // it (icon-only labels) and image alts DO feed the name per accname.
31
+ //
32
+ // An UNRENDERED descendant is excluded the same way: accname step 2A
33
+ // returns the empty string for a hidden node that is not the root of
34
+ // the label traversal, and hidden covers display:none and
35
+ // visibility:hidden (the hidden attribute is display:none). A label
36
+ // whose only text sits in a display:none span names nothing (Chromium
37
+ // computes ""), the mirror of the aria-hidden twin above (2026-08-25
38
+ // overnight audit). Computed style rather than checkVisibility: an SVG
39
+ // <title> has no box yet still names its graphic.
31
40
  const accessibleText = (node) => {
32
41
  if (node.nodeType === Node.TEXT_NODE) return node.textContent;
33
42
  if (node.nodeType !== Node.ELEMENT_NODE) return '';
34
43
  if (node.getAttribute('aria-hidden') === 'true') return '';
44
+ const nodeStyle = getComputedStyle(node);
45
+ if (nodeStyle.display === 'none' || nodeStyle.visibility === 'hidden') return '';
35
46
  const aria = node.getAttribute('aria-label');
36
47
  if (aria?.trim()) return aria;
37
48
  const alt = (node.tagName === 'IMG' || node.tagName === 'AREA') ? node.getAttribute('alt') : null;
@@ -66,9 +77,25 @@ export default {
66
77
  };
67
78
  }
68
79
  if (element.labels?.length && !visibleLabels.length) {
80
+ // aria-hidden has no visual effect: a label hidden ONLY that way is
81
+ // still painted for sighted users and lost only to assistive
82
+ // technology (Chromium computes an empty name). Telling the author
83
+ // to "make the label visible" sent them to fix the wrong thing; the
84
+ // fix is to remove aria-hidden (2026-08-25 overnight audit).
85
+ const onlyAriaHidden = [...element.labels].some((label) => {
86
+ const style = getComputedStyle(label);
87
+ return style.display !== 'none' && style.visibility !== 'hidden';
88
+ });
89
+ if (onlyAriaHidden) {
90
+ return {
91
+ status: 'fail',
92
+ message: 'This field\'s only <label> is inside aria-hidden="true": sighted users still see it, but it is removed from the accessibility tree, so the field has no accessible name.',
93
+ fix: 'Remove aria-hidden from the label (or from the ancestor that hides it). If the label must stay hidden from assistive technology, name the field another way with aria-label or aria-labelledby.',
94
+ };
95
+ }
69
96
  return {
70
97
  status: 'fail',
71
- message: 'This field\'s only <label> is hidden (display:none or aria-hidden) — no one can see or rely on it.',
98
+ message: 'This field\'s only <label> is hidden (display:none or visibility:hidden), so no one can see or rely on it.',
72
99
  fix: 'Make the label visible, or use a visually-hidden-but-rendered technique (clip/sr-only) if it must not show.',
73
100
  };
74
101
  }
@@ -77,6 +104,17 @@ export default {
77
104
  // sitting inside an aria-hidden subtree (the styled-twin custom
78
105
  // control pattern) is excluded from the name computation.
79
106
  const looksLabelled = visibleLabels.some((l) => l.textContent.trim());
107
+ const ariaHiddenText = visibleLabels.some((l) =>
108
+ [...l.querySelectorAll('[aria-hidden="true"]')].some((twin) => twin.textContent.trim()));
109
+ if (looksLabelled && !ariaHiddenText) {
110
+ // The text is there in the markup but nothing renders it (defect
111
+ // 8's shape): the sighted user sees an empty label too.
112
+ return {
113
+ status: 'fail',
114
+ message: 'This field\'s <label> holds text, but all of it is inside unrendered content (display:none or visibility:hidden), so the accessible name computes to nothing and nobody sees a label.',
115
+ fix: 'Render the text inside the label, move it out of the hidden element, or add aria-label to the field.',
116
+ };
117
+ }
80
118
  return {
81
119
  status: 'fail',
82
120
  message: looksLabelled
@@ -11,7 +11,8 @@ export default {
11
11
  // Judge the FLAT-tree parent — what assistive technology actually sees:
12
12
  // a slotted <li> belongs to the <ul> around its slot, and an <li> at the
13
13
  // top of a shadow tree belongs to its host element.
14
- let parent = element.assignedSlot?.parentElement ?? element.parentElement ?? element.getRootNode()?.host;
14
+ const flatParent = (el) => el.assignedSlot?.parentElement ?? el.parentElement ?? el.getRootNode()?.host;
15
+ let parent = flatParent(element);
15
16
  while (parent?.tagName === 'SLOT') parent = parent.assignedSlot?.parentElement ?? parent.parentElement;
16
17
  // List ownership passes through presentation/none wrappers and
17
18
  // role-less div/span grouping wrappers (generic nodes) — an <li> in a
@@ -32,16 +33,31 @@ export default {
32
33
  if (el.matches('ul, ol, menu')) return !role || ['list', 'presentation', 'none'].includes(role);
33
34
  return role === 'list';
34
35
  };
35
- while (parent && !listContainer(parent) && (
36
- ['presentation', 'none'].includes(parent.getAttribute('role') ?? '')
37
- || (!parent.hasAttribute('role') && (parent.tagName === 'DIV' || parent.tagName === 'SPAN'))
38
- )) {
39
- parent = parent.assignedSlot?.parentElement ?? parent.parentElement ?? parent.getRootNode()?.host;
40
- }
36
+ const transparent = (el) => ['presentation', 'none'].includes(el.getAttribute('role') ?? '')
37
+ || (!el.hasAttribute('role') && (el.tagName === 'DIV' || el.tagName === 'SPAN'));
38
+ while (parent && !listContainer(parent) && transparent(parent)) parent = flatParent(parent);
41
39
  if (parent && listContainer(parent)) return { status: 'pass' };
42
40
  const parentLabel = parent
43
41
  ? `<${parent.tagName.toLowerCase()}${parent.getAttribute('role') ? ` role="${parent.getAttribute('role')}"` : ''}>`
44
42
  : 'nothing';
43
+ // A role="group" wrapper BETWEEN a list and its items: the list is
44
+ // there (Chromium exposes list > group > listitem), but ARIA 1.2 gives
45
+ // list one required owned element, listitem, so the group is not
46
+ // permitted in between and the items are no longer the list's own. The
47
+ // verdict stands; the old message blamed a missing list and the fix
48
+ // sent the author to add one that already existed (2026-08-25
49
+ // overnight audit).
50
+ if (parent?.getAttribute('role') === 'group') {
51
+ let above = flatParent(parent);
52
+ while (above && !listContainer(above) && (transparent(above) || above.getAttribute('role') === 'group')) above = flatParent(above);
53
+ if (above && listContainer(above)) {
54
+ return {
55
+ status: 'fail',
56
+ message: `This <li> sits inside ${parentLabel} within its list. A list may only own list items directly, and role="group" is not permitted between a list and its items, so screen readers lose the item's list context.`,
57
+ fix: 'Remove role="group" from the wrapper (a plain <div> keeps the items owned by the list), or move the group label onto the list element with aria-label.',
58
+ };
59
+ }
60
+ }
45
61
  return {
46
62
  status: 'fail',
47
63
  message: `This <li> sits inside ${parentLabel} — ${parent?.matches('ul, ol, menu') && parent.getAttribute('role')
@@ -7,6 +7,17 @@
7
7
  // Layout tables are skipped via a conservative heuristic (role=presentation,
8
8
  // or too small/text-sparse to be a data grid) — a missed layout table is
9
9
  // better than false positives on real pages.
10
+ //
11
+ // Block-level content inside cells (headings, paragraphs, lists, forms,
12
+ // navigation) is the other layout signature: tabular data is inline text,
13
+ // and a page scaffold built as a table (site name / nav / sidebar / article
14
+ // / footer) meets the 2 x 2 x 4-text-cells heuristic while conveying no
15
+ // header relationship at all. H51 and F91 are scoped to "tabular
16
+ // information"; the DOM cannot prove such a table is data, so it asks
17
+ // rather than asserts (2026-08-25 overnight audit).
18
+ const BLOCK_CONTENT = 'h1, h2, h3, h4, h5, h6, p, ul, ol, dl, menu, form, nav, main, header, footer, '
19
+ + 'section, article, aside, blockquote, figure, fieldset, pre, hr, table';
20
+
10
21
  export default {
11
22
  id: 'table-headers',
12
23
  name: 'Table header associations',
@@ -18,18 +29,32 @@ export default {
18
29
  evaluate(element) {
19
30
  const role = element.getAttribute('role');
20
31
  if (role && !['table', 'grid'].includes(role)) return { status: 'pass' }; // presentation/none/other widget
32
+ // The table's OWN rows and cells (table.rows / row.cells stop at the
33
+ // table's boundary): a header cell inside a nested table belongs to that
34
+ // table, so it neither satisfies the outer table nor lifts it out of the
35
+ // nested-layout demotion (2026-08-25 overnight audit; a deep
36
+ // querySelector('th') did both).
21
37
  const rows = [...element.rows];
22
38
  const cells = rows.flatMap((row) => [...row.cells]);
23
39
  // Data-grid heuristic: at least a 2×2 grid with mostly-text cells.
24
40
  const isGrid = rows.length >= 2 && Math.max(0, ...rows.map((row) => row.cells.length)) >= 2;
25
41
  const textCells = cells.filter((cell) => cell.textContent.trim()).length;
26
42
  if (!isGrid || textCells < 4) return { status: 'pass' }; // likely layout or trivial
43
+ // A header cell is a <th>, or a <td> given the columnheader/rowheader
44
+ // role: ARIA in HTML permits any role on a td of a role-less table, and
45
+ // the header roles are the header semantics ARIA defines (Chromium
46
+ // exposes td[role=columnheader] as columnheader). 1.3.1 asks that the
47
+ // relationship be programmatically determinable, which the role
48
+ // satisfies (2026-08-25 overnight audit).
49
+ const cellRole = (cell) => cell.getAttribute('role')?.trim().split(/\s+/)[0]?.toLowerCase();
50
+ const isHeaderCell = (cell) => cell.tagName === 'TH' || ['columnheader', 'rowheader'].includes(cellRole(cell));
51
+ const hasHeaderCell = cells.some(isHeaderCell);
27
52
  // Nested tables are the signature of legacy LAYOUT markup, not data —
28
53
  // asserting "missing headers" there flags email-style scaffolding. A
29
54
  // human can tell in a glance; the machine can't. The signature reads in
30
55
  // both directions: a table INSIDE another table (Hacker News's story
31
56
  // list inside its page-scaffold table) is the same legacy layout.
32
- if (!element.querySelector('th') && (element.querySelector('table') || element.parentElement?.closest('table'))) {
57
+ if (!hasHeaderCell && (element.querySelector('table') || element.parentElement?.closest('table'))) {
33
58
  return {
34
59
  status: 'incomplete',
35
60
  message: 'This table has no header cells and is part of a nested-table structure — a layout-table signature. If it really presents data, mark its header cells with <th>; if it is layout scaffolding, add role="presentation".',
@@ -40,7 +65,7 @@ export default {
40
65
  const headers = cell.getAttribute('headers');
41
66
  return headers && headers.trim().split(/\s+/).filter(Boolean).some((id) => {
42
67
  const target = element.getRootNode().getElementById?.(id);
43
- return !target || !element.contains(target) || !/^T[HD]$/.test(target.tagName);
68
+ return !target || !cells.includes(target) || !(/^T[HD]$/.test(target.tagName) || isHeaderCell(target));
44
69
  });
45
70
  });
46
71
  if (badRefs.length) {
@@ -51,8 +76,14 @@ export default {
51
76
  };
52
77
  }
53
78
 
54
- const hasHeaders = element.querySelector('th, [scope], td[headers]');
79
+ const hasHeaders = hasHeaderCell || cells.some((cell) => cell.hasAttribute('scope') || cell.hasAttribute('headers'));
55
80
  if (!hasHeaders) {
81
+ if (cells.some((cell) => cell.querySelector(BLOCK_CONTENT))) {
82
+ return {
83
+ status: 'incomplete',
84
+ message: 'This table has no header cells and its cells hold block content (headings, paragraphs, lists or forms), which is a layout-table signature. If it really presents data, mark its header cells with <th>; if it is layout scaffolding, add role="presentation".',
85
+ };
86
+ }
56
87
  return {
57
88
  status: 'fail',
58
89
  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.',
@@ -0,0 +1,69 @@
1
+ // WCAG SC 1.3.2 Meaningful Sequence (Level A) — the CSS-reordering slice
2
+ //
3
+ // Screen readers read the DOM in source order. When CSS `order` or a
4
+ // reversed flex direction arranges sibling content differently on screen,
5
+ // sighted users and screen reader users receive the page in two different
6
+ // sequences — C27's substance ("make the DOM order match the visual
7
+ // order"), with the divergence measurable. Whether that divergence
8
+ // CHANGES MEANING is the criterion's own test and a human call, so this
9
+ // reviews, never asserts.
10
+ //
11
+ // Same geometry and the same deliberate narrowness as 2.4.3's
12
+ // visual-order-divergence (same-container siblings only; `order` or
13
+ // `*-reverse` as the only accepted evidence; wrapped-one-level-deep and
14
+ // grid-native placement are disclosed gaps there and equally here), with
15
+ // two scope cuts of its own:
16
+ // - at least two of the reordered siblings must carry rendered text:
17
+ // a reversed row of purely decorative icons has no reading sequence
18
+ // to preserve;
19
+ // - groups where EVERY reordered sibling is tabbable are left to 2.4.3,
20
+ // which already asks about that container from the focus side —
21
+ // asking the same question twice about the same box helps nobody.
22
+ import { FLEXGRID, tabbable, visualSequence } from './2.4.3-visual-order-divergence.js';
23
+
24
+ export default {
25
+ id: 'reading-order-divergence',
26
+ name: 'Reading order vs visual order',
27
+ impact: 'moderate',
28
+ tags: ['wcag2a', 'wcag132'],
29
+ help: 'CSS reordering should not make reading order diverge from the visual order',
30
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/meaningful-sequence.html',
31
+ selector: '*',
32
+ evaluateAll(elements) {
33
+ const outcomes = new Map();
34
+ const groups = new Map();
35
+ for (const element of elements) {
36
+ const parent = element.parentElement;
37
+ if (!parent) continue;
38
+ if (!groups.has(parent)) groups.set(parent, []);
39
+ groups.get(parent).push(element);
40
+ }
41
+ for (const [parent, children] of groups) {
42
+ if (children.length < 2) continue;
43
+ const parentStyle = getComputedStyle(parent);
44
+ if (!FLEXGRID.test(parentStyle.display)) continue;
45
+ const reversed = /-reverse$/.test(parentStyle.flexDirection);
46
+ const entries = children
47
+ .map((element) => ({ element, style: getComputedStyle(element) }))
48
+ .filter(({ style }) => style.position !== 'absolute' && style.position !== 'fixed')
49
+ .map((entry) => ({ ...entry, rect: entry.element.getBoundingClientRect() }))
50
+ .filter(({ rect }) => rect.width > 0 && rect.height > 0);
51
+ if (entries.length < 2) continue;
52
+ const usesOrder = entries.some(({ style }) => style.order !== '0');
53
+ if (!reversed && !usesOrder) continue;
54
+ const withText = entries.filter(({ element }) => (element.textContent || '').trim().length > 0);
55
+ if (withText.length < 2) continue;
56
+ if (entries.every(({ element }) => element.matches('a[href], button, input, select, textarea, summary, [tabindex]') && tabbable(element))) continue;
57
+ const visual = visualSequence(entries, parentStyle.direction === 'rtl');
58
+ const diverges = visual.some((entry, i) => entry !== entries[i]);
59
+ if (!diverges) continue;
60
+ const cause = reversed ? `flex-direction: ${parentStyle.flexDirection}` : 'CSS order';
61
+ outcomes.set(entries[0].element, {
62
+ status: 'incomplete',
63
+ message: `A screen reader reads these ${entries.length} blocks in DOM order, but ${cause} arranges them differently on screen — sighted and screen reader users get two different sequences. Check the source order still tells the same story as the layout.`,
64
+ fix: 'Reorder the source to match the visual order instead of reordering with CSS, or confirm the sequence difference does not change meaning here.',
65
+ });
66
+ }
67
+ return elements.map((element) => outcomes.get(element) ?? { status: 'pass' });
68
+ },
69
+ };
@@ -16,22 +16,36 @@
16
16
  // Script-driven locks (screen.orientation.lock()) and cross-origin
17
17
  // stylesheets (unreadable under CORS) are invisible to a static pass; the
18
18
  // rule asserts only what it can see and stays silent otherwise.
19
- const ROOT_PART = /^(html|body|:root)(::?[a-z-]+(\(.*\))?)?$/i;
19
+ // The root ELEMENT only, optionally qualified by pseudo-classes. A pseudo-
20
+ // element (body::before, html::backdrop) is one generated box, and hiding
21
+ // it in portrait is the region restyling this rule deliberately leaves
22
+ // alone; matching it asserted "hides the page" over a decorative band
23
+ // (2026-08-25 overnight audit). Pseudo-classes are kept, but only assert
24
+ // when the live root actually matches them (see rootSelector).
25
+ const ROOT_PART = /^(html|body|:root)((?::[a-z-]+(?:\([^()]*\))?)*)$/i;
20
26
  const LOCK_ROTATE = /rotate(?:3d\([^)]*,\s*)?\(?\s*(?:-?(?:90|270)deg|0\.25turn|-0\.25turn|100grad|-100grad)/i;
21
27
 
22
- function rootSelector(selectorText) {
23
- return (selectorText ?? '').split(',').some((part) => ROOT_PART.test(part.trim()));
28
+ function rootSelector(selectorText, doc) {
29
+ return (selectorText ?? '').split(',').some((part) => {
30
+ const match = ROOT_PART.exec(part.trim());
31
+ if (!match) return false;
32
+ if (!match[2]) return true; // bare root: always in force
33
+ // body:not(.ready) { visibility: hidden } locks nothing once the class
34
+ // is on; the selector must match the root as it stands to be a lock.
35
+ const root = /^body$/i.test(match[1]) ? doc.body : doc.documentElement;
36
+ try { return !!root && root.matches(part.trim()); } catch { return false; }
37
+ });
24
38
  }
25
39
 
26
- function scanRules(rules, insideOrientation, findings) {
40
+ function scanRules(rules, insideOrientation, findings, doc) {
27
41
  for (const rule of rules ?? []) {
28
42
  const condition = rule.conditionText ?? rule.media?.mediaText;
29
43
  if (condition !== undefined && rule.cssRules) {
30
44
  const orientation = /orientation\s*:\s*(portrait|landscape)/i.exec(condition)?.[1];
31
- scanRules(rule.cssRules, orientation ? { condition, orientation } : insideOrientation, findings);
45
+ scanRules(rule.cssRules, orientation ? { condition, orientation } : insideOrientation, findings, doc);
32
46
  continue;
33
47
  }
34
- if (insideOrientation && rule.style && rootSelector(rule.selectorText)) {
48
+ if (insideOrientation && rule.style && rootSelector(rule.selectorText, doc)) {
35
49
  const { display, visibility, transform, rotate } = rule.style;
36
50
  if (display === 'none' || visibility === 'hidden') {
37
51
  findings.push({ kind: 'hidden', selector: rule.selectorText, ...insideOrientation });
@@ -61,7 +75,7 @@ export default {
61
75
  for (const sheet of element.ownerDocument.styleSheets) {
62
76
  let rules;
63
77
  try { rules = sheet.cssRules; } catch { continue; } // cross-origin: unreadable, skip
64
- scanRules(rules, null, findings);
78
+ scanRules(rules, null, findings, element.ownerDocument);
65
79
  }
66
80
  if (!findings.length) return { status: 'pass' };
67
81
  const f = findings[0];
@@ -2,19 +2,77 @@
2
2
  // WCAG 1.3.5: autocomplete tokens from the HTML spec's input-purposes list.
3
3
  // Membership set, so order is free: kept a-z rather than mirroring the spec
4
4
  // table's own sequence.
5
- const TOKENS = new Set([
5
+ //
6
+ // The field names: the 53 purposes WCAG 2.2 section 7 lists, plus
7
+ // one-time-code (HTML's list is a superset of WCAG's).
8
+ const FIELD_NAMES = new Set([
6
9
  'additional-name', 'address-level1', 'address-level2', 'address-level3', 'address-level4',
7
10
  'address-line1', 'address-line2', 'address-line3', 'bday', 'bday-day', 'bday-month',
8
- 'bday-year', 'billing', 'cc-additional-name', 'cc-csc', 'cc-exp', 'cc-exp-month',
11
+ 'bday-year', 'cc-additional-name', 'cc-csc', 'cc-exp', 'cc-exp-month',
9
12
  'cc-exp-year', 'cc-family-name', 'cc-given-name', 'cc-name', 'cc-number', 'cc-type',
10
- 'country', 'country-name', 'current-password', 'email', 'family-name', 'fax', 'given-name',
11
- 'home', 'honorific-prefix', 'honorific-suffix', 'impp', 'language', 'mobile', 'name',
12
- 'new-password', 'nickname', 'off', 'on', 'one-time-code', 'organization',
13
- 'organization-title', 'pager', 'photo', 'postal-code', 'sex', 'shipping', 'street-address',
13
+ 'country', 'country-name', 'current-password', 'email', 'family-name', 'given-name',
14
+ 'honorific-prefix', 'honorific-suffix', 'impp', 'language', 'name',
15
+ 'new-password', 'nickname', 'one-time-code', 'organization',
16
+ 'organization-title', 'photo', 'postal-code', 'sex', 'street-address',
14
17
  'tel', 'tel-area-code', 'tel-country-code', 'tel-extension', 'tel-local', 'tel-local-prefix',
15
18
  'tel-local-suffix', 'tel-national', 'transaction-amount', 'transaction-currency', 'url',
16
- 'username', 'webauthn', 'work',
19
+ 'username',
17
20
  ]);
21
+ // HTML's contact types, and the field names they may qualify.
22
+ const CONTACT_TYPES = new Set(['home', 'work', 'mobile', 'fax', 'pager']);
23
+ const CONTACT_FIELDS = new Set([
24
+ 'tel', 'tel-country-code', 'tel-national', 'tel-area-code', 'tel-local',
25
+ 'tel-local-prefix', 'tel-local-suffix', 'tel-extension', 'email', 'impp',
26
+ ]);
27
+ const MODIFIERS = new Set(['shipping', 'billing', 'webauthn', 'on', 'off']);
28
+ const isSection = (token) => token.startsWith('section-');
29
+ const isKnown = (token) => FIELD_NAMES.has(token) || CONTACT_TYPES.has(token) || MODIFIERS.has(token) || isSection(token);
30
+
31
+ /**
32
+ * HTML's autofill grammar, parsed positionally: the value is either "on" or
33
+ * "off" alone, or autofill detail tokens in this order: optionally one
34
+ * section-* token, then optionally shipping or billing, then EITHER one
35
+ * field name OR a contact type (home, work, mobile, fax, pager) immediately
36
+ * followed by a tel, tel-*, email or impp field name, then optionally
37
+ * webauthn. A browser that cannot parse the value discards all of it, so a
38
+ * grammatically broken value exposes no purpose at all: F107 territory even
39
+ * when every token is individually real. Membership alone passed "shipping"
40
+ * (no field), "tel email" (two fields), "home name" (contact type before a
41
+ * non-contact field), "billing section-x tel" (section not first), "off
42
+ * email" and "webauthn" alone (2026-08-25 overnight audit).
43
+ * Returns null when the value is well formed, otherwise the reason it is not.
44
+ */
45
+ function grammarFault(tokens) {
46
+ if (tokens.length === 1 && (tokens[0] === 'on' || tokens[0] === 'off')) return null;
47
+ if (tokens.includes('on') || tokens.includes('off')) return '"on" and "off" must stand alone, with no other tokens';
48
+ let i = 0;
49
+ if (isSection(tokens[i])) i += 1;
50
+ if (tokens[i] === 'shipping' || tokens[i] === 'billing') i += 1;
51
+ if (CONTACT_TYPES.has(tokens[i])) {
52
+ if (!CONTACT_FIELDS.has(tokens[i + 1])) {
53
+ return `"${tokens[i]}" must come directly before a tel, tel-*, email or impp field name`;
54
+ }
55
+ i += 2;
56
+ } else if (FIELD_NAMES.has(tokens[i])) {
57
+ i += 1;
58
+ } else {
59
+ const found = tokens[i];
60
+ if (found && isSection(found)) return `"${found}" must be the first token`;
61
+ if (found === 'webauthn') return '"webauthn" must come last, after a field name';
62
+ if (found === 'shipping' || found === 'billing') return `"${found}" may appear once, directly before the field name`;
63
+ return 'the value names no field (exactly one field name such as email, tel or given-name is required)';
64
+ }
65
+ if (tokens[i] === 'webauthn') i += 1;
66
+ if (i < tokens.length) {
67
+ const extra = tokens[i];
68
+ if (isSection(extra)) return `"${extra}" must be the first token`;
69
+ if (extra === 'shipping' || extra === 'billing') return `"${extra}" must come before the field name`;
70
+ if (extra === 'webauthn') return '"webauthn" must be the last token';
71
+ if (FIELD_NAMES.has(extra) || CONTACT_TYPES.has(extra)) return `only one field name is allowed, but "${tokens[i - 1]}" is followed by "${extra}"`;
72
+ return `"${extra}" cannot follow the field name`;
73
+ }
74
+ return null;
75
+ }
18
76
 
19
77
  export default {
20
78
  id: 'autocomplete-valid',
@@ -23,19 +81,35 @@ export default {
23
81
  tags: ['wcag21aa', 'wcag135'],
24
82
  help: 'autocomplete attributes must use valid tokens',
25
83
  helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/identify-input-purpose.html',
26
- selector: 'input[autocomplete], select[autocomplete], textarea[autocomplete]',
84
+ // HTML gives the autocomplete attribute no effect on checkbox, radio, file,
85
+ // submit, image, reset and button inputs, and a disabled control collects
86
+ // nothing, so neither can misidentify a purpose (2026-08-25 overnight
87
+ // audit; the same applicability ACT rule 73f2c2 states).
88
+ selector: 'input[autocomplete]:not([type="checkbox"]):not([type="radio"]):not([type="file"]):not([type="submit"])'
89
+ + ':not([type="image"]):not([type="reset"]):not([type="button"]):not(:disabled),'
90
+ + ' select[autocomplete]:not(:disabled), textarea[autocomplete]:not(:disabled)',
27
91
  evaluate(element) {
28
- const invalid = element
92
+ if (element.getAttribute('aria-disabled') === 'true') return { status: 'pass' };
93
+ const tokens = element
29
94
  .getAttribute('autocomplete')
30
95
  .trim()
31
96
  .toLowerCase()
32
97
  .split(/\s+/)
33
- .filter((token) => token && !TOKENS.has(token) && !token.startsWith('section-'));
34
- if (!invalid.length) return { status: 'pass' };
98
+ .filter(Boolean);
99
+ const invalid = tokens.filter((token) => !isKnown(token));
100
+ if (invalid.length) {
101
+ return {
102
+ status: 'fail',
103
+ message: `autocomplete contains unknown token(s): ${invalid.join(', ')} — browsers and assistive tools can't identify this field's purpose.`,
104
+ fix: 'Use tokens from the HTML input-purposes list, e.g. autocomplete="email" or autocomplete="given-name".',
105
+ };
106
+ }
107
+ const fault = tokens.length ? grammarFault(tokens) : null;
108
+ if (!fault) return { status: 'pass' };
35
109
  return {
36
110
  status: 'fail',
37
- message: `autocomplete contains unknown token(s): ${invalid.join(', ')} — browsers and assistive tools can't identify this field's purpose.`,
38
- fix: 'Use tokens from the HTML input-purposes list, e.g. autocomplete="email" or autocomplete="given-name".',
111
+ message: `autocomplete="${element.getAttribute('autocomplete').trim()}" is not a valid autofill value: ${fault}. Browsers discard the whole value, so no purpose is exposed.`,
112
+ fix: 'Use one field name from the HTML autofill list, optionally preceded by section-*, then shipping or billing, and (for tel, email and impp only) a contact type, e.g. autocomplete="shipping tel" or autocomplete="home email".',
39
113
  };
40
114
  },
41
115
  };