@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,381 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
+ import { SEVERITY, SEVERITY_RANK, groupFindings } from '../culprits.js';
3
+ import { fmtMs, splitPath } from '../format.js';
4
+ import CodeRef from './CodeRef.jsx';
5
+ import { ChevronIcon } from './Icons.jsx';
6
+
7
+ const FILTERS = [
8
+ { id: 'all', label: 'All' },
9
+ { id: 'critical', label: 'Critical' },
10
+ { id: 'warning', label: 'Warnings' },
11
+ { id: 'info', label: 'Notes' },
12
+ ];
13
+
14
+ function CulpritTable({ culprits, onOpenFile }) {
15
+ const maxMs = Math.max(1, ...culprits.map(c => c.ms || 0));
16
+ return (
17
+ <table className="data culprit-table">
18
+ <thead>
19
+ <tr>
20
+ <th>Function</th>
21
+ <th>Where</th>
22
+ <th className="num">Time</th>
23
+ </tr>
24
+ </thead>
25
+ <tbody>
26
+ {culprits.map(c => (
27
+ <tr key={`${c.fn}|${c.file}|${c.line}`}>
28
+ <td>
29
+ <code className="fn">{c.fn || '(unknown)'}()</code>
30
+ {c.note && <div className="muted small">{c.note}</div>}
31
+ </td>
32
+ <td>
33
+ <CodeRef
34
+ file={c.file}
35
+ line={c.line}
36
+ lineKind={c.lineKind}
37
+ abs={c.abs}
38
+ onOpenFile={onOpenFile}
39
+ />
40
+ </td>
41
+ <td className="num">
42
+ {c.ms > 0 && (
43
+ <>
44
+ {fmtMs(c.ms)}
45
+ <div className="bar">
46
+ <span style={{ width: `${(c.ms / maxMs) * 100}%` }} />
47
+ </div>
48
+ </>
49
+ )}
50
+ </td>
51
+ </tr>
52
+ ))}
53
+ </tbody>
54
+ </table>
55
+ );
56
+ }
57
+
58
+ function FixCallout({ fix }) {
59
+ if (!fix) return null;
60
+ return (
61
+ <div className="fix-callout">
62
+ <span className="fix-label">What to do</span>
63
+ <p>{fix}</p>
64
+ </div>
65
+ );
66
+ }
67
+
68
+ function FindingBody({ finding, onOpenFile }) {
69
+ return (
70
+ <div className="fbody">
71
+ <FixCallout fix={finding.fix} />
72
+ {finding.detail && (
73
+ <p className="fdetail">
74
+ <b>Why it matters</b>
75
+ {finding.detail}
76
+ </p>
77
+ )}
78
+ {finding.culprits?.length > 0 && (
79
+ <CulpritTable culprits={finding.culprits} onOpenFile={onOpenFile} />
80
+ )}
81
+ {!finding.culprits?.length && finding.file && (
82
+ <button type="button" onClick={() => onOpenFile(finding.file)}>
83
+ Open {finding.file}
84
+ </button>
85
+ )}
86
+ </div>
87
+ );
88
+ }
89
+
90
+ function RowHead({
91
+ severity,
92
+ title,
93
+ chips,
94
+ impactMs,
95
+ maxImpact,
96
+ isOpen,
97
+ onToggle,
98
+ subtitle,
99
+ }) {
100
+ const sev = SEVERITY[severity];
101
+ return (
102
+ <button
103
+ type="button"
104
+ className="frow-head"
105
+ aria-expanded={isOpen}
106
+ onClick={onToggle}
107
+ >
108
+ <span className="sev-badge" aria-label={sev.label} title={sev.label}>
109
+ {sev.icon}
110
+ </span>
111
+ <span className="frow-main">
112
+ <span className="frow-title">{title}</span>
113
+ {subtitle && <span className="frow-sub muted">{subtitle}</span>}
114
+ </span>
115
+ <span className="frow-chips">
116
+ {chips.map(c => (
117
+ <span key={c} className="chip">
118
+ {c}
119
+ </span>
120
+ ))}
121
+ </span>
122
+ <span className="impact">
123
+ {impactMs > 0 ? (
124
+ <>
125
+ <span className="impact-bar">
126
+ <span
127
+ style={{
128
+ width: `${Math.max(4, (impactMs / maxImpact) * 100)}%`,
129
+ }}
130
+ />
131
+ </span>
132
+ <span className="impact-ms">{fmtMs(impactMs)}</span>
133
+ </>
134
+ ) : (
135
+ <span className="muted">–</span>
136
+ )}
137
+ </span>
138
+ <ChevronIcon isUp={isOpen} />
139
+ </button>
140
+ );
141
+ }
142
+
143
+ function SingleRow({
144
+ finding,
145
+ maxImpact,
146
+ isOpen,
147
+ onToggle,
148
+ onOpenFile,
149
+ rowRef,
150
+ }) {
151
+ return (
152
+ <li
153
+ ref={rowRef}
154
+ className={`frow sev-${finding.severity} ${isOpen ? 'open' : ''}`}
155
+ >
156
+ <RowHead
157
+ severity={finding.severity}
158
+ title={finding.title}
159
+ subtitle={finding.file ? splitPath(finding.file).name : ''}
160
+ chips={[finding.category]}
161
+ impactMs={finding.impactMs}
162
+ maxImpact={maxImpact}
163
+ isOpen={isOpen}
164
+ onToggle={onToggle}
165
+ />
166
+ {isOpen && <FindingBody finding={finding} onOpenFile={onOpenFile} />}
167
+ </li>
168
+ );
169
+ }
170
+
171
+ function GroupRow({
172
+ group,
173
+ maxImpact,
174
+ openIds,
175
+ onToggle,
176
+ onOpenFile,
177
+ rowRefs,
178
+ }) {
179
+ const isOpen = openIds.has(group.id);
180
+ const count = group.members.length;
181
+ const name = `${group.culprit.fn || '(unknown)'}()`;
182
+ return (
183
+ <li
184
+ ref={el => rowRefs.current.set(group.id, el)}
185
+ className={`frow group sev-${group.severity} ${isOpen ? 'open' : ''}`}
186
+ >
187
+ <RowHead
188
+ severity={group.severity}
189
+ title={`${name} is behind ${count} findings`}
190
+ subtitle={splitPath(group.culprit.file).name}
191
+ chips={[...new Set(group.members.map(m => m.category))]}
192
+ impactMs={group.impactMs}
193
+ maxImpact={maxImpact}
194
+ isOpen={isOpen}
195
+ onToggle={() => onToggle(group.id)}
196
+ />
197
+ {isOpen && (
198
+ <div className="fbody">
199
+ <FixCallout fix={group.lead.fix} />
200
+ <CulpritTable culprits={[group.culprit]} onOpenFile={onOpenFile} />
201
+ <h4 className="members-title">Findings it causes</h4>
202
+ <ul className="members">
203
+ {group.members.map(m => (
204
+ <SingleRow
205
+ key={m.id}
206
+ finding={m}
207
+ maxImpact={group.impactMs}
208
+ isOpen={openIds.has(m.id)}
209
+ onToggle={() => onToggle(m.id)}
210
+ onOpenFile={onOpenFile}
211
+ rowRef={el => rowRefs.current.set(m.id, el)}
212
+ />
213
+ ))}
214
+ </ul>
215
+ </div>
216
+ )}
217
+ </li>
218
+ );
219
+ }
220
+
221
+ /**
222
+ * Findings as a scannable list: root causes grouped, one line each, details
223
+ * (what to do first, then why, then the exact code) only when expanded.
224
+ */
225
+ export default function FindingsList({ findings, onOpenFile, focus }) {
226
+ const [severity, setSeverity] = useState('all');
227
+ const [category, setCategory] = useState('all');
228
+ const items = useMemo(
229
+ () =>
230
+ groupFindings(
231
+ findings.filter(
232
+ f =>
233
+ (severity === 'all' || f.severity === severity) &&
234
+ (category === 'all' || f.category === category),
235
+ ),
236
+ ),
237
+ [findings, severity, category],
238
+ );
239
+ const [openIds, setOpenIds] = useState(() => {
240
+ const first = groupFindings(findings)[0];
241
+ return new Set(first ? [first.id] : []);
242
+ });
243
+ const rowRefs = useRef(new Map());
244
+ const pendingScroll = useRef(null);
245
+
246
+ const categories = useMemo(
247
+ () => [...new Set(findings.map(f => f.category))].sort(),
248
+ [findings],
249
+ );
250
+ const counts = useMemo(
251
+ () => ({
252
+ all: findings.length,
253
+ ...Object.fromEntries(
254
+ Object.keys(SEVERITY_RANK).map(s => [
255
+ s,
256
+ findings.filter(f => f.severity === s).length,
257
+ ]),
258
+ ),
259
+ }),
260
+ [findings],
261
+ );
262
+ const maxImpact = Math.max(1, ...items.map(i => i.impactMs));
263
+
264
+ const toggle = id =>
265
+ setOpenIds(prev => {
266
+ const next = new Set(prev);
267
+ if (!next.delete(id)) next.add(id);
268
+ return next;
269
+ });
270
+
271
+ useEffect(() => {
272
+ if (!focus) return;
273
+ const target = groupFindings(findings).find(
274
+ i => i.id === focus.id || i.members?.some(m => m.id === focus.id),
275
+ );
276
+ if (!target) return;
277
+ setSeverity('all');
278
+ setCategory('all');
279
+ setOpenIds(prev => new Set([...prev, target.id, focus.id]));
280
+ pendingScroll.current = focus.id;
281
+ // Only a new jump request should re-run this.
282
+ // eslint-disable-next-line react-hooks/exhaustive-deps
283
+ }, [focus?.tick]);
284
+
285
+ useEffect(() => {
286
+ const id = pendingScroll.current;
287
+ if (!id) return;
288
+ const el = rowRefs.current.get(id);
289
+ if (el) {
290
+ pendingScroll.current = null;
291
+ el.scrollIntoView({ block: 'center', behavior: 'smooth' });
292
+ }
293
+ });
294
+
295
+ return (
296
+ <div className="findings-list">
297
+ <div className="findings-toolbar">
298
+ <div className="segmented" role="group" aria-label="Filter by severity">
299
+ {FILTERS.map(f => (
300
+ <button
301
+ key={f.id}
302
+ type="button"
303
+ aria-pressed={severity === f.id}
304
+ onClick={() => setSeverity(f.id)}
305
+ >
306
+ {f.label} <span className="muted">{counts[f.id]}</span>
307
+ </button>
308
+ ))}
309
+ </div>
310
+ {categories.length > 1 && (
311
+ <select
312
+ aria-label="Filter by category"
313
+ value={category}
314
+ onChange={e => setCategory(e.target.value)}
315
+ >
316
+ <option value="all">All categories</option>
317
+ {categories.map(c => (
318
+ <option key={c} value={c}>
319
+ {c}
320
+ </option>
321
+ ))}
322
+ </select>
323
+ )}
324
+ <span className="spacer" />
325
+ <button
326
+ type="button"
327
+ className="ghost"
328
+ onClick={() =>
329
+ setOpenIds(
330
+ new Set(
331
+ items.flatMap(i =>
332
+ i.kind === 'group'
333
+ ? [i.id, ...i.members.map(m => m.id)]
334
+ : [i.id],
335
+ ),
336
+ ),
337
+ )
338
+ }
339
+ >
340
+ Expand all
341
+ </button>
342
+ <button
343
+ type="button"
344
+ className="ghost"
345
+ onClick={() => setOpenIds(new Set())}
346
+ >
347
+ Collapse all
348
+ </button>
349
+ </div>
350
+ {items.length === 0 ? (
351
+ <p className="muted pad">Nothing matches these filters.</p>
352
+ ) : (
353
+ <ul className="frows">
354
+ {items.map(item =>
355
+ item.kind === 'group' ? (
356
+ <GroupRow
357
+ key={item.id}
358
+ group={item}
359
+ maxImpact={maxImpact}
360
+ openIds={openIds}
361
+ onToggle={toggle}
362
+ onOpenFile={onOpenFile}
363
+ rowRefs={rowRefs}
364
+ />
365
+ ) : (
366
+ <SingleRow
367
+ key={item.id}
368
+ finding={item.finding}
369
+ maxImpact={maxImpact}
370
+ isOpen={openIds.has(item.id)}
371
+ onToggle={() => toggle(item.id)}
372
+ onOpenFile={onOpenFile}
373
+ rowRef={el => rowRefs.current.set(item.id, el)}
374
+ />
375
+ ),
376
+ )}
377
+ </ul>
378
+ )}
379
+ </div>
380
+ );
381
+ }
@@ -0,0 +1,222 @@
1
+ import { useCallback, useEffect, useMemo, useState } from 'react';
2
+ import {
3
+ FILE_METRICS,
4
+ ancestorIds,
5
+ buildFileTree,
6
+ decorateCallNode,
7
+ decorateFileNode,
8
+ defaultCallExpanded,
9
+ defaultExpanded,
10
+ formatMetric,
11
+ } from '../heat.js';
12
+ import { fmtMs } from '../format.js';
13
+ import { layoutTree } from '../layout.js';
14
+ import TreeView from './TreeView.jsx';
15
+
16
+ function Legend({ extra }) {
17
+ return (
18
+ <div className="legend">
19
+ <span className="ramp" aria-hidden="true" /> low → high heat (share of the
20
+ total)
21
+ <span>
22
+ <i className="ring" /> library code (node_modules)
23
+ </span>
24
+ {extra}
25
+ </div>
26
+ );
27
+ }
28
+
29
+ /** Directory tree of profiled files, heat-coloured by the chosen metric. */
30
+ export function FileHeatTree({
31
+ report,
32
+ hideVendor,
33
+ selectedPath,
34
+ revealTick,
35
+ onSelectFile,
36
+ }) {
37
+ const [metricId, setMetricId] = useState('selfMs');
38
+ const [expanded, setExpanded] = useState(null);
39
+ const [centerTick, setCenterTick] = useState(0);
40
+ const metric = FILE_METRICS.find(m => m.id === metricId);
41
+
42
+ const tree = useMemo(
43
+ () =>
44
+ buildFileTree(report.files, metric, {
45
+ hideVendor,
46
+ rootName: report?.meta?.title || report?.meta?.url || 'target-frontend',
47
+ }),
48
+ [report, metric, hideVendor],
49
+ );
50
+ // A new tree (report, metric, filter) restarts from its hottest paths.
51
+ useEffect(() => {
52
+ setExpanded(null);
53
+ setCenterTick(t => t + 1);
54
+ }, [tree]);
55
+ const expandedSet = useMemo(
56
+ () => expanded || defaultExpanded(tree),
57
+ [expanded, tree],
58
+ );
59
+
60
+ // Reveal a file chosen elsewhere (findings, React table, search).
61
+ useEffect(() => {
62
+ if (!selectedPath || !tree.byId.has(selectedPath)) return;
63
+ setExpanded(
64
+ prev =>
65
+ new Set([
66
+ ...(prev || defaultExpanded(tree)),
67
+ ...ancestorIds(tree, selectedPath),
68
+ ]),
69
+ );
70
+ setCenterTick(t => t + 1);
71
+ }, [revealTick, selectedPath, tree]);
72
+
73
+ const layout = useMemo(
74
+ () =>
75
+ layoutTree(
76
+ tree.root,
77
+ n => (n.isDir && expandedSet.has(n.id) ? n.children : null),
78
+ n => `${n.name}${n.isDir ? '/' : ''} ${formatMetric(metric, n.value)}`,
79
+ n => decorateFileNode(n, tree, metric),
80
+ ),
81
+ [tree, expandedSet, metric],
82
+ );
83
+ const toggle = useCallback(
84
+ node => {
85
+ setExpanded(prev => {
86
+ const next = new Set(prev || expandedSet);
87
+ if (next.has(node.data.id)) next.delete(node.data.id);
88
+ else next.add(node.data.id);
89
+ return next;
90
+ });
91
+ },
92
+ [expandedSet],
93
+ );
94
+
95
+ const activate = useCallback(
96
+ node => {
97
+ if (node.data.isDir) toggle(node);
98
+ else onSelectFile(node.data.id);
99
+ },
100
+ [toggle, onSelectFile],
101
+ );
102
+
103
+ return (
104
+ <div className="tree-pane">
105
+ <div className="tree-toolbar">
106
+ <label>
107
+ Colour and size by
108
+ <select value={metricId} onChange={e => setMetricId(e.target.value)}>
109
+ {FILE_METRICS.map(m => (
110
+ <option key={m.id} value={m.id}>
111
+ {m.label}
112
+ </option>
113
+ ))}
114
+ </select>
115
+ </label>
116
+ <span className="muted">{metric.hint}</span>
117
+ <span className="spacer" />
118
+ <button
119
+ type="button"
120
+ onClick={() => setExpanded(new Set([tree.root.id]))}
121
+ >
122
+ Collapse all
123
+ </button>
124
+ <button
125
+ type="button"
126
+ onClick={() => setExpanded(new Set(tree.byId.keys()))}
127
+ >
128
+ Expand all
129
+ </button>
130
+ </div>
131
+ {tree.total === 0 ? (
132
+ <div className="empty">
133
+ No files have any “{metric.label}” in this recording.
134
+ </div>
135
+ ) : (
136
+ <TreeView
137
+ layout={layout}
138
+ selectedKey={selectedPath}
139
+ centerKey={selectedPath}
140
+ centerTick={centerTick}
141
+ onActivate={activate}
142
+ onToggle={toggle}
143
+ />
144
+ )}
145
+ <Legend />
146
+ </div>
147
+ );
148
+ }
149
+
150
+ /** Top-down call tree: hot paths are expanded, colour = self time. */
151
+ export function CallHeatTree({ report, onSelectFile }) {
152
+ const root = report.callTree;
153
+ const [expanded, setExpanded] = useState(() =>
154
+ root ? defaultCallExpanded(root) : new Set(),
155
+ );
156
+ const [centerTick, setCenterTick] = useState(0);
157
+ useEffect(() => {
158
+ setExpanded(root ? defaultCallExpanded(root) : new Set());
159
+ setCenterTick(t => t + 1);
160
+ }, [root]);
161
+
162
+ const layout = useMemo(
163
+ () =>
164
+ root &&
165
+ layoutTree(
166
+ root,
167
+ n => (expanded.has(n.id) || n === root ? n.children : null),
168
+ n => `${n.name} ${fmtMs(n.totalMs)} · self ${fmtMs(n.selfMs)}`,
169
+ n => decorateCallNode(n, root.totalMs),
170
+ ),
171
+ [root, expanded],
172
+ );
173
+
174
+ // Open the view on the function with the most self time among the visible nodes.
175
+ const hottestKey = useMemo(() => {
176
+ if (!layout) return undefined;
177
+ return layout.nodes.reduce(
178
+ (best, n) => (n.data.selfMs > (best?.data.selfMs ?? -1) ? n : best),
179
+ null,
180
+ )?.data.id;
181
+ }, [layout]);
182
+
183
+ const toggle = useCallback(node => {
184
+ setExpanded(prev => {
185
+ const next = new Set(prev);
186
+ if (next.has(node.data.id)) next.delete(node.data.id);
187
+ else next.add(node.data.id);
188
+ return next;
189
+ });
190
+ }, []);
191
+ const activate = useCallback(
192
+ node => {
193
+ const { file } = node.data;
194
+ if (file && !file.startsWith('(')) onSelectFile(file);
195
+ else if (node.view.expandable) toggle(node);
196
+ },
197
+ [toggle, onSelectFile],
198
+ );
199
+
200
+ if (!layout)
201
+ return <div className="empty">No CPU samples were captured.</div>;
202
+ return (
203
+ <div className="tree-pane">
204
+ <div className="tree-toolbar">
205
+ <span className="muted">
206
+ Each node is a function call path: total time, then self time. Click a
207
+ circle to expand/collapse; click a label to open its file.
208
+ </span>
209
+ </div>
210
+ <TreeView
211
+ layout={layout}
212
+ selectedKey={null}
213
+ centerKey={hottestKey}
214
+ centerTick={centerTick}
215
+ anchor={0.55}
216
+ onActivate={activate}
217
+ onToggle={toggle}
218
+ />
219
+ <Legend extra={<span>colour = self time · size = total time</span>} />
220
+ </div>
221
+ );
222
+ }
@@ -0,0 +1,58 @@
1
+ const Svg = ({ children, size = 14 }) => (
2
+ <svg
3
+ className="icon"
4
+ viewBox="0 0 16 16"
5
+ width={size}
6
+ height={size}
7
+ aria-hidden="true"
8
+ fill="none"
9
+ stroke="currentColor"
10
+ strokeWidth="1.6"
11
+ strokeLinecap="round"
12
+ strokeLinejoin="round"
13
+ >
14
+ {children}
15
+ </svg>
16
+ );
17
+
18
+ export const ChevronIcon = ({ isUp = false }) => (
19
+ <Svg size={12}>
20
+ <path d={isUp ? 'M3.5 10 8 5.5l4.5 4.5' : 'M3.5 6 8 10.5 12.5 6'} />
21
+ </Svg>
22
+ );
23
+
24
+ export const RecordIcon = () => (
25
+ <svg
26
+ className="icon"
27
+ viewBox="0 0 16 16"
28
+ width="12"
29
+ height="12"
30
+ aria-hidden="true"
31
+ >
32
+ <circle cx="8" cy="8" r="5.5" fill="currentColor" />
33
+ </svg>
34
+ );
35
+
36
+ export const StopIcon = () => (
37
+ <svg
38
+ className="icon"
39
+ viewBox="0 0 16 16"
40
+ width="12"
41
+ height="12"
42
+ aria-hidden="true"
43
+ >
44
+ <rect x="3" y="3" width="10" height="10" rx="2" fill="currentColor" />
45
+ </svg>
46
+ );
47
+
48
+ export const PageLoadIcon = () => (
49
+ <Svg>
50
+ <path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9M13.5 2.5v3h-3" />
51
+ </Svg>
52
+ );
53
+
54
+ export const DownloadIcon = () => (
55
+ <Svg>
56
+ <path d="M8 2.5v8M4.5 7.5 8 11l3.5-3.5M3 13.5h10" />
57
+ </Svg>
58
+ );