@surea11y/core 1.5.0 → 1.7.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 (157) hide show
  1. package/CHANGELOG.md +240 -149
  2. package/README.md +51 -44
  3. package/docs/ACT_RULE_MAPPING.md +245 -0
  4. package/docs/API_STABILITY.md +53 -5
  5. package/docs/BINDING_AUTHORS_GUIDE.md +106 -4
  6. package/docs/DESIGN_CHALLENGES.md +367 -0
  7. package/docs/EARL.md +100 -0
  8. package/docs/ENGINE_OPTIONS.md +42 -4
  9. package/docs/I18N.md +4 -4
  10. package/docs/INTEGRATION.md +4 -2
  11. package/docs/LIMITATIONS.md +9 -5
  12. package/docs/OUTPUT_SCHEMA.md +44 -6
  13. package/docs/POLICY.md +1 -1
  14. package/docs/REPORT.md +1 -1
  15. package/docs/RULE_AUTHORING.md +63 -36
  16. package/docs/RULE_CATALOG.md +1928 -169
  17. package/docs/RULE_HELPERS.md +333 -0
  18. package/docs/RULE_TAXONOMY.md +27 -6
  19. package/docs/SARIF.md +21 -2
  20. package/docs/TROUBLESHOOTING.md +2 -2
  21. package/docs/WCAG_CONFORMANCE.md +34 -10
  22. package/package.json +11 -9
  23. package/src/baseline.js +3 -3
  24. package/src/checks/automatic/area-alt-present.js +2 -2
  25. package/src/checks/automatic/aria-allowed-attr.js +74 -10
  26. package/src/checks/automatic/aria-allowed-role.js +34 -25
  27. package/src/checks/automatic/aria-braille-equivalent.js +21 -13
  28. package/src/checks/automatic/aria-conditional-attr.js +22 -15
  29. package/src/checks/automatic/aria-deprecated-role.js +13 -1
  30. package/src/checks/automatic/aria-hidden-body.js +3 -3
  31. package/src/checks/automatic/aria-hidden-focus.js +5 -5
  32. package/src/checks/automatic/aria-prohibited-attr.js +23 -18
  33. package/src/checks/automatic/aria-prohibited-children.js +136 -43
  34. package/src/checks/automatic/aria-required-attr.js +119 -24
  35. package/src/checks/automatic/aria-required-children.js +54 -30
  36. package/src/checks/automatic/aria-required-parent.js +93 -15
  37. package/src/checks/automatic/aria-role-name-present.js +37 -23
  38. package/src/checks/automatic/aria-roles-valid.js +52 -21
  39. package/src/checks/automatic/aria-valid-attr-value.js +89 -33
  40. package/src/checks/automatic/aria-valid-attr.js +15 -10
  41. package/src/checks/automatic/autocomplete-valid.js +2 -2
  42. package/src/checks/automatic/avoid-inline-spacing.js +133 -6
  43. package/src/checks/automatic/binary-control-name-present.js +27 -5
  44. package/src/checks/automatic/button-name-present.js +92 -6
  45. package/src/checks/automatic/combobox-name-present.js +26 -6
  46. package/src/checks/automatic/contrast-computable.js +42 -0
  47. package/src/checks/automatic/contrast-enhanced.js +33 -1
  48. package/src/checks/automatic/contrast-minimum.js +33 -1
  49. package/src/checks/automatic/css-orientation-lock.js +138 -24
  50. package/src/checks/automatic/definition-list-children-valid.js +7 -8
  51. package/src/checks/automatic/deprecated-elements-not-used.js +1 -1
  52. package/src/checks/automatic/dialog-name-present.js +20 -2
  53. package/src/checks/automatic/duplicate-id-aria.js +10 -3
  54. package/src/checks/automatic/duplicate-id.js +203 -0
  55. package/src/checks/automatic/embed-text-alternative-present.js +2 -2
  56. package/src/checks/automatic/form-control-programmatic-label-present.js +19 -2
  57. package/src/checks/automatic/form-control-single-label.js +10 -1
  58. package/src/checks/automatic/identical-iframes-same-purpose.js +229 -0
  59. package/src/checks/automatic/iframe-focusable-content.js +68 -7
  60. package/src/checks/automatic/iframe-name-present.js +37 -3
  61. package/src/checks/automatic/iframe-title-unique.js +1 -1
  62. package/src/checks/automatic/img-alt-present.js +12 -4
  63. package/src/checks/automatic/label-in-name.js +204 -68
  64. package/src/checks/automatic/link-in-text-block.js +285 -29
  65. package/src/checks/automatic/link-name-present.js +22 -1
  66. package/src/checks/automatic/list-children-valid.js +6 -6
  67. package/src/checks/automatic/listbox-name-present.js +28 -8
  68. package/src/checks/automatic/listitem-parent-valid.js +4 -4
  69. package/src/checks/automatic/menuitem-name-present.js +20 -2
  70. package/src/checks/automatic/meta-refresh-no-exceptions.js +25 -14
  71. package/src/checks/automatic/meta-refresh-timing-absent.js +14 -7
  72. package/src/checks/automatic/meter-name-present.js +23 -4
  73. package/src/checks/automatic/nested-interactive-controls-absent.js +5 -5
  74. package/src/checks/automatic/option-name-present.js +23 -4
  75. package/src/checks/automatic/page-title-present.js +21 -3
  76. package/src/checks/automatic/presentational-children-focusable-absent.js +330 -0
  77. package/src/checks/automatic/progressbar-name-present.js +23 -4
  78. package/src/checks/automatic/{role-img-alt-present.js → role-img-text-alternative-present.js} +64 -16
  79. package/src/checks/automatic/searchbox-name-present.js +28 -8
  80. package/src/checks/automatic/server-side-image-map-absent.js +1 -1
  81. package/src/checks/automatic/slider-name-present.js +27 -6
  82. package/src/checks/automatic/spinbutton-name-present.js +28 -8
  83. package/src/checks/automatic/summary-name-present.js +18 -2
  84. package/src/checks/automatic/svg-image-text-alternative-present.js +1 -1
  85. package/src/checks/automatic/svg-text-alternative-present.js +13 -10
  86. package/src/checks/automatic/tab-name-present.js +21 -2
  87. package/src/checks/automatic/table-headers-attr-valid.js +43 -8
  88. package/src/checks/automatic/table-th-has-data-cells.js +61 -5
  89. package/src/checks/automatic/target-size-minimum.js +155 -58
  90. package/src/checks/automatic/td-has-header.js +24 -23
  91. package/src/checks/automatic/textbox-name-present.js +28 -8
  92. package/src/checks/automatic/tooltip-name-present.js +21 -2
  93. package/src/checks/automatic/treeitem-name-present.js +23 -4
  94. package/src/checks/automatic/valid-lang.js +92 -7
  95. package/src/checks/automatic/video-poster-text-alternative-present.js +1 -1
  96. package/src/checks/manual/accesskeys-manual.js +3 -3
  97. package/src/checks/manual/area-alt-decorative-manual.js +7 -0
  98. package/src/checks/manual/area-alt-quality-manual.js +6 -0
  99. package/src/checks/manual/aria-checked-state-mismatch-manual.js +5 -5
  100. package/src/checks/manual/aria-text-manual.js +4 -4
  101. package/src/checks/manual/bypass-blocks-present-manual.js +44 -26
  102. package/src/checks/manual/canvas-text-alternative-quality-manual.js +8 -0
  103. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +444 -0
  104. package/src/checks/manual/embed-text-alternative-quality-manual.js +8 -0
  105. package/src/checks/manual/empty-heading-manual.js +58 -11
  106. package/src/checks/manual/empty-table-header-manual.js +8 -8
  107. package/src/checks/manual/focus-order-semantics-manual.js +15 -15
  108. package/src/checks/manual/form-control-label-quality-manual.js +563 -0
  109. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -2
  110. package/src/checks/manual/heading-order-manual.js +3 -3
  111. package/src/checks/manual/heading-quality-manual.js +338 -0
  112. package/src/checks/manual/identical-links-same-purpose-manual.js +73 -12
  113. package/src/checks/manual/image-redundant-alt-manual.js +4 -4
  114. package/src/checks/manual/img-alt-decorative-manual.js +211 -52
  115. package/src/checks/manual/img-alt-quality-manual.js +7 -0
  116. package/src/checks/manual/input-image-alt-decorative-manual.js +8 -0
  117. package/src/checks/manual/input-image-alt-quality-manual.js +5 -0
  118. package/src/checks/manual/label-title-only-manual.js +4 -4
  119. package/src/checks/manual/landmark-banner-is-top-level-manual.js +7 -7
  120. package/src/checks/manual/landmark-complementary-is-top-level-manual.js +231 -0
  121. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +9 -9
  122. package/src/checks/manual/landmark-main-is-top-level-manual.js +6 -6
  123. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +6 -6
  124. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +6 -6
  125. package/src/checks/manual/landmark-no-duplicate-main-manual.js +2 -2
  126. package/src/checks/manual/landmark-one-main-manual.js +6 -6
  127. package/src/checks/manual/landmark-unique-manual.js +9 -9
  128. package/src/checks/manual/link-name-quality-manual.js +161 -32
  129. package/src/checks/manual/media-transcript-present-manual.js +2 -3
  130. package/src/checks/manual/meta-viewport-large-manual.js +2 -2
  131. package/src/checks/manual/mouse-only-event-handlers-manual.js +9 -9
  132. package/src/checks/manual/no-autoplay-audio-manual.js +3 -3
  133. package/src/checks/manual/object-text-alternative-quality-manual.js +8 -0
  134. package/src/checks/manual/p-as-heading-manual.js +4 -4
  135. package/src/checks/manual/page-has-heading-one-manual.js +6 -6
  136. package/src/checks/manual/page-title-patterns-manual.js +26 -3
  137. package/src/checks/manual/password-paste-enabled-manual.js +255 -0
  138. package/src/checks/manual/presentation-role-conflict-manual.js +55 -25
  139. package/src/checks/manual/region-manual.js +19 -19
  140. package/src/checks/manual/scope-attr-valid-manual.js +2 -2
  141. package/src/checks/manual/scrollable-region-focusable-manual.js +7 -7
  142. package/src/checks/manual/skip-link-manual.js +5 -5
  143. package/src/checks/manual/svg-text-alternative-quality-manual.js +9 -0
  144. package/src/checks/manual/tabindex-manual.js +2 -2
  145. package/src/checks/manual/table-duplicate-name-manual.js +2 -2
  146. package/src/checks/manual/table-fake-caption-manual.js +2 -2
  147. package/src/checks/manual/video-caption-manual.js +3 -3
  148. package/src/checks/manual-review.js +17 -1
  149. package/src/core.js +8880 -41883
  150. package/src/earl.js +144 -0
  151. package/src/report.js +2 -2
  152. package/src/sarif.js +22 -2
  153. package/surea11y.browser.js +10 -37882
  154. package/surea11y.i18n.de.js +2 -21
  155. package/surea11y.i18n.es.js +2 -21
  156. package/surea11y.i18n.fr.js +2 -21
  157. package/bin/surea11y-core.js +0 -20
@@ -9,11 +9,18 @@
9
9
  * @standard WCAG 2.2
10
10
  * @sc 1.3.1
11
11
  * @applicability
12
- * Applies to <td>/<th> elements that carry a non-empty headers attribute.
12
+ * Applies to <td>/<th> elements that carry a non-empty headers attribute,
13
+ * within a <table> whose semantic role is still table/grid/treegrid --
14
+ * an explicit role of anything else (role="presentation"/"none", but
15
+ * also role="heading" or any other real role) replaces the native table
16
+ * semantics, leaving no table for headers to describe. Matches ACT
17
+ * a25f45's applicability.
13
18
  * @expectation
14
19
  * Every id token in the headers attribute resolves to an element that:
15
- * (a) exists, (b) is a <th> element, (c) is inside the same <table> as
16
- * the referencing cell, and (d) is not the cell itself.
20
+ * (a) exists, (b) is a cell (<td> or <th>) of the same <table> as the
21
+ * referencing cell, and (c) is not the cell itself. A <td> serving as a
22
+ * header via role="columnheader"/"rowheader" is a valid target, same as
23
+ * a plain <th> -- ACT a25f45 does not require the native tag.
17
24
  * @implementation-notes
18
25
  * - One occurrence per offending cell (not per bad token), listing every
19
26
  * invalid reference.
@@ -27,7 +34,7 @@ const id = 'table-headers-attr-valid';
27
34
  const meta = {
28
35
  title: 'Table cell "headers" attribute must reference valid header cells',
29
36
  description:
30
- 'Checks that each id in a <td>/<th> headers attribute resolves to a <th> element within the same table (not missing, not a non-th element, not itself).',
37
+ 'Checks that each id in a <td>/<th> headers attribute resolves to a cell (<td> or <th>) within the same table (not missing, not a non-cell element, not itself).',
31
38
  i18n: {
32
39
  titleKey: 'tableHeadersAttrValid_title',
33
40
  descriptionKey: 'tableHeadersAttrValid_description'
@@ -54,6 +61,23 @@ const meta = {
54
61
  function runInPage(ctx) {
55
62
  const { document, helpers, rule } = ctx;
56
63
 
64
+ const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
65
+
66
+ // The role attribute holds a fallback list; the first token naming a real
67
+ // role wins, and unknown tokens are skipped over.
68
+ function getExplicitRole(el) {
69
+ const raw = el && el.getAttribute ? el.getAttribute('role') : null;
70
+ if (!raw) return '';
71
+ const tokens = String(raw).trim().toLowerCase().split(/\s+/);
72
+ for (const token of tokens) {
73
+ if (!token) continue;
74
+ if (token === 'presentation' || token === 'none') return token;
75
+ const known = ariaHelpers ? ariaHelpers.isValidConcreteRole(token) : true;
76
+ if (known) return token;
77
+ }
78
+ return '';
79
+ }
80
+
57
81
  const nodes = helpers.queryAllSmart
58
82
  ? helpers.queryAllSmart('td[headers], th[headers]')
59
83
  : helpers.queryAll('td[headers], th[headers]');
@@ -69,9 +93,19 @@ function runInPage(ctx) {
69
93
  const ids = raw.split(/\s+/).filter(Boolean);
70
94
  if (!ids.length) continue;
71
95
 
96
+ const table = el.closest ? el.closest('table') : null;
97
+
98
+ // An explicit role on the <table> replaces its native table role. Only
99
+ // the three roles that still describe a table keep the cell's headers
100
+ // attribute meaningful; anything else (presentation/none, heading, ...)
101
+ // takes the whole table out of scope. Unknown tokens name no role, so
102
+ // the native one stands.
103
+ const tableRole = table ? getExplicitRole(table) : '';
104
+ if (tableRole && tableRole !== 'table' && tableRole !== 'grid' && tableRole !== 'treegrid')
105
+ continue;
106
+
72
107
  applicableCount += 1;
73
108
 
74
- const table = el.closest ? el.closest('table') : null;
75
109
  const invalid = [];
76
110
 
77
111
  for (const headerId of ids) {
@@ -90,8 +124,9 @@ function runInPage(ctx) {
90
124
  invalid.push({ id: headerId, reason: 'self-reference' });
91
125
  continue;
92
126
  }
93
- if (!ref.tagName || ref.tagName.toLowerCase() !== 'th') {
94
- invalid.push({ id: headerId, reason: 'not-a-th' });
127
+ const refTag = ref.tagName ? ref.tagName.toLowerCase() : '';
128
+ if (refTag !== 'th' && refTag !== 'td') {
129
+ invalid.push({ id: headerId, reason: 'not-a-cell' });
95
130
  continue;
96
131
  }
97
132
  if (table && (!ref.closest || ref.closest('table') !== table)) {
@@ -108,7 +143,7 @@ function runInPage(ctx) {
108
143
  occurrences.push(
109
144
  helpers.reportOccurrence(el, {
110
145
  summary: 'This cell’s headers attribute references one or more invalid header cells.',
111
- hint: 'Update the headers attribute so every id refers to a <th> element within the same table.',
146
+ hint: 'Update the headers attribute so every id refers to a cell (<td> or <th>) within the same table.',
112
147
  i18n: {
113
148
  summaryKey: 'tableHeadersAttrValid_summary_fail',
114
149
  hintKey: 'tableHeadersAttrValid_hint_fail',
@@ -12,26 +12,29 @@
12
12
  * Applies to <table> elements that keep their table semantics and are included
13
13
  * in the accessibility tree, and that contain at least one <th> which is
14
14
  * visible, included in the accessibility tree, and not overridden by an
15
- * explicit role other than rowheader/columnheader.
15
+ * explicit role other than rowheader/columnheader. Also applies to the
16
+ * ARIA-only equivalent: an element with role="grid"/"treegrid" (no
17
+ * native <table> involved) that contains at least one in-scope
18
+ * columnheader/rowheader-role element.
16
19
  * @expectation
17
20
  * The table also contains at least one <td> somewhere in it.
18
21
  * @implementation-notes
19
- * - DELIBERATELY SCOPED (see aria-helpers.js file header for the same
22
+ * - SCOPED (see aria-helpers.js file header for the same
20
23
  * conservative-scope rationale used throughout this engine): this rule
21
24
  * does NOT implement the full HTML5 header-association algorithm
22
25
  * (resolving which specific data cells a given <th scope="row"|"col">
23
26
  * describes, accounting for colspan/rowspan and default-scope
24
27
  * inference). That algorithm is one of the more error-prone parts of
25
28
  * the HTML spec to reimplement correctly, and a wrong positional match
26
- * would risk a false `fail` — unacceptable for this engine's fail-
29
+ * would risk a false `fail`, unacceptable for this engine's fail-
27
30
  * integrity bar.
28
31
  * - Instead, this rule only catches the single unambiguous case: a table
29
32
  * that has <th> elements but ZERO <td> elements anywhere. In that case
30
- * every <th> in the table trivially describes no data cell — no
33
+ * every <th> in the table trivially describes no data cell, no
31
34
  * positional analysis is needed to know that. A table that has at
32
35
  * least one <td> somewhere is not evaluated further by this rule, even
33
36
  * if some particular <th> in it doesn't actually describe any cell
34
- * (false negative, not a false positive — acceptable under this
37
+ * (false negative, not a false positive, acceptable under this
35
38
  * engine's philosophy).
36
39
  * - Applicability IS gated, because the zero-<td> case above turns any
37
40
  * over-broad scope straight into a false fail: a layout table marked
@@ -174,6 +177,59 @@ function runInPage(ctx) {
174
177
  }
175
178
  }
176
179
 
180
+ // ARIA-only equivalent: a role="grid"/"treegrid" container with no
181
+ // backing <table> at all. Same trivial "zero data cells anywhere" check,
182
+ // just keyed off ARIA roles instead of native tags -- a genuine
183
+ // columnheader/rowheader with zero gridcell/cell-role elements anywhere
184
+ // in the container is exactly as unambiguous as a <th>-only <table>.
185
+ const grids = helpers.queryAllSmart
186
+ ? helpers.queryAllSmart('[role="grid"], [role="treegrid"]')
187
+ : helpers.queryAll('[role="grid"], [role="treegrid"]');
188
+
189
+ for (const grid of grids) {
190
+ if (!grid || !grid.querySelectorAll) continue;
191
+ if (grid.tagName && grid.tagName.toLowerCase() === 'table') continue; // already handled above
192
+ if (!isIncludedInTree(grid)) continue;
193
+
194
+ let headerNodes;
195
+ try {
196
+ headerNodes = grid.querySelectorAll('[role="columnheader"], [role="rowheader"]');
197
+ } catch {
198
+ headerNodes = [];
199
+ }
200
+ const headers = Array.from(headerNodes).filter(isHeaderCellInScope);
201
+ if (!headers.length) continue;
202
+
203
+ applicableCount += 1;
204
+
205
+ let hasDataCell;
206
+ try {
207
+ hasDataCell = grid.querySelectorAll('[role="gridcell"], [role="cell"]').length > 0;
208
+ } catch {
209
+ hasDataCell = false;
210
+ }
211
+ if (hasDataCell) continue;
212
+
213
+ for (const th of headers) {
214
+ if (!th) continue;
215
+
216
+ occurrences.push(
217
+ helpers.reportOccurrence(th, {
218
+ summary: 'This table has header cells but no data cells for them to describe.',
219
+ hint: 'Add data cells (<td>) to the table, or remove the header cells if the table has no data.',
220
+ i18n: {
221
+ summaryKey: 'tableThHasDataCells_summary_fail',
222
+ hintKey: 'tableThHasDataCells_hint_fail',
223
+ params: {}
224
+ },
225
+ data: {
226
+ details: { reasonCode: 'TABLE_TH_NO_DATA_CELLS' }
227
+ }
228
+ })
229
+ );
230
+ }
231
+ }
232
+
177
233
  if (applicableCount === 0) {
178
234
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
179
235
  }
@@ -8,6 +8,24 @@
8
8
  * @summary Pointer-operable targets should be at least 24×24 CSS px (or meet an exception)
9
9
  * @standard WCAG 2.2
10
10
  * @sc 2.5.8
11
+ * @applicability
12
+ * Applies to <button>, <summary>, <a href>, <area href>, <input>,
13
+ * <select>, <textarea> and elements with role="button"/"link" that are
14
+ * pointer-reachable: rendered, not suppressed by pointer-events:none, and
15
+ * with a measurable box of non-zero size. Accessibility-tree exclusion isn't
16
+ * a filter here: an aria-hidden control is still a target a pointer can hit.
17
+ * <area> is matched but never actually evaluated, for the reason given below.
18
+ * @expectation
19
+ * Each target is at least 24 by 24 CSS pixels, or meets one of the SC
20
+ * 2.5.8 exceptions this rule can establish from geometry: spacing (a
21
+ * 24px-diameter circle centred on the target reaches no unrelated target),
22
+ * the inline exception for a link inside a run of text, or user-agent
23
+ * sizing (an unstyled native checkbox or radio, detected by appearance not
24
+ * having been reset to none). An undersized target too close to a
25
+ * neighbour fails. Where an exception may apply but geometry cannot
26
+ * confirm it (two inline links in one run of text, or a target inside an
27
+ * SVG, canvas or image map that may be essential), the result is cantTell
28
+ * rather than a guess.
11
29
  *
12
30
  * Notes (engine intent):
13
31
  * - This rule is DOM-based and measures pointer hit regions available to sighted pointer users.
@@ -19,42 +37,43 @@
19
37
  * - Spacing: a 24px-diameter circle centered on an undersized target must not
20
38
  * intersect another (unrelated) target's box or another undersized
21
39
  * target's own circle. Two passes: a fast center-distance check (exact for
22
- * undersized-vs-undersized; a reasonable proxy otherwise) and a 16-point
40
+ * undersized-vs-undersized, a reasonable proxy otherwise) and a 16-point
23
41
  * perimeter sample via elementFromPoint as a more precise fallback for
24
42
  * cases the distance check under-detects (e.g. a small target adjacent to
25
43
  * a large, elongated neighbor). Ancestor/descendant relationships between
26
- * the target and the "other" element are never treated as a conflict —
27
- * see isRelated — since a nested-interactive shape (a small control inside
28
- * its own wrapping link/button) is one visual region, not two independent
29
- * targets; that pattern is nested-interactive-controls-absent's
30
- * concern, not a spacing one.
44
+ * the target and the "other" element are never treated as a conflict (see
45
+ * isRelated): a nested-interactive shape, a small control inside its own
46
+ * wrapping link/button, is one visual region, not two independent targets.
47
+ * That pattern is nested-interactive-controls-absent's concern, not a
48
+ * spacing one.
31
49
  * - Inline: a link inside a text-block container passes outright
32
50
  * (isInlineTextExceptionTarget). An inline link whose only spacing conflict
33
51
  * is another inline link in the same run is reported as cantTell
34
- * (isInlineLinkTarget) — the inline exception may cover it, but geometry
35
- * can't confirm that.
52
+ * (isInlineLinkTarget), since the inline exception may cover it but
53
+ * geometry can't confirm that.
36
54
  * - User Agent Control: an unstyled native checkbox/radio, detected via
37
- * `appearance` not being reset to `none` (see isUserAgentSizedControl) —
38
- * scoped narrowly to checkbox/radio specifically, not every form control,
55
+ * `appearance` not being reset to `none` (see isUserAgentSizedControl).
56
+ * Scoped narrowly to checkbox/radio specifically, not every form control,
39
57
  * since those are the only types with unambiguous native rendering.
40
58
  * - Essential/Equivalent: only a narrow, high-confidence subset is asserted
41
- * (SVG/canvas/map-embedded controls) — see isPlausiblyEssentialOrEquivalent;
59
+ * (SVG/canvas/map-embedded controls, see isPlausiblyEssentialOrEquivalent);
42
60
  * anything else defers to cantTell rather than guessing "essential" from a
43
61
  * layout container.
44
62
  *
45
- * Known, deliberately unimplemented gap: `<area>` (image-map hotspot)
63
+ * Known gap, left unimplemented on purpose: `<area>` (image-map hotspot)
46
64
  * elements are not evaluated at all. `area[href]` is in CANDIDATE_SELECTOR
47
- * for forward-compatibility, but it's currently a no-op: `<area>` has no
65
+ * for forward-compatibility, but it's currently a no-op. `<area>` has no
48
66
  * CSS box of its own (`display: none` by the HTML spec's default UA
49
- * stylesheet — verified, not a jsdom quirk), so `getBoundingClientRect()`
50
- * always reports zero geometry and `isPointerReachable`'s existing
51
- * `display:none` check rejects it before any size/exception logic runs. A
52
- * real `<area>` hit-region is computed by the browser from its `shape`/
53
- * `coords` attributes against the associated `<img>`'s *rendered* size —
54
- * an entirely different measurement path than every other candidate here.
55
- * Implementing that properly (parsing `coords`, resolving the owning
56
- * `<img>` via its `usemap`, accounting for the image's CSS-scaled render
57
- * size) is a separate, larger feature, not attempted in this pass.
67
+ * stylesheet, confirmed against the spec rather than a jsdom quirk), so
68
+ * `getBoundingClientRect()` always reports zero geometry and
69
+ * `isPointerReachable`'s existing `display:none` check rejects it before any
70
+ * size/exception logic runs. A real `<area>` hit-region is computed by the
71
+ * browser from its `shape`/`coords` attributes against the associated
72
+ * `<img>`'s *rendered* size, an entirely different measurement path than
73
+ * every other candidate here. Implementing that properly (parsing `coords`,
74
+ * resolving the owning `<img>` via its `usemap`, accounting for the image's
75
+ * CSS-scaled render size) is a separate, larger feature, not attempted in
76
+ * this pass.
58
77
  *
59
78
  * This is an automatic, deterministic approximation intended to be:
60
79
  * - strict on clear failures,
@@ -64,7 +83,8 @@
64
83
  const id = 'target-size-minimum';
65
84
 
66
85
  const meta = {
67
- title: 'Pointer targets meet minimum size (AA)',
86
+ title:
87
+ 'Pointer targets must be at least 24x24px large, or leave sufficient distance to other targets',
68
88
  description:
69
89
  'Checks that pointer-operable targets have an effective hit region of at least 24 by 24 CSS pixels, or meet an allowed exception (e.g. sufficient spacing).',
70
90
  i18n: {
@@ -273,14 +293,29 @@ function runInPage(ctx) {
273
293
  }
274
294
  }
275
295
 
296
+ // Per-run memoization: getComputedStyle is a real, non-trivial cost in an
297
+ // actual browser (unlike jsdom, which no-ops it), and hasSpacingConflict
298
+ // below calls getStyle on the SAME candidate element repeatedly -- once
299
+ // per undersized target it's compared against, via
300
+ // isInlineTextExceptionTarget/isInlineLinkTarget -- so with U undersized
301
+ // targets and N total candidates this was an uncached O(U * N)
302
+ // getComputedStyle call count. The DOM is read-only for the rest of this
303
+ // rule's run (no writes between reads), so caching per element here is
304
+ // safe -- style cannot change mid-run.
305
+ const __styleCache = new WeakMap();
276
306
  function getStyle(el) {
307
+ if (__styleCache.has(el)) return __styleCache.get(el);
308
+ let cs;
277
309
  try {
278
- return document && document.defaultView && document.defaultView.getComputedStyle
279
- ? document.defaultView.getComputedStyle(el)
280
- : null;
310
+ cs =
311
+ document && document.defaultView && document.defaultView.getComputedStyle
312
+ ? document.defaultView.getComputedStyle(el)
313
+ : null;
281
314
  } catch {
282
- return null;
315
+ cs = null;
283
316
  }
317
+ __styleCache.set(el, cs);
318
+ return cs;
284
319
  }
285
320
 
286
321
  function isPointerReachable(el) {
@@ -354,11 +389,11 @@ function runInPage(ctx) {
354
389
  // element, or either one is an ancestor of the other. A nested-interactive
355
390
  // pattern (e.g. a small <button> inside a wrapping <a href>, or vice
356
391
  // versa) is a single visual/interactive region, not two independently
357
- // placed targets — the spacing exception's "does the circle intersect
392
+ // placed targets. The spacing exception's "does the circle intersect
358
393
  // ANOTHER target" language is about separate targets, not an element and
359
394
  // its own container. (Nested interactive controls are their own,
360
- // separately-flagged anti-pattern — nested-interactive-controls-
361
- // absent — not a target-size spacing concern.)
395
+ // separately-flagged anti-pattern, nested-interactive-controls-absent,
396
+ // not a target-size spacing concern.)
362
397
  function isRelated(a, b) {
363
398
  try {
364
399
  if (!a || !b) return false;
@@ -411,14 +446,54 @@ function runInPage(ctx) {
411
446
 
412
447
  const undersized = items.filter((it) => it.rect.width < MIN || it.rect.height < MIN);
413
448
 
449
+ // Spatial grid over ALL items, cell size = MIN (24px), so hasSpacingConflict's
450
+ // proximity check below doesn't have to compare every undersized target
451
+ // against every other item -- an O(items^2) cost that dominates real-browser
452
+ // (not jsdom -- see this rule's own perf note further down) runtime on a
453
+ // page with many small targets. A point can only be within MIN of another
454
+ // point that shares its grid cell or one of the 8 adjacent cells: cell size
455
+ // equals the search radius, so two points in cells 2+ apart on either axis
456
+ // are, on that axis alone, already >= MIN apart. Restricting the candidate
457
+ // set to that 3x3 neighborhood is therefore never a false negative -- the
458
+ // exact same dist() < MIN check still runs on every candidate it returns,
459
+ // just skipping candidates that are geometrically guaranteed too far away.
460
+ // This changes performance only, never which targets conflict.
461
+ const grid = new Map(); // "cx,cy" -> item[]
462
+ function cellKeyFor(cx, cy) {
463
+ return cx + ',' + cy;
464
+ }
465
+ for (const it of items) {
466
+ const cx = Math.floor(it.center.cx / MIN);
467
+ const cy = Math.floor(it.center.cy / MIN);
468
+ const key = cellKeyFor(cx, cy);
469
+ let bucket = grid.get(key);
470
+ if (!bucket) {
471
+ bucket = [];
472
+ grid.set(key, bucket);
473
+ }
474
+ bucket.push(it);
475
+ }
476
+ function nearbyItems(center) {
477
+ const cx = Math.floor(center.cx / MIN);
478
+ const cy = Math.floor(center.cy / MIN);
479
+ const out = [];
480
+ for (let dx = -1; dx <= 1; dx++) {
481
+ for (let dy = -1; dy <= 1; dy++) {
482
+ const bucket = grid.get(cellKeyFor(cx + dx, cy + dy));
483
+ if (bucket) for (const it of bucket) out.push(it);
484
+ }
485
+ }
486
+ return out;
487
+ }
488
+
414
489
  // --- spacing/occlusion evaluation ---
415
490
  function hasSpacingConflict(target) {
416
491
  // 0) Pure geometry: deterministic center-distance check against ANY
417
492
  // nearby target, not just other undersized ones. Per WCAG 2.5.8, the
418
- // spacing exception depends on proximity to any adjacent target — an
493
+ // spacing exception depends on proximity to any adjacent target, so an
419
494
  // undersized target sitting flush against an adequately-sized one still
420
495
  // fails the exception, which an undersized-only comparison would miss.
421
- for (const other of items) {
496
+ for (const other of nearbyItems(target.center)) {
422
497
  if (!other || !other.el || isRelated(target.el, other.el)) continue;
423
498
 
424
499
  // Ignore inline-text exception targets when evaluating spacing conflicts.
@@ -439,8 +514,8 @@ function runInPage(ctx) {
439
514
  // as a confident conflict. Perimeter sampling is an approximation
440
515
  // (rounded corners, border-radius, and sub-pixel geometry can shift a
441
516
  // sample point in or out of a neighboring element), so a result that
442
- // merely reaches HIT_THRESHOLD is not asserted as a deterministic
443
- // fail — see the ambiguous band below.
517
+ // merely reaches HIT_THRESHOLD is not asserted as a deterministic fail.
518
+ // See the ambiguous band below.
444
519
  const CONFIDENT_THRESHOLD = 5;
445
520
  let hitCount = 0;
446
521
  let firstConflictEl = null;
@@ -486,20 +561,20 @@ function runInPage(ctx) {
486
561
 
487
562
  // WCAG 2.5.8 "User Agent Control" exception: the target's size requirement
488
563
  // does not apply at all when its size is determined by the user agent and
489
- // not modified by the author — the canonical example being an unstyled
490
- // native checkbox/radio (browsers render these well under 24px by
491
- // default, and that's not the author's choice). Scoped narrowly to
492
- // input[type=checkbox]/[type=radio] specifically (the only form-control
493
- // types with a universally-recognized, unambiguous native rendering) —
494
- // deliberately not extended to select/range/color/file, whose "default"
495
- // sizing varies enough across browsers/OSes that a wrong exemption there
496
- // risks masking a real author-introduced undersized target.
564
+ // not modified by the author. The canonical example is an unstyled native
565
+ // checkbox/radio (browsers render these well under 24px by default, and
566
+ // that's not the author's choice). Scoped narrowly to
567
+ // input[type=checkbox]/[type=radio] specifically, the only form-control
568
+ // types with a universally-recognized, unambiguous native rendering, and
569
+ // not extended to select/range/color/file, whose "default" sizing varies
570
+ // enough across browsers/OSes that a wrong exemption there risks masking a
571
+ // real author-introduced undersized target.
497
572
  //
498
573
  // Detection signal: `appearance` (or the legacy `-webkit-appearance`)
499
574
  // computed as `none` is the near-universal first step of custom
500
- // checkbox/radio styling across every CSS framework/design system —
501
- // if the author hasn't reset it, the browser is still rendering its own
502
- // default control chrome, so the size is genuinely UA-determined.
575
+ // checkbox/radio styling across every CSS framework/design system. If the
576
+ // author hasn't reset it, the browser is still rendering its own default
577
+ // control chrome, so the size is UA-determined rather than authored.
503
578
  function isUserAgentSizedControl(el) {
504
579
  try {
505
580
  if (!el || el.nodeType !== 1) return false;
@@ -545,8 +620,8 @@ function runInPage(ctx) {
545
620
  // Image map targets are often constrained by the underlying image.
546
621
  // Currently unreachable in practice: <area> never becomes a
547
622
  // measurable candidate at all (see the file header's "Known,
548
- // deliberately unimplemented gap" note) — kept for forward
549
- // compatibility if that gap is closed later.
623
+ // unimplemented gap" note). Kept for forward compatibility if that
624
+ // gap is closed later.
550
625
  if (tag === 'area') return true;
551
626
 
552
627
  // Graphics / spatial interaction regions are commonly essential by design.
@@ -570,7 +645,7 @@ function runInPage(ctx) {
570
645
 
571
646
  // User Agent Control exception: size isn't the author's choice, so the
572
647
  // size requirement (and therefore any spacing conflict stemming from
573
- // it) doesn't apply at all — skip straight to pass, no need to even
648
+ // it) doesn't apply at all. Skip straight to pass, no need to even
574
649
  // evaluate spacing.
575
650
  if (isUserAgentSizedControl(it.el)) {
576
651
  continue;
@@ -579,12 +654,10 @@ function runInPage(ctx) {
579
654
  const info = hasSpacingConflict(it);
580
655
 
581
656
  if (!info.conflict && info.confident === false) {
582
- // Ambiguous perimeter-sampling result near the decision threshold —
583
- // previously recorded only as a page-level boolean with no per-target
584
- // occurrence at all, so this specific target was unrecoverable from
585
- // the result once any other target on the page had a confident
586
- // fail (see helpers.resolveTieredOutcome's header comment). Now
587
- // reported as its own cantTell-tier occurrence instead.
657
+ // Ambiguous perimeter-sampling result near the decision threshold: report
658
+ // it as its own cantTell-tier occurrence for this target so it isn't lost
659
+ // once any other target on the page has a confident fail (see
660
+ // helpers.resolveTieredOutcome's header comment).
588
661
  cantTellOccurrences.push(
589
662
  helpers.reportOccurrence(it.el, {
590
663
  occurrenceOutcome: 'cantTell',
@@ -596,6 +669,14 @@ function runInPage(ctx) {
596
669
  hintKey: 'targetSizeMinimum_hint_cantTell_ambiguousSpacing',
597
670
  params: {}
598
671
  },
672
+ uncertainty: {
673
+ code: 'not-computable',
674
+ needed: 'A reliable measurement of the spacing between this target and its neighbour.',
675
+ evidence: {
676
+ measured: { width: it.rect.width, height: it.rect.height },
677
+ conflictHitCount: info.hitCount
678
+ }
679
+ },
599
680
  data: {
600
681
  details: {
601
682
  measured: { width: it.rect.width, height: it.rect.height },
@@ -612,19 +693,27 @@ function runInPage(ctx) {
612
693
  if (info.conflict) {
613
694
  if (isPlausiblyEssentialOrEquivalent(it.el)) {
614
695
  // Confident spacing conflict, but the target may be exempt as part
615
- // of an essential graphic/image-map region — same "previously
616
- // unrecoverable" gap as above, now reported instead of dropped.
696
+ // of an essential graphic/image-map region, so report it as
697
+ // cantTell rather than dropping it.
617
698
  cantTellOccurrences.push(
618
699
  helpers.reportOccurrence(it.el, {
619
700
  occurrenceOutcome: 'cantTell',
620
701
  summary:
621
702
  'Target is too small and too close to another target, but may be exempt as part of an essential graphic or image-map region.',
622
- hint: 'Verify whether this target’s size is genuinely essential to its function (e.g. part of an SVG/canvas/image map); if not, increase target size or spacing.',
703
+ hint: 'Verify whether this target’s size is essential to its function (e.g. part of an SVG/canvas/image map); if not, increase target size or spacing.',
623
704
  i18n: {
624
705
  summaryKey: 'targetSizeMinimum_summary_cantTell_plausiblyEssential',
625
706
  hintKey: 'targetSizeMinimum_hint_cantTell_plausiblyEssential',
626
707
  params: {}
627
708
  },
709
+ uncertainty: {
710
+ code: 'judgement-required',
711
+ needed: 'Whether this target’s size is essential, which WCAG exempts.',
712
+ evidence: {
713
+ measured: { width: it.rect.width, height: it.rect.height },
714
+ conflictHitCount: info.hitCount
715
+ }
716
+ },
628
717
  data: {
629
718
  details: {
630
719
  measured: { width: it.rect.width, height: it.rect.height },
@@ -653,6 +742,14 @@ function runInPage(ctx) {
653
742
  hintKey: 'targetSizeMinimum_hint_cantTell_inlineLinkRun',
654
743
  params: {}
655
744
  },
745
+ uncertainty: {
746
+ code: 'judgement-required',
747
+ needed: 'Whether this target is a link in a sentence, which WCAG exempts.',
748
+ evidence: {
749
+ measured: { width: it.rect.width, height: it.rect.height },
750
+ conflictHitCount: info.hitCount
751
+ }
752
+ },
656
753
  data: {
657
754
  details: {
658
755
  measured: { width: it.rect.width, height: it.rect.height },
@@ -691,7 +788,7 @@ function runInPage(ctx) {
691
788
 
692
789
  // See helpers.resolveTieredOutcome's own header comment (src/core/dom-helpers.js):
693
790
  // a fail-tier finding never silently discards cantTell-tier findings from
694
- // the same run — both are returned together when the outcome is 'fail'.
791
+ // the same run. Both are returned together when the outcome is 'fail'.
695
792
  const resolved = helpers.resolveTieredOutcome(
696
793
  failOccurrences,
697
794
  cantTellOccurrences,
@@ -11,7 +11,7 @@
11
11
  * @applicability
12
12
  * `<table>` elements with at least 4 rows and at least 4 columns
13
13
  * (a "large" table, where implicit row/column header association is
14
- * genuinely useful — small tables are usually self-evident), and with
14
+ * useful; small tables are usually self-evident), and with
15
15
  * NO `colspan`/`rowspan` anywhere in the table.
16
16
  * @expectation
17
17
  * Every `<td>` has an associated header, via one of:
@@ -22,14 +22,14 @@
22
22
  * earlier row, OR
23
23
  * - an implicit row header: some `<th>` earlier in the same row.
24
24
  * @implementation-notes
25
- * - Closes the gap `table-th-has-data-cells` deliberately deferred (see
25
+ * - Closes the gap `table-th-has-data-cells` deferred (see
26
26
  * that rule's own implementation notes): this is the fuller positional
27
- * header-association algorithm, but still intentionally scoped —
27
+ * header-association algorithm, but still intentionally scoped,
28
28
  * tables with any `colspan`/`rowspan` are skipped entirely (marked
29
29
  * `notApplicable`) rather than risk a wrong column-index computation
30
30
  * producing a false `fail`.
31
31
  * - The `headers`-attribute branch does not itself validate that the
32
- * referenced ids exist or point at `<th>` elements — that's already
32
+ * referenced ids exist or point at `<th>` elements, that's already
33
33
  * `table-headers-attr-valid`'s job.
34
34
  */
35
35
 
@@ -38,7 +38,7 @@ const id = 'td-has-header';
38
38
  const meta = {
39
39
  title: 'Data cells in large tables must have an associated header',
40
40
  description:
41
- 'Checks that every <td> in a large, simple (no colspan/rowspan) table has an associated header — via a headers attribute, an implicit column <th> above it, or an implicit row <th> to its left.',
41
+ 'Checks that every <td> in a large, simple (no colspan/rowspan) table has an associated header, via a headers attribute, an implicit column <th> above it, or an implicit row <th> to its left.',
42
42
  i18n: {
43
43
  titleKey: 'tdHasHeader_title',
44
44
  descriptionKey: 'tdHasHeader_description'
@@ -118,27 +118,28 @@ function runInPage(ctx) {
118
118
  return !!(cell && cell.tagName && cell.tagName.toLowerCase() === 'th' && isEligible(cell));
119
119
  }
120
120
 
121
- function hasColumnHeaderAbove(r, c) {
122
- for (let ri = 0; ri < r; ri++) {
123
- const cell = rowCells[ri] && rowCells[ri][c];
124
- if (isHeaderCell(cell)) return true;
125
- }
126
- return false;
127
- }
128
-
129
- function hasRowHeaderBefore(r, c) {
130
- const cells = rowCells[r] || [];
131
- for (let ci = 0; ci < c; ci++) {
132
- if (isHeaderCell(cells[ci])) return true;
133
- }
134
- return false;
135
- }
121
+ // "Was there a <th> above this cell's column" and "was there a <th>
122
+ // earlier in this cell's row" are both prefix questions over a scan
123
+ // already in progress (rows top to bottom, cells left to right within a
124
+ // row), so each is tracked incrementally instead of rescanning the
125
+ // rows/columns already passed for every cell: colHasHeaderAbove[c]
126
+ // carries forward across rows, rowHasHeaderBefore resets at the start
127
+ // of each row. One pass over every cell, not one rescan per cell.
128
+ const colHasHeaderAbove = new Array(maxCols).fill(false);
136
129
 
137
130
  for (let r = 0; r < rowCells.length; r++) {
138
131
  const cells = rowCells[r];
132
+ let rowHasHeaderBefore = false;
133
+
139
134
  for (let c = 0; c < cells.length; c++) {
140
135
  const cell = cells[c];
141
- if (!cell || isHeaderCell(cell)) continue;
136
+ if (!cell) continue;
137
+
138
+ if (isHeaderCell(cell)) {
139
+ colHasHeaderAbove[c] = true;
140
+ rowHasHeaderBefore = true;
141
+ continue;
142
+ }
142
143
 
143
144
  // An aria-hidden data cell isn't exposed to AT either, so it has
144
145
  // no need for an accessible header association.
@@ -147,8 +148,8 @@ function runInPage(ctx) {
147
148
  const headersAttr = trim(cell.getAttribute('headers'));
148
149
  if (headersAttr) continue;
149
150
 
150
- if (hasColumnHeaderAbove(r, c)) continue;
151
- if (hasRowHeaderBefore(r, c)) continue;
151
+ if (colHasHeaderAbove[c]) continue;
152
+ if (rowHasHeaderBefore) continue;
152
153
 
153
154
  occurrences.push(
154
155
  helpers.reportOccurrence(cell, {