@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
package/AGENTS.md DELETED
@@ -1,697 +0,0 @@
1
- # Pulse — AI Agent Guide
2
-
3
- Pulse is a spec-first frontend framework. The spec is the source of truth. No codegen, no virtual DOM, no dependencies (esbuild is dev-only).
4
-
5
- ## Commands
6
-
7
- ```bash
8
- npm run dev # start dev server at http://localhost:3000 (pulse_build serves production on 3001)
9
- npm test # run the full unit test suite
10
- npm run build # bundle for production → public/dist/
11
- ```
12
-
13
- ## Project Structure
14
-
15
- ```
16
- src/
17
- spec/schema.js # authoritative spec definition + validation
18
- runtime/index.js # client mount() + dispatch + constraints
19
- runtime/ssr.js # server-side rendering (string + stream)
20
- runtime/navigate.js # client-side navigation + history
21
- server/index.js # HTTP server (Node built-in, zero deps)
22
- examples/
23
- counter.js # simple client-only spec
24
- contact.js # spec with server data + async action
25
- dev.server.js # combined dev server for both specs
26
- scripts/
27
- build.js # esbuild bundler — generates public/dist/
28
- public/
29
- pulse.css # base stylesheet (dark theme)
30
- dist/ # generated — do not edit
31
- runtime-[hash].js # shared runtime chunk (mount + navigate)
32
- [name].boot-[hash].js # per-page spec bundle
33
- manifest.json # source hydrate paths → bundle paths
34
- ```
35
-
36
- ## The Spec
37
-
38
- A spec is a plain JS object. Every property is optional except `route` and `view`. `state` defaults to `{}` — omit it on purely server-rendered pages.
39
-
40
- **One spec = one page = one file.** A site is multiple specs, one per route. A dynamic route (`route: '/blog/:slug'`) is still a single spec serving every matching URL — never one spec per item, and never several pages folded into one spec with `ctx` conditionals. Shared sections live in `src/components/` and are imported by each spec.
41
-
42
- ```js
43
- export const mySpec = {
44
- route: '/path', // URL pattern, supports :params. Use '*' for the custom
45
- // not-found page — rendered with status 404 when no route matches
46
-
47
- meta: {
48
- title: 'Page Title',
49
- description: 'Meta description',
50
- styles: ['/pulse.css'],
51
- theme: 'light', // 'light' | 'dark' — ⚠ default is DARK when unset; decide at write time
52
- vibe: 'minimal', // optional preset: radius, weight, spacing
53
- ogTitle: '...',
54
- ogImage: '...',
55
- },
56
-
57
- // Server data — resolved before render, passed to view as second arg
58
- server: {
59
- data: async (ctx) => ({ ... }) // ctx has params, query, headers, cookies
60
- },
61
-
62
- // Timeout for all server fetchers on this page (ms). Overrides createServer fetcherTimeout.
63
- serverTimeout: 5000,
64
-
65
- // Query-string keys that change this page's output, for the in-process page cache.
66
- // The cache IGNORES the query string by default — arbitrary ?n=… params cannot mint
67
- // unbounded cache entries (memory-DoS guard). If the rendered output depends on a
68
- // query param, list it here so distinct values get distinct cache entries.
69
- cacheVary: ['q', 'page'],
70
-
71
- // Sitemap inclusion (when createServer has sitemap: true).
72
- // Static routes are included automatically — only set this to:
73
- // false → exclude this page
74
- // true → include a guarded page (excluded by default)
75
- // async () => [...] → enumerate a dynamic :param route's URLs
76
- sitemap: async () => (await db.posts.list()).map(p => `/blog/${p.slug}`),
77
-
78
- // Global store keys this page subscribes to — appears in view's server arg.
79
- // The store is defined in pulse.store.js at the project root — AUTO-DISCOVERED
80
- // by pulse dev/start (no registration step). Mutations update all subscribed
81
- // pages without a server round-trip.
82
- store: ['user', 'cart'],
83
-
84
- // Initial client state — deep cloned on mount, never mutated directly
85
- // Optional — omit entirely on purely server-rendered pages (no mutations/actions/persist)
86
- state: { count: 0 },
87
-
88
- // Declarative min/max bounds — always enforced after mutations
89
- constraints: {
90
- count: { min: 0, max: 10 }
91
- },
92
-
93
- // Validation rules — checked when action.validate === true
94
- validation: {
95
- 'fields.email': { required: true, format: 'email' },
96
- 'fields.name': { required: true, minLength: 2, maxLength: 100 }
97
- // formats: email | url | numeric
98
- // rules: required | minLength | maxLength | min | max | pattern
99
- },
100
-
101
- // Pure function(s) — return HTML string, no side effects
102
- // Can be a single function or a keyed object of segment functions
103
- view: (state, server) => `<main>...</main>`,
104
-
105
- // Optional — called client-side when view() throws. Return an HTML string.
106
- // Without this, the runtime shows an inline error message and logs to console.
107
- // On the server, a throwing view always propagates to the server error handler
108
- // unless onViewError is defined, in which case it returns the fallback HTML with 200.
109
- onViewError: (err, state, serverState) => `<p>Something went wrong</p>`,
110
-
111
- // Synchronous state changes — return partial state to merge
112
- mutations: {
113
- increment: (state, event) => ({ count: state.count + 1 }),
114
- },
115
-
116
- // Async operations — lifecycle: onStart → validate → run → onSuccess/onError
117
- actions: {
118
- submit: {
119
- onStart: (state, formData) => ({ status: 'loading', ... }),
120
- validate: true, // runs validation before run()
121
- run: async (state, serverState, formData) => { /* fetch, etc */ },
122
- onSuccess: (state, payload) => ({
123
- status: 'success',
124
- _toast: { message: 'Saved!', variant: 'success' }, // show a toast
125
- }),
126
- onError: (state, err) => ({
127
- status: 'error',
128
- errors: err?.validation ?? [{ message: err.message }],
129
- _toast: { message: 'Something went wrong', variant: 'error' },
130
- }),
131
- }
132
- },
133
-
134
- // Server-side form handling — POST to this route, no client JS required.
135
- // CSRF protection is automatic: include ${server.csrf} inside the <form>.
136
- // { redirect: '/path' } → 303 POST-redirect-GET
137
- // anything else → page re-renders; return value appears as server.form
138
- // (optional status field sets the response status, e.g. 422)
139
- submit: async (ctx) => {
140
- const data = await ctx.formData()
141
- if (!data?.email) return { errors: { email: 'Required' }, values: data ?? {} }
142
- await saveSignup(data)
143
- return { redirect: '/thanks' }
144
- },
145
- // csrf: false, // opt out ONLY for endpoints with their own auth (e.g. signed webhooks)
146
-
147
- // Streaming SSR — split view into shell (instant) + deferred segments
148
- stream: {
149
- shell: ['header', 'nav'],
150
- deferred: ['feed'],
151
-
152
- // Optional: scope each segment to only the server fetchers it needs.
153
- // Shell sends as soon as its own fetchers resolve — deferred-only fetchers
154
- // never block the shell. Each deferred segment streams independently.
155
- // Omit scope (or omit a segment from scope) to give it all server state.
156
- scope: {
157
- header: ['user'], // 'user' fetcher resolves → shell writes
158
- nav: ['user'],
159
- feed: ['posts', 'ads'], // 'posts' + 'ads' fetchers resolve → feed writes
160
- }
161
- }
162
- }
163
-
164
- export default mySpec // required for hydration imports
165
- ```
166
-
167
- ## Server-Side Forms (no client JS)
168
-
169
- A spec with `submit` accepts POST on its route — the form works with JavaScript disabled. **Always include `${server.csrf}` inside the form** (CSRF is enforced; POSTs without the token get 403):
170
-
171
- ```js
172
- view: (state, server) => `
173
- <main id="main-content">
174
- ${server.form?.errors?.email ? alert({ variant: 'error', message: server.form.errors.email }) : ''}
175
- <form method="POST">
176
- ${server.csrf}
177
- ${input({ label: 'Email', name: 'email', type: 'email', required: true, value: server.form?.values?.email ?? '' })}
178
- ${button({ label: 'Sign up', type: 'submit' })}
179
- </form>
180
- </main>`,
181
- ```
182
-
183
- - `server.form` is the return value of `submit()` on a re-render (validation errors, submitted values) — `undefined` on plain GET.
184
- - **Progressive enhancement:** the same form can also carry `data-action="signup"` — hydrated visitors get the async action (no page reload), no-JS visitors fall back to the POST. Echo submitted values back into inputs via `value=` so a failed validation doesn't wipe the form.
185
- - Always `return { redirect }` after a successful mutation (POST-redirect-GET) — never render success directly from the POST, or refresh resubmits the form.
186
- - Multi-instance deployments: set a stable `secret` in `createServer` so CSRF tokens validate across instances.
187
-
188
- ## Custom 404 Page
189
-
190
- Create a spec with `route: '*'` — it renders through the normal pipeline (layout, styles, hydration, validation) with status 404 whenever no route matches:
191
-
192
- ```js
193
- // src/pages/not-found.js
194
- export default {
195
- route: '*',
196
- meta: { title: 'Page not found', styles: ['/pulse-ui.css', '/theme.css', '/app.css'] },
197
- view: () => `<main id="main-content"><h1>Page not found</h1><p><a href="/">Back home</a></p></main>`,
198
- }
199
- ```
200
-
201
- Without a `'*'` spec, the framework's plain default 404 is served. 500s are customised via `createServer`'s `onError` option.
202
-
203
- ## HTML Event Binding
204
-
205
- Pulse binds to DOM attributes — no JSX, no templates.
206
-
207
- ```html
208
- <button data-event="increment">+</button> <!-- click → mutation -->
209
- <input data-event="change:setName"> <!-- change → mutation -->
210
- <input data-event="input:setQuery"> <!-- input → mutation -->
211
- <input data-event="input:search" data-debounce="300"> <!-- debounced input (300ms) -->
212
- <input data-event="input:filter" data-throttle="100"> <!-- throttled input (100ms) -->
213
- <form data-action="submit">...</form> <!-- submit → action, passes FormData -->
214
- <button data-store-event="toggleTheme">Toggle</button> <!-- click → store mutation -->
215
- <select data-store-event="change:setLang">...</select> <!-- change → store mutation -->
216
- <button data-dialog-open="my-modal">Open modal</button> <!-- opens <dialog id="my-modal"> -->
217
- <button data-dialog-close>Cancel</button> <!-- closes nearest ancestor <dialog> -->
218
- ```
219
-
220
- **List keys — use `data-key` on repeated elements** to enable key-based DOM reconciliation. When all element children of a container carry `data-key`, the runtime matches nodes by key instead of position — inserts, removals, and reorders become O(1) rather than O(n) patches, and existing DOM nodes (inputs, images) are preserved correctly:
221
-
222
- ```html
223
- <ul>
224
- ${items.map(item => `<li data-key="${item.id}">${item.name}</li>`).join('')}
225
- </ul>
226
- ```
227
-
228
- All sibling elements in the container must have `data-key` to activate key mode — mixed keyed/unkeyed siblings fall back to position-based matching.
229
-
230
- **Modal pattern — never use `state.modalOpen`.** Always render the `<dialog>` in the DOM unconditionally and use `data-dialog-open` to show it. ESC key, backdrop click, and `<form method="dialog">` all close it natively with no spec state.
231
-
232
- **Important:** Do not use `data-event` on text inputs to mirror their value into state. The `innerHTML` replacement on every keystroke destroys focus. Instead, use uncontrolled inputs and capture `FormData` in `action.onStart` before `validate` runs.
233
-
234
- ## Dev vs Production Hydration
235
-
236
- **Dev mode** — the HTML includes an inline bootstrap script importing source files directly:
237
- ```html
238
- <script type="module">
239
- import spec from '/examples/counter.js'
240
- import { mount } from '/src/runtime/index.js'
241
- import { initNavigation } from '/src/runtime/navigate.js'
242
- mount(spec, root, window.__PULSE_SERVER__ || {}, { ssr: true })
243
- initNavigation(root, mount)
244
- </script>
245
- ```
246
-
247
- **Production** — after `npm run build`, `spec.hydrate` is resolved via `manifest.json` to a content-hashed bundle. The HTML becomes a single external tag:
248
- ```html
249
- <script type="module" src="/dist/counter.boot-HASH.js"></script>
250
- ```
251
-
252
- The bundle is self-executing (imports spec, calls `mount` + `initNavigation` internally). The `{ ssr: true }` option tells `mount` to skip the initial re-render and only bind events — this preserves the SSR-painted LCP element.
253
-
254
- ## Build Output
255
-
256
- `npm run build` generates three things per app:
257
-
258
- - `public/dist/runtime-[hash].js` — shared runtime (mount + navigate + store, ~3.8 kB brotli)
259
- - `public/dist/[name].boot-[hash].js` — per-page spec bundle (~0.5–0.9 kB brotli)
260
- - `public/dist/manifest.json` — maps `/examples/foo.js` → `/dist/foo.boot-HASH.js`
261
-
262
- To add a new page to the build, add its spec path to the `ENTRIES` array in `scripts/build.js`.
263
-
264
- The server auto-detects the manifest from `staticDir/dist/manifest.json` when `staticDir` is set. No config needed.
265
-
266
- ## Server
267
-
268
- ```js
269
- import { createServer } from './src/server/index.js'
270
-
271
- // Pass URL objects — hydrate is auto-derived, no need to set it in specs.
272
- // Specs with mutations/actions/persist are hydrated automatically.
273
- // Purely server-rendered specs get zero JavaScript.
274
- await createServer(
275
- [
276
- new URL('./src/pages/home.js', import.meta.url),
277
- new URL('./src/pages/contact.js', import.meta.url),
278
- ],
279
- {
280
- port: 3000,
281
- stream: true, // streaming SSR (default)
282
- staticDir: 'public', // serve static files + auto-load manifest
283
- root: new URL('.', import.meta.url), // project root for deriving browser paths
284
- manifest: null, // explicit manifest path or object (overrides auto-detect)
285
- defaultCache: 3600, // default HTML cache TTL in seconds for all pages (prod only)
286
- // also accepts true (3600s + swr 86400s) or { public, maxAge, staleWhileRevalidate }
287
- // spec.cache overrides per-page; in-process + Cache-Control headers both set
288
- fetcherTimeout: 5000, // ms before any server fetcher times out (null = no limit)
289
- // spec.serverTimeout overrides per-page
290
- shutdownTimeout: 30000, // ms to wait for in-flight requests before force-exit on SIGTERM/SIGINT
291
- healthCheck: '/healthz', // built-in health endpoint path, or false to disable
292
- secret: process.env.PULSE_SECRET, // HMAC secret for CSRF tokens — set a stable value
293
- // when running multiple instances (default: random per boot)
294
- sitemap: true, // auto-serve /sitemap.xml + robots.txt from registered routes
295
- // also accepts { origin: 'https://mysite.com' } to pin the URL origin;
296
- // dynamic :param routes contribute via spec.sitemap enumerators;
297
- // a physical sitemap.xml/robots.txt in staticDir always wins
298
- robots: null, // robots.txt when sitemap on: null = auto, false = off, string = verbatim
299
- redirects: { // declarative redirects for legacy URLs — 301 by default,
300
- '/old-blog/:slug': '/blog/:slug', // :params carry over, query string preserved
301
- '/promo': { to: '/pricing', status: 302 }, // custom status: 301 | 302 | 307 | 308
302
- }, // checked before routing (GET/HEAD only); validated at startup
303
- live: true, // SSE store-push channel (default path /__pulse/live) —
304
- // createServer returns pushStore(partial) for broadcasting
305
- csp: { // extra sources merged into the framework's default CSP
306
- 'style-src': ['https://fonts.googleapis.com'],
307
- 'font-src': ['https://fonts.gstatic.com'],
308
- },
309
- resolveBrand: async (host) => db.brands.findBySlug(host.split('.')[0]),
310
- // multi-brand: result cached 60s, attached to ctx.brand
311
- onRequest: (req, res) => { /* return false to short-circuit */ },
312
- onError: (err, req, res) => { /* custom error handling */ }
313
- }
314
- )
315
- ```
316
-
317
- `createServer` is async — always `await` it. Spec objects are still accepted alongside URL objects for backwards compatibility.
318
-
319
- All specs are validated at startup — bad specs throw before the server accepts connections.
320
-
321
- ## Live Store Push (SSE)
322
-
323
- With `live: true`, `createServer` returns a **`pushStore(partial)`** function that broadcasts a store patch to every connected browser. Pages subscribed via `spec.store` re-render immediately — no polling, no client code:
324
-
325
- ```js
326
- // Enable in pulse.config.js (CLI-managed apps) or createServer options:
327
- export default { live: true }
328
- ```
329
-
330
- ```js
331
- // Broadcast from any spec file — e.g. a webhook that updates stock:
332
- import { pushStore } from '@invisibleloop/pulse'
333
-
334
- export default {
335
- route: '/hooks/stock',
336
- contentType: 'text/plain',
337
- state: {},
338
- render: async (ctx) => {
339
- const { sku, stock } = await ctx.json()
340
- pushStore({ stock }) // every page with store: ['stock'] re-renders
341
- return 'ok'
342
- },
343
- }
344
- ```
345
-
346
- When you own the server entry, `createServer` also returns the handle directly: `const { pushStore } = await createServer(pages, { live: true })`.
347
-
348
- - The client connects automatically — only on pages that declare `store: [...]` keys; one EventSource per tab, survives client navigations, reconnects natively.
349
- - **Broadcast channel: shared data only** (stock counts, announcements, live scores). Never push per-user data — every connected client receives every patch.
350
- - Views must handle keys that haven't arrived yet: `server.stock ?? '—'`.
351
-
352
- ## Multi-brand Sites
353
-
354
- Pass `resolveBrand: async (host) => brandConfig` to `createServer`. The result is cached per host for 60 seconds and attached to `ctx.brand`. It is available in `guard`, `server` fetchers, and any `meta` field that is a function.
355
-
356
- Any `meta` field can be a function `(ctx) => value` — called per request, not at startup. Meta functions can also be **async**, so you can fetch data for `title`, `description`, `ogImage` etc.:
357
-
358
- ```js
359
- export default {
360
- meta: {
361
- title: async (ctx) => {
362
- const product = await fetchProduct(ctx.params.id)
363
- return product ? `${product.name} — Store` : 'Store'
364
- },
365
- styles: (ctx) => ['/pulse-ui.css', `/themes/${ctx.brand.slug}.css`],
366
- },
367
- server: {
368
- brand: (ctx) => ctx.brand, // expose to view as serverState.brand
369
- },
370
- view: (state, { brand }) => `<h1>${brand.name}</h1>`,
371
- guard: async (ctx) => {
372
- if (!ctx.brand) return { redirect: '/not-found' }
373
- },
374
- }
375
- ```
376
-
377
- If `meta` and `server` both need the same API data, use a **request-scoped cache** on `ctx` to avoid fetching twice. `ctx` is created fresh per request, so `ctx._cache` never leaks between requests:
378
-
379
- ```js
380
- // shared-fetchers.js
381
- export async function getProduct(ctx) {
382
- ctx._cache ??= {}
383
- ctx._cache.product ??= await db.products.find(ctx.params.id)
384
- return ctx._cache.product
385
- }
386
- ```
387
-
388
- ```js
389
- // spec
390
- import { getProduct } from './shared-fetchers.js'
391
-
392
- export default {
393
- meta: {
394
- title: async (ctx) => {
395
- const p = await getProduct(ctx) // fetches once, cached on ctx
396
- return p ? `${p.name} — Store` : 'Store'
397
- },
398
- },
399
- server: {
400
- product: (ctx) => getProduct(ctx), // hits the same cache, no second fetch
401
- },
402
- }
403
- ```
404
-
405
- Keep brand differences in CSS custom properties — one `/pulse-ui.css` for components, one small `/themes/slug.css` per brand that overrides `:root` variables only.
406
-
407
- ## Custom Fonts
408
-
409
- `pulse-ui.css` exposes `--ui-font: var(--font, system-ui, ...)` and `--ui-mono: var(--mono, ...)`. All components use these tokens. To set a custom font, override `--font` in `:root` — it cascades into every component automatically.
410
-
411
- ```css
412
- /* app.css */
413
- :root {
414
- --font: 'Inter', system-ui, sans-serif;
415
- }
416
- ```
417
-
418
- Load the font via `meta.styles` before `pulse-ui.css`, or self-host in `staticDir/fonts/` with `@font-face`:
419
-
420
- ```js
421
- meta: {
422
- styles: [
423
- 'https://fonts.googleapis.com/css2?family=Inter&display=swap',
424
- '/pulse-ui.css',
425
- '/app.css',
426
- ]
427
- }
428
- ```
429
-
430
- For multi-brand setups, each brand theme file just overrides `--font` in `:root`.
431
-
432
- ## Overriding Theme Colours
433
-
434
- Use **input tokens** (unprefixed: `--accent`, `--bg`, `--text`, etc.) in `theme.css`. `pulse-ui.css` maps them to output tokens via `var()` in **both** the dark (`:root`) and light (`[data-theme="light"]`) blocks:
435
-
436
- ```css
437
- /* Dark theme — in :root */
438
- :root {
439
- --accent: #9b8dff;
440
- }
441
-
442
- /* Light theme — in [data-theme="light"] */
443
- [data-theme="light"] {
444
- --accent: #e25; /* overrides the light default */
445
- }
446
- ```
447
-
448
- Setting `--accent` in `[data-theme="light"]` works correctly — `pulse-ui.css` resolves `var(--accent, ...)` there just like it does in `:root`.
449
-
450
- If you need to override an output token directly (e.g. one with no input equivalent), target `[data-theme="light"]` explicitly:
451
-
452
- ```css
453
- /* Direct output token override for light theme only */
454
- [data-theme="light"] {
455
- --ui-nav-sticky-bg: rgba(255, 255, 255, 0.95);
456
- }
457
- ```
458
-
459
- ## CSS File Responsibilities
460
-
461
- There are two CSS files and they have distinct roles — do not mix them:
462
-
463
- | File | Purpose | Allowed content |
464
- |---|---|---|
465
- | `public/theme.css` | Token definitions — hex values, raw colours, font URLs | Hex values, `rgb()`, `rgba()`, `hsl()`, `hsla()`, raw values, `@font-face`, `@import` |
466
- | `app.css` (or per-page) | Layout and component overrides | `var()` tokens **only** — no hex values |
467
-
468
- **`app.css` must never contain hex values.** `rgba()` and `hsla()` are allowed for translucency — but if the same translucent colour is used more than once, extract it as a token in `theme.css` (e.g. `--brand-overlay-12: rgba(0,0,0,0.12)`). If you need to define or override a colour token, put it in `public/theme.css` and reference it via `var()` in `app.css`.
469
-
470
- **CSS for a section used by more than one spec 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. Per-page stylesheets are only for styles a single page uses.
471
-
472
- ```css
473
- /* public/theme.css — hex values live here */
474
- [data-theme="light"] {
475
- --color-accent: #e25;
476
- --color-bg: #ffffff;
477
- }
478
-
479
- /* app.css — only var() references */
480
- .hero {
481
- background: var(--color-bg);
482
- color: var(--color-accent);
483
- }
484
- ```
485
-
486
- Load both files via `meta.styles`, theme first:
487
-
488
- ```js
489
- meta: {
490
- styles: ['/pulse-ui.css', '/theme.css', '/app.css'],
491
- }
492
- ```
493
-
494
- ## HTTP Response Behaviour
495
-
496
- - **Full page request** — SSR HTML with hydration bootstrap
497
- - **`X-Pulse-Navigate: true`** — returns JSON `{ html, title, hydrate, serverState }` for client-side navigation
498
- - **Security headers** — on every response: `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy`, `Cross-Origin-Opener-Policy`, `Cross-Origin-Resource-Policy`
499
- - **Compression** — brotli preferred, gzip fallback for all compressible types
500
- - **Cache** — `/dist/*` bundles: `immutable, max-age=31536000`; static assets: `max-age=3600`; HTML: `no-store`
501
-
502
- ## Client Navigation
503
-
504
- `initNavigation(root, mount)` intercepts same-origin `<a>` clicks:
505
- 1. Fetches the new page with `X-Pulse-Navigate: true`
506
- 2. Swaps `root.innerHTML` + updates `document.title`
507
- 3. Dynamically imports the new spec bundle (`import(hydrate)`)
508
- 4. Calls `mount(spec, root, serverState)` to re-attach interactivity
509
-
510
- Falls back to `location.href` on any error.
511
-
512
- ## Testing
513
-
514
- Tests use Node.js built-in `node:test` and `node:assert`. Each test file is run directly:
515
-
516
- ```bash
517
- node src/spec/schema.test.js
518
- node src/runtime/runtime.test.js
519
- node src/runtime/ssr.test.js
520
- node src/server/server.test.js
521
- ```
522
-
523
- Server tests use an incrementing port counter (`withServer` helper) to avoid TCP TIME_WAIT conflicts between tests. Each test gets its own port.
524
-
525
- ### View testing — `@invisibleloop/pulse/testing`
526
-
527
- Use `renderSync` and `render` to test view HTML output. Both return a `RenderResult` with CSS-like query helpers — no DOM, no jsdom, pure Node.js.
528
-
529
- ```js
530
- import { renderSync, render } from '@invisibleloop/pulse/testing'
531
-
532
- // Sync — calls view directly, pass mock state/server
533
- const result = renderSync(mySpec, { state: { count: 5 }, server: { items: [] } })
534
- result.has('button') // → true/false
535
- result.get('#count').text // → '5' (throws if not found)
536
- result.find('.title')?.text // → string | null
537
- result.findAll('li') // → Element[]
538
- result.count('li') // → number
539
- result.attr('input[name="email"]', 'value') // → string | null
540
- result.text() // → all text, tags stripped
541
-
542
- // Async — runs real spec.server fetchers (pass server to mock them)
543
- const result = await render(mySpec, { server: { product: mockProduct } })
544
- const result = await render(mySpec, { ctx: { params: { id: '1' } } }) // real fetchers
545
- ```
546
-
547
- **Supported selectors:** `button`, `.class`, `#id`, `[attr]`, `[attr="value"]`, and combinations: `button.primary[type="submit"]`. **Descendant combinators are supported:** `result.count('.parent li')` or `result.find('tbody tr')`.
548
-
549
- `Element` also has `.find()`, `.findAll()`, `.has()`, `.attr()`, `.text`, `.tag`, `.attrs`.
550
-
551
- ## Key Decisions (Do Not Reverse)
552
-
553
- | Decision | Reason |
554
- |---|---|
555
- | `{ ssr: true }` skips initial render in `mount()` | Preserves SSR-painted LCP — re-rendering would cause a flash and push LCP to ~500ms |
556
- | `onStart` captures `FormData` before `validate` runs | Uncontrolled inputs — `innerHTML` replacement on keystroke destroys focus |
557
- | Self-executing bundles with `export default spec` | Single HTTP request for JS; spec exported so client navigation can re-mount |
558
- | `splitting: true` in esbuild | Shared runtime extracted once, cached across all page navigations |
559
- | `window.__PULSE_SERVER__` injected by `renderToStream` | Streaming path must serialize server state into HTML — client hydration reads it without a second request |
560
- | Bundle path detection via `/dist/` prefix | `wrapDocument` and streaming path emit `<script src>` for bundles, inline bootstrap for dev source files |
561
- | Security headers on every response, including 404/405 | Defense in depth — error responses are also entry points |
562
-
563
- ## Build Workflow
564
-
565
- ### New page or new site? Start with intake — always
566
-
567
- For any **new page, landing page, or branded site**, run this sequence first. Do not skip to building.
568
-
569
- ```
570
- 0. pulse_extract_inspiration(url/image) → structured design brief [if user shares reference]
571
- 1. pulse_intake(name, pitch, features, ...) → product brief + contrast check
572
- 2. pulse_sketch(brief, vibe?, antiStyle?) → 3 layout directions to choose from
573
- 3. pulse_intent(description) → archetype + scaffold + guide list
574
- ```
575
-
576
- **Before calling `pulse_intake`**, gather the required information by asking the user — one question at a time, in plain prose, no bullet lists. You need at minimum: the product name, what it does (pitch), and its key features. **Always ask first whether they have design inspiration** (a site they love, a screenshot, a mood board — dropped into `public/intake/` or pasted as a URL), and check `public/intake/` for images already there. Then ask for palette, **theme (light or dark — Pulse defaults to dark when unset)**, vibe, and anti-style if not mentioned.
577
-
578
- **`pulse_sketch` is mandatory** for `playful`, `bold`, `brutalist`, `retro`, or `neon` vibes — those vibes fight the default centred-hero layout. Skip it only for `corporate` or `minimal` when no structural preference has been stated.
579
-
580
- For **small edits, bug fixes, or "add X to existing Y"** — skip intake/sketch and go straight to Understand below.
581
-
582
- ---
583
-
584
- Every build task follows this sequence. Each phase has a pass gate — do not advance until it clears.
585
-
586
- **Before calling any slow tool (`pulse_build`, `lighthouse_audit`), output a status message to the user first** — e.g. "Building for production — ~30 s…" or "Running Lighthouse desktop audit…". Never call a slow tool silently.
587
-
588
- > **Canonical workflow:** `pulse://workflow` (`src/agent/workflow.md`) is the authoritative version, including complexity tiers (not every task needs all phases). The table below is the summary — if they ever disagree, the MCP resource wins.
589
-
590
- | Phase | Action | Gate |
591
- |---|---|---|
592
- | 1. Understand | Read guides, call `pulse_list_structure` | — |
593
- | 2. Plan | Present plan to user, wait for confirmation | User confirms |
594
- | 3. Build | Write spec + related files | — |
595
- | 4. Validate | `pulse_validate` — fix all errors + warnings | Clean output |
596
- | 5a. Design approval *(new builds only)* | Screenshot → describe → ask the user | ⛔ User approves layout before any slow checks run (skip for edits/fixes) |
597
- | 5b. Browser | `pulse_design_review` (if intake ran) → `pulse_layout_review` → `/verify` (Lighthouse desktop + mobile + `pulse_review`) | ⛔ Do not skip: all gates must pass + `.pulse-verified` stamp written |
598
- | 6. Tests | Write tests, run them, fix failures | All pass |
599
- | 7. Code Review | `pulse_review` — only after phases 4–6 pass | — |
600
- | 8. Fix | Fix every review issue, re-run affected gates | All gates still pass |
601
-
602
- **Skip phase 2 confirmation only for trivially small, unambiguous tasks.** When in doubt, confirm.
603
-
604
- **Template mode exception:** when adapting a pre-built template (see `pulse://guide/templates`), replace the full plan with a single compact confirmation block — template, substitutions, files. One turn is enough; do not run the full phase 2 ceremony.
605
-
606
- **The Code Review is always last.** Never invoke `pulse_review` before validate, Lighthouse, design review, and tests all pass.
607
-
608
- The `/verify` command runs the browser check loop (phases 4–5b) automatically. Use it.
609
-
610
- ## Pulse vs React — Do Not Do These
611
-
612
- Pulse views are plain JS template literals, not JSX. These React patterns are **wrong in Pulse**:
613
-
614
- | Wrong (React) | Correct (Pulse) |
615
- |---|---|
616
- | `className="foo"` | `class="foo"` |
617
- | `htmlFor="id"` | `for="id"` |
618
- | `onClick={handler}` | `data-event="click:mutationName"` |
619
- | `onChange={handler}` | `data-event="change:mutationName"` |
620
- | `<input value={state.x}>` | Uncontrolled — read via `FormData` in `onStart` |
621
- | `{expression}` in JSX | `${expression}` in template literal |
622
- | `<>...</>` fragments | No fragments — use a real wrapper element |
623
- | `import React from 'react'` | No import needed — plain JS |
624
- | `useState`, `useEffect`, `useRef` | No hooks — use `state`, `mutations`, `actions` |
625
- | `key={item.id}` on list items | No `key` props — no virtual DOM |
626
- | Capitalized `<MyComponent />` | Call as a plain function: `${myComponent(props)}` |
627
- | `setState(prev => ...)` | Mutations return partial state: `(state) => ({ x: state.x + 1 })` |
628
-
629
- Also: mutations must be pure (no fetch, no DOM access). Only `actions` can be async.
630
-
631
- ## Markdown
632
-
633
- Pulse has a built-in markdown parser. Use it for blog posts, docs pages, and any static content written in `.md` files. Import from `@invisibleloop/pulse/md`. All parsing is server-side — zero browser JS.
634
-
635
- ```js
636
- import { md } from '@invisibleloop/pulse/md'
637
- import { prose } from '@invisibleloop/pulse/ui'
638
-
639
- // Static path
640
- const page = md('content/about.md')
641
-
642
- // Dynamic route — :param resolved from ctx.params
643
- const post = md('content/blog/:slug.md')
644
-
645
- export default {
646
- route: '/blog/:slug',
647
- meta: {
648
- title: async (ctx) => (await post(ctx)).frontmatter.title,
649
- description: async (ctx) => (await post(ctx)).frontmatter.description,
650
- },
651
- server: { post },
652
- view: (state, { post }) => `
653
- <main id="main-content">
654
- ${prose({ content: post.html })}
655
- </main>
656
- `,
657
- onViewError: () => `<main id="main-content"><p>Post not found.</p></main>`,
658
- }
659
- ```
660
-
661
- - `md(path)` returns an async fetcher — call it in both `meta` and `server`, the file is read only once per request
662
- - Result is `{ html, frontmatter }` — `html` goes into `prose()`, `frontmatter` holds the `---` key/value pairs
663
- - Missing file throws `{ status: 404 }` — always add `onViewError` on dynamic markdown routes
664
- - For markdown from a database/API use `parseMd(source)` directly: `import { parseMd } from '@invisibleloop/pulse/md'`
665
- - Use `new URL('./content/file.md', import.meta.url)` to resolve relative to the spec file rather than `process.cwd()`
666
-
667
- ## Check Components Before Building
668
-
669
- Before writing any UI HTML by hand, check `src/ui/index.js` — there are 50+ components available. Use them. Do not reinvent `button`, `card`, `alert`, `modal`, `spinner`, `badge`, `input`, etc.
670
-
671
- **Common patterns with dedicated components — never write these from scratch:**
672
-
673
- - Hero sections → `hero({ title, subtitle, actions, image, layout })`
674
- - Product/service cards → `card({ title, body, image, footer })`
675
- - Image + text layouts → `media({ image, content, reverse })`
676
- - Feature tiles → `feature({ icon, title, description })`
677
- - Testimonials → `card({ content, footer })` with quote + avatar/name in the content slot
678
- - Call-to-action sections → `cta({ title, subtitle, actions })`
679
-
680
- If you're about to write `class="hero"` or `class="product-card"`, stop — import the component instead. You can add custom CSS on top of components for brand-specific styling, but the structure and accessibility must come from the component.
681
-
682
- @src/agent/checklist.md
683
-
684
- ## Performance Baseline
685
-
686
- | Page | CLS | JS (brotli) |
687
- |---|---|---|
688
- | /counter | 0.00 | 4.2 kB (first visit) / 0.4 kB (cached runtime) |
689
- | /contact | 0.00 | 4.3 kB (first visit) / 0.5 kB (cached runtime) |
690
-
691
- First-visit JS = runtime chunk (~3.8 kB) + per-page boot file (~0.4–0.9 kB). On repeat visits the runtime is served from cache — only the boot file is fetched. If multiple pages share UI components, esbuild's code splitting extracts those into the runtime chunk, so the runtime chunk grows with shared code.
692
-
693
- Lighthouse: 100/100/100 (Accessibility / Best Practices / SEO) on both pages.
694
-
695
- LCP is fast by design (streaming SSR sends HTML before data resolves) but actual millisecond values depend on machine speed, browser, network conditions, and server location — do not quote specific numbers.
696
-
697
- **Caveat — third-party ads:** 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.