@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.
- package/.claude/commands/build-page.md +1 -1
- package/.claude/commands/verify.md +7 -5
- package/.claude/pulse-checklist.md +5 -2
- package/CLAUDE.md +8 -2
- package/README.md +3 -3
- package/docs/.claude/pulse-checklist.md +5 -2
- package/docs/public/pulse-ui.css +8 -2
- package/docs/src/pages/project-structure.js +2 -0
- package/examples/.claude/pulse-checklist.md +5 -2
- package/examples/public/pulse-ui.css +52 -2
- package/package.json +2 -2
- package/public/.pulse-ui-version +1 -1
- package/public/pulse-ui.css +8 -2
- package/scripts/build.js +1 -204
- package/scripts/css-purge.js +241 -0
- package/scripts/css-purge.test.js +94 -0
- package/src/agent/checklist.md +5 -2
- package/src/agent/commands/verify.md +7 -5
- package/src/agent/coverage-check.js +5 -0
- package/src/agent/guide-spec.md +10 -0
- package/src/agent/guide-styles.md +3 -1
- package/src/agent/skills/build-page/SKILL.md +7 -5
- package/src/agent/skills/verify/SKILL.md +3 -3
- package/src/agent/workflow.md +23 -1
- package/src/cli/scaffold.js +18 -2
- package/src/mcp/mcp.test.js +116 -2
- package/src/mcp/server.js +106 -2
- package/src/ui/drop-cap.js +1 -1
- package/src/ui/gallery.js +5 -1
- package/src/ui/ui.test.js +34 -0
|
@@ -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
|
+
})
|
package/src/agent/checklist.md
CHANGED
|
@@ -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/
|
|
70
|
-
- **Do not abstract a pattern that appears only once.** Duplication is cheaper than the wrong abstraction.
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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
|
|
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)) {
|
package/src/agent/guide-spec.md
CHANGED
|
@@ -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.
|
|
43
|
+
### 5. Restart and reload
|
|
44
44
|
|
|
45
|
-
|
|
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)
|
|
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 `.
|
|
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.
|
|
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
|
-
|
|
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
|
|
package/src/agent/workflow.md
CHANGED
|
@@ -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:
|
package/src/cli/scaffold.js
CHANGED
|
@@ -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\\
|
|
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',
|