@invisibleloop/pulse 0.2.2 → 0.2.4

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Pulse
2
2
 
3
- **A spec-first, AI-native web framework.** Early access — v0.1.30.
3
+ **A spec-first, AI-native web framework.** Early access.
4
4
 
5
5
  Write a plain JavaScript object that describes what a page does. Pulse handles routing, SSR, hydration, client-side navigation, compression, security headers, and caching automatically.
6
6
 
@@ -1 +1 @@
1
- 0.1.38
1
+ 0.2.2
@@ -1157,8 +1157,10 @@ a:hover {
1157
1157
  }
1158
1158
 
1159
1159
  .demo-preview--col .demo-preview-inner {
1160
- flex-direction: column;
1161
- align-items: stretch;
1160
+ display: block;
1161
+ }
1162
+ .demo-preview--col .demo-preview-inner > * + * {
1163
+ margin-top: 0.75rem;
1162
1164
  }
1163
1165
 
1164
1166
  .demo-preview--scroll .demo-preview-inner {
@@ -0,0 +1,18 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 450" role="img" aria-label="Placeholder image">
2
+ <defs>
3
+ <linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
4
+ <stop offset="0%" stop-color="#0d1b2e"/>
5
+ <stop offset="100%" stop-color="#1a3a5c"/>
6
+ </linearGradient>
7
+ <linearGradient id="ground" x1="0" y1="0" x2="0" y2="1">
8
+ <stop offset="0%" stop-color="#1a2e1a"/>
9
+ <stop offset="100%" stop-color="#0d1a0d"/>
10
+ </linearGradient>
11
+ </defs>
12
+ <rect width="800" height="450" fill="url(#sky)"/>
13
+ <circle cx="640" cy="100" r="55" fill="#f5c542" opacity=".15"/>
14
+ <circle cx="640" cy="100" r="40" fill="#f5c542" opacity=".2"/>
15
+ <polygon points="0,300 120,180 240,260 320,160 440,240 560,140 680,210 800,170 800,450 0,450" fill="url(#ground)"/>
16
+ <polygon points="0,310 100,270 200,300 320,260 440,290 560,255 680,280 800,260 800,450 0,450" fill="#0f200f" opacity=".7"/>
17
+ <rect width="800" height="450" fill="#1a1a2e" opacity=".1"/>
18
+ </svg>
@@ -0,0 +1,14 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" role="img" aria-label="Placeholder image">
2
+ <defs>
3
+ <linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
4
+ <stop offset="0%" stop-color="#1a1a2e"/>
5
+ <stop offset="100%" stop-color="#0d0d1a"/>
6
+ </linearGradient>
7
+ </defs>
8
+ <rect width="400" height="400" fill="url(#bg)"/>
9
+ <circle cx="200" cy="155" r="72" fill="#2a2a3e"/>
10
+ <circle cx="200" cy="155" r="58" fill="#3a3a50"/>
11
+ <circle cx="200" cy="140" r="35" fill="#4a4a62"/>
12
+ <ellipse cx="200" cy="320" rx="110" ry="70" fill="#2a2a3e"/>
13
+ <ellipse cx="200" cy="310" rx="90" ry="58" fill="#3a3a50"/>
14
+ </svg>
@@ -0,0 +1,22 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 600" role="img" aria-label="Placeholder image">
2
+ <defs>
3
+ <linearGradient id="sea" x1="0" y1="0" x2="0" y2="1">
4
+ <stop offset="0%" stop-color="#0d2a4a"/>
5
+ <stop offset="100%" stop-color="#051520"/>
6
+ </linearGradient>
7
+ <linearGradient id="sky2" x1="0" y1="0" x2="0" y2="1">
8
+ <stop offset="0%" stop-color="#0d1b2e"/>
9
+ <stop offset="100%" stop-color="#1a3a5c"/>
10
+ </linearGradient>
11
+ </defs>
12
+ <rect width="800" height="600" fill="url(#sky2)"/>
13
+ <rect x="0" y="320" width="800" height="280" fill="url(#sea)"/>
14
+ <rect x="0" y="310" width="800" height="20" fill="#0a2a3a" opacity=".6"/>
15
+ <rect x="120" y="200" width="60" height="120" fill="#1e2e3e"/>
16
+ <rect x="200" y="170" width="80" height="150" fill="#1a2a3a"/>
17
+ <rect x="300" y="210" width="50" height="110" fill="#1e2e3e"/>
18
+ <rect x="380" y="185" width="90" height="135" fill="#1a2a3a"/>
19
+ <rect x="490" y="220" width="55" height="100" fill="#1e2e3e"/>
20
+ <rect x="570" y="195" width="70" height="125" fill="#1a2a3a"/>
21
+ <path d="M0,320 Q200,300 400,315 Q600,330 800,310 L800,340 Q600,355 400,340 Q200,325 0,345Z" fill="#0d2535" opacity=".5"/>
22
+ </svg>
@@ -774,6 +774,21 @@ hr.ui-divider {
774
774
  }
775
775
  .ui-hero--left .ui-hero-actions { justify-content: flex-start; }
776
776
 
777
+ /* Split (text + image) */
778
+ .ui-hero--split { text-align: left; }
779
+ .ui-hero--split .ui-hero-inner {
780
+ max-width: 1200px;
781
+ margin: 0 auto;
782
+ display: grid;
783
+ grid-template-columns: 1fr 1fr;
784
+ gap: 3rem;
785
+ align-items: center;
786
+ }
787
+ .ui-hero--split .ui-hero-actions { justify-content: flex-start; }
788
+ .ui-hero--split .ui-hero-media { display: flex; align-items: center; justify-content: center; }
789
+ .ui-hero--split .ui-hero-media img { width: 100%; height: auto; border-radius: var(--ui-radius, 8px); display: block; }
790
+ .ui-hero--media-left .ui-hero-media { order: -1; }
791
+
777
792
  /* ─── Testimonial ────────────────────────────────────────────────────────── */
778
793
  .ui-testimonial {
779
794
  background: var(--ui-surface, var(--surface, #111116));
@@ -1313,6 +1328,8 @@ hr.ui-divider {
1313
1328
  .ui-hero { padding: 3rem 1.25rem; }
1314
1329
  .ui-hero-title { font-size: 2rem; }
1315
1330
  .ui-hero-subtitle { font-size: 1rem; }
1331
+ .ui-hero--split .ui-hero-inner { grid-template-columns: 1fr; }
1332
+ .ui-hero--media-left .ui-hero-media { order: 0; }
1316
1333
 
1317
1334
  .ui-nav-links { display: none; }
1318
1335
  .ui-nav-burger { display: flex; }
@@ -2529,7 +2546,7 @@ fieldset[disabled] .ui-rating-stars .ui-rating-star:hover ~ .ui-rating-star { co
2529
2546
  .ui-image-img--cover {
2530
2547
  display: block;
2531
2548
  width: 100%;
2532
- height: auto;
2549
+ height: 100%;
2533
2550
  object-fit: cover;
2534
2551
  }
2535
2552
  .ui-image-img {
package/docs/server.js CHANGED
@@ -191,5 +191,8 @@ createServer(
191
191
  port: process.env.PORT ? Number(process.env.PORT) : 4000,
192
192
  staticDir: new URL('./public', import.meta.url).pathname,
193
193
  defaultCache: true,
194
+ csp: {
195
+ 'img-src': ['https://picsum.photos', 'https://images.unsplash.com'],
196
+ },
194
197
  }
195
198
  )
@@ -13,12 +13,30 @@ export default {
13
13
  styles: ['/pulse-ui.css', '/docs.css'],
14
14
  },
15
15
  state: {},
16
- view: () => renderComponentPage({
16
+ view: () => {
17
+ 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">
18
+ <rect width="600" height="400" fill="#111116" rx="8"/>
19
+ <rect x="24" y="24" width="552" height="32" rx="6" fill="#1a1a22"/>
20
+ <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"/>
21
+ <rect x="24" y="72" width="552" height="200" rx="6" fill="#1a1a22"/>
22
+ <rect x="40" y="92" width="200" height="12" rx="3" fill="#2a2a35"/>
23
+ <rect x="40" y="116" width="300" height="8" rx="3" fill="#222228"/>
24
+ <rect x="40" y="132" width="260" height="8" rx="3" fill="#222228"/>
25
+ <rect x="40" y="148" width="280" height="8" rx="3" fill="#222228"/>
26
+ <rect x="40" y="176" width="120" height="32" rx="6" fill="#9b8dff" opacity=".9"/>
27
+ <rect x="24" y="288" width="264" height="88" rx="6" fill="#1a1a22"/>
28
+ <rect x="312" y="288" width="264" height="88" rx="6" fill="#1a1a22"/>
29
+ <rect x="40" y="304" width="100" height="8" rx="3" fill="#2a2a35"/>
30
+ <rect x="40" y="320" width="140" height="6" rx="3" fill="#222228"/>
31
+ <rect x="328" y="304" width="100" height="8" rx="3" fill="#2a2a35"/>
32
+ <rect x="328" y="320" width="120" height="6" rx="3" fill="#222228"/>
33
+ </svg>`
34
+ return renderComponentPage({
17
35
  currentHref: '/components/hero',
18
36
  prev,
19
37
  next,
20
38
  name: 'hero',
21
- description: 'Full-width hero section. The <code>actions</code> slot accepts any combination of <code>button()</code> and <code>appBadge()</code> calls. Set <code>align: \'left\'</code> for a split-layout hero. Use <code>size: \'sm\'</code> for inner-page headers that don\'t need the full vertical padding.',
39
+ description: 'Full-width hero section. Pass <code>image</code> HTML to activate the split layout text on one side, image on the other. Use <code>imageAlign</code> to swap sides. Set <code>align: \'left\'</code> for left-aligned text without an image. Use <code>size: \'sm\'</code> for inner-page headers.',
22
40
  content: `
23
41
  ${demo(
24
42
  hero({
@@ -36,6 +54,41 @@ export default {
36
54
  })`
37
55
  )}
38
56
 
57
+ ${demo(
58
+ hero({
59
+ eyebrow: 'Split layout',
60
+ title: 'Text left, image right',
61
+ subtitle: 'Pass an image slot to activate the split layout. The text sits on the left, image on the right.',
62
+ actions: appBadge({ store: 'apple', href: '#' }) + ' ' + appBadge({ store: 'google', href: '#' }),
63
+ image: placeholderImg,
64
+ }),
65
+ `hero({
66
+ eyebrow: 'Split layout',
67
+ title: 'Text left, image right',
68
+ subtitle: 'Pass an image slot to activate the split layout.',
69
+ actions: appBadge({ store: 'apple', href: appStoreUrl }) +
70
+ appBadge({ store: 'google', href: playStoreUrl }),
71
+ image: \`<img src="\${screenshotUrl}" alt="Product screenshot">\`,
72
+ })`
73
+ )}
74
+
75
+ ${demo(
76
+ hero({
77
+ eyebrow: 'Split layout',
78
+ title: 'Image left, text right',
79
+ subtitle: 'Set imageAlign: \'left\' to put the image on the left and text on the right.',
80
+ image: placeholderImg,
81
+ imageAlign: 'left',
82
+ }),
83
+ `hero({
84
+ eyebrow: 'Split layout',
85
+ title: 'Image left, text right',
86
+ subtitle: 'Set imageAlign to swap sides.',
87
+ image: \`<img src="\${screenshotUrl}" alt="Product screenshot">\`,
88
+ imageAlign: 'left',
89
+ })`
90
+ )}
91
+
39
92
  ${demo(
40
93
  hero({
41
94
  title: 'Blog',
@@ -52,14 +105,17 @@ export default {
52
105
  ${table(
53
106
  ['Prop', 'Type', 'Default', 'Description'],
54
107
  [
55
- ['<code>eyebrow</code>', 'string', '—', 'Small label above the title'],
56
- ['<code>title</code>', 'string', '—', ''],
57
- ['<code>subtitle</code>', 'string', '—', ''],
58
- ['<code>actions</code>', 'string (HTML)', '—', 'Raw HTML slot'],
59
- ['<code>align</code>', 'string', "'center'", "'center' or 'left'"],
60
- ['<code>size</code>', 'string', "'md'", "'md' (5rem padding) or 'sm' (2.5rem top, no bottom) — use sm for inner-page headers"],
108
+ ['<code>eyebrow</code>', 'string', '—', 'Small label above the title'],
109
+ ['<code>title</code>', 'string', '—', ''],
110
+ ['<code>subtitle</code>', 'string', '—', ''],
111
+ ['<code>actions</code>', 'string (HTML)', '—', 'Raw HTML slot — buttons, badges, etc.'],
112
+ ['<code>image</code>', 'string (HTML)', '', 'Raw HTML slot — activates split layout when set'],
113
+ ['<code>imageAlign</code>', 'string', "'right'", "'right' (text left) or 'left' (text right) — only applies when image is set"],
114
+ ['<code>align</code>', 'string', "'center'", "'center' or 'left' — text alignment when no image"],
115
+ ['<code>size</code>', 'string', "'md'", "'md' (5rem padding) or 'sm' (2.5rem top, no bottom) — use sm for inner-page headers"],
61
116
  ]
62
117
  )}
63
118
  `,
64
- }),
119
+ })
120
+ },
65
121
  }
@@ -24,14 +24,14 @@ 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: 'https://picsum.photos/seed/pulse1/800/450', alt: 'Mountain landscape at dusk', ratio: '16/9' }),
27
+ uiImage({ src: '/img/placeholder-landscape.svg', alt: 'Mountain landscape at dusk', ratio: '16/9' }),
28
28
  `uiImage({ src: '/img/photo.jpg', alt: 'Mountain landscape at dusk', 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: 'https://picsum.photos/seed/pulse2/400/400', alt: 'Profile photo', ratio: '1/1', rounded: true })}</div>`,
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
35
  `uiImage({ src: '/img/avatar.jpg', alt: 'Profile photo', ratio: '1/1', rounded: true })`,
36
36
  { col: true }
37
37
  )}
@@ -39,7 +39,7 @@ export default {
39
39
  <h2 class="doc-h2" id="caption">With caption</h2>
40
40
  ${demo(
41
41
  uiImage({
42
- src: 'https://picsum.photos/seed/pulse3/800/600',
42
+ src: '/img/placeholder-tall.svg',
43
43
  alt: 'Aerial view of a coastal town',
44
44
  ratio: '4/3',
45
45
  caption: 'Aerial view of Porto, Portugal. Photo by João Silva.',
@@ -69,7 +69,7 @@ export default {
69
69
  <div class="hero-badge">v${version} — EARLY ACCESS</div>
70
70
  <p class="hero-kicker">A Node.js framework for building server-rendered web apps</p>
71
71
  <h1 class="hero-title">Describe the outcome. Pulse guarantees it.</h1>
72
- <p class="hero-subtitle">One spec object per page — server data, state, mutations, and view in plain JS. Streaming SSR, security headers, and production caching are enforced by the framework, not left to configuration. Designed for AI agents.</p>
72
+ <p class="hero-subtitle">One spec object per page — server data, state, mutations, and view in plain JS. Streaming SSR, security headers, and production caching are enforced by the framework, not left to configuration.<br><strong>Designed for AI agents.</strong></p>
73
73
  <div class="hero-ctas">
74
74
  <a href="/getting-started" class="btn-primary">Get Started</a>
75
75
  <a href="/spec" class="btn-secondary">Read the Spec</a>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invisibleloop/pulse",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
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.2.2
1
+ 0.2.4
@@ -774,6 +774,21 @@ hr.ui-divider {
774
774
  }
775
775
  .ui-hero--left .ui-hero-actions { justify-content: flex-start; }
776
776
 
777
+ /* Split (text + image) */
778
+ .ui-hero--split { text-align: left; }
779
+ .ui-hero--split .ui-hero-inner {
780
+ max-width: 1200px;
781
+ margin: 0 auto;
782
+ display: grid;
783
+ grid-template-columns: 1fr 1fr;
784
+ gap: 3rem;
785
+ align-items: center;
786
+ }
787
+ .ui-hero--split .ui-hero-actions { justify-content: flex-start; }
788
+ .ui-hero--split .ui-hero-media { display: flex; align-items: center; justify-content: center; }
789
+ .ui-hero--split .ui-hero-media img { width: 100%; height: auto; border-radius: var(--ui-radius, 8px); display: block; }
790
+ .ui-hero--media-left .ui-hero-media { order: -1; }
791
+
777
792
  /* ─── Testimonial ────────────────────────────────────────────────────────── */
778
793
  .ui-testimonial {
779
794
  background: var(--ui-surface, var(--surface, #111116));
@@ -1313,6 +1328,8 @@ hr.ui-divider {
1313
1328
  .ui-hero { padding: 3rem 1.25rem; }
1314
1329
  .ui-hero-title { font-size: 2rem; }
1315
1330
  .ui-hero-subtitle { font-size: 1rem; }
1331
+ .ui-hero--split .ui-hero-inner { grid-template-columns: 1fr; }
1332
+ .ui-hero--media-left .ui-hero-media { order: 0; }
1316
1333
 
1317
1334
  .ui-nav-links { display: none; }
1318
1335
  .ui-nav-burger { display: flex; }
@@ -2529,7 +2546,7 @@ fieldset[disabled] .ui-rating-stars .ui-rating-star:hover ~ .ui-rating-star { co
2529
2546
  .ui-image-img--cover {
2530
2547
  display: block;
2531
2548
  width: 100%;
2532
- height: auto;
2549
+ height: 100%;
2533
2550
  object-fit: cover;
2534
2551
  }
2535
2552
  .ui-image-img {
package/scripts/build.js CHANGED
@@ -51,11 +51,48 @@ function stripServerOnlyKeys(source) {
51
51
  * from a JS source string. Uses a character-level scanner to correctly handle
52
52
  * nested structures, string literals, template literals, and function expressions.
53
53
  */
54
+ /**
55
+ * Returns true if `pos` in `source` falls inside a string or template literal.
56
+ * Used to avoid stripping keys that appear inside code-example strings in docs pages.
57
+ */
58
+ function isInsideString(source, pos) {
59
+ let i = 0
60
+ const stack = [] // stack of open delimiters: '"' | "'" | '`' | '{' ('{' = template expression)
61
+ while (i < pos) {
62
+ const c = source[i]
63
+ const top = stack[stack.length - 1]
64
+ if (top === '"' || top === "'") {
65
+ if (c === '\\') { i += 2; continue }
66
+ if (c === top) { stack.pop() }
67
+ i++; continue
68
+ }
69
+ if (top === '`') {
70
+ if (c === '\\') { i += 2; continue }
71
+ if (c === '`') { stack.pop(); i++; continue }
72
+ if (c === '$' && source[i + 1] === '{') { stack.push('{'); i += 2; continue }
73
+ i++; continue
74
+ }
75
+ if (top === '{') {
76
+ // inside a template expression — track nested braces
77
+ if (c === '{') { stack.push('{'); i++; continue }
78
+ if (c === '}') { stack.pop(); i++; continue }
79
+ if (c === '"' || c === "'" || c === '`') { stack.push(c); i++; continue }
80
+ i++; continue
81
+ }
82
+ // top-level
83
+ if (c === '"' || c === "'" || c === '`') { stack.push(c) }
84
+ i++
85
+ }
86
+ return stack.length > 0
87
+ }
88
+
54
89
  function removeObjectKey(source, key) {
55
90
  const keyRe = new RegExp(`^([ \\t]*)(${key})([ \\t]*:)`, 'gm')
56
91
  let match
57
92
 
58
93
  while ((match = keyRe.exec(source)) !== null) {
94
+ if (isInsideString(source, match.index)) { continue }
95
+
59
96
  const removeStart = match.index // start of indentation
60
97
  const afterColon = match.index + match[0].length // character after ':'
61
98
 
@@ -14,10 +14,40 @@ function stripServerOnlyKeys(source) {
14
14
  return source
15
15
  }
16
16
 
17
+ function isInsideString(source, pos) {
18
+ let i = 0
19
+ const stack = []
20
+ while (i < pos) {
21
+ const c = source[i]
22
+ const top = stack[stack.length - 1]
23
+ if (top === '"' || top === "'") {
24
+ if (c === '\\') { i += 2; continue }
25
+ if (c === top) { stack.pop() }
26
+ i++; continue
27
+ }
28
+ if (top === '`') {
29
+ if (c === '\\') { i += 2; continue }
30
+ if (c === '`') { stack.pop(); i++; continue }
31
+ if (c === '$' && source[i + 1] === '{') { stack.push('{'); i += 2; continue }
32
+ i++; continue
33
+ }
34
+ if (top === '{') {
35
+ if (c === '{') { stack.push('{'); i++; continue }
36
+ if (c === '}') { stack.pop(); i++; continue }
37
+ if (c === '"' || c === "'" || c === '`') { stack.push(c); i++; continue }
38
+ i++; continue
39
+ }
40
+ if (c === '"' || c === "'" || c === '`') { stack.push(c) }
41
+ i++
42
+ }
43
+ return stack.length > 0
44
+ }
45
+
17
46
  function removeObjectKey(source, key) {
18
47
  const keyRe = new RegExp(`^([ \\t]*)(${key})([ \\t]*:)`, 'gm')
19
48
  let match
20
49
  while ((match = keyRe.exec(source)) !== null) {
50
+ if (isInsideString(source, match.index)) { continue }
21
51
  const removeStart = match.index
22
52
  const afterColon = match.index + match[0].length
23
53
  let pos = afterColon
@@ -181,6 +211,16 @@ test('server with deeply nested template literal in view is not affected',
181
211
  'export default {\n view: (s, srv) => `<ul>${srv.items.map(i => `<li>${i}</li>`).join(\'\')}</ul>`\n}'
182
212
  )
183
213
 
214
+ test('render: inside a template literal string (docs code example) is not stripped',
215
+ 'export default {\n route: \'/auth\',\n view: () => `${highlight(`export default {\n render: (ctx) => ctx.user\n}`)}`\n}',
216
+ 'export default {\n route: \'/auth\',\n view: () => `${highlight(`export default {\n render: (ctx) => ctx.user\n}`)}`\n}'
217
+ )
218
+
219
+ test('server: inside a double-quoted string is not stripped',
220
+ 'export default {\n route: \'/docs\',\n view: () => "<pre>server: { data: async () => {} }</pre>"\n}',
221
+ 'export default {\n route: \'/docs\',\n view: () => "<pre>server: { data: async () => {} }</pre>"\n}'
222
+ )
223
+
184
224
  // ---------------------------------------------------------------------------
185
225
 
186
226
  console.log(`\n${pass + fail} tests: ${pass} passed, ${fail} failed\n`)
@@ -22,10 +22,16 @@ export function initNavigation(root, mountFn) {
22
22
  // Track the current mount instance so we can destroy it (and its store
23
23
  // subscription) before mounting the next page.
24
24
  let currentMount = null
25
+ let currentNavController = null
25
26
 
26
27
  async function navigate(path, push) {
28
+ // Cancel any in-flight navigation before starting a new one
29
+ currentNavController?.abort()
30
+ const controller = new AbortController()
31
+ currentNavController = controller
32
+
27
33
  try {
28
- const res = await fetch(path, { headers: { 'X-Pulse-Navigate': 'true' } })
34
+ const res = await fetch(path, { headers: { 'X-Pulse-Navigate': 'true' }, signal: controller.signal })
29
35
 
30
36
  if (!res.ok) { location.href = path; return }
31
37
 
@@ -34,6 +40,10 @@ export function initNavigation(root, mountFn) {
34
40
  if (ct.includes('application/x-ndjson')) {
35
41
  // Streaming nav response — apply chunks progressively as they arrive
36
42
  const reader = res.body.getReader()
43
+ // Explicitly cancel the reader when this navigation is superseded — aborting
44
+ // the fetch signal does not reliably cancel an in-progress body stream reader
45
+ // in all browsers, so we do it explicitly.
46
+ controller.signal.addEventListener('abort', () => reader.cancel(), { once: true })
37
47
  const decoder = new TextDecoder()
38
48
  let buf = ''
39
49
  let hydratePath = null
@@ -87,6 +97,9 @@ export function initNavigation(root, mountFn) {
87
97
  }
88
98
  if (buf) await processLine(buf)
89
99
 
100
+ // Bail out if a newer navigation superseded us while we were streaming
101
+ if (controller.signal.aborted) return
102
+
90
103
  runScripts(root)
91
104
  document.dispatchEvent(new CustomEvent('pulse:navigate'))
92
105
 
@@ -102,6 +115,8 @@ export function initNavigation(root, mountFn) {
102
115
  // Legacy JSON response (server running with stream: false)
103
116
  const { html, title, styles, scripts, hydrate, serverState, storeState } = await res.json()
104
117
 
118
+ if (controller.signal.aborted) return
119
+
105
120
  if (storeState && window.__updatePulseStore__) {
106
121
  window.__updatePulseStore__(storeState)
107
122
  }
@@ -126,7 +141,8 @@ export function initNavigation(root, mountFn) {
126
141
  scrollAndFocus(root)
127
142
  }
128
143
 
129
- } catch {
144
+ } catch (err) {
145
+ if (err?.name === 'AbortError') return
130
146
  location.href = path
131
147
  }
132
148
  }
@@ -601,8 +601,12 @@ export function createServer(specs, options = {}) {
601
601
 
602
602
  // Client-side navigation request — return JSON fragment (or NDJSON stream), not a full document
603
603
  if (req.headers['x-pulse-navigate'] === 'true') {
604
- if (stream) {
605
- await handleNavStreamResponse(spec, ctx, res)
604
+ // Only use NDJSON streaming when there are actual deferred segments to stream.
605
+ // For simple pages (no spec.stream.deferred), the JSON path is faster and does
606
+ // not hold the HTTP/1.1 connection open for the duration of the stream.
607
+ const hasDeferred = stream && spec.stream?.deferred?.length > 0
608
+ if (hasDeferred) {
609
+ await handleNavStreamResponse(spec, ctx, req, res)
606
610
  } else {
607
611
  await handleNavResponse(spec, ctx, res, dev)
608
612
  }
@@ -730,7 +734,7 @@ async function handleNavResponse(spec, ctx, res, dev = false) {
730
734
  * as their server data resolves. The browser applies chunks progressively,
731
735
  * showing shell content without waiting for slower deferred fetchers.
732
736
  */
733
- async function handleNavStreamResponse(spec, ctx, res) {
737
+ async function handleNavStreamResponse(spec, ctx, req, res) {
734
738
  const meta = resolveMeta(spec.meta, ctx)
735
739
 
736
740
  res.writeHead(200, {
@@ -743,6 +747,12 @@ async function handleNavStreamResponse(spec, ctx, res) {
743
747
  const navStream = renderToNavStream(spec, ctx, meta)
744
748
  const reader = navStream.getReader()
745
749
 
750
+ // If the client disconnects (e.g. user navigated away before the stream ended),
751
+ // cancel the reader immediately so the HTTP connection is freed rather than held
752
+ // open until the navStream finishes naturally.
753
+ const onClose = () => reader.cancel()
754
+ req.on('close', onClose)
755
+
746
756
  try {
747
757
  while (true) {
748
758
  const { done, value } = await reader.read()
@@ -752,6 +762,7 @@ async function handleNavStreamResponse(spec, ctx, res) {
752
762
  } catch {
753
763
  // Headers already sent — cannot change status, just end cleanly
754
764
  } finally {
765
+ req.off('close', onClose)
755
766
  res.end()
756
767
  }
757
768
  }
package/src/ui/hero.js CHANGED
@@ -2,36 +2,60 @@
2
2
  * Pulse UI — Hero
3
3
  *
4
4
  * Full-width hero section with eyebrow, headline, subheadline, and action slot.
5
+ * Pass `image` HTML to activate the split layout — text on one side, image on the other.
5
6
  *
6
7
  * @param {object} opts
7
- * @param {string} opts.eyebrow - Small label above the title (e.g. "Now available")
8
- * @param {string} opts.title - Main headline
9
- * @param {string} opts.subtitle - Supporting text beneath the headline
10
- * @param {string} opts.actions - Raw HTML slot — typically button() or appBadge() calls
11
- * @param {'center'|'left'} opts.align - Text alignment (default: 'center')
12
- * @param {'md'|'sm'} opts.size - Vertical padding size: 'md' (default, 5rem) or 'sm' (2.5rem)
8
+ * @param {string} opts.eyebrow - Small label above the title (e.g. "Now available")
9
+ * @param {string} opts.title - Main headline
10
+ * @param {string} opts.subtitle - Supporting text beneath the headline
11
+ * @param {string} opts.actions - Raw HTML slot — typically button() or appBadge() calls
12
+ * @param {string} opts.image - Raw HTML slot for the image (activates split layout)
13
+ * @param {'right'|'left'} opts.imageAlign - Which side the image sits on (default: 'right')
14
+ * @param {'center'|'left'} opts.align - Text alignment when no image (default: 'center')
15
+ * @param {'md'|'sm'} opts.size - Vertical padding: 'md' (default, 5rem) or 'sm' (2.5rem)
13
16
  * @param {string} opts.class
14
17
  */
15
18
 
16
19
  import { escHtml as e } from '../html.js'
17
20
 
18
21
  export function hero({
19
- eyebrow = '',
20
- title = '',
21
- subtitle = '',
22
- actions = '',
23
- align = 'center',
24
- size = 'md',
25
- class: cls = '',
22
+ eyebrow = '',
23
+ title = '',
24
+ subtitle = '',
25
+ actions = '',
26
+ image = '',
27
+ imageAlign = 'right',
28
+ align = 'center',
29
+ size = 'md',
30
+ class: cls = '',
26
31
  } = {}) {
27
- const classes = ['ui-hero', align === 'left' && 'ui-hero--left', size === 'sm' && 'ui-hero--sm', cls].filter(Boolean).join(' ')
32
+ const split = Boolean(image)
33
+ const classes = [
34
+ 'ui-hero',
35
+ split ? 'ui-hero--split' : align === 'left' && 'ui-hero--left',
36
+ split && imageAlign === 'left' && 'ui-hero--media-left',
37
+ size === 'sm' && 'ui-hero--sm',
38
+ cls,
39
+ ].filter(Boolean).join(' ')
28
40
 
29
- return `<section class="${e(classes)}">
30
- <div class="ui-hero-inner">
41
+ const content = `
31
42
  ${eyebrow ? `<p class="ui-hero-eyebrow">${e(eyebrow)}</p>` : ''}
32
43
  ${title ? `<h1 class="ui-hero-title">${e(title)}</h1>` : ''}
33
44
  ${subtitle ? `<p class="ui-hero-subtitle">${e(subtitle)}</p>` : ''}
34
- ${actions ? `<div class="ui-hero-actions">${actions}</div>` : ''}
45
+ ${actions ? `<div class="ui-hero-actions">${actions}</div>` : ''}`
46
+
47
+ if (split) {
48
+ return `<section class="${e(classes)}">
49
+ <div class="ui-hero-inner">
50
+ <div class="ui-hero-content">${content}
51
+ </div>
52
+ <div class="ui-hero-media">${image}</div>
53
+ </div>
54
+ </section>`
55
+ }
56
+
57
+ return `<section class="${e(classes)}">
58
+ <div class="ui-hero-inner">${content}
35
59
  </div>
36
60
  </section>`
37
61
  }
package/src/ui/uiimage.js CHANGED
@@ -49,8 +49,8 @@ export function uiImage({
49
49
 
50
50
  if (ratio) {
51
51
  return `<figure class="${e(figClasses)}"${maxWidthStyle}>
52
- <div class="ui-image-crop">
53
- <img src="${e(src)}" alt="${e(alt)}" class="ui-image-img--cover" style="aspect-ratio:${e(ratio)}"${widthAttr}${heightAttr} loading="lazy" decoding="async">
52
+ <div class="ui-image-crop" style="aspect-ratio:${e(ratio)}">
53
+ <img src="${e(src)}" alt="${e(alt)}" class="ui-image-img--cover"${widthAttr}${heightAttr} loading="lazy" decoding="async">
54
54
  </div>
55
55
  ${captionHtml}
56
56
  </figure>`