@invisibleloop/pulse 0.12.3 → 0.14.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. package/.claude/commands/build-page.md +5 -1
  2. package/.claude/commands/verify.md +31 -17
  3. package/.claude/pulse-checklist.md +21 -1
  4. package/.github/workflows/publish.yml +1 -0
  5. package/.pulse-verified +1 -0
  6. package/CLAUDE.md +56 -15
  7. package/docs/.claude/commands/verify.md +4 -0
  8. package/docs/.claude/pulse-checklist.md +20 -2
  9. package/docs/public/.pulse-ui-version +1 -1
  10. package/docs/public/andypixel.css +237 -0
  11. package/docs/public/bookworms.css +422 -0
  12. package/docs/public/coutumes.css +584 -0
  13. package/docs/public/images/bookworms/hero-books.jpg +0 -0
  14. package/docs/public/images/bookworms/how-we-read-now.jpg +0 -0
  15. package/docs/public/images/bookworms/lantern-keepers.jpg +0 -0
  16. package/docs/public/images/bookworms/provincial-bookseller.jpg +0 -0
  17. package/docs/public/images/journey/178.jpg +0 -0
  18. package/docs/public/images/journey/29.jpg +0 -0
  19. package/docs/public/images/retrowheels/hero.jpg +0 -0
  20. package/docs/public/images/shop/devbook-pro.jpg +0 -0
  21. package/docs/public/images/suzukibento/1015.jpg +0 -0
  22. package/docs/public/images/suzukibento/1025.jpg +0 -0
  23. package/docs/public/images/suzukibento/1043.jpg +0 -0
  24. package/docs/public/images/suzukibento/1059.jpg +0 -0
  25. package/docs/public/images/suzukibento/1062.jpg +0 -0
  26. package/docs/public/images/suzukibento/1084.jpg +0 -0
  27. package/docs/public/images/swish/1469334031218-e382a71b716b.jpg +0 -0
  28. package/docs/public/images/swish/1483985988355-763728e1935b.jpg +0 -0
  29. package/docs/public/images/swish/1485518882345-15568b007407.jpg +0 -0
  30. package/docs/public/images/swish/1490481651871-ab68de25d43d.jpg +0 -0
  31. package/docs/public/images/swish/1496747611176-843222e1e57c.jpg +0 -0
  32. package/docs/public/images/swish/1502716119720-b23a93e5fe1b.jpg +0 -0
  33. package/docs/public/images/swish/1539109136881-3be0616acf4b.jpg +0 -0
  34. package/docs/public/images/swish/1551803091-e20673f15770.jpg +0 -0
  35. package/docs/public/images/swish/1558769132-cb1aea458c5e.jpg +0 -0
  36. package/docs/public/images/swish/1572804013309-59a88b7e92f1.jpg +0 -0
  37. package/docs/public/journey.css +528 -0
  38. package/docs/public/pulse-ui.css +1036 -22
  39. package/docs/public/retrowheels.css +402 -0
  40. package/docs/public/shop.css +235 -0
  41. package/docs/public/sparkys.css +349 -0
  42. package/docs/public/suzukibento.css +517 -0
  43. package/docs/public/swish.css +394 -0
  44. package/docs/server.js +19 -4
  45. package/docs/src/lib/nav.js +14 -3
  46. package/docs/src/pages/components/decorate.js +74 -0
  47. package/docs/src/pages/components/gallery.js +102 -0
  48. package/docs/src/pages/components/hero.js +1 -0
  49. package/docs/src/pages/components/marquee.js +76 -0
  50. package/docs/src/pages/components/photo-card.js +74 -0
  51. package/docs/src/pages/components/section.js +40 -1
  52. package/docs/src/pages/meta.js +26 -0
  53. package/docs/src/pages/templates/andypixel.js +260 -0
  54. package/docs/src/pages/templates/bookworms.js +227 -0
  55. package/docs/src/pages/templates/coutumes.js +226 -0
  56. package/docs/src/pages/templates/journey.js +186 -0
  57. package/docs/src/pages/templates/retrowheels.js +342 -0
  58. package/docs/src/pages/templates/shop.js +257 -0
  59. package/docs/src/pages/templates/sparkys.js +249 -0
  60. package/docs/src/pages/templates/suzukibento.js +200 -0
  61. package/docs/src/pages/templates/swish.js +313 -0
  62. package/docs/src/pages/templates.js +313 -2
  63. package/docs/src/pages/testing.js +2 -1
  64. package/examples/.claude/commands/pulse-build.md +7 -0
  65. package/examples/.claude/commands/pulse-dev.md +7 -0
  66. package/examples/.claude/commands/pulse-start.md +9 -0
  67. package/examples/.claude/commands/pulse-stop.md +5 -0
  68. package/examples/.claude/commands/verify.md +84 -0
  69. package/examples/.claude/coverage-check.js +127 -0
  70. package/examples/.claude/pulse-checklist.md +177 -0
  71. package/examples/andypixel.css +237 -0
  72. package/examples/andypixel.js +258 -0
  73. package/examples/bookworms.css +422 -0
  74. package/examples/bookworms.js +225 -0
  75. package/examples/contact.js +1 -0
  76. package/examples/counter.js +1 -0
  77. package/examples/dev.server.js +17 -0
  78. package/examples/journey.css +528 -0
  79. package/examples/journey.js +186 -0
  80. package/examples/pricing.js +1 -0
  81. package/examples/public/.pulse-ui-version +1 -0
  82. package/examples/public/pulse-ui.css +4031 -0
  83. package/examples/public/pulse-ui.js +257 -0
  84. package/examples/quiz.js +1 -0
  85. package/examples/retrowheels.css +402 -0
  86. package/examples/retrowheels.js +340 -0
  87. package/examples/shared.js +6 -0
  88. package/examples/suzukibento.css +513 -0
  89. package/examples/suzukibento.js +200 -0
  90. package/examples/swish.css +394 -0
  91. package/examples/swish.js +311 -0
  92. package/examples/todos.js +1 -0
  93. package/package.json +2 -3
  94. package/public/.pulse-ui-version +1 -1
  95. package/public/images/bookworms/hero-books.jpg +0 -0
  96. package/public/images/bookworms/how-we-read-now.jpg +0 -0
  97. package/public/images/bookworms/lantern-keepers.jpg +0 -0
  98. package/public/images/bookworms/provincial-bookseller.jpg +0 -0
  99. package/public/images/journey/178.jpg +0 -0
  100. package/public/images/journey/29.jpg +0 -0
  101. package/public/images/retrowheels/hero.jpg +0 -0
  102. package/public/images/shop/devbook-pro.jpg +0 -0
  103. package/public/images/suzukibento/1015.jpg +0 -0
  104. package/public/images/suzukibento/1025.jpg +0 -0
  105. package/public/images/suzukibento/1043.jpg +0 -0
  106. package/public/images/suzukibento/1059.jpg +0 -0
  107. package/public/images/suzukibento/1062.jpg +0 -0
  108. package/public/images/suzukibento/1084.jpg +0 -0
  109. package/public/images/swish/1469334031218-e382a71b716b.jpg +0 -0
  110. package/public/images/swish/1483985988355-763728e1935b.jpg +0 -0
  111. package/public/images/swish/1485518882345-15568b007407.jpg +0 -0
  112. package/public/images/swish/1490481651871-ab68de25d43d.jpg +0 -0
  113. package/public/images/swish/1496747611176-843222e1e57c.jpg +0 -0
  114. package/public/images/swish/1502716119720-b23a93e5fe1b.jpg +0 -0
  115. package/public/images/swish/1539109136881-3be0616acf4b.jpg +0 -0
  116. package/public/images/swish/1551803091-e20673f15770.jpg +0 -0
  117. package/public/images/swish/1558769132-cb1aea458c5e.jpg +0 -0
  118. package/public/images/swish/1572804013309-59a88b7e92f1.jpg +0 -0
  119. package/public/pulse-ui.css +1080 -22
  120. package/scripts/build.js +72 -18
  121. package/src/agent/checklist.md +22 -2
  122. package/src/agent/commands/verify.md +4 -0
  123. package/src/agent/guide-components.md +146 -8
  124. package/src/agent/guide-design-gallery.md +288 -0
  125. package/src/agent/guide-design-references.md +172 -0
  126. package/src/agent/guide-explore.md +313 -0
  127. package/src/agent/guide-styles.md +212 -11
  128. package/src/agent/guide-templates.md +223 -10
  129. package/src/agent/guide.md +9 -4
  130. package/src/agent/identity.md +2 -1
  131. package/src/agent/skills/verify/SKILL.md +64 -28
  132. package/src/agent/workflow.md +247 -61
  133. package/src/cli/dev.js +39 -14
  134. package/src/cli/fmt.js +113 -0
  135. package/src/cli/index.js +60 -33
  136. package/src/cli/logger.js +145 -0
  137. package/src/cli/scaffold.js +57 -3
  138. package/src/html.js +6 -1
  139. package/src/mcp/server.js +2561 -99
  140. package/src/mcp/validate-worker.js +49 -5
  141. package/src/runtime/ssr.js +4 -1
  142. package/src/server/index.js +29 -5
  143. package/src/spec/schema.js +21 -0
  144. package/src/spec/schema.test.js +28 -0
  145. package/src/testing/html.js +130 -4
  146. package/src/testing/testing.test.js +58 -0
  147. package/src/ui/banner.js +24 -9
  148. package/src/ui/brand-statement.js +58 -0
  149. package/src/ui/button.js +13 -2
  150. package/src/ui/card.js +20 -1
  151. package/src/ui/colophon.js +29 -0
  152. package/src/ui/contact-row.js +41 -0
  153. package/src/ui/cta.js +18 -4
  154. package/src/ui/decorate.js +55 -0
  155. package/src/ui/display-heading.js +44 -0
  156. package/src/ui/drop-cap.js +29 -0
  157. package/src/ui/editorial-hero.js +63 -0
  158. package/src/ui/entry-list.js +36 -0
  159. package/src/ui/feature.js +20 -3
  160. package/src/ui/footer.js +78 -9
  161. package/src/ui/gallery.js +56 -0
  162. package/src/ui/hero.js +68 -18
  163. package/src/ui/image-pair.js +72 -0
  164. package/src/ui/index.js +20 -0
  165. package/src/ui/input.js +1 -0
  166. package/src/ui/marquee.js +48 -0
  167. package/src/ui/nav.js +19 -9
  168. package/src/ui/photo-card.js +44 -0
  169. package/src/ui/product-card.js +65 -0
  170. package/src/ui/pullquote.js +14 -3
  171. package/src/ui/search.js +1 -0
  172. package/src/ui/section-label.js +38 -0
  173. package/src/ui/section.js +1 -1
  174. package/src/ui/service-card.js +47 -0
  175. package/src/ui/stat-grid.js +40 -0
  176. package/src/ui/stat.js +16 -2
  177. package/src/ui/testimonial.js +17 -6
  178. package/src/ui/textarea.js +1 -0
  179. package/src/ui/top-strip.js +47 -0
  180. package/src/ui/trust-list.js +46 -0
  181. package/src/ui/ui.test.js +35 -0
package/src/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
+ }
package/src/ui/cta.js CHANGED
@@ -21,17 +21,31 @@ export function cta({
21
21
  title = '',
22
22
  level = 2,
23
23
  subtitle = '',
24
+ body, // alias for subtitle
24
25
  actions = '',
25
26
  align = 'center',
26
27
  class: cls = '',
28
+ ...rest
27
29
  } = {}) {
30
+ // Accept 'body' as alias for 'subtitle'
31
+ const sub = subtitle || body || ''
32
+
33
+ // Warn about unknown props on the server
34
+ if (typeof window === 'undefined') {
35
+ const knownProps = new Set(['eyebrow', 'title', 'level', 'subtitle', 'body', 'actions', 'align', 'class'])
36
+ const unknownProps = Object.keys(rest).filter(k => !knownProps.has(k))
37
+ if (unknownProps.length > 0) {
38
+ console.warn(`[Pulse cta] Unknown prop${unknownProps.length > 1 ? 's' : ''}: ${unknownProps.join(', ')}. Did you mean 'subtitle' instead of '${unknownProps[0]}'?`)
39
+ }
40
+ }
41
+
28
42
  const classes = ['ui-cta', align === 'left' && 'ui-cta--left', cls].filter(Boolean).join(' ')
29
43
  const tag = `h${Math.min(Math.max(Math.floor(level), 1), 6)}`
30
44
 
31
45
  return `<div class="${e(classes)}">
32
- ${eyebrow ? `<p class="ui-cta-eyebrow">${e(eyebrow)}</p>` : ''}
33
- ${title ? `<${tag} class="ui-cta-title">${e(title)}</${tag}>` : ''}
34
- ${subtitle ? `<p class="ui-cta-subtitle">${e(subtitle)}</p>` : ''}
35
- ${actions ? `<div class="ui-cta-actions">${actions}</div>` : ''}
46
+ ${eyebrow ? `<p class="ui-cta-eyebrow">${e(eyebrow)}</p>` : ''}
47
+ ${title ? `<${tag} class="ui-cta-title">${e(title)}</${tag}>` : ''}
48
+ ${sub ? `<p class="ui-cta-subtitle">${e(sub)}</p>` : ''}
49
+ ${actions ? `<div class="ui-cta-actions">${actions}</div>` : ''}
36
50
  </div>`
37
51
  }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Pulse UI — Decorate
3
+ *
4
+ * Inline SVG background pattern element. Drop it inside any container as an
5
+ * absolute overlay to add texture — dots, grid, diagonal lines, or zigzag.
6
+ * The SVG is positioned absolute so it doesn't affect layout; the parent must
7
+ * have position: relative (or use a `u-relative` class).
8
+ *
9
+ * @param {object} opts
10
+ * @param {'dots'|'grid'|'lines'|'zigzag'|'cross'} opts.pattern - Pattern type (default: 'dots')
11
+ * @param {string} opts.color - Stroke/fill color, any CSS value (default: var(--ui-border))
12
+ * @param {number} opts.opacity - Opacity 0–1 (default: 0.4)
13
+ * @param {number} opts.size - Pattern tile size in px (default: 20)
14
+ * @param {string} opts.class
15
+ */
16
+
17
+ const PATTERNS = new Set(['dots', 'grid', 'lines', 'zigzag', 'cross'])
18
+
19
+ let _uid = 0
20
+
21
+ export function decorate({
22
+ pattern = 'dots',
23
+ color = 'var(--ui-border)',
24
+ opacity = 0.4,
25
+ size = 20,
26
+ class: cls = '',
27
+ } = {}) {
28
+ if (!PATTERNS.has(pattern)) pattern = 'dots'
29
+ const s = Math.max(4, parseInt(size) || 20)
30
+ const safeOpac = Math.min(1, Math.max(0, parseFloat(opacity) || 0.4))
31
+ // Use a color we can reference in the SVG — replace var() with currentColor trick
32
+ const useColor = color.startsWith('var(') ? 'currentColor' : color
33
+ const style = color.startsWith('var(') ? ` style="color:${color}"` : ''
34
+
35
+ const classes = ['ui-decorate', cls].filter(Boolean).join(' ')
36
+
37
+ const pid = `dp${++_uid}`
38
+ let patternSvg
39
+ const half = s / 2
40
+
41
+ if (pattern === 'dots') {
42
+ patternSvg = `<pattern id="${pid}" patternUnits="userSpaceOnUse" width="${s}" height="${s}"><circle cx="${half}" cy="${half}" r="1" fill="${useColor}"/></pattern>`
43
+ } else if (pattern === 'grid') {
44
+ patternSvg = `<pattern id="${pid}" patternUnits="userSpaceOnUse" width="${s}" height="${s}"><path d="M ${s} 0 L 0 0 0 ${s}" fill="none" stroke="${useColor}" stroke-width="0.5"/></pattern>`
45
+ } else if (pattern === 'lines') {
46
+ patternSvg = `<pattern id="${pid}" patternUnits="userSpaceOnUse" width="${s}" height="${s}" patternTransform="rotate(45)"><line x1="0" y1="0" x2="0" y2="${s}" stroke="${useColor}" stroke-width="0.75"/></pattern>`
47
+ } else if (pattern === 'zigzag') {
48
+ const q = s / 4
49
+ patternSvg = `<pattern id="${pid}" patternUnits="userSpaceOnUse" width="${s}" height="${half}"><polyline points="0,${half} ${q},0 ${half},${half} ${s - q},0 ${s},${half}" fill="none" stroke="${useColor}" stroke-width="0.75"/></pattern>`
50
+ } else { // cross
51
+ patternSvg = `<pattern id="${pid}" patternUnits="userSpaceOnUse" width="${s}" height="${s}"><line x1="${half}" y1="0" x2="${half}" y2="${s}" stroke="${useColor}" stroke-width="0.5"/><line x1="0" y1="${half}" x2="${s}" y2="${half}" stroke="${useColor}" stroke-width="0.5"/><circle cx="${half}" cy="${half}" r="1.5" fill="${useColor}"/></pattern>`
52
+ }
53
+
54
+ return `<svg class="${classes}"${style} xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" style="position:absolute;inset:0;pointer-events:none;opacity:${safeOpac}" aria-hidden="true"><defs>${patternSvg}</defs><rect width="100%" height="100%" fill="url(#${pid})"/></svg>`
55
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Pulse UI — Display Heading
3
+ *
4
+ * Large typographic heading for editorial moments. Controls line-break,
5
+ * tracking, max-width in ch. Use for hero titles, chapter openings,
6
+ * section leads.
7
+ *
8
+ * @param {object} opts
9
+ * @param {string} opts.text - Heading text (HTML entities supported)
10
+ * @param {number} opts.level - Heading level 1–6 (default 1)
11
+ * @param {number} opts.maxWidth - Max width in ch units (default 20)
12
+ * @param {boolean} opts.balance - Use text-wrap: balance (default true)
13
+ * @param {string} opts.tracking - Letter-spacing: 'tight' | 'normal' | 'wide' (default 'tight')
14
+ * @param {string} opts.class
15
+ */
16
+
17
+ import { escHtml as e } from '../html.js'
18
+
19
+ const TRACKING = {
20
+ tight: '-0.02em',
21
+ normal: '0',
22
+ wide: '0.05em',
23
+ }
24
+
25
+ export function displayHeading({
26
+ text = '',
27
+ level = 1,
28
+ maxWidth = 20,
29
+ balance = true,
30
+ tracking = 'tight',
31
+ class: cls = '',
32
+ } = {}) {
33
+ const tag = `h${Math.min(Math.max(Math.floor(level), 1), 6)}`
34
+ const classes = ['ui-display-heading', cls].filter(Boolean).join(' ')
35
+
36
+ const letterSpacing = TRACKING[tracking] || TRACKING.tight
37
+ const styles = [
38
+ `max-width:${maxWidth}ch`,
39
+ `letter-spacing:${letterSpacing}`,
40
+ balance && 'text-wrap:balance',
41
+ ].filter(Boolean).join(';')
42
+
43
+ return `<${tag} class="${e(classes)}" style="${styles}">${e(text)}</${tag}>`
44
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Pulse UI — Drop Cap
3
+ *
4
+ * First-letter drop cap for editorial openings. Wraps the first
5
+ * letter or word of a paragraph.
6
+ *
7
+ * @param {object} opts
8
+ * @param {string} opts.letter - The drop cap letter or word
9
+ * @param {string} opts.content - The rest of the paragraph text
10
+ * @param {number} opts.lines - How many lines tall (default 3)
11
+ * @param {string} opts.class
12
+ */
13
+
14
+ import { escHtml as e } from '../html.js'
15
+
16
+ export function dropCap({
17
+ letter = '',
18
+ content = '',
19
+ lines = 3,
20
+ class: cls = '',
21
+ } = {}) {
22
+ if (!letter) return content ? `<p>${e(content)}</p>` : ''
23
+
24
+ const classes = ['ui-drop-cap', cls].filter(Boolean).join(' ')
25
+
26
+ return `<p class="${e(classes)}">
27
+ <span class="ui-drop-cap-letter" style="float:left;font-size:${lines * 1.2}em;line-height:${lines * 0.8};margin-right:0.1em" aria-hidden="true">${e(letter)}</span>${e(content)}
28
+ </p>`
29
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Pulse UI — Editorial Hero
3
+ *
4
+ * Full-bleed hero with two side-by-side images and display text that
5
+ * overlaps both columns simultaneously. The text uses mix-blend-mode: multiply
6
+ * so it visually "lives inside" the photography — a signature technique in
7
+ * fashion and luxury editorial design.
8
+ *
9
+ * The overlay text is decorative (aria-hidden) with a separate accessible
10
+ * heading provided via the `heading` param.
11
+ *
12
+ * @param {object} opts
13
+ * @param {string} opts.leftImage - Raw HTML slot for the left image (<img>, uiImage, etc.)
14
+ * @param {string} opts.rightImage - Raw HTML slot for the right image
15
+ * @param {string|string[]} opts.lines - Headline text — string or array of lines
16
+ * @param {string} opts.heading - Accessible heading text (sr-only, replaces lines for AT)
17
+ * @param {string} opts.cta - Raw HTML slot — optional CTA link at the bottom
18
+ * @param {string} opts.color - Overlay text colour (default: var(--ui-color-accent))
19
+ * @param {'multiply'|'screen'|'overlay'|'none'} opts.blend - blend-mode for overlay text (default: 'multiply')
20
+ * @param {string} opts.ratio - Image aspect-ratio CSS string (default: '3/4')
21
+ * @param {string} opts.class
22
+ */
23
+
24
+ import { escHtml as e } from '../html.js'
25
+
26
+ const BLENDS = new Set(['multiply', 'screen', 'overlay', 'none'])
27
+
28
+ export function editorialHero({
29
+ leftImage = '',
30
+ rightImage = '',
31
+ lines = [],
32
+ heading = '',
33
+ cta = '',
34
+ color = '',
35
+ blend = 'multiply',
36
+ ratio = '3/4',
37
+ class: cls = '',
38
+ } = {}) {
39
+ if (!BLENDS.has(blend)) blend = 'multiply'
40
+
41
+ const classes = ['ui-editorial-hero', cls].filter(Boolean).join(' ')
42
+ const lineArray = Array.isArray(lines) ? lines : [lines]
43
+
44
+ const overlayStyle = [
45
+ color ? `color:${color.replace(/"/g, "'")}` : '',
46
+ blend !== 'none' ? `mix-blend-mode:${blend}` : '',
47
+ ].filter(Boolean).join(';')
48
+
49
+ const overlayAttr = overlayStyle ? ` style="${overlayStyle}"` : ''
50
+ const imageStyle = ratio ? ` style="aspect-ratio:${e(ratio)}"` : ''
51
+
52
+ const linesHtml = lineArray.map(l => `<span>${e(l)}</span>`).join('')
53
+
54
+ return `<section class="${e(classes)}">
55
+ ${heading ? `<h1 class="ui-editorial-hero-heading">${e(heading)}</h1>` : ''}
56
+ <div class="ui-editorial-hero-images"${imageStyle}>
57
+ <div class="ui-editorial-hero-img">${leftImage}</div>
58
+ <div class="ui-editorial-hero-img">${rightImage}</div>
59
+ </div>
60
+ ${linesHtml ? `<div class="ui-editorial-hero-overlay" aria-hidden="true"${overlayAttr}>${linesHtml}</div>` : ''}
61
+ ${cta ? `<div class="ui-editorial-hero-cta">${cta}</div>` : ''}
62
+ </section>`
63
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Pulse UI — Entry List
3
+ *
4
+ * Tabular list of items with leading metadata (year, number, date),
5
+ * title, and trailing description. For: backlists, release notes,
6
+ * project archives, talk logs, changelogs.
7
+ *
8
+ * @param {object} opts
9
+ * @param {Array<{meta: string, title: string, description: string}>} opts.items
10
+ * @param {string} opts.metaLabel - Accessible label for metadata column (e.g. "Year", "Date")
11
+ * @param {string} opts.class
12
+ */
13
+
14
+ import { escHtml as e } from '../html.js'
15
+
16
+ export function entryList({
17
+ items = [],
18
+ metaLabel = 'Date',
19
+ class: cls = '',
20
+ } = {}) {
21
+ if (!Array.isArray(items) || items.length === 0) return ''
22
+
23
+ const classes = ['ui-entry-list', cls].filter(Boolean).join(' ')
24
+
25
+ const rows = items.map(item => `
26
+ <div class="ui-entry-list-item">
27
+ <div class="ui-entry-list-meta" aria-label="${e(metaLabel)}">${e(item.meta || '')}</div>
28
+ <div class="ui-entry-list-content">
29
+ <h3 class="ui-entry-list-title">${e(item.title || '')}</h3>
30
+ ${item.description ? `<p class="ui-entry-list-description">${e(item.description)}</p>` : ''}
31
+ </div>
32
+ </div>
33
+ `).join('')
34
+
35
+ return `<div class="${e(classes)}">${rows}</div>`
36
+ }
package/src/ui/feature.js CHANGED
@@ -4,6 +4,7 @@
4
4
  * Icon + title + description block. The standard "why us" grid card.
5
5
  *
6
6
  * @param {object} opts
7
+ * @param {string} opts.image - Raw HTML slot — rendered above the icon (e.g. `<img src="...">`)
7
8
  * @param {string} opts.icon - Raw HTML slot — SVG or emoji; displayed in an accent-tinted box
8
9
  * @param {string} opts.title
9
10
  * @param {number} opts.level - Heading level 1–6 (default 3). Visual style is always ui-feature-title.
@@ -15,19 +16,35 @@
15
16
  import { escHtml as e } from '../html.js'
16
17
 
17
18
  export function feature({
19
+ image = '',
18
20
  icon = '',
19
21
  title = '',
20
22
  level = 3,
21
23
  description = '',
24
+ body, // alias for description
22
25
  center = false,
23
26
  class: cls = '',
27
+ ...rest
24
28
  } = {}) {
29
+ // Accept 'body' as alias for 'description'
30
+ const desc = description || body || ''
31
+
32
+ // Warn about unknown props on the server
33
+ if (typeof window === 'undefined') {
34
+ const knownProps = new Set(['image', 'icon', 'title', 'level', 'description', 'body', 'center', 'class'])
35
+ const unknownProps = Object.keys(rest).filter(k => !knownProps.has(k))
36
+ if (unknownProps.length > 0) {
37
+ console.warn(`[Pulse feature] Unknown prop${unknownProps.length > 1 ? 's' : ''}: ${unknownProps.join(', ')}. Did you mean 'description' instead of '${unknownProps[0]}'?`)
38
+ }
39
+ }
40
+
25
41
  const classes = ['ui-feature', center && 'ui-feature--center', cls].filter(Boolean).join(' ')
26
42
  const tag = `h${Math.min(Math.max(Math.floor(level), 1), 6)}`
27
43
 
28
44
  return `<div class="${e(classes)}">
29
- ${icon ? `<div class="ui-feature-icon" aria-hidden="true">${icon}</div>` : ''}
30
- ${title ? `<${tag} class="ui-feature-title">${e(title)}</${tag}>` : ''}
31
- ${description ? `<p class="ui-feature-desc">${e(description)}</p>` : ''}
45
+ ${image ? `<div class="ui-feature-image">${image}</div>` : ''}
46
+ ${icon ? `<div class="ui-feature-icon" aria-hidden="true">${icon}</div>` : ''}
47
+ ${title ? `<${tag} class="ui-feature-title">${e(title)}</${tag}>` : ''}
48
+ ${desc ? `<p class="ui-feature-desc">${e(desc)}</p>` : ''}
32
49
  </div>`
33
50
  }
package/src/ui/footer.js CHANGED
@@ -5,11 +5,27 @@
5
5
  * Handles accessibility (landmark role, aria-label on nav) and responsive
6
6
  * stacking automatically.
7
7
  *
8
+ * Two layouts:
9
+ *
10
+ * Simple (default):
11
+ * logo + flat link list + legal — one-liner or small site footer.
12
+ *
13
+ * Rich (when `columns` is provided):
14
+ * Optional subscribe form row, multi-column link grid, optional wordmark,
15
+ * and legal row. Use for brand-heavy or e-commerce sites.
16
+ *
8
17
  * @param {object} opts
9
- * @param {string} opts.logo - Raw HTML slot — SVG, img, or text
10
- * @param {string} opts.logoHref - Logo link destination (default: '/')
11
- * @param {Array<{label: string, href: string}>} opts.links - Footer nav links
12
- * @param {string} opts.legal - Copyright / legal text (e.g. '© 2026 Acme Ltd.')
18
+ * @param {string} opts.logo - Raw HTML slot — SVG, img, or text
19
+ * @param {string} opts.logoHref - Logo link destination (default: '/')
20
+ * @param {Array<{label: string, href: string}>} opts.links - Flat footer nav links (simple layout)
21
+ * @param {string} opts.legal - Copyright / legal text
22
+ *
23
+ * Rich layout params:
24
+ * @param {Array<{title: string, links: Array<{label, href}>}>} opts.columns - Multi-column link sections
25
+ * @param {string} opts.subscribe - Raw HTML slot — newsletter subscribe form
26
+ * @param {string} opts.wordmark - Giant display text shown at the very bottom of the footer
27
+ * @param {string} opts.background - CSS background value — sets the footer background colour
28
+ * @param {string} opts.color - Foreground/text colour — use when background needs contrast override
13
29
  * @param {string} opts.class
14
30
  */
15
31
 
@@ -20,19 +36,72 @@ export function footer({
20
36
  logoHref = '/',
21
37
  links = [],
22
38
  legal = '',
39
+ columns = [],
40
+ subscribe = '',
41
+ wordmark = '',
42
+ background = '',
43
+ color = '',
23
44
  class: cls = '',
24
45
  } = {}) {
25
- const classes = ['ui-footer', cls].filter(Boolean).join(' ')
46
+ const isRich = columns.length > 0 || subscribe || wordmark
26
47
 
27
- const linksHtml = links.map(({ label = '', href = '' }) =>
28
- `<a href="${e(href)}" class="ui-footer-link">${e(label)}</a>`
29
- ).join('')
48
+ const styles = [
49
+ background && `background:${background.replace(/"/g, "'")}`,
50
+ color && `color:${color.replace(/"/g, "'")}`,
51
+ ].filter(Boolean).join(';')
52
+ const styleAttr = styles ? ` style="${styles}"` : ''
53
+
54
+ const classes = ['ui-footer', isRich && 'ui-footer--rich', cls].filter(Boolean).join(' ')
55
+
56
+ // Defensive shape checks — common mistakes that produce silent empty output
57
+ if (typeof window === 'undefined') { // server only
58
+ if (columns.length && columns.some(c => c.items && !c.links)) {
59
+ console.warn('[Pulse footer] Column shape should be { title, links: [...] } — found { items } instead. Did you mean links?')
60
+ }
61
+ if (!columns.length && links.some(l => l.title || l.items || l.heading)) {
62
+ console.warn('[Pulse footer] Simple footer expects links: [{ label, href }] — found grouped shape ({ title/heading/items } keys). For multi-column layout, use columns: [{ title, links: [...] }] instead of links.')
63
+ }
64
+ }
30
65
 
31
- return `<footer class="${e(classes)}">
66
+ // Simple layout
67
+ if (!isRich) {
68
+ const linksHtml = links.map(({ label = '', href = '' }) =>
69
+ `<a href="${e(href)}" class="ui-footer-link">${e(label)}</a>`
70
+ ).join('')
71
+
72
+ return `<footer class="${e(classes)}"${styleAttr}>
32
73
  <div class="ui-footer-inner">
33
74
  ${logo ? `<a href="${e(logoHref)}" class="ui-footer-logo">${logo}</a>` : ''}
34
75
  ${links.length ? `<nav class="ui-footer-links" aria-label="Footer navigation">${linksHtml}</nav>` : ''}
35
76
  ${legal ? `<p class="ui-footer-legal">${e(legal)}</p>` : ''}
36
77
  </div>
78
+ </footer>`
79
+ }
80
+
81
+ // Rich layout
82
+ const columnsHtml = columns.map(({ title = '', links: cols = [] }) => `
83
+ <div class="ui-footer-col">
84
+ ${title ? `<p class="ui-footer-col-title">${e(title)}</p>` : ''}
85
+ <ul class="ui-footer-col-links" role="list">
86
+ ${cols.map(({ label = '', href = '' }) => `<li><a href="${e(href)}">${e(label)}</a></li>`).join('')}
87
+ </ul>
88
+ </div>`).join('')
89
+
90
+ const legalLinks = links.map(({ label = '', href = '' }) =>
91
+ `<a href="${e(href)}">${e(label)}</a>`
92
+ ).join('')
93
+
94
+ return `<footer class="${e(classes)}"${styleAttr}>
95
+ ${logo || subscribe ? `<div class="ui-footer-top">
96
+ ${logo ? `<a href="${e(logoHref)}" class="ui-footer-logo">${logo}</a>` : ''}
97
+ ${subscribe ? `<div class="ui-footer-subscribe">${subscribe}</div>` : ''}
98
+ </div>` : ''}
99
+ ${columns.length ? `<div class="ui-footer-columns">${columnsHtml}
100
+ </div>` : ''}
101
+ ${wordmark ? `<div class="ui-footer-wordmark" data-wordmark="${e(wordmark)}" aria-hidden="true"></div>` : ''}
102
+ ${legal || links.length ? `<div class="ui-footer-legal-row">
103
+ ${legalLinks}
104
+ ${legal ? `<span>${e(legal)}</span>` : ''}
105
+ </div>` : ''}
37
106
  </footer>`
38
107
  }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Pulse UI — Gallery
3
+ *
4
+ * Responsive image grid for photos, illustrations, or any visual content.
5
+ * Three layout modes: grid (equal cells), strip (single scrollable row), masonry (Pinterest-style).
6
+ *
7
+ * @param {object} opts
8
+ * @param {Array<{src:string,alt:string,caption?:string,href?:string}>} opts.images
9
+ * @param {'grid'|'strip'|'masonry'} opts.layout - Grid layout (default: 'grid')
10
+ * @param {1|2|3|4} opts.cols - Columns in grid/masonry layout (default: 3)
11
+ * @param {'sm'|'md'|'lg'} opts.gap - Gap between images (default: 'md')
12
+ * @param {boolean} opts.rounded - Rounded corners on images
13
+ * @param {string} opts.ratio - CSS aspect-ratio for grid/strip cells e.g. '4/3', '1/1', '16/9'
14
+ * @param {string} opts.class
15
+ */
16
+
17
+ import { escHtml as e } from '../html.js'
18
+
19
+ const LAYOUTS = new Set(['grid', 'strip', 'masonry'])
20
+ const GAPS = new Set(['sm', 'md', 'lg'])
21
+
22
+ export function gallery({
23
+ images = [],
24
+ layout = 'grid',
25
+ cols = 3,
26
+ gap = 'md',
27
+ rounded = false,
28
+ ratio = '4/3',
29
+ class: cls = '',
30
+ } = {}) {
31
+ if (!LAYOUTS.has(layout)) layout = 'grid'
32
+ if (!GAPS.has(gap)) gap = 'md'
33
+ const safeC = Math.min(Math.max(Math.floor(cols), 1), 4)
34
+
35
+ const classes = [
36
+ 'ui-gallery',
37
+ `ui-gallery--${layout}`,
38
+ `ui-gallery--cols-${safeC}`,
39
+ gap !== 'md' && `ui-gallery--gap-${gap}`,
40
+ rounded && 'ui-gallery--rounded',
41
+ cls,
42
+ ].filter(Boolean).join(' ')
43
+
44
+ const items = images.map(img => {
45
+ const imgTag = `<img src="${e(img.src)}" alt="${e(img.alt || '')}" class="ui-gallery-img" loading="lazy" decoding="async">`
46
+ const cap = img.caption ? `<figcaption class="ui-gallery-caption">${e(img.caption)}</figcaption>` : ''
47
+ const inner = img.href
48
+ ? `<a href="${e(img.href)}" class="ui-gallery-link">${imgTag}</a>`
49
+ : imgTag
50
+ const cropStyle = (layout !== 'masonry' && ratio) ? ` style="aspect-ratio:${e(ratio)}"` : ''
51
+ const crop = `<div class="ui-gallery-crop"${cropStyle}>${inner}</div>`
52
+ return `<figure class="ui-gallery-item">${crop}${cap}</figure>`
53
+ }).join('\n')
54
+
55
+ return `<div class="${e(classes)}">\n${items}\n</div>`
56
+ }