@invisibleloop/pulse 0.20.8 → 0.20.10

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.
@@ -852,42 +852,6 @@ hr.ui-divider {
852
852
  .ui-hero { padding: 5rem 1.5rem; text-align: center; }
853
853
  .ui-hero--left { text-align: left; }
854
854
  .ui-hero--sm { padding-top: 2.5rem; padding-bottom: 0; }
855
- .ui-hero--gradient,
856
- .ui-hero--gradient--purple {
857
- background:
858
- radial-gradient(ellipse 70% 80% at 75% 50%, #2d1b6e 0%, #1a0f45 45%, transparent 75%),
859
- radial-gradient(ellipse 60% 60% at 20% 80%, #1c1060 0%, transparent 65%),
860
- #0d0a1e;
861
- }
862
-
863
- .ui-hero--gradient--blue {
864
- background:
865
- radial-gradient(ellipse 70% 80% at 75% 50%, #0e2d6b 0%, #071840 45%, transparent 75%),
866
- radial-gradient(ellipse 60% 60% at 20% 80%, #0a1f5c 0%, transparent 65%),
867
- #060d1e;
868
- }
869
-
870
- .ui-hero--gradient--green {
871
- background:
872
- radial-gradient(ellipse 70% 80% at 75% 50%, #0d3d28 0%, #052218 45%, transparent 75%),
873
- radial-gradient(ellipse 60% 60% at 20% 80%, #083020 0%, transparent 65%),
874
- #020e09;
875
- }
876
-
877
- .ui-hero--gradient--rose {
878
- background:
879
- radial-gradient(ellipse 70% 80% at 75% 50%, #6b0e35 0%, #400720 45%, transparent 75%),
880
- radial-gradient(ellipse 60% 60% at 20% 80%, #5c0a2a 0%, transparent 65%),
881
- #1e0610;
882
- }
883
-
884
- .ui-hero--gradient--orange {
885
- background:
886
- radial-gradient(ellipse 70% 80% at 75% 50%, #6b2e0e 0%, #401807 45%, transparent 75%),
887
- radial-gradient(ellipse 60% 60% at 20% 80%, #5c2208 0%, transparent 65%),
888
- #1e0e04;
889
- }
890
-
891
855
  .ui-hero-inner { max-width: 720px; margin: 0 auto; }
892
856
  .ui-hero--left .ui-hero-inner { margin: 0; }
893
857
 
@@ -1755,14 +1719,14 @@ hr.ui-divider {
1755
1719
  .ui-code-window-filename {
1756
1720
  font-size: .75rem;
1757
1721
  font-family: var(--ui-mono);
1758
- color: #9898b2;
1722
+ color: var(--ui-muted);
1759
1723
  margin-left: .35rem;
1760
1724
  }
1761
1725
 
1762
1726
  .ui-code-window-lang {
1763
1727
  font-size: .7rem;
1764
1728
  font-family: var(--ui-mono);
1765
- color: #9898b2;
1729
+ color: var(--ui-muted);
1766
1730
  margin-left: auto;
1767
1731
  }
1768
1732
 
@@ -24,22 +24,22 @@
24
24
  --border-subtle: #ece7d9;
25
25
  --text: var(--brut-ink);
26
26
  --muted: var(--brut-muted);
27
- --muted-2: #8a8272;
28
- --accent: var(--brut-gold-deep);
27
+ --muted-2: #736b5c;
28
+ --accent: #8a6300;
29
29
  --accent-text: var(--brut-ink);
30
- --accent-hover: #a97f00;
30
+ --accent-hover: #7a5700;
31
31
  --accent-dim: rgba(201, 151, 0, 0.12);
32
32
  --green: #166534;
33
33
  --red: #b91c1c;
34
34
 
35
35
  /* Syntax tokens — dark-on-light, tuned for contrast on --surface-2 */
36
36
  --tok-kw: #7c3aed;
37
- --tok-str: #15803d;
38
- --tok-cmt: #78716c;
39
- --tok-num: #c2410c;
37
+ --tok-str: #166534;
38
+ --tok-cmt: #5c574f;
39
+ --tok-num: #9a3412;
40
40
  --tok-fn: #1d4ed8;
41
- --tok-prop: #a16207;
42
- --tok-op: #0891b2;
41
+ --tok-prop: #854d0e;
42
+ --tok-op: #0e6280;
43
43
  --tok-punct: #57534e;
44
44
 
45
45
  /* Sticky header background — replaces the hardcoded rgba(13,13,16,.9) */
@@ -40,28 +40,28 @@ export default {
40
40
  card({
41
41
  flush: true,
42
42
  content: `
43
- <div class="u-rounded-md u-overflow-hidden" style="height:180px;background:linear-gradient(135deg,#667eea,#764ba2);display:flex;align-items:center;justify-content:center;">
44
- <span style="font-size:3rem">🌄</span>
43
+ <div class="u-overflow-hidden" style="height:180px;border-radius:var(--ui-radius) var(--ui-radius) 0 0">
44
+ <img src="/img/heic1501a.jpg" alt="The Pillars of Creation in the Eagle Nebula, captured by the Hubble Space Telescope" style="width:100%;height:100%;object-fit:cover;display:block">
45
45
  </div>
46
46
  <div class="u-p-5">
47
- <p class="u-font-semibold u-mb-2" style="margin-top:0">Mountain retreat</p>
48
- <p class="u-text-muted u-text-sm" style="margin:0">A peaceful escape in the highlands. Three nights, all-inclusive.</p>
47
+ <p class="u-font-semibold u-mb-2" style="margin-top:0">Deep field</p>
48
+ <p class="u-text-muted u-text-sm" style="margin:0">The Pillars of Creation, imaged by the Hubble Space Telescope.</p>
49
49
  </div>
50
50
  `,
51
- footer: button({ label: 'Book now', href: '#', variant: 'primary', size: 'sm' }),
51
+ footer: button({ label: 'View gallery', href: '#', variant: 'primary', size: 'sm' }),
52
52
  }),
53
53
  `card({
54
54
  flush: true,
55
55
  content: \`
56
- <div class="u-rounded-md u-overflow-hidden" style="height:180px;background:...">
57
- <img src="/img/retreat.jpg" alt="Mountain retreat" style="width:100%;height:100%;object-fit:cover">
56
+ <div class="u-overflow-hidden" style="height:180px;border-radius:var(--ui-radius) var(--ui-radius) 0 0">
57
+ <img src="/img/nebula.jpg" alt="The Pillars of Creation in the Eagle Nebula" style="width:100%;height:100%;object-fit:cover">
58
58
  </div>
59
59
  <div class="u-p-5">
60
- <p class="u-font-semibold u-mb-2">Mountain retreat</p>
61
- <p class="u-text-muted u-text-sm">A peaceful escape in the highlands.</p>
60
+ <p class="u-font-semibold u-mb-2">Deep field</p>
61
+ <p class="u-text-muted u-text-sm">The Pillars of Creation, imaged by the Hubble Space Telescope.</p>
62
62
  </div>
63
63
  \`,
64
- footer: button({ label: 'Book now', href: '/book', variant: 'primary', size: 'sm' }),
64
+ footer: button({ label: 'View gallery', href: '/gallery', variant: 'primary', size: 'sm' }),
65
65
  })`
66
66
  )}
67
67
 
@@ -15,23 +15,7 @@ export default {
15
15
  },
16
16
  state: {},
17
17
  view: () => {
18
- const placeholderImg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 400" style="width:100%;height:auto;border-radius:var(--ui-radius,8px);display:block" role="img" aria-label="Placeholder image">
19
- <rect width="600" height="400" fill="#111116" rx="8"/>
20
- <rect x="24" y="24" width="552" height="32" rx="6" fill="#1a1a22"/>
21
- <circle cx="44" cy="40" r="6" fill="#ff5f57"/><circle cx="64" cy="40" r="6" fill="#febc2e"/><circle cx="84" cy="40" r="6" fill="#28c840"/>
22
- <rect x="24" y="72" width="552" height="200" rx="6" fill="#1a1a22"/>
23
- <rect x="40" y="92" width="200" height="12" rx="3" fill="#2a2a35"/>
24
- <rect x="40" y="116" width="300" height="8" rx="3" fill="#222228"/>
25
- <rect x="40" y="132" width="260" height="8" rx="3" fill="#222228"/>
26
- <rect x="40" y="148" width="280" height="8" rx="3" fill="#222228"/>
27
- <rect x="40" y="176" width="120" height="32" rx="6" fill="#9b8dff" opacity=".9"/>
28
- <rect x="24" y="288" width="264" height="88" rx="6" fill="#1a1a22"/>
29
- <rect x="312" y="288" width="264" height="88" rx="6" fill="#1a1a22"/>
30
- <rect x="40" y="304" width="100" height="8" rx="3" fill="#2a2a35"/>
31
- <rect x="40" y="320" width="140" height="6" rx="3" fill="#222228"/>
32
- <rect x="328" y="304" width="100" height="8" rx="3" fill="#2a2a35"/>
33
- <rect x="328" y="320" width="120" height="6" rx="3" fill="#222228"/>
34
- </svg>`
18
+ const placeholderImg = `<img src="/img/heic1501a.jpg" alt="The Pillars of Creation in the Eagle Nebula, captured by the Hubble Space Telescope" style="width:100%;height:100%;object-fit:cover;border-radius:var(--ui-radius,8px);display:block">`
35
19
  return renderComponentPage({
36
20
  currentHref: '/components/hero',
37
21
  prev,
@@ -93,18 +77,6 @@ export default {
93
77
  { bleed: true }
94
78
  )}
95
79
 
96
- ${demo(
97
- `<div style="display:flex;flex-direction:column;gap:0">` +
98
- hero({ eyebrow: 'Purple (default)', title: 'gradient: true', gradient: 'purple', size: 'sm' }) +
99
- hero({ eyebrow: 'Blue', title: "gradient: 'blue'", gradient: 'blue', size: 'sm' }) +
100
- hero({ eyebrow: 'Green', title: "gradient: 'green'", gradient: 'green', size: 'sm' }) +
101
- hero({ eyebrow: 'Rose', title: "gradient: 'rose'", gradient: 'rose', size: 'sm' }) +
102
- hero({ eyebrow: 'Orange', title: "gradient: 'orange'", gradient: 'orange', size: 'sm' }) +
103
- `</div>`,
104
- `hero({ title: 'Headline', gradient: 'purple' }) // or 'blue' | 'green' | 'rose' | 'orange'`,
105
- { bleed: true }
106
- )}
107
-
108
80
  ${demo(
109
81
  `<div style="display:flex;flex-direction:column;gap:0">` +
110
82
  hero({ eyebrow: 'Solid colour', title: 'background: color', background: '#0f172a', size: 'sm' }) +
@@ -142,8 +114,7 @@ hero({ title: 'Headline', background: 'linear-gradient(135deg, #1e1b4b, #312e81)
142
114
  ['<code>imageAlign</code>', 'string', "'right'", "'right' (text left) or 'left' (text right) — only applies when image is set"],
143
115
  ['<code>align</code>', 'string', "'center'", "'center' or 'left' — text alignment when no image"],
144
116
  ['<code>size</code>', 'string', "'md'", "'md' (5rem padding) or 'sm' (2.5rem top, no bottom) — use sm for inner-page headers"],
145
- ['<code>gradient</code>', "boolean | 'purple' | 'blue' | 'green' | 'rose' | 'orange'", 'false', 'Named gradient preset. true and \'purple\' are identical.'],
146
- ['<code>background</code>', 'string', '—', 'Any CSS background value — solid colour or custom gradient. Overrides gradient preset.'],
117
+ ['<code>background</code>', 'string', '—', 'Any CSS background value — solid colour or custom gradient.'],
147
118
  ]
148
119
  )}
149
120
  `,
@@ -24,31 +24,31 @@ export default {
24
24
  <h2 class="doc-h2" id="ratio">With aspect ratio</h2>
25
25
  <p>Set <code>ratio</code> to constrain the image to a fixed aspect ratio. The image fills the crop area with <code>object-fit: cover</code>.</p>
26
26
  ${demo(
27
- uiImage({ src: '/img/placeholder-landscape.svg', alt: 'Mountain landscape at dusk', ratio: '16/9' }),
28
- `uiImage({ src: '/img/photo.jpg', alt: 'Mountain landscape at dusk', ratio: '16/9' })`,
27
+ uiImage({ src: '/img/heic1501a.jpg', alt: 'The Pillars of Creation in the Eagle Nebula, captured by the Hubble Space Telescope', ratio: '16/9' }),
28
+ `uiImage({ src: '/img/nebula.jpg', alt: 'The Pillars of Creation in the Eagle Nebula', ratio: '16/9' })`,
29
29
  { col: true }
30
30
  )}
31
31
 
32
32
  <h2 class="doc-h2" id="rounded">Square and rounded</h2>
33
33
  ${demo(
34
- `<div style="max-width:200px;margin:0 auto">${uiImage({ src: '/img/placeholder-square.svg', alt: 'Profile photo', ratio: '1/1', rounded: true })}</div>`,
35
- `uiImage({ src: '/img/avatar.jpg', alt: 'Profile photo', ratio: '1/1', rounded: true })`,
34
+ `<div style="max-width:200px;margin:0 auto">${uiImage({ src: '/img/heic1501a.jpg', alt: 'The Pillars of Creation in the Eagle Nebula, captured by the Hubble Space Telescope', ratio: '1/1', rounded: true })}</div>`,
35
+ `uiImage({ src: '/img/nebula.jpg', alt: 'The Pillars of Creation in the Eagle Nebula', ratio: '1/1', rounded: true })`,
36
36
  { col: true }
37
37
  )}
38
38
 
39
39
  <h2 class="doc-h2" id="caption">With caption</h2>
40
40
  ${demo(
41
41
  uiImage({
42
- src: '/img/placeholder-tall.svg',
43
- alt: 'Aerial view of a coastal town',
42
+ src: '/img/heic1501a.jpg',
43
+ alt: 'The Pillars of Creation in the Eagle Nebula, captured by the Hubble Space Telescope',
44
44
  ratio: '4/3',
45
- caption: 'Aerial view of Porto, Portugal. Photo by João Silva.',
45
+ caption: 'The Pillars of Creation, Eagle Nebula. Photo: NASA, ESA, Hubble Heritage Team.',
46
46
  }),
47
47
  `uiImage({
48
- src: '/img/photo.jpg',
49
- alt: 'Aerial view of a coastal town',
48
+ src: '/img/nebula.jpg',
49
+ alt: 'The Pillars of Creation in the Eagle Nebula',
50
50
  ratio: '4/3',
51
- caption: 'Aerial view of Porto, Portugal. Photo by João Silva.',
51
+ caption: 'The Pillars of Creation, Eagle Nebula. Photo: NASA, ESA, Hubble Heritage Team.',
52
52
  })`,
53
53
  { col: true }
54
54
  )}
@@ -22,7 +22,7 @@ export default {
22
22
  content: `
23
23
  ${demo(
24
24
  media({
25
- image: `<div style="background:var(--ui-surface-2);border:1px solid var(--ui-border);border-radius:8px;aspect-ratio:16/9;display:flex;align-items:center;justify-content:center;color:var(--ui-muted);font-size:.875rem">Screenshot</div>`,
25
+ image: `<img src="/img/heic1501a.jpg" alt="The Pillars of Creation in the Eagle Nebula, captured by the Hubble Space Telescope" style="width:100%;height:100%;aspect-ratio:16/9;object-fit:cover;border-radius:8px;display:block">`,
26
26
  content: stack({ gap: 'md', content: '<h3 style="font-size:1.25rem;font-weight:700;color:var(--ui-text);margin:0">One-tap controls</h3><p style="color:var(--ui-muted);margin:0">Tap to flap. Chippy Bird takes seconds to learn and a lifetime to conquer.</p>' + button({ label: 'Download', href: '#' }) }),
27
27
  }),
28
28
  `media({
@@ -13,20 +13,26 @@ function esc(s) {
13
13
  }
14
14
 
15
15
  function statPill(label, value) {
16
- return `<span style="display:inline-flex;align-items:center;gap:0.3rem;padding:0.2rem 0.6rem;border-radius:999px;font-size:0.72rem;font-weight:500;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.1);color:rgba(255,255,255,0.55)">
17
- <span style="color:#7c3aed">●</span> ${esc(label)}: <strong style="color:rgba(255,255,255,0.85);font-weight:600">${esc(String(value))}</strong>
16
+ return `<span style="display:inline-flex;align-items:center;gap:0.3rem;padding:0.2rem 0.6rem;border-radius:999px;font-size:0.72rem;font-weight:500;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.14);color:rgba(255,255,255,0.7)">
17
+ <span style="color:#a78bfa">●</span> ${esc(label)}: <strong style="color:#fff;font-weight:600">${esc(String(value))}</strong>
18
18
  </span>`
19
19
  }
20
20
 
21
21
  function templateCard({ name, route, description, tags = [], stats = {}, preview = '' }) {
22
22
  const tagHtml = tags.map(t =>
23
- `<span style="display:inline-block;padding:0.2rem 0.6rem;background:rgba(255,255,255,0.07);border:1px solid rgba(255,255,255,0.12);border-radius:4px;font-size:0.72rem;font-weight:500;color:rgba(255,255,255,0.55)">${esc(t)}</span>`
23
+ `<span style="display:inline-block;padding:0.2rem 0.6rem;background:rgba(255,255,255,0.1);border:1px solid rgba(255,255,255,0.16);border-radius:4px;font-size:0.72rem;font-weight:500;color:rgba(255,255,255,0.7)">${esc(t)}</span>`
24
24
  ).join('')
25
25
 
26
26
  const statHtml = Object.entries(stats).map(([k, v]) => statPill(k, v)).join('')
27
27
 
28
+ // This card is a deliberately dark, self-contained surface (macOS-style
29
+ // preview window + dark body) regardless of the page's own theme — it
30
+ // mirrors the code-window pattern elsewhere. Uses a real dark background
31
+ // (not a translucent white tint over the page background), so it reads
32
+ // correctly on both the light and dark docs themes and keeps the
33
+ // white-tinted text/borders it was designed with at proper contrast.
28
34
  return `
29
- <div style="border:1px solid rgba(255,255,255,0.1);border-radius:0.875rem;overflow:hidden;display:flex;flex-direction:column;background:rgba(255,255,255,0.03)">
35
+ <div style="border:1px solid rgba(255,255,255,0.1);border-radius:0.875rem;overflow:hidden;display:flex;flex-direction:column;background:#171512">
30
36
 
31
37
  <a href="${esc(route)}" aria-label="Open ${esc(name)} template" style="display:block;background:#0d0d12;border-bottom:1px solid rgba(255,255,255,0.08);text-decoration:none;overflow:hidden">
32
38
  <div style="padding:0.5rem 0.75rem;background:#16161e;display:flex;align-items:center;gap:0.375rem;border-bottom:1px solid rgba(255,255,255,0.06)">
@@ -40,13 +46,13 @@ function templateCard({ name, route, description, tags = [], stats = {}, preview
40
46
  </div>
41
47
  </a>
42
48
 
43
- <div style="padding:1.25rem 1.25rem 1rem;flex:1;display:flex;flex-direction:column;gap:0.875rem">
49
+ <div style="padding:1.25rem 1.25rem 1rem;flex:1;display:flex;flex-direction:column;gap:0.875rem;color:#fff">
44
50
  <div>
45
51
  <div style="display:flex;flex-wrap:wrap;align-items:center;gap:0.5rem;margin-bottom:0.5rem">
46
- <h2 style="margin:0;font-size:1rem;font-weight:700;line-height:1.3">${esc(name)}</h2>
52
+ <h2 style="margin:0;font-size:1rem;font-weight:700;line-height:1.3;color:#fff">${esc(name)}</h2>
47
53
  ${tagHtml}
48
54
  </div>
49
- <p style="margin:0;font-size:0.85rem;line-height:1.6;opacity:0.65">${esc(description)}</p>
55
+ <p style="margin:0;font-size:0.85rem;line-height:1.6;color:rgba(255,255,255,0.7)">${esc(description)}</p>
50
56
  </div>
51
57
  <div style="display:flex;flex-wrap:wrap;gap:0.3rem;padding-top:0.625rem;border-top:1px solid rgba(255,255,255,0.08)">${statHtml}</div>
52
58
  </div>
@@ -852,42 +852,6 @@ hr.ui-divider {
852
852
  .ui-hero { padding: 5rem 1.5rem; text-align: center; }
853
853
  .ui-hero--left { text-align: left; }
854
854
  .ui-hero--sm { padding-top: 2.5rem; padding-bottom: 0; }
855
- .ui-hero--gradient,
856
- .ui-hero--gradient--purple {
857
- background:
858
- radial-gradient(ellipse 70% 80% at 75% 50%, #2d1b6e 0%, #1a0f45 45%, transparent 75%),
859
- radial-gradient(ellipse 60% 60% at 20% 80%, #1c1060 0%, transparent 65%),
860
- #0d0a1e;
861
- }
862
-
863
- .ui-hero--gradient--blue {
864
- background:
865
- radial-gradient(ellipse 70% 80% at 75% 50%, #0e2d6b 0%, #071840 45%, transparent 75%),
866
- radial-gradient(ellipse 60% 60% at 20% 80%, #0a1f5c 0%, transparent 65%),
867
- #060d1e;
868
- }
869
-
870
- .ui-hero--gradient--green {
871
- background:
872
- radial-gradient(ellipse 70% 80% at 75% 50%, #0d3d28 0%, #052218 45%, transparent 75%),
873
- radial-gradient(ellipse 60% 60% at 20% 80%, #083020 0%, transparent 65%),
874
- #020e09;
875
- }
876
-
877
- .ui-hero--gradient--rose {
878
- background:
879
- radial-gradient(ellipse 70% 80% at 75% 50%, #6b0e35 0%, #400720 45%, transparent 75%),
880
- radial-gradient(ellipse 60% 60% at 20% 80%, #5c0a2a 0%, transparent 65%),
881
- #1e0610;
882
- }
883
-
884
- .ui-hero--gradient--orange {
885
- background:
886
- radial-gradient(ellipse 70% 80% at 75% 50%, #6b2e0e 0%, #401807 45%, transparent 75%),
887
- radial-gradient(ellipse 60% 60% at 20% 80%, #5c2208 0%, transparent 65%),
888
- #1e0e04;
889
- }
890
-
891
855
  .ui-hero-inner { max-width: 720px; margin: 0 auto; }
892
856
  .ui-hero--left .ui-hero-inner { margin: 0; }
893
857
 
@@ -1755,14 +1719,14 @@ hr.ui-divider {
1755
1719
  .ui-code-window-filename {
1756
1720
  font-size: .75rem;
1757
1721
  font-family: var(--ui-mono);
1758
- color: #9898b2;
1722
+ color: var(--ui-muted);
1759
1723
  margin-left: .35rem;
1760
1724
  }
1761
1725
 
1762
1726
  .ui-code-window-lang {
1763
1727
  font-size: .7rem;
1764
1728
  font-family: var(--ui-mono);
1765
- color: #9898b2;
1729
+ color: var(--ui-muted);
1766
1730
  margin-left: auto;
1767
1731
  }
1768
1732
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invisibleloop/pulse",
3
- "version": "0.20.8",
3
+ "version": "0.20.10",
4
4
  "type": "module",
5
5
  "description": "AI-first frontend framework. The spec is the source of truth.",
6
6
  "license": "MIT",
@@ -1 +1 @@
1
- 0.20.8
1
+ 0.20.10
@@ -852,42 +852,6 @@ hr.ui-divider {
852
852
  .ui-hero { padding: 5rem 1.5rem; text-align: center; }
853
853
  .ui-hero--left { text-align: left; }
854
854
  .ui-hero--sm { padding-top: 2.5rem; padding-bottom: 0; }
855
- .ui-hero--gradient,
856
- .ui-hero--gradient--purple {
857
- background:
858
- radial-gradient(ellipse 70% 80% at 75% 50%, #2d1b6e 0%, #1a0f45 45%, transparent 75%),
859
- radial-gradient(ellipse 60% 60% at 20% 80%, #1c1060 0%, transparent 65%),
860
- #0d0a1e;
861
- }
862
-
863
- .ui-hero--gradient--blue {
864
- background:
865
- radial-gradient(ellipse 70% 80% at 75% 50%, #0e2d6b 0%, #071840 45%, transparent 75%),
866
- radial-gradient(ellipse 60% 60% at 20% 80%, #0a1f5c 0%, transparent 65%),
867
- #060d1e;
868
- }
869
-
870
- .ui-hero--gradient--green {
871
- background:
872
- radial-gradient(ellipse 70% 80% at 75% 50%, #0d3d28 0%, #052218 45%, transparent 75%),
873
- radial-gradient(ellipse 60% 60% at 20% 80%, #083020 0%, transparent 65%),
874
- #020e09;
875
- }
876
-
877
- .ui-hero--gradient--rose {
878
- background:
879
- radial-gradient(ellipse 70% 80% at 75% 50%, #6b0e35 0%, #400720 45%, transparent 75%),
880
- radial-gradient(ellipse 60% 60% at 20% 80%, #5c0a2a 0%, transparent 65%),
881
- #1e0610;
882
- }
883
-
884
- .ui-hero--gradient--orange {
885
- background:
886
- radial-gradient(ellipse 70% 80% at 75% 50%, #6b2e0e 0%, #401807 45%, transparent 75%),
887
- radial-gradient(ellipse 60% 60% at 20% 80%, #5c2208 0%, transparent 65%),
888
- #1e0e04;
889
- }
890
-
891
855
  .ui-hero-inner { max-width: 720px; margin: 0 auto; }
892
856
  .ui-hero--left .ui-hero-inner { margin: 0; }
893
857
 
@@ -1755,14 +1719,14 @@ hr.ui-divider {
1755
1719
  .ui-code-window-filename {
1756
1720
  font-size: .75rem;
1757
1721
  font-family: var(--ui-mono);
1758
- color: #9898b2;
1722
+ color: var(--ui-muted);
1759
1723
  margin-left: .35rem;
1760
1724
  }
1761
1725
 
1762
1726
  .ui-code-window-lang {
1763
1727
  font-size: .7rem;
1764
1728
  font-family: var(--ui-mono);
1765
- color: #9898b2;
1729
+ color: var(--ui-muted);
1766
1730
  margin-left: auto;
1767
1731
  }
1768
1732
 
package/scripts/build.js CHANGED
@@ -502,7 +502,7 @@ const result = await esbuild.build({
502
502
  platform: 'browser',
503
503
  outdir: OUT_DIR,
504
504
  entryNames: '[name]-[hash]',
505
- chunkNames: 'runtime-[hash]',
505
+ chunkNames: 'chunk-[hash]',
506
506
  splitting: true,
507
507
  minify: true,
508
508
  metafile: true,
@@ -520,13 +520,25 @@ const result = await esbuild.build({
520
520
 
521
521
  const manifest = {}
522
522
 
523
+ // Identify the real Pulse framework runtime chunk (the one containing mount())
524
+ // by which auto-split chunks actually import RUNTIME_PATH — not by filename
525
+ // prefix, since chunkNames now applies to every generic shared chunk esbuild
526
+ // splits out (app-level shared code included), not just the framework runtime.
527
+ const RUNTIME_ABS = path.resolve(RUNTIME_PATH)
528
+ const realRuntimeChunk = Object.entries(result.metafile.outputs).find(([outFile, meta]) => {
529
+ if (meta.entryPoint) return false
530
+ // meta.inputs keys are relative to process.cwd() — resolve to compare reliably
531
+ return Object.keys(meta.inputs).some(input => path.resolve(input) === RUNTIME_ABS)
532
+ })?.[0]
533
+
523
534
  for (const [outFile, meta] of Object.entries(result.metafile.outputs)) {
524
535
  // outFile is relative to process.cwd(), not ROOT — use path.resolve to get the
525
536
  // true absolute path before computing the URL-relative bundle path.
526
537
  const bundlePath = '/' + path.relative(path.join(ROOT, 'public'), path.resolve(outFile))
527
538
 
528
- // Shared runtime chunk — esbuild generates this via splitting, no entryPoint
529
- if (!meta.entryPoint && path.basename(outFile).startsWith('runtime-')) {
539
+ // Shared runtime chunk — esbuild generates this via splitting, no entryPoint.
540
+ // Identified by containing the actual runtime/index.js source, not by name.
541
+ if (!meta.entryPoint && outFile === realRuntimeChunk) {
530
542
  manifest['_runtime'] = bundlePath
531
543
  continue
532
544
  }
package/src/mcp/server.js CHANGED
@@ -1856,7 +1856,7 @@ export default {
1856
1856
  { label: 'Features', href: '#features' },
1857
1857
  { label: 'Pricing', href: '#pricing' },
1858
1858
  ], actions: button({ label: 'Get started', size: 'sm' }) })}
1859
- \${hero({ title: 'Your headline here', subtitle: 'Supporting copy that explains the value.', gradient: 'purple',
1859
+ \${hero({ title: 'Your headline here', subtitle: 'Supporting copy that explains the value.',
1860
1860
  actions: button({ label: 'Get started', size: 'lg' }) })}
1861
1861
  \${section({ id: 'features', content: container({ content: \`
1862
1862
  \${grid({ cols: 3, content:
@@ -7,12 +7,28 @@
7
7
  * No framework. No virtual DOM. No dependencies.
8
8
  */
9
9
 
10
- import { initClientStore, getStoreState, subscribe, updateStore, registerStoreMutations, dispatchStoreMutation, initLiveStore } from './store.js'
11
10
  import { trustedHTML, trustedScriptURL } from './tt.js'
12
11
 
13
12
  // Toast is lazy-loaded on first use — pages that never use _toast pay zero bytes
14
13
  const showToast = (opts) => import('./toast.js').then(m => m.showToast(opts))
15
14
 
15
+ // store.js is lazy-loaded only by pages that declare spec.store — pages that
16
+ // never subscribe to the global store pay zero bytes for it (same pattern as
17
+ // showToast above). updateStore is needed synchronously inside runAction()
18
+ // for _storeUpdate (any page can push to the store from an action, even one
19
+ // that doesn't itself subscribe), so it is loaded the same lazy way on first
20
+ // use and cached in _storeModule for subsequent calls.
21
+ let _storeModule = null
22
+ const loadStore = () => _storeModule
23
+ ? Promise.resolve(_storeModule)
24
+ : import('./store.js').then(m => { _storeModule = m; return m })
25
+
26
+ // data-store-event is wired via event delegation at bindEvents() time, before
27
+ // spec.store is known to be involved (a page can carry a data-store-event
28
+ // button without declaring spec.store itself) — load store.js on first fire.
29
+ const dispatchStoreMutationLazy = (name, payload) =>
30
+ loadStore().then(({ dispatchStoreMutation }) => dispatchStoreMutation(name, payload))
31
+
16
32
  // ---------------------------------------------------------------------------
17
33
  // Mount
18
34
  // ---------------------------------------------------------------------------
@@ -29,37 +45,85 @@ export function mount(spec, el, serverState = {}, options = {}) {
29
45
  throw new Error('[Pulse] mount: spec must have a view')
30
46
  }
31
47
  // Spec is validated server-side at startup — no need to re-validate in the browser
32
- // Initialise the client store from SSR data (no-op after the first page mount).
33
- // window.__PULSE_STORE__ is serialised by the server when a store is registered.
34
- if (typeof window !== 'undefined') {
35
- initClientStore(window.__PULSE_STORE__ || {})
36
- // Register store mutations so data-store-event bindings can dispatch them.
37
- // No-op on subsequent mounts — mutations persist across client navigations.
38
- if (options.store?.mutations) {
39
- registerStoreMutations(options.store.mutations)
40
- }
41
- }
42
-
43
48
  // Separate page-level server state from store keys so they can be tracked
44
49
  // independently — store values come from the live singleton, not the snapshot.
45
50
  const _storeKeys = new Set(spec.store || [])
46
51
 
47
- // Live store push — connect to the server's SSE channel, but only when this
48
- // page actually subscribes to store keys. Singleton: survives navigations,
49
- // repeat mounts are no-ops. Pages without store keys never open a connection.
50
- if (typeof window !== 'undefined' && window.__PULSE_LIVE__ && _storeKeys.size > 0) {
51
- initLiveStore(window.__PULSE_LIVE__)
52
+ // store.js (initClientStore, registerStoreMutations, subscribe, initLiveStore,
53
+ // getStoreState, updateStore, dispatchStoreMutation) is lazy-loaded — only pages
54
+ // that actually touch the store pay for it. Two independent triggers load it:
55
+ // 1. spec.store is non-empty (this page reads store data / needs live reactivity)
56
+ // 2. a data-store-event fires, or an action returns _storeUpdate (this page
57
+ // pushes to the store without necessarily subscribing to it)
58
+ // Both paths funnel through loadStore(), which caches the resolved module so a
59
+ // second trigger on the same page is a no-op re-await, not a second fetch.
60
+ //
61
+ // initClientStore is safe to defer: it is only ever read via getStoreState(),
62
+ // which is only called from getEffectiveServerState() below, itself gated on
63
+ // _storeKeys.size — so a page with no spec.store never needs the store singleton
64
+ // initialised at all. window.__PULSE_STORE__ is also re-serialised fresh by the
65
+ // server on every navigation (see ssr.js buildStoreScript / navigate.js's
66
+ // window.__updatePulseStore__), so there is no cross-navigation dependency on an
67
+ // eager initClientStore call from a page that didn't declare spec.store.
68
+ // Populated once store.js resolves and this page subscribes — destroy() reads
69
+ // it at call time, so a destroy() that races the import still unsubscribes
70
+ // correctly (it just runs the .then() below first, since both are microtasks
71
+ // queued off the same import; destroy() itself is only ever called later, from
72
+ // user code or client navigation, well after mount() has returned).
73
+ let _unsubStore = null
74
+ let _destroyed = false
75
+
76
+ // NOTE: subscribe() below is intentionally not gated on `typeof window` — this
77
+ // matches the pre-lazy behaviour, where store subscription worked in any
78
+ // environment (including Node test environments with no `window` global) as
79
+ // long as spec.store was non-empty. Only the window-derived inputs
80
+ // (window.__PULSE_STORE__, window.__PULSE_LIVE__) are individually guarded.
81
+ if (_storeKeys.size > 0) {
82
+ loadStore().then(({ initClientStore, registerStoreMutations, initLiveStore, subscribe }) => {
83
+ if (_destroyed) return
84
+ if (typeof window !== 'undefined') {
85
+ // window.__PULSE_STORE__ is serialised by the server when a store is registered.
86
+ initClientStore(window.__PULSE_STORE__ || {})
87
+ // Register store mutations so data-store-event bindings can dispatch them.
88
+ // No-op on subsequent mounts — mutations persist across client navigations.
89
+ if (options.store?.mutations) {
90
+ registerStoreMutations(options.store.mutations)
91
+ }
92
+ // Live store push — connect to the server's SSE channel, but only when this
93
+ // page actually subscribes to store keys. Singleton: survives navigations,
94
+ // repeat mounts are no-ops. Pages without store keys never open a connection.
95
+ if (window.__PULSE_LIVE__) {
96
+ initLiveStore(window.__PULSE_LIVE__)
97
+ }
98
+ }
99
+ // Subscribe to global store — re-render whenever store keys this page uses
100
+ // change. Wired here (after the async import resolves) rather than
101
+ // synchronously in mount() — see the module-level comment on loadStore.
102
+ _unsubStore = subscribe(() => render())
103
+ })
52
104
  }
53
105
  const _pageServerState = {}
54
106
  for (const [k, v] of Object.entries(serverState)) {
55
107
  if (!_storeKeys.has(k)) _pageServerState[k] = v
56
108
  }
57
109
 
110
+ // Snapshot of the store keys as SSR handed them to mount() — used as a
111
+ // fallback for renders that happen before store.js's lazy import resolves
112
+ // (initial render, and any dispatch that fires in that window). This keeps
113
+ // first paint identical to the pre-lazy behaviour: the SSR-seeded values are
114
+ // available immediately. Once store.js loads, getEffectiveServerState()
115
+ // switches over to the live singleton (getStoreState()), which is required
116
+ // for correctness after any store mutation.
117
+ const _storeSnapshot = {}
118
+ for (const k of _storeKeys) {
119
+ if (serverState[k] !== undefined) _storeSnapshot[k] = serverState[k]
120
+ }
121
+
58
122
  // Build the server state the view sees: fresh store slice + page-level data.
59
123
  // Page-level keys always win over store keys with the same name.
60
124
  function getEffectiveServerState() {
61
125
  if (!_storeKeys.size) return _pageServerState
62
- const storeState = getStoreState()
126
+ const storeState = _storeModule ? _storeModule.getStoreState() : _storeSnapshot
63
127
  const slice = {}
64
128
  for (const key of _storeKeys) {
65
129
  if (storeState[key] !== undefined) slice[key] = storeState[key]
@@ -109,7 +173,7 @@ export function mount(spec, el, serverState = {}, options = {}) {
109
173
  try {
110
174
  html = resolveView(spec, state, getEffectiveServerState())
111
175
  } catch (err) {
112
- console.error('[Pulse] View error:', err)
176
+ console.error('[Pulse] view error:', err)
113
177
  const serverState = getEffectiveServerState()
114
178
  html = spec.onViewError
115
179
  ? spec.onViewError(err, state, serverState)
@@ -120,6 +184,24 @@ export function mount(spec, el, serverState = {}, options = {}) {
120
184
  morph(el, html)
121
185
  }
122
186
 
187
+ // ---------------------------------------------------------------------------
188
+ // Result application — shared by every mutation/action lifecycle step.
189
+ // Handles the side-channel keys (_toast, _storeUpdate), merges the rest of
190
+ // `raw` into state via constraints, and re-renders only if state actually
191
+ // changed. Used by dispatch() and every stage of runAction().
192
+ // ---------------------------------------------------------------------------
193
+
194
+ function applyResult(raw) {
195
+ const { _toast, _storeUpdate, ...partial } = raw ?? {}
196
+ // store.js may not be loaded yet on a page that doesn't itself declare
197
+ // spec.store but still pushes via _storeUpdate — load it on demand.
198
+ if (_storeUpdate) loadStore().then(({ updateStore }) => updateStore(_storeUpdate))
199
+ if (_toast) showToast(_toast)
200
+ const prev = state
201
+ state = applyConstraints({ ...state, ...partial }, spec.constraints)
202
+ if (shallowChanged(prev, state)) render()
203
+ }
204
+
123
205
  // ---------------------------------------------------------------------------
124
206
  // Dispatch — the single entry point for all state changes
125
207
  // ---------------------------------------------------------------------------
@@ -128,12 +210,8 @@ export function mount(spec, el, serverState = {}, options = {}) {
128
210
  // Mutation
129
211
  if (spec.mutations?.[type]) {
130
212
  const raw = spec.mutations[type](state, payload)
131
- if (raw?._toast) showToast(raw._toast)
132
- const { _toast, ...partial } = raw ?? {}
133
- const prev = state
134
- state = applyConstraints({ ...state, ...partial }, spec.constraints)
213
+ applyResult(raw)
135
214
  persist()
136
- if (shallowChanged(prev, state)) render()
137
215
  return
138
216
  }
139
217
 
@@ -143,7 +221,7 @@ export function mount(spec, el, serverState = {}, options = {}) {
143
221
  return
144
222
  }
145
223
 
146
- console.warn(`[Pulse] No mutation or action found for "${type}"`)
224
+ console.warn(`[Pulse] no mutation/action "${type}"`)
147
225
  }
148
226
 
149
227
  // ---------------------------------------------------------------------------
@@ -153,25 +231,15 @@ export function mount(spec, el, serverState = {}, options = {}) {
153
231
  async function runAction(name, action, currentState, payload) {
154
232
  // Optimistic update before async work
155
233
  if (action.onStart) {
156
- const raw = action.onStart(currentState, payload)
157
- if (raw?._toast) showToast(raw._toast)
158
- const { _toast, ...partial } = raw ?? {}
159
- const prev = state
160
- state = applyConstraints({ ...state, ...partial }, spec.constraints)
161
- if (shallowChanged(prev, state)) render()
234
+ applyResult(action.onStart(currentState, payload))
162
235
  }
163
236
 
164
237
  // Validate before running if requested
165
238
  if (action.validate) {
166
239
  const errors = validateFields(state, spec.validation)
167
240
  if (errors.length > 0) {
168
- console.warn(`[Pulse] Validation failed for action "${name}":`, errors)
169
- const raw = action.onError?.(state, { validation: errors }) ?? {}
170
- if (raw._toast) showToast(raw._toast)
171
- const { _toast, ...partial } = raw
172
- const prev = state
173
- state = applyConstraints({ ...state, ...partial }, spec.constraints)
174
- if (shallowChanged(prev, state)) render()
241
+ console.warn(`[Pulse] Validation failed: "${name}"`, errors)
242
+ applyResult(action.onError?.(state, { validation: errors }))
175
243
  return
176
244
  }
177
245
  }
@@ -179,23 +247,10 @@ export function mount(spec, el, serverState = {}, options = {}) {
179
247
  try {
180
248
  // Pass fresh effective server state (includes live store values)
181
249
  const result = await action.run(state, getEffectiveServerState(), payload)
182
- const raw = action.onSuccess(state, result) ?? {}
183
-
184
- // _storeUpdate — push changes to the global store and notify all subscribers
185
- if (raw._storeUpdate) updateStore(raw._storeUpdate)
186
- if (raw._toast) showToast(raw._toast)
187
- const { _storeUpdate: _su, _toast: _t, ...partial } = raw
188
- const prev = state
189
- state = applyConstraints({ ...state, ...partial }, spec.constraints)
190
- if (shallowChanged(prev, state)) render()
250
+ applyResult(action.onSuccess(state, result))
191
251
  } catch (error) {
192
- console.error(`[Pulse] Action "${name}" failed:`, error)
193
- const raw = action.onError(state, error) ?? {}
194
- if (raw._toast) showToast(raw._toast)
195
- const { _toast, ...partial } = raw
196
- const prev = state
197
- state = applyConstraints({ ...state, ...partial }, spec.constraints)
198
- if (shallowChanged(prev, state)) render()
252
+ console.error(`[Pulse] action "${name}" failed:`, error)
253
+ applyResult(action.onError(state, error))
199
254
  }
200
255
 
201
256
  persist()
@@ -210,9 +265,6 @@ export function mount(spec, el, serverState = {}, options = {}) {
210
265
  const _eventAbort = new AbortController()
211
266
  bindEvents(el, dispatch, _eventAbort.signal)
212
267
 
213
- // Subscribe to global store — re-render whenever store keys this page uses change.
214
- const _unsubStore = _storeKeys.size > 0 ? subscribe(() => render()) : null
215
-
216
268
  // If the element was server-rendered, skip the initial render to avoid
217
269
  // touching existing DOM — this preserves the early LCP paint from SSR.
218
270
  // Morph on first mutation will diff from whatever SSR left in the DOM.
@@ -235,7 +287,7 @@ export function mount(spec, el, serverState = {}, options = {}) {
235
287
  refresh: render,
236
288
 
237
289
  /** Tear down — remove event listeners, unsubscribe from store, clear element */
238
- destroy: () => { _unsubStore?.(); _eventAbort.abort(); el.innerHTML = trustedHTML('') }
290
+ destroy: () => { _destroyed = true; _unsubStore?.(); _eventAbort.abort(); el.innerHTML = trustedHTML('') }
239
291
  }
240
292
  }
241
293
 
@@ -291,7 +343,7 @@ function bindEvents(el, dispatch, signal) {
291
343
  const storeTarget = e.target?.closest?.('[data-store-event]')
292
344
  if (storeTarget) {
293
345
  const [sType, sName] = parseEventAttr(storeTarget.dataset.storeEvent)
294
- if (sType === 'click') { e.preventDefault(); dispatchStoreMutation(sName, e) }
346
+ if (sType === 'click') { e.preventDefault(); dispatchStoreMutationLazy(sName, e) }
295
347
  return
296
348
  }
297
349
 
@@ -334,7 +386,7 @@ function bindEvents(el, dispatch, signal) {
334
386
  const storeTarget = e.target?.closest?.('[data-store-event]')
335
387
  if (storeTarget) {
336
388
  const [sType, sName] = parseEventAttr(storeTarget.dataset.storeEvent)
337
- if (sType === 'change') dispatchStoreMutation(sName, e)
389
+ if (sType === 'change') dispatchStoreMutationLazy(sName, e)
338
390
  return
339
391
  }
340
392
  const target = e.target?.closest?.('[data-event]')
@@ -348,7 +400,7 @@ function bindEvents(el, dispatch, signal) {
348
400
  const storeTarget = e.target?.closest?.('[data-store-event]')
349
401
  if (storeTarget) {
350
402
  const [sType, sName] = parseEventAttr(storeTarget.dataset.storeEvent)
351
- if (sType === 'input') dispatchStoreMutation(sName, e)
403
+ if (sType === 'input') dispatchStoreMutationLazy(sName, e)
352
404
  return
353
405
  }
354
406
  const target = e.target?.closest?.('[data-event]')
@@ -598,7 +598,7 @@ test('dispatchStoreMutation warns on unknown mutation', () => {
598
598
  assert(warnings.some(w => w.includes('doesNotExist')), 'Expected warning for unknown store mutation')
599
599
  })
600
600
 
601
- test('dispatchStoreMutation notifies subscribed pages', () => {
601
+ await testAsync('dispatchStoreMutation notifies subscribed pages', async () => {
602
602
  updateStore({ counter: 0 })
603
603
  const el = new FakeElement()
604
604
  const spec = {
@@ -608,6 +608,12 @@ test('dispatchStoreMutation notifies subscribed pages', () => {
608
608
  view: (state, server) => `<span>${server.counter ?? 0}</span>`,
609
609
  }
610
610
  mount(spec, el, { counter: 0 })
611
+ // store.js is lazy-loaded by mount() when spec.store is non-empty — its
612
+ // subscription is wired up after the dynamic import() resolves. Resolving
613
+ // a dynamic import is scheduled as a macrotask even for an already-cached
614
+ // module (verified empirically — microtask-only yields are not enough),
615
+ // so a setTimeout(0) tick-yield is required here, not just Promise.resolve().
616
+ await new Promise(r => setTimeout(r, 0))
611
617
  const before = el.innerHTML
612
618
  dispatchStoreMutation('inc')
613
619
  assert(el.innerHTML !== before, `Page should re-render after store mutation`)
@@ -623,7 +629,7 @@ test('registerStoreMutations is a no-op after first call', () => {
623
629
 
624
630
  console.log('\nLive store push (client)\n')
625
631
 
626
- test('initLiveStore connects once, merges SSE store events, survives bad frames', () => {
632
+ await testAsync('initLiveStore connects once, merges SSE store events, survives bad frames', async () => {
627
633
  const instances = []
628
634
  globalThis.EventSource = class {
629
635
  constructor(url) { this.url = url; this._listeners = {}; instances.push(this) }
@@ -638,6 +644,9 @@ test('initLiveStore connects once, merges SSE store events, survives bad frames'
638
644
  // A store event merges into the client store and notifies subscribers
639
645
  const el = new FakeElement()
640
646
  mount({ route: '/stock', store: ['stock'], state: {}, view: (s, server) => `<b>${server.stock ?? '—'}</b>` }, el, {})
647
+ // store.js is lazy-loaded when spec.store is non-empty — wait for the dynamic
648
+ // import to resolve before the subscription (wired inside it) is live.
649
+ await new Promise(r => setTimeout(r, 0))
641
650
  instances[0]._listeners.store({ data: JSON.stringify({ stock: 7 }) })
642
651
  assert(getStoreState().stock === 7, `Expected stock 7, got ${getStoreState().stock}`)
643
652
  assert(el.innerHTML.includes('7'), `Subscribed page should re-render: ${el.innerHTML}`)
@@ -650,6 +659,41 @@ test('initLiveStore connects once, merges SSE store events, survives bad frames'
650
659
  delete globalThis.EventSource
651
660
  })
652
661
 
662
+ console.log('\nLazy store loading (through mount())\n')
663
+
664
+ await testAsync('mount() with spec.store lazy-loads store.js and wires reactivity end to end', async () => {
665
+ updateStore({ counter: 100 })
666
+ const el = new FakeElement()
667
+ const spec = {
668
+ route: '/lazy-store',
669
+ store: ['counter'],
670
+ state: {},
671
+ view: (state, server) => `<span>${server.counter ?? 0}</span>`,
672
+ }
673
+
674
+ const instance = mount(spec, el, { counter: 100 })
675
+
676
+ // mount() returns synchronously, before store.js's dynamic import has resolved —
677
+ // the view renders with the page-level serverState snapshot passed to mount()
678
+ // (not yet the live store slice), and store-driven reactivity is not wired yet.
679
+ assert(el.innerHTML.includes('100'), `Expected initial render to use the snapshot: ${el.innerHTML}`)
680
+
681
+ // Yield a macrotask so the lazy import('./store.js') resolves. This is the
682
+ // "one microtask/tick later" cost documented for the lazy-loading change —
683
+ // after this point store subscription, registerStoreMutations, and (when
684
+ // window.__PULSE_LIVE__ is set) initLiveStore have all been wired up.
685
+ await new Promise(r => setTimeout(r, 0))
686
+
687
+ // Store-driven re-render now works correctly end to end through mount():
688
+ // a mutation dispatched on the singleton reaches this page's subscription.
689
+ const before = el.innerHTML
690
+ dispatchStoreMutation('inc')
691
+ assert(el.innerHTML !== before, 'Page should re-render once store.js has loaded and subscribed')
692
+ assert(el.innerHTML.includes('101'), `Expected counter 101 in HTML, got: ${el.innerHTML}`)
693
+
694
+ instance.destroy()
695
+ })
696
+
653
697
  // ---------------------------------------------------------------------------
654
698
 
655
699
  console.log('\nDebounce / throttle\n')
@@ -313,6 +313,32 @@ export function buildHydrateScript(spec, storeDef, nonce) {
313
313
  * @param {Object} [options.timing] - Server-Timing values
314
314
  * @returns {string}
315
315
  */
316
+ // Third-party stylesheets (e.g. Google Fonts) pay a DNS+TLS round trip before
317
+ // the request can even start — preconnect warms that connection up in
318
+ // parallel with the document request instead of after the browser parses the
319
+ // stylesheet link. Relative URLs (same-origin) don't need this — the
320
+ // connection to the page's own origin is already open. Shared by wrapDocument
321
+ // (non-streaming render) and the streaming response head in server/index.js,
322
+ // which builds its <head> independently rather than calling wrapDocument.
323
+ export function buildStylePreconnects(styles = []) {
324
+ const styleOrigins = [...new Set(
325
+ styles
326
+ .map(href => { try { return new URL(href).origin } catch { return null } })
327
+ .filter(Boolean)
328
+ )]
329
+ return styleOrigins
330
+ .flatMap(origin => {
331
+ const links = [`<link rel="preconnect" href="${esc(origin)}">`]
332
+ // Google Fonts serves the CSS from fonts.googleapis.com but the actual
333
+ // font files from fonts.gstatic.com — preconnect to both up front.
334
+ if (origin === 'https://fonts.googleapis.com') {
335
+ links.push('<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>')
336
+ }
337
+ return links
338
+ })
339
+ .join('\n ')
340
+ }
341
+
316
342
  export function wrapDocument({ content, spec = {}, serverState = {}, storeState = null, storeDef = null, timing = {}, extraBody = '', extraHead = '', nonce = '', runtimeBundle = '', faviconHref = '', livePath = '' }) {
317
343
  const meta = spec.meta || {}
318
344
  const title = meta.title || 'Pulse'
@@ -328,6 +354,8 @@ export function wrapDocument({ content, spec = {}, serverState = {}, storeState
328
354
  meta.ogImage ? `<meta property="og:image" content="${esc(meta.ogImage)}">` : '',
329
355
  ].filter(Boolean).join('\n ')
330
356
 
357
+ const stylePreconnects = buildStylePreconnects(meta.styles || [])
358
+
331
359
  const stylePreloads = (meta.styles || [])
332
360
  .map(href => `<link rel="preload" as="style" href="${esc(href)}">`)
333
361
  .join('\n ')
@@ -381,6 +409,7 @@ export function wrapDocument({ content, spec = {}, serverState = {}, storeState
381
409
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
382
410
  <link rel="icon" href="${faviconHref || 'data:,'}">
383
411
  <title>${esc(title)}</title>
412
+ ${stylePreconnects}
384
413
  ${stylePreloads}
385
414
  ${runtimePreload}
386
415
  ${extraHead}
@@ -21,7 +21,7 @@ import { createRequire } from 'module'
21
21
 
22
22
  const _require = createRequire(import.meta.url)
23
23
  export const version = _require('../../package.json').version
24
- import { renderToString, renderToStream, renderToNavStream, wrapDocument, resolveServerState, buildStoreScript, buildHydrateScript } from '../runtime/ssr.js'
24
+ import { renderToString, renderToStream, renderToNavStream, wrapDocument, resolveServerState, buildStoreScript, buildHydrateScript, buildStylePreconnects } from '../runtime/ssr.js'
25
25
  import { validateSpec } from '../spec/schema.js'
26
26
  import { validateStore, resolveStoreState } from '../store/index.js'
27
27
 
@@ -1322,6 +1322,8 @@ async function handleStreamResponse(spec, ctx, req, res, extraBody = '', dev = f
1322
1322
  try { res.writeEarlyHints({ link: earlyLinks }) } catch {}
1323
1323
  }
1324
1324
 
1325
+ const stylePreconnects = buildStylePreconnects(meta.styles || [])
1326
+
1325
1327
  const stylePreloads = (meta.styles || [])
1326
1328
  .map(href => ` <link rel="preload" as="style" href="${escHtml(href)}">`)
1327
1329
  .join('\n')
@@ -1358,7 +1360,7 @@ async function handleStreamResponse(spec, ctx, req, res, extraBody = '', dev = f
1358
1360
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
1359
1361
  <link rel="icon" href="${faviconPath || 'data:,'}">
1360
1362
  <title>${escHtml(title)}</title>
1361
- ${stylePreloads ? stylePreloads + '\n' : ''}${runtimePreload ? runtimePreload + '\n' : ''}${dev ? devImportMap(nonce) + '\n' : ''}${metaTags}
1363
+ ${stylePreconnects ? stylePreconnects + '\n' : ''}${stylePreloads ? stylePreloads + '\n' : ''}${runtimePreload ? runtimePreload + '\n' : ''}${dev ? devImportMap(nonce) + '\n' : ''}${metaTags}
1362
1364
  </head>
1363
1365
  <body${bodyAttr}>
1364
1366
  <div id="pulse-root">`
package/src/ui/hero.js CHANGED
@@ -17,8 +17,7 @@
17
17
  * 'split' (default): 50/50 columns.
18
18
  * 'asymmetric': 60/40 text-heavy columns.
19
19
  * 'overlap': image fills the section background, text overlaid with dark gradient.
20
- * @param {boolean|'purple'|'blue'|'green'|'rose'|'orange'} opts.gradient
21
- * @param {string} opts.background - Arbitrary CSS background value — overrides gradient preset
20
+ * @param {string} opts.background - Arbitrary CSS background value
22
21
  * @param {string} opts.backgroundImage - CSS background-image value (url, gradient, etc) — enables full-bleed mode
23
22
  * @param {boolean|number} opts.overlay - Dark overlay opacity for backgroundImage mode (0–1, or true for 0.4 default)
24
23
  * @param {string} opts.eyebrowColor - Overrides eyebrow text colour
@@ -27,8 +26,7 @@
27
26
 
28
27
  import { escHtml as e } from '../html.js'
29
28
 
30
- const GRADIENT_PRESETS = new Set(['purple', 'blue', 'green', 'rose', 'orange'])
31
- const LAYOUTS = new Set(['split', 'asymmetric', 'overlap'])
29
+ const LAYOUTS = new Set(['split', 'asymmetric', 'overlap'])
32
30
 
33
31
  export function hero({
34
32
  eyebrow = '',
@@ -40,7 +38,6 @@ export function hero({
40
38
  align = 'center',
41
39
  size = 'md',
42
40
  layout = 'split',
43
- gradient = false,
44
41
  background = '',
45
42
  backgroundImage = '',
46
43
  overlay = false,
@@ -52,13 +49,6 @@ export function hero({
52
49
  const hasBackgroundImage = Boolean(backgroundImage)
53
50
  const safeLayout = (hasImage && LAYOUTS.has(layout)) ? layout : 'split'
54
51
 
55
- // Resolve gradient class
56
- const gradientClass = gradient
57
- ? (gradient === true || gradient === 'purple')
58
- ? 'ui-hero--gradient'
59
- : GRADIENT_PRESETS.has(gradient) ? `ui-hero--gradient--${gradient}` : ''
60
- : ''
61
-
62
52
  const classes = [
63
53
  'ui-hero',
64
54
  !hasImage && !hasBackgroundImage && align === 'left' && 'ui-hero--left',
@@ -66,7 +56,6 @@ export function hero({
66
56
  hasImage && safeLayout !== 'overlap' && imageAlign === 'left' && 'ui-hero--media-left',
67
57
  hasBackgroundImage && 'ui-hero--bg-image',
68
58
  size === 'sm' && 'ui-hero--sm',
69
- gradientClass,
70
59
  cls,
71
60
  ].filter(Boolean).join(' ')
72
61