sloptimize 0.3.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.
- package/.claude-plugin/marketplace.json +11 -0
- package/.claude-plugin/plugin.json +8 -0
- package/.mcp.json +9 -0
- package/LICENSE +21 -0
- package/README.md +263 -0
- package/bin/sloptimize.mjs +346 -0
- package/docs/DESIGN-mecharoyale-v0.md +32 -0
- package/docs/INTEGRATION.md +230 -0
- package/docs/JITTER-AND-FOOTPRINTS.md +236 -0
- package/docs/SPEC-attach.md +176 -0
- package/docs/SPEC.md +845 -0
- package/docs/USAGE.md +227 -0
- package/hooks/hooks.json +16 -0
- package/mcp/server.mjs +127 -0
- package/package.json +64 -0
- package/skills/install/SKILL.md +143 -0
- package/skills/sloptimize/SKILL.md +74 -0
- package/src/attach.mjs +197 -0
- package/src/census.js +193 -0
- package/src/classify.js +70 -0
- package/src/footprint.js +170 -0
- package/src/history.js +273 -0
- package/src/index.js +8 -0
- package/src/inject-body.js +152 -0
- package/src/motion.js +345 -0
- package/src/panel.js +530 -0
- package/src/proposals.mjs +268 -0
- package/src/recorder.js +235 -0
- package/src/watch.mjs +242 -0
package/src/panel.js
ADDED
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// panel.js — the in-game perf debugger (SPEC §8.5): Session · Issues · Optimizations · Settings
|
|
3
|
+
// ============================================================
|
|
4
|
+
// The human's face of the ledger. The host (the game's dev runtime) owns the
|
|
5
|
+
// evidence and the transport; this module owns only the view:
|
|
6
|
+
//
|
|
7
|
+
// SESSION what the recorder caught since the tab opened (every row already
|
|
8
|
+
// reached the agent when it happened) + the one-line note box.
|
|
9
|
+
// ISSUES the catalogue (SPEC §3.7): every incident type on the ledger
|
|
10
|
+
// grouped by FOOTPRINT — the same cause across builds, sessions
|
|
11
|
+
// and days is one row — with how often, how recently, in what
|
|
12
|
+
// situation, and which fixes were applied to it. Pick a row for
|
|
13
|
+
// its history.
|
|
14
|
+
// TIMELINE the deployment's history folded from perf.jsonl — frame p95,
|
|
15
|
+
// draw calls and hitch spikes on ONE time axis, build boundaries
|
|
16
|
+
// marked — so "is it better than yesterday" is a glance.
|
|
17
|
+
// FIXES the fix ledger: issue → solution, commit, date, and the MEASURED
|
|
18
|
+
// before/after window of each, sparklined.
|
|
19
|
+
//
|
|
20
|
+
// No dependencies, no framework, no stylesheet: one root element with inline
|
|
21
|
+
// styles (the host page's CSS must not leak in, and ours must not leak out).
|
|
22
|
+
// Charts are inline SVG, one series per strip (never a dual axis), a shared
|
|
23
|
+
// crosshair, and the honest ceiling: a spike past the strip's ceiling is
|
|
24
|
+
// drawn AT the ceiling with a caret, and the readout says the real number.
|
|
25
|
+
import { buildHistory, buildIssues, agoText } from './history.js';
|
|
26
|
+
|
|
27
|
+
const C = {
|
|
28
|
+
bg: 'rgba(8,12,20,0.94)', line: 'rgba(60,224,255,0.5)', ink: '#cfe6f5', dim: '#8fb4c4', mute: '#6f9db0',
|
|
29
|
+
accent: '#3ce0ff', warn: '#ffb454', mark: '#ffd479', good: '#5fd68b', rule: 'rgba(207,230,245,0.14)',
|
|
30
|
+
fill: 'rgba(60,224,255,0.10)', field: 'rgba(20,30,45,0.9)',
|
|
31
|
+
};
|
|
32
|
+
const FONT = '12px system-ui, sans-serif';
|
|
33
|
+
const MONO = 'ui-monospace, SFMono-Regular, Menlo, monospace';
|
|
34
|
+
// The panel is a FIXED-size window (PANEL_W × PANEL_H, clamped to the
|
|
35
|
+
// viewport) and its body scrolls: a panel sized to its content moved every
|
|
36
|
+
// time the content did — the timeline readout wrapping to a second line
|
|
37
|
+
// under the cursor shifted the strips the cursor was over (field, 2026-08-28).
|
|
38
|
+
// The strips' viewBox is drawn wide so the SVG fills the window at the same
|
|
39
|
+
// font proportions rather than scaling a small drawing up.
|
|
40
|
+
const PANEL_W = 1100, PANEL_H = 720;
|
|
41
|
+
const W = 1000, STRIP_H = 96, PAD_L = 52, PAD_R = 10;
|
|
42
|
+
const TABS = [['session', 'Current Session'], ['issues', 'Issues'], ['optimizations', 'Optimizations'], ['settings', 'Settings']];
|
|
43
|
+
/** The Fixes badge remembers what you have seen per browser. */
|
|
44
|
+
const SEEN_KEY = 'sloptimize.fixes.seen';
|
|
45
|
+
|
|
46
|
+
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
47
|
+
const num = (v, unit = '') => (v === undefined || v === null ? '—' : `${typeof v === 'number' ? +v.toFixed(v >= 100 ? 0 : 1) : v}${unit}`);
|
|
48
|
+
const when = (iso) => { const d = new Date(iso); return Number.isNaN(d.getTime()) ? '' : d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); };
|
|
49
|
+
const el = (tag, style, html) => { const e = document.createElement(tag); if (style) e.style.cssText = style; if (html !== undefined) e.innerHTML = html; return e; };
|
|
50
|
+
const H = (s) => `<div style="letter-spacing:1px;color:${C.accent};font-size:11px;margin:10px 0 6px;text-transform:uppercase">${s}</div>`;
|
|
51
|
+
|
|
52
|
+
/** A strip: one series over the shared x axis. `kind` line|bars. Values may be
|
|
53
|
+
* undefined (unmeasured → a gap, never a zero). Ceiling = 1.5 × the 90th
|
|
54
|
+
* percentile, so one 500s freeze cannot flatten a week of 17ms. */
|
|
55
|
+
function strip(label, unit, values, kind, ticks) {
|
|
56
|
+
const n = values.length, xw = (W - PAD_L - PAD_R) / Math.max(n, 1);
|
|
57
|
+
const known = values.filter((v) => typeof v === 'number').sort((a, b) => a - b);
|
|
58
|
+
if (known.length === 0) return `<g><text x="${PAD_L}" y="${STRIP_H / 2}" fill="${C.mute}" font-size="10">${label}: unmeasured in this window</text></g>`;
|
|
59
|
+
const p90 = known[Math.min(known.length - 1, Math.floor(known.length * 0.9))];
|
|
60
|
+
const ceil = Math.max(p90 * 1.5, known[known.length - 1] * 0.0001, 1);
|
|
61
|
+
const top = 8, bottom = STRIP_H - 6;
|
|
62
|
+
const y = (v) => bottom - Math.min(v, ceil) / ceil * (bottom - top);
|
|
63
|
+
const x = (i) => PAD_L + i * xw;
|
|
64
|
+
let marks = '';
|
|
65
|
+
if (kind === 'line') {
|
|
66
|
+
let d = '', pen = false;
|
|
67
|
+
values.forEach((v, i) => {
|
|
68
|
+
if (typeof v !== 'number') { pen = false; return; }
|
|
69
|
+
d += `${pen ? 'L' : 'M'}${(x(i) + xw / 2).toFixed(1)} ${y(v).toFixed(1)} `; pen = true;
|
|
70
|
+
});
|
|
71
|
+
// Dots as well as the stroke: a session is minutes inside a window of
|
|
72
|
+
// days, so many buckets are lone measurements a stroke cannot show.
|
|
73
|
+
const dots = values.map((v, i) => (typeof v === 'number' ? `<circle cx="${(x(i) + xw / 2).toFixed(1)}" cy="${y(v).toFixed(1)}" r="1.6" fill="${C.accent}"/>` : '')).join('');
|
|
74
|
+
marks = `<path d="${d}" fill="none" stroke="${C.accent}" stroke-width="1.5" stroke-linejoin="round"/>${dots}`;
|
|
75
|
+
} else {
|
|
76
|
+
values.forEach((v, i) => {
|
|
77
|
+
if (typeof v !== 'number' || v <= 0) return;
|
|
78
|
+
marks += `<rect x="${(x(i) + 1).toFixed(1)}" y="${y(v).toFixed(1)}" width="${Math.max(xw - 2, 1).toFixed(1)}" height="${(bottom - y(v)).toFixed(1)}" fill="${C.warn}" rx="1"/>`;
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
// Past the ceiling: drawn at the ceiling, flagged with a caret.
|
|
82
|
+
const carets = values.map((v, i) => (typeof v === 'number' && v > ceil
|
|
83
|
+
? `<path d="M${(x(i) + xw / 2 - 3).toFixed(1)} ${top + 4} l3 -4 l3 4z" fill="${kind === 'line' ? C.accent : C.warn}"/>` : '')).join('');
|
|
84
|
+
const tickLines = ticks.map((i) => `<line x1="${x(i).toFixed(1)}" x2="${x(i).toFixed(1)}" y1="${top - 4}" y2="${bottom}" stroke="${C.rule}" stroke-dasharray="2 3"/>`).join('');
|
|
85
|
+
return `<g>
|
|
86
|
+
<line x1="${PAD_L}" x2="${W - PAD_R}" y1="${bottom}" y2="${bottom}" stroke="${C.rule}"/>
|
|
87
|
+
${tickLines}${marks}${carets}
|
|
88
|
+
<text x="${PAD_L - 6}" y="${top + 4}" text-anchor="end" fill="${C.mute}" font-size="9" font-family="${MONO}">${num(ceil, unit)}</text>
|
|
89
|
+
<text x="${PAD_L - 6}" y="${bottom}" text-anchor="end" fill="${C.mute}" font-size="9" font-family="${MONO}">0</text>
|
|
90
|
+
<text x="${PAD_L + 4}" y="${top + 3}" fill="${C.dim}" font-size="10">${label}</text>
|
|
91
|
+
</g>`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A card's sparkline: the window's per-slice p95, one pen stroke. */
|
|
95
|
+
function spark(series, color) {
|
|
96
|
+
if (!series || series.length < 2) return `<svg width="120" height="28"><text x="0" y="18" fill="${C.mute}" font-size="10">no series</text></svg>`;
|
|
97
|
+
const max = Math.max(...series, 1), xw = 120 / (series.length - 1);
|
|
98
|
+
const d = series.map((v, i) => `${i ? 'L' : 'M'}${(i * xw).toFixed(1)} ${(26 - v / max * 22).toFixed(1)}`).join(' ');
|
|
99
|
+
return `<svg width="120" height="28" viewBox="0 0 120 28"><path d="${d}" fill="none" stroke="${color}" stroke-width="1.5"/></svg>`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** before → after for one metric: the after value, and the change, colored by
|
|
103
|
+
* whether it moved the right way (every metric here is lower-is-better). */
|
|
104
|
+
function delta(b, a, unit) {
|
|
105
|
+
if (typeof b !== 'number' || typeof a !== 'number') return `<span style="color:${C.mute}">${num(a, unit)}</span>`;
|
|
106
|
+
if (b === 0 && a === 0) return num(a, unit);
|
|
107
|
+
const pct = b === 0 ? null : Math.round((a - b) / b * 100);
|
|
108
|
+
const better = a < b, same = a === b;
|
|
109
|
+
const col = same ? C.dim : better ? C.good : C.warn;
|
|
110
|
+
const arrow = same ? '' : better ? '▼' : '▲';
|
|
111
|
+
return `${num(a, unit)} <span style="color:${col};font-size:10px">${arrow}${pct === null ? '' : `${Math.abs(pct)}%`}</span>`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The three strips over `h` (a folded history), or a one-line reason. */
|
|
115
|
+
function stripsSvg(h) {
|
|
116
|
+
if (!h || !h.span) return { svg: `<div style="color:${C.dim};padding:12px 0">No measured records in this range — the timeline fills as the recorder posts heartbeats and hitches.</div>`, ticks: [] };
|
|
117
|
+
const b = h.buckets;
|
|
118
|
+
// Build boundaries as dashed hairlines — but a dev day ships thirty
|
|
119
|
+
// bundles, and thirty hairlines is texture, not information: past 12 the
|
|
120
|
+
// count is said in words instead.
|
|
121
|
+
let ticks = []; let prev;
|
|
122
|
+
b.forEach((k, i) => { if (k.build && k.build !== prev) { if (prev !== undefined) ticks.push(i); prev = k.build; } });
|
|
123
|
+
if (ticks.length > 12) ticks = [];
|
|
124
|
+
const svg = `<svg id="sl-strips" viewBox="0 0 ${W} ${STRIP_H * 3 + 14}" width="100%" style="display:block;font-family:${FONT}">
|
|
125
|
+
${strip('frame p95', 'ms', b.map((k) => k.p95Ms), 'line', ticks)}
|
|
126
|
+
<g transform="translate(0 ${STRIP_H})">${strip('draw calls', '', b.map((k) => k.calls), 'line', ticks)}</g>
|
|
127
|
+
<g transform="translate(0 ${STRIP_H * 2})">${strip('hitches', '', b.map((k) => k.hitches), 'bars', ticks)}</g>
|
|
128
|
+
<line id="sl-x" x1="0" x2="0" y1="4" y2="${STRIP_H * 3 - 6}" stroke="${C.ink}" stroke-opacity="0.5" visibility="hidden"/>
|
|
129
|
+
<text x="${PAD_L}" y="${STRIP_H * 3 + 10}" fill="${C.mute}" font-size="9" font-family="${MONO}">${esc(when(h.span.from))}</text>
|
|
130
|
+
<text x="${W - PAD_R}" y="${STRIP_H * 3 + 10}" text-anchor="end" fill="${C.mute}" font-size="9" font-family="${MONO}">${esc(when(h.span.to))}</text>
|
|
131
|
+
</svg>
|
|
132
|
+
<div id="sl-read" style="height:18px;line-height:18px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;font-family:${MONO};font-size:11px;color:${C.dim};padding:0 0 0 ${PAD_L * 100 / W}%">hover the strips</div>`;
|
|
133
|
+
return { svg, ticks };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** "How much did we gain between these dates": the first build in range
|
|
137
|
+
* against the last, every number a measured window of the ledger. */
|
|
138
|
+
function improvementLine(h, fixCount) {
|
|
139
|
+
if (!h || !h.span || h.builds.length === 0) return '';
|
|
140
|
+
const first = h.builds[0], last = h.builds[h.builds.length - 1];
|
|
141
|
+
const one = h.builds.length === 1;
|
|
142
|
+
return `<div style="display:flex;flex-wrap:wrap;gap:6px 18px;font-family:${MONO};font-size:11px;color:${C.dim};margin:4px 0 2px;align-items:baseline">
|
|
143
|
+
<span style="color:${C.accent};letter-spacing:1px;text-transform:uppercase;font-size:10px">Improvement in range</span>
|
|
144
|
+
<span>p95 ${one ? num(last.p95Ms, 'ms') : delta(first.p95Ms, last.p95Ms, 'ms')}</span>
|
|
145
|
+
<span>draw calls ${one ? num(last.calls) : delta(first.calls, last.calls)}</span>
|
|
146
|
+
<span>hitches/h ${one ? num(last.hitchesPerHour) : delta(first.hitchesPerHour, last.hitchesPerHour)}</span>
|
|
147
|
+
<span>worst frame ${one ? num(last.worstMs, 'ms') : delta(first.worstMs, last.worstMs, 'ms')}</span>
|
|
148
|
+
<span style="color:${C.mute}">${one ? `one build (${esc(first.build)})` : `${esc(first.build)} → ${esc(last.build)} · ${h.builds.length} builds`} · ${fixCount} fix${fixCount === 1 ? '' : 'es'}</span></div>`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The date filter bar. `range` = {from, to} as datetime-local strings ('' = open). */
|
|
152
|
+
function filterBar(range) {
|
|
153
|
+
const inp = (id, v) => `<input id="${id}" type="datetime-local" value="${esc(v ?? '')}" style="background:${C.field};border:1px solid rgba(120,150,190,0.4);border-radius:4px;color:#e8f0ff;padding:3px 6px;font:11px ${MONO};outline:none">`;
|
|
154
|
+
return `<div style="display:flex;gap:10px;align-items:center;font-size:11px;color:${C.dim};margin:2px 0 8px">
|
|
155
|
+
<span style="color:${C.accent};letter-spacing:1px;text-transform:uppercase;font-size:10px">Range</span>
|
|
156
|
+
<label>from ${inp('sl-from', range.from)}</label><span style="color:${C.mute}">(empty = since the beginning)</span>
|
|
157
|
+
<label>to ${inp('sl-to', range.to)}</label><span style="color:${C.mute}">(empty = now)</span>
|
|
158
|
+
<button id="sl-range-clear" type="button" style="background:none;border:1px solid ${C.rule};color:${C.mute};border-radius:4px;padding:2px 8px;font:inherit;font-size:10px;cursor:pointer">clear</button></div>`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function renderFixRows(h, list) {
|
|
162
|
+
// `list` is the git-backed proposal list (host.fixes): status per fix as
|
|
163
|
+
// git sees it, merge/reject verbs. `h.fixes` is the measured ledger the
|
|
164
|
+
// timeline folds; the two meet on `id` / commit.
|
|
165
|
+
if (list && list.repo === false) {
|
|
166
|
+
return `<div style="color:${C.warn};padding:12px 0;line-height:1.5">${esc(list.error || "this project isn't a git repo")}</div>`;
|
|
167
|
+
}
|
|
168
|
+
const inRange = (f) => !h?.rangeLo || !Number.isFinite(Date.parse(f.at)) || (Date.parse(f.at) >= h.rangeLo && Date.parse(f.at) <= h.rangeHi);
|
|
169
|
+
const fixes = (list?.fixes?.length ? list.fixes : (h?.fixes ?? [])).filter(inRange);
|
|
170
|
+
if (fixes.length === 0) return `<div style="color:${C.dim};padding:12px 0;line-height:1.5">No fixes in this range. When a Claude Code session has a fix, it proposes it as a branch and records it here with the measured before/after:<br><code style="font-family:${MONO};color:${C.ink}">sloptimize fix propose --title "…" --issue "…" --solution "…"</code></div>`;
|
|
171
|
+
const row = (label, b, a, unit) => `<tr><td style="color:${C.mute};padding:1px 8px 1px 0">${label}</td><td style="text-align:right;padding:1px 8px">${num(b, unit)}</td><td style="text-align:right">${delta(b, a, unit)}</td></tr>`;
|
|
172
|
+
const badge = (st) => {
|
|
173
|
+
const col = st === 'merged' ? C.good : st === 'proposed' ? C.accent : st === 'rejected' ? C.mute : C.warn;
|
|
174
|
+
return `<span style="font-family:${MONO};font-size:10px;letter-spacing:1px;text-transform:uppercase;color:${col};border:1px solid ${col};border-radius:3px;padding:1px 6px">${esc(st)}</span>`;
|
|
175
|
+
};
|
|
176
|
+
const btn = (id, action, label, color) => `<button data-fix="${esc(id)}" data-action="${action}" type="button" style="background:none;border:1px solid ${color};color:${color};border-radius:4px;padding:3px 10px;font:inherit;font-size:11px;cursor:pointer">${label}</button>`;
|
|
177
|
+
return fixes.map((f) => `<div style="border-top:1px solid ${C.rule};padding:10px 0" data-fix-row="${esc(f.id ?? '')}">
|
|
178
|
+
<div style="display:flex;justify-content:space-between;gap:12px;align-items:baseline">
|
|
179
|
+
<div style="color:${C.ink};font-size:13px;display:flex;gap:10px;align-items:baseline">${badge(f.status ?? 'recorded')} ${esc(f.title)}</div>
|
|
180
|
+
<div style="font-family:${MONO};font-size:10px;color:${C.mute};white-space:nowrap;display:flex;gap:8px;align-items:center">${esc(when(f.at))}${f.branch ? ` · ${esc(f.branch)}` : ''}${f.commit ? ` @ <span style="color:${C.ink}">${esc(f.commit)}</span>` : ''}${f.mergeCommit ? ` → ${esc(f.mergeCommit)}` : ''}
|
|
181
|
+
${f.pr?.url ? `<a href="${esc(f.pr.url)}" target="_blank" rel="noopener" style="color:${C.accent};border:1px solid ${C.accent};border-radius:3px;padding:1px 6px;text-decoration:none;font-size:10px">PR #${esc(String(f.pr.number))} ↗</a>` : ''}</div>
|
|
182
|
+
</div>
|
|
183
|
+
${f.issue ? `<div style="color:${C.dim};margin-top:4px"><span style="color:${C.warn}">was</span> ${esc(f.issue)}</div>` : ''}
|
|
184
|
+
${f.solution ? `<div style="color:${C.dim};margin-top:2px"><span style="color:${C.good}">now</span> ${esc(f.solution)}</div>` : ''}
|
|
185
|
+
<div style="display:flex;gap:18px;margin-top:8px;align-items:flex-start">
|
|
186
|
+
<div style="font-family:${MONO};font-size:10px;color:${C.mute}">before<br>${spark(f.before?.series, C.warn)}<br>${esc(f.before?.build ?? (f.before ? when(f.before.from) : 'not measured yet'))}</div>
|
|
187
|
+
<div style="font-family:${MONO};font-size:10px;color:${C.mute}">after<br>${spark(f.after?.series, C.good)}<br>${esc(f.after?.build ?? (f.after ? when(f.after.from) : 'not measured yet'))}</div>
|
|
188
|
+
<table style="font-family:${MONO};font-size:11px;color:${C.ink};border-collapse:collapse;margin-left:auto">
|
|
189
|
+
<tr style="color:${C.mute};font-size:10px"><td></td><td style="text-align:right;padding:0 8px">before</td><td style="text-align:right">after</td></tr>
|
|
190
|
+
${row('frame p95', f.before?.p95Ms, f.after?.p95Ms, 'ms')}
|
|
191
|
+
${row('draw calls', f.before?.calls, f.after?.calls)}
|
|
192
|
+
${row('hitches/h', f.before?.hitchesPerHour, f.after?.hitchesPerHour)}
|
|
193
|
+
${row('worst frame', f.before?.worstMs, f.after?.worstMs, 'ms')}
|
|
194
|
+
</table>
|
|
195
|
+
</div>
|
|
196
|
+
${f.status === 'proposed' ? `<div style="display:flex;gap:8px;margin-top:8px;align-items:center">
|
|
197
|
+
${f.upToDate === false ? `<span style="font-size:10px;color:${C.warn}">behind ${esc(list?.main ?? 'main')} — rebase before merging</span>` : btn(f.id, 'merge', 'Merge', C.good)}
|
|
198
|
+
${btn(f.id, 'reject', 'Reject', C.mute)}
|
|
199
|
+
<span data-fix-msg="${esc(f.id)}" style="font-size:10px;color:${C.warn}"></span></div>` : ''}
|
|
200
|
+
</div>`).join('');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function renderOptimizations(h, list, range) {
|
|
204
|
+
const notRepo = list && list.repo === false ? `<div style="color:${C.warn};padding:0 0 8px;line-height:1.5">${esc(list.error || "this project isn't a git repo")}</div>` : '';
|
|
205
|
+
const { svg } = stripsSvg(h);
|
|
206
|
+
const rows = renderFixRows(h, list);
|
|
207
|
+
const fixCount = (rows.match(/data-fix-row=/g) || []).length;
|
|
208
|
+
return `${filterBar(range)}${notRepo}${svg}${improvementLine(h, fixCount)}${H('Fixes in range')}${rows}`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** A status badge, the same one the fix rows wear. */
|
|
212
|
+
function statusBadge(st) {
|
|
213
|
+
const col = st === 'merged' ? C.good : st === 'proposed' ? C.accent : st === 'rejected' ? C.mute : C.warn;
|
|
214
|
+
return `<span style="font-family:${MONO};font-size:10px;letter-spacing:1px;text-transform:uppercase;color:${col};border:1px solid ${col};border-radius:3px;padding:1px 6px">${esc(st)}</span>`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** A facet chip: `hull=walker` as a small labelled pill. */
|
|
218
|
+
function chip(k, v, color = C.dim) {
|
|
219
|
+
return `<span style="font-family:${MONO};font-size:10px;color:${color};border:1px solid ${C.rule};border-radius:3px;padding:0 5px;white-space:nowrap"><span style="color:${C.mute}">${esc(k)}</span>${v !== undefined ? `=${esc(v)}` : ''}</span>`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* THE ISSUES TAB (SPEC §3.7). `issues` is `buildIssues(...)` over the ledger in
|
|
224
|
+
* the current range: one row per footprint, most frequent first. `selected`
|
|
225
|
+
* is the id whose history is open under its row: the readable key, when it
|
|
226
|
+
* was first and last seen, on which builds, how bad at worst, the last
|
|
227
|
+
* verdict's evidence, and every fix that names this footprint — or the exact
|
|
228
|
+
* command that would.
|
|
229
|
+
*/
|
|
230
|
+
function renderIssues(issues, range, selected) {
|
|
231
|
+
const total = issues.reduce((n, i) => n + i.count, 0);
|
|
232
|
+
const head = `<div style="display:flex;gap:18px;align-items:baseline;font-family:${MONO};font-size:11px;color:${C.dim};margin:2px 0 6px">
|
|
233
|
+
<span style="color:${C.accent};letter-spacing:1px;text-transform:uppercase;font-size:10px">Issues in range</span>
|
|
234
|
+
<span>${issues.length} footprint${issues.length === 1 ? '' : 's'}</span><span>${total} occurrence${total === 1 ? '' : 's'}</span>
|
|
235
|
+
<span style="color:${C.mute}">grouped by footprint: the same cause across builds and sessions is one row · click a row for its fix history</span></div>`;
|
|
236
|
+
if (issues.length === 0) return `${filterBar(range)}${head}<div style="color:${C.dim};padding:12px 0">No incidents in this range.</div>`;
|
|
237
|
+
const rows = issues.map((i) => {
|
|
238
|
+
const on = i.id === selected;
|
|
239
|
+
const ctx = Object.entries(i.ctx ?? {}).map(([k, v]) => chip(k, v)).join(' ');
|
|
240
|
+
const fixesTxt = i.fixes.length ? `<span style="color:${C.good}">${i.fixes.length} fix${i.fixes.length === 1 ? '' : 'es'}</span>` : `<span style="color:${C.mute}">no fix</span>`;
|
|
241
|
+
const worst = i.worst ? `worst ${num(i.worst.value, i.worst.unit === 'u' ? 'm' : i.worst.unit)}` : '';
|
|
242
|
+
const row = `<div data-issue="${esc(i.id)}" role="button" tabindex="0" style="display:grid;grid-template-columns:2ch 1fr auto auto auto;gap:0 12px;align-items:baseline;padding:6px 4px;border-top:1px solid ${C.rule};cursor:pointer;background:${on ? C.fill : 'none'}">
|
|
243
|
+
<span>${i.glyph}</span>
|
|
244
|
+
<span style="color:${C.ink};font-size:12.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(i.label)} ${i.phase ? chip(i.phase) : ''} ${ctx}</span>
|
|
245
|
+
<span style="font-family:${MONO};font-size:11px;color:${C.warn};font-variant-numeric:tabular-nums">×${i.count}</span>
|
|
246
|
+
<span style="font-family:${MONO};font-size:11px;color:${C.dim};white-space:nowrap">last ${esc(agoText(i.lastAgoMs))}</span>
|
|
247
|
+
<span style="font-family:${MONO};font-size:10px;white-space:nowrap">${fixesTxt}</span></div>`;
|
|
248
|
+
if (!on) return row;
|
|
249
|
+
const fixLine = (f) => `<div style="display:flex;gap:10px;align-items:baseline;padding:3px 0">${statusBadge(f.status ?? 'recorded')}<span style="color:${C.ink}">${esc(f.title)}</span>
|
|
250
|
+
<span style="font-family:${MONO};font-size:10px;color:${C.mute};white-space:nowrap">${esc(when(f.at))}${f.commit ? ` @ ${esc(f.commit)}` : ''}${f.mergeCommit ? ` → ${esc(f.mergeCommit)}` : ''}</span>
|
|
251
|
+
${f.pr?.url ? `<a href="${esc(f.pr.url)}" target="_blank" rel="noopener" style="color:${C.accent};border:1px solid ${C.accent};border-radius:3px;padding:0 6px;text-decoration:none;font-size:10px">PR #${esc(String(f.pr.number))} ↗</a>` : ''}</div>`;
|
|
252
|
+
const detail = `<div style="padding:4px 4px 12px 26px;color:${C.dim};font-size:11.5px;background:${C.fill}">
|
|
253
|
+
<div style="font-family:${MONO};font-size:10px;color:${C.mute};margin-bottom:6px">fp ${esc(i.id)} · ${esc(i.key)}</div>
|
|
254
|
+
<div style="display:flex;flex-wrap:wrap;gap:4px 18px;font-family:${MONO};font-size:11px">
|
|
255
|
+
<span>first ${esc(when(i.first))}</span><span>last ${esc(when(i.last))} (${esc(agoText(i.lastAgoMs))})</span>
|
|
256
|
+
<span>${i.count} occurrence${i.count === 1 ? '' : 's'}</span>${worst ? `<span>${worst}</span>` : ''}
|
|
257
|
+
<span>builds ${i.builds.length ? esc(i.builds.slice(-6).join(', ')) : '—'}${i.builds.length > 6 ? ` +${i.builds.length - 6}` : ''}</span></div>
|
|
258
|
+
${i.sample ? `<div style="margin-top:6px"><span style="color:${C.mute}">last verdict</span> <b style="color:${C.ink}">${esc(i.sample.guess)}</b> <span>${esc(i.sample.evidence)}</span></div>` : ''}
|
|
259
|
+
${H('fixes applied to this issue')}
|
|
260
|
+
${i.fixes.length ? i.fixes.map(fixLine).join('') : `<div style="color:${C.mute}">none recorded — a session that fixes it names the footprint: <code style="font-family:${MONO};color:${C.ink}">sloptimize fix propose --footprints ${esc(i.id)} --title "…"</code></div>`}
|
|
261
|
+
</div>`;
|
|
262
|
+
return row + detail;
|
|
263
|
+
}).join('');
|
|
264
|
+
return `${filterBar(range)}${head}<div id="sl-issues">${rows}</div>`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function renderSettings(settings, list) {
|
|
268
|
+
const s = settings ?? { automation: 'propose' };
|
|
269
|
+
const opt = (v, label, help) => `<label style="display:flex;gap:10px;align-items:flex-start;padding:6px 0;cursor:pointer">
|
|
270
|
+
<input type="radio" name="sl-automation" value="${v}" ${s.automation === v ? 'checked' : ''} style="margin-top:3px">
|
|
271
|
+
<span><b style="color:${C.ink}">${label}</b><br><span style="color:${C.dim}">${help}</span></span></label>`;
|
|
272
|
+
return `${H('Automation')}
|
|
273
|
+
<div style="color:${C.dim};margin-bottom:6px">How far a Claude Code session goes on its own when it has a verified fix. Saved server-side in <code style="font-family:${MONO}">.sloptimize/settings.json</code>; sessions read it before acting.</div>
|
|
274
|
+
${opt('propose', 'Propose', 'Branch + commit + ledger entry. You merge or reject from the Fixes tab.')}
|
|
275
|
+
${opt('merge', 'Merge', 'Propose, then merge into main itself once its tests are green. A merge is always a merge commit of a branch based on current main — never a rewritten tree.')}
|
|
276
|
+
<div id="sl-settings-msg" style="font-size:11px;color:${C.mute};min-height:16px;margin-top:6px">${list && list.repo === false ? esc(list.error) : ''}</div>`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function renderSession(host) {
|
|
280
|
+
const inc = host.incidents?.() ?? [];
|
|
281
|
+
const now = host.now?.() ?? performance.now();
|
|
282
|
+
const rows = inc.slice(-12).map((i) => {
|
|
283
|
+
const ago = Math.round((now - i.at) / 1000);
|
|
284
|
+
const agoTxt = ago < 90 ? `${ago}s ago` : `${Math.round(ago / 60)}m ago`;
|
|
285
|
+
const ph = i.phase ? `<span style="color:${C.mute}">[${esc(i.phase)}]</span> ` : '';
|
|
286
|
+
// A row's measure is milliseconds unless it says otherwise (a jitter row
|
|
287
|
+
// measures a distance, and marks itself ↯).
|
|
288
|
+
const measure = i.label ? esc(i.label) : `${Math.round(i.frameMs)}ms`;
|
|
289
|
+
const glyph = i.manual ? '★' : (i.glyph ? esc(i.glyph) : '·');
|
|
290
|
+
const fp = i.fp ? ` <span style="font-family:${MONO};font-size:10px;color:${C.mute}">fp ${esc(i.fp)}</span>` : '';
|
|
291
|
+
return `<div style="padding:2px 0;color:${i.manual ? C.mark : C.ink}">${glyph} ${agoTxt} — ${ph}${measure} → <b>${esc(i.guess)}</b> <span style="color:${C.dim}">${esc(String(i.evidence).slice(0, 64))}</span>${fp}</div>`;
|
|
292
|
+
}).join('');
|
|
293
|
+
const feed = host.feed?.() ?? { state: 'ok' };
|
|
294
|
+
const feedLine = feed.state === 'ok'
|
|
295
|
+
? `<div style="font-size:10px;color:${C.good}">feed: live — incidents reach Claude Code as they happen</div>`
|
|
296
|
+
: `<div style="font-size:10px;color:${C.warn}">feed: DARK ${feed.darkForS !== undefined ? `${feed.darkForS}s` : ''} — ${esc(feed.reason)}; recording continues, ${feed.buffered ?? 0} post(s) buffered for retry</div>`;
|
|
297
|
+
return `${feedLine}${H(`incidents this session (${inc.length} logged${feed.state === 'ok' ? ', all already sent to Claude Code' : ' — feed dark, buffered'})`)}
|
|
298
|
+
<div id="sl-list" style="font-size:11.5px;font-variant-numeric:tabular-nums">${rows || `<div style="color:${C.dim}">none yet — the recorder is watching</div>`}</div>`;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* @param host {{
|
|
303
|
+
* incidents?: () => Array<{at:number, frameMs:number, guess:string, evidence:string, manual:boolean, phase?:string, label?:string, glyph?:string}>,
|
|
304
|
+
* feed?: () => {state:'ok'|'dark', reason?:string, buffered?:number, darkForS?:number},
|
|
305
|
+
* history?: () => Promise<{records:object[], fixes?:object[]} | ReturnType<typeof buildHistory> | null>,
|
|
306
|
+
* onNote: (note: string|null) => void, // called exactly once per open, on close
|
|
307
|
+
* now?: () => number,
|
|
308
|
+
* }}
|
|
309
|
+
*/
|
|
310
|
+
export function createPanel(host) {
|
|
311
|
+
let root = null, input = null, body = null, tab = 'session', histCache = null;
|
|
312
|
+
let selectedIssue = null;
|
|
313
|
+
|
|
314
|
+
function close(note) {
|
|
315
|
+
if (!root) return;
|
|
316
|
+
document.removeEventListener('keydown', onKey, true);
|
|
317
|
+
document.removeEventListener('keyup', swallow, true);
|
|
318
|
+
root.remove(); root = null; input = null; body = null;
|
|
319
|
+
host.onNote(note);
|
|
320
|
+
}
|
|
321
|
+
const submit = () => close(input?.value.trim() || null);
|
|
322
|
+
const swallow = (e) => { e.stopPropagation(); };
|
|
323
|
+
function onKey(e) {
|
|
324
|
+
e.stopPropagation(); // WASD must not walk the mech
|
|
325
|
+
if (e.key === 'Escape') { e.preventDefault(); close(null); return; }
|
|
326
|
+
if (e.key === 'Enter' && e.target === input) { e.preventDefault(); submit(); return; }
|
|
327
|
+
if ((e.key === 'ArrowRight' || e.key === 'ArrowLeft') && e.target?.dataset?.tab) {
|
|
328
|
+
const i = TABS.findIndex(([k]) => k === tab);
|
|
329
|
+
show(TABS[(i + (e.key === 'ArrowRight' ? 1 : TABS.length - 1)) % TABS.length][0], true);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function show(next, focusTab = false) {
|
|
334
|
+
tab = next;
|
|
335
|
+
for (const b of root.querySelectorAll('[data-tab]')) {
|
|
336
|
+
const on = b.dataset.tab === tab;
|
|
337
|
+
b.setAttribute('aria-selected', String(on));
|
|
338
|
+
b.style.color = on ? C.accent : C.mute;
|
|
339
|
+
b.style.borderBottomColor = on ? C.accent : 'transparent';
|
|
340
|
+
if (on && focusTab) b.focus();
|
|
341
|
+
}
|
|
342
|
+
if (tab === 'session') { body.innerHTML = renderSession(host); const l = body.querySelector('#sl-list'); if (l) l.scrollTop = l.scrollHeight; return; }
|
|
343
|
+
body.innerHTML = `<div style="color:${C.dim};padding:12px 0">loading the ledger…</div>`;
|
|
344
|
+
if (tab === 'settings') {
|
|
345
|
+
Promise.all([loadSettings(), loadFixes()]).then(([st, list]) => {
|
|
346
|
+
if (!root || tab !== next) return;
|
|
347
|
+
body.innerHTML = renderSettings(st, list);
|
|
348
|
+
wireSettings();
|
|
349
|
+
});
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (tab === 'issues') {
|
|
353
|
+
Promise.all([loadHistory(), loadFixes()]).then(([h, list]) => {
|
|
354
|
+
if (!root || tab !== next) return;
|
|
355
|
+
// Fixes with their git status when the fix loop is here; the measured
|
|
356
|
+
// ledger otherwise. Either carries `footprints` when the fix named them.
|
|
357
|
+
const fixes = list?.fixes?.length ? list.fixes : (rawCache?.fixes ?? []);
|
|
358
|
+
const issues = buildIssues(rawCache?.records ?? [], { fixes, from: h?.rangeLo, to: h?.rangeHi, now: Date.now() });
|
|
359
|
+
body.innerHTML = renderIssues(issues, range, selectedIssue);
|
|
360
|
+
wireRange();
|
|
361
|
+
wireIssueRows();
|
|
362
|
+
});
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
Promise.all([loadHistory(), loadFixes()]).then(([h, list]) => {
|
|
366
|
+
if (!root || (tab !== next)) return;
|
|
367
|
+
body.innerHTML = renderOptimizations(h, list, range);
|
|
368
|
+
wireCrosshair(h);
|
|
369
|
+
wireFixButtons();
|
|
370
|
+
wireRange();
|
|
371
|
+
markSeen(list);
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ── the date range: re-fold the same bytes over fewer records ──
|
|
376
|
+
let range = { from: '', to: '' };
|
|
377
|
+
function wireRange() {
|
|
378
|
+
const from = body.querySelector('#sl-from'), to = body.querySelector('#sl-to'), clear = body.querySelector('#sl-range-clear');
|
|
379
|
+
// The range is shared by every ledger view; re-fold the tab that has it.
|
|
380
|
+
const apply = () => { range = { from: from?.value ?? '', to: to?.value ?? '' }; histCache = null; show(tab); };
|
|
381
|
+
if (from) from.onchange = apply;
|
|
382
|
+
if (to) to.onchange = apply;
|
|
383
|
+
if (clear) clear.onclick = () => { range = { from: '', to: '' }; histCache = null; show(tab); };
|
|
384
|
+
}
|
|
385
|
+
function wireIssueRows() {
|
|
386
|
+
for (const r of body.querySelectorAll('[data-issue]')) {
|
|
387
|
+
const pick = () => {
|
|
388
|
+
const id = r.dataset.issue;
|
|
389
|
+
selectedIssue = selectedIssue === id ? null : id;
|
|
390
|
+
const top = body.scrollTop;
|
|
391
|
+
show('issues');
|
|
392
|
+
// Keep the reader where they were: the re-render must not scroll away
|
|
393
|
+
// from the row they just opened.
|
|
394
|
+
requestAnimationFrame(() => { if (body) body.scrollTop = top; });
|
|
395
|
+
};
|
|
396
|
+
r.onclick = pick;
|
|
397
|
+
r.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(); } };
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
let fixesCache = null, settingsCache = null;
|
|
402
|
+
function loadFixes() {
|
|
403
|
+
if (fixesCache) return Promise.resolve(fixesCache);
|
|
404
|
+
if (!host.fixes) return Promise.resolve(null);
|
|
405
|
+
return Promise.resolve().then(() => host.fixes()).then((l) => { fixesCache = l; updateBadge(l); return l; }).catch(() => null);
|
|
406
|
+
}
|
|
407
|
+
function loadSettings() {
|
|
408
|
+
if (settingsCache) return Promise.resolve(settingsCache);
|
|
409
|
+
if (!host.settings) return Promise.resolve(null);
|
|
410
|
+
return Promise.resolve().then(() => host.settings()).then((s) => { settingsCache = s; return s; }).catch(() => null);
|
|
411
|
+
}
|
|
412
|
+
// ── the Fixes badge: proposals you have not looked at yet ──
|
|
413
|
+
function seenIds() { try { return new Set(JSON.parse(localStorage.getItem(SEEN_KEY) || '[]')); } catch { return new Set(); } }
|
|
414
|
+
function markSeen(list) {
|
|
415
|
+
if (!list?.fixes) return;
|
|
416
|
+
try { localStorage.setItem(SEEN_KEY, JSON.stringify(list.fixes.map((f) => f.id).filter(Boolean).slice(0, 500))); } catch { /* storage may be unavailable */ }
|
|
417
|
+
updateBadge(list);
|
|
418
|
+
}
|
|
419
|
+
function updateBadge(list) {
|
|
420
|
+
const b = root?.querySelector('[data-tab="optimizations"] [data-badge]');
|
|
421
|
+
if (!b) return;
|
|
422
|
+
const seen = seenIds();
|
|
423
|
+
const fresh = (list?.fixes ?? []).filter((f) => f.status === 'proposed' && !seen.has(f.id)).length;
|
|
424
|
+
b.textContent = fresh ? String(fresh) : '';
|
|
425
|
+
b.style.display = fresh ? 'inline-block' : 'none';
|
|
426
|
+
}
|
|
427
|
+
function wireFixButtons() {
|
|
428
|
+
for (const b of body.querySelectorAll('button[data-fix]')) {
|
|
429
|
+
b.onclick = () => {
|
|
430
|
+
const id = b.dataset.fix, action = b.dataset.action;
|
|
431
|
+
if (action === 'merge' && !window.confirm('Merge this fix into main?')) return;
|
|
432
|
+
const msg = body.querySelector(`[data-fix-msg="${CSS.escape(id)}"]`);
|
|
433
|
+
for (const x of body.querySelectorAll(`button[data-fix="${CSS.escape(id)}"]`)) x.disabled = true;
|
|
434
|
+
if (msg) { msg.style.color = C.dim; msg.textContent = `${action === 'merge' ? 'merging' : 'rejecting'}…`; }
|
|
435
|
+
Promise.resolve().then(() => host.fixAction(id, action)).then((r) => {
|
|
436
|
+
fixesCache = null; histCache = null;
|
|
437
|
+
if (r && r.error) { if (msg) { msg.style.color = C.warn; msg.textContent = r.error; } for (const x of body.querySelectorAll(`button[data-fix="${CSS.escape(id)}"]`)) x.disabled = false; return; }
|
|
438
|
+
show('optimizations');
|
|
439
|
+
}).catch((e) => { if (msg) { msg.style.color = C.warn; msg.textContent = String(e?.message ?? e); } });
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
function wireSettings() {
|
|
444
|
+
for (const r of body.querySelectorAll('input[name="sl-automation"]')) {
|
|
445
|
+
r.onchange = () => {
|
|
446
|
+
const msg = body.querySelector('#sl-settings-msg');
|
|
447
|
+
if (msg) { msg.style.color = C.dim; msg.textContent = 'saving…'; }
|
|
448
|
+
Promise.resolve().then(() => host.saveSettings({ automation: r.value })).then((s) => {
|
|
449
|
+
settingsCache = s && !s.error ? s : null;
|
|
450
|
+
if (msg) { msg.style.color = s?.error ? C.warn : C.good; msg.textContent = s?.error ? s.error : `saved — automation: ${s.automation}`; }
|
|
451
|
+
}).catch((e) => { if (msg) { msg.style.color = C.warn; msg.textContent = String(e?.message ?? e); } });
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
let rawCache = null;
|
|
457
|
+
function loadHistory() {
|
|
458
|
+
if (histCache) return Promise.resolve(histCache);
|
|
459
|
+
if (!host.history) return Promise.resolve(null);
|
|
460
|
+
const fold = (raw) => {
|
|
461
|
+
if (!raw) return null;
|
|
462
|
+
const lo = range.from ? Date.parse(range.from) : -Infinity, hi = range.to ? Date.parse(range.to) : Infinity;
|
|
463
|
+
const h = raw.buckets ? raw : buildHistory(raw.records ?? [], { fixes: raw.fixes ?? [], buckets: 72, from: range.from || undefined, to: range.to || undefined });
|
|
464
|
+
h.rangeLo = lo === -Infinity ? undefined : lo; h.rangeHi = hi;
|
|
465
|
+
histCache = h;
|
|
466
|
+
return h;
|
|
467
|
+
};
|
|
468
|
+
if (rawCache) return Promise.resolve(fold(rawCache));
|
|
469
|
+
return Promise.resolve().then(() => host.history()).then((raw) => { rawCache = raw; return fold(raw); }).catch(() => null);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function wireCrosshair(h) {
|
|
473
|
+
const svg = body.querySelector('#sl-strips'), x = body.querySelector('#sl-x'), read = body.querySelector('#sl-read');
|
|
474
|
+
if (!svg || !h?.buckets?.length) return;
|
|
475
|
+
const n = h.buckets.length, xw = (W - PAD_L - PAD_R) / n;
|
|
476
|
+
svg.addEventListener('mousemove', (e) => {
|
|
477
|
+
const r = svg.getBoundingClientRect();
|
|
478
|
+
const vx = (e.clientX - r.left) / r.width * W;
|
|
479
|
+
const i = Math.floor((vx - PAD_L) / xw);
|
|
480
|
+
if (i < 0 || i >= n) { x.setAttribute('visibility', 'hidden'); return; }
|
|
481
|
+
const k = h.buckets[i], cx = (PAD_L + i * xw + xw / 2).toFixed(1);
|
|
482
|
+
x.setAttribute('x1', cx); x.setAttribute('x2', cx); x.setAttribute('visibility', 'visible');
|
|
483
|
+
read.textContent = `${when(k.from)} p95 ${num(k.p95Ms, 'ms')} calls ${num(k.calls)} hitches ${k.hitches}${k.worstMs ? ` (worst ${num(k.worstMs, 'ms')} ${k.worstGuess ?? ''})` : ''}${k.build ? ` build ${k.build}` : ''}`;
|
|
484
|
+
});
|
|
485
|
+
svg.addEventListener('mouseleave', () => { x.setAttribute('visibility', 'hidden'); read.textContent = 'hover the strips'; });
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return {
|
|
489
|
+
open() {
|
|
490
|
+
if (root) return;
|
|
491
|
+
root = el('div', `position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);z-index:99999;`
|
|
492
|
+
+ `width:${PANEL_W}px;max-width:calc(100vw - 24px);height:${PANEL_H}px;max-height:calc(100vh - 24px);display:flex;flex-direction:column;`
|
|
493
|
+
+ `background:${C.bg};border:1px solid ${C.line};border-radius:8px;padding:12px 18px;font:${FONT};color:${C.ink};box-shadow:0 4px 24px rgba(0,0,0,0.6);box-sizing:border-box`);
|
|
494
|
+
root.setAttribute('role', 'dialog'); root.setAttribute('aria-label', 'sloptimize perf debugger');
|
|
495
|
+
const tabs = el('div', `display:flex;gap:2px;border-bottom:1px solid ${C.rule};margin:0 0 6px`);
|
|
496
|
+
tabs.setAttribute('role', 'tablist');
|
|
497
|
+
for (const [k, label] of TABS) {
|
|
498
|
+
const b = el('button', `background:none;border:0;border-bottom:2px solid transparent;color:${C.mute};font:inherit;font-size:11px;letter-spacing:1.5px;text-transform:uppercase;padding:4px 10px 6px;cursor:pointer;margin-bottom:-1px`,
|
|
499
|
+
k === 'optimizations' ? `${label} <span data-badge style="display:none;background:${C.accent};color:#06101a;border-radius:9px;padding:0 6px;font-size:10px;letter-spacing:0;vertical-align:1px"></span>` : label);
|
|
500
|
+
b.dataset.tab = k; b.setAttribute('role', 'tab'); b.type = 'button';
|
|
501
|
+
b.onclick = () => show(k);
|
|
502
|
+
b.onfocus = () => { b.style.outline = `1px solid ${C.accent}`; b.style.outlineOffset = '-1px'; };
|
|
503
|
+
b.onblur = () => { b.style.outline = 'none'; };
|
|
504
|
+
tabs.appendChild(b);
|
|
505
|
+
}
|
|
506
|
+
// The title leads, top-left, ahead of the tabs (admin, 2026-08-28).
|
|
507
|
+
const brand = el('span', `align-self:center;margin-right:14px;font-size:11px;letter-spacing:2px;color:${C.accent}`, 'SLOPTIMIZE');
|
|
508
|
+
tabs.prepend(brand);
|
|
509
|
+
// The body is the ONLY thing that scrolls; the tabs above and the
|
|
510
|
+
// keyframe prompt below hold their place whatever the tab contains.
|
|
511
|
+
body = el('div', 'overflow-y:auto;overflow-x:hidden;flex:1 1 auto;min-height:0;padding-right:6px');
|
|
512
|
+
const ask = el('div', '', H('Describe what you just saw to save a keyframe <span style="color:' + C.dim + ';text-transform:none;letter-spacing:0">(Enter sends · Esc just closes)</span>'));
|
|
513
|
+
input = el('input', `width:100%;box-sizing:border-box;background:${C.field};border:1px solid rgba(120,150,190,0.4);border-radius:4px;color:#e8f0ff;padding:6px 8px;font:13px system-ui,sans-serif;outline:none`);
|
|
514
|
+
input.type = 'text'; input.maxLength = 200; input.placeholder = 'e.g. huge stutter when the buildings loaded';
|
|
515
|
+
input.onfocus = () => { input.style.borderColor = C.accent; }; input.onblur = () => { input.style.borderColor = 'rgba(120,150,190,0.4)'; };
|
|
516
|
+
root.append(tabs, body, ask, input);
|
|
517
|
+
document.body.appendChild(root);
|
|
518
|
+
document.addEventListener('keydown', onKey, true);
|
|
519
|
+
document.addEventListener('keyup', swallow, true);
|
|
520
|
+
show(tab);
|
|
521
|
+
loadFixes(); // the badge counts unseen proposals whichever tab is open
|
|
522
|
+
input.focus();
|
|
523
|
+
},
|
|
524
|
+
close: () => close(null),
|
|
525
|
+
submit,
|
|
526
|
+
isOpen: () => root !== null,
|
|
527
|
+
/** Forget the folded ledger so the next open re-fetches it. */
|
|
528
|
+
refresh() { histCache = null; rawCache = null; fixesCache = null; settingsCache = null; },
|
|
529
|
+
};
|
|
530
|
+
}
|