@invisibleloop/pulse 0.17.0 → 0.17.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.
@@ -0,0 +1,241 @@
1
+ /**
2
+ * CSS purge + minify — used by scripts/build.js.
3
+ *
4
+ * Extracted into its own module so the parser can be unit-tested
5
+ * (build.js runs the build at import time and cannot be imported by tests).
6
+ */
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // CSS class extractor
10
+ // ---------------------------------------------------------------------------
11
+
12
+ export function extractUsedClasses(htmlContents, jsContents, safelist = []) {
13
+ const used = new Set()
14
+
15
+ // Extract from class="..." in both rendered HTML and JS template literals
16
+ for (const content of [...htmlContents, ...jsContents]) {
17
+ const re = /class(?:Name)?=["']([^"'\n]+)["']/g
18
+ let m
19
+ while ((m = re.exec(content)) !== null) {
20
+ for (const cls of m[1].trim().split(/\s+/)) {
21
+ if (cls) used.add(cls)
22
+ }
23
+ }
24
+ }
25
+
26
+ // Scan JS source for quoted strings that look like CSS class names (contain hyphens)
27
+ // Catches conditionally-applied classes like 'ui-btn--disabled' in ternaries
28
+ for (const js of jsContents) {
29
+ const re = /['"`]((?:[a-zA-Z][a-zA-Z0-9]*-[a-zA-Z0-9-]*)(?:\s+[a-zA-Z][a-zA-Z0-9-]*)*)['"`]/g
30
+ let m
31
+ while ((m = re.exec(js)) !== null) {
32
+ for (const cls of m[1].trim().split(/\s+/)) {
33
+ if (cls && cls.includes('-')) used.add(cls)
34
+ }
35
+ }
36
+ }
37
+
38
+ // Apply safelist from pulse.config.js — css.safelist accepts strings or RegExp.
39
+ // String entries are added directly; RegExp entries are matched against all
40
+ // class names defined in the CSS (resolved later in purgeCss via selectorUsed
41
+ // because we don't have the CSS text here). To handle RegExp, we mark them as
42
+ // a sentinel that selectorUsed checks separately.
43
+ for (const entry of safelist) {
44
+ if (typeof entry === 'string') {
45
+ used.add(entry)
46
+ }
47
+ // RegExp entries are stored on the Set as a symbol property for selectorUsed to check
48
+ }
49
+ if (safelist.some(e => e instanceof RegExp)) {
50
+ used._safelistPatterns = safelist.filter(e => e instanceof RegExp)
51
+ }
52
+
53
+ return used
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // CSS purger — strips unused rules, preserves @media / :root / element rules
58
+ // ---------------------------------------------------------------------------
59
+
60
+ /**
61
+ * Parse CSS text into top-level blocks using a bracket-depth counter.
62
+ * Quote-aware: `;` and braces inside '...' or "..." strings are content, not
63
+ * structure — an @import URL like url('...?family=Inter:wght@400;600&display=swap')
64
+ * must not be split at the `;` inside the quotes.
65
+ * Returns [{ type, selector?, prelude?, inner?, raw }]
66
+ */
67
+ export function parseCssBlocks(css) {
68
+ const blocks = []
69
+ let i = 0
70
+ const len = css.length
71
+
72
+ // Advance past a quoted string starting at css[i] (which is ' or ").
73
+ // Handles backslash escapes. Returns the index just past the closing quote.
74
+ const skipString = (idx) => {
75
+ const quote = css[idx]
76
+ idx++
77
+ while (idx < len && css[idx] !== quote) {
78
+ if (css[idx] === '\\') idx++
79
+ idx++
80
+ }
81
+ return idx + 1 // past closing quote (or len)
82
+ }
83
+
84
+ while (i < len) {
85
+ // Skip whitespace
86
+ while (i < len && /\s/.test(css[i])) i++
87
+ if (i >= len) break
88
+
89
+ // Skip block comments
90
+ if (css[i] === '/' && css[i + 1] === '*') {
91
+ const end = css.indexOf('*/', i + 2)
92
+ i = end === -1 ? len : end + 2
93
+ continue
94
+ }
95
+
96
+ const start = i
97
+ let depth = 0
98
+
99
+ while (i < len) {
100
+ // Skip inline comments
101
+ if (css[i] === '/' && css[i + 1] === '*') {
102
+ const end = css.indexOf('*/', i + 2)
103
+ i = end === -1 ? len : end + 2
104
+ continue
105
+ }
106
+ // Skip quoted strings — ; { } inside them are content, not structure
107
+ if (css[i] === '"' || css[i] === "'") { i = skipString(i); continue }
108
+ if (css[i] === '{') { depth++; i++; continue }
109
+ if (css[i] === '}') { depth--; i++; if (depth === 0) break; continue }
110
+ if (css[i] === ';' && depth === 0) { i++; break }
111
+ i++
112
+ }
113
+
114
+ const raw = css.slice(start, i).trim()
115
+ if (!raw) continue
116
+
117
+ if (raw.startsWith('@')) {
118
+ const braceIdx = raw.indexOf('{')
119
+ if (braceIdx === -1) {
120
+ blocks.push({ type: 'at-simple', raw })
121
+ } else {
122
+ const prelude = raw.slice(0, braceIdx).trim()
123
+ const inner = raw.slice(braceIdx + 1, raw.lastIndexOf('}')).trim()
124
+ blocks.push({ type: 'at-block', prelude, inner, raw })
125
+ }
126
+ } else {
127
+ const braceIdx = raw.indexOf('{')
128
+ if (braceIdx !== -1) {
129
+ blocks.push({ type: 'rule', selector: raw.slice(0, braceIdx).trim(), raw })
130
+ }
131
+ }
132
+ }
133
+
134
+ return blocks
135
+ }
136
+
137
+ /**
138
+ * Return true if any selector in the rule should be kept.
139
+ *
140
+ * Matching strategy: check the *rightmost* class token in the selector (the
141
+ * subject of the rule). A rule like `.parent .child` should survive only when
142
+ * `.child` is used — keeping it whenever `.parent` is used would retain dead
143
+ * rules whose actual target class was never referenced.
144
+ *
145
+ * Pseudo-classes/elements and attribute selectors are stripped before checking
146
+ * so `.btn:hover` is treated as `.btn`, `.btn--active::before` as `.btn--active`.
147
+ *
148
+ * Element-only selectors (`a`, `*`, `:root`) and rules that contain no class
149
+ * tokens at all are always kept.
150
+ */
151
+ export function selectorUsed(selector, usedClasses) {
152
+ const selectors = selector.split(',').map(s => s.trim())
153
+
154
+ for (const sel of selectors) {
155
+ // Strip pseudo-classes/elements and attribute selectors to find the base
156
+ const base = sel
157
+ .replace(/::?[a-z-]+(\([^)]*\))?/gi, '')
158
+ .replace(/\[[^\]]*\]/g, '')
159
+ .trim()
160
+
161
+ // Keep element-only selectors — no class tokens present
162
+ if (!base.includes('.')) return true
163
+
164
+ // Extract all class tokens and check the rightmost one (the rule subject).
165
+ // Using the rightmost class correctly handles descendant selectors:
166
+ // .nav .link → subject is .link
167
+ // .btn--active → subject is .btn--active
168
+ const classes = [...base.matchAll(/\.([a-zA-Z][a-zA-Z0-9_-]*)/g)].map(m => m[1])
169
+ if (classes.length === 0) return true
170
+ const subject = classes[classes.length - 1]
171
+ if (usedClasses.has(subject)) return true
172
+
173
+ // Check RegExp safelist patterns if any were provided
174
+ const patterns = usedClasses._safelistPatterns
175
+ if (patterns && patterns.some(re => re.test(subject))) return true
176
+ }
177
+
178
+ return false
179
+ }
180
+
181
+ /**
182
+ * Minify a CSS string. String-aware: content inside '...' or "..." is left
183
+ * untouched so quoted URLs (e.g. Google Fonts @import with ; and : in the
184
+ * query string) survive intact.
185
+ */
186
+ export function minifyCss(css) {
187
+ // Protect quoted strings from the whitespace/punctuation passes
188
+ const strings = []
189
+ const protectedCss = css.replace(/(['"])(?:\\.|(?!\1).)*\1/g, (m) => {
190
+ strings.push(m)
191
+ return `\x00S${strings.length - 1}\x00`
192
+ })
193
+
194
+ const minified = protectedCss
195
+ .replace(/\/\*[\s\S]*?\*\//g, '') // strip comments
196
+ .replace(/\s+/g, ' ') // collapse whitespace
197
+ // Remove spaces around safe punctuation — NOT + or - (would break calc() expressions)
198
+ .replace(/\s*([{}:;,>~])\s*/g, '$1')
199
+ .replace(/;}/g, '}') // remove trailing semicolons
200
+ .trim()
201
+
202
+ return minified.replace(/\x00S(\d+)\x00/g, (_, i) => strings[+i])
203
+ }
204
+
205
+ /**
206
+ * Purge unused CSS rules from a CSS string.
207
+ * Recursively processes @media and @supports blocks.
208
+ */
209
+ export function purgeCss(cssText, usedClasses) {
210
+ const blocks = parseCssBlocks(cssText)
211
+ const kept = []
212
+
213
+ for (const block of blocks) {
214
+ if (block.type === 'at-simple') {
215
+ kept.push(block.raw)
216
+ } else if (block.type === 'at-block') {
217
+ const p = block.prelude.toLowerCase()
218
+ if (/^@(?:keyframes|font-face|charset)/.test(p)) {
219
+ kept.push(block.raw)
220
+ } else if (/^@(?:media|supports)/.test(p)) {
221
+ const innerPurged = purgeCss(block.inner, usedClasses)
222
+ if (innerPurged.trim()) kept.push(`${block.prelude} {\n${innerPurged}\n}`)
223
+ } else if (/^@layer/.test(p)) {
224
+ // Always preserve empty @layer declarations — they establish cascade order
225
+ // and removing them silently changes specificity even when no rules remain.
226
+ const innerPurged = purgeCss(block.inner, usedClasses)
227
+ if (innerPurged.trim()) {
228
+ kept.push(`${block.prelude} {\n${innerPurged}\n}`)
229
+ } else {
230
+ kept.push(`${block.prelude} {}`)
231
+ }
232
+ } else {
233
+ kept.push(block.raw) // unknown at-blocks — keep to be safe
234
+ }
235
+ } else if (block.type === 'rule') {
236
+ if (selectorUsed(block.selector, usedClasses)) kept.push(block.raw)
237
+ }
238
+ }
239
+
240
+ return kept.join('\n\n')
241
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * CSS purge/minify tests — run with: node scripts/css-purge.test.js
3
+ *
4
+ * Regression focus: @import URLs containing `;` and `&` in their query string
5
+ * (Google Fonts) were truncated by the production build — the block parser
6
+ * split the statement at the `;` inside the quoted URL, silently breaking the
7
+ * theme in production while dev (which serves CSS unprocessed) looked fine.
8
+ */
9
+
10
+ import { test } from 'node:test'
11
+ import assert from 'node:assert/strict'
12
+ import { parseCssBlocks, purgeCss, minifyCss, extractUsedClasses, selectorUsed } from './css-purge.js'
13
+
14
+ const FONT_IMPORT = `@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap');`
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // The @import regression
18
+ // ---------------------------------------------------------------------------
19
+
20
+ test('parseCssBlocks: does not split @import at a ; inside a quoted URL', () => {
21
+ const css = `${FONT_IMPORT}\n:root { --accent: #e25; }`
22
+ const blocks = parseCssBlocks(css)
23
+ assert.equal(blocks.length, 2, `Expected 2 blocks, got ${blocks.length}: ${JSON.stringify(blocks.map(b => b.raw))}`)
24
+ assert.equal(blocks[0].type, 'at-simple')
25
+ assert.equal(blocks[0].raw, FONT_IMPORT)
26
+ })
27
+
28
+ test('parseCssBlocks: handles double-quoted URLs and escapes', () => {
29
+ const css = `@import url("https://example.com/a;b&c=d");\n.x { color: red; }`
30
+ const blocks = parseCssBlocks(css)
31
+ assert.equal(blocks[0].raw, `@import url("https://example.com/a;b&c=d");`)
32
+ })
33
+
34
+ test('purgeCss + minifyCss: Google Fonts @import survives the full pipeline intact', () => {
35
+ const css = `${FONT_IMPORT}\n:root { --font: 'Inter', sans-serif; }\n.unused { color: red; }`
36
+ const out = minifyCss(purgeCss(css, new Set([])))
37
+ assert.ok(
38
+ out.includes(`url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap')`),
39
+ `@import URL was mangled by the build pipeline. Output: ${out}`
40
+ )
41
+ })
42
+
43
+ test('minifyCss: quoted strings are untouched by punctuation collapsing', () => {
44
+ const css = `.x { content: "a ; b : c , d" ; }`
45
+ const out = minifyCss(css)
46
+ assert.ok(out.includes(`"a ; b : c , d"`), `Quoted string was altered: ${out}`)
47
+ })
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Behaviour preserved through the extraction refactor
51
+ // ---------------------------------------------------------------------------
52
+
53
+ test('purgeCss: drops unused class rules, keeps used ones', () => {
54
+ const css = `.used { color: red; }\n.unused { color: blue; }`
55
+ const out = purgeCss(css, new Set(['used']))
56
+ assert.ok(out.includes('.used'))
57
+ assert.ok(!out.includes('.unused'))
58
+ })
59
+
60
+ test('purgeCss: element and :root rules are always kept', () => {
61
+ const css = `:root { --x: 1; }\nbody { margin: 0; }`
62
+ const out = purgeCss(css, new Set([]))
63
+ assert.ok(out.includes(':root'))
64
+ assert.ok(out.includes('body'))
65
+ })
66
+
67
+ test('purgeCss: recurses into @media and keeps @font-face', () => {
68
+ const css = `@media (min-width: 600px) { .used { color: red; } .unused { color: blue; } }\n@font-face { font-family: X; src: url('/x.woff2'); }`
69
+ const out = purgeCss(css, new Set(['used']))
70
+ assert.ok(out.includes('.used'))
71
+ assert.ok(!out.includes('.unused'))
72
+ assert.ok(out.includes('@font-face'))
73
+ })
74
+
75
+ test('selectorUsed: rightmost class is the subject', () => {
76
+ assert.equal(selectorUsed('.nav .link', new Set(['link'])), true)
77
+ assert.equal(selectorUsed('.nav .link', new Set(['nav'])), false)
78
+ assert.equal(selectorUsed('.btn:hover', new Set(['btn'])), true)
79
+ })
80
+
81
+ test('extractUsedClasses: finds class attributes and hyphenated JS strings', () => {
82
+ const used = extractUsedClasses(
83
+ ['<div class="hero card">'],
84
+ [`el.classList.add('ui-btn--active')`]
85
+ )
86
+ assert.ok(used.has('hero'))
87
+ assert.ok(used.has('card'))
88
+ assert.ok(used.has('ui-btn--active'))
89
+ })
90
+
91
+ test('minifyCss: calc() expressions keep their operator spacing', () => {
92
+ const out = minifyCss(`.x { width: calc(100% - 2rem); }`)
93
+ assert.ok(out.includes('calc(100% - 2rem)'), `calc broken: ${out}`)
94
+ })
@@ -20,6 +20,8 @@ Before finishing any spec, verify every point below. Fix anything that fails.
20
20
 
21
21
  - **Do not set `hydrate` in specs.** It is auto-derived by the framework from the URL entry passed to `createServer`. Specs with `mutations`, `actions`, or `persist` are hydrated automatically. Purely server-rendered specs get zero JavaScript — no configuration needed.
22
22
 
23
+ - **The default theme is DARK.** When `meta.theme` is unset, the page renders with the dark palette. If the design calls for a light page, set `meta.theme: 'light'` when you **first write the spec** — do not assume light and discover dark at the screenshot, which costs an edit → restart → re-approval cycle. Declare the theme in your plan/build brief, and ask the user if the brief doesn't say.
24
+
23
25
  - **Always restart the server and reload the browser after every file edit — before checking the result.** The dev server restarts on file changes but the browser tab stays stale. The required sequence is: `pulse_restart_server` → `navigate_page`. Never attempt to debug a visual problem without doing both steps first.
24
26
 
25
27
  ### Components first — or creative override
@@ -66,8 +68,8 @@ Before finishing any spec, verify every point below. Fix anything that fails.
66
68
  ```js
67
69
  const card = ({ title, body }) => `<div class="card"><h3>${title}</h3><p>${body}</p></div>`
68
70
  ```
69
- - **Create a shared component in `src/ui/` when the same pattern is needed across 2 or more different specs.** Follow the existing pattern: a named export that returns an HTML string.
70
- - **Do not abstract a pattern that appears only once.** Duplication is cheaper than the wrong abstraction. Wait until the third use before extracting.
71
+ - **Create a shared component in `src/components/` when the same pattern is needed across 2 or more different specs.** A named export that returns an HTML string — same shape as the `layout()` pattern in the routing guide. Never paste a section's HTML into a second spec: extract it at the second use, then import it from both. (`src/ui/` is the framework's own library inside the package — project-level shared code always goes in `src/components/`.)
72
+ - **Do not abstract a pattern that appears only once.** Duplication is cheaper than the wrong abstraction. Within a single spec, wait until the third use before extracting a helper; across specs, extract at the second use.
71
73
 
72
74
  ### Correctness
73
75
 
@@ -106,6 +108,7 @@ Before finishing any spec, verify every point below. Fix anything that fails.
106
108
 
107
109
  - **`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.
108
110
  - **Load order in `meta.styles`:** `pulse-ui.css` → `theme.css` → `app.css`. Theme tokens must be defined before `app.css` references them.
111
+ - **CSS for a shared section lives in the shared `app.css`, once.** Every spec that renders the section lists the same file in `meta.styles`. Never copy a CSS block into a second per-page stylesheet — two copies drift apart. Per-page stylesheets are for styles only one page uses.
109
112
 
110
113
  ### Security
111
114
 
@@ -15,8 +15,8 @@ If $ARGUMENTS is provided, use it as the file path or route. Otherwise, identify
15
15
  If this is a new page build (came through `pulse_intake` or `build-page`):
16
16
 
17
17
  1. Take a screenshot and describe what you see to the user.
18
- 2. Ask the user: "Happy with the design and layout, or would you like any changes before I run Lighthouse?" — if your host provides a question tool (e.g. AskUserQuestion), offer the choices `["Yes, looks good — proceed", "I'd like some changes first"]`; otherwise ask in plain prose.
19
- 3. **Do not proceed to step 3 until the user explicitly confirms.** If they want changes, stop here, make the changes, then restart from step 2. Lighthouse takes ~90 seconds — do not waste it on a design the user hasn't approved.
18
+ 2. **Call `pulse_await_approval` first**, then ask the user: "Happy with the design and layout, or would you like any changes before I run Lighthouse?" — if your host provides a question tool (e.g. AskUserQuestion), offer the choices `["Yes, looks good — proceed", "I'd like some changes first"]`; otherwise ask in plain prose. Always call the tool before asking, whichever way you ask: in some hosts (including Claude Code) even a question tool ends the turn and fires the Stop hooks. The marker is harmless if the turn doesn't end — it is consumed when the user replies.
19
+ 3. **Do not proceed to step 3 until the user explicitly confirms.** If they want changes, stop here, make the changes, then restart from step 2. Lighthouse takes ~90 seconds — do not waste it on a design the user hasn't approved. 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.
20
20
 
21
21
  ### 3. Validate the spec
22
22
 
@@ -46,7 +46,7 @@ The browser should still be on `http://localhost:3001/` from step 6. Run `mcp__c
46
46
 
47
47
  **Same pass bar: Accessibility, Best Practices, and SEO must all be 100.** Fix any failures and restart from step 3.
48
48
 
49
- After both Lighthouse runs pass, call `pulse_restart_server` to return to the dev server.
49
+ **Stay on the production server (port 3001)** — steps 8 and 9 use it too. Do not call `pulse_restart_server` yet.
50
50
 
51
51
  ### 8. Mobile layout check
52
52
 
@@ -54,7 +54,7 @@ Emulate a mobile viewport and take a screenshot to catch wrapping, overflow, and
54
54
 
55
55
  ```
56
56
  mcp__chrome-devtools__emulate viewport: "390x844x2,mobile,touch"
57
- mcp__chrome-devtools__navigate_page url: "http://localhost:3000/"
57
+ mcp__chrome-devtools__navigate_page url: "http://localhost:3001/"
58
58
  mcp__chrome-devtools__take_screenshot
59
59
  ```
60
60
 
@@ -72,7 +72,9 @@ mcp__chrome-devtools__emulate viewport: "1440x900x1"
72
72
 
73
73
  ### 9. Performance
74
74
 
75
- Navigate to the page route, then run `mcp__chrome-devtools__performance_start_trace` with `reload: true` and `autoStop: true`. Report LCP and CLS. Flag any LCP insight that suggests a fixable problem (render-blocking resources, large image load delay, etc.).
75
+ **Run the trace against the production server, not dev.** Navigate to `http://localhost:3001/<route>` first — if the production server is no longer running, run `pulse_build` again. Tracing the dev server (port 3000) reports misleading LCP/CLS. Then run `mcp__chrome-devtools__performance_start_trace` with `reload: true` and `autoStop: true`. Report LCP and CLS. Flag any LCP insight that suggests a fixable problem (render-blocking resources, large image load delay, etc.).
76
+
77
+ After the trace, call `pulse_restart_server` to return to the dev server.
76
78
 
77
79
  ### 10. Console errors
78
80
 
@@ -13,6 +13,11 @@
13
13
  import { execSync } from 'child_process'
14
14
  import fs from 'fs'
15
15
 
16
+ // Agent is ending its turn to wait for the user's answer (design-approval gate) —
17
+ // let the turn end. The marker is consumed on the user's next prompt, so the
18
+ // gates return in full force afterwards.
19
+ if (fs.existsSync('.pulse-awaiting-approval')) process.exit(0)
20
+
16
21
  // No test files → nothing to check
17
22
  const hasTests = fs.existsSync('src/pages') && (function scan(d) {
18
23
  for (const f of fs.readdirSync(d)) {
@@ -1,5 +1,11 @@
1
1
  Pulse is a spec-first frontend framework. Pages are JS files that export a default spec object.
2
2
 
3
+ ## Mental model — one spec per page
4
+
5
+ - **One spec = one route = one file** under `src/pages/`. Every page of a site is its own spec file — a site is a collection of specs, not one big spec.
6
+ - **A dynamic route is still one spec.** `route: '/blog/:slug'` is a single file serving *every* blog post URL — the `:param` does the multiplying. Never create one spec per item (`post-1.js`, `post-2.js`, …), and never merge several distinct pages into one spec with conditionals on `ctx`.
7
+ - **Specs share code, they don't repeat it.** Shared sections, layouts, nav, and footer live in `src/components/` and are imported by each spec that needs them (see `pulse://guide/routing` for the `layout()` pattern).
8
+
3
9
  ## Spec structure
4
10
 
5
11
  ```js
@@ -10,6 +16,10 @@ export default {
10
16
  title: 'Page Title', // string or async (ctx) => string
11
17
  description: 'Meta description', // string or async (ctx) => string
12
18
  styles: ['/app.css'], // string[] or (ctx) => string[]
19
+ theme: 'light', // 'light' | 'dark' — ⚠ DEFAULT IS DARK when unset.
20
+ // Decide at write time: a light design needs
21
+ // meta.theme: 'light' or the page renders dark.
22
+ vibe: 'minimal', // optional — radius/weight/spacing preset
13
23
  ogTitle: 'Social title', // optional — falls back to title
14
24
  ogImage: 'https://…/og.jpg', // optional — 1200×630 recommended
15
25
  canonical: 'https://mysite.com/…', // optional — string or (ctx) => string
@@ -199,6 +199,8 @@ The default CSP does **not** restrict `img-src` — it inherits `default-src 'se
199
199
 
200
200
  **Do this at build time, before writing any image tags.** If you know you'll use external images, add the CSP entry to `pulse.config.js` first — discovering the block at Lighthouse time costs an extra round-trip.
201
201
 
202
+ **Use `pulse_check_csp` to get the origin list right first time.** Many hosts redirect to a CDN origin (picsum.photos → fastly.picsum.photos), and allowing only the URL you wrote still blocks the actual bytes. Pass it the URLs you plan to use and it follows each redirect chain and returns ready-to-paste CSP config with every origin included.
203
+
202
204
  ```js
203
205
  // pulse.config.js
204
206
  export default {
@@ -213,7 +215,7 @@ Or via `createServer`:
213
215
  ```js
214
216
  createServer(specs, {
215
217
  csp: {
216
- 'img-src': ['https://images.unsplash.com', 'https://picsum.photos'],
218
+ 'img-src': ['https://images.unsplash.com', 'https://picsum.photos', 'https://fastly.picsum.photos'],
217
219
  },
218
220
  })
219
221
  ```
@@ -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 **call `pulse_await_approval`** and ask the user: *"Happy with the design and layout, or would you like any changes before I run tests and Lighthouse?"* Always call the tool before asking, even when using a question tool like AskUserQuestion — in some hosts (including Claude Code) the question still ends the turn and fires the Stop hooks; the marker is harmless if the turn doesn't 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. **Call `pulse_await_approval` first, then ask:** *"Happy with the layout and direction, or any changes before I run Lighthouse?"* Always call the tool before asking, even when using a question tool like AskUserQuestion — in some hosts (including Claude Code) the question still ends the turn and fires the Stop hooks. The marker is harmless if the turn doesn't end; it is consumed when the user replies.
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
 
@@ -67,7 +67,7 @@ Run `mcp__chrome-devtools__lighthouse_audit` with `device: "mobile"` against the
67
67
 
68
68
  ### 8. Performance
69
69
 
70
- Navigate to the page route, then run `mcp__chrome-devtools__performance_start_trace` with `reload: true` and `autoStop: true`. Report LCP and CLS as a single line, e.g. `LCP 73ms · CLS 0.00`. Flag any insight that identifies a fixable cause (render-blocking resources, large image delay). CLS must be 0.00.
70
+ **Stay on the production server (port 3001) for the trace** — navigate to `http://localhost:3001/<route>` first; tracing the dev server (port 3000) reports misleading LCP/CLS. Then run `mcp__chrome-devtools__performance_start_trace` with `reload: true` and `autoStop: true`. Report LCP and CLS as a single line, e.g. `LCP 73ms · CLS 0.00`. Flag any insight that identifies a fixable cause (render-blocking resources, large image delay). CLS must be 0.00.
71
71
 
72
72
  ### 9. Console errors
73
73
 
@@ -32,7 +32,7 @@ For any **new page, landing page, or branded site**, run the intake sequence fir
32
32
 
33
33
  - **Step 0 — Ask for inspiration first (mandatory):** Before calling any tool or presenting any plan, ask the user: *"Do you have any design inspiration — a site you love, a screenshot, or a mood board image? Drop images into `public/intake/` or paste a URL and I'll extract the design intent before we start."* Wait for the answer. Do not skip this question, even if the user's brief already seems detailed. Check `public/intake/` for any images already dropped there.
34
34
  - **`pulse_extract_inspiration`** — call this after step 0 if the user supplies a URL, site name, or image they like. Also call it for any images already present in `public/intake/`. Gives you a structured extraction template: visit the URL or analyse the image with your vision tools, extract colours, layout, typography, and feel, then feed those findings into pulse_intake. If the user explicitly says they have no reference, skip this step.
35
- - **`pulse_intake`** — captures the real content (name, pitch, features, palette, vibe, what it should NOT look like). Returns a brief with copy-ready content and early contrast warnings. Always ask the user one question at a time — never multi-choice for open-ended questions.
35
+ - **`pulse_intake`** — captures the real content (name, pitch, features, palette, theme, vibe, what it should NOT look like). Returns a brief with copy-ready content and early contrast warnings. Always ask the user one question at a time — never multi-choice for open-ended questions. **Always ask light or dark** — Pulse renders dark when `meta.theme` is unset, so an unstated light design ships dark.
36
36
  - **`pulse_sketch`** — generates 3 structurally distinct layout directions (full-bleed, asymmetric split, typography-only, editorial, dense grid, story scroll, content-first). **Call this before writing any code.** Prevents defaulting to centred hero + three-column features every time.
37
37
  - **Skip `pulse_sketch` only** when the vibe is `corporate` or `minimal` AND the user hasn't expressed any structural preference. For `playful`, `bold`, `brutalist`, `retro`, or `neon` vibes, `pulse_sketch` is mandatory — templates have default section orders that actively fight these vibes.
38
38
  - **`pulse_intent`** — maps the chosen direction to a spec scaffold and tells you which guides to read.
@@ -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
  ---
@@ -68,6 +70,7 @@ Do not fetch every guide section for every task. Fetch what you need.
68
70
  Decide the complexity tier (see below) and confirm with the user if the task is non-trivial. Output a concise plan:
69
71
 
70
72
  - Route, state shape, mutations/actions, server fetchers
73
+ - **Theme: light or dark** — Pulse defaults to **dark** when `meta.theme` is unset. Decide now (ask the user if the brief doesn't say) — discovering a wrong theme at the screenshot costs a full edit → restart → re-approval cycle.
71
74
  - Which UI components you will use
72
75
  - Files you will create or modify
73
76
 
@@ -157,6 +160,8 @@ Or use the guide-components table. Never write these patterns from scratch in Mo
157
160
 
158
161
  If you're about to type `class="hero"` or `class="product-card"` in Mode A, stop — use the component instead.
159
162
 
163
+ **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.
164
+
160
165
  **Mode B — Creative override (raw HTML throughout)**
161
166
  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
167
 
@@ -175,6 +180,7 @@ Before writing any file, output a build brief:
175
180
  ```
176
181
  Building: <page name> (<route>)
177
182
  Mode: A — components first | B — creative override (raw HTML, reason: ...)
183
+ Theme: light | dark (+ vibe, if any) — default is DARK when meta.theme is unset
178
184
  State: <fields and types, or "none">
179
185
  Mutations: <list, or "none">
180
186
  Actions: <list with brief description, or "none">
@@ -236,6 +242,12 @@ Do this before every screenshot, console check, or visual inspection. Never debu
236
242
  3. **Ask the user directly:** *"Happy with the layout and direction, or any changes before I run tests and Lighthouse?"*
237
243
  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.
238
244
 
245
+ **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:
246
+
247
+ - **Always call `pulse_await_approval` immediately before asking** — regardless of whether you ask via a question tool or in plain prose. In some hosts (including Claude Code) even AskUserQuestion ends the turn and fires the Stop hooks. The marker is harmless if the turn doesn't end: it is simply consumed when the user replies.
248
+ - Then ask the question (question tool with choices where available, plain prose otherwise) and end your turn.
249
+ - **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.
250
+
239
251
  **If the user requests changes:**
240
252
  - Make the edits
241
253
  - Take a new screenshot
@@ -264,6 +276,16 @@ Do this before every screenshot, console check, or visual inspection. Never debu
264
276
  2. `pulse_layout_review <url>` — 390/768/1280px overflow, broken images, collapsed sections
265
277
  3. `/verify` — Lighthouse desktop + mobile (100/100/100) + CLS 0.00 + `pulse_review`
266
278
 
279
+ **Two different reviews — don't confuse them.** `pulse_design_review` is the *visual* review (7 signals) and runs **before** Lighthouse. `pulse_review` is the *code* review and runs **after** Lighthouse, always last. The canonical end-to-end order is:
280
+
281
+ ```
282
+ pulse_validate → design approval → pulse_design_review → pulse_layout_review
283
+ → pulse_build → Lighthouse desktop + mobile → performance trace
284
+ → pulse_review → pulse_stamp
285
+ ```
286
+
287
+ If a step's fixes touch the spec, re-run from `pulse_validate` — never re-run `pulse_review` on code that hasn't re-passed Lighthouse.
288
+
267
289
  **Do not report the page as done until `/verify` writes the `.pulse-verified` stamp.** Skipping Lighthouse means broken contrast, missing landmarks, and Best Practices failures ship to the user.
268
290
 
269
291
  Pass gates:
@@ -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',