@invisibleloop/pulse 0.8.2 → 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/agent/checklist.md +11 -0
- package/src/mcp/server.js +11 -0
- 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.
|
|
1
|
+
0.9.1
|
package/src/agent/checklist.md
CHANGED
|
@@ -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.
|
package/src/mcp/server.js
CHANGED
|
@@ -644,6 +644,17 @@ Work through every item. Fix anything that fails.
|
|
|
644
644
|
- [ ] \`aria-live\` and \`aria-label\` are NOT on the same element
|
|
645
645
|
- [ ] Heading hierarchy is correct — no skipped levels, starts at h1
|
|
646
646
|
- [ ] Disabled state uses the \`disabled\` attribute, not just CSS or opacity
|
|
647
|
+
- [ ] **Keyboard focusability** — every element carrying \`data-event\`, \`data-store-event\`, \`data-dialog-open\`, or \`data-dialog-close\` is either a natively interactive element (\`button\`, \`a\`, \`input\`, \`select\`, \`textarea\`, \`summary\`) or has \`tabindex="0"\`. Scan the rendered HTML above — a \`<div>\`, \`<span>\`, \`<li>\`, or any other non-interactive tag with one of these attributes is a keyboard accessibility failure. Prefer \`<button>\` over a div + tabindex.
|
|
648
|
+
- [ ] **Purpose** — every interactive element has a clear accessible name. Buttons have visible text or \`aria-label\`. Links use descriptive text — flag generic labels ("click here", "here", "read more", "more"). Form inputs have an associated \`<label for="id">\` or \`aria-label\` — \`placeholder\` alone is not a label (it disappears on focus and is not read by all screen readers).
|
|
649
|
+
- [ ] **State** — interactive elements communicate their current state via ARIA:
|
|
650
|
+
- Toggle controls (open/close, show/hide, expand/collapse) have \`aria-expanded="true|false"\` or \`aria-pressed="true|false"\`
|
|
651
|
+
- While an action is running, the trigger button has \`aria-busy="true"\` or its visible label changes (e.g. "Saving…") — a spinner alone is not sufficient
|
|
652
|
+
- Active navigation items have \`aria-current="page"\`
|
|
653
|
+
- Selected items in a list, tab set, or option group have \`aria-selected="true"\`
|
|
654
|
+
- [ ] **Tab order** — scan the rendered HTML for these failures:
|
|
655
|
+
- No \`tabindex\` value greater than 0. \`tabindex="1"\` and above override the natural DOM order and almost always create a broken, unpredictable tab sequence. The only valid values are \`0\` (add to natural order) and \`-1\` (remove from order). If you find a positive tabindex, remove it and reorder the DOM instead.
|
|
656
|
+
- Off-screen or visually hidden interactive content is removed from the tab order. Elements that are hidden via CSS alone (e.g. \`opacity:0\`, \`visibility:hidden\` without \`display:none\`, off-canvas menus, collapsed panels) but remain in the DOM must have \`tabindex="-1"\` or \`inert\` so keyboard users cannot tab into invisible controls.
|
|
657
|
+
- DOM order matches the visual reading order. When CSS flexbox \`order\` or grid placement is used to visually reposition elements, the tab sequence follows the DOM — not the visual layout. Ensure the DOM is authored in the order a sighted user would read and interact with the page.
|
|
647
658
|
|
|
648
659
|
### Defensive coding
|
|
649
660
|
- [ ] Any \`fetch\` in actions or server fetchers checks \`res.ok\` before calling \`.json()\`
|
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
|
// ---------------------------------------------------------------------------
|