@invisibleloop/pulse 0.8.1 → 0.9.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/docs/.claude/commands/verify.md +13 -5
- package/docs/.claude/pulse-checklist.md +5 -0
- package/docs/public/.pulse-ui-version +1 -1
- package/docs/public/pulse-ui.css +24 -0
- package/docs/src/pages/home.js +12 -0
- 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 +3 -2
- package/src/runtime/navigate.js +5 -3
- package/src/runtime/tt.js +20 -0
- package/src/server/index.js +8 -6
|
@@ -24,13 +24,13 @@ Use `mcp__chrome-devtools__navigate_page` to load the page route, then `mcp__chr
|
|
|
24
24
|
|
|
25
25
|
Run `mcp__chrome-devtools__lighthouse_audit` on the route with `{ "strategy": "desktop" }`.
|
|
26
26
|
|
|
27
|
-
**Pass bar:
|
|
27
|
+
**Pass bar: Accessibility, Best Practices, and SEO must all be 100.** Performance is measured and reported but is not a hard requirement (it varies with machine load). Report the actual scores. If Accessibility, Best Practices, or SEO is below 100, identify the failing audit(s), fix the issue, and restart from step 2.
|
|
28
28
|
|
|
29
29
|
### 6. Lighthouse — mobile
|
|
30
30
|
|
|
31
31
|
Run `mcp__chrome-devtools__lighthouse_audit` on the same route with `{ "strategy": "mobile" }`.
|
|
32
32
|
|
|
33
|
-
**Same pass bar:
|
|
33
|
+
**Same pass bar: Accessibility, Best Practices, and SEO must all be 100.** Fix any failures and restart from step 2.
|
|
34
34
|
|
|
35
35
|
### 7. Performance
|
|
36
36
|
|
|
@@ -42,7 +42,7 @@ Use `mcp__chrome-devtools__list_console_messages` — report any errors or unexp
|
|
|
42
42
|
|
|
43
43
|
### 9. Code review
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
Call `pulse_review` with the absolute path to the spec file. Read the source, rendered HTML, validator output, and checklist it returns. Fix every issue before proceeding. This is the structured review — do not skip it or replace it with a manual checklist read.
|
|
46
46
|
|
|
47
47
|
### 10. Close the browser
|
|
48
48
|
|
|
@@ -56,7 +56,7 @@ Run:
|
|
|
56
56
|
date +%s > .pulse-verified
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
-
This
|
|
59
|
+
**This must be the last write operation before stopping.** The stop hook compares each edited spec's modification time against this stamp — if any spec is newer than the stamp, the hook blocks. Do not edit any spec file after writing the stamp.
|
|
60
60
|
|
|
61
61
|
### 12. Report
|
|
62
62
|
|
|
@@ -69,4 +69,12 @@ Summarise:
|
|
|
69
69
|
- Console: any errors
|
|
70
70
|
- Review: pass or issues found and fixed
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
**Pass bar — all of these must be met before the page is confirmed good:**
|
|
73
|
+
- Validation: clean (no errors, no warnings)
|
|
74
|
+
- Lighthouse desktop: Accessibility, Best Practices, SEO all 100
|
|
75
|
+
- Lighthouse mobile: same
|
|
76
|
+
- CLS: 0.00 — any layout shift is a blocker
|
|
77
|
+
- LCP: report the value; flag and fix any insight that identifies a fixable cause
|
|
78
|
+
- Console: no errors
|
|
79
|
+
|
|
80
|
+
If any gate fails, fix the issue and run `/verify` again.
|
|
@@ -66,6 +66,11 @@ Before finishing any spec, verify every point below. Fix anything that fails.
|
|
|
66
66
|
- **Validate FormData fields before use.** `formData.get('email')` returns `null` if the field is missing. Check for null/empty before passing to an API or database.
|
|
67
67
|
- **Do not trust URL params.** `ctx.params.id` is a raw string from the URL. Validate it before use — check it exists, is the right type, and refers to a real resource. Return a 404 or redirect if it doesn't.
|
|
68
68
|
|
|
69
|
+
### CSS
|
|
70
|
+
|
|
71
|
+
- **`app.css` must contain no hex values or raw colour values.** A lint hook enforces this and will block the build. Hex values belong in `public/theme.css` as token definitions; `app.css` references them via `var()` only.
|
|
72
|
+
- **Load order in `meta.styles`:** `pulse-ui.css` → `theme.css` → `app.css`. Theme tokens must be defined before `app.css` references them.
|
|
73
|
+
|
|
69
74
|
### Security
|
|
70
75
|
|
|
71
76
|
- Any value from user input (URL params, form fields, external APIs) interpolated into view HTML must be escaped.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.8.1
|
package/docs/public/pulse-ui.css
CHANGED
|
@@ -40,6 +40,30 @@
|
|
|
40
40
|
|
|
41
41
|
/* Touch target minimum per WCAG 2.5.5 */
|
|
42
42
|
--ui-touch-min: 44px;
|
|
43
|
+
|
|
44
|
+
/* ── Spacing scale ── */
|
|
45
|
+
--ui-space-1: 0.25rem; /* 4px */
|
|
46
|
+
--ui-space-2: 0.5rem; /* 8px */
|
|
47
|
+
--ui-space-3: 0.75rem; /* 12px */
|
|
48
|
+
--ui-space-4: 1rem; /* 16px */
|
|
49
|
+
--ui-space-5: 1.25rem; /* 20px */
|
|
50
|
+
--ui-space-6: 1.5rem; /* 24px */
|
|
51
|
+
--ui-space-8: 2rem; /* 32px */
|
|
52
|
+
--ui-space-10: 2.5rem; /* 40px */
|
|
53
|
+
--ui-space-12: 3rem; /* 48px */
|
|
54
|
+
--ui-space-16: 4rem; /* 64px */
|
|
55
|
+
--ui-space-20: 5rem; /* 80px */
|
|
56
|
+
--ui-space-24: 6rem; /* 96px */
|
|
57
|
+
|
|
58
|
+
/* ── Type scale ── */
|
|
59
|
+
--ui-text-xs: 0.75rem; /* 12px */
|
|
60
|
+
--ui-text-sm: 0.875rem; /* 14px */
|
|
61
|
+
--ui-text-base: 1rem; /* 16px */
|
|
62
|
+
--ui-text-lg: 1.125rem; /* 18px */
|
|
63
|
+
--ui-text-xl: 1.25rem; /* 20px */
|
|
64
|
+
--ui-text-2xl: 1.5rem; /* 24px */
|
|
65
|
+
--ui-text-3xl: 1.875rem; /* 30px */
|
|
66
|
+
--ui-text-4xl: 2.25rem; /* 36px */
|
|
43
67
|
}
|
|
44
68
|
|
|
45
69
|
/* ─── Base reset ─────────────────────────────────────────────────────────────
|
package/docs/src/pages/home.js
CHANGED
|
@@ -39,6 +39,18 @@ export default {
|
|
|
39
39
|
title: 'Pulse — The spec-first web framework',
|
|
40
40
|
description: 'Pulse is a server-first Node.js framework with zero runtime dependencies. One spec object per page — server data, state, mutations, and view in plain JS. Streaming SSR, security headers, and production caching are enforced by the architecture.',
|
|
41
41
|
styles: ['/pulse-ui.css', '/docs.css'],
|
|
42
|
+
schema: {
|
|
43
|
+
'@context': 'https://schema.org',
|
|
44
|
+
'@type': 'SoftwareApplication',
|
|
45
|
+
name: 'Pulse',
|
|
46
|
+
applicationCategory: 'DeveloperApplication',
|
|
47
|
+
operatingSystem: 'Node.js ≥ 22',
|
|
48
|
+
offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' },
|
|
49
|
+
license: 'https://opensource.org/licenses/MIT',
|
|
50
|
+
description: 'Spec-first Node.js web framework. Zero runtime dependencies. Streaming SSR, built-in security headers, and production caching enforced by the architecture.',
|
|
51
|
+
url: 'https://pulse.invisibleloop.com',
|
|
52
|
+
codeRepository: 'https://github.com/invisibleloop/pulse-framework',
|
|
53
|
+
},
|
|
42
54
|
},
|
|
43
55
|
state: {},
|
|
44
56
|
server: {
|
package/package.json
CHANGED
package/public/.pulse-ui-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.9.0
|
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,6 +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
12
|
|
|
12
13
|
// Toast is lazy-loaded on first use — pages that never use _toast pay zero bytes
|
|
13
14
|
const showToast = (opts) => import('./toast.js').then(m => m.showToast(opts))
|
|
@@ -227,7 +228,7 @@ export function mount(spec, el, serverState = {}, options = {}) {
|
|
|
227
228
|
refresh: render,
|
|
228
229
|
|
|
229
230
|
/** Tear down — remove event listeners, unsubscribe from store, clear element */
|
|
230
|
-
destroy: () => { _unsubStore?.(); _eventAbort.abort(); el.innerHTML = '' }
|
|
231
|
+
destroy: () => { _unsubStore?.(); _eventAbort.abort(); el.innerHTML = trustedHTML('') }
|
|
231
232
|
}
|
|
232
233
|
}
|
|
233
234
|
|
|
@@ -363,7 +364,7 @@ function morph(el, newHtml) {
|
|
|
363
364
|
return
|
|
364
365
|
}
|
|
365
366
|
const temp = document.createElement('div')
|
|
366
|
-
temp.innerHTML = newHtml
|
|
367
|
+
temp.innerHTML = trustedHTML(newHtml)
|
|
367
368
|
morphNodes(el, temp)
|
|
368
369
|
}
|
|
369
370
|
|
package/src/runtime/navigate.js
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* Falls back to full page load on any error.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { trustedHTML } from './tt.js'
|
|
12
|
+
|
|
11
13
|
/**
|
|
12
14
|
* Initialise navigation for a Pulse app.
|
|
13
15
|
* Call once after the initial mount().
|
|
@@ -61,7 +63,7 @@ export function initNavigation(root, mountFn) {
|
|
|
61
63
|
await applyScripts(msg.scripts)
|
|
62
64
|
|
|
63
65
|
} else if (msg.type === 'html') {
|
|
64
|
-
root.innerHTML = msg.html
|
|
66
|
+
root.innerHTML = trustedHTML(msg.html)
|
|
65
67
|
// Index <pulse-deferred> placeholders so deferred chunks land in the right spot
|
|
66
68
|
for (const id of (msg.deferred || [])) {
|
|
67
69
|
const el = root.querySelector(`[id="pd-${id}"]`)
|
|
@@ -74,7 +76,7 @@ export function initNavigation(root, mountFn) {
|
|
|
74
76
|
const slot = deferredSlots.get(msg.id)
|
|
75
77
|
if (slot) {
|
|
76
78
|
const tmp = document.createElement('div')
|
|
77
|
-
tmp.innerHTML = msg.html
|
|
79
|
+
tmp.innerHTML = trustedHTML(msg.html)
|
|
78
80
|
slot.replaceWith(...tmp.childNodes)
|
|
79
81
|
deferredSlots.delete(msg.id)
|
|
80
82
|
}
|
|
@@ -124,7 +126,7 @@ export function initNavigation(root, mountFn) {
|
|
|
124
126
|
window.__updatePulseStore__(storeState)
|
|
125
127
|
}
|
|
126
128
|
|
|
127
|
-
root.innerHTML = html
|
|
129
|
+
root.innerHTML = trustedHTML(html)
|
|
128
130
|
document.title = title || document.title
|
|
129
131
|
applyStyles(styles)
|
|
130
132
|
await applyScripts(scripts)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trusted Types helper — routes innerHTML assignments through a declared policy
|
|
3
|
+
* in browsers that support the API (Chromium). Falls back to a plain string
|
|
4
|
+
* everywhere else — behaviour is identical, the protection is simply absent.
|
|
5
|
+
*
|
|
6
|
+
* Policy name 'pulse' must match the trusted-types CSP directive on the server.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
let _policy = null
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Return a TrustedHTML object (Chromium) or the raw string (all other browsers).
|
|
13
|
+
* @param {string} html
|
|
14
|
+
* @returns {TrustedHTML|string}
|
|
15
|
+
*/
|
|
16
|
+
export function trustedHTML(html) {
|
|
17
|
+
if (typeof window === 'undefined' || !window.trustedTypes) return html
|
|
18
|
+
if (!_policy) _policy = window.trustedTypes.createPolicy('pulse', { createHTML: s => s })
|
|
19
|
+
return _policy.createHTML(html)
|
|
20
|
+
}
|
package/src/server/index.js
CHANGED
|
@@ -63,10 +63,12 @@ const BASE_CSP = {
|
|
|
63
63
|
'img-src': ["'self'", 'data:'],
|
|
64
64
|
'font-src': ["'self'"],
|
|
65
65
|
'connect-src': ["'self'"],
|
|
66
|
-
'object-src':
|
|
67
|
-
'frame-ancestors':["'none'"],
|
|
68
|
-
'base-uri':
|
|
69
|
-
'form-action':
|
|
66
|
+
'object-src': ["'none'"],
|
|
67
|
+
'frame-ancestors': ["'none'"],
|
|
68
|
+
'base-uri': ["'self'"],
|
|
69
|
+
'form-action': ["'self'"],
|
|
70
|
+
'require-trusted-types-for': ["'script'"],
|
|
71
|
+
'trusted-types': ['pulse'],
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
function serializeCsp(directives) {
|
|
@@ -81,8 +83,8 @@ function serializeCsp(directives) {
|
|
|
81
83
|
function buildCsp(nonce, ext = {}) {
|
|
82
84
|
const d = {
|
|
83
85
|
...BASE_CSP,
|
|
84
|
-
'script-src': ["'self'", `'nonce-${nonce}'
|
|
85
|
-
'style-src': ["'self'", `'nonce-${nonce}'
|
|
86
|
+
'script-src': ["'self'", `'nonce-${nonce}'`, "'unsafe-inline'"], // 'unsafe-inline' ignored by nonce-aware browsers; fallback for CSP1-only browsers
|
|
87
|
+
'style-src': ["'self'", `'nonce-${nonce}'`, "'unsafe-inline'"], // same — nonce takes precedence; allows toast's runtime-injected <style> in older browsers
|
|
86
88
|
}
|
|
87
89
|
for (const [k, sources] of Object.entries(ext)) {
|
|
88
90
|
d[k] = [...(d[k] || []), ...sources]
|