bee-ladybug 0.5.1 → 0.5.3

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
@@ -134,6 +134,7 @@ bee.subscribe((packet) => {
134
134
  | `F3` | slow-motion del tempo fittizio (`0.25x` / `1x`) |
135
135
  | `F4` | freeze / run del tempo fittizio |
136
136
  | `F6` | CSS hunt, se `CSSAdapter` è attaccato |
137
+ | `P` | blocca il pannello CSS sull'elemento corrente, solo con la hunt accesa |
137
138
  | `S` | scansione CSS, solo con la hunt accesa |
138
139
 
139
140
  Niente `F12`, niente tilde: sui layout italiani la tilde non è un tasto unico.
@@ -194,13 +195,13 @@ css.attach();
194
195
 
195
196
  `F6` (pulsante **F6 CSS** accanto a F3/F4) accende la caccia. Sull'elemento sotto il puntatore l'overlay elenca:
196
197
 
197
- - regole `!important` che gli corrispondono (fogli cross-origin: `unreadable sheet: <href>`)
198
+ - regole `!important` che gli corrispondono. Un foglio cross-origin è una nota grigia, una volta sola: `unreadable sheet: <href>`
198
199
  - overflow del viewport e margini negativi
199
200
  - testo trasparente, `opacity: 0`, o colore uguale allo sfondo
200
201
  - 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
+ - `<img>` con rapporto del file diverso di oltre il 5% dal riquadro disegnato (`getBoundingClientRect`), solo se `object-fit` calcolato è `fill` (il default)
202
203
 
203
- `S` con la hunt accesa percorre la pagina. L'overflow resta solo sull'elemento che lo causa: un antenato si salta se un discendente sfora della stessa quantità (±2px), oppure se la larghezza del box ci sta nel viewport e a uscire è un figlio. Righe uguali (stesso selettore e stesso problema) diventano una sola, con il conteggio (`article overflows viewport ×4`). L'ordine è: causa di overflow, click bloccato, testo invisibile, immagine distorta, `!important`. Un click sulla riga disegna il contorno rosso. In console un elemento pulito non viene scritto, a meno che `F3` SLOW sia acceso. I fogli di stile restano in cache finché un `MutationObserver` non vede un cambio a `<style>` o `<link>`. Hover è limitato a un frame.
204
+ `S` con la hunt accesa percorre la pagina. L'overflow resta solo sull'elemento che lo causa: un antenato si salta se un discendente sfora della stessa quantità (±2px), oppure se la larghezza del box ci sta nel viewport e a uscire è un figlio. Righe uguali (stesso selettore e stesso problema) diventano una sola, con il conteggio (`article overflows viewport ×4`). L'ordine è: causa di overflow, click bloccato, testo invisibile, immagine distorta, `!important`. Un click sulla riga disegna il contorno rosso. `P` ferma il pannello sull'elemento corrente. **Issues** raggruppa per causa: `body::after blocks clicks on 10 elements` è una riga rossa, e il click la apre sull'elenco degli elementi. In cima alla sezione, la legenda dice rosso = bug e grigio = info. Un foglio illeggibile è una riga grigia, una per foglio, e non entra tra i bug. L'overlay resta alto al massimo metà schermo, scorre al suo interno, e le sezioni Status, Issues, Inspect ed Events si chiudono. In console un elemento pulito non viene scritto, e con la hunt accesa restano fuori anche `hover` e `mutate attr` del DOM, a meno che `F3` SLOW sia acceso. Ogni problema CSS viene scritto una sola volta per elemento ogni 5 secondi. 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
 
205
206
  Demo: `examples/css-hunt.html`.
206
207
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bee-ladybug",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
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",
@@ -15,8 +15,11 @@ export const CSS_ADAPTER_DEFAULTS = Object.freeze({
15
15
  source: 'css',
16
16
  hotkey: 'F6',
17
17
  scanKey: 'KeyS',
18
+ pinKey: 'KeyP',
18
19
  label: 'F6 CSS',
19
- maxScan: 8
20
+ maxScan: 8,
21
+ issueWindow: 5000,
22
+ maxIssues: 10
20
23
  });
21
24
 
22
25
  export class CSSAdapter {
@@ -30,19 +33,26 @@ export class CSSAdapter {
30
33
  this.source = cfg.source;
31
34
  this.hotkey = cfg.hotkey;
32
35
  this.scanKey = cfg.scanKey;
36
+ this.pinKey = cfg.pinKey;
33
37
  this.label = cfg.label;
34
38
  this.maxScan = cfg.maxScan;
39
+ this.issueWindow = cfg.issueWindow;
40
+ this.maxIssues = cfg.maxIssues;
35
41
 
36
42
  this.#attached = false;
37
43
  this.#active = false;
38
44
  this.#rules = [];
39
- this.#unreadable = [];
40
45
  this.#sheetsDirty = true;
41
46
  this.#x = 0;
42
47
  this.#y = 0;
43
48
  this.#rafId = 0;
44
49
  this.#lastEl = null;
45
50
  this.#pinned = null;
51
+ this.#panelLock = false;
52
+ this.#issues = [];
53
+ this.#notes = [];
54
+ this.#seenSheets = new Set();
55
+ this.#issueLog = new Map();
46
56
  this.#hoverBox = null;
47
57
  this.#pinBox = null;
48
58
  this.#observer = null;
@@ -63,13 +73,17 @@ export class CSSAdapter {
63
73
  #attached;
64
74
  #active;
65
75
  #rules;
66
- #unreadable;
67
76
  #sheetsDirty;
68
77
  #x;
69
78
  #y;
70
79
  #rafId;
71
80
  #lastEl;
72
81
  #pinned;
82
+ #panelLock;
83
+ #issues;
84
+ #notes;
85
+ #seenSheets;
86
+ #issueLog;
73
87
  #hoverBox;
74
88
  #pinBox;
75
89
  #observer;
@@ -94,7 +108,7 @@ export class CSSAdapter {
94
108
  });
95
109
  this.core?.sendData('log', {
96
110
  source: this.source,
97
- message: 'CSSAdapter attached. F6 CSS hunt. S scans the page while hunt is on.'
111
+ message: 'CSSAdapter attached. F6 CSS hunt. S scans. P pins the panel.'
98
112
  });
99
113
  this.core?.sendData('state', {
100
114
  source: this.source,
@@ -146,8 +160,14 @@ export class CSSAdapter {
146
160
  score: cause.score
147
161
  });
148
162
  }
149
- const groups = groupIssues(rows).slice(0, this.maxScan);
150
- const items = groups.map((group) => ({
163
+ const groups = groupIssues(rows);
164
+ for (let i = 0; i < rows.length; i++) {
165
+ const row = rows[i];
166
+ this.#rememberCause(row.el, row.selector, row.kind);
167
+ }
168
+ this.#publishIssues();
169
+ const shown = groups.slice(0, this.maxScan);
170
+ const items = shown.map((group) => ({
151
171
  level: group.level,
152
172
  text: formatIssue(group),
153
173
  onSelect: () => this.#pin(group.el)
@@ -157,13 +177,13 @@ export class CSSAdapter {
157
177
  this.core?.sendData('state', {
158
178
  source: this.source,
159
179
  key: 'css.scan',
160
- value: String(groups.length)
180
+ value: String(shown.length)
161
181
  });
162
- if (groups.length || isVerbose(this.core)) {
182
+ if (shown.length || isVerbose(this.core)) {
163
183
  this.core?.sendData('warn', {
164
184
  source: this.source,
165
- message: groups.length
166
- ? `scan ${groups.length} issue(s). Click a row to outline it.`
185
+ message: shown.length
186
+ ? `scan ${shown.length} issue(s). Click a row to outline it.`
167
187
  : 'scan clean: no causes found.'
168
188
  });
169
189
  }
@@ -193,7 +213,7 @@ export class CSSAdapter {
193
213
  this.core?.sendData('state', { source: this.source, key: 'css.hunt', value: 'on' });
194
214
  this.core?.sendData('log', {
195
215
  source: this.source,
196
- message: 'CSS hunt on. Hover an element, or press S to scan.'
216
+ message: 'CSS hunt on. Hover an element, P pins the panel, S scans.'
197
217
  });
198
218
  }
199
219
 
@@ -212,17 +232,29 @@ export class CSSAdapter {
212
232
  this.#observer = null;
213
233
  this.#lastEl = null;
214
234
  this.#pinned = null;
235
+ this.#panelLock = false;
236
+ this.#issues = [];
237
+ this.#notes = [];
238
+ this.#seenSheets.clear();
239
+ this.#issueLog.clear();
215
240
  this.#unmountBoxes();
216
241
  this.core?.publishDetail([]);
242
+ this.core?.publishIssues([]);
217
243
  this.core?.sendData('state', { source: this.source, key: 'css.hunt', value: 'off' });
218
244
  this.core?.sendData('log', { source: this.source, message: 'CSS hunt off.' });
219
245
  }
220
246
 
221
247
  #handleKey(event) {
222
248
  if (!this.#active || event.repeat) return;
223
- if (event.code !== this.scanKey) return;
224
249
  if (isTypingTarget(event.target)) return;
250
+ if (event.code === this.pinKey) {
251
+ event.preventDefault();
252
+ this.#togglePanelPin();
253
+ return;
254
+ }
255
+ if (event.code !== this.scanKey) return;
225
256
  event.preventDefault();
257
+ this.#panelLock = false;
226
258
  this.scan();
227
259
  }
228
260
 
@@ -240,18 +272,14 @@ export class CSSAdapter {
240
272
  this.#sheetsDirty = false;
241
273
  const built = collectImportantRules();
242
274
  this.#rules = built.rules;
243
- this.#unreadable = built.unreadable;
244
- for (let i = 0; i < built.unreadable.length; i++) {
245
- this.core?.sendData('warn', {
246
- source: this.source,
247
- message: `unreadable sheet: ${built.unreadable[i]}`
248
- });
249
- }
275
+ for (let i = 0; i < built.unreadable.length; i++) this.#rememberSheet(built.unreadable[i]);
276
+ this.#publishIssues();
250
277
  }
251
278
 
252
279
  #inspect(x, y) {
253
280
  const el = pickElement(x, y);
254
281
  placeBox(this.#hoverBox, el);
282
+ if (this.#panelLock) return;
255
283
  if (el === this.#lastEl) return;
256
284
  this.#lastEl = el;
257
285
  if (!el) {
@@ -265,13 +293,6 @@ export class CSSAdapter {
265
293
  pushInvisible(el, findings);
266
294
  pushBlockers(el, findings);
267
295
  pushImage(el, findings);
268
- for (let i = 0; i < this.#unreadable.length; i++) {
269
- findings.push({
270
- level: 'warn',
271
- text: `unreadable sheet: ${this.#unreadable[i]}`,
272
- score: 0
273
- });
274
- }
275
296
  const path = cssPath(el);
276
297
  this.core?.sendData('state', { source: this.source, key: 'css.target', value: path });
277
298
  this.core?.sendData('state', {
@@ -284,19 +305,98 @@ export class CSSAdapter {
284
305
  text: `${path} ${hit.text}`,
285
306
  onSelect: () => this.#pin(el)
286
307
  })));
308
+ this.#stickFindings(el, path, findings);
287
309
  if (!findings.length) {
288
310
  if (isVerbose(this.core)) {
289
311
  this.core?.sendData('log', { source: this.source, message: `${path} clean` });
290
312
  }
291
313
  return;
292
314
  }
293
- const lead = findings.find((hit) => hit.level === 'error') || findings[0];
294
- this.core?.sendData(lead.level === 'error' ? 'error' : 'warn', {
315
+ this.#logFindings(path, findings);
316
+ }
317
+
318
+ #togglePanelPin() {
319
+ if (this.#panelLock) {
320
+ this.#panelLock = false;
321
+ this.#pinned = null;
322
+ placeBox(this.#pinBox, null);
323
+ this.core?.sendData('state', { source: this.source, key: 'css.pin', value: 'off' });
324
+ this.core?.sendData('log', { source: this.source, message: 'CSS pin off.' });
325
+ this.#lastEl = null;
326
+ this.#schedule();
327
+ return;
328
+ }
329
+ const el = this.#lastEl;
330
+ if (!el) return;
331
+ this.#panelLock = true;
332
+ this.#pin(el);
333
+ const path = cssPath(el);
334
+ this.core?.sendData('state', { source: this.source, key: 'css.pin', value: path });
335
+ this.core?.sendData('log', { source: this.source, message: `CSS pin ${path}` });
336
+ }
337
+
338
+ #logFindings(path, findings) {
339
+ const now = nowMs();
340
+ for (let i = 0; i < findings.length; i++) {
341
+ const hit = findings[i];
342
+ const text = hit.text || hit.kind || '';
343
+ if (!text || text.startsWith('unreadable sheet:')) continue;
344
+ if (!rememberIssue(this.#issueLog, `${path}\0${text}`, now, this.issueWindow)) continue;
345
+ const level = hit.level === 'error' ? 'error' : 'warn';
346
+ this.core?.sendData(level, {
347
+ source: this.source,
348
+ message: `${path} ${text}`
349
+ });
350
+ }
351
+ }
352
+
353
+ #stickFindings(el, path, findings) {
354
+ let changed = false;
355
+ for (let i = 0; i < findings.length; i++) {
356
+ const hit = findings[i];
357
+ if (hit.kind === 'overflows viewport' && !elementCausesOverflow(el)) continue;
358
+ if (this.#rememberCause(el, path, hit.kind)) changed = true;
359
+ }
360
+ if (changed) this.#publishIssues();
361
+ }
362
+
363
+ #rememberCause(el, path, kind) {
364
+ const next = addCause(this.#issues, { kind, path, el }, this.maxIssues);
365
+ if (next === this.#issues) return false;
366
+ this.#issues = next;
367
+ return true;
368
+ }
369
+
370
+ #rememberSheet(href) {
371
+ const label = String(href || '');
372
+ if (!label || this.#seenSheets.has(label)) return;
373
+ this.#seenSheets.add(label);
374
+ this.#notes.push({ text: `unreadable sheet: ${label}` });
375
+ this.core?.sendData('log', {
295
376
  source: this.source,
296
- message: `${path} ${lead.text}`
377
+ level: 'info',
378
+ message: `unreadable sheet: ${label}`
297
379
  });
298
380
  }
299
381
 
382
+ #publishIssues() {
383
+ const bugs = this.#issues.map((entry) => ({
384
+ level: 'error',
385
+ key: entry.key,
386
+ text: formatCause(entry),
387
+ targets: entry.targets.map((target) => ({
388
+ text: target.text,
389
+ onSelect: () => this.#pin(target.el)
390
+ }))
391
+ }));
392
+ const notes = this.#notes.map((note) => ({
393
+ level: 'info',
394
+ key: note.text,
395
+ text: note.text
396
+ }));
397
+ this.core?.publishIssues(bugs.concat(notes));
398
+ }
399
+
300
400
  #pin(el) {
301
401
  this.#pinned = el;
302
402
  placeBox(this.#pinBox, el);
@@ -568,23 +668,140 @@ function pushImage(el, findings) {
568
668
  if (!el || el.tagName !== 'IMG') return;
569
669
  const naturalW = el.naturalWidth;
570
670
  const naturalH = el.naturalHeight;
571
- const shownW = el.clientWidth;
572
- const shownH = el.clientHeight;
573
- if (!naturalW || !naturalH || !shownW || !shownH) return;
574
- const natural = naturalW / naturalH;
575
- const shown = shownW / shownH;
576
- const diff = Math.abs(natural - shown) / natural;
577
- const fit = getComputedStyle(el).objectFit;
578
- if (diff <= 0.05 || fit !== 'fill') return;
671
+ if (!naturalW || !naturalH || typeof el.getBoundingClientRect !== 'function') return;
672
+ const rect = el.getBoundingClientRect();
673
+ const fit = (typeof getComputedStyle === 'function' ? getComputedStyle(el).objectFit : '') || 'fill';
674
+ if (!isDistortedImage(naturalW, naturalH, rect.width, rect.height, fit)) return;
675
+ const gap = renderedRatioGap(naturalW, naturalH, rect.width, rect.height);
579
676
  findings.push({
580
677
  level: 'error',
581
678
  rank: ISSUE_RANK.image,
582
- score: diff * 100,
679
+ score: gap * 100,
583
680
  kind: 'distorted image',
584
- text: `distorted image ${Math.round(diff * 100)}% (object-fit: fill)`
681
+ text: `distorted image ${Math.round(gap * 100)}% (object-fit: fill)`
585
682
  });
586
683
  }
587
684
 
685
+ /**
686
+ * Rendered box versus the file. Attributes and clientWidth are ignored.
687
+ * `object-fit` defaults to fill. Flag when the ratios differ by more than 5%.
688
+ */
689
+ export function isDistortedImage(naturalW, naturalH, renderedW, renderedH, objectFit) {
690
+ const fit = objectFit || 'fill';
691
+ if (fit !== 'fill') return false;
692
+ return renderedRatioGap(naturalW, naturalH, renderedW, renderedH) > 0.05;
693
+ }
694
+
695
+ export function renderedRatioGap(naturalW, naturalH, renderedW, renderedH) {
696
+ if (!naturalW || !naturalH || !renderedW || !renderedH) return 0;
697
+ const natural = naturalW / naturalH;
698
+ const rendered = renderedW / renderedH;
699
+ return Math.abs(natural - rendered) / natural;
700
+ }
701
+
702
+ /** @returns {boolean} true when this issue should be written to the event list */
703
+ export function rememberIssue(seen, key, now, windowMs = 5000) {
704
+ const prev = seen.get(key);
705
+ if (typeof prev === 'number' && now - prev < windowMs) return false;
706
+ seen.set(key, now);
707
+ if (seen.size > 64) {
708
+ for (const [name, at] of seen) {
709
+ if (now - at >= windowMs) seen.delete(name);
710
+ }
711
+ }
712
+ return true;
713
+ }
714
+
715
+ /**
716
+ * Newest first, duplicates move to the top, capped at `max`.
717
+ * @param {Array<{ text: string, el?: Element }>} list
718
+ * @param {{ text: string, el?: Element }} entry
719
+ */
720
+ export function pushStickyIssue(list, entry, max = 10) {
721
+ const next = [entry];
722
+ for (let i = 0; i < list.length; i++) {
723
+ if (list[i].text === entry.text) continue;
724
+ next.push(list[i]);
725
+ if (next.length >= max) break;
726
+ }
727
+ return next.slice(0, max);
728
+ }
729
+
730
+ export function describeCause(kind, path) {
731
+ if (kind === 'overflows viewport') {
732
+ return { key: `overflow\0${path}`, type: 'overflow', cause: path };
733
+ }
734
+ const prefix = 'click blocked by ';
735
+ if (typeof kind === 'string' && kind.startsWith(prefix)) {
736
+ const cause = kind.slice(prefix.length);
737
+ return { key: `block\0${cause}`, type: 'block', cause };
738
+ }
739
+ return null;
740
+ }
741
+
742
+ /**
743
+ * One row per cause. A blocker that covers many nodes stays a single row.
744
+ * @param {Array<{ key: string, type: string, cause: string, targets: Array<{ text: string, el?: Element }> }>} list
745
+ * @param {{ kind?: string, path: string, el?: Element }} entry
746
+ */
747
+ export function addCause(list, entry, max = 10) {
748
+ const described = describeCause(entry.kind, entry.path);
749
+ if (!described) return list;
750
+ const target = { text: entry.path, el: entry.el };
751
+ const rest = [];
752
+ let prev = null;
753
+ for (let i = 0; i < list.length; i++) {
754
+ if (list[i].key === described.key) prev = list[i];
755
+ else rest.push(list[i]);
756
+ }
757
+ const targets = prev ? prev.targets.filter((item) => !sameTarget(item, target)) : [];
758
+ targets.unshift(target);
759
+ rest.unshift({
760
+ key: described.key,
761
+ type: described.type,
762
+ cause: described.cause,
763
+ targets
764
+ });
765
+ return rest.slice(0, max);
766
+ }
767
+
768
+ export function formatCause(entry) {
769
+ const count = entry.targets.length;
770
+ if (entry.type === 'block') {
771
+ const noun = count === 1 ? 'element' : 'elements';
772
+ return `${entry.cause} blocks clicks on ${count} ${noun}`;
773
+ }
774
+ if (count <= 1) return `${entry.cause} overflows viewport`;
775
+ return `${entry.cause} overflows viewport · ${count} elements`;
776
+ }
777
+
778
+ function sameTarget(a, b) {
779
+ if (a.el && b.el) return a.el === b.el;
780
+ return a.text === b.text;
781
+ }
782
+
783
+ function elementCausesOverflow(el) {
784
+ const self = measureViewportOverflow(el);
785
+ if (!self) return false;
786
+ const items = [self];
787
+ if (typeof el.querySelectorAll === 'function') {
788
+ const nodes = el.querySelectorAll('*');
789
+ for (let i = 0; i < nodes.length; i++) {
790
+ const measured = measureViewportOverflow(nodes[i]);
791
+ if (measured) items.push(measured);
792
+ }
793
+ }
794
+ const causes = keepOverflowCauses(items);
795
+ for (let i = 0; i < causes.length; i++) {
796
+ if (causes[i].el === el) return true;
797
+ }
798
+ return false;
799
+ }
800
+
801
+ function nowMs() {
802
+ return typeof performance !== 'undefined' ? performance.now() : Date.now();
803
+ }
804
+
588
805
  function issue(kind, rank, score) {
589
806
  return { level: 'error', rank, score, kind, text: kind };
590
807
  }
@@ -279,10 +279,12 @@ export class WebDOMAdapter {
279
279
 
280
280
  if (key !== this.#lastHoverKey) {
281
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
- });
282
+ if (!domNoiseQuiet(cssHuntEnabled(this.core), this.frozen, this.timeScale)) {
283
+ this.core.sendData('log', {
284
+ source: this.source,
285
+ message: `hover ${key} ${Math.round(model.border.w)}×${Math.round(model.border.h)} ${model.display}`
286
+ });
287
+ }
286
288
  }
287
289
  }
288
290
 
@@ -313,14 +315,17 @@ export class WebDOMAdapter {
313
315
  this.#mutRemoved = 0;
314
316
  this.#mutAttr = 0;
315
317
  if (!added && !removed && !attr) return;
318
+ const quiet = domNoiseQuiet(cssHuntEnabled(this.core), this.frozen, this.timeScale);
316
319
  const parts = [];
317
320
  if (added) parts.push(`+${added}`);
318
321
  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
- });
322
+ if (attr && !quiet) parts.push(`attr ${attr}`);
323
+ if (parts.length) {
324
+ this.core?.sendData('warn', {
325
+ source: this.source,
326
+ message: `mutate ${parts.join(' ')}`
327
+ });
328
+ }
324
329
  this.core?.sendData('metric', {
325
330
  name: 'mutations',
326
331
  value: added + removed + attr,
@@ -504,3 +509,21 @@ function countElements(root) {
504
509
  if (root.nodeType === 1) return 1 + (root.getElementsByTagName?.('*').length ?? 0);
505
510
  return root.getElementsByTagName?.('*').length ?? 0;
506
511
  }
512
+
513
+ /**
514
+ * While CSS hunt is on, hover and attribute-mutation lines stay quiet
515
+ * unless F3 SLOW is running.
516
+ */
517
+ export function domNoiseQuiet(cssEnabled, frozen, timeScale) {
518
+ if (!cssEnabled) return false;
519
+ return Boolean(frozen) || timeScale === 1;
520
+ }
521
+
522
+ function cssHuntEnabled(core) {
523
+ if (!core || typeof core.getAdapters !== 'function') return false;
524
+ const list = core.getAdapters();
525
+ for (let i = 0; i < list.length; i++) {
526
+ if (list[i].name === 'css' && list[i].enabled) return true;
527
+ }
528
+ return false;
529
+ }
@@ -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.5.1';
12
+ export const BEE_LADYBUG_VERSION = '0.5.3';
13
13
 
14
14
  export const CORE_DEFAULTS = Object.freeze({
15
15
  toggleKey: 'F2',
@@ -99,6 +99,7 @@ export class BeeLadybugCore {
99
99
  this.#askBusy = false;
100
100
  this.#adapters = new Map();
101
101
  this.#detail = [];
102
+ this.#issues = [];
102
103
 
103
104
  this.ui = null;
104
105
  if (cfg.ui !== false && typeof document !== 'undefined') {
@@ -135,6 +136,8 @@ export class BeeLadybugCore {
135
136
  #askBusy;
136
137
  /** @type {Array<{ text: string, level?: string, onSelect?: () => void }>} */
137
138
  #detail;
139
+ /** @type {Array<{ text: string, level?: string, onSelect?: () => void }>} */
140
+ #issues;
138
141
  /** @type {Map<string, { name: string, label: string, enabled: boolean, enable: Function, disable: Function }>} */
139
142
  #adapters;
140
143
 
@@ -356,6 +359,17 @@ export class BeeLadybugCore {
356
359
  return this;
357
360
  }
358
361
 
362
+ /**
363
+ * Sticky red warnings. The core does not interpret them.
364
+ * @param {Array<{ text: string, level?: string, onSelect?: () => void }>} items
365
+ */
366
+ publishIssues(items) {
367
+ const list = Array.isArray(items) ? items : [];
368
+ this.#issues = list;
369
+ this.ui?.setIssues(this.#issues);
370
+ return this;
371
+ }
372
+
359
373
  /**
360
374
  * @param {string} name
361
375
  */
@@ -463,6 +477,7 @@ export class BeeLadybugCore {
463
477
  this.#askBusy = false;
464
478
  this.#adapters.clear();
465
479
  this.#detail = [];
480
+ this.#issues = [];
466
481
  this.ui?.destroy();
467
482
  this.ui = null;
468
483
  return this;
@@ -35,6 +35,9 @@ const STYLES = `
35
35
  top: 12px;
36
36
  left: 12px;
37
37
  width: 360px;
38
+ max-height: 50vh;
39
+ display: flex;
40
+ flex-direction: column;
38
41
  background: rgba(7, 8, 12, 0.94);
39
42
  border: 1px solid rgba(255, 59, 78, 0.55);
40
43
  box-sizing: border-box;
@@ -62,6 +65,7 @@ const STYLES = `
62
65
  display: flex;
63
66
  align-items: center;
64
67
  gap: 8px;
68
+ flex: 0 0 auto;
65
69
  padding: 10px 12px 8px;
66
70
  border-bottom: 1px solid rgba(255, 59, 78, 0.28);
67
71
  }
@@ -201,7 +205,7 @@ const STYLES = `
201
205
  .bl-k { color: #8b91a3; }
202
206
  .bl-v { color: #e9edf5; }
203
207
  .bl-console {
204
- height: 168px;
208
+ max-height: 140px;
205
209
  overflow: auto;
206
210
  padding: 6px 10px 8px;
207
211
  background: #05060a;
@@ -212,13 +216,14 @@ const STYLES = `
212
216
  .bl-line { white-space: normal; word-break: break-word; }
213
217
  .bl-line .t { color: #8b91a3; margin-right: 8px; }
214
218
  .bl-line .s { color: #6ee7ff; margin-right: 8px; }
215
- .bl-line.info .m { color: #d8dce6; }
219
+ .bl-line.info .m { color: #8b91a3; }
216
220
  .bl-line.warn .m { color: #ffd36a; }
217
221
  .bl-line.error .m { color: #ff6b7d; }
218
222
  .bl-line.debug .m { color: #8b91a3; }
219
223
  .bl-foot {
220
224
  display: flex;
221
225
  align-items: center;
226
+ flex: 0 0 auto;
222
227
  flex-wrap: wrap;
223
228
  gap: 6px;
224
229
  padding: 8px 10px;
@@ -263,6 +268,72 @@ const STYLES = `
263
268
  .bl-hotkeys:empty {
264
269
  display: none;
265
270
  }
271
+ .bl-scroll {
272
+ overflow: auto;
273
+ min-height: 0;
274
+ flex: 1 1 auto;
275
+ }
276
+ .bl-section[hidden] { display: none; }
277
+ .bl-section-toggle {
278
+ display: block;
279
+ width: 100%;
280
+ margin: 0;
281
+ padding: 5px 10px;
282
+ border: 0;
283
+ border-top: 1px solid rgba(255, 59, 78, 0.28);
284
+ background: #101218;
285
+ color: #ffd36a;
286
+ font: 700 9px ui-monospace, Consolas, monospace;
287
+ letter-spacing: 0.14em;
288
+ text-align: left;
289
+ cursor: pointer;
290
+ }
291
+ .bl-section-toggle::before { content: "▾ "; }
292
+ .bl-section[data-open="false"] > .bl-section-toggle::before { content: "▸ "; }
293
+ .bl-section[data-open="false"] > .bl-section-body { display: none; }
294
+ .bl-issues {
295
+ background: #07080c;
296
+ }
297
+ .bl-legend {
298
+ display: flex;
299
+ gap: 14px;
300
+ padding: 4px 10px 2px;
301
+ color: #8b91a3;
302
+ font-size: 10px;
303
+ }
304
+ .bl-dot {
305
+ display: inline-block;
306
+ width: 7px;
307
+ height: 7px;
308
+ margin-right: 5px;
309
+ border-radius: 50%;
310
+ vertical-align: baseline;
311
+ }
312
+ .bl-dot.bug { background: #ff3b4e; }
313
+ .bl-dot.info { background: #8b91a3; }
314
+ .bl-cause > button,
315
+ .bl-cause-kids button,
316
+ .bl-note {
317
+ display: block;
318
+ width: 100%;
319
+ margin: 0;
320
+ padding: 3px 10px;
321
+ border: 0;
322
+ background: transparent;
323
+ font: 11px ui-monospace, Consolas, monospace;
324
+ line-height: 1.4;
325
+ text-align: left;
326
+ }
327
+ .bl-cause > button,
328
+ .bl-cause-kids button {
329
+ color: #ff6b7d;
330
+ cursor: pointer;
331
+ }
332
+ .bl-cause-kids[hidden] { display: none; }
333
+ .bl-cause-kids button { padding-left: 22px; }
334
+ .bl-cause > button:hover,
335
+ .bl-cause-kids button:hover { background: rgba(255, 59, 78, 0.08); }
336
+ .bl-note { color: #8b91a3; }
266
337
  .bl-detail {
267
338
  max-height: 132px;
268
339
  overflow: auto;
@@ -366,6 +437,9 @@ export class UIOverlay {
366
437
  this.#adapterSig = '';
367
438
  this.#hotkeySig = '';
368
439
  this.#detail = [];
440
+ this.#issues = [];
441
+ this.#issueNotes = [];
442
+ this.#openCauses = new Set();
369
443
  this.#flashAt = new Map();
370
444
  this.#flashTimers = new Map();
371
445
  }
@@ -380,6 +454,9 @@ export class UIOverlay {
380
454
  #adapterSig;
381
455
  #hotkeySig;
382
456
  #detail;
457
+ #issues;
458
+ #issueNotes;
459
+ #openCauses;
383
460
  #flashAt;
384
461
  #flashTimers;
385
462
 
@@ -416,6 +493,10 @@ export class UIOverlay {
416
493
  </button>
417
494
  </span>
418
495
  </div>
496
+ <div class="bl-scroll">
497
+ <section class="bl-section" data-section="status" data-open="true">
498
+ <button class="bl-section-toggle" type="button" data-section-toggle aria-expanded="true">STATUS</button>
499
+ <div class="bl-section-body">
419
500
  <div class="bl-stats">
420
501
  <div>HOST <b data-el="hostFps">—</b></div>
421
502
  <div>CORE <b data-el="coreFps">—</b></div>
@@ -430,8 +511,32 @@ export class UIOverlay {
430
511
  </div>
431
512
  </div>
432
513
  <div class="bl-hud" data-el="hud"></div>
433
- <div class="bl-detail" data-el="detail" hidden></div>
434
- <div class="bl-console" data-el="console" role="log" aria-live="polite"></div>
514
+ </div>
515
+ </section>
516
+ <section class="bl-section" data-el="issues" data-section="issues" data-open="true" hidden>
517
+ <button class="bl-section-toggle" type="button" data-section-toggle aria-expanded="true">ISSUES</button>
518
+ <div class="bl-section-body">
519
+ <div class="bl-legend">
520
+ <span><i class="bl-dot bug"></i>bug</span>
521
+ <span><i class="bl-dot info"></i>info</span>
522
+ </div>
523
+ <div data-el="issues-list"></div>
524
+ <div data-el="notes"></div>
525
+ </div>
526
+ </section>
527
+ <section class="bl-section" data-el="inspect" data-section="inspect" data-open="true" hidden>
528
+ <button class="bl-section-toggle" type="button" data-section-toggle aria-expanded="true">INSPECT</button>
529
+ <div class="bl-section-body">
530
+ <div class="bl-detail" data-el="detail" hidden></div>
531
+ </div>
532
+ </section>
533
+ <section class="bl-section" data-section="events" data-open="true">
534
+ <button class="bl-section-toggle" type="button" data-section-toggle aria-expanded="true">EVENTS</button>
535
+ <div class="bl-section-body">
536
+ <div class="bl-console" data-el="console" role="log" aria-live="polite"></div>
537
+ </div>
538
+ </section>
539
+ </div>
435
540
  <div class="bl-foot">
436
541
  <button class="bl-btn" data-act="slow" type="button">F3 SLOW</button>
437
542
  <button class="bl-btn" data-act="freeze" type="button">F4 STOP</button>
@@ -459,6 +564,10 @@ export class UIOverlay {
459
564
  hud: this.#root.querySelector('[data-el="hud"]'),
460
565
  console: this.#root.querySelector('[data-el="console"]'),
461
566
  detail: this.#root.querySelector('[data-el="detail"]'),
567
+ inspect: this.#root.querySelector('[data-el="inspect"]'),
568
+ issues: this.#root.querySelector('[data-el="issues"]'),
569
+ issuesList: this.#root.querySelector('[data-el="issues-list"]'),
570
+ notes: this.#root.querySelector('[data-el="notes"]'),
462
571
  providers: this.#root.querySelector('[data-el="providers"]'),
463
572
  adapters: this.#root.querySelector('[data-el="adapters"]'),
464
573
  hotkeys: this.#root.querySelector('[data-el="hotkeys"]')
@@ -476,6 +585,42 @@ export class UIOverlay {
476
585
  else if (act === 'adapter') this.core.toggleAdapter(btn.getAttribute('data-name'));
477
586
  });
478
587
 
588
+ this.#root.querySelector('.bl-scroll')?.addEventListener('click', (event) => {
589
+ const toggle = event.target.closest('[data-section-toggle]');
590
+ if (!toggle) return;
591
+ const section = toggle.closest('.bl-section');
592
+ if (!section) return;
593
+ const open = section.dataset.open === 'false';
594
+ section.dataset.open = open ? 'true' : 'false';
595
+ toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
596
+ });
597
+
598
+ this.#els.issues?.addEventListener('click', (event) => {
599
+ if (event.target.closest('[data-section-toggle]')) return;
600
+ const childBtn = event.target.closest('[data-target]');
601
+ if (childBtn) {
602
+ const item = this.#issues[Number(childBtn.getAttribute('data-issue'))];
603
+ const child = item?.targets?.[Number(childBtn.getAttribute('data-target'))];
604
+ if (child && typeof child.onSelect === 'function') child.onSelect();
605
+ return;
606
+ }
607
+ const row = event.target.closest('[data-issue]');
608
+ if (!row) return;
609
+ const item = this.#issues[Number(row.getAttribute('data-issue'))];
610
+ if (!item) return;
611
+ const many = Array.isArray(item.targets) && item.targets.length > 1;
612
+ if (many) {
613
+ const key = item.key || item.text;
614
+ if (this.#openCauses.has(key)) this.#openCauses.delete(key);
615
+ else this.#openCauses.add(key);
616
+ this.setIssues(this.#issues.concat(this.#issueNotes));
617
+ return;
618
+ }
619
+ const only = item.targets?.[0];
620
+ if (only && typeof only.onSelect === 'function') only.onSelect();
621
+ else if (typeof item.onSelect === 'function') item.onSelect();
622
+ });
623
+
479
624
  this.#root.querySelector('.bl-detail')?.addEventListener('click', (event) => {
480
625
  const row = event.target.closest('[data-detail]');
481
626
  if (!row) return;
@@ -506,6 +651,9 @@ export class UIOverlay {
506
651
  this.#adapterSig = '';
507
652
  this.#hotkeySig = '';
508
653
  this.#detail = [];
654
+ this.#issues = [];
655
+ this.#issueNotes = [];
656
+ this.#openCauses.clear();
509
657
  this.#clearFlashTimers();
510
658
  this.#flashAt.clear();
511
659
  return this;
@@ -531,9 +679,11 @@ export class UIOverlay {
531
679
  const list = Array.isArray(items) ? items : [];
532
680
  this.#detail = list;
533
681
  const box = this.#els.detail;
682
+ const section = this.#els.inspect;
534
683
  if (!list.length) {
535
684
  box.innerHTML = '';
536
685
  box.hidden = true;
686
+ if (section) section.hidden = true;
537
687
  return this;
538
688
  }
539
689
  let html = '';
@@ -542,10 +692,61 @@ export class UIOverlay {
542
692
  html += `<button type="button" data-detail="${i}" data-level="${level}">${escapeHtml(list[i].text)}</button>`;
543
693
  }
544
694
  box.hidden = false;
695
+ if (section) section.hidden = false;
545
696
  box.innerHTML = html;
546
697
  return this;
547
698
  }
548
699
 
700
+ /**
701
+ * Cause rows plus grey info notes. Bugs are red and expand; info stays grey.
702
+ * @param {Array<{ text: string, level?: string, key?: string, onSelect?: () => void, targets?: Array<{ text: string, onSelect?: () => void }> }>} items
703
+ */
704
+ setIssues(items) {
705
+ if (!this.#els?.issues || !this.#els.issuesList || !this.#els.notes) return this;
706
+ const list = Array.isArray(items) ? items : [];
707
+ const bugs = [];
708
+ const notes = [];
709
+ for (let i = 0; i < list.length; i++) {
710
+ if (list[i].level === 'info') notes.push(list[i]);
711
+ else bugs.push(list[i]);
712
+ }
713
+ const capped = bugs.slice(0, 10);
714
+ this.#issues = capped;
715
+ this.#issueNotes = notes;
716
+ const section = this.#els.issues;
717
+ if (!capped.length && !notes.length) {
718
+ this.#els.issuesList.innerHTML = '';
719
+ this.#els.notes.innerHTML = '';
720
+ section.hidden = true;
721
+ return this;
722
+ }
723
+ let html = '';
724
+ for (let i = 0; i < capped.length; i++) {
725
+ const item = capped[i];
726
+ const targets = Array.isArray(item.targets) ? item.targets : [];
727
+ const many = targets.length > 1;
728
+ const open = many && this.#openCauses.has(item.key || item.text);
729
+ html += `<div class="bl-cause" data-open="${open ? 'true' : 'false'}">`;
730
+ html += `<button type="button" data-issue="${i}">${many ? (open ? '▾ ' : '▸ ') : ''}${escapeHtml(item.text)}</button>`;
731
+ if (many) {
732
+ html += `<div class="bl-cause-kids"${open ? '' : ' hidden'}>`;
733
+ for (let j = 0; j < targets.length; j++) {
734
+ html += `<button type="button" data-issue="${i}" data-target="${j}">${escapeHtml(targets[j].text)}</button>`;
735
+ }
736
+ html += '</div>';
737
+ }
738
+ html += '</div>';
739
+ }
740
+ this.#els.issuesList.innerHTML = html;
741
+ let noteHtml = '';
742
+ for (let i = 0; i < notes.length; i++) {
743
+ noteHtml += `<div class="bl-note">${escapeHtml(notes[i].text)}</div>`;
744
+ }
745
+ this.#els.notes.innerHTML = noteHtml;
746
+ section.hidden = false;
747
+ return this;
748
+ }
749
+
549
750
  onPacket(packet) {
550
751
  if (!this.#els || !packet) return;
551
752
  if (packet.type === 'sys') return;
package/src/index.d.ts CHANGED
@@ -178,12 +178,15 @@ export class BeeLadybugCore {
178
178
  toggleAdapter(name: string): this;
179
179
  setAdapterEnabled(name: string, enabled: boolean): this;
180
180
  publishDetail(items: DetailItem[]): this;
181
+ publishIssues(items: DetailItem[]): this;
181
182
  }
182
183
 
183
184
  export interface DetailItem {
184
185
  text: string;
185
186
  level?: 'info' | 'warn' | 'error';
186
187
  onSelect?: () => void;
188
+ key?: string;
189
+ targets?: Array<{ text: string; onSelect?: () => void }>;
187
190
  }
188
191
 
189
192
  export class UIOverlay {
@@ -197,6 +200,7 @@ export class UIOverlay {
197
200
  onClear(channel?: string): void;
198
201
  render(): void;
199
202
  setDetail(items: DetailItem[]): this;
203
+ setIssues(items: DetailItem[]): this;
200
204
  }
201
205
 
202
206
  export interface CanvasEntity {
@@ -287,8 +291,11 @@ export interface CSSAdapterOptions {
287
291
  source?: string;
288
292
  hotkey?: string;
289
293
  scanKey?: string;
294
+ pinKey?: string;
290
295
  label?: string;
291
296
  maxScan?: number;
297
+ issueWindow?: number;
298
+ maxIssues?: number;
292
299
  }
293
300
 
294
301
  export const CSS_ADAPTER_DEFAULTS: Required<CSSAdapterOptions>;
@@ -298,8 +305,11 @@ export class CSSAdapter {
298
305
  source: string;
299
306
  hotkey: string;
300
307
  scanKey: string;
308
+ pinKey: string;
301
309
  label: string;
302
310
  maxScan: number;
311
+ issueWindow: number;
312
+ maxIssues: number;
303
313
  readonly active: boolean;
304
314
  constructor(core: BeeLadybugCore, options?: CSSAdapterOptions);
305
315
  attach(): this;