@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/src/mcp/server.js CHANGED
@@ -22,6 +22,9 @@
22
22
  * pulse_validate — validate a spec against the schema
23
23
  * pulse_review — full code review (pass quick:true for lightweight mid-build check)
24
24
  * pulse_stamp — write the .pulse-verified stamp (call as last step of /verify)
25
+ * pulse_diagnose — read the dev-only error journal (.pulse/errors.json)
26
+ * pulse_resolve_error — mark error journal entries resolved
27
+ * pulse_check_bundles — inspect production bundle contents (not just sizes)
25
28
  * pulse_check_version — installed vs static vs npm latest
26
29
  * pulse_update — re-copy pulse-ui assets from package → public/
27
30
  */
@@ -33,9 +36,15 @@ import { z } from 'zod'
33
36
  import path from 'path'
34
37
  import fs from 'fs'
35
38
  import http from 'http'
36
- import { execFileSync, spawn, spawnSync } from 'child_process'
39
+ import { spawn, spawnSync } from 'child_process'
37
40
 
38
41
  import { loadPages } from '../cli/discover.js'
42
+ import { validateContent, validateFile, formatValidationResult } from '../cli/validate.js'
43
+ import { readJournal, formatJournal, resolveEntries, formatResolveResult } from '../cli/diagnose.js'
44
+ import { writeStamp, formatStampResult } from '../cli/stamp.js'
45
+ import { checkBundles, formatBundleCheck } from '../cli/check-bundles.js'
46
+ import { runQuickReview, formatQuickReview, runFullReview, formatFullReview } from '../cli/review.js'
47
+ import { killPulseServerOnPort } from '../cli/process-utils.js'
39
48
 
40
49
  // ---------------------------------------------------------------------------
41
50
  // Crash guards — an uncaught error in any tool handler must not kill the
@@ -78,6 +87,22 @@ const PKG_VERSION = JSON.parse(
78
87
  fs.readFileSync(new URL('../../package.json', import.meta.url).pathname, 'utf8')
79
88
  ).version
80
89
 
90
+ // Reads pulse.config.js fresh on every call (cache-busted — the project's
91
+ // config can change between calls in a long-lived MCP server process, e.g.
92
+ // a user flips `design: 'freeform'` mid-session and the agent restarts the
93
+ // server or just calls a tool again). Returns {} if there is no config file
94
+ // or it fails to load — every caller treats missing keys as "use the default".
95
+ async function readPulseConfig() {
96
+ const configPath = path.join(ROOT, 'pulse.config.js')
97
+ if (!fs.existsSync(configPath)) return {}
98
+ try {
99
+ const mod = await import(`${configPath}?t=${Date.now()}`)
100
+ return mod.default ?? {}
101
+ } catch {
102
+ return {}
103
+ }
104
+ }
105
+
81
106
  // Common synonym → canonical vibe normalisation
82
107
  // Prevents hard enum errors when agents use intuitive names like "modern-minimal"
83
108
  const VIBE_SYNONYMS = {
@@ -237,6 +262,25 @@ server.registerResource(
237
262
 
238
263
  const isNewProject = pageCount === 0
239
264
 
265
+ const config = await readPulseConfig()
266
+ // 'components' (default) — the standard pulse_intake/sketch/intent pipeline
267
+ // and src/ui/* component-first mandate below apply as normal.
268
+ // 'freeform' — the user has opted this project out of that pipeline: build
269
+ // plain hand-written HTML/CSS, no src/ui/* components, no intake/sketch
270
+ // ceremony. Read once per pulse://start fetch (project-wide, not a per-page
271
+ // choice) so it applies automatically every session without the user
272
+ // having to repeat it — set once in pulse.config.js, honoured everywhere.
273
+ const isFreeform = config.design === 'freeform'
274
+
275
+ const freeformBanner = isFreeform ? `
276
+ > ⚠ **\`design: 'freeform'\` is set in pulse.config.js.** This project defaults to **Mode B — creative override** (see the persona's "Design Freedom" rule) on every page, every session — you do not need to ask the user or re-decide per page.
277
+ > - Skip \`pulse_intake\` → \`pulse_sketch\` → \`pulse_intent\`'s component scaffolding. Go straight to the spec.
278
+ > - Write plain hand-rolled HTML/CSS in the view — any structure, any class names, no \`@invisibleloop/pulse/ui\` components required.
279
+ > - **Still write the override comment at the top of every new spec file** — \`// component-free — creative override: <reason>\` — even though the mode is project-wide. \`pulse_review\` detects Mode B by reading that comment from each file's source, not from this config; a spec without it will be flagged.
280
+ > - Functional atoms (\`button\`, \`input\`, \`badge\`, \`modal\`) can still come from components where there's no design reason not to — Mode B doesn't forbid them, it just removes the obligation.
281
+ > - The pass bar is unchanged from Mode B: Lighthouse 100 on Accessibility, Best Practices, and SEO (desktop + mobile), CLS 0.00, \`<main id="main-content">\`, and hex-in-\`theme.css\`-only — all still enforced by \`pulse_validate\`/\`/verify\`/\`pulse_review\`.
282
+ ` : ''
283
+
240
284
  return ({
241
285
  contents: [{
242
286
  uri: 'pulse://start',
@@ -246,19 +290,27 @@ server.registerResource(
246
290
  ${isNewProject
247
291
  ? `> **New project detected** — no pages found in src/pages/ yet.`
248
292
  : `> **Existing project** — ${pageCount} page${pageCount !== 1 ? 's' : ''} found.`}
249
-
293
+ ${freeformBanner}
250
294
  ---
251
295
 
252
296
  ## What are you doing?
253
297
 
254
298
  ### A — New page or new site from scratch
255
- Fetch \`pulse://workflow\` for the full phase/gate sequence, then follow the intake → sketch → intent pipeline.
299
+ ${isFreeform
300
+ ? `Skip the intake/sketch/intent component pipeline (this project is \`design: 'freeform'\` — Mode B on every page). Fetch \`pulse://workflow\` for the phase/gate sequence, but treat step 3a as already decided: Mode B, every page, no re-asking.
301
+
302
+ Quick checklist before your first line of code:
303
+ 1. Ask the user for design inspiration first (a site, screenshot, or mood board) if they haven't already described a direction
304
+ 2. Ask: **light or dark?** Pulse renders dark when \`meta.theme\` is unset — decide before writing a single line
305
+ 3. Write the spec directly with hand-rolled HTML/CSS, including the \`// component-free — creative override: <reason>\` comment at the top of the file
306
+ 4. Fetch \`pulse://guide/design-references\` for aesthetic direction if useful — skip \`pulse://guide/components\``
307
+ : `Fetch \`pulse://workflow\` for the full phase/gate sequence, then follow the intake → sketch → intent pipeline.
256
308
 
257
309
  Quick checklist before your first line of code:
258
310
  1. Ask the user for design inspiration first (a site, screenshot, or mood board)
259
311
  2. Ask: **light or dark?** Pulse renders dark when \`meta.theme\` is unset — decide before writing a single line
260
312
  3. Run \`pulse_intake\` → \`pulse_sketch\` → \`pulse_intent\`
261
- 4. Fetch \`pulse://guide/templates\` + \`pulse://guide/design-references\` for aesthetic direction
313
+ 4. Fetch \`pulse://guide/templates\` + \`pulse://guide/design-references\` for aesthetic direction`}
262
314
 
263
315
  ### B — Editing an existing page, adding a section, or fixing a bug
264
316
  Fetch \`pulse://quickstart\` — workflow phases, spec skeleton, components, and theming in one fetch.
@@ -414,6 +466,8 @@ export default {
414
466
  | Color picker | \`<input type="color" data-event="change:setColor">\` | same as select |
415
467
  | Range slider | \`<input type="range" data-event="input:setVolume">\` | \`input\` fires on every drag step |
416
468
  | Text / email / password / search | **No \`data-event\`** — read via FormData | \`data-event\` on text inputs re-renders on every keystroke, destroying cursor position and focus |
469
+ | Continuous drag (vertex, panel resize, custom slider handle) | \`data-event="pointerdown:start pointermove:drag pointerup:end"\` | one element, multiple space-separated bindings — \`pointerdown\` captures the pointer so \`pointermove\`/\`pointerup\` keep firing on that element even off-bounds; \`pointercancel\` also routes to the \`pointerup\` mutation |
470
+ | Keyboard shortcut / arrow-key nudge | \`<div tabindex="0" data-event="keydown:onKey">\` | fires on \`keydown\`; read \`e.key\` in the mutation |
417
471
 
418
472
  **Text input pattern (uncontrolled):**
419
473
  \`\`\`js
@@ -438,6 +492,25 @@ actions: {
438
492
 
439
493
  **\`change:\` is the blur/commit event** — it fires when a non-text input loses focus with a changed value. This is correct for selects, color pickers, checkboxes, and radios. It does NOT fire on every keystroke (use \`input:\` for live-updating sliders or search fields where re-render on every character is intentional and the element is not a free-text input).
440
494
 
495
+ **Continuous drag pattern (pointer events):**
496
+ \`\`\`js
497
+ // view — pointerdown/pointermove/pointerup all bound on the same element
498
+ view: (state) => \`
499
+ <circle
500
+ cx="\${state.x}" cy="\${state.y}" r="8"
501
+ data-event="pointerdown:startDrag pointermove:dragVertex pointerup:endDrag">
502
+ </circle>
503
+ \`,
504
+ mutations: {
505
+ startDrag: (state) => ({ dragging: true }),
506
+ // gate on \`dragging\` — pointermove fires on every cursor move over the
507
+ // element, not just while a drag is active
508
+ dragVertex: (state, e) => state.dragging ? { x: e.clientX, y: e.clientY } : {},
509
+ endDrag: (state) => ({ dragging: false }),
510
+ }
511
+ \`\`\`
512
+ \`pointerdown\` calls \`setPointerCapture\` on its target automatically — no manual capture code needed. \`pointercancel\` (browser-interrupted gestures) fires the same mutation as \`pointerup\`, so a drag can never get stuck "in progress".
513
+
441
514
  ---
442
515
 
443
516
  ## Key components (quick reference)
@@ -747,18 +820,9 @@ server.registerTool(
747
820
  if (content && file) {
748
821
  return text('Error: provide only one of content or file, not both')
749
822
  }
750
-
751
- if (file) {
752
- if (!fs.existsSync(file)) {
753
- return text(`File not found: ${file}`)
754
- }
755
- content = fs.readFileSync(file, 'utf8')
756
- // Validate from the file's own directory so relative imports resolve
757
- // correctly for pages in subdirectories
758
- return validateContent(content, path.dirname(file))
759
- }
760
823
 
761
- return validateContent(content)
824
+ const result = file ? await validateFile(file, ROOT) : await validateContent(content, ROOT)
825
+ return text(formatValidationResult(result, { nextSteps: true }))
762
826
  }
763
827
  )
764
828
 
@@ -804,8 +868,8 @@ Rules for the spec you write:
804
868
  const content = fs.readFileSync(fullPath, 'utf8')
805
869
  // Validate from the file's own directory so relative imports resolve
806
870
  // correctly for pages in subdirectories (src/pages/news/index.js)
807
- const validation = await validateContent(content, path.dirname(fullPath))
808
- if (validation.content[0].text.startsWith('Invalid')) return validation
871
+ const result = await validateContent(content, ROOT, path.dirname(fullPath))
872
+ if (!result.valid) return text(formatValidationResult(result))
809
873
 
810
874
  const route = derivedRouteFromName(name)
811
875
  return text(`Validated ${path.relative(ROOT, fullPath)} → route "${route}"`)
@@ -1196,8 +1260,19 @@ server.registerTool(
1196
1260
  return text(`Dev server specs reloaded (hot) on port ${port}`)
1197
1261
  }
1198
1262
 
1199
- // Slow path — server not running or unresponsive, do a full kill/restart
1200
- try { execFileSync('sh', ['-c', `lsof -ti:${port} | xargs kill -9 2>/dev/null; true`]) } catch { /* nothing running */ }
1263
+ // Slow path — server not running or unresponsive, do a full kill/restart.
1264
+ // Only kill a process on this port if it's actually a Pulse server — see
1265
+ // killPulseServerOnPort's doc comment. If something else owns the port,
1266
+ // report that instead of starting a second server that will just fail
1267
+ // to bind (or, worse, silently kill someone else's unrelated process).
1268
+ const { killed, skipped } = killPulseServerOnPort(port)
1269
+ if (skipped.length > 0 && killed.length === 0) {
1270
+ return text(
1271
+ `Port ${port} is occupied by a process that is not a Pulse server — refusing to kill it:\n` +
1272
+ skipped.map(s => ` PID ${s.pid}: ${s.command}`).join('\n') +
1273
+ `\n\nFree the port yourself, or set a different port in pulse.config.js.`
1274
+ )
1275
+ }
1201
1276
 
1202
1277
  const devScript = new URL('../cli/dev.js', import.meta.url).pathname
1203
1278
  const proc = spawn(process.execPath, [devScript, '--root', ROOT], { detached: true, stdio: 'ignore' })
@@ -1241,8 +1316,17 @@ server.registerTool(
1241
1316
  return resolve(text(`Build failed:\n${build.stderr || build.stdout}`))
1242
1317
  }
1243
1318
 
1244
- // Kill anything on prodPort
1245
- try { execFileSync('sh', ['-c', `lsof -ti:${prodPort} | xargs kill -9 2>/dev/null; true`]) } catch { /* ok */ }
1319
+ // Kill only a Pulse server on prodPort — see killPulseServerOnPort's doc
1320
+ // comment. Refuse (don't kill, don't start a second server on the same
1321
+ // port) if something else owns it.
1322
+ const { killed: prodKilled, skipped: prodSkipped } = killPulseServerOnPort(prodPort)
1323
+ if (prodSkipped.length > 0 && prodKilled.length === 0) {
1324
+ return resolve(text(
1325
+ `Build succeeded, but port ${prodPort} is occupied by a process that is not a Pulse server — refusing to kill it:\n` +
1326
+ prodSkipped.map(s => ` PID ${s.pid}: ${s.command}`).join('\n') +
1327
+ `\n\nFree the port yourself, or set a different port in pulse.config.js.`
1328
+ ))
1329
+ }
1246
1330
 
1247
1331
  // Start prod server detached on prodPort
1248
1332
  const startScript = new URL('../cli/start.js', import.meta.url).pathname
@@ -1258,6 +1342,31 @@ server.registerTool(
1258
1342
  })
1259
1343
  )
1260
1344
 
1345
+ // ---------------------------------------------------------------------------
1346
+ // pulse_check_bundles — inspect what's actually inside the production bundles
1347
+ // ---------------------------------------------------------------------------
1348
+ // Lighthouse checks scores, not contents. A bundle can be small enough to pass
1349
+ // every score and still contain something wrong: server-only code that leaked
1350
+ // through the build's hydration-need check (found via real dogfooding — a
1351
+ // page with no mutations/actions/persist got bundled anyway, and a bundle
1352
+ // that imported a node:fs-based helper failed the build outright), or a
1353
+ // boot file generated for a page that should ship zero client JS. This reads
1354
+ // the real files in public/dist/ after pulse_build, not just the manifest.
1355
+
1356
+ server.registerTool(
1357
+ 'pulse_check_bundles',
1358
+ {
1359
+ description: `Inspect the actual production bundle files in public/dist/ after pulse_build — not just their sizes, their contents. Checks for two things Lighthouse's score-based gate cannot catch:
1360
+
1361
+ 1. A boot bundle exists for a page that doesn't need one (no mutations/actions/persist in its spec) — wasted output, and the exact shape of bug that can make a production build fail outright if that page also imports a server-only helper module.
1362
+ 2. A boot bundle contains a literal reference to a Node built-in (node:fs, node:crypto, etc.) — a sign server-only code leaked into a client bundle instead of being stripped.
1363
+
1364
+ Call this as part of the full /verify pass, after pulse_build, alongside Lighthouse — not part of /verify --quick, since it needs a production build to inspect.`,
1365
+ inputSchema: {},
1366
+ },
1367
+ () => text(formatBundleCheck(checkBundles(ROOT)))
1368
+ )
1369
+
1261
1370
  // ---------------------------------------------------------------------------
1262
1371
  // pulse_review
1263
1372
  // ---------------------------------------------------------------------------
@@ -1295,352 +1404,13 @@ any feature build.
1295
1404
  source = fs.readFileSync(file, 'utf8')
1296
1405
  }
1297
1406
 
1298
- // Quick mode — lightweight structural checks only, no full checklist or Lighthouse gate
1299
1407
  if (quick) {
1300
- const issues = []
1301
- const warnings = []
1302
-
1303
- // Try to render the view
1304
- let renderedHtml = ''
1305
- if (file) {
1306
- try {
1307
- const mod = await import(`${file}?quick=${Date.now()}`)
1308
- const spec = mod.default
1309
- if (spec && typeof spec.view === 'function') {
1310
- renderedHtml = spec.view(spec.state || {}, {})
1311
- }
1312
- } catch { /* ignore — server data dependency */ }
1313
- }
1314
-
1315
- if (renderedHtml) {
1316
- if (!/<main[^>]*id=["']?main-content/.test(renderedHtml))
1317
- issues.push('✗ Missing `<main id="main-content">` landmark')
1318
- if (/<input[^>]*data-event/.test(renderedHtml))
1319
- issues.push('✗ `data-event` on `<input>` — destroys focus on every keystroke, use FormData in onStart instead')
1320
- if (/(className|htmlFor|onClick)=/.test(renderedHtml))
1321
- issues.push('✗ React patterns found (className / htmlFor / onClick) — use class, for, data-event')
1322
- if (/tabindex=["']?([1-9]\d*)/.test(renderedHtml))
1323
- issues.push('✗ Positive tabindex found — remove, reorder DOM instead')
1324
- const rawNavInSource = (source.match(/<nav[\s>]/gi) || []).length
1325
- if (rawNavInSource > 0)
1326
- issues.push(`✗ Raw <nav> tag in spec source — nav() renders <nav> internally; wrapping it in another <nav> creates duplicate landmarks that fail Lighthouse accessibility. Remove the outer <nav>.`)
1327
- const viewBlockStripped = renderedHtml
1328
- .replace(/href=["']#[^"']*["']/g, 'href="#"')
1329
- .replace(/id=["'][^"']*["']/g, 'id=""')
1330
- // Strip CSS custom property assignments from style attrs (--foo:#hex is a runtime
1331
- // data binding, not an authored colour — e.g. style="--swatch-bg:#E8524A").
1332
- // Standard property assignments like color:#000 are left intact and still flagged.
1333
- .replace(/style="([^"]*)"/g, (_, v) => `style="${v.replace(/--[a-zA-Z][^:]*:[^;"]*/g, '')}"`)
1334
- if (/#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3}\b(?![0-9a-fA-F])/.test(viewBlockStripped))
1335
- warnings.push('⚠ Possible hex colour in rendered HTML — use var(--ui-*) tokens')
1336
-
1337
- // Component pattern checks (unless creative override declared)
1338
- if (!/component.free|creative\s+override/i.test(source)) {
1339
- if (/<[^>]+class="[^"]*\bhero\b/.test(renderedHtml) && !source.includes('hero('))
1340
- warnings.push('⚠ `.hero` class in HTML but no `hero()` component — use the component or declare creative override')
1341
- if (/<[^>]+class="[^"]*\bcard\b/.test(renderedHtml) && !source.includes('card('))
1342
- warnings.push('⚠ `.card` class in HTML but no `card()` component — use the component or declare creative override')
1343
- }
1344
- }
1345
-
1346
- // Source-level checks (don't need rendered HTML).
1347
- // Use line-anchored regex for actions: to avoid false positives from component
1348
- // prop strings like hero({ actions: '...' }) or card({ actions: '...' }).
1349
- const hasActionsBlock = /^\s{0,4}actions\s*:/m.test(source)
1350
- if (hasActionsBlock && !source.includes('onError:'))
1351
- issues.push('✗ Action missing `onError` — required, will throw at runtime')
1352
- if (hasActionsBlock && !source.includes('onSuccess:'))
1353
- issues.push('✗ Action missing `onSuccess` — required')
1354
- if (source.includes('hydrate:'))
1355
- issues.push('✗ `hydrate` is set manually — remove it, the framework sets it automatically')
1356
- if (/meta\s*:\s*async/.test(source))
1357
- issues.push('✗ `meta` is an async function — `meta` must be a plain object; make individual fields async instead')
1358
-
1359
- // Structural confirmations — always shown regardless of issues, so the agent
1360
- // gets positive signal on what's correct, not just a list of failures.
1361
- const confirms = []
1362
- if (renderedHtml) {
1363
- confirms.push(/<main[^>]*id=["']?main-content/.test(renderedHtml) ? '✓ `<main id="main-content">` landmark present' : null)
1364
- const rawNavInSource = (source.match(/<nav[\s>]/gi) || []).length
1365
- if (rawNavInSource === 0) confirms.push('✓ No raw <nav> in spec source — nav() component handles landmarks correctly')
1366
- const headings = [...renderedHtml.matchAll(/<h([1-6])[^>]*>/g)].map(m => parseInt(m[1]))
1367
- if (headings.length > 0) {
1368
- const orderOk = headings.every((h, i) => i === 0 || h <= headings[i - 1] + 1)
1369
- confirms.push(orderOk ? `✓ Heading order correct (${headings.map(h => `h${h}`).join(' → ')})` : `⚠ Heading order may be skipping levels (${headings.map(h => `h${h}`).join(' → ')})`)
1370
- }
1371
- const interactiveWithoutLabel = [...renderedHtml.matchAll(/<button(?![^>]*aria-label)[^>]*>\s*<\/button>/g)]
1372
- confirms.push(interactiveWithoutLabel.length === 0 ? '✓ No empty buttons without aria-label detected' : `⚠ ${interactiveWithoutLabel.length} button(s) appear empty — check aria-label`)
1373
- const dataEvents = [...renderedHtml.matchAll(/data-event=/g)].length
1374
- if (dataEvents > 0) confirms.push(`✓ ${dataEvents} data-event binding(s) found`)
1375
- const creativeOverride = /component.free|creative\s+override/i.test(source)
1376
- if (creativeOverride) confirms.push('✓ Creative override declared — component pattern checks are advisory')
1377
- }
1378
-
1379
- const lines = ['## Quick review\n']
1380
-
1381
- if (confirms.filter(Boolean).length > 0) {
1382
- lines.push('### Structural checks\n')
1383
- for (const c of confirms.filter(Boolean)) lines.push(c)
1384
- lines.push('')
1385
- }
1386
-
1387
- if (issues.length === 0 && warnings.length === 0) {
1388
- lines.push('✓ No issues found. Run `pulse_validate` next, then `/verify`.')
1389
- } else {
1390
- if (issues.length) {
1391
- lines.push('### Fix before proceeding\n')
1392
- for (const i of issues) lines.push(i)
1393
- lines.push('')
1394
- }
1395
- if (warnings.length) {
1396
- lines.push('### Warnings (check these)\n')
1397
- for (const w of warnings) lines.push(w)
1398
- lines.push('')
1399
- }
1400
- lines.push('---\nFix issues, then run `pulse_validate` → `/verify --quick`.')
1401
- }
1402
-
1403
- return text(lines.join('\n'))
1404
- }
1405
-
1406
- // Run the validator in a child process (same as pulse_validate)
1407
- let validationResult = '(could not run validator)'
1408
- if (file) {
1409
- const validatorScript = new URL('./validate-worker.js', import.meta.url).pathname
1410
- try {
1411
- validationResult = execFileSync(process.execPath, [validatorScript, file], {
1412
- timeout: 10_000,
1413
- encoding: 'utf8',
1414
- }).trim()
1415
- } catch (err) {
1416
- validationResult = err.stdout?.trim() || err.message
1417
- }
1418
- } else {
1419
- // Content-only mode — write to temp file and validate
1420
- const tmpFile = path.join(os.tmpdir(), `pulse-review-${Date.now()}.js`)
1421
- fs.writeFileSync(tmpFile, source, 'utf8')
1422
- const validatorScript = new URL('./validate-worker.js', import.meta.url).pathname
1423
- try {
1424
- validationResult = execFileSync(process.execPath, [validatorScript, tmpFile], {
1425
- timeout: 10_000,
1426
- encoding: 'utf8',
1427
- }).trim()
1428
- } catch (err) {
1429
- validationResult = err.stdout?.trim() || err.message
1430
- } finally {
1431
- try { fs.unlinkSync(tmpFile) } catch {}
1432
- }
1433
- }
1434
-
1435
- // Try to render the view with initial state
1436
- let renderedHtml = ''
1437
- let renderNote = ''
1438
- try {
1439
- const mod = await import(`${file}?review=${Date.now()}`)
1440
- const spec = mod.default
1441
- if (spec && typeof spec.view === 'function') {
1442
- renderedHtml = spec.view(spec.state || {}, {})
1443
- } else if (spec && typeof spec.view === 'object') {
1444
- const segments = Object.entries(spec.view)
1445
- .map(([k, fn]) => `<!-- segment: ${k} -->\n${typeof fn === 'function' ? fn(spec.state || {}, {}) : ''}`)
1446
- .join('\n')
1447
- renderedHtml = segments
1448
- renderNote = '(streamed spec — segments rendered individually)'
1449
- }
1450
- } catch {
1451
- renderNote = '(view could not be rendered — may depend on server data)'
1408
+ const result = await runQuickReview(source, file)
1409
+ return text(formatQuickReview(result, { agentFacing: true }))
1452
1410
  }
1453
1411
 
1454
- return text(`# Pulse Code Review
1455
-
1456
- You are now a **senior code reviewer**. Read the spec, find every problem, and fix them all before reporting back.
1457
-
1458
- ---
1459
-
1460
- ## Validator output
1461
-
1462
- ${validationResult}
1463
-
1464
- ${validationResult.includes('✓') ? '' : `
1465
- ## Spec source (validation failed — showing for debugging)
1466
-
1467
- \`\`\`js
1468
- ${source}
1469
- \`\`\`
1470
- `}
1471
-
1472
- ${renderNote.includes('could not') ? `
1473
- ## Render error
1474
-
1475
- ${renderNote}
1476
-
1477
- \`\`\`js
1478
- ${source}
1479
- \`\`\`
1480
- ` : ''}
1481
-
1482
- ---
1483
-
1484
- ## Auto-checked items
1485
-
1486
- ${(() => {
1487
- const checks = []
1488
-
1489
- // Check rendered HTML
1490
- if (renderedHtml) {
1491
- // Positive tabindex
1492
- const posTabindex = /tabindex=["']?([1-9]\d*)/.test(renderedHtml)
1493
- checks.push(posTabindex ? '✗ **Positive tabindex found** — remove tabindex > 0, reorder DOM instead' : '✓ No positive tabindex')
1494
-
1495
- // data-event on input
1496
- const dataEventInput = /<input[^>]*data-event/.test(renderedHtml)
1497
- checks.push(dataEventInput ? '✗ **data-event on <input>** — this destroys focus on every keystroke' : '✓ No data-event on text inputs')
1498
-
1499
- // React patterns
1500
- const reactPatterns = /(className|htmlFor|onClick)=/.test(renderedHtml)
1501
- checks.push(reactPatterns ? '✗ **React patterns found** — use class, for, data-event instead' : '✓ No React patterns (className/htmlFor/onClick)')
1502
-
1503
- // Emoji in HTML — strip SVG, data URIs, and aria-hidden elements before checking.
1504
- // Mathematical/decorative Unicode in aria-hidden elements is intentional and accessible.
1505
- const htmlNoSvg = renderedHtml
1506
- .replace(/<svg[\s\S]*?<\/svg>/gi, '')
1507
- .replace(/data:[^"']+/g, '')
1508
- .replace(/<[^>]+aria-hidden=["']true["'][^>]*>[\s\S]*?<\/[^>]+>/gi, '')
1509
- // Restrict to the emoji Unicode blocks only — excludes math symbols (∑, π, ∞, etc.),
1510
- // arrows, and other non-emoji Unicode that are commonly used as decorative text.
1511
- const emojiRegex = /[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA6F}\u{1FA70}-\u{1FAFF}]/u
1512
- const hasEmoji = emojiRegex.test(htmlNoSvg)
1513
- checks.push(hasEmoji ? '⚠ **Emoji in HTML** — use icon components or aria-label instead (verify: may be intentional icon)' : '✓ No emoji in view HTML')
1514
-
1515
- // Main landmark
1516
- const hasMain = /<main[^>]*id=["']?main-content/.test(renderedHtml)
1517
- checks.push(hasMain ? '✓ <main id="main-content"> present' : '✗ **Missing main landmark** — add <main id="main-content">')
1518
- }
1519
-
1520
- // Check spec source
1521
- // Hex colours in view — strip anchor hrefs, id attributes, template literal expressions,
1522
- // and style attributes before checking so runtime-generated values (e.g. style="--swatch-bg:${hex}")
1523
- // are never flagged. Only flag literal hex values authored directly in the view string.
1524
- const viewBlock = source.slice(source.indexOf('view:'))
1525
- const viewBlockStripped = viewBlock
1526
- .replace(/href=["']#[^"']*["']/g, 'href="#"') // href="#anchor" → href="#"
1527
- .replace(/id=["'][^"']*["']/g, 'id=""') // id="foo" → id=""
1528
- .replace(/\$\{[^}]*\}/g, '${…}') // ${expr} — runtime values, not authored hex
1529
- const hexInView = /#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3}\b(?![0-9a-fA-F])/.test(viewBlockStripped)
1530
- if (hexInView) {
1531
- checks.push('⚠ **Possible hex colour in view** — use var(--ui-*) tokens only')
1532
- } else {
1533
- checks.push('✓ No obvious hex colours in view')
1534
- }
1535
-
1536
- // XSS test coverage — check whether the test file includes a <script>alert assertion
1537
- // for each user-controlled string interpolated into the view.
1538
- // Heuristic: if the view interpolates any exported string variable (escape(), or direct
1539
- // string props), the test file should contain a '<script>' XSS assertion.
1540
- if (file) {
1541
- const testFile = file.replace(/\.js$/, '.test.js')
1542
- let xssNote = ''
1543
- if (fs.existsSync(testFile)) {
1544
- const testSrc = fs.readFileSync(testFile, 'utf8')
1545
- const hasXssAssertion = testSrc.includes('<script>') || testSrc.includes('xss') || testSrc.includes('script>alert')
1546
- if (!hasXssAssertion) {
1547
- xssNote = '⚠ **No XSS test found** — test file exists but has no `<script>alert` assertion. Add at least one test that passes a `\'<script>alert(1)</script>\'` string as a user-controlled input and asserts it does not appear unescaped in the output.'
1548
- } else {
1549
- xssNote = '✓ XSS test assertion present'
1550
- }
1551
- } else {
1552
- xssNote = '○ No test file found — create one at ' + path.basename(testFile)
1553
- }
1554
- checks.push(xssNote)
1555
- }
1556
-
1557
- // Creative override detection — check if spec declares component-free mode
1558
- const creativeOverride = /component.free|creative\s+override|raw\s+HTML\s+throughout/i.test(source)
1559
- checks.push(creativeOverride
1560
- ? '⚡ **Creative override declared** — component pattern checks are advisory only. Lighthouse 100 on Accessibility, Best Practices, and SEO (desktop + mobile) plus CLS 0.00 is the pass bar.'
1561
- : '○ No creative override declared — component checks apply')
1562
-
1563
- return checks.join('\n')
1564
- })()}
1565
-
1566
- ---
1567
-
1568
- ## Review checklist
1569
-
1570
- Work through every item. Fix anything that fails. Refer to the spec source at ${file} as needed — do not ask me to paste it.
1571
-
1572
- ### Structure
1573
- - [ ] \`route\` is set explicitly — not left to auto-discovery
1574
- - [ ] \`hydrate\` is NOT set manually — the framework injects it automatically. Remove it if present.
1575
- - [ ] \`state\` shape is consistent — no fields that flip between null/string/boolean
1576
- - [ ] \`meta.title\` is meaningful and unique to this page
1577
- - [ ] \`meta.description\` is a real description, not "Built with Pulse"
1578
-
1579
- ### Mutations & actions
1580
- - [ ] Every mutation returns a plain partial object — no side effects, no fetch, no DOM access
1581
- - [ ] \`constraints\` are used for bounds instead of conditional logic inside mutations
1582
- - [ ] \`disabled\` in the view matches the constraint bounds — but check: is it redundant with the constraint, or does it serve a UX purpose?
1583
- - [ ] Actions read user input from FormData in \`onStart\`, not from mirrored state
1584
- - [ ] \`onStart\` sets a loading status, \`onSuccess\`/\`onError\` resolve it
1585
- - [ ] A single \`status\` field is used instead of multiple boolean flags
1586
-
1587
- ### Components & HTML
1588
- - [ ] Components from the UI library are used — no hand-written \`<button>\`, \`<input>\`, \`<table>\` etc where a component exists
1589
- - [ ] **Component patterns check** — grep the rendered HTML for these class names:
1590
- - \`.hero\`, \`.-hero\`, \`__hero\` → should use \`hero()\` component
1591
- - \`.card\`, \`.product-card\`, \`.service-card\` → should use \`card()\` component
1592
- - Any two-column image + text layout → should use \`media()\` component
1593
- - \`.feature\`, \`.feature-card\` → should use \`feature()\` component
1594
-
1595
- **Before flagging a pattern match**, ask: *can the \`hero()\` (or relevant) component actually reproduce this layout?* If the design uses full-viewport height, custom gradient glows, clamp-scaled display type, asymmetric layout, or other features the component doesn't support — that is a **creative override**, not a violation. Check the auto-checked items above: if "creative override declared" is shown, these checks are advisory only and Lighthouse is the pass bar.
1596
-
1597
- If no creative override is declared and a component *could* reproduce the design, refactor to use it. Custom utility classes on top of components are fine (\`hero({ ... })\` + override CSS) — but do not write the entire structure from scratch when the component supports the layout.
1598
-
1599
- - [ ] **Creative override** (fill in if applicable): *State the override reason here — e.g. "full-viewport gradient hero not achievable with hero() component"*. Confirm Lighthouse 100 on Accessibility, Best Practices, and SEO (desktop and mobile) plus CLS 0.00 before closing the review.
1600
- - [ ] No \`data-event\` on text inputs — this destroys focus on every keystroke
1601
- - [ ] No \`className\`, \`htmlFor\`, \`onClick=\`, or other React patterns
1602
- - [ ] No hardcoded hex colours in view — only \`var(--ui-*)\` tokens (anchor \`href="#id"\` values are fine — those are not colours)
1603
- - [ ] No emoji in view HTML (decorative emoji without accessible text is a fail; emoji with \`aria-label\` or inside \`<span aria-hidden="true">\` is acceptable)
1604
- - [ ] **CTA must be wrapped in section/container** — grep the rendered HTML for \`<div class="ui-cta"\`. If it appears as a direct child of \`<main>\` or \`<div id="app">\` without a section or container wrapper, wrap it. CTA has no padding of its own.
1605
-
1606
- ### Accessibility
1607
- - [ ] \`<main id="main-content">\` is present
1608
- - [ ] Icon-only buttons have \`aria-label\`
1609
- - [ ] \`aria-live\` and \`aria-label\` are NOT on the same element
1610
- - [ ] Heading hierarchy is correct — no skipped levels, starts at h1
1611
- - [ ] Disabled state uses the \`disabled\` attribute, not just CSS or opacity
1612
- - [ ] **Keyboard focusability** — every element carrying \`data-event\`, \`data-store-event\`, \`data-dialog-open\`, or \`data-dialog-close\` is either a natively interactive element (\`button\`, \`a\`, \`input\`, \`select\`, \`textarea\`, \`summary\`) or has \`tabindex="0"\`. Scan the rendered HTML above — a \`<div>\`, \`<span>\`, \`<li>\`, or any other non-interactive tag with one of these attributes is a keyboard accessibility failure. Prefer \`<button>\` over a div + tabindex.
1613
- - [ ] **Purpose** — every interactive element has a clear accessible name. Buttons have visible text or \`aria-label\`. Links use descriptive text — flag generic labels ("click here", "here", "read more", "more"). Form inputs have an associated \`<label for="id">\` or \`aria-label\` — \`placeholder\` alone is not a label (it disappears on focus and is not read by all screen readers).
1614
- - [ ] **State** — interactive elements communicate their current state via ARIA:
1615
- - Toggle controls (open/close, show/hide, expand/collapse) have \`aria-expanded="true|false"\` or \`aria-pressed="true|false"\`
1616
- - While an action is running, the trigger button has \`aria-busy="true"\` or its visible label changes (e.g. "Saving…") — a spinner alone is not sufficient
1617
- - Active navigation items have \`aria-current="page"\`
1618
- - Selected items in a list, tab set, or option group have \`aria-selected="true"\`
1619
- - [ ] **Tab order** — scan the rendered HTML for these failures:
1620
- - No \`tabindex\` value greater than 0. \`tabindex="1"\` and above override the natural DOM order and almost always create a broken, unpredictable tab sequence. The only valid values are \`0\` (add to natural order) and \`-1\` (remove from order). If you find a positive tabindex, remove it and reorder the DOM instead.
1621
- - Off-screen or visually hidden interactive content is removed from the tab order. Elements that are hidden via CSS alone (e.g. \`opacity:0\`, \`visibility:hidden\` without \`display:none\`, off-canvas menus, collapsed panels) but remain in the DOM must have \`tabindex="-1"\` or \`inert\` so keyboard users cannot tab into invisible controls.
1622
- - DOM order matches the visual reading order. When CSS flexbox \`order\` or grid placement is used to visually reposition elements, the tab sequence follows the DOM — not the visual layout. Ensure the DOM is authored in the order a sighted user would read and interact with the page.
1623
-
1624
- ### Defensive coding
1625
- - [ ] Any \`fetch\` in actions or server fetchers checks \`res.ok\` before calling \`.json()\`
1626
- - [ ] Fetch errors use the safe pattern — NOT \`throw new Error(await res.text())\` which exposes raw HTML in toasts:
1627
- \`\`\`js
1628
- if (!res.ok) {
1629
- let message = \`Request failed: \${res.status}\`
1630
- try { const j = await res.json(); message = j.message || j.error || message } catch {}
1631
- throw new Error(message)
1632
- }
1633
- \`\`\`
1634
- - [ ] Optional chaining used for any data from external sources
1635
- - [ ] URL params validated before use
1636
- - [ ] \`onViewError\` defined if the view could crash on bad or missing data
1637
-
1638
- ---
1639
-
1640
- Fix every issue you find. Then confirm what was changed.
1641
-
1642
- **After confirming fixes: you are back in builder mode. Continue to the verification workflow — navigate to the page in the browser, take a screenshot, run Lighthouse desktop audit, run Lighthouse mobile audit. Do not stop at the review.**`)
1643
-
1412
+ const result = await runFullReview(source, file)
1413
+ return text(formatFullReview(result, { source, file, agentFacing: true }))
1644
1414
  }
1645
1415
  )
1646
1416
 
@@ -1662,18 +1432,66 @@ server.registerTool(
1662
1432
 
1663
1433
  Call this as the **last step** of /verify — after Lighthouse desktop + mobile both pass 100/100/100, pulse_review is complete, and no further spec edits will be made.
1664
1434
 
1435
+ Pass \`route\` and \`mode: "full"\` (full /verify, not --quick) and this refuses to stamp unless \`pulse save-report\` was actually run for that route in the last 10 minutes — a real Lighthouse run that passed the score bar but was never saved (a documented step this has been silently skipped for before, since prose alone didn't stop it) leaves nothing for \`pulse report-server\` or a companion dashboard to show. Omit \`mode\` (or pass "quick") to skip this check — quick mode never runs Lighthouse, so there is nothing to have saved.
1436
+
1437
+ When \`route\` is passed, also marks any unresolved error-journal entries for that route as resolved (a clean pass this far means whatever was flagged either wasn't real or was already fixed by an earlier /verify step) — this previously only happened via the separate pulse_resolve_error tool, despite this tool's own description having claimed it happened here.
1438
+
1665
1439
  The stop hook compares each changed spec's mtime against this stamp. Any spec newer than the stamp blocks the session. Do NOT call this before Lighthouse or before fixing issues found in pulse_review.`,
1666
- inputSchema: {},
1440
+ inputSchema: {
1441
+ route: z.string().optional().describe('The route just verified, e.g. "/dashboard" — enables the saved-report check and error-journal auto-resolve. Omit only if truly not tied to one route.'),
1442
+ mode: z.enum(['full', 'quick']).optional().describe('Which /verify mode this is. "full" enforces that a Lighthouse report was actually saved via pulse save-report in the last 10 minutes. Omit or "quick" skips that check.'),
1443
+ },
1667
1444
  },
1668
- () => {
1669
- const stampPath = path.join(ROOT, '.pulse-verified')
1670
- const ts = String(Math.floor(Date.now() / 1000))
1671
- try {
1672
- fs.writeFileSync(stampPath, ts, 'utf8')
1673
- return text(`✓ .pulse-verified written (${ts}). Stop hook cleared — session can end.`)
1674
- } catch (err) {
1675
- return text(`Error writing .pulse-verified: ${err.message}\nFallback: run \`date +%s > .pulse-verified\` in Bash.`)
1445
+ ({ route, mode } = {}) => text(formatStampResult(writeStamp(ROOT, { route, mode }), { agentFacing: true }))
1446
+ )
1447
+
1448
+ // ---------------------------------------------------------------------------
1449
+ // pulse_diagnose — read the dev-only error journal
1450
+ // ---------------------------------------------------------------------------
1451
+ // Errors that used to dead-end at console.error (server errors, SSR view
1452
+ // throws, post-hydration client view/action failures) are captured to
1453
+ // .pulse/errors.json by the dev server. This tool is the agent-facing read
1454
+ // side of that journal — a mechanized diagnostic instead of relying on the
1455
+ // agent to notice a console line or a user report.
1456
+
1457
+ server.registerTool(
1458
+ 'pulse_diagnose',
1459
+ {
1460
+ description: `Read the dev-only error journal (.pulse/errors.json) — server errors, SSR view throws, and post-hydration client view/action failures that would otherwise only appear as a console.error line.
1461
+
1462
+ Call this when something seems broken and you're not sure why, or proactively after building/testing a page to check nothing threw during the session. Each entry has a route, a phase (view/action/server/guard), a message, a stack trace, and a resolved flag.
1463
+
1464
+ Pass \`route\` to filter to one page's errors — this is what /verify uses to check a specific page before stamping it. Omit it to see everything recorded this session.`,
1465
+ inputSchema: {
1466
+ route: z.string().optional().describe('Filter to errors on this route only, e.g. "/dashboard". Omit to see all recorded errors.'),
1467
+ includeResolved: z.boolean().optional().describe('Include entries already marked resolved. Default false — only unresolved errors are shown.'),
1468
+ },
1469
+ },
1470
+ ({ route, includeResolved = false } = {}) => {
1471
+ const result = readJournal(ROOT, { route, includeResolved })
1472
+ let output = formatJournal(result, { route, includeResolved })
1473
+ if (result.exists && !result.error && result.entries.length > 0) {
1474
+ output += '\n---\nFix the underlying issue, then call `pulse_resolve_error` with the id (or route) to clear it from the unresolved list.'
1676
1475
  }
1476
+ return text(output)
1477
+ }
1478
+ )
1479
+
1480
+ // ---------------------------------------------------------------------------
1481
+ // pulse_resolve_error — mark journal entries resolved
1482
+ // ---------------------------------------------------------------------------
1483
+
1484
+ server.registerTool(
1485
+ 'pulse_resolve_error',
1486
+ {
1487
+ description: `Mark error journal entries as resolved after fixing the underlying issue. Pass a specific \`id\`, or \`route\` to resolve every unresolved entry for that page at once — /verify's stamp step calls this automatically for the target route on a clean pass, so you usually don't need to call it manually. Use it directly when you've fixed something pulse_diagnose surfaced but aren't running the full /verify loop right now.`,
1488
+ inputSchema: {
1489
+ id: z.string().optional().describe('Resolve one entry by its id.'),
1490
+ route: z.string().optional().describe('Resolve every unresolved entry for this route.'),
1491
+ },
1492
+ },
1493
+ ({ id, route } = {}) => {
1494
+ return text(formatResolveResult(resolveEntries(ROOT, { id, route })))
1677
1495
  }
1678
1496
  )
1679
1497
 
@@ -2288,7 +2106,12 @@ If your page genuinely doesn't fit a pattern, start from pulse://guide/spec and
2288
2106
  lines.push(`1. **Light or dark?** Pulse renders **dark** by default when \`meta.theme\` is unset. If the user hasn't stated a preference, ask now — discovering the wrong theme at the screenshot costs a full edit → restart → re-approval cycle.`)
2289
2107
  lines.push(` Add \`theme: 'light'\` to your plan or brief if confirmed light.`)
2290
2108
  lines.push(``)
2291
- lines.push(`2. **Design inspiration?** Ask: "Do you have any design inspiration — a site you love, a screenshot, or a mood board image? Drop it into \`public/intake/\` or paste a URL." If yes, call \`pulse_extract_inspiration\` before proceeding.`)
2109
+ if (key === 'dashboard') {
2110
+ lines.push(`2. **Design inspiration — reframed for a functional/internal tool.** The standard aesthetic-inspiration question ("a site you love") lands oddly for a work screen or admin tool — most people building one have no mood board, and asking anyway invites either an awkward non-answer or, worse, an agent skipping the question rather than reinterpreting it on the fly. Ask instead: "Do you have any reference for how this should look, or should I go with a clean, functional dark-mode style typical of internal tools? Also — what's the most important thing to see at a glance, and roughly how many items/rows will be on screen at once?" This still surfaces genuine visual references if the user has one (some do), while asking what actually matters for a dashboard: information density and priority, not mood.`)
2111
+ lines.push(` If the user does name a real visual reference (a URL, an image, or a well-known site), call \`pulse_extract_inspiration\` as normal. A bare mention with nothing to point to ("no, we just use a whiteboard") is not skippable — the question itself is still mandatory — it just isn't inspiration to extract; fold the answer into \`pulse_intake\`'s vibe/anti-style fields and move on.`)
2112
+ } else {
2113
+ lines.push(`2. **Design inspiration?** Ask: "Do you have any design inspiration — a site you love, a screenshot, or a mood board image? Drop it into \`public/intake/\` or paste a URL." If yes, call \`pulse_extract_inspiration\` before proceeding.`)
2114
+ }
2292
2115
  lines.push(` (Skip if this came from pulse_intake — it already asked.)`)
2293
2116
 
2294
2117
  lines.push(``)
@@ -2781,14 +2604,16 @@ server.registerTool(
2781
2604
  server.registerTool(
2782
2605
  'pulse_extract_inspiration',
2783
2606
  {
2784
- description: `Extract a structured design brief from a URL or image the user has shared as inspiration.
2607
+ description: `Extract a structured design brief from a URL, image, or well-known named reference the user has shared as inspiration.
2785
2608
 
2786
- Call this when the user says "I like the design of X", shares a URL, or pastes/attaches an image. It returns a structured extraction template that tells you exactly what to observe and capture. You then use your own browsing or vision tools to fill in the template, and feed the result into pulse_intake.
2609
+ Call this when the user shares a URL, pastes/attaches an image, or names a site you have real knowledge of well enough to describe its actual design (e.g. "make it feel like linear.app"). It returns a structured extraction template that tells you exactly what to observe and capture. You then use your own browsing or vision tools to fill in the template, and feed the result into pulse_intake.
2787
2610
 
2788
2611
  Works for:
2789
2612
  - URLs: visit the site with your browser tool, observe the rendered page
2790
2613
  - Images: use your vision capability to analyse the screenshot/photo the user shared
2791
- - Named references: use your knowledge of the brand/site to populate the template
2614
+ - Named references you actually know: use your knowledge of the brand/site to populate the template
2615
+
2616
+ **Do not call this for a name alone with nothing to extract** — a small or local business the user mentioned in passing ("there's a place called X, I like their logo, but I haven't looked at their site") with no URL, no image, and no training knowledge of what it actually looks like. That's not an inspiration source, it's a name — calling this tool on it produces a fabricated brief dressed up as an observation. Skip straight to pulse_intake with only what the user has actually described.
2792
2617
 
2793
2618
  The extracted values map directly to pulse_intake fields (palette, vibe, styleNotes, antiStyle, font).`,
2794
2619
  inputSchema: {
@@ -2818,7 +2643,9 @@ The extracted values map directly to pulse_intake fields (palette, vibe, styleNo
2818
2643
  lines.push('## Step 1 — Analyse the source (do this now)')
2819
2644
  lines.push(`Source: **${source}**`)
2820
2645
  lines.push('Use your vision capability or knowledge of this reference to fill in the template below.')
2821
- lines.push('**Return a completed extraction with your observations filled in — not a blank template.**\n')
2646
+ lines.push('**Return a completed extraction with your observations filled in — not a blank template.**')
2647
+ lines.push('')
2648
+ lines.push('> **If you have no reliable knowledge of this specific reference** (a small or local business the user named but you have no training data on, and they have not described what it looks like — e.g. "there\'s a place called X, I like their logo, but I haven\'t actually looked at their site"): do not guess or fabricate a brief. There is nothing to extract here — this is not a real inspiration source, just a name in passing. Skip this tool entirely and proceed with `pulse_intake` using only what the user has actually described (their own words about tone, anti-style, etc.). Note in your build plan that no visual reference was available, rather than presenting a fabricated extraction as if it came from observing something real.\n')
2822
2649
  }
2823
2650
 
2824
2651
  lines.push('---\n')
@@ -3867,142 +3694,6 @@ async function waitForServer(port, maxMs = 10_000) {
3867
3694
  return false
3868
3695
  }
3869
3696
 
3870
- // Common prop aliases that agents and developers pass by mistake.
3871
- // Maps wrong name → { correct, component } for each well-known component.
3872
- const PROP_ALIASES = [
3873
- { component: 'nav()', wrong: 'brand', correct: 'logo', note: 'The nav logo is set with the `logo` prop, not `brand`.' },
3874
- { component: 'nav()', wrong: 'actions', correct: 'action', note: 'nav() takes a single `action` string (HTML), not an array.' },
3875
- { component: 'footer()', wrong: 'items', correct: 'links', note: 'footer() top-level links use `links: [{ label, href }]`, not `items`.' },
3876
- { component: 'footer()', wrong: 'nav', correct: 'links', note: 'footer() links prop is `links`, not `nav`.' },
3877
- { component: 'input()', wrong: 'autocomplete', correct: 'attrs: { autocomplete }', note: 'HTML attributes not in the component API (autocomplete, min, max, step, pattern, inputmode, etc.) go inside the `attrs` object: input({ attrs: { autocomplete: "email" } }).' },
3878
- { component: 'input()', wrong: 'maxlength', correct: 'attrs: { maxlength }', note: 'HTML attributes go inside `attrs`: input({ attrs: { maxlength: "100" } }).' },
3879
- { component: 'input()', wrong: 'minlength', correct: 'attrs: { minlength }', note: 'HTML attributes go inside `attrs`: input({ attrs: { minlength: "2" } }).' },
3880
- { component: 'input()', wrong: 'pattern', correct: 'attrs: { pattern }', note: 'HTML attributes go inside `attrs`: input({ attrs: { pattern: "[0-9]+" } }).' },
3881
- ]
3882
-
3883
- async function validateContent(content, tmpDir = PAGES_DIR) {
3884
- // Source-level checks — run before the worker so errors are caught without importing
3885
- const sourceWarnings = []
3886
-
3887
- // Component prop alias checks — flag known wrong prop names
3888
- for (const { component, wrong, correct, note } of PROP_ALIASES) {
3889
- // Match: nav({ ...wrong: or footer({ ...wrong: (within the call args)
3890
- const fnName = component.replace('()', '')
3891
- const re = new RegExp(`${fnName}\\s*\\(\\s*\\{([^}]*)\\b${wrong}\\s*:`, 'g')
3892
- let m
3893
- while ((m = re.exec(content)) !== null) {
3894
- // Inside attrs: { … } is the CORRECT placement for these HTML attributes —
3895
- // when the text between the call's opening brace and the prop ends inside
3896
- // an open attrs object, this is the recommended pattern, not a mistake.
3897
- // (The naive match crossed into attrs blocks and flagged exactly what the
3898
- // fix-it note tells people to write.)
3899
- if (/attrs\s*:\s*\{[^}]*$/.test(m[1])) continue
3900
- sourceWarnings.push(`"${wrong}" is not a recognised prop for ${component} — did you mean "${correct}"? ${note}`)
3901
- break
3902
- }
3903
- }
3904
-
3905
- // External image URL check — warn if spec contains external image domains not
3906
- // commonly whitelisted in CSP. Caught here prevents a Lighthouse Best Practices failure.
3907
- const externalImgHosts = [
3908
- {
3909
- pattern: /https?:\/\/images\.unsplash\.com/,
3910
- host: 'images.unsplash.com',
3911
- entry: 'https://images.unsplash.com',
3912
- cookieWarning: true,
3913
- name: 'Unsplash',
3914
- },
3915
- {
3916
- pattern: /https?:\/\/(?:fastly\.)?picsum\.photos/,
3917
- host: 'picsum.photos',
3918
- entry: 'https://picsum.photos https://fastly.picsum.photos',
3919
- cookieWarning: false,
3920
- name: 'picsum',
3921
- },
3922
- {
3923
- pattern: /https?:\/\/res\.cloudinary\.com/,
3924
- host: 'res.cloudinary.com',
3925
- entry: 'https://res.cloudinary.com',
3926
- cookieWarning: true,
3927
- name: 'Cloudinary',
3928
- },
3929
- {
3930
- pattern: /https?:\/\/cdn\.shopify\.com/,
3931
- host: 'cdn.shopify.com',
3932
- entry: 'https://cdn.shopify.com',
3933
- cookieWarning: false,
3934
- name: 'Shopify CDN',
3935
- },
3936
- ]
3937
- const configPath = path.join(ROOT, 'pulse.config.js')
3938
- let configImgSrc = ''
3939
- if (fs.existsSync(configPath)) {
3940
- try { configImgSrc = fs.readFileSync(configPath, 'utf8') } catch { /* ignore */ }
3941
- }
3942
- for (const { pattern, host, entry, cookieWarning, name } of externalImgHosts) {
3943
- // Config match is by HOST, not full URL — CSP sources are valid without a
3944
- // scheme ('images.unsplash.com'), so requiring https?:// in the config
3945
- // produced false positives for correctly configured projects.
3946
- if (pattern.test(content) && !configImgSrc.includes(host)) {
3947
- const cookieNote = cookieWarning
3948
- ? `\n ⚠ ${name} sets tracking cookies that fail Lighthouse Best Practices regardless of CSP. For production, download images to public/images/ instead of linking to ${name} directly.`
3949
- : ''
3950
- sourceWarnings.push(
3951
- `External image host detected (${name}). Add it to csp.img-src in pulse.config.js before running Lighthouse:\n` +
3952
- ` csp: { 'img-src': ['${entry}'] }\n` +
3953
- ` Without this, images will be blocked and Lighthouse Best Practices will fail.${cookieNote}`
3954
- )
3955
- }
3956
- }
3957
-
3958
- // Write the temp file into the directory the spec actually lives in (passed by
3959
- // callers that know it) so relative imports resolve from the true location.
3960
- // A page at src/pages/news/index.js importing '../../components/layout.js'
3961
- // resolved wrongly when the temp file was always dropped in src/pages/ root.
3962
- fs.mkdirSync(tmpDir, { recursive: true })
3963
- const tmpFile = path.join(tmpDir, `.pulse-validate-${Date.now()}.mjs`)
3964
- try {
3965
- fs.writeFileSync(tmpFile, content, 'utf8')
3966
-
3967
- // Run validation in a child process with a hard timeout so a hanging import
3968
- // (slow module, circular dep, network call) cannot block the MCP server.
3969
- const validatorScript = new URL('./validate-worker.js', import.meta.url).pathname
3970
- let output
3971
- try {
3972
- output = execFileSync(process.execPath, [validatorScript, tmpFile], {
3973
- timeout: 10_000,
3974
- encoding: 'utf8',
3975
- })
3976
- } catch (err) {
3977
- const msg = err.killed || err.signal === 'SIGTERM'
3978
- ? 'Invalid: validation timed out — spec may have a hanging import or infinite loop'
3979
- : `Invalid: could not parse — ${err.stdout || err.message}`
3980
- return text(msg)
3981
- }
3982
-
3983
- let finalOutput = output.trim()
3984
- // Merge source-level prop warnings into the worker output
3985
- if (sourceWarnings.length > 0) {
3986
- const propNotes = sourceWarnings.map(w => ` ⚠ ${w}`).join('\n')
3987
- if (finalOutput.startsWith('Valid ✓')) {
3988
- finalOutput = finalOutput.replace('Valid ✓', 'Valid ✓ — but fix these issues:').replace(' — but fix these issues: — but fix these issues:', ' — but fix these issues:')
3989
- finalOutput = finalOutput + '\n' + propNotes
3990
- } else {
3991
- finalOutput = finalOutput + '\n' + propNotes
3992
- }
3993
- }
3994
-
3995
- // Append browser check reminder on clean pass so agents don't skip Lighthouse
3996
- if (finalOutput.startsWith('Valid ✓') && !finalOutput.includes('Invalid')) {
3997
- finalOutput += '\n\n---\n**Next: browser check sequence** (do not skip)\n1. `pulse_fetch_page` → screenshot\n2. **New build?** Show screenshot to user and ask for design approval before continuing\n3. Once approved (or edit/fix): `pulse_design_review` (if intake ran) → `pulse_layout_review <url>` → `/verify`\nDo not run Lighthouse before the user approves the design. Do not report done until `/verify` passes.'
3998
- }
3999
-
4000
- return text(finalOutput)
4001
- } finally {
4002
- try { fs.unlinkSync(tmpFile) } catch { /* ignore */ }
4003
- }
4004
- }
4005
-
4006
3697
  function findComponents() {
4007
3698
  if (!fs.existsSync(COMPONENTS_DIR)) return []
4008
3699
  return fs.readdirSync(COMPONENTS_DIR)
@@ -4092,7 +3783,7 @@ If you know your context already:
4092
3783
  ## Tools available
4093
3784
 
4094
3785
  **Pulse MCP tools** (always available):
4095
- - \`pulse_extract_inspiration(source, focus?)\` — **Extract a structured design brief from a URL or image.** Call this when the user shares a website URL, a site name they admire, or pastes/attaches an inspiration image. Returns a structured extraction template — you fill it in using your browsing or vision tools, then feed the results into pulse_intake. Maps directly to palette, vibe, styleNotes, and font fields. **Always check \`public/intake/\` for images at the start of a new build — if any exist, call this before pulse_intake.**
3786
+ - \`pulse_extract_inspiration(source, focus?)\` — **Extract a structured design brief from a URL, image, or a named site you actually have real knowledge of.** Call this when the user shares a website URL, pastes/attaches an inspiration image, or names a site well-known enough that you can describe its real design (not just its existence). A name alone with no URL, no image, and no genuine knowledge of what it looks like isn't an inspiration source — skip this tool and go straight to \`pulse_intake\` with what the user actually described. Returns a structured extraction template — you fill it in using your browsing or vision tools, then feed the results into pulse_intake. Maps directly to palette, vibe, styleNotes, and font fields. **Always check \`public/intake/\` for images at the start of a new build — if any exist, call this before pulse_intake.**
4096
3787
  - \`pulse_intake(name, pitch, features, targetUser?, palette?, font?, theme?, vibe?, styleNotes?, antiStyle?, inspiration?)\` — **Capture product details before scaffolding.** Run this first for any new project or branded template — before pulse_sketch or pulse_intent. **Gather answers by asking the user one free-form question at a time — never use multi-choice lists for open-ended intake questions.** The final intake question must always be: "Do you have any design inspiration — a site you love, a screenshot, or a mood board? Drop images into \`public/intake/\` or share a URL." If the user provides references, call \`pulse_extract_inspiration\` before proceeding. After intake, call pulse_sketch to explore structural directions before writing code.
4097
3788
  - \`pulse_sketch(brief, vibe?, antiStyle?, pageType?)\` — **Generate 3 structurally distinct layout directions before writing any code.** Call after pulse_intake. Returns three named directions (full-bleed, asymmetric split, typography-only, editorial flow, dense grid, story scroll, content-first) with wireframes, key decisions, and component strategies. Prevents defaulting to "centred hero + three columns" on every project. After choosing a direction, fetch \`pulse://guide/explore\` for raw HTML patterns.
4098
3789
  - \`pulse_intent(description)\` — Describe what you want to build in plain language and get back a matched archetype, component recommendations, a ready-to-adapt spec scaffold, and which guides to read. Use after pulse_intake and pulse_sketch, before fetching guides.
@@ -4114,6 +3805,7 @@ If you know your context already:
4114
3805
  - \`pulse_fetch_page(url)\` — HTTP GET the dev server URL. Use to verify SSR output.
4115
3806
  - \`pulse_restart_server\` — hot-reload specs in the running dev server (~200 ms). Falls back to full kill/restart if the server is not running.
4116
3807
  - \`pulse_build\` — production build + starts prod server on devPort+1 for Lighthouse. Returns the URL. Call \`pulse_restart_server\` after to return to dev. **Slow — takes 30–60 s. Tell the user before calling.**
3808
+ - \`pulse_check_bundles\` — inspects what's actually inside the generated \`public/dist/\` bundles after \`pulse_build\`, not just their sizes. Flags a boot bundle that exists for a page with no mutations/actions/persist (should ship zero JS), and a literal Node built-in reference inside a bundle (server-only code that leaked through stripping). Lighthouse checks scores; this checks content — call it as part of the full \`/verify\` pass, right after \`pulse_build\`.
4117
3809
  - \`pulse_check_version\` — check installed package version, static asset version, and latest on npm. Use this instead of running npm commands when the user asks about updates.
4118
3810
  - \`pulse_update\` — install the latest \`@invisibleloop/pulse\` package and re-copy \`pulse-ui.css\`, \`pulse-ui.js\`, and the agent checklist into \`public/\`. One command does the full upgrade.
4119
3811