@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,621 @@
1
+ /**
2
+ * Pulse 2 — Client Runtime
3
+ *
4
+ * Takes a spec, mounts it to a DOM element.
5
+ * Handles events, applies mutations, enforces constraints, re-renders.
6
+ *
7
+ * No framework. No virtual DOM. No dependencies.
8
+ */
9
+
10
+ import { initClientStore, getStoreState, subscribe, updateStore, registerStoreMutations, dispatchStoreMutation } from './store.js'
11
+
12
+ // Toast is lazy-loaded on first use — pages that never use _toast pay zero bytes
13
+ const showToast = (opts) => import('./toast.js').then(m => m.showToast(opts))
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Mount
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /**
20
+ * Mount a spec to a DOM element.
21
+ *
22
+ * @param {import('../spec/schema.js').PulseSpec} spec
23
+ * @param {HTMLElement} el
24
+ * @param {Object} [serverState] - Serialised server state from SSR
25
+ */
26
+ export function mount(spec, el, serverState = {}, options = {}) {
27
+ // Spec is validated server-side at startup — no need to re-validate in the browser
28
+ // Initialise the client store from SSR data (no-op after the first page mount).
29
+ // window.__PULSE_STORE__ is serialised by the server when a store is registered.
30
+ if (typeof window !== 'undefined') {
31
+ initClientStore(window.__PULSE_STORE__ || {})
32
+ // Register store mutations so data-store-event bindings can dispatch them.
33
+ // No-op on subsequent mounts — mutations persist across client navigations.
34
+ if (options.store?.mutations) {
35
+ registerStoreMutations(options.store.mutations)
36
+ }
37
+ }
38
+
39
+ // Separate page-level server state from store keys so they can be tracked
40
+ // independently — store values come from the live singleton, not the snapshot.
41
+ const _storeKeys = new Set(spec.store || [])
42
+ const _pageServerState = {}
43
+ for (const [k, v] of Object.entries(serverState)) {
44
+ if (!_storeKeys.has(k)) _pageServerState[k] = v
45
+ }
46
+
47
+ // Build the server state the view sees: fresh store slice + page-level data.
48
+ // Page-level keys always win over store keys with the same name.
49
+ function getEffectiveServerState() {
50
+ if (!_storeKeys.size) return _pageServerState
51
+ const storeState = getStoreState()
52
+ const slice = {}
53
+ for (const key of _storeKeys) {
54
+ if (storeState[key] !== undefined) slice[key] = storeState[key]
55
+ }
56
+ return { ...slice, ..._pageServerState }
57
+ }
58
+
59
+ // Deep clone initial state — never mutate the spec itself
60
+ let state = deepClone(spec.state)
61
+
62
+ // Restore persisted keys from localStorage
63
+ const persistKey = spec.persist?.length ? `pulse:${spec.route || location.pathname}` : null
64
+ let restoredFromPersist = false
65
+ if (persistKey) {
66
+ try {
67
+ const saved = JSON.parse(localStorage.getItem(persistKey) || '{}')
68
+ spec.persist.forEach(k => {
69
+ if (saved[k] !== undefined && saved[k] !== spec.state[k]) {
70
+ state[k] = saved[k]
71
+ restoredFromPersist = true
72
+ }
73
+ })
74
+ } catch { /* ignore parse errors */ }
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Persist
79
+ // ---------------------------------------------------------------------------
80
+
81
+ function persist() {
82
+ if (!persistKey) return
83
+ try {
84
+ const snapshot = {}
85
+ spec.persist.forEach(k => { snapshot[k] = state[k] })
86
+ localStorage.setItem(persistKey, JSON.stringify(snapshot))
87
+ } catch { /* ignore quota errors */ }
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Render
92
+ // ---------------------------------------------------------------------------
93
+
94
+ let lastHtml = null
95
+
96
+ function render() {
97
+ let html
98
+ try {
99
+ html = resolveView(spec, state, getEffectiveServerState())
100
+ } catch (err) {
101
+ console.error('[Pulse] View error:', err)
102
+ const serverState = getEffectiveServerState()
103
+ html = spec.onViewError
104
+ ? spec.onViewError(err, state, serverState)
105
+ : viewErrorFallback(err)
106
+ }
107
+ if (html === lastHtml) return // nothing changed
108
+ lastHtml = html
109
+ morph(el, html)
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Dispatch — the single entry point for all state changes
114
+ // ---------------------------------------------------------------------------
115
+
116
+ function dispatch(type, payload) {
117
+ // Mutation
118
+ if (spec.mutations?.[type]) {
119
+ const raw = spec.mutations[type](state, payload)
120
+ if (raw?._toast) showToast(raw._toast)
121
+ const { _toast, ...partial } = raw ?? {}
122
+ state = applyConstraints({ ...state, ...partial }, spec.constraints)
123
+ persist()
124
+ render()
125
+ return
126
+ }
127
+
128
+ // Action
129
+ if (spec.actions?.[type]) {
130
+ runAction(type, spec.actions[type], state, payload)
131
+ return
132
+ }
133
+
134
+ console.warn(`[Pulse] No mutation or action found for "${type}"`)
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Actions — async, cross the server/client boundary
139
+ // ---------------------------------------------------------------------------
140
+
141
+ async function runAction(name, action, currentState, payload) {
142
+ // Optimistic update before async work
143
+ if (action.onStart) {
144
+ const raw = action.onStart(currentState, payload)
145
+ if (raw?._toast) showToast(raw._toast)
146
+ const { _toast, ...partial } = raw ?? {}
147
+ state = applyConstraints({ ...state, ...partial }, spec.constraints)
148
+ render()
149
+ }
150
+
151
+ // Validate before running if requested
152
+ if (action.validate) {
153
+ const errors = validateFields(state, spec.validation)
154
+ if (errors.length > 0) {
155
+ console.warn(`[Pulse] Validation failed for action "${name}":`, errors)
156
+ const raw = action.onError?.(state, { validation: errors }) ?? {}
157
+ if (raw._toast) showToast(raw._toast)
158
+ const { _toast, ...partial } = raw
159
+ state = applyConstraints({ ...state, ...partial }, spec.constraints)
160
+ render()
161
+ return
162
+ }
163
+ }
164
+
165
+ try {
166
+ // Pass fresh effective server state (includes live store values)
167
+ const result = await action.run(state, getEffectiveServerState(), payload)
168
+ const raw = action.onSuccess(state, result) ?? {}
169
+
170
+ // _storeUpdate — push changes to the global store and notify all subscribers
171
+ if (raw._storeUpdate) updateStore(raw._storeUpdate)
172
+ if (raw._toast) showToast(raw._toast)
173
+ const { _storeUpdate: _su, _toast: _t, ...partial } = raw
174
+ state = applyConstraints({ ...state, ...partial }, spec.constraints)
175
+ } catch (error) {
176
+ console.error(`[Pulse] Action "${name}" failed:`, error)
177
+ const raw = action.onError(state, error) ?? {}
178
+ if (raw._toast) showToast(raw._toast)
179
+ const { _toast, ...partial } = raw
180
+ state = applyConstraints({ ...state, ...partial }, spec.constraints)
181
+ }
182
+
183
+ persist()
184
+ render()
185
+ }
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // Initial render
189
+ // ---------------------------------------------------------------------------
190
+
191
+ // Event delegation — one set of listeners on the root, survives morphing.
192
+ // AbortController lets destroy() remove all listeners at once.
193
+ const _eventAbort = new AbortController()
194
+ bindEvents(el, dispatch, _eventAbort.signal)
195
+
196
+ // Subscribe to global store — re-render whenever store keys this page uses change.
197
+ const _unsubStore = _storeKeys.size > 0 ? subscribe(() => render()) : null
198
+
199
+ // If the element was server-rendered, skip the initial render to avoid
200
+ // touching existing DOM — this preserves the early LCP paint from SSR.
201
+ // Morph on first mutation will diff from whatever SSR left in the DOM.
202
+ if (!(options.ssr && !restoredFromPersist)) {
203
+ render()
204
+ }
205
+
206
+ // ---------------------------------------------------------------------------
207
+ // Public API
208
+ // ---------------------------------------------------------------------------
209
+
210
+ return {
211
+ /** Read current state — useful for debugging and testing */
212
+ getState: () => deepClone(state),
213
+
214
+ /** Programmatically dispatch a mutation or action */
215
+ dispatch,
216
+
217
+ /** Force a re-render — useful after external state changes */
218
+ refresh: render,
219
+
220
+ /** Tear down — remove event listeners, unsubscribe from store, clear element */
221
+ destroy: () => { _unsubStore?.(); _eventAbort.abort(); el.innerHTML = '' }
222
+ }
223
+ }
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // View resolution
227
+ // ---------------------------------------------------------------------------
228
+
229
+ /**
230
+ * Execute the spec's view function(s) and return a complete HTML string.
231
+ *
232
+ * @param {import('../spec/schema.js').PulseSpec} spec
233
+ * @param {Object} state
234
+ * @param {Object} serverState
235
+ * @returns {string}
236
+ */
237
+ function resolveView(spec, state, serverState) {
238
+ if (typeof spec.view === 'function') {
239
+ return spec.view(state, serverState)
240
+ }
241
+
242
+ // Segmented view — concatenate all segments in definition order
243
+ return Object.values(spec.view)
244
+ .map(fn => fn(state, serverState))
245
+ .join('')
246
+ }
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // Event binding
250
+ // ---------------------------------------------------------------------------
251
+
252
+ /**
253
+ * Bind all data-event and data-action attributes within el.
254
+ * Uses event delegation — one listener per event type per element.
255
+ *
256
+ * Attribute format:
257
+ * data-event="mutationName" → fires on click
258
+ * data-event="change:mutationName" → fires on change
259
+ * data-event="input:mutationName" → fires on input
260
+ * data-action="actionName" → fires on form submit
261
+ *
262
+ * @param {HTMLElement} el
263
+ * @param {function} dispatch
264
+ */
265
+ /**
266
+ * Bind events via delegation on the root element.
267
+ * One listener per event type — survives DOM morphing without rebinding.
268
+ */
269
+ function bindEvents(el, dispatch, signal) {
270
+ const opts = signal ? { signal } : {}
271
+
272
+ el.addEventListener('click', e => {
273
+ // Store mutations
274
+ const storeTarget = e.target?.closest?.('[data-store-event]')
275
+ if (storeTarget) {
276
+ const [sType, sName] = parseEventAttr(storeTarget.dataset.storeEvent)
277
+ if (sType === 'click') { e.preventDefault(); dispatchStoreMutation(sName, e) }
278
+ return
279
+ }
280
+
281
+ // Dialog open — data-dialog-open="dialogId"
282
+ const dialogOpenTarget = e.target?.closest?.('[data-dialog-open]')
283
+ if (dialogOpenTarget) {
284
+ const dialog = document.getElementById(dialogOpenTarget.dataset.dialogOpen)
285
+ if (dialog?.showModal) { e.preventDefault(); dialog.showModal() }
286
+ return
287
+ }
288
+
289
+ // Dialog close — data-dialog-close (closes nearest ancestor <dialog>)
290
+ const dialogCloseTarget = e.target?.closest?.('[data-dialog-close]')
291
+ if (dialogCloseTarget) {
292
+ const dialog = dialogCloseTarget.closest('dialog')
293
+ if (dialog) { e.preventDefault(); dialog.close() }
294
+ return
295
+ }
296
+
297
+ // Backdrop close — click lands on the <dialog> element itself (outside content area)
298
+ if (e.target?.tagName === 'DIALOG') { e.target.close(); return }
299
+
300
+ // Spec events
301
+ const target = e.target?.closest?.('[data-event]')
302
+ if (!target) return
303
+ const [type, name] = parseEventAttr(target.dataset.event)
304
+ if (type !== 'click') return
305
+ e.preventDefault()
306
+ dispatch(name, e)
307
+ }, opts)
308
+
309
+ el.addEventListener('change', e => {
310
+ const storeTarget = e.target?.closest?.('[data-store-event]')
311
+ if (storeTarget) {
312
+ const [sType, sName] = parseEventAttr(storeTarget.dataset.storeEvent)
313
+ if (sType === 'change') dispatchStoreMutation(sName, e)
314
+ return
315
+ }
316
+ const target = e.target?.closest?.('[data-event]')
317
+ if (!target) return
318
+ const [type, name] = parseEventAttr(target.dataset.event)
319
+ if (type !== 'change') return
320
+ dispatchTimed(target, name, e, dispatch)
321
+ }, opts)
322
+
323
+ el.addEventListener('input', e => {
324
+ const storeTarget = e.target?.closest?.('[data-store-event]')
325
+ if (storeTarget) {
326
+ const [sType, sName] = parseEventAttr(storeTarget.dataset.storeEvent)
327
+ if (sType === 'input') dispatchStoreMutation(sName, e)
328
+ return
329
+ }
330
+ const target = e.target?.closest?.('[data-event]')
331
+ if (!target) return
332
+ const [type, name] = parseEventAttr(target.dataset.event)
333
+ if (type !== 'input') return
334
+ dispatchTimed(target, name, e, dispatch)
335
+ }, opts)
336
+
337
+ el.addEventListener('submit', e => {
338
+ const target = e.target?.closest?.('[data-action]')
339
+ if (!target) return
340
+ e.preventDefault()
341
+ dispatch(target.dataset.action, new FormData(target))
342
+ if (target.hasAttribute('data-reset')) target.reset()
343
+ }, opts)
344
+ }
345
+
346
+ /**
347
+ * Morph the children of `el` to match `newHtml` — updates only what changed.
348
+ * Preserves existing DOM nodes (images, inputs, etc.) rather than replacing them.
349
+ * Falls back to innerHTML in non-browser environments (tests, SSR).
350
+ */
351
+ function morph(el, newHtml) {
352
+ if (typeof document === 'undefined') {
353
+ el.innerHTML = newHtml
354
+ return
355
+ }
356
+ const temp = document.createElement('div')
357
+ temp.innerHTML = newHtml
358
+ morphNodes(el, temp)
359
+ }
360
+
361
+ function morphNodes(cur, nxt) {
362
+ const curNodes = Array.from(cur.childNodes)
363
+ const nxtNodes = Array.from(nxt.childNodes)
364
+
365
+ nxtNodes.forEach((nxtNode, i) => {
366
+ const curNode = curNodes[i]
367
+
368
+ if (!curNode) {
369
+ cur.appendChild(nxtNode.cloneNode(true))
370
+ return
371
+ }
372
+
373
+ if (curNode.nodeType !== nxtNode.nodeType || curNode.nodeName !== nxtNode.nodeName) {
374
+ cur.replaceChild(nxtNode.cloneNode(true), curNode)
375
+ return
376
+ }
377
+
378
+ if (nxtNode.nodeType === 3) { // TEXT_NODE
379
+ if (curNode.nodeValue !== nxtNode.nodeValue) curNode.nodeValue = nxtNode.nodeValue
380
+ return
381
+ }
382
+
383
+ if (nxtNode.nodeType === 1) { // ELEMENT_NODE
384
+ morphAttrs(curNode, nxtNode)
385
+ morphNodes(curNode, nxtNode)
386
+ }
387
+ })
388
+
389
+ // Remove surplus nodes
390
+ while (cur.childNodes.length > nxtNodes.length) cur.removeChild(cur.lastChild)
391
+ }
392
+
393
+ function morphAttrs(cur, nxt) {
394
+ for (const { name, value } of Array.from(nxt.attributes)) {
395
+ if (cur.getAttribute(name) !== value) cur.setAttribute(name, value)
396
+ }
397
+ for (const { name } of Array.from(cur.attributes)) {
398
+ if (!nxt.hasAttribute(name)) cur.removeAttribute(name)
399
+ }
400
+ }
401
+
402
+ /**
403
+ * Parse a data-event attribute value.
404
+ * "click:increment" → ['click', 'increment']
405
+ * "increment" → ['click', 'increment'] (click is default)
406
+ *
407
+ * @param {string} value
408
+ * @returns {[string, string]}
409
+ */
410
+ function parseEventAttr(value) {
411
+ const parts = value.split(':')
412
+ if (parts.length === 1) return ['click', parts[0]]
413
+ return [parts[0], parts[1]]
414
+ }
415
+
416
+ /**
417
+ * Dispatch a mutation, applying debounce or throttle if declared on the element.
418
+ * data-debounce="300" — waits 300ms after the last event before firing
419
+ * data-throttle="300" — fires at most once every 300ms
420
+ * No attribute — fires immediately (existing behaviour)
421
+ *
422
+ * The wrapped function is cached on the element via WeakMap so accumulation
423
+ * works correctly across renders without creating new timers on every event.
424
+ */
425
+ function dispatchTimed(target, name, e, dispatch) {
426
+ const debounceMs = parseInt(target.dataset.debounce, 10)
427
+ if (debounceMs > 0) {
428
+ getCached(target, 'd', name, debounceMs, (ev) => dispatch(name, ev))(e)
429
+ return
430
+ }
431
+ const throttleMs = parseInt(target.dataset.throttle, 10)
432
+ if (throttleMs > 0) {
433
+ getCached(target, 't', name, throttleMs, (ev) => dispatch(name, ev))(e)
434
+ return
435
+ }
436
+ dispatch(name, e)
437
+ }
438
+
439
+ // ---------------------------------------------------------------------------
440
+ // Constraints
441
+ // ---------------------------------------------------------------------------
442
+
443
+ /**
444
+ * Apply declared constraints to a state object.
445
+ * Returns a new state object — never mutates in place.
446
+ *
447
+ * @param {Object} state
448
+ * @param {Object} [constraints]
449
+ * @returns {Object}
450
+ */
451
+ function applyConstraints(state, constraints) {
452
+ if (!constraints) return state
453
+
454
+ const next = deepClone(state)
455
+
456
+ for (const [path, rules] of Object.entries(constraints)) {
457
+ const { obj, key } = resolvePath(next, path)
458
+ if (obj === null || obj[key] === undefined) continue
459
+
460
+ if (rules.min !== undefined && typeof obj[key] === 'number') {
461
+ obj[key] = Math.max(obj[key], rules.min)
462
+ }
463
+ if (rules.max !== undefined && typeof obj[key] === 'number') {
464
+ obj[key] = Math.min(obj[key], rules.max)
465
+ }
466
+ }
467
+
468
+ return next
469
+ }
470
+
471
+ // ---------------------------------------------------------------------------
472
+ // Validation
473
+ // ---------------------------------------------------------------------------
474
+
475
+ /**
476
+ * Run validation rules against the current state.
477
+ * Returns an array of error objects — empty means valid.
478
+ *
479
+ * @param {Object} state
480
+ * @param {Object} [validation]
481
+ * @returns {{ path: string, rule: string, message: string }[]}
482
+ */
483
+ export function validateFields(state, validation) {
484
+ if (!validation) return []
485
+
486
+ const errors = []
487
+
488
+ for (const [path, rules] of Object.entries(validation)) {
489
+ const { obj, key } = resolvePath(state, path)
490
+ const value = obj?.[key]
491
+
492
+ if (rules.required && !value) {
493
+ errors.push({ path, rule: 'required', message: `${path} is required` })
494
+ continue // no point running further rules on an empty value
495
+ }
496
+
497
+ if (value === undefined || value === null || value === '') continue
498
+
499
+ if (rules.minLength !== undefined && String(value).length < rules.minLength) {
500
+ errors.push({ path, rule: 'minLength', message: `${path} must be at least ${rules.minLength} characters` })
501
+ }
502
+
503
+ if (rules.maxLength !== undefined && String(value).length > rules.maxLength) {
504
+ errors.push({ path, rule: 'maxLength', message: `${path} must be no more than ${rules.maxLength} characters` })
505
+ }
506
+
507
+ if (rules.min !== undefined && Number(value) < rules.min) {
508
+ errors.push({ path, rule: 'min', message: `${path} must be at least ${rules.min}` })
509
+ }
510
+
511
+ if (rules.max !== undefined && Number(value) > rules.max) {
512
+ errors.push({ path, rule: 'max', message: `${path} must be no more than ${rules.max}` })
513
+ }
514
+
515
+ if (rules.format === 'email' && !isValidEmail(String(value))) {
516
+ errors.push({ path, rule: 'format', message: `${path} must be a valid email address` })
517
+ }
518
+
519
+ if (rules.format === 'url' && !isValidUrl(String(value))) {
520
+ errors.push({ path, rule: 'format', message: `${path} must be a valid URL` })
521
+ }
522
+
523
+ if (rules.format === 'numeric' && isNaN(Number(value))) {
524
+ errors.push({ path, rule: 'format', message: `${path} must be numeric` })
525
+ }
526
+
527
+ if (rules.pattern && !rules.pattern.test(String(value))) {
528
+ errors.push({ path, rule: 'pattern', message: `${path} does not match the required format` })
529
+ }
530
+ }
531
+
532
+ return errors
533
+ }
534
+
535
+ // ---------------------------------------------------------------------------
536
+ // Utilities
537
+ // ---------------------------------------------------------------------------
538
+
539
+ /**
540
+ * Resolve a dot-notation path into an object.
541
+ * Returns the parent object and the final key so the caller can read or write.
542
+ *
543
+ * resolvePath({ fields: { email: '' } }, 'fields.email')
544
+ * → { obj: { email: '' }, key: 'email' }
545
+ *
546
+ * @param {Object} obj
547
+ * @param {string} path
548
+ * @returns {{ obj: Object|null, key: string }}
549
+ */
550
+ function resolvePath(obj, path) {
551
+ const parts = path.split('.')
552
+ const key = parts.pop()
553
+ let cur = obj
554
+
555
+ for (const part of parts) {
556
+ if (cur === null || cur === undefined || typeof cur !== 'object') {
557
+ return { obj: null, key }
558
+ }
559
+ cur = cur[part]
560
+ }
561
+
562
+ return { obj: cur, key }
563
+ }
564
+
565
+ /**
566
+ * Deep clone a plain object. No circular reference support needed —
567
+ * spec state is always a plain serialisable object.
568
+ *
569
+ * @param {Object} obj
570
+ * @returns {Object}
571
+ */
572
+ function deepClone(obj) {
573
+ return JSON.parse(JSON.stringify(obj))
574
+ }
575
+
576
+ function viewErrorFallback(err) {
577
+ const msg = err?.message ? `: ${err.message}` : ''
578
+ return `<div style="padding:1rem;color:#b91c1c;background:#fef2f2;border:1px solid #fca5a5;border-radius:.375rem;font-family:monospace;font-size:.875rem"><strong>View error</strong>${msg}</div>`
579
+ }
580
+
581
+ function isValidEmail(value) {
582
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
583
+ }
584
+
585
+ // ---------------------------------------------------------------------------
586
+ // Debounce / throttle
587
+ // ---------------------------------------------------------------------------
588
+
589
+ /**
590
+ * WeakMap cache so each element gets one stable debounced/throttled wrapper
591
+ * per (name, delay) pair — survives DOM morphing without creating new timers.
592
+ */
593
+ const _timerCache = new WeakMap()
594
+
595
+ function getCached(el, kind, name, delay, fn) {
596
+ if (!_timerCache.has(el)) _timerCache.set(el, {})
597
+ const cache = _timerCache.get(el)
598
+ const key = `${kind}:${name}:${delay}`
599
+ if (!cache[key]) cache[key] = kind === 'd' ? debounce(fn, delay) : throttle(fn, delay)
600
+ return cache[key]
601
+ }
602
+
603
+ export function debounce(fn, delay) {
604
+ let timer
605
+ return function(...args) {
606
+ clearTimeout(timer)
607
+ timer = setTimeout(() => fn(...args), delay)
608
+ }
609
+ }
610
+
611
+ export function throttle(fn, delay) {
612
+ let last = 0
613
+ return function(...args) {
614
+ const now = Date.now()
615
+ if (now - last >= delay) { last = now; fn(...args) }
616
+ }
617
+ }
618
+
619
+ function isValidUrl(value) {
620
+ try { new URL(value); return true } catch { return false }
621
+ }