@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,979 @@
1
+ Pulse is a spec-first frontend framework. Pages are JS files that export a default spec object.
2
+
3
+ ## Spec structure
4
+
5
+ ```js
6
+ export default {
7
+ meta: { title, description, styles: ['/app.css'] },
8
+ state: { /* initial state */ },
9
+ mutations: {
10
+ // Synchronous. Return partial state. First arg is state, second is the DOM event.
11
+ setName: (state, e) => ({ name: e.target.value }),
12
+ },
13
+ actions: {
14
+ // Async lifecycle. <form data-action="actionName"> passes FormData as payload.
15
+ submit: {
16
+ onStart: (state, formData) => ({ status: 'loading' }), // optional — runs before run()
17
+ run: async (state, serverState, formData) => { // required — return value passed to onSuccess
18
+ const name = formData.get('name')
19
+ return await fetch('/api', { method: 'POST', body: formData }).then(r => r.json())
20
+ },
21
+ onSuccess: (state, result) => ({ status: 'success' }), // required — result = return value of run()
22
+ onError: (state, error) => ({ status: 'error' }), // required — called if run() throws
23
+ },
24
+ },
25
+ server: {
26
+ // Resolved server-side before render. Passed to view() as second arg.
27
+ posts: async () => fetchPostsFromDb(),
28
+ },
29
+ view: (state, serverState) => `<h1>${state.title}</h1>`,
30
+ }
31
+ ```
32
+
33
+ ## Streaming SSR — shell + deferred segments
34
+
35
+ Use streaming when a page has slow data and you want the shell to paint instantly while slower content streams in.
36
+
37
+ To use streaming, the `view` must be an **object of named segment functions** (not a single function), and the spec must declare a `stream` field:
38
+
39
+ ```js
40
+ export default {
41
+ route: '/',
42
+ state: {},
43
+ server: {
44
+ user: async (ctx) => getUser(ctx.cookies.session), // fast
45
+ items: async () => {
46
+ await new Promise(r => setTimeout(r, 1000)) // slow
47
+ return fetchItems()
48
+ },
49
+ },
50
+ stream: {
51
+ shell: ['shell'], // rendered and sent immediately
52
+ deferred: ['content'], // streamed in when server data resolves
53
+ },
54
+ view: {
55
+ shell: (state, server) => `<nav>My App</nav><h1>Welcome ${server.user?.name}</h1>`,
56
+ content: (state, server) => `<ul>${server.items.map(i => `<li>${i.title}</li>`).join('')}</ul>`,
57
+ },
58
+ }
59
+ ```
60
+
61
+ **Key rules:**
62
+ - `view` must be an object of named functions — not a single function — when using `stream`
63
+ - Streaming splits the **view**, not data fetching. All `server.*` fetchers resolve before any segment renders. Use `Promise.all` inside a fetcher to parallelise slow calls
64
+ - Deferred segments render a `<pulse-deferred>` placeholder while loading — no client JS required
65
+ - Only specs with a `stream` field use chunked responses; all others use standard buffered SSR
66
+
67
+ ## Cross-page state and persistence
68
+
69
+ ### Per-page persistence — `spec.persist`
70
+
71
+ `spec.persist` is an array of state key names automatically saved to `localStorage` after every mutation/action, and restored on mount. Storage key is `pulse:/route-path` (scoped per route).
72
+
73
+ ```js
74
+ export default {
75
+ route: '/cart',
76
+ state: { items: [], count: 0 },
77
+ persist: ['items', 'count'], // survive page refresh
78
+ }
79
+ ```
80
+
81
+ Only declared keys are saved. Restored values that differ from the spec default trigger a re-render on mount (even after SSR).
82
+
83
+ ### Global store — `pulse.store.js`
84
+
85
+ The global store is a single shared server-side data layer. Declare fetchers once — user profiles, settings, feature flags — and any page can access them by name. No repeated fetchers, no prop drilling.
86
+
87
+ **Define the store** in `pulse.store.js`:
88
+ ```js
89
+ // pulse.store.js
90
+ export default {
91
+ state: { // default/fallback values
92
+ user: null,
93
+ settings: { theme: 'dark', lang: 'en' },
94
+ },
95
+ server: { // async fetchers — run per request
96
+ user: async (ctx) => db.users.findByCookie(ctx.cookies.session),
97
+ settings: async (ctx) => db.settings.forUser(ctx.cookies.userId),
98
+ },
99
+ }
100
+ ```
101
+
102
+ **Register the store** in your server file:
103
+ ```js
104
+ import store from './pulse.store.js'
105
+ createServer([...specs], { store })
106
+ ```
107
+
108
+ **Use store data in a page** — declare `spec.store` with the keys needed. They appear in the view's `server` argument:
109
+ ```js
110
+ export default {
111
+ route: '/dashboard',
112
+ store: ['user', 'settings'], // declare which store keys this page uses
113
+ server: {
114
+ stats: async (ctx) => db.stats.forUser(ctx.store.user?.id), // ctx.store available here
115
+ },
116
+ state: {},
117
+ view: (state, server) => `
118
+ <h1>Hello, ${server.user?.name}</h1>
119
+ <p>Theme: ${server.settings.theme}</p>
120
+ <p>Requests: ${server.stats.total}</p>
121
+ `,
122
+ }
123
+ ```
124
+
125
+ - Store fetchers run **before** page server fetchers and guards — `ctx.store` is available in all of them
126
+ - Only keys listed in `spec.store` are passed to the view — nothing leaks to pages that don't declare it
127
+ - Page-level `server` keys win over store keys if there is a name collision
128
+ - Pages with `spec.store` are never HTML-cached (same rule as pages with `spec.server`)
129
+
130
+ **Reactive updates — no refresh needed**
131
+
132
+ Return `_storeUpdate` from a page action's `onSuccess` to push a change into the global store. All mounted pages that subscribe to the affected keys re-render immediately:
133
+ ```js
134
+ actions: {
135
+ saveTheme: {
136
+ run: async (state, server, payload) => {
137
+ await fetch('/api/settings', { method: 'PATCH', body: payload })
138
+ return payload.get('theme')
139
+ },
140
+ onSuccess: (state, theme) => ({
141
+ saved: true,
142
+ _storeUpdate: { settings: { theme } }, // ← broadcast to all subscribed pages
143
+ }),
144
+ onError: (state, err) => ({ error: err.message }),
145
+ },
146
+ },
147
+ ```
148
+ `_storeUpdate` is stripped from local page state — it is only forwarded to the store. The rest of `onSuccess` merges into the page's own state as normal.
149
+
150
+ ## Server context — redirects, cookies, POST bodies
151
+
152
+ The `ctx` object is available in `guard`, `server.*` fetchers, and `meta` functions.
153
+
154
+ ### Reading cookies
155
+ `ctx.cookies` — plain object parsed from the request `Cookie` header:
156
+ ```js
157
+ server: { user: async (ctx) => getUserByToken(ctx.cookies.session) }
158
+ ```
159
+
160
+ ### Redirects — use `guard`
161
+ Return `{ redirect: '/path' }` from `guard` to redirect (302) before any data fetching.
162
+ There is **no redirect mechanism from `server.*` fetchers** — use `guard` for that.
163
+ ```js
164
+ guard: async (ctx) => {
165
+ if (!ctx.cookies.session) return { redirect: '/login' }
166
+ }
167
+ ```
168
+
169
+ ### Setting cookies and headers
170
+ `ctx.setCookie(name, value, opts)` — queues a `Set-Cookie` header. Options: `httpOnly`, `secure`, `path`, `maxAge`, `sameSite`, `domain`. Defaults: `Path=/`, `SameSite=Lax`.
171
+ `ctx.setHeader(name, value)` — queues any arbitrary response header.
172
+
173
+ ### Reading the request body
174
+
175
+ Body parsing is available in `guard`, `server.*` fetchers, and `render` (raw specs). All methods are lazy — the body stream is only consumed once and the result is memoised for the lifetime of the request.
176
+
177
+ ```js
178
+ await ctx.json() // parse JSON body → object | null
179
+ await ctx.text() // raw string body → string
180
+ await ctx.formData() // URL-encoded body → plain object | null
181
+ await ctx.buffer() // raw Buffer
182
+ ```
183
+
184
+ Bodies larger than `maxBody` (default 1 MB, configurable in `createServer`) are rejected with a 413 response before the handler runs.
185
+
186
+ **Page specs only accept GET/HEAD by default** (POST → 405). To handle POST on a page spec, opt in with `spec.methods`:
187
+
188
+ ```js
189
+ export default {
190
+ route: '/contact',
191
+ methods: ['GET', 'POST'],
192
+
193
+ guard: async (ctx) => {
194
+ if (ctx.method === 'POST') {
195
+ const data = await ctx.formData()
196
+ if (!data.email) return { status: 422, json: { error: 'Email required' } }
197
+ await db.leads.create(data)
198
+ return { redirect: '/contact?sent=1' }
199
+ }
200
+ },
201
+
202
+ state: {},
203
+ view: (state) => `<form method="POST">...</form>`,
204
+ }
205
+ ```
206
+
207
+ **Guard can return a custom HTTP response** (instead of a redirect) by returning `{ status, json?, body?, headers? }`:
208
+
209
+ ```js
210
+ guard: async (ctx) => {
211
+ const token = ctx.headers.authorization
212
+ if (!token) return { status: 401, json: { error: 'Unauthorized' } }
213
+ // returning nothing lets the request proceed
214
+ }
215
+ ```
216
+
217
+ **Raw response specs** (`contentType` set) accept any HTTP method by default — use `ctx.method` and `await ctx.json()` / `ctx.text()` to build webhooks or JSON APIs:
218
+
219
+ ```js
220
+ export default {
221
+ route: '/api/hook',
222
+ contentType: 'application/json',
223
+ render: async (ctx) => {
224
+ const payload = await ctx.json()
225
+ await processWebhook(payload)
226
+ return JSON.stringify({ ok: true })
227
+ },
228
+ }
229
+ ```
230
+
231
+ ## Key rules
232
+
233
+ - view() returns an HTML string using template literals
234
+ - data-event="mutationName" on buttons/elements — passes the DOM event to the mutation
235
+ - data-event="change:mutationName" to fire on input change
236
+ - data-action="actionName" on <form> elements only — submits pass FormData to the action
237
+ - Do NOT use data-event on text inputs to mirror value into state — innerHTML re-renders destroy focus. Capture values from FormData in onStart or run instead.
238
+ - onSuccess AND onError are both required in every action (missing either will cause a runtime error)
239
+ - Always export default spec
240
+
241
+ ## Form layout pattern
242
+
243
+ Use a `<form data-action="...">` element with class `u-flex u-flex-col u-gap-4` for vertical field stacking. For side-by-side fields (e.g. name + email), use `grid({ cols: 2, gap: 'md' })` inside the form — it collapses to one column on mobile automatically. Never use raw `<div class="...">` grids or custom CSS when `grid()` covers it.
244
+
245
+ ```js
246
+ card({ content: `
247
+ <form data-action="submit" class="u-flex u-flex-col u-gap-4">
248
+ ${fieldset({ legend: 'Your details', content: `
249
+ ${grid({ cols: 2, gap: 'md', content: `
250
+ ${input({ name: 'firstName', label: 'First name', required: true })}
251
+ ${input({ name: 'lastName', label: 'Last name', required: true })}
252
+ ` })}
253
+ ${input({ name: 'email', label: 'Email', type: 'email', required: true })}
254
+ ` })}
255
+ ${fieldset({ legend: 'Message', content: `
256
+ ${textarea({ name: 'message', label: 'Tell us about your project', rows: 5, required: true })}
257
+ ` })}
258
+ ${button({ label: 'Send', type: 'submit', variant: 'primary', fullWidth: true })}
259
+ </form>
260
+ ` })
261
+ ```
262
+
263
+ For simple forms without distinct groups, omit `fieldset` and use `u-flex u-flex-col u-gap-4` on the `<form>` directly.
264
+
265
+ ## meta.styles and meta.scripts
266
+
267
+ - meta.styles — array of CSS paths loaded as <link rel="stylesheet">. Always include '/app.css'.
268
+ - meta.scripts — array of JS paths loaded as <script defer>. Required for interactive UI components.
269
+
270
+ Interactive Pulse UI components (carousel, modal, accordion, tooltip) require BOTH:
271
+ ```js
272
+ meta: {
273
+ styles: ['/app.css', '/pulse-ui.css'],
274
+ scripts: ['/pulse-ui.js'],
275
+ }
276
+ ```
277
+ Non-interactive components (nav, hero, button, card, etc.) only need '/pulse-ui.css' in styles.
278
+
279
+ ## Theming — always use CSS custom properties
280
+
281
+ pulse-ui.css exposes CSS custom properties for every token. app.css MUST use these tokens — never hardcode colour hex values.
282
+
283
+ Override tokens in :root inside app.css to retheme all components at once:
284
+ ```css
285
+ :root {
286
+ --bg: #0d0d10; /* page background */
287
+ --surface: #111116; /* card / panel background */
288
+ --surface-2: #18181f; /* inset / code background */
289
+ --border: #38383f;
290
+ --text: #e2e2ea;
291
+ --muted: #9090a0;
292
+ --accent: #9b8dff;
293
+ --accent-hover: #b5aaff;
294
+ --radius: 8px;
295
+ }
296
+ ```
297
+
298
+ Then use the computed --ui-* tokens everywhere in app.css:
299
+ ```css
300
+ body { background-color: var(--ui-bg); color: var(--ui-text); font-family: var(--ui-font); }
301
+ h1 { color: var(--ui-text); }
302
+ p { color: var(--ui-muted); }
303
+ a { color: var(--ui-accent); }
304
+ code { background: var(--ui-surface-2); color: var(--ui-accent); border: 1px solid var(--ui-border); }
305
+ ```
306
+
307
+ The library is dark by default. To apply a **light theme**, set `meta.theme: 'light'` in the spec — this adds `data-theme="light"` to the `<body>` and activates the built-in light token set (accessible contrast for badges, alerts, and all semantic colours). Do NOT manually copy token values into `:root`.
308
+
309
+ ```js
310
+ meta: {
311
+ theme: 'light',
312
+ styles: ['/pulse-ui.css', '/app.css'],
313
+ }
314
+ ```
315
+
316
+ Token list: --ui-bg, --ui-surface, --ui-surface-2, --ui-border, --ui-text, --ui-muted, --ui-accent, --ui-accent-hover, --ui-green, --ui-red, --ui-yellow, --ui-blue, --ui-radius, --ui-radius-sm, --ui-font, --ui-mono. Never hardcode hex values — override the tokens.
317
+
318
+ ## Custom fonts
319
+
320
+ All components use `--ui-font` (body) and `--ui-mono` (code). These resolve from `--font` and `--mono` respectively, so overriding those two variables in `:root` is all that is ever needed — no other CSS changes required.
321
+
322
+ **Two rules that must never be broken:**
323
+ - **Never `@import url(...)` a font in CSS** — use `meta.styles` instead. CSS `@import` is render-blocking; a `<link>` tag is not.
324
+ - **Never set `font-family` directly on `body` or any element** — this bypasses `--ui-font` and breaks component inheritance. Always set `--font` in `:root`.
325
+
326
+ ```css
327
+ /* app.css */
328
+ :root {
329
+ --font: 'Inter', system-ui, sans-serif;
330
+ --mono: 'JetBrains Mono', monospace;
331
+ }
332
+ ```
333
+
334
+ ### Google Fonts
335
+
336
+ Add the Google Fonts stylesheet URL **before** `pulse-ui.css` in `meta.styles`. Use `family=Name:wght@weights` and always include `&display=swap`.
337
+
338
+ **Never use `@import url(...)` in app.css** — CSS `@import` is render-blocking and much slower than a `<link>` tag. Always use `meta.styles`.
339
+
340
+ **Never set `font-family` directly on `body`** — this bypasses `--ui-font` so components won't inherit the font. Always set `--font` in `:root` instead.
341
+
342
+ ```js
343
+ meta: {
344
+ styles: [
345
+ 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap',
346
+ '/pulse-ui.css',
347
+ '/app.css',
348
+ ],
349
+ }
350
+ ```
351
+
352
+ Then in app.css:
353
+ ```css
354
+ :root { --font: 'Inter', system-ui, sans-serif; }
355
+ ```
356
+
357
+ For multiple weights or italic variants, separate them with a semicolon in the URL:
358
+ ```
359
+ ?family=Inter:ital,wght@0,400;0,700;1,400&display=swap
360
+ ```
361
+
362
+ ### Adobe Fonts (Typekit)
363
+
364
+ Adobe Fonts provides a per-project CSS URL from your kit settings. Add it before `pulse-ui.css` in `meta.styles` — the font-family name comes from your Adobe Fonts kit.
365
+
366
+ ```js
367
+ meta: {
368
+ styles: [
369
+ 'https://use.typekit.net/YOURPROJECTID.css',
370
+ '/pulse-ui.css',
371
+ '/app.css',
372
+ ],
373
+ }
374
+ ```
375
+
376
+ Then in app.css, use the exact font name shown in your Adobe Fonts kit:
377
+ ```css
378
+ :root { --font: 'proxima-nova', system-ui, sans-serif; }
379
+ ```
380
+
381
+ Note: Adobe Fonts kit URLs require the project to be published and the domain to be authorised in your Adobe Fonts account.
382
+
383
+ ### Self-hosted fonts
384
+
385
+ Place font files in `public/fonts/` and declare them with `@font-face` in `app.css`. Always use `woff2` format and `font-display: swap`.
386
+
387
+ ```css
388
+ /* app.css */
389
+ @font-face {
390
+ font-family: 'MyFont';
391
+ src: url('/fonts/myfont-regular.woff2') format('woff2');
392
+ font-weight: 400;
393
+ font-style: normal;
394
+ font-display: swap;
395
+ }
396
+
397
+ @font-face {
398
+ font-family: 'MyFont';
399
+ src: url('/fonts/myfont-bold.woff2') format('woff2');
400
+ font-weight: 700;
401
+ font-style: normal;
402
+ font-display: swap;
403
+ }
404
+
405
+ :root { --font: 'MyFont', system-ui, sans-serif; }
406
+ ```
407
+
408
+ No changes to `meta.styles` needed — the `@font-face` declarations live in `app.css` which is already loaded.
409
+
410
+ ### Multi-brand fonts
411
+
412
+ For multi-brand sites, keep `@font-face` declarations (or the font service URL) in the per-brand theme file and override `--font` there:
413
+
414
+ ```css
415
+ /* themes/acme.css */
416
+ :root { --font: 'proxima-nova', system-ui, sans-serif; }
417
+ ```
418
+
419
+ ## CSS rules — where to put styles and when to use utilities
420
+
421
+ RULE: Never use inline style attributes (style="...") in HTML. Always use classes.
422
+
423
+ RULE: For spacing, typography, layout, and colour, always prefer pulse-ui utility classes first. Only add to app.css if you need something the utilities cannot provide (e.g. a unique component style, a keyframe animation, a custom grid).
424
+
425
+ pulse-ui.css includes a full utility layer (u- prefix). Use these directly in HTML:
426
+
427
+ Spacing (scale: 1=4px 2=8px 3=12px 4=16px 5=20px 6=24px 8=32px 10=40px 12=48px 16=64px):
428
+ u-mt-{0-16} u-mb-{0-16} u-mx-auto u-ml-auto u-mr-auto
429
+ u-p-{0-8} u-px-{0-8} u-py-{0-8}
430
+
431
+ Typography:
432
+ u-text-{xs,sm,base,lg,xl,2xl,3xl,4xl}
433
+ u-font-{normal,medium,semibold,bold}
434
+ u-text-{left,center,right}
435
+ u-text-{default,muted,accent,green,red,yellow,blue}
436
+ u-leading-{tight,snug,normal,relaxed,loose}
437
+
438
+ Layout:
439
+ u-flex u-flex-col u-flex-wrap u-flex-1 u-shrink-0
440
+ u-items-{start,center,end,stretch}
441
+ u-justify-{start,center,end,between}
442
+ u-gap-{1-8}
443
+ u-w-full u-max-w-{xs,sm,md,lg,xl,prose}
444
+ u-block u-inline u-inline-block u-hidden
445
+
446
+ Visual:
447
+ u-rounded u-rounded-md u-rounded-lg u-rounded-xl u-rounded-full
448
+ u-border u-border-t u-border-b
449
+ u-bg-surface u-bg-surface2 u-bg-accent
450
+ u-overflow-hidden u-overflow-auto
451
+ u-relative u-absolute u-opacity-50 u-opacity-75
452
+
453
+ Example — a centred hero block using only utilities, no custom CSS:
454
+ ```html
455
+ <div class="u-flex u-flex-col u-items-center u-text-center u-py-16 u-gap-4">
456
+ <h1 class="u-text-4xl u-font-bold">Hello</h1>
457
+ <p class="u-text-lg u-text-muted u-max-w-prose">Subtitle goes here.</p>
458
+ </div>
459
+ ```
460
+
461
+ When you DO need to write CSS, add it to public/app.css — never inline.
462
+
463
+ ## Site navigation
464
+
465
+ Projects define navigation in src/components/layout.js via a NAV_LINKS array.
466
+ To add a new page to the nav: edit NAV_LINKS in src/components/layout.js only — do NOT add links in individual page files.
467
+
468
+ ```js
469
+ const NAV_LINKS = [
470
+ { label: 'Home', href: '/' },
471
+ { label: 'About', href: '/about' }, // ← add new pages here
472
+ ]
473
+ ```
474
+
475
+ ## Page discovery — no registration needed
476
+
477
+ Pulse automatically discovers every .js file under src/pages/ and registers it as a route. You NEVER need to edit a server.js or register specs manually. Just create the file and it is live.
478
+
479
+ File → derived route (used only when the spec has no route property):
480
+ src/pages/about.js → /about
481
+ src/pages/blog.js → /blog
482
+ src/pages/blog/index.js → /blog
483
+ src/pages/home.js or index.js → /
484
+
485
+ ## Dynamic routes
486
+
487
+ Use :param syntax in the spec's route property. The filename doesn't matter — name it [slug].js or post.js, it makes no difference. The route property controls matching.
488
+
489
+ route: '/blog/:slug' ← param captured as ctx.params.slug
490
+
491
+ The param is available in server fetchers and meta functions:
492
+ server: { post: async (ctx) => posts.find(p => p.slug === ctx.params.slug) ?? null }
493
+ meta: { title: (ctx) => posts[ctx.params.slug]?.title ?? 'Not Found' }
494
+
495
+ Convention: name dynamic-route files [param].js inside a subfolder:
496
+ src/pages/blog/[slug].js with route: '/blog/:slug'
497
+
498
+ This is purely a human readability convention. Pulse does not process [ ] in filenames.
499
+
500
+ ## UI components — MANDATORY
501
+
502
+ **RULE: Before writing any HTML, check whether a Pulse UI component exists for the purpose. If it does, you MUST use it. Raw HTML is only permitted for structural tags with no component equivalent (div, main, aside, footer, header).**
503
+
504
+ **RULE: For every feature, page, or UI element you build — regardless of how the request is phrased, whether it came from text or an image — your first step is always to check whether a Pulse UI component exists for it. If it does, you MUST use it. There are no exceptions.**
505
+
506
+ Before writing a single line of HTML, mentally scan the task and list which components apply. Only after exhausting the component list should you write raw HTML — and only for structural wrappers (div, main, aside, footer) with no component equivalent.
507
+
508
+ **RULE: All components accept a `class` prop — never `className` (that is React). Use `class` to add utility classes or custom identifiers to any component.**
509
+
510
+ Import only what you use. Icons are included — no third-party library needed. Always include `/pulse-ui.css` in `meta.styles`:
511
+ ```js
512
+ import { nav, hero, feature, button, card, stat, grid, section, container, iconZap, iconShield, iconCheck } from '@invisibleloop/pulse/ui'
513
+ meta: { styles: ['/pulse-ui.css', '/app.css'] }
514
+ ```
515
+
516
+ Interactive components (modal, carousel, tooltip, accordion) also need `/pulse-ui.js` in `meta.scripts`.
517
+
518
+ REQUIRED in meta.styles: always include '/pulse-ui.css' whenever you use any UI component.
519
+
520
+ Setup: pulse-ui.css must exist at public/pulse-ui.css — if missing, copy from node_modules/@invisibleloop/pulse/public/pulse-ui.css. Interactive components (modal, carousel) also need public/pulse-ui.js.
521
+
522
+ ### Charts
523
+
524
+ Server-rendered SVG charts — no JS, no dependencies. Drop into any card, section, or grid.
525
+
526
+ ```js
527
+ import { barChart, lineChart, donutChart, sparkline } from '@invisibleloop/pulse/ui'
528
+
529
+ // Bar chart
530
+ barChart({
531
+ data: [{ label: 'Jan', value: 42 }, { label: 'Feb', value: 78 }],
532
+ color: 'accent', // accent · success · warning · error · blue · muted
533
+ showValues: true, // value labels above bars
534
+ showGrid: true, // horizontal grid lines (default: true)
535
+ gap: 0.25, // gap between bars 0–0.9
536
+ height: 220, // SVG height in px
537
+ })
538
+
539
+ // Line chart
540
+ lineChart({
541
+ data: [{ label: 'Jan', value: 42 }, ...],
542
+ color: 'accent',
543
+ area: true, // fill area under the line
544
+ showDots: true, // dots at each point (default: true)
545
+ height: 220,
546
+ })
547
+
548
+ // Donut chart — each segment can override its colour
549
+ donutChart({
550
+ data: [
551
+ { label: 'Satisfied', value: 73, color: 'success' },
552
+ { label: 'Neutral', value: 18, color: 'muted' },
553
+ { label: 'Unsatisfied', value: 9, color: 'error' },
554
+ ],
555
+ size: 200, // diameter in px
556
+ thickness: 40, // ring thickness
557
+ label: '73%', // large centre text
558
+ sublabel: 'satisfied',
559
+ })
560
+
561
+ // Sparkline — inline trend line, plain number array
562
+ sparkline({ data: [12,18,14,22,19,28], color: 'success', area: true })
563
+ sparkline({ data: [12,18,14,22,19,28], width: 80, height: 32 })
564
+ ```
565
+
566
+ Charts compose naturally with other components:
567
+ ```js
568
+ // Chart inside a card
569
+ card({ title: 'Monthly signups', content: barChart({ data, height: 180 }) })
570
+
571
+ // Grid of chart cards
572
+ grid({ cols: 2, content:
573
+ card({ title: 'Revenue', content: barChart({ data: monthly, color: 'accent' }) }) +
574
+ card({ title: 'Traffic', content: lineChart({ data: daily, color: 'blue', area: true }) }),
575
+ })
576
+
577
+ // Sparkline below a stat tile
578
+ card({ content:
579
+ stat({ label: 'Revenue', value: '$18k', change: '+12%', trend: 'up' }) +
580
+ sparkline({ data: trend, color: 'success', area: true }),
581
+ })
582
+ ```
583
+
584
+ ### Icons
585
+
586
+ 55 built-in icons — no third-party library needed. All are pure functions: `iconName({ size, class, bg, bgColor }) => svgString`.
587
+
588
+ Props:
589
+ - `size` — width/height in px (default: 16)
590
+ - `class` — extra CSS classes (on wrapper when `bg` is set, otherwise on the SVG)
591
+ - `bg` — `'circle'` or `'square'` — wraps icon in a tinted background shape
592
+ - `bgColor` — `'accent'` · `'success'` · `'warning'` · `'error'` · `'muted'` (default: `'accent'`)
593
+
594
+ ```js
595
+ import { iconCheck, iconArrowRight, iconZap, iconShield } from '@invisibleloop/pulse/ui'
596
+
597
+ // Plain icon — inherits color from parent
598
+ feature({ icon: iconZap({ size: 20 }) })
599
+
600
+ // Icon in button — use the button icon prop
601
+ button({ label: 'Download', variant: 'primary', icon: iconDownload({ size: 14 }) })
602
+ button({ label: 'Delete', variant: 'danger', icon: iconTrash({ size: 14 }) })
603
+ button({ label: 'Search', variant: 'ghost', icon: iconSearch({ size: 14 }) })
604
+
605
+ // Background circle — great for feature() icon slots or stat/timeline dots
606
+ iconZap({ size: 20, bg: 'circle', bgColor: 'accent' })
607
+ iconCheck({ size: 20, bg: 'circle', bgColor: 'success' })
608
+ iconAlertTriangle({ size: 20, bg: 'circle', bgColor: 'warning' })
609
+
610
+ // Background square (rounded corners)
611
+ iconShield({ size: 22, bg: 'square', bgColor: 'success' })
612
+ iconCode({ size: 22, bg: 'square', bgColor: 'muted' })
613
+
614
+ // Tint colour via utility class (no bg)
615
+ `<span class="u-text-accent">${iconStar({ size: 20 })}</span>`
616
+ ```
617
+
618
+ Available icons (import by name):
619
+ - **Navigation:** iconArrowLeft/Right/Up/Down, iconChevronLeft/Right/Up/Down, iconExternalLink, iconMenu, iconX, iconMoreHorizontal, iconMoreVertical
620
+ - **Hand Pointers:** iconHandPointUp, iconHandPointDown, iconHandPointLeft, iconHandPointRight
621
+ - **Status:** iconCheck, iconCheckCircle, iconXCircle, iconAlertCircle, iconAlertTriangle, iconInfo
622
+ - **Actions:** iconPlus, iconMinus, iconEdit, iconTrash, iconCopy, iconSearch, iconFilter, iconDownload, iconUpload, iconRefresh, iconSend
623
+ - **UI Controls:** iconEye, iconEyeOff, iconLock, iconUnlock, iconSettings, iconBell
624
+ - **People:** iconUser, iconUsers, iconMail, iconMessageSquare
625
+ - **Pages:** iconHome, iconLogOut, iconLogIn
626
+ - **Content:** iconFile, iconImage, iconLink, iconCode, iconCalendar, iconClock, iconBookmark, iconTag
627
+ - **Media:** iconPlay, iconPause, iconVolume, iconStar, iconHeart
628
+ - **Devices:** iconPhone, iconGamepad
629
+ - **Misc:** iconGlobe, iconShield, iconZap, iconTrendingUp, iconTrendingDown, iconLoader, iconGrid
630
+
631
+ **Never use emoji in UI output.** Use icons instead. Emoji are not accessible, not theme-aware, and render inconsistently across platforms.
632
+
633
+ #### Adding a new icon
634
+
635
+ If the icon you need does not exist, add it to `src/ui/icons.js`. All icons follow the same pattern:
636
+
637
+ ```js
638
+ // Stroke-based (most icons) — inherits color from CSS via stroke="currentColor"
639
+ export const iconRocket = (o) => s('<path d="M4.5 16.5c-1.5 1.5-1.5 4 0 5.5s4 1.5 5.5 0"/><path d="M12 2C6.5 7 5 13 7 18l5 5c5-2 11-3.5 16-9a15 15 0 00-16-12z"/><circle cx="14" cy="10" r="2"/>', opts(o))
640
+
641
+ // Fill-based (solid shapes only — use sparingly)
642
+ export const iconDiamond = (o) => f('<polygon points="12 2 22 12 12 22 2 12"/>', opts(o))
643
+ ```
644
+
645
+ Rules for new icons:
646
+ - 24×24 viewBox, Lucide-compatible paths (lucide.dev is MIT licensed — copy paths directly)
647
+ - Stroke icons use `s(paths, opts(o))`. Fill icons use `f(paths, opts(o))`
648
+ - Name as `iconCamelCase` — exported as a named const
649
+ - Add it to the correct section in the file (Navigation, Status, Actions, etc.) or add a new section
650
+ - Export it from `src/ui/index.js` alongside the existing icon exports
651
+
652
+ After adding, import and use it exactly like any built-in icon:
653
+ ```js
654
+ import { iconRocket } from '@invisibleloop/pulse/ui'
655
+ iconRocket({ size: 20, bg: 'circle', bgColor: 'accent' })
656
+ ```
657
+
658
+ ### UI components
659
+
660
+ | Component | Key props |
661
+ |-----------|-----------|
662
+ | `button` | `label`, `variant` (primary/secondary/ghost/danger), `size` (sm/md/lg), `href` (renders `<a>`), `type` (button/submit/reset), `disabled`, `fullWidth`, `icon`, `iconAfter`, `attrs` |
663
+ | `input` | `name`, `label`, `type`, `placeholder`, `value`, `error`, `hint`, `required`, `disabled`, `attrs` |
664
+ | `select` | `name`, `label`, `options` (strings or `{value,label}`), `value`, `error`, `required`, `event` (data-event binding, e.g. `change:setVal`) |
665
+ | `textarea` | `name`, `label`, `placeholder`, `value`, `rows`, `error`, `hint`, `required`, `disabled`, `attrs` |
666
+ | `fieldset` | `legend` (group label), `content` (HTML slot), `gap` (xs/sm/md/lg) — semantic `<fieldset>` + `<legend>` for grouping related fields |
667
+ | `toggle` | `name`, `label`, `checked`, `disabled`, `hint`, `id`, `event` (data-event binding, e.g. `change:setEnabled`) — iOS-style switch; reads as `'on'` in FormData when checked |
668
+ | `fileUpload` | `name`, `label`, `hint`, `error`, `accept` (MIME types/extensions), `multiple`, `required`, `disabled`, `event` (data-event on the input, e.g. `change:fileSelected`) — drag-and-drop upload zone; file submitted in FormData under `name` |
669
+ | `rating` | `value` (0–max, supports 0.5 steps for display), `max` (default 5), `name` (enables interactive radio mode), `label`, `size` (sm/md/lg), `disabled` — omit `name` for read-only display |
670
+ | `spinner` | `size` (sm/md/lg), `color` (accent/muted/white), `label` (aria-label, default 'Loading…') — CSS-only rotating ring |
671
+ | `progress` | `value` (0–max, omit for indeterminate), `max` (default 100), `label`, `showLabel`, `showValue`, `variant` (accent/success/warning/error), `size` (sm/md/lg) |
672
+ | `slider` | `name`, `label`, `min` (0), `max` (100), `step` (1), `value` (50), `disabled`, `hint`, `event` (data-event binding, use `change:mutationName` not `input`) — styled range input; fill gradient updates live during drag automatically. Use `data-event="change:mutationName"` (not `input`) to capture value in state — `change` fires on release, avoiding mid-drag re-render. Submits numeric value in FormData |
673
+ | `segmented` | `name`, `options` ([{value,label}]), `value` (selected), `size` (sm/md/lg), `disabled`, `event` (data-event binding, e.g. `change:setTab`) — iOS-style segmented control using radio inputs |
674
+ | `breadcrumbs` | `items` ([{label,href}] — last item has no href), `separator` (default '/') — accessible nav with aria-current on the current page |
675
+ | `stepper` | `steps` (array of label strings), `current` (0-based active index) — horizontal step progress indicator |
676
+ | `uiImage` | `src`, `alt`, `caption`, `ratio` (CSS aspect-ratio e.g. '16/9'), `rounded` (larger corner radius ~1rem — for photos and book covers), `pill` (999px stadium radius — for avatars), `width`, `height`, `maxWidth` (number in px or CSS string — constrains the figure and centres it; use for portrait/book-cover images inside a `media()` column so they don't stretch to 50% container width) |
677
+ | `pullquote` | `quote`, `cite`, `size` (md/lg) — styled blockquote with accent left border |
678
+ | `heading` | `level` (1–6), `text`, `size` (xs/sm/base/lg/xl/2xl/3xl/4xl — overrides level default), `color` (default/muted/accent), `balance` (true — adds `text-wrap: balance` to prevent orphaned words on the last line) — semantic heading tag with correct visual styling; no margin added, use `u-mb-*` for spacing |
679
+ | `list` | `items` (array of HTML strings), `ordered` (false=ul, true=ol), `gap` (xs/sm/md) — styled list with tokens; items can contain any HTML including other components |
680
+ | `prose` | `content` (raw HTML string — NOT escaped), `size` (sm/base/lg) — typography wrapper for CMS output, markdown-rendered HTML, or any HTML you don't control; styles all descendant elements automatically |
681
+ | `radio` | `name`, `value`, `label`, `checked`, `disabled`, `id`, `event` (data-event binding, e.g. `change:setChoice`) — single radio button with custom styled dot |
682
+ | `radioGroup` | `name`, `legend`, `options` ([{value,label,hint?,disabled?}]), `value` (selected), `hint`, `error`, `gap`, `event` (data-event propagated to each radio input) (sm/md/lg) — semantic `<fieldset>` of radio options |
683
+ | `card` | `title`, `content` (HTML slot), `footer` (HTML slot), `flush` (removes body padding — use for full-bleed images or tables) |
684
+ | `alert` | `variant` (info/success/warning/error), `title`, `content` |
685
+ | `badge` | `label`, `variant` (default/success/warning/error/info) |
686
+ | `stat` | `label`, `value`, `change`, `trend` (up/down/neutral), `center` |
687
+ | `avatar` | `src`, `alt`, `size` (sm/md/lg/xl), `initials` |
688
+ | `empty` | `title`, `description`, `action` ({label,href,variant}) |
689
+ | `table` | `headers`, `rows` (2D array of HTML strings), `caption` |
690
+ | `timeline` | `direction` (vertical/horizontal), `items` ([{dot,dotColor,label,content}]), `content` (raw HTML via `timelineItem()`) — ordered events connected by a line |
691
+ | `timelineItem` | `content` (HTML slot — any component), `label` (timestamp/step label, escaped), `dot` (HTML slot — SVG or emoji, grows dot to 2rem), `dotColor` (accent/success/warning/error/muted) |
692
+
693
+ ### Landing page components
694
+
695
+ | Component | Key props |
696
+ |-----------|-----------|
697
+ | `nav` | `logo` (HTML slot), `logoHref`, `links` ([{label,href}]), `action` (HTML slot), `sticky` |
698
+ | `hero` | `eyebrow`, `title`, `subtitle`, `actions` (HTML slot — button() calls), `align` (center/left), `size` (md/sm — sm reduces top padding and removes bottom padding, good for inner-page headers) |
699
+ | `feature` | `icon` (HTML slot — SVG, icon component, or emoji; rendered as-is with no wrapper styling — the icon itself controls its own shape and background), `title`, `description`, `center` (boolean — centres icon, title, and description on all screen sizes) |
700
+ | `testimonial` | `quote`, `name`, `role`, `src` (avatar image URL), `rating` (1–5) |
701
+ | `pricing` | `name`, `price`, `period`, `features` ([strings]), `action` (HTML slot), `highlighted` |
702
+ | `accordion` | `items` ([{title,content}]) — no JS, native `<details>` |
703
+ | `appBadge` | `store` (apple/google), `href` |
704
+
705
+ ### Layout components
706
+
707
+ | Component | Key props |
708
+ |-----------|-----------|
709
+ | `container` | `content` (HTML slot), `size` (sm/md/lg/xl) — max-width wrapper |
710
+ | `section` | `content` (HTML slot), `variant` (default/alt/dark), `padding` (sm/md/lg), `id`, `eyebrow`, `title`, `subtitle`, `align` (left/center), `class` — heading props render above content |
711
+ | `grid` | `content` (HTML slot), `cols` (1–4), `gap` (sm/md/lg) — responsive CSS grid |
712
+ | `stack` | `content` (HTML slot), `gap` (xs/sm/md/lg/xl), `align` (stretch/start/center/end) — flex column |
713
+ | `cluster` | `content` (HTML slot), `gap` (xs/sm/md/lg), `justify` (start/center/end/between), `align` — flex row with wrap |
714
+ | `divider` | `label` — `<hr>` with optional centred label |
715
+ | `banner` | `content` (HTML slot), `variant` — full-width announcement bar |
716
+ | `media` | `image` (HTML slot), `content` (HTML slot), `reverse` (boolean — puts text left, image right) — two-column image + text, stacks on mobile |
717
+ | `cta` | `eyebrow`, `title`, `subtitle`, `actions` (HTML slot), `align` (center/left) — call-to-action block, sits inside section + container |
718
+ | `codeWindow` | `content` (raw HTML slot — highlighted code), `filename`, `lang` — macOS-style window chrome around a code block |
719
+ | `footer` | `logo` (HTML slot), `logoHref`, `links` ([{label,href}]), `legal` — accessible site footer, stacks on mobile |
720
+
721
+ ### Component composition
722
+
723
+ All HTML slot props (`content`, `footer`, `actions`, etc.) accept any string — including the output of other components. Nest freely:
724
+
725
+ ```js
726
+ // grid() wrapping multiple cards — the standard pattern for card grids
727
+ grid({
728
+ cols: 3, gap: 'md',
729
+ content: items.map(item => card({ title: item.name, content: `...` })).join(''),
730
+ })
731
+
732
+ // grid() inside a card's content slot — for structured layouts within a surface
733
+ card({
734
+ title: 'Plan comparison',
735
+ content: grid({
736
+ cols: 3, gap: 'sm',
737
+ content: plans.map(p => `<div class="u-text-center u-p-2">...</div>`).join(''),
738
+ }),
739
+ footer: button({ label: 'View pricing', href: '/pricing', variant: 'ghost', size: 'sm' }),
740
+ })
741
+
742
+ // badge() + button() inside card content and footer
743
+ card({
744
+ title: 'API rate limits',
745
+ content: `
746
+ <div class="u-flex u-gap-2 u-mb-4">
747
+ ${badge({ label: 'Production', variant: 'success' })}
748
+ ${badge({ label: 'v2.1', variant: 'info' })}
749
+ </div>
750
+ <p class="u-text-muted u-text-sm">Requests are capped at 1,000/min.</p>
751
+ `,
752
+ footer: `
753
+ ${button({ label: 'View docs', href: '/docs', variant: 'ghost', size: 'sm' })}
754
+ ${button({ label: 'Request increase', href: '/contact', variant: 'secondary', size: 'sm' })}
755
+ `,
756
+ })
757
+
758
+ // flush card with image — remove body padding for full-bleed image, restore below
759
+ card({
760
+ flush: true,
761
+ content: `
762
+ <div class="u-overflow-hidden u-rounded-md" style="height:180px">
763
+ <img src="/img/photo.jpg" alt="..." style="width:100%;height:100%;object-fit:cover">
764
+ </div>
765
+ <div class="u-p-5">
766
+ <p class="u-font-semibold u-mb-2">Card title</p>
767
+ <p class="u-text-muted u-text-sm">Supporting description.</p>
768
+ </div>
769
+ `,
770
+ footer: button({ label: 'Read more', href: '#', variant: 'ghost', size: 'sm' }),
771
+ })
772
+ ```
773
+
774
+ ### Timeline patterns
775
+
776
+ Vertical (default) — events flow downward, connector line links each dot:
777
+ ```js
778
+ timeline({
779
+ items: [
780
+ { label: 'Jan 2024', dotColor: 'success', content: '<strong>Launched</strong><p>v1.0 shipped to GA.</p>' },
781
+ { label: 'Mar 2024', dotColor: 'accent', content: '<strong>1k users</strong><p>Organic growth milestone.</p>' },
782
+ { label: 'Q3 2024', dotColor: 'muted', content: '<strong>Mobile (planned)</strong>' },
783
+ ],
784
+ })
785
+ ```
786
+
787
+ Horizontal — steps flow left to right (good for onboarding flows):
788
+ ```js
789
+ timeline({
790
+ direction: 'horizontal',
791
+ items: [
792
+ { label: 'Step 1', content: '<p>Sign up</p>' },
793
+ { label: 'Step 2', content: '<p>Connect data</p>' },
794
+ { label: 'Step 3', content: '<p>Go live</p>' },
795
+ ],
796
+ })
797
+ ```
798
+
799
+ Icon dots — pass any SVG as `dot`; dotColor tints both dot background and icon:
800
+ ```js
801
+ timeline({
802
+ items: [
803
+ { dot: checkSvg, dotColor: 'success', label: 'Done', content: card({ title: 'Onboarding complete' }) },
804
+ { dot: alertSvg, dotColor: 'warning', label: 'Pending', content: '<p>Awaiting approval.</p>' },
805
+ ],
806
+ })
807
+ ```
808
+
809
+ The `content` slot accepts any component — card(), stat(), badge(), button(), etc. Use `timelineItem()` directly for conditional or dynamic lists:
810
+ ```js
811
+ timeline({
812
+ content: steps.map(s =>
813
+ timelineItem({ dotColor: s.done ? 'success' : 'muted', label: s.date, content: s.html })
814
+ ).join(''),
815
+ })
816
+ ```
817
+
818
+ ### Typography components — headings, lists, and prose
819
+
820
+ **Never write raw `<h1>`–`<h6>` or `<ul>`/`<ol>` without styling.** Use these components instead:
821
+
822
+ #### `heading({ level, text, size?, color?, balance?, class? })`
823
+
824
+ Renders the correct semantic tag with consistent visual styling. Use for any standalone heading in a UI page — above a form, inside a card, in a section.
825
+
826
+ ```js
827
+ import { heading } from '@invisibleloop/pulse/ui'
828
+
829
+ heading({ level: 1, text: 'Dashboard' })
830
+ // → <h1 class="u-text-4xl u-font-bold u-leading-tight">Dashboard</h1>
831
+
832
+ heading({ level: 2, text: 'Recent orders' })
833
+ heading({ level: 3, text: 'Billing address', class: 'u-mb-4' })
834
+ heading({ level: 2, text: 'No results', color: 'muted' })
835
+
836
+ // Override visual size independently of semantic level (SEO/accessibility need h2 but want h4 size visually)
837
+ heading({ level: 2, text: 'Related articles', size: 'lg' })
838
+
839
+ // Prevent orphaned words — adds text-wrap: balance
840
+ heading({ level: 1, text: 'The quick brown fox jumps over', balance: true })
841
+ ```
842
+
843
+ Default size per level: h1=4xl, h2=3xl, h3=2xl, h4=xl, h5=base, h6=sm. Does not add margin — use `u-mb-*` / `u-mt-*` utility classes for spacing.
844
+
845
+ **`balance: true`** adds `text-wrap: balance`, which distributes text evenly across lines so no single word is stranded on the last line. Use it when a heading wraps and the visual result looks uneven. The verification workflow includes an orphan check — apply `balance: true` to any heading it flags.
846
+
847
+ #### `list({ items, ordered?, gap?, class? })`
848
+
849
+ Styled unordered or ordered list. Items are HTML strings — other components can be passed as items.
850
+
851
+ ```js
852
+ import { list } from '@invisibleloop/pulse/ui'
853
+
854
+ // Simple text list
855
+ list({ items: ['Fast', 'Accessible', 'Zero dependencies'] })
856
+
857
+ // Ordered steps
858
+ list({ items: ['Create account', 'Verify email', 'Set up profile'], ordered: true })
859
+
860
+ // Items with markup — include links, badges, etc.
861
+ list({ items: items.map(i => `<strong>${e(i.name)}</strong> — ${e(i.desc)}`) })
862
+
863
+ // Spacing: 'xs' | 'sm' (default) | 'md'
864
+ list({ items: features, gap: 'md' })
865
+ ```
866
+
867
+ #### `prose({ content, size?, class? })`
868
+
869
+ Typography wrapper for **any HTML you don't control**: CMS rich text, markdown output, database content, API responses. Styles all descendant elements (h1–h6, p, ul, ol, li, a, blockquote, code, pre, table, img) using `--ui-*` tokens. No classes needed on individual elements.
870
+
871
+ ```js
872
+ import { prose } from '@invisibleloop/pulse/ui'
873
+
874
+ // CMS rich text field — output directly, fully styled
875
+ prose({ content: server.article.bodyHtml })
876
+
877
+ // Markdown rendered to HTML
878
+ prose({ content: renderMarkdown(server.post.body) })
879
+
880
+ // Larger text (e.g. hero intro)
881
+ prose({ content: server.page.intro, size: 'lg' })
882
+ ```
883
+
884
+ **When to use `prose()` vs `heading()` / `list()`:**
885
+ - **`prose()`** — when the HTML comes from outside your spec (CMS, database, markdown). You don't control the tags.
886
+ - **`heading()` / `list()`** — when you're writing the content yourself inside the view template.
887
+
888
+ ### Attaching Pulse events to components
889
+
890
+ Pass events via `attrs` — must be an object, not a string:
891
+ ```js
892
+ button({ label: 'Like', attrs: { 'data-event': 'like' } })
893
+ button({ label: 'Delete', attrs: { 'data-event': 'remove', 'data-id': item.id } })
894
+ ```
895
+
896
+ ### Typical landing page structure
897
+
898
+ ```js
899
+ import { nav, hero, section, container, grid, feature, button } from '@invisibleloop/pulse/ui'
900
+
901
+ view: () => `
902
+ ${nav({
903
+ logo: 'MyApp',
904
+ links: [{ label: 'Docs', href: '/docs' }, { label: 'Pricing', href: '/pricing' }],
905
+ action: button({ label: 'Sign up', href: '/signup', variant: 'primary', size: 'sm' }),
906
+ })}
907
+ <main id="main-content">
908
+ ${hero({
909
+ eyebrow: 'Now in beta',
910
+ title: 'Build fast. Ship faster.',
911
+ subtitle: 'The framework that gets out of your way.',
912
+ actions: button({ label: 'Get started →', href: '/docs', variant: 'primary', size: 'lg' }),
913
+ })}
914
+ ${section({ content: container({ content: grid({
915
+ cols: 3,
916
+ content: [
917
+ feature({ icon: '⚡', title: 'Fast', description: 'SSR always on.' }),
918
+ feature({ icon: '🛡️', title: 'Safe', description: 'Constraints enforced.' }),
919
+ feature({ icon: '🎯', title: 'Correct', description: '100 Lighthouse.' }),
920
+ ].join(''),
921
+ }) }) })}
922
+ </main>
923
+ `
924
+ ```
925
+
926
+ ## Example page — contact form
927
+
928
+ ```js
929
+ import { nav, section, input, textarea, button, alert, container } from '@invisibleloop/pulse/ui'
930
+ import { layout } from '../components/layout.js'
931
+
932
+ export default {
933
+ meta: {
934
+ title: 'Contact',
935
+ styles: ['/app.css', '/pulse-ui.css'],
936
+ },
937
+ state: {
938
+ status: 'idle',
939
+ errors: [],
940
+ },
941
+ actions: {
942
+ send: {
943
+ onStart: (state, formData) => ({
944
+ status: 'loading',
945
+ name: formData.get('name'),
946
+ email: formData.get('email'),
947
+ message: formData.get('message'),
948
+ }),
949
+ run: async (state, serverState, formData) => {
950
+ const res = await fetch('/api/contact', { method: 'POST', body: formData })
951
+ if (!res.ok) throw new Error(await res.text())
952
+ return await res.json()
953
+ },
954
+ onSuccess: (state, result) => ({ status: 'success' }),
955
+ onError: (state, err) => ({
956
+ status: 'error',
957
+ errors: err?.validation ?? [{ message: err.message }],
958
+ }),
959
+ },
960
+ },
961
+ view: (state) => layout(`
962
+ ${container({ content: `
963
+ ${section({ title: 'Contact Us', subtitle: 'We will get back to you within 24 hours.' })}
964
+ ${state.status === 'success'
965
+ ? alert({ type: 'success', message: 'Message sent!' })
966
+ : `<form data-action="send" class="u-flex u-flex-col u-gap-4">
967
+ ${grid({ cols: 2, gap: 'md', content: `
968
+ ${input({ name: 'name', label: 'Name', placeholder: 'Your name', required: true })}
969
+ ${input({ name: 'email', label: 'Email', type: 'email', placeholder: 'you@example.com', required: true })}
970
+ ` })}
971
+ ${textarea({ name: 'message', label: 'Message', rows: 5, required: true })}
972
+ ${state.errors.length ? alert({ type: 'error', message: state.errors[0].message }) : ''}
973
+ ${button({ label: state.status === 'loading' ? 'Sending…' : 'Send', type: 'submit', variant: 'primary', fullWidth: true })}
974
+ </form>`
975
+ }
976
+ ` })}
977
+ `),
978
+ }
979
+ ```