@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/cli/index.js CHANGED
@@ -91,7 +91,7 @@ function isDirEmpty(dir) {
91
91
  return fs.readdirSync(dir).length === 0
92
92
  }
93
93
 
94
- async function runDefault(root) {
94
+ async function runDefault(root, agentFlag = null) {
95
95
  if (!isPulseProject(root)) {
96
96
  console.log(`\n⚡ No Pulse project found here.\n`)
97
97
 
@@ -169,13 +169,14 @@ async function launchSession(root, agentOverride = null) {
169
169
  }
170
170
  }
171
171
 
172
- console.log(`\n⚡ Pulse project: ${root}`)
173
- console.log(` Use /pulse-dev to start the dev server, /pulse-stop to stop it, /pulse-build to build, /pulse-start to run production.`)
174
- console.log(` Tell me what you'd like to build — a new page, a component, a form, or anything else.\n`)
172
+ console.log(`\n ⚡ Pulse → ${root}`)
173
+ console.log(` ${agent === 'copilot' ? '● Copilot' : '● Claude'} agent starting — tell me what you'd like to build.\n`)
175
174
 
176
175
  if (agent === 'copilot') {
176
+ process.env.PULSE_AGENT_MODE = '1'
177
177
  await launchCopilotSession(root, mcpServerPath, spawn, os.default)
178
178
  } else {
179
+ process.env.PULSE_AGENT_MODE = '1'
179
180
  await launchClaudeSession(root, mcpConfig, spawn, os.default)
180
181
  }
181
182
  }
@@ -343,7 +344,11 @@ async function runUpdate(root) {
343
344
  process.exit(1)
344
345
  }
345
346
 
347
+ // Copy assets from the package this CLI binary lives in (import.meta.url).
348
+ // When globally npm-linked to the dev repo, this resolves to dev source.
349
+ // When installed normally, this resolves to the installed version.
346
350
  const pkgPublic = new URL('../../public', import.meta.url).pathname
351
+ const pkgSrc = new URL('..', import.meta.url).pathname
347
352
  const assets = ['pulse-ui.css', 'pulse-ui.js', '.pulse-ui-version']
348
353
  const publicDir = path.join(root, 'public')
349
354
  const updated = []
@@ -359,11 +364,10 @@ async function runUpdate(root) {
359
364
 
360
365
  // Sync agent files into .claude/
361
366
  const agentFiles = [
362
- ['../agent/checklist.md', 'pulse-checklist.md'],
363
- ['../agent/coverage-check.js', 'coverage-check.js'],
367
+ [path.join(pkgSrc, 'agent', 'checklist.md'), 'pulse-checklist.md'],
368
+ [path.join(pkgSrc, 'agent', 'coverage-check.js'), 'coverage-check.js'],
364
369
  ]
365
- for (const [rel, dst] of agentFiles) {
366
- const src = new URL(rel, import.meta.url).pathname
370
+ for (const [src, dst] of agentFiles) {
367
371
  if (fs.existsSync(src)) {
368
372
  const dstPath = path.join(root, '.claude', dst)
369
373
  fs.mkdirSync(path.dirname(dstPath), { recursive: true })
@@ -373,7 +377,7 @@ async function runUpdate(root) {
373
377
  }
374
378
 
375
379
  // Sync slash commands into .claude/commands/
376
- const commandsSrc = new URL('../agent/commands', import.meta.url).pathname
380
+ const commandsSrc = path.join(pkgSrc, 'agent', 'commands')
377
381
  const commandsDst = path.join(root, '.claude', 'commands')
378
382
  if (fs.existsSync(commandsSrc)) {
379
383
  fs.mkdirSync(commandsDst, { recursive: true })
@@ -410,9 +414,16 @@ async function runUpdate(root) {
410
414
  const versionFile = path.join(publicDir, '.pulse-ui-version')
411
415
  const version = fs.existsSync(versionFile) ? fs.readFileSync(versionFile, 'utf8').trim() : '?'
412
416
 
413
- console.log(`\n⚡ Pulse updated to ${version}\n`)
414
- for (const f of updated) console.log(` ✓ ${f}`)
415
- for (const f of missing) console.log(` ✗ ${f} not found in package`)
417
+ const { c, ok: fmtOk, fail: fmtFail, icon } = await import('./fmt.js').catch(() => ({
418
+ c: { dim: s => s, bold: s => s },
419
+ ok: s => ` ✓ ${s}`,
420
+ fail: s => ` ✗ ${s}`,
421
+ icon: { bolt: () => '⚡' },
422
+ }))
423
+
424
+ console.log(`\n ${icon.bolt()} ${c.bold('Pulse')} updated to ${c.bold(version)}\n`)
425
+ for (const f of updated) console.log(fmtOk(f))
426
+ for (const f of missing) console.log(fmtFail(`${f} not found in package`))
416
427
  console.log()
417
428
  }
418
429
 
@@ -446,29 +457,37 @@ switch (command) {
446
457
  process.exit(0)
447
458
  }
448
459
  case '--help':
449
- case '-h':
460
+ case '-h': {
461
+ const { c, icon } = await import('./fmt.js').catch(() => ({
462
+ c: { bold: s => s, dim: s => s, cyan: s => s, purple: s => s, white: s => s },
463
+ icon: { bolt: () => '⚡' },
464
+ }))
450
465
  console.log(`
451
- ⚡ Pulse — spec-first frontend framework
452
-
453
- Usage: pulse [command] [options]
454
-
455
- Commands:
456
- (none) detect project or start scaffold wizard
457
- dev start dev server
458
- build production build → public/dist/
459
- start production server (requires prior build)
460
- update re-copy pulse-ui assets from installed package → public/
461
-
462
- Options:
463
- --agent <name> AI agent to use: claude (default) or copilot
464
- -v, --version print version and exit
465
- -h, --help show this help
466
-
467
- Examples:
468
- pulse start with Claude (default)
469
- pulse --agent copilot start with GitHub Copilot CLI
466
+ ${icon.bolt()} ${c.bold('Pulse')} ${c.dim('— spec-first frontend framework')}
467
+
468
+ ${c.bold('Usage:')} pulse ${c.dim('[command] [options]')}
469
+
470
+ ${c.bold('Commands:')}
471
+ ${c.cyan('(none)')} detect project, or launch the scaffold wizard
472
+ ${c.cyan('dev')} start the dev server
473
+ ${c.cyan('build')} production build ${c.dim('→ public/dist/')}
474
+ ${c.cyan('start')} production server ${c.dim('(requires prior build)')}
475
+ ${c.cyan('update')} re-copy pulse-ui assets from installed package
476
+
477
+ ${c.bold('Options:')}
478
+ ${c.cyan('--agent')} ${c.dim('<name>')} use specific agent ${c.dim('(claude | copilot)')}
479
+ ${c.cyan('-v')}, ${c.cyan('--version')} print version and exit
480
+ ${c.cyan('-h')}, ${c.cyan('--help')} show this help
481
+
482
+ ${c.bold('Examples:')}
483
+ ${c.dim('pulse # new project wizard')}
484
+ ${c.dim('pulse --agent copilot # launch with GitHub Copilot CLI')}
485
+ ${c.dim('pulse dev # dev server')}
486
+ ${c.dim('pulse build # bundle for production')}
487
+ ${c.dim('pulse start # serve production build')}
470
488
  `)
471
489
  process.exit(0)
490
+ }
472
491
  case 'dev':
473
492
  await runDev(CWD)
474
493
  break
@@ -496,6 +515,14 @@ switch (command) {
496
515
  case 'update':
497
516
  await runUpdate(CWD)
498
517
  break
518
+ case 'link': {
519
+ // DEV-ONLY: npm link the local dev repo into this project, then pulse update.
520
+ const { execSync } = await import('child_process')
521
+ console.log('\n Linking @invisibleloop/pulse from dev repo…\n')
522
+ execSync('npm link @invisibleloop/pulse', { cwd: CWD, stdio: 'inherit' })
523
+ await runUpdate(CWD)
524
+ break
525
+ }
499
526
  default:
500
- await runDefault(CWD)
527
+ await runDefault(CWD, agentFlag)
501
528
  }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Pulse CLI — structured terminal logger
3
+ *
4
+ * Single module that owns all terminal output. Produces clean, scannable
5
+ * output: one-time startup banner, then a timestamped scrolling log.
6
+ *
7
+ * Rules:
8
+ * - Static asset requests (JS, CSS, images, fonts) are dimmed and collapsed
9
+ * - Errors always stand out (red ✗ prefix)
10
+ * - Agent activity gets a distinct indicator
11
+ * - Respects NO_COLOR and CI env vars
12
+ */
13
+
14
+ import { c, icon, elapsed, visLen } from './fmt.js'
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Helpers
18
+ // ---------------------------------------------------------------------------
19
+
20
+ const STATIC_EXT = /\.(js|css|map|ico|png|jpg|jpeg|gif|svg|woff2?|ttf|eot|webp|avif|json)$/i
21
+ const STATIC_PATH = /^\/(dist|fonts|images|public)\//
22
+
23
+ function isStatic(pathname) {
24
+ return STATIC_EXT.test(pathname) || STATIC_PATH.test(pathname)
25
+ }
26
+
27
+ function timestamp() {
28
+ return c.dim(new Date().toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' }))
29
+ }
30
+
31
+ function statusColor(code) {
32
+ if (code >= 500) return c.red(String(code))
33
+ if (code >= 400) return c.yellow(String(code))
34
+ if (code >= 300) return c.cyan(String(code))
35
+ return c.dim(String(code))
36
+ }
37
+
38
+ function methodColor(method) {
39
+ if (method === 'POST' || method === 'PUT' || method === 'PATCH') return c.yellow(method.padEnd(4))
40
+ if (method === 'DELETE') return c.red(method.padEnd(4))
41
+ return c.dim(method.padEnd(4))
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Banner — printed once at startup
46
+ // ---------------------------------------------------------------------------
47
+
48
+ /**
49
+ * Print the startup banner.
50
+ * @param {{ url: string, version: string, specs: Array<{route:string, method?:string, hydrate?:string}>, agentMode?: boolean }} opts
51
+ */
52
+ export function banner({ url, version, specs = [], agentMode = false }) {
53
+ const agentLabel = agentMode ? ` ${c.cyan('●')} ${c.dim('Agent active')}` : ''
54
+
55
+ process.stdout.write('\n')
56
+ process.stdout.write(` ${icon.bolt()} ${c.bold('Pulse')} ${c.dim(`v${version}`)} ${c.dim('→')} ${c.cyan(url)}${agentLabel}\n`)
57
+ process.stdout.write('\n')
58
+
59
+ if (specs.length > 0) {
60
+ // Column widths
61
+ const routeW = Math.max(5, ...specs.map(s => (s.route || '?').length))
62
+ const methodW = 6
63
+
64
+ const header = ` ${c.dim('Route'.padEnd(routeW))} ${c.dim('Method'.padEnd(methodW))} ${c.dim('JS')}`
65
+ const divider = ` ${c.dim('─'.repeat(routeW + methodW + 8))}`
66
+
67
+ process.stdout.write(header + '\n')
68
+ process.stdout.write(divider + '\n')
69
+
70
+ for (const s of specs) {
71
+ const route = (s.route || '?').padEnd(routeW)
72
+ const method = (s.method?.toUpperCase() || 'GET').padEnd(methodW)
73
+ const hydrate = s.hydrate ? c.green('✓ hydrated') : c.dim('— static')
74
+ process.stdout.write(` ${c.cyan(route)} ${c.dim(method)} ${hydrate}\n`)
75
+ }
76
+
77
+ process.stdout.write('\n')
78
+ }
79
+
80
+ process.stdout.write(c.dim(' Ready. Watching for changes…\n'))
81
+ process.stdout.write(c.dim(' ' + '─'.repeat(48) + '\n'))
82
+ process.stdout.write('\n')
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Request log — one line per request
87
+ // ---------------------------------------------------------------------------
88
+
89
+ /**
90
+ * Log a completed HTTP request.
91
+ * @param {{ method: string, pathname: string, status: number, ms: number }} opts
92
+ */
93
+ export function request({ method, pathname, status, ms }) {
94
+ const isAsset = isStatic(pathname)
95
+
96
+ // Skip health check noise
97
+ if (pathname === '/healthz' || pathname === '/_pulse/health') return
98
+
99
+ const ts = timestamp()
100
+ const m = methodColor(method)
101
+ const p = isAsset ? c.dim(pathname) : pathname
102
+ const s = statusColor(status)
103
+ const t = c.dim(elapsed(ms))
104
+
105
+ if (isAsset) {
106
+ // Static assets — dim the whole line, no leading symbol
107
+ process.stdout.write(`${ts} ${c.dim(method.padEnd(4))} ${p} ${c.dim(String(status))} ${t}\n`)
108
+ } else if (status >= 500) {
109
+ process.stdout.write(`${ts} ${icon.fail()} ${m} ${c.red(pathname)} ${s} ${t}\n`)
110
+ } else if (status >= 400) {
111
+ process.stdout.write(`${ts} ${icon.warn()} ${m} ${c.yellow(pathname)} ${s} ${t}\n`)
112
+ } else {
113
+ process.stdout.write(`${ts} ${icon.info()} ${m} ${p} ${s} ${t}\n`)
114
+ }
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Named events — server lifecycle
119
+ // ---------------------------------------------------------------------------
120
+
121
+ export function ready(url) {
122
+ process.stdout.write(`${timestamp()} ${icon.ok()} ${c.dim('Server ready')} ${c.dim(url)}\n`)
123
+ }
124
+
125
+ export function shutdown(forced = false) {
126
+ process.stdout.write('\n')
127
+ if (forced) {
128
+ process.stdout.write(` ${icon.fail()} ${c.red('Force-exiting after shutdown timeout')}\n`)
129
+ } else {
130
+ process.stdout.write(` ${icon.info()} ${c.dim('Shutting down gracefully…')}\n`)
131
+ }
132
+ }
133
+
134
+ export function error(msg, err) {
135
+ process.stdout.write(`${timestamp()} ${icon.fail()} ${c.red(msg)}\n`)
136
+ if (err?.stack) process.stdout.write(c.dim(err.stack.split('\n').slice(1, 4).map(l => ' ' + l).join('\n')) + '\n')
137
+ }
138
+
139
+ export function info(msg) {
140
+ process.stdout.write(`${timestamp()} ${icon.info()} ${c.dim(msg)}\n`)
141
+ }
142
+
143
+ export function warn(msg) {
144
+ process.stdout.write(`${timestamp()} ${icon.warn()} ${c.yellow(msg)}\n`)
145
+ }
@@ -27,6 +27,7 @@ export async function scaffold(targetDir, options = {}) {
27
27
  fs.mkdirSync(path.join(targetDir, 'src', 'pages'), { recursive: true })
28
28
  fs.mkdirSync(path.join(targetDir, 'src', 'components'), { recursive: true })
29
29
  fs.mkdirSync(path.join(targetDir, 'public'), { recursive: true })
30
+ fs.mkdirSync(path.join(targetDir, 'public', 'intake'), { recursive: true })
30
31
 
31
32
  // package.json
32
33
  write(targetDir, 'package.json', JSON.stringify({
@@ -62,6 +63,9 @@ ${port !== 3000 ? ` port: ${port},\n` : ''}${agent && agent !== 'claude' ? ` a
62
63
  write(targetDir, 'public/theme.css', themeCSS())
63
64
  write(targetDir, 'public/app.css', baseCSS())
64
65
 
66
+ // Intake directory — drop inspiration images here before running pulse_intake
67
+ write(targetDir, 'public/intake/README.md', intakeReadme())
68
+
65
69
  // Copy pulse-ui assets from the package into public/ so they're served immediately
66
70
  const pkgPublic = new URL('../../public', import.meta.url).pathname
67
71
  for (const asset of ['pulse-ui.css', 'pulse-ui.js']) {
@@ -124,13 +128,18 @@ ${port !== 3000 ? ` port: ${port},\n` : ''}${agent && agent !== 'claude' ? ` a
124
128
  hooks: [
125
129
  {
126
130
  type: 'command',
127
- command: `node -e "process.stdout.write(JSON.stringify({hookSpecificOutput:{hookEventName:'SessionStart',additionalContext:'START OF SESSION: Before doing anything else — (1) run pulse_list_structure to see the current project structure, (2) read pulse://guide from MCP for the complete Pulse reference. Both are mandatory. Do not skip them.'}}))"`,
131
+ command: `node -e "process.stdout.write(JSON.stringify({hookSpecificOutput:{hookEventName:'SessionStart',additionalContext:'START OF SESSION: Before doing anything else — (1) run pulse_list_structure to see the current project structure, (2) read pulse://guide from MCP for the complete Pulse reference. Both are mandatory. Do not skip them.\\n\\nBUILD RULE — ALWAYS ASK BEFORE PLANNING: If the user is requesting a new page or site, your FIRST action must be to ask: \\'Do you have any design inspiration — a site you love, a screenshot, a mood board image? Drop images into public/intake/ or paste a URL and I will extract the design intent before we start.\\' Do not present a plan or write any code until you have asked this question and received an answer. This question is not optional and cannot be skipped.'}}))"`,
128
132
  statusMessage: 'Loading Pulse session...',
129
133
  },
130
134
  {
131
135
  type: 'command',
132
136
  command: `node -e "const{execSync}=require('child_process');let out='';let ok=true;try{out=execSync('npm test 2>&1',{encoding:'utf8',timeout:60000});}catch(e){ok=false;out=e.stdout||e.message;}const lines=out.trim().split('\\n');const summary=lines[lines.length-1]||'';const msg=ok?'TEST BASELINE — all tests passing: '+summary+'. Note this result. If tests fail after your changes, you introduced the regression.':'PRE-EXISTING TEST FAILURES detected before you wrote any code:\\n'+out.slice(-2000)+'\\nDo not treat these as regressions you caused. Fix them first if they are related to your task, otherwise note them and proceed.';process.stdout.write(JSON.stringify({hookSpecificOutput:{hookEventName:'SessionStart',additionalContext:msg}}))"`,
133
137
  statusMessage: 'Running test baseline...',
138
+ },
139
+ {
140
+ type: 'command',
141
+ command: `node -e "const fs=require('fs');const path=require('path');const intakeDir='public/intake';let images=[];try{images=fs.readdirSync(intakeDir).filter(f=>/\\.(jpe?g|png|gif|webp|avif)$/i.test(f));}catch{}if(!images.length){process.exit(0);}const list=images.map(f=>' • '+f).join('\\n');process.stdout.write(JSON.stringify({hookSpecificOutput:{hookEventName:'SessionStart',additionalContext:'DESIGN REFERENCES FOUND in public/intake/:\\n'+list+'\\n\\nBefore running pulse_intake, call pulse_extract_inspiration on each image to extract palette, vibe, layout density, and type signals. This gives the design much more precision than text descriptions alone.\\n\\nWorkflow: pulse_extract_inspiration(image) → fill in the template using your vision → feed results into pulse_intake(inspiration: \\'...\\')'}}));"`,
142
+ statusMessage: 'Checking for design references...',
134
143
  }
135
144
  ]
136
145
  }
@@ -209,6 +218,13 @@ ${port !== 3000 ? ` port: ${port},\n` : ''}${agent && agent !== 'claude' ? ` a
209
218
  'public/dist',
210
219
  '.pulse-build',
211
220
  '.DS_Store',
221
+ '# Intake images — local design references, not committed',
222
+ 'public/intake/*.jpg',
223
+ 'public/intake/*.jpeg',
224
+ 'public/intake/*.png',
225
+ 'public/intake/*.gif',
226
+ 'public/intake/*.webp',
227
+ 'public/intake/*.avif',
212
228
  ].join('\n') + '\n')
213
229
 
214
230
  console.log(' ✓ Project files created')
@@ -349,6 +365,42 @@ function themeCSS() {
349
365
  `
350
366
  }
351
367
 
368
+ function intakeReadme() {
369
+ return `\
370
+ # Design References
371
+
372
+ Drop inspiration images here before starting a new build.
373
+
374
+ The session start hook will detect them and remind the agent to run
375
+ \`pulse_extract_inspiration\` on each one before \`pulse_intake\`.
376
+
377
+ ## What to put here
378
+
379
+ - Screenshots of sites whose design you admire
380
+ - Your brand mood board or style guide
381
+ - Product photography that anchors the visual direction
382
+ - Competitor sites you want to differentiate from
383
+ - Any image that captures the feeling you're going for
384
+
385
+ ## How the agent uses them
386
+
387
+ 1. \`pulse_extract_inspiration(image)\` — reads the image and extracts palette,
388
+ type weight, spacing density, radius signals, and layout intent
389
+ 2. The extracted values feed into \`pulse_intake\` as concrete constraints
390
+ 3. \`pulse_sketch\` uses them to generate layout directions matched to the actual
391
+ visual reference, not just a text description of a vibe
392
+
393
+ ## File formats
394
+
395
+ JPEG, PNG, WebP, AVIF, GIF — any image the model can read.
396
+
397
+ ## Note
398
+
399
+ These files are listed in .gitignore and will not be committed.
400
+ Only this README is tracked.
401
+ `
402
+ }
403
+
352
404
  function baseCSS() {
353
405
  return `\
354
406
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@@ -377,12 +429,14 @@ src/pages/ ← one .js file per page, auto-discovered
377
429
  src/components/ ← shared view fragments (JS functions returning HTML strings)
378
430
  public/theme.css ← token definitions (hex values, colour overrides) — hex allowed here only
379
431
  public/app.css ← layout and component overrides — var() references only, no hex
432
+ public/intake/ ← drop design reference images here before starting (not committed to git)
380
433
  \`\`\`
381
434
 
382
435
  ## Start of every session
383
436
 
384
437
  1. Run \`pulse_list_structure\` to see what already exists
385
438
  2. Read \`pulse://guide\` from MCP — the complete reference for spec format, components, verification workflow, CSS rules, and patterns
439
+ 3. If images are present in \`public/intake/\`, call \`pulse_extract_inspiration\` on each before \`pulse_intake\` — the session hook will remind you if you forget
386
440
 
387
441
  The MCP guide is the single source of truth. Follow it for all technical decisions, component usage, and the mandatory verification workflow.
388
442
 
@@ -451,8 +505,8 @@ const fallback = spec.onViewError(new Error('boom'), {}, {})
451
505
  assert(fallback.includes('main-content'))
452
506
  \`\`\`
453
507
 
454
- **Selector support:** \`tag\`, \`.class\`, \`#id\`, \`[attr]\`, \`[attr="value"]\`, and combinations (\`button.primary[disabled]\`).
455
- Descendant selectors (\`tbody tr\`, \`ul li\`) are NOT supported — use \`r.count('tr')\` not \`r.count('tbody tr')\`.
508
+ **Selector support:** \`tag\`, \`.class\`, \`#id\`, \`[attr]\`, \`[attr="value"]\`, combinations (\`button.primary[disabled]\`), and **descendant combinators** (\`r.count('tbody tr')\`, \`r.find('.card h2')\`).
509
+ Descendant selectors match the rightmost token within the ancestor context — \`r.count('.foo span')\` counts spans that are descendants of \`.foo\` elements.
456
510
 
457
511
  @.claude/pulse-checklist.md
458
512
  `
package/src/html.js CHANGED
@@ -6,13 +6,18 @@
6
6
  /**
7
7
  * Escape HTML special characters. Use this in view functions whenever
8
8
  * rendering untrusted user data into HTML attributes or text content.
9
+ *
10
+ * Preserves HTML entities (&middot;, &nbsp;, &copy;, etc) so component
11
+ * labels can use them without double-escaping.
9
12
  *
10
13
  * @param {unknown} str
11
14
  * @returns {string}
12
15
  */
13
16
  export function escHtml(str) {
14
17
  return String(str)
15
- .replace(/&/g, '&amp;')
18
+ // Replace & only if not already part of an HTML entity
19
+ // Matches &<word>; or &#<digits>; or &#x<hex>;
20
+ .replace(/&(?![a-zA-Z]+;|#\d+;|#x[0-9a-fA-F]+;)/g, '&amp;')
16
21
  .replace(/</g, '&lt;')
17
22
  .replace(/>/g, '&gt;')
18
23
  .replace(/"/g, '&quot;')