bee-ladybug 0.4.1 → 0.5.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.
package/README.MD CHANGED
@@ -39,6 +39,7 @@ bee-ladybug/
39
39
  │ └── adapters/
40
40
  │ ├── CanvasAdapter.js
41
41
  │ ├── WebDOMAdapter.js
42
+ │ ├── CSSAdapter.js
42
43
  │ ├── AntAdapter.js
43
44
  │ ├── SpiderAdapter.js
44
45
  │ └── python/PythonBridge.js
@@ -46,10 +47,12 @@ bee-ladybug/
46
47
  │ ├── core.html
47
48
  │ ├── canvas-sandbox.js
48
49
  │ ├── dom.html
50
+ │ ├── css-hunt.html
49
51
  │ ├── python.html
50
52
  │ └── python/sender.py
51
53
  ├── tsconfig.json
52
54
  ├── package.json
55
+ ├── CHANGELOG.md
53
56
  ├── LICENSE
54
57
  ├── index.html
55
58
  └── README.md
@@ -130,6 +133,8 @@ bee.subscribe((packet) => {
130
133
  | `F2` | mostra / nascondi overlay (e hitbox canvas) |
131
134
  | `F3` | slow-motion del tempo fittizio (`0.25x` / `1x`) |
132
135
  | `F4` | freeze / run del tempo fittizio |
136
+ | `F6` | CSS hunt, se `CSSAdapter` è attaccato |
137
+ | `S` | scansione overflow / immagini, solo con la hunt accesa |
133
138
 
134
139
  Niente `F12`, niente tilde: sui layout italiani la tilde non è un tasto unico.
135
140
 
@@ -177,6 +182,28 @@ adapter.attach();
177
182
 
178
183
  Traduce il DOM in pacchetti: nodo sotto il puntatore, box model (margin / padding / content), conteggio nodi, mutazioni (`+n -n attr`). `F4` congela l'hover. `F2` nasconde overlay e highlight. Il Core non sa che esiste HTML.
179
184
 
185
+ ## CSS hunt (F6)
186
+
187
+ ```js
188
+ import { BeeLadybugCore, CSSAdapter } from './src/index.js';
189
+
190
+ const bee = new BeeLadybugCore();
191
+ const css = new CSSAdapter(bee);
192
+ css.attach();
193
+ ```
194
+
195
+ `F6` (pulsante **F6 CSS** accanto a F3/F4) accende la caccia. Sull'elemento sotto il puntatore l'overlay elenca:
196
+
197
+ - regole `!important` che gli corrispondono (fogli cross-origin: `unreadable sheet: <href>`)
198
+ - overflow del viewport e margini negativi
199
+ - testo trasparente, `opacity: 0`, o colore uguale allo sfondo
200
+ - chi intercetta il click al centro, inclusi `::before` / `::after` fissi su `html` / `body`
201
+ - `<img>` con rapporto stirato oltre il 5% e `object-fit: fill`
202
+
203
+ `S` con la hunt accesa esegue overflow e immagini su tutta la pagina e lista i casi peggiori. Un click sulla riga disegna il contorno rosso. I fogli di stile restano in cache finché un `MutationObserver` non vede un cambio a `<style>` o `<link>`. Hover è limitato a un frame.
204
+
205
+ Demo: `examples/css-hunt.html`.
206
+
180
207
  ## Registro adapter (overlay)
181
208
 
182
209
  Il Core non conosce ANT/SPIDER. Gli adapter si iscrivono con hook propri; l'overlay disegna un toggle per ciascuno.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bee-ladybug",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Universal telemetry and visual inspection overlay for Canvas, DOM, Python and AI flows.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -0,0 +1,647 @@
1
+ /**
2
+ * CSSAdapter — CSS hunt.
3
+ *
4
+ * Hover an element to list !important hits, viewport overflow, negative
5
+ * margins, invisible text, click blockers and stretched images. The core
6
+ * only receives packets and detail rows; it does not parse CSS.
7
+ *
8
+ * @example
9
+ * const css = new CSSAdapter(core);
10
+ * css.attach();
11
+ * // F6 toggles hunt. S scans the page while hunt is on.
12
+ */
13
+
14
+ export const CSS_ADAPTER_DEFAULTS = Object.freeze({
15
+ source: 'css',
16
+ hotkey: 'F6',
17
+ scanKey: 'KeyS',
18
+ label: 'F6 CSS',
19
+ maxScan: 8
20
+ });
21
+
22
+ export class CSSAdapter {
23
+ /**
24
+ * @param {import('../core/BeeLadybugCore.js').BeeLadybugCore} core
25
+ * @param {Partial<typeof CSS_ADAPTER_DEFAULTS>} [options]
26
+ */
27
+ constructor(core, options = {}) {
28
+ const cfg = { ...CSS_ADAPTER_DEFAULTS, ...options };
29
+ this.core = core;
30
+ this.source = cfg.source;
31
+ this.hotkey = cfg.hotkey;
32
+ this.scanKey = cfg.scanKey;
33
+ this.label = cfg.label;
34
+ this.maxScan = cfg.maxScan;
35
+
36
+ this.#attached = false;
37
+ this.#active = false;
38
+ this.#rules = [];
39
+ this.#unreadable = [];
40
+ this.#sheetsDirty = true;
41
+ this.#x = 0;
42
+ this.#y = 0;
43
+ this.#rafId = 0;
44
+ this.#lastEl = null;
45
+ this.#pinned = null;
46
+ this.#hoverBox = null;
47
+ this.#pinBox = null;
48
+ this.#observer = null;
49
+ this.#onMove = (event) => {
50
+ this.#x = event.clientX;
51
+ this.#y = event.clientY;
52
+ this.#schedule();
53
+ };
54
+ this.#onKey = (event) => this.#handleKey(event);
55
+ this.#onScroll = () => this.#placeBoxes();
56
+ this.#onMutate = (records) => {
57
+ if (!records.some(touchesSheet)) return;
58
+ this.#sheetsDirty = true;
59
+ this.#schedule();
60
+ };
61
+ }
62
+
63
+ #attached;
64
+ #active;
65
+ #rules;
66
+ #unreadable;
67
+ #sheetsDirty;
68
+ #x;
69
+ #y;
70
+ #rafId;
71
+ #lastEl;
72
+ #pinned;
73
+ #hoverBox;
74
+ #pinBox;
75
+ #observer;
76
+ #onMove;
77
+ #onKey;
78
+ #onScroll;
79
+ #onMutate;
80
+
81
+ get active() {
82
+ return this.#active;
83
+ }
84
+
85
+ attach() {
86
+ if (this.#attached) return this;
87
+ this.#attached = true;
88
+ this.core?.registerAdapter('css', {
89
+ label: this.label,
90
+ hotkey: this.hotkey,
91
+ enabled: false,
92
+ enable: () => this.#enable(),
93
+ disable: () => this.#disable()
94
+ });
95
+ this.core?.sendData('log', {
96
+ source: this.source,
97
+ message: 'CSSAdapter attached. F6 CSS hunt. S scans the page while hunt is on.'
98
+ });
99
+ this.core?.sendData('state', {
100
+ source: this.source,
101
+ key: 'css.hunt',
102
+ value: 'off'
103
+ });
104
+ return this;
105
+ }
106
+
107
+ detach() {
108
+ if (!this.#attached) return this;
109
+ this.#disable();
110
+ this.#attached = false;
111
+ this.core?.unregisterAdapter('css');
112
+ return this;
113
+ }
114
+
115
+ /** Checks 2 and 5 across the document. Click a row to outline it. */
116
+ scan() {
117
+ if (!this.#active || typeof document === 'undefined') return this;
118
+ if (this.#sheetsDirty) this.#rebuildSheets();
119
+ const nodes = document.body ? document.body.querySelectorAll('*') : [];
120
+ const offenders = [];
121
+ for (let i = 0; i < nodes.length; i++) {
122
+ const el = nodes[i];
123
+ if (!el || isBeeChrome(el) || isSkippedTag(el)) continue;
124
+ const findings = [];
125
+ pushOverflow(el, findings);
126
+ pushImage(el, findings);
127
+ if (!findings.length) continue;
128
+ let score = 0;
129
+ for (let j = 0; j < findings.length; j++) score += findings[j].score;
130
+ offenders.push({ el, findings, score });
131
+ }
132
+ offenders.sort((a, b) => b.score - a.score);
133
+ const top = offenders.slice(0, this.maxScan);
134
+ const items = top.map((entry) => ({
135
+ level: 'error',
136
+ text: `${cssPath(entry.el)} — ${entry.findings.map((hit) => hit.text).join('; ')}`,
137
+ onSelect: () => this.#pin(entry.el)
138
+ }));
139
+ this.#lastEl = null;
140
+ this.core?.publishDetail(items);
141
+ this.core?.sendData('state', {
142
+ source: this.source,
143
+ key: 'css.scan',
144
+ value: String(top.length)
145
+ });
146
+ this.core?.sendData('warn', {
147
+ source: this.source,
148
+ message: top.length
149
+ ? `scan ${top.length} offender(s). Click a row to outline it.`
150
+ : 'scan clean: no overflow or distorted images.'
151
+ });
152
+ return this;
153
+ }
154
+
155
+ #enable() {
156
+ if (this.#active || typeof document === 'undefined' || typeof window === 'undefined') return;
157
+ this.#active = true;
158
+ this.#sheetsDirty = true;
159
+ this.#mountBoxes();
160
+ window.addEventListener('mousemove', this.#onMove, true);
161
+ window.addEventListener('keydown', this.#onKey);
162
+ window.addEventListener('scroll', this.#onScroll, true);
163
+ window.addEventListener('resize', this.#onScroll);
164
+ if (typeof MutationObserver !== 'undefined' && document.documentElement) {
165
+ this.#observer = new MutationObserver(this.#onMutate);
166
+ this.#observer.observe(document.documentElement, {
167
+ childList: true,
168
+ subtree: true,
169
+ attributes: true,
170
+ attributeFilter: ['href', 'rel', 'media', 'disabled', 'type'],
171
+ characterData: true
172
+ });
173
+ }
174
+ this.#rebuildSheets();
175
+ this.core?.sendData('state', { source: this.source, key: 'css.hunt', value: 'on' });
176
+ this.core?.sendData('log', {
177
+ source: this.source,
178
+ message: 'CSS hunt on. Hover an element, or press S to scan.'
179
+ });
180
+ }
181
+
182
+ #disable() {
183
+ if (!this.#active) return;
184
+ this.#active = false;
185
+ if (typeof window !== 'undefined') {
186
+ window.removeEventListener('mousemove', this.#onMove, true);
187
+ window.removeEventListener('keydown', this.#onKey);
188
+ window.removeEventListener('scroll', this.#onScroll, true);
189
+ window.removeEventListener('resize', this.#onScroll);
190
+ }
191
+ if (this.#rafId && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(this.#rafId);
192
+ this.#rafId = 0;
193
+ if (this.#observer) this.#observer.disconnect();
194
+ this.#observer = null;
195
+ this.#lastEl = null;
196
+ this.#pinned = null;
197
+ this.#unmountBoxes();
198
+ this.core?.publishDetail([]);
199
+ this.core?.sendData('state', { source: this.source, key: 'css.hunt', value: 'off' });
200
+ this.core?.sendData('log', { source: this.source, message: 'CSS hunt off.' });
201
+ }
202
+
203
+ #handleKey(event) {
204
+ if (!this.#active || event.repeat) return;
205
+ if (event.code !== this.scanKey) return;
206
+ if (isTypingTarget(event.target)) return;
207
+ event.preventDefault();
208
+ this.scan();
209
+ }
210
+
211
+ #schedule() {
212
+ if (!this.#active || this.#rafId || typeof requestAnimationFrame !== 'function') return;
213
+ this.#rafId = requestAnimationFrame(() => {
214
+ this.#rafId = 0;
215
+ if (!this.#active) return;
216
+ if (this.#sheetsDirty) this.#rebuildSheets();
217
+ this.#inspect(this.#x, this.#y);
218
+ });
219
+ }
220
+
221
+ #rebuildSheets() {
222
+ this.#sheetsDirty = false;
223
+ const built = collectImportantRules();
224
+ this.#rules = built.rules;
225
+ this.#unreadable = built.unreadable;
226
+ for (let i = 0; i < built.unreadable.length; i++) {
227
+ this.core?.sendData('warn', {
228
+ source: this.source,
229
+ message: `unreadable sheet: ${built.unreadable[i]}`
230
+ });
231
+ }
232
+ }
233
+
234
+ #inspect(x, y) {
235
+ const el = pickElement(x, y);
236
+ placeBox(this.#hoverBox, el);
237
+ if (el === this.#lastEl) return;
238
+ this.#lastEl = el;
239
+ if (!el) {
240
+ this.core?.publishDetail([]);
241
+ this.core?.sendData('state', { source: this.source, key: 'css.target', value: '—' });
242
+ return;
243
+ }
244
+ const findings = [];
245
+ pushImportant(el, this.#rules, findings);
246
+ pushOverflow(el, findings);
247
+ pushInvisible(el, findings);
248
+ pushBlockers(el, findings);
249
+ pushImage(el, findings);
250
+ for (let i = 0; i < this.#unreadable.length; i++) {
251
+ findings.push({
252
+ level: 'warn',
253
+ text: `unreadable sheet: ${this.#unreadable[i]}`,
254
+ score: 0
255
+ });
256
+ }
257
+ const path = cssPath(el);
258
+ this.core?.sendData('state', { source: this.source, key: 'css.target', value: path });
259
+ this.core?.sendData('state', {
260
+ source: this.source,
261
+ key: 'css.hits',
262
+ value: String(findings.length)
263
+ });
264
+ this.core?.publishDetail(findings.map((hit) => ({
265
+ level: hit.level,
266
+ text: `${path} ${hit.text}`,
267
+ onSelect: () => this.#pin(el)
268
+ })));
269
+ if (!findings.length) {
270
+ this.core?.sendData('log', { source: this.source, message: `${path} clean` });
271
+ return;
272
+ }
273
+ const lead = findings.find((hit) => hit.level === 'error') || findings[0];
274
+ this.core?.sendData(lead.level === 'error' ? 'error' : 'warn', {
275
+ source: this.source,
276
+ message: `${path} ${lead.text}`
277
+ });
278
+ }
279
+
280
+ #pin(el) {
281
+ this.#pinned = el;
282
+ placeBox(this.#pinBox, el);
283
+ if (el && typeof el.scrollIntoView === 'function') {
284
+ el.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'smooth' });
285
+ }
286
+ }
287
+
288
+ #placeBoxes() {
289
+ placeBox(this.#hoverBox, this.#lastEl);
290
+ placeBox(this.#pinBox, this.#pinned);
291
+ }
292
+
293
+ #mountBoxes() {
294
+ if (typeof document === 'undefined' || this.#hoverBox) return;
295
+ this.#hoverBox = makeBox('hover', '1px solid #ffd36a', 'transparent');
296
+ this.#pinBox = makeBox('pin', '2px solid #ff3b4e', 'rgba(255, 59, 78, 0.12)');
297
+ document.body.appendChild(this.#hoverBox);
298
+ document.body.appendChild(this.#pinBox);
299
+ }
300
+
301
+ #unmountBoxes() {
302
+ this.#hoverBox?.remove();
303
+ this.#pinBox?.remove();
304
+ this.#hoverBox = null;
305
+ this.#pinBox = null;
306
+ }
307
+ }
308
+
309
+ function collectImportantRules() {
310
+ const rules = [];
311
+ const unreadable = [];
312
+ if (typeof document === 'undefined') return { rules, unreadable };
313
+ const sheets = document.styleSheets;
314
+ for (let i = 0; i < sheets.length; i++) {
315
+ const sheet = sheets[i];
316
+ let list = null;
317
+ try {
318
+ list = sheet.cssRules;
319
+ } catch {
320
+ unreadable.push(sheet.href || '<style> inline');
321
+ continue;
322
+ }
323
+ walkRules(list, sheetLabel(sheet), rules);
324
+ }
325
+ return { rules, unreadable };
326
+ }
327
+
328
+ function walkRules(list, source, into) {
329
+ if (!list) return;
330
+ for (let i = 0; i < list.length; i++) {
331
+ const rule = list[i];
332
+ if (!rule) continue;
333
+ if (rule.type === 4 && rule.media) {
334
+ let active = true;
335
+ try {
336
+ active = typeof matchMedia === 'function'
337
+ ? matchMedia(rule.media.mediaText).matches
338
+ : true;
339
+ } catch {
340
+ active = true;
341
+ }
342
+ if (active) walkRules(rule.cssRules, source, into);
343
+ continue;
344
+ }
345
+ if (rule.type === 1 && rule.selectorText && rule.style) {
346
+ const props = importantProps(rule.style);
347
+ if (props.length) into.push({ selector: rule.selectorText, source, props });
348
+ } else if (rule.cssRules) {
349
+ walkRules(rule.cssRules, source, into);
350
+ }
351
+ }
352
+ }
353
+
354
+ function importantProps(style) {
355
+ const props = [];
356
+ for (let i = 0; i < style.length; i++) {
357
+ const property = style.item(i);
358
+ if (style.getPropertyPriority(property) !== 'important') continue;
359
+ props.push({ property, value: style.getPropertyValue(property).trim() });
360
+ }
361
+ return props;
362
+ }
363
+
364
+ function sheetLabel(sheet) {
365
+ if (sheet.href) return sheet.href;
366
+ return '<style> inline';
367
+ }
368
+
369
+ function pushImportant(el, rules, findings) {
370
+ for (let i = 0; i < rules.length; i++) {
371
+ const rule = rules[i];
372
+ let matched = false;
373
+ try {
374
+ matched = el.matches(rule.selector);
375
+ } catch {
376
+ continue;
377
+ }
378
+ if (!matched) continue;
379
+ for (let j = 0; j < rule.props.length; j++) {
380
+ const prop = rule.props[j];
381
+ findings.push({
382
+ level: 'warn',
383
+ score: 5,
384
+ text: `!important ${rule.selector} { ${prop.property}: ${prop.value} } @ ${rule.source}`
385
+ });
386
+ }
387
+ }
388
+ }
389
+
390
+ function pushOverflow(el, findings) {
391
+ if (!el.getBoundingClientRect || el === document.documentElement || el === document.body) return;
392
+ const rect = el.getBoundingClientRect();
393
+ const view = document.documentElement.clientWidth;
394
+ if (rect.left < -1 || rect.right > view + 1) {
395
+ const past = rect.left < -1
396
+ ? `left ${Math.round(rect.left)}px`
397
+ : `right +${Math.round(rect.right - view)}px`;
398
+ findings.push({
399
+ level: 'error',
400
+ score: Math.abs(rect.left < -1 ? rect.left : rect.right - view),
401
+ text: `overflows viewport (${past})`
402
+ });
403
+ }
404
+ const style = getComputedStyle(el);
405
+ const sides = ['top', 'right', 'bottom', 'left'];
406
+ for (let i = 0; i < sides.length; i++) {
407
+ const raw = style.getPropertyValue(`margin-${sides[i]}`);
408
+ const value = parseFloat(raw);
409
+ if (!Number.isFinite(value) || value >= 0) continue;
410
+ findings.push({
411
+ level: 'warn',
412
+ score: Math.abs(value),
413
+ text: `negative margin margin-${sides[i]}: ${raw.trim()}`
414
+ });
415
+ }
416
+ }
417
+
418
+ function pushInvisible(el, findings) {
419
+ if (!hasOwnText(el)) return;
420
+ const style = getComputedStyle(el);
421
+ if (opacityZero(el)) {
422
+ findings.push({ level: 'error', score: 30, text: 'invisible text (opacity 0)' });
423
+ }
424
+ const color = parseColor(style.color);
425
+ if (!color || color.a === 0) {
426
+ findings.push({ level: 'error', score: 30, text: 'invisible text (transparent color)' });
427
+ return;
428
+ }
429
+ const behind = paintBehind(el);
430
+ if (behind && nearlySame(color, behind)) {
431
+ findings.push({ level: 'error', score: 30, text: 'invisible text (color matches background)' });
432
+ }
433
+ }
434
+
435
+ function pushBlockers(el, findings) {
436
+ if (!el.getBoundingClientRect || typeof document.elementFromPoint !== 'function') return;
437
+ const rect = el.getBoundingClientRect();
438
+ const x = rect.left + rect.width / 2;
439
+ const y = rect.top + rect.height / 2;
440
+ if (x < 0 || y < 0 || x > window.innerWidth || y > window.innerHeight) return;
441
+ const pseudo = coveringPseudo(x, y);
442
+ if (pseudo) {
443
+ findings.push({ level: 'error', score: 40, text: `click blocked by ${pseudo}` });
444
+ }
445
+ const hit = document.elementFromPoint(x, y);
446
+ if (!hit || isBeeChrome(hit) || hit === el || el.contains(hit)) return;
447
+ if (pseudo && (hit === document.documentElement || hit === document.body)) return;
448
+ findings.push({ level: 'error', score: 40, text: `click blocked by ${cssPath(hit)}` });
449
+ }
450
+
451
+ function pushImage(el, findings) {
452
+ if (!el || el.tagName !== 'IMG') return;
453
+ const naturalW = el.naturalWidth;
454
+ const naturalH = el.naturalHeight;
455
+ const shownW = el.clientWidth;
456
+ const shownH = el.clientHeight;
457
+ if (!naturalW || !naturalH || !shownW || !shownH) return;
458
+ const natural = naturalW / naturalH;
459
+ const shown = shownW / shownH;
460
+ const diff = Math.abs(natural - shown) / natural;
461
+ const fit = getComputedStyle(el).objectFit;
462
+ if (diff <= 0.05 || fit !== 'fill') return;
463
+ findings.push({
464
+ level: 'error',
465
+ score: diff * 100,
466
+ text: `distorted image ${Math.round(diff * 100)}% (object-fit: fill)`
467
+ });
468
+ }
469
+
470
+ function coveringPseudo(x, y) {
471
+ const hosts = [document.documentElement, document.body];
472
+ const names = ['::before', '::after'];
473
+ for (let i = 0; i < hosts.length; i++) {
474
+ const host = hosts[i];
475
+ if (!host) continue;
476
+ for (let j = 0; j < names.length; j++) {
477
+ const box = pseudoBox(host, names[j]);
478
+ if (!box || box.pointerEvents === 'none') continue;
479
+ if (x < box.x || y < box.y || x > box.x + box.w || y > box.y + box.h) continue;
480
+ return `${host.tagName.toLowerCase()}${names[j]}`;
481
+ }
482
+ }
483
+ return '';
484
+ }
485
+
486
+ function pseudoBox(el, pseudo) {
487
+ const style = getComputedStyle(el, pseudo);
488
+ const content = style.content;
489
+ if (!content || content === 'none' || content === 'normal') return null;
490
+ if (style.position !== 'fixed' && style.position !== 'absolute') return null;
491
+ const width = parseFloat(style.width);
492
+ const height = parseFloat(style.height);
493
+ if (!width || !height || width < 2 || height < 2) return null;
494
+ let x = parseFloat(style.left);
495
+ let y = parseFloat(style.top);
496
+ if (!Number.isFinite(x)) x = 0;
497
+ if (!Number.isFinite(y)) y = 0;
498
+ if (style.position === 'absolute') {
499
+ const host = el.getBoundingClientRect();
500
+ x = host.left + x;
501
+ y = host.top + y;
502
+ }
503
+ return { x, y, w: width, h: height, pointerEvents: style.pointerEvents };
504
+ }
505
+
506
+ function pickElement(x, y) {
507
+ if (typeof document === 'undefined' || typeof document.elementsFromPoint !== 'function') return null;
508
+ const stack = document.elementsFromPoint(x, y);
509
+ for (let i = 0; i < stack.length; i++) {
510
+ const el = stack[i];
511
+ if (!el || isBeeChrome(el)) continue;
512
+ if (el === document.documentElement || el === document.body) continue;
513
+ return el;
514
+ }
515
+ return null;
516
+ }
517
+
518
+ function paintBehind(el) {
519
+ const own = parseColor(getComputedStyle(el).backgroundColor);
520
+ if (own && own.a > 0.04) return own;
521
+ let node = el.parentElement;
522
+ while (node) {
523
+ const bg = parseColor(getComputedStyle(node).backgroundColor);
524
+ if (bg && bg.a > 0.04) return bg;
525
+ node = node.parentElement;
526
+ }
527
+ return null;
528
+ }
529
+
530
+ function opacityZero(el) {
531
+ let node = el;
532
+ while (node && node.nodeType === 1) {
533
+ const value = parseFloat(getComputedStyle(node).opacity);
534
+ if (value === 0) return true;
535
+ node = node.parentElement;
536
+ }
537
+ return false;
538
+ }
539
+
540
+ function parseColor(value) {
541
+ const text = String(value || '').trim();
542
+ if (text === 'transparent') return { r: 0, g: 0, b: 0, a: 0 };
543
+ const match = text.match(/rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:[,\s/]+([\d.]+%?))?\s*\)/);
544
+ if (!match) return null;
545
+ let alpha = 1;
546
+ if (match[4] != null) {
547
+ alpha = match[4].endsWith('%') ? parseFloat(match[4]) / 100 : parseFloat(match[4]);
548
+ }
549
+ return { r: Number(match[1]), g: Number(match[2]), b: Number(match[3]), a: alpha };
550
+ }
551
+
552
+ function nearlySame(a, b) {
553
+ return Math.abs(a.r - b.r) < 2
554
+ && Math.abs(a.g - b.g) < 2
555
+ && Math.abs(a.b - b.b) < 2
556
+ && Math.abs(a.a - b.a) < 0.05;
557
+ }
558
+
559
+ function hasOwnText(el) {
560
+ const tag = el.tagName;
561
+ if (tag === 'BUTTON' || tag === 'A' || tag === 'LABEL' || tag === 'INPUT' || tag === 'TEXTAREA') return true;
562
+ if (el.childElementCount > 0) return false;
563
+ return Boolean((el.textContent || '').trim());
564
+ }
565
+
566
+ function cssPath(el) {
567
+ if (!el || !el.tagName) return 'node';
568
+ const tag = el.tagName.toLowerCase();
569
+ if (el.id) return `${tag}#${el.id}`;
570
+ const classes = el.classList ? [...el.classList].slice(0, 2) : [];
571
+ if (classes.length) return `${tag}.${classes.join('.')}`;
572
+ return tag;
573
+ }
574
+
575
+ function isSkippedTag(el) {
576
+ const tag = el.tagName;
577
+ return tag === 'SCRIPT' || tag === 'STYLE' || tag === 'LINK' || tag === 'META';
578
+ }
579
+
580
+ function isTypingTarget(target) {
581
+ if (!target || !target.tagName) return false;
582
+ const tag = target.tagName;
583
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
584
+ return Boolean(target.isContentEditable);
585
+ }
586
+
587
+ function touchesSheet(record) {
588
+ if (!record) return false;
589
+ if (record.type === 'characterData') return record.target?.parentElement?.nodeName === 'STYLE';
590
+ if (record.type === 'attributes') {
591
+ const name = record.target?.nodeName;
592
+ return name === 'STYLE' || name === 'LINK';
593
+ }
594
+ const nodes = [record.target];
595
+ if (record.addedNodes) {
596
+ for (let i = 0; i < record.addedNodes.length; i++) nodes.push(record.addedNodes[i]);
597
+ }
598
+ if (record.removedNodes) {
599
+ for (let i = 0; i < record.removedNodes.length; i++) nodes.push(record.removedNodes[i]);
600
+ }
601
+ for (let i = 0; i < nodes.length; i++) {
602
+ const node = nodes[i];
603
+ if (!node || node.nodeType !== 1) continue;
604
+ if (node.nodeName === 'STYLE' || node.nodeName === 'LINK') return true;
605
+ if (typeof node.querySelector === 'function' && node.querySelector('style, link')) return true;
606
+ }
607
+ return false;
608
+ }
609
+
610
+ function isBeeChrome(node) {
611
+ let el = node && node.nodeType === 1 ? node : node?.parentElement;
612
+ while (el) {
613
+ if (el.tagName === 'BEE-LADYBUG-OVERLAY') return true;
614
+ if (el.hasAttribute?.('data-bee-ladybug-dom')) return true;
615
+ if (el.hasAttribute?.('data-bee-ladybug-canvas')) return true;
616
+ if (el.hasAttribute?.('data-bee-ladybug-css')) return true;
617
+ el = el.parentElement;
618
+ }
619
+ return false;
620
+ }
621
+
622
+ function makeBox(kind, border, fill) {
623
+ const node = document.createElement('div');
624
+ node.setAttribute('data-bee-ladybug-css', kind);
625
+ node.style.position = 'fixed';
626
+ node.style.pointerEvents = 'none';
627
+ node.style.zIndex = '2147483644';
628
+ node.style.display = 'none';
629
+ node.style.boxSizing = 'border-box';
630
+ node.style.border = border;
631
+ node.style.background = fill;
632
+ return node;
633
+ }
634
+
635
+ function placeBox(node, el) {
636
+ if (!node) return;
637
+ if (!el || typeof el.getBoundingClientRect !== 'function' || isBeeChrome(el)) {
638
+ node.style.display = 'none';
639
+ return;
640
+ }
641
+ const rect = el.getBoundingClientRect();
642
+ node.style.display = 'block';
643
+ node.style.left = `${rect.left}px`;
644
+ node.style.top = `${rect.top}px`;
645
+ node.style.width = `${rect.width}px`;
646
+ node.style.height = `${rect.height}px`;
647
+ }
@@ -9,7 +9,7 @@
9
9
  import { UIOverlay } from './UIOverlay.js';
10
10
  import { buildAssistantSnapshot, normalizeAssistant } from './AiAssistant.js';
11
11
 
12
- export const BEE_LADYBUG_VERSION = '0.4.1';
12
+ export const BEE_LADYBUG_VERSION = '0.5.0';
13
13
 
14
14
  export const CORE_DEFAULTS = Object.freeze({
15
15
  toggleKey: 'F2',
@@ -98,6 +98,7 @@ export class BeeLadybugCore {
98
98
  this.#activeAssistant = null;
99
99
  this.#askBusy = false;
100
100
  this.#adapters = new Map();
101
+ this.#detail = [];
101
102
 
102
103
  this.ui = null;
103
104
  if (cfg.ui !== false && typeof document !== 'undefined') {
@@ -132,6 +133,8 @@ export class BeeLadybugCore {
132
133
  /** @type {string | null} */
133
134
  #activeAssistant;
134
135
  #askBusy;
136
+ /** @type {Array<{ text: string, level?: string, onSelect?: () => void }>} */
137
+ #detail;
135
138
  /** @type {Map<string, { name: string, label: string, enabled: boolean, enable: Function, disable: Function }>} */
136
139
  #adapters;
137
140
 
@@ -289,7 +292,7 @@ export class BeeLadybugCore {
289
292
  * Register a toggleable diagnostic adapter. The core stores hooks only —
290
293
  * it does not import or special-case adapter modules.
291
294
  * @param {string} name
292
- * @param {{ enable: () => void, disable: () => void, label?: string }} hooks
295
+ * @param {{ enable: () => void, disable: () => void, label?: string, hotkey?: string, enabled?: boolean }} hooks
293
296
  */
294
297
  registerAdapter(name, hooks = {}) {
295
298
  const key = String(name || '').trim();
@@ -301,14 +304,24 @@ export class BeeLadybugCore {
301
304
  return this;
302
305
  }
303
306
  const label = String(hooks.label || key).trim() || key;
307
+ const hotkey = typeof hooks.hotkey === 'string' ? hooks.hotkey : '';
308
+ const enabled = hooks.enabled !== false;
304
309
  this.#adapters.set(key, {
305
310
  name: key,
306
311
  label,
307
- enabled: true,
312
+ hotkey,
313
+ enabled,
308
314
  enable: hooks.enable,
309
315
  disable: hooks.disable
310
316
  });
311
- this.sendData('sys', { op: 'adapter', name: key, registered: true, label, enabled: true });
317
+ this.sendData('sys', {
318
+ op: 'adapter',
319
+ name: key,
320
+ registered: true,
321
+ label,
322
+ hotkey,
323
+ enabled
324
+ });
312
325
  return this;
313
326
  }
314
327
 
@@ -328,10 +341,21 @@ export class BeeLadybugCore {
328
341
  return [...this.#adapters.values()].map((entry) => ({
329
342
  name: entry.name,
330
343
  label: entry.label,
344
+ hotkey: entry.hotkey || '',
331
345
  enabled: entry.enabled
332
346
  }));
333
347
  }
334
348
 
349
+ /**
350
+ * Clickable rows inside the overlay. The core does not interpret them.
351
+ * @param {Array<{ text: string, level?: string, onSelect?: () => void }>} items
352
+ */
353
+ publishDetail(items) {
354
+ this.#detail = Array.isArray(items) ? items : [];
355
+ this.ui?.setDetail(this.#detail);
356
+ return this;
357
+ }
358
+
335
359
  /**
336
360
  * @param {string} name
337
361
  */
@@ -438,6 +462,7 @@ export class BeeLadybugCore {
438
462
  this.#activeAssistant = null;
439
463
  this.#askBusy = false;
440
464
  this.#adapters.clear();
465
+ this.#detail = [];
441
466
  this.ui?.destroy();
442
467
  this.ui = null;
443
468
  return this;
@@ -720,10 +745,27 @@ export class BeeLadybugCore {
720
745
  if (code === this.freezeKey) {
721
746
  event.preventDefault();
722
747
  this.toggleFreeze();
748
+ return;
749
+ }
750
+
751
+ if (isTypingTarget(event.target)) return;
752
+
753
+ for (const entry of this.#adapters.values()) {
754
+ if (!entry.hotkey || code !== entry.hotkey) continue;
755
+ event.preventDefault();
756
+ this.toggleAdapter(entry.name);
757
+ return;
723
758
  }
724
759
  }
725
760
  }
726
761
 
762
+ function isTypingTarget(target) {
763
+ if (!target || typeof target !== 'object') return false;
764
+ const tag = target.tagName;
765
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
766
+ return Boolean(target.isContentEditable);
767
+ }
768
+
727
769
  function asObject(payload) {
728
770
  if (payload == null) return {};
729
771
  if (typeof payload !== 'object') return { value: payload, message: String(payload) };
@@ -255,6 +255,39 @@ const STYLES = `
255
255
  .bl-adapters:empty {
256
256
  display: none;
257
257
  }
258
+ .bl-hotkeys {
259
+ display: inline-flex;
260
+ align-items: center;
261
+ gap: 6px;
262
+ }
263
+ .bl-hotkeys:empty {
264
+ display: none;
265
+ }
266
+ .bl-detail {
267
+ max-height: 132px;
268
+ overflow: auto;
269
+ background: #07080c;
270
+ border-top: 1px solid rgba(255, 59, 78, 0.22);
271
+ }
272
+ .bl-detail[hidden] {
273
+ display: none;
274
+ }
275
+ .bl-detail button {
276
+ display: block;
277
+ width: 100%;
278
+ margin: 0;
279
+ padding: 4px 10px;
280
+ border: 0;
281
+ background: transparent;
282
+ color: #d8dce6;
283
+ font: 11px ui-monospace, Consolas, monospace;
284
+ line-height: 1.4;
285
+ text-align: left;
286
+ cursor: pointer;
287
+ }
288
+ .bl-detail button[data-level="warn"] { color: #ffd36a; }
289
+ .bl-detail button[data-level="error"] { color: #ff6b7d; }
290
+ .bl-detail button:hover { background: rgba(255, 255, 255, 0.04); }
258
291
  .bl-adapter {
259
292
  appearance: none;
260
293
  background: #16181f;
@@ -331,6 +364,8 @@ export class UIOverlay {
331
364
  this.#mounted = false;
332
365
  this.#providerSig = '';
333
366
  this.#adapterSig = '';
367
+ this.#hotkeySig = '';
368
+ this.#detail = [];
334
369
  this.#flashAt = new Map();
335
370
  this.#flashTimers = new Map();
336
371
  }
@@ -343,6 +378,8 @@ export class UIOverlay {
343
378
  #mounted;
344
379
  #providerSig;
345
380
  #adapterSig;
381
+ #hotkeySig;
382
+ #detail;
346
383
  #flashAt;
347
384
  #flashTimers;
348
385
 
@@ -393,11 +430,13 @@ export class UIOverlay {
393
430
  </div>
394
431
  </div>
395
432
  <div class="bl-hud" data-el="hud"></div>
433
+ <div class="bl-detail" data-el="detail" hidden></div>
396
434
  <div class="bl-console" data-el="console" role="log" aria-live="polite"></div>
397
435
  <div class="bl-foot">
398
436
  <button class="bl-btn" data-act="slow" type="button">F3 SLOW</button>
399
437
  <button class="bl-btn" data-act="freeze" type="button">F4 STOP</button>
400
438
  <button class="bl-btn" data-act="live" type="button">1x LIVE</button>
439
+ <span class="bl-hotkeys" data-el="hotkeys"></span>
401
440
  <button class="bl-btn" data-act="ask" type="button">ASK AI</button>
402
441
  <span class="bl-providers" data-el="providers"></span>
403
442
  <span class="bl-adapters" data-el="adapters"></span>
@@ -419,8 +458,10 @@ export class UIOverlay {
419
458
  graphStats: this.#root.querySelector('[data-el="graphStats"]'),
420
459
  hud: this.#root.querySelector('[data-el="hud"]'),
421
460
  console: this.#root.querySelector('[data-el="console"]'),
461
+ detail: this.#root.querySelector('[data-el="detail"]'),
422
462
  providers: this.#root.querySelector('[data-el="providers"]'),
423
- adapters: this.#root.querySelector('[data-el="adapters"]')
463
+ adapters: this.#root.querySelector('[data-el="adapters"]'),
464
+ hotkeys: this.#root.querySelector('[data-el="hotkeys"]')
424
465
  };
425
466
 
426
467
  this.#root.querySelector('.bl-foot').addEventListener('click', (event) => {
@@ -435,6 +476,13 @@ export class UIOverlay {
435
476
  else if (act === 'adapter') this.core.toggleAdapter(btn.getAttribute('data-name'));
436
477
  });
437
478
 
479
+ this.#root.querySelector('.bl-detail')?.addEventListener('click', (event) => {
480
+ const row = event.target.closest('[data-detail]');
481
+ if (!row) return;
482
+ const item = this.#detail[Number(row.getAttribute('data-detail'))];
483
+ if (item && typeof item.onSelect === 'function') item.onSelect();
484
+ });
485
+
438
486
  this.#els.console.addEventListener('scroll', () => {
439
487
  const el = this.#els.console;
440
488
  this.#stickBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
@@ -456,6 +504,8 @@ export class UIOverlay {
456
504
  this.#mounted = false;
457
505
  this.#providerSig = '';
458
506
  this.#adapterSig = '';
507
+ this.#hotkeySig = '';
508
+ this.#detail = [];
459
509
  this.#clearFlashTimers();
460
510
  this.#flashAt.clear();
461
511
  return this;
@@ -473,6 +523,29 @@ export class UIOverlay {
473
523
  return this;
474
524
  }
475
525
 
526
+ /**
527
+ * @param {Array<{ text: string, level?: string, onSelect?: () => void }>} items
528
+ */
529
+ setDetail(items) {
530
+ if (!this.#els?.detail) return this;
531
+ const list = Array.isArray(items) ? items : [];
532
+ this.#detail = list;
533
+ const box = this.#els.detail;
534
+ if (!list.length) {
535
+ box.innerHTML = '';
536
+ box.hidden = true;
537
+ return this;
538
+ }
539
+ let html = '';
540
+ for (let i = 0; i < list.length; i++) {
541
+ const level = list[i].level === 'error' || list[i].level === 'warn' ? list[i].level : 'info';
542
+ html += `<button type="button" data-detail="${i}" data-level="${level}">${escapeHtml(list[i].text)}</button>`;
543
+ }
544
+ box.hidden = false;
545
+ box.innerHTML = html;
546
+ return this;
547
+ }
548
+
476
549
  onPacket(packet) {
477
550
  if (!this.#els || !packet) return;
478
551
  if (packet.type === 'sys') return;
@@ -508,6 +581,7 @@ export class UIOverlay {
508
581
  this.#renderHud(state.hud);
509
582
  this.#syncButtons(state);
510
583
  this.#syncProviders(state);
584
+ this.#syncHotkeys(state);
511
585
  this.#syncAdapters(state);
512
586
 
513
587
  if (this.#stickBottom) {
@@ -584,10 +658,26 @@ export class UIOverlay {
584
658
  wrap.innerHTML = html;
585
659
  }
586
660
 
661
+ #syncHotkeys(state) {
662
+ const wrap = this.#els.hotkeys;
663
+ if (!wrap) return;
664
+ const list = (Array.isArray(state.adapters) ? state.adapters : []).filter((entry) => entry.hotkey);
665
+ const sig = list.map((entry) => `${entry.name}\0${entry.label}\0${entry.enabled ? 1 : 0}`).join('\n');
666
+ if (sig === this.#hotkeySig) return;
667
+ this.#hotkeySig = sig;
668
+ let html = '';
669
+ for (let i = 0; i < list.length; i++) {
670
+ const entry = list[i];
671
+ const hot = entry.enabled ? 'true' : 'false';
672
+ html += `<button class="bl-btn" data-act="adapter" data-name="${escapeHtml(entry.name)}" data-hot="${hot}" type="button">${escapeHtml(entry.label)}</button>`;
673
+ }
674
+ wrap.innerHTML = html;
675
+ }
676
+
587
677
  #syncAdapters(state) {
588
678
  const wrap = this.#els.adapters;
589
679
  if (!wrap) return;
590
- const list = Array.isArray(state.adapters) ? state.adapters : [];
680
+ const list = (Array.isArray(state.adapters) ? state.adapters : []).filter((entry) => !entry.hotkey);
591
681
  const sig = list.map((entry) => `${entry.name}\0${entry.label}\0${entry.enabled ? 1 : 0}`).join('\n');
592
682
  if (sig === this.#adapterSig) return;
593
683
  this.#adapterSig = sig;
package/src/index.d.ts CHANGED
@@ -112,11 +112,14 @@ export interface AdapterHooks {
112
112
  enable(): void;
113
113
  disable(): void;
114
114
  label?: string;
115
+ hotkey?: string;
116
+ enabled?: boolean;
115
117
  }
116
118
 
117
119
  export interface AdapterInfo {
118
120
  name: string;
119
121
  label: string;
122
+ hotkey: string;
120
123
  enabled: boolean;
121
124
  }
122
125
 
@@ -174,6 +177,13 @@ export class BeeLadybugCore {
174
177
  getAdapters(): AdapterInfo[];
175
178
  toggleAdapter(name: string): this;
176
179
  setAdapterEnabled(name: string, enabled: boolean): this;
180
+ publishDetail(items: DetailItem[]): this;
181
+ }
182
+
183
+ export interface DetailItem {
184
+ text: string;
185
+ level?: 'info' | 'warn' | 'error';
186
+ onSelect?: () => void;
177
187
  }
178
188
 
179
189
  export class UIOverlay {
@@ -186,6 +196,7 @@ export class UIOverlay {
186
196
  onPacket(packet: BeePacket): void;
187
197
  onClear(channel?: string): void;
188
198
  render(): void;
199
+ setDetail(items: DetailItem[]): this;
189
200
  }
190
201
 
191
202
  export interface CanvasEntity {
@@ -272,6 +283,30 @@ export class WebDOMAdapter {
272
283
  pump(): this;
273
284
  }
274
285
 
286
+ export interface CSSAdapterOptions {
287
+ source?: string;
288
+ hotkey?: string;
289
+ scanKey?: string;
290
+ label?: string;
291
+ maxScan?: number;
292
+ }
293
+
294
+ export const CSS_ADAPTER_DEFAULTS: Required<CSSAdapterOptions>;
295
+
296
+ export class CSSAdapter {
297
+ core: BeeLadybugCore | null;
298
+ source: string;
299
+ hotkey: string;
300
+ scanKey: string;
301
+ label: string;
302
+ maxScan: number;
303
+ readonly active: boolean;
304
+ constructor(core: BeeLadybugCore, options?: CSSAdapterOptions);
305
+ attach(): this;
306
+ detach(): this;
307
+ scan(): this;
308
+ }
309
+
275
310
  export interface AntAdapterOptions {
276
311
  root?: ParentNode | null;
277
312
  threshold?: number;
package/src/index.js CHANGED
@@ -2,6 +2,7 @@ export { BeeLadybugCore, BEE_LADYBUG_VERSION, CORE_DEFAULTS, WELL_KNOWN_TYPES, f
2
2
  export { UIOverlay } from './core/UIOverlay.js';
3
3
  export { CanvasAdapter, CANVAS_ADAPTER_DEFAULTS } from './adapters/CanvasAdapter.js';
4
4
  export { WebDOMAdapter, WEB_DOM_ADAPTER_DEFAULTS } from './adapters/WebDOMAdapter.js';
5
+ export { CSSAdapter, CSS_ADAPTER_DEFAULTS } from './adapters/CSSAdapter.js';
5
6
  export { AntAdapter, ANT_ADAPTER_DEFAULTS } from './adapters/AntAdapter.js';
6
7
  export { SpiderAdapter, SPIDER_ADAPTER_DEFAULTS } from './adapters/SpiderAdapter.js';
7
8
  export { PythonBridge, PYTHON_BRIDGE_DEFAULTS } from './adapters/python/PythonBridge.js';