@lightworkai.official/debug-capture 0.19.0 → 0.19.2

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.
@@ -1,7 +1,68 @@
1
1
  /** The panel's address: the support host's page, told the realm, signing in by hand-off (no token in the address). */
2
2
  export declare function supportFrameUrl(host: string, realmKey: string): string;
3
+ /** The desk's face on the button — the support host's public file, the same picture as its replies (0.19.2). */
4
+ export declare function supportFaceUrl(host: string): string;
5
+ /**
6
+ * How far the call's card and frame rise from their own 16px so they clear the bubble — its offset, its size and the
7
+ * gap above it (0.19.1: 0.19.0 assumed the bubble at the corner, and a raised one sat under a ringing call). Pure.
8
+ */
9
+ export declare function chatLift(bubbleY: number): number;
10
+ /** The panel above the bubble, never past the top of the window — its height for the bubble's offset (0.19.1). Pure. */
11
+ export declare function panelHeight(bubbleY: number): string;
12
+ export interface BubbleSpot {
13
+ side: "right" | "left";
14
+ /** In from that side, px. */
15
+ x: number;
16
+ /** Up from the bottom, px — to the bubble's lower edge. */
17
+ y: number;
18
+ }
19
+ /** Let go at (px, py), its centre: the nearer side, the height it was dropped at — inside the window. Pure. */
20
+ export declare function settleBubble(px: number, py: number, view: {
21
+ w: number;
22
+ h: number;
23
+ }, inset: number): BubbleSpot;
24
+ /** A height that keeps the whole bubble on screen. Pure. */
25
+ export declare function clampBubbleY(y: number, viewH: number): number;
26
+ /** How far the call's card rises to clear the bubble: only a bubble on its side, low enough to meet it. Pure. */
27
+ export declare function liftFor(spot: BubbleSpot): number;
28
+ /**
29
+ * The panel by the bubble, where it was not dragged: above it when the room above is enough (or more than below),
30
+ * else below it — its own height, never past the window. Pure.
31
+ */
32
+ export declare function anchorPanel(spot: BubbleSpot, viewH: number): {
33
+ bottom?: number;
34
+ top?: number;
35
+ height: number;
36
+ };
37
+ /** A dragged panel's top-left, inside the window by the margin. Pure. */
38
+ export declare function clampPanel(p: {
39
+ x: number;
40
+ y: number;
41
+ }, box: {
42
+ w: number;
43
+ h: number;
44
+ }, view: {
45
+ w: number;
46
+ h: number;
47
+ }): {
48
+ x: number;
49
+ y: number;
50
+ };
3
51
  /** The badge's number: `99+` past 99; nothing at 0. Pure, for the tests. */
4
52
  export declare function badgeText(n: number): string;
53
+ export declare function bubbleLabel(s: {
54
+ own?: string | false;
55
+ unread: number;
56
+ opened: boolean;
57
+ open: boolean;
58
+ }, t: {
59
+ chatLabel: string;
60
+ chatLabelNew: string;
61
+ }): {
62
+ text: string;
63
+ shown: boolean;
64
+ isNew: boolean;
65
+ } | null;
5
66
  /** Open the panel — from the bubble, or from the host app's own button (`openSupportChat`). */
6
67
  export declare function openSupportChat(): void;
7
68
  /** Fold the panel — it stays loaded, so the chat is there as it was the next time. */
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The host page's own font, for what this library draws on the host's page — the chat bubble's words, the call's card
3
+ * (0.19.2, the owner: "make sure we should correct font as well, now look like font not work"). A host loads its fonts
4
+ * for its own document (the ERP: Anuphan, by next/font, under a generated name); a shadow root reset by `all: initial`
5
+ * asked for families the host never loaded, and Thai fell to the system's looped face.
6
+ *
7
+ * Read from the host's body and set on the shadow host as `--lw-host-font` — a custom property, which `all: initial`
8
+ * does not reset — and the CSS falls back to its own list when there is none. A page that never chose a font (the
9
+ * browser's serif default) is not followed.
10
+ */
11
+ export declare const OWN_FONTS = "\"Inter Variable\", \"Noto Sans Thai Variable\", \"IBM Plex Sans Thai\", -apple-system, \"Segoe UI\", Roboto, \"Noto Sans Thai\", sans-serif";
12
+ /** The host's family as its body is drawn — "" when it never chose one. Pure but for the read. */
13
+ export declare function hostFontFamily(computed: string | null | undefined): string;
14
+ /** Give a shadow host the page's font (again: a host may set its font after this library mounted). */
15
+ export declare function followHostFont(el: HTMLElement | null): void;
@@ -124,5 +124,10 @@ export interface Strings {
124
124
  chatOpen: string;
125
125
  chatTitle: string;
126
126
  chatUnread: string;
127
+ /** The bubble's tooltip: what a press does, and that it moves (0.19.2). */
128
+ chatMove: string;
129
+ /** The words beside the bubble (0.19.2): what it is, until first opened; that something new waits. */
130
+ chatLabel: string;
131
+ chatLabelNew: string;
127
132
  }
128
133
  export declare function strings(locale: "th" | "en" | undefined): Strings;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightworkai.official/debug-capture",
3
- "version": "0.19.0",
3
+ "version": "0.19.2",
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",
package/src/config.ts CHANGED
@@ -226,6 +226,12 @@ export interface ChatOptions {
226
226
  position?: "right" | "left";
227
227
  /** In from that corner, in px — for a host that already has something there. */
228
228
  offset?: { x?: number; y?: number };
229
+ /**
230
+ * The words beside the bubble (0.19.2) — says what the round button is to someone who never pressed it: shown until
231
+ * they first open the chat on this device, again while something new waits, and on hover. Your own words in place
232
+ * of "แชทกับทีมงาน", or `false` for the button alone.
233
+ */
234
+ label?: string | false;
229
235
  }
230
236
 
231
237
  /** The bubble's place, from `chat`: null when it is off. */
package/src/index.ts CHANGED
@@ -77,7 +77,7 @@ export type { OpenPortalOptions } from "./portal";
77
77
  * แชทกับทีม (0.19): `chat` in the config mounts the bubble on install; a host with its own button opens and closes
78
78
  * the same panel. The panel is the support host's page, framed — as the tickets are, maintained where they live.
79
79
  */
80
- export { openSupportChat, closeSupportChat, mountSupportChat, unmountSupportChat, supportFrameUrl, badgeText } from "./ui/chatBubble";
80
+ export { openSupportChat, closeSupportChat, mountSupportChat, unmountSupportChat, supportFrameUrl, badgeText, chatLift, panelHeight } from "./ui/chatBubble";
81
81
  export type { ChatOptions } from "./config";
82
82
 
83
83
  export type * from "./types";
@@ -16,6 +16,7 @@
16
16
  import { chatPlace, getConfig } from "../config";
17
17
  import { hostToken, onLiveFrame } from "../live";
18
18
  import { launch } from "../reporter";
19
+ import { followHostFont, OWN_FONTS } from "./hostFont";
19
20
  import { strings } from "./strings";
20
21
 
21
22
  const HOST_TAG = "lw-support-chat";
@@ -33,29 +34,54 @@ const CSS = `
33
34
  display: grid; place-items: center; border: 0; cursor: pointer; padding: 0;
34
35
  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);
35
36
  transition: transform 150ms ease, background 150ms ease;
36
- font-family: "Inter Variable", "Noto Sans Thai Variable", "IBM Plex Sans Thai", -apple-system, "Segoe UI", Roboto, "Noto Sans Thai", sans-serif;
37
+ font-family: var(--lw-host-font, ${OWN_FONTS});
37
38
  }
38
39
  .bubble:hover { background: #003a78; transform: translateY(-1px); }
40
+ .bubble { touch-action: none; }
41
+ .bubble.dragging { cursor: grabbing; transform: scale(1.06); transition: none; box-shadow: 0 18px 35px -8px rgba(0, 0, 0, 0.4); }
39
42
  .bubble:focus-visible { outline: 3px solid rgba(0, 70, 143, 0.35); outline-offset: 3px; }
43
+ .face { display: none; width: 100%; height: 100%; border-radius: 50%; object-fit: cover; pointer-events: none; user-select: none; -webkit-user-drag: none; }
44
+ .bubble.has-face, .bubble.has-face:hover { background: #fff; }
45
+ .has-face .face { display: block; }
46
+ .has-face > svg { display: none; }
40
47
  .badge {
41
48
  position: absolute; top: -2px; right: -2px; min-width: 20px; height: 20px; padding: 0 5px; box-sizing: border-box;
42
- border-radius: 10px; background: #dc2626; color: #fff; font: 700 11px/20px "Inter Variable", -apple-system, sans-serif;
49
+ border-radius: 10px; background: #dc2626; color: #fff; font-family: inherit; font-size: 11px; font-weight: 700; line-height: 20px;
43
50
  text-align: center; box-shadow: 0 0 0 2px #fff;
44
51
  }
45
52
  .badge[hidden] { display: none; }
53
+ .label {
54
+ position: absolute; top: 50%; right: calc(100% + 12px); transform: translate(4px, -50%);
55
+ padding: 9px 15px; border-radius: 999px; background: #fff; color: #1f2937; white-space: nowrap;
56
+ font-family: inherit; font-size: 13px; font-weight: 600; line-height: 18px;
57
+ box-shadow: 0 8px 22px -6px rgba(0, 0, 0, 0.28), 0 0 0 1px rgba(0, 0, 0, 0.05);
58
+ opacity: 0; visibility: hidden; pointer-events: none; transition: opacity 150ms ease, transform 150ms ease, visibility 150ms;
59
+ }
60
+ .label::after {
61
+ content: ""; position: absolute; top: 50%; right: -4px; width: 10px; height: 10px; background: #fff; border-radius: 2px;
62
+ transform: translateY(-50%) rotate(45deg); box-shadow: 1px -1px 0 rgba(0, 0, 0, 0.04);
63
+ }
64
+ .label .text { display: block; max-width: min(240px, calc(100vw - ${SIZE + 64}px)); overflow: hidden; text-overflow: ellipsis; }
65
+ .at-left .label { right: auto; left: calc(100% + 12px); transform: translate(-4px, -50%); }
66
+ .at-left .label::after { right: auto; left: -4px; }
67
+ .new .label { color: #00468f; }
68
+ .labelled .label { opacity: 1; visibility: visible; pointer-events: auto; transform: translate(0, -50%); }
69
+ @media (hover: hover) { .bubble:hover .label { opacity: 1; visibility: visible; pointer-events: auto; transform: translate(0, -50%); } }
70
+ .unlabelled .label, .bubble.dragging .label, .open .label { display: none; }
46
71
  .panel {
47
72
  position: fixed; z-index: 2147483001; width: min(380px, calc(100vw - 32px)); height: min(640px, calc(100vh - ${SIZE + GAP + 32}px));
48
- border-radius: 16px; overflow: hidden; background: #fff; border: 1px solid #e8e8ec;
49
- box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.3); animation: in 180ms cubic-bezier(0.16, 1, 0.3, 1);
73
+ border-radius: 20px; overflow: hidden; background: #fff; border: 1px solid rgba(15, 23, 42, 0.08);
74
+ box-shadow: 0 28px 64px -16px rgba(15, 23, 42, 0.3), 0 10px 24px -10px rgba(15, 23, 42, 0.14);
75
+ animation: in 180ms cubic-bezier(0.16, 1, 0.3, 1);
50
76
  }
51
77
  .panel[hidden] { display: none; }
52
78
  .panel iframe { display: block; width: 100%; height: 100%; border: 0; background: #fff; }
53
79
  @keyframes in { from { opacity: 0; transform: translateY(8px) scale(0.98); } to { opacity: 1; transform: none; } }
54
80
  @media (max-width: 639px) {
55
- .panel { inset: 0 !important; width: 100vw; height: 100dvh; border-radius: 0; border: 0; }
81
+ .panel { inset: 0 !important; width: 100vw !important; height: 100dvh !important; border-radius: 0; border: 0; }
56
82
  .open .bubble { display: none; }
57
83
  }
58
- @media (prefers-reduced-motion: reduce) { .panel { animation: none; } .bubble { transition: none; } }
84
+ @media (prefers-reduced-motion: reduce) { .panel { animation: none; } .bubble, .label { transition: none; } }
59
85
  `;
60
86
 
61
87
  let root: HTMLElement | null = null;
@@ -64,6 +90,7 @@ let panel: HTMLDivElement | null = null;
64
90
  let frame: HTMLIFrameElement | null = null;
65
91
  let bubble: HTMLButtonElement | null = null;
66
92
  let badge: HTMLSpanElement | null = null;
93
+ let label: HTMLSpanElement | null = null;
67
94
  let isOpen = false;
68
95
  let unread = 0;
69
96
 
@@ -83,6 +110,66 @@ export function supportFrameUrl(host: string, realmKey: string): string {
83
110
  url.searchParams.set("hand", "1");
84
111
  return url.toString();
85
112
  }
113
+ /** The desk's face on the button — the support host's public file, the same picture as its replies (0.19.2). */
114
+ export function supportFaceUrl(host: string): string {
115
+ return new URL("support-face.webp", base(host)).toString();
116
+ }
117
+
118
+ /**
119
+ * How far the call's card and frame rise from their own 16px so they clear the bubble — its offset, its size and the
120
+ * gap above it (0.19.1: 0.19.0 assumed the bubble at the corner, and a raised one sat under a ringing call). Pure.
121
+ */
122
+ export function chatLift(bubbleY: number): number {
123
+ return bubbleY + SIZE + GAP - 16;
124
+ }
125
+ /** The panel above the bubble, never past the top of the window — its height for the bubble's offset (0.19.1). Pure. */
126
+ export function panelHeight(bubbleY: number): string {
127
+ return `min(640px, calc(100vh - ${bubbleY + SIZE + GAP + 16}px))`;
128
+ }
129
+
130
+ /*
131
+ * Moved by hand (0.19.2, the owner: "sometimes the chatbubble icon might over something … can move or also the chat panel
132
+ * that it can drag to move it … save it on local storage"): the bubble is dragged anywhere and settles on the nearer
133
+ * side, at the height it was let go; the panel is dragged by its head. Both kept for this desk on this device, and
134
+ * brought back inside the window when it is smaller than where they were left.
135
+ */
136
+ export interface BubbleSpot {
137
+ side: "right" | "left";
138
+ /** In from that side, px. */
139
+ x: number;
140
+ /** Up from the bottom, px — to the bubble's lower edge. */
141
+ y: number;
142
+ }
143
+ const MARGIN = 8;
144
+ /** The ring's card is ~180px tall at the corner: a bubble above that leaves it there. */
145
+ const RING_ZONE = 200;
146
+ /** Let go at (px, py), its centre: the nearer side, the height it was dropped at — inside the window. Pure. */
147
+ export function settleBubble(px: number, py: number, view: { w: number; h: number }, inset: number): BubbleSpot {
148
+ const side = px < view.w / 2 ? "left" : "right";
149
+ return { side, x: inset, y: clampBubbleY(view.h - py - SIZE / 2, view.h) };
150
+ }
151
+ /** A height that keeps the whole bubble on screen. Pure. */
152
+ export function clampBubbleY(y: number, viewH: number): number {
153
+ return Math.round(Math.min(Math.max(y, MARGIN), Math.max(MARGIN, viewH - SIZE - MARGIN)));
154
+ }
155
+ /** How far the call's card rises to clear the bubble: only a bubble on its side, low enough to meet it. Pure. */
156
+ export function liftFor(spot: BubbleSpot): number {
157
+ return spot.side === "right" && spot.y < RING_ZONE ? chatLift(spot.y) : 0;
158
+ }
159
+ /**
160
+ * The panel by the bubble, where it was not dragged: above it when the room above is enough (or more than below),
161
+ * else below it — its own height, never past the window. Pure.
162
+ */
163
+ export function anchorPanel(spot: BubbleSpot, viewH: number): { bottom?: number; top?: number; height: number } {
164
+ const above = viewH - (spot.y + SIZE + GAP) - 16;
165
+ const below = spot.y - GAP - 16;
166
+ if (above >= 420 || above >= below) return { bottom: spot.y + SIZE + GAP, height: Math.max(240, Math.min(640, above)) };
167
+ return { top: viewH - spot.y + GAP, height: Math.max(240, Math.min(640, below)) };
168
+ }
169
+ /** A dragged panel's top-left, inside the window by the margin. Pure. */
170
+ export function clampPanel(p: { x: number; y: number }, box: { w: number; h: number }, view: { w: number; h: number }): { x: number; y: number } {
171
+ return { x: Math.round(Math.min(Math.max(p.x, MARGIN), Math.max(MARGIN, view.w - box.w - MARGIN))), y: Math.round(Math.min(Math.max(p.y, MARGIN), Math.max(MARGIN, view.h - box.h - MARGIN))) };
172
+ }
86
173
 
87
174
  /** The badge's number: `99+` past 99; nothing at 0. Pure, for the tests. */
88
175
  export function badgeText(n: number): string {
@@ -96,6 +183,32 @@ function setBadge(n: number): void {
96
183
  badge.textContent = badgeText(unread);
97
184
  badge.hidden = unread === 0;
98
185
  bubble.setAttribute("aria-label", unread ? `${t.chatOpen} · ${t.chatUnread.replace("{n}", String(unread))}` : t.chatOpen);
186
+ setLabel();
187
+ }
188
+
189
+ /*
190
+ * The words beside the bubble (0.19.2, the owner: "should we also have like a text on the left side icon"): a round
191
+ * button alone says nothing to someone who never pressed it, and words always there cover the page the bubble was just
192
+ * made movable for. So: until they first open the chat on this device, while something new waits, and on hover.
193
+ */
194
+ export function bubbleLabel(s: { own?: string | false; unread: number; opened: boolean; open: boolean }, t: { chatLabel: string; chatLabelNew: string }): { text: string; shown: boolean; isNew: boolean } | null {
195
+ if (s.own === false) return null;
196
+ const isNew = s.unread > 0;
197
+ const own = typeof s.own === "string" ? s.own.trim().slice(0, 60) : "";
198
+ return { text: isNew ? t.chatLabelNew : own || t.chatLabel, shown: !s.open && (isNew || !s.opened), isNew };
199
+ }
200
+ /** Opened once on this device (kept with its place): from then the words come back only for something new. */
201
+ let opened = false;
202
+ function setLabel(): void {
203
+ if (!label || !bubble) return;
204
+ const config = getConfig();
205
+ const own = config.chat && config.chat !== true ? config.chat.label : undefined;
206
+ const l = bubbleLabel({ own, unread, opened, open: isOpen }, strings(config.locale));
207
+ bubble.classList.toggle("unlabelled", !l);
208
+ bubble.classList.toggle("labelled", !!l?.shown);
209
+ bubble.classList.toggle("new", !!l?.isNew);
210
+ bubble.classList.toggle("at-left", spot.side === "left");
211
+ label.querySelector(".text")!.textContent = l?.text ?? "";
99
212
  }
100
213
 
101
214
  let asking: Promise<void> | null = null;
@@ -125,11 +238,150 @@ async function refreshUnread(): Promise<void> {
125
238
  function show(on: boolean): void {
126
239
  if (!root) return;
127
240
  root.style.display = on ? "" : "none";
128
- // The call's card and frame sit above the bubble while it is there (ring.ts, callFrame.ts).
129
- const place = chatPlace(getConfig().chat);
130
- if (on && place?.side === "right") document.documentElement.style.setProperty("--lw-chat-lift", `${SIZE + GAP}px`);
241
+ // Its words in the host page's own font — read again here, after the host has drawn itself.
242
+ if (on) followHostFont(root);
243
+ layout();
244
+ }
245
+
246
+ /* ── Where it is: the config's place, or where the person put it ───────── */
247
+
248
+ let spot: BubbleSpot = { side: "right", x: 16, y: 16 };
249
+ /** The panel's top-left once dragged by its head — null: by the bubble. */
250
+ let panelAt: { x: number; y: number; h: number } | null = null;
251
+ const PANEL_W = 380;
252
+ const placeKey = () => `lw-support-chat-place:${getConfig().realmKey}`;
253
+ const view = () => ({ w: window.innerWidth, h: window.innerHeight });
254
+
255
+ /** Put there by hand — kept; else the config's place, which follows the host's own offset if it changes. */
256
+ let movedByHand = false;
257
+ function loadPlace(): void {
258
+ const def = chatPlace(getConfig().chat);
259
+ if (def) spot = { side: def.side, x: def.x, y: def.y };
260
+ try {
261
+ const kept = JSON.parse(localStorage.getItem(placeKey()) ?? "null") as { bubble?: { side?: unknown; y?: unknown }; panel?: { x?: unknown; y?: unknown; h?: unknown } | null; opened?: unknown } | null;
262
+ const b = kept?.bubble;
263
+ movedByHand = !!b && (b.side === "left" || b.side === "right") && typeof b.y === "number" && Number.isFinite(b.y);
264
+ if (movedByHand) spot = { ...spot, side: b!.side as BubbleSpot["side"], y: b!.y as number };
265
+ const p = kept?.panel;
266
+ panelAt = p && typeof p.x === "number" && typeof p.y === "number" && Number.isFinite(p.x) && Number.isFinite(p.y) ? { x: p.x, y: p.y, h: typeof p.h === "number" && p.h >= 240 ? p.h : 640 } : null;
267
+ opened = kept?.opened === true;
268
+ } catch {
269
+ /* a page that cannot keep it: the config's place */
270
+ }
271
+ }
272
+ function savePlace(): void {
273
+ try {
274
+ localStorage.setItem(placeKey(), JSON.stringify({ ...(movedByHand ? { bubble: { side: spot.side, y: spot.y } } : {}), panel: panelAt, ...(opened ? { opened: true } : {}) }));
275
+ } catch {
276
+ /* this visit only */
277
+ }
278
+ }
279
+
280
+ /** Everything placed from `spot` and `panelAt`, inside the window as it is now — and the call's card clear of the bubble. */
281
+ function layout(): void {
282
+ if (!bubble || !panel) return;
283
+ const v = view();
284
+ const y = clampBubbleY(spot.y, v.h);
285
+ const other = spot.side === "right" ? "left" : "right";
286
+ Object.assign(bubble.style, { [spot.side]: `${spot.x}px`, [other]: "", bottom: `${y}px`, top: "" });
287
+ if (panelAt) {
288
+ // The height it had when it was picked up — a drag moves it, never resizes it under the hand.
289
+ const box = { w: Math.min(PANEL_W, v.w - 32), h: Math.min(panelAt.h, v.h - 2 * MARGIN) };
290
+ const p = clampPanel(panelAt, box, v);
291
+ Object.assign(panel.style, { left: `${p.x}px`, top: `${p.y}px`, right: "", bottom: "", height: `${box.h}px` });
292
+ } else {
293
+ const a = anchorPanel({ ...spot, y }, v.h);
294
+ 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` });
295
+ }
296
+ // The call's card and frame sit above the bubble while it is there and low on their side (ring.ts, callFrame.ts).
297
+ const lift = root && root.style.display !== "none" ? liftFor({ ...spot, y }) : 0;
298
+ if (lift) document.documentElement.style.setProperty("--lw-chat-lift", `${lift}px`);
131
299
  else document.documentElement.style.removeProperty("--lw-chat-lift");
300
+ setLabel();
301
+ }
302
+
303
+ /* ── The bubble, dragged: a press that moves is a move, never a press ─── */
304
+
305
+ let pressed: { id: number; sx: number; sy: number; moved: boolean } | null = null;
306
+ let swallowClick = false;
307
+ function onBubbleDown(e: PointerEvent): void {
308
+ if (e.button !== 0) return;
309
+ pressed = { id: e.pointerId, sx: e.clientX, sy: e.clientY, moved: false };
310
+ try {
311
+ bubble?.setPointerCapture(e.pointerId);
312
+ } catch {
313
+ /* a synthetic press */
314
+ }
132
315
  }
316
+ function onBubbleMove(e: PointerEvent): void {
317
+ if (!pressed || e.pointerId !== pressed.id || !bubble) return;
318
+ if (!pressed.moved && Math.hypot(e.clientX - pressed.sx, e.clientY - pressed.sy) < 6) return;
319
+ pressed.moved = true;
320
+ bubble.classList.add("dragging");
321
+ Object.assign(bubble.style, { left: `${e.clientX - SIZE / 2}px`, top: `${e.clientY - SIZE / 2}px`, right: "", bottom: "" });
322
+ }
323
+ function onBubbleUp(e: PointerEvent): void {
324
+ if (!pressed || e.pointerId !== pressed.id) return;
325
+ const was = pressed;
326
+ pressed = null;
327
+ bubble?.classList.remove("dragging");
328
+ if (!was.moved) return;
329
+ // The click that follows this release is the end of a move, not a press.
330
+ swallowClick = true;
331
+ setTimeout(() => (swallowClick = false), 0);
332
+ if (e.type === "pointerup") {
333
+ spot = settleBubble(e.clientX, e.clientY, view(), chatPlace(getConfig().chat)?.x ?? 16);
334
+ movedByHand = true;
335
+ // Moved by hand: the panel opens by it again.
336
+ panelAt = null;
337
+ savePlace();
338
+ }
339
+ layout();
340
+ }
341
+
342
+ /* ── The panel, dragged by its head — the press is in the frame (as the call's, ui/callFrame) ─── */
343
+
344
+ let pdrag: { sx: number; sy: number; x: number; y: number; lx: number; ly: number; h: number } | null = null;
345
+ function onPanelDrag(m: { phase?: unknown; x?: unknown; y?: unknown }): void {
346
+ // A phone's panel is the whole screen: nothing to move.
347
+ if (!panel || typeof m.x !== "number" || typeof m.y !== "number" || view().w < 640) return;
348
+ if (m.phase === "start") {
349
+ const r = panel.getBoundingClientRect();
350
+ pdrag = { sx: m.x, sy: m.y, x: r.left, y: r.top, lx: m.x, ly: m.y, h: Math.round(r.height) || 640 };
351
+ followHere(true);
352
+ return;
353
+ }
354
+ if (!pdrag) return;
355
+ pdrag.lx = m.x;
356
+ pdrag.ly = m.y;
357
+ panelAt = { ...clampPanel({ x: pdrag.x + (m.x - pdrag.sx), y: pdrag.y + (m.y - pdrag.sy) }, { w: panel.offsetWidth, h: pdrag.h }, view()), h: pdrag.h };
358
+ layout();
359
+ if (m.phase === "end") {
360
+ pdrag = null;
361
+ followHere(false);
362
+ savePlace();
363
+ }
364
+ }
365
+ /*
366
+ * This page's own pointer while the panel is dragged: a quick flick leaves the frame on its first move, and from then
367
+ * only this page hears it (the call's 0.17.1 lesson). Screen coordinates, as the frame sends them.
368
+ */
369
+ let hostFollow: ((e: PointerEvent) => void) | null = null;
370
+ function followHere(on: boolean): void {
371
+ if (hostFollow) {
372
+ for (const t of ["pointermove", "pointerup", "pointercancel"] as const) window.removeEventListener(t, hostFollow, true);
373
+ hostFollow = null;
374
+ }
375
+ if (!on) return;
376
+ hostFollow = (e: PointerEvent) => {
377
+ if (!pdrag) return followHere(false);
378
+ if (e.type === "pointermove" && e.buttons !== 0) return onPanelDrag({ phase: "move", x: e.screenX, y: e.screenY });
379
+ const released = e.type === "pointerup";
380
+ onPanelDrag({ phase: "end", x: released ? e.screenX : pdrag.lx, y: released ? e.screenY : pdrag.ly });
381
+ };
382
+ for (const t of ["pointermove", "pointerup", "pointercancel"] as const) window.addEventListener(t, hostFollow, true);
383
+ }
384
+ const onResize = () => layout();
133
385
 
134
386
  function post(message: Record<string, unknown>): void {
135
387
  frame?.contentWindow?.postMessage({ source: HOST, ...message }, hostOrigin());
@@ -137,7 +389,7 @@ function post(message: Record<string, unknown>): void {
137
389
 
138
390
  async function onFrame(e: MessageEvent): Promise<void> {
139
391
  if (!frame || e.source !== frame.contentWindow) return;
140
- const d = e.data as { source?: unknown; type?: unknown; total?: unknown } | null;
392
+ const d = e.data as { source?: unknown; type?: unknown; total?: unknown; phase?: unknown; x?: unknown; y?: unknown } | null;
141
393
  if (!d || d.source !== FRAME) return;
142
394
  if (d.type === "hello") {
143
395
  const token = await hostToken();
@@ -146,6 +398,7 @@ async function onFrame(e: MessageEvent): Promise<void> {
146
398
  }
147
399
  if (d.type === "ready") return post({ type: isOpen ? "shown" : "hidden" });
148
400
  if (d.type === "close") return closeSupportChat();
401
+ if (d.type === "drag") return onPanelDrag(d);
149
402
  if (d.type === "unread" && typeof d.total === "number") return setBadge(d.total);
150
403
  if (d.type === "report") {
151
404
  // The panel folds, and the report opens over the page it is about — linked to the chat (ingest `fromChat`).
@@ -173,9 +426,15 @@ export function openSupportChat(): void {
173
426
  panel.append(frame);
174
427
  }
175
428
  isOpen = true;
429
+ if (!opened) {
430
+ // They know what it is now: the words beside it come back only for something new.
431
+ opened = true;
432
+ savePlace();
433
+ }
176
434
  panel.hidden = false;
177
435
  shadow?.querySelector(".wrap")?.classList.add("open");
178
436
  bubble?.setAttribute("aria-expanded", "true");
437
+ setLabel();
179
438
  post({ type: "shown" });
180
439
  frame.focus();
181
440
  }
@@ -187,6 +446,7 @@ export function closeSupportChat(refocus = true): void {
187
446
  panel.hidden = true;
188
447
  shadow?.querySelector(".wrap")?.classList.remove("open");
189
448
  bubble?.setAttribute("aria-expanded", "false");
449
+ setLabel();
190
450
  post({ type: "hidden" });
191
451
  if (refocus) bubble?.focus();
192
452
  void refreshUnread();
@@ -211,18 +471,33 @@ export function mountSupportChat(): void {
211
471
  root = document.createElement(HOST_TAG);
212
472
  root.style.display = "none";
213
473
  shadow = root.attachShadow({ mode: "open" });
214
- const side = place.side;
215
- shadow.innerHTML = `<style>${CSS}</style><div class="wrap"><div class="panel" role="dialog" aria-label="${t.chatTitle}" hidden></div><button type="button" class="bubble" aria-expanded="false" aria-haspopup="dialog" aria-label="${t.chatOpen}" title="${t.chatOpen}">${ICON}<span class="badge" hidden></span></button></div>`;
474
+ shadow.innerHTML = `<style>${CSS}</style><div class="wrap"><div class="panel" role="dialog" aria-label="${t.chatTitle}" hidden></div><button type="button" class="bubble" aria-expanded="false" aria-haspopup="dialog" aria-label="${t.chatOpen}" title="${t.chatMove}">${ICON}<img class="face" alt="" draggable="false" decoding="async"><span class="badge" hidden></span><span class="label" aria-hidden="true"><span class="text"></span></span></button></div>`;
216
475
  panel = shadow.querySelector(".panel");
217
476
  bubble = shadow.querySelector(".bubble");
218
477
  badge = shadow.querySelector(".badge");
219
- Object.assign(bubble!.style, { [side]: `${place.x}px`, bottom: `${place.y}px` });
220
- Object.assign(panel!.style, { [side]: `${place.x}px`, bottom: `${place.y + SIZE + GAP}px` });
221
- bubble!.addEventListener("click", () => (isOpen ? closeSupportChat() : openSupportChat()));
478
+ label = shadow.querySelector(".label");
479
+ // The desk's face (the owner, 10-31: "all the support icon that user see … use this icon"): the support host's own,
480
+ // the one its replies wear — the chat glyph until it is there, and if it never comes.
481
+ const face = shadow.querySelector<HTMLImageElement>(".face")!;
482
+ face.addEventListener("load", () => bubble?.classList.add("has-face"), { once: true });
483
+ face.src = supportFaceUrl(config.host);
484
+ // Where the person left it on this device, else the config's place (0.19.2).
485
+ loadPlace();
486
+ layout();
487
+ bubble!.addEventListener("click", () => {
488
+ if (swallowClick) return;
489
+ if (isOpen) closeSupportChat();
490
+ else openSupportChat();
491
+ });
492
+ bubble!.addEventListener("pointerdown", onBubbleDown);
493
+ bubble!.addEventListener("pointermove", onBubbleMove);
494
+ bubble!.addEventListener("pointerup", onBubbleUp);
495
+ bubble!.addEventListener("pointercancel", onBubbleUp);
222
496
  document.body.append(root);
223
497
  window.addEventListener("message", onMessage);
224
498
  document.addEventListener("keydown", onKey);
225
499
  window.addEventListener("focus", onFocus);
500
+ window.addEventListener("resize", onResize);
226
501
  // The desk wrote, or opened a เรื่อง, or read: the badge asks again — while the panel is open, the panel tells it.
227
502
  let settle: ReturnType<typeof setTimeout> | undefined;
228
503
  stopFrames = onLiveFrame(() => {
@@ -240,8 +515,14 @@ export function unmountSupportChat(): void {
240
515
  window.removeEventListener("message", onMessage);
241
516
  document.removeEventListener("keydown", onKey);
242
517
  window.removeEventListener("focus", onFocus);
518
+ window.removeEventListener("resize", onResize);
519
+ followHere(false);
243
520
  root?.remove();
244
- root = shadow = panel = frame = bubble = badge = null;
521
+ root = shadow = panel = frame = bubble = badge = label = null;
245
522
  isOpen = false;
523
+ pressed = pdrag = null;
524
+ panelAt = null;
525
+ movedByHand = opened = false;
526
+ unread = 0;
246
527
  document.documentElement.style.removeProperty("--lw-chat-lift");
247
528
  }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The host page's own font, for what this library draws on the host's page — the chat bubble's words, the call's card
3
+ * (0.19.2, the owner: "make sure we should correct font as well, now look like font not work"). A host loads its fonts
4
+ * for its own document (the ERP: Anuphan, by next/font, under a generated name); a shadow root reset by `all: initial`
5
+ * asked for families the host never loaded, and Thai fell to the system's looped face.
6
+ *
7
+ * Read from the host's body and set on the shadow host as `--lw-host-font` — a custom property, which `all: initial`
8
+ * does not reset — and the CSS falls back to its own list when there is none. A page that never chose a font (the
9
+ * browser's serif default) is not followed.
10
+ */
11
+ export const OWN_FONTS = `"Inter Variable", "Noto Sans Thai Variable", "IBM Plex Sans Thai", -apple-system, "Segoe UI", Roboto, "Noto Sans Thai", sans-serif`;
12
+
13
+ /** The host's family as its body is drawn — "" when it never chose one. Pure but for the read. */
14
+ export function hostFontFamily(computed: string | null | undefined): string {
15
+ const f = (computed ?? "").trim();
16
+ return !f || /^(?:"?Times New Roman"?|"?Times"?|serif)$/i.test(f) ? "" : f;
17
+ }
18
+
19
+ /** Give a shadow host the page's font (again: a host may set its font after this library mounted). */
20
+ export function followHostFont(el: HTMLElement | null): void {
21
+ if (!el || typeof document === "undefined" || !document.body) return;
22
+ let f = "";
23
+ try {
24
+ f = hostFontFamily(getComputedStyle(document.body).fontFamily);
25
+ } catch {
26
+ /* no styles to read: the library's own fonts */
27
+ }
28
+ if (f) el.style.setProperty("--lw-host-font", f);
29
+ else el.style.removeProperty("--lw-host-font");
30
+ }
package/src/ui/ring.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  * it is up does not photograph it.
13
13
  */
14
14
  import { getConfig } from "../config";
15
+ import { followHostFont, OWN_FONTS } from "./hostFont";
15
16
  import { containKeys } from "./keys";
16
17
  import { strings } from "./strings";
17
18
 
@@ -30,7 +31,7 @@ const CSS = `
30
31
  width: min(352px, calc(100vw - 32px)); box-sizing: border-box;
31
32
  padding: 16px; border-radius: 16px; background: #fff; color: #1a1a1e;
32
33
  border: 1px solid #e8e8ec; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
33
- font-family: "Inter Variable", "Noto Sans Thai Variable", "IBM Plex Sans Thai", -apple-system, "Segoe UI", Roboto, "Noto Sans Thai", sans-serif;
34
+ font-family: var(--lw-host-font, ${OWN_FONTS});
34
35
  animation: in 200ms cubic-bezier(0.16, 1, 0.3, 1);
35
36
  }
36
37
  @media (max-width: 639px) { .card { right: 8px; left: 8px; bottom: calc(8px + var(--lw-chat-lift, 0px)); width: auto; } }
@@ -117,6 +118,8 @@ function mount(): ShadowRoot | null {
117
118
  if (host?.isConnected) return host.shadowRoot;
118
119
  host = document.createElement(HOST_TAG);
119
120
  host.dataset.debugReporter = "true";
121
+ // The card's words in the host page's own font (ui/hostFont).
122
+ followHostFont(host);
120
123
  const root = host.attachShadow({ mode: "open" });
121
124
  containKeys(root);
122
125
  document.body.append(host);
package/src/ui/strings.ts CHANGED
@@ -129,6 +129,11 @@ export interface Strings {
129
129
  chatOpen: string;
130
130
  chatTitle: string;
131
131
  chatUnread: string;
132
+ /** The bubble's tooltip: what a press does, and that it moves (0.19.2). */
133
+ chatMove: string;
134
+ /** The words beside the bubble (0.19.2): what it is, until first opened; that something new waits. */
135
+ chatLabel: string;
136
+ chatLabelNew: string;
132
137
  }
133
138
 
134
139
  const th: Strings = {
@@ -221,6 +226,9 @@ const th: Strings = {
221
226
  chatOpen: "แชทกับทีมงาน",
222
227
  chatTitle: "แชทกับทีมงาน",
223
228
  chatUnread: "{n} ข้อความใหม่จากทีมงาน",
229
+ chatMove: "แชทกับทีมงาน — ลากเพื่อย้ายไปที่อื่นได้ถ้าบังอะไรอยู่",
230
+ chatLabel: "แชทกับทีมงาน",
231
+ chatLabelNew: "มีข้อความใหม่จากทีมงาน",
224
232
  };
225
233
 
226
234
  const en: Strings = {
@@ -313,6 +321,9 @@ const en: Strings = {
313
321
  chatOpen: "Chat with support",
314
322
  chatTitle: "Chat with support",
315
323
  chatUnread: "{n} new from support",
324
+ chatMove: "Chat with support — drag to move it if it covers something",
325
+ chatLabel: "Chat with us",
326
+ chatLabelNew: "New message from support",
316
327
  };
317
328
 
318
329
  export function strings(locale: "th" | "en" | undefined): Strings {