bee-ladybug 0.5.2 → 0.5.4

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
@@ -195,13 +195,14 @@ css.attach();
195
195
 
196
196
  `F6` (pulsante **F6 CSS** accanto a F3/F4) accende la caccia. Sull'elemento sotto il puntatore l'overlay elenca:
197
197
 
198
- - 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>`
199
199
  - overflow del viewport e margini negativi
200
200
  - testo trasparente, `opacity: 0`, o colore uguale allo sfondo
201
201
  - chi intercetta il click al centro, inclusi `::before` / `::after` fissi su `html` / `body`
202
+ - un antenato che crea uno stacking context (`transform`, `opacity` sotto 1, `filter`, oppure `position` non static con `z-index`). I figli si raggruppano sulla causa: `div.hero creates stacking context (transform) - z-index of 3 children is trapped`. `will-change: transform` o `opacity` è una riga grigia, con la nota sullo `z-index` sulla stessa riga
202
203
  - `<img>` con rapporto del file diverso di oltre il 5% dal riquadro disegnato (`getBoundingClientRect`), solo se `object-fit` calcolato è `fill` (il default)
203
204
 
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.
205
+ `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.
205
206
 
206
207
  Demo: `examples/css-hunt.html`.
207
208
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bee-ladybug",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
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",
@@ -42,7 +42,6 @@ export class CSSAdapter {
42
42
  this.#attached = false;
43
43
  this.#active = false;
44
44
  this.#rules = [];
45
- this.#unreadable = [];
46
45
  this.#sheetsDirty = true;
47
46
  this.#x = 0;
48
47
  this.#y = 0;
@@ -51,6 +50,8 @@ export class CSSAdapter {
51
50
  this.#pinned = null;
52
51
  this.#panelLock = false;
53
52
  this.#issues = [];
53
+ this.#notes = [];
54
+ this.#seenSheets = new Set();
54
55
  this.#issueLog = new Map();
55
56
  this.#hoverBox = null;
56
57
  this.#pinBox = null;
@@ -72,7 +73,6 @@ export class CSSAdapter {
72
73
  #attached;
73
74
  #active;
74
75
  #rules;
75
- #unreadable;
76
76
  #sheetsDirty;
77
77
  #x;
78
78
  #y;
@@ -81,6 +81,8 @@ export class CSSAdapter {
81
81
  #pinned;
82
82
  #panelLock;
83
83
  #issues;
84
+ #notes;
85
+ #seenSheets;
84
86
  #issueLog;
85
87
  #hoverBox;
86
88
  #pinBox;
@@ -141,6 +143,7 @@ export class CSSAdapter {
141
143
  pushInvisible(el, findings);
142
144
  pushBlockers(el, findings);
143
145
  pushImage(el, findings);
146
+ pushStacking(el, findings);
144
147
  pushImportant(el, this.#rules, findings);
145
148
  for (let j = 0; j < findings.length; j++) {
146
149
  rows.push({ el, selector: cssPath(el), ...findings[j] });
@@ -159,7 +162,11 @@ export class CSSAdapter {
159
162
  });
160
163
  }
161
164
  const groups = groupIssues(rows);
162
- for (let i = groups.length - 1; i >= 0; i--) this.#stickGroup(groups[i]);
165
+ for (let i = 0; i < rows.length; i++) {
166
+ const row = rows[i];
167
+ this.#rememberCause(row.el, row.selector, row.kind);
168
+ }
169
+ this.#publishIssues();
163
170
  const shown = groups.slice(0, this.maxScan);
164
171
  const items = shown.map((group) => ({
165
172
  level: group.level,
@@ -228,6 +235,8 @@ export class CSSAdapter {
228
235
  this.#pinned = null;
229
236
  this.#panelLock = false;
230
237
  this.#issues = [];
238
+ this.#notes = [];
239
+ this.#seenSheets.clear();
231
240
  this.#issueLog.clear();
232
241
  this.#unmountBoxes();
233
242
  this.core?.publishDetail([]);
@@ -264,13 +273,8 @@ export class CSSAdapter {
264
273
  this.#sheetsDirty = false;
265
274
  const built = collectImportantRules();
266
275
  this.#rules = built.rules;
267
- this.#unreadable = built.unreadable;
268
- for (let i = 0; i < built.unreadable.length; i++) {
269
- this.core?.sendData('warn', {
270
- source: this.source,
271
- message: `unreadable sheet: ${built.unreadable[i]}`
272
- });
273
- }
276
+ for (let i = 0; i < built.unreadable.length; i++) this.#rememberSheet(built.unreadable[i]);
277
+ this.#publishIssues();
274
278
  }
275
279
 
276
280
  #inspect(x, y) {
@@ -290,13 +294,7 @@ export class CSSAdapter {
290
294
  pushInvisible(el, findings);
291
295
  pushBlockers(el, findings);
292
296
  pushImage(el, findings);
293
- for (let i = 0; i < this.#unreadable.length; i++) {
294
- findings.push({
295
- level: 'warn',
296
- text: `unreadable sheet: ${this.#unreadable[i]}`,
297
- score: 0
298
- });
299
- }
297
+ pushStacking(el, findings);
300
298
  const path = cssPath(el);
301
299
  this.core?.sendData('state', { source: this.source, key: 'css.target', value: path });
302
300
  this.core?.sendData('state', {
@@ -344,7 +342,7 @@ export class CSSAdapter {
344
342
  for (let i = 0; i < findings.length; i++) {
345
343
  const hit = findings[i];
346
344
  const text = hit.text || hit.kind || '';
347
- if (!text) continue;
345
+ if (!text || text.startsWith('unreadable sheet:') || hit.level === 'info') continue;
348
346
  if (!rememberIssue(this.#issueLog, `${path}\0${text}`, now, this.issueWindow)) continue;
349
347
  const level = hit.level === 'error' ? 'error' : 'warn';
350
348
  this.core?.sendData(level, {
@@ -355,34 +353,55 @@ export class CSSAdapter {
355
353
  }
356
354
 
357
355
  #stickFindings(el, path, findings) {
356
+ let changed = false;
358
357
  for (let i = 0; i < findings.length; i++) {
359
358
  const hit = findings[i];
360
- if (!isStickyKind(hit.kind)) continue;
361
359
  if (hit.kind === 'overflows viewport' && !elementCausesOverflow(el)) continue;
362
- this.#issues = pushStickyIssue(this.#issues, {
363
- text: `${path} ${hit.kind}`,
364
- el
365
- }, this.maxIssues);
360
+ if (this.#rememberCause(el, path, hit.kind)) changed = true;
366
361
  }
367
- this.#publishIssues();
362
+ if (changed) this.#publishIssues();
368
363
  }
369
364
 
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();
365
+ #rememberCause(el, path, kind) {
366
+ const next = addCause(this.#issues, { kind, path, el }, this.maxIssues);
367
+ if (next === this.#issues) return false;
368
+ this.#issues = next;
369
+ return true;
370
+ }
371
+
372
+ #rememberSheet(href) {
373
+ const label = String(href || '');
374
+ if (!label || this.#seenSheets.has(label)) return;
375
+ this.#seenSheets.add(label);
376
+ this.#notes.push({ text: `unreadable sheet: ${label}` });
377
+ this.core?.sendData('log', {
378
+ source: this.source,
379
+ level: 'info',
380
+ message: `unreadable sheet: ${label}`
381
+ });
378
382
  }
379
383
 
380
384
  #publishIssues() {
381
- this.core?.publishIssues(this.#issues.map((item) => ({
382
- level: 'error',
383
- text: item.text,
384
- onSelect: () => this.#pin(item.el)
385
- })));
385
+ const bugs = [];
386
+ const notes = this.#notes.map((note) => ({
387
+ level: 'info',
388
+ key: note.text,
389
+ text: note.text
390
+ }));
391
+ for (let i = 0; i < this.#issues.length; i++) {
392
+ const entry = this.#issues[i];
393
+ const item = {
394
+ key: entry.key,
395
+ text: formatCause(entry),
396
+ targets: entry.targets.map((target) => ({
397
+ text: target.text,
398
+ onSelect: () => this.#pin(target.el)
399
+ }))
400
+ };
401
+ if (entry.level === 'info') notes.push({ ...item, level: 'info' });
402
+ else bugs.push({ ...item, level: 'error' });
403
+ }
404
+ this.core?.publishIssues(bugs.concat(notes));
386
405
  }
387
406
 
388
407
  #pin(el) {
@@ -586,9 +605,11 @@ function pushNegativeMargins(el, findings) {
586
605
  const ISSUE_RANK = Object.freeze({
587
606
  overflow: 0,
588
607
  blocker: 1,
608
+ stack: 1,
589
609
  invisible: 2,
590
610
  image: 3,
591
- important: 4
611
+ important: 4,
612
+ hint: 6
592
613
  });
593
614
 
594
615
  /**
@@ -715,8 +736,165 @@ export function pushStickyIssue(list, entry, max = 10) {
715
736
  return next.slice(0, max);
716
737
  }
717
738
 
718
- function isStickyKind(kind) {
719
- return kind === 'overflows viewport' || (typeof kind === 'string' && kind.startsWith('click blocked'));
739
+ export function describeCause(kind, path) {
740
+ if (kind === 'overflows viewport') {
741
+ return { key: `overflow\0${path}`, type: 'overflow', cause: path, reason: '', level: 'error' };
742
+ }
743
+ const prefix = 'click blocked by ';
744
+ if (typeof kind === 'string' && kind.startsWith(prefix)) {
745
+ const cause = kind.slice(prefix.length);
746
+ return { key: `block\0${cause}`, type: 'block', cause, reason: '', level: 'error' };
747
+ }
748
+ const stack = typeof kind === 'string' ? kind.match(/^stacking context \(([^)]+)\) by (.+)$/) : null;
749
+ if (stack) {
750
+ return {
751
+ key: `stack\0${stack[2]}\0${stack[1]}`,
752
+ type: 'stack',
753
+ cause: stack[2],
754
+ reason: stack[1],
755
+ level: 'error'
756
+ };
757
+ }
758
+ const hint = typeof kind === 'string' ? kind.match(/^will-change (.+?) by (.+)$/) : null;
759
+ if (hint) {
760
+ return {
761
+ key: `will\0${hint[2]}\0${hint[1]}`,
762
+ type: 'stack-hint',
763
+ cause: hint[2],
764
+ reason: hint[1],
765
+ level: 'info'
766
+ };
767
+ }
768
+ return null;
769
+ }
770
+
771
+ /**
772
+ * One row per cause. A blocker that covers many nodes stays a single row.
773
+ * @param {Array<{ key: string, type: string, cause: string, targets: Array<{ text: string, el?: Element }> }>} list
774
+ * @param {{ kind?: string, path: string, el?: Element }} entry
775
+ */
776
+ export function addCause(list, entry, max = 10) {
777
+ const described = describeCause(entry.kind, entry.path);
778
+ if (!described) return list;
779
+ const target = { text: entry.path, el: entry.el };
780
+ const rest = [];
781
+ let prev = null;
782
+ for (let i = 0; i < list.length; i++) {
783
+ if (list[i].key === described.key) prev = list[i];
784
+ else rest.push(list[i]);
785
+ }
786
+ const targets = prev ? prev.targets.filter((item) => !sameTarget(item, target)) : [];
787
+ targets.unshift(target);
788
+ rest.unshift({
789
+ key: described.key,
790
+ type: described.type,
791
+ cause: described.cause,
792
+ reason: described.reason || '',
793
+ level: described.level || 'error',
794
+ targets
795
+ });
796
+ return rest.slice(0, max);
797
+ }
798
+
799
+ export function formatCause(entry) {
800
+ const count = entry.targets.length;
801
+ if (entry.type === 'block') {
802
+ const noun = count === 1 ? 'element' : 'elements';
803
+ return `${entry.cause} blocks clicks on ${count} ${noun}`;
804
+ }
805
+ if (entry.type === 'stack') {
806
+ const noun = count === 1 ? 'child' : 'children';
807
+ return `${entry.cause} creates stacking context (${entry.reason}) - z-index of ${count} ${noun} is trapped`;
808
+ }
809
+ if (entry.type === 'stack-hint') {
810
+ const noun = count === 1 ? 'child' : 'children';
811
+ return `${entry.cause} will-change: ${entry.reason} — z-index of ${count} ${noun} stays in this layer`;
812
+ }
813
+ if (count <= 1) return `${entry.cause} overflows viewport`;
814
+ return `${entry.cause} overflows viewport · ${count} elements`;
815
+ }
816
+
817
+ /**
818
+ * Properties that create a stacking context. `will-change` for transform or
819
+ * opacity is only a hint: it may promote a layer, it is not reported as a bug.
820
+ * @param {{ transform?: string, opacity?: string, filter?: string, position?: string, zIndex?: string, willChange?: string, getPropertyValue?: (name: string) => string }} style
821
+ */
822
+ export function stackingReasons(style) {
823
+ const read = (prop, camel) => {
824
+ if (style && typeof style.getPropertyValue === 'function') {
825
+ const raw = style.getPropertyValue(prop);
826
+ if (raw) return String(raw).trim();
827
+ }
828
+ if (style && style[camel] != null && style[camel] !== '') return String(style[camel]).trim();
829
+ return '';
830
+ };
831
+ const bugs = [];
832
+ const transform = read('transform', 'transform');
833
+ if (transform && transform !== 'none') bugs.push('transform');
834
+ const opacity = parseFloat(read('opacity', 'opacity'));
835
+ if (Number.isFinite(opacity) && opacity < 1) bugs.push('opacity');
836
+ const filter = read('filter', 'filter');
837
+ if (filter && filter !== 'none') bugs.push('filter');
838
+ const position = read('position', 'position') || 'static';
839
+ const zIndex = read('z-index', 'zIndex') || 'auto';
840
+ if (position !== 'static' && zIndex !== 'auto') bugs.push('z-index');
841
+ const hint = willChangeHint(read('will-change', 'willChange'));
842
+ return { bugs, hint };
843
+ }
844
+
845
+ function willChangeHint(value) {
846
+ if (!value || value === 'auto') return '';
847
+ const parts = value.split(',').map((part) => part.trim().toLowerCase());
848
+ const hit = [];
849
+ for (let i = 0; i < parts.length; i++) {
850
+ if ((parts[i] === 'transform' || parts[i] === 'opacity') && !hit.includes(parts[i])) hit.push(parts[i]);
851
+ }
852
+ return hit.join(', ');
853
+ }
854
+
855
+ function pushStacking(el, findings) {
856
+ if (!el || typeof getComputedStyle !== 'function' || !el.parentElement) return;
857
+ const own = getComputedStyle(el);
858
+ const ownZ = own.zIndex || own.getPropertyValue?.('z-index') || 'auto';
859
+ if (!ownZ || ownZ === 'auto') return;
860
+ let node = el.parentElement;
861
+ while (node && node.nodeType === 1) {
862
+ if (isBeeChrome(node)) return;
863
+ const found = stackingReasons(getComputedStyle(node));
864
+ const ancestor = cssPath(node);
865
+ if (found.bugs.length) {
866
+ const reason = found.bugs[0];
867
+ findings.push({
868
+ level: 'error',
869
+ rank: ISSUE_RANK.stack,
870
+ score: 35,
871
+ kind: `stacking context (${reason}) by ${ancestor}`,
872
+ text: `z-index trapped by ${ancestor} (${reason})`
873
+ });
874
+ if (found.hint) findings.push(stackHint(ancestor, found.hint));
875
+ return;
876
+ }
877
+ if (found.hint) {
878
+ findings.push(stackHint(ancestor, found.hint));
879
+ return;
880
+ }
881
+ node = node.parentElement;
882
+ }
883
+ }
884
+
885
+ function stackHint(ancestor, hint) {
886
+ return {
887
+ level: 'info',
888
+ rank: ISSUE_RANK.hint,
889
+ score: 1,
890
+ kind: `will-change ${hint} by ${ancestor}`,
891
+ text: `will-change: ${hint} on ${ancestor}`
892
+ };
893
+ }
894
+
895
+ function sameTarget(a, b) {
896
+ if (a.el && b.el) return a.el === b.el;
897
+ return a.text === b.text;
720
898
  }
721
899
 
722
900
  function elementCausesOverflow(el) {
@@ -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.2';
12
+ export const BEE_LADYBUG_VERSION = '0.5.4';
13
13
 
14
14
  export const CORE_DEFAULTS = Object.freeze({
15
15
  toggleKey: 'F2',
@@ -364,7 +364,7 @@ export class BeeLadybugCore {
364
364
  * @param {Array<{ text: string, level?: string, onSelect?: () => void }>} items
365
365
  */
366
366
  publishIssues(items) {
367
- const list = Array.isArray(items) ? items.slice(0, 10) : [];
367
+ const list = Array.isArray(items) ? items : [];
368
368
  this.#issues = list;
369
369
  this.ui?.setIssues(this.#issues);
370
370
  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,35 +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; }
266
294
  .bl-issues {
267
- overflow: hidden;
268
295
  background: #07080c;
269
- border-top: 1px solid rgba(255, 59, 78, 0.55);
270
296
  }
271
- .bl-issues[hidden] {
272
- display: none;
297
+ .bl-legend {
298
+ display: flex;
299
+ gap: 14px;
300
+ padding: 4px 10px 2px;
301
+ color: #8b91a3;
302
+ font-size: 10px;
273
303
  }
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;
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;
280
311
  }
281
- .bl-issues button {
312
+ .bl-dot.bug { background: #ff3b4e; }
313
+ .bl-dot.info { background: #8b91a3; }
314
+ .bl-cause > button,
315
+ .bl-cause-kids button,
316
+ .bl-note {
282
317
  display: block;
283
318
  width: 100%;
284
319
  margin: 0;
285
320
  padding: 3px 10px;
286
321
  border: 0;
287
322
  background: transparent;
288
- color: #ff6b7d;
289
323
  font: 11px ui-monospace, Consolas, monospace;
290
324
  line-height: 1.4;
291
325
  text-align: left;
326
+ }
327
+ .bl-cause > button,
328
+ .bl-cause-kids button {
329
+ color: #ff6b7d;
292
330
  cursor: pointer;
293
331
  }
294
- .bl-issues button:hover { background: rgba(255, 59, 78, 0.08); }
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; }
295
337
  .bl-detail {
296
338
  max-height: 132px;
297
339
  overflow: auto;
@@ -316,6 +358,7 @@ const STYLES = `
316
358
  }
317
359
  .bl-detail button[data-level="warn"] { color: #ffd36a; }
318
360
  .bl-detail button[data-level="error"] { color: #ff6b7d; }
361
+ .bl-detail button[data-level="info"] { color: #8b91a3; }
319
362
  .bl-detail button:hover { background: rgba(255, 255, 255, 0.04); }
320
363
  .bl-adapter {
321
364
  appearance: none;
@@ -396,6 +439,8 @@ export class UIOverlay {
396
439
  this.#hotkeySig = '';
397
440
  this.#detail = [];
398
441
  this.#issues = [];
442
+ this.#issueNotes = [];
443
+ this.#openCauses = new Set();
399
444
  this.#flashAt = new Map();
400
445
  this.#flashTimers = new Map();
401
446
  }
@@ -411,6 +456,8 @@ export class UIOverlay {
411
456
  #hotkeySig;
412
457
  #detail;
413
458
  #issues;
459
+ #issueNotes;
460
+ #openCauses;
414
461
  #flashAt;
415
462
  #flashTimers;
416
463
 
@@ -447,6 +494,10 @@ export class UIOverlay {
447
494
  </button>
448
495
  </span>
449
496
  </div>
497
+ <div class="bl-scroll">
498
+ <section class="bl-section" data-section="status" data-open="true">
499
+ <button class="bl-section-toggle" type="button" data-section-toggle aria-expanded="true">STATUS</button>
500
+ <div class="bl-section-body">
450
501
  <div class="bl-stats">
451
502
  <div>HOST <b data-el="hostFps">—</b></div>
452
503
  <div>CORE <b data-el="coreFps">—</b></div>
@@ -461,12 +512,32 @@ export class UIOverlay {
461
512
  </div>
462
513
  </div>
463
514
  <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>
515
+ </div>
516
+ </section>
517
+ <section class="bl-section" data-el="issues" data-section="issues" data-open="true" hidden>
518
+ <button class="bl-section-toggle" type="button" data-section-toggle aria-expanded="true">ISSUES</button>
519
+ <div class="bl-section-body">
520
+ <div class="bl-legend">
521
+ <span><i class="bl-dot bug"></i>bug</span>
522
+ <span><i class="bl-dot info"></i>info</span>
523
+ </div>
524
+ <div data-el="issues-list"></div>
525
+ <div data-el="notes"></div>
526
+ </div>
527
+ </section>
528
+ <section class="bl-section" data-el="inspect" data-section="inspect" data-open="true" hidden>
529
+ <button class="bl-section-toggle" type="button" data-section-toggle aria-expanded="true">INSPECT</button>
530
+ <div class="bl-section-body">
531
+ <div class="bl-detail" data-el="detail" hidden></div>
532
+ </div>
533
+ </section>
534
+ <section class="bl-section" data-section="events" data-open="true">
535
+ <button class="bl-section-toggle" type="button" data-section-toggle aria-expanded="true">EVENTS</button>
536
+ <div class="bl-section-body">
537
+ <div class="bl-console" data-el="console" role="log" aria-live="polite"></div>
538
+ </div>
539
+ </section>
467
540
  </div>
468
- <div class="bl-detail" data-el="detail" hidden></div>
469
- <div class="bl-console" data-el="console" role="log" aria-live="polite"></div>
470
541
  <div class="bl-foot">
471
542
  <button class="bl-btn" data-act="slow" type="button">F3 SLOW</button>
472
543
  <button class="bl-btn" data-act="freeze" type="button">F4 STOP</button>
@@ -494,8 +565,10 @@ export class UIOverlay {
494
565
  hud: this.#root.querySelector('[data-el="hud"]'),
495
566
  console: this.#root.querySelector('[data-el="console"]'),
496
567
  detail: this.#root.querySelector('[data-el="detail"]'),
568
+ inspect: this.#root.querySelector('[data-el="inspect"]'),
497
569
  issues: this.#root.querySelector('[data-el="issues"]'),
498
570
  issuesList: this.#root.querySelector('[data-el="issues-list"]'),
571
+ notes: this.#root.querySelector('[data-el="notes"]'),
499
572
  providers: this.#root.querySelector('[data-el="providers"]'),
500
573
  adapters: this.#root.querySelector('[data-el="adapters"]'),
501
574
  hotkeys: this.#root.querySelector('[data-el="hotkeys"]')
@@ -513,11 +586,40 @@ export class UIOverlay {
513
586
  else if (act === 'adapter') this.core.toggleAdapter(btn.getAttribute('data-name'));
514
587
  });
515
588
 
516
- this.#root.querySelector('.bl-issues')?.addEventListener('click', (event) => {
589
+ this.#root.querySelector('.bl-scroll')?.addEventListener('click', (event) => {
590
+ const toggle = event.target.closest('[data-section-toggle]');
591
+ if (!toggle) return;
592
+ const section = toggle.closest('.bl-section');
593
+ if (!section) return;
594
+ const open = section.dataset.open === 'false';
595
+ section.dataset.open = open ? 'true' : 'false';
596
+ toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
597
+ });
598
+
599
+ this.#els.issues?.addEventListener('click', (event) => {
600
+ if (event.target.closest('[data-section-toggle]')) return;
601
+ const childBtn = event.target.closest('[data-target]');
602
+ if (childBtn) {
603
+ const item = this.#issues[Number(childBtn.getAttribute('data-issue'))];
604
+ const child = item?.targets?.[Number(childBtn.getAttribute('data-target'))];
605
+ if (child && typeof child.onSelect === 'function') child.onSelect();
606
+ return;
607
+ }
517
608
  const row = event.target.closest('[data-issue]');
518
609
  if (!row) return;
519
610
  const item = this.#issues[Number(row.getAttribute('data-issue'))];
520
- if (item && typeof item.onSelect === 'function') item.onSelect();
611
+ if (!item) return;
612
+ const many = Array.isArray(item.targets) && item.targets.length > 1;
613
+ if (many) {
614
+ const key = item.key || item.text;
615
+ if (this.#openCauses.has(key)) this.#openCauses.delete(key);
616
+ else this.#openCauses.add(key);
617
+ this.setIssues(this.#issues.concat(this.#issueNotes));
618
+ return;
619
+ }
620
+ const only = item.targets?.[0];
621
+ if (only && typeof only.onSelect === 'function') only.onSelect();
622
+ else if (typeof item.onSelect === 'function') item.onSelect();
521
623
  });
522
624
 
523
625
  this.#root.querySelector('.bl-detail')?.addEventListener('click', (event) => {
@@ -551,6 +653,8 @@ export class UIOverlay {
551
653
  this.#hotkeySig = '';
552
654
  this.#detail = [];
553
655
  this.#issues = [];
656
+ this.#issueNotes = [];
657
+ this.#openCauses.clear();
554
658
  this.#clearFlashTimers();
555
659
  this.#flashAt.clear();
556
660
  return this;
@@ -576,9 +680,11 @@ export class UIOverlay {
576
680
  const list = Array.isArray(items) ? items : [];
577
681
  this.#detail = list;
578
682
  const box = this.#els.detail;
683
+ const section = this.#els.inspect;
579
684
  if (!list.length) {
580
685
  box.innerHTML = '';
581
686
  box.hidden = true;
687
+ if (section) section.hidden = true;
582
688
  return this;
583
689
  }
584
690
  let html = '';
@@ -587,30 +693,58 @@ export class UIOverlay {
587
693
  html += `<button type="button" data-detail="${i}" data-level="${level}">${escapeHtml(list[i].text)}</button>`;
588
694
  }
589
695
  box.hidden = false;
696
+ if (section) section.hidden = false;
590
697
  box.innerHTML = html;
591
698
  return this;
592
699
  }
593
700
 
594
701
  /**
595
- * Red warnings pinned above the scrolling console. Newest first.
596
- * @param {Array<{ text: string, level?: string, onSelect?: () => void }>} items
702
+ * Cause rows plus grey info notes. Bugs are red and expand; info stays grey.
703
+ * @param {Array<{ text: string, level?: string, key?: string, onSelect?: () => void, targets?: Array<{ text: string, onSelect?: () => void }> }>} items
597
704
  */
598
705
  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) {
706
+ if (!this.#els?.issues || !this.#els.issuesList || !this.#els.notes) return this;
707
+ const list = Array.isArray(items) ? items : [];
708
+ const bugs = [];
709
+ const notes = [];
710
+ for (let i = 0; i < list.length; i++) {
711
+ if (list[i].level === 'info') notes.push(list[i]);
712
+ else bugs.push(list[i]);
713
+ }
714
+ const capped = bugs.slice(0, 10);
715
+ this.#issues = capped;
716
+ this.#issueNotes = notes;
717
+ const section = this.#els.issues;
718
+ if (!capped.length && !notes.length) {
604
719
  this.#els.issuesList.innerHTML = '';
605
- box.hidden = true;
720
+ this.#els.notes.innerHTML = '';
721
+ section.hidden = true;
606
722
  return this;
607
723
  }
608
724
  let html = '';
609
- for (let i = 0; i < list.length; i++) {
610
- html += `<button type="button" data-issue="${i}">${escapeHtml(list[i].text)}</button>`;
725
+ for (let i = 0; i < capped.length; i++) {
726
+ const item = capped[i];
727
+ const targets = Array.isArray(item.targets) ? item.targets : [];
728
+ const many = targets.length > 1;
729
+ const open = many && this.#openCauses.has(item.key || item.text);
730
+ html += `<div class="bl-cause" data-open="${open ? 'true' : 'false'}">`;
731
+ html += `<button type="button" data-issue="${i}">${many ? (open ? '▾ ' : '▸ ') : ''}${escapeHtml(item.text)}</button>`;
732
+ if (many) {
733
+ html += `<div class="bl-cause-kids"${open ? '' : ' hidden'}>`;
734
+ for (let j = 0; j < targets.length; j++) {
735
+ html += `<button type="button" data-issue="${i}" data-target="${j}">${escapeHtml(targets[j].text)}</button>`;
736
+ }
737
+ html += '</div>';
738
+ }
739
+ html += '</div>';
611
740
  }
612
- box.hidden = false;
613
741
  this.#els.issuesList.innerHTML = html;
742
+ let noteHtml = '';
743
+ for (let i = 0; i < notes.length; i++) {
744
+ noteHtml += `<div class="bl-note">${escapeHtml(notes[i].text)}</div>`;
745
+ }
746
+ this.#els.notes.innerHTML = noteHtml;
747
+ section.hidden = false;
614
748
  return this;
615
749
  }
616
750
 
package/src/index.d.ts CHANGED
@@ -185,6 +185,8 @@ export interface DetailItem {
185
185
  text: string;
186
186
  level?: 'info' | 'warn' | 'error';
187
187
  onSelect?: () => void;
188
+ key?: string;
189
+ targets?: Array<{ text: string; onSelect?: () => void }>;
188
190
  }
189
191
 
190
192
  export class UIOverlay {