@progen-labs/spectra 0.0.0-stage → 0.1.0

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 (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -2
  3. package/bin/spectra.js +215 -0
  4. package/dist/assets/index-BvBsPok5.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 +551 -0
  11. package/server/analysis/locate.js +99 -0
  12. package/server/analysis/report.js +750 -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 +689 -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 +118 -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 +200 -0
  62. package/src/components/SidePicker.jsx +207 -0
  63. package/src/components/SnapshotButton.jsx +140 -0
  64. package/src/components/TimeBreakdown.jsx +45 -0
  65. package/src/components/TraceButton.jsx +76 -0
  66. package/src/components/TreeView.jsx +173 -0
  67. package/src/components/Vitals.jsx +163 -0
  68. package/src/culprits.js +127 -0
  69. package/src/download.js +18 -0
  70. package/src/format.js +26 -0
  71. package/src/heat.js +177 -0
  72. package/src/highlight.js +45 -0
  73. package/src/hooks/useCompare.js +187 -0
  74. package/src/hooks/useSession.js +73 -0
  75. package/src/layout.js +55 -0
  76. package/src/main.jsx +15 -0
  77. package/src/styles.css +2478 -0
  78. package/src/theme.js +75 -0
  79. package/src/themes.css +286 -0
  80. package/vite.config.js +8 -0
@@ -0,0 +1,109 @@
1
+ import { useMemo, useState } from 'react';
2
+ import { SEVERITY, topCulprits } from '../culprits.js';
3
+ import { fmtMs } from '../format.js';
4
+ import CodeRef from './CodeRef.jsx';
5
+ import CulpritSphere from './CulpritSphere.jsx';
6
+
7
+ const MAX_CULPRITS = 12;
8
+
9
+ function Insight({ item, onOpenFile, onJump }) {
10
+ const sev = SEVERITY[item.severity];
11
+ return (
12
+ <div className={`insight sev-${item.severity}`} aria-live="polite">
13
+ <div className="insight-head">
14
+ <span className="badge-sev">
15
+ <span aria-hidden="true">{sev.icon}</span> {sev.label}
16
+ </span>
17
+ <b className="insight-name">{item.name}()</b>
18
+ <span className="insight-ms">{fmtMs(item.ms)}</span>
19
+ </div>
20
+ <CodeRef
21
+ file={item.file}
22
+ line={item.line}
23
+ lineKind={item.lineKind}
24
+ abs={item.abs}
25
+ onOpenFile={onOpenFile}
26
+ />
27
+ {item.categories.length > 0 && (
28
+ <p className="muted">
29
+ Appears in {item.findings.length} finding
30
+ {item.findings.length === 1 ? '' : 's'} · {item.categories.join(', ')}
31
+ </p>
32
+ )}
33
+ {item.lead && (
34
+ <p className="insight-fix">
35
+ <b>What to do:</b> {item.lead.fix}
36
+ </p>
37
+ )}
38
+ {item.lead && (
39
+ <button type="button" onClick={() => onJump(item.lead.id)}>
40
+ Show the finding
41
+ </button>
42
+ )}
43
+ </div>
44
+ );
45
+ }
46
+
47
+ /** Top culprits: the sphere, a ranked list for exact reading, and one insight card. */
48
+ export default function CulpritsHero({
49
+ report,
50
+ hideVendor,
51
+ onOpenFile,
52
+ onJump,
53
+ }) {
54
+ const items = useMemo(
55
+ () => topCulprits(report, hideVendor, MAX_CULPRITS),
56
+ [report, hideVendor],
57
+ );
58
+ const [hoverKey, setHoverKey] = useState(null);
59
+ const [pickedKey, setPickedKey] = useState(null);
60
+ if (!items.length) return null;
61
+ const shown =
62
+ items.find(i => i.key === hoverKey) ||
63
+ items.find(i => i.key === pickedKey) ||
64
+ items[0];
65
+ const maxMs = Math.max(1, ...items.map(i => i.ms));
66
+ return (
67
+ <section className="culprits-hero" aria-label="Top culprits">
68
+ <div className="hero-sphere">
69
+ <h2>
70
+ Top culprits <span className="muted">· drag to rotate</span>
71
+ </h2>
72
+ <CulpritSphere
73
+ items={items}
74
+ activeKey={hoverKey}
75
+ highlightKey={shown.key}
76
+ onActivate={setHoverKey}
77
+ onSelect={setPickedKey}
78
+ />
79
+ </div>
80
+ <div className="hero-detail">
81
+ <Insight item={shown} onOpenFile={onOpenFile} onJump={onJump} />
82
+ <ol className="rank-list">
83
+ {items.map((item, i) => (
84
+ <li key={item.key}>
85
+ <button
86
+ type="button"
87
+ className={`rank-row sev-${item.severity} ${
88
+ shown.key === item.key ? 'active' : ''
89
+ }`}
90
+ onMouseEnter={() => setHoverKey(item.key)}
91
+ onMouseLeave={() => setHoverKey(null)}
92
+ onFocus={() => setHoverKey(item.key)}
93
+ onBlur={() => setHoverKey(null)}
94
+ onClick={() => setPickedKey(item.key)}
95
+ >
96
+ <span className="rank-n">{i + 1}</span>
97
+ <span className="rank-name">{item.name}()</span>
98
+ <span className="rank-bar">
99
+ <span style={{ width: `${(item.ms / maxMs) * 100}%` }} />
100
+ </span>
101
+ <span className="rank-ms">{fmtMs(item.ms)}</span>
102
+ </button>
103
+ </li>
104
+ ))}
105
+ </ol>
106
+ </div>
107
+ </section>
108
+ );
109
+ }
@@ -0,0 +1,160 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { api } from '../api.js';
3
+ import { fmtMs } from '../format.js';
4
+
5
+ /**
6
+ * Golden-path evaluation view: lists the `spectra performance` runs, and for
7
+ * the selected one shows the performance matrix (baseline + each journey with
8
+ * its automation result and key metrics). Each row opens its full report.
9
+ */
10
+ export default function EvaluationPanel({ evaluations, onOpenReport }) {
11
+ const [current, setCurrent] = useState(evaluations[0]?.id || '');
12
+ const [detail, setDetail] = useState(null);
13
+ const [error, setError] = useState('');
14
+
15
+ useEffect(() => {
16
+ if (!current) {
17
+ setDetail(null);
18
+ return;
19
+ }
20
+ let live = true;
21
+ setError('');
22
+ api
23
+ .evaluation(current)
24
+ .then(d => live && setDetail(d))
25
+ .catch(e => live && setError(e.message));
26
+ return () => {
27
+ live = false;
28
+ };
29
+ }, [current]);
30
+
31
+ if (!evaluations.length) {
32
+ return (
33
+ <div className="welcome">
34
+ <div>
35
+ <h2>No golden-path evaluations yet</h2>
36
+ <p className="welcome-lead">
37
+ Run <code>spectra performance</code> in your project. Spectra starts
38
+ your dev server, auto-discovers the actionable elements, drives a set
39
+ of golden-path journeys, and records each one. They’ll appear here as
40
+ a performance matrix — every path openable as a full report.
41
+ </p>
42
+ </div>
43
+ </div>
44
+ );
45
+ }
46
+
47
+ const rows = detail ? [detail.baseline, ...detail.paths] : [];
48
+ const passed = detail ? detail.paths.filter(p => p.ok).length : 0;
49
+
50
+ return (
51
+ <div className="eval-panel">
52
+ <div className="eval-head">
53
+ <select
54
+ aria-label="Evaluation run"
55
+ value={current}
56
+ onChange={e => setCurrent(e.target.value)}
57
+ >
58
+ {evaluations.map(e => (
59
+ <option key={e.id} value={e.id}>
60
+ {new Date(e.createdAt).toLocaleString()} · {e.passed}/{e.pathCount}{' '}
61
+ passed
62
+ </option>
63
+ ))}
64
+ </select>
65
+ {detail && (
66
+ <span className="eval-summary">
67
+ <span className="pill">{detail.discovered} actionable elements</span>
68
+ <span className="pill">{detail.paths.length} golden paths</span>
69
+ <span className={`pill ${passed === detail.paths.length ? 'ok' : 'warn'}`}>
70
+ {passed}/{detail.paths.length} passed
71
+ </span>
72
+ {detail.skippedDestructive?.length > 0 && (
73
+ <span className="pill" title={detail.skippedDestructive.map(s => s.name).join(', ')}>
74
+ {detail.skippedDestructive.length} destructive skipped
75
+ </span>
76
+ )}
77
+ </span>
78
+ )}
79
+ </div>
80
+
81
+ {error && <div className="error" role="alert"><span>{error}</span></div>}
82
+
83
+ {detail && (
84
+ <>
85
+ <div className="eval-target" title={detail.url}>
86
+ {detail.target} — <span className="muted">{detail.url}</span>
87
+ </div>
88
+ <div className="eval-table-wrap">
89
+ <table className="eval-matrix">
90
+ <thead>
91
+ <tr>
92
+ <th>#</th>
93
+ <th>Golden path</th>
94
+ <th>Result</th>
95
+ <th className="num">TBT</th>
96
+ <th className="num">LCP</th>
97
+ <th className="num">Long tasks</th>
98
+ <th className="num">Findings</th>
99
+ <th>Top finding</th>
100
+ <th />
101
+ </tr>
102
+ </thead>
103
+ <tbody>
104
+ {rows.map((r, i) => {
105
+ const s = r.summary || {};
106
+ const top = s.topFinding;
107
+ return (
108
+ <tr key={r.id || i} className={r.ok ? '' : 'row-fail'}>
109
+ <td className="muted">{i === 0 ? '—' : i}</td>
110
+ <td>
111
+ <div className="eval-path-title">{r.title}</div>
112
+ {!r.ok && r.error && (
113
+ <div className="eval-path-error">{r.error}</div>
114
+ )}
115
+ </td>
116
+ <td>
117
+ <span className={`badge ${r.ok ? 'badge-pass' : 'badge-fail'}`}>
118
+ {r.ok ? 'PASS' : 'FAIL'}
119
+ </span>
120
+ </td>
121
+ <td className="num">{s.tbtMs == null ? '—' : fmtMs(s.tbtMs)}</td>
122
+ <td className="num">{s.lcpMs == null ? '—' : fmtMs(s.lcpMs)}</td>
123
+ <td className="num">{s.longTaskCount ?? '—'}</td>
124
+ <td className="num">{s.findingCount ?? '—'}</td>
125
+ <td>
126
+ {top ? (
127
+ <span className={`top-finding sev-${top.severity}`}>
128
+ [{top.severity}] {top.title}
129
+ </span>
130
+ ) : (
131
+ <span className="muted">—</span>
132
+ )}
133
+ </td>
134
+ <td>
135
+ {r.id && (
136
+ <button
137
+ type="button"
138
+ className="link"
139
+ onClick={() => onOpenReport(r.id)}
140
+ >
141
+ Open report →
142
+ </button>
143
+ )}
144
+ </td>
145
+ </tr>
146
+ );
147
+ })}
148
+ </tbody>
149
+ </table>
150
+ </div>
151
+ <p className="muted eval-foot">
152
+ Automation: {passed}/{detail.paths.length} golden paths passed. Open any
153
+ row for its full report — culprits, React re-renders, long tasks, PDF
154
+ export and commit compare.
155
+ </p>
156
+ </>
157
+ )}
158
+ </div>
159
+ );
160
+ }
@@ -0,0 +1,225 @@
1
+ import { useEffect, useMemo, useState } from 'react';
2
+ import { api } from '../api.js';
3
+ import { fmtMs, fmtNumber } from '../format.js';
4
+ import { highlightSource } from '../highlight.js';
5
+
6
+ function Metric({ label, value, warn }) {
7
+ return (
8
+ <div className={`metric ${warn ? 'warn' : ''}`}>
9
+ <div className="metric-value">{value}</div>
10
+ <div className="metric-label">{label}</div>
11
+ </div>
12
+ );
13
+ }
14
+
15
+ function Source({ path, functions }) {
16
+ const [state, setState] = useState({ status: 'loading', file: null });
17
+ useEffect(() => {
18
+ let cancelled = false;
19
+ setState({ status: 'loading', file: null });
20
+ api
21
+ .source(path)
22
+ .then(file => !cancelled && setState({ status: 'ready', file }))
23
+ .catch(() => !cancelled && setState({ status: 'missing', file: null }));
24
+ return () => {
25
+ cancelled = true;
26
+ };
27
+ }, [path]);
28
+
29
+ // highlight.js escapes everything it emits and highlightSource escapes the
30
+ // fallback path, so the markup never contains raw file content.
31
+ const html = useMemo(
32
+ () => (state.file ? highlightSource(path, state.file.content) : ''),
33
+ [state.file, path],
34
+ );
35
+ const hot = useMemo(() => {
36
+ const map = new Map();
37
+ for (const fn of functions)
38
+ if (fn.line && fn.selfMs >= 0.5) map.set(fn.line, fn);
39
+ return map;
40
+ }, [functions]);
41
+
42
+ if (state.status === 'loading')
43
+ return <div className="empty">Loading source…</div>;
44
+ if (state.status === 'missing') {
45
+ return (
46
+ <div className="empty">
47
+ Source not found on disk for <code>{path}</code>. Bundled or generated
48
+ files have no source file.
49
+ </div>
50
+ );
51
+ }
52
+ const lineCount = state.file.content.split('\n').length;
53
+ return (
54
+ <div className="code-scroll">
55
+ <div className="gutter">
56
+ {Array.from({ length: lineCount }, (_, i) => {
57
+ const fn = hot.get(i + 1);
58
+ return (
59
+ <div
60
+ key={i + 1}
61
+ className={`gutter-line ${fn ? 'gutter-hot' : ''}`}
62
+ title={fn ? `${fn.name}: ${fmtMs(fn.selfMs)} self` : undefined}
63
+ >
64
+ {fn ? `${fmtMs(fn.selfMs)} ` : ''}
65
+ {i + 1}
66
+ </div>
67
+ );
68
+ })}
69
+ </div>
70
+ <pre className="code">
71
+ <code className="hljs" dangerouslySetInnerHTML={{ __html: html }} />
72
+ </pre>
73
+ </div>
74
+ );
75
+ }
76
+
77
+ export default function FilePanel({ report, path, onClose }) {
78
+ const [tab, setTab] = useState('functions');
79
+ const file = report.files.find(f => f.path === path);
80
+ const relatedFindings = report.findings.filter(f => f.file === path);
81
+ const maxSelf = Math.max(
82
+ ...(file?.functions.map(f => f.selfMs) || [0]),
83
+ 0.001,
84
+ );
85
+ const canShowSource = !path.startsWith('bundle:') && !path.startsWith('(');
86
+
87
+ return (
88
+ <aside className="file-panel" aria-label="Selected file">
89
+ <div className="panel-head">
90
+ <h2 title={path}>{path}</h2>
91
+ <button type="button" onClick={onClose} aria-label="Close file panel">
92
+ ×
93
+ </button>
94
+ </div>
95
+ {file ? (
96
+ <div className="metrics">
97
+ <Metric label="CPU self" value={fmtMs(file.selfMs)} />
98
+ <Metric label="CPU inclusive" value={fmtMs(file.totalMs)} />
99
+ <Metric
100
+ label="Blocking"
101
+ value={fmtMs(file.blockMs)}
102
+ warn={file.blockMs > 0}
103
+ />
104
+ <Metric label="React renders" value={fmtNumber(file.renders)} />
105
+ <Metric
106
+ label="Wasted"
107
+ value={fmtNumber(file.wasted)}
108
+ warn={file.wasted > 0}
109
+ />
110
+ <Metric
111
+ label="Forced reflows"
112
+ value={fmtNumber(file.forcedCount)}
113
+ warn={file.forcedCount > 0}
114
+ />
115
+ </div>
116
+ ) : (
117
+ <p className="muted pad">
118
+ No profile samples were recorded for this file.
119
+ </p>
120
+ )}
121
+ {relatedFindings.length > 0 && (
122
+ <ul className="mini-findings">
123
+ {relatedFindings.map(f => (
124
+ <li key={f.id} className={`sev-${f.severity}`}>
125
+ {f.title}
126
+ <div className="muted">{f.fix}</div>
127
+ </li>
128
+ ))}
129
+ </ul>
130
+ )}
131
+ <div role="tablist" className="tabs">
132
+ {[
133
+ ['functions', 'Functions'],
134
+ ['components', 'Components'],
135
+ ['source', 'Source'],
136
+ ].map(([id, label]) => (
137
+ <button
138
+ key={id}
139
+ type="button"
140
+ role="tab"
141
+ aria-selected={tab === id}
142
+ onClick={() => setTab(id)}
143
+ >
144
+ {label}
145
+ </button>
146
+ ))}
147
+ </div>
148
+ <div className="panel-body">
149
+ {tab === 'functions' && (
150
+ <table className="data">
151
+ <thead>
152
+ <tr>
153
+ <th>Function</th>
154
+ <th>Self</th>
155
+ <th>Total</th>
156
+ </tr>
157
+ </thead>
158
+ <tbody>
159
+ {(file?.functions || []).map(fn => (
160
+ <tr key={`${fn.name}${fn.line}${fn.bundleLine}`}>
161
+ <td className="mono">
162
+ {fn.name}()
163
+ <span className="muted">
164
+ {' '}
165
+ {fn.line
166
+ ? `:${fn.lineKind === 'located' ? '≈' : ''}${fn.line}`
167
+ : fn.bundleLine
168
+ ? `(bundle line ${fn.bundleLine})`
169
+ : ''}
170
+ </span>
171
+ <div className="bar">
172
+ <span
173
+ style={{ width: `${(fn.selfMs / maxSelf) * 100}%` }}
174
+ />
175
+ </div>
176
+ </td>
177
+ <td className="num">{fmtMs(fn.selfMs)}</td>
178
+ <td className="num">{fmtMs(fn.totalMs)}</td>
179
+ </tr>
180
+ ))}
181
+ </tbody>
182
+ </table>
183
+ )}
184
+ {tab === 'components' &&
185
+ ((file?.components.length || 0) === 0 ? (
186
+ <p className="muted pad">
187
+ No React components from this file rendered.
188
+ </p>
189
+ ) : (
190
+ <table className="data">
191
+ <thead>
192
+ <tr>
193
+ <th>Component</th>
194
+ <th>Renders</th>
195
+ <th>Wasted</th>
196
+ <th>Self</th>
197
+ </tr>
198
+ </thead>
199
+ <tbody>
200
+ {file.components.map(c => (
201
+ <tr key={`${c.name}${c.line}`}>
202
+ <td className="mono">
203
+ {c.name}
204
+ {c.line ? <span className="muted">:{c.line}</span> : null}
205
+ </td>
206
+ <td className="num">{fmtNumber(c.renders)}</td>
207
+ <td className={`num ${c.wasted ? 'warn' : ''}`}>
208
+ {fmtNumber(c.wasted)}
209
+ </td>
210
+ <td className="num">{fmtMs(c.selfMs)}</td>
211
+ </tr>
212
+ ))}
213
+ </tbody>
214
+ </table>
215
+ ))}
216
+ {tab === 'source' &&
217
+ (canShowSource ? (
218
+ <Source path={path} functions={file?.functions || []} />
219
+ ) : (
220
+ <div className="empty">This is a bundle, not a source file.</div>
221
+ ))}
222
+ </div>
223
+ </aside>
224
+ );
225
+ }