@invisibleloop/pulse 0.20.1 → 0.20.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/pulse-start.md +11 -0
- package/.claude/commands/pulse-stop.md +5 -0
- 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/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/coverage-check.js +154 -90
- package/src/agent/workflow.md +16 -2
- package/src/cli/cli.test.js +72 -0
- package/src/cli/dev.js +6 -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 +406 -21
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\`
|
|
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\`.
|
|
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\` 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.
|
|
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.
|
|
@@ -586,8 +682,11 @@ server.registerTool(
|
|
|
586
682
|
return text(`File not found: ${file}`)
|
|
587
683
|
}
|
|
588
684
|
content = fs.readFileSync(file, 'utf8')
|
|
685
|
+
// Validate from the file's own directory so relative imports resolve
|
|
686
|
+
// correctly for pages in subdirectories
|
|
687
|
+
return validateContent(content, path.dirname(file))
|
|
589
688
|
}
|
|
590
|
-
|
|
689
|
+
|
|
591
690
|
return validateContent(content)
|
|
592
691
|
}
|
|
593
692
|
)
|
|
@@ -632,7 +731,9 @@ Rules for the spec you write:
|
|
|
632
731
|
}
|
|
633
732
|
|
|
634
733
|
const content = fs.readFileSync(fullPath, 'utf8')
|
|
635
|
-
|
|
734
|
+
// Validate from the file's own directory so relative imports resolve
|
|
735
|
+
// correctly for pages in subdirectories (src/pages/news/index.js)
|
|
736
|
+
const validation = await validateContent(content, path.dirname(fullPath))
|
|
636
737
|
if (validation.content[0].text.startsWith('Invalid')) return validation
|
|
637
738
|
|
|
638
739
|
const route = derivedRouteFromName(name)
|
|
@@ -788,6 +889,178 @@ ${onStart}${validate ? '\n validate: true,' : ''}
|
|
|
788
889
|
}
|
|
789
890
|
)
|
|
790
891
|
|
|
892
|
+
// ---------------------------------------------------------------------------
|
|
893
|
+
// pulse_create_tests
|
|
894
|
+
// ---------------------------------------------------------------------------
|
|
895
|
+
|
|
896
|
+
server.registerTool(
|
|
897
|
+
'pulse_create_tests',
|
|
898
|
+
{
|
|
899
|
+
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.
|
|
900
|
+
|
|
901
|
+
Workflow:
|
|
902
|
+
1. Call this with the spec file path after writing the spec
|
|
903
|
+
2. Review the generated stub file — add real assertions where marked TODO
|
|
904
|
+
3. Run the tests with pulse_run_tests
|
|
905
|
+
4. Fill in any missing assertions until 100% branch coverage passes
|
|
906
|
+
|
|
907
|
+
The generated file uses @invisibleloop/pulse/testing (renderSync / render). Do NOT use raw html.includes() in tests.`,
|
|
908
|
+
inputSchema: {
|
|
909
|
+
file: z.string().describe('Absolute path to the spec file, e.g. /Users/me/project/src/pages/about.js'),
|
|
910
|
+
},
|
|
911
|
+
},
|
|
912
|
+
async ({ file }) => {
|
|
913
|
+
if (!fs.existsSync(file)) {
|
|
914
|
+
return text(`File not found: ${file}`)
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
const source = fs.readFileSync(file, 'utf8')
|
|
918
|
+
const relPath = path.relative(ROOT, file)
|
|
919
|
+
const testFile = file.replace(/\.js$/, '.test.js')
|
|
920
|
+
|
|
921
|
+
if (fs.existsSync(testFile)) {
|
|
922
|
+
return text(`Test file already exists: ${path.relative(ROOT, testFile)}\nDelete it first if you want to regenerate.`)
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// Extract spec details to inform the scaffolding
|
|
926
|
+
const hasMutations = source.includes('mutations:')
|
|
927
|
+
const hasActions = source.includes('actions:')
|
|
928
|
+
const hasServer = source.includes('server:')
|
|
929
|
+
const hasOnViewError = source.includes('onViewError')
|
|
930
|
+
const hasValidation = source.includes('validation:')
|
|
931
|
+
|
|
932
|
+
const mutationNames = [...source.matchAll(/^\s{4}(\w+)\s*:\s*\(state/gm)].map(m => m[1])
|
|
933
|
+
const actionNames = hasActions
|
|
934
|
+
? [...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')
|
|
935
|
+
: []
|
|
936
|
+
|
|
937
|
+
const specName = path.basename(file, '.js')
|
|
938
|
+
const importPath = path.relative(path.dirname(testFile), file).replace(/\.js$/, '.js')
|
|
939
|
+
|
|
940
|
+
const lines = []
|
|
941
|
+
lines.push(`import { describe, it } from 'node:test'`)
|
|
942
|
+
lines.push(`import assert from 'node:assert/strict'`)
|
|
943
|
+
lines.push(`import { renderSync${hasServer ? ', render' : ''} } from '@invisibleloop/pulse/testing'`)
|
|
944
|
+
lines.push(`import spec from './${importPath.replace(/^\.\//, '')}'`)
|
|
945
|
+
lines.push(``)
|
|
946
|
+
lines.push(`// ${relPath}`)
|
|
947
|
+
lines.push(``)
|
|
948
|
+
|
|
949
|
+
lines.push(`describe('${specName} — view', () => {`)
|
|
950
|
+
lines.push(` it('renders main landmark', () => {`)
|
|
951
|
+
lines.push(` const r = renderSync(spec${hasServer ? `, { server: {} }` : ''})`)
|
|
952
|
+
lines.push(` assert(r.has('main#main-content'), 'missing <main id="main-content">')`)
|
|
953
|
+
lines.push(` })`)
|
|
954
|
+
lines.push(``)
|
|
955
|
+
|
|
956
|
+
if (hasServer) {
|
|
957
|
+
lines.push(` it('handles null/empty server data without crashing', () => {`)
|
|
958
|
+
lines.push(` // Replace null with realistic empty values for each server fetcher`)
|
|
959
|
+
lines.push(` const r = renderSync(spec, { server: { /* TODO: add fetcher keys with null/[] values */ } })`)
|
|
960
|
+
lines.push(` assert(r.has('main'))`)
|
|
961
|
+
lines.push(` })`)
|
|
962
|
+
lines.push(``)
|
|
963
|
+
|
|
964
|
+
lines.push(` it('handles empty array server data', () => {`)
|
|
965
|
+
lines.push(` // Verify empty-state rendering (empty() component or fallback message)`)
|
|
966
|
+
lines.push(` const r = renderSync(spec, { server: { /* TODO: fetchers with [] values */ } })`)
|
|
967
|
+
lines.push(` assert(r.has('main'))`)
|
|
968
|
+
lines.push(` })`)
|
|
969
|
+
lines.push(``)
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
lines.push(` it('does not render user input unescaped (XSS)', () => {`)
|
|
973
|
+
lines.push(` // Replace one user-controlled field with a script tag`)
|
|
974
|
+
lines.push(` const xss = '<script>alert(1)</script>'`)
|
|
975
|
+
if (hasServer) {
|
|
976
|
+
lines.push(` const r = renderSync(spec, { server: { /* TODO: pass xss as a string field */ } })`)
|
|
977
|
+
} else {
|
|
978
|
+
lines.push(` const r = renderSync(spec, { state: { /* TODO: pass xss as a string state field */ } })`)
|
|
979
|
+
}
|
|
980
|
+
lines.push(` assert(!r.text().includes('<script>'), 'XSS string rendered unescaped')`)
|
|
981
|
+
lines.push(` })`)
|
|
982
|
+
lines.push(``)
|
|
983
|
+
|
|
984
|
+
if (hasOnViewError) {
|
|
985
|
+
lines.push(` it('onViewError returns fallback HTML when view throws', () => {`)
|
|
986
|
+
lines.push(` assert(typeof spec.onViewError, 'function')`)
|
|
987
|
+
lines.push(` const fallback = spec.onViewError(new Error('test'), spec.state ?? {}, {})`)
|
|
988
|
+
lines.push(` assert(typeof fallback === 'string' && fallback.length > 0, 'onViewError must return non-empty HTML string')`)
|
|
989
|
+
lines.push(` })`)
|
|
990
|
+
lines.push(``)
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
lines.push(`})`)
|
|
994
|
+
lines.push(``)
|
|
995
|
+
|
|
996
|
+
if (hasMutations && mutationNames.length > 0) {
|
|
997
|
+
lines.push(`describe('${specName} — mutations', () => {`)
|
|
998
|
+
for (const name of mutationNames.slice(0, 6)) {
|
|
999
|
+
lines.push(` it('${name} returns partial state', () => {`)
|
|
1000
|
+
lines.push(` assert(typeof spec.mutations.${name}, 'function')`)
|
|
1001
|
+
lines.push(` // TODO: call spec.mutations.${name}(spec.state, mockEvent) and assert the returned shape`)
|
|
1002
|
+
lines.push(` // e.g. const next = spec.mutations.${name}({ ...spec.state }, { target: { value: 'test' } })`)
|
|
1003
|
+
lines.push(` // assert.equal(typeof next, 'object')`)
|
|
1004
|
+
lines.push(` })`)
|
|
1005
|
+
lines.push(``)
|
|
1006
|
+
}
|
|
1007
|
+
lines.push(`})`)
|
|
1008
|
+
lines.push(``)
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
if (hasActions && actionNames.length > 0) {
|
|
1012
|
+
lines.push(`describe('${specName} — actions', () => {`)
|
|
1013
|
+
for (const name of actionNames.slice(0, 4)) {
|
|
1014
|
+
lines.push(` it('${name}.onStart returns loading state', () => {`)
|
|
1015
|
+
lines.push(` if (!spec.actions?.${name}?.onStart) return`)
|
|
1016
|
+
lines.push(` const next = spec.actions.${name}.onStart(spec.state ?? {}, new FormData())`)
|
|
1017
|
+
lines.push(` assert(next.status === 'loading' || typeof next === 'object', 'onStart must return partial state')`)
|
|
1018
|
+
lines.push(` })`)
|
|
1019
|
+
lines.push(``)
|
|
1020
|
+
lines.push(` it('${name}.onError returns error state', () => {`)
|
|
1021
|
+
lines.push(` if (!spec.actions?.${name}?.onError) return`)
|
|
1022
|
+
lines.push(` const next = spec.actions.${name}.onError(spec.state ?? {}, new Error('test'))`)
|
|
1023
|
+
lines.push(` assert(typeof next === 'object', 'onError must return partial state')`)
|
|
1024
|
+
lines.push(` })`)
|
|
1025
|
+
lines.push(``)
|
|
1026
|
+
}
|
|
1027
|
+
lines.push(`})`)
|
|
1028
|
+
lines.push(``)
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
if (hasValidation) {
|
|
1032
|
+
lines.push(`describe('${specName} — validation', () => {`)
|
|
1033
|
+
lines.push(` it('spec has validation rules', () => {`)
|
|
1034
|
+
lines.push(` assert(typeof spec.validation === 'object' && spec.validation !== null)`)
|
|
1035
|
+
lines.push(` assert(Object.keys(spec.validation).length > 0, 'validation object is empty')`)
|
|
1036
|
+
lines.push(` })`)
|
|
1037
|
+
lines.push(`})`)
|
|
1038
|
+
lines.push(``)
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
const testContent = lines.join('\n')
|
|
1042
|
+
fs.writeFileSync(testFile, testContent, 'utf8')
|
|
1043
|
+
|
|
1044
|
+
const summary = [
|
|
1045
|
+
`Generated ${path.relative(ROOT, testFile)}`,
|
|
1046
|
+
``,
|
|
1047
|
+
`Stubs created:`,
|
|
1048
|
+
` ✓ View landmark test`,
|
|
1049
|
+
hasServer ? ` ✓ Null/empty server data tests` : null,
|
|
1050
|
+
` ✓ XSS injection test`,
|
|
1051
|
+
hasOnViewError ? ` ✓ onViewError fallback test` : null,
|
|
1052
|
+
hasMutations && mutationNames.length > 0 ? ` ✓ Mutation tests (${mutationNames.slice(0, 6).join(', ')})` : null,
|
|
1053
|
+
hasActions && actionNames.length > 0 ? ` ✓ Action onStart/onError tests (${actionNames.slice(0, 4).join(', ')})` : null,
|
|
1054
|
+
hasValidation ? ` ✓ Validation structure test` : null,
|
|
1055
|
+
``,
|
|
1056
|
+
`Next: search for "TODO" in the file — each TODO needs a real assertion.`,
|
|
1057
|
+
`Run: pulse_run_tests`,
|
|
1058
|
+
].filter(Boolean).join('\n')
|
|
1059
|
+
|
|
1060
|
+
return text(summary)
|
|
1061
|
+
}
|
|
1062
|
+
)
|
|
1063
|
+
|
|
791
1064
|
// ---------------------------------------------------------------------------
|
|
792
1065
|
// pulse_fetch_page
|
|
793
1066
|
// ---------------------------------------------------------------------------
|
|
@@ -908,13 +1181,18 @@ server.registerTool(
|
|
|
908
1181
|
Reads the spec source, renders the view with initial state, runs all validation checks,
|
|
909
1182
|
and returns a structured review brief. You must read everything carefully, find every
|
|
910
1183
|
issue, and fix them all before reporting back to the user. Use this after completing
|
|
911
|
-
any feature build
|
|
1184
|
+
any feature build.
|
|
1185
|
+
|
|
1186
|
+
**Two modes:**
|
|
1187
|
+
- Default (full review): runs after Lighthouse passes — the canonical final gate. Returns full checklist + rendered HTML + validator output.
|
|
1188
|
+
- \`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
1189
|
inputSchema: {
|
|
913
|
-
file:
|
|
1190
|
+
file: z.string().optional().describe('Absolute path to the spec file to review'),
|
|
914
1191
|
content: z.string().optional().describe('JavaScript spec content to review (alternative to file)'),
|
|
1192
|
+
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
1193
|
},
|
|
916
1194
|
},
|
|
917
|
-
async ({ file, content }) => {
|
|
1195
|
+
async ({ file, content, quick = false }) => {
|
|
918
1196
|
if (!file && !content) {
|
|
919
1197
|
return text('Error: must provide either file or content')
|
|
920
1198
|
}
|
|
@@ -928,6 +1206,77 @@ any feature build.`,
|
|
|
928
1206
|
source = fs.readFileSync(file, 'utf8')
|
|
929
1207
|
}
|
|
930
1208
|
|
|
1209
|
+
// Quick mode — lightweight structural checks only, no full checklist or Lighthouse gate
|
|
1210
|
+
if (quick) {
|
|
1211
|
+
const issues = []
|
|
1212
|
+
const warnings = []
|
|
1213
|
+
|
|
1214
|
+
// Try to render the view
|
|
1215
|
+
let renderedHtml = ''
|
|
1216
|
+
if (file) {
|
|
1217
|
+
try {
|
|
1218
|
+
const mod = await import(`${file}?quick=${Date.now()}`)
|
|
1219
|
+
const spec = mod.default
|
|
1220
|
+
if (spec && typeof spec.view === 'function') {
|
|
1221
|
+
renderedHtml = spec.view(spec.state || {}, {})
|
|
1222
|
+
}
|
|
1223
|
+
} catch { /* ignore — server data dependency */ }
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
if (renderedHtml) {
|
|
1227
|
+
if (!/<main[^>]*id=["']?main-content/.test(renderedHtml))
|
|
1228
|
+
issues.push('✗ Missing `<main id="main-content">` landmark')
|
|
1229
|
+
if (/<input[^>]*data-event/.test(renderedHtml))
|
|
1230
|
+
issues.push('✗ `data-event` on `<input>` — destroys focus on every keystroke, use FormData in onStart instead')
|
|
1231
|
+
if (/(className|htmlFor|onClick)=/.test(renderedHtml))
|
|
1232
|
+
issues.push('✗ React patterns found (className / htmlFor / onClick) — use class, for, data-event')
|
|
1233
|
+
if (/tabindex=["']?([1-9]\d*)/.test(renderedHtml))
|
|
1234
|
+
issues.push('✗ Positive tabindex found — remove, reorder DOM instead')
|
|
1235
|
+
const viewBlockStripped = renderedHtml
|
|
1236
|
+
.replace(/href=["']#[^"']*["']/g, 'href="#"')
|
|
1237
|
+
.replace(/id=["'][^"']*["']/g, 'id=""')
|
|
1238
|
+
if (/#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3}\b(?![0-9a-fA-F])/.test(viewBlockStripped))
|
|
1239
|
+
warnings.push('⚠ Possible hex colour in rendered HTML — use var(--ui-*) tokens')
|
|
1240
|
+
|
|
1241
|
+
// Component pattern checks (unless creative override declared)
|
|
1242
|
+
if (!/component.free|creative\s+override/i.test(source)) {
|
|
1243
|
+
if (/<[^>]+class="[^"]*\bhero\b/.test(renderedHtml) && !source.includes('hero('))
|
|
1244
|
+
warnings.push('⚠ `.hero` class in HTML but no `hero()` component — use the component or declare creative override')
|
|
1245
|
+
if (/<[^>]+class="[^"]*\bcard\b/.test(renderedHtml) && !source.includes('card('))
|
|
1246
|
+
warnings.push('⚠ `.card` class in HTML but no `card()` component — use the component or declare creative override')
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
// Source-level checks (don't need rendered HTML)
|
|
1251
|
+
if (source.includes('actions:') && !source.includes('onError:'))
|
|
1252
|
+
issues.push('✗ Action missing `onError` — required, will throw at runtime')
|
|
1253
|
+
if (source.includes('actions:') && !source.includes('onSuccess:'))
|
|
1254
|
+
issues.push('✗ Action missing `onSuccess` — required')
|
|
1255
|
+
if (source.includes('hydrate:'))
|
|
1256
|
+
issues.push('✗ `hydrate` is set manually — remove it, the framework sets it automatically')
|
|
1257
|
+
if (/meta\s*:\s*async/.test(source))
|
|
1258
|
+
issues.push('✗ `meta` is an async function — `meta` must be a plain object; make individual fields async instead')
|
|
1259
|
+
|
|
1260
|
+
const lines = ['## Quick review\n']
|
|
1261
|
+
if (issues.length === 0 && warnings.length === 0) {
|
|
1262
|
+
lines.push('✓ No obvious issues found. Run `pulse_validate` next, then `/verify`.')
|
|
1263
|
+
} else {
|
|
1264
|
+
if (issues.length) {
|
|
1265
|
+
lines.push('### Fix before proceeding\n')
|
|
1266
|
+
for (const i of issues) lines.push(i)
|
|
1267
|
+
lines.push('')
|
|
1268
|
+
}
|
|
1269
|
+
if (warnings.length) {
|
|
1270
|
+
lines.push('### Warnings (check these)\n')
|
|
1271
|
+
for (const w of warnings) lines.push(w)
|
|
1272
|
+
lines.push('')
|
|
1273
|
+
}
|
|
1274
|
+
lines.push('---\nFix issues, then run `pulse_validate` → `pulse_suggest` → `/verify`.')
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
return text(lines.join('\n'))
|
|
1278
|
+
}
|
|
1279
|
+
|
|
931
1280
|
// Run the validator in a child process (same as pulse_validate)
|
|
932
1281
|
let validationResult = '(could not run validator)'
|
|
933
1282
|
if (file) {
|
|
@@ -1210,13 +1559,16 @@ The stop hook compares each changed spec's mtime against this stamp. Any spec ne
|
|
|
1210
1559
|
server.registerTool(
|
|
1211
1560
|
'pulse_await_approval',
|
|
1212
1561
|
{
|
|
1213
|
-
description: `Pause the workflow
|
|
1562
|
+
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
1563
|
|
|
1215
|
-
|
|
1564
|
+
- **Blocking questions** — the design-approval gate ("Happy with the layout and direction…?"), or any decision only the user can make.
|
|
1565
|
+
- **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
1566
|
|
|
1217
|
-
|
|
1567
|
+
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
1568
|
|
|
1219
|
-
|
|
1569
|
+
**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.
|
|
1570
|
+
|
|
1571
|
+
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
1572
|
inputSchema: {},
|
|
1221
1573
|
},
|
|
1222
1574
|
() => {
|
|
@@ -1793,6 +2145,15 @@ If your page genuinely doesn't fit a pattern, start from pulse://guide/spec and
|
|
|
1793
2145
|
lines.push(`State: ${arch.stateHint}`)
|
|
1794
2146
|
lines.push(`Server: ${arch.serverHint}`)
|
|
1795
2147
|
|
|
2148
|
+
lines.push(``)
|
|
2149
|
+
lines.push(`## ⚠ Before writing any code — ask these two questions`)
|
|
2150
|
+
lines.push(``)
|
|
2151
|
+
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.`)
|
|
2152
|
+
lines.push(` Add \`theme: 'light'\` to your plan or brief if confirmed light.`)
|
|
2153
|
+
lines.push(``)
|
|
2154
|
+
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.`)
|
|
2155
|
+
lines.push(` (Skip if this came from pulse_intake — it already asked.)`)
|
|
2156
|
+
|
|
1796
2157
|
lines.push(``)
|
|
1797
2158
|
lines.push(`## Guide sections to read`)
|
|
1798
2159
|
lines.push(``)
|
|
@@ -3380,7 +3741,7 @@ const PROP_ALIASES = [
|
|
|
3380
3741
|
{ component: 'input()', wrong: 'pattern', correct: 'attrs: { pattern }', note: 'HTML attributes go inside `attrs`: input({ attrs: { pattern: "[0-9]+" } }).' },
|
|
3381
3742
|
]
|
|
3382
3743
|
|
|
3383
|
-
async function validateContent(content) {
|
|
3744
|
+
async function validateContent(content, tmpDir = PAGES_DIR) {
|
|
3384
3745
|
// Source-level checks — run before the worker so errors are caught without importing
|
|
3385
3746
|
const sourceWarnings = []
|
|
3386
3747
|
|
|
@@ -3388,9 +3749,17 @@ async function validateContent(content) {
|
|
|
3388
3749
|
for (const { component, wrong, correct, note } of PROP_ALIASES) {
|
|
3389
3750
|
// Match: nav({ ...wrong: or footer({ ...wrong: (within the call args)
|
|
3390
3751
|
const fnName = component.replace('()', '')
|
|
3391
|
-
const re = new RegExp(`${fnName}\\s*\\(\\s*\\{[^}]
|
|
3392
|
-
|
|
3752
|
+
const re = new RegExp(`${fnName}\\s*\\(\\s*\\{([^}]*)\\b${wrong}\\s*:`, 'g')
|
|
3753
|
+
let m
|
|
3754
|
+
while ((m = re.exec(content)) !== null) {
|
|
3755
|
+
// Inside attrs: { … } is the CORRECT placement for these HTML attributes —
|
|
3756
|
+
// when the text between the call's opening brace and the prop ends inside
|
|
3757
|
+
// an open attrs object, this is the recommended pattern, not a mistake.
|
|
3758
|
+
// (The naive match crossed into attrs blocks and flagged exactly what the
|
|
3759
|
+
// fix-it note tells people to write.)
|
|
3760
|
+
if (/attrs\s*:\s*\{[^}]*$/.test(m[1])) continue
|
|
3393
3761
|
sourceWarnings.push(`"${wrong}" is not a recognised prop for ${component} — did you mean "${correct}"? ${note}`)
|
|
3762
|
+
break
|
|
3394
3763
|
}
|
|
3395
3764
|
}
|
|
3396
3765
|
|
|
@@ -3399,24 +3768,28 @@ async function validateContent(content) {
|
|
|
3399
3768
|
const externalImgHosts = [
|
|
3400
3769
|
{
|
|
3401
3770
|
pattern: /https?:\/\/images\.unsplash\.com/,
|
|
3771
|
+
host: 'images.unsplash.com',
|
|
3402
3772
|
entry: 'https://images.unsplash.com',
|
|
3403
3773
|
cookieWarning: true,
|
|
3404
3774
|
name: 'Unsplash',
|
|
3405
3775
|
},
|
|
3406
3776
|
{
|
|
3407
3777
|
pattern: /https?:\/\/(?:fastly\.)?picsum\.photos/,
|
|
3778
|
+
host: 'picsum.photos',
|
|
3408
3779
|
entry: 'https://picsum.photos https://fastly.picsum.photos',
|
|
3409
3780
|
cookieWarning: false,
|
|
3410
3781
|
name: 'picsum',
|
|
3411
3782
|
},
|
|
3412
3783
|
{
|
|
3413
3784
|
pattern: /https?:\/\/res\.cloudinary\.com/,
|
|
3785
|
+
host: 'res.cloudinary.com',
|
|
3414
3786
|
entry: 'https://res.cloudinary.com',
|
|
3415
3787
|
cookieWarning: true,
|
|
3416
3788
|
name: 'Cloudinary',
|
|
3417
3789
|
},
|
|
3418
3790
|
{
|
|
3419
3791
|
pattern: /https?:\/\/cdn\.shopify\.com/,
|
|
3792
|
+
host: 'cdn.shopify.com',
|
|
3420
3793
|
entry: 'https://cdn.shopify.com',
|
|
3421
3794
|
cookieWarning: false,
|
|
3422
3795
|
name: 'Shopify CDN',
|
|
@@ -3427,8 +3800,11 @@ async function validateContent(content) {
|
|
|
3427
3800
|
if (fs.existsSync(configPath)) {
|
|
3428
3801
|
try { configImgSrc = fs.readFileSync(configPath, 'utf8') } catch { /* ignore */ }
|
|
3429
3802
|
}
|
|
3430
|
-
for (const { pattern, entry, cookieWarning, name } of externalImgHosts) {
|
|
3431
|
-
|
|
3803
|
+
for (const { pattern, host, entry, cookieWarning, name } of externalImgHosts) {
|
|
3804
|
+
// Config match is by HOST, not full URL — CSP sources are valid without a
|
|
3805
|
+
// scheme ('images.unsplash.com'), so requiring https?:// in the config
|
|
3806
|
+
// produced false positives for correctly configured projects.
|
|
3807
|
+
if (pattern.test(content) && !configImgSrc.includes(host)) {
|
|
3432
3808
|
const cookieNote = cookieWarning
|
|
3433
3809
|
? `\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
3810
|
: ''
|
|
@@ -3440,9 +3816,12 @@ async function validateContent(content) {
|
|
|
3440
3816
|
}
|
|
3441
3817
|
}
|
|
3442
3818
|
|
|
3443
|
-
// Write into
|
|
3444
|
-
|
|
3445
|
-
|
|
3819
|
+
// Write the temp file into the directory the spec actually lives in (passed by
|
|
3820
|
+
// callers that know it) so relative imports resolve from the true location.
|
|
3821
|
+
// A page at src/pages/news/index.js importing '../../components/layout.js'
|
|
3822
|
+
// resolved wrongly when the temp file was always dropped in src/pages/ root.
|
|
3823
|
+
fs.mkdirSync(tmpDir, { recursive: true })
|
|
3824
|
+
const tmpFile = path.join(tmpDir, `.pulse-validate-${Date.now()}.mjs`)
|
|
3446
3825
|
try {
|
|
3447
3826
|
fs.writeFileSync(tmpFile, content, 'utf8')
|
|
3448
3827
|
|
|
@@ -3547,12 +3926,17 @@ const PULSE_GUIDE_INDEX = `# Pulse Framework Guide
|
|
|
3547
3926
|
|
|
3548
3927
|
## Start here
|
|
3549
3928
|
|
|
3550
|
-
**Fetch \`pulse://
|
|
3929
|
+
**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.
|
|
3930
|
+
|
|
3931
|
+
If you know your context already:
|
|
3932
|
+
- New page / new site → \`pulse://workflow\`
|
|
3933
|
+
- Edit, fix, or one-shot build → \`pulse://quickstart\`
|
|
3551
3934
|
|
|
3552
3935
|
## Guide resources
|
|
3553
3936
|
|
|
3554
3937
|
| Resource | When to fetch |
|
|
3555
3938
|
|---|---|
|
|
3939
|
+
| \`pulse://start\` | **Always fetch first** — context-aware entry point that routes you to the right resources |
|
|
3556
3940
|
| \`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
3941
|
| \`pulse://workflow\` | **New project or new page** — full phase/gate sequence before writing any code. |
|
|
3558
3942
|
| \`pulse://guide/spec\` | Building a spec — state, mutations, actions, streaming SSR, key rules, form layout |
|
|
@@ -3573,14 +3957,15 @@ const PULSE_GUIDE_INDEX = `# Pulse Framework Guide
|
|
|
3573
3957
|
- \`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
3958
|
- \`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
3959
|
- \`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
|
|
3960
|
+
- \`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.
|
|
3961
|
+
- \`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
3962
|
- \`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
3963
|
- \`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
3964
|
- \`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
3965
|
- \`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
3966
|
- \`pulse_list_structure\` — list pages, components, and pulse-ui version. Call at the start of every session.
|
|
3582
3967
|
- \`pulse_validate\` — validate spec content. Call after every write. Fix all errors AND warnings.
|
|
3583
|
-
- \`pulse_review\` —
|
|
3968
|
+
- \`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
3969
|
- \`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
3970
|
- \`pulse_create_component\` — register a component you wrote with the Write tool. Write the file first, then call this.
|
|
3586
3971
|
- \`pulse_create_store\` — register a pulse.store.js you wrote with the Write tool. Write the file first, then call this.
|