@lightworkai.official/debug-capture 0.19.4 → 0.19.6

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.
@@ -10,6 +10,7 @@
10
10
  * sign-in the panel asks (`hello`); the host's token goes to it by postMessage, to the support host's origin only
11
11
  * แจ้งปัญหา… in the panel: the panel folds and this library's report opens — its screen, its page — linked to the chat
12
12
  * the corner the call's card and frame sit above the bubble (`--lw-chat-lift`)
13
+ * its place out of the page's own floating controls, and where the desk says for a page (0.19.6, ui/bubblePlace)
13
14
  *
14
15
  * Shown only for a signed-in person (`identity` answers a token): a chat is someone's, and is read back.
15
16
  */
@@ -17,6 +18,7 @@ import { chatPlace, getConfig } from "../config";
17
18
  import { hostToken, onLiveFrame } from "../live";
18
19
  import { openSupportPortal } from "../portal";
19
20
  import { launch } from "../reporter";
21
+ import { blockedRanges, placeFor, scanColumns, settleSpot, type PlaceRule, type Side } from "./bubblePlace";
20
22
  import { followHostFont, OWN_FONTS } from "./hostFont";
21
23
  import { strings } from "./strings";
22
24
 
@@ -33,11 +35,11 @@ const CSS = `
33
35
  .bubble {
34
36
  position: fixed; z-index: 2147483000; width: ${SIZE}px; height: ${SIZE}px; border-radius: 50%;
35
37
  display: grid; place-items: center; border: 0; cursor: pointer; padding: 0;
36
- background: #00468f; color: #fff; box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3), 0 4px 10px -4px rgba(0, 0, 0, 0.2);
38
+ background: var(--lw-chat-color, #00468f); color: #fff; box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3), 0 4px 10px -4px rgba(0, 0, 0, 0.2);
37
39
  transition: transform 150ms ease, background 150ms ease;
38
40
  font-family: var(--lw-host-font, ${OWN_FONTS});
39
41
  }
40
- .bubble:hover { background: #003a78; transform: translateY(-1px); }
42
+ .bubble:hover { filter: brightness(0.92); transform: translateY(-1px); }
41
43
  .bubble { touch-action: none; }
42
44
  .bubble.dragging { cursor: grabbing; transform: scale(1.06); transition: none; box-shadow: 0 18px 35px -8px rgba(0, 0, 0, 0.4); }
43
45
  .bubble:focus-visible { outline: 3px solid rgba(0, 70, 143, 0.35); outline-offset: 3px; }
@@ -65,7 +67,7 @@ const CSS = `
65
67
  .label .text { display: block; max-width: min(240px, calc(100vw - ${SIZE + 64}px)); overflow: hidden; text-overflow: ellipsis; }
66
68
  .at-left .label { right: auto; left: calc(100% + 12px); transform: translate(-4px, -50%); }
67
69
  .at-left .label::after { right: auto; left: -4px; }
68
- .new .label { color: #00468f; }
70
+ .new .label { color: var(--lw-chat-color, #00468f); }
69
71
  .labelled .label { opacity: 1; visibility: visible; pointer-events: auto; transform: translate(0, -50%); }
70
72
  @media (hover: hover) { .bubble:hover .label { opacity: 1; visibility: visible; pointer-events: auto; transform: translate(0, -50%); } }
71
73
  .unlabelled .label, .bubble.dragging .label, .open .label { display: none; }
@@ -207,37 +209,100 @@ let opened = false;
207
209
  function setLabel(): void {
208
210
  if (!label || !bubble) return;
209
211
  const config = getConfig();
210
- const own = config.chat && config.chat !== true ? config.chat.label : undefined;
212
+ // The host's own words in code win; else the desk's (ปุ่มแชท, its look); else the shipped ones.
213
+ const hostOwn = config.chat && config.chat !== true ? config.chat.label : undefined;
214
+ const own = hostOwn !== undefined ? hostOwn : deskLook?.label;
211
215
  const l = bubbleLabel({ own, unread, opened, open: isOpen }, strings(config.locale));
212
216
  bubble.classList.toggle("unlabelled", !l);
213
217
  bubble.classList.toggle("labelled", !!l?.shown);
214
218
  bubble.classList.toggle("new", !!l?.isNew);
215
- bubble.classList.toggle("at-left", spot.side === "left");
219
+ bubble.classList.toggle("at-left", (shown ?? spot).side === "left");
216
220
  label.querySelector(".text")!.textContent = l?.text ?? "";
217
221
  }
218
222
 
223
+ /*
224
+ * The desk's look (0.19.5, the support desk's ปุ่มแชท — docs/CHAT-BUBBLE-PLAN.md): its face (or the chat glyph on its
225
+ * colour), its colour, the words beside the button — set on the desk, read with the first answer here.
226
+ */
227
+ export interface DeskLook {
228
+ label?: string;
229
+ color?: string;
230
+ /** A path on the support host; null: the chat glyph on the colour. */
231
+ faceUrl?: string | null;
232
+ /** Step aside from the page's own floating controls (0.19.6) — the desk's switch; on unless false. */
233
+ avoid?: boolean;
234
+ /** A page's own place (0.19.6): hidden there, or started in a corner at a height — the desk's rules. */
235
+ places?: PlaceRule[];
236
+ }
237
+ let deskLook: DeskLook | null = null;
238
+ /** The face's address for a desk's look: its own path on the support host, the shipped one, or none (the glyph). Pure. */
239
+ export function lookFaceUrl(host: string, look: DeskLook | null): string | null {
240
+ if (!look || look.faceUrl === undefined) return supportFaceUrl(host);
241
+ if (look.faceUrl === null) return null;
242
+ try {
243
+ return new URL(look.faceUrl.replace(/^\//, ""), base(host)).toString();
244
+ } catch {
245
+ return supportFaceUrl(host);
246
+ }
247
+ }
248
+ function wearLook(look: DeskLook | null): void {
249
+ deskLook = look;
250
+ if (!root || !bubble) return;
251
+ const color = look?.color && /^#[0-9a-f]{6}$/i.test(look.color) ? look.color : null;
252
+ if (color) root.style.setProperty("--lw-chat-color", color);
253
+ else root.style.removeProperty("--lw-chat-color");
254
+ const face = shadow?.querySelector<HTMLImageElement>(".face");
255
+ const url = lookFaceUrl(getConfig().host, look);
256
+ // Its rules and its switch may have changed where it stands.
257
+ queueRescan(true);
258
+ if (face) {
259
+ if (!url) {
260
+ bubble.classList.remove("has-face");
261
+ face.removeAttribute("src");
262
+ } else if (face.getAttribute("src") !== url) {
263
+ bubble.classList.remove("has-face");
264
+ face.src = url;
265
+ }
266
+ }
267
+ setLabel();
268
+ }
269
+
219
270
  let asking: Promise<void> | null = null;
220
271
  /** What waits for the person, read with the host's token — no token: nobody is signed in, and there is no bubble. */
221
272
  async function refreshUnread(): Promise<void> {
222
- asking ??= (async () => {
223
- try {
224
- const token = await hostToken();
225
- if (!token) return void show(false);
226
- const config = getConfig();
227
- const res = await fetch(new URL("ingest/support-unread", base(config.host)).toString(), { headers: { "x-realm-key": config.realmKey, authorization: `Bearer ${token}` } });
228
- // A support host from before the chat, or a token it does not accept: no bubble rather than one that cannot open.
229
- if (res.status === 404 || res.status === 401 || res.status === 403) return void show(false);
230
- if (!res.ok) return;
231
- show(true);
232
- const out = (await res.json()) as { total?: number };
233
- if (!isOpen) setBadge(typeof out.total === "number" ? out.total : 0);
234
- } catch {
235
- /* the next nudge asks again */
236
- } finally {
237
- asking = null;
238
- }
239
- })();
240
- return asking;
273
+ if (asking) return asking;
274
+ /*
275
+ * Kept until it settles, then let go — by THIS read only. (`asking ??= (async () => { … finally { asking = null } })()`
276
+ * lost the let-go when the read ended before its first await — `show` saying no: the finally ran before the
277
+ * assignment, and every read after was that settled one, never asked again.)
278
+ */
279
+ const run = readUnread();
280
+ asking = run;
281
+ try {
282
+ await run;
283
+ } finally {
284
+ if (asking === run) asking = null;
285
+ }
286
+ }
287
+ async function readUnread(): Promise<void> {
288
+ try {
289
+ const config = getConfig();
290
+ // The host's own say (0.19.5): not for whoever is signed in now — no bubble, nothing asked.
291
+ const opts = config.chat && config.chat !== true ? config.chat : null;
292
+ if (opts?.show && !opts.show()) return void show(false);
293
+ const token = await hostToken();
294
+ if (!token) return void show(false);
295
+ const res = await fetch(new URL("ingest/support-unread", base(config.host)).toString(), { headers: { "x-realm-key": config.realmKey, authorization: `Bearer ${token}` } });
296
+ // A support host from before the chat, a desk that turned its bubble off, or a token it does not accept: no bubble.
297
+ if (res.status === 404 || res.status === 401 || res.status === 403) return void show(false);
298
+ if (!res.ok) return;
299
+ const out = (await res.json()) as { total?: number; look?: DeskLook };
300
+ if (out.look && typeof out.look === "object") wearLook(out.look);
301
+ show(true);
302
+ if (!isOpen) setBadge(typeof out.total === "number" ? out.total : 0);
303
+ } catch {
304
+ /* the next nudge asks again */
305
+ }
241
306
  }
242
307
 
243
308
  function show(on: boolean): void {
@@ -246,6 +311,7 @@ function show(on: boolean): void {
246
311
  // Its words in the host page's own font — read again here, after the host has drawn itself.
247
312
  if (on) followHostFont(root);
248
313
  layout();
314
+ if (on) queueRescan(true);
249
315
  }
250
316
 
251
317
  /* ── Where it is: the config's place, or where the person put it ───────── */
@@ -282,24 +348,154 @@ function savePlace(): void {
282
348
  }
283
349
  }
284
350
 
351
+ /* ── Out of the page's way, and a page's own place (0.19.6, ui/bubblePlace) ─────── */
352
+
353
+ /** The host page's path — through `window`, as every other read here is. */
354
+ const pagePath = () => window.location?.pathname ?? "/";
355
+ /** Where it stands now — `spot` (or the page's rule), moved clear of the page's floating controls. */
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). */
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;
361
+ /** Hidden on this page by the desk's rule (ซ่อนปุ่มแชทในหน้านี้). */
362
+ let ruleHidden = false;
363
+
364
+ function avoidSetting(): { on: boolean; extra: string | null } {
365
+ const c = getConfig().chat;
366
+ const own = c && c !== true ? c.avoid : undefined;
367
+ if (own === false || deskLook?.avoid === false) return { on: false, extra: null };
368
+ return { on: true, extra: typeof own === "string" && own.trim() ? own.trim() : null };
369
+ }
370
+ /** Where it wants to be on this page: put by hand — kept; else the desk's rule for the page; else the config's place. */
371
+ function wanted(): BubbleSpot {
372
+ const rule = placeFor(pagePath(), deskLook?.places);
373
+ ruleHidden = rule?.show === "hide";
374
+ if (movedByHand || !rule || rule.show === "hide") return spot;
375
+ return { side: rule.show, x: spot.x, y: rule.y };
376
+ }
377
+ /** Read the page's floating controls across both corners' columns — then place again. */
378
+ function rescan(): void {
379
+ if (!bubble) return;
380
+ const { on, extra } = avoidSetting();
381
+ if (!on) {
382
+ blocked = null;
383
+ return layout();
384
+ }
385
+ 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 };
395
+ layout();
396
+ }
397
+ /*
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
399
+ * 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
+ * (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
+ * 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.
403
+ */
404
+ const RESCAN_MS = 400;
405
+ const CALM_STEPS = 3;
406
+ const URGENT_MS = 60;
407
+ let rescanTimer: ReturnType<typeof setTimeout> | undefined;
408
+ let rescanDue = 0;
409
+ let lastRescan = 0;
410
+ let calm = 0;
411
+ function queueRescan(urgent = false): void {
412
+ if (!root) return;
413
+ 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));
422
+ rescanDue = now + wait;
423
+ rescanTimer = setTimeout(() => {
424
+ rescanTimer = undefined;
425
+ if (!root || isOpen || pressed?.moved || (typeof document !== "undefined" && document.hidden)) return;
426
+ lastRescan = Date.now();
427
+ rescan();
428
+ }, wait);
429
+ }
430
+ /** The page's own changes worth a look: a toolbar opening, a page arriving — never this library's own boxes. */
431
+ let watcher: MutationObserver | null = null;
432
+ let lastPath = "";
433
+ let pathTimer: ReturnType<typeof setInterval> | undefined;
434
+ const onPageMoved = () => queueRescan();
435
+ /** What a person did, or the window itself changed: looked at at once. */
436
+ const onPageActed = () => queueRescan(true);
437
+ const ACTED = ["popstate", "hashchange", "pointerup", "keyup"] as const;
438
+ function watchPage(on: boolean): void {
439
+ watcher?.disconnect();
440
+ watcher = null;
441
+ clearInterval(pathTimer);
442
+ 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);
444
+ document.removeEventListener("visibilitychange", onPageActed);
445
+ if (!on) return;
446
+ if (typeof MutationObserver !== "undefined") {
447
+ watcher = new MutationObserver((records) => {
448
+ const theirs = records.some((r) => {
449
+ const t = r.target as Element;
450
+ if (t === document.documentElement && r.type === "attributes") return false;
451
+ return !(t.nodeType === 1 && /^LW-/.test(t.tagName)) && t !== root;
452
+ });
453
+ if (theirs) queueRescan();
454
+ });
455
+ watcher.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ["class", "style", "hidden", "open", "aria-expanded"] });
456
+ }
457
+ for (const t of ACTED) window.addEventListener(t, onPageActed, { capture: true, passive: true });
458
+ // 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 });
460
+ document.addEventListener("visibilitychange", onPageActed);
461
+ // A single-page app moves by history.pushState, which says nothing: the path is looked at once a second.
462
+ lastPath = pagePath();
463
+ pathTimer = setInterval(() => {
464
+ if (pagePath() === lastPath) return;
465
+ lastPath = pagePath();
466
+ layout();
467
+ queueRescan(true);
468
+ }, 1000);
469
+ }
470
+
285
471
  /** Everything placed from `spot` and `panelAt`, inside the window as it is now — and the call's card clear of the bubble. */
286
472
  function layout(): void {
287
473
  if (!bubble || !panel) return;
288
474
  const v = view();
289
- const y = clampBubbleY(spot.y, v.h);
290
- const other = spot.side === "right" ? "left" : "right";
291
- Object.assign(bubble.style, { [spot.side]: `${spot.x}px`, [other]: "", bottom: `${y}px`, top: "" });
475
+ const want = wanted();
476
+ const wantY = clampBubbleY(want.y, v.h);
477
+ // While the panel is open it stays where it was opened from — never moved under the person's eyes.
478
+ const wish = `${pagePath()}|${want.side}|${want.x}|${wantY}`;
479
+ 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 };
482
+ shownFor = wish;
483
+ const at = shown;
484
+ const y = at.y;
485
+ const other = at.side === "right" ? "left" : "right";
486
+ bubble.style.display = ruleHidden && !isOpen ? "none" : "";
487
+ Object.assign(bubble.style, { [at.side]: `${at.x}px`, [other]: "", bottom: `${y}px`, top: "" });
292
488
  if (panelAt) {
293
489
  // The height it had when it was picked up — a drag moves it, never resizes it under the hand.
294
490
  const box = { w: Math.min(PANEL_W, v.w - 32), h: Math.min(panelAt.h, v.h - 2 * MARGIN) };
295
491
  const p = clampPanel(panelAt, box, v);
296
492
  Object.assign(panel.style, { left: `${p.x}px`, top: `${p.y}px`, right: "", bottom: "", height: `${box.h}px` });
297
493
  } else {
298
- const a = anchorPanel({ ...spot, y }, v.h);
299
- Object.assign(panel.style, { [spot.side]: `${spot.x}px`, [other]: "", bottom: a.bottom !== undefined ? `${a.bottom}px` : "", top: a.top !== undefined ? `${a.top}px` : "", height: `${a.height}px` });
494
+ const a = anchorPanel(at, v.h);
495
+ Object.assign(panel.style, { [at.side]: `${at.x}px`, [other]: "", bottom: a.bottom !== undefined ? `${a.bottom}px` : "", top: a.top !== undefined ? `${a.top}px` : "", height: `${a.height}px` });
300
496
  }
301
497
  // The call's card and frame sit above the bubble while it is there and low on their side (ring.ts, callFrame.ts).
302
- const lift = root && root.style.display !== "none" ? liftFor({ ...spot, y }) : 0;
498
+ const lift = root && root.style.display !== "none" && bubble.style.display !== "none" ? liftFor(at) : 0;
303
499
  if (lift) document.documentElement.style.setProperty("--lw-chat-lift", `${lift}px`);
304
500
  else document.documentElement.style.removeProperty("--lw-chat-lift");
305
501
  setLabel();
@@ -341,7 +537,9 @@ function onBubbleUp(e: PointerEvent): void {
341
537
  panelAt = null;
342
538
  savePlace();
343
539
  }
344
- layout();
540
+ // Let go on a toolbar: to the nearest free place, read off the page as it is now.
541
+ if (avoidSetting().on) rescan();
542
+ else layout();
345
543
  }
346
544
 
347
545
  /* ── The panel, dragged by its head — the press is in the frame (as the call's, ui/callFrame) ─── */
@@ -386,7 +584,10 @@ function followHere(on: boolean): void {
386
584
  };
387
585
  for (const t of ["pointermove", "pointerup", "pointercancel"] as const) window.addEventListener(t, hostFollow, true);
388
586
  }
389
- const onResize = () => layout();
587
+ const onResize = () => {
588
+ layout();
589
+ queueRescan(true);
590
+ };
390
591
 
391
592
  function post(message: Record<string, unknown>): void {
392
593
  frame?.contentWindow?.postMessage({ source: HOST, ...message }, hostOrigin());
@@ -462,6 +663,9 @@ export function closeSupportChat(refocus = true): void {
462
663
  post({ type: "hidden" });
463
664
  if (refocus) bubble?.focus();
464
665
  void refreshUnread();
666
+ // Closed: free to step aside again from whatever the page did meanwhile.
667
+ layout();
668
+ queueRescan(true);
465
669
  }
466
670
 
467
671
  let stopFrames: (() => void) | null = null;
@@ -491,8 +695,12 @@ export function mountSupportChat(): void {
491
695
  // The desk's face (the owner, 10-31: "all the support icon that user see … use this icon"): the support host's own,
492
696
  // the one its replies wear — the chat glyph until it is there, and if it never comes.
493
697
  const face = shadow.querySelector<HTMLImageElement>(".face")!;
494
- face.addEventListener("load", () => bubble?.classList.add("has-face"), { once: true });
495
- face.src = supportFaceUrl(config.host);
698
+ // Every picture it is given (the desk's look may change it, 0.19.5): worn once loaded, the glyph if it fails.
699
+ face.addEventListener("load", () => {
700
+ if (face.getAttribute("src")) bubble?.classList.add("has-face");
701
+ });
702
+ face.addEventListener("error", () => bubble?.classList.remove("has-face"));
703
+ face.src = lookFaceUrl(config.host, deskLook) ?? supportFaceUrl(config.host);
496
704
  // Where the person left it on this device, else the config's place (0.19.2).
497
705
  loadPlace();
498
706
  layout();
@@ -510,6 +718,7 @@ export function mountSupportChat(): void {
510
718
  document.addEventListener("keydown", onKey);
511
719
  window.addEventListener("focus", onFocus);
512
720
  window.addEventListener("resize", onResize);
721
+ watchPage(true);
513
722
  // The desk wrote, or opened a เรื่อง, or read: the badge asks again — while the panel is open, the panel tells it.
514
723
  let settle: ReturnType<typeof setTimeout> | undefined;
515
724
  stopFrames = onLiveFrame(() => {
@@ -521,13 +730,27 @@ export function mountSupportChat(): void {
521
730
  }
522
731
 
523
732
  /** Take it away — for a host that unmounts, and for the tests. */
733
+ /** Ask again whether there is a bubble, and what waits (0.19.5) — after a sign-in or a sign-out, or `show` changed. */
734
+ export function refreshSupportChat(): Promise<void> {
735
+ return root ? refreshUnread() : Promise.resolve();
736
+ }
737
+
524
738
  export function unmountSupportChat(): void {
739
+ deskLook = null;
525
740
  stopFrames?.();
526
741
  stopFrames = null;
527
742
  window.removeEventListener("message", onMessage);
528
743
  document.removeEventListener("keydown", onKey);
529
744
  window.removeEventListener("focus", onFocus);
530
745
  window.removeEventListener("resize", onResize);
746
+ watchPage(false);
747
+ clearTimeout(rescanTimer);
748
+ rescanTimer = undefined;
749
+ calm = 0;
750
+ shown = null;
751
+ shownFor = "";
752
+ blocked = null;
753
+ ruleHidden = false;
531
754
  followHere(false);
532
755
  root?.remove();
533
756
  root = shadow = panel = frame = bubble = badge = label = null;