@surea11y/core 1.1.2 → 1.3.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 (160) hide show
  1. package/CHANGELOG.md +48 -4
  2. package/LICENSE +373 -21
  3. package/README.md +70 -1
  4. package/bin/core.js +240 -11
  5. package/docs/API_STABILITY.md +61 -0
  6. package/docs/BASELINE.md +66 -0
  7. package/docs/BINDING_AUTHORS_GUIDE.md +9 -9
  8. package/docs/CI_INTEGRATIONS.md +103 -0
  9. package/docs/CLI.md +80 -1
  10. package/docs/ENGINE_OPTIONS.md +4 -0
  11. package/docs/INTEGRATION.md +19 -1
  12. package/docs/OUTPUT_SCHEMA.md +2 -2
  13. package/docs/REPORT.md +33 -0
  14. package/docs/RULE_AUTHORING.md +31 -0
  15. package/docs/RULE_CATALOG.md +1 -1
  16. package/docs/SARIF.md +59 -0
  17. package/package.json +15 -4
  18. package/src/baseline.js +0 -0
  19. package/src/catalogs/composites.wcag.js +414 -450
  20. package/src/checks/automatic/area-alt-present.js +59 -25
  21. package/src/checks/automatic/aria-allowed-attr.js +193 -33
  22. package/src/checks/automatic/aria-allowed-role.js +21 -7
  23. package/src/checks/automatic/aria-braille-equivalent.js +32 -10
  24. package/src/checks/automatic/aria-conditional-attr.js +24 -7
  25. package/src/checks/automatic/aria-deprecated-role.js +22 -8
  26. package/src/checks/automatic/aria-hidden-body.js +50 -19
  27. package/src/checks/automatic/aria-hidden-focus.js +408 -53
  28. package/src/checks/automatic/aria-prohibited-attr.js +296 -22
  29. package/src/checks/automatic/aria-prohibited-children.js +125 -29
  30. package/src/checks/automatic/aria-required-attr.js +23 -8
  31. package/src/checks/automatic/aria-required-children.js +37 -14
  32. package/src/checks/automatic/aria-required-parent.js +48 -14
  33. package/src/checks/automatic/aria-role-name-present.js +47 -21
  34. package/src/checks/automatic/aria-roles-valid.js +22 -12
  35. package/src/checks/automatic/aria-valid-attr-value.js +28 -7
  36. package/src/checks/automatic/aria-valid-attr.js +17 -5
  37. package/src/checks/automatic/autocomplete-valid.js +74 -16
  38. package/src/checks/automatic/avoid-inline-spacing.js +20 -6
  39. package/src/checks/automatic/binary-control-name-present.js +60 -50
  40. package/src/checks/automatic/button-name-present.js +48 -18
  41. package/src/checks/automatic/bypass-blocks-present.js +50 -25
  42. package/src/checks/automatic/canvas-text-alternative-present.js +57 -26
  43. package/src/checks/automatic/combobox-name-present.js +38 -45
  44. package/src/checks/automatic/contrast-computable.js +361 -341
  45. package/src/checks/automatic/contrast-enhanced.js +487 -466
  46. package/src/checks/automatic/contrast-minimum.js +486 -465
  47. package/src/checks/automatic/css-orientation-lock.js +39 -9
  48. package/src/checks/automatic/definition-list-children-valid.js +40 -19
  49. package/src/checks/automatic/deprecated-elements-not-used.js +21 -7
  50. package/src/checks/automatic/dialog-name-present.js +37 -75
  51. package/src/checks/automatic/dlitem-parent-valid.js +23 -8
  52. package/src/checks/automatic/duplicate-id-aria.js +24 -6
  53. package/src/checks/automatic/embed-text-alternative-present.js +86 -35
  54. package/src/checks/automatic/form-control-programmatic-label-present.js +79 -196
  55. package/src/checks/automatic/form-control-single-label.js +47 -10
  56. package/src/checks/automatic/html-xml-lang-mismatch.js +43 -19
  57. package/src/checks/automatic/iframe-focusable-content.js +26 -11
  58. package/src/checks/automatic/iframe-name-present.js +31 -9
  59. package/src/checks/automatic/iframe-title-unique.js +29 -8
  60. package/src/checks/automatic/img-alt-present.js +47 -43
  61. package/src/checks/automatic/input-image-alt-present.js +143 -112
  62. package/src/checks/automatic/label-in-name.js +50 -22
  63. package/src/checks/automatic/language-page-present.js +117 -109
  64. package/src/checks/automatic/link-in-text-block.js +59 -19
  65. package/src/checks/automatic/link-name-present.js +45 -14
  66. package/src/checks/automatic/list-children-valid.js +26 -9
  67. package/src/checks/automatic/listbox-name-present.js +39 -19
  68. package/src/checks/automatic/listitem-parent-valid.js +18 -6
  69. package/src/checks/automatic/menuitem-name-present.js +39 -61
  70. package/src/checks/automatic/meta-refresh-no-exceptions.js +37 -8
  71. package/src/checks/automatic/meta-refresh-timing-absent.js +28 -6
  72. package/src/checks/automatic/meta-viewport-zoom-enabled.js +32 -7
  73. package/src/checks/automatic/meter-name-present.js +36 -33
  74. package/src/checks/automatic/nested-interactive-controls-absent.js +31 -10
  75. package/src/checks/automatic/object-text-alternative-present.js +91 -39
  76. package/src/checks/automatic/option-name-present.js +38 -21
  77. package/src/checks/automatic/page-title-present.js +26 -7
  78. package/src/checks/automatic/progressbar-name-present.js +41 -34
  79. package/src/checks/automatic/role-img-alt-present.js +209 -157
  80. package/src/checks/automatic/searchbox-name-present.js +39 -19
  81. package/src/checks/automatic/server-side-image-map-absent.js +23 -8
  82. package/src/checks/automatic/slider-name-present.js +40 -47
  83. package/src/checks/automatic/spinbutton-name-present.js +39 -19
  84. package/src/checks/automatic/summary-name-present.js +37 -17
  85. package/src/checks/automatic/svg-image-text-alternative-present.js +114 -47
  86. package/src/checks/automatic/svg-text-alternative-present.js +246 -226
  87. package/src/checks/automatic/tab-name-present.js +37 -60
  88. package/src/checks/automatic/table-headers-attr-valid.js +24 -8
  89. package/src/checks/automatic/table-th-has-data-cells.js +22 -8
  90. package/src/checks/automatic/target-size-minimum.js +118 -48
  91. package/src/checks/automatic/td-has-header.js +29 -11
  92. package/src/checks/automatic/textbox-name-present.js +39 -19
  93. package/src/checks/automatic/tooltip-name-present.js +37 -18
  94. package/src/checks/automatic/treeitem-name-present.js +38 -21
  95. package/src/checks/automatic/valid-lang.js +20 -6
  96. package/src/checks/automatic/video-poster-text-alternative-present.js +79 -36
  97. package/src/checks/manual/accesskeys-manual.js +14 -5
  98. package/src/checks/manual/area-alt-decorative-manual.js +192 -193
  99. package/src/checks/manual/area-alt-quality-manual.js +182 -141
  100. package/src/checks/manual/aria-checked-state-mismatch-manual.js +34 -11
  101. package/src/checks/manual/aria-text-manual.js +14 -6
  102. package/src/checks/manual/canvas-text-alternative-quality-manual.js +149 -114
  103. package/src/checks/manual/css-hidden-focus.js +196 -165
  104. package/src/checks/manual/embed-text-alternative-quality-manual.js +171 -160
  105. package/src/checks/manual/empty-heading-manual.js +24 -12
  106. package/src/checks/manual/empty-table-header-manual.js +21 -14
  107. package/src/checks/manual/focus-order-semantics-manual.js +45 -10
  108. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +207 -246
  109. package/src/checks/manual/heading-order-manual.js +22 -12
  110. package/src/checks/manual/identical-links-same-purpose-manual.js +34 -12
  111. package/src/checks/manual/image-redundant-alt-manual.js +17 -7
  112. package/src/checks/manual/img-alt-decorative-manual.js +131 -96
  113. package/src/checks/manual/img-alt-quality-manual.js +176 -127
  114. package/src/checks/manual/input-image-alt-decorative-manual.js +125 -92
  115. package/src/checks/manual/input-image-alt-quality-manual.js +125 -92
  116. package/src/checks/manual/label-title-only-manual.js +14 -5
  117. package/src/checks/manual/landmark-banner-is-top-level-manual.js +95 -29
  118. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +82 -29
  119. package/src/checks/manual/landmark-main-is-top-level-manual.js +64 -23
  120. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +54 -23
  121. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +54 -23
  122. package/src/checks/manual/landmark-no-duplicate-main-manual.js +17 -8
  123. package/src/checks/manual/landmark-one-main-manual.js +35 -21
  124. package/src/checks/manual/landmark-unique-manual.js +73 -45
  125. package/src/checks/manual/link-name-quality-manual.js +43 -12
  126. package/src/checks/manual/media-transcript-present-manual.js +35 -22
  127. package/src/checks/manual/meta-viewport-large-manual.js +25 -6
  128. package/src/checks/manual/mouse-only-event-handlers-manual.js +38 -11
  129. package/src/checks/manual/no-autoplay-audio-manual.js +20 -6
  130. package/src/checks/manual/object-text-alternative-quality-manual.js +175 -154
  131. package/src/checks/manual/p-as-heading-manual.js +22 -7
  132. package/src/checks/manual/page-has-heading-one-manual.js +40 -23
  133. package/src/checks/manual/page-title-patterns-manual.js +87 -51
  134. package/src/checks/manual/presentation-role-conflict-manual.js +59 -19
  135. package/src/checks/manual/region-manual.js +275 -62
  136. package/src/checks/manual/scope-attr-valid-manual.js +11 -9
  137. package/src/checks/manual/scrollable-region-focusable-manual.js +37 -11
  138. package/src/checks/manual/skip-link-manual.js +35 -12
  139. package/src/checks/manual/svg-text-alternative-quality-manual.js +206 -165
  140. package/src/checks/manual/tabindex-manual.js +11 -9
  141. package/src/checks/manual/table-duplicate-name-manual.js +17 -7
  142. package/src/checks/manual/table-fake-caption-manual.js +24 -7
  143. package/src/checks/manual/video-caption-manual.js +15 -4
  144. package/src/checks/manual-review.js +56 -12
  145. package/src/core/aria-helpers.js +1128 -823
  146. package/src/core/contrast-helpers.js +1217 -1062
  147. package/src/core/dom-helpers.js +4176 -3886
  148. package/src/core/dom-runner.js +720 -596
  149. package/src/core/frame-messaging.js +189 -138
  150. package/src/core/frame-scan.js +94 -82
  151. package/src/core/rollup-composites.js +94 -102
  152. package/src/core/rule-meta.js +71 -35
  153. package/src/core.js +37939 -29121
  154. package/src/i18n/en.js +1194 -887
  155. package/src/i18n/fr.js +1136 -793
  156. package/src/policy/contracts.js +13 -13
  157. package/src/policy/resolvePolicy.js +48 -44
  158. package/src/report.js +502 -0
  159. package/src/sarif.js +175 -0
  160. package/surea11y.browser.js +36042 -0
@@ -10,10 +10,16 @@ const meta = {
10
10
  descriptionKey: 'tabNamePresent_description'
11
11
  },
12
12
  helpUrl: null,
13
- tags: ['wcag2a','wcag412','navigation','atomic','automatic','name','tab'],
13
+ tags: ['wcag2a', 'wcag412', 'navigation', 'atomic', 'automatic', 'name', 'tab'],
14
14
  wcagSc: ['4.1.2'],
15
15
  normativeMappings: [
16
- { standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
16
+ {
17
+ standard: 'WCAG',
18
+ version: '2.2',
19
+ requirement: '4.1.2',
20
+ title: 'Name, Role, Value',
21
+ conformanceLevel: 'A'
22
+ }
17
23
  ],
18
24
  defaultSeverity: 'serious',
19
25
  category: 'robust',
@@ -24,53 +30,22 @@ const meta = {
24
30
 
25
31
  function runInPage(ctx) {
26
32
  const { document, helpers, rule } = ctx;
27
- const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
28
- ? helpers.getEligibilityInfo
29
- : null;
30
-
33
+ const getEligibilityInfo =
34
+ helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
31
35
 
32
36
  function normalizeWs(s) {
33
- return String(s || '').replace(/\s+/g, ' ').trim();
37
+ return String(s || '')
38
+ .replace(/\s+/g, ' ')
39
+ .trim();
34
40
  }
35
41
 
36
42
  function getAttr(el, name) {
37
43
  try {
38
44
  if (!el || !el.getAttribute) return '';
39
45
  return normalizeWs(el.getAttribute(name));
40
- } catch { return ''; }
41
- }
42
-
43
- function hasAttr(el, name) {
44
- try { return !!(el && el.hasAttribute && el.hasAttribute(name)); } catch { return false; }
45
- }
46
-
47
- function isExplicitProgrammatic(el) {
48
- // Programmatic name mechanisms we treat as authoritative (presence-only):
49
- // - aria-label (non-empty)
50
- // - aria-labelledby (non-empty)
51
- // - title (non-empty) [weak but allowed for presence]
52
- const al = getAttr(el, 'aria-label');
53
- if (al) return true;
54
- const alb = getAttr(el, 'aria-labelledby');
55
- if (alb) return true;
56
- const t = getAttr(el, 'title');
57
- if (t) return true;
58
- return false;
59
- }
60
-
61
- function buildLabelForMap(doc) {
62
- const map = new Map(); // id -> label element (first)
63
- try {
64
- const labels = doc && doc.getElementsByTagName ? doc.getElementsByTagName('label') : [];
65
- for (let i = 0; i < labels.length; i += 1) {
66
- const lab = labels[i];
67
- if (!lab || !lab.getAttribute) continue;
68
- const f = normalizeWs(lab.getAttribute('for'));
69
- if (!f) continue;
70
- if (!map.has(f)) map.set(f, lab);
71
- }
72
- } catch {}
73
- return map;
46
+ } catch {
47
+ return '';
48
+ }
74
49
  }
75
50
 
76
51
  function getConservativeSubtreeText(document, container) {
@@ -85,7 +60,7 @@ function runInPage(ctx) {
85
60
  const info = helpers.getContentNameInfo(container, ctx);
86
61
  return info && info.present ? info.value : '';
87
62
  }
88
- const t = (container && container.textContent) ? String(container.textContent) : '';
63
+ const t = container && container.textContent ? String(container.textContent) : '';
89
64
  return t.replace(/\s+/g, ' ').trim();
90
65
  }
91
66
 
@@ -106,17 +81,9 @@ function runInPage(ctx) {
106
81
  return '';
107
82
  }
108
83
 
109
- function getInputValueName(el) {
110
- try {
111
- const type = getAttr(el, 'type').toLowerCase();
112
- if (type !== 'button' && type !== 'submit' && type !== 'reset') return '';
113
- const v = getAttr(el, 'value');
114
- return v;
115
- } catch { return ''; }
116
- }
117
-
118
84
  function isEligibleAcc(helpers, el, ctx) {
119
- const fn = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
85
+ const fn =
86
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
120
87
  if (!fn) return true;
121
88
  try {
122
89
  const r = fn(el, ctx);
@@ -127,13 +94,13 @@ function runInPage(ctx) {
127
94
  }
128
95
  }
129
96
 
130
-
131
97
  const occurrences = [];
132
98
  let applicableCount = 0;
133
99
 
134
100
  const selector = '[role="tab"]';
135
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector) : helpers.queryAll(selector);
136
-
101
+ const nodes = helpers.queryAllSmart
102
+ ? helpers.queryAllSmart(selector)
103
+ : helpers.queryAll(selector);
137
104
 
138
105
  function evaluate(el) {
139
106
  const ariaLabel = getAttr(el, 'aria-label');
@@ -164,10 +131,16 @@ function runInPage(ctx) {
164
131
  if (res.ok) continue;
165
132
 
166
133
  const eligInfo = getEligibilityInfo
167
- ? (() => { try { return getEligibilityInfo(el, ctx, { targetSet: 'acc' }); } catch { return null; } })()
168
- : null;
134
+ ? (() => {
135
+ try {
136
+ return getEligibilityInfo(el, ctx, { targetSet: 'acc' });
137
+ } catch {
138
+ return null;
139
+ }
140
+ })()
141
+ : null;
169
142
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
170
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
143
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
171
144
 
172
145
  occurrences.push({
173
146
  selector: stableSelector,
@@ -186,12 +159,16 @@ function runInPage(ctx) {
186
159
  });
187
160
  }
188
161
 
189
-
190
162
  if (applicableCount === 0) {
191
163
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
192
164
  }
193
165
  if (occurrences.length) {
194
- return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
166
+ return {
167
+ ruleId: rule.ruleId,
168
+ outcome: 'fail',
169
+ severity: rule.defaultSeverity || 'minor',
170
+ occurrences
171
+ };
195
172
  }
196
173
  return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
197
174
  }
@@ -25,7 +25,8 @@ const id = 'table-headers-attr-valid';
25
25
 
26
26
  const meta = {
27
27
  title: 'Table cell "headers" attribute must reference valid header cells',
28
- description: '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).',
28
+ description:
29
+ '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).',
29
30
  i18n: {
30
31
  titleKey: 'tableHeadersAttrValid_title',
31
32
  descriptionKey: 'tableHeadersAttrValid_description'
@@ -34,7 +35,13 @@ const meta = {
34
35
  tags: ['wcag2a', 'wcag131', 'structure', 'atomic', 'automatic', 'table'],
35
36
  wcagSc: ['1.3.1'],
36
37
  normativeMappings: [
37
- { standard: 'WCAG', version: '2.2', requirement: '1.3.1', title: 'Info and Relationships', conformanceLevel: 'A' }
38
+ {
39
+ standard: 'WCAG',
40
+ version: '2.2',
41
+ requirement: '1.3.1',
42
+ title: 'Info and Relationships',
43
+ conformanceLevel: 'A'
44
+ }
38
45
  ],
39
46
  defaultSeverity: 'serious',
40
47
  category: 'perceivable',
@@ -46,7 +53,9 @@ const meta = {
46
53
  function runInPage(ctx) {
47
54
  const { document, helpers, rule } = ctx;
48
55
 
49
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('td[headers], th[headers]') : helpers.queryAll('td[headers], th[headers]');
56
+ const nodes = helpers.queryAllSmart
57
+ ? helpers.queryAllSmart('td[headers], th[headers]')
58
+ : helpers.queryAll('td[headers], th[headers]');
50
59
 
51
60
  const occurrences = [];
52
61
  let applicableCount = 0;
@@ -65,7 +74,7 @@ function runInPage(ctx) {
65
74
  const invalid = [];
66
75
 
67
76
  for (const headerId of ids) {
68
- let ref = null;
77
+ let ref;
69
78
  try {
70
79
  ref = document.getElementById(headerId);
71
80
  } catch {
@@ -91,9 +100,11 @@ function runInPage(ctx) {
91
100
 
92
101
  if (!invalid.length) continue;
93
102
 
103
+ const dedupedInvalidIds = [...new Set(invalid.map((i) => i.id))];
104
+
94
105
  const tag = el.tagName.toLowerCase();
95
106
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
96
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
107
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
97
108
 
98
109
  occurrences.push({
99
110
  selector: stableSelector,
@@ -103,7 +114,7 @@ function runInPage(ctx) {
103
114
  i18n: {
104
115
  summaryKey: 'tableHeadersAttrValid_summary_fail',
105
116
  hintKey: 'tableHeadersAttrValid_hint_fail',
106
- params: { element: tag, invalidIds: invalid.map((i) => i.id).join(', ') }
117
+ params: { element: tag, invalidIds: dedupedInvalidIds.join(', ') }
107
118
  },
108
119
  data: {
109
120
  details: { reasonCode: 'TABLE_HEADERS_ATTR_INVALID', element: tag, invalid }
@@ -115,9 +126,14 @@ function runInPage(ctx) {
115
126
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
116
127
  }
117
128
  if (occurrences.length) {
118
- return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
129
+ return {
130
+ ruleId: rule.ruleId,
131
+ outcome: 'fail',
132
+ severity: rule.defaultSeverity || 'serious',
133
+ occurrences
134
+ };
119
135
  }
120
136
  return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
121
137
  }
122
138
 
123
- module.exports = { id, meta, runInPage };
139
+ module.exports = { id, meta, runInPage };
@@ -37,7 +37,8 @@ const id = 'table-th-has-data-cells';
37
37
 
38
38
  const meta = {
39
39
  title: '<th> elements must describe at least one data cell',
40
- description: 'Checks that a table containing <th> elements also contains at least one <td> data cell for those headers to describe.',
40
+ description:
41
+ 'Checks that a table containing <th> elements also contains at least one <td> data cell for those headers to describe.',
41
42
  i18n: {
42
43
  titleKey: 'tableThHasDataCells_title',
43
44
  descriptionKey: 'tableThHasDataCells_description'
@@ -46,7 +47,13 @@ const meta = {
46
47
  tags: ['wcag2a', 'wcag131', 'structure', 'atomic', 'automatic', 'table'],
47
48
  wcagSc: ['1.3.1'],
48
49
  normativeMappings: [
49
- { standard: 'WCAG', version: '2.2', requirement: '1.3.1', title: 'Info and Relationships', conformanceLevel: 'A' }
50
+ {
51
+ standard: 'WCAG',
52
+ version: '2.2',
53
+ requirement: '1.3.1',
54
+ title: 'Info and Relationships',
55
+ conformanceLevel: 'A'
56
+ }
50
57
  ],
51
58
  defaultSeverity: 'moderate',
52
59
  category: 'perceivable',
@@ -56,7 +63,7 @@ const meta = {
56
63
  };
57
64
 
58
65
  function runInPage(ctx) {
59
- const { document, helpers, rule } = ctx;
66
+ const { helpers, rule } = ctx;
60
67
 
61
68
  const tables = helpers.queryAllSmart ? helpers.queryAllSmart('table') : helpers.queryAll('table');
62
69
 
@@ -66,7 +73,7 @@ function runInPage(ctx) {
66
73
  for (const table of tables) {
67
74
  if (!table || !table.querySelectorAll) continue;
68
75
 
69
- let ths = [];
76
+ let ths;
70
77
  try {
71
78
  ths = table.querySelectorAll('th');
72
79
  } catch {
@@ -76,7 +83,7 @@ function runInPage(ctx) {
76
83
 
77
84
  applicableCount += 1;
78
85
 
79
- let hasDataCell = false;
86
+ let hasDataCell;
80
87
  try {
81
88
  hasDataCell = table.querySelectorAll('td').length > 0;
82
89
  } catch {
@@ -87,7 +94,9 @@ function runInPage(ctx) {
87
94
  for (const th of ths) {
88
95
  if (!th) continue;
89
96
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(th) : 'html';
90
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(th) : (th.outerHTML || '');
97
+ const html = helpers.getOuterHtmlSnippet
98
+ ? helpers.getOuterHtmlSnippet(th)
99
+ : th.outerHTML || '';
91
100
 
92
101
  occurrences.push({
93
102
  selector: stableSelector,
@@ -110,9 +119,14 @@ function runInPage(ctx) {
110
119
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
111
120
  }
112
121
  if (occurrences.length) {
113
- return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'moderate', occurrences };
122
+ return {
123
+ ruleId: rule.ruleId,
124
+ outcome: 'fail',
125
+ severity: rule.defaultSeverity || 'moderate',
126
+ occurrences
127
+ };
114
128
  }
115
129
  return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
116
130
  }
117
131
 
118
- module.exports = { id, meta, runInPage };
132
+ module.exports = { id, meta, runInPage };
@@ -60,13 +60,23 @@ const id = 'target-size-minimum';
60
60
  const meta = {
61
61
  title: 'Pointer targets meet minimum size (AA)',
62
62
  description:
63
- '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).',
63
+ '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).',
64
64
  i18n: {
65
65
  titleKey: 'targetSizeMinimum_title',
66
66
  descriptionKey: 'targetSizeMinimum_description'
67
67
  },
68
68
  helpUrl: null,
69
- tags: ['wcag22aa', 'wcag258', 'navigation', 'operable', 'pointer', 'target-size', 'atomic', 'automatic', 'dom'],
69
+ tags: [
70
+ 'wcag22aa',
71
+ 'wcag258',
72
+ 'navigation',
73
+ 'operable',
74
+ 'pointer',
75
+ 'target-size',
76
+ 'atomic',
77
+ 'automatic',
78
+ 'dom'
79
+ ],
70
80
  wcagSc: ['2.5.8'],
71
81
  normativeMappings: [
72
82
  {
@@ -115,8 +125,10 @@ function runInPage(ctx) {
115
125
  // with its own .querySelectorAll to call directly.
116
126
  function qsa(sel) {
117
127
  try {
118
- if (helpers && typeof helpers.queryAllSmart === 'function') return Array.from(helpers.queryAllSmart(sel) || []);
119
- if (helpers && typeof helpers.queryAll === 'function') return Array.from(helpers.queryAll(sel) || []);
128
+ if (helpers && typeof helpers.queryAllSmart === 'function')
129
+ return Array.from(helpers.queryAllSmart(sel) || []);
130
+ if (helpers && typeof helpers.queryAll === 'function')
131
+ return Array.from(helpers.queryAll(sel) || []);
120
132
  return [];
121
133
  } catch {
122
134
  return [];
@@ -135,10 +147,11 @@ function runInPage(ctx) {
135
147
 
136
148
  function htmlSnippet(el) {
137
149
  try {
138
- if (helpers && typeof helpers.getOuterHtmlSnippet === 'function') return helpers.getOuterHtmlSnippet(el);
150
+ if (helpers && typeof helpers.getOuterHtmlSnippet === 'function')
151
+ return helpers.getOuterHtmlSnippet(el);
139
152
  } catch {}
140
153
  try {
141
- return (el && el.outerHTML) ? String(el.outerHTML) : '';
154
+ return el && el.outerHTML ? String(el.outerHTML) : '';
142
155
  } catch {}
143
156
  return '';
144
157
  }
@@ -153,8 +166,14 @@ function runInPage(ctx) {
153
166
  if (!el || el.nodeType !== 1) return false;
154
167
 
155
168
  const tag = (el.tagName || '').toLowerCase();
156
- const role = (el.getAttribute && String(el.getAttribute('role') || '').trim().toLowerCase()) || '';
157
- const isLinkLike = (tag === 'a' && el.getAttribute && el.getAttribute('href')) || role === 'link';
169
+ const role =
170
+ (el.getAttribute &&
171
+ String(el.getAttribute('role') || '')
172
+ .trim()
173
+ .toLowerCase()) ||
174
+ '';
175
+ const isLinkLike =
176
+ (tag === 'a' && el.getAttribute && el.getAttribute('href')) || role === 'link';
158
177
  if (!isLinkLike) return false;
159
178
 
160
179
  const cs = getStyle(el);
@@ -164,11 +183,11 @@ function runInPage(ctx) {
164
183
  // Inline exception is for inline text runs; many design systems use inline-block for links,
165
184
  // so treat inline-block variants as eligible for this exception.
166
185
  const isInline =
167
- display === 'inline' ||
168
- display === 'inline-block' ||
169
- display === 'inline-flex' ||
170
- display === 'inline-grid' ||
171
- display === 'inline-table';
186
+ display === 'inline' ||
187
+ display === 'inline-block' ||
188
+ display === 'inline-flex' ||
189
+ display === 'inline-grid' ||
190
+ display === 'inline-table';
172
191
 
173
192
  if (!isInline) return false;
174
193
 
@@ -182,7 +201,10 @@ function runInPage(ctx) {
182
201
  // Fallback: sometimes links are inside spans within a paragraph-like region
183
202
  const inlineContainer = closest(el, 'span, em, strong, small, label');
184
203
  if (inlineContainer) {
185
- const outerTextBlock = closest(inlineContainer, 'p, li, dd, dt, blockquote, figcaption, caption, td, th');
204
+ const outerTextBlock = closest(
205
+ inlineContainer,
206
+ 'p, li, dd, dt, blockquote, figcaption, caption, td, th'
207
+ );
186
208
  if (outerTextBlock) return true;
187
209
  }
188
210
 
@@ -234,7 +256,7 @@ function runInPage(ctx) {
234
256
  try {
235
257
  if (det.hasAttribute('open')) return false;
236
258
 
237
- const tag = (el && el.tagName) ? String(el.tagName).toLowerCase() : '';
259
+ const tag = el && el.tagName ? String(el.tagName).toLowerCase() : '';
238
260
  // summary remains operable even when <details> is closed
239
261
  if (tag === 'summary') return false;
240
262
 
@@ -256,9 +278,9 @@ function runInPage(ctx) {
256
278
 
257
279
  function getStyle(el) {
258
280
  try {
259
- return (document && document.defaultView && document.defaultView.getComputedStyle)
260
- ? document.defaultView.getComputedStyle(el)
261
- : null;
281
+ return document && document.defaultView && document.defaultView.getComputedStyle
282
+ ? document.defaultView.getComputedStyle(el)
283
+ : null;
262
284
  } catch {
263
285
  return null;
264
286
  }
@@ -288,7 +310,11 @@ function runInPage(ctx) {
288
310
 
289
311
  // aria-disabled elements are typically treated as not operable
290
312
  try {
291
- const ad = el.getAttribute && String(el.getAttribute('aria-disabled') || '').trim().toLowerCase();
313
+ const ad =
314
+ el.getAttribute &&
315
+ String(el.getAttribute('aria-disabled') || '')
316
+ .trim()
317
+ .toLowerCase();
292
318
  if (ad === 'true') return false;
293
319
  } catch {}
294
320
 
@@ -321,7 +347,8 @@ function runInPage(ctx) {
321
347
 
322
348
  function elementFromPoint(x, y) {
323
349
  try {
324
- if (document && typeof document.elementFromPoint === 'function') return document.elementFromPoint(x, y);
350
+ if (document && typeof document.elementFromPoint === 'function')
351
+ return document.elementFromPoint(x, y);
325
352
  } catch {}
326
353
  return null;
327
354
  }
@@ -347,7 +374,8 @@ function runInPage(ctx) {
347
374
  }
348
375
  }
349
376
 
350
- const CANDIDATE_SELECTOR = 'button, summary, a[href], area[href], input, select, textarea, [role="button"], [role="link"]';
377
+ const CANDIDATE_SELECTOR =
378
+ 'button, summary, a[href], area[href], input, select, textarea, [role="button"], [role="link"]';
351
379
 
352
380
  // --- candidate collection ---
353
381
  const candidates = qsa(CANDIDATE_SELECTOR);
@@ -481,14 +509,20 @@ function runInPage(ctx) {
481
509
  const tag = (el.tagName || '').toLowerCase();
482
510
  if (tag !== 'input') return false;
483
511
 
484
- const type = (el.getAttribute && String(el.getAttribute('type') || '').trim().toLowerCase()) || '';
512
+ const type =
513
+ (el.getAttribute &&
514
+ String(el.getAttribute('type') || '')
515
+ .trim()
516
+ .toLowerCase()) ||
517
+ '';
485
518
  if (type !== 'checkbox' && type !== 'radio') return false;
486
519
 
487
520
  const cs = getStyle(el);
488
521
  let appearance = '';
489
522
  try {
490
523
  if (cs && typeof cs.getPropertyValue === 'function') {
491
- appearance = cs.getPropertyValue('appearance') || cs.getPropertyValue('-webkit-appearance') || '';
524
+ appearance =
525
+ cs.getPropertyValue('appearance') || cs.getPropertyValue('-webkit-appearance') || '';
492
526
  } else if (cs) {
493
527
  appearance = cs.appearance || cs.webkitAppearance || '';
494
528
  }
@@ -528,8 +562,8 @@ function runInPage(ctx) {
528
562
  }
529
563
  }
530
564
 
531
- const occurrences = [];
532
- let hasUncertainConflicts = false;
565
+ const failOccurrences = [];
566
+ const cantTellOccurrences = [];
533
567
 
534
568
  for (const it of undersized) {
535
569
  // Inline-text exception: do not fail purely on size/spacing for inline links in text.
@@ -548,18 +582,64 @@ function runInPage(ctx) {
548
582
  const info = hasSpacingConflict(it);
549
583
 
550
584
  if (!info.conflict && info.confident === false) {
551
- // Ambiguous perimeter-sampling result near the decision threshold.
552
- hasUncertainConflicts = true;
585
+ // Ambiguous perimeter-sampling result near the decision threshold —
586
+ // previously recorded only as a page-level boolean with no per-target
587
+ // occurrence at all, so this specific target was unrecoverable from
588
+ // the result once any other target on the page had a confident
589
+ // fail (see helpers.resolveTieredOutcome's header comment). Now
590
+ // reported as its own cantTell-tier occurrence instead.
591
+ cantTellOccurrences.push({
592
+ selector: buildSelector(it.el),
593
+ html: htmlSnippet(it.el),
594
+ summary:
595
+ 'Target may be too small and too close to another target, but the overlap is near the detection threshold and could not be confidently measured.',
596
+ hint: 'Manually verify the effective spacing between this target and its neighbor; increase target size or spacing if the overlap is real.',
597
+ i18n: {
598
+ summaryKey: 'targetSizeMinimum_summary_cantTell_ambiguousSpacing',
599
+ hintKey: 'targetSizeMinimum_hint_cantTell_ambiguousSpacing',
600
+ params: {}
601
+ },
602
+ data: {
603
+ details: {
604
+ measured: { width: it.rect.width, height: it.rect.height },
605
+ reasonCode: 'undersized-ambiguous-spacing',
606
+ conflictHitCount: info.hitCount,
607
+ conflictWith: info.conflictEl ? buildSelector(info.conflictEl) : null
608
+ }
609
+ }
610
+ });
553
611
  continue;
554
612
  }
555
613
 
556
614
  if (info.conflict) {
557
615
  if (isPlausiblyEssentialOrEquivalent(it.el)) {
558
- hasUncertainConflicts = true;
616
+ // Confident spacing conflict, but the target may be exempt as part
617
+ // of an essential graphic/image-map region — same "previously
618
+ // unrecoverable" gap as above, now reported instead of dropped.
619
+ cantTellOccurrences.push({
620
+ selector: buildSelector(it.el),
621
+ html: htmlSnippet(it.el),
622
+ summary:
623
+ 'Target is too small and too close to another target, but may be exempt as part of an essential graphic or image-map region.',
624
+ 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.',
625
+ i18n: {
626
+ summaryKey: 'targetSizeMinimum_summary_cantTell_plausiblyEssential',
627
+ hintKey: 'targetSizeMinimum_hint_cantTell_plausiblyEssential',
628
+ params: {}
629
+ },
630
+ data: {
631
+ details: {
632
+ measured: { width: it.rect.width, height: it.rect.height },
633
+ reasonCode: 'undersized-plausibly-essential',
634
+ conflictHitCount: info.hitCount,
635
+ conflictWith: info.conflictEl ? buildSelector(info.conflictEl) : null
636
+ }
637
+ }
638
+ });
559
639
  continue;
560
640
  }
561
641
 
562
- occurrences.push({
642
+ failOccurrences.push({
563
643
  selector: buildSelector(it.el),
564
644
  html: htmlSnippet(it.el),
565
645
  summary: 'Target is too small and too close to another target.',
@@ -581,25 +661,15 @@ function runInPage(ctx) {
581
661
  }
582
662
  }
583
663
 
584
- if (occurrences.length > 0) {
585
- return {
586
- ruleId: RULE_ID,
587
- outcome: 'fail',
588
- severity: (rule && rule.defaultSeverity) || 'minor',
589
- occurrences
590
- };
591
- }
592
-
593
- if (hasUncertainConflicts) {
594
- return {
595
- ruleId: RULE_ID,
596
- outcome: 'cantTell',
597
- severity: 'minor',
598
- occurrences: []
599
- };
600
- }
601
-
602
- return { ruleId: RULE_ID, outcome: 'pass', severity: 'minor', occurrences: [] };
664
+ // See helpers.resolveTieredOutcome's own header comment (src/core/dom-helpers.js):
665
+ // a fail-tier finding never silently discards cantTell-tier findings from
666
+ // the same run — both are returned together when the outcome is 'fail'.
667
+ const resolved = helpers.resolveTieredOutcome(
668
+ failOccurrences,
669
+ cantTellOccurrences,
670
+ (rule && rule.defaultSeverity) || 'minor'
671
+ );
672
+ return { ruleId: RULE_ID, ...resolved };
603
673
  }
604
674
 
605
675
  module.exports = { id, meta, runInPage };