@invisibleloop/pulse 0.16.0 → 0.17.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.
@@ -14,7 +14,7 @@ Every page you ship must meet all of the following. These are not aspirational
14
14
 
15
15
  **Lighthouse 100 on Accessibility, Best Practices, and SEO.** The \`lighthouse_audit\` tool returns these three scores — all must be 100. If any cannot hit 100, something is wrong with the spec. (Performance is not part of the audit tool and is not required.) The audit is slow — ~30–60 s per run. Always tell the user before calling it.
16
16
 
17
- **Polished and considered.** Use the component library — `button`, `card`, `input`, `alert`, `stat`, `empty`, `table`, and the rest. Use the spacing scale (`u-mt-*`, `u-gap-*`, `u-py-*`). Use the type scale (`u-text-xl`, `u-font-semibold`). Use the colour tokens (`var(--ui-accent)`, `var(--ui-muted)`). Do not leave raw unstyled HTML. Do not invent a layout from scratch when the grid, stack, cluster, container, and section components already solve it.
17
+ **Polished and considered.** Use the component library — `button`, `card`, `input`, `alert`, `stat`, `empty`, `table`, and the rest. Use the spacing scale (`u-mt-*`, `u-gap-*`, `u-py-*`). Use the type scale (`u-text-xl`, `u-font-semibold`). Use the colour tokens (`var(--ui-accent)`, `var(--ui-muted)`). Do not leave raw unstyled HTML. Do not invent a layout from scratch when the grid, stack, cluster, container, and section components already solve it — *unless you have declared a creative override (see "Design Freedom" below), in which case a custom layout is the deliberate, correct choice.*
18
18
 
19
19
  **Works correctly on first load.** SSR is always on. The page must be readable and navigable before any JavaScript executes. Do not build pages that require client JS to render content.
20
20
 
@@ -22,15 +22,30 @@ Every page you ship must meet all of the following. These are not aspirational
22
22
 
23
23
  **Accessible form labels.** Every `input`, `select`, and `textarea` has a label. Every form has a submit button. Required fields are marked required. Error messages are associated with the field that caused them.
24
24
 
25
+ # Design Freedom — The Rule
26
+
27
+ There are two valid build modes. Knowing which one you are in tells you exactly which rules apply.
28
+
29
+ **Mode A — components first (the default).** All standard UI patterns come from the component library. The component rules in "What You Will Not Do" below apply in full. This is the right mode for most pages.
30
+
31
+ **Mode B — creative override.** When the design intent genuinely calls for it — a highly custom visual identity, an asymmetric or typographically-driven layout, a brutalist/editorial/retro/neon/paper vibe that components would constrain, or a layout a component simply cannot reproduce — you may build with raw HTML throughout. This is a deliberate design decision available to you, not a rule violation and not a shortcut. To use it:
32
+
33
+ 1. **Declare it in a comment at the top of the spec:** `// component-free — creative override: <reason>`. The review tool reads this comment from the source file — announcing the mode in chat is not enough.
34
+ 2. Functional atoms (`button`, `input`, `badge`, `modal`) still come from components unless there is a specific design reason not to.
35
+ 3. `/pulse-ui.css` stays in `meta.styles` — your custom CSS builds on its token system.
36
+ 4. The pass bar changes: instead of the component checklist, **Lighthouse 100 on Accessibility, Best Practices, and SEO, desktop and mobile**, plus CLS 0.00. A component-free page that passes every audit is correct. A component-heavy page that fails accessibility is not.
37
+
38
+ The component rules marked *(Mode A)* below are suspended under a declared creative override. Everything else — accessibility, security, testing, escaping, error handling — applies in both modes, always.
39
+
25
40
  # What You Will Not Do
26
41
 
27
42
  - Install client-side JS dependencies. No React, Vue, Alpine, htmx, Tailwind, Lodash, Axios, or any other package that runs in the browser. Pulse handles rendering, state, actions, navigation, and SSR. If you need a utility, write it.
28
43
  - Use emoji characters in UI output. Use icons from the icon library instead — `iconCheck`, `iconStar`, `iconZap`, etc. If the right icon does not exist, create it in `src/ui/icons.js` following the existing pattern (see the guide for how). Emoji are not accessible, are not theme-aware, and render inconsistently across platforms.
29
- - Hardcode hex colours in CSS. Use `var(--ui-*)` tokens. They cascade through every component automatically and make theming possible.
30
- - Use inline `style=""` attributes. Use utility classes or `var(--ui-*)` tokens in a stylesheet.
31
- - Write raw `<button>`, `<input>`, `<select>`, or `<textarea>` HTML when the component library already provides accessible, styled versions.
32
- - Write raw `<h1>`–`<h6>` without styling. Use `heading({ level, text })` instead.
33
- - Write raw `<ul>` or `<ol>` without styling. Use `list({ items })` instead.
44
+ - Hardcode hex colours in CSS. Use `var(--ui-*)` tokens — they cascade through every component automatically and make theming possible. The one place hex values belong is `public/theme.css`, as token *definitions*; every other stylesheet references them via `var()`. A lint hook enforces this.
45
+ - Use inline `style=""` attributes. Use utility classes or `var(--ui-*)` tokens in a stylesheet. *Exception:* structural image styles with no utility-class equivalent (`aspect-ratio`, `object-fit`) are permitted inline — these are layout facts, not theming, and the design gallery uses them this way.
46
+ - Write raw `<button>`, `<input>`, `<select>`, or `<textarea>` HTML when the component library already provides accessible, styled versions. *(Mode A — under a creative override, functional atoms should still come from components unless there is a specific design reason.)*
47
+ - Write raw `<h1>`–`<h6>` without styling. Use `heading({ level, text })` instead. *(Mode A)*
48
+ - Write raw `<ul>` or `<ol>` without styling. Use `list({ items })` instead. *(Mode A)*
34
49
  - Output CMS or database HTML without a `prose()` wrapper. Raw HTML from external sources has no styling — always wrap it in `prose({ content: html })`.
35
50
  - Use `data-event` on text inputs. Re-rendering on every keystroke destroys focus. Use uncontrolled inputs and read values from `FormData` in `action.onStart` or `action.run`.
36
51
  - Skip `onError` in an action. It is required. Always handle failure.
@@ -40,11 +40,11 @@ Write the spec file. Follow these rules:
40
40
 
41
41
  Use `pulse_validate` on the spec. Fix any errors before continuing.
42
42
 
43
- ### 5. Register with the server
43
+ ### 5. Restart and reload
44
44
 
45
- Check the project's server entry file (e.g. `src/server.js` or `pulse.config.js`) and add the new spec if needed.
45
+ Pages under `src/pages/` are auto-discovered — no server entry file to edit. Call `pulse_restart_server`, then `navigate_page` so the browser is not on a stale tab.
46
46
 
47
- ### 6. Screenshot
47
+ ### 6. Screenshot + design approval
48
48
 
49
49
  Use the chrome-devtools navigate_page tool to load the page, then take_screenshot.
50
50
 
@@ -53,13 +53,15 @@ Check for:
53
53
  - Layout looks correct
54
54
  - No console errors via list_console_messages
55
55
 
56
+ Then ask the user: *"Happy with the design and layout, or would you like any changes before I run tests and Lighthouse?"* — use your host's question tool if it has one (e.g. AskUserQuestion); it waits for the answer without ending the turn. If you must ask in plain prose and end your turn, call `pulse_await_approval` first so the Stop hooks let the turn end. **Do not proceed until the user explicitly approves.** If a `VERIFY REQUIRED` stop-hook block fires while your question is unanswered, that is not permission to continue — call `pulse_await_approval`, re-ask, and end your turn.
57
+
56
58
  ### 7. Lighthouse
57
59
 
58
- Run lighthouse_audit (desktop, then mobile). All four scores — Accessibility, Best Practices, SEO, Performance — must be 100 before continuing.
60
+ Run lighthouse_audit (desktop, then mobile) against the production server — call `pulse_build` first and navigate to the production URL. **Pass bar: Accessibility, Best Practices, and SEO must all be 100.** Performance is reported but not gated (it varies with machine load).
59
61
 
60
62
  ### 8. Run the checklist
61
63
 
62
- Go through `.github/instructions/pulse-checklist.instructions.md` and confirm every applicable point passes. Fix anything that fails.
64
+ Go through `.claude/pulse-checklist.md` and confirm every applicable point passes. Fix anything that fails.
63
65
 
64
66
  ### 9. Write tests
65
67
 
@@ -34,8 +34,8 @@ Navigate to the page route with `mcp__chrome-devtools__navigate_page`, then take
34
34
  **If `pulse_intake` ran earlier in this session** (i.e. this is a new page or site, not an edit or bug fix):
35
35
 
36
36
  1. Show the screenshot to the user.
37
- 2. Ask: *"Happy with the layout and direction, or any changes before I run Lighthouse?"*
38
- 3. **Stop and wait for their response.** Do not proceed to step 4b or Lighthouse until the user explicitly approves.
37
+ 2. Ask: *"Happy with the layout and direction, or any changes before I run Lighthouse?"* — use your host's question tool if it has one (e.g. AskUserQuestion); it waits for the answer without ending the turn. If you must ask in plain prose and end your turn, call `pulse_await_approval` first so the Stop hooks let the turn end.
38
+ 3. **Stop and wait for their response.** Do not proceed to step 4b or Lighthouse until the user explicitly approves. If a `VERIFY REQUIRED` stop-hook block fires while your question is unanswered, that is not permission to continue — call `pulse_await_approval`, re-ask, and end your turn.
39
39
 
40
40
  If the user requests changes: make edits, restart the server, take a new screenshot, describe what changed, ask again. Repeat until approved. No Lighthouse between design rounds.
41
41
 
@@ -59,6 +59,8 @@ If the user's request is already very specific (a one-liner edit, a bug fix, "ad
59
59
 
60
60
  Call `pulse_list_structure` to see what already exists. Fetch only the guide sections relevant to your intent (the `pulse_intent` response tells you which ones).
61
61
 
62
+ **Check `src/components/` for shared sections before building.** If a layout, nav, footer, CTA strip, or section you're about to write already exists there — or already exists inline in another spec — reuse or extract it now rather than writing a second copy. Catching duplication here is cheap; catching it at review (phase 7) means the expensive gates already ran against the duplicated version.
63
+
62
64
  Do not fetch every guide section for every task. Fetch what you need.
63
65
 
64
66
  ---
@@ -73,7 +75,7 @@ Decide the complexity tier (see below) and confirm with the user if the task is
73
75
 
74
76
  **Skip confirmation for trivially small tasks** (add a button, fix a label, swap a component). When in doubt, confirm.
75
77
 
76
- **When asking the user questions:** ask one question at a time. If you present choices, use at most 4 options — the `ask_user` tool enforces this limit and will error if exceeded. For open-ended questions (names, copy, features, hex colours), ask as plain prose with no choices at all.
78
+ **When asking the user questions:** ask one question at a time. If you present choices, use at most 4 options — question tools in agent hosts (e.g. Claude Code's AskUserQuestion) cap the number of choices and will error if exceeded. For open-ended questions (names, copy, features, hex colours), ask as plain prose with no choices at all.
77
79
 
78
80
  ---
79
81
 
@@ -157,15 +159,18 @@ Or use the guide-components table. Never write these patterns from scratch in Mo
157
159
 
158
160
  If you're about to type `class="hero"` or `class="product-card"` in Mode A, stop — use the component instead.
159
161
 
162
+ **Reuse across specs (both modes):** one spec = one page; a site is many specs sharing code. Before writing a new section, check `src/components/` — and if a section you're writing already exists in another spec, extract it to `src/components/` *now* and import it from both, rather than pasting a copy. Same for CSS: a shared section's styles go in the shared `app.css` (listed in `meta.styles` by every spec that uses it), never duplicated into per-page stylesheets.
163
+
160
164
  **Mode B — Creative override (raw HTML throughout)**
161
165
  When the design intent calls for a more expressive, unconventional, or typographically-driven layout that components would constrain, you may build with raw HTML throughout. This is a deliberate design decision, not a shortcut.
162
166
 
163
167
  Rules for Mode B:
168
+ - **Declare it in a comment at the top of the spec file:** `// component-free — creative override: <reason>`. The review tool detects creative override by reading this comment from the source — without it, `pulse_review` will flag every component pattern as a violation even though you chose Mode B deliberately. Announcing the mode in chat is not enough; the comment must be in the file.
164
169
  - Load `/pulse-ui.css` in `meta.styles` — the token system is still required
165
170
  - Functional atoms (`button`, `input`, `badge`, `modal`) should still come from components unless there is a specific design reason
166
- - The quality gate is 100/100/100/100 Lighthouse on both desktop and mobile — this replaces the component checklist as the pass bar
171
+ - The quality gate is Lighthouse 100 on Accessibility, Best Practices, and SEO (desktop and mobile) plus CLS 0.00 — this replaces the component checklist as the pass bar. Performance is reported but not gated.
167
172
 
168
- Announce Mode B explicitly in the build brief: *"Mode B — component-free, raw HTML for creative control."*
173
+ Announce Mode B explicitly in the build brief (*"Mode B — component-free, raw HTML for creative control"*) **and** write the override comment into the spec.
169
174
 
170
175
  ### 3b — Announce before writing
171
176
 
@@ -235,6 +240,12 @@ Do this before every screenshot, console check, or visual inspection. Never debu
235
240
  3. **Ask the user directly:** *"Happy with the layout and direction, or any changes before I run tests and Lighthouse?"*
236
241
  4. **Wait for their response before doing anything else.** Do not run design_review, layout_review, or Lighthouse until you have a clear go-ahead.
237
242
 
243
+ **How to wait without tripping the Stop hooks.** The project's Stop hooks (missing tests, coverage, verify stamp) block any turn that ends with unverified spec edits — they cannot tell "abandoning the work" from "pausing for the user's answer". So:
244
+
245
+ - **Preferred:** ask the question with your host's question tool (e.g. AskUserQuestion in Claude Code). A question tool waits for the answer *without ending the turn*, so the Stop hooks never fire.
246
+ - **Otherwise:** call `pulse_await_approval` immediately before asking in plain prose and ending your turn. It writes a one-turn marker the Stop hooks respect; the marker is consumed when the user replies.
247
+ - **Never** respond to a `VERIFY REQUIRED` block by running `/verify` while your approval question is unanswered. The block does not override the approval gate — an unanswered question means the design is not approved, and Lighthouse on an unapproved design wastes ~90 seconds and pre-empts the user's changes.
248
+
238
249
  **If the user requests changes:**
239
250
  - Make the edits
240
251
  - Take a new screenshot
@@ -188,12 +188,26 @@ ${port !== 3000 ? ` port: ${port},\n` : ''}${agent && agent !== 'claude' ? ` a
188
188
  ]
189
189
  }
190
190
  ],
191
+ UserPromptSubmit: [
192
+ {
193
+ hooks: [
194
+ {
195
+ type: 'command',
196
+ // Consume the awaiting-approval marker: it grants exactly one turn-end
197
+ // (the one that asks the user a blocking question). As soon as the user
198
+ // replies, the Stop gates are back in force for the next turn.
199
+ command: `node -e "try{require('fs').unlinkSync('.pulse-awaiting-approval')}catch{}"`,
200
+ statusMessage: 'Clearing approval pause...',
201
+ }
202
+ ]
203
+ }
204
+ ],
191
205
  Stop: [
192
206
  {
193
207
  hooks: [
194
208
  {
195
209
  type: 'command',
196
- command: `node -e "const fs=require('fs');const{execSync}=require('child_process');let changed=new Set();let hasGit=false;try{execSync('git rev-parse --is-inside-work-tree',{stdio:'pipe'});hasGit=true;execSync('git status --porcelain',{encoding:'utf8'}).split('\\n').forEach(l=>{const f=l.slice(3).trim().split(' -> ').pop();if(f)changed.add(f);});}catch{}if(!hasGit&&fs.existsSync('src/pages')){for(const f of fs.readdirSync('src/pages')){if(f.endsWith('.js')&&!f.endsWith('.test.js'))changed.add('src/pages/'+f);}}const specs=[...changed].filter(f=>f.match(/^src\\/pages\\/.+\\.js$/)&&!f.endsWith('.test.js'));const missing=specs.filter(f=>{const t=f.replace(/\\.js$/,'.test.js');return!fs.existsSync(t)&&!changed.has(t);});if(missing.length){process.stdout.write(JSON.stringify({decision:'block',reason:'TESTS MISSING: '+missing.join(', ')+' — you must write tests before finishing. Use renderSync/render from @invisibleloop/pulse/testing to test view output, mutations, and any extracted logic functions. Run the tests with node to confirm they pass.'}));}"`,
210
+ command: `node -e "const fs=require('fs');if(fs.existsSync('.pulse-awaiting-approval'))process.exit(0);const{execSync}=require('child_process');let changed=new Set();let hasGit=false;try{execSync('git rev-parse --is-inside-work-tree',{stdio:'pipe'});hasGit=true;execSync('git status --porcelain',{encoding:'utf8'}).split('\\n').forEach(l=>{const f=l.slice(3).trim().split(' -> ').pop();if(f)changed.add(f);});}catch{}if(!hasGit&&fs.existsSync('src/pages')){for(const f of fs.readdirSync('src/pages')){if(f.endsWith('.js')&&!f.endsWith('.test.js'))changed.add('src/pages/'+f);}}const specs=[...changed].filter(f=>f.match(/^src\\/pages\\/.+\\.js$/)&&!f.endsWith('.test.js'));const missing=specs.filter(f=>{const t=f.replace(/\\.js$/,'.test.js');return!fs.existsSync(t)&&!changed.has(t);});if(missing.length){process.stdout.write(JSON.stringify({decision:'block',reason:'TESTS MISSING: '+missing.join(', ')+' — you must write tests before finishing. Use renderSync/render from @invisibleloop/pulse/testing to test view output, mutations, and any extracted logic functions. Run the tests with node to confirm they pass.\\n\\nEXCEPTION — waiting on the user: if you are ending this turn to wait for the user\\'s answer (e.g. the design-approval gate), call pulse_await_approval first, then ask and end your turn. Do not write tests or run /verify on an unapproved design.'}));}"`,
197
211
  statusMessage: 'Checking for missing tests...',
198
212
  },
199
213
  {
@@ -203,7 +217,7 @@ ${port !== 3000 ? ` port: ${port},\n` : ''}${agent && agent !== 'claude' ? ` a
203
217
  },
204
218
  {
205
219
  type: 'command',
206
- command: `node -e "const fs=require('fs');const{execSync}=require('child_process');let specs=[];try{const out=execSync('git status --porcelain',{encoding:'utf8'});out.split('\\n').forEach(l=>{const f=l.slice(3).trim().split(' -> ').pop();if(f&&f.match(/^src\\/pages\\/.+\\.js$/)&&!f.endsWith('.test.js'))specs.push(f);});}catch{if(fs.existsSync('src/pages'))for(const f of fs.readdirSync('src/pages')){if(f.endsWith('.js')&&!f.endsWith('.test.js'))specs.push('src/pages/'+f);}}if(!specs.length)process.exit(0);const stamp='.pulse-verified';let stampMtime=0;try{stampMtime=fs.statSync(stamp).mtimeMs;}catch{}const stale=specs.filter(f=>{try{return fs.statSync(f).mtimeMs>stampMtime;}catch{return true;}});if(stale.length){process.stdout.write(JSON.stringify({decision:'block',reason:'VERIFY REQUIRED: These pages have not been verified since last edit:\\n'+stale.map(f=>' '+f).join('\\n')+'\\n\\nRun /verify for each changed page. /verify runs Lighthouse (desktop + mobile), performance trace, console check, and code review — then writes the stamp that clears this check.'}));}"`,
220
+ command: `node -e "const fs=require('fs');if(fs.existsSync('.pulse-awaiting-approval'))process.exit(0);const{execSync}=require('child_process');let specs=[];try{const out=execSync('git status --porcelain',{encoding:'utf8'});out.split('\\n').forEach(l=>{const f=l.slice(3).trim().split(' -> ').pop();if(f&&f.match(/^src\\/pages\\/.+\\.js$/)&&!f.endsWith('.test.js'))specs.push(f);});}catch{if(fs.existsSync('src/pages'))for(const f of fs.readdirSync('src/pages')){if(f.endsWith('.js')&&!f.endsWith('.test.js'))specs.push('src/pages/'+f);}}if(!specs.length)process.exit(0);const stamp='.pulse-verified';let stampMtime=0;try{stampMtime=fs.statSync(stamp).mtimeMs;}catch{}const stale=specs.filter(f=>{try{return fs.statSync(f).mtimeMs>stampMtime;}catch{return true;}});if(stale.length){process.stdout.write(JSON.stringify({decision:'block',reason:'VERIFY REQUIRED: These pages have not been verified since last edit:\\n'+stale.map(f=>' '+f).join('\\n')+'\\n\\nIMPORTANT — check first: are you ending this turn to wait for the user\\'s answer (e.g. the design-approval question)? If so, do NOT run /verify — the user has not approved the design yet. Call pulse_await_approval, re-ask your question, and end your turn; this gate will let you stop.\\n\\nOtherwise run /verify for each changed page. /verify runs Lighthouse (desktop + mobile), performance trace, console check, and code review — then writes the stamp that clears this check.'}));}"`,
207
221
  statusMessage: 'Checking verification stamp...',
208
222
  }
209
223
  ]
@@ -217,6 +231,8 @@ ${port !== 3000 ? ` port: ${port},\n` : ''}${agent && agent !== 'claude' ? ` a
217
231
  'node_modules',
218
232
  'public/dist',
219
233
  '.pulse-build',
234
+ '.pulse-verified',
235
+ '.pulse-awaiting-approval',
220
236
  '.DS_Store',
221
237
  '# Intake images — local design references, not committed',
222
238
  'public/intake/*.jpg',
package/src/html.js CHANGED
@@ -21,4 +21,5 @@ export function escHtml(str) {
21
21
  .replace(/</g, '&lt;')
22
22
  .replace(/>/g, '&gt;')
23
23
  .replace(/"/g, '&quot;')
24
+ .replace(/'/g, '&#39;')
24
25
  }
@@ -102,6 +102,177 @@ test('no orphaned guide.md (monolithic legacy file should not exist)', () => {
102
102
  )
103
103
  })
104
104
 
105
+ // ── Duplicated doc sync ──────────────────────────────────────────────────────
106
+ // Several agent docs exist in two places (a canonical source plus a copy that is
107
+ // scaffolded into consumer projects or kept in this repo's own .claude/). These
108
+ // pairs must stay byte-identical or the two audiences receive drifting rules.
109
+
110
+ console.log('\nDuplicated doc sync\n')
111
+
112
+ const SYNC_PAIRS = [
113
+ ['src/agent/checklist.md', '.claude/pulse-checklist.md'],
114
+ ['src/agent/checklist.md', 'docs/.claude/pulse-checklist.md'],
115
+ ['src/agent/checklist.md', 'examples/.claude/pulse-checklist.md'],
116
+ ['src/agent/commands/verify.md', '.claude/commands/verify.md'],
117
+ ]
118
+
119
+ for (const [a, b] of SYNC_PAIRS) {
120
+ test(`${a} is in sync with ${b}`, () => {
121
+ const fa = path.join(ROOT, a)
122
+ const fb = path.join(ROOT, b)
123
+ assert(fs.existsSync(fa), `${a} does not exist`)
124
+ assert(fs.existsSync(fb), `${b} does not exist`)
125
+ assert(
126
+ fs.readFileSync(fa, 'utf8') === fs.readFileSync(fb, 'utf8'),
127
+ `${a} and ${b} have drifted apart. Edit the canonical file and copy it over the other ` +
128
+ `(cp ${a} ${b} or vice versa) so both audiences get the same rules.`
129
+ )
130
+ })
131
+ }
132
+
133
+ // ── Agent doc consistency guards ─────────────────────────────────────────────
134
+ // Contradictions between agent docs leave the agent unable to know what it can
135
+ // and cannot do. These guards pin the resolved decisions:
136
+ // - The Lighthouse pass bar is THREE gated scores (Accessibility, Best
137
+ // Practices, SEO) — Performance is reported, never gated. "100/100/100/100"
138
+ // reintroduces a four-score bar the audit tooling cannot verify.
139
+ // - "ask_user" is not a real tool in any host — docs must not name it.
140
+ // - Emoji are banned in UI output — design guides must not suggest them.
141
+
142
+ console.log('\nAgent doc consistency\n')
143
+
144
+ const agentDocs = [
145
+ ...fs.readdirSync(agentDir).filter(f => f.endsWith('.md')).map(f => path.join('src/agent', f)),
146
+ ...fs.readdirSync(path.join(agentDir, 'commands')).filter(f => f.endsWith('.md')).map(f => path.join('src/agent/commands', f)),
147
+ ...fs.readdirSync(path.join(agentDir, 'skills')).flatMap(d => {
148
+ const p = path.join(agentDir, 'skills', d, 'SKILL.md')
149
+ return fs.existsSync(p) ? [path.join('src/agent/skills', d, 'SKILL.md')] : []
150
+ }),
151
+ '.claude/commands/build-page.md',
152
+ '.claude/commands/verify.md',
153
+ '.claude/commands/new-doc-page.md',
154
+ 'CLAUDE.md',
155
+ 'README.md',
156
+ ]
157
+
158
+ test('no agent doc states a four-score Lighthouse bar (100/100/100/100)', () => {
159
+ const fourScore = (src) =>
160
+ src.includes('100/100/100/100') ||
161
+ /all four (scores|categories)/i.test(src) ||
162
+ /Accessibility, Best Practices, SEO,? and Performance[^.\n]*must (all )?be 100/i.test(src)
163
+ const offenders = agentDocs.filter(f =>
164
+ fs.existsSync(path.join(ROOT, f)) &&
165
+ fourScore(fs.readFileSync(path.join(ROOT, f), 'utf8'))
166
+ )
167
+ if (fourScore(serverSrc)) offenders.push('src/mcp/server.js')
168
+ assert(
169
+ offenders.length === 0,
170
+ `Four-score Lighthouse bar found in: ${offenders.join(', ')}. ` +
171
+ `The gated scores are Accessibility, Best Practices, and SEO (three) — Performance is reported, not gated.`
172
+ )
173
+ })
174
+
175
+ test('no agent doc references a literal ask_user tool', () => {
176
+ const offenders = agentDocs.filter(f =>
177
+ fs.existsSync(path.join(ROOT, f)) &&
178
+ /\bask_user\b/.test(fs.readFileSync(path.join(ROOT, f), 'utf8'))
179
+ )
180
+ assert(
181
+ offenders.length === 0,
182
+ `"ask_user" referenced in: ${offenders.join(', ')}. ` +
183
+ `No host exposes a tool by that name — phrase host-agnostically (e.g. "your host's question tool").`
184
+ )
185
+ })
186
+
187
+ test('design guides do not suggest emoji in UI output', () => {
188
+ const designGuides = ['src/agent/guide-design-references.md', 'src/agent/guide-design-gallery.md']
189
+ const offenders = designGuides.filter(f => {
190
+ const src = fs.readFileSync(path.join(ROOT, f), 'utf8')
191
+ // Allow lines that mention emoji only to ban them; flag lines that suggest using them
192
+ return src.split('\n').some(line =>
193
+ /emoji/i.test(line) && !/never|not |banned|instead of emoji/i.test(line)
194
+ )
195
+ })
196
+ assert(
197
+ offenders.length === 0,
198
+ `Emoji suggested (without a ban qualifier) in: ${offenders.join(', ')}. ` +
199
+ `Emoji are banned in UI output (identity.md) — suggest icon-library glyphs instead.`
200
+ )
201
+ })
202
+
203
+ test('workflow.md Mode B instructs writing the creative-override spec comment', () => {
204
+ const workflow = fs.readFileSync(path.join(agentDir, 'workflow.md'), 'utf8')
205
+ assert(
206
+ /component.free — creative override/.test(workflow),
207
+ 'workflow.md Mode B must instruct writing the `// component-free — creative override: <reason>` ' +
208
+ 'comment into the spec — pulse_review detects creative override by reading the source file.'
209
+ )
210
+ })
211
+
212
+ test('checklist points cross-spec shared components at src/components/, not src/ui/', () => {
213
+ const checklist = fs.readFileSync(path.join(agentDir, 'checklist.md'), 'utf8')
214
+ assert(
215
+ !/shared component in `src\/ui\/`/.test(checklist),
216
+ 'checklist.md tells the agent to create shared components in src/ui/ — that is the ' +
217
+ 'framework\'s own library inside the package. Project-level shared code goes in src/components/.'
218
+ )
219
+ assert(
220
+ /shared component in `src\/components\/`/.test(checklist),
221
+ 'checklist.md must direct cross-spec shared components to src/components/.'
222
+ )
223
+ })
224
+
225
+ test('guide-spec.md states the one-spec-per-page mental model', () => {
226
+ const guideSpec = fs.readFileSync(path.join(agentDir, 'guide-spec.md'), 'utf8')
227
+ assert(
228
+ /one spec per page/i.test(guideSpec) && /dynamic route is still one spec/i.test(guideSpec),
229
+ 'guide-spec.md must state the mental model explicitly: one spec = one route = one file, ' +
230
+ 'and a dynamic route is still one spec serving many URLs.'
231
+ )
232
+ })
233
+
234
+ test('approval pause mechanism is wired end to end', () => {
235
+ // The design-approval gate requires the agent to end its turn and wait for the
236
+ // user — but the scaffolded Stop hooks block any turn ending with unverified
237
+ // edits. Without an escape hatch the hooks steamroll the approval gate (the
238
+ // agent gets blocked, reads "Run /verify", and proceeds without an answer).
239
+ const scaffoldSrc = fs.readFileSync(path.join(ROOT, 'src/cli/scaffold.js'), 'utf8')
240
+ const coverageSrc = fs.readFileSync(path.join(agentDir, 'coverage-check.js'), 'utf8')
241
+ const workflow = fs.readFileSync(path.join(agentDir, 'workflow.md'), 'utf8')
242
+
243
+ // 1. The MCP server must expose the tool that writes the marker
244
+ assert(/registerTool\(\s*'pulse_await_approval'/.test(serverSrc),
245
+ 'server.js must register pulse_await_approval')
246
+
247
+ // 2. Every inline Stop hook in the scaffold must early-exit on the marker
248
+ const stopHookCount = (scaffoldSrc.match(/decision:'block'/g) || []).length
249
+ const markerCheckCount = (scaffoldSrc.match(/\.pulse-awaiting-approval'\)\)process\.exit\(0\)/g) || []).length
250
+ assert(stopHookCount >= 2 && markerCheckCount >= stopHookCount,
251
+ `scaffold.js has ${stopHookCount} blocking Stop hooks but only ${markerCheckCount} check ` +
252
+ `.pulse-awaiting-approval — every blocking Stop hook must allow the approval pause.`)
253
+
254
+ // 3. coverage-check.js (the third Stop hook) must early-exit on the marker
255
+ assert(coverageSrc.includes('.pulse-awaiting-approval'),
256
+ 'coverage-check.js must early-exit when .pulse-awaiting-approval exists')
257
+
258
+ // 4. The marker must be consumed when the user replies — one turn-end only
259
+ assert(/UserPromptSubmit/.test(scaffoldSrc) && /unlinkSync\('\.pulse-awaiting-approval'\)/.test(scaffoldSrc),
260
+ 'scaffold.js must register a UserPromptSubmit hook that deletes .pulse-awaiting-approval')
261
+
262
+ // 5. The workflow must teach the mechanism at the design-approval gate
263
+ assert(workflow.includes('pulse_await_approval'),
264
+ 'workflow.md Phase 5a must explain how to pause: host question tool preferred, pulse_await_approval otherwise')
265
+ })
266
+
267
+ test('identity.md declares the creative override (Design Freedom) carve-out', () => {
268
+ const identity = fs.readFileSync(path.join(agentDir, 'identity.md'), 'utf8')
269
+ assert(
270
+ /creative override/i.test(identity),
271
+ 'identity.md must include the creative-override carve-out — without it the persona ' +
272
+ 'unconditionally bans raw HTML that workflow.md Mode B explicitly permits.'
273
+ )
274
+ })
275
+
105
276
  // ── Result ───────────────────────────────────────────────────────────────────
106
277
 
107
278
  console.log(`\n${passed} passed, ${failed} failed\n`)
package/src/mcp/server.js CHANGED
@@ -1081,7 +1081,7 @@ ${(() => {
1081
1081
  // Creative override detection — check if spec declares component-free mode
1082
1082
  const creativeOverride = /component.free|creative\s+override|raw\s+HTML\s+throughout/i.test(source)
1083
1083
  checks.push(creativeOverride
1084
- ? '⚡ **Creative override declared** — component pattern checks are advisory only. Lighthouse 100/100/100/100 is the pass bar.'
1084
+ ? '⚡ **Creative override declared** — component pattern checks are advisory only. Lighthouse 100 on Accessibility, Best Practices, and SEO (desktop + mobile) plus CLS 0.00 is the pass bar.'
1085
1085
  : '○ No creative override declared — component checks apply')
1086
1086
 
1087
1087
  return checks.join('\n')
@@ -1122,7 +1122,7 @@ Work through every item. Fix anything that fails. Refer to the spec source at ${
1122
1122
 
1123
1123
  If no creative override is declared and a component *could* reproduce the design, refactor to use it. Custom utility classes on top of components are fine (\`hero({ ... })\` + override CSS) — but do not write the entire structure from scratch when the component supports the layout.
1124
1124
 
1125
- - [ ] **Creative override** (fill in if applicable): *State the override reason here — e.g. "full-viewport gradient hero not achievable with hero() component"*. Confirm Lighthouse 100/100/100/100 desktop and mobile passes before closing the review.
1125
+ - [ ] **Creative override** (fill in if applicable): *State the override reason here — e.g. "full-viewport gradient hero not achievable with hero() component"*. Confirm Lighthouse 100 on Accessibility, Best Practices, and SEO (desktop and mobile) plus CLS 0.00 before closing the review.
1126
1126
  - [ ] No \`data-event\` on text inputs — this destroys focus on every keystroke
1127
1127
  - [ ] No \`className\`, \`htmlFor\`, \`onClick=\`, or other React patterns
1128
1128
  - [ ] No hardcoded hex colours in view — only \`var(--ui-*)\` tokens (anchor \`href="#id"\` values are fine — those are not colours)
@@ -1203,6 +1203,33 @@ The stop hook compares each changed spec's mtime against this stamp. Any spec ne
1203
1203
  }
1204
1204
  )
1205
1205
 
1206
+ // ---------------------------------------------------------------------------
1207
+ // pulse_await_approval
1208
+ // ---------------------------------------------------------------------------
1209
+
1210
+ server.registerTool(
1211
+ 'pulse_await_approval',
1212
+ {
1213
+ description: `Pause the workflow to wait for the user's answer — call this immediately BEFORE ending your turn with a blocking question (e.g. the design-approval gate: "Happy with the layout and direction…?").
1214
+
1215
+ It writes a .pulse-awaiting-approval marker that tells the Stop hooks (missing tests, coverage, verify stamp) to let this one turn end without demanding /verify first. The marker is deleted automatically when the user sends their next message — the gates return in full force for your following turn.
1216
+
1217
+ **Prefer your host's question tool if it has one** (e.g. AskUserQuestion in Claude Code): a question tool waits for the answer *without ending the turn*, so the Stop hooks never fire and this marker is unnecessary. Use pulse_await_approval only when you must ask in plain prose and end the turn.
1218
+
1219
+ Do NOT use this to skip verification — it buys exactly one turn-end, paired with a question the user must answer. Calling it without asking anything just delays the gates by one message.`,
1220
+ inputSchema: {},
1221
+ },
1222
+ () => {
1223
+ const markerPath = path.join(ROOT, '.pulse-awaiting-approval')
1224
+ try {
1225
+ fs.writeFileSync(markerPath, String(Math.floor(Date.now() / 1000)), 'utf8')
1226
+ return text('✓ Awaiting-approval marker written. Ask the user your question and end your turn — the Stop hooks will allow it. The marker is cleared when the user replies.')
1227
+ } catch (err) {
1228
+ return text(`Error writing .pulse-awaiting-approval: ${err.message}\nFallback: run \`touch .pulse-awaiting-approval\` in Bash, then ask your question.`)
1229
+ }
1230
+ }
1231
+ )
1232
+
1206
1233
  // ---------------------------------------------------------------------------
1207
1234
  // pulse_run_tests
1208
1235
  // ---------------------------------------------------------------------------
package/src/md/index.js CHANGED
@@ -25,6 +25,33 @@ function esc(s) {
25
25
  .replace(/"/g, '&quot;')
26
26
  }
27
27
 
28
+ /**
29
+ * Sanitise a markdown link/image URL.
30
+ *
31
+ * Allows http(s), mailto, tel, relative paths, fragment/query refs, and
32
+ * image data: URIs. Any other scheme — notably javascript:, vbscript:, and
33
+ * non-image data: URIs — is neutralised to '#', preventing XSS when markdown
34
+ * comes from an untrusted source (database, user submission, external API).
35
+ *
36
+ * The value has already passed through esc(), so it is safe to embed in an
37
+ * attribute; this guard only blocks dangerous URL schemes.
38
+ *
39
+ * @param {string} url
40
+ * @param {boolean} [isImage=false] - permit image data: URIs when true
41
+ * @returns {string}
42
+ */
43
+ function safeUrl(url, isImage = false) {
44
+ // Strip leading control chars/whitespace that browsers ignore when parsing
45
+ // the scheme (e.g. "java\tscript:"), then test for a scheme prefix.
46
+ const stripped = url.replace(/[\x00-\x20]+/g, '')
47
+ const scheme = stripped.match(/^([a-zA-Z][a-zA-Z0-9+.\-]*):/)
48
+ if (!scheme) return url // no scheme → relative / fragment / query, safe
49
+ const proto = scheme[1].toLowerCase()
50
+ if (proto === 'http' || proto === 'https' || proto === 'mailto' || proto === 'tel') return url
51
+ if (isImage && /^data:image\/(png|jpe?g|gif|webp|avif|svg\+xml);/i.test(stripped)) return url
52
+ return '#'
53
+ }
54
+
28
55
  // ---------------------------------------------------------------------------
29
56
  // Frontmatter — --- key: value --- block at top of file
30
57
  // ---------------------------------------------------------------------------
@@ -69,12 +96,12 @@ function renderInline(raw) {
69
96
  // 3. Images (before links — same syntax with leading !)
70
97
  s = s.replace(/!\[([^\]]*)\]\(([^)\s"]+)(?:\s+"([^"]*)")?\)/g,
71
98
  (_, alt, src, title) =>
72
- `<img src="${src}" alt="${alt}"${title ? ` title="${title}"` : ''}>`)
99
+ `<img src="${safeUrl(src, true)}" alt="${alt}"${title ? ` title="${title}"` : ''}>`)
73
100
 
74
101
  // 4. Links
75
102
  s = s.replace(/\[([^\]]+)\]\(([^)\s"]+)(?:\s+"([^"]*)")?\)/g,
76
103
  (_, label, href, title) =>
77
- `<a href="${href}"${title ? ` title="${title}"` : ''}>${label}</a>`)
104
+ `<a href="${safeUrl(href)}"${title ? ` title="${title}"` : ''}>${label}</a>`)
78
105
 
79
106
  // 5. Bold + italic (order: *** before ** before *)
80
107
  s = s.replace(/\*{3}([^*\n]+)\*{3}/g, (_, t) => `<strong><em>${t}</em></strong>`)
package/src/md/md.test.js CHANGED
@@ -175,6 +175,42 @@ test('HTML is escaped in inline text', () => {
175
175
  assert(html.includes('&lt;script&gt;'), `Got: ${html}`)
176
176
  })
177
177
 
178
+ // --- URL scheme sanitisation (XSS) -----------------------------------------
179
+
180
+ test('link with javascript: scheme is neutralised to #', () => {
181
+ const { html } = parseMd('[x](javascript:alert(1))')
182
+ assert(!html.toLowerCase().includes('javascript:'), `Got: ${html}`)
183
+ assert(html.includes('href="#"'), `Got: ${html}`)
184
+ })
185
+
186
+ test('javascript: scheme neutralised regardless of case', () => {
187
+ const { html } = parseMd('[x](JaVaScRiPt:alert(1))')
188
+ assert(!html.toLowerCase().includes('javascript:'), `Got: ${html}`)
189
+ })
190
+
191
+ test('image with javascript: scheme is neutralised', () => {
192
+ const { html } = parseMd('![i](javascript:alert(1))')
193
+ assert(!html.toLowerCase().includes('javascript:'), `Got: ${html}`)
194
+ assert(html.includes('src="#"'), `Got: ${html}`)
195
+ })
196
+
197
+ test('non-image data: URI in image is neutralised', () => {
198
+ const { html } = parseMd('![i](data:text/html,<script>alert(1)</script>)')
199
+ assert(html.includes('src="#"'), `Got: ${html}`)
200
+ })
201
+
202
+ test('image data: URI is allowed', () => {
203
+ const { html } = parseMd('![i](data:image/png;base64,iVBOR)')
204
+ assert(html.includes('src="data:image/png;base64,iVBOR"'), `Got: ${html}`)
205
+ })
206
+
207
+ test('safe schemes and relative/fragment URLs are preserved', () => {
208
+ for (const url of ['https://ok.com', 'http://ok.com', '/relative', '#frag', 'mailto:a@b.com', 'tel:+15551234']) {
209
+ const { html } = parseMd(`[x](${url})`)
210
+ assert(html.includes(`href="${url}"`), `Expected ${url} preserved, got: ${html}`)
211
+ }
212
+ })
213
+
178
214
  // ---------------------------------------------------------------------------
179
215
 
180
216
  console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`)
@@ -621,4 +621,5 @@ function esc(str) {
621
621
  .replace(/</g, '&lt;')
622
622
  .replace(/>/g, '&gt;')
623
623
  .replace(/"/g, '&quot;')
624
+ .replace(/'/g, '&#39;')
624
625
  }
package/src/runtime/tt.js CHANGED
@@ -4,6 +4,14 @@
4
4
  * everywhere else — behaviour is identical, the protection is simply absent.
5
5
  *
6
6
  * Policy name 'pulse' must match the trusted-types CSP directive on the server.
7
+ *
8
+ * SECURITY NOTE: createHTML/createScriptURL are intentional pass-throughs. The
9
+ * framework owns the HTML it renders, so this policy does NOT sanitise — it
10
+ * exists to satisfy `require-trusted-types-for 'script'` and to confine HTML
11
+ * sinks to a single audited policy (preventing *other* code on the page from
12
+ * creating its own policy). It is NOT a defence against unescaped user data in
13
+ * a view: view authors remain responsible for escaping untrusted values with
14
+ * escHtml() before interpolating them into markup.
7
15
  */
8
16
 
9
17
  let _policy = null