@invisibleloop/pulse 0.2.12 → 0.2.14
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/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/server.js +8 -0
- package/docs/src/lib/layout.js +8 -2
- package/docs/src/pages/components/charts.js +17 -13
- package/package.json +1 -1
- package/public/.pulse-ui-version +1 -1
- package/scripts/build.js +39 -0
- package/src/server/index.js +7 -0
- package/src/ui/charts.js +11 -3
|
@@ -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.12
|
package/docs/server.js
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
1
2
|
import { createServer } from '../src/server/index.js'
|
|
3
|
+
import { initLayoutManifest } from './src/lib/layout.js'
|
|
2
4
|
import { metrics } from './src/lib/stats.js'
|
|
3
5
|
import { metricsStore } from './src/lib/metrics-store.js'
|
|
4
6
|
metricsStore.current = metrics
|
|
7
|
+
|
|
8
|
+
// Populate hashed asset paths in layout.js before any page renders
|
|
9
|
+
try {
|
|
10
|
+
const raw = fs.readFileSync(new URL('./public/dist/manifest.json', import.meta.url), 'utf8')
|
|
11
|
+
initLayoutManifest(JSON.parse(raw))
|
|
12
|
+
} catch { /* dev: no manifest yet, layout falls back to unhashed paths */ }
|
|
5
13
|
import home from './src/pages/home.js'
|
|
6
14
|
import howItWorks from './src/pages/how-it-works.js'
|
|
7
15
|
import faq from './src/pages/faq.js'
|
package/docs/src/lib/layout.js
CHANGED
|
@@ -8,6 +8,12 @@
|
|
|
8
8
|
import { NAV } from './nav.js'
|
|
9
9
|
import pkg from '../../../package.json' with { type: 'json' }
|
|
10
10
|
|
|
11
|
+
// Resolve static asset paths through the build manifest (hashed in prod, raw in dev).
|
|
12
|
+
// Populated by initLayoutManifest() in server.js before the server starts.
|
|
13
|
+
let _manifest = {}
|
|
14
|
+
export function initLayoutManifest(m) { _manifest = m }
|
|
15
|
+
const asset = href => _manifest[href] || href
|
|
16
|
+
|
|
11
17
|
const { version } = pkg
|
|
12
18
|
|
|
13
19
|
function esc(s) {
|
|
@@ -90,8 +96,8 @@ export function renderLayout({ currentHref, content, prev = null, next = null })
|
|
|
90
96
|
${prevNextBar(prev, next)}
|
|
91
97
|
</main>
|
|
92
98
|
</div>
|
|
93
|
-
<script src="/menu.js"></script>
|
|
94
|
-
<script src="/pulse-ui.js"></script>`
|
|
99
|
+
<script src="${asset('/menu.js')}" defer></script>
|
|
100
|
+
<script src="${asset('/pulse-ui.js')}" defer></script>`
|
|
95
101
|
}
|
|
96
102
|
|
|
97
103
|
/**
|
|
@@ -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.14
|
package/scripts/build.js
CHANGED
|
@@ -404,6 +404,12 @@ fs.rmSync(TMP_DIR, { recursive: true })
|
|
|
404
404
|
|
|
405
405
|
await purgeCssStep(pages, manifest, ROOT, OUT_DIR)
|
|
406
406
|
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
// JS static assets — minify + hash any .js files directly in public/
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
|
|
411
|
+
await jsAssetsStep(ROOT, OUT_DIR, manifest)
|
|
412
|
+
|
|
407
413
|
// ---------------------------------------------------------------------------
|
|
408
414
|
// Report
|
|
409
415
|
// ---------------------------------------------------------------------------
|
|
@@ -420,6 +426,39 @@ for (const [src, bundle] of Object.entries(manifest)) {
|
|
|
420
426
|
}
|
|
421
427
|
console.log('\n✓ manifest written to public/dist/manifest.json\n')
|
|
422
428
|
|
|
429
|
+
// ---------------------------------------------------------------------------
|
|
430
|
+
// JS static assets implementation
|
|
431
|
+
// ---------------------------------------------------------------------------
|
|
432
|
+
|
|
433
|
+
async function jsAssetsStep(root, outDir, manifest) {
|
|
434
|
+
const publicDir = path.join(root, 'public')
|
|
435
|
+
if (!fs.existsSync(publicDir)) return
|
|
436
|
+
|
|
437
|
+
const jsFiles = fs.readdirSync(publicDir).filter(f => f.endsWith('.js'))
|
|
438
|
+
if (jsFiles.length === 0) return
|
|
439
|
+
|
|
440
|
+
console.log('⚡ Hashing static JS assets...\n')
|
|
441
|
+
|
|
442
|
+
for (const file of jsFiles) {
|
|
443
|
+
const srcPath = path.join(publicDir, file)
|
|
444
|
+
const source = fs.readFileSync(srcPath, 'utf8')
|
|
445
|
+
const { code } = await esbuild.transform(source, { minify: true, target: 'es2018' })
|
|
446
|
+
const hash = createHash('sha256').update(code).digest('hex').slice(0, 8)
|
|
447
|
+
const name = path.basename(file, '.js')
|
|
448
|
+
const outName = `${name}-${hash}.js`
|
|
449
|
+
|
|
450
|
+
fs.writeFileSync(path.join(outDir, outName), code)
|
|
451
|
+
manifest[`/${file}`] = `/dist/${outName}`
|
|
452
|
+
|
|
453
|
+
const origKb = (Buffer.byteLength(source) / 1024).toFixed(1)
|
|
454
|
+
const minKb = (Buffer.byteLength(code) / 1024).toFixed(1)
|
|
455
|
+
console.log(` /${file.padEnd(24)} → /dist/${outName} (${minKb} kB from ${origKb} kB)`)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
fs.writeFileSync(path.join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2))
|
|
459
|
+
console.log('\n✓ JS asset entries added to manifest\n')
|
|
460
|
+
}
|
|
461
|
+
|
|
423
462
|
// ---------------------------------------------------------------------------
|
|
424
463
|
// CSS Purge implementation
|
|
425
464
|
// ---------------------------------------------------------------------------
|
package/src/server/index.js
CHANGED
|
@@ -1315,6 +1315,13 @@ function resolveSpec(spec, hydrateMap) {
|
|
|
1315
1315
|
}
|
|
1316
1316
|
}
|
|
1317
1317
|
|
|
1318
|
+
if (Array.isArray(spec.meta?.scripts)) {
|
|
1319
|
+
const resolvedScripts = spec.meta.scripts.map(href => hydrateMap[href] || href)
|
|
1320
|
+
if (resolvedScripts.some((s, i) => s !== spec.meta.scripts[i])) {
|
|
1321
|
+
resolved = { ...resolved, meta: { ...resolved.meta, scripts: resolvedScripts } }
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1318
1325
|
return resolved
|
|
1319
1326
|
}
|
|
1320
1327
|
|
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 `<
|
|
120
|
+
return `<div${clsAttr} style="width:100%;aspect-ratio:${IW}/${height}"><svg viewBox="0 0 ${IW} ${height}" width="100%" height="100%" style="display:block" role="img" aria-label="${e(ariaLabel)}">${grid}${baseline}${bars}</svg></div>`
|
|
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 `<
|
|
196
|
+
return `<div${clsAttr} style="width:100%;aspect-ratio:${IW}/${height}"><svg viewBox="0 0 ${IW} ${height}" width="100%" height="100%" style="display:block" role="img" aria-label="${e(ariaLabel)}">${grid}${areaPath}${line}${dots}${xlabels}</svg></div>`
|
|
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,9 @@ export function sparkline({
|
|
|
316
321
|
}
|
|
317
322
|
|
|
318
323
|
const clsAttr = cls ? ` class="${e(cls)}"` : ''
|
|
324
|
+
if (fluid) {
|
|
325
|
+
return `<div${clsAttr} style="width:100%;aspect-ratio:${width}/${height}"><svg viewBox="0 0 ${width} ${height}" width="100%" height="100%" style="display:block" aria-hidden="true">${areaPath}<polyline points="${points}" fill="none" stroke="${c}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg></div>`
|
|
326
|
+
}
|
|
319
327
|
|
|
320
328
|
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>`
|
|
321
329
|
}
|