@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
@@ -18,7 +18,23 @@ let mod
18
18
  try {
19
19
  mod = await import(tmpFile)
20
20
  } catch (err) {
21
- process.stdout.write(`Invalid: could not parse — ${err.message}`)
21
+ // Give a targeted message for common import-resolution failures
22
+ const msg = err.message || ''
23
+ if (msg.includes('Cannot find package') || msg.includes('Cannot find module') || msg.includes('ERR_MODULE_NOT_FOUND')) {
24
+ const bare = msg.match(/Cannot find (?:package|module) '([^']+)'/)?.[1]
25
+ if (bare && !bare.startsWith('.') && !bare.startsWith('/') && !bare.startsWith('@invisibleloop/pulse')) {
26
+ process.stdout.write(
27
+ `Invalid: spec has an unresolvable import — \`${bare}\` is not a valid module specifier.\n` +
28
+ ` • Use relative paths for project-local files: \`'../components/nav.js'\`\n` +
29
+ ` • Use \`'@invisibleloop/pulse/ui'\` for Pulse components\n` +
30
+ ` • Bare paths like \`'src/...' \` or \`'components/...'\` are not valid — they are not packages`
31
+ )
32
+ } else {
33
+ process.stdout.write(`Invalid: could not import spec — ${msg.split('\n')[0]}`)
34
+ }
35
+ } else {
36
+ process.stdout.write(`Invalid: could not parse — ${err.message}`)
37
+ }
22
38
  process.exit(0)
23
39
  }
24
40
 
@@ -71,6 +87,18 @@ try {
71
87
  warnings.push(`Heading order: page starts with h${levels[0]}, expected h1`)
72
88
  }
73
89
 
90
+ // Duplicate h2s inside a single section — inner item headings should be h3
91
+ // A section with an h2 title and child items that also use h2 creates a flat
92
+ // outline that is semantically incorrect. Flag when a <section> contains 2+ h2s.
93
+ const sectionBlocks = [...html.matchAll(/<section[\s>][^]*?<\/section>/gi)]
94
+ for (const [sectionHtml] of sectionBlocks) {
95
+ const h2s = [...sectionHtml.matchAll(/<h2[\s>]/gi)]
96
+ if (h2s.length > 1) {
97
+ warnings.push(`Multiple h2 headings inside a single <section> — the first h2 should be the section title; inner item headings should use h3 (or deeper). A flat outline of h2s makes navigation harder for screen reader users.`)
98
+ break // one warning is enough
99
+ }
100
+ }
101
+
74
102
  // Missing main landmark
75
103
  if (!/id=["']main-content["']/.test(html)) {
76
104
  warnings.push('Missing <main id="main-content"> — required for accessibility and Lighthouse landmark audit')
@@ -98,7 +126,7 @@ try {
98
126
  }
99
127
 
100
128
  // Unknown u-* utility classes — catch invented classes that have no CSS definition
101
- const KNOWN_UTILS = new Set(['u-absolute','u-bg-accent','u-bg-surface','u-bg-surface2','u-block','u-border','u-border-b','u-border-t','u-flex','u-flex-1','u-flex-col','u-flex-wrap','u-font-bold','u-font-medium','u-font-normal','u-font-semibold','u-gap-1','u-gap-2','u-gap-3','u-gap-4','u-gap-5','u-gap-6','u-gap-8','u-hidden','u-inline','u-inline-block','u-items-center','u-items-end','u-items-start','u-items-stretch','u-justify-between','u-justify-center','u-justify-end','u-justify-start','u-leading-loose','u-leading-normal','u-leading-relaxed','u-leading-snug','u-leading-tight','u-max-w-lg','u-max-w-md','u-max-w-prose','u-max-w-sm','u-max-w-xl','u-max-w-xs','u-mb-0','u-mb-1','u-mb-10','u-mb-12','u-mb-16','u-mb-2','u-mb-3','u-mb-4','u-mb-5','u-mb-6','u-mb-8','u-ml-auto','u-mr-auto','u-mt-0','u-mt-1','u-mt-10','u-mt-12','u-mt-16','u-mt-2','u-mt-3','u-mt-4','u-mt-5','u-mt-6','u-mt-8','u-mx-auto','u-opacity-50','u-opacity-75','u-overflow-auto','u-overflow-hidden','u-p-0','u-p-1','u-p-2','u-p-3','u-p-4','u-p-5','u-p-6','u-p-8','u-px-0','u-px-2','u-px-3','u-px-4','u-px-5','u-px-6','u-px-8','u-py-0','u-py-2','u-py-3','u-py-4','u-py-5','u-py-6','u-py-8','u-relative','u-rounded','u-rounded-full','u-rounded-lg','u-rounded-md','u-rounded-xl','u-shrink-0','u-text-2xl','u-text-3xl','u-text-4xl','u-text-accent','u-text-balance','u-text-base','u-text-blue','u-text-center','u-text-default','u-text-green','u-text-left','u-text-lg','u-text-muted','u-text-red','u-text-right','u-text-sm','u-text-xl','u-text-xs','u-text-yellow','u-w-auto','u-w-full'])
129
+ const KNOWN_UTILS = new Set(['u-absolute','u-bg-accent','u-bg-surface','u-bg-surface2','u-block','u-border','u-border-b','u-border-t','u-flex','u-flex-1','u-flex-col','u-flex-wrap','u-font-bold','u-font-medium','u-font-normal','u-font-semibold','u-gap-1','u-gap-2','u-gap-3','u-gap-4','u-gap-5','u-gap-6','u-gap-8','u-hidden','u-inline','u-inline-block','u-items-center','u-items-end','u-items-start','u-items-stretch','u-justify-between','u-justify-center','u-justify-end','u-justify-start','u-leading-loose','u-leading-normal','u-leading-relaxed','u-leading-snug','u-leading-tight','u-max-w-lg','u-max-w-md','u-max-w-prose','u-max-w-sm','u-max-w-xl','u-max-w-xs','u-mb-0','u-mb-1','u-mb-10','u-mb-12','u-mb-16','u-mb-2','u-mb-3','u-mb-4','u-mb-5','u-mb-6','u-mb-8','u-ml-auto','u-mr-auto','u-mt-0','u-mt-1','u-mt-10','u-mt-12','u-mt-16','u-mt-2','u-mt-3','u-mt-4','u-mt-5','u-mt-6','u-mt-8','u-mx-auto','u-opacity-50','u-opacity-75','u-overflow-auto','u-overflow-hidden','u-p-0','u-p-1','u-p-2','u-p-3','u-p-4','u-p-5','u-p-6','u-p-8','u-px-0','u-px-2','u-px-3','u-px-4','u-px-5','u-px-6','u-px-8','u-py-0','u-py-2','u-py-3','u-py-4','u-py-5','u-py-6','u-py-8','u-relative','u-rounded','u-rounded-full','u-rounded-lg','u-rounded-md','u-rounded-xl','u-shrink-0','u-tabular-nums','u-text-2xl','u-text-3xl','u-text-4xl','u-text-accent','u-text-balance','u-text-base','u-text-blue','u-text-center','u-text-default','u-text-green','u-text-left','u-text-lg','u-text-muted','u-text-red','u-text-right','u-text-sm','u-text-xl','u-text-xs','u-text-yellow','u-w-auto','u-w-full'])
102
130
  const usedUtils = new Set([...html.matchAll(/\bu-([\w-]+)/g)].map(m => 'u-' + m[1]))
103
131
  const unknownUtils = [...usedUtils].filter(c => !KNOWN_UTILS.has(c))
104
132
  if (unknownUtils.length) {
@@ -110,9 +138,25 @@ try {
110
138
  warnings.push('input[type="file"] detected — use the fileUpload() component instead. It provides the drag-and-drop zone, correct styling, and pulse-ui.js integration. import { fileUpload } from \'@invisibleloop/pulse/ui\'')
111
139
  }
112
140
 
113
- // Unescaped apostrophes in attribute values
114
- if (/=\s*'[^']*'[^']*'/.test(html)) {
115
- warnings.push('Possible unescaped apostrophe in an HTML attribute — use &apos; or &#39; or switch to double quotes')
141
+ // Unescaped apostrophes — only flag inside attribute values (attr='it's wrong'),
142
+ // not in text content (it's fine in body text). Match: attr='...apostrophe...'
143
+ // Pattern: an attribute assignment with single-quote delimiter containing a second single quote
144
+ if (/\s[\w-]+=\s*'[^'<>]*'[^']*'/.test(html)) {
145
+ warnings.push('Possible unescaped apostrophe in an HTML attribute value — use &apos; or &#39; or switch to double-quoted attributes')
146
+ }
147
+
148
+ // Inline <style> blocks in view — blocked by Pulse CSP nonce policy
149
+ if (/<style[\s>]/i.test(html)) {
150
+ warnings.push('Inline <style> block detected in view — Pulse\'s CSP (style-src with nonces) will silently block these styles at runtime. Move styles to public/app.css instead.')
151
+ }
152
+
153
+ // Hardcoded hex colours in view HTML — must use var(--ui-*) tokens instead.
154
+ // Matches: style="...color:#hex..." or style="...background:#hex..."
155
+ // Allows: href="#section-id" (anchor links are not colours)
156
+ const hexInStyle = [...html.matchAll(/style="[^"]*#[0-9a-fA-F]{3,6}[^"]*"/g)]
157
+ if (hexInStyle.length) {
158
+ const samples = [...new Set(hexInStyle.flatMap(m => [...m[0].matchAll(/#[0-9a-fA-F]{3,6}/g)].map(h => h[0])))].slice(0, 3)
159
+ warnings.push(`Hardcoded hex colour${samples.length > 1 ? 's' : ''} in view HTML (${samples.join(', ')}) — use var(--ui-*) CSS tokens instead. Move the hex value to public/theme.css as a named token, then reference it via var() in public/app.css or as a CSS class on the element.`)
116
160
  }
117
161
  } catch { /* view may depend on server data — skip HTML checks */ }
118
162
 
@@ -314,7 +314,10 @@ export function wrapDocument({ content, spec = {}, serverState = {}, storeState
314
314
  const meta = spec.meta || {}
315
315
  const title = meta.title || 'Pulse'
316
316
 
317
- const bodyAttr = meta.theme ? ` data-theme="${esc(meta.theme)}"` : ''
317
+ const bodyAttr = [
318
+ meta.theme ? `data-theme="${esc(meta.theme)}"` : '',
319
+ meta.vibe ? `data-vibe="${esc(meta.vibe)}"` : '',
320
+ ].filter(Boolean).map(a => ` ${a}`).join('')
318
321
 
319
322
  const metaTags = [
320
323
  meta.description ? `<meta name="description" content="${esc(meta.description)}">` : '',
@@ -39,6 +39,7 @@ const SECURITY_HEADERS = {
39
39
  'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
40
40
  'Cross-Origin-Opener-Policy': 'same-origin',
41
41
  'Cross-Origin-Resource-Policy': 'same-origin',
42
+ 'X-Pulse': version,
42
43
  }
43
44
 
44
45
  /**
@@ -498,7 +499,9 @@ export async function createServer(entries, options = {}) {
498
499
  healthCheck = '/healthz', // path for health check endpoint, or false to disable
499
500
  csp = {}, // extra CSP sources: { 'style-src': ['https://fonts.googleapis.com'] }
500
501
  onError = (err, req, res) => defaultErrorHandler(err, req, res, dev),
501
- onRequest
502
+ onRequest,
503
+ agentMode = false, // show agent-active indicator in banner
504
+ quiet = false, // suppress request logging
502
505
  } = options
503
506
 
504
507
  // Resolve URL entries → spec objects, auto-setting hydrate where needed
@@ -552,6 +555,15 @@ export async function createServer(entries, options = {}) {
552
555
  let router = buildRouter(specs)
553
556
 
554
557
  const server = http.createServer(async (req, res) => {
558
+ const reqStart = Date.now()
559
+ res.on('finish', () => {
560
+ if (!quiet) {
561
+ import('../cli/logger.js').then(({ request: logRequest }) => {
562
+ const url = new URL(req.url, `http://localhost:${port}`)
563
+ logRequest({ method: req.method, pathname: url.pathname, status: res.statusCode, ms: Date.now() - reqStart })
564
+ }).catch(() => {})
565
+ }
566
+ })
555
567
  try {
556
568
  // Parse URL — needed before health check and routing
557
569
  const url = new URL(req.url, `http://localhost:${port}`)
@@ -753,7 +765,7 @@ export async function createServer(entries, options = {}) {
753
765
  if (draining) return
754
766
  draining = true
755
767
 
756
- console.log('⚡ Pulse shutting down gracefully…')
768
+ import('../cli/logger.js').then(({ shutdown: logShutdown }) => logShutdown()).catch(() => {})
757
769
 
758
770
  // Stop background cache eviction timers
759
771
  serverDataCache.stopEviction()
@@ -770,7 +782,7 @@ export async function createServer(entries, options = {}) {
770
782
 
771
783
  // Force-exit after shutdownTimeout so a stuck request can't block a deploy
772
784
  setTimeout(() => {
773
- console.error(`⚡ Pulse force-exiting after ${shutdownTimeout}ms shutdown timeout`)
785
+ import('../cli/logger.js').then(({ shutdown: logShutdown }) => logShutdown(true)).catch(() => {})
774
786
  process.exit(1)
775
787
  }, shutdownTimeout).unref()
776
788
  }
@@ -779,7 +791,16 @@ export async function createServer(entries, options = {}) {
779
791
  process.on('SIGINT', shutdown)
780
792
 
781
793
  server.listen(port, () => {
782
- console.log(`⚡ Pulse running at http://localhost:${port}`)
794
+ import('../cli/logger.js').then(({ banner }) => {
795
+ banner({
796
+ url: `http://localhost:${port}`,
797
+ version,
798
+ specs,
799
+ agentMode,
800
+ })
801
+ }).catch(() => {
802
+ console.log(`⚡ Pulse running at http://localhost:${port}`)
803
+ })
783
804
  })
784
805
 
785
806
  return {
@@ -981,7 +1002,10 @@ async function handleStreamResponse(spec, ctx, req, res, extraBody = '', dev = f
981
1002
  meta.schema ? ` <script type="application/ld+json">${JSON.stringify(meta.schema)}</script>` : '',
982
1003
  ].filter(Boolean).join('\n')
983
1004
 
984
- const bodyAttr = meta.theme ? ` data-theme="${escHtml(meta.theme)}"` : ''
1005
+ const bodyAttr = [
1006
+ meta.theme ? `data-theme="${escHtml(meta.theme)}"` : '',
1007
+ meta.vibe ? `data-vibe="${escHtml(meta.vibe)}"` : '',
1008
+ ].filter(Boolean).map(a => ` ${a}`).join('')
985
1009
 
986
1010
  const docOpen = `<!DOCTYPE html>
987
1011
  <html lang="en">
@@ -376,6 +376,22 @@ export function validateSpec(spec) {
376
376
  warnings.push('spec.hydrate is set manually — it is auto-derived by the framework from the URL entry passed to createServer. Remove it from the spec.')
377
377
  }
378
378
 
379
+ // meta — warn on unrecognised fields (e.g. meta.vibe is silently ignored; the correct
380
+ // field is top-level spec.meta.vibe which IS supported, but an agent might write
381
+ // meta: { vibe: 'bold' } expecting it to work when the real effect comes from
382
+ // the framework reading spec.meta.vibe directly — this check catches unknown keys)
383
+ if (spec.meta && typeof spec.meta === 'object' && !Array.isArray(spec.meta)) {
384
+ const KNOWN_META_KEYS = new Set([
385
+ 'title', 'description', 'styles', 'scripts', 'theme', 'vibe',
386
+ 'ogTitle', 'ogDescription', 'ogImage', 'canonical', 'schema',
387
+ 'robots', 'viewport', 'charset', 'lang',
388
+ ])
389
+ const unknown = Object.keys(spec.meta).filter(k => typeof spec.meta[k] !== 'function' && !KNOWN_META_KEYS.has(k))
390
+ if (unknown.length) {
391
+ warnings.push(`spec.meta has unrecognised field${unknown.length > 1 ? 's' : ''}: ${unknown.map(k => `"${k}"`).join(', ')} — these are silently ignored by the framework. Known fields: ${[...KNOWN_META_KEYS].join(', ')}.`)
392
+ }
393
+ }
394
+
379
395
  // meta quality
380
396
  if (!spec.meta) {
381
397
  warnings.push('spec.meta is missing — add at minimum title and description for SEO')
@@ -389,6 +405,11 @@ export function validateSpec(spec) {
389
405
  } else if (typeof desc === 'string' && desc.trim() === 'Built with Pulse') {
390
406
  warnings.push('spec.meta.description is still the default "Built with Pulse" — replace it with a real description')
391
407
  }
408
+ // If meta.theme is not set (and not a function), Pulse defaults to dark — warn so agents
409
+ // don't accidentally ship a dark-themed page when they intended light.
410
+ if (typeof spec.meta.theme !== 'string' && typeof spec.meta.theme !== 'function') {
411
+ warnings.push('spec.meta.theme is not set — Pulse defaults to dark. Add meta.theme: \'dark\' (or \'light\') to make the intent explicit and suppress this warning.')
412
+ }
392
413
  }
393
414
 
394
415
  return {
@@ -384,6 +384,34 @@ test('validateSpec still warns when hydrate is set manually in source', () => {
384
384
  assert(warnings.some(w => w.includes('hydrate')), 'Expected hydrate warning from validateSpec')
385
385
  })
386
386
 
387
+ // meta.theme warnings
388
+ test('warns when meta.theme is not set', () => {
389
+ const { warnings } = validateSpec({
390
+ route: '/entry',
391
+ view: () => '<main id="main-content"></main>',
392
+ meta: { title: 'Entry', description: 'A page' },
393
+ })
394
+ assert(warnings.some(w => w.includes('meta.theme')), 'Expected meta.theme warning')
395
+ })
396
+
397
+ test('does not warn when meta.theme is a string', () => {
398
+ const { warnings } = validateSpec({
399
+ route: '/entry',
400
+ view: () => '<main id="main-content"></main>',
401
+ meta: { title: 'Entry', description: 'A page', theme: 'dark' },
402
+ })
403
+ assert(!warnings.some(w => w.includes('meta.theme')), 'Unexpected meta.theme warning when theme is set')
404
+ })
405
+
406
+ test('does not warn when meta.theme is a function', () => {
407
+ const { warnings } = validateSpec({
408
+ route: '/entry',
409
+ view: () => '<main id="main-content"></main>',
410
+ meta: { title: 'Entry', description: 'A page', theme: (ctx) => ctx.brand?.theme ?? 'dark' },
411
+ })
412
+ assert(!warnings.some(w => w.includes('meta.theme')), 'Unexpected meta.theme warning when theme is a function')
413
+ })
414
+
387
415
  // ---------------------------------------------------------------------------
388
416
 
389
417
  console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`)
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * No dependencies. Handles common HTML patterns well enough for test assertions.
5
5
  * Supports: tag, .class, #id, [attr], [attr="value"] and combinations thereof.
6
- * Does NOT support descendant combinators (div p) — match within el.findAll() instead.
6
+ * Supports descendant combinators (div p, .parent span) — findFirst/findAll walk the ancestor stack.
7
7
  */
8
8
 
9
9
  // ---------------------------------------------------------------------------
@@ -152,14 +152,64 @@ export function tokenize(html) {
152
152
  // Selector parsing
153
153
  // ---------------------------------------------------------------------------
154
154
 
155
+ /**
156
+ * Split a selector on whitespace that is NOT inside [...] attribute brackets.
157
+ * "span[aria-label="foo bar"]" → ["span[aria-label="foo bar"]"] (single part)
158
+ * "div p span" → ["div", "p", "span"]
159
+ *
160
+ * @param {string} selector
161
+ * @returns {string[]}
162
+ */
163
+ function splitDescendant(selector) {
164
+ const parts = []
165
+ let current = ''
166
+ let inBracket = false
167
+ let inQuote = false
168
+ let quoteChar = ''
169
+
170
+ for (let i = 0; i < selector.length; i++) {
171
+ const c = selector[i]
172
+ if (inQuote) {
173
+ current += c
174
+ if (c === quoteChar) inQuote = false
175
+ } else if (inBracket) {
176
+ current += c
177
+ if (c === '"' || c === "'") { inQuote = true; quoteChar = c }
178
+ else if (c === ']') inBracket = false
179
+ } else if (c === '[') {
180
+ inBracket = true
181
+ current += c
182
+ } else if (c === ' ') {
183
+ if (current.trim()) parts.push(current.trim())
184
+ current = ''
185
+ } else {
186
+ current += c
187
+ }
188
+ }
189
+ if (current.trim()) parts.push(current.trim())
190
+ return parts
191
+ }
192
+
155
193
  /**
156
194
  * Parse a simple CSS selector string.
157
195
  * Supports: tag .class #id [attr] [attr="value"] and combinations.
196
+ * Also supports descendant combinators: parent child grandchild
158
197
  *
159
198
  * @param {string} selector
160
- * @returns {{ tag: string|null, id: string|null, classes: string[], attrs: Array<{name,value}> }}
199
+ * @returns {{ tag: string|null, id: string|null, classes: string[], attrs: Array<{name,value}> }} | Array of same
161
200
  */
162
201
  export function parseSelector(selector) {
202
+ const trimmed = selector.trim()
203
+ const parts = splitDescendant(trimmed)
204
+
205
+ if (parts.length > 1) {
206
+ return parts.map(parseSingleSelector)
207
+ }
208
+
209
+ return parseSingleSelector(trimmed)
210
+ }
211
+
212
+ function parseSingleSelector(selector) {
163
213
  const result = { tag: null, id: null, classes: [], attrs: [] }
164
214
  let s = selector.trim()
165
215
 
@@ -255,29 +305,105 @@ export function extractText(tokens) {
255
305
 
256
306
  /**
257
307
  * Find the first token matching selector.
308
+ * Supports descendant combinators: parent child
258
309
  * @param {Array} tokens
259
310
  * @param {string} selector
260
311
  * @returns {{ token, index } | null}
261
312
  */
262
313
  export function findFirst(tokens, selector) {
263
314
  const sel = parseSelector(selector)
315
+
316
+ // Simple selector (no spaces)
317
+ if (!Array.isArray(sel)) {
318
+ for (let i = 0; i < tokens.length; i++) {
319
+ if (matchesToken(tokens[i], sel)) return { token: tokens[i], index: i }
320
+ }
321
+ return null
322
+ }
323
+
324
+ // Descendant selector (array of selectors)
325
+ const target = sel[sel.length - 1] // Last selector is the element we're looking for
326
+ const ancestors = sel.slice(0, -1) // Earlier selectors are ancestor requirements
327
+
264
328
  for (let i = 0; i < tokens.length; i++) {
265
- if (matchesToken(tokens[i], sel)) return { token: tokens[i], index: i }
329
+ if (matchesToken(tokens[i], target)) {
330
+ // Check if all ancestor requirements are met
331
+ if (hasAncestors(tokens, i, ancestors)) {
332
+ return { token: tokens[i], index: i }
333
+ }
334
+ }
266
335
  }
267
336
  return null
268
337
  }
269
338
 
339
+ /**
340
+ * Check if a token at index has all required ancestors.
341
+ * @param {Array} tokens
342
+ * @param {number} index - index of the target token
343
+ * @param {Array} ancestorSels - array of ancestor selectors to match
344
+ */
345
+ function hasAncestors(tokens, index, ancestorSels) {
346
+ if (ancestorSels.length === 0) return true
347
+
348
+ // Build a stack of open tags from start to index
349
+ const stack = []
350
+ for (let i = 0; i < index; i++) {
351
+ if (tokens[i].type === 'open') {
352
+ stack.push(tokens[i])
353
+ } else if (tokens[i].type === 'close') {
354
+ stack.pop()
355
+ }
356
+ }
357
+
358
+ // Now check if the stack contains all required ancestors in order
359
+ // We need to find each ancestor selector somewhere in the stack, in order
360
+ let stackIdx = 0
361
+ for (const ancestorSel of ancestorSels) {
362
+ let found = false
363
+ while (stackIdx < stack.length) {
364
+ if (matchesToken(stack[stackIdx], ancestorSel)) {
365
+ found = true
366
+ stackIdx++ // Move past this one for the next ancestor search
367
+ break
368
+ }
369
+ stackIdx++
370
+ }
371
+ if (!found) return false
372
+ }
373
+
374
+ return true
375
+ }
376
+
270
377
  /**
271
378
  * Find all tokens matching selector.
379
+ * Supports descendant combinators: parent child
272
380
  * @param {Array} tokens
273
381
  * @param {string} selector
274
382
  * @returns {Array<{ token, index }>}
275
383
  */
276
384
  export function findAll(tokens, selector) {
277
385
  const sel = parseSelector(selector)
386
+
387
+ // Simple selector (no spaces)
388
+ if (!Array.isArray(sel)) {
389
+ const results = []
390
+ for (let i = 0; i < tokens.length; i++) {
391
+ if (matchesToken(tokens[i], sel)) results.push({ token: tokens[i], index: i })
392
+ }
393
+ return results
394
+ }
395
+
396
+ // Descendant selector (array of selectors)
397
+ const target = sel[sel.length - 1]
398
+ const ancestors = sel.slice(0, -1)
399
+
278
400
  const results = []
279
401
  for (let i = 0; i < tokens.length; i++) {
280
- if (matchesToken(tokens[i], sel)) results.push({ token: tokens[i], index: i })
402
+ if (matchesToken(tokens[i], target)) {
403
+ if (hasAncestors(tokens, i, ancestors)) {
404
+ results.push({ token: tokens[i], index: i })
405
+ }
406
+ }
281
407
  }
282
408
  return results
283
409
  }
@@ -214,6 +214,24 @@ await test('matches attribute selector [attr="value"] (negative)', async () => {
214
214
  assert(!result.has('input[type="password"]'), 'Expected no password input')
215
215
  })
216
216
 
217
+ await test('matches attribute selector [attr="value with spaces"]', async () => {
218
+ const spec = {
219
+ route: '/test-attr-spaces',
220
+ view: () => `<div><span aria-label="3 items in cart">3</span></div>`,
221
+ }
222
+ const result = renderSync(spec)
223
+ assert(result.has('span[aria-label="3 items in cart"]'), 'Expected span with aria-label containing spaces')
224
+ })
225
+
226
+ await test('descendant selector not confused by spaces in attr value', async () => {
227
+ const spec = {
228
+ route: '/test-desc-attr',
229
+ view: () => `<nav><a href="/cart" aria-label="3 items in cart">Cart</a></nav>`,
230
+ }
231
+ const result = renderSync(spec)
232
+ assert(result.has('nav a[aria-label="3 items in cart"]'), 'Expected descendant with spaced attr value')
233
+ })
234
+
217
235
  // ---------------------------------------------------------------------------
218
236
 
219
237
  console.log('\nresult.find() and result.get()\n')
@@ -444,6 +462,46 @@ await test('renderSync returns raw html string', async () => {
444
462
  assert(typeof result.html === 'string', 'Expected html to be a string')
445
463
  })
446
464
 
465
+ console.log('\nDescendant selectors\n')
466
+
467
+ await test('descendant selector finds nested elements', async () => {
468
+ const spec = {
469
+ route: '/list',
470
+ state: {},
471
+ view: () => `<div><ul class="items"><li>A</li><li>B</li></ul><ul class="other"><li>X</li></ul></div>`,
472
+ }
473
+ const result = renderSync(spec)
474
+ assertEqual(result.count('.items li'), 2)
475
+ assertEqual(result.count('.other li'), 1)
476
+ assertEqual(result.count('ul li'), 3)
477
+ })
478
+
479
+ await test('descendant selector with multiple levels', async () => {
480
+ const spec = {
481
+ route: '/table',
482
+ state: {},
483
+ view: () => `<table><thead><tr><th>Name</th></tr></thead><tbody><tr><td>Alice</td></tr><tr><td>Bob</td></tr></tbody></table>`,
484
+ }
485
+ const result = renderSync(spec)
486
+ assertEqual(result.count('tbody tr'), 2)
487
+ assertEqual(result.count('thead tr'), 1)
488
+ const cells = result.findAll('tbody td')
489
+ assertEqual(cells.length, 2)
490
+ assertEqual(cells[0].text, 'Alice')
491
+ assertEqual(cells[1].text, 'Bob')
492
+ })
493
+
494
+ await test('descendant selector returns empty for no matches', async () => {
495
+ const spec = {
496
+ route: '/empty',
497
+ state: {},
498
+ view: () => `<div class="container"><span>text</span></div>`,
499
+ }
500
+ const result = renderSync(spec)
501
+ assertEqual(result.count('.container li'), 0)
502
+ assertEqual(result.find('.container li'), null)
503
+ })
504
+
447
505
  // ---------------------------------------------------------------------------
448
506
 
449
507
  console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`)
package/src/ui/banner.js CHANGED
@@ -4,10 +4,13 @@
4
4
  * Full-width announcement bar — sits above the nav for promotions,
5
5
  * notices, or launch announcements.
6
6
  *
7
- * @param {object} opts
8
- * @param {string} opts.content - Raw HTML slot
9
- * @param {'info'|'promo'|'warning'} opts.variant - Visual style (default: 'info')
10
- * @param {string} opts.class
7
+ * @param {object} opts
8
+ * @param {string} opts.content - Raw HTML slot
9
+ * @param {'info'|'promo'|'warning'} opts.variant - Visual style (default: 'info')
10
+ * @param {'top'|'bottom'} opts.position - 'bottom' fixes the bar to the bottom of the viewport
11
+ * @param {boolean} opts.dismissible - Show a close (×) button
12
+ * @param {string} opts.dismissEvent - Mutation name fired on close (default: 'dismissBanner')
13
+ * @param {string} opts.class
11
14
  */
12
15
 
13
16
  import { escHtml as e } from '../html.js'
@@ -15,12 +18,24 @@ import { escHtml as e } from '../html.js'
15
18
  const VARIANTS = new Set(['info', 'promo', 'warning'])
16
19
 
17
20
  export function banner({
18
- content = '',
19
- variant = 'info',
20
- class: cls = '',
21
+ content = '',
22
+ variant = 'info',
23
+ position = 'top',
24
+ dismissible = false,
25
+ dismissEvent = 'dismissBanner',
26
+ class: cls = '',
21
27
  } = {}) {
22
28
  if (!VARIANTS.has(variant)) variant = 'info'
23
- const classes = ['ui-banner', `ui-banner--${variant}`, cls].filter(Boolean).join(' ')
29
+ const classes = [
30
+ 'ui-banner',
31
+ `ui-banner--${variant}`,
32
+ position === 'bottom' && 'ui-banner--bottom',
33
+ cls,
34
+ ].filter(Boolean).join(' ')
24
35
 
25
- return `<div class="${e(classes)}" role="banner">${content}</div>`
36
+ const closeBtn = dismissible
37
+ ? `<button class="ui-banner-close" data-event="${e(dismissEvent)}" aria-label="Close announcement">×</button>`
38
+ : ''
39
+
40
+ return `<div class="${e(classes)}" role="banner">${content}${closeBtn}</div>`
26
41
  }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Pulse UI — Brand Statement
3
+ *
4
+ * Full-width centred editorial statement — large serif or display type used
5
+ * for brand voice paragraphs between visual sections. This is not a pull-quote
6
+ * (no attribution border) — it is a first-person brand declaration.
7
+ *
8
+ * Typical use: "pieces that are both eclectic and everyday. Subtly precious,
9
+ * expressive, and refined…"
10
+ *
11
+ * @param {object} opts
12
+ * @param {string} opts.text - Statement text (can include basic HTML like <em>, <br>)
13
+ * @param {string} opts.attribution - Optional small attribution or tagline below
14
+ * @param {'serif'|'display'|'sans'} opts.font - Font style (default: 'serif')
15
+ * @param {'sm'|'md'|'lg'} opts.size - Text size (default: 'md')
16
+ * @param {'left'|'center'|'right'} opts.align - Text alignment (default: 'center')
17
+ * @param {string} opts.maxWidth - CSS max-width value (default: '860px')
18
+ * @param {string} opts.class
19
+ */
20
+
21
+ import { escHtml as e } from '../html.js'
22
+
23
+ const FONTS = new Set(['serif', 'display', 'sans'])
24
+ const SIZES = new Set(['sm', 'md', 'lg'])
25
+ const ALIGNS = new Set(['left', 'center', 'right'])
26
+
27
+ export function brandStatement({
28
+ text = '',
29
+ attribution = '',
30
+ font = 'serif',
31
+ size = 'md',
32
+ align = 'center',
33
+ maxWidth = '',
34
+ class: cls = '',
35
+ } = {}) {
36
+ if (!FONTS.has(font)) font = 'serif'
37
+ if (!SIZES.has(size)) size = 'md'
38
+ if (!ALIGNS.has(align)) align = 'center'
39
+
40
+ const classes = [
41
+ 'ui-brand-statement',
42
+ font !== 'serif' && `ui-brand-statement--${font}`,
43
+ size !== 'md' && `ui-brand-statement--${size}`,
44
+ align !== 'center' && `ui-brand-statement--${align}`,
45
+ cls,
46
+ ].filter(Boolean).join(' ')
47
+
48
+ const innerStyle = maxWidth
49
+ ? ` style="max-width:${e(typeof maxWidth === 'number' ? `${maxWidth}px` : maxWidth)}"`
50
+ : ''
51
+
52
+ return `<div class="${e(classes)}">
53
+ <div class="ui-brand-statement-inner"${innerStyle}>
54
+ <p class="ui-brand-statement-text">${text}</p>
55
+ ${attribution ? `<p class="ui-brand-statement-attr">${e(attribution)}</p>` : ''}
56
+ </div>
57
+ </div>`
58
+ }
package/src/ui/button.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * All visual variation goes through CSS modifier classes — never inline styles.
6
6
  *
7
7
  * @param {object} opts
8
- * @param {string} opts.label - Visible text (required)
8
+ * @param {string} opts.label - Visible text — HTML is escaped. For SVG/icon content use opts.icon or opts.iconAfter.
9
9
  * @param {'primary'|'secondary'|'ghost'|'danger'} opts.variant
10
10
  * @param {'sm'|'md'|'lg'} opts.size
11
11
  * @param {string} opts.href - Renders as <a> when set
@@ -20,7 +20,7 @@
20
20
 
21
21
  import { escHtml as e } from '../html.js'
22
22
 
23
- const VARIANTS = new Set(['primary', 'secondary', 'ghost', 'danger'])
23
+ const VARIANTS = new Set(['primary', 'secondary', 'ghost', 'ghost-light', 'danger'])
24
24
  const SIZES = new Set(['sm', 'md', 'lg'])
25
25
 
26
26
  export function button({
@@ -39,6 +39,16 @@ export function button({
39
39
  if (!VARIANTS.has(variant)) variant = 'primary'
40
40
  if (!SIZES.has(size)) size = 'md'
41
41
 
42
+ // Warn if href passed via attrs instead of the href prop (produces invalid <button href>)
43
+ if (typeof window === 'undefined' && !href && attrs.href) {
44
+ console.warn('[Pulse button] href passed via attrs produces invalid HTML (<button href>). Use button({ href: "..." }) instead — it renders an <a> automatically.')
45
+ }
46
+
47
+ // Warn if label contains HTML — it will be escaped. Use icon/iconAfter for SVG content.
48
+ if (typeof window === 'undefined' && label && label.includes('<')) {
49
+ console.warn('[Pulse button] label is HTML-escaped — SVG/HTML passed as label will render as text. Use icon: svgString or iconAfter: svgString instead.')
50
+ }
51
+
42
52
  const classes = [
43
53
  'ui-btn',
44
54
  `ui-btn--${variant}`,
@@ -59,6 +69,7 @@ export function button({
59
69
  }
60
70
 
61
71
  const attrsStr = Object.entries(attrs)
72
+ .filter(([, v]) => v !== undefined)
62
73
  .map(([k, v]) => ` ${e(k)}="${e(String(v))}"`)
63
74
  .join('')
64
75