@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,1371 @@
1
+ /**
2
+ * Pulse — Project scaffolding
3
+ *
4
+ * Creates a minimal Pulse project in the target directory.
5
+ * Includes a working home page with a counter to prove the app runs.
6
+ */
7
+
8
+ import fs from 'fs'
9
+ import path from 'path'
10
+ import { execSync } from 'child_process'
11
+
12
+ const PULSE_PKG = '@invisibleloop/pulse'
13
+
14
+ /**
15
+ * Scaffold a new Pulse project.
16
+ *
17
+ * @param {string} targetDir - Absolute path to the project directory
18
+ * @param {Object} options
19
+ * @param {string} [options.name] - Project name (defaults to directory name)
20
+ * @param {number} [options.port] - Dev server port (defaults to 3000)
21
+ */
22
+ export async function scaffold(targetDir, options = {}) {
23
+ const name = options.name || path.basename(targetDir)
24
+ const port = options.port || 3000
25
+
26
+ fs.mkdirSync(path.join(targetDir, 'src', 'pages'), { recursive: true })
27
+ fs.mkdirSync(path.join(targetDir, 'src', 'components'), { recursive: true })
28
+ fs.mkdirSync(path.join(targetDir, 'public'), { recursive: true })
29
+ fs.mkdirSync(path.join(targetDir, '.claude', 'commands'), { recursive: true })
30
+
31
+ // package.json
32
+ write(targetDir, 'package.json', JSON.stringify({
33
+ name,
34
+ version: '0.1.0',
35
+ type: 'module',
36
+ scripts: {
37
+ dev: 'pulse dev',
38
+ build: 'pulse build',
39
+ start: 'pulse start',
40
+ },
41
+ engines: {
42
+ node: '>=22',
43
+ },
44
+ dependencies: {
45
+ [PULSE_PKG]: 'latest',
46
+ }
47
+ }, null, 2))
48
+
49
+ // pulse.config.js
50
+ write(targetDir, 'pulse.config.js',
51
+ `export default {
52
+ ${port !== 3000 ? ` port: ${port},\n` : ''} // Load test config — all fields optional, shown with defaults.
53
+ // load: {
54
+ // duration: 10, // seconds per test run
55
+ // connections: 10, // concurrent request chains
56
+ // thresholds: {
57
+ // rps: undefined, // minimum requests/sec (optional)
58
+ // p99: undefined, // maximum p99 latency ms (optional)
59
+ // errors: 0, // maximum error count
60
+ // },
61
+ // },
62
+
63
+ // Lighthouse & CWV thresholds — all fields optional, shown with defaults.
64
+ // lighthouse: {
65
+ // performance: 100,
66
+ // accessibility: 100,
67
+ // bestPractices: 100,
68
+ // seo: 100,
69
+ // lcp: 2500, // ms
70
+ // cls: 0.1,
71
+ // tbt: 200, // ms
72
+ // fcp: 1800, // ms
73
+ // si: 3400, // ms
74
+ // inp: 200, // ms
75
+ // },
76
+
77
+ // Per-route overrides — merged on top of global lighthouse/load config.
78
+ // routes: {
79
+ // '/dashboard': {
80
+ // lighthouse: { performance: 85, lcp: 4000 },
81
+ // load: { connections: 5, thresholds: { rps: 20 } },
82
+ // },
83
+ // },
84
+
85
+ // Named environments — for running tests and audits against different targets.
86
+ // Environment names are bespoke — choose whatever suits your project.
87
+ // environments: {
88
+ // local: { url: 'http://localhost:3000', default: true },
89
+ // staging: {
90
+ // url: 'https://staging.myapp.com',
91
+ // headers: { Authorization: \`Bearer \${process.env.STAGING_TOKEN}\` },
92
+ // load: { duration: 30, connections: 50 },
93
+ // lighthouse: { performance: 90 },
94
+ // },
95
+ // production: { url: 'https://myapp.com' },
96
+ // },
97
+ }
98
+ `
99
+ )
100
+
101
+ // Home page — working counter proves the app runs
102
+ write(targetDir, 'src/pages/home.js', homePage(name))
103
+
104
+ // Minimal stylesheet
105
+ write(targetDir, 'public/app.css', baseCSS())
106
+
107
+ // Consumer-facing CLAUDE.md
108
+ write(targetDir, 'CLAUDE.md', claudeMd(name))
109
+
110
+ // Slash commands
111
+ write(targetDir, '.claude/commands/pulse-dev.md', devCmd())
112
+ write(targetDir, '.claude/commands/pulse-stop.md', stopCmd())
113
+ write(targetDir, '.claude/commands/pulse-build.md', buildCmd())
114
+ write(targetDir, '.claude/commands/pulse-start.md', startCmd())
115
+ write(targetDir, '.claude/commands/pulse-report.md', reportCmd())
116
+ write(targetDir, '.claude/commands/pulse-load.md', loadCmd())
117
+ write(targetDir, '.claude/commands/pulse-contribute.md', contributeCmd())
118
+
119
+ // .gitignore
120
+ write(targetDir, '.gitignore', [
121
+ 'node_modules',
122
+ 'public/dist',
123
+ '.pulse-build',
124
+ '.DS_Store',
125
+ ].join('\n') + '\n')
126
+
127
+ console.log(' ✓ Project files created')
128
+
129
+ // Install dependencies
130
+ console.log(' ✓ Installing dependencies...\n')
131
+ try {
132
+ // Use the globally linked package if available (local dev), otherwise npm install
133
+ execSync(`npm link ${PULSE_PKG}`, { cwd: targetDir, stdio: 'inherit' })
134
+ } catch {
135
+ execSync('npm install', { cwd: targetDir, stdio: 'inherit' })
136
+ }
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // File templates
141
+ // ---------------------------------------------------------------------------
142
+
143
+ function homePage(appName) {
144
+ return `\
145
+ export default {
146
+ meta: {
147
+ title: '${appName}',
148
+ description: 'Built with Pulse',
149
+ styles: ['/app.css'],
150
+ },
151
+
152
+ state: {
153
+ count: 0,
154
+ },
155
+
156
+ constraints: {
157
+ count: { min: 0, max: 10 },
158
+ },
159
+
160
+ view: (state) => \`
161
+ <main id="main-content" class="page">
162
+ <h1>${appName}</h1>
163
+ <p>Your Pulse app is running.</p>
164
+
165
+ <div class="counter">
166
+ <button data-event="decrement" \${state.count === 0 ? 'disabled' : ''}>−</button>
167
+ <span class="count">\${state.count}</span>
168
+ <button data-event="increment" \${state.count === 10 ? 'disabled' : ''}>+</button>
169
+ </div>
170
+ </main>
171
+ \`,
172
+
173
+ mutations: {
174
+ increment: (state) => ({ count: state.count + 1 }),
175
+ decrement: (state) => ({ count: state.count - 1 }),
176
+ },
177
+ }
178
+ `
179
+ }
180
+
181
+ function baseCSS() {
182
+ return `\
183
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
184
+
185
+ :root {
186
+ --bg: #111;
187
+ --surface: #1a1a1a;
188
+ --text: #f0f0f0;
189
+ --muted: #888;
190
+ --accent: #9b8dff;
191
+ --accent-btn: #5c4de3;
192
+ --radius: 8px;
193
+ }
194
+
195
+ body {
196
+ font-family: system-ui, sans-serif;
197
+ background: var(--bg);
198
+ color: var(--text);
199
+ line-height: 1.6;
200
+ }
201
+
202
+ .page {
203
+ max-width: 640px;
204
+ margin: 0 auto;
205
+ padding: 3rem 1.5rem;
206
+ }
207
+
208
+ h1 { font-size: 2rem; margin-bottom: 1.5rem; }
209
+ p { color: var(--muted); margin-bottom: 2rem; }
210
+ a { color: var(--accent); }
211
+
212
+ .counter {
213
+ display: flex;
214
+ align-items: center;
215
+ gap: 1rem;
216
+ }
217
+
218
+ .count {
219
+ font-size: 2rem;
220
+ font-weight: 700;
221
+ min-width: 3rem;
222
+ text-align: center;
223
+ }
224
+
225
+ button {
226
+ background: var(--accent-btn);
227
+ color: #fff;
228
+ border: none;
229
+ border-radius: var(--radius);
230
+ padding: 0.5rem 1.25rem;
231
+ font-size: 1.25rem;
232
+ cursor: pointer;
233
+ }
234
+
235
+ button:disabled {
236
+ opacity: 0.3;
237
+ cursor: not-allowed;
238
+ }
239
+
240
+ button:focus-visible {
241
+ outline: 3px solid var(--accent);
242
+ outline-offset: 2px;
243
+ }
244
+
245
+ a:focus-visible {
246
+ outline: 3px solid var(--accent);
247
+ outline-offset: 2px;
248
+ border-radius: 2px;
249
+ }
250
+ `
251
+ }
252
+
253
+ function claudeMd(appName) {
254
+ return `\
255
+ # ${appName} — Pulse App
256
+
257
+ Built with [Pulse](https://github.com/invisibleloop/pulse) — a spec-first, AI-native framework.
258
+
259
+ ## Philosophy
260
+
261
+ **The spec is the source of truth.** You write a plain JS object that describes what a page does — its data, state, mutations, and view. The framework handles routing, SSR, hydration, compression, security headers, and client-side navigation automatically. You never touch any of that.
262
+
263
+ **Performance is non-negotiable.** Every page must meet these targets:
264
+
265
+ | Metric | Target |
266
+ |--------|--------|
267
+ | LCP | < 100ms (localhost) |
268
+ | CLS | 0.00 |
269
+ | Lighthouse Performance | 100 |
270
+ | Lighthouse Accessibility | 100 |
271
+
272
+ These are achieved automatically by the framework (streaming SSR, immutable asset caching, zero layout shift). Do not make changes that compromise them.
273
+
274
+ **No external JavaScript dependencies.** Pulse has no client-side dependencies. Do not install or import React, Vue, Alpine, jQuery, or any other JS library. If you need UI behaviour, express it as mutations and actions in the spec.
275
+
276
+ ## Commands
277
+
278
+ \`\`\`bash
279
+ pulse dev # dev server (port from pulse.config.js, default 3000)
280
+ pulse stop # stop the dev server
281
+ pulse build # production build → public/dist/
282
+ pulse start # production server
283
+ \`\`\`
284
+
285
+ ## Project structure
286
+
287
+ \`\`\`
288
+ src/
289
+ pages/ ← one file per page, auto-discovered
290
+ components/ ← reusable view fragments (JS functions returning HTML strings)
291
+ public/
292
+ app.css ← global stylesheet
293
+ \`\`\`
294
+
295
+ ## Pages
296
+
297
+ Files in \`src/pages/\` are automatically registered as routes.
298
+
299
+ | File | Route |
300
+ |------|-------|
301
+ | \`home.js\` | \`/\` |
302
+ | \`about.js\` | \`/about\` |
303
+ | \`blog/post.js\` | \`/blog/post\` |
304
+
305
+ For dynamic segments, set \`route\` explicitly in the spec:
306
+
307
+ \`\`\`js
308
+ // src/pages/blog/show.js → set route: '/blog/:slug'
309
+ export default {
310
+ route: '/blog/:slug',
311
+ server: {
312
+ post: async (ctx) => fetchPost(ctx.params.slug),
313
+ },
314
+ // ...
315
+ }
316
+ \`\`\`
317
+
318
+ ## The spec
319
+
320
+ \`\`\`js
321
+ export default {
322
+ // route: '/path' — omit to derive from filename. Required for dynamic segments.
323
+
324
+ meta: {
325
+ title: 'Page title',
326
+ description: 'Meta description',
327
+ styles: ['/app.css'],
328
+
329
+ // Structured data — injected as <script type="application/ld+json"> in <head>
330
+ schema: {
331
+ '@context': 'https://schema.org',
332
+ '@type': 'WebPage',
333
+ name: 'Page title',
334
+ },
335
+ },
336
+
337
+ // Server data — resolved before render, passed to view as second arg.
338
+ // ctx: { params, query, headers, cookies }
339
+ server: {
340
+ items: async (ctx) => fetchItems(ctx.query),
341
+ },
342
+
343
+ // Guard — runs before server fetchers on every request to this route.
344
+ // Return { redirect: '/path' } to deny access, or nothing to allow.
345
+ // ctx: { params, query, headers, cookies, pathname, method }
346
+ guard: async (ctx) => {
347
+ if (!ctx.cookies.session) return { redirect: '/login' }
348
+ },
349
+
350
+ // Initial client state
351
+ state: { count: 0 },
352
+
353
+ // Min/max bounds — always enforced after every mutation
354
+ constraints: {
355
+ count: { min: 0, max: 10 },
356
+ },
357
+
358
+ // Persist state keys to localStorage — restored on next visit
359
+ persist: ['count'],
360
+
361
+ // Validation rules — checked when action.validate === true
362
+ validation: {
363
+ 'fields.email': { required: true, format: 'email' },
364
+ 'fields.name': { required: true, minLength: 2 },
365
+ },
366
+
367
+ // Pure function — returns an HTML string
368
+ view: (state, server) => \`<main>...</main>\`,
369
+
370
+ // Synchronous state changes — return partial state to merge
371
+ mutations: {
372
+ increment: (state) => ({ count: state.count + 1 }),
373
+ },
374
+
375
+ // Async operations — form submissions, API calls
376
+ actions: {
377
+ submit: {
378
+ onStart: (state, formData) => ({ status: 'loading' }),
379
+ validate: true,
380
+ run: async (state, serverState, formData) => {
381
+ const res = await fetch('/api/endpoint', { method: 'POST', body: formData })
382
+ return res.json() // returned value is passed to onSuccess as second arg
383
+ },
384
+ onSuccess: (state, result) => ({ status: 'success', data: result }),
385
+ onError: (state, err) => ({
386
+ status: 'error',
387
+ errors: err?.validation ?? [{ message: err.message }],
388
+ }),
389
+ },
390
+ },
391
+ }
392
+ \`\`\`
393
+
394
+ ## HTML event binding
395
+
396
+ \`\`\`html
397
+ <button data-event="increment">+</button> <!-- click → mutation -->
398
+ <input data-event="change:update"> <!-- change event → mutation -->
399
+ <form data-action="submit">...</form> <!-- submit → action, passes FormData -->
400
+ \`\`\`
401
+
402
+ ## Images
403
+
404
+ Never write a bare \`<img>\` tag. Always use the \`img()\` or \`picture()\` helpers — they prevent CLS and handle loading priority correctly.
405
+
406
+ \`\`\`js
407
+ import { img, picture } from '@invisibleloop/pulse/image'
408
+
409
+ // Simple image — lazy loaded, prevents CLS
410
+ img({ src: '/photo.jpg', alt: 'A photo', width: 800, height: 600 })
411
+
412
+ // LCP hero image — eager + high fetchpriority
413
+ img({ src: '/hero.jpg', alt: 'Hero', width: 1200, height: 630, priority: true })
414
+
415
+ // With modern format sources (AVIF/WebP)
416
+ picture({
417
+ src: '/hero.jpg',
418
+ alt: 'Hero',
419
+ width: 1200,
420
+ height: 630,
421
+ priority: true,
422
+ sources: [
423
+ { src: '/hero.avif', type: 'image/avif' },
424
+ { src: '/hero.webp', type: 'image/webp' },
425
+ ]
426
+ })
427
+ \`\`\`
428
+
429
+ Rules:
430
+ - **Always provide \`width\` and \`height\`** — without them the browser can't reserve space and CLS is non-zero
431
+ - **Use \`priority: true\` for the first visible image** (hero, above-fold card) — sets \`loading="eager"\` and \`fetchpriority="high"\` for LCP
432
+ - **All other images** default to \`loading="lazy"\`
433
+ - **Use \`picture()\` when you have AVIF/WebP variants** — AVIF is typically 50% smaller than JPEG
434
+
435
+ ## Embedding videos (oEmbed / YouTube)
436
+
437
+ Never drop a bare YouTube \`<iframe>\` into the view — it loads ~500 KB of scripts immediately and kills LCP. Use the **facade pattern**: fetch oEmbed data in \`server\`, SSR the thumbnail as a priority \`<img>\`, and swap to the real \`youtube-nocookie.com\` iframe only on click via an inline \`<script nonce="\${server.meta.nonce}">\`. Always \`escapeHtml\` oEmbed title/URL values before injecting into HTML attributes.
438
+
439
+ ## Guard (route authorization)
440
+
441
+ \`guard\` runs on every request to a route, before server data is fetched. Returning \`{ redirect: '/path' }\` sends a 302 and skips all fetchers. Returning nothing allows the request to proceed.
442
+
443
+ \`\`\`js
444
+ export default {
445
+ route: '/dashboard',
446
+
447
+ guard: async (ctx) => {
448
+ if (!ctx.cookies.session) return { redirect: '/login' }
449
+ // Role check example:
450
+ // const user = await getUserFromSession(ctx.cookies.session)
451
+ // if (!user?.isAdmin) return { redirect: '/403' }
452
+ },
453
+
454
+ server: {
455
+ profile: async (ctx) => getProfile(ctx.cookies.session),
456
+ },
457
+
458
+ state: {},
459
+ view: (state, server) => \`<main id="main-content"><h1>Welcome, \${server.profile.name}</h1></main>\`,
460
+ }
461
+ \`\`\`
462
+
463
+ Guard also works in reverse — redirect already-authenticated users away from login pages:
464
+
465
+ \`\`\`js
466
+ guard: async (ctx) => {
467
+ if (ctx.cookies.session) return { redirect: '/dashboard' }
468
+ },
469
+ \`\`\`
470
+
471
+ ## ctx methods — setHeader and setCookie
472
+
473
+ Available inside \`guard\`, \`server\` fetchers, and \`render\`. Changes are included in the response automatically.
474
+
475
+ \`\`\`js
476
+ // Set an arbitrary response header
477
+ ctx.setHeader('X-Custom-Header', 'value')
478
+
479
+ // Set a cookie
480
+ ctx.setCookie('session', token, {
481
+ httpOnly: true,
482
+ secure: process.env.NODE_ENV === 'production',
483
+ sameSite: 'Lax',
484
+ maxAge: 86400, // seconds — omit for session cookie
485
+ path: '/', // default
486
+ })
487
+ \`\`\`
488
+
489
+ \`setCookie\` options: \`httpOnly\` (boolean), \`secure\` (boolean), \`sameSite\` ('Lax'|'Strict'|'None'), \`maxAge\` (number), \`path\` (string), \`domain\` (string).
490
+
491
+ Use \`render\` returning \`{ redirect: '/path' }\` for raw response specs that need to redirect (e.g. OAuth callbacks):
492
+
493
+ \`\`\`js
494
+ render: (ctx, server) => {
495
+ if (!server.session) return { redirect: '/auth/login' }
496
+ return { redirect: '/' }
497
+ }
498
+ \`\`\`
499
+
500
+ ## Canonical URLs and trailing slashes
501
+
502
+ A \`<link rel="canonical">\` is injected into every page \`<head>\` automatically. Trailing slash behaviour is controlled by the \`trailingSlash\` option in \`createServer\`:
503
+
504
+ | Value | Behaviour | Canonical form |
505
+ |-------|-----------|----------------|
506
+ | \`"remove"\` (default) | 301 \`/about/\` → \`/about\` | no-slash |
507
+ | \`"add"\` | 301 \`/about\` → \`/about/\` | slash |
508
+ | \`"allow"\` | serve both, no redirect | no-slash |
509
+
510
+ \`\`\`js
511
+ createServer(specs, {
512
+ trailingSlash: 'add', // slash is canonical for this project
513
+ })
514
+ \`\`\`
515
+
516
+ Override the canonical URL for a specific page with \`meta.canonical\`:
517
+
518
+ \`\`\`js
519
+ meta: {
520
+ title: 'My Page',
521
+ canonical: 'https://example.com/my-page',
522
+ }
523
+ \`\`\`
524
+
525
+ ## Raw content responses (RSS, sitemaps, JSON APIs)
526
+
527
+ For non-HTML routes, use \`contentType\` + \`render\` instead of \`view\`. The HTML pipeline is bypassed entirely — no document wrapper, no hydration.
528
+
529
+ \`\`\`js
530
+ // src/pages/feed.js
531
+ export default {
532
+ route: '/feed.xml',
533
+
534
+ // Fetch data server-side — same caching options as pages
535
+ server: {
536
+ posts: async () => fetchRecentPosts(),
537
+ },
538
+
539
+ // Tell the server to serve raw content with this MIME type
540
+ contentType: 'application/rss+xml; charset=utf-8',
541
+
542
+ // Pure function — (ctx, serverData) => string
543
+ render: (ctx, server) => \`<?xml version="1.0" encoding="UTF-8"?>
544
+ <rss version="2.0">
545
+ <channel>
546
+ <title>My Blog</title>
547
+ <link>https://example.com</link>
548
+ <description>Latest posts</description>
549
+ \${server.posts.map(p => \`
550
+ <item>
551
+ <title>\${escXml(p.title)}</title>
552
+ <link>https://example.com/blog/\${p.slug}</link>
553
+ <pubDate>\${new Date(p.date).toUTCString()}</pubDate>
554
+ <description>\${escXml(p.excerpt)}</description>
555
+ </item>\`).join('')}
556
+ </channel>
557
+ </rss>\`,
558
+
559
+ // Cache the rendered XML for 1 hour in HTTP caches
560
+ cache: { public: true, maxAge: 3600, staleWhileRevalidate: 86400 },
561
+
562
+ // Also cache the server data fetch in-process for 5 minutes
563
+ serverTtl: 300,
564
+ }
565
+ \`\`\`
566
+
567
+ Rules:
568
+ - **\`render(ctx, server)\`** is synchronous — do all async work in \`spec.server\`, same as \`view\`
569
+ - **\`state\`, \`view\`, \`mutations\`, \`actions\`** are not used and should be omitted
570
+ - **Always escape special characters** in XML output (\`&\` → \`&amp;\`, \`<\` → \`&lt;\`, \`>\` → \`&gt;\`)
571
+ - **Text, XML, and JSON** responses are compressed automatically (brotli/gzip)
572
+ - Common content types: \`application/rss+xml; charset=utf-8\`, \`application/xml\`, \`application/json\`, \`text/plain\`
573
+
574
+ ## Caching
575
+
576
+ By default HTML responses are served with \`Cache-Control: no-store\`. To enable caching on a route, add \`cache\` and/or \`serverTtl\` to the spec:
577
+
578
+ \`\`\`js
579
+ export default {
580
+ route: '/blog/:slug',
581
+
582
+ // In-process server data cache — fetchers are not called again until TTL expires.
583
+ // The cached result is reused to re-render the page on each request within the window.
584
+ serverTtl: 60, // seconds
585
+
586
+ // HTTP cache headers sent with the HTML response (prod only — dev always sends no-store)
587
+ cache: {
588
+ public: true, // use 'public' (CDN-cacheable); omit or false for 'private'
589
+ maxAge: 60, // max-age in seconds
590
+ staleWhileRevalidate: 3600, // stale-while-revalidate in seconds (optional)
591
+ },
592
+
593
+ // ...
594
+ }
595
+ \`\`\`
596
+
597
+ Rules:
598
+ - **\`serverTtl\`** caches the server data fetch result in-process — not the HTML. The page is re-rendered from cached data on every request, so the response is still dynamic (state, params etc. are live).
599
+ - **\`cache.public\`** marks the response as CDN-cacheable. Only use this on routes where the HTML is safe to share across users.
600
+ - Both settings are **ignored in dev mode** — dev always returns \`Cache-Control: no-store\`.
601
+ - Static assets under \`/dist/\` are always \`immutable, max-age=31536000\` — never override this.
602
+
603
+ ## Keyboard accessibility
604
+
605
+ Every page must be fully navigable by keyboard alone.
606
+
607
+ **Structure**
608
+ - Wrap page content in \`<main id="main-content">\` — the skip link injected by the framework targets this id
609
+ - Use semantic elements: \`<nav>\`, \`<main>\`, \`<header>\`, \`<footer>\`, \`<section>\`, \`<article>\`, \`<aside>\`
610
+ - One \`<h1>\` per page — the primary heading that describes the current page
611
+
612
+ **Interactive elements**
613
+ - Only use \`<button>\` for actions and \`<a href>\` for navigation — never a \`<div>\` or \`<span>\` with a click handler
614
+ - All interactive elements must be reachable by Tab and operable by Enter/Space
615
+ - Buttons that toggle state must have an \`aria-expanded\` or \`aria-pressed\` attribute when appropriate
616
+ - Disabled buttons must use the HTML \`disabled\` attribute — not just a visual style
617
+
618
+ **Focus management**
619
+ - After client-side navigation, focus is moved automatically by the framework (to \`#main-content\`, \`<main>\`, or \`<h1>\`)
620
+ - After a mutation that opens a modal or drawer, move focus to the first interactive element inside it
621
+ - When a modal closes, return focus to the element that opened it
622
+ - Never trap focus outside of intentional modal/dialog patterns (and always provide a close path)
623
+
624
+ **Dynamic content**
625
+ - Status messages (loading, success, error) must use \`role="status"\` or \`role="alert"\` so screen readers announce them
626
+ - Use \`role="alert"\` for errors (assertive) and \`role="status"\` for non-urgent updates (polite)
627
+ - Example: \`<p role="alert">\${state.error}</p>\`
628
+
629
+ **Forms**
630
+ - Every \`<input>\`, \`<select>\`, and \`<textarea>\` must have an associated \`<label>\` (via \`for\`/\`id\` or wrapping)
631
+ - Error messages must be linked to their input using \`aria-describedby\`
632
+ - Required fields must have \`required\` (or \`aria-required="true"\`)
633
+ - Group related inputs with \`<fieldset>\` and \`<legend>\`
634
+
635
+ **Images and icons**
636
+ - Decorative images: \`alt=""\`
637
+ - Informative images: meaningful \`alt\` text
638
+ - Icon-only buttons: \`aria-label\` on the button, \`aria-hidden="true"\` on the icon
639
+
640
+ ## Security defaults
641
+
642
+ Pulse applies the following security measures automatically — no configuration needed:
643
+
644
+ | Feature | Behaviour |
645
+ |---|---|
646
+ | Security headers | Sent on every response: \`X-Content-Type-Options\`, \`X-Frame-Options\`, \`Referrer-Policy\`, \`Permissions-Policy\`, \`Cross-Origin-Opener-Policy\`, \`Cross-Origin-Resource-Policy\` |
647
+ | CSP with nonce | Every HTML response includes a \`Content-Security-Policy\` header with a per-request cryptographic nonce. All inline scripts injected by the framework carry a matching \`nonce\` attribute |
648
+ | HSTS | When a request arrives with \`x-forwarded-proto: https\` or over a TLS socket, \`Strict-Transport-Security: max-age=31536000; includeSubDomains\` is added automatically |
649
+ | SameSite=Lax cookies | Cookies set via \`ctx.setCookie()\` default to \`SameSite=Lax\` — CSRF protection without explicit opt-in |
650
+ | POST gating | POST/PUT/DELETE to a page spec returns 405. Raw response specs (\`contentType\` + \`render\`) accept any method — use these for webhooks |
651
+
652
+ ### Escaping user data in views
653
+
654
+ Import \`escHtml\` to safely embed untrusted data in HTML strings:
655
+
656
+ \`\`\`js
657
+ import { escHtml } from '@invisibleloop/pulse/html'
658
+
659
+ view: (state) => \`
660
+ <p>Hello, \${escHtml(state.username)}</p>
661
+ \`
662
+ \`\`\`
663
+
664
+ **Always use \`escHtml\`** around any value that originates from user input, URL params, or external APIs. Omitting it is an XSS vulnerability.
665
+
666
+ ### Using ctx.nonce in view functions
667
+
668
+ The per-request nonce is available as \`ctx.nonce\` inside \`server\` fetchers and \`guard\`. If a view needs to emit its own inline \`<script>\`, pass the nonce through server data:
669
+
670
+ \`\`\`js
671
+ server: {
672
+ meta: async (ctx) => ({ nonce: ctx.nonce }),
673
+ },
674
+
675
+ view: (state, server) => \`
676
+ <script nonce="\${server.meta.nonce}">console.log('inline ok')</script>
677
+ \`
678
+ \`\`\`
679
+
680
+ Inline scripts without the matching nonce are blocked by the CSP.
681
+
682
+ ## Testing
683
+
684
+ Tests use Node's built-in test runner — no test framework, no extra dependencies. Run with:
685
+
686
+ \`\`\`bash
687
+ node src/pages/home.test.js # single file
688
+ node --test src/**/*.test.js # all tests
689
+ \`\`\`
690
+
691
+ Place test files alongside the spec they test: \`src/pages/home.test.js\` next to \`src/pages/home.js\`.
692
+
693
+ ### Minimal test harness
694
+
695
+ Each test file uses a minimal inline harness (no imports) — \`async function test(label, fn)\` + \`function assert(condition, msg)\`. Call spec functions directly: mutations/view/action stages are pure functions, pass mocks for \`ctx\`. For HTTP tests use \`createServer\` with an incrementing port counter.
696
+
697
+ ### What to test
698
+
699
+ | Thing | Test it? | How |
700
+ |---|---|---|
701
+ | Mutations | Yes — always | Call directly, assert returned state |
702
+ | View functions | Yes — key states | Call directly, assert HTML contains expected content |
703
+ | Server fetchers | Yes — happy path + errors | Mock ctx, assert return shape |
704
+ | Action lifecycles | Yes — each stage | Call onStart/onSuccess/onError directly |
705
+ | Full page HTTP | Yes — smoke test each page | \`withServer\` + \`get()\` |
706
+ | CSS / visual output | No | Covered by Lighthouse audits |
707
+
708
+ ## CSS conventions
709
+
710
+ All styles live in \`public/app.css\`. There is no CSS-in-JS, no scoped styles, no Tailwind.
711
+
712
+ **Token-first** — every colour, radius, and spacing value must come from a CSS custom property defined in \`:root\`. Never write raw hex values or magic numbers in component rules.
713
+
714
+ \`\`\`css
715
+ /* wrong */
716
+ .card { background: #1a1a1a; border-radius: 8px; color: #888; }
717
+
718
+ /* right */
719
+ .card { background: var(--surface); border-radius: var(--radius); color: var(--muted); }
720
+ \`\`\`
721
+
722
+ **Modifier pattern** — write one base class and extend it with modifiers. Never create two parallel classes that do the same thing with different values.
723
+
724
+ \`\`\`css
725
+ /* wrong — two separate button classes */
726
+ .btn-primary { display: inline-flex; padding: .65rem 1.4rem; background: var(--accent); ... }
727
+ .btn-ghost { display: inline-flex; padding: .65rem 1.4rem; background: transparent; ... }
728
+
729
+ /* right — shared base, modifier overrides only what changes */
730
+ .btn { display: inline-flex; align-items: center; gap: .5rem; padding: .65rem 1.4rem; border-radius: var(--radius); font-weight: 600; text-decoration: none; transition: all .15s; }
731
+ .btn--primary { background: var(--accent); color: var(--bg); }
732
+ .btn--ghost { background: transparent; color: var(--text); border: 1px solid var(--border); }
733
+ \`\`\`
734
+
735
+ **Utility classes for repeated patterns** — if the same combination of properties appears on three or more elements, extract it into a named utility. Common candidates:
736
+
737
+ \`\`\`css
738
+ /* label utility — uppercase, small, muted, tracked */
739
+ .label { font-size: .75rem; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); }
740
+ \`\`\`
741
+
742
+ **No duplication** — before writing a new class, check \`app.css\` for an existing one that covers it. If a class already exists, use it. If it almost fits, extend it with a modifier — never copy-paste and rename.
743
+
744
+ **Dead code** — if a class is no longer referenced in any component, remove it from \`app.css\`.
745
+
746
+ ## Development workflow
747
+
748
+ Follow these steps in order. Do not skip steps or reorder them.
749
+
750
+ ### Creating a new page
751
+
752
+ 1. **Inventory** — run \`pulse_list_structure\` to see what pages and components already exist. Reuse anything that fits rather than creating from scratch.
753
+ 2. **Plan** — draft the spec mentally. If the user asked to preview first, output it as a code block and wait for confirmation before continuing.
754
+ 3. **Validate** — run \`pulse_validate\` on the spec before writing any file. Fix all validation errors before proceeding.
755
+ 4. **Write the spec** — call \`pulse_create_page\`. Register it in \`server.js\`.
756
+ 5. **Write tests** — create \`src/pages/[name].test.js\`. Cover: all mutations, view output for key states, server fetcher shape, action lifecycle stages, and an HTTP smoke test.
757
+ 6. **Run tests** — \`node src/pages/[name].test.js\`. All must pass before continuing.
758
+ 7. **Restart the dev server** — run \`/pulse-dev\` (new files require a restart; hot reload only covers edits to existing files).
759
+ 8. **Lighthouse audit** — run a full audit on the new route. All four scores must be **100**. Fix any failures before marking the task done.
760
+ 9. **Save the report** — save Lighthouse results to the report store via \`pulse save-report\`.
761
+
762
+ ### Creating a new component
763
+
764
+ 1. **Inventory** — run \`pulse_list_structure\`. If a similar component exists, extend it rather than creating a new one.
765
+ 2. **Plan** — if the user asked to preview, show the component as a code block and wait for confirmation.
766
+ 3. **Write the component** — call \`pulse_create_component\`.
767
+ 4. **Write tests** — create \`src/components/[name].test.js\`. Test the render function output for all meaningful states.
768
+ 5. **Run tests** — \`node src/components/[name].test.js\`. All must pass.
769
+ 6. **Restart the dev server** — run \`/pulse-dev\`.
770
+ 7. **Lighthouse audit** — audit every page that uses the component. All scores must remain **100**.
771
+ 8. **Save the report** — save results for each audited route.
772
+
773
+ ### Editing an existing page or component
774
+
775
+ 1. **Read first** — read the current file before making any changes. Never edit blind.
776
+ 2. **Check tests** — read the existing test file to understand what is already covered.
777
+ 3. **Edit** — make the change.
778
+ 4. **Run tests** — run the test file for the changed spec. All must pass.
779
+ 5. **Lighthouse audit** — if the change affects rendered output, run a full audit. All scores must remain **100**.
780
+ 6. **Save the report** — if an audit was run, save the results.
781
+
782
+ ### Adding CSS
783
+
784
+ 1. **Read \`public/app.css\` first** — check for an existing class or token that covers the need.
785
+ 2. **Extend before adding** — use a modifier on an existing class if possible.
786
+ 3. **Tokens only** — never write raw hex values or magic numbers. Use \`var(--token)\`.
787
+ 4. **Add the class** — write it in \`app.css\`, not inline in the component.
788
+ 5. **Check for dead code** — if a class was replaced or renamed, remove the old one.
789
+
790
+ ## Component library
791
+
792
+ Import from \`@invisibleloop/pulse/ui\`. Add \`pulse-ui.css\` to \`meta.styles\`.
793
+
794
+ \`\`\`js
795
+ import { button, card, input, alert, badge, stat, avatar, empty, table, select, textarea } from '@invisibleloop/pulse/ui'
796
+
797
+ meta: { styles: ['/pulse-ui.css', '/app.css'] }
798
+ \`\`\`
799
+
800
+ ### Components
801
+
802
+ | Component | Props | Notes |
803
+ |---|---|---|
804
+ | \`button\` | \`label\`, \`variant\` (primary/secondary/ghost/danger), \`size\` (sm/md/lg), \`href\`, \`disabled\`, \`type\`, \`icon\`, \`iconAfter\`, \`fullWidth\`, \`class\`, \`attrs\` | Renders \`<a>\` when \`href\` set, \`<button>\` otherwise |
805
+ | \`badge\` | \`label\`, \`variant\` (default/success/warning/error/info), \`class\` | Inline status label |
806
+ | \`card\` | \`title\`, \`content\`, \`footer\`, \`flush\`, \`class\` | \`content\` and \`footer\` are HTML strings — escape user data before passing |
807
+ | \`input\` | \`name\`, \`label\`, \`type\`, \`placeholder\`, \`value\`, \`error\`, \`hint\`, \`required\`, \`disabled\`, \`id\`, \`class\`, \`attrs\` | Label/error wired via \`for\`/\`aria-describedby\` automatically |
808
+ | \`select\` | \`name\`, \`label\`, \`options\` (strings or \`{value,label}\`), \`value\`, \`error\`, \`hint\`, \`required\`, \`disabled\`, \`id\`, \`class\` | |
809
+ | \`textarea\` | \`name\`, \`label\`, \`placeholder\`, \`value\`, \`rows\`, \`error\`, \`hint\`, \`required\`, \`disabled\`, \`id\`, \`class\`, \`attrs\` | |
810
+ | \`alert\` | \`variant\` (info/success/warning/error), \`title\`, \`content\`, \`class\` | \`error\`/\`warning\` use \`role="alert"\`; \`info\`/\`success\` use \`role="status"\` |
811
+ | \`stat\` | \`label\`, \`value\`, \`change\`, \`trend\` (up/down/neutral), \`class\` | |
812
+ | \`avatar\` | \`src\`, \`alt\`, \`size\` (sm/md/lg/xl), \`initials\`, \`class\` | Renders \`<img>\` with src, \`<span>\` with initials fallback |
813
+ | \`empty\` | \`title\`, \`description\`, \`action\` (\`{label,href,variant}\`), \`class\` | |
814
+ | \`table\` | \`headers\`, \`rows\` (2D array of HTML strings), \`caption\`, \`class\` | Scroll wrapper has \`role="region"\` + \`tabindex="0"\` |
815
+
816
+ ### Rules
817
+
818
+ - **Check for an existing component first.** Run \`pulse_list_structure\` before creating a new UI element. If a component covers the need, use it — do not recreate it inline.
819
+ - **Theming is CSS-only.** Override \`--ui-*\` custom properties in \`:root\` in \`app.css\`. Never pass \`style=""\` to a component.
820
+ - **Extension is modifier classes.** Add new variants with a CSS class (e.g. \`.ui-btn--brand\`). Never fork or modify a component source file.
821
+ - **User data must be escaped before passing.** \`content\`, \`footer\`, and \`rows\` in \`card\`/\`table\` accept HTML strings — they are not automatically escaped. Use \`escHtml()\` from \`@invisibleloop/pulse/html\` on any user-supplied content before passing it.
822
+ - **Missing variant → safe fallback.** All components fall back to their default variant when an unknown value is passed — they never throw.
823
+
824
+ ## When Pulse doesn't have a built-in pattern
825
+
826
+ If asked to implement something with no direct Pulse equivalent, identify which escape hatch fits before reaching for an external library:
827
+
828
+ | Need | Approach |
829
+ |---|---|
830
+ | Middleware — logging, rate limiting, IP blocking, custom headers | \`onRequest\` hook in \`createServer\` |
831
+ | Non-HTML responses — JSON APIs, webhooks, RSS | Raw response spec (\`contentType\` + \`render\`) |
832
+ | WebSockets | \`server.on('upgrade')\` on the instance returned by \`createServer\` |
833
+ | Server-Sent Events | \`onRequest\` — write \`text/event-stream\` response and return \`false\` |
834
+ | Custom error pages | \`onError\` hook in \`createServer\` |
835
+ | Browser-only behaviour | Inline \`<script nonce="\${server.meta.nonce}">\` in the view |
836
+
837
+ If none of these cover the requirement, explain the limitation honestly. Do not introduce client-side JS frameworks or npm packages to work around a missing Pulse feature.
838
+
839
+ ## Environments
840
+
841
+ \`pulse.config.js\` supports named environments for running tests and audits against different targets:
842
+
843
+ \`\`\`js
844
+ environments: {
845
+ local: { url: 'http://localhost:3000', default: true },
846
+ staging: {
847
+ url: 'https://staging.myapp.com',
848
+ headers: { Authorization: \`Bearer \${process.env.STAGING_TOKEN}\` },
849
+ load: { duration: 30, connections: 50 },
850
+ lighthouse: { performance: 90 },
851
+ },
852
+ production: { url: 'https://myapp.com' },
853
+ }
854
+ \`\`\`
855
+
856
+ - **Environment names are bespoke** — choose names that fit the project (e.g. \`local\`, \`staging\`, \`prod\`, \`preview\`)
857
+ - **\`url\`** — base URL to test against; if it contains \`localhost\` or \`127.0.0.1\`, a local production build and temporary server are used automatically; remote URLs are tested directly
858
+ - **\`default: true\`** — the environment used when none is specified; if no default is set the agent asks the user to choose
859
+ - **\`headers\`** — HTTP headers sent with every request (useful for auth tokens on protected environments); always read values from \`process.env\` — never hardcode credentials
860
+ - **\`load\`** and **\`lighthouse\`** — per-environment threshold overrides; merged on top of global config and below per-route overrides
861
+
862
+ Threshold merge order: **global config → environment override → per-route override**
863
+
864
+ ## Edge cases
865
+
866
+ ### Routing
867
+ - **Static vs dynamic route conflicts** — if \`/blog/new\` and \`/blog/:slug\` both exist, the static route wins. Register static routes before dynamic ones in \`server.js\`.
868
+ - **Route params vs query params** — \`:param\` segments are in \`ctx.params\`; \`?key=value\` strings are in \`ctx.query\`. Never mix them.
869
+ - **Trailing slashes** — the default \`trailingSlash: 'remove'\` setting 301-redirects \`/about/\` → \`/about\`. Do not create routes with trailing slashes.
870
+
871
+ ### State & mutations
872
+ - **Shallow merge** — mutations return a partial state object that is shallow-merged. To update a nested field, spread the parent: \`{ form: { ...state.form, email } }\`.
873
+ - **Mutation returning undefined** — a mutation that falls through without returning silently skips the state update. Every code path must return a partial state object.
874
+ - **Constraints apply after every mutation** — transient out-of-bounds values between two mutations are not possible. Constraints clamp immediately after each one.
875
+
876
+ ### Actions
877
+ - **FormData in run()** — fields are available in both \`onStart\` and \`run\`. However, read \`File\` entries to \`ArrayBuffer\` at the start of \`run\` before any \`await\` — file references can be dropped across async boundaries in some environments.
878
+ - **run() return value** — whatever \`run()\` returns is passed as the second argument to \`onSuccess\`. If \`run()\` returns nothing, \`onSuccess\` receives \`undefined\`. Always \`return\` the response.
879
+ - **Redirect after action** — return \`{ redirect: '/path' }\` from \`onSuccess\` to navigate without a full page reload.
880
+ - **Validation error shape** — \`onError\` receives either an error with \`err.validation\` (array of \`{ field, message }\`) when validation fails, or a plain \`Error\` at runtime. Always handle both: \`err?.validation ?? [{ message: err.message }]\`.
881
+
882
+ ### Server fetchers
883
+ - **\`cache.public\` on user-specific routes** — a CDN will cache one user's response and serve it to everyone. Only use \`cache.public: true\` on routes where the HTML is identical for all visitors.
884
+ - **Parallel fetchers** — multiple keys in \`server:\` run in parallel. If one fetcher depends on another's result, combine them into a single fetcher using \`Promise.all\`.
885
+ - **Fetcher throwing** — an unhandled throw goes to \`onError\` in \`createServer\`. Catch expected errors inside the fetcher and return a meaningful value (\`null\`, empty array) rather than throwing.
886
+
887
+ ### Streaming
888
+ - **Segment names must match exactly** — if \`stream.deferred\` lists \`['feed']\`, the \`view\` object must have a key \`feed\`. A mismatch means the segment never resolves.
889
+ - **Shell is already sent when a deferred segment throws** — wrap deferred segment logic in try/catch and render a graceful error state rather than throwing.
890
+ - **Shell-only streaming** — \`stream: { shell: ['header'], deferred: [] }\` is valid. The shell streams immediately; the rest renders normally.
891
+
892
+ ### Security
893
+ - **Inline \`style=""\` attributes are blocked by the default CSP** — \`style-src 'self'\` blocks all inline style attributes. Use CSS classes. If dynamic inline styles are genuinely needed, use a \`<style nonce="...">\` block and pass \`ctx.nonce\` through a server fetcher.
894
+ - **\`SameSite=None\` requires \`Secure\`** — browsers silently ignore \`SameSite=None\` cookies without \`Secure: true\`. Only use \`None\` for cross-site embedding, and only in production over HTTPS.
895
+ - **\`guard\` throwing vs returning** — a throw inside \`guard\` goes to \`onError\`. A returned \`{ redirect }\` sends a clean 302. Always catch auth/database errors inside guard and return a redirect rather than rethrowing.
896
+ - **Redirect loops** — if \`/dashboard\` guards redirect to \`/login\`, and \`/login\` guards redirect authenticated users to \`/dashboard\`, test both directions to confirm there is no loop.
897
+
898
+ ### Performance & Lighthouse
899
+ - **Missing \`<main id="main-content">\`** — the framework injects a skip link targeting \`#main-content\` on every page. A missing element breaks the skip link and fails Lighthouse Accessibility.
900
+ - **Multiple \`<h1>\` elements** — one \`<h1>\` per page. Multiple at the same level fail Lighthouse Accessibility.
901
+ - **Images without \`width\` and \`height\`** — omitting dimensions prevents the browser from reserving space, causing layout shift and a non-zero CLS. Always provide both.
902
+ - **Colour contrast** — \`#888\` on \`#111\` is the practical minimum for muted text that passes WCAG AA. Lighter greys will fail. Check new colour tokens before committing.
903
+ - **Missing \`meta.description\`** — every page needs one. Omitting it fails Lighthouse SEO.
904
+
905
+ ### Raw response specs
906
+ - **\`state\`, \`view\`, \`mutations\`, \`actions\` are ignored** on raw response specs (\`contentType\` + \`render\`). Do not include them.
907
+ - **\`render\` is synchronous** — all async work must happen in \`server\` fetchers. \`render(ctx, server)\` receives already-resolved data.
908
+
909
+ ## Rules the agent must follow
910
+
911
+ - **Never set \`hydrate\`** — the framework sets it automatically.
912
+ - **Always wrap page content in \`<main id="main-content">\`** — the framework injects a skip link targeting this id on every page.
913
+ - **Never use \`data-event\` on text inputs** to mirror value into state. It destroys focus on every keystroke. Use uncontrolled inputs and read values from \`FormData\` in \`action.onStart\` instead. For client-side filtering/search, render all items in the HTML and use an inline \`<script>\` to show/hide elements — no state or re-render needed.
914
+ - **Never add \`<script>\` tags manually** — hydration is handled by the framework.
915
+ - **Never add external npm packages** for client-side behaviour — express it in the spec.
916
+ - **Always use \`pulse_list_structure\`** before creating pages or components to avoid duplicating what already exists.
917
+ - **Always validate with \`pulse_validate\`** before writing a spec file.
918
+ - **Preview on request** — if the user says "preview", "show me first", "draft the spec", or similar, generate the full spec as a code block and ask "Shall I write this?" before calling any \`pulse_create_*\` tool. Do not write any files until the user confirms.
919
+ - **Never edit \`pulse.config.js\` without explicit permission.** When a change to \`pulse.config.js\` is needed, show the exact proposed diff or updated block as a code snippet and ask "Shall I apply this?" before writing. Do not assume that asking to run a report or load test implies permission to change the config.
920
+ - **Never read or write \`.env\` files.** If an environment variable needs to be added or changed, tell the developer the variable name and value to set — do not touch the file. Credentials and secrets belong in the developer's environment, not in the agent's context.
921
+ - **Restart the dev server with \`/pulse-dev\`** after creating or renaming files (hot reload handles edits to existing files).
922
+ - **Always run a Lighthouse audit** after creating or significantly changing a page. Before checking results, read \`pulse.config.js\` and resolve the effective thresholds: start with global \`lighthouse\` config (defaults: all category scores 100; LCP 2500ms, CLS 0.1, TBT 200ms, FCP 1800ms, SI 3400ms, INP 200ms), merge selected environment's \`lighthouse\` overrides (if any), then merge \`routes['/path'].lighthouse\` on top. All scores and metrics must meet their effective threshold. Fix any failures before considering the task done. Common failures to watch for: colour contrast (use \`#888\` minimum for muted text on \`#111\` backgrounds), missing alt text, missing meta description.
923
+ - **Load testing is opt-in** — run \`/pulse-load\` when the user asks, or before shipping a page that fetches server data or handles significant traffic. Read \`pulse.config.js\` for \`load\` thresholds and environment/route overrides before checking results. Results are saved to the same report dashboard under the Load Tests tab.
924
+ - **Environments** — when \`environments\` is configured in \`pulse.config.js\`, select the environment before running \`/pulse-load\` or \`/pulse-report\`: use the \`default: true\` entry automatically (telling the user which one), or ask the user to choose if no default is set. Localhost environments follow the standard local build approach; remote environments are tested directly against their URL.
925
+ - **Always save Lighthouse results** to the report store immediately after every audit — even routine ones run during development. Run Lighthouse via \`npx --yes lighthouse <url> --output json --output-path /tmp/pulse-lhr.json --chrome-flags="--headless=new" --quiet 2>/dev/null\`, then extract all metrics with the node script in \`/pulse-report\` step 4, then run \`pulse save-report --url <url> --data '<extracted json>'\`. This builds the historical record used by \`/pulse-report\`. Do not use \`mcp__chrome-devtools__lighthouse_audit\` for saving reports — it does not return Performance scores or web vitals.
926
+ `
927
+ }
928
+
929
+ function devCmd() {
930
+ return `Start (or restart) the Pulse dev server for this project.
931
+
932
+ \`\`\`bash
933
+ pulse stop; pulse dev
934
+ \`\`\`
935
+
936
+ The server port is read from \`pulse.config.js\` (defaults to 3000).
937
+ `
938
+ }
939
+
940
+ function stopCmd() {
941
+ return `Stop the Pulse dev server for this project.
942
+
943
+ \`\`\`bash
944
+ pulse stop
945
+ \`\`\`
946
+ `
947
+ }
948
+
949
+ function reportCmd() {
950
+ return `Run a Lighthouse audit on a page, save the results, and open the report dashboard.
951
+
952
+ ## Steps
953
+
954
+ 1. Read \`pulse.config.js\` to find the dev port (default 3000), Lighthouse thresholds, and environments.
955
+
956
+ **Resolve the effective environment:**
957
+ - Check if \`environments\` is defined in \`pulse.config.js\`
958
+ - If it is: use the entry marked \`default: true\` automatically (tell the user which one); if no default is set, list all environment names and ask the user to choose
959
+ - If \`environments\` is not defined, no selection is needed — local build approach is used
960
+
961
+ **Resolve effective Lighthouse thresholds** (apply in order, later values win):
962
+ 1. Global \`lighthouse\` config (defaults: all category scores 100; LCP 2500ms, CLS 0.1, TBT 200ms, FCP 1800ms, SI 3400ms, INP 200ms)
963
+ 2. Selected environment's \`lighthouse\` overrides (if any)
964
+ 3. \`routes['/path'].lighthouse\` overrides (if any)
965
+
966
+ 2. List available routes by reading \`src/pages/\` filenames. Present them to the user and ask which page to audit — e.g. \`/about\`. If only one page exists, proceed with it automatically.
967
+
968
+ 3. **If the target URL is localhost** (environment \`url\` contains \`localhost\` or \`127.0.0.1\`, or no environment is configured):
969
+
970
+ a. Check the dev server is running:
971
+ \`\`\`bash
972
+ lsof -ti:<port> > /dev/null 2>&1 && echo "running" || echo "stopped"
973
+ \`\`\`
974
+ If it is not running, start it first with \`/pulse-dev\` and wait for it to be ready.
975
+
976
+ b. Build the project (required for accurate scores — dev mode inflates bundle sizes and skips compression):
977
+ \`\`\`bash
978
+ pulse build
979
+ \`\`\`
980
+
981
+ c. Start a temporary production server on \`devPort + 2\` (keeps the dev server untouched):
982
+ \`\`\`bash
983
+ pulse start --port <devPort+2> &
984
+ \`\`\`
985
+ Wait for it to be ready:
986
+ \`\`\`bash
987
+ node -e "
988
+ const http = require('http')
989
+ const port = <devPort+2>
990
+ const poll = (n) => {
991
+ if (n <= 0) { console.error('prod server did not start'); process.exit(1) }
992
+ http.get('http://localhost:' + port, () => process.exit(0))
993
+ .on('error', () => setTimeout(() => poll(n - 1), 300))
994
+ }
995
+ poll(20)
996
+ "
997
+ \`\`\`
998
+
999
+ d. The audit URL is \`http://localhost:<devPort+2>/<path>\`
1000
+
1001
+ 4. **If the target URL is remote** (e.g. staging or production environment):
1002
+
1003
+ a. The audit URL is \`<envUrl>/<path>\` — use the environment URL directly
1004
+ b. No build or local server steps needed
1005
+
1006
+ 5. Run Lighthouse against the audit URL. If the environment has \`headers\`, pass them via \`--extra-headers\`:
1007
+ \`\`\`bash
1008
+ # No headers:
1009
+ npx --yes lighthouse <auditUrl> --output json --output-path /tmp/pulse-lhr.json --chrome-flags="--headless=new" --quiet 2>/dev/null
1010
+
1011
+ # With headers (JSON object):
1012
+ npx --yes lighthouse <auditUrl> --output json --output-path /tmp/pulse-lhr.json --extra-headers='{"Key":"Value"}' --chrome-flags="--headless=new" --quiet 2>/dev/null
1013
+ \`\`\`
1014
+
1015
+ 6. **If localhost target:** Kill the temporary production server:
1016
+ \`\`\`bash
1017
+ lsof -ti:<devPort+2> | xargs kill -9 2>/dev/null; true
1018
+ \`\`\`
1019
+
1020
+ 7. Parse the Lighthouse JSON and extract all metrics:
1021
+ \`\`\`bash
1022
+ node -e "
1023
+ const lhr = JSON.parse(require('fs').readFileSync('/tmp/pulse-lhr.json', 'utf8'))
1024
+ const s = lhr.categories
1025
+ const a = lhr.audits
1026
+ const rs = (a['resource-summary']?.details?.items) || []
1027
+ const total = rs.find(i => i.resourceType === 'total')
1028
+ const js = rs.find(i => i.resourceType === 'script')
1029
+ const css = rs.find(i => i.resourceType === 'stylesheet')
1030
+ const d = {
1031
+ scores: {
1032
+ performance: Math.round(s.performance.score * 100),
1033
+ accessibility: Math.round(s.accessibility.score * 100),
1034
+ bestPractices: Math.round(s['best-practices'].score * 100),
1035
+ seo: Math.round(s.seo.score * 100),
1036
+ },
1037
+ metrics: {
1038
+ lcp: Math.round(a['largest-contentful-paint'].numericValue),
1039
+ cls: parseFloat(a['cumulative-layout-shift'].numericValue.toFixed(2)),
1040
+ fcp: Math.round(a['first-contentful-paint'].numericValue),
1041
+ tbt: Math.round(a['total-blocking-time'].numericValue),
1042
+ ttfb: Math.round(a['server-response-time'].numericValue),
1043
+ si: Math.round(a['speed-index'].numericValue),
1044
+ pageWeight: total ? parseFloat((total.transferSize/1024).toFixed(1)) : undefined,
1045
+ jsBytes: js ? parseFloat((js.transferSize/1024).toFixed(1)) : undefined,
1046
+ cssBytes: css ? parseFloat((css.transferSize/1024).toFixed(1)) : undefined,
1047
+ requests: total ? total.requestCount : undefined,
1048
+ }
1049
+ }
1050
+ Object.keys(d.metrics).forEach(k => { if (d.metrics[k] === undefined || (typeof d.metrics[k] === 'number' && isNaN(d.metrics[k]))) delete d.metrics[k] })
1051
+ console.log(JSON.stringify(d))
1052
+ "
1053
+ \`\`\`
1054
+
1055
+ 8. Check results against effective thresholds (from step 1). Fail and report any score or metric that falls outside its threshold.
1056
+
1057
+ 9. Save the report — always use the **dev server URL** (\`http://localhost:<devPort>/<path>\`) as the canonical URL so reports are grouped by page, not by environment:
1058
+ \`\`\`bash
1059
+ pulse save-report --url http://localhost:<devPort>/<path> --data '<json from step 7>'
1060
+ \`\`\`
1061
+
1062
+ 10. Start (or restart) the report server and wait until it is ready:
1063
+ \`\`\`bash
1064
+ node -e "
1065
+ import('./pulse.config.js').then(m => {
1066
+ const port = (m.default?.reportPort) || (m.default?.port || 3000) + 1
1067
+ process.stdout.write(String(port))
1068
+ }).catch(() => process.stdout.write('3001'))
1069
+ " | xargs -I{} sh -c '
1070
+ lsof -ti:{} | xargs kill -9 2>/dev/null
1071
+ pulse report-server --port {} &
1072
+ node -e "
1073
+ const http = require(\\"http\\")
1074
+ const port = {}
1075
+ const poll = (n) => {
1076
+ if (n <= 0) process.exit(1)
1077
+ http.get(\\"http://localhost:\\" + port, () => process.exit(0))
1078
+ .on(\\"error\\", () => setTimeout(() => poll(n - 1), 300))
1079
+ }
1080
+ poll(20)
1081
+ "
1082
+ '
1083
+ \`\`\`
1084
+
1085
+ 11. Tell the user their report is ready at \`http://localhost:[devPort+1]\` (e.g. \`http://localhost:3001\`). Include which environment was audited.
1086
+ `
1087
+ }
1088
+
1089
+ function loadCmd() {
1090
+ return `Run a load test, save the results, and open the load report tab.
1091
+
1092
+ ## Steps
1093
+
1094
+ 1. Read \`pulse.config.js\` to find the dev port (default 3000), load config, and environments.
1095
+
1096
+ **Resolve the effective environment:**
1097
+ - Check if \`environments\` is defined in \`pulse.config.js\`
1098
+ - If it is: use the entry marked \`default: true\` automatically (tell the user which one); if no default is set, list all environment names and ask the user to choose
1099
+ - If \`environments\` is not defined, no selection is needed — local build approach is used
1100
+
1101
+ **Merge load config** (apply in order, later values win):
1102
+ \`\`\`js
1103
+ // Defaults:
1104
+ // load.duration = 10 (seconds)
1105
+ // load.connections = 10 (concurrent)
1106
+ // load.thresholds = { rps: undefined, p99: undefined, errors: 0 }
1107
+ \`\`\`
1108
+ Global \`load\` → selected environment's \`load\` overrides → \`routes['/path'].load\` overrides.
1109
+
1110
+ 2. List available routes from \`src/pages/\` and ask the user which route to test. If only one route exists, proceed automatically.
1111
+
1112
+ 3. **If the target URL is localhost** (environment \`url\` contains \`localhost\` or \`127.0.0.1\`, or no environment is configured):
1113
+
1114
+ a. Build the project:
1115
+ \`\`\`bash
1116
+ pulse build
1117
+ \`\`\`
1118
+
1119
+ b. Start a temporary production server on \`devPort + 2\` (keeps the dev server untouched):
1120
+ \`\`\`bash
1121
+ pulse start --port <devPort+2> &
1122
+ \`\`\`
1123
+ Wait for it to be ready:
1124
+ \`\`\`bash
1125
+ node -e "
1126
+ const http = require('http')
1127
+ const port = <devPort+2>
1128
+ const poll = (n) => {
1129
+ if (n <= 0) { console.error('prod server did not start'); process.exit(1) }
1130
+ http.get('http://localhost:' + port, () => process.exit(0))
1131
+ .on('error', () => setTimeout(() => poll(n - 1), 300))
1132
+ }
1133
+ poll(20)
1134
+ "
1135
+ \`\`\`
1136
+
1137
+ c. The test URL is \`http://localhost:<devPort+2>/<path>\`
1138
+
1139
+ 4. **If the target URL is remote** (e.g. staging or production environment):
1140
+
1141
+ a. The test URL is \`<envUrl>/<path>\` — use the environment URL directly
1142
+ b. No build or local server steps needed
1143
+
1144
+ 5. Run the load test. Pass each header from the environment's \`headers\` object as a separate \`--header "Key: Value"\` argument:
1145
+ \`\`\`bash
1146
+ pulse load-test --url <testUrl> --duration <duration> --connections <connections> [--header "Key: Value" ...]
1147
+ \`\`\`
1148
+ This prints a JSON result to stdout. Capture it.
1149
+
1150
+ 6. **If localhost target:** Kill the temporary production server:
1151
+ \`\`\`bash
1152
+ lsof -ti:<devPort+2> | xargs kill -9 2>/dev/null; true
1153
+ \`\`\`
1154
+
1155
+ 7. Check results against effective thresholds (from step 1). Fail and report if:
1156
+ - \`rps\` is below \`thresholds.rps\` (if set)
1157
+ - \`latency.p99\` exceeds \`thresholds.p99\` (if set)
1158
+ - \`requests.errors\` exceeds \`thresholds.errors\` (default: 0)
1159
+
1160
+ 8. Save the result — always use the **dev server URL** (\`http://localhost:<devPort>/<path>\`) as the canonical URL so reports are grouped by page, not by environment:
1161
+ \`\`\`bash
1162
+ pulse save-load-report --url http://localhost:<devPort>/<path> --data '<json from step 5>'
1163
+ \`\`\`
1164
+
1165
+ 9. Start (or restart) the report server and wait until ready:
1166
+ \`\`\`bash
1167
+ node -e "
1168
+ import('./pulse.config.js').then(m => {
1169
+ const port = (m.default?.reportPort) || (m.default?.port || 3000) + 1
1170
+ process.stdout.write(String(port))
1171
+ }).catch(() => process.stdout.write('3001'))
1172
+ " | xargs -I{} sh -c '
1173
+ lsof -ti:{} | xargs kill -9 2>/dev/null
1174
+ pulse report-server --port {} &
1175
+ node -e "
1176
+ const http = require(\\"http\\")
1177
+ const port = {}
1178
+ const poll = (n) => {
1179
+ if (n <= 0) process.exit(1)
1180
+ http.get(\\"http://localhost:\\" + port, () => process.exit(0))
1181
+ .on(\\"error\\", () => setTimeout(() => poll(n - 1), 300))
1182
+ }
1183
+ poll(20)
1184
+ "
1185
+ '
1186
+ \`\`\`
1187
+
1188
+ 10. Tell the user the load report is ready at \`http://localhost:[reportPort]/[slug]/load\` (e.g. \`http://localhost:3001/home/load\`). Include which environment was tested. For localhost results, remind them that numbers are useful for relative comparison across runs — not as production capacity estimates.
1189
+ `
1190
+ }
1191
+
1192
+ function buildCmd() {
1193
+ return `Build this Pulse project for production.
1194
+
1195
+ Run:
1196
+
1197
+ \`\`\`bash
1198
+ pulse build
1199
+ \`\`\`
1200
+
1201
+ This bundles all pages into \`public/dist/\`. When complete, confirm the build succeeded and list the generated files.
1202
+ `
1203
+ }
1204
+
1205
+ function startCmd() {
1206
+ return `Start the Pulse production server for this project.
1207
+
1208
+ First ensure a build exists (\`public/dist/\`). Then run:
1209
+
1210
+ \`\`\`bash
1211
+ pulse start
1212
+ \`\`\`
1213
+
1214
+ The production server starts at http://localhost:3000.
1215
+ `
1216
+ }
1217
+
1218
+ function contributeCmd() {
1219
+ return `Implement a Pulse framework-level feature and open a pull request to the main repository.
1220
+
1221
+ Use this when the current project needs something Pulse does not yet support, and the right fix is to add it to the framework itself rather than work around it.
1222
+
1223
+ ## Step 1 — Describe and scope the change
1224
+
1225
+ Before touching any code, write a one-paragraph description of:
1226
+ - What the feature does
1227
+ - Which part of the framework needs to change (spec schema, server, SSR, client runtime, CLI, or build)
1228
+ - Why it cannot be done with existing escape hatches (\`onRequest\`, raw response spec, \`onError\`, inline \`<script nonce>\`)
1229
+
1230
+ Show this to the developer and confirm before proceeding.
1231
+
1232
+ ## Step 2 — Locate the framework source
1233
+
1234
+ Check for the framework repo in this order:
1235
+
1236
+ \`\`\`bash
1237
+ # 1. Already cloned alongside this project?
1238
+ ls ../pulse2/src 2>/dev/null && echo "found at ../pulse2"
1239
+
1240
+ # 2. Available elsewhere on disk?
1241
+ find ~ -maxdepth 5 -name "pulse2" -type d 2>/dev/null | head -3
1242
+
1243
+ # 3. Clone it if not found
1244
+ gh repo clone invisibleloop/pulse /tmp/pulse-contrib
1245
+ \`\`\`
1246
+
1247
+ Use whichever path is found. All subsequent steps run from that directory.
1248
+
1249
+ ## Step 3 — Read before writing
1250
+
1251
+ Read the files relevant to your change. Match the existing patterns exactly — do not introduce new patterns without a strong reason.
1252
+
1253
+ | Adding | Read first |
1254
+ |--------|-----------|
1255
+ | New spec property | \`src/spec/schema.js\`, \`src/server/index.js\`, \`src/runtime/ssr.js\` |
1256
+ | New server behaviour | \`src/server/index.js\` |
1257
+ | New client behaviour | \`src/runtime/index.js\`, \`src/runtime/navigate.js\` |
1258
+ | New CLI command | \`src/cli/index.js\` and one existing command file for style reference |
1259
+ | New build option | \`scripts/build.js\` |
1260
+ | Scaffold change | \`src/cli/scaffold.js\` |
1261
+
1262
+ Also read the existing test file(s) for each file you will change, so your tests follow the same style.
1263
+
1264
+ ## Step 4 — Create a branch
1265
+
1266
+ \`\`\`bash
1267
+ cd <framework-path>
1268
+ git checkout main
1269
+ git pull origin main
1270
+ git checkout -b feat/<short-description>
1271
+ \`\`\`
1272
+
1273
+ Use \`feat/\`, \`fix/\`, \`docs/\` prefixes matching the change type.
1274
+
1275
+ ## Step 5 — Implement
1276
+
1277
+ Make the smallest change that fully implements the feature. Constraints:
1278
+
1279
+ - **Match the existing code style exactly** — spacing, naming, comment style, error message format.
1280
+ - **No new runtime dependencies.** The server and client runtime are zero-dependency. esbuild is the only dev dependency.
1281
+ - **Schema changes must be backward-compatible.** New spec properties must be optional.
1282
+ - **Error messages must be actionable.** Say what was wrong and what to provide instead.
1283
+ - **Security headers are on by default.** Any new HTTP response path must include the full security header set.
1284
+
1285
+ ### Checklist for adding a spec property
1286
+
1287
+ - [ ] \`src/spec/schema.js\` — add property definition, type check, and validation error message
1288
+ - [ ] \`src/server/index.js\` — read the property and pass it to the renderer
1289
+ - [ ] \`src/runtime/ssr.js\` — render it into the HTML output
1290
+ - [ ] \`src/runtime/index.js\` — handle on the client if there is client-side behaviour
1291
+ - [ ] All relevant test files updated
1292
+
1293
+ ## Step 6 — Write tests
1294
+
1295
+ Add tests in the \`.test.js\` file alongside each file changed. Use Node's built-in test runner:
1296
+
1297
+ \`\`\`js
1298
+ import { test } from 'node:test'
1299
+ import assert from 'node:assert/strict'
1300
+ \`\`\`
1301
+
1302
+ Cover the happy path, invalid input with the correct error message, and edge cases. Do not delete or modify existing tests unless the change intentionally breaks backward compatibility (confirm with the developer first).
1303
+
1304
+ ## Step 7 — Run all tests
1305
+
1306
+ \`\`\`bash
1307
+ cd <framework-path>
1308
+ npm test
1309
+ \`\`\`
1310
+
1311
+ All tests must pass. Fix any failures before continuing.
1312
+
1313
+ ## Step 8 — Commit
1314
+
1315
+ \`\`\`bash
1316
+ git add -p
1317
+ git commit -m "feat: <short description>
1318
+
1319
+ <one or two sentences on what was added and why>"
1320
+ \`\`\`
1321
+
1322
+ ## Step 9 — Push and open a PR
1323
+
1324
+ \`\`\`bash
1325
+ git push origin feat/<short-description>
1326
+
1327
+ gh pr create \\
1328
+ --title "feat: <short description>" \\
1329
+ --body "$(cat <<'EOF'
1330
+ ## What
1331
+
1332
+ <1–3 sentences describing what this adds or fixes.>
1333
+
1334
+ ## Why
1335
+
1336
+ <1–2 sentences on the motivation — what could not be done before, or what was broken.>
1337
+
1338
+ ## Changes
1339
+
1340
+ - \`src/spec/schema.js\` — <what changed>
1341
+ - \`src/server/index.js\` — <what changed>
1342
+
1343
+ ## Tests
1344
+
1345
+ - [ ] All existing tests pass (\`npm test\`)
1346
+ - [ ] New tests added for the happy path
1347
+ - [ ] New tests added for invalid input
1348
+ - [ ] Manually tested in a Pulse project
1349
+
1350
+ ## Notes
1351
+
1352
+ <Any tradeoffs, limitations, or follow-up work worth flagging.>
1353
+ EOF
1354
+ )"
1355
+ \`\`\`
1356
+
1357
+ ## Step 10 — Report back
1358
+
1359
+ Tell the developer the PR URL, a plain-English summary of what changed and which files, and any limitations or follow-up work they should know about.
1360
+ `
1361
+ }
1362
+
1363
+ // ---------------------------------------------------------------------------
1364
+ // Helpers
1365
+ // ---------------------------------------------------------------------------
1366
+
1367
+ function write(dir, relPath, content) {
1368
+ const filePath = path.join(dir, relPath)
1369
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
1370
+ fs.writeFileSync(filePath, content, 'utf8')
1371
+ }