@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,198 @@
1
+ import { renderLayout, h1, lead, section, codeBlock, callout, table } from '../lib/layout.js'
2
+ import { prevNext } from '../lib/nav.js'
3
+ import { highlight } from '../lib/highlight.js'
4
+
5
+ const { prev, next } = prevNext('/testing')
6
+
7
+ export default {
8
+ route: '/testing',
9
+ meta: {
10
+ title: 'Testing — Pulse Docs',
11
+ description: 'Test Pulse view functions with renderSync and render — query the HTML output with CSS-like selectors, no DOM required.',
12
+ styles: ['/docs.css'],
13
+ },
14
+ state: {},
15
+ view: () => renderLayout({
16
+ currentHref: '/testing',
17
+ prev,
18
+ next,
19
+ content: `
20
+ ${h1('Testing')}
21
+ ${lead('Pulse ships a built-in testing helper at <code>@invisibleloop/pulse/testing</code>. Render a spec\'s view in tests and query the HTML output with CSS-like selectors — no DOM, no jsdom, no extra dependencies.')}
22
+
23
+ ${section('quick-start', 'Quick start')}
24
+ ${codeBlock(highlight(`import { renderSync, render } from '@invisibleloop/pulse/testing'
25
+ import assert from 'node:assert/strict'
26
+ import spec from './src/pages/counter.js'
27
+
28
+ // Synchronous — calls view directly, mock state and server
29
+ const result = renderSync(spec, { state: { count: 5 } })
30
+ assert(result.has('button'))
31
+ assert.equal(result.get('#count').text, '5')
32
+
33
+ // Async — runs real spec.server fetchers (or pass server to mock them)
34
+ const result = await render(productSpec, {
35
+ server: { product: { id: 1, name: 'Widget', price: 9.99 } }
36
+ })
37
+ assert.equal(result.get('h1').text, 'Widget')
38
+ assert.equal(result.count('li'), 3)`, 'js'))}
39
+
40
+ ${section('render-sync', 'renderSync(spec, options?)')}
41
+ <p>Synchronous. Calls the view function directly — no server fetcher resolution. The fastest path for unit testing pure view functions.</p>
42
+ ${table(
43
+ ['Option', 'Type', 'Default', 'Description'],
44
+ [
45
+ ['<code>state</code>', '<code>object</code>', '<code>{}</code>', 'State overrides merged with <code>spec.state</code>.'],
46
+ ['<code>server</code>', '<code>object</code>', '<code>{}</code>', 'Server state passed directly to the view. Fetchers are never called.'],
47
+ ]
48
+ )}
49
+ ${codeBlock(highlight(`import { renderSync } from '@invisibleloop/pulse/testing'
50
+
51
+ const result = renderSync(formSpec, {
52
+ state: { name: 'Alice', email: 'alice@example.com' },
53
+ server: { plans: [{ id: 'pro', label: 'Pro' }] },
54
+ })`, 'js'))}
55
+
56
+ ${section('render-async', 'render(spec, options?)')}
57
+ <p>Async. Two modes — mock or integration:</p>
58
+ <ul>
59
+ <li><strong>Mock mode</strong> — pass <code>server</code> to use that data directly. Fetchers are not called. Fast and deterministic.</li>
60
+ <li><strong>Integration mode</strong> — omit <code>server</code> and real <code>spec.server</code> fetchers run. Pass <code>ctx</code> to set params, cookies, headers, etc.</li>
61
+ </ul>
62
+ ${table(
63
+ ['Option', 'Type', 'Default', 'Description'],
64
+ [
65
+ ['<code>state</code>', '<code>object</code>', '<code>{}</code>', 'State overrides merged with <code>spec.state</code>.'],
66
+ ['<code>server</code>', '<code>object</code>', '<code>undefined</code>', 'Server state passed directly to the view. When set, fetchers are skipped entirely.'],
67
+ ['<code>ctx</code>', '<code>object</code>', '<code>{}</code>', 'Request context passed to <code>spec.server</code> fetchers (integration mode only). Accepts <code>params</code>, <code>query</code>, <code>cookies</code>, <code>headers</code>, etc.'],
68
+ ]
69
+ )}
70
+ ${codeBlock(highlight(`import { render } from '@invisibleloop/pulse/testing'
71
+
72
+ // Mock — skip fetchers
73
+ const result = await render(productSpec, {
74
+ server: { product: { id: 42, name: 'Gadget' } }
75
+ })
76
+
77
+ // Integration — real fetchers, with ctx
78
+ const result = await render(productSpec, {
79
+ ctx: { params: { id: '42' }, cookies: { session: 'abc' } }
80
+ })`, 'js'))}
81
+
82
+ ${section('render-result', 'RenderResult')}
83
+ <p>Both functions return the same <code>RenderResult</code> object.</p>
84
+ ${table(
85
+ ['Property / method', 'Returns', 'Description'],
86
+ [
87
+ ['<code>.html</code>', '<code>string</code>', 'Raw HTML string from the view.'],
88
+ ['<code>.state</code>', '<code>object</code>', 'Client state used for rendering.'],
89
+ ['<code>.server</code>', '<code>object</code>', 'Server state used for rendering.'],
90
+ ['<code>.text()</code>', '<code>string</code>', 'All text content — tags stripped, entities decoded, whitespace collapsed.'],
91
+ ['<code>.has(selector)</code>', '<code>boolean</code>', 'True if any element matches selector.'],
92
+ ['<code>.find(selector)</code>', '<code>Element | null</code>', 'First matching element, or null.'],
93
+ ['<code>.get(selector)</code>', '<code>Element</code>', 'First matching element. Throws with a clear message if not found.'],
94
+ ['<code>.findAll(selector)</code>', '<code>Element[]</code>', 'All matching elements.'],
95
+ ['<code>.count(selector)</code>', '<code>number</code>', 'Number of matching elements.'],
96
+ ['<code>.attr(selector, name)</code>', '<code>string | null</code>', 'Attribute value of the first matching element. Null if element or attribute absent.'],
97
+ ]
98
+ )}
99
+
100
+ ${section('element', 'Element')}
101
+ <p>Elements returned by <code>find()</code>, <code>get()</code>, and <code>findAll()</code> support the same query methods scoped to their own subtree.</p>
102
+ ${table(
103
+ ['Property / method', 'Returns', 'Description'],
104
+ [
105
+ ['<code>.tag</code>', '<code>string</code>', 'Tag name (lowercase).'],
106
+ ['<code>.text</code>', '<code>string</code>', 'All text content within the element, whitespace-collapsed.'],
107
+ ['<code>.attrs</code>', '<code>object</code>', 'Parsed attribute map. Boolean attrs (e.g. <code>disabled</code>) have value <code>true</code>.'],
108
+ ['<code>.attr(name)</code>', '<code>string | null</code>', 'Get one attribute. Returns <code>""</code> for boolean attrs, <code>null</code> if absent — mirrors <code>getAttribute()</code>.'],
109
+ ['<code>.find(selector)</code>', '<code>Element | null</code>', 'First matching descendant.'],
110
+ ['<code>.findAll(selector)</code>', '<code>Element[]</code>', 'All matching descendants.'],
111
+ ['<code>.has(selector)</code>', '<code>boolean</code>', 'True if any descendant matches selector.'],
112
+ ]
113
+ )}
114
+
115
+ ${section('selectors', 'Supported selectors')}
116
+ <p>The selector engine supports the most common patterns. Descendant combinators (<code>div p</code>) are not supported — use <code>element.findAll()</code> to search within a matched element instead.</p>
117
+ ${table(
118
+ ['Selector', 'Example', 'Matches'],
119
+ [
120
+ ['Tag', '<code>button</code>', 'Any <code>&lt;button&gt;</code>'],
121
+ ['Class', '<code>.ui-btn</code>', 'Elements with that class'],
122
+ ['ID', '<code>#submit</code>', 'Element with that id'],
123
+ ['Attribute present','<code>[disabled]</code>', 'Elements with a <code>disabled</code> attribute'],
124
+ ['Attribute value', '<code>[type="submit"]</code>', 'Elements where <code>type</code> equals <code>submit</code>'],
125
+ ['Compound', '<code>button.primary[disabled]</code>', 'All conditions on the same element'],
126
+ ]
127
+ )}
128
+ ${codeBlock(highlight(`result.has('button') // any <button>
129
+ result.has('.ui-btn--primary') // BEM modifier class
130
+ result.has('[data-action="submit"]') // data attribute
131
+ result.has('input[type="email"][required]') // compound
132
+ result.get('form').findAll('input') // inputs inside form`, 'js'))}
133
+
134
+ ${section('patterns', 'Common patterns')}
135
+ ${codeBlock(highlight(`// Assert an element exists and check its text
136
+ assert.equal(result.get('h1').text, 'Page Title')
137
+
138
+ // Assert an element does NOT exist
139
+ assert(!result.has('.error-message'))
140
+
141
+ // Check an attribute value
142
+ assert.equal(result.attr('input[name="email"]', 'type'), 'email')
143
+
144
+ // Boolean attributes — attr() returns '' (not 'disabled')
145
+ assert.equal(result.attr('[disabled]', 'disabled'), '')
146
+ assert(result.get('[disabled]').attr('disabled') === '')
147
+
148
+ // Count elements
149
+ assert.equal(result.count('li'), 3)
150
+
151
+ // Inspect all items
152
+ const items = result.findAll('li')
153
+ assert.equal(items[0].text, 'Alpha')
154
+ assert.equal(items[1].text, 'Beta')
155
+
156
+ // Scope a search to a subtree
157
+ const form = result.get('form')
158
+ assert(form.has('button[type="submit"]'))
159
+ assert.equal(form.count('input'), 2)
160
+
161
+ // Text content decodes entities
162
+ // <p>&lt;b&gt;bold&lt;/b&gt;</p> → text === '<b>bold</b>'
163
+ assert.equal(result.get('p').text, '<b>bold</b>')`, 'js'))}
164
+
165
+ ${section('test-file', 'Example test file')}
166
+ ${codeBlock(highlight(`/**
167
+ * src/pages/counter.test.js
168
+ * run: node src/pages/counter.test.js
169
+ */
170
+ import { test } from 'node:test'
171
+ import assert from 'node:assert/strict'
172
+ import { renderSync } from '@invisibleloop/pulse/testing'
173
+ import spec from './counter.js'
174
+
175
+ test('renders the current count', () => {
176
+ const result = renderSync(spec, { state: { count: 7 } })
177
+ assert.equal(result.get('#count').text, '7')
178
+ })
179
+
180
+ test('increment mutation returns count + 1', () => {
181
+ const next = spec.mutations.increment({ count: 0 })
182
+ assert.equal(next.count, 1)
183
+ })
184
+
185
+ test('decrement mutation returns count - 1', () => {
186
+ const next = spec.mutations.decrement({ count: 5 })
187
+ assert.equal(next.count, 4)
188
+ })
189
+
190
+ test('view renders increment and decrement buttons', () => {
191
+ const result = renderSync(spec)
192
+ assert(result.has('[data-event="increment"]'))
193
+ assert(result.has('[data-event="decrement"]'))
194
+ })`, 'js'))}
195
+ ${callout('note', 'Use <code>renderSync</code> for mutations and pure view tests — it\'s synchronous and needs no <code>await</code>. Use <code>render</code> when your spec has <code>server</code> fetchers you want to exercise for integration coverage.')}
196
+ `,
197
+ }),
198
+ }
@@ -0,0 +1,138 @@
1
+ import { renderLayout, h1, lead, section, sub, codeBlock, callout, table } from '../lib/layout.js'
2
+ import { prevNext } from '../lib/nav.js'
3
+ import { highlight } from '../lib/highlight.js'
4
+
5
+ const { prev, next } = prevNext('/validation')
6
+
7
+ export default {
8
+ route: '/validation',
9
+ meta: {
10
+ title: 'Validation — Pulse Docs',
11
+ description: 'Declarative validation rules in Pulse — syntax, formats, dot-path notation, and error handling.',
12
+ styles: ['/docs.css'],
13
+ },
14
+ state: {},
15
+ view: () => renderLayout({
16
+ currentHref: '/validation',
17
+ prev,
18
+ next,
19
+ content: `
20
+ ${h1('Validation')}
21
+ ${lead('Validation rules are declared in the spec, co-located with the state they guard. When an action sets <code>validate: true</code>, Pulse enforces every rule before the async work runs. Invalid data cannot reach <code>run()</code>.')}
22
+
23
+ ${section('declaring', 'Declaring validation rules')}
24
+ <p>The <code>validation</code> field maps dot-path state keys to rule objects:</p>
25
+ ${codeBlock(highlight(`export default {
26
+ route: '/signup',
27
+ state: {
28
+ fields: { name: '', email: '', age: '', website: '' },
29
+ },
30
+ validation: {
31
+ 'fields.name': { required: true, minLength: 2, maxLength: 100 },
32
+ 'fields.email': { required: true, format: 'email' },
33
+ 'fields.age': { required: true, min: 18, max: 120 },
34
+ 'fields.website': { format: 'url' }, // optional field, but must be valid URL if provided
35
+ },
36
+ // ...
37
+ }`, 'js'))}
38
+
39
+ ${section('rules', 'Available rules')}
40
+ ${table(
41
+ ['Rule', 'Type', 'Description'],
42
+ [
43
+ ['<code>required</code>', '<code>boolean</code>', 'Field must be present and non-empty.'],
44
+ ['<code>minLength</code>', '<code>number</code>', 'String must be at least N characters.'],
45
+ ['<code>maxLength</code>', '<code>number</code>', 'String must be at most N characters.'],
46
+ ['<code>min</code>', '<code>number</code>', 'Numeric value must be ≥ N.'],
47
+ ['<code>max</code>', '<code>number</code>', 'Numeric value must be ≤ N.'],
48
+ ['<code>pattern</code>', '<code>RegExp | string</code>', 'Value must match the regular expression.'],
49
+ ['<code>format</code>', '<code>string</code>', 'Named format: <code>email</code>, <code>url</code>, or <code>numeric</code>.'],
50
+ ]
51
+ )}
52
+
53
+ ${section('formats', 'Named formats')}
54
+ ${table(
55
+ ['Format', 'What it checks'],
56
+ [
57
+ ['<code>email</code>', 'Basic email structure — must contain <code>@</code> and a domain.'],
58
+ ['<code>url</code>', 'Must start with <code>http://</code> or <code>https://</code>.'],
59
+ ['<code>numeric</code>', 'Must consist entirely of digit characters.'],
60
+ ]
61
+ )}
62
+
63
+ ${section('dot-paths', 'Dot-path notation')}
64
+ <p>Validation keys are dot-paths into the current <code>state</code>, allowing nested fields to be validated without any special syntax:</p>
65
+ ${codeBlock(highlight(`state: {
66
+ billing: {
67
+ address: { street: '', city: '', postcode: '' },
68
+ card: { number: '', expiry: '' },
69
+ },
70
+ }
71
+
72
+ validation: {
73
+ 'billing.address.street': { required: true },
74
+ 'billing.address.city': { required: true },
75
+ 'billing.address.postcode': { required: true, pattern: /^[A-Z]{1,2}\\d[A-Z\\d]? \\d[A-Z]{2}$/i },
76
+ 'billing.card.number': { required: true, format: 'numeric', minLength: 16, maxLength: 16 },
77
+ 'billing.card.expiry': { required: true },
78
+ }`, 'js'))}
79
+
80
+ ${section('when-runs', 'When validation runs')}
81
+ <p>Validation only runs when an action declares <code>validate: true</code>. The order is enforced by the framework:</p>
82
+ <ol>
83
+ <li><code>onStart</code> captures <code>FormData</code> values into state</li>
84
+ <li>Validation reads those values from state using dot-paths</li>
85
+ <li>If any rules fail, <code>onError</code> is called immediately — <code>run</code> is skipped</li>
86
+ </ol>
87
+ ${callout('note', 'Validation reads from <strong>state</strong>, not from raw <code>FormData</code>. <code>onStart</code> must copy form values into state first — this is what makes them available to dot-path rules.')}
88
+
89
+ ${section('error-structure', 'Error structure')}
90
+ <p>When validation fails, the runtime throws an error object with a <code>validation</code> array:</p>
91
+ ${codeBlock(highlight(`{
92
+ message: 'Validation failed',
93
+ validation: [
94
+ { field: 'fields.email', rule: 'format', message: 'Must be a valid email address' },
95
+ { field: 'fields.name', rule: 'required', message: 'Required' },
96
+ { field: 'fields.age', rule: 'min', message: 'Must be at least 18' },
97
+ ]
98
+ }`, 'js'))}
99
+ <p>In your action's <code>onError</code>, check for <code>err?.validation</code> to distinguish validation errors from other failures:</p>
100
+ ${codeBlock(highlight(`onError: (state, err) => ({
101
+ status: 'error',
102
+ errors: err?.validation ?? [{ message: err.message }],
103
+ })`, 'js'))}
104
+
105
+ ${section('rendering', 'Rendering errors')}
106
+ <p>The errors array maps to UI in the view — a global error list, or inline errors using the <code>field</code> property to place them next to each input:</p>
107
+ ${codeBlock(highlight(`view: (state) => {
108
+ const errFor = (field) => {
109
+ const e = state.errors.find(e => e.field === field)
110
+ return e ? \`<p class="field-error">\${e.message}</p>\` : ''
111
+ }
112
+
113
+ return \`
114
+ <form data-action="submit">
115
+ <label>
116
+ Email
117
+ <input name="email" type="email" value="\${state.fields.email}">
118
+ \${errFor('fields.email')}
119
+ </label>
120
+ <label>
121
+ Name
122
+ <input name="name" type="text" value="\${state.fields.name}">
123
+ \${errFor('fields.name')}
124
+ </label>
125
+ <button>Submit</button>
126
+ </form>
127
+ \`
128
+ }`, 'js'))}
129
+
130
+ ${section('optional-fields', 'Optional fields')}
131
+ <p>Omit <code>required: true</code> for optional fields. Other rules (format, minLength, etc.) are only enforced when the field has a value — empty optional fields always pass:</p>
132
+ ${codeBlock(highlight(`validation: {
133
+ // website is optional, but must be a valid URL if provided
134
+ 'fields.website': { format: 'url' },
135
+ }`, 'js'))}
136
+ `,
137
+ }),
138
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Pulse — Contact form example
3
+ *
4
+ * Demonstrates:
5
+ * - Server data (fetched before render, passed to view)
6
+ * - Validation (required, email format, length limits)
7
+ * - Async action with full lifecycle: onStart → validate → run → onSuccess/onError
8
+ * - Loading, success, and error states
9
+ *
10
+ * Run: node examples/dev.server.js → http://localhost:3001/contact
11
+ */
12
+
13
+ import { button, alert, heading, input, textarea, card, container, section, stack, grid, iconMail, iconPhone, iconMapPin, iconClock, iconCheckCircle } from '../src/ui/index.js'
14
+ import { examplesNav } from './shared.js'
15
+
16
+ function infoDetail(icon, label, value, href) {
17
+ const valueHtml = href
18
+ ? `<a href="${href}" class="u-text-default">${value}</a>`
19
+ : `<span>${value}</span>`
20
+ return `<div class="u-flex u-items-start u-gap-3">
21
+ <span class="u-text-muted u-shrink-0" aria-hidden="true">${icon}</span>
22
+ <div>
23
+ <p class="u-text-xs u-text-muted u-font-semibold u-mb-1">${label}</p>
24
+ <p class="u-text-sm">${valueHtml}</p>
25
+ </div>
26
+ </div>`
27
+ }
28
+
29
+ export default {
30
+ route: '/contact',
31
+ hydrate: '/examples/contact.js',
32
+
33
+ meta: {
34
+ title: 'Contact — Pulse',
35
+ description: 'A contact form built with Pulse. Demonstrates server data, validation, and async action lifecycle.',
36
+ styles: ['/pulse-ui.css'],
37
+ },
38
+
39
+ server: {
40
+ info: async () => ({
41
+ email: 'hello@example.com',
42
+ phone: '+44 20 7946 0958',
43
+ address: '123 Shoreditch High St, London E1 6JE',
44
+ hours: 'Monday – Friday, 9 am – 5 pm GMT',
45
+ }),
46
+ },
47
+
48
+ state: {
49
+ status: 'idle', // idle | loading | success | error
50
+ errors: [],
51
+ fields: { name: '', email: '', subject: '', message: '' },
52
+ },
53
+
54
+ validation: {
55
+ 'fields.name': { required: true, minLength: 2, maxLength: 80 },
56
+ 'fields.email': { required: true, format: 'email' },
57
+ 'fields.subject': { required: true, minLength: 2, maxLength: 120 },
58
+ 'fields.message': { required: true, minLength: 10, maxLength: 2000 },
59
+ },
60
+
61
+ mutations: {
62
+ reset: () => ({ status: 'idle', errors: [], fields: { name: '', email: '', subject: '', message: '' } }),
63
+ },
64
+
65
+ actions: {
66
+ submit: {
67
+ onStart: (_state, formData) => ({
68
+ status: 'loading',
69
+ errors: [],
70
+ fields: {
71
+ name: formData.get('name') || '',
72
+ email: formData.get('email') || '',
73
+ subject: formData.get('subject') || '',
74
+ message: formData.get('message') || '',
75
+ },
76
+ }),
77
+
78
+ validate: true,
79
+
80
+ run: async (_state, _server, formData) => {
81
+ // Simulate sending — replace with a real fetch() to your API
82
+ await new Promise(resolve => setTimeout(resolve, 900))
83
+ // Demo: email addresses containing "fail" trigger the error path
84
+ if ((formData.get('email') || '').includes('fail')) {
85
+ throw new Error('Unable to deliver message. Please try another address.')
86
+ }
87
+ },
88
+
89
+ onSuccess: () => ({ status: 'success', errors: [] }),
90
+
91
+ onError: (_state, err) => ({
92
+ status: 'error',
93
+ errors: err?.validation
94
+ ? err.validation.map(e => {
95
+ const field = e.path?.split('.').pop() || ''
96
+ const label = { name: 'Name', email: 'Email', subject: 'Subject', message: 'Message' }[field] || field
97
+ const msg = e.rule === 'required' ? `${label} is required`
98
+ : e.rule === 'minLength' ? `${label} is too short`
99
+ : e.rule === 'maxLength' ? `${label} is too long`
100
+ : e.rule === 'format' ? `${label} must be a valid email address`
101
+ : e.message
102
+ return { field, message: msg }
103
+ })
104
+ : [{ message: err?.message || 'Something went wrong. Please try again.' }],
105
+ }),
106
+ },
107
+ },
108
+
109
+ view: (state, server) => {
110
+ const { info } = server
111
+ const loading = state.status === 'loading'
112
+ const isSuccess = state.status === 'success'
113
+
114
+ const fieldError = (field) => state.errors.find(e => e.field === field)?.message || ''
115
+
116
+ const infoCol = stack({ gap: 'lg', content: `
117
+ ${heading({ level: 2, text: 'Get in touch', size: 'xl' })}
118
+ <p class="u-text-sm u-text-muted">Fill in the form and we&rsquo;ll get back to you within one business day.</p>
119
+ ${stack({ gap: 'md', content: `
120
+ ${infoDetail(iconMail({ size: 18 }), 'Email', info.email, 'mailto:' + info.email)}
121
+ ${infoDetail(iconPhone({ size: 18 }), 'Phone', info.phone, 'tel:' + info.phone.replace(/\s/g, ''))}
122
+ ${infoDetail(iconMapPin({ size: 18 }), 'Address', info.address, null)}
123
+ ${infoDetail(iconClock({ size: 18 }), 'Hours', info.hours, null)}
124
+ ` })}
125
+ ` })
126
+
127
+ const cardContent = isSuccess
128
+ ? stack({ gap: 'md', align: 'center', content:
129
+ iconCheckCircle({ size: 48, bg: 'circle', bgColor: 'success' }) +
130
+ heading({ level: 2, text: 'Message sent!', size: 'xl' }) +
131
+ `<p class="u-text-sm u-text-muted u-text-center">Thanks for reaching out. We&rsquo;ll be in touch within one business day.</p>` +
132
+ button({ label: 'Send another message', variant: 'secondary', attrs: { 'data-event': 'reset' } })
133
+ })
134
+ : `<form data-action="submit" novalidate>
135
+ <fieldset style="border:none;padding:0;margin:0" ${loading ? 'disabled' : ''}>
136
+ <legend class="u-text-lg u-font-semibold u-mb-5">Send a message</legend>
137
+ ${stack({ gap: 'md', content: `
138
+ ${state.status === 'error' && state.errors.length > 0
139
+ ? alert({ variant: 'error', content: state.errors[0]?.message || 'Please check the fields below.' })
140
+ : ''}
141
+ ${grid({ cols: 2, gap: 'sm', content:
142
+ input({ name: 'name', label: 'Name', type: 'text', placeholder: 'Jane Smith', value: state.fields.name, required: true, error: fieldError('name') }) +
143
+ input({ name: 'email', label: 'Email address', type: 'email', placeholder: 'jane@example.com', value: state.fields.email, required: true, error: fieldError('email') })
144
+ })}
145
+ ${input({ name: 'subject', label: 'Subject', placeholder: 'How can we help?', value: state.fields.subject, required: true, error: fieldError('subject') })}
146
+ ${textarea({ name: 'message', label: 'Message', placeholder: "Tell us what's on your mind…", value: state.fields.message, rows: 5, required: true, error: fieldError('message') })}
147
+ ${button({ label: loading ? 'Sending…' : 'Send message', type: 'submit', fullWidth: true, disabled: loading })}
148
+ ` })}
149
+ </fieldset>
150
+ </form>`
151
+
152
+ return `
153
+ ${examplesNav('<span>✉ Contact</span>', '/contact')}
154
+
155
+ <main id="main-content">
156
+ ${section({ eyebrow: 'Contact', title: 'We\u2019d love to hear from you', level: 1, content:
157
+ container({ size: 'lg', content:
158
+ grid({ cols: 2, gap: 'lg', content: `
159
+ <div>${infoCol}</div>
160
+ <div>${card({ content: cardContent })}</div>
161
+ ` })
162
+ })
163
+ })}
164
+ </main>`
165
+ },
166
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Pulse — Counter example
3
+ *
4
+ * Demonstrates:
5
+ * - State and mutations
6
+ * - Constraints (min/max enforced after every mutation)
7
+ * - data-event binding
8
+ * - Hydration for client interactivity
9
+ *
10
+ * Run: node examples/dev.server.js → http://localhost:3001/counter
11
+ */
12
+
13
+ import { button, badge, card, container, section, stack, cluster, progress, segmented } from '../src/ui/index.js'
14
+ import { examplesNav } from './shared.js'
15
+
16
+ export default {
17
+ route: '/counter',
18
+ hydrate: '/examples/counter.js',
19
+
20
+ meta: {
21
+ title: 'Counter — Pulse',
22
+ description: 'An interactive counter built with Pulse. Demonstrates mutations, constraints, and data-event binding.',
23
+ styles: ['/pulse-ui.css'],
24
+ },
25
+
26
+ state: {
27
+ count: 0,
28
+ step: 1,
29
+ },
30
+
31
+ constraints: {
32
+ count: { min: 0, max: 20 },
33
+ step: { min: 1, max: 5 },
34
+ },
35
+
36
+ mutations: {
37
+ increment: (state) => ({ count: state.count + state.step }),
38
+ decrement: (state) => ({ count: state.count - state.step }),
39
+ reset: () => ({ count: 0 }),
40
+ setStep: (_state, e) => ({ step: parseInt(e.target.value, 10) || 1 }),
41
+ },
42
+
43
+ view: (state) => {
44
+ const atMin = state.count <= 0
45
+ const atMax = state.count >= 20
46
+
47
+
48
+ return `
49
+ ${examplesNav('<span>⚡ Counter</span>', '/counter')}
50
+
51
+ <main id="main-content">
52
+ ${section({ eyebrow: 'Mutations & Constraints', title: 'Counter', level: 1, align: 'center',
53
+ subtitle: 'Constraints are enforced after every mutation — the count can never go below 0 or above 20.',
54
+ content:
55
+ container({ size: 'sm', content: `
56
+
57
+ ${card({ content: stack({ gap: 'lg', align: 'center', content: `
58
+
59
+ <div class="u-flex u-items-end u-justify-center u-gap-3" aria-live="polite" aria-atomic="true">
60
+ <span class="u-text-7xl u-font-bold u-text-accent">${state.count}</span>
61
+ <span class="u-text-3xl u-text-muted u-mb-2">/ 20</span>
62
+ </div>
63
+
64
+ ${progress({ value: state.count, max: 20, label: 'Counter progress' })}
65
+
66
+ ${cluster({ justify: 'center', gap: 'sm', content:
67
+ button({ label: '−', variant: 'secondary', disabled: atMin, attrs: { 'data-event': 'decrement', 'aria-label': 'Decrement' } }) +
68
+ button({ label: 'Reset', variant: 'ghost', attrs: { 'data-event': 'reset' } }) +
69
+ button({ label: '+', variant: 'primary', disabled: atMax, attrs: { 'data-event': 'increment', 'aria-label': 'Increment' } })
70
+ })}
71
+
72
+ ${stack({ gap: 'xs', align: 'center', content: `
73
+ <p class="u-text-xs u-text-muted" id="step-label">Step size</p>
74
+ ${segmented({
75
+ name: 'step',
76
+ value: String(state.step),
77
+ event: 'change:setStep',
78
+ options: [1, 2, 5].map(n => ({ value: n, label: String(n) })),
79
+ size: 'md',
80
+ })}
81
+ ` })}
82
+
83
+ ${cluster({ justify: 'center', gap: 'xs', content:
84
+ badge({ label: atMin ? 'At minimum' : 'Min: 0', variant: atMin ? 'warning' : 'default' }) +
85
+ badge({ label: atMax ? 'At maximum' : 'Max: 20', variant: atMax ? 'warning' : 'default' })
86
+ })}
87
+
88
+ ` }) })}
89
+
90
+ `})
91
+ })}
92
+ </main>`
93
+ },
94
+ }