@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,421 @@
1
+ /**
2
+ * Pulse — SSR renderer tests
3
+ * run: node src/runtime/ssr.test.js
4
+ */
5
+
6
+ import { renderToString, renderToStream, wrapDocument, withTimeout } from './ssr.js'
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Test runner
10
+ // ---------------------------------------------------------------------------
11
+
12
+ let passed = 0
13
+ let failed = 0
14
+
15
+ async function test(label, fn) {
16
+ try {
17
+ await fn()
18
+ console.log(` ✓ ${label}`)
19
+ passed++
20
+ } catch (e) {
21
+ console.log(` ✗ ${label}`)
22
+ console.log(` ${e.message}`)
23
+ failed++
24
+ }
25
+ }
26
+
27
+ function assert(condition, msg) {
28
+ if (!condition) throw new Error(msg || 'Assertion failed')
29
+ }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Helpers
33
+ // ---------------------------------------------------------------------------
34
+
35
+ async function streamToString(stream) {
36
+ const reader = stream.getReader()
37
+ const decoder = new TextDecoder()
38
+ let result = ''
39
+ while (true) {
40
+ const { done, value } = await reader.read()
41
+ if (done) break
42
+ result += decoder.decode(value)
43
+ }
44
+ return result
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Specs used across tests
49
+ // ---------------------------------------------------------------------------
50
+
51
+ const helloSpec = {
52
+ route: '/hello',
53
+ state: {},
54
+ view: () => '<h1>Hello world</h1>'
55
+ }
56
+
57
+ const counterSpec = {
58
+ route: '/counter',
59
+ state: { count: 5 },
60
+ view: (state) => `<span id="count">${state.count}</span>`
61
+ }
62
+
63
+ const serverDataSpec = {
64
+ route: '/products',
65
+ state: {},
66
+ server: {
67
+ products: async (ctx) => [
68
+ { id: 1, name: 'Widget' },
69
+ { id: 2, name: 'Gadget' }
70
+ ]
71
+ },
72
+ view: (state, server) =>
73
+ `<ul>${server.products.map(p => `<li>${p.name}</li>`).join('')}</ul>`
74
+ }
75
+
76
+ const streamSpec = {
77
+ route: '/stream',
78
+ stream: {
79
+ shell: ['header', 'nav'],
80
+ deferred: ['feed']
81
+ },
82
+ server: {
83
+ feed: async () => {
84
+ // Simulate async data
85
+ await new Promise(r => setTimeout(r, 10))
86
+ return [{ title: 'Post 1' }, { title: 'Post 2' }]
87
+ }
88
+ },
89
+ state: {},
90
+ view: {
91
+ header: () => '<header>Pulse</header>',
92
+ nav: () => '<nav>Home</nav>',
93
+ feed: (s, server) => `<main>${server.feed.map(p => `<h2>${p.title}</h2>`).join('')}</main>`
94
+ }
95
+ }
96
+
97
+ const metaSpec = {
98
+ route: '/about',
99
+ state: {},
100
+ view: () => '<p>About</p>',
101
+ meta: {
102
+ title: 'About Us',
103
+ description: 'Learn more about us',
104
+ ogTitle: 'About Us — Pulse'
105
+ }
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+
110
+ console.log('\nrenderToString\n')
111
+
112
+ await test('renders simple function view', async () => {
113
+ const { html } = await renderToString(helloSpec)
114
+ assert(html.includes('<h1>Hello world</h1>'), `Got: ${html}`)
115
+ })
116
+
117
+ await test('renders initial client state into view', async () => {
118
+ const { html } = await renderToString(counterSpec)
119
+ assert(html.includes('>5<'), `Expected count 5, got: ${html}`)
120
+ })
121
+
122
+ await test('resolves server data and passes to view', async () => {
123
+ const { html } = await renderToString(serverDataSpec)
124
+ assert(html.includes('<li>Widget</li>'), `Expected Widget, got: ${html}`)
125
+ assert(html.includes('<li>Gadget</li>'), `Expected Gadget, got: ${html}`)
126
+ })
127
+
128
+ await test('passes ctx to server data fetchers', async () => {
129
+ const spec = {
130
+ route: '/user',
131
+ state: {},
132
+ server: {
133
+ greeting: async (ctx) => `Hello ${ctx.user}`
134
+ },
135
+ view: (s, server) => `<p>${server.greeting}</p>`
136
+ }
137
+ const { html } = await renderToString(spec, { user: 'Andy' })
138
+ assert(html.includes('Hello Andy'), `Got: ${html}`)
139
+ })
140
+
141
+ await test('returns serverState from resolved fetchers', async () => {
142
+ const { serverState } = await renderToString(serverDataSpec)
143
+ assert(Array.isArray(serverState.products), 'Expected products array')
144
+ assert(serverState.products.length === 2, `Expected 2 products, got ${serverState.products.length}`)
145
+ })
146
+
147
+ await test('returns timing object', async () => {
148
+ const { timing } = await renderToString(helloSpec)
149
+ assert(typeof timing.data === 'number', 'timing.data should be a number')
150
+ assert(typeof timing.render === 'number', 'timing.render should be a number')
151
+ assert(typeof timing.total === 'number', 'timing.total should be a number')
152
+ assert(timing.total >= 0, 'timing.total should be non-negative')
153
+ })
154
+
155
+ await test('resolves multiple server fetchers in parallel', async () => {
156
+ const spec = {
157
+ route: '/multi',
158
+ state: {},
159
+ server: {
160
+ a: async () => { await new Promise(r => setTimeout(r, 20)); return 'A' },
161
+ b: async () => { await new Promise(r => setTimeout(r, 20)); return 'B' }
162
+ },
163
+ view: (s, server) => `${server.a}${server.b}`
164
+ }
165
+
166
+ const t0 = Date.now()
167
+ const { html } = await renderToString(spec)
168
+ const elapsed = Date.now() - t0
169
+
170
+ assert(html === 'AB', `Got: ${html}`)
171
+ // Parallel: should be ~20ms, not ~40ms
172
+ assert(elapsed < 35, `Expected parallel resolution ~20ms, took ${elapsed}ms`)
173
+ })
174
+
175
+ await test('renders segmented view in order', async () => {
176
+ const spec = {
177
+ route: '/seg',
178
+ state: {},
179
+ view: {
180
+ header: () => '<header/>',
181
+ body: () => '<main/>',
182
+ footer: () => '<footer/>'
183
+ }
184
+ }
185
+ const { html } = await renderToString(spec)
186
+ const headerPos = html.indexOf('<header/>')
187
+ const bodyPos = html.indexOf('<main/>')
188
+ const footerPos = html.indexOf('<footer/>')
189
+ assert(headerPos < bodyPos, 'header should come before body')
190
+ assert(bodyPos < footerPos, 'body should come before footer')
191
+ })
192
+
193
+ await test('throws on invalid spec', async () => {
194
+ let threw = false
195
+ try { await renderToString({ route: '/bad' }) } catch { threw = true }
196
+ assert(threw, 'Expected renderToString to throw on invalid spec')
197
+ })
198
+
199
+ // ---------------------------------------------------------------------------
200
+
201
+ console.log('\nrenderToStream\n')
202
+
203
+ await test('streams shell segments immediately', async () => {
204
+ const html = await streamToString(renderToStream(streamSpec))
205
+ assert(html.includes('<header>Pulse</header>'), `Shell header missing: ${html}`)
206
+ assert(html.includes('<nav>Home</nav>'), `Shell nav missing: ${html}`)
207
+ })
208
+
209
+ await test('streams deferred content after shell', async () => {
210
+ const html = await streamToString(renderToStream(streamSpec))
211
+ assert(html.includes('Post 1'), `Deferred feed missing Post 1: ${html}`)
212
+ assert(html.includes('Post 2'), `Deferred feed missing Post 2: ${html}`)
213
+ })
214
+
215
+ await test('deferred content comes after shell in stream order', async () => {
216
+ const html = await streamToString(renderToStream(streamSpec))
217
+ const shellPos = html.indexOf('<header>')
218
+ const deferredPos = html.indexOf('Post 1')
219
+ assert(shellPos < deferredPos, 'Shell should appear before deferred content')
220
+ })
221
+
222
+ await test('includes placeholder for deferred segment', async () => {
223
+ const html = await streamToString(renderToStream(streamSpec))
224
+ assert(html.includes('pulse-deferred'), `Expected pulse-deferred placeholder: ${html}`)
225
+ })
226
+
227
+ await test('includes replacement script for deferred segment', async () => {
228
+ const html = await streamToString(renderToStream(streamSpec))
229
+ assert(html.includes('replaceWith'), `Expected inline replacement script: ${html}`)
230
+ })
231
+
232
+ await test('streams simple function view', async () => {
233
+ const html = await streamToString(renderToStream(helloSpec))
234
+ assert(html.includes('<h1>Hello world</h1>'), `Got: ${html}`)
235
+ })
236
+
237
+ await test('stream injects __PULSE_SERVER__ when spec has server data', async () => {
238
+ const html = await streamToString(renderToStream(serverDataSpec))
239
+ assert(html.includes('__PULSE_SERVER__'), `Expected server state script: ${html}`)
240
+ assert(html.includes('"Widget"'), `Expected serialised server data: ${html}`)
241
+ })
242
+
243
+ await test('stream does not inject __PULSE_SERVER__ when no server data', async () => {
244
+ const html = await streamToString(renderToStream(helloSpec))
245
+ assert(!html.includes('__PULSE_SERVER__'), `Should not include empty server state: ${html}`)
246
+ })
247
+
248
+ // ---------------------------------------------------------------------------
249
+
250
+ console.log('\nwrapDocument\n')
251
+
252
+ await test('wraps content in full HTML document', async () => {
253
+ const { html } = wrapDocument({ content: '<p>Hello</p>' })
254
+ assert(html.includes('<!DOCTYPE html>'), 'Missing DOCTYPE')
255
+ assert(html.includes('<p>Hello</p>'), 'Missing content')
256
+ assert(html.includes('</html>'), 'Missing closing tag')
257
+ })
258
+
259
+ await test('sets title from spec.meta', async () => {
260
+ const { html } = wrapDocument({ content: '', spec: metaSpec })
261
+ assert(html.includes('<title>About Us</title>'), `Got: ${html}`)
262
+ })
263
+
264
+ await test('sets description meta tag', async () => {
265
+ const { html } = wrapDocument({ content: '', spec: metaSpec })
266
+ assert(html.includes('name="description"'), `Got: ${html}`)
267
+ assert(html.includes('Learn more about us'), `Got: ${html}`)
268
+ })
269
+
270
+ await test('sets og:title meta tag', async () => {
271
+ const { html } = wrapDocument({ content: '', spec: metaSpec })
272
+ assert(html.includes('og:title'), `Got: ${html}`)
273
+ })
274
+
275
+ await test('serialises server state into page', async () => {
276
+ const { html } = wrapDocument({
277
+ content: '',
278
+ serverState: { products: [{ id: 1 }] }
279
+ })
280
+ assert(html.includes('__PULSE_SERVER__'), `Expected server state script: ${html}`)
281
+ assert(html.includes('"id":1'), `Expected serialised data: ${html}`)
282
+ })
283
+
284
+ await test('omits server state script when empty', async () => {
285
+ const { html } = wrapDocument({ content: '' })
286
+ assert(!html.includes('__PULSE_SERVER__'), 'Should not include empty server state script')
287
+ })
288
+
289
+ await test('returns serverTimingValue when timing provided', async () => {
290
+ const { serverTimingValue } = wrapDocument({
291
+ content: '',
292
+ timing: { data: 5.2, render: 1.1, total: 6.3 }
293
+ })
294
+ assert(serverTimingValue !== null, 'Expected timing value')
295
+ assert(serverTimingValue.includes('total'), `Got: ${serverTimingValue}`)
296
+ })
297
+
298
+ await test('escapes special characters in title', async () => {
299
+ const { html } = wrapDocument({
300
+ content: '',
301
+ spec: { meta: { title: '<script>bad</script>' } }
302
+ })
303
+ assert(!html.includes('<script>bad'), `Title should be escaped: ${html}`)
304
+ assert(html.includes('&lt;script&gt;'), `Expected escaped title: ${html}`)
305
+ })
306
+
307
+ await test('includes hydration script when spec.hydrate is set', async () => {
308
+ const { html } = wrapDocument({
309
+ content: '',
310
+ spec: { hydrate: '/examples/counter.js' }
311
+ })
312
+ assert(html.includes('type="module"'), `Expected module script: ${html}`)
313
+ assert(html.includes('/examples/counter.js'), `Expected spec path: ${html}`)
314
+ assert(html.includes('/@pulse/runtime/index.js'), `Expected runtime path: ${html}`)
315
+ assert(html.includes('mount('), `Expected mount() call: ${html}`)
316
+ })
317
+
318
+ await test('uses external script tag for bundle hydrate paths', async () => {
319
+ const { html } = wrapDocument({
320
+ content: '',
321
+ spec: { hydrate: '/dist/counter.boot-ABC123.js' }
322
+ })
323
+ assert(html.includes('type="module" src="/dist/counter.boot-ABC123.js"'), `Expected external script: ${html}`)
324
+ assert(!html.includes('import spec from'), `Should not include inline import: ${html}`)
325
+ })
326
+
327
+ await test('omits hydration script when spec.hydrate is not set', async () => {
328
+ const { html } = wrapDocument({ content: '' })
329
+ assert(!html.includes("import spec from"), `Should not include import: ${html}`)
330
+ })
331
+
332
+ // ---------------------------------------------------------------------------
333
+
334
+ console.log('\nError boundaries (SSR)\n')
335
+
336
+ await test('renderToString: rethrows when view throws and no onViewError — server handles it', async () => {
337
+ const spec = {
338
+ route: '/err',
339
+ state: {},
340
+ view: () => { throw new Error('ssr boom') }
341
+ }
342
+ let threw = false
343
+ try { await renderToString(spec) } catch (e) {
344
+ threw = true
345
+ assert(e.message === 'ssr boom', `Expected original error, got: ${e.message}`)
346
+ }
347
+ assert(threw, 'Expected renderToString to rethrow when no onViewError is defined')
348
+ })
349
+
350
+ await test('renderToString: calls spec.onViewError and returns fallback when view throws', async () => {
351
+ const spec = {
352
+ route: '/err',
353
+ state: { val: 99 },
354
+ view: () => { throw new Error('ssr oops') },
355
+ onViewError: (err, state) => `<p data-testid="fallback">${err.message}:${state.val}</p>`
356
+ }
357
+ const { html } = await renderToString(spec)
358
+ assert(html.includes('data-testid="fallback"'), `Expected custom fallback, got: ${html}`)
359
+ assert(html.includes('ssr oops:99'), `Expected err + state in fallback, got: ${html}`)
360
+ })
361
+
362
+ // ---------------------------------------------------------------------------
363
+
364
+ console.log('\nRequest timeouts\n')
365
+
366
+ await test('withTimeout: resolves normally when promise completes in time', async () => {
367
+ const result = await withTimeout(Promise.resolve('ok'), 100, 'test')
368
+ assert(result === 'ok', `Expected "ok", got "${result}"`)
369
+ })
370
+
371
+ await test('withTimeout: rejects with timeout error when promise is too slow', async () => {
372
+ const slow = new Promise(r => setTimeout(r, 200))
373
+ let err
374
+ try { await withTimeout(slow, 30, 'myFetcher') } catch (e) { err = e }
375
+ assert(err?.message?.includes('myFetcher'), `Expected fetcher name in error: ${err?.message}`)
376
+ assert(err?.message?.includes('30ms'), `Expected timeout duration in error: ${err?.message}`)
377
+ })
378
+
379
+ await test('withTimeout: no-op when ms is null', async () => {
380
+ const result = await withTimeout(Promise.resolve(42), null, 'test')
381
+ assert(result === 42, `Expected 42, got ${result}`)
382
+ })
383
+
384
+ await test('withTimeout: no-op when ms is 0', async () => {
385
+ const result = await withTimeout(Promise.resolve(42), 0, 'test')
386
+ assert(result === 42, `Expected 42, got ${result}`)
387
+ })
388
+
389
+ await test('renderToString: ctx.fetcherTimeout is applied to server fetchers', async () => {
390
+ const spec = {
391
+ route: '/slow',
392
+ state: {},
393
+ server: {
394
+ data: () => new Promise(r => setTimeout(() => r('done'), 200))
395
+ },
396
+ view: (state, server) => `<p>${server.data}</p>`
397
+ }
398
+ let err
399
+ try {
400
+ await renderToString(spec, { fetcherTimeout: 30 })
401
+ } catch (e) { err = e }
402
+ assert(err?.message?.includes('timed out'), `Expected timeout error, got: ${err?.message}`)
403
+ })
404
+
405
+ await test('renderToString: resolves normally when fetcher completes within timeout', async () => {
406
+ const spec = {
407
+ route: '/fast',
408
+ state: {},
409
+ server: {
410
+ data: () => Promise.resolve('quick')
411
+ },
412
+ view: (state, server) => `<p>${server.data}</p>`
413
+ }
414
+ const { html } = await renderToString(spec, { fetcherTimeout: 500 })
415
+ assert(html.includes('quick'), `Expected rendered data, got: ${html}`)
416
+ })
417
+
418
+ // ---------------------------------------------------------------------------
419
+
420
+ console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`)
421
+ if (failed > 0) process.exit(1)
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Pulse — Client Store Singleton
3
+ *
4
+ * A module-level singleton that holds global store state on the client.
5
+ * Pages that declare spec.store subscribe to this and re-render when it changes.
6
+ *
7
+ * Initialised once from window.__PULSE_STORE__ (serialised by the server).
8
+ * Subsequent calls to initClientStore are no-ops — the store persists across
9
+ * client-side navigations so store actions on one page are visible on the next.
10
+ *
11
+ * Browser-only — never imported by the SSR path.
12
+ */
13
+
14
+ let _state = {}
15
+ let _initialized = false
16
+ let _mutations = {}
17
+ let _mutationsRegistered = false
18
+ const _subs = new Set()
19
+
20
+ /**
21
+ * Seed the store from SSR data.
22
+ * No-op after the first call — navigations do not reset the store.
23
+ *
24
+ * @param {Object} initialState
25
+ */
26
+ export function initClientStore(initialState) {
27
+ if (_initialized) return
28
+ _state = { ...(initialState || {}) }
29
+ _initialized = true
30
+ }
31
+
32
+ /**
33
+ * Return the current store state.
34
+ * @returns {Object}
35
+ */
36
+ export function getStoreState() {
37
+ return _state
38
+ }
39
+
40
+ /**
41
+ * Subscribe to store changes.
42
+ * The callback receives the new store state on every update.
43
+ * Returns an unsubscribe function — call it in destroy() to prevent leaks.
44
+ *
45
+ * @param {function} fn
46
+ * @returns {function} unsubscribe
47
+ */
48
+ export function subscribe(fn) {
49
+ _subs.add(fn)
50
+ return () => _subs.delete(fn)
51
+ }
52
+
53
+ /**
54
+ * Merge a partial object into the store and notify all subscribers.
55
+ * Called by the runtime when a page action returns { _storeUpdate: {...} }.
56
+ *
57
+ * @param {Object} partial
58
+ */
59
+ export function updateStore(partial) {
60
+ _state = { ..._state, ...partial }
61
+ for (const fn of _subs) fn(_state)
62
+ }
63
+
64
+ /**
65
+ * Register store mutations from the store definition.
66
+ * No-op after the first call — navigations do not re-register mutations.
67
+ *
68
+ * @param {Object} mutations
69
+ */
70
+ export function registerStoreMutations(mutations) {
71
+ if (_mutationsRegistered) return
72
+ _mutations = mutations || {}
73
+ _mutationsRegistered = true
74
+ }
75
+
76
+ /**
77
+ * Dispatch a store mutation by name.
78
+ * The mutation receives the current store state and an optional payload,
79
+ * and returns a partial store object merged via updateStore.
80
+ * All subscribed pages re-render automatically.
81
+ *
82
+ * @param {string} name
83
+ * @param {*} [payload]
84
+ */
85
+ export function dispatchStoreMutation(name, payload) {
86
+ const fn = _mutations[name]
87
+ if (!fn) {
88
+ console.warn(`[Pulse] No store mutation found for "${name}"`)
89
+ return
90
+ }
91
+ updateStore(fn(_state, payload))
92
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Pulse — Toast / notification system
3
+ *
4
+ * Triggered by returning `_toast` from any action lifecycle hook or mutation.
5
+ * The container is injected into document.body once and survives page navigations.
6
+ *
7
+ * Usage:
8
+ * onSuccess: (state, result) => ({
9
+ * saved: true,
10
+ * _toast: { message: 'Saved!', variant: 'success' }
11
+ * })
12
+ *
13
+ * _toast options:
14
+ * message {string} — required
15
+ * variant {'success'|'error'|'warning'|'info'} — default 'info'
16
+ * duration {number} — ms before auto-dismiss, default 4000. 0 = no auto-dismiss.
17
+ */
18
+
19
+ const CONTAINER_ID = 'pulse-toasts'
20
+ const MAX_TOASTS = 5
21
+ const VARIANTS = new Set(['success', 'error', 'warning', 'info'])
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // CSS — injected once, overridable via .pulse-toast classes in app CSS
25
+ // ---------------------------------------------------------------------------
26
+
27
+ const TOAST_CSS = `
28
+ #pulse-toasts {
29
+ position: fixed;
30
+ top: 1rem;
31
+ right: 1rem;
32
+ z-index: 9999;
33
+ display: flex;
34
+ flex-direction: column;
35
+ gap: .5rem;
36
+ pointer-events: none;
37
+ max-width: min(24rem, calc(100vw - 2rem));
38
+ }
39
+ .pulse-toast {
40
+ display: flex;
41
+ align-items: flex-start;
42
+ gap: .75rem;
43
+ padding: .75rem 1rem;
44
+ border-radius: .5rem;
45
+ box-shadow: 0 4px 16px rgba(0,0,0,.2);
46
+ font-size: .875rem;
47
+ line-height: 1.4;
48
+ pointer-events: all;
49
+ opacity: 0;
50
+ transform: translateX(calc(100% + 1.5rem));
51
+ transition: opacity .2s ease, transform .2s ease;
52
+ background: #1e293b;
53
+ color: #f8fafc;
54
+ }
55
+ .pulse-toast--visible {
56
+ opacity: 1;
57
+ transform: translateX(0);
58
+ }
59
+ .pulse-toast--success { background: #166534; }
60
+ .pulse-toast--error { background: #991b1b; }
61
+ .pulse-toast--warning { background: #92400e; }
62
+ .pulse-toast-message { flex: 1; }
63
+ .pulse-toast-close {
64
+ background: none;
65
+ border: none;
66
+ color: inherit;
67
+ cursor: pointer;
68
+ padding: 0 0 0 .25rem;
69
+ font-size: 1.125rem;
70
+ line-height: 1;
71
+ opacity: .7;
72
+ flex-shrink: 0;
73
+ }
74
+ .pulse-toast-close:hover { opacity: 1; }
75
+ `
76
+
77
+ let _stylesInjected = false
78
+
79
+ function injectStyles() {
80
+ if (_stylesInjected) return
81
+ _stylesInjected = true
82
+ const style = document.createElement('style')
83
+ style.textContent = TOAST_CSS
84
+ document.head.appendChild(style)
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Container
89
+ // ---------------------------------------------------------------------------
90
+
91
+ function getContainer() {
92
+ let container = document.getElementById(CONTAINER_ID)
93
+ if (!container) {
94
+ injectStyles()
95
+ container = document.createElement('div')
96
+ container.id = CONTAINER_ID
97
+ container.setAttribute('aria-live', 'polite')
98
+ container.setAttribute('aria-atomic', 'false')
99
+ document.body.appendChild(container)
100
+ }
101
+ return container
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Public API
106
+ // ---------------------------------------------------------------------------
107
+
108
+ /**
109
+ * Show a toast notification.
110
+ *
111
+ * @param {Object|string} options
112
+ * @param {string} options.message
113
+ * @param {'success'|'error'|'warning'|'info'} [options.variant='info']
114
+ * @param {number} [options.duration=4000] ms before auto-dismiss; 0 = sticky
115
+ */
116
+ export function showToast(options) {
117
+ if (typeof document === 'undefined') return // no-op in SSR/test environments
118
+
119
+ const { message, variant = 'info', duration = 4000 } =
120
+ typeof options === 'string' ? { message: options } : options
121
+
122
+ if (!message) return
123
+
124
+ const safeVariant = VARIANTS.has(variant) ? variant : 'info'
125
+ const container = getContainer()
126
+
127
+ // Evict oldest toast if at the limit
128
+ while (container.children.length >= MAX_TOASTS) {
129
+ container.firstElementChild?.remove()
130
+ }
131
+
132
+ // Build toast element
133
+ const toast = document.createElement('div')
134
+ toast.className = `pulse-toast pulse-toast--${safeVariant}`
135
+ toast.setAttribute('role', 'status')
136
+
137
+ const msg = document.createElement('span')
138
+ msg.className = 'pulse-toast-message'
139
+ msg.textContent = message // safe — textContent, no innerHTML
140
+
141
+ const close = document.createElement('button')
142
+ close.className = 'pulse-toast-close'
143
+ close.setAttribute('aria-label', 'Dismiss notification')
144
+ close.textContent = '×'
145
+ close.addEventListener('click', () => dismiss(toast))
146
+
147
+ toast.appendChild(msg)
148
+ toast.appendChild(close)
149
+ container.appendChild(toast)
150
+
151
+ // Animate in on next frame
152
+ requestAnimationFrame(() => toast.classList.add('pulse-toast--visible'))
153
+
154
+ // Auto-dismiss
155
+ if (duration > 0) {
156
+ setTimeout(() => dismiss(toast), duration)
157
+ }
158
+ }
159
+
160
+ function dismiss(toast) {
161
+ toast.classList.remove('pulse-toast--visible')
162
+ toast.addEventListener('transitionend', () => toast.remove(), { once: true })
163
+ }