@lightworkai.official/debug-capture 0.19.6 → 0.19.7

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.
@@ -15,9 +15,11 @@
15
15
  * more (`chat.avoid: "<selector>"`) or turn it off (`false`).
16
16
  *
17
17
  * THE NEAREST FREE PLACE. Up or down its own side first; the other side when that is nearer than a long climb
18
- * (`SWITCH_COST`) — a column the height of the window sends it to the other corner, a FAB lifts it above. Once it
19
- * stepped aside on a page it stays there while that place is free (`settleSpot`): a toolbar opening and closing
20
- * does not send it back and forth across the screen.
18
+ * (`SWITCH_COST`) — a column the height of the window sends it to the other corner, a FAB lifts it above. When a
19
+ * nearer place frees up it goes back (`settleSpot`): the tray shut, it is above the toggle again. 0.19.6 stayed
20
+ * where it had stepped to for the rest of the page, which on the real เอกสาร page left it high on the right over
21
+ * the viewer's ยังไม่มีการวางตรา once the tray was shut. A shift of less than STAY_MARGIN does not move it — no
22
+ * jitter as the page settles.
21
23
  *
22
24
  * A PAGE'S OWN PLACE. The desk's rules (ปุ่มแชท › ตำแหน่งปุ่ม): pages whose path starts so — hidden there, or
23
25
  * started in a corner at a height. The most specific rule wins; a person who moved it keeps their place.
@@ -35,6 +37,8 @@ export type Side = "right" | "left";
35
37
  export declare const CLEAR = 8;
36
38
  /** What moving to the other side costs, in px of climbing — a short lift stays on its side, a long one crosses. */
37
39
  export declare const SWITCH_COST = 160;
40
+ /** A free place this much nearer (in the same px of cost) is needed to move it from one it already stands in. */
41
+ export declare const STAY_MARGIN = 48;
38
42
  /** An obstacle is smaller than this share of the window — a box as big as the page is the page. */
39
43
  export declare const OBSTACLE_MAX_AREA = 0.34;
40
44
  /**
@@ -59,8 +63,8 @@ export declare function chooseSpot(want: {
59
63
  y: number;
60
64
  };
61
65
  /**
62
- * Where it stands now, given where it stood: kept while that place is still free and it was chosen for the same wish
63
- * (`prev` is null when the wish, the page or the person's own place changed) — else `chooseSpot`. Pure.
66
+ * Where it stands now, given where it stood: the best place (`chooseSpot`), unless where it stands is still free and
67
+ * within STAY_MARGIN of it (`prev` is null when the wish, the page or the person's own place changed). Pure.
64
68
  */
65
69
  export declare function settleSpot(prev: {
66
70
  side: Side;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightworkai.official/debug-capture",
3
- "version": "0.19.6",
3
+ "version": "0.19.7",
4
4
  "description": "Report-a-problem widget: rolling network/console/action capture, optional rrweb session replay, annotated screenshot, and submission to a Lightwork Support host",
5
5
  "type": "module",
6
6
  "module": "./dist/index.mjs",
@@ -15,9 +15,11 @@
15
15
  * more (`chat.avoid: "<selector>"`) or turn it off (`false`).
16
16
  *
17
17
  * THE NEAREST FREE PLACE. Up or down its own side first; the other side when that is nearer than a long climb
18
- * (`SWITCH_COST`) — a column the height of the window sends it to the other corner, a FAB lifts it above. Once it
19
- * stepped aside on a page it stays there while that place is free (`settleSpot`): a toolbar opening and closing
20
- * does not send it back and forth across the screen.
18
+ * (`SWITCH_COST`) — a column the height of the window sends it to the other corner, a FAB lifts it above. When a
19
+ * nearer place frees up it goes back (`settleSpot`): the tray shut, it is above the toggle again. 0.19.6 stayed
20
+ * where it had stepped to for the rest of the page, which on the real เอกสาร page left it high on the right over
21
+ * the viewer's ยังไม่มีการวางตรา once the tray was shut. A shift of less than STAY_MARGIN does not move it — no
22
+ * jitter as the page settles.
21
23
  *
22
24
  * A PAGE'S OWN PLACE. The desk's rules (ปุ่มแชท › ตำแหน่งปุ่ม): pages whose path starts so — hidden there, or
23
25
  * started in a corner at a height. The most specific rule wins; a person who moved it keeps their place.
@@ -37,6 +39,8 @@ export type Side = "right" | "left";
37
39
  export const CLEAR = 8;
38
40
  /** What moving to the other side costs, in px of climbing — a short lift stays on its side, a long one crosses. */
39
41
  export const SWITCH_COST = 160;
42
+ /** A free place this much nearer (in the same px of cost) is needed to move it from one it already stands in. */
43
+ export const STAY_MARGIN = 48;
40
44
  /** An obstacle is smaller than this share of the window — a box as big as the page is the page. */
41
45
  export const OBSTACLE_MAX_AREA = 0.34;
42
46
 
@@ -86,9 +90,12 @@ export function chooseSpot(want: { side: Side; y: number }, free: Record<Side, r
86
90
  return costHere <= costThere ? { side: want.side, y: here! } : { side: other, y: there! };
87
91
  }
88
92
 
93
+ /** What a place costs against the wish: the climb, and SWITCH_COST more on the other side. */
94
+ const costOf = (spot: { side: Side; y: number }, want: { side: Side; y: number }) => Math.abs(spot.y - want.y) + (spot.side === want.side ? 0 : SWITCH_COST);
95
+
89
96
  /**
90
- * Where it stands now, given where it stood: kept while that place is still free and it was chosen for the same wish
91
- * (`prev` is null when the wish, the page or the person's own place changed) — else `chooseSpot`. Pure.
97
+ * Where it stands now, given where it stood: the best place (`chooseSpot`), unless where it stands is still free and
98
+ * within STAY_MARGIN of it (`prev` is null when the wish, the page or the person's own place changed). Pure.
92
99
  */
93
100
  export function settleSpot(
94
101
  prev: { side: Side; y: number } | null,
@@ -97,8 +104,9 @@ export function settleSpot(
97
104
  min: number,
98
105
  max: number,
99
106
  ): { side: Side; y: number } {
100
- if (prev && nearestFreeY(prev.y, free[prev.side], min, max) === prev.y) return prev;
101
- return chooseSpot(want, free, min, max);
107
+ const best = chooseSpot(want, free, min, max);
108
+ if (prev && nearestFreeY(prev.y, free[prev.side], min, max) === prev.y && costOf(prev, want) <= costOf(best, want) + STAY_MARGIN) return prev;
109
+ return best;
102
110
  }
103
111
 
104
112
  /* ── A page's own place: the desk's rules ───────────────────────────────── */
@@ -157,7 +165,8 @@ const SCROLLS = /(auto|scroll|overlay)/;
157
165
  */
158
166
  export function scanColumns(cols: readonly { left: number; right: number }[], view: { w: number; h: number }, opts: { step?: number; extra?: string | null } = {}): Rect[][] {
159
167
  if (typeof document === "undefined" || typeof document.elementsFromPoint !== "function") return cols.map(() => []);
160
- const step = opts.step ?? 12;
168
+ // 16 px: anything a hand presses is taller. Each point is a hit test, ~30 µs on the ERP's เอกสาร page.
169
+ const step = opts.step ?? 16;
161
170
  const html = document.documentElement;
162
171
  const body = document.body;
163
172
  const styles = new Map<Element, CSSStyleDeclaration>();
@@ -205,6 +214,16 @@ export function scanColumns(cols: readonly { left: number; right: number }[], vi
205
214
  return { float, scrolled, opacity };
206
215
  };
207
216
  const pins = new Map<Element, ReturnType<typeof pinOf>>();
217
+ /*
218
+ * The page's topmost piece at a point. elementsFromPoint hit-tests every layer down to the root — 450 of them took
219
+ * 30–37 ms on the ERP's เอกสาร page, against 14 ms for elementFromPoint, which stops at the first; the whole stack
220
+ * is asked for only where this library's own box is the first.
221
+ */
222
+ const topAt = (x: number, y: number): Element | null => {
223
+ const first = document.elementFromPoint(x, y);
224
+ if (!first || !OURS.test(first.tagName)) return first;
225
+ return document.elementsFromPoint(x, y).find((e) => !OURS.test(e.tagName)) ?? null;
226
+ };
208
227
  return cols.map((col) => {
209
228
  const seen = new Set<Element>();
210
229
  const kept = new Set<Element>();
@@ -224,7 +243,7 @@ export function scanColumns(cols: readonly { left: number; right: number }[], vi
224
243
  const xs = [col.left + 6, (col.left + col.right) / 2, col.right - 6];
225
244
  for (let y = step / 2; y < view.h; y += step) {
226
245
  for (const x of xs) {
227
- const top = document.elementsFromPoint(x, y).find((e) => !OURS.test(e.tagName));
246
+ const top = topAt(x, y);
228
247
  if (!top || top === html || top === body || seen.has(top)) continue;
229
248
  seen.add(top);
230
249
  if (opts.extra) {
@@ -18,7 +18,7 @@ import { chatPlace, getConfig } from "../config";
18
18
  import { hostToken, onLiveFrame } from "../live";
19
19
  import { openSupportPortal } from "../portal";
20
20
  import { launch } from "../reporter";
21
- import { blockedRanges, placeFor, scanColumns, settleSpot, type PlaceRule, type Side } from "./bubblePlace";
21
+ import { blockedRanges, nearestFreeY, placeFor, scanColumns, settleSpot, type PlaceRule, type Side } from "./bubblePlace";
22
22
  import { followHostFont, OWN_FONTS } from "./hostFont";
23
23
  import { strings } from "./strings";
24
24
 
@@ -254,7 +254,7 @@ function wearLook(look: DeskLook | null): void {
254
254
  const face = shadow?.querySelector<HTMLImageElement>(".face");
255
255
  const url = lookFaceUrl(getConfig().host, look);
256
256
  // Its rules and its switch may have changed where it stands.
257
- queueRescan(true);
257
+ queueRescan("now");
258
258
  if (face) {
259
259
  if (!url) {
260
260
  bubble.classList.remove("has-face");
@@ -311,7 +311,7 @@ function show(on: boolean): void {
311
311
  // Its words in the host page's own font — read again here, after the host has drawn itself.
312
312
  if (on) followHostFont(root);
313
313
  layout();
314
- if (on) queueRescan(true);
314
+ if (on) queueRescan("now");
315
315
  }
316
316
 
317
317
  /* ── Where it is: the config's place, or where the person put it ───────── */
@@ -356,8 +356,11 @@ const pagePath = () => window.location?.pathname ?? "/";
356
356
  let shown: BubbleSpot | null = null;
357
357
  /** What `shown` was chosen for — the page and the wish; while they hold, a free `shown` is kept (settleSpot). */
358
358
  let shownFor = "";
359
- /** The heights each side's column rules out, as last read off the page — null: not read yet, or not stepping aside. */
360
- let blocked: Record<Side, [number, number][]> | null = null;
359
+ /**
360
+ * The heights each side's column rules out, as last read off the page — null: not read yet, or not stepping aside. A
361
+ * side is null when it was not read: its own place was free, so the other corner could not be better.
362
+ */
363
+ let blocked: Record<Side, [number, number][] | null> | null = null;
361
364
  /** Hidden on this page by the desk's rule (ซ่อนปุ่มแชทในหน้านี้). */
362
365
  let ruleHidden = false;
363
366
 
@@ -384,22 +387,29 @@ function rescan(): void {
384
387
  }
385
388
  const v = view();
386
389
  const x = spot.x;
387
- const colR = { left: v.w - x - SIZE, right: v.w - x };
388
- const colL = { left: x, right: x + SIZE };
389
- const [foundR = [], foundL = []] = scanColumns([colR, colL], v, { extra });
390
- const right = blockedRanges(foundR, colR, v.h, SIZE);
391
- const left = blockedRanges(foundL, colL, v.h, SIZE);
390
+ const cols: Record<Side, { left: number; right: number }> = { right: { left: v.w - x - SIZE, right: v.w - x }, left: { left: x, right: x + SIZE } };
391
+ const read = (side: Side) => blockedRanges(scanColumns([cols[side]], v, { extra })[0] ?? [], cols[side], v.h, SIZE);
392
+ // Its own side first; the other corner is read only when the place it wants there is taken (half the hit tests).
393
+ const want = wanted();
394
+ const wantY = clampBubbleY(want.y, v.h);
395
+ const other: Side = want.side === "right" ? "left" : "right";
396
+ const here = read(want.side);
397
+ const there = nearestFreeY(wantY, here, MARGIN, Math.max(MARGIN, v.h - SIZE - MARGIN)) === wantY ? null : read(other);
398
+ const next = { [want.side]: here, [other]: there } as Record<Side, [number, number][] | null>;
392
399
  // Nothing new where it could stand: the page is looked at less often while that holds (queueRescan).
393
- calm = JSON.stringify([right, left]) === JSON.stringify(blocked && [blocked.right, blocked.left]) ? Math.min(calm + 1, CALM_STEPS) : 0;
394
- blocked = { right, left };
400
+ calm = JSON.stringify([next.right, next.left]) === JSON.stringify(blocked && [blocked.right, blocked.left]) ? Math.min(calm + 1, CALM_STEPS) : 0;
401
+ blocked = next;
395
402
  layout();
396
403
  }
397
404
  /*
398
- * How often the page is read (a read is two columns of hit tests, a few ms). A page that keeps changing — a clock, a
405
+ * How often the page is read (a read is a column or two of hit tests). A page that keeps changing — a clock, a
399
406
  * live list — said "look again" ten times a second, and a read every 400 ms cost it half a second of script in ten
400
407
  * (0.19.5: 20 ms). So: a read that finds nothing new doubles the wait for the next one a change asks for (400 ms up to
401
408
  * 3.2 s); a person's own press or key, a new page, a resize or the tab coming back is looked at at once (URGENT_MS),
402
- * and resets it. Never while the panel is open (it does not move then), while it is dragged, or in a hidden tab.
409
+ * and resets it; a transition or animation that ends in a corner's column — something there just finished appearing
410
+ * — resets it too and is looked at within RESCAN_MS ("soon": a read during the ERP tray's 300 ms fade-in saw it nearly
411
+ * clear, and the next waited out the doubling). Never while the panel is open (it does not move then), while it is
412
+ * dragged, or in a hidden tab.
403
413
  */
404
414
  const RESCAN_MS = 400;
405
415
  const CALM_STEPS = 3;
@@ -408,17 +418,14 @@ let rescanTimer: ReturnType<typeof setTimeout> | undefined;
408
418
  let rescanDue = 0;
409
419
  let lastRescan = 0;
410
420
  let calm = 0;
411
- function queueRescan(urgent = false): void {
421
+ function queueRescan(when: "now" | "soon" | "later" = "later"): void {
412
422
  if (!root) return;
413
423
  const now = Date.now();
414
- if (urgent) {
415
- calm = 0;
416
- if (rescanTimer && rescanDue - now <= URGENT_MS) return;
417
- clearTimeout(rescanTimer);
418
- rescanTimer = undefined;
419
- }
420
- if (rescanTimer) return;
421
- const wait = urgent ? URGENT_MS : Math.max(0, RESCAN_MS * 2 ** calm - (now - lastRescan));
424
+ if (when !== "later") calm = 0;
425
+ const wait = when === "now" ? URGENT_MS : Math.max(0, RESCAN_MS * 2 ** calm - (now - lastRescan));
426
+ // One read waiting at a time — moved sooner when this asks sooner.
427
+ if (rescanTimer && rescanDue <= now + wait) return;
428
+ clearTimeout(rescanTimer);
422
429
  rescanDue = now + wait;
423
430
  rescanTimer = setTimeout(() => {
424
431
  rescanTimer = undefined;
@@ -431,16 +438,31 @@ function queueRescan(urgent = false): void {
431
438
  let watcher: MutationObserver | null = null;
432
439
  let lastPath = "";
433
440
  let pathTimer: ReturnType<typeof setInterval> | undefined;
434
- const onPageMoved = () => queueRescan();
441
+ const ours = (t: EventTarget | null) => t instanceof Element && (t === root || /^LW-/.test(t.tagName));
442
+ const onPageMoved = (e: Event) => {
443
+ if (!ours(e.target)) queueRescan();
444
+ };
445
+ /** A transition or animation ended: soon when it was in a corner's column, where the bubble may stand. */
446
+ const onPageSettled = (e: Event) => {
447
+ if (ours(e.target)) return;
448
+ const r = e.target instanceof Element ? e.target.getBoundingClientRect() : null;
449
+ const v = view();
450
+ const x = spot.x;
451
+ const inColumn = !!r && r.width > 0 && (r.left < x + SIZE || r.right > v.w - x - SIZE);
452
+ queueRescan(inColumn ? "soon" : "later");
453
+ };
435
454
  /** What a person did, or the window itself changed: looked at at once. */
436
- const onPageActed = () => queueRescan(true);
455
+ const onPageActed = (e: Event) => {
456
+ if (!ours(e.target)) queueRescan("now");
457
+ };
437
458
  const ACTED = ["popstate", "hashchange", "pointerup", "keyup"] as const;
438
459
  function watchPage(on: boolean): void {
439
460
  watcher?.disconnect();
440
461
  watcher = null;
441
462
  clearInterval(pathTimer);
442
463
  for (const t of ACTED) window.removeEventListener(t, onPageActed, true);
443
- for (const t of ["scroll", "transitionend", "animationend"] as const) window.removeEventListener(t, onPageMoved, true);
464
+ window.removeEventListener("scroll", onPageMoved, true);
465
+ for (const t of ["transitionend", "animationend"] as const) window.removeEventListener(t, onPageSettled, true);
444
466
  document.removeEventListener("visibilitychange", onPageActed);
445
467
  if (!on) return;
446
468
  if (typeof MutationObserver !== "undefined") {
@@ -456,7 +478,8 @@ function watchPage(on: boolean): void {
456
478
  }
457
479
  for (const t of ACTED) window.addEventListener(t, onPageActed, { capture: true, passive: true });
458
480
  // A toolbar's own opening ends after the press; a sticky bar sticks as the page scrolls.
459
- for (const t of ["scroll", "transitionend", "animationend"] as const) window.addEventListener(t, onPageMoved, { capture: true, passive: true });
481
+ window.addEventListener("scroll", onPageMoved, { capture: true, passive: true });
482
+ for (const t of ["transitionend", "animationend"] as const) window.addEventListener(t, onPageSettled, { capture: true, passive: true });
460
483
  document.addEventListener("visibilitychange", onPageActed);
461
484
  // A single-page app moves by history.pushState, which says nothing: the path is looked at once a second.
462
485
  lastPath = pagePath();
@@ -464,7 +487,7 @@ function watchPage(on: boolean): void {
464
487
  if (pagePath() === lastPath) return;
465
488
  lastPath = pagePath();
466
489
  layout();
467
- queueRescan(true);
490
+ queueRescan("now");
468
491
  }, 1000);
469
492
  }
470
493
 
@@ -477,7 +500,9 @@ function layout(): void {
477
500
  // While the panel is open it stays where it was opened from — never moved under the person's eyes.
478
501
  const wish = `${pagePath()}|${want.side}|${want.x}|${wantY}`;
479
502
  const prev = shown && shownFor === wish ? shown : null;
480
- const free = !isOpen || !shown ? (blocked && avoidSetting().on ? settleSpot(prev, { side: want.side, y: wantY }, blocked, MARGIN, Math.max(MARGIN, v.h - SIZE - MARGIN)) : { side: want.side, y: wantY }) : shown;
503
+ // A side not read is free here: it was left unread because the wish's own place was free, and that place wins.
504
+ const read = blocked && avoidSetting().on ? { right: blocked.right ?? [], left: blocked.left ?? [] } : null;
505
+ const free = !isOpen || !shown ? (read ? settleSpot(prev, { side: want.side, y: wantY }, read, MARGIN, Math.max(MARGIN, v.h - SIZE - MARGIN)) : { side: want.side, y: wantY }) : shown;
481
506
  shown = { side: free.side, x: want.x, y: free.y };
482
507
  shownFor = wish;
483
508
  const at = shown;
@@ -496,8 +521,13 @@ function layout(): void {
496
521
  }
497
522
  // The call's card and frame sit above the bubble while it is there and low on their side (ring.ts, callFrame.ts).
498
523
  const lift = root && root.style.display !== "none" && bubble.style.display !== "none" ? liftFor(at) : 0;
499
- if (lift) document.documentElement.style.setProperty("--lw-chat-lift", `${lift}px`);
500
- else document.documentElement.style.removeProperty("--lw-chat-lift");
524
+ // Written only when it differs from what <html> carries (read back, so a host that rewrites its style attribute
525
+ // gets it again): a property there restyles the host's whole page, and layout() runs on every read of the page.
526
+ const liftNow = lift ? `${lift}px` : "";
527
+ if (document.documentElement.style.getPropertyValue("--lw-chat-lift") !== liftNow) {
528
+ if (liftNow) document.documentElement.style.setProperty("--lw-chat-lift", liftNow);
529
+ else document.documentElement.style.removeProperty("--lw-chat-lift");
530
+ }
501
531
  setLabel();
502
532
  }
503
533
 
@@ -586,7 +616,7 @@ function followHere(on: boolean): void {
586
616
  }
587
617
  const onResize = () => {
588
618
  layout();
589
- queueRescan(true);
619
+ queueRescan("now");
590
620
  };
591
621
 
592
622
  function post(message: Record<string, unknown>): void {
@@ -665,7 +695,7 @@ export function closeSupportChat(refocus = true): void {
665
695
  void refreshUnread();
666
696
  // Closed: free to step aside again from whatever the page did meanwhile.
667
697
  layout();
668
- queueRescan(true);
698
+ queueRescan("now");
669
699
  }
670
700
 
671
701
  let stopFrames: (() => void) | null = null;