@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.
package/scripts/build.js CHANGED
@@ -617,9 +617,13 @@ async function purgeCssStep(pages, manifest, root, outDir) {
617
617
  // Scan pulse UI package source — class names generated by UI components
618
618
  // (breadcrumbs, avatar, container, etc.) only appear in the package source,
619
619
  // not in the project's own pages, so they'd otherwise be purged.
620
+ // Also scan the parent src/ui in case root is a subdirectory (e.g. docs/) that
621
+ // imports UI components via relative paths from the parent project.
620
622
  const scanUiDirs = [
621
- path.join(root, 'src', 'ui'), // pulse2 own source
622
- path.join(root, 'node_modules', '@invisibleloop', 'pulse', 'src', 'ui'), // installed package
623
+ path.join(root, 'src', 'ui'), // project's own source
624
+ path.join(root, '..', 'src', 'ui'), // parent project source (e.g. pulse2/src/ui when root=docs)
625
+ path.join(root, 'node_modules', '@invisibleloop', 'pulse', 'src', 'ui'), // installed package
626
+ path.join(root, '..', 'node_modules', '@invisibleloop', 'pulse', 'src', 'ui'), // parent node_modules
623
627
  ]
624
628
  for (const dir of scanUiDirs) {
625
629
  if (!fs.existsSync(dir)) continue
@@ -146,8 +146,9 @@ Use `prose()` for HTML from external sources (CMS, markdown). Use `heading()`/`l
146
146
 
147
147
  | Component | Key props |
148
148
  |-----------|-----------|
149
- | `nav` | `logo` (HTML slot), `logoHref`, `links` ([{label,href}]), `action` (HTML slot), `sticky` |
150
- | `hero` | `eyebrow`, `title`, `subtitle`, `actions` (HTML slot), `align` (center/left), `size` (md/sm) |
149
+ | `nav` | `logo` (HTML slot), `logoHref`, `links` ([{label,href}]), `action` (HTML slot), `sticky`, `background` (any CSS value — overrides default surface colour), `color` (foreground colour — set when custom background doesn't contrast with default muted text) |
150
+ | `hero` | `eyebrow`, `title`, `subtitle`, `actions` (HTML slot), `image` (HTML slot — activates split layout), `imageAlign` (right/left), `align` (center/left), `size` (md/sm), `gradient` (true/false or preset: 'purple'\|'blue'\|'green'\|'rose'\|'orange'), `background` (any CSS value — solid colour or custom gradient, overrides gradient preset), `eyebrowColor` (overrides eyebrow text colour — always set when using a custom background to ensure WCAG contrast) |
151
+ | `phoneFrame` | `content` (HTML slot — rendered inside the phone screen). Pure CSS phone mockup with dynamic island, side buttons, home indicator. Use as the `image` slot in `hero()` for app landing pages. |
151
152
  | `feature` | `icon` (HTML slot), `title`, `level` (1–6, default 3), `description`, `center` |
152
153
  | `testimonial` | `quote`, `name`, `role`, `src`, `rating` (1–5) |
153
154
  | `pricing` | `name`, `level` (1–6, default 3), `price`, `period`, `features` ([strings]), `action` (HTML slot), `highlighted` |
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { initClientStore, getStoreState, subscribe, updateStore, registerStoreMutations, dispatchStoreMutation } from './store.js'
11
- import { trustedHTML } from './tt.js'
11
+ import { trustedHTML, trustedScriptURL } from './tt.js'
12
12
 
13
13
  // Toast is lazy-loaded on first use — pages that never use _toast pay zero bytes
14
14
  const showToast = (opts) => import('./toast.js').then(m => m.showToast(opts))
@@ -456,7 +456,13 @@ function morphKeyed(cur, nxtEls) {
456
456
 
457
457
  function morphAttrs(cur, nxt) {
458
458
  for (const { name, value } of Array.from(nxt.attributes)) {
459
- if (cur.getAttribute(name) !== value) cur.setAttribute(name, value)
459
+ if (cur.getAttribute(name) !== value) {
460
+ // Trusted Types: script[src] is a TrustedScriptURL sink
461
+ const safeValue = (cur.tagName === 'SCRIPT' && name === 'src')
462
+ ? trustedScriptURL(value)
463
+ : value
464
+ cur.setAttribute(name, safeValue)
465
+ }
460
466
  }
461
467
  for (const { name } of Array.from(cur.attributes)) {
462
468
  if (!nxt.hasAttribute(name)) cur.removeAttribute(name)
package/src/runtime/tt.js CHANGED
@@ -8,6 +8,16 @@
8
8
 
9
9
  let _policy = null
10
10
 
11
+ function getPolicy() {
12
+ if (!_policy) {
13
+ _policy = window.trustedTypes.createPolicy('pulse', {
14
+ createHTML: s => s,
15
+ createScriptURL: s => s,
16
+ })
17
+ }
18
+ return _policy
19
+ }
20
+
11
21
  /**
12
22
  * Return a TrustedHTML object (Chromium) or the raw string (all other browsers).
13
23
  * @param {string} html
@@ -15,6 +25,16 @@ let _policy = null
15
25
  */
16
26
  export function trustedHTML(html) {
17
27
  if (typeof window === 'undefined' || !window.trustedTypes) return html
18
- if (!_policy) _policy = window.trustedTypes.createPolicy('pulse', { createHTML: s => s })
19
- return _policy.createHTML(html)
28
+ return getPolicy().createHTML(html)
29
+ }
30
+
31
+ /**
32
+ * Return a TrustedScriptURL object (Chromium) or the raw string (all other browsers).
33
+ * Used when setting the `src` attribute on <script> elements during DOM morphing.
34
+ * @param {string} url
35
+ * @returns {TrustedScriptURL|string}
36
+ */
37
+ export function trustedScriptURL(url) {
38
+ if (typeof window === 'undefined' || !window.trustedTypes) return url
39
+ return getPolicy().createScriptURL(url)
20
40
  }
@@ -317,14 +317,17 @@ function buildCacheKey(spec, ctx) {
317
317
  *
318
318
  * A page is NOT cacheable if it has any of:
319
319
  * - spec.server — embeds a nonce'd __PULSE_SERVER__ inline script
320
- * - spec.store — same
321
- * - spec.hydrate — embeds a nonce'd __PULSE_NONCE__ inline script (for toasts)
320
+ * - spec.store — same
321
+ * - spec.hydrate — embeds a nonce'd __PULSE_NONCE__ inline script (for toasts)
322
+ * - spec.stream.deferred — deferred segments inject content via inline <script nonce="...">
323
+ * tags; caching bakes in one nonce while subsequent CSP headers
324
+ * carry a different nonce, causing a violation
322
325
  *
323
- * In all three cases the cached HTML would contain a nonce that doesn't match
326
+ * In all these cases the cached HTML would contain a nonce that doesn't match
324
327
  * the nonce-free CSP used for cached responses, causing a CSP violation.
325
328
  */
326
329
  function isCacheablePage(spec) {
327
- return !spec.server && !spec.store?.length && !spec.hydrate
330
+ return !spec.server && !spec.store?.length && !spec.hydrate && !spec.stream?.deferred?.length
328
331
  }
329
332
 
330
333
  // ---------------------------------------------------------------------------
package/src/ui/hero.js CHANGED
@@ -13,11 +13,19 @@
13
13
  * @param {'right'|'left'} opts.imageAlign - Which side the image sits on (default: 'right')
14
14
  * @param {'center'|'left'} opts.align - Text alignment when no image (default: 'center')
15
15
  * @param {'md'|'sm'} opts.size - Vertical padding: 'md' (default, 5rem) or 'sm' (2.5rem)
16
+ * @param {boolean|'purple'|'blue'|'green'|'rose'|'orange'} opts.gradient
17
+ * Gradient background preset. true/'purple' = deep indigo, 'blue' = navy,
18
+ * 'green' = forest, 'rose' = crimson, 'orange' = amber. (default: false)
19
+ * @param {string} opts.background - Arbitrary CSS background value — overrides gradient preset.
20
+ * Accepts any valid CSS: solid colours ('#1a1a2e'), gradients ('linear-gradient(...)'), etc.
21
+ * @param {string} opts.eyebrowColor - Overrides the eyebrow text colour — use when the default accent colour lacks contrast against a custom background
16
22
  * @param {string} opts.class
17
23
  */
18
24
 
19
25
  import { escHtml as e } from '../html.js'
20
26
 
27
+ const GRADIENT_PRESETS = new Set(['purple', 'blue', 'green', 'rose', 'orange'])
28
+
21
29
  export function hero({
22
30
  eyebrow = '',
23
31
  title = '',
@@ -27,25 +35,46 @@ export function hero({
27
35
  imageAlign = 'right',
28
36
  align = 'center',
29
37
  size = 'md',
30
- class: cls = '',
38
+ gradient = false,
39
+ background = '',
40
+ eyebrowColor = '',
41
+ color = '',
42
+ class: cls = '',
31
43
  } = {}) {
32
44
  const split = Boolean(image)
45
+
46
+ // Resolve gradient class — true/'purple' share the base class; others get their own modifier
47
+ const gradientClass = gradient
48
+ ? (gradient === true || gradient === 'purple')
49
+ ? 'ui-hero--gradient'
50
+ : GRADIENT_PRESETS.has(gradient) ? `ui-hero--gradient--${gradient}` : ''
51
+ : ''
52
+
33
53
  const classes = [
34
54
  'ui-hero',
35
55
  split ? 'ui-hero--split' : align === 'left' && 'ui-hero--left',
36
56
  split && imageAlign === 'left' && 'ui-hero--media-left',
37
57
  size === 'sm' && 'ui-hero--sm',
58
+ gradientClass,
38
59
  cls,
39
60
  ].filter(Boolean).join(' ')
40
61
 
62
+ // Inline style — strip quotes to prevent attribute injection
63
+ const styles = [
64
+ background && `background:${background.replace(/"/g, "'")}`,
65
+ color && `color:${color.replace(/"/g, "'")};--ui-muted:${color.replace(/"/g, "'")}`,
66
+ ].filter(Boolean).join(';')
67
+ const bgStyle = styles ? ` style="${styles}"` : ''
68
+
69
+ const eyebrowStyle = eyebrowColor ? ` style="color:${eyebrowColor.replace(/"/g, "'")}"` : ''
41
70
  const content = `
42
- ${eyebrow ? `<p class="ui-hero-eyebrow">${e(eyebrow)}</p>` : ''}
71
+ ${eyebrow ? `<p class="ui-hero-eyebrow"${eyebrowStyle}>${e(eyebrow)}</p>` : ''}
43
72
  ${title ? `<h1 class="ui-hero-title">${e(title)}</h1>` : ''}
44
73
  ${subtitle ? `<p class="ui-hero-subtitle">${e(subtitle)}</p>` : ''}
45
74
  ${actions ? `<div class="ui-hero-actions">${actions}</div>` : ''}`
46
75
 
47
76
  if (split) {
48
- return `<section class="${e(classes)}">
77
+ return `<section class="${e(classes)}"${bgStyle}>
49
78
  <div class="ui-hero-inner">
50
79
  <div class="ui-hero-content">${content}
51
80
  </div>
@@ -54,7 +83,7 @@ export function hero({
54
83
  </section>`
55
84
  }
56
85
 
57
- return `<section class="${e(classes)}">
86
+ return `<section class="${e(classes)}"${bgStyle}>
58
87
  <div class="ui-hero-inner">${content}
59
88
  </div>
60
89
  </section>`
package/src/ui/icons.js CHANGED
@@ -173,6 +173,16 @@ export const iconGrid = (o) => s('<rect x="3" y="3" width="7" height="
173
173
  export const iconBug = (o) => s('<rect x="8" y="6" width="8" height="14" rx="4"/><path d="m19 7-3 2"/><path d="m5 7 3 2"/><path d="m19 19-3-2"/><path d="m5 19 3-2"/><path d="M20 13h-4"/><path d="M4 13h4"/><path d="m10 4 1 2"/><path d="m14 4-1 2"/>', opts(o))
174
174
  export const iconMapPin = (o) => s('<path d="M20 10c0 6-8 13-8 13s-8-7-8-13a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/>', opts(o))
175
175
 
176
+ // ─── AI & Data ───────────────────────────────────────────────────────────────
177
+
178
+ export const iconAi = (o) => s('<path d="M12 2a2 2 0 012 2v1a7 7 0 017 7 7 7 0 01-7 7v1a2 2 0 01-4 0v-1A7 7 0 012 12a7 7 0 017-7V4a2 2 0 012-2z"/><circle cx="12" cy="12" r="2"/>', opts(o))
179
+ export const iconSmile = (o) => s('<circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/>', opts(o))
180
+ export const iconBarChart = (o) => s('<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>', opts(o))
181
+
182
+ // ─── Writing ─────────────────────────────────────────────────────────────────
183
+
184
+ export const iconFeather = (o) => s('<path d="M20.24 12.24a6 6 0 0 0-8.49-8.49L5 10.5V19h8.5z"/><line x1="16" y1="8" x2="2" y2="22"/><line x1="17.5" y1="15" x2="9" y2="15"/>', opts(o))
185
+
176
186
  // ─── Ecommerce ───────────────────────────────────────────────────────────────
177
187
 
178
188
  export const iconShoppingCart = (o) => s('<circle cx="8" cy="21" r="1"/><circle cx="19" cy="21" r="1"/><path d="M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12"/>', opts(o))
package/src/ui/index.js CHANGED
@@ -32,6 +32,7 @@ export { avatar } from './avatar.js'
32
32
  export { empty } from './empty.js'
33
33
  export { table } from './table.js'
34
34
  export { hero } from './hero.js'
35
+ export { phoneFrame } from './phone-frame.js'
35
36
  export { testimonial } from './testimonial.js'
36
37
  export { feature } from './feature.js'
37
38
  export { pricing } from './pricing.js'
@@ -86,6 +87,8 @@ export {
86
87
  iconHandPointUp, iconHandPointDown, iconHandPointLeft, iconHandPointRight,
87
88
  iconGlobe, iconShield, iconZap, iconTrendingUp, iconTrendingDown, iconLoader, iconGrid, iconBug, iconMapPin,
88
89
  iconSun, iconMoon,
90
+ iconFeather,
91
+ iconAi, iconSmile, iconBarChart,
89
92
  iconShoppingCart, iconShoppingBag, iconCreditCard, iconPackage, iconGift,
90
93
  iconWallet, iconTruck, iconReceipt, iconStore, iconPercent, iconTicket, iconBanknote,
91
94
  iconUtensils, iconCoffee, iconPizza, iconApple, iconCarrot,
package/src/ui/nav.js CHANGED
@@ -24,6 +24,8 @@
24
24
  * @param {string} opts.action - Raw HTML slot — typically a button()
25
25
  * @param {boolean} opts.sticky - Position sticky with backdrop blur
26
26
  * @param {'right'|'left'} opts.burgerAlign - Mobile burger position (default: 'right')
27
+ * @param {string} opts.background - Any CSS background value — overrides the default surface colour
28
+ * @param {string} opts.color - Foreground colour for links and text — use when background has low contrast with defaults
27
29
  * @param {string} opts.class
28
30
  */
29
31
 
@@ -79,10 +81,17 @@ export function nav({
79
81
  action = '',
80
82
  sticky = false,
81
83
  burgerAlign = 'right',
84
+ background = '',
85
+ color = '',
82
86
  class: cls = '',
83
87
  } = {}) {
84
88
  const id = `ui-nav-${++_navId}`
85
89
  const classes = ['ui-nav', sticky && 'ui-nav--sticky', burgerAlign === 'left' && 'ui-nav--burger-left', cls].filter(Boolean).join(' ')
90
+ const styles = [
91
+ background && `background:${background.replace(/"/g, "'")}`,
92
+ color && `color:${color.replace(/"/g, "'")};--ui-muted:${color.replace(/"/g, "'")}`,
93
+ ].filter(Boolean).join(';')
94
+ const bgStyle = styles ? ` style="${styles}"` : ''
86
95
 
87
96
  const linksHtml = links.map((l, i) => renderLink(l, id, i)).join('')
88
97
  const mobileLinksHtml = links.map(renderMobileLink).join('')
@@ -98,7 +107,7 @@ export function nav({
98
107
  <nav>${mobileLinksHtml}</nav>
99
108
  </div>` : ''
100
109
 
101
- return `<header class="${e(classes)}">
110
+ return `<header class="${e(classes)}"${bgStyle}>
102
111
  <div class="ui-nav-inner">
103
112
  <a href="${e(logoHref)}" class="ui-nav-logo">${logo}</a>
104
113
  ${links.length ? `<nav class="ui-nav-links" aria-label="Site navigation">${linksHtml}</nav>` : ''}
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Pulse UI — Phone Frame
3
+ *
4
+ * A realistic CSS mobile phone frame. Pass screen content in the `content` slot.
5
+ * Renders the phone body, dynamic island, side buttons, and home indicator.
6
+ * No images — pure CSS/HTML.
7
+ *
8
+ * @param {object} opts
9
+ * @param {string} opts.content - HTML to render inside the screen area
10
+ * @param {string} opts.class
11
+ */
12
+
13
+ export function phoneFrame({ content = '', class: cls = '' } = {}) {
14
+ const classes = ['ui-phone', cls].filter(Boolean).join(' ')
15
+
16
+ return `<div class="${classes}" role="img" aria-label="Phone screen preview">
17
+ <!-- Left buttons: volume up, volume down, mute -->
18
+ <div class="ui-phone-btn ui-phone-btn--vol-up" aria-hidden="true"></div>
19
+ <div class="ui-phone-btn ui-phone-btn--vol-down" aria-hidden="true"></div>
20
+ <div class="ui-phone-btn ui-phone-btn--mute" aria-hidden="true"></div>
21
+ <!-- Right button: power -->
22
+ <div class="ui-phone-btn ui-phone-btn--power" aria-hidden="true"></div>
23
+
24
+ <div class="ui-phone-inner">
25
+ <!-- Dynamic island -->
26
+ <div class="ui-phone-island" aria-hidden="true">
27
+ <span class="ui-phone-camera"></span>
28
+ </div>
29
+
30
+ <!-- Screen -->
31
+ <div class="ui-phone-screen">
32
+ ${content}
33
+ </div>
34
+
35
+ <!-- Home indicator -->
36
+ <div class="ui-phone-home" aria-hidden="true"></div>
37
+ </div>
38
+ </div>`
39
+ }