agent-device-lab 0.0.0-stage → 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.
Files changed (54) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +10 -0
  3. package/README.md +142 -2
  4. package/assets/dashboard/app.css +238 -0
  5. package/assets/dashboard/app.js +976 -0
  6. package/assets/dashboard/index.html +120 -0
  7. package/bin/agentlab.js +8 -0
  8. package/dist/cli/ci.js +443 -0
  9. package/dist/cli/flow.js +127 -0
  10. package/dist/cli/main.js +855 -0
  11. package/dist/cli/signals.js +38 -0
  12. package/dist/core/action-log.js +125 -0
  13. package/dist/core/auth.js +175 -0
  14. package/dist/core/bundle.js +432 -0
  15. package/dist/core/checks.js +137 -0
  16. package/dist/core/ci.js +389 -0
  17. package/dist/core/commands.js +366 -0
  18. package/dist/core/control.js +217 -0
  19. package/dist/core/detectors.js +392 -0
  20. package/dist/core/devices.js +83 -0
  21. package/dist/core/doctor.js +203 -0
  22. package/dist/core/explore-safety.js +110 -0
  23. package/dist/core/extract.js +1445 -0
  24. package/dist/core/feed.js +493 -0
  25. package/dist/core/findings.js +179 -0
  26. package/dist/core/format.js +311 -0
  27. package/dist/core/init.js +607 -0
  28. package/dist/core/lab.js +1979 -0
  29. package/dist/core/migrate.js +35 -0
  30. package/dist/core/observation.js +161 -0
  31. package/dist/core/ownership.js +142 -0
  32. package/dist/core/process-group.js +206 -0
  33. package/dist/core/process-identity.js +56 -0
  34. package/dist/core/profile.js +816 -0
  35. package/dist/core/project-runner.js +359 -0
  36. package/dist/core/replay.js +303 -0
  37. package/dist/core/reproduction.js +36 -0
  38. package/dist/core/scan-policy.js +156 -0
  39. package/dist/core/scan-report.js +145 -0
  40. package/dist/core/scan.js +455 -0
  41. package/dist/core/schema.js +39 -0
  42. package/dist/core/services.js +134 -0
  43. package/dist/core/steps.js +80 -0
  44. package/dist/core/sweep.js +138 -0
  45. package/dist/core/trace-sanitize.js +641 -0
  46. package/dist/core/url-redact.js +25 -0
  47. package/dist/core/versions.js +29 -0
  48. package/dist/core/zip.js +264 -0
  49. package/dist/daemon/main.js +160 -0
  50. package/dist/daemon/state.js +93 -0
  51. package/dist/dashboard/open.js +25 -0
  52. package/dist/dashboard/server.js +366 -0
  53. package/dist/mcp/server.js +89 -0
  54. package/package.json +58 -4
@@ -0,0 +1,976 @@
1
+ // Agent Device Lab dashboard: monitoring, plus supervision (pause, take over, stop) when the page was
2
+ // opened with the person's control link. Page text from the app under test is untrusted: everything
3
+ // is rendered with textContent, never as HTML.
4
+ 'use strict';
5
+
6
+ const token = new URLSearchParams(location.hash.slice(1)).get('token') || '';
7
+ const api = (path) => `${path}${path.includes('?') ? '&' : '?'}token=${encodeURIComponent(token)}`;
8
+ const $ = (id) => document.getElementById(id);
9
+
10
+ const model = {
11
+ status: null, timeline: [], findings: new Map(), serverLog: [], sweeps: [], scans: [], selected: null, device: 'all',
12
+ runIndex: null, confidence: 'all', scenario: 'all', group: null, expanded: new Set(), suppressedOpen: false,
13
+ canControl: null, control: null, controlEpoch: 0,
14
+ };
15
+ const frames = new Map(); // finding id (or "F12-0" for an extra evidence frame) → object URL
16
+ const sweepFrames = new Map(); // "S1/mobile-320" → object URL
17
+ const scanFrames = new Map(); // "R1/0/s0" → object URL (fetched when a thumbnail is first drawn)
18
+ let source = null;
19
+
20
+ function h(tag, attrs = {}, ...children) {
21
+ const el = document.createElement(tag);
22
+ for (const [k, v] of Object.entries(attrs)) {
23
+ if (v === undefined || v === null || v === false) continue;
24
+ if (k === 'class') el.className = v;
25
+ else if (k === 'text') el.textContent = v;
26
+ else if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
27
+ else el.setAttribute(k, v === true ? '' : String(v));
28
+ }
29
+ for (const c of children.flat()) if (c !== undefined && c !== null && c !== false) el.append(c instanceof Node ? c : document.createTextNode(String(c)));
30
+ return el;
31
+ }
32
+
33
+ /** replaceChildren, but skipping the null and false placeholders that conditional parts leave (they would render as text). */
34
+ const setChildren = (el, ...kids) => el.replaceChildren(...kids.flat().filter((c) => c !== undefined && c !== null && c !== false));
35
+
36
+ const time = (iso) => new Date(iso).toLocaleTimeString([], { hour12: false }) + '.' + String(new Date(iso).getMilliseconds()).padStart(3, '0');
37
+ const q = (s) => JSON.stringify(s);
38
+
39
+ // ---------- connection ----------
40
+
41
+ function setConn(state, text) {
42
+ const el = $('conn');
43
+ el.dataset.conn = state;
44
+ el.textContent = text;
45
+ }
46
+
47
+ function connect() {
48
+ if (!token) {
49
+ setConn('denied', 'no token');
50
+ notice('This page needs its access link. Run `agentlab ui` (or read the `dashboard` URL from the start result) and open that URL.');
51
+ return;
52
+ }
53
+ loadCapabilities();
54
+ source = new EventSource(api('/api/events'));
55
+ source.addEventListener('open', () => setConn('live', 'live'));
56
+ source.addEventListener('snapshot', (e) => {
57
+ const snap = JSON.parse(e.data);
58
+ model.timeline = snap.timeline;
59
+ model.findings = new Map(snap.findings.map((f) => [f.id, f]));
60
+ model.serverLog = snap.serverLog;
61
+ model.sweeps = snap.sweeps || [];
62
+ model.scans = snap.scans || [];
63
+ for (const f of snap.findings) if (f.hasFrame) fetchFrame(f.id);
64
+ for (const sw of model.sweeps) for (const d of sw.devices) if (d.hasFrame) fetchSweepFrame(sw.id, d.id);
65
+ renderSweep();
66
+ renderScan();
67
+ setStatus(snap.status);
68
+ renderTimeline();
69
+ renderFindings();
70
+ renderLog();
71
+ });
72
+ source.addEventListener('feed', (e) => apply(JSON.parse(e.data).event));
73
+ source.addEventListener('error', () => {
74
+ const s = model.status && model.status.state;
75
+ if (source.readyState === EventSource.CLOSED) {
76
+ // The server refused the stream (e.g. 401 after a new session rotated the token) or went away.
77
+ if (s === 'ended' || s === 'failed') return setConn('closed', 'session over');
78
+ setConn('denied', 'disconnected');
79
+ checkAccess();
80
+ } else {
81
+ setConn('reconnecting', 'reconnecting…');
82
+ }
83
+ });
84
+ }
85
+
86
+ async function checkAccess() {
87
+ try {
88
+ const res = await fetch(api('/api/state'));
89
+ if (res.status === 401) notice('This link belongs to an earlier session, so access was revoked. Run `agentlab ui` for the current one.');
90
+ } catch {
91
+ notice('The session process has exited. Its run directory still has actions.jsonl, findings.json and evidence frames.');
92
+ }
93
+ }
94
+
95
+ function notice(text) {
96
+ const el = $('notice');
97
+ el.hidden = false;
98
+ el.textContent = text;
99
+ }
100
+
101
+ function apply(ev) {
102
+ switch (ev.type) {
103
+ case 'reset':
104
+ model.timeline = []; model.findings = new Map(); model.serverLog = []; model.sweeps = []; model.scans = []; model.selected = null; model.device = 'all';
105
+ model.runIndex = null; model.confidence = 'all'; model.scenario = 'all'; model.group = null; model.expanded = new Set();
106
+ model.control = null; model.controlEpoch++;
107
+ renderControl();
108
+ renderTimeline(); renderFindings(); renderLog(); renderSweep(); renderScan();
109
+ break;
110
+ case 'status': setStatus(ev.status); break;
111
+ case 'timeline': model.timeline.push(ev.entry); if (model.timeline.length > 500) model.timeline.shift(); appendEntry(ev.entry); break;
112
+ case 'finding': {
113
+ const known = model.findings.has(ev.finding.id);
114
+ model.findings.set(ev.finding.id, ev.finding);
115
+ if (ev.finding.hasFrame) fetchFrame(ev.finding.id);
116
+ renderFindings(known ? undefined : ev.finding.id);
117
+ renderTimeline(); // entries show finding chips with severity
118
+ if (ev.finding.scenario) renderScan(); // the matrix counts findings per scenario and device
119
+ break;
120
+ }
121
+ case 'sweep': {
122
+ const i = model.sweeps.findIndex((x) => x.id === ev.sweep.id);
123
+ if (i >= 0) model.sweeps[i] = ev.sweep; else model.sweeps.push(ev.sweep);
124
+ for (const d of ev.sweep.devices) if (d.hasFrame) fetchSweepFrame(ev.sweep.id, d.id);
125
+ renderSweep();
126
+ renderFindings();
127
+ break;
128
+ }
129
+ case 'scan': {
130
+ const prev = model.scans[model.scans.length - 1];
131
+ const i = model.scans.findIndex((x) => x.id === ev.scan.id);
132
+ if (i >= 0) model.scans[i] = ev.scan; else model.scans.push(ev.scan);
133
+ if (!prev || prev.id !== model.scans[model.scans.length - 1].id) model.runIndex = null;
134
+ renderScan();
135
+ renderFindings();
136
+ break;
137
+ }
138
+ case 'server-log':
139
+ model.serverLog.push(ev.line);
140
+ if (model.serverLog.length > 500) model.serverLog.shift();
141
+ renderLog();
142
+ break;
143
+ }
144
+ }
145
+
146
+ async function fetchFrame(id) {
147
+ if (frames.has(id)) return;
148
+ frames.set(id, null);
149
+ try {
150
+ const res = await fetch(api(`/api/frames/${id}.jpg`));
151
+ if (!res.ok) throw new Error(String(res.status));
152
+ frames.set(id, URL.createObjectURL(await res.blob()));
153
+ if (model.selected && id.split('-')[0] === model.selected) renderDetail();
154
+ } catch {
155
+ frames.delete(id);
156
+ }
157
+ }
158
+
159
+ async function fetchSweepFrame(sweepId, device) {
160
+ const key = `${sweepId}/${device}`;
161
+ if (sweepFrames.has(key)) return;
162
+ sweepFrames.set(key, null);
163
+ try {
164
+ const res = await fetch(api(`/api/sweeps/${sweepId}/${device}.jpg`));
165
+ if (!res.ok) throw new Error(String(res.status));
166
+ sweepFrames.set(key, URL.createObjectURL(await res.blob()));
167
+ renderSweep();
168
+ } catch {
169
+ sweepFrames.delete(key);
170
+ }
171
+ }
172
+
173
+ /** The object URL of a scan state's frame, or null while it loads (a re-render follows). */
174
+ function scanFrameUrl(scanId, runIndex, stateId) {
175
+ const key = `${scanId}/${runIndex}/${stateId}`;
176
+ if (scanFrames.has(key)) return scanFrames.get(key);
177
+ scanFrames.set(key, null);
178
+ fetch(api(`/api/scans/${key}.jpg`))
179
+ .then((res) => { if (!res.ok) throw new Error(String(res.status)); return res.blob(); })
180
+ .then((blob) => { scanFrames.set(key, URL.createObjectURL(blob)); renderScan(); renderDetail(); })
181
+ .catch(() => { scanFrames.delete(key); });
182
+ return null;
183
+ }
184
+
185
+ // ---------- sweep + device selection ----------
186
+
187
+ /** Select a device (clicking the selected one again returns to all). */
188
+ function setDevice(id) {
189
+ model.device = id === 'all' || model.device === id ? 'all' : id;
190
+ if (model.selected && model.device !== 'all' && model.findings.get(model.selected)?.device !== model.device) model.selected = null;
191
+ renderSweep();
192
+ renderFindings();
193
+ }
194
+
195
+ function renderSweep() {
196
+ renderStage();
197
+ const box = $('sweep');
198
+ const sw = model.sweeps[model.sweeps.length - 1];
199
+ if (!sw) { box.hidden = true; return; }
200
+ box.hidden = false;
201
+ const done = sw.devices.filter((d) => d.state === 'done' || d.state === 'error').length;
202
+ $('sweep-caption').textContent = `${sw.id} · ${sw.route} · ${sw.state === 'running' ? `${done}/${sw.devices.length} widths` : `done in ${sw.ms} ms`}`;
203
+ $('sweep-devices').replaceChildren(...sw.devices.map((d) => {
204
+ const state = d.state === 'done' ? (d.findings.length ? 'bad' : 'clean') : d.state;
205
+ const label = d.state === 'pending' ? 'waiting' : d.state === 'running' ? 'measuring…' : d.state === 'error' ? `error: ${d.error || ''}`
206
+ : d.findings.length ? `${d.findings.length} finding${d.findings.length === 1 ? '' : 's'} (${d.confirmed} confirmed)` : 'clean';
207
+ const frame = sweepFrames.get(`${sw.id}/${d.id}`);
208
+ return h('li', {}, h('button', { type: 'button', 'data-device': d.id, 'aria-pressed': model.device === d.id ? 'true' : 'false', onclick: () => setDevice(d.id) },
209
+ h('div', {}, h('strong', { text: d.id }), ` ${d.width}×${d.height}`),
210
+ h('div', { class: `state ${state}`, text: label }),
211
+ frame ? h('img', { src: frame, alt: `${sw.route} at ${d.width}px` }) : null));
212
+ }));
213
+ }
214
+
215
+ /** Devices seen in findings or sweeps, narrowest first. */
216
+ function knownDevices() {
217
+ const widths = new Map();
218
+ for (const f of model.findings.values()) widths.set(f.device, f.viewportWidth);
219
+ for (const sw of model.sweeps) for (const d of sw.devices) widths.set(d.id, d.width);
220
+ if (model.status && model.status.device) widths.set(model.status.device.id, model.status.device.viewport.width);
221
+ return [...widths.entries()].sort((a, b) => a[1] - b[1]).map(([id]) => id);
222
+ }
223
+
224
+ function renderDeviceFilter() {
225
+ const devices = knownDevices();
226
+ const count = (id) => [...model.findings.values()].filter((f) => id === 'all' || f.device === id).length;
227
+ $('device-filter').replaceChildren(...(devices.length > 1 ? ['all', ...devices] : []).map((id) =>
228
+ h('button', { type: 'button', 'data-device': id, 'aria-pressed': model.device === id ? 'true' : 'false', onclick: () => setDevice(id), text: `${id === 'all' ? 'All' : id} (${count(id)})` })));
229
+ }
230
+
231
+ // ---------- status + viewport ----------
232
+
233
+ function setStatus(s) {
234
+ const prev = model.status;
235
+ model.status = s;
236
+ $('state').dataset.state = s.state;
237
+ $('state').textContent = s.state;
238
+ $('session').textContent = s.sessionId || '';
239
+ $('s-project').textContent = s.project || '–';
240
+ const d = s.device;
241
+ $('s-device').textContent = d ? `${d.id} · ${d.viewport.width}×${d.viewport.height} @${d.deviceScaleFactor}x${d.hasTouch ? ' touch' : ''} · ${s.headed ? 'headed' : 'headless'}` : '–';
242
+ $('s-url').textContent = s.url || '–';
243
+ $('s-url').title = s.title ? `${s.title} — ${s.url}` : (s.url || '');
244
+ for (const [id, n] of [['s-console', s.consoleErrors], ['s-failed', s.failedRequests], ['s-findings', s.findings]]) {
245
+ $(id).textContent = String(n);
246
+ $(id).classList.toggle('bad', n > 0);
247
+ }
248
+ const srv = s.server;
249
+ const services = s.services || [];
250
+ $('s-server').textContent = services.length > 1
251
+ ? services.map((x) => `${x.name} ${x.owned ? `started${x.pid ? ` (pid ${x.pid})` : ''}` : 'reused'}${x.url ? ` ${x.url}` : ''}, ready in ${x.readyMs} ms`).join(' · ')
252
+ : srv
253
+ ? `${srv.url} · ${srv.owned ? `started by the lab${srv.pid ? ` (pid ${srv.pid})` : ''}, ready in ${srv.readyMs} ms` : 'reused, not owned'}${srv.command ? ` · ${srv.command}` : ''}`
254
+ : (s.state === 'starting' ? (services.length ? `starting… ${services.map((x) => `${x.name} ready`).join(', ')}` : 'starting…') : '–');
255
+ if (s.tab) $('s-url').textContent = `[${s.tab}] ${$('s-url').textContent}`;
256
+
257
+ const box = $('start-error');
258
+ if (s.startError) {
259
+ const e = s.startError;
260
+ const tail = e.details && Array.isArray(e.details.logTail) ? e.details.logTail : [];
261
+ box.replaceChildren(
262
+ h('h3', { text: `Start failed: ${e.code}` }),
263
+ h('div', { text: e.message }),
264
+ e.hint ? h('div', { text: `Hint: ${e.hint}` }) : null,
265
+ tail.length ? h('pre', { text: tail.join('\n') }) : null,
266
+ );
267
+ box.hidden = false;
268
+ } else {
269
+ box.hidden = true;
270
+ }
271
+ if (s.state === 'ended' || s.state === 'failed') {
272
+ setConn('closed', 'session over');
273
+ if (source) source.close();
274
+ }
275
+ if (s.control) { model.control = s.control; model.controlEpoch++; }
276
+ renderControl();
277
+ if (!prev || prev.state !== s.state) updateViewport();
278
+ renderStage();
279
+ }
280
+
281
+ /** Stream frames only while the session is live and this tab is visible. */
282
+ /**
283
+ * What the viewport is showing right now: the width a sweep or scan is measuring, otherwise the
284
+ * session's own page. Sizes the device frame to match, so a change of width is visible.
285
+ */
286
+ function renderStage() {
287
+ const sw = model.sweeps[model.sweeps.length - 1];
288
+ const sd = sw && sw.state === 'running' ? sw.devices.find((d) => d.state === 'running') : undefined;
289
+ const scan = latestScan();
290
+ const cur = scan && scan.state === 'running' ? scan.current : undefined;
291
+ const run = cur ? scan.runs.find((r) => r.scenario === cur.scenario && r.device === cur.device) : undefined;
292
+ const d = model.status && model.status.device;
293
+ let what, id, w, h;
294
+ if (sd) { what = `Sweep of ${sw.route}`; id = sd.id; w = sd.width; h = sd.height; }
295
+ else if (run) { what = `Scan: ${cur.scenario}${cur.state ? ` — ${cur.state}` : ''}`; id = run.device; w = run.width; h = run.height; }
296
+ else if (d) { what = 'The agent\'s page'; id = d.id; w = d.viewport.width; h = d.viewport.height; }
297
+ const now = $('vp-now');
298
+ if (!w || !h) { now.replaceChildren(); return; }
299
+ $('device').style.setProperty('--vp-w', String(w));
300
+ $('device').style.setProperty('--vp-h', String(h));
301
+ now.replaceChildren(h_('strong', what), ` · ${id} · ${w}×${h}`);
302
+ }
303
+ const h_ = (tag, text) => h(tag, { text });
304
+
305
+ function updateViewport() {
306
+ const img = $('viewport');
307
+ const s = model.status && model.status.state;
308
+ const live = (s === 'active' || s === 'starting') && document.visibilityState === 'visible';
309
+ if (live && !img.getAttribute('src')) {
310
+ img.src = api('/api/viewport');
311
+ $('vp-caption').textContent = 'live · capped at 5 fps';
312
+ } else if (!live && img.getAttribute('src') && s !== 'ended' && s !== 'failed') {
313
+ img.removeAttribute('src'); // ends the stream, so capture stops
314
+ $('vp-caption').textContent = 'paused while this tab is hidden';
315
+ } else if (s === 'ended' || s === 'failed') {
316
+ $('vp-caption').textContent = 'session over · last frame';
317
+ }
318
+ }
319
+ // A multipart (MJPEG) image never fires `load` per frame; poll whether one has been decoded.
320
+ setInterval(() => {
321
+ const img = $('viewport');
322
+ $('vp-empty').hidden = img.naturalWidth > 0;
323
+ // A frame of another shape than the device named above it belongs to the previous width.
324
+ const w = Number($('device').style.getPropertyValue('--vp-w')), ht = Number($('device').style.getPropertyValue('--vp-h'));
325
+ const stale = img.naturalWidth > 0 && w > 0 && Math.abs(img.naturalWidth / img.naturalHeight - w / ht) > 0.02;
326
+ $('device').classList.toggle('switching', stale);
327
+ }, 200);
328
+ document.addEventListener('visibilitychange', updateViewport);
329
+
330
+ // ---------- timeline ----------
331
+
332
+ function renderTimeline() {
333
+ const list = $('timeline');
334
+ const stick = list.scrollTop + list.clientHeight >= list.scrollHeight - 40;
335
+ list.replaceChildren(...model.timeline.map(entryNode));
336
+ $('tl-count').textContent = model.timeline.length ? `${model.timeline.length} steps` : '';
337
+ if (stick) list.scrollTop = list.scrollHeight;
338
+ }
339
+
340
+ function appendEntry(entry) {
341
+ const list = $('timeline');
342
+ const stick = list.scrollTop + list.clientHeight >= list.scrollHeight - 40;
343
+ list.append(entryNode(entry));
344
+ $('tl-count').textContent = `${model.timeline.length} steps`;
345
+ if (stick) list.scrollTop = list.scrollHeight;
346
+ }
347
+
348
+ /**
349
+ * What an action changed, kept short: where it went, what opened and what was typed stay in view; the
350
+ * list of controls and headings that appeared or went is folded under one line.
351
+ */
352
+ const openChanges = new Set();
353
+ function changesNode(seq, changes) {
354
+ const isDetail = (c) => /^([+-] |… |\.\.\. |focus |\d+ unchanged controls)/.test(c);
355
+ const detail = changes.filter(isDetail);
356
+ const main = changes.filter((c) => !isDetail(c));
357
+ const fold = detail.length > 3;
358
+ const added = detail.filter((c) => c.startsWith('+ ')).length;
359
+ const removed = detail.filter((c) => c.startsWith('- ')).length;
360
+ const more = detail.some((c) => /^(… |\.\.\. )/.test(c));
361
+ const li = (c) => h('li', { class: 'small', text: c });
362
+ return h('div', { class: 'changes' },
363
+ main.length || !fold ? h('ul', {}, [...main, ...(fold ? [] : detail)].map(li)) : null,
364
+ fold ? h('details', { class: 'small', ...(openChanges.has(seq) ? { open: '' } : {}), ontoggle: (ev) => { if (ev.target.open) openChanges.add(seq); else openChanges.delete(seq); } },
365
+ h('summary', { text: `Page content changed: ${added}${more ? '+' : ''} appeared, ${removed}${more ? '+' : ''} went` }),
366
+ h('ul', {}, detail.map(li))) : null);
367
+ }
368
+
369
+ function entryNode(e) {
370
+ if (e.kind === 'control') {
371
+ // A full-width marker row: who held the session changed here. The summary is written by the server.
372
+ return h('li', { class: `entry marker${/stop/i.test(e.summary) ? ' stopped' : ''}`, 'data-seq': e.seq, 'data-kind': 'control' },
373
+ h('span', { class: 'time', text: time(e.at) }), h('span', { class: 'marker-text', text: e.summary }));
374
+ }
375
+ if (e.kind === 'human') {
376
+ return h('li', { class: 'entry human', 'data-seq': e.seq, 'data-kind': 'human' },
377
+ h('div', { class: 'head' },
378
+ h('span', { class: 'time', text: time(e.at) }),
379
+ h('span', { class: 'pico', title: 'a person did this', 'aria-hidden': 'true' }),
380
+ h('span', { class: 'what', text: e.summary })));
381
+ }
382
+ if (e.kind === 'refused') {
383
+ return h('li', { class: 'entry refused error', 'data-seq': e.seq, 'data-kind': 'refused' },
384
+ h('div', { class: 'head' },
385
+ h('span', { class: 'time', text: time(e.at) }),
386
+ h('span', { class: 'chip refused', text: 'not run' }),
387
+ h('span', { class: 'what', text: e.summary }),
388
+ e.error ? h('span', { class: 'outcome mono', text: e.error.code }) : null),
389
+ e.error ? h('div', { class: 'err' }, e.error.message, e.error.hint ? h('div', { class: 'muted small', text: `hint: ${e.error.hint}` }) : null) : null);
390
+ }
391
+ const findings = e.findings.map((id) => model.findings.get(id)).filter(Boolean);
392
+ const pan = e.target && e.outcome === 'success' && findings.some((f) => f.kind === 'horizontal-pan-required');
393
+ const meta = e.kind === 'observe' || e.kind === 'stop';
394
+ const cls = ['entry', pan ? 'pan' : e.outcome, meta ? 'meta' : ''].join(' ');
395
+ const t = e.target;
396
+ const what = t && t.role ? `${e.kind} ${t.role} ${q(t.name)}${e.detail ? ` ${e.detail}` : ''}` : e.summary;
397
+ const changes = e.changes || [];
398
+ return h('li', { class: cls, 'data-seq': e.seq },
399
+ h('div', { class: 'head' },
400
+ h('span', { class: 'time', text: time(e.at) }),
401
+ h('span', { class: 'what', text: what }),
402
+ t && t.ref ? h('span', { class: 'muted mono', text: t.ref }) : null,
403
+ e.value !== undefined ? h('span', { class: 'mono', text: `= ${e.value}` }) : null,
404
+ h('span', { class: 'outcome', text: e.outcome === 'success' ? 'ok' : `error ${e.error ? e.error.code : ''}` }),
405
+ e.durationMs !== undefined ? h('span', { class: 'dur', text: `${e.durationMs} ms${e.method ? ` · ${e.method}` : ''}` }) : null,
406
+ e.settle ? h('span', { class: `chip ${e.settle.reason === 'timeout' ? 'timeout' : ''}`, text: e.settle.reason === 'timeout' ? `settle timeout ${e.settle.ms} ms` : `settled ${e.settle.ms} ms` }) : null,
407
+ ),
408
+ pan ? h('div', { class: 'banner', text: 'The action succeeded, but a person would first have to pan sideways to find this control.' }) : null,
409
+ e.settleText ? h('div', { class: 'settle small', text: e.settleText }) : null,
410
+ e.error ? h('div', { class: 'err' }, h('strong', { text: `${e.error.code}: ` }), e.error.message, e.error.hint ? h('div', { class: 'muted small', text: `hint: ${e.error.hint}` }) : null) : null,
411
+ changes.length && !meta ? changesNode(e.seq, changes) : null,
412
+ e.notes.length ? h('div', { class: 'notes small' }, e.notes.map((n) => h('div', { text: n }))) : null,
413
+ (e.newConsoleErrors || e.newFailedRequests) ? h('div', { class: 'small', text: `${e.newConsoleErrors || 0} new console errors · ${e.newFailedRequests || 0} new failed requests` }) : null,
414
+ findings.length ? h('div', {}, findings.map(findingChip)) : null,
415
+ );
416
+ }
417
+
418
+ function findingChip(f) {
419
+ return h('button', { class: `fchip sev-${f.severity}`, type: 'button', title: f.message, onclick: () => select(f.id), text: `${f.id} ${f.severity} ${f.kind}` });
420
+ }
421
+
422
+ function renderLog() {
423
+ const pre = $('serverlog');
424
+ pre.textContent = model.serverLog.join('\n');
425
+ $('log-count').textContent = model.serverLog.length ? `(${model.serverLog.length} lines)` : '';
426
+ }
427
+
428
+ // ---------- scan ----------
429
+
430
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
431
+ const latestScan = () => model.scans[model.scans.length - 1];
432
+
433
+ function selectedRun() {
434
+ const scan = latestScan();
435
+ return scan && model.runIndex !== null ? scan.runs.find((r) => r.index === model.runIndex) : undefined;
436
+ }
437
+
438
+ function selectRun(index) {
439
+ model.runIndex = model.runIndex === index ? null : index;
440
+ renderScan();
441
+ renderFindings();
442
+ }
443
+
444
+ /** What a run cell says. A run that never got to its checks must not read like a clean or a failed check. */
445
+ function runLabel(run) {
446
+ switch (run.state) {
447
+ case 'pending': return 'pending';
448
+ case 'running': return 'running…';
449
+ case 'ok': {
450
+ // Width-comparison findings are attached after the run finishes, so count what the findings list holds.
451
+ const listed = [...model.findings.values()].filter((f) => f.scenario === run.scenario && f.device === run.device).length;
452
+ return `ok · ${plural(run.states.length, 'state')} · ${plural(Math.max(listed, run.findings.length), 'finding')}`;
453
+ }
454
+ default:
455
+ if (['context', 'load', 'setup'].includes(run.failedAt)) return 'failed before checks completed';
456
+ if (run.failedAt === 'cleanup') return 'cleanup failed';
457
+ if (run.failedAt === 'checks') return 'failed during checks';
458
+ return 'failed';
459
+ }
460
+ }
461
+
462
+ function renderScan() {
463
+ renderStage();
464
+ const scan = latestScan();
465
+ $('scan').hidden = !scan;
466
+ if (!scan) return;
467
+ const cur = scan.current;
468
+ $('scan-caption').textContent = scan.state === 'running'
469
+ ? `${scan.id} · running${cur ? ` (${cur.scenario} @ ${cur.device}${cur.state ? ` — ${cur.state}` : ''})` : ''}`
470
+ : `${scan.id} · done in ${scan.ms} ms`;
471
+
472
+ const v = scan.verdict;
473
+ setChildren($('scan-verdict'),
474
+ v ? h('span', { class: `verdict ${v.result}`, text: v.result.toUpperCase() }) : null,
475
+ h('span', { class: 'muted small', text: `exploration ${scan.explore ? 'on' : 'off'}` }),
476
+ v && v.reasons.length ? h('ul', { class: 'reasons' }, v.reasons.map((r) => h('li', { text: r }))) : null,
477
+ );
478
+
479
+ const devices = new Map();
480
+ for (const r of scan.runs) devices.set(r.device, r.width);
481
+ const cols = [...devices.entries()].sort((a, b) => a[1] - b[1]);
482
+ const scenarios = [...new Set(scan.runs.map((r) => r.scenario))];
483
+ $('scan-matrix').replaceChildren(
484
+ h('thead', {}, h('tr', {}, h('th', { text: 'Scenario' }), cols.map(([id, w]) => h('th', { text: `${id} · ${w}px` })))),
485
+ h('tbody', {}, scenarios.map((name) => h('tr', {},
486
+ h('th', { scope: 'row', text: name }),
487
+ cols.map(([id]) => {
488
+ const run = scan.runs.find((r) => r.scenario === name && r.device === id);
489
+ if (!run) return h('td', { class: 'muted', text: '–' });
490
+ const before = run.state === 'failed' && ['context', 'load', 'setup'].includes(run.failedAt);
491
+ return h('td', {}, h('button', {
492
+ type: 'button', class: `cell cell-${run.state}${before ? ' cell-before' : ''}`, 'data-run': run.index,
493
+ 'aria-pressed': model.runIndex === run.index ? 'true' : 'false', onclick: () => selectRun(run.index),
494
+ }, h('span', { class: 'cell-state', text: runLabel(run) }),
495
+ run.state === 'failed' && run.error ? h('span', { class: 'cell-error', text: run.error }) : null));
496
+ })))),
497
+ );
498
+ renderRun();
499
+ }
500
+
501
+ function stateNode(scan, run, st) {
502
+ const frame = st.hasFrame ? scanFrameUrl(scan.id, run.index, st.id) : null;
503
+ const chips = st.findings.map((id) => model.findings.get(id)).filter(Boolean).map(findingChip);
504
+ return h('li', { class: 'state', 'data-state': st.id },
505
+ st.hasFrame ? (frame ? h('img', { class: 'thumb', src: frame, alt: `${run.scenario} at ${run.device}: ${st.label}` }) : h('div', { class: 'thumb ph muted small', text: 'loading…' })) : h('div', { class: 'thumb ph muted small', text: 'no frame' }),
506
+ h('div', { class: 'state-body' },
507
+ h('div', {}, h('strong', { text: `${st.id} · ${st.label}` }), ` `, h('span', { class: `chip state-${st.status}`, text: st.status })),
508
+ h('div', { class: 'small mono', text: st.path.length ? st.path.join(' → ') : '(as loaded)' }),
509
+ h('div', { class: 'small muted', text: [st.route ? `route ${st.route}` : '', st.restore ? `restore: ${st.restore}` : '', st.dialog ? `dialog: ${st.dialog}` : ''].filter(Boolean).join(' · ') }),
510
+ chips.length ? h('div', {}, chips) : null,
511
+ st.blocked && st.blocked.length ? h('div', { class: 'small', text: `blocked requests: ${st.blocked.join(', ')}` }) : null,
512
+ st.error ? h('div', { class: 'small err-text', text: st.error }) : null));
513
+ }
514
+
515
+ function renderRun() {
516
+ const box = $('scan-run');
517
+ const scan = latestScan();
518
+ const run = selectedRun();
519
+ if (!scan || !run) { box.hidden = true; return; }
520
+ const skipped = run.decisions.filter((d) => d.verdict === 'skip');
521
+ const explored = run.decisions.filter((d) => d.verdict === 'explore');
522
+ setChildren(box,
523
+ h('h3', {}, `${run.scenario} @ ${run.device} `, h('span', { class: 'muted small', text: `${run.width}×${run.height} · ${runLabel(run)}${run.ms !== undefined ? ` · ${run.ms} ms` : ''}` })),
524
+ run.state === 'failed' && run.error ? h('div', { class: `run-error${['context', 'load', 'setup'].includes(run.failedAt) ? ' before' : ''}`, text: run.error }) : null,
525
+ h('div', { class: 'k', text: `States (${run.states.length})` }),
526
+ run.states.length ? h('ul', { class: 'states' }, run.states.map((st) => stateNode(scan, run, st))) : h('div', { class: 'muted small', text: 'No state was measured.' }),
527
+ h('div', { class: 'k', text: `Skipped controls (${skipped.length}${run.decisionsOmitted ? ` shown, ${run.decisionsOmitted} more not listed` : ''})` }),
528
+ skipped.length
529
+ ? h('table', { class: 'skipped' }, h('thead', {}, h('tr', {}, ['Role', 'Name', 'Context', 'Reason'].map((t) => h('th', { text: t })))),
530
+ h('tbody', {}, skipped.map((d) => h('tr', {}, h('td', { text: d.role }), h('td', { text: d.name }), h('td', { text: d.context || '' }), h('td', { text: d.reason })))))
531
+ : h('div', { class: 'muted small', text: run.decisionsOmitted ? `None listed; ${run.decisionsOmitted} decisions not listed.` : 'None.' }),
532
+ explored.length ? h('div', { class: 'k', text: `Explored controls (${explored.length})` }) : null,
533
+ explored.length ? h('ul', { class: 'explored' }, explored.map((d) => h('li', { class: 'small', text: `${d.role} ${q(d.name)}${d.context ? ` in ${d.context}` : ''}${d.kind ? ` (${d.kind})` : ''} — ${d.reason}` }))) : null,
534
+ run.limits.length ? h('div', { class: 'k', text: 'Limits' }) : null,
535
+ run.limits.length ? h('ul', { class: 'limits' }, run.limits.map((l) => h('li', { class: 'small', text: l }))) : null,
536
+ );
537
+ box.hidden = false;
538
+ }
539
+
540
+ // ---------- findings ----------
541
+
542
+ const SEV_RANK = { high: 3, medium: 2, low: 1 };
543
+
544
+ function matchesFilters(f) {
545
+ if (model.device !== 'all' && f.device !== model.device) return false;
546
+ if (model.confidence !== 'all' && f.confidence !== model.confidence) return false;
547
+ if (model.scenario !== 'all' && f.scenario !== model.scenario) return false;
548
+ const run = selectedRun();
549
+ return !run || (f.scenario === run.scenario && f.device === run.device);
550
+ }
551
+
552
+ const grouping = () => (model.group === null ? model.scans.length > 0 : model.group);
553
+
554
+ function chipRow(id, items, current, set) {
555
+ const box = $(id);
556
+ box.hidden = !items.length;
557
+ box.replaceChildren(...items.map(([value, label]) =>
558
+ h('button', { type: 'button', 'data-value': value, 'aria-pressed': current === value ? 'true' : 'false', onclick: () => set(value), text: label })));
559
+ }
560
+
561
+ function renderFilters() {
562
+ const all = [...model.findings.values()];
563
+ const n = (pred) => all.filter(pred).length;
564
+ chipRow('confidence-filter', all.length ? [['all', `All (${all.length})`], ['confirmed', `Confirmed (${n((f) => f.confidence === 'confirmed')})`], ['heuristic', `Heuristic (${n((f) => f.confidence === 'heuristic')})`]] : [],
565
+ model.confidence, (v) => { model.confidence = v; renderFindings(); });
566
+ const names = [...new Set(all.map((f) => f.scenario).filter(Boolean))];
567
+ chipRow('scenario-filter', names.length ? [['all', 'All scenarios'], ...names.map((x) => [x, `${x} (${n((f) => f.scenario === x)})`])] : [],
568
+ model.scenario, (v) => { model.scenario = model.scenario === v ? 'all' : v; renderFindings(); });
569
+ $('group-row').hidden = !model.scans.length;
570
+ $('group-toggle').setAttribute('aria-pressed', grouping() ? 'true' : 'false');
571
+ const run = selectedRun();
572
+ $('run-filter').hidden = !run;
573
+ if (run) {
574
+ $('run-filter').replaceChildren(`Showing ${run.scenario} @ ${run.device} only. `, h('button', { type: 'button', class: 'link', onclick: () => selectRun(run.index), text: 'Show all runs' }));
575
+ }
576
+ }
577
+
578
+ function findingItem(f, freshId) {
579
+ const s = f.suppressed;
580
+ return h('li', {
581
+ class: `item${f.id === freshId ? ' fresh' : ''}`, role: 'option', tabindex: 0, 'data-id': f.id, 'aria-selected': model.selected === f.id ? 'true' : 'false',
582
+ onclick: () => select(f.id), onkeydown: (ev) => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); select(f.id); } },
583
+ },
584
+ // The spaces keep the words apart in the option's accessible name ("overflow heuristic", not "overflowheuristic").
585
+ h('span', { class: `sev sev-${f.severity}`, text: f.severity }), ' ',
586
+ h('span', { class: 'kind' }, `${f.id} ${f.kind}${f.target ? ` · ${f.target.role} ${q(f.target.name)}` : ''} `, h('span', { class: `conf conf-${f.confidence}`, text: f.confidence })), ' ',
587
+ h('span', { class: 'msg', text: `${f.device} · ${f.scenario ? `${f.scenario} · ` : ''}${f.route} · ${f.source}` }),
588
+ s ? h('span', { class: 'msg why', text: `suppressed by rule ${s.rule}: ${s.reason}${s.expires ? ` (until ${s.expires})` : ''}` }) : null);
589
+ }
590
+
591
+ /** Findings that share a fingerprint are one problem seen on several devices, scenarios or states. */
592
+ function buildGroups(items) {
593
+ const by = new Map();
594
+ for (const f of items) {
595
+ const key = f.fingerprint || f.id;
596
+ if (!by.has(key)) by.set(key, []);
597
+ by.get(key).push(f);
598
+ }
599
+ const meta = (fp) => { for (let i = model.scans.length - 1; i >= 0; i--) { const g = (model.scans[i].groups || []).find((x) => x.fingerprint === fp); if (g) return g; } return undefined; };
600
+ return [...by.entries()].map(([fingerprint, members]) => {
601
+ const first = members[0];
602
+ const uniq = (xs) => [...new Set(xs)];
603
+ return {
604
+ fingerprint, members, kind: first.kind,
605
+ title: (meta(fingerprint) && meta(fingerprint).title) || `${first.kind}${first.target ? ` · ${first.target.role} ${q(first.target.name)}` : ''}`,
606
+ severity: members.reduce((m, f) => (SEV_RANK[f.severity] > SEV_RANK[m] ? f.severity : m), 'low'),
607
+ confirmed: members.filter((f) => f.confidence === 'confirmed').length,
608
+ heuristic: members.filter((f) => f.confidence === 'heuristic').length,
609
+ devices: uniq([...members].sort((a, b) => a.viewportWidth - b.viewportWidth).map((f) => f.device)),
610
+ scenarios: uniq(members.map((f) => f.scenario).filter(Boolean)),
611
+ };
612
+ }).sort((a, b) => SEV_RANK[b.severity] - SEV_RANK[a.severity]);
613
+ }
614
+
615
+ function groupNode(g, freshId) {
616
+ const open = model.expanded.has(g.fingerprint);
617
+ return h('li', { class: 'group', 'data-fingerprint': g.fingerprint },
618
+ h('button', {
619
+ type: 'button', class: 'group-head', 'aria-expanded': open ? 'true' : 'false',
620
+ onclick: () => { if (open) model.expanded.delete(g.fingerprint); else model.expanded.add(g.fingerprint); renderFindings(); },
621
+ },
622
+ h('span', { class: `sev sev-${g.severity}`, text: g.severity }),
623
+ h('span', { class: 'kind' }, g.title,
624
+ g.confirmed ? h('span', { class: 'conf conf-confirmed', text: `${g.confirmed} confirmed` }) : null,
625
+ g.heuristic ? h('span', { class: 'conf conf-heuristic', text: `${g.heuristic} heuristic` }) : null),
626
+ h('span', { class: 'msg', text: `${g.kind} · ${plural(g.members.length, 'finding')} · ${g.devices.join(', ')}${g.scenarios.length ? ` · ${g.scenarios.join(', ')}` : ''}` })),
627
+ open ? h('ul', { class: 'members' }, g.members.map((f) => findingItem(f, freshId))) : null);
628
+ }
629
+
630
+ function renderFindings(freshId) {
631
+ renderDeviceFilter();
632
+ renderFilters();
633
+ const list = $('findings');
634
+ const shown = [...model.findings.values()].filter(matchesFilters);
635
+ const live = shown.filter((f) => !f.suppressed);
636
+ const suppressed = shown.filter((f) => f.suppressed);
637
+ if (model.selected && !shown.some((f) => f.id === model.selected)) model.selected = null;
638
+ if (!live.length) {
639
+ const others = model.confidence !== 'all' || model.scenario !== 'all' || selectedRun();
640
+ list.replaceChildren(h('li', { class: 'muted empty', text: suppressed.length ? 'Only suppressed findings match (listed below)'
641
+ : others ? 'No findings match these filters' : model.device === 'all' ? 'None recorded' : `None recorded at ${model.device}` }));
642
+ } else if (grouping()) {
643
+ list.replaceChildren(...buildGroups(live).map((g) => groupNode(g, freshId)));
644
+ } else {
645
+ list.replaceChildren(...live.map((f) => findingItem(f, freshId)));
646
+ }
647
+ // Suppressed findings are never dropped: they are listed apart, with the reason.
648
+ const box = $('suppressed');
649
+ box.hidden = !suppressed.length;
650
+ box.open = model.suppressedOpen;
651
+ $('suppressed-summary').textContent = `Suppressed (${suppressed.length})`;
652
+ $('suppressed-list').replaceChildren(...suppressed.map((f) => findingItem(f, freshId)));
653
+ renderDetail();
654
+ }
655
+
656
+ $('suppressed').addEventListener('toggle', () => { model.suppressedOpen = $('suppressed').open; });
657
+ $('group-toggle').addEventListener('click', () => { model.group = !grouping(); renderFindings(); });
658
+
659
+ function select(id) {
660
+ const f = model.findings.get(id);
661
+ if (f && !matchesFilters(f)) {
662
+ model.device = 'all'; model.confidence = 'all'; model.scenario = 'all'; model.runIndex = null;
663
+ renderSweep(); renderScan();
664
+ }
665
+ if (f && f.fingerprint) model.expanded.add(f.fingerprint);
666
+ if (f && f.suppressed) { model.suppressedOpen = true; }
667
+ model.selected = id;
668
+ renderFindings();
669
+ }
670
+
671
+ const WRAP_HARM = {
672
+ none: 'No harm measured: wrapping by itself is not a defect.',
673
+ cosmetic: 'Cosmetic: nothing is hidden or covered, but the label grows taller or sits uneven beside its neighbour.',
674
+ functional: 'Functional harm: the label is cut off or truncated, or the control overlaps something.',
675
+ expectation: 'Violates an expectation the project declared (scan.noWrap): this control should never wrap.',
676
+ };
677
+
678
+ function evidenceFigure(key, label, alt) {
679
+ fetchFrame(key);
680
+ const url = frames.get(key);
681
+ return h('figure', { class: 'ev', 'data-frame': key },
682
+ url ? h('img', { src: url, alt }) : h('div', { class: 'muted small', text: 'loading frame…' }),
683
+ h('figcaption', { text: label }));
684
+ }
685
+
686
+ function evidenceBlock(f) {
687
+ const extra = f.frames || [];
688
+ if (extra.length) {
689
+ return h('div', {},
690
+ h('div', { class: 'k', text: f.kind === 'layout-shift' ? 'Before and after it settled' : 'Side-by-side evidence' }),
691
+ h('div', { class: `pair${f.kind === 'layout-shift' ? ' before-after' : ''}` }, extra.map((x, i) => evidenceFigure(`${f.id}-${i}`, x.label, `${f.id}: ${x.label}`))));
692
+ }
693
+ if (!f.hasFrame) return h('div', { class: 'muted small', text: 'No evidence frame (captured only while a dashboard is attached to the session).' });
694
+ const frame = frames.get(f.id);
695
+ return h('figure', {},
696
+ frame ? h('img', { src: frame, alt: `Viewport when ${f.id} was recorded` }) : h('div', { class: 'muted small', text: 'loading frame…' }),
697
+ h('figcaption', { text: f.kind === 'horizontal-pan-required' ? 'Viewport before the lab panned sideways to reach the control.' : 'Viewport when the finding was recorded.' }));
698
+ }
699
+
700
+ /** The same state in every run of the scenario, so a developer sees how it changes with the width. */
701
+ function otherWidthsBlock(f) {
702
+ if (!f.scenario) return null;
703
+ const labels = new Set([f.state, ...(f.states || [])].filter(Boolean));
704
+ for (let i = model.scans.length - 1; i >= 0; i--) {
705
+ const scan = model.scans[i];
706
+ const items = [];
707
+ for (const run of scan.runs) {
708
+ if (run.scenario !== f.scenario) continue;
709
+ const st = run.states.find((x) => labels.has(x.label) && x.hasFrame);
710
+ if (st) items.push({ scan, run, st });
711
+ }
712
+ if (items.length < 2) continue;
713
+ items.sort((a, b) => a.run.width - b.run.width);
714
+ return h('div', {},
715
+ h('div', { class: 'k', text: 'Same state at other widths' }),
716
+ h('div', { class: 'pair widths' }, items.map(({ run, st }) => {
717
+ const url = scanFrameUrl(scan.id, run.index, st.id);
718
+ return h('figure', { class: 'ev', 'data-run': run.index },
719
+ url ? h('img', { src: url, alt: `${run.scenario} at ${run.device}: ${st.label}` }) : h('div', { class: 'muted small', text: 'loading frame…' }),
720
+ h('figcaption', { text: `${run.device} · ${run.width}px` }));
721
+ })));
722
+ }
723
+ return null;
724
+ }
725
+
726
+ function wrapBlock(f) {
727
+ const e = f.evidence;
728
+ const px = (v) => (v === undefined ? '–' : `${v} px`);
729
+ const row = (device, w, lines, height, neighbour) => h('tr', {}, h('td', { text: device }), h('td', { text: px(w) }), h('td', { text: lines === undefined ? '–' : String(lines) }), h('td', { text: px(height) }), h('td', { text: px(neighbour) }));
730
+ const lines = (n) => (n === 1 ? '1 line' : `${n} lines`);
731
+ return h('div', { class: 'wrap' },
732
+ h('div', { class: 'k', text: 'Label measurements' }),
733
+ h('table', { class: 'measure' },
734
+ h('thead', {}, h('tr', {}, ['Device', 'Width', 'Lines', 'Height', 'Neighbour height'].map((t) => h('th', { text: t })))),
735
+ h('tbody', {}, row(f.device, e.widthNarrow, e.linesNarrow, e.heightNarrow, e.rowNeighbourHeight), row(e.comparedWith || 'wider', e.widthWide, e.linesWide, e.heightWide, undefined))),
736
+ h('div', { class: 'small', text: `At ${e.widthNarrow} px it takes ${lines(e.linesNarrow)} (${e.heightNarrow} px tall); at ${e.widthWide} px it takes ${lines(e.linesWide)} (${e.heightWide} px tall).` }),
737
+ h('div', { class: `small harm harm-${e.harm}`, text: `Harm: ${e.harm}. ${WRAP_HARM[e.harm] || ''}` }));
738
+ }
739
+
740
+ function confidenceText(f) {
741
+ if (f.confidenceScore === undefined) return f.confidence === 'confirmed' ? 'confirmed (measured reach problem)' : 'heuristic (layout warning)';
742
+ return `${f.confidence} (score ${Number(f.confidenceScore).toFixed(2)}; basis: ${(f.basis || []).join(', ') || 'none'})`;
743
+ }
744
+
745
+ function renderDetail() {
746
+ const box = $('finding-detail');
747
+ const f = model.selected && model.findings.get(model.selected);
748
+ if (!f) { box.hidden = true; return; }
749
+ const states = f.states && f.states.length ? f.states : f.state ? [f.state] : [];
750
+ const rows = [
751
+ ['confidence', confidenceText(f)],
752
+ ...(f.suppressed ? [['suppressed', `rule ${f.suppressed.rule}: ${f.suppressed.reason}${f.suppressed.expires ? ` (until ${f.suppressed.expires})` : ''}`]] : []),
753
+ ...(f.detector ? [['detector', `${f.detector.name}@${f.detector.version}`]] : []),
754
+ ...(f.scenario ? [['scenario', f.scenario]] : []),
755
+ ...(states.length ? [[states.length > 1 ? 'states' : 'state', states.join(', ')]] : []),
756
+ ['route', f.route], ['device', `${f.device} (${f.viewportWidth}px wide)`], ['source', f.source],
757
+ ['target', f.target ? `${f.target.role} ${q(f.target.name)}${f.target.ref ? ` (${f.target.ref})` : ''}${f.target.context ? ` in ${f.target.context}` : ''}` : '–'],
758
+ ...(f.target && f.target.selector ? [['selector', f.target.selector]] : []),
759
+ ['seen', `${f.occurrences}× (gen ${f.firstSeen.gen}–${f.lastSeenGen}), first at ${time(f.firstSeen.at)}`],
760
+ ...Object.entries(f.evidence).map(([k, v]) => [k, String(v)]),
761
+ ];
762
+ setChildren(box,
763
+ h('h3', {}, h('span', { class: `fchip sev-${f.severity}`, text: f.severity }), ` ${f.id} ${f.kind}`),
764
+ h('div', { text: f.message }),
765
+ h('table', {}, h('tbody', {}, rows.map(([k, v]) => h('tr', {}, h('td', { text: k }), h('td', { class: 'mono', text: v }))))),
766
+ f.kind === 'text-wrap-change' ? wrapBlock(f) : null,
767
+ h('div', { class: 'k', text: 'Reproduction' }),
768
+ h('ol', {}, f.reproduction.map((s) => h('li', { text: s }))),
769
+ evidenceBlock(f),
770
+ otherWidthsBlock(f),
771
+ );
772
+ box.hidden = false;
773
+ }
774
+
775
+ // ---------- supervision ----------
776
+ // Requests go out with the token in an Authorization header (never in a URL). What a person types is sent
777
+ // and forgotten: it is never stored, echoed or shown here, and the server records descriptions only.
778
+
779
+ const CTL_OPS = ['pause', 'pause-next', 'resume', 'takeover', 'return', 'stop', 'emergency-stop'];
780
+ const CONFIRM = { 'stop': 'Confirm stop run', 'emergency-stop': 'Confirm emergency stop' };
781
+ const CONFIRM_MS = 5000;
782
+ let confirming = null; // { op, timer }
783
+ let wheelDy = 0;
784
+ let wheelTimer = null;
785
+
786
+ async function post(path, body) {
787
+ try {
788
+ const res = await fetch(path, {
789
+ method: 'POST',
790
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
791
+ body: JSON.stringify(body),
792
+ });
793
+ let json = {};
794
+ try { json = await res.json(); } catch { /* no body */ }
795
+ return { ok: res.ok, status: res.status, json };
796
+ } catch {
797
+ return { ok: false, status: 0, json: { error: 'could not reach the session process' } };
798
+ }
799
+ }
800
+
801
+ async function loadCapabilities() {
802
+ try {
803
+ const res = await fetch('/api/state', { headers: { authorization: `Bearer ${token}` } });
804
+ model.canControl = res.ok ? !!(await res.json()).canControl : false;
805
+ } catch {
806
+ model.canControl = false;
807
+ }
808
+ renderControl();
809
+ }
810
+
811
+ function setCtlError(text) {
812
+ const el = $('ctl-error');
813
+ el.hidden = !text;
814
+ el.textContent = text || '';
815
+ }
816
+
817
+ const controlState = () => model.control || { mode: 'agent', interrupt: false, observeRequired: false, humanInteractions: 0 };
818
+ const humanActive = () => model.canControl === true && controlState().mode === 'human' && model.status && model.status.state === 'active';
819
+
820
+ /** Mirrors the session's own transition rules; the server still decides and explains a refusal. */
821
+ function opEnabled(op, c) {
822
+ const m = c.mode;
823
+ const live = model.status && (model.status.state === 'active' || model.status.state === 'starting');
824
+ if (!live) return false;
825
+ switch (op) {
826
+ case 'pause': return m === 'agent' || (m === 'pausing' && c.pending === 'paused' && !c.interrupt);
827
+ case 'pause-next': return m === 'agent';
828
+ case 'resume': return m === 'paused' || (m === 'pausing' && c.pending === 'paused');
829
+ case 'takeover': return m === 'agent' || m === 'paused' || (m === 'pausing' && c.pending !== 'human');
830
+ case 'return': return m === 'human' || (m === 'pausing' && c.pending === 'human');
831
+ case 'stop': return m !== 'stopping' && m !== 'stopped';
832
+ case 'emergency-stop': return m !== 'stopped';
833
+ default: return false;
834
+ }
835
+ }
836
+
837
+ function badgeText(c) {
838
+ const after = c.busy && c.busy.command ? ` after ${c.busy.command}` : '';
839
+ switch (c.mode) {
840
+ case 'agent': return 'Agent in control';
841
+ case 'pausing': return c.pending === 'human' ? `Taking over${after}…` : `Pausing${after}…`;
842
+ case 'paused': return 'Paused';
843
+ case 'human': return 'You have control';
844
+ case 'stopping': return `Stopping${after}…`;
845
+ case 'stopped': return 'Stopped';
846
+ default: return c.mode;
847
+ }
848
+ }
849
+
850
+ function clearConfirm() {
851
+ if (!confirming) return;
852
+ clearTimeout(confirming.timer);
853
+ confirming = null;
854
+ renderControl();
855
+ }
856
+
857
+ function renderControl() {
858
+ const can = model.canControl === true;
859
+ $('supervision').hidden = !can;
860
+ $('ctl-viewonly').hidden = model.canControl !== false;
861
+ const human = humanActive();
862
+ $('human-panel').hidden = !human;
863
+ $('device').classList.toggle('human', !!human);
864
+ $('viewport').dataset.interactive = human ? 'true' : 'false';
865
+ if (!human) $('human-text').value = '';
866
+ $('human-headed').hidden = !(model.status && model.status.headed);
867
+ if (!can) return;
868
+
869
+ const c = controlState();
870
+ const badge = $('ctl-badge');
871
+ badge.dataset.mode = c.mode;
872
+ badge.textContent = badgeText(c);
873
+ $('ctl-detail').textContent = c.observeRequired && c.mode !== 'human' && c.mode !== 'stopped' ? 'agent must observe before acting' : '';
874
+ for (const op of CTL_OPS) {
875
+ const btn = $(`ctl-${op}`);
876
+ const on = opEnabled(op, c);
877
+ btn.setAttribute('aria-disabled', on ? 'false' : 'true');
878
+ if (btn.hasAttribute('aria-pressed')) {
879
+ btn.setAttribute('aria-pressed', String((op === 'pause' && (c.mode === 'paused' || (c.mode === 'pausing' && c.pending === 'paused'))) || (op === 'takeover' && c.mode === 'human')));
880
+ }
881
+ const asking = confirming && confirming.op === op && on;
882
+ btn.classList.toggle('confirming', !!asking);
883
+ btn.textContent = asking ? CONFIRM[op] : BUTTON_LABEL[op];
884
+ }
885
+ }
886
+
887
+ const BUTTON_LABEL = {
888
+ 'pause': 'Pause after this action', 'pause-next': 'Pause before next action', 'resume': 'Resume', 'takeover': 'Take over',
889
+ 'return': 'Return control to agent', 'stop': 'Stop run', 'emergency-stop': 'Emergency stop',
890
+ };
891
+
892
+ async function sendControl(op) {
893
+ const epoch = model.controlEpoch;
894
+ const r = await post('/api/control', { op });
895
+ if (r.ok && r.json.control) {
896
+ // A newer state from the event stream wins over this (older) reply.
897
+ if (epoch === model.controlEpoch) { model.control = r.json.control; model.controlEpoch++; }
898
+ setCtlError('');
899
+ renderControl();
900
+ } else {
901
+ setCtlError(r.json.error || `request failed (${r.status})`);
902
+ }
903
+ }
904
+
905
+ function onControlClick(op) {
906
+ if (!opEnabled(op, controlState())) return;
907
+ setCtlError('');
908
+ if (CONFIRM[op]) {
909
+ if (!confirming || confirming.op !== op) {
910
+ // First click only asks; a second click within 5 s does it.
911
+ if (confirming) clearTimeout(confirming.timer);
912
+ confirming = { op, timer: setTimeout(clearConfirm, CONFIRM_MS) };
913
+ renderControl();
914
+ return;
915
+ }
916
+ }
917
+ if (confirming) { clearTimeout(confirming.timer); confirming = null; }
918
+ sendControl(op);
919
+ }
920
+
921
+ for (const op of CTL_OPS) {
922
+ $(`ctl-${op}`).addEventListener('click', () => {
923
+ // Any other button cancels a pending confirmation.
924
+ if (confirming && confirming.op !== op) { clearTimeout(confirming.timer); confirming = null; renderControl(); }
925
+ onControlClick(op);
926
+ });
927
+ }
928
+
929
+ async function sendInput(body) {
930
+ setCtlError('');
931
+ const r = await post('/api/input', body);
932
+ if (!r.ok) setCtlError(r.json.error || `input was not accepted (${r.status})`);
933
+ return r.ok;
934
+ }
935
+
936
+ const clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, n));
937
+
938
+ $('viewport').addEventListener('click', (e) => {
939
+ if (!humanActive()) return;
940
+ const img = e.currentTarget;
941
+ if (!img.clientWidth || !img.clientHeight) return;
942
+ sendInput({ type: 'tap', x: clamp(e.offsetX / img.clientWidth, 0, 1), y: clamp(e.offsetY / img.clientHeight, 0, 1) });
943
+ });
944
+
945
+ $('viewport').addEventListener('wheel', (e) => {
946
+ if (!humanActive()) return;
947
+ e.preventDefault();
948
+ const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? e.currentTarget.clientHeight : 1;
949
+ wheelDy = clamp(wheelDy + e.deltaY * unit, -5000, 5000);
950
+ if (wheelTimer) return;
951
+ // One scroll request per 120 ms, however fast the wheel spins.
952
+ wheelTimer = setTimeout(() => {
953
+ const dy = Math.round(wheelDy);
954
+ wheelDy = 0;
955
+ wheelTimer = null;
956
+ if (dy !== 0 && humanActive()) sendInput({ type: 'scroll', dy });
957
+ }, 120);
958
+ }, { passive: false });
959
+
960
+ $('human-form').addEventListener('submit', async (e) => {
961
+ e.preventDefault();
962
+ const box = $('human-text');
963
+ const text = box.value;
964
+ if (!text || !humanActive()) return;
965
+ if (text.length > 500) return setCtlError('Type at most 500 characters at a time.');
966
+ if (await sendInput({ type: 'text', text })) box.value = '';
967
+ box.focus();
968
+ });
969
+
970
+ $('human-hide').addEventListener('change', (e) => { $('human-text').type = e.currentTarget.checked ? 'password' : 'text'; });
971
+
972
+ for (const btn of document.querySelectorAll('#human-panel [data-key]')) {
973
+ btn.addEventListener('click', () => { if (humanActive()) sendInput({ type: 'key', key: btn.dataset.key }); });
974
+ }
975
+
976
+ connect();