@invisibleloop/pulse 0.20.11 → 0.20.20

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
@@ -1,107 +0,0 @@
1
- import { renderLayout, h1, lead, section, codeBlock, callout, table } from '../lib/layout.js'
2
- import { prevNext } from '../lib/nav.js'
3
- import { highlight } from '../lib/highlight.js'
4
-
5
- const { prev, next } = prevNext('/mutations')
6
-
7
- export default {
8
- route: '/mutations',
9
- meta: {
10
- theme: 'light',
11
- title: 'Mutations — Pulse Docs',
12
- description: 'Synchronous state changes in Pulse — how to declare and trigger mutations.',
13
- styles: ['/theme.css', '/docs.css'],
14
- },
15
- state: {},
16
- view: () => renderLayout({
17
- currentHref: '/mutations',
18
- prev,
19
- next,
20
- content: `
21
- ${h1('Mutations')}
22
- ${lead('Mutations are the only permitted way to change client state. They are synchronous pure functions — network requests, DOM manipulation, and timers are structurally excluded. After every mutation, Pulse automatically applies constraints and re-renders the view.')}
23
-
24
- ${section('what', 'What is a mutation?')}
25
- <p>A mutation is a function with the signature <code>(state, event) =&gt; partialState</code>. It receives the current state and the DOM event that triggered it, and returns a plain object to merge into state.</p>
26
- ${codeBlock(highlight(`mutations: {
27
- increment: (state) => ({ count: state.count + 1 }),
28
- decrement: (state) => ({ count: state.count - 1 }),
29
- reset: () => ({ count: 0 }),
30
- }`, 'js'))}
31
- <p>The returned partial is shallow-merged. Only the returned keys are changed — everything else in state is preserved.</p>
32
-
33
- ${section('binding', 'Binding mutations to DOM events')}
34
- <p>Mutations are bound to DOM events using the <code>data-event</code> attribute in the view HTML:</p>
35
- ${codeBlock(highlight(`<button data-event="increment">+</button> <!-- click → increment -->
36
- <button data-event="click:decrement">-</button> <!-- explicit click -->
37
- <input data-event="change:setName"> <!-- change event -->
38
- <input data-event="input:setQuery"> <!-- input event (every keystroke) -->`, 'html'))}
39
-
40
- ${table(
41
- ['Event type', 'Shorthand', 'Typical use'],
42
- [
43
- ['<code>click</code>', '<code>data-event="mutName"</code>', 'Buttons, links'],
44
- ['<code>change</code>', '<code>data-event="change:mutName"</code>', 'Select dropdowns, checkboxes'],
45
- ['<code>input</code>', '<code>data-event="input:mutName"</code>', 'Search/filter fields — add <code>data-debounce="300"</code> to rate-limit'],
46
- ]
47
- )}
48
-
49
- ${section('debounce', 'Debounce and throttle')}
50
- <p>Add <code>data-debounce="300"</code> alongside <code>data-event</code> to delay the mutation until typing stops. The mutation fires once, 300ms after the last keystroke — not on every character. Use this for live search and filter inputs.</p>
51
- ${codeBlock(highlight(`<input data-event="input:search" data-debounce="300">
52
- <input data-event="input:filter" data-throttle="100">`, 'html'))}
53
- <p><code>data-throttle="100"</code> fires at most once per 100ms — useful when you want frequent updates but need to limit the rate. Both attributes accept a value in milliseconds and apply to <code>input</code> and <code>change</code> events. No per-spec timer code needed.</p>
54
-
55
- ${section('event-arg', 'The event argument')}
56
- <p>The second argument to a mutation is the native DOM <code>Event</code> object, giving access to the element and its value:</p>
57
- ${codeBlock(highlight(`mutations: {
58
- setName: (state, e) => ({ name: e.target.value }),
59
- setCountry: (state, e) => ({ country: e.target.value }),
60
- toggle: (state, e) => ({ checked: e.target.checked }),
61
- }`, 'js'))}
62
-
63
- ${section('partial-merge', 'Partial state merge')}
64
- <p>Only the keys that need to change are returned. The runtime merges the returned object into the existing state at the top level:</p>
65
- ${codeBlock(highlight(`// state = { step: 2, name: 'Alice', email: 'a@b.com' }
66
-
67
- mutations: {
68
- nextStep: (state) => ({ step: state.step + 1 }),
69
- // After: { step: 3, name: 'Alice', email: 'a@b.com' }
70
- // name and email are untouched
71
- }`, 'js'))}
72
- ${callout('warning', 'The merge is <strong>shallow</strong>. When state has nested objects, returning a new version of a nested key replaces the entire sub-object — not a deep merge. Use the spread operator to preserve nested fields:')}
73
- ${codeBlock(highlight(`mutations: {
74
- setEmail: (state, e) => ({
75
- // Spread the nested object to preserve sibling keys
76
- user: { ...state.user, email: e.target.value }
77
- }),
78
- }`, 'js'))}
79
-
80
- ${section('no-side-effects', 'No side effects')}
81
- <p>Mutations are pure functions. Network requests, DOM manipulation, and timers belong in <a href="/actions">actions</a> — not here. The structural separation is what lets Pulse re-render predictably, apply constraints safely, and make state changes auditable.</p>
82
-
83
- ${section('constraints', 'Mutations and constraints')}
84
- <p>After every mutation, Pulse automatically applies any <a href="/constraints">constraints</a> declared in the spec. State bounds are never checked inside mutations — they are declared once and enforced by the framework regardless of what a mutation returns:</p>
85
- ${codeBlock(highlight(`{
86
- state: { count: 0 },
87
- constraints: { count: { min: 0, max: 10 } },
88
- mutations: {
89
- increment: (state) => ({ count: state.count + 1 }),
90
- // count is automatically clamped to 10 — no need to check here
91
- }
92
- }`, 'js'))}
93
-
94
- ${section('forms', 'Mutations and forms')}
95
- <p>Mirroring every keystroke into state via <code>data-event="input:..."</code> causes <code>innerHTML</code> replacement on each keypress, which destroys input focus. Pulse prevents this by keeping form inputs uncontrolled — values are captured via <code>FormData</code> in an action's <code>onStart</code>, before the view re-renders:</p>
96
- ${codeBlock(highlight(`<!-- mirroring every keystroke causes focus loss -->
97
- <input data-event="input:setEmail">
98
-
99
- <!-- uncontrolled: values captured at submit via FormData -->
100
- <form data-action="submit">
101
- <input name="email" type="email">
102
- <button>Submit</button>
103
- </form>`, 'html'))}
104
- ${callout('tip', 'Mutations are the right tool for binary UI state (toggles, step counters, tab selections) and controls where a re-render on each change is acceptable — such as live character counters or filtered lists. For form submission, use actions.')}
105
- `,
106
- }),
107
- }
@@ -1,85 +0,0 @@
1
- import { renderLayout, h1, lead, section, sub, codeBlock, callout, table } from '../lib/layout.js'
2
- import { prevNext } from '../lib/nav.js'
3
- import { highlight } from '../lib/highlight.js'
4
-
5
- const { prev, next } = prevNext('/navigation')
6
-
7
- export default {
8
- route: '/navigation',
9
- meta: {
10
- theme: 'light',
11
- title: 'Navigation — Pulse Docs',
12
- description: 'Client-side navigation in Pulse — how link interception, JSON responses, and spec re-mounting work.',
13
- styles: ['/theme.css', '/docs.css'],
14
- },
15
- view: () => renderLayout({
16
- currentHref: '/navigation',
17
- prev,
18
- next,
19
- content: `
20
- ${h1('Navigation')}
21
- ${lead('Client-side navigation in Pulse requires no configuration. When hydration is active, same-origin link clicks are intercepted automatically — the server renders the new page and returns JSON, and Pulse swaps the content without a full reload. If anything fails, it falls back to standard browser navigation.')}
22
-
23
- ${section('how-it-works', 'How it works')}
24
- <p>When <code>initNavigation</code> is called (part of the hydration bootstrap), Pulse attaches a click listener to the document. When a same-origin <code>&lt;a&gt;</code> is clicked:</p>
25
- <ol>
26
- <li>The default navigation is prevented.</li>
27
- <li>A fetch request is sent to the new URL with the header <code>X-Pulse-Navigate: true</code>.</li>
28
- <li>The server renders the page and returns a JSON response instead of full HTML.</li>
29
- <li>Pulse replaces the current page's root <code>innerHTML</code> with the new content and updates <code>document.title</code>.</li>
30
- <li>The new page's spec bundle is dynamically imported.</li>
31
- <li><code>mount()</code> is called to bind the new spec's events.</li>
32
- <li>The browser history is updated with <code>history.pushState</code>.</li>
33
- </ol>
34
- ${callout('note', 'If any step fails — network error, missing bundle, unexpected response — Pulse falls back to <code>location.href = url</code> for a standard full-page navigation.')}
35
-
36
- ${section('init-navigation', 'initNavigation')}
37
- <p><code>initNavigation</code> is called automatically by the hydration bootstrap script. You do not call it manually in application code. It is exported from the runtime for use in custom bootstrap scenarios:</p>
38
- ${codeBlock(highlight(`import { mount } from '@invisibleloop/pulse/runtime'
39
- import { initNavigation } from '@invisibleloop/pulse/navigate'
40
-
41
- mount(spec, root, window.__PULSE_SERVER__ || {}, { ssr: true })
42
- initNavigation(root, mount)`, 'js'))}
43
-
44
- ${section('json-response', 'JSON response shape')}
45
- <p>When Pulse receives a request with <code>X-Pulse-Navigate: true</code>, it renders the page and returns:</p>
46
- ${codeBlock(highlight(`{
47
- "html": "<main>...the page content...</main>",
48
- "title": "New Page Title — Site",
49
- "hydrate": "/dist/new-page.boot-abc123.js",
50
- "serverState": { "key": "value" }
51
- }`, 'js'))}
52
- ${table(
53
- ['Field', 'Description'],
54
- [
55
- ['<code>html</code>', 'The rendered page content (the output of the view function, without the full document wrapper).'],
56
- ['<code>title</code>', 'The new page title, set via <code>document.title</code>.'],
57
- ['<code>hydrate</code>', 'The bundle path for the new spec. <code>null</code> if the page has no hydration.'],
58
- ['<code>serverState</code>', 'The server data for the new page, used when mounting the new spec.'],
59
- ]
60
- )}
61
-
62
- ${section('link-interception', 'Which links are intercepted')}
63
- <p>Only same-origin links are intercepted. Links with <code>target="_blank"</code>, <code>download</code>, <code>rel="external"</code>, or any cross-origin <code>href</code> are ignored and behave normally:</p>
64
- ${codeBlock(highlight(`<!-- Intercepted by Pulse client navigation -->
65
- <a href="/about">About</a>
66
- <a href="/products/42">Product</a>
67
-
68
- <!-- NOT intercepted — standard browser navigation -->
69
- <a href="https://example.com">External</a>
70
- <a href="/report.pdf" download>Download</a>
71
- <a href="/admin" target="_blank">Open in new tab</a>`, 'html'))}
72
-
73
- ${section('history', 'Browser history')}
74
- <p>Pulse uses the History API (<code>history.pushState</code>) to update the URL after each navigation. The back and forward buttons work as expected — each history entry corresponds to a page navigation.</p>
75
- <p>When the user navigates back, Pulse receives a <code>popstate</code> event and performs the same fetch-and-swap process for the previous URL.</p>
76
-
77
- ${section('no-hydration', 'Navigation without hydration')}
78
- <p>Client-side navigation only works on pages that have loaded the Pulse client runtime (i.e. pages with a <code>hydrate</code> path). If a user navigates from a hydrated page to a non-hydrated page, Pulse falls back to a full page load.</p>
79
- ${callout('tip', 'For documentation sites or mostly-static apps, omitting <code>hydrate</code> entirely and relying on standard browser navigation is simpler and keeps the JS payload at zero.')}
80
-
81
- ${section('scroll', 'Scroll behaviour')}
82
- <p>After each client-side navigation, Pulse scrolls to the top of the page — matching the behaviour of a full page load. If the URL includes a hash (e.g. <code>/docs#section</code>), Pulse scrolls to the target element.</p>
83
- `,
84
- }),
85
- }
@@ -1,157 +0,0 @@
1
- import { renderLayout, h1, lead, section, codeBlock, table, callout } from '../lib/layout.js'
2
- import { prevNext } from '../lib/nav.js'
3
- import { highlight } from '../lib/highlight.js'
4
-
5
- const { prev, next } = prevNext('/performance')
6
-
7
- export default {
8
- route: '/performance',
9
- meta: {
10
- theme: 'light',
11
- title: 'Performance — Pulse Docs',
12
- description: 'Performance targets, techniques, and built-in optimisations in Pulse.',
13
- styles: ['/theme.css', '/docs.css'],
14
- },
15
- view: () => renderLayout({
16
- currentHref: '/performance',
17
- prev,
18
- next,
19
- content: `
20
- ${h1('Performance')}
21
- ${lead('Performance in Pulse is structural, not optional. Streaming SSR, immutable asset caching, brotli compression, and zero client JS by default are the baseline — not optimisations applied after the fact. A page that uses the framework correctly cannot score poorly.')}
22
-
23
- ${section('targets', 'Performance targets')}
24
- <p>Every page served by Pulse should meet these targets on localhost with no throttling:</p>
25
- ${table(
26
- ['Metric', 'Target', 'How'],
27
- [
28
- ['LCP', 'Fast', 'Streaming SSR sends HTML before server data resolves. Actual LCP depends on server location, CDN, and network conditions.'],
29
- ['CLS', '0.00', 'Always set <code>width</code> and <code>height</code> on images; framework never shifts layout'],
30
- ['Lighthouse Performance', '100', 'Compression, immutable caching, no render-blocking resources'],
31
- ['Lighthouse Accessibility', '100', 'Semantic HTML, proper alt text, sufficient contrast'],
32
- ['Lighthouse SEO', '100', 'Meta tags, structured data, canonical links'],
33
- ['Lighthouse Best Practices', '100', 'HTTPS, security headers, no deprecated APIs'],
34
- ]
35
- )}
36
- ${callout('note', 'Run <code>mcp__chrome-devtools__lighthouse_audit</code> after every new page to verify all four scores are 100. Fix any failures before considering the task done.')}
37
- ${callout('warning', '<strong>Third-party ads caveat:</strong> Pages that load ad network scripts will not achieve 100 across all categories. Ad scripts introduce render-blocking JS, third-party cookies (Best Practices), and late-loading content that causes CLS. Reserving fixed space for ad slots reduces CLS but cannot fully compensate for what ad networks inject at runtime. Treat 100/100/100 as the baseline for ad-free pages only.')}
38
-
39
- ${section('streaming-ssr', 'Streaming SSR')}
40
- <p>Pulse uses Node.js streams for SSR. The server sends the <code>&lt;head&gt;</code> and page shell immediately, before any async data resolves. Browsers start downloading CSS and fonts while the server fetches data — so the user sees a styled shell within milliseconds.</p>
41
- ${codeBlock(highlight(`export default {
42
- route: '/feed',
43
-
44
- // Shell renders instantly — hero, nav, layout
45
- // Deferred segments wait for data then stream in
46
- stream: {
47
- shell: ['header', 'hero'],
48
- deferred: ['feed', 'sidebar'],
49
- },
50
-
51
- server: {
52
- feed: async () => db.posts.getLatest(20), // slow
53
- sidebar: async () => db.tags.getPopular(), // slow
54
- },
55
-
56
- // view is a keyed object matching stream segments
57
- view: {
58
- header: (state) => \`<header>...</header>\`,
59
- hero: (state) => \`<section class="hero">...</section>\`,
60
- feed: (state, server) => server.feed.map(renderPost).join(''),
61
- sidebar: (state, server) => renderSidebar(server.sidebar),
62
- },
63
- }`, 'js'))}
64
-
65
- ${section('compression', 'Automatic compression')}
66
- <p>All responses are compressed automatically. Pulse negotiates the best available encoding from the <code>Accept-Encoding</code> header:</p>
67
- ${table(
68
- ['Encoding', 'Priority', 'Typical savings'],
69
- [
70
- ['Brotli (<code>br</code>)', 'First choice', '20–30% smaller than gzip for text content'],
71
- ['gzip', 'Fallback', 'Widely supported, good compression'],
72
- ['None', 'Last resort', 'No compression (rare)'],
73
- ]
74
- )}
75
- <p>HTML, CSS, JavaScript, JSON, XML, and SVG responses are all compressed. Binary formats (images, fonts) are served as-is.</p>
76
-
77
- ${section('asset-caching', 'Immutable asset caching')}
78
- <p>Production JS bundles include a content hash in their filename (<code>/dist/counter.boot-a1b2c3d4.js</code>). The server sends <code>Cache-Control: public, max-age=31536000, immutable</code> for these files — browsers cache them forever and never re-request them unless the hash changes.</p>
79
- <p>When you deploy a new version, the hash changes, and users automatically get the new file. No cache-busting tricks needed.</p>
80
- ${callout('tip', 'Static assets in <code>public/</code> get <code>max-age=3600</code>. For rarely-updated images, consider versioning them by filename.')}
81
-
82
- ${section('zero-client-js', 'Zero client JS by default')}
83
- <p>Pages with no <code>mutations</code>, <code>actions</code>, or <code>persist</code> send no JavaScript at all — the HTML is entirely self-contained. The Pulse CLI detects this automatically; you never need to opt out. This is appropriate for static content pages: marketing pages, blog posts, documentation.</p>
84
- ${codeBlock(highlight(`// No mutations, actions, or persist → zero JS sent to browser
85
- export default {
86
- route: '/about',
87
- meta: { title: 'About', styles: ['/app.css'] },
88
- view: () => \`<main><h1>About us</h1></main>\`,
89
- }`, 'js'))}
90
-
91
- ${section('js-bundle-splitting', 'JS bundle splitting and caching')}
92
- <p>When you open the network tab you will see a single <code>[page].boot-[hash].js</code> file loading. What is inside that file depends on how many pages your app has:</p>
93
- ${table(
94
- ['App size', 'What the boot file contains', 'Size (brotli)'],
95
- [
96
- ['Single page', 'Your spec + the full Pulse runtime bundled together', '~4 kB'],
97
- ['Multiple pages', 'Your spec only — runtime is in a separate <code>runtime-[hash].js</code> chunk', '~0.4–0.9 kB'],
98
- ]
99
- )}
100
- <p>With multiple pages, esbuild's code splitting extracts the Pulse runtime into a shared chunk because every page imports it. The browser downloads it once and caches it — subsequent page navigations only fetch the small per-page boot file.</p>
101
- <p><strong>What you see in the network tab across navigations:</strong></p>
102
- <ul>
103
- <li><strong>First page visit</strong> — <code>runtime-[hash].js</code> (~3.8 kB) + <code>home.boot-[hash].js</code> (~0.4 kB)</li>
104
- <li><strong>Navigate to another page</strong> — <code>contact.boot-[hash].js</code> (~0.5 kB) only. Runtime already cached.</li>
105
- <li><strong>Return visit</strong> — nothing. Both files served from cache with <code>immutable</code> headers.</li>
106
- </ul>
107
- ${callout('tip', 'The runtime hash only changes when the Pulse runtime itself is updated — not when your app changes. Deploying new pages or mutations does not bust the runtime cache for returning visitors.')}
108
-
109
- ${section('security-headers', 'Security headers')}
110
- <p>Every response — including 404 and 500 errors — carries a full set of security headers automatically. There is no configuration step and no way to accidentally omit them:</p>
111
- ${table(
112
- ['Header', 'Value'],
113
- [
114
- ['<code>X-Content-Type-Options</code>', '<code>nosniff</code>'],
115
- ['<code>X-Frame-Options</code>', '<code>DENY</code>'],
116
- ['<code>Referrer-Policy</code>', '<code>strict-origin-when-cross-origin</code>'],
117
- ['<code>Permissions-Policy</code>', '<code>camera=(), microphone=(), geolocation=()</code>'],
118
- ['<code>Cross-Origin-Opener-Policy</code>', '<code>same-origin</code>'],
119
- ['<code>Cross-Origin-Resource-Policy</code>', '<code>same-origin</code>'],
120
- ]
121
- )}
122
- <p>These headers are applied automatically — no configuration needed.</p>
123
-
124
- ${section('browser-support', 'Browser support')}
125
- <p>Pulse ships modern JavaScript and CSS without transpilation or polyfills. The effective minimum is set by two features:</p>
126
- ${table(
127
- ['Constraint', 'Chrome', 'Firefox', 'Safari', 'Edge', 'Since'],
128
- [
129
- ['<code>?.</code> optional chaining (JS)', '80', '74', '13.1', '80', 'Feb – Mar 2020'],
130
- ['<code>gap</code> on flexbox (CSS)', '84', '63', '14.1', '84', 'Aug 2020 – Apr 2021'],
131
- ]
132
- )}
133
- <p>In practice, <strong>Safari 14.1 (April 2021) is the combined floor</strong> — browsers released after that date support everything Pulse uses. This covers roughly 95%+ of global traffic.</p>
134
- ${callout('note', 'No explicit <code>target</code> is set in the esbuild config, so syntax ships as written. If you need to support older browsers, set <code>target</code> in <code>scripts/build.js</code> and esbuild will downcompile optional chaining and other modern syntax automatically.')}
135
-
136
- ${section('cls', 'Preventing layout shift (CLS)')}
137
- <p>Pulse targets 0.00 CLS. Layout shift is caused by elements that change size or position after the initial paint. These rules prevent it:</p>
138
- <ul>
139
- <li>Always set <code>width</code> and <code>height</code> on images (use the <code>img()</code> helper)</li>
140
- <li>Never inject content above existing content after page load</li>
141
- <li>Use <code>aspect-ratio</code> CSS for embeds (videos, iframes)</li>
142
- <li>Avoid loading web fonts that cause FOUT — use <code>font-display: swap</code> or system fonts</li>
143
- </ul>
144
-
145
- ${section('lcp', 'Optimising LCP')}
146
- <p>LCP (Largest Contentful Paint) is typically your hero image or largest heading. Tips:</p>
147
- <ul>
148
- <li>Use <code>priority: true</code> on the LCP image — sets <code>fetchpriority="high"</code> and <code>loading="eager"</code></li>
149
- <li>Avoid blocking server fetches — use <code>stream</code> so the shell renders without waiting for data</li>
150
- <li>Keep your hero HTML inline (SSR) — never rely on client JS to render the LCP element</li>
151
- <li>Use modern image formats (AVIF, WebP) via the <code>picture()</code> helper</li>
152
- </ul>
153
-
154
- ${callout('warning', 'Never render your LCP element (hero image, main heading) client-side. If it requires a client JS import or a dynamic import, it will not paint until JS executes — pushing LCP from &lt;100ms to &gt;500ms.')}
155
- `,
156
- }),
157
- }
@@ -1,89 +0,0 @@
1
- import { renderLayout, h1, lead, section, sub, codeBlock, callout, table } from '../lib/layout.js'
2
- import { prevNext } from '../lib/nav.js'
3
- import { highlight } from '../lib/highlight.js'
4
-
5
- const { prev, next } = prevNext('/persist')
6
-
7
- export default {
8
- route: '/persist',
9
- meta: {
10
- theme: 'light',
11
- title: 'Persist — Pulse Docs',
12
- description: 'Persist client state across page refreshes using localStorage.',
13
- styles: ['/theme.css', '/docs.css'],
14
- },
15
- state: {},
16
- view: () => renderLayout({
17
- currentHref: '/persist',
18
- prev,
19
- next,
20
- content: `
21
- ${h1('Persist')}
22
- ${lead('The <code>persist</code> field declares which state keys survive page refreshes. Everything else resets. The list is explicit — nothing is persisted unless it is named here, and sensitive data should never appear in it.')}
23
-
24
- ${section('declaring', 'Declaring persistence')}
25
- <p><code>persist</code> is an array of dot-path strings. Each path points to a key in state that should be saved:</p>
26
- ${codeBlock(highlight(`export default {
27
- route: '/settings',
28
- state: {
29
- theme: 'light',
30
- fontSize: 16,
31
- sidebarOpen: true,
32
- user: {
33
- name: '',
34
- preferences: { notifications: true, newsletter: false },
35
- },
36
- },
37
- persist: [
38
- 'theme',
39
- 'fontSize',
40
- 'user.preferences',
41
- ],
42
- // ...
43
- }`, 'js'))}
44
- <p>In this example, <code>theme</code>, <code>fontSize</code>, and the entire <code>user.preferences</code> sub-object are saved to <code>localStorage</code>. The <code>sidebarOpen</code> key and <code>user.name</code> are session-only — they reset on each visit.</p>
45
-
46
- ${section('how-it-works', 'How it works')}
47
- <p>On every state update, Pulse serialises the persisted keys and writes them to <code>localStorage</code> under a key derived from the page route. On the next mount, persisted values are read back and merged over the spec's initial state before the view renders.</p>
48
-
49
- ${table(
50
- ['Step', 'What happens'],
51
- [
52
- ['First visit', 'State initialised from <code>spec.state</code>. Nothing in storage yet.'],
53
- ['User interacts', 'Mutations update state. Persisted keys are written to <code>localStorage</code>.'],
54
- ['Page refresh / return visit', 'Persisted values loaded from storage and merged over initial state. View renders with restored state.'],
55
- ]
56
- )}
57
-
58
- ${section('dot-paths', 'Dot-path notation')}
59
- <p>Persist keys use the same dot-path notation as <a href="/validation">validation</a>. Entire top-level keys or specific nested sub-objects can be persisted:</p>
60
- ${codeBlock(highlight(`persist: [
61
- 'theme', // top-level key
62
- 'user.preferences', // nested sub-object (entire object is saved)
63
- 'cart.items', // array of cart items
64
- ]`, 'js'))}
65
- ${callout('note', 'When persisting a nested path like <code>user.preferences</code>, the <em>entire value at that path</em> is saved and restored — not individual sub-keys within it.')}
66
-
67
- ${section('storage-key', 'Storage key')}
68
- <p>Pulse stores persisted state under <code>pulse:[route]</code> in <code>localStorage</code>. For example, a spec with <code>route: \'/settings\'</code> uses the key <code>pulse:/settings</code>. This namespacing prevents collisions between pages.</p>
69
-
70
- ${section('ssr', 'SSR and persistence')}
71
- <p>On the server, <code>localStorage</code> does not exist. The server always renders with the spec's initial state. Persisted values are applied on the client after hydration — before the first mutation, after mount.</p>
72
- ${callout('warning', 'If SSR and client state diverge significantly due to persisted values, a content flash may occur. Best practice: keep persisted state to preferences and settings that do not affect the main page content.')}
73
-
74
- ${section('clearing', 'Clearing persisted state')}
75
- <p>To clear persisted state programmatically, remove the relevant key from <code>localStorage</code>:</p>
76
- ${codeBlock(highlight(`localStorage.removeItem('pulse:/settings')`, 'js'))}
77
- <p>Or use a mutation that resets the persisted fields to their initial values — Pulse will save the reset values on the next update.</p>
78
-
79
- ${section('best-practices', 'Best practices')}
80
- <ul>
81
- <li>Persist preferences and settings (theme, language, layout choices)</li>
82
- <li>Persist shopping cart contents or draft form data</li>
83
- <li>Never persist sensitive data (tokens, passwords, PII) — <code>localStorage</code> is not secure storage</li>
84
- <li>Never persist data that must be authoritative — use <a href="/server-data">server data</a> for anything that should be fresh from the server</li>
85
- <li>Keep persisted payloads small — <code>localStorage</code> has a ~5 MB limit and blocks the main thread if abused</li>
86
- </ul>
87
- `,
88
- }),
89
- }
@@ -1,91 +0,0 @@
1
- import { renderLayout, h1, lead, section, sub, codeBlock, callout, table } from '../lib/layout.js'
2
- import { prevNext } from '../lib/nav.js'
3
- import { highlight } from '../lib/highlight.js'
4
-
5
- const { prev, next } = prevNext('/project-structure')
6
-
7
- export default {
8
- route: '/project-structure',
9
- meta: {
10
- theme: 'light',
11
- title: 'Project Structure — Pulse Docs',
12
- description: 'Recommended directory layout for a Pulse application.',
13
- styles: ['/theme.css', '/docs.css'],
14
- },
15
- view: () => renderLayout({
16
- currentHref: '/project-structure',
17
- prev,
18
- next,
19
- content: `
20
- ${h1('Project Structure')}
21
- ${lead('Pulse enforces a single convention: one spec per page, one directory per concern. The structure is not a recommendation — it is how the framework discovers and registers your pages. There is nothing to configure because there is nothing to decide.')}
22
-
23
- ${section('layout', 'Layout')}
24
- ${codeBlock(highlight(`my-app/
25
- ├── package.json
26
- ├── public/ # static assets served directly
27
- │ ├── app.css
28
- │ └── dist/ # generated bundles (pulse build)
29
- │ ├── manifest.json
30
- │ ├── runtime-[hash].js
31
- │ └── [name].boot-[hash].js
32
- └── src/
33
- ├── pages/ # one file per page — auto-discovered
34
- │ ├── home.js
35
- │ ├── about.js
36
- │ └── contact.js
37
- ├── lib/ # shared helpers
38
- │ └── db.js
39
- └── components/ # shared view fragments (optional)
40
- └── card.js`, 'bash'))}
41
-
42
- ${section('pages', 'src/pages/')}
43
- <p>Each file in <code>src/pages/</code> exports a single spec as the default export. <code>pulse dev</code> auto-discovers every file in this directory and registers it as a route. There is no route registry to maintain — the file is the registration.</p>
44
-
45
- ${callout('note', 'Routes are derived from filenames by default (<code>about.js</code> → <code>/about</code>). For dynamic segments, <code>route</code> is set explicitly in the spec.')}
46
-
47
- ${section('public', 'public/')}
48
- <p>Static files served directly — CSS, fonts, images. Referenced via <code>meta.styles</code> in the spec. The <code>dist/</code> subdirectory is generated by <code>pulse build</code>. Its contents are content-hashed and must not be edited manually.</p>
49
-
50
- ${table(
51
- ['Path', 'Purpose'],
52
- [
53
- ['<code>public/app.css</code>', 'Your global stylesheet — reference it in spec <code>meta.styles</code>'],
54
- ['<code>public/dist/</code>', 'Generated bundles from <code>npm run build</code> — do not edit manually'],
55
- ['<code>public/dist/manifest.json</code>', 'Maps spec hydrate paths to hashed bundle filenames'],
56
- ]
57
- )}
58
-
59
- ${section('lib', 'src/lib/')}
60
- <p>Shared helpers used across multiple pages — database clients, API wrappers, utility functions. Imported directly into specs. Plain JavaScript modules with no framework coupling — they work identically in tests, scripts, or other contexts.</p>
61
-
62
- ${section('components', 'src/components/ (optional)')}
63
- <p>Reusable view fragments. Since views are just functions that return HTML strings, a component is simply a function:</p>
64
-
65
- ${codeBlock(highlight(`// src/components/card.js
66
- export function card({ title, body }) {
67
- return \`
68
- <div class="card">
69
- <h2>\${title}</h2>
70
- <p>\${body}</p>
71
- </div>
72
- \`
73
- }`, 'js'), 'src/components/card.js')}
74
-
75
- ${codeBlock(highlight(`// src/pages/home.js
76
- import { card } from '../components/card.js'
77
-
78
- export default {
79
- route: '/',
80
- view: () => card({ title: 'Hello', body: 'Welcome to Pulse' }),
81
- }`, 'js'), 'src/pages/home.js')}
82
-
83
- ${section('one-file-per-page', 'One file per page')}
84
- <p>Pulse enforces <strong>one spec per file</strong>. Every page is self-contained — state, view, mutations, actions, and validation in one place. This is not a style preference. It is how the framework eliminates the question of where things live.</p>
85
-
86
- <p>This holds for dynamic routes too: <code>route: '/blog/:slug'</code> is <strong>one spec serving every matching URL</strong> — the <code>:param</code> does the multiplying, so a thousand blog posts are still a single file. A site is therefore a small collection of specs, one per route, sharing layouts and sections from <code>src/components/</code> rather than repeating them.</p>
87
-
88
- ${callout('tip', 'For large pages, view fragments and helpers can be imported from other modules. The spec file remains the coordination point — the structure is always clear regardless of how the implementation is organised.')}
89
- `,
90
- }),
91
- }