@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.
@@ -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. Performance
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
- ### 9. Console errors
77
+ ### 10. Console errors
56
78
 
57
79
  Use `mcp__chrome-devtools__list_console_messages` — report any errors or unexpected warnings.
58
80
 
59
- ### 10. Code review
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
- ### 11. Close extra browser tabs
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
- ### 12. Write verification stamp
72
-
73
- `pulse_review` writes the stamp automatically. As a belt-and-suspenders backup, also run:
93
+ ### 13. Write verification stamp
74
94
 
75
- ```bash
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 ensures the stamp is present even if `pulse_review` was not called with a file path.
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
- ### 13. Report
99
+ ### 14. Report
82
100
 
83
101
  Summarise:
84
102
  - Validation: pass or fail (with errors if any)
85
- - Visual: what was visible in the screenshot
86
- - Lighthouse desktop: scores for all four categories
87
- - Lighthouse mobile: scores for all four categories
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/100, CLS is 0.00, and there are no console errors. Otherwise, fix and run `/verify` again.
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.15.1",
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"
@@ -1 +1 @@
1
- 0.15.1
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)