@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,143 @@
1
+ import { useState } from 'react';
2
+ import { api } from '../api.js';
3
+ import { downloadBlob } from '../download.js';
4
+ import { DownloadIcon } from './Icons.jsx';
5
+ import CompareResults from './CompareResults.jsx';
6
+ import SidePicker from './SidePicker.jsx';
7
+
8
+ function PdfButtons({ comparison }) {
9
+ const [busy, setBusy] = useState(null);
10
+ const [error, setError] = useState('');
11
+ const save = async kind => {
12
+ setBusy(kind);
13
+ setError('');
14
+ try {
15
+ const { blob, name } = await api.comparePdf(comparison, kind);
16
+ downloadBlob(blob, name);
17
+ } catch (err) {
18
+ setError(err.message);
19
+ }
20
+ setBusy(null);
21
+ };
22
+ return (
23
+ <span className="pdf-buttons">
24
+ <button
25
+ type="button"
26
+ className="primary"
27
+ disabled={Boolean(busy)}
28
+ onClick={() => save('summary')}
29
+ >
30
+ {busy === 'summary' ? (
31
+ 'Preparing…'
32
+ ) : (
33
+ <>
34
+ <DownloadIcon /> PDF summary
35
+ </>
36
+ )}
37
+ </button>
38
+ <button
39
+ type="button"
40
+ disabled={Boolean(busy)}
41
+ onClick={() => save('detailed')}
42
+ >
43
+ {busy === 'detailed' ? (
44
+ 'Preparing…'
45
+ ) : (
46
+ <>
47
+ <DownloadIcon /> PDF detailed report
48
+ </>
49
+ )}
50
+ </button>
51
+ {error && (
52
+ <span className="warn" role="alert">
53
+ {error}
54
+ </span>
55
+ )}
56
+ </span>
57
+ );
58
+ }
59
+
60
+ /**
61
+ * Compare tab: pick the recordings taken before and after a commit and see what
62
+ * improved, degraded or stayed the same. All numbers come from shared/compare.js.
63
+ */
64
+ export default function ComparePanel({ compare, savedReports, hideVendor }) {
65
+ const {
66
+ sides,
67
+ comparison,
68
+ addFiles,
69
+ addSaved,
70
+ removeRun,
71
+ setLabel,
72
+ lookupCommit,
73
+ swap,
74
+ reset,
75
+ } = compare;
76
+ const picker = (name, title, hint) => (
77
+ <SidePicker
78
+ title={title}
79
+ hint={hint}
80
+ side={sides[name]}
81
+ saved={savedReports}
82
+ onFiles={files => addFiles(name, files)}
83
+ onSaved={id => addSaved(name, id)}
84
+ onRemove={id => removeRun(name, id)}
85
+ onLabel={label => setLabel(name, label)}
86
+ onLookup={hash => lookupCommit(name, hash)}
87
+ />
88
+ );
89
+ return (
90
+ <div className="compare-panel">
91
+ <div className="compare-intro">
92
+ <h2>Compare two commits</h2>
93
+ <p className="muted">
94
+ Record the same scenario on the base commit and on the commit under
95
+ review (<b>3 times each</b> is best), use{' '}
96
+ <b>Snapshot for comparison</b> to save each recording, then upload
97
+ them here. A change counts only if it is bigger than the noise between
98
+ runs.
99
+ </p>
100
+ </div>
101
+ <div className="sides">
102
+ {picker('before', 'Before', '· base commit')}
103
+ <div className="swap">
104
+ <button
105
+ type="button"
106
+ onClick={swap}
107
+ title="Swap before and after"
108
+ aria-label="Swap before and after"
109
+ >
110
+ ⇄
111
+ </button>
112
+ </div>
113
+ {picker('after', 'After', '· commit under review')}
114
+ </div>
115
+ {comparison ? (
116
+ <>
117
+ <div className="compare-toolbar">
118
+ <span className="commit-pair">
119
+ <code>{comparison.before.git?.shortHash || 'before'}</code>
120
+ {comparison.before.git?.author && (
121
+ <span className="muted"> ({comparison.before.git.author})</span>
122
+ )}{' '}
123
+ → <code>{comparison.after.git?.shortHash || 'after'}</code>
124
+ {comparison.after.git?.author && (
125
+ <span className="muted"> ({comparison.after.git.author})</span>
126
+ )}
127
+ </span>
128
+ <span className="spacer" />
129
+ <PdfButtons comparison={comparison} />
130
+ <button type="button" onClick={reset}>
131
+ Clear
132
+ </button>
133
+ </div>
134
+ <CompareResults comparison={comparison} hideVendor={hideVendor} />
135
+ </>
136
+ ) : (
137
+ <p className="empty">
138
+ Add at least one recording on each side to see the comparison.
139
+ </p>
140
+ )}
141
+ </div>
142
+ );
143
+ }
@@ -0,0 +1,473 @@
1
+ import { useState } from 'react';
2
+ import { formatDelta, formatPct, formatValue } from '../../shared/compare.js';
3
+ import CodeRef from './CodeRef.jsx';
4
+
5
+ const VERDICT = {
6
+ improved: { icon: '✓', word: 'Improved', cls: 'good' },
7
+ degraded: { icon: '✕', word: 'Degraded', cls: 'bad' },
8
+ same: { icon: '=', word: 'Unchanged', cls: 'same' },
9
+ 'n/a': { icon: '–', word: 'Not measured', cls: 'na' },
10
+ };
11
+
12
+ const OVERALL = {
13
+ improved: {
14
+ title: 'Improved',
15
+ cls: 'good',
16
+ sentence: 'Key metrics got better and none got significantly worse.',
17
+ },
18
+ regressed: {
19
+ title: 'Regressed',
20
+ cls: 'bad',
21
+ sentence: 'Key metrics got worse and none got significantly better.',
22
+ },
23
+ mixed: {
24
+ title: 'Mixed result',
25
+ cls: 'warn',
26
+ sentence: 'Some key metrics improved while others degraded.',
27
+ },
28
+ neutral: {
29
+ title: 'No significant change',
30
+ cls: 'same',
31
+ sentence: 'No key metric moved beyond the noise thresholds.',
32
+ },
33
+ };
34
+
35
+ const CONFIDENCE = {
36
+ high: 'High confidence: comparable recordings, repeated runs.',
37
+ medium: 'Medium confidence: see the comparability notes.',
38
+ low: 'Low confidence: the recordings are not cleanly comparable.',
39
+ };
40
+
41
+ const CHECK_ICON = {
42
+ ok: ['✓', 'good'],
43
+ warn: ['!', 'warn'],
44
+ bad: ['✕', 'bad'],
45
+ };
46
+
47
+ function Chip({ verdict }) {
48
+ const v = VERDICT[verdict];
49
+ return (
50
+ <span className={`verdict ${v.cls}`}>
51
+ <span aria-hidden="true">{v.icon}</span> {v.word}
52
+ </span>
53
+ );
54
+ }
55
+
56
+ /** Diverging bar: left of the axis = lower (better), right = higher (worse); capped at ±100 %. */
57
+ function ChangeBar({ metric }) {
58
+ if (metric.verdict === 'n/a') return null;
59
+ const half = 60;
60
+ const frac = metric.pct === null ? 1 : Math.min(Math.abs(metric.pct), 1);
61
+ const len = Math.max(frac * (half - 2), metric.verdict === 'same' ? 1 : 3);
62
+ const x = metric.delta < 0 ? half - len : half;
63
+ return (
64
+ <svg
65
+ className={`change-bar ${VERDICT[metric.verdict].cls}`}
66
+ width={half * 2}
67
+ height="12"
68
+ role="img"
69
+ aria-label={formatPct(metric.pct)}
70
+ >
71
+ <line x1={half} x2={half} y1="0" y2="12" className="axis" />
72
+ <rect x={x} y="3" width={len} height="6" rx="2" />
73
+ </svg>
74
+ );
75
+ }
76
+
77
+ function Verdict({ comparison }) {
78
+ const o = OVERALL[comparison.verdict];
79
+ const { improved, degraded, same, unavailable } = comparison.counts;
80
+ return (
81
+ <div className={`verdict-banner ${o.cls}`} role="status">
82
+ <div className="big">{o.title}</div>
83
+ <p>
84
+ {o.sentence} {CONFIDENCE[comparison.confidence]}
85
+ </p>
86
+ <p className="counts">
87
+ <span className="good">✓ {improved} improved</span>
88
+ <span className="bad">✕ {degraded} degraded</span>
89
+ <span className="same">= {same} unchanged</span>
90
+ {unavailable > 0 && (
91
+ <span className="muted">{unavailable} not measured</span>
92
+ )}
93
+ </p>
94
+ </div>
95
+ );
96
+ }
97
+
98
+ function Checks({ checks }) {
99
+ return (
100
+ <ul className="checks">
101
+ {checks.map(c => (
102
+ <li key={c.id} className={CHECK_ICON[c.level][1]}>
103
+ <span className="i" aria-hidden="true">
104
+ {CHECK_ICON[c.level][0]}
105
+ </span>
106
+ <span>
107
+ <b>{c.title}.</b> {c.detail}
108
+ </span>
109
+ </li>
110
+ ))}
111
+ </ul>
112
+ );
113
+ }
114
+
115
+ function MetricsTable({ metrics }) {
116
+ const [filter, setFilter] = useState('all');
117
+ const shown = metrics.filter(m => filter === 'all' || m.verdict === filter);
118
+ const counts = v => metrics.filter(m => m.verdict === v).length;
119
+ const filters = [
120
+ ['all', `All (${metrics.length})`],
121
+ ['improved', `✓ Improved (${counts('improved')})`],
122
+ ['degraded', `✕ Degraded (${counts('degraded')})`],
123
+ ['same', `= Unchanged (${counts('same')})`],
124
+ ];
125
+ let group = null;
126
+ return (
127
+ <section>
128
+ <h2>Metrics</h2>
129
+ <div className="segmented" role="group" aria-label="Filter metrics">
130
+ {filters.map(([id, label]) => (
131
+ <button
132
+ key={id}
133
+ type="button"
134
+ aria-pressed={filter === id}
135
+ onClick={() => setFilter(id)}
136
+ >
137
+ {label}
138
+ </button>
139
+ ))}
140
+ </div>
141
+ <table className="data compare-table">
142
+ <thead>
143
+ <tr>
144
+ <th>Metric</th>
145
+ <th className="num">Before</th>
146
+ <th className="num">After</th>
147
+ <th className="num">Change</th>
148
+ <th aria-label="Change as a bar" />
149
+ <th>Result</th>
150
+ </tr>
151
+ </thead>
152
+ <tbody>
153
+ {shown.map(m => {
154
+ const header =
155
+ m.group !== group
156
+ ? ((group = m.group),
157
+ (
158
+ <tr key={`g-${m.group}`} className="group-row">
159
+ <td colSpan={6}>{m.group}</td>
160
+ </tr>
161
+ ))
162
+ : null;
163
+ return [
164
+ header,
165
+ <tr
166
+ key={m.id}
167
+ className={
168
+ m.verdict === 'same' || m.verdict === 'n/a' ? 'dim' : ''
169
+ }
170
+ >
171
+ <td>
172
+ {m.label}{' '}
173
+ {m.key && (
174
+ <span
175
+ className="chip"
176
+ title="Counts toward the overall verdict"
177
+ >
178
+ key
179
+ </span>
180
+ )}
181
+ {(m.before.n > 1 || m.after.n > 1) && (
182
+ <div className="muted small">
183
+ range {formatValue(m.unit, m.before.min)}–
184
+ {formatValue(m.unit, m.before.max)} →{' '}
185
+ {formatValue(m.unit, m.after.min)}–
186
+ {formatValue(m.unit, m.after.max)}
187
+ </div>
188
+ )}
189
+ </td>
190
+ <td className="num">{formatValue(m.unit, m.before.value)}</td>
191
+ <td className="num">{formatValue(m.unit, m.after.value)}</td>
192
+ <td className="num">
193
+ {m.verdict === 'n/a'
194
+ ? '–'
195
+ : `${formatDelta(m.unit, m.delta)} (${formatPct(m.pct)})`}
196
+ </td>
197
+ <td>
198
+ <ChangeBar metric={m} />
199
+ </td>
200
+ <td>
201
+ <Chip verdict={m.verdict} />
202
+ </td>
203
+ </tr>,
204
+ ];
205
+ })}
206
+ </tbody>
207
+ </table>
208
+ {!shown.length && <p className="muted">Nothing in this category.</p>}
209
+ </section>
210
+ );
211
+ }
212
+
213
+ function EntityList({ title, tone, list, total, hideVendor, defaultOpen }) {
214
+ const rows = hideVendor ? list.filter(e => !e.vendor) : list;
215
+ if (!rows.length) return null;
216
+ return (
217
+ <details className={`entity-group ${tone}`} open={defaultOpen}>
218
+ <summary>
219
+ {title}{' '}
220
+ <span className="muted">({hideVendor ? rows.length : total})</span>
221
+ </summary>
222
+ <table className="data">
223
+ <tbody>
224
+ {rows.map(e => (
225
+ <tr key={e.key}>
226
+ <td className="entity-name">
227
+ <b>
228
+ {e.name === e.file ? (
229
+ <span className="mono">{e.name}</span>
230
+ ) : (
231
+ e.name
232
+ )}
233
+ </b>
234
+ {e.vendor && <span className="chip">library</span>}
235
+ {e.name !== e.file && e.file && (
236
+ <CodeRef
237
+ file={e.file}
238
+ line={e.line}
239
+ lineKind={e.lineKind}
240
+ abs={e.abs}
241
+ />
242
+ )}
243
+ </td>
244
+ <td>
245
+ {e.changes.map(c => (
246
+ <div
247
+ key={c.id}
248
+ className={
249
+ c.verdict === 'same' ? 'muted' : VERDICT[c.verdict].cls
250
+ }
251
+ >
252
+ {c.verdict !== 'same' && (
253
+ <b aria-hidden="true">{VERDICT[c.verdict].icon} </b>
254
+ )}
255
+ {c.label}: {formatValue(c.unit, c.before)} →{' '}
256
+ {formatValue(c.unit, c.after)}
257
+ {c.verdict !== 'same' && ` (${formatPct(c.pct)})`}
258
+ </div>
259
+ ))}
260
+ </td>
261
+ </tr>
262
+ ))}
263
+ </tbody>
264
+ </table>
265
+ </details>
266
+ );
267
+ }
268
+
269
+ function Entities({ data, hideVendor }) {
270
+ const group = (title, bucket, tone, open) => (
271
+ <EntityList
272
+ key={bucket}
273
+ title={title}
274
+ tone={tone}
275
+ list={data[bucket]}
276
+ total={data[`${bucket}Total`]}
277
+ hideVendor={hideVendor}
278
+ defaultOpen={open}
279
+ />
280
+ );
281
+ return (
282
+ <>
283
+ <p className="muted">
284
+ {data.compared} compared · {data.unchanged} unchanged
285
+ </p>
286
+ {group('Degraded', 'degraded', 'bad', true)}
287
+ {group('Mixed (some metrics better, some worse)', 'mixed', 'warn', true)}
288
+ {group('Improved', 'improved', 'good', true)}
289
+ {group('Newly active (new cost)', 'added', 'bad', false)}
290
+ {group('No longer active (cost gone)', 'removed', 'good', false)}
291
+ {!data.degradedTotal &&
292
+ !data.improvedTotal &&
293
+ !data.mixedTotal &&
294
+ !data.addedTotal &&
295
+ !data.removedTotal && (
296
+ <p className="muted">Nothing changed beyond the thresholds.</p>
297
+ )}
298
+ </>
299
+ );
300
+ }
301
+
302
+ function FindingCard({ finding, tone }) {
303
+ const sev = { critical: 'bad', warning: 'warn', info: 'info' }[
304
+ finding.severity
305
+ ];
306
+ return (
307
+ <li className={`finding-card ${tone || sev}`}>
308
+ <div>
309
+ <span className={`sev ${sev}`}>{finding.severity.toUpperCase()}</span>{' '}
310
+ <span className="chip">{finding.category}</span>
311
+ </div>
312
+ <h4>{finding.title}</h4>
313
+ {finding.culprits?.map(c => (
314
+ <div key={`${c.fn}${c.file}`} className="mono">
315
+ {c.fn}() in{' '}
316
+ <CodeRef
317
+ file={c.file}
318
+ line={c.line}
319
+ lineKind={c.lineKind}
320
+ abs={c.abs}
321
+ />
322
+ </div>
323
+ ))}
324
+ <p className="fix">
325
+ <b>Fix:</b> {finding.fix}
326
+ </p>
327
+ </li>
328
+ );
329
+ }
330
+
331
+ function Findings({ findings }) {
332
+ const block = (title, list, tone, empty) => (
333
+ <section>
334
+ <h3>
335
+ {title} <span className="muted">({list.length})</span>
336
+ </h3>
337
+ {list.length ? (
338
+ <ul className="finding-cards">
339
+ {list.map(f => (
340
+ <FindingCard key={f.id} finding={f} tone={tone} />
341
+ ))}
342
+ </ul>
343
+ ) : (
344
+ <p className="muted">{empty}</p>
345
+ )}
346
+ </section>
347
+ );
348
+ return (
349
+ <>
350
+ {block(
351
+ 'New problems introduced',
352
+ findings.added,
353
+ 'bad',
354
+ 'No new problems.',
355
+ )}
356
+ {block(
357
+ 'Problems resolved',
358
+ findings.resolved,
359
+ 'good',
360
+ 'No problems were resolved.',
361
+ )}
362
+ <section>
363
+ <h3>
364
+ Problems that persist{' '}
365
+ <span className="muted">({findings.persisting.length})</span>
366
+ </h3>
367
+ {findings.persisting.length ? (
368
+ <table className="data">
369
+ <thead>
370
+ <tr>
371
+ <th>Issue</th>
372
+ <th className="num">Impact before</th>
373
+ <th className="num">Impact after</th>
374
+ </tr>
375
+ </thead>
376
+ <tbody>
377
+ {findings.persisting.map(f => (
378
+ <tr key={f.id}>
379
+ <td>
380
+ <span
381
+ className={`sev ${{ critical: 'bad', warning: 'warn', info: 'info' }[f.severity]}`}
382
+ >
383
+ {f.severity}
384
+ </span>{' '}
385
+ · {f.title}
386
+ </td>
387
+ <td className="num">{formatValue('ms', f.beforeImpactMs)}</td>
388
+ <td className="num">{formatValue('ms', f.impactMs)}</td>
389
+ </tr>
390
+ ))}
391
+ </tbody>
392
+ </table>
393
+ ) : (
394
+ <p className="muted">None.</p>
395
+ )}
396
+ </section>
397
+ </>
398
+ );
399
+ }
400
+
401
+ const DETAIL_TABS = [
402
+ ['components', 'React components'],
403
+ ['files', 'Files'],
404
+ ['functions', 'Functions'],
405
+ ['findings', 'Problems'],
406
+ ];
407
+
408
+ export default function CompareResults({ comparison, hideVendor }) {
409
+ const [detail, setDetail] = useState('components');
410
+ const badge = id => {
411
+ if (id === 'findings')
412
+ return (
413
+ comparison.findings.added.length + comparison.findings.resolved.length
414
+ );
415
+ const d = comparison[id];
416
+ return (
417
+ d.degradedTotal +
418
+ d.improvedTotal +
419
+ d.mixedTotal +
420
+ d.addedTotal +
421
+ d.removedTotal
422
+ );
423
+ };
424
+ return (
425
+ <div className="compare-results">
426
+ <Verdict comparison={comparison} />
427
+ {comparison.headlines.length > 0 && (
428
+ <section>
429
+ <h2>Highlights</h2>
430
+ <ul className="icons">
431
+ {comparison.headlines.map(h => (
432
+ <li key={h.text} className={VERDICT[h.kind].cls}>
433
+ <span className="i" aria-hidden="true">
434
+ {VERDICT[h.kind].icon}
435
+ </span>
436
+ {h.text}
437
+ </li>
438
+ ))}
439
+ </ul>
440
+ </section>
441
+ )}
442
+ <section>
443
+ <h2>Can this comparison be trusted?</h2>
444
+ <Checks checks={comparison.checks} />
445
+ </section>
446
+ <MetricsTable metrics={comparison.metrics} />
447
+ <section>
448
+ <h2>What changed, in detail</h2>
449
+ <div role="tablist" className="tabs">
450
+ {DETAIL_TABS.map(([id, label]) => (
451
+ <button
452
+ key={id}
453
+ type="button"
454
+ role="tab"
455
+ aria-selected={detail === id}
456
+ onClick={() => setDetail(id)}
457
+ >
458
+ {label}
459
+ <span className="count">{badge(id)}</span>
460
+ </button>
461
+ ))}
462
+ </div>
463
+ <div className="detail-pane">
464
+ {detail === 'findings' ? (
465
+ <Findings findings={comparison.findings} />
466
+ ) : (
467
+ <Entities data={comparison[detail]} hideVendor={hideVendor} />
468
+ )}
469
+ </div>
470
+ </section>
471
+ </div>
472
+ );
473
+ }