@surea11y/core 1.0.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 (164) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/LICENSE +21 -0
  3. package/README.md +145 -0
  4. package/bin/core.js +244 -0
  5. package/docs/BINDING_AUTHORS_GUIDE.md +41 -0
  6. package/docs/CLI.md +49 -0
  7. package/docs/ENGINE_OPTIONS.md +155 -0
  8. package/docs/I18N.md +47 -0
  9. package/docs/INTEGRATION.md +156 -0
  10. package/docs/LIMITATIONS.md +31 -0
  11. package/docs/OUTPUT_SCHEMA.md +237 -0
  12. package/docs/POLICY.md +71 -0
  13. package/docs/RULE_AUTHORING.md +375 -0
  14. package/docs/RULE_CATALOG.md +180 -0
  15. package/docs/RULE_TAXONOMY.md +145 -0
  16. package/docs/TROUBLESHOOTING.md +48 -0
  17. package/docs/WCAG_CONFORMANCE.md +49 -0
  18. package/package.json +60 -0
  19. package/src/catalogs/composites.wcag.js +490 -0
  20. package/src/checks/automatic/area-alt-present.js +225 -0
  21. package/src/checks/automatic/aria-allowed-attr.js +206 -0
  22. package/src/checks/automatic/aria-allowed-role.js +102 -0
  23. package/src/checks/automatic/aria-braille-equivalent.js +139 -0
  24. package/src/checks/automatic/aria-conditional-attr.js +110 -0
  25. package/src/checks/automatic/aria-deprecated-role.js +106 -0
  26. package/src/checks/automatic/aria-hidden-body.js +87 -0
  27. package/src/checks/automatic/aria-hidden-focus.js +480 -0
  28. package/src/checks/automatic/aria-prohibited-attr.js +156 -0
  29. package/src/checks/automatic/aria-prohibited-children.js +265 -0
  30. package/src/checks/automatic/aria-required-attr.js +154 -0
  31. package/src/checks/automatic/aria-required-children.js +274 -0
  32. package/src/checks/automatic/aria-required-parent.js +222 -0
  33. package/src/checks/automatic/aria-role-name-present.js +201 -0
  34. package/src/checks/automatic/aria-roles-valid.js +110 -0
  35. package/src/checks/automatic/aria-valid-attr-value.js +123 -0
  36. package/src/checks/automatic/aria-valid-attr.js +109 -0
  37. package/src/checks/automatic/autocomplete-valid.js +134 -0
  38. package/src/checks/automatic/avoid-inline-spacing.js +107 -0
  39. package/src/checks/automatic/binary-control-name-present.js +294 -0
  40. package/src/checks/automatic/button-name-present.js +146 -0
  41. package/src/checks/automatic/bypass-blocks-present.js +162 -0
  42. package/src/checks/automatic/canvas-text-alternative-present.js +140 -0
  43. package/src/checks/automatic/combobox-name-present.js +267 -0
  44. package/src/checks/automatic/contrast-computable.js +378 -0
  45. package/src/checks/automatic/contrast-enhanced.js +517 -0
  46. package/src/checks/automatic/contrast-minimum.js +512 -0
  47. package/src/checks/automatic/css-orientation-lock.js +206 -0
  48. package/src/checks/automatic/definition-list-children-valid.js +148 -0
  49. package/src/checks/automatic/deprecated-elements-not-used.js +91 -0
  50. package/src/checks/automatic/dialog-name-present.js +209 -0
  51. package/src/checks/automatic/dlitem-parent-valid.js +100 -0
  52. package/src/checks/automatic/duplicate-id-aria.js +126 -0
  53. package/src/checks/automatic/embed-text-alternative-present.js +190 -0
  54. package/src/checks/automatic/form-control-programmatic-label-present.js +409 -0
  55. package/src/checks/automatic/form-control-single-label.js +117 -0
  56. package/src/checks/automatic/html-xml-lang-mismatch.js +91 -0
  57. package/src/checks/automatic/iframe-focusable-content.js +141 -0
  58. package/src/checks/automatic/iframe-name-present.js +102 -0
  59. package/src/checks/automatic/iframe-title-unique.js +107 -0
  60. package/src/checks/automatic/img-alt-present.js +223 -0
  61. package/src/checks/automatic/input-image-alt-present.js +155 -0
  62. package/src/checks/automatic/label-in-name.js +326 -0
  63. package/src/checks/automatic/language-page-present.js +159 -0
  64. package/src/checks/automatic/link-in-text-block.js +218 -0
  65. package/src/checks/automatic/link-name-present.js +114 -0
  66. package/src/checks/automatic/list-children-valid.js +152 -0
  67. package/src/checks/automatic/listbox-name-present.js +236 -0
  68. package/src/checks/automatic/listitem-parent-valid.js +118 -0
  69. package/src/checks/automatic/menuitem-name-present.js +201 -0
  70. package/src/checks/automatic/meta-refresh-no-exceptions.js +105 -0
  71. package/src/checks/automatic/meta-refresh-timing-absent.js +107 -0
  72. package/src/checks/automatic/meta-viewport-zoom-enabled.js +118 -0
  73. package/src/checks/automatic/meter-name-present.js +160 -0
  74. package/src/checks/automatic/nested-interactive-controls-absent.js +135 -0
  75. package/src/checks/automatic/object-text-alternative-present.js +193 -0
  76. package/src/checks/automatic/option-name-present.js +157 -0
  77. package/src/checks/automatic/page-title-present.js +86 -0
  78. package/src/checks/automatic/progressbar-name-present.js +165 -0
  79. package/src/checks/automatic/role-img-alt-present.js +206 -0
  80. package/src/checks/automatic/searchbox-name-present.js +236 -0
  81. package/src/checks/automatic/server-side-image-map-absent.js +88 -0
  82. package/src/checks/automatic/slider-name-present.js +276 -0
  83. package/src/checks/automatic/spinbutton-name-present.js +236 -0
  84. package/src/checks/automatic/summary-name-present.js +153 -0
  85. package/src/checks/automatic/svg-image-text-alternative-present.js +220 -0
  86. package/src/checks/automatic/svg-text-alternative-present.js +298 -0
  87. package/src/checks/automatic/tab-name-present.js +200 -0
  88. package/src/checks/automatic/table-headers-attr-valid.js +122 -0
  89. package/src/checks/automatic/table-th-has-data-cells.js +117 -0
  90. package/src/checks/automatic/target-size-minimum.js +605 -0
  91. package/src/checks/automatic/td-has-header.js +151 -0
  92. package/src/checks/automatic/textbox-name-present.js +236 -0
  93. package/src/checks/automatic/tooltip-name-present.js +158 -0
  94. package/src/checks/automatic/treeitem-name-present.js +157 -0
  95. package/src/checks/automatic/valid-lang.js +100 -0
  96. package/src/checks/automatic/video-poster-text-alternative-present.js +193 -0
  97. package/src/checks/manual/accesskeys-manual.js +93 -0
  98. package/src/checks/manual/area-alt-decorative-manual.js +247 -0
  99. package/src/checks/manual/area-alt-quality-manual.js +204 -0
  100. package/src/checks/manual/aria-checked-state-mismatch-manual.js +141 -0
  101. package/src/checks/manual/aria-text-manual.js +109 -0
  102. package/src/checks/manual/canvas-text-alternative-quality-manual.js +170 -0
  103. package/src/checks/manual/css-hidden-focus.js +259 -0
  104. package/src/checks/manual/embed-text-alternative-quality-manual.js +204 -0
  105. package/src/checks/manual/empty-heading-manual.js +182 -0
  106. package/src/checks/manual/empty-table-header-manual.js +163 -0
  107. package/src/checks/manual/focus-order-semantics-manual.js +117 -0
  108. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +291 -0
  109. package/src/checks/manual/heading-order-manual.js +130 -0
  110. package/src/checks/manual/identical-links-same-purpose-manual.js +142 -0
  111. package/src/checks/manual/image-redundant-alt-manual.js +118 -0
  112. package/src/checks/manual/img-alt-decorative-manual.js +148 -0
  113. package/src/checks/manual/img-alt-quality-manual.js +182 -0
  114. package/src/checks/manual/input-image-alt-decorative-manual.js +144 -0
  115. package/src/checks/manual/input-image-alt-quality-manual.js +144 -0
  116. package/src/checks/manual/label-title-only-manual.js +115 -0
  117. package/src/checks/manual/landmark-banner-is-top-level-manual.js +180 -0
  118. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +169 -0
  119. package/src/checks/manual/landmark-main-is-top-level-manual.js +167 -0
  120. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +177 -0
  121. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +169 -0
  122. package/src/checks/manual/landmark-no-duplicate-main-manual.js +132 -0
  123. package/src/checks/manual/landmark-one-main-manual.js +151 -0
  124. package/src/checks/manual/landmark-unique-manual.js +252 -0
  125. package/src/checks/manual/link-name-quality-manual.js +143 -0
  126. package/src/checks/manual/media-transcript-present-manual.js +373 -0
  127. package/src/checks/manual/meta-viewport-large-manual.js +119 -0
  128. package/src/checks/manual/mouse-only-event-handlers-manual.js +134 -0
  129. package/src/checks/manual/no-autoplay-audio-manual.js +116 -0
  130. package/src/checks/manual/object-text-alternative-quality-manual.js +194 -0
  131. package/src/checks/manual/p-as-heading-manual.js +163 -0
  132. package/src/checks/manual/page-has-heading-one-manual.js +110 -0
  133. package/src/checks/manual/page-title-patterns-manual.js +262 -0
  134. package/src/checks/manual/presentation-role-conflict-manual.js +159 -0
  135. package/src/checks/manual/region-manual.js +183 -0
  136. package/src/checks/manual/scope-attr-valid-manual.js +93 -0
  137. package/src/checks/manual/scrollable-region-focusable-manual.js +168 -0
  138. package/src/checks/manual/skip-link-manual.js +150 -0
  139. package/src/checks/manual/svg-text-alternative-quality-manual.js +209 -0
  140. package/src/checks/manual/tabindex-manual.js +94 -0
  141. package/src/checks/manual/table-duplicate-name-manual.js +99 -0
  142. package/src/checks/manual/table-fake-caption-manual.js +122 -0
  143. package/src/checks/manual/video-caption-manual.js +118 -0
  144. package/src/checks/manual-review.js +95 -0
  145. package/src/checks/rules-and-tags.full.csv +19 -0
  146. package/src/checks/rules-and-tags.full.json +259 -0
  147. package/src/core/aria-helpers.js +906 -0
  148. package/src/core/contrast-helpers.js +1147 -0
  149. package/src/core/dom-helpers.js +4085 -0
  150. package/src/core/dom-runner.js +627 -0
  151. package/src/core/frame-messaging.js +210 -0
  152. package/src/core/frame-scan.js +178 -0
  153. package/src/core/rollup-composites.js +135 -0
  154. package/src/core/rule-meta.js +140 -0
  155. package/src/core.js +79055 -0
  156. package/src/coverage/wcag-facets.js +1079 -0
  157. package/src/coverage/wcag-version-map.js +84 -0
  158. package/src/i18n/en.js +919 -0
  159. package/src/i18n/fr.js +527 -0
  160. package/src/index.js +4 -0
  161. package/src/policy/contracts.js +18 -0
  162. package/src/policy/resolvePolicy.js +55 -0
  163. package/src/policy/schemas/engine-options.schema.json +103 -0
  164. package/src/policy/schemas/policy-contract.schema.json +40 -0
@@ -0,0 +1,605 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check target-size-minimum
5
+ * @atomic true
6
+ * @summary Pointer-operable targets should be at least 24×24 CSS px (or meet an exception)
7
+ * @standard WCAG 2.2
8
+ * @sc 2.5.8
9
+ *
10
+ * Notes (engine intent):
11
+ * - This rule is DOM-based and measures pointer hit regions available to sighted pointer users.
12
+ * - Elements can be "pointer-operable" even if excluded from the accessibility tree (e.g. aria-hidden="true").
13
+ * - Excludes targets that are not pointer-reachable due to rendering suppression (display:none, etc.),
14
+ * or pointer suppression (pointer-events:none), or zero geometry (e.g. scale(0) -> zero rects).
15
+ *
16
+ * WCAG 2.5.8 exceptions implemented, and how:
17
+ * - Spacing: a 24px-diameter circle centered on an undersized target must not
18
+ * intersect another (unrelated) target's box or another undersized
19
+ * target's own circle. Two passes: a fast center-distance check (exact for
20
+ * undersized-vs-undersized; a reasonable proxy otherwise) and a 16-point
21
+ * perimeter sample via elementFromPoint as a more precise fallback for
22
+ * cases the distance check under-detects (e.g. a small target adjacent to
23
+ * a large, elongated neighbor). Ancestor/descendant relationships between
24
+ * the target and the "other" element are never treated as a conflict —
25
+ * see isRelated — since a nested-interactive shape (a small control inside
26
+ * its own wrapping link/button) is one visual region, not two independent
27
+ * targets; that pattern is nested-interactive-controls-absent's
28
+ * concern, not a spacing one.
29
+ * - Inline: a link inside a run of text (isInlineTextExceptionTarget).
30
+ * - User Agent Control: an unstyled native checkbox/radio, detected via
31
+ * `appearance` not being reset to `none` (see isUserAgentSizedControl) —
32
+ * scoped narrowly to checkbox/radio specifically, not every form control,
33
+ * since those are the only types with unambiguous native rendering.
34
+ * - Essential/Equivalent: only a narrow, high-confidence subset is asserted
35
+ * (SVG/canvas/map-embedded controls) — see isPlausiblyEssentialOrEquivalent;
36
+ * anything else defers to cantTell rather than guessing "essential" from a
37
+ * layout container.
38
+ *
39
+ * Known, deliberately unimplemented gap: `<area>` (image-map hotspot)
40
+ * elements are not evaluated at all. `area[href]` is in CANDIDATE_SELECTOR
41
+ * for forward-compatibility, but it's currently a no-op: `<area>` has no
42
+ * CSS box of its own (`display: none` by the HTML spec's default UA
43
+ * stylesheet — verified, not a jsdom quirk), so `getBoundingClientRect()`
44
+ * always reports zero geometry and `isPointerReachable`'s existing
45
+ * `display:none` check rejects it before any size/exception logic runs. A
46
+ * real `<area>` hit-region is computed by the browser from its `shape`/
47
+ * `coords` attributes against the associated `<img>`'s *rendered* size —
48
+ * an entirely different measurement path than every other candidate here.
49
+ * Implementing that properly (parsing `coords`, resolving the owning
50
+ * `<img>` via its `usemap`, accounting for the image's CSS-scaled render
51
+ * size) is a separate, larger feature, not attempted in this pass.
52
+ *
53
+ * This is an automatic, deterministic approximation intended to be:
54
+ * - strict on clear failures,
55
+ * - conservative when exceptions cannot be determined reliably.
56
+ */
57
+
58
+ const id = 'target-size-minimum';
59
+
60
+ const meta = {
61
+ title: 'Pointer targets meet minimum size (AA)',
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).',
64
+ i18n: {
65
+ titleKey: 'targetSizeMinimum_title',
66
+ descriptionKey: 'targetSizeMinimum_description'
67
+ },
68
+ helpUrl: null,
69
+ tags: ['wcag22aa', 'wcag258', 'navigation', 'operable', 'pointer', 'target-size', 'atomic', 'automatic', 'dom'],
70
+ wcagSc: ['2.5.8'],
71
+ normativeMappings: [
72
+ {
73
+ standard: 'WCAG',
74
+ version: '2.2',
75
+ requirement: '2.5.8',
76
+ title: 'Target Size (Minimum)',
77
+ conformanceLevel: 'AA'
78
+ }
79
+ ],
80
+ defaultSeverity: 'serious',
81
+ category: 'operable',
82
+ type: 'automatic',
83
+ defaultConfidence: 'medium',
84
+ coverage: {
85
+ facetsBySc: {
86
+ '2.5.8': ['target-size-minimum-pointer']
87
+ }
88
+ }
89
+ };
90
+
91
+ // ---- constants (deterministic, CSS pixels) ----
92
+ const MIN_SIZE = 24;
93
+ const MIN_RADIUS = 12; // spacing exception circle radius (24px diameter)
94
+
95
+ // Grid sampling configuration (kept small for perf; only used for undersized targets)
96
+ const GRID_POINTS = 25; // 5x5
97
+ const GRID_N = 5;
98
+
99
+ // Circle sampling configuration (perimeter)
100
+ const CIRCLE_SAMPLES = 16; // deterministic, fixed
101
+
102
+ function runInPage(ctx) {
103
+ 'use strict';
104
+
105
+ const { document, helpers, rule } = ctx;
106
+
107
+ const RULE_ID = (rule && rule.ruleId) || 'target-size-minimum';
108
+ const MIN = 24;
109
+ const RADIUS = MIN / 2;
110
+
111
+ // --- tiny safe helpers (never throw) ---
112
+ // Goes through helpers.queryAllSmart/queryAll (multi-root and shadow-DOM
113
+ // aware, cached) rather than a raw root/safeRoot DOM query -- ctx.root is
114
+ // an array (multi-region contextSelector support), not a single element
115
+ // with its own .querySelectorAll to call directly.
116
+ function qsa(sel) {
117
+ 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) || []);
120
+ return [];
121
+ } catch {
122
+ return [];
123
+ }
124
+ }
125
+
126
+ function buildSelector(el) {
127
+ try {
128
+ if (helpers && typeof helpers.buildSelector === 'function') return helpers.buildSelector(el);
129
+ } catch {}
130
+ try {
131
+ if (el && el.id) return `#${el.id}`;
132
+ } catch {}
133
+ return 'html';
134
+ }
135
+
136
+ function htmlSnippet(el) {
137
+ try {
138
+ if (helpers && typeof helpers.getOuterHtmlSnippet === 'function') return helpers.getOuterHtmlSnippet(el);
139
+ } catch {}
140
+ try {
141
+ return (el && el.outerHTML) ? String(el.outerHTML) : '';
142
+ } catch {}
143
+ return '';
144
+ }
145
+
146
+ function isInlineTextExceptionTarget(el) {
147
+ // SC 2.5.8 exception: target is in a sentence/block of text (inline)
148
+ // Deterministic heuristic:
149
+ // - must be a link-like target
150
+ // - must be rendered as inline/inline-*
151
+ // - must be inside a typical text container
152
+ try {
153
+ if (!el || el.nodeType !== 1) return false;
154
+
155
+ 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';
158
+ if (!isLinkLike) return false;
159
+
160
+ const cs = getStyle(el);
161
+ const display = cs && cs.display ? String(cs.display) : '';
162
+ if (!display) return false;
163
+
164
+ // Inline exception is for inline text runs; many design systems use inline-block for links,
165
+ // so treat inline-block variants as eligible for this exception.
166
+ const isInline =
167
+ display === 'inline' ||
168
+ display === 'inline-block' ||
169
+ display === 'inline-flex' ||
170
+ display === 'inline-grid' ||
171
+ display === 'inline-table';
172
+
173
+ if (!isInline) return false;
174
+
175
+ const t = (el.textContent || '').trim();
176
+ if (!t) return false;
177
+
178
+ // Require that it sits in a "text block" context
179
+ const textContainer = closest(el, 'p, li, dd, dt, blockquote, figcaption, caption, td, th');
180
+ if (textContainer) return true;
181
+
182
+ // Fallback: sometimes links are inside spans within a paragraph-like region
183
+ const inlineContainer = closest(el, 'span, em, strong, small, label');
184
+ if (inlineContainer) {
185
+ const outerTextBlock = closest(inlineContainer, 'p, li, dd, dt, blockquote, figcaption, caption, td, th');
186
+ if (outerTextBlock) return true;
187
+ }
188
+
189
+ return false;
190
+ } catch {
191
+ return false;
192
+ }
193
+ }
194
+
195
+ function getRects(el) {
196
+ try {
197
+ if (!el || typeof el.getClientRects !== 'function') return [];
198
+ const r = el.getClientRects();
199
+ return r ? Array.from(r) : [];
200
+ } catch {
201
+ return [];
202
+ }
203
+ }
204
+
205
+ function getBcr(el) {
206
+ try {
207
+ if (!el || typeof el.getBoundingClientRect !== 'function') return null;
208
+ return el.getBoundingClientRect();
209
+ } catch {
210
+ return null;
211
+ }
212
+ }
213
+
214
+ function hasHiddenAttr(el) {
215
+ try {
216
+ return !!(el && el.hasAttribute && el.hasAttribute('hidden'));
217
+ } catch {
218
+ return false;
219
+ }
220
+ }
221
+
222
+ function closest(el, sel) {
223
+ try {
224
+ return el && typeof el.closest === 'function' ? el.closest(sel) : null;
225
+ } catch {
226
+ return null;
227
+ }
228
+ }
229
+
230
+ function inClosedDetails(el) {
231
+ const det = closest(el, 'details');
232
+ if (!det) return false;
233
+
234
+ try {
235
+ if (det.hasAttribute('open')) return false;
236
+
237
+ const tag = (el && el.tagName) ? String(el.tagName).toLowerCase() : '';
238
+ // summary remains operable even when <details> is closed
239
+ if (tag === 'summary') return false;
240
+
241
+ // everything else inside closed details is suppressed
242
+ return true;
243
+ } catch {
244
+ return false;
245
+ }
246
+ }
247
+
248
+ function inInertSubtree(el) {
249
+ // Any ancestor with [inert] suppresses, including self.
250
+ try {
251
+ return !!closest(el, '[inert]');
252
+ } catch {
253
+ return false;
254
+ }
255
+ }
256
+
257
+ function getStyle(el) {
258
+ try {
259
+ return (document && document.defaultView && document.defaultView.getComputedStyle)
260
+ ? document.defaultView.getComputedStyle(el)
261
+ : null;
262
+ } catch {
263
+ return null;
264
+ }
265
+ }
266
+
267
+ function isPointerReachable(el) {
268
+ // Match test expectations:
269
+ // - exclude display:none
270
+ // - exclude [hidden]
271
+ // - exclude content-visibility:hidden
272
+ // - exclude visibility hidden/collapse
273
+ // - exclude pointer-events:none
274
+ // - exclude inert subtree
275
+ // - exclude elements inside closed details (except summary)
276
+ // - exclude elements with no client rects
277
+ // - DO NOT exclude aria-hidden or opacity:0
278
+ if (!el || el.nodeType !== 1) return false;
279
+
280
+ if (hasHiddenAttr(el)) return false;
281
+ if (inInertSubtree(el)) return false;
282
+ if (inClosedDetails(el)) return false;
283
+
284
+ // Not operable => exclude
285
+ try {
286
+ if (typeof el.matches === 'function' && el.matches(':disabled')) return false;
287
+ } catch {}
288
+
289
+ // aria-disabled elements are typically treated as not operable
290
+ try {
291
+ const ad = el.getAttribute && String(el.getAttribute('aria-disabled') || '').trim().toLowerCase();
292
+ if (ad === 'true') return false;
293
+ } catch {}
294
+
295
+ const rects = getRects(el);
296
+ if (!rects || rects.length === 0) return false;
297
+
298
+ const cs = getStyle(el);
299
+ const display = cs && cs.display ? String(cs.display) : 'block';
300
+ const visibility = cs && cs.visibility ? String(cs.visibility) : 'visible';
301
+ const contentVisibility = cs && cs.contentVisibility ? String(cs.contentVisibility) : 'visible';
302
+ const pointerEvents = cs && cs.pointerEvents ? String(cs.pointerEvents) : 'auto';
303
+
304
+ if (display === 'none') return false;
305
+ if (visibility === 'hidden' || visibility === 'collapse') return false;
306
+ if (contentVisibility === 'hidden') return false;
307
+ if (pointerEvents === 'none') return false;
308
+
309
+ return true;
310
+ }
311
+
312
+ function centerOfRect(r) {
313
+ return { cx: r.left + r.width / 2, cy: r.top + r.height / 2 };
314
+ }
315
+
316
+ function dist(a, b) {
317
+ const dx = a.cx - b.cx;
318
+ const dy = a.cy - b.cy;
319
+ return Math.sqrt(dx * dx + dy * dy);
320
+ }
321
+
322
+ function elementFromPoint(x, y) {
323
+ try {
324
+ if (document && typeof document.elementFromPoint === 'function') return document.elementFromPoint(x, y);
325
+ } catch {}
326
+ return null;
327
+ }
328
+
329
+ // Bidirectional containment check: true when `a` and `b` are the same
330
+ // element, or either one is an ancestor of the other. A nested-interactive
331
+ // pattern (e.g. a small <button> inside a wrapping <a href>, or vice
332
+ // versa) is a single visual/interactive region, not two independently
333
+ // placed targets — the spacing exception's "does the circle intersect
334
+ // ANOTHER target" language is about separate targets, not an element and
335
+ // its own container. (Nested interactive controls are their own,
336
+ // separately-flagged anti-pattern — nested-interactive-controls-
337
+ // absent — not a target-size spacing concern.)
338
+ function isRelated(a, b) {
339
+ try {
340
+ if (!a || !b) return false;
341
+ if (a === b) return true;
342
+ if (typeof a.contains === 'function' && a.contains(b)) return true;
343
+ if (typeof b.contains === 'function' && b.contains(a)) return true;
344
+ return false;
345
+ } catch {
346
+ return false;
347
+ }
348
+ }
349
+
350
+ const CANDIDATE_SELECTOR = 'button, summary, a[href], area[href], input, select, textarea, [role="button"], [role="link"]';
351
+
352
+ // --- candidate collection ---
353
+ const candidates = qsa(CANDIDATE_SELECTOR);
354
+
355
+ const applicable = [];
356
+ for (const el of candidates) {
357
+ if (isPointerReachable(el)) applicable.push(el);
358
+ }
359
+
360
+ if (applicable.length === 0) {
361
+ return { ruleId: RULE_ID, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
362
+ }
363
+
364
+ // Precompute geometry for applicable elements.
365
+ const items = [];
366
+ for (const el of applicable) {
367
+ const r = getBcr(el);
368
+ if (!r) continue;
369
+ // Guard against nonsense
370
+ const w = Number(r.width);
371
+ const h = Number(r.height);
372
+ if (!Number.isFinite(w) || !Number.isFinite(h)) continue;
373
+ if (w <= 0 || h <= 0) continue;
374
+
375
+ items.push({
376
+ el,
377
+ rect: { left: r.left, top: r.top, width: w, height: h, right: r.left + w, bottom: r.top + h },
378
+ center: centerOfRect({ left: r.left, top: r.top, width: w, height: h })
379
+ });
380
+ }
381
+
382
+ if (items.length === 0) {
383
+ // had “applicable” but no measurable geometry
384
+ return { ruleId: RULE_ID, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
385
+ }
386
+
387
+ const undersized = items.filter((it) => it.rect.width < MIN || it.rect.height < MIN);
388
+
389
+ // --- spacing/occlusion evaluation ---
390
+ function hasSpacingConflict(target) {
391
+ // 0) Pure geometry: deterministic center-distance check against ANY
392
+ // nearby target, not just other undersized ones. Per WCAG 2.5.8, the
393
+ // spacing exception depends on proximity to any adjacent target — an
394
+ // undersized target sitting flush against an adequately-sized one still
395
+ // fails the exception, which an undersized-only comparison would miss.
396
+ for (const other of items) {
397
+ if (!other || !other.el || isRelated(target.el, other.el)) continue;
398
+
399
+ // Ignore inline-text exception targets when evaluating spacing conflicts.
400
+ // (Inline links in text are exempt and should not invalidate spacing.)
401
+ if (isInlineTextExceptionTarget(other.el)) continue;
402
+
403
+ if (dist(target.center, other.center) < MIN) {
404
+ return { conflict: true, confident: true, hitCount: 0, conflictEl: other.el };
405
+ }
406
+ }
407
+
408
+ // 1) Perimeter sampling: reduce false positives from incidental overlaps/stacking.
409
+ // Sample every point (no early exit) so the confidence banding below can
410
+ // be based on the full hit count rather than stopping the instant a
411
+ // threshold is crossed.
412
+ const HIT_THRESHOLD = 3;
413
+ // Comfortably above HIT_THRESHOLD: only a hit count this high is treated
414
+ // as a confident conflict. Perimeter sampling is an approximation
415
+ // (rounded corners, border-radius, and sub-pixel geometry can shift a
416
+ // sample point in or out of a neighboring element), so a result that
417
+ // merely reaches HIT_THRESHOLD is not asserted as a deterministic
418
+ // fail — see the ambiguous band below.
419
+ const CONFIDENT_THRESHOLD = 5;
420
+ let hitCount = 0;
421
+ let firstConflictEl = null;
422
+
423
+ const STEPS = 16;
424
+ for (let i = 0; i < STEPS; i++) {
425
+ const ang = (Math.PI * 2 * i) / STEPS;
426
+ const x = target.center.cx + RADIUS * Math.cos(ang);
427
+ const y = target.center.cy + RADIUS * Math.sin(ang);
428
+
429
+ const hit = elementFromPoint(x, y);
430
+ if (!hit) continue;
431
+
432
+ let hitCandidate = null;
433
+ try {
434
+ hitCandidate = hit.closest ? hit.closest(CANDIDATE_SELECTOR) : null;
435
+ } catch {}
436
+
437
+ if (!hitCandidate) continue;
438
+ if (isRelated(hitCandidate, target.el)) continue;
439
+ if (!isPointerReachable(hitCandidate)) continue;
440
+
441
+ // If the thing we hit is an inline-text exception target, don't treat it as a spacing conflict.
442
+ if (isInlineTextExceptionTarget(hitCandidate)) continue;
443
+
444
+ hitCount++;
445
+ if (!firstConflictEl) firstConflictEl = hitCandidate;
446
+ }
447
+
448
+ if (hitCount >= CONFIDENT_THRESHOLD) {
449
+ return { conflict: true, confident: true, hitCount, conflictEl: firstConflictEl };
450
+ }
451
+
452
+ // Ambiguous band: close enough to HIT_THRESHOLD that sampling noise
453
+ // could have tipped the result either way. Defer to manual review
454
+ // instead of committing to pass or fail.
455
+ if (hitCount >= HIT_THRESHOLD - 1) {
456
+ return { conflict: false, confident: false, hitCount, conflictEl: firstConflictEl };
457
+ }
458
+
459
+ return { conflict: false, confident: true, hitCount, conflictEl: null };
460
+ }
461
+
462
+ // WCAG 2.5.8 "User Agent Control" exception: the target's size requirement
463
+ // does not apply at all when its size is determined by the user agent and
464
+ // not modified by the author — the canonical example being an unstyled
465
+ // native checkbox/radio (browsers render these well under 24px by
466
+ // default, and that's not the author's choice). Scoped narrowly to
467
+ // input[type=checkbox]/[type=radio] specifically (the only form-control
468
+ // types with a universally-recognized, unambiguous native rendering) —
469
+ // deliberately not extended to select/range/color/file, whose "default"
470
+ // sizing varies enough across browsers/OSes that a wrong exemption there
471
+ // risks masking a real author-introduced undersized target.
472
+ //
473
+ // Detection signal: `appearance` (or the legacy `-webkit-appearance`)
474
+ // computed as `none` is the near-universal first step of custom
475
+ // checkbox/radio styling across every CSS framework/design system —
476
+ // if the author hasn't reset it, the browser is still rendering its own
477
+ // default control chrome, so the size is genuinely UA-determined.
478
+ function isUserAgentSizedControl(el) {
479
+ try {
480
+ if (!el || el.nodeType !== 1) return false;
481
+ const tag = (el.tagName || '').toLowerCase();
482
+ if (tag !== 'input') return false;
483
+
484
+ const type = (el.getAttribute && String(el.getAttribute('type') || '').trim().toLowerCase()) || '';
485
+ if (type !== 'checkbox' && type !== 'radio') return false;
486
+
487
+ const cs = getStyle(el);
488
+ let appearance = '';
489
+ try {
490
+ if (cs && typeof cs.getPropertyValue === 'function') {
491
+ appearance = cs.getPropertyValue('appearance') || cs.getPropertyValue('-webkit-appearance') || '';
492
+ } else if (cs) {
493
+ appearance = cs.appearance || cs.webkitAppearance || '';
494
+ }
495
+ } catch {}
496
+ appearance = String(appearance).trim().toLowerCase();
497
+
498
+ // Author has reset the native chrome => size is now author-controlled;
499
+ // the exception no longer applies, evaluate normally.
500
+ if (appearance === 'none') return false;
501
+
502
+ return true;
503
+ } catch {
504
+ return false;
505
+ }
506
+ }
507
+
508
+ function isPlausiblyEssentialOrEquivalent(el) {
509
+ try {
510
+ if (!el || el.nodeType !== 1) return false;
511
+
512
+ const tag = (el.tagName || '').toLowerCase();
513
+
514
+ // Image map targets are often constrained by the underlying image.
515
+ // Currently unreachable in practice: <area> never becomes a
516
+ // measurable candidate at all (see the file header's "Known,
517
+ // deliberately unimplemented gap" note) — kept for forward
518
+ // compatibility if that gap is closed later.
519
+ if (tag === 'area') return true;
520
+
521
+ // Graphics / spatial interaction regions are commonly essential by design.
522
+ if (closest(el, 'svg, canvas, map')) return true;
523
+
524
+ // Otherwise: do NOT guess "essential/equivalent" from layout containers.
525
+ return false;
526
+ } catch {
527
+ return false; // conservative: don't mask failures as cantTell
528
+ }
529
+ }
530
+
531
+ const occurrences = [];
532
+ let hasUncertainConflicts = false;
533
+
534
+ for (const it of undersized) {
535
+ // Inline-text exception: do not fail purely on size/spacing for inline links in text.
536
+ if (isInlineTextExceptionTarget(it.el)) {
537
+ continue; // pass by exception (no occurrence)
538
+ }
539
+
540
+ // User Agent Control exception: size isn't the author's choice, so the
541
+ // size requirement (and therefore any spacing conflict stemming from
542
+ // it) doesn't apply at all — skip straight to pass, no need to even
543
+ // evaluate spacing.
544
+ if (isUserAgentSizedControl(it.el)) {
545
+ continue;
546
+ }
547
+
548
+ const info = hasSpacingConflict(it);
549
+
550
+ if (!info.conflict && info.confident === false) {
551
+ // Ambiguous perimeter-sampling result near the decision threshold.
552
+ hasUncertainConflicts = true;
553
+ continue;
554
+ }
555
+
556
+ if (info.conflict) {
557
+ if (isPlausiblyEssentialOrEquivalent(it.el)) {
558
+ hasUncertainConflicts = true;
559
+ continue;
560
+ }
561
+
562
+ occurrences.push({
563
+ selector: buildSelector(it.el),
564
+ html: htmlSnippet(it.el),
565
+ summary: 'Target is too small and too close to another target.',
566
+ hint: 'Increase target size to at least 24 by 24 CSS pixels, or add sufficient spacing.',
567
+ i18n: {
568
+ summaryKey: 'targetSizeMinimum_summary_fail',
569
+ hintKey: 'targetSizeMinimum_hint_fail',
570
+ params: {}
571
+ },
572
+ data: {
573
+ details: {
574
+ measured: { width: it.rect.width, height: it.rect.height },
575
+ reasonCode: 'undersized-and-too-close',
576
+ conflictHitCount: info.hitCount,
577
+ conflictWith: info.conflictEl ? buildSelector(info.conflictEl) : null
578
+ }
579
+ }
580
+ });
581
+ }
582
+ }
583
+
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: [] };
603
+ }
604
+
605
+ module.exports = { id, meta, runInPage };