@invisibleloop/pulse 0.20.1 → 0.20.3
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/pulse-start.md +11 -0
- package/.claude/commands/pulse-stop.md +5 -0
- package/.claude/commands/verify.md +17 -11
- package/docs/.claude/coverage-check.js +153 -84
- package/examples/.claude/coverage-check.js +153 -84
- package/package.json +1 -1
- package/public/.pulse-ui-version +1 -1
- package/scripts/build.js +1 -1
- package/scripts/css-purge.js +65 -3
- package/scripts/css-purge.test.js +16 -0
- package/scripts/strip-server.test.js +11 -1
- package/src/agent/commands/pulse-start.md +6 -4
- package/src/agent/commands/pulse-stop.md +1 -1
- package/src/agent/commands/verify.md +17 -11
- package/src/agent/coverage-check.js +154 -90
- package/src/agent/guide-components.md +2 -2
- package/src/agent/identity.md +2 -2
- package/src/agent/workflow.md +21 -4
- package/src/cli/cli.test.js +72 -0
- package/src/cli/dev.js +23 -3
- package/src/cli/index.js +5 -2
- package/src/cli/scaffold.js +2 -2
- package/src/mcp/mcp.test.js +44 -0
- package/src/mcp/server.js +523 -33
- package/src/mcp/validate-worker.js +3 -0
- package/src/runtime/index.js +9 -0
- package/src/server/index.js +21 -30
package/src/mcp/server.js
CHANGED
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
* Provides tools and resources for an AI agent working inside a Pulse project.
|
|
6
6
|
*
|
|
7
7
|
* Resources:
|
|
8
|
+
* pulse://start — context-aware entry point (new project / edit / bug fix)
|
|
8
9
|
* pulse://guide — complete guide: spec format, UI components, CSS rules, patterns
|
|
9
10
|
*
|
|
10
11
|
* Tools:
|
|
11
12
|
* pulse_intent — intent engine: describe what to build, get archetype + scaffold
|
|
12
|
-
* pulse_suggest — draft-mode contextual feedback on partial specs
|
|
13
|
+
* pulse_suggest — draft-mode contextual feedback on partial specs (use after first draft)
|
|
13
14
|
* pulse_intake — product intake: capture app details before scaffolding
|
|
14
15
|
* pulse_sketch — generate 3 structural layout directions before writing code
|
|
15
16
|
* pulse_list_icons — list all available icon names, grouped by category
|
|
@@ -17,7 +18,9 @@
|
|
|
17
18
|
* pulse_list_structure — list all pages and components
|
|
18
19
|
* pulse_create_page — create a new page spec with proper template
|
|
19
20
|
* pulse_create_component — create a reusable component
|
|
21
|
+
* pulse_create_tests — generate starter test file with XSS, null data, and landmark stubs
|
|
20
22
|
* pulse_validate — validate a spec against the schema
|
|
23
|
+
* pulse_review — full code review (pass quick:true for lightweight mid-build check)
|
|
21
24
|
* pulse_stamp — write the .pulse-verified stamp (call as last step of /verify)
|
|
22
25
|
* pulse_check_version — installed vs static vs npm latest
|
|
23
26
|
* pulse_update — re-copy pulse-ui assets from package → public/
|
|
@@ -188,6 +191,99 @@ for (const { name, uri, title, description, content } of GUIDE_RESOURCES) {
|
|
|
188
191
|
)
|
|
189
192
|
}
|
|
190
193
|
|
|
194
|
+
// pulse://start — context-aware single entry point.
|
|
195
|
+
// Detects whether this is a new project, new page, or targeted edit, and returns
|
|
196
|
+
// exactly what the agent needs — no upfront decision tree, no wasted fetches.
|
|
197
|
+
server.registerResource(
|
|
198
|
+
'start',
|
|
199
|
+
'pulse://start',
|
|
200
|
+
{
|
|
201
|
+
title: 'Pulse Start — Context-Aware Entry Point',
|
|
202
|
+
description: 'Single entry point that returns the right resources for your situation: new project, new page, targeted edit, or bug fix. Fetch this first instead of deciding between pulse://workflow and pulse://quickstart.',
|
|
203
|
+
mimeType: 'text/plain',
|
|
204
|
+
},
|
|
205
|
+
async () => {
|
|
206
|
+
// Detect project state to tailor the response
|
|
207
|
+
let pageCount = 0
|
|
208
|
+
try {
|
|
209
|
+
const specs = await loadPages(ROOT)
|
|
210
|
+
pageCount = specs.length
|
|
211
|
+
} catch { /* fresh project */ }
|
|
212
|
+
|
|
213
|
+
const isNewProject = pageCount === 0
|
|
214
|
+
|
|
215
|
+
return ({
|
|
216
|
+
contents: [{
|
|
217
|
+
uri: 'pulse://start',
|
|
218
|
+
mimeType: 'text/plain',
|
|
219
|
+
text: `# Pulse — Start Here
|
|
220
|
+
|
|
221
|
+
${isNewProject
|
|
222
|
+
? `> **New project detected** — no pages found in src/pages/ yet.`
|
|
223
|
+
: `> **Existing project** — ${pageCount} page${pageCount !== 1 ? 's' : ''} found.`}
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## What are you doing?
|
|
228
|
+
|
|
229
|
+
### A — New page or new site from scratch
|
|
230
|
+
Fetch \`pulse://workflow\` for the full phase/gate sequence, then follow the intake → sketch → intent pipeline.
|
|
231
|
+
|
|
232
|
+
Quick checklist before your first line of code:
|
|
233
|
+
1. Ask the user for design inspiration first (a site, screenshot, or mood board)
|
|
234
|
+
2. Ask: **light or dark?** Pulse renders dark when \`meta.theme\` is unset — decide before writing a single line
|
|
235
|
+
3. Run \`pulse_intake\` → \`pulse_sketch\` → \`pulse_intent\`
|
|
236
|
+
4. Fetch \`pulse://guide/templates\` + \`pulse://guide/design-references\` for aesthetic direction
|
|
237
|
+
|
|
238
|
+
### B — Editing an existing page, adding a section, or fixing a bug
|
|
239
|
+
Fetch \`pulse://quickstart\` — workflow phases, spec skeleton, components, and theming in one fetch.
|
|
240
|
+
|
|
241
|
+
Quick checklist:
|
|
242
|
+
1. Read the file you're changing
|
|
243
|
+
2. Make the edit
|
|
244
|
+
3. \`pulse_validate\` → \`/verify --quick\` (mid-iteration) or \`/verify\` (final check)
|
|
245
|
+
|
|
246
|
+
### C — Targeted one-liner (label change, prop swap, CSS tweak)
|
|
247
|
+
No resource fetch needed. Read the file, make the change, run \`/verify --quick\` to check it looks right, then \`/verify\` when the user is happy.
|
|
248
|
+
|
|
249
|
+
### D — Stuck mid-build
|
|
250
|
+
- Wrong component props? → \`pulse://guide/components\`
|
|
251
|
+
- Server data / store / auth? → \`pulse://guide/server\`
|
|
252
|
+
- CSS / theming? → \`pulse://guide/styles\`
|
|
253
|
+
- Routing or layout? → \`pulse://guide/routing\`
|
|
254
|
+
- Lighthouse < 100? → Fix the failing audit, re-run \`/verify\`
|
|
255
|
+
- Something looks wrong visually? → \`pulse_restart_server\` (hot-reloads specs instantly) then navigate the browser
|
|
256
|
+
|
|
257
|
+
---
|
|
258
|
+
|
|
259
|
+
## Essential rules — memorise these
|
|
260
|
+
|
|
261
|
+
- **\`meta.theme\` defaults to dark.** If the design is light, set \`theme: 'light'\` in the plan — not at screenshot time.
|
|
262
|
+
- **Never set \`hydrate\`** — the framework sets it automatically from the URL entry.
|
|
263
|
+
- **\`meta\` is always a plain object** — individual fields (\`title\`, \`description\`) can be async functions, but \`meta\` itself is \`{}\`.
|
|
264
|
+
- **After every file edit:** \`pulse_restart_server\` → navigate browser → check result. (Hot-reloads specs in ~200 ms — no process restart needed.)
|
|
265
|
+
- **\`/verify\` is always last** — it writes the \`.pulse-verified\` stamp. Without it, the stop hook blocks.
|
|
266
|
+
|
|
267
|
+
---
|
|
268
|
+
|
|
269
|
+
## Tools you'll use most
|
|
270
|
+
|
|
271
|
+
| Tool | When |
|
|
272
|
+
|---|---|
|
|
273
|
+
| \`pulse_intake\` | New project — capture name, pitch, palette, theme, vibe |
|
|
274
|
+
| \`pulse_sketch\` | New project — 3 layout directions before writing code |
|
|
275
|
+
| \`pulse_intent\` | Map a description to archetype + scaffold |
|
|
276
|
+
| \`pulse_suggest\` | **After first draft** — mid-build health check before hard validation |
|
|
277
|
+
| \`pulse_validate\` | After every write |
|
|
278
|
+
| \`pulse_review\` | Final gate after Lighthouse passes |
|
|
279
|
+
| \`pulse_restart_server\` | After every file edit |
|
|
280
|
+
| \`pulse_status\` | Session start — pages, server status, last build |
|
|
281
|
+
`,
|
|
282
|
+
}]
|
|
283
|
+
})
|
|
284
|
+
}
|
|
285
|
+
)
|
|
286
|
+
|
|
191
287
|
// pulse://quickstart — combined essentials for simple/targeted tasks.
|
|
192
288
|
// Reduces cold-start fetch round-trips from 4 (workflow + spec + components + styles)
|
|
193
289
|
// to 1 for edits, bug fixes, and one-shot page builds that don't need the full guides.
|
|
@@ -222,9 +318,15 @@ server.registerResource(
|
|
|
222
318
|
|
|
223
319
|
\`\`\`
|
|
224
320
|
New page: pulse_intake → pulse_sketch → pulse_intent → build → validate → screenshot → design approval → /verify
|
|
225
|
-
Edit/fix: read file → change → validate → /verify
|
|
321
|
+
Edit/fix: read file → change → validate → /verify --quick → (iterate) → /verify
|
|
226
322
|
\`\`\`
|
|
227
323
|
|
|
324
|
+
**\`/verify\` has two modes:**
|
|
325
|
+
- \`/verify --quick\` — validate + screenshot + console + code review. No Lighthouse, no build. Use this during active iteration, between design rounds, or any time you're not yet at the "ready to ship" point. Fast (~10 s).
|
|
326
|
+
- \`/verify\` — full loop: Lighthouse desktop + mobile + perf trace + all of the above. Use once the user has approved the design and you're done building. Slow (~90 s).
|
|
327
|
+
|
|
328
|
+
**Rule: default to \`/verify --quick\` while building. Only run \`/verify\` (full) when the user signals they're done or happy with the result.**
|
|
329
|
+
|
|
228
330
|
Tier 1 (static/read-only): phases 3 → 4 → 5a (design approval) → 5b (/verify)
|
|
229
331
|
Tier 2 (interactive): phases 3 → 4 → 5a → 5b → 6 (tests) → 7 (review)
|
|
230
332
|
|
|
@@ -276,6 +378,43 @@ export default {
|
|
|
276
378
|
|
|
277
379
|
---
|
|
278
380
|
|
|
381
|
+
## Event binding — input patterns
|
|
382
|
+
|
|
383
|
+
**The right pattern for each input type:**
|
|
384
|
+
|
|
385
|
+
| Input type | Pattern | Why |
|
|
386
|
+
|---|---|---|
|
|
387
|
+
| Button click | \`<button data-event="increment">\` | click fires mutation |
|
|
388
|
+
| Select / radio / checkbox | \`<select data-event="change:setFilter">\` | \`change\` fires on blur-with-changed-value — the correct commit semantic |
|
|
389
|
+
| Color picker | \`<input type="color" data-event="change:setColor">\` | same as select |
|
|
390
|
+
| Range slider | \`<input type="range" data-event="input:setVolume">\` | \`input\` fires on every drag step |
|
|
391
|
+
| Text / email / password / search | **No \`data-event\`** — read via FormData | \`data-event\` on text inputs re-renders on every keystroke, destroying cursor position and focus |
|
|
392
|
+
|
|
393
|
+
**Text input pattern (uncontrolled):**
|
|
394
|
+
\`\`\`js
|
|
395
|
+
// view — no data-event on the input
|
|
396
|
+
view: (state) => \`
|
|
397
|
+
<form data-action="submit">
|
|
398
|
+
<input type="text" name="email" placeholder="Email">
|
|
399
|
+
<button type="submit">Save</button>
|
|
400
|
+
</form>
|
|
401
|
+
\`,
|
|
402
|
+
// action — read FormData in onStart (before validation)
|
|
403
|
+
actions: {
|
|
404
|
+
submit: {
|
|
405
|
+
onStart: (state, formData) => ({ status: 'loading', email: formData.get('email') }),
|
|
406
|
+
validate: true,
|
|
407
|
+
run: async (state) => { /* use state.email */ },
|
|
408
|
+
onSuccess: (state) => ({ status: 'success' }),
|
|
409
|
+
onError: (state, err) => ({ status: 'error', error: err.message }),
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
\`\`\`
|
|
413
|
+
|
|
414
|
+
**\`change:\` is the blur/commit event** — it fires when a non-text input loses focus with a changed value. This is correct for selects, color pickers, checkboxes, and radios. It does NOT fire on every keystroke (use \`input:\` for live-updating sliders or search fields where re-render on every character is intentional and the element is not a free-text input).
|
|
415
|
+
|
|
416
|
+
---
|
|
417
|
+
|
|
279
418
|
## Key components (quick reference)
|
|
280
419
|
|
|
281
420
|
Import from \`@invisibleloop/pulse/ui\`. Always include \`/pulse-ui.css\` in \`meta.styles\`.
|
|
@@ -586,8 +725,11 @@ server.registerTool(
|
|
|
586
725
|
return text(`File not found: ${file}`)
|
|
587
726
|
}
|
|
588
727
|
content = fs.readFileSync(file, 'utf8')
|
|
728
|
+
// Validate from the file's own directory so relative imports resolve
|
|
729
|
+
// correctly for pages in subdirectories
|
|
730
|
+
return validateContent(content, path.dirname(file))
|
|
589
731
|
}
|
|
590
|
-
|
|
732
|
+
|
|
591
733
|
return validateContent(content)
|
|
592
734
|
}
|
|
593
735
|
)
|
|
@@ -632,7 +774,9 @@ Rules for the spec you write:
|
|
|
632
774
|
}
|
|
633
775
|
|
|
634
776
|
const content = fs.readFileSync(fullPath, 'utf8')
|
|
635
|
-
|
|
777
|
+
// Validate from the file's own directory so relative imports resolve
|
|
778
|
+
// correctly for pages in subdirectories (src/pages/news/index.js)
|
|
779
|
+
const validation = await validateContent(content, path.dirname(fullPath))
|
|
636
780
|
if (validation.content[0].text.startsWith('Invalid')) return validation
|
|
637
781
|
|
|
638
782
|
const route = derivedRouteFromName(name)
|
|
@@ -788,6 +932,178 @@ ${onStart}${validate ? '\n validate: true,' : ''}
|
|
|
788
932
|
}
|
|
789
933
|
)
|
|
790
934
|
|
|
935
|
+
// ---------------------------------------------------------------------------
|
|
936
|
+
// pulse_create_tests
|
|
937
|
+
// ---------------------------------------------------------------------------
|
|
938
|
+
|
|
939
|
+
server.registerTool(
|
|
940
|
+
'pulse_create_tests',
|
|
941
|
+
{
|
|
942
|
+
description: `Generate a starter test file for a Pulse page spec. Stubs out the formulaic test cases — null data, empty arrays, XSS injection, view landmarks, mutation logic, and onViewError — so you start with coverage scaffolding rather than a blank file.
|
|
943
|
+
|
|
944
|
+
Workflow:
|
|
945
|
+
1. Call this with the spec file path after writing the spec
|
|
946
|
+
2. Review the generated stub file — add real assertions where marked TODO
|
|
947
|
+
3. Run the tests with pulse_run_tests
|
|
948
|
+
4. Fill in any missing assertions until 100% branch coverage passes
|
|
949
|
+
|
|
950
|
+
The generated file uses @invisibleloop/pulse/testing (renderSync / render). Do NOT use raw html.includes() in tests.`,
|
|
951
|
+
inputSchema: {
|
|
952
|
+
file: z.string().describe('Absolute path to the spec file, e.g. /Users/me/project/src/pages/about.js'),
|
|
953
|
+
},
|
|
954
|
+
},
|
|
955
|
+
async ({ file }) => {
|
|
956
|
+
if (!fs.existsSync(file)) {
|
|
957
|
+
return text(`File not found: ${file}`)
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
const source = fs.readFileSync(file, 'utf8')
|
|
961
|
+
const relPath = path.relative(ROOT, file)
|
|
962
|
+
const testFile = file.replace(/\.js$/, '.test.js')
|
|
963
|
+
|
|
964
|
+
if (fs.existsSync(testFile)) {
|
|
965
|
+
return text(`Test file already exists: ${path.relative(ROOT, testFile)}\nDelete it first if you want to regenerate.`)
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// Extract spec details to inform the scaffolding
|
|
969
|
+
const hasMutations = source.includes('mutations:')
|
|
970
|
+
const hasActions = source.includes('actions:')
|
|
971
|
+
const hasServer = source.includes('server:')
|
|
972
|
+
const hasOnViewError = source.includes('onViewError')
|
|
973
|
+
const hasValidation = source.includes('validation:')
|
|
974
|
+
|
|
975
|
+
const mutationNames = [...source.matchAll(/^\s{4}(\w+)\s*:\s*\(state/gm)].map(m => m[1])
|
|
976
|
+
const actionNames = hasActions
|
|
977
|
+
? [...source.matchAll(/^\s{4}(\w+)\s*:\s*\{/gm)].map(m => m[1]).filter(n => n !== 'meta' && n !== 'server' && n !== 'mutations' && n !== 'actions' && n !== 'state' && n !== 'validation' && n !== 'constraints')
|
|
978
|
+
: []
|
|
979
|
+
|
|
980
|
+
const specName = path.basename(file, '.js')
|
|
981
|
+
const importPath = path.relative(path.dirname(testFile), file).replace(/\.js$/, '.js')
|
|
982
|
+
|
|
983
|
+
const lines = []
|
|
984
|
+
lines.push(`import { describe, it } from 'node:test'`)
|
|
985
|
+
lines.push(`import assert from 'node:assert/strict'`)
|
|
986
|
+
lines.push(`import { renderSync${hasServer ? ', render' : ''} } from '@invisibleloop/pulse/testing'`)
|
|
987
|
+
lines.push(`import spec from './${importPath.replace(/^\.\//, '')}'`)
|
|
988
|
+
lines.push(``)
|
|
989
|
+
lines.push(`// ${relPath}`)
|
|
990
|
+
lines.push(``)
|
|
991
|
+
|
|
992
|
+
lines.push(`describe('${specName} — view', () => {`)
|
|
993
|
+
lines.push(` it('renders main landmark', () => {`)
|
|
994
|
+
lines.push(` const r = renderSync(spec${hasServer ? `, { server: {} }` : ''})`)
|
|
995
|
+
lines.push(` assert(r.has('main#main-content'), 'missing <main id="main-content">')`)
|
|
996
|
+
lines.push(` })`)
|
|
997
|
+
lines.push(``)
|
|
998
|
+
|
|
999
|
+
if (hasServer) {
|
|
1000
|
+
lines.push(` it('handles null/empty server data without crashing', () => {`)
|
|
1001
|
+
lines.push(` // Replace null with realistic empty values for each server fetcher`)
|
|
1002
|
+
lines.push(` const r = renderSync(spec, { server: { /* TODO: add fetcher keys with null/[] values */ } })`)
|
|
1003
|
+
lines.push(` assert(r.has('main'))`)
|
|
1004
|
+
lines.push(` })`)
|
|
1005
|
+
lines.push(``)
|
|
1006
|
+
|
|
1007
|
+
lines.push(` it('handles empty array server data', () => {`)
|
|
1008
|
+
lines.push(` // Verify empty-state rendering (empty() component or fallback message)`)
|
|
1009
|
+
lines.push(` const r = renderSync(spec, { server: { /* TODO: fetchers with [] values */ } })`)
|
|
1010
|
+
lines.push(` assert(r.has('main'))`)
|
|
1011
|
+
lines.push(` })`)
|
|
1012
|
+
lines.push(``)
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
lines.push(` it('does not render user input unescaped (XSS)', () => {`)
|
|
1016
|
+
lines.push(` // Replace one user-controlled field with a script tag`)
|
|
1017
|
+
lines.push(` const xss = '<script>alert(1)</script>'`)
|
|
1018
|
+
if (hasServer) {
|
|
1019
|
+
lines.push(` const r = renderSync(spec, { server: { /* TODO: pass xss as a string field */ } })`)
|
|
1020
|
+
} else {
|
|
1021
|
+
lines.push(` const r = renderSync(spec, { state: { /* TODO: pass xss as a string state field */ } })`)
|
|
1022
|
+
}
|
|
1023
|
+
lines.push(` assert(!r.text().includes('<script>'), 'XSS string rendered unescaped')`)
|
|
1024
|
+
lines.push(` })`)
|
|
1025
|
+
lines.push(``)
|
|
1026
|
+
|
|
1027
|
+
if (hasOnViewError) {
|
|
1028
|
+
lines.push(` it('onViewError returns fallback HTML when view throws', () => {`)
|
|
1029
|
+
lines.push(` assert(typeof spec.onViewError, 'function')`)
|
|
1030
|
+
lines.push(` const fallback = spec.onViewError(new Error('test'), spec.state ?? {}, {})`)
|
|
1031
|
+
lines.push(` assert(typeof fallback === 'string' && fallback.length > 0, 'onViewError must return non-empty HTML string')`)
|
|
1032
|
+
lines.push(` })`)
|
|
1033
|
+
lines.push(``)
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
lines.push(`})`)
|
|
1037
|
+
lines.push(``)
|
|
1038
|
+
|
|
1039
|
+
if (hasMutations && mutationNames.length > 0) {
|
|
1040
|
+
lines.push(`describe('${specName} — mutations', () => {`)
|
|
1041
|
+
for (const name of mutationNames.slice(0, 6)) {
|
|
1042
|
+
lines.push(` it('${name} returns partial state', () => {`)
|
|
1043
|
+
lines.push(` assert(typeof spec.mutations.${name}, 'function')`)
|
|
1044
|
+
lines.push(` // TODO: call spec.mutations.${name}(spec.state, mockEvent) and assert the returned shape`)
|
|
1045
|
+
lines.push(` // e.g. const next = spec.mutations.${name}({ ...spec.state }, { target: { value: 'test' } })`)
|
|
1046
|
+
lines.push(` // assert.equal(typeof next, 'object')`)
|
|
1047
|
+
lines.push(` })`)
|
|
1048
|
+
lines.push(``)
|
|
1049
|
+
}
|
|
1050
|
+
lines.push(`})`)
|
|
1051
|
+
lines.push(``)
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
if (hasActions && actionNames.length > 0) {
|
|
1055
|
+
lines.push(`describe('${specName} — actions', () => {`)
|
|
1056
|
+
for (const name of actionNames.slice(0, 4)) {
|
|
1057
|
+
lines.push(` it('${name}.onStart returns loading state', () => {`)
|
|
1058
|
+
lines.push(` if (!spec.actions?.${name}?.onStart) return`)
|
|
1059
|
+
lines.push(` const next = spec.actions.${name}.onStart(spec.state ?? {}, new FormData())`)
|
|
1060
|
+
lines.push(` assert(next.status === 'loading' || typeof next === 'object', 'onStart must return partial state')`)
|
|
1061
|
+
lines.push(` })`)
|
|
1062
|
+
lines.push(``)
|
|
1063
|
+
lines.push(` it('${name}.onError returns error state', () => {`)
|
|
1064
|
+
lines.push(` if (!spec.actions?.${name}?.onError) return`)
|
|
1065
|
+
lines.push(` const next = spec.actions.${name}.onError(spec.state ?? {}, new Error('test'))`)
|
|
1066
|
+
lines.push(` assert(typeof next === 'object', 'onError must return partial state')`)
|
|
1067
|
+
lines.push(` })`)
|
|
1068
|
+
lines.push(``)
|
|
1069
|
+
}
|
|
1070
|
+
lines.push(`})`)
|
|
1071
|
+
lines.push(``)
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
if (hasValidation) {
|
|
1075
|
+
lines.push(`describe('${specName} — validation', () => {`)
|
|
1076
|
+
lines.push(` it('spec has validation rules', () => {`)
|
|
1077
|
+
lines.push(` assert(typeof spec.validation === 'object' && spec.validation !== null)`)
|
|
1078
|
+
lines.push(` assert(Object.keys(spec.validation).length > 0, 'validation object is empty')`)
|
|
1079
|
+
lines.push(` })`)
|
|
1080
|
+
lines.push(`})`)
|
|
1081
|
+
lines.push(``)
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
const testContent = lines.join('\n')
|
|
1085
|
+
fs.writeFileSync(testFile, testContent, 'utf8')
|
|
1086
|
+
|
|
1087
|
+
const summary = [
|
|
1088
|
+
`Generated ${path.relative(ROOT, testFile)}`,
|
|
1089
|
+
``,
|
|
1090
|
+
`Stubs created:`,
|
|
1091
|
+
` ✓ View landmark test`,
|
|
1092
|
+
hasServer ? ` ✓ Null/empty server data tests` : null,
|
|
1093
|
+
` ✓ XSS injection test`,
|
|
1094
|
+
hasOnViewError ? ` ✓ onViewError fallback test` : null,
|
|
1095
|
+
hasMutations && mutationNames.length > 0 ? ` ✓ Mutation tests (${mutationNames.slice(0, 6).join(', ')})` : null,
|
|
1096
|
+
hasActions && actionNames.length > 0 ? ` ✓ Action onStart/onError tests (${actionNames.slice(0, 4).join(', ')})` : null,
|
|
1097
|
+
hasValidation ? ` ✓ Validation structure test` : null,
|
|
1098
|
+
``,
|
|
1099
|
+
`Next: search for "TODO" in the file — each TODO needs a real assertion.`,
|
|
1100
|
+
`Run: pulse_run_tests`,
|
|
1101
|
+
].filter(Boolean).join('\n')
|
|
1102
|
+
|
|
1103
|
+
return text(summary)
|
|
1104
|
+
}
|
|
1105
|
+
)
|
|
1106
|
+
|
|
791
1107
|
// ---------------------------------------------------------------------------
|
|
792
1108
|
// pulse_fetch_page
|
|
793
1109
|
// ---------------------------------------------------------------------------
|
|
@@ -820,7 +1136,7 @@ server.registerTool(
|
|
|
820
1136
|
server.registerTool(
|
|
821
1137
|
'pulse_restart_server',
|
|
822
1138
|
{
|
|
823
|
-
description: '
|
|
1139
|
+
description: 'Reload specs in the running dev server. Tries a fast hot-reload first (no process restart); falls back to full kill/restart only if the server is not running.',
|
|
824
1140
|
inputSchema: {},
|
|
825
1141
|
},
|
|
826
1142
|
async () => {
|
|
@@ -833,15 +1149,32 @@ server.registerTool(
|
|
|
833
1149
|
} catch { /* use default */ }
|
|
834
1150
|
}
|
|
835
1151
|
|
|
836
|
-
//
|
|
1152
|
+
// Fast path — POST to the running dev server's hot-reload endpoint.
|
|
1153
|
+
// This reloads specs in-process and sends an SSE reload event to the browser
|
|
1154
|
+
// without killing the server process (~200 ms vs ~3 s for a full restart).
|
|
1155
|
+
const hotReloaded = await new Promise(resolve => {
|
|
1156
|
+
try {
|
|
1157
|
+
const req = http.request(
|
|
1158
|
+
{ hostname: '127.0.0.1', port, path: '/_pulse/trigger-reload', method: 'POST', timeout: 2000 },
|
|
1159
|
+
res => resolve(res.statusCode === 204)
|
|
1160
|
+
)
|
|
1161
|
+
req.on('error', () => resolve(false))
|
|
1162
|
+
req.on('timeout', () => { req.destroy(); resolve(false) })
|
|
1163
|
+
req.end()
|
|
1164
|
+
} catch { resolve(false) }
|
|
1165
|
+
})
|
|
1166
|
+
|
|
1167
|
+
if (hotReloaded) {
|
|
1168
|
+
return text(`Dev server specs reloaded (hot) on port ${port}`)
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
// Slow path — server not running or unresponsive, do a full kill/restart
|
|
837
1172
|
try { execFileSync('sh', ['-c', `lsof -ti:${port} | xargs kill -9 2>/dev/null; true`]) } catch { /* nothing running */ }
|
|
838
1173
|
|
|
839
|
-
// Start fresh dev server detached so it outlives the MCP tool call
|
|
840
1174
|
const devScript = new URL('../cli/dev.js', import.meta.url).pathname
|
|
841
1175
|
const proc = spawn(process.execPath, [devScript, '--root', ROOT], { detached: true, stdio: 'ignore' })
|
|
842
1176
|
proc.unref()
|
|
843
1177
|
|
|
844
|
-
// Wait until the server is actually accepting requests
|
|
845
1178
|
const ready = await waitForServer(port)
|
|
846
1179
|
return text(ready
|
|
847
1180
|
? `Dev server restarted on port ${port}`
|
|
@@ -908,13 +1241,18 @@ server.registerTool(
|
|
|
908
1241
|
Reads the spec source, renders the view with initial state, runs all validation checks,
|
|
909
1242
|
and returns a structured review brief. You must read everything carefully, find every
|
|
910
1243
|
issue, and fix them all before reporting back to the user. Use this after completing
|
|
911
|
-
any feature build
|
|
1244
|
+
any feature build.
|
|
1245
|
+
|
|
1246
|
+
**Two modes:**
|
|
1247
|
+
- Default (full review): runs after Lighthouse passes — the canonical final gate. Returns full checklist + rendered HTML + validator output.
|
|
1248
|
+
- \`quick: true\`: lightweight mid-build check. Runs structural checks (main landmark, data-event on inputs, missing onError, hex in view, component patterns) without Lighthouse or the full checklist. Use after first draft, before the expensive verification pass. Not a substitute for the full review.`,
|
|
912
1249
|
inputSchema: {
|
|
913
|
-
file:
|
|
1250
|
+
file: z.string().optional().describe('Absolute path to the spec file to review'),
|
|
914
1251
|
content: z.string().optional().describe('JavaScript spec content to review (alternative to file)'),
|
|
1252
|
+
quick: z.boolean().optional().describe('Run a lightweight mid-build check instead of the full review gate. Catches obvious structural issues without running Lighthouse. Use after first draft, before /verify.'),
|
|
915
1253
|
},
|
|
916
1254
|
},
|
|
917
|
-
async ({ file, content }) => {
|
|
1255
|
+
async ({ file, content, quick = false }) => {
|
|
918
1256
|
if (!file && !content) {
|
|
919
1257
|
return text('Error: must provide either file or content')
|
|
920
1258
|
}
|
|
@@ -924,10 +1262,119 @@ any feature build.`,
|
|
|
924
1262
|
|
|
925
1263
|
let source = content
|
|
926
1264
|
if (file) {
|
|
1265
|
+
if (!path.isAbsolute(file)) file = path.resolve(process.cwd(), file)
|
|
927
1266
|
if (!fs.existsSync(file)) return text(`File not found: ${file}`)
|
|
928
1267
|
source = fs.readFileSync(file, 'utf8')
|
|
929
1268
|
}
|
|
930
1269
|
|
|
1270
|
+
// Quick mode — lightweight structural checks only, no full checklist or Lighthouse gate
|
|
1271
|
+
if (quick) {
|
|
1272
|
+
const issues = []
|
|
1273
|
+
const warnings = []
|
|
1274
|
+
|
|
1275
|
+
// Try to render the view
|
|
1276
|
+
let renderedHtml = ''
|
|
1277
|
+
if (file) {
|
|
1278
|
+
try {
|
|
1279
|
+
const mod = await import(`${file}?quick=${Date.now()}`)
|
|
1280
|
+
const spec = mod.default
|
|
1281
|
+
if (spec && typeof spec.view === 'function') {
|
|
1282
|
+
renderedHtml = spec.view(spec.state || {}, {})
|
|
1283
|
+
}
|
|
1284
|
+
} catch { /* ignore — server data dependency */ }
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
if (renderedHtml) {
|
|
1288
|
+
if (!/<main[^>]*id=["']?main-content/.test(renderedHtml))
|
|
1289
|
+
issues.push('✗ Missing `<main id="main-content">` landmark')
|
|
1290
|
+
if (/<input[^>]*data-event/.test(renderedHtml))
|
|
1291
|
+
issues.push('✗ `data-event` on `<input>` — destroys focus on every keystroke, use FormData in onStart instead')
|
|
1292
|
+
if (/(className|htmlFor|onClick)=/.test(renderedHtml))
|
|
1293
|
+
issues.push('✗ React patterns found (className / htmlFor / onClick) — use class, for, data-event')
|
|
1294
|
+
if (/tabindex=["']?([1-9]\d*)/.test(renderedHtml))
|
|
1295
|
+
issues.push('✗ Positive tabindex found — remove, reorder DOM instead')
|
|
1296
|
+
const rawNavInSource = (source.match(/<nav[\s>]/gi) || []).length
|
|
1297
|
+
if (rawNavInSource > 0)
|
|
1298
|
+
issues.push(`✗ Raw <nav> tag in spec source — nav() renders <nav> internally; wrapping it in another <nav> creates duplicate landmarks that fail Lighthouse accessibility. Remove the outer <nav>.`)
|
|
1299
|
+
const viewBlockStripped = renderedHtml
|
|
1300
|
+
.replace(/href=["']#[^"']*["']/g, 'href="#"')
|
|
1301
|
+
.replace(/id=["'][^"']*["']/g, 'id=""')
|
|
1302
|
+
// Strip CSS custom property assignments from style attrs (--foo:#hex is a runtime
|
|
1303
|
+
// data binding, not an authored colour — e.g. style="--swatch-bg:#E8524A").
|
|
1304
|
+
// Standard property assignments like color:#000 are left intact and still flagged.
|
|
1305
|
+
.replace(/style="([^"]*)"/g, (_, v) => `style="${v.replace(/--[a-zA-Z][^:]*:[^;"]*/g, '')}"`)
|
|
1306
|
+
if (/#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3}\b(?![0-9a-fA-F])/.test(viewBlockStripped))
|
|
1307
|
+
warnings.push('⚠ Possible hex colour in rendered HTML — use var(--ui-*) tokens')
|
|
1308
|
+
|
|
1309
|
+
// Component pattern checks (unless creative override declared)
|
|
1310
|
+
if (!/component.free|creative\s+override/i.test(source)) {
|
|
1311
|
+
if (/<[^>]+class="[^"]*\bhero\b/.test(renderedHtml) && !source.includes('hero('))
|
|
1312
|
+
warnings.push('⚠ `.hero` class in HTML but no `hero()` component — use the component or declare creative override')
|
|
1313
|
+
if (/<[^>]+class="[^"]*\bcard\b/.test(renderedHtml) && !source.includes('card('))
|
|
1314
|
+
warnings.push('⚠ `.card` class in HTML but no `card()` component — use the component or declare creative override')
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
// Source-level checks (don't need rendered HTML).
|
|
1319
|
+
// Use line-anchored regex for actions: to avoid false positives from component
|
|
1320
|
+
// prop strings like hero({ actions: '...' }) or card({ actions: '...' }).
|
|
1321
|
+
const hasActionsBlock = /^\s{0,4}actions\s*:/m.test(source)
|
|
1322
|
+
if (hasActionsBlock && !source.includes('onError:'))
|
|
1323
|
+
issues.push('✗ Action missing `onError` — required, will throw at runtime')
|
|
1324
|
+
if (hasActionsBlock && !source.includes('onSuccess:'))
|
|
1325
|
+
issues.push('✗ Action missing `onSuccess` — required')
|
|
1326
|
+
if (source.includes('hydrate:'))
|
|
1327
|
+
issues.push('✗ `hydrate` is set manually — remove it, the framework sets it automatically')
|
|
1328
|
+
if (/meta\s*:\s*async/.test(source))
|
|
1329
|
+
issues.push('✗ `meta` is an async function — `meta` must be a plain object; make individual fields async instead')
|
|
1330
|
+
|
|
1331
|
+
// Structural confirmations — always shown regardless of issues, so the agent
|
|
1332
|
+
// gets positive signal on what's correct, not just a list of failures.
|
|
1333
|
+
const confirms = []
|
|
1334
|
+
if (renderedHtml) {
|
|
1335
|
+
confirms.push(/<main[^>]*id=["']?main-content/.test(renderedHtml) ? '✓ `<main id="main-content">` landmark present' : null)
|
|
1336
|
+
const rawNavInSource = (source.match(/<nav[\s>]/gi) || []).length
|
|
1337
|
+
if (rawNavInSource === 0) confirms.push('✓ No raw <nav> in spec source — nav() component handles landmarks correctly')
|
|
1338
|
+
const headings = [...renderedHtml.matchAll(/<h([1-6])[^>]*>/g)].map(m => parseInt(m[1]))
|
|
1339
|
+
if (headings.length > 0) {
|
|
1340
|
+
const orderOk = headings.every((h, i) => i === 0 || h <= headings[i - 1] + 1)
|
|
1341
|
+
confirms.push(orderOk ? `✓ Heading order correct (${headings.map(h => `h${h}`).join(' → ')})` : `⚠ Heading order may be skipping levels (${headings.map(h => `h${h}`).join(' → ')})`)
|
|
1342
|
+
}
|
|
1343
|
+
const interactiveWithoutLabel = [...renderedHtml.matchAll(/<button(?![^>]*aria-label)[^>]*>\s*<\/button>/g)]
|
|
1344
|
+
confirms.push(interactiveWithoutLabel.length === 0 ? '✓ No empty buttons without aria-label detected' : `⚠ ${interactiveWithoutLabel.length} button(s) appear empty — check aria-label`)
|
|
1345
|
+
const dataEvents = [...renderedHtml.matchAll(/data-event=/g)].length
|
|
1346
|
+
if (dataEvents > 0) confirms.push(`✓ ${dataEvents} data-event binding(s) found`)
|
|
1347
|
+
const creativeOverride = /component.free|creative\s+override/i.test(source)
|
|
1348
|
+
if (creativeOverride) confirms.push('✓ Creative override declared — component pattern checks are advisory')
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
const lines = ['## Quick review\n']
|
|
1352
|
+
|
|
1353
|
+
if (confirms.filter(Boolean).length > 0) {
|
|
1354
|
+
lines.push('### Structural checks\n')
|
|
1355
|
+
for (const c of confirms.filter(Boolean)) lines.push(c)
|
|
1356
|
+
lines.push('')
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
if (issues.length === 0 && warnings.length === 0) {
|
|
1360
|
+
lines.push('✓ No issues found. Run `pulse_validate` next, then `/verify`.')
|
|
1361
|
+
} else {
|
|
1362
|
+
if (issues.length) {
|
|
1363
|
+
lines.push('### Fix before proceeding\n')
|
|
1364
|
+
for (const i of issues) lines.push(i)
|
|
1365
|
+
lines.push('')
|
|
1366
|
+
}
|
|
1367
|
+
if (warnings.length) {
|
|
1368
|
+
lines.push('### Warnings (check these)\n')
|
|
1369
|
+
for (const w of warnings) lines.push(w)
|
|
1370
|
+
lines.push('')
|
|
1371
|
+
}
|
|
1372
|
+
lines.push('---\nFix issues, then run `pulse_validate` → `/verify --quick`.')
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
return text(lines.join('\n'))
|
|
1376
|
+
}
|
|
1377
|
+
|
|
931
1378
|
// Run the validator in a child process (same as pulse_validate)
|
|
932
1379
|
let validationResult = '(could not run validator)'
|
|
933
1380
|
if (file) {
|
|
@@ -1043,13 +1490,14 @@ ${(() => {
|
|
|
1043
1490
|
}
|
|
1044
1491
|
|
|
1045
1492
|
// Check spec source
|
|
1046
|
-
// Hex colours in view — strip anchor hrefs
|
|
1047
|
-
//
|
|
1048
|
-
// Only flag
|
|
1493
|
+
// Hex colours in view — strip anchor hrefs, id attributes, template literal expressions,
|
|
1494
|
+
// and style attributes before checking so runtime-generated values (e.g. style="--swatch-bg:${hex}")
|
|
1495
|
+
// are never flagged. Only flag literal hex values authored directly in the view string.
|
|
1049
1496
|
const viewBlock = source.slice(source.indexOf('view:'))
|
|
1050
1497
|
const viewBlockStripped = viewBlock
|
|
1051
1498
|
.replace(/href=["']#[^"']*["']/g, 'href="#"') // href="#anchor" → href="#"
|
|
1052
1499
|
.replace(/id=["'][^"']*["']/g, 'id=""') // id="foo" → id=""
|
|
1500
|
+
.replace(/\$\{[^}]*\}/g, '${…}') // ${expr} — runtime values, not authored hex
|
|
1053
1501
|
const hexInView = /#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3}\b(?![0-9a-fA-F])/.test(viewBlockStripped)
|
|
1054
1502
|
if (hexInView) {
|
|
1055
1503
|
checks.push('⚠ **Possible hex colour in view** — use var(--ui-*) tokens only')
|
|
@@ -1210,13 +1658,16 @@ The stop hook compares each changed spec's mtime against this stamp. Any spec ne
|
|
|
1210
1658
|
server.registerTool(
|
|
1211
1659
|
'pulse_await_approval',
|
|
1212
1660
|
{
|
|
1213
|
-
description: `Pause the workflow
|
|
1661
|
+
description: `Pause the workflow gates for one turn-end — call this immediately BEFORE ending a turn that legitimately waits on the user. That covers:
|
|
1214
1662
|
|
|
1215
|
-
|
|
1663
|
+
- **Blocking questions** — the design-approval gate ("Happy with the layout and direction…?"), or any decision only the user can make.
|
|
1664
|
+
- **Mid-flow progress pauses** — reporting between long verification steps (e.g. between Lighthouse runs while verifying many pages) when the work is not yet stamped.
|
|
1216
1665
|
|
|
1217
|
-
|
|
1666
|
+
It writes a .pulse-awaiting-approval marker that tells the Stop hooks (missing tests, coverage, verify stamp) to let the 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, so re-call it before each pause in a long multi-page flow.
|
|
1218
1667
|
|
|
1219
|
-
|
|
1668
|
+
**Always call this before an approval question, regardless of how you ask.** In some hosts (including Claude Code) even a dedicated question tool like AskUserQuestion ends the turn, which fires the Stop hooks. The marker is harmless if the turn does not end — it is simply consumed when the user replies.
|
|
1669
|
+
|
|
1670
|
+
Do NOT use this to skip verification — it buys exactly one turn-end, paired with something the user genuinely needs to see or answer. Calling it to dodge the gates just delays them by one message.`,
|
|
1220
1671
|
inputSchema: {},
|
|
1221
1672
|
},
|
|
1222
1673
|
() => {
|
|
@@ -1793,6 +2244,15 @@ If your page genuinely doesn't fit a pattern, start from pulse://guide/spec and
|
|
|
1793
2244
|
lines.push(`State: ${arch.stateHint}`)
|
|
1794
2245
|
lines.push(`Server: ${arch.serverHint}`)
|
|
1795
2246
|
|
|
2247
|
+
lines.push(``)
|
|
2248
|
+
lines.push(`## ⚠ Before writing any code — ask these two questions`)
|
|
2249
|
+
lines.push(``)
|
|
2250
|
+
lines.push(`1. **Light or dark?** Pulse renders **dark** by default when \`meta.theme\` is unset. If the user hasn't stated a preference, ask now — discovering the wrong theme at the screenshot costs a full edit → restart → re-approval cycle.`)
|
|
2251
|
+
lines.push(` Add \`theme: 'light'\` to your plan or brief if confirmed light.`)
|
|
2252
|
+
lines.push(``)
|
|
2253
|
+
lines.push(`2. **Design inspiration?** Ask: "Do you have any design inspiration — a site you love, a screenshot, or a mood board image? Drop it into \`public/intake/\` or paste a URL." If yes, call \`pulse_extract_inspiration\` before proceeding.`)
|
|
2254
|
+
lines.push(` (Skip if this came from pulse_intake — it already asked.)`)
|
|
2255
|
+
|
|
1796
2256
|
lines.push(``)
|
|
1797
2257
|
lines.push(`## Guide sections to read`)
|
|
1798
2258
|
lines.push(``)
|
|
@@ -2313,7 +2773,9 @@ The extracted values map directly to pulse_intake fields (palette, vibe, styleNo
|
|
|
2313
2773
|
lines.push(`Navigate to: **${source}**`)
|
|
2314
2774
|
lines.push('Observe the rendered page at desktop width (~1440px) and again at mobile (~375px).')
|
|
2315
2775
|
lines.push('Take note of the above-the-fold area, then scroll through the full page.')
|
|
2316
|
-
lines.push('**Fill in the sections below based on what you observe. Return a completed extraction, not an empty template
|
|
2776
|
+
lines.push('**Fill in the sections below based on what you observe. Return a completed extraction, not an empty template.**')
|
|
2777
|
+
lines.push('')
|
|
2778
|
+
lines.push('> **If the URL is blocked or unreachable:** do not return a blank template. Instead, use your training knowledge of the site/brand to populate as many fields as you can, mark each cell with "(from knowledge, not live)" and note at the top that the URL was unreachable. A knowledge-based extraction is more useful than an empty template.\n')
|
|
2317
2779
|
} else {
|
|
2318
2780
|
lines.push('## Step 1 — Analyse the source (do this now)')
|
|
2319
2781
|
lines.push(`Source: **${source}**`)
|
|
@@ -3380,7 +3842,7 @@ const PROP_ALIASES = [
|
|
|
3380
3842
|
{ component: 'input()', wrong: 'pattern', correct: 'attrs: { pattern }', note: 'HTML attributes go inside `attrs`: input({ attrs: { pattern: "[0-9]+" } }).' },
|
|
3381
3843
|
]
|
|
3382
3844
|
|
|
3383
|
-
async function validateContent(content) {
|
|
3845
|
+
async function validateContent(content, tmpDir = PAGES_DIR) {
|
|
3384
3846
|
// Source-level checks — run before the worker so errors are caught without importing
|
|
3385
3847
|
const sourceWarnings = []
|
|
3386
3848
|
|
|
@@ -3388,9 +3850,17 @@ async function validateContent(content) {
|
|
|
3388
3850
|
for (const { component, wrong, correct, note } of PROP_ALIASES) {
|
|
3389
3851
|
// Match: nav({ ...wrong: or footer({ ...wrong: (within the call args)
|
|
3390
3852
|
const fnName = component.replace('()', '')
|
|
3391
|
-
const re = new RegExp(`${fnName}\\s*\\(\\s*\\{[^}]
|
|
3392
|
-
|
|
3853
|
+
const re = new RegExp(`${fnName}\\s*\\(\\s*\\{([^}]*)\\b${wrong}\\s*:`, 'g')
|
|
3854
|
+
let m
|
|
3855
|
+
while ((m = re.exec(content)) !== null) {
|
|
3856
|
+
// Inside attrs: { … } is the CORRECT placement for these HTML attributes —
|
|
3857
|
+
// when the text between the call's opening brace and the prop ends inside
|
|
3858
|
+
// an open attrs object, this is the recommended pattern, not a mistake.
|
|
3859
|
+
// (The naive match crossed into attrs blocks and flagged exactly what the
|
|
3860
|
+
// fix-it note tells people to write.)
|
|
3861
|
+
if (/attrs\s*:\s*\{[^}]*$/.test(m[1])) continue
|
|
3393
3862
|
sourceWarnings.push(`"${wrong}" is not a recognised prop for ${component} — did you mean "${correct}"? ${note}`)
|
|
3863
|
+
break
|
|
3394
3864
|
}
|
|
3395
3865
|
}
|
|
3396
3866
|
|
|
@@ -3399,24 +3869,28 @@ async function validateContent(content) {
|
|
|
3399
3869
|
const externalImgHosts = [
|
|
3400
3870
|
{
|
|
3401
3871
|
pattern: /https?:\/\/images\.unsplash\.com/,
|
|
3872
|
+
host: 'images.unsplash.com',
|
|
3402
3873
|
entry: 'https://images.unsplash.com',
|
|
3403
3874
|
cookieWarning: true,
|
|
3404
3875
|
name: 'Unsplash',
|
|
3405
3876
|
},
|
|
3406
3877
|
{
|
|
3407
3878
|
pattern: /https?:\/\/(?:fastly\.)?picsum\.photos/,
|
|
3879
|
+
host: 'picsum.photos',
|
|
3408
3880
|
entry: 'https://picsum.photos https://fastly.picsum.photos',
|
|
3409
3881
|
cookieWarning: false,
|
|
3410
3882
|
name: 'picsum',
|
|
3411
3883
|
},
|
|
3412
3884
|
{
|
|
3413
3885
|
pattern: /https?:\/\/res\.cloudinary\.com/,
|
|
3886
|
+
host: 'res.cloudinary.com',
|
|
3414
3887
|
entry: 'https://res.cloudinary.com',
|
|
3415
3888
|
cookieWarning: true,
|
|
3416
3889
|
name: 'Cloudinary',
|
|
3417
3890
|
},
|
|
3418
3891
|
{
|
|
3419
3892
|
pattern: /https?:\/\/cdn\.shopify\.com/,
|
|
3893
|
+
host: 'cdn.shopify.com',
|
|
3420
3894
|
entry: 'https://cdn.shopify.com',
|
|
3421
3895
|
cookieWarning: false,
|
|
3422
3896
|
name: 'Shopify CDN',
|
|
@@ -3427,8 +3901,11 @@ async function validateContent(content) {
|
|
|
3427
3901
|
if (fs.existsSync(configPath)) {
|
|
3428
3902
|
try { configImgSrc = fs.readFileSync(configPath, 'utf8') } catch { /* ignore */ }
|
|
3429
3903
|
}
|
|
3430
|
-
for (const { pattern, entry, cookieWarning, name } of externalImgHosts) {
|
|
3431
|
-
|
|
3904
|
+
for (const { pattern, host, entry, cookieWarning, name } of externalImgHosts) {
|
|
3905
|
+
// Config match is by HOST, not full URL — CSP sources are valid without a
|
|
3906
|
+
// scheme ('images.unsplash.com'), so requiring https?:// in the config
|
|
3907
|
+
// produced false positives for correctly configured projects.
|
|
3908
|
+
if (pattern.test(content) && !configImgSrc.includes(host)) {
|
|
3432
3909
|
const cookieNote = cookieWarning
|
|
3433
3910
|
? `\n ⚠ ${name} sets tracking cookies that fail Lighthouse Best Practices regardless of CSP. For production, download images to public/images/ instead of linking to ${name} directly.`
|
|
3434
3911
|
: ''
|
|
@@ -3440,9 +3917,12 @@ async function validateContent(content) {
|
|
|
3440
3917
|
}
|
|
3441
3918
|
}
|
|
3442
3919
|
|
|
3443
|
-
// Write into
|
|
3444
|
-
|
|
3445
|
-
|
|
3920
|
+
// Write the temp file into the directory the spec actually lives in (passed by
|
|
3921
|
+
// callers that know it) so relative imports resolve from the true location.
|
|
3922
|
+
// A page at src/pages/news/index.js importing '../../components/layout.js'
|
|
3923
|
+
// resolved wrongly when the temp file was always dropped in src/pages/ root.
|
|
3924
|
+
fs.mkdirSync(tmpDir, { recursive: true })
|
|
3925
|
+
const tmpFile = path.join(tmpDir, `.pulse-validate-${Date.now()}.mjs`)
|
|
3446
3926
|
try {
|
|
3447
3927
|
fs.writeFileSync(tmpFile, content, 'utf8')
|
|
3448
3928
|
|
|
@@ -3547,12 +4027,17 @@ const PULSE_GUIDE_INDEX = `# Pulse Framework Guide
|
|
|
3547
4027
|
|
|
3548
4028
|
## Start here
|
|
3549
4029
|
|
|
3550
|
-
**Fetch \`pulse://
|
|
4030
|
+
**Fetch \`pulse://start\` first.** It detects your context (new project, new page, edit, or bug fix) and returns exactly what you need — no upfront decision tree.
|
|
4031
|
+
|
|
4032
|
+
If you know your context already:
|
|
4033
|
+
- New page / new site → \`pulse://workflow\`
|
|
4034
|
+
- Edit, fix, or one-shot build → \`pulse://quickstart\`
|
|
3551
4035
|
|
|
3552
4036
|
## Guide resources
|
|
3553
4037
|
|
|
3554
4038
|
| Resource | When to fetch |
|
|
3555
4039
|
|---|---|
|
|
4040
|
+
| \`pulse://start\` | **Always fetch first** — context-aware entry point that routes you to the right resources |
|
|
3556
4041
|
| \`pulse://quickstart\` | **Editing, bug-fixing, or a simple one-shot build** — workflow + spec skeleton + components + theming in one fetch. Skip the 4-resource cold-start for targeted tasks. |
|
|
3557
4042
|
| \`pulse://workflow\` | **New project or new page** — full phase/gate sequence before writing any code. |
|
|
3558
4043
|
| \`pulse://guide/spec\` | Building a spec — state, mutations, actions, streaming SSR, key rules, form layout |
|
|
@@ -3573,14 +4058,15 @@ const PULSE_GUIDE_INDEX = `# Pulse Framework Guide
|
|
|
3573
4058
|
- \`pulse_intake(name, pitch, features, targetUser?, palette?, font?, theme?, vibe?, styleNotes?, antiStyle?, inspiration?)\` — **Capture product details before scaffolding.** Run this first for any new project or branded template — before pulse_sketch or pulse_intent. **Gather answers by asking the user one free-form question at a time — never use multi-choice lists for open-ended intake questions.** The final intake question must always be: "Do you have any design inspiration — a site you love, a screenshot, or a mood board? Drop images into \`public/intake/\` or share a URL." If the user provides references, call \`pulse_extract_inspiration\` before proceeding. After intake, call pulse_sketch to explore structural directions before writing code.
|
|
3574
4059
|
- \`pulse_sketch(brief, vibe?, antiStyle?, pageType?)\` — **Generate 3 structurally distinct layout directions before writing any code.** Call after pulse_intake. Returns three named directions (full-bleed, asymmetric split, typography-only, editorial flow, dense grid, story scroll, content-first) with wireframes, key decisions, and component strategies. Prevents defaulting to "centred hero + three columns" on every project. After choosing a direction, fetch \`pulse://guide/explore\` for raw HTML patterns.
|
|
3575
4060
|
- \`pulse_intent(description)\` — Describe what you want to build in plain language and get back a matched archetype, component recommendations, a ready-to-adapt spec scaffold, and which guides to read. Use after pulse_intake and pulse_sketch, before fetching guides.
|
|
3576
|
-
- \`pulse_suggest(content)\` — **Draft-mode feedback.** Paste a partial or complete spec and get constructive, non-blocking suggestions: missing pieces, likely omissions, component upgrades, empty-state reminders. A collaborator, not a gate. Use
|
|
4061
|
+
- \`pulse_suggest(content)\` — **Draft-mode feedback.** Paste a partial or complete spec and get constructive, non-blocking suggestions: missing pieces, likely omissions, component upgrades, empty-state reminders. A collaborator, not a gate. **Use after first draft** (after writing, before hard validation) — not just when something feels wrong.
|
|
4062
|
+
- \`pulse_create_tests(file)\` — **Generate a starter test file** for a spec. Stubs out the formulaic cases: null data, empty arrays, XSS injection, view landmarks, mutations, action onStart/onError, and onViewError. Search for "TODO" in the output and fill in real assertions. Covers 100% branch coverage requirements without hand-writing the boilerplate.
|
|
3577
4063
|
- \`pulse_list_icons(filter?)\` — **List all available icon names grouped by category.** Always call this before importing icons — never guess a name or grep source files. Optional filter keyword narrows results (e.g. filter: "arrow").
|
|
3578
4064
|
- \`pulse_check_contrast(css?, file?, theme?)\` — **Static WCAG contrast check.** Provide theme CSS content or a file path; it checks all token color pairings against WCAG AA thresholds (4.5:1 normal text, 3:1 large text/UI). Run immediately after writing a theme file — before pulse_build and Lighthouse. Catches palette mistakes in milliseconds.
|
|
3579
4065
|
- \`pulse_tokens\` — **List all --ui-* CSS tokens** from the installed pulse-ui.css, grouped by category (colour, spacing, type-size, typography, shape). Call before writing theme overrides to avoid guessing names. Reminder: set \`--accent\` (no prefix) in theme.css — pulse-ui maps it to \`--ui-accent\` automatically.
|
|
3580
4066
|
- \`pulse_status\` — **Project health snapshot.** Returns page count, routes, dev server status, last build age, and pulse-ui version check. Call at the start of a session to orient quickly without reading files.
|
|
3581
4067
|
- \`pulse_list_structure\` — list pages, components, and pulse-ui version. Call at the start of every session.
|
|
3582
4068
|
- \`pulse_validate\` — validate spec content. Call after every write. Fix all errors AND warnings.
|
|
3583
|
-
- \`pulse_review\` —
|
|
4069
|
+
- \`pulse_review(file, { quick? })\` — **Two modes.** Default: full review gate (call after Lighthouse + tests pass). Pass \`quick: true\` for a lightweight mid-build structural check — no Lighthouse, just catches obvious issues (missing main landmark, data-event on inputs, missing onError, hex colours, component pattern violations). Use quick mid-build after \`pulse_suggest\`. **Full mode only after validate, Lighthouse (desktop + mobile), and tests all pass.**
|
|
3584
4070
|
- \`pulse_create_page\` — validate a page spec you already wrote to disk. **Always write the file with the Write tool first, then call this.** Never pass content to this tool.
|
|
3585
4071
|
- \`pulse_create_component\` — register a component you wrote with the Write tool. Write the file first, then call this.
|
|
3586
4072
|
- \`pulse_create_store\` — register a pulse.store.js you wrote with the Write tool. Write the file first, then call this.
|
|
@@ -3588,7 +4074,7 @@ const PULSE_GUIDE_INDEX = `# Pulse Framework Guide
|
|
|
3588
4074
|
- \`pulse_run_tests\` — run the project test suite (npm test). Use after writing or editing specs.
|
|
3589
4075
|
- \`pulse_stamp\` — write the \`.pulse-verified\` stamp. Call as the **last step** of \`/verify\`, after Lighthouse and \`pulse_review\` both pass. Using this MCP tool avoids the mtime race that \`date +%s > .pulse-verified\` can cause when the stamp and the last spec write land in the same filesystem second.
|
|
3590
4076
|
- \`pulse_fetch_page(url)\` — HTTP GET the dev server URL. Use to verify SSR output.
|
|
3591
|
-
- \`pulse_restart_server\` —
|
|
4077
|
+
- \`pulse_restart_server\` — hot-reload specs in the running dev server (~200 ms). Falls back to full kill/restart if the server is not running.
|
|
3592
4078
|
- \`pulse_build\` — production build + starts prod server on devPort+1 for Lighthouse. Returns the URL. Call \`pulse_restart_server\` after to return to dev. **Slow — takes 30–60 s. Tell the user before calling.**
|
|
3593
4079
|
- \`pulse_check_version\` — check installed package version, static asset version, and latest on npm. Use this instead of running npm commands when the user asks about updates.
|
|
3594
4080
|
- \`pulse_update\` — install the latest \`@invisibleloop/pulse\` package and re-copy \`pulse-ui.css\`, \`pulse-ui.js\`, and the agent checklist into \`public/\`. One command does the full upgrade.
|
|
@@ -3604,9 +4090,13 @@ const PULSE_GUIDE_INDEX = `# Pulse Framework Guide
|
|
|
3604
4090
|
|
|
3605
4091
|
## MANDATORY: Verify every build
|
|
3606
4092
|
|
|
3607
|
-
**After writing or editing any page spec, you MUST run \`/verify\` before declaring done.**
|
|
4093
|
+
**After writing or editing any page spec, you MUST run \`/verify\` or \`/verify --quick\` before declaring done.**
|
|
4094
|
+
|
|
4095
|
+
**Choose the right mode:**
|
|
4096
|
+
- \`/verify --quick\` — use during active building and iteration. Runs: validate → screenshot → console check → code review → stamp. No Lighthouse, no production build. Fast (~10 s). Use this between every round of changes.
|
|
4097
|
+
- \`/verify\` (full) — use when the user signals they're done or happy with the result. Runs everything: validate → screenshot → production build → Lighthouse desktop → Lighthouse mobile → performance trace → console check → code review → stamp. Slow (~90 s). Run this once at the end.
|
|
3608
4098
|
|
|
3609
|
-
|
|
4099
|
+
The stop hook checks the \`.pulse-verified\` stamp — \`/verify --quick\` writes the stamp too, so it satisfies the hook for mid-build turns. The full \`/verify\` is required before the final commit.
|
|
3610
4100
|
|
|
3611
4101
|
**Do not replicate \`/verify\` steps manually.** Running the individual tools yourself does not write the stamp. The agent will be blocked at the end regardless.
|
|
3612
4102
|
|