@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
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Pulse UI — StatGrid
3
+ *
4
+ * A grid of stat tiles with optional coloured panel background.
5
+ * Use when you need multiple stat() tiles arranged together — e.g. a 2×2
6
+ * "at a glance" panel inside a hero or section.
7
+ *
8
+ * Each item is passed directly to stat(). The grid handles layout.
9
+ *
10
+ * @param {object} opts
11
+ * @param {Array} opts.stats - Array of stat() option objects (label, value, change, trend, size, suffix)
12
+ * @param {number} opts.columns - Number of grid columns (default: 2)
13
+ * @param {string} opts.background - CSS background value (e.g. 'var(--ui-surface-raised)')
14
+ * @param {string} opts.color - Foreground/text colour override
15
+ * @param {string} opts.class
16
+ */
17
+
18
+ import { stat } from './stat.js'
19
+ import { escHtml as e } from '../html.js'
20
+
21
+ export function statGrid({
22
+ stats = [],
23
+ columns = 2,
24
+ background = '',
25
+ color = '',
26
+ class: cls = '',
27
+ } = {}) {
28
+ const classes = ['ui-stat-grid', cls].filter(Boolean).join(' ')
29
+
30
+ const styles = [
31
+ `--stat-cols:${Number(columns) || 2}`,
32
+ background && `background:${background.replace(/"/g, "'")}`,
33
+ color && `color:${color.replace(/"/g, "'")}`,
34
+ ].filter(Boolean).join(';')
35
+ const styleAttr = styles ? ` style="${styles}"` : ''
36
+
37
+ const tilesHtml = stats.map(s => stat(s)).join('')
38
+
39
+ return `<div class="${e(classes)}"${styleAttr}>${tilesHtml}</div>`
40
+ }
package/src/ui/stat.js CHANGED
@@ -8,6 +8,11 @@
8
8
  * @param {string} opts.value - Formatted value string (e.g. "2.4k", "98%")
9
9
  * @param {string} opts.change - Change label (e.g. "+12%", "−3")
10
10
  * @param {'up'|'down'|'neutral'} opts.trend
11
+ * @param {string} opts.suffix - Raw HTML slot appended after the value — use for icons, ratings, units
12
+ * @param {'sm'|'md'|'lg'} opts.size
13
+ * - 'sm' — compact, for dense grids
14
+ * - 'md' — default
15
+ * - 'lg' — hero-scale, for top-of-page KPIs
11
16
  * @param {boolean} opts.center - Centre-align all text
12
17
  * @param {string} opts.class
13
18
  */
@@ -16,6 +21,7 @@ import { escHtml as e } from '../html.js'
16
21
  import { iconTrendingUp, iconTrendingDown, iconMinus } from './icons.js'
17
22
 
18
23
  const TRENDS = new Set(['up', 'down', 'neutral'])
24
+ const SIZES = new Set(['sm', 'md', 'lg'])
19
25
 
20
26
  const TREND_ICONS = {
21
27
  up: iconTrendingUp({ size: 13 }),
@@ -30,12 +36,20 @@ export function stat({
30
36
  value = '',
31
37
  change = '',
32
38
  trend = 'neutral',
39
+ suffix = '',
40
+ size = 'md',
33
41
  center = false,
34
42
  class: cls = '',
35
43
  } = {}) {
36
44
  if (!TRENDS.has(trend)) trend = 'neutral'
45
+ if (!SIZES.has(size)) size = 'md'
37
46
 
38
- const classes = ['ui-stat', center && 'ui-stat--center', cls].filter(Boolean).join(' ')
47
+ const classes = [
48
+ 'ui-stat',
49
+ size !== 'md' ? `ui-stat--${size}` : '',
50
+ center ? 'ui-stat--center' : '',
51
+ cls,
52
+ ].filter(Boolean).join(' ')
39
53
 
40
54
  const changeHtml = change
41
55
  ? `<p class="ui-stat-change ui-stat-change--${e(trend)}">
@@ -46,7 +60,7 @@ export function stat({
46
60
 
47
61
  return `<div class="${e(classes)}">
48
62
  <p class="ui-stat-label">${e(label)}</p>
49
- <p class="ui-stat-value">${e(value)}</p>
63
+ <p class="ui-stat-value">${e(value)}${suffix ? `<span class="ui-stat-suffix" aria-hidden="true">${suffix}</span>` : ''}</p>
50
64
  ${changeHtml}
51
65
  </div>`
52
66
  }
@@ -4,11 +4,12 @@
4
4
  * A customer quote with optional star rating, avatar, and attribution.
5
5
  *
6
6
  * @param {object} opts
7
- * @param {string} opts.quote - The testimonial text
8
- * @param {string} opts.name - Author full name
9
- * @param {string} opts.role - Author role/company (e.g. "CEO at Acme")
10
- * @param {string} opts.src - Avatar image URL; falls back to initials when omitted
11
- * @param {number} opts.rating - Star rating 1–5; omit to hide stars
7
+ * @param {string} opts.quote - The testimonial text
8
+ * @param {string} opts.name - Author full name
9
+ * @param {string} opts.role - Author role/company (e.g. "CEO at Acme")
10
+ * @param {string} opts.src - Avatar image URL; falls back to initials when omitted
11
+ * @param {string} opts.initials - Override auto-derived initials (useful for names containing & or unusual characters)
12
+ * @param {number} opts.rating - Star rating 1–5; omit to hide stars
12
13
  * @param {string} opts.class
13
14
  */
14
15
 
@@ -19,6 +20,7 @@ export function testimonial({
19
20
  name = '',
20
21
  role = '',
21
22
  src = '',
23
+ initials: initialsOverride = '',
22
24
  rating = 0,
23
25
  class: cls = '',
24
26
  } = {}) {
@@ -28,7 +30,16 @@ export function testimonial({
28
30
  ? `<p class="ui-testimonial-rating" aria-label="${Math.round(rating)} out of 5 stars">${'★'.repeat(Math.min(5, Math.max(1, Math.round(rating))))}</p>`
29
31
  : ''
30
32
 
31
- const initials = name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()
33
+ // Filter to letter chars only so names like "Tomás & Ren Okabe" produce "TR" not "T&"
34
+ const derivedInitials = name
35
+ .split(/\s+/)
36
+ .map(w => w.replace(/[^a-zA-ZÀ-ÿ]/g, ''))
37
+ .filter(Boolean)
38
+ .map(w => w[0])
39
+ .join('')
40
+ .slice(0, 2)
41
+ .toUpperCase()
42
+ const initials = initialsOverride || derivedInitials
32
43
 
33
44
  const avatarHtml = src
34
45
  ? `<img src="${e(src)}" alt="${e(name)}" class="ui-testimonial-avatar" loading="lazy" width="40" height="40">`
@@ -42,6 +42,7 @@ export function textarea({
42
42
  const wrapClasses = ['ui-field', error ? 'ui-field--error' : '', cls].filter(Boolean).join(' ')
43
43
 
44
44
  const attrsStr = Object.entries(attrs)
45
+ .filter(([, v]) => v !== undefined)
45
46
  .map(([k, v]) => ` ${e(k)}="${e(String(v))}"`)
46
47
  .join('')
47
48
 
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Pulse UI — TopStrip
3
+ *
4
+ * A slim utility bar that sits above the nav — phone numbers, trading hours,
5
+ * social links, or any short announcements. Distinct from banner() which is
6
+ * for promotional/dismissible messages.
7
+ *
8
+ * Two layout modes:
9
+ * - left + right slots: `topStrip({ left: '...', right: '...' })`
10
+ * - single content: `topStrip({ content: '...' })`
11
+ *
12
+ * @param {object} opts
13
+ * @param {string} opts.content - Single centred content (overrides left/right)
14
+ * @param {string} opts.left - Left-aligned slot
15
+ * @param {string} opts.right - Right-aligned slot
16
+ * @param {string} opts.background - CSS background value
17
+ * @param {string} opts.color - Foreground/text colour
18
+ * @param {string} opts.class
19
+ */
20
+
21
+ import { escHtml as e } from '../html.js'
22
+
23
+ export function topStrip({
24
+ content = '',
25
+ left = '',
26
+ right = '',
27
+ background = '',
28
+ color = '',
29
+ class: cls = '',
30
+ } = {}) {
31
+ const classes = ['ui-top-strip', cls].filter(Boolean).join(' ')
32
+
33
+ const styles = [
34
+ background && `background:${background.replace(/"/g, "'")}`,
35
+ color && `color:${color.replace(/"/g, "'")}`,
36
+ ].filter(Boolean).join(';')
37
+ const styleAttr = styles ? ` style="${styles}"` : ''
38
+
39
+ if (content) {
40
+ return `<div class="${e(classes)}"${styleAttr}><div class="ui-top-strip-content">${content}</div></div>`
41
+ }
42
+
43
+ return `<div class="${e(classes)}"${styleAttr}>
44
+ ${left ? `<div class="ui-top-strip-left">${left}</div>` : ''}
45
+ ${right ? `<div class="ui-top-strip-right">${right}</div>` : ''}
46
+ </div>`
47
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Pulse UI — TrustList
3
+ *
4
+ * A compact row of trust/compliance badges — "Part P registered · NICEIC · £10m insured".
5
+ * Each item gets a leading icon (default: check mark). Common on trades, services,
6
+ * and professional-services sites.
7
+ *
8
+ * @param {object} opts
9
+ * @param {string[]} opts.items - List of labels
10
+ * @param {string} opts.icon - Raw HTML slot — icon shown before each item (default: ✓ check SVG)
11
+ * @param {string} opts.color - Icon colour CSS value (default: inherits --ui-accent)
12
+ * @param {'sm'|'md'} opts.size
13
+ * @param {string} opts.class
14
+ */
15
+
16
+ import { escHtml as e } from '../html.js'
17
+ import { iconCheck } from './icons.js'
18
+
19
+ const SIZES = new Set(['sm', 'md'])
20
+
21
+ export function trustList({
22
+ items = [],
23
+ icon = iconCheck({ size: 14 }),
24
+ color = '',
25
+ size = 'sm',
26
+ class: cls = '',
27
+ } = {}) {
28
+ if (!SIZES.has(size)) size = 'sm'
29
+
30
+ const classes = [
31
+ 'ui-trust-list',
32
+ size !== 'sm' && `ui-trust-list--${size}`,
33
+ cls,
34
+ ].filter(Boolean).join(' ')
35
+
36
+ const iconStyle = color ? ` style="color:${color.replace(/"/g, "'")}"` : ''
37
+
38
+ const itemsHtml = items.map(label =>
39
+ `<li class="ui-trust-list-item">
40
+ <span class="ui-trust-list-icon" aria-hidden="true"${iconStyle}>${icon}</span>
41
+ <span>${e(label)}</span>
42
+ </li>`
43
+ ).join('')
44
+
45
+ return `<ul class="${e(classes)}">${itemsHtml}</ul>`
46
+ }
package/src/ui/ui.test.js CHANGED
@@ -113,6 +113,17 @@ test('button: renders icon HTML inside element', () => {
113
113
  assert.match(html, /<svg\/>/)
114
114
  })
115
115
 
116
+ test('button: attrs with undefined values are omitted', () => {
117
+ const html = button({ label: 'Minus', attrs: { disabled: undefined, 'data-count': '3' } })
118
+ assert.doesNotMatch(html, /disabled="undefined"/)
119
+ assert.match(html, /data-count="3"/)
120
+ })
121
+
122
+ test('button: attrs with defined values are rendered', () => {
123
+ const html = button({ label: 'Add', attrs: { 'data-action': 'add' } })
124
+ assert.match(html, /data-action="add"/)
125
+ })
126
+
116
127
  // ─── badge ──────────────────────────────────────────────────────────────────
117
128
 
118
129
  test('badge: renders with default variant', () => {
@@ -656,6 +667,30 @@ test('nav: escapes link labels and hrefs', () => {
656
667
  assert.doesNotMatch(html, /<b>X<\/b>/)
657
668
  })
658
669
 
670
+ test('nav: aria-current="page" on current link', () => {
671
+ const html = nav({ logo: 'x', links: [
672
+ { label: 'Home', href: '/', current: true },
673
+ { label: 'About', href: '/about' },
674
+ ] })
675
+ assert.match(html, /aria-current="page"/)
676
+ // Appears in both desktop and mobile nav — once per nav, so 2 total
677
+ const matches = html.match(/aria-current="page"/g)
678
+ assert.ok(matches && matches.length >= 1, 'At least one link should have aria-current')
679
+ assert.doesNotMatch(html, /href="\/about"[^>]*aria-current/)
680
+ })
681
+
682
+ test('nav: no aria-current when current not set', () => {
683
+ const html = nav({ logo: 'x', links: [{ label: 'Home', href: '/' }] })
684
+ assert.doesNotMatch(html, /aria-current/)
685
+ })
686
+
687
+ test('nav: aria-current on utility link', () => {
688
+ const html = nav({ logo: 'x', links: [], utilityLinks: [
689
+ { label: 'Sign in', href: '/login', current: true },
690
+ ] })
691
+ assert.match(html, /aria-current="page"/)
692
+ })
693
+
659
694
  // ─── appBadge ────────────────────────────────────────────────────────────────
660
695
 
661
696
  test('appBadge: renders apple badge by default', () => {