@tangle-network/browser-agent-driver 0.13.0 → 0.14.1

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 (38) hide show
  1. package/README.md +60 -1
  2. package/dist/brain/index.d.ts.map +1 -1
  3. package/dist/brain/index.js +80 -6
  4. package/dist/brain/index.js.map +1 -1
  5. package/dist/cli-view.d.ts +83 -0
  6. package/dist/cli-view.d.ts.map +1 -0
  7. package/dist/cli-view.js +397 -0
  8. package/dist/cli-view.js.map +1 -0
  9. package/dist/cli.js +30 -1
  10. package/dist/cli.js.map +1 -1
  11. package/dist/design/audit/evaluate.d.ts.map +1 -1
  12. package/dist/design/audit/evaluate.js +4 -2
  13. package/dist/design/audit/evaluate.js.map +1 -1
  14. package/dist/drivers/cursor-overlay.d.ts +26 -0
  15. package/dist/drivers/cursor-overlay.d.ts.map +1 -0
  16. package/dist/drivers/cursor-overlay.js +235 -0
  17. package/dist/drivers/cursor-overlay.js.map +1 -0
  18. package/dist/drivers/playwright.d.ts +32 -0
  19. package/dist/drivers/playwright.d.ts.map +1 -1
  20. package/dist/drivers/playwright.js +79 -0
  21. package/dist/drivers/playwright.js.map +1 -1
  22. package/dist/drivers/steel.d.ts +135 -0
  23. package/dist/drivers/steel.d.ts.map +1 -0
  24. package/dist/drivers/steel.js +236 -0
  25. package/dist/drivers/steel.js.map +1 -0
  26. package/dist/index.d.ts +2 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +1 -0
  29. package/dist/index.js.map +1 -1
  30. package/dist/provider-defaults.d.ts +19 -1
  31. package/dist/provider-defaults.d.ts.map +1 -1
  32. package/dist/provider-defaults.js +34 -0
  33. package/dist/provider-defaults.js.map +1 -1
  34. package/dist/test-runner.d.ts.map +1 -1
  35. package/dist/test-runner.js +51 -14
  36. package/dist/test-runner.js.map +1 -1
  37. package/dist/viewer/viewer.html +940 -0
  38. package/package.json +7 -2
@@ -0,0 +1,940 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>bad — session viewer</title>
7
+ <style>
8
+ :root {
9
+ --bg: #0b0d11;
10
+ --surface: #14171d;
11
+ --surface-2: #1c2028;
12
+ --border: #262b35;
13
+ --border-strong: #2f3543;
14
+ --text: #e6e8ec;
15
+ --text-muted: #9aa1ae;
16
+ --text-subtle: #6b7280;
17
+ --brand: #6366f1;
18
+ --brand-soft: rgba(99, 102, 241, 0.14);
19
+ --green: #22c55e;
20
+ --yellow: #eab308;
21
+ --red: #ef4444;
22
+ --orange: #f97316;
23
+ --radius: 8px;
24
+ --radius-lg: 12px;
25
+ --shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
26
+ }
27
+ * { box-sizing: border-box; }
28
+ html, body {
29
+ margin: 0;
30
+ padding: 0;
31
+ height: 100%;
32
+ background: var(--bg);
33
+ color: var(--text);
34
+ font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, sans-serif;
35
+ font-size: 14px;
36
+ line-height: 1.5;
37
+ -webkit-font-smoothing: antialiased;
38
+ }
39
+ a { color: var(--brand); text-decoration: none; }
40
+ a:hover { text-decoration: underline; }
41
+ code, pre {
42
+ font-family: "JetBrains Mono", "SF Mono", Menlo, Consolas, monospace;
43
+ font-size: 12px;
44
+ }
45
+ button {
46
+ font-family: inherit;
47
+ font-size: inherit;
48
+ cursor: pointer;
49
+ border: 1px solid var(--border);
50
+ background: var(--surface-2);
51
+ color: var(--text);
52
+ padding: 6px 12px;
53
+ border-radius: var(--radius);
54
+ transition: background 120ms ease, border-color 120ms ease;
55
+ }
56
+ button:hover { background: var(--surface); border-color: var(--border-strong); }
57
+ button:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
58
+
59
+ .app {
60
+ display: grid;
61
+ grid-template-columns: 280px 1fr 360px;
62
+ grid-template-rows: 56px 1fr;
63
+ grid-template-areas:
64
+ "header header header"
65
+ "sidebar main aside";
66
+ height: 100vh;
67
+ }
68
+
69
+ /* ── Header ────────────────────────────────────────────────── */
70
+ header {
71
+ grid-area: header;
72
+ display: flex;
73
+ align-items: center;
74
+ gap: 16px;
75
+ padding: 0 20px;
76
+ background: var(--surface);
77
+ border-bottom: 1px solid var(--border);
78
+ }
79
+ .logo {
80
+ display: inline-flex;
81
+ align-items: center;
82
+ gap: 10px;
83
+ font-weight: 700;
84
+ letter-spacing: -0.01em;
85
+ }
86
+ .logo-mark {
87
+ width: 24px;
88
+ height: 24px;
89
+ background: linear-gradient(135deg, var(--brand) 0%, #06b6d4 100%);
90
+ border-radius: 6px;
91
+ }
92
+ .title {
93
+ font-size: 13px;
94
+ color: var(--text-muted);
95
+ font-weight: 500;
96
+ }
97
+ .header-right { margin-left: auto; display: flex; gap: 8px; align-items: center; }
98
+
99
+ /* ── Sidebar ──────────────────────────────────────────────── */
100
+ .sidebar {
101
+ grid-area: sidebar;
102
+ background: var(--surface);
103
+ border-right: 1px solid var(--border);
104
+ overflow-y: auto;
105
+ padding: 16px 12px;
106
+ }
107
+ .sidebar h3 {
108
+ margin: 0 8px 8px;
109
+ font-size: 11px;
110
+ text-transform: uppercase;
111
+ letter-spacing: 0.06em;
112
+ color: var(--text-subtle);
113
+ font-weight: 600;
114
+ }
115
+ .turn-list {
116
+ list-style: none;
117
+ padding: 0;
118
+ margin: 0 0 24px;
119
+ }
120
+ .turn-item {
121
+ display: flex;
122
+ align-items: center;
123
+ gap: 8px;
124
+ padding: 8px 10px;
125
+ border-radius: var(--radius);
126
+ cursor: pointer;
127
+ font-size: 13px;
128
+ transition: background 120ms ease;
129
+ border: 1px solid transparent;
130
+ }
131
+ .turn-item:hover { background: var(--surface-2); }
132
+ .turn-item.active {
133
+ background: var(--brand-soft);
134
+ border-color: rgba(99, 102, 241, 0.3);
135
+ color: var(--text);
136
+ }
137
+ .turn-num {
138
+ display: inline-flex;
139
+ align-items: center;
140
+ justify-content: center;
141
+ width: 24px;
142
+ height: 24px;
143
+ background: var(--surface-2);
144
+ border-radius: 6px;
145
+ font-size: 11px;
146
+ font-weight: 600;
147
+ color: var(--text-muted);
148
+ flex-shrink: 0;
149
+ }
150
+ .turn-item.active .turn-num { background: var(--brand); color: #fff; }
151
+ .turn-action {
152
+ flex: 1;
153
+ overflow: hidden;
154
+ text-overflow: ellipsis;
155
+ white-space: nowrap;
156
+ }
157
+ .turn-action .verb {
158
+ font-weight: 600;
159
+ color: var(--text);
160
+ }
161
+ .turn-status {
162
+ width: 8px;
163
+ height: 8px;
164
+ border-radius: 50%;
165
+ flex-shrink: 0;
166
+ }
167
+ .turn-status.success { background: var(--green); }
168
+ .turn-status.error { background: var(--red); }
169
+ .turn-status.pending { background: var(--text-subtle); }
170
+
171
+ /* ── Main viewport (screenshot + scrubber) ────────────────── */
172
+ .main {
173
+ grid-area: main;
174
+ overflow: auto;
175
+ display: flex;
176
+ flex-direction: column;
177
+ align-items: center;
178
+ padding: 24px;
179
+ background: var(--bg);
180
+ }
181
+ .screenshot-wrap {
182
+ max-width: 100%;
183
+ width: 100%;
184
+ max-height: calc(100vh - 200px);
185
+ display: flex;
186
+ align-items: center;
187
+ justify-content: center;
188
+ background: var(--surface);
189
+ border: 1px solid var(--border);
190
+ border-radius: var(--radius-lg);
191
+ overflow: hidden;
192
+ box-shadow: var(--shadow);
193
+ }
194
+ .screenshot-wrap img {
195
+ max-width: 100%;
196
+ max-height: calc(100vh - 220px);
197
+ display: block;
198
+ }
199
+ .empty-screenshot {
200
+ padding: 80px 40px;
201
+ color: var(--text-muted);
202
+ text-align: center;
203
+ }
204
+ .scrubber {
205
+ width: 100%;
206
+ max-width: 900px;
207
+ margin-top: 16px;
208
+ display: flex;
209
+ align-items: center;
210
+ gap: 12px;
211
+ }
212
+ .scrubber input[type="range"] {
213
+ flex: 1;
214
+ height: 4px;
215
+ -webkit-appearance: none;
216
+ background: var(--surface-2);
217
+ border-radius: 9999px;
218
+ outline: none;
219
+ }
220
+ .scrubber input[type="range"]::-webkit-slider-thumb {
221
+ -webkit-appearance: none;
222
+ width: 16px;
223
+ height: 16px;
224
+ background: var(--brand);
225
+ border-radius: 50%;
226
+ cursor: pointer;
227
+ }
228
+ .scrubber-label {
229
+ font-size: 12px;
230
+ color: var(--text-muted);
231
+ min-width: 80px;
232
+ }
233
+
234
+ /* ── Aside (decision + reasoning + a11y tree) ─────────────── */
235
+ .aside {
236
+ grid-area: aside;
237
+ background: var(--surface);
238
+ border-left: 1px solid var(--border);
239
+ overflow-y: auto;
240
+ padding: 20px;
241
+ }
242
+ .panel {
243
+ margin-bottom: 24px;
244
+ }
245
+ .panel h3 {
246
+ margin: 0 0 8px;
247
+ font-size: 11px;
248
+ text-transform: uppercase;
249
+ letter-spacing: 0.06em;
250
+ color: var(--text-subtle);
251
+ font-weight: 600;
252
+ }
253
+ .panel-body {
254
+ background: var(--surface-2);
255
+ border: 1px solid var(--border);
256
+ border-radius: var(--radius);
257
+ padding: 12px;
258
+ font-size: 13px;
259
+ line-height: 1.5;
260
+ color: var(--text);
261
+ }
262
+ .panel-body pre {
263
+ margin: 0;
264
+ white-space: pre-wrap;
265
+ word-break: break-word;
266
+ max-height: 240px;
267
+ overflow-y: auto;
268
+ }
269
+ .badge {
270
+ display: inline-block;
271
+ padding: 2px 8px;
272
+ border-radius: 9999px;
273
+ font-size: 11px;
274
+ font-weight: 600;
275
+ text-transform: uppercase;
276
+ letter-spacing: 0.04em;
277
+ }
278
+ .badge-success { background: rgba(34, 197, 94, 0.18); color: #4ade80; }
279
+ .badge-error { background: rgba(239, 68, 68, 0.18); color: #f87171; }
280
+ .badge-info { background: rgba(99, 102, 241, 0.18); color: #a5b4fc; }
281
+ .badge-warn { background: rgba(234, 179, 8, 0.18); color: #facc15; }
282
+
283
+ /* ── Top fixes mode (for design audits) ───────────────────── */
284
+ .top-fixes {
285
+ max-width: 920px;
286
+ margin: 0 auto;
287
+ padding: 0 24px;
288
+ }
289
+ .fix-card {
290
+ background: var(--surface);
291
+ border: 1px solid var(--border);
292
+ border-radius: var(--radius-lg);
293
+ padding: 18px 20px;
294
+ margin-bottom: 12px;
295
+ }
296
+ .fix-card-head {
297
+ display: flex;
298
+ align-items: center;
299
+ gap: 12px;
300
+ margin-bottom: 8px;
301
+ }
302
+ .fix-rank {
303
+ display: inline-flex;
304
+ align-items: center;
305
+ justify-content: center;
306
+ width: 28px;
307
+ height: 28px;
308
+ background: var(--brand);
309
+ color: #fff;
310
+ border-radius: 8px;
311
+ font-size: 13px;
312
+ font-weight: 700;
313
+ }
314
+ .fix-roi {
315
+ margin-left: auto;
316
+ font-size: 12px;
317
+ font-weight: 600;
318
+ color: var(--text-muted);
319
+ }
320
+ .fix-roi b { color: var(--brand); font-size: 14px; }
321
+ .fix-desc {
322
+ color: var(--text);
323
+ font-size: 14px;
324
+ line-height: 1.45;
325
+ }
326
+ .fix-meta {
327
+ margin-top: 8px;
328
+ font-size: 12px;
329
+ color: var(--text-muted);
330
+ display: flex;
331
+ gap: 12px;
332
+ flex-wrap: wrap;
333
+ }
334
+ .fix-meta span { white-space: nowrap; }
335
+ .fix-suggestion {
336
+ margin-top: 10px;
337
+ padding: 10px 12px;
338
+ background: var(--surface-2);
339
+ border-left: 3px solid var(--brand);
340
+ border-radius: 4px;
341
+ font-size: 13px;
342
+ color: var(--text-muted);
343
+ }
344
+ .fix-css {
345
+ margin-top: 8px;
346
+ padding: 10px;
347
+ background: #06080b;
348
+ border: 1px solid var(--border);
349
+ border-radius: 6px;
350
+ font-family: "JetBrains Mono", monospace;
351
+ font-size: 12px;
352
+ color: #c5cae0;
353
+ overflow-x: auto;
354
+ }
355
+
356
+ /* ── Score bars ────────────────────────────────────────── */
357
+ .score-grid {
358
+ display: grid;
359
+ grid-template-columns: 1fr 1fr;
360
+ gap: 8px 16px;
361
+ margin-bottom: 16px;
362
+ }
363
+ .score-row {
364
+ display: flex;
365
+ align-items: center;
366
+ gap: 10px;
367
+ font-size: 12px;
368
+ }
369
+ .score-label {
370
+ color: var(--text-muted);
371
+ min-width: 96px;
372
+ text-transform: capitalize;
373
+ }
374
+ .score-bar {
375
+ flex: 1;
376
+ height: 6px;
377
+ background: var(--surface-2);
378
+ border-radius: 9999px;
379
+ overflow: hidden;
380
+ }
381
+ .score-bar-fill {
382
+ height: 100%;
383
+ background: var(--brand);
384
+ transition: width 200ms ease;
385
+ }
386
+ .score-bar-fill.good { background: var(--green); }
387
+ .score-bar-fill.ok { background: var(--yellow); }
388
+ .score-bar-fill.bad { background: var(--red); }
389
+ .score-num {
390
+ font-weight: 600;
391
+ color: var(--text);
392
+ min-width: 28px;
393
+ text-align: right;
394
+ }
395
+
396
+ .empty-state {
397
+ padding: 80px 40px;
398
+ text-align: center;
399
+ color: var(--text-muted);
400
+ }
401
+ .empty-state h2 { color: var(--text); margin: 0 0 8px; font-size: 16px; }
402
+ .empty-state p { margin: 0; font-size: 13px; }
403
+ </style>
404
+ </head>
405
+ <body>
406
+ <div class="app">
407
+ <header>
408
+ <div class="logo">
409
+ <div class="logo-mark"></div>
410
+ bad <span class="title">session viewer</span>
411
+ </div>
412
+ <div class="header-right">
413
+ <span id="run-meta" class="title"></span>
414
+ </div>
415
+ </header>
416
+
417
+ <aside class="sidebar">
418
+ <h3>Run</h3>
419
+ <div id="run-summary" class="panel-body" style="margin-bottom: 16px;">
420
+ <div class="empty-state" style="padding: 16px 0;">
421
+ <p>Loading…</p>
422
+ </div>
423
+ </div>
424
+ <h3>Pages / Turns</h3>
425
+ <ul id="turn-list" class="turn-list"></ul>
426
+ </aside>
427
+
428
+ <section class="main">
429
+ <div id="main-content"></div>
430
+ </section>
431
+
432
+ <aside class="aside" id="aside-panel">
433
+ <div class="empty-state">
434
+ <h2>Select a turn</h2>
435
+ <p>Click any turn in the sidebar to inspect.</p>
436
+ </div>
437
+ </aside>
438
+ </div>
439
+
440
+ <script>
441
+ // The CLI injects window.__bad_runData before opening this file.
442
+ // Fall back to fetching ./report.json relative to the page if running in a webserver.
443
+ const runData = window.__bad_runData || null;
444
+
445
+ async function loadData() {
446
+ if (runData) return runData;
447
+ try {
448
+ const res = await fetch('./report.json');
449
+ if (res.ok) return await res.json();
450
+ } catch {}
451
+ return null;
452
+ }
453
+
454
+ function escapeHtml(s) {
455
+ if (s == null) return '';
456
+ return String(s)
457
+ .replace(/&/g, '&amp;')
458
+ .replace(/</g, '&lt;')
459
+ .replace(/>/g, '&gt;')
460
+ .replace(/"/g, '&quot;')
461
+ .replace(/'/g, '&#39;');
462
+ }
463
+
464
+ /** Coerce any score-shaped value to a finite number, defaulting to 0. */
465
+ function toScore(n) {
466
+ const v = typeof n === 'number' ? n : parseFloat(n);
467
+ return Number.isFinite(v) ? v : 0;
468
+ }
469
+
470
+ function classifyScore(n) {
471
+ const v = toScore(n);
472
+ if (v >= 8) return 'good';
473
+ if (v >= 5) return 'ok';
474
+ return 'bad';
475
+ }
476
+
477
+ /** Try to render a URL's pathname; fall back to the original string. */
478
+ function urlPathname(u) {
479
+ if (!u) return '/';
480
+ try {
481
+ const parsed = new URL(u, 'http://_local_');
482
+ return parsed.pathname || '/';
483
+ } catch {
484
+ return String(u);
485
+ }
486
+ }
487
+
488
+ /**
489
+ * Resolve a screenshot path stored in the report into something the
490
+ * loopback HTTP server can serve. Reports may store absolute paths
491
+ * (legacy) or relative paths (new). We strip everything up to and
492
+ * including the report root by looking for the segment matching the
493
+ * report directory name; otherwise return the basename in `screenshots/`.
494
+ */
495
+ function resolveScreenshotPath(p) {
496
+ if (!p) return null;
497
+ const s = String(p);
498
+ // Already a relative URL from the server root
499
+ if (!s.startsWith('/') && !s.includes('://')) return s;
500
+ // Find the last `/screenshots/` segment, anchor relative to it
501
+ const m = s.match(/(?:^|\/)(screenshots\/[^?#]+)$/);
502
+ if (m) return m[1];
503
+ // Fallback: use the basename
504
+ const base = s.split('/').pop();
505
+ return base ? `screenshots/${base}` : null;
506
+ }
507
+
508
+ /** Get an element by id or throw a visible error in the UI. */
509
+ function el(id) {
510
+ const node = document.getElementById(id);
511
+ if (!node) throw new Error(`viewer: missing element #${id}`);
512
+ return node;
513
+ }
514
+
515
+ function renderRunSummary(data) {
516
+ const summaryEl = el('run-summary');
517
+ const meta = el('run-meta');
518
+ if (!data) {
519
+ summaryEl.innerHTML = '<div class="empty-state" style="padding:16px 0;"><p>No data</p></div>';
520
+ return;
521
+ }
522
+ // Run type detection: design audit (top-level pages[]) vs agent run (top-level tests[])
523
+ const isAudit = !!data.pages;
524
+ const isAgentRun = Array.isArray(data.tests);
525
+ if (isAudit) {
526
+ const avg = toScore(data.summary?.avgScore).toFixed(1);
527
+ const total = data.summary?.totalFindings ?? 0;
528
+ meta.textContent = `design audit · ${escapeHtml(data.url ?? '')}`;
529
+ summaryEl.innerHTML = `
530
+ <div style="display:flex;align-items:center;gap:12px;">
531
+ <div style="font-size:32px;font-weight:700;">${avg}<span style="font-size:14px;color:var(--text-muted);">/10</span></div>
532
+ <div style="font-size:12px;color:var(--text-muted);">${total} findings<br>${data.pages.length} page${data.pages.length !== 1 ? 's' : ''}</div>
533
+ </div>
534
+ `;
535
+ } else if (isAgentRun) {
536
+ const tests = data.tests;
537
+ const passed = tests.filter(t => t.success).length;
538
+ meta.textContent = `agent run · ${tests.length} test${tests.length !== 1 ? 's' : ''}`;
539
+ summaryEl.innerHTML = `
540
+ <div>
541
+ <div style="font-weight:600;">${passed}/${tests.length} passed</div>
542
+ <div style="font-size:12px;color:var(--text-muted);margin-top:4px;">
543
+ ${tests.reduce((s, t) => s + (t.turns?.length || 0), 0)} turns total
544
+ </div>
545
+ </div>
546
+ `;
547
+ } else {
548
+ meta.textContent = `agent run`;
549
+ const turns = (data.turns || data.steps || []).length;
550
+ summaryEl.innerHTML = `
551
+ <div>
552
+ <div style="font-weight:600;">${turns} turn${turns !== 1 ? 's' : ''}</div>
553
+ <div style="font-size:12px;color:var(--text-muted);margin-top:4px;">${data.success === false ? 'failed' : 'completed'}</div>
554
+ </div>
555
+ `;
556
+ }
557
+ }
558
+
559
+ function renderTurnList(data) {
560
+ const list = el('turn-list');
561
+ const items = [];
562
+
563
+ if (data?.pages) {
564
+ // Design audit: list pages
565
+ data.pages.forEach((p, i) => {
566
+ const score = toScore(p.score);
567
+ items.push(`
568
+ <li class="turn-item" data-kind="page" data-index="${i}">
569
+ <span class="turn-num">${i + 1}</span>
570
+ <div class="turn-action"><span class="verb">${escapeHtml(urlPathname(p.url))}</span></div>
571
+ <span class="badge ${classifyScore(score) === 'good' ? 'badge-success' : classifyScore(score) === 'ok' ? 'badge-warn' : 'badge-error'}">${score}</span>
572
+ </li>
573
+ `);
574
+ });
575
+ // Add a "Top Fixes" entry at the top if present
576
+ if (data.topFixes?.length > 0) {
577
+ items.unshift(`
578
+ <li class="turn-item active" data-kind="topfixes">
579
+ <span class="turn-num">★</span>
580
+ <div class="turn-action"><span class="verb">Top Fixes</span></div>
581
+ <span class="badge badge-info">${data.topFixes.length}</span>
582
+ </li>
583
+ `);
584
+ }
585
+ } else if (Array.isArray(data?.tests)) {
586
+ // Agent run with one or more tests. List each test as a header,
587
+ // then a Recording entry (if present), then each turn beneath.
588
+ data.tests.forEach((test, ti) => {
589
+ const okBadge = test.success
590
+ ? '<span class="badge badge-success">pass</span>'
591
+ : '<span class="badge badge-error">fail</span>';
592
+ items.push(`
593
+ <li style="padding: 12px 10px 6px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-subtle); display: flex; align-items: center; justify-content: space-between;">
594
+ <span>${escapeHtml(test.name || test.id)}</span>
595
+ ${okBadge}
596
+ </li>
597
+ `);
598
+ if (test.recording) {
599
+ items.push(`
600
+ <li class="turn-item" data-kind="recording" data-test="${ti}">
601
+ <span class="turn-num">▶</span>
602
+ <div class="turn-action"><span class="verb">Recording</span></div>
603
+ <span class="badge badge-info">video</span>
604
+ </li>
605
+ `);
606
+ }
607
+ (test.turns || []).forEach((t, i) => {
608
+ const action = t.action?.action ?? 'unknown';
609
+ const status = t.result?.success === false || t.verificationFailure ? 'error' : 'success';
610
+ items.push(`
611
+ <li class="turn-item" data-kind="turn" data-test="${ti}" data-index="${i}">
612
+ <span class="turn-num">${i + 1}</span>
613
+ <div class="turn-action"><span class="verb">${escapeHtml(action)}</span></div>
614
+ <span class="turn-status ${status}"></span>
615
+ </li>
616
+ `);
617
+ });
618
+ });
619
+ } else if (data?.turns) {
620
+ // Legacy single agent run shape — turns directly at root
621
+ data.turns.forEach((t, i) => {
622
+ const action = t.action?.action ?? 'unknown';
623
+ const status = t.success === false ? 'error' : 'success';
624
+ items.push(`
625
+ <li class="turn-item" data-kind="turn" data-index="${i}">
626
+ <span class="turn-num">${i + 1}</span>
627
+ <div class="turn-action"><span class="verb">${escapeHtml(action)}</span></div>
628
+ <span class="turn-status ${status}"></span>
629
+ </li>
630
+ `);
631
+ });
632
+ }
633
+
634
+ list.innerHTML = items.join('') || '<li class="empty-state" style="padding:8px;font-size:12px;">No turns yet.</li>';
635
+
636
+ list.querySelectorAll('.turn-item').forEach(node => {
637
+ node.addEventListener('click', () => {
638
+ list.querySelectorAll('.turn-item').forEach(x => x.classList.remove('active'));
639
+ node.classList.add('active');
640
+ const kind = node.dataset.kind;
641
+ const idx = parseInt(node.dataset.index, 10);
642
+ const testIdx = parseInt(node.dataset.test, 10);
643
+ if (kind === 'page') {
644
+ showPage(data.pages[idx]);
645
+ } else if (kind === 'topfixes') {
646
+ showTopFixes(data.topFixes, data);
647
+ } else if (kind === 'recording') {
648
+ showRecording(data.tests[testIdx]);
649
+ } else if (kind === 'turn') {
650
+ // Agent run: data.tests[].turns[]; legacy: data.turns[]
651
+ if (Number.isFinite(testIdx) && data.tests) {
652
+ showAgentTurn(data.tests[testIdx], idx);
653
+ } else {
654
+ showTurn(data.turns[idx]);
655
+ }
656
+ }
657
+ });
658
+ });
659
+ }
660
+
661
+ /** Render the recording.webm video player for an agent test. */
662
+ function showRecording(test) {
663
+ const main = el('main-content');
664
+ if (!test?.recording) {
665
+ main.innerHTML = '<div class="empty-state"><h2>No recording</h2><p>This test did not produce a recording.webm.</p></div>';
666
+ return;
667
+ }
668
+ main.innerHTML = `
669
+ <div style="width: 100%; max-width: 1200px;">
670
+ <div style="margin-bottom: 16px;">
671
+ <h2 style="margin: 0; font-size: 18px;">${escapeHtml(test.name || test.id)} — Recording</h2>
672
+ <div style="color: var(--text-muted); font-size: 12px; margin-top: 4px;">
673
+ Frame-by-frame screencast of the agent session. Cursor + clicks are visible inline.
674
+ </div>
675
+ </div>
676
+ <div class="screenshot-wrap" style="background: #000;">
677
+ <video controls autoplay muted loop style="max-width: 100%; max-height: calc(100vh - 220px); display: block;" src="${escapeHtml(test.recording)}">
678
+ Your browser doesn't support inline video playback.
679
+ </video>
680
+ </div>
681
+ <div style="margin-top: 12px; font-size: 12px; color: var(--text-muted);">
682
+ Tip: scrub the video to see the agent's cursor move to each click target.
683
+ Each click pulses a ring at the contact point when the run was captured with <code>--show-cursor</code>.
684
+ </div>
685
+ </div>
686
+ `;
687
+ const aside = el('aside-panel');
688
+ aside.innerHTML = `
689
+ <div class="panel">
690
+ <h3>Test</h3>
691
+ <div class="panel-body">
692
+ <div style="font-weight:600;">${escapeHtml(test.name || test.id)}</div>
693
+ <div style="font-size:12px;color:var(--text-muted);margin-top:6px;">
694
+ ${test.success ? '<span class="badge badge-success">pass</span>' : '<span class="badge badge-error">fail</span>'}
695
+ ${test.turns?.length ? ' · ' + test.turns.length + ' turns' : ''}
696
+ ${test.durationMs ? ' · ' + Math.round(test.durationMs / 1000) + 's' : ''}
697
+ ${test.estimatedCostUsd ? ' · $' + test.estimatedCostUsd.toFixed(3) : ''}
698
+ </div>
699
+ </div>
700
+ </div>
701
+ ${test.verdict ? `<div class="panel"><h3>Verdict</h3><div class="panel-body">${escapeHtml(test.verdict)}</div></div>` : ''}
702
+ <div class="panel">
703
+ <h3>About this view</h3>
704
+ <div class="panel-body" style="font-size: 12px;">
705
+ The recording is a Playwright screencast captured during the run. The cursor sprite, click pulses, and element highlights are baked into the video frames when <code>--show-cursor</code> was passed to <code>bad run</code>.
706
+ </div>
707
+ </div>
708
+ `;
709
+ }
710
+
711
+ /** Render a single turn from an agent test. */
712
+ function showAgentTurn(test, turnIndex) {
713
+ const turn = test?.turns?.[turnIndex];
714
+ if (!turn) {
715
+ el('main-content').innerHTML = '<div class="empty-state"><h2>Turn not found</h2></div>';
716
+ return;
717
+ }
718
+ const main = el('main-content');
719
+ const ssRaw = turn.state?.screenshot;
720
+ const screenshot = ssRaw
721
+ ? `<img src="${escapeHtml(ssRaw)}" alt="screenshot" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'empty-screenshot',textContent:'Screenshot not available'}))">`
722
+ : '<div class="empty-screenshot">No screenshot for this turn</div>';
723
+ main.innerHTML = `
724
+ <div style="width: 100%; max-width: 1200px;">
725
+ <div style="margin-bottom: 16px;">
726
+ <h2 style="margin: 0; font-size: 18px;">Turn ${turnIndex + 1} — ${escapeHtml(turn.action?.action ?? 'unknown')}</h2>
727
+ <div style="color: var(--text-muted); font-size: 12px; margin-top: 4px;">${escapeHtml(turn.state?.url ?? '')}</div>
728
+ </div>
729
+ <div class="screenshot-wrap">${screenshot}</div>
730
+ </div>
731
+ `;
732
+ const aside = el('aside-panel');
733
+ aside.innerHTML = `
734
+ ${turn.action ? `
735
+ <div class="panel">
736
+ <h3>Action</h3>
737
+ <div class="panel-body">
738
+ <div style="font-weight:600;margin-bottom:6px;">${escapeHtml(turn.action.action ?? '')}</div>
739
+ <pre>${escapeHtml(JSON.stringify(turn.action, null, 2))}</pre>
740
+ </div>
741
+ </div>
742
+ ` : ''}
743
+ ${turn.reasoning ? `<div class="panel"><h3>Reasoning</h3><div class="panel-body">${escapeHtml(turn.reasoning)}</div></div>` : ''}
744
+ ${turn.expectedEffect ? `<div class="panel"><h3>Expected Effect</h3><div class="panel-body">${escapeHtml(turn.expectedEffect)}</div></div>` : ''}
745
+ ${turn.plan ? `<div class="panel"><h3>Plan</h3><div class="panel-body"><pre>${escapeHtml(Array.isArray(turn.plan) ? turn.plan.join('\n') : String(turn.plan))}</pre></div></div>` : ''}
746
+ ${turn.tokensUsed ? `<div class="panel"><h3>Tokens</h3><div class="panel-body">${turn.tokensUsed} tokens · ${turn.durationMs}ms</div></div>` : ''}
747
+ `;
748
+ }
749
+
750
+ function showTopFixes(fixes, data) {
751
+ const main = el('main-content');
752
+ main.innerHTML = `
753
+ <div class="top-fixes">
754
+ <h2 style="margin: 8px 0 16px; font-size: 20px;">Top Fixes (by ROI)</h2>
755
+ <p style="color: var(--text-muted); margin: 0 0 24px; font-size: 13px;">
756
+ Sorted by impact × blast / effort. Fix these first.
757
+ </p>
758
+ ${fixes.map((f, i) => `
759
+ <div class="fix-card">
760
+ <div class="fix-card-head">
761
+ <div class="fix-rank">${i + 1}</div>
762
+ <span class="badge badge-${f.severity === 'critical' ? 'error' : f.severity === 'major' ? 'warn' : 'info'}">${f.severity}</span>
763
+ <span class="badge badge-info">${f.category}</span>
764
+ ${f.pageCount && f.pageCount >= 2 ? `<span class="badge badge-warn">${f.pageCount} pages</span>` : ''}
765
+ <div class="fix-roi">ROI <b>${(f.roi ?? 0).toFixed(1)}</b></div>
766
+ </div>
767
+ <div class="fix-desc">${escapeHtml(f.description)}</div>
768
+ <div class="fix-meta">
769
+ <span>Impact ${f.impact ?? '—'}</span>
770
+ <span>Effort ${f.effort ?? '—'}</span>
771
+ <span>Blast ${f.blast ?? '—'}</span>
772
+ <span>📍 ${escapeHtml(f.location)}</span>
773
+ </div>
774
+ ${f.suggestion ? `<div class="fix-suggestion">${escapeHtml(f.suggestion)}</div>` : ''}
775
+ ${f.cssSelector && f.cssFix ? `<div class="fix-css">${escapeHtml(f.cssSelector)} { ${escapeHtml(f.cssFix)} }</div>` : ''}
776
+ </div>
777
+ `).join('')}
778
+ </div>
779
+ `;
780
+ el('aside-panel').innerHTML = `
781
+ <div class="panel">
782
+ <h3>About Top Fixes</h3>
783
+ <div class="panel-body">
784
+ ROI = impact × blast / effort. Higher is better. blast=system means a single fix improves every page.
785
+ </div>
786
+ </div>
787
+ `;
788
+ }
789
+
790
+ function showPage(page) {
791
+ const main = el('main-content');
792
+ const ssPath = resolveScreenshotPath(page.screenshotPath);
793
+ const screenshot = ssPath
794
+ ? `<img src="${escapeHtml(ssPath)}" alt="screenshot" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'empty-screenshot',textContent:'Screenshot not available'}))">`
795
+ : '<div class="empty-screenshot">No screenshot</div>';
796
+ main.innerHTML = `
797
+ <div style="width: 100%; max-width: 1200px;">
798
+ <div style="margin-bottom: 16px;">
799
+ <h2 style="margin: 0; font-size: 18px;">${escapeHtml(page.url)}</h2>
800
+ ${page.classification ? `<div style="color: var(--text-muted); font-size: 12px; margin-top: 4px;">${escapeHtml(page.classification.type)} · ${escapeHtml(page.classification.domain)} · ${escapeHtml(page.classification.maturity)}</div>` : ''}
801
+ </div>
802
+ <div class="screenshot-wrap">${screenshot}</div>
803
+ </div>
804
+ `;
805
+ const aside = el('aside-panel');
806
+ const ds = page.designSystemScore || {};
807
+ const dsRows = Object.entries(ds).map(([k, v]) => `
808
+ <div class="score-row">
809
+ <div class="score-label">${escapeHtml(k)}</div>
810
+ <div class="score-bar"><div class="score-bar-fill ${classifyScore(v)}" style="width: ${v * 10}%;"></div></div>
811
+ <div class="score-num">${v}</div>
812
+ </div>
813
+ `).join('');
814
+
815
+ aside.innerHTML = `
816
+ <div class="panel">
817
+ <h3>Score</h3>
818
+ <div class="panel-body" style="display:flex;align-items:baseline;gap:8px;">
819
+ <span style="font-size:32px;font-weight:700;">${page.score}</span>
820
+ <span style="color:var(--text-muted);font-size:13px;">/10</span>
821
+ </div>
822
+ </div>
823
+ ${page.summary ? `<div class="panel"><h3>Summary</h3><div class="panel-body">${escapeHtml(page.summary)}</div></div>` : ''}
824
+ ${dsRows ? `<div class="panel"><h3>Design System</h3><div class="panel-body"><div class="score-grid">${dsRows}</div></div></div>` : ''}
825
+ ${page.findings?.length ? `
826
+ <div class="panel">
827
+ <h3>Findings (${page.findings.length})</h3>
828
+ <div class="panel-body" style="max-height: 300px; overflow-y: auto;">
829
+ ${page.findings.slice(0, 20).map(f => `
830
+ <div style="padding: 6px 0; border-bottom: 1px solid var(--border);">
831
+ <div style="display:flex;gap:6px;align-items:center;margin-bottom:2px;">
832
+ <span class="badge badge-${f.severity === 'critical' ? 'error' : f.severity === 'major' ? 'warn' : 'info'}">${f.severity}</span>
833
+ <span style="font-size:11px;color:var(--text-muted);">${escapeHtml(f.category)}</span>
834
+ </div>
835
+ <div style="font-size:12px;line-height:1.4;">${escapeHtml(f.description)}</div>
836
+ </div>
837
+ `).join('')}
838
+ </div>
839
+ </div>
840
+ ` : ''}
841
+ `;
842
+ }
843
+
844
+ function showTurn(turn) {
845
+ const main = el('main-content');
846
+ const ssPath = resolveScreenshotPath(turn.state?.screenshot);
847
+ const screenshot = ssPath
848
+ ? `<img src="${escapeHtml(ssPath)}" alt="screenshot" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'empty-screenshot',textContent:'Screenshot not available'}))">`
849
+ : '<div class="empty-screenshot">No screenshot</div>';
850
+ main.innerHTML = `
851
+ <div style="width: 100%; max-width: 1200px;">
852
+ <div style="margin-bottom: 16px;">
853
+ <h2 style="margin: 0; font-size: 18px;">Turn ${(turn.turnNumber ?? 0) + 1}</h2>
854
+ <div style="color: var(--text-muted); font-size: 12px; margin-top: 4px;">${escapeHtml(turn.state?.url ?? '')}</div>
855
+ </div>
856
+ <div class="screenshot-wrap">${screenshot}</div>
857
+ </div>
858
+ `;
859
+ const aside = el('aside-panel');
860
+ aside.innerHTML = `
861
+ ${turn.action ? `
862
+ <div class="panel">
863
+ <h3>Action</h3>
864
+ <div class="panel-body">
865
+ <div style="font-weight:600;margin-bottom:6px;">${escapeHtml(turn.action.action ?? '')}</div>
866
+ <pre>${escapeHtml(JSON.stringify(turn.action, null, 2))}</pre>
867
+ </div>
868
+ </div>
869
+ ` : ''}
870
+ ${turn.reasoning ? `<div class="panel"><h3>Reasoning</h3><div class="panel-body">${escapeHtml(turn.reasoning)}</div></div>` : ''}
871
+ ${turn.expectedEffect ? `<div class="panel"><h3>Expected Effect</h3><div class="panel-body">${escapeHtml(turn.expectedEffect)}</div></div>` : ''}
872
+ ${turn.result ? `
873
+ <div class="panel">
874
+ <h3>Result</h3>
875
+ <div class="panel-body">
876
+ <span class="badge badge-${turn.result.success ? 'success' : 'error'}">${turn.result.success ? 'success' : 'error'}</span>
877
+ ${turn.result.error ? `<pre style="margin-top:8px;">${escapeHtml(turn.result.error)}</pre>` : ''}
878
+ </div>
879
+ </div>
880
+ ` : ''}
881
+ `;
882
+ }
883
+
884
+ function showFatalError(err) {
885
+ console.error('viewer fatal error:', err);
886
+ const main = document.getElementById('main-content');
887
+ const msg = err && err.message ? err.message : String(err);
888
+ if (main) {
889
+ main.innerHTML = `
890
+ <div class="empty-state">
891
+ <h2 style="color: var(--red);">Viewer error</h2>
892
+ <p>${escapeHtml(msg)}</p>
893
+ <p style="margin-top: 16px; font-size: 11px;">Open the browser console for details.</p>
894
+ </div>
895
+ `;
896
+ }
897
+ }
898
+
899
+ window.addEventListener('error', (e) => showFatalError(e.error || e.message));
900
+ window.addEventListener('unhandledrejection', (e) => showFatalError(e.reason));
901
+
902
+ (async () => {
903
+ try {
904
+ const data = await loadData();
905
+ renderRunSummary(data);
906
+ renderTurnList(data);
907
+ // Auto-select the most useful default per run type:
908
+ // - Design audit: Top Fixes section if present, else first page
909
+ // - Agent run: first test's recording if present, else first turn
910
+ if (data?.topFixes?.length) {
911
+ showTopFixes(data.topFixes, data);
912
+ } else if (data?.pages?.length) {
913
+ showPage(data.pages[0]);
914
+ document.querySelector('.turn-item[data-kind="page"]')?.classList.add('active');
915
+ } else if (Array.isArray(data?.tests) && data.tests.length > 0) {
916
+ const firstTest = data.tests[0];
917
+ // Prefer recording when available — that's where the cursor + clicks
918
+ // are visible frame-by-frame. Fall back to per-turn screenshots.
919
+ if (firstTest.recording) {
920
+ showRecording(firstTest);
921
+ document.querySelector('.turn-item[data-kind="recording"]')?.classList.add('active');
922
+ } else if (firstTest.turns?.length) {
923
+ showAgentTurn(firstTest, 0);
924
+ document.querySelector('.turn-item[data-kind="turn"]')?.classList.add('active');
925
+ } else {
926
+ el('main-content').innerHTML = '<div class="empty-state"><h2>Empty test</h2><p>This test has no recording and no turn data.</p></div>';
927
+ }
928
+ } else if (data?.turns?.length) {
929
+ showTurn(data.turns[0]);
930
+ document.querySelector('.turn-item[data-kind="turn"]')?.classList.add('active');
931
+ } else {
932
+ el('main-content').innerHTML = '<div class="empty-state"><h2>No data</h2><p>Pass a run directory or place report.json next to this file.</p></div>';
933
+ }
934
+ } catch (err) {
935
+ showFatalError(err);
936
+ }
937
+ })();
938
+ </script>
939
+ </body>
940
+ </html>