@invisibleloop/pulse 0.20.10 → 0.20.19

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 (266) hide show
  1. package/package.json +6 -5
  2. package/public/.pulse-ui-version +1 -1
  3. package/scripts/build.js +26 -0
  4. package/scripts/release-version.js +17 -8
  5. package/scripts/strip-server.test.js +60 -0
  6. package/src/agent/checklist.md +8 -12
  7. package/src/agent/commands/pulse-start.md +2 -0
  8. package/src/agent/commands/verify.md +30 -8
  9. package/src/agent/guide-components.md +7 -25
  10. package/src/agent/guide-routing.md +0 -12
  11. package/src/agent/guide-server.md +102 -1
  12. package/src/agent/guide-spec.md +8 -1
  13. package/src/agent/guide-styles.md +23 -0
  14. package/src/agent/identity.md +9 -0
  15. package/src/agent/workflow.md +4 -23
  16. package/src/cli/check-bundles.js +113 -0
  17. package/src/cli/check-bundles.test.js +137 -0
  18. package/src/cli/cli.test.js +105 -0
  19. package/src/cli/dev.js +48 -3
  20. package/src/cli/diagnose.js +105 -0
  21. package/src/cli/diagnose.test.js +124 -0
  22. package/src/cli/discover.js +9 -2
  23. package/src/cli/index.js +171 -9
  24. package/src/cli/process-utils.js +57 -0
  25. package/src/cli/process-utils.test.js +114 -0
  26. package/src/cli/review.js +391 -0
  27. package/src/cli/review.test.js +143 -0
  28. package/src/cli/scaffold.js +9 -4
  29. package/src/cli/stamp.js +102 -0
  30. package/src/cli/stamp.test.js +149 -0
  31. package/src/cli/start.js +15 -2
  32. package/src/cli/validate.js +184 -0
  33. package/src/cli/validate.test.js +153 -0
  34. package/src/mcp/mcp.test.js +261 -30
  35. package/src/mcp/server.js +207 -515
  36. package/src/mcp/validate-worker.js +18 -1
  37. package/src/runtime/index.js +144 -11
  38. package/src/runtime/runtime.test.js +199 -0
  39. package/src/runtime/ssr.js +14 -7
  40. package/src/runtime/ssr.test.js +31 -0
  41. package/src/server/index.js +163 -10
  42. package/src/server/server.test.js +225 -2
  43. package/src/spec/schema.js +1 -1
  44. package/src/ui/index.js +0 -5
  45. package/src/ui/ui.test.js +0 -75
  46. package/types/ui.d.ts +0 -21
  47. package/.claude/commands/build-page.md +0 -63
  48. package/.claude/commands/new-doc-page.md +0 -45
  49. package/.claude/commands/pulse-start.md +0 -11
  50. package/.claude/commands/pulse-stop.md +0 -5
  51. package/.claude/commands/verify.md +0 -118
  52. package/.claude/pulse-checklist.md +0 -189
  53. package/.github/workflows/ci.yml +0 -25
  54. package/.github/workflows/publish.yml +0 -45
  55. package/.pulse/load-reports/home/1773432711417.json +0 -22
  56. package/.pulse-verified +0 -1
  57. package/AGENTS.md +0 -697
  58. package/CLAUDE.md +0 -697
  59. package/README.md +0 -95
  60. package/benchmark/public/dist/counter.boot-PO6SNSJZ.js +0 -8
  61. package/benchmark/public/dist/home.boot-YK6VAEQI.js +0 -6
  62. package/benchmark/public/dist/manifest.json +0 -5
  63. package/benchmark/public/dist/runtime-5Y7ESAIB.js +0 -2
  64. package/benchmark/public/dist/runtime-KO4BHUQ3.js +0 -49
  65. package/benchmark/results/2026-03-27-09-20.json +0 -188
  66. package/benchmark/results/2026-03-27-09-21.json +0 -188
  67. package/benchmark/results/2026-03-27-09-29.json +0 -188
  68. package/benchmark/results/2026-03-27-10-14.json +0 -188
  69. package/benchmark/results/2026-03-27-10-18.json +0 -232
  70. package/benchmark/src/pages/counter.js +0 -37
  71. package/benchmark/src/pages/home.js +0 -21
  72. package/docs/.claude/commands/pulse-build.md +0 -7
  73. package/docs/.claude/commands/pulse-dev.md +0 -7
  74. package/docs/.claude/commands/pulse-start.md +0 -9
  75. package/docs/.claude/commands/pulse-stop.md +0 -5
  76. package/docs/.claude/commands/verify.md +0 -84
  77. package/docs/.claude/coverage-check.js +0 -196
  78. package/docs/.claude/pulse-checklist.md +0 -189
  79. package/docs/public/.pulse-ui-version +0 -1
  80. package/docs/public/andypixel.css +0 -237
  81. package/docs/public/bookworms.css +0 -422
  82. package/docs/public/coutumes.css +0 -561
  83. package/docs/public/docs.css +0 -2388
  84. package/docs/public/favicon.svg +0 -4
  85. package/docs/public/home-brut.css +0 -982
  86. package/docs/public/images/bookworms/hero-books.jpg +0 -0
  87. package/docs/public/images/bookworms/how-we-read-now.jpg +0 -0
  88. package/docs/public/images/bookworms/lantern-keepers.jpg +0 -0
  89. package/docs/public/images/bookworms/provincial-bookseller.jpg +0 -0
  90. package/docs/public/images/journey/178.jpg +0 -0
  91. package/docs/public/images/journey/29.jpg +0 -0
  92. package/docs/public/images/retrowheels/hero.jpg +0 -0
  93. package/docs/public/images/shop/devbook-pro.jpg +0 -0
  94. package/docs/public/images/suzukibento/1015.jpg +0 -0
  95. package/docs/public/images/suzukibento/1025.jpg +0 -0
  96. package/docs/public/images/suzukibento/1043.jpg +0 -0
  97. package/docs/public/images/suzukibento/1059.jpg +0 -0
  98. package/docs/public/images/suzukibento/1062.jpg +0 -0
  99. package/docs/public/images/suzukibento/1084.jpg +0 -0
  100. package/docs/public/images/swish/1469334031218-e382a71b716b.jpg +0 -0
  101. package/docs/public/images/swish/1483985988355-763728e1935b.jpg +0 -0
  102. package/docs/public/images/swish/1485518882345-15568b007407.jpg +0 -0
  103. package/docs/public/images/swish/1490481651871-ab68de25d43d.jpg +0 -0
  104. package/docs/public/images/swish/1496747611176-843222e1e57c.jpg +0 -0
  105. package/docs/public/images/swish/1502716119720-b23a93e5fe1b.jpg +0 -0
  106. package/docs/public/images/swish/1539109136881-3be0616acf4b.jpg +0 -0
  107. package/docs/public/images/swish/1551803091-e20673f15770.jpg +0 -0
  108. package/docs/public/images/swish/1558769132-cb1aea458c5e.jpg +0 -0
  109. package/docs/public/images/swish/1572804013309-59a88b7e92f1.jpg +0 -0
  110. package/docs/public/img/heic1501a.jpg +0 -0
  111. package/docs/public/img/placeholder-landscape.svg +0 -18
  112. package/docs/public/img/placeholder-square.svg +0 -14
  113. package/docs/public/img/placeholder-tall.svg +0 -22
  114. package/docs/public/journey.css +0 -528
  115. package/docs/public/menu.js +0 -327
  116. package/docs/public/pulse-ui.css +0 -3028
  117. package/docs/public/pulse-ui.js +0 -257
  118. package/docs/public/retrowheels.css +0 -425
  119. package/docs/public/shop.css +0 -269
  120. package/docs/public/sparkys.css +0 -382
  121. package/docs/public/suzukibento.css +0 -517
  122. package/docs/public/swish.css +0 -394
  123. package/docs/public/theme.css +0 -52
  124. package/docs/public/themes/lumio.css +0 -50
  125. package/docs/public/themes/observatory.css +0 -104
  126. package/docs/server.js +0 -161
  127. package/docs/src/lib/component-page.js +0 -49
  128. package/docs/src/lib/highlight.js +0 -2
  129. package/docs/src/lib/layout.js +0 -190
  130. package/docs/src/lib/metrics-store.js +0 -6
  131. package/docs/src/lib/nav.js +0 -171
  132. package/docs/src/lib/stats.js +0 -81
  133. package/docs/src/pages/accessibility.js +0 -157
  134. package/docs/src/pages/actions.js +0 -194
  135. package/docs/src/pages/auth.js +0 -176
  136. package/docs/src/pages/caching.js +0 -95
  137. package/docs/src/pages/components/accordion.js +0 -48
  138. package/docs/src/pages/components/alert.js +0 -35
  139. package/docs/src/pages/components/app-badge.js +0 -41
  140. package/docs/src/pages/components/avatar.js +0 -35
  141. package/docs/src/pages/components/badge.js +0 -36
  142. package/docs/src/pages/components/breadcrumbs.js +0 -94
  143. package/docs/src/pages/components/button.js +0 -84
  144. package/docs/src/pages/components/card.js +0 -225
  145. package/docs/src/pages/components/charts.js +0 -283
  146. package/docs/src/pages/components/checkbox.js +0 -129
  147. package/docs/src/pages/components/cluster.js +0 -47
  148. package/docs/src/pages/components/container.js +0 -40
  149. package/docs/src/pages/components/cta.js +0 -54
  150. package/docs/src/pages/components/divider.js +0 -37
  151. package/docs/src/pages/components/empty.js +0 -36
  152. package/docs/src/pages/components/feature.js +0 -60
  153. package/docs/src/pages/components/fieldset.js +0 -65
  154. package/docs/src/pages/components/fileupload.js +0 -128
  155. package/docs/src/pages/components/footer.js +0 -58
  156. package/docs/src/pages/components/grid.js +0 -169
  157. package/docs/src/pages/components/heading.js +0 -107
  158. package/docs/src/pages/components/hero.js +0 -123
  159. package/docs/src/pages/components/icons.js +0 -410
  160. package/docs/src/pages/components/image.js +0 -71
  161. package/docs/src/pages/components/input.js +0 -51
  162. package/docs/src/pages/components/media.js +0 -51
  163. package/docs/src/pages/components/modal.js +0 -111
  164. package/docs/src/pages/components/nav.js +0 -136
  165. package/docs/src/pages/components/phone-frame.js +0 -74
  166. package/docs/src/pages/components/progress.js +0 -102
  167. package/docs/src/pages/components/prose.js +0 -111
  168. package/docs/src/pages/components/pullquote.js +0 -71
  169. package/docs/src/pages/components/radio.js +0 -194
  170. package/docs/src/pages/components/rating.js +0 -106
  171. package/docs/src/pages/components/search.js +0 -61
  172. package/docs/src/pages/components/section.js +0 -98
  173. package/docs/src/pages/components/segmented.js +0 -121
  174. package/docs/src/pages/components/select.js +0 -45
  175. package/docs/src/pages/components/slider.js +0 -115
  176. package/docs/src/pages/components/spinner.js +0 -73
  177. package/docs/src/pages/components/stack.js +0 -48
  178. package/docs/src/pages/components/stat.js +0 -55
  179. package/docs/src/pages/components/stepper.js +0 -66
  180. package/docs/src/pages/components/table.js +0 -45
  181. package/docs/src/pages/components/textarea.js +0 -31
  182. package/docs/src/pages/components/toggle.js +0 -84
  183. package/docs/src/pages/components/tooltip.js +0 -48
  184. package/docs/src/pages/components.js +0 -200
  185. package/docs/src/pages/config.js +0 -193
  186. package/docs/src/pages/constraints.js +0 -100
  187. package/docs/src/pages/deploy.js +0 -233
  188. package/docs/src/pages/error-pages.js +0 -99
  189. package/docs/src/pages/extending.js +0 -198
  190. package/docs/src/pages/faq.js +0 -99
  191. package/docs/src/pages/forms.js +0 -129
  192. package/docs/src/pages/getting-started.js +0 -147
  193. package/docs/src/pages/guard.js +0 -120
  194. package/docs/src/pages/home.js +0 -345
  195. package/docs/src/pages/how-it-works.js +0 -244
  196. package/docs/src/pages/hydration.js +0 -108
  197. package/docs/src/pages/images.js +0 -120
  198. package/docs/src/pages/markdown.js +0 -156
  199. package/docs/src/pages/meta.js +0 -193
  200. package/docs/src/pages/mutations.js +0 -107
  201. package/docs/src/pages/navigation.js +0 -85
  202. package/docs/src/pages/performance.js +0 -157
  203. package/docs/src/pages/persist.js +0 -89
  204. package/docs/src/pages/project-structure.js +0 -91
  205. package/docs/src/pages/prompt-examples.js +0 -186
  206. package/docs/src/pages/raw-responses.js +0 -122
  207. package/docs/src/pages/routing.js +0 -120
  208. package/docs/src/pages/server-api.js +0 -291
  209. package/docs/src/pages/server-data.js +0 -203
  210. package/docs/src/pages/slash-commands.js +0 -55
  211. package/docs/src/pages/spec.js +0 -207
  212. package/docs/src/pages/state.js +0 -102
  213. package/docs/src/pages/store.js +0 -203
  214. package/docs/src/pages/streaming.js +0 -107
  215. package/docs/src/pages/stripe.js +0 -194
  216. package/docs/src/pages/supabase.js +0 -324
  217. package/docs/src/pages/templates/andypixel.js +0 -260
  218. package/docs/src/pages/templates/blog-post.js +0 -226
  219. package/docs/src/pages/templates/bookworms.js +0 -227
  220. package/docs/src/pages/templates/coutumes.js +0 -237
  221. package/docs/src/pages/templates/journey.js +0 -186
  222. package/docs/src/pages/templates/mobile-app.js +0 -371
  223. package/docs/src/pages/templates/retrowheels.js +0 -355
  224. package/docs/src/pages/templates/shop.js +0 -268
  225. package/docs/src/pages/templates/sparkys.js +0 -264
  226. package/docs/src/pages/templates/suzukibento.js +0 -200
  227. package/docs/src/pages/templates/swish.js +0 -313
  228. package/docs/src/pages/templates.js +0 -534
  229. package/docs/src/pages/testing.js +0 -199
  230. package/docs/src/pages/validation.js +0 -138
  231. package/examples/.claude/commands/pulse-build.md +0 -7
  232. package/examples/.claude/commands/pulse-dev.md +0 -7
  233. package/examples/.claude/commands/pulse-start.md +0 -9
  234. package/examples/.claude/commands/pulse-stop.md +0 -5
  235. package/examples/.claude/commands/verify.md +0 -84
  236. package/examples/.claude/coverage-check.js +0 -196
  237. package/examples/.claude/pulse-checklist.md +0 -189
  238. package/examples/andypixel.css +0 -237
  239. package/examples/andypixel.js +0 -258
  240. package/examples/bookworms.css +0 -422
  241. package/examples/bookworms.js +0 -225
  242. package/examples/contact.js +0 -166
  243. package/examples/counter.js +0 -94
  244. package/examples/dev.server.js +0 -110
  245. package/examples/examples.test.js +0 -394
  246. package/examples/journey.css +0 -528
  247. package/examples/journey.js +0 -186
  248. package/examples/pricing.js +0 -262
  249. package/examples/products.js +0 -190
  250. package/examples/public/.pulse-ui-version +0 -1
  251. package/examples/public/pulse-ui.css +0 -3028
  252. package/examples/public/pulse-ui.js +0 -257
  253. package/examples/quiz.js +0 -208
  254. package/examples/retrowheels.css +0 -425
  255. package/examples/retrowheels.js +0 -353
  256. package/examples/shared.js +0 -84
  257. package/examples/suzukibento.css +0 -513
  258. package/examples/suzukibento.js +0 -200
  259. package/examples/swish.css +0 -394
  260. package/examples/swish.js +0 -311
  261. package/examples/todos.js +0 -162
  262. package/src/ui/fieldset.js +0 -37
  263. package/src/ui/phone-frame.js +0 -40
  264. package/src/ui/search.js +0 -78
  265. package/src/ui/segmented.js +0 -50
  266. package/src/ui/tooltip.js +0 -28
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invisibleloop/pulse",
3
- "version": "0.20.10",
3
+ "version": "0.20.19",
4
4
  "type": "module",
5
5
  "description": "AI-first frontend framework. The spec is the source of truth.",
6
6
  "license": "MIT",
@@ -61,10 +61,10 @@
61
61
  "scripts": {
62
62
  "dev": "node --watch src/cli/dev.js",
63
63
  "build": "node scripts/build.js",
64
- "test": "node src/spec/schema.test.js && node src/runtime/runtime.test.js && node src/runtime/navigate.test.js && node src/runtime/morph.test.js && node src/runtime/ssr.test.js && node src/runtime/image.test.js && node src/server/server.test.js && node src/cli/cli.test.js && node src/ui/ui.test.js && node src/testing/testing.test.js && node src/store/store.test.js && node src/md/md.test.js && node scripts/strip-server.test.js && node scripts/css-purge.test.js && node src/mcp/mcp.test.js",
64
+ "test": "node src/spec/schema.test.js && node src/runtime/runtime.test.js && node src/runtime/navigate.test.js && node src/runtime/morph.test.js && node src/runtime/ssr.test.js && node src/runtime/image.test.js && node src/server/server.test.js && node src/cli/cli.test.js && node src/cli/validate.test.js && node src/cli/diagnose.test.js && node src/cli/check-bundles.test.js && node src/cli/review.test.js && node src/cli/process-utils.test.js && node src/cli/stamp.test.js && node src/ui/ui.test.js && node src/testing/testing.test.js && node src/store/store.test.js && node src/md/md.test.js && node scripts/strip-server.test.js && node scripts/css-purge.test.js && node src/mcp/mcp.test.js",
65
65
  "typecheck": "tsc --noEmit",
66
- "docs": "node --watch src/cli/dev.js --root docs --port 4000",
67
- "docs:start": "node docs/server.js"
66
+ "docs": "node --watch src/cli/dev.js --root ../../docs --port 4000",
67
+ "docs:start": "node ../../docs/server.js"
68
68
  },
69
69
  "engines": {
70
70
  "node": ">=22"
@@ -75,7 +75,8 @@
75
75
  },
76
76
  "repository": {
77
77
  "type": "git",
78
- "url": "https://github.com/invisibleloop/pulse-framework"
78
+ "url": "https://github.com/invisibleloop/pulse-framework",
79
+ "directory": "packages/pulse"
79
80
  },
80
81
  "publishConfig": {
81
82
  "access": "public"
@@ -1 +1 @@
1
- 0.20.10
1
+ 0.20.19
package/scripts/build.js CHANGED
@@ -59,6 +59,24 @@ function stripServerOnlyKeys(source) {
59
59
  return source
60
60
  }
61
61
 
62
+ /**
63
+ * A page needs a client boot bundle only if it needs hydration — matches
64
+ * discover.js's needsHydration() exactly (mutations || actions || persist ||
65
+ * store). Call this on source that has already had stripServerOnlyKeys/Imports
66
+ * applied, and that has already been confirmed to have a `view:` key
67
+ * (a view alone is necessary but not sufficient — a purely server-rendered
68
+ * page, e.g. a redirect handler or a spec.submit-only form, has a view and
69
+ * no hydration need at all).
70
+ *
71
+ * `store` counts even with no local mutations/actions: mount() is what wires
72
+ * up data-store-event dispatch AND the live SSE store-push subscription — a
73
+ * store-only page with no client bundle would silently never receive
74
+ * pushStore() broadcasts and have a dead data-store-event button.
75
+ */
76
+ function needsClientBundle(strippedSource) {
77
+ return /\b(mutations|actions|persist|store)\s*:/.test(strippedSource)
78
+ }
79
+
62
80
  /**
63
81
  * Strip import statements for server-only modules and any top-level
64
82
  * variable declarations whose right-hand side calls the imported names.
@@ -456,6 +474,14 @@ const bootstrapFiles = pages.flatMap(({ filePath }) => {
456
474
  const source = fs.readFileSync(filePath, 'utf8')
457
475
  const stripped = stripServerOnlyImports(stripServerOnlyKeys(source))
458
476
  if (!/\bview\s*:/.test(stripped)) return []
477
+ // A view alone doesn't need hydration — see needsClientBundle's doc comment.
478
+ // Without this, a purely server-rendered page (a redirect handler, a custom
479
+ // 404, any spec.submit-only page) got a client bundle generated anyway —
480
+ // wasted build output that's silently never referenced, and worse, a real
481
+ // build failure if that page imports a local server-only helper module
482
+ // (e.g. a persistence layer using node:fs) that esbuild then tries to
483
+ // bundle for the browser.
484
+ if (!needsClientBundle(stripped)) return []
459
485
 
460
486
  // Use the path relative to src/pages/ so nested pages get unique names.
461
487
  // e.g. src/pages/api/products.js → 'api--products' (not 'products')
@@ -2,29 +2,38 @@
2
2
  /**
3
3
  * Determine the next version from conventional commits.
4
4
  *
5
- * Reads all commits since the last "chore: release X.Y.Z [skip ci]" commit,
6
- * applies conventional commit rules to determine the bump level, then prints
7
- * the new version string to stdout.
5
+ * Reads all commits since the last "chore: release X.Y.Z [skip ci]" commit
6
+ * that touched packages/pulse, applies conventional commit rules to
7
+ * determine the bump level, then prints the new version string to stdout.
8
8
  *
9
9
  * Bump rules:
10
10
  * BREAKING CHANGE footer or "type!:" prefix → major
11
11
  * feat: → minor
12
12
  * anything else (fix, perf, chore, docs…) → patch
13
13
  *
14
- * Usage: node scripts/release-version.js
14
+ * Scoped to `-- packages/pulse`: this repo is a monorepo where sibling
15
+ * packages could share the same git history — without the path filter, a
16
+ * commit that only touches a sibling package would incorrectly drive a
17
+ * version bump for the @invisibleloop/pulse framework package.
18
+ *
19
+ * Usage: node scripts/release-version.js (run from packages/pulse/)
15
20
  */
16
21
 
17
22
  import { execSync } from 'child_process'
18
23
  import { readFileSync } from 'fs'
24
+ import path from 'path'
19
25
 
20
- const pkg = JSON.parse(readFileSync('package.json', 'utf8'))
26
+ const PKG_DIR = path.resolve(import.meta.dirname, '..')
27
+ const pkg = JSON.parse(readFileSync(path.join(PKG_DIR, 'package.json'), 'utf8'))
21
28
  const [major, minor, patch] = pkg.version.split('.').map(Number)
22
29
 
23
- // Collect commit subjects since the last release commit.
24
- // git log outputs newest-first; we stop at the first release commit we see.
30
+ // Collect commit subjects since the last release commit that touched this
31
+ // package. git log outputs newest-first; we stop at the first release
32
+ // commit we see. Must be run with cwd inside the repo (any subdirectory) —
33
+ // `-- packages/pulse` is resolved relative to the repo root by git itself.
25
34
  let commits = []
26
35
  try {
27
- const log = execSync('git log --pretty=format:%s', { encoding: 'utf8' }).trim()
36
+ const log = execSync('git log --pretty=format:%s -- packages/pulse', { encoding: 'utf8' }).trim()
28
37
  for (const line of log.split('\n')) {
29
38
  if (/^chore: release \d+\.\d+\.\d+/.test(line)) break
30
39
  if (line) commits.push(line)
@@ -50,6 +50,11 @@ function strip(source) {
50
50
  return stripServerOnlyImports(stripServerOnlyKeys(source))
51
51
  }
52
52
 
53
+ // Mirrors needsClientBundle() in build.js exactly — see its doc comment there.
54
+ function needsClientBundle(strippedSource) {
55
+ return /\b(mutations|actions|persist|store)\s*:/.test(strippedSource)
56
+ }
57
+
53
58
  function isInsideString(source, pos) {
54
59
  let i = 0
55
60
  const stack = []
@@ -425,6 +430,61 @@ test('subpath imports (/ui) are NOT stripped by the root entry',
425
430
  `import { button } from '@invisibleloop/pulse/ui'\nexport default {\n route: '/x',\n view: () => button({ label: 'x' }),\n}`
426
431
  )
427
432
 
433
+ // ---------------------------------------------------------------------------
434
+ // needsClientBundle — must match discover.js's needsHydration() exactly.
435
+ // Regression: found via dogfooding. This check used to be "does the spec have
436
+ // a `view:` key" alone, so a purely server-rendered page (a redirect handler,
437
+ // a custom 404, a spec.submit-only form) still got a client bundle generated —
438
+ // and if that page imported a local server-only helper module (e.g. a
439
+ // node:fs-based persistence layer), the production build failed outright when
440
+ // esbuild tried to bundle Node built-ins for the browser.
441
+ // ---------------------------------------------------------------------------
442
+
443
+ console.log('\nneedsClientBundle — matches discover.js hydration logic\n')
444
+
445
+ function boolTest(label, input, expected) {
446
+ const got = needsClientBundle(strip(input))
447
+ if (got === expected) {
448
+ console.log(' ✓ ' + label)
449
+ pass++
450
+ } else {
451
+ console.log(' ✗ ' + label)
452
+ console.log(' expected: ' + expected)
453
+ console.log(' got: ' + got)
454
+ fail++
455
+ }
456
+ }
457
+
458
+ boolTest('a view-only spec (no mutations/actions/persist) does not need a bundle — the exact bug this fixes',
459
+ `export default {\n route: '/:code',\n guard: async (ctx) => {},\n view: () => '',\n}`,
460
+ false
461
+ )
462
+
463
+ boolTest('a spec.submit-only page does not need a bundle',
464
+ `export default {\n route: '/',\n submit: async (ctx) => ({ redirect: '/' }),\n view: () => '',\n}`,
465
+ false
466
+ )
467
+
468
+ boolTest('mutations require a bundle',
469
+ `export default {\n route: '/x',\n state: {},\n mutations: {\n inc: (s) => ({ n: s.n + 1 }),\n },\n view: () => '',\n}`,
470
+ true
471
+ )
472
+
473
+ boolTest('actions require a bundle',
474
+ `export default {\n route: '/x',\n state: {},\n actions: {\n save: { run: async () => {} },\n },\n view: () => '',\n}`,
475
+ true
476
+ )
477
+
478
+ boolTest('persist requires a bundle',
479
+ `export default {\n route: '/x',\n state: { count: 0 },\n persist: ['count'],\n view: () => '',\n}`,
480
+ true
481
+ )
482
+
483
+ boolTest('store alone (no local mutations/actions) requires a bundle — regression: mount() is what wires up data-store-event dispatch AND the live SSE store-push subscription; a store-only page with no client bundle would silently never receive pushStore() broadcasts and have a dead data-store-event button',
484
+ `export default {\n route: '/x',\n store: ['count'],\n state: {},\n view: (state, server) => \`\${server.count}\`,\n}`,
485
+ true
486
+ )
487
+
428
488
  // ---------------------------------------------------------------------------
429
489
 
430
490
  console.log(`\n${pass + fail} tests: ${pass} passed, ${fail} failed\n`)
@@ -2,6 +2,8 @@
2
2
 
3
3
  Before finishing any spec, verify every point below. Fix anything that fails.
4
4
 
5
+ **Most of these are already mechanically checked** by `pulse_review` (auto-checked items: hex colours, tabindex, `data-event` on inputs, React patterns, main landmark, XSS test presence, modal-state pattern, CSRF token presence, `_storeUpdate` shape, creative-override detection). Where a rule is auto-checked, this file states *why* the rule exists and how to judge edge cases (e.g. "is this a real creative override?") — it does not restate the mechanical check itself. Rules with no entry below in "Auto-checked" are prose-only — nothing catches them but you.
6
+
5
7
  ### Critical
6
8
 
7
9
  - **`meta` must be a plain object — never a function.** Individual fields (`title`, `description`, `styles`) can be `async (ctx) => value` functions, but `meta` itself is always `{}`.
@@ -37,15 +39,9 @@ Before finishing any spec, verify every point below. Fix anything that fails.
37
39
  - Navigation → `nav({ logo, links, actions })`
38
40
  - Footers → `footer({ logo, links, columns, legal, ... })`
39
41
 
40
- - **Creative override — raw HTML throughout is permitted** when the design intent genuinely calls for it: a highly custom visual identity, an asymmetric or typographically-driven layout, a brutalist/editorial/retro/neon/paper vibe where components would constrain the expression, or a layout that requires features a component simply doesn't support (e.g. full-viewport height, custom gradient glows, clamp-scaled display type). This is a deliberate design decision, not a shortcut. When taking this path:
41
- - **Declare it in a comment at the top of the spec** so the reviewer recognises it: `// component-free — creative override: <reason>`. This is the signal the review tool reads.
42
- - Functional atoms (`button`, `input`, `badge`, `modal`) should still come from components unless there is a specific design reason not to.
43
- - `/pulse-ui.css` is always required — it provides the token system your custom CSS will use.
44
- - The quality gate replaces the component checklist: **Lighthouse 100 on Accessibility, Best Practices, and SEO — desktop and mobile — plus CLS 0.00 is the pass bar.** (Performance is reported but not gated; it varies with machine load.) A component-free page that passes every audit is correct. A component-heavy page that fails accessibility is not.
45
-
46
- - **When reviewing**: before flagging a class name like `.hero` as a violation, first ask whether the `hero()` component could actually reproduce the layout. If the answer is no, it is a creative override — check Lighthouse instead of refactoring. Do not flatten a good custom layout into a generic component just to clear a pattern-match.
42
+ - **Creative override — raw HTML throughout is permitted** when the design intent genuinely calls for it: a highly custom visual identity, an asymmetric or typographically-driven layout, a brutalist/editorial/retro/neon/paper vibe where components would constrain the expression, or a layout that requires features a component simply doesn't support (e.g. full-viewport height, custom gradient glows, clamp-scaled display type). This is a deliberate design decision, not a shortcut. **Declare it in a comment at the top of the spec** — `// component-free — creative override: <reason>` — that's the only signal `pulse_review` reads to switch component checks from failures to advisory. Functional atoms (`button`, `input`, `badge`, `modal`) should still come from components unless there's a specific design reason not to. The quality gate replaces the component checklist: Lighthouse 100 on Accessibility/Best Practices/SEO, desktop and mobile, plus CLS 0.00.
47
43
 
48
- - **The review tool auto-detects creative override** from the spec comment. When detected, component pattern checks are shown as advisory (`⚡`) not as failures, and the Lighthouse gate is the stated pass bar.
44
+ - **The judgment call, not the mechanics:** before flagging a class name like `.hero` as a violation, ask whether the `hero()` component could actually reproduce the layout. If no, it's a legitimate creative override — check Lighthouse instead of refactoring. Don't flatten a good custom layout into a generic component just to clear a pattern-match.
49
45
 
50
46
  - **Never write App Store or Google Play download buttons by hand.** Always use `appBadge({ store: 'apple', href })` and `appBadge({ store: 'google', href })`. Raw `<a>` tags with badge images are incorrect — `appBadge` renders the correct accessible, styled badge for each store.
51
47
 
@@ -76,7 +72,7 @@ Before finishing any spec, verify every point below. Fix anything that fails.
76
72
  - `persist` contains only serialisable state that should survive a page reload — not ephemeral UI state like a loading flag or temporary selection.
77
73
  - `e.target` assumptions in mutations are safe if the element has child nodes — use `e.target.closest('[data-index]')` rather than assuming `e.target` is the element with the attribute.
78
74
  - State shape is consistent — avoid a single field that is sometimes `null`, sometimes a string, sometimes a boolean. Use a dedicated `status` field instead.
79
- - **Never use `state.modalOpen` or conditional modal rendering.** This destroys the `<dialog>` on every render, breaking focus, animation, and native ESC handling. Instead, always render the `<dialog>` in the DOM and use `data-dialog-open="id"` to open it — the runtime handles this without any spec state:
75
+ - **Never use `state.modalOpen` or conditional modal rendering** (auto-checked). This destroys the `<dialog>` on every render, breaking focus, animation, and native ESC handling. Instead, always render the `<dialog>` in the DOM and use `data-dialog-open="id"` to open it — the runtime handles this without any spec state:
80
76
  ```html
81
77
  <!-- always in the view, never conditional -->
82
78
  ${modal({ id: 'confirm', title: 'Confirm', content: '...' })}
@@ -105,14 +101,14 @@ Before finishing any spec, verify every point below. Fix anything that fails.
105
101
 
106
102
  ### CSS
107
103
 
108
- - **`app.css` must contain no hex values or raw colour values.** A lint hook enforces this and will block the build. Hex values belong in `public/theme.css` as token definitions; `app.css` references them via `var()` only.
104
+ - **`app.css` must contain no hex values or raw colour values** (auto-checked, lint hook also blocks the build). Hex values belong in `public/theme.css` as token definitions; `app.css` references them via `var()` only.
109
105
  - **Load order in `meta.styles`:** `pulse-ui.css` → `theme.css` → `app.css`. Theme tokens must be defined before `app.css` references them.
110
106
  - **CSS for a shared section lives in the shared `app.css`, once.** Every spec that renders the section lists the same file in `meta.styles`. Never copy a CSS block into a second per-page stylesheet — two copies drift apart. Per-page stylesheets are for styles only one page uses.
111
107
 
112
108
  ### Security
113
109
 
114
110
  - Any value from user input (URL params, form fields, external APIs) interpolated into view HTML must be escaped.
115
- - **Every `<form method="POST">` on a `submit` page includes `${server.csrf}`.** CSRF is enforced — a form without the hidden token field gets 403 on submit. Never set `csrf: false` to "fix" a 403; that disables the protection. `csrf: false` is only for endpoints with their own authentication (e.g. signed webhooks).
111
+ - **Every `<form method="POST">` on a `submit` page includes `${server.csrf}`** (auto-checked). CSRF is enforced — a form without the hidden token field gets 403 on submit. Never set `csrf: false` to "fix" a 403; that disables the protection. `csrf: false` is only for endpoints with their own authentication (e.g. signed webhooks).
116
112
  - **`submit` returns `{ redirect }` after a successful mutation** (POST-redirect-GET). Rendering success directly from the POST means browser refresh resubmits the form.
117
113
 
118
114
  ### Server-side forms & error pages
@@ -162,7 +158,7 @@ Before finishing any spec, verify every point below. Fix anything that fails.
162
158
 
163
159
  ### Store updates from actions
164
160
 
165
- - **Use `_storeUpdate` to push changes to the global store from an action.** Return it from `onSuccess` alongside the local state update — it is stripped from page state and forwarded to the store. All mounted pages that subscribe to the affected keys re-render immediately:
161
+ - **Use `_storeUpdate` to push changes to the global store from an action.** Return it from `onSuccess` alongside the local state update — it is stripped from page state and forwarded to the store. It must be an object, not a primitive (auto-checked). All mounted pages that subscribe to the affected keys re-render immediately:
166
162
  ```js
167
163
  onSuccess: (state, theme) => ({
168
164
  saved: true,
@@ -9,3 +9,5 @@ pulse start --port 3001
9
9
  If `pulse.config.js` sets a custom `port`, use that value + 1 instead.
10
10
 
11
11
  > **Why the flag:** plain `pulse start` binds the configured port (3000) because in real deployment it is the only server and platforms inject `PORT`. Locally that collides with the dev server and breaks the convention used by `pulse_build`, Lighthouse, and the performance trace — production is always dev port + 1. Never use `--port` in actual deployment.
12
+
13
+ **If the server doesn't come up on the port you expected:** check whether a `PORT` environment variable is set in this shell (`echo $PORT`) — it silently overrides `--port` (correct behavior for real PaaS deployment, where the platform's injected `PORT` must always win, but a stray local `PORT` from a sandbox/CI/shell profile will do the same thing here with no indication why). `pulse start` now warns to stderr when this happens; if you see that warning, either unset `PORT` for this session or work with whatever port it actually bound to.
@@ -40,18 +40,33 @@ Use `mcp__chrome-devtools__navigate_page` to load the page route, then `mcp__chr
40
40
 
41
41
  **Pre-flight (required before every Lighthouse run):**
42
42
  1. Call `pulse_build` to produce a production build and start the production server on port 3001.
43
- 2. Call `navigate_page` with `url: "http://localhost:3001/"` so the browser is on the production server.
43
+ 2. Call `pulse_check_bundles` — reads the actual generated bundle files, not just their sizes. Lighthouse scores can pass on a bundle that shouldn't exist at all (a page with no mutations/actions/persist that got a boot file anyway) or one containing leaked server-only code — neither shows up as a score failure. If it flags anything, fix it and restart from step 3.
44
+ 3. Call `navigate_page` with `url: "http://localhost:3001/"` so the browser is on the production server.
44
45
 
45
46
  Then run `mcp__chrome-devtools__lighthouse_audit` with `{ "device": "desktop" }`.
46
47
 
47
48
  **Pass bar: Accessibility, Best Practices, and SEO must all be 100.** Performance is measured and reported but is not a hard requirement (it varies with machine load). Report the actual scores. If Accessibility, Best Practices, or SEO is below 100, identify the failing audit(s), fix the issue, and restart from step 3.
48
49
 
50
+ Keep the score numbers from this run — step 7a saves both desktop and mobile together.
51
+
49
52
  ### 7. Lighthouse — mobile *(skip in quick mode)*
50
53
 
51
54
  The browser should still be on `http://localhost:3001/` from step 6. Run `mcp__chrome-devtools__lighthouse_audit` with `{ "device": "mobile" }`.
52
55
 
53
56
  **Same pass bar: Accessibility, Best Practices, and SEO must all be 100.** Fix any failures and restart from step 3.
54
57
 
58
+ ### 7a. Save the Lighthouse reports *(skip in quick mode — do not skip this if you ran steps 6–7)*
59
+
60
+ **This is a required step, not optional narrative — a Lighthouse run that passes step 6/7 and skips this one leaves no record anywhere except this conversation.** `pulse report-server` and any dashboard watching `.pulse/reports/` (including a companion app) read only what actually got saved to disk; reporting scores in chat is not the same as saving them, no matter how many times `/verify` passes.
61
+
62
+ Run one `Bash` call per device — **not an MCP tool call**, and run from the project root (the directory containing `pulse.config.js`):
63
+
64
+ ```
65
+ pulse save-report --url "http://localhost:3001/<route>" --data '{"scores":{"performance":<N>,"accessibility":100,"bestPractices":100,"seo":100},"metrics":{"lcp":<N>,"cls":<N>,"fcp":<N>,"tbt":<N>}}'
66
+ ```
67
+
68
+ Use the real numbers from step 6's result for one call and step 7's result for a second call. `<route>` is the page's route (`/` for the homepage). Confirm each call printed `✓ Report saved for ... (slug: ...)` — if it errored, fix the command and retry before moving on; do not silently continue to step 8 with an unsaved report.
69
+
55
70
  **Stay on the production server (port 3001)** — steps 8 and 9 use it too. Do not call `pulse_restart_server` yet.
56
71
 
57
72
  ### 8. Mobile layout check *(skip in quick mode)*
@@ -88,9 +103,7 @@ Use `mcp__chrome-devtools__list_console_messages` — report any errors or unexp
88
103
 
89
104
  ### 11. Code review
90
105
 
91
- Call `pulse_review` with the spec file path. This runs a full code review AND atomically writes the `.pulse-verified` stamp server-side, eliminating any race condition with the stop hook.
92
-
93
- Work through every item the review returns. Fix anything that fails before proceeding.
106
+ Call `pulse_review` with the spec file path. Work through every item the review returns. Fix anything that fails before proceeding.
94
107
 
95
108
  ### 12. Close extra browser tabs
96
109
 
@@ -98,20 +111,29 @@ Use `mcp__chrome-devtools__list_pages` to get all open pages. Close every page *
98
111
 
99
112
  If there is only one page open, skip this step — there is nothing to close.
100
113
 
101
- ### 13. Write verification stamp
114
+ ### 13. Check for unresolved errors on this route
115
+
116
+ Call `pulse_diagnose` with `{ route: "<this page's route>" }`. If it returns any unresolved entries, **do not proceed to step 14** — something threw during this session that hasn't been addressed. Fix the underlying issue and restart from step 3.
117
+
118
+ If it returns none, continue — `pulse_stamp` in the next step auto-resolves any journal entries for this route, since a clean pass this far means whatever was flagged either wasn't real or has already been fixed by an earlier step in this loop.
119
+
120
+ ### 14. Write verification stamp
121
+
122
+ Call `pulse_stamp` with `{ route: "<this page's route>", mode: "full" }` in full mode, or just `{ route: "<this page's route>" }` in quick mode (quick never runs Lighthouse, so there's nothing to check). This writes `.pulse-verified` via the MCP server, which is more reliable than the `date +%s` shell command — the MCP write always lands after all spec edits, avoiding the mtime race condition that can cause the stop hook to block immediately after verification. It also marks any error-journal entries for this route as resolved.
102
123
 
103
- Call `pulse_stamp`. This writes `.pulse-verified` via the MCP server, which is more reliable than the `date +%s` shell command — the MCP write always lands after all spec edits, avoiding the mtime race condition that can cause the stop hook to block immediately after verification.
124
+ **In full mode, `pulse_stamp` refuses to write the stamp unless it finds a Lighthouse report actually saved for this route in the last 10 minutes** (i.e. step 7a genuinely happened, not just step 6/7's scores existing in this conversation). If it refuses, go back and run the missing `pulse save-report` call(s) from step 7a, then call `pulse_stamp` again — do not work around the refusal any other way.
104
125
 
105
126
  **This must be the last operation before stopping.** The stop hook compares each edited spec's mtime against this stamp — if any spec is newer than the stamp, the hook blocks. Do not edit any spec file after calling `pulse_stamp`.
106
127
 
107
- ### 14. Report
128
+ ### 15. Report
108
129
 
109
130
  Summarise:
110
131
  - Validation: pass or fail (with errors if any)
111
132
  - Visual: desktop screenshot — any issues found and fixed
112
133
  - Console: any errors
113
134
  - Review: pass or issues found and fixed
114
- - **Full mode only:** Lighthouse desktop/mobile scores, mobile layout check, LCP and CLS values
135
+ - Error journal: clean, or resolved N entries
136
+ - **Full mode only:** bundle content check (clean, or issues found and fixed), Lighthouse desktop/mobile scores, whether both were saved via `pulse save-report` (step 7a), mobile layout check, LCP and CLS values
115
137
 
116
138
  In quick mode, end with: "Quick check passed — run `/verify` when you're ready for the full Lighthouse audit."
117
139
 
@@ -157,7 +157,7 @@ To add a missing icon: copy a Lucide path (MIT) into `src/ui/icons.js` using `s(
157
157
  | `stat` | `label`, `value`, `change`, `trend` (up/down/neutral), `size` (sm/md/lg — lg for hero KPIs), `center` |
158
158
  | `avatar` | `src`, `alt`, `size` (sm/md/lg/xl), `initials` |
159
159
  | `empty` | `title`, `description`, `action` ({label,href,variant}) |
160
- | `table` | `headers`, `rows` (2D array of HTML strings), `caption` | ⚠ Each cell item must be **raw HTML content** — `table()` wraps each item in `<td>` itself. Do NOT pass `<td>...</td>` strings or you will get double-nested cells. |
160
+ | `table` | `headers`, `rows` (2D array of HTML strings), `caption` | ⚠ Each cell item must be **raw HTML content** — `table()` wraps each item in `<td>` itself. Do NOT pass `<td>...</td>` strings or you will get double-nested cells. Raw HTML you put in a cell (a `<a>` link, a `<button>`, anything) gets **no styling from the component** — `table()` only styles the table structure, not injected content. A bare `<a href="...">text</a>` in a cell falls back to the browser's default link blue, which fails contrast on a dark theme. Style it explicitly (`color: var(--ui-accent)`, `:hover { color: var(--ui-accent-hover) }`) the same as any raw HTML you'd write directly in a view. |
161
161
  | `spinner` | `size` (sm/md/lg), `color` (accent/muted/white), `label` |
162
162
  | `progress` | `value`, `max` (100), `label`, `showLabel`, `showValue`, `variant`, `size` |
163
163
  | `breadcrumbs` | `items` ([{label,href}] — last item has no href), `separator` |
@@ -229,32 +229,14 @@ ${heading({ level: 1, text: 'In Search of Lost Code', class: 'display-heading' }
229
229
 
230
230
  #### When components feel restrictive
231
231
 
232
- **Symptom:** You're fighting a component's wrapper styles (padding, max-width, centering) in your CSS overrides.
232
+ Symptom: fighting a component's wrapper styles (padding, max-width, centering) in CSS overrides. Fix, in order of preference:
233
+ 1. Editorial/typographic page → compose from `heading()`, `prose()`, raw HTML (creative override) instead of forcing `hero()`/`feature()`/`cta()` into that shape.
234
+ 2. Structure diverges significantly (asymmetric grid, full-bleed, custom positioning) → write the outer HTML yourself; still use components for primitives (`button`, `card`, `input`, `badge`).
235
+ 3. Only cosmetic → override with higher specificity in `app.css`: `.my-editorial-hero .ui-hero-inner { max-width: none; padding: 0; }` (component wrappers use single class names like `.ui-hero`).
233
236
 
234
- **Solutions:**
235
- 1. **Use the right approach for your design.** If the page is editorial/typographic, compose from `heading()`, `prose()`, and raw HTML (creative override) instead of trying to force `hero()`, `feature()`, `cta()` into an editorial shape.
236
- 2. **Write custom markup for structural differences.** Components provide sensible defaults for common patterns. When your design structure diverges significantly (asymmetric grid, full-bleed sections, custom positioning), write the HTML directly — that's permitted for layouts. Use components for the UI primitives (`button`, `card`, `input`, `badge`), but write the outer structure yourself.
237
- 3. **Override with higher specificity.** Component wrappers use single class names (`.ui-hero`, `.ui-cta`). Override with a more specific selector in your `app.css`:
238
- ```css
239
- .my-editorial-hero .ui-hero-inner {
240
- max-width: none;
241
- padding: 0;
242
- }
243
- ```
237
+ #### Vibes are a paint job, not a structural transformation
244
238
 
245
- Components are opinionated by design — they enforce accessibility, consistency, and tested patterns. When the design requires breaking those patterns, prefer custom markup over fighting the component.
246
-
247
- #### Important: Vibes affect CSS tokens, not component HTML structure
248
-
249
- The `meta.vibe` setting (`warm`, `editorial`, `playful`, `minimal`, `bold`, `brutalist`, `retro`, `corporate`, `neon`, `paper`) applies CSS variable overrides via `data-vibe` on the `<body>` element. This changes **visual styling** — border radius, font size, letter-spacing, heading weight — but does **not** change the **HTML structure** of components.
250
-
251
- For example:
252
- - `hero()` with `vibe: 'editorial'` still renders eyebrow + title + subtitle + actions (SaaS structure)
253
- - The vibe adjusts the font sizes, tracking, and spacing, but the component HTML is unchanged
254
-
255
- **If you need different HTML structure for an editorial design** — e.g., a large display title + asymmetric layout + drop cap instead of eyebrow/subtitle/actions — write custom markup styled with `heading()`/`prose()` plus utility classes or `app.css`. Do not expect `hero()` to become structurally editorial when `vibe: 'editorial'` is set.
256
-
257
- This is by design: vibes are a paint job, not a structural transformation. Components stay predictable and testable. When the design structure itself differs from the SaaS patterns (centred hero, three-column features, pricing cards), reach for custom layout instead of trying to force `hero()` / `feature()` / `cta()` into shapes they weren't designed for.
239
+ `meta.vibe` (`warm`/`editorial`/`playful`/`minimal`/`bold`/`brutalist`/`retro`/`corporate`/`neon`/`paper`) sets `data-vibe` on `<body>` — changes radius/font-size/letter-spacing/weight tokens only. **Never changes component HTML structure.** `hero({ vibe: 'editorial' })` still renders eyebrow+title+subtitle+actions — vibe ≠ structural editorial layout. For a genuinely different structure (large display title + asymmetric layout + drop cap instead of eyebrow/subtitle/actions), write custom markup with `heading()`/`prose()` + utility classes — don't expect the vibe setting to produce it.
258
240
 
259
241
  ### Landing page components
260
242
 
@@ -89,18 +89,6 @@ Use the global store (`pulse.store.js`) for state that needs to be consistent ac
89
89
 
90
90
  See `pulse://guide/server` for the full store API.
91
91
 
92
- ## Site navigation
93
-
94
- Projects define navigation in src/components/layout.js via a NAV_LINKS array.
95
- 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.
96
-
97
- ```js
98
- const NAV_LINKS = [
99
- { label: 'Home', href: '/' },
100
- { label: 'About', href: '/about' }, // ← add new pages here
101
- ]
102
- ```
103
-
104
92
  ## Page discovery — no registration needed
105
93
 
106
94
  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.
@@ -80,6 +80,8 @@ All pages that declare `spec.store` with the affected keys re-render automatical
80
80
  - Page-level `server` keys win over store keys if there is a name collision
81
81
  - Pages with `spec.store` are never HTML-cached (same rule as pages with `spec.server`)
82
82
 
83
+ **Testing `pulse.store.js` — put the test file next to it, at the project root, not under `src/`.** `npm test`'s glob covers `src/**/*.test.js` plus a root-level `*.test.js` specifically so `pulse.store.test.js` is picked up — a store test placed anywhere under `src/pages/` instead (following the page-test convention out of habit) still runs, but `pulse.store.test.js` itself only runs from the root. Import the store the normal relative way: `import store from '../pulse.store.js'` from `src/pages/`, or `import store from './pulse.store.js'` from the root.
84
+
83
85
  **Reactive updates — no refresh needed**
84
86
 
85
87
  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:
@@ -177,6 +179,87 @@ export default {
177
179
  - Escape user-submitted values before interpolating them into HTML (`escHtml`).
178
180
  - **Progressive enhancement:** add `data-action="send"` to the same form — hydrated visitors get the async action with no page reload; no-JS visitors fall back to the POST.
179
181
  - Multi-instance deployments: pass a stable `secret` to `createServer` (e.g. from an env var) so CSRF tokens issued by one instance validate on another.
182
+ - **On a longer page, a form mid-page needs to scroll the success/error state into view after the redirect.** The 303 lands the browser at the top of the page — a success message rendered next to the form (or anywhere below the fold) is invisible until the visitor scrolls down manually, which they have no reason to do after just submitting. Give the form section an `id`, add `${server.form?.sent ? '<script nonce=\"${server.nonce}\">document.getElementById(\"contact-form\").scrollIntoView()</script>' : ''}` right after it, or simplest: `<form id="contact-form" method="POST">`. Don't skip this on a short/single-purpose page (the form IS the page, nothing to scroll past) — it only matters once the form sits below other content.
183
+
184
+ ## Server-side data persistence — outside the store and `spec.persist`
185
+
186
+ Neither of Pulse's two built-in state mechanisms is durable, shared server-side storage:
187
+ - `spec.persist` is **client-side** `localStorage`, scoped per route — gone if the visitor clears their browser, invisible to other visitors.
188
+ - The global store (`pulse.store.js`) is **in-memory**, shared across connected clients via SSE, but reset on every server restart and never written to disk.
189
+
190
+ For anything that needs to survive a restart and be shared across all requests — a URL shortener's code→URL map, a simple content store, any small dataset a form writes to — there's no built-in database, and the framework has no opinion on which one to use. **A JSON file on disk is a reasonable zero-dependency default for a small, low-concurrency dataset** (a few thousand records, not a high-write-throughput service); for anything bigger, bring your own database. Either way, write the persistence layer as its own module under `src/lib/`, imported from `guard`/`server`/`submit`/`actions.*.run` — never from the view.
191
+
192
+ ```js
193
+ // src/lib/data-store.js — a minimal safe pattern for a small JSON-backed store
194
+ import fs from 'node:fs/promises'
195
+ import path from 'node:path'
196
+
197
+ const FILE = path.join(process.cwd(), 'data', 'store.json')
198
+ let cache = null // in-memory, refreshed on write — avoids a disk read per request
199
+ let writeQueue = Promise.resolve() // serializes writes so concurrent requests can't interleave
200
+
201
+ async function load() {
202
+ if (cache) return cache
203
+ try {
204
+ cache = JSON.parse(await fs.readFile(FILE, 'utf8'))
205
+ } catch {
206
+ cache = {} // file doesn't exist yet — start empty
207
+ }
208
+ return cache
209
+ }
210
+
211
+ async function persist(next) {
212
+ // Atomic write: write to a temp file, then rename — rename is atomic on POSIX,
213
+ // so a crash mid-write can never leave a corrupt/partial file on disk.
214
+ const tmp = FILE + '.tmp'
215
+ await fs.mkdir(path.dirname(FILE), { recursive: true })
216
+ await fs.writeFile(tmp, JSON.stringify(next, null, 2))
217
+ await fs.rename(tmp, FILE)
218
+ cache = next
219
+ }
220
+
221
+ // Serialize every write through one promise chain — without this, two
222
+ // concurrent requests can both read the same state, modify it separately,
223
+ // and the second write silently clobbers the first (a classic lost update).
224
+ export function mutate(fn) {
225
+ writeQueue = writeQueue.then(load).then(async (data) => {
226
+ const next = await fn(data)
227
+ await persist(next)
228
+ return next
229
+ })
230
+ return writeQueue
231
+ }
232
+
233
+ export const read = load
234
+ ```
235
+
236
+ **Rules:**
237
+ - Load-on-first-read, cache in memory, refresh the cache on every write — the hot path (a read-heavy redirect lookup, say) shouldn't hit disk on every request.
238
+ - Serialize writes through a single promise queue, as above. This is single-process only — it does not protect against two separate server *instances* writing the same file; for multi-instance deployment, use a real database instead.
239
+ - Write via temp-file-then-rename, never `fs.writeFile` directly to the real path — a process killed mid-write leaves the rename-source temp file intact and the real file untouched, not a half-written JSON file that fails to parse on next boot.
240
+ - Add the data file to `.gitignore` — it's runtime state, not something to commit.
241
+ - **This local helper module must not be imported into a page's `view` or client-side `mutations`/`actions`** — only into `guard`, `server` fetchers, `submit`, or an action's `run`. A local module using `node:fs` (or any Node built-in) that ends up reachable from client code will break the production build — see the next point.
242
+ - **A page whose only server-side logic is `guard`/`submit`/`server` (no `mutations`, `actions`, or `persist`) gets zero client JS** — this is the normal, correct outcome for a redirect handler, a form-only page, or anything else that's purely server-rendered. If a build ever tries to bundle a local server-only module (e.g. you see esbuild choke on `node:fs` or similar), that's the build's hydration-need check misfiring, not something to work around in your spec — it should already match this rule.
243
+
244
+ ## Third-party inline scripts — `server.nonce`
245
+
246
+ The framework auto-nonces its own inline `<script>` tags for CSP (`script-src` is nonce-restricted, not `'unsafe-inline'`). A hand-written `<script>` in a view — e.g. an ad network's or analytics tool's inline snippet that the framework doesn't generate itself — needs that same nonce or the browser blocks it as a CSP violation.
247
+
248
+ `server.nonce` is the per-request CSP nonce, available in every view with no extra setup:
249
+
250
+ ```js
251
+ view: (state, server) => `
252
+ <main id="main-content">
253
+ <script nonce="${server.nonce}">
254
+ (adsbygoogle = window.adsbygoogle || []).push({});
255
+ </script>
256
+ </main>`,
257
+ ```
258
+
259
+ **Rules:**
260
+ - Only covers **inline** `<script>` content. An external `<script src="...">` doesn't need a nonce at all — CSP's `script-src` origin allowlist (`csp: { 'script-src': ['https://example.com'] }` in `createServer`) gates those instead; add the third party's origin there.
261
+ - Never hardcode a nonce value — it's per-request and regenerated on every response. `server.nonce` always reflects the current request's real value.
262
+ - If the third party's own SDK tries to register a [Trusted Types](https://developer.mozilla.org/en-US/docs/Web/API/Trusted_Types_API) policy (some ad networks do), the framework's CSP restricts `trusted-types` to its own `pulse` policy — that's a separate, unrelated CSP dimension from nonces and isn't solved by `server.nonce`. It **is** extendable via `csp: { 'trusted-types': ['policy-name'] }` (merges with `pulse`, doesn't replace it) — see "Third-party scripts" in `pulse://guide/styles` for the full pattern and the explicit trade-off it represents.
180
263
 
181
264
  ## Server context — redirects, cookies, POST bodies
182
265
 
@@ -208,12 +291,14 @@ Body parsing is available in `guard`, `server.*` fetchers, and `render` (raw spe
208
291
  ```js
209
292
  await ctx.json() // parse JSON body → object | null
210
293
  await ctx.text() // raw string body → string
211
- await ctx.formData() // URL-encoded body → plain object | null
294
+ await ctx.formData() // URL-encoded body → plain object | null — NOT a FormData, see below
212
295
  await ctx.buffer() // raw Buffer
213
296
  ```
214
297
 
215
298
  Bodies larger than `maxBody` (default 1 MB, configurable in `createServer`) are rejected with a 413 response before the handler runs.
216
299
 
300
+ **`ctx.formData()` returns a plain object, not a browser `FormData`.** Read fields directly — `data?.email`, not `data.get('email')` — the latter throws `TypeError: data.get is not a function`. This trips people up because client-side action code (`actions.*.run(state, serverState, formData)`) receives a real `FormData` object with a `.get()` method — same method name, different context, different shape. If you're writing `spec.submit` and reach for `.get()`, that's the tell you've copied the client-side pattern by mistake.
301
+
217
302
  **Page specs only accept GET/HEAD by default** (POST → 405). To handle POST on a page spec, opt in with `spec.methods`:
218
303
 
219
304
  ```js
@@ -310,6 +395,22 @@ Key properties:
310
395
  - `Cache-Control: no-store` — proxies never serve a stale health status
311
396
  - Fires before route matching — a user spec at `/healthz` is shadowed when the built-in is enabled
312
397
 
398
+ ## Error journal — mechanized, agent-facing error tracking
399
+
400
+ In dev mode (`dev: true`), every error the three error layers above catch — plus post-hydration client view/action failures — is also recorded to `.pulse/errors.json`. This is a machine-checkable diagnostic, not a console line you have to notice: read it with `pulse_diagnose`, clear entries with `pulse_resolve_error`.
401
+
402
+ ```js
403
+ // .pulse/errors.json — written automatically, never edit directly
404
+ [{ id, ts, route, phase, message, stack, resolved }]
405
+ // phase: 'view' | 'action' | 'server' | 'guard'
406
+ ```
407
+
408
+ **Call `pulse_diagnose` when something seems broken and the cause isn't obvious**, or proactively after building/testing a page to confirm nothing threw during the session. Pass `{ route }` to filter to one page. `/verify` already calls this automatically before writing the stamp — an unresolved error for the target route blocks the stamp; a clean pass auto-resolves the route's entries via `pulse_stamp`. You don't need to call `pulse_resolve_error` manually in the normal `/verify` flow — only if you fixed something outside that loop and want it cleared immediately.
409
+
410
+ - Ring-buffered at 50 entries — a diagnostic tool, not a production error tracker
411
+ - `dev`-only — nothing is written in production, and the client-reporting endpoint (`/__pulse/error`) doesn't exist outside dev mode
412
+ - Never write to this file directly — it's framework-managed
413
+
313
414
  ## Graceful shutdown
314
415
 
315
416
  `createServer` registers `SIGTERM` and `SIGINT` handlers automatically. When either signal arrives:
@@ -49,8 +49,15 @@ export default {
49
49
  // Server-side form handling — POST works with JavaScript disabled.
50
50
  // { redirect } → 303 PRG; anything else → re-render with the value as server.form.
51
51
  // CSRF enforced automatically: the view MUST include ${server.csrf} inside the <form>.
52
+ //
53
+ // ⚠ ctx.formData() here is NOT the same type as the client-side FormData used
54
+ // in actions.run above. Server-side it's already parsed into a plain object —
55
+ // read fields directly (data?.email), not with .get('email'). Calling .get()
56
+ // on it throws "data.get is not a function". This is the single most common
57
+ // mistake writing spec.submit — the two formData()s look identical but return
58
+ // different shapes because one runs in the browser and one runs server-side.
52
59
  submit: async (ctx) => {
53
- const data = await ctx.formData()
60
+ const data = await ctx.formData() // plain object: { email: '...', ... } | null
54
61
  if (!data?.email) return { errors: { email: 'Required' }, values: data ?? {} }
55
62
  await save(data)
56
63
  return { redirect: '/thanks' }
@@ -238,6 +238,29 @@ createServer(specs, {
238
238
 
239
239
  Without this, external images will be blocked in production and Lighthouse will flag a Best Practices failure. Use the same pattern for other external resource types (`media-src` for video, `connect-src` for fetch/XHR to external APIs).
240
240
 
241
+ ### Third-party scripts (ad networks, analytics SDKs)
242
+
243
+ **Every `csp` key merges the same way — including `trusted-types` and `require-trusted-types-for`.** This is a real, tested behavior, not a guess: `csp` passed to `createServer` (or `pulse.config.js`) is spread onto the framework's base CSP directive-by-directive, so `csp: { 'trusted-types': ['google'] }` produces `trusted-types pulse google` in the response header — it does not replace or drop the framework's own `pulse` policy.
244
+
245
+ A third-party script (Google AdSense, GTM, and similar SDKs) typically needs up to three separate CSP extensions — do not assume only one is needed:
246
+
247
+ ```js
248
+ // pulse.config.js
249
+ export default {
250
+ csp: {
251
+ // The loader <script src="..."> itself — origin allowlist, no nonce needed
252
+ 'script-src': ['https://pagead2.googlesyndication.com'],
253
+ // If the SDK creates its own Trusted Types policy (some ad SDKs do) —
254
+ // scoped to exactly the policy name it registers, not a broad relaxation
255
+ 'trusted-types': ['google'],
256
+ },
257
+ }
258
+ ```
259
+
260
+ For the **inline** per-slot script the SDK's own docs tell you to paste in (e.g. `(adsbygoogle = window.adsbygoogle || []).push({})`), use `server.nonce` — see "Third-party inline scripts" in `pulse://guide/server`. `csp.script-src` origin allowlisting only covers `<script src="...">`, not inline `<script>` content.
261
+
262
+ **Without the `trusted-types` extension, this is a hard failure, not a cosmetic one:** a script that calls `trustedTypes.createPolicy(...)` under a name other than `pulse` throws in the browser console, the SDK fails to initialize, AND `lighthouse_audit` fails Best Practices on the resulting console error/CSP violation — confirmed directly (an agent hit exactly this running a real audit against AdSense). This is not a "check the console if something seems off" edge case; if you're integrating a third-party SDK and Lighthouse Best Practices won't reach 100, check the browser console for a Trusted Types policy rejection before assuming the problem is elsewhere.
263
+
241
264
  For multiple weights or italic variants, separate them with a semicolon in the URL:
242
265
  ```
243
266
  ?family=Inter:ital,wght@0,400;0,700;1,400&display=swap