@invisibleloop/pulse 0.15.1 → 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 +34 -16
- package/package.json +2 -2
- package/public/.pulse-ui-version +1 -1
- package/src/agent/guide-routing.md +91 -0
- package/src/mcp/mcp.test.js +108 -0
- package/src/mcp/server.js +22 -1
- package/src/mcp/validate-worker.js +18 -0
|
@@ -48,45 +48,63 @@ The browser should still be on `http://localhost:3001/` from step 6. Run `mcp__c
|
|
|
48
48
|
|
|
49
49
|
After both Lighthouse runs pass, call `pulse_restart_server` to return to the dev server.
|
|
50
50
|
|
|
51
|
-
### 8.
|
|
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
|
|
66
|
+
|
|
67
|
+
Fix any issues, then reset to desktop before continuing:
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
mcp__chrome-devtools__emulate viewport: "1440x900x1"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### 9. Performance
|
|
52
74
|
|
|
53
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.).
|
|
54
76
|
|
|
55
|
-
###
|
|
77
|
+
### 10. Console errors
|
|
56
78
|
|
|
57
79
|
Use `mcp__chrome-devtools__list_console_messages` — report any errors or unexpected warnings.
|
|
58
80
|
|
|
59
|
-
###
|
|
81
|
+
### 11. Code review
|
|
60
82
|
|
|
61
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.
|
|
62
84
|
|
|
63
85
|
Work through every item the review returns. Fix anything that fails before proceeding.
|
|
64
86
|
|
|
65
|
-
###
|
|
87
|
+
### 12. Close extra browser tabs
|
|
66
88
|
|
|
67
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.
|
|
68
90
|
|
|
69
91
|
If there is only one page open, skip this step — there is nothing to close.
|
|
70
92
|
|
|
71
|
-
###
|
|
72
|
-
|
|
73
|
-
`pulse_review` writes the stamp automatically. As a belt-and-suspenders backup, also run:
|
|
93
|
+
### 13. Write verification stamp
|
|
74
94
|
|
|
75
|
-
|
|
76
|
-
date +%s > .pulse-verified
|
|
77
|
-
```
|
|
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.
|
|
78
96
|
|
|
79
|
-
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`.
|
|
80
98
|
|
|
81
|
-
###
|
|
99
|
+
### 14. Report
|
|
82
100
|
|
|
83
101
|
Summarise:
|
|
84
102
|
- Validation: pass or fail (with errors if any)
|
|
85
|
-
- Visual:
|
|
86
|
-
- Lighthouse desktop: scores for
|
|
87
|
-
- 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
|
|
88
106
|
- Performance: LCP and CLS values, any flagged insights
|
|
89
107
|
- Console: any errors
|
|
90
108
|
- Review: pass or issues found and fixed
|
|
91
109
|
|
|
92
|
-
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
|
|
@@ -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.
|
|
@@ -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
|
@@ -1057,12 +1057,33 @@ ${(() => {
|
|
|
1057
1057
|
checks.push('✓ No obvious hex colours in view')
|
|
1058
1058
|
}
|
|
1059
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
|
+
|
|
1060
1081
|
// Creative override detection — check if spec declares component-free mode
|
|
1061
1082
|
const creativeOverride = /component.free|creative\s+override|raw\s+HTML\s+throughout/i.test(source)
|
|
1062
1083
|
checks.push(creativeOverride
|
|
1063
1084
|
? '⚡ **Creative override declared** — component pattern checks are advisory only. Lighthouse 100/100/100/100 is the pass bar.'
|
|
1064
1085
|
: '○ No creative override declared — component checks apply')
|
|
1065
|
-
|
|
1086
|
+
|
|
1066
1087
|
return checks.join('\n')
|
|
1067
1088
|
})()}
|
|
1068
1089
|
|
|
@@ -156,6 +156,24 @@ try {
|
|
|
156
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
157
|
}
|
|
158
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
|
+
|
|
159
177
|
// Hardcoded hex colours in view HTML — must use var(--ui-*) tokens instead.
|
|
160
178
|
// Matches: style="...color:#hex..." or style="...background:#hex..."
|
|
161
179
|
// Allows: href="#section-id" (anchor links are not colours)
|