@lightworkai.official/debug-capture 0.19.6 → 0.19.8

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.
@@ -14,11 +14,14 @@
14
14
  * Content that scrolls under is not: a floating button over content is what a floating button is. A host may name
15
15
  * more (`chat.avoid: "<selector>"`) or turn it off (`false`).
16
16
  *
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.
21
- *
17
+ * THE NEAREST FREE PLACE (`placeSpot`). What a place costs is the climb plus the move in from its edge, and
18
+ * SWITCH_COST more on the other side. Up or down its own column; or a column further in, just past what stands in
19
+ * the way; or the other corner. A FAB lifts it above. A toolbar column as tall as the window puts it just inside the
20
+ * toolbar, at the same height. 0.19.7 only knew the two corner columns, so on a short window, with the ERP's tray
21
+ * filling the right edge and its sidebar the left, it found no free place and stayed on the tray (the owner's
22
+ * screenshot, 11-02). When a nearer place frees up it goes back: the tray shut, it is home again. A shift of less than
23
+ * STAY_MARGIN does not move it, so it does not jitter as the page settles.
24
+
22
25
  * A PAGE'S OWN PLACE. The desk's rules (ปุ่มแชท › ตำแหน่งปุ่ม): pages whose path starts so — hidden there, or
23
26
  * started in a corner at a height. The most specific rule wins; a person who moved it keeps their place.
24
27
  *
@@ -35,6 +38,8 @@ export type Side = "right" | "left";
35
38
  export declare const CLEAR = 8;
36
39
  /** What moving to the other side costs, in px of climbing — a short lift stays on its side, a long one crosses. */
37
40
  export declare const SWITCH_COST = 160;
41
+ /** A free place this much nearer (in the same px of cost) is needed to move it from one it already stands in. */
42
+ export declare const STAY_MARGIN = 48;
38
43
  /** An obstacle is smaller than this share of the window — a box as big as the page is the page. */
39
44
  export declare const OBSTACLE_MAX_AREA = 0.34;
40
45
  /**
@@ -47,31 +52,28 @@ export declare function blockedRanges(obstacles: readonly Rect[], col: {
47
52
  }, viewH: number, size: number): [number, number][];
48
53
  /** The free height nearest `want` within [min, max], outside every range (open ends) — null when none. Up wins a tie. Pure. */
49
54
  export declare function nearestFreeY(want: number, ranges: readonly [number, number][], min: number, max: number): number | null;
50
- /**
51
- * Where the bubble stands: `want` on its side when free, else the nearest free height there — or the other side's when
52
- * that is cheaper (SWITCH_COST plus its own move). Nothing free anywhere: `want`, as it was. Pure.
53
- */
54
- export declare function chooseSpot(want: {
55
- side: Side;
56
- y: number;
57
- }, free: Record<Side, readonly [number, number][]>, min: number, max: number): {
55
+ export interface Spot {
58
56
  side: Side;
57
+ /** From its side's edge, px. */
58
+ x: number;
59
+ /** Up from the bottom, px. */
59
60
  y: number;
60
- };
61
+ }
62
+ /** The page's boxes across the bubble's column at `x` from `side`'s edge — read off the page, or given in the tests. */
63
+ export type ReadColumn = (side: Side, x: number) => readonly Rect[];
64
+ /** How many columns further in it looks on a side, past what stands in the way. */
65
+ export declare const MAX_SHIFTS = 3;
61
66
  /**
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.
67
+ * Where the bubble stands: `want` when it is free there; else the cheapest free place — up or down its column, a column
68
+ * further in just past what stands in the way (a toolbar column as tall as the window: just left of it, at the same
69
+ * height), or the other side's (SWITCH_COST more). What a place costs is the climb plus the move in. A place it already
70
+ * stands in (`prev`), still free and within STAY_MARGIN of the best, is kept — no jitter as the page settles. Columns
71
+ * are read only as needed (`read`), and never past the middle of the window. Nothing free anywhere: `want`. Pure.
64
72
  */
65
- export declare function settleSpot(prev: {
66
- side: Side;
67
- y: number;
68
- } | null, want: {
69
- side: Side;
70
- y: number;
71
- }, free: Record<Side, readonly [number, number][]>, min: number, max: number): {
72
- side: Side;
73
- y: number;
74
- };
73
+ export declare function placeSpot(want: Spot, read: ReadColumn, view: {
74
+ w: number;
75
+ h: number;
76
+ }, size: number, min: number, max: number, prev?: Spot | null): Spot;
75
77
  export interface PlaceRule {
76
78
  /** "/saraban/edoc-management" — the page and every page under it; `*` one segment, `**` any. */
77
79
  path: string;
@@ -89,7 +91,9 @@ export declare function placeFor(pathname: string, rules: readonly PlaceRule[] |
89
91
  * - a host's selector (`extra`): what it names, whole, wherever it is;
90
92
  * - in a small floating box (its nearest `fixed`, or `sticky` stuck at its edge): what shows there, by its own box —
91
93
  * the control it is part of, else itself;
92
- * - in a box as big as the window, or on a page that does not scroll: a control not inside a scroller;
94
+ * - in a box as big as the window, or on a page that does not scroll: a control not inside a scroller — a disabled one
95
+ * too, found by its own selector, as no hit test reaches it (the ERP's ผลการจำแนก panel on a phone, 11-02: its
96
+ * greyed มอบหมายผู้ดำเนินการ was under the bubble);
93
97
  * - anything else scrolls with the page, and is not.
94
98
  */
95
99
  export declare function scanColumns(cols: readonly {
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.8",
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",
@@ -14,11 +14,14 @@
14
14
  * Content that scrolls under is not: a floating button over content is what a floating button is. A host may name
15
15
  * more (`chat.avoid: "<selector>"`) or turn it off (`false`).
16
16
  *
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.
21
- *
17
+ * THE NEAREST FREE PLACE (`placeSpot`). What a place costs is the climb plus the move in from its edge, and
18
+ * SWITCH_COST more on the other side. Up or down its own column; or a column further in, just past what stands in
19
+ * the way; or the other corner. A FAB lifts it above. A toolbar column as tall as the window puts it just inside the
20
+ * toolbar, at the same height. 0.19.7 only knew the two corner columns, so on a short window, with the ERP's tray
21
+ * filling the right edge and its sidebar the left, it found no free place and stayed on the tray (the owner's
22
+ * screenshot, 11-02). When a nearer place frees up it goes back: the tray shut, it is home again. A shift of less than
23
+ * STAY_MARGIN does not move it, so it does not jitter as the page settles.
24
+
22
25
  * A PAGE'S OWN PLACE. The desk's rules (ปุ่มแชท › ตำแหน่งปุ่ม): pages whose path starts so — hidden there, or
23
26
  * started in a corner at a height. The most specific rule wins; a person who moved it keeps their place.
24
27
  *
@@ -37,6 +40,8 @@ export type Side = "right" | "left";
37
40
  export const CLEAR = 8;
38
41
  /** What moving to the other side costs, in px of climbing — a short lift stays on its side, a long one crosses. */
39
42
  export const SWITCH_COST = 160;
43
+ /** A free place this much nearer (in the same px of cost) is needed to move it from one it already stands in. */
44
+ export const STAY_MARGIN = 48;
40
45
  /** An obstacle is smaller than this share of the window — a box as big as the page is the page. */
41
46
  export const OBSTACLE_MAX_AREA = 0.34;
42
47
 
@@ -72,33 +77,58 @@ export function nearestFreeY(want: number, ranges: readonly [number, number][],
72
77
  });
73
78
  }
74
79
 
75
- /**
76
- * Where the bubble stands: `want` on its side when free, else the nearest free height there — or the other side's when
77
- * that is cheaper (SWITCH_COST plus its own move). Nothing free anywhere: `want`, as it was. Pure.
78
- */
79
- export function chooseSpot(want: { side: Side; y: number }, free: Record<Side, readonly [number, number][]>, min: number, max: number): { side: Side; y: number } {
80
- const other: Side = want.side === "right" ? "left" : "right";
81
- const here = nearestFreeY(want.y, free[want.side], min, max);
82
- const there = nearestFreeY(want.y, free[other], min, max);
83
- if (here === null && there === null) return want;
84
- const costHere = here === null ? Infinity : Math.abs(here - want.y);
85
- const costThere = there === null ? Infinity : SWITCH_COST + Math.abs(there - want.y);
86
- return costHere <= costThere ? { side: want.side, y: here! } : { side: other, y: there! };
80
+ export interface Spot {
81
+ side: Side;
82
+ /** From its side's edge, px. */
83
+ x: number;
84
+ /** Up from the bottom, px. */
85
+ y: number;
87
86
  }
87
+ /** The page's boxes across the bubble's column at `x` from `side`'s edge — read off the page, or given in the tests. */
88
+ export type ReadColumn = (side: Side, x: number) => readonly Rect[];
89
+ /** How many columns further in it looks on a side, past what stands in the way. */
90
+ export const MAX_SHIFTS = 3;
88
91
 
89
92
  /**
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.
93
+ * Where the bubble stands: `want` when it is free there; else the cheapest free place — up or down its column, a column
94
+ * further in just past what stands in the way (a toolbar column as tall as the window: just left of it, at the same
95
+ * height), or the other side's (SWITCH_COST more). What a place costs is the climb plus the move in. A place it already
96
+ * stands in (`prev`), still free and within STAY_MARGIN of the best, is kept — no jitter as the page settles. Columns
97
+ * are read only as needed (`read`), and never past the middle of the window. Nothing free anywhere: `want`. Pure.
92
98
  */
93
- export function settleSpot(
94
- prev: { side: Side; y: number } | null,
95
- want: { side: Side; y: number },
96
- free: Record<Side, readonly [number, number][]>,
97
- min: number,
98
- max: number,
99
- ): { 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);
99
+ export function placeSpot(want: Spot, read: ReadColumn, view: { w: number; h: number }, size: number, min: number, max: number, prev: Spot | null = null): Spot {
100
+ const boxes = new Map<string, readonly Rect[]>();
101
+ const at = (side: Side, x: number) => {
102
+ const k = `${side}:${x}`;
103
+ if (!boxes.has(k)) boxes.set(k, read(side, x));
104
+ return boxes.get(k)!;
105
+ };
106
+ const col = (side: Side, x: number) => (side === "right" ? { left: view.w - x - size, right: view.w - x } : { left: x, right: x + size });
107
+ const ranges = (side: Side, x: number) => blockedRanges(at(side, x), col(side, x), view.h, size);
108
+ const cost = (s: Spot) => Math.abs(s.y - want.y) + Math.abs(s.x - want.x) + (s.side === want.side ? 0 : SWITCH_COST);
109
+ const freeAt = (s: Spot) => nearestFreeY(s.y, ranges(s.side, s.x), min, max) === s.y;
110
+ const keep = (best: Spot) => (prev && (prev.side !== best.side || prev.x !== best.x || prev.y !== best.y) && cost(prev) <= cost(best) + STAY_MARGIN && freeAt(prev) ? prev : best);
111
+ if (freeAt(want)) return keep(want);
112
+ let best: Spot | null = null;
113
+ const other: Side = want.side === "right" ? "left" : "right";
114
+ for (const side of [want.side, other]) {
115
+ const away = side === want.side ? 0 : SWITCH_COST;
116
+ if (best && away >= cost(best)) break;
117
+ let x = want.x;
118
+ for (let i = 0; i <= MAX_SHIFTS; i++) {
119
+ const y = nearestFreeY(want.y, ranges(side, x), min, max);
120
+ if (y !== null && (!best || cost({ side, x, y }) < cost(best))) best = { side, x, y };
121
+ // What stands where it wants to be, at this column: the next column in is just past it.
122
+ const c = col(side, x);
123
+ const band = { top: view.h - want.y - size - CLEAR, bottom: view.h - want.y + CLEAR };
124
+ const inWay = at(side, x).filter((r) => r.top < band.bottom && r.bottom > band.top && r.left < c.right && r.right > c.left);
125
+ if (!inWay.length) break;
126
+ const next = Math.round(side === "right" ? Math.max(...inWay.map((r) => view.w - r.left + CLEAR)) : Math.max(...inWay.map((r) => r.right + CLEAR)));
127
+ if (next <= x || next + size > view.w / 2 || (best && away + next - want.x >= cost(best))) break;
128
+ x = next;
129
+ }
130
+ }
131
+ return best ? keep(best) : want;
102
132
  }
103
133
 
104
134
  /* ── A page's own place: the desk's rules ───────────────────────────────── */
@@ -145,6 +175,8 @@ const OURS = /^LW-/;
145
175
  const CONTROL =
146
176
  'a[href],button,input,select,textarea,summary,label,[role="button"],[role="link"],[role="tab"],[role="menuitem"],[role="checkbox"],[role="switch"],[role="radio"],[role="option"],[role="slider"],[role="combobox"],[role="textbox"],[contenteditable=""],[contenteditable="true"],[tabindex]:not([tabindex="-1"])';
147
177
  const SCROLLS = /(auto|scroll|overlay)/;
178
+ /** A control that takes no press now — no hit test finds it (`disabled:pointer-events-none`), yet it shows, and takes one soon. */
179
+ const DISABLED = 'button:disabled,input:disabled,select:disabled,textarea:disabled,[aria-disabled="true"]';
148
180
 
149
181
  /**
150
182
  * The page's pieces the bubble must not cover, for each column — sampled every `step` px down it at three points
@@ -152,12 +184,15 @@ const SCROLLS = /(auto|scroll|overlay)/;
152
184
  * - a host's selector (`extra`): what it names, whole, wherever it is;
153
185
  * - in a small floating box (its nearest `fixed`, or `sticky` stuck at its edge): what shows there, by its own box —
154
186
  * the control it is part of, else itself;
155
- * - in a box as big as the window, or on a page that does not scroll: a control not inside a scroller;
187
+ * - in a box as big as the window, or on a page that does not scroll: a control not inside a scroller — a disabled one
188
+ * too, found by its own selector, as no hit test reaches it (the ERP's ผลการจำแนก panel on a phone, 11-02: its
189
+ * greyed มอบหมายผู้ดำเนินการ was under the bubble);
156
190
  * - anything else scrolls with the page, and is not.
157
191
  */
158
192
  export function scanColumns(cols: readonly { left: number; right: number }[], view: { w: number; h: number }, opts: { step?: number; extra?: string | null } = {}): Rect[][] {
159
193
  if (typeof document === "undefined" || typeof document.elementsFromPoint !== "function") return cols.map(() => []);
160
- const step = opts.step ?? 12;
194
+ // 16 px: anything a hand presses is taller. Each point is a hit test, ~30 µs on the ERP's เอกสาร page.
195
+ const step = opts.step ?? 16;
161
196
  const html = document.documentElement;
162
197
  const body = document.body;
163
198
  const styles = new Map<Element, CSSStyleDeclaration>();
@@ -205,6 +240,16 @@ export function scanColumns(cols: readonly { left: number; right: number }[], vi
205
240
  return { float, scrolled, opacity };
206
241
  };
207
242
  const pins = new Map<Element, ReturnType<typeof pinOf>>();
243
+ /*
244
+ * The page's topmost piece at a point. elementsFromPoint hit-tests every layer down to the root — 450 of them took
245
+ * 30–37 ms on the ERP's เอกสาร page, against 14 ms for elementFromPoint, which stops at the first; the whole stack
246
+ * is asked for only where this library's own box is the first.
247
+ */
248
+ const topAt = (x: number, y: number): Element | null => {
249
+ const first = document.elementFromPoint(x, y);
250
+ if (!first || !OURS.test(first.tagName)) return first;
251
+ return document.elementsFromPoint(x, y).find((e) => !OURS.test(e.tagName)) ?? null;
252
+ };
208
253
  return cols.map((col) => {
209
254
  const seen = new Set<Element>();
210
255
  const kept = new Set<Element>();
@@ -221,10 +266,12 @@ export function scanColumns(cols: readonly { left: number; right: number }[], vi
221
266
  right: r.right,
222
267
  });
223
268
  };
269
+ /** The frames as big as the window met down this column (null: a page that does not scroll) — their disabled controls are looked for below. */
270
+ const frames = new Set<Element | null>();
224
271
  const xs = [col.left + 6, (col.left + col.right) / 2, col.right - 6];
225
272
  for (let y = step / 2; y < view.h; y += step) {
226
273
  for (const x of xs) {
227
- const top = document.elementsFromPoint(x, y).find((e) => !OURS.test(e.tagName));
274
+ const top = topAt(x, y);
228
275
  if (!top || top === html || top === body || seen.has(top)) continue;
229
276
  seen.add(top);
230
277
  if (opts.extra) {
@@ -249,10 +296,23 @@ export function scanColumns(cols: readonly { left: number; right: number }[], vi
249
296
  continue;
250
297
  }
251
298
  // The page itself — a scrolling page, or what is in a frame as big as the window: only a control that stays put.
299
+ if (!scrolled && (float || !pageScrolls)) frames.add(float);
252
300
  if (scrolled || !own || big(own.getBoundingClientRect())) continue;
253
301
  keep(own);
254
302
  }
255
303
  }
304
+ for (const frame of frames) {
305
+ for (const el of (frame ?? body)?.querySelectorAll(DISABLED) ?? []) {
306
+ const r = el.getBoundingClientRect();
307
+ if (!r.width || !r.height || r.right <= col.left || r.left >= col.right || big(r) || style(el).visibility === "hidden") continue;
308
+ const pin = pins.get(el) ?? (pins.set(el, pinOf(el)), pins.get(el)!);
309
+ if (pin.float !== frame || pin.scrolled || pin.opacity < 0.05) continue;
310
+ // Not under another layer: what is on top at its middle (in this column) is it, or the frame's piece around it.
311
+ const on = topAt(Math.min(Math.max((r.left + r.right) / 2, col.left + 6), col.right - 6), (r.top + r.bottom) / 2);
312
+ if (on && on !== el && !el.contains(on) && !on.contains(el)) continue;
313
+ keep(el);
314
+ }
315
+ }
256
316
  return out;
257
317
  });
258
318
  }
@@ -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, placeFor, placeSpot, scanColumns, type PlaceRule, type Rect, 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 ───────── */
@@ -354,10 +354,27 @@ function savePlace(): void {
354
354
  const pagePath = () => window.location?.pathname ?? "/";
355
355
  /** Where it stands now — `spot` (or the page's rule), moved clear of the page's floating controls. */
356
356
  let shown: BubbleSpot | null = null;
357
- /** What `shown` was chosen for — the page and the wish; while they hold, a free `shown` is kept (settleSpot). */
357
+ /** What `shown` was chosen for — the page and the wish; while they hold, a free `shown` is kept (placeSpot's prev). */
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 page's boxes across each column read since the last look, for the window size they were read at — null: not
361
+ * looked at yet, or not stepping aside. `placeSpot` asks for columns as it needs them; one not read is read then.
362
+ */
363
+ let columns: { w: number; h: number; boxes: Map<string, Rect[]> } | null = null;
364
+ /** What the last placing saw at home and chose — a look that changes neither is "nothing new" (queueRescan). */
365
+ let placedSig = "";
366
+ function readColumn(side: Side, x: number): Rect[] {
367
+ const v = view();
368
+ if (!columns || columns.w !== v.w || columns.h !== v.h) columns = { w: v.w, h: v.h, boxes: new Map() };
369
+ const k = `${side}:${x}`;
370
+ let boxes = columns.boxes.get(k);
371
+ if (!boxes) {
372
+ const col = side === "right" ? { left: v.w - x - SIZE, right: v.w - x } : { left: x, right: x + SIZE };
373
+ boxes = scanColumns([col], v, { extra: avoidSetting().extra })[0] ?? [];
374
+ columns.boxes.set(k, boxes);
375
+ }
376
+ return boxes;
377
+ }
361
378
  /** Hidden on this page by the desk's rule (ซ่อนปุ่มแชทในหน้านี้). */
362
379
  let ruleHidden = false;
363
380
 
@@ -374,32 +391,29 @@ function wanted(): BubbleSpot {
374
391
  if (movedByHand || !rule || rule.show === "hide") return spot;
375
392
  return { side: rule.show, x: spot.x, y: rule.y };
376
393
  }
377
- /** Read the page's floating controls across both corners' columns — then place again. */
394
+ /** Look at the page afresh — its columns read again as placing asks for them — and place again. */
378
395
  function rescan(): void {
379
396
  if (!bubble) return;
380
- const { on, extra } = avoidSetting();
381
- if (!on) {
382
- blocked = null;
397
+ if (!avoidSetting().on) {
398
+ columns = null;
383
399
  return layout();
384
400
  }
401
+ const before = placedSig;
385
402
  const v = view();
386
- 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);
392
- // 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 };
403
+ columns = { w: v.w, h: v.h, boxes: new Map() };
395
404
  layout();
405
+ // Nothing new where it could stand: the page is looked at less often while that holds (queueRescan).
406
+ calm = placedSig === before ? Math.min(calm + 1, CALM_STEPS) : 0;
396
407
  }
397
408
  /*
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
409
+ * 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
410
  * 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
411
  * (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
412
  * 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.
413
+ * and resets it; a transition or animation that ends in a corner's column — something there just finished appearing
414
+ * — 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
415
+ * clear, and the next waited out the doubling). Never while the panel is open (it does not move then), while it is
416
+ * dragged, or in a hidden tab.
403
417
  */
404
418
  const RESCAN_MS = 400;
405
419
  const CALM_STEPS = 3;
@@ -408,17 +422,14 @@ let rescanTimer: ReturnType<typeof setTimeout> | undefined;
408
422
  let rescanDue = 0;
409
423
  let lastRescan = 0;
410
424
  let calm = 0;
411
- function queueRescan(urgent = false): void {
425
+ function queueRescan(when: "now" | "soon" | "later" = "later"): void {
412
426
  if (!root) return;
413
427
  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));
428
+ if (when !== "later") calm = 0;
429
+ const wait = when === "now" ? URGENT_MS : Math.max(0, RESCAN_MS * 2 ** calm - (now - lastRescan));
430
+ // One read waiting at a time — moved sooner when this asks sooner.
431
+ if (rescanTimer && rescanDue <= now + wait) return;
432
+ clearTimeout(rescanTimer);
422
433
  rescanDue = now + wait;
423
434
  rescanTimer = setTimeout(() => {
424
435
  rescanTimer = undefined;
@@ -431,16 +442,31 @@ function queueRescan(urgent = false): void {
431
442
  let watcher: MutationObserver | null = null;
432
443
  let lastPath = "";
433
444
  let pathTimer: ReturnType<typeof setInterval> | undefined;
434
- const onPageMoved = () => queueRescan();
445
+ const ours = (t: EventTarget | null) => t instanceof Element && (t === root || /^LW-/.test(t.tagName));
446
+ const onPageMoved = (e: Event) => {
447
+ if (!ours(e.target)) queueRescan();
448
+ };
449
+ /** A transition or animation ended: soon when it was in a corner's column, where the bubble may stand. */
450
+ const onPageSettled = (e: Event) => {
451
+ if (ours(e.target)) return;
452
+ const r = e.target instanceof Element ? e.target.getBoundingClientRect() : null;
453
+ const v = view();
454
+ const x = spot.x;
455
+ const inColumn = !!r && r.width > 0 && (r.left < x + SIZE || r.right > v.w - x - SIZE);
456
+ queueRescan(inColumn ? "soon" : "later");
457
+ };
435
458
  /** What a person did, or the window itself changed: looked at at once. */
436
- const onPageActed = () => queueRescan(true);
459
+ const onPageActed = (e: Event) => {
460
+ if (!ours(e.target)) queueRescan("now");
461
+ };
437
462
  const ACTED = ["popstate", "hashchange", "pointerup", "keyup"] as const;
438
463
  function watchPage(on: boolean): void {
439
464
  watcher?.disconnect();
440
465
  watcher = null;
441
466
  clearInterval(pathTimer);
442
467
  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);
468
+ window.removeEventListener("scroll", onPageMoved, true);
469
+ for (const t of ["transitionend", "animationend"] as const) window.removeEventListener(t, onPageSettled, true);
444
470
  document.removeEventListener("visibilitychange", onPageActed);
445
471
  if (!on) return;
446
472
  if (typeof MutationObserver !== "undefined") {
@@ -452,11 +478,12 @@ function watchPage(on: boolean): void {
452
478
  });
453
479
  if (theirs) queueRescan();
454
480
  });
455
- watcher.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ["class", "style", "hidden", "open", "aria-expanded"] });
481
+ watcher.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ["class", "style", "hidden", "open", "aria-expanded", "disabled", "aria-disabled"] });
456
482
  }
457
483
  for (const t of ACTED) window.addEventListener(t, onPageActed, { capture: true, passive: true });
458
484
  // 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 });
485
+ window.addEventListener("scroll", onPageMoved, { capture: true, passive: true });
486
+ for (const t of ["transitionend", "animationend"] as const) window.addEventListener(t, onPageSettled, { capture: true, passive: true });
460
487
  document.addEventListener("visibilitychange", onPageActed);
461
488
  // A single-page app moves by history.pushState, which says nothing: the path is looked at once a second.
462
489
  lastPath = pagePath();
@@ -464,7 +491,7 @@ function watchPage(on: boolean): void {
464
491
  if (pagePath() === lastPath) return;
465
492
  lastPath = pagePath();
466
493
  layout();
467
- queueRescan(true);
494
+ queueRescan("now");
468
495
  }, 1000);
469
496
  }
470
497
 
@@ -477,9 +504,12 @@ function layout(): void {
477
504
  // While the panel is open it stays where it was opened from — never moved under the person's eyes.
478
505
  const wish = `${pagePath()}|${want.side}|${want.x}|${wantY}`;
479
506
  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;
481
- shown = { side: free.side, x: want.x, y: free.y };
507
+ const home = { side: want.side, x: want.x, y: wantY };
508
+ const placing = !!columns && avoidSetting().on;
509
+ const free = !isOpen || !shown ? (placing ? placeSpot(home, readColumn, v, SIZE, MARGIN, Math.max(MARGIN, v.h - SIZE - MARGIN), prev) : home) : shown;
510
+ shown = { side: free.side, x: free.x, y: free.y };
482
511
  shownFor = wish;
512
+ if (placing) placedSig = JSON.stringify([blockedRanges(readColumn(home.side, home.x), home.side === "right" ? { left: v.w - home.x - SIZE, right: v.w - home.x } : { left: home.x, right: home.x + SIZE }, v.h, SIZE), shown]);
483
513
  const at = shown;
484
514
  const y = at.y;
485
515
  const other = at.side === "right" ? "left" : "right";
@@ -496,8 +526,13 @@ function layout(): void {
496
526
  }
497
527
  // The call's card and frame sit above the bubble while it is there and low on their side (ring.ts, callFrame.ts).
498
528
  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");
529
+ // Written only when it differs from what <html> carries (read back, so a host that rewrites its style attribute
530
+ // gets it again): a property there restyles the host's whole page, and layout() runs on every read of the page.
531
+ const liftNow = lift ? `${lift}px` : "";
532
+ if (document.documentElement.style.getPropertyValue("--lw-chat-lift") !== liftNow) {
533
+ if (liftNow) document.documentElement.style.setProperty("--lw-chat-lift", liftNow);
534
+ else document.documentElement.style.removeProperty("--lw-chat-lift");
535
+ }
501
536
  setLabel();
502
537
  }
503
538
 
@@ -586,7 +621,7 @@ function followHere(on: boolean): void {
586
621
  }
587
622
  const onResize = () => {
588
623
  layout();
589
- queueRescan(true);
624
+ queueRescan("now");
590
625
  };
591
626
 
592
627
  function post(message: Record<string, unknown>): void {
@@ -665,7 +700,7 @@ export function closeSupportChat(refocus = true): void {
665
700
  void refreshUnread();
666
701
  // Closed: free to step aside again from whatever the page did meanwhile.
667
702
  layout();
668
- queueRescan(true);
703
+ queueRescan("now");
669
704
  }
670
705
 
671
706
  let stopFrames: (() => void) | null = null;
@@ -749,7 +784,8 @@ export function unmountSupportChat(): void {
749
784
  calm = 0;
750
785
  shown = null;
751
786
  shownFor = "";
752
- blocked = null;
787
+ columns = null;
788
+ placedSig = "";
753
789
  ruleHidden = false;
754
790
  followHere(false);
755
791
  root?.remove();