@progen-labs/spectra 0.0.0-stage → 0.1.1

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.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -2
  3. package/bin/spectra.js +215 -0
  4. package/dist/assets/index-D0Lxrmdt.js +46 -0
  5. package/dist/assets/index-Da3Le5Px.css +1 -0
  6. package/dist/index.html +14 -0
  7. package/index.html +13 -0
  8. package/package.json +76 -4
  9. package/server/analysis/cpu.js +369 -0
  10. package/server/analysis/findings.js +570 -0
  11. package/server/analysis/locate.js +99 -0
  12. package/server/analysis/report.js +775 -0
  13. package/server/analysis/sources.js +249 -0
  14. package/server/analysis/trace.js +314 -0
  15. package/server/api.js +292 -0
  16. package/server/chrome.js +81 -0
  17. package/server/cli.js +94 -0
  18. package/server/config.js +59 -0
  19. package/server/dashboard.js +154 -0
  20. package/server/explore/boot.js +150 -0
  21. package/server/explore/classify.js +169 -0
  22. package/server/explore/discover.js +162 -0
  23. package/server/explore/evaluate-cli.js +101 -0
  24. package/server/explore/evaluate.js +165 -0
  25. package/server/explore/paths.js +105 -0
  26. package/server/explore/runner.js +94 -0
  27. package/server/explore/test-explore.js +139 -0
  28. package/server/explore/test-paths.js +75 -0
  29. package/server/framework.js +67 -0
  30. package/server/git.js +59 -0
  31. package/server/pdf/render.js +60 -0
  32. package/server/pdf/template.js +419 -0
  33. package/server/plugin.js +19 -0
  34. package/server/probe.browser.js +828 -0
  35. package/server/recorder.js +610 -0
  36. package/server/signin.js +27 -0
  37. package/server/test-analysis.js +424 -0
  38. package/server/test-compare.js +473 -0
  39. package/server/test-sources.js +136 -0
  40. package/shared/compare.js +977 -0
  41. package/shared/snapshot.js +77 -0
  42. package/src/App.jsx +361 -0
  43. package/src/api.js +51 -0
  44. package/src/components/AppearanceMenu.jsx +76 -0
  45. package/src/components/CodeRef.jsx +48 -0
  46. package/src/components/ComparePanel.jsx +143 -0
  47. package/src/components/CompareResults.jsx +473 -0
  48. package/src/components/ControlBar.jsx +336 -0
  49. package/src/components/CulpritSphere.jsx +218 -0
  50. package/src/components/Culprits.jsx +120 -0
  51. package/src/components/CulpritsHero.jsx +109 -0
  52. package/src/components/EvaluationPanel.jsx +160 -0
  53. package/src/components/FilePanel.jsx +225 -0
  54. package/src/components/FindingsList.jsx +381 -0
  55. package/src/components/HeatTrees.jsx +222 -0
  56. package/src/components/Icons.jsx +58 -0
  57. package/src/components/LongTasks.jsx +199 -0
  58. package/src/components/NetworkPanel.jsx +98 -0
  59. package/src/components/PageMap.jsx +329 -0
  60. package/src/components/Popover.jsx +51 -0
  61. package/src/components/ReactPanel.jsx +206 -0
  62. package/src/components/SidePicker.jsx +207 -0
  63. package/src/components/SnapshotButton.jsx +140 -0
  64. package/src/components/SpectraCulprits.jsx +39 -0
  65. package/src/components/TimeBreakdown.jsx +45 -0
  66. package/src/components/TraceButton.jsx +76 -0
  67. package/src/components/TreeView.jsx +173 -0
  68. package/src/components/Vitals.jsx +163 -0
  69. package/src/culprits.js +127 -0
  70. package/src/download.js +18 -0
  71. package/src/format.js +26 -0
  72. package/src/heat.js +177 -0
  73. package/src/highlight.js +45 -0
  74. package/src/hooks/useCompare.js +187 -0
  75. package/src/hooks/useSession.js +73 -0
  76. package/src/layout.js +55 -0
  77. package/src/main.jsx +15 -0
  78. package/src/styles.css +2478 -0
  79. package/src/theme.js +75 -0
  80. package/src/themes.css +286 -0
  81. package/vite.config.js +8 -0
@@ -0,0 +1,76 @@
1
+ import { useState } from 'react';
2
+ import { api } from '../api.js';
3
+ import { fmtBytes } from '../format.js';
4
+ import { DownloadIcon } from './Icons.jsx';
5
+
6
+ /**
7
+ * Writes the recording's raw trace to reports/<id>.trace.json so it can be opened
8
+ * in Chrome DevTools → Performance → Load profile. (The report JSON itself is this
9
+ * tool's own format and cannot be loaded there.)
10
+ */
11
+ export default function TraceButton({ reportId }) {
12
+ const [state, setState] = useState({ status: 'idle' });
13
+
14
+ const save = async () => {
15
+ setState({ status: 'working' });
16
+ try {
17
+ const result = await api.saveTrace(reportId);
18
+ setState({ status: 'done', ...result });
19
+ } catch (err) {
20
+ setState({ status: 'error', message: err.message });
21
+ }
22
+ };
23
+
24
+ return (
25
+ <span className="trace-button">
26
+ <button
27
+ type="button"
28
+ onClick={save}
29
+ disabled={state.status === 'working'}
30
+ >
31
+ {state.status === 'working' ? (
32
+ 'Preparing…'
33
+ ) : (
34
+ <>
35
+ <DownloadIcon /> Trace for Chrome DevTools
36
+ </>
37
+ )}
38
+ </button>
39
+ {state.status !== 'idle' && state.status !== 'working' && (
40
+ <div className={`trace-note ${state.status}`} role="status">
41
+ <button
42
+ type="button"
43
+ className="close"
44
+ aria-label="Dismiss"
45
+ onClick={() => setState({ status: 'idle' })}
46
+ >
47
+ ×
48
+ </button>
49
+ {state.status === 'error' ? (
50
+ state.message
51
+ ) : (
52
+ <>
53
+ <b>Saved</b> <code>{state.path}</code> ({fmtBytes(state.size)}).
54
+ <ol>
55
+ <li>
56
+ In Chrome open DevTools (<kbd>F12</kbd>) → <b>Performance</b>.
57
+ </li>
58
+ <li>
59
+ Click <b>Load profile…</b> (the ↑ icon) and choose that file —
60
+ or{' '}
61
+ <a
62
+ href={`/api/reports/${encodeURIComponent(reportId)}/trace`}
63
+ download
64
+ >
65
+ download it
66
+ </a>{' '}
67
+ first.
68
+ </li>
69
+ </ol>
70
+ </>
71
+ )}
72
+ </div>
73
+ )}
74
+ </span>
75
+ );
76
+ }
@@ -0,0 +1,173 @@
1
+ import { select, zoom, zoomIdentity } from 'd3';
2
+ import { useEffect, useMemo, useRef, useState } from 'react';
3
+ import { ROW_HEIGHT } from '../layout.js';
4
+
5
+ const CULL_MARGIN = 200;
6
+
7
+ function linkPath(link) {
8
+ const sx = link.source.px + link.source.labelWidth - 10;
9
+ const sy = link.source.py;
10
+ const tx = link.target.px;
11
+ const ty = link.target.py;
12
+ const mid = (sx + tx) / 2;
13
+ return `M${sx},${sy}C${mid},${sy} ${mid},${ty} ${tx},${ty}`;
14
+ }
15
+
16
+ function TreeNode({ node, isSelected, onActivate, onToggle }) {
17
+ const view = node.view;
18
+ const hasChildren = view.expandable;
19
+ const heat = Math.max(0, Math.min(1, view.heat || 0));
20
+ const cls = [
21
+ 'node',
22
+ view.isDir ? 'dir' : 'file',
23
+ view.vendor ? 'vendor' : '',
24
+ isSelected ? 'selected' : '',
25
+ ]
26
+ .filter(Boolean)
27
+ .join(' ');
28
+ const r = view.r || 5;
29
+ return (
30
+ <g className={cls} transform={`translate(${node.px},${node.py})`}>
31
+ <title>{view.tooltip || node.label}</title>
32
+ <rect
33
+ className="node-hit"
34
+ x={-r}
35
+ y={-ROW_HEIGHT / 2 + 1}
36
+ width={node.labelWidth}
37
+ height={ROW_HEIGHT - 2}
38
+ rx={4}
39
+ />
40
+ <circle
41
+ className="node-dot"
42
+ r={r}
43
+ style={{ '--heat-pct': `${Math.round(12 + heat * 88)}%` }}
44
+ onClick={() => (hasChildren ? onToggle(node) : onActivate(node))}
45
+ />
46
+ {hasChildren && (
47
+ <text className="node-glyph" y={3.5} textAnchor="middle">
48
+ {node.children ? '−' : '+'}
49
+ </text>
50
+ )}
51
+ <text
52
+ className="node-label"
53
+ x={r + 6}
54
+ y={4}
55
+ role="button"
56
+ tabIndex={0}
57
+ onClick={() => onActivate(node)}
58
+ onKeyDown={e => {
59
+ if (e.key === 'Enter' || e.key === ' ') {
60
+ e.preventDefault();
61
+ onActivate(node);
62
+ }
63
+ }}
64
+ >
65
+ {node.label}
66
+ </text>
67
+ </g>
68
+ );
69
+ }
70
+
71
+ /** Pan/zoomable node-link tree. Wheel scrolls, ctrl/cmd + wheel zooms. */
72
+ export default function TreeView({
73
+ layout,
74
+ selectedKey,
75
+ centerKey,
76
+ centerTick,
77
+ anchor = 0.12,
78
+ onActivate,
79
+ onToggle,
80
+ }) {
81
+ const svgRef = useRef(null);
82
+ const zoomRef = useRef(null);
83
+ const sizeRef = useRef({ width: 800, height: 600 });
84
+ const [size, setSize] = useState(sizeRef.current);
85
+ const [transform, setTransform] = useState(zoomIdentity);
86
+
87
+ useEffect(() => {
88
+ const svg = svgRef.current;
89
+ const behavior = zoom()
90
+ .scaleExtent([0.25, 2.5])
91
+ .filter(e => (e.type === 'wheel' ? e.ctrlKey || e.metaKey : !e.button))
92
+ .on('zoom', e => setTransform(e.transform));
93
+ zoomRef.current = behavior;
94
+ select(svg).call(behavior).on('dblclick.zoom', null);
95
+ const onWheel = e => {
96
+ if (e.ctrlKey || e.metaKey) return;
97
+ e.preventDefault();
98
+ select(svg).call(behavior.translateBy, -e.deltaX, -e.deltaY);
99
+ };
100
+ svg.addEventListener('wheel', onWheel, { passive: false });
101
+ const observer = new ResizeObserver(([entry]) => {
102
+ sizeRef.current = {
103
+ width: entry.contentRect.width,
104
+ height: entry.contentRect.height,
105
+ };
106
+ setSize(sizeRef.current);
107
+ });
108
+ observer.observe(svg);
109
+ return () => {
110
+ svg.removeEventListener('wheel', onWheel);
111
+ observer.disconnect();
112
+ select(svg).on('.zoom', null);
113
+ };
114
+ }, []);
115
+
116
+ // Re-centre on an explicit request (new report, search, list click) only.
117
+ useEffect(() => {
118
+ const node = layout.byKey.get(centerKey) || layout.root;
119
+ const { width, height } = sizeRef.current;
120
+ const target = zoomIdentity.translate(
121
+ Math.max(60, width * anchor) - node.px,
122
+ height / 2 - node.py,
123
+ );
124
+ select(svgRef.current)
125
+ .transition()
126
+ .duration(centerTick > 0 ? 300 : 0)
127
+ .call(zoomRef.current.transform, target);
128
+ // eslint-disable-next-line react-hooks/exhaustive-deps
129
+ }, [centerTick]);
130
+
131
+ const visible = useMemo(() => {
132
+ const top = (-transform.y - CULL_MARGIN) / transform.k;
133
+ const bottom = (size.height - transform.y + CULL_MARGIN) / transform.k;
134
+ const inView = n => n.py >= top && n.py <= bottom;
135
+ return {
136
+ nodes: layout.nodes.filter(inView),
137
+ links: layout.links.filter(l => inView(l.source) || inView(l.target)),
138
+ };
139
+ }, [layout, transform, size.height]);
140
+
141
+ return (
142
+ <svg
143
+ ref={svgRef}
144
+ className="tree-svg"
145
+ role="tree"
146
+ aria-label="Performance heat tree"
147
+ >
148
+ <g
149
+ transform={`translate(${transform.x},${transform.y}) scale(${transform.k})`}
150
+ >
151
+ <g className="tree-links">
152
+ {visible.links.map(l => (
153
+ <path
154
+ key={`${l.source.data.id}>${l.target.data.id}`}
155
+ d={linkPath(l)}
156
+ />
157
+ ))}
158
+ </g>
159
+ <g>
160
+ {visible.nodes.map(n => (
161
+ <TreeNode
162
+ key={n.data.id}
163
+ node={n}
164
+ isSelected={n.data.id === selectedKey}
165
+ onActivate={onActivate}
166
+ onToggle={onToggle}
167
+ />
168
+ ))}
169
+ </g>
170
+ </g>
171
+ </svg>
172
+ );
173
+ }
@@ -0,0 +1,163 @@
1
+ import { fmtMs, fmtNumber } from '../format.js';
2
+
3
+ // Web-vitals style thresholds: [good, poor]. `hero` ones get a full card.
4
+ const RATINGS = [
5
+ {
6
+ key: 'tbt',
7
+ label: 'Total blocking time',
8
+ get: r => r.vitals.tbtMs,
9
+ limits: [200, 600],
10
+ fmt: fmtMs,
11
+ hero: true,
12
+ },
13
+ {
14
+ key: 'long',
15
+ label: 'Longest task',
16
+ get: r => r.vitals.maxLongTaskMs,
17
+ limits: [50, 200],
18
+ fmt: fmtMs,
19
+ hero: true,
20
+ },
21
+ {
22
+ key: 'inp',
23
+ label: 'Slowest interaction',
24
+ get: r => r.vitals.inpMs,
25
+ limits: [200, 500],
26
+ fmt: fmtMs,
27
+ hero: true,
28
+ },
29
+ {
30
+ key: 'wasted',
31
+ label: 'Wasted React renders',
32
+ get: r =>
33
+ r.react.detected
34
+ ? (r.react && r.react.components ? r.react.components.reduce((s, c) => s + c.wasted, 0) : 0)
35
+ : null,
36
+ limits: [20, 200],
37
+ fmt: fmtNumber,
38
+ hero: true,
39
+ },
40
+ {
41
+ key: 'lcp',
42
+ label: 'Largest paint',
43
+ get: r => r.vitals.lcpMs,
44
+ limits: [2500, 4000],
45
+ fmt: fmtMs,
46
+ },
47
+ {
48
+ key: 'fcp',
49
+ label: 'First paint',
50
+ get: r => r.vitals.fcpMs,
51
+ limits: [1800, 3000],
52
+ fmt: fmtMs,
53
+ },
54
+ {
55
+ key: 'cls',
56
+ label: 'Layout shift',
57
+ get: r => r.vitals.cls,
58
+ limits: [0.1, 0.25],
59
+ fmt: v => v.toFixed(3),
60
+ },
61
+ {
62
+ key: 'dom',
63
+ label: 'DOM nodes',
64
+ get: r => r.memory.domNodes,
65
+ limits: [1500, 3000],
66
+ fmt: fmtNumber,
67
+ },
68
+ {
69
+ key: 'heap',
70
+ label: 'Heap growth',
71
+ get: r => r.memory.heapGrowthMb,
72
+ limits: [30, 100],
73
+ fmt: v => `${v.toFixed(1)} MB`,
74
+ },
75
+ {
76
+ key: 'forced',
77
+ label: 'Forced reflows',
78
+ get: r => r.totals.forcedLayoutCount + r.totals.forcedStyleCount,
79
+ limits: [10, 100],
80
+ fmt: fmtNumber,
81
+ },
82
+ ];
83
+
84
+ const LABELS = { good: 'Good', warn: 'Needs work', poor: 'Poor', none: 'n/a' };
85
+ const ICONS = { good: '✓', warn: '!', poor: '✕', none: '–' };
86
+ const GAUGE_HEADROOM = 1.5;
87
+
88
+ function rate(value, [good, poor]) {
89
+ if (value == null) return 'none';
90
+ if (value <= good) return 'good';
91
+ return value >= poor ? 'poor' : 'warn';
92
+ }
93
+
94
+ function Gauge({ value, limits }) {
95
+ const max = limits[1] * GAUGE_HEADROOM;
96
+ const pct = n => `${Math.min(100, (n / max) * 100)}%`;
97
+ return (
98
+ <div
99
+ className="gauge"
100
+ aria-hidden="true"
101
+ style={{ '--g': pct(limits[0]), '--p': pct(limits[1]) }}
102
+ >
103
+ <span className="gauge-needle" style={{ left: pct(value) }} />
104
+ </div>
105
+ );
106
+ }
107
+
108
+ function HeroCard({ vital, value, rating }) {
109
+ const [good, poor] = vital.limits;
110
+ return (
111
+ <div className={`vital-hero rating-${rating}`}>
112
+ <div className="vital-label">{vital.label}</div>
113
+ <div className="vital-value">
114
+ {value == null ? '–' : vital.fmt(value)}
115
+ </div>
116
+ <div className="vital-rating">
117
+ <span aria-hidden="true">{ICONS[rating]}</span> {LABELS[rating]}
118
+ </div>
119
+ {value != null && <Gauge value={value} limits={vital.limits} />}
120
+ <div className="vital-target muted">
121
+ Good ≤ {vital.fmt(good)} · Poor ≥ {vital.fmt(poor)}
122
+ </div>
123
+ </div>
124
+ );
125
+ }
126
+
127
+ function Chip({ vital, value, rating }) {
128
+ return (
129
+ <div className={`vital-chip rating-${rating}`}>
130
+ <span className="vital-chip-icon" aria-hidden="true">
131
+ {ICONS[rating]}
132
+ </span>
133
+ <span className="vital-chip-label">{vital.label}</span>
134
+ <b>{value == null ? '–' : vital.fmt(value)}</b>
135
+ <span className="visually-hidden">{LABELS[rating]}</span>
136
+ </div>
137
+ );
138
+ }
139
+
140
+ export default function Vitals({ report }) {
141
+ const rows = RATINGS.map(v => {
142
+ const value = v.get(report);
143
+ return { vital: v, value, rating: rate(value, v.limits) };
144
+ });
145
+ return (
146
+ <div className="vitals-block">
147
+ <div className="vitals-hero">
148
+ {rows
149
+ .filter(r => r.vital.hero)
150
+ .map(r => (
151
+ <HeroCard key={r.vital.key} {...r} />
152
+ ))}
153
+ </div>
154
+ <div className="vitals-secondary">
155
+ {rows
156
+ .filter(r => !r.vital.hero)
157
+ .map(r => (
158
+ <Chip key={r.vital.key} {...r} />
159
+ ))}
160
+ </div>
161
+ </div>
162
+ );
163
+ }
@@ -0,0 +1,127 @@
1
+ export const SEVERITY_RANK = { critical: 3, warning: 2, info: 1 };
2
+
3
+ export const SEVERITY = {
4
+ critical: { label: 'Critical', icon: '✕' },
5
+ warning: { label: 'Warning', icon: '!' },
6
+ info: { label: 'Note', icon: 'i' },
7
+ };
8
+
9
+ const isVendorFile = file => file.includes('node_modules/');
10
+ const higher = (a, b) => (SEVERITY_RANK[b] > SEVERITY_RANK[a] ? b : a);
11
+
12
+ export const culpritKey = c => `${c.fn || c.name}|${c.file}`;
13
+
14
+ /** The finding to quote a fix from: most severe, then biggest impact. */
15
+ function leadFinding(findings) {
16
+ return [...findings].sort(
17
+ (a, b) =>
18
+ SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity] ||
19
+ b.impactMs - a.impactMs,
20
+ )[0];
21
+ }
22
+
23
+ /**
24
+ * The functions behind the report, merged across findings: one entry per
25
+ * function with its worst severity, the time it cost and the findings it
26
+ * appears in. Time is the largest figure seen (not a sum) because the same
27
+ * work is counted again by the total-blocking-time finding.
28
+ */
29
+ export function topCulprits(report, hideVendor, limit = 12) {
30
+ const map = new Map();
31
+ const entryFor = (key, base) => {
32
+ let e = map.get(key);
33
+ if (!e) {
34
+ e = {
35
+ ...base,
36
+ key,
37
+ ms: 0,
38
+ severity: 'info',
39
+ findings: [],
40
+ categories: new Set(),
41
+ };
42
+ map.set(key, e);
43
+ }
44
+ return e;
45
+ };
46
+ for (const f of report.findings) {
47
+ for (const c of f.culprits || []) {
48
+ if (!c.file || (hideVendor && isVendorFile(c.file))) continue;
49
+ const e = entryFor(culpritKey(c), {
50
+ name: c.fn || '(unknown)',
51
+ file: c.file,
52
+ line: c.line,
53
+ lineKind: c.lineKind,
54
+ abs: c.abs,
55
+ });
56
+ e.ms = Math.max(e.ms, c.ms || 0);
57
+ e.severity = higher(e.severity, f.severity);
58
+ e.findings.push(f);
59
+ e.categories.add(f.category);
60
+ }
61
+ }
62
+ for (const h of report.hotFunctions || []) {
63
+ if (!h.file || (hideVendor && h.vendor)) continue;
64
+ const e = entryFor(culpritKey({ fn: h.name, file: h.file }), {
65
+ name: h.name,
66
+ file: h.file,
67
+ line: h.line,
68
+ lineKind: h.lineKind,
69
+ abs: h.abs,
70
+ });
71
+ e.ms = Math.max(e.ms, h.selfMs || 0);
72
+ if (!e.findings.length && h.share >= 10) e.severity = 'warning';
73
+ }
74
+ return [...map.values()]
75
+ .sort((a, b) => b.ms - a.ms)
76
+ .slice(0, limit)
77
+ .map(e => ({
78
+ ...e,
79
+ categories: [...e.categories],
80
+ lead: e.findings.length ? leadFinding(e.findings) : null,
81
+ }));
82
+ }
83
+
84
+ /**
85
+ * Collapses findings that blame the same function (typically a dozen long
86
+ * tasks all caused by one library call) into one group, so the list shows
87
+ * root causes instead of repeating them.
88
+ */
89
+ export function groupFindings(findings) {
90
+ const buckets = new Map();
91
+ const order = [];
92
+ for (const f of findings) {
93
+ const first = f.culprits?.[0];
94
+ const key = first?.file ? culpritKey(first) : `solo:${f.id}`;
95
+ if (!buckets.has(key)) {
96
+ buckets.set(key, []);
97
+ order.push(key);
98
+ }
99
+ buckets.get(key).push(f);
100
+ }
101
+ const items = order.map(key => {
102
+ const members = buckets.get(key);
103
+ if (members.length === 1)
104
+ return {
105
+ kind: 'single',
106
+ id: members[0].id,
107
+ severity: members[0].severity,
108
+ impactMs: members[0].impactMs,
109
+ finding: members[0],
110
+ };
111
+ const lead = leadFinding(members);
112
+ return {
113
+ kind: 'group',
114
+ id: `group:${key}`,
115
+ severity: members.reduce((s, m) => higher(s, m.severity), 'info'),
116
+ impactMs: Math.max(...members.map(m => m.impactMs)),
117
+ culprit: lead.culprits[0],
118
+ lead,
119
+ members: [...members].sort((a, b) => b.impactMs - a.impactMs),
120
+ };
121
+ });
122
+ return items.sort(
123
+ (a, b) =>
124
+ SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity] ||
125
+ b.impactMs - a.impactMs,
126
+ );
127
+ }
@@ -0,0 +1,18 @@
1
+ /** Saves a Blob through the browser's download mechanism. */
2
+ export function downloadBlob(blob, filename) {
3
+ const url = URL.createObjectURL(blob);
4
+ const link = document.createElement('a');
5
+ link.href = url;
6
+ link.download = filename;
7
+ document.body.appendChild(link);
8
+ link.click();
9
+ link.remove();
10
+ // The download starts asynchronously; releasing the URL immediately can cancel it.
11
+ setTimeout(() => URL.revokeObjectURL(url), 30_000);
12
+ }
13
+
14
+ export const downloadJson = (value, filename) =>
15
+ downloadBlob(
16
+ new Blob([JSON.stringify(value)], { type: 'application/json' }),
17
+ filename,
18
+ );
package/src/format.js ADDED
@@ -0,0 +1,26 @@
1
+ export function fmtMs(ms) {
2
+ if (ms == null) return '–';
3
+ if (ms >= 1000) return `${(ms / 1000).toFixed(2)} s`;
4
+ if (ms >= 100) return `${Math.round(ms)} ms`;
5
+ if (ms >= 1) return `${ms.toFixed(1)} ms`;
6
+ return `${ms.toFixed(2)} ms`;
7
+ }
8
+
9
+ export function fmtBytes(n) {
10
+ if (n == null) return '–';
11
+ if (n < 1024) return `${n} B`;
12
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
13
+ return `${(n / 1024 / 1024).toFixed(2)} MB`;
14
+ }
15
+
16
+ export function fmtNumber(n) {
17
+ return n == null ? '–' : Number(n).toLocaleString();
18
+ }
19
+
20
+ export function splitPath(path) {
21
+ const slash = path.lastIndexOf('/');
22
+ return {
23
+ dir: slash < 0 ? '' : path.slice(0, slash + 1),
24
+ name: path.slice(slash + 1),
25
+ };
26
+ }