bee-ladybug 0.5.4 → 0.6.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.
@@ -0,0 +1,615 @@
1
+ /**
2
+ * ReportExporter — turns a core snapshot into HTML, JSON, or CSV.
3
+ *
4
+ * No adapter imports. Attached modules are asked through the snapshot
5
+ * hook stored by the core. A missing hook is skipped.
6
+ */
7
+
8
+ const LATEST_TYPES = Object.freeze([
9
+ 'fps',
10
+ 'metric',
11
+ 'coord',
12
+ 'log',
13
+ 'warn',
14
+ 'error',
15
+ 'state',
16
+ 'sys',
17
+ 'telemetry'
18
+ ]);
19
+
20
+ /**
21
+ * @typedef {'html' | 'json' | 'csv'} ReportFormat
22
+ * @typedef {'error' | 'warn' | 'ok'} ReportSeverity
23
+ * @typedef {{ severity: ReportSeverity, selector: string, rule: string, value: string, source: string }} ReportItem
24
+ */
25
+
26
+ /**
27
+ * Build a report and, when the overlay is mounted, download it.
28
+ * Headless cores (`ui: false`, or no document) return the value and do not download.
29
+ *
30
+ * JSON returns the plain object. HTML and CSV return strings.
31
+ *
32
+ * @param {object} core
33
+ * @param {{ format?: ReportFormat }} [options]
34
+ * @returns {object | string}
35
+ */
36
+ export function exportReport(core, options = {}) {
37
+ if (!core || typeof core.getState !== 'function' || typeof core.collectSnapshots !== 'function') {
38
+ throw new TypeError('exportReport() needs a BeeLadybug core.');
39
+ }
40
+
41
+ const format = normalizeFormat(options.format);
42
+ const report = buildReport(core);
43
+
44
+ if (format === 'json') {
45
+ saveIfMounted(core, report, 'json', 'application/json', JSON.stringify(report, null, 2));
46
+ return report;
47
+ }
48
+
49
+ if (format === 'csv') {
50
+ const text = renderCsv(report);
51
+ saveIfMounted(core, report, 'csv', 'text/csv', text);
52
+ return text;
53
+ }
54
+
55
+ const html = renderHtml(report);
56
+ saveIfMounted(core, report, 'html', 'text/html', html);
57
+ return html;
58
+ }
59
+
60
+ /**
61
+ * Skip hidden boxes, the WordPress admin bar, and BeeLadybug's own chrome.
62
+ * Adapters call this while they still have the element. The exported row does not.
63
+ *
64
+ * @param {object | null | undefined} node
65
+ * @returns {boolean}
66
+ */
67
+ export function isReportNoise(node) {
68
+ if (!node || typeof node !== 'object') return false;
69
+ const el = node.nodeType === 1 ? node : (node.parentElement || node);
70
+ if (!el || typeof el !== 'object') return false;
71
+
72
+ const seen = new Set();
73
+ let current = el;
74
+ while (current && typeof current === 'object') {
75
+ if (seen.has(current)) break;
76
+ seen.add(current);
77
+ if (current.id === 'wpadminbar') return true;
78
+ if (hasAbClass(current.className)) return true;
79
+ if (current.tagName === 'BEE-LADYBUG-OVERLAY') return true;
80
+ if (hasBeeAttribute(current)) return true;
81
+ current = current.parentElement || null;
82
+ }
83
+
84
+ const style = readStyle(el);
85
+ if (style && (style.display === 'none' || style.visibility === 'hidden')) return true;
86
+
87
+ if (typeof el.getBoundingClientRect === 'function') {
88
+ const rect = el.getBoundingClientRect();
89
+ if (rect && (Number(rect.width) === 0 || Number(rect.height) === 0)) return true;
90
+ }
91
+
92
+ return false;
93
+ }
94
+
95
+ /**
96
+ * @param {object} core
97
+ */
98
+ function buildReport(core) {
99
+ const state = core.getState();
100
+ const generatedAt = new Date();
101
+ const env = readEnvironment();
102
+ const logs = readLogs(core);
103
+ const series = readSeries(core);
104
+ const latest = readLatest(core);
105
+ const adapters = readAdapters(core);
106
+ const summary = countFindings(adapters);
107
+
108
+ return {
109
+ version: state.version,
110
+ generatedAt: generatedAt.toISOString(),
111
+ fileStamp: fileStamp(generatedAt),
112
+ url: env.url,
113
+ viewport: env.viewport,
114
+ clock: {
115
+ wallTime: generatedAt.getTime(),
116
+ simTime: state.simTime,
117
+ frame: state.frameCount,
118
+ frozen: state.frozen,
119
+ timeScale: state.timeScale,
120
+ coreFps: state.coreFps
121
+ },
122
+ summary,
123
+ logs,
124
+ series,
125
+ latest,
126
+ adapters
127
+ };
128
+ }
129
+
130
+ /**
131
+ * @param {object} core
132
+ */
133
+ function readLogs(core) {
134
+ const logs = typeof core.getLogs === 'function' ? core.getLogs() : [];
135
+ return logs.map((packet) => ({
136
+ type: packet.type,
137
+ level: packet.level ?? null,
138
+ source: packet.source,
139
+ message: packetMessage(packet),
140
+ wallTime: packet.wallTime,
141
+ simTime: packet.simTime,
142
+ frame: packet.frame
143
+ }));
144
+ }
145
+
146
+ /**
147
+ * FPS plus every metric name currently on the HUD.
148
+ * @param {object} core
149
+ */
150
+ function readSeries(core) {
151
+ const names = new Set(['fps']);
152
+ const hud = typeof core.getHud === 'function' ? core.getHud() : {};
153
+ for (const [name, entry] of Object.entries(hud)) {
154
+ if (entry && entry.type === 'metric') names.add(name);
155
+ }
156
+
157
+ const series = [];
158
+ for (const name of names) {
159
+ const history = core.getHistory(name);
160
+ if (!history || !history.values || !history.values.length) continue;
161
+ series.push({
162
+ name: history.name,
163
+ min: history.min,
164
+ max: history.max,
165
+ avg: history.avg,
166
+ last: history.last,
167
+ samples: history.values.slice()
168
+ });
169
+ }
170
+ return series;
171
+ }
172
+
173
+ /**
174
+ * @param {object} core
175
+ */
176
+ function readLatest(core) {
177
+ if (typeof core.getLatest !== 'function') return [];
178
+ const packets = [];
179
+ for (const type of LATEST_TYPES) {
180
+ const packet = core.getLatest(type);
181
+ if (!packet) continue;
182
+ packets.push({
183
+ id: packet.id,
184
+ type: packet.type,
185
+ payload: packet.payload,
186
+ wallTime: packet.wallTime,
187
+ simTime: packet.simTime,
188
+ frame: packet.frame,
189
+ source: packet.source,
190
+ level: packet.level ?? null
191
+ });
192
+ }
193
+ return packets;
194
+ }
195
+
196
+ /**
197
+ * @param {object} core
198
+ */
199
+ function readAdapters(core) {
200
+ const raw = core.collectSnapshots();
201
+ const sections = [];
202
+ for (const entry of raw) {
203
+ if (entry.error) {
204
+ sections.push({
205
+ name: entry.name,
206
+ items: [makeItem({
207
+ severity: 'warn',
208
+ rule: 'snapshot',
209
+ value: entry.error,
210
+ source: entry.name
211
+ })]
212
+ });
213
+ continue;
214
+ }
215
+ const result = entry.result;
216
+ const name = result && typeof result.name === 'string' && result.name
217
+ ? result.name
218
+ : entry.name;
219
+ const list = result && Array.isArray(result.items) ? result.items : [];
220
+ sections.push({
221
+ name,
222
+ items: list.filter((row) => row && typeof row === 'object').map((row) => makeItem(row, name))
223
+ });
224
+ }
225
+ return sections;
226
+ }
227
+
228
+ /**
229
+ * @param {object} row
230
+ * @param {string} [fallbackSource]
231
+ * @returns {ReportItem}
232
+ */
233
+ function makeItem(row, fallbackSource = '') {
234
+ return {
235
+ severity: normalizeSeverity(row.severity),
236
+ selector: asText(row.selector),
237
+ rule: asText(row.rule),
238
+ value: asText(row.value),
239
+ source: asText(row.source) || fallbackSource
240
+ };
241
+ }
242
+
243
+ /**
244
+ * @param {Array<{ items: ReportItem[] }>} adapters
245
+ */
246
+ function countFindings(adapters) {
247
+ const summary = { error: 0, warn: 0, ok: 0 };
248
+ for (const section of adapters) {
249
+ for (const item of section.items) summary[item.severity] += 1;
250
+ }
251
+ return summary;
252
+ }
253
+
254
+ /**
255
+ * @param {string | undefined} format
256
+ * @returns {ReportFormat}
257
+ */
258
+ function normalizeFormat(format) {
259
+ if (format === 'json' || format === 'csv' || format === 'html') return format;
260
+ return 'html';
261
+ }
262
+
263
+ /**
264
+ * @param {unknown} value
265
+ * @returns {ReportSeverity}
266
+ */
267
+ function normalizeSeverity(value) {
268
+ const text = String(value || '').toLowerCase();
269
+ if (text === 'error') return 'error';
270
+ if (text === 'warn' || text === 'warning') return 'warn';
271
+ return 'ok';
272
+ }
273
+
274
+ /**
275
+ * @param {object} report
276
+ */
277
+ function renderCsv(report) {
278
+ const lines = ['adapter,severity,selector,rule,value,source'];
279
+ for (const log of report.logs) {
280
+ lines.push(csvRow([
281
+ 'core',
282
+ logSeverity(log),
283
+ '',
284
+ log.type,
285
+ log.message,
286
+ log.source
287
+ ]));
288
+ }
289
+ for (const series of report.series) {
290
+ lines.push(csvRow([
291
+ 'core',
292
+ 'ok',
293
+ '',
294
+ series.name,
295
+ `min ${formatNum(series.min)} / avg ${formatNum(series.avg)} / max ${formatNum(series.max)}`,
296
+ 'core'
297
+ ]));
298
+ }
299
+ for (const section of report.adapters) {
300
+ for (const item of section.items) {
301
+ lines.push(csvRow([
302
+ section.name,
303
+ item.severity,
304
+ item.selector,
305
+ item.rule,
306
+ item.value,
307
+ item.source
308
+ ]));
309
+ }
310
+ }
311
+ return `${lines.join('\n')}\n`;
312
+ }
313
+
314
+ /**
315
+ * @param {unknown[]} cells
316
+ */
317
+ function csvRow(cells) {
318
+ return cells.map(csvCell).join(',');
319
+ }
320
+
321
+ /**
322
+ * @param {unknown} value
323
+ */
324
+ function csvCell(value) {
325
+ const text = value == null ? '' : String(value);
326
+ if (/[",\n\r]/.test(text)) return `"${text.replace(/"/g, '""')}"`;
327
+ return text;
328
+ }
329
+
330
+ /**
331
+ * @param {{ type: string, level: string | null }} log
332
+ * @returns {ReportSeverity}
333
+ */
334
+ function logSeverity(log) {
335
+ if (log.level === 'error' || log.type === 'error') return 'error';
336
+ if (log.level === 'warn' || log.type === 'warn') return 'warn';
337
+ return 'ok';
338
+ }
339
+
340
+ /**
341
+ * @param {object} report
342
+ */
343
+ function renderHtml(report) {
344
+ const title = `BeeLadybug report ${report.fileStamp}`;
345
+ const viewport = report.viewport
346
+ ? `${report.viewport.width}×${report.viewport.height}`
347
+ : 'n/a';
348
+ const sections = report.adapters.map((section) => renderSection(section)).join('\n');
349
+
350
+ return `<!DOCTYPE html>
351
+ <html lang="en">
352
+ <head>
353
+ <meta charset="utf-8">
354
+ <meta name="viewport" content="width=device-width, initial-scale=1">
355
+ <title>${escapeHtml(title)}</title>
356
+ <style>
357
+ :root { color-scheme: dark; }
358
+ * { box-sizing: border-box; }
359
+ body {
360
+ margin: 0;
361
+ background: #07080c;
362
+ color: #f1f2f6;
363
+ font: 14px/1.45 ui-monospace, Consolas, monospace;
364
+ }
365
+ main { max-width: 1100px; margin: 0 auto; padding: 28px 22px 64px; }
366
+ h1 { margin: 0 0 8px; color: #ff3b4e; font-size: 22px; letter-spacing: 0.04em; }
367
+ h2 { margin: 28px 0 10px; font-size: 13px; letter-spacing: 0.14em; color: #8b91a3; }
368
+ .meta { color: #8b91a3; margin: 0 0 18px; }
369
+ .counts { display: flex; gap: 10px; flex-wrap: wrap; margin: 0 0 8px; }
370
+ .count { padding: 8px 12px; background: #101218; border: 1px solid #2a2e3a; }
371
+ .count b { display: block; font-size: 20px; }
372
+ .count.error b { color: #ff3b4e; }
373
+ .count.warn b { color: #ffd36a; }
374
+ .count.ok b { color: #3dd68c; }
375
+ table { width: 100%; border-collapse: collapse; margin: 0 0 8px; }
376
+ th, td { text-align: left; padding: 7px 8px; border-bottom: 1px solid #2a2e3a; vertical-align: top; }
377
+ th { color: #8b91a3; font-weight: 600; cursor: pointer; background: #101218; }
378
+ tr.sev-error td { color: #ff8b96; }
379
+ tr.sev-warn td { color: #ffd36a; }
380
+ tr.sev-ok td { color: #d5d8e2; }
381
+ td.sev { font-weight: 700; }
382
+ tr.sev-error td.sev { color: #ff3b4e; }
383
+ tr.sev-warn td.sev { color: #ffd36a; }
384
+ tr.sev-ok td.sev { color: #3dd68c; }
385
+ .empty { color: #8b91a3; }
386
+ @media print {
387
+ body { background: #07080c; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
388
+ main { max-width: none; padding: 0; }
389
+ h2, table, .count { break-inside: avoid; }
390
+ thead { display: table-header-group; }
391
+ th { cursor: default; }
392
+ }
393
+ </style>
394
+ </head>
395
+ <body>
396
+ <main>
397
+ <h1>${escapeHtml(title)}</h1>
398
+ <p class="meta">v${escapeHtml(report.version)} · ${escapeHtml(report.generatedAt)} · sim ${escapeHtml(formatSim(report.clock.simTime))} · frame ${escapeHtml(report.clock.frame)} · ${escapeHtml(report.clock.frozen ? 'FREEZE' : `${formatNum(report.clock.timeScale)}x`)}<br>
399
+ ${escapeHtml(report.url || 'n/a')} · viewport ${escapeHtml(viewport)}</p>
400
+ <div class="counts">
401
+ <div class="count error"><b>${report.summary.error}</b>errors</div>
402
+ <div class="count warn"><b>${report.summary.warn}</b>warnings</div>
403
+ <div class="count ok"><b>${report.summary.ok}</b>ok</div>
404
+ </div>
405
+ <h2>CORE · LOG</h2>
406
+ ${renderLogTable(report.logs)}
407
+ <h2>CORE · SERIES</h2>
408
+ ${renderSeriesTable(report.series)}
409
+ ${sections || '<h2>ADAPTERS</h2><p class="empty">No attached module exposed a snapshot.</p>'}
410
+ </main>
411
+ <script>
412
+ document.querySelectorAll('table').forEach((table) => {
413
+ table.querySelectorAll('thead th').forEach((th, index) => {
414
+ th.addEventListener('click', () => {
415
+ const body = table.tBodies[0];
416
+ if (!body) return;
417
+ const dir = th.dataset.dir === 'asc' ? 'desc' : 'asc';
418
+ table.querySelectorAll('thead th').forEach((other) => { delete other.dataset.dir; });
419
+ th.dataset.dir = dir;
420
+ const mul = dir === 'asc' ? 1 : -1;
421
+ [...body.rows].sort((a, b) => {
422
+ const av = a.cells[index] ? a.cells[index].textContent : '';
423
+ const bv = b.cells[index] ? b.cells[index].textContent : '';
424
+ const an = Number(av);
425
+ const bn = Number(bv);
426
+ if (av !== '' && bv !== '' && Number.isFinite(an) && Number.isFinite(bn)) return (an - bn) * mul;
427
+ return av.localeCompare(bv) * mul;
428
+ }).forEach((row) => body.appendChild(row));
429
+ });
430
+ });
431
+ });
432
+ </script>
433
+ </body>
434
+ </html>
435
+ `;
436
+ }
437
+
438
+ /**
439
+ * @param {Array<{ type: string, level: string | null, source: string, message: string, simTime: number, frame: number }>} logs
440
+ */
441
+ function renderLogTable(logs) {
442
+ if (!logs.length) return '<p class="empty">No log lines.</p>';
443
+ const rows = logs.map((log) => {
444
+ const severity = logSeverity(log);
445
+ return `<tr class="sev-${severity}"><td class="sev">${escapeHtml(severity)}</td><td>${escapeHtml(log.type)}</td><td>${escapeHtml(log.source)}</td><td>${escapeHtml(log.message)}</td><td>${escapeHtml(formatSim(log.simTime))}</td><td>${escapeHtml(log.frame)}</td></tr>`;
446
+ }).join('');
447
+ return `<table>
448
+ <thead><tr><th>severity</th><th>type</th><th>source</th><th>message</th><th>sim</th><th>frame</th></tr></thead>
449
+ <tbody>${rows}</tbody>
450
+ </table>`;
451
+ }
452
+
453
+ /**
454
+ * @param {Array<{ name: string, min: number, avg: number, max: number, last: number, samples: number[] }>} series
455
+ */
456
+ function renderSeriesTable(series) {
457
+ if (!series.length) return '<p class="empty">No fps or metric samples.</p>';
458
+ const rows = series.map((entry) => `<tr class="sev-ok"><td>${escapeHtml(entry.name)}</td><td>${escapeHtml(formatNum(entry.min))}</td><td>${escapeHtml(formatNum(entry.avg))}</td><td>${escapeHtml(formatNum(entry.max))}</td><td>${escapeHtml(formatNum(entry.last))}</td><td>${entry.samples.length}</td></tr>`).join('');
459
+ return `<table>
460
+ <thead><tr><th>name</th><th>min</th><th>avg</th><th>max</th><th>last</th><th>samples</th></tr></thead>
461
+ <tbody>${rows}</tbody>
462
+ </table>`;
463
+ }
464
+
465
+ /**
466
+ * @param {{ name: string, items: ReportItem[] }} section
467
+ */
468
+ function renderSection(section) {
469
+ const title = escapeHtml(section.name);
470
+ if (!section.items.length) {
471
+ return `<h2>${title}</h2><p class="empty">No rows.</p>`;
472
+ }
473
+ const rows = section.items.map((item) => `<tr class="sev-${item.severity}"><td class="sev">${escapeHtml(item.severity)}</td><td>${escapeHtml(item.selector)}</td><td>${escapeHtml(item.rule)}</td><td>${escapeHtml(item.value)}</td><td>${escapeHtml(item.source)}</td></tr>`).join('');
474
+ return `<h2>${title}</h2>
475
+ <table>
476
+ <thead><tr><th>severity</th><th>selector</th><th>rule</th><th>value</th><th>source</th></tr></thead>
477
+ <tbody>${rows}</tbody>
478
+ </table>`;
479
+ }
480
+
481
+ /**
482
+ * @param {object} core
483
+ * @param {{ fileStamp: string }} report
484
+ * @param {string} ext
485
+ * @param {string} mime
486
+ * @param {string} text
487
+ */
488
+ function saveIfMounted(core, report, ext, mime, text) {
489
+ if (!core.ui) return;
490
+ if (typeof document === 'undefined' || !document.body) return;
491
+ if (typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function' || typeof Blob === 'undefined') return;
492
+
493
+ const blob = new Blob([text], { type: `${mime};charset=utf-8` });
494
+ const url = URL.createObjectURL(blob);
495
+ const link = document.createElement('a');
496
+ link.href = url;
497
+ link.download = `report-${report.fileStamp}.${ext}`;
498
+ link.rel = 'noopener';
499
+ document.body.appendChild(link);
500
+ link.click();
501
+ link.remove();
502
+ setTimeout(() => URL.revokeObjectURL(url), 1500);
503
+ }
504
+
505
+ function readEnvironment() {
506
+ if (typeof window === 'undefined' || typeof location === 'undefined') {
507
+ return { url: '', viewport: null };
508
+ }
509
+ const width = Number(window.innerWidth);
510
+ const height = Number(window.innerHeight);
511
+ return {
512
+ url: String(location.href || ''),
513
+ viewport: Number.isFinite(width) && Number.isFinite(height)
514
+ ? { width, height }
515
+ : null
516
+ };
517
+ }
518
+
519
+ /**
520
+ * @param {object} packet
521
+ */
522
+ function packetMessage(packet) {
523
+ const payload = packet && packet.payload;
524
+ if (!payload || typeof payload !== 'object') return '';
525
+ if (typeof payload.message === 'string') return payload.message;
526
+ if (payload.value != null) return String(payload.value);
527
+ return '';
528
+ }
529
+
530
+ /**
531
+ * @param {unknown} value
532
+ */
533
+ function asText(value) {
534
+ if (value == null) return '';
535
+ if (typeof value === 'string') return value;
536
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
537
+ try {
538
+ return JSON.stringify(value);
539
+ } catch {
540
+ return String(value);
541
+ }
542
+ }
543
+
544
+ /**
545
+ * @param {number} value
546
+ */
547
+ function formatNum(value) {
548
+ const n = Number(value);
549
+ if (!Number.isFinite(n)) return '';
550
+ if (Number.isInteger(n)) return String(n);
551
+ return String(Math.round(n * 100) / 100);
552
+ }
553
+
554
+ /**
555
+ * @param {number} ms
556
+ */
557
+ function formatSim(ms) {
558
+ const total = Math.max(0, Number(ms) || 0) / 1000;
559
+ const minutes = Math.floor(total / 60);
560
+ const seconds = total - minutes * 60;
561
+ return `${String(minutes).padStart(2, '0')}:${seconds.toFixed(1).padStart(4, '0')}`;
562
+ }
563
+
564
+ /**
565
+ * @param {Date} date
566
+ */
567
+ function fileStamp(date) {
568
+ const pad = (n) => String(n).padStart(2, '0');
569
+ return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}`;
570
+ }
571
+
572
+ /**
573
+ * @param {unknown} value
574
+ */
575
+ function escapeHtml(value) {
576
+ return String(value ?? '')
577
+ .replace(/&/g, '&amp;')
578
+ .replace(/</g, '&lt;')
579
+ .replace(/>/g, '&gt;')
580
+ .replace(/"/g, '&quot;');
581
+ }
582
+
583
+ /**
584
+ * @param {unknown} className
585
+ */
586
+ function hasAbClass(className) {
587
+ const raw = typeof className === 'string'
588
+ ? className
589
+ : (className && typeof className === 'object' && 'baseVal' in className
590
+ ? String(className.baseVal)
591
+ : '');
592
+ return raw.split(/\s+/).some((token) => token.startsWith('ab-'));
593
+ }
594
+
595
+ /**
596
+ * @param {object} el
597
+ */
598
+ function hasBeeAttribute(el) {
599
+ if (typeof el.hasAttribute !== 'function') return false;
600
+ return el.hasAttribute('data-bee-ladybug-dom')
601
+ || el.hasAttribute('data-bee-ladybug-canvas')
602
+ || el.hasAttribute('data-bee-ladybug-css');
603
+ }
604
+
605
+ /**
606
+ * @param {object} el
607
+ */
608
+ function readStyle(el) {
609
+ if (typeof getComputedStyle !== 'function') return null;
610
+ try {
611
+ return getComputedStyle(el);
612
+ } catch {
613
+ return null;
614
+ }
615
+ }
@@ -542,11 +542,12 @@ export class UIOverlay {
542
542
  <button class="bl-btn" data-act="slow" type="button">F3 SLOW</button>
543
543
  <button class="bl-btn" data-act="freeze" type="button">F4 STOP</button>
544
544
  <button class="bl-btn" data-act="live" type="button">1x LIVE</button>
545
+ <button class="bl-btn" data-act="export" type="button">F8 EXPORT</button>
545
546
  <span class="bl-hotkeys" data-el="hotkeys"></span>
546
547
  <button class="bl-btn" data-act="ask" type="button">ASK AI</button>
547
548
  <span class="bl-providers" data-el="providers"></span>
548
549
  <span class="bl-adapters" data-el="adapters"></span>
549
- <span class="bl-hint">F2 overlay</span>
550
+ <span class="bl-hint">F2 overlay · F8 export</span>
550
551
  </div>
551
552
  </div>
552
553
  </div>
@@ -581,6 +582,7 @@ export class UIOverlay {
581
582
  if (act === 'slow') this.core.applySlowMo();
582
583
  else if (act === 'freeze') this.core.toggleFreeze();
583
584
  else if (act === 'live') this.core.restoreRealtime();
585
+ else if (act === 'export') this.core.exportReport('html');
584
586
  else if (act === 'ask') this.core.ask();
585
587
  else if (act === 'assist') this.core.setActiveAssistant(btn.getAttribute('data-name'));
586
588
  else if (act === 'adapter') this.core.toggleAdapter(btn.getAttribute('data-name'));