@invisibleloop/pulse 0.20.2 → 0.20.4

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.
@@ -1,12 +1,18 @@
1
1
  # Verify
2
2
 
3
- Run the full verification loop on the current page or the page specified in $ARGUMENTS.
3
+ Run the verification loop on the current page or the page specified in $ARGUMENTS.
4
+
5
+ **Modes:**
6
+ - `/verify` — full loop including Lighthouse (desktop + mobile), perf trace, and mobile layout check. Use when the user approves the design or signals they're ready to ship.
7
+ - `/verify --quick` — skips Lighthouse and perf trace; just validate → screenshot → console → review → stamp. Use during iterative building: design rounds, debugging visual issues, mid-build checkpoints. Fast enough to run between every change.
8
+
9
+ If `--quick` appears anywhere in $ARGUMENTS, run in quick mode (steps 6, 7, 8, and 9 are skipped). The route/file path is whatever remains in $ARGUMENTS after removing `--quick`.
4
10
 
5
11
  ## Steps
6
12
 
7
13
  ### 1. Identify the target
8
14
 
9
- If $ARGUMENTS is provided, use it as the file path or route. Otherwise, identify the most recently edited spec file from context.
15
+ If $ARGUMENTS is provided, use it (minus `--quick` if present) as the file path or route. Otherwise, identify the most recently edited spec file from context.
10
16
 
11
17
  ### 2. Design approval (new builds only)
12
18
 
@@ -30,7 +36,7 @@ Use `pulse_fetch_page` to confirm the server is responding for the route. If it
30
36
 
31
37
  Use `mcp__chrome-devtools__navigate_page` to load the page route, then `mcp__chrome-devtools__take_screenshot` to capture the result. Describe what you see — layout, content, any obvious rendering issues.
32
38
 
33
- ### 6. Lighthouse — desktop
39
+ ### 6. Lighthouse — desktop *(skip in quick mode)*
34
40
 
35
41
  **Pre-flight (required before every Lighthouse run):**
36
42
  1. Call `pulse_build` to produce a production build and start the production server on port 3001.
@@ -40,7 +46,7 @@ Then run `mcp__chrome-devtools__lighthouse_audit` with `{ "device": "desktop" }`
40
46
 
41
47
  **Pass bar: Accessibility, Best Practices, and SEO must all be 100.** Performance is measured and reported but is not a hard requirement (it varies with machine load). Report the actual scores. If Accessibility, Best Practices, or SEO is below 100, identify the failing audit(s), fix the issue, and restart from step 3.
42
48
 
43
- ### 7. Lighthouse — mobile
49
+ ### 7. Lighthouse — mobile *(skip in quick mode)*
44
50
 
45
51
  The browser should still be on `http://localhost:3001/` from step 6. Run `mcp__chrome-devtools__lighthouse_audit` with `{ "device": "mobile" }`.
46
52
 
@@ -48,7 +54,7 @@ The browser should still be on `http://localhost:3001/` from step 6. Run `mcp__c
48
54
 
49
55
  **Stay on the production server (port 3001)** — steps 8 and 9 use it too. Do not call `pulse_restart_server` yet.
50
56
 
51
- ### 8. Mobile layout check
57
+ ### 8. Mobile layout check *(skip in quick mode)*
52
58
 
53
59
  Emulate a mobile viewport and take a screenshot to catch wrapping, overflow, and layout issues before they become Lighthouse failures.
54
60
 
@@ -70,7 +76,7 @@ Fix any issues, then reset to desktop before continuing:
70
76
  mcp__chrome-devtools__emulate viewport: "1440x900x1"
71
77
  ```
72
78
 
73
- ### 9. Performance
79
+ ### 9. Performance *(skip in quick mode)*
74
80
 
75
81
  **Run the trace against the production server, not dev.** Navigate to `http://localhost:3001/<route>` first — if the production server is no longer running, run `pulse_build` again. Tracing the dev server (port 3000) reports misleading LCP/CLS. Then run `mcp__chrome-devtools__performance_start_trace` with `reload: true` and `autoStop: true`. Report LCP and CLS. Flag any LCP insight that suggests a fixable problem (render-blocking resources, large image load delay, etc.).
76
82
 
@@ -102,11 +108,11 @@ Call `pulse_stamp`. This writes `.pulse-verified` via the MCP server, which is m
102
108
 
103
109
  Summarise:
104
110
  - Validation: pass or fail (with errors if any)
105
- - Visual: desktop screenshot and mobile layout check — any issues found and fixed
106
- - Lighthouse desktop: scores for Accessibility, Best Practices, SEO
107
- - Lighthouse mobile: scores for Accessibility, Best Practices, SEO
108
- - Performance: LCP and CLS values, any flagged insights
111
+ - Visual: desktop screenshot — any issues found and fixed
109
112
  - Console: any errors
110
113
  - Review: pass or issues found and fixed
114
+ - **Full mode only:** Lighthouse desktop/mobile scores, mobile layout check, LCP and CLS values
115
+
116
+ In quick mode, end with: "Quick check passed — run `/verify` when you're ready for the full Lighthouse audit."
111
117
 
112
- Only confirm the page is good when validation passes, both Lighthouse runs are 100/100/100, CLS is 0.00, and there are no console errors. Otherwise, fix and run `/verify` again.
118
+ In full mode, only confirm the page is good when validation passes, both Lighthouse runs are 100/100/100, CLS is 0.00, and there are no console errors. Otherwise, fix and run `/verify` again.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invisibleloop/pulse",
3
- "version": "0.20.2",
3
+ "version": "0.20.4",
4
4
  "type": "module",
5
5
  "description": "AI-first frontend framework. The spec is the source of truth.",
6
6
  "license": "MIT",
@@ -1 +1 @@
1
- 0.20.2
1
+ 0.20.4
@@ -12,9 +12,9 @@
12
12
  export function extractUsedClasses(htmlContents, jsContents, safelist = []) {
13
13
  const used = new Set()
14
14
 
15
- // Extract from class="..." in both rendered HTML and JS template literals
16
- for (const content of [...htmlContents, ...jsContents]) {
17
- const re = /class(?:Name)?=["']([^"'\n]+)["']/g
15
+ // Extract from class="..." in rendered HTML (no template expressions to worry about)
16
+ for (const content of htmlContents) {
17
+ const re = /class=["']([^"'\n]+)["']/g
18
18
  let m
19
19
  while ((m = re.exec(content)) !== null) {
20
20
  for (const cls of m[1].trim().split(/\s+/)) {
@@ -23,6 +23,41 @@ export function extractUsedClasses(htmlContents, jsContents, safelist = []) {
23
23
  }
24
24
  }
25
25
 
26
+ // Extract from class= attributes in JS source. Template literal class attributes
27
+ // like class="foo${expr ? ' bar' : ''}" have their expressions interrupted by
28
+ // single quotes inside the ternary, so [^"'\n]+ stops mid-expression and misses
29
+ // the base class. Instead: scan for class= followed by a double-quoted string
30
+ // OR a backtick-delimited segment, extract everything up to the first ${ or the
31
+ // closing delimiter, and tokenise only the static prefix.
32
+ for (const js of jsContents) {
33
+ // Match class="...anything up to closing quote or ${..."
34
+ // Using a two-pass approach: find class= then manually scan to get the static part
35
+ let i = 0
36
+ while (i < js.length) {
37
+ const idx = js.indexOf('class=', i)
38
+ if (idx === -1) break
39
+ i = idx + 6 // past 'class='
40
+
41
+ // Skip optional whitespace (shouldn't be any but be safe)
42
+ while (i < js.length && (js[i] === ' ' || js[i] === '\t')) i++
43
+
44
+ const quote = js[i]
45
+ if (quote !== '"' && quote !== "'" && quote !== '`') continue
46
+ i++ // past opening quote
47
+
48
+ // Collect characters until we hit the closing quote, a ${, or end of string
49
+ let staticPart = ''
50
+ while (i < js.length && js[i] !== quote && !(js[i] === '$' && js[i + 1] === '{')) {
51
+ staticPart += js[i++]
52
+ }
53
+
54
+ // Tokenise the static prefix
55
+ for (const cls of staticPart.trim().split(/\s+/)) {
56
+ if (cls && /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(cls)) used.add(cls)
57
+ }
58
+ }
59
+ }
60
+
26
61
  // Scan JS source for quoted strings that look like CSS class names (contain hyphens)
27
62
  // Catches conditionally-applied classes like 'ui-btn--disabled' in ternaries
28
63
  for (const js of jsContents) {
@@ -50,6 +85,33 @@ export function extractUsedClasses(htmlContents, jsContents, safelist = []) {
50
85
  }
51
86
  }
52
87
 
88
+ // Pulse re-renders via innerHTML — classes added after mutations appear as
89
+ // template literal expressions inside class= attributes, not classList calls.
90
+ // Pattern: class="${state.x ? 'foo' : 'bar'}" or class="base ${state.x ? 'active' : ''}"
91
+ // The hyphen-only scan above misses single-word classes like 'active', 'open',
92
+ // 'visible', 'selected', 'hidden' that are common state-driven class names.
93
+ // Extract every quoted string that appears inside a class= template expression.
94
+ for (const js of jsContents) {
95
+ // Match: class=`...` or class="${...}" template attribute values
96
+ const attrRe = /class(?:Name)?=`([^`]*)`/g
97
+ let m
98
+ while ((m = attrRe.exec(js)) !== null) {
99
+ const attrContent = m[1]
100
+ // Collect static class tokens (outside ${...} expressions)
101
+ for (const cls of attrContent.replace(/\$\{[^}]*\}/g, ' ').trim().split(/\s+/)) {
102
+ if (cls && /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(cls)) used.add(cls)
103
+ }
104
+ // Collect quoted strings inside ${...} expressions — these are the
105
+ // conditional class names: ${state.open ? 'open' : ''} → 'open'
106
+ for (const [, inner] of attrContent.matchAll(/\$\{([^}]*)\}/g)) {
107
+ for (const [, cls] of inner.matchAll(/['"]([a-zA-Z][a-zA-Z0-9_-]*)['"]|`([a-zA-Z][a-zA-Z0-9_-]*)`/g)) {
108
+ const name = cls
109
+ if (name && /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(name)) used.add(name)
110
+ }
111
+ }
112
+ }
113
+ }
114
+
53
115
  // Apply safelist from pulse.config.js — css.safelist accepts strings or RegExp.
54
116
  // String entries are added directly; RegExp entries are matched against all
55
117
  // class names defined in the CSS (resolved later in purgeCss via selectorUsed
@@ -119,6 +119,22 @@ test('multiple classList arguments in one call are all collected', () => {
119
119
  for (const cls of ['one', 'two-x', 'three']) assert.ok(used.has(cls), cls)
120
120
  })
121
121
 
122
+ // Regression: class name immediately followed by ${ (no space before the expression)
123
+ // was not extracted — "saved-palette${...}" yielded "saved-palette${expanded" as a
124
+ // single token, so the rule was silently purged in prod while dev (no purging) looked fine.
125
+ test('class name immediately before ${ is extracted correctly', () => {
126
+ const js = [
127
+ // No space before ${ — the broken pattern
128
+ `const v = \`<div class="saved-palette\${expanded ? ' saved-palette--expanded' : ''}"></div>\``,
129
+ // Multiple static classes, last one butts into ${
130
+ `const v = \`<button class="btn btn--primary\${loading ? ' btn--loading' : ''}"></button>\``,
131
+ ]
132
+ const used = extractUsedClasses([], js)
133
+ for (const cls of ['saved-palette', 'btn', 'btn--primary']) {
134
+ assert.ok(used.has(cls), `Expected "${cls}" to be extracted but it was missing`)
135
+ }
136
+ })
137
+
122
138
  test('runtime-toggled compound rule survives the purge', () => {
123
139
  const css = `.docs-sidebar { transform: translateX(-100%); }\n.docs-sidebar.open { transform: translateX(0); }\n.unused { color: red; }`
124
140
  const used = extractUsedClasses(
@@ -1,12 +1,18 @@
1
1
  # Verify
2
2
 
3
- Run the full verification loop on the current page or the page specified in $ARGUMENTS.
3
+ Run the verification loop on the current page or the page specified in $ARGUMENTS.
4
+
5
+ **Modes:**
6
+ - `/verify` — full loop including Lighthouse (desktop + mobile), perf trace, and mobile layout check. Use when the user approves the design or signals they're ready to ship.
7
+ - `/verify --quick` — skips Lighthouse and perf trace; just validate → screenshot → console → review → stamp. Use during iterative building: design rounds, debugging visual issues, mid-build checkpoints. Fast enough to run between every change.
8
+
9
+ If `--quick` appears anywhere in $ARGUMENTS, run in quick mode (steps 6, 7, 8, and 9 are skipped). The route/file path is whatever remains in $ARGUMENTS after removing `--quick`.
4
10
 
5
11
  ## Steps
6
12
 
7
13
  ### 1. Identify the target
8
14
 
9
- If $ARGUMENTS is provided, use it as the file path or route. Otherwise, identify the most recently edited spec file from context.
15
+ If $ARGUMENTS is provided, use it (minus `--quick` if present) as the file path or route. Otherwise, identify the most recently edited spec file from context.
10
16
 
11
17
  ### 2. Design approval (new builds only)
12
18
 
@@ -30,7 +36,7 @@ Use `pulse_fetch_page` to confirm the server is responding for the route. If it
30
36
 
31
37
  Use `mcp__chrome-devtools__navigate_page` to load the page route, then `mcp__chrome-devtools__take_screenshot` to capture the result. Describe what you see — layout, content, any obvious rendering issues.
32
38
 
33
- ### 6. Lighthouse — desktop
39
+ ### 6. Lighthouse — desktop *(skip in quick mode)*
34
40
 
35
41
  **Pre-flight (required before every Lighthouse run):**
36
42
  1. Call `pulse_build` to produce a production build and start the production server on port 3001.
@@ -40,7 +46,7 @@ Then run `mcp__chrome-devtools__lighthouse_audit` with `{ "device": "desktop" }`
40
46
 
41
47
  **Pass bar: Accessibility, Best Practices, and SEO must all be 100.** Performance is measured and reported but is not a hard requirement (it varies with machine load). Report the actual scores. If Accessibility, Best Practices, or SEO is below 100, identify the failing audit(s), fix the issue, and restart from step 3.
42
48
 
43
- ### 7. Lighthouse — mobile
49
+ ### 7. Lighthouse — mobile *(skip in quick mode)*
44
50
 
45
51
  The browser should still be on `http://localhost:3001/` from step 6. Run `mcp__chrome-devtools__lighthouse_audit` with `{ "device": "mobile" }`.
46
52
 
@@ -48,7 +54,7 @@ The browser should still be on `http://localhost:3001/` from step 6. Run `mcp__c
48
54
 
49
55
  **Stay on the production server (port 3001)** — steps 8 and 9 use it too. Do not call `pulse_restart_server` yet.
50
56
 
51
- ### 8. Mobile layout check
57
+ ### 8. Mobile layout check *(skip in quick mode)*
52
58
 
53
59
  Emulate a mobile viewport and take a screenshot to catch wrapping, overflow, and layout issues before they become Lighthouse failures.
54
60
 
@@ -70,7 +76,7 @@ Fix any issues, then reset to desktop before continuing:
70
76
  mcp__chrome-devtools__emulate viewport: "1440x900x1"
71
77
  ```
72
78
 
73
- ### 9. Performance
79
+ ### 9. Performance *(skip in quick mode)*
74
80
 
75
81
  **Run the trace against the production server, not dev.** Navigate to `http://localhost:3001/<route>` first — if the production server is no longer running, run `pulse_build` again. Tracing the dev server (port 3000) reports misleading LCP/CLS. Then run `mcp__chrome-devtools__performance_start_trace` with `reload: true` and `autoStop: true`. Report LCP and CLS. Flag any LCP insight that suggests a fixable problem (render-blocking resources, large image load delay, etc.).
76
82
 
@@ -102,11 +108,11 @@ Call `pulse_stamp`. This writes `.pulse-verified` via the MCP server, which is m
102
108
 
103
109
  Summarise:
104
110
  - Validation: pass or fail (with errors if any)
105
- - Visual: desktop screenshot and mobile layout check — any issues found and fixed
106
- - Lighthouse desktop: scores for Accessibility, Best Practices, SEO
107
- - Lighthouse mobile: scores for Accessibility, Best Practices, SEO
108
- - Performance: LCP and CLS values, any flagged insights
111
+ - Visual: desktop screenshot — any issues found and fixed
109
112
  - Console: any errors
110
113
  - Review: pass or issues found and fixed
114
+ - **Full mode only:** Lighthouse desktop/mobile scores, mobile layout check, LCP and CLS values
115
+
116
+ In quick mode, end with: "Quick check passed — run `/verify` when you're ready for the full Lighthouse audit."
111
117
 
112
- Only confirm the page is good when validation passes, both Lighthouse runs are 100/100/100, CLS is 0.00, and there are no console errors. Otherwise, fix and run `/verify` again.
118
+ In full mode, only confirm the page is good when validation passes, both Lighthouse runs are 100/100/100, CLS is 0.00, and there are no console errors. Otherwise, fix and run `/verify` again.
@@ -194,7 +194,7 @@ To add a missing icon: copy a Lucide path (MIT) into `src/ui/icons.js` using `s(
194
194
  | `list` | `items` (array of HTML strings), `ordered`, `gap` (xs/sm/md) |
195
195
  | `prose` | `content` (raw HTML — not escaped), `size` (sm/base/lg) — for CMS/markdown content you don't control |
196
196
 
197
- **Never write raw `<h1>`–`<h6>` or `<ul>`/`<ol>` without styling.** Use `heading()` and `list()` instead.
197
+ **Never write raw `<h1>`–`<h6>` or `<ul>`/`<ol>` without styling.** Use `heading()` and `list()` instead. *(Mode A only — in a creative override, raw headings styled directly with utility classes or custom CSS are correct. The `heading()` component adds wrapper markup that can fight custom layouts.)*
198
198
 
199
199
  **Heading order across components.** `card`, `feature`, `section`, `pricing`, `cta`, and `modal` all accept a `level` prop. Use it to keep the document outline correct without changing visual style. For example, if a page has an h2 section title and each card inside it has a title, pass `level: 3` on the cards (the default) — but if the cards are the first heading on the page, pass `level: 2`. The CSS class stays the same (`ui-card-title` etc.) so appearance is unaffected.
200
200
 
@@ -284,7 +284,7 @@ This is by design: vibes are a paint job, not a structural transformation. Compo
284
284
 
285
285
  | Component | Key props |
286
286
  |-----------|-----------|
287
- | `nav` | `logo` (HTML slot), `logoHref`, `links` ([{label,href}]), `action` (HTML slot), `sticky`, `background` (any CSS value — overrides default surface colour), `color` (foreground colour — set when custom background doesn't contrast with default muted text) |
287
+ | `nav` | `logo` (HTML slot), `logoHref`, `links` ([{label,href}]), `action` (HTML slot), `sticky`, `background` (any CSS value — overrides default surface colour), `color` (foreground colour — set when custom background doesn't contrast with default muted text) — ⚠ **renders a `<nav>` landmark internally. Never wrap it in another `<nav>` or `<header><nav>` — that creates a duplicate landmark Lighthouse flags as an accessibility failure. Wrapping in `<header>` alone is fine.** |
288
288
  | `hero` | `eyebrow`, `title`, `subtitle`, `actions` (HTML slot), `image` (HTML slot — activates image layout), `imageAlign` (right/left), `align` (center/left), `size` (md/sm), `layout` (split/asymmetric/overlap — when image provided: 'split'=50/50, 'asymmetric'=60/40 text-heavy, 'overlap'=image fills section with text overlay), `gradient` (true/false or preset: 'purple'\|'blue'\|'green'\|'rose'\|'orange'), `background` (any CSS value), `eyebrowColor` |
289
289
  | `phoneFrame` | `content` (HTML slot — rendered inside the phone screen), `animate` (boolean, default `false` — enables a gentle 3-D tilt on hover). Pure CSS phone mockup with dynamic island, side buttons, home indicator. Use as the `image` slot in `hero()` for app landing pages. Add `animate: true` whenever the phone is a prominent hero element. |
290
290
  | `feature` | `image` (HTML slot — rendered above icon), `icon` (HTML slot), `title`, `level` (1–6, default 3), `description` (or `body` as alias), `center` |
@@ -44,8 +44,8 @@ The component rules marked *(Mode A)* below are suspended under a declared creat
44
44
  - Hardcode hex colours in CSS. Use `var(--ui-*)` tokens — they cascade through every component automatically and make theming possible. The one place hex values belong is `public/theme.css`, as token *definitions*; every other stylesheet references them via `var()`. A lint hook enforces this.
45
45
  - Use inline `style=""` attributes. Use utility classes or `var(--ui-*)` tokens in a stylesheet. *Exception:* structural image styles with no utility-class equivalent (`aspect-ratio`, `object-fit`) are permitted inline — these are layout facts, not theming, and the design gallery uses them this way.
46
46
  - Write raw `<button>`, `<input>`, `<select>`, or `<textarea>` HTML when the component library already provides accessible, styled versions. *(Mode A — under a creative override, functional atoms should still come from components unless there is a specific design reason.)*
47
- - Write raw `<h1>`–`<h6>` without styling. Use `heading({ level, text })` instead. *(Mode A)*
48
- - Write raw `<ul>` or `<ol>` without styling. Use `list({ items })` instead. *(Mode A)*
47
+ - Write raw `<h1>`–`<h6>` without styling. Use `heading({ level, text })` instead. *(Mode A — under a creative override, raw headings styled with utility classes or custom CSS are correct; `heading()` adds wrapper markup that fights custom layouts.)*
48
+ - Write raw `<ul>` or `<ol>` without styling. Use `list({ items })` instead. *(Mode A — under a creative override, raw lists are permitted when the component markup would interfere with the layout.)*
49
49
  - Output CMS or database HTML without a `prose()` wrapper. Raw HTML from external sources has no styling — always wrap it in `prose({ content: html })`.
50
50
  - Use `data-event` on text inputs. Re-rendering on every keystroke destroys focus. Use uncontrolled inputs and read values from `FormData` in `action.onStart` or `action.run`.
51
51
  - Skip `onError` in an action. It is required. Always handle failure.
@@ -9,9 +9,10 @@
9
9
  | New site, new branded page, or "build me X from scratch" | `pulse_intake` → `pulse_sketch` → `pulse_intent` → build |
10
10
  | Editing an existing page, adding a section, fixing a bug | Go straight to Step 1 — Understand |
11
11
  | "Add X to the existing Y" (one-liner) | Read the relevant file, make the change, run `/verify` |
12
+ | Iterating on design before user approves | `/verify --quick` between rounds; full `/verify` once approved |
12
13
  | Stuck on which component to use | `pulse_intent("describe what I'm building")` |
13
14
  | Colours failing contrast / unsure about palette | `pulse_check_contrast` |
14
- | Something looks wrong in the browser | `pulse_restart_server`, then `/verify` |
15
+ | Something looks wrong in the browser | `pulse_restart_server`, then `/verify --quick` |
15
16
  | Lighthouse < 100 | Fix the flagged audit, run `/verify` again — do not skip to commit |
16
17
 
17
18
  ---
@@ -224,6 +225,8 @@ After the Write tool completes, call `pulse_create_page(name)` to register the p
224
225
 
225
226
  After writing the first draft, call `pulse_suggest(content)` before running the hard validator. It catches obvious omissions collaboratively — a second opinion, not a gate. Then call `pulse_review(file, { quick: true })` for a lightweight structural pass (main landmark, data-event on inputs, missing onError, hex in view). Neither is a substitute for the full review, but catching issues here is faster than discovering them at Lighthouse.
226
227
 
228
+ If the user is iterating on the design (multiple rounds of changes before giving a final go-ahead), use `/verify --quick` between rounds — it validates, screenshots, checks console, and runs a quick code review without the 90-second Lighthouse cost. Save the full `/verify` for after the user explicitly approves the design.
229
+
227
230
  **Rule: never output `✓` before the tool call that confirms the result.**
228
231
 
229
232
  ---
@@ -278,7 +281,7 @@ Do this before every screenshot, console check, or visual inspection. Never debu
278
281
 
279
282
  ### Phase 5b — Full verification (pass gate)
280
283
 
281
- **Run `/verify` now.** Do not execute these steps manually.
284
+ **Run `/verify` now** (full mode — not `--quick`). Do not execute these steps manually.
282
285
 
283
286
  `/verify` validates, screenshots, runs Lighthouse (desktop + mobile), runs the performance trace (LCP + CLS), checks console errors, runs `pulse_review`, and writes the `.pulse-verified` stamp. Running steps manually does not write the stamp.
284
287
 
package/src/cli/dev.js CHANGED
@@ -118,6 +118,15 @@ if (specs.length === 0) {
118
118
  process.exit(1)
119
119
  }
120
120
 
121
+ // Warn if a production build exists — public/dist/ is ignored by the dev server
122
+ // (manifest: {} forces source-file hydration) but its presence can cause confusion
123
+ // because static files in public/dist/ ARE served by the static file handler.
124
+ // If the user ran pulse build and sees stale styles, deleting public/dist/ fixes it.
125
+ const DIST_DIR = path.join(ROOT, 'public', 'dist')
126
+ if (fs.existsSync(path.join(DIST_DIR, 'manifest.json'))) {
127
+ console.warn(`\n ⚠ A production build exists in public/dist/\n Dev mode ignores the manifest and always serves source files, but if\n styles look wrong after a build, run: rm -rf public/dist/\n`)
128
+ }
129
+
121
130
  // ---------------------------------------------------------------------------
122
131
  // Hot reload — SSE clients + file watcher
123
132
  // ---------------------------------------------------------------------------
@@ -257,6 +266,14 @@ const { updateSpecs, updateStore } = await createServer(specs, {
257
266
  onRequest(req, res) {
258
267
  const url = req.url.split('?')[0]
259
268
 
269
+ // MCP hot-reload trigger — POST to this instead of killing the process
270
+ if (url === '/_pulse/trigger-reload' && req.method === 'POST') {
271
+ triggerReload('MCP trigger')
272
+ res.writeHead(204)
273
+ res.end()
274
+ return false
275
+ }
276
+
260
277
  // SSE endpoint — browser connects here to receive reload events
261
278
  if (url === '/_pulse/reload') {
262
279
  res.writeHead(200, {
package/src/mcp/server.js CHANGED
@@ -37,6 +37,20 @@ import { execFileSync, spawn, spawnSync } from 'child_process'
37
37
 
38
38
  import { loadPages } from '../cli/discover.js'
39
39
 
40
+ // ---------------------------------------------------------------------------
41
+ // Crash guards — an uncaught error in any tool handler must not kill the
42
+ // stdio transport. Without these, Claude Code sees the process exit and
43
+ // reports "pulse: disconnected" instead of a tool-level error. stderr only —
44
+ // stdout is the JSON-RPC channel and any stray write corrupts the stream.
45
+ // ---------------------------------------------------------------------------
46
+
47
+ process.on('uncaughtException', (err) => {
48
+ console.error('[pulse-mcp] uncaught exception:', err?.stack || err)
49
+ })
50
+ process.on('unhandledRejection', (err) => {
51
+ console.error('[pulse-mcp] unhandled rejection:', err?.stack || err)
52
+ })
53
+
40
54
  // ---------------------------------------------------------------------------
41
55
  // Project root
42
56
  // ---------------------------------------------------------------------------
@@ -49,6 +63,17 @@ const ROOT = rootArg !== -1
49
63
  const PAGES_DIR = path.join(ROOT, 'src', 'pages')
50
64
  const COMPONENTS_DIR = path.join(ROOT, 'src', 'components')
51
65
 
66
+ // Detect whether ROOT is actually a Pulse project — used to warn the agent
67
+ // early rather than silently misbehaving in unrelated repos.
68
+ const IS_PULSE_PROJECT = (() => {
69
+ if (fs.existsSync(path.join(ROOT, 'pulse.config.js'))) return true
70
+ if (fs.existsSync(PAGES_DIR)) return true
71
+ try {
72
+ const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'))
73
+ return !!(pkg?.dependencies?.['@invisibleloop/pulse'] || pkg?.devDependencies?.['@invisibleloop/pulse'])
74
+ } catch { return false }
75
+ })()
76
+
52
77
  const PKG_VERSION = JSON.parse(
53
78
  fs.readFileSync(new URL('../../package.json', import.meta.url).pathname, 'utf8')
54
79
  ).version
@@ -241,10 +266,10 @@ Fetch \`pulse://quickstart\` — workflow phases, spec skeleton, components, and
241
266
  Quick checklist:
242
267
  1. Read the file you're changing
243
268
  2. Make the edit
244
- 3. \`pulse_validate\` → \`/verify\`
269
+ 3. \`pulse_validate\` → \`/verify --quick\` (mid-iteration) or \`/verify\` (final check)
245
270
 
246
271
  ### C — Targeted one-liner (label change, prop swap, CSS tweak)
247
- No resource fetch needed. Read the file, make the change, run \`/verify\`.
272
+ No resource fetch needed. Read the file, make the change, run \`/verify --quick\` to check it looks right, then \`/verify\` when the user is happy.
248
273
 
249
274
  ### D — Stuck mid-build
250
275
  - Wrong component props? → \`pulse://guide/components\`
@@ -252,7 +277,7 @@ No resource fetch needed. Read the file, make the change, run \`/verify\`.
252
277
  - CSS / theming? → \`pulse://guide/styles\`
253
278
  - Routing or layout? → \`pulse://guide/routing\`
254
279
  - Lighthouse < 100? → Fix the failing audit, re-run \`/verify\`
255
- - Something looks wrong visually? → \`pulse_restart_server\` then navigate the browser
280
+ - Something looks wrong visually? → \`pulse_restart_server\` (hot-reloads specs instantly) then navigate the browser
256
281
 
257
282
  ---
258
283
 
@@ -261,7 +286,7 @@ No resource fetch needed. Read the file, make the change, run \`/verify\`.
261
286
  - **\`meta.theme\` defaults to dark.** If the design is light, set \`theme: 'light'\` in the plan — not at screenshot time.
262
287
  - **Never set \`hydrate\`** — the framework sets it automatically from the URL entry.
263
288
  - **\`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.
289
+ - **After every file edit:** \`pulse_restart_server\` → navigate browser → check result. (Hot-reloads specs in ~200 ms — no process restart needed.)
265
290
  - **\`/verify\` is always last** — it writes the \`.pulse-verified\` stamp. Without it, the stop hook blocks.
266
291
 
267
292
  ---
@@ -318,9 +343,15 @@ server.registerResource(
318
343
 
319
344
  \`\`\`
320
345
  New page: pulse_intake → pulse_sketch → pulse_intent → build → validate → screenshot → design approval → /verify
321
- Edit/fix: read file → change → validate → /verify
346
+ Edit/fix: read file → change → validate → /verify --quick → (iterate) → /verify
322
347
  \`\`\`
323
348
 
349
+ **\`/verify\` has two modes:**
350
+ - \`/verify --quick\` — validate + screenshot + console + code review. No Lighthouse, no build. Use this during active iteration, between design rounds, or any time you're not yet at the "ready to ship" point. Fast (~10 s).
351
+ - \`/verify\` — full loop: Lighthouse desktop + mobile + perf trace + all of the above. Use once the user has approved the design and you're done building. Slow (~90 s).
352
+
353
+ **Rule: default to \`/verify --quick\` while building. Only run \`/verify\` (full) when the user signals they're done or happy with the result.**
354
+
324
355
  Tier 1 (static/read-only): phases 3 → 4 → 5a (design approval) → 5b (/verify)
325
356
  Tier 2 (interactive): phases 3 → 4 → 5a → 5b → 6 (tests) → 7 (review)
326
357
 
@@ -372,6 +403,43 @@ export default {
372
403
 
373
404
  ---
374
405
 
406
+ ## Event binding — input patterns
407
+
408
+ **The right pattern for each input type:**
409
+
410
+ | Input type | Pattern | Why |
411
+ |---|---|---|
412
+ | Button click | \`<button data-event="increment">\` | click fires mutation |
413
+ | Select / radio / checkbox | \`<select data-event="change:setFilter">\` | \`change\` fires on blur-with-changed-value — the correct commit semantic |
414
+ | Color picker | \`<input type="color" data-event="change:setColor">\` | same as select |
415
+ | Range slider | \`<input type="range" data-event="input:setVolume">\` | \`input\` fires on every drag step |
416
+ | 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 |
417
+
418
+ **Text input pattern (uncontrolled):**
419
+ \`\`\`js
420
+ // view — no data-event on the input
421
+ view: (state) => \`
422
+ <form data-action="submit">
423
+ <input type="text" name="email" placeholder="Email">
424
+ <button type="submit">Save</button>
425
+ </form>
426
+ \`,
427
+ // action — read FormData in onStart (before validation)
428
+ actions: {
429
+ submit: {
430
+ onStart: (state, formData) => ({ status: 'loading', email: formData.get('email') }),
431
+ validate: true,
432
+ run: async (state) => { /* use state.email */ },
433
+ onSuccess: (state) => ({ status: 'success' }),
434
+ onError: (state, err) => ({ status: 'error', error: err.message }),
435
+ }
436
+ }
437
+ \`\`\`
438
+
439
+ **\`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
+
441
+ ---
442
+
375
443
  ## Key components (quick reference)
376
444
 
377
445
  Import from \`@invisibleloop/pulse/ui\`. Always include \`/pulse-ui.css\` in \`meta.styles\`.
@@ -457,6 +525,9 @@ server.registerTool(
457
525
  inputSchema: {},
458
526
  },
459
527
  async () => {
528
+ if (!IS_PULSE_PROJECT) {
529
+ return { content: [{ type: 'text', text: `⚠ This does not appear to be a Pulse project (no pulse.config.js, no src/pages/, no @invisibleloop/pulse dependency found in ${ROOT}).\n\nThe pulse MCP server is project-specific — it is started by the \`pulse\` CLI from inside a Pulse project directory. If you are working on a Pulse project, check that you launched with \`pulse\` from the correct directory.` }] }
530
+ }
460
531
  const specs = await loadPages(ROOT)
461
532
  const components = findComponents()
462
533
  const lines = []
@@ -528,6 +599,9 @@ server.registerTool(
528
599
  inputSchema: {},
529
600
  },
530
601
  async () => {
602
+ if (!IS_PULSE_PROJECT) {
603
+ return { content: [{ type: 'text', text: `⚠ This does not appear to be a Pulse project (no pulse.config.js, no src/pages/, no @invisibleloop/pulse dependency found in ${ROOT}).\n\nThe pulse MCP server is project-specific — it is started by the \`pulse\` CLI from inside a Pulse project directory. If you are working on a Pulse project, check that you launched with \`pulse\` from the correct directory.` }] }
604
+ }
531
605
  const lines = []
532
606
 
533
607
  // ── Pages ──────────────────────────────────────────────────────────────
@@ -1093,7 +1167,7 @@ server.registerTool(
1093
1167
  server.registerTool(
1094
1168
  'pulse_restart_server',
1095
1169
  {
1096
- description: 'Stop and restart the Pulse dev server. Use after adding new pages or making changes that require a restart.',
1170
+ description: 'Reload specs in the running dev server. Tries a fast hot-reload first (no process restart); falls back to full kill/restart only if the server is not running.',
1097
1171
  inputSchema: {},
1098
1172
  },
1099
1173
  async () => {
@@ -1106,15 +1180,32 @@ server.registerTool(
1106
1180
  } catch { /* use default */ }
1107
1181
  }
1108
1182
 
1109
- // Kill any process on the port
1183
+ // Fast path — POST to the running dev server's hot-reload endpoint.
1184
+ // This reloads specs in-process and sends an SSE reload event to the browser
1185
+ // without killing the server process (~200 ms vs ~3 s for a full restart).
1186
+ const hotReloaded = await new Promise(resolve => {
1187
+ try {
1188
+ const req = http.request(
1189
+ { hostname: '127.0.0.1', port, path: '/_pulse/trigger-reload', method: 'POST', timeout: 2000 },
1190
+ res => resolve(res.statusCode === 204)
1191
+ )
1192
+ req.on('error', () => resolve(false))
1193
+ req.on('timeout', () => { req.destroy(); resolve(false) })
1194
+ req.end()
1195
+ } catch { resolve(false) }
1196
+ })
1197
+
1198
+ if (hotReloaded) {
1199
+ return text(`Dev server specs reloaded (hot) on port ${port}`)
1200
+ }
1201
+
1202
+ // Slow path — server not running or unresponsive, do a full kill/restart
1110
1203
  try { execFileSync('sh', ['-c', `lsof -ti:${port} | xargs kill -9 2>/dev/null; true`]) } catch { /* nothing running */ }
1111
1204
 
1112
- // Start fresh dev server detached so it outlives the MCP tool call
1113
1205
  const devScript = new URL('../cli/dev.js', import.meta.url).pathname
1114
1206
  const proc = spawn(process.execPath, [devScript, '--root', ROOT], { detached: true, stdio: 'ignore' })
1115
1207
  proc.unref()
1116
1208
 
1117
- // Wait until the server is actually accepting requests
1118
1209
  const ready = await waitForServer(port)
1119
1210
  return text(ready
1120
1211
  ? `Dev server restarted on port ${port}`
@@ -1202,6 +1293,7 @@ any feature build.
1202
1293
 
1203
1294
  let source = content
1204
1295
  if (file) {
1296
+ if (!path.isAbsolute(file)) file = path.resolve(process.cwd(), file)
1205
1297
  if (!fs.existsSync(file)) return text(`File not found: ${file}`)
1206
1298
  source = fs.readFileSync(file, 'utf8')
1207
1299
  }
@@ -1232,9 +1324,16 @@ any feature build.
1232
1324
  issues.push('✗ React patterns found (className / htmlFor / onClick) — use class, for, data-event')
1233
1325
  if (/tabindex=["']?([1-9]\d*)/.test(renderedHtml))
1234
1326
  issues.push('✗ Positive tabindex found — remove, reorder DOM instead')
1327
+ const rawNavInSource = (source.match(/<nav[\s>]/gi) || []).length
1328
+ if (rawNavInSource > 0)
1329
+ 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>.`)
1235
1330
  const viewBlockStripped = renderedHtml
1236
1331
  .replace(/href=["']#[^"']*["']/g, 'href="#"')
1237
1332
  .replace(/id=["'][^"']*["']/g, 'id=""')
1333
+ // Strip CSS custom property assignments from style attrs (--foo:#hex is a runtime
1334
+ // data binding, not an authored colour — e.g. style="--swatch-bg:#E8524A").
1335
+ // Standard property assignments like color:#000 are left intact and still flagged.
1336
+ .replace(/style="([^"]*)"/g, (_, v) => `style="${v.replace(/--[a-zA-Z][^:]*:[^;"]*/g, '')}"`)
1238
1337
  if (/#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3}\b(?![0-9a-fA-F])/.test(viewBlockStripped))
1239
1338
  warnings.push('⚠ Possible hex colour in rendered HTML — use var(--ui-*) tokens')
1240
1339
 
@@ -1247,19 +1346,49 @@ any feature build.
1247
1346
  }
1248
1347
  }
1249
1348
 
1250
- // Source-level checks (don't need rendered HTML)
1251
- if (source.includes('actions:') && !source.includes('onError:'))
1349
+ // Source-level checks (don't need rendered HTML).
1350
+ // Use line-anchored regex for actions: to avoid false positives from component
1351
+ // prop strings like hero({ actions: '...' }) or card({ actions: '...' }).
1352
+ const hasActionsBlock = /^\s{0,4}actions\s*:/m.test(source)
1353
+ if (hasActionsBlock && !source.includes('onError:'))
1252
1354
  issues.push('✗ Action missing `onError` — required, will throw at runtime')
1253
- if (source.includes('actions:') && !source.includes('onSuccess:'))
1355
+ if (hasActionsBlock && !source.includes('onSuccess:'))
1254
1356
  issues.push('✗ Action missing `onSuccess` — required')
1255
1357
  if (source.includes('hydrate:'))
1256
1358
  issues.push('✗ `hydrate` is set manually — remove it, the framework sets it automatically')
1257
1359
  if (/meta\s*:\s*async/.test(source))
1258
1360
  issues.push('✗ `meta` is an async function — `meta` must be a plain object; make individual fields async instead')
1259
1361
 
1362
+ // Structural confirmations — always shown regardless of issues, so the agent
1363
+ // gets positive signal on what's correct, not just a list of failures.
1364
+ const confirms = []
1365
+ if (renderedHtml) {
1366
+ confirms.push(/<main[^>]*id=["']?main-content/.test(renderedHtml) ? '✓ `<main id="main-content">` landmark present' : null)
1367
+ const rawNavInSource = (source.match(/<nav[\s>]/gi) || []).length
1368
+ if (rawNavInSource === 0) confirms.push('✓ No raw <nav> in spec source — nav() component handles landmarks correctly')
1369
+ const headings = [...renderedHtml.matchAll(/<h([1-6])[^>]*>/g)].map(m => parseInt(m[1]))
1370
+ if (headings.length > 0) {
1371
+ const orderOk = headings.every((h, i) => i === 0 || h <= headings[i - 1] + 1)
1372
+ confirms.push(orderOk ? `✓ Heading order correct (${headings.map(h => `h${h}`).join(' → ')})` : `⚠ Heading order may be skipping levels (${headings.map(h => `h${h}`).join(' → ')})`)
1373
+ }
1374
+ const interactiveWithoutLabel = [...renderedHtml.matchAll(/<button(?![^>]*aria-label)[^>]*>\s*<\/button>/g)]
1375
+ confirms.push(interactiveWithoutLabel.length === 0 ? '✓ No empty buttons without aria-label detected' : `⚠ ${interactiveWithoutLabel.length} button(s) appear empty — check aria-label`)
1376
+ const dataEvents = [...renderedHtml.matchAll(/data-event=/g)].length
1377
+ if (dataEvents > 0) confirms.push(`✓ ${dataEvents} data-event binding(s) found`)
1378
+ const creativeOverride = /component.free|creative\s+override/i.test(source)
1379
+ if (creativeOverride) confirms.push('✓ Creative override declared — component pattern checks are advisory')
1380
+ }
1381
+
1260
1382
  const lines = ['## Quick review\n']
1383
+
1384
+ if (confirms.filter(Boolean).length > 0) {
1385
+ lines.push('### Structural checks\n')
1386
+ for (const c of confirms.filter(Boolean)) lines.push(c)
1387
+ lines.push('')
1388
+ }
1389
+
1261
1390
  if (issues.length === 0 && warnings.length === 0) {
1262
- lines.push('✓ No obvious issues found. Run `pulse_validate` next, then `/verify`.')
1391
+ lines.push('✓ No issues found. Run `pulse_validate` next, then `/verify`.')
1263
1392
  } else {
1264
1393
  if (issues.length) {
1265
1394
  lines.push('### Fix before proceeding\n')
@@ -1271,7 +1400,7 @@ any feature build.
1271
1400
  for (const w of warnings) lines.push(w)
1272
1401
  lines.push('')
1273
1402
  }
1274
- lines.push('---\nFix issues, then run `pulse_validate` → `pulse_suggest` → `/verify`.')
1403
+ lines.push('---\nFix issues, then run `pulse_validate` → `/verify --quick`.')
1275
1404
  }
1276
1405
 
1277
1406
  return text(lines.join('\n'))
@@ -1392,13 +1521,14 @@ ${(() => {
1392
1521
  }
1393
1522
 
1394
1523
  // Check spec source
1395
- // Hex colours in view — strip anchor hrefs and id attributes before checking so
1396
- // href="#fixtures" / id="main-content" / href="#join" are never flagged as hex colours.
1397
- // Only flag actual colour values: # followed by exactly 3 or 6 hex chars.
1524
+ // Hex colours in view — strip anchor hrefs, id attributes, template literal expressions,
1525
+ // and style attributes before checking so runtime-generated values (e.g. style="--swatch-bg:${hex}")
1526
+ // are never flagged. Only flag literal hex values authored directly in the view string.
1398
1527
  const viewBlock = source.slice(source.indexOf('view:'))
1399
1528
  const viewBlockStripped = viewBlock
1400
1529
  .replace(/href=["']#[^"']*["']/g, 'href="#"') // href="#anchor" → href="#"
1401
1530
  .replace(/id=["'][^"']*["']/g, 'id=""') // id="foo" → id=""
1531
+ .replace(/\$\{[^}]*\}/g, '${…}') // ${expr} — runtime values, not authored hex
1402
1532
  const hexInView = /#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3}\b(?![0-9a-fA-F])/.test(viewBlockStripped)
1403
1533
  if (hexInView) {
1404
1534
  checks.push('⚠ **Possible hex colour in view** — use var(--ui-*) tokens only')
@@ -2674,7 +2804,9 @@ The extracted values map directly to pulse_intake fields (palette, vibe, styleNo
2674
2804
  lines.push(`Navigate to: **${source}**`)
2675
2805
  lines.push('Observe the rendered page at desktop width (~1440px) and again at mobile (~375px).')
2676
2806
  lines.push('Take note of the above-the-fold area, then scroll through the full page.')
2677
- lines.push('**Fill in the sections below based on what you observe. Return a completed extraction, not an empty template.**\n')
2807
+ lines.push('**Fill in the sections below based on what you observe. Return a completed extraction, not an empty template.**')
2808
+ lines.push('')
2809
+ lines.push('> **If the URL is blocked or unreachable:** do not return a blank template. Instead, use your training knowledge of the site/brand to populate as many fields as you can, mark each cell with "(from knowledge, not live)" and note at the top that the URL was unreachable. A knowledge-based extraction is more useful than an empty template.\n')
2678
2810
  } else {
2679
2811
  lines.push('## Step 1 — Analyse the source (do this now)')
2680
2812
  lines.push(`Source: **${source}**`)
@@ -3973,7 +4105,7 @@ If you know your context already:
3973
4105
  - \`pulse_run_tests\` — run the project test suite (npm test). Use after writing or editing specs.
3974
4106
  - \`pulse_stamp\` — write the \`.pulse-verified\` stamp. Call as the **last step** of \`/verify\`, after Lighthouse and \`pulse_review\` both pass. Using this MCP tool avoids the mtime race that \`date +%s > .pulse-verified\` can cause when the stamp and the last spec write land in the same filesystem second.
3975
4107
  - \`pulse_fetch_page(url)\` — HTTP GET the dev server URL. Use to verify SSR output.
3976
- - \`pulse_restart_server\` — stop and restart the dev server.
4108
+ - \`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.
3977
4109
  - \`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.**
3978
4110
  - \`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.
3979
4111
  - \`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.
@@ -3989,9 +4121,13 @@ If you know your context already:
3989
4121
 
3990
4122
  ## MANDATORY: Verify every build
3991
4123
 
3992
- **After writing or editing any page spec, you MUST run \`/verify\` before declaring done.**
4124
+ **After writing or editing any page spec, you MUST run \`/verify\` or \`/verify --quick\` before declaring done.**
4125
+
4126
+ **Choose the right mode:**
4127
+ - \`/verify --quick\` — use during active building and iteration. Runs: validate → screenshot → console check → code review → stamp. No Lighthouse, no production build. Fast (~10 s). Use this between every round of changes.
4128
+ - \`/verify\` (full) — use when the user signals they're done or happy with the result. Runs everything: validate → screenshot → production build → Lighthouse desktop → Lighthouse mobile → performance trace → console check → code review → stamp. Slow (~90 s). Run this once at the end.
3993
4129
 
3994
- \`/verify\` is the single canonical verification workflow. It runs: validate → screenshot → console check → network check → SSR check → production build → Lighthouse desktop → Lighthouse mobile → performance trace → code review → writes the \`.pulse-verified\` stamp. The stop hook checks this stamp — if it is missing or older than the last spec edit, the hook will block and make you run verification again.
4130
+ The stop hook checks the \`.pulse-verified\` stamp — \`/verify --quick\` writes the stamp too, so it satisfies the hook for mid-build turns. The full \`/verify\` is required before the final commit.
3995
4131
 
3996
4132
  **Do not replicate \`/verify\` steps manually.** Running the individual tools yourself does not write the stamp. The agent will be blocked at the end regardless.
3997
4133
 
@@ -11,8 +11,11 @@
11
11
 
12
12
  import { validateSpec } from '../spec/schema.js'
13
13
 
14
+ import { existsSync } from 'fs'
15
+
14
16
  const tmpFile = process.argv[2]
15
17
  if (!tmpFile) { process.stdout.write('Invalid: no file path provided'); process.exit(0) }
18
+ if (!existsSync(tmpFile)) { process.stdout.write(`Invalid: file not found — ${tmpFile}`); process.exit(0) }
16
19
 
17
20
  let mod
18
21
  try {
@@ -296,8 +296,15 @@ function bindEvents(el, dispatch, signal) {
296
296
  }
297
297
 
298
298
  // Dialog open — data-dialog-open="dialogId"
299
+ // If the element also carries data-event, fire the mutation first so the view
300
+ // re-renders with updated state before the dialog opens.
299
301
  const dialogOpenTarget = e.target?.closest?.('[data-dialog-open]')
300
302
  if (dialogOpenTarget) {
303
+ const eventAttr = dialogOpenTarget.dataset.event
304
+ if (eventAttr) {
305
+ const [type, name] = parseEventAttr(eventAttr)
306
+ if (type === 'click') { e.preventDefault(); dispatch(name, e) }
307
+ }
301
308
  const dialog = document.getElementById(dialogOpenTarget.dataset.dialogOpen)
302
309
  if (dialog?.showModal) { e.preventDefault(); dialog.showModal() }
303
310
  return
@@ -472,6 +479,8 @@ function morphAttrs(cur, nxt) {
472
479
  }
473
480
  }
474
481
  for (const { name } of Array.from(cur.attributes)) {
482
+ // `open` on <dialog> is managed by the native showModal/close API — never remove it
483
+ if (cur.tagName === 'DIALOG' && name === 'open') continue
475
484
  if (!nxt.hasAttribute(name)) cur.removeAttribute(name)
476
485
  }
477
486
  }
@@ -611,9 +611,25 @@ export async function createServer(entries, options = {}) {
611
611
  const brandCache = new TtlCache(500)
612
612
  brandCache.startEviction(30_000) // brand TTL is 60s, scan every 30s
613
613
 
614
- // Load manifest — maps source hydrate paths to production bundle paths
615
- const hydrateMap = loadManifest(manifest, staticDir)
616
- const runtimeBundle = hydrateMap['_runtime'] || ''
614
+ // Load manifest — maps source hydrate paths to production bundle paths.
615
+ // When staticDir is set and no manifest is explicitly provided, the manifest is
616
+ // re-read from disk on every request so that `pulse build` takes effect immediately
617
+ // without a server restart. The file is small (~1 kB) and the read is synchronous,
618
+ // so the overhead is negligible.
619
+ // When manifest is an explicit object (including {} from the dev server to disable
620
+ // manifest-based hydration), it is used as-is without disk reads. This ensures that
621
+ // passing manifest: {} always means "no manifest" — even when staticDir is set.
622
+ const _manifestStatic = (manifest !== null && typeof manifest === 'object') ? manifest : null
623
+ const _manifestPath = _manifestStatic ? null
624
+ : typeof manifest === 'string' ? manifest
625
+ : (manifest === null && staticDir) ? path.join(staticDir, 'dist', 'manifest.json') : null
626
+ const getHydrateMap = _manifestStatic
627
+ ? () => _manifestStatic
628
+ : () => {
629
+ if (!_manifestPath) return {}
630
+ try { return JSON.parse(fs.readFileSync(_manifestPath, 'utf8')) } catch { return {} }
631
+ }
632
+
617
633
 
618
634
  // Auto-detect favicon in staticDir
619
635
  const faviconPath = staticDir
@@ -905,6 +921,8 @@ export async function createServer(entries, options = {}) {
905
921
  ctx.store = await resolveStoreState(storeDef, ctx)
906
922
  }
907
923
 
924
+ const hydrateMap = getHydrateMap()
925
+ const runtimeBundle = hydrateMap['_runtime'] || ''
908
926
  const spec = resolveSpec(match.spec, hydrateMap)
909
927
 
910
928
  // Per-spec timeout overrides the global default
@@ -1691,33 +1709,6 @@ function mergeCtxHeaders(ctx, headers) {
1691
1709
  // Manifest & spec resolution
1692
1710
  // ---------------------------------------------------------------------------
1693
1711
 
1694
- /**
1695
- * Load a hydrate manifest.
1696
- * Accepts a manifest object, a path to a JSON file, or auto-detects from
1697
- * staticDir/dist/manifest.json when staticDir is set.
1698
- *
1699
- * @param {Object|string|null} manifest
1700
- * @param {string|null} staticDir
1701
- * @returns {Object} map of source paths → bundle paths
1702
- */
1703
- function loadManifest(manifest, staticDir) {
1704
- if (!manifest && !staticDir) return {}
1705
-
1706
- if (manifest && typeof manifest === 'object') return manifest
1707
-
1708
- const manifestPath = typeof manifest === 'string'
1709
- ? manifest
1710
- : staticDir ? path.join(staticDir, 'dist', 'manifest.json') : null
1711
-
1712
- if (!manifestPath) return {}
1713
-
1714
- try {
1715
- return JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
1716
- } catch {
1717
- return {}
1718
- }
1719
- }
1720
-
1721
1712
  /**
1722
1713
  * Return a copy of the spec with the hydrate path and any meta.styles paths
1723
1714
  * resolved to their production bundle paths (if manifest entries exist).