@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
@@ -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
 
@@ -37,6 +53,12 @@ if (!valid) {
37
53
  // Render the view and run additional HTML checks
38
54
  const warnings = [...schemaWarnings]
39
55
 
56
+ // meta.theme missing — a page without theme set will use the user's system preference,
57
+ // which can produce unexpected dark/light rendering for spec authors who built for one mode.
58
+ if (!spec.meta?.theme) {
59
+ warnings.push('meta.theme is not set — the page will follow the user\'s system colour-scheme preference. Add meta: { theme: \'light\' } or meta: { theme: \'dark\' } to lock the appearance. Most Pulse pages should set this explicitly.')
60
+ }
61
+
40
62
  // Raw-response specs (contentType + render) produce non-HTML output —
41
63
  // skip all view rendering and HTML-specific checks for them.
42
64
  const isRawResponse = typeof spec.render === 'function' ||
@@ -98,7 +120,7 @@ try {
98
120
  }
99
121
 
100
122
  // 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'])
123
+ 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
124
  const usedUtils = new Set([...html.matchAll(/\bu-([\w-]+)/g)].map(m => 'u-' + m[1]))
103
125
  const unknownUtils = [...usedUtils].filter(c => !KNOWN_UTILS.has(c))
104
126
  if (unknownUtils.length) {
@@ -110,9 +132,16 @@ try {
110
132
  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
133
  }
112
134
 
113
- // Unescaped apostrophes in attribute values
114
- if (/=\s*'[^']*'[^']*'/.test(html)) {
115
- warnings.push('Possible unescaped apostrophe in an HTML attribute — use ' or ' or switch to double quotes')
135
+ // Unescaped apostrophes — only flag inside attribute values (attr='it's wrong'),
136
+ // not in text content (it's fine in body text). Match: attr='...apostrophe...'
137
+ // Pattern: an attribute assignment with single-quote delimiter containing a second single quote
138
+ if (/\s[\w-]+=\s*'[^'<>]*'[^']*'/.test(html)) {
139
+ warnings.push('Possible unescaped apostrophe in an HTML attribute value — use &apos; or &#39; or switch to double-quoted attributes')
140
+ }
141
+
142
+ // Inline <style> blocks in view — blocked by Pulse CSP nonce policy
143
+ if (/<style[\s>]/i.test(html)) {
144
+ 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.')
116
145
  }
117
146
  } catch { /* view may depend on server data — skip HTML checks */ }
118
147
 
@@ -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
  /**
@@ -779,7 +780,23 @@ export async function createServer(entries, options = {}) {
779
780
  process.on('SIGINT', shutdown)
780
781
 
781
782
  server.listen(port, () => {
782
- console.log(`⚡ Pulse running at http://localhost:${port}`)
783
+ import('../cli/fmt.js').then(({ c, table, icon }) => {
784
+ const url = `http://localhost:${port}`
785
+ const rows = specs.map(s => {
786
+ const hydrated = s.hydrate ? c.green('✓') : c.dim('—')
787
+ const method = s.method?.toUpperCase() || 'GET'
788
+ const route = s.route || '?'
789
+ return [c.cyan(route), c.dim(method), hydrated]
790
+ })
791
+ console.log(`\n ${icon.bolt()} ${c.bold('Pulse')} ${c.dim(`→ ${url}`)}\n`)
792
+ if (rows.length > 0) {
793
+ const tbl = table(['Route', 'Method', 'Hydrated'], rows)
794
+ console.log(tbl.split('\n').map(l => ' ' + l).join('\n'))
795
+ console.log()
796
+ }
797
+ }).catch(() => {
798
+ console.log(`⚡ Pulse running at http://localhost:${port}`)
799
+ })
783
800
  })
784
801
 
785
802
  return {
@@ -981,7 +998,10 @@ async function handleStreamResponse(spec, ctx, req, res, extraBody = '', dev = f
981
998
  meta.schema ? ` <script type="application/ld+json">${JSON.stringify(meta.schema)}</script>` : '',
982
999
  ].filter(Boolean).join('\n')
983
1000
 
984
- const bodyAttr = meta.theme ? ` data-theme="${escHtml(meta.theme)}"` : ''
1001
+ const bodyAttr = [
1002
+ meta.theme ? `data-theme="${escHtml(meta.theme)}"` : '',
1003
+ meta.vibe ? `data-vibe="${escHtml(meta.vibe)}"` : '',
1004
+ ].filter(Boolean).map(a => ` ${a}`).join('')
985
1005
 
986
1006
  const docOpen = `<!DOCTYPE html>
987
1007
  <html lang="en">
@@ -389,6 +389,11 @@ export function validateSpec(spec) {
389
389
  } else if (typeof desc === 'string' && desc.trim() === 'Built with Pulse') {
390
390
  warnings.push('spec.meta.description is still the default "Built with Pulse" — replace it with a real description')
391
391
  }
392
+ // If meta.theme is not set, Pulse defaults to dark — warn so agents don't accidentally
393
+ // ship a dark-themed page when they intended light, or forget to declare intent.
394
+ if (spec.meta.theme === undefined && typeof spec.meta.theme !== 'function') {
395
+ 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.')
396
+ }
392
397
  }
393
398
 
394
399
  return {
@@ -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
@@ -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
 
package/src/ui/card.js CHANGED
@@ -11,20 +11,39 @@
11
11
  * @param {string} opts.content - HTML string for the card body
12
12
  * @param {string} opts.footer - HTML string for the card footer (optional)
13
13
  * @param {boolean} opts.flush - Remove internal padding (for full-bleed content)
14
+ * @param {'default'|'elevated'|'bordered'|'flat'|'glass'|'tinted'} opts.variant
15
+ * Visual style:
16
+ * - 'default' — standard surface with subtle shadow (same as no variant)
17
+ * - 'elevated' — stronger shadow, lifts above the page
18
+ * - 'bordered' — no shadow, prominent border
19
+ * - 'flat' — no shadow, no border, background only
20
+ * - 'glass' — frosted-glass effect with backdrop blur
21
+ * - 'tinted' — accent-tinted background, no shadow
14
22
  * @param {string} opts.class
15
23
  */
16
24
 
17
25
  import { escHtml as e } from '../html.js'
18
26
 
27
+ const VARIANTS = new Set(['default', 'elevated', 'bordered', 'flat', 'glass', 'tinted'])
28
+
19
29
  export function card({
20
30
  title = '',
21
31
  level = 3,
22
32
  content = '',
23
33
  footer = '',
24
34
  flush = false,
35
+ variant = 'default',
25
36
  class: cls = '',
26
37
  } = {}) {
27
- const classes = ['ui-card', flush ? 'ui-card--flush' : '', cls].filter(Boolean).join(' ')
38
+ if (!VARIANTS.has(variant)) variant = 'default'
39
+
40
+ const classes = [
41
+ 'ui-card',
42
+ variant !== 'default' ? `ui-card--${variant}` : '',
43
+ flush ? 'ui-card--flush' : '',
44
+ cls,
45
+ ].filter(Boolean).join(' ')
46
+
28
47
  const tag = `h${Math.min(Math.max(Math.floor(level), 1), 6)}`
29
48
 
30
49
  const titleHtml = title ? `<div class="ui-card-header"><${tag} class="ui-card-title">${e(title)}</${tag}></div>` : ''
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Pulse UI — Colophon
3
+ *
4
+ * Paperback-style end-of-page metadata block. A minimal footer
5
+ * variant with tight type, no columns, centered layout.
6
+ *
7
+ * @param {object} opts
8
+ * @param {string} opts.content - Raw HTML content slot
9
+ * @param {'left'|'center'} opts.align - Text alignment (default 'center')
10
+ * @param {string} opts.class
11
+ */
12
+
13
+ import { escHtml as e } from '../html.js'
14
+
15
+ export function colophon({
16
+ content = '',
17
+ align = 'center',
18
+ class: cls = '',
19
+ } = {}) {
20
+ const classes = [
21
+ 'ui-colophon',
22
+ align === 'left' && 'ui-colophon--left',
23
+ cls,
24
+ ].filter(Boolean).join(' ')
25
+
26
+ return `<footer class="${e(classes)}">
27
+ <div class="ui-colophon-inner">${content}</div>
28
+ </footer>`
29
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Pulse UI — ContactRow
3
+ *
4
+ * A horizontal strip of contact items — phone, email, address, hours.
5
+ * Universal on local-business and trades sites. Renders as a <ul> of
6
+ * icon + label pairs, each optionally linked.
7
+ *
8
+ * @param {object} opts
9
+ * @param {Array<{icon?: string, label: string, href?: string}>} opts.items
10
+ * @param {'sm'|'md'} opts.size - Text size (default: 'md')
11
+ * @param {string} opts.class
12
+ */
13
+
14
+ import { escHtml as e } from '../html.js'
15
+
16
+ const SIZES = new Set(['sm', 'md'])
17
+
18
+ export function contactRow({
19
+ items = [],
20
+ size = 'md',
21
+ class: cls = '',
22
+ } = {}) {
23
+ if (!SIZES.has(size)) size = 'md'
24
+
25
+ const classes = [
26
+ 'ui-contact-row',
27
+ size !== 'md' && `ui-contact-row--${size}`,
28
+ cls,
29
+ ].filter(Boolean).join(' ')
30
+
31
+ const itemsHtml = items.map(({ icon = '', label = '', href = '' }) => {
32
+ const iconHtml = icon ? `<span class="ui-contact-row-icon" aria-hidden="true">${icon}</span>` : ''
33
+ const inner = `${iconHtml}<span>${e(label)}</span>`
34
+ const content = href
35
+ ? `<a href="${e(href)}">${inner}</a>`
36
+ : inner
37
+ return `<li class="ui-contact-row-item">${content}</li>`
38
+ }).join('')
39
+
40
+ return `<ul class="${e(classes)}">${itemsHtml}</ul>`
41
+ }