@invisibleloop/pulse 0.2.11 → 0.2.13
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 +24 -4
- package/docs/.claude/commands/pulse-build.md +7 -0
- package/docs/.claude/commands/pulse-dev.md +7 -0
- package/docs/.claude/commands/pulse-start.md +9 -0
- package/docs/.claude/commands/pulse-stop.md +5 -0
- package/docs/.claude/commands/verify.md +72 -0
- package/docs/.claude/coverage-check.js +127 -0
- package/docs/public/.pulse-ui-version +1 -1
- package/docs/src/pages/components/charts.js +17 -13
- package/package.json +1 -1
- package/public/.pulse-ui-version +1 -1
- package/src/agent/commands/verify.md +72 -0
- package/src/agent/coverage-check.js +63 -2
- package/src/cli/dev.js +12 -6
- package/src/cli/index.js +13 -7
- package/src/cli/scaffold.js +5 -0
- package/src/ui/charts.js +10 -4
|
@@ -32,21 +32,41 @@ Run `mcp__chrome-devtools__lighthouse_audit` on the same route with `{ "strategy
|
|
|
32
32
|
|
|
33
33
|
**Same pass bar: all four categories must be 100.** Report the actual scores. If any score is below 100, fix and restart from step 2.
|
|
34
34
|
|
|
35
|
-
### 7.
|
|
35
|
+
### 7. Performance
|
|
36
|
+
|
|
37
|
+
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.).
|
|
38
|
+
|
|
39
|
+
### 8. Console errors
|
|
36
40
|
|
|
37
41
|
Use `mcp__chrome-devtools__list_console_messages` — report any errors or unexpected warnings.
|
|
38
42
|
|
|
39
|
-
###
|
|
43
|
+
### 9. Code review
|
|
44
|
+
|
|
45
|
+
Check the spec against the checklist in `.claude/pulse-checklist.md`. Work through every item. Fix anything that fails before proceeding to the next step.
|
|
46
|
+
|
|
47
|
+
### 10. Close the browser
|
|
40
48
|
|
|
41
49
|
Use `mcp__chrome-devtools__list_pages` to get all open pages, then call `mcp__chrome-devtools__close_page` for every page ID returned. This closes the entire browser, not just the tab. `pageId` must be a number, not a string.
|
|
42
50
|
|
|
43
|
-
###
|
|
51
|
+
### 11. Write verification stamp
|
|
52
|
+
|
|
53
|
+
Run:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
date +%s > .pulse-verified
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
This records that verify completed successfully. The stop hook checks this stamp — do not skip this step.
|
|
60
|
+
|
|
61
|
+
### 12. Report
|
|
44
62
|
|
|
45
63
|
Summarise:
|
|
46
64
|
- Validation: pass or fail (with errors if any)
|
|
47
65
|
- Visual: what was visible in the screenshot
|
|
48
66
|
- Lighthouse desktop: scores for all four categories
|
|
49
67
|
- Lighthouse mobile: scores for all four categories
|
|
68
|
+
- Performance: LCP and CLS values, any flagged insights
|
|
50
69
|
- Console: any errors
|
|
70
|
+
- Review: pass or issues found and fixed
|
|
51
71
|
|
|
52
|
-
Only confirm the page is good when validation passes, both Lighthouse runs are 100/100/100/100, and there are no console errors. Otherwise, fix and run `/verify` again.
|
|
72
|
+
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.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Verify
|
|
2
|
+
|
|
3
|
+
Run the full verification loop on the current page or the page specified in $ARGUMENTS.
|
|
4
|
+
|
|
5
|
+
## Steps
|
|
6
|
+
|
|
7
|
+
### 1. Identify the target
|
|
8
|
+
|
|
9
|
+
If $ARGUMENTS is provided, use it as the file path or route. Otherwise, identify the most recently edited spec file from context.
|
|
10
|
+
|
|
11
|
+
### 2. Validate the spec
|
|
12
|
+
|
|
13
|
+
Use `pulse_validate` with the spec file content. If validation fails, report every error clearly and stop — do not proceed to screenshot until the spec is valid.
|
|
14
|
+
|
|
15
|
+
### 3. Check the dev server
|
|
16
|
+
|
|
17
|
+
Use `pulse_fetch_page` to confirm the server is responding for the route. If it errors, use `pulse_restart_server` and retry once.
|
|
18
|
+
|
|
19
|
+
### 4. Screenshot
|
|
20
|
+
|
|
21
|
+
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.
|
|
22
|
+
|
|
23
|
+
### 5. Lighthouse — desktop
|
|
24
|
+
|
|
25
|
+
Run `mcp__chrome-devtools__lighthouse_audit` on the route with `{ "strategy": "desktop" }`.
|
|
26
|
+
|
|
27
|
+
**Pass bar: Performance, Accessibility, Best Practices, and SEO must all be 100.** Report the actual scores. If any score is below 100, identify the failing audit(s), fix the issue, and restart from step 2.
|
|
28
|
+
|
|
29
|
+
### 6. Lighthouse — mobile
|
|
30
|
+
|
|
31
|
+
Run `mcp__chrome-devtools__lighthouse_audit` on the same route with `{ "strategy": "mobile" }`.
|
|
32
|
+
|
|
33
|
+
**Same pass bar: all four categories must be 100.** Report the actual scores. If any score is below 100, fix and restart from step 2.
|
|
34
|
+
|
|
35
|
+
### 7. Performance
|
|
36
|
+
|
|
37
|
+
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.).
|
|
38
|
+
|
|
39
|
+
### 8. Console errors
|
|
40
|
+
|
|
41
|
+
Use `mcp__chrome-devtools__list_console_messages` — report any errors or unexpected warnings.
|
|
42
|
+
|
|
43
|
+
### 9. Code review
|
|
44
|
+
|
|
45
|
+
Check the spec against the checklist in `.claude/pulse-checklist.md`. Work through every item. Fix anything that fails before proceeding to the next step.
|
|
46
|
+
|
|
47
|
+
### 10. Close the browser
|
|
48
|
+
|
|
49
|
+
Use `mcp__chrome-devtools__list_pages` to get all open pages, then call `mcp__chrome-devtools__close_page` for every page ID returned. This closes the entire browser, not just the tab. `pageId` must be a number, not a string.
|
|
50
|
+
|
|
51
|
+
### 11. Write verification stamp
|
|
52
|
+
|
|
53
|
+
Run:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
date +%s > .pulse-verified
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
This records that verify completed successfully. The stop hook checks this stamp — do not skip this step.
|
|
60
|
+
|
|
61
|
+
### 12. Report
|
|
62
|
+
|
|
63
|
+
Summarise:
|
|
64
|
+
- Validation: pass or fail (with errors if any)
|
|
65
|
+
- Visual: what was visible in the screenshot
|
|
66
|
+
- Lighthouse desktop: scores for all four categories
|
|
67
|
+
- Lighthouse mobile: scores for all four categories
|
|
68
|
+
- Performance: LCP and CLS values, any flagged insights
|
|
69
|
+
- Console: any errors
|
|
70
|
+
- Review: pass or issues found and fixed
|
|
71
|
+
|
|
72
|
+
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.
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* coverage-check.js
|
|
4
|
+
*
|
|
5
|
+
* Called by the Stop hook in .claude/settings.json to enforce test coverage.
|
|
6
|
+
* Runs `npm run test:coverage` and blocks the agent if any src/pages/ file
|
|
7
|
+
* has uncovered lines — except async action run() and server fetcher functions
|
|
8
|
+
* which cannot be tested without mocking real APIs.
|
|
9
|
+
*
|
|
10
|
+
* Outputs a Claude Code hook JSON decision to stdout.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { execSync } from 'child_process'
|
|
14
|
+
import fs from 'fs'
|
|
15
|
+
|
|
16
|
+
// No test files → nothing to check
|
|
17
|
+
const hasTests = fs.existsSync('src/pages') && (function scan(d) {
|
|
18
|
+
for (const f of fs.readdirSync(d)) {
|
|
19
|
+
const p = `${d}/${f}`
|
|
20
|
+
if (fs.statSync(p).isDirectory()) { if (scan(p)) return true }
|
|
21
|
+
else if (f.endsWith('.test.js')) return true
|
|
22
|
+
}
|
|
23
|
+
return false
|
|
24
|
+
})('src/pages')
|
|
25
|
+
|
|
26
|
+
if (!hasTests) process.exit(0)
|
|
27
|
+
|
|
28
|
+
let out = ''
|
|
29
|
+
try {
|
|
30
|
+
out = execSync('npm run test:coverage 2>&1', { encoding: 'utf8', timeout: 60000 })
|
|
31
|
+
} catch (e) {
|
|
32
|
+
out = e.stdout || e.message
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Parse the coverage report — look for lines with uncovered line numbers
|
|
36
|
+
const lines = out.split('\n')
|
|
37
|
+
let inReport = false
|
|
38
|
+
const rawGaps = []
|
|
39
|
+
|
|
40
|
+
for (const line of lines) {
|
|
41
|
+
if (line.includes('start of coverage report')) { inReport = true; continue }
|
|
42
|
+
if (!inReport) continue
|
|
43
|
+
|
|
44
|
+
// Match: # filename.js | line% | branch% | func% | 30-36 50
|
|
45
|
+
const m = line.match(/^[#ℹ]\s+([\w./[\]-]+\.js)\s*\|\s*[\d.]+\s*\|\s*[\d.]+\s*\|\s*[\d.]+\s*\|\s*([^|\n]+)$/)
|
|
46
|
+
if (m) {
|
|
47
|
+
const uncov = m[2].trim()
|
|
48
|
+
if (uncov && uncov !== 'uncovered lines') {
|
|
49
|
+
rawGaps.push({ file: m[1].trim(), uncovered: uncov })
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Returns true if all uncovered line numbers in the given file fall inside
|
|
56
|
+
* a `server:` property block or an action `run:` function — both of which
|
|
57
|
+
* call real external APIs and are explicitly exempt from coverage requirements.
|
|
58
|
+
*/
|
|
59
|
+
function allLinesExempt(filePath, uncoveredSpec) {
|
|
60
|
+
let src
|
|
61
|
+
try { src = fs.readFileSync(filePath, 'utf8') } catch { return false }
|
|
62
|
+
const srcLines = src.split('\n')
|
|
63
|
+
|
|
64
|
+
// Expand "30-36 50" → [30, 31, 32, 33, 34, 35, 36, 50]
|
|
65
|
+
const lineNums = []
|
|
66
|
+
for (const part of uncoveredSpec.split(/\s+/)) {
|
|
67
|
+
const range = part.match(/^(\d+)-(\d+)$/)
|
|
68
|
+
if (range) {
|
|
69
|
+
for (let i = parseInt(range[1]); i <= parseInt(range[2]); i++) lineNums.push(i)
|
|
70
|
+
} else if (/^\d+$/.test(part)) {
|
|
71
|
+
lineNums.push(parseInt(part))
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Build a set of exempt line numbers: lines inside `server:` blocks or action `run:` functions.
|
|
76
|
+
// Strategy: scan the source tracking brace depth relative to the start of each exempt block.
|
|
77
|
+
const exemptLines = new Set()
|
|
78
|
+
let i = 0
|
|
79
|
+
while (i < srcLines.length) {
|
|
80
|
+
const trimmed = srcLines[i].trimStart()
|
|
81
|
+
// Detect start of `server:` property or action `run:` function
|
|
82
|
+
const isExemptStart = /^server\s*:/.test(trimmed) || /^run\s*:/.test(trimmed) || /^run\s*\(/.test(trimmed)
|
|
83
|
+
if (isExemptStart) {
|
|
84
|
+
// Mark lines from here until the block closes (depth reaches 0 after opening)
|
|
85
|
+
let depth = 0
|
|
86
|
+
let started = false
|
|
87
|
+
const startLine = i
|
|
88
|
+
while (i < srcLines.length) {
|
|
89
|
+
exemptLines.add(i + 1) // 1-based
|
|
90
|
+
for (const ch of srcLines[i]) {
|
|
91
|
+
if (ch === '{' || ch === '(') { depth++; started = true }
|
|
92
|
+
if (ch === '}' || ch === ')') { depth-- }
|
|
93
|
+
}
|
|
94
|
+
if (started && depth <= 0) break
|
|
95
|
+
i++
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
i++
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return lineNums.every(n => exemptLines.has(n))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const gaps = []
|
|
105
|
+
for (const { file, uncovered } of rawGaps) {
|
|
106
|
+
// Try to resolve relative to cwd (coverage reports use relative paths)
|
|
107
|
+
const candidates = [file, `src/pages/${file}`, file.replace(/^.*src\/pages\//, 'src/pages/')]
|
|
108
|
+
const resolved = candidates.find(p => fs.existsSync(p))
|
|
109
|
+
|
|
110
|
+
if (resolved && allLinesExempt(resolved, uncovered)) continue // auto-exempt
|
|
111
|
+
|
|
112
|
+
gaps.push(` ${file} — uncovered lines: ${uncovered}`)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (gaps.length) {
|
|
116
|
+
process.stdout.write(JSON.stringify({
|
|
117
|
+
decision: 'block',
|
|
118
|
+
reason: [
|
|
119
|
+
'COVERAGE GAPS detected. Add tests for these uncovered lines before finishing:',
|
|
120
|
+
...gaps,
|
|
121
|
+
'',
|
|
122
|
+
'Run `npm run test:coverage` to see the full report.',
|
|
123
|
+
'Exempt from this rule: async action run() functions and server fetchers that call real APIs.',
|
|
124
|
+
'Everything else — view branches, mutations, onViewError, pure helper functions — must be covered.',
|
|
125
|
+
].join('\n'),
|
|
126
|
+
}))
|
|
127
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
0.2.
|
|
1
|
+
0.2.11
|
|
@@ -120,7 +120,7 @@ export default {
|
|
|
120
120
|
)}
|
|
121
121
|
|
|
122
122
|
<h2 class="doc-h2" id="donut">Donut chart</h2>
|
|
123
|
-
<p>Ring chart with multiple segments. Each item can override its colour. Pass <code>label</code> and <code>sublabel</code> for a centred annotation.</p>
|
|
123
|
+
<p>Ring chart with multiple segments. Each item can override its colour. Pass <code>label</code> and <code>sublabel</code> for a centred annotation. Add <code>fluid: true</code> to expand to the parent container width.</p>
|
|
124
124
|
${demo(
|
|
125
125
|
`<div style="display:flex;justify-content:center">` +
|
|
126
126
|
donutChart({
|
|
@@ -171,7 +171,7 @@ export default {
|
|
|
171
171
|
)}
|
|
172
172
|
|
|
173
173
|
<h2 class="doc-h2" id="sparkline">Sparkline</h2>
|
|
174
|
-
<p>Minimal inline trend line — pass a plain array of numbers. Designed to sit alongside <code>stat()</code> tiles or inside table cells.</p>
|
|
174
|
+
<p>Minimal inline trend line — pass a plain array of numbers. Designed to sit alongside <code>stat()</code> tiles or inside table cells. Add <code>fluid: true</code> to expand to the parent container width.</p>
|
|
175
175
|
${demo(
|
|
176
176
|
`<div style="display:flex;gap:.75rem;align-items:center;flex-wrap:wrap">` +
|
|
177
177
|
sparkline({ data: [12,18,14,22,19,28,24,31], color: 'accent', area: true }) +
|
|
@@ -228,14 +228,15 @@ sparkline({ data: [31,24,28,19,22,14,18,12], color: 'error', area: true })`
|
|
|
228
228
|
${table(
|
|
229
229
|
['Prop', 'Type', 'Default', 'Description'],
|
|
230
230
|
[
|
|
231
|
-
['<code>data</code>', 'array', '—',
|
|
232
|
-
['<code>height</code>', 'number', '220',
|
|
231
|
+
['<code>data</code>', 'array', '—', '<code>{ label, value }[]</code>'],
|
|
232
|
+
['<code>height</code>', 'number', '220', 'SVG height in px'],
|
|
233
233
|
['<code>color</code>', 'string', "'accent'", 'accent · success · warning · error · blue · muted'],
|
|
234
|
-
['<code>showValues</code>', 'boolean', 'false',
|
|
235
|
-
['<code>showGrid</code>', 'boolean', 'true',
|
|
236
|
-
['<code>gap</code>', 'number', '0.25',
|
|
234
|
+
['<code>showValues</code>', 'boolean', 'false', 'Value labels above each bar'],
|
|
235
|
+
['<code>showGrid</code>', 'boolean', 'true', 'Horizontal grid lines'],
|
|
236
|
+
['<code>gap</code>', 'number', '0.25', 'Gap between bars as fraction 0–0.9'],
|
|
237
237
|
]
|
|
238
238
|
)}
|
|
239
|
+
<p class="doc-note u-mt-2">Always renders at full parent width (<code>width="100%"</code>).</p>
|
|
239
240
|
|
|
240
241
|
<h3 class="doc-h3" style="margin-top:2rem">lineChart()</h3>
|
|
241
242
|
${table(
|
|
@@ -249,16 +250,18 @@ sparkline({ data: [31,24,28,19,22,14,18,12], color: 'error', area: true })`
|
|
|
249
250
|
['<code>showGrid</code>', 'boolean', 'true', 'Horizontal grid lines'],
|
|
250
251
|
]
|
|
251
252
|
)}
|
|
253
|
+
<p class="doc-note u-mt-2">Always renders at full parent width (<code>width="100%"</code>).</p>
|
|
252
254
|
|
|
253
255
|
<h3 class="doc-h3" style="margin-top:2rem">donutChart()</h3>
|
|
254
256
|
${table(
|
|
255
257
|
['Prop', 'Type', 'Default', 'Description'],
|
|
256
258
|
[
|
|
257
|
-
['<code>data</code>', 'array',
|
|
258
|
-
['<code>size</code>', 'number',
|
|
259
|
-
['<code>thickness</code>', 'number',
|
|
260
|
-
['<code>label</code>', 'string',
|
|
261
|
-
['<code>sublabel</code>', 'string',
|
|
259
|
+
['<code>data</code>', 'array', '—', '<code>{ label, value, color? }[]</code> — color per segment'],
|
|
260
|
+
['<code>size</code>', 'number', '200', 'Diameter in px'],
|
|
261
|
+
['<code>thickness</code>', 'number', '40', 'Ring thickness in px'],
|
|
262
|
+
['<code>label</code>', 'string', '—', 'Large centre text'],
|
|
263
|
+
['<code>sublabel</code>', 'string', '—', 'Smaller text below centre label'],
|
|
264
|
+
['<code>fluid</code>', 'boolean', 'false', 'Expand to parent width, preserving aspect ratio'],
|
|
262
265
|
]
|
|
263
266
|
)}
|
|
264
267
|
|
|
@@ -267,10 +270,11 @@ sparkline({ data: [31,24,28,19,22,14,18,12], color: 'error', area: true })`
|
|
|
267
270
|
['Prop', 'Type', 'Default', 'Description'],
|
|
268
271
|
[
|
|
269
272
|
['<code>data</code>', 'number[]', '—', 'Plain array of numbers'],
|
|
270
|
-
['<code>width</code>', 'number', '80', 'SVG width in px'],
|
|
273
|
+
['<code>width</code>', 'number', '80', 'SVG width in px (ignored when fluid: true)'],
|
|
271
274
|
['<code>height</code>', 'number', '32', 'SVG height in px'],
|
|
272
275
|
['<code>color</code>', 'string', "'accent'", 'accent · success · warning · error · blue · muted'],
|
|
273
276
|
['<code>area</code>', 'boolean', 'false', 'Fill area under the line'],
|
|
277
|
+
['<code>fluid</code>', 'boolean', 'false', 'Expand to parent width, preserving aspect ratio'],
|
|
274
278
|
]
|
|
275
279
|
)}
|
|
276
280
|
`,
|
package/package.json
CHANGED
package/public/.pulse-ui-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.2.
|
|
1
|
+
0.2.13
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Verify
|
|
2
|
+
|
|
3
|
+
Run the full verification loop on the current page or the page specified in $ARGUMENTS.
|
|
4
|
+
|
|
5
|
+
## Steps
|
|
6
|
+
|
|
7
|
+
### 1. Identify the target
|
|
8
|
+
|
|
9
|
+
If $ARGUMENTS is provided, use it as the file path or route. Otherwise, identify the most recently edited spec file from context.
|
|
10
|
+
|
|
11
|
+
### 2. Validate the spec
|
|
12
|
+
|
|
13
|
+
Use `pulse_validate` with the spec file content. If validation fails, report every error clearly and stop — do not proceed to screenshot until the spec is valid.
|
|
14
|
+
|
|
15
|
+
### 3. Check the dev server
|
|
16
|
+
|
|
17
|
+
Use `pulse_fetch_page` to confirm the server is responding for the route. If it errors, use `pulse_restart_server` and retry once.
|
|
18
|
+
|
|
19
|
+
### 4. Screenshot
|
|
20
|
+
|
|
21
|
+
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.
|
|
22
|
+
|
|
23
|
+
### 5. Lighthouse — desktop
|
|
24
|
+
|
|
25
|
+
Run `mcp__chrome-devtools__lighthouse_audit` on the route with `{ "strategy": "desktop" }`.
|
|
26
|
+
|
|
27
|
+
**Pass bar: Performance, Accessibility, Best Practices, and SEO must all be 100.** Report the actual scores. If any score is below 100, identify the failing audit(s), fix the issue, and restart from step 2.
|
|
28
|
+
|
|
29
|
+
### 6. Lighthouse — mobile
|
|
30
|
+
|
|
31
|
+
Run `mcp__chrome-devtools__lighthouse_audit` on the same route with `{ "strategy": "mobile" }`.
|
|
32
|
+
|
|
33
|
+
**Same pass bar: all four categories must be 100.** Report the actual scores. If any score is below 100, fix and restart from step 2.
|
|
34
|
+
|
|
35
|
+
### 7. Performance
|
|
36
|
+
|
|
37
|
+
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.).
|
|
38
|
+
|
|
39
|
+
### 8. Console errors
|
|
40
|
+
|
|
41
|
+
Use `mcp__chrome-devtools__list_console_messages` — report any errors or unexpected warnings.
|
|
42
|
+
|
|
43
|
+
### 9. Code review
|
|
44
|
+
|
|
45
|
+
Check the spec against the checklist in `.claude/pulse-checklist.md`. Work through every item. Fix anything that fails before proceeding to the next step.
|
|
46
|
+
|
|
47
|
+
### 10. Close the browser
|
|
48
|
+
|
|
49
|
+
Use `mcp__chrome-devtools__list_pages` to get all open pages, then call `mcp__chrome-devtools__close_page` for every page ID returned. This closes the entire browser, not just the tab. `pageId` must be a number, not a string.
|
|
50
|
+
|
|
51
|
+
### 11. Write verification stamp
|
|
52
|
+
|
|
53
|
+
Run:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
date +%s > .pulse-verified
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
This records that verify completed successfully. The stop hook checks this stamp — do not skip this step.
|
|
60
|
+
|
|
61
|
+
### 12. Report
|
|
62
|
+
|
|
63
|
+
Summarise:
|
|
64
|
+
- Validation: pass or fail (with errors if any)
|
|
65
|
+
- Visual: what was visible in the screenshot
|
|
66
|
+
- Lighthouse desktop: scores for all four categories
|
|
67
|
+
- Lighthouse mobile: scores for all four categories
|
|
68
|
+
- Performance: LCP and CLS values, any flagged insights
|
|
69
|
+
- Console: any errors
|
|
70
|
+
- Review: pass or issues found and fixed
|
|
71
|
+
|
|
72
|
+
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.
|
|
@@ -35,7 +35,7 @@ try {
|
|
|
35
35
|
// Parse the coverage report — look for lines with uncovered line numbers
|
|
36
36
|
const lines = out.split('\n')
|
|
37
37
|
let inReport = false
|
|
38
|
-
const
|
|
38
|
+
const rawGaps = []
|
|
39
39
|
|
|
40
40
|
for (const line of lines) {
|
|
41
41
|
if (line.includes('start of coverage report')) { inReport = true; continue }
|
|
@@ -46,11 +46,72 @@ for (const line of lines) {
|
|
|
46
46
|
if (m) {
|
|
47
47
|
const uncov = m[2].trim()
|
|
48
48
|
if (uncov && uncov !== 'uncovered lines') {
|
|
49
|
-
|
|
49
|
+
rawGaps.push({ file: m[1].trim(), uncovered: uncov })
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Returns true if all uncovered line numbers in the given file fall inside
|
|
56
|
+
* a `server:` property block or an action `run:` function — both of which
|
|
57
|
+
* call real external APIs and are explicitly exempt from coverage requirements.
|
|
58
|
+
*/
|
|
59
|
+
function allLinesExempt(filePath, uncoveredSpec) {
|
|
60
|
+
let src
|
|
61
|
+
try { src = fs.readFileSync(filePath, 'utf8') } catch { return false }
|
|
62
|
+
const srcLines = src.split('\n')
|
|
63
|
+
|
|
64
|
+
// Expand "30-36 50" → [30, 31, 32, 33, 34, 35, 36, 50]
|
|
65
|
+
const lineNums = []
|
|
66
|
+
for (const part of uncoveredSpec.split(/\s+/)) {
|
|
67
|
+
const range = part.match(/^(\d+)-(\d+)$/)
|
|
68
|
+
if (range) {
|
|
69
|
+
for (let i = parseInt(range[1]); i <= parseInt(range[2]); i++) lineNums.push(i)
|
|
70
|
+
} else if (/^\d+$/.test(part)) {
|
|
71
|
+
lineNums.push(parseInt(part))
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Build a set of exempt line numbers: lines inside `server:` blocks or action `run:` functions.
|
|
76
|
+
// Strategy: scan the source tracking brace depth relative to the start of each exempt block.
|
|
77
|
+
const exemptLines = new Set()
|
|
78
|
+
let i = 0
|
|
79
|
+
while (i < srcLines.length) {
|
|
80
|
+
const trimmed = srcLines[i].trimStart()
|
|
81
|
+
// Detect start of `server:` property or action `run:` function
|
|
82
|
+
const isExemptStart = /^server\s*:/.test(trimmed) || /^run\s*:/.test(trimmed) || /^run\s*\(/.test(trimmed)
|
|
83
|
+
if (isExemptStart) {
|
|
84
|
+
// Mark lines from here until the block closes (depth reaches 0 after opening)
|
|
85
|
+
let depth = 0
|
|
86
|
+
let started = false
|
|
87
|
+
const startLine = i
|
|
88
|
+
while (i < srcLines.length) {
|
|
89
|
+
exemptLines.add(i + 1) // 1-based
|
|
90
|
+
for (const ch of srcLines[i]) {
|
|
91
|
+
if (ch === '{' || ch === '(') { depth++; started = true }
|
|
92
|
+
if (ch === '}' || ch === ')') { depth-- }
|
|
93
|
+
}
|
|
94
|
+
if (started && depth <= 0) break
|
|
95
|
+
i++
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
i++
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return lineNums.every(n => exemptLines.has(n))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const gaps = []
|
|
105
|
+
for (const { file, uncovered } of rawGaps) {
|
|
106
|
+
// Try to resolve relative to cwd (coverage reports use relative paths)
|
|
107
|
+
const candidates = [file, `src/pages/${file}`, file.replace(/^.*src\/pages\//, 'src/pages/')]
|
|
108
|
+
const resolved = candidates.find(p => fs.existsSync(p))
|
|
109
|
+
|
|
110
|
+
if (resolved && allLinesExempt(resolved, uncovered)) continue // auto-exempt
|
|
111
|
+
|
|
112
|
+
gaps.push(` ${file} — uncovered lines: ${uncovered}`)
|
|
113
|
+
}
|
|
114
|
+
|
|
54
115
|
if (gaps.length) {
|
|
55
116
|
process.stdout.write(JSON.stringify({
|
|
56
117
|
decision: 'block',
|
package/src/cli/dev.js
CHANGED
|
@@ -63,12 +63,18 @@ const PUBLIC_DIR = path.join(ROOT, 'public')
|
|
|
63
63
|
}
|
|
64
64
|
fs.writeFileSync(stampPath, pkgVersion, 'utf8')
|
|
65
65
|
|
|
66
|
-
// Sync agent
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
66
|
+
// Sync agent files into .claude/
|
|
67
|
+
const agentFiles = [
|
|
68
|
+
['../agent/checklist.md', 'pulse-checklist.md'],
|
|
69
|
+
['../agent/coverage-check.js', 'coverage-check.js'],
|
|
70
|
+
]
|
|
71
|
+
for (const [rel, dst] of agentFiles) {
|
|
72
|
+
const src = new URL(rel, import.meta.url).pathname
|
|
73
|
+
if (fs.existsSync(src)) {
|
|
74
|
+
const dstPath = path.join(ROOT, '.claude', dst)
|
|
75
|
+
fs.mkdirSync(path.dirname(dstPath), { recursive: true })
|
|
76
|
+
fs.copyFileSync(src, dstPath)
|
|
77
|
+
}
|
|
72
78
|
}
|
|
73
79
|
|
|
74
80
|
// Sync slash commands into .claude/commands/ so /pulse-dev etc. always exist
|
package/src/cli/index.js
CHANGED
|
@@ -292,13 +292,19 @@ async function runUpdate(root) {
|
|
|
292
292
|
updated.push(`public/${asset}`)
|
|
293
293
|
}
|
|
294
294
|
|
|
295
|
-
// Sync agent
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
295
|
+
// Sync agent files into .claude/
|
|
296
|
+
const agentFiles = [
|
|
297
|
+
['../agent/checklist.md', 'pulse-checklist.md'],
|
|
298
|
+
['../agent/coverage-check.js', 'coverage-check.js'],
|
|
299
|
+
]
|
|
300
|
+
for (const [rel, dst] of agentFiles) {
|
|
301
|
+
const src = new URL(rel, import.meta.url).pathname
|
|
302
|
+
if (fs.existsSync(src)) {
|
|
303
|
+
const dstPath = path.join(root, '.claude', dst)
|
|
304
|
+
fs.mkdirSync(path.dirname(dstPath), { recursive: true })
|
|
305
|
+
fs.copyFileSync(src, dstPath)
|
|
306
|
+
updated.push(`.claude/${dst}`)
|
|
307
|
+
}
|
|
302
308
|
}
|
|
303
309
|
|
|
304
310
|
// Sync slash commands into .claude/commands/
|
package/src/cli/scaffold.js
CHANGED
|
@@ -168,6 +168,11 @@ ${port !== 3000 ? ` port: ${port},\n` : ''}}
|
|
|
168
168
|
type: 'command',
|
|
169
169
|
command: `node .claude/coverage-check.js`,
|
|
170
170
|
statusMessage: 'Checking test coverage...',
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
type: 'command',
|
|
174
|
+
command: `node -e "const fs=require('fs');const{execSync}=require('child_process');let specs=[];try{const out=execSync('git status --porcelain',{encoding:'utf8'});out.split('\\n').forEach(l=>{const f=l.slice(3).trim().split(' -> ').pop();if(f&&f.match(/^src\\/pages\\/.+\\.js$/)&&!f.endsWith('.test.js'))specs.push(f);});}catch{if(fs.existsSync('src/pages'))for(const f of fs.readdirSync('src/pages')){if(f.endsWith('.js')&&!f.endsWith('.test.js'))specs.push('src/pages/'+f);}}if(!specs.length)process.exit(0);const stamp='.pulse-verified';let stampMtime=0;try{stampMtime=fs.statSync(stamp).mtimeMs;}catch{}const stale=specs.filter(f=>{try{return fs.statSync(f).mtimeMs>stampMtime;}catch{return true;}});if(stale.length){process.stdout.write(JSON.stringify({decision:'block',reason:'VERIFY REQUIRED: These pages have not been verified since last edit:\\n'+stale.map(f=>' '+f).join('\\n')+'\\n\\nRun /verify for each changed page. /verify runs Lighthouse (desktop + mobile), performance trace, console check, and code review — then writes the stamp that clears this check.'}));}"`,
|
|
175
|
+
statusMessage: 'Checking verification stamp...',
|
|
171
176
|
}
|
|
172
177
|
]
|
|
173
178
|
}
|
package/src/ui/charts.js
CHANGED
|
@@ -117,7 +117,7 @@ export function barChart({
|
|
|
117
117
|
const ariaLabel = `Bar chart: ${data.map(d => `${d.label ?? ''} ${d.value}`).join(', ')}`
|
|
118
118
|
const clsAttr = cls ? ` class="${e(cls)}"` : ''
|
|
119
119
|
|
|
120
|
-
return `<svg${clsAttr} viewBox="0 0 ${IW} ${height}"
|
|
120
|
+
return `<svg${clsAttr} viewBox="0 0 ${IW} ${height}" preserveAspectRatio="none" height="${height}" style="display:block;width:100%" role="img" aria-label="${e(ariaLabel)}">${grid}${baseline}${bars}</svg>`
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
// ─── Line chart ───────────────────────────────────────────────────────────────
|
|
@@ -193,7 +193,7 @@ export function lineChart({
|
|
|
193
193
|
const ariaLabel = `Line chart: ${data.map(d => `${d.label ?? ''} ${d.value}`).join(', ')}`
|
|
194
194
|
const clsAttr = cls ? ` class="${e(cls)}"` : ''
|
|
195
195
|
|
|
196
|
-
return `<svg${clsAttr} viewBox="0 0 ${IW} ${height}"
|
|
196
|
+
return `<svg${clsAttr} viewBox="0 0 ${IW} ${height}" preserveAspectRatio="none" height="${height}" style="display:block;width:100%" role="img" aria-label="${e(ariaLabel)}">${grid}${areaPath}${line}${dots}${xlabels}</svg>`
|
|
197
197
|
}
|
|
198
198
|
|
|
199
199
|
// ─── Donut chart ──────────────────────────────────────────────────────────────
|
|
@@ -207,6 +207,7 @@ export function lineChart({
|
|
|
207
207
|
* @param {number} opts.thickness - Ring thickness in px (default: 40)
|
|
208
208
|
* @param {string} opts.label - Large text in the centre
|
|
209
209
|
* @param {string} opts.sublabel - Smaller text below the centre label
|
|
210
|
+
* @param {boolean} opts.fluid - Expand to parent width (preserves aspect ratio)
|
|
210
211
|
* @param {string} opts.class
|
|
211
212
|
*/
|
|
212
213
|
export function donutChart({
|
|
@@ -215,6 +216,7 @@ export function donutChart({
|
|
|
215
216
|
thickness = 40,
|
|
216
217
|
label = '',
|
|
217
218
|
sublabel = '',
|
|
219
|
+
fluid = false,
|
|
218
220
|
class: cls = '',
|
|
219
221
|
} = {}) {
|
|
220
222
|
if (!data.length) return ''
|
|
@@ -267,8 +269,9 @@ export function donutChart({
|
|
|
267
269
|
|
|
268
270
|
const ariaLabel = `Donut chart: ${data.map(d => `${d.label ?? ''} ${d.value}`).join(', ')}`
|
|
269
271
|
const clsAttr = cls ? ` class="${e(cls)}"` : ''
|
|
272
|
+
const dims = fluid ? `style="display:block;width:100%"` : `width="${size}" height="${size}"`
|
|
270
273
|
|
|
271
|
-
return `<svg${clsAttr} viewBox="0 0 ${size} ${size}"
|
|
274
|
+
return `<svg${clsAttr} viewBox="0 0 ${size} ${size}" ${dims} role="img" aria-label="${e(ariaLabel)}">${segments}${labelEl}${subEl}</svg>`
|
|
272
275
|
}
|
|
273
276
|
|
|
274
277
|
// ─── Sparkline ────────────────────────────────────────────────────────────────
|
|
@@ -283,6 +286,7 @@ export function donutChart({
|
|
|
283
286
|
* @param {number} opts.height - SVG height in px (default: 32)
|
|
284
287
|
* @param {'accent'|'success'|'warning'|'error'|'blue'|'muted'} opts.color
|
|
285
288
|
* @param {boolean} opts.area - Fill area under the line
|
|
289
|
+
* @param {boolean} opts.fluid - Expand to parent width (preserves aspect ratio)
|
|
286
290
|
* @param {string} opts.class
|
|
287
291
|
*/
|
|
288
292
|
export function sparkline({
|
|
@@ -291,6 +295,7 @@ export function sparkline({
|
|
|
291
295
|
height = 32,
|
|
292
296
|
color = 'accent',
|
|
293
297
|
area = false,
|
|
298
|
+
fluid = false,
|
|
294
299
|
class: cls = '',
|
|
295
300
|
} = {}) {
|
|
296
301
|
if (data.length < 2) return ''
|
|
@@ -316,6 +321,7 @@ export function sparkline({
|
|
|
316
321
|
}
|
|
317
322
|
|
|
318
323
|
const clsAttr = cls ? ` class="${e(cls)}"` : ''
|
|
324
|
+
const dims = fluid ? `preserveAspectRatio="none" height="${height}" style="display:block;width:100%"` : `width="${width}" height="${height}"`
|
|
319
325
|
|
|
320
|
-
return `<svg${clsAttr} viewBox="0 0 ${width} ${height}"
|
|
326
|
+
return `<svg${clsAttr} viewBox="0 0 ${width} ${height}" ${dims} aria-hidden="true">${areaPath}<polyline points="${points}" fill="none" stroke="${c}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`
|
|
321
327
|
}
|