@invisibleloop/pulse 0.2.12 → 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.
@@ -0,0 +1,7 @@
1
+ Build this Pulse project for production.
2
+
3
+ ```bash
4
+ pulse build
5
+ ```
6
+
7
+ This bundles all pages into `public/dist/`. When complete, confirm the build succeeded and list the generated files.
@@ -0,0 +1,7 @@
1
+ Start (or restart) the Pulse dev server for this project.
2
+
3
+ ```bash
4
+ pulse stop; pulse dev
5
+ ```
6
+
7
+ The server port is read from `pulse.config.js` (defaults to 3000).
@@ -0,0 +1,9 @@
1
+ Start the Pulse production server for this project.
2
+
3
+ First ensure a build exists (`public/dist/`). Then run:
4
+
5
+ ```bash
6
+ pulse start
7
+ ```
8
+
9
+ The production server uses the port from `pulse.config.js` (defaults to 3000).
@@ -0,0 +1,5 @@
1
+ Stop the Pulse dev server for this project.
2
+
3
+ ```bash
4
+ pulse stop
5
+ ```
@@ -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.5
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', '—', '<code>{ label, value }[]</code>'],
232
- ['<code>height</code>', 'number', '220', 'SVG height in px'],
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', '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'],
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', '—', '<code>{ label, value, color? }[]</code> — color per segment'],
258
- ['<code>size</code>', 'number', '200', 'Diameter in px'],
259
- ['<code>thickness</code>', 'number', '40', 'Ring thickness in px'],
260
- ['<code>label</code>', 'string', '—', 'Large centre text'],
261
- ['<code>sublabel</code>', 'string', '—', 'Smaller text below centre label'],
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invisibleloop/pulse",
3
- "version": "0.2.12",
3
+ "version": "0.2.13",
4
4
  "type": "module",
5
5
  "description": "AI-first frontend framework. The spec is the source of truth.",
6
6
  "license": "MIT",
@@ -1 +1 @@
1
- 0.2.12
1
+ 0.2.13
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}" width="100%" height="${height}" role="img" aria-label="${e(ariaLabel)}">${grid}${baseline}${bars}</svg>`
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}" width="100%" height="${height}" role="img" aria-label="${e(ariaLabel)}">${grid}${areaPath}${line}${dots}${xlabels}</svg>`
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}" width="${size}" height="${size}" role="img" aria-label="${e(ariaLabel)}">${segments}${labelEl}${subEl}</svg>`
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}" width="${width}" height="${height}" aria-hidden="true">${areaPath}<polyline points="${points}" fill="none" stroke="${c}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`
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
  }