@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/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
- const validation = await validateContent(content)
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: z.string().optional().describe('Absolute path to the spec file to review'),
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 to wait for the user's answer — call this immediately BEFORE asking a blocking question (e.g. the design-approval gate: "Happy with the layout and direction…?").
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
- 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.
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
- **Always call this before the 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. Calling it first works in every host; relying on the question tool to keep the turn open does not.
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
- Do NOT use this to skip verification — it buys exactly one turn-end, paired with a question the user must answer. Calling it without asking anything just delays the gates by one message.`,
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*\\{[^}]*\\b${wrong}\\s*:`, 'g')
3392
- if (re.test(content)) {
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
- if (pattern.test(content) && !pattern.test(configImgSrc)) {
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 PAGES_DIR so relative imports (e.g. '../components/nav.js') resolve correctly
3444
- fs.mkdirSync(PAGES_DIR, { recursive: true })
3445
- const tmpFile = path.join(PAGES_DIR, `.pulse-validate-${Date.now()}.mjs`)
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://workflow\` before anything else.** It defines the exact sequence of phases and pass gates for every build task. Do not fetch guide sections or start writing code until you have read it. Skipping it means you will run steps in the wrong order.
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 mid-build before running the hard validator.
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\` — switch into reviewer mode and critically examine a spec you just built. Returns the source, rendered HTML, validator output, and a full review checklist. **Call this only after validate, Lighthouse (desktop + mobile), and tests all pass — it is the final phase before declaring done.**
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.