@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.
- package/.claude/commands/verify.md +17 -11
- package/package.json +1 -1
- package/public/.pulse-ui-version +1 -1
- package/scripts/css-purge.js +65 -3
- package/scripts/css-purge.test.js +16 -0
- package/src/agent/commands/verify.md +17 -11
- package/src/agent/guide-components.md +2 -2
- package/src/agent/identity.md +2 -2
- package/src/agent/workflow.md +5 -2
- package/src/cli/dev.js +17 -0
- package/src/mcp/server.js +157 -21
- package/src/mcp/validate-worker.js +3 -0
- package/src/runtime/index.js +9 -0
- package/src/server/index.js +21 -30
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
# Verify
|
|
2
2
|
|
|
3
|
-
Run the
|
|
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
|
|
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
|
-
|
|
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
package/public/.pulse-ui-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.20.
|
|
1
|
+
0.20.4
|
package/scripts/css-purge.js
CHANGED
|
@@ -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
|
|
16
|
-
for (const content of
|
|
17
|
-
const re = /class
|
|
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
|
|
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
|
|
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
|
-
|
|
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` |
|
package/src/agent/identity.md
CHANGED
|
@@ -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.
|
package/src/agent/workflow.md
CHANGED
|
@@ -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
|
|
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: '
|
|
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
|
-
//
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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` →
|
|
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
|
|
1396
|
-
//
|
|
1397
|
-
// Only flag
|
|
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
|
|
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\` —
|
|
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
|
-
|
|
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 {
|
package/src/runtime/index.js
CHANGED
|
@@ -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
|
}
|
package/src/server/index.js
CHANGED
|
@@ -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
|
-
|
|
616
|
-
|
|
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).
|