@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,77 @@
1
+ /**
2
+ * "Snapshots" are profiler reports exported for comparison. This module knows how
3
+ * to name them (the commit hash goes in the file name so they are recognisable
4
+ * once downloaded), check that an uploaded file really is one, and recover the
5
+ * commit from a file name when the report itself carries no git information.
6
+ */
7
+
8
+ /** @returns {{ok: true} | {ok: false, reason: string}} */
9
+ export function validateReport(obj) {
10
+ if (obj && (Array.isArray(obj) || Array.isArray(obj.traceEvents))) {
11
+ return {
12
+ ok: false,
13
+ reason:
14
+ 'This is a raw Chrome trace (.trace.json). Comparison needs the profiler report: use “Snapshot for comparison” on the report instead — the trace lacks the React and source-file data.',
15
+ };
16
+ }
17
+ const ok =
18
+ obj &&
19
+ typeof obj === 'object' &&
20
+ obj.meta &&
21
+ obj.vitals &&
22
+ obj.totals &&
23
+ obj.memory &&
24
+ obj.network &&
25
+ Array.isArray(obj.files) &&
26
+ Array.isArray(obj.findings);
27
+ return ok
28
+ ? { ok: true }
29
+ : { ok: false, reason: 'This file is not a performance-profiler report.' };
30
+ }
31
+
32
+ /**
33
+ * Commit hash from a file name such as `perf_3fa516c_load_20260930-1548.json`.
34
+ * The segment right after the `perf_` prefix wins; otherwise the first hex-looking
35
+ * token that contains a letter (so plain dates like 20260930 are never mistaken for a hash).
36
+ */
37
+ export function hashFromFileName(name) {
38
+ const base = String(name || '').replace(/\.json$/i, '');
39
+ const prefixed = /^perf[_-]([0-9a-f]{7,40})(?:[_-]|$)/i.exec(base);
40
+ if (prefixed) return prefixed[1].toLowerCase();
41
+ const token = base
42
+ .split(/[^0-9a-zA-Z]+/)
43
+ .find(t => /^[0-9a-f]{7,40}$/i.test(t) && /[a-f]/i.test(t));
44
+ return token ? token.toLowerCase() : '';
45
+ }
46
+
47
+ const slug = text =>
48
+ String(text || '')
49
+ .toLowerCase()
50
+ .replace(/[^a-z0-9]+/g, '-')
51
+ .replace(/^-+|-+$/g, '')
52
+ .slice(0, 40);
53
+
54
+ export function snapshotFileName(report, git, label) {
55
+ const hash = (git?.shortHash || git?.hash || '').slice(0, 10) || 'nocommit';
56
+ const stamp = String(report.createdAt || '')
57
+ .replace(/[-:]/g, '')
58
+ .replace(/\..+$/, '')
59
+ .replace('T', '-')
60
+ .slice(0, 15); // yyyymmdd-hhmmss: two runs a few seconds apart must not share a name
61
+ const parts = ['perf', hash, report.meta.mode, stamp, slug(label)].filter(
62
+ Boolean,
63
+ );
64
+ return `${parts.join('_')}.json`;
65
+ }
66
+
67
+ /** The report with the commit and an optional label recorded in `meta`. */
68
+ export function withCommit(report, git, label) {
69
+ return {
70
+ ...report,
71
+ meta: {
72
+ ...report.meta,
73
+ git: git || report.meta.git || null,
74
+ label: label || report.meta.label || '',
75
+ },
76
+ };
77
+ }
package/src/App.jsx ADDED
@@ -0,0 +1,361 @@
1
+ import { useCallback, useEffect, useState } from 'react';
2
+ import { api } from './api.js';
3
+ import AppearanceMenu from './components/AppearanceMenu.jsx';
4
+ import { CallHeatTree, FileHeatTree } from './components/HeatTrees.jsx';
5
+ import ComparePanel from './components/ComparePanel.jsx';
6
+ import ControlBar from './components/ControlBar.jsx';
7
+ import Culprits from './components/Culprits.jsx';
8
+ import EvaluationPanel from './components/EvaluationPanel.jsx';
9
+ import FilePanel from './components/FilePanel.jsx';
10
+ import LongTasks from './components/LongTasks.jsx';
11
+ import NetworkPanel from './components/NetworkPanel.jsx';
12
+ import PageMap from './components/PageMap.jsx';
13
+ import ReactPanel from './components/ReactPanel.jsx';
14
+ import SnapshotButton from './components/SnapshotButton.jsx';
15
+ import TraceButton from './components/TraceButton.jsx';
16
+ import { fmtMs } from './format.js';
17
+ import { useCompare } from './hooks/useCompare.js';
18
+ import { useSession } from './hooks/useSession.js';
19
+ import { useAppearance } from './theme.js';
20
+
21
+ const TABS = [
22
+ { id: 'culprits', label: 'Culprits' },
23
+ { id: 'page', label: 'Page map' },
24
+ { id: 'files', label: 'File tree' },
25
+ { id: 'calls', label: 'Call tree' },
26
+ { id: 'react', label: 'React' },
27
+ { id: 'tasks', label: 'Long tasks' },
28
+ { id: 'network', label: 'Network & memory' },
29
+ { id: 'evaluation', label: 'Golden paths' },
30
+ { id: 'compare', label: 'Compare commits' },
31
+ ];
32
+
33
+ /** Tabs that don't require a loaded report to be usable. */
34
+ const REPORTLESS_TABS = new Set(['compare', 'evaluation']);
35
+
36
+ function ReportPicker({ reports, current, onPick, onDelete }) {
37
+ if (!reports.length)
38
+ return <span className="muted">No saved reports yet</span>;
39
+ return (
40
+ <span className="report-picker">
41
+ <select
42
+ aria-label="Saved report"
43
+ value={current || ''}
44
+ onChange={e => onPick(e.target.value)}
45
+ >
46
+ {!current && <option value="">Open a saved report…</option>}
47
+ {reports.map(r => (
48
+ <option key={r.id} value={r.id}>
49
+ {new Date(r.createdAt).toLocaleString()} · {r.mode} ·{' '}
50
+ {fmtMs(r.durationMs)} · {r.critical} critical
51
+ </option>
52
+ ))}
53
+ </select>
54
+ {current && (
55
+ <button
56
+ type="button"
57
+ className="ghost destructive"
58
+ onClick={() => onDelete(current)}
59
+ aria-label="Delete this report"
60
+ >
61
+ Delete
62
+ </button>
63
+ )}
64
+ </span>
65
+ );
66
+ }
67
+
68
+ const STEPS = [
69
+ {
70
+ title: 'Start the frontend',
71
+ body: (
72
+ <>
73
+ Run <code>npm run dev-server</code> (usually on port 9000). A{' '}
74
+ <b>development</b> build gives per-file and per-component results.
75
+ </>
76
+ ),
77
+ },
78
+ {
79
+ title: 'Launch Chrome',
80
+ body: (
81
+ <>
82
+ Click <b>Launch Chrome</b>, sign in to the app in the window that opens,
83
+ and navigate to the page you care about.
84
+ </>
85
+ ),
86
+ },
87
+ {
88
+ title: 'Record',
89
+ body: (
90
+ <>
91
+ Press <b>Record interaction</b>, use the page, then <b>Stop</b> — or use{' '}
92
+ <b>Record page load</b> for startup performance.
93
+ </>
94
+ ),
95
+ },
96
+ ];
97
+
98
+ function Welcome({ onCompare }) {
99
+ return (
100
+ <div className="welcome">
101
+ <div>
102
+ <h2>Find what makes the frontend slow</h2>
103
+ <p className="welcome-lead">
104
+ Ranked culprits, heat trees, React re-renders, long tasks and network
105
+ findings from a single recording.
106
+ </p>
107
+ </div>
108
+ <ol className="steps">
109
+ {STEPS.map((step, i) => (
110
+ <li key={step.title}>
111
+ <span className="step-num">{i + 1}</span>
112
+ <h3>{step.title}</h3>
113
+ <p>{step.body}</p>
114
+ </li>
115
+ ))}
116
+ </ol>
117
+ <p className="welcome-foot">
118
+ Reviewing a PR? Use{' '}
119
+ <button type="button" className="link" onClick={onCompare}>
120
+ Compare commits
121
+ </button>{' '}
122
+ to see what a commit changed.
123
+ </p>
124
+ </div>
125
+ );
126
+ }
127
+
128
+ export default function App() {
129
+ const [report, setReport] = useState(null);
130
+ const [tab, setTab] = useState('culprits');
131
+ const [selectedFile, setSelectedFile] = useState(null);
132
+ const [hideVendor, setHideVendor] = useState(false);
133
+ const [revealTick, setRevealTick] = useState(0);
134
+ const compare = useCompare();
135
+ const [appearance, setAppearance] = useAppearance();
136
+ const [evaluations, setEvaluations] = useState([]);
137
+
138
+ useEffect(() => {
139
+ api
140
+ .evaluations()
141
+ .then(setEvaluations)
142
+ .catch(() => setEvaluations([]));
143
+ }, []);
144
+
145
+ const openReport = useCallback(async id => {
146
+ const data = await api.report(id);
147
+ setReport(data);
148
+ setSelectedFile(null);
149
+ setTab('culprits');
150
+ }, []);
151
+ const { status, reports, error, clearError, run, refreshReports } =
152
+ useSession({ onReport: openReport });
153
+
154
+ // Open the newest saved report on first load so the page is never empty.
155
+ useEffect(() => {
156
+ if (!report && reports.length) openReport(reports[0].id);
157
+ // eslint-disable-next-line react-hooks/exhaustive-deps
158
+ }, [reports.length]);
159
+
160
+ const openFile = useCallback(path => {
161
+ setSelectedFile(path);
162
+ setRevealTick(t => t + 1);
163
+ setTab(t =>
164
+ t === 'culprits' || t === 'network' || t === 'tasks' ? 'files' : t,
165
+ );
166
+ }, []);
167
+
168
+ const deleteReport = async id => {
169
+ await api.deleteReport(id);
170
+ setReport(null);
171
+ refreshReports();
172
+ };
173
+
174
+ const showPanel =
175
+ selectedFile &&
176
+ report &&
177
+ ['files', 'calls', 'react', 'culprits', 'tasks', 'page'].includes(tab);
178
+ const inCompare = tab === 'compare';
179
+ const inEvaluation = tab === 'evaluation';
180
+ return (
181
+ <div className="app">
182
+ <header className="topbar">
183
+ <div className="brand">
184
+ <span className="brand-mark" aria-hidden="true">
185
+ <svg viewBox="0 0 16 16" width="16" height="16">
186
+ <path
187
+ d="M1.5 8.5h3l2-5 3 9 2-4h3"
188
+ fill="none"
189
+ stroke="currentColor"
190
+ strokeWidth="1.7"
191
+ strokeLinecap="round"
192
+ strokeLinejoin="round"
193
+ />
194
+ </svg>
195
+ </span>
196
+ <div>
197
+ <h1>Performance Profiler</h1>
198
+ <small>{status?.defaultName || 'target-frontend'}</small>
199
+ </div>
200
+ </div>
201
+ <span className="spacer" />
202
+ <ReportPicker
203
+ reports={reports}
204
+ current={report?.id}
205
+ onPick={openReport}
206
+ onDelete={deleteReport}
207
+ />
208
+ <AppearanceMenu appearance={appearance} onChange={setAppearance} />
209
+ </header>
210
+ <ControlBar status={status} run={run} isCompact={Boolean(report)} />
211
+ {error && (
212
+ <div className="error" role="alert">
213
+ <span>{error}</span>
214
+ <button type="button" onClick={clearError}>
215
+ Dismiss
216
+ </button>
217
+ </div>
218
+ )}
219
+ <div className="workspace">
220
+ {report && !inCompare && !inEvaluation && (
221
+ <div className="report-bar">
222
+ <span className="report-title" title={report.meta.url}>
223
+ {report.meta.title || report.meta.url}
224
+ </span>
225
+ <span className="report-meta">
226
+ <span className="pill">
227
+ {report.meta.mode === 'load' ? 'Page load' : 'Interaction'}
228
+ </span>
229
+ <span className="pill">{fmtMs(report.meta.durationMs)}</span>
230
+ {report.meta.cpuThrottle > 1 && (
231
+ <span className="pill warn">
232
+ {report.meta.cpuThrottle}× CPU slowdown
233
+ </span>
234
+ )}
235
+ <span className="pill">
236
+ Attribution: {report.meta.attribution}
237
+ </span>
238
+ {report.meta.git && (
239
+ <span className="pill">
240
+ Commit <code>{report.meta.git.shortHash}</code>
241
+ {report.meta.git.dirty ? ' · uncommitted changes' : ''}
242
+ </span>
243
+ )}
244
+ </span>
245
+ <span className="spacer" />
246
+ <span className="btn-group">
247
+ <SnapshotButton key={`snap-${report.id}`} report={report} />
248
+ <TraceButton key={report.id} reportId={report.id} />
249
+ </span>
250
+ </div>
251
+ )}
252
+ <div className="tabs main-tabs">
253
+ <div role="tablist" className="tab-list" aria-label="Report views">
254
+ {TABS.map(t => (
255
+ <button
256
+ key={t.id}
257
+ type="button"
258
+ role="tab"
259
+ aria-selected={
260
+ tab === t.id && (Boolean(report) || REPORTLESS_TABS.has(t.id))
261
+ }
262
+ disabled={!report && !REPORTLESS_TABS.has(t.id)}
263
+ onClick={() => setTab(t.id)}
264
+ >
265
+ {t.label}
266
+ {t.id === 'culprits' && report && (
267
+ <span className="count">{report.findings.length}</span>
268
+ )}
269
+ </button>
270
+ ))}
271
+ </div>
272
+ <span className="spacer" />
273
+ <label className="switch">
274
+ <input
275
+ type="checkbox"
276
+ role="switch"
277
+ checked={hideVendor}
278
+ onChange={e => setHideVendor(e.target.checked)}
279
+ />
280
+ Hide node_modules
281
+ </label>
282
+ </div>
283
+ {inCompare && (
284
+ <main className="main">
285
+ <div className="content">
286
+ <ComparePanel
287
+ compare={compare}
288
+ savedReports={reports}
289
+ hideVendor={hideVendor}
290
+ />
291
+ </div>
292
+ </main>
293
+ )}
294
+ {inEvaluation && (
295
+ <main className="main">
296
+ <div className="content">
297
+ <EvaluationPanel
298
+ evaluations={evaluations}
299
+ onOpenReport={openReport}
300
+ />
301
+ </div>
302
+ </main>
303
+ )}
304
+ {!inCompare && !inEvaluation && report && (
305
+ <main className="main">
306
+ <div className="content">
307
+ {tab === 'culprits' && (
308
+ <Culprits
309
+ key={report.id}
310
+ report={report}
311
+ hideVendor={hideVendor}
312
+ onOpenFile={openFile}
313
+ />
314
+ )}
315
+ {tab === 'page' && (
316
+ <PageMap
317
+ report={report}
318
+ hideVendor={hideVendor}
319
+ onOpenFile={openFile}
320
+ />
321
+ )}
322
+ {tab === 'files' && (
323
+ <FileHeatTree
324
+ report={report}
325
+ hideVendor={hideVendor}
326
+ selectedPath={selectedFile}
327
+ revealTick={revealTick}
328
+ onSelectFile={setSelectedFile}
329
+ />
330
+ )}
331
+ {tab === 'calls' && (
332
+ <CallHeatTree report={report} onSelectFile={setSelectedFile} />
333
+ )}
334
+ {tab === 'react' && (
335
+ <ReactPanel
336
+ report={report}
337
+ hideVendor={hideVendor}
338
+ onOpenFile={openFile}
339
+ />
340
+ )}
341
+ {tab === 'tasks' && (
342
+ <LongTasks report={report} onOpenFile={openFile} />
343
+ )}
344
+ {tab === 'network' && <NetworkPanel report={report} />}
345
+ </div>
346
+ {showPanel && (
347
+ <FilePanel
348
+ report={report}
349
+ path={selectedFile}
350
+ onClose={() => setSelectedFile(null)}
351
+ />
352
+ )}
353
+ </main>
354
+ )}
355
+ {!inCompare && !inEvaluation && !report && (
356
+ <Welcome onCompare={() => setTab('compare')} />
357
+ )}
358
+ </div>
359
+ </div>
360
+ );
361
+ }
package/src/api.js ADDED
@@ -0,0 +1,51 @@
1
+ async function request(method, url, body) {
2
+ const res = await fetch(url, {
3
+ method,
4
+ headers: body ? { 'Content-Type': 'application/json' } : undefined,
5
+ body: body ? JSON.stringify(body) : undefined,
6
+ });
7
+ const data = await res.json().catch(() => ({}));
8
+ if (!res.ok) throw new Error(data.error || `${method} ${url}: ${res.status}`);
9
+ return data;
10
+ }
11
+
12
+ export const api = {
13
+ status: () => request('GET', '/api/status'),
14
+ connect: options => request('POST', '/api/connect', options),
15
+ disconnect: () => request('POST', '/api/disconnect', {}),
16
+ selectPage: id => request('POST', '/api/select-page', { id }),
17
+ navigate: (url, signIn) => request('POST', '/api/navigate', { url, signIn }),
18
+ reload: () => request('POST', '/api/reload', {}),
19
+ start: options => request('POST', '/api/record/start', options),
20
+ stop: () => request('POST', '/api/record/stop', {}),
21
+ reports: () => request('GET', '/api/reports'),
22
+ report: id => request('GET', `/api/reports/${encodeURIComponent(id)}`),
23
+ evaluations: () => request('GET', '/api/evaluations'),
24
+ evaluation: id =>
25
+ request('GET', `/api/evaluations/${encodeURIComponent(id)}`),
26
+ saveTrace: id =>
27
+ request('POST', `/api/reports/${encodeURIComponent(id)}/trace`, {}),
28
+ gitCommit: rev =>
29
+ request('GET', `/api/git/commit?rev=${encodeURIComponent(rev)}`),
30
+ /** @returns {Promise<{blob: Blob, name: string}>} */
31
+ comparePdf: async (comparison, kind) => {
32
+ const res = await fetch('/api/compare/pdf', {
33
+ method: 'POST',
34
+ headers: { 'Content-Type': 'application/json' },
35
+ body: JSON.stringify({ comparison, kind }),
36
+ });
37
+ if (!res.ok) {
38
+ const data = await res.json().catch(() => ({}));
39
+ throw new Error(data.error || `PDF export failed (${res.status})`);
40
+ }
41
+ const disposition = res.headers.get('content-disposition') || '';
42
+ return {
43
+ blob: await res.blob(),
44
+ name: /filename="([^"]+)"/.exec(disposition)?.[1] || 'comparison.pdf',
45
+ };
46
+ },
47
+ deleteReport: id =>
48
+ request('DELETE', `/api/reports/${encodeURIComponent(id)}`),
49
+ source: path =>
50
+ request('GET', `/api/source?path=${encodeURIComponent(path)}`),
51
+ };
@@ -0,0 +1,76 @@
1
+ import { MODES, STYLES } from '../theme.js';
2
+ import Popover from './Popover.jsx';
3
+
4
+ const PaletteIcon = () => (
5
+ <svg
6
+ className="icon"
7
+ viewBox="0 0 16 16"
8
+ width="14"
9
+ height="14"
10
+ aria-hidden="true"
11
+ >
12
+ <path
13
+ fill="currentColor"
14
+ d="M8 1a7 7 0 1 0 0 14c1 0 1.5-.7 1.5-1.4 0-.9-.6-1.1-.6-1.9 0-.7.6-1.2 1.3-1.2H12a3 3 0 0 0 3-3C15 3.6 11.9 1 8 1Zm-4 7a1 1 0 1 1 0-2 1 1 0 0 1 0 2Zm2-3a1 1 0 1 1 0-2 1 1 0 0 1 0 2Zm4 0a1 1 0 1 1 0-2 1 1 0 0 1 0 2Zm2 3a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"
15
+ />
16
+ </svg>
17
+ );
18
+
19
+ function ChoiceGroup({ name, legend, options, value, onChange, variant }) {
20
+ return (
21
+ <fieldset className="choice-group">
22
+ <legend>{legend}</legend>
23
+ <div className={variant}>
24
+ {options.map(option => (
25
+ <label
26
+ key={option.id}
27
+ className={value === option.id ? 'choice selected' : 'choice'}
28
+ >
29
+ <input
30
+ type="radio"
31
+ name={name}
32
+ value={option.id}
33
+ checked={value === option.id}
34
+ onChange={() => onChange(option.id)}
35
+ />
36
+ {variant === 'theme-cards' && (
37
+ <span className={`swatch swatch-${option.id}`} aria-hidden="true">
38
+ <i />
39
+ <i />
40
+ </span>
41
+ )}
42
+ <span className="choice-label">{option.label}</span>
43
+ {option.description && (
44
+ <small className="muted">{option.description}</small>
45
+ )}
46
+ </label>
47
+ ))}
48
+ </div>
49
+ </fieldset>
50
+ );
51
+ }
52
+
53
+ export default function AppearanceMenu({ appearance, onChange }) {
54
+ return (
55
+ <Popover label="Appearance" icon={<PaletteIcon />}>
56
+ <div className="appearance">
57
+ <ChoiceGroup
58
+ name="theme-style"
59
+ legend="Theme"
60
+ options={STYLES}
61
+ value={appearance.style}
62
+ onChange={style => onChange({ style })}
63
+ variant="theme-cards"
64
+ />
65
+ <ChoiceGroup
66
+ name="theme-mode"
67
+ legend="Mode"
68
+ options={MODES}
69
+ value={appearance.mode}
70
+ onChange={mode => onChange({ mode })}
71
+ variant="segmented-control"
72
+ />
73
+ </div>
74
+ </Popover>
75
+ );
76
+ }
@@ -0,0 +1,48 @@
1
+ import { splitPath } from '../format.js';
2
+
3
+ /** `file:line` as a link that opens the file in the tool, plus an “open in editor” link. */
4
+ export default function CodeRef({ file, line, lineKind, abs, onOpenFile }) {
5
+ if (!file) return <span className="muted">unknown file</span>;
6
+ const { dir, name } = splitPath(file);
7
+ const label = `${line ? `${lineKind === 'located' ? '≈' : ''}${line}` : ''}`;
8
+ return (
9
+ <span className="code-ref">
10
+ {onOpenFile ? (
11
+ <button
12
+ type="button"
13
+ className="link mono"
14
+ onClick={() => onOpenFile(file)}
15
+ title={file}
16
+ >
17
+ <span className="muted">{dir}</span>
18
+ <b>{name}</b>
19
+ {label && <span>:{label}</span>}
20
+ </button>
21
+ ) : (
22
+ <span className="mono" title={file}>
23
+ <span className="muted">{dir}</span>
24
+ <b>{name}</b>
25
+ {label && <span>:{label}</span>}
26
+ </span>
27
+ )}
28
+ {abs && (
29
+ <a
30
+ className="editor-link"
31
+ href={`vscode://file/${abs}${line ? `:${line}` : ''}`}
32
+ title="Open in VS Code"
33
+ >
34
+ ↗ editor
35
+ </a>
36
+ )}
37
+ {lineKind === 'located' && (
38
+ <span
39
+ className="muted"
40
+ title="Webpack dev bundles have no source map, so the line was found by searching the file for this function's definition."
41
+ >
42
+ {' '}
43
+ (line found by name)
44
+ </span>
45
+ )}
46
+ </span>
47
+ );
48
+ }