@invisibleloop/pulse 0.8.0 → 0.8.2

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/CLAUDE.md CHANGED
@@ -336,6 +336,39 @@ Always target `[data-theme="light"]` when overriding colours for the light theme
336
336
 
337
337
  If a colour must apply in both themes, set it in both `:root` and `[data-theme="light"]`.
338
338
 
339
+ ## CSS File Responsibilities
340
+
341
+ There are two CSS files and they have distinct roles — do not mix them:
342
+
343
+ | File | Purpose | Allowed content |
344
+ |---|---|---|
345
+ | `public/theme.css` | Token definitions — hex values, raw colours, font URLs | Hex values, `rgb()`, raw values, `@font-face`, `@import` |
346
+ | `app.css` (or per-page) | Layout and component overrides | `var()` tokens **only** — no hex, no raw colour values |
347
+
348
+ **`app.css` must never contain hex values or raw colour values.** A lint hook enforces this. If you need to define or override a colour token, put it in `public/theme.css` and reference it via `var()` in `app.css`.
349
+
350
+ ```css
351
+ /* public/theme.css — hex values live here */
352
+ [data-theme="light"] {
353
+ --color-accent: #e25;
354
+ --color-bg: #ffffff;
355
+ }
356
+
357
+ /* app.css — only var() references */
358
+ .hero {
359
+ background: var(--color-bg);
360
+ color: var(--color-accent);
361
+ }
362
+ ```
363
+
364
+ Load both files via `meta.styles`, theme first:
365
+
366
+ ```js
367
+ meta: {
368
+ styles: ['/pulse-ui.css', '/theme.css', '/app.css'],
369
+ }
370
+ ```
371
+
339
372
  ## HTTP Response Behaviour
340
373
 
341
374
  - **Full page request** — SSR HTML with hydration bootstrap
@@ -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: Performance, Accessibility, Best Practices, and SEO must all be 100.** Report the actual scores. If any score is below 100, identify the failing audit(s), fix the issue, and restart from step 2.
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: all four categories must be 100.** Report the actual scores. If any score is below 100, fix and restart from step 2.
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
- Check the spec against the checklist in `.claude/pulse-checklist.md`. Work through every item. Fix anything that fails before proceeding to the next step.
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 records that verify completed successfully. The stop hook checks this stamp — do not skip this step.
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
- Only confirm the page is good when validation passes, both Lighthouse runs are 100/100/100/100, CLS is 0.00, and there are no console errors. Otherwise, fix and run `/verify` again.
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.7.2
1
+ 0.8.1
@@ -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 ─────────────────────────────────────────────────────────────
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invisibleloop/pulse",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
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.8.0
1
+ 0.8.2
@@ -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 ─────────────────────────────────────────────────────────────
@@ -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.
@@ -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: Performance, Accessibility, Best Practices, and SEO must all be 100.** Report the actual scores. If any score is below 100, identify the failing audit(s), fix the issue, and restart from step 2.
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: all four categories must be 100.** Report the actual scores. If any score is below 100, fix and restart from step 2.
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
- Check the spec against the checklist in `.claude/pulse-checklist.md`. Work through every item. Fix anything that fails before proceeding to the next step.
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 records that verify completed successfully. The stop hook checks this stamp — do not skip this step.
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
- Only confirm the page is good when validation passes, both Lighthouse runs are 100/100/100/100, CLS is 0.00, and there are no console errors. Otherwise, fix and run `/verify` again.
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.
@@ -61,7 +61,11 @@ meta: {
61
61
  }
62
62
  ```
63
63
 
64
- Token list: --ui-bg, --ui-surface, --ui-surface-2, --ui-border, --ui-text, --ui-muted, --ui-accent, --ui-accent-hover, --ui-green, --ui-red, --ui-yellow, --ui-blue, --ui-radius, --ui-radius-sm, --ui-font, --ui-mono. Never hardcode hex values — override the tokens.
64
+ **Colour tokens:** `--ui-bg`, `--ui-surface`, `--ui-surface-2`, `--ui-border`, `--ui-text`, `--ui-muted`, `--ui-accent`, `--ui-accent-hover`, `--ui-accent-dim`, `--ui-accent-text`, `--ui-green`, `--ui-red`, `--ui-yellow`, `--ui-blue`, `--ui-radius`, `--ui-radius-sm`, `--ui-font`, `--ui-mono`. Never hardcode hex values — override the tokens.
65
+
66
+ **Spacing tokens** (`--ui-space-N`): `--ui-space-1` (4px), `--ui-space-2` (8px), `--ui-space-3` (12px), `--ui-space-4` (16px), `--ui-space-5` (20px), `--ui-space-6` (24px), `--ui-space-8` (32px), `--ui-space-10` (40px), `--ui-space-12` (48px), `--ui-space-16` (64px), `--ui-space-20` (80px), `--ui-space-24` (96px). Use these for `padding`, `margin`, and `gap`.
67
+
68
+ **Type-scale tokens** (`--ui-text-*`): `--ui-text-xs` (12px), `--ui-text-sm` (14px), `--ui-text-base` (16px), `--ui-text-lg` (18px), `--ui-text-xl` (20px), `--ui-text-2xl` (24px), `--ui-text-3xl` (30px), `--ui-text-4xl` (36px). Use these for `font-size`.
65
69
 
66
70
  ## Custom fonts
67
71
 
@@ -33,7 +33,39 @@ Wait for the user to confirm or adjust the plan before writing any code.
33
33
 
34
34
  ## Phase 3 — Build
35
35
 
36
- Write the spec and any related files using the **Write tool** — not `pulse_create_page`. This shows the user a readable diff of the file content. After writing, call `pulse_create_page(name)` to validate the file you just wrote. After each file is written, output a one-line status: `✓ Page written — validating...`
36
+ ### 3a — Announce before writing
37
+
38
+ Before writing any file, output a build brief so the user can see what is being constructed and catch misunderstandings before minutes of work are lost. Format:
39
+
40
+ ```
41
+ Building: <page name> (<route>)
42
+ State: <fields and types, or "none">
43
+ Mutations: <list, or "none">
44
+ Actions: <list with brief description, or "none">
45
+ Server: <fetcher names and what they fetch, or "none">
46
+ View: <key sections / landmarks>
47
+ Files: <list of files that will be written>
48
+ ```
49
+
50
+ Output this before making any tool calls. Do not skip it for "simple" pages — a one-liner is fine for simple pages, but always output something.
51
+
52
+ ### 3b — Write
53
+
54
+ Write each file using the **Write tool** — not `pulse_create_page`. This shows the user a readable diff. Before each file write, output a one-line status (present-progressive, no tick — the work hasn't happened yet):
55
+
56
+ ```
57
+ Writing src/pages/my-page.js...
58
+ ```
59
+
60
+ After the Write tool completes, call `pulse_create_page(name)` to register the page. Only after that call succeeds output:
61
+
62
+ ```
63
+ ✓ src/pages/my-page.js written and registered.
64
+ ```
65
+
66
+ **Rule: never output `✓` before the tool call that confirms the result. Use present-progressive (`Writing...`, `Running...`, `Checking...`) for pre-tool announcements.**
67
+
68
+ If multiple files are needed (spec + shared component, spec + test stub, etc.), announce and write them one at a time with a status line before each.
37
69
 
38
70
  ---
39
71
 
@@ -48,15 +80,21 @@ Run `pulse_validate` on the spec file.
48
80
 
49
81
  ## Phase 5 — Browser check (pass gate)
50
82
 
51
- 1. Navigate to the page route in the browser.
52
- 2. Take a screenshot. Check it visually — layout, content, spacing, no raw unstyled HTML.
53
- 3. Check the browser console for errors (JS errors, failed network requests).
54
- 4. Run Lighthouse with `strategy: 'desktop'`. All three scores must be 100: Accessibility, Best Practices, SEO. The tool does not return a Performance score — use `performance_start_trace` separately if performance profiling is needed. Tell the user before calling Lighthouse — it takes 30–60 s.
55
- 5. Run Lighthouse with `strategy: 'mobile'`. Same pass bar.
83
+ **Run `/verify` now — as soon as the first build is working.** Do not defer it to the end. Do not execute these steps manually.
84
+
85
+ `/verify` is the canonical implementation of phases 4–7: it validates, screenshots, runs Lighthouse (desktop + mobile), runs the performance trace (LCP + CLS), checks console errors, runs `pulse_review`, and — critically — **writes the `.pulse-verified` stamp** at the end. Running the steps manually without writing the stamp will cause the stop hook to block.
86
+
87
+ Pass gates (all must be met before continuing to Phase 6):
88
+ - Screenshot: no layout or rendering issues
89
+ - Lighthouse desktop: Accessibility, Best Practices, SEO all 100
90
+ - Lighthouse mobile: same pass bar
91
+ - **CLS: 0.00** — any layout shift is a blocker; fix it before proceeding
92
+ - LCP: measured and reported; flag any fixable insight (render-blocking resources, large image delay)
93
+ - No console errors
56
94
 
57
- **If any score is below 100:** fix the issue, reload, re-run Lighthouse. Repeat until both strategies pass 100/100/100. Do not continue until both pass.
95
+ **If any gate fails:** fix the issue and run `/verify` again. Repeat until all gates pass.
58
96
 
59
- Output after passing: `✓ Lighthouse 100/100/100 desktop + mobile — writing tests...`
97
+ Output after passing: `✓ Verified — writing tests...`
60
98
 
61
99
  ---
62
100
 
@@ -80,15 +118,13 @@ Output after passing: `✓ Tests passing — ready for review.`
80
118
 
81
119
  The Review Agent checks the code for correctness, security, accessibility, DRY violations, and adherence to the checklist. It returns a list of issues.
82
120
 
121
+ > Note: `/verify` includes the review step (`pulse_review`) — if you ran `/verify` and it passed cleanly, the Review Agent has already run. You do not need to invoke it again separately.
122
+
83
123
  ---
84
124
 
85
125
  ## Phase 8 — Fix review issues
86
126
 
87
- Fix every issue raised by the Review Agent. If the fixes touch the spec or view:
88
-
89
- - Re-run `pulse_validate`
90
- - Re-run Lighthouse if visual or structural changes were made
91
- - Re-run tests
127
+ Fix every issue raised by the Review Agent. If the fixes touch the spec or view, run `/verify` again — it re-runs validation, Lighthouse, the performance trace, console check, and review in one pass and writes a fresh stamp.
92
128
 
93
129
  Only declare the task done after phase 8 is complete and all gates still pass.
94
130
 
@@ -99,9 +135,9 @@ Only declare the task done after phase 8 is complete and all gates still pass.
99
135
  ```
100
136
  Phase 1 Understand (no gate — always run)
101
137
  Phase 2 Plan gate: user confirmation
102
- Phase 3 Build (no gate — write the code)
138
+ Phase 3 Build announce brief → write files (no gate, but always narrate)
103
139
  Phase 4 Validate gate: pulse_validate clean
104
- Phase 5 Browser gate: Lighthouse 100/100/100 (Accessibility/Best Practices/SEO) desktop + mobile
140
+ Phase 5 Browser gate: Lighthouse 100/100/100 (A/BP/SEO) + CLS 0.00, desktop + mobile
105
141
  Phase 6 Tests gate: all tests pass
106
142
  Phase 7 Review Agent (only reached after phases 4–6 all pass)
107
143
  Phase 8 Fix + re-verify gate: all gates still pass
@@ -57,8 +57,9 @@ ${port !== 3000 ? ` port: ${port},\n` : ''}}
57
57
  write(targetDir, 'src/pages/home.js', homePage(name))
58
58
  write(targetDir, 'src/pages/home.test.js', homePageTest(name))
59
59
 
60
- // Minimal stylesheet
61
- write(targetDir, 'public/app.css', baseCSS())
60
+ // Stylesheets — theme.css holds hex token definitions, app.css uses only var() references
61
+ write(targetDir, 'public/theme.css', themeCSS())
62
+ write(targetDir, 'public/app.css', baseCSS())
62
63
 
63
64
  // Copy pulse-ui assets from the package into public/ so they're served immediately
64
65
  const pkgPublic = new URL('../../public', import.meta.url).pathname
@@ -95,9 +96,6 @@ ${port !== 3000 ? ` port: ${port},\n` : ''}}
95
96
 
96
97
  // settings.json — hooks that enforce correct agent behaviour
97
98
  write(targetDir, '.claude/settings.json', JSON.stringify({
98
- enabledPlugins: {
99
- 'frontend-design@claude-plugins-official': true,
100
- },
101
99
  hooks: {
102
100
  SessionStart: [
103
101
  {
@@ -148,7 +146,7 @@ ${port !== 3000 ? ` port: ${port},\n` : ''}}
148
146
  },
149
147
  {
150
148
  type: 'command',
151
- command: `node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{const i=JSON.parse(d);const f=(i.tool_input||{}).file_path||'';if(!f.endsWith('.css')){return;}const fs=require('fs');let c;try{c=fs.readFileSync(f,'utf8');}catch(e){return;}const m=c.match(/#[0-9a-fA-F]{6}|#[0-9a-fA-F]{3}/g);if(m&&m.length){const u=[...new Set(m)];process.stdout.write(JSON.stringify({hookSpecificOutput:{hookEventName:'PostToolUse',additionalContext:'HARDCODED HEX in '+f+': '+u.join(', ')+'. Use var(--ui-*) tokens instead — hardcoded colours break theming.'}}));}})"`,
149
+ command: `node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{const i=JSON.parse(d);const f=(i.tool_input||{}).file_path||'';if(!f.endsWith('.css')){return;}if(f.endsWith('theme.css')){return;}const fs=require('fs');let c;try{c=fs.readFileSync(f,'utf8');}catch(e){return;}const m=c.match(/#[0-9a-fA-F]{6}|#[0-9a-fA-F]{3}/g);if(m&&m.length){const u=[...new Set(m)];process.stdout.write(JSON.stringify({hookSpecificOutput:{hookEventName:'PostToolUse',additionalContext:'HARDCODED HEX in '+f+': '+u.join(', ')+'. Hex values belong in public/theme.css as token definitions — '+f+' should use var(--token-name) references only.'}}));}})"`,
152
150
  statusMessage: 'Checking for hardcoded colours...',
153
151
  },
154
152
  {
@@ -226,7 +224,7 @@ export default {
226
224
  meta: {
227
225
  title: '${appName}',
228
226
  description: '${appName} — built with Pulse.',
229
- styles: ['/pulse-ui.css', '/app.css'],
227
+ styles: ['/pulse-ui.css', '/theme.css', '/app.css'],
230
228
  },
231
229
 
232
230
  state: {
@@ -313,6 +311,22 @@ test('decrement mutation subtracts 1', () => {
313
311
  `
314
312
  }
315
313
 
314
+ function themeCSS() {
315
+ return `\
316
+ /*
317
+ * theme.css — token definitions
318
+ * Hex values and raw colours live here. app.css references these via var().
319
+ * Override pulse-ui tokens by targeting [data-theme="light"] (higher specificity than :root).
320
+ */
321
+
322
+ /* Example — uncomment and edit to set a custom accent colour:
323
+ [data-theme="light"] {
324
+ --color-accent: #0066cc;
325
+ }
326
+ */
327
+ `
328
+ }
329
+
316
330
  function baseCSS() {
317
331
  return `\
318
332
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@@ -337,9 +351,10 @@ function claudeMd(appName) {
337
351
  ## Project
338
352
 
339
353
  \`\`\`
340
- src/pages/ ← one .js file per page, auto-discovered
341
- src/components/ ← shared view fragments (JS functions returning HTML strings)
342
- public/app.css ← global stylesheet
354
+ src/pages/ ← one .js file per page, auto-discovered
355
+ src/components/ ← shared view fragments (JS functions returning HTML strings)
356
+ public/theme.css ← token definitions (hex values, colour overrides) — hex allowed here only
357
+ public/app.css ← layout and component overrides — var() references only, no hex
343
358
  \`\`\`
344
359
 
345
360
  ## Start of every session
@@ -349,18 +364,13 @@ public/app.css ← global stylesheet
349
364
 
350
365
  The MCP guide is the single source of truth. Follow it for all technical decisions, component usage, and the mandatory verification workflow.
351
366
 
352
- ## Design — do this before writing any HTML
353
-
354
- Before planning any new page or component, use the \`frontend-design\` plugin to establish the visual direction:
355
-
356
- 1. Call the plugin's design tools with the page brief (what it does, who it's for, the tone)
357
- 2. The plugin will return decisions — colour palette, typography, spacing scale, component variants, layout approach
358
- 3. **Apply those decisions as CSS custom property overrides in \`public/app.css\`** — not as inline styles or class hacks
359
- 4. Document the chosen tokens at the top of \`public/app.css\` so every page stays consistent
367
+ ## CSS and theming
360
368
 
361
- The plugin informs *what* to build visually. The Pulse component library is *how* you build it. Use both — the plugin gives direction, the components implement it.
369
+ - Hex values and colour token definitions go in \`public/theme.css\` — that is the only file where hex is allowed. The hex lint hook will fire on any other CSS file.
370
+ - \`public/app.css\` must not contain hex colour values. Use \`var(--ui-space-N)\` spacing tokens (\`--ui-space-1\` = 4px through \`--ui-space-24\` = 96px) for padding, margin, and gap. Use \`var(--ui-text-*)\` tokens (\`--ui-text-xs\` through \`--ui-text-4xl\`) for font sizes. Plain values like \`1rem\` are also fine when no token fits.
371
+ - Load order in \`meta.styles\`: \`/pulse-ui.css\` → \`/theme.css\` → \`/app.css\`.
362
372
 
363
- **Do not default to stock component styles.** If the design plugin gives you a colour, use it. If it suggests a layout pattern, follow it. Every page should reflect deliberate design decisions, not the component library defaults.
373
+ When the user asks for layout or spacing changes, apply them directly — do not ask for confirmation on small CSS tweaks. Only pause for confirmation before large structural changes (new pages, major layout reworks).
364
374
 
365
375
  ## Before writing any code
366
376
 
@@ -376,7 +386,7 @@ Run these steps in order — do not declare work done without them:
376
386
  2. \`pulse_review\` — switch into reviewer mode, read the source and rendered HTML critically, fix every issue before continuing
377
387
  3. Write tests for every page you created or changed, run \`npm test\` (all pass), then \`npm run test:coverage\` — fix any untested branches
378
388
  4. Navigate to the page in the browser and take a screenshot
379
- 5. Run Lighthouse (desktop then mobile) — all four scores must be 100
389
+ 5. Run Lighthouse (desktop then mobile) — Accessibility, Best Practices, and SEO must all be 100
380
390
 
381
391
  ## Writing tests
382
392
 
package/src/mcp/server.js CHANGED
@@ -281,19 +281,24 @@ Rules for the spec you write:
281
281
  server.registerTool(
282
282
  'pulse_create_component',
283
283
  {
284
- description: `Create a reusable view component in src/components/. Components export named functions that return HTML strings.
284
+ description: `Register a component you have already written to disk with the Write tool.
285
285
 
286
- IMPORTANT rules for component content:
286
+ Workflow — always in this order:
287
+ 1. Write the component file to src/components/<name>.js using the Write tool (user sees the diff)
288
+ 2. Call pulse_create_component with just the name to confirm it was created correctly
289
+
290
+ Do NOT pass content here — write the file first, then call this tool.
291
+
292
+ Rules for the component you write:
287
293
  - Import Pulse UI components from '@invisibleloop/pulse/ui' where applicable
288
294
  - Use u- utility classes for spacing/layout — never inline styles
289
295
  - Use var(--ui-*) CSS tokens for any colour references — never hardcode hex values
290
296
  - Export named functions only (no default export needed)`,
291
297
  inputSchema: {
292
- name: z.string().describe('Component name, e.g. "hero" or "nav"'),
293
- content: z.string().describe('JS — export named functions that return HTML strings'),
298
+ name: z.string().describe('Component filename without extension, e.g. "hero" or "nav"'),
294
299
  },
295
300
  },
296
- async ({ name, content }) => {
301
+ ({ name }) => {
297
302
  const safeName = name.replace(/\.js$/, '')
298
303
  const fullPath = path.join(COMPONENTS_DIR, `${safeName}.js`)
299
304
 
@@ -301,10 +306,15 @@ IMPORTANT rules for component content:
301
306
  return text('Error: component name must not escape src/components/')
302
307
  }
303
308
 
304
- fs.mkdirSync(path.dirname(fullPath), { recursive: true })
305
- fs.writeFileSync(fullPath, content, 'utf8')
309
+ if (!fs.existsSync(fullPath)) {
310
+ return text(`Error: ${path.relative(ROOT, fullPath)} does not exist — write the file with the Write tool first, then call pulse_create_component.`)
311
+ }
312
+
313
+ const content = fs.readFileSync(fullPath, 'utf8')
314
+ const exports = [...content.matchAll(/^export\s+(?:function|const|async function)\s+(\w+)/gm)].map(m => m[1])
315
+ const exportNote = exports.length > 0 ? `Exports: ${exports.join(', ')}` : 'Warning: no named exports found — check the file exports at least one function.'
306
316
 
307
- return text(`Created ${path.relative(ROOT, fullPath)}`)
317
+ return text(`Registered ${path.relative(ROOT, fullPath)}\n${exportNote}`)
308
318
  }
309
319
  )
310
320
 
@@ -315,42 +325,59 @@ IMPORTANT rules for component content:
315
325
  server.registerTool(
316
326
  'pulse_create_store',
317
327
  {
318
- description: `Create a pulse.store.js global store at the project root. The store defines server fetchers and client mutations that are shared across pages.
328
+ description: `Register a pulse.store.js global store that you have already written to disk with the Write tool.
329
+
330
+ Workflow — always in this order:
331
+ 1. Write pulse.store.js to the project root using the Write tool (user sees the diff)
332
+ 2. Call pulse_create_store to validate the file you just wrote
333
+
334
+ Do NOT pass content here — write the file first, then call this tool.
319
335
 
320
- IMPORTANT rules:
336
+ Rules for the store you write:
321
337
  - server fetchers must be async functions: async (ctx) => value
322
338
  - mutations must be pure functions: (storeState, payload?) => partialState — no fetch, no side effects
323
339
  - hydrate is required if the store has mutations (enables client-side store mutation dispatch)
324
340
  - Register the store in your server file by passing it to createServer({ store })
325
341
  - Pages subscribe to store keys via spec.store: ['user', 'settings']`,
326
- inputSchema: {
327
- content: z.string().describe('Complete pulse.store.js content — must export default a valid store object'),
328
- },
342
+ inputSchema: {},
329
343
  },
330
- async ({ content }) => {
344
+ () => {
331
345
  const storePath = path.join(ROOT, 'pulse.store.js')
332
346
 
333
- // Basic structure check before writing
347
+ if (!fs.existsSync(storePath)) {
348
+ return text('Error: pulse.store.js does not exist — write the file with the Write tool first, then call pulse_create_store.')
349
+ }
350
+
351
+ const content = fs.readFileSync(storePath, 'utf8')
352
+
334
353
  if (!content.includes('export default')) {
335
- return text('Invalid: store must contain "export default { ... }"')
354
+ return text('Invalid: pulse.store.js must contain "export default { ... }"')
336
355
  }
337
356
 
338
- fs.writeFileSync(storePath, content, 'utf8')
357
+ const hasServer = content.includes('server:')
358
+ const hasMutations = content.includes('mutations:')
359
+ const hasHydrate = content.includes('hydrate:')
339
360
 
340
- return text(`Created pulse.store.js
361
+ const warnings = []
362
+ if (hasMutations && !hasHydrate) {
363
+ warnings.push('Warning: store has mutations but no hydrate — add hydrate: \'/pulse.store.js\' to enable client-side store dispatch.')
364
+ }
341
365
 
366
+ const lines = ['Validated pulse.store.js']
367
+ if (hasServer) lines.push(' ✓ server fetchers defined')
368
+ if (hasMutations) lines.push(' ✓ mutations defined')
369
+ if (hasHydrate) lines.push(' ✓ hydrate set')
370
+ if (warnings.length) lines.push('', ...warnings)
371
+ lines.push(`
342
372
  Next steps:
343
373
  1. Import and register it in your server file:
344
374
  import store from './pulse.store.js'
345
375
  createServer(specs, { store })
346
376
 
347
377
  2. Declare which keys each page uses:
348
- export default { route: '/dashboard', store: ['user', 'settings'], ... }
349
-
350
- 3. If you added mutations, set hydrate in pulse.store.js:
351
- hydrate: '/pulse.store.js'
378
+ export default { route: '/dashboard', store: ['user', 'settings'], ... }`)
352
379
 
353
- Store data is available in the view as the second argument alongside page server data.`)
380
+ return text(lines.join('\n'))
354
381
  }
355
382
  )
356
383
 
@@ -447,9 +474,12 @@ server.registerTool(
447
474
  const proc = spawn(process.execPath, [devScript, '--root', ROOT], { detached: true, stdio: 'ignore' })
448
475
  proc.unref()
449
476
 
450
- // Give it a moment to bind the port
451
- await new Promise(r => setTimeout(r, 1500))
452
- return text(`Dev server restarted on port ${port}`)
477
+ // Wait until the server is actually accepting requests
478
+ const ready = await waitForServer(port)
479
+ return text(ready
480
+ ? `Dev server restarted on port ${port}`
481
+ : `Dev server started on port ${port} (did not respond within 10 s — check for errors)`
482
+ )
453
483
  }
454
484
  )
455
485
 
@@ -491,11 +521,12 @@ server.registerTool(
491
521
  const proc = spawn(process.execPath, [startScript, '--root', ROOT, '--port', String(prodPort)], { detached: true, stdio: 'ignore' })
492
522
  proc.unref()
493
523
 
494
- // Wait for it to bind
495
- setTimeout(() => resolve(text(
496
- `Production build complete. Server running at http://localhost:${prodPort}/\n` +
497
- `Run Lighthouse against this URL, then call pulse_restart_server to return to dev.`
498
- )), 2000)
524
+ // Wait until the prod server is actually accepting requests
525
+ waitForServer(prodPort, 15_000).then(ready => resolve(text(
526
+ ready
527
+ ? `Production build complete. Server running at http://localhost:${prodPort}/\nRun Lighthouse against this URL, then call pulse_restart_server to return to dev.`
528
+ : `Build complete but prod server on port ${prodPort} did not respond within 15 s — check for startup errors.`
529
+ )))
499
530
  })
500
531
  )
501
532
 
@@ -587,7 +618,7 @@ Work through every item. Fix anything that fails.
587
618
 
588
619
  ### Structure
589
620
  - [ ] \`route\` is set explicitly — not left to auto-discovery
590
- - [ ] \`hydrate\` is set if the page has mutations, actions, or persist
621
+ - [ ] \`hydrate\` is NOT set manually — the framework injects it automatically. Remove it if present.
591
622
  - [ ] \`state\` shape is consistent — no fields that flip between null/string/boolean
592
623
  - [ ] \`meta.title\` is meaningful and unique to this page
593
624
  - [ ] \`meta.description\` is a real description, not "Built with Pulse"
@@ -636,6 +667,38 @@ Fix every issue you find. Then confirm what was changed.
636
667
  }
637
668
  )
638
669
 
670
+ // ---------------------------------------------------------------------------
671
+ // pulse_run_tests
672
+ // ---------------------------------------------------------------------------
673
+
674
+ server.registerTool(
675
+ 'pulse_run_tests',
676
+ {
677
+ description: 'Run the project test suite (npm test). Returns the full output. Use after writing or editing specs to verify nothing is broken.',
678
+ inputSchema: {},
679
+ },
680
+ () => new Promise(resolve => {
681
+ const result = spawnSync('npm', ['test'], {
682
+ cwd: ROOT,
683
+ encoding: 'utf8',
684
+ timeout: 120_000,
685
+ })
686
+
687
+ const output = (result.stdout || '') + (result.stderr || '')
688
+ const exitCode = result.status ?? 1
689
+
690
+ if (exitCode === 0) {
691
+ // Surface just the per-suite summary lines — enough to confirm all passed
692
+ const summaryLines = output.split('\n').filter(l => /\d+ tests?:/.test(l) || /passed|failed/.test(l))
693
+ const summary = summaryLines.length ? summaryLines.join('\n') : output.slice(-2000)
694
+ resolve(text(`All tests passed.\n\n${summary}`))
695
+ } else {
696
+ // Return the tail of the output where failures are reported
697
+ resolve(text(`Tests failed (exit ${exitCode}):\n\n${output.slice(-4000)}`))
698
+ }
699
+ })
700
+ )
701
+
639
702
  // ---------------------------------------------------------------------------
640
703
  // pulse_check_version
641
704
  // ---------------------------------------------------------------------------
@@ -735,6 +798,24 @@ server.registerTool(
735
798
  // Helpers
736
799
  // ---------------------------------------------------------------------------
737
800
 
801
+ /** Poll until the HTTP server on the given port accepts a connection, or timeout. */
802
+ async function waitForServer(port, maxMs = 10_000) {
803
+ const deadline = Date.now() + maxMs
804
+ while (Date.now() < deadline) {
805
+ const alive = await new Promise(resolve => {
806
+ const req = http.get(`http://localhost:${port}/`, { timeout: 500 }, res => {
807
+ res.resume()
808
+ resolve(true)
809
+ })
810
+ req.on('error', () => resolve(false))
811
+ req.on('timeout', () => { req.destroy(); resolve(false) })
812
+ })
813
+ if (alive) return true
814
+ await new Promise(r => setTimeout(r, 300))
815
+ }
816
+ return false
817
+ }
818
+
738
819
  async function validateContent(content) {
739
820
  // Write into PAGES_DIR so relative imports (e.g. '../components/nav.js') resolve correctly
740
821
  fs.mkdirSync(PAGES_DIR, { recursive: true })
@@ -843,9 +924,10 @@ const PULSE_GUIDE_INDEX = `# Pulse Framework Guide
843
924
  - \`pulse_validate\` — validate spec content. Call after every write. Fix all errors AND warnings.
844
925
  - \`pulse_review\` — switch into reviewer mode and critically examine a spec you just built. Returns the source, rendered HTML, validator output, and a full review checklist. **Call this only after validate, Lighthouse (desktop + mobile), and tests all pass — it is the final phase before declaring done.**
845
926
  - \`pulse_create_page\` — validate a page spec you already wrote to disk. **Always write the file with the Write tool first, then call this.** Never pass content to this tool.
846
- - \`pulse_create_component\` — create a reusable component.
847
- - \`pulse_create_store\` — create the pulse.store.js global store.
927
+ - \`pulse_create_component\` — register a component you wrote with the Write tool. Write the file first, then call this.
928
+ - \`pulse_create_store\` — register a pulse.store.js you wrote with the Write tool. Write the file first, then call this.
848
929
  - \`pulse_create_action\` — generate a correctly-structured action snippet.
930
+ - \`pulse_run_tests\` — run the project test suite (npm test). Use after writing or editing specs.
849
931
  - \`pulse_fetch_page(url)\` — HTTP GET the dev server URL. Use to verify SSR output.
850
932
  - \`pulse_restart_server\` — stop and restart the dev server.
851
933
  - \`pulse_build\` — production build + starts prod server on devPort+1 for Lighthouse. Returns the URL. Call \`pulse_restart_server\` after to return to dev. **Slow — takes 30–60 s. Tell the user before calling.**
@@ -863,32 +945,15 @@ const PULSE_GUIDE_INDEX = `# Pulse Framework Guide
863
945
 
864
946
  ## MANDATORY: Verify every build
865
947
 
866
- **RULE: NEVER run \`lighthouse_audit\` against the dev server. Dev mode serves unminified source files — scores are meaningless. Lighthouse MUST always run against the production build.**
867
-
868
- **Before calling any slow tool (\`pulse_build\`, \`lighthouse_audit\`), output a short status message to the user explaining what you are about to do and that it may take a moment.** Example: "Building for production — this takes ~30 s…" or "Running Lighthouse desktop audit — may take up to a minute…". Do not call the tool silently.
869
-
870
- After writing or editing any page, run ALL of the following steps in order before telling the user you are done:
871
-
872
- 1. \`pulse_validate\` — validate the spec. Fix all errors and warnings before continuing.
873
- 2. \`pulse_review\` — **switch into reviewer mode**. Read the source, rendered HTML, and checklist returned by the tool. Fix every issue found before moving on. This is mandatory — do not skip it.
874
- 3. \`pulse_restart_server\` — only if you added a new page or changed imports.
875
- 2. \`mcp__chrome-devtools__navigate_page\` — navigate to the dev server URL. After any CSS or asset change, follow immediately with \`mcp__chrome-devtools__evaluate_script\` running \`location.reload(true)\` to force a hard refresh.
876
- 3. \`mcp__chrome-devtools__take_screenshot\` — check layout, spacing, content visibility, no overflow. Also check headings for orphans (a single short word stranded on the last line). To detect them programmatically, run \`mcp__chrome-devtools__evaluate_script\` with:
877
- \`\`\`js
878
- Array.from(document.querySelectorAll('h1,h2,h3,h4,h5,h6')).filter(h=>{const r=document.createRange();r.selectNodeContents(h);const rects=r.getClientRects();if(rects.length<2)return false;const last=rects[rects.length-1];return last.width/h.getBoundingClientRect().width<0.4;}).map(h=>h.tagName+': '+h.textContent.trim())
879
- \`\`\`
880
- Any heading returned has a last line shorter than 40% of its width — fix it with \`balance: true\` on the \`heading()\` component.
881
- 4. \`mcp__chrome-devtools__list_console_messages\` (errors) — fix every JS error.
882
- 5. \`mcp__chrome-devtools__list_network_requests\` (failed) — fix every 404 or failed fetch.
883
- 6. \`pulse_fetch_page\` — pass the full URL e.g. \`{ url: "http://localhost:3000/" }\`. Confirm SSR renders expected content, no blank body.
884
- 7. \`pulse_build\` — this builds for production AND starts a prod server on devPort+1. Wait for it to return the prod URL.
885
- 8. \`mcp__chrome-devtools__navigate_page\` — navigate to the production URL returned by \`pulse_build\` (e.g. \`http://localhost:3001/\`).
886
- 9. \`mcp__chrome-devtools__lighthouse_audit\` with \`{ "strategy": "desktop" }\` — run against the production URL. All four scores (Performance, Accessibility, Best Practices, SEO) must be 100. Report the actual scores and fix every failing audit before continuing.
887
- 10. \`mcp__chrome-devtools__lighthouse_audit\` with \`{ "strategy": "mobile" }\` — run the same audit for mobile. All four scores must also be 100. Fix any failures before continuing.
888
- 11. \`pulse_restart_server\` — shut down the prod server and return to dev.
889
- 12. \`mcp__chrome-devtools__list_pages\` then \`mcp__chrome-devtools__close_page\` — close **every** page returned by \`list_pages\` to shut the browser down entirely. \`pageId\` must be a number: \`{ pageId: 2 }\` ✓ — NOT \`{ pageId: "2" }\` ✗ (string will fail). Loop through all page IDs and close each one.
890
-
891
- Do not declare success until all steps pass (step 12 is cleanup — always run it). If any step reveals a problem, fix it and repeat from step 2.
948
+ **After writing or editing any page spec, you MUST run \`/verify\` before declaring done.**
949
+
950
+ \`/verify\` is the single canonical verification workflow. It runs: validate → screenshot → console check → network check → SSR check → production build → Lighthouse desktop → Lighthouse mobile → performance trace → code review → writes the \`.pulse-verified\` stamp. The stop hook checks this stamp — if it is missing or older than the last spec edit, the hook will block and make you run verification again.
951
+
952
+ **Do not replicate \`/verify\` steps manually.** Running the individual tools yourself does not write the stamp. The agent will be blocked at the end regardless.
953
+
954
+ **RULE: NEVER run \`lighthouse_audit\` against the dev server.** Dev mode serves unminified source files — scores are meaningless. \`/verify\` handles this correctly by calling \`pulse_build\` first.
955
+
956
+ **Before calling any slow tool (\`pulse_build\`, \`lighthouse_audit\`), output a short status message to the user.** Example: "Building for production — this takes ~30 s…". Do not call slow tools silently.
892
957
 
893
958
  ${CHECKLIST}`
894
959
 
@@ -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
 
@@ -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
+ }
@@ -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': ["'none'"],
67
- 'frame-ancestors':["'none'"],
68
- 'base-uri': ["'self'"],
69
- 'form-action': ["'self'"],
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}'`], // allows toast's runtime-injected <style 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]