@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.
- package/docs/public/pulse-ui.css +2 -38
- package/docs/public/theme.css +8 -8
- package/docs/src/pages/components/card.js +10 -10
- package/docs/src/pages/components/hero.js +2 -31
- package/docs/src/pages/components/image.js +10 -10
- package/docs/src/pages/components/media.js +1 -1
- package/docs/src/pages/templates.js +13 -7
- package/examples/public/pulse-ui.css +2 -38
- package/package.json +1 -1
- package/public/.pulse-ui-version +1 -1
- package/public/pulse-ui.css +2 -38
- package/scripts/build.js +15 -3
- package/src/mcp/server.js +1 -1
- package/src/runtime/index.js +113 -61
- package/src/runtime/runtime.test.js +46 -2
- package/src/runtime/ssr.js +29 -0
- package/src/server/index.js +4 -2
- package/src/ui/hero.js +2 -13
package/docs/public/pulse-ui.css
CHANGED
|
@@ -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:
|
|
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:
|
|
1729
|
+
color: var(--ui-muted);
|
|
1766
1730
|
margin-left: auto;
|
|
1767
1731
|
}
|
|
1768
1732
|
|
package/docs/public/theme.css
CHANGED
|
@@ -24,22 +24,22 @@
|
|
|
24
24
|
--border-subtle: #ece7d9;
|
|
25
25
|
--text: var(--brut-ink);
|
|
26
26
|
--muted: var(--brut-muted);
|
|
27
|
-
--muted-2: #
|
|
28
|
-
--accent:
|
|
27
|
+
--muted-2: #736b5c;
|
|
28
|
+
--accent: #8a6300;
|
|
29
29
|
--accent-text: var(--brut-ink);
|
|
30
|
-
--accent-hover: #
|
|
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: #
|
|
38
|
-
--tok-cmt: #
|
|
39
|
-
--tok-num: #
|
|
37
|
+
--tok-str: #166534;
|
|
38
|
+
--tok-cmt: #5c574f;
|
|
39
|
+
--tok-num: #9a3412;
|
|
40
40
|
--tok-fn: #1d4ed8;
|
|
41
|
-
--tok-prop: #
|
|
42
|
-
--tok-op: #
|
|
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-
|
|
44
|
-
<
|
|
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">
|
|
48
|
-
<p class="u-text-muted u-text-sm" style="margin:0">
|
|
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: '
|
|
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-
|
|
57
|
-
<img src="/img/
|
|
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">
|
|
61
|
-
<p class="u-text-muted u-text-sm">
|
|
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: '
|
|
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 = `<
|
|
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>
|
|
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/
|
|
28
|
-
`uiImage({ src: '/img/
|
|
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/
|
|
35
|
-
`uiImage({ src: '/img/
|
|
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/
|
|
43
|
-
alt: '
|
|
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: '
|
|
45
|
+
caption: 'The Pillars of Creation, Eagle Nebula. Photo: NASA, ESA, Hubble Heritage Team.',
|
|
46
46
|
}),
|
|
47
47
|
`uiImage({
|
|
48
|
-
src: '/img/
|
|
49
|
-
alt: '
|
|
48
|
+
src: '/img/nebula.jpg',
|
|
49
|
+
alt: 'The Pillars of Creation in the Eagle Nebula',
|
|
50
50
|
ratio: '4/3',
|
|
51
|
-
caption: '
|
|
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: `<
|
|
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.
|
|
17
|
-
<span style="color:#
|
|
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.
|
|
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
|
|
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;
|
|
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:
|
|
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:
|
|
1729
|
+
color: var(--ui-muted);
|
|
1766
1730
|
margin-left: auto;
|
|
1767
1731
|
}
|
|
1768
1732
|
|
package/package.json
CHANGED
package/public/.pulse-ui-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.20.
|
|
1
|
+
0.20.10
|
package/public/pulse-ui.css
CHANGED
|
@@ -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:
|
|
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:
|
|
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: '
|
|
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
|
-
|
|
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.',
|
|
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:
|
package/src/runtime/index.js
CHANGED
|
@@ -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
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
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]
|
|
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
|
-
|
|
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]
|
|
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
|
-
|
|
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
|
|
169
|
-
|
|
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
|
-
|
|
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]
|
|
193
|
-
|
|
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();
|
|
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')
|
|
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')
|
|
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
|
-
|
|
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
|
-
|
|
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')
|
package/src/runtime/ssr.js
CHANGED
|
@@ -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}
|
package/src/server/index.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|