@invisibleloop/pulse 0.12.3 → 0.14.1

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 (166) hide show
  1. package/.claude/commands/verify.md +9 -5
  2. package/.claude/pulse-checklist.md +21 -1
  3. package/.github/workflows/publish.yml +1 -0
  4. package/.pulse-verified +1 -0
  5. package/CLAUDE.md +56 -15
  6. package/docs/.claude/commands/verify.md +4 -0
  7. package/docs/.claude/pulse-checklist.md +20 -2
  8. package/docs/public/.pulse-ui-version +1 -1
  9. package/docs/public/andypixel.css +237 -0
  10. package/docs/public/bookworms.css +422 -0
  11. package/docs/public/coutumes.css +584 -0
  12. package/docs/public/images/bookworms/hero-books.jpg +0 -0
  13. package/docs/public/images/bookworms/how-we-read-now.jpg +0 -0
  14. package/docs/public/images/bookworms/lantern-keepers.jpg +0 -0
  15. package/docs/public/images/bookworms/provincial-bookseller.jpg +0 -0
  16. package/docs/public/images/journey/178.jpg +0 -0
  17. package/docs/public/images/journey/29.jpg +0 -0
  18. package/docs/public/images/retrowheels/hero.jpg +0 -0
  19. package/docs/public/images/shop/devbook-pro.jpg +0 -0
  20. package/docs/public/images/suzukibento/1015.jpg +0 -0
  21. package/docs/public/images/suzukibento/1025.jpg +0 -0
  22. package/docs/public/images/suzukibento/1043.jpg +0 -0
  23. package/docs/public/images/suzukibento/1059.jpg +0 -0
  24. package/docs/public/images/suzukibento/1062.jpg +0 -0
  25. package/docs/public/images/suzukibento/1084.jpg +0 -0
  26. package/docs/public/images/swish/1469334031218-e382a71b716b.jpg +0 -0
  27. package/docs/public/images/swish/1483985988355-763728e1935b.jpg +0 -0
  28. package/docs/public/images/swish/1485518882345-15568b007407.jpg +0 -0
  29. package/docs/public/images/swish/1490481651871-ab68de25d43d.jpg +0 -0
  30. package/docs/public/images/swish/1496747611176-843222e1e57c.jpg +0 -0
  31. package/docs/public/images/swish/1502716119720-b23a93e5fe1b.jpg +0 -0
  32. package/docs/public/images/swish/1539109136881-3be0616acf4b.jpg +0 -0
  33. package/docs/public/images/swish/1551803091-e20673f15770.jpg +0 -0
  34. package/docs/public/images/swish/1558769132-cb1aea458c5e.jpg +0 -0
  35. package/docs/public/images/swish/1572804013309-59a88b7e92f1.jpg +0 -0
  36. package/docs/public/journey.css +528 -0
  37. package/docs/public/pulse-ui.css +1036 -22
  38. package/docs/public/retrowheels.css +402 -0
  39. package/docs/public/shop.css +235 -0
  40. package/docs/public/sparkys.css +349 -0
  41. package/docs/public/suzukibento.css +517 -0
  42. package/docs/public/swish.css +394 -0
  43. package/docs/server.js +19 -4
  44. package/docs/src/lib/nav.js +14 -3
  45. package/docs/src/pages/components/decorate.js +74 -0
  46. package/docs/src/pages/components/gallery.js +102 -0
  47. package/docs/src/pages/components/hero.js +1 -0
  48. package/docs/src/pages/components/marquee.js +76 -0
  49. package/docs/src/pages/components/photo-card.js +74 -0
  50. package/docs/src/pages/components/section.js +40 -1
  51. package/docs/src/pages/meta.js +26 -0
  52. package/docs/src/pages/templates/andypixel.js +260 -0
  53. package/docs/src/pages/templates/bookworms.js +227 -0
  54. package/docs/src/pages/templates/coutumes.js +226 -0
  55. package/docs/src/pages/templates/journey.js +186 -0
  56. package/docs/src/pages/templates/retrowheels.js +342 -0
  57. package/docs/src/pages/templates/shop.js +257 -0
  58. package/docs/src/pages/templates/sparkys.js +249 -0
  59. package/docs/src/pages/templates/suzukibento.js +200 -0
  60. package/docs/src/pages/templates/swish.js +313 -0
  61. package/docs/src/pages/templates.js +313 -2
  62. package/docs/src/pages/testing.js +2 -1
  63. package/examples/andypixel.css +237 -0
  64. package/examples/andypixel.js +258 -0
  65. package/examples/bookworms.css +422 -0
  66. package/examples/bookworms.js +225 -0
  67. package/examples/contact.js +1 -0
  68. package/examples/counter.js +1 -0
  69. package/examples/dev.server.js +17 -0
  70. package/examples/journey.css +528 -0
  71. package/examples/journey.js +186 -0
  72. package/examples/pricing.js +1 -0
  73. package/examples/quiz.js +1 -0
  74. package/examples/retrowheels.css +402 -0
  75. package/examples/retrowheels.js +340 -0
  76. package/examples/shared.js +6 -0
  77. package/examples/suzukibento.css +513 -0
  78. package/examples/suzukibento.js +200 -0
  79. package/examples/swish.css +394 -0
  80. package/examples/swish.js +311 -0
  81. package/examples/todos.js +1 -0
  82. package/package.json +2 -3
  83. package/public/.pulse-ui-version +1 -1
  84. package/public/images/bookworms/hero-books.jpg +0 -0
  85. package/public/images/bookworms/how-we-read-now.jpg +0 -0
  86. package/public/images/bookworms/lantern-keepers.jpg +0 -0
  87. package/public/images/bookworms/provincial-bookseller.jpg +0 -0
  88. package/public/images/journey/178.jpg +0 -0
  89. package/public/images/journey/29.jpg +0 -0
  90. package/public/images/retrowheels/hero.jpg +0 -0
  91. package/public/images/shop/devbook-pro.jpg +0 -0
  92. package/public/images/suzukibento/1015.jpg +0 -0
  93. package/public/images/suzukibento/1025.jpg +0 -0
  94. package/public/images/suzukibento/1043.jpg +0 -0
  95. package/public/images/suzukibento/1059.jpg +0 -0
  96. package/public/images/suzukibento/1062.jpg +0 -0
  97. package/public/images/suzukibento/1084.jpg +0 -0
  98. package/public/images/swish/1469334031218-e382a71b716b.jpg +0 -0
  99. package/public/images/swish/1483985988355-763728e1935b.jpg +0 -0
  100. package/public/images/swish/1485518882345-15568b007407.jpg +0 -0
  101. package/public/images/swish/1490481651871-ab68de25d43d.jpg +0 -0
  102. package/public/images/swish/1496747611176-843222e1e57c.jpg +0 -0
  103. package/public/images/swish/1502716119720-b23a93e5fe1b.jpg +0 -0
  104. package/public/images/swish/1539109136881-3be0616acf4b.jpg +0 -0
  105. package/public/images/swish/1551803091-e20673f15770.jpg +0 -0
  106. package/public/images/swish/1558769132-cb1aea458c5e.jpg +0 -0
  107. package/public/images/swish/1572804013309-59a88b7e92f1.jpg +0 -0
  108. package/public/pulse-ui.css +1036 -22
  109. package/scripts/build.js +72 -18
  110. package/src/agent/checklist.md +20 -2
  111. package/src/agent/commands/verify.md +4 -0
  112. package/src/agent/guide-components.md +145 -7
  113. package/src/agent/guide-design-gallery.md +288 -0
  114. package/src/agent/guide-design-references.md +172 -0
  115. package/src/agent/guide-explore.md +313 -0
  116. package/src/agent/guide-styles.md +197 -11
  117. package/src/agent/guide-templates.md +220 -10
  118. package/src/agent/guide.md +9 -4
  119. package/src/agent/workflow.md +208 -58
  120. package/src/cli/dev.js +39 -7
  121. package/src/cli/fmt.js +113 -0
  122. package/src/cli/index.js +63 -30
  123. package/src/cli/scaffold.js +2 -2
  124. package/src/html.js +6 -1
  125. package/src/mcp/server.js +2415 -101
  126. package/src/mcp/validate-worker.js +34 -5
  127. package/src/runtime/ssr.js +4 -1
  128. package/src/server/index.js +22 -2
  129. package/src/spec/schema.js +5 -0
  130. package/src/testing/html.js +130 -4
  131. package/src/testing/testing.test.js +58 -0
  132. package/src/ui/banner.js +24 -9
  133. package/src/ui/brand-statement.js +58 -0
  134. package/src/ui/button.js +12 -1
  135. package/src/ui/card.js +20 -1
  136. package/src/ui/colophon.js +29 -0
  137. package/src/ui/contact-row.js +41 -0
  138. package/src/ui/cta.js +18 -4
  139. package/src/ui/decorate.js +55 -0
  140. package/src/ui/display-heading.js +44 -0
  141. package/src/ui/drop-cap.js +29 -0
  142. package/src/ui/editorial-hero.js +63 -0
  143. package/src/ui/entry-list.js +36 -0
  144. package/src/ui/feature.js +20 -3
  145. package/src/ui/footer.js +78 -9
  146. package/src/ui/gallery.js +56 -0
  147. package/src/ui/hero.js +68 -18
  148. package/src/ui/image-pair.js +72 -0
  149. package/src/ui/index.js +20 -0
  150. package/src/ui/input.js +1 -0
  151. package/src/ui/marquee.js +48 -0
  152. package/src/ui/nav.js +19 -9
  153. package/src/ui/photo-card.js +44 -0
  154. package/src/ui/product-card.js +65 -0
  155. package/src/ui/pullquote.js +14 -3
  156. package/src/ui/search.js +1 -0
  157. package/src/ui/section-label.js +38 -0
  158. package/src/ui/section.js +1 -1
  159. package/src/ui/service-card.js +47 -0
  160. package/src/ui/stat-grid.js +40 -0
  161. package/src/ui/stat.js +16 -2
  162. package/src/ui/testimonial.js +17 -6
  163. package/src/ui/textarea.js +1 -0
  164. package/src/ui/top-strip.js +47 -0
  165. package/src/ui/trust-list.js +46 -0
  166. package/src/ui/ui.test.js +35 -0
package/src/cli/dev.js CHANGED
@@ -57,7 +57,15 @@ const PUBLIC_DIR = path.join(ROOT, 'public')
57
57
  const stampPath = path.join(PUBLIC_DIR, '.pulse-ui-version')
58
58
  const stamp = fs.existsSync(stampPath) ? fs.readFileSync(stampPath, 'utf8').trim() : null
59
59
 
60
- if (stamp === pkgVersion) return
60
+ // Compare CSS byte-length so any content change triggers a re-copy, even
61
+ // within the same version (e.g. when iterating locally with npm link).
62
+ const srcCss = path.join(pkgPublic, 'pulse-ui.css')
63
+ const dstCss = path.join(PUBLIC_DIR, 'pulse-ui.css')
64
+ const srcSize = fs.existsSync(srcCss) ? fs.statSync(srcCss).size : -1
65
+ const dstSize = fs.existsSync(dstCss) ? fs.statSync(dstCss).size : -2
66
+ const cssStale = srcSize !== dstSize
67
+
68
+ if (stamp === pkgVersion && !cssStale) return
61
69
 
62
70
  fs.mkdirSync(PUBLIC_DIR, { recursive: true })
63
71
  for (const asset of ['pulse-ui.css', 'pulse-ui.js']) {
@@ -135,16 +143,40 @@ process.on('SIGTERM', closeReloadClients)
135
143
  process.on('SIGINT', closeReloadClients)
136
144
 
137
145
  let reloadTimer = null
138
- fs.watch(path.join(ROOT, 'src'), { recursive: true }, () => {
146
+ let lastReload = 0
147
+
148
+ async function triggerReload(label = 'File changed') {
139
149
  clearTimeout(reloadTimer)
140
150
  reloadTimer = setTimeout(async () => {
151
+ // Debounce rapid file changes (e.g. multiple Edit tool calls)
152
+ const now = Date.now()
153
+ if (now - lastReload < 100) return
154
+ lastReload = now
155
+
156
+ console.log(` ⟳ ${label}, reloading specs...`)
141
157
  try {
142
- const fresh = await loadPages(ROOT, Date.now())
158
+ const fresh = await loadPages(ROOT, now)
143
159
  updateSpecs(fresh)
144
- } catch { /* spec error — browser will show the old page, not crash */ }
160
+ console.log(' ✓ Specs reloaded')
161
+ } catch (err) {
162
+ console.error(' ✗ Spec reload failed:', err.message)
163
+ /* spec error — browser will show the old page, not crash */
164
+ }
145
165
  notifyReload()
146
- }, 50)
147
- })
166
+ }, 200)
167
+ }
168
+
169
+ // Watch src/ for changes to existing files (spec edits, component edits)
170
+ fs.watch(path.join(ROOT, 'src'), { recursive: true }, () => triggerReload('File changed'))
171
+
172
+ // Also watch the pages directory explicitly so macOS reliably fires on new file creation.
173
+ // On macOS, fs.watch with recursive=true watches subdirectory contents but may not fire
174
+ // for the parent dir entry when a brand-new file is created. Watching the dir non-recursively
175
+ // catches the "new entry added to this directory" event.
176
+ const pagesDir = path.join(ROOT, 'src', 'pages')
177
+ if (fs.existsSync(pagesDir)) {
178
+ fs.watch(pagesDir, { recursive: false }, () => triggerReload('New page detected'))
179
+ }
148
180
 
149
181
  // Tiny script injected into every page — connects to SSE and reloads on change
150
182
  // Passed as a function so the server can inject the per-request CSP nonce
@@ -160,7 +192,7 @@ const reloadScript = (nonce) => `<script nonce="${nonce}">
160
192
  })();
161
193
  </script>`
162
194
 
163
- const { updateSpecs } = createServer(specs, {
195
+ const { updateSpecs } = await createServer(specs, {
164
196
  port: PORT,
165
197
  stream: true,
166
198
  staticDir: fs.existsSync(PUBLIC_DIR) ? PUBLIC_DIR : null,
package/src/cli/fmt.js ADDED
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Pulse CLI — terminal formatting helpers
3
+ *
4
+ * Respects NO_COLOR and CI env vars — falls back to plain text automatically.
5
+ * Zero dependencies, Node built-ins only.
6
+ */
7
+
8
+ const USE_COLOR = !process.env.NO_COLOR && !process.env.CI && process.stdout.isTTY
9
+
10
+ // ANSI escape helpers — no-ops when colour is disabled
11
+ const c = {
12
+ reset: s => USE_COLOR ? `\x1b[0m${s}\x1b[0m` : s,
13
+ bold: s => USE_COLOR ? `\x1b[1m${s}\x1b[0m` : s,
14
+ dim: s => USE_COLOR ? `\x1b[2m${s}\x1b[0m` : s,
15
+ green: s => USE_COLOR ? `\x1b[32m${s}\x1b[0m` : s,
16
+ yellow: s => USE_COLOR ? `\x1b[33m${s}\x1b[0m` : s,
17
+ blue: s => USE_COLOR ? `\x1b[34m${s}\x1b[0m` : s,
18
+ cyan: s => USE_COLOR ? `\x1b[36m${s}\x1b[0m` : s,
19
+ white: s => USE_COLOR ? `\x1b[97m${s}\x1b[0m` : s,
20
+ red: s => USE_COLOR ? `\x1b[31m${s}\x1b[0m` : s,
21
+ purple: s => USE_COLOR ? `\x1b[35m${s}\x1b[0m` : s,
22
+ }
23
+
24
+ // Strip ANSI codes to measure true visual width
25
+ function visLen(s) {
26
+ // eslint-disable-next-line no-control-regex
27
+ return s.replace(/\x1b\[[0-9;]*m/g, '').length
28
+ }
29
+
30
+ // Pad a string to a visual width (accounting for ANSI codes)
31
+ function pad(s, width, align = 'left') {
32
+ const len = visLen(s)
33
+ const fill = Math.max(0, width - len)
34
+ return align === 'right' ? ' '.repeat(fill) + s : s + ' '.repeat(fill)
35
+ }
36
+
37
+ // ─── Box ─────────────────────────────────────────────────────────────────────
38
+
39
+ export function box(lines, opts = {}) {
40
+ const { title = '', style = 'single' } = opts
41
+ const chars = style === 'double'
42
+ ? { tl: '╔', tr: '╗', bl: '╚', br: '╝', h: '═', v: '║' }
43
+ : { tl: '┌', tr: '┐', bl: '└', br: '┘', h: '─', v: '│' }
44
+
45
+ const inner = Array.isArray(lines) ? lines : [lines]
46
+ const maxW = Math.max(...inner.map(visLen), title ? visLen(title) + 2 : 0)
47
+ const bar = chars.h.repeat(maxW + 2)
48
+
49
+ const top = title
50
+ ? `${chars.tl}${chars.h} ${c.bold(title)} ${chars.h.repeat(maxW - visLen(title) - 1)}${chars.tr}`
51
+ : `${chars.tl}${bar}${chars.tr}`
52
+
53
+ const rows = inner.map(l => `${chars.v} ${pad(l, maxW)} ${chars.v}`)
54
+ return [top, ...rows, `${chars.bl}${bar}${chars.br}`].join('\n')
55
+ }
56
+
57
+ // ─── Header (branded section label) ──────────────────────────────────────────
58
+
59
+ export function header(text) {
60
+ const line = `${c.purple('⚡')} ${c.bold(c.white(text))}`
61
+ return `\n${line}\n${c.dim('─'.repeat(visLen('⚡ ' + text) - (USE_COLOR ? 0 : 0)))}`
62
+ }
63
+
64
+ // ─── Table ───────────────────────────────────────────────────────────────────
65
+
66
+ /**
67
+ * Render an aligned table.
68
+ * @param {string[]} cols - Column headers
69
+ * @param {string[][]} rows - Data rows (array of arrays)
70
+ * @param {{align?: ('left'|'right')[]}} opts
71
+ */
72
+ export function table(cols, rows, opts = {}) {
73
+ const aligns = opts.align || cols.map(() => 'left')
74
+ const allRows = [cols, ...rows]
75
+ const widths = cols.map((_, i) => Math.max(...allRows.map(r => visLen(r[i] ?? ''))))
76
+
77
+ const sep = ' '
78
+
79
+ const headerRow = cols.map((h, i) => pad(c.dim(h), widths[i], aligns[i])).join(sep)
80
+ const divider = c.dim('─'.repeat(widths.reduce((s, w, i) => s + w + (i < widths.length - 1 ? sep.length : 0), 0)))
81
+ const body = rows.map(row =>
82
+ row.map((cell, i) => pad(cell ?? '', widths[i], aligns[i])).join(sep)
83
+ )
84
+
85
+ return [headerRow, divider, ...body].join('\n')
86
+ }
87
+
88
+ // ─── Status line helpers ──────────────────────────────────────────────────────
89
+
90
+ export const icon = {
91
+ ok: () => c.green('✓'),
92
+ fail: () => c.red('✗'),
93
+ warn: () => c.yellow('⚠'),
94
+ info: () => c.cyan('·'),
95
+ run: () => c.yellow('›'),
96
+ bolt: () => c.purple('⚡'),
97
+ }
98
+
99
+ export function ok(msg) { return ` ${icon.ok()} ${msg}` }
100
+ export function fail(msg) { return ` ${icon.fail()} ${c.red(msg)}` }
101
+ export function warn(msg) { return ` ${icon.warn()} ${c.yellow(msg)}` }
102
+ export function info(msg) { return ` ${icon.info()} ${c.dim(msg)}` }
103
+
104
+ // ─── Timing ───────────────────────────────────────────────────────────────────
105
+
106
+ export function elapsed(ms) {
107
+ if (ms < 1000) return `${Math.round(ms)}ms`
108
+ return `${(ms / 1000).toFixed(1)}s`
109
+ }
110
+
111
+ // ─── Exports ──────────────────────────────────────────────────────────────────
112
+
113
+ export { c, pad, visLen }
package/src/cli/index.js CHANGED
@@ -33,6 +33,13 @@ for (let i = 0; i < rawArgs.length; i++) {
33
33
  const command = args[0]
34
34
  const CWD = process.cwd()
35
35
 
36
+ // Parse --agent flag before routing to commands
37
+ let agentFlag = null
38
+ const agentIdx = args.indexOf('--agent')
39
+ if (agentIdx !== -1 && args[agentIdx + 1]) {
40
+ agentFlag = args[agentIdx + 1]
41
+ }
42
+
36
43
  // ---------------------------------------------------------------------------
37
44
  // Helpers
38
45
  // ---------------------------------------------------------------------------
@@ -91,7 +98,7 @@ function isDirEmpty(dir) {
91
98
  return fs.readdirSync(dir).length === 0
92
99
  }
93
100
 
94
- async function runDefault(root) {
101
+ async function runDefault(root, agentFlag = null) {
95
102
  if (!isPulseProject(root)) {
96
103
  console.log(`\n⚡ No Pulse project found here.\n`)
97
104
 
@@ -343,7 +350,11 @@ async function runUpdate(root) {
343
350
  process.exit(1)
344
351
  }
345
352
 
353
+ // Copy assets from the package this CLI binary lives in (import.meta.url).
354
+ // When globally npm-linked to the dev repo, this resolves to dev source.
355
+ // When installed normally, this resolves to the installed version.
346
356
  const pkgPublic = new URL('../../public', import.meta.url).pathname
357
+ const pkgSrc = new URL('..', import.meta.url).pathname
347
358
  const assets = ['pulse-ui.css', 'pulse-ui.js', '.pulse-ui-version']
348
359
  const publicDir = path.join(root, 'public')
349
360
  const updated = []
@@ -359,11 +370,10 @@ async function runUpdate(root) {
359
370
 
360
371
  // Sync agent files into .claude/
361
372
  const agentFiles = [
362
- ['../agent/checklist.md', 'pulse-checklist.md'],
363
- ['../agent/coverage-check.js', 'coverage-check.js'],
373
+ [path.join(pkgSrc, 'agent', 'checklist.md'), 'pulse-checklist.md'],
374
+ [path.join(pkgSrc, 'agent', 'coverage-check.js'), 'coverage-check.js'],
364
375
  ]
365
- for (const [rel, dst] of agentFiles) {
366
- const src = new URL(rel, import.meta.url).pathname
376
+ for (const [src, dst] of agentFiles) {
367
377
  if (fs.existsSync(src)) {
368
378
  const dstPath = path.join(root, '.claude', dst)
369
379
  fs.mkdirSync(path.dirname(dstPath), { recursive: true })
@@ -373,7 +383,7 @@ async function runUpdate(root) {
373
383
  }
374
384
 
375
385
  // Sync slash commands into .claude/commands/
376
- const commandsSrc = new URL('../agent/commands', import.meta.url).pathname
386
+ const commandsSrc = path.join(pkgSrc, 'agent', 'commands')
377
387
  const commandsDst = path.join(root, '.claude', 'commands')
378
388
  if (fs.existsSync(commandsSrc)) {
379
389
  fs.mkdirSync(commandsDst, { recursive: true })
@@ -410,9 +420,16 @@ async function runUpdate(root) {
410
420
  const versionFile = path.join(publicDir, '.pulse-ui-version')
411
421
  const version = fs.existsSync(versionFile) ? fs.readFileSync(versionFile, 'utf8').trim() : '?'
412
422
 
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`)
423
+ const { c, ok: fmtOk, fail: fmtFail, icon } = await import('./fmt.js').catch(() => ({
424
+ c: { dim: s => s, bold: s => s },
425
+ ok: s => ` ✓ ${s}`,
426
+ fail: s => ` ✗ ${s}`,
427
+ icon: { bolt: () => '⚡' },
428
+ }))
429
+
430
+ console.log(`\n ${icon.bolt()} ${c.bold('Pulse')} updated to ${c.bold(version)}\n`)
431
+ for (const f of updated) console.log(fmtOk(f))
432
+ for (const f of missing) console.log(fmtFail(`${f} not found in package`))
416
433
  console.log()
417
434
  }
418
435
 
@@ -446,29 +463,37 @@ switch (command) {
446
463
  process.exit(0)
447
464
  }
448
465
  case '--help':
449
- case '-h':
466
+ case '-h': {
467
+ const { c, icon } = await import('./fmt.js').catch(() => ({
468
+ c: { bold: s => s, dim: s => s, cyan: s => s, purple: s => s, white: s => s },
469
+ icon: { bolt: () => '⚡' },
470
+ }))
450
471
  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
472
+ ${icon.bolt()} ${c.bold('Pulse')} ${c.dim('— spec-first frontend framework')}
473
+
474
+ ${c.bold('Usage:')} pulse ${c.dim('[command] [options]')}
475
+
476
+ ${c.bold('Commands:')}
477
+ ${c.cyan('(none)')} detect project, or launch the scaffold wizard
478
+ ${c.cyan('dev')} start the dev server
479
+ ${c.cyan('build')} production build ${c.dim('→ public/dist/')}
480
+ ${c.cyan('start')} production server ${c.dim('(requires prior build)')}
481
+ ${c.cyan('update')} re-copy pulse-ui assets from installed package
482
+
483
+ ${c.bold('Options:')}
484
+ ${c.cyan('--agent')} ${c.dim('<name>')} use specific agent ${c.dim('(claude | copilot)')}
485
+ ${c.cyan('-v')}, ${c.cyan('--version')} print version and exit
486
+ ${c.cyan('-h')}, ${c.cyan('--help')} show this help
487
+
488
+ ${c.bold('Examples:')}
489
+ ${c.dim('pulse # new project wizard')}
490
+ ${c.dim('pulse --agent copilot # launch with GitHub Copilot CLI')}
491
+ ${c.dim('pulse dev # dev server')}
492
+ ${c.dim('pulse build # bundle for production')}
493
+ ${c.dim('pulse start # serve production build')}
470
494
  `)
471
495
  process.exit(0)
496
+ }
472
497
  case 'dev':
473
498
  await runDev(CWD)
474
499
  break
@@ -496,6 +521,14 @@ switch (command) {
496
521
  case 'update':
497
522
  await runUpdate(CWD)
498
523
  break
524
+ case 'link': {
525
+ // DEV-ONLY: npm link the local dev repo into this project, then pulse update.
526
+ const { execSync } = await import('child_process')
527
+ console.log('\n Linking @invisibleloop/pulse from dev repo…\n')
528
+ execSync('npm link @invisibleloop/pulse', { cwd: CWD, stdio: 'inherit' })
529
+ await runUpdate(CWD)
530
+ break
531
+ }
499
532
  default:
500
- await runDefault(CWD)
533
+ await runDefault(CWD, agentFlag)
501
534
  }
@@ -451,8 +451,8 @@ const fallback = spec.onViewError(new Error('boom'), {}, {})
451
451
  assert(fallback.includes('main-content'))
452
452
  \`\`\`
453
453
 
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')\`.
454
+ **Selector support:** \`tag\`, \`.class\`, \`#id\`, \`[attr]\`, \`[attr="value"]\`, combinations (\`button.primary[disabled]\`), and **descendant combinators** (\`r.count('tbody tr')\`, \`r.find('.card h2')\`).
455
+ Descendant selectors match the rightmost token within the ancestor context — \`r.count('.foo span')\` counts spans that are descendants of \`.foo\` elements.
456
456
 
457
457
  @.claude/pulse-checklist.md
458
458
  `
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;')