@setsquare/ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (312) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +49 -0
  4. package/README.md +313 -0
  5. package/dist/index.d.ts +129 -0
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +78 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/lib/cn.d.ts +4 -0
  10. package/dist/lib/cn.d.ts.map +1 -0
  11. package/dist/lib/cn.js +59 -0
  12. package/dist/lib/cn.js.map +1 -0
  13. package/dist/lib/tokens.d.ts +5 -0
  14. package/dist/lib/tokens.d.ts.map +1 -0
  15. package/dist/lib/tokens.js +22 -0
  16. package/dist/lib/tokens.js.map +1 -0
  17. package/dist/lib/use-element-width.d.ts +4 -0
  18. package/dist/lib/use-element-width.d.ts.map +1 -0
  19. package/dist/lib/use-element-width.js +17 -0
  20. package/dist/lib/use-element-width.js.map +1 -0
  21. package/dist/lint/index.d.ts +41 -0
  22. package/dist/lint/index.d.ts.map +1 -0
  23. package/dist/lint/index.js +648 -0
  24. package/dist/lint/index.js.map +1 -0
  25. package/dist/shell/app-header.d.ts +13 -0
  26. package/dist/shell/app-header.d.ts.map +1 -0
  27. package/dist/shell/app-header.js +11 -0
  28. package/dist/shell/app-header.js.map +1 -0
  29. package/dist/shell/dashboard-shell.d.ts +67 -0
  30. package/dist/shell/dashboard-shell.d.ts.map +1 -0
  31. package/dist/shell/dashboard-shell.js +168 -0
  32. package/dist/shell/dashboard-shell.js.map +1 -0
  33. package/dist/shell/page-header.d.ts +19 -0
  34. package/dist/shell/page-header.d.ts.map +1 -0
  35. package/dist/shell/page-header.js +11 -0
  36. package/dist/shell/page-header.js.map +1 -0
  37. package/dist/shell/right-panel.d.ts +25 -0
  38. package/dist/shell/right-panel.d.ts.map +1 -0
  39. package/dist/shell/right-panel.js +37 -0
  40. package/dist/shell/right-panel.js.map +1 -0
  41. package/dist/shell/sidebar.d.ts +68 -0
  42. package/dist/shell/sidebar.d.ts.map +1 -0
  43. package/dist/shell/sidebar.js +68 -0
  44. package/dist/shell/sidebar.js.map +1 -0
  45. package/dist/shell/site-footer.d.ts +28 -0
  46. package/dist/shell/site-footer.d.ts.map +1 -0
  47. package/dist/shell/site-footer.js +15 -0
  48. package/dist/shell/site-footer.js.map +1 -0
  49. package/dist/shell/site-header-padding.d.ts +7 -0
  50. package/dist/shell/site-header-padding.d.ts.map +1 -0
  51. package/dist/shell/site-header-padding.js +27 -0
  52. package/dist/shell/site-header-padding.js.map +1 -0
  53. package/dist/shell/site-header.d.ts +23 -0
  54. package/dist/shell/site-header.d.ts.map +1 -0
  55. package/dist/shell/site-header.js +13 -0
  56. package/dist/shell/site-header.js.map +1 -0
  57. package/dist/shell/site-menu.d.ts +27 -0
  58. package/dist/shell/site-menu.d.ts.map +1 -0
  59. package/dist/shell/site-menu.js +56 -0
  60. package/dist/shell/site-menu.js.map +1 -0
  61. package/dist/shell/site-nav-link.d.ts +24 -0
  62. package/dist/shell/site-nav-link.d.ts.map +1 -0
  63. package/dist/shell/site-nav-link.js +35 -0
  64. package/dist/shell/site-nav-link.js.map +1 -0
  65. package/dist/shell/site-nav.d.ts +12 -0
  66. package/dist/shell/site-nav.d.ts.map +1 -0
  67. package/dist/shell/site-nav.js +9 -0
  68. package/dist/shell/site-nav.js.map +1 -0
  69. package/dist/shell/site-path.d.ts +8 -0
  70. package/dist/shell/site-path.d.ts.map +1 -0
  71. package/dist/shell/site-path.js +11 -0
  72. package/dist/shell/site-path.js.map +1 -0
  73. package/dist/shell/site-shell.d.ts +25 -0
  74. package/dist/shell/site-shell.d.ts.map +1 -0
  75. package/dist/shell/site-shell.js +17 -0
  76. package/dist/shell/site-shell.js.map +1 -0
  77. package/dist/styles/styles.css +10 -0
  78. package/dist/styles/theme.css +579 -0
  79. package/dist/styles/tokens.css +295 -0
  80. package/dist/testing/index.d.ts +321 -0
  81. package/dist/testing/index.d.ts.map +1 -0
  82. package/dist/testing/index.js +1175 -0
  83. package/dist/testing/index.js.map +1 -0
  84. package/dist/tokens.json +982 -0
  85. package/dist/ui/alert.d.ts +35 -0
  86. package/dist/ui/alert.d.ts.map +1 -0
  87. package/dist/ui/alert.js +65 -0
  88. package/dist/ui/alert.js.map +1 -0
  89. package/dist/ui/avatar.d.ts +16 -0
  90. package/dist/ui/avatar.d.ts.map +1 -0
  91. package/dist/ui/avatar.js +13 -0
  92. package/dist/ui/avatar.js.map +1 -0
  93. package/dist/ui/badge.d.ts +13 -0
  94. package/dist/ui/badge.d.ts.map +1 -0
  95. package/dist/ui/badge.js +24 -0
  96. package/dist/ui/badge.js.map +1 -0
  97. package/dist/ui/breadcrumbs.d.ts +13 -0
  98. package/dist/ui/breadcrumbs.d.ts.map +1 -0
  99. package/dist/ui/breadcrumbs.js +12 -0
  100. package/dist/ui/breadcrumbs.js.map +1 -0
  101. package/dist/ui/button-styles.d.ts +24 -0
  102. package/dist/ui/button-styles.d.ts.map +1 -0
  103. package/dist/ui/button-styles.js +50 -0
  104. package/dist/ui/button-styles.js.map +1 -0
  105. package/dist/ui/button.d.ts +27 -0
  106. package/dist/ui/button.d.ts.map +1 -0
  107. package/dist/ui/button.js +30 -0
  108. package/dist/ui/button.js.map +1 -0
  109. package/dist/ui/calendar.d.ts +19 -0
  110. package/dist/ui/calendar.d.ts.map +1 -0
  111. package/dist/ui/calendar.js +52 -0
  112. package/dist/ui/calendar.js.map +1 -0
  113. package/dist/ui/card.d.ts +30 -0
  114. package/dist/ui/card.d.ts.map +1 -0
  115. package/dist/ui/card.js +18 -0
  116. package/dist/ui/card.js.map +1 -0
  117. package/dist/ui/checkbox.d.ts +36 -0
  118. package/dist/ui/checkbox.d.ts.map +1 -0
  119. package/dist/ui/checkbox.js +38 -0
  120. package/dist/ui/checkbox.js.map +1 -0
  121. package/dist/ui/code-block.d.ts +20 -0
  122. package/dist/ui/code-block.d.ts.map +1 -0
  123. package/dist/ui/code-block.js +22 -0
  124. package/dist/ui/code-block.js.map +1 -0
  125. package/dist/ui/combo-box.d.ts +17 -0
  126. package/dist/ui/combo-box.d.ts.map +1 -0
  127. package/dist/ui/combo-box.js +14 -0
  128. package/dist/ui/combo-box.js.map +1 -0
  129. package/dist/ui/copy-button.d.ts +14 -0
  130. package/dist/ui/copy-button.d.ts.map +1 -0
  131. package/dist/ui/copy-button.js +36 -0
  132. package/dist/ui/copy-button.js.map +1 -0
  133. package/dist/ui/date-field.d.ts +38 -0
  134. package/dist/ui/date-field.d.ts.map +1 -0
  135. package/dist/ui/date-field.js +78 -0
  136. package/dist/ui/date-field.js.map +1 -0
  137. package/dist/ui/dialog.d.ts +41 -0
  138. package/dist/ui/dialog.d.ts.map +1 -0
  139. package/dist/ui/dialog.js +69 -0
  140. package/dist/ui/dialog.js.map +1 -0
  141. package/dist/ui/disclosure.d.ts +18 -0
  142. package/dist/ui/disclosure.d.ts.map +1 -0
  143. package/dist/ui/disclosure.js +15 -0
  144. package/dist/ui/disclosure.js.map +1 -0
  145. package/dist/ui/empty-state.d.ts +17 -0
  146. package/dist/ui/empty-state.d.ts.map +1 -0
  147. package/dist/ui/empty-state.js +9 -0
  148. package/dist/ui/empty-state.js.map +1 -0
  149. package/dist/ui/error-state.d.ts +45 -0
  150. package/dist/ui/error-state.d.ts.map +1 -0
  151. package/dist/ui/error-state.js +88 -0
  152. package/dist/ui/error-state.js.map +1 -0
  153. package/dist/ui/field.d.ts +46 -0
  154. package/dist/ui/field.d.ts.map +1 -0
  155. package/dist/ui/field.js +79 -0
  156. package/dist/ui/field.js.map +1 -0
  157. package/dist/ui/file-upload.d.ts +54 -0
  158. package/dist/ui/file-upload.d.ts.map +1 -0
  159. package/dist/ui/file-upload.js +170 -0
  160. package/dist/ui/file-upload.js.map +1 -0
  161. package/dist/ui/form.d.ts +56 -0
  162. package/dist/ui/form.d.ts.map +1 -0
  163. package/dist/ui/form.js +137 -0
  164. package/dist/ui/form.js.map +1 -0
  165. package/dist/ui/grid-list.d.ts +24 -0
  166. package/dist/ui/grid-list.d.ts.map +1 -0
  167. package/dist/ui/grid-list.js +21 -0
  168. package/dist/ui/grid-list.js.map +1 -0
  169. package/dist/ui/hero.d.ts +36 -0
  170. package/dist/ui/hero.d.ts.map +1 -0
  171. package/dist/ui/hero.js +19 -0
  172. package/dist/ui/hero.js.map +1 -0
  173. package/dist/ui/icon-button.d.ts +18 -0
  174. package/dist/ui/icon-button.d.ts.map +1 -0
  175. package/dist/ui/icon-button.js +13 -0
  176. package/dist/ui/icon-button.js.map +1 -0
  177. package/dist/ui/icon.d.ts +20 -0
  178. package/dist/ui/icon.d.ts.map +1 -0
  179. package/dist/ui/icon.js +26 -0
  180. package/dist/ui/icon.js.map +1 -0
  181. package/dist/ui/kbd.d.ts +7 -0
  182. package/dist/ui/kbd.d.ts.map +1 -0
  183. package/dist/ui/kbd.js +7 -0
  184. package/dist/ui/kbd.js.map +1 -0
  185. package/dist/ui/link-card.d.ts +23 -0
  186. package/dist/ui/link-card.d.ts.map +1 -0
  187. package/dist/ui/link-card.js +19 -0
  188. package/dist/ui/link-card.js.map +1 -0
  189. package/dist/ui/list-box.d.ts +17 -0
  190. package/dist/ui/list-box.d.ts.map +1 -0
  191. package/dist/ui/list-box.js +18 -0
  192. package/dist/ui/list-box.js.map +1 -0
  193. package/dist/ui/menu.d.ts +31 -0
  194. package/dist/ui/menu.d.ts.map +1 -0
  195. package/dist/ui/menu.js +40 -0
  196. package/dist/ui/menu.js.map +1 -0
  197. package/dist/ui/number-field.d.ts +19 -0
  198. package/dist/ui/number-field.d.ts.map +1 -0
  199. package/dist/ui/number-field.js +19 -0
  200. package/dist/ui/number-field.js.map +1 -0
  201. package/dist/ui/prose.d.ts +12 -0
  202. package/dist/ui/prose.d.ts.map +1 -0
  203. package/dist/ui/prose.js +11 -0
  204. package/dist/ui/prose.js.map +1 -0
  205. package/dist/ui/radio-group.d.ts +20 -0
  206. package/dist/ui/radio-group.d.ts.map +1 -0
  207. package/dist/ui/radio-group.js +16 -0
  208. package/dist/ui/radio-group.js.map +1 -0
  209. package/dist/ui/scroll-region.d.ts +18 -0
  210. package/dist/ui/scroll-region.d.ts.map +1 -0
  211. package/dist/ui/scroll-region.js +29 -0
  212. package/dist/ui/scroll-region.js.map +1 -0
  213. package/dist/ui/search-field.d.ts +12 -0
  214. package/dist/ui/search-field.d.ts.map +1 -0
  215. package/dist/ui/search-field.js +12 -0
  216. package/dist/ui/search-field.js.map +1 -0
  217. package/dist/ui/search.d.ts +43 -0
  218. package/dist/ui/search.d.ts.map +1 -0
  219. package/dist/ui/search.js +45 -0
  220. package/dist/ui/search.js.map +1 -0
  221. package/dist/ui/section.d.ts +50 -0
  222. package/dist/ui/section.d.ts.map +1 -0
  223. package/dist/ui/section.js +25 -0
  224. package/dist/ui/section.js.map +1 -0
  225. package/dist/ui/segmented-control.d.ts +18 -0
  226. package/dist/ui/segmented-control.d.ts.map +1 -0
  227. package/dist/ui/segmented-control.js +17 -0
  228. package/dist/ui/segmented-control.js.map +1 -0
  229. package/dist/ui/select.d.ts +18 -0
  230. package/dist/ui/select.d.ts.map +1 -0
  231. package/dist/ui/select.js +13 -0
  232. package/dist/ui/select.js.map +1 -0
  233. package/dist/ui/selection-bar.d.ts +28 -0
  234. package/dist/ui/selection-bar.d.ts.map +1 -0
  235. package/dist/ui/selection-bar.js +35 -0
  236. package/dist/ui/selection-bar.js.map +1 -0
  237. package/dist/ui/sheet.d.ts +21 -0
  238. package/dist/ui/sheet.d.ts.map +1 -0
  239. package/dist/ui/sheet.js +16 -0
  240. package/dist/ui/sheet.js.map +1 -0
  241. package/dist/ui/skeleton.d.ts +31 -0
  242. package/dist/ui/skeleton.d.ts.map +1 -0
  243. package/dist/ui/skeleton.js +38 -0
  244. package/dist/ui/skeleton.js.map +1 -0
  245. package/dist/ui/slider.d.ts +31 -0
  246. package/dist/ui/slider.d.ts.map +1 -0
  247. package/dist/ui/slider.js +33 -0
  248. package/dist/ui/slider.js.map +1 -0
  249. package/dist/ui/spinner.d.ts +27 -0
  250. package/dist/ui/spinner.d.ts.map +1 -0
  251. package/dist/ui/spinner.js +21 -0
  252. package/dist/ui/spinner.js.map +1 -0
  253. package/dist/ui/split.d.ts +14 -0
  254. package/dist/ui/split.d.ts.map +1 -0
  255. package/dist/ui/split.js +11 -0
  256. package/dist/ui/split.js.map +1 -0
  257. package/dist/ui/stat-list.d.ts +25 -0
  258. package/dist/ui/stat-list.d.ts.map +1 -0
  259. package/dist/ui/stat-list.js +14 -0
  260. package/dist/ui/stat-list.js.map +1 -0
  261. package/dist/ui/steps.d.ts +23 -0
  262. package/dist/ui/steps.d.ts.map +1 -0
  263. package/dist/ui/steps.js +16 -0
  264. package/dist/ui/steps.js.map +1 -0
  265. package/dist/ui/switch.d.ts +13 -0
  266. package/dist/ui/switch.d.ts.map +1 -0
  267. package/dist/ui/switch.js +12 -0
  268. package/dist/ui/switch.js.map +1 -0
  269. package/dist/ui/table.d.ts +25 -0
  270. package/dist/ui/table.d.ts.map +1 -0
  271. package/dist/ui/table.js +29 -0
  272. package/dist/ui/table.js.map +1 -0
  273. package/dist/ui/tag.d.ts +14 -0
  274. package/dist/ui/tag.d.ts.map +1 -0
  275. package/dist/ui/tag.js +11 -0
  276. package/dist/ui/tag.js.map +1 -0
  277. package/dist/ui/text-field.d.ts +27 -0
  278. package/dist/ui/text-field.d.ts.map +1 -0
  279. package/dist/ui/text-field.js +17 -0
  280. package/dist/ui/text-field.js.map +1 -0
  281. package/dist/ui/text-link.d.ts +17 -0
  282. package/dist/ui/text-link.d.ts.map +1 -0
  283. package/dist/ui/text-link.js +15 -0
  284. package/dist/ui/text-link.js.map +1 -0
  285. package/dist/ui/theme-switcher.d.ts +26 -0
  286. package/dist/ui/theme-switcher.d.ts.map +1 -0
  287. package/dist/ui/theme-switcher.js +32 -0
  288. package/dist/ui/theme-switcher.js.map +1 -0
  289. package/dist/ui/theme.d.ts +54 -0
  290. package/dist/ui/theme.d.ts.map +1 -0
  291. package/dist/ui/theme.js +135 -0
  292. package/dist/ui/theme.js.map +1 -0
  293. package/dist/ui/toast.d.ts +34 -0
  294. package/dist/ui/toast.d.ts.map +1 -0
  295. package/dist/ui/toast.js +69 -0
  296. package/dist/ui/toast.js.map +1 -0
  297. package/dist/ui/tooltip.d.ts +10 -0
  298. package/dist/ui/tooltip.d.ts.map +1 -0
  299. package/dist/ui/tooltip.js +10 -0
  300. package/dist/ui/tooltip.js.map +1 -0
  301. package/package.json +123 -0
  302. package/templates/next/error.tsx +32 -0
  303. package/templates/next/loading.tsx +19 -0
  304. package/templates/next/not-found.tsx +18 -0
  305. package/templates/next/page-transition.tsx +33 -0
  306. package/templates/next/portal-layout.tsx +70 -0
  307. package/templates/next/prefetch-on-intent.ts +31 -0
  308. package/templates/next/root-layout.tsx +16 -0
  309. package/templates/next/site-layout.tsx +58 -0
  310. package/templates/next/site-nav.ts +16 -0
  311. package/templates/next/site-not-found.tsx +21 -0
  312. package/templates/next/site-router.tsx +17 -0
@@ -0,0 +1,1175 @@
1
+ /**
2
+ * @setsquare/ui/testing — checks on a rendered page, for use in Playwright (or anything with a
3
+ * `page.evaluate`). They look at what the browser actually laid out, so they find what the lint rules cannot:
4
+ * a wrapper without `relative`, a third-party widget, CSS from elsewhere.
5
+ *
6
+ * - Positioning: `findUncontainedPositioning`, `findHiddenOverflowScrolls`, `expectPositioningContained`.
7
+ * - Targets: `findSmallTargets`, `findOverlappingTargets`, `expectTargetsSized` — the target is the element a
8
+ * pointer hits (Layer 1), measured as laid out, not the paint inside it.
9
+ * - Focus: `findInvisibleFocus`, `expectFocusVisible` — every control a keyboard reaches must change on screen
10
+ * when it takes focus. These take screenshots, so they need a Playwright page.
11
+ * - Links: `findUndistinguishedLinks`, `expectLinksDistinguished` — a link in running text must be findable
12
+ * without colour (WCAG's F73 and G183), or with `{ underline: true }`, underlined.
13
+ * - Cursors: `findCursorMismatches`, `expectCursorsMatch` — one cursor rule on every target: the pointer on
14
+ * what is pressed, the I-beam on text entry, not-allowed when disabled. These hover, so they need a page
15
+ * with a mouse.
16
+ * - Reflow: `findPageOverflow`, `expectPageReflows` — at the width the page is laid out at, nothing makes it
17
+ * scroll sideways or runs off its edge, apart from content in a scroller of its own.
18
+ * - Text spacing: `findClippedText`, `expectTextUnclipped` — with WCAG 1.4.12's four spacing metrics applied,
19
+ * no text is cut off by its box or spills out of one.
20
+ * - Motion: `findEndlessAnimations`, `expectAnimationsPausable` — nothing moves for more than five seconds
21
+ * unless a visible control on the page pauses it.
22
+ *
23
+ * The DOM scans are self-contained functions: `page.evaluate(findSmallTargets)` runs them in the page.
24
+ * Nothing here imports Playwright.
25
+ */
26
+ /**
27
+ * Every `position: absolute` element whose containing block lies outside its nearest scroll container
28
+ * (`overflow: auto | scroll`). Such an element does not scroll with the content it belongs to, and scrolling
29
+ * it into view scrolls an ancestor instead. Returns [] when the page is clean.
30
+ */
31
+ export function findUncontainedPositioning(root = document) {
32
+ const describe = (el) => {
33
+ if (!el)
34
+ return 'the page';
35
+ const id = el.id ? `#${el.id}` : '';
36
+ const cls = typeof el.className === 'string'
37
+ ? el.className
38
+ .split(/\s+/)
39
+ .filter(Boolean)
40
+ .slice(0, 3)
41
+ .map((c) => `.${c}`)
42
+ .join('')
43
+ : '';
44
+ const name = (el.getAttribute('aria-label') ?? el.textContent ?? '')
45
+ .trim()
46
+ .replace(/\s+/g, ' ')
47
+ .slice(0, 40);
48
+ return `${el.tagName.toLowerCase()}${id}${cls}${name ? ` "${name}"` : ''}`;
49
+ };
50
+ const scrollerOf = (el) => {
51
+ for (let p = el.parentElement; p; p = p.parentElement) {
52
+ const cs = getComputedStyle(p);
53
+ if (/(auto|scroll)/.test(cs.overflowX + cs.overflowY))
54
+ return p;
55
+ }
56
+ return null;
57
+ };
58
+ const containingBlockOf = (el) => {
59
+ for (let p = el.parentElement; p; p = p.parentElement) {
60
+ const cs = getComputedStyle(p);
61
+ if (cs.position !== 'static' ||
62
+ cs.transform !== 'none' ||
63
+ cs.translate !== 'none' ||
64
+ cs.scale !== 'none' ||
65
+ cs.rotate !== 'none' ||
66
+ cs.perspective !== 'none' ||
67
+ cs.filter !== 'none' ||
68
+ /layout|paint|strict|content/.test(cs.contain) ||
69
+ /transform|perspective|filter/.test(cs.willChange) ||
70
+ cs.containerType === 'size')
71
+ return p;
72
+ }
73
+ return null;
74
+ };
75
+ const out = [];
76
+ for (const el of Array.from(root.querySelectorAll('*'))) {
77
+ if (getComputedStyle(el).position !== 'absolute')
78
+ continue;
79
+ const scroller = scrollerOf(el);
80
+ if (!scroller)
81
+ continue;
82
+ const cb = containingBlockOf(el);
83
+ if (cb && (cb === scroller || scroller.contains(cb)))
84
+ continue;
85
+ const kind = el.querySelector(':scope > input, :scope > button')
86
+ ? 'hidden-input'
87
+ : el.classList.contains('sr-only')
88
+ ? 'sr-only'
89
+ : el.matches('a[href], button, input, select, textarea, [tabindex]')
90
+ ? 'focusable'
91
+ : 'other';
92
+ out.push({
93
+ element: describe(el),
94
+ kind,
95
+ scroller: describe(scroller),
96
+ containingBlock: describe(cb),
97
+ });
98
+ }
99
+ return out;
100
+ }
101
+ /**
102
+ * Focus every focusable element, and bring every absolutely positioned element into view the way a screen
103
+ * reader's cursor or find-in-page would (so not a decorative, aria-hidden one with no text), with each scroll
104
+ * container scrolled first to its start and then to its
105
+ * end. Reports every time an `overflow: hidden` element scrolled as a result: content that moved with no way
106
+ * to scroll it back, which is the blank space at the bottom of the page. Restores scroll positions and focus.
107
+ */
108
+ export function findHiddenOverflowScrolls(root = document) {
109
+ const describe = (el) => {
110
+ if (!el)
111
+ return 'the page';
112
+ const id = el.id ? `#${el.id}` : '';
113
+ const cls = typeof el.className === 'string'
114
+ ? el.className
115
+ .split(/\s+/)
116
+ .filter(Boolean)
117
+ .slice(0, 3)
118
+ .map((c) => `.${c}`)
119
+ .join('')
120
+ : '';
121
+ const name = (el.getAttribute('aria-label') ?? el.textContent ?? '')
122
+ .trim()
123
+ .replace(/\s+/g, ' ')
124
+ .slice(0, 40);
125
+ return `${el.tagName.toLowerCase()}${id}${cls}${name ? ` "${name}"` : ''}`;
126
+ };
127
+ const all = Array.from(root.querySelectorAll('*'));
128
+ const hidden = all.filter((el) => {
129
+ const cs = getComputedStyle(el);
130
+ const r = el.getBoundingClientRect();
131
+ // 1px visually hidden boxes scroll their own input into view; that is harmless and ignored.
132
+ return /(hidden|clip)/.test(cs.overflowX + cs.overflowY) && r.width > 2 && r.height > 2;
133
+ });
134
+ const scrollers = all.filter((el) => {
135
+ const cs = getComputedStyle(el);
136
+ return (/(auto|scroll)/.test(cs.overflowX + cs.overflowY) &&
137
+ (el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth));
138
+ });
139
+ // Neither a screen reader's cursor nor find-in-page goes to decorative, aria-hidden boxes with no text, or
140
+ // into inert content: HTML exposes none of it to assistive technology, and asks find-in-page to ignore it
141
+ // (a picture of a page, such as a docs preview, is inert).
142
+ const reachable = (el) => !el.closest('[aria-hidden=true]') || !!el.textContent?.trim();
143
+ const triggers = all.filter((el) => !el.closest('[inert]') &&
144
+ (el.matches('a[href], button, input, select, textarea, [tabindex], [contenteditable]') ||
145
+ (getComputedStyle(el).position === 'absolute' && reachable(el))));
146
+ // A page with scroll-behavior: smooth would scroll after the reading is taken, and hide what moved.
147
+ const instant = document.createElement('style');
148
+ instant.textContent = '* { scroll-behavior: auto !important; }';
149
+ document.head.append(instant);
150
+ const saved = new Map();
151
+ for (const el of [...hidden, ...scrollers, document.scrollingElement])
152
+ saved.set(el, [el.scrollLeft, el.scrollTop]);
153
+ const active = document.activeElement;
154
+ const out = [];
155
+ const seen = new Set();
156
+ const positions = scrollers.length
157
+ ? scrollers.flatMap((s) => [
158
+ [s, 0],
159
+ [s, s.scrollHeight],
160
+ ])
161
+ : [[null, 0]];
162
+ for (const el of triggers) {
163
+ for (const [s, top] of positions) {
164
+ for (const [h, [x, y]] of saved) {
165
+ h.scrollLeft = x;
166
+ h.scrollTop = y;
167
+ }
168
+ if (s)
169
+ s.scrollTop = top;
170
+ const before = hidden.map((h) => [h.scrollLeft, h.scrollTop]);
171
+ if (el.matches('a[href], button, input, select, textarea, [tabindex], [contenteditable]'))
172
+ el.focus();
173
+ else
174
+ el.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'instant' });
175
+ hidden.forEach((h, i) => {
176
+ const [bx, by] = before[i];
177
+ if (h.scrollLeft !== bx || h.scrollTop !== by) {
178
+ const key = `${describe(el)}|${describe(h)}`;
179
+ if (!seen.has(key)) {
180
+ seen.add(key);
181
+ out.push({
182
+ trigger: describe(el),
183
+ scrolled: describe(h),
184
+ x: h.scrollLeft - bx,
185
+ y: h.scrollTop - by,
186
+ });
187
+ }
188
+ }
189
+ });
190
+ }
191
+ }
192
+ for (const [h, [x, y]] of saved) {
193
+ h.scrollLeft = x;
194
+ h.scrollTop = y;
195
+ }
196
+ active?.focus?.();
197
+ instant.remove();
198
+ return out;
199
+ }
200
+ /** Runs both scans in the page. */
201
+ export async function checkPositioning(page) {
202
+ const uncontained = await page.evaluate(findUncontainedPositioning, undefined);
203
+ const hiddenOverflowScrolls = await page.evaluate(findHiddenOverflowScrolls, undefined);
204
+ return { uncontained, hiddenOverflowScrolls };
205
+ }
206
+ /**
207
+ * Throws, listing every finding, unless the page is clean. Use it after the page reaches the state you want to
208
+ * check — open the panel, show the errors — since only what is rendered can be checked.
209
+ *
210
+ * ```ts
211
+ * import { expectPositioningContained } from '@setsquare/ui/testing';
212
+ * test('settings', async ({ page }) => { await page.goto('/settings'); await expectPositioningContained(page); });
213
+ * ```
214
+ */
215
+ export async function expectPositioningContained(page) {
216
+ const { uncontained, hiddenOverflowScrolls } = await checkPositioning(page);
217
+ if (!uncontained.length && !hiddenOverflowScrolls.length)
218
+ return;
219
+ const lines = [
220
+ ...uncontained.map((u) => ` ${u.kind}: ${u.element}\n scrolls inside ${u.scroller}, but is positioned against ${u.containingBlock}. Make the scroller 'relative'.`),
221
+ ...hiddenOverflowScrolls.map((h) => ` ${h.trigger}\n scrolled ${h.scrolled} by ${h.x}, ${h.y}px, which is overflow:hidden and can't be scrolled back.`),
222
+ ];
223
+ throw new Error(`Positioning is not contained (${uncontained.length + hiddenOverflowScrolls.length}):\n${lines.join('\n')}`);
224
+ }
225
+ /**
226
+ * Every pointer target smaller than the minimum in either axis. A target is an interactive element as laid
227
+ * out — for a visually hidden input (React Aria's checkbox, radio, switch, slider), the control around it that
228
+ * a pointer actually hits. Disabled, hidden and aria-hidden elements are skipped, and so are inline elements
229
+ * (display: inline), which is how this approximates the Inline exception for links in a sentence.
230
+ * Returns [] when every target is big enough.
231
+ */
232
+ export function findSmallTargets(options = {}, root = document) {
233
+ const SELECTOR = 'a[href], button, input:not([type=hidden]), select, textarea, summary, [role=button], [role=link], [role=checkbox], [role=radio], [role=switch], [role=tab], [role=menuitem], [role=menuitemcheckbox], [role=menuitemradio], [role=option], [role=slider], [role=spinbutton], [role=treeitem]';
234
+ const min = options.min ??
235
+ (parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--target-min')) || 24);
236
+ const tiny = (el) => {
237
+ const r = el.getBoundingClientRect();
238
+ return r.width <= 2 && r.height <= 2;
239
+ };
240
+ /** A visually hidden element stands for the nearest ancestor with a size: the control a pointer hits. */
241
+ const targetOf = (el) => {
242
+ let hidden = tiny(el);
243
+ for (let p = el.parentElement, i = 0; p && i < 3 && !hidden; p = p.parentElement, i++)
244
+ if (tiny(p) && getComputedStyle(p).overflow !== 'visible')
245
+ hidden = true;
246
+ if (!hidden)
247
+ return el;
248
+ for (let p = el.parentElement; p; p = p.parentElement)
249
+ if (!tiny(p))
250
+ return p;
251
+ return null;
252
+ };
253
+ const describe = (el) => {
254
+ const id = el.id ? `#${el.id}` : '';
255
+ const cls = typeof el.className === 'string'
256
+ ? el.className
257
+ .split(/\s+/)
258
+ .filter(Boolean)
259
+ .slice(0, 2)
260
+ .map((c) => `.${c}`)
261
+ .join('')
262
+ : '';
263
+ const name = (el.getAttribute('aria-label') ?? el.textContent ?? '')
264
+ .trim()
265
+ .replace(/\s+/g, ' ')
266
+ .slice(0, 40);
267
+ return `${el.tagName.toLowerCase()}${id}${cls}${name ? ` "${name}"` : ''}`;
268
+ };
269
+ const seen = new Set();
270
+ const out = [];
271
+ for (const found of root.querySelectorAll(SELECTOR)) {
272
+ if (found.matches(':disabled, [aria-disabled=true], [data-disabled]') ||
273
+ found.closest('[inert], [aria-hidden=true]'))
274
+ continue;
275
+ const el = targetOf(found);
276
+ if (!el || seen.has(el))
277
+ continue;
278
+ seen.add(el);
279
+ if (options.ignore && (el.matches(options.ignore) || found.matches(options.ignore)))
280
+ continue;
281
+ const cs = getComputedStyle(el);
282
+ if (cs.display === 'inline' || cs.visibility !== 'visible')
283
+ continue;
284
+ const r = el.getBoundingClientRect();
285
+ if (r.width === 0 && r.height === 0)
286
+ continue;
287
+ if (r.width < min - 0.5 || r.height < min - 0.5)
288
+ out.push({
289
+ element: describe(el),
290
+ width: Math.round(r.width * 10) / 10,
291
+ height: Math.round(r.height * 10) / 10,
292
+ });
293
+ }
294
+ return out;
295
+ }
296
+ /**
297
+ * Pairs of targets whose boxes overlap on screen, where neither contains the other. WCAG's definition of
298
+ * target leaves the overlapping area out of the measurement, so an overlap can take a target below its
299
+ * minimum even when its box is big enough. Three-layer controls keep the target in the layout, so they don't
300
+ * overlap by construction; this finds targets made bigger with an overlay or a negative margin. Only what is
301
+ * on screen is compared (something scrolled or translated out of view overlaps nothing), and only within one
302
+ * layer: a toast or popover over the page is left to the focus-obscured checks.
303
+ */
304
+ export function findOverlappingTargets(root = document) {
305
+ const SELECTOR = 'a[href], button, input:not([type=hidden]), select, textarea, summary, [role=button], [role=link], [role=checkbox], [role=radio], [role=switch], [role=tab], [role=menuitem], [role=menuitemcheckbox], [role=menuitemradio], [role=option], [role=slider], [role=spinbutton], [role=treeitem]';
306
+ const tiny = (el) => {
307
+ const r = el.getBoundingClientRect();
308
+ return r.width <= 2 && r.height <= 2;
309
+ };
310
+ const targetOf = (el) => {
311
+ let hidden = tiny(el);
312
+ for (let p = el.parentElement, i = 0; p && i < 3 && !hidden; p = p.parentElement, i++)
313
+ if (tiny(p) && getComputedStyle(p).overflow !== 'visible')
314
+ hidden = true;
315
+ if (!hidden)
316
+ return el;
317
+ for (let p = el.parentElement; p; p = p.parentElement)
318
+ if (!tiny(p))
319
+ return p;
320
+ return null;
321
+ };
322
+ const describe = (el) => {
323
+ const name = (el.getAttribute('aria-label') ?? el.textContent ?? '')
324
+ .trim()
325
+ .replace(/\s+/g, ' ')
326
+ .slice(0, 40);
327
+ return `${el.tagName.toLowerCase()}${name ? ` "${name}"` : ''}`;
328
+ };
329
+ /** The part of the box inside the viewport and every clipping ancestor. */
330
+ const visibleRect = (el) => {
331
+ const r = el.getBoundingClientRect();
332
+ let left = Math.max(r.left, 0), top = Math.max(r.top, 0), right = Math.min(r.right, innerWidth), bottom = Math.min(r.bottom, innerHeight);
333
+ for (let p = el.parentElement; p; p = p.parentElement) {
334
+ if (getComputedStyle(p).overflow === 'visible')
335
+ continue;
336
+ const pr = p.getBoundingClientRect();
337
+ left = Math.max(left, pr.left);
338
+ top = Math.max(top, pr.top);
339
+ right = Math.min(right, pr.right);
340
+ bottom = Math.min(bottom, pr.bottom);
341
+ }
342
+ return { left, top, right, bottom };
343
+ };
344
+ /**
345
+ * The overlay a target sits in: the nearest fixed-position ancestor (a toast region, a modal) or portal at
346
+ * the top of the body (a popover). An overlay covers the page on purpose, which is a question of focus
347
+ * being obscured, not of target size, so only targets in the same layer are compared.
348
+ */
349
+ const layerOf = (el) => {
350
+ for (let p = el; p && p !== document.body; p = p.parentElement) {
351
+ const pos = getComputedStyle(p).position;
352
+ if (pos === 'fixed' || (p.parentElement === document.body && pos === 'absolute'))
353
+ return p;
354
+ }
355
+ return null;
356
+ };
357
+ const targets = [];
358
+ const seen = new Set();
359
+ for (const found of root.querySelectorAll(SELECTOR)) {
360
+ if (found.matches(':disabled') || found.closest('[inert], [aria-hidden=true]'))
361
+ continue;
362
+ const el = targetOf(found);
363
+ if (!el || seen.has(el))
364
+ continue;
365
+ seen.add(el);
366
+ const cs = getComputedStyle(el);
367
+ if (cs.display === 'inline' || cs.visibility !== 'visible')
368
+ continue;
369
+ const r = visibleRect(el);
370
+ if (r.right - r.left > 0 && r.bottom - r.top > 0)
371
+ targets.push({ el, layer: layerOf(el), r });
372
+ }
373
+ const out = [];
374
+ for (let i = 0; i < targets.length; i++)
375
+ for (let j = i + 1; j < targets.length; j++) {
376
+ const a = targets[i], b = targets[j];
377
+ if (a.layer !== b.layer || a.el.contains(b.el) || b.el.contains(a.el))
378
+ continue;
379
+ const w = Math.min(a.r.right, b.r.right) - Math.max(a.r.left, b.r.left);
380
+ const h = Math.min(a.r.bottom, b.r.bottom) - Math.max(a.r.top, b.r.top);
381
+ if (w > 0.5 && h > 0.5)
382
+ out.push({ a: describe(a.el), b: describe(b.el), area: Math.round(w * h) });
383
+ }
384
+ return out;
385
+ }
386
+ /** Throws, listing every finding, unless every target meets the minimum and no two targets overlap. */
387
+ export async function expectTargetsSized(page, options = {}) {
388
+ const small = await page.evaluate(findSmallTargets, options);
389
+ const overlaps = await page.evaluate(findOverlappingTargets, undefined);
390
+ if (!small.length && !overlaps.length)
391
+ return;
392
+ throw new Error([
393
+ `Targets are not sized (${small.length + overlaps.length}):`,
394
+ ...small.map((t) => ` ${t.element} is ${t.width} × ${t.height}px`),
395
+ ...overlaps.map((o) => ` ${o.a} overlaps ${o.b} by ${o.area}px²`),
396
+ ].join('\n'));
397
+ }
398
+ /**
399
+ * Focuses every control a keyboard can reach, one at a time, and compares a screenshot of it (and `pad` px
400
+ * around it) with focus on it and with focus somewhere else. An element whose pixels do not change has no
401
+ * visible focus indicator, which fails WCAG 2.4.7. "Somewhere else" is another control far from this one —
402
+ * not the page body, because a modal dialog or menu would pull focus straight back. Tooltips are hidden, and
403
+ * transitions, animations and the text caret are turned off, so only the indicator can make the difference.
404
+ * This is the check that finds a ring styled on an element that never receives focus.
405
+ */
406
+ export async function findInvisibleFocus(page, options = {}) {
407
+ const pad = options.pad ?? 8;
408
+ const count = await page.evaluate((ignore) => {
409
+ const style = document.createElement('style');
410
+ style.setAttribute('data-setsquare-focus-probe-style', '');
411
+ style.textContent =
412
+ '*, *::before, *::after { transition: none !important; animation: none !important; caret-color: transparent !important; scroll-behavior: auto !important; } [role=tooltip] { visibility: hidden !important; }';
413
+ document.head.append(style);
414
+ const composite = '[role=grid], [role=treegrid], [role=listbox], [role=menu], [role=menubar], [role=tree], [role=tablist], [role=radiogroup], [role=toolbar]';
415
+ const candidates = [
416
+ ...document.querySelectorAll('a[href], button, input:not([type=hidden]), select, textarea, summary, [tabindex]'),
417
+ ].filter((el) => {
418
+ if (el.matches(':disabled') || el.closest('[inert], [aria-hidden=true]'))
419
+ return false;
420
+ // Reached by Tab, or by the arrow keys inside a composite widget.
421
+ if (el.tabIndex >= 0)
422
+ return true;
423
+ return (el.getAttribute('tabindex') === '-1' &&
424
+ el.hasAttribute('role') &&
425
+ !!el.parentElement?.closest(composite));
426
+ });
427
+ candidates.forEach((el, i) => {
428
+ el.setAttribute('data-setsquare-focus-probe', String(i));
429
+ // Not checked, but still somewhere to move focus to: without them, a control in a tight group can have
430
+ // nowhere clear of its ring to send focus, and its ring falls out of the frame.
431
+ if (ignore && el.matches(ignore))
432
+ el.setAttribute('data-setsquare-focus-skip', '');
433
+ });
434
+ return candidates.length;
435
+ }, options.ignore);
436
+ // Keyboard modality, for React Aria's data-focus-visible and the browser's :focus-visible. (React Aria
437
+ // ignores Shift and the other modifiers, so it has to be a real key.)
438
+ await page.keyboard.press('Tab');
439
+ const out = [];
440
+ for (let i = 0; i < count; i++) {
441
+ // 1. Put focus on another control, far from this one, and measure this one where it now sits.
442
+ const box = await page.evaluate(async (args) => {
443
+ const frames = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
444
+ const el = document.querySelector(`[data-setsquare-focus-probe="${args.i}"]`);
445
+ if (!el || !el.isConnected || el.hasAttribute('data-setsquare-focus-skip'))
446
+ return null;
447
+ el.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'instant' });
448
+ const tiny = (e) => {
449
+ const r = e.getBoundingClientRect();
450
+ return r.width <= 2 && r.height <= 2;
451
+ };
452
+ // A visually hidden input stands for its control: measure the control around it.
453
+ let shown = el;
454
+ let hidden = tiny(el);
455
+ for (let p = el.parentElement, k = 0; p && k < 3 && !hidden; p = p.parentElement, k++)
456
+ if (tiny(p) && getComputedStyle(p).overflow !== 'visible')
457
+ hidden = true;
458
+ if (hidden)
459
+ for (let p = el.parentElement; p; p = p.parentElement)
460
+ if (!tiny(p)) {
461
+ shown = p;
462
+ break;
463
+ }
464
+ // Where it is when focused (a skip link only appears then) and when not: compare the union of both.
465
+ el.focus({ preventScroll: true });
466
+ await frames();
467
+ const r1 = shown.getBoundingClientRect();
468
+ const clip = {
469
+ left: r1.left - args.pad,
470
+ top: r1.top - args.pad,
471
+ right: r1.right + args.pad,
472
+ bottom: r1.bottom + args.pad,
473
+ };
474
+ const apart = (or, c) => or.right <= c.left || or.left >= c.right || or.bottom <= c.top || or.top >= c.bottom;
475
+ const all = [...document.querySelectorAll('[data-setsquare-focus-probe]')]
476
+ .filter((o) => o !== el && !shown.contains(o) && !o.contains(el))
477
+ .map((o) => ({
478
+ o,
479
+ r: o.getBoundingClientRect(),
480
+ d: Math.hypot(o.getBoundingClientRect().left - r1.left, o.getBoundingClientRect().top - r1.top),
481
+ }))
482
+ .sort((a, b) => b.d - a.d);
483
+ // Prefer a control clear of the padded area. In a tight stack (menu items) settle for one clear of the
484
+ // element itself, and compare the element's own box only, so the other's highlight is out of frame.
485
+ // With no other control at all, keep the padding: nothing else can light up in it, and a ring drawn
486
+ // outside the element (an outline with an offset) is only in frame with it.
487
+ let margin = args.pad;
488
+ let others = all.filter((x) => apart(x.r, clip));
489
+ if (!others.length && all.length) {
490
+ margin = 0;
491
+ others = all.filter((x) => apart(x.r, r1));
492
+ }
493
+ let away = false;
494
+ for (const { o } of others.slice(0, 8)) {
495
+ o.focus({ preventScroll: true });
496
+ await frames();
497
+ if (document.activeElement !== el && !shown.contains(document.activeElement)) {
498
+ away = true;
499
+ break;
500
+ }
501
+ }
502
+ if (!away) {
503
+ document.activeElement?.blur();
504
+ await frames();
505
+ away = document.activeElement !== el && !shown.contains(document.activeElement);
506
+ }
507
+ const r2 = shown.getBoundingClientRect();
508
+ const left = Math.min(r1.left, r2.left) - margin;
509
+ const top = Math.min(r1.top, r2.top) - margin;
510
+ const x = Math.max(0, left);
511
+ const y = Math.max(0, top);
512
+ const width = Math.min(innerWidth, Math.max(r1.right, r2.right) + margin) - x;
513
+ const height = Math.min(innerHeight, Math.max(r1.bottom, r2.bottom) + margin) - y;
514
+ const role = el.getAttribute('role');
515
+ const name = (el.getAttribute('aria-label') ?? el.textContent ?? '')
516
+ .trim()
517
+ .replace(/\s+/g, ' ')
518
+ .slice(0, 40);
519
+ return {
520
+ x,
521
+ y,
522
+ width,
523
+ height,
524
+ trapped: !away,
525
+ described: `${el.tagName.toLowerCase()}${role ? `[role=${role}]` : ''}${name ? ` "${name}"` : ''}`,
526
+ };
527
+ }, { i, pad });
528
+ if (!box)
529
+ continue;
530
+ if (box.trapped) {
531
+ out.push({ element: box.described, reason: 'trapped' });
532
+ continue;
533
+ }
534
+ if (box.width < 1 || box.height < 1) {
535
+ out.push({ element: box.described, reason: 'offscreen' });
536
+ continue;
537
+ }
538
+ const clip = { x: box.x, y: box.y, width: box.width, height: box.height };
539
+ const away = await page.screenshot({ clip, animations: 'disabled', caret: 'hide' });
540
+ // 2. Focus this one. If it hands focus to something inside it (a grid to its first row), that element is
541
+ // probed on its own turn.
542
+ const kept = await page.evaluate(async (n) => {
543
+ const el = document.querySelector(`[data-setsquare-focus-probe="${n}"]`);
544
+ if (!el)
545
+ return false;
546
+ el.focus({ preventScroll: true });
547
+ await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
548
+ return document.activeElement === el;
549
+ }, i);
550
+ if (!kept)
551
+ continue;
552
+ const here = await page.screenshot({ clip, animations: 'disabled', caret: 'hide' });
553
+ const same = away.length === here.length && away.every((byte, k) => byte === here[k]);
554
+ if (same)
555
+ out.push({ element: box.described, reason: 'unchanged' });
556
+ }
557
+ await page.evaluate(() => {
558
+ document.querySelector('style[data-setsquare-focus-probe-style]')?.remove();
559
+ for (const el of document.querySelectorAll('[data-setsquare-focus-probe]')) {
560
+ el.removeAttribute('data-setsquare-focus-probe');
561
+ el.removeAttribute('data-setsquare-focus-skip');
562
+ }
563
+ }, undefined);
564
+ return out;
565
+ }
566
+ /** Throws, listing every control whose focus cannot be seen, unless there are none. `trapped` ones are skipped. */
567
+ export async function expectFocusVisible(page, options = {}) {
568
+ const found = (await findInvisibleFocus(page, options)).filter((f) => f.reason !== 'trapped');
569
+ if (!found.length)
570
+ return;
571
+ throw new Error([
572
+ `Focus is not visible on ${found.length} control${found.length === 1 ? '' : 's'}:`,
573
+ ...found.map((f) => f.reason === 'unchanged'
574
+ ? ` ${f.element}: nothing on screen changed when it took focus`
575
+ : ` ${f.element}: took focus but could not be seen`),
576
+ ].join('\n'));
577
+ }
578
+ /**
579
+ * Every link in running text that people who can't tell the colours apart could not find. A link counts as
580
+ * in running text when it is laid out inline and the block around it has words of its own — so navigation,
581
+ * lists of links and link buttons are left alone, as WCAG's F73 leaves them. A link passes on an underline or
582
+ * bottom border the text around it doesn't have, a heavier weight (600+ against 200 lighter), italics the
583
+ * text around it doesn't have, or a colour at least 3:1 against that text (G183). Returns [] when every link
584
+ * is distinguished.
585
+ */
586
+ export function findUndistinguishedLinks(options = {}, root = document) {
587
+ const LINK = 'a[href], [role=link]';
588
+ const canvas = document.createElement('canvas');
589
+ canvas.width = canvas.height = 1;
590
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
591
+ /** Any CSS colour as 8-bit sRGB, through the canvas, so oklch and color-mix resolve the way they paint. */
592
+ const rgba = (colour) => {
593
+ ctx.clearRect(0, 0, 1, 1);
594
+ ctx.fillStyle = '#000';
595
+ ctx.fillStyle = colour;
596
+ ctx.fillRect(0, 0, 1, 1);
597
+ const d = ctx.getImageData(0, 0, 1, 1).data;
598
+ return [d[0], d[1], d[2], d[3] / 255];
599
+ };
600
+ const background = (el) => {
601
+ for (let p = el; p; p = p.parentElement) {
602
+ const c = rgba(getComputedStyle(p).backgroundColor);
603
+ if (c[3] > 0.99)
604
+ return [c[0], c[1], c[2]];
605
+ }
606
+ return [255, 255, 255];
607
+ };
608
+ const over = (el, colour) => {
609
+ const [r, g, b, a] = rgba(colour);
610
+ if (a > 0.99)
611
+ return [r, g, b];
612
+ const bg = background(el);
613
+ return [r * a + bg[0] * (1 - a), g * a + bg[1] * (1 - a), b * a + bg[2] * (1 - a)];
614
+ };
615
+ const luminance = ([r, g, b]) => {
616
+ const f = (v) => {
617
+ const c = v / 255;
618
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
619
+ };
620
+ return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
621
+ };
622
+ const contrast = (a, b) => {
623
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
624
+ return (hi + 0.05) / (lo + 0.05);
625
+ };
626
+ const underlined = (el) => getComputedStyle(el).textDecorationLine.includes('underline');
627
+ const bordered = (el) => {
628
+ const cs = getComputedStyle(el);
629
+ return (!['none', 'hidden'].includes(cs.borderBottomStyle) &&
630
+ parseFloat(cs.borderBottomWidth) >= 1 &&
631
+ rgba(cs.borderBottomColor)[3] > 0);
632
+ };
633
+ const tiny = (el) => {
634
+ const r = el.getBoundingClientRect();
635
+ return r.width <= 2 && r.height <= 2;
636
+ };
637
+ const describe = (el) => {
638
+ const name = (el.textContent ?? '').trim().replace(/\s+/g, ' ').slice(0, 40);
639
+ return `${el.tagName.toLowerCase()}${el.getAttribute('role') ? `[role=${el.getAttribute('role')}]` : ''}${name ? ` "${name}"` : ''}`;
640
+ };
641
+ const out = [];
642
+ for (const link of root.querySelectorAll(LINK)) {
643
+ if (options.ignore && link.matches(options.ignore))
644
+ continue;
645
+ if (link.closest('[inert], [aria-hidden=true]') || !link.getClientRects().length)
646
+ continue;
647
+ const cs = getComputedStyle(link);
648
+ if (cs.display !== 'inline' || cs.visibility !== 'visible')
649
+ continue;
650
+ // The block the sentence is in, and whether it has words outside its links.
651
+ let block = link.parentElement;
652
+ while (block && ['inline', 'contents'].includes(getComputedStyle(block).display))
653
+ block = block.parentElement;
654
+ if (!block)
655
+ continue;
656
+ let words = '';
657
+ const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
658
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
659
+ const parent = n.parentElement;
660
+ if (!parent || parent.closest(LINK) || parent.closest('[aria-hidden=true]') || tiny(parent))
661
+ continue;
662
+ words += n.textContent ?? '';
663
+ }
664
+ if (!/[\p{L}\p{N}]/u.test(words))
665
+ continue;
666
+ const around = link.parentElement;
667
+ const ac = getComputedStyle(around);
668
+ // Decoration isn't inherited in computed style but is painted on descendants, so an underline anywhere
669
+ // between the link and its block is on the surrounding words too.
670
+ let aroundUnderlined = false;
671
+ for (let p = around; p; p = p === block ? null : p.parentElement)
672
+ if (underlined(p))
673
+ aroundUnderlined = true;
674
+ const text = (link.textContent ?? '').trim();
675
+ const ownUnderline = underlined(link) ||
676
+ bordered(link) ||
677
+ [...link.querySelectorAll('*')].some((d) => (underlined(d) || bordered(d)) && (d.textContent ?? '').trim() === text);
678
+ const hasUnderline = ownUnderline && !aroundUnderlined;
679
+ const ratio = Math.round(contrast(over(link, cs.color), over(around, ac.color)) * 100) / 100;
680
+ if (options.underline) {
681
+ if (!hasUnderline)
682
+ out.push({ element: describe(link), contrast: ratio, reason: 'no-underline' });
683
+ continue;
684
+ }
685
+ const weight = parseFloat(cs.fontWeight);
686
+ const bold = weight >= 600 && weight - parseFloat(ac.fontWeight) >= 200;
687
+ const italic = cs.fontStyle !== 'normal' && ac.fontStyle === 'normal';
688
+ if (hasUnderline || bold || italic || ratio >= 3)
689
+ continue;
690
+ out.push({ element: describe(link), contrast: ratio, reason: 'colour-only' });
691
+ }
692
+ return out;
693
+ }
694
+ /** Throws, listing every finding, unless every link in running text can be told apart without colour. */
695
+ export async function expectLinksDistinguished(page, options = {}) {
696
+ const found = await page.evaluate(findUndistinguishedLinks, options);
697
+ if (!found.length)
698
+ return;
699
+ throw new Error([
700
+ `Links in running text are not distinguished (${found.length}):`,
701
+ ...found.map((f) => f.reason === 'no-underline'
702
+ ? ` ${f.element} has no underline`
703
+ : ` ${f.element} differs from the text around it only by colour, at ${f.contrast}:1 (needs an underline, bold, italics or 3:1)`),
704
+ ].join('\n'));
705
+ }
706
+ /**
707
+ * Hovers every target and reads the cursor at its centre and just inside each corner — the whole of Layer 1,
708
+ * not only the paint — so a child that resets the cursor, or a target whose edge still shows the arrow, is
709
+ * found. Hovering first means states that only appear on hover (React Aria's data-hovered) count. Points
710
+ * covered by another target (a checkbox in a row) are that target's, and are checked on its own turn. A
711
+ * visually hidden input stands for its label (a checkbox, radio or switch) or, for a range input, the thumb
712
+ * around it; one with neither (a drop zone's hidden button) has no pointer target and is skipped. No WCAG
713
+ * criterion covers the cursor: this checks that one rule is applied everywhere.
714
+ */
715
+ export async function findCursorMismatches(page, options = {}) {
716
+ const probes = await page.evaluate((ignore) => {
717
+ const style = document.createElement('style');
718
+ style.setAttribute('data-setsquare-cursor-probe-style', '');
719
+ style.textContent =
720
+ '[role=tooltip] { visibility: hidden !important; } * { scroll-behavior: auto !important; }';
721
+ document.head.append(style);
722
+ const TEXT = 'input:is(:not([type]), [type=text], [type=search], [type=email], [type=url], [type=tel], [type=password], [type=number]), textarea, [contenteditable]:not([contenteditable=false]), [role=spinbutton]';
723
+ const PRESS = 'a[href], button, input:is([type=button], [type=submit], [type=reset], [type=checkbox], [type=radio], [type=file]), select, summary, [role=button], [role=link], [role=checkbox], [role=radio], [role=switch], [role=tab], [role=menuitem], [role=menuitemcheckbox], [role=menuitemradio], [role=option], [role=treeitem], [role=row][aria-selected], [role=columnheader][aria-sort], th[aria-sort]';
724
+ const tiny = (el) => {
725
+ const r = el.getBoundingClientRect();
726
+ return r.width <= 2 && r.height <= 2;
727
+ };
728
+ const hiddenInput = (el) => {
729
+ if (tiny(el))
730
+ return true;
731
+ for (let p = el.parentElement, k = 0; p && k < 3; p = p.parentElement, k++)
732
+ if (tiny(p) && getComputedStyle(p).overflow !== 'visible')
733
+ return true;
734
+ return false;
735
+ };
736
+ const out = [];
737
+ const seen = new Set();
738
+ for (const found of document.querySelectorAll(`${PRESS}, ${TEXT}, input[type=range]`)) {
739
+ if (found.closest('[inert], [aria-hidden=true]') || !found.getClientRects().length)
740
+ continue;
741
+ let el = found;
742
+ if (hiddenInput(found)) {
743
+ el = null;
744
+ if (found.matches('input[type=range]')) {
745
+ for (let p = found.parentElement; p; p = p.parentElement)
746
+ if (!tiny(p)) {
747
+ el = p;
748
+ break;
749
+ }
750
+ }
751
+ else {
752
+ const label = found.closest('label');
753
+ if (label && !tiny(label))
754
+ el = label;
755
+ }
756
+ }
757
+ if (!el || seen.has(el))
758
+ continue;
759
+ seen.add(el);
760
+ if (ignore && (el.matches(ignore) || found.matches(ignore)))
761
+ continue;
762
+ if (getComputedStyle(el).visibility !== 'visible')
763
+ continue;
764
+ const off = (e) => e.matches(':disabled, [aria-disabled=true], [data-disabled]');
765
+ const disabled = off(found) || off(el) || !!found.closest('fieldset:disabled');
766
+ const kind = found.matches('[data-pending], [aria-busy=true]')
767
+ ? 'pending'
768
+ : disabled && (found.hasAttribute('aria-current') || el.hasAttribute('aria-current'))
769
+ ? 'current'
770
+ : disabled
771
+ ? 'disabled'
772
+ : found.matches(TEXT)
773
+ ? 'text'
774
+ : found.matches('input[type=range]')
775
+ ? 'drag'
776
+ : found.matches('[aria-readonly=true], input[readonly]') ||
777
+ el.matches('[data-readonly]')
778
+ ? 'readonly'
779
+ : 'press';
780
+ const role = el.getAttribute('role');
781
+ const name = (el.getAttribute('aria-label') ?? el.textContent ?? '')
782
+ .trim()
783
+ .replace(/\s+/g, ' ')
784
+ .slice(0, 40);
785
+ el.setAttribute('data-setsquare-cursor-probe', String(out.length));
786
+ out.push({
787
+ i: out.length,
788
+ kind,
789
+ described: `${el.tagName.toLowerCase()}${role ? `[role=${role}]` : ''}${name ? ` "${name}"` : ''}`,
790
+ });
791
+ }
792
+ return out;
793
+ }, options.ignore);
794
+ const defaults = {
795
+ press: ['pointer'],
796
+ text: ['text', 'auto'],
797
+ drag: ['grab'],
798
+ disabled: ['not-allowed'],
799
+ pending: ['wait', 'progress'],
800
+ readonly: ['default'],
801
+ current: ['default'],
802
+ };
803
+ const out = [];
804
+ for (const probe of probes) {
805
+ const setting = options.expect?.[probe.kind];
806
+ if (setting === false)
807
+ continue;
808
+ const expected = setting ?? defaults[probe.kind];
809
+ const centre = await page.evaluate((i) => {
810
+ const el = document.querySelector(`[data-setsquare-cursor-probe="${i}"]`);
811
+ if (!el)
812
+ return null;
813
+ el.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'instant' });
814
+ const r = el.getBoundingClientRect();
815
+ const left = Math.max(r.left, 0), top = Math.max(r.top, 0), right = Math.min(r.right, innerWidth), bottom = Math.min(r.bottom, innerHeight);
816
+ if (right - left < 1 || bottom - top < 1)
817
+ return null;
818
+ return { x: (left + right) / 2, y: (top + bottom) / 2 };
819
+ }, probe.i);
820
+ if (!centre)
821
+ continue;
822
+ await page.mouse.move(centre.x, centre.y);
823
+ const seen = await page.evaluate((i) => {
824
+ const el = document.querySelector(`[data-setsquare-cursor-probe="${i}"]`);
825
+ if (!el)
826
+ return [];
827
+ const r = el.getBoundingClientRect();
828
+ const left = Math.max(r.left, 0), top = Math.max(r.top, 0), right = Math.min(r.right, innerWidth), bottom = Math.min(r.bottom, innerHeight);
829
+ // Just inside each corner, clear of a rounded corner's cut.
830
+ const inset = Math.min(4, (right - left) / 4, (bottom - top) / 4);
831
+ const points = [
832
+ [(left + right) / 2, (top + bottom) / 2, 'centre'],
833
+ [left + inset, top + inset, 'edge'],
834
+ [right - inset, top + inset, 'edge'],
835
+ [left + inset, bottom - inset, 'edge'],
836
+ [right - inset, bottom - inset, 'edge'],
837
+ ];
838
+ const found = [];
839
+ for (const [x, y, at] of points) {
840
+ const hit = document.elementFromPoint(x, y);
841
+ if (!hit)
842
+ continue;
843
+ // Only points where this target is the nearest one to what the pointer is over.
844
+ const owner = hit.closest('[data-setsquare-cursor-probe]');
845
+ if (owner !== el)
846
+ continue;
847
+ found.push({ cursor: getComputedStyle(hit).cursor, at });
848
+ }
849
+ return found;
850
+ }, probe.i);
851
+ const wrong = seen.filter((s) => !expected.includes(s.cursor));
852
+ if (wrong.length)
853
+ out.push({
854
+ element: probe.described,
855
+ kind: probe.kind,
856
+ expected,
857
+ actual: wrong.filter((w, k) => wrong.findIndex((o) => o.cursor === w.cursor && o.at === w.at) === k),
858
+ });
859
+ }
860
+ await page.mouse.move(0, 0);
861
+ await page.evaluate(() => {
862
+ document.querySelector('style[data-setsquare-cursor-probe-style]')?.remove();
863
+ for (const el of document.querySelectorAll('[data-setsquare-cursor-probe]'))
864
+ el.removeAttribute('data-setsquare-cursor-probe');
865
+ }, undefined);
866
+ return out;
867
+ }
868
+ /** Throws, listing every target whose cursor breaks the rule, unless there are none. */
869
+ export async function expectCursorsMatch(page, options = {}) {
870
+ const found = await findCursorMismatches(page, options);
871
+ if (!found.length)
872
+ return;
873
+ throw new Error([
874
+ `Cursors do not match (${found.length}):`,
875
+ ...found.map((f) => ` ${f.element} (${f.kind}): ${f.actual.map((a) => `${a.cursor} at the ${a.at}`).join(', ')}; expected ${f.expected.join(' or ')}`),
876
+ ].join('\n'));
877
+ }
878
+ /**
879
+ * Everything that reaches past the right edge of the page at the width it is laid out at: set the viewport
880
+ * to 320px to check WCAG 1.4.10's reflow width. Content inside its own scroll container (a `ScrollRegion`,
881
+ * a code block, a data table) is left alone as long as the scroller itself fits, since it scrolls on its own
882
+ * and the page doesn't; so is content an ancestor clips inside the page, which is that component's business.
883
+ * `clipped` is only reported for something with words or media in it, so a decorative glow bleeding off the
884
+ * edge isn't. Fixed-position elements are left out. Only the outermost culprit is reported. Returns [] when
885
+ * the page fits.
886
+ */
887
+ export function findPageOverflow(options = {}, root = document) {
888
+ const describe = (el) => {
889
+ const id = el.id ? `#${el.id}` : '';
890
+ const cls = typeof el.className === 'string'
891
+ ? el.className
892
+ .split(/\s+/)
893
+ .filter(Boolean)
894
+ .slice(0, 3)
895
+ .map((c) => `.${c}`)
896
+ .join('')
897
+ : '';
898
+ const name = (el.getAttribute('aria-label') ?? el.textContent ?? '')
899
+ .trim()
900
+ .replace(/\s+/g, ' ')
901
+ .slice(0, 40);
902
+ return `${el.tagName.toLowerCase()}${id}${cls}${name ? ` "${name}"` : ''}`;
903
+ };
904
+ const page = document.scrollingElement ?? document.documentElement;
905
+ const width = document.documentElement.clientWidth;
906
+ const scrolls = page.scrollWidth > width;
907
+ // overflow on <html> or <body> clips at the viewport: content past the edge is cut off, not scrolled to.
908
+ const pageClips = [document.documentElement, document.body].some((e) => /(hidden|clip)/.test(getComputedStyle(e).overflowX));
909
+ /** Right edge in page coordinates, so a page already scrolled sideways measures the same. */
910
+ const rightOf = (el) => el.getBoundingClientRect().right + page.scrollLeft;
911
+ const meaningful = (el) => !el.closest('[aria-hidden=true]') &&
912
+ (!!el.textContent?.trim() ||
913
+ !!el.matches('img, svg, video, canvas, iframe, input, select, textarea') ||
914
+ !!el.querySelector('img, svg, video, canvas, iframe, input, select, textarea'));
915
+ const out = [];
916
+ const reported = [];
917
+ for (const el of Array.from(root.querySelectorAll('*'))) {
918
+ if (el === document.documentElement || el === document.body)
919
+ continue;
920
+ if (options.ignore && el.closest(options.ignore))
921
+ continue;
922
+ if (reported.some((r) => r.contains(el)))
923
+ continue;
924
+ const r = el.getBoundingClientRect();
925
+ if (r.width === 0 && r.height === 0)
926
+ continue;
927
+ const by = rightOf(el) - width;
928
+ if (by <= 0.5)
929
+ continue;
930
+ // Walk out to whatever decides where this overflow ends up.
931
+ let fixed = getComputedStyle(el).position === 'fixed';
932
+ let held = false; // an ancestor inside the page scrolls or clips it: not the page's problem
933
+ let clipped = false; // an ancestor that runs to the page's edge cuts it off
934
+ for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) {
935
+ const cs = getComputedStyle(p);
936
+ if (cs.position === 'fixed') {
937
+ fixed = true;
938
+ break;
939
+ }
940
+ if (cs.overflowX === 'visible')
941
+ continue;
942
+ const edge = rightOf(p);
943
+ if (/(auto|scroll)/.test(cs.overflowX)) {
944
+ if (edge <= width + 0.5) {
945
+ held = true; // its own scroller holds it, and the scroller fits
946
+ break;
947
+ }
948
+ continue; // a scroller that is itself too wide is reported on its own turn
949
+ }
950
+ if (edge < width - 1) {
951
+ held = true; // clipped inside the page: that component's business
952
+ break;
953
+ }
954
+ clipped = true;
955
+ break;
956
+ }
957
+ if (fixed || held)
958
+ continue;
959
+ const reason = clipped || pageClips ? 'clipped' : scrolls ? 'scrolls' : null;
960
+ if (!reason || (reason === 'clipped' && !meaningful(el)))
961
+ continue;
962
+ reported.push(el);
963
+ out.push({ element: describe(el), reason, by: Math.round(by * 10) / 10 });
964
+ }
965
+ if (scrolls && !out.some((o) => o.reason === 'scrolls'))
966
+ out.push({ element: 'the page', reason: 'scrolls', by: page.scrollWidth - width });
967
+ return out;
968
+ }
969
+ /** Throws, listing every finding, unless the page fits its width. Set the viewport first: 320px for reflow. */
970
+ export async function expectPageReflows(page, options = {}) {
971
+ const found = await page.evaluate(findPageOverflow, options);
972
+ if (!found.length)
973
+ return;
974
+ throw new Error([
975
+ `The page doesn't fit its width (${found.length}):`,
976
+ ...found.map((f) => f.reason === 'scrolls'
977
+ ? ` ${f.element} reaches ${f.by}px past the edge, so the page scrolls sideways`
978
+ : ` ${f.element} reaches ${f.by}px past the edge, where an overflow:hidden box cuts it off`),
979
+ ].join('\n'));
980
+ }
981
+ /**
982
+ * Applies WCAG 1.4.12's four text-spacing metrics to the whole page — line height 1.5, paragraph spacing
983
+ * 2em, letter spacing 0.12em, word spacing 0.16em, the way a user's stylesheet or bookmarklet would — and
984
+ * reports every box whose content now runs out of it further than it did before. Measuring the difference
985
+ * leaves alone anything that was already clipped on purpose (visually hidden text, a decorative bleed).
986
+ * Scroll containers are left out too, since their content can still be scrolled to, and so is text that
987
+ * spills from a box inside a scroller that scrolls that way (code in a code block). Only the innermost box
988
+ * is reported. Takes the spacing off again before returning. Returns [] when everything still fits.
989
+ */
990
+ export function findClippedText(options = {}, root = document) {
991
+ const describe = (el) => {
992
+ const id = el.id ? `#${el.id}` : '';
993
+ const cls = typeof el.className === 'string'
994
+ ? el.className
995
+ .split(/\s+/)
996
+ .filter(Boolean)
997
+ .slice(0, 3)
998
+ .map((c) => `.${c}`)
999
+ .join('')
1000
+ : '';
1001
+ const name = (el.getAttribute('aria-label') ?? el.textContent ?? '')
1002
+ .trim()
1003
+ .replace(/\s+/g, ' ')
1004
+ .slice(0, 40);
1005
+ return `${el.tagName.toLowerCase()}${id}${cls}${name ? ` "${name}"` : ''}`;
1006
+ };
1007
+ const boxes = Array.from(root.querySelectorAll('*')).filter((el) => {
1008
+ if (el === document.documentElement || el === document.body)
1009
+ return false;
1010
+ if (options.ignore && el.closest(options.ignore))
1011
+ return false;
1012
+ const cs = getComputedStyle(el);
1013
+ if (cs.display === 'inline' || cs.display === 'contents' || cs.display === 'none')
1014
+ return false;
1015
+ if (/(auto|scroll)/.test(cs.overflowX + cs.overflowY))
1016
+ return false;
1017
+ const r = el.getBoundingClientRect();
1018
+ // 1px visually hidden boxes clip on purpose.
1019
+ return r.width > 2 && r.height > 2 && !!el.textContent?.trim();
1020
+ });
1021
+ const excess = (el) => [
1022
+ Math.max(0, el.scrollWidth - el.clientWidth),
1023
+ Math.max(0, el.scrollHeight - el.clientHeight),
1024
+ ];
1025
+ const before = boxes.map(excess);
1026
+ const style = document.createElement('style');
1027
+ style.setAttribute('data-setsquare-text-spacing', '');
1028
+ style.textContent =
1029
+ '* { line-height: 1.5 !important; letter-spacing: 0.12em !important; word-spacing: 0.16em !important; } p { margin-bottom: 2em !important; }';
1030
+ document.head.append(style);
1031
+ /** Which ways the nearest ancestor that doesn't show its overflow scrolls: text spilled that way is reachable. */
1032
+ const scrollsTo = (el) => {
1033
+ for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) {
1034
+ const cs = getComputedStyle(p);
1035
+ if (cs.overflowX === 'visible' && cs.overflowY === 'visible')
1036
+ continue;
1037
+ return { x: /(auto|scroll)/.test(cs.overflowX), y: /(auto|scroll)/.test(cs.overflowY) };
1038
+ }
1039
+ return { x: false, y: false };
1040
+ };
1041
+ const clips = (el) => {
1042
+ const cs = getComputedStyle(el);
1043
+ return /(hidden|clip)/.test(cs.overflowX + cs.overflowY) || cs.textOverflow === 'ellipsis';
1044
+ };
1045
+ const found = [];
1046
+ boxes.forEach((el, i) => {
1047
+ const [x, y] = excess(el);
1048
+ let dx = x - before[i][0];
1049
+ let dy = y - before[i][1];
1050
+ if (!clips(el)) {
1051
+ const reach = scrollsTo(el);
1052
+ if (reach.x)
1053
+ dx = 0;
1054
+ if (reach.y)
1055
+ dy = 0;
1056
+ }
1057
+ if (dx > 1 || dy > 1)
1058
+ found.push({ el, x: Math.round(dx), y: Math.round(dy) });
1059
+ });
1060
+ const out = found
1061
+ .filter((f) => !found.some((g) => g !== f && f.el.contains(g.el)))
1062
+ .map((f) => ({
1063
+ element: describe(f.el),
1064
+ reason: clips(f.el) ? 'clipped' : 'overflows',
1065
+ x: f.x,
1066
+ y: f.y,
1067
+ }));
1068
+ style.remove();
1069
+ return out;
1070
+ }
1071
+ /** Throws, listing every finding, unless all the text still fits with WCAG 1.4.12's spacing applied. */
1072
+ export async function expectTextUnclipped(page, options = {}) {
1073
+ const found = await page.evaluate(findClippedText, options);
1074
+ if (!found.length)
1075
+ return;
1076
+ throw new Error([
1077
+ `Text doesn't fit with 1.4.12's spacing applied (${found.length}):`,
1078
+ ...found.map((f) => ` ${f.element} ${f.reason === 'clipped' ? 'cuts its text off' : 'spills its text over what follows'} (${f.x} × ${f.y}px more than before)`),
1079
+ ].join('\n'));
1080
+ }
1081
+ /**
1082
+ * Everything moving on the page that runs longer than `max` (or forever) with no visible control to pause
1083
+ * it. It reads `document.getAnimations()` — CSS animations, CSS transitions and Web Animations that are
1084
+ * running now — and every `<video>` that is playing without its own `controls`. Something counts as paused
1085
+ * by a control when it sits inside an element whose `id` a visible button on the page names in
1086
+ * `data-pauses`; `aria-controls` doesn't count, since a menu button names its panel that way. It can't see
1087
+ * motion drawn by `requestAnimationFrame`, SVG (SMIL)
1088
+ * animation, animated images or canvas, and it only sees what is running when it is called, so call it once
1089
+ * the page has settled and again in each state that starts something. Returns [] when nothing qualifies.
1090
+ */
1091
+ export function findEndlessAnimations(options = {}, root = document) {
1092
+ const max = options.max ?? 5000;
1093
+ const describe = (el, pseudo) => {
1094
+ const id = el.id ? `#${el.id}` : '';
1095
+ const cls = typeof el.className === 'string'
1096
+ ? el.className
1097
+ .split(/\s+/)
1098
+ .filter(Boolean)
1099
+ .slice(0, 3)
1100
+ .map((c) => `.${c}`)
1101
+ .join('')
1102
+ : '';
1103
+ const name = (el.getAttribute('aria-label') ?? el.textContent ?? '')
1104
+ .trim()
1105
+ .replace(/\s+/g, ' ')
1106
+ .slice(0, 40);
1107
+ return `${el.tagName.toLowerCase()}${id}${cls}${pseudo ?? ''}${name ? ` "${name}"` : ''}`;
1108
+ };
1109
+ // On screen. aria-hidden doesn't count against it: decorative motion is usually aria-hidden, and still moves.
1110
+ const shown = (el) => el.getClientRects().length > 0 && getComputedStyle(el).visibility === 'visible';
1111
+ // The ids that a visible button someone can use says it pauses.
1112
+ const paused = new Set();
1113
+ for (const b of document.querySelectorAll('button, [role=button]')) {
1114
+ if (!shown(b) || b.closest('[inert], [aria-hidden=true]'))
1115
+ continue;
1116
+ for (const id of (b.getAttribute('data-pauses') ?? '').split(/\s+/))
1117
+ if (id)
1118
+ paused.add(id);
1119
+ }
1120
+ const controlled = (el) => {
1121
+ for (let p = el; p; p = p.parentElement)
1122
+ if (p.id && paused.has(p.id))
1123
+ return true;
1124
+ return false;
1125
+ };
1126
+ const inRoot = (el) => root.contains(el);
1127
+ const out = [];
1128
+ const seen = new Set();
1129
+ for (const a of document.getAnimations()) {
1130
+ if (a.playState !== 'running')
1131
+ continue;
1132
+ const effect = a.effect;
1133
+ const target = effect?.target;
1134
+ if (!target || !inRoot(target) || !shown(target))
1135
+ continue;
1136
+ if (options.ignore && target.closest(options.ignore))
1137
+ continue;
1138
+ const duration = Number(effect.getComputedTiming().activeDuration);
1139
+ if (!(duration > max) || controlled(target))
1140
+ continue;
1141
+ const animation = 'animationName' in a
1142
+ ? String(a.animationName)
1143
+ : 'transitionProperty' in a
1144
+ ? `transition: ${a.transitionProperty}`
1145
+ : 'script';
1146
+ const element = describe(target, effect.pseudoElement);
1147
+ const key = `${element}|${animation}`;
1148
+ if (seen.has(key))
1149
+ continue;
1150
+ seen.add(key);
1151
+ out.push({ element, animation, duration });
1152
+ }
1153
+ for (const v of root.querySelectorAll('video')) {
1154
+ if (v.paused || v.ended || v.controls || !shown(v))
1155
+ continue;
1156
+ if (options.ignore && v.closest(options.ignore))
1157
+ continue;
1158
+ const duration = v.loop || !Number.isFinite(v.duration) ? Infinity : v.duration * 1000;
1159
+ if (!(duration > max) || controlled(v))
1160
+ continue;
1161
+ out.push({ element: describe(v), animation: 'video', duration });
1162
+ }
1163
+ return out;
1164
+ }
1165
+ /** Throws, listing every finding, unless everything that moves for longer than `max` can be paused. */
1166
+ export async function expectAnimationsPausable(page, options = {}) {
1167
+ const found = await page.evaluate(findEndlessAnimations, options);
1168
+ if (!found.length)
1169
+ return;
1170
+ throw new Error([
1171
+ `Motion runs on with nothing to pause it (${found.length}):`,
1172
+ ...found.map((f) => ` ${f.element}: ${f.animation}, ${f.duration === Infinity ? 'forever' : `${Math.round(f.duration / 100) / 10}s`}`),
1173
+ ].join('\n'));
1174
+ }
1175
+ //# sourceMappingURL=index.js.map