@invisibleloop/pulse 0.9.0 → 0.9.1

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.0
@@ -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
  })()
@@ -238,21 +238,7 @@ function iconModals_() {
238
238
  return ICONS.flatMap(({ icons }) => icons).map(iconPreviewModal).join('')
239
239
  }
240
240
 
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
-
241
+ const pageContent = `
256
242
  <h2 class="doc-h2" id="usage">Usage</h2>
257
243
  <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
244
  ${demo(
@@ -370,8 +356,6 @@ feature({ icon: iconCode({ size: 22, bg: 'square', bgColor: 'muted' }), titl
370
356
 
371
357
  ${iconGrid_()}
372
358
 
373
- ${iconModals_()}
374
-
375
359
  ${table(
376
360
  ['Prop', 'Type', 'Default', 'Description'],
377
361
  [
@@ -383,6 +367,33 @@ feature({ icon: iconCode({ size: 22, bg: 'square', bgColor: 'muted' }), titl
383
367
  )}
384
368
 
385
369
  <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
- }),
370
+ `
371
+
372
+ export default {
373
+ route: '/components/icons',
374
+ meta: {
375
+ title: 'Icons — Pulse Docs',
376
+ description: 'Built-in icon set for Pulse UI.',
377
+ styles: ['/pulse-ui.css', '/docs.css'],
378
+ },
379
+
380
+ // Shell: all visible content (nav, examples, icon grid) — sends immediately.
381
+ // Modals: 100 hidden <dialog> elements — streamed after. Users never see them
382
+ // arrive since dialogs are display:none until opened.
383
+ stream: {
384
+ shell: ['page'],
385
+ deferred: ['modals'],
386
+ },
387
+
388
+ view: {
389
+ page: () => renderComponentPage({
390
+ currentHref: '/components/icons',
391
+ prev,
392
+ next,
393
+ name: 'icons',
394
+ 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.',
395
+ content: pageContent,
396
+ }),
397
+ modals: () => iconModals_(),
398
+ },
388
399
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invisibleloop/pulse",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
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.9.0
1
+ 0.9.1
@@ -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
  // ---------------------------------------------------------------------------