@invisibleloop/pulse 0.1.21

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 (344) hide show
  1. package/.claude/commands/build-page.md +59 -0
  2. package/.claude/commands/new-doc-page.md +45 -0
  3. package/.claude/commands/verify.md +52 -0
  4. package/.claude/pulse-checklist.md +111 -0
  5. package/.claude/settings.local.json +102 -0
  6. package/.github/workflows/ci.yml +22 -0
  7. package/.github/workflows/publish.yml +41 -0
  8. package/.pulse/load-reports/home/1773432711417.json +22 -0
  9. package/CLAUDE.md +383 -0
  10. package/README.md +95 -0
  11. package/docs/.claude/pulse-checklist.md +111 -0
  12. package/docs/public/.pulse-ui-version +1 -0
  13. package/docs/public/dist/accessibility.boot-5DVTARJU.js +115 -0
  14. package/docs/public/dist/actions.boot-P66HKQEM.js +164 -0
  15. package/docs/public/dist/auth.boot-IMAJAUPH.js +140 -0
  16. package/docs/public/dist/caching.boot-DVR6KDE7.js +53 -0
  17. package/docs/public/dist/components--accordion.boot-3HVKMNWC.js +11 -0
  18. package/docs/public/dist/components--alert.boot-GCEXOZAC.js +6 -0
  19. package/docs/public/dist/components--app-badge.boot-DVT3GCHJ.js +6 -0
  20. package/docs/public/dist/components--avatar.boot-PSW24EVA.js +5 -0
  21. package/docs/public/dist/components--badge.boot-TYDY2RMK.js +7 -0
  22. package/docs/public/dist/components--banner.boot-EI5PZSZK.js +7 -0
  23. package/docs/public/dist/components--breadcrumbs.boot-SMA2E2GO.js +34 -0
  24. package/docs/public/dist/components--button.boot-J54BQM2E.js +23 -0
  25. package/docs/public/dist/components--card.boot-PZGNDIB6.js +138 -0
  26. package/docs/public/dist/components--carousel.boot-TP6LPFZZ.js +12 -0
  27. package/docs/public/dist/components--charts.boot-2EOYQWKL.js +108 -0
  28. package/docs/public/dist/components--checkbox.boot-DS5BSL6T.js +54 -0
  29. package/docs/public/dist/components--cluster.boot-HHVIBBJG.js +9 -0
  30. package/docs/public/dist/components--code-window.boot-2GR2DV33.js +20 -0
  31. package/docs/public/dist/components--container.boot-7LOOGK2K.js +5 -0
  32. package/docs/public/dist/components--cta.boot-FSNZ5YRT.js +11 -0
  33. package/docs/public/dist/components--divider.boot-3NI2C3QG.js +6 -0
  34. package/docs/public/dist/components--empty.boot-YX2UR3PV.js +7 -0
  35. package/docs/public/dist/components--feature.boot-MUD7NSUO.js +13 -0
  36. package/docs/public/dist/components--fieldset.boot-J7BYHMKF.js +19 -0
  37. package/docs/public/dist/components--fileupload.boot-NIKVTTPD.js +52 -0
  38. package/docs/public/dist/components--footer.boot-EYUK5FRG.js +14 -0
  39. package/docs/public/dist/components--grid.boot-URDQVDDR.js +59 -0
  40. package/docs/public/dist/components--heading.boot-BPQKU43E.js +44 -0
  41. package/docs/public/dist/components--hero.boot-4RAPRGAB.js +17 -0
  42. package/docs/public/dist/components--icons.boot-ZITNU5JP.js +68 -0
  43. package/docs/public/dist/components--image.boot-XEEGHQZF.js +19 -0
  44. package/docs/public/dist/components--input.boot-SGASZG5K.js +7 -0
  45. package/docs/public/dist/components--list.boot-W3XC5MHD.js +55 -0
  46. package/docs/public/dist/components--media.boot-5VFIETZO.js +13 -0
  47. package/docs/public/dist/components--modal.boot-RZUYXBN2.js +47 -0
  48. package/docs/public/dist/components--nav.boot-ODBOHU7O.js +33 -0
  49. package/docs/public/dist/components--pricing.boot-4AQ4ZVBY.js +21 -0
  50. package/docs/public/dist/components--progress.boot-GHAGYZOK.js +30 -0
  51. package/docs/public/dist/components--prose.boot-QANJL6JI.js +67 -0
  52. package/docs/public/dist/components--pullquote.boot-Q2WMNAZU.js +22 -0
  53. package/docs/public/dist/components--radio.boot-TJRDQ2OL.js +75 -0
  54. package/docs/public/dist/components--rating.boot-QBAN6DEL.js +38 -0
  55. package/docs/public/dist/components--search.boot-PXH5O5AG.js +17 -0
  56. package/docs/public/dist/components--section.boot-AQGIYHWW.js +12 -0
  57. package/docs/public/dist/components--segmented.boot-BEVTKEJO.js +33 -0
  58. package/docs/public/dist/components--select.boot-47X5RHOC.js +10 -0
  59. package/docs/public/dist/components--slider.boot-PSRRX7XL.js +47 -0
  60. package/docs/public/dist/components--spinner.boot-MZ5MO2OH.js +22 -0
  61. package/docs/public/dist/components--stack.boot-DI4NJXBF.js +9 -0
  62. package/docs/public/dist/components--stat.boot-QMFUWBQT.js +9 -0
  63. package/docs/public/dist/components--stepper.boot-34PP2NEV.js +22 -0
  64. package/docs/public/dist/components--table.boot-FCQGSFIQ.js +11 -0
  65. package/docs/public/dist/components--testimonial.boot-DWQPDKYG.js +11 -0
  66. package/docs/public/dist/components--textarea.boot-QVXLBOJ5.js +4 -0
  67. package/docs/public/dist/components--timeline.boot-26LN52P2.js +95 -0
  68. package/docs/public/dist/components--toggle.boot-IQQEI76S.js +29 -0
  69. package/docs/public/dist/components--tooltip.boot-LGHCO6NN.js +9 -0
  70. package/docs/public/dist/components.boot-SE6PQ4P7.js +103 -0
  71. package/docs/public/dist/config.boot-DTRRWUE6.js +126 -0
  72. package/docs/public/dist/constraints.boot-DUHDZBMC.js +71 -0
  73. package/docs/public/dist/deploy.boot-SLAD3NI2.js +163 -0
  74. package/docs/public/dist/docs-8e3d4b5c.css +1 -0
  75. package/docs/public/dist/extending.boot-UA3CN243.js +159 -0
  76. package/docs/public/dist/faq.boot-6EQAWLQR.js +43 -0
  77. package/docs/public/dist/getting-started.boot-TDKIFL5U.js +86 -0
  78. package/docs/public/dist/guard.boot-AUHAWTG4.js +80 -0
  79. package/docs/public/dist/home.boot-BVQXRH32.js +383 -0
  80. package/docs/public/dist/how-it-works.boot-LTWAKWKW.js +104 -0
  81. package/docs/public/dist/hydration.boot-JRM6IPJL.js +78 -0
  82. package/docs/public/dist/images.boot-M6ZVKTZS.js +80 -0
  83. package/docs/public/dist/manifest.json +94 -0
  84. package/docs/public/dist/meta.boot-7NXGPHR4.js +79 -0
  85. package/docs/public/dist/mutations.boot-F6F43UDX.js +79 -0
  86. package/docs/public/dist/navigation.boot-AOXWS3ZF.js +57 -0
  87. package/docs/public/dist/performance.boot-C3UPCOBK.js +98 -0
  88. package/docs/public/dist/persist.boot-WT32PQOQ.js +61 -0
  89. package/docs/public/dist/project-structure.boot-FB3LRVJ4.js +63 -0
  90. package/docs/public/dist/prompt-examples.boot-YKR4VDK4.js +31 -0
  91. package/docs/public/dist/pulse-ui-81a85c03.css +1 -0
  92. package/docs/public/dist/raw-responses.boot-M4KA5YXL.js +104 -0
  93. package/docs/public/dist/routing.boot-FNX5FDGH.js +70 -0
  94. package/docs/public/dist/runtime-B73WLANC.js +1 -0
  95. package/docs/public/dist/runtime-KO4BHUQ3.js +49 -0
  96. package/docs/public/dist/runtime-L2HNXIHW.js +59 -0
  97. package/docs/public/dist/runtime-QFURDKA2.js +5 -0
  98. package/docs/public/dist/runtime-UVPXO4IR.js +375 -0
  99. package/docs/public/dist/runtime-VMJA3Z4N.js +10 -0
  100. package/docs/public/dist/runtime-ZJ4FXT5O.js +11 -0
  101. package/docs/public/dist/server-api.boot-K7X3LCFB.js +219 -0
  102. package/docs/public/dist/server-data.boot-Y7HQYC4R.js +157 -0
  103. package/docs/public/dist/slash-commands.boot-V2UV7OW2.js +26 -0
  104. package/docs/public/dist/spec.boot-2WU7ZHCV.js +159 -0
  105. package/docs/public/dist/state.boot-B24GUE3R.js +73 -0
  106. package/docs/public/dist/store.boot-TLIB4XHH.js +150 -0
  107. package/docs/public/dist/streaming.boot-W2DZSMW4.js +80 -0
  108. package/docs/public/dist/stripe.boot-QN3C2GEL.js +164 -0
  109. package/docs/public/dist/supabase.boot-BG4XXLZE.js +303 -0
  110. package/docs/public/dist/testing.boot-6U4WKMTE.js +130 -0
  111. package/docs/public/dist/validation.boot-PQHYGW5B.js +100 -0
  112. package/docs/public/docs.css +2020 -0
  113. package/docs/public/menu.js +83 -0
  114. package/docs/public/pulse-ui.css +2739 -0
  115. package/docs/public/pulse-ui.js +236 -0
  116. package/docs/server.js +192 -0
  117. package/docs/src/lib/component-page.js +47 -0
  118. package/docs/src/lib/highlight.js +255 -0
  119. package/docs/src/lib/layout.js +131 -0
  120. package/docs/src/lib/metrics-store.js +6 -0
  121. package/docs/src/lib/nav.js +159 -0
  122. package/docs/src/lib/stats.js +81 -0
  123. package/docs/src/pages/accessibility.js +157 -0
  124. package/docs/src/pages/actions.js +191 -0
  125. package/docs/src/pages/auth.js +177 -0
  126. package/docs/src/pages/caching.js +95 -0
  127. package/docs/src/pages/components/accordion.js +48 -0
  128. package/docs/src/pages/components/alert.js +35 -0
  129. package/docs/src/pages/components/app-badge.js +41 -0
  130. package/docs/src/pages/components/avatar.js +35 -0
  131. package/docs/src/pages/components/badge.js +36 -0
  132. package/docs/src/pages/components/banner.js +45 -0
  133. package/docs/src/pages/components/breadcrumbs.js +94 -0
  134. package/docs/src/pages/components/button.js +84 -0
  135. package/docs/src/pages/components/card.js +225 -0
  136. package/docs/src/pages/components/carousel.js +72 -0
  137. package/docs/src/pages/components/charts.js +278 -0
  138. package/docs/src/pages/components/checkbox.js +129 -0
  139. package/docs/src/pages/components/cluster.js +47 -0
  140. package/docs/src/pages/components/code-window.js +57 -0
  141. package/docs/src/pages/components/container.js +40 -0
  142. package/docs/src/pages/components/cta.js +53 -0
  143. package/docs/src/pages/components/divider.js +37 -0
  144. package/docs/src/pages/components/empty.js +36 -0
  145. package/docs/src/pages/components/feature.js +60 -0
  146. package/docs/src/pages/components/fieldset.js +65 -0
  147. package/docs/src/pages/components/fileupload.js +127 -0
  148. package/docs/src/pages/components/footer.js +58 -0
  149. package/docs/src/pages/components/grid.js +165 -0
  150. package/docs/src/pages/components/heading.js +107 -0
  151. package/docs/src/pages/components/hero.js +65 -0
  152. package/docs/src/pages/components/icons.js +285 -0
  153. package/docs/src/pages/components/image.js +71 -0
  154. package/docs/src/pages/components/input.js +51 -0
  155. package/docs/src/pages/components/list.js +112 -0
  156. package/docs/src/pages/components/media.js +51 -0
  157. package/docs/src/pages/components/modal.js +111 -0
  158. package/docs/src/pages/components/nav.js +86 -0
  159. package/docs/src/pages/components/pricing.js +68 -0
  160. package/docs/src/pages/components/progress.js +102 -0
  161. package/docs/src/pages/components/prose.js +111 -0
  162. package/docs/src/pages/components/pullquote.js +71 -0
  163. package/docs/src/pages/components/radio.js +194 -0
  164. package/docs/src/pages/components/rating.js +106 -0
  165. package/docs/src/pages/components/search.js +61 -0
  166. package/docs/src/pages/components/section.js +59 -0
  167. package/docs/src/pages/components/segmented.js +121 -0
  168. package/docs/src/pages/components/select.js +45 -0
  169. package/docs/src/pages/components/slider.js +114 -0
  170. package/docs/src/pages/components/spinner.js +73 -0
  171. package/docs/src/pages/components/stack.js +48 -0
  172. package/docs/src/pages/components/stat.js +55 -0
  173. package/docs/src/pages/components/stepper.js +66 -0
  174. package/docs/src/pages/components/table.js +45 -0
  175. package/docs/src/pages/components/testimonial.js +49 -0
  176. package/docs/src/pages/components/textarea.js +31 -0
  177. package/docs/src/pages/components/timeline.js +227 -0
  178. package/docs/src/pages/components/toggle.js +84 -0
  179. package/docs/src/pages/components/tooltip.js +48 -0
  180. package/docs/src/pages/components.js +204 -0
  181. package/docs/src/pages/config.js +193 -0
  182. package/docs/src/pages/constraints.js +99 -0
  183. package/docs/src/pages/deploy.js +233 -0
  184. package/docs/src/pages/extending.js +198 -0
  185. package/docs/src/pages/faq.js +96 -0
  186. package/docs/src/pages/getting-started.js +106 -0
  187. package/docs/src/pages/guard.js +121 -0
  188. package/docs/src/pages/home.js +401 -0
  189. package/docs/src/pages/how-it-works.js +183 -0
  190. package/docs/src/pages/hydration.js +98 -0
  191. package/docs/src/pages/images.js +121 -0
  192. package/docs/src/pages/meta.js +120 -0
  193. package/docs/src/pages/mutations.js +106 -0
  194. package/docs/src/pages/navigation.js +85 -0
  195. package/docs/src/pages/performance.js +157 -0
  196. package/docs/src/pages/persist.js +88 -0
  197. package/docs/src/pages/project-structure.js +90 -0
  198. package/docs/src/pages/prompt-examples.js +186 -0
  199. package/docs/src/pages/raw-responses.js +124 -0
  200. package/docs/src/pages/routing.js +99 -0
  201. package/docs/src/pages/server-api.js +281 -0
  202. package/docs/src/pages/server-data.js +185 -0
  203. package/docs/src/pages/slash-commands.js +55 -0
  204. package/docs/src/pages/spec.js +207 -0
  205. package/docs/src/pages/state.js +101 -0
  206. package/docs/src/pages/store.js +181 -0
  207. package/docs/src/pages/streaming.js +108 -0
  208. package/docs/src/pages/stripe.js +193 -0
  209. package/docs/src/pages/supabase.js +323 -0
  210. package/docs/src/pages/testing.js +198 -0
  211. package/docs/src/pages/validation.js +138 -0
  212. package/examples/contact.js +166 -0
  213. package/examples/counter.js +94 -0
  214. package/examples/dev.server.js +91 -0
  215. package/examples/examples.test.js +394 -0
  216. package/examples/pricing.js +244 -0
  217. package/examples/products.js +191 -0
  218. package/examples/quiz.js +208 -0
  219. package/examples/shared.js +78 -0
  220. package/examples/todos.js +162 -0
  221. package/package.json +75 -0
  222. package/public/.pulse-ui-version +1 -0
  223. package/public/chippy-bird.css +246 -0
  224. package/public/examples/contact.css +119 -0
  225. package/public/examples/counter.css +79 -0
  226. package/public/examples/pricing.css +132 -0
  227. package/public/examples/products.css +100 -0
  228. package/public/examples/quiz.css +200 -0
  229. package/public/examples/todos.css +137 -0
  230. package/public/favicon.ico +0 -0
  231. package/public/log-dashboard.css +383 -0
  232. package/public/pulse-ui.css +2740 -0
  233. package/public/pulse-ui.js +236 -0
  234. package/public/pulse.css +149 -0
  235. package/scripts/build.js +411 -0
  236. package/src/agent/checklist.md +111 -0
  237. package/src/agent/coverage-check.js +66 -0
  238. package/src/agent/guide-components.md +274 -0
  239. package/src/agent/guide-examples.md +54 -0
  240. package/src/agent/guide-routing.md +36 -0
  241. package/src/agent/guide-server.md +258 -0
  242. package/src/agent/guide-spec.md +103 -0
  243. package/src/agent/guide-styles.md +191 -0
  244. package/src/agent/guide.md +979 -0
  245. package/src/agent/identity.md +106 -0
  246. package/src/agent/workflow.md +108 -0
  247. package/src/cli/cli.test.js +82 -0
  248. package/src/cli/dev.js +195 -0
  249. package/src/cli/discover.js +113 -0
  250. package/src/cli/index.js +361 -0
  251. package/src/cli/load-report.js +91 -0
  252. package/src/cli/load-runner.js +121 -0
  253. package/src/cli/report-server.js +723 -0
  254. package/src/cli/report.js +116 -0
  255. package/src/cli/scaffold.archive.js +1371 -0
  256. package/src/cli/scaffold.js +349 -0
  257. package/src/cli/start.js +74 -0
  258. package/src/html.js +19 -0
  259. package/src/mcp/server.js +884 -0
  260. package/src/mcp/validate-worker.js +110 -0
  261. package/src/runtime/image.js +74 -0
  262. package/src/runtime/image.test.js +111 -0
  263. package/src/runtime/index.js +621 -0
  264. package/src/runtime/navigate.js +146 -0
  265. package/src/runtime/runtime.test.js +773 -0
  266. package/src/runtime/ssr.js +464 -0
  267. package/src/runtime/ssr.test.js +421 -0
  268. package/src/runtime/store.js +92 -0
  269. package/src/runtime/toast.js +163 -0
  270. package/src/server/index.js +1386 -0
  271. package/src/server/server.test.js +1248 -0
  272. package/src/spec/schema.js +428 -0
  273. package/src/spec/schema.test.js +291 -0
  274. package/src/store/index.js +102 -0
  275. package/src/store/store.test.js +210 -0
  276. package/src/testing/html.js +283 -0
  277. package/src/testing/index.js +249 -0
  278. package/src/testing/testing.test.js +450 -0
  279. package/src/ui/accordion.js +28 -0
  280. package/src/ui/alert.js +43 -0
  281. package/src/ui/app-badge.js +48 -0
  282. package/src/ui/avatar.js +47 -0
  283. package/src/ui/badge.js +24 -0
  284. package/src/ui/banner.js +26 -0
  285. package/src/ui/breadcrumbs.js +38 -0
  286. package/src/ui/button.js +66 -0
  287. package/src/ui/card.js +34 -0
  288. package/src/ui/carousel.js +59 -0
  289. package/src/ui/charts.js +321 -0
  290. package/src/ui/checkbox.js +65 -0
  291. package/src/ui/cluster.js +44 -0
  292. package/src/ui/code-window.js +39 -0
  293. package/src/ui/container.js +24 -0
  294. package/src/ui/cta.js +37 -0
  295. package/src/ui/divider.js +29 -0
  296. package/src/ui/empty.js +33 -0
  297. package/src/ui/feature.js +33 -0
  298. package/src/ui/fieldset.js +37 -0
  299. package/src/ui/fileupload.js +89 -0
  300. package/src/ui/footer.js +38 -0
  301. package/src/ui/grid.js +36 -0
  302. package/src/ui/heading.js +45 -0
  303. package/src/ui/hero.js +37 -0
  304. package/src/ui/icons.js +161 -0
  305. package/src/ui/index.js +89 -0
  306. package/src/ui/input.js +74 -0
  307. package/src/ui/list.js +36 -0
  308. package/src/ui/media.js +44 -0
  309. package/src/ui/modal.js +80 -0
  310. package/src/ui/nav.js +61 -0
  311. package/src/ui/pricing.js +56 -0
  312. package/src/ui/progress.js +62 -0
  313. package/src/ui/prose.js +29 -0
  314. package/src/ui/pullquote.js +34 -0
  315. package/src/ui/radio.js +102 -0
  316. package/src/ui/rating.js +93 -0
  317. package/src/ui/search.js +77 -0
  318. package/src/ui/section.js +69 -0
  319. package/src/ui/segmented.js +50 -0
  320. package/src/ui/select.js +77 -0
  321. package/src/ui/slider.js +84 -0
  322. package/src/ui/spinner.js +34 -0
  323. package/src/ui/stack.js +36 -0
  324. package/src/ui/stat.js +52 -0
  325. package/src/ui/stepper.js +46 -0
  326. package/src/ui/switch.js +57 -0
  327. package/src/ui/table.js +45 -0
  328. package/src/ui/testimonial.js +48 -0
  329. package/src/ui/textarea.js +72 -0
  330. package/src/ui/timeline.js +72 -0
  331. package/src/ui/tooltip.js +28 -0
  332. package/src/ui/ui.test.js +1241 -0
  333. package/src/ui/uiimage.js +65 -0
  334. package/tsconfig.json +13 -0
  335. package/types/html.d.ts +17 -0
  336. package/types/image.d.ts +70 -0
  337. package/types/index.d.ts +7 -0
  338. package/types/navigate.d.ts +38 -0
  339. package/types/runtime.d.ts +63 -0
  340. package/types/schema.d.ts +243 -0
  341. package/types/server.d.ts +145 -0
  342. package/types/ssr.d.ts +110 -0
  343. package/types/testing.d.ts +154 -0
  344. package/types/ui.d.ts +704 -0
@@ -0,0 +1,1241 @@
1
+ /**
2
+ * Pulse UI — Component tests
3
+ *
4
+ * Tests cover: default rendering, all variants, HTML escaping (XSS),
5
+ * accessibility attributes, and edge cases.
6
+ */
7
+
8
+ import { test } from 'node:test'
9
+ import assert from 'node:assert/strict'
10
+ import { button } from './button.js'
11
+ import { badge } from './badge.js'
12
+ import { card } from './card.js'
13
+ import { input } from './input.js'
14
+ import { select } from './select.js'
15
+ import { textarea } from './textarea.js'
16
+ import { alert } from './alert.js'
17
+ import { stat } from './stat.js'
18
+ import { avatar } from './avatar.js'
19
+ import { empty } from './empty.js'
20
+ import { table } from './table.js'
21
+ import { hero } from './hero.js'
22
+ import { testimonial } from './testimonial.js'
23
+ import { feature } from './feature.js'
24
+ import { pricing } from './pricing.js'
25
+ import { accordion } from './accordion.js'
26
+ import { nav } from './nav.js'
27
+ import { appBadge } from './app-badge.js'
28
+ import { container } from './container.js'
29
+ import { section } from './section.js'
30
+ import { grid } from './grid.js'
31
+ import { stack } from './stack.js'
32
+ import { cluster } from './cluster.js'
33
+ import { divider } from './divider.js'
34
+ import { banner } from './banner.js'
35
+ import { media } from './media.js'
36
+ import { fieldset } from './fieldset.js'
37
+ import { slider } from './slider.js'
38
+ import { toggle } from './switch.js'
39
+ import { radio, radioGroup } from './radio.js'
40
+ import { segmented } from './segmented.js'
41
+ import { fileUpload } from './fileupload.js'
42
+ import { modal, modalTrigger } from './modal.js'
43
+
44
+ // ─── button ─────────────────────────────────────────────────────────────────
45
+
46
+ test('button: renders <button> by default', () => {
47
+ const html = button({ label: 'Save' })
48
+ assert.match(html, /<button/)
49
+ assert.match(html, /Save/)
50
+ assert.match(html, /type="button"/)
51
+ assert.match(html, /ui-btn--primary/)
52
+ assert.match(html, /ui-btn--md/)
53
+ })
54
+
55
+ test('button: renders <a> when href is provided', () => {
56
+ const html = button({ label: 'Go', href: '/dashboard' })
57
+ assert.match(html, /<a /)
58
+ assert.match(html, /href="\/dashboard"/)
59
+ assert.doesNotMatch(html, /<button/)
60
+ })
61
+
62
+ test('button: applies variant classes', () => {
63
+ for (const v of ['primary', 'secondary', 'ghost', 'danger']) {
64
+ assert.match(button({ label: 'x', variant: v }), new RegExp(`ui-btn--${v}`))
65
+ }
66
+ })
67
+
68
+ test('button: applies size classes', () => {
69
+ for (const s of ['sm', 'md', 'lg']) {
70
+ assert.match(button({ label: 'x', size: s }), new RegExp(`ui-btn--${s}`))
71
+ }
72
+ })
73
+
74
+ test('button: unknown variant falls back to primary', () => {
75
+ assert.match(button({ label: 'x', variant: 'rainbow' }), /ui-btn--primary/)
76
+ })
77
+
78
+ test('button: disabled <button> has disabled attribute and aria-disabled', () => {
79
+ const html = button({ label: 'x', disabled: true })
80
+ assert.match(html, /disabled/)
81
+ assert.match(html, /aria-disabled="true"/)
82
+ })
83
+
84
+ test('button: disabled <a> has aria-disabled and tabindex=-1, no href behavior issues', () => {
85
+ const html = button({ label: 'x', href: '/x', disabled: true })
86
+ assert.match(html, /aria-disabled="true"/)
87
+ assert.match(html, /tabindex="-1"/)
88
+ })
89
+
90
+ test('button: fullWidth adds modifier class', () => {
91
+ assert.match(button({ label: 'x', fullWidth: true }), /ui-btn--full/)
92
+ })
93
+
94
+ test('button: escapes label to prevent XSS', () => {
95
+ const html = button({ label: '<script>alert(1)</script>' })
96
+ assert.doesNotMatch(html, /<script>/)
97
+ assert.match(html, /&lt;script&gt;/)
98
+ })
99
+
100
+ test('button: escapes href to prevent XSS', () => {
101
+ const html = button({ label: 'x', href: '"><script>alert(1)</script>' })
102
+ assert.doesNotMatch(html, /<script>/)
103
+ })
104
+
105
+ test('button: submit type', () => {
106
+ assert.match(button({ label: 'x', type: 'submit' }), /type="submit"/)
107
+ })
108
+
109
+ test('button: renders icon HTML inside element', () => {
110
+ const html = button({ label: 'x', icon: '<svg/>' })
111
+ assert.match(html, /ui-btn-icon/)
112
+ assert.match(html, /<svg\/>/)
113
+ })
114
+
115
+ // ─── badge ──────────────────────────────────────────────────────────────────
116
+
117
+ test('badge: renders with default variant', () => {
118
+ const html = badge({ label: 'New' })
119
+ assert.match(html, /<span/)
120
+ assert.match(html, /ui-badge--default/)
121
+ assert.match(html, /New/)
122
+ })
123
+
124
+ test('badge: applies all variants', () => {
125
+ for (const v of ['default', 'success', 'warning', 'error', 'info']) {
126
+ assert.match(badge({ label: 'x', variant: v }), new RegExp(`ui-badge--${v}`))
127
+ }
128
+ })
129
+
130
+ test('badge: unknown variant falls back to default', () => {
131
+ assert.match(badge({ label: 'x', variant: 'neon' }), /ui-badge--default/)
132
+ })
133
+
134
+ test('badge: escapes label', () => {
135
+ const html = badge({ label: '<b>bold</b>' })
136
+ assert.doesNotMatch(html, /<b>/)
137
+ assert.match(html, /&lt;b&gt;/)
138
+ })
139
+
140
+ // ─── card ───────────────────────────────────────────────────────────────────
141
+
142
+ test('card: renders card wrapper', () => {
143
+ const html = card({ content: '<p>Hello</p>' })
144
+ assert.match(html, /ui-card/)
145
+ assert.match(html, /ui-card-body/)
146
+ assert.match(html, /<p>Hello<\/p>/)
147
+ })
148
+
149
+ test('card: renders title when provided', () => {
150
+ const html = card({ title: 'My Card', content: '' })
151
+ assert.match(html, /ui-card-title/)
152
+ assert.match(html, /My Card/)
153
+ })
154
+
155
+ test('card: renders footer when provided', () => {
156
+ const html = card({ content: '', footer: '<button>OK</button>' })
157
+ assert.match(html, /ui-card-footer/)
158
+ })
159
+
160
+ test('card: no title/footer when not provided', () => {
161
+ const html = card({ content: 'x' })
162
+ assert.doesNotMatch(html, /ui-card-header/)
163
+ assert.doesNotMatch(html, /ui-card-footer/)
164
+ })
165
+
166
+ test('card: flush modifier', () => {
167
+ assert.match(card({ content: '', flush: true }), /ui-card--flush/)
168
+ })
169
+
170
+ test('card: escapes title to prevent XSS', () => {
171
+ const html = card({ title: '<script>', content: '' })
172
+ assert.doesNotMatch(html, /<script>/)
173
+ })
174
+
175
+ // ─── input ──────────────────────────────────────────────────────────────────
176
+
177
+ test('input: renders label and input linked by for/id', () => {
178
+ const html = input({ name: 'email', label: 'Email' })
179
+ assert.match(html, /for="field-email"/)
180
+ assert.match(html, /id="field-email"/)
181
+ assert.match(html, /Email/)
182
+ })
183
+
184
+ test('input: renders error with role=alert and aria-invalid', () => {
185
+ const html = input({ name: 'email', label: 'Email', error: 'Required' })
186
+ assert.match(html, /role="alert"/)
187
+ assert.match(html, /aria-invalid="true"/)
188
+ assert.match(html, /Required/)
189
+ assert.match(html, /aria-describedby/)
190
+ })
191
+
192
+ test('input: renders hint with aria-describedby', () => {
193
+ const html = input({ name: 'email', label: 'Email', hint: 'We will never spam you' })
194
+ assert.match(html, /ui-hint/)
195
+ assert.match(html, /aria-describedby/)
196
+ })
197
+
198
+ test('input: required adds required attribute and aria-required', () => {
199
+ const html = input({ name: 'x', label: 'X', required: true })
200
+ assert.match(html, /required/)
201
+ assert.match(html, /aria-required="true"/)
202
+ assert.match(html, /ui-required/)
203
+ })
204
+
205
+ test('input: disabled attribute', () => {
206
+ const html = input({ name: 'x', label: 'X', disabled: true })
207
+ assert.match(html, /disabled/)
208
+ })
209
+
210
+ test('input: pre-filled value', () => {
211
+ const html = input({ name: 'x', label: 'X', value: 'hello' })
212
+ assert.match(html, /value="hello"/)
213
+ })
214
+
215
+ test('input: escapes value to prevent XSS', () => {
216
+ const html = input({ name: 'x', label: 'X', value: '"><script>alert(1)</script>' })
217
+ assert.doesNotMatch(html, /<script>/)
218
+ })
219
+
220
+ test('input: escapes error message', () => {
221
+ const html = input({ name: 'x', label: 'X', error: '<b>bad</b>' })
222
+ assert.doesNotMatch(html, /<b>/)
223
+ })
224
+
225
+ test('input: custom id overrides generated id', () => {
226
+ const html = input({ name: 'x', label: 'X', id: 'custom-id' })
227
+ assert.match(html, /id="custom-id"/)
228
+ assert.match(html, /for="custom-id"/)
229
+ })
230
+
231
+ test('input: error field adds ui-field--error wrapper class', () => {
232
+ const html = input({ name: 'x', label: 'X', error: 'oops' })
233
+ assert.match(html, /ui-field--error/)
234
+ })
235
+
236
+ // ─── select ─────────────────────────────────────────────────────────────────
237
+
238
+ test('select: renders options from strings', () => {
239
+ const html = select({ name: 's', label: 'Pick', options: ['One', 'Two'] })
240
+ assert.match(html, /value="One"/)
241
+ assert.match(html, /value="Two"/)
242
+ })
243
+
244
+ test('select: renders options from objects', () => {
245
+ const html = select({ name: 's', label: 'Pick', options: [{ value: 'a', label: 'Alpha' }] })
246
+ assert.match(html, /value="a"/)
247
+ assert.match(html, /Alpha/)
248
+ })
249
+
250
+ test('select: marks selected option', () => {
251
+ const html = select({ name: 's', label: 'Pick', options: ['a', 'b'], value: 'b' })
252
+ assert.match(html, /value="b" selected/)
253
+ })
254
+
255
+ test('select: renders chevron', () => {
256
+ assert.match(select({ name: 's', label: 'S', options: [] }), /ui-select-chevron/)
257
+ })
258
+
259
+ test('select: error triggers aria-invalid and role=alert', () => {
260
+ const html = select({ name: 's', label: 'S', options: [], error: 'Required' })
261
+ assert.match(html, /aria-invalid="true"/)
262
+ assert.match(html, /role="alert"/)
263
+ })
264
+
265
+ test('select: escapes option values and labels', () => {
266
+ const html = select({ name: 's', label: 'S', options: [{ value: '"><script>', label: '<b>' }] })
267
+ assert.doesNotMatch(html, /<script>/)
268
+ assert.doesNotMatch(html, /<b>/)
269
+ })
270
+
271
+ // ─── textarea ───────────────────────────────────────────────────────────────
272
+
273
+ test('textarea: renders with label', () => {
274
+ const html = textarea({ name: 'msg', label: 'Message' })
275
+ assert.match(html, /for="field-msg"/)
276
+ assert.match(html, /<textarea/)
277
+ })
278
+
279
+ test('textarea: value is escaped and placed inside element', () => {
280
+ const html = textarea({ name: 'msg', label: 'M', value: '<script>evil</script>' })
281
+ assert.doesNotMatch(html, /<script>evil/)
282
+ assert.match(html, /&lt;script&gt;/)
283
+ })
284
+
285
+ test('textarea: rows attribute', () => {
286
+ const html = textarea({ name: 'x', label: 'X', rows: 8 })
287
+ assert.match(html, /rows="8"/)
288
+ })
289
+
290
+ test('textarea: error renders role=alert', () => {
291
+ const html = textarea({ name: 'x', label: 'X', error: 'Too long' })
292
+ assert.match(html, /role="alert"/)
293
+ assert.match(html, /Too long/)
294
+ })
295
+
296
+ // ─── alert ──────────────────────────────────────────────────────────────────
297
+
298
+ test('alert: info uses role=status', () => {
299
+ assert.match(alert({ variant: 'info', content: 'x' }), /role="status"/)
300
+ })
301
+
302
+ test('alert: success uses role=status', () => {
303
+ assert.match(alert({ variant: 'success', content: 'x' }), /role="status"/)
304
+ })
305
+
306
+ test('alert: warning uses role=alert', () => {
307
+ assert.match(alert({ variant: 'warning', content: 'x' }), /role="alert"/)
308
+ })
309
+
310
+ test('alert: error uses role=alert', () => {
311
+ assert.match(alert({ variant: 'error', content: 'x' }), /role="alert"/)
312
+ })
313
+
314
+ test('alert: renders title when provided', () => {
315
+ const html = alert({ variant: 'info', title: 'Note', content: 'Something' })
316
+ assert.match(html, /ui-alert-title/)
317
+ assert.match(html, /Note/)
318
+ })
319
+
320
+ test('alert: renders icon', () => {
321
+ assert.match(alert({ variant: 'info', content: 'x' }), /ui-alert-icon/)
322
+ })
323
+
324
+ test('alert: unknown variant falls back to info', () => {
325
+ assert.match(alert({ variant: 'banana', content: 'x' }), /ui-alert--info/)
326
+ })
327
+
328
+ test('alert: escapes title', () => {
329
+ const html = alert({ variant: 'info', title: '<script>', content: '' })
330
+ assert.doesNotMatch(html, /<script>/)
331
+ })
332
+
333
+ // ─── stat ────────────────────────────────────────────────────────────────────
334
+
335
+ test('stat: renders label and value', () => {
336
+ const html = stat({ label: 'Users', value: '1,204' })
337
+ assert.match(html, /ui-stat-label/)
338
+ assert.match(html, /ui-stat-value/)
339
+ assert.match(html, /Users/)
340
+ assert.match(html, /1,204/)
341
+ })
342
+
343
+ test('stat: renders change with trend class', () => {
344
+ const html = stat({ label: 'X', value: '10', change: '+5%', trend: 'up' })
345
+ assert.match(html, /ui-stat-change--up/)
346
+ assert.match(html, /\+5%/)
347
+ })
348
+
349
+ test('stat: no change rendered when change is empty', () => {
350
+ const html = stat({ label: 'X', value: '10' })
351
+ assert.doesNotMatch(html, /ui-stat-change/)
352
+ })
353
+
354
+ test('stat: escapes label and value', () => {
355
+ const html = stat({ label: '<b>L</b>', value: '<i>V</i>' })
356
+ assert.doesNotMatch(html, /<b>/)
357
+ assert.doesNotMatch(html, /<i>/)
358
+ })
359
+
360
+ test('stat: unknown trend falls back to neutral', () => {
361
+ assert.match(stat({ label: 'X', value: '1', change: '+1', trend: 'sideways' }), /ui-stat-change--neutral/)
362
+ })
363
+
364
+ test('stat: center prop adds ui-stat--center class', () => {
365
+ assert.match(stat({ label: 'X', value: '1', center: true }), /ui-stat--center/)
366
+ })
367
+
368
+ test('stat: center is absent by default', () => {
369
+ assert.doesNotMatch(stat({ label: 'X', value: '1' }), /ui-stat--center/)
370
+ })
371
+
372
+ // ─── avatar ──────────────────────────────────────────────────────────────────
373
+
374
+ test('avatar: renders <img> when src provided', () => {
375
+ const html = avatar({ src: '/photo.jpg', alt: 'Alice' })
376
+ assert.match(html, /<img /)
377
+ assert.match(html, /src="\/photo.jpg"/)
378
+ assert.match(html, /alt="Alice"/)
379
+ assert.match(html, /loading="lazy"/)
380
+ })
381
+
382
+ test('avatar: renders <span> with initials when no src', () => {
383
+ const html = avatar({ alt: 'Alice Smith' })
384
+ assert.match(html, /<span/)
385
+ assert.match(html, /AS/)
386
+ assert.match(html, /role="img"/)
387
+ })
388
+
389
+ test('avatar: uses explicit initials over derived', () => {
390
+ const html = avatar({ alt: 'Alice Smith', initials: 'A' })
391
+ assert.match(html, />A</)
392
+ })
393
+
394
+ test('avatar: applies size class', () => {
395
+ assert.match(avatar({ alt: 'x', size: 'lg' }), /ui-avatar--lg/)
396
+ })
397
+
398
+ test('avatar: unknown size falls back to md', () => {
399
+ assert.match(avatar({ alt: 'x', size: 'giant' }), /ui-avatar--md/)
400
+ })
401
+
402
+ test('avatar: escapes src to prevent XSS', () => {
403
+ const html = avatar({ src: '"><script>alert(1)</script>', alt: 'x' })
404
+ assert.doesNotMatch(html, /<script>/)
405
+ })
406
+
407
+ // ─── empty ───────────────────────────────────────────────────────────────────
408
+
409
+ test('empty: renders title', () => {
410
+ const html = empty({ title: 'No results' })
411
+ assert.match(html, /No results/)
412
+ assert.match(html, /ui-empty-title/)
413
+ })
414
+
415
+ test('empty: renders description when provided', () => {
416
+ const html = empty({ title: 'x', description: 'Try a different search' })
417
+ assert.match(html, /ui-empty-desc/)
418
+ assert.match(html, /Try a different search/)
419
+ })
420
+
421
+ test('empty: renders action button when provided', () => {
422
+ const html = empty({ title: 'x', action: { label: 'Add item', href: '/new' } })
423
+ assert.match(html, /Add item/)
424
+ assert.match(html, /href="\/new"/)
425
+ })
426
+
427
+ test('empty: no action rendered when not provided', () => {
428
+ const html = empty({ title: 'x' })
429
+ assert.doesNotMatch(html, /ui-btn/)
430
+ })
431
+
432
+ test('empty: escapes title and description', () => {
433
+ const html = empty({ title: '<script>', description: '<b>bad</b>' })
434
+ assert.doesNotMatch(html, /<script>/)
435
+ assert.doesNotMatch(html, /<b>/)
436
+ })
437
+
438
+ // ─── table ───────────────────────────────────────────────────────────────────
439
+
440
+ test('table: renders headers with scope=col', () => {
441
+ const html = table({ headers: ['Name', 'Role'], rows: [] })
442
+ assert.match(html, /scope="col"/)
443
+ assert.match(html, /Name/)
444
+ assert.match(html, /Role/)
445
+ })
446
+
447
+ test('table: renders rows', () => {
448
+ const html = table({ headers: ['A'], rows: [['Cell 1'], ['Cell 2']] })
449
+ assert.match(html, /Cell 1/)
450
+ assert.match(html, /Cell 2/)
451
+ })
452
+
453
+ test('table: renders caption', () => {
454
+ const html = table({ headers: [], rows: [], caption: 'User list' })
455
+ assert.match(html, /ui-table-caption/)
456
+ assert.match(html, /User list/)
457
+ })
458
+
459
+ test('table: scroll wrapper has role=region and tabindex', () => {
460
+ const html = table({ headers: [], rows: [] })
461
+ assert.match(html, /role="region"/)
462
+ assert.match(html, /tabindex="0"/)
463
+ })
464
+
465
+ test('table: escapes headers', () => {
466
+ const html = table({ headers: ['<script>'], rows: [] })
467
+ assert.doesNotMatch(html, /<script>/)
468
+ })
469
+
470
+ test('table: empty rows renders empty tbody', () => {
471
+ const html = table({ headers: ['A', 'B'], rows: [] })
472
+ assert.match(html, /<tbody><\/tbody>/)
473
+ })
474
+
475
+ // ─── hero ────────────────────────────────────────────────────────────────────
476
+
477
+ test('hero: renders title, eyebrow, subtitle', () => {
478
+ const html = hero({ eyebrow: 'New', title: 'Hello', subtitle: 'World' })
479
+ assert.match(html, /ui-hero-eyebrow/)
480
+ assert.match(html, /ui-hero-title/)
481
+ assert.match(html, /ui-hero-subtitle/)
482
+ })
483
+
484
+ test('hero: left align adds ui-hero--left', () => {
485
+ assert.match(hero({ title: 'x', align: 'left' }), /ui-hero--left/)
486
+ })
487
+
488
+ test('hero: center align (default) has no modifier class', () => {
489
+ assert.doesNotMatch(hero({ title: 'x' }), /ui-hero--left/)
490
+ })
491
+
492
+ test('hero: actions slot passes through raw HTML', () => {
493
+ const html = hero({ title: 'x', actions: '<a href="/dl">Download</a>' })
494
+ assert.match(html, /href="\/dl"/)
495
+ })
496
+
497
+ test('hero: escapes title and eyebrow', () => {
498
+ const html = hero({ title: '<script>', eyebrow: '<b>bad</b>' })
499
+ assert.doesNotMatch(html, /<script>/)
500
+ assert.doesNotMatch(html, /<b>bad<\/b>/)
501
+ })
502
+
503
+ // ─── testimonial ─────────────────────────────────────────────────────────────
504
+
505
+ test('testimonial: renders quote, name, role', () => {
506
+ const html = testimonial({ quote: 'Great app', name: 'Alice Smith', role: 'CEO' })
507
+ assert.match(html, /Great app/)
508
+ assert.match(html, /Alice Smith/)
509
+ assert.match(html, /CEO/)
510
+ })
511
+
512
+ test('testimonial: renders stars when rating provided', () => {
513
+ const html = testimonial({ quote: 'x', name: 'A', rating: 5 })
514
+ assert.match(html, /ui-testimonial-rating/)
515
+ assert.match(html, /★★★★★/)
516
+ })
517
+
518
+ test('testimonial: no stars when rating omitted', () => {
519
+ assert.doesNotMatch(testimonial({ quote: 'x', name: 'A' }), /ui-testimonial-rating/)
520
+ })
521
+
522
+ test('testimonial: renders initials when src omitted', () => {
523
+ const html = testimonial({ quote: 'x', name: 'Alice Smith' })
524
+ assert.match(html, /ui-testimonial-avatar--initials/)
525
+ assert.match(html, /AS/)
526
+ })
527
+
528
+ test('testimonial: renders img when src provided', () => {
529
+ const html = testimonial({ quote: 'x', name: 'Alice', src: '/photo.jpg' })
530
+ assert.match(html, /<img/)
531
+ assert.match(html, /photo\.jpg/)
532
+ })
533
+
534
+ test('testimonial: escapes quote and name', () => {
535
+ const html = testimonial({ quote: '<script>', name: '<b>X</b>' })
536
+ assert.doesNotMatch(html, /<script>/)
537
+ assert.doesNotMatch(html, /<b>X<\/b>/)
538
+ })
539
+
540
+ // ─── feature ─────────────────────────────────────────────────────────────────
541
+
542
+ test('feature: renders title and description', () => {
543
+ const html = feature({ title: 'Fast', description: 'Under 100ms' })
544
+ assert.match(html, /ui-feature-title/)
545
+ assert.match(html, /ui-feature-desc/)
546
+ })
547
+
548
+ test('feature: icon slot passes through raw HTML', () => {
549
+ const html = feature({ icon: '<svg><circle/></svg>', title: 'x' })
550
+ assert.match(html, /<svg>/)
551
+ assert.match(html, /ui-feature-icon/)
552
+ })
553
+
554
+ test('feature: escapes title and description', () => {
555
+ const html = feature({ title: '<script>', description: '<b>bad</b>' })
556
+ assert.doesNotMatch(html, /<script>/)
557
+ assert.doesNotMatch(html, /<b>bad<\/b>/)
558
+ })
559
+
560
+ // ─── pricing ─────────────────────────────────────────────────────────────────
561
+
562
+ test('pricing: renders name, price, period', () => {
563
+ const html = pricing({ name: 'Pro', price: '$9', period: '/month' })
564
+ assert.match(html, /ui-pricing-name/)
565
+ assert.match(html, /\$9/)
566
+ assert.match(html, /\/month/)
567
+ })
568
+
569
+ test('pricing: renders feature list', () => {
570
+ const html = pricing({ name: 'x', price: 'Free', features: ['Unlimited pages', 'Support'] })
571
+ assert.match(html, /Unlimited pages/)
572
+ assert.match(html, /Support/)
573
+ assert.match(html, /ui-pricing-check/)
574
+ })
575
+
576
+ test('pricing: highlighted adds modifier class', () => {
577
+ assert.match(pricing({ name: 'x', price: 'x', highlighted: true }), /ui-pricing--highlighted/)
578
+ })
579
+
580
+ test('pricing: badge renders when provided', () => {
581
+ const html = pricing({ name: 'x', price: 'x', badge: 'Most popular' })
582
+ assert.match(html, /ui-pricing-badge/)
583
+ assert.match(html, /Most popular/)
584
+ })
585
+
586
+ test('pricing: action slot passes through raw HTML', () => {
587
+ const html = pricing({ name: 'x', price: 'x', action: '<button>Buy</button>' })
588
+ assert.match(html, /<button>Buy<\/button>/)
589
+ })
590
+
591
+ test('pricing: escapes name and description', () => {
592
+ const html = pricing({ name: '<script>', description: '<b>x</b>' })
593
+ assert.doesNotMatch(html, /<script>/)
594
+ assert.doesNotMatch(html, /<b>x<\/b>/)
595
+ })
596
+
597
+ // ─── accordion ───────────────────────────────────────────────────────────────
598
+
599
+ test('accordion: renders details/summary', () => {
600
+ const html = accordion({ items: [{ question: 'What?', answer: 'This.' }] })
601
+ assert.match(html, /<details/)
602
+ assert.match(html, /<summary/)
603
+ assert.match(html, /What\?/)
604
+ assert.match(html, /This\./)
605
+ })
606
+
607
+ test('accordion: renders multiple items', () => {
608
+ const html = accordion({ items: [{ question: 'A', answer: '1' }, { question: 'B', answer: '2' }] })
609
+ assert.match(html, /ui-accordion-item/)
610
+ assert.match(html, />A</)
611
+ assert.match(html, />B</)
612
+ })
613
+
614
+ test('accordion: empty items renders empty container', () => {
615
+ const html = accordion({ items: [] })
616
+ assert.match(html, /ui-accordion/)
617
+ })
618
+
619
+ test('accordion: escapes question and answer', () => {
620
+ const html = accordion({ items: [{ question: '<script>', answer: '<b>x</b>' }] })
621
+ assert.doesNotMatch(html, /<script>/)
622
+ assert.doesNotMatch(html, /<b>x<\/b>/)
623
+ })
624
+
625
+ // ─── nav ─────────────────────────────────────────────────────────────────────
626
+
627
+ test('nav: renders logo and links', () => {
628
+ const html = nav({ logo: 'MyApp', links: [{ label: 'Features', href: '#features' }] })
629
+ assert.match(html, /MyApp/)
630
+ assert.match(html, /Features/)
631
+ assert.match(html, /href="#features"/)
632
+ })
633
+
634
+ test('nav: sticky adds modifier class', () => {
635
+ assert.match(nav({ logo: 'x', sticky: true }), /ui-nav--sticky/)
636
+ })
637
+
638
+ test('nav: no sticky class by default', () => {
639
+ assert.doesNotMatch(nav({ logo: 'x' }), /ui-nav--sticky/)
640
+ })
641
+
642
+ test('nav: action slot passes through raw HTML', () => {
643
+ const html = nav({ logo: 'x', action: '<button>Sign up</button>' })
644
+ assert.match(html, /Sign up/)
645
+ })
646
+
647
+ test('nav: escapes link labels and hrefs', () => {
648
+ const html = nav({ logo: 'x', links: [{ label: '<b>X</b>', href: 'javascript:void(0)' }] })
649
+ assert.doesNotMatch(html, /<b>X<\/b>/)
650
+ })
651
+
652
+ // ─── appBadge ────────────────────────────────────────────────────────────────
653
+
654
+ test('appBadge: renders apple badge by default', () => {
655
+ const html = appBadge({ href: 'https://apps.apple.com/x' })
656
+ assert.match(html, /App Store/)
657
+ assert.match(html, /apps\.apple\.com/)
658
+ })
659
+
660
+ test('appBadge: renders google badge', () => {
661
+ assert.match(appBadge({ store: 'google', href: '/play' }), /Google Play/)
662
+ })
663
+
664
+ test('appBadge: unknown store falls back to apple', () => {
665
+ assert.match(appBadge({ store: 'unknown' }), /App Store/)
666
+ })
667
+
668
+ test('appBadge: has aria-label and rel=noopener', () => {
669
+ const html = appBadge({ href: '/x' })
670
+ assert.match(html, /aria-label/)
671
+ assert.match(html, /noopener/)
672
+ })
673
+
674
+ // ─── container ───────────────────────────────────────────────────────────────
675
+
676
+ test('container: renders content', () => {
677
+ assert.match(container({ content: '<p>Hello</p>' }), /Hello/)
678
+ })
679
+
680
+ test('container: default size is lg', () => {
681
+ assert.match(container({ content: '' }), /ui-container--lg/)
682
+ })
683
+
684
+ test('container: size variants apply modifier class', () => {
685
+ assert.match(container({ content: '', size: 'sm' }), /ui-container--sm/)
686
+ assert.match(container({ content: '', size: 'md' }), /ui-container--md/)
687
+ assert.match(container({ content: '', size: 'xl' }), /ui-container--xl/)
688
+ })
689
+
690
+ test('container: unknown size falls back to lg', () => {
691
+ assert.match(container({ content: '', size: 'huge' }), /ui-container--lg/)
692
+ })
693
+
694
+ // ─── section ─────────────────────────────────────────────────────────────────
695
+
696
+ test('section: renders as <section> element', () => {
697
+ assert.match(section({ content: 'x' }), /<section/)
698
+ })
699
+
700
+ test('section: alt variant adds modifier class', () => {
701
+ assert.match(section({ content: '', variant: 'alt' }), /ui-section--alt/)
702
+ })
703
+
704
+ test('section: dark variant adds modifier class', () => {
705
+ assert.match(section({ content: '', variant: 'dark' }), /ui-section--dark/)
706
+ })
707
+
708
+ test('section: default variant has no modifier class', () => {
709
+ assert.doesNotMatch(section({ content: '' }), /ui-section--default/)
710
+ })
711
+
712
+ test('section: sm padding adds modifier class', () => {
713
+ assert.match(section({ content: '', padding: 'sm' }), /ui-section--sm/)
714
+ })
715
+
716
+ test('section: id attribute renders when provided', () => {
717
+ assert.match(section({ content: '', id: 'features' }), /id="features"/)
718
+ })
719
+
720
+ test('section: unknown variant falls back to default', () => {
721
+ assert.doesNotMatch(section({ content: '', variant: 'rainbow' }), /ui-section--rainbow/)
722
+ })
723
+
724
+ // ─── grid ────────────────────────────────────────────────────────────────────
725
+
726
+ test('grid: renders content', () => {
727
+ assert.match(grid({ content: '<div>A</div>' }), /A/)
728
+ })
729
+
730
+ test('grid: default cols is 3', () => {
731
+ assert.match(grid({ content: '' }), /ui-grid--cols-3/)
732
+ })
733
+
734
+ test('grid: cols variants apply modifier class', () => {
735
+ assert.match(grid({ content: '', cols: 2 }), /ui-grid--cols-2/)
736
+ assert.match(grid({ content: '', cols: 4 }), /ui-grid--cols-4/)
737
+ })
738
+
739
+ test('grid: unknown cols falls back to 3', () => {
740
+ assert.match(grid({ content: '', cols: 5 }), /ui-grid--cols-3/)
741
+ })
742
+
743
+ test('grid: gap sm adds modifier class', () => {
744
+ assert.match(grid({ content: '', gap: 'sm' }), /ui-grid--gap-sm/)
745
+ })
746
+
747
+ test('grid: default gap has no modifier class', () => {
748
+ assert.doesNotMatch(grid({ content: '' }), /ui-grid--gap-md/)
749
+ })
750
+
751
+ // ─── stack ───────────────────────────────────────────────────────────────────
752
+
753
+ test('stack: renders content', () => {
754
+ assert.match(stack({ content: '<p>x</p>' }), /x/)
755
+ })
756
+
757
+ test('stack: gap variants apply modifier class', () => {
758
+ assert.match(stack({ content: '', gap: 'lg' }), /ui-stack--gap-lg/)
759
+ assert.match(stack({ content: '', gap: 'xs' }), /ui-stack--gap-xs/)
760
+ })
761
+
762
+ test('stack: default gap has no modifier class', () => {
763
+ assert.doesNotMatch(stack({ content: '' }), /ui-stack--gap-md/)
764
+ })
765
+
766
+ test('stack: align center adds modifier class', () => {
767
+ assert.match(stack({ content: '', align: 'center' }), /ui-stack--align-center/)
768
+ })
769
+
770
+ test('stack: default align has no modifier class', () => {
771
+ assert.doesNotMatch(stack({ content: '' }), /ui-stack--align-stretch/)
772
+ })
773
+
774
+ // ─── cluster ─────────────────────────────────────────────────────────────────
775
+
776
+ test('cluster: renders content', () => {
777
+ assert.match(cluster({ content: '<span>x</span>' }), /x/)
778
+ })
779
+
780
+ test('cluster: justify center adds modifier class', () => {
781
+ assert.match(cluster({ content: '', justify: 'center' }), /ui-cluster--justify-center/)
782
+ })
783
+
784
+ test('cluster: justify between adds modifier class', () => {
785
+ assert.match(cluster({ content: '', justify: 'between' }), /ui-cluster--justify-between/)
786
+ })
787
+
788
+ test('cluster: nowrap adds modifier class', () => {
789
+ assert.match(cluster({ content: '', wrap: false }), /ui-cluster--nowrap/)
790
+ })
791
+
792
+ test('cluster: wrap is true by default (no nowrap class)', () => {
793
+ assert.doesNotMatch(cluster({ content: '' }), /ui-cluster--nowrap/)
794
+ })
795
+
796
+ // ─── divider ─────────────────────────────────────────────────────────────────
797
+
798
+ test('divider: renders <hr> when no label', () => {
799
+ assert.match(divider(), /<hr/)
800
+ })
801
+
802
+ test('divider: renders label version as <div>', () => {
803
+ assert.match(divider({ label: 'or' }), /<div/)
804
+ assert.match(divider({ label: 'or' }), /or/)
805
+ })
806
+
807
+ test('divider: has role=separator and aria-label when label provided', () => {
808
+ const html = divider({ label: 'or' })
809
+ assert.match(html, /role="separator"/)
810
+ assert.match(html, /aria-label="or"/)
811
+ })
812
+
813
+ test('divider: escapes label', () => {
814
+ assert.doesNotMatch(divider({ label: '<script>' }), /<script>/)
815
+ })
816
+
817
+ // ─── banner ──────────────────────────────────────────────────────────────────
818
+
819
+ test('banner: renders content', () => {
820
+ assert.match(banner({ content: 'Now available' }), /Now available/)
821
+ })
822
+
823
+ test('banner: info variant by default', () => {
824
+ assert.match(banner({ content: '' }), /ui-banner--info/)
825
+ })
826
+
827
+ test('banner: promo variant adds class', () => {
828
+ assert.match(banner({ content: '', variant: 'promo' }), /ui-banner--promo/)
829
+ })
830
+
831
+ test('banner: warning variant adds class', () => {
832
+ assert.match(banner({ content: '', variant: 'warning' }), /ui-banner--warning/)
833
+ })
834
+
835
+ test('banner: unknown variant falls back to info', () => {
836
+ assert.match(banner({ content: '', variant: 'fancy' }), /ui-banner--info/)
837
+ })
838
+
839
+ test('banner: has role=banner', () => {
840
+ assert.match(banner({ content: '' }), /role="banner"/)
841
+ })
842
+
843
+ // ─── media ───────────────────────────────────────────────────────────────────
844
+
845
+ test('media: renders image and content slots', () => {
846
+ const html = media({ image: '<img src="/x.jpg">', content: '<p>Text</p>' })
847
+ assert.match(html, /ui-media-image/)
848
+ assert.match(html, /ui-media-content/)
849
+ assert.match(html, /x\.jpg/)
850
+ assert.match(html, /Text/)
851
+ })
852
+
853
+ test('media: reverse adds modifier class', () => {
854
+ assert.match(media({ image: '', content: '', reverse: true }), /ui-media--reverse/)
855
+ })
856
+
857
+ test('media: no reverse class by default', () => {
858
+ assert.doesNotMatch(media({ image: '', content: '' }), /ui-media--reverse/)
859
+ })
860
+
861
+ test('media: align start adds modifier class', () => {
862
+ assert.match(media({ image: '', content: '', align: 'start' }), /ui-media--align-start/)
863
+ })
864
+
865
+ test('media: gap lg adds modifier class', () => {
866
+ assert.match(media({ image: '', content: '', gap: 'lg' }), /ui-media--gap-lg/)
867
+ })
868
+
869
+ // ---------------------------------------------------------------------------
870
+ // fieldset
871
+ // ---------------------------------------------------------------------------
872
+
873
+ test('fieldset: renders fieldset element', () => {
874
+ assert.match(fieldset({ legend: 'Address', content: '<p>fields</p>' }), /<fieldset/)
875
+ })
876
+
877
+ test('fieldset: renders legend', () => {
878
+ assert.match(fieldset({ legend: 'Billing', content: '' }), /ui-fieldset-legend/)
879
+ assert.match(fieldset({ legend: 'Billing', content: '' }), /Billing/)
880
+ })
881
+
882
+ test('fieldset: no legend element when omitted', () => {
883
+ assert.doesNotMatch(fieldset({ content: 'fields' }), /ui-fieldset-legend/)
884
+ })
885
+
886
+ test('fieldset: renders content in body', () => {
887
+ assert.match(fieldset({ content: '<input>' }), /ui-fieldset-body/)
888
+ assert.match(fieldset({ content: '<input>' }), /<input>/)
889
+ })
890
+
891
+ test('fieldset: gap lg adds modifier class', () => {
892
+ assert.match(fieldset({ gap: 'lg', content: '' }), /ui-fieldset--gap-lg/)
893
+ })
894
+
895
+ test('fieldset: no gap modifier for default md', () => {
896
+ assert.doesNotMatch(fieldset({ content: '' }), /ui-fieldset--gap/)
897
+ })
898
+
899
+ test('fieldset: escapes legend to prevent XSS', () => {
900
+ assert.match(fieldset({ legend: '<script>bad</script>', content: '' }), /&lt;script&gt;/)
901
+ })
902
+
903
+ // ─── slider ──────────────────────────────────────────────────────────────────
904
+
905
+ test('slider: renders range input with label', () => {
906
+ const html = slider({ name: 'vol', label: 'Volume' })
907
+ assert.match(html, /type="range"/)
908
+ assert.match(html, /name="vol"/)
909
+ assert.match(html, /Volume/)
910
+ })
911
+
912
+ test('slider: sets --slider-fill inline style from value', () => {
913
+ const html = slider({ name: 'x', min: 0, max: 100, value: 50 })
914
+ assert.match(html, /--slider-fill:50\.00%/)
915
+ })
916
+
917
+ test('slider: --slider-fill is 0% when value equals min', () => {
918
+ const html = slider({ name: 'x', min: 0, max: 100, value: 0 })
919
+ assert.match(html, /--slider-fill:0\.00%/)
920
+ })
921
+
922
+ test('slider: --slider-fill is 100% when value equals max', () => {
923
+ const html = slider({ name: 'x', min: 0, max: 100, value: 100 })
924
+ assert.match(html, /--slider-fill:100\.00%/)
925
+ })
926
+
927
+ test('slider: has --slider-fill on wrapper for CSS fill gradient', () => {
928
+ const html = slider({ name: 'x' })
929
+ // fill is on the .ui-field wrapper so pulse-ui.js can update it via closest('.ui-field')
930
+ assert.match(html, /--slider-fill/)
931
+ assert.doesNotMatch(html, /oninput=/) // no inline handler — handled by pulse-ui.js
932
+ })
933
+
934
+ test('slider: event prop renders data-event attribute', () => {
935
+ const html = slider({ name: 'x', event: 'change:setBrightness' })
936
+ assert.match(html, /data-event="change:setBrightness"/)
937
+ })
938
+
939
+ test('slider: no data-event when event not provided', () => {
940
+ assert.doesNotMatch(slider({ name: 'x' }), /data-event/)
941
+ })
942
+
943
+ test('slider: hint renders with aria-describedby', () => {
944
+ const html = slider({ name: 'x', hint: 'Drag to adjust' })
945
+ assert.match(html, /Drag to adjust/)
946
+ assert.match(html, /aria-describedby/)
947
+ })
948
+
949
+ test('slider: disabled attribute', () => {
950
+ assert.match(slider({ name: 'x', disabled: true }), /disabled/)
951
+ })
952
+
953
+ test('slider: clamps value to min', () => {
954
+ assert.match(slider({ name: 'x', min: 10, max: 100, value: 0 }), /value="10"/)
955
+ })
956
+
957
+ test('slider: clamps value to max', () => {
958
+ assert.match(slider({ name: 'x', min: 0, max: 100, value: 200 }), /value="100"/)
959
+ })
960
+
961
+ // ─── toggle (switch) ─────────────────────────────────────────────────────────
962
+
963
+ test('toggle: renders checkbox input with label', () => {
964
+ const html = toggle({ name: 'enabled', label: 'Enable' })
965
+ assert.match(html, /type="checkbox"/)
966
+ assert.match(html, /name="enabled"/)
967
+ assert.match(html, /Enable/)
968
+ })
969
+
970
+ test('toggle: checked attribute when checked=true', () => {
971
+ assert.match(toggle({ name: 'x', checked: true }), /checked/)
972
+ })
973
+
974
+ test('toggle: no checked attribute by default', () => {
975
+ assert.doesNotMatch(toggle({ name: 'x' }), /checked/)
976
+ })
977
+
978
+ test('toggle: event prop renders data-event attribute', () => {
979
+ const html = toggle({ name: 'x', event: 'change:setEnabled' })
980
+ assert.match(html, /data-event="change:setEnabled"/)
981
+ })
982
+
983
+ test('toggle: no data-event when event not provided', () => {
984
+ assert.doesNotMatch(toggle({ name: 'x' }), /data-event/)
985
+ })
986
+
987
+ test('toggle: disabled attribute', () => {
988
+ assert.match(toggle({ name: 'x', disabled: true }), /disabled/)
989
+ })
990
+
991
+ test('toggle: hint renders aria-describedby', () => {
992
+ const html = toggle({ name: 'x', hint: 'Turn on notifications' })
993
+ assert.match(html, /Turn on notifications/)
994
+ assert.match(html, /aria-describedby/)
995
+ })
996
+
997
+ // ─── radio / radioGroup ──────────────────────────────────────────────────────
998
+
999
+ test('radio: renders radio input with label', () => {
1000
+ const html = radio({ name: 'colour', value: 'red', label: 'Red' })
1001
+ assert.match(html, /type="radio"/)
1002
+ assert.match(html, /name="colour"/)
1003
+ assert.match(html, /value="red"/)
1004
+ assert.match(html, /Red/)
1005
+ })
1006
+
1007
+ test('radio: checked attribute when checked=true', () => {
1008
+ assert.match(radio({ name: 'x', value: 'a', checked: true }), /checked/)
1009
+ })
1010
+
1011
+ test('radio: event prop renders data-event', () => {
1012
+ const html = radio({ name: 'x', value: 'a', event: 'change:setChoice' })
1013
+ assert.match(html, /data-event="change:setChoice"/)
1014
+ })
1015
+
1016
+ test('radio: no data-event when event not provided', () => {
1017
+ assert.doesNotMatch(radio({ name: 'x', value: 'a' }), /data-event/)
1018
+ })
1019
+
1020
+ test('radio: escapes value and label', () => {
1021
+ const html = radio({ name: 'x', value: '"><script>', label: '<b>bold</b>' })
1022
+ assert.doesNotMatch(html, /<script>/)
1023
+ assert.doesNotMatch(html, /<b>bold/)
1024
+ })
1025
+
1026
+ test('radioGroup: renders fieldset with legend', () => {
1027
+ const html = radioGroup({ name: 'size', legend: 'Size', options: [{ value: 's', label: 'Small' }] })
1028
+ assert.match(html, /<fieldset/)
1029
+ assert.match(html, /Size/)
1030
+ assert.match(html, /Small/)
1031
+ })
1032
+
1033
+ test('radioGroup: marks selected option as checked', () => {
1034
+ const html = radioGroup({ name: 'x', options: [{ value: 'a', label: 'A' }, { value: 'b', label: 'B' }], value: 'b' })
1035
+ const inputs = [...html.matchAll(/type="radio"[^>]*checked/g)]
1036
+ assert.equal(inputs.length, 1)
1037
+ assert.ok(html.includes('value="b"'))
1038
+ })
1039
+
1040
+ test('radioGroup: event prop propagates data-event to all radio inputs', () => {
1041
+ const html = radioGroup({ name: 'x', options: [{ value: 'a', label: 'A' }, { value: 'b', label: 'B' }], event: 'change:setPlan' })
1042
+ const matches = [...html.matchAll(/data-event="change:setPlan"/g)]
1043
+ assert.equal(matches.length, 2)
1044
+ })
1045
+
1046
+ test('radioGroup: error renders role=alert', () => {
1047
+ const html = radioGroup({ name: 'x', options: [], error: 'Pick one' })
1048
+ assert.match(html, /role="alert"/)
1049
+ assert.match(html, /Pick one/)
1050
+ })
1051
+
1052
+ // ─── segmented ───────────────────────────────────────────────────────────────
1053
+
1054
+ test('segmented: renders radio inputs for each option', () => {
1055
+ const html = segmented({ name: 'tab', options: [{ value: 'a', label: 'A' }, { value: 'b', label: 'B' }] })
1056
+ const inputs = [...html.matchAll(/type="radio"/g)]
1057
+ assert.equal(inputs.length, 2)
1058
+ assert.match(html, /name="tab"/)
1059
+ })
1060
+
1061
+ test('segmented: marks selected value as checked', () => {
1062
+ const html = segmented({ name: 'x', options: [{ value: 'a', label: 'A' }, { value: 'b', label: 'B' }], value: 'b' })
1063
+ assert.match(html, /value="b"[\s\S]*?checked/)
1064
+ })
1065
+
1066
+ test('segmented: event prop renders data-event on all inputs', () => {
1067
+ const html = segmented({ name: 'x', options: [{ value: 'a', label: 'A' }, { value: 'b', label: 'B' }], event: 'change:setTab' })
1068
+ const matches = [...html.matchAll(/data-event="change:setTab"/g)]
1069
+ assert.equal(matches.length, 2)
1070
+ })
1071
+
1072
+ test('segmented: no data-event when event not provided', () => {
1073
+ const html = segmented({ name: 'x', options: [{ value: 'a', label: 'A' }] })
1074
+ assert.doesNotMatch(html, /data-event/)
1075
+ })
1076
+
1077
+ test('segmented: size sm adds modifier class', () => {
1078
+ assert.match(segmented({ name: 'x', options: [], size: 'sm' }), /ui-segmented--sm/)
1079
+ })
1080
+
1081
+ test('segmented: size lg adds modifier class', () => {
1082
+ assert.match(segmented({ name: 'x', options: [], size: 'lg' }), /ui-segmented--lg/)
1083
+ })
1084
+
1085
+ test('segmented: disabled applies to all inputs', () => {
1086
+ const html = segmented({ name: 'x', options: [{ value: 'a', label: 'A' }, { value: 'b', label: 'B' }], disabled: true })
1087
+ const matches = [...html.matchAll(/disabled/g)]
1088
+ assert.ok(matches.length >= 2)
1089
+ })
1090
+
1091
+ // ─── fileUpload ───────────────────────────────────────────────────────────────
1092
+
1093
+ test('fileUpload: renders file input with correct name', () => {
1094
+ const html = fileUpload({ name: 'avatar', label: 'Photo' })
1095
+ assert.match(html, /type="file"/)
1096
+ assert.match(html, /name="avatar"/)
1097
+ assert.match(html, /Photo/)
1098
+ })
1099
+
1100
+ test('fileUpload: accept attribute when provided', () => {
1101
+ assert.match(fileUpload({ name: 'x', accept: 'image/*' }), /accept="image\/\*"/)
1102
+ })
1103
+
1104
+ test('fileUpload: multiple attribute when provided', () => {
1105
+ assert.match(fileUpload({ name: 'x', multiple: true }), /multiple/)
1106
+ })
1107
+
1108
+ test('fileUpload: no multiple attribute by default', () => {
1109
+ assert.doesNotMatch(fileUpload({ name: 'x' }), /multiple/)
1110
+ })
1111
+
1112
+ test('fileUpload: required adds required and aria-required', () => {
1113
+ const html = fileUpload({ name: 'x', required: true })
1114
+ assert.match(html, /required/)
1115
+ assert.match(html, /aria-required="true"/)
1116
+ })
1117
+
1118
+ test('fileUpload: disabled applies to input and zone', () => {
1119
+ const html = fileUpload({ name: 'x', disabled: true })
1120
+ assert.match(html, /ui-upload--disabled/)
1121
+ assert.match(html, /disabled/)
1122
+ })
1123
+
1124
+ test('fileUpload: event prop renders data-event on input', () => {
1125
+ assert.match(fileUpload({ name: 'x', event: 'change:fileSelected' }), /data-event="change:fileSelected"/)
1126
+ })
1127
+
1128
+ test('fileUpload: no data-event when event not provided', () => {
1129
+ assert.doesNotMatch(fileUpload({ name: 'x' }), /data-event/)
1130
+ })
1131
+
1132
+ test('fileUpload: error renders role=alert and ui-upload--error', () => {
1133
+ const html = fileUpload({ name: 'x', error: 'File too large' })
1134
+ assert.match(html, /role="alert"/)
1135
+ assert.match(html, /File too large/)
1136
+ assert.match(html, /ui-upload--error/)
1137
+ assert.match(html, /aria-invalid="true"/)
1138
+ })
1139
+
1140
+ test('fileUpload: hint renders with aria-describedby', () => {
1141
+ const html = fileUpload({ name: 'x', hint: 'PNG, JPG up to 5 MB' })
1142
+ assert.match(html, /PNG, JPG up to 5 MB/)
1143
+ assert.match(html, /aria-describedby/)
1144
+ })
1145
+
1146
+ test('fileUpload: no inline drag handlers — handled by pulse-ui.js', () => {
1147
+ const html = fileUpload({ name: 'x' })
1148
+ // All drag/click/keyboard behaviour is delegated via pulse-ui.js (CSP-safe)
1149
+ assert.doesNotMatch(html, /ondragover=/)
1150
+ assert.doesNotMatch(html, /ondrop=/)
1151
+ assert.doesNotMatch(html, /onclick=/)
1152
+ })
1153
+
1154
+ test('fileUpload: drag handlers absent when disabled', () => {
1155
+ const html = fileUpload({ name: 'x', disabled: true })
1156
+ assert.doesNotMatch(html, /ondragover=/)
1157
+ })
1158
+
1159
+ test('fileUpload: zone has role=button and tabindex for keyboard access', () => {
1160
+ const html = fileUpload({ name: 'x' })
1161
+ assert.match(html, /role="button"/)
1162
+ assert.match(html, /tabindex="0"/)
1163
+ })
1164
+
1165
+ test('fileUpload: input is hidden — opened programmatically by pulse-ui.js', () => {
1166
+ const html = fileUpload({ name: 'x' })
1167
+ assert.match(html, /class="ui-upload-input"/)
1168
+ assert.doesNotMatch(html, /onkeydown=/) // keyboard handling in pulse-ui.js
1169
+ })
1170
+
1171
+ test('fileUpload: zone has role=button and tabindex=0', () => {
1172
+ const html = fileUpload({ name: 'x' })
1173
+ assert.match(html, /role="button"/)
1174
+ assert.match(html, /tabindex="0"/)
1175
+ })
1176
+
1177
+ test('fileUpload: disabled zone has tabindex=-1', () => {
1178
+ assert.match(fileUpload({ name: 'x', disabled: true }), /tabindex="-1"/)
1179
+ })
1180
+
1181
+ test('fileUpload: escapes name to prevent XSS', () => {
1182
+ const html = fileUpload({ name: '"><script>alert(1)</script>', label: 'x' })
1183
+ assert.doesNotMatch(html, /<script>alert/)
1184
+ })
1185
+
1186
+ // ─── select: event prop ──────────────────────────────────────────────────────
1187
+
1188
+ test('select: event prop renders data-event attribute', () => {
1189
+ const html = select({ name: 'cat', options: [], event: 'change:setCategory' })
1190
+ assert.match(html, /data-event="change:setCategory"/)
1191
+ })
1192
+
1193
+ test('select: no data-event when event not provided', () => {
1194
+ assert.doesNotMatch(select({ name: 'x', options: [] }), /data-event/)
1195
+ })
1196
+
1197
+ // ─── modal / modalTrigger ────────────────────────────────────────────────────
1198
+
1199
+ test('modal: renders a <dialog> element', () => {
1200
+ const html = modal({ id: 'test', title: 'Hello', content: '<p>Body</p>' })
1201
+ assert.match(html, /<dialog/)
1202
+ assert.match(html, /id="test"/)
1203
+ })
1204
+
1205
+ test('modal: sets aria-labelledby from id', () => {
1206
+ const html = modal({ id: 'my-modal', title: 'Title' })
1207
+ assert.match(html, /aria-labelledby="my-modal-title"/)
1208
+ })
1209
+
1210
+ test('modal: applies size modifier class', () => {
1211
+ assert.match(modal({ id: 'x', size: 'lg' }), /ui-modal--lg/)
1212
+ assert.doesNotMatch(modal({ id: 'x', size: 'md' }), /ui-modal--md/)
1213
+ })
1214
+
1215
+ test('modal: renders footer only when provided', () => {
1216
+ assert.match(modal({ id: 'x', footer: '<button>OK</button>' }), /<footer/)
1217
+ assert.doesNotMatch(modal({ id: 'x' }), /<footer/)
1218
+ })
1219
+
1220
+ test('modal: escapes title to prevent XSS', () => {
1221
+ const html = modal({ id: 'x', title: '<script>alert(1)</script>' })
1222
+ assert.doesNotMatch(html, /<script>alert/)
1223
+ })
1224
+
1225
+ test('modalTrigger: renders button with data-dialog-open', () => {
1226
+ const html = modalTrigger({ target: 'confirm', label: 'Open' })
1227
+ assert.match(html, /data-dialog-open="confirm"/)
1228
+ assert.match(html, /<button/)
1229
+ })
1230
+
1231
+ test('modalTrigger: does not use legacy data-modal-open', () => {
1232
+ const html = modalTrigger({ target: 'confirm', label: 'Open' })
1233
+ assert.doesNotMatch(html, /data-modal-open/)
1234
+ })
1235
+
1236
+ test('modalTrigger: escapes target id to prevent XSS', () => {
1237
+ const html = modalTrigger({ target: '"><script>', label: 'x' })
1238
+ assert.doesNotMatch(html, /<script>/)
1239
+ })
1240
+
1241
+ console.log('✓ All UI component tests passed')