@lightworkai.official/debug-capture 0.17.4 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/live.d.ts CHANGED
@@ -13,7 +13,14 @@ export declare function liveSocketUrl(host: string, ticket: string): string;
13
13
  export declare function answerPath(call: Pick<RingingCall, "id" | "ticketId">): string;
14
14
  /** Is this a call ringing for the person at this widget? */
15
15
  export declare function ringsHere(call: Pick<RingingCall, "status" | "side">): boolean;
16
+ type LiveFrame = {
17
+ type?: string;
18
+ [k: string]: unknown;
19
+ };
20
+ export declare function onLiveFrame(fn: (frame: LiveFrame) => void): () => void;
21
+ export declare function hostToken(): Promise<string | null>;
16
22
  /** Start listening for calls. Idempotent; a no-op without identity, or with `ring: false`. */
17
23
  export declare function startLiveRing(): void;
18
24
  /** Let go — the socket, the card, the timers. For a host that unmounts, and for the tests. */
19
25
  export declare function stopLiveRing(): void;
26
+ export {};
package/dist/server.d.ts CHANGED
@@ -14,11 +14,15 @@ export interface SupportProfileFact {
14
14
  label: string;
15
15
  value: string;
16
16
  }
17
- /** Whoever the host has authenticated. Email is the account key. */
17
+ /**
18
+ * Whoever the host has authenticated. Email is the account key — or, for someone the host knows but holds no email
19
+ * for (a CIB officer with none in the ERP, 2026-10-27: every report they ever sent landed anonymous, nobody could call
20
+ * them back), the host's own id alone.
21
+ */
18
22
  export interface SupportUser {
19
- /** The host's own id for this person, recorded so a changed email still matches. */
23
+ /** The host's own id for this person, recorded so a changed email still matches — the key when there is no email. */
20
24
  id?: string | null;
21
- email: string;
25
+ email?: string | null;
22
26
  name?: string | null;
23
27
  /** What the host knows about their work — most important first. Optional; bounded on the way in. */
24
28
  profile?: SupportProfileFact[] | null;
package/dist/server.mjs CHANGED
@@ -23,17 +23,18 @@ function cleanProfile(profile) {
23
23
  var DEFAULT_TTL_SECONDS = 120;
24
24
  var b64 = (value) => Buffer.from(JSON.stringify(value), "utf8").toString("base64url");
25
25
  function createSupportToken(options) {
26
- const email = options.user.email?.trim().toLowerCase();
27
- if (!email || !email.includes("@"))
28
- throw new Error("createSupportToken: a real email is required");
26
+ const email = options.user.email?.trim().toLowerCase() || null;
27
+ const id = options.user.id?.trim() || null;
28
+ if (email ? !email.includes("@") : !id)
29
+ throw new Error("createSupportToken: a real email, or the host's own id for this person, is required");
29
30
  if (!options.secret || options.secret.length < 16) {
30
31
  throw new Error("createSupportToken: the realm's reporter secret is missing or too short");
31
32
  }
32
33
  const now = Math.floor(Date.now() / 1000);
33
34
  const expiresIn = Math.min(Math.max(Math.floor(options.ttlSeconds ?? DEFAULT_TTL_SECONDS), 30), 12 * 60 * 60);
34
35
  const body = `${b64({ alg: "HS256", typ: "JWT" })}.${b64({
35
- sub: options.user.id?.trim() || email,
36
- email,
36
+ sub: id ?? email,
37
+ ...email ? { email } : {},
37
38
  name: options.user.name?.trim() || null,
38
39
  ...cleanProfile(options.user.profile) ? { profile: cleanProfile(options.user.profile) } : {},
39
40
  jti: randomUUID(),
@@ -59,7 +60,7 @@ function supportTokenHandler(options) {
59
60
  return json({ error: "Support identity is not configured" }, 503);
60
61
  }
61
62
  const user = await options.user();
62
- if (!user?.email)
63
+ if (!user?.email?.trim() && !user?.id?.trim())
63
64
  return json({ error: "Unauthorized" }, 401);
64
65
  try {
65
66
  const { token, expiresIn } = createSupportToken({ secret, user, ttlSeconds: options.ttlSeconds });
package/dist/types.d.ts CHANGED
@@ -80,6 +80,8 @@ export interface DebugReason {
80
80
  type: DebugReasonType;
81
81
  message?: string;
82
82
  stack?: string;
83
+ /** Opened from แชทกับทีม's panel (แจ้งปัญหาพร้อมภาพหน้าจอ): the report is linked to the person's chat. */
84
+ fromChat?: boolean;
83
85
  }
84
86
  /** A failed data-layer query the host chose to report (react-query, Apollo, …). */
85
87
  export interface ErroredQuery {
@@ -0,0 +1,12 @@
1
+ /** The panel's address: the support host's page, told the realm, signing in by hand-off (no token in the address). */
2
+ export declare function supportFrameUrl(host: string, realmKey: string): string;
3
+ /** The badge's number: `99+` past 99; nothing at 0. Pure, for the tests. */
4
+ export declare function badgeText(n: number): string;
5
+ /** Open the panel — from the bubble, or from the host app's own button (`openSupportChat`). */
6
+ export declare function openSupportChat(): void;
7
+ /** Fold the panel — it stays loaded, so the chat is there as it was the next time. */
8
+ export declare function closeSupportChat(refocus?: boolean): void;
9
+ /** The bubble, once — hidden until the host's token says somebody is signed in. */
10
+ export declare function mountSupportChat(): void;
11
+ /** Take it away — for a host that unmounts, and for the tests. */
12
+ export declare function unmountSupportChat(): void;
@@ -120,5 +120,9 @@ export interface Strings {
120
120
  ringAnswering: string;
121
121
  /** The frame could not come up: answer in a new tab instead. */
122
122
  ringFallback: string;
123
+ /** แชทกับทีม's bubble (ui/chatBubble): its name, the panel's, and what waits — `chatUnread` carries `{n}`. */
124
+ chatOpen: string;
125
+ chatTitle: string;
126
+ chatUnread: string;
123
127
  }
124
128
  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.17.4",
3
+ "version": "0.19.0",
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
@@ -111,6 +111,17 @@ export interface DebugCaptureConfig {
111
111
  /** Anything else worth recording — feature flags, build id, tenant. */
112
112
  extra?: () => Record<string, unknown>;
113
113
 
114
+ /**
115
+ * แชทกับทีม — a chat bubble at the bottom right: the person asks the support desk anything, no report needed, and
116
+ * follows what they filed (เรื่องของฉัน) in the same panel. The desk answers from สนทนา › แชท and can open a เรื่อง
117
+ * from it; แจ้งปัญหาพร้อมภาพหน้าจอ in the panel opens this library's report, linked to the chat.
118
+ *
119
+ * Off unless set. Needs `identity` — a chat is a person's, and is read back: with no signed-in person the bubble is
120
+ * not shown. `true` puts it at the bottom right; `{ position: "left" }` at the bottom left; `offset` moves it in from
121
+ * the corner (px) for a host that already has something there.
122
+ */
123
+ chat?: boolean | ChatOptions;
124
+
114
125
  capture?: CaptureOptions;
115
126
  redact?: RedactOptions;
116
127
 
@@ -209,6 +220,22 @@ export interface ToastNotice {
209
220
  action?: { label: string; onClick: () => void };
210
221
  }
211
222
 
223
+ /** Where แชทกับทีม's bubble sits (`chat`). */
224
+ export interface ChatOptions {
225
+ /** Which bottom corner — the right unless said. */
226
+ position?: "right" | "left";
227
+ /** In from that corner, in px — for a host that already has something there. */
228
+ offset?: { x?: number; y?: number };
229
+ }
230
+
231
+ /** The bubble's place, from `chat`: null when it is off. */
232
+ export function chatPlace(chat: DebugCaptureConfig["chat"]): { side: "right" | "left"; x: number; y: number } | null {
233
+ if (!chat) return null;
234
+ const o = chat === true ? {} : chat;
235
+ const n = (v: unknown, d: number) => (typeof v === "number" && Number.isFinite(v) && v >= 0 && v < 2000 ? v : d);
236
+ return { side: o.position === "left" ? "left" : "right", x: n(o.offset?.x, 16), y: n(o.offset?.y, 16) };
237
+ }
238
+
212
239
  export interface CaptureOptions {
213
240
  /** Rolling buffer sizes. */
214
241
  maxRequests?: number;
package/src/embed.ts CHANGED
@@ -9,7 +9,9 @@
9
9
  * data-app-version="1.4.2"
10
10
  * data-environment="production"
11
11
  * data-modules='{"/saraban":"งานสารบรรณ","/asset":"งานพัสดุ"}'
12
- * data-replay="60"></script>
12
+ * data-replay="60"
13
+ * data-identity-url="/api/support-token"
14
+ * data-chat="right"></script>
13
15
  *
14
16
  * `data-replay` records the last N seconds of the page for a report (see
15
17
  * `replay` in config.ts). The recorder is `rrweb-record.mjs`, shipped beside
@@ -20,6 +22,7 @@ import { moduleResolver, type ModuleMap } from "./modules";
20
22
  import { installDebugCapture } from "./install";
21
23
  import { launch } from "./reporter";
22
24
  import { setRecorderLoader } from "./capture/replay";
25
+ import { openSupportChat } from "./ui/chatBubble";
23
26
 
24
27
  const script = document.currentScript as HTMLScriptElement | null;
25
28
  const data = script?.dataset ?? {};
@@ -65,6 +68,12 @@ if (data.host && data.realmKey) {
65
68
  module: moduleResolver(parseModules(data.modules)),
66
69
  locale: data.locale === "en" ? "en" : "th",
67
70
  ...(data.replay !== undefined ? { replay: { seconds: Number(data.replay) || undefined } } : {}),
71
+ /*
72
+ * Who is signed in (`identity`): the host's own endpoint answering its user's support token as text — the call's
73
+ * ring and แชทกับทีม need it. `data-chat`: the bubble, "left" for the bottom left.
74
+ */
75
+ ...(data.identityUrl ? { identity: () => fetch(data.identityUrl!, { credentials: "include" }).then((r) => (r.ok ? r.text() : null)).catch(() => null) } : {}),
76
+ ...(data.chat !== undefined && data.chat !== "false" ? { chat: data.chat === "left" ? { position: "left" as const } : true } : {}),
68
77
  });
69
78
  /*
70
79
  * In <head>, the script runs before there is a <body> — and installing then
@@ -78,3 +87,5 @@ if (data.host && data.realmKey) {
78
87
  // The only handle a no-bundler page gets. Call it from a button's onclick —
79
88
  // directly, so the click is still the active gesture when screen capture asks.
80
89
  (window as unknown as { lwReportProblem: typeof launch }).lwReportProblem = launch;
90
+ // แชทกับทีม's panel, for a page with its own chat button.
91
+ (window as unknown as { lwOpenSupportChat: typeof openSupportChat }).lwOpenSupportChat = openSupportChat;
package/src/index.ts CHANGED
@@ -73,5 +73,11 @@ export type { Strings as ReporterStrings } from "./ui/strings";
73
73
  */
74
74
  export { openSupportPortal, supportPortalUrl, NoIdentityError } from "./portal";
75
75
  export type { OpenPortalOptions } from "./portal";
76
+ /*
77
+ * แชทกับทีม (0.19): `chat` in the config mounts the bubble on install; a host with its own button opens and closes
78
+ * the same panel. The panel is the support host's page, framed — as the tickets are, maintained where they live.
79
+ */
80
+ export { openSupportChat, closeSupportChat, mountSupportChat, unmountSupportChat, supportFrameUrl, badgeText } from "./ui/chatBubble";
81
+ export type { ChatOptions } from "./config";
76
82
 
77
83
  export type * from "./types";
package/src/install.ts CHANGED
@@ -15,6 +15,7 @@ import { showToast } from "./ui/toast";
15
15
  import { defineReporterElement, REPORTER_TAG } from "./ui/element";
16
16
  import { loadRequestTypes } from "./requests";
17
17
  import { startLiveRing } from "./live";
18
+ import { mountSupportChat } from "./ui/chatBubble";
18
19
 
19
20
  let armed = false;
20
21
 
@@ -78,4 +79,14 @@ export function installDebugCapture(options: InstallOptions = {}): void {
78
79
  */
79
80
  if (idle) idle(() => startLiveRing(), { timeout: 8000 });
80
81
  else setTimeout(() => startLiveRing(), 3000);
82
+
83
+ /*
84
+ * แชทกับทีม's bubble (`chat`), once the page has settled — it shows itself only for a signed-in person (the host's
85
+ * token answers), and the person's socket above tells its badge when the desk writes.
86
+ */
87
+ if (getConfig().chat) {
88
+ if (!getConfig().identity) console.warn("[debug-capture] `chat` needs `identity` — a chat is a signed-in person's; the bubble is not shown");
89
+ else if (idle) idle(() => mountSupportChat(), { timeout: 4000 });
90
+ else setTimeout(() => mountSupportChat(), 1500);
91
+ }
81
92
  }
package/src/live.ts CHANGED
@@ -74,7 +74,20 @@ export function ringsHere(call: Pick<RingingCall, "status" | "side">): boolean {
74
74
  return call.status === "ringing" && (call.side === "reporter" || call.side === undefined || call.side === null);
75
75
  }
76
76
 
77
- async function hostToken(): Promise<string | null> {
77
+ /*
78
+ * What else the person's socket says — แชทกับทีม's bubble hears the desk answer here (ui/chatBubble): `chat-changed`,
79
+ * `ticket-changed`, `chat-seen`. One socket for the ring and the bubble.
80
+ */
81
+ type LiveFrame = { type?: string; [k: string]: unknown };
82
+ const frameListeners = new Set<(frame: LiveFrame) => void>();
83
+ export function onLiveFrame(fn: (frame: LiveFrame) => void): () => void {
84
+ frameListeners.add(fn);
85
+ return () => frameListeners.delete(fn);
86
+ }
87
+ /** Calls ring here — unless the host said `ring: false` (the socket may still be open for the chat's bubble). */
88
+ const ringOn = () => getConfig().ring !== false;
89
+
90
+ export async function hostToken(): Promise<string | null> {
78
91
  const provider = getConfig().identity;
79
92
  if (!provider) return null;
80
93
  try {
@@ -97,7 +110,8 @@ async function post(path: string, token: string): Promise<Response> {
97
110
  export function startLiveRing(): void {
98
111
  if (started || typeof window === "undefined" || typeof WebSocket === "undefined") return;
99
112
  const config = getConfig();
100
- if (!config.identity || config.ring === false) return;
113
+ // The ring, or แชทกับทีม's bubble — either needs the person's socket.
114
+ if (!config.identity || (config.ring === false && !config.chat)) return;
101
115
  started = true;
102
116
  stopped = false;
103
117
  warmUp(config.host);
@@ -147,7 +161,7 @@ async function connect(): Promise<void> {
147
161
  tokenless = 0;
148
162
  let res: Response;
149
163
  try {
150
- res = await post("ingest/live-ticket", token);
164
+ res = await post(getConfig().chat ? "ingest/live-ticket?chat=1" : "ingest/live-ticket", token);
151
165
  } catch {
152
166
  return schedule();
153
167
  }
@@ -164,11 +178,12 @@ async function connect(): Promise<void> {
164
178
  return schedule();
165
179
  }
166
180
  // Calls off for this desk: no socket at all.
167
- if (!out.calls || !out.ticket) {
181
+ // Calls off and no chat asked for: no socket at all. Calls off with the chat's bubble: the socket, no ring.
182
+ if (!out.ticket) {
168
183
  stopped = true;
169
184
  return;
170
185
  }
171
- if (out.call) hear(out.call);
186
+ if (out.calls && out.call && ringOn()) hear(out.call);
172
187
  const ws = new WebSocket(liveSocketUrl(getConfig().host, out.ticket));
173
188
  socket = ws;
174
189
  ws.onopen = () => {
@@ -183,7 +198,13 @@ async function connect(): Promise<void> {
183
198
  ws.onmessage = (e) => {
184
199
  try {
185
200
  const frame = JSON.parse(String(e.data)) as { type?: string; call?: RingingCall | string; op?: unknown };
186
- if (frame.type === "call-state" && frame.call && typeof frame.call === "object") hear(frame.call);
201
+ if (frame.type === "chat-changed" || frame.type === "ticket-changed" || frame.type === "chat-seen") {
202
+ for (const fn of frameListeners) fn(frame as LiveFrame);
203
+ return;
204
+ }
205
+ if (frame.type === "call-state" && frame.call && typeof frame.call === "object") {
206
+ if (ringOn()) hear(frame.call);
207
+ }
187
208
  // The team's marks on this page, shared from the portal's call (§21) — read again, never drawn as received.
188
209
  else if (frame.type === "call-ink" && typeof frame.call === "string") {
189
210
  const op = readInkOp(frame.op);
package/src/server.ts CHANGED
@@ -34,11 +34,15 @@ export interface SupportProfileFact {
34
34
  value: string;
35
35
  }
36
36
 
37
- /** Whoever the host has authenticated. Email is the account key. */
37
+ /**
38
+ * Whoever the host has authenticated. Email is the account key — or, for someone the host knows but holds no email
39
+ * for (a CIB officer with none in the ERP, 2026-10-27: every report they ever sent landed anonymous, nobody could call
40
+ * them back), the host's own id alone.
41
+ */
38
42
  export interface SupportUser {
39
- /** The host's own id for this person, recorded so a changed email still matches. */
43
+ /** The host's own id for this person, recorded so a changed email still matches — the key when there is no email. */
40
44
  id?: string | null;
41
- email: string;
45
+ email?: string | null;
42
46
  name?: string | null;
43
47
  /** What the host knows about their work — most important first. Optional; bounded on the way in. */
44
48
  profile?: SupportProfileFact[] | null;
@@ -95,8 +99,9 @@ export function createSupportToken(options: {
95
99
  user: SupportUser;
96
100
  ttlSeconds?: number;
97
101
  }): { token: string; expiresIn: number } {
98
- const email = options.user.email?.trim().toLowerCase();
99
- if (!email || !email.includes("@")) throw new Error("createSupportToken: a real email is required");
102
+ const email = options.user.email?.trim().toLowerCase() || null;
103
+ const id = options.user.id?.trim() || null;
104
+ if (email ? !email.includes("@") : !id) throw new Error("createSupportToken: a real email, or the host's own id for this person, is required");
100
105
  if (!options.secret || options.secret.length < 16) {
101
106
  throw new Error("createSupportToken: the realm's reporter secret is missing or too short");
102
107
  }
@@ -105,9 +110,10 @@ export function createSupportToken(options: {
105
110
  const expiresIn = Math.min(Math.max(Math.floor(options.ttlSeconds ?? DEFAULT_TTL_SECONDS), 30), 12 * 60 * 60);
106
111
  const body = `${b64({ alg: "HS256", typ: "JWT" })}.${b64({
107
112
  // The host's own id when it has one, so the same person is recognised even
108
- // if their email later changes. The email stands in when it does not.
109
- sub: options.user.id?.trim() || email,
110
- email,
113
+ // if their email later changes. The email stands in when it does not; with
114
+ // no email the id is the whole of who they are, and the claim is left out.
115
+ sub: id ?? email,
116
+ ...(email ? { email } : {}),
111
117
  name: options.user.name?.trim() || null,
112
118
  // Only when there is one: an older support desk ignores the claim, a newer one records it.
113
119
  ...(cleanProfile(options.user.profile) ? { profile: cleanProfile(options.user.profile) } : {}),
@@ -184,7 +190,8 @@ export function supportTokenHandler(options: {
184
190
  }
185
191
 
186
192
  const user = await options.user();
187
- if (!user?.email) return json({ error: "Unauthorized" }, 401);
193
+ // Nobody — or somebody the host cannot name by an email or an id of its own.
194
+ if (!user?.email?.trim() && !user?.id?.trim()) return json({ error: "Unauthorized" }, 401);
188
195
 
189
196
  try {
190
197
  const { token, expiresIn } = createSupportToken({ secret, user, ttlSeconds: options.ttlSeconds });
package/src/submit.ts CHANGED
@@ -182,6 +182,8 @@ export async function submitBundle(title: string, bundle: DebugBundle): Promise<
182
182
  ...(replay ? { replay } : {}),
183
183
  },
184
184
  attachments,
185
+ // From แชทกับทีม's panel: linked to the person's chat with the desk (a known person only — the desk checks).
186
+ ...(trimmed.reason.fromChat ? { fromChat: true } : {}),
185
187
  };
186
188
 
187
189
  // The widget ingest mounts at the ROOT of the support app, beside the
package/src/types.ts CHANGED
@@ -88,6 +88,8 @@ export interface DebugReason {
88
88
  type: DebugReasonType;
89
89
  message?: string;
90
90
  stack?: string;
91
+ /** Opened from แชทกับทีม's panel (แจ้งปัญหาพร้อมภาพหน้าจอ): the report is linked to the person's chat. */
92
+ fromChat?: boolean;
91
93
  }
92
94
 
93
95
  /** A failed data-layer query the host chose to report (react-query, Apollo, …). */
@@ -357,5 +357,5 @@ function place(): void {
357
357
  if (placed && !narrow) {
358
358
  const p = clampTo(placed, box, viewOf());
359
359
  Object.assign(s, { left: `${p.x}px`, top: `${p.y}px`, right: "", bottom: "" });
360
- } else Object.assign(s, { left: narrow ? `${MARGIN / 2}px` : "", top: "", right: narrow ? "" : `${MARGIN / 2}px`, bottom: `${MARGIN / 2}px` });
360
+ } else Object.assign(s, { left: narrow ? `${MARGIN / 2}px` : "", top: "", right: narrow ? "" : `${MARGIN / 2}px`, bottom: `calc(${MARGIN / 2}px + var(--lw-chat-lift, 0px))` });
361
361
  }
@@ -0,0 +1,247 @@
1
+ /**
2
+ * แชทกับทีม — the chat bubble (the support desk's docs/SUPPORT-CHAT-PLAN.md phase 4). `chat` in the config puts a round
3
+ * button at the bottom right of the host app; pressed, a panel above it: the support host's own page
4
+ * (`/embed/support`), framed — the person's chat with the desk and their เรื่อง, each thread in the panel. One chat,
5
+ * one thread, one editor for the bubble and the portal alike: nothing of it is drawn here but the button and the box.
6
+ *
7
+ * the badge what still waits for the person — the desk's lines in the chat and their เรื่อง's replies — read with
8
+ * the host's token before the panel was ever opened (/ingest/support-unread), again when the person's
9
+ * socket says the desk wrote (live.ts `onLiveFrame`), and from the panel itself while it is open
10
+ * sign-in the panel asks (`hello`); the host's token goes to it by postMessage, to the support host's origin only
11
+ * แจ้งปัญหา… in the panel: the panel folds and this library's report opens — its screen, its page — linked to the chat
12
+ * the corner the call's card and frame sit above the bubble (`--lw-chat-lift`)
13
+ *
14
+ * Shown only for a signed-in person (`identity` answers a token): a chat is someone's, and is read back.
15
+ */
16
+ import { chatPlace, getConfig } from "../config";
17
+ import { hostToken, onLiveFrame } from "../live";
18
+ import { launch } from "../reporter";
19
+ import { strings } from "./strings";
20
+
21
+ const HOST_TAG = "lw-support-chat";
22
+ const FRAME = "lw-support-frame";
23
+ const HOST = "lw-support-host";
24
+ const SIZE = 56;
25
+ const GAP = 12;
26
+
27
+ const ICON = `<svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/></svg>`;
28
+
29
+ const CSS = `
30
+ :host { all: initial; }
31
+ .bubble {
32
+ position: fixed; z-index: 2147483000; width: ${SIZE}px; height: ${SIZE}px; border-radius: 50%;
33
+ display: grid; place-items: center; border: 0; cursor: pointer; padding: 0;
34
+ 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
+ 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
+ }
38
+ .bubble:hover { background: #003a78; transform: translateY(-1px); }
39
+ .bubble:focus-visible { outline: 3px solid rgba(0, 70, 143, 0.35); outline-offset: 3px; }
40
+ .badge {
41
+ 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;
43
+ text-align: center; box-shadow: 0 0 0 2px #fff;
44
+ }
45
+ .badge[hidden] { display: none; }
46
+ .panel {
47
+ 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);
50
+ }
51
+ .panel[hidden] { display: none; }
52
+ .panel iframe { display: block; width: 100%; height: 100%; border: 0; background: #fff; }
53
+ @keyframes in { from { opacity: 0; transform: translateY(8px) scale(0.98); } to { opacity: 1; transform: none; } }
54
+ @media (max-width: 639px) {
55
+ .panel { inset: 0 !important; width: 100vw; height: 100dvh; border-radius: 0; border: 0; }
56
+ .open .bubble { display: none; }
57
+ }
58
+ @media (prefers-reduced-motion: reduce) { .panel { animation: none; } .bubble { transition: none; } }
59
+ `;
60
+
61
+ let root: HTMLElement | null = null;
62
+ let shadow: ShadowRoot | null = null;
63
+ let panel: HTMLDivElement | null = null;
64
+ let frame: HTMLIFrameElement | null = null;
65
+ let bubble: HTMLButtonElement | null = null;
66
+ let badge: HTMLSpanElement | null = null;
67
+ let isOpen = false;
68
+ let unread = 0;
69
+
70
+ const base = (host: string) => (host.endsWith("/") ? host : `${host}/`);
71
+ const hostOrigin = () => {
72
+ try {
73
+ return new URL(getConfig().host).origin;
74
+ } catch {
75
+ return "*";
76
+ }
77
+ };
78
+
79
+ /** The panel's address: the support host's page, told the realm, signing in by hand-off (no token in the address). */
80
+ export function supportFrameUrl(host: string, realmKey: string): string {
81
+ const url = new URL("embed/support", base(host));
82
+ url.searchParams.set("realm", realmKey);
83
+ url.searchParams.set("hand", "1");
84
+ return url.toString();
85
+ }
86
+
87
+ /** The badge's number: `99+` past 99; nothing at 0. Pure, for the tests. */
88
+ export function badgeText(n: number): string {
89
+ return n <= 0 ? "" : n > 99 ? "99+" : String(n);
90
+ }
91
+
92
+ function setBadge(n: number): void {
93
+ unread = Math.max(0, Math.floor(n) || 0);
94
+ if (!badge || !bubble) return;
95
+ const t = strings(getConfig().locale);
96
+ badge.textContent = badgeText(unread);
97
+ badge.hidden = unread === 0;
98
+ bubble.setAttribute("aria-label", unread ? `${t.chatOpen} · ${t.chatUnread.replace("{n}", String(unread))}` : t.chatOpen);
99
+ }
100
+
101
+ let asking: Promise<void> | null = null;
102
+ /** What waits for the person, read with the host's token — no token: nobody is signed in, and there is no bubble. */
103
+ async function refreshUnread(): Promise<void> {
104
+ asking ??= (async () => {
105
+ try {
106
+ const token = await hostToken();
107
+ if (!token) return void show(false);
108
+ const config = getConfig();
109
+ const res = await fetch(new URL("ingest/support-unread", base(config.host)).toString(), { headers: { "x-realm-key": config.realmKey, authorization: `Bearer ${token}` } });
110
+ // A support host from before the chat, or a token it does not accept: no bubble rather than one that cannot open.
111
+ if (res.status === 404 || res.status === 401 || res.status === 403) return void show(false);
112
+ if (!res.ok) return;
113
+ show(true);
114
+ const out = (await res.json()) as { total?: number };
115
+ if (!isOpen) setBadge(typeof out.total === "number" ? out.total : 0);
116
+ } catch {
117
+ /* the next nudge asks again */
118
+ } finally {
119
+ asking = null;
120
+ }
121
+ })();
122
+ return asking;
123
+ }
124
+
125
+ function show(on: boolean): void {
126
+ if (!root) return;
127
+ 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`);
131
+ else document.documentElement.style.removeProperty("--lw-chat-lift");
132
+ }
133
+
134
+ function post(message: Record<string, unknown>): void {
135
+ frame?.contentWindow?.postMessage({ source: HOST, ...message }, hostOrigin());
136
+ }
137
+
138
+ async function onFrame(e: MessageEvent): Promise<void> {
139
+ if (!frame || e.source !== frame.contentWindow) return;
140
+ const d = e.data as { source?: unknown; type?: unknown; total?: unknown } | null;
141
+ if (!d || d.source !== FRAME) return;
142
+ if (d.type === "hello") {
143
+ const token = await hostToken();
144
+ if (token) post({ type: "token", token });
145
+ return;
146
+ }
147
+ if (d.type === "ready") return post({ type: isOpen ? "shown" : "hidden" });
148
+ if (d.type === "close") return closeSupportChat();
149
+ if (d.type === "unread" && typeof d.total === "number") return setBadge(d.total);
150
+ if (d.type === "report") {
151
+ // The panel folds, and the report opens over the page it is about — linked to the chat (ingest `fromChat`).
152
+ closeSupportChat(false);
153
+ launch({ type: "manual", fromChat: true });
154
+ }
155
+ }
156
+
157
+ const onMessage = (e: MessageEvent) => void onFrame(e);
158
+
159
+ function onKey(e: KeyboardEvent): void {
160
+ if (isOpen && e.key === "Escape") closeSupportChat();
161
+ }
162
+
163
+ /** Open the panel — from the bubble, or from the host app's own button (`openSupportChat`). */
164
+ export function openSupportChat(): void {
165
+ if (!root || !panel || !shadow) mountSupportChat();
166
+ if (!panel) return;
167
+ const config = getConfig();
168
+ if (!frame) {
169
+ frame = document.createElement("iframe");
170
+ frame.src = supportFrameUrl(config.host, config.realmKey);
171
+ frame.title = strings(config.locale).chatTitle;
172
+ frame.setAttribute("allow", "clipboard-write");
173
+ panel.append(frame);
174
+ }
175
+ isOpen = true;
176
+ panel.hidden = false;
177
+ shadow?.querySelector(".wrap")?.classList.add("open");
178
+ bubble?.setAttribute("aria-expanded", "true");
179
+ post({ type: "shown" });
180
+ frame.focus();
181
+ }
182
+
183
+ /** Fold the panel — it stays loaded, so the chat is there as it was the next time. */
184
+ export function closeSupportChat(refocus = true): void {
185
+ if (!panel) return;
186
+ isOpen = false;
187
+ panel.hidden = true;
188
+ shadow?.querySelector(".wrap")?.classList.remove("open");
189
+ bubble?.setAttribute("aria-expanded", "false");
190
+ post({ type: "hidden" });
191
+ if (refocus) bubble?.focus();
192
+ void refreshUnread();
193
+ }
194
+
195
+ let stopFrames: (() => void) | null = null;
196
+ let lastFocusAsk = 0;
197
+ function onFocus(): void {
198
+ // A person coming back to the tab: what came while they were away — at most twice a minute.
199
+ if (Date.now() - lastFocusAsk < 30_000) return;
200
+ lastFocusAsk = Date.now();
201
+ void refreshUnread();
202
+ }
203
+
204
+ /** The bubble, once — hidden until the host's token says somebody is signed in. */
205
+ export function mountSupportChat(): void {
206
+ if (root || typeof document === "undefined") return;
207
+ const config = getConfig();
208
+ const place = chatPlace(config.chat);
209
+ if (!place || !config.identity) return;
210
+ const t = strings(config.locale);
211
+ root = document.createElement(HOST_TAG);
212
+ root.style.display = "none";
213
+ 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>`;
216
+ panel = shadow.querySelector(".panel");
217
+ bubble = shadow.querySelector(".bubble");
218
+ 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()));
222
+ document.body.append(root);
223
+ window.addEventListener("message", onMessage);
224
+ document.addEventListener("keydown", onKey);
225
+ window.addEventListener("focus", onFocus);
226
+ // The desk wrote, or opened a เรื่อง, or read: the badge asks again — while the panel is open, the panel tells it.
227
+ let settle: ReturnType<typeof setTimeout> | undefined;
228
+ stopFrames = onLiveFrame(() => {
229
+ if (isOpen) return;
230
+ clearTimeout(settle);
231
+ settle = setTimeout(() => void refreshUnread(), 500);
232
+ });
233
+ void refreshUnread();
234
+ }
235
+
236
+ /** Take it away — for a host that unmounts, and for the tests. */
237
+ export function unmountSupportChat(): void {
238
+ stopFrames?.();
239
+ stopFrames = null;
240
+ window.removeEventListener("message", onMessage);
241
+ document.removeEventListener("keydown", onKey);
242
+ window.removeEventListener("focus", onFocus);
243
+ root?.remove();
244
+ root = shadow = panel = frame = bubble = badge = null;
245
+ isOpen = false;
246
+ document.documentElement.style.removeProperty("--lw-chat-lift");
247
+ }
package/src/ui/ring.ts CHANGED
@@ -26,14 +26,14 @@ const HOST_TAG = "lw-call-ring";
26
26
  const CSS = `
27
27
  :host { all: initial; }
28
28
  .card {
29
- position: fixed; right: 16px; bottom: 16px; z-index: 2147483002;
29
+ position: fixed; right: 16px; bottom: calc(16px + var(--lw-chat-lift, 0px)); z-index: 2147483002;
30
30
  width: min(352px, calc(100vw - 32px)); box-sizing: border-box;
31
31
  padding: 16px; border-radius: 16px; background: #fff; color: #1a1a1e;
32
32
  border: 1px solid #e8e8ec; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
33
33
  font-family: "Inter Variable", "Noto Sans Thai Variable", "IBM Plex Sans Thai", -apple-system, "Segoe UI", Roboto, "Noto Sans Thai", sans-serif;
34
34
  animation: in 200ms cubic-bezier(0.16, 1, 0.3, 1);
35
35
  }
36
- @media (max-width: 639px) { .card { right: 8px; left: 8px; bottom: 8px; width: auto; } }
36
+ @media (max-width: 639px) { .card { right: 8px; left: 8px; bottom: calc(8px + var(--lw-chat-lift, 0px)); width: auto; } }
37
37
  .head { display: flex; align-items: center; gap: 12px; }
38
38
  .icon {
39
39
  position: relative; flex: none; width: 48px; height: 48px; border-radius: 50%;