@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
package/CLAUDE.md ADDED
@@ -0,0 +1,383 @@
1
+ # Pulse — AI Agent Guide
2
+
3
+ Pulse is a spec-first frontend framework. The spec is the source of truth. No codegen, no virtual DOM, no dependencies (esbuild is dev-only).
4
+
5
+ ## Commands
6
+
7
+ ```bash
8
+ npm run dev # start dev server at http://localhost:3001
9
+ npm test # run all 92 unit tests
10
+ npm run build # bundle for production → public/dist/
11
+ ```
12
+
13
+ ## Project Structure
14
+
15
+ ```
16
+ src/
17
+ spec/schema.js # authoritative spec definition + validation
18
+ runtime/index.js # client mount() + dispatch + constraints
19
+ runtime/ssr.js # server-side rendering (string + stream)
20
+ runtime/navigate.js # client-side navigation + history
21
+ server/index.js # HTTP server (Node built-in, zero deps)
22
+ examples/
23
+ counter.js # simple client-only spec
24
+ contact.js # spec with server data + async action
25
+ dev.server.js # combined dev server for both specs
26
+ scripts/
27
+ build.js # esbuild bundler — generates public/dist/
28
+ public/
29
+ pulse.css # base stylesheet (dark theme)
30
+ dist/ # generated — do not edit
31
+ runtime-[hash].js # shared runtime chunk (mount + navigate)
32
+ [name].boot-[hash].js # per-page spec bundle
33
+ manifest.json # source hydrate paths → bundle paths
34
+ ```
35
+
36
+ ## The Spec
37
+
38
+ A spec is a plain JS object. Every property is optional except `route`, `state`, and `view`.
39
+
40
+ ```js
41
+ export const mySpec = {
42
+ route: '/path', // URL pattern, supports :params
43
+ hydrate: '/path/to/spec.js',// browser-importable path → enables hydration
44
+
45
+ meta: {
46
+ title: 'Page Title',
47
+ description: 'Meta description',
48
+ styles: ['/pulse.css'],
49
+ ogTitle: '...',
50
+ ogImage: '...',
51
+ },
52
+
53
+ // Server data — resolved before render, passed to view as second arg
54
+ server: {
55
+ data: async (ctx) => ({ ... }) // ctx has params, query, headers, cookies
56
+ },
57
+
58
+ // Timeout for all server fetchers on this page (ms). Overrides createServer fetcherTimeout.
59
+ serverTimeout: 5000,
60
+
61
+ // Global store keys this page subscribes to — appears in view's server arg
62
+ // Store mutations update all subscribed pages without a server round-trip
63
+ store: ['user', 'cart'],
64
+
65
+ // Initial client state — deep cloned on mount, never mutated directly
66
+ state: { count: 0 },
67
+
68
+ // Declarative min/max bounds — always enforced after mutations
69
+ constraints: {
70
+ count: { min: 0, max: 10 }
71
+ },
72
+
73
+ // Validation rules — checked when action.validate === true
74
+ validation: {
75
+ 'fields.email': { required: true, format: 'email' },
76
+ 'fields.name': { required: true, minLength: 2, maxLength: 100 }
77
+ // formats: email | url | numeric
78
+ // rules: required | minLength | maxLength | min | max | pattern
79
+ },
80
+
81
+ // Pure function(s) — return HTML string, no side effects
82
+ // Can be a single function or a keyed object of segment functions
83
+ view: (state, server) => `<main>...</main>`,
84
+
85
+ // Optional — called client-side when view() throws. Return an HTML string.
86
+ // Without this, the runtime shows an inline error message and logs to console.
87
+ // On the server, a throwing view always propagates to the server error handler
88
+ // unless onViewError is defined, in which case it returns the fallback HTML with 200.
89
+ onViewError: (err, state, serverState) => `<p>Something went wrong</p>`,
90
+
91
+ // Synchronous state changes — return partial state to merge
92
+ mutations: {
93
+ increment: (state, event) => ({ count: state.count + 1 }),
94
+ },
95
+
96
+ // Async operations — lifecycle: onStart → validate → run → onSuccess/onError
97
+ actions: {
98
+ submit: {
99
+ onStart: (state, formData) => ({ status: 'loading', ... }),
100
+ validate: true, // runs validation before run()
101
+ run: async (state, serverState, formData) => { /* fetch, etc */ },
102
+ onSuccess: (state, payload) => ({
103
+ status: 'success',
104
+ _toast: { message: 'Saved!', variant: 'success' }, // show a toast
105
+ }),
106
+ onError: (state, err) => ({
107
+ status: 'error',
108
+ errors: err?.validation ?? [{ message: err.message }],
109
+ _toast: { message: 'Something went wrong', variant: 'error' },
110
+ }),
111
+ }
112
+ },
113
+
114
+ // Streaming SSR — split view into shell (instant) + deferred segments
115
+ stream: {
116
+ shell: ['header', 'nav'],
117
+ deferred: ['feed']
118
+ }
119
+ }
120
+
121
+ export default mySpec // required for hydration imports
122
+ ```
123
+
124
+ ## HTML Event Binding
125
+
126
+ Pulse binds to DOM attributes — no JSX, no templates.
127
+
128
+ ```html
129
+ <button data-event="increment">+</button> <!-- click → mutation -->
130
+ <input data-event="change:setName"> <!-- change → mutation -->
131
+ <input data-event="input:setQuery"> <!-- input → mutation -->
132
+ <input data-event="input:search" data-debounce="300"> <!-- debounced input (300ms) -->
133
+ <input data-event="input:filter" data-throttle="100"> <!-- throttled input (100ms) -->
134
+ <form data-action="submit">...</form> <!-- submit → action, passes FormData -->
135
+ <button data-store-event="toggleTheme">Toggle</button> <!-- click → store mutation -->
136
+ <select data-store-event="change:setLang">...</select> <!-- change → store mutation -->
137
+ <button data-dialog-open="my-modal">Open modal</button> <!-- opens <dialog id="my-modal"> -->
138
+ <button data-dialog-close>Cancel</button> <!-- closes nearest ancestor <dialog> -->
139
+ ```
140
+
141
+ **Modal pattern — never use `state.modalOpen`.** Always render the `<dialog>` in the DOM unconditionally and use `data-dialog-open` to show it. ESC key, backdrop click, and `<form method="dialog">` all close it natively with no spec state.
142
+
143
+ **Important:** Do not use `data-event` on text inputs to mirror their value into state. The `innerHTML` replacement on every keystroke destroys focus. Instead, use uncontrolled inputs and capture `FormData` in `action.onStart` before `validate` runs.
144
+
145
+ ## Dev vs Production Hydration
146
+
147
+ **Dev mode** — the HTML includes an inline bootstrap script importing source files directly:
148
+ ```html
149
+ <script type="module">
150
+ import spec from '/examples/counter.js'
151
+ import { mount } from '/src/runtime/index.js'
152
+ import { initNavigation } from '/src/runtime/navigate.js'
153
+ mount(spec, root, window.__PULSE_SERVER__ || {}, { ssr: true })
154
+ initNavigation(root, mount)
155
+ </script>
156
+ ```
157
+
158
+ **Production** — after `npm run build`, `spec.hydrate` is resolved via `manifest.json` to a content-hashed bundle. The HTML becomes a single external tag:
159
+ ```html
160
+ <script type="module" src="/dist/counter.boot-HASH.js"></script>
161
+ ```
162
+
163
+ The bundle is self-executing (imports spec, calls `mount` + `initNavigation` internally). The `{ ssr: true }` option tells `mount` to skip the initial re-render and only bind events — this preserves the SSR-painted LCP element.
164
+
165
+ ## Build Output
166
+
167
+ `npm run build` generates three things per app:
168
+
169
+ - `public/dist/runtime-[hash].js` — shared runtime (mount + navigate + schema, ~2.1 kB brotli)
170
+ - `public/dist/[name].boot-[hash].js` — per-page spec bundle (~0.5–0.9 kB brotli)
171
+ - `public/dist/manifest.json` — maps `/examples/foo.js` → `/dist/foo.boot-HASH.js`
172
+
173
+ To add a new page to the build, add its spec path to the `ENTRIES` array in `scripts/build.js`.
174
+
175
+ The server auto-detects the manifest from `staticDir/dist/manifest.json` when `staticDir` is set. No config needed.
176
+
177
+ ## Server
178
+
179
+ ```js
180
+ import { createServer } from './src/server/index.js'
181
+
182
+ createServer([specA, specB], {
183
+ port: 3000,
184
+ stream: true, // streaming SSR (default)
185
+ staticDir: 'public', // serve static files + auto-load manifest
186
+ manifest: null, // explicit manifest path or object (overrides auto-detect)
187
+ defaultCache: 3600, // default HTML cache TTL in seconds for all pages (prod only)
188
+ // also accepts true (3600s + swr 86400s) or { public, maxAge, staleWhileRevalidate }
189
+ // spec.cache overrides per-page; in-process + Cache-Control headers both set
190
+ fetcherTimeout: 5000, // ms before any server fetcher times out (null = no limit)
191
+ // spec.serverTimeout overrides per-page
192
+ shutdownTimeout: 30000, // ms to wait for in-flight requests before force-exit on SIGTERM/SIGINT
193
+ healthCheck: '/healthz', // built-in health endpoint path, or false to disable
194
+ csp: { // extra sources merged into the framework's default CSP
195
+ 'style-src': ['https://fonts.googleapis.com'],
196
+ 'font-src': ['https://fonts.gstatic.com'],
197
+ },
198
+ resolveBrand: async (host) => db.brands.findBySlug(host.split('.')[0]),
199
+ // multi-brand: result cached 60s, attached to ctx.brand
200
+ onRequest: (req, res) => { /* return false to short-circuit */ },
201
+ onError: (err, req, res) => { /* custom error handling */ }
202
+ })
203
+ ```
204
+
205
+ All specs are validated at startup — bad specs throw before the server accepts connections.
206
+
207
+ ## Multi-brand Sites
208
+
209
+ Pass `resolveBrand: async (host) => brandConfig` to `createServer`. The result is cached per host for 60 seconds and attached to `ctx.brand`. It is available in `guard`, `server` fetchers, and any `meta` field that is a function.
210
+
211
+ Any `meta` field can be a function `(ctx) => value` — called per request, not at startup:
212
+
213
+ ```js
214
+ export default {
215
+ meta: {
216
+ title: (ctx) => `${ctx.brand.name} — Home`,
217
+ styles: (ctx) => ['/pulse-ui.css', `/themes/${ctx.brand.slug}.css`],
218
+ },
219
+ server: {
220
+ brand: (ctx) => ctx.brand, // expose to view as serverState.brand
221
+ },
222
+ view: (state, { brand }) => `<h1>${brand.name}</h1>`,
223
+ guard: async (ctx) => {
224
+ if (!ctx.brand) return { redirect: '/not-found' }
225
+ },
226
+ }
227
+ ```
228
+
229
+ Keep brand differences in CSS custom properties — one `/pulse-ui.css` for components, one small `/themes/slug.css` per brand that overrides `:root` variables only.
230
+
231
+ ## Custom Fonts
232
+
233
+ `pulse-ui.css` exposes `--ui-font: var(--font, system-ui, ...)` and `--ui-mono: var(--mono, ...)`. All components use these tokens. To set a custom font, override `--font` in `:root` — it cascades into every component automatically.
234
+
235
+ ```css
236
+ /* app.css */
237
+ :root {
238
+ --font: 'Inter', system-ui, sans-serif;
239
+ }
240
+ ```
241
+
242
+ Load the font via `meta.styles` before `pulse-ui.css`, or self-host in `staticDir/fonts/` with `@font-face`:
243
+
244
+ ```js
245
+ meta: {
246
+ styles: [
247
+ 'https://fonts.googleapis.com/css2?family=Inter&display=swap',
248
+ '/pulse-ui.css',
249
+ '/app.css',
250
+ ]
251
+ }
252
+ ```
253
+
254
+ For multi-brand setups, each brand theme file just overrides `--font` in `:root`.
255
+
256
+ ## HTTP Response Behaviour
257
+
258
+ - **Full page request** — SSR HTML with hydration bootstrap
259
+ - **`X-Pulse-Navigate: true`** — returns JSON `{ html, title, hydrate, serverState }` for client-side navigation
260
+ - **Security headers** — on every response: `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy`, `Cross-Origin-Opener-Policy`, `Cross-Origin-Resource-Policy`
261
+ - **Compression** — brotli preferred, gzip fallback for all compressible types
262
+ - **Cache** — `/dist/*` bundles: `immutable, max-age=31536000`; static assets: `max-age=3600`; HTML: `no-store`
263
+
264
+ ## Client Navigation
265
+
266
+ `initNavigation(root, mount)` intercepts same-origin `<a>` clicks:
267
+ 1. Fetches the new page with `X-Pulse-Navigate: true`
268
+ 2. Swaps `root.innerHTML` + updates `document.title`
269
+ 3. Dynamically imports the new spec bundle (`import(hydrate)`)
270
+ 4. Calls `mount(spec, root, serverState)` to re-attach interactivity
271
+
272
+ Falls back to `location.href` on any error.
273
+
274
+ ## Testing
275
+
276
+ Tests use Node.js built-in `node:test` and `node:assert`. Each test file is run directly:
277
+
278
+ ```bash
279
+ node src/spec/schema.test.js
280
+ node src/runtime/runtime.test.js
281
+ node src/runtime/ssr.test.js
282
+ node src/server/server.test.js
283
+ ```
284
+
285
+ Server tests use an incrementing port counter (`withServer` helper) to avoid TCP TIME_WAIT conflicts between tests. Each test gets its own port.
286
+
287
+ ### View testing — `@invisibleloop/pulse/testing`
288
+
289
+ Use `renderSync` and `render` to test view HTML output. Both return a `RenderResult` with CSS-like query helpers — no DOM, no jsdom, pure Node.js.
290
+
291
+ ```js
292
+ import { renderSync, render } from '@invisibleloop/pulse/testing'
293
+
294
+ // Sync — calls view directly, pass mock state/server
295
+ const result = renderSync(mySpec, { state: { count: 5 }, server: { items: [] } })
296
+ result.has('button') // → true/false
297
+ result.get('#count').text // → '5' (throws if not found)
298
+ result.find('.title')?.text // → string | null
299
+ result.findAll('li') // → Element[]
300
+ result.count('li') // → number
301
+ result.attr('input[name="email"]', 'value') // → string | null
302
+ result.text() // → all text, tags stripped
303
+
304
+ // Async — runs real spec.server fetchers (pass server to mock them)
305
+ const result = await render(mySpec, { server: { product: mockProduct } })
306
+ const result = await render(mySpec, { ctx: { params: { id: '1' } } }) // real fetchers
307
+ ```
308
+
309
+ **Supported selectors:** `button`, `.class`, `#id`, `[attr]`, `[attr="value"]`, and combinations: `button.primary[type="submit"]`.
310
+ `Element` also has `.find()`, `.findAll()`, `.has()`, `.attr()`, `.text`, `.tag`, `.attrs`.
311
+
312
+ ## Key Decisions (Do Not Reverse)
313
+
314
+ | Decision | Reason |
315
+ |---|---|
316
+ | `{ ssr: true }` skips initial render in `mount()` | Preserves SSR-painted LCP — re-rendering would cause a flash and push LCP to ~500ms |
317
+ | `onStart` captures `FormData` before `validate` runs | Uncontrolled inputs — `innerHTML` replacement on keystroke destroys focus |
318
+ | Self-executing bundles with `export default spec` | Single HTTP request for JS; spec exported so client navigation can re-mount |
319
+ | `splitting: true` in esbuild | Shared runtime extracted once, cached across all page navigations |
320
+ | `window.__PULSE_SERVER__` injected by `renderToStream` | Streaming path must serialize server state into HTML — client hydration reads it without a second request |
321
+ | Bundle path detection via `/dist/` prefix | `wrapDocument` and streaming path emit `<script src>` for bundles, inline bootstrap for dev source files |
322
+ | Security headers on every response, including 404/405 | Defense in depth — error responses are also entry points |
323
+
324
+ ## Build Workflow
325
+
326
+ Every build task follows this sequence. Each phase has a pass gate — do not advance until it clears.
327
+
328
+ **Before calling any slow tool (`pulse_build`, `lighthouse_audit`), output a status message to the user first** — e.g. "Building for production — ~30 s…" or "Running Lighthouse desktop audit…". Never call a slow tool silently.
329
+
330
+ | Phase | Action | Gate |
331
+ |---|---|---|
332
+ | 1. Understand | Read guides, call `pulse_list_structure` | — |
333
+ | 2. Plan | Present plan to user, wait for confirmation | User confirms |
334
+ | 3. Build | Write spec + related files | — |
335
+ | 4. Validate | `pulse_validate` — fix all errors + warnings | Clean output |
336
+ | 5. Browser | Screenshot + Lighthouse desktop + mobile | 100/100/100 (Accessibility, Best Practices, SEO) both strategies |
337
+ | 6. Tests | Write tests, run them, fix failures | All pass |
338
+ | 7. Review Agent | Invoke review — **only after phases 4–6 all pass** | — |
339
+ | 8. Fix | Fix every review issue, re-run affected gates | All gates still pass |
340
+
341
+ **Skip phase 2 confirmation only for trivially small, unambiguous tasks.** When in doubt, confirm.
342
+
343
+ **The Review Agent is always last.** Never invoke it before validate, Lighthouse, and tests all pass.
344
+
345
+ The `/verify` command runs the browser check loop (phases 4–5) automatically. Use it.
346
+
347
+ ## Pulse vs React — Do Not Do These
348
+
349
+ Pulse views are plain JS template literals, not JSX. These React patterns are **wrong in Pulse**:
350
+
351
+ | Wrong (React) | Correct (Pulse) |
352
+ |---|---|
353
+ | `className="foo"` | `class="foo"` |
354
+ | `htmlFor="id"` | `for="id"` |
355
+ | `onClick={handler}` | `data-event="click:mutationName"` |
356
+ | `onChange={handler}` | `data-event="change:mutationName"` |
357
+ | `<input value={state.x}>` | Uncontrolled — read via `FormData` in `onStart` |
358
+ | `{expression}` in JSX | `${expression}` in template literal |
359
+ | `<>...</>` fragments | No fragments — use a real wrapper element |
360
+ | `import React from 'react'` | No import needed — plain JS |
361
+ | `useState`, `useEffect`, `useRef` | No hooks — use `state`, `mutations`, `actions` |
362
+ | `key={item.id}` on list items | No `key` props — no virtual DOM |
363
+ | Capitalized `<MyComponent />` | Call as a plain function: `${myComponent(props)}` |
364
+ | `setState(prev => ...)` | Mutations return partial state: `(state) => ({ x: state.x + 1 })` |
365
+
366
+ Also: mutations must be pure (no fetch, no DOM access). Only `actions` can be async.
367
+
368
+ ## Check Components Before Building
369
+
370
+ Before writing any UI HTML by hand, check `src/ui/index.js` — there are 50+ components available. Use them. Do not reinvent `button`, `card`, `alert`, `modal`, `spinner`, `badge`, `input`, etc.
371
+
372
+ @src/agent/checklist.md
373
+
374
+ ## Performance Baseline
375
+
376
+ | Page | CLS | JS (brotli) |
377
+ |---|---|---|
378
+ | /counter | 0.00 | 3.5 kB (first visit) / 0.35 kB (cached runtime) |
379
+ | /contact | 0.00 | 3.6 kB (first visit) / 0.47 kB (cached runtime) |
380
+
381
+ Lighthouse: 100/100/100 (Accessibility / Best Practices / SEO) on both pages.
382
+
383
+ LCP is fast by design (streaming SSR sends HTML before data resolves) but actual millisecond values depend on machine speed, browser, network conditions, and server location — do not quote specific numbers.
package/README.md ADDED
@@ -0,0 +1,95 @@
1
+ # Pulse
2
+
3
+ **A spec-first, AI-native web framework.** Early access — v0.1.
4
+
5
+ Write a plain JavaScript object that describes what a page does. Pulse handles routing, SSR, hydration, client-side navigation, compression, security headers, and caching automatically.
6
+
7
+ ```js
8
+ export default {
9
+ route: '/counter',
10
+ hydrate: '/src/pages/counter.js',
11
+ meta: {
12
+ title: 'Counter',
13
+ styles: ['/app.css'],
14
+ },
15
+
16
+ state: { count: 0 },
17
+
18
+ constraints: {
19
+ count: { min: 0, max: 10 },
20
+ },
21
+
22
+ view: (state) => `
23
+ <main id="main-content">
24
+ <p>${state.count}</p>
25
+ <button data-event="decrement">−</button>
26
+ <button data-event="increment">+</button>
27
+ </main>
28
+ `,
29
+
30
+ mutations: {
31
+ increment: (state) => ({ count: state.count + 1 }),
32
+ decrement: (state) => ({ count: state.count - 1 }),
33
+ },
34
+ }
35
+ ```
36
+
37
+ ## Requirements
38
+
39
+ Node.js 22 or later.
40
+
41
+ ## Getting started
42
+
43
+ ```bash
44
+ npm install -g @invisibleloop/pulse
45
+ mkdir my-project && cd my-project
46
+ pulse
47
+ ```
48
+
49
+ Running `pulse` in an empty directory scaffolds a new project and starts an AI coding session. Your agent has MCP tools and slash commands available from the first prompt.
50
+
51
+ Full documentation: **[pulseframework.dev](https://pulseframework.dev)**
52
+
53
+ ## Key ideas
54
+
55
+ **The spec is the source of truth.** Every page is a single JS object — data fetching, state, mutations, async actions, and view all in one place. No separate files for routes, controllers, or templates.
56
+
57
+ **Performance is built in.** Streaming SSR, immutable asset caching, and zero layout shift are automatic. Every scaffolded project targets Lighthouse 100 across all four categories.
58
+
59
+ **No client-side dependencies.** Pulse ships no runtime framework to the browser — only a small hydration bundle (~2 kB brotli) that binds your spec's mutations and actions to the DOM.
60
+
61
+ **AI-native.** The CLI starts an MCP server alongside the dev server, giving the coding agent tools to create pages, validate specs, and run Lighthouse audits — all without leaving the editor.
62
+
63
+ ## CLI
64
+
65
+ ```bash
66
+ pulse # scaffold a new project or start AI session
67
+ pulse dev # dev server (default port 3000)
68
+ pulse build # production build → public/dist/
69
+ pulse start # production server
70
+ ```
71
+
72
+ ## Docs
73
+
74
+ Full reference at [pulseframework.dev](https://pulseframework.dev):
75
+
76
+ - **Getting started** — install, scaffold, first page
77
+ - **The spec** — full reference for every property
78
+ - **State, mutations, actions** — client interactivity
79
+ - **Server data** — async data fetching before render
80
+ - **Routing** — dynamic routes and URL params
81
+ - **Streaming SSR** — shell + deferred segments
82
+ - **Caching** — per-page TTL and HTTP cache headers
83
+ - **Guard** — authentication and authorisation before data fetches
84
+ - **Validation & constraints** — declarative form validation and state bounds
85
+ - **Raw responses** — RSS feeds, sitemaps, JSON APIs
86
+ - **Performance** — how the framework hits Lighthouse 100
87
+ - **UI components** — 50+ built-in components
88
+
89
+ ## Status
90
+
91
+ This is an early-access release. The core architecture is stable and production-quality, but the API may evolve before v1. Feedback and issues welcome on [GitHub](https://github.com/invisibleloop/pulse).
92
+
93
+ ## License
94
+
95
+ MIT
@@ -0,0 +1,111 @@
1
+ ## Spec review checklist
2
+
3
+ Before finishing any spec, verify every point below. Fix anything that fails.
4
+
5
+ ### Critical
6
+
7
+ - **`hydrate` is set on every interactive page.** Without it, `data-event` / `data-action` bindings do nothing, `persist` never runs, and client-side navigation cannot re-mount the page. Every spec with `mutations`, `actions`, or `persist` must include:
8
+ ```js
9
+ hydrate: '/src/pages/my-page.js', // browser-importable path to this file
10
+ ```
11
+ Omit `hydrate` only for purely server-rendered pages with zero client interactivity.
12
+
13
+ ### Components first
14
+
15
+ - **Before writing any HTML by hand, check `src/ui/index.js`.** There are 50+ components. Use `button`, `card`, `alert`, `input`, `spinner`, `badge`, `modal`, `nav`, `pagination`, `table`, etc. before writing equivalent HTML from scratch.
16
+
17
+ ### Reuse (DRY)
18
+
19
+ - **Extract a view helper when the same HTML pattern appears 3 or more times in a single spec.** A plain JS function returning an HTML string is sufficient — no framework needed:
20
+ ```js
21
+ const card = ({ title, body }) => `<div class="card"><h3>${title}</h3><p>${body}</p></div>`
22
+ ```
23
+ - **Create a shared component in `src/ui/` when the same pattern is needed across 2 or more different specs.** Follow the existing pattern: a named export that returns an HTML string.
24
+ - **Do not abstract a pattern that appears only once.** Duplication is cheaper than the wrong abstraction. Wait until the third use before extracting.
25
+
26
+ ### Correctness
27
+
28
+ - Mutations return plain partial-state objects and have no side effects (no fetch, no DOM access).
29
+ - `persist` contains only serialisable state that should survive a page reload — not ephemeral UI state like a loading flag or temporary selection.
30
+ - `e.target` assumptions in mutations are safe if the element has child nodes — use `e.target.closest('[data-index]')` rather than assuming `e.target` is the element with the attribute.
31
+ - State shape is consistent — avoid a single field that is sometimes `null`, sometimes a string, sometimes a boolean. Use a dedicated `status` field instead.
32
+ - **Never use `state.modalOpen` or conditional modal rendering.** This destroys the `<dialog>` on every render, breaking focus, animation, and native ESC handling. Instead, always render the `<dialog>` in the DOM and use `data-dialog-open="id"` to open it — the runtime handles this without any spec state:
33
+ ```html
34
+ <!-- always in the view, never conditional -->
35
+ ${modal({ id: 'confirm', title: 'Confirm', content: '...' })}
36
+ <!-- anywhere on the page — opens the dialog, no mutation needed -->
37
+ ${modalTrigger({ target: 'confirm', label: 'Open' })}
38
+ <!-- or inline: -->
39
+ <button data-dialog-open="confirm">Open</button>
40
+ ```
41
+ Close is handled natively by `<form method="dialog">` (inside the modal), ESC key, backdrop click, or `data-dialog-close` on any element.
42
+
43
+ ### Defensive data handling
44
+
45
+ - **Always check `res.ok` before parsing fetch responses.** Never call `res.json()` on a response that may have failed. Never use `await res.text()` as an error message — on a 404 or 500, this returns raw HTML which surfaces directly in toasts and error alerts. The correct pattern:
46
+ ```js
47
+ const res = await fetch('/api/...')
48
+ if (!res.ok) {
49
+ let message = `Request failed: ${res.status}`
50
+ try { const j = await res.json(); message = j.message || j.error || message } catch {}
51
+ throw new Error(message)
52
+ }
53
+ return await res.json()
54
+ ```
55
+ - **Never assume the shape of data from external APIs or server fetchers.** Use optional chaining (`?.`) and nullish coalescing (`??`) at every access point. If a server fetcher can return `null`, the view must handle it — `server.user?.name ?? 'Guest'` not `server.user.name`.
56
+ - **Validate FormData fields before use.** `formData.get('email')` returns `null` if the field is missing. Check for null/empty before passing to an API or database.
57
+ - **Do not trust URL params.** `ctx.params.id` is a raw string from the URL. Validate it before use — check it exists, is the right type, and refers to a real resource. Return a 404 or redirect if it doesn't.
58
+
59
+ ### Security
60
+
61
+ - Any value from user input (URL params, form fields, external APIs) interpolated into view HTML must be escaped.
62
+
63
+ ### Tests
64
+
65
+ - Pure logic functions extracted from a spec (e.g. `checkResult`, `validate`, `formatPrice`) must have unit tests in a corresponding `.test.js` file.
66
+ - **When fixing a bug, write a failing test first.** The test must reproduce the bug before the fix is applied, then pass after. This pins the behaviour so the bug cannot silently return. A fix without a regression test is incomplete.
67
+ - **Use `renderSync` / `render` from `@invisibleloop/pulse/testing` to test view HTML output.** Do not test views with raw `html.includes()` — use the query helpers instead:
68
+ ```js
69
+ import { renderSync, render } from '@invisibleloop/pulse/testing'
70
+
71
+ // Sync — call view directly with mock state/server
72
+ const result = renderSync(mySpec, { state: { count: 5 }, server: { items: [] } })
73
+ assert(result.has('button'))
74
+ assert.equal(result.get('#count').text, '5')
75
+ assert.equal(result.count('li'), 0)
76
+
77
+ // Async — run real server fetchers (integration), or pass server to skip them
78
+ const result = await render(mySpec, { server: { product: mockProduct } })
79
+ assert.equal(result.get('h1').text, mockProduct.name)
80
+ assert.equal(result.attr('img', 'src'), mockProduct.image)
81
+ ```
82
+ Supported selectors: `tag`, `.class`, `#id`, `[attr]`, `[attr="value"]`, and combinations (`button.primary[disabled]`).
83
+
84
+ ### View error handling
85
+
86
+ - **Define `onViewError` on any page where the view could throw due to bad or missing data.** Without it, a runtime view error returns a 500 on the server and shows a generic inline message on the client. With it, the server returns 200 with your fallback HTML instead of a 500, and the client renders your fallback:
87
+ ```js
88
+ onViewError: (err, state, serverState) => `
89
+ <div class="u-p-4 u-text-center">
90
+ <p>Something went wrong. <a href="">Reload</a></p>
91
+ </div>
92
+ `
93
+ ```
94
+ Use this on pages that render data from external APIs or user-supplied content — any path where the view can encounter `null`, `undefined`, or unexpected shapes that would cause a crash. It is not required on simple pages with predictable data.
95
+
96
+ ### Store updates from actions
97
+
98
+ - **Use `_storeUpdate` to push changes to the global store from an action.** Return it from `onSuccess` alongside the local state update — it is stripped from page state and forwarded to the store. All mounted pages that subscribe to the affected keys re-render immediately:
99
+ ```js
100
+ onSuccess: (state, theme) => ({
101
+ saved: true,
102
+ _storeUpdate: { settings: { theme } }, // ← merged into store state
103
+ }),
104
+ ```
105
+ `_storeUpdate` only merges into the store — it does not appear in the page's own state. The rest of the return is merged into local state as normal. Use this instead of a full-page reload when a user action changes shared data (theme, cart count, user profile, etc.).
106
+
107
+ ### Accessibility
108
+
109
+ - Interactive elements without visible text have an `aria-label`.
110
+ - Disabled state is reflected with the `disabled` attribute, not just CSS.
111
+ - The page has a `<main id="main-content">` landmark.
@@ -0,0 +1 @@
1
+ 0.1.20