@invisibleloop/pulse 0.20.10 → 0.20.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (266) hide show
  1. package/package.json +6 -5
  2. package/public/.pulse-ui-version +1 -1
  3. package/scripts/build.js +26 -0
  4. package/scripts/release-version.js +17 -8
  5. package/scripts/strip-server.test.js +60 -0
  6. package/src/agent/checklist.md +8 -12
  7. package/src/agent/commands/pulse-start.md +2 -0
  8. package/src/agent/commands/verify.md +30 -8
  9. package/src/agent/guide-components.md +7 -25
  10. package/src/agent/guide-routing.md +0 -12
  11. package/src/agent/guide-server.md +102 -1
  12. package/src/agent/guide-spec.md +8 -1
  13. package/src/agent/guide-styles.md +23 -0
  14. package/src/agent/identity.md +9 -0
  15. package/src/agent/workflow.md +4 -23
  16. package/src/cli/check-bundles.js +113 -0
  17. package/src/cli/check-bundles.test.js +137 -0
  18. package/src/cli/cli.test.js +105 -0
  19. package/src/cli/dev.js +48 -3
  20. package/src/cli/diagnose.js +105 -0
  21. package/src/cli/diagnose.test.js +124 -0
  22. package/src/cli/discover.js +9 -2
  23. package/src/cli/index.js +171 -9
  24. package/src/cli/process-utils.js +57 -0
  25. package/src/cli/process-utils.test.js +114 -0
  26. package/src/cli/review.js +391 -0
  27. package/src/cli/review.test.js +143 -0
  28. package/src/cli/scaffold.js +9 -4
  29. package/src/cli/stamp.js +102 -0
  30. package/src/cli/stamp.test.js +149 -0
  31. package/src/cli/start.js +15 -2
  32. package/src/cli/validate.js +184 -0
  33. package/src/cli/validate.test.js +153 -0
  34. package/src/mcp/mcp.test.js +261 -30
  35. package/src/mcp/server.js +207 -515
  36. package/src/mcp/validate-worker.js +18 -1
  37. package/src/runtime/index.js +144 -11
  38. package/src/runtime/runtime.test.js +199 -0
  39. package/src/runtime/ssr.js +14 -7
  40. package/src/runtime/ssr.test.js +31 -0
  41. package/src/server/index.js +163 -10
  42. package/src/server/server.test.js +225 -2
  43. package/src/spec/schema.js +1 -1
  44. package/src/ui/index.js +0 -5
  45. package/src/ui/ui.test.js +0 -75
  46. package/types/ui.d.ts +0 -21
  47. package/.claude/commands/build-page.md +0 -63
  48. package/.claude/commands/new-doc-page.md +0 -45
  49. package/.claude/commands/pulse-start.md +0 -11
  50. package/.claude/commands/pulse-stop.md +0 -5
  51. package/.claude/commands/verify.md +0 -118
  52. package/.claude/pulse-checklist.md +0 -189
  53. package/.github/workflows/ci.yml +0 -25
  54. package/.github/workflows/publish.yml +0 -45
  55. package/.pulse/load-reports/home/1773432711417.json +0 -22
  56. package/.pulse-verified +0 -1
  57. package/AGENTS.md +0 -697
  58. package/CLAUDE.md +0 -697
  59. package/README.md +0 -95
  60. package/benchmark/public/dist/counter.boot-PO6SNSJZ.js +0 -8
  61. package/benchmark/public/dist/home.boot-YK6VAEQI.js +0 -6
  62. package/benchmark/public/dist/manifest.json +0 -5
  63. package/benchmark/public/dist/runtime-5Y7ESAIB.js +0 -2
  64. package/benchmark/public/dist/runtime-KO4BHUQ3.js +0 -49
  65. package/benchmark/results/2026-03-27-09-20.json +0 -188
  66. package/benchmark/results/2026-03-27-09-21.json +0 -188
  67. package/benchmark/results/2026-03-27-09-29.json +0 -188
  68. package/benchmark/results/2026-03-27-10-14.json +0 -188
  69. package/benchmark/results/2026-03-27-10-18.json +0 -232
  70. package/benchmark/src/pages/counter.js +0 -37
  71. package/benchmark/src/pages/home.js +0 -21
  72. package/docs/.claude/commands/pulse-build.md +0 -7
  73. package/docs/.claude/commands/pulse-dev.md +0 -7
  74. package/docs/.claude/commands/pulse-start.md +0 -9
  75. package/docs/.claude/commands/pulse-stop.md +0 -5
  76. package/docs/.claude/commands/verify.md +0 -84
  77. package/docs/.claude/coverage-check.js +0 -196
  78. package/docs/.claude/pulse-checklist.md +0 -189
  79. package/docs/public/.pulse-ui-version +0 -1
  80. package/docs/public/andypixel.css +0 -237
  81. package/docs/public/bookworms.css +0 -422
  82. package/docs/public/coutumes.css +0 -561
  83. package/docs/public/docs.css +0 -2388
  84. package/docs/public/favicon.svg +0 -4
  85. package/docs/public/home-brut.css +0 -982
  86. package/docs/public/images/bookworms/hero-books.jpg +0 -0
  87. package/docs/public/images/bookworms/how-we-read-now.jpg +0 -0
  88. package/docs/public/images/bookworms/lantern-keepers.jpg +0 -0
  89. package/docs/public/images/bookworms/provincial-bookseller.jpg +0 -0
  90. package/docs/public/images/journey/178.jpg +0 -0
  91. package/docs/public/images/journey/29.jpg +0 -0
  92. package/docs/public/images/retrowheels/hero.jpg +0 -0
  93. package/docs/public/images/shop/devbook-pro.jpg +0 -0
  94. package/docs/public/images/suzukibento/1015.jpg +0 -0
  95. package/docs/public/images/suzukibento/1025.jpg +0 -0
  96. package/docs/public/images/suzukibento/1043.jpg +0 -0
  97. package/docs/public/images/suzukibento/1059.jpg +0 -0
  98. package/docs/public/images/suzukibento/1062.jpg +0 -0
  99. package/docs/public/images/suzukibento/1084.jpg +0 -0
  100. package/docs/public/images/swish/1469334031218-e382a71b716b.jpg +0 -0
  101. package/docs/public/images/swish/1483985988355-763728e1935b.jpg +0 -0
  102. package/docs/public/images/swish/1485518882345-15568b007407.jpg +0 -0
  103. package/docs/public/images/swish/1490481651871-ab68de25d43d.jpg +0 -0
  104. package/docs/public/images/swish/1496747611176-843222e1e57c.jpg +0 -0
  105. package/docs/public/images/swish/1502716119720-b23a93e5fe1b.jpg +0 -0
  106. package/docs/public/images/swish/1539109136881-3be0616acf4b.jpg +0 -0
  107. package/docs/public/images/swish/1551803091-e20673f15770.jpg +0 -0
  108. package/docs/public/images/swish/1558769132-cb1aea458c5e.jpg +0 -0
  109. package/docs/public/images/swish/1572804013309-59a88b7e92f1.jpg +0 -0
  110. package/docs/public/img/heic1501a.jpg +0 -0
  111. package/docs/public/img/placeholder-landscape.svg +0 -18
  112. package/docs/public/img/placeholder-square.svg +0 -14
  113. package/docs/public/img/placeholder-tall.svg +0 -22
  114. package/docs/public/journey.css +0 -528
  115. package/docs/public/menu.js +0 -327
  116. package/docs/public/pulse-ui.css +0 -3028
  117. package/docs/public/pulse-ui.js +0 -257
  118. package/docs/public/retrowheels.css +0 -425
  119. package/docs/public/shop.css +0 -269
  120. package/docs/public/sparkys.css +0 -382
  121. package/docs/public/suzukibento.css +0 -517
  122. package/docs/public/swish.css +0 -394
  123. package/docs/public/theme.css +0 -52
  124. package/docs/public/themes/lumio.css +0 -50
  125. package/docs/public/themes/observatory.css +0 -104
  126. package/docs/server.js +0 -161
  127. package/docs/src/lib/component-page.js +0 -49
  128. package/docs/src/lib/highlight.js +0 -2
  129. package/docs/src/lib/layout.js +0 -190
  130. package/docs/src/lib/metrics-store.js +0 -6
  131. package/docs/src/lib/nav.js +0 -171
  132. package/docs/src/lib/stats.js +0 -81
  133. package/docs/src/pages/accessibility.js +0 -157
  134. package/docs/src/pages/actions.js +0 -194
  135. package/docs/src/pages/auth.js +0 -176
  136. package/docs/src/pages/caching.js +0 -95
  137. package/docs/src/pages/components/accordion.js +0 -48
  138. package/docs/src/pages/components/alert.js +0 -35
  139. package/docs/src/pages/components/app-badge.js +0 -41
  140. package/docs/src/pages/components/avatar.js +0 -35
  141. package/docs/src/pages/components/badge.js +0 -36
  142. package/docs/src/pages/components/breadcrumbs.js +0 -94
  143. package/docs/src/pages/components/button.js +0 -84
  144. package/docs/src/pages/components/card.js +0 -225
  145. package/docs/src/pages/components/charts.js +0 -283
  146. package/docs/src/pages/components/checkbox.js +0 -129
  147. package/docs/src/pages/components/cluster.js +0 -47
  148. package/docs/src/pages/components/container.js +0 -40
  149. package/docs/src/pages/components/cta.js +0 -54
  150. package/docs/src/pages/components/divider.js +0 -37
  151. package/docs/src/pages/components/empty.js +0 -36
  152. package/docs/src/pages/components/feature.js +0 -60
  153. package/docs/src/pages/components/fieldset.js +0 -65
  154. package/docs/src/pages/components/fileupload.js +0 -128
  155. package/docs/src/pages/components/footer.js +0 -58
  156. package/docs/src/pages/components/grid.js +0 -169
  157. package/docs/src/pages/components/heading.js +0 -107
  158. package/docs/src/pages/components/hero.js +0 -123
  159. package/docs/src/pages/components/icons.js +0 -410
  160. package/docs/src/pages/components/image.js +0 -71
  161. package/docs/src/pages/components/input.js +0 -51
  162. package/docs/src/pages/components/media.js +0 -51
  163. package/docs/src/pages/components/modal.js +0 -111
  164. package/docs/src/pages/components/nav.js +0 -136
  165. package/docs/src/pages/components/phone-frame.js +0 -74
  166. package/docs/src/pages/components/progress.js +0 -102
  167. package/docs/src/pages/components/prose.js +0 -111
  168. package/docs/src/pages/components/pullquote.js +0 -71
  169. package/docs/src/pages/components/radio.js +0 -194
  170. package/docs/src/pages/components/rating.js +0 -106
  171. package/docs/src/pages/components/search.js +0 -61
  172. package/docs/src/pages/components/section.js +0 -98
  173. package/docs/src/pages/components/segmented.js +0 -121
  174. package/docs/src/pages/components/select.js +0 -45
  175. package/docs/src/pages/components/slider.js +0 -115
  176. package/docs/src/pages/components/spinner.js +0 -73
  177. package/docs/src/pages/components/stack.js +0 -48
  178. package/docs/src/pages/components/stat.js +0 -55
  179. package/docs/src/pages/components/stepper.js +0 -66
  180. package/docs/src/pages/components/table.js +0 -45
  181. package/docs/src/pages/components/textarea.js +0 -31
  182. package/docs/src/pages/components/toggle.js +0 -84
  183. package/docs/src/pages/components/tooltip.js +0 -48
  184. package/docs/src/pages/components.js +0 -200
  185. package/docs/src/pages/config.js +0 -193
  186. package/docs/src/pages/constraints.js +0 -100
  187. package/docs/src/pages/deploy.js +0 -233
  188. package/docs/src/pages/error-pages.js +0 -99
  189. package/docs/src/pages/extending.js +0 -198
  190. package/docs/src/pages/faq.js +0 -99
  191. package/docs/src/pages/forms.js +0 -129
  192. package/docs/src/pages/getting-started.js +0 -147
  193. package/docs/src/pages/guard.js +0 -120
  194. package/docs/src/pages/home.js +0 -345
  195. package/docs/src/pages/how-it-works.js +0 -244
  196. package/docs/src/pages/hydration.js +0 -108
  197. package/docs/src/pages/images.js +0 -120
  198. package/docs/src/pages/markdown.js +0 -156
  199. package/docs/src/pages/meta.js +0 -193
  200. package/docs/src/pages/mutations.js +0 -107
  201. package/docs/src/pages/navigation.js +0 -85
  202. package/docs/src/pages/performance.js +0 -157
  203. package/docs/src/pages/persist.js +0 -89
  204. package/docs/src/pages/project-structure.js +0 -91
  205. package/docs/src/pages/prompt-examples.js +0 -186
  206. package/docs/src/pages/raw-responses.js +0 -122
  207. package/docs/src/pages/routing.js +0 -120
  208. package/docs/src/pages/server-api.js +0 -291
  209. package/docs/src/pages/server-data.js +0 -203
  210. package/docs/src/pages/slash-commands.js +0 -55
  211. package/docs/src/pages/spec.js +0 -207
  212. package/docs/src/pages/state.js +0 -102
  213. package/docs/src/pages/store.js +0 -203
  214. package/docs/src/pages/streaming.js +0 -107
  215. package/docs/src/pages/stripe.js +0 -194
  216. package/docs/src/pages/supabase.js +0 -324
  217. package/docs/src/pages/templates/andypixel.js +0 -260
  218. package/docs/src/pages/templates/blog-post.js +0 -226
  219. package/docs/src/pages/templates/bookworms.js +0 -227
  220. package/docs/src/pages/templates/coutumes.js +0 -237
  221. package/docs/src/pages/templates/journey.js +0 -186
  222. package/docs/src/pages/templates/mobile-app.js +0 -371
  223. package/docs/src/pages/templates/retrowheels.js +0 -355
  224. package/docs/src/pages/templates/shop.js +0 -268
  225. package/docs/src/pages/templates/sparkys.js +0 -264
  226. package/docs/src/pages/templates/suzukibento.js +0 -200
  227. package/docs/src/pages/templates/swish.js +0 -313
  228. package/docs/src/pages/templates.js +0 -534
  229. package/docs/src/pages/testing.js +0 -199
  230. package/docs/src/pages/validation.js +0 -138
  231. package/examples/.claude/commands/pulse-build.md +0 -7
  232. package/examples/.claude/commands/pulse-dev.md +0 -7
  233. package/examples/.claude/commands/pulse-start.md +0 -9
  234. package/examples/.claude/commands/pulse-stop.md +0 -5
  235. package/examples/.claude/commands/verify.md +0 -84
  236. package/examples/.claude/coverage-check.js +0 -196
  237. package/examples/.claude/pulse-checklist.md +0 -189
  238. package/examples/andypixel.css +0 -237
  239. package/examples/andypixel.js +0 -258
  240. package/examples/bookworms.css +0 -422
  241. package/examples/bookworms.js +0 -225
  242. package/examples/contact.js +0 -166
  243. package/examples/counter.js +0 -94
  244. package/examples/dev.server.js +0 -110
  245. package/examples/examples.test.js +0 -394
  246. package/examples/journey.css +0 -528
  247. package/examples/journey.js +0 -186
  248. package/examples/pricing.js +0 -262
  249. package/examples/products.js +0 -190
  250. package/examples/public/.pulse-ui-version +0 -1
  251. package/examples/public/pulse-ui.css +0 -3028
  252. package/examples/public/pulse-ui.js +0 -257
  253. package/examples/quiz.js +0 -208
  254. package/examples/retrowheels.css +0 -425
  255. package/examples/retrowheels.js +0 -353
  256. package/examples/shared.js +0 -84
  257. package/examples/suzukibento.css +0 -513
  258. package/examples/suzukibento.js +0 -200
  259. package/examples/swish.css +0 -394
  260. package/examples/swish.js +0 -311
  261. package/examples/todos.js +0 -162
  262. package/src/ui/fieldset.js +0 -37
  263. package/src/ui/phone-frame.js +0 -40
  264. package/src/ui/search.js +0 -78
  265. package/src/ui/segmented.js +0 -50
  266. package/src/ui/tooltip.js +0 -28
@@ -0,0 +1,391 @@
1
+ /**
2
+ * Pulse — Spec review (shared logic)
3
+ *
4
+ * The single source of truth behind pulse_review (MCP tool, for an agent)
5
+ * and `pulse review` (CLI command, for a human). Both modes — quick and
6
+ * full — return plain data here; formatting is split into an agent-facing
7
+ * formatter (instructional framing: "you are now a reviewer, fix every
8
+ * issue") and a human-facing one (a plain report), since the MCP tool's
9
+ * existing output talks directly to an agent in a way that reads wrong at
10
+ * a terminal.
11
+ */
12
+
13
+ import fs from 'fs'
14
+ import path from 'path'
15
+ import os from 'os'
16
+ import { execFileSync } from 'child_process'
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Quick mode — lightweight structural checks, no validator subprocess, no
20
+ // checklist. Same regex checks pulse_review({ quick: true }) has always run.
21
+ // ---------------------------------------------------------------------------
22
+
23
+ /**
24
+ * @param {string} source - spec file source text
25
+ * @param {string|null} file - absolute path to the spec file, or null (content-only mode)
26
+ * @returns {Promise<{ issues: string[], warnings: string[], confirms: string[] }>}
27
+ */
28
+ export async function runQuickReview(source, file) {
29
+ const issues = []
30
+ const warnings = []
31
+
32
+ let renderedHtml = ''
33
+ if (file) {
34
+ try {
35
+ const mod = await import(`${file}?quick=${Date.now()}`)
36
+ const spec = mod.default
37
+ if (spec && typeof spec.view === 'function') {
38
+ renderedHtml = spec.view(spec.state || {}, {})
39
+ }
40
+ } catch { /* ignore — server data dependency */ }
41
+ }
42
+
43
+ if (renderedHtml) {
44
+ if (!/<main[^>]*id=["']?main-content/.test(renderedHtml))
45
+ issues.push('✗ Missing `<main id="main-content">` landmark')
46
+ if (/<input[^>]*data-event/.test(renderedHtml))
47
+ issues.push('✗ `data-event` on `<input>` — destroys focus on every keystroke, use FormData in onStart instead')
48
+ if (/(className|htmlFor|onClick)=/.test(renderedHtml))
49
+ issues.push('✗ React patterns found (className / htmlFor / onClick) — use class, for, data-event')
50
+ if (/tabindex=["']?([1-9]\d*)/.test(renderedHtml))
51
+ issues.push('✗ Positive tabindex found — remove, reorder DOM instead')
52
+ const rawNavInSource = (source.match(/<nav[\s>]/gi) || []).length
53
+ if (rawNavInSource > 0)
54
+ 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>.`)
55
+ const viewBlockStripped = renderedHtml
56
+ .replace(/href=["']#[^"']*["']/g, 'href="#"')
57
+ .replace(/id=["'][^"']*["']/g, 'id=""')
58
+ .replace(/style="([^"]*)"/g, (_, v) => `style="${v.replace(/--[a-zA-Z][^:]*:[^;"]*/g, '')}"`)
59
+ if (/#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3}\b(?![0-9a-fA-F])/.test(viewBlockStripped))
60
+ warnings.push('⚠ Possible hex colour in rendered HTML — use var(--ui-*) tokens')
61
+
62
+ if (!/component.free|creative\s+override/i.test(source)) {
63
+ if (/<[^>]+class="[^"]*\bhero\b/.test(renderedHtml) && !source.includes('hero('))
64
+ warnings.push('⚠ `.hero` class in HTML but no `hero()` component — use the component or declare creative override')
65
+ if (/<[^>]+class="[^"]*\bcard\b/.test(renderedHtml) && !source.includes('card('))
66
+ warnings.push('⚠ `.card` class in HTML but no `card()` component — use the component or declare creative override')
67
+ }
68
+ }
69
+
70
+ const hasActionsBlock = /^\s{0,4}actions\s*:/m.test(source)
71
+ if (hasActionsBlock && !source.includes('onError:'))
72
+ issues.push('✗ Action missing `onError` — required, will throw at runtime')
73
+ if (hasActionsBlock && !source.includes('onSuccess:'))
74
+ issues.push('✗ Action missing `onSuccess` — required')
75
+ if (source.includes('hydrate:'))
76
+ issues.push('✗ `hydrate` is set manually — remove it, the framework sets it automatically')
77
+ if (/meta\s*:\s*async/.test(source))
78
+ issues.push('✗ `meta` is an async function — `meta` must be a plain object; make individual fields async instead')
79
+ if (/state\.modalOpen|modalOpen\s*:/.test(source))
80
+ issues.push('✗ `modalOpen`-style state found — never conditionally render a `<dialog>`; always render it unconditionally and open it with `data-dialog-open`')
81
+ if (/\bsubmit\s*:\s*async/.test(source) && /<form[^>]*method=["']POST["']/i.test(source) && !source.includes('server.csrf'))
82
+ issues.push('✗ `<form method="POST">` on a page with `submit` but no `${server.csrf}` in the form — POSTs will get 403')
83
+ if (/_storeUpdate\s*:\s*(true|false|\d|['"`])/.test(source))
84
+ issues.push('✗ `_storeUpdate` must be an object merged into the store, e.g. `_storeUpdate: { settings: { theme } }` — not a primitive')
85
+
86
+ const confirms = []
87
+ if (renderedHtml) {
88
+ confirms.push(/<main[^>]*id=["']?main-content/.test(renderedHtml) ? '✓ `<main id="main-content">` landmark present' : null)
89
+ const rawNavInSource = (source.match(/<nav[\s>]/gi) || []).length
90
+ if (rawNavInSource === 0) confirms.push('✓ No raw <nav> in spec source — nav() component handles landmarks correctly')
91
+ const headings = [...renderedHtml.matchAll(/<h([1-6])[^>]*>/g)].map(m => parseInt(m[1]))
92
+ if (headings.length > 0) {
93
+ const orderOk = headings.every((h, i) => i === 0 || h <= headings[i - 1] + 1)
94
+ confirms.push(orderOk ? `✓ Heading order correct (${headings.map(h => `h${h}`).join(' → ')})` : `⚠ Heading order may be skipping levels (${headings.map(h => `h${h}`).join(' → ')})`)
95
+ }
96
+ const interactiveWithoutLabel = [...renderedHtml.matchAll(/<button(?![^>]*aria-label)[^>]*>\s*<\/button>/g)]
97
+ confirms.push(interactiveWithoutLabel.length === 0 ? '✓ No empty buttons without aria-label detected' : `⚠ ${interactiveWithoutLabel.length} button(s) appear empty — check aria-label`)
98
+ const dataEvents = [...renderedHtml.matchAll(/data-event=/g)].length
99
+ if (dataEvents > 0) confirms.push(`✓ ${dataEvents} data-event binding(s) found`)
100
+ const creativeOverride = /component.free|creative\s+override/i.test(source)
101
+ if (creativeOverride) confirms.push('✓ Creative override declared — component pattern checks are advisory')
102
+ }
103
+
104
+ return { issues, warnings, confirms: confirms.filter(Boolean) }
105
+ }
106
+
107
+ export function formatQuickReview(result, { agentFacing = false } = {}) {
108
+ const { issues, warnings, confirms } = result
109
+ const lines = ['## Quick review\n']
110
+
111
+ if (confirms.length > 0) {
112
+ lines.push('### Structural checks\n')
113
+ for (const c of confirms) lines.push(c)
114
+ lines.push('')
115
+ }
116
+
117
+ if (issues.length === 0 && warnings.length === 0) {
118
+ lines.push(agentFacing
119
+ ? '✓ No issues found. Run `pulse_validate` next, then `/verify`.'
120
+ : '✓ No issues found. Run `pulse validate` next, then a full review.')
121
+ } else {
122
+ if (issues.length) {
123
+ lines.push('### Fix before proceeding\n')
124
+ for (const i of issues) lines.push(i)
125
+ lines.push('')
126
+ }
127
+ if (warnings.length) {
128
+ lines.push('### Warnings (check these)\n')
129
+ for (const w of warnings) lines.push(w)
130
+ lines.push('')
131
+ }
132
+ lines.push(agentFacing
133
+ ? '---\nFix issues, then run `pulse_validate` → `/verify --quick`.'
134
+ : '---\nFix issues, then run `pulse validate` again.')
135
+ }
136
+
137
+ return lines.join('\n')
138
+ }
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // Full mode — validator subprocess + rendered HTML + mechanized checklist.
142
+ // ---------------------------------------------------------------------------
143
+
144
+ /**
145
+ * @param {string} source
146
+ * @param {string|null} file - absolute path, or null for content-only mode
147
+ * @returns {Promise<{
148
+ * validationResult: string,
149
+ * renderedHtml: string,
150
+ * renderNote: string,
151
+ * autoChecks: string[],
152
+ * }>}
153
+ */
154
+ export async function runFullReview(source, file) {
155
+ // Run the validator in a child process (same as pulse_validate/pulse validate)
156
+ let validationResult = '(could not run validator)'
157
+ const validatorScript = new URL('../mcp/validate-worker.js', import.meta.url).pathname
158
+ if (file) {
159
+ try {
160
+ validationResult = execFileSync(process.execPath, [validatorScript, file], {
161
+ timeout: 10_000,
162
+ encoding: 'utf8',
163
+ }).trim()
164
+ } catch (err) {
165
+ validationResult = err.stdout?.trim() || err.message
166
+ }
167
+ } else {
168
+ const tmpFile = path.join(os.tmpdir(), `pulse-review-${Date.now()}.js`)
169
+ fs.writeFileSync(tmpFile, source, 'utf8')
170
+ try {
171
+ validationResult = execFileSync(process.execPath, [validatorScript, tmpFile], {
172
+ timeout: 10_000,
173
+ encoding: 'utf8',
174
+ }).trim()
175
+ } catch (err) {
176
+ validationResult = err.stdout?.trim() || err.message
177
+ } finally {
178
+ try { fs.unlinkSync(tmpFile) } catch {}
179
+ }
180
+ }
181
+
182
+ // Try to render the view with initial state
183
+ let renderedHtml = ''
184
+ let renderNote = ''
185
+ try {
186
+ const mod = await import(`${file}?review=${Date.now()}`)
187
+ const spec = mod.default
188
+ if (spec && typeof spec.view === 'function') {
189
+ renderedHtml = spec.view(spec.state || {}, {})
190
+ } else if (spec && typeof spec.view === 'object') {
191
+ const segments = Object.entries(spec.view)
192
+ .map(([k, fn]) => `<!-- segment: ${k} -->\n${typeof fn === 'function' ? fn(spec.state || {}, {}) : ''}`)
193
+ .join('\n')
194
+ renderedHtml = segments
195
+ renderNote = '(streamed spec — segments rendered individually)'
196
+ }
197
+ } catch {
198
+ renderNote = '(view could not be rendered — may depend on server data)'
199
+ }
200
+
201
+ const autoChecks = []
202
+
203
+ if (renderedHtml) {
204
+ const posTabindex = /tabindex=["']?([1-9]\d*)/.test(renderedHtml)
205
+ autoChecks.push(posTabindex ? '✗ **Positive tabindex found** — remove tabindex > 0, reorder DOM instead' : '✓ No positive tabindex')
206
+
207
+ const dataEventInput = /<input[^>]*data-event/.test(renderedHtml)
208
+ autoChecks.push(dataEventInput ? '✗ **data-event on <input>** — this destroys focus on every keystroke' : '✓ No data-event on text inputs')
209
+
210
+ const reactPatterns = /(className|htmlFor|onClick)=/.test(renderedHtml)
211
+ autoChecks.push(reactPatterns ? '✗ **React patterns found** — use class, for, data-event instead' : '✓ No React patterns (className/htmlFor/onClick)')
212
+
213
+ const htmlNoSvg = renderedHtml
214
+ .replace(/<svg[\s\S]*?<\/svg>/gi, '')
215
+ .replace(/data:[^"']+/g, '')
216
+ .replace(/<[^>]+aria-hidden=["']true["'][^>]*>[\s\S]*?<\/[^>]+>/gi, '')
217
+ 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
218
+ const hasEmoji = emojiRegex.test(htmlNoSvg)
219
+ autoChecks.push(hasEmoji ? '⚠ **Emoji in HTML** — use icon components or aria-label instead (verify: may be intentional icon)' : '✓ No emoji in view HTML')
220
+
221
+ const hasMain = /<main[^>]*id=["']?main-content/.test(renderedHtml)
222
+ autoChecks.push(hasMain ? '✓ <main id="main-content"> present' : '✗ **Missing main landmark** — add <main id="main-content">')
223
+ }
224
+
225
+ const viewBlock = source.slice(source.indexOf('view:'))
226
+ const viewBlockStripped = viewBlock
227
+ .replace(/href=["']#[^"']*["']/g, 'href="#"')
228
+ .replace(/id=["'][^"']*["']/g, 'id=""')
229
+ .replace(/\$\{[^}]*\}/g, '${…}')
230
+ const hexInView = /#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3}\b(?![0-9a-fA-F])/.test(viewBlockStripped)
231
+ autoChecks.push(hexInView
232
+ ? '⚠ **Possible hex colour in view** — use var(--ui-*) tokens only'
233
+ : '✓ No obvious hex colours in view')
234
+
235
+ if (file) {
236
+ const testFile = file.replace(/\.js$/, '.test.js')
237
+ let xssNote = ''
238
+ if (fs.existsSync(testFile)) {
239
+ const testSrc = fs.readFileSync(testFile, 'utf8')
240
+ const hasXssAssertion = testSrc.includes('<script>') || testSrc.includes('xss') || testSrc.includes('script>alert')
241
+ xssNote = hasXssAssertion
242
+ ? '✓ XSS test assertion present'
243
+ : '⚠ **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.'
244
+ } else {
245
+ xssNote = '○ No test file found — create one at ' + path.basename(testFile)
246
+ }
247
+ autoChecks.push(xssNote)
248
+ }
249
+
250
+ const hasModalOpenState = /state\.modalOpen|modalOpen\s*:/.test(source)
251
+ autoChecks.push(hasModalOpenState
252
+ ? '✗ **`modalOpen`-style state found** — never conditionally render a `<dialog>`; always render it unconditionally and open it with `data-dialog-open`'
253
+ : '✓ No conditional-modal state pattern found')
254
+
255
+ const hasSubmit = /\bsubmit\s*:\s*async/.test(source)
256
+ if (hasSubmit) {
257
+ const hasPostForm = /<form[^>]*method=["']POST["']/i.test(source)
258
+ const hasCsrfToken = source.includes('server.csrf')
259
+ autoChecks.push(hasPostForm && !hasCsrfToken
260
+ ? '✗ **`<form method="POST">` missing `${server.csrf}`** — POSTs will get 403. (Not applicable if `csrf: false` is deliberately set for a self-authenticated endpoint.)'
261
+ : '✓ CSRF token present in POST form (or no POST form found)')
262
+ }
263
+
264
+ const badStoreUpdate = /_storeUpdate\s*:\s*(true|false|\d|['"`])/.test(source)
265
+ autoChecks.push(badStoreUpdate
266
+ ? '✗ **`_storeUpdate` is not an object** — it must be a partial store object, e.g. `_storeUpdate: { settings: { theme } }`'
267
+ : '✓ No malformed `_storeUpdate` found')
268
+
269
+ const creativeOverride = /component.free|creative\s+override|raw\s+HTML\s+throughout/i.test(source)
270
+ autoChecks.push(creativeOverride
271
+ ? '⚡ **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.'
272
+ : '○ No creative override declared — component checks apply')
273
+
274
+ return { validationResult, renderedHtml, renderNote, autoChecks }
275
+ }
276
+
277
+ // The static checklist text — invariant reference material, not a mechanized
278
+ // check. Shared verbatim by both formatters; only the surrounding framing differs.
279
+ const CHECKLIST_MD = `### Structure
280
+ - [ ] \`route\` is set explicitly — not left to auto-discovery
281
+ - [ ] \`hydrate\` is NOT set manually — the framework injects it automatically. Remove it if present.
282
+ - [ ] \`state\` shape is consistent — no fields that flip between null/string/boolean
283
+ - [ ] \`meta.title\` is meaningful and unique to this page
284
+ - [ ] \`meta.description\` is a real description, not "Built with Pulse"
285
+
286
+ ### Mutations & actions
287
+ - [ ] Every mutation returns a plain partial object — no side effects, no fetch, no DOM access
288
+ - [ ] \`constraints\` are used for bounds instead of conditional logic inside mutations
289
+ - [ ] \`disabled\` in the view matches the constraint bounds — but check: is it redundant with the constraint, or does it serve a UX purpose?
290
+ - [ ] Actions read user input from FormData in \`onStart\`, not from mirrored state
291
+ - [ ] \`onStart\` sets a loading status, \`onSuccess\`/\`onError\` resolve it
292
+ - [ ] A single \`status\` field is used instead of multiple boolean flags
293
+
294
+ ### Components & HTML
295
+ - [ ] Components from the UI library are used — no hand-written \`<button>\`, \`<input>\`, \`<table>\` etc where a component exists
296
+ - [ ] **Component patterns check** — grep the rendered HTML for these class names:
297
+ - \`.hero\`, \`.-hero\`, \`__hero\` → should use \`hero()\` component
298
+ - \`.card\`, \`.product-card\`, \`.service-card\` → should use \`card()\` component
299
+ - Any two-column image + text layout → should use \`media()\` component
300
+ - \`.feature\`, \`.feature-card\` → should use \`feature()\` component
301
+
302
+ **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.
303
+
304
+ 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.
305
+
306
+ - [ ] **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.
307
+ - [ ] No \`data-event\` on text inputs — this destroys focus on every keystroke
308
+ - [ ] No \`className\`, \`htmlFor\`, \`onClick=\`, or other React patterns
309
+ - [ ] No hardcoded hex colours in view — only \`var(--ui-*)\` tokens (anchor \`href="#id"\` values are fine — those are not colours)
310
+ - [ ] 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)
311
+ - [ ] **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.
312
+
313
+ ### Accessibility
314
+ - [ ] \`<main id="main-content">\` is present
315
+ - [ ] Icon-only buttons have \`aria-label\`
316
+ - [ ] \`aria-live\` and \`aria-label\` are NOT on the same element
317
+ - [ ] Heading hierarchy is correct — no skipped levels, starts at h1
318
+ - [ ] Disabled state uses the \`disabled\` attribute, not just CSS or opacity
319
+ - [ ] **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.
320
+ - [ ] **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).
321
+ - [ ] **State** — interactive elements communicate their current state via ARIA:
322
+ - Toggle controls (open/close, show/hide, expand/collapse) have \`aria-expanded="true|false"\` or \`aria-pressed="true|false"\`
323
+ - 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
324
+ - Active navigation items have \`aria-current="page"\`
325
+ - Selected items in a list, tab set, or option group have \`aria-selected="true"\`
326
+ - [ ] **Tab order** — scan the rendered HTML for these failures:
327
+ - 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.
328
+ - 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.
329
+ - 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.
330
+
331
+ ### Defensive coding
332
+ - [ ] Any \`fetch\` in actions or server fetchers checks \`res.ok\` before calling \`.json()\`
333
+ - [ ] Fetch errors use the safe pattern — NOT \`throw new Error(await res.text())\` which exposes raw HTML in toasts:
334
+ \`\`\`js
335
+ if (!res.ok) {
336
+ let message = \`Request failed: \${res.status}\`
337
+ try { const j = await res.json(); message = j.message || j.error || message } catch {}
338
+ throw new Error(message)
339
+ }
340
+ \`\`\`
341
+ - [ ] Optional chaining used for any data from external sources
342
+ - [ ] URL params validated before use
343
+ - [ ] \`onViewError\` defined if the view could crash on bad or missing data`
344
+
345
+ /**
346
+ * Format the full review result.
347
+ *
348
+ * @param {{ validationResult, renderedHtml, renderNote, autoChecks }} result
349
+ * @param {{ source: string, file: string|null, agentFacing?: boolean }} opts
350
+ */
351
+ export function formatFullReview(result, { source, file, agentFacing = false }) {
352
+ const { validationResult, renderNote, autoChecks } = result
353
+
354
+ const intro = agentFacing
355
+ ? `# Pulse Code Review\n\nYou are now a **senior code reviewer**. Read the spec, find every problem, and fix them all before reporting back to the user.`
356
+ : `# Pulse Code Review\n\nSpec: ${file || '(content-only mode)'}`
357
+
358
+ const specDump = validationResult.includes('✓') ? '' : `\n## Spec source (validation failed — showing for debugging)\n\n\`\`\`js\n${source}\n\`\`\`\n`
359
+ const renderErrorDump = renderNote.includes('could not') ? `\n## Render error\n\n${renderNote}\n\n\`\`\`js\n${source}\n\`\`\`\n` : ''
360
+
361
+ const workOrder = agentFacing
362
+ ? 'Work through every item. Fix anything that fails. Refer to the spec source at ' + file + ' as needed — do not ask me to paste it.'
363
+ : 'Work through every item and fix anything that fails.'
364
+
365
+ const closing = agentFacing
366
+ ? '\nFix every issue you find. Then confirm what was changed.\n\n**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.**'
367
+ : '\nFix every issue found above, then re-run this review.'
368
+
369
+ return `${intro}
370
+
371
+ ---
372
+
373
+ ## Validator output
374
+
375
+ ${validationResult}
376
+ ${specDump}${renderErrorDump}
377
+ ---
378
+
379
+ ## Auto-checked items
380
+
381
+ ${autoChecks.join('\n')}
382
+
383
+ ---
384
+
385
+ ## Review checklist
386
+
387
+ ${workOrder}
388
+
389
+ ${CHECKLIST_MD}
390
+ ${closing}`
391
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Pulse — review.js tests
3
+ * run: node src/cli/review.test.js
4
+ *
5
+ * Shared logic behind pulse_review (MCP tool) and `pulse review` (CLI
6
+ * command). The MCP tool's output talks directly to an agent ("you are now
7
+ * a senior code reviewer") — these tests specifically confirm that framing
8
+ * does NOT leak into the CLI's human-facing output, since that was the main
9
+ * risk in this extraction.
10
+ */
11
+
12
+ import { test } from 'node:test'
13
+ import assert from 'node:assert/strict'
14
+ import fs from 'node:fs'
15
+ import os from 'node:os'
16
+ import path from 'node:path'
17
+ import { runQuickReview, formatQuickReview, runFullReview, formatFullReview } from './review.js'
18
+
19
+ function tmpProject() {
20
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-review-test-'))
21
+ fs.mkdirSync(path.join(root, 'src', 'pages'), { recursive: true })
22
+ return root
23
+ }
24
+
25
+ function writeSpec(root, name, content) {
26
+ const file = path.join(root, 'src', 'pages', name)
27
+ fs.writeFileSync(file, content)
28
+ return file
29
+ }
30
+
31
+ // ── Quick mode ────────────────────────────────────────────────────────────
32
+
33
+ test('quick review flags a modalOpen state pattern', async () => {
34
+ const root = tmpProject()
35
+ const file = writeSpec(root, 'bad.js',
36
+ `export default {\n route: '/x',\n state: { modalOpen: false },\n view: (state) => \`<main id="main-content">\${state.modalOpen ? 'open' : ''}</main>\`,\n}`)
37
+ const source = fs.readFileSync(file, 'utf8')
38
+ const result = await runQuickReview(source, file)
39
+ assert.equal(result.issues.some(i => i.includes('modalOpen')), true)
40
+ fs.rmSync(root, { recursive: true, force: true })
41
+ })
42
+
43
+ test('quick review flags a hex colour in rendered HTML', async () => {
44
+ const root = tmpProject()
45
+ const file = writeSpec(root, 'bad.js',
46
+ `export default {\n route: '/x',\n state: {},\n view: () => '<main id="main-content"><h1 style="color:#ff0000">hi</h1></main>',\n}`)
47
+ const source = fs.readFileSync(file, 'utf8')
48
+ const result = await runQuickReview(source, file)
49
+ assert.equal(result.warnings.some(w => w.includes('hex')), true)
50
+ fs.rmSync(root, { recursive: true, force: true })
51
+ })
52
+
53
+ test('quick review reports no issues for a clean spec', async () => {
54
+ const root = tmpProject()
55
+ const file = writeSpec(root, 'good.js',
56
+ `export default {\n route: '/x',\n state: {},\n view: () => '<main id="main-content"><h1>hi</h1></main>',\n}`)
57
+ const source = fs.readFileSync(file, 'utf8')
58
+ const result = await runQuickReview(source, file)
59
+ assert.equal(result.issues.length, 0)
60
+ fs.rmSync(root, { recursive: true, force: true })
61
+ })
62
+
63
+ test('formatQuickReview agentFacing:true tells the agent to run pulse_validate then /verify', () => {
64
+ const output = formatQuickReview({ issues: [], warnings: [], confirms: [] }, { agentFacing: true })
65
+ assert.match(output, /pulse_validate/)
66
+ assert.match(output, /\/verify/)
67
+ })
68
+
69
+ test('formatQuickReview agentFacing:false (CLI default) uses plain human phrasing', () => {
70
+ const output = formatQuickReview({ issues: [], warnings: [], confirms: [] }, { agentFacing: false })
71
+ assert.match(output, /pulse validate/)
72
+ assert.doesNotMatch(output, /pulse_validate/, 'CLI output must not reference the MCP tool name pulse_validate')
73
+ assert.doesNotMatch(output, /\/verify/, 'CLI output must not reference the /verify slash command')
74
+ })
75
+
76
+ // ── Full mode ─────────────────────────────────────────────────────────────
77
+
78
+ test('full review runs the schema validator and reports auto-checks', async () => {
79
+ const root = tmpProject()
80
+ const file = writeSpec(root, 'good.js',
81
+ `export default {\n route: '/x',\n meta: { title: 't', description: 'd', theme: 'dark' },\n view: () => '<main id="main-content"><h1>hi</h1></main>',\n}`)
82
+ const source = fs.readFileSync(file, 'utf8')
83
+ const result = await runFullReview(source, file)
84
+ assert.match(result.validationResult, /Valid ✓/)
85
+ assert.equal(result.autoChecks.length > 0, true)
86
+ fs.rmSync(root, { recursive: true, force: true })
87
+ })
88
+
89
+ test('full review auto-checks flag modalOpen and malformed _storeUpdate', async () => {
90
+ const root = tmpProject()
91
+ const file = writeSpec(root, 'bad.js',
92
+ `export default {\n route: '/x',\n state: { modalOpen: false },\n actions: {\n save: {\n onSuccess: (state) => ({ _storeUpdate: true }),\n onError: (state, err) => ({ error: err.message }),\n run: async () => {},\n },\n },\n view: () => '<main id="main-content">x</main>',\n}`)
93
+ const source = fs.readFileSync(file, 'utf8')
94
+ const result = await runFullReview(source, file)
95
+ assert.equal(result.autoChecks.some(c => c.includes('modalOpen')), true)
96
+ assert.equal(result.autoChecks.some(c => c.includes('_storeUpdate')), true)
97
+ fs.rmSync(root, { recursive: true, force: true })
98
+ })
99
+
100
+ test('formatFullReview agentFacing:true opens with "you are now a senior code reviewer"', async () => {
101
+ const root = tmpProject()
102
+ const file = writeSpec(root, 'good.js',
103
+ `export default {\n route: '/x',\n meta: { title: 't', description: 'd', theme: 'dark' },\n view: () => '<main id="main-content">x</main>',\n}`)
104
+ const source = fs.readFileSync(file, 'utf8')
105
+ const result = await runFullReview(source, file)
106
+ const output = formatFullReview(result, { source, file, agentFacing: true })
107
+ assert.match(output, /you are now a \*\*senior code reviewer\*\*/i)
108
+ assert.match(output, /Continue to the verification workflow/)
109
+ fs.rmSync(root, { recursive: true, force: true })
110
+ })
111
+
112
+ test('formatFullReview agentFacing:false (CLI default) never says "you are now a reviewer" or references agent-only workflow', async () => {
113
+ const root = tmpProject()
114
+ const file = writeSpec(root, 'good.js',
115
+ `export default {\n route: '/x',\n meta: { title: 't', description: 'd', theme: 'dark' },\n view: () => '<main id="main-content">x</main>',\n}`)
116
+ const source = fs.readFileSync(file, 'utf8')
117
+ const result = await runFullReview(source, file)
118
+ const output = formatFullReview(result, { source, file, agentFacing: false })
119
+ assert.doesNotMatch(output, /you are now a/i,
120
+ 'CLI output must not carry the agent-directive "you are now a reviewer" framing')
121
+ assert.doesNotMatch(output, /Continue to the verification workflow/,
122
+ 'CLI output must not tell a human to "continue to the verification workflow" — that instruction is for an agent session')
123
+ assert.match(output, new RegExp(`Spec: ${file.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`),
124
+ 'CLI output should plainly name the file being reviewed')
125
+ fs.rmSync(root, { recursive: true, force: true })
126
+ })
127
+
128
+ test('the checklist reference material is identical in both agentFacing modes', async () => {
129
+ const root = tmpProject()
130
+ const file = writeSpec(root, 'good.js',
131
+ `export default {\n route: '/x',\n meta: { title: 't', description: 'd', theme: 'dark' },\n view: () => '<main id="main-content">x</main>',\n}`)
132
+ const source = fs.readFileSync(file, 'utf8')
133
+ const result = await runFullReview(source, file)
134
+ const agentOutput = formatFullReview(result, { source, file, agentFacing: true })
135
+ const humanOutput = formatFullReview(result, { source, file, agentFacing: false })
136
+ // Both must contain the same checklist section headers — only the framing
137
+ // around them (intro/closing) should differ, never the checklist itself.
138
+ for (const heading of ['### Structure', '### Mutations & actions', '### Components & HTML', '### Accessibility', '### Defensive coding']) {
139
+ assert.equal(agentOutput.includes(heading), true, `agent output missing ${heading}`)
140
+ assert.equal(humanOutput.includes(heading), true, `human output missing ${heading}`)
141
+ }
142
+ fs.rmSync(root, { recursive: true, force: true })
143
+ })
@@ -38,8 +38,11 @@ export async function scaffold(targetDir, options = {}) {
38
38
  dev: 'pulse dev',
39
39
  build: 'pulse build',
40
40
  start: 'pulse start',
41
- test: 'find src -name "*.test.js" | xargs node --test',
42
- 'test:coverage': 'find src -name "*.test.js" | xargs node --test --experimental-test-coverage --test-coverage-include=\'src/pages/!(*.test).js\'',
41
+ // Also picks up a root-level pulse.store.test.js — pulse.store.js itself
42
+ // lives at the project root (not under src/), so its test does too, and
43
+ // the src/-only glob used to silently exclude it (found via dogfooding).
44
+ test: '(find src -name "*.test.js"; find . -maxdepth 1 -name "*.test.js") | xargs node --test',
45
+ 'test:coverage': '(find src -name "*.test.js"; find . -maxdepth 1 -name "*.test.js") | xargs node --test --experimental-test-coverage --test-coverage-include=\'src/pages/!(*.test).js\'',
43
46
  },
44
47
  engines: {
45
48
  node: '>=22',
@@ -52,7 +55,8 @@ export async function scaffold(targetDir, options = {}) {
52
55
  // pulse.config.js — write agent if specified, otherwise leave as commented hint
53
56
  write(targetDir, 'pulse.config.js',
54
57
  `export default {
55
- ${port !== 3000 ? ` port: ${port},\n` : ''}${agent && agent !== 'claude' ? ` agent: '${agent}',\n` : ` // agent: 'copilot', // uncomment to use GitHub Copilot CLI instead of Claude\n`}}
58
+ ${port !== 3000 ? ` port: ${port},\n` : ''}${agent && agent !== 'claude' ? ` agent: '${agent}',\n` : ` // agent: 'copilot', // uncomment to use GitHub Copilot CLI instead of Claude\n`} // design: 'freeform', // uncomment for hand-rolled HTML/CSS on every page — no src/ui/* components, no intake/sketch
59
+ }
56
60
  `)
57
61
 
58
62
  // Home page + tests — working counter proves the app runs
@@ -452,7 +456,8 @@ public/intake/ ← drop design reference images here before starting (not com
452
456
 
453
457
  1. Run \`pulse_list_structure\` to see what already exists
454
458
  2. Read \`pulse://guide\` from MCP — the complete reference for spec format, components, verification workflow, CSS rules, and patterns
455
- 3. If images are present in \`public/intake/\`, call \`pulse_extract_inspiration\` on each before \`pulse_intake\` — the session hook will remind you if you forget
459
+ 3. **If the user is requesting a new page or site, ask before planning anything:** "Do you have any design inspiration — a site you love, a screenshot, a mood board image? Drop images into \`public/intake/\` or paste a URL and I will extract the design intent before we start." This is not optional — do not present a plan or write code first. (A name mentioned in passing with nothing to look at — no URL, no image, no site you actually know — isn't inspiration to extract; treat it as color for tone and move on to \`pulse_intake\` with what the user actually described.)
460
+ 4. If images are present in \`public/intake/\`, call \`pulse_extract_inspiration\` on each before \`pulse_intake\` — the session hook will remind you if you forget
456
461
 
457
462
  The MCP guide is the single source of truth. Follow it for all technical decisions, component usage, and the mandatory verification workflow.
458
463