@invisibleloop/pulse 0.20.1 → 0.20.3
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/pulse-start.md +11 -0
- package/.claude/commands/pulse-stop.md +5 -0
- package/.claude/commands/verify.md +17 -11
- package/docs/.claude/coverage-check.js +153 -84
- package/examples/.claude/coverage-check.js +153 -84
- package/package.json +1 -1
- package/public/.pulse-ui-version +1 -1
- package/scripts/build.js +1 -1
- package/scripts/css-purge.js +65 -3
- package/scripts/css-purge.test.js +16 -0
- package/scripts/strip-server.test.js +11 -1
- package/src/agent/commands/pulse-start.md +6 -4
- package/src/agent/commands/pulse-stop.md +1 -1
- package/src/agent/commands/verify.md +17 -11
- package/src/agent/coverage-check.js +154 -90
- package/src/agent/guide-components.md +2 -2
- package/src/agent/identity.md +2 -2
- package/src/agent/workflow.md +21 -4
- package/src/cli/cli.test.js +72 -0
- package/src/cli/dev.js +23 -3
- package/src/cli/index.js +5 -2
- package/src/cli/scaffold.js +2 -2
- package/src/mcp/mcp.test.js +44 -0
- package/src/mcp/server.js +523 -33
- package/src/mcp/validate-worker.js +3 -0
- package/src/runtime/index.js +9 -0
- package/src/server/index.js +21 -30
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(
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// ---------------------------------------------------------------------------
|
|
9
9
|
|
|
10
10
|
const SERVER_ONLY_KEYS = ['server', 'meta', 'guard', 'serverTimeout', 'contentType', 'render', 'sitemap']
|
|
11
|
-
const SERVER_ONLY_IMPORTS = ['@invisibleloop/pulse/md']
|
|
11
|
+
const SERVER_ONLY_IMPORTS = ['@invisibleloop/pulse/md', '@invisibleloop/pulse']
|
|
12
12
|
|
|
13
13
|
function stripServerOnlyKeys(source) {
|
|
14
14
|
for (const key of SERVER_ONLY_KEYS) source = removeObjectKey(source, key)
|
|
@@ -415,6 +415,16 @@ test('store definition: server fetchers stripped, state and mutations survive',
|
|
|
415
415
|
`export default {\n state: { basket: [] },\n mutations: {\n add: (store, item) => ({ basket: [...store.basket, item] }),\n },\n}`
|
|
416
416
|
)
|
|
417
417
|
|
|
418
|
+
test('package-root import (pushStore) is stripped — the root export is the server itself',
|
|
419
|
+
`import { pushStore } from '@invisibleloop/pulse'\nexport default {\n route: '/staff',\n view: () => '',\n}`,
|
|
420
|
+
`export default {\n route: '/staff',\n view: () => '',\n}`
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
test('subpath imports (/ui) are NOT stripped by the root entry',
|
|
424
|
+
`import { button } from '@invisibleloop/pulse/ui'\nexport default {\n route: '/x',\n view: () => button({ label: 'x' }),\n}`,
|
|
425
|
+
`import { button } from '@invisibleloop/pulse/ui'\nexport default {\n route: '/x',\n view: () => button({ label: 'x' }),\n}`
|
|
426
|
+
)
|
|
427
|
+
|
|
418
428
|
// ---------------------------------------------------------------------------
|
|
419
429
|
|
|
420
430
|
console.log(`\n${pass + fail} tests: ${pass} passed, ${fail} failed\n`)
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
Start the Pulse production server for this project.
|
|
1
|
+
Start the Pulse production server for this project — **locally, on the production port (dev port + 1, normally 3001)** so it can run alongside the dev server on 3000.
|
|
2
2
|
|
|
3
|
-
First ensure a build exists (`public/dist/`). Then
|
|
3
|
+
First ensure a build exists (`public/dist/` — run `pulse build` if not). Then:
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
|
-
pulse start
|
|
6
|
+
pulse start --port 3001
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
If `pulse.config.js` sets a custom `port`, use that value + 1 instead.
|
|
10
|
+
|
|
11
|
+
> **Why the flag:** plain `pulse start` binds the configured port (3000) because in real deployment it is the only server and platforms inject `PORT`. Locally that collides with the dev server and breaks the convention used by `pulse_build`, Lighthouse, and the performance trace — production is always dev port + 1. Never use `--port` in actual deployment.
|
|
@@ -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.
|
|
@@ -4,92 +4,59 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Called by the Stop hook in .claude/settings.json to enforce test coverage.
|
|
6
6
|
* Runs `npm run test:coverage` and blocks the agent if any src/pages/ file
|
|
7
|
-
* has uncovered lines — except
|
|
8
|
-
*
|
|
7
|
+
* has uncovered lines — except code that cannot be unit-tested without mocking
|
|
8
|
+
* real APIs:
|
|
9
|
+
*
|
|
10
|
+
* - server fetchers (`server:` blocks) and store fetchers
|
|
11
|
+
* - action `run:` functions
|
|
12
|
+
* - `guard:`, `submit:`, and `sitemap:` handlers
|
|
13
|
+
* - `meta:` blocks (async meta functions hit real data sources)
|
|
14
|
+
*
|
|
15
|
+
* Also honours c8-style ignore comments, which Node's built-in coverage
|
|
16
|
+
* (--experimental-test-coverage) does NOT support natively. In a block or line
|
|
17
|
+
* comment, write:
|
|
18
|
+
*
|
|
19
|
+
* "c8 ignore next" — exempts the following line
|
|
20
|
+
* "c8 ignore next 3" — exempts the following 3 lines
|
|
21
|
+
* "c8 ignore start" ... "c8 ignore stop" — exempts the range
|
|
22
|
+
* ("coverage-ignore" works as a synonym for "c8 ignore")
|
|
9
23
|
*
|
|
10
24
|
* Outputs a Claude Code hook JSON decision to stdout.
|
|
11
25
|
*/
|
|
12
26
|
|
|
13
27
|
import { execSync } from 'child_process'
|
|
14
28
|
import fs from 'fs'
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
return false
|
|
29
|
-
})('src/pages')
|
|
30
|
-
|
|
31
|
-
if (!hasTests) process.exit(0)
|
|
32
|
-
|
|
33
|
-
let out = ''
|
|
34
|
-
try {
|
|
35
|
-
out = execSync('npm run test:coverage 2>&1', { encoding: 'utf8', timeout: 60000 })
|
|
36
|
-
} catch (e) {
|
|
37
|
-
out = e.stdout || e.message
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// Parse the coverage report — look for lines with uncovered line numbers
|
|
41
|
-
const lines = out.split('\n')
|
|
42
|
-
let inReport = false
|
|
43
|
-
const rawGaps = []
|
|
44
|
-
|
|
45
|
-
for (const line of lines) {
|
|
46
|
-
if (line.includes('start of coverage report')) { inReport = true; continue }
|
|
47
|
-
if (!inReport) continue
|
|
48
|
-
|
|
49
|
-
// Match: # filename.js | line% | branch% | func% | 30-36 50
|
|
50
|
-
const m = line.match(/^[#ℹ]\s+([\w./[\]-]+\.js)\s*\|\s*[\d.]+\s*\|\s*[\d.]+\s*\|\s*[\d.]+\s*\|\s*([^|\n]+)$/)
|
|
51
|
-
if (m) {
|
|
52
|
-
const uncov = m[2].trim()
|
|
53
|
-
if (uncov && uncov !== 'uncovered lines') {
|
|
54
|
-
rawGaps.push({ file: m[1].trim(), uncovered: uncov })
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
}
|
|
29
|
+
import { pathToFileURL } from 'url'
|
|
30
|
+
|
|
31
|
+
// Property blocks that are exempt — all server-side-only code paths that call
|
|
32
|
+
// real external APIs and cannot run in unit tests.
|
|
33
|
+
const EXEMPT_BLOCK_STARTS = [
|
|
34
|
+
/^server\s*:/,
|
|
35
|
+
/^run\s*:/, /^run\s*\(/,
|
|
36
|
+
/^guard\s*:/,
|
|
37
|
+
/^submit\s*:/,
|
|
38
|
+
/^sitemap\s*:/,
|
|
39
|
+
/^meta\s*:/,
|
|
40
|
+
]
|
|
58
41
|
|
|
59
42
|
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
43
|
+
* Build the set of exempt (1-based) line numbers for a source string:
|
|
44
|
+
* lines inside exempt property blocks, plus c8-ignore-annotated lines.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} src
|
|
47
|
+
* @returns {Set<number>}
|
|
63
48
|
*/
|
|
64
|
-
function
|
|
65
|
-
let src
|
|
66
|
-
try { src = fs.readFileSync(filePath, 'utf8') } catch { return false }
|
|
49
|
+
export function computeExemptLines(src) {
|
|
67
50
|
const srcLines = src.split('\n')
|
|
68
|
-
|
|
69
|
-
// Expand "30-36 50" → [30, 31, 32, 33, 34, 35, 36, 50]
|
|
70
|
-
const lineNums = []
|
|
71
|
-
for (const part of uncoveredSpec.split(/\s+/)) {
|
|
72
|
-
const range = part.match(/^(\d+)-(\d+)$/)
|
|
73
|
-
if (range) {
|
|
74
|
-
for (let i = parseInt(range[1]); i <= parseInt(range[2]); i++) lineNums.push(i)
|
|
75
|
-
} else if (/^\d+$/.test(part)) {
|
|
76
|
-
lineNums.push(parseInt(part))
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Build a set of exempt line numbers: lines inside `server:` blocks or action `run:` functions.
|
|
81
|
-
// Strategy: scan the source tracking brace depth relative to the start of each exempt block.
|
|
82
51
|
const exemptLines = new Set()
|
|
52
|
+
|
|
53
|
+
// 1. Exempt property blocks — scan tracking brace/paren depth from block start
|
|
83
54
|
let i = 0
|
|
84
55
|
while (i < srcLines.length) {
|
|
85
56
|
const trimmed = srcLines[i].trimStart()
|
|
86
|
-
|
|
87
|
-
const isExemptStart = /^server\s*:/.test(trimmed) || /^run\s*:/.test(trimmed) || /^run\s*\(/.test(trimmed)
|
|
88
|
-
if (isExemptStart) {
|
|
89
|
-
// Mark lines from here until the block closes (depth reaches 0 after opening)
|
|
57
|
+
if (EXEMPT_BLOCK_STARTS.some(re => re.test(trimmed))) {
|
|
90
58
|
let depth = 0
|
|
91
59
|
let started = false
|
|
92
|
-
const startLine = i
|
|
93
60
|
while (i < srcLines.length) {
|
|
94
61
|
exemptLines.add(i + 1) // 1-based
|
|
95
62
|
for (const ch of srcLines[i]) {
|
|
@@ -103,30 +70,127 @@ function allLinesExempt(filePath, uncoveredSpec) {
|
|
|
103
70
|
i++
|
|
104
71
|
}
|
|
105
72
|
|
|
106
|
-
|
|
73
|
+
// 2. c8-style ignore comments (Node's built-in coverage ignores these, so we
|
|
74
|
+
// honour them here — the marker line itself is always exempt too)
|
|
75
|
+
const IGNORE = /(?:c8 ignore|coverage-ignore)(?:\s+(next|start|stop))?(?:\s+(\d+))?/
|
|
76
|
+
let rangeOpen = false
|
|
77
|
+
for (let n = 0; n < srcLines.length; n++) {
|
|
78
|
+
if (rangeOpen) exemptLines.add(n + 1)
|
|
79
|
+
const m = srcLines[n].match(IGNORE)
|
|
80
|
+
if (!m) continue
|
|
81
|
+
exemptLines.add(n + 1)
|
|
82
|
+
const keyword = m[1]
|
|
83
|
+
if (keyword === 'start') { rangeOpen = true; continue }
|
|
84
|
+
if (keyword === 'stop') { rangeOpen = false; continue }
|
|
85
|
+
// 'next' (or bare marker) — exempt the following N lines (default 1)
|
|
86
|
+
const count = m[2] ? parseInt(m[2], 10) : 1
|
|
87
|
+
for (let k = 1; k <= count; k++) exemptLines.add(n + 1 + k)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return exemptLines
|
|
107
91
|
}
|
|
108
92
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
93
|
+
/**
|
|
94
|
+
* Returns true if all uncovered line numbers fall on exempt lines.
|
|
95
|
+
*
|
|
96
|
+
* @param {string} filePath
|
|
97
|
+
* @param {string} uncoveredSpec - e.g. "30-36 50"
|
|
98
|
+
* @returns {boolean}
|
|
99
|
+
*/
|
|
100
|
+
export function allLinesExempt(filePath, uncoveredSpec) {
|
|
101
|
+
let src
|
|
102
|
+
try { src = fs.readFileSync(filePath, 'utf8') } catch { return false }
|
|
103
|
+
|
|
104
|
+
// Expand "30-36 50" → [30, 31, 32, 33, 34, 35, 36, 50]
|
|
105
|
+
const lineNums = []
|
|
106
|
+
for (const part of uncoveredSpec.split(/\s+/)) {
|
|
107
|
+
const range = part.match(/^(\d+)-(\d+)$/)
|
|
108
|
+
if (range) {
|
|
109
|
+
for (let i = parseInt(range[1]); i <= parseInt(range[2]); i++) lineNums.push(i)
|
|
110
|
+
} else if (/^\d+$/.test(part)) {
|
|
111
|
+
lineNums.push(parseInt(part))
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const exemptLines = computeExemptLines(src)
|
|
116
|
+
return lineNums.every(n => exemptLines.has(n))
|
|
117
|
+
}
|
|
114
118
|
|
|
115
|
-
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Main — only when executed directly (the exports above are unit-tested)
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
116
122
|
|
|
117
|
-
|
|
123
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
124
|
+
main()
|
|
118
125
|
}
|
|
119
126
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
127
|
+
function main() {
|
|
128
|
+
// Agent is ending its turn to wait for the user's answer (approval gate or a
|
|
129
|
+
// mid-flow pause) — let the turn end. The marker is consumed on the user's
|
|
130
|
+
// next prompt, so the gates return in full force afterwards.
|
|
131
|
+
if (fs.existsSync('.pulse-awaiting-approval')) process.exit(0)
|
|
132
|
+
|
|
133
|
+
// No test files → nothing to check
|
|
134
|
+
const hasTests = fs.existsSync('src/pages') && (function scan(d) {
|
|
135
|
+
for (const f of fs.readdirSync(d)) {
|
|
136
|
+
const p = `${d}/${f}`
|
|
137
|
+
if (fs.statSync(p).isDirectory()) { if (scan(p)) return true }
|
|
138
|
+
else if (f.endsWith('.test.js')) return true
|
|
139
|
+
}
|
|
140
|
+
return false
|
|
141
|
+
})('src/pages')
|
|
142
|
+
|
|
143
|
+
if (!hasTests) process.exit(0)
|
|
144
|
+
|
|
145
|
+
let out = ''
|
|
146
|
+
try {
|
|
147
|
+
out = execSync('npm run test:coverage 2>&1', { encoding: 'utf8', timeout: 60000 })
|
|
148
|
+
} catch (e) {
|
|
149
|
+
out = e.stdout || e.message
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Parse the coverage report — look for lines with uncovered line numbers
|
|
153
|
+
const lines = out.split('\n')
|
|
154
|
+
let inReport = false
|
|
155
|
+
const rawGaps = []
|
|
156
|
+
|
|
157
|
+
for (const line of lines) {
|
|
158
|
+
if (line.includes('start of coverage report')) { inReport = true; continue }
|
|
159
|
+
if (!inReport) continue
|
|
160
|
+
|
|
161
|
+
// Match: # filename.js | line% | branch% | func% | 30-36 50
|
|
162
|
+
const m = line.match(/^[#ℹ]\s+([\w./[\]-]+\.js)\s*\|\s*[\d.]+\s*\|\s*[\d.]+\s*\|\s*[\d.]+\s*\|\s*([^|\n]+)$/)
|
|
163
|
+
if (m) {
|
|
164
|
+
const uncov = m[2].trim()
|
|
165
|
+
if (uncov && uncov !== 'uncovered lines') {
|
|
166
|
+
rawGaps.push({ file: m[1].trim(), uncovered: uncov })
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const gaps = []
|
|
172
|
+
for (const { file, uncovered } of rawGaps) {
|
|
173
|
+
// Try to resolve relative to cwd (coverage reports use relative paths)
|
|
174
|
+
const candidates = [file, `src/pages/${file}`, file.replace(/^.*src\/pages\//, 'src/pages/')]
|
|
175
|
+
const resolved = candidates.find(p => fs.existsSync(p))
|
|
176
|
+
|
|
177
|
+
if (resolved && allLinesExempt(resolved, uncovered)) continue // auto-exempt
|
|
178
|
+
|
|
179
|
+
gaps.push(` ${file} — uncovered lines: ${uncovered}`)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (gaps.length) {
|
|
183
|
+
process.stdout.write(JSON.stringify({
|
|
184
|
+
decision: 'block',
|
|
185
|
+
reason: [
|
|
186
|
+
'COVERAGE GAPS detected. Add tests for these uncovered lines before finishing:',
|
|
187
|
+
...gaps,
|
|
188
|
+
'',
|
|
189
|
+
'Run `npm run test:coverage` to see the full report.',
|
|
190
|
+
'Auto-exempt: server fetchers, action run(), guard, submit, sitemap, and meta blocks — these call real APIs and need no tests.',
|
|
191
|
+
'For anything else that genuinely cannot be tested, annotate it: /* c8 ignore next */ (or next N, or start/stop for a range) — this hook honours those comments even though Node\'s built-in coverage does not.',
|
|
192
|
+
'Everything else — view branches, mutations, onViewError, pure helper functions — must be covered.',
|
|
193
|
+
].join('\n'),
|
|
194
|
+
}))
|
|
195
|
+
}
|
|
132
196
|
}
|
|
@@ -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
|
---
|
|
@@ -165,6 +166,20 @@ If you're about to type `class="hero"` or `class="product-card"` in Mode A, stop
|
|
|
165
166
|
**Mode B — Creative override (raw HTML throughout)**
|
|
166
167
|
When the design intent calls for a more expressive, unconventional, or typographically-driven layout that components would constrain, you may build with raw HTML throughout. This is a deliberate design decision, not a shortcut.
|
|
167
168
|
|
|
169
|
+
**When to choose Mode B — use the component, unless:**
|
|
170
|
+
- The layout requires **full-viewport height or edge-to-edge bleed** that the component doesn't support (e.g. `hero()` doesn't fill the viewport)
|
|
171
|
+
- The design uses **custom gradient glows, parallax, or large-scale image fills** that the component would wrap in unwanted structure
|
|
172
|
+
- The vibe is **brutalist, retro, neon, paper, or typographic-editorial** and the structural feel would be actively fought by the component's default padding/spacing/radius
|
|
173
|
+
- The typography is **clamp-scaled display type** (e.g. `clamp(4rem, 10vw, 12rem)`) that breaks inside a component's internal box model
|
|
174
|
+
- The layout is **asymmetric or zone-based** (e.g. 70/30 split, overlapping elements, staggered grid) that no single component supports
|
|
175
|
+
- A **Figma/reference design was provided** that clearly departs from the component's output
|
|
176
|
+
|
|
177
|
+
**When to use the component (not a valid reason for override):**
|
|
178
|
+
- "The component doesn't have a perfect prop for this" — add a utility class or wrapper div instead
|
|
179
|
+
- "I want a slightly different colour or size" — override with CSS tokens
|
|
180
|
+
- "I'm not sure what the component looks like" — check the guide first
|
|
181
|
+
- "It's easier to write raw HTML" — this is never a valid reason
|
|
182
|
+
|
|
168
183
|
Rules for Mode B:
|
|
169
184
|
- **Declare it in a comment at the top of the spec file:** `// component-free — creative override: <reason>`. The review tool detects creative override by reading this comment from the source — without it, `pulse_review` will flag every component pattern as a violation even though you chose Mode B deliberately. Announcing the mode in chat is not enough; the comment must be in the file.
|
|
170
185
|
- Load `/pulse-ui.css` in `meta.styles` — the token system is still required
|
|
@@ -206,9 +221,11 @@ After the Write tool completes, call `pulse_create_page(name)` to register the p
|
|
|
206
221
|
✓ src/pages/my-page.js written and registered.
|
|
207
222
|
```
|
|
208
223
|
|
|
209
|
-
### 3d — Suggest (
|
|
224
|
+
### 3d — Suggest (recommended mid-build checkpoint)
|
|
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.
|
|
210
227
|
|
|
211
|
-
|
|
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.
|
|
212
229
|
|
|
213
230
|
**Rule: never output `✓` before the tool call that confirms the result.**
|
|
214
231
|
|
|
@@ -264,7 +281,7 @@ Do this before every screenshot, console check, or visual inspection. Never debu
|
|
|
264
281
|
|
|
265
282
|
### Phase 5b — Full verification (pass gate)
|
|
266
283
|
|
|
267
|
-
**Run `/verify` now
|
|
284
|
+
**Run `/verify` now** (full mode — not `--quick`). Do not execute these steps manually.
|
|
268
285
|
|
|
269
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.
|
|
270
287
|
|