@lightworkai.official/debug-capture 0.14.2 → 0.15.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/src/ui/ring.ts ADDED
@@ -0,0 +1,248 @@
1
+ /**
2
+ * A call from the support team, rung in the host app (live.ts).
3
+ *
4
+ * The call itself happens on the support host — its media code, its screen
5
+ * sharing, its dock — so this card does one thing: say who is calling and
6
+ * about what, and open the reporter's request there to answer it. A soft ring
7
+ * and the tab's title too, because a call that rings in a background tab and
8
+ * says nothing is a missed call.
9
+ *
10
+ * Its own shadow root and styles, like the dialog, the toast and the hand-off:
11
+ * it lands on pages we do not control. Marked as ours, so a report filed while
12
+ * it is up does not photograph it.
13
+ */
14
+ import { getConfig } from "../config";
15
+ import { containKeys } from "./keys";
16
+ import { strings } from "./strings";
17
+
18
+ const HOST_TAG = "lw-call-ring";
19
+
20
+ const CSS = `
21
+ :host { all: initial; }
22
+ .card {
23
+ position: fixed; right: 16px; bottom: 16px; z-index: 2147483002;
24
+ width: min(340px, calc(100vw - 32px)); box-sizing: border-box;
25
+ padding: 16px; border-radius: 16px; background: #fff; color: #0f172a;
26
+ border: 1px solid #e2e8f0; box-shadow: 0 24px 60px rgba(15, 23, 42, 0.24);
27
+ font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans Thai", sans-serif;
28
+ animation: in 200ms cubic-bezier(0.16, 1, 0.3, 1);
29
+ }
30
+ @media (max-width: 480px) { .card { right: 8px; left: 8px; bottom: 8px; width: auto; } }
31
+ .head { display: flex; align-items: center; gap: 12px; }
32
+ .icon {
33
+ position: relative; flex: none; width: 44px; height: 44px; border-radius: 50%;
34
+ display: grid; place-items: center; background: #ecfdf5; color: #059669;
35
+ }
36
+ .icon::after {
37
+ content: ""; position: absolute; inset: 0; border-radius: 50%;
38
+ background: rgba(16, 185, 129, 0.25); animation: ping 1.4s ease-out infinite;
39
+ }
40
+ .icon svg { position: relative; width: 20px; height: 20px; }
41
+ h2 { margin: 0; font-size: 15px; font-weight: 600; line-height: 1.4; }
42
+ .about { margin: 2px 0 0; font-size: 12px; color: #475569; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 250px; }
43
+ .how { margin: 10px 0 0; font-size: 12px; line-height: 1.6; color: #64748b; }
44
+ .how a { color: #0369a1; font-weight: 600; }
45
+ .actions { display: flex; gap: 8px; margin-top: 12px; }
46
+ .actions button {
47
+ flex: 1; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center;
48
+ height: 40px; padding: 0 12px; border-radius: 999px; font: inherit; font-size: 14px; font-weight: 600; cursor: pointer;
49
+ }
50
+ .decline { border: 1px solid #fecdd3; background: #fff1f2; color: #be123c; }
51
+ .decline:hover { background: #ffe4e6; }
52
+ .open { border: none; background: #059669; color: #fff; }
53
+ .open:hover { background: #047857; }
54
+ .close { border: 1px solid #e2e8f0; background: #fff; color: #334155; }
55
+ .actions button:focus-visible { outline: 2px solid #0ea5e9; outline-offset: 2px; }
56
+ @keyframes in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
57
+ @keyframes ping { from { transform: scale(1); opacity: 1; } to { transform: scale(1.6); opacity: 0; } }
58
+ @media (prefers-reduced-motion: reduce) { .card { animation: none; } .icon::after { animation: none; opacity: 0; } }
59
+ `;
60
+
61
+ const PHONE = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.36 1.9.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.34 1.85.57 2.81.7A2 2 0 0 1 22 16.92z"/></svg>`;
62
+
63
+ export interface RingCall {
64
+ id: string;
65
+ ticketKey: string;
66
+ ticketTitle: string;
67
+ }
68
+
69
+ export interface RingHandlers {
70
+ /** Runs INSIDE the press: a tab can be opened only there, before anything is awaited. */
71
+ open: () => void;
72
+ decline: () => void;
73
+ }
74
+
75
+ let host: HTMLElement | null = null;
76
+ let shownId: string | null = null;
77
+ let titleBefore: string | null = null;
78
+
79
+ /** "เรื่อง CALL-12 · title" — the line under the card's title. Pure, for the tests. */
80
+ export function ringAbout(template: string, call: RingCall): string {
81
+ return template.replace("{key}", call.ticketKey).replace("{title}", call.ticketTitle);
82
+ }
83
+
84
+ const t = () => strings(getConfig().locale);
85
+
86
+ function mount(): ShadowRoot | null {
87
+ if (typeof document === "undefined") return null;
88
+ if (host?.isConnected) return host.shadowRoot;
89
+ host = document.createElement(HOST_TAG);
90
+ host.dataset.debugReporter = "true";
91
+ const root = host.attachShadow({ mode: "open" });
92
+ containKeys(root);
93
+ document.body.append(host);
94
+ return root;
95
+ }
96
+
97
+ /** Show the ring for this call — once; the same call again changes nothing. */
98
+ export function showRing(call: RingCall, handlers: RingHandlers): void {
99
+ if (shownId === call.id && host?.isConnected) return;
100
+ const root = mount();
101
+ if (!root) return;
102
+ shownId = call.id;
103
+ const s = t();
104
+ root.innerHTML = "";
105
+ const style = document.createElement("style");
106
+ style.textContent = CSS;
107
+ const card = document.createElement("div");
108
+ card.className = "card";
109
+ card.setAttribute("role", "alertdialog");
110
+ card.setAttribute("aria-label", s.ringTitle);
111
+ card.dataset.lwCallRing = call.id;
112
+ const head = document.createElement("div");
113
+ head.className = "head";
114
+ const icon = document.createElement("span");
115
+ icon.className = "icon";
116
+ icon.innerHTML = PHONE;
117
+ const words = document.createElement("div");
118
+ const h = document.createElement("h2");
119
+ h.textContent = s.ringTitle;
120
+ const about = document.createElement("p");
121
+ about.className = "about";
122
+ about.textContent = ringAbout(s.ringAbout, call);
123
+ about.title = about.textContent;
124
+ words.append(h, about);
125
+ head.append(icon, words);
126
+ const how = document.createElement("p");
127
+ how.className = "how";
128
+ how.textContent = s.ringHow;
129
+ const actions = document.createElement("div");
130
+ actions.className = "actions";
131
+ const decline = document.createElement("button");
132
+ decline.type = "button";
133
+ decline.className = "decline";
134
+ decline.textContent = s.ringDecline;
135
+ decline.dataset.lwRing = "decline";
136
+ decline.onclick = () => handlers.decline();
137
+ const open = document.createElement("button");
138
+ open.type = "button";
139
+ open.className = "open";
140
+ open.textContent = s.ringOpen;
141
+ open.dataset.lwRing = "open";
142
+ open.onclick = () => handlers.open();
143
+ actions.append(decline, open);
144
+ card.append(head, how, actions);
145
+ root.append(style, card);
146
+ startTone();
147
+ if (titleBefore === null) {
148
+ titleBefore = document.title;
149
+ document.title = `${s.ringTab} — ${titleBefore}`;
150
+ }
151
+ open.focus({ preventScroll: true });
152
+ }
153
+
154
+ /** Opened: the card says where to answer, and stops ringing. `blockedUrl` — the browser refused the tab: a link, which is a fresh press. */
155
+ export function ringOpened(blockedUrl?: string): void {
156
+ const root = host?.shadowRoot;
157
+ if (!root) return;
158
+ stopTone();
159
+ restoreTitle();
160
+ const s = t();
161
+ const how = root.querySelector(".how");
162
+ const actions = root.querySelector(".actions");
163
+ if (how) {
164
+ how.textContent = "";
165
+ if (blockedUrl) {
166
+ const a = document.createElement("a");
167
+ a.href = blockedUrl;
168
+ a.target = "_blank";
169
+ a.rel = "noopener";
170
+ a.textContent = s.ringBlocked;
171
+ a.dataset.lwRing = "link";
172
+ a.onclick = () => window.setTimeout(() => ringOpened(), 0);
173
+ how.append(a);
174
+ } else how.textContent = s.ringOpened;
175
+ }
176
+ if (actions) {
177
+ actions.innerHTML = "";
178
+ const close = document.createElement("button");
179
+ close.type = "button";
180
+ close.className = "close";
181
+ close.textContent = s.ringClose;
182
+ close.dataset.lwRing = "close";
183
+ close.onclick = () => hideRing();
184
+ actions.append(close);
185
+ }
186
+ }
187
+
188
+ /** The call is no longer ringing for this person — answered, declined, missed or ended. */
189
+ export function hideRing(id?: string): void {
190
+ if (id && shownId && id !== shownId) return;
191
+ stopTone();
192
+ restoreTitle();
193
+ host?.remove();
194
+ host = null;
195
+ shownId = null;
196
+ }
197
+
198
+ export function ringShownFor(): string | null {
199
+ return host?.isConnected ? shownId : null;
200
+ }
201
+
202
+ function restoreTitle(): void {
203
+ if (titleBefore === null || typeof document === "undefined") return;
204
+ if (document.title.startsWith(t().ringTab)) document.title = titleBefore;
205
+ titleBefore = null;
206
+ }
207
+
208
+ /* ── A soft ring: two notes every two seconds, as the support host's own ── */
209
+
210
+ let audio: AudioContext | null = null;
211
+ let toneTimer: ReturnType<typeof setInterval> | undefined;
212
+
213
+ function startTone(): void {
214
+ stopTone();
215
+ const w = window as unknown as { AudioContext?: typeof AudioContext; webkitAudioContext?: typeof AudioContext };
216
+ const Ctx = w.AudioContext ?? w.webkitAudioContext;
217
+ if (!Ctx) return;
218
+ try {
219
+ const ctx = audio ?? new Ctx();
220
+ audio = ctx;
221
+ // A page nobody has pressed anything on may not play sound — then the card and the title carry it.
222
+ void ctx.resume?.().catch(() => undefined);
223
+ } catch {
224
+ return;
225
+ }
226
+ const beep = () => {
227
+ if (!audio || audio.state !== "running") return;
228
+ const now = audio.currentTime;
229
+ for (const [i, f] of [660, 880].entries()) {
230
+ const osc = audio.createOscillator();
231
+ const gain = audio.createGain();
232
+ osc.frequency.value = f;
233
+ gain.gain.setValueAtTime(0.0001, now + i * 0.22);
234
+ gain.gain.exponentialRampToValueAtTime(0.08, now + i * 0.22 + 0.02);
235
+ gain.gain.exponentialRampToValueAtTime(0.0001, now + i * 0.22 + 0.2);
236
+ osc.connect(gain).connect(audio.destination);
237
+ osc.start(now + i * 0.22);
238
+ osc.stop(now + i * 0.22 + 0.21);
239
+ }
240
+ };
241
+ beep();
242
+ toneTimer = setInterval(beep, 2200);
243
+ }
244
+
245
+ function stopTone(): void {
246
+ clearInterval(toneTimer);
247
+ toneTimer = undefined;
248
+ }
package/src/ui/strings.ts CHANGED
@@ -65,20 +65,29 @@ export interface Strings {
65
65
  handoffBlocked: string;
66
66
  handoffOpen: string;
67
67
  /*
68
- * ขอให้ทีมดำเนินการ — the same dialog turned into a request, where the desk
69
- * opens request types to its reporters (docs/WIDGET-ASK-PLAN.md): two cards
70
- * under the heading, แจ้งปัญหา chosen as it opens, either one any time.
68
+ * ขอให้ทีมดำเนินการ, where the desk opens request types to its reporters
69
+ * (docs/WIDGET-ASK-PLAN.md): the press asks first with two cards; the form
70
+ * keeps one quiet line to switch.
71
71
  */
72
72
  chooseTitle: string;
73
73
  kindProblem: string;
74
- kindProblemHint: string;
75
74
  kindRequest: string;
76
- kindRequestHint: string;
75
+ askInstead: string;
76
+ askInsteadAction: string;
77
+ problemInstead: string;
78
+ problemInsteadAction: string;
79
+ /** The same, from a request that began with no screen: the share will be asked for. */
80
+ problemInsteadShare: string;
77
81
  /** On the cards before anything is asked of the browser: what each will do. */
78
82
  chooseProblemHint: string;
79
83
  chooseRequestHint: string;
80
84
  requestTitle: string;
81
85
  requestTypeLabel: string;
86
+ /** Over a long list of types: the search, and what it says when nothing matches. */
87
+ requestTypeSearch: string;
88
+ requestTypeNone: string;
89
+ /** Carries `{n}`. */
90
+ requestTypeCount: string;
82
91
  /** Carries `{time}`. */
83
92
  requestTarget: string;
84
93
  requestSummaryLabel: string;
@@ -99,6 +108,16 @@ export interface Strings {
99
108
  /** Carry `{n}`. */
100
109
  days: string;
101
110
  hours: string;
111
+ /** A call from the support team (ui/ring). `ringAbout` carries `{key}` and `{title}`. */
112
+ ringTitle: string;
113
+ ringAbout: string;
114
+ ringHow: string;
115
+ ringOpen: string;
116
+ ringDecline: string;
117
+ ringOpened: string;
118
+ ringBlocked: string;
119
+ ringClose: string;
120
+ ringTab: string;
102
121
  }
103
122
 
104
123
  const th: Strings = {
@@ -146,13 +165,19 @@ const th: Strings = {
146
165
  handoffOpen: "เปิดในแท็บใหม่",
147
166
  chooseTitle: "ต้องการให้ทีมช่วยเรื่องอะไร",
148
167
  kindProblem: "แจ้งปัญหา",
149
- kindProblemHint: "มีอะไรเสีย หรือใช้งานไม่ได้",
150
168
  kindRequest: "คำขอ",
151
- kindRequestHint: "ขอให้ทีมทำ เช่น ขอสิทธิ์ ขอแก้ข้อมูล",
169
+ askInstead: "เป็นคำขอ?",
170
+ askInsteadAction: "เลือกเรื่องที่ขอ",
171
+ problemInstead: "เป็นปัญหาที่ต้องแก้?",
172
+ problemInsteadAction: "แจ้งปัญหาแทน",
173
+ problemInsteadShare: "แจ้งปัญหาแทน (แนบภาพหน้าจอ)",
152
174
  chooseProblemHint: "มีอะไรเสีย หรือใช้งานไม่ได้ — แนบภาพหน้าจอ",
153
175
  chooseRequestHint: "ขอให้ทีมทำ เช่น ขอสิทธิ์ ขอแก้ข้อมูล — ไม่ต้องแชร์หน้าจอ",
154
176
  requestTitle: "ขอให้ทีมดำเนินการ",
155
177
  requestTypeLabel: "เรื่องที่ขอ",
178
+ requestTypeSearch: "ค้นหาเรื่องที่ขอ",
179
+ requestTypeNone: "ไม่พบเรื่องที่ขอ — ลองคำอื่น",
180
+ requestTypeCount: "{n} เรื่อง — เลื่อนดูหรือพิมพ์ค้นหา",
156
181
  requestTarget: "ทีมตั้งใจให้เสร็จภายใน {time}",
157
182
  requestSummaryLabel: "หัวข้อ",
158
183
  requestSummaryPlaceholder: "ขออะไร — สั้น ๆ",
@@ -168,6 +193,15 @@ const th: Strings = {
168
193
  requestLeavesWhat: "บันทึกการใช้งานของหน้านี้ — ใช้กับการแจ้งปัญหาเท่านั้น",
169
194
  days: "{n} วัน",
170
195
  hours: "{n} ชั่วโมง",
196
+ ringTitle: "ทีมงานกำลังโทรหาคุณ",
197
+ ringAbout: "เรื่อง {key} · {title}",
198
+ ringHow: "รับสายในหน้าคำร้องของคุณ — เปิดในแท็บใหม่ ไม่ต้องเข้าสู่ระบบอีก",
199
+ ringOpen: "เปิดเพื่อรับสาย",
200
+ ringDecline: "ไม่รับ",
201
+ ringOpened: "เปิดหน้ารับสายแล้ว — กด รับสาย ในแท็บที่เปิดขึ้น",
202
+ ringBlocked: "เบราว์เซอร์บล็อกแท็บใหม่ — กดที่นี่เพื่อเปิดหน้ารับสาย",
203
+ ringClose: "ปิด",
204
+ ringTab: "📞 สายเข้า",
171
205
  };
172
206
 
173
207
  const en: Strings = {
@@ -215,13 +249,19 @@ const en: Strings = {
215
249
  handoffOpen: "Open in a new tab",
216
250
  chooseTitle: "How can the team help?",
217
251
  kindProblem: "Report a problem",
218
- kindProblemHint: "Something is broken or not working",
219
252
  kindRequest: "Request",
220
- kindRequestHint: "Ask the team to do something, like access or a data change",
253
+ askInstead: "Asking for something?",
254
+ askInsteadAction: "Choose what you need",
255
+ problemInstead: "Something broken?",
256
+ problemInsteadAction: "Report a problem instead",
257
+ problemInsteadShare: "Report a problem instead (with a screenshot)",
221
258
  chooseProblemHint: "Something is broken or not working — with a screenshot",
222
259
  chooseRequestHint: "Ask the team to do something, like access or a data change — no screen sharing",
223
260
  requestTitle: "Ask the team",
224
261
  requestTypeLabel: "What you need",
262
+ requestTypeSearch: "Search what you need",
263
+ requestTypeNone: "Nothing matches — try another word",
264
+ requestTypeCount: "{n} to choose from — scroll or search",
225
265
  requestTarget: "The team aims to finish within {time}",
226
266
  requestSummaryLabel: "Subject",
227
267
  requestSummaryPlaceholder: "What do you need — briefly",
@@ -237,6 +277,15 @@ const en: Strings = {
237
277
  requestLeavesWhat: "this page's activity log — it is for problem reports only",
238
278
  days: "{n} days",
239
279
  hours: "{n} hours",
280
+ ringTitle: "The support team is calling you",
281
+ ringAbout: "About {key} · {title}",
282
+ ringHow: "Answer on your request's page — it opens in a new tab, already signed in",
283
+ ringOpen: "Open to answer",
284
+ ringDecline: "Decline",
285
+ ringOpened: "Opened — press Answer in the new tab",
286
+ ringBlocked: "Your browser blocked the new tab — open the call here",
287
+ ringClose: "Close",
288
+ ringTab: "📞 Incoming call",
240
289
  };
241
290
 
242
291
  export function strings(locale: "th" | "en" | undefined): Strings {
package/src/ui/styles.ts CHANGED
@@ -34,11 +34,11 @@ h2 { margin: 0; font-size: 20px; font-weight: 700; flex: 1; }
34
34
  label { display: block; font-size: 14px; font-weight: 600; margin-bottom: 6px; }
35
35
  .req { color: #dc2626; }
36
36
  .hint { font-weight: 400; color: #64748b; }
37
- input[type="text"], textarea, select {
37
+ input[type="text"], input[type="search"], textarea, select {
38
38
  width: 100%; padding: 10px 12px; font: inherit; font-size: 14px;
39
39
  border: 1px solid #cbd5e1; border-radius: 10px; background: #fff; color: inherit;
40
40
  }
41
- input[type="text"]:focus, textarea:focus, select:focus {
41
+ input[type="text"]:focus, input[type="search"]:focus, textarea:focus, select:focus {
42
42
  outline: none; border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.18);
43
43
  }
44
44
  textarea { min-height: 88px; resize: vertical; }
@@ -98,8 +98,10 @@ canvas { width: 100%; height: auto; display: block; border: 1px solid #e2e8f0; b
98
98
  แจ้งปัญหา · คำขอ as two cards under the heading, the way the desk's own
99
99
  สร้างเวิร์กโฟลว์ asks it; what is asked for as cards, the way the portal lists it. */
100
100
  [hidden] { display: none !important; }
101
- .kinds { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: -4px; }
102
- /* Before anything is asked of the browser: the same two cards, a little larger, in a smaller dialog. */
101
+ /* The press's choice: two cards in a smaller dialog, before anything is asked of the browser. */
102
+ .kinds { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
103
+ /* In the form, the choice is made: one quiet line under the heading to switch. */
104
+ .door { margin: -8px 0 0; font-size: 13px; color: #64748b; }
103
105
  .panel.narrow { width: min(560px, 100%); }
104
106
  .kinds.choose { margin: 0 0 24px; }
105
107
  .kinds.choose button.kind { padding: 16px 18px; gap: 6px; }
@@ -120,7 +122,16 @@ button.kind[aria-checked="true"], button.kind[aria-checked="true"]:hover:not(:di
120
122
  button.kind[aria-checked="true"] .tick { opacity: 1; }
121
123
  button.kind:focus-visible, button.type:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.25); }
122
124
  button.kind[aria-checked="true"]:focus-visible { box-shadow: inset 0 0 0 1px #2563eb, 0 0 0 3px rgba(37, 99, 235, 0.25); }
123
- .types { display: grid; gap: 8px; }
125
+ .types { display: grid; gap: 6px; }
126
+ /* A long list scrolls in its own height, so the title and ส่งคำขอ stay near. */
127
+ .types.long { max-height: 300px; overflow-y: auto; padding: 2px; margin: -2px; overscroll-behavior: contain; }
128
+ .type-search { margin-bottom: 8px; }
129
+ .ask .type-count { margin: 6px 0 0; font-size: 12px; }
130
+ /* The rest are one line: only the chosen one opens up with its words and the team's time. */
131
+ button.type:not([aria-checked="true"]) { padding: 8px 12px; align-items: center; }
132
+ button.type:not([aria-checked="true"]) .type-hint, button.type:not([aria-checked="true"]) .type-due { display: none; }
133
+ button.type:not([aria-checked="true"]) .type-body b { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
134
+ button.type:not([aria-checked="true"]) .icon { margin-top: 0; }
124
135
  button.type {
125
136
  display: flex; align-items: flex-start; gap: 10px; width: 100%;
126
137
  padding: 10px 12px; text-align: left; border-radius: 10px;