@invisibleloop/pulse 0.12.3 → 0.14.2

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 (181) hide show
  1. package/.claude/commands/build-page.md +5 -1
  2. package/.claude/commands/verify.md +31 -17
  3. package/.claude/pulse-checklist.md +21 -1
  4. package/.github/workflows/publish.yml +1 -0
  5. package/.pulse-verified +1 -0
  6. package/CLAUDE.md +56 -15
  7. package/docs/.claude/commands/verify.md +4 -0
  8. package/docs/.claude/pulse-checklist.md +20 -2
  9. package/docs/public/.pulse-ui-version +1 -1
  10. package/docs/public/andypixel.css +237 -0
  11. package/docs/public/bookworms.css +422 -0
  12. package/docs/public/coutumes.css +584 -0
  13. package/docs/public/images/bookworms/hero-books.jpg +0 -0
  14. package/docs/public/images/bookworms/how-we-read-now.jpg +0 -0
  15. package/docs/public/images/bookworms/lantern-keepers.jpg +0 -0
  16. package/docs/public/images/bookworms/provincial-bookseller.jpg +0 -0
  17. package/docs/public/images/journey/178.jpg +0 -0
  18. package/docs/public/images/journey/29.jpg +0 -0
  19. package/docs/public/images/retrowheels/hero.jpg +0 -0
  20. package/docs/public/images/shop/devbook-pro.jpg +0 -0
  21. package/docs/public/images/suzukibento/1015.jpg +0 -0
  22. package/docs/public/images/suzukibento/1025.jpg +0 -0
  23. package/docs/public/images/suzukibento/1043.jpg +0 -0
  24. package/docs/public/images/suzukibento/1059.jpg +0 -0
  25. package/docs/public/images/suzukibento/1062.jpg +0 -0
  26. package/docs/public/images/suzukibento/1084.jpg +0 -0
  27. package/docs/public/images/swish/1469334031218-e382a71b716b.jpg +0 -0
  28. package/docs/public/images/swish/1483985988355-763728e1935b.jpg +0 -0
  29. package/docs/public/images/swish/1485518882345-15568b007407.jpg +0 -0
  30. package/docs/public/images/swish/1490481651871-ab68de25d43d.jpg +0 -0
  31. package/docs/public/images/swish/1496747611176-843222e1e57c.jpg +0 -0
  32. package/docs/public/images/swish/1502716119720-b23a93e5fe1b.jpg +0 -0
  33. package/docs/public/images/swish/1539109136881-3be0616acf4b.jpg +0 -0
  34. package/docs/public/images/swish/1551803091-e20673f15770.jpg +0 -0
  35. package/docs/public/images/swish/1558769132-cb1aea458c5e.jpg +0 -0
  36. package/docs/public/images/swish/1572804013309-59a88b7e92f1.jpg +0 -0
  37. package/docs/public/journey.css +528 -0
  38. package/docs/public/pulse-ui.css +1036 -22
  39. package/docs/public/retrowheels.css +402 -0
  40. package/docs/public/shop.css +235 -0
  41. package/docs/public/sparkys.css +349 -0
  42. package/docs/public/suzukibento.css +517 -0
  43. package/docs/public/swish.css +394 -0
  44. package/docs/server.js +19 -4
  45. package/docs/src/lib/nav.js +14 -3
  46. package/docs/src/pages/components/decorate.js +74 -0
  47. package/docs/src/pages/components/gallery.js +102 -0
  48. package/docs/src/pages/components/hero.js +1 -0
  49. package/docs/src/pages/components/marquee.js +76 -0
  50. package/docs/src/pages/components/photo-card.js +74 -0
  51. package/docs/src/pages/components/section.js +40 -1
  52. package/docs/src/pages/meta.js +26 -0
  53. package/docs/src/pages/templates/andypixel.js +260 -0
  54. package/docs/src/pages/templates/bookworms.js +227 -0
  55. package/docs/src/pages/templates/coutumes.js +226 -0
  56. package/docs/src/pages/templates/journey.js +186 -0
  57. package/docs/src/pages/templates/retrowheels.js +342 -0
  58. package/docs/src/pages/templates/shop.js +257 -0
  59. package/docs/src/pages/templates/sparkys.js +249 -0
  60. package/docs/src/pages/templates/suzukibento.js +200 -0
  61. package/docs/src/pages/templates/swish.js +313 -0
  62. package/docs/src/pages/templates.js +313 -2
  63. package/docs/src/pages/testing.js +2 -1
  64. package/examples/.claude/commands/pulse-build.md +7 -0
  65. package/examples/.claude/commands/pulse-dev.md +7 -0
  66. package/examples/.claude/commands/pulse-start.md +9 -0
  67. package/examples/.claude/commands/pulse-stop.md +5 -0
  68. package/examples/.claude/commands/verify.md +84 -0
  69. package/examples/.claude/coverage-check.js +127 -0
  70. package/examples/.claude/pulse-checklist.md +177 -0
  71. package/examples/andypixel.css +237 -0
  72. package/examples/andypixel.js +258 -0
  73. package/examples/bookworms.css +422 -0
  74. package/examples/bookworms.js +225 -0
  75. package/examples/contact.js +1 -0
  76. package/examples/counter.js +1 -0
  77. package/examples/dev.server.js +17 -0
  78. package/examples/journey.css +528 -0
  79. package/examples/journey.js +186 -0
  80. package/examples/pricing.js +1 -0
  81. package/examples/public/.pulse-ui-version +1 -0
  82. package/examples/public/pulse-ui.css +4031 -0
  83. package/examples/public/pulse-ui.js +257 -0
  84. package/examples/quiz.js +1 -0
  85. package/examples/retrowheels.css +402 -0
  86. package/examples/retrowheels.js +340 -0
  87. package/examples/shared.js +6 -0
  88. package/examples/suzukibento.css +513 -0
  89. package/examples/suzukibento.js +200 -0
  90. package/examples/swish.css +394 -0
  91. package/examples/swish.js +311 -0
  92. package/examples/todos.js +1 -0
  93. package/package.json +2 -3
  94. package/public/.pulse-ui-version +1 -1
  95. package/public/images/bookworms/hero-books.jpg +0 -0
  96. package/public/images/bookworms/how-we-read-now.jpg +0 -0
  97. package/public/images/bookworms/lantern-keepers.jpg +0 -0
  98. package/public/images/bookworms/provincial-bookseller.jpg +0 -0
  99. package/public/images/journey/178.jpg +0 -0
  100. package/public/images/journey/29.jpg +0 -0
  101. package/public/images/retrowheels/hero.jpg +0 -0
  102. package/public/images/shop/devbook-pro.jpg +0 -0
  103. package/public/images/suzukibento/1015.jpg +0 -0
  104. package/public/images/suzukibento/1025.jpg +0 -0
  105. package/public/images/suzukibento/1043.jpg +0 -0
  106. package/public/images/suzukibento/1059.jpg +0 -0
  107. package/public/images/suzukibento/1062.jpg +0 -0
  108. package/public/images/suzukibento/1084.jpg +0 -0
  109. package/public/images/swish/1469334031218-e382a71b716b.jpg +0 -0
  110. package/public/images/swish/1483985988355-763728e1935b.jpg +0 -0
  111. package/public/images/swish/1485518882345-15568b007407.jpg +0 -0
  112. package/public/images/swish/1490481651871-ab68de25d43d.jpg +0 -0
  113. package/public/images/swish/1496747611176-843222e1e57c.jpg +0 -0
  114. package/public/images/swish/1502716119720-b23a93e5fe1b.jpg +0 -0
  115. package/public/images/swish/1539109136881-3be0616acf4b.jpg +0 -0
  116. package/public/images/swish/1551803091-e20673f15770.jpg +0 -0
  117. package/public/images/swish/1558769132-cb1aea458c5e.jpg +0 -0
  118. package/public/images/swish/1572804013309-59a88b7e92f1.jpg +0 -0
  119. package/public/pulse-ui.css +1080 -22
  120. package/scripts/build.js +72 -18
  121. package/src/agent/checklist.md +22 -2
  122. package/src/agent/commands/verify.md +4 -0
  123. package/src/agent/guide-components.md +146 -8
  124. package/src/agent/guide-design-gallery.md +288 -0
  125. package/src/agent/guide-design-references.md +172 -0
  126. package/src/agent/guide-explore.md +313 -0
  127. package/src/agent/guide-styles.md +212 -11
  128. package/src/agent/guide-templates.md +223 -10
  129. package/src/agent/guide.md +9 -4
  130. package/src/agent/identity.md +2 -1
  131. package/src/agent/skills/verify/SKILL.md +64 -28
  132. package/src/agent/workflow.md +247 -61
  133. package/src/cli/dev.js +39 -14
  134. package/src/cli/fmt.js +113 -0
  135. package/src/cli/index.js +60 -33
  136. package/src/cli/logger.js +145 -0
  137. package/src/cli/scaffold.js +57 -3
  138. package/src/html.js +6 -1
  139. package/src/mcp/server.js +2561 -99
  140. package/src/mcp/validate-worker.js +49 -5
  141. package/src/runtime/ssr.js +4 -1
  142. package/src/server/index.js +29 -5
  143. package/src/spec/schema.js +21 -0
  144. package/src/spec/schema.test.js +28 -0
  145. package/src/testing/html.js +130 -4
  146. package/src/testing/testing.test.js +58 -0
  147. package/src/ui/banner.js +24 -9
  148. package/src/ui/brand-statement.js +58 -0
  149. package/src/ui/button.js +13 -2
  150. package/src/ui/card.js +20 -1
  151. package/src/ui/colophon.js +29 -0
  152. package/src/ui/contact-row.js +41 -0
  153. package/src/ui/cta.js +18 -4
  154. package/src/ui/decorate.js +55 -0
  155. package/src/ui/display-heading.js +44 -0
  156. package/src/ui/drop-cap.js +29 -0
  157. package/src/ui/editorial-hero.js +63 -0
  158. package/src/ui/entry-list.js +36 -0
  159. package/src/ui/feature.js +20 -3
  160. package/src/ui/footer.js +78 -9
  161. package/src/ui/gallery.js +56 -0
  162. package/src/ui/hero.js +68 -18
  163. package/src/ui/image-pair.js +72 -0
  164. package/src/ui/index.js +20 -0
  165. package/src/ui/input.js +1 -0
  166. package/src/ui/marquee.js +48 -0
  167. package/src/ui/nav.js +19 -9
  168. package/src/ui/photo-card.js +44 -0
  169. package/src/ui/product-card.js +65 -0
  170. package/src/ui/pullquote.js +14 -3
  171. package/src/ui/search.js +1 -0
  172. package/src/ui/section-label.js +38 -0
  173. package/src/ui/section.js +1 -1
  174. package/src/ui/service-card.js +47 -0
  175. package/src/ui/stat-grid.js +40 -0
  176. package/src/ui/stat.js +16 -2
  177. package/src/ui/testimonial.js +17 -6
  178. package/src/ui/textarea.js +1 -0
  179. package/src/ui/top-strip.js +47 -0
  180. package/src/ui/trust-list.js +46 -0
  181. package/src/ui/ui.test.js +35 -0
package/src/mcp/server.js CHANGED
@@ -8,12 +8,18 @@
8
8
  * pulse://guide — complete guide: spec format, UI components, CSS rules, patterns
9
9
  *
10
10
  * Tools:
11
- * pulse_list_structure — list all pages and components
12
- * pulse_create_page — create a new page spec with proper template
11
+ * pulse_intent — intent engine: describe what to build, get archetype + scaffold
12
+ * pulse_suggest — draft-mode contextual feedback on partial specs
13
+ * pulse_intake — product intake: capture app details before scaffolding
14
+ * pulse_sketch — generate 3 structural layout directions before writing code
15
+ * pulse_list_icons — list all available icon names, grouped by category
16
+ * pulse_check_contrast — static WCAG contrast check on theme CSS colors
17
+ * pulse_list_structure — list all pages and components
18
+ * pulse_create_page — create a new page spec with proper template
13
19
  * pulse_create_component — create a reusable component
14
- * pulse_validate — validate a spec against the schema
15
- * pulse_check_version — installed vs static vs npm latest
16
- * pulse_update — re-copy pulse-ui assets from package → public/
20
+ * pulse_validate — validate a spec against the schema
21
+ * pulse_check_version — installed vs static vs npm latest
22
+ * pulse_update — re-copy pulse-ui assets from package → public/
17
23
  */
18
24
 
19
25
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
@@ -43,6 +49,48 @@ const PKG_VERSION = JSON.parse(
43
49
  fs.readFileSync(new URL('../../package.json', import.meta.url).pathname, 'utf8')
44
50
  ).version
45
51
 
52
+ // Common synonym → canonical vibe normalisation
53
+ // Prevents hard enum errors when agents use intuitive names like "modern-minimal"
54
+ const VIBE_SYNONYMS = {
55
+ 'modern': 'minimal',
56
+ 'modern-minimal': 'minimal',
57
+ 'clean': 'minimal',
58
+ 'sleek': 'minimal',
59
+ 'stark': 'minimal',
60
+ 'newspaper': 'editorial',
61
+ 'magazine': 'editorial',
62
+ 'typographic': 'editorial',
63
+ 'serif': 'editorial',
64
+ 'fun': 'playful',
65
+ 'playful-bold': 'playful',
66
+ 'energetic': 'bold',
67
+ 'impactful': 'bold',
68
+ 'strong': 'bold',
69
+ 'raw': 'brutalist',
70
+ 'grunge': 'brutalist',
71
+ 'vintage': 'retro',
72
+ 'nostalgic': 'retro',
73
+ 'classic': 'retro',
74
+ 'futuristic': 'neon',
75
+ 'cyber': 'neon',
76
+ 'dark-tech': 'neon',
77
+ 'journal': 'paper',
78
+ 'organic': 'paper',
79
+ 'handmade': 'paper',
80
+ 'friendly': 'warm',
81
+ 'cosy': 'warm',
82
+ 'cozy': 'warm',
83
+ 'professional': 'corporate',
84
+ 'business': 'corporate',
85
+ 'enterprise': 'corporate',
86
+ }
87
+
88
+ function normaliseVibe(v) {
89
+ if (!v) return v
90
+ const lower = v.toLowerCase().replace(/[_\s]+/g, '-')
91
+ return VIBE_SYNONYMS[lower] || v
92
+ }
93
+
46
94
  // ---------------------------------------------------------------------------
47
95
  // Server
48
96
  // ---------------------------------------------------------------------------
@@ -110,6 +158,27 @@ const GUIDE_RESOURCES = [
110
158
  description: 'Ready-made page templates. Fetch this when the user asks to build a landing page, marketing site, or branded template — it lists triggers, pre-build questions, and adaptation rules for each template.',
111
159
  content: () => GUIDE_TEMPLATES,
112
160
  },
161
+ {
162
+ name: 'guide-design-references',
163
+ uri: 'pulse://guide/design-references',
164
+ title: 'Pulse Guide — Design Directions & Aesthetic Vocabulary',
165
+ description: '12 named design directions (warm local business, editorial, brutalist, event, portfolio, etc.) with vibe presets, component combinations, palette patterns, and signature moves. Fetch when choosing an aesthetic approach for a new project.',
166
+ content: () => GUIDE_DESIGN_REF,
167
+ },
168
+ {
169
+ name: 'guide-design-gallery',
170
+ uri: 'pulse://guide/design-gallery',
171
+ title: 'Pulse Guide — Design Gallery & Prop Reference',
172
+ description: 'Curated catalogue of all 6 templates with visual descriptions, vibes, CSS themes, and key components. Also includes component combination recipes (image card, article card, stat strip, credentials list, booking form) and a critical prop-name reference (content vs children, name vs author, question vs title, etc.).',
173
+ content: () => GUIDE_DESIGN_GALL,
174
+ },
175
+ {
176
+ name: 'guide-explore',
177
+ uri: 'pulse://guide/explore',
178
+ title: 'Pulse Guide — Blank-Canvas Layout Thinking',
179
+ description: 'Escape template defaults: zone-based layout thinking, emotional intent, 7 structural gestures (full-bleed, asymmetric split, typography-only, editorial flow, dense grid, story scroll, content-first), raw HTML patterns with zero components, CSS token reference, and an anti-pattern checklist. Read when the user wants something truly distinctive or pulse_sketch returns an unusual direction.',
180
+ content: () => GUIDE_EXPLORE,
181
+ },
113
182
  ]
114
183
 
115
184
  for (const { name, uri, title, description, content } of GUIDE_RESOURCES) {
@@ -118,6 +187,134 @@ for (const { name, uri, title, description, content } of GUIDE_RESOURCES) {
118
187
  )
119
188
  }
120
189
 
190
+ // pulse://quickstart — combined essentials for simple/targeted tasks.
191
+ // Reduces cold-start fetch round-trips from 4 (workflow + spec + components + styles)
192
+ // to 1 for edits, bug fixes, and one-shot page builds that don't need the full guides.
193
+ server.registerResource(
194
+ 'quickstart',
195
+ 'pulse://quickstart',
196
+ {
197
+ title: 'Pulse Quickstart — Essentials in One Fetch',
198
+ description: 'Workflow phases, spec skeleton, key component rules, and theming essentials combined. Use for targeted edits, bug fixes, or simple page builds instead of fetching pulse://workflow + pulse://guide/spec + pulse://guide/components separately.',
199
+ mimeType: 'text/plain',
200
+ },
201
+ async () => ({
202
+ contents: [{
203
+ uri: 'pulse://quickstart',
204
+ mimeType: 'text/plain',
205
+ text: `# Pulse Quickstart
206
+
207
+ > **When to use this resource**
208
+ > - Editing an existing page, fixing a bug, or adding a section to a known page
209
+ > - One-shot "build me X" requests where you already know what to build
210
+ > - Any task where you don't need intake, sketch, or design direction
211
+ >
212
+ > **When to use the full guides instead**
213
+ > - New project or new branded page → \`pulse://workflow\` then \`pulse://guide/templates\` + \`pulse://guide/design-references\`
214
+ > - Unfamiliar component props → \`pulse://guide/components\`
215
+ > - Server data, store, auth → \`pulse://guide/server\`
216
+ > - CSS / theming deep-dive → \`pulse://guide/styles\`
217
+
218
+ ---
219
+
220
+ ## Workflow (abbreviated)
221
+
222
+ \`\`\`
223
+ New page: pulse_intake → pulse_sketch → pulse_intent → build → validate → screenshot → design approval → /verify
224
+ Edit/fix: read file → change → validate → /verify
225
+ \`\`\`
226
+
227
+ Tier 1 (static/read-only): phases 3 → 4 → 5a (design approval) → 5b (/verify)
228
+ Tier 2 (interactive): phases 3 → 4 → 5a → 5b → 6 (tests) → 7 (review)
229
+
230
+ Pass bar — ALL must pass before done:
231
+ - \`pulse_validate\` clean (no errors, no warnings)
232
+ - Lighthouse desktop + mobile: Accessibility, Best Practices, SEO all 100
233
+ - CLS: 0.00
234
+ - No console errors
235
+
236
+ ---
237
+
238
+ ## Spec skeleton
239
+
240
+ \`\`\`js
241
+ import { nav, hero, section, container, grid, button, footer } from '@invisibleloop/pulse/ui'
242
+
243
+ export default {
244
+ route: '/',
245
+ meta: {
246
+ title: 'Page Title',
247
+ description: 'Meta description.',
248
+ theme: 'light', // omit for dark (default)
249
+ vibe: 'editorial', // warm|editorial|playful|minimal|bold|brutalist|retro|corporate|neon|paper
250
+ styles: ['/pulse-ui.css', '/theme.css', '/app.css'],
251
+ },
252
+ // state + mutations only if interactive:
253
+ state: { status: 'idle' },
254
+ mutations: { setStatus: (state, e) => ({ status: e.target.value }) },
255
+ // server fetchers (SSR, optional):
256
+ server: { items: async () => fetchItems() },
257
+ view: (state, server) => \`
258
+ <a href="#main-content" class="skip-link" style="position:absolute;left:-9999px;...">Skip to main content</a>
259
+ \${nav({ logo: 'My Site', links: [...], action: button({ label: 'CTA', href: '/signup' }) })}
260
+ <main id="main-content">
261
+ <!-- sections here -->
262
+ </main>
263
+ \${footer({ logo: 'My Site', links: [...], legal: '© 2026 My Site' })}
264
+ \`,
265
+ }
266
+ \`\`\`
267
+
268
+ **Critical rules:**
269
+ - \`meta\` is always a plain object — never \`meta: async (ctx) => ({...})\`
270
+ - Never set \`hydrate\` — the framework sets it automatically
271
+ - \`onSuccess\` AND \`onError\` are both required in every action
272
+ - Never use \`data-event\` on text inputs — use FormData in \`onStart\`/\`run\`
273
+ - Every interactive element must be \`<button>\`, \`<a>\`, or have \`tabindex="0"\`
274
+ - \`<main id="main-content">\` is required on every page
275
+
276
+ ---
277
+
278
+ ## Key components (quick reference)
279
+
280
+ Import from \`@invisibleloop/pulse/ui\`. Always include \`/pulse-ui.css\` in \`meta.styles\`.
281
+
282
+ | Component | Key props |
283
+ |---|---|
284
+ | \`nav\` | \`logo\`, \`logoHref\`, \`links\` ([{label,href}]), \`action\` (HTML slot), \`sticky\` |
285
+ | \`hero\` | \`title\`, \`subtitle\`, \`eyebrow\`, \`actions\` (HTML slot), \`image\` (HTML slot), \`background\` |
286
+ | \`button\` | \`label\`, \`variant\` (primary/secondary/ghost/**ghost-light**/danger), \`size\` (sm/md/lg), \`href\` — use **ghost-light** on dark backgrounds |
287
+ | \`section\` | \`content\`, \`variant\` (default/alt/**dark**/diagonal/paper/spotlight), \`padding\`, \`id\` — **dark** in light theme = navy bg |
288
+ | \`container\` | \`content\`, \`size\` (sm/md/lg/xl) |
289
+ | \`grid\` | \`content\`, \`cols\` (1–4), \`gap\` (sm/md/lg) |
290
+ | \`card\` | \`title\`, \`content\`, \`footer\`, \`variant\` (default/elevated/bordered/flat/glass/tinted) |
291
+ | \`feature\` | \`icon\`, \`title\`, \`description\`, \`level\`, \`center\` |
292
+ | \`stat\` | \`label\`, \`value\`, \`change\`, \`trend\`, \`center\` |
293
+ | \`testimonial\` | \`quote\`, \`name\`, \`role\`, \`rating\` |
294
+ | \`cta\` | \`title\`, \`subtitle\`, \`actions\` (HTML slot), \`eyebrow\` |
295
+ | \`footer\` | \`logo\`, \`logoHref\`, \`links\`, \`legal\`, \`columns\` |
296
+ | \`input\` | \`name\`, \`label\`, \`type\`, \`required\`, \`error\` |
297
+ | \`modal\` | \`id\`, \`title\`, \`content\`, \`footer\` — always in DOM, never conditional |
298
+ | \`displayHeading\` | \`text\`, \`level\`, \`tracking\` — editorial large type |
299
+ | \`sectionLabel\` | \`eyebrow\`, \`heading\`, \`level\`, \`rule\` — editorial section divider |
300
+ | \`pullquote\` | \`quote\`, \`cite\`, \`size\` |
301
+ | \`entryList\` | \`items\` ([{meta, title, description}]), \`metaLabel\` — tabular editorial list |
302
+
303
+ ---
304
+
305
+ ## Theming essentials
306
+
307
+ - Hex values → \`public/theme.css\` only. \`app.css\` uses \`var()\` references only.
308
+ - Load order: \`/pulse-ui.css\` → \`/theme.css\` → \`/app.css\`
309
+ - Light theme overrides target \`[data-theme="light"]\`, NOT \`\:root\`
310
+ - \`--ui-accent\` must pass WCAG AA 4.5:1 on \`--ui-bg\` for body text
311
+ - \`--ui-muted\` on warm/light palettes often fails contrast — always override \`--muted\` when changing \`--bg\` and run \`pulse_check_contrast\`
312
+ - Ghost buttons on dark backgrounds: use \`variant: 'ghost-light'\` not \`'ghost'\`
313
+ `,
314
+ }]
315
+ })
316
+ )
317
+
121
318
  server.registerResource(
122
319
  'workflow',
123
320
  'pulse://workflow',
@@ -186,7 +383,10 @@ server.registerTool(
186
383
  ].filter(Boolean)
187
384
 
188
385
  const tagStr = tags.length ? ` [${tags.join(' | ')}]` : ''
189
- lines.push(` ${route.padEnd(24)} → ${path.relative(ROOT, spec.hydrate.replace('/src/', 'src/'))}${tagStr}`)
386
+ const hydrateStr = spec.hydrate
387
+ ? path.relative(ROOT, spec.hydrate.replace('/src/', 'src/'))
388
+ : '(server-only)'
389
+ lines.push(` ${route.padEnd(24)} → ${hydrateStr}${tagStr}`)
190
390
  }
191
391
  }
192
392
 
@@ -220,6 +420,145 @@ server.registerTool(
220
420
  }
221
421
  )
222
422
 
423
+ // ---------------------------------------------------------------------------
424
+ // pulse_status
425
+ // ---------------------------------------------------------------------------
426
+
427
+ server.registerTool(
428
+ 'pulse_status',
429
+ {
430
+ description: 'Project health snapshot — pages, routes, server status, last build. Call at the start of a session to orient quickly without reading files.',
431
+ inputSchema: {},
432
+ },
433
+ async () => {
434
+ const lines = []
435
+
436
+ // ── Pages ──────────────────────────────────────────────────────────────
437
+ const specs = await loadPages(ROOT)
438
+ const components = findComponents()
439
+ lines.push(`Pages: ${specs.length}`)
440
+ for (const s of specs) {
441
+ const tags = [
442
+ s.server && 'server',
443
+ s.mutations && 'mutations',
444
+ s.actions && 'actions',
445
+ s.store && 'store',
446
+ s.stream && 'streaming',
447
+ ].filter(Boolean)
448
+ const tagStr = tags.length ? ` [${tags.join(', ')}]` : ''
449
+ lines.push(` ${(s.route || '?').padEnd(26)}${tagStr}`)
450
+ }
451
+
452
+ lines.push(`\nComponents: ${components.length}`)
453
+ if (components.length > 0) {
454
+ lines.push(components.map(c => ` ${c.name}`).join('\n'))
455
+ }
456
+
457
+ // ── Dev server ─────────────────────────────────────────────────────────
458
+ let port = 3000
459
+ const configPath = path.join(ROOT, 'pulse.config.js')
460
+ if (fs.existsSync(configPath)) {
461
+ try {
462
+ const mod = await import(`${configPath}?t=${Date.now()}`)
463
+ if (mod.default?.port) port = mod.default.port
464
+ } catch { /* use default */ }
465
+ }
466
+ const serverRunning = await new Promise(res => {
467
+ const req = http.get(`http://localhost:${port}/`, r => { r.resume(); res(true) })
468
+ req.on('error', () => res(false))
469
+ req.setTimeout(1500, () => { req.destroy(); res(false) })
470
+ })
471
+ lines.push(`\nDev server: ${serverRunning ? `running on port ${port}` : `not running (port ${port})`}`)
472
+
473
+ // ── Last build ─────────────────────────────────────────────────────────
474
+ const manifestPath = path.join(ROOT, 'public', 'dist', 'manifest.json')
475
+ if (fs.existsSync(manifestPath)) {
476
+ const mtime = fs.statSync(manifestPath).mtime
477
+ const age = Math.round((Date.now() - mtime) / 1000)
478
+ const ageStr = age < 60 ? `${age}s ago`
479
+ : age < 3600 ? `${Math.round(age / 60)}m ago`
480
+ : `${Math.round(age / 3600)}h ago`
481
+
482
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
483
+ const bundles = Object.values(manifest).filter(v => v.endsWith('.js') && !v.includes('runtime') && !v.includes('menu') && !v.includes('pulse-ui'))
484
+ const cssFiles = Object.values(manifest).filter(v => v.endsWith('.css'))
485
+ lines.push(`\nLast build: ${ageStr}`)
486
+ lines.push(` ${bundles.length} JS bundle${bundles.length !== 1 ? 's' : ''}, ${cssFiles.length} CSS file${cssFiles.length !== 1 ? 's' : ''}`)
487
+ } else {
488
+ lines.push('\nLast build: never (run pulse build first)')
489
+ }
490
+
491
+ // ── pulse-ui version ───────────────────────────────────────────────────
492
+ const stampPath = path.join(ROOT, 'public', '.pulse-ui-version')
493
+ const syncedVersion = fs.existsSync(stampPath) ? fs.readFileSync(stampPath, 'utf8').trim() : null
494
+ const versionMatch = syncedVersion === PKG_VERSION
495
+ lines.push(`\npulse-ui: ${versionMatch ? `v${PKG_VERSION} ✓` : `OUT OF DATE (project: ${syncedVersion || 'unknown'}, installed: v${PKG_VERSION})`}`)
496
+
497
+ // ── Last verified stamp ────────────────────────────────────────────────
498
+ const verifyStampPath = path.join(ROOT, '.pulse-verified')
499
+ if (fs.existsSync(verifyStampPath)) {
500
+ const stampSecs = parseInt(fs.readFileSync(verifyStampPath, 'utf8').trim(), 10)
501
+ const stampAge = Math.round((Date.now() / 1000) - stampSecs)
502
+ const stampStr = stampAge < 60 ? `${stampAge}s ago`
503
+ : stampAge < 3600 ? `${Math.round(stampAge / 60)}m ago`
504
+ : `${Math.round(stampAge / 3600)}h ago`
505
+
506
+ // Check if any spec was edited after the stamp
507
+ const pagesDir = path.join(ROOT, 'src', 'pages')
508
+ let staleSince = null
509
+ if (fs.existsSync(pagesDir)) {
510
+ const checkDir = (dir) => {
511
+ for (const entry of fs.readdirSync(dir)) {
512
+ const full = path.join(dir, entry)
513
+ if (fs.statSync(full).isDirectory()) { checkDir(full); continue }
514
+ if (!entry.endsWith('.js') || entry.endsWith('.test.js')) continue
515
+ const mtime = fs.statSync(full).mtimeMs / 1000
516
+ if (mtime > stampSecs && (!staleSince || mtime > staleSince)) staleSince = mtime
517
+ }
518
+ }
519
+ try { checkDir(pagesDir) } catch { /* ignore */ }
520
+ }
521
+ if (staleSince) {
522
+ const editAge = Math.round((Date.now() / 1000) - staleSince)
523
+ const editStr = editAge < 60 ? `${editAge}s ago` : editAge < 3600 ? `${Math.round(editAge / 60)}m ago` : `${Math.round(editAge / 3600)}h ago`
524
+ lines.push(`\nLast verified: ${stampStr} ⚠ spec edited ${editStr} after stamp — run /verify`)
525
+ } else {
526
+ lines.push(`\nLast verified: ${stampStr} ✓`)
527
+ }
528
+ } else {
529
+ lines.push('\nLast verified: never — run /verify before marking work done')
530
+ }
531
+
532
+ // ── Served title check ─────────────────────────────────────────────────
533
+ // Fetch the served HTML and compare its <title> against the spec's meta.title.
534
+ // A mismatch means the dev server is serving a different project — a common
535
+ // confusion source when multiple Pulse projects share the same port.
536
+ if (serverRunning && specs.length > 0) {
537
+ const rootSpec = specs.find(s => s.route === '/' || s.route === '')
538
+ if (rootSpec?.meta?.title && typeof rootSpec.meta.title === 'string') {
539
+ try {
540
+ const servedHtml = await new Promise((resolve, reject) => {
541
+ let body = ''
542
+ const req = http.get(`http://localhost:${port}/`, res => {
543
+ res.on('data', chunk => { body += chunk; if (body.length > 4096) { req.destroy(); resolve(body) } })
544
+ res.on('end', () => resolve(body))
545
+ })
546
+ req.on('error', reject)
547
+ req.setTimeout(2000, () => { req.destroy(); reject(new Error('timeout')) })
548
+ })
549
+ const titleMatch = servedHtml.match(/<title>([^<]*)<\/title>/)
550
+ const servedTitle = titleMatch?.[1]?.trim()
551
+ if (servedTitle && servedTitle !== rootSpec.meta.title) {
552
+ lines.push(`\n⚠ Server title mismatch: served "${servedTitle}" but spec says "${rootSpec.meta.title}". The dev server may be serving a different project — run pulse_restart_server.`)
553
+ }
554
+ } catch { /* ignore — network error, non-critical */ }
555
+ }
556
+ }
557
+
558
+ return text(lines.join('\n'))
559
+ }
560
+ )
561
+
223
562
  // ---------------------------------------------------------------------------
224
563
  // pulse_validate
225
564
  // ---------------------------------------------------------------------------
@@ -228,9 +567,28 @@ server.registerTool(
228
567
  'pulse_validate',
229
568
  {
230
569
  description: 'Validate a Pulse spec before writing it. Returns errors or confirms the spec is valid.',
231
- inputSchema: { content: z.string().describe('JavaScript spec content to validate') },
570
+ inputSchema: {
571
+ content: z.string().optional().describe('JavaScript spec content to validate'),
572
+ file: z.string().optional().describe('Absolute path to spec file to validate'),
573
+ },
232
574
  },
233
- async ({ content }) => validateContent(content)
575
+ async ({ content, file }) => {
576
+ if (!content && !file) {
577
+ return text('Error: must provide either content or file')
578
+ }
579
+ if (content && file) {
580
+ return text('Error: provide only one of content or file, not both')
581
+ }
582
+
583
+ if (file) {
584
+ if (!fs.existsSync(file)) {
585
+ return text(`File not found: ${file}`)
586
+ }
587
+ content = fs.readFileSync(file, 'utf8')
588
+ }
589
+
590
+ return validateContent(content)
591
+ }
234
592
  )
235
593
 
236
594
  // ---------------------------------------------------------------------------
@@ -445,7 +803,8 @@ server.registerTool(
445
803
  res.on('data', d => chunks.push(d))
446
804
  res.on('end', () => {
447
805
  const body = Buffer.concat(chunks).toString('utf-8')
448
- resolve(text(`HTTP ${res.statusCode}\n\n${body.slice(0, 8000)}`))
806
+ const reminder = res.statusCode === 200 ? `\n\n---\n⚠ POST-BUILD CHECKLIST — run these before telling the user you're done:\n1. Take a screenshot with chrome-devtools\n2. If this was a NEW build (pulse_intake ran): show the screenshot to the user and ASK for design approval before continuing\n3. Once the user approves (or this is an edit/fix): pulse_design_review → pulse_layout_review → /verify\nDo NOT run Lighthouse before the user has approved the design.` : ''
807
+ resolve(text(`HTTP ${res.statusCode}\n\n${body.slice(0, 8000)}${reminder}`))
449
808
  })
450
809
  })
451
810
  req.on('error', e => resolve(text(`Error fetching page: ${e.message}`)))
@@ -550,24 +909,51 @@ and returns a structured review brief. You must read everything carefully, find
550
909
  issue, and fix them all before reporting back to the user. Use this after completing
551
910
  any feature build.`,
552
911
  inputSchema: {
553
- file: z.string().describe('Absolute path to the spec file to review'),
912
+ file: z.string().optional().describe('Absolute path to the spec file to review'),
913
+ content: z.string().optional().describe('JavaScript spec content to review (alternative to file)'),
554
914
  },
555
915
  },
556
- async ({ file }) => {
557
- if (!fs.existsSync(file)) return text(`File not found: ${file}`)
916
+ async ({ file, content }) => {
917
+ if (!file && !content) {
918
+ return text('Error: must provide either file or content')
919
+ }
920
+ if (file && content) {
921
+ return text('Error: provide only one of file or content, not both')
922
+ }
558
923
 
559
- const source = fs.readFileSync(file, 'utf8')
924
+ let source = content
925
+ if (file) {
926
+ if (!fs.existsSync(file)) return text(`File not found: ${file}`)
927
+ source = fs.readFileSync(file, 'utf8')
928
+ }
560
929
 
561
930
  // Run the validator in a child process (same as pulse_validate)
562
- const validatorScript = new URL('./validate-worker.js', import.meta.url).pathname
563
931
  let validationResult = '(could not run validator)'
564
- try {
565
- validationResult = execFileSync(process.execPath, [validatorScript, file], {
566
- timeout: 10_000,
567
- encoding: 'utf8',
568
- }).trim()
569
- } catch (err) {
570
- validationResult = err.stdout?.trim() || err.message
932
+ if (file) {
933
+ const validatorScript = new URL('./validate-worker.js', import.meta.url).pathname
934
+ try {
935
+ validationResult = execFileSync(process.execPath, [validatorScript, file], {
936
+ timeout: 10_000,
937
+ encoding: 'utf8',
938
+ }).trim()
939
+ } catch (err) {
940
+ validationResult = err.stdout?.trim() || err.message
941
+ }
942
+ } else {
943
+ // Content-only mode — write to temp file and validate
944
+ const tmpFile = path.join(os.tmpdir(), `pulse-review-${Date.now()}.js`)
945
+ fs.writeFileSync(tmpFile, source, 'utf8')
946
+ const validatorScript = new URL('./validate-worker.js', import.meta.url).pathname
947
+ try {
948
+ validationResult = execFileSync(process.execPath, [validatorScript, tmpFile], {
949
+ timeout: 10_000,
950
+ encoding: 'utf8',
951
+ }).trim()
952
+ } catch (err) {
953
+ validationResult = err.stdout?.trim() || err.message
954
+ } finally {
955
+ try { fs.unlinkSync(tmpFile) } catch {}
956
+ }
571
957
  }
572
958
 
573
959
  // Try to render the view with initial state
@@ -591,37 +977,95 @@ any feature build.`,
591
977
 
592
978
  return text(`# Pulse Code Review
593
979
 
594
- You are now a **senior code reviewer**. You did not write this code. Read it with fresh eyes and find every problem — no matter how small.
595
-
596
- Work through each section of the checklist below. For every issue you find, fix it immediately before moving on. Do not report issues without fixing them. When you have fixed everything, confirm what you changed.
980
+ You are now a **senior code reviewer**. Read the spec, find every problem, and fix them all before reporting back.
597
981
 
598
982
  ---
599
983
 
600
- ## Spec source
984
+ ## Validator output
985
+
986
+ ${validationResult}
987
+
988
+ ${validationResult.includes('✓') ? '' : `
989
+ ## Spec source (validation failed — showing for debugging)
601
990
 
602
991
  \`\`\`js
603
992
  ${source}
604
993
  \`\`\`
994
+ `}
605
995
 
606
- ---
996
+ ${renderNote.includes('could not') ? `
997
+ ## Render error
607
998
 
608
- ## Rendered HTML (initial state) ${renderNote}
999
+ ${renderNote}
609
1000
 
610
- \`\`\`html
611
- ${renderedHtml || '(empty)'}
1001
+ \`\`\`js
1002
+ ${source}
612
1003
  \`\`\`
1004
+ ` : ''}
613
1005
 
614
1006
  ---
615
1007
 
616
- ## Validator output
617
-
618
- ${validationResult}
1008
+ ## Auto-checked items
1009
+
1010
+ ${(() => {
1011
+ const checks = []
1012
+
1013
+ // Check rendered HTML
1014
+ if (renderedHtml) {
1015
+ // Positive tabindex
1016
+ const posTabindex = /tabindex=["']?([1-9]\d*)/.test(renderedHtml)
1017
+ checks.push(posTabindex ? '✗ **Positive tabindex found** — remove tabindex > 0, reorder DOM instead' : '✓ No positive tabindex')
1018
+
1019
+ // data-event on input
1020
+ const dataEventInput = /<input[^>]*data-event/.test(renderedHtml)
1021
+ checks.push(dataEventInput ? '✗ **data-event on <input>** — this destroys focus on every keystroke' : '✓ No data-event on text inputs')
1022
+
1023
+ // React patterns
1024
+ const reactPatterns = /(className|htmlFor|onClick)=/.test(renderedHtml)
1025
+ checks.push(reactPatterns ? '✗ **React patterns found** — use class, for, data-event instead' : '✓ No React patterns (className/htmlFor/onClick)')
1026
+
1027
+ // Emoji in HTML — strip SVG, data URIs, and aria-hidden elements before checking.
1028
+ // Mathematical/decorative Unicode in aria-hidden elements is intentional and accessible.
1029
+ const htmlNoSvg = renderedHtml
1030
+ .replace(/<svg[\s\S]*?<\/svg>/gi, '')
1031
+ .replace(/data:[^"']+/g, '')
1032
+ .replace(/<[^>]+aria-hidden=["']true["'][^>]*>[\s\S]*?<\/[^>]+>/gi, '')
1033
+ // Restrict to the emoji Unicode blocks only — excludes math symbols (∑, π, ∞, etc.),
1034
+ // arrows, and other non-emoji Unicode that are commonly used as decorative text.
1035
+ 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
1036
+ const hasEmoji = emojiRegex.test(htmlNoSvg)
1037
+ checks.push(hasEmoji ? '⚠ **Emoji in HTML** — use icon components or aria-label instead (verify: may be intentional icon)' : '✓ No emoji in view HTML')
1038
+
1039
+ // Main landmark
1040
+ const hasMain = /<main[^>]*id=["']?main-content/.test(renderedHtml)
1041
+ checks.push(hasMain ? '✓ <main id="main-content"> present' : '✗ **Missing main landmark** — add <main id="main-content">')
1042
+ }
1043
+
1044
+ // Check spec source
1045
+ // Hex colours in view — exclude anchor hrefs (#id), CSS selector strings, and comments
1046
+ // Only flag actual colour values: # followed by exactly 3 or 6 hex chars with no trailing word chars
1047
+ const viewBlock = source.slice(source.indexOf('view:'))
1048
+ const hexInView = /(?<!href=["']?)#[0-9a-fA-F]{6}\b|(?<!href=["']?)#[0-9a-fA-F]{3}\b(?![0-9a-fA-F])/.test(viewBlock)
1049
+ if (hexInView) {
1050
+ checks.push('⚠ **Possible hex colour in view** — use var(--ui-*) tokens only (verify: may be an anchor #id href)')
1051
+ } else {
1052
+ checks.push('✓ No obvious hex colours in view')
1053
+ }
1054
+
1055
+ // Creative override detection — check if spec declares component-free mode
1056
+ const creativeOverride = /component.free|creative\s+override|raw\s+HTML\s+throughout/i.test(source)
1057
+ checks.push(creativeOverride
1058
+ ? '⚡ **Creative override declared** — component pattern checks are advisory only. Lighthouse 100/100/100/100 is the pass bar.'
1059
+ : '○ No creative override declared — component checks apply')
1060
+
1061
+ return checks.join('\n')
1062
+ })()}
619
1063
 
620
1064
  ---
621
1065
 
622
1066
  ## Review checklist
623
1067
 
624
- Work through every item. Fix anything that fails.
1068
+ Work through every item. Fix anything that fails. Refer to the spec source at ${file} as needed — do not ask me to paste it.
625
1069
 
626
1070
  ### Structure
627
1071
  - [ ] \`route\` is set explicitly — not left to auto-discovery
@@ -640,10 +1084,24 @@ Work through every item. Fix anything that fails.
640
1084
 
641
1085
  ### Components & HTML
642
1086
  - [ ] Components from the UI library are used — no hand-written \`<button>\`, \`<input>\`, \`<table>\` etc where a component exists
1087
+ - [ ] **Component patterns check** — grep the rendered HTML for these class names:
1088
+ - \`.hero\`, \`.-hero\`, \`__hero\` → should use \`hero()\` component
1089
+ - \`.card\`, \`.product-card\`, \`.service-card\` → should use \`card()\` component
1090
+ - Any two-column image + text layout → should use \`media()\` component
1091
+ - \`.banner\`, \`.strip\`, \`.bar\` → should use \`banner()\` component
1092
+ - \`.feature\`, \`.feature-card\` → should use \`feature()\` component
1093
+ - \`.testimonial\` → should use \`testimonial()\` component
1094
+
1095
+ **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.
1096
+
1097
+ 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.
1098
+
1099
+ - [ ] **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/100/100/100 desktop and mobile passes before closing the review.
643
1100
  - [ ] No \`data-event\` on text inputs — this destroys focus on every keystroke
644
1101
  - [ ] No \`className\`, \`htmlFor\`, \`onClick=\`, or other React patterns
645
- - [ ] No hardcoded hex colours — only \`var(--ui-*)\` tokens
646
- - [ ] No emoji in the view HTML
1102
+ - [ ] No hardcoded hex colours in view — only \`var(--ui-*)\` tokens (anchor \`href="#id"\` values are fine — those are not colours)
1103
+ - [ ] 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)
1104
+ - [ ] **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.
647
1105
 
648
1106
  ### Accessibility
649
1107
  - [ ] \`<main id="main-content">\` is present
@@ -682,6 +1140,13 @@ Work through every item. Fix anything that fails.
682
1140
  Fix every issue you find. Then confirm what was changed.
683
1141
 
684
1142
  **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.**`)
1143
+
1144
+ // Write the .pulse-verified stamp from the MCP side so the stop hook
1145
+ // is never blocked by a sub-second mtime race from a Bash touch command.
1146
+ // This is a best-effort write — if it fails, the agent can still Bash-stamp.
1147
+ try {
1148
+ fs.writeFileSync(path.join(ROOT, '.pulse-verified'), String(Math.floor(Date.now() / 1000)), 'utf8')
1149
+ } catch { /* non-critical */ }
685
1150
  }
686
1151
  )
687
1152
 
@@ -767,89 +1232,2053 @@ server.registerTool(
767
1232
  )
768
1233
 
769
1234
  // ---------------------------------------------------------------------------
770
- // pulse_update
1235
+ // pulse_intent — Intent Engine
771
1236
  // ---------------------------------------------------------------------------
772
1237
 
1238
+ const ARCHETYPES = {
1239
+ dashboard: {
1240
+ keywords: ['dashboard', 'analytics', 'metrics', 'stats', 'overview', 'admin', 'monitor', 'report', 'kpi', 'insight', 'data'],
1241
+ components: ['nav', 'stat', 'card', 'grid', 'barChart', 'lineChart', 'donutChart', 'table', 'badge', 'section', 'container', 'empty', 'spinner'],
1242
+ description: 'Data-rich admin or analytics page with metrics, charts, and tabular data',
1243
+ stateHint: "{ period: 'week', loading: false }",
1244
+ serverHint: "{ stats: async (ctx) => fetchStats(), rows: async (ctx) => fetchRows() }",
1245
+ scaffold: `import { nav, stat, card, grid, barChart, table, section, container, badge, empty, spinner } from '@invisibleloop/pulse/ui'
1246
+
1247
+ export default {
1248
+ route: '/dashboard',
1249
+ meta: {
1250
+ title: 'Dashboard',
1251
+ styles: ['/pulse-ui.css'],
1252
+ },
1253
+ server: {
1254
+ stats: async (ctx) => ({ total: 0, change: 0 }),
1255
+ rows: async (ctx) => [],
1256
+ },
1257
+ state: { period: 'week' },
1258
+ mutations: {
1259
+ setPeriod: (state, e) => ({ period: e.target.value }),
1260
+ },
1261
+ view: (state, server) => \`
1262
+ <main id="main-content">
1263
+ \${nav({ logo: 'Dashboard', links: [] })}
1264
+ \${section({ content: container({ content: \`
1265
+ \${grid({ cols: 4, content:
1266
+ stat({ label: 'Total', value: String(server.stats.total), trend: 'up', change: server.stats.change + '%' })
1267
+ })}
1268
+ \${card({ title: 'Recent activity', content:
1269
+ server.rows.length
1270
+ ? table({ head: ['Name', 'Status'], rows: server.rows.map(r => [r.name, badge({ label: r.status })]) })
1271
+ : empty({ message: 'No activity yet' })
1272
+ })}
1273
+ \` }) })}
1274
+ </main>
1275
+ \`,
1276
+ }`,
1277
+ guides: ['pulse://guide/components', 'pulse://guide/spec', 'pulse://guide/server'],
1278
+ },
1279
+
1280
+ landing: {
1281
+ keywords: ['landing', 'marketing', 'homepage', 'product', 'saas', 'startup', 'website', 'launch', 'hero', 'promotional'],
1282
+ components: ['nav', 'hero', 'feature', 'stat', 'pricing', 'testimonial', 'cta', 'footer', 'grid', 'section', 'container', 'button', 'appBadge'],
1283
+ description: 'Marketing or product landing page with hero, features, and calls to action',
1284
+ stateHint: 'null — purely server-rendered, no client state needed',
1285
+ serverHint: 'none — all content is static in the view',
1286
+ scaffold: `import { nav, hero, feature, stat, cta, footer, grid, section, container, button, iconZap, iconShield, iconCheck } from '@invisibleloop/pulse/ui'
1287
+
1288
+ export default {
1289
+ route: '/',
1290
+ meta: {
1291
+ title: 'Product — Tagline',
1292
+ description: 'One clear sentence describing the product.',
1293
+ styles: ['/pulse-ui.css'],
1294
+ },
1295
+ view: () => \`
1296
+ <main id="main-content">
1297
+ \${nav({ logo: 'Product', links: [
1298
+ { label: 'Features', href: '#features' },
1299
+ { label: 'Pricing', href: '#pricing' },
1300
+ ], actions: button({ label: 'Get started', size: 'sm' }) })}
1301
+ \${hero({ title: 'Your headline here', subtitle: 'Supporting copy that explains the value.', gradient: 'purple',
1302
+ actions: button({ label: 'Get started', size: 'lg' }) })}
1303
+ \${section({ id: 'features', content: container({ content: \`
1304
+ \${grid({ cols: 3, content:
1305
+ feature({ icon: iconZap(), title: 'Fast', body: 'Describe the benefit.' }) +
1306
+ feature({ icon: iconShield(), title: 'Secure', body: 'Describe the benefit.' }) +
1307
+ feature({ icon: iconCheck(), title: 'Reliable', body: 'Describe the benefit.' })
1308
+ })}
1309
+ \` }) })}
1310
+ \${cta({ title: 'Ready to start?', body: 'Sign up in seconds.', actions: button({ label: 'Get started' }) })}
1311
+ \${footer({ logo: 'Product', links: [] })}
1312
+ </main>
1313
+ \`,
1314
+ }`,
1315
+ guides: ['pulse://guide/components', 'pulse://guide/templates'],
1316
+ },
1317
+
1318
+ crud: {
1319
+ keywords: ['list', 'crud', 'table', 'records', 'manage', 'items', 'inventory', 'data', 'collection', 'browse', 'search'],
1320
+ components: ['nav', 'table', 'button', 'modal', 'input', 'select', 'pagination', 'alert', 'empty', 'badge', 'search', 'spinner'],
1321
+ description: 'Browsable list of records with search, filter, and row actions',
1322
+ stateHint: "{ query: '', page: 1, status: 'idle' }",
1323
+ serverHint: "{ items: async (ctx) => fetchItems(ctx.query), total: async (ctx) => countItems() }",
1324
+ scaffold: `import { nav, table, button, input, alert, empty, badge, section, container, spinner } from '@invisibleloop/pulse/ui'
1325
+
1326
+ export default {
1327
+ route: '/items',
1328
+ meta: {
1329
+ title: 'Items',
1330
+ styles: ['/pulse-ui.css'],
1331
+ },
1332
+ server: {
1333
+ items: async (ctx) => [],
1334
+ total: async (ctx) => 0,
1335
+ },
1336
+ state: { query: '', status: 'idle', deleteId: null },
1337
+ mutations: {
1338
+ setQuery: (state, e) => ({ query: e.target.value }),
1339
+ },
1340
+ actions: {
1341
+ deleteItem: {
1342
+ onStart: (state, formData) => ({ status: 'loading', deleteId: formData.get('id') }),
1343
+ run: async (state, serverState, formData) => {
1344
+ const res = await fetch(\`/api/items/\${formData.get('id')}\`, { method: 'DELETE' })
1345
+ if (!res.ok) { let m = \`Error: \${res.status}\`; try { const j = await res.json(); m = j.message || m } catch {} throw new Error(m) }
1346
+ },
1347
+ onSuccess: (state) => ({ status: 'success', deleteId: null, _toast: { message: 'Deleted', variant: 'success' } }),
1348
+ onError: (state, err) => ({ status: 'error', deleteId: null, _toast: { message: err.message, variant: 'error' } }),
1349
+ },
1350
+ },
1351
+ view: (state, server) => \`
1352
+ <main id="main-content">
1353
+ \${nav({ logo: 'Items', links: [] })}
1354
+ \${section({ content: container({ content: \`
1355
+ <div class="u-flex u-gap-3 u-mb-4">
1356
+ \${input({ label: 'Search', name: 'query', placeholder: 'Search…', value: state.query })}
1357
+ </div>
1358
+ \${server.items.length
1359
+ ? table({ head: ['Name', 'Status', ''], rows: server.items.map(item => [
1360
+ item.name,
1361
+ badge({ label: item.status }),
1362
+ \`<form data-action="deleteItem">\${button({ label: 'Delete', variant: 'danger', size: 'sm', type: 'submit' })}<input type="hidden" name="id" value="\${item.id}"></form>\`
1363
+ ])})
1364
+ : empty({ message: 'No items found' })
1365
+ }
1366
+ \` }) })}
1367
+ </main>
1368
+ \`,
1369
+ }`,
1370
+ guides: ['pulse://guide/spec', 'pulse://guide/components'],
1371
+ },
1372
+
1373
+ form: {
1374
+ keywords: ['form', 'submit', 'contact', 'register', 'signup', 'onboarding', 'wizard', 'checkout', 'enquiry', 'apply'],
1375
+ components: ['card', 'input', 'select', 'textarea', 'checkbox', 'radio', 'button', 'alert', 'fieldset', 'stepper', 'section', 'container'],
1376
+ description: 'Form page with validation, submission, and success/error states',
1377
+ stateHint: "{ status: 'idle', errors: [] }",
1378
+ serverHint: 'none — forms are typically client-only with a server action endpoint',
1379
+ scaffold: `import { card, input, textarea, button, alert, section, container, heading } from '@invisibleloop/pulse/ui'
1380
+
1381
+ export default {
1382
+ route: '/contact',
1383
+ meta: {
1384
+ title: 'Contact',
1385
+ styles: ['/pulse-ui.css'],
1386
+ },
1387
+ state: { status: 'idle', errors: [] },
1388
+ validation: {
1389
+ 'fields.email': { required: true, format: 'email' },
1390
+ 'fields.name': { required: true, minLength: 2 },
1391
+ },
1392
+ actions: {
1393
+ submit: {
1394
+ onStart: (state, formData) => ({ status: 'loading', errors: [], fields: { name: formData.get('name'), email: formData.get('email') } }),
1395
+ validate: true,
1396
+ run: async (state, serverState, formData) => {
1397
+ const res = await fetch('/api/contact', { method: 'POST', body: formData })
1398
+ if (!res.ok) { let m = \`Error: \${res.status}\`; try { const j = await res.json(); m = j.message || m } catch {} throw new Error(m) }
1399
+ return await res.json()
1400
+ },
1401
+ onSuccess: (state) => ({ status: 'success', _toast: { message: 'Message sent!', variant: 'success' } }),
1402
+ onError: (state, err) => ({ status: 'error', errors: err?.validation ?? [{ message: err.message }], _toast: { message: 'Please check the form', variant: 'error' } }),
1403
+ },
1404
+ },
1405
+ view: (state) => \`
1406
+ <main id="main-content">
1407
+ \${section({ content: container({ size: 'sm', content:
1408
+ state.status === 'success'
1409
+ ? \`<p class="u-text-center u-py-8">Thanks! We'll be in touch.</p>\`
1410
+ : card({ title: 'Get in touch', content: \`
1411
+ \${state.errors.length ? alert({ variant: 'error', message: state.errors.map(e => e.message).join(', ') }) : ''}
1412
+ <form data-action="submit" novalidate>
1413
+ \${input({ label: 'Your name', name: 'name', required: true, error: state.errors.find(e => e.field === 'name')?.message })}
1414
+ \${input({ label: 'Email', name: 'email', type: 'email', required: true, error: state.errors.find(e => e.field === 'email')?.message })}
1415
+ \${textarea({ label: 'Message', name: 'message' })}
1416
+ \${button({ label: state.status === 'loading' ? 'Sending\u2026' : 'Send message', type: 'submit', attrs: state.status === 'loading' ? { 'aria-busy': 'true', disabled: '' } : {} })}
1417
+ </form>
1418
+ \` })
1419
+ }) })}
1420
+ </main>
1421
+ \`,
1422
+ }`,
1423
+ guides: ['pulse://guide/spec', 'pulse://guide/components'],
1424
+ },
1425
+
1426
+ settings: {
1427
+ keywords: ['settings', 'preferences', 'profile', 'account', 'configuration', 'options', 'edit profile', 'personal details'],
1428
+ components: ['nav', 'card', 'input', 'select', 'switch', 'button', 'alert', 'avatar', 'section', 'container', 'fieldset'],
1429
+ description: 'Settings or preferences page with multiple form sections and save actions',
1430
+ stateHint: "{ status: 'idle', saved: false }",
1431
+ serverHint: "{ user: async (ctx) => getUser(ctx), settings: async (ctx) => getSettings(ctx) }",
1432
+ scaffold: `import { nav, card, input, select, button, alert, section, container, avatar } from '@invisibleloop/pulse/ui'
1433
+
1434
+ export default {
1435
+ route: '/settings',
1436
+ meta: {
1437
+ title: 'Settings',
1438
+ styles: ['/pulse-ui.css'],
1439
+ },
1440
+ server: {
1441
+ user: async (ctx) => null,
1442
+ },
1443
+ state: { status: 'idle' },
1444
+ actions: {
1445
+ save: {
1446
+ onStart: (state) => ({ status: 'loading' }),
1447
+ run: async (state, serverState, formData) => {
1448
+ const res = await fetch('/api/settings', { method: 'POST', body: formData })
1449
+ if (!res.ok) { let m = \`Error: \${res.status}\`; try { const j = await res.json(); m = j.message || m } catch {} throw new Error(m) }
1450
+ return await res.json()
1451
+ },
1452
+ onSuccess: (state) => ({ status: 'success', _toast: { message: 'Settings saved', variant: 'success' } }),
1453
+ onError: (state, err) => ({ status: 'error', _toast: { message: err.message, variant: 'error' } }),
1454
+ },
1455
+ },
1456
+ view: (state, server) => \`
1457
+ <main id="main-content">
1458
+ \${nav({ logo: 'App', links: [] })}
1459
+ \${section({ content: container({ size: 'sm', content: \`
1460
+ <form data-action="save">
1461
+ \${card({ title: 'Profile', content: \`
1462
+ \${input({ label: 'Display name', name: 'name', value: server.user?.name ?? '' })}
1463
+ \${input({ label: 'Email', name: 'email', type: 'email', value: server.user?.email ?? '' })}
1464
+ \` })}
1465
+ <div class="u-mt-4 u-flex u-justify-end">
1466
+ \${button({ label: state.status === 'loading' ? 'Saving\u2026' : 'Save changes', type: 'submit', attrs: state.status === 'loading' ? { 'aria-busy': 'true', disabled: '' } : {} })}
1467
+ </div>
1468
+ </form>
1469
+ \` }) })}
1470
+ </main>
1471
+ \`,
1472
+ }`,
1473
+ guides: ['pulse://guide/server', 'pulse://guide/spec', 'pulse://guide/components'],
1474
+ },
1475
+
1476
+ blog: {
1477
+ keywords: ['blog', 'article', 'post', 'content', 'editorial', 'news', 'publication', 'markdown', 'text', 'read', 'writing'],
1478
+ components: ['nav', 'hero', 'prose', 'section', 'container', 'footer', 'badge'],
1479
+ description: 'Content page rendering markdown or rich text from a file or CMS',
1480
+ stateHint: 'null — purely server-rendered, no client state needed',
1481
+ serverHint: "{ post: md('content/blog/:slug.md') } — use @invisibleloop/pulse/md",
1482
+ scaffold: `import { nav, hero, prose, section, container, footer } from '@invisibleloop/pulse/ui'
1483
+ import { md } from '@invisibleloop/pulse/md'
1484
+
1485
+ const post = md('content/blog/:slug.md')
1486
+
1487
+ export default {
1488
+ route: '/blog/:slug',
1489
+ meta: {
1490
+ title: async (ctx) => (await post(ctx)).frontmatter.title,
1491
+ description: async (ctx) => (await post(ctx)).frontmatter.description,
1492
+ styles: ['/pulse-ui.css'],
1493
+ },
1494
+ server: { post },
1495
+ view: (state, server) => \`
1496
+ <main id="main-content">
1497
+ \${nav({ logo: 'Blog', links: [{ label: 'Home', href: '/' }] })}
1498
+ \${hero({ size: 'sm', title: server.post.frontmatter.title, subtitle: server.post.frontmatter.description })}
1499
+ \${section({ content: container({ size: 'sm', content: prose({ content: server.post.html }) }) })}
1500
+ \${footer({ logo: 'Blog', links: [] })}
1501
+ </main>
1502
+ \`,
1503
+ onViewError: (err) => \`<main id="main-content"><p class="u-p-4">Post not found.</p></main>\`,
1504
+ }`,
1505
+ guides: ['pulse://guide/spec', 'pulse://guide/components'],
1506
+ },
1507
+
1508
+ auth: {
1509
+ keywords: ['login', 'signin', 'sign in', 'signup', 'sign up', 'register', 'auth', 'password', 'forgot', 'reset', 'verify', 'otp'],
1510
+ components: ['card', 'input', 'button', 'alert', 'section', 'container', 'heading'],
1511
+ description: 'Authentication flow — login, signup, or password reset',
1512
+ stateHint: "{ status: 'idle', errors: [] }",
1513
+ serverHint: 'none for login; guard for protected pages',
1514
+ scaffold: `import { card, input, button, alert, section, container } from '@invisibleloop/pulse/ui'
1515
+
1516
+ export default {
1517
+ route: '/login',
1518
+ meta: {
1519
+ title: 'Sign in',
1520
+ styles: ['/pulse-ui.css'],
1521
+ },
1522
+ state: { status: 'idle', errors: [] },
1523
+ validation: {
1524
+ 'fields.email': { required: true, format: 'email' },
1525
+ 'fields.password': { required: true, minLength: 8 },
1526
+ },
1527
+ actions: {
1528
+ login: {
1529
+ onStart: (state) => ({ status: 'loading', errors: [] }),
1530
+ validate: true,
1531
+ run: async (state, serverState, formData) => {
1532
+ const res = await fetch('/api/auth/login', { method: 'POST', body: formData })
1533
+ if (!res.ok) { let m = \`Error: \${res.status}\`; try { const j = await res.json(); m = j.message || m } catch {} throw new Error(m) }
1534
+ return await res.json()
1535
+ },
1536
+ onSuccess: (state) => ({ status: 'success', _redirect: '/dashboard' }),
1537
+ onError: (state, err) => ({ status: 'error', errors: err?.validation ?? [{ message: err.message }] }),
1538
+ },
1539
+ },
1540
+ view: (state) => \`
1541
+ <main id="main-content">
1542
+ \${section({ content: container({ size: 'xs', content:
1543
+ card({ title: 'Sign in', content: \`
1544
+ \${state.errors.length ? alert({ variant: 'error', message: state.errors.map(e => e.message).join(', ') }) : ''}
1545
+ <form data-action="login" novalidate>
1546
+ \${input({ label: 'Email', name: 'email', type: 'email', required: true })}
1547
+ \${input({ label: 'Password', name: 'password', type: 'password', required: true })}
1548
+ \${button({ label: state.status === 'loading' ? 'Signing in\u2026' : 'Sign in', type: 'submit', fullWidth: true, attrs: state.status === 'loading' ? { 'aria-busy': 'true', disabled: '' } : {} })}
1549
+ </form>
1550
+ <p class="u-text-sm u-text-center u-mt-3"><a href="/forgot">Forgot password?</a></p>
1551
+ \` })
1552
+ }) })}
1553
+ </main>
1554
+ \`,
1555
+ }`,
1556
+ guides: ['pulse://guide/spec', 'pulse://guide/server', 'pulse://guide/components'],
1557
+ },
1558
+
1559
+ profile: {
1560
+ keywords: ['profile', 'user page', 'bio', 'portfolio', 'about', 'member', 'author', 'personal page', 'public profile'],
1561
+ components: ['nav', 'avatar', 'card', 'stat', 'badge', 'grid', 'section', 'container', 'button', 'timeline', 'prose'],
1562
+ description: 'User or entity profile page with metadata and activity',
1563
+ stateHint: 'null — purely server-rendered',
1564
+ serverHint: "{ user: async (ctx) => getUser(ctx.params.id), activity: async (ctx) => getActivity(ctx.params.id) }",
1565
+ scaffold: `import { nav, avatar, card, stat, badge, grid, section, container } from '@invisibleloop/pulse/ui'
1566
+
1567
+ export default {
1568
+ route: '/users/:id',
1569
+ meta: {
1570
+ title: async (ctx) => \`User \${ctx.params.id}\`,
1571
+ styles: ['/pulse-ui.css'],
1572
+ },
1573
+ server: {
1574
+ user: async (ctx) => null,
1575
+ activity: async (ctx) => [],
1576
+ },
1577
+ view: (state, server) => \`
1578
+ <main id="main-content">
1579
+ \${nav({ logo: 'App', links: [] })}
1580
+ \${section({ content: container({ content: \`
1581
+ \${card({ content: \`
1582
+ <div class="u-flex u-gap-4 u-items-center">
1583
+ \${avatar({ name: server.user?.name ?? 'Unknown', size: 'lg' })}
1584
+ <div>
1585
+ <h1 class="u-text-2xl u-font-bold">\${server.user?.name ?? 'Unknown'}</h1>
1586
+ <p class="u-text-muted">\${server.user?.bio ?? ''}</p>
1587
+ </div>
1588
+ </div>
1589
+ \` })}
1590
+ \${grid({ cols: 3, content:
1591
+ stat({ label: 'Posts', value: String(server.user?.posts ?? 0) }) +
1592
+ stat({ label: 'Followers', value: String(server.user?.followers ?? 0) }) +
1593
+ stat({ label: 'Following', value: String(server.user?.following ?? 0) })
1594
+ })}
1595
+ \` }) })}
1596
+ </main>
1597
+ \`,
1598
+ onViewError: () => \`<main id="main-content"><p class="u-p-4">User not found.</p></main>\`,
1599
+ }`,
1600
+ guides: ['pulse://guide/components', 'pulse://guide/spec', 'pulse://guide/server'],
1601
+ },
1602
+
1603
+ pricing: {
1604
+ keywords: ['pricing', 'plans', 'subscription', 'tiers', 'billing', 'upgrade', 'compare', 'packages'],
1605
+ components: ['nav', 'pricing', 'feature', 'cta', 'accordion', 'footer', 'section', 'grid', 'button', 'badge'],
1606
+ description: 'Pricing page with plan comparison, feature lists, and upgrade CTA',
1607
+ stateHint: "{ billing: 'monthly' } — for toggle between monthly/annual",
1608
+ serverHint: 'none — content is typically static',
1609
+ scaffold: `import { nav, pricing, cta, accordion, footer, section, container, button } from '@invisibleloop/pulse/ui'
1610
+
1611
+ export default {
1612
+ route: '/pricing',
1613
+ meta: {
1614
+ title: 'Pricing',
1615
+ styles: ['/pulse-ui.css'],
1616
+ },
1617
+ state: { billing: 'monthly' },
1618
+ mutations: {
1619
+ setBilling: (state, e) => ({ billing: e.target.value }),
1620
+ },
1621
+ view: (state) => \`
1622
+ <main id="main-content">
1623
+ \${nav({ logo: 'App', links: [] })}
1624
+ \${section({ content: container({ content: \`
1625
+ <h1 class="u-text-4xl u-font-bold u-text-center u-mb-2">Simple pricing</h1>
1626
+ <p class="u-text-center u-text-muted u-mb-8">No hidden fees. Cancel any time.</p>
1627
+ \${pricing({
1628
+ plans: [
1629
+ { name: 'Free', price: '\$0', period: 'forever', features: ['Feature A', 'Feature B'], cta: button({ label: 'Get started', variant: 'secondary' }) },
1630
+ { name: 'Pro', price: '\$12', period: '/month', features: ['Everything in Free', 'Feature C', 'Feature D'], featured: true, cta: button({ label: 'Start free trial' }) },
1631
+ { name: 'Team', price: '\$49', period: '/month', features: ['Everything in Pro', 'Feature E', 'Unlimited seats'], cta: button({ label: 'Contact sales', variant: 'secondary' }) },
1632
+ ]
1633
+ })}
1634
+ \${accordion({ items: [
1635
+ { title: 'Can I cancel any time?', content: 'Yes — no lock-in, cancel from your account settings.' },
1636
+ { title: 'What payment methods do you accept?', content: 'Visa, Mastercard, Amex, and PayPal.' },
1637
+ ]})}
1638
+ \` }) })}
1639
+ \${cta({ title: 'Still have questions?', body: 'Talk to the team.', actions: button({ label: 'Contact us', variant: 'secondary' }) })}
1640
+ \${footer({ logo: 'App', links: [] })}
1641
+ </main>
1642
+ \`,
1643
+ }`,
1644
+ guides: ['pulse://guide/components', 'pulse://guide/templates'],
1645
+ },
1646
+ }
1647
+
773
1648
  server.registerTool(
774
- 'pulse_update',
1649
+ 'pulse_intent',
775
1650
  {
776
- description: 'Install the latest @invisibleloop/pulse package, then re-copy pulse-ui.css, pulse-ui.js, and the agent checklist into public/. One command does the full upgrade.',
777
- inputSchema: {},
1651
+ description: `Intent engine — describe what you want to build and get back a matched archetype, recommended components, a ready-to-adapt spec scaffold, and which guide sections to read first.
1652
+
1653
+ Use this at the start of any build task instead of (or before) fetching pulse://workflow. It short-circuits the "what components should I use?" and "what does the state shape look like?" questions by detecting your intent and giving you a tailored starting point.
1654
+
1655
+ Examples:
1656
+ "a dark analytics dashboard with live stats and a data table"
1657
+ "a contact form with email validation and a success screen"
1658
+ "a pricing page for a SaaS product with three tiers"
1659
+ "a blog post page using markdown with a newsletter signup"`,
1660
+ inputSchema: {
1661
+ description: z.string().describe('Plain-language description of the page or feature you want to build'),
1662
+ },
778
1663
  },
779
- async () => {
780
- // 1. npm install latest
781
- const { execSync } = await import('child_process')
782
- try {
783
- execSync('npm install @invisibleloop/pulse@latest', { cwd: ROOT, stdio: 'pipe' })
784
- } catch (e) {
785
- return text(`npm install failed:\n${e.stderr?.toString() || e.message}`)
1664
+ ({ description }) => {
1665
+ const desc = description.toLowerCase()
1666
+
1667
+ // Score each archetype by how many keywords appear in the description
1668
+ const scored = Object.entries(ARCHETYPES).map(([key, arch]) => {
1669
+ const matches = arch.keywords.filter(kw => desc.includes(kw))
1670
+ return { key, arch, score: matches.length, matches }
1671
+ }).filter(({ score }) => score > 0)
1672
+ .sort((a, b) => b.score - a.score)
1673
+
1674
+ if (scored.length === 0) {
1675
+ // No archetype matched — return generic starting advice
1676
+ return text(`No archetype match found for: "${description}"
1677
+
1678
+ Try describing what the page *does* rather than how it looks. Examples:
1679
+ "a list of users I can search and delete"
1680
+ "a login form with email and password"
1681
+ "a dashboard with charts and a recent activity table"
1682
+ "a landing page for a mobile app"
1683
+
1684
+ If your page genuinely doesn't fit a pattern, start from pulse://guide/spec and build from scratch.`)
786
1685
  }
787
1686
 
788
- // 2. Copy assets from the newly installed package
789
- const pkgPublic = new URL('../../public', import.meta.url).pathname
790
- const publicDir = path.join(ROOT, 'public')
791
- const assets = ['pulse-ui.css', 'pulse-ui.js', '.pulse-ui-version']
792
- const updated = []
1687
+ const best = scored[0]
1688
+ const { key, arch, matches } = best
793
1689
 
794
- fs.mkdirSync(publicDir, { recursive: true })
795
- for (const asset of assets) {
796
- const src = path.join(pkgPublic, asset)
797
- const dst = path.join(publicDir, asset)
798
- if (fs.existsSync(src)) { fs.copyFileSync(src, dst); updated.push(`public/${asset}`) }
1690
+ // Build the response
1691
+ const lines = []
1692
+
1693
+ lines.push(`## Detected intent: ${key}`)
1694
+ lines.push(``)
1695
+ lines.push(`${arch.description}`)
1696
+ lines.push(`Matched on: ${matches.join(', ')}`)
1697
+
1698
+ if (scored.length > 1) {
1699
+ const alts = scored.slice(1, 3).map(s => `${s.key} (${s.matches.join(', ')})`).join(' · ')
1700
+ lines.push(`Alternative archetypes: ${alts}`)
799
1701
  }
800
1702
 
801
- const checklistSrc = new URL('../agent/checklist.md', import.meta.url).pathname
802
- const checklistDst = path.join(ROOT, '.claude', 'pulse-checklist.md')
803
- if (fs.existsSync(checklistSrc)) {
804
- fs.mkdirSync(path.dirname(checklistDst), { recursive: true })
805
- fs.copyFileSync(checklistSrc, checklistDst)
806
- updated.push('.claude/pulse-checklist.md')
1703
+ lines.push(``)
1704
+ lines.push(`## Recommended components`)
1705
+ lines.push(``)
1706
+ lines.push(`\`\`\`js`)
1707
+ lines.push(`import { ${arch.components.join(', ')} } from '@invisibleloop/pulse/ui'`)
1708
+ lines.push(`\`\`\``)
1709
+
1710
+ lines.push(``)
1711
+ lines.push(`## State & server hints`)
1712
+ lines.push(``)
1713
+ lines.push(`State: ${arch.stateHint}`)
1714
+ lines.push(`Server: ${arch.serverHint}`)
1715
+
1716
+ lines.push(``)
1717
+ lines.push(`## Guide sections to read`)
1718
+ lines.push(``)
1719
+ for (const g of arch.guides) {
1720
+ lines.push(` ${g}`)
807
1721
  }
808
1722
 
809
- const versionFile = path.join(publicDir, '.pulse-ui-version')
810
- const version = fs.existsSync(versionFile) ? fs.readFileSync(versionFile, 'utf8').trim() : '?'
811
- return text(`Pulse updated to v${version}\n\n${updated.map(f => `✓ ${f}`).join('\n')}`)
1723
+ lines.push(``)
1724
+ lines.push(`## Starter spec scaffold`)
1725
+ lines.push(``)
1726
+ lines.push(`Adapt this — it is a complete but minimal working page. Replace placeholder content, add your real data fetchers, and adjust state as needed.`)
1727
+ lines.push(``)
1728
+ lines.push(`\`\`\`js`)
1729
+ lines.push(arch.scaffold)
1730
+ lines.push(`\`\`\``)
1731
+
1732
+ lines.push(``)
1733
+ lines.push(`## Next steps`)
1734
+ lines.push(``)
1735
+ lines.push(`1. Fetch \`pulse://workflow\` to understand the build phases`)
1736
+ for (const g of arch.guides) {
1737
+ lines.push(`2. Fetch \`${g}\` for the full component and spec reference`)
1738
+ }
1739
+ lines.push(`3. Call \`pulse_list_structure\` to see what already exists`)
1740
+ lines.push(`4. Adapt the scaffold above and write it to \`src/pages/your-name.js\``)
1741
+ lines.push(`5. Call \`pulse_suggest\` on your draft at any point for early feedback`)
1742
+
1743
+ return text(lines.join('\n'))
812
1744
  }
813
1745
  )
814
1746
 
815
1747
  // ---------------------------------------------------------------------------
816
- // Helpers
1748
+ // pulse_layout_review — Multi-viewport layout check
817
1749
  // ---------------------------------------------------------------------------
818
1750
 
819
- /** Poll until the HTTP server on the given port accepts a connection, or timeout. */
820
- async function waitForServer(port, maxMs = 10_000) {
821
- const deadline = Date.now() + maxMs
822
- while (Date.now() < deadline) {
823
- const alive = await new Promise(resolve => {
824
- const req = http.get(`http://localhost:${port}/`, { timeout: 500 }, res => {
825
- res.resume()
826
- resolve(true)
827
- })
828
- req.on('error', () => resolve(false))
829
- req.on('timeout', () => { req.destroy(); resolve(false) })
830
- })
831
- if (alive) return true
832
- await new Promise(r => setTimeout(r, 300))
1751
+ server.registerTool(
1752
+ 'pulse_layout_review',
1753
+ {
1754
+ description: `Run a multi-viewport layout review on the current page. Returns a structured set of browser steps to execute using chrome-devtools tools.
1755
+
1756
+ Use this AFTER design approval (user has seen the screenshot and signed off on the layout) but BEFORE Lighthouse. It catches layout issues (overflow, collapsed sections, broken images) that Lighthouse doesn't check and that are invisible at a single viewport width.
1757
+
1758
+ Call this as part of the Phase 5b verification flow (after design approval):
1759
+ design approval → pulse_design_review → pulse_layout_review → Lighthouse`,
1760
+ inputSchema: {
1761
+ url: z.string().describe('The full URL to check, e.g. http://localhost:3001/about'),
1762
+ },
1763
+ },
1764
+ async ({ url }) => {
1765
+ const steps = `
1766
+ ## pulse_layout_review — run these steps in sequence
1767
+
1768
+ **URL:** ${url}
1769
+
1770
+ Execute the following in order using chrome-devtools tools:
1771
+
1772
+ ### 1. Mobile (390 × 844)
1773
+ \`\`\`
1774
+ chrome-devtools-resize_page: { width: 390, height: 844 }
1775
+ chrome-devtools-navigate_page: { type: "url", url: "${url}" }
1776
+ chrome-devtools-take_screenshot
1777
+ \`\`\`
1778
+ Then run this scan:
1779
+ \`\`\`js
1780
+ chrome-devtools-evaluate_script: () => {
1781
+ const issues = []
1782
+ if (document.documentElement.scrollWidth > document.documentElement.clientWidth) {
1783
+ issues.push('OVERFLOW: page has horizontal overflow at 390px — some element is wider than the viewport')
833
1784
  }
834
- return false
1785
+ document.querySelectorAll('img').forEach((img, i) => {
1786
+ if (img.complete && img.naturalWidth === 0) {
1787
+ issues.push('BROKEN IMAGE: img[' + i + '] src="' + img.src + '" failed to load (naturalWidth === 0)')
1788
+ }
1789
+ })
1790
+ document.querySelectorAll('section, main, header, footer, [class*="hero"], [class*="feature"], [class*="grid"]').forEach(el => {
1791
+ if (el.offsetHeight === 0) {
1792
+ issues.push('COLLAPSED: <' + el.tagName.toLowerCase() + (el.className ? ' class="' + el.className + '"' : '') + '> has zero height — check visibility, display, or missing content')
1793
+ }
1794
+ })
1795
+ return issues.length ? issues : ['No layout issues found at 390px']
835
1796
  }
1797
+ \`\`\`
836
1798
 
837
- async function validateContent(content) {
838
- // Write into PAGES_DIR so relative imports (e.g. '../components/nav.js') resolve correctly
839
- fs.mkdirSync(PAGES_DIR, { recursive: true })
840
- const tmpFile = path.join(PAGES_DIR, `.pulse-validate-${Date.now()}.mjs`)
841
- try {
842
- fs.writeFileSync(tmpFile, content, 'utf8')
1799
+ ### 2. Tablet (768 × 1024)
1800
+ \`\`\`
1801
+ chrome-devtools-resize_page: { width: 768, height: 1024 }
1802
+ chrome-devtools-navigate_page: { type: "url", url: "${url}" }
1803
+ chrome-devtools-take_screenshot
1804
+ \`\`\`
1805
+ Run the same JS scan. Report any issues found.
843
1806
 
844
- // Run validation in a child process with a hard timeout so a hanging import
845
- // (slow module, circular dep, network call) cannot block the MCP server.
846
- const validatorScript = new URL('./validate-worker.js', import.meta.url).pathname
847
- let output
848
- try {
849
- output = execFileSync(process.execPath, [validatorScript, tmpFile], {
850
- timeout: 10_000,
851
- encoding: 'utf8',
852
- })
1807
+ ### 3. Desktop (1280 × 800)
1808
+ \`\`\`
1809
+ chrome-devtools-resize_page: { width: 1280, height: 800 }
1810
+ chrome-devtools-navigate_page: { type: "url", url: "${url}" }
1811
+ chrome-devtools-take_screenshot
1812
+ \`\`\`
1813
+ Run the same JS scan. Report any issues found.
1814
+
1815
+ ### 4. Restore desktop size
1816
+ \`\`\`
1817
+ chrome-devtools-resize_page: { width: 1280, height: 800 }
1818
+ \`\`\`
1819
+
1820
+ ### 5. Report
1821
+
1822
+ Fill in this table before proceeding to Lighthouse:
1823
+
1824
+ | Viewport | Overflow | Broken images | Collapsed sections | Screenshot |
1825
+ |---|---|---|---|---|
1826
+ | 390px mobile | ✅ / ❌ | ✅ / ❌ | ✅ / ❌ | [describe] |
1827
+ | 768px tablet | ✅ / ❌ | ✅ / ❌ | ✅ / ❌ | [describe] |
1828
+ | 1280px desktop | ✅ / ❌ | ✅ / ❌ | ✅ / ❌ | [describe] |
1829
+
1830
+ **If any cell is ❌**, fix the issue and re-run pulse_layout_review before proceeding to Lighthouse.
1831
+ `.trim()
1832
+
1833
+ return text(steps)
1834
+ }
1835
+ )
1836
+
1837
+ // ---------------------------------------------------------------------------
1838
+ // pulse_design_review — Visual design review against the original brief
1839
+ // ---------------------------------------------------------------------------
1840
+
1841
+
1842
+ server.registerTool(
1843
+ 'pulse_design_review',
1844
+ {
1845
+ description: `Review the built page's visual design against the original product brief from pulse_intake.
1846
+
1847
+ Use this AFTER taking a screenshot but BEFORE the code review gate. It checks whether the design looks and feels appropriate for the stated product, audience, and vibe — catching mismatches like "builders merchant site that looks like a magazine" or "children's app that feels corporate".
1848
+
1849
+ Returns a structured design critique the agent must address.`,
1850
+ inputSchema: {
1851
+ route: z.string().optional().describe('Route to screenshot, e.g. "/" or "/about". Defaults to "/".'),
1852
+ screenshot: z.string().optional().describe('Description of what you observed in the screenshot (optional — helps focus the review).'),
1853
+ },
1854
+ },
1855
+ ({ route = '/', screenshot }) => {
1856
+ const briefFile = path.join(ROOT, '.pulse', 'brief.json')
1857
+ const hasBrief = fs.existsSync(briefFile)
1858
+ const brief = hasBrief ? JSON.parse(fs.readFileSync(briefFile, 'utf8')) : null
1859
+
1860
+ const lines = []
1861
+ lines.push('# Design review — brief vs reality\n')
1862
+
1863
+ if (!brief) {
1864
+ lines.push('> **No brief found** — `.pulse/brief.json` does not exist. Either `pulse_intake` was not run for this project, or the brief was not saved.')
1865
+ lines.push('> You can still do a qualitative design review — describe the product type and target user to assess below.\n')
1866
+ } else {
1867
+ lines.push('## The original brief\n')
1868
+ lines.push(`| Field | Value |`)
1869
+ lines.push(`|---|---|`)
1870
+ lines.push(`| Product | **${brief.name}** — ${brief.pitch} |`)
1871
+ if (brief.targetUser) lines.push(`| Target user | ${brief.targetUser} |`)
1872
+ if (brief.vibe) lines.push(`| Vibe | ${brief.vibe} |`)
1873
+ if (brief.theme) lines.push(`| Theme | ${brief.theme} |`)
1874
+ if (brief.antiStyle) lines.push(`| Anti-style | ${brief.antiStyle} |`)
1875
+ if (brief.styleNotes) lines.push(`| Style notes | ${brief.styleNotes} |`)
1876
+ if (brief.features?.length) lines.push(`| Features | ${brief.features.join(', ')} |`)
1877
+ lines.push('')
1878
+ }
1879
+
1880
+ if (screenshot) {
1881
+ lines.push('## Your screenshot observation\n')
1882
+ lines.push(`> ${screenshot}\n`)
1883
+ }
1884
+
1885
+ lines.push('## Your task\n')
1886
+ lines.push('You are now a **design reviewer with no knowledge of the code**. Look only at the screenshot.')
1887
+ lines.push('Work through each signal below. Give an honest verdict for each — do not skip any.\n')
1888
+
1889
+ // Build product-type signals from brief if available
1890
+ const productType = brief ? `${brief.name} (${brief.pitch})` : 'this product'
1891
+ const audience = brief?.targetUser ?? 'the stated target user'
1892
+ const vibe = brief?.vibe ?? null
1893
+ const antiStyle = brief?.antiStyle ?? null
1894
+
1895
+ lines.push('### 1. First impression — audience fit')
1896
+ lines.push(`Show the screenshot to someone unfamiliar with the brief. Would they immediately guess this is **${productType}** aimed at **${audience}**?`)
1897
+ lines.push('- What three words does this design communicate at a glance?')
1898
+ lines.push(`- Do those words match what **${audience}** would expect and trust?\n`)
1899
+
1900
+ lines.push('### 2. Typography feel')
1901
+ lines.push('- **Serif vs sans**: Serifs read as editorial, literary, premium. Sans reads as functional, modern, utilitarian.')
1902
+ lines.push('- **Weight**: Heavy/condensed headings = bold, urgent, industrial. Light/spaced = refined, editorial.')
1903
+ lines.push('- **Scale**: Oversized display type = magazine/editorial. Modest headings with dense body = catalogue/functional.')
1904
+ if (vibe) lines.push(`- Expected for **${vibe}** vibe: ${vibeTypographyHint(vibe)}`)
1905
+ if (antiStyle) lines.push(`- Anti-style check: does it resemble "${antiStyle}"? If so, flag it.\n`)
1906
+ else lines.push('')
1907
+
1908
+ lines.push('### 3. Colour mood')
1909
+ lines.push('- **Warm neutrals + organic accent** = hospitality, artisan, food')
1910
+ lines.push('- **Industrial palette** (grey, orange, yellow, black) = trades, construction, hardware')
1911
+ lines.push('- **Navy/white/gold** = professional services, finance, prestige')
1912
+ lines.push('- **Bright primaries** = consumer, retail, energy')
1913
+ lines.push('- **Dark bg + neon accent** = tech, dev tools, gaming')
1914
+ lines.push(`- Does the palette match what **${audience}** would associate with this category?\n`)
1915
+
1916
+ lines.push('### 4. Layout density and hierarchy')
1917
+ lines.push('- **Sparse, generous whitespace, large imagery** → magazine, editorial, luxury')
1918
+ lines.push('- **Dense, product grid, clear categories** → e-commerce, catalogue, trades')
1919
+ lines.push('- **Dashboard grid, data-forward** → SaaS, B2B, professional tools')
1920
+ lines.push('- **Single focus per section, story-driven scroll** → startup landing, consumer app')
1921
+ lines.push(`- Which pattern does this layout use? Is that right for **${productType}**?\n`)
1922
+
1923
+ lines.push('### 5. Imagery and visual language')
1924
+ lines.push('- **Lifestyle/aspirational photography** → consumer, food, travel, luxury')
1925
+ lines.push('- **Product/catalogue photos** → e-commerce, hardware, trades')
1926
+ lines.push('- **Abstract/geometric** → tech, SaaS, fintech')
1927
+ lines.push('- **Illustration** → consumer apps, education, healthcare')
1928
+ lines.push('- **No imagery, type-only** → brutalist, editorial, developer tools')
1929
+ lines.push(`- Does the visual language feel appropriate for **${audience}**?\n`)
1930
+
1931
+ lines.push('### 6. CTA and copy tone')
1932
+ lines.push('- **"Get a quote", "Order now", "Find a branch"** → trades, construction, B2B')
1933
+ lines.push('- **"Get started", "Try free", "Sign up"** → SaaS, consumer app')
1934
+ lines.push('- **"Explore", "Discover", "See the collection"** → editorial, luxury, fashion')
1935
+ lines.push('- **"Book", "Reserve", "Check availability"** → hospitality, events, services')
1936
+ lines.push(`- Do the CTAs sound like something **${audience}** would respond to?\n`)
1937
+
1938
+ lines.push('### 7. Trust signals for this audience')
1939
+ lines.push(`What would make **${audience}** trust this site immediately? Check if those elements are present and prominent:`)
1940
+ if (audience.match(/trade|builder|contractor|professional|B2B/i)) {
1941
+ lines.push('- Trade account / account login visible?')
1942
+ lines.push('- Product categories or catalogue entry point prominent?')
1943
+ lines.push('- Delivery/collection info surfaced early?')
1944
+ lines.push('- No unnecessary lifestyle imagery pushing catalogue below fold?')
1945
+ } else if (audience.match(/consumer|shopper|customer|general/i)) {
1946
+ lines.push('- Social proof (reviews, ratings, testimonials) visible?')
1947
+ lines.push('- Clear pricing or "from £X" signals?')
1948
+ lines.push('- Easy navigation to product categories?')
1949
+ } else {
1950
+ lines.push('- Are the primary conversion actions immediately visible?')
1951
+ lines.push('- Does the above-fold content answer "what is this and why should I care"?')
1952
+ }
1953
+ lines.push('')
1954
+
1955
+ lines.push('## Verdict\n')
1956
+ lines.push('After working through every signal above, give a verdict:')
1957
+ lines.push('')
1958
+ lines.push('| Signal | Pass / Fail / Warn | Notes |')
1959
+ lines.push('|---|---|---|')
1960
+ lines.push('| Audience fit | | |')
1961
+ lines.push('| Typography feel | | |')
1962
+ lines.push('| Colour mood | | |')
1963
+ lines.push('| Layout density | | |')
1964
+ lines.push('| Imagery style | | |')
1965
+ lines.push('| CTA tone | | |')
1966
+ lines.push('| Trust signals | | |')
1967
+ lines.push('')
1968
+ lines.push('**If any signal is Fail:** stop. Describe what needs to change and why before proceeding to the code review.')
1969
+ lines.push('**If all Pass or Warn:** proceed to `pulse_review` for the code review gate.')
1970
+
1971
+ return text(lines.join('\n'))
1972
+ }
1973
+ )
1974
+
1975
+ function vibeTypographyHint(vibe) {
1976
+ const hints = {
1977
+ editorial: 'serif headings, tight tracking, high contrast text, editorial scale',
1978
+ bold: 'heavy condensed headings, impact weight, high contrast',
1979
+ brutalist: 'monospace or system font, raw weight, zero decoration',
1980
+ retro: 'slab serif or display font, warm tones, nostalgic feel',
1981
+ neon: 'monospace, futuristic, glowing accent on dark background',
1982
+ warm: 'rounded sans, generous spacing, soft palette',
1983
+ playful: 'rounded display font, large type, bright colours',
1984
+ minimal: 'light weight sans, generous whitespace, restrained scale',
1985
+ corporate: 'conservative sans, neutral palette, structured layout',
1986
+ paper: 'serif body, organic texture, journal-like rhythm',
1987
+ }
1988
+ return hints[vibe] ?? 'match the vibe description'
1989
+ }
1990
+
1991
+ // ---------------------------------------------------------------------------
1992
+ // pulse_suggest — Draft-mode contextual feedback
1993
+ // ---------------------------------------------------------------------------
1994
+
1995
+ server.registerTool(
1996
+ 'pulse_suggest',
1997
+ {
1998
+ description: `Draft-mode feedback — paste a partial or complete spec and get constructive suggestions before running the hard validator.
1999
+
2000
+ Unlike pulse_validate (which fails on errors), pulse_suggest is a collaborator: it notices patterns, spots likely omissions, and offers ideas. Use it mid-build when you want a second opinion, not a gate.
2001
+
2002
+ Returns observations grouped by category: completeness, data flow, components, state shape, accessibility hints, and quick wins.`,
2003
+ inputSchema: {
2004
+ content: z.string().describe('JavaScript spec content — can be partial or incomplete'),
2005
+ },
2006
+ },
2007
+ ({ content }) => {
2008
+ const suggestions = []
2009
+ const observations = []
2010
+ const quickWins = []
2011
+
2012
+ // --- Route & meta ---
2013
+ if (!content.includes('route:')) {
2014
+ suggestions.push('No route defined yet — add `route: \'/your-path\'` to register this page.')
2015
+ }
2016
+ if (!content.includes('meta:')) {
2017
+ quickWins.push('Add a `meta` block with `title` and `description` — needed for SEO and Lighthouse.')
2018
+ } else {
2019
+ if (!content.includes('title:')) quickWins.push('`meta.title` is missing — every page needs a unique title.')
2020
+ if (!content.includes('description:')) quickWins.push('`meta.description` is missing — used for SEO and social previews.')
2021
+ if (!content.includes('styles:')) quickWins.push('`meta.styles` not set — add `[\'/pulse-ui.css\']` if you are using any UI components.')
2022
+ }
2023
+
2024
+ // --- Export ---
2025
+ if (!content.includes('export default')) {
2026
+ suggestions.push('No `export default` found — specs must export default for hydration to work. Add `export default spec` or `export default { ... }` at the end.')
2027
+ }
2028
+
2029
+ // --- Server fetchers ---
2030
+ const hasServer = content.includes('server:')
2031
+ const hasStore = content.includes('store:')
2032
+ const viewRefs = (content.match(/server\.\w+/g) || []).map(r => r.slice(7))
2033
+ if (viewRefs.length > 0 && !hasServer && !hasStore) {
2034
+ suggestions.push(`View references server data (${viewRefs.slice(0, 3).join(', ')}) but no \`server\` block is defined. Add server fetchers or check if these should come from a store instead.`)
2035
+ }
2036
+ if (hasServer) {
2037
+ const declaredFetchers = [...content.matchAll(/^\s{4}(\w+)\s*:/gm)].map(m => m[1]).filter(f => f !== 'server' && f !== 'state' && f !== 'mutations' && f !== 'actions' && f !== 'meta' && f !== 'route' && f !== 'view' && f !== 'store' && f !== 'stream' && f !== 'persist' && f !== 'constraints' && f !== 'validation')
2038
+ const missingInView = viewRefs.filter(r => !content.includes(r + ':') && !declaredFetchers.includes(r))
2039
+ if (missingInView.length > 0) {
2040
+ suggestions.push(`View references \`server.${missingInView[0]}\` but no matching server fetcher found — check the spelling or add the fetcher.`)
2041
+ }
2042
+ }
2043
+
2044
+ // --- State ---
2045
+ const hasMutations = content.includes('mutations:')
2046
+ const hasActions = content.includes('actions:')
2047
+ const hasState = content.includes('state:')
2048
+ if ((hasMutations || hasActions) && !hasState) {
2049
+ suggestions.push('Spec has mutations/actions but no `state` — the runtime needs an initial state object. Add `state: { ... }` with your initial values.')
2050
+ }
2051
+ if (hasState && !hasMutations && !hasActions) {
2052
+ observations.push('State is defined but there are no mutations or actions — if this is purely server-rendered and never updated client-side, you can remove `state` for a lighter page (zero hydration JS).')
2053
+ }
2054
+
2055
+ // --- Forms & actions ---
2056
+ const hasForms = content.includes('data-action=')
2057
+ const hasActionsDef = content.includes('actions:')
2058
+ if (hasForms && !hasActionsDef) {
2059
+ suggestions.push('Found `data-action=` in the view but no `actions` block defined — the form submit will silently do nothing. Add an `actions` block with `onStart`, `run`, `onSuccess`, and `onError`.')
2060
+ }
2061
+ if (hasActionsDef && !content.includes('onError:')) {
2062
+ suggestions.push('Action found without `onError` — this will cause a runtime error on failure. Every action must define `onSuccess` AND `onError`.')
2063
+ }
2064
+ if (hasActionsDef && !content.includes('onSuccess:')) {
2065
+ suggestions.push('Action found without `onSuccess` — this is required. Add `onSuccess: (state, result) => ({ ... })`.')
2066
+ }
2067
+
2068
+ // --- Event binding on inputs ---
2069
+ if (content.includes('data-event=') && (content.match(/data-event="[^"]*"\s*(?:type="(?:text|email|password|search|tel|url|number)"|name=)/g) || []).length > 0) {
2070
+ suggestions.push('`data-event` on a text input causes re-render on every keystroke, destroying focus. Use uncontrolled inputs and read values from `FormData` in `action.onStart` instead.')
2071
+ }
2072
+
2073
+ // --- Validation ---
2074
+ if (hasActions && !content.includes('validate:') && (content.includes('required') || content.includes('format:'))) {
2075
+ quickWins.push('You have validation rules but no action sets `validate: true` — add `validate: true` to the action that submits the form to run validation before `run()`.')
2076
+ }
2077
+
2078
+ // --- View ---
2079
+ if (!content.includes('id="main-content"')) {
2080
+ quickWins.push('Add `<main id="main-content">` as the page landmark — needed for accessibility (skip link target) and Lighthouse.')
2081
+ }
2082
+
2083
+ // --- Components ---
2084
+ const hasRawButton = /<button(?!\s+[^>]*class="ui-)/.test(content)
2085
+ const hasRawInput = /<input(?!\s+[^>]*class="ui-)/.test(content) && !content.includes('type="hidden"')
2086
+ const hasRawTable = /<table(?!\s+[^>]*class="ui-)/.test(content)
2087
+ if (hasRawButton) quickWins.push('Raw `<button>` found — use `button({...})` from `@invisibleloop/pulse/ui` for consistent styling and accessibility.')
2088
+ if (hasRawInput) quickWins.push('Raw `<input>` found — use `input({...})` from `@invisibleloop/pulse/ui` for accessible labels and consistent styling.')
2089
+ if (hasRawTable) quickWins.push('Raw `<table>` found — use `table({ head, rows })` from `@invisibleloop/pulse/ui`.')
2090
+
2091
+ // --- Empty / error states ---
2092
+ if (hasServer && !content.includes('empty(') && !content.includes("'empty'") && (content.includes('.length') || content.includes('.map('))) {
2093
+ quickWins.push('Server data is rendered but no empty state found — add `empty({ message: \'...\' })` for when the list is empty.')
2094
+ }
2095
+ if (hasActions && !content.includes('alert(') && !content.includes("'error'")) {
2096
+ quickWins.push("Action defined but no error display in the view — add an `alert({ variant: 'error', message: '...' })` to show the user when something goes wrong.")
2097
+ }
2098
+
2099
+ // --- Loading state ---
2100
+ if (hasActions && !content.includes("'loading'") && !content.includes('aria-busy')) {
2101
+ quickWins.push("No loading state found — while an action runs, the submit button should show feedback (change label to 'Saving…' and add `aria-busy='true'` + `disabled`).")
2102
+ }
2103
+
2104
+ // --- onViewError ---
2105
+ if (hasServer && !content.includes('onViewError') && (content.includes('?.') === false) && content.includes('server.')) {
2106
+ quickWins.push('Consider adding `onViewError` — if a server fetcher returns unexpected data, the view can crash. A simple fallback prevents a 500 error.')
2107
+ }
2108
+
2109
+ // --- Constraints ---
2110
+ const mutationBodies = [...content.matchAll(/=>\s*\(\{[^}]*\+\s*1[^}]*\}|=>\s*\(\{[^}]*-\s*1[^}]*\}/g)]
2111
+ if (mutationBodies.length > 0 && !content.includes('constraints:')) {
2112
+ quickWins.push('Mutations with increment/decrement found but no `constraints` block — use `constraints: { field: { min: 0, max: 10 } }` instead of conditional logic inside mutations.')
2113
+ }
2114
+
2115
+ // --- Assemble output ---
2116
+ const lines = ['## Suggestions\n']
2117
+
2118
+ if (suggestions.length === 0 && observations.length === 0 && quickWins.length === 0) {
2119
+ lines.push('Spec looks solid — nothing obvious to flag. Run `pulse_validate` for a full schema check.')
2120
+ return text(lines.join('\n'))
2121
+ }
2122
+
2123
+ if (suggestions.length > 0) {
2124
+ lines.push('### Things to address\n')
2125
+ for (const s of suggestions) lines.push(`• ${s}\n`)
2126
+ }
2127
+
2128
+ if (observations.length > 0) {
2129
+ lines.push('### Observations\n')
2130
+ for (const o of observations) lines.push(`ℹ ${o}\n`)
2131
+ }
2132
+
2133
+ if (quickWins.length > 0) {
2134
+ lines.push('### Quick wins\n')
2135
+ for (const q of quickWins) lines.push(`→ ${q}\n`)
2136
+ }
2137
+
2138
+ lines.push('\nRun `pulse_validate` when ready for a full schema check, or keep iterating.')
2139
+
2140
+ return text(lines.join('\n'))
2141
+ }
2142
+ )
2143
+
2144
+ // ---------------------------------------------------------------------------
2145
+ // pulse_list_icons — Icon catalogue
2146
+ // ---------------------------------------------------------------------------
2147
+
2148
+ const ICON_CATALOGUE = {
2149
+ Navigation: ['iconArrowLeft', 'iconArrowRight', 'iconArrowUp', 'iconArrowDown', 'iconChevronLeft', 'iconChevronRight', 'iconChevronUp', 'iconChevronDown', 'iconExternalLink', 'iconMenu', 'iconX', 'iconMoreHorizontal', 'iconMoreVertical', 'iconHome', 'iconLogOut', 'iconLogIn'],
2150
+ Status: ['iconCheck', 'iconCheckCircle', 'iconXCircle', 'iconAlertCircle', 'iconAlertTriangle', 'iconInfo', 'iconLoader', 'iconBug'],
2151
+ Actions: ['iconPlus', 'iconMinus', 'iconEdit', 'iconTrash', 'iconCopy', 'iconSearch', 'iconFilter', 'iconDownload', 'iconUpload', 'iconRefresh', 'iconSend'],
2152
+ UI: ['iconEye', 'iconEyeOff', 'iconLock', 'iconUnlock', 'iconSettings', 'iconBell', 'iconGrid', 'iconBarChart'],
2153
+ People: ['iconUser', 'iconUsers', 'iconMail', 'iconMessageSquare', 'iconSmile', 'iconHeart', 'iconHandPointUp', 'iconHandPointDown', 'iconHandPointLeft', 'iconHandPointRight'],
2154
+ Media: ['iconFile', 'iconImage', 'iconLink', 'iconCode', 'iconPlay', 'iconPause', 'iconVolume'],
2155
+ Time: ['iconCalendar', 'iconClock'],
2156
+ Utility: ['iconBookmark', 'iconTag', 'iconStar', 'iconMapPin', 'iconGlobe', 'iconShield', 'iconZap', 'iconTrendingUp', 'iconTrendingDown', 'iconSun', 'iconMoon', 'iconPhone', 'iconGamepad', 'iconTelescope', 'iconAi', 'iconFeather'],
2157
+ Ecommerce: ['iconShoppingCart', 'iconShoppingBag', 'iconCreditCard', 'iconPackage', 'iconGift', 'iconWallet', 'iconTruck', 'iconReceipt', 'iconStore', 'iconPercent', 'iconTicket', 'iconBanknote'],
2158
+ Food: ['iconUtensils', 'iconCoffee', 'iconPizza', 'iconApple', 'iconCarrot', 'iconWine', 'iconCakeSlice', 'iconFish', 'iconCherry', 'iconEgg', 'iconCookie', 'iconIceCream', 'iconCroissant', 'iconSalad', 'iconWheat'],
2159
+ }
2160
+
2161
+ server.registerTool(
2162
+ 'pulse_list_icons',
2163
+ {
2164
+ description: 'List all available Pulse UI icon names, grouped by category. Use this before importing icons — never guess a name or grep the source.',
2165
+ inputSchema: {
2166
+ filter: z.string().optional().describe('Optional keyword to filter icon names (e.g. "arrow", "check", "shop")'),
2167
+ },
2168
+ },
2169
+ ({ filter }) => {
2170
+ const q = filter?.toLowerCase()
2171
+ const lines = ['# Pulse UI Icons\n', 'Import from `@invisibleloop/pulse/ui`. All icons accept `{ size, class, color }` props.\n']
2172
+
2173
+ if (!q) {
2174
+ lines.push('## Quick scan — one from each category\n')
2175
+ for (const [category, icons] of Object.entries(ICON_CATALOGUE)) {
2176
+ const sample = icons.slice(0, 5).join(' · ')
2177
+ lines.push(`**${category}** — ${sample} _(+${Math.max(0, icons.length - 5)} more)_`)
2178
+ }
2179
+ lines.push('\nUse `filter` to drill into a category: `pulse_list_icons({ filter: "arrow" })`, `pulse_list_icons({ filter: "shop" })`\n')
2180
+ lines.push('---\n')
2181
+ }
2182
+
2183
+ for (const [category, icons] of Object.entries(ICON_CATALOGUE)) {
2184
+ const filtered = q ? icons.filter(n => n.toLowerCase().includes(q)) : icons
2185
+ if (filtered.length === 0) continue
2186
+ lines.push(`## ${category}`)
2187
+ lines.push(filtered.join(' · '))
2188
+ lines.push('')
2189
+ }
2190
+
2191
+ const total = Object.values(ICON_CATALOGUE).reduce((n, arr) => n + arr.length, 0)
2192
+ lines.push(`---\n${total} icons total. Usage: \`iconCheck({ size: 16 })\`, \`iconZap({ size: 20, class: 'u-text-accent' })\``)
2193
+
2194
+ return text(lines.join('\n'))
2195
+ }
2196
+ )
2197
+
2198
+ // ---------------------------------------------------------------------------
2199
+ // pulse_extract_inspiration — Structured design extraction from URL or image
2200
+ // ---------------------------------------------------------------------------
2201
+
2202
+ server.registerTool(
2203
+ 'pulse_extract_inspiration',
2204
+ {
2205
+ description: `Extract a structured design brief from a URL or image the user has shared as inspiration.
2206
+
2207
+ 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.
2208
+
2209
+ Works for:
2210
+ - URLs: visit the site with your browser tool, observe the rendered page
2211
+ - Images: use your vision capability to analyse the screenshot/photo the user shared
2212
+ - Named references: use your knowledge of the brand/site to populate the template
2213
+
2214
+ The extracted values map directly to pulse_intake fields (palette, vibe, styleNotes, antiStyle, font).`,
2215
+ inputSchema: {
2216
+ source: z.string().describe('What to analyse — a URL (e.g. "https://stripe.com"), a site name (e.g. "linear.app"), or a description of an image the user shared (e.g. "the screenshot the user just pasted")'),
2217
+ focus: z.enum(['all', 'colours', 'layout', 'typography', 'feel']).optional().describe('What aspect to focus on. Default is "all". Use "colours" if user specifically wants to extract a palette, "layout" for structural decisions, "typography" for font choices, "feel" for overall tone.'),
2218
+ },
2219
+ },
2220
+ ({ source, focus = 'all' }) => {
2221
+ const lines = []
2222
+ lines.push(`# Inspiration Extraction — ${source}\n`)
2223
+ lines.push('Use this template to systematically observe and capture design decisions from the inspiration source.')
2224
+ lines.push('Fill in every section you can observe. Leave a field blank if it cannot be determined.\n')
2225
+ lines.push('---\n')
2226
+
2227
+ const isUrl = /^https?:\/\//.test(source) || /\.(com|io|co|app|design|net|org|dev)\b/.test(source)
2228
+
2229
+ if (isUrl) {
2230
+ lines.push('## Step 1 — Visit the site')
2231
+ lines.push(`Navigate to: **${source}**`)
2232
+ lines.push('Observe the rendered page at desktop width (~1440px) and again at mobile (~375px).')
2233
+ lines.push('Take note of the above-the-fold area, then scroll through the full page.\n')
2234
+ } else {
2235
+ lines.push('## Step 1 — Analyse the source')
2236
+ lines.push(`Source: **${source}**`)
2237
+ lines.push('Use your vision capability or knowledge of this reference to fill in the template below.\n')
2238
+ }
2239
+
2240
+ lines.push('---\n')
2241
+
2242
+ if (focus === 'all' || focus === 'colours') {
2243
+ lines.push('## Colour palette')
2244
+ lines.push('Extract the dominant colours. Try to identify hex values where visible (use browser DevTools / colour picker if available).\n')
2245
+ lines.push('| Role | Colour | Hex (if known) |')
2246
+ lines.push('|---|---|---|')
2247
+ lines.push('| Background | | |')
2248
+ lines.push('| Surface / card | | |')
2249
+ lines.push('| Primary text | | |')
2250
+ lines.push('| Secondary / muted text | | |')
2251
+ lines.push('| Accent / brand | | |')
2252
+ lines.push('| Border / divider | | |')
2253
+ lines.push('')
2254
+ lines.push('**Theme:** light / dark / system')
2255
+ lines.push('**Colour character:** monochrome / single accent / multi-colour / gradient-heavy')
2256
+ lines.push('**→ Maps to:** `palette` and `theme` in pulse_intake\n')
2257
+ }
2258
+
2259
+ if (focus === 'all' || focus === 'typography') {
2260
+ lines.push('## Typography')
2261
+ lines.push('| Property | Observation |')
2262
+ lines.push('|---|---|')
2263
+ lines.push('| Heading font | (name or character — serif, sans, slab, monospace, display) |')
2264
+ lines.push('| Body font | |')
2265
+ lines.push('| Heading weight | (light, regular, medium, bold, black) |')
2266
+ lines.push('| Heading size feel | (compact, standard, oversized, huge) |')
2267
+ lines.push('| Letter spacing | (tight / normal / loose / very loose) |')
2268
+ lines.push('| Line height | (tight / comfortable / generous) |')
2269
+ lines.push('')
2270
+ lines.push('**→ Maps to:** `font` and `styleNotes` in pulse_intake\n')
2271
+ }
2272
+
2273
+ if (focus === 'all' || focus === 'layout') {
2274
+ lines.push('## Layout structure')
2275
+ lines.push('**Hero / above-fold:**')
2276
+ lines.push('- Structure: (centred / left-aligned / full-bleed image / split / typography-only)')
2277
+ lines.push('- Nav position: (top bar / sidebar / minimal / none visible)')
2278
+ lines.push('- CTA placement: (inline with heading / separate row / fixed)')
2279
+ lines.push('')
2280
+ lines.push('**Page rhythm:**')
2281
+ lines.push('- Section width: (full-bleed / contained / narrow column)')
2282
+ lines.push('- Spacing: (tight / standard / very generous)')
2283
+ lines.push('- Grid: (centred single column / multi-column / asymmetric)')
2284
+ lines.push('')
2285
+ lines.push('**→ Maps to:** layout direction for pulse_sketch — note which of the 7 directions this resembles:')
2286
+ lines.push('full-bleed-photo / asymmetric-split / typography-only / editorial-flow / dense-grid / story-scroll / content-first\n')
2287
+ }
2288
+
2289
+ if (focus === 'all' || focus === 'feel') {
2290
+ lines.push('## Overall feel')
2291
+ lines.push('**Emotional tone** (circle one or write your own):')
2292
+ lines.push('trustworthy · urgent · warm · clinical · playful · premium · raw · editorial · technical · minimal\n')
2293
+ lines.push('**Closest vibe** (pick from Pulse vibes):')
2294
+ lines.push('warm / editorial / playful / minimal / bold / brutalist / retro / corporate / neon / paper\n')
2295
+ lines.push('**Three words that describe the design:**')
2296
+ lines.push('1.')
2297
+ lines.push('2.')
2298
+ lines.push('3.\n')
2299
+ lines.push('**→ Maps to:** `vibe` and `styleNotes` in pulse_intake\n')
2300
+ }
2301
+
2302
+ lines.push('---\n')
2303
+ lines.push('## Signature moves')
2304
+ lines.push('What does this design do that most sites don\'t? Note 1–3 distinctive techniques:')
2305
+ lines.push('1.')
2306
+ lines.push('2.')
2307
+ lines.push('3.\n')
2308
+
2309
+ lines.push('---\n')
2310
+ lines.push('## Ready to use in pulse_intake\n')
2311
+ lines.push('Once you have filled in the template above, map the findings to pulse_intake params:')
2312
+ lines.push('')
2313
+ lines.push('```')
2314
+ lines.push('pulse_intake({')
2315
+ lines.push(' // ... name, pitch, features ...')
2316
+ lines.push(' palette: "<hex values extracted above, comma-separated>",')
2317
+ lines.push(' theme: "<light or dark>",')
2318
+ lines.push(' font: "<heading font name if identified>",')
2319
+ lines.push(' vibe: "<closest Pulse vibe>",')
2320
+ lines.push(' styleNotes: "<3-word summary> + signature moves observed",')
2321
+ lines.push(' inspiration: "<' + source + '>",')
2322
+ lines.push('})')
2323
+ lines.push('```')
2324
+ lines.push('')
2325
+ lines.push('Then call `pulse_sketch` — pass the layout direction observed above as context in your `brief`.')
2326
+
2327
+ return text(lines.join('\n'))
2328
+ }
2329
+ )
2330
+
2331
+ // ---------------------------------------------------------------------------
2332
+ // pulse_intake — Product intake before scaffolding
2333
+ // ---------------------------------------------------------------------------
2334
+
2335
+ server.registerTool(
2336
+ 'pulse_intake',
2337
+ {
2338
+ description: `Capture product details before scaffolding a new page or site. Run this first for any new project or branded template — before pulse_sketch or pulse_intent.
2339
+
2340
+ Returns a structured product brief with copy-ready content, early contrast warnings, and component suggestions matched to the vibe. After intake, call pulse_sketch to explore structural layout directions before writing any code.
2341
+
2342
+ Accepts antiStyle ("what should this NOT look like?") and inspiration (a site or brand reference) to push the design beyond default patterns.`,
2343
+ inputSchema: {
2344
+ name: z.string().describe('App or product name exactly as it should appear in the UI'),
2345
+ pitch: z.string().describe('One-line description — what the product does. Used as the hero subtitle.'),
2346
+ features: z.string().describe('3–6 real selling points, comma-separated. E.g. "Habit streaks, Smart reminders, Weekly insights, Dark mode"'),
2347
+ targetUser: z.string().optional().describe('Who this is for — shapes copy tone. E.g. "busy professionals", "indie developers", "home cooks"'),
2348
+ palette: z.string().optional().describe('Brand colours as hex values, comma-separated. E.g. "#6366f1, #f8fafc, #1e1b4b". Used for early contrast check.'),
2349
+ font: z.string().optional().describe('Brand font name, or omit for system-ui'),
2350
+ theme: z.enum(['light', 'dark']).optional().describe('Colour theme preference'),
2351
+ vibe: z.enum(['warm', 'editorial', 'playful', 'minimal', 'bold', 'brutalist', 'retro', 'corporate', 'neon', 'paper']).optional().describe('Visual personality — see pulse://guide/design-references for full descriptions. warm (rounded, inviting), editorial (serif, sharp), playful (very rounded), minimal (clean), bold (impact headings), brutalist (zero radius, raw), retro (slab serif, nostalgic), corporate (conservative), neon (monospace, futuristic), paper (organic, serif, journal)'),
2352
+ styleNotes: z.string().optional().describe('Free-form style direction — e.g. "like a print poster", "market stall chalkboard feel", "clinical and professional"'),
2353
+ antiStyle: z.string().optional().describe('"What should this NOT look like?" — negative aesthetic constraint. E.g. "not corporate SaaS", "not another Vercel dark card page", "not a startup landing page". Used to steer component and layout choices away from over-used patterns.'),
2354
+ inspiration: z.string().optional().describe('A website, brand, or visual reference you admire — any industry. E.g. "stripe.com", "a Swiss railway poster", "the Economist magazine". Used to extract aesthetic intent and inform structural choices, not to copy.'),
2355
+ },
2356
+ },
2357
+ ({ name, pitch, features, targetUser, palette, font, theme = 'dark', vibe: rawVibe, styleNotes, antiStyle, inspiration }) => {
2358
+ const vibe = normaliseVibe(rawVibe)
2359
+ const featureList = features.split(',').map(f => f.trim()).filter(Boolean)
2360
+ const lines = []
2361
+
2362
+ lines.push(`# Product brief — ${name}\n`)
2363
+ lines.push(`**Pitch:** ${pitch}`)
2364
+ if (targetUser) lines.push(`**Target user:** ${targetUser}`)
2365
+ lines.push(`**Theme:** ${theme}`)
2366
+ if (vibe) lines.push(`**Vibe:** ${vibe} → set \`meta.vibe: '${vibe}'\` in the spec`)
2367
+ if (styleNotes) lines.push(`**Style direction:** ${styleNotes}`)
2368
+ if (inspiration) lines.push(`**Inspiration:** ${inspiration} — extract the aesthetic intent (structure, whitespace, type scale, colour restraint) not the literal appearance`)
2369
+ if (antiStyle) lines.push(`**Anti-pattern:** NOT ${antiStyle} — actively steer away from this in layout and component choices`)
2370
+ if (font) lines.push(`**Font:** ${font}`)
2371
+ lines.push('')
2372
+
2373
+ lines.push('## Features')
2374
+ featureList.forEach((f, i) => lines.push(`${i + 1}. ${f}`))
2375
+ lines.push('')
2376
+
2377
+ // Palette
2378
+ const hexValues = palette
2379
+ ? palette.split(',').map(s => s.trim()).filter(s => /^#[0-9a-fA-F]{3,8}$/.test(s))
2380
+ : []
2381
+
2382
+ if (hexValues.length > 0) {
2383
+ lines.push('## Palette')
2384
+ hexValues.forEach(h => lines.push(` ${h}`))
2385
+ lines.push('')
2386
+
2387
+ // Quick luminance / tone analysis
2388
+ const tones = hexValues.map(hex => {
2389
+ const r = parseInt(hex.slice(1, 3), 16) / 255
2390
+ const g = parseInt(hex.slice(3, 5), 16) / 255
2391
+ const b = parseInt(hex.slice(5, 7), 16) / 255
2392
+ const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b
2393
+ const tone = lum > 0.7 ? 'light' : lum > 0.2 ? 'mid' : 'dark'
2394
+ return { hex, lum: lum.toFixed(3), tone }
2395
+ })
2396
+
2397
+ lines.push('## Palette tone analysis')
2398
+ lines.push('| Hex | Luminance | Tone | Use for |')
2399
+ lines.push('|---|---|---|---|')
2400
+ tones.forEach(({ hex, lum, tone }) => {
2401
+ const use = tone === 'light' ? 'background, surface' : tone === 'dark' ? 'backgrounds, deep fills' : 'accent colour, buttons'
2402
+ lines.push(`| ${hex} | ${lum} | ${tone} | ${use} |`)
2403
+ })
2404
+ lines.push('')
2405
+
2406
+ // Early contrast warnings — check mid-tones on light/dark backgrounds
2407
+ const mids = tones.filter(t => t.tone === 'mid')
2408
+ const lights = tones.filter(t => t.tone === 'light')
2409
+ const darks = tones.filter(t => t.tone === 'dark')
2410
+
2411
+ const contrastWarnings = []
2412
+ const contrast = (l1, l2) => {
2413
+ const [hi, lo] = l1 > l2 ? [l1, l2] : [l2, l1]
2414
+ return (hi + 0.05) / (lo + 0.05)
2415
+ }
2416
+
2417
+ for (const mid of mids) {
2418
+ if (lights.length > 0) {
2419
+ const bg = lights[0]
2420
+ const ratio = contrast(parseFloat(mid.lum), parseFloat(bg.lum))
2421
+ if (ratio < 4.5) {
2422
+ contrastWarnings.push(`⚠ ${mid.hex} (mid) on ${bg.hex} (light): contrast ${ratio.toFixed(1)}:1 — fails WCAG AA (4.5:1 needed for body text). Darken the accent or use it only on large text (3:1 threshold).`)
2423
+ }
2424
+ }
2425
+ if (darks.length > 0) {
2426
+ const bg = darks[0]
2427
+ const ratio = contrast(parseFloat(mid.lum), parseFloat(bg.lum))
2428
+ if (ratio < 4.5) {
2429
+ contrastWarnings.push(`⚠ ${mid.hex} (mid) on ${bg.hex} (dark): contrast ${ratio.toFixed(1)}:1 — fails WCAG AA for body text. Consider a lighter tint for the dark theme.`)
2430
+ }
2431
+ }
2432
+ }
2433
+
2434
+ if (contrastWarnings.length > 0) {
2435
+ lines.push('## ⚠ Early contrast warnings')
2436
+ lines.push('Resolve these before writing CSS — fixing palette problems after building is expensive:\n')
2437
+ contrastWarnings.forEach(w => lines.push(w))
2438
+ lines.push('')
2439
+ lines.push('**Tip:** Mid-tone palette colours often fail on both light and dark backgrounds. Use them for large background areas (3:1 threshold), and derive a darker variant for small text (needs 4.5:1).')
2440
+ lines.push('')
2441
+ } else if (hexValues.length > 1) {
2442
+ lines.push('✓ No obvious contrast failures detected in palette. Run `pulse_check_contrast` after writing theme CSS for a full check.')
2443
+ lines.push('')
2444
+ }
2445
+ } else if (palette) {
2446
+ lines.push(`Note: palette "${palette}" could not be parsed — provide hex values like #6366f1, #f8fafc\n`)
2447
+ }
2448
+
2449
+ lines.push('## Ready to build')
2450
+ lines.push('')
2451
+ lines.push('Pass this brief to `pulse_intent` or use it when adapting a template. Replace every placeholder in the scaffold with content from this brief.')
2452
+ lines.push('')
2453
+ lines.push('**Spec copy checklist:**')
2454
+ lines.push(`- [ ] App name everywhere it appears: \`${name}\``)
2455
+ lines.push(`- [ ] Hero title/subtitle: use the pitch — "${pitch}"`)
2456
+ lines.push(`- [ ] Features section: ${featureList.slice(0, 3).join(', ')} (+ ${Math.max(0, featureList.length - 3)} more)`)
2457
+ if (targetUser) lines.push(`- [ ] Copy tone: aimed at ${targetUser}`)
2458
+ lines.push(`- [ ] Theme: ${theme} — ${theme === 'light' ? "use `meta.theme: 'light'` and target `[data-theme=\"light\"]` for CSS overrides" : 'default Pulse theme (no meta.theme needed), override tokens in `:root`'}`)
2459
+ if (vibe) {
2460
+ lines.push(`- [ ] Vibe: \`meta.vibe: '${vibe}'\` — sets data-vibe on body, activating geometry/type presets`)
2461
+ const vibeGuide = {
2462
+ warm: 'Pair with rounded imagery (photoCard with tilt), warmer accent tones, section variant: paper or alt',
2463
+ editorial: 'Use layout: overlap on hero for dramatic imagery. Serif headings via --font-display. section variant: diagonal for transitions.',
2464
+ playful: 'gallery with rounded images, photoCard with tilt, marquee for social proof, bright accent',
2465
+ minimal: 'hero align: left (no center), section variant: spotlight, clean whitespace, monochrome palette',
2466
+ bold: 'hero gradient with strong color, large stat components, section variant: dark for contrast sections',
2467
+ brutalist: 'raw section wrappers, oversized heading with zero padding, no shadows, high contrast accent (red/lime)',
2468
+ retro: 'decorate(pattern: "dots") backgrounds, badge eyebrows, thick dividers, earthy amber/cream palette',
2469
+ corporate: 'feature grid with checkmarks, testimonial with company name+logo, trust logo marquee, stat bar',
2470
+ neon: 'section(variant: "spotlight") for glow sections, codeWindow for terminal/API examples, stat with glowing accent',
2471
+ paper: 'pullquote prominently, prose with generous line-height, avatar for author, container(size: "sm")',
2472
+ }
2473
+ if (vibeGuide[vibe]) lines.push(` **${vibe} component suggestions:** ${vibeGuide[vibe]}`)
2474
+ }
2475
+ if (styleNotes) lines.push(`- [ ] Style direction: "${styleNotes}" — translate this into component choices and layout decisions`)
2476
+ if (antiStyle) lines.push(`- [ ] Anti-pattern enforced: "${antiStyle}" — if your layout resembles this, reconsider structural choices before writing the spec`)
2477
+ if (inspiration) lines.push(`- [ ] Inspiration taken from: "${inspiration}" — extract intent (spacing, type scale, restraint), not appearance`)
2478
+ if (font) lines.push(`- [ ] Font: set \`--font: '${font}', system-ui\` in \`:root\` via app.css; if display font differs set \`--font-display: '${font}'\``)
2479
+
2480
+ lines.push('')
2481
+ lines.push('## Next step')
2482
+ lines.push('')
2483
+ lines.push('Call `pulse_sketch` with this brief to get 3 structurally distinct layout directions before writing any code.')
2484
+ lines.push('This prevents defaulting to the standard centred hero + three columns layout.')
2485
+ if (antiStyle) lines.push(`Pass \`antiStyle: "${antiStyle}"\` to pulse_sketch to filter directions that might drift toward the constraint.`)
2486
+ lines.push('')
2487
+ lines.push('**Before writing image tags:** if you plan to use external images (picsum.photos, Unsplash, Cloudinary, etc.), add the host to `csp.img-src` in `pulse.config.js` before your first Lighthouse run — or images will be blocked and Best Practices will fail. See `pulse://guide/styles` → "External images (img-src)".')
2488
+
2489
+ // Save brief to .pulse/brief.json for later design review
2490
+ try {
2491
+ const pulseDir = path.join(ROOT, '.pulse')
2492
+ const briefFile = path.join(pulseDir, 'brief.json')
2493
+ fs.mkdirSync(pulseDir, { recursive: true })
2494
+ fs.writeFileSync(briefFile, JSON.stringify({
2495
+ name, pitch, features: featureList, targetUser: targetUser ?? null,
2496
+ vibe: vibe ?? null, theme, styleNotes: styleNotes ?? null,
2497
+ antiStyle: antiStyle ?? null, palette: palette ?? null,
2498
+ font: font ?? null, inspiration: inspiration ?? null,
2499
+ }, null, 2))
2500
+ } catch (_) { /* non-fatal — brief.json is best-effort */ }
2501
+
2502
+ return text(lines.join('\n'))
2503
+ }
2504
+ )
2505
+
2506
+ // ---------------------------------------------------------------------------
2507
+ // pulse_sketch — Generate 3 structural layout directions before writing code
2508
+ // ---------------------------------------------------------------------------
2509
+
2510
+ const SKETCH_DIRECTIONS = {
2511
+ 'full-bleed-photo': {
2512
+ name: 'Full-Bleed Photo',
2513
+ mood: 'Immersive. The image IS the hero. Type floats over.',
2514
+ gesture: 'Edge-to-edge image fills the viewport. Title reversed out. Content below in a clean strip.',
2515
+ wireframe: [
2516
+ '┌─────────────────────────────────────────────────────┐',
2517
+ '│▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓│',
2518
+ '│▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓│',
2519
+ '│▓▓▓▓▓ LARGE TITLE — REVERSED OUT ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓│',
2520
+ '│▓▓▓▓▓ subtitle · CTA ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓│',
2521
+ '│▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓│',
2522
+ '├─────────────────────────────────────────────────────┤',
2523
+ '│ Content / features / grid below the fold │',
2524
+ '└─────────────────────────────────────────────────────┘',
2525
+ ],
2526
+ decisions: [
2527
+ 'Type positioned bottom-left or lower-centre — never centred at eye level',
2528
+ 'Nav is transparent on load, transitions to solid on scroll',
2529
+ 'Gradient overlay at bottom of hero — text readable without a stroke',
2530
+ 'Below-fold uses a clean background for contrast reset',
2531
+ ],
2532
+ useComponents: 'nav (transparent variant), hero (gradient overlay), footer, card (below-fold features)',
2533
+ rawHtml: 'hero layout positioning — use hero() component with custom .hero-wrapper CSS for full-bleed background image',
2534
+ },
2535
+ 'asymmetric-split': {
2536
+ name: 'Asymmetric Split',
2537
+ mood: 'Confident and considered. Permanent visual tension.',
2538
+ gesture: '60/40 or 65/35 vertical split throughout. Left: identity + text. Right: image, proof, or form.',
2539
+ wireframe: [
2540
+ '┌──────────────────┬──────────────────────────────────┐',
2541
+ '│ │ │',
2542
+ '│ Logo │ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │',
2543
+ '│ │ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │',
2544
+ '│ BIG HEADING │ ▓▓▓▓▓ FULL-BLEED IMAGE ▓▓▓▓ │',
2545
+ '│ │ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │',
2546
+ '│ Subtitle │ │',
2547
+ '│ [CTA Button] │ │',
2548
+ '│ │ │',
2549
+ '└──────────────────┴──────────────────────────────────┘',
2550
+ ],
2551
+ decisions: [
2552
+ 'Left column can be sticky on large screens — identity persists while right scrolls',
2553
+ 'Asymmetry is maintained across ALL sections — never go full-width mid-page',
2554
+ 'Right column can hold image, testimonials, or a form',
2555
+ 'Mobile: stacks vertically — image first, then text',
2556
+ ],
2557
+ useComponents: 'nav (left column), hero (split layout), media, card, footer',
2558
+ rawHtml: 'page-level grid wrapper — wrap all sections in a two-column CSS grid container',
2559
+ },
2560
+ 'typography-only': {
2561
+ name: 'Typography-Only',
2562
+ mood: 'Quiet confidence. The words do all the work.',
2563
+ gesture: 'No hero image. Ultra-large heading fills the viewport. Restrained details. Generous whitespace.',
2564
+ wireframe: [
2565
+ '┌─────────────────────────────────────────────────────┐',
2566
+ '│ │',
2567
+ '│ Logo Nav links │',
2568
+ '│ │',
2569
+ '│ THE HEADLINE IS ENORMOUS. │',
2570
+ '│ IT FILLS THE SPACE. │',
2571
+ '│ │',
2572
+ '│ Subtitle copy. One sentence max. │',
2573
+ '│ │',
2574
+ '│ [ CTA Button ] ↓ Scroll │',
2575
+ '│ │',
2576
+ '└─────────────────────────────────────────────────────┘',
2577
+ ],
2578
+ decisions: [
2579
+ 'font-size: clamp(3rem, 10vw, 9rem) — headline scales with viewport',
2580
+ 'No images above the fold — trust the writing',
2581
+ 'Single accent colour — everything else is text and background',
2582
+ 'Below-fold sections may introduce imagery, but type remains dominant',
2583
+ ],
2584
+ useComponents: 'nav, footer, stat (for numbers/proof below fold)',
2585
+ useComponents: 'hero (size:lg, align:center), section, container, footer',
2586
+ rawHtml: 'page-level typography overrides — custom font-size on hero title, tighter letter-spacing',
2587
+ },
2588
+ 'editorial-flow': {
2589
+ name: 'Editorial Flow',
2590
+ mood: 'Authoritative. Reads like a magazine, not a landing page.',
2591
+ gesture: 'Long vertical column with intentional zone widths — intro full-width, body at reading width (~65ch), pullouts wider.',
2592
+ wireframe: [
2593
+ '┌─────────────────────────────────────────────────────┐',
2594
+ '│ Full-width header image or colour fill │',
2595
+ '│ ───────────────────────────────────────── │',
2596
+ '│ HEADLINE RUNS FULL WIDTH HERE │',
2597
+ '│ ───────────────────────────────────────── │',
2598
+ '│ │ Opening paragraph at reading width │ │',
2599
+ '│ │ (max 65ch, generous leading) │ │',
2600
+ '│ │ │ │',
2601
+ '│ ╔══════ PULLQUOTE BREAKS OUT WIDER ══════╗ │ │',
2602
+ '│ ║ "The quoted passage." ║ │ │',
2603
+ '│ ╚════════════════════════════════════════╝ │ │',
2604
+ '│ │ Body text resumes… │ │',
2605
+ '└─────────────────────────────────────────────────────┘',
2606
+ ],
2607
+ decisions: [
2608
+ 'Text is the primary element — no sidebar, no card grid above the fold',
2609
+ 'pullquote used prominently to break the reading rhythm',
2610
+ 'Section widths vary deliberately — not uniform padding throughout',
2611
+ 'CTA lives at the end of the reading flow, not above the fold',
2612
+ ],
2613
+ useComponents: 'nav, hero (image layout), prose, pullquote, footer, avatar (author bio)',
2614
+ rawHtml: 'section width variations — use container(size) with different sizes for editorial flow',
2615
+ },
2616
+ 'dense-grid': {
2617
+ name: 'Dense Grid',
2618
+ mood: 'Rich and scannable. Every pixel earns its place.',
2619
+ gesture: 'Information-dense from the first scroll. Tight gutters, small cards, horizontal + vertical navigation.',
2620
+ wireframe: [
2621
+ '┌──────────────────────────────────────────────────────┐',
2622
+ '│ Logo · Nav · Search · Category filter · CTA │',
2623
+ '├───────────────┬────────────────────────────┬─────────┤',
2624
+ '│ │ ┌──────┐ ┌──────┐ ┌────┐ │ Filter │',
2625
+ '│ Sidebar │ │ Card │ │ Card │ │ │ │ ────── │',
2626
+ '│ ────────── │ │ │ │ │ │ │ │ Tags │',
2627
+ '│ Category 1 │ └──────┘ └──────┘ └────┘ │ ────── │',
2628
+ '│ Category 2 │ ┌────┐ ┌──────────┐ │ Sort │',
2629
+ '│ Category 3 │ │ │ │ Featured │ │ │',
2630
+ '└───────────────┴────────────────────────────┴─────────┘',
2631
+ ],
2632
+ decisions: [
2633
+ 'Three-column layout with sidebar — breaks the standard single-column scroll',
2634
+ 'Cards use flush:true — image-first, minimal padding',
2635
+ 'No hero — jump straight into content',
2636
+ 'Filtering visible on load — for sites where browsing/exploration is the UX',
2637
+ ],
2638
+ useComponents: 'nav, card (flush:true), badge, grid, footer',
2639
+ rawHtml: 'three-column page wrapper — wrap entire page in CSS grid layout for sidebar + content + filters',
2640
+ },
2641
+ 'story-scroll': {
2642
+ name: 'Story Scroll',
2643
+ mood: 'Narrative and immersive. Each section feels like a chapter.',
2644
+ gesture: 'Full-viewport sections that scroll one at a time. Each zone fills 100svh with a single focused message.',
2645
+ wireframe: [
2646
+ '┌──────────────────────────────────────┐ ← 100svh',
2647
+ '│ │',
2648
+ '│ IDENTITY │',
2649
+ '│ The hook, the name, the feeling │',
2650
+ '│ │',
2651
+ '└──────────────────────────────────────┘ ← scroll',
2652
+ '┌──────────────────────────────────────┐ ← 100svh',
2653
+ '│ ▓▓▓▓▓▓▓▓▓▓▓▓ PROOF ZONE │',
2654
+ '│ ▓▓ photo ▓▓▓ Stat or testimonial │',
2655
+ '└──────────────────────────────────────┘ ← scroll',
2656
+ '┌──────────────────────────────────────┐ ← 100svh',
2657
+ '│ THE ASK │',
2658
+ '│ Form or CTA, nothing else │',
2659
+ '└──────────────────────────────────────┘',
2660
+ ],
2661
+ decisions: [
2662
+ 'min-height: 100svh per section — each screen has one job',
2663
+ 'Nav is minimal or floating — does not compete with content',
2664
+ 'Sections alternate visual weight (light → dark → image-heavy)',
2665
+ 'Best for short sites: 3–5 sections maximum',
2666
+ ],
2667
+ useComponents: 'nav (minimal), section (min-height: 100svh), hero, stat, cta, footer',
2668
+ rawHtml: 'full-viewport section wrappers — use section() with custom CSS for 100svh height and scroll snap',
2669
+ },
2670
+ 'content-first': {
2671
+ name: 'Content First',
2672
+ mood: 'Humble and honest. The content leads, the design follows.',
2673
+ gesture: 'Narrow reading column, no hero image, minimal nav — content is visible within two scrolls of the fold.',
2674
+ wireframe: [
2675
+ '┌─────────────────────────────────────────────────────┐',
2676
+ '│ Logo Simple nav │',
2677
+ '├─────────────────────────────────────────────────────┤',
2678
+ '│ │',
2679
+ '│ PAGE TITLE │',
2680
+ '│ One line of context │',
2681
+ '│ │',
2682
+ '│ ┌─────────────────────────────────────────────┐ │',
2683
+ '│ │ Content in a readable column (~65ch) │ │',
2684
+ '│ │ Designed for reading, not scanning │ │',
2685
+ '│ └─────────────────────────────────────────────┘ │',
2686
+ '│ │',
2687
+ '└─────────────────────────────────────────────────────┘',
2688
+ ],
2689
+ decisions: [
2690
+ 'max-width: 65ch on body text — readability over width-filling',
2691
+ 'No above-fold hero — title and content appear immediately',
2692
+ 'CTA integrated inline with content — not a separate section',
2693
+ 'Feels finished at 3 sections, not 8',
2694
+ ],
2695
+ useComponents: 'nav, hero (no image, size:lg), prose, pullquote, avatar, footer',
2696
+ rawHtml: 'narrow content wrapper — use container(size:sm) for 65ch reading width',
2697
+ },
2698
+ }
2699
+
2700
+ const SKETCH_VIBE_MAP = {
2701
+ warm: ['asymmetric-split', 'full-bleed-photo', 'story-scroll'],
2702
+ editorial: ['editorial-flow', 'typography-only', 'content-first'],
2703
+ playful: ['full-bleed-photo', 'story-scroll', 'dense-grid'],
2704
+ minimal: ['typography-only', 'content-first', 'asymmetric-split'],
2705
+ bold: ['typography-only', 'full-bleed-photo', 'story-scroll'],
2706
+ brutalist: ['typography-only', 'dense-grid', 'content-first'],
2707
+ retro: ['story-scroll', 'full-bleed-photo', 'editorial-flow'],
2708
+ corporate: ['asymmetric-split', 'content-first', 'dense-grid'],
2709
+ neon: ['full-bleed-photo', 'story-scroll', 'dense-grid'],
2710
+ paper: ['editorial-flow', 'content-first', 'typography-only'],
2711
+ }
2712
+
2713
+ const SKETCH_PAGE_MAP = {
2714
+ landing: ['full-bleed-photo', 'asymmetric-split', 'typography-only'],
2715
+ about: ['editorial-flow', 'story-scroll', 'content-first'],
2716
+ portfolio: ['dense-grid', 'full-bleed-photo', 'asymmetric-split'],
2717
+ blog: ['editorial-flow', 'content-first', 'dense-grid'],
2718
+ product: ['story-scroll', 'asymmetric-split', 'full-bleed-photo'],
2719
+ event: ['full-bleed-photo', 'story-scroll', 'typography-only'],
2720
+ contact: ['content-first', 'typography-only', 'asymmetric-split'],
2721
+ dashboard: ['dense-grid', 'content-first', 'asymmetric-split'],
2722
+ }
2723
+
2724
+ server.registerTool(
2725
+ 'pulse_sketch',
2726
+ {
2727
+ description: `Generate 3 structurally distinct layout directions for a page before writing any code.
2728
+
2729
+ Call this after pulse_intake, before fetching guide sections or writing the spec. Returns three named layout directions — each with a different structural gesture (full-bleed, asymmetric split, typography-only, editorial flow, etc.) — so you can choose the one that matches the emotional intent rather than defaulting to "centred hero + three-column features".
2730
+
2731
+ After choosing a direction, fetch \`pulse://guide/explore\` for raw HTML patterns that match the structure, then \`pulse://guide/components\` for Pulse components to mix in.`,
2732
+ inputSchema: {
2733
+ brief: z.string().describe('Product brief — paste the pulse_intake output, or describe the site in free text: what it is, who it\'s for, what feeling it should give.'),
2734
+ vibe: z.enum(['warm', 'editorial', 'playful', 'minimal', 'bold', 'brutalist', 'retro', 'corporate', 'neon', 'paper']).optional().describe('Visual personality from pulse_intake — tailors the structural suggestions.'),
2735
+ antiStyle: z.string().optional().describe('What it should NOT look like — from pulse_intake antiStyle or stated directly.'),
2736
+ pageType: z.enum(['landing', 'about', 'portfolio', 'blog', 'product', 'event', 'contact', 'dashboard']).optional().describe('Type of page — calibrates structural options. Defaults to landing.'),
2737
+ },
2738
+ },
2739
+ ({ brief, vibe: rawVibe, antiStyle, pageType = 'landing' }) => {
2740
+ const vibe = normaliseVibe(rawVibe)
2741
+ const lines = []
2742
+ lines.push('# Layout Directions\n')
2743
+ lines.push('Three structurally distinct options for this page. Read all three before choosing — the right one is rarely the first.\n')
2744
+
2745
+ if (antiStyle) {
2746
+ lines.push(`**Anti-constraint:** NOT "${antiStyle}"`)
2747
+ lines.push('Each direction below is chosen to contrast with this constraint. If any still feels too close, say so.\n')
2748
+ }
2749
+
2750
+ const keys = (vibe && SKETCH_VIBE_MAP[vibe]) || SKETCH_PAGE_MAP[pageType] || SKETCH_PAGE_MAP.landing
2751
+ const directions = keys.map(k => SKETCH_DIRECTIONS[k]).filter(Boolean)
2752
+
2753
+ directions.forEach((dir, i) => {
2754
+ lines.push('---\n')
2755
+ lines.push(`## Direction ${i + 1}: ${dir.name}`)
2756
+ lines.push(`*${dir.mood}*\n`)
2757
+ lines.push(`**Structural gesture:** ${dir.gesture}\n`)
2758
+ lines.push('```')
2759
+ dir.wireframe.forEach(l => lines.push(l))
2760
+ lines.push('```\n')
2761
+ lines.push('**Key structural decisions:**')
2762
+ dir.decisions.forEach(d => lines.push(`- ${d}`))
2763
+ lines.push('')
2764
+ lines.push('**Component strategy:**')
2765
+ lines.push(`- **Use Pulse components:** ${dir.useComponents}`)
2766
+ lines.push(`- **Custom CSS/styling may be needed for:** ${dir.rawHtml}`)
2767
+ lines.push(`- **Important:** Always use components first. Custom CSS is for styling overrides only, never for rewriting component HTML from scratch.`)
2768
+ lines.push('')
2769
+ })
2770
+
2771
+ lines.push('---\n')
2772
+ lines.push('## Next step\n')
2773
+ lines.push('Choose a direction (e.g. "Direction 2" or "mix 1 and 3 — split layout but with full-bleed image on the right").')
2774
+ lines.push('')
2775
+ lines.push('Then:')
2776
+ lines.push('1. Fetch `pulse://guide/explore` — raw HTML patterns for your chosen structure')
2777
+ lines.push('2. Fetch `pulse://guide/components` — Pulse components to mix in')
2778
+ lines.push('3. Fetch `pulse://guide/spec` — spec format reference')
2779
+ lines.push('4. Write the spec')
2780
+
2781
+ return text(lines.join('\n'))
2782
+ }
2783
+ )
2784
+
2785
+
2786
+
2787
+ /**
2788
+ * Parse a hex color string (#rgb, #rrggbb, #rrggbbaa) to relative luminance.
2789
+ * Returns null if the string is not a valid hex color.
2790
+ */
2791
+ function hexToLuminance(hex) {
2792
+ const h = hex.replace('#', '')
2793
+ let r, g, b
2794
+ if (h.length === 3 || h.length === 4) {
2795
+ r = parseInt(h[0] + h[0], 16)
2796
+ g = parseInt(h[1] + h[1], 16)
2797
+ b = parseInt(h[2] + h[2], 16)
2798
+ } else if (h.length >= 6) {
2799
+ r = parseInt(h.slice(0, 2), 16)
2800
+ g = parseInt(h.slice(2, 4), 16)
2801
+ b = parseInt(h.slice(4, 6), 16)
2802
+ } else {
2803
+ return null
2804
+ }
2805
+ if (isNaN(r) || isNaN(g) || isNaN(b)) return null
2806
+ const linearize = c => {
2807
+ const s = c / 255
2808
+ return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4)
2809
+ }
2810
+ return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b)
2811
+ }
2812
+
2813
+ function contrastRatio(lum1, lum2) {
2814
+ const [hi, lo] = lum1 > lum2 ? [lum1, lum2] : [lum2, lum1]
2815
+ return (hi + 0.05) / (lo + 0.05)
2816
+ }
2817
+
2818
+ /**
2819
+ * Given a hex foreground, background luminance, and target contrast ratio,
2820
+ * suggest a corrected hex value that achieves the target ratio.
2821
+ * Strategy: adjust foreground luminance toward darker/lighter as needed.
2822
+ */
2823
+ function suggestContrastFix(fgHex, bgLum, targetRatio) {
2824
+ const fgLum = hexToLuminance(fgHex)
2825
+ if (fgLum === null) return null
2826
+
2827
+ // Calculate target foreground luminance needed to achieve targetRatio
2828
+ // (hi + 0.05) / (lo + 0.05) = targetRatio
2829
+ // If bg is lighter: (bgLum + 0.05) / (fgLum + 0.05) = targetRatio
2830
+ // → fgLum = (bgLum + 0.05) / targetRatio - 0.05
2831
+ // If bg is darker: (fgLum + 0.05) / (bgLum + 0.05) = targetRatio
2832
+ // → fgLum = targetRatio * (bgLum + 0.05) - 0.05
2833
+
2834
+ let targetLum
2835
+ if (bgLum > fgLum) {
2836
+ // Background is lighter — darken foreground
2837
+ targetLum = (bgLum + 0.05) / targetRatio - 0.05
2838
+ } else {
2839
+ // Background is darker — lighten foreground
2840
+ targetLum = targetRatio * (bgLum + 0.05) - 0.05
2841
+ }
2842
+
2843
+ // Clamp to valid luminance range
2844
+ targetLum = Math.max(0, Math.min(1, targetLum))
2845
+
2846
+ // Convert target luminance to RGB (grayscale approximation for simplicity)
2847
+ // Inverse of linearize: L = 0.2126*R + 0.7152*G + 0.0722*B
2848
+ // For grayscale: L = R = G = B (simplified), so solve for sRGB value
2849
+ const delinearize = l => {
2850
+ return l <= 0.0031308 ? l * 12.92 : 1.055 * Math.pow(l, 1 / 2.4) - 0.055
2851
+ }
2852
+
2853
+ const srgb = delinearize(targetLum)
2854
+ const val = Math.round(srgb * 255)
2855
+ const clamp = Math.max(0, Math.min(255, val))
2856
+
2857
+ return `#${clamp.toString(16).padStart(2, '0').repeat(3)}`
2858
+ }
2859
+
2860
+ server.registerTool(
2861
+ 'pulse_tokens',
2862
+ {
2863
+ description: `List all available --ui-* CSS custom property tokens from the installed pulse-ui.css.
2864
+
2865
+ Use this before writing theme overrides or app.css to avoid guessing token names. The correct override pattern is:
2866
+ - Set --accent (no prefix) in theme.css — pulse-ui maps it to --ui-accent automatically
2867
+ - Reference --ui-accent (prefixed) in app.css
2868
+
2869
+ Returns tokens grouped by category.`,
2870
+ inputSchema: {},
2871
+ },
2872
+ async () => {
2873
+ const cssPath = path.join(ROOT, 'public', 'pulse-ui.css')
2874
+ let tokens = []
2875
+
2876
+ if (fs.existsSync(cssPath)) {
2877
+ const css = fs.readFileSync(cssPath, 'utf8')
2878
+ // Extract property: value pairs for --ui-* defined in :root
2879
+ const matches = [...css.matchAll(/--ui-([a-z0-9-]+)\s*:\s*([^;}{]+)/g)]
2880
+ tokens = [...new Set(matches.map(m => `--ui-${m[1]}`))]
2881
+ }
2882
+
2883
+ // Group tokens by category
2884
+ const groups = {
2885
+ 'Colour': tokens.filter(t => /^--ui-(bg|surface|border|text|muted|accent|green|red|yellow|blue|shadow)/.test(t)),
2886
+ 'Spacing': tokens.filter(t => t.startsWith('--ui-space-')),
2887
+ 'Type size': tokens.filter(t => t.startsWith('--ui-text-')),
2888
+ 'Typography': tokens.filter(t => /^--ui-(font|mono|letter-spacing)/.test(t)),
2889
+ 'Shape': tokens.filter(t => t.startsWith('--ui-radius')),
2890
+ 'Other': tokens.filter(t => !/(bg|surface|border|text|muted|accent|green|red|yellow|blue|shadow|space-|text-|font|mono|letter|radius)/.test(t.slice(5))),
2891
+ }
2892
+
2893
+ const lines = [
2894
+ '## Pulse UI Tokens\n',
2895
+ '**Override pattern:**',
2896
+ ' • Set `--accent: #yourcolour` in `:root` (theme.css) — maps to `--ui-accent` automatically',
2897
+ ' • Reference `var(--ui-accent)` in app.css — never `var(--accent)` in app.css',
2898
+ ' • Common mistake: `--ui-color-accent` or `--color-accent` — these do NOT exist\n',
2899
+ ]
2900
+
2901
+ for (const [group, toks] of Object.entries(groups)) {
2902
+ if (toks.length === 0) continue
2903
+ lines.push(`### ${group}`)
2904
+ lines.push(toks.join(' • '))
2905
+ lines.push('')
2906
+ }
2907
+
2908
+ lines.push('**Input tokens (set these in theme.css):**')
2909
+ lines.push('`--accent` `--accent-hover` `--accent-dim` `--accent-text` `--bg` `--surface` `--surface-2` `--border` `--text` `--muted` `--muted-bg` `--radius` `--font` `--mono`')
2910
+
2911
+ return text(lines.join('\n'))
2912
+ }
2913
+ )
2914
+
2915
+ server.registerTool(
2916
+ 'pulse_check_contrast',
2917
+ {
2918
+ description: `Static WCAG contrast checker. Provide theme CSS content (or a file path) and it extracts all color variable definitions, then checks common foreground/background pairings for WCAG AA compliance (4.5:1 for normal text, 3:1 for large text and UI components).
2919
+
2920
+ Run this immediately after writing a theme file — before production build and Lighthouse. It catches palette mistakes in milliseconds instead of after a 90-second build cycle.`,
2921
+ inputSchema: {
2922
+ css: z.string().optional().describe('CSS content to analyse — paste the contents of your theme file'),
2923
+ file: z.string().optional().describe('Absolute path to a CSS file to analyse (alternative to css)'),
2924
+ theme: z.enum(['light', 'dark']).optional().describe('Which theme context to analyse — determines which CSS block to parse (default: both)'),
2925
+ },
2926
+ },
2927
+ ({ css, file, theme }) => {
2928
+ let source = css || ''
2929
+
2930
+ if (!source && file) {
2931
+ if (!fs.existsSync(file)) return text(`File not found: ${file}`)
2932
+ source = fs.readFileSync(file, 'utf8')
2933
+ }
2934
+
2935
+ if (!source) return text('Provide either css content or a file path.')
2936
+
2937
+ // Extract CSS custom property definitions from all relevant blocks
2938
+ // Look in :root, [data-theme="light"], and .ui-theme-light blocks
2939
+ const extractVars = (cssText, blockPattern) => {
2940
+ const vars = {}
2941
+ const blockRegex = new RegExp(blockPattern + '\\s*\\{([^}]+)\\}', 'gi')
2942
+ let block
2943
+ while ((block = blockRegex.exec(cssText)) !== null) {
2944
+ const body = block[1]
2945
+ const propRegex = /--([\w-]+)\s*:\s*(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|var\(--[\w-]+\)|[a-z]+)\s*;/gi
2946
+ let prop
2947
+ while ((prop = propRegex.exec(body)) !== null) {
2948
+ vars[`--${prop[1]}`] = prop[2].trim()
2949
+ }
2950
+ }
2951
+ return vars
2952
+ }
2953
+
2954
+ const rootVars = extractVars(source, ':root')
2955
+ const lightVars = extractVars(source, '\\[data-theme=["\']light["\']\\]|[.]ui-theme-light')
2956
+
2957
+ // pulse-ui.css maps --NAME → --ui-NAME (e.g. --accent → --ui-accent).
2958
+ // Theme files written per the guide define bare --* tokens. Synthesize the
2959
+ // --ui-* entries so the checker can resolve them without needing pulse-ui.css.
2960
+ const UI_ALIASES = ['text','bg','muted','accent','accent-text','surface','surface-2','border','heading']
2961
+ const addAliases = (vars) => {
2962
+ for (const name of UI_ALIASES) {
2963
+ if (vars[`--${name}`] && !vars[`--ui-${name}`]) {
2964
+ vars[`--ui-${name}`] = vars[`--${name}`]
2965
+ }
2966
+ }
2967
+ }
2968
+ addAliases(rootVars)
2969
+ addAliases(lightVars)
2970
+
2971
+ const resolveColor = (value, varMap) => {
2972
+ if (!value) return null
2973
+ if (value.startsWith('#')) return hexToLuminance(value)
2974
+ if (value.startsWith('var(')) {
2975
+ const ref = value.match(/var\(--([\w-]+)\)/)?.[1]
2976
+ if (ref && varMap[`--${ref}`]) return resolveColor(varMap[`--${ref}`], varMap)
2977
+ }
2978
+ return null
2979
+ }
2980
+
2981
+ const resolveToHex = (value, varMap) => {
2982
+ if (!value) return null
2983
+ if (value.startsWith('#')) return value
2984
+ if (value.startsWith('var(')) {
2985
+ const ref = value.match(/var\(--([\w-]+)\)/)?.[1]
2986
+ if (ref && varMap[`--${ref}`]) return resolveToHex(varMap[`--${ref}`], varMap)
2987
+ }
2988
+ return null
2989
+ }
2990
+
2991
+ // Standard pairings to check — [foreground token, background token, label, isLargeText]
2992
+ const PAIRINGS = [
2993
+ ['--ui-text', '--ui-bg', 'Body text on page background', false],
2994
+ ['--ui-muted', '--ui-bg', 'Muted text on page background', false],
2995
+ ['--ui-accent', '--ui-bg', 'Accent text on page background', false],
2996
+ ['--ui-text', '--ui-surface', 'Body text on card/surface', false],
2997
+ ['--ui-muted', '--ui-surface', 'Muted text on card/surface', false],
2998
+ ['--ui-accent-text', '--ui-accent', 'Button text on accent background', false],
2999
+ ['--ui-accent', '--ui-surface', 'Accent text on surface', false],
3000
+ ['--ui-text', '--ui-surface-2','Text on nested surface', false],
3001
+ ]
3002
+
3003
+ const checkSet = (vars, context) => {
3004
+ const results = []
3005
+ for (const [fg, bg, label, isLarge] of PAIRINGS) {
3006
+ const fgVal = vars[fg]
3007
+ const bgVal = vars[bg]
3008
+ if (!fgVal || !bgVal) continue
3009
+
3010
+ const fgLum = resolveColor(fgVal, vars)
3011
+ const bgLum = resolveColor(bgVal, vars)
3012
+ if (fgLum === null || bgLum === null) continue
3013
+
3014
+ const fgHex = resolveToHex(fgVal, vars)
3015
+ const bgHex = resolveToHex(bgVal, vars)
3016
+
3017
+ const ratio = contrastRatio(fgLum, bgLum)
3018
+ const threshold = isLarge ? 3.0 : 4.5
3019
+ const pass = ratio >= threshold
3020
+ const level = ratio >= 7 ? 'AAA' : ratio >= 4.5 ? 'AA' : ratio >= 3 ? 'AA large' : 'FAIL'
3021
+
3022
+ results.push({
3023
+ context, label,
3024
+ fg: fg + ' (' + fgVal + ')',
3025
+ bg: bg + ' (' + bgVal + ')',
3026
+ fgHex,
3027
+ bgHex,
3028
+ bgLum,
3029
+ ratio: ratio.toFixed(2),
3030
+ pass,
3031
+ level,
3032
+ threshold,
3033
+ })
3034
+ }
3035
+ return results
3036
+ }
3037
+
3038
+ const allResults = []
3039
+
3040
+ if (!theme || theme === 'dark') {
3041
+ const combined = { ...rootVars }
3042
+ allResults.push(...checkSet(combined, 'Dark theme (:root)'))
3043
+ }
3044
+ if (!theme || theme === 'light') {
3045
+ if (Object.keys(lightVars).length > 0) {
3046
+ const combined = { ...rootVars, ...lightVars }
3047
+ allResults.push(...checkSet(combined, 'Light theme ([data-theme="light"])'))
3048
+ }
3049
+ }
3050
+
3051
+ if (allResults.length === 0) {
3052
+ // Try to cross-reference app.css to extract real-world color pairings
3053
+ // (e.g. --color-text on --color-bg used in body { color: var(--color-text); background: var(--color-bg) })
3054
+ const appCssPath = path.join(ROOT, 'public', 'app.css')
3055
+ let appCssHints = ''
3056
+ if (fs.existsSync(appCssPath)) {
3057
+ try {
3058
+ const appCss = fs.readFileSync(appCssPath, 'utf8')
3059
+ const varRefs = [...appCss.matchAll(/var\(--([\w-]+)\)/g)].map(m => `--${m[1]}`)
3060
+ const uniqueRefs = [...new Set(varRefs)]
3061
+ const unknownToChecker = uniqueRefs.filter(v =>
3062
+ !v.startsWith('--ui-') &&
3063
+ (Object.keys(rootVars).some(k => k === v) || Object.keys(lightVars).some(k => k === v))
3064
+ )
3065
+ if (unknownToChecker.length > 0) {
3066
+ appCssHints = `\n\napp.css uses these custom tokens that the checker couldn't auto-map to --ui-* pairings:\n ${unknownToChecker.join(', ')}\n\nTo check these, add explicit --ui-* aliases in your theme file:\n :root { --ui-text: var(--color-text); --ui-bg: var(--color-bg); }\nOr run this tool with theme.css content that includes --ui-text/--ui-bg definitions.`
3067
+ }
3068
+ } catch { /* ignore */ }
3069
+ }
3070
+
3071
+ return text(`⚠ CHECKED 0 PAIRINGS — this is NOT "all clear". The checker found no colour variable pairs to test.\n\nThis typically means your theme uses custom token names (e.g. --color-text, --color-sunburst) rather than --ui-text / --ui-bg. The checker auto-maps bare tokens (--text, --bg, --accent, --muted, --surface, --border, --heading, --accent-text) but NOT arbitrary custom names.\n\nThe checker looked for --ui-* token pairs (e.g. --ui-text, --ui-bg, --ui-accent). Make sure variables are inside a :root { } or [data-theme="light"] { } block.\n\nYou must check contrast manually (e.g. via WebAIM) or alias your tokens:\n :root { --ui-text: var(--color-text); --ui-bg: var(--color-bg); }\n\nDo NOT skip contrast checking and proceed to Lighthouse — 11 contrast failures have been caught at Lighthouse stage after a 0-pairing result here.${appCssHints}`)
3072
+ }
3073
+
3074
+ const failures = allResults.filter(r => !r.pass)
3075
+ const passes = allResults.filter(r => r.pass)
3076
+
3077
+ const lines = [`# Contrast check — ${failures.length} failure${failures.length !== 1 ? 's' : ''}, ${passes.length} pass${passes.length !== 1 ? 'es' : ''}\n`]
3078
+
3079
+ if (failures.length > 0) {
3080
+ lines.push('## ✗ Failures (must fix before shipping)\n')
3081
+ for (const r of failures) {
3082
+ lines.push(`**${r.label}** — ${r.context}`)
3083
+ lines.push(` ${r.fg} on ${r.bg}`)
3084
+ lines.push(` Ratio: ${r.ratio}:1 · Needed: ${r.threshold}:1 · Level: ${r.level}`)
3085
+
3086
+ // Suggest a corrected hex value
3087
+ if (r.fgHex && r.bgLum !== null) {
3088
+ const suggested = suggestContrastFix(r.fgHex, r.bgLum, r.threshold)
3089
+ if (suggested) {
3090
+ lines.push(` 💡 Suggested fix: use ${suggested} instead of ${r.fgHex}`)
3091
+ }
3092
+ }
3093
+ lines.push('')
3094
+ }
3095
+ }
3096
+
3097
+ if (passes.length > 0) {
3098
+ lines.push('## ✓ Passing\n')
3099
+ for (const r of passes) {
3100
+ lines.push(`✓ ${r.level} ${r.ratio}:1 — ${r.label} (${r.context})`)
3101
+ }
3102
+ lines.push('')
3103
+ }
3104
+
3105
+ if (failures.length > 0) {
3106
+ lines.push('---')
3107
+ lines.push('**Fix guidance:**')
3108
+ lines.push('• Mid-tone accents on near-white backgrounds often fail 4.5:1. Darken the accent token or use it only for large text/icons (3:1 threshold).')
3109
+ lines.push('• For `--ui-muted` failures: muted text on `--ui-bg` needs at minimum a 4.5:1 ratio. Adjust the muted tone.')
3110
+ lines.push('• Light-theme `--ui-accent` must be distinctly darker than the page background — many default accent hues are too light.')
3111
+ lines.push('• After fixing, run this tool again before `pulse_build`.')
3112
+ }
3113
+
3114
+ return text(lines.join('\n'))
3115
+ }
3116
+ )
3117
+
3118
+ // ---------------------------------------------------------------------------
3119
+ // pulse_update
3120
+ // ---------------------------------------------------------------------------
3121
+
3122
+ server.registerTool(
3123
+ 'pulse_update',
3124
+ {
3125
+ description: 'Install the latest @invisibleloop/pulse package (skipped if npm-linked), then re-copy pulse-ui.css, pulse-ui.js, and the agent checklist into public/. One command does the full upgrade.',
3126
+ inputSchema: {},
3127
+ },
3128
+ async () => {
3129
+ // 1. npm install latest — but only if the package is NOT npm-linked.
3130
+ // An npm link is a symlink in node_modules. Installing @latest over a
3131
+ // link downgrades the project to the published version even when the
3132
+ // developer has a newer local build linked. Detect the symlink and skip.
3133
+ const { execSync } = await import('child_process')
3134
+ const pkgDir = path.join(ROOT, 'node_modules', '@invisibleloop', 'pulse')
3135
+ const isLinked = fs.existsSync(pkgDir) && fs.lstatSync(pkgDir).isSymbolicLink()
3136
+
3137
+ if (!isLinked) {
3138
+ try {
3139
+ execSync('npm install @invisibleloop/pulse@latest', { cwd: ROOT, stdio: 'pipe' })
3140
+ } catch (e) {
3141
+ return text(`npm install failed:\n${e.stderr?.toString() || e.message}`)
3142
+ }
3143
+ }
3144
+
3145
+ // 2. Copy assets from the package in node_modules (installed or linked).
3146
+ const pkgPublic = path.join(ROOT, 'node_modules', '@invisibleloop', 'pulse', 'public')
3147
+ const publicDir = path.join(ROOT, 'public')
3148
+ const assets = ['pulse-ui.css', 'pulse-ui.js', '.pulse-ui-version']
3149
+ const updated = []
3150
+
3151
+ fs.mkdirSync(publicDir, { recursive: true })
3152
+ for (const asset of assets) {
3153
+ const src = path.join(pkgPublic, asset)
3154
+ const dst = path.join(publicDir, asset)
3155
+ if (fs.existsSync(src)) { fs.copyFileSync(src, dst); updated.push(`public/${asset}`) }
3156
+ }
3157
+
3158
+ const checklistSrc = path.join(ROOT, 'node_modules', '@invisibleloop', 'pulse', 'src', 'agent', 'checklist.md')
3159
+ const checklistDst = path.join(ROOT, '.claude', 'pulse-checklist.md')
3160
+ if (fs.existsSync(checklistSrc)) {
3161
+ fs.mkdirSync(path.dirname(checklistDst), { recursive: true })
3162
+ fs.copyFileSync(checklistSrc, checklistDst)
3163
+ updated.push('.claude/pulse-checklist.md')
3164
+ }
3165
+
3166
+ const versionFile = path.join(publicDir, '.pulse-ui-version')
3167
+ const version = fs.existsSync(versionFile) ? fs.readFileSync(versionFile, 'utf8').trim() : '?'
3168
+ return text(`Pulse updated to v${version}\n\n${updated.map(f => `✓ ${f}`).join('\n')}`)
3169
+ }
3170
+ )
3171
+
3172
+ // ---------------------------------------------------------------------------
3173
+ // Helpers
3174
+ // ---------------------------------------------------------------------------
3175
+
3176
+ /** Poll until the HTTP server on the given port accepts a connection, or timeout. */
3177
+ async function waitForServer(port, maxMs = 10_000) {
3178
+ const deadline = Date.now() + maxMs
3179
+ while (Date.now() < deadline) {
3180
+ const alive = await new Promise(resolve => {
3181
+ const req = http.get(`http://localhost:${port}/`, { timeout: 500 }, res => {
3182
+ res.resume()
3183
+ resolve(true)
3184
+ })
3185
+ req.on('error', () => resolve(false))
3186
+ req.on('timeout', () => { req.destroy(); resolve(false) })
3187
+ })
3188
+ if (alive) return true
3189
+ await new Promise(r => setTimeout(r, 300))
3190
+ }
3191
+ return false
3192
+ }
3193
+
3194
+ // Common prop aliases that agents and developers pass by mistake.
3195
+ // Maps wrong name → { correct, component } for each well-known component.
3196
+ const PROP_ALIASES = [
3197
+ { component: 'nav()', wrong: 'brand', correct: 'logo', note: 'The nav logo is set with the `logo` prop, not `brand`.' },
3198
+ { component: 'nav()', wrong: 'actions', correct: 'action', note: 'nav() takes a single `action` string (HTML), not an array.' },
3199
+ { component: 'footer()', wrong: 'items', correct: 'links', note: 'footer() top-level links use `links: [{ label, href }]`, not `items`.' },
3200
+ { component: 'footer()', wrong: 'nav', correct: 'links', note: 'footer() links prop is `links`, not `nav`.' },
3201
+ { 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" } }).' },
3202
+ { component: 'input()', wrong: 'maxlength', correct: 'attrs: { maxlength }', note: 'HTML attributes go inside `attrs`: input({ attrs: { maxlength: "100" } }).' },
3203
+ { component: 'input()', wrong: 'minlength', correct: 'attrs: { minlength }', note: 'HTML attributes go inside `attrs`: input({ attrs: { minlength: "2" } }).' },
3204
+ { component: 'input()', wrong: 'pattern', correct: 'attrs: { pattern }', note: 'HTML attributes go inside `attrs`: input({ attrs: { pattern: "[0-9]+" } }).' },
3205
+ ]
3206
+
3207
+ async function validateContent(content) {
3208
+ // Source-level checks — run before the worker so errors are caught without importing
3209
+ const sourceWarnings = []
3210
+
3211
+ // Component prop alias checks — flag known wrong prop names
3212
+ for (const { component, wrong, correct, note } of PROP_ALIASES) {
3213
+ // Match: nav({ ...wrong: or footer({ ...wrong: (within the call args)
3214
+ const fnName = component.replace('()', '')
3215
+ const re = new RegExp(`${fnName}\\s*\\(\\s*\\{[^}]*\\b${wrong}\\s*:`, 'g')
3216
+ if (re.test(content)) {
3217
+ sourceWarnings.push(`"${wrong}" is not a recognised prop for ${component} — did you mean "${correct}"? ${note}`)
3218
+ }
3219
+ }
3220
+
3221
+ // External image URL check — warn if spec contains external image domains not
3222
+ // commonly whitelisted in CSP. Caught here prevents a Lighthouse Best Practices failure.
3223
+ const externalImgHosts = [
3224
+ {
3225
+ pattern: /https?:\/\/images\.unsplash\.com/,
3226
+ entry: 'https://images.unsplash.com',
3227
+ cookieWarning: true,
3228
+ name: 'Unsplash',
3229
+ },
3230
+ {
3231
+ pattern: /https?:\/\/(?:fastly\.)?picsum\.photos/,
3232
+ entry: 'https://picsum.photos https://fastly.picsum.photos',
3233
+ cookieWarning: false,
3234
+ name: 'picsum',
3235
+ },
3236
+ {
3237
+ pattern: /https?:\/\/res\.cloudinary\.com/,
3238
+ entry: 'https://res.cloudinary.com',
3239
+ cookieWarning: true,
3240
+ name: 'Cloudinary',
3241
+ },
3242
+ {
3243
+ pattern: /https?:\/\/cdn\.shopify\.com/,
3244
+ entry: 'https://cdn.shopify.com',
3245
+ cookieWarning: false,
3246
+ name: 'Shopify CDN',
3247
+ },
3248
+ ]
3249
+ const configPath = path.join(ROOT, 'pulse.config.js')
3250
+ let configImgSrc = ''
3251
+ if (fs.existsSync(configPath)) {
3252
+ try { configImgSrc = fs.readFileSync(configPath, 'utf8') } catch { /* ignore */ }
3253
+ }
3254
+ for (const { pattern, entry, cookieWarning, name } of externalImgHosts) {
3255
+ if (pattern.test(content) && !pattern.test(configImgSrc)) {
3256
+ const cookieNote = cookieWarning
3257
+ ? `\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.`
3258
+ : ''
3259
+ sourceWarnings.push(
3260
+ `External image host detected (${name}). Add it to csp.img-src in pulse.config.js before running Lighthouse:\n` +
3261
+ ` csp: { 'img-src': ['${entry}'] }\n` +
3262
+ ` Without this, images will be blocked and Lighthouse Best Practices will fail.${cookieNote}`
3263
+ )
3264
+ }
3265
+ }
3266
+
3267
+ // Write into PAGES_DIR so relative imports (e.g. '../components/nav.js') resolve correctly
3268
+ fs.mkdirSync(PAGES_DIR, { recursive: true })
3269
+ const tmpFile = path.join(PAGES_DIR, `.pulse-validate-${Date.now()}.mjs`)
3270
+ try {
3271
+ fs.writeFileSync(tmpFile, content, 'utf8')
3272
+
3273
+ // Run validation in a child process with a hard timeout so a hanging import
3274
+ // (slow module, circular dep, network call) cannot block the MCP server.
3275
+ const validatorScript = new URL('./validate-worker.js', import.meta.url).pathname
3276
+ let output
3277
+ try {
3278
+ output = execFileSync(process.execPath, [validatorScript, tmpFile], {
3279
+ timeout: 10_000,
3280
+ encoding: 'utf8',
3281
+ })
853
3282
  } catch (err) {
854
3283
  const msg = err.killed || err.signal === 'SIGTERM'
855
3284
  ? 'Invalid: validation timed out — spec may have a hanging import or infinite loop'
@@ -857,7 +3286,24 @@ async function validateContent(content) {
857
3286
  return text(msg)
858
3287
  }
859
3288
 
860
- return text(output.trim())
3289
+ let finalOutput = output.trim()
3290
+ // Merge source-level prop warnings into the worker output
3291
+ if (sourceWarnings.length > 0) {
3292
+ const propNotes = sourceWarnings.map(w => ` ⚠ ${w}`).join('\n')
3293
+ if (finalOutput.startsWith('Valid ✓')) {
3294
+ finalOutput = finalOutput.replace('Valid ✓', 'Valid ✓ — but fix these issues:').replace(' — but fix these issues: — but fix these issues:', ' — but fix these issues:')
3295
+ finalOutput = finalOutput + '\n' + propNotes
3296
+ } else {
3297
+ finalOutput = finalOutput + '\n' + propNotes
3298
+ }
3299
+ }
3300
+
3301
+ // Append browser check reminder on clean pass so agents don't skip Lighthouse
3302
+ if (finalOutput.startsWith('Valid ✓') && !finalOutput.includes('Invalid')) {
3303
+ 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.'
3304
+ }
3305
+
3306
+ return text(finalOutput)
861
3307
  } finally {
862
3308
  try { fs.unlinkSync(tmpFile) } catch { /* ignore */ }
863
3309
  }
@@ -895,7 +3341,10 @@ const GUIDE_STYLES = fs.readFileSync(new URL('../agent/guide-styles.md',
895
3341
  const GUIDE_ROUTING = fs.readFileSync(new URL('../agent/guide-routing.md', import.meta.url), 'utf8')
896
3342
  const GUIDE_COMPONENTS = fs.readFileSync(new URL('../agent/guide-components.md', import.meta.url), 'utf8')
897
3343
  const GUIDE_EXAMPLES = fs.readFileSync(new URL('../agent/guide-examples.md', import.meta.url), 'utf8')
898
- const GUIDE_TEMPLATES = fs.readFileSync(new URL('../agent/guide-templates.md', import.meta.url), 'utf8')
3344
+ const GUIDE_TEMPLATES = fs.readFileSync(new URL('../agent/guide-templates.md', import.meta.url), 'utf8')
3345
+ const GUIDE_DESIGN_REF = fs.readFileSync(new URL('../agent/guide-design-references.md', import.meta.url), 'utf8')
3346
+ const GUIDE_DESIGN_GALL = fs.readFileSync(new URL('../agent/guide-design-gallery.md', import.meta.url), 'utf8')
3347
+ const GUIDE_EXPLORE = fs.readFileSync(new URL('../agent/guide-explore.md', import.meta.url), 'utf8')
899
3348
 
900
3349
  // ---------------------------------------------------------------------------
901
3350
  // The Persona — CLI-specific identity header + shared identity content
@@ -928,7 +3377,8 @@ const PULSE_GUIDE_INDEX = `# Pulse Framework Guide
928
3377
 
929
3378
  | Resource | When to fetch |
930
3379
  |---|---|
931
- | \`pulse://workflow\` | **First. Always. Before any guide sections or code.** |
3380
+ | \`pulse://quickstart\` | **Editing, bug-fixing, or a simple one-shot build** — workflow + spec skeleton + components + theming in one fetch. Skip the 4-resource cold-start for targeted tasks. |
3381
+ | \`pulse://workflow\` | **New project or new page** — full phase/gate sequence before writing any code. |
932
3382
  | \`pulse://guide/spec\` | Building a spec — state, mutations, actions, streaming SSR, key rules, form layout |
933
3383
  | \`pulse://guide/server\` | Server data, global store, persist, cookies, redirects, POST handling |
934
3384
  | \`pulse://guide/styles\` | CSS tokens, theming, custom fonts, utility classes |
@@ -936,10 +3386,22 @@ const PULSE_GUIDE_INDEX = `# Pulse Framework Guide
936
3386
  | \`pulse://guide/components\` | All UI components, icons, charts, composition patterns |
937
3387
  | \`pulse://guide/examples\` | Complete working page examples |
938
3388
  | \`pulse://guide/templates\` | **Fetch when asked to build a landing page or branded template.** Pre-build questions, template inventory, adaptation rules, theme CSS patterns. |
3389
+ | \`pulse://guide/design-references\` | **Fetch when choosing a design aesthetic.** 12 design directions with vibes, component combos, palette patterns, and signature moves. Use at intake time to pick the right direction, not SaaS-by-default. |
3390
+ | \`pulse://guide/design-gallery\` | **Fetch when adapting a template or combining components.** All 6 templates with visual descriptions + key components. Critical prop-name reference (content vs children, name vs author, etc.). Component recipes: image card, article card, stat strip, booking form. |
3391
+ | \`pulse://guide/explore\` | **Fetch when you want a distinctive or unusual layout.** Zone-based layout thinking, 7 structural gestures (full-bleed, asymmetric, typography-only, editorial, dense grid, story scroll, content-first), raw HTML patterns with zero components, CSS token reference, anti-pattern checklist. |
939
3392
 
940
3393
  ## Tools available
941
3394
 
942
3395
  **Pulse MCP tools** (always available):
3396
+ - \`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.**
3397
+ - \`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.
3398
+ - \`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.
3399
+ - \`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.
3400
+ - \`pulse_suggest(content)\` — **Draft-mode feedback.** Paste a partial or complete spec and get constructive, non-blocking suggestions: missing pieces, likely omissions, component upgrades, empty-state reminders. A collaborator, not a gate. Use mid-build before running the hard validator.
3401
+ - \`pulse_list_icons(filter?)\` — **List all available icon names grouped by category.** Always call this before importing icons — never guess a name or grep source files. Optional filter keyword narrows results (e.g. filter: "arrow").
3402
+ - \`pulse_check_contrast(css?, file?, theme?)\` — **Static WCAG contrast check.** Provide theme CSS content or a file path; it checks all token color pairings against WCAG AA thresholds (4.5:1 normal text, 3:1 large text/UI). Run immediately after writing a theme file — before pulse_build and Lighthouse. Catches palette mistakes in milliseconds.
3403
+ - \`pulse_tokens\` — **List all --ui-* CSS tokens** from the installed pulse-ui.css, grouped by category (colour, spacing, type-size, typography, shape). Call before writing theme overrides to avoid guessing names. Reminder: set \`--accent\` (no prefix) in theme.css — pulse-ui maps it to \`--ui-accent\` automatically.
3404
+ - \`pulse_status\` — **Project health snapshot.** Returns page count, routes, dev server status, last build age, and pulse-ui version check. Call at the start of a session to orient quickly without reading files.
943
3405
  - \`pulse_list_structure\` — list pages, components, and pulse-ui version. Call at the start of every session.
944
3406
  - \`pulse_validate\` — validate spec content. Call after every write. Fix all errors AND warnings.
945
3407
  - \`pulse_review\` — switch into reviewer mode and critically examine a spec you just built. Returns the source, rendered HTML, validator output, and a full review checklist. **Call this only after validate, Lighthouse (desktop + mobile), and tests all pass — it is the final phase before declaring done.**