bee-ladybug 0.5.3 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.MD +18 -1
- package/package.json +1 -1
- package/src/adapters/AntAdapter.js +52 -1
- package/src/adapters/CSSAdapter.js +257 -14
- package/src/adapters/CanvasAdapter.js +87 -1
- package/src/adapters/SpiderAdapter.js +53 -2
- package/src/adapters/WebDOMAdapter.js +209 -1
- package/src/adapters/python/PythonBridge.js +74 -2
- package/src/core/BeeLadybugCore.js +48 -4
- package/src/core/ReportExporter.js +615 -0
- package/src/core/UIOverlay.js +4 -1
- package/src/index.d.ts +89 -0
- package/src/index.js +1 -0
package/README.MD
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# BeeLadybug
|
|
3
3
|
|
|
4
4
|
<p align="center">
|
|
5
|
-
<img src="https://
|
|
5
|
+
<img src="https://beeenginejs.com/wp-content/uploads/2026/10/beeladybug-logo-animato-ezgif.com-effects.gif" alt="BeeEngine Logo" width="900" />
|
|
6
6
|
</p>
|
|
7
7
|
|
|
8
8
|
Universal telemetry and visual inspection overlay.
|
|
@@ -133,12 +133,26 @@ bee.subscribe((packet) => {
|
|
|
133
133
|
| `F2` | mostra / nascondi overlay (e hitbox canvas) |
|
|
134
134
|
| `F3` | slow-motion del tempo fittizio (`0.25x` / `1x`) |
|
|
135
135
|
| `F4` | freeze / run del tempo fittizio |
|
|
136
|
+
| `F8` | scarica il report HTML |
|
|
136
137
|
| `F6` | CSS hunt, se `CSSAdapter` è attaccato |
|
|
137
138
|
| `P` | blocca il pannello CSS sull'elemento corrente, solo con la hunt accesa |
|
|
138
139
|
| `S` | scansione CSS, solo con la hunt accesa |
|
|
139
140
|
|
|
140
141
|
Niente `F12`, niente tilde: sui layout italiani la tilde non è un tasto unico.
|
|
141
142
|
|
|
143
|
+
## Export
|
|
144
|
+
|
|
145
|
+
`F8`, il pulsante **F8 EXPORT** nel piede dell'overlay, o `exportReport()` scaricano `report-AAAAMMGG-HHMM.html`. Il file raccoglie log, serie e una sezione per ogni adapter attaccato. `json` e `csv` usano gli stessi dati.
|
|
146
|
+
|
|
147
|
+
```js
|
|
148
|
+
import { exportReport } from './src/index.js';
|
|
149
|
+
|
|
150
|
+
exportReport(bee, { format: 'html' });
|
|
151
|
+
bee.exportReport('json');
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Con `ui: false`, o senza `document`, la chiamata restituisce la stringa o l'oggetto e non scarica nulla.
|
|
155
|
+
|
|
142
156
|
## CanvasAdapter
|
|
143
157
|
|
|
144
158
|
```js
|
|
@@ -199,6 +213,7 @@ css.attach();
|
|
|
199
213
|
- overflow del viewport e margini negativi
|
|
200
214
|
- testo trasparente, `opacity: 0`, o colore uguale allo sfondo
|
|
201
215
|
- chi intercetta il click al centro, inclusi `::before` / `::after` fissi su `html` / `body`
|
|
216
|
+
- 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
217
|
- `<img>` con rapporto del file diverso di oltre il 5% dal riquadro disegnato (`getBoundingClientRect`), solo se `object-fit` calcolato è `fill` (il default)
|
|
203
218
|
|
|
204
219
|
`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.
|
|
@@ -349,6 +364,7 @@ const bee = new BeeLadybugCore({
|
|
|
349
364
|
toggleKey: 'F2',
|
|
350
365
|
slowKey: 'F3',
|
|
351
366
|
freezeKey: 'F4',
|
|
367
|
+
exportKey: 'F8',
|
|
352
368
|
slowScale: 0.25,
|
|
353
369
|
maxLogs: 250,
|
|
354
370
|
historySize: 120,
|
|
@@ -367,6 +383,7 @@ bee.getHud();
|
|
|
367
383
|
bee.show(); bee.hide(); bee.toggle();
|
|
368
384
|
bee.freeze(); bee.unfreeze(); bee.toggleFreeze();
|
|
369
385
|
bee.applySlowMo(); bee.restoreRealtime();
|
|
386
|
+
bee.exportReport('html');
|
|
370
387
|
bee.registerAssistant({ name: 'mock', complete: async ({ snapshot }) => 'ok' });
|
|
371
388
|
bee.setActiveAssistant('mock');
|
|
372
389
|
await bee.ask('why fps drop?');
|
package/package.json
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* AntAdapter — DOM mutation rate tracker.
|
|
3
3
|
*
|
|
4
4
|
* Independent MutationObserver. Can sit on the same root as WebDOMAdapter.
|
|
5
|
+
* `snapshot()` lists nodes still over the mutation threshold.
|
|
5
6
|
* The core must never import this file.
|
|
6
7
|
*
|
|
7
8
|
* @example
|
|
@@ -9,6 +10,10 @@
|
|
|
9
10
|
* ant.attach();
|
|
10
11
|
*/
|
|
11
12
|
|
|
13
|
+
import { isReportNoise } from '../core/ReportExporter.js';
|
|
14
|
+
|
|
15
|
+
const MAX_HOT = 40;
|
|
16
|
+
|
|
12
17
|
export const ANT_ADAPTER_DEFAULTS = Object.freeze({
|
|
13
18
|
threshold: 30,
|
|
14
19
|
windowMs: 1000,
|
|
@@ -35,12 +40,14 @@ export class AntAdapter {
|
|
|
35
40
|
this.#attached = false;
|
|
36
41
|
this.#observer = null;
|
|
37
42
|
this.#counts = new WeakMap();
|
|
43
|
+
this.#hot = [];
|
|
38
44
|
this.#onMutate = (records) => this.#ingest(records);
|
|
39
45
|
}
|
|
40
46
|
|
|
41
47
|
#attached;
|
|
42
48
|
#observer;
|
|
43
49
|
#counts;
|
|
50
|
+
#hot;
|
|
44
51
|
#onMutate;
|
|
45
52
|
|
|
46
53
|
attach() {
|
|
@@ -50,7 +57,8 @@ export class AntAdapter {
|
|
|
50
57
|
this.core?.registerAdapter('ant', {
|
|
51
58
|
enable: () => this.#enable(),
|
|
52
59
|
disable: () => this.#disable(),
|
|
53
|
-
label: 'ANT'
|
|
60
|
+
label: 'ANT',
|
|
61
|
+
snapshot: () => this.snapshot()
|
|
54
62
|
});
|
|
55
63
|
this.core?.sendData('log', {
|
|
56
64
|
source: this.source,
|
|
@@ -67,6 +75,30 @@ export class AntAdapter {
|
|
|
67
75
|
return this;
|
|
68
76
|
}
|
|
69
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Nodes whose current window is still at or above the threshold.
|
|
80
|
+
* Hidden boxes, the WordPress admin bar, and BeeLadybug chrome are skipped.
|
|
81
|
+
* @returns {{ name: string, items: Array<{ severity: string, selector: string, rule: string, value: string, source: string }> } | null}
|
|
82
|
+
*/
|
|
83
|
+
snapshot() {
|
|
84
|
+
if (!this.#attached) return null;
|
|
85
|
+
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
86
|
+
const items = [];
|
|
87
|
+
for (let i = 0; i < this.#hot.length && items.length < MAX_HOT; i++) {
|
|
88
|
+
const row = this.#hot[i];
|
|
89
|
+
if (!row || now - row.windowStart >= this.windowMs) continue;
|
|
90
|
+
if (isBeeChrome(row.node) || isReportNoise(row.node)) continue;
|
|
91
|
+
items.push({
|
|
92
|
+
severity: 'warn',
|
|
93
|
+
selector: row.selector,
|
|
94
|
+
rule: 'mutations',
|
|
95
|
+
value: `${row.count}/${this.windowMs}ms`,
|
|
96
|
+
source: this.source
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return { name: 'ant', items };
|
|
100
|
+
}
|
|
101
|
+
|
|
70
102
|
#enable() {
|
|
71
103
|
if (!this.#attached || this.#observer) return;
|
|
72
104
|
if (typeof MutationObserver === 'undefined' || !this.root) return;
|
|
@@ -89,6 +121,7 @@ export class AntAdapter {
|
|
|
89
121
|
if (this.#observer) this.#observer.disconnect();
|
|
90
122
|
this.#observer = null;
|
|
91
123
|
this.#counts = new WeakMap();
|
|
124
|
+
this.#hot = [];
|
|
92
125
|
}
|
|
93
126
|
|
|
94
127
|
#ingest(records) {
|
|
@@ -104,6 +137,7 @@ export class AntAdapter {
|
|
|
104
137
|
this.#counts.set(node, slot);
|
|
105
138
|
}
|
|
106
139
|
slot.count += 1;
|
|
140
|
+
this.#track(node, slot);
|
|
107
141
|
if (slot.count < this.threshold || slot.warned) continue;
|
|
108
142
|
|
|
109
143
|
slot.warned = true;
|
|
@@ -115,6 +149,23 @@ export class AntAdapter {
|
|
|
115
149
|
});
|
|
116
150
|
}
|
|
117
151
|
}
|
|
152
|
+
|
|
153
|
+
#track(node, slot) {
|
|
154
|
+
const index = this.#hot.findIndex((row) => row.node === node);
|
|
155
|
+
if (slot.count < this.threshold) {
|
|
156
|
+
if (index >= 0) this.#hot.splice(index, 1);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const row = {
|
|
160
|
+
node,
|
|
161
|
+
selector: nodeSelector(node),
|
|
162
|
+
count: slot.count,
|
|
163
|
+
windowStart: slot.windowStart
|
|
164
|
+
};
|
|
165
|
+
if (index >= 0) this.#hot[index] = row;
|
|
166
|
+
else this.#hot.push(row);
|
|
167
|
+
if (this.#hot.length > MAX_HOT) this.#hot.splice(0, this.#hot.length - MAX_HOT);
|
|
168
|
+
}
|
|
118
169
|
}
|
|
119
170
|
|
|
120
171
|
function positive(value, fallback) {
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Hover an element to list !important hits, viewport overflow, negative
|
|
5
5
|
* margins, invisible text, click blockers and stretched images. The core
|
|
6
6
|
* only receives packets and detail rows; it does not parse CSS.
|
|
7
|
+
* `snapshot()` repeats that scan for the report while the hunt is off.
|
|
7
8
|
*
|
|
8
9
|
* @example
|
|
9
10
|
* const css = new CSSAdapter(core);
|
|
@@ -11,6 +12,10 @@
|
|
|
11
12
|
* // F6 toggles hunt. S scans the page while hunt is on.
|
|
12
13
|
*/
|
|
13
14
|
|
|
15
|
+
import { isReportNoise } from '../core/ReportExporter.js';
|
|
16
|
+
|
|
17
|
+
const MAX_SNAPSHOT = 80;
|
|
18
|
+
|
|
14
19
|
export const CSS_ADAPTER_DEFAULTS = Object.freeze({
|
|
15
20
|
source: 'css',
|
|
16
21
|
hotkey: 'F6',
|
|
@@ -104,7 +109,8 @@ export class CSSAdapter {
|
|
|
104
109
|
hotkey: this.hotkey,
|
|
105
110
|
enabled: false,
|
|
106
111
|
enable: () => this.#enable(),
|
|
107
|
-
disable: () => this.#disable()
|
|
112
|
+
disable: () => this.#disable(),
|
|
113
|
+
snapshot: () => this.snapshot()
|
|
108
114
|
});
|
|
109
115
|
this.core?.sendData('log', {
|
|
110
116
|
source: this.source,
|
|
@@ -143,6 +149,7 @@ export class CSSAdapter {
|
|
|
143
149
|
pushInvisible(el, findings);
|
|
144
150
|
pushBlockers(el, findings);
|
|
145
151
|
pushImage(el, findings);
|
|
152
|
+
pushStacking(el, findings);
|
|
146
153
|
pushImportant(el, this.#rules, findings);
|
|
147
154
|
for (let j = 0; j < findings.length; j++) {
|
|
148
155
|
rows.push({ el, selector: cssPath(el), ...findings[j] });
|
|
@@ -190,6 +197,98 @@ export class CSSAdapter {
|
|
|
190
197
|
return this;
|
|
191
198
|
}
|
|
192
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Full hunt scan for the report. Hunt can stay off: nothing is logged,
|
|
202
|
+
* pinned, or published. Hidden boxes, WordPress admin, and BeeLadybug
|
|
203
|
+
* chrome are skipped.
|
|
204
|
+
* @returns {{ name: string, items: Array<{ severity: string, selector: string, rule: string, value: string, source: string }> } | null}
|
|
205
|
+
*/
|
|
206
|
+
snapshot() {
|
|
207
|
+
if (!this.#attached) return null;
|
|
208
|
+
if (typeof document === 'undefined' || !document.body) return { name: 'css', items: [] };
|
|
209
|
+
|
|
210
|
+
const built = collectImportantRules();
|
|
211
|
+
const nodes = document.body.querySelectorAll('*');
|
|
212
|
+
const overflow = [];
|
|
213
|
+
const rows = [];
|
|
214
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
215
|
+
const el = nodes[i];
|
|
216
|
+
if (!el || isBeeChrome(el) || isSkippedTag(el) || isReportNoise(el)) continue;
|
|
217
|
+
const measured = measureViewportOverflow(el);
|
|
218
|
+
if (measured) overflow.push(measured);
|
|
219
|
+
const findings = [];
|
|
220
|
+
pushNegativeMargins(el, findings);
|
|
221
|
+
pushInvisible(el, findings);
|
|
222
|
+
pushBlockers(el, findings);
|
|
223
|
+
pushImage(el, findings);
|
|
224
|
+
pushStacking(el, findings);
|
|
225
|
+
pushImportant(el, built.rules, findings);
|
|
226
|
+
for (let j = 0; j < findings.length; j++) {
|
|
227
|
+
rows.push({ el, selector: cssPath(el), ...findings[j] });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const kept = keepOverflowCauses(overflow);
|
|
231
|
+
for (let i = 0; i < kept.length; i++) {
|
|
232
|
+
const cause = kept[i];
|
|
233
|
+
rows.push({
|
|
234
|
+
el: cause.el,
|
|
235
|
+
selector: cssPath(cause.el),
|
|
236
|
+
kind: 'overflows viewport',
|
|
237
|
+
rank: 0,
|
|
238
|
+
level: 'error',
|
|
239
|
+
score: cause.score
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
let causes = [];
|
|
244
|
+
const plain = [];
|
|
245
|
+
for (let i = 0; i < rows.length; i++) {
|
|
246
|
+
const row = rows[i];
|
|
247
|
+
if (describeCause(row.kind, row.selector)) {
|
|
248
|
+
causes = addCause(causes, { kind: row.kind, path: row.selector, el: row.el }, MAX_SNAPSHOT);
|
|
249
|
+
} else {
|
|
250
|
+
plain.push(row);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const items = [];
|
|
255
|
+
for (let i = 0; i < causes.length; i++) {
|
|
256
|
+
const entry = causes[i];
|
|
257
|
+
items.push({
|
|
258
|
+
severity: reportSeverity(entry.level),
|
|
259
|
+
selector: entry.cause,
|
|
260
|
+
rule: causeRule(entry.type),
|
|
261
|
+
value: formatCause(entry),
|
|
262
|
+
source: this.source
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
const groups = groupIssues(plain);
|
|
266
|
+
for (let i = 0; i < groups.length; i++) {
|
|
267
|
+
const group = groups[i];
|
|
268
|
+
const count = group.count > 1 ? ` ×${group.count}` : '';
|
|
269
|
+
items.push({
|
|
270
|
+
severity: reportSeverity(group.level),
|
|
271
|
+
selector: group.selector,
|
|
272
|
+
rule: plainRule(group.kind),
|
|
273
|
+
value: `${group.kind}${count}`,
|
|
274
|
+
source: this.source
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
for (let i = 0; i < built.unreadable.length; i++) {
|
|
278
|
+
const label = built.unreadable[i];
|
|
279
|
+
items.push({
|
|
280
|
+
severity: 'ok',
|
|
281
|
+
selector: label,
|
|
282
|
+
rule: 'unreadable sheet',
|
|
283
|
+
value: label,
|
|
284
|
+
source: this.source
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
items.sort((a, b) => severityRank(a.severity) - severityRank(b.severity));
|
|
288
|
+
if (items.length > MAX_SNAPSHOT) items.length = MAX_SNAPSHOT;
|
|
289
|
+
return { name: 'css', items };
|
|
290
|
+
}
|
|
291
|
+
|
|
193
292
|
#enable() {
|
|
194
293
|
if (this.#active || typeof document === 'undefined' || typeof window === 'undefined') return;
|
|
195
294
|
this.#active = true;
|
|
@@ -293,6 +392,7 @@ export class CSSAdapter {
|
|
|
293
392
|
pushInvisible(el, findings);
|
|
294
393
|
pushBlockers(el, findings);
|
|
295
394
|
pushImage(el, findings);
|
|
395
|
+
pushStacking(el, findings);
|
|
296
396
|
const path = cssPath(el);
|
|
297
397
|
this.core?.sendData('state', { source: this.source, key: 'css.target', value: path });
|
|
298
398
|
this.core?.sendData('state', {
|
|
@@ -340,7 +440,7 @@ export class CSSAdapter {
|
|
|
340
440
|
for (let i = 0; i < findings.length; i++) {
|
|
341
441
|
const hit = findings[i];
|
|
342
442
|
const text = hit.text || hit.kind || '';
|
|
343
|
-
if (!text || text.startsWith('unreadable sheet:')) continue;
|
|
443
|
+
if (!text || text.startsWith('unreadable sheet:') || hit.level === 'info') continue;
|
|
344
444
|
if (!rememberIssue(this.#issueLog, `${path}\0${text}`, now, this.issueWindow)) continue;
|
|
345
445
|
const level = hit.level === 'error' ? 'error' : 'warn';
|
|
346
446
|
this.core?.sendData(level, {
|
|
@@ -380,20 +480,25 @@ export class CSSAdapter {
|
|
|
380
480
|
}
|
|
381
481
|
|
|
382
482
|
#publishIssues() {
|
|
383
|
-
const bugs =
|
|
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
|
-
}));
|
|
483
|
+
const bugs = [];
|
|
392
484
|
const notes = this.#notes.map((note) => ({
|
|
393
485
|
level: 'info',
|
|
394
486
|
key: note.text,
|
|
395
487
|
text: note.text
|
|
396
488
|
}));
|
|
489
|
+
for (let i = 0; i < this.#issues.length; i++) {
|
|
490
|
+
const entry = this.#issues[i];
|
|
491
|
+
const item = {
|
|
492
|
+
key: entry.key,
|
|
493
|
+
text: formatCause(entry),
|
|
494
|
+
targets: entry.targets.map((target) => ({
|
|
495
|
+
text: target.text,
|
|
496
|
+
onSelect: () => this.#pin(target.el)
|
|
497
|
+
}))
|
|
498
|
+
};
|
|
499
|
+
if (entry.level === 'info') notes.push({ ...item, level: 'info' });
|
|
500
|
+
else bugs.push({ ...item, level: 'error' });
|
|
501
|
+
}
|
|
397
502
|
this.core?.publishIssues(bugs.concat(notes));
|
|
398
503
|
}
|
|
399
504
|
|
|
@@ -598,9 +703,11 @@ function pushNegativeMargins(el, findings) {
|
|
|
598
703
|
const ISSUE_RANK = Object.freeze({
|
|
599
704
|
overflow: 0,
|
|
600
705
|
blocker: 1,
|
|
706
|
+
stack: 1,
|
|
601
707
|
invisible: 2,
|
|
602
708
|
image: 3,
|
|
603
|
-
important: 4
|
|
709
|
+
important: 4,
|
|
710
|
+
hint: 6
|
|
604
711
|
});
|
|
605
712
|
|
|
606
713
|
/**
|
|
@@ -729,12 +836,32 @@ export function pushStickyIssue(list, entry, max = 10) {
|
|
|
729
836
|
|
|
730
837
|
export function describeCause(kind, path) {
|
|
731
838
|
if (kind === 'overflows viewport') {
|
|
732
|
-
return { key: `overflow\0${path}`, type: 'overflow', cause: path };
|
|
839
|
+
return { key: `overflow\0${path}`, type: 'overflow', cause: path, reason: '', level: 'error' };
|
|
733
840
|
}
|
|
734
841
|
const prefix = 'click blocked by ';
|
|
735
842
|
if (typeof kind === 'string' && kind.startsWith(prefix)) {
|
|
736
843
|
const cause = kind.slice(prefix.length);
|
|
737
|
-
return { key: `block\0${cause}`, type: 'block', cause };
|
|
844
|
+
return { key: `block\0${cause}`, type: 'block', cause, reason: '', level: 'error' };
|
|
845
|
+
}
|
|
846
|
+
const stack = typeof kind === 'string' ? kind.match(/^stacking context \(([^)]+)\) by (.+)$/) : null;
|
|
847
|
+
if (stack) {
|
|
848
|
+
return {
|
|
849
|
+
key: `stack\0${stack[2]}\0${stack[1]}`,
|
|
850
|
+
type: 'stack',
|
|
851
|
+
cause: stack[2],
|
|
852
|
+
reason: stack[1],
|
|
853
|
+
level: 'error'
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
const hint = typeof kind === 'string' ? kind.match(/^will-change (.+?) by (.+)$/) : null;
|
|
857
|
+
if (hint) {
|
|
858
|
+
return {
|
|
859
|
+
key: `will\0${hint[2]}\0${hint[1]}`,
|
|
860
|
+
type: 'stack-hint',
|
|
861
|
+
cause: hint[2],
|
|
862
|
+
reason: hint[1],
|
|
863
|
+
level: 'info'
|
|
864
|
+
};
|
|
738
865
|
}
|
|
739
866
|
return null;
|
|
740
867
|
}
|
|
@@ -760,6 +887,8 @@ export function addCause(list, entry, max = 10) {
|
|
|
760
887
|
key: described.key,
|
|
761
888
|
type: described.type,
|
|
762
889
|
cause: described.cause,
|
|
890
|
+
reason: described.reason || '',
|
|
891
|
+
level: described.level || 'error',
|
|
763
892
|
targets
|
|
764
893
|
});
|
|
765
894
|
return rest.slice(0, max);
|
|
@@ -771,10 +900,96 @@ export function formatCause(entry) {
|
|
|
771
900
|
const noun = count === 1 ? 'element' : 'elements';
|
|
772
901
|
return `${entry.cause} blocks clicks on ${count} ${noun}`;
|
|
773
902
|
}
|
|
903
|
+
if (entry.type === 'stack') {
|
|
904
|
+
const noun = count === 1 ? 'child' : 'children';
|
|
905
|
+
return `${entry.cause} creates stacking context (${entry.reason}) - z-index of ${count} ${noun} is trapped`;
|
|
906
|
+
}
|
|
907
|
+
if (entry.type === 'stack-hint') {
|
|
908
|
+
const noun = count === 1 ? 'child' : 'children';
|
|
909
|
+
return `${entry.cause} will-change: ${entry.reason} — z-index of ${count} ${noun} stays in this layer`;
|
|
910
|
+
}
|
|
774
911
|
if (count <= 1) return `${entry.cause} overflows viewport`;
|
|
775
912
|
return `${entry.cause} overflows viewport · ${count} elements`;
|
|
776
913
|
}
|
|
777
914
|
|
|
915
|
+
/**
|
|
916
|
+
* Properties that create a stacking context. `will-change` for transform or
|
|
917
|
+
* opacity is only a hint: it may promote a layer, it is not reported as a bug.
|
|
918
|
+
* @param {{ transform?: string, opacity?: string, filter?: string, position?: string, zIndex?: string, willChange?: string, getPropertyValue?: (name: string) => string }} style
|
|
919
|
+
*/
|
|
920
|
+
export function stackingReasons(style) {
|
|
921
|
+
const read = (prop, camel) => {
|
|
922
|
+
if (style && typeof style.getPropertyValue === 'function') {
|
|
923
|
+
const raw = style.getPropertyValue(prop);
|
|
924
|
+
if (raw) return String(raw).trim();
|
|
925
|
+
}
|
|
926
|
+
if (style && style[camel] != null && style[camel] !== '') return String(style[camel]).trim();
|
|
927
|
+
return '';
|
|
928
|
+
};
|
|
929
|
+
const bugs = [];
|
|
930
|
+
const transform = read('transform', 'transform');
|
|
931
|
+
if (transform && transform !== 'none') bugs.push('transform');
|
|
932
|
+
const opacity = parseFloat(read('opacity', 'opacity'));
|
|
933
|
+
if (Number.isFinite(opacity) && opacity < 1) bugs.push('opacity');
|
|
934
|
+
const filter = read('filter', 'filter');
|
|
935
|
+
if (filter && filter !== 'none') bugs.push('filter');
|
|
936
|
+
const position = read('position', 'position') || 'static';
|
|
937
|
+
const zIndex = read('z-index', 'zIndex') || 'auto';
|
|
938
|
+
if (position !== 'static' && zIndex !== 'auto') bugs.push('z-index');
|
|
939
|
+
const hint = willChangeHint(read('will-change', 'willChange'));
|
|
940
|
+
return { bugs, hint };
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
function willChangeHint(value) {
|
|
944
|
+
if (!value || value === 'auto') return '';
|
|
945
|
+
const parts = value.split(',').map((part) => part.trim().toLowerCase());
|
|
946
|
+
const hit = [];
|
|
947
|
+
for (let i = 0; i < parts.length; i++) {
|
|
948
|
+
if ((parts[i] === 'transform' || parts[i] === 'opacity') && !hit.includes(parts[i])) hit.push(parts[i]);
|
|
949
|
+
}
|
|
950
|
+
return hit.join(', ');
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function pushStacking(el, findings) {
|
|
954
|
+
if (!el || typeof getComputedStyle !== 'function' || !el.parentElement) return;
|
|
955
|
+
const own = getComputedStyle(el);
|
|
956
|
+
const ownZ = own.zIndex || own.getPropertyValue?.('z-index') || 'auto';
|
|
957
|
+
if (!ownZ || ownZ === 'auto') return;
|
|
958
|
+
let node = el.parentElement;
|
|
959
|
+
while (node && node.nodeType === 1) {
|
|
960
|
+
if (isBeeChrome(node)) return;
|
|
961
|
+
const found = stackingReasons(getComputedStyle(node));
|
|
962
|
+
const ancestor = cssPath(node);
|
|
963
|
+
if (found.bugs.length) {
|
|
964
|
+
const reason = found.bugs[0];
|
|
965
|
+
findings.push({
|
|
966
|
+
level: 'error',
|
|
967
|
+
rank: ISSUE_RANK.stack,
|
|
968
|
+
score: 35,
|
|
969
|
+
kind: `stacking context (${reason}) by ${ancestor}`,
|
|
970
|
+
text: `z-index trapped by ${ancestor} (${reason})`
|
|
971
|
+
});
|
|
972
|
+
if (found.hint) findings.push(stackHint(ancestor, found.hint));
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
if (found.hint) {
|
|
976
|
+
findings.push(stackHint(ancestor, found.hint));
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
node = node.parentElement;
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
function stackHint(ancestor, hint) {
|
|
984
|
+
return {
|
|
985
|
+
level: 'info',
|
|
986
|
+
rank: ISSUE_RANK.hint,
|
|
987
|
+
score: 1,
|
|
988
|
+
kind: `will-change ${hint} by ${ancestor}`,
|
|
989
|
+
text: `will-change: ${hint} on ${ancestor}`
|
|
990
|
+
};
|
|
991
|
+
}
|
|
992
|
+
|
|
778
993
|
function sameTarget(a, b) {
|
|
779
994
|
if (a.el && b.el) return a.el === b.el;
|
|
780
995
|
return a.text === b.text;
|
|
@@ -802,6 +1017,34 @@ function nowMs() {
|
|
|
802
1017
|
return typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
803
1018
|
}
|
|
804
1019
|
|
|
1020
|
+
function reportSeverity(level) {
|
|
1021
|
+
if (level === 'error') return 'error';
|
|
1022
|
+
if (level === 'warn' || level === 'warning') return 'warn';
|
|
1023
|
+
return 'ok';
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function severityRank(level) {
|
|
1027
|
+
if (level === 'error') return 0;
|
|
1028
|
+
if (level === 'warn') return 1;
|
|
1029
|
+
return 2;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
function causeRule(type) {
|
|
1033
|
+
if (type === 'block') return 'click blocked';
|
|
1034
|
+
if (type === 'stack') return 'stacking context';
|
|
1035
|
+
if (type === 'stack-hint') return 'will-change';
|
|
1036
|
+
return 'overflow';
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
function plainRule(kind) {
|
|
1040
|
+
const text = String(kind || '');
|
|
1041
|
+
if (text.startsWith('!important')) return '!important';
|
|
1042
|
+
if (text.startsWith('invisible text')) return 'invisible text';
|
|
1043
|
+
if (text.startsWith('negative margin')) return 'negative margin';
|
|
1044
|
+
if (text === 'distorted image') return 'distorted image';
|
|
1045
|
+
return text;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
805
1048
|
function issue(kind, rank, score) {
|
|
806
1049
|
return { level: 'error', rank, score, kind, text: kind };
|
|
807
1050
|
}
|