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 CHANGED
@@ -2,7 +2,7 @@
2
2
  # BeeLadybug
3
3
 
4
4
  <p align="center">
5
- <img src="https://raw.githubusercontent.com/antonioprosperi2-svg/BeeLadybug-universal/main/docs/logo.jpg" alt="BeeLadybug" width="720">
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bee-ladybug",
3
- "version": "0.5.3",
3
+ "version": "0.6.0",
4
4
  "description": "Universal telemetry and visual inspection overlay for Canvas, DOM, Python and AI flows.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -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 = 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
- }));
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
  }