@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/hero.js CHANGED
@@ -9,22 +9,26 @@
9
9
  * @param {string} opts.title - Main headline
10
10
  * @param {string} opts.subtitle - Supporting text beneath the headline
11
11
  * @param {string} opts.actions - Raw HTML slot — typically button() or appBadge() calls
12
- * @param {string} opts.image - Raw HTML slot for the image (activates split layout)
12
+ * @param {string} opts.image - Raw HTML slot for the image
13
13
  * @param {'right'|'left'} opts.imageAlign - Which side the image sits on (default: 'right')
14
14
  * @param {'center'|'left'} opts.align - Text alignment when no image (default: 'center')
15
15
  * @param {'md'|'sm'} opts.size - Vertical padding: 'md' (default, 5rem) or 'sm' (2.5rem)
16
+ * @param {'split'|'asymmetric'|'overlap'} opts.layout - Layout when image is provided.
17
+ * 'split' (default): 50/50 columns.
18
+ * 'asymmetric': 60/40 text-heavy columns.
19
+ * 'overlap': image fills the section background, text overlaid with dark gradient.
16
20
  * @param {boolean|'purple'|'blue'|'green'|'rose'|'orange'} opts.gradient
17
- * Gradient background preset. true/'purple' = deep indigo, 'blue' = navy,
18
- * 'green' = forest, 'rose' = crimson, 'orange' = amber. (default: false)
19
- * @param {string} opts.background - Arbitrary CSS background value — overrides gradient preset.
20
- * Accepts any valid CSS: solid colours ('#1a1a2e'), gradients ('linear-gradient(...)'), etc.
21
- * @param {string} opts.eyebrowColor - Overrides the eyebrow text colour — use when the default accent colour lacks contrast against a custom background
21
+ * @param {string} opts.background - Arbitrary CSS background value — overrides gradient preset
22
+ * @param {string} opts.backgroundImage - CSS background-image value (url, gradient, etc) — enables full-bleed mode
23
+ * @param {boolean|number} opts.overlay - Dark overlay opacity for backgroundImage mode (0–1, or true for 0.4 default)
24
+ * @param {string} opts.eyebrowColor - Overrides eyebrow text colour
22
25
  * @param {string} opts.class
23
26
  */
24
27
 
25
28
  import { escHtml as e } from '../html.js'
26
29
 
27
30
  const GRADIENT_PRESETS = new Set(['purple', 'blue', 'green', 'rose', 'orange'])
31
+ const LAYOUTS = new Set(['split', 'asymmetric', 'overlap'])
28
32
 
29
33
  export function hero({
30
34
  eyebrow = '',
@@ -35,15 +39,20 @@ export function hero({
35
39
  imageAlign = 'right',
36
40
  align = 'center',
37
41
  size = 'md',
42
+ layout = 'split',
38
43
  gradient = false,
39
44
  background = '',
45
+ backgroundImage = '',
46
+ overlay = false,
40
47
  eyebrowColor = '',
41
48
  color = '',
42
49
  class: cls = '',
43
50
  } = {}) {
44
- const split = Boolean(image)
51
+ const hasImage = Boolean(image)
52
+ const hasBackgroundImage = Boolean(backgroundImage)
53
+ const safeLayout = (hasImage && LAYOUTS.has(layout)) ? layout : 'split'
45
54
 
46
- // Resolve gradient class — true/'purple' share the base class; others get their own modifier
55
+ // Resolve gradient class
47
56
  const gradientClass = gradient
48
57
  ? (gradient === true || gradient === 'purple')
49
58
  ? 'ui-hero--gradient'
@@ -52,20 +61,38 @@ export function hero({
52
61
 
53
62
  const classes = [
54
63
  'ui-hero',
55
- split ? 'ui-hero--split' : align === 'left' && 'ui-hero--left',
56
- split && imageAlign === 'left' && 'ui-hero--media-left',
64
+ !hasImage && !hasBackgroundImage && align === 'left' && 'ui-hero--left',
65
+ hasImage && `ui-hero--${safeLayout}`,
66
+ hasImage && safeLayout !== 'overlap' && imageAlign === 'left' && 'ui-hero--media-left',
67
+ hasBackgroundImage && 'ui-hero--bg-image',
57
68
  size === 'sm' && 'ui-hero--sm',
58
69
  gradientClass,
59
70
  cls,
60
71
  ].filter(Boolean).join(' ')
61
72
 
62
- // Inline style — strip quotes to prevent attribute injection
63
- const styles = [
64
- background && `background:${background.replace(/"/g, "'")}`,
65
- color && `color:${color.replace(/"/g, "'")};--ui-muted:${color.replace(/"/g, "'")}`,
66
- ].filter(Boolean).join(';')
73
+ // Build inline styles
74
+ const styleArr = []
75
+ if (background) styleArr.push(`background:${background.replace(/"/g, "'")}`)
76
+ if (backgroundImage) {
77
+ styleArr.push(`background-image:${backgroundImage.replace(/"/g, "'")}`)
78
+ styleArr.push(`background-size:cover`)
79
+ styleArr.push(`background-position:center`)
80
+ styleArr.push(`position:relative`)
81
+ }
82
+ if (color) {
83
+ styleArr.push(`color:${color.replace(/"/g, "'")}`)
84
+ styleArr.push(`--ui-muted:${color.replace(/"/g, "'")}`)
85
+ }
86
+
87
+ const styles = styleArr.join(';')
67
88
  const bgStyle = styles ? ` style="${styles}"` : ''
68
89
 
90
+ // Overlay for background-image mode
91
+ const overlayOpacity = overlay === true ? 0.4 : (typeof overlay === 'number' ? overlay : 0)
92
+ const overlayHtml = hasBackgroundImage && overlay
93
+ ? `<div class="ui-hero-overlay" style="position:absolute;inset:0;background:rgba(0,0,0,${overlayOpacity});pointer-events:none;"></div>`
94
+ : ''
95
+
69
96
  const eyebrowStyle = eyebrowColor ? ` style="color:${eyebrowColor.replace(/"/g, "'")}"` : ''
70
97
  const content = `
71
98
  ${eyebrow ? `<p class="ui-hero-eyebrow"${eyebrowStyle}>${e(eyebrow)}</p>` : ''}
@@ -73,18 +100,41 @@ export function hero({
73
100
  ${subtitle ? `<p class="ui-hero-subtitle">${e(subtitle)}</p>` : ''}
74
101
  ${actions ? `<div class="ui-hero-actions">${actions}</div>` : ''}`
75
102
 
76
- if (split) {
103
+ // Full-bleed background-image mode
104
+ if (hasBackgroundImage) {
105
+ return `<section class="${e(classes)}"${bgStyle}>
106
+ ${overlayHtml}
107
+ <div class="ui-hero-inner" style="position:relative;z-index:1;">${content}
108
+ </div>
109
+ </section>`
110
+ }
111
+
112
+ // No image — centered or left-aligned
113
+ if (!hasImage) {
114
+ return `<section class="${e(classes)}"${bgStyle}>
115
+ <div class="ui-hero-inner">${content}
116
+ </div>
117
+ </section>`
118
+ }
119
+
120
+ // Overlap: image fills the section, text overlaid
121
+ if (safeLayout === 'overlap') {
77
122
  return `<section class="${e(classes)}"${bgStyle}>
123
+ <div class="ui-hero-overlap-media" aria-hidden="true">${image}</div>
78
124
  <div class="ui-hero-inner">
79
125
  <div class="ui-hero-content">${content}
80
126
  </div>
81
- <div class="ui-hero-media">${image}</div>
82
127
  </div>
83
128
  </section>`
84
129
  }
85
130
 
131
+ // Split or asymmetric
86
132
  return `<section class="${e(classes)}"${bgStyle}>
87
- <div class="ui-hero-inner">${content}
133
+ <div class="ui-hero-inner">
134
+ <div class="ui-hero-content">${content}
135
+ </div>
136
+ <div class="ui-hero-media">${image}</div>
88
137
  </div>
89
138
  </section>`
90
139
  }
140
+
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Pulse UI — Image Pair
3
+ *
4
+ * Two images side by side — no text. A pure editorial layout used in fashion,
5
+ * portfolio, and magazine contexts to present photography at full scale.
6
+ * Both images share the same aspect ratio and fill equal columns.
7
+ *
8
+ * For image + text layouts, use the media() component instead.
9
+ *
10
+ * @param {object} opts
11
+ * @param {string} opts.leftSrc - Left image URL
12
+ * @param {string} opts.leftAlt - Left image alt text
13
+ * @param {string} opts.rightSrc - Right image URL
14
+ * @param {string} opts.rightAlt - Right image alt text
15
+ * @param {string} opts.ratio - CSS aspect-ratio for both images (default: '3/4')
16
+ * @param {'none'|'sm'|'md'} opts.gap - Gap between images (default: 'none')
17
+ * @param {string} opts.label - Optional aria-label for the section
18
+ * @param {string} opts.leftLabel - Link label below left image (e.g. 'Craftmanship')
19
+ * @param {string} opts.leftHref - href for left link
20
+ * @param {string} opts.rightLabel - Link label below right image (e.g. 'Store')
21
+ * @param {string} opts.rightHref - href for right link
22
+ * @param {string} opts.class
23
+ */
24
+
25
+ import { escHtml as e } from '../html.js'
26
+
27
+ const GAPS = new Set(['none', 'sm', 'md'])
28
+
29
+ export function imagePair({
30
+ leftSrc = '',
31
+ leftAlt = '',
32
+ rightSrc = '',
33
+ rightAlt = '',
34
+ ratio = '3/4',
35
+ gap = 'none',
36
+ label = '',
37
+ leftLabel = '',
38
+ leftHref = '#',
39
+ rightLabel = '',
40
+ rightHref = '#',
41
+ class: cls = '',
42
+ } = {}) {
43
+ if (!GAPS.has(gap)) gap = 'none'
44
+
45
+ const classes = [
46
+ 'ui-image-pair',
47
+ gap !== 'none' && `ui-image-pair--gap-${gap}`,
48
+ (leftLabel || rightLabel) && 'ui-image-pair--captioned',
49
+ cls,
50
+ ].filter(Boolean).join(' ')
51
+
52
+ const labelAttr = label ? ` aria-label="${e(label)}"` : ''
53
+ const cropStyle = ratio ? ` style="aspect-ratio:${e(ratio)}"` : ''
54
+
55
+ const leftCaption = leftLabel ? `<a href="${e(leftHref)}" class="ui-image-pair-link">${e(leftLabel)} →</a>` : ''
56
+ const rightCaption = rightLabel ? `<a href="${e(rightHref)}" class="ui-image-pair-link">${e(rightLabel)} →</a>` : ''
57
+
58
+ return `<div class="${e(classes)}"${labelAttr}>
59
+ <div class="ui-image-pair-col">
60
+ <div class="ui-image-pair-crop"${cropStyle}>
61
+ <img src="${e(leftSrc)}" alt="${e(leftAlt)}" loading="lazy" decoding="async">
62
+ </div>
63
+ ${leftCaption}
64
+ </div>
65
+ <div class="ui-image-pair-col">
66
+ <div class="ui-image-pair-crop"${cropStyle}>
67
+ <img src="${e(rightSrc)}" alt="${e(rightAlt)}" loading="lazy" decoding="async">
68
+ </div>
69
+ ${rightCaption}
70
+ </div>
71
+ </div>`
72
+ }
package/src/ui/index.js CHANGED
@@ -67,9 +67,29 @@ export { fileUpload } from './fileupload.js'
67
67
  export { breadcrumbs } from './breadcrumbs.js'
68
68
  export { stepper } from './stepper.js'
69
69
  export { uiImage } from './uiimage.js'
70
+ export { gallery } from './gallery.js'
71
+ export { photoCard } from './photo-card.js'
72
+ export { productCard } from './product-card.js'
73
+ export { editorialHero } from './editorial-hero.js'
74
+ export { imagePair } from './image-pair.js'
75
+ export { brandStatement } from './brand-statement.js'
76
+ export { marquee } from './marquee.js'
77
+ export { serviceCard } from './service-card.js'
78
+ export { contactRow } from './contact-row.js'
79
+ export { trustList } from './trust-list.js'
80
+ export { topStrip } from './top-strip.js'
81
+ export { statGrid } from './stat-grid.js'
82
+ export { decorate } from './decorate.js'
70
83
  export { pullquote } from './pullquote.js'
71
84
  export { prose } from './prose.js'
72
85
  export { heading } from './heading.js'
86
+
87
+ // Editorial components
88
+ export { entryList } from './entry-list.js'
89
+ export { displayHeading } from './display-heading.js'
90
+ export { sectionLabel } from './section-label.js'
91
+ export { dropCap } from './drop-cap.js'
92
+ export { colophon } from './colophon.js'
73
93
  export { list } from './list.js'
74
94
  export {
75
95
  iconArrowLeft, iconArrowRight, iconArrowUp, iconArrowDown,
package/src/ui/input.js CHANGED
@@ -43,6 +43,7 @@ export function input({
43
43
  const wrapClasses = ['ui-field', error ? 'ui-field--error' : '', cls].filter(Boolean).join(' ')
44
44
 
45
45
  const attrsStr = Object.entries(attrs)
46
+ .filter(([, v]) => v !== undefined)
46
47
  .map(([k, v]) => ` ${e(k)}="${e(String(v))}"`)
47
48
  .join('')
48
49
 
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Pulse UI — Marquee
3
+ *
4
+ * CSS-only infinite scrolling strip. Ideal for logo clouds, trust badges,
5
+ * press mentions, or tag lists. No JavaScript — pure CSS animation.
6
+ *
7
+ * @param {object} opts
8
+ * @param {string[]} opts.items - Array of raw HTML strings (logos, badges, text)
9
+ * @param {number} opts.speed - Duration in seconds for one full loop (default: 30)
10
+ * @param {'sm'|'md'|'lg'} opts.gap - Gap between items (default: 'lg')
11
+ * @param {'left'|'right'} opts.direction - Scroll direction (default: 'left')
12
+ * @param {boolean} opts.pause - Pause animation on hover (default: true)
13
+ * @param {boolean} opts.fade - Fade edges with gradient mask (default: true)
14
+ * @param {string} opts.class
15
+ */
16
+
17
+ import { escHtml as e } from '../html.js'
18
+
19
+ const GAPS = new Set(['sm', 'md', 'lg'])
20
+
21
+ export function marquee({
22
+ items = [],
23
+ speed = 30,
24
+ gap = 'lg',
25
+ direction = 'left',
26
+ pause = true,
27
+ fade = true,
28
+ class: cls = '',
29
+ } = {}) {
30
+ if (!GAPS.has(gap)) gap = 'lg'
31
+ const safeSpeed = Math.max(5, parseFloat(speed) || 30)
32
+ const dir = direction === 'right' ? 'reverse' : 'normal'
33
+
34
+ const classes = [
35
+ 'ui-marquee',
36
+ gap !== 'lg' && `ui-marquee--gap-${gap}`,
37
+ pause && 'ui-marquee--pause',
38
+ fade && 'ui-marquee--fade',
39
+ cls,
40
+ ].filter(Boolean).join(' ')
41
+
42
+ // Items are duplicated so the loop is seamless
43
+ const itemsHtml = items.map(item => `<li class="ui-marquee-item">${item}</li>`).join('')
44
+
45
+ return `<div class="${e(classes)}" style="--marquee-speed:${safeSpeed}s;--marquee-dir:${dir}" aria-hidden="true">
46
+ <ul class="ui-marquee-track">${itemsHtml}${itemsHtml}</ul>
47
+ </div>`
48
+ }
package/src/ui/nav.js CHANGED
@@ -20,7 +20,8 @@
20
20
  * @param {object} opts
21
21
  * @param {string} opts.logo - Raw HTML slot — SVG, img, or text
22
22
  * @param {string} opts.logoHref - Logo link destination (default: '/')
23
- * @param {Array<{label:string, href?:string, mega?:Array}>} opts.links
23
+ * @param {Array<{label:string, href?:string, current?:boolean, mega?:Array}>} opts.links
24
+ * @param {Array<{label:string, href?:string}>} opts.utilityLinks - Secondary links pushed to the far right
24
25
  * @param {string} opts.action - Raw HTML slot — typically a button()
25
26
  * @param {boolean} opts.sticky - Position sticky with backdrop blur
26
27
  * @param {'right'|'left'} opts.burgerAlign - Mobile burger position (default: 'right')
@@ -63,7 +64,8 @@ function renderLink(link, navId, idx) {
63
64
  ${megaPanel(link.mega, panelId)}
64
65
  </div>`
65
66
  }
66
- return `<a href="${e(link.href || '')}" class="ui-nav-link">${e(link.label || '')}</a>`
67
+ const current = link.current ? ' aria-current="page"' : ''
68
+ return `<a href="${e(link.href || '')}" class="ui-nav-link"${current}>${e(link.label || '')}</a>`
67
69
  }
68
70
 
69
71
  function renderMobileLink(link) {
@@ -71,13 +73,15 @@ function renderMobileLink(link) {
71
73
  const items = link.mega.flatMap(col => col.items || [])
72
74
  return `<p class="ui-nav-mobile-section">${e(link.label || '')}</p>` + items.map(item => megaItem(item)).join('')
73
75
  }
74
- return `<a href="${e(link.href || '')}" class="ui-nav-link">${e(link.label || '')}</a>`
76
+ const current = link.current ? ' aria-current="page"' : ''
77
+ return `<a href="${e(link.href || '')}" class="ui-nav-link"${current}>${e(link.label || '')}</a>`
75
78
  }
76
79
 
77
80
  export function nav({
78
81
  logo = '',
79
82
  logoHref = '/',
80
83
  links = [],
84
+ utilityLinks = [],
81
85
  action = '',
82
86
  sticky = false,
83
87
  burgerAlign = 'right',
@@ -89,29 +93,35 @@ export function nav({
89
93
  const classes = ['ui-nav', sticky && 'ui-nav--sticky', burgerAlign === 'left' && 'ui-nav--burger-left', cls].filter(Boolean).join(' ')
90
94
  const styles = [
91
95
  background && `background:${background.replace(/"/g, "'")}`,
92
- color && `color:${color.replace(/"/g, "'")};--ui-muted:${color.replace(/"/g, "'")}`,
96
+ color && `color:${color.replace(/"/g, "'")};--ui-text:${color.replace(/"/g, "'")};--ui-muted:${color.replace(/"/g, "'")}`,
93
97
  ].filter(Boolean).join(';')
94
98
  const bgStyle = styles ? ` style="${styles}"` : ''
95
99
 
96
100
  const linksHtml = links.map((l, i) => renderLink(l, id, i)).join('')
97
- const mobileLinksHtml = links.map(renderMobileLink).join('')
101
+ const utilityHtml = utilityLinks.map(l => `<a href="${e(l.href || '')}" class="ui-nav-link"${l.current ? ' aria-current="page"' : ''}>${e(l.label || '')}</a>`).join('')
102
+ const mobileLinksHtml = [...links, ...utilityLinks].map(renderMobileLink).join('')
98
103
 
99
- const burgerHtml = links.length ? `
104
+ const burgerHtml = (links.length || utilityLinks.length) ? `
100
105
  <button class="ui-nav-burger" type="button" aria-label="Toggle menu" aria-expanded="false" aria-controls="${id}-mobile">
101
106
  ${iconMenu({ size: 20, class: 'ui-nav-burger-open' })}
102
107
  ${iconX({ size: 20, class: 'ui-nav-burger-close' })}
103
108
  </button>` : ''
104
109
 
105
- const mobileMenuHtml = links.length ? `
110
+ const mobileMenuHtml = (links.length || utilityLinks.length) ? `
106
111
  <div class="ui-nav-mobile" id="${id}-mobile" aria-label="Mobile navigation">
107
112
  <nav>${mobileLinksHtml}</nav>
108
113
  </div>` : ''
109
114
 
115
+ const navContent = [
116
+ links.length && `<nav class="ui-nav-links" aria-label="Site navigation">${linksHtml}</nav>`,
117
+ utilityLinks.length && `<nav class="ui-nav-links ui-nav-utility" aria-label="Utility navigation">${utilityHtml}</nav>`,
118
+ action && `<div class="ui-nav-action">${action}</div>`,
119
+ ].filter(Boolean).join('')
120
+
110
121
  return `<header class="${e(classes)}"${bgStyle}>
111
122
  <div class="ui-nav-inner">
112
123
  <a href="${e(logoHref)}" class="ui-nav-logo">${logo}</a>
113
- ${links.length ? `<nav class="ui-nav-links" aria-label="Site navigation">${linksHtml}</nav>` : ''}
114
- ${action ? `<div class="ui-nav-action">${action}</div>` : ''}${burgerHtml}
124
+ ${navContent}${burgerHtml}
115
125
  </div>${mobileMenuHtml}
116
126
  </header>`
117
127
  }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Pulse UI — PhotoCard
3
+ *
4
+ * Polaroid-style photo card with optional caption and CSS tilt.
5
+ * Ideal for "meet the team", testimonial portraits, or lifestyle photography.
6
+ *
7
+ * @param {object} opts
8
+ * @param {string} opts.src - Image source URL
9
+ * @param {string} opts.alt - Alt text
10
+ * @param {string} opts.caption - Caption text below the photo
11
+ * @param {number} opts.tilt - CSS rotate degrees, e.g. 2 or -1.5. 0 = no tilt (default)
12
+ * @param {boolean} opts.rounded - Adds corner radius to the image (default: false = square crop)
13
+ * @param {string} opts.ratio - CSS aspect-ratio for the image crop (default: '4/3')
14
+ * @param {string} opts.class
15
+ */
16
+
17
+ import { escHtml as e } from '../html.js'
18
+
19
+ export function photoCard({
20
+ src = '',
21
+ alt = '',
22
+ caption = '',
23
+ tilt = 0,
24
+ rounded = false,
25
+ ratio = '4/3',
26
+ class: cls = '',
27
+ } = {}) {
28
+ const safeTilt = parseFloat(tilt) || 0
29
+ const classes = [
30
+ 'ui-photo-card',
31
+ rounded && 'ui-photo-card--rounded',
32
+ cls,
33
+ ].filter(Boolean).join(' ')
34
+
35
+ const tiltStyle = safeTilt ? ` style="--photo-tilt:${safeTilt}deg"` : ''
36
+ const cropStyle = ratio ? ` style="aspect-ratio:${e(ratio)}"` : ''
37
+
38
+ return `<figure class="${e(classes)}"${tiltStyle}>
39
+ <div class="ui-photo-card-crop"${cropStyle}>
40
+ <img src="${e(src)}" alt="${e(alt)}" class="ui-photo-card-img" loading="lazy" decoding="async">
41
+ </div>
42
+ ${caption ? `<figcaption class="ui-photo-card-caption">${e(caption)}</figcaption>` : ''}
43
+ </figure>`
44
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Pulse UI — Product Card
3
+ *
4
+ * Full-bleed editorial product card — the image IS the card. The product
5
+ * name (and optional price) overlays at the bottom on hover. Designed for
6
+ * fashion/e-commerce grids where photography is the primary communication.
7
+ *
8
+ * Unlike photoCard (polaroid frame + caption always visible), this card is
9
+ * frameless: the image fills 100% of the card, and metadata is revealed on
10
+ * hover via a gradient overlay.
11
+ *
12
+ * @param {object} opts
13
+ * @param {string} opts.src - Image source URL
14
+ * @param {string} opts.alt - Alt text
15
+ * @param {string} opts.name - Product name
16
+ * @param {string} opts.price - Optional price string
17
+ * @param {string} opts.href - Link destination (default: '#')
18
+ * @param {string} opts.badge - Short badge text ("NEW", "SALE", "−20%")
19
+ * @param {'left'|'right'} opts.badgeAlign - Badge corner (default: 'left')
20
+ * @param {string} opts.ratio - CSS aspect-ratio (default: '4/5')
21
+ * @param {'overlay'|'below'} opts.layout - 'overlay' (hover reveal) or 'below' (caption under image)
22
+ * @param {string} opts.class
23
+ */
24
+
25
+ import { escHtml as e } from '../html.js'
26
+
27
+ export function productCard({
28
+ src = '',
29
+ alt = '',
30
+ name = '',
31
+ price = '',
32
+ href = '#',
33
+ badge = '',
34
+ badgeAlign = 'left',
35
+ ratio = '4/5',
36
+ layout = 'overlay',
37
+ class: cls = '',
38
+ } = {}) {
39
+ const classes = [
40
+ 'ui-product-card',
41
+ badgeAlign === 'right' && 'ui-product-card--badge-right',
42
+ layout === 'below' && 'ui-product-card--below',
43
+ cls,
44
+ ].filter(Boolean).join(' ')
45
+
46
+ const cropStyle = ratio ? ` style="aspect-ratio:${e(ratio)}"` : ''
47
+
48
+ const namePrice = name ? (layout === 'below'
49
+ ? `<div class="ui-product-card-caption">
50
+ <p class="ui-product-card-name">${e(name)}</p>
51
+ ${price ? `<p class="ui-product-card-price">${e(price)}</p>` : ''}
52
+ </div>`
53
+ : `<div class="ui-product-card-overlay" aria-hidden="true">
54
+ <p class="ui-product-card-name">${e(name)}</p>
55
+ ${price ? `<p class="ui-product-card-price">${e(price)}</p>` : ''}
56
+ </div>`) : ''
57
+
58
+ return `<a href="${e(href)}" class="${e(classes)}">
59
+ <div class="ui-product-card-crop"${cropStyle}>
60
+ <img src="${e(src)}" alt="${e(alt)}" class="ui-product-card-img" loading="lazy" decoding="async">
61
+ </div>
62
+ ${badge ? `<span class="ui-product-card-badge">${e(badge)}</span>` : ''}
63
+ ${namePrice}
64
+ </a>`
65
+ }
@@ -1,12 +1,18 @@
1
1
  /**
2
2
  * Pulse UI — Pullquote
3
3
  *
4
- * Styled blockquote with accent left border and optional attribution.
4
+ * Styled blockquote with optional attribution.
5
+ *
6
+ * Two variants:
7
+ * - Default: left accent border — for inline editorial quotes in prose or sidebars
8
+ * - 'editorial': centred large italic serif, no border — for brand voice statements
9
+ * in fashion, luxury, or magazine contexts
5
10
  *
6
11
  * @param {object} opts
7
12
  * @param {string} opts.quote - The quote text
8
13
  * @param {string} opts.cite - Attribution (name, role, etc.)
9
14
  * @param {'md'|'lg'} opts.size - Size variant (default: 'md')
15
+ * @param {'default'|'editorial'} opts.variant - Visual style (default: 'default')
10
16
  * @param {string} opts.class
11
17
  */
12
18
 
@@ -16,10 +22,15 @@ export function pullquote({
16
22
  quote = '',
17
23
  cite = '',
18
24
  size = 'md',
25
+ variant = 'default',
19
26
  class: cls = '',
20
27
  } = {}) {
21
- const sizeClass = size === 'lg' ? 'ui-pullquote--lg' : ''
22
- const figClasses = ['ui-pullquote', sizeClass, cls].filter(Boolean).join(' ')
28
+ const figClasses = [
29
+ 'ui-pullquote',
30
+ size === 'lg' && 'ui-pullquote--lg',
31
+ variant === 'editorial' && 'ui-pullquote--editorial',
32
+ cls,
33
+ ].filter(Boolean).join(' ')
23
34
 
24
35
  const citeHtml = cite
25
36
  ? `<figcaption class="ui-pullquote-cite">${e(cite)}</figcaption>`
package/src/ui/search.js CHANGED
@@ -48,6 +48,7 @@ export function search({
48
48
  : ''
49
49
 
50
50
  const attrsStr = Object.entries(attrs)
51
+ .filter(([, v]) => v !== undefined)
51
52
  .map(([k, v]) => ` ${e(k)}="${e(String(v))}"`)
52
53
  .join('')
53
54
 
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Pulse UI — Section Label
3
+ *
4
+ * Eyebrow + heading + horizontal rule. The editorial equivalent of feature().
5
+ * For: chapter openings, section dividers, content blocks.
6
+ *
7
+ * @param {object} opts
8
+ * @param {string} opts.eyebrow - Small label above heading
9
+ * @param {string} opts.heading - Main heading text
10
+ * @param {number} opts.level - Heading level 1–6 (default 2)
11
+ * @param {boolean} opts.rule - Show horizontal rule after heading (default true)
12
+ * @param {'left'|'center'} opts.align - Text alignment (default 'left')
13
+ * @param {string} opts.class
14
+ */
15
+
16
+ import { escHtml as e } from '../html.js'
17
+
18
+ export function sectionLabel({
19
+ eyebrow = '',
20
+ heading = '',
21
+ level = 2,
22
+ rule = true,
23
+ align = 'left',
24
+ class: cls = '',
25
+ } = {}) {
26
+ const tag = `h${Math.min(Math.max(Math.floor(level), 1), 6)}`
27
+ const classes = [
28
+ 'ui-section-label',
29
+ align === 'center' && 'ui-section-label--center',
30
+ cls,
31
+ ].filter(Boolean).join(' ')
32
+
33
+ return `<div class="${e(classes)}">
34
+ ${eyebrow ? `<p class="ui-section-label-eyebrow">${e(eyebrow)}</p>` : ''}
35
+ ${heading ? `<${tag} class="ui-section-label-heading">${e(heading)}</${tag}>` : ''}
36
+ ${rule ? '<hr class="ui-section-label-rule" aria-hidden="true">' : ''}
37
+ </div>`
38
+ }
package/src/ui/section.js CHANGED
@@ -21,7 +21,7 @@
21
21
 
22
22
  import { escHtml as e } from '../html.js'
23
23
 
24
- const VARIANTS = new Set(['default', 'alt', 'dark'])
24
+ const VARIANTS = new Set(['default', 'alt', 'dark', 'diagonal', 'paper', 'spotlight'])
25
25
  const PADDINGS = new Set(['sm', 'md', 'lg'])
26
26
  const GAPS = new Set(['none', 'sm', 'md', 'lg'])
27
27
 
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Pulse UI — ServiceCard
3
+ *
4
+ * A bordered, left-aligned tile with icon, title, and description.
5
+ * The most common pattern for trades/services grids — an unboxed, centred
6
+ * feature() is the wrong tool for these; serviceCard() is built for it.
7
+ *
8
+ * Use inside a grid() or css grid for multi-column layouts.
9
+ *
10
+ * @param {object} opts
11
+ * @param {string} opts.icon - Raw HTML slot — SVG icon displayed in an accent-tinted box
12
+ * @param {string} opts.title
13
+ * @param {number} opts.level - Heading level 1–6 (default 3)
14
+ * @param {string} opts.description
15
+ * @param {string} opts.href - When set, wraps the card in an <a> making it fully clickable
16
+ * @param {string} opts.class
17
+ */
18
+
19
+ import { escHtml as e } from '../html.js'
20
+
21
+ export function serviceCard({
22
+ icon = '',
23
+ title = '',
24
+ level = 3,
25
+ description = '',
26
+ href = '',
27
+ class: cls = '',
28
+ } = {}) {
29
+ const tag = `h${Math.min(Math.max(Math.floor(level), 1), 6)}`
30
+ const linked = Boolean(href)
31
+ const classes = [
32
+ 'ui-service-card',
33
+ linked && 'ui-service-card--linked',
34
+ cls,
35
+ ].filter(Boolean).join(' ')
36
+
37
+ const inner = `
38
+ ${icon ? `<div class="ui-service-card-icon" aria-hidden="true">${icon}</div>` : ''}
39
+ ${title ? `<${tag} class="ui-service-card-title">${e(title)}</${tag}>` : ''}
40
+ ${description ? `<p class="ui-service-card-desc">${e(description)}</p>` : ''}
41
+ `
42
+
43
+ if (linked) {
44
+ return `<a href="${e(href)}" class="${e(classes)}">${inner}</a>`
45
+ }
46
+ return `<div class="${e(classes)}">${inner}</div>`
47
+ }