bee-ladybug 0.1.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,506 @@
1
+ /**
2
+ * WebDOMAdapter — HTML / layout translator.
3
+ *
4
+ * Reads box models, the node under the pointer, and mutation noise,
5
+ * then pushes packets into the core. Highlights are drawn on a stacked
6
+ * overlay canvas so the page stays untouched.
7
+ *
8
+ * The core must never import this file.
9
+ *
10
+ * @example
11
+ * const adapter = new WebDOMAdapter(core, { root: document.body, watch: ['.card'] });
12
+ * adapter.attach();
13
+ */
14
+
15
+ export const WEB_DOM_ADAPTER_DEFAULTS = Object.freeze({
16
+ overlay: true,
17
+ autoTick: true,
18
+ watch: [],
19
+ source: 'dom',
20
+ colorHover: '#6ee7ff',
21
+ colorWatch: '#3dff6a',
22
+ colorMargin: 'rgba(255, 211, 106, 0.18)',
23
+ colorPadding: 'rgba(61, 255, 106, 0.16)',
24
+ colorContent: 'rgba(110, 231, 255, 0.20)'
25
+ });
26
+
27
+ export class WebDOMAdapter {
28
+ /**
29
+ * @param {import('../core/BeeLadybugCore.js').BeeLadybugCore} core
30
+ * @param {Partial<typeof WEB_DOM_ADAPTER_DEFAULTS> & {
31
+ * root?: ParentNode | null
32
+ * }} [options]
33
+ */
34
+ constructor(core, options = {}) {
35
+ const cfg = { ...WEB_DOM_ADAPTER_DEFAULTS, ...options };
36
+ this.core = core;
37
+ this.root = options.root !== undefined
38
+ ? options.root
39
+ : (typeof document !== 'undefined' ? document.body : null);
40
+ this.overlayEnabled = cfg.overlay;
41
+ this.autoTick = cfg.autoTick;
42
+ this.watch = Array.isArray(cfg.watch) ? cfg.watch.slice() : [];
43
+ this.source = cfg.source;
44
+ this.colorHover = cfg.colorHover;
45
+ this.colorWatch = cfg.colorWatch;
46
+ this.colorMargin = cfg.colorMargin;
47
+ this.colorPadding = cfg.colorPadding;
48
+ this.colorContent = cfg.colorContent;
49
+
50
+ this.frozen = false;
51
+ this.timeScale = 1;
52
+ this.hover = null;
53
+
54
+ this.#attached = false;
55
+ this.#overlay = null;
56
+ this.#unsubscribe = null;
57
+ this.#observer = null;
58
+ this.#rafId = 0;
59
+ this.#pointer = { x: 0, y: 0, dirty: false };
60
+ this.#lastPump = 0;
61
+ this.#fps = 0;
62
+ this.#mutAdded = 0;
63
+ this.#mutRemoved = 0;
64
+ this.#mutAttr = 0;
65
+ this.#mutTimer = 0;
66
+ this.#lastHoverKey = '';
67
+ this.#onMove = (event) => {
68
+ this.#pointer.x = event.clientX;
69
+ this.#pointer.y = event.clientY;
70
+ this.#pointer.dirty = true;
71
+ };
72
+ this.#onResize = () => this.#syncOverlay();
73
+ this.#onTick = () => {
74
+ this.#rafId = requestAnimationFrame(this.#onTick);
75
+ this.pump();
76
+ };
77
+ this.#onMutate = (records) => this.#queueMutations(records);
78
+ }
79
+
80
+ #attached;
81
+ #overlay;
82
+ #unsubscribe;
83
+ #observer;
84
+ #rafId;
85
+ #pointer;
86
+ #lastPump;
87
+ #fps;
88
+ #mutAdded;
89
+ #mutRemoved;
90
+ #mutAttr;
91
+ #mutTimer;
92
+ #lastHoverKey;
93
+ #onMove;
94
+ #onResize;
95
+ #onTick;
96
+ #onMutate;
97
+
98
+ attach() {
99
+ if (this.#attached) return this;
100
+ this.#attached = true;
101
+ this.frozen = Boolean(this.core?.frozen);
102
+ this.timeScale = this.core?.timeScale ?? 1;
103
+
104
+ if (this.core && typeof this.core.subscribe === 'function') {
105
+ this.#unsubscribe = this.core.subscribe((packet) => this.#onCore(packet));
106
+ }
107
+ if (this.overlayEnabled) this.#mountOverlay();
108
+ if (typeof window !== 'undefined') {
109
+ window.addEventListener('mousemove', this.#onMove, true);
110
+ window.addEventListener('resize', this.#onResize);
111
+ window.addEventListener('scroll', this.#onResize, true);
112
+ }
113
+ if (this.root && typeof MutationObserver !== 'undefined') {
114
+ this.#observer = new MutationObserver(this.#onMutate);
115
+ this.#observer.observe(this.root, {
116
+ childList: true,
117
+ subtree: true,
118
+ attributes: true,
119
+ attributeFilter: ['class', 'style', 'hidden', 'disabled', 'open']
120
+ });
121
+ }
122
+ if (this.autoTick && typeof requestAnimationFrame === 'function') {
123
+ this.#rafId = requestAnimationFrame(this.#onTick);
124
+ }
125
+
126
+ this.core?.sendData('log', {
127
+ source: this.source,
128
+ message: 'WebDOMAdapter attached. Hover a node to inspect the box model.'
129
+ });
130
+ this.core?.sendData('state', {
131
+ source: this.source,
132
+ key: 'adapter',
133
+ value: 'dom'
134
+ });
135
+ return this;
136
+ }
137
+
138
+ detach() {
139
+ if (!this.#attached) return this;
140
+ this.#attached = false;
141
+ if (this.#unsubscribe) this.#unsubscribe();
142
+ this.#unsubscribe = null;
143
+ if (this.#observer) this.#observer.disconnect();
144
+ this.#observer = null;
145
+ if (this.#rafId && typeof cancelAnimationFrame === 'function') {
146
+ cancelAnimationFrame(this.#rafId);
147
+ }
148
+ this.#rafId = 0;
149
+ if (this.#mutTimer && typeof clearTimeout === 'function') clearTimeout(this.#mutTimer);
150
+ this.#mutTimer = 0;
151
+ if (typeof window !== 'undefined') {
152
+ window.removeEventListener('mousemove', this.#onMove, true);
153
+ window.removeEventListener('resize', this.#onResize);
154
+ window.removeEventListener('scroll', this.#onResize, true);
155
+ }
156
+ this.#unmountOverlay();
157
+ return this;
158
+ }
159
+
160
+ /** @param {ParentNode | null} root */
161
+ setRoot(root) {
162
+ this.root = root;
163
+ if (this.#attached && this.#observer && root) {
164
+ this.#observer.disconnect();
165
+ this.#observer.observe(root, {
166
+ childList: true,
167
+ subtree: true,
168
+ attributes: true,
169
+ attributeFilter: ['class', 'style', 'hidden', 'disabled', 'open']
170
+ });
171
+ }
172
+ return this;
173
+ }
174
+
175
+ pump() {
176
+ if (!this.#attached || !this.core) return this;
177
+
178
+ const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
179
+ if (this.#lastPump) {
180
+ const dt = Math.min(Math.max(now - this.#lastPump, 0), 100);
181
+ const instant = dt > 0 ? 1000 / dt : 0;
182
+ this.#fps = this.#fps ? this.#fps * 0.85 + instant * 0.15 : instant;
183
+ }
184
+ this.#lastPump = now;
185
+
186
+ if (!this.frozen && this.#pointer.dirty) {
187
+ this.#pointer.dirty = false;
188
+ this.hover = this.#pick(this.#pointer.x, this.#pointer.y);
189
+ }
190
+
191
+ this.#emitTelemetry();
192
+ this.#draw();
193
+ return this;
194
+ }
195
+
196
+ #onCore(packet) {
197
+ if (packet.type !== 'sys') return;
198
+ const op = packet.payload.op;
199
+ if (op === 'freeze') this.frozen = Boolean(packet.payload.frozen);
200
+ if (op === 'timescale') {
201
+ const scale = Number(packet.payload.scale);
202
+ if (Number.isFinite(scale)) this.timeScale = scale;
203
+ }
204
+ if (op === 'visibility') this.#syncOverlay();
205
+ }
206
+
207
+ #pick(x, y) {
208
+ if (typeof document === 'undefined' || typeof document.elementsFromPoint !== 'function') {
209
+ return null;
210
+ }
211
+ const stack = document.elementsFromPoint(x, y);
212
+ for (let i = 0; i < stack.length; i++) {
213
+ const el = stack[i];
214
+ if (!el || el.nodeType !== 1) continue;
215
+ if (isBeeChrome(el)) continue;
216
+ if (this.root && this.root !== document && !this.root.contains(el) && el !== this.root) continue;
217
+ return el;
218
+ }
219
+ return null;
220
+ }
221
+
222
+ #watchedNodes() {
223
+ if (!this.root || !this.watch.length || typeof this.root.querySelectorAll !== 'function') return [];
224
+ const out = [];
225
+ const seen = new Set();
226
+ for (let i = 0; i < this.watch.length; i++) {
227
+ const sel = this.watch[i];
228
+ let list;
229
+ try {
230
+ list = this.root.querySelectorAll(sel);
231
+ } catch {
232
+ continue;
233
+ }
234
+ for (let j = 0; j < list.length; j++) {
235
+ if (seen.has(list[j])) continue;
236
+ seen.add(list[j]);
237
+ out.push(list[j]);
238
+ }
239
+ }
240
+ return out;
241
+ }
242
+
243
+ #emitTelemetry() {
244
+ const hover = this.hover;
245
+ const nodes = countElements(this.root);
246
+ if (this.#fps > 0) {
247
+ this.core.sendData('fps', { value: this.#fps, source: this.source });
248
+ }
249
+ this.core.sendData('metric', { name: 'nodes', value: nodes, source: this.source });
250
+ this.core.sendData('coord', {
251
+ source: this.source,
252
+ label: 'pointer',
253
+ x: this.#pointer.x,
254
+ y: this.#pointer.y
255
+ });
256
+
257
+ if (!hover) {
258
+ if (this.#lastHoverKey) {
259
+ this.#lastHoverKey = '';
260
+ this.core.sendData('state', { source: this.source, key: 'hover', value: '—' });
261
+ this.core.sendData('state', { source: this.source, key: 'box', value: '—' });
262
+ }
263
+ return;
264
+ }
265
+
266
+ const model = boxModel(hover);
267
+ const key = cssPath(hover);
268
+ this.core.sendData('state', { source: this.source, key: 'hover', value: key });
269
+ this.core.sendData('state', {
270
+ source: this.source,
271
+ key: 'box',
272
+ value: `${Math.round(model.border.w)}×${Math.round(model.border.h)}`
273
+ });
274
+ this.core.sendData('state', {
275
+ source: this.source,
276
+ key: 'display',
277
+ value: model.display
278
+ });
279
+
280
+ if (key !== this.#lastHoverKey) {
281
+ this.#lastHoverKey = key;
282
+ this.core.sendData('log', {
283
+ source: this.source,
284
+ message: `hover ${key} ${Math.round(model.border.w)}×${Math.round(model.border.h)} ${model.display}`
285
+ });
286
+ }
287
+ }
288
+
289
+ #queueMutations(records) {
290
+ if (this.frozen) return;
291
+ for (let i = 0; i < records.length; i++) {
292
+ const rec = records[i];
293
+ if (isBeeChrome(rec.target)) continue;
294
+ if (rec.type === 'childList') {
295
+ this.#mutAdded += rec.addedNodes.length;
296
+ this.#mutRemoved += rec.removedNodes.length;
297
+ } else if (rec.type === 'attributes') {
298
+ this.#mutAttr += 1;
299
+ }
300
+ }
301
+ if (this.#mutTimer || typeof setTimeout !== 'function') return;
302
+ this.#mutTimer = setTimeout(() => {
303
+ this.#mutTimer = 0;
304
+ this.#flushMutations();
305
+ }, 180);
306
+ }
307
+
308
+ #flushMutations() {
309
+ const added = this.#mutAdded;
310
+ const removed = this.#mutRemoved;
311
+ const attr = this.#mutAttr;
312
+ this.#mutAdded = 0;
313
+ this.#mutRemoved = 0;
314
+ this.#mutAttr = 0;
315
+ if (!added && !removed && !attr) return;
316
+ const parts = [];
317
+ if (added) parts.push(`+${added}`);
318
+ if (removed) parts.push(`-${removed}`);
319
+ if (attr) parts.push(`attr ${attr}`);
320
+ this.core?.sendData('warn', {
321
+ source: this.source,
322
+ message: `mutate ${parts.join(' ')}`
323
+ });
324
+ this.core?.sendData('metric', {
325
+ name: 'mutations',
326
+ value: added + removed + attr,
327
+ source: this.source
328
+ });
329
+ }
330
+
331
+ #draw() {
332
+ const canvas = this.#overlay;
333
+ if (!canvas) return;
334
+ this.#syncOverlay();
335
+ const ctx = canvas.getContext('2d');
336
+ const w = window.innerWidth;
337
+ const h = window.innerHeight;
338
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
339
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
340
+ if (this.core && this.core.visible === false) return;
341
+
342
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
343
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
344
+
345
+ const watched = this.#watchedNodes();
346
+ for (let i = 0; i < watched.length; i++) {
347
+ if (watched[i] === this.hover) continue;
348
+ strokeBox(ctx, boxModel(watched[i]).border, this.colorWatch);
349
+ }
350
+
351
+ if (this.hover) {
352
+ drawBoxModel(ctx, boxModel(this.hover), {
353
+ margin: this.colorMargin,
354
+ padding: this.colorPadding,
355
+ content: this.colorContent,
356
+ line: this.colorHover
357
+ });
358
+ drawLabel(ctx, this.hover, w, h, this.colorHover);
359
+ }
360
+ }
361
+
362
+ #mountOverlay() {
363
+ if (typeof document === 'undefined' || this.#overlay) return;
364
+ const canvas = document.createElement('canvas');
365
+ canvas.setAttribute('data-bee-ladybug-dom', '');
366
+ canvas.style.pointerEvents = 'none';
367
+ canvas.style.position = 'fixed';
368
+ canvas.style.left = '0';
369
+ canvas.style.top = '0';
370
+ canvas.style.zIndex = '2147483645';
371
+ canvas.style.margin = '0';
372
+ document.body.appendChild(canvas);
373
+ this.#overlay = canvas;
374
+ this.#syncOverlay();
375
+ }
376
+
377
+ #unmountOverlay() {
378
+ if (this.#overlay?.parentNode) this.#overlay.parentNode.removeChild(this.#overlay);
379
+ this.#overlay = null;
380
+ }
381
+
382
+ #syncOverlay() {
383
+ const canvas = this.#overlay;
384
+ if (!canvas || typeof window === 'undefined') return;
385
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
386
+ const w = window.innerWidth;
387
+ const h = window.innerHeight;
388
+ canvas.style.width = `${w}px`;
389
+ canvas.style.height = `${h}px`;
390
+ canvas.width = Math.round(w * dpr);
391
+ canvas.height = Math.round(h * dpr);
392
+ canvas.style.display = this.core && this.core.visible === false ? 'none' : 'block';
393
+ }
394
+ }
395
+
396
+ function isBeeChrome(node) {
397
+ let el = node && node.nodeType === 1 ? node : node?.parentElement;
398
+ while (el) {
399
+ const tag = el.tagName;
400
+ if (tag === 'BEE-LADYBUG-OVERLAY') return true;
401
+ if (el.hasAttribute?.('data-bee-ladybug-dom')) return true;
402
+ if (el.hasAttribute?.('data-bee-ladybug-canvas')) return true;
403
+ el = el.parentElement;
404
+ }
405
+ return false;
406
+ }
407
+
408
+ function cssPath(el) {
409
+ if (!el || !el.tagName) return 'node';
410
+ const tag = el.tagName.toLowerCase();
411
+ if (el.id) return `${tag}#${cssEscape(el.id)}`;
412
+ const classes = el.classList ? [...el.classList].slice(0, 2) : [];
413
+ if (classes.length) return `${tag}.${classes.map(cssEscape).join('.')}`;
414
+ return tag;
415
+ }
416
+
417
+ function cssEscape(value) {
418
+ return String(value).replace(/[^\w-]/g, '\\$&');
419
+ }
420
+
421
+ function parsePx(value) {
422
+ const n = parseFloat(value);
423
+ return Number.isFinite(n) ? n : 0;
424
+ }
425
+
426
+ function boxModel(el) {
427
+ const r = el.getBoundingClientRect();
428
+ const s = typeof getComputedStyle === 'function' ? getComputedStyle(el) : null;
429
+ const mt = s ? parsePx(s.marginTop) : 0;
430
+ const mr = s ? parsePx(s.marginRight) : 0;
431
+ const mb = s ? parsePx(s.marginBottom) : 0;
432
+ const ml = s ? parsePx(s.marginLeft) : 0;
433
+ const bt = s ? parsePx(s.borderTopWidth) : 0;
434
+ const br = s ? parsePx(s.borderRightWidth) : 0;
435
+ const bb = s ? parsePx(s.borderBottomWidth) : 0;
436
+ const bl = s ? parsePx(s.borderLeftWidth) : 0;
437
+ const pt = s ? parsePx(s.paddingTop) : 0;
438
+ const pr = s ? parsePx(s.paddingRight) : 0;
439
+ const pb = s ? parsePx(s.paddingBottom) : 0;
440
+ const pl = s ? parsePx(s.paddingLeft) : 0;
441
+ return {
442
+ display: s ? s.display : 'block',
443
+ margin: { x: r.left - ml, y: r.top - mt, w: r.width + ml + mr, h: r.height + mt + mb },
444
+ border: { x: r.left, y: r.top, w: r.width, h: r.height },
445
+ padding: {
446
+ x: r.left + bl,
447
+ y: r.top + bt,
448
+ w: Math.max(0, r.width - bl - br),
449
+ h: Math.max(0, r.height - bt - bb)
450
+ },
451
+ content: {
452
+ x: r.left + bl + pl,
453
+ y: r.top + bt + pt,
454
+ w: Math.max(0, r.width - bl - br - pl - pr),
455
+ h: Math.max(0, r.height - bt - bb - pt - pb)
456
+ }
457
+ };
458
+ }
459
+
460
+ function fillBox(ctx, box, color) {
461
+ if (!box.w || !box.h) return;
462
+ ctx.fillStyle = color;
463
+ ctx.fillRect(box.x, box.y, box.w, box.h);
464
+ }
465
+
466
+ function strokeBox(ctx, box, color) {
467
+ if (!box.w || !box.h) return;
468
+ ctx.strokeStyle = color;
469
+ ctx.lineWidth = 1.5;
470
+ ctx.strokeRect(box.x + 0.5, box.y + 0.5, box.w, box.h);
471
+ }
472
+
473
+ function drawBoxModel(ctx, model, colors) {
474
+ fillBox(ctx, model.margin, colors.margin);
475
+ fillBox(ctx, model.border, 'rgba(7, 8, 12, 0.04)');
476
+ fillBox(ctx, model.padding, colors.padding);
477
+ fillBox(ctx, model.content, colors.content);
478
+ strokeBox(ctx, model.border, colors.line);
479
+ }
480
+
481
+ function drawLabel(ctx, el, viewW, viewH, color) {
482
+ const model = boxModel(el);
483
+ const text = `${cssPath(el)} ${Math.round(model.border.w)}×${Math.round(model.border.h)}`;
484
+ ctx.font = '11px ui-monospace, Consolas, monospace';
485
+ const width = Math.ceil(ctx.measureText(text).width) + 12;
486
+ const height = 18;
487
+ let x = model.border.x;
488
+ let y = model.border.y - height - 4;
489
+ if (y < 8) y = model.border.y + model.border.h + 4;
490
+ if (x + width > viewW - 8) x = Math.max(8, viewW - width - 8);
491
+ if (x < 8) x = 8;
492
+ if (y + height > viewH - 8) y = Math.max(8, viewH - height - 8);
493
+ ctx.fillStyle = 'rgba(7, 8, 12, 0.92)';
494
+ ctx.fillRect(x, y, width, height);
495
+ ctx.strokeStyle = color;
496
+ ctx.strokeRect(x + 0.5, y + 0.5, width, height);
497
+ ctx.fillStyle = color;
498
+ ctx.textBaseline = 'middle';
499
+ ctx.fillText(text, x + 6, y + height / 2);
500
+ }
501
+
502
+ function countElements(root) {
503
+ if (!root) return 0;
504
+ if (root.nodeType === 1) return 1 + (root.getElementsByTagName?.('*').length ?? 0);
505
+ return root.getElementsByTagName?.('*').length ?? 0;
506
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * PythonBridge — future inbound channel for Python / AI processes.
3
+ *
4
+ * Planned transport: WebSocket (or HTTP ingest) that unpacks JSON packets
5
+ * and calls core.sendData(type, payload). The core stays browser-side;
6
+ * Python never talks to the overlay directly.
7
+ *
8
+ * Expected wire shape:
9
+ * { "type": "log"|"error"|"metric"|"fps"|"state", "payload": { ... } }
10
+ */
11
+ export class PythonBridge {
12
+ /**
13
+ * @param {import('../../core/BeeLadybugCore.js').BeeLadybugCore} core
14
+ * @param {{ url?: string }} [options]
15
+ */
16
+ constructor(core, options = {}) {
17
+ this.core = core;
18
+ this.url = options.url ?? 'ws://127.0.0.1:8765';
19
+ this.#socket = null;
20
+ }
21
+
22
+ #socket;
23
+
24
+ connect() {
25
+ this.core?.sendData('log', {
26
+ source: 'python',
27
+ level: 'warn',
28
+ message: `PythonBridge reserved (${this.url}). WebSocket ingest comes after adapters.`
29
+ });
30
+ return this;
31
+ }
32
+
33
+ disconnect() {
34
+ if (this.#socket) {
35
+ this.#socket.close();
36
+ this.#socket = null;
37
+ }
38
+ return this;
39
+ }
40
+ }