@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
package/server/git.js ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Commit metadata for labelling recordings, so two snapshots can be compared as
3
+ * "before this commit / after this commit". Only the author's *name* is read
4
+ * (never the email), and only commit metadata: no file contents or diffs.
5
+ */
6
+ import { execFileSync } from 'node:child_process';
7
+
8
+ const FIELD = '\x1f';
9
+ const FORMAT = ['%H', '%h', '%an', '%aI', '%s'].join('%x1f');
10
+ // A hash (4-40 hex) or HEAD: never a free-form ref, so nothing here can look like a git option.
11
+ const REV = /^(?:HEAD|[0-9a-fA-F]{4,40})$/;
12
+
13
+ const git = (args, cwd) =>
14
+ execFileSync('git', args, {
15
+ cwd,
16
+ encoding: 'utf8',
17
+ stdio: ['ignore', 'pipe', 'ignore'],
18
+ maxBuffer: 1024 * 1024,
19
+ }).trim();
20
+
21
+ export function isValidRev(rev) {
22
+ return typeof rev === 'string' && REV.test(rev);
23
+ }
24
+
25
+ /**
26
+ * @param {string} cwd a directory inside the repository
27
+ * @param {string} [rev] 'HEAD' or a commit hash (full or abbreviated)
28
+ * @returns {{hash: string, shortHash: string, author: string, date: string,
29
+ * subject: string, branch: string|null, dirty: boolean|null} | null}
30
+ */
31
+ export function gitInfo(cwd, rev = 'HEAD') {
32
+ if (!isValidRev(rev)) return null;
33
+ try {
34
+ const [hash, shortHash, author, date, subject] = git(
35
+ ['log', '-1', `--format=${FORMAT}`, rev, '--'],
36
+ cwd,
37
+ ).split(FIELD);
38
+ if (!hash) return null;
39
+ const isHead = rev === 'HEAD';
40
+ return {
41
+ hash,
42
+ shortHash,
43
+ author,
44
+ date,
45
+ subject,
46
+ branch: isHead ? git(['rev-parse', '--abbrev-ref', 'HEAD'], cwd) : null,
47
+ // Uncommitted edits to tracked frontend sources mean the hash does not describe what was measured.
48
+ // The profiler's own folder is excluded: it is a dev tool, not the code being profiled.
49
+ dirty: isHead
50
+ ? git(
51
+ ['status', '--porcelain', '-uno', '--', '.', ':(exclude)tools'],
52
+ cwd,
53
+ ).length > 0
54
+ : null,
55
+ };
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * HTML -> PDF with the headless Chrome this tool already depends on. A throwaway
3
+ * profile directory is used so it never collides with the Chrome window the
4
+ * profiler itself launched (they would fight over one profile lock).
5
+ */
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import puppeteer from 'puppeteer-core';
10
+ import { findChrome } from '../chrome.js';
11
+ import { footerTemplate, renderComparisonHtml } from './template.js';
12
+
13
+ /**
14
+ * @param {object} comparison result of compareSides()
15
+ * @param {'summary'|'detailed'} kind
16
+ * @returns {Promise<Buffer>}
17
+ */
18
+ export async function renderComparisonPdf(comparison, kind) {
19
+ const executablePath = findChrome();
20
+ if (!executablePath) {
21
+ throw new Error(
22
+ 'Chrome was not found, and it is needed to render the PDF. Set CHROME_PATH to its executable.',
23
+ );
24
+ }
25
+ const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'perf-pdf-'));
26
+ const browser = await puppeteer.launch({
27
+ executablePath,
28
+ headless: 'new',
29
+ userDataDir: profile,
30
+ args: ['--no-first-run', '--no-default-browser-check'],
31
+ });
32
+ try {
33
+ const page = await browser.newPage();
34
+ await page.setContent(renderComparisonHtml(comparison, kind), {
35
+ waitUntil: 'load',
36
+ });
37
+ const pdf = await page.pdf({
38
+ format: 'A4',
39
+ printBackground: true,
40
+ displayHeaderFooter: true,
41
+ headerTemplate: '<span></span>',
42
+ footerTemplate: footerTemplate(comparison, kind),
43
+ margin: { top: '16mm', bottom: '18mm', left: '14mm', right: '14mm' },
44
+ });
45
+ return Buffer.from(pdf);
46
+ } finally {
47
+ await browser.close();
48
+ fs.rmSync(profile, { recursive: true, force: true });
49
+ }
50
+ }
51
+
52
+ export function pdfFileName(comparison, kind) {
53
+ const clean = value =>
54
+ String(value || '')
55
+ .replace(/[^\w-]/g, '')
56
+ .slice(0, 12);
57
+ const b = clean(comparison.before.git?.shortHash) || 'before';
58
+ const a = clean(comparison.after.git?.shortHash) || 'after';
59
+ return `perf-compare_${b}_${a}_${kind}.pdf`;
60
+ }
@@ -0,0 +1,419 @@
1
+ /**
2
+ * Print-ready HTML for a commit-to-commit performance comparison.
3
+ *
4
+ * Two variants of the same document:
5
+ * summary 2-3 pages for a PR description or a reviewer glancing at it
6
+ * detailed everything: every metric, component, file, function, finding,
7
+ * the run inventory and the methodology
8
+ *
9
+ * The input is the object produced by shared/compare.js, so the PDF is
10
+ * guaranteed to say what the Compare tab says. Every dynamic string is escaped:
11
+ * names and URLs come from the profiled app.
12
+ */
13
+ import {
14
+ METRICS,
15
+ formatDelta,
16
+ formatPct,
17
+ formatValue,
18
+ } from '../../shared/compare.js';
19
+ import { TARGET_NAME } from '../config.js';
20
+
21
+ const esc = value =>
22
+ String(value ?? '')
23
+ .replace(/&/g, '&amp;')
24
+ .replace(/</g, '&lt;')
25
+ .replace(/>/g, '&gt;')
26
+ .replace(/"/g, '&quot;');
27
+
28
+ const VERDICT = {
29
+ improved: { icon: '✓', word: 'Improved', cls: 'good' },
30
+ degraded: { icon: '✕', word: 'Degraded', cls: 'bad' },
31
+ same: { icon: '=', word: 'Unchanged', cls: 'same' },
32
+ 'n/a': { icon: '–', word: 'n/a', cls: 'na' },
33
+ mixed: { icon: '±', word: 'Mixed', cls: 'warn' },
34
+ };
35
+
36
+ const OVERALL = {
37
+ improved: {
38
+ title: 'Improved',
39
+ cls: 'good',
40
+ sentence: 'Key metrics got better and none got significantly worse.',
41
+ },
42
+ regressed: {
43
+ title: 'Regressed',
44
+ cls: 'bad',
45
+ sentence: 'Key metrics got worse and none got significantly better.',
46
+ },
47
+ mixed: {
48
+ title: 'Mixed result',
49
+ cls: 'warn',
50
+ sentence: 'Some key metrics improved while others degraded.',
51
+ },
52
+ neutral: {
53
+ title: 'No significant change',
54
+ cls: 'same',
55
+ sentence: 'No key metric moved beyond the noise thresholds.',
56
+ },
57
+ };
58
+
59
+ const CONFIDENCE = {
60
+ high: 'High confidence: comparable recordings, repeated runs.',
61
+ medium: 'Medium confidence: see the comparability notes.',
62
+ low: 'Low confidence: the recordings are not cleanly comparable (see below).',
63
+ };
64
+
65
+ const CSS = `
66
+ @page { size: A4; }
67
+ * { box-sizing: border-box; }
68
+ html { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
69
+ body { margin: 0; font: 9.5pt/1.45 -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; color: #15150f; }
70
+ h1 { font-size: 20pt; margin: 0; }
71
+ h2 { font-size: 12.5pt; margin: 18pt 0 6pt; padding-bottom: 3pt; border-bottom: 1.5pt solid #15150f; break-after: avoid; }
72
+ h3 { font-size: 10.5pt; margin: 12pt 0 4pt; break-after: avoid; }
73
+ p { margin: 3pt 0; }
74
+ code, .mono { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 8.5pt; }
75
+ .muted { color: #5b5a52; }
76
+ .sub { color: #5b5a52; margin-top: 2pt; }
77
+ .good { --c: #1a7f37; } .bad { --c: #c62828; } .warn { --c: #b45309; } .same { --c: #6b6a62; } .na { --c: #6b6a62; } .info { --c: #1f5fb4; }
78
+ .cls-text { color: var(--c); font-weight: 600; }
79
+ .pair { display: grid; grid-template-columns: 1fr 1fr; gap: 10pt; margin-top: 10pt; }
80
+ .commit { border: 1pt solid #d7d6cf; border-radius: 5pt; padding: 8pt 10pt; break-inside: avoid; background: #f7f7f4; }
81
+ .commit .role { font-size: 8pt; text-transform: uppercase; letter-spacing: .08em; color: #5b5a52; }
82
+ .commit .hash { font: 700 13pt ui-monospace, Menlo, monospace; margin: 1pt 0 3pt; }
83
+ .commit dl { margin: 4pt 0 0; display: grid; grid-template-columns: 52pt 1fr; gap: 1pt 6pt; }
84
+ .commit dt { color: #5b5a52; } .commit dd { margin: 0; overflow-wrap: anywhere; }
85
+ .badge { display: inline-block; border: 1pt solid var(--c, #999); color: var(--c, #555); border-radius: 3pt; padding: 0 4pt; font-size: 8pt; font-weight: 600; }
86
+ .banner { margin-top: 12pt; border: 1.5pt solid var(--c); border-left-width: 6pt; border-radius: 5pt; padding: 9pt 12pt; break-inside: avoid; }
87
+ .banner .big { font-size: 17pt; font-weight: 700; color: var(--c); }
88
+ .counts span { margin-right: 14pt; font-weight: 600; }
89
+ ul.tight { margin: 3pt 0 3pt 14pt; padding: 0; } ul.tight li { margin: 2pt 0; }
90
+ ul.icons { list-style: none; margin: 4pt 0; padding: 0; } ul.icons li { margin: 3pt 0; padding-left: 15pt; text-indent: -15pt; }
91
+ ul.icons .i { display: inline-block; width: 15pt; text-indent: 0; font-weight: 700; color: var(--c); }
92
+ table { width: 100%; border-collapse: collapse; margin: 4pt 0; }
93
+ th { text-align: left; font-size: 8pt; text-transform: uppercase; letter-spacing: .04em; color: #5b5a52; border-bottom: 1pt solid #15150f; padding: 3pt 5pt; }
94
+ td { padding: 3.5pt 5pt; border-bottom: .5pt solid #d7d6cf; vertical-align: top; }
95
+ tr { break-inside: avoid; }
96
+ td.n, th.n { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
97
+ tr.group td { background: #efeee9; font-weight: 700; font-size: 8.5pt; text-transform: uppercase; letter-spacing: .05em; border-bottom: 0; }
98
+ .verdict { color: var(--c); font-weight: 700; white-space: nowrap; }
99
+ .dim td { color: #77766d; }
100
+ svg.bar { display: block; }
101
+ .sev { font-weight: 700; color: var(--c); }
102
+ .entity-changes div { white-space: nowrap; }
103
+ .card { border: 1pt solid #d7d6cf; border-left: 4pt solid var(--c, #999); border-radius: 4pt; padding: 6pt 9pt; margin: 6pt 0; break-inside: avoid; }
104
+ .card h4 { margin: 0 0 2pt; font-size: 10pt; }
105
+ .card p { color: #3b3a33; }
106
+ .callout { background: #f7f7f4; border: 1pt solid #d7d6cf; border-radius: 4pt; padding: 6pt 9pt; margin: 8pt 0; break-inside: avoid; }
107
+ .pagebreak { break-before: page; }
108
+ `;
109
+
110
+ // -------------------------------------------------------------------- pieces
111
+
112
+ function verdictCell(verdict) {
113
+ const v = VERDICT[verdict] || VERDICT['n/a'];
114
+ return `<span class="verdict ${v.cls}"><span aria-hidden="true">${v.icon}</span> ${v.word}</span>`;
115
+ }
116
+
117
+ /** Diverging bar of percentage change: left = lower (better), right = higher (worse), capped at ±100%. */
118
+ function changeBar(m) {
119
+ if (m.verdict === 'n/a') return '';
120
+ const width = 120;
121
+ const mid = width / 2;
122
+ const frac = m.pct === null ? 1 : Math.min(Math.abs(m.pct), 1);
123
+ const len =
124
+ m.verdict === 'same'
125
+ ? Math.max(frac * (mid - 4), 1)
126
+ : Math.max(frac * (mid - 4), 3);
127
+ const x = m.delta < 0 ? mid - len : mid;
128
+ const colour =
129
+ m.verdict === 'improved'
130
+ ? '#1a7f37'
131
+ : m.verdict === 'degraded'
132
+ ? '#c62828'
133
+ : '#9a998f';
134
+ return `<svg class="bar" width="${width}" height="11" viewBox="0 0 ${width} 11" role="img" aria-label="${esc(formatPct(m.pct))}">
135
+ <line x1="${mid}" x2="${mid}" y1="0" y2="11" stroke="#15150f" stroke-width="1"/>
136
+ <rect x="${x}" y="2" width="${len}" height="7" rx="1.5" fill="${colour}"/></svg>`;
137
+ }
138
+
139
+ function metricRow(m) {
140
+ const change =
141
+ m.verdict === 'n/a'
142
+ ? '–'
143
+ : `${esc(formatDelta(m.unit, m.delta))} <span class="muted">(${esc(formatPct(m.pct))})</span>`;
144
+ const runs =
145
+ m.before.n > 1 || m.after.n > 1
146
+ ? `<div class="muted" style="font-size:7.5pt">range ${esc(formatValue(m.unit, m.before.min))}–${esc(formatValue(m.unit, m.before.max))} → ${esc(formatValue(m.unit, m.after.min))}–${esc(formatValue(m.unit, m.after.max))}</div>`
147
+ : '';
148
+ return `<tr class="${m.verdict === 'same' || m.verdict === 'n/a' ? 'dim' : ''}">
149
+ <td>${esc(m.label)}${m.key ? ' <span class="badge info" title="key metric">key</span>' : ''}${runs}</td>
150
+ <td class="n">${esc(formatValue(m.unit, m.before.value))}</td>
151
+ <td class="n">${esc(formatValue(m.unit, m.after.value))}</td>
152
+ <td class="n">${change}</td>
153
+ <td>${changeBar(m)}</td>
154
+ <td>${verdictCell(m.verdict)}</td></tr>`;
155
+ }
156
+
157
+ const METRIC_HEAD = `<thead><tr><th>Metric</th><th class="n">Before</th><th class="n">After</th><th class="n">Change</th><th>Δ%</th><th>Result</th></tr></thead>`;
158
+
159
+ function metricsTable(metrics, { grouped }) {
160
+ let rows = '';
161
+ let group = null;
162
+ for (const m of metrics) {
163
+ if (grouped && m.group !== group) {
164
+ group = m.group;
165
+ rows += `<tr class="group"><td colspan="6">${esc(group)}</td></tr>`;
166
+ }
167
+ rows += metricRow(m);
168
+ }
169
+ return `<table>${METRIC_HEAD}<tbody>${rows}</tbody></table>`;
170
+ }
171
+
172
+ function commitCard(role, side) {
173
+ const git = side.git;
174
+ const run = side.runs[0];
175
+ const date = git?.date
176
+ ? new Date(git.date).toLocaleString('en-GB', {
177
+ dateStyle: 'medium',
178
+ timeStyle: 'short',
179
+ })
180
+ : '';
181
+ const rows = [
182
+ ['Author', git?.author || 'unknown'],
183
+ ['Date', date || 'unknown'],
184
+ ['Message', git?.subject || '–'],
185
+ git?.branch ? ['Branch', git.branch] : null,
186
+ ['Scenario', side.label || run?.title || '–'],
187
+ [
188
+ 'Runs',
189
+ `${side.runs.length} (${run?.mode === 'load' ? 'page load' : 'interaction'}${run?.cpuThrottle > 1 ? `, ${run.cpuThrottle}× CPU slowdown` : ''})`,
190
+ ],
191
+ ].filter(Boolean);
192
+ return `<div class="commit"><div class="role">${esc(role)}</div>
193
+ <div class="hash">${esc(git?.shortHash || (git?.hash || '').slice(0, 10) || 'no commit')}${git?.dirty ? ' <span class="badge warn">uncommitted changes</span>' : ''}</div>
194
+ <dl>${rows.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join('')}</dl></div>`;
195
+ }
196
+
197
+ function verdictBanner(cmp) {
198
+ const o = OVERALL[cmp.verdict];
199
+ const { improved, degraded, same, unavailable } = cmp.counts;
200
+ return `<div class="banner ${o.cls}"><div class="big">${esc(o.title)}</div>
201
+ <p>${esc(o.sentence)} ${esc(CONFIDENCE[cmp.confidence])}</p>
202
+ <p class="counts"><span class="good cls-text">✓ ${improved} improved</span><span class="bad cls-text">✕ ${degraded} degraded</span><span class="same cls-text">= ${same} unchanged</span>${unavailable ? `<span class="muted">${unavailable} not measured</span>` : ''}</p></div>`;
203
+ }
204
+
205
+ function headlines(cmp) {
206
+ if (!cmp.headlines.length)
207
+ return '<p class="muted">No metric moved beyond the noise thresholds.</p>';
208
+ const item = h => {
209
+ const v = VERDICT[h.kind];
210
+ return `<li class="${v.cls}"><span class="i">${v.icon}</span>${esc(h.text)}</li>`;
211
+ };
212
+ return `<ul class="icons">${cmp.headlines.map(item).join('')}</ul>`;
213
+ }
214
+
215
+ function checksList(cmp, { onlyProblems }) {
216
+ const icon = { ok: ['✓', 'good'], warn: ['!', 'warn'], bad: ['✕', 'bad'] };
217
+ const list = onlyProblems
218
+ ? cmp.checks.filter(c => c.level !== 'ok')
219
+ : cmp.checks;
220
+ const okCount =
221
+ cmp.checks.length - cmp.checks.filter(c => c.level !== 'ok').length;
222
+ const intro =
223
+ onlyProblems && okCount
224
+ ? `<p class="muted">${okCount} of ${cmp.checks.length} comparability checks passed.</p>`
225
+ : '';
226
+ if (!list.length)
227
+ return intro || '<p class="muted">All comparability checks passed.</p>';
228
+ return `${intro}<ul class="icons">${list.map(c => `<li class="${icon[c.level][1]}"><span class="i">${icon[c.level][0]}</span><b>${esc(c.title)}.</b> ${esc(c.detail)}</li>`).join('')}</ul>`;
229
+ }
230
+
231
+ function whereLine(e) {
232
+ if (!e.file) return '';
233
+ return `<div class="muted mono">${esc(e.file)}${e.line ? `:${e.lineKind === 'located' ? '≈' : ''}${e.line}` : ''}</div>`;
234
+ }
235
+
236
+ function changesCell(e) {
237
+ return `<div class="entity-changes">${e.changes
238
+ .map(c => {
239
+ const v = VERDICT[c.verdict];
240
+ return `<div class="${c.verdict === 'same' ? 'muted' : v.cls}">${c.verdict === 'same' ? '' : `<b>${v.icon}</b> `}${esc(c.label)}: ${esc(formatValue(c.unit, c.before))} → ${esc(formatValue(c.unit, c.after))}${c.verdict === 'same' ? '' : ` (${esc(formatPct(c.pct))})`}</div>`;
241
+ })
242
+ .join('')}</div>`;
243
+ }
244
+
245
+ function entityTable(title, list, total, { empty }) {
246
+ if (!list.length)
247
+ return empty
248
+ ? `<h3>${esc(title)}</h3><p class="muted">${esc(empty)}</p>`
249
+ : '';
250
+ const more =
251
+ total > list.length
252
+ ? `<p class="muted">Showing the ${list.length} largest of ${total}.</p>`
253
+ : '';
254
+ return `<h3>${esc(title)} <span class="muted">(${total})</span></h3>
255
+ <table><thead><tr><th>Name</th><th>What changed</th></tr></thead><tbody>
256
+ ${list.map(e => `<tr><td><b>${esc(e.name)}</b>${e.vendor ? ' <span class="badge same">library</span>' : ''}${e.name === e.file ? '' : whereLine(e)}</td><td>${changesCell(e)}</td></tr>`).join('')}
257
+ </tbody></table>${more}`;
258
+ }
259
+
260
+ function entitySection(title, key, cmp, { vendor }) {
261
+ const data = cmp[key];
262
+ const keep = list => (vendor ? list : list.filter(e => !e.vendor));
263
+ const build = (name, bucket, emptyText) => {
264
+ const list = keep(data[bucket]);
265
+ return entityTable(
266
+ name,
267
+ list,
268
+ vendor ? data[`${bucket}Total`] : list.length,
269
+ { empty: emptyText },
270
+ );
271
+ };
272
+ return `<h2>${esc(title)}</h2>
273
+ <p class="muted">${data.compared} compared · ${data.unchanged} unchanged${vendor ? '' : ' · library code (node_modules) omitted'}</p>
274
+ ${build('Degraded', 'degraded', 'None degraded.')}
275
+ ${build('Mixed (some metrics better, some worse)', 'mixed', '')}
276
+ ${build('Improved', 'improved', 'None improved.')}
277
+ ${build('Newly active in the “after” recording (new cost)', 'added', '')}
278
+ ${build('No longer active in the “after” recording (cost gone)', 'removed', '')}`;
279
+ }
280
+
281
+ function culpritLine(c) {
282
+ return `<code>${esc(c.fn)}()</code> in <code>${esc(c.file || 'unknown')}${c.line ? `:${c.lineKind === 'located' ? '≈' : ''}${c.line}` : ''}</code>`;
283
+ }
284
+
285
+ function findingCard(f, tone) {
286
+ const sev = { critical: 'bad', warning: 'warn', info: 'info' }[f.severity];
287
+ return `<div class="card ${tone || sev}"><h4><span class="sev ${sev}">${esc(f.severity.toUpperCase())}</span> · ${esc(f.category)} — ${esc(f.title)}</h4>
288
+ ${f.culprits?.length ? `<p>${f.culprits.map(culpritLine).join('<br>')}</p>` : ''}
289
+ <p><b>Fix:</b> ${esc(f.fix || '–')}</p></div>`;
290
+ }
291
+
292
+ function findingsSection(cmp, { limit }) {
293
+ const { added, resolved, persisting } = cmp.findings;
294
+ const cap = list => (limit ? list.slice(0, limit) : list);
295
+ const group = (title, list, tone, empty) =>
296
+ `<h3>${esc(title)} <span class="muted">(${list.length})</span></h3>${
297
+ list.length
298
+ ? cap(list)
299
+ .map(f => findingCard(f, tone))
300
+ .join('')
301
+ : `<p class="muted">${esc(empty)}</p>`
302
+ }${limit && list.length > limit ? `<p class="muted">… and ${list.length - limit} more in the detailed report.</p>` : ''}`;
303
+ const persistingTable = persisting.length
304
+ ? `<table><thead><tr><th>Issue</th><th class="n">Impact before</th><th class="n">Impact after</th></tr></thead><tbody>${cap(
305
+ persisting,
306
+ )
307
+ .map(
308
+ f =>
309
+ `<tr><td><span class="sev ${{ critical: 'bad', warning: 'warn', info: 'info' }[f.severity]}">${esc(f.severity)}</span> · ${esc(f.title)}</td><td class="n">${esc(formatValue('ms', f.beforeImpactMs))}</td><td class="n">${esc(formatValue('ms', f.impactMs))}</td></tr>`,
310
+ )
311
+ .join('')}</tbody></table>`
312
+ : '<p class="muted">None.</p>';
313
+ return `${group('New problems introduced', added, 'bad', 'No new problems.')}
314
+ ${group('Problems resolved', resolved, 'good', 'No problems were resolved.')}
315
+ <h3>Problems that persist <span class="muted">(${persisting.length})</span></h3>${persistingTable}`;
316
+ }
317
+
318
+ function topChanges(cmp) {
319
+ const rows = [];
320
+ for (const [kind, label] of [
321
+ ['components', 'Component'],
322
+ ['files', 'File'],
323
+ ]) {
324
+ for (const bucket of ['degraded', 'improved']) {
325
+ cmp[kind][bucket]
326
+ .filter(e => !e.vendor)
327
+ .slice(0, 4)
328
+ .forEach(e => rows.push({ label, bucket, e }));
329
+ }
330
+ }
331
+ if (!rows.length)
332
+ return '<p class="muted">No application component or file changed beyond the thresholds.</p>';
333
+ return `<table><thead><tr><th>Where</th><th>What changed</th><th>Result</th></tr></thead><tbody>${rows
334
+ .map(
335
+ ({ label, bucket, e }) =>
336
+ `<tr><td><span class="muted">${label}</span> <b>${esc(e.name)}</b>${e.name === e.file ? '' : whereLine(e)}</td><td>${changesCell(e)}</td><td>${verdictCell(bucket)}</td></tr>`,
337
+ )
338
+ .join('')}</tbody></table>`;
339
+ }
340
+
341
+ function runsTable(cmp) {
342
+ const rows = (role, side) =>
343
+ side.runs
344
+ .map(
345
+ r =>
346
+ `<tr><td>${role}</td><td class="mono">${esc(r.id)}</td><td>${esc(r.commit || '–')}</td><td>${esc(r.mode === 'load' ? 'page load' : 'interaction')}</td><td class="n">${esc(formatValue('ms', r.durationMs))}</td><td class="n">${r.cpuThrottle}×</td><td class="mono">${esc(r.url)}</td></tr>`,
347
+ )
348
+ .join('');
349
+ return `<table><thead><tr><th>Side</th><th>Recording</th><th>Commit</th><th>Mode</th><th class="n">Length</th><th class="n">CPU</th><th>URL</th></tr></thead><tbody>${rows('Before', cmp.before)}${rows('After', cmp.after)}</tbody></table>`;
350
+ }
351
+
352
+ function methodology() {
353
+ const rows = METRICS.map(
354
+ m =>
355
+ `<tr><td>${esc(m.label)}</td><td>${esc(m.group)}</td><td class="n">${esc(formatValue(m.unit, m.minAbs))}</td><td class="n">${Math.round(m.minRel * 100)}%</td><td>${m.key ? 'yes' : ''}</td></tr>`,
356
+ ).join('');
357
+ return `<h2>Methodology</h2>
358
+ <div class="callout">
359
+ <p><b>What is compared.</b> Profiler reports recorded in Chrome through the DevTools Protocol (tracing, V8 CPU sampling and an in-page React probe). When a side has several runs, each metric is the <b>median</b> of its runs.</p>
360
+ <p><b>What counts as a change.</b> A metric is “improved” or “degraded” only when the difference exceeds <b>all three</b> of: its absolute floor, its relative floor (both in the table below) and the <b>run-to-run spread</b> seen on either side. Everything else is “unchanged”. For every metric listed here <b>lower is better</b>.</p>
361
+ <p><b>Overall verdict.</b> Decided only by the <i>key</i> metrics: improved if some improved and none degraded; regressed if some degraded and none improved; mixed if both; otherwise no significant change.</p>
362
+ <p><b>Limits.</b> A single run per side cannot separate a real change from noise: record each side at least three times, on the same page, in the same mode, with the same CPU setting. Interaction recordings are only comparable if the same actions were performed. Dev builds are slower than production: trust direction and ranking more than absolute numbers.</p>
363
+ </div>
364
+ <table><thead><tr><th>Metric</th><th>Group</th><th class="n">Abs. floor</th><th class="n">Rel. floor</th><th>Key</th></tr></thead><tbody>${rows}</tbody></table>`;
365
+ }
366
+
367
+ function header(cmp, kind) {
368
+ const b = cmp.before.git?.shortHash || 'before';
369
+ const a = cmp.after.git?.shortHash || 'after';
370
+ return `<h1>Performance comparison</h1>
371
+ <p class="sub">${esc(TARGET_NAME)} · <span class="mono">${esc(b)}</span> → <span class="mono">${esc(a)}</span> · ${kind === 'summary' ? 'Summary' : 'Detailed report'} · generated ${esc(new Date(cmp.generatedAt).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }))}</p>
372
+ <div class="pair">${commitCard('Before', cmp.before)}${commitCard('After', cmp.after)}</div>`;
373
+ }
374
+
375
+ // -------------------------------------------------------------------- documents
376
+
377
+ function summaryBody(cmp) {
378
+ const keyMetrics = cmp.metrics.filter(m => m.key);
379
+ const movedOthers = cmp.metrics.filter(
380
+ m => !m.key && (m.verdict === 'improved' || m.verdict === 'degraded'),
381
+ );
382
+ return `${header(cmp, 'summary')}
383
+ ${verdictBanner(cmp)}
384
+ <h2>Highlights</h2>${headlines(cmp)}
385
+ <h2>Key metrics</h2>${metricsTable(keyMetrics, { grouped: false })}
386
+ ${movedOthers.length ? `<h3>Other metrics that moved</h3>${metricsTable(movedOthers, { grouped: false })}` : ''}
387
+ <h2>Components and files that changed most</h2>${topChanges(cmp)}
388
+ <h2>Problems found</h2>${findingsSection(cmp, { limit: 4 })}
389
+ <h2>Can this comparison be trusted?</h2>${checksList(cmp, { onlyProblems: true })}
390
+ <p class="muted" style="margin-top:8pt">A change counts only if it exceeds the metric's noise floor and the run-to-run spread. The detailed report lists every metric, component, file and function, plus the methodology.</p>`;
391
+ }
392
+
393
+ function detailedBody(cmp) {
394
+ return `${header(cmp, 'detailed')}
395
+ ${verdictBanner(cmp)}
396
+ <h2>Highlights</h2>${headlines(cmp)}
397
+ <h2>Comparability</h2>${checksList(cmp, { onlyProblems: false })}
398
+ <h2 class="pagebreak">All metrics</h2>${metricsTable(cmp.metrics, { grouped: true })}
399
+ <div class="pagebreak"></div>${entitySection('React components', 'components', cmp, { vendor: false })}
400
+ <div class="pagebreak"></div>${entitySection('Files', 'files', cmp, { vendor: false })}
401
+ <div class="pagebreak"></div>${entitySection('Functions', 'functions', cmp, { vendor: false })}
402
+ <div class="pagebreak"></div><h2>Problems found</h2>${findingsSection(cmp, { limit: 0 })}
403
+ <h2 class="pagebreak">Recordings used</h2>${runsTable(cmp)}
404
+ ${methodology()}`;
405
+ }
406
+
407
+ /** @param {'summary'|'detailed'} kind */
408
+ export function renderComparisonHtml(cmp, kind = 'summary') {
409
+ const body = kind === 'detailed' ? detailedBody(cmp) : summaryBody(cmp);
410
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Performance comparison</title><style>${CSS}</style></head><body><div class="wrap">${body}</div></body></html>`;
411
+ }
412
+
413
+ export function footerTemplate(cmp, kind) {
414
+ const b = esc(cmp.before.git?.shortHash || 'before');
415
+ const a = esc(cmp.after.git?.shortHash || 'after');
416
+ return `<div style="width:100%;padding:0 14mm;font:7.5pt -apple-system,Helvetica,Arial,sans-serif;color:#6b6a62;display:flex;justify-content:space-between">
417
+ <span>${esc(TARGET_NAME)} performance · ${b} → ${a} · ${kind === 'summary' ? 'summary' : 'detailed'}</span>
418
+ <span>Page <span class="pageNumber"></span> of <span class="totalPages"></span></span></div>`;
419
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Vite plugin: the dev server doubles as the Spectra backend. The API itself
3
+ * lives in server/api.js so the standalone dashboard server can mount the exact
4
+ * same routes; this plugin just wires it into Vite's Connect middleware.
5
+ */
6
+ import { createApi } from './api.js';
7
+
8
+ export default function perfProfiler() {
9
+ return {
10
+ name: 'perf-profiler',
11
+ configureServer(server) {
12
+ const api = createApi();
13
+ server.httpServer?.on('close', () => api.dispose());
14
+ // Vite's Connect stack strips the '/api' mount prefix from req.url, which
15
+ // is exactly what api.handle expects.
16
+ server.middlewares.use('/api', api.handle);
17
+ },
18
+ };
19
+ }