@invisibleloop/pulse 0.15.0 → 0.16.0
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 +44 -20
- package/package.json +2 -2
- package/public/.pulse-ui-version +1 -1
- package/src/agent/commands/verify.md +2 -6
- package/src/agent/guide-components.md +1 -1
- package/src/agent/guide-routing.md +91 -0
- package/src/agent/skills/verify/SKILL.md +2 -4
- package/src/agent/workflow.md +3 -1
- package/src/mcp/mcp.test.js +108 -0
- package/src/mcp/server.js +101 -28
- package/src/mcp/validate-worker.js +24 -0
- package/src/agent/guide.md +0 -1087
|
@@ -32,55 +32,79 @@ Use `mcp__chrome-devtools__navigate_page` to load the page route, then `mcp__chr
|
|
|
32
32
|
|
|
33
33
|
### 6. Lighthouse — desktop
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
**Pre-flight (required before every Lighthouse run):**
|
|
36
|
+
1. Call `pulse_build` to produce a production build and start the production server on port 3001.
|
|
37
|
+
2. Call `navigate_page` with `url: "http://localhost:3001/"` so the browser is on the production server.
|
|
36
38
|
|
|
37
|
-
|
|
39
|
+
Then run `mcp__chrome-devtools__lighthouse_audit` with `{ "device": "desktop" }`.
|
|
40
|
+
|
|
41
|
+
**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.
|
|
38
42
|
|
|
39
43
|
### 7. Lighthouse — mobile
|
|
40
44
|
|
|
41
|
-
Run `mcp__chrome-devtools__lighthouse_audit`
|
|
45
|
+
The browser should still be on `http://localhost:3001/` from step 6. Run `mcp__chrome-devtools__lighthouse_audit` with `{ "device": "mobile" }`.
|
|
46
|
+
|
|
47
|
+
**Same pass bar: Accessibility, Best Practices, and SEO must all be 100.** Fix any failures and restart from step 3.
|
|
48
|
+
|
|
49
|
+
After both Lighthouse runs pass, call `pulse_restart_server` to return to the dev server.
|
|
50
|
+
|
|
51
|
+
### 8. Mobile layout check
|
|
52
|
+
|
|
53
|
+
Emulate a mobile viewport and take a screenshot to catch wrapping, overflow, and layout issues before they become Lighthouse failures.
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
mcp__chrome-devtools__emulate viewport: "390x844x2,mobile,touch"
|
|
57
|
+
mcp__chrome-devtools__navigate_page url: "http://localhost:3000/"
|
|
58
|
+
mcp__chrome-devtools__take_screenshot
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Describe what you see. Look specifically for:
|
|
62
|
+
- Text or buttons overflowing the viewport
|
|
63
|
+
- Content too small to read or tap
|
|
64
|
+
- Navigation that overlaps content
|
|
65
|
+
- Images that break the layout
|
|
42
66
|
|
|
43
|
-
|
|
67
|
+
Fix any issues, then reset to desktop before continuing:
|
|
44
68
|
|
|
45
|
-
|
|
69
|
+
```
|
|
70
|
+
mcp__chrome-devtools__emulate viewport: "1440x900x1"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### 9. Performance
|
|
46
74
|
|
|
47
75
|
Navigate to the page route, 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.).
|
|
48
76
|
|
|
49
|
-
###
|
|
77
|
+
### 10. Console errors
|
|
50
78
|
|
|
51
79
|
Use `mcp__chrome-devtools__list_console_messages` — report any errors or unexpected warnings.
|
|
52
80
|
|
|
53
|
-
###
|
|
81
|
+
### 11. Code review
|
|
54
82
|
|
|
55
83
|
Call `pulse_review` with the spec file path. This runs a full code review AND atomically writes the `.pulse-verified` stamp server-side, eliminating any race condition with the stop hook.
|
|
56
84
|
|
|
57
85
|
Work through every item the review returns. Fix anything that fails before proceeding.
|
|
58
86
|
|
|
59
|
-
###
|
|
87
|
+
### 12. Close extra browser tabs
|
|
60
88
|
|
|
61
89
|
Use `mcp__chrome-devtools__list_pages` to get all open pages. Close every page **except the last one** — `close_page` refuses to close the final tab. `pageId` must be a number, not a string.
|
|
62
90
|
|
|
63
91
|
If there is only one page open, skip this step — there is nothing to close.
|
|
64
92
|
|
|
65
|
-
###
|
|
66
|
-
|
|
67
|
-
`pulse_review` writes the stamp automatically. As a belt-and-suspenders backup, also run:
|
|
93
|
+
### 13. Write verification stamp
|
|
68
94
|
|
|
69
|
-
|
|
70
|
-
date +%s > .pulse-verified
|
|
71
|
-
```
|
|
95
|
+
Call `pulse_stamp`. This writes `.pulse-verified` via the MCP server, which is more reliable than the `date +%s` shell command — the MCP write always lands after all spec edits, avoiding the mtime race condition that can cause the stop hook to block immediately after verification.
|
|
72
96
|
|
|
73
|
-
This
|
|
97
|
+
**This must be the last operation before stopping.** The stop hook compares each edited spec's mtime against this stamp — if any spec is newer than the stamp, the hook blocks. Do not edit any spec file after calling `pulse_stamp`.
|
|
74
98
|
|
|
75
|
-
###
|
|
99
|
+
### 14. Report
|
|
76
100
|
|
|
77
101
|
Summarise:
|
|
78
102
|
- Validation: pass or fail (with errors if any)
|
|
79
|
-
- Visual:
|
|
80
|
-
- Lighthouse desktop: scores for
|
|
81
|
-
- Lighthouse mobile: scores for
|
|
103
|
+
- Visual: desktop screenshot and mobile layout check — any issues found and fixed
|
|
104
|
+
- Lighthouse desktop: scores for Accessibility, Best Practices, SEO
|
|
105
|
+
- Lighthouse mobile: scores for Accessibility, Best Practices, SEO
|
|
82
106
|
- Performance: LCP and CLS values, any flagged insights
|
|
83
107
|
- Console: any errors
|
|
84
108
|
- Review: pass or issues found and fixed
|
|
85
109
|
|
|
86
|
-
Only confirm the page is good when validation passes, both Lighthouse runs are 100/100/100
|
|
110
|
+
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.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "AI-first frontend framework. The spec is the source of truth.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"scripts": {
|
|
58
58
|
"dev": "node --watch src/cli/dev.js",
|
|
59
59
|
"build": "node scripts/build.js",
|
|
60
|
-
"test": "node src/spec/schema.test.js && node src/runtime/runtime.test.js && node src/runtime/navigate.test.js && node src/runtime/morph.test.js && node src/runtime/ssr.test.js && node src/runtime/image.test.js && node src/server/server.test.js && node src/cli/cli.test.js && node src/ui/ui.test.js && node src/testing/testing.test.js && node src/store/store.test.js && node src/md/md.test.js && node scripts/strip-server.test.js",
|
|
60
|
+
"test": "node src/spec/schema.test.js && node src/runtime/runtime.test.js && node src/runtime/navigate.test.js && node src/runtime/morph.test.js && node src/runtime/ssr.test.js && node src/runtime/image.test.js && node src/server/server.test.js && node src/cli/cli.test.js && node src/ui/ui.test.js && node src/testing/testing.test.js && node src/store/store.test.js && node src/md/md.test.js && node scripts/strip-server.test.js && node src/mcp/mcp.test.js",
|
|
61
61
|
"typecheck": "tsc --noEmit",
|
|
62
62
|
"docs": "node --watch src/cli/dev.js --root docs --port 4000",
|
|
63
63
|
"docs:start": "node docs/server.js"
|
package/public/.pulse-ui-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.16.0
|
|
@@ -54,13 +54,9 @@ Use `mcp__chrome-devtools__list_pages` to get all open pages, then call `mcp__ch
|
|
|
54
54
|
|
|
55
55
|
### 11. Write verification stamp
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
Call `pulse_stamp`. This writes `.pulse-verified` via the MCP server, which is more reliable than the `date +%s` shell command — the MCP write always lands after all spec edits, avoiding the mtime race condition that can cause the stop hook to block immediately after verification.
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
date +%s > .pulse-verified
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
**This must be the last write operation before stopping.** The stop hook compares each edited spec's modification time against this stamp — if any spec is newer than the stamp, the hook blocks. Do not edit any spec file after writing the stamp.
|
|
59
|
+
**This must be the last operation before stopping.** The stop hook compares each edited spec's mtime against this stamp — if any spec is newer than the stamp, the hook blocks. Do not edit any spec file after calling `pulse_stamp`.
|
|
64
60
|
|
|
65
61
|
### 12. Report
|
|
66
62
|
|
|
@@ -172,7 +172,7 @@ To add a missing icon: copy a Lucide path (MIT) into `src/ui/icons.js` using `s(
|
|
|
172
172
|
| `stat` | `label`, `value`, `change`, `trend` (up/down/neutral), `size` (sm/md/lg — lg for hero KPIs), `center` |
|
|
173
173
|
| `avatar` | `src`, `alt`, `size` (sm/md/lg/xl), `initials` |
|
|
174
174
|
| `empty` | `title`, `description`, `action` ({label,href,variant}) |
|
|
175
|
-
| `table` | `headers`, `rows` (2D array of HTML strings), `caption` |
|
|
175
|
+
| `table` | `headers`, `rows` (2D array of HTML strings), `caption` | ⚠ Each cell item must be **raw HTML content** — `table()` wraps each item in `<td>` itself. Do NOT pass `<td>...</td>` strings or you will get double-nested cells. |
|
|
176
176
|
| `spinner` | `size` (sm/md/lg), `color` (accent/muted/white), `label` |
|
|
177
177
|
| `progress` | `value`, `max` (100), `label`, `showLabel`, `showValue`, `variant`, `size` |
|
|
178
178
|
| `breadcrumbs` | `items` ([{label,href}] — last item has no href), `separator` |
|
|
@@ -1,3 +1,94 @@
|
|
|
1
|
+
## Multi-page sites — the right patterns
|
|
2
|
+
|
|
3
|
+
### Shared layout component
|
|
4
|
+
|
|
5
|
+
When a project has more than one page, **never repeat the nav and footer in every spec**. Extract them into a shared component:
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
// src/components/layout.js
|
|
9
|
+
import { nav, footer, button } from '@invisibleloop/pulse/ui'
|
|
10
|
+
|
|
11
|
+
export const NAV_LINKS = [
|
|
12
|
+
{ label: 'Home', href: '/' },
|
|
13
|
+
{ label: 'About', href: '/about' },
|
|
14
|
+
{ label: 'Blog', href: '/blog' },
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
export const layout = ({ content, activePath = '' }) => `
|
|
18
|
+
${nav({
|
|
19
|
+
logo: 'My Site',
|
|
20
|
+
logoHref: '/',
|
|
21
|
+
links: NAV_LINKS.map(l => ({
|
|
22
|
+
...l,
|
|
23
|
+
attrs: activePath === l.href ? 'aria-current="page"' : '',
|
|
24
|
+
})),
|
|
25
|
+
action: button({ label: 'Sign up', href: '/signup', variant: 'primary', size: 'sm' }),
|
|
26
|
+
})}
|
|
27
|
+
<main id="main-content">
|
|
28
|
+
${content}
|
|
29
|
+
</main>
|
|
30
|
+
${footer({
|
|
31
|
+
logo: 'My Site',
|
|
32
|
+
links: NAV_LINKS,
|
|
33
|
+
legal: '© 2026 My Site',
|
|
34
|
+
})}
|
|
35
|
+
`
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Each page spec uses it:
|
|
39
|
+
```js
|
|
40
|
+
import { layout } from '../components/layout.js'
|
|
41
|
+
|
|
42
|
+
export default {
|
|
43
|
+
route: '/about',
|
|
44
|
+
view: () => layout({
|
|
45
|
+
activePath: '/about',
|
|
46
|
+
content: `<h1>About us</h1>`,
|
|
47
|
+
}),
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
**Rules:**
|
|
52
|
+
- Add new nav links in `src/components/layout.js` only — never hardcode nav links in individual page specs.
|
|
53
|
+
- Pass `activePath` so the active nav item gets `aria-current="page"` — this is required for Lighthouse accessibility.
|
|
54
|
+
- The `layout()` component must include `<main id="main-content">` — do not add it again inside `content`.
|
|
55
|
+
|
|
56
|
+
### Skip link
|
|
57
|
+
|
|
58
|
+
Add a skip link before the nav for keyboard users. It must be the first focusable element on every page:
|
|
59
|
+
|
|
60
|
+
```js
|
|
61
|
+
// src/components/layout.js
|
|
62
|
+
export const SKIP_LINK = `<a href="#main-content" class="skip-link">Skip to main content</a>`
|
|
63
|
+
|
|
64
|
+
// public/app.css
|
|
65
|
+
.skip-link {
|
|
66
|
+
position: absolute;
|
|
67
|
+
left: -9999px;
|
|
68
|
+
top: var(--ui-space-2);
|
|
69
|
+
z-index: 9999;
|
|
70
|
+
padding: var(--ui-space-2) var(--ui-space-4);
|
|
71
|
+
background: var(--ui-accent);
|
|
72
|
+
color: var(--ui-bg);
|
|
73
|
+
font-weight: 600;
|
|
74
|
+
}
|
|
75
|
+
.skip-link:focus { left: var(--ui-space-2); }
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Include it at the top of every layout: `${SKIP_LINK}${nav({...})}`.
|
|
79
|
+
|
|
80
|
+
### Active nav state
|
|
81
|
+
|
|
82
|
+
The active page link must have `aria-current="page"`. Without it Lighthouse accessibility fails.
|
|
83
|
+
|
|
84
|
+
The pattern above uses `attrs` — alternatively, use CSS with `:not` and a body class, or check `ctx` in the server fetcher and pass a flag to the view. Any approach is fine; the output must be `aria-current="page"` on the matching link.
|
|
85
|
+
|
|
86
|
+
### Shared state across pages
|
|
87
|
+
|
|
88
|
+
Use the global store (`pulse.store.js`) for state that needs to be consistent across pages — user session, cart count, theme preference. Do not duplicate server fetchers across specs for the same data.
|
|
89
|
+
|
|
90
|
+
See `pulse://guide/server` for the full store API.
|
|
91
|
+
|
|
1
92
|
## Site navigation
|
|
2
93
|
|
|
3
94
|
Projects define navigation in src/components/layout.js via a NAV_LINKS array.
|
|
@@ -83,11 +83,9 @@ Run `mcp__chrome-devtools__list_pages`, then `mcp__chrome-devtools__close_page`
|
|
|
83
83
|
|
|
84
84
|
### 12. Write verification stamp
|
|
85
85
|
|
|
86
|
-
|
|
87
|
-
date +%s > .pulse-verified
|
|
88
|
-
```
|
|
86
|
+
Call `pulse_stamp`. This writes `.pulse-verified` via the MCP server, which is more reliable than `date +%s > .pulse-verified` — the MCP write always lands after all spec edits, avoiding the mtime race that causes the stop hook to block immediately after verification.
|
|
89
87
|
|
|
90
|
-
**This must be the last
|
|
88
|
+
**This must be the last operation before stopping.** Do not edit any spec file after calling `pulse_stamp`.
|
|
91
89
|
|
|
92
90
|
### 13. Report
|
|
93
91
|
|
package/src/agent/workflow.md
CHANGED
|
@@ -298,7 +298,9 @@ Run the tests. Fix every failure.
|
|
|
298
298
|
|
|
299
299
|
Only invoke `pulse_review` after phases 4–6 all pass. It checks the code for correctness, security, accessibility, DRY violations, and checklist adherence.
|
|
300
300
|
|
|
301
|
-
|
|
301
|
+
**Ordering:** `pulse_review` runs **after** Lighthouse, not before. Lighthouse catches contrast failures, missing landmarks, and Best Practices issues that `pulse_review` cannot detect statically. Fixing a Lighthouse failure may require spec edits — so the code review always comes last, against the final version of the code.
|
|
302
|
+
|
|
303
|
+
> Note: `/verify` includes the review step in the correct position — Lighthouse first (steps 6–7), then `pulse_review` (step 10). If you ran `/verify` and it passed, `pulse_review` has already run against the Lighthouse-passing version of the code.
|
|
302
304
|
|
|
303
305
|
---
|
|
304
306
|
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP integrity tests — run with: node src/mcp/mcp.test.js
|
|
3
|
+
*
|
|
4
|
+
* Guards against guide file drift: every guide-*.md file under src/agent/
|
|
5
|
+
* must be registered as a named MCP resource in server.js, and every
|
|
6
|
+
* readFileSync call in server.js that references a guide-*.md must point
|
|
7
|
+
* to a file that actually exists.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs'
|
|
11
|
+
import path from 'node:path'
|
|
12
|
+
import { fileURLToPath } from 'node:url'
|
|
13
|
+
|
|
14
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
15
|
+
const ROOT = path.resolve(__dirname, '../..')
|
|
16
|
+
|
|
17
|
+
let passed = 0
|
|
18
|
+
let failed = 0
|
|
19
|
+
|
|
20
|
+
function test(label, fn) {
|
|
21
|
+
try {
|
|
22
|
+
fn()
|
|
23
|
+
console.log(` ✓ ${label}`)
|
|
24
|
+
passed++
|
|
25
|
+
} catch (e) {
|
|
26
|
+
console.log(` ✗ ${label}`)
|
|
27
|
+
console.log(` ${e.message}`)
|
|
28
|
+
failed++
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function assert(condition, msg) {
|
|
33
|
+
if (!condition) throw new Error(msg || 'Assertion failed')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── Read source files ────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
const serverSrc = fs.readFileSync(path.join(__dirname, 'server.js'), 'utf8')
|
|
39
|
+
const agentDir = path.join(ROOT, 'src/agent')
|
|
40
|
+
|
|
41
|
+
// All guide-*.md files that exist on disk
|
|
42
|
+
const guideFiles = fs.readdirSync(agentDir)
|
|
43
|
+
.filter(f => f.startsWith('guide-') && f.endsWith('.md'))
|
|
44
|
+
.sort()
|
|
45
|
+
|
|
46
|
+
// All guide-*.md files referenced via readFileSync in server.js
|
|
47
|
+
// Pattern: new URL('../agent/guide-*.md', import.meta.url)
|
|
48
|
+
const referencedFiles = [...serverSrc.matchAll(/new URL\(['"`][^'"`]*?(guide-[^'"`/]+\.md)['"`]/g)]
|
|
49
|
+
.map(m => m[1])
|
|
50
|
+
|
|
51
|
+
// All guide-*.md filenames registered as MCP resource URIs
|
|
52
|
+
const registeredUris = [...serverSrc.matchAll(/uri:\s*['"`](pulse:\/\/guide\/[^'"`]+)['"`]/g)]
|
|
53
|
+
.map(m => m[1])
|
|
54
|
+
|
|
55
|
+
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
console.log('\nGuide file coverage\n')
|
|
58
|
+
|
|
59
|
+
test('guide-routing.md exists on disk', () => {
|
|
60
|
+
assert(fs.existsSync(path.join(agentDir, 'guide-routing.md')), 'guide-routing.md not found')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('guide-components.md exists on disk', () => {
|
|
64
|
+
assert(fs.existsSync(path.join(agentDir, 'guide-components.md')), 'guide-components.md not found')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
for (const file of guideFiles) {
|
|
68
|
+
test(`${file} is referenced by readFileSync in server.js`, () => {
|
|
69
|
+
assert(
|
|
70
|
+
referencedFiles.includes(file),
|
|
71
|
+
`${file} exists in src/agent/ but is not loaded by server.js via readFileSync. ` +
|
|
72
|
+
`Either register it as a guide resource or delete it.`
|
|
73
|
+
)
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (const file of referencedFiles) {
|
|
78
|
+
test(`${file} (referenced in server.js) exists on disk`, () => {
|
|
79
|
+
assert(
|
|
80
|
+
fs.existsSync(path.join(agentDir, file)),
|
|
81
|
+
`server.js references ${file} via readFileSync but the file does not exist in src/agent/`
|
|
82
|
+
)
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
test('every guide-*.md on disk has a corresponding pulse://guide/* MCP resource', () => {
|
|
87
|
+
// Map file names to expected URI slugs: guide-design-references.md → pulse://guide/design-references
|
|
88
|
+
const fileToUri = (f) => 'pulse://guide/' + f.replace(/^guide-/, '').replace(/\.md$/, '')
|
|
89
|
+
const missing = guideFiles.filter(f => !registeredUris.includes(fileToUri(f)))
|
|
90
|
+
assert(
|
|
91
|
+
missing.length === 0,
|
|
92
|
+
`These guide files have no registered MCP resource URI:\n ${missing.join('\n ')}\n` +
|
|
93
|
+
`Add them to GUIDE_RESOURCES in server.js or delete them.`
|
|
94
|
+
)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('no orphaned guide.md (monolithic legacy file should not exist)', () => {
|
|
98
|
+
assert(
|
|
99
|
+
!fs.existsSync(path.join(agentDir, 'guide.md')),
|
|
100
|
+
'guide.md exists in src/agent/ — this is the old monolithic guide and should be deleted. ' +
|
|
101
|
+
'Content should live in the split guide-*.md files.'
|
|
102
|
+
)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
// ── Result ───────────────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
console.log(`\n${passed} passed, ${failed} failed\n`)
|
|
108
|
+
if (failed > 0) process.exit(1)
|
package/src/mcp/server.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* pulse_create_page — create a new page spec with proper template
|
|
19
19
|
* pulse_create_component — create a reusable component
|
|
20
20
|
* pulse_validate — validate a spec against the schema
|
|
21
|
+
* pulse_stamp — write the .pulse-verified stamp (call as last step of /verify)
|
|
21
22
|
* pulse_check_version — installed vs static vs npm latest
|
|
22
23
|
* pulse_update — re-copy pulse-ui assets from package → public/
|
|
23
24
|
*/
|
|
@@ -1042,22 +1043,47 @@ ${(() => {
|
|
|
1042
1043
|
}
|
|
1043
1044
|
|
|
1044
1045
|
// Check spec source
|
|
1045
|
-
// Hex colours in view —
|
|
1046
|
-
//
|
|
1046
|
+
// Hex colours in view — strip anchor hrefs and id attributes before checking so
|
|
1047
|
+
// href="#fixtures" / id="main-content" / href="#join" are never flagged as hex colours.
|
|
1048
|
+
// Only flag actual colour values: # followed by exactly 3 or 6 hex chars.
|
|
1047
1049
|
const viewBlock = source.slice(source.indexOf('view:'))
|
|
1048
|
-
const
|
|
1050
|
+
const viewBlockStripped = viewBlock
|
|
1051
|
+
.replace(/href=["']#[^"']*["']/g, 'href="#"') // href="#anchor" → href="#"
|
|
1052
|
+
.replace(/id=["'][^"']*["']/g, 'id=""') // id="foo" → id=""
|
|
1053
|
+
const hexInView = /#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3}\b(?![0-9a-fA-F])/.test(viewBlockStripped)
|
|
1049
1054
|
if (hexInView) {
|
|
1050
|
-
checks.push('⚠ **Possible hex colour in view** — use var(--ui-*) tokens only
|
|
1055
|
+
checks.push('⚠ **Possible hex colour in view** — use var(--ui-*) tokens only')
|
|
1051
1056
|
} else {
|
|
1052
1057
|
checks.push('✓ No obvious hex colours in view')
|
|
1053
1058
|
}
|
|
1054
1059
|
|
|
1060
|
+
// XSS test coverage — check whether the test file includes a <script>alert assertion
|
|
1061
|
+
// for each user-controlled string interpolated into the view.
|
|
1062
|
+
// Heuristic: if the view interpolates any exported string variable (escape(), or direct
|
|
1063
|
+
// string props), the test file should contain a '<script>' XSS assertion.
|
|
1064
|
+
if (file) {
|
|
1065
|
+
const testFile = file.replace(/\.js$/, '.test.js')
|
|
1066
|
+
let xssNote = ''
|
|
1067
|
+
if (fs.existsSync(testFile)) {
|
|
1068
|
+
const testSrc = fs.readFileSync(testFile, 'utf8')
|
|
1069
|
+
const hasXssAssertion = testSrc.includes('<script>') || testSrc.includes('xss') || testSrc.includes('script>alert')
|
|
1070
|
+
if (!hasXssAssertion) {
|
|
1071
|
+
xssNote = '⚠ **No XSS test found** — test file exists but has no `<script>alert` assertion. Add at least one test that passes a `\'<script>alert(1)</script>\'` string as a user-controlled input and asserts it does not appear unescaped in the output.'
|
|
1072
|
+
} else {
|
|
1073
|
+
xssNote = '✓ XSS test assertion present'
|
|
1074
|
+
}
|
|
1075
|
+
} else {
|
|
1076
|
+
xssNote = '○ No test file found — create one at ' + path.basename(testFile)
|
|
1077
|
+
}
|
|
1078
|
+
checks.push(xssNote)
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1055
1081
|
// Creative override detection — check if spec declares component-free mode
|
|
1056
1082
|
const creativeOverride = /component.free|creative\s+override|raw\s+HTML\s+throughout/i.test(source)
|
|
1057
1083
|
checks.push(creativeOverride
|
|
1058
1084
|
? '⚡ **Creative override declared** — component pattern checks are advisory only. Lighthouse 100/100/100/100 is the pass bar.'
|
|
1059
1085
|
: '○ No creative override declared — component checks apply')
|
|
1060
|
-
|
|
1086
|
+
|
|
1061
1087
|
return checks.join('\n')
|
|
1062
1088
|
})()}
|
|
1063
1089
|
|
|
@@ -1141,12 +1167,39 @@ Fix every issue you find. Then confirm what was changed.
|
|
|
1141
1167
|
|
|
1142
1168
|
**After confirming fixes: you are back in builder mode. Continue to the verification workflow — navigate to the page in the browser, take a screenshot, run Lighthouse desktop audit, run Lighthouse mobile audit. Do not stop at the review.**`)
|
|
1143
1169
|
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1170
|
+
}
|
|
1171
|
+
)
|
|
1172
|
+
|
|
1173
|
+
// ---------------------------------------------------------------------------
|
|
1174
|
+
// pulse_stamp — write the .pulse-verified stamp
|
|
1175
|
+
// ---------------------------------------------------------------------------
|
|
1176
|
+
// Called as the final step of /verify, after all Lighthouse gates pass and
|
|
1177
|
+
// pulse_review is complete. Using the MCP tool avoids the mtime race that can
|
|
1178
|
+
// occur when `date +%s > .pulse-verified` runs in the same second as the last
|
|
1179
|
+
// spec write, making the stamp appear older than the spec.
|
|
1180
|
+
//
|
|
1181
|
+
// The stop hook compares spec mtimeMs against stamp mtimeMs — not the content.
|
|
1182
|
+
// Writing via MCP ensures the stamp is always written after all spec edits.
|
|
1183
|
+
|
|
1184
|
+
server.registerTool(
|
|
1185
|
+
'pulse_stamp',
|
|
1186
|
+
{
|
|
1187
|
+
description: `Write the .pulse-verified stamp to clear the stop-hook verification gate.
|
|
1188
|
+
|
|
1189
|
+
Call this as the **last step** of /verify — after Lighthouse desktop + mobile both pass 100/100/100, pulse_review is complete, and no further spec edits will be made.
|
|
1190
|
+
|
|
1191
|
+
The stop hook compares each changed spec's mtime against this stamp. Any spec newer than the stamp blocks the session. Do NOT call this before Lighthouse or before fixing issues found in pulse_review.`,
|
|
1192
|
+
inputSchema: {},
|
|
1193
|
+
},
|
|
1194
|
+
() => {
|
|
1195
|
+
const stampPath = path.join(ROOT, '.pulse-verified')
|
|
1196
|
+
const ts = String(Math.floor(Date.now() / 1000))
|
|
1147
1197
|
try {
|
|
1148
|
-
fs.writeFileSync(
|
|
1149
|
-
|
|
1198
|
+
fs.writeFileSync(stampPath, ts, 'utf8')
|
|
1199
|
+
return text(`✓ .pulse-verified written (${ts}). Stop hook cleared — session can end.`)
|
|
1200
|
+
} catch (err) {
|
|
1201
|
+
return text(`Error writing .pulse-verified: ${err.message}\nFallback: run \`date +%s > .pulse-verified\` in Bash.`)
|
|
1202
|
+
}
|
|
1150
1203
|
}
|
|
1151
1204
|
)
|
|
1152
1205
|
|
|
@@ -2220,21 +2273,24 @@ The extracted values map directly to pulse_intake fields (palette, vibe, styleNo
|
|
|
2220
2273
|
({ source, focus = 'all' }) => {
|
|
2221
2274
|
const lines = []
|
|
2222
2275
|
lines.push(`# Inspiration Extraction — ${source}\n`)
|
|
2223
|
-
lines.push('
|
|
2224
|
-
lines.push('
|
|
2276
|
+
lines.push('**You must fill in this template now** — do not return it blank to the user.')
|
|
2277
|
+
lines.push('Use your browsing tool (for URLs) or vision capability (for images) to observe the source and populate every field you can determine.')
|
|
2278
|
+
lines.push('Leave a cell blank only if it is genuinely unobservable. When done, call `pulse_intake` with the extracted values.\n')
|
|
2225
2279
|
lines.push('---\n')
|
|
2226
2280
|
|
|
2227
2281
|
const isUrl = /^https?:\/\//.test(source) || /\.(com|io|co|app|design|net|org|dev)\b/.test(source)
|
|
2228
2282
|
|
|
2229
2283
|
if (isUrl) {
|
|
2230
|
-
lines.push('## Step 1 — Visit the site')
|
|
2284
|
+
lines.push('## Step 1 — Visit the site (do this now)')
|
|
2231
2285
|
lines.push(`Navigate to: **${source}**`)
|
|
2232
2286
|
lines.push('Observe the rendered page at desktop width (~1440px) and again at mobile (~375px).')
|
|
2233
|
-
lines.push('Take note of the above-the-fold area, then scroll through the full page
|
|
2287
|
+
lines.push('Take note of the above-the-fold area, then scroll through the full page.')
|
|
2288
|
+
lines.push('**Fill in the sections below based on what you observe. Return a completed extraction, not an empty template.**\n')
|
|
2234
2289
|
} else {
|
|
2235
|
-
lines.push('## Step 1 — Analyse the source')
|
|
2290
|
+
lines.push('## Step 1 — Analyse the source (do this now)')
|
|
2236
2291
|
lines.push(`Source: **${source}**`)
|
|
2237
|
-
lines.push('Use your vision capability or knowledge of this reference to fill in the template below
|
|
2292
|
+
lines.push('Use your vision capability or knowledge of this reference to fill in the template below.')
|
|
2293
|
+
lines.push('**Return a completed extraction with your observations filled in — not a blank template.**\n')
|
|
2238
2294
|
}
|
|
2239
2295
|
|
|
2240
2296
|
lines.push('---\n')
|
|
@@ -2322,7 +2378,9 @@ The extracted values map directly to pulse_intake fields (palette, vibe, styleNo
|
|
|
2322
2378
|
lines.push('})')
|
|
2323
2379
|
lines.push('```')
|
|
2324
2380
|
lines.push('')
|
|
2325
|
-
lines.push('
|
|
2381
|
+
lines.push('**Now fill in the template above with your observations, then call `pulse_sketch` — pass the layout direction observed above as context in your `brief`.**')
|
|
2382
|
+
lines.push('')
|
|
2383
|
+
lines.push('> Do not return this template blank. The user is waiting for a completed extraction.')
|
|
2326
2384
|
|
|
2327
2385
|
return text(lines.join('\n'))
|
|
2328
2386
|
}
|
|
@@ -2989,15 +3047,21 @@ Run this immediately after writing a theme file — before production build and
|
|
|
2989
3047
|
}
|
|
2990
3048
|
|
|
2991
3049
|
// Standard pairings to check — [foreground token, background token, label, isLargeText]
|
|
3050
|
+
// isLargeText=true → WCAG AA large text threshold (3:1); isLargeText=false → body text (4.5:1)
|
|
3051
|
+
// Muted text is checked at both thresholds: body text pairings always need 4.5:1.
|
|
3052
|
+
// When a muted pairing fails 4.5:1 but passes 3:1, the warning distinguishes the two
|
|
3053
|
+
// so designers know it can be used for captions/eyebrows (large) but not body copy (small).
|
|
2992
3054
|
const PAIRINGS = [
|
|
2993
|
-
['--ui-text', '--ui-bg', 'Body text on page background',
|
|
2994
|
-
['--ui-muted', '--ui-bg', 'Muted text on page background', false],
|
|
2995
|
-
['--ui-
|
|
2996
|
-
['--ui-
|
|
2997
|
-
['--ui-
|
|
2998
|
-
['--ui-
|
|
2999
|
-
['--ui-
|
|
3000
|
-
['--ui-text',
|
|
3055
|
+
['--ui-text', '--ui-bg', 'Body text on page background', false],
|
|
3056
|
+
['--ui-muted', '--ui-bg', 'Muted/secondary text on page background (body size)', false],
|
|
3057
|
+
['--ui-muted', '--ui-bg', 'Muted text on page background (large text / captions)', true],
|
|
3058
|
+
['--ui-accent', '--ui-bg', 'Accent text on page background', false],
|
|
3059
|
+
['--ui-text', '--ui-surface', 'Body text on card/surface', false],
|
|
3060
|
+
['--ui-muted', '--ui-surface', 'Muted text on card/surface (body size)', false],
|
|
3061
|
+
['--ui-muted', '--ui-surface', 'Muted text on card/surface (large text / captions)', true],
|
|
3062
|
+
['--ui-accent-text', '--ui-accent', 'Button text on accent background', false],
|
|
3063
|
+
['--ui-accent', '--ui-surface', 'Accent text on surface', false],
|
|
3064
|
+
['--ui-text', '--ui-surface-2','Text on nested surface', false],
|
|
3001
3065
|
]
|
|
3002
3066
|
|
|
3003
3067
|
const checkSet = (vars, context) => {
|
|
@@ -3082,12 +3146,19 @@ Run this immediately after writing a theme file — before production build and
|
|
|
3082
3146
|
lines.push(`**${r.label}** — ${r.context}`)
|
|
3083
3147
|
lines.push(` ${r.fg} on ${r.bg}`)
|
|
3084
3148
|
lines.push(` Ratio: ${r.ratio}:1 · Needed: ${r.threshold}:1 · Level: ${r.level}`)
|
|
3085
|
-
|
|
3149
|
+
|
|
3150
|
+
// Distinguish between body and large-text failures for --ui-muted
|
|
3151
|
+
if (r.label.includes('large text') || r.label.includes('captions')) {
|
|
3152
|
+
lines.push(` Context: large text / captions (3:1 threshold) — fails even the relaxed threshold.`)
|
|
3153
|
+
} else if (r.label.includes('body size') || r.threshold === 4.5) {
|
|
3154
|
+
lines.push(` Context: body text (4.5:1 threshold) — only use this colour for large text (≥18pt / ≥14pt bold) or decorative elements, not for body copy or captions.`)
|
|
3155
|
+
}
|
|
3156
|
+
|
|
3086
3157
|
// Suggest a corrected hex value
|
|
3087
3158
|
if (r.fgHex && r.bgLum !== null) {
|
|
3088
3159
|
const suggested = suggestContrastFix(r.fgHex, r.bgLum, r.threshold)
|
|
3089
3160
|
if (suggested) {
|
|
3090
|
-
lines.push(`
|
|
3161
|
+
lines.push(` Suggested fix: use ${suggested} instead of ${r.fgHex}`)
|
|
3091
3162
|
}
|
|
3092
3163
|
}
|
|
3093
3164
|
lines.push('')
|
|
@@ -3105,8 +3176,9 @@ Run this immediately after writing a theme file — before production build and
|
|
|
3105
3176
|
if (failures.length > 0) {
|
|
3106
3177
|
lines.push('---')
|
|
3107
3178
|
lines.push('**Fix guidance:**')
|
|
3179
|
+
lines.push('• **WCAG AA thresholds:** 4.5:1 for body text (≤18pt regular / ≤14pt bold); 3:1 for large text (≥18pt / ≥14pt bold), UI components, and decorative elements.')
|
|
3108
3180
|
lines.push('• Mid-tone accents on near-white backgrounds often fail 4.5:1. Darken the accent token or use it only for large text/icons (3:1 threshold).')
|
|
3109
|
-
lines.push('• For `--ui-muted` failures: muted text on `--ui-bg` needs
|
|
3181
|
+
lines.push('• For `--ui-muted` "body size" failures: muted text on `--ui-bg` needs 4.5:1 for body copy. If you only use this colour for eyebrows, captions, or large labels (≥18pt), the 3:1 "large text" row is the relevant gate — check whether that row passes.')
|
|
3110
3182
|
lines.push('• Light-theme `--ui-accent` must be distinctly darker than the page background — many default accent hues are too light.')
|
|
3111
3183
|
lines.push('• After fixing, run this tool again before `pulse_build`.')
|
|
3112
3184
|
}
|
|
@@ -3410,6 +3482,7 @@ const PULSE_GUIDE_INDEX = `# Pulse Framework Guide
|
|
|
3410
3482
|
- \`pulse_create_store\` — register a pulse.store.js you wrote with the Write tool. Write the file first, then call this.
|
|
3411
3483
|
- \`pulse_create_action\` — generate a correctly-structured action snippet.
|
|
3412
3484
|
- \`pulse_run_tests\` — run the project test suite (npm test). Use after writing or editing specs.
|
|
3485
|
+
- \`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.
|
|
3413
3486
|
- \`pulse_fetch_page(url)\` — HTTP GET the dev server URL. Use to verify SSR output.
|
|
3414
3487
|
- \`pulse_restart_server\` — stop and restart the dev server.
|
|
3415
3488
|
- \`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.**
|
|
@@ -150,6 +150,30 @@ try {
|
|
|
150
150
|
warnings.push('Inline <style> block detected in view — Pulse\'s CSP (style-src with nonces) will silently block these styles at runtime. Move styles to public/app.css instead.')
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
// Inline <script> blocks in view — blocked by Pulse CSP nonce policy
|
|
154
|
+
// meta.scripts is the correct way to add JS: it receives the server nonce automatically.
|
|
155
|
+
if (/<script[\s>]/i.test(html)) {
|
|
156
|
+
warnings.push('Inline <script> block detected in view — Pulse\'s CSP (script-src with nonces) will block these scripts at runtime. Move JavaScript to a file in public/ and reference it via meta.scripts: [\'/my-script.js\'] in the spec instead.')
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// External image URLs in src attributes — will be blocked by Pulse's default CSP (img-src 'self')
|
|
160
|
+
// unless the domain is explicitly listed in pulse.config.js under csp['img-src'].
|
|
161
|
+
// Flag any http/https src that isn't a data URI or relative path.
|
|
162
|
+
const externalImgSrcs = [...html.matchAll(/\bsrc=["'](https?:\/\/[^"']+)["']/gi)]
|
|
163
|
+
.map(m => m[1])
|
|
164
|
+
if (externalImgSrcs.length) {
|
|
165
|
+
const domains = [...new Set(externalImgSrcs.map(u => {
|
|
166
|
+
try { return new URL(u).origin } catch { return u }
|
|
167
|
+
}))].slice(0, 3)
|
|
168
|
+
warnings.push(
|
|
169
|
+
`External image URL${externalImgSrcs.length > 1 ? 's' : ''} in view (${domains.join(', ')}) — ` +
|
|
170
|
+
`Pulse's default CSP blocks cross-origin images. ` +
|
|
171
|
+
`Either download the images to public/ and use a relative path (/images/foo.jpg), ` +
|
|
172
|
+
`or allow the domain in pulse.config.js:\n` +
|
|
173
|
+
` export default { csp: { 'img-src': ['${domains[0]}'] } }`
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
153
177
|
// Hardcoded hex colours in view HTML — must use var(--ui-*) tokens instead.
|
|
154
178
|
// Matches: style="...color:#hex..." or style="...background:#hex..."
|
|
155
179
|
// Allows: href="#section-id" (anchor links are not colours)
|