@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.
- package/docs/.claude/pulse-checklist.md +11 -0
- package/docs/public/.pulse-ui-version +1 -1
- package/docs/public/menu.js +25 -15
- package/docs/src/pages/components/icons.js +30 -19
- package/package.json +1 -1
- package/public/.pulse-ui-version +1 -1
- package/src/runtime/index.js +8 -2
- package/src/runtime/tt.js +22 -2
- package/src/server/index.js +7 -4
|
@@ -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.
|
|
1
|
+
0.9.0
|
package/docs/public/menu.js
CHANGED
|
@@ -67,20 +67,30 @@
|
|
|
67
67
|
input.style.setProperty('--slider-fill', pct)
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
package/public/.pulse-ui-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.9.
|
|
1
|
+
0.9.1
|
package/src/runtime/index.js
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
19
|
-
|
|
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
|
}
|
package/src/server/index.js
CHANGED
|
@@ -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
|
|
321
|
-
* - spec.hydrate
|
|
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
|
|
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
|
// ---------------------------------------------------------------------------
|