bee-ladybug 0.5.0 → 0.5.2

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,7 +134,8 @@ 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
- | `S` | scansione overflow / immagini, solo con la hunt accesa |
137
+ | `P` | blocca il pannello CSS sull'elemento corrente, solo con la hunt accesa |
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.
140
141
 
@@ -198,9 +199,9 @@ css.attach();
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 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
+ `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, così si può leggere. In cima all'overlay, la lista **Issues** tiene gli avvisi rossi (click bloccato e causa di overflow): al massimo 10, il più recente in alto, e non scorre via con la console. 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.0",
3
+ "version": "0.5.2",
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,8 +33,11 @@ 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;
@@ -43,6 +49,9 @@ export class CSSAdapter {
43
49
  this.#rafId = 0;
44
50
  this.#lastEl = null;
45
51
  this.#pinned = null;
52
+ this.#panelLock = false;
53
+ this.#issues = [];
54
+ this.#issueLog = new Map();
46
55
  this.#hoverBox = null;
47
56
  this.#pinBox = null;
48
57
  this.#observer = null;
@@ -70,6 +79,9 @@ export class CSSAdapter {
70
79
  #rafId;
71
80
  #lastEl;
72
81
  #pinned;
82
+ #panelLock;
83
+ #issues;
84
+ #issueLog;
73
85
  #hoverBox;
74
86
  #pinBox;
75
87
  #observer;
@@ -94,7 +106,7 @@ export class CSSAdapter {
94
106
  });
95
107
  this.core?.sendData('log', {
96
108
  source: this.source,
97
- message: 'CSSAdapter attached. F6 CSS hunt. S scans the page while hunt is on.'
109
+ message: 'CSSAdapter attached. F6 CSS hunt. S scans. P pins the panel.'
98
110
  });
99
111
  this.core?.sendData('state', {
100
112
  source: this.source,
@@ -112,43 +124,63 @@ export class CSSAdapter {
112
124
  return this;
113
125
  }
114
126
 
115
- /** Checks 2 and 5 across the document. Click a row to outline it. */
127
+ /** Page scan. Click a row to outline one element of that group. */
116
128
  scan() {
117
129
  if (!this.#active || typeof document === 'undefined') return this;
118
130
  if (this.#sheetsDirty) this.#rebuildSheets();
119
131
  const nodes = document.body ? document.body.querySelectorAll('*') : [];
120
- const offenders = [];
132
+ const overflow = [];
133
+ const rows = [];
121
134
  for (let i = 0; i < nodes.length; i++) {
122
135
  const el = nodes[i];
123
136
  if (!el || isBeeChrome(el) || isSkippedTag(el)) continue;
137
+ const measured = measureViewportOverflow(el);
138
+ if (measured) overflow.push(measured);
124
139
  const findings = [];
125
- pushOverflow(el, findings);
140
+ pushNegativeMargins(el, findings);
141
+ pushInvisible(el, findings);
142
+ pushBlockers(el, findings);
126
143
  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 });
144
+ pushImportant(el, this.#rules, findings);
145
+ for (let j = 0; j < findings.length; j++) {
146
+ rows.push({ el, selector: cssPath(el), ...findings[j] });
147
+ }
131
148
  }
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)
149
+ const causes = keepOverflowCauses(overflow);
150
+ for (let i = 0; i < causes.length; i++) {
151
+ const cause = causes[i];
152
+ rows.push({
153
+ el: cause.el,
154
+ selector: cssPath(cause.el),
155
+ kind: 'overflows viewport',
156
+ rank: 0,
157
+ level: 'error',
158
+ score: cause.score
159
+ });
160
+ }
161
+ const groups = groupIssues(rows);
162
+ for (let i = groups.length - 1; i >= 0; i--) this.#stickGroup(groups[i]);
163
+ const shown = groups.slice(0, this.maxScan);
164
+ const items = shown.map((group) => ({
165
+ level: group.level,
166
+ text: formatIssue(group),
167
+ onSelect: () => this.#pin(group.el)
138
168
  }));
139
169
  this.#lastEl = null;
140
170
  this.core?.publishDetail(items);
141
171
  this.core?.sendData('state', {
142
172
  source: this.source,
143
173
  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.'
174
+ value: String(shown.length)
151
175
  });
176
+ if (shown.length || isVerbose(this.core)) {
177
+ this.core?.sendData('warn', {
178
+ source: this.source,
179
+ message: shown.length
180
+ ? `scan ${shown.length} issue(s). Click a row to outline it.`
181
+ : 'scan clean: no causes found.'
182
+ });
183
+ }
152
184
  return this;
153
185
  }
154
186
 
@@ -175,7 +207,7 @@ export class CSSAdapter {
175
207
  this.core?.sendData('state', { source: this.source, key: 'css.hunt', value: 'on' });
176
208
  this.core?.sendData('log', {
177
209
  source: this.source,
178
- message: 'CSS hunt on. Hover an element, or press S to scan.'
210
+ message: 'CSS hunt on. Hover an element, P pins the panel, S scans.'
179
211
  });
180
212
  }
181
213
 
@@ -194,17 +226,27 @@ export class CSSAdapter {
194
226
  this.#observer = null;
195
227
  this.#lastEl = null;
196
228
  this.#pinned = null;
229
+ this.#panelLock = false;
230
+ this.#issues = [];
231
+ this.#issueLog.clear();
197
232
  this.#unmountBoxes();
198
233
  this.core?.publishDetail([]);
234
+ this.core?.publishIssues([]);
199
235
  this.core?.sendData('state', { source: this.source, key: 'css.hunt', value: 'off' });
200
236
  this.core?.sendData('log', { source: this.source, message: 'CSS hunt off.' });
201
237
  }
202
238
 
203
239
  #handleKey(event) {
204
240
  if (!this.#active || event.repeat) return;
205
- if (event.code !== this.scanKey) return;
206
241
  if (isTypingTarget(event.target)) return;
242
+ if (event.code === this.pinKey) {
243
+ event.preventDefault();
244
+ this.#togglePanelPin();
245
+ return;
246
+ }
247
+ if (event.code !== this.scanKey) return;
207
248
  event.preventDefault();
249
+ this.#panelLock = false;
208
250
  this.scan();
209
251
  }
210
252
 
@@ -234,6 +276,7 @@ export class CSSAdapter {
234
276
  #inspect(x, y) {
235
277
  const el = pickElement(x, y);
236
278
  placeBox(this.#hoverBox, el);
279
+ if (this.#panelLock) return;
237
280
  if (el === this.#lastEl) return;
238
281
  this.#lastEl = el;
239
282
  if (!el) {
@@ -266,15 +309,80 @@ export class CSSAdapter {
266
309
  text: `${path} ${hit.text}`,
267
310
  onSelect: () => this.#pin(el)
268
311
  })));
312
+ this.#stickFindings(el, path, findings);
269
313
  if (!findings.length) {
270
- this.core?.sendData('log', { source: this.source, message: `${path} clean` });
314
+ if (isVerbose(this.core)) {
315
+ this.core?.sendData('log', { source: this.source, message: `${path} clean` });
316
+ }
271
317
  return;
272
318
  }
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
- });
319
+ this.#logFindings(path, findings);
320
+ }
321
+
322
+ #togglePanelPin() {
323
+ if (this.#panelLock) {
324
+ this.#panelLock = false;
325
+ this.#pinned = null;
326
+ placeBox(this.#pinBox, null);
327
+ this.core?.sendData('state', { source: this.source, key: 'css.pin', value: 'off' });
328
+ this.core?.sendData('log', { source: this.source, message: 'CSS pin off.' });
329
+ this.#lastEl = null;
330
+ this.#schedule();
331
+ return;
332
+ }
333
+ const el = this.#lastEl;
334
+ if (!el) return;
335
+ this.#panelLock = true;
336
+ this.#pin(el);
337
+ const path = cssPath(el);
338
+ this.core?.sendData('state', { source: this.source, key: 'css.pin', value: path });
339
+ this.core?.sendData('log', { source: this.source, message: `CSS pin ${path}` });
340
+ }
341
+
342
+ #logFindings(path, findings) {
343
+ const now = nowMs();
344
+ for (let i = 0; i < findings.length; i++) {
345
+ const hit = findings[i];
346
+ const text = hit.text || hit.kind || '';
347
+ if (!text) continue;
348
+ if (!rememberIssue(this.#issueLog, `${path}\0${text}`, now, this.issueWindow)) continue;
349
+ const level = hit.level === 'error' ? 'error' : 'warn';
350
+ this.core?.sendData(level, {
351
+ source: this.source,
352
+ message: `${path} ${text}`
353
+ });
354
+ }
355
+ }
356
+
357
+ #stickFindings(el, path, findings) {
358
+ for (let i = 0; i < findings.length; i++) {
359
+ const hit = findings[i];
360
+ if (!isStickyKind(hit.kind)) continue;
361
+ if (hit.kind === 'overflows viewport' && !elementCausesOverflow(el)) continue;
362
+ this.#issues = pushStickyIssue(this.#issues, {
363
+ text: `${path} ${hit.kind}`,
364
+ el
365
+ }, this.maxIssues);
366
+ }
367
+ this.#publishIssues();
368
+ }
369
+
370
+ #stickGroup(group) {
371
+ if (!isStickyKind(group.kind)) return;
372
+ const count = group.count > 1 ? ` ×${group.count}` : '';
373
+ this.#issues = pushStickyIssue(this.#issues, {
374
+ text: `${group.selector} ${group.kind}${count}`,
375
+ el: group.el
376
+ }, this.maxIssues);
377
+ this.#publishIssues();
378
+ }
379
+
380
+ #publishIssues() {
381
+ this.core?.publishIssues(this.#issues.map((item) => ({
382
+ level: 'error',
383
+ text: item.text,
384
+ onSelect: () => this.#pin(item.el)
385
+ })));
278
386
  }
279
387
 
280
388
  #pin(el) {
@@ -380,7 +488,9 @@ function pushImportant(el, rules, findings) {
380
488
  const prop = rule.props[j];
381
489
  findings.push({
382
490
  level: 'warn',
491
+ rank: 4,
383
492
  score: 5,
493
+ kind: `!important ${rule.selector} { ${prop.property}: ${prop.value} }`,
384
494
  text: `!important ${rule.selector} { ${prop.property}: ${prop.value} } @ ${rule.source}`
385
495
  });
386
496
  }
@@ -388,19 +498,75 @@ function pushImportant(el, rules, findings) {
388
498
  }
389
499
 
390
500
  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`;
501
+ const measured = measureViewportOverflow(el);
502
+ if (measured) {
503
+ const past = measured.left > 1
504
+ ? `left ${Math.round(-measured.left)}px`
505
+ : `right +${Math.round(measured.right)}px`;
398
506
  findings.push({
399
507
  level: 'error',
400
- score: Math.abs(rect.left < -1 ? rect.left : rect.right - view),
508
+ rank: 0,
509
+ score: measured.score,
510
+ kind: 'overflows viewport',
401
511
  text: `overflows viewport (${past})`
402
512
  });
403
513
  }
514
+ pushNegativeMargins(el, findings);
515
+ }
516
+
517
+ function measureViewportOverflow(el) {
518
+ if (!el || typeof el.getBoundingClientRect !== 'function') return null;
519
+ if (typeof document === 'undefined') return null;
520
+ if (el === document.documentElement || el === document.body) return null;
521
+ const rect = el.getBoundingClientRect();
522
+ const view = document.documentElement.clientWidth;
523
+ const left = rect.left < -1 ? -rect.left : 0;
524
+ const right = rect.right > view + 1 ? rect.right - view : 0;
525
+ if (left <= 1 && right <= 1) return null;
526
+ return { el, left, right, width: rect.width, view, score: Math.max(left, right) };
527
+ }
528
+
529
+ /**
530
+ * Keep the node that actually sticks out.
531
+ * Drop a parent when a descendant overflows by the same amount (±2px),
532
+ * and drop a box whose own width fits the viewport when a child is the one overflowing.
533
+ * @param {Array<{ el: Element, left: number, right: number, width: number, view: number, score: number }>} items
534
+ */
535
+ export function keepOverflowCauses(items) {
536
+ return items.filter((item) => !echoedByDescendant(item, items) && !fitsOnlyBecauseOfChild(item, items));
537
+ }
538
+
539
+ function echoedByDescendant(item, items) {
540
+ for (let i = 0; i < items.length; i++) {
541
+ const other = items[i];
542
+ if (other.el === item.el || typeof item.el.contains !== 'function' || !item.el.contains(other.el)) continue;
543
+ const leftEcho = item.left <= 1 || Math.abs(item.left - other.left) <= 2;
544
+ const rightEcho = item.right <= 1 || Math.abs(item.right - other.right) <= 2;
545
+ const matched = (item.left > 1 && Math.abs(item.left - other.left) <= 2)
546
+ || (item.right > 1 && Math.abs(item.right - other.right) <= 2);
547
+ if (matched && leftEcho && rightEcho) return true;
548
+ }
549
+ return false;
550
+ }
551
+
552
+ function fitsOnlyBecauseOfChild(item, items) {
553
+ if (item.width > item.view + 2) return false;
554
+ if (item.left <= 1 && item.right <= 1) return false;
555
+ let leftOk = item.left <= 1;
556
+ let rightOk = item.right <= 1;
557
+ let childOverflows = false;
558
+ for (let i = 0; i < items.length; i++) {
559
+ const other = items[i];
560
+ if (other.el === item.el || typeof item.el.contains !== 'function' || !item.el.contains(other.el)) continue;
561
+ if (other.left > 1 || other.right > 1) childOverflows = true;
562
+ if (item.left > 1 && other.left + 2 >= item.left) leftOk = true;
563
+ if (item.right > 1 && other.right + 2 >= item.right) rightOk = true;
564
+ }
565
+ return childOverflows && leftOk && rightOk;
566
+ }
567
+
568
+ function pushNegativeMargins(el, findings) {
569
+ if (typeof getComputedStyle !== 'function') return;
404
570
  const style = getComputedStyle(el);
405
571
  const sides = ['top', 'right', 'bottom', 'left'];
406
572
  for (let i = 0; i < sides.length; i++) {
@@ -409,26 +575,64 @@ function pushOverflow(el, findings) {
409
575
  if (!Number.isFinite(value) || value >= 0) continue;
410
576
  findings.push({
411
577
  level: 'warn',
578
+ rank: 5,
412
579
  score: Math.abs(value),
580
+ kind: `negative margin margin-${sides[i]}: ${raw.trim()}`,
413
581
  text: `negative margin margin-${sides[i]}: ${raw.trim()}`
414
582
  });
415
583
  }
416
584
  }
417
585
 
586
+ const ISSUE_RANK = Object.freeze({
587
+ overflow: 0,
588
+ blocker: 1,
589
+ invisible: 2,
590
+ image: 3,
591
+ important: 4
592
+ });
593
+
594
+ /**
595
+ * @param {Array<{ selector: string, kind: string, rank: number, level: string, score: number, el?: Element }>} rows
596
+ */
597
+ export function groupIssues(rows) {
598
+ const map = new Map();
599
+ for (let i = 0; i < rows.length; i++) {
600
+ const row = rows[i];
601
+ const kind = row.kind || row.text;
602
+ const key = `${row.selector}\0${kind}`;
603
+ const prev = map.get(key);
604
+ if (!prev) {
605
+ map.set(key, { ...row, kind, count: 1 });
606
+ continue;
607
+ }
608
+ prev.count += 1;
609
+ if (row.score > prev.score) {
610
+ prev.score = row.score;
611
+ prev.el = row.el;
612
+ }
613
+ }
614
+ return [...map.values()].sort((a, b) => a.rank - b.rank || b.score - a.score || b.count - a.count);
615
+ }
616
+
617
+ export function formatIssue(group) {
618
+ const count = group.count > 1 ? ` ×${group.count}` : '';
619
+ return `${group.selector} ${group.kind}${count}`;
620
+ }
621
+
418
622
  function pushInvisible(el, findings) {
419
623
  if (!hasOwnText(el)) return;
420
624
  const style = getComputedStyle(el);
421
625
  if (opacityZero(el)) {
422
- findings.push({ level: 'error', score: 30, text: 'invisible text (opacity 0)' });
626
+ findings.push(issue('invisible text (opacity 0)', ISSUE_RANK.invisible, 30));
423
627
  }
424
628
  const color = parseColor(style.color);
425
629
  if (!color || color.a === 0) {
426
- findings.push({ level: 'error', score: 30, text: 'invisible text (transparent color)' });
630
+ findings.push(issue('invisible text (transparent color)', ISSUE_RANK.invisible, 30));
427
631
  return;
428
632
  }
429
633
  const behind = paintBehind(el);
430
634
  if (behind && nearlySame(color, behind)) {
431
- findings.push({ level: 'error', score: 30, text: 'invisible text (color matches background)' });
635
+ findings.push(issue('invisible text (color matches background)', ISSUE_RANK.invisible, 30));
432
636
  }
433
637
  }
434
638
 
@@ -440,33 +644,111 @@ function pushBlockers(el, findings) {
440
644
  if (x < 0 || y < 0 || x > window.innerWidth || y > window.innerHeight) return;
441
645
  const pseudo = coveringPseudo(x, y);
442
646
  if (pseudo) {
443
- findings.push({ level: 'error', score: 40, text: `click blocked by ${pseudo}` });
647
+ findings.push(issue(`click blocked by ${pseudo}`, ISSUE_RANK.blocker, 40));
444
648
  }
445
649
  const hit = document.elementFromPoint(x, y);
446
650
  if (!hit || isBeeChrome(hit) || hit === el || el.contains(hit)) return;
447
651
  if (pseudo && (hit === document.documentElement || hit === document.body)) return;
448
- findings.push({ level: 'error', score: 40, text: `click blocked by ${cssPath(hit)}` });
652
+ findings.push(issue(`click blocked by ${cssPath(hit)}`, ISSUE_RANK.blocker, 40));
449
653
  }
450
654
 
451
655
  function pushImage(el, findings) {
452
656
  if (!el || el.tagName !== 'IMG') return;
453
657
  const naturalW = el.naturalWidth;
454
658
  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;
659
+ if (!naturalW || !naturalH || typeof el.getBoundingClientRect !== 'function') return;
660
+ const rect = el.getBoundingClientRect();
661
+ const fit = (typeof getComputedStyle === 'function' ? getComputedStyle(el).objectFit : '') || 'fill';
662
+ if (!isDistortedImage(naturalW, naturalH, rect.width, rect.height, fit)) return;
663
+ const gap = renderedRatioGap(naturalW, naturalH, rect.width, rect.height);
463
664
  findings.push({
464
665
  level: 'error',
465
- score: diff * 100,
466
- text: `distorted image ${Math.round(diff * 100)}% (object-fit: fill)`
666
+ rank: ISSUE_RANK.image,
667
+ score: gap * 100,
668
+ kind: 'distorted image',
669
+ text: `distorted image ${Math.round(gap * 100)}% (object-fit: fill)`
467
670
  });
468
671
  }
469
672
 
673
+ /**
674
+ * Rendered box versus the file. Attributes and clientWidth are ignored.
675
+ * `object-fit` defaults to fill. Flag when the ratios differ by more than 5%.
676
+ */
677
+ export function isDistortedImage(naturalW, naturalH, renderedW, renderedH, objectFit) {
678
+ const fit = objectFit || 'fill';
679
+ if (fit !== 'fill') return false;
680
+ return renderedRatioGap(naturalW, naturalH, renderedW, renderedH) > 0.05;
681
+ }
682
+
683
+ export function renderedRatioGap(naturalW, naturalH, renderedW, renderedH) {
684
+ if (!naturalW || !naturalH || !renderedW || !renderedH) return 0;
685
+ const natural = naturalW / naturalH;
686
+ const rendered = renderedW / renderedH;
687
+ return Math.abs(natural - rendered) / natural;
688
+ }
689
+
690
+ /** @returns {boolean} true when this issue should be written to the event list */
691
+ export function rememberIssue(seen, key, now, windowMs = 5000) {
692
+ const prev = seen.get(key);
693
+ if (typeof prev === 'number' && now - prev < windowMs) return false;
694
+ seen.set(key, now);
695
+ if (seen.size > 64) {
696
+ for (const [name, at] of seen) {
697
+ if (now - at >= windowMs) seen.delete(name);
698
+ }
699
+ }
700
+ return true;
701
+ }
702
+
703
+ /**
704
+ * Newest first, duplicates move to the top, capped at `max`.
705
+ * @param {Array<{ text: string, el?: Element }>} list
706
+ * @param {{ text: string, el?: Element }} entry
707
+ */
708
+ export function pushStickyIssue(list, entry, max = 10) {
709
+ const next = [entry];
710
+ for (let i = 0; i < list.length; i++) {
711
+ if (list[i].text === entry.text) continue;
712
+ next.push(list[i]);
713
+ if (next.length >= max) break;
714
+ }
715
+ return next.slice(0, max);
716
+ }
717
+
718
+ function isStickyKind(kind) {
719
+ return kind === 'overflows viewport' || (typeof kind === 'string' && kind.startsWith('click blocked'));
720
+ }
721
+
722
+ function elementCausesOverflow(el) {
723
+ const self = measureViewportOverflow(el);
724
+ if (!self) return false;
725
+ const items = [self];
726
+ if (typeof el.querySelectorAll === 'function') {
727
+ const nodes = el.querySelectorAll('*');
728
+ for (let i = 0; i < nodes.length; i++) {
729
+ const measured = measureViewportOverflow(nodes[i]);
730
+ if (measured) items.push(measured);
731
+ }
732
+ }
733
+ const causes = keepOverflowCauses(items);
734
+ for (let i = 0; i < causes.length; i++) {
735
+ if (causes[i].el === el) return true;
736
+ }
737
+ return false;
738
+ }
739
+
740
+ function nowMs() {
741
+ return typeof performance !== 'undefined' ? performance.now() : Date.now();
742
+ }
743
+
744
+ function issue(kind, rank, score) {
745
+ return { level: 'error', rank, score, kind, text: kind };
746
+ }
747
+
748
+ function isVerbose(core) {
749
+ return Boolean(core) && !core.frozen && core.timeScale !== 1;
750
+ }
751
+
470
752
  function coveringPseudo(x, y) {
471
753
  const hosts = [document.documentElement, document.body];
472
754
  const names = ['::before', '::after'];
@@ -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.0';
12
+ export const BEE_LADYBUG_VERSION = '0.5.2';
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.slice(0, 10) : [];
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;
@@ -263,6 +263,35 @@ const STYLES = `
263
263
  .bl-hotkeys:empty {
264
264
  display: none;
265
265
  }
266
+ .bl-issues {
267
+ overflow: hidden;
268
+ background: #07080c;
269
+ border-top: 1px solid rgba(255, 59, 78, 0.55);
270
+ }
271
+ .bl-issues[hidden] {
272
+ display: none;
273
+ }
274
+ .bl-issues-label {
275
+ padding: 5px 10px 0;
276
+ color: #ff3b4e;
277
+ font-size: 9px;
278
+ font-weight: 700;
279
+ letter-spacing: 0.14em;
280
+ }
281
+ .bl-issues button {
282
+ display: block;
283
+ width: 100%;
284
+ margin: 0;
285
+ padding: 3px 10px;
286
+ border: 0;
287
+ background: transparent;
288
+ color: #ff6b7d;
289
+ font: 11px ui-monospace, Consolas, monospace;
290
+ line-height: 1.4;
291
+ text-align: left;
292
+ cursor: pointer;
293
+ }
294
+ .bl-issues button:hover { background: rgba(255, 59, 78, 0.08); }
266
295
  .bl-detail {
267
296
  max-height: 132px;
268
297
  overflow: auto;
@@ -366,6 +395,7 @@ export class UIOverlay {
366
395
  this.#adapterSig = '';
367
396
  this.#hotkeySig = '';
368
397
  this.#detail = [];
398
+ this.#issues = [];
369
399
  this.#flashAt = new Map();
370
400
  this.#flashTimers = new Map();
371
401
  }
@@ -380,6 +410,7 @@ export class UIOverlay {
380
410
  #adapterSig;
381
411
  #hotkeySig;
382
412
  #detail;
413
+ #issues;
383
414
  #flashAt;
384
415
  #flashTimers;
385
416
 
@@ -430,6 +461,10 @@ export class UIOverlay {
430
461
  </div>
431
462
  </div>
432
463
  <div class="bl-hud" data-el="hud"></div>
464
+ <div class="bl-issues" data-el="issues" hidden>
465
+ <div class="bl-issues-label">ISSUES</div>
466
+ <div data-el="issues-list"></div>
467
+ </div>
433
468
  <div class="bl-detail" data-el="detail" hidden></div>
434
469
  <div class="bl-console" data-el="console" role="log" aria-live="polite"></div>
435
470
  <div class="bl-foot">
@@ -459,6 +494,8 @@ export class UIOverlay {
459
494
  hud: this.#root.querySelector('[data-el="hud"]'),
460
495
  console: this.#root.querySelector('[data-el="console"]'),
461
496
  detail: this.#root.querySelector('[data-el="detail"]'),
497
+ issues: this.#root.querySelector('[data-el="issues"]'),
498
+ issuesList: this.#root.querySelector('[data-el="issues-list"]'),
462
499
  providers: this.#root.querySelector('[data-el="providers"]'),
463
500
  adapters: this.#root.querySelector('[data-el="adapters"]'),
464
501
  hotkeys: this.#root.querySelector('[data-el="hotkeys"]')
@@ -476,6 +513,13 @@ export class UIOverlay {
476
513
  else if (act === 'adapter') this.core.toggleAdapter(btn.getAttribute('data-name'));
477
514
  });
478
515
 
516
+ this.#root.querySelector('.bl-issues')?.addEventListener('click', (event) => {
517
+ const row = event.target.closest('[data-issue]');
518
+ if (!row) return;
519
+ const item = this.#issues[Number(row.getAttribute('data-issue'))];
520
+ if (item && typeof item.onSelect === 'function') item.onSelect();
521
+ });
522
+
479
523
  this.#root.querySelector('.bl-detail')?.addEventListener('click', (event) => {
480
524
  const row = event.target.closest('[data-detail]');
481
525
  if (!row) return;
@@ -506,6 +550,7 @@ export class UIOverlay {
506
550
  this.#adapterSig = '';
507
551
  this.#hotkeySig = '';
508
552
  this.#detail = [];
553
+ this.#issues = [];
509
554
  this.#clearFlashTimers();
510
555
  this.#flashAt.clear();
511
556
  return this;
@@ -546,6 +591,29 @@ export class UIOverlay {
546
591
  return this;
547
592
  }
548
593
 
594
+ /**
595
+ * Red warnings pinned above the scrolling console. Newest first.
596
+ * @param {Array<{ text: string, level?: string, onSelect?: () => void }>} items
597
+ */
598
+ setIssues(items) {
599
+ if (!this.#els?.issues || !this.#els.issuesList) return this;
600
+ const list = Array.isArray(items) ? items.slice(0, 10) : [];
601
+ this.#issues = list;
602
+ const box = this.#els.issues;
603
+ if (!list.length) {
604
+ this.#els.issuesList.innerHTML = '';
605
+ box.hidden = true;
606
+ return this;
607
+ }
608
+ let html = '';
609
+ for (let i = 0; i < list.length; i++) {
610
+ html += `<button type="button" data-issue="${i}">${escapeHtml(list[i].text)}</button>`;
611
+ }
612
+ box.hidden = false;
613
+ this.#els.issuesList.innerHTML = html;
614
+ return this;
615
+ }
616
+
549
617
  onPacket(packet) {
550
618
  if (!this.#els || !packet) return;
551
619
  if (packet.type === 'sys') return;
package/src/index.d.ts CHANGED
@@ -178,6 +178,7 @@ 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 {
@@ -197,6 +198,7 @@ export class UIOverlay {
197
198
  onClear(channel?: string): void;
198
199
  render(): void;
199
200
  setDetail(items: DetailItem[]): this;
201
+ setIssues(items: DetailItem[]): this;
200
202
  }
201
203
 
202
204
  export interface CanvasEntity {
@@ -287,8 +289,11 @@ export interface CSSAdapterOptions {
287
289
  source?: string;
288
290
  hotkey?: string;
289
291
  scanKey?: string;
292
+ pinKey?: string;
290
293
  label?: string;
291
294
  maxScan?: number;
295
+ issueWindow?: number;
296
+ maxIssues?: number;
292
297
  }
293
298
 
294
299
  export const CSS_ADAPTER_DEFAULTS: Required<CSSAdapterOptions>;
@@ -298,8 +303,11 @@ export class CSSAdapter {
298
303
  source: string;
299
304
  hotkey: string;
300
305
  scanKey: string;
306
+ pinKey: string;
301
307
  label: string;
302
308
  maxScan: number;
309
+ issueWindow: number;
310
+ maxIssues: number;
303
311
  readonly active: boolean;
304
312
  constructor(core: BeeLadybugCore, options?: CSSAdapterOptions);
305
313
  attach(): this;