@lightworkai.official/debug-capture 0.13.0 → 0.14.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/element.ts CHANGED
@@ -24,6 +24,19 @@ import { CSS } from "./styles";
24
24
 
25
25
  export const REPORTER_TAG = "lw-debug-reporter";
26
26
 
27
+ /**
28
+ * What was typed and not sent, kept for the next press in this tab — Gmail's
29
+ * compose: closing keeps the draft (DESIGN §7). ✕, ปิด and Escape all close;
30
+ * none of them is a decision to throw words away. Cleared when it is sent.
31
+ */
32
+ interface Draft {
33
+ title: string;
34
+ detail: string;
35
+ asking: boolean;
36
+ typeId: string | null;
37
+ }
38
+ let draft: Draft | null = null;
39
+
27
40
  /**
28
41
  * Failed data-layer queries the host wants on the record — react-query, Apollo,
29
42
  * whatever it uses. Set by the host; read at capture time. A callback rather
@@ -82,6 +95,10 @@ function createReporterClass(): CustomElementConstructor {
82
95
  private annotator: Annotator | null = null;
83
96
  private bundle: DebugBundle | null = null;
84
97
  private busy = false;
98
+ /** The open form's words, read when it closes unsent. */
99
+ private keep: (() => Draft | null) | null = null;
100
+ /** What had the focus before the press — the host's button, usually — given back on close. */
101
+ private returnFocus: HTMLElement | null = null;
85
102
 
86
103
  constructor() {
87
104
  super();
@@ -95,6 +112,14 @@ function createReporterClass(): CustomElementConstructor {
95
112
  // shortcuts must not hear it (ui/keys). Once, on the root — it outlives
96
113
  // every open and close.
97
114
  containKeys(this.root);
115
+ // Escape closes the dialog — unless something inside it took the key
116
+ // first (a note being typed on the picture, a shape being let go of).
117
+ this.root.addEventListener("keydown", (event) => {
118
+ const e = event as KeyboardEvent;
119
+ if (e.key !== "Escape" || e.defaultPrevented || !this.root.querySelector("[role=dialog]")) return;
120
+ e.preventDefault();
121
+ this.close();
122
+ });
98
123
  }
99
124
 
100
125
  connectedCallback(): void {
@@ -119,23 +144,19 @@ function createReporterClass(): CustomElementConstructor {
119
144
  private async open(reason: DebugReason): Promise<void> {
120
145
  if (this.busy) return;
121
146
  this.busy = true;
147
+ const active = document.activeElement;
148
+ this.returnFocus = active instanceof HTMLElement && active !== document.body ? active : null;
122
149
  const t = strings(getConfig().locale);
123
-
124
150
  /*
125
- * Pressed by hand, on a desk that takes requests from its reporters:
126
- * ask first whether this is a problem or a request. Read from what is
127
- * already known — waiting on the network here would spend the click the
128
- * screen share needs; a read not landed yet is the old dialog, and the
129
- * next press will have it.
151
+ * One door: the press takes the screen and opens the report form,
152
+ * always. Asking the team is a choice made IN that form — เป็นคำขอ?
153
+ * เลือกเรื่องที่ขอ — never a door before it (docs/WIDGET-ASK-PLAN.md): a
154
+ * chooser here cost every press one more click to serve the one in
155
+ * twelve that asks, and a press before the offer was read had no way to
156
+ * ask at all. The offer is read without waiting — the share needs this
157
+ * click — and the form shows the door whenever it lands.
130
158
  */
131
- if (reason.type === "manual") {
132
- const asks = requestTypesNow();
133
- if (asks?.length) {
134
- this.renderChooser(t, reason, asks);
135
- return;
136
- }
137
- if (asks === null) void loadRequestTypes();
138
- }
159
+ if (requestTypesNow() === null) void loadRequestTypes();
139
160
  await this.report(reason, t);
140
161
  }
141
162
 
@@ -165,117 +186,22 @@ function createReporterClass(): CustomElementConstructor {
165
186
  screenshotMethod: shot.method,
166
187
  erroredQueries: safeQueries(),
167
188
  });
168
- this.renderForm(t);
169
- }
170
-
171
- /** แจ้งปัญหา or ขอให้ทีมดำเนินการ — two cards, nothing else. */
172
- private renderChooser(t: Strings, reason: DebugReason, types: RequestTypeOption[]): void {
173
- const panel = this.shell(t);
174
- panel.classList.add("narrow");
175
- panel.setAttribute("aria-label", t.chooseTitle);
176
- const head = el("div", "head");
177
- const closeBtn = button("✕", "icon");
178
- closeBtn.setAttribute("aria-label", t.close);
179
- closeBtn.addEventListener("click", () => this.close());
180
- head.append(el("h2", "", t.chooseTitle), closeBtn);
181
-
182
- const problem = choice(t.chooseProblem, t.chooseProblemHint);
183
- problem.dataset.choose = "problem";
184
- // Our own dialog leaves the screen before the share starts, or it is in the picture.
185
- problem.addEventListener("click", () => {
186
- this.root.replaceChildren();
187
- void this.report(reason, t);
188
- });
189
- const ask = choice(t.chooseRequest, t.chooseRequestHint);
190
- ask.dataset.choose = "request";
191
- ask.addEventListener("click", () => this.renderRequestForm(t, reason, types));
192
-
193
- const choices = el("div", "choices");
194
- choices.append(problem, ask);
195
- panel.append(head, choices);
196
- problem.focus();
197
- }
198
-
199
- /** A request: what is asked for, a subject, a line or two — no screenshot, no logs. */
200
- private renderRequestForm(t: Strings, reason: DebugReason, types: RequestTypeOption[]): void {
201
- const panel = this.shell(t);
202
- panel.classList.add("narrow");
203
- panel.setAttribute("aria-label", t.requestTitle);
204
- const head = el("div", "head");
205
- const closeBtn = button("✕", "icon");
206
- closeBtn.setAttribute("aria-label", t.close);
207
- closeBtn.addEventListener("click", () => this.close());
208
- head.append(el("h2", "", t.requestTitle), closeBtn);
209
-
210
- let chosen: RequestTypeOption = types[0]!;
211
- const about = el("p", "status", "");
212
- const describe = () => {
213
- const parts = [chosen.description, chosen.targetHours ? t.requestTarget.replace("{time}", hoursWords(t, chosen.targetHours)) : ""].filter(Boolean);
214
- about.textContent = parts.join(" · ");
215
- };
216
- const typeRow: HTMLElement[] = [labelFor(t.requestTypeLabel, "", true)];
217
- if (types.length > 1) {
218
- const select = document.createElement("select");
219
- select.dataset.requestType = "";
220
- for (const option of types) {
221
- const o = document.createElement("option");
222
- o.value = option.id;
223
- o.textContent = option.name;
224
- select.append(o);
225
- }
226
- select.addEventListener("change", () => {
227
- chosen = types.find((x) => x.id === select.value) ?? chosen;
228
- describe();
229
- });
230
- typeRow.push(select);
231
- } else typeRow.push(el("p", "ask-type", chosen.name));
232
- describe();
233
-
234
- const summary = document.createElement("input");
235
- summary.type = "text";
236
- summary.placeholder = t.requestSummaryPlaceholder;
237
- summary.maxLength = 300;
238
- const detail = document.createElement("textarea");
239
- detail.placeholder = t.requestDetailPlaceholder;
240
-
241
- const status = el("p", "status", "");
242
- const submitBtn = button(t.requestSubmit, "primary");
243
- submitBtn.disabled = true;
244
- summary.addEventListener("input", () => {
245
- submitBtn.disabled = summary.value.trim().length === 0;
246
- });
247
- const back = button(t.back);
248
- back.addEventListener("click", () => this.renderChooser(t, reason, types));
249
- const foot = el("div", "foot");
250
- foot.append(back, status, el("span", "grow"), submitBtn);
251
- panel.append(head, ...typeRow, about, labelFor(t.requestSummaryLabel, "", true), summary, labelFor(t.requestDetailLabel), detail, foot);
252
-
253
- submitBtn.addEventListener("click", async () => {
254
- submitBtn.disabled = true;
255
- status.dataset.tone = "";
256
- status.textContent = t.submitting;
257
- try {
258
- const filed = await submitRequest({ ticketTypeId: chosen.id, title: summary.value.trim(), description: detail.value });
259
- const announced = this.dispatchEvent(new CustomEvent("lw-request-submitted", { detail: filed, bubbles: true, composed: true, cancelable: true }));
260
- this.close();
261
- if (announced) showToast(`${t.requestSubmitted} — #${filed.number}`, "success");
262
- } catch (error) {
263
- const message = failureMessage(error, t);
264
- status.dataset.tone = "error";
265
- status.textContent = message;
266
- showToast(message, "error");
267
- submitBtn.disabled = false;
268
- }
269
- });
270
- summary.focus();
189
+ this.renderForm(t, reason);
271
190
  }
272
191
 
273
- private close(): void {
192
+ /** Closed unsent, what was typed is kept for the next press; sent, it is gone. */
193
+ private close(sent = false): void {
194
+ if (sent) draft = null;
195
+ else if (this.keep) draft = this.keep() ?? draft;
196
+ this.keep = null;
274
197
  this.annotator?.destroy();
275
198
  this.annotator = null;
276
199
  this.bundle = null;
277
200
  this.busy = false;
278
201
  this.root.replaceChildren();
202
+ const back = this.returnFocus;
203
+ this.returnFocus = null;
204
+ if (back?.isConnected) back.focus();
279
205
  }
280
206
 
281
207
  private shell(t: Strings): HTMLElement {
@@ -302,88 +228,220 @@ function createReporterClass(): CustomElementConstructor {
302
228
  panel.append(head, el("p", "status", message));
303
229
  }
304
230
 
305
- private renderForm(t: Strings): void {
231
+ /**
232
+ * The one dialog: a report — or, where the desk offers request types to
233
+ * its reporters, a request, the same dialog turned (docs/WIDGET-ASK-PLAN.md;
234
+ * Zendesk's and Freshdesk's widgets keep the kind inside the one form).
235
+ * What was typed, the marked picture and the page cross over; a request
236
+ * leaves the page's logs behind — they are a problem's evidence, not an
237
+ * ask's — and its picture can be taken off.
238
+ */
239
+ private renderForm(t: Strings, reason: DebugReason): void {
306
240
  const bundle = this.bundle;
307
241
  if (!bundle) return;
308
242
  const panel = this.shell(t);
243
+ let mode: "problem" | "request" = "problem";
244
+ let chosen: RequestTypeOption | null = null;
245
+ let select: HTMLSelectElement | null = null;
309
246
 
310
247
  const head = el("div", "head");
248
+ const heading = el("h2", "", t.title);
311
249
  const closeBtn = button("✕", "icon");
312
250
  closeBtn.setAttribute("aria-label", t.close);
313
251
  closeBtn.addEventListener("click", () => this.close());
314
- head.append(el("h2", "", t.title), closeBtn);
252
+ head.append(heading, closeBtn);
253
+
254
+ // The other door: a quiet line under the heading, only where the desk offers something to ask.
255
+ const door = el("p", "door");
256
+ door.hidden = true;
257
+ const doorText = el("span");
258
+ const doorBtn = button("", "link");
259
+ doorBtn.dataset.switchKind = "";
260
+ door.append(doorText, doorBtn);
261
+
262
+ // What is asked for — the request's own rows.
263
+ const ask = el("div", "ask");
264
+ ask.hidden = true;
265
+ const typeHolder = el("div");
266
+ const about = el("p", "status", "");
267
+ ask.append(labelFor(t.requestTypeLabel, "", true), typeHolder, about);
268
+ const describe = () => {
269
+ if (!chosen) return;
270
+ about.textContent = [chosen.description, chosen.targetHours ? t.requestTarget.replace("{time}", hoursWords(t, chosen.targetHours)) : ""].filter(Boolean).join(" · ");
271
+ };
315
272
 
316
273
  const summary = document.createElement("input");
317
274
  summary.type = "text";
318
- summary.placeholder = t.summaryPlaceholder;
319
- const summaryLabel = labelFor(t.summaryLabel, t.summaryRequired, true);
320
-
275
+ const summaryLabel = labelFor("");
321
276
  const detail = document.createElement("textarea");
322
- detail.placeholder = t.detailPlaceholder;
277
+ const detailLabel = labelFor("");
323
278
 
324
279
  const meta = el("div", "meta");
325
- meta.innerHTML = "";
326
280
  const { context } = bundle;
327
- for (const [key, value] of [
328
- [t.reporter, `${context.user.fullName ?? "—"} (${context.user.email ?? "—"})`],
329
- [t.page, context.route.href],
330
- // No version row. It read "vundefined · undefined" once version and
331
- // environment became optional, and it was telling the person filing the
332
- // report something only the person reading it needs — both still travel
333
- // with the bundle.
334
- //
335
- // The replay counts as one more thing captured, listed with the rest.
336
- // It is the support team's debug log, taken the way the network and
337
- // console logs are — not a choice put to the reporter. There was a
338
- // tick box here once; the platform owner's call was that a report
339
- // should always carry what makes it reproducible.
340
- [
341
- t.captured,
342
- `${bundle.network.length} network · ${bundle.console.length} console · ${bundle.actionTrail.length} actions${
343
- bundle.replay ? ` · ${t.replay} ${Math.round(bundle.replay.durationMs / 1000)}s` : ""
344
- }`,
345
- ],
346
- ] as [string, string][]) {
281
+ const captured = `${bundle.network.length} network · ${bundle.console.length} console · ${bundle.actionTrail.length} actions${
282
+ bundle.replay ? ` · ${t.replay} ${Math.round(bundle.replay.durationMs / 1000)}s` : ""
283
+ }`;
284
+ // textContent, not innerHTML: every one of these values is attacker-
285
+ // influenced (a URL, a display name, a captured label).
286
+ const metaRow = (key: string, value: string) => {
347
287
  const row = document.createElement("div");
348
- // textContent, not innerHTML: every one of these values is attacker-
349
- // influenced (a URL, a display name, a captured label).
350
288
  const b = document.createElement("b");
351
289
  b.textContent = `${key}: `;
352
- row.append(b, document.createTextNode(value));
290
+ const v = document.createTextNode(value);
291
+ row.append(b, v);
353
292
  meta.append(row);
354
- }
293
+ return { b, v };
294
+ };
295
+ metaRow(t.reporter, `${context.user.fullName ?? "—"} (${context.user.email ?? "—"})`);
296
+ metaRow(t.page, context.route.href);
297
+ // No version row: it is for the person reading, not the one filing — it
298
+ // still travels with the bundle. The replay is listed with the rest of
299
+ // what was captured: the support team's debug log, not a choice put to
300
+ // the reporter.
301
+ const logs = metaRow(t.captured, captured);
302
+
303
+ // A request's picture is its own to keep or take off; a report always carries what it captured.
304
+ const shotChoice = document.createElement("label");
305
+ shotChoice.className = "shot-choice";
306
+ shotChoice.hidden = true;
307
+ const withShot = document.createElement("input");
308
+ withShot.type = "checkbox";
309
+ withShot.checked = true;
310
+ withShot.dataset.requestShot = "";
311
+ shotChoice.append(withShot, document.createTextNode(` ${t.requestWithShot}`));
312
+ const shot = bundle.screenshotDataUrl ? this.screenshotSection(t, bundle.screenshotDataUrl) : [el("p", "status", t.noScreenshot)];
313
+ // What to mark on the picture: the problem, or what is asked for.
314
+ const markWhat = bundle.screenshotDataUrl ? shot[0]! : null;
315
+ if (!bundle.screenshotDataUrl) shotChoice.replaceChildren();
355
316
 
356
317
  const status = el("p", "status", "");
357
318
  const submitBtn = button(t.submit, "primary");
358
- submitBtn.disabled = true;
359
- summary.addEventListener("input", () => {
360
- submitBtn.disabled = summary.value.trim().length === 0;
319
+ const ready = () => {
320
+ submitBtn.disabled = summary.value.trim().length === 0 || (mode === "request" && !chosen);
321
+ };
322
+ summary.addEventListener("input", ready);
323
+
324
+ const apply = () => {
325
+ const asking = mode === "request";
326
+ heading.textContent = asking ? t.requestTitle : t.title;
327
+ panel.setAttribute("aria-label", asking ? t.requestTitle : t.title);
328
+ doorText.textContent = `${asking ? t.problemInstead : t.askInstead} `;
329
+ doorBtn.textContent = asking ? t.problemInsteadAction : t.askInsteadAction;
330
+ ask.hidden = !asking;
331
+ fillLabel(summaryLabel, asking ? t.requestSummaryLabel : t.summaryLabel, asking ? "" : t.summaryRequired, true);
332
+ summary.placeholder = asking ? t.requestSummaryPlaceholder : t.summaryPlaceholder;
333
+ fillLabel(detailLabel, asking ? t.requestDetailLabel : t.detailLabel);
334
+ detail.placeholder = asking ? t.requestDetailPlaceholder : t.detailPlaceholder;
335
+ logs.b.textContent = `${asking ? t.requestLeaves : t.captured}: `;
336
+ logs.v.textContent = asking ? t.requestLeavesWhat : captured;
337
+ shotChoice.hidden = !asking || !bundle.screenshotDataUrl;
338
+ if (markWhat) fillLabel(markWhat, asking ? t.requestAnnotateHint : t.annotateHint);
339
+ for (const node of shot) node.classList.toggle("dropped", asking && !withShot.checked);
340
+ submitBtn.textContent = asking ? t.requestSubmit : t.submit;
341
+ ready();
342
+ };
343
+ withShot.addEventListener("change", apply);
344
+ doorBtn.addEventListener("click", () => {
345
+ mode = mode === "problem" ? "request" : "problem";
346
+ apply();
347
+ (mode === "request" ? (select ?? summary) : summary).focus();
361
348
  });
362
349
 
363
- panel.append(head, summaryLabel, summary, labelFor(t.detailLabel), detail, meta);
364
- if (bundle.screenshotDataUrl) panel.append(...this.screenshotSection(t, bundle.screenshotDataUrl));
365
- else panel.append(el("p", "status", t.noScreenshot));
350
+ /*
351
+ * What the desk offers, as soon as it is known: now, or when the read
352
+ * lands with the form already open. Only on a press by hand — a crash's
353
+ * invitation is a problem by definition.
354
+ */
355
+ const offer = (types: RequestTypeOption[]) => {
356
+ if (this.bundle !== bundle || reason.type !== "manual" || !types.length) return;
357
+ chosen = types[0]!;
358
+ if (types.length > 1) {
359
+ select = document.createElement("select");
360
+ select.dataset.requestType = "";
361
+ for (const option of types) {
362
+ const o = document.createElement("option");
363
+ o.value = option.id;
364
+ o.textContent = option.name;
365
+ select.append(o);
366
+ }
367
+ select.addEventListener("change", () => {
368
+ chosen = types.find((x) => x.id === select!.value) ?? chosen;
369
+ describe();
370
+ });
371
+ typeHolder.replaceChildren(select);
372
+ } else typeHolder.replaceChildren(el("p", "ask-type", chosen.name));
373
+ describe();
374
+ door.hidden = false;
375
+ };
376
+ /*
377
+ * What was typed last time and not sent comes back, said so — with a
378
+ * way to clear it. A request goes back to being one once its type is
379
+ * offered again.
380
+ */
381
+ const restored = draft && (draft.title || draft.detail) ? draft : null;
382
+ const again = el("p", "draft");
383
+ again.hidden = !restored;
384
+ if (restored) {
385
+ summary.value = restored.title;
386
+ detail.value = restored.detail;
387
+ const clear = button(t.draftClear, "link");
388
+ clear.dataset.draftClear = "";
389
+ clear.addEventListener("click", () => {
390
+ summary.value = "";
391
+ detail.value = "";
392
+ draft = null;
393
+ again.hidden = true;
394
+ ready();
395
+ summary.focus();
396
+ });
397
+ again.append(document.createTextNode(`${t.draftRestored} · `), clear);
398
+ }
399
+ const known = requestTypesNow();
400
+ const offerAndRestore = (types: RequestTypeOption[]) => {
401
+ offer(types);
402
+ if (!restored?.asking || door.hidden) return;
403
+ const was = types.find((x) => x.id === restored.typeId);
404
+ if (was) {
405
+ chosen = was;
406
+ if (select) select.value = was.id;
407
+ describe();
408
+ }
409
+ mode = "request";
410
+ apply();
411
+ };
412
+ if (known) offerAndRestore(known);
413
+ else if (reason.type === "manual") void loadRequestTypes().then(offerAndRestore);
414
+ this.keep = () => ({ title: summary.value, detail: detail.value, asking: mode === "request", typeId: chosen?.id ?? null });
366
415
 
416
+ panel.append(head, door, again, ask, summaryLabel, summary, detailLabel, detail, meta, shotChoice, ...shot);
367
417
  const foot = el("div", "foot");
368
418
  const cancel = button(t.close);
369
419
  cancel.addEventListener("click", () => this.close());
370
420
  foot.append(cancel, status, el("span", "grow"), submitBtn);
371
421
  panel.append(foot);
422
+ apply();
372
423
 
373
424
  submitBtn.addEventListener("click", async () => {
374
425
  submitBtn.disabled = true;
375
426
  status.dataset.tone = "";
376
427
  status.textContent = t.submitting;
428
+ // Take the annotated image, not the raw capture — the drawing IS the
429
+ // report for most people.
430
+ const picture = () => (this.annotator?.hasShapes() ? this.annotator.toDataUrl() : bundle.screenshotDataUrl);
377
431
  try {
378
- const filed = await submitBundle(summary.value.trim(), {
379
- ...bundle,
380
- note: detail.value,
381
- // Take the annotated image, not the raw capture — the drawing IS the
382
- // report for most people.
383
- screenshotDataUrl: this.annotator?.hasShapes()
384
- ? this.annotator.toDataUrl()
385
- : bundle.screenshotDataUrl,
386
- });
432
+ if (mode === "request" && chosen) {
433
+ const filed = await submitRequest({
434
+ ticketTypeId: chosen.id,
435
+ title: summary.value.trim(),
436
+ description: detail.value,
437
+ screenshotDataUrl: withShot.checked ? picture() : null,
438
+ });
439
+ const announced = this.dispatchEvent(new CustomEvent("lw-request-submitted", { detail: filed, bubbles: true, composed: true, cancelable: true }));
440
+ this.close(true);
441
+ if (announced) showToast(`${t.requestSubmitted} — #${filed.number}`, "success");
442
+ return;
443
+ }
444
+ const filed = await submitBundle(summary.value.trim(), { ...bundle, note: detail.value, screenshotDataUrl: picture() });
387
445
  // Cancelable: a host with its own toast system calls preventDefault()
388
446
  // and shows its own. Ours is the default, not the only option.
389
447
  const announced = this.dispatchEvent(
@@ -397,7 +455,7 @@ function createReporterClass(): CustomElementConstructor {
397
455
  // Close first, then announce. The dialog is what the user was looking
398
456
  // at, and a confirmation written into a panel that then closes says
399
457
  // nothing — which is why filing a ticket appeared to do nothing.
400
- this.close();
458
+ this.close(true);
401
459
  if (announced) showToast(`${t.submitted} — #${filed.number}`, "success");
402
460
  } catch (error) {
403
461
  const message = failureMessage(error, t);
@@ -408,7 +466,7 @@ function createReporterClass(): CustomElementConstructor {
408
466
  showToast(message, "error");
409
467
  // The dialog stays open and nothing is cleared: what they wrote is
410
468
  // still in the box, and pressing again is the right thing to do.
411
- submitBtn.disabled = false;
469
+ ready();
412
470
  }
413
471
  });
414
472
 
@@ -488,17 +546,6 @@ function hoursWords(t: Strings, h: number): string {
488
546
  return h >= 24 && h % 24 === 0 ? t.days.replace("{n}", String(h / 24)) : t.hours.replace("{n}", String(h));
489
547
  }
490
548
 
491
- /** A card of a choice: its name, and a line under it. */
492
- function choice(title: string, hint: string): HTMLButtonElement {
493
- const node = button("", "choice");
494
- const b = document.createElement("b");
495
- b.textContent = title;
496
- const span = document.createElement("span");
497
- span.textContent = hint;
498
- node.append(b, span);
499
- return node;
500
- }
501
-
502
549
  function button(text: string, className = ""): HTMLButtonElement {
503
550
  const node = document.createElement("button");
504
551
  node.type = "button";
@@ -508,8 +555,12 @@ function button(text: string, className = ""): HTMLButtonElement {
508
555
  }
509
556
 
510
557
  function labelFor(text: string, hint = "", required = false): HTMLElement {
511
- const label = document.createElement("label");
512
- label.append(document.createTextNode(text));
558
+ return fillLabel(document.createElement("label"), text, hint, required);
559
+ }
560
+
561
+ /** A label's words, written again when the dialog turns from a report to a request. */
562
+ function fillLabel(label: HTMLElement, text: string, hint = "", required = false): HTMLElement {
563
+ label.replaceChildren(document.createTextNode(text));
513
564
  if (required) {
514
565
  const star = document.createElement("span");
515
566
  star.className = "req";
package/src/ui/strings.ts CHANGED
@@ -65,14 +65,14 @@ export interface Strings {
65
65
  handoffBlocked: string;
66
66
  handoffOpen: string;
67
67
  /*
68
- * ขอให้ทีมดำเนินการ — offered beside แจ้งปัญหา when the desk opens request
69
- * types to its reporters (requests.ts). A request asks for no screenshot.
68
+ * ขอให้ทีมดำเนินการ — the same dialog turned into a request, where the desk
69
+ * opens request types to its reporters (docs/WIDGET-ASK-PLAN.md): the door
70
+ * is a line in the report form, and back.
70
71
  */
71
- chooseTitle: string;
72
- chooseProblem: string;
73
- chooseProblemHint: string;
74
- chooseRequest: string;
75
- chooseRequestHint: string;
72
+ askInstead: string;
73
+ askInsteadAction: string;
74
+ problemInstead: string;
75
+ problemInsteadAction: string;
76
76
  requestTitle: string;
77
77
  requestTypeLabel: string;
78
78
  /** Carries `{time}`. */
@@ -83,7 +83,15 @@ export interface Strings {
83
83
  requestDetailPlaceholder: string;
84
84
  requestSubmit: string;
85
85
  requestSubmitted: string;
86
- back: string;
86
+ /** The picture, a request's to keep or take off. */
87
+ requestWithShot: string;
88
+ requestAnnotateHint: string;
89
+ /** What was typed last time and not sent, back in the form — and the way to clear it. */
90
+ draftRestored: string;
91
+ draftClear: string;
92
+ /** In the captured row, turned: what a request does NOT send. */
93
+ requestLeaves: string;
94
+ requestLeavesWhat: string;
87
95
  /** Carry `{n}`. */
88
96
  days: string;
89
97
  hours: string;
@@ -132,11 +140,10 @@ const th: Strings = {
132
140
  handoffFailed: "เปิดรายการที่แจ้งไว้ไม่สำเร็จ",
133
141
  handoffBlocked: "เบราว์เซอร์บล็อกการเปิดแท็บใหม่",
134
142
  handoffOpen: "เปิดในแท็บใหม่",
135
- chooseTitle: "ต้องการให้ทีมช่วยเรื่องอะไร",
136
- chooseProblem: "แจ้งปัญหา",
137
- chooseProblemHint: "มีอะไรเสียหรือใช้งานไม่ได้ — แนบภาพหน้าจอ",
138
- chooseRequest: "ขอให้ทีมดำเนินการ",
139
- chooseRequestHint: "ขอสิทธิ์ ขอแก้ข้อมูล หรือขอให้ทีมทำบางอย่าง",
143
+ askInstead: "เป็นคำขอ?",
144
+ askInsteadAction: "เลือกเรื่องที่ขอ",
145
+ problemInstead: "เป็นปัญหาที่ต้องแก้?",
146
+ problemInsteadAction: "แจ้งปัญหาแทน",
140
147
  requestTitle: "ขอให้ทีมดำเนินการ",
141
148
  requestTypeLabel: "เรื่องที่ขอ",
142
149
  requestTarget: "ทีมตั้งใจให้เสร็จภายใน {time}",
@@ -146,7 +153,12 @@ const th: Strings = {
146
153
  requestDetailPlaceholder: "สิ่งที่ทีมต้องรู้เพื่อดำเนินการ",
147
154
  requestSubmit: "ส่งคำขอ",
148
155
  requestSubmitted: "ส่งคำขอแล้ว",
149
- back: "ย้อนกลับ",
156
+ requestWithShot: "แนบภาพหน้าจอนี้ไปกับคำขอ",
157
+ requestAnnotateHint: "ทำเครื่องหมายสิ่งที่ขอบนภาพ — วงกรอบ ชี้ลูกศร ไฮไลต์ หรือพิมพ์ข้อความ",
158
+ draftRestored: "ข้อความที่พิมพ์ไว้ครั้งก่อน ยังไม่ได้ส่ง",
159
+ draftClear: "ล้าง",
160
+ requestLeaves: "ไม่ส่งไปกับคำขอ",
161
+ requestLeavesWhat: "บันทึกการใช้งานของหน้านี้ — ใช้กับการแจ้งปัญหาเท่านั้น",
150
162
  days: "{n} วัน",
151
163
  hours: "{n} ชั่วโมง",
152
164
  };
@@ -194,11 +206,10 @@ const en: Strings = {
194
206
  handoffFailed: "Could not open your reports",
195
207
  handoffBlocked: "Your browser blocked the new tab",
196
208
  handoffOpen: "Open in a new tab",
197
- chooseTitle: "How can the team help?",
198
- chooseProblem: "Report a problem",
199
- chooseProblemHint: "Something is broken or not working — with a screenshot",
200
- chooseRequest: "Ask the team",
201
- chooseRequestHint: "Access, a data change, or something done for you",
209
+ askInstead: "Asking for something?",
210
+ askInsteadAction: "Choose what you need",
211
+ problemInstead: "Something broken?",
212
+ problemInsteadAction: "Report a problem instead",
202
213
  requestTitle: "Ask the team",
203
214
  requestTypeLabel: "What you need",
204
215
  requestTarget: "The team aims to finish within {time}",
@@ -208,7 +219,12 @@ const en: Strings = {
208
219
  requestDetailPlaceholder: "What the team needs to know to do it",
209
220
  requestSubmit: "Send request",
210
221
  requestSubmitted: "Request sent",
211
- back: "Back",
222
+ requestWithShot: "Attach this screenshot to the request",
223
+ requestAnnotateHint: "Mark what you are asking about — box it, point at it, highlight it, or type on it",
224
+ draftRestored: "What you typed last time, not sent yet",
225
+ draftClear: "Clear",
226
+ requestLeaves: "Not sent with a request",
227
+ requestLeavesWhat: "this page's activity log — it is for problem reports only",
212
228
  days: "{n} days",
213
229
  hours: "{n} hours",
214
230
  };
package/src/ui/styles.ts CHANGED
@@ -94,16 +94,23 @@ canvas { width: 100%; height: auto; display: block; border: 1px solid #e2e8f0; b
94
94
  }
95
95
  .foot .grow { flex: 1; }
96
96
 
97
- /* ขอให้ทีมดำเนินการ: the chooser and the request form — a smaller dialog, no image in it. */
98
- .panel.narrow { width: min(560px, 100%); }
99
- .choices { display: grid; gap: 10px; padding-bottom: 24px; }
100
- button.choice {
101
- display: flex; flex-direction: column; align-items: flex-start; gap: 2px;
102
- width: 100%; padding: 14px 16px; text-align: left; border-radius: 12px;
97
+ /* ขอให้ทีมดำเนินการ — the same dialog turned into a request (docs/WIDGET-ASK-PLAN.md).
98
+ The door is a quiet line under the heading: read by the one who asks,
99
+ costing nothing to the many who report. */
100
+ [hidden] { display: none !important; }
101
+ .door { margin: -8px 0 0; font-size: 13px; color: #64748b; }
102
+ button.link {
103
+ display: inline; padding: 0; border: 0; border-radius: 4px; background: none;
104
+ font-size: 13px; font-weight: 600; color: #2563eb;
105
+ text-decoration: underline; text-underline-offset: 3px;
103
106
  }
104
- button.choice b { font-size: 15px; font-weight: 600; }
105
- button.choice span { font-size: 13px; font-weight: 400; color: #64748b; }
106
- button.choice:focus-visible { outline: none; border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.18); }
107
- .panel.narrow p { margin: 0; }
108
- .ask-type { font-size: 14px; font-weight: 600; margin-top: -8px; }
107
+ button.link:hover:not(:disabled) { background: none; color: #1d4ed8; }
108
+ button.link:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
109
+ .ask p { margin: 0; }
110
+ .draft { margin: -8px 0 0; padding: 8px 12px; border-radius: 10px; background: #eff6ff; font-size: 13px; color: #1e3a8a; }
111
+ .ask .status { margin-top: 6px; }
112
+ .ask-type { font-size: 14px; font-weight: 600; }
113
+ .shot-choice { display: flex; align-items: center; gap: 8px; margin: 0; font-weight: 500; }
114
+ .shot-choice input { width: 16px; height: 16px; margin: 0; accent-color: #2563eb; }
115
+ .dropped { opacity: 0.35; pointer-events: none; }
109
116
  `;