@invisibleloop/pulse 0.20.11 → 0.20.20
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/package.json +6 -5
- package/public/.pulse-ui-version +1 -1
- package/scripts/build.js +26 -0
- package/scripts/release-version.js +17 -8
- package/scripts/strip-server.test.js +60 -0
- package/src/agent/checklist.md +8 -12
- package/src/agent/commands/pulse-start.md +2 -0
- package/src/agent/commands/verify.md +30 -8
- package/src/agent/guide-components.md +7 -25
- package/src/agent/guide-routing.md +0 -12
- package/src/agent/guide-server.md +102 -1
- package/src/agent/guide-spec.md +8 -1
- package/src/agent/guide-styles.md +23 -0
- package/src/agent/identity.md +9 -0
- package/src/agent/workflow.md +4 -23
- package/src/cli/check-bundles.js +113 -0
- package/src/cli/check-bundles.test.js +137 -0
- package/src/cli/cli.test.js +105 -0
- package/src/cli/dev.js +48 -3
- package/src/cli/diagnose.js +105 -0
- package/src/cli/diagnose.test.js +124 -0
- package/src/cli/discover.js +9 -2
- package/src/cli/index.js +171 -9
- package/src/cli/process-utils.js +57 -0
- package/src/cli/process-utils.test.js +114 -0
- package/src/cli/review.js +391 -0
- package/src/cli/review.test.js +143 -0
- package/src/cli/scaffold.js +9 -4
- package/src/cli/stamp.js +102 -0
- package/src/cli/stamp.test.js +149 -0
- package/src/cli/start.js +15 -2
- package/src/cli/validate.js +184 -0
- package/src/cli/validate.test.js +153 -0
- package/src/mcp/mcp.test.js +261 -30
- package/src/mcp/server.js +207 -515
- package/src/mcp/validate-worker.js +18 -1
- package/src/runtime/index.js +144 -11
- package/src/runtime/runtime.test.js +199 -0
- package/src/runtime/ssr.js +13 -6
- package/src/runtime/ssr.test.js +31 -0
- package/src/server/index.js +162 -9
- package/src/server/server.test.js +225 -2
- package/src/spec/schema.js +1 -1
- package/src/ui/index.js +0 -5
- package/src/ui/ui.test.js +0 -75
- package/types/ui.d.ts +0 -21
- package/.claude/commands/build-page.md +0 -63
- package/.claude/commands/new-doc-page.md +0 -45
- package/.claude/commands/pulse-start.md +0 -11
- package/.claude/commands/pulse-stop.md +0 -5
- package/.claude/commands/verify.md +0 -118
- package/.claude/pulse-checklist.md +0 -189
- package/.github/workflows/ci.yml +0 -25
- package/.github/workflows/publish.yml +0 -45
- package/.pulse/load-reports/home/1773432711417.json +0 -22
- package/.pulse-verified +0 -1
- package/AGENTS.md +0 -697
- package/CLAUDE.md +0 -697
- package/README.md +0 -95
- package/benchmark/public/dist/counter.boot-PO6SNSJZ.js +0 -8
- package/benchmark/public/dist/home.boot-YK6VAEQI.js +0 -6
- package/benchmark/public/dist/manifest.json +0 -5
- package/benchmark/public/dist/runtime-5Y7ESAIB.js +0 -2
- package/benchmark/public/dist/runtime-KO4BHUQ3.js +0 -49
- package/benchmark/results/2026-03-27-09-20.json +0 -188
- package/benchmark/results/2026-03-27-09-21.json +0 -188
- package/benchmark/results/2026-03-27-09-29.json +0 -188
- package/benchmark/results/2026-03-27-10-14.json +0 -188
- package/benchmark/results/2026-03-27-10-18.json +0 -232
- package/benchmark/src/pages/counter.js +0 -37
- package/benchmark/src/pages/home.js +0 -21
- package/docs/.claude/commands/pulse-build.md +0 -7
- package/docs/.claude/commands/pulse-dev.md +0 -7
- package/docs/.claude/commands/pulse-start.md +0 -9
- package/docs/.claude/commands/pulse-stop.md +0 -5
- package/docs/.claude/commands/verify.md +0 -84
- package/docs/.claude/coverage-check.js +0 -196
- package/docs/.claude/pulse-checklist.md +0 -189
- package/docs/public/.pulse-ui-version +0 -1
- package/docs/public/andypixel.css +0 -237
- package/docs/public/bookworms.css +0 -422
- package/docs/public/coutumes.css +0 -561
- package/docs/public/docs.css +0 -2388
- package/docs/public/favicon.svg +0 -4
- package/docs/public/home-brut.css +0 -982
- package/docs/public/images/bookworms/hero-books.jpg +0 -0
- package/docs/public/images/bookworms/how-we-read-now.jpg +0 -0
- package/docs/public/images/bookworms/lantern-keepers.jpg +0 -0
- package/docs/public/images/bookworms/provincial-bookseller.jpg +0 -0
- package/docs/public/images/journey/178.jpg +0 -0
- package/docs/public/images/journey/29.jpg +0 -0
- package/docs/public/images/retrowheels/hero.jpg +0 -0
- package/docs/public/images/shop/devbook-pro.jpg +0 -0
- package/docs/public/images/suzukibento/1015.jpg +0 -0
- package/docs/public/images/suzukibento/1025.jpg +0 -0
- package/docs/public/images/suzukibento/1043.jpg +0 -0
- package/docs/public/images/suzukibento/1059.jpg +0 -0
- package/docs/public/images/suzukibento/1062.jpg +0 -0
- package/docs/public/images/suzukibento/1084.jpg +0 -0
- package/docs/public/images/swish/1469334031218-e382a71b716b.jpg +0 -0
- package/docs/public/images/swish/1483985988355-763728e1935b.jpg +0 -0
- package/docs/public/images/swish/1485518882345-15568b007407.jpg +0 -0
- package/docs/public/images/swish/1490481651871-ab68de25d43d.jpg +0 -0
- package/docs/public/images/swish/1496747611176-843222e1e57c.jpg +0 -0
- package/docs/public/images/swish/1502716119720-b23a93e5fe1b.jpg +0 -0
- package/docs/public/images/swish/1539109136881-3be0616acf4b.jpg +0 -0
- package/docs/public/images/swish/1551803091-e20673f15770.jpg +0 -0
- package/docs/public/images/swish/1558769132-cb1aea458c5e.jpg +0 -0
- package/docs/public/images/swish/1572804013309-59a88b7e92f1.jpg +0 -0
- package/docs/public/img/heic1501a.jpg +0 -0
- package/docs/public/img/placeholder-landscape.svg +0 -18
- package/docs/public/img/placeholder-square.svg +0 -14
- package/docs/public/img/placeholder-tall.svg +0 -22
- package/docs/public/journey.css +0 -528
- package/docs/public/menu.js +0 -327
- package/docs/public/pulse-ui.css +0 -3028
- package/docs/public/pulse-ui.js +0 -257
- package/docs/public/retrowheels.css +0 -425
- package/docs/public/shop.css +0 -269
- package/docs/public/sparkys.css +0 -382
- package/docs/public/suzukibento.css +0 -517
- package/docs/public/swish.css +0 -394
- package/docs/public/theme.css +0 -52
- package/docs/public/themes/lumio.css +0 -50
- package/docs/public/themes/observatory.css +0 -104
- package/docs/server.js +0 -161
- package/docs/src/lib/component-page.js +0 -49
- package/docs/src/lib/highlight.js +0 -2
- package/docs/src/lib/layout.js +0 -190
- package/docs/src/lib/metrics-store.js +0 -6
- package/docs/src/lib/nav.js +0 -171
- package/docs/src/lib/stats.js +0 -81
- package/docs/src/pages/accessibility.js +0 -157
- package/docs/src/pages/actions.js +0 -194
- package/docs/src/pages/auth.js +0 -176
- package/docs/src/pages/caching.js +0 -95
- package/docs/src/pages/components/accordion.js +0 -48
- package/docs/src/pages/components/alert.js +0 -35
- package/docs/src/pages/components/app-badge.js +0 -41
- package/docs/src/pages/components/avatar.js +0 -35
- package/docs/src/pages/components/badge.js +0 -36
- package/docs/src/pages/components/breadcrumbs.js +0 -94
- package/docs/src/pages/components/button.js +0 -84
- package/docs/src/pages/components/card.js +0 -225
- package/docs/src/pages/components/charts.js +0 -283
- package/docs/src/pages/components/checkbox.js +0 -129
- package/docs/src/pages/components/cluster.js +0 -47
- package/docs/src/pages/components/container.js +0 -40
- package/docs/src/pages/components/cta.js +0 -54
- package/docs/src/pages/components/divider.js +0 -37
- package/docs/src/pages/components/empty.js +0 -36
- package/docs/src/pages/components/feature.js +0 -60
- package/docs/src/pages/components/fieldset.js +0 -65
- package/docs/src/pages/components/fileupload.js +0 -128
- package/docs/src/pages/components/footer.js +0 -58
- package/docs/src/pages/components/grid.js +0 -169
- package/docs/src/pages/components/heading.js +0 -107
- package/docs/src/pages/components/hero.js +0 -123
- package/docs/src/pages/components/icons.js +0 -410
- package/docs/src/pages/components/image.js +0 -71
- package/docs/src/pages/components/input.js +0 -51
- package/docs/src/pages/components/media.js +0 -51
- package/docs/src/pages/components/modal.js +0 -111
- package/docs/src/pages/components/nav.js +0 -136
- package/docs/src/pages/components/phone-frame.js +0 -74
- package/docs/src/pages/components/progress.js +0 -102
- package/docs/src/pages/components/prose.js +0 -111
- package/docs/src/pages/components/pullquote.js +0 -71
- package/docs/src/pages/components/radio.js +0 -194
- package/docs/src/pages/components/rating.js +0 -106
- package/docs/src/pages/components/search.js +0 -61
- package/docs/src/pages/components/section.js +0 -98
- package/docs/src/pages/components/segmented.js +0 -121
- package/docs/src/pages/components/select.js +0 -45
- package/docs/src/pages/components/slider.js +0 -115
- package/docs/src/pages/components/spinner.js +0 -73
- package/docs/src/pages/components/stack.js +0 -48
- package/docs/src/pages/components/stat.js +0 -55
- package/docs/src/pages/components/stepper.js +0 -66
- package/docs/src/pages/components/table.js +0 -45
- package/docs/src/pages/components/textarea.js +0 -31
- package/docs/src/pages/components/toggle.js +0 -84
- package/docs/src/pages/components/tooltip.js +0 -48
- package/docs/src/pages/components.js +0 -200
- package/docs/src/pages/config.js +0 -193
- package/docs/src/pages/constraints.js +0 -100
- package/docs/src/pages/deploy.js +0 -233
- package/docs/src/pages/error-pages.js +0 -99
- package/docs/src/pages/extending.js +0 -198
- package/docs/src/pages/faq.js +0 -99
- package/docs/src/pages/forms.js +0 -129
- package/docs/src/pages/getting-started.js +0 -147
- package/docs/src/pages/guard.js +0 -120
- package/docs/src/pages/home.js +0 -350
- package/docs/src/pages/how-it-works.js +0 -244
- package/docs/src/pages/hydration.js +0 -108
- package/docs/src/pages/images.js +0 -120
- package/docs/src/pages/markdown.js +0 -156
- package/docs/src/pages/meta.js +0 -193
- package/docs/src/pages/mutations.js +0 -107
- package/docs/src/pages/navigation.js +0 -85
- package/docs/src/pages/performance.js +0 -157
- package/docs/src/pages/persist.js +0 -89
- package/docs/src/pages/project-structure.js +0 -91
- package/docs/src/pages/prompt-examples.js +0 -186
- package/docs/src/pages/raw-responses.js +0 -122
- package/docs/src/pages/routing.js +0 -120
- package/docs/src/pages/server-api.js +0 -291
- package/docs/src/pages/server-data.js +0 -203
- package/docs/src/pages/slash-commands.js +0 -55
- package/docs/src/pages/spec.js +0 -207
- package/docs/src/pages/state.js +0 -102
- package/docs/src/pages/store.js +0 -203
- package/docs/src/pages/streaming.js +0 -107
- package/docs/src/pages/stripe.js +0 -194
- package/docs/src/pages/supabase.js +0 -324
- package/docs/src/pages/templates/andypixel.js +0 -260
- package/docs/src/pages/templates/blog-post.js +0 -226
- package/docs/src/pages/templates/bookworms.js +0 -227
- package/docs/src/pages/templates/coutumes.js +0 -237
- package/docs/src/pages/templates/journey.js +0 -186
- package/docs/src/pages/templates/mobile-app.js +0 -371
- package/docs/src/pages/templates/retrowheels.js +0 -355
- package/docs/src/pages/templates/shop.js +0 -268
- package/docs/src/pages/templates/sparkys.js +0 -264
- package/docs/src/pages/templates/suzukibento.js +0 -200
- package/docs/src/pages/templates/swish.js +0 -313
- package/docs/src/pages/templates.js +0 -534
- package/docs/src/pages/testing.js +0 -199
- package/docs/src/pages/validation.js +0 -138
- package/examples/.claude/commands/pulse-build.md +0 -7
- package/examples/.claude/commands/pulse-dev.md +0 -7
- package/examples/.claude/commands/pulse-start.md +0 -9
- package/examples/.claude/commands/pulse-stop.md +0 -5
- package/examples/.claude/commands/verify.md +0 -84
- package/examples/.claude/coverage-check.js +0 -196
- package/examples/.claude/pulse-checklist.md +0 -189
- package/examples/andypixel.css +0 -237
- package/examples/andypixel.js +0 -258
- package/examples/bookworms.css +0 -422
- package/examples/bookworms.js +0 -225
- package/examples/contact.js +0 -166
- package/examples/counter.js +0 -94
- package/examples/dev.server.js +0 -110
- package/examples/examples.test.js +0 -394
- package/examples/journey.css +0 -528
- package/examples/journey.js +0 -186
- package/examples/pricing.js +0 -262
- package/examples/products.js +0 -190
- package/examples/public/.pulse-ui-version +0 -1
- package/examples/public/pulse-ui.css +0 -3028
- package/examples/public/pulse-ui.js +0 -257
- package/examples/quiz.js +0 -208
- package/examples/retrowheels.css +0 -425
- package/examples/retrowheels.js +0 -353
- package/examples/shared.js +0 -84
- package/examples/suzukibento.css +0 -513
- package/examples/suzukibento.js +0 -200
- package/examples/swish.css +0 -394
- package/examples/swish.js +0 -311
- package/examples/todos.js +0 -162
- package/src/ui/fieldset.js +0 -37
- package/src/ui/phone-frame.js +0 -40
- package/src/ui/search.js +0 -78
- package/src/ui/segmented.js +0 -50
- package/src/ui/tooltip.js +0 -28
package/src/mcp/server.js
CHANGED
|
@@ -22,6 +22,9 @@
|
|
|
22
22
|
* pulse_validate — validate a spec against the schema
|
|
23
23
|
* pulse_review — full code review (pass quick:true for lightweight mid-build check)
|
|
24
24
|
* pulse_stamp — write the .pulse-verified stamp (call as last step of /verify)
|
|
25
|
+
* pulse_diagnose — read the dev-only error journal (.pulse/errors.json)
|
|
26
|
+
* pulse_resolve_error — mark error journal entries resolved
|
|
27
|
+
* pulse_check_bundles — inspect production bundle contents (not just sizes)
|
|
25
28
|
* pulse_check_version — installed vs static vs npm latest
|
|
26
29
|
* pulse_update — re-copy pulse-ui assets from package → public/
|
|
27
30
|
*/
|
|
@@ -33,9 +36,15 @@ import { z } from 'zod'
|
|
|
33
36
|
import path from 'path'
|
|
34
37
|
import fs from 'fs'
|
|
35
38
|
import http from 'http'
|
|
36
|
-
import {
|
|
39
|
+
import { spawn, spawnSync } from 'child_process'
|
|
37
40
|
|
|
38
41
|
import { loadPages } from '../cli/discover.js'
|
|
42
|
+
import { validateContent, validateFile, formatValidationResult } from '../cli/validate.js'
|
|
43
|
+
import { readJournal, formatJournal, resolveEntries, formatResolveResult } from '../cli/diagnose.js'
|
|
44
|
+
import { writeStamp, formatStampResult } from '../cli/stamp.js'
|
|
45
|
+
import { checkBundles, formatBundleCheck } from '../cli/check-bundles.js'
|
|
46
|
+
import { runQuickReview, formatQuickReview, runFullReview, formatFullReview } from '../cli/review.js'
|
|
47
|
+
import { killPulseServerOnPort } from '../cli/process-utils.js'
|
|
39
48
|
|
|
40
49
|
// ---------------------------------------------------------------------------
|
|
41
50
|
// Crash guards — an uncaught error in any tool handler must not kill the
|
|
@@ -78,6 +87,22 @@ const PKG_VERSION = JSON.parse(
|
|
|
78
87
|
fs.readFileSync(new URL('../../package.json', import.meta.url).pathname, 'utf8')
|
|
79
88
|
).version
|
|
80
89
|
|
|
90
|
+
// Reads pulse.config.js fresh on every call (cache-busted — the project's
|
|
91
|
+
// config can change between calls in a long-lived MCP server process, e.g.
|
|
92
|
+
// a user flips `design: 'freeform'` mid-session and the agent restarts the
|
|
93
|
+
// server or just calls a tool again). Returns {} if there is no config file
|
|
94
|
+
// or it fails to load — every caller treats missing keys as "use the default".
|
|
95
|
+
async function readPulseConfig() {
|
|
96
|
+
const configPath = path.join(ROOT, 'pulse.config.js')
|
|
97
|
+
if (!fs.existsSync(configPath)) return {}
|
|
98
|
+
try {
|
|
99
|
+
const mod = await import(`${configPath}?t=${Date.now()}`)
|
|
100
|
+
return mod.default ?? {}
|
|
101
|
+
} catch {
|
|
102
|
+
return {}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
81
106
|
// Common synonym → canonical vibe normalisation
|
|
82
107
|
// Prevents hard enum errors when agents use intuitive names like "modern-minimal"
|
|
83
108
|
const VIBE_SYNONYMS = {
|
|
@@ -237,6 +262,25 @@ server.registerResource(
|
|
|
237
262
|
|
|
238
263
|
const isNewProject = pageCount === 0
|
|
239
264
|
|
|
265
|
+
const config = await readPulseConfig()
|
|
266
|
+
// 'components' (default) — the standard pulse_intake/sketch/intent pipeline
|
|
267
|
+
// and src/ui/* component-first mandate below apply as normal.
|
|
268
|
+
// 'freeform' — the user has opted this project out of that pipeline: build
|
|
269
|
+
// plain hand-written HTML/CSS, no src/ui/* components, no intake/sketch
|
|
270
|
+
// ceremony. Read once per pulse://start fetch (project-wide, not a per-page
|
|
271
|
+
// choice) so it applies automatically every session without the user
|
|
272
|
+
// having to repeat it — set once in pulse.config.js, honoured everywhere.
|
|
273
|
+
const isFreeform = config.design === 'freeform'
|
|
274
|
+
|
|
275
|
+
const freeformBanner = isFreeform ? `
|
|
276
|
+
> ⚠ **\`design: 'freeform'\` is set in pulse.config.js.** This project defaults to **Mode B — creative override** (see the persona's "Design Freedom" rule) on every page, every session — you do not need to ask the user or re-decide per page.
|
|
277
|
+
> - Skip \`pulse_intake\` → \`pulse_sketch\` → \`pulse_intent\`'s component scaffolding. Go straight to the spec.
|
|
278
|
+
> - Write plain hand-rolled HTML/CSS in the view — any structure, any class names, no \`@invisibleloop/pulse/ui\` components required.
|
|
279
|
+
> - **Still write the override comment at the top of every new spec file** — \`// component-free — creative override: <reason>\` — even though the mode is project-wide. \`pulse_review\` detects Mode B by reading that comment from each file's source, not from this config; a spec without it will be flagged.
|
|
280
|
+
> - Functional atoms (\`button\`, \`input\`, \`badge\`, \`modal\`) can still come from components where there's no design reason not to — Mode B doesn't forbid them, it just removes the obligation.
|
|
281
|
+
> - The pass bar is unchanged from Mode B: Lighthouse 100 on Accessibility, Best Practices, and SEO (desktop + mobile), CLS 0.00, \`<main id="main-content">\`, and hex-in-\`theme.css\`-only — all still enforced by \`pulse_validate\`/\`/verify\`/\`pulse_review\`.
|
|
282
|
+
` : ''
|
|
283
|
+
|
|
240
284
|
return ({
|
|
241
285
|
contents: [{
|
|
242
286
|
uri: 'pulse://start',
|
|
@@ -246,19 +290,27 @@ server.registerResource(
|
|
|
246
290
|
${isNewProject
|
|
247
291
|
? `> **New project detected** — no pages found in src/pages/ yet.`
|
|
248
292
|
: `> **Existing project** — ${pageCount} page${pageCount !== 1 ? 's' : ''} found.`}
|
|
249
|
-
|
|
293
|
+
${freeformBanner}
|
|
250
294
|
---
|
|
251
295
|
|
|
252
296
|
## What are you doing?
|
|
253
297
|
|
|
254
298
|
### A — New page or new site from scratch
|
|
255
|
-
|
|
299
|
+
${isFreeform
|
|
300
|
+
? `Skip the intake/sketch/intent component pipeline (this project is \`design: 'freeform'\` — Mode B on every page). Fetch \`pulse://workflow\` for the phase/gate sequence, but treat step 3a as already decided: Mode B, every page, no re-asking.
|
|
301
|
+
|
|
302
|
+
Quick checklist before your first line of code:
|
|
303
|
+
1. Ask the user for design inspiration first (a site, screenshot, or mood board) if they haven't already described a direction
|
|
304
|
+
2. Ask: **light or dark?** Pulse renders dark when \`meta.theme\` is unset — decide before writing a single line
|
|
305
|
+
3. Write the spec directly with hand-rolled HTML/CSS, including the \`// component-free — creative override: <reason>\` comment at the top of the file
|
|
306
|
+
4. Fetch \`pulse://guide/design-references\` for aesthetic direction if useful — skip \`pulse://guide/components\``
|
|
307
|
+
: `Fetch \`pulse://workflow\` for the full phase/gate sequence, then follow the intake → sketch → intent pipeline.
|
|
256
308
|
|
|
257
309
|
Quick checklist before your first line of code:
|
|
258
310
|
1. Ask the user for design inspiration first (a site, screenshot, or mood board)
|
|
259
311
|
2. Ask: **light or dark?** Pulse renders dark when \`meta.theme\` is unset — decide before writing a single line
|
|
260
312
|
3. Run \`pulse_intake\` → \`pulse_sketch\` → \`pulse_intent\`
|
|
261
|
-
4. Fetch \`pulse://guide/templates\` + \`pulse://guide/design-references\` for aesthetic direction
|
|
313
|
+
4. Fetch \`pulse://guide/templates\` + \`pulse://guide/design-references\` for aesthetic direction`}
|
|
262
314
|
|
|
263
315
|
### B — Editing an existing page, adding a section, or fixing a bug
|
|
264
316
|
Fetch \`pulse://quickstart\` — workflow phases, spec skeleton, components, and theming in one fetch.
|
|
@@ -414,6 +466,8 @@ export default {
|
|
|
414
466
|
| Color picker | \`<input type="color" data-event="change:setColor">\` | same as select |
|
|
415
467
|
| Range slider | \`<input type="range" data-event="input:setVolume">\` | \`input\` fires on every drag step |
|
|
416
468
|
| 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 |
|
|
469
|
+
| Continuous drag (vertex, panel resize, custom slider handle) | \`data-event="pointerdown:start pointermove:drag pointerup:end"\` | one element, multiple space-separated bindings — \`pointerdown\` captures the pointer so \`pointermove\`/\`pointerup\` keep firing on that element even off-bounds; \`pointercancel\` also routes to the \`pointerup\` mutation |
|
|
470
|
+
| Keyboard shortcut / arrow-key nudge | \`<div tabindex="0" data-event="keydown:onKey">\` | fires on \`keydown\`; read \`e.key\` in the mutation |
|
|
417
471
|
|
|
418
472
|
**Text input pattern (uncontrolled):**
|
|
419
473
|
\`\`\`js
|
|
@@ -438,6 +492,25 @@ actions: {
|
|
|
438
492
|
|
|
439
493
|
**\`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).
|
|
440
494
|
|
|
495
|
+
**Continuous drag pattern (pointer events):**
|
|
496
|
+
\`\`\`js
|
|
497
|
+
// view — pointerdown/pointermove/pointerup all bound on the same element
|
|
498
|
+
view: (state) => \`
|
|
499
|
+
<circle
|
|
500
|
+
cx="\${state.x}" cy="\${state.y}" r="8"
|
|
501
|
+
data-event="pointerdown:startDrag pointermove:dragVertex pointerup:endDrag">
|
|
502
|
+
</circle>
|
|
503
|
+
\`,
|
|
504
|
+
mutations: {
|
|
505
|
+
startDrag: (state) => ({ dragging: true }),
|
|
506
|
+
// gate on \`dragging\` — pointermove fires on every cursor move over the
|
|
507
|
+
// element, not just while a drag is active
|
|
508
|
+
dragVertex: (state, e) => state.dragging ? { x: e.clientX, y: e.clientY } : {},
|
|
509
|
+
endDrag: (state) => ({ dragging: false }),
|
|
510
|
+
}
|
|
511
|
+
\`\`\`
|
|
512
|
+
\`pointerdown\` calls \`setPointerCapture\` on its target automatically — no manual capture code needed. \`pointercancel\` (browser-interrupted gestures) fires the same mutation as \`pointerup\`, so a drag can never get stuck "in progress".
|
|
513
|
+
|
|
441
514
|
---
|
|
442
515
|
|
|
443
516
|
## Key components (quick reference)
|
|
@@ -747,18 +820,9 @@ server.registerTool(
|
|
|
747
820
|
if (content && file) {
|
|
748
821
|
return text('Error: provide only one of content or file, not both')
|
|
749
822
|
}
|
|
750
|
-
|
|
751
|
-
if (file) {
|
|
752
|
-
if (!fs.existsSync(file)) {
|
|
753
|
-
return text(`File not found: ${file}`)
|
|
754
|
-
}
|
|
755
|
-
content = fs.readFileSync(file, 'utf8')
|
|
756
|
-
// Validate from the file's own directory so relative imports resolve
|
|
757
|
-
// correctly for pages in subdirectories
|
|
758
|
-
return validateContent(content, path.dirname(file))
|
|
759
|
-
}
|
|
760
823
|
|
|
761
|
-
|
|
824
|
+
const result = file ? await validateFile(file, ROOT) : await validateContent(content, ROOT)
|
|
825
|
+
return text(formatValidationResult(result, { nextSteps: true }))
|
|
762
826
|
}
|
|
763
827
|
)
|
|
764
828
|
|
|
@@ -804,8 +868,8 @@ Rules for the spec you write:
|
|
|
804
868
|
const content = fs.readFileSync(fullPath, 'utf8')
|
|
805
869
|
// Validate from the file's own directory so relative imports resolve
|
|
806
870
|
// correctly for pages in subdirectories (src/pages/news/index.js)
|
|
807
|
-
const
|
|
808
|
-
if (
|
|
871
|
+
const result = await validateContent(content, ROOT, path.dirname(fullPath))
|
|
872
|
+
if (!result.valid) return text(formatValidationResult(result))
|
|
809
873
|
|
|
810
874
|
const route = derivedRouteFromName(name)
|
|
811
875
|
return text(`Validated ${path.relative(ROOT, fullPath)} → route "${route}"`)
|
|
@@ -1196,8 +1260,19 @@ server.registerTool(
|
|
|
1196
1260
|
return text(`Dev server specs reloaded (hot) on port ${port}`)
|
|
1197
1261
|
}
|
|
1198
1262
|
|
|
1199
|
-
// Slow path — server not running or unresponsive, do a full kill/restart
|
|
1200
|
-
|
|
1263
|
+
// Slow path — server not running or unresponsive, do a full kill/restart.
|
|
1264
|
+
// Only kill a process on this port if it's actually a Pulse server — see
|
|
1265
|
+
// killPulseServerOnPort's doc comment. If something else owns the port,
|
|
1266
|
+
// report that instead of starting a second server that will just fail
|
|
1267
|
+
// to bind (or, worse, silently kill someone else's unrelated process).
|
|
1268
|
+
const { killed, skipped } = killPulseServerOnPort(port)
|
|
1269
|
+
if (skipped.length > 0 && killed.length === 0) {
|
|
1270
|
+
return text(
|
|
1271
|
+
`Port ${port} is occupied by a process that is not a Pulse server — refusing to kill it:\n` +
|
|
1272
|
+
skipped.map(s => ` PID ${s.pid}: ${s.command}`).join('\n') +
|
|
1273
|
+
`\n\nFree the port yourself, or set a different port in pulse.config.js.`
|
|
1274
|
+
)
|
|
1275
|
+
}
|
|
1201
1276
|
|
|
1202
1277
|
const devScript = new URL('../cli/dev.js', import.meta.url).pathname
|
|
1203
1278
|
const proc = spawn(process.execPath, [devScript, '--root', ROOT], { detached: true, stdio: 'ignore' })
|
|
@@ -1241,8 +1316,17 @@ server.registerTool(
|
|
|
1241
1316
|
return resolve(text(`Build failed:\n${build.stderr || build.stdout}`))
|
|
1242
1317
|
}
|
|
1243
1318
|
|
|
1244
|
-
// Kill
|
|
1245
|
-
|
|
1319
|
+
// Kill only a Pulse server on prodPort — see killPulseServerOnPort's doc
|
|
1320
|
+
// comment. Refuse (don't kill, don't start a second server on the same
|
|
1321
|
+
// port) if something else owns it.
|
|
1322
|
+
const { killed: prodKilled, skipped: prodSkipped } = killPulseServerOnPort(prodPort)
|
|
1323
|
+
if (prodSkipped.length > 0 && prodKilled.length === 0) {
|
|
1324
|
+
return resolve(text(
|
|
1325
|
+
`Build succeeded, but port ${prodPort} is occupied by a process that is not a Pulse server — refusing to kill it:\n` +
|
|
1326
|
+
prodSkipped.map(s => ` PID ${s.pid}: ${s.command}`).join('\n') +
|
|
1327
|
+
`\n\nFree the port yourself, or set a different port in pulse.config.js.`
|
|
1328
|
+
))
|
|
1329
|
+
}
|
|
1246
1330
|
|
|
1247
1331
|
// Start prod server detached on prodPort
|
|
1248
1332
|
const startScript = new URL('../cli/start.js', import.meta.url).pathname
|
|
@@ -1258,6 +1342,31 @@ server.registerTool(
|
|
|
1258
1342
|
})
|
|
1259
1343
|
)
|
|
1260
1344
|
|
|
1345
|
+
// ---------------------------------------------------------------------------
|
|
1346
|
+
// pulse_check_bundles — inspect what's actually inside the production bundles
|
|
1347
|
+
// ---------------------------------------------------------------------------
|
|
1348
|
+
// Lighthouse checks scores, not contents. A bundle can be small enough to pass
|
|
1349
|
+
// every score and still contain something wrong: server-only code that leaked
|
|
1350
|
+
// through the build's hydration-need check (found via real dogfooding — a
|
|
1351
|
+
// page with no mutations/actions/persist got bundled anyway, and a bundle
|
|
1352
|
+
// that imported a node:fs-based helper failed the build outright), or a
|
|
1353
|
+
// boot file generated for a page that should ship zero client JS. This reads
|
|
1354
|
+
// the real files in public/dist/ after pulse_build, not just the manifest.
|
|
1355
|
+
|
|
1356
|
+
server.registerTool(
|
|
1357
|
+
'pulse_check_bundles',
|
|
1358
|
+
{
|
|
1359
|
+
description: `Inspect the actual production bundle files in public/dist/ after pulse_build — not just their sizes, their contents. Checks for two things Lighthouse's score-based gate cannot catch:
|
|
1360
|
+
|
|
1361
|
+
1. A boot bundle exists for a page that doesn't need one (no mutations/actions/persist in its spec) — wasted output, and the exact shape of bug that can make a production build fail outright if that page also imports a server-only helper module.
|
|
1362
|
+
2. A boot bundle contains a literal reference to a Node built-in (node:fs, node:crypto, etc.) — a sign server-only code leaked into a client bundle instead of being stripped.
|
|
1363
|
+
|
|
1364
|
+
Call this as part of the full /verify pass, after pulse_build, alongside Lighthouse — not part of /verify --quick, since it needs a production build to inspect.`,
|
|
1365
|
+
inputSchema: {},
|
|
1366
|
+
},
|
|
1367
|
+
() => text(formatBundleCheck(checkBundles(ROOT)))
|
|
1368
|
+
)
|
|
1369
|
+
|
|
1261
1370
|
// ---------------------------------------------------------------------------
|
|
1262
1371
|
// pulse_review
|
|
1263
1372
|
// ---------------------------------------------------------------------------
|
|
@@ -1295,352 +1404,13 @@ any feature build.
|
|
|
1295
1404
|
source = fs.readFileSync(file, 'utf8')
|
|
1296
1405
|
}
|
|
1297
1406
|
|
|
1298
|
-
// Quick mode — lightweight structural checks only, no full checklist or Lighthouse gate
|
|
1299
1407
|
if (quick) {
|
|
1300
|
-
const
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
// Try to render the view
|
|
1304
|
-
let renderedHtml = ''
|
|
1305
|
-
if (file) {
|
|
1306
|
-
try {
|
|
1307
|
-
const mod = await import(`${file}?quick=${Date.now()}`)
|
|
1308
|
-
const spec = mod.default
|
|
1309
|
-
if (spec && typeof spec.view === 'function') {
|
|
1310
|
-
renderedHtml = spec.view(spec.state || {}, {})
|
|
1311
|
-
}
|
|
1312
|
-
} catch { /* ignore — server data dependency */ }
|
|
1313
|
-
}
|
|
1314
|
-
|
|
1315
|
-
if (renderedHtml) {
|
|
1316
|
-
if (!/<main[^>]*id=["']?main-content/.test(renderedHtml))
|
|
1317
|
-
issues.push('✗ Missing `<main id="main-content">` landmark')
|
|
1318
|
-
if (/<input[^>]*data-event/.test(renderedHtml))
|
|
1319
|
-
issues.push('✗ `data-event` on `<input>` — destroys focus on every keystroke, use FormData in onStart instead')
|
|
1320
|
-
if (/(className|htmlFor|onClick)=/.test(renderedHtml))
|
|
1321
|
-
issues.push('✗ React patterns found (className / htmlFor / onClick) — use class, for, data-event')
|
|
1322
|
-
if (/tabindex=["']?([1-9]\d*)/.test(renderedHtml))
|
|
1323
|
-
issues.push('✗ Positive tabindex found — remove, reorder DOM instead')
|
|
1324
|
-
const rawNavInSource = (source.match(/<nav[\s>]/gi) || []).length
|
|
1325
|
-
if (rawNavInSource > 0)
|
|
1326
|
-
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>.`)
|
|
1327
|
-
const viewBlockStripped = renderedHtml
|
|
1328
|
-
.replace(/href=["']#[^"']*["']/g, 'href="#"')
|
|
1329
|
-
.replace(/id=["'][^"']*["']/g, 'id=""')
|
|
1330
|
-
// Strip CSS custom property assignments from style attrs (--foo:#hex is a runtime
|
|
1331
|
-
// data binding, not an authored colour — e.g. style="--swatch-bg:#E8524A").
|
|
1332
|
-
// Standard property assignments like color:#000 are left intact and still flagged.
|
|
1333
|
-
.replace(/style="([^"]*)"/g, (_, v) => `style="${v.replace(/--[a-zA-Z][^:]*:[^;"]*/g, '')}"`)
|
|
1334
|
-
if (/#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3}\b(?![0-9a-fA-F])/.test(viewBlockStripped))
|
|
1335
|
-
warnings.push('⚠ Possible hex colour in rendered HTML — use var(--ui-*) tokens')
|
|
1336
|
-
|
|
1337
|
-
// Component pattern checks (unless creative override declared)
|
|
1338
|
-
if (!/component.free|creative\s+override/i.test(source)) {
|
|
1339
|
-
if (/<[^>]+class="[^"]*\bhero\b/.test(renderedHtml) && !source.includes('hero('))
|
|
1340
|
-
warnings.push('⚠ `.hero` class in HTML but no `hero()` component — use the component or declare creative override')
|
|
1341
|
-
if (/<[^>]+class="[^"]*\bcard\b/.test(renderedHtml) && !source.includes('card('))
|
|
1342
|
-
warnings.push('⚠ `.card` class in HTML but no `card()` component — use the component or declare creative override')
|
|
1343
|
-
}
|
|
1344
|
-
}
|
|
1345
|
-
|
|
1346
|
-
// Source-level checks (don't need rendered HTML).
|
|
1347
|
-
// Use line-anchored regex for actions: to avoid false positives from component
|
|
1348
|
-
// prop strings like hero({ actions: '...' }) or card({ actions: '...' }).
|
|
1349
|
-
const hasActionsBlock = /^\s{0,4}actions\s*:/m.test(source)
|
|
1350
|
-
if (hasActionsBlock && !source.includes('onError:'))
|
|
1351
|
-
issues.push('✗ Action missing `onError` — required, will throw at runtime')
|
|
1352
|
-
if (hasActionsBlock && !source.includes('onSuccess:'))
|
|
1353
|
-
issues.push('✗ Action missing `onSuccess` — required')
|
|
1354
|
-
if (source.includes('hydrate:'))
|
|
1355
|
-
issues.push('✗ `hydrate` is set manually — remove it, the framework sets it automatically')
|
|
1356
|
-
if (/meta\s*:\s*async/.test(source))
|
|
1357
|
-
issues.push('✗ `meta` is an async function — `meta` must be a plain object; make individual fields async instead')
|
|
1358
|
-
|
|
1359
|
-
// Structural confirmations — always shown regardless of issues, so the agent
|
|
1360
|
-
// gets positive signal on what's correct, not just a list of failures.
|
|
1361
|
-
const confirms = []
|
|
1362
|
-
if (renderedHtml) {
|
|
1363
|
-
confirms.push(/<main[^>]*id=["']?main-content/.test(renderedHtml) ? '✓ `<main id="main-content">` landmark present' : null)
|
|
1364
|
-
const rawNavInSource = (source.match(/<nav[\s>]/gi) || []).length
|
|
1365
|
-
if (rawNavInSource === 0) confirms.push('✓ No raw <nav> in spec source — nav() component handles landmarks correctly')
|
|
1366
|
-
const headings = [...renderedHtml.matchAll(/<h([1-6])[^>]*>/g)].map(m => parseInt(m[1]))
|
|
1367
|
-
if (headings.length > 0) {
|
|
1368
|
-
const orderOk = headings.every((h, i) => i === 0 || h <= headings[i - 1] + 1)
|
|
1369
|
-
confirms.push(orderOk ? `✓ Heading order correct (${headings.map(h => `h${h}`).join(' → ')})` : `⚠ Heading order may be skipping levels (${headings.map(h => `h${h}`).join(' → ')})`)
|
|
1370
|
-
}
|
|
1371
|
-
const interactiveWithoutLabel = [...renderedHtml.matchAll(/<button(?![^>]*aria-label)[^>]*>\s*<\/button>/g)]
|
|
1372
|
-
confirms.push(interactiveWithoutLabel.length === 0 ? '✓ No empty buttons without aria-label detected' : `⚠ ${interactiveWithoutLabel.length} button(s) appear empty — check aria-label`)
|
|
1373
|
-
const dataEvents = [...renderedHtml.matchAll(/data-event=/g)].length
|
|
1374
|
-
if (dataEvents > 0) confirms.push(`✓ ${dataEvents} data-event binding(s) found`)
|
|
1375
|
-
const creativeOverride = /component.free|creative\s+override/i.test(source)
|
|
1376
|
-
if (creativeOverride) confirms.push('✓ Creative override declared — component pattern checks are advisory')
|
|
1377
|
-
}
|
|
1378
|
-
|
|
1379
|
-
const lines = ['## Quick review\n']
|
|
1380
|
-
|
|
1381
|
-
if (confirms.filter(Boolean).length > 0) {
|
|
1382
|
-
lines.push('### Structural checks\n')
|
|
1383
|
-
for (const c of confirms.filter(Boolean)) lines.push(c)
|
|
1384
|
-
lines.push('')
|
|
1385
|
-
}
|
|
1386
|
-
|
|
1387
|
-
if (issues.length === 0 && warnings.length === 0) {
|
|
1388
|
-
lines.push('✓ No issues found. Run `pulse_validate` next, then `/verify`.')
|
|
1389
|
-
} else {
|
|
1390
|
-
if (issues.length) {
|
|
1391
|
-
lines.push('### Fix before proceeding\n')
|
|
1392
|
-
for (const i of issues) lines.push(i)
|
|
1393
|
-
lines.push('')
|
|
1394
|
-
}
|
|
1395
|
-
if (warnings.length) {
|
|
1396
|
-
lines.push('### Warnings (check these)\n')
|
|
1397
|
-
for (const w of warnings) lines.push(w)
|
|
1398
|
-
lines.push('')
|
|
1399
|
-
}
|
|
1400
|
-
lines.push('---\nFix issues, then run `pulse_validate` → `/verify --quick`.')
|
|
1401
|
-
}
|
|
1402
|
-
|
|
1403
|
-
return text(lines.join('\n'))
|
|
1404
|
-
}
|
|
1405
|
-
|
|
1406
|
-
// Run the validator in a child process (same as pulse_validate)
|
|
1407
|
-
let validationResult = '(could not run validator)'
|
|
1408
|
-
if (file) {
|
|
1409
|
-
const validatorScript = new URL('./validate-worker.js', import.meta.url).pathname
|
|
1410
|
-
try {
|
|
1411
|
-
validationResult = execFileSync(process.execPath, [validatorScript, file], {
|
|
1412
|
-
timeout: 10_000,
|
|
1413
|
-
encoding: 'utf8',
|
|
1414
|
-
}).trim()
|
|
1415
|
-
} catch (err) {
|
|
1416
|
-
validationResult = err.stdout?.trim() || err.message
|
|
1417
|
-
}
|
|
1418
|
-
} else {
|
|
1419
|
-
// Content-only mode — write to temp file and validate
|
|
1420
|
-
const tmpFile = path.join(os.tmpdir(), `pulse-review-${Date.now()}.js`)
|
|
1421
|
-
fs.writeFileSync(tmpFile, source, 'utf8')
|
|
1422
|
-
const validatorScript = new URL('./validate-worker.js', import.meta.url).pathname
|
|
1423
|
-
try {
|
|
1424
|
-
validationResult = execFileSync(process.execPath, [validatorScript, tmpFile], {
|
|
1425
|
-
timeout: 10_000,
|
|
1426
|
-
encoding: 'utf8',
|
|
1427
|
-
}).trim()
|
|
1428
|
-
} catch (err) {
|
|
1429
|
-
validationResult = err.stdout?.trim() || err.message
|
|
1430
|
-
} finally {
|
|
1431
|
-
try { fs.unlinkSync(tmpFile) } catch {}
|
|
1432
|
-
}
|
|
1433
|
-
}
|
|
1434
|
-
|
|
1435
|
-
// Try to render the view with initial state
|
|
1436
|
-
let renderedHtml = ''
|
|
1437
|
-
let renderNote = ''
|
|
1438
|
-
try {
|
|
1439
|
-
const mod = await import(`${file}?review=${Date.now()}`)
|
|
1440
|
-
const spec = mod.default
|
|
1441
|
-
if (spec && typeof spec.view === 'function') {
|
|
1442
|
-
renderedHtml = spec.view(spec.state || {}, {})
|
|
1443
|
-
} else if (spec && typeof spec.view === 'object') {
|
|
1444
|
-
const segments = Object.entries(spec.view)
|
|
1445
|
-
.map(([k, fn]) => `<!-- segment: ${k} -->\n${typeof fn === 'function' ? fn(spec.state || {}, {}) : ''}`)
|
|
1446
|
-
.join('\n')
|
|
1447
|
-
renderedHtml = segments
|
|
1448
|
-
renderNote = '(streamed spec — segments rendered individually)'
|
|
1449
|
-
}
|
|
1450
|
-
} catch {
|
|
1451
|
-
renderNote = '(view could not be rendered — may depend on server data)'
|
|
1408
|
+
const result = await runQuickReview(source, file)
|
|
1409
|
+
return text(formatQuickReview(result, { agentFacing: true }))
|
|
1452
1410
|
}
|
|
1453
1411
|
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
You are now a **senior code reviewer**. Read the spec, find every problem, and fix them all before reporting back.
|
|
1457
|
-
|
|
1458
|
-
---
|
|
1459
|
-
|
|
1460
|
-
## Validator output
|
|
1461
|
-
|
|
1462
|
-
${validationResult}
|
|
1463
|
-
|
|
1464
|
-
${validationResult.includes('✓') ? '' : `
|
|
1465
|
-
## Spec source (validation failed — showing for debugging)
|
|
1466
|
-
|
|
1467
|
-
\`\`\`js
|
|
1468
|
-
${source}
|
|
1469
|
-
\`\`\`
|
|
1470
|
-
`}
|
|
1471
|
-
|
|
1472
|
-
${renderNote.includes('could not') ? `
|
|
1473
|
-
## Render error
|
|
1474
|
-
|
|
1475
|
-
${renderNote}
|
|
1476
|
-
|
|
1477
|
-
\`\`\`js
|
|
1478
|
-
${source}
|
|
1479
|
-
\`\`\`
|
|
1480
|
-
` : ''}
|
|
1481
|
-
|
|
1482
|
-
---
|
|
1483
|
-
|
|
1484
|
-
## Auto-checked items
|
|
1485
|
-
|
|
1486
|
-
${(() => {
|
|
1487
|
-
const checks = []
|
|
1488
|
-
|
|
1489
|
-
// Check rendered HTML
|
|
1490
|
-
if (renderedHtml) {
|
|
1491
|
-
// Positive tabindex
|
|
1492
|
-
const posTabindex = /tabindex=["']?([1-9]\d*)/.test(renderedHtml)
|
|
1493
|
-
checks.push(posTabindex ? '✗ **Positive tabindex found** — remove tabindex > 0, reorder DOM instead' : '✓ No positive tabindex')
|
|
1494
|
-
|
|
1495
|
-
// data-event on input
|
|
1496
|
-
const dataEventInput = /<input[^>]*data-event/.test(renderedHtml)
|
|
1497
|
-
checks.push(dataEventInput ? '✗ **data-event on <input>** — this destroys focus on every keystroke' : '✓ No data-event on text inputs')
|
|
1498
|
-
|
|
1499
|
-
// React patterns
|
|
1500
|
-
const reactPatterns = /(className|htmlFor|onClick)=/.test(renderedHtml)
|
|
1501
|
-
checks.push(reactPatterns ? '✗ **React patterns found** — use class, for, data-event instead' : '✓ No React patterns (className/htmlFor/onClick)')
|
|
1502
|
-
|
|
1503
|
-
// Emoji in HTML — strip SVG, data URIs, and aria-hidden elements before checking.
|
|
1504
|
-
// Mathematical/decorative Unicode in aria-hidden elements is intentional and accessible.
|
|
1505
|
-
const htmlNoSvg = renderedHtml
|
|
1506
|
-
.replace(/<svg[\s\S]*?<\/svg>/gi, '')
|
|
1507
|
-
.replace(/data:[^"']+/g, '')
|
|
1508
|
-
.replace(/<[^>]+aria-hidden=["']true["'][^>]*>[\s\S]*?<\/[^>]+>/gi, '')
|
|
1509
|
-
// Restrict to the emoji Unicode blocks only — excludes math symbols (∑, π, ∞, etc.),
|
|
1510
|
-
// arrows, and other non-emoji Unicode that are commonly used as decorative text.
|
|
1511
|
-
const emojiRegex = /[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA6F}\u{1FA70}-\u{1FAFF}]/u
|
|
1512
|
-
const hasEmoji = emojiRegex.test(htmlNoSvg)
|
|
1513
|
-
checks.push(hasEmoji ? '⚠ **Emoji in HTML** — use icon components or aria-label instead (verify: may be intentional icon)' : '✓ No emoji in view HTML')
|
|
1514
|
-
|
|
1515
|
-
// Main landmark
|
|
1516
|
-
const hasMain = /<main[^>]*id=["']?main-content/.test(renderedHtml)
|
|
1517
|
-
checks.push(hasMain ? '✓ <main id="main-content"> present' : '✗ **Missing main landmark** — add <main id="main-content">')
|
|
1518
|
-
}
|
|
1519
|
-
|
|
1520
|
-
// Check spec source
|
|
1521
|
-
// Hex colours in view — strip anchor hrefs, id attributes, template literal expressions,
|
|
1522
|
-
// and style attributes before checking so runtime-generated values (e.g. style="--swatch-bg:${hex}")
|
|
1523
|
-
// are never flagged. Only flag literal hex values authored directly in the view string.
|
|
1524
|
-
const viewBlock = source.slice(source.indexOf('view:'))
|
|
1525
|
-
const viewBlockStripped = viewBlock
|
|
1526
|
-
.replace(/href=["']#[^"']*["']/g, 'href="#"') // href="#anchor" → href="#"
|
|
1527
|
-
.replace(/id=["'][^"']*["']/g, 'id=""') // id="foo" → id=""
|
|
1528
|
-
.replace(/\$\{[^}]*\}/g, '${…}') // ${expr} — runtime values, not authored hex
|
|
1529
|
-
const hexInView = /#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3}\b(?![0-9a-fA-F])/.test(viewBlockStripped)
|
|
1530
|
-
if (hexInView) {
|
|
1531
|
-
checks.push('⚠ **Possible hex colour in view** — use var(--ui-*) tokens only')
|
|
1532
|
-
} else {
|
|
1533
|
-
checks.push('✓ No obvious hex colours in view')
|
|
1534
|
-
}
|
|
1535
|
-
|
|
1536
|
-
// XSS test coverage — check whether the test file includes a <script>alert assertion
|
|
1537
|
-
// for each user-controlled string interpolated into the view.
|
|
1538
|
-
// Heuristic: if the view interpolates any exported string variable (escape(), or direct
|
|
1539
|
-
// string props), the test file should contain a '<script>' XSS assertion.
|
|
1540
|
-
if (file) {
|
|
1541
|
-
const testFile = file.replace(/\.js$/, '.test.js')
|
|
1542
|
-
let xssNote = ''
|
|
1543
|
-
if (fs.existsSync(testFile)) {
|
|
1544
|
-
const testSrc = fs.readFileSync(testFile, 'utf8')
|
|
1545
|
-
const hasXssAssertion = testSrc.includes('<script>') || testSrc.includes('xss') || testSrc.includes('script>alert')
|
|
1546
|
-
if (!hasXssAssertion) {
|
|
1547
|
-
xssNote = '⚠ **No XSS test found** — test file exists but has no `<script>alert` assertion. Add at least one test that passes a `\'<script>alert(1)</script>\'` string as a user-controlled input and asserts it does not appear unescaped in the output.'
|
|
1548
|
-
} else {
|
|
1549
|
-
xssNote = '✓ XSS test assertion present'
|
|
1550
|
-
}
|
|
1551
|
-
} else {
|
|
1552
|
-
xssNote = '○ No test file found — create one at ' + path.basename(testFile)
|
|
1553
|
-
}
|
|
1554
|
-
checks.push(xssNote)
|
|
1555
|
-
}
|
|
1556
|
-
|
|
1557
|
-
// Creative override detection — check if spec declares component-free mode
|
|
1558
|
-
const creativeOverride = /component.free|creative\s+override|raw\s+HTML\s+throughout/i.test(source)
|
|
1559
|
-
checks.push(creativeOverride
|
|
1560
|
-
? '⚡ **Creative override declared** — component pattern checks are advisory only. Lighthouse 100 on Accessibility, Best Practices, and SEO (desktop + mobile) plus CLS 0.00 is the pass bar.'
|
|
1561
|
-
: '○ No creative override declared — component checks apply')
|
|
1562
|
-
|
|
1563
|
-
return checks.join('\n')
|
|
1564
|
-
})()}
|
|
1565
|
-
|
|
1566
|
-
---
|
|
1567
|
-
|
|
1568
|
-
## Review checklist
|
|
1569
|
-
|
|
1570
|
-
Work through every item. Fix anything that fails. Refer to the spec source at ${file} as needed — do not ask me to paste it.
|
|
1571
|
-
|
|
1572
|
-
### Structure
|
|
1573
|
-
- [ ] \`route\` is set explicitly — not left to auto-discovery
|
|
1574
|
-
- [ ] \`hydrate\` is NOT set manually — the framework injects it automatically. Remove it if present.
|
|
1575
|
-
- [ ] \`state\` shape is consistent — no fields that flip between null/string/boolean
|
|
1576
|
-
- [ ] \`meta.title\` is meaningful and unique to this page
|
|
1577
|
-
- [ ] \`meta.description\` is a real description, not "Built with Pulse"
|
|
1578
|
-
|
|
1579
|
-
### Mutations & actions
|
|
1580
|
-
- [ ] Every mutation returns a plain partial object — no side effects, no fetch, no DOM access
|
|
1581
|
-
- [ ] \`constraints\` are used for bounds instead of conditional logic inside mutations
|
|
1582
|
-
- [ ] \`disabled\` in the view matches the constraint bounds — but check: is it redundant with the constraint, or does it serve a UX purpose?
|
|
1583
|
-
- [ ] Actions read user input from FormData in \`onStart\`, not from mirrored state
|
|
1584
|
-
- [ ] \`onStart\` sets a loading status, \`onSuccess\`/\`onError\` resolve it
|
|
1585
|
-
- [ ] A single \`status\` field is used instead of multiple boolean flags
|
|
1586
|
-
|
|
1587
|
-
### Components & HTML
|
|
1588
|
-
- [ ] Components from the UI library are used — no hand-written \`<button>\`, \`<input>\`, \`<table>\` etc where a component exists
|
|
1589
|
-
- [ ] **Component patterns check** — grep the rendered HTML for these class names:
|
|
1590
|
-
- \`.hero\`, \`.-hero\`, \`__hero\` → should use \`hero()\` component
|
|
1591
|
-
- \`.card\`, \`.product-card\`, \`.service-card\` → should use \`card()\` component
|
|
1592
|
-
- Any two-column image + text layout → should use \`media()\` component
|
|
1593
|
-
- \`.feature\`, \`.feature-card\` → should use \`feature()\` component
|
|
1594
|
-
|
|
1595
|
-
**Before flagging a pattern match**, ask: *can the \`hero()\` (or relevant) component actually reproduce this layout?* If the design uses full-viewport height, custom gradient glows, clamp-scaled display type, asymmetric layout, or other features the component doesn't support — that is a **creative override**, not a violation. Check the auto-checked items above: if "creative override declared" is shown, these checks are advisory only and Lighthouse is the pass bar.
|
|
1596
|
-
|
|
1597
|
-
If no creative override is declared and a component *could* reproduce the design, refactor to use it. Custom utility classes on top of components are fine (\`hero({ ... })\` + override CSS) — but do not write the entire structure from scratch when the component supports the layout.
|
|
1598
|
-
|
|
1599
|
-
- [ ] **Creative override** (fill in if applicable): *State the override reason here — e.g. "full-viewport gradient hero not achievable with hero() component"*. Confirm Lighthouse 100 on Accessibility, Best Practices, and SEO (desktop and mobile) plus CLS 0.00 before closing the review.
|
|
1600
|
-
- [ ] No \`data-event\` on text inputs — this destroys focus on every keystroke
|
|
1601
|
-
- [ ] No \`className\`, \`htmlFor\`, \`onClick=\`, or other React patterns
|
|
1602
|
-
- [ ] No hardcoded hex colours in view — only \`var(--ui-*)\` tokens (anchor \`href="#id"\` values are fine — those are not colours)
|
|
1603
|
-
- [ ] No emoji in view HTML (decorative emoji without accessible text is a fail; emoji with \`aria-label\` or inside \`<span aria-hidden="true">\` is acceptable)
|
|
1604
|
-
- [ ] **CTA must be wrapped in section/container** — grep the rendered HTML for \`<div class="ui-cta"\`. If it appears as a direct child of \`<main>\` or \`<div id="app">\` without a section or container wrapper, wrap it. CTA has no padding of its own.
|
|
1605
|
-
|
|
1606
|
-
### Accessibility
|
|
1607
|
-
- [ ] \`<main id="main-content">\` is present
|
|
1608
|
-
- [ ] Icon-only buttons have \`aria-label\`
|
|
1609
|
-
- [ ] \`aria-live\` and \`aria-label\` are NOT on the same element
|
|
1610
|
-
- [ ] Heading hierarchy is correct — no skipped levels, starts at h1
|
|
1611
|
-
- [ ] Disabled state uses the \`disabled\` attribute, not just CSS or opacity
|
|
1612
|
-
- [ ] **Keyboard focusability** — every element carrying \`data-event\`, \`data-store-event\`, \`data-dialog-open\`, or \`data-dialog-close\` is either a natively interactive element (\`button\`, \`a\`, \`input\`, \`select\`, \`textarea\`, \`summary\`) or has \`tabindex="0"\`. Scan the rendered HTML above — a \`<div>\`, \`<span>\`, \`<li>\`, or any other non-interactive tag with one of these attributes is a keyboard accessibility failure. Prefer \`<button>\` over a div + tabindex.
|
|
1613
|
-
- [ ] **Purpose** — every interactive element has a clear accessible name. Buttons have visible text or \`aria-label\`. Links use descriptive text — flag generic labels ("click here", "here", "read more", "more"). Form inputs have an associated \`<label for="id">\` or \`aria-label\` — \`placeholder\` alone is not a label (it disappears on focus and is not read by all screen readers).
|
|
1614
|
-
- [ ] **State** — interactive elements communicate their current state via ARIA:
|
|
1615
|
-
- Toggle controls (open/close, show/hide, expand/collapse) have \`aria-expanded="true|false"\` or \`aria-pressed="true|false"\`
|
|
1616
|
-
- While an action is running, the trigger button has \`aria-busy="true"\` or its visible label changes (e.g. "Saving…") — a spinner alone is not sufficient
|
|
1617
|
-
- Active navigation items have \`aria-current="page"\`
|
|
1618
|
-
- Selected items in a list, tab set, or option group have \`aria-selected="true"\`
|
|
1619
|
-
- [ ] **Tab order** — scan the rendered HTML for these failures:
|
|
1620
|
-
- No \`tabindex\` value greater than 0. \`tabindex="1"\` and above override the natural DOM order and almost always create a broken, unpredictable tab sequence. The only valid values are \`0\` (add to natural order) and \`-1\` (remove from order). If you find a positive tabindex, remove it and reorder the DOM instead.
|
|
1621
|
-
- Off-screen or visually hidden interactive content is removed from the tab order. Elements that are hidden via CSS alone (e.g. \`opacity:0\`, \`visibility:hidden\` without \`display:none\`, off-canvas menus, collapsed panels) but remain in the DOM must have \`tabindex="-1"\` or \`inert\` so keyboard users cannot tab into invisible controls.
|
|
1622
|
-
- DOM order matches the visual reading order. When CSS flexbox \`order\` or grid placement is used to visually reposition elements, the tab sequence follows the DOM — not the visual layout. Ensure the DOM is authored in the order a sighted user would read and interact with the page.
|
|
1623
|
-
|
|
1624
|
-
### Defensive coding
|
|
1625
|
-
- [ ] Any \`fetch\` in actions or server fetchers checks \`res.ok\` before calling \`.json()\`
|
|
1626
|
-
- [ ] Fetch errors use the safe pattern — NOT \`throw new Error(await res.text())\` which exposes raw HTML in toasts:
|
|
1627
|
-
\`\`\`js
|
|
1628
|
-
if (!res.ok) {
|
|
1629
|
-
let message = \`Request failed: \${res.status}\`
|
|
1630
|
-
try { const j = await res.json(); message = j.message || j.error || message } catch {}
|
|
1631
|
-
throw new Error(message)
|
|
1632
|
-
}
|
|
1633
|
-
\`\`\`
|
|
1634
|
-
- [ ] Optional chaining used for any data from external sources
|
|
1635
|
-
- [ ] URL params validated before use
|
|
1636
|
-
- [ ] \`onViewError\` defined if the view could crash on bad or missing data
|
|
1637
|
-
|
|
1638
|
-
---
|
|
1639
|
-
|
|
1640
|
-
Fix every issue you find. Then confirm what was changed.
|
|
1641
|
-
|
|
1642
|
-
**After confirming fixes: you are back in builder mode. Continue to the verification workflow — navigate to the page in the browser, take a screenshot, run Lighthouse desktop audit, run Lighthouse mobile audit. Do not stop at the review.**`)
|
|
1643
|
-
|
|
1412
|
+
const result = await runFullReview(source, file)
|
|
1413
|
+
return text(formatFullReview(result, { source, file, agentFacing: true }))
|
|
1644
1414
|
}
|
|
1645
1415
|
)
|
|
1646
1416
|
|
|
@@ -1662,18 +1432,66 @@ server.registerTool(
|
|
|
1662
1432
|
|
|
1663
1433
|
Call this as the **last step** of /verify — after Lighthouse desktop + mobile both pass 100/100/100, pulse_review is complete, and no further spec edits will be made.
|
|
1664
1434
|
|
|
1435
|
+
Pass \`route\` and \`mode: "full"\` (full /verify, not --quick) and this refuses to stamp unless \`pulse save-report\` was actually run for that route in the last 10 minutes — a real Lighthouse run that passed the score bar but was never saved (a documented step this has been silently skipped for before, since prose alone didn't stop it) leaves nothing for \`pulse report-server\` or a companion dashboard to show. Omit \`mode\` (or pass "quick") to skip this check — quick mode never runs Lighthouse, so there is nothing to have saved.
|
|
1436
|
+
|
|
1437
|
+
When \`route\` is passed, also marks any unresolved error-journal entries for that route as resolved (a clean pass this far means whatever was flagged either wasn't real or was already fixed by an earlier /verify step) — this previously only happened via the separate pulse_resolve_error tool, despite this tool's own description having claimed it happened here.
|
|
1438
|
+
|
|
1665
1439
|
The stop hook compares each changed spec's mtime against this stamp. Any spec newer than the stamp blocks the session. Do NOT call this before Lighthouse or before fixing issues found in pulse_review.`,
|
|
1666
|
-
inputSchema: {
|
|
1440
|
+
inputSchema: {
|
|
1441
|
+
route: z.string().optional().describe('The route just verified, e.g. "/dashboard" — enables the saved-report check and error-journal auto-resolve. Omit only if truly not tied to one route.'),
|
|
1442
|
+
mode: z.enum(['full', 'quick']).optional().describe('Which /verify mode this is. "full" enforces that a Lighthouse report was actually saved via pulse save-report in the last 10 minutes. Omit or "quick" skips that check.'),
|
|
1443
|
+
},
|
|
1667
1444
|
},
|
|
1668
|
-
() => {
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1445
|
+
({ route, mode } = {}) => text(formatStampResult(writeStamp(ROOT, { route, mode }), { agentFacing: true }))
|
|
1446
|
+
)
|
|
1447
|
+
|
|
1448
|
+
// ---------------------------------------------------------------------------
|
|
1449
|
+
// pulse_diagnose — read the dev-only error journal
|
|
1450
|
+
// ---------------------------------------------------------------------------
|
|
1451
|
+
// Errors that used to dead-end at console.error (server errors, SSR view
|
|
1452
|
+
// throws, post-hydration client view/action failures) are captured to
|
|
1453
|
+
// .pulse/errors.json by the dev server. This tool is the agent-facing read
|
|
1454
|
+
// side of that journal — a mechanized diagnostic instead of relying on the
|
|
1455
|
+
// agent to notice a console line or a user report.
|
|
1456
|
+
|
|
1457
|
+
server.registerTool(
|
|
1458
|
+
'pulse_diagnose',
|
|
1459
|
+
{
|
|
1460
|
+
description: `Read the dev-only error journal (.pulse/errors.json) — server errors, SSR view throws, and post-hydration client view/action failures that would otherwise only appear as a console.error line.
|
|
1461
|
+
|
|
1462
|
+
Call this when something seems broken and you're not sure why, or proactively after building/testing a page to check nothing threw during the session. Each entry has a route, a phase (view/action/server/guard), a message, a stack trace, and a resolved flag.
|
|
1463
|
+
|
|
1464
|
+
Pass \`route\` to filter to one page's errors — this is what /verify uses to check a specific page before stamping it. Omit it to see everything recorded this session.`,
|
|
1465
|
+
inputSchema: {
|
|
1466
|
+
route: z.string().optional().describe('Filter to errors on this route only, e.g. "/dashboard". Omit to see all recorded errors.'),
|
|
1467
|
+
includeResolved: z.boolean().optional().describe('Include entries already marked resolved. Default false — only unresolved errors are shown.'),
|
|
1468
|
+
},
|
|
1469
|
+
},
|
|
1470
|
+
({ route, includeResolved = false } = {}) => {
|
|
1471
|
+
const result = readJournal(ROOT, { route, includeResolved })
|
|
1472
|
+
let output = formatJournal(result, { route, includeResolved })
|
|
1473
|
+
if (result.exists && !result.error && result.entries.length > 0) {
|
|
1474
|
+
output += '\n---\nFix the underlying issue, then call `pulse_resolve_error` with the id (or route) to clear it from the unresolved list.'
|
|
1676
1475
|
}
|
|
1476
|
+
return text(output)
|
|
1477
|
+
}
|
|
1478
|
+
)
|
|
1479
|
+
|
|
1480
|
+
// ---------------------------------------------------------------------------
|
|
1481
|
+
// pulse_resolve_error — mark journal entries resolved
|
|
1482
|
+
// ---------------------------------------------------------------------------
|
|
1483
|
+
|
|
1484
|
+
server.registerTool(
|
|
1485
|
+
'pulse_resolve_error',
|
|
1486
|
+
{
|
|
1487
|
+
description: `Mark error journal entries as resolved after fixing the underlying issue. Pass a specific \`id\`, or \`route\` to resolve every unresolved entry for that page at once — /verify's stamp step calls this automatically for the target route on a clean pass, so you usually don't need to call it manually. Use it directly when you've fixed something pulse_diagnose surfaced but aren't running the full /verify loop right now.`,
|
|
1488
|
+
inputSchema: {
|
|
1489
|
+
id: z.string().optional().describe('Resolve one entry by its id.'),
|
|
1490
|
+
route: z.string().optional().describe('Resolve every unresolved entry for this route.'),
|
|
1491
|
+
},
|
|
1492
|
+
},
|
|
1493
|
+
({ id, route } = {}) => {
|
|
1494
|
+
return text(formatResolveResult(resolveEntries(ROOT, { id, route })))
|
|
1677
1495
|
}
|
|
1678
1496
|
)
|
|
1679
1497
|
|
|
@@ -2288,7 +2106,12 @@ If your page genuinely doesn't fit a pattern, start from pulse://guide/spec and
|
|
|
2288
2106
|
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.`)
|
|
2289
2107
|
lines.push(` Add \`theme: 'light'\` to your plan or brief if confirmed light.`)
|
|
2290
2108
|
lines.push(``)
|
|
2291
|
-
|
|
2109
|
+
if (key === 'dashboard') {
|
|
2110
|
+
lines.push(`2. **Design inspiration — reframed for a functional/internal tool.** The standard aesthetic-inspiration question ("a site you love") lands oddly for a work screen or admin tool — most people building one have no mood board, and asking anyway invites either an awkward non-answer or, worse, an agent skipping the question rather than reinterpreting it on the fly. Ask instead: "Do you have any reference for how this should look, or should I go with a clean, functional dark-mode style typical of internal tools? Also — what's the most important thing to see at a glance, and roughly how many items/rows will be on screen at once?" This still surfaces genuine visual references if the user has one (some do), while asking what actually matters for a dashboard: information density and priority, not mood.`)
|
|
2111
|
+
lines.push(` If the user does name a real visual reference (a URL, an image, or a well-known site), call \`pulse_extract_inspiration\` as normal. A bare mention with nothing to point to ("no, we just use a whiteboard") is not skippable — the question itself is still mandatory — it just isn't inspiration to extract; fold the answer into \`pulse_intake\`'s vibe/anti-style fields and move on.`)
|
|
2112
|
+
} else {
|
|
2113
|
+
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.`)
|
|
2114
|
+
}
|
|
2292
2115
|
lines.push(` (Skip if this came from pulse_intake — it already asked.)`)
|
|
2293
2116
|
|
|
2294
2117
|
lines.push(``)
|
|
@@ -2781,14 +2604,16 @@ server.registerTool(
|
|
|
2781
2604
|
server.registerTool(
|
|
2782
2605
|
'pulse_extract_inspiration',
|
|
2783
2606
|
{
|
|
2784
|
-
description: `Extract a structured design brief from a URL or
|
|
2607
|
+
description: `Extract a structured design brief from a URL, image, or well-known named reference the user has shared as inspiration.
|
|
2785
2608
|
|
|
2786
|
-
Call this when the user
|
|
2609
|
+
Call this when the user shares a URL, pastes/attaches an image, or names a site you have real knowledge of well enough to describe its actual design (e.g. "make it feel like linear.app"). It returns a structured extraction template that tells you exactly what to observe and capture. You then use your own browsing or vision tools to fill in the template, and feed the result into pulse_intake.
|
|
2787
2610
|
|
|
2788
2611
|
Works for:
|
|
2789
2612
|
- URLs: visit the site with your browser tool, observe the rendered page
|
|
2790
2613
|
- Images: use your vision capability to analyse the screenshot/photo the user shared
|
|
2791
|
-
- Named references: use your knowledge of the brand/site to populate the template
|
|
2614
|
+
- Named references you actually know: use your knowledge of the brand/site to populate the template
|
|
2615
|
+
|
|
2616
|
+
**Do not call this for a name alone with nothing to extract** — a small or local business the user mentioned in passing ("there's a place called X, I like their logo, but I haven't looked at their site") with no URL, no image, and no training knowledge of what it actually looks like. That's not an inspiration source, it's a name — calling this tool on it produces a fabricated brief dressed up as an observation. Skip straight to pulse_intake with only what the user has actually described.
|
|
2792
2617
|
|
|
2793
2618
|
The extracted values map directly to pulse_intake fields (palette, vibe, styleNotes, antiStyle, font).`,
|
|
2794
2619
|
inputSchema: {
|
|
@@ -2818,7 +2643,9 @@ The extracted values map directly to pulse_intake fields (palette, vibe, styleNo
|
|
|
2818
2643
|
lines.push('## Step 1 — Analyse the source (do this now)')
|
|
2819
2644
|
lines.push(`Source: **${source}**`)
|
|
2820
2645
|
lines.push('Use your vision capability or knowledge of this reference to fill in the template below.')
|
|
2821
|
-
lines.push('**Return a completed extraction with your observations filled in — not a blank template
|
|
2646
|
+
lines.push('**Return a completed extraction with your observations filled in — not a blank template.**')
|
|
2647
|
+
lines.push('')
|
|
2648
|
+
lines.push('> **If you have no reliable knowledge of this specific reference** (a small or local business the user named but you have no training data on, and they have not described what it looks like — e.g. "there\'s a place called X, I like their logo, but I haven\'t actually looked at their site"): do not guess or fabricate a brief. There is nothing to extract here — this is not a real inspiration source, just a name in passing. Skip this tool entirely and proceed with `pulse_intake` using only what the user has actually described (their own words about tone, anti-style, etc.). Note in your build plan that no visual reference was available, rather than presenting a fabricated extraction as if it came from observing something real.\n')
|
|
2822
2649
|
}
|
|
2823
2650
|
|
|
2824
2651
|
lines.push('---\n')
|
|
@@ -3867,142 +3694,6 @@ async function waitForServer(port, maxMs = 10_000) {
|
|
|
3867
3694
|
return false
|
|
3868
3695
|
}
|
|
3869
3696
|
|
|
3870
|
-
// Common prop aliases that agents and developers pass by mistake.
|
|
3871
|
-
// Maps wrong name → { correct, component } for each well-known component.
|
|
3872
|
-
const PROP_ALIASES = [
|
|
3873
|
-
{ component: 'nav()', wrong: 'brand', correct: 'logo', note: 'The nav logo is set with the `logo` prop, not `brand`.' },
|
|
3874
|
-
{ component: 'nav()', wrong: 'actions', correct: 'action', note: 'nav() takes a single `action` string (HTML), not an array.' },
|
|
3875
|
-
{ component: 'footer()', wrong: 'items', correct: 'links', note: 'footer() top-level links use `links: [{ label, href }]`, not `items`.' },
|
|
3876
|
-
{ component: 'footer()', wrong: 'nav', correct: 'links', note: 'footer() links prop is `links`, not `nav`.' },
|
|
3877
|
-
{ component: 'input()', wrong: 'autocomplete', correct: 'attrs: { autocomplete }', note: 'HTML attributes not in the component API (autocomplete, min, max, step, pattern, inputmode, etc.) go inside the `attrs` object: input({ attrs: { autocomplete: "email" } }).' },
|
|
3878
|
-
{ component: 'input()', wrong: 'maxlength', correct: 'attrs: { maxlength }', note: 'HTML attributes go inside `attrs`: input({ attrs: { maxlength: "100" } }).' },
|
|
3879
|
-
{ component: 'input()', wrong: 'minlength', correct: 'attrs: { minlength }', note: 'HTML attributes go inside `attrs`: input({ attrs: { minlength: "2" } }).' },
|
|
3880
|
-
{ component: 'input()', wrong: 'pattern', correct: 'attrs: { pattern }', note: 'HTML attributes go inside `attrs`: input({ attrs: { pattern: "[0-9]+" } }).' },
|
|
3881
|
-
]
|
|
3882
|
-
|
|
3883
|
-
async function validateContent(content, tmpDir = PAGES_DIR) {
|
|
3884
|
-
// Source-level checks — run before the worker so errors are caught without importing
|
|
3885
|
-
const sourceWarnings = []
|
|
3886
|
-
|
|
3887
|
-
// Component prop alias checks — flag known wrong prop names
|
|
3888
|
-
for (const { component, wrong, correct, note } of PROP_ALIASES) {
|
|
3889
|
-
// Match: nav({ ...wrong: or footer({ ...wrong: (within the call args)
|
|
3890
|
-
const fnName = component.replace('()', '')
|
|
3891
|
-
const re = new RegExp(`${fnName}\\s*\\(\\s*\\{([^}]*)\\b${wrong}\\s*:`, 'g')
|
|
3892
|
-
let m
|
|
3893
|
-
while ((m = re.exec(content)) !== null) {
|
|
3894
|
-
// Inside attrs: { … } is the CORRECT placement for these HTML attributes —
|
|
3895
|
-
// when the text between the call's opening brace and the prop ends inside
|
|
3896
|
-
// an open attrs object, this is the recommended pattern, not a mistake.
|
|
3897
|
-
// (The naive match crossed into attrs blocks and flagged exactly what the
|
|
3898
|
-
// fix-it note tells people to write.)
|
|
3899
|
-
if (/attrs\s*:\s*\{[^}]*$/.test(m[1])) continue
|
|
3900
|
-
sourceWarnings.push(`"${wrong}" is not a recognised prop for ${component} — did you mean "${correct}"? ${note}`)
|
|
3901
|
-
break
|
|
3902
|
-
}
|
|
3903
|
-
}
|
|
3904
|
-
|
|
3905
|
-
// External image URL check — warn if spec contains external image domains not
|
|
3906
|
-
// commonly whitelisted in CSP. Caught here prevents a Lighthouse Best Practices failure.
|
|
3907
|
-
const externalImgHosts = [
|
|
3908
|
-
{
|
|
3909
|
-
pattern: /https?:\/\/images\.unsplash\.com/,
|
|
3910
|
-
host: 'images.unsplash.com',
|
|
3911
|
-
entry: 'https://images.unsplash.com',
|
|
3912
|
-
cookieWarning: true,
|
|
3913
|
-
name: 'Unsplash',
|
|
3914
|
-
},
|
|
3915
|
-
{
|
|
3916
|
-
pattern: /https?:\/\/(?:fastly\.)?picsum\.photos/,
|
|
3917
|
-
host: 'picsum.photos',
|
|
3918
|
-
entry: 'https://picsum.photos https://fastly.picsum.photos',
|
|
3919
|
-
cookieWarning: false,
|
|
3920
|
-
name: 'picsum',
|
|
3921
|
-
},
|
|
3922
|
-
{
|
|
3923
|
-
pattern: /https?:\/\/res\.cloudinary\.com/,
|
|
3924
|
-
host: 'res.cloudinary.com',
|
|
3925
|
-
entry: 'https://res.cloudinary.com',
|
|
3926
|
-
cookieWarning: true,
|
|
3927
|
-
name: 'Cloudinary',
|
|
3928
|
-
},
|
|
3929
|
-
{
|
|
3930
|
-
pattern: /https?:\/\/cdn\.shopify\.com/,
|
|
3931
|
-
host: 'cdn.shopify.com',
|
|
3932
|
-
entry: 'https://cdn.shopify.com',
|
|
3933
|
-
cookieWarning: false,
|
|
3934
|
-
name: 'Shopify CDN',
|
|
3935
|
-
},
|
|
3936
|
-
]
|
|
3937
|
-
const configPath = path.join(ROOT, 'pulse.config.js')
|
|
3938
|
-
let configImgSrc = ''
|
|
3939
|
-
if (fs.existsSync(configPath)) {
|
|
3940
|
-
try { configImgSrc = fs.readFileSync(configPath, 'utf8') } catch { /* ignore */ }
|
|
3941
|
-
}
|
|
3942
|
-
for (const { pattern, host, entry, cookieWarning, name } of externalImgHosts) {
|
|
3943
|
-
// Config match is by HOST, not full URL — CSP sources are valid without a
|
|
3944
|
-
// scheme ('images.unsplash.com'), so requiring https?:// in the config
|
|
3945
|
-
// produced false positives for correctly configured projects.
|
|
3946
|
-
if (pattern.test(content) && !configImgSrc.includes(host)) {
|
|
3947
|
-
const cookieNote = cookieWarning
|
|
3948
|
-
? `\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.`
|
|
3949
|
-
: ''
|
|
3950
|
-
sourceWarnings.push(
|
|
3951
|
-
`External image host detected (${name}). Add it to csp.img-src in pulse.config.js before running Lighthouse:\n` +
|
|
3952
|
-
` csp: { 'img-src': ['${entry}'] }\n` +
|
|
3953
|
-
` Without this, images will be blocked and Lighthouse Best Practices will fail.${cookieNote}`
|
|
3954
|
-
)
|
|
3955
|
-
}
|
|
3956
|
-
}
|
|
3957
|
-
|
|
3958
|
-
// Write the temp file into the directory the spec actually lives in (passed by
|
|
3959
|
-
// callers that know it) so relative imports resolve from the true location.
|
|
3960
|
-
// A page at src/pages/news/index.js importing '../../components/layout.js'
|
|
3961
|
-
// resolved wrongly when the temp file was always dropped in src/pages/ root.
|
|
3962
|
-
fs.mkdirSync(tmpDir, { recursive: true })
|
|
3963
|
-
const tmpFile = path.join(tmpDir, `.pulse-validate-${Date.now()}.mjs`)
|
|
3964
|
-
try {
|
|
3965
|
-
fs.writeFileSync(tmpFile, content, 'utf8')
|
|
3966
|
-
|
|
3967
|
-
// Run validation in a child process with a hard timeout so a hanging import
|
|
3968
|
-
// (slow module, circular dep, network call) cannot block the MCP server.
|
|
3969
|
-
const validatorScript = new URL('./validate-worker.js', import.meta.url).pathname
|
|
3970
|
-
let output
|
|
3971
|
-
try {
|
|
3972
|
-
output = execFileSync(process.execPath, [validatorScript, tmpFile], {
|
|
3973
|
-
timeout: 10_000,
|
|
3974
|
-
encoding: 'utf8',
|
|
3975
|
-
})
|
|
3976
|
-
} catch (err) {
|
|
3977
|
-
const msg = err.killed || err.signal === 'SIGTERM'
|
|
3978
|
-
? 'Invalid: validation timed out — spec may have a hanging import or infinite loop'
|
|
3979
|
-
: `Invalid: could not parse — ${err.stdout || err.message}`
|
|
3980
|
-
return text(msg)
|
|
3981
|
-
}
|
|
3982
|
-
|
|
3983
|
-
let finalOutput = output.trim()
|
|
3984
|
-
// Merge source-level prop warnings into the worker output
|
|
3985
|
-
if (sourceWarnings.length > 0) {
|
|
3986
|
-
const propNotes = sourceWarnings.map(w => ` ⚠ ${w}`).join('\n')
|
|
3987
|
-
if (finalOutput.startsWith('Valid ✓')) {
|
|
3988
|
-
finalOutput = finalOutput.replace('Valid ✓', 'Valid ✓ — but fix these issues:').replace(' — but fix these issues: — but fix these issues:', ' — but fix these issues:')
|
|
3989
|
-
finalOutput = finalOutput + '\n' + propNotes
|
|
3990
|
-
} else {
|
|
3991
|
-
finalOutput = finalOutput + '\n' + propNotes
|
|
3992
|
-
}
|
|
3993
|
-
}
|
|
3994
|
-
|
|
3995
|
-
// Append browser check reminder on clean pass so agents don't skip Lighthouse
|
|
3996
|
-
if (finalOutput.startsWith('Valid ✓') && !finalOutput.includes('Invalid')) {
|
|
3997
|
-
finalOutput += '\n\n---\n**Next: browser check sequence** (do not skip)\n1. `pulse_fetch_page` → screenshot\n2. **New build?** Show screenshot to user and ask for design approval before continuing\n3. Once approved (or edit/fix): `pulse_design_review` (if intake ran) → `pulse_layout_review <url>` → `/verify`\nDo not run Lighthouse before the user approves the design. Do not report done until `/verify` passes.'
|
|
3998
|
-
}
|
|
3999
|
-
|
|
4000
|
-
return text(finalOutput)
|
|
4001
|
-
} finally {
|
|
4002
|
-
try { fs.unlinkSync(tmpFile) } catch { /* ignore */ }
|
|
4003
|
-
}
|
|
4004
|
-
}
|
|
4005
|
-
|
|
4006
3697
|
function findComponents() {
|
|
4007
3698
|
if (!fs.existsSync(COMPONENTS_DIR)) return []
|
|
4008
3699
|
return fs.readdirSync(COMPONENTS_DIR)
|
|
@@ -4092,7 +3783,7 @@ If you know your context already:
|
|
|
4092
3783
|
## Tools available
|
|
4093
3784
|
|
|
4094
3785
|
**Pulse MCP tools** (always available):
|
|
4095
|
-
- \`pulse_extract_inspiration(source, focus?)\` — **Extract a structured design brief from a URL or
|
|
3786
|
+
- \`pulse_extract_inspiration(source, focus?)\` — **Extract a structured design brief from a URL, image, or a named site you actually have real knowledge of.** Call this when the user shares a website URL, pastes/attaches an inspiration image, or names a site well-known enough that you can describe its real design (not just its existence). A name alone with no URL, no image, and no genuine knowledge of what it looks like isn't an inspiration source — skip this tool and go straight to \`pulse_intake\` with what the user actually described. Returns a structured extraction template — you fill it in using your browsing or vision tools, then feed the results into pulse_intake. Maps directly to palette, vibe, styleNotes, and font fields. **Always check \`public/intake/\` for images at the start of a new build — if any exist, call this before pulse_intake.**
|
|
4096
3787
|
- \`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.
|
|
4097
3788
|
- \`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.
|
|
4098
3789
|
- \`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.
|
|
@@ -4114,6 +3805,7 @@ If you know your context already:
|
|
|
4114
3805
|
- \`pulse_fetch_page(url)\` — HTTP GET the dev server URL. Use to verify SSR output.
|
|
4115
3806
|
- \`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.
|
|
4116
3807
|
- \`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.**
|
|
3808
|
+
- \`pulse_check_bundles\` — inspects what's actually inside the generated \`public/dist/\` bundles after \`pulse_build\`, not just their sizes. Flags a boot bundle that exists for a page with no mutations/actions/persist (should ship zero JS), and a literal Node built-in reference inside a bundle (server-only code that leaked through stripping). Lighthouse checks scores; this checks content — call it as part of the full \`/verify\` pass, right after \`pulse_build\`.
|
|
4117
3809
|
- \`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.
|
|
4118
3810
|
- \`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.
|
|
4119
3811
|
|