@invisibleloop/pulse 0.20.11 → 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 +13 -6
  40. package/src/runtime/ssr.test.js +31 -0
  41. package/src/server/index.js +162 -9
  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 -350
  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
@@ -12,9 +12,25 @@
12
12
  import { validateSpec } from '../spec/schema.js'
13
13
 
14
14
  import { existsSync } from 'fs'
15
+ import { isAbsolute } from 'path'
15
16
 
16
17
  const tmpFile = process.argv[2]
17
18
  if (!tmpFile) { process.stdout.write('Invalid: no file path provided'); process.exit(0) }
19
+ // A relative path here resolves against THIS FILE's location (src/mcp/), not the
20
+ // caller's cwd — a path like "src/pages/home.js" then looks to Node's ESM resolver
21
+ // like a bare package specifier ("Cannot find package 'src'"), which reads as an
22
+ // import-resolution bug in the spec rather than what it actually is: this worker
23
+ // was invoked with the wrong kind of path. Catch it here with a precise message
24
+ // instead of letting it fall through to the generic import-failure branch below.
25
+ if (!isAbsolute(tmpFile)) {
26
+ process.stdout.write(
27
+ `Invalid: validate-worker.js requires an absolute path, got a relative one — ${tmpFile}\n` +
28
+ ` A relative path resolves against this worker's own location, not your cwd, and often ` +
29
+ `fails with a confusing "Cannot find package" error that looks like a bug in the spec's ` +
30
+ `imports. Pass an absolute path instead.`
31
+ )
32
+ process.exit(0)
33
+ }
18
34
  if (!existsSync(tmpFile)) { process.stdout.write(`Invalid: file not found — ${tmpFile}`); process.exit(0) }
19
35
 
20
36
  let mod
@@ -155,7 +171,8 @@ try {
155
171
 
156
172
  // Inline <script> blocks in view — blocked by Pulse CSP nonce policy
157
173
  // meta.scripts is the correct way to add JS: it receives the server nonce automatically.
158
- if (/<script[\s>]/i.test(html)) {
174
+ // External scripts (<script src="…">) are allowed by script-src 'self', so only flag tags without a src.
175
+ if (/<script(?=[\s>])(?![^>]*\ssrc\s*=)/i.test(html)) {
159
176
  warnings.push('Inline <script> block detected in view — Pulse\'s CSP (script-src with nonces) will block these scripts at runtime. Move JavaScript to a file in public/ and reference it via meta.scripts: [\'/my-script.js\'] in the spec instead.')
160
177
  }
161
178
 
@@ -29,6 +29,21 @@ const loadStore = () => _storeModule
29
29
  const dispatchStoreMutationLazy = (name, payload) =>
30
30
  loadStore().then(({ dispatchStoreMutation }) => dispatchStoreMutation(name, payload))
31
31
 
32
+ // Best-effort error reporting to the dev-only error journal. The endpoint
33
+ // only exists when the server is running in dev mode — in production this
34
+ // POSTs to a route that 404s, so there's no dev-detection needed here and
35
+ // no risk to production behaviour. Never throws, never blocks the UI.
36
+ function reportError({ phase, message, stack }) {
37
+ try {
38
+ fetch('/__pulse/error', {
39
+ method: 'POST',
40
+ headers: { 'Content-Type': 'application/json' },
41
+ body: JSON.stringify({ route: location.pathname, phase, message, stack }),
42
+ keepalive: true,
43
+ }).catch(() => {})
44
+ } catch { /* fetch unavailable or blocked — drop it */ }
45
+ }
46
+
32
47
  // ---------------------------------------------------------------------------
33
48
  // Mount
34
49
  // ---------------------------------------------------------------------------
@@ -174,6 +189,7 @@ export function mount(spec, el, serverState = {}, options = {}) {
174
189
  html = resolveView(spec, state, getEffectiveServerState())
175
190
  } catch (err) {
176
191
  console.error('[Pulse] view error:', err)
192
+ reportError({ phase: 'view', message: err.message, stack: err.stack })
177
193
  const serverState = getEffectiveServerState()
178
194
  html = spec.onViewError
179
195
  ? spec.onViewError(err, state, serverState)
@@ -250,6 +266,7 @@ export function mount(spec, el, serverState = {}, options = {}) {
250
266
  applyResult(action.onSuccess(state, result))
251
267
  } catch (error) {
252
268
  console.error(`[Pulse] action "${name}" failed:`, error)
269
+ reportError({ phase: 'action', message: `action "${name}" failed: ${error.message}`, stack: error.stack })
253
270
  applyResult(action.onError(state, error))
254
271
  }
255
272
 
@@ -323,10 +340,19 @@ function resolveView(spec, state, serverState) {
323
340
  * Uses event delegation — one listener per event type per element.
324
341
  *
325
342
  * Attribute format:
326
- * data-event="mutationName" → fires on click
327
- * data-event="change:mutationName" → fires on change
328
- * data-event="input:mutationName" → fires on input
329
- * data-action="actionName" → fires on form submit
343
+ * data-event="mutationName" → fires on click
344
+ * data-event="change:mutationName" → fires on change
345
+ * data-event="input:mutationName" → fires on input
346
+ * data-event="pointerdown:mutationName" → fires on pointerdown, captures the pointer
347
+ * data-event="pointermove:mutationName" → fires on pointermove (while captured)
348
+ * data-event="pointerup:mutationName" → fires on pointerup or pointercancel
349
+ * data-event="keydown:mutationName" → fires on keydown
350
+ * data-action="actionName" → fires on form submit
351
+ *
352
+ * data-event accepts multiple space-separated bindings on one element —
353
+ * needed for pointer drag, where pointerdown/pointermove/pointerup must all
354
+ * resolve on the same captured element:
355
+ * data-event="pointerdown:start pointermove:drag pointerup:end"
330
356
  *
331
357
  * @param {HTMLElement} el
332
358
  * @param {function} dispatch
@@ -376,8 +402,8 @@ function bindEvents(el, dispatch, signal) {
376
402
  // Spec events
377
403
  const target = e.target?.closest?.('[data-event]')
378
404
  if (!target) return
379
- const [type, name] = parseEventAttr(target.dataset.event)
380
- if (type !== 'click') return
405
+ const name = findEventBinding(target, 'click')
406
+ if (!name) return
381
407
  e.preventDefault()
382
408
  dispatch(name, e)
383
409
  }, opts)
@@ -391,8 +417,8 @@ function bindEvents(el, dispatch, signal) {
391
417
  }
392
418
  const target = e.target?.closest?.('[data-event]')
393
419
  if (!target) return
394
- const [type, name] = parseEventAttr(target.dataset.event)
395
- if (type !== 'change') return
420
+ const name = findEventBinding(target, 'change')
421
+ if (!name) return
396
422
  dispatchTimed(target, name, e, dispatch)
397
423
  }, opts)
398
424
 
@@ -405,8 +431,8 @@ function bindEvents(el, dispatch, signal) {
405
431
  }
406
432
  const target = e.target?.closest?.('[data-event]')
407
433
  if (!target) return
408
- const [type, name] = parseEventAttr(target.dataset.event)
409
- if (type !== 'input') return
434
+ const name = findEventBinding(target, 'input')
435
+ if (!name) return
410
436
  dispatchTimed(target, name, e, dispatch)
411
437
  }, opts)
412
438
 
@@ -417,6 +443,57 @@ function bindEvents(el, dispatch, signal) {
417
443
  dispatch(target.dataset.action, new FormData(target))
418
444
  if (target.hasAttribute('data-reset')) target.reset()
419
445
  }, opts)
446
+
447
+ // Pointer events — click/input/change only cover discrete interactions.
448
+ // Continuous drag (grab a vertex, drag a slider handle, resize a panel) needs
449
+ // the raw pointer stream. pointerdown sets pointer capture on the target so
450
+ // pointermove/pointerup keep firing on it even once the cursor leaves the
451
+ // element's bounds — without capture, dragging fast loses the element under
452
+ // the cursor and the drag appears to "let go".
453
+ el.addEventListener('pointerdown', e => {
454
+ const target = e.target?.closest?.('[data-event]')
455
+ if (!target) return
456
+ const name = findEventBinding(target, 'pointerdown')
457
+ if (!name) return
458
+ target.setPointerCapture?.(e.pointerId)
459
+ dispatch(name, e)
460
+ }, opts)
461
+
462
+ el.addEventListener('pointermove', e => {
463
+ // Pointer capture routes the event straight to the captured target, so no
464
+ // closest() lookup is needed (and none would work once the cursor is
465
+ // outside the element's bounds).
466
+ const target = e.target
467
+ if (!target?.dataset?.event) return
468
+ const name = findEventBinding(target, 'pointermove')
469
+ if (!name) return
470
+ dispatchTimed(target, name, e, dispatch)
471
+ }, opts)
472
+
473
+ el.addEventListener('pointerup', e => {
474
+ const target = e.target
475
+ const name = target?.dataset?.event ? findEventBinding(target, 'pointerup') : null
476
+ if (name) dispatch(name, e)
477
+ target?.releasePointerCapture?.(e.pointerId)
478
+ }, opts)
479
+
480
+ // pointercancel fires instead of pointerup for interrupted gestures (browser
481
+ // gesture takeover, tab switch mid-drag, etc). Routed to the same handler
482
+ // as pointerup so a drag never gets stuck "in progress".
483
+ el.addEventListener('pointercancel', e => {
484
+ const target = e.target
485
+ const name = target?.dataset?.event ? findEventBinding(target, 'pointerup') : null
486
+ if (name) dispatch(name, e)
487
+ target?.releasePointerCapture?.(e.pointerId)
488
+ }, opts)
489
+
490
+ el.addEventListener('keydown', e => {
491
+ const target = e.target?.closest?.('[data-event]')
492
+ if (!target) return
493
+ const name = findEventBinding(target, 'keydown')
494
+ if (!name) return
495
+ dispatch(name, e)
496
+ }, opts)
420
497
  }
421
498
 
422
499
  /**
@@ -535,10 +612,44 @@ function morphAttrs(cur, nxt) {
535
612
  if (cur.tagName === 'DIALOG' && name === 'open') continue
536
613
  if (!nxt.hasAttribute(name)) cur.removeAttribute(name)
537
614
  }
615
+ syncFormProperty(cur, nxt)
538
616
  }
539
617
 
540
618
  /**
541
- * Parse a data-event attribute value.
619
+ * setAttribute('value', …) / setAttribute('checked', …) above only touch the
620
+ * *default* value/checkedness a form control resets to — not the live
621
+ * `.value`/`.checked` property the browser actually renders. Once a user has
622
+ * focused+edited a field (or any code has assigned `.value`/`.checked`
623
+ * directly), the attribute and the property permanently diverge — this is
624
+ * standard DOM behaviour, not a Pulse bug in the attribute-sync loop above.
625
+ * Re-rendering after that point (e.g. a `change:`-committed input, a value
626
+ * driven by a drag elsewhere on the page) would otherwise leave the field
627
+ * showing a stale value even though the attribute updated correctly. Sync
628
+ * the live property directly for the form-control elements where this
629
+ * matters, skipping the element the user is actively typing in — the
630
+ * common "don't destroy focus for a live input:-bound field" case already
631
+ * has its own guidance (use FormData, not a controlled value) but this
632
+ * covers every other legitimate re-render source.
633
+ */
634
+ function syncFormProperty(cur, nxt) {
635
+ const tag = cur.tagName
636
+ if (tag === 'INPUT' || tag === 'TEXTAREA') {
637
+ const type = cur.type
638
+ if (type === 'checkbox' || type === 'radio') {
639
+ const next = nxt.hasAttribute('checked')
640
+ if (cur.checked !== next) cur.checked = next
641
+ } else if (document.activeElement !== cur) {
642
+ const next = nxt.getAttribute('value') ?? ''
643
+ if (cur.value !== next) cur.value = next
644
+ }
645
+ } else if (tag === 'SELECT' && document.activeElement !== cur) {
646
+ const next = nxt.getAttribute('value')
647
+ if (next !== null && cur.value !== next) cur.value = next
648
+ }
649
+ }
650
+
651
+ /**
652
+ * Parse a single data-event binding.
542
653
  * "click:increment" → ['click', 'increment']
543
654
  * "increment" → ['click', 'increment'] (click is default)
544
655
  *
@@ -551,6 +662,28 @@ function parseEventAttr(value) {
551
662
  return [parts[0], parts[1]]
552
663
  }
553
664
 
665
+ /**
666
+ * Look up the mutation/action name bound to `eventType` on `target`'s
667
+ * data-event attribute. Supports multiple space-separated bindings on one
668
+ * element — needed for pointer drag, where pointerdown/pointermove/pointerup
669
+ * must all resolve on the same captured element:
670
+ * data-event="pointerdown:start pointermove:drag pointerup:end"
671
+ * A plain single binding ("increment" or "change:setName") still works
672
+ * unchanged — split() on a string with no spaces returns a one-element array.
673
+ *
674
+ * @param {HTMLElement} target
675
+ * @param {string} eventType
676
+ * @returns {string|null}
677
+ */
678
+ function findEventBinding(target, eventType) {
679
+ for (const binding of target.dataset.event.split(' ')) {
680
+ if (!binding) continue
681
+ const [type, name] = parseEventAttr(binding)
682
+ if (type === eventType) return name
683
+ }
684
+ return null
685
+ }
686
+
554
687
  /**
555
688
  * Dispatch a mutation, applying debounce or throttle if declared on the element.
556
689
  * data-debounce="300" — waits 300ms after the last event before firing
@@ -54,6 +54,53 @@ function parseElements(html, selector) {
54
54
  return matches
55
55
  }
56
56
 
57
+ /**
58
+ * A more realistic element for exercising bindEvents() delegation directly —
59
+ * unlike FakeElement above (which stubs addEventListener as a no-op and is
60
+ * only used to assert on rendered innerHTML), this one wires real listeners
61
+ * via the native EventTarget and supports closest()/dataset/pointer capture,
62
+ * so pointerdown/pointermove/pointerup/keydown routing can be verified.
63
+ */
64
+ class DelegatingElement extends EventTarget {
65
+ constructor() {
66
+ super()
67
+ this.innerHTML = ''
68
+ this._captured = new Map()
69
+ }
70
+ }
71
+
72
+ class ChildElement extends EventTarget {
73
+ constructor(attrs = {}, parent = null) {
74
+ super()
75
+ this.dataset = {}
76
+ for (const [k, v] of Object.entries(attrs)) this.dataset[k] = v
77
+ this._parent = parent
78
+ }
79
+ closest(selector) {
80
+ const attr = selector.match(/\[([^\]]+)\]/)?.[1]
81
+ const key = attr === 'data-event' ? 'event'
82
+ : attr === 'data-action' ? 'action'
83
+ : attr === 'data-store-event' ? 'storeEvent'
84
+ : attr === 'data-dialog-open' ? 'dialogOpen'
85
+ : attr === 'data-dialog-close' ? 'dialogClose'
86
+ : null
87
+ let node = this
88
+ while (node) {
89
+ if (key && node.dataset && node.dataset[key] !== undefined) return node
90
+ node = node._parent
91
+ }
92
+ return null
93
+ }
94
+ setPointerCapture(id) { this._root?._captured.set(id, this) }
95
+ releasePointerCapture(id) { this._root?._captured.delete(id) }
96
+ }
97
+
98
+ function fireOn(root, target, type, props = {}) {
99
+ const event = Object.assign(new Event(type, { bubbles: true }), props)
100
+ Object.defineProperty(event, 'target', { value: target, configurable: true })
101
+ root.dispatchEvent(event)
102
+ }
103
+
57
104
  // ---------------------------------------------------------------------------
58
105
  // Test runner
59
106
  // ---------------------------------------------------------------------------
@@ -866,5 +913,157 @@ await testAsync('_toast in onError is stripped from spec state', async () => {
866
913
 
867
914
  // ---------------------------------------------------------------------------
868
915
 
916
+ console.log('\nPointer / keyboard event binding\n')
917
+
918
+ test('pointerdown dispatches mutation and captures the pointer', () => {
919
+ const el = new DelegatingElement()
920
+ const dragSpec = {
921
+ route: '/drag',
922
+ state: { dragging: false },
923
+ view: (state) => `<div data-event="pointerdown:startDrag">${state.dragging}</div>`,
924
+ mutations: { startDrag: (state) => ({ dragging: true }) },
925
+ }
926
+ mount(dragSpec, el)
927
+
928
+ const child = new ChildElement({ event: 'pointerdown:startDrag' })
929
+ child._root = el
930
+ let captured = null
931
+ child.setPointerCapture = (id) => { captured = id }
932
+ fireOn(el, child, 'pointerdown', { pointerId: 1 })
933
+
934
+ assert(el.innerHTML.includes('true'), `Expected dragging=true, got: ${el.innerHTML}`)
935
+ assert(captured === 1, `Expected pointer capture with id 1, got ${captured}`)
936
+ })
937
+
938
+ test('pointermove dispatches mutation while captured, ignores elements without data-event', () => {
939
+ const el = new DelegatingElement()
940
+ const dragSpec = {
941
+ route: '/drag-move',
942
+ state: { x: 0 },
943
+ view: (state) => `<div data-event="pointermove:move">${state.x}</div>`,
944
+ mutations: { move: (state, e) => ({ x: e.clientX }) },
945
+ }
946
+ mount(dragSpec, el)
947
+
948
+ const child = new ChildElement({ event: 'pointermove:move' })
949
+ child._root = el
950
+ fireOn(el, child, 'pointermove', { clientX: 42 })
951
+ assert(el.innerHTML.includes('42'), `Expected x=42, got: ${el.innerHTML}`)
952
+
953
+ const plainChild = new ChildElement({})
954
+ plainChild._root = el
955
+ fireOn(el, plainChild, 'pointermove', { clientX: 999 })
956
+ assert(el.innerHTML.includes('42'), `Element without data-event should not dispatch: ${el.innerHTML}`)
957
+ })
958
+
959
+ test('pointerup dispatches mutation and releases capture', () => {
960
+ const el = new DelegatingElement()
961
+ const dragSpec = {
962
+ route: '/drag-up',
963
+ state: { dragging: true },
964
+ view: (state) => `<div data-event="pointerup:endDrag">${state.dragging}</div>`,
965
+ mutations: { endDrag: () => ({ dragging: false }) },
966
+ }
967
+ mount(dragSpec, el)
968
+
969
+ const child = new ChildElement({ event: 'pointerup:endDrag' })
970
+ child._root = el
971
+ let released = null
972
+ child.releasePointerCapture = (id) => { released = id }
973
+ fireOn(el, child, 'pointerup', { pointerId: 7 })
974
+
975
+ assert(el.innerHTML.includes('false'), `Expected dragging=false, got: ${el.innerHTML}`)
976
+ assert(released === 7, `Expected pointer capture released with id 7, got ${released}`)
977
+ })
978
+
979
+ test('pointercancel dispatches the same mutation as pointerup and releases capture', () => {
980
+ const el = new DelegatingElement()
981
+ const dragSpec = {
982
+ route: '/drag-cancel',
983
+ state: { dragging: true },
984
+ view: (state) => `<div data-event="pointerup:endDrag">${state.dragging}</div>`,
985
+ mutations: { endDrag: () => ({ dragging: false }) },
986
+ }
987
+ mount(dragSpec, el)
988
+
989
+ const child = new ChildElement({ event: 'pointerup:endDrag' })
990
+ child._root = el
991
+ let released = null
992
+ child.releasePointerCapture = (id) => { released = id }
993
+ fireOn(el, child, 'pointercancel', { pointerId: 3 })
994
+
995
+ assert(el.innerHTML.includes('false'), `Expected dragging=false after cancel, got: ${el.innerHTML}`)
996
+ assert(released === 3, `Expected pointer capture released on cancel, got ${released}`)
997
+ })
998
+
999
+ test('keydown dispatches mutation with the key', () => {
1000
+ const el = new DelegatingElement()
1001
+ const keySpec = {
1002
+ route: '/key',
1003
+ state: { key: '' },
1004
+ view: (state) => `<div data-event="keydown:onKey">${state.key}</div>`,
1005
+ mutations: { onKey: (state, e) => ({ key: e.key }) },
1006
+ }
1007
+ mount(keySpec, el)
1008
+
1009
+ const child = new ChildElement({ event: 'keydown:onKey' })
1010
+ child._root = el
1011
+ fireOn(el, child, 'keydown', { key: 'ArrowLeft' })
1012
+
1013
+ assert(el.innerHTML.includes('ArrowLeft'), `Expected key=ArrowLeft, got: ${el.innerHTML}`)
1014
+ })
1015
+
1016
+ test('data-event supports multiple space-separated bindings on one element', () => {
1017
+ const el = new DelegatingElement()
1018
+ const dragSpec = {
1019
+ route: '/drag-multi',
1020
+ state: { dragging: false, x: 0 },
1021
+ view: (state) => `<div data-event="pointerdown:startDrag pointermove:dragMove pointerup:endDrag">${state.dragging}:${state.x}</div>`,
1022
+ mutations: {
1023
+ startDrag: () => ({ dragging: true }),
1024
+ dragMove: (state, e) => state.dragging ? { x: e.clientX } : {},
1025
+ endDrag: () => ({ dragging: false }),
1026
+ },
1027
+ }
1028
+ mount(dragSpec, el)
1029
+
1030
+ const child = new ChildElement({ event: 'pointerdown:startDrag pointermove:dragMove pointerup:endDrag' })
1031
+ child._root = el
1032
+
1033
+ fireOn(el, child, 'pointerdown', { pointerId: 1 })
1034
+ assert(el.innerHTML.includes('true:0'), `Expected dragging=true, got: ${el.innerHTML}`)
1035
+
1036
+ fireOn(el, child, 'pointermove', { clientX: 55 })
1037
+ assert(el.innerHTML.includes('true:55'), `Expected x=55 while dragging, got: ${el.innerHTML}`)
1038
+
1039
+ fireOn(el, child, 'pointerup', { pointerId: 1 })
1040
+ assert(el.innerHTML.includes('false:55'), `Expected dragging=false, got: ${el.innerHTML}`)
1041
+
1042
+ // pointermove after release is a no-op because the mutation gates on `dragging`
1043
+ fireOn(el, child, 'pointermove', { clientX: 999 })
1044
+ assert(el.innerHTML.includes('false:55'), `Expected no change after release, got: ${el.innerHTML}`)
1045
+ })
1046
+
1047
+ test('pointerdown/pointerup ignore wrong event type on the same element', () => {
1048
+ const el = new DelegatingElement()
1049
+ const spec = {
1050
+ route: '/only-down',
1051
+ state: { count: 0 },
1052
+ view: (state) => `<div data-event="pointerdown:bump">${state.count}</div>`,
1053
+ mutations: { bump: (state) => ({ count: state.count + 1 }) },
1054
+ }
1055
+ mount(spec, el)
1056
+
1057
+ const child = new ChildElement({ event: 'pointerdown:bump' })
1058
+ child._root = el
1059
+ fireOn(el, child, 'pointerup', { pointerId: 1 }) // wrong type — should not fire
1060
+ assert(el.innerHTML.includes('>0<'), `Should not have dispatched on pointerup: ${el.innerHTML}`)
1061
+
1062
+ fireOn(el, child, 'pointerdown', { pointerId: 1 }) // correct type — should fire
1063
+ assert(el.innerHTML.includes('>1<'), `Expected count 1, got: ${el.innerHTML}`)
1064
+ })
1065
+
1066
+ // ---------------------------------------------------------------------------
1067
+
869
1068
  console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`)
870
1069
  if (failed > 0) process.exit(1)
@@ -365,7 +365,7 @@ export function wrapDocument({ content, spec = {}, serverState = {}, storeState
365
365
  : ''
366
366
 
367
367
  const styleLinks = (meta.styles || [])
368
- .map((href, i) => `<link rel="stylesheet" href="${esc(href)}"${i === 0 ? ' fetchpriority="high"' : ''}>`)
368
+ .map((href, i) => `<link rel="stylesheet" href="${esc(href)}"${i < 2 ? ' fetchpriority="high"' : ''}>`)
369
369
  .join('\n ')
370
370
 
371
371
  // Deferred styles — injected via a nonce'd script so CSP is respected.
@@ -461,23 +461,30 @@ function mergeStoreKeys(spec, serverState, storeState) {
461
461
  /**
462
462
  * Merge per-request extras set by the server into the view's server state:
463
463
  *
464
- * ctx._form → serverState.form — the return value of spec.submit() on a
464
+ * ctx._form → serverState.form — the return value of spec.submit() on a
465
465
  * POST re-render (validation errors, submitted values, etc.)
466
- * ctx._csrfField → serverState.csrf — ready-to-embed hidden <input> for CSRF;
466
+ * ctx._csrfField → serverState.csrf — ready-to-embed hidden <input> for CSRF;
467
467
  * views include it inside <form method="POST"> as ${server.csrf}
468
+ * ctx.nonce → serverState.nonce — the per-request CSP nonce, for views that
469
+ * need to hand-write a <script nonce="${server.nonce}"> tag (e.g.
470
+ * third-party scripts like AdSense/GTM that require an inline
471
+ * snippet the framework itself doesn't generate). ctx.nonce is
472
+ * always set (buildContext defaults it to '' if none was passed),
473
+ * so this key is always present — unlike form/csrf, which are
474
+ * conditional on spec.submit.
468
475
  *
469
- * Only set by the server for specs that declare `submit` — other pages see
470
- * neither key. Explicit server fetcher keys with the same names win.
476
+ * Explicit server fetcher keys with the same names win over all of the above.
471
477
  *
472
478
  * @param {Object} serverState
473
479
  * @param {Object} ctx
474
480
  * @returns {Object}
475
481
  */
476
482
  function applyCtxExtras(serverState, ctx) {
477
- if (ctx?._form === undefined && ctx?._csrfField === undefined) return serverState
483
+ if (ctx?._form === undefined && ctx?._csrfField === undefined && !ctx?.nonce) return serverState
478
484
  const extras = {}
479
485
  if (ctx._form !== undefined) extras.form = ctx._form
480
486
  if (ctx._csrfField !== undefined) extras.csrf = ctx._csrfField
487
+ if (ctx.nonce) extras.nonce = ctx.nonce
481
488
  return { ...extras, ...serverState }
482
489
  }
483
490
 
@@ -139,6 +139,37 @@ await test('passes ctx to server data fetchers', async () => {
139
139
  assert(html.includes('Hello Andy'), `Got: ${html}`)
140
140
  })
141
141
 
142
+ await test('exposes ctx.nonce to the view as server.nonce', async () => {
143
+ // Regression: ctx.nonce was already available to guard/server fetchers
144
+ // (buildContext sets it directly on ctx), but never reached the view
145
+ // itself — there was no documented way for a hand-written <script> tag
146
+ // (e.g. third-party scripts like AdSense that require an inline snippet
147
+ // the framework doesn't generate) to receive the per-request CSP nonce.
148
+ const spec = {
149
+ route: '/ads',
150
+ state: {},
151
+ view: (s, server) => `<script nonce="${server.nonce}">/* ad init */</script>`,
152
+ }
153
+ const { html } = await renderToString(spec, { nonce: 'abc123' })
154
+ assert(html.includes('nonce="abc123"'), `Got: ${html}`)
155
+ })
156
+
157
+ await test('server.nonce is absent (not empty string) when ctx.nonce is unset', async () => {
158
+ const { serverState } = await renderToString(helloSpec, {})
159
+ assert(serverState.nonce === undefined, `Expected no nonce key, got: ${serverState.nonce}`)
160
+ })
161
+
162
+ await test('an explicit server.nonce fetcher wins over ctx.nonce', async () => {
163
+ const spec = {
164
+ route: '/custom-nonce',
165
+ state: {},
166
+ server: { nonce: async () => 'explicit-value' },
167
+ view: (s, server) => `<p>${server.nonce}</p>`,
168
+ }
169
+ const { serverState } = await renderToString(spec, { nonce: 'from-ctx' })
170
+ assert(serverState.nonce === 'explicit-value', `Got: ${serverState.nonce}`)
171
+ })
172
+
142
173
  await test('returns serverState from resolved fetchers', async () => {
143
174
  const { serverState } = await renderToString(serverDataSpec)
144
175
  assert(Array.isArray(serverState.products), 'Expected products array')