@invisibleloop/pulse 0.9.0 → 0.10.0

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.
@@ -130,3 +130,14 @@ Before finishing any spec, verify every point below. Fix anything that fails.
130
130
  - Interactive elements without visible text have an `aria-label`.
131
131
  - Disabled state is reflected with the `disabled` attribute, not just CSS.
132
132
  - The page has a `<main id="main-content">` landmark.
133
+ - **Every element with `data-event`, `data-store-event`, `data-dialog-open`, or `data-dialog-close` is natively interactive (`button`, `a`, `input`, `select`, `textarea`, `summary`) or has `tabindex="0"`.** Non-interactive tags (`div`, `span`, `li`, `td`, etc.) with these attributes are not keyboard reachable. Prefer `<button>` — it is focusable, fires on Enter/Space, and carries the correct ARIA role for free. Only use `tabindex="0"` on a non-interactive element when a `<button>` genuinely cannot be used.
134
+ - **Every interactive element communicates its purpose and state:**
135
+ - Buttons have visible text or `aria-label`. Links use descriptive text — "click here", "here", "read more" are failures.
136
+ - Form inputs have an associated `<label for="id">` or `aria-label`. `placeholder` alone is not a label.
137
+ - Toggle controls (open/close, show/hide, expand/collapse) carry `aria-expanded="true|false"` or `aria-pressed="true|false"`.
138
+ - While an action is in flight, the trigger button has `aria-busy="true"` or its label changes (e.g. "Saving…"). A spinner alone is not enough.
139
+ - Active nav items have `aria-current="page"`. Selected items in a list, tab set, or option group have `aria-selected="true"`.
140
+ - **The page has a logical tab order:**
141
+ - No `tabindex` value greater than 0. Positive tabindex values override the natural DOM order and create a broken tab sequence. Remove them and reorder the DOM instead.
142
+ - Visually hidden interactive content (off-canvas menus, collapsed panels, `opacity:0` elements) is removed from the tab order with `tabindex="-1"` or the `inert` attribute — CSS alone does not remove an element from tab order.
143
+ - DOM order matches the visual reading order. When flexbox `order` or CSS grid placement visually reposition elements, the tab sequence still follows the DOM — author the DOM in the order a user would read and interact with the page.
@@ -1 +1 @@
1
- 0.8.1
1
+ 0.9.1
@@ -1160,6 +1160,18 @@ a:hover {
1160
1160
  width: 100%;
1161
1161
  }
1162
1162
 
1163
+ .demo-preview--bleed {
1164
+ padding-inline: 0;
1165
+ }
1166
+
1167
+ .demo-preview--bleed .demo-preview-inner {
1168
+ gap: 0;
1169
+ }
1170
+
1171
+ .demo-preview--bleed .demo-preview-inner > * {
1172
+ width: 100%;
1173
+ }
1174
+
1163
1175
  .demo-preview--col .demo-preview-inner {
1164
1176
  display: block;
1165
1177
  }
@@ -67,20 +67,30 @@
67
67
  input.style.setProperty('--slider-fill', pct)
68
68
  }
69
69
 
70
- document.querySelectorAll('.ui-slider').forEach(function (input) {
71
- input.addEventListener('input', function () { updateSliderFill(this) })
72
- })
73
-
74
- // ── Demo theme toggles ────────────────────────────────────────────────────
75
- document.querySelectorAll('.demo-theme-toggle').forEach(function (toggle) {
76
- var preview = toggle.closest('.demo-preview')
77
- if (!preview) return
78
- toggle.addEventListener('click', function () {
79
- var isLight = preview.classList.toggle('is-light')
80
- var inner = preview.querySelector('.demo-preview-inner')
81
- if (inner) {
82
- inner.classList.toggle('ui-theme-light', isLight)
83
- }
70
+ function bindDemoControls() {
71
+ document.querySelectorAll('.ui-slider').forEach(function (input) {
72
+ input.addEventListener('input', function () { updateSliderFill(this) })
84
73
  })
85
- })
74
+
75
+ // ── Demo theme toggles ──────────────────────────────────────────────────
76
+ document.querySelectorAll('.demo-theme-toggle').forEach(function (toggle) {
77
+ var preview = toggle.closest('.demo-preview')
78
+ if (!preview) return
79
+ toggle.addEventListener('click', function () {
80
+ var isLight = preview.classList.toggle('is-light')
81
+ var inner = preview.querySelector('.demo-preview-inner')
82
+ if (inner) {
83
+ inner.classList.toggle('ui-theme-light', isLight)
84
+ }
85
+ })
86
+ })
87
+ }
88
+
89
+ document.addEventListener('pulse:navigate', bindDemoControls)
90
+
91
+ if (document.readyState === 'complete') {
92
+ setTimeout(bindDemoControls, 50)
93
+ } else {
94
+ window.addEventListener('load', bindDemoControls)
95
+ }
86
96
  })()
@@ -24,7 +24,7 @@
24
24
  --ui-accent: var(--accent, #9b8dff);
25
25
  --ui-accent-hover: var(--accent-hover, #b5aaff);
26
26
  --ui-accent-dim: var(--accent-dim, rgba(155,141,255,.12));
27
- --ui-accent-text: var(--accent-text, #ffffff);
27
+ --ui-accent-text: var(--accent-text, #0d0a20);
28
28
  --ui-green: var(--green, #3dd68c);
29
29
  --ui-green-dim: rgba(61, 214, 140, .12);
30
30
  --ui-red: var(--red, #ff6b6b);
@@ -525,6 +525,7 @@ html, body {
525
525
  .ui-stat-change--neutral { color: var(--ui-muted); }
526
526
 
527
527
  .ui-stat--center { align-items: center; text-align: center; }
528
+ .ui-stat--center .ui-stat-change { margin-inline: auto; }
528
529
 
529
530
  /* ─── Avatar ─────────────────────────────────────────────────────────────── */
530
531
  .ui-avatar {
@@ -758,11 +759,46 @@ hr.ui-divider {
758
759
  }
759
760
 
760
761
  /* ─── Hero ───────────────────────────────────────────────────────────────── */
761
- .ui-hero { padding: 5rem 1.5rem; text-align: center; }
762
+ .ui-hero { padding: 5rem 0; text-align: center; }
762
763
  .ui-hero--left { text-align: left; }
763
764
  .ui-hero--sm { padding-top: 2.5rem; padding-bottom: 0; }
765
+ .ui-hero--gradient,
766
+ .ui-hero--gradient--purple {
767
+ background:
768
+ radial-gradient(ellipse 70% 80% at 75% 50%, #2d1b6e 0%, #1a0f45 45%, transparent 75%),
769
+ radial-gradient(ellipse 60% 60% at 20% 80%, #1c1060 0%, transparent 65%),
770
+ #0d0a1e;
771
+ }
772
+
773
+ .ui-hero--gradient--blue {
774
+ background:
775
+ radial-gradient(ellipse 70% 80% at 75% 50%, #0e2d6b 0%, #071840 45%, transparent 75%),
776
+ radial-gradient(ellipse 60% 60% at 20% 80%, #0a1f5c 0%, transparent 65%),
777
+ #060d1e;
778
+ }
779
+
780
+ .ui-hero--gradient--green {
781
+ background:
782
+ radial-gradient(ellipse 70% 80% at 75% 50%, #0d3d28 0%, #052218 45%, transparent 75%),
783
+ radial-gradient(ellipse 60% 60% at 20% 80%, #083020 0%, transparent 65%),
784
+ #020e09;
785
+ }
786
+
787
+ .ui-hero--gradient--rose {
788
+ background:
789
+ radial-gradient(ellipse 70% 80% at 75% 50%, #6b0e35 0%, #400720 45%, transparent 75%),
790
+ radial-gradient(ellipse 60% 60% at 20% 80%, #5c0a2a 0%, transparent 65%),
791
+ #1e0610;
792
+ }
793
+
794
+ .ui-hero--gradient--orange {
795
+ background:
796
+ radial-gradient(ellipse 70% 80% at 75% 50%, #6b2e0e 0%, #401807 45%, transparent 75%),
797
+ radial-gradient(ellipse 60% 60% at 20% 80%, #5c2208 0%, transparent 65%),
798
+ #1e0e04;
799
+ }
764
800
 
765
- .ui-hero-inner { max-width: 720px; margin: 0 auto; }
801
+ .ui-hero-inner { max-width: 720px; margin: 0 auto; padding: 0 1.5rem; }
766
802
  .ui-hero--left .ui-hero-inner { margin: 0; }
767
803
 
768
804
  .ui-hero-eyebrow {
@@ -801,8 +837,9 @@ hr.ui-divider {
801
837
  /* Split (text + image) */
802
838
  .ui-hero--split { text-align: left; }
803
839
  .ui-hero--split .ui-hero-inner {
804
- max-width: 1200px;
840
+ max-width: 1100px;
805
841
  margin: 0 auto;
842
+ padding: 0 1.5rem;
806
843
  display: grid;
807
844
  grid-template-columns: 1fr 1fr;
808
845
  gap: 3rem;
@@ -1425,7 +1462,8 @@ hr.ui-divider {
1425
1462
  .ui-media { grid-template-columns: 1fr; }
1426
1463
  .ui-media--reverse { direction: ltr; }
1427
1464
 
1428
- .ui-hero { padding: 3rem 1.25rem; }
1465
+ .ui-hero { padding: 3rem 0; }
1466
+ .ui-hero-inner { padding: 0 1.25rem; }
1429
1467
  .ui-hero-title { font-size: 2rem; }
1430
1468
  .ui-hero-subtitle { font-size: 1rem; }
1431
1469
  .ui-hero--split .ui-hero-inner { grid-template-columns: 1fr; }
@@ -1767,6 +1805,88 @@ hr.ui-divider {
1767
1805
 
1768
1806
  .ui-code-window-code { font-family: inherit; }
1769
1807
 
1808
+ /* ─── Phone Frame ────────────────────────────────────────────────────────────
1809
+ Realistic CSS mobile phone frame — body, dynamic island, side buttons,
1810
+ home indicator. Drop any HTML into the content slot. */
1811
+ .ui-phone {
1812
+ position: relative;
1813
+ display: inline-flex;
1814
+ width: 260px;
1815
+ background: linear-gradient(160deg, #1e1e28 0%, #141418 100%);
1816
+ border-radius: 3rem;
1817
+ border: 1.5px solid rgba(255, 255, 255, 0.1);
1818
+ box-shadow:
1819
+ 0 0 0 1px rgba(0, 0, 0, 0.8),
1820
+ 0 2px 0 1px rgba(255, 255, 255, 0.05) inset,
1821
+ 0 48px 96px rgba(0, 0, 0, 0.55),
1822
+ 0 12px 32px rgba(0, 0, 0, 0.4);
1823
+ padding: 12px;
1824
+ }
1825
+
1826
+ /* Side buttons */
1827
+ .ui-phone-btn {
1828
+ position: absolute;
1829
+ background: linear-gradient(90deg, #1a1a22 0%, #26262e 100%);
1830
+ border-radius: 2px;
1831
+ }
1832
+ .ui-phone-btn--mute { left: -3px; top: 88px; width: 3px; height: 28px; }
1833
+ .ui-phone-btn--vol-up { left: -3px; top: 128px; width: 3px; height: 48px; }
1834
+ .ui-phone-btn--vol-down { left: -3px; top: 186px; width: 3px; height: 48px; }
1835
+ .ui-phone-btn--power { right: -3px; top: 140px; width: 3px; height: 64px; }
1836
+
1837
+ /* Phone inner — holds screen + chrome */
1838
+ .ui-phone-inner {
1839
+ width: 100%;
1840
+ background: #000;
1841
+ border-radius: 2.5rem;
1842
+ overflow: hidden;
1843
+ display: flex;
1844
+ flex-direction: column;
1845
+ }
1846
+
1847
+ /* Dynamic island */
1848
+ .ui-phone-island {
1849
+ flex-shrink: 0;
1850
+ display: flex;
1851
+ align-items: center;
1852
+ justify-content: center;
1853
+ height: 32px;
1854
+ background: #000;
1855
+ }
1856
+ .ui-phone-camera {
1857
+ display: block;
1858
+ width: 10px;
1859
+ height: 10px;
1860
+ background: radial-gradient(circle at 35% 35%, #2a2a3a, #0a0a0a);
1861
+ border-radius: 50%;
1862
+ box-shadow: 0 0 0 1px rgba(255,255,255,0.06);
1863
+ }
1864
+
1865
+ /* Screen */
1866
+ .ui-phone-screen {
1867
+ flex: 1;
1868
+ min-height: 440px;
1869
+ overflow: hidden;
1870
+ }
1871
+
1872
+ /* Home indicator */
1873
+ .ui-phone-home {
1874
+ flex-shrink: 0;
1875
+ display: flex;
1876
+ align-items: center;
1877
+ justify-content: center;
1878
+ height: 24px;
1879
+ background: #000;
1880
+ }
1881
+ .ui-phone-home::after {
1882
+ content: '';
1883
+ display: block;
1884
+ width: 80px;
1885
+ height: 4px;
1886
+ background: rgba(255, 255, 255, 0.25);
1887
+ border-radius: 2px;
1888
+ }
1889
+
1770
1890
  /* ─── Footer ─────────────────────────────────────────────────────────────────
1771
1891
  Site footer — logo, nav links, legal text. Stacks on mobile. */
1772
1892
  .ui-footer {
@@ -1789,6 +1909,9 @@ hr.ui-divider {
1789
1909
  color: var(--ui-text);
1790
1910
  text-decoration: none;
1791
1911
  flex-shrink: 0;
1912
+ display: flex;
1913
+ align-items: center;
1914
+ gap: .5rem;
1792
1915
  transition: color .15s;
1793
1916
  }
1794
1917
 
@@ -0,0 +1,50 @@
1
+ /* ─── Lumio brand theme ───────────────────────────────────────────────────────
2
+ Palette tokens + light-theme overrides for the Lumio mobile-app template.
3
+ Load after pulse-ui.css so these override the framework defaults. */
4
+
5
+ /* Palette tokens — available in any rule */
6
+ :root {
7
+ --cotton-rose: #e5c1bd;
8
+ --bone: #d2d0ba;
9
+ --dry-sage: #b6be9c;
10
+ --muted-teal: #7b9e87;
11
+ --blue-slate: #5e747f;
12
+ }
13
+
14
+ /* Override light-theme semantic tokens */
15
+ [data-theme="light"],
16
+ .ui-theme-light {
17
+ --ui-bg: #f7f4ed;
18
+ --ui-surface: #edeae0;
19
+ --ui-surface-2: #e4e1d6;
20
+ --ui-border: #cbc8b8;
21
+ /* Deep teal — darker than --muted-teal so it passes WCAG AA on both bg surfaces */
22
+ --ui-accent: #3d6650;
23
+ --ui-accent-hover: #2e5040;
24
+ --ui-accent-dim: rgba(61, 102, 80, 0.14);
25
+ --ui-accent-text: #ffffff;
26
+ --ui-muted: #5a574e;
27
+ }
28
+
29
+ /* Nav: frosted glass effect */
30
+ [data-theme="light"] .ui-nav {
31
+ backdrop-filter: blur(14px);
32
+ -webkit-backdrop-filter: blur(14px);
33
+ }
34
+
35
+ /* Mobile menu: dark text on light dropdown background */
36
+ [data-theme="light"] .ui-nav-mobile .ui-nav-link,
37
+ [data-theme="light"] .ui-nav-mobile .ui-nav-mega-item-label,
38
+ [data-theme="light"] .ui-nav-mobile-section {
39
+ color: var(--ui-text);
40
+ }
41
+
42
+ /* Pricing: show the Pro (highlighted) card first on mobile */
43
+ @media (max-width: 639px) {
44
+ #pricing .ui-grid > div:nth-child(2) { order: -1; }
45
+ }
46
+
47
+ /* Primary buttons: white text on the deep teal accent background (6.5:1) */
48
+ [data-theme="light"] .ui-btn--primary {
49
+ color: #fff;
50
+ }
package/docs/server.js CHANGED
@@ -100,8 +100,12 @@ await createServer(
100
100
  new URL('./src/pages/components/feature.js', import.meta.url),
101
101
  new URL('./src/pages/components/hero.js', import.meta.url),
102
102
  new URL('./src/pages/components/nav.js', import.meta.url),
103
+ new URL('./src/pages/components/phone-frame.js', import.meta.url),
103
104
  new URL('./src/pages/components/pricing.js', import.meta.url),
104
105
  new URL('./src/pages/components/testimonial.js', import.meta.url),
106
+ // Templates
107
+ new URL('./src/pages/templates.js', import.meta.url),
108
+ new URL('./src/pages/templates/mobile-app.js', import.meta.url),
105
109
  // Layout Components
106
110
  new URL('./src/pages/components/banner.js', import.meta.url),
107
111
  new URL('./src/pages/components/cluster.js', import.meta.url),
@@ -8,11 +8,12 @@
8
8
  import { renderLayout, h1, lead, codeBlock } from './layout.js'
9
9
  import { highlight } from './highlight.js'
10
10
 
11
- export function demo(previewHtml, codeStr, { col = false, scroll = false } = {}) {
11
+ export function demo(previewHtml, codeStr, { col = false, scroll = false, bleed = false } = {}) {
12
12
  const previewClass = [
13
13
  'demo-preview',
14
14
  col ? 'demo-preview--col' : '',
15
15
  scroll ? 'demo-preview--scroll' : '',
16
+ bleed ? 'demo-preview--bleed' : '',
16
17
  ].filter(Boolean).join(' ')
17
18
  const toggle = `<button class="demo-theme-toggle" aria-label="Toggle light/dark theme" title="Toggle theme">
18
19
  <svg class="demo-theme-toggle__dark" width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M21 12.79A9 9 0 1111.21 3a7 7 0 109.79 9.79z"/></svg>
@@ -121,6 +121,7 @@ export const NAV = [
121
121
  { label: 'Feature', href: '/components/feature' },
122
122
  { label: 'Hero', href: '/components/hero' },
123
123
  { label: 'Nav', href: '/components/nav' },
124
+ { label: 'Phone Frame', href: '/components/phone-frame' },
124
125
  { label: 'Pricing', href: '/components/pricing' },
125
126
  { label: 'Testimonial', href: '/components/testimonial' },
126
127
  ],
@@ -148,6 +149,13 @@ export const NAV = [
148
149
  { label: 'Payments (Stripe)', href: '/stripe' },
149
150
  ],
150
151
  },
152
+ {
153
+ section: 'Templates',
154
+ items: [
155
+ { label: 'All Templates', href: '/templates' },
156
+ { label: 'Mobile App Landing', href: '/templates/mobile-app' },
157
+ ],
158
+ },
151
159
  ]
152
160
 
153
161
  export function prevNext(currentHref) {
@@ -51,7 +51,8 @@ export default {
51
51
  subtitle: 'Beautifully simple. Ridiculously fast.',
52
52
  actions: appBadge({ store: 'apple', href: appStoreUrl }) +
53
53
  appBadge({ store: 'google', href: playStoreUrl }),
54
- })`
54
+ })`,
55
+ { bleed: true }
55
56
  )}
56
57
 
57
58
  ${demo(
@@ -69,7 +70,8 @@ export default {
69
70
  actions: appBadge({ store: 'apple', href: appStoreUrl }) +
70
71
  appBadge({ store: 'google', href: playStoreUrl }),
71
72
  image: \`<img src="\${screenshotUrl}" alt="Product screenshot">\`,
72
- })`
73
+ })`,
74
+ { bleed: true }
73
75
  )}
74
76
 
75
77
  ${demo(
@@ -86,7 +88,31 @@ export default {
86
88
  subtitle: 'Set imageAlign to swap sides.',
87
89
  image: \`<img src="\${screenshotUrl}" alt="Product screenshot">\`,
88
90
  imageAlign: 'left',
89
- })`
91
+ })`,
92
+ { bleed: true }
93
+ )}
94
+
95
+ ${demo(
96
+ `<div style="display:flex;flex-direction:column;gap:0">` +
97
+ hero({ eyebrow: 'Purple (default)', title: 'gradient: true', gradient: 'purple', size: 'sm' }) +
98
+ hero({ eyebrow: 'Blue', title: "gradient: 'blue'", gradient: 'blue', size: 'sm' }) +
99
+ hero({ eyebrow: 'Green', title: "gradient: 'green'", gradient: 'green', size: 'sm' }) +
100
+ hero({ eyebrow: 'Rose', title: "gradient: 'rose'", gradient: 'rose', size: 'sm' }) +
101
+ hero({ eyebrow: 'Orange', title: "gradient: 'orange'", gradient: 'orange', size: 'sm' }) +
102
+ `</div>`,
103
+ `hero({ title: 'Headline', gradient: 'purple' }) // or 'blue' | 'green' | 'rose' | 'orange'`,
104
+ { bleed: true }
105
+ )}
106
+
107
+ ${demo(
108
+ `<div style="display:flex;flex-direction:column;gap:0">` +
109
+ hero({ eyebrow: 'Solid colour', title: 'background: color', background: '#0f172a', size: 'sm' }) +
110
+ hero({ eyebrow: 'Custom gradient', title: 'background: CSS gradient', background: 'linear-gradient(135deg, #1e1b4b 0%, #312e81 50%, #1e1b4b 100%)', size: 'sm' }) +
111
+ `</div>`,
112
+ `// Any valid CSS background value
113
+ hero({ title: 'Headline', background: '#0f172a' })
114
+ hero({ title: 'Headline', background: 'linear-gradient(135deg, #1e1b4b, #312e81)' })`,
115
+ { bleed: true }
90
116
  )}
91
117
 
92
118
  ${demo(
@@ -99,7 +125,8 @@ export default {
99
125
  title: 'Blog',
100
126
  subtitle: 'Thoughts on building for the web.',
101
127
  size: 'sm',
102
- })`
128
+ })`,
129
+ { bleed: true }
103
130
  )}
104
131
 
105
132
  ${table(
@@ -113,6 +140,8 @@ export default {
113
140
  ['<code>imageAlign</code>', 'string', "'right'", "'right' (text left) or 'left' (text right) — only applies when image is set"],
114
141
  ['<code>align</code>', 'string', "'center'", "'center' or 'left' — text alignment when no image"],
115
142
  ['<code>size</code>', 'string', "'md'", "'md' (5rem padding) or 'sm' (2.5rem top, no bottom) — use sm for inner-page headers"],
143
+ ['<code>gradient</code>', "boolean | 'purple' | 'blue' | 'green' | 'rose' | 'orange'", 'false', 'Named gradient preset. true and \'purple\' are identical.'],
144
+ ['<code>background</code>', 'string', '—', 'Any CSS background value — solid colour or custom gradient. Overrides gradient preset.'],
116
145
  ]
117
146
  )}
118
147
  `,
@@ -19,6 +19,7 @@ import {
19
19
  iconHandPointUp, iconHandPointDown, iconHandPointLeft, iconHandPointRight,
20
20
  iconGlobe, iconShield, iconZap, iconTrendingUp, iconTrendingDown, iconLoader, iconGrid, iconBug,
21
21
  iconMapPin, iconSun, iconMoon,
22
+ iconFeather, iconAi, iconSmile, iconBarChart,
22
23
  iconShoppingCart, iconShoppingBag, iconCreditCard, iconPackage, iconGift,
23
24
  iconWallet, iconTruck, iconReceipt, iconStore, iconPercent, iconTicket, iconBanknote,
24
25
  iconUtensils, iconCoffee, iconPizza, iconApple, iconCarrot,
@@ -52,6 +53,11 @@ const ICONS = [
52
53
  { name: 'iconAlertCircle', fn: iconAlertCircle },
53
54
  { name: 'iconAlertTriangle', fn: iconAlertTriangle },
54
55
  { name: 'iconInfo', fn: iconInfo },
56
+ { name: 'iconSmile', fn: iconSmile },
57
+ ]},
58
+ { group: 'AI & Data', icons: [
59
+ { name: 'iconAi', fn: iconAi },
60
+ { name: 'iconBarChart', fn: iconBarChart },
55
61
  ]},
56
62
  { group: 'Actions', icons: [
57
63
  { name: 'iconPlus', fn: iconPlus },
@@ -87,6 +93,7 @@ const ICONS = [
87
93
  { name: 'iconLogIn', fn: iconLogIn },
88
94
  ]},
89
95
  { group: 'Content & Files', icons: [
96
+ { name: 'iconFeather', fn: iconFeather },
90
97
  { name: 'iconFile', fn: iconFile },
91
98
  { name: 'iconImage', fn: iconImage },
92
99
  { name: 'iconLink', fn: iconLink },
@@ -238,21 +245,7 @@ function iconModals_() {
238
245
  return ICONS.flatMap(({ icons }) => icons).map(iconPreviewModal).join('')
239
246
  }
240
247
 
241
- export default {
242
- route: '/components/icons',
243
- meta: {
244
- title: 'Icons — Pulse Docs',
245
- description: 'Built-in icon set for Pulse UI.',
246
- styles: ['/pulse-ui.css', '/docs.css'],
247
- },
248
- view: () => renderComponentPage({
249
- currentHref: '/components/icons',
250
- prev,
251
- next,
252
- name: 'icons',
253
- description: '100 curated icons. All are pure functions returning an SVG string — no external library, no DOM dependency, tree-shakeable. Style: 24×24 viewBox, <code>stroke="currentColor"</code>, compatible with any colour token.',
254
- content: `
255
-
248
+ const pageContent = `
256
249
  <h2 class="doc-h2" id="usage">Usage</h2>
257
250
  <p>Import the icon functions you need alongside other components. Call each as a function — optionally pass <code>size</code> and <code>class</code>.</p>
258
251
  ${demo(
@@ -370,8 +363,6 @@ feature({ icon: iconCode({ size: 22, bg: 'square', bgColor: 'muted' }), titl
370
363
 
371
364
  ${iconGrid_()}
372
365
 
373
- ${iconModals_()}
374
-
375
366
  ${table(
376
367
  ['Prop', 'Type', 'Default', 'Description'],
377
368
  [
@@ -383,6 +374,33 @@ feature({ icon: iconCode({ size: 22, bg: 'square', bgColor: 'muted' }), titl
383
374
  )}
384
375
 
385
376
  <p style="margin-top:2rem;font-size:.8rem;color:var(--muted)">SVG paths derived from <a href="https://lucide.dev" target="_blank" rel="noopener noreferrer">Lucide</a> (ISC License, Copyright © 2022 Lucide Contributors) and <a href="https://feathericons.com" target="_blank" rel="noopener noreferrer">Feather Icons</a> (MIT License, Copyright © 2013–2017 Cole Bemis). Phosphor hand-pointer icons MIT License, Copyright © 2020 Phosphor Icons.</p>
386
- `,
387
- }),
377
+ `
378
+
379
+ export default {
380
+ route: '/components/icons',
381
+ meta: {
382
+ title: 'Icons — Pulse Docs',
383
+ description: 'Built-in icon set for Pulse UI.',
384
+ styles: ['/pulse-ui.css', '/docs.css'],
385
+ },
386
+
387
+ // Shell: all visible content (nav, examples, icon grid) — sends immediately.
388
+ // Modals: 100 hidden <dialog> elements — streamed after. Users never see them
389
+ // arrive since dialogs are display:none until opened.
390
+ stream: {
391
+ shell: ['page'],
392
+ deferred: ['modals'],
393
+ },
394
+
395
+ view: {
396
+ page: () => renderComponentPage({
397
+ currentHref: '/components/icons',
398
+ prev,
399
+ next,
400
+ name: 'icons',
401
+ description: '100 curated icons. All are pure functions returning an SVG string — no external library, no DOM dependency, tree-shakeable. Style: 24×24 viewBox, <code>stroke="currentColor"</code>, compatible with any colour token.',
402
+ content: pageContent,
403
+ }),
404
+ modals: () => iconModals_(),
405
+ },
388
406
  }
@@ -0,0 +1,54 @@
1
+ import { renderComponentPage, demo } from '../../lib/component-page.js'
2
+ import { prevNext } from '../../lib/nav.js'
3
+ import { table } from '../../lib/layout.js'
4
+ import { phoneFrame } from '../../../../src/ui/index.js'
5
+
6
+ const { prev, next } = prevNext('/components/phone-frame')
7
+
8
+ const sampleScreen = `
9
+ <div style="background:#1a2c24;height:100%;padding:0.75rem;display:flex;flex-direction:column;gap:0.625rem;font-family:system-ui,sans-serif;">
10
+ <div style="display:flex;justify-content:space-between;align-items:center;padding:0 0.25rem;font-size:0.6rem;color:rgba(255,255,255,0.5)">
11
+ <span>9:41</span>
12
+ <svg width="20" height="8" viewBox="0 0 20 8" fill="none" aria-hidden="true"><rect x="0.5" y="0.5" width="16" height="7" rx="2" stroke="rgba(255,255,255,0.35)" stroke-width="1"/><rect x="1.5" y="1.5" width="12" height="5" rx="1" fill="rgba(255,255,255,0.7)"/><path d="M18 3v2c.8-.3.8-1.7 0-2z" fill="rgba(255,255,255,0.6)"/></svg>
13
+ </div>
14
+ <div style="font-size:0.85rem;font-weight:700;color:#fff">Good morning, Sarah</div>
15
+ <div style="background:rgba(255,255,255,0.06);border-radius:0.625rem;padding:0.625rem;flex:1">
16
+ <div style="font-size:0.6rem;color:rgba(255,255,255,0.5);margin-bottom:0.3rem">TODAY'S ENTRY</div>
17
+ <div style="font-size:0.72rem;color:rgba(255,255,255,0.85);line-height:1.5">Feeling grateful today. The early morning light…</div>
18
+ </div>
19
+ </div>`
20
+
21
+ export default {
22
+ route: '/components/phone-frame',
23
+ meta: {
24
+ title: 'Phone Frame — Pulse Docs',
25
+ description: 'Phone Frame component for Pulse UI.',
26
+ styles: ['/pulse-ui.css', '/docs.css'],
27
+ },
28
+ state: {},
29
+ view: () => renderComponentPage({
30
+ currentHref: '/components/phone-frame',
31
+ prev,
32
+ next,
33
+ name: 'phoneFrame',
34
+ description: 'A realistic CSS mobile phone frame — device chrome, dynamic island, side buttons, and home indicator. Pass arbitrary HTML into the <code>content</code> slot to fill the screen area. No images, pure CSS/HTML.',
35
+ content: `
36
+ ${demo(
37
+ phoneFrame({ content: sampleScreen }),
38
+ `phoneFrame({
39
+ content: \`<div style="background:#1a2c24;height:100%;padding:0.75rem;">
40
+ <!-- your app screen UI -->
41
+ </div>\`,
42
+ })`,
43
+ )}
44
+
45
+ ${table(
46
+ ['Prop', 'Type', 'Default', 'Description'],
47
+ [
48
+ ['<code>content</code>', 'string (HTML)', "''", 'HTML rendered inside the screen area'],
49
+ ['<code>class</code>', 'string', "''", 'Extra CSS classes on the root element'],
50
+ ]
51
+ )}
52
+ `,
53
+ }),
54
+ }