bee-ladybug 0.5.4 → 0.6.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.
@@ -4,6 +4,8 @@
4
4
  * Reads box models, the node under the pointer, and mutation noise,
5
5
  * then pushes packets into the core. Highlights are drawn on a stacked
6
6
  * overlay canvas so the page stays untouched.
7
+ * `snapshot()` hands the report the box model, problem nodes, and
8
+ * the latest mutation counts.
7
9
  *
8
10
  * The core must never import this file.
9
11
  *
@@ -12,6 +14,10 @@
12
14
  * adapter.attach();
13
15
  */
14
16
 
17
+ import { isReportNoise } from '../core/ReportExporter.js';
18
+
19
+ const MAX_PROBLEM_ROWS = 40;
20
+
15
21
  export const WEB_DOM_ADAPTER_DEFAULTS = Object.freeze({
16
22
  overlay: true,
17
23
  autoTick: true,
@@ -52,6 +58,7 @@ export class WebDOMAdapter {
52
58
  this.hover = null;
53
59
 
54
60
  this.#attached = false;
61
+ this.#reporting = true;
55
62
  this.#overlay = null;
56
63
  this.#unsubscribe = null;
57
64
  this.#observer = null;
@@ -62,6 +69,9 @@ export class WebDOMAdapter {
62
69
  this.#mutAdded = 0;
63
70
  this.#mutRemoved = 0;
64
71
  this.#mutAttr = 0;
72
+ this.#seenAdded = 0;
73
+ this.#seenRemoved = 0;
74
+ this.#seenAttr = 0;
65
75
  this.#mutTimer = 0;
66
76
  this.#lastHoverKey = '';
67
77
  this.#onMove = (event) => {
@@ -78,6 +88,7 @@ export class WebDOMAdapter {
78
88
  }
79
89
 
80
90
  #attached;
91
+ #reporting;
81
92
  #overlay;
82
93
  #unsubscribe;
83
94
  #observer;
@@ -88,6 +99,9 @@ export class WebDOMAdapter {
88
99
  #mutAdded;
89
100
  #mutRemoved;
90
101
  #mutAttr;
102
+ #seenAdded;
103
+ #seenRemoved;
104
+ #seenAttr;
91
105
  #mutTimer;
92
106
  #lastHoverKey;
93
107
  #onMove;
@@ -123,6 +137,15 @@ export class WebDOMAdapter {
123
137
  this.#rafId = requestAnimationFrame(this.#onTick);
124
138
  }
125
139
 
140
+ this.#reporting = true;
141
+ this.core?.registerAdapter('dom', {
142
+ label: 'DOM',
143
+ enabled: true,
144
+ enable: () => this.#enable(),
145
+ disable: () => this.#disable(),
146
+ snapshot: () => this.snapshot()
147
+ });
148
+
126
149
  this.core?.sendData('log', {
127
150
  source: this.source,
128
151
  message: 'WebDOMAdapter attached. Hover a node to inspect the box model.'
@@ -148,6 +171,7 @@ export class WebDOMAdapter {
148
171
  this.#rafId = 0;
149
172
  if (this.#mutTimer && typeof clearTimeout === 'function') clearTimeout(this.#mutTimer);
150
173
  this.#mutTimer = 0;
174
+ this.core?.unregisterAdapter('dom');
151
175
  if (typeof window !== 'undefined') {
152
176
  window.removeEventListener('mousemove', this.#onMove, true);
153
177
  window.removeEventListener('resize', this.#onResize);
@@ -172,8 +196,36 @@ export class WebDOMAdapter {
172
196
  return this;
173
197
  }
174
198
 
199
+ /**
200
+ * Box model of the hover and watched nodes, layout problems under root,
201
+ * and the latest mutation counts. Returns null when detached.
202
+ * Hidden boxes, the WordPress admin bar, and BeeLadybug chrome are skipped.
203
+ * The core stores this function and does not read the rows.
204
+ * @returns {{ name: string, items: Array<{ severity: string, selector: string, rule: string, value: string, source: string }> } | null}
205
+ */
206
+ snapshot() {
207
+ if (!this.#attached) return null;
208
+ const items = [];
209
+ const seen = new Set();
210
+ const targets = [];
211
+ if (this.hover) targets.push(this.hover);
212
+ const watched = this.#watchedNodes();
213
+ for (let i = 0; i < watched.length; i++) targets.push(watched[i]);
214
+ for (let i = 0; i < targets.length; i++) {
215
+ const node = targets[i];
216
+ if (!node || seen.has(node) || !reportable(node)) continue;
217
+ seen.add(node);
218
+ items.push(boxItem(node, this.source));
219
+ }
220
+ const problems = problemItems(this.root, this.source);
221
+ for (let i = 0; i < problems.length; i++) items.push(problems[i]);
222
+ items.push(mutationItem(this.#mutationCounts(), this.root, this.source));
223
+ return { name: 'dom', items };
224
+ }
225
+
175
226
  pump() {
176
227
  if (!this.#attached || !this.core) return this;
228
+ if (!this.#reporting) return this;
177
229
 
178
230
  const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
179
231
  if (this.#lastPump) {
@@ -311,6 +363,11 @@ export class WebDOMAdapter {
311
363
  const added = this.#mutAdded;
312
364
  const removed = this.#mutRemoved;
313
365
  const attr = this.#mutAttr;
366
+ if (added || removed || attr) {
367
+ this.#seenAdded = added;
368
+ this.#seenRemoved = removed;
369
+ this.#seenAttr = attr;
370
+ }
314
371
  this.#mutAdded = 0;
315
372
  this.#mutRemoved = 0;
316
373
  this.#mutAttr = 0;
@@ -394,7 +451,26 @@ export class WebDOMAdapter {
394
451
  canvas.style.height = `${h}px`;
395
452
  canvas.width = Math.round(w * dpr);
396
453
  canvas.height = Math.round(h * dpr);
397
- canvas.style.display = this.core && this.core.visible === false ? 'none' : 'block';
454
+ const hidden = !this.#reporting || (this.core && this.core.visible === false);
455
+ canvas.style.display = hidden ? 'none' : 'block';
456
+ }
457
+
458
+ #enable() {
459
+ this.#reporting = true;
460
+ this.#syncOverlay();
461
+ }
462
+
463
+ #disable() {
464
+ this.#reporting = false;
465
+ this.#syncOverlay();
466
+ }
467
+
468
+ #mutationCounts() {
469
+ const pending = this.#mutAdded + this.#mutRemoved + this.#mutAttr;
470
+ if (pending) {
471
+ return { added: this.#mutAdded, removed: this.#mutRemoved, attr: this.#mutAttr };
472
+ }
473
+ return { added: this.#seenAdded, removed: this.#seenRemoved, attr: this.#seenAttr };
398
474
  }
399
475
  }
400
476
 
@@ -504,6 +580,138 @@ function drawLabel(ctx, el, viewW, viewH, color) {
504
580
  ctx.fillText(text, x + 6, y + height / 2);
505
581
  }
506
582
 
583
+ function reportable(node) {
584
+ if (!node || node.nodeType !== 1) return false;
585
+ if (typeof node.getBoundingClientRect !== 'function') return false;
586
+ if (isBeeChrome(node) || isReportNoise(node)) return false;
587
+ return true;
588
+ }
589
+
590
+ function boxItem(node, source) {
591
+ const model = boxModel(node);
592
+ return {
593
+ severity: 'ok',
594
+ selector: cssPath(node),
595
+ rule: 'box',
596
+ value: boxValue(model, styleOf(node)),
597
+ source
598
+ };
599
+ }
600
+
601
+ function boxValue(model, style) {
602
+ const border = model.border;
603
+ const content = model.content;
604
+ return `border ${Math.round(border.w)}x${Math.round(border.h)} margin ${sideText(style, 'margin')} padding ${sideText(style, 'padding')} content ${Math.round(content.w)}x${Math.round(content.h)} ${model.display}`;
605
+ }
606
+
607
+ function sideText(style, kind) {
608
+ const prefix = kind === 'margin' ? 'margin' : 'padding';
609
+ const top = style ? parsePx(style[`${prefix}Top`]) : 0;
610
+ const right = style ? parsePx(style[`${prefix}Right`]) : 0;
611
+ const bottom = style ? parsePx(style[`${prefix}Bottom`]) : 0;
612
+ const left = style ? parsePx(style[`${prefix}Left`]) : 0;
613
+ return `${top},${right},${bottom},${left}`;
614
+ }
615
+
616
+ function problemItems(root, source) {
617
+ const nodes = collectElements(root);
618
+ const items = [];
619
+ const seen = new Set();
620
+ for (let i = 0; i < nodes.length && items.length < MAX_PROBLEM_ROWS; i++) {
621
+ const node = nodes[i];
622
+ if (!node || seen.has(node) || !reportable(node)) continue;
623
+ seen.add(node);
624
+ const style = styleOf(node);
625
+ const margin = sideText(style, 'margin');
626
+ if (hasNegative(margin)) {
627
+ items.push(problemItem(node, 'negative margin', margin, source));
628
+ if (items.length >= MAX_PROBLEM_ROWS) break;
629
+ }
630
+ const clip = clipValue(node);
631
+ if (clip) {
632
+ items.push(problemItem(node, 'clipped', clip, source));
633
+ if (items.length >= MAX_PROBLEM_ROWS) break;
634
+ }
635
+ const model = boxModel(node);
636
+ if (model.border.w > 0 && model.border.h > 0 && (model.content.w === 0 || model.content.h === 0)) {
637
+ items.push(problemItem(
638
+ node,
639
+ 'collapsed content',
640
+ `content ${Math.round(model.content.w)}x${Math.round(model.content.h)}`,
641
+ source
642
+ ));
643
+ }
644
+ }
645
+ return items;
646
+ }
647
+
648
+ function problemItem(node, rule, value, source) {
649
+ return {
650
+ severity: 'warn',
651
+ selector: cssPath(node),
652
+ rule,
653
+ value,
654
+ source
655
+ };
656
+ }
657
+
658
+ function hasNegative(sides) {
659
+ return sides.split(',').some((part) => Number(part) < 0);
660
+ }
661
+
662
+ function clipValue(node) {
663
+ const sw = finite(node.scrollWidth);
664
+ const cw = finite(node.clientWidth);
665
+ const sh = finite(node.scrollHeight);
666
+ const ch = finite(node.clientHeight);
667
+ const wide = sw != null && cw != null && sw > cw + 2;
668
+ const tall = sh != null && ch != null && sh > ch + 2;
669
+ if (!wide && !tall) return '';
670
+ return `scroll ${sw ?? 0}x${sh ?? 0} client ${cw ?? 0}x${ch ?? 0}`;
671
+ }
672
+
673
+ function mutationItem(counts, root, source) {
674
+ const total = counts.added + counts.removed + counts.attr;
675
+ return {
676
+ severity: total > 0 ? 'warn' : 'ok',
677
+ selector: root && root.nodeType === 1 ? cssPath(root) : 'root',
678
+ rule: 'mutations',
679
+ value: `+${counts.added} -${counts.removed} attr ${counts.attr}`,
680
+ source
681
+ };
682
+ }
683
+
684
+ function styleOf(node) {
685
+ if (typeof getComputedStyle !== 'function') return null;
686
+ try {
687
+ return getComputedStyle(node);
688
+ } catch {
689
+ return null;
690
+ }
691
+ }
692
+
693
+ function collectElements(root) {
694
+ if (!root) return [];
695
+ const out = [];
696
+ if (root.nodeType === 1) out.push(root);
697
+ let list = null;
698
+ if (typeof root.querySelectorAll === 'function') {
699
+ try {
700
+ list = root.querySelectorAll('*');
701
+ } catch {
702
+ list = null;
703
+ }
704
+ }
705
+ if (list) {
706
+ for (let i = 0; i < list.length; i++) out.push(list[i]);
707
+ }
708
+ return out;
709
+ }
710
+
711
+ function finite(value) {
712
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
713
+ }
714
+
507
715
  function countElements(root) {
508
716
  if (!root) return 0;
509
717
  if (root.nodeType === 1) return 1 + (root.getElementsByTagName?.('*').length ?? 0);
@@ -10,6 +10,7 @@
10
10
  *
11
11
  * ingest() is the same parser used by the socket, so tests and the mock
12
12
  * demo do not need a live Python process.
13
+ * `snapshot()` lists the latest metric received for each name.
13
14
  */
14
15
 
15
16
  export const PYTHON_BRIDGE_DEFAULTS = Object.freeze({
@@ -35,16 +36,27 @@ export class PythonBridge {
35
36
 
36
37
  this.#socket = null;
37
38
  this.#wanted = false;
39
+ this.#live = true;
38
40
  this.#timer = 0;
39
41
  this.#packets = 0;
40
42
  this.#retries = 0;
43
+ this.#metrics = new Map();
44
+ this.core?.registerAdapter('python', {
45
+ label: 'PYTHON',
46
+ enabled: true,
47
+ enable: () => this.#enable(),
48
+ disable: () => this.#disable(),
49
+ snapshot: () => this.snapshot()
50
+ });
41
51
  }
42
52
 
43
53
  #socket;
44
54
  #wanted;
55
+ #live;
45
56
  #timer;
46
57
  #packets;
47
58
  #retries;
59
+ #metrics;
48
60
 
49
61
  get connected() {
50
62
  return this.status === 'open';
@@ -53,6 +65,7 @@ export class PythonBridge {
53
65
  connect(url) {
54
66
  if (url) this.url = url;
55
67
  this.#wanted = true;
68
+ this.#live = true;
56
69
  this.#retries = 0;
57
70
  this.#open();
58
71
  return this;
@@ -77,6 +90,25 @@ export class PythonBridge {
77
90
  return this;
78
91
  }
79
92
 
93
+ /**
94
+ * Latest metric value for each name received through ingest().
95
+ * @returns {{ name: string, items: Array<{ severity: string, selector: string, rule: string, value: string, source: string }> }}
96
+ */
97
+ snapshot() {
98
+ const items = [];
99
+ for (const [name, metric] of this.#metrics) {
100
+ const unit = metric.unit ? ` ${metric.unit}` : '';
101
+ items.push({
102
+ severity: 'ok',
103
+ selector: name,
104
+ rule: 'metric',
105
+ value: `${metricText(metric.value)}${unit}`,
106
+ source: this.source
107
+ });
108
+ }
109
+ return { name: 'python', items };
110
+ }
111
+
80
112
  /**
81
113
  * Parse one wire message (object, JSON string, or array) into sendData().
82
114
  * @param {unknown} raw
@@ -117,6 +149,7 @@ export class PythonBridge {
117
149
  }
118
150
 
119
151
  const payload = unpackPayload(data, this.source);
152
+ if (type === 'metric') this.#rememberMetric(payload);
120
153
  this.#packets += 1;
121
154
  this.core.sendData(type, payload);
122
155
  this.core.sendData('state', {
@@ -128,7 +161,7 @@ export class PythonBridge {
128
161
  }
129
162
 
130
163
  #open() {
131
- if (!this.#wanted) return;
164
+ if (!this.#wanted || !this.#live) return;
132
165
  if (typeof WebSocket === 'undefined') {
133
166
  this.core?.sendData('error', {
134
167
  source: this.source,
@@ -205,7 +238,7 @@ export class PythonBridge {
205
238
  }
206
239
 
207
240
  #scheduleReconnect() {
208
- if (!this.#wanted || !this.autoReconnect) return;
241
+ if (!this.#wanted || !this.#live || !this.autoReconnect) return;
209
242
  this.#clearTimer();
210
243
  this.#timer = setTimeout(() => this.#open(), this.reconnectMs);
211
244
  }
@@ -215,6 +248,39 @@ export class PythonBridge {
215
248
  this.#timer = 0;
216
249
  }
217
250
 
251
+ #enable() {
252
+ this.#live = true;
253
+ if (this.#wanted) this.#open();
254
+ }
255
+
256
+ #disable() {
257
+ this.#live = false;
258
+ this.#clearTimer();
259
+ const socket = this.#socket;
260
+ this.#socket = null;
261
+ if (socket) {
262
+ try {
263
+ socket.close();
264
+ } catch {
265
+ /* already closing */
266
+ }
267
+ }
268
+ if (this.status !== 'idle') this.#setStatus('closed');
269
+ }
270
+
271
+ #rememberMetric(payload) {
272
+ const name = typeof payload.name === 'string' && payload.name ? payload.name : 'metric';
273
+ if (this.#metrics.has(name)) this.#metrics.delete(name);
274
+ this.#metrics.set(name, {
275
+ value: payload.value,
276
+ unit: typeof payload.unit === 'string' ? payload.unit : ''
277
+ });
278
+ while (this.#metrics.size > 40) {
279
+ const oldest = this.#metrics.keys().next().value;
280
+ this.#metrics.delete(oldest);
281
+ }
282
+ }
283
+
218
284
  #setStatus(status) {
219
285
  this.status = status;
220
286
  this.core?.sendData('state', {
@@ -225,6 +291,12 @@ export class PythonBridge {
225
291
  }
226
292
  }
227
293
 
294
+ function metricText(value) {
295
+ if (value == null) return '';
296
+ if (typeof value === 'object') return JSON.stringify(value);
297
+ return String(value);
298
+ }
299
+
228
300
  function unpackPayload(data, source) {
229
301
  const body = data.payload != null && typeof data.payload === 'object' && !Array.isArray(data.payload)
230
302
  ? { ...data.payload }
@@ -8,13 +8,15 @@
8
8
 
9
9
  import { UIOverlay } from './UIOverlay.js';
10
10
  import { buildAssistantSnapshot, normalizeAssistant } from './AiAssistant.js';
11
+ import { exportReport as writeReport } from './ReportExporter.js';
11
12
 
12
- export const BEE_LADYBUG_VERSION = '0.5.4';
13
+ export const BEE_LADYBUG_VERSION = '0.6.1';
13
14
 
14
15
  export const CORE_DEFAULTS = Object.freeze({
15
16
  toggleKey: 'F2',
16
17
  slowKey: 'F3',
17
18
  freezeKey: 'F4',
19
+ exportKey: 'F8',
18
20
  slowScale: 0.25,
19
21
  maxLogs: 250,
20
22
  historySize: 120,
@@ -61,6 +63,7 @@ export class BeeLadybugCore {
61
63
  this.toggleKey = cfg.toggleKey;
62
64
  this.slowKey = cfg.slowKey;
63
65
  this.freezeKey = cfg.freezeKey;
66
+ this.exportKey = cfg.exportKey;
64
67
  this.slowScale = cfg.slowScale;
65
68
  this.maxLogs = cfg.maxLogs;
66
69
  this.historySize = cfg.historySize;
@@ -138,7 +141,7 @@ export class BeeLadybugCore {
138
141
  #detail;
139
142
  /** @type {Array<{ text: string, level?: string, onSelect?: () => void }>} */
140
143
  #issues;
141
- /** @type {Map<string, { name: string, label: string, enabled: boolean, enable: Function, disable: Function }>} */
144
+ /** @type {Map<string, { name: string, label: string, hotkey: string, enabled: boolean, enable: Function, disable: Function, snapshot: Function | null }>} */
142
145
  #adapters;
143
146
 
144
147
  /**
@@ -295,7 +298,7 @@ export class BeeLadybugCore {
295
298
  * Register a toggleable diagnostic adapter. The core stores hooks only —
296
299
  * it does not import or special-case adapter modules.
297
300
  * @param {string} name
298
- * @param {{ enable: () => void, disable: () => void, label?: string, hotkey?: string, enabled?: boolean }} hooks
301
+ * @param {{ enable: () => void, disable: () => void, label?: string, hotkey?: string, enabled?: boolean, snapshot?: () => { name?: string, items?: object[] } | null }} hooks
299
302
  */
300
303
  registerAdapter(name, hooks = {}) {
301
304
  const key = String(name || '').trim();
@@ -315,7 +318,8 @@ export class BeeLadybugCore {
315
318
  hotkey,
316
319
  enabled,
317
320
  enable: hooks.enable,
318
- disable: hooks.disable
321
+ disable: hooks.disable,
322
+ snapshot: typeof hooks.snapshot === 'function' ? hooks.snapshot : null
319
323
  });
320
324
  this.sendData('sys', {
321
325
  op: 'adapter',
@@ -349,6 +353,30 @@ export class BeeLadybugCore {
349
353
  }));
350
354
  }
351
355
 
356
+ /**
357
+ * Ask every registered adapter for a report snapshot.
358
+ * Modules without a snapshot hook are skipped. The core does not read the rows.
359
+ * A thrown snapshot becomes `{ name, error }` so one module cannot abort the report.
360
+ * @returns {Array<{ name: string, result?: unknown, error?: string }>}
361
+ */
362
+ collectSnapshots() {
363
+ const out = [];
364
+ for (const entry of this.#adapters.values()) {
365
+ if (typeof entry.snapshot !== 'function') continue;
366
+ try {
367
+ const result = entry.snapshot();
368
+ if (result == null) continue;
369
+ out.push({ name: entry.name, result });
370
+ } catch (err) {
371
+ out.push({
372
+ name: entry.name,
373
+ error: err instanceof Error ? err.message : String(err)
374
+ });
375
+ }
376
+ }
377
+ return out;
378
+ }
379
+
352
380
  /**
353
381
  * Clickable rows inside the overlay. The core does not interpret them.
354
382
  * @param {Array<{ text: string, level?: string, onSelect?: () => void }>} items
@@ -535,6 +563,16 @@ export class BeeLadybugCore {
535
563
  return this;
536
564
  }
537
565
 
566
+ /**
567
+ * Build a telemetry report. HTML and CSV return a string; JSON returns
568
+ * the plain object. A mounted overlay downloads the file.
569
+ * @param {'html' | 'json' | 'csv'} [format]
570
+ * @returns {object | string}
571
+ */
572
+ exportReport(format = 'html') {
573
+ return writeReport(this, { format });
574
+ }
575
+
538
576
  applySlowMo() {
539
577
  if (this.frozen) this.unfreeze();
540
578
  const next = this.timeScale === this.slowScale ? 1 : this.slowScale;
@@ -749,6 +787,12 @@ export class BeeLadybugCore {
749
787
  return;
750
788
  }
751
789
 
790
+ if (code === this.exportKey) {
791
+ event.preventDefault();
792
+ this.exportReport('html');
793
+ return;
794
+ }
795
+
752
796
  if (!this.visible) return;
753
797
 
754
798
  if (code === this.slowKey) {