@lightworkai.official/debug-capture 0.13.0 → 0.14.1

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);
189
+ this.renderForm(t, reason);
169
190
  }
170
191
 
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();
271
- }
272
-
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,266 @@ 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;
309
245
 
310
246
  const head = el("div", "head");
247
+ const heading = el("h2", "", t.title);
311
248
  const closeBtn = button("✕", "icon");
312
249
  closeBtn.setAttribute("aria-label", t.close);
313
250
  closeBtn.addEventListener("click", () => this.close());
314
- head.append(el("h2", "", t.title), closeBtn);
251
+ head.append(heading, closeBtn);
252
+
253
+ /*
254
+ * แจ้งปัญหา or คำขอ — two cards under the heading, the way the desk's own
255
+ * สร้างเวิร์กโฟลว์ asks it, shown only where the desk offers something to
256
+ * ask. แจ้งปัญหา is chosen as the form opens (the press is a report for
257
+ * most, so no click is added); either card switches the form, any time,
258
+ * and nothing typed is lost by it.
259
+ */
260
+ const kinds = el("div", "kinds");
261
+ kinds.hidden = true;
262
+ kinds.setAttribute("role", "radiogroup");
263
+ kinds.setAttribute("aria-label", t.chooseTitle);
264
+ const kindCard = (kind: "problem" | "request", icon: IconNode, title: string, hint: string) => {
265
+ const card = button("", "kind");
266
+ card.setAttribute("role", "radio");
267
+ card.dataset.kind = kind;
268
+ const top = el("span", "kind-top");
269
+ top.append(svgIcon(icon), el("b", "", title));
270
+ card.append(top, el("span", "kind-hint", hint), svgIcon(CHECK, "tick"));
271
+ card.addEventListener("click", () => setMode(kind));
272
+ return card;
273
+ };
274
+ const problemCard = kindCard("problem", BUG, t.kindProblem, t.kindProblemHint);
275
+ const requestCard = kindCard("request", WORKFLOW, t.kindRequest, t.kindRequestHint);
276
+ kinds.append(problemCard, requestCard);
277
+ // A radio group's own keys: an arrow moves the choice, and the focus with it.
278
+ kinds.addEventListener("keydown", (e) => {
279
+ if (!ARROWS.includes(e.key)) return;
280
+ e.preventDefault();
281
+ setMode(mode === "problem" ? "request" : "problem");
282
+ (mode === "problem" ? problemCard : requestCard).focus();
283
+ });
284
+
285
+ // What is asked for: one card each — the type's own words and how long the team means to take, as the portal shows them.
286
+ const ask = el("div", "ask");
287
+ ask.hidden = true;
288
+ const typeList = el("div", "types");
289
+ typeList.setAttribute("role", "radiogroup");
290
+ typeList.setAttribute("aria-label", t.requestTypeLabel);
291
+ ask.append(labelFor(t.requestTypeLabel, "", true), typeList);
292
+ const typeCards: HTMLButtonElement[] = [];
293
+ const markType = () => {
294
+ for (const card of typeCards) {
295
+ const on = card.dataset.requestType === chosen?.id;
296
+ card.setAttribute("aria-checked", String(on));
297
+ card.tabIndex = on ? 0 : -1;
298
+ }
299
+ };
315
300
 
316
301
  const summary = document.createElement("input");
317
302
  summary.type = "text";
318
- summary.placeholder = t.summaryPlaceholder;
319
- const summaryLabel = labelFor(t.summaryLabel, t.summaryRequired, true);
320
-
303
+ const summaryLabel = labelFor("");
321
304
  const detail = document.createElement("textarea");
322
- detail.placeholder = t.detailPlaceholder;
305
+ const detailLabel = labelFor("");
323
306
 
324
307
  const meta = el("div", "meta");
325
- meta.innerHTML = "";
326
308
  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][]) {
309
+ const captured = `${bundle.network.length} network · ${bundle.console.length} console · ${bundle.actionTrail.length} actions${
310
+ bundle.replay ? ` · ${t.replay} ${Math.round(bundle.replay.durationMs / 1000)}s` : ""
311
+ }`;
312
+ // textContent, not innerHTML: every one of these values is attacker-
313
+ // influenced (a URL, a display name, a captured label).
314
+ const metaRow = (key: string, value: string) => {
347
315
  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
316
  const b = document.createElement("b");
351
317
  b.textContent = `${key}: `;
352
- row.append(b, document.createTextNode(value));
318
+ const v = document.createTextNode(value);
319
+ row.append(b, v);
353
320
  meta.append(row);
354
- }
321
+ return { b, v };
322
+ };
323
+ metaRow(t.reporter, `${context.user.fullName ?? "—"} (${context.user.email ?? "—"})`);
324
+ metaRow(t.page, context.route.href);
325
+ // No version row: it is for the person reading, not the one filing — it
326
+ // still travels with the bundle. The replay is listed with the rest of
327
+ // what was captured: the support team's debug log, not a choice put to
328
+ // the reporter.
329
+ const logs = metaRow(t.captured, captured);
330
+
331
+ // A request's picture is its own to keep or take off; a report always carries what it captured.
332
+ const shotChoice = document.createElement("label");
333
+ shotChoice.className = "shot-choice";
334
+ shotChoice.hidden = true;
335
+ const withShot = document.createElement("input");
336
+ withShot.type = "checkbox";
337
+ withShot.checked = true;
338
+ withShot.dataset.requestShot = "";
339
+ shotChoice.append(withShot, document.createTextNode(` ${t.requestWithShot}`));
340
+ const shot = bundle.screenshotDataUrl ? this.screenshotSection(t, bundle.screenshotDataUrl) : [el("p", "status", t.noScreenshot)];
341
+ // What to mark on the picture: the problem, or what is asked for.
342
+ const markWhat = bundle.screenshotDataUrl ? shot[0]! : null;
343
+ if (!bundle.screenshotDataUrl) shotChoice.replaceChildren();
355
344
 
356
345
  const status = el("p", "status", "");
357
346
  const submitBtn = button(t.submit, "primary");
358
- submitBtn.disabled = true;
359
- summary.addEventListener("input", () => {
360
- submitBtn.disabled = summary.value.trim().length === 0;
361
- });
347
+ const ready = () => {
348
+ submitBtn.disabled = summary.value.trim().length === 0 || (mode === "request" && !chosen);
349
+ };
350
+ summary.addEventListener("input", ready);
351
+
352
+ const apply = () => {
353
+ const asking = mode === "request";
354
+ heading.textContent = kinds.hidden ? t.title : t.chooseTitle;
355
+ panel.setAttribute("aria-label", asking ? t.requestTitle : t.title);
356
+ for (const [card, on] of [[problemCard, !asking], [requestCard, asking]] as const) {
357
+ card.setAttribute("aria-checked", String(on));
358
+ card.tabIndex = on ? 0 : -1;
359
+ }
360
+ ask.hidden = !asking;
361
+ fillLabel(summaryLabel, asking ? t.requestSummaryLabel : t.summaryLabel, asking ? "" : t.summaryRequired, true);
362
+ summary.placeholder = asking ? t.requestSummaryPlaceholder : t.summaryPlaceholder;
363
+ fillLabel(detailLabel, asking ? t.requestDetailLabel : t.detailLabel);
364
+ detail.placeholder = asking ? t.requestDetailPlaceholder : t.detailPlaceholder;
365
+ logs.b.textContent = `${asking ? t.requestLeaves : t.captured}: `;
366
+ logs.v.textContent = asking ? t.requestLeavesWhat : captured;
367
+ shotChoice.hidden = !asking || !bundle.screenshotDataUrl;
368
+ if (markWhat) fillLabel(markWhat, asking ? t.requestAnnotateHint : t.annotateHint);
369
+ for (const node of shot) node.classList.toggle("dropped", asking && !withShot.checked);
370
+ submitBtn.textContent = asking ? t.requestSubmit : t.submit;
371
+ ready();
372
+ };
373
+ withShot.addEventListener("change", apply);
374
+ function setMode(next: "problem" | "request"): void {
375
+ if (next === mode) return;
376
+ mode = next;
377
+ apply();
378
+ }
362
379
 
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));
380
+ /*
381
+ * What the desk offers, as soon as it is known: now, or when the read
382
+ * lands with the form already open. Only on a press by hand — a crash's
383
+ * invitation is a problem by definition.
384
+ */
385
+ const offer = (types: RequestTypeOption[]) => {
386
+ if (this.bundle !== bundle || reason.type !== "manual" || !types.length) return;
387
+ chosen = types[0]!;
388
+ typeCards.length = 0;
389
+ for (const option of types) {
390
+ const card = button("", "type");
391
+ card.setAttribute("role", "radio");
392
+ card.dataset.requestType = option.id;
393
+ const body = el("span", "type-body");
394
+ body.append(el("b", "", option.name));
395
+ if (option.description) body.append(el("span", "type-hint", option.description));
396
+ if (option.targetHours) {
397
+ const due = el("span", "type-due");
398
+ due.append(svgIcon(CLOCK), document.createTextNode(t.requestTarget.replace("{time}", hoursWords(t, option.targetHours))));
399
+ body.append(due);
400
+ }
401
+ card.append(svgIcon(WORKFLOW), body);
402
+ card.addEventListener("click", () => {
403
+ chosen = option;
404
+ markType();
405
+ ready();
406
+ });
407
+ typeCards.push(card);
408
+ }
409
+ typeList.replaceChildren(...typeCards);
410
+ typeList.onkeydown = (e) => {
411
+ if (!ARROWS.includes(e.key) || !chosen) return;
412
+ e.preventDefault();
413
+ const at = types.findIndex((x) => x.id === chosen!.id);
414
+ const step = e.key === "ArrowUp" || e.key === "ArrowLeft" ? -1 : 1;
415
+ chosen = types[(at + step + types.length) % types.length]!;
416
+ markType();
417
+ typeCards.find((c) => c.dataset.requestType === chosen!.id)?.focus();
418
+ };
419
+ markType();
420
+ kinds.hidden = false;
421
+ apply();
422
+ };
423
+ /*
424
+ * What was typed last time and not sent comes back, said so — with a
425
+ * way to clear it. A request goes back to being one once its type is
426
+ * offered again.
427
+ */
428
+ const restored = draft && (draft.title || draft.detail) ? draft : null;
429
+ const again = el("p", "draft");
430
+ again.hidden = !restored;
431
+ if (restored) {
432
+ summary.value = restored.title;
433
+ detail.value = restored.detail;
434
+ const clear = button(t.draftClear, "link");
435
+ clear.dataset.draftClear = "";
436
+ clear.addEventListener("click", () => {
437
+ summary.value = "";
438
+ detail.value = "";
439
+ draft = null;
440
+ again.hidden = true;
441
+ ready();
442
+ summary.focus();
443
+ });
444
+ again.append(document.createTextNode(`${t.draftRestored} · `), clear);
445
+ }
446
+ const known = requestTypesNow();
447
+ const offerAndRestore = (types: RequestTypeOption[]) => {
448
+ offer(types);
449
+ if (!restored?.asking || kinds.hidden) return;
450
+ const was = types.find((x) => x.id === restored.typeId);
451
+ if (was) {
452
+ chosen = was;
453
+ markType();
454
+ }
455
+ mode = "request";
456
+ apply();
457
+ };
458
+ if (known) offerAndRestore(known);
459
+ else if (reason.type === "manual") void loadRequestTypes().then(offerAndRestore);
460
+ this.keep = () => ({ title: summary.value, detail: detail.value, asking: mode === "request", typeId: chosen?.id ?? null });
366
461
 
462
+ panel.append(head, kinds, again, ask, summaryLabel, summary, detailLabel, detail, meta, shotChoice, ...shot);
367
463
  const foot = el("div", "foot");
368
464
  const cancel = button(t.close);
369
465
  cancel.addEventListener("click", () => this.close());
370
466
  foot.append(cancel, status, el("span", "grow"), submitBtn);
371
467
  panel.append(foot);
468
+ apply();
372
469
 
373
470
  submitBtn.addEventListener("click", async () => {
374
471
  submitBtn.disabled = true;
375
472
  status.dataset.tone = "";
376
473
  status.textContent = t.submitting;
474
+ // Take the annotated image, not the raw capture — the drawing IS the
475
+ // report for most people.
476
+ const picture = () => (this.annotator?.hasShapes() ? this.annotator.toDataUrl() : bundle.screenshotDataUrl);
377
477
  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
- });
478
+ if (mode === "request" && chosen) {
479
+ const filed = await submitRequest({
480
+ ticketTypeId: chosen.id,
481
+ title: summary.value.trim(),
482
+ description: detail.value,
483
+ screenshotDataUrl: withShot.checked ? picture() : null,
484
+ });
485
+ const announced = this.dispatchEvent(new CustomEvent("lw-request-submitted", { detail: filed, bubbles: true, composed: true, cancelable: true }));
486
+ this.close(true);
487
+ if (announced) showToast(`${t.requestSubmitted} — #${filed.number}`, "success");
488
+ return;
489
+ }
490
+ const filed = await submitBundle(summary.value.trim(), { ...bundle, note: detail.value, screenshotDataUrl: picture() });
387
491
  // Cancelable: a host with its own toast system calls preventDefault()
388
492
  // and shows its own. Ours is the default, not the only option.
389
493
  const announced = this.dispatchEvent(
@@ -397,7 +501,7 @@ function createReporterClass(): CustomElementConstructor {
397
501
  // Close first, then announce. The dialog is what the user was looking
398
502
  // at, and a confirmation written into a panel that then closes says
399
503
  // nothing — which is why filing a ticket appeared to do nothing.
400
- this.close();
504
+ this.close(true);
401
505
  if (announced) showToast(`${t.submitted} — #${filed.number}`, "success");
402
506
  } catch (error) {
403
507
  const message = failureMessage(error, t);
@@ -408,7 +512,7 @@ function createReporterClass(): CustomElementConstructor {
408
512
  showToast(message, "error");
409
513
  // The dialog stays open and nothing is cleared: what they wrote is
410
514
  // still in the box, and pressing again is the right thing to do.
411
- submitBtn.disabled = false;
515
+ ready();
412
516
  }
413
517
  });
414
518
 
@@ -488,15 +592,46 @@ function hoursWords(t: Strings, h: number): string {
488
592
  return h >= 24 && h % 24 === 0 ? t.days.replace("{n}", String(h / 24)) : t.hours.replace("{n}", String(h));
489
593
  }
490
594
 
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;
595
+ const ARROWS = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"];
596
+
597
+ /*
598
+ * The desk's own icons (lucide, as its สร้างเวิร์กโฟลว์ and the portal draw
599
+ * them), carried as their few nodes: the widget takes no icon library.
600
+ */
601
+ type IconNode = [string, Record<string, string>][];
602
+ const BUG: IconNode = [
603
+ ["path", { d: "M12 20v-9" }],
604
+ ["path", { d: "M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z" }],
605
+ ["path", { d: "M14.12 3.88 16 2" }],
606
+ ["path", { d: "M21 21a4 4 0 0 0-3.81-4" }],
607
+ ["path", { d: "M21 5a4 4 0 0 1-3.55 3.97" }],
608
+ ["path", { d: "M22 13h-4" }],
609
+ ["path", { d: "M3 21a4 4 0 0 1 3.81-4" }],
610
+ ["path", { d: "M3 5a4 4 0 0 0 3.55 3.97" }],
611
+ ["path", { d: "M6 13H2" }],
612
+ ["path", { d: "m8 2 1.88 1.88" }],
613
+ ["path", { d: "M9 7.13V6a3 3 0 1 1 6 0v1.13" }],
614
+ ];
615
+ const WORKFLOW: IconNode = [
616
+ ["rect", { width: "8", height: "8", x: "3", y: "3", rx: "2" }],
617
+ ["path", { d: "M7 11v4a2 2 0 0 0 2 2h4" }],
618
+ ["rect", { width: "8", height: "8", x: "13", y: "13", rx: "2" }],
619
+ ];
620
+ const CLOCK: IconNode = [
621
+ ["circle", { cx: "12", cy: "12", r: "10" }],
622
+ ["path", { d: "M12 6v6l4 2" }],
623
+ ];
624
+ const CHECK: IconNode = [["path", { d: "M20 6 9 17l-5-5" }]];
625
+ const SVG_NS = "http://www.w3.org/2000/svg";
626
+ function svgIcon(nodes: IconNode, className = "icon"): SVGSVGElement {
627
+ const svg = document.createElementNS(SVG_NS, "svg");
628
+ for (const [k, v] of Object.entries({ viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2", "stroke-linecap": "round", "stroke-linejoin": "round", "aria-hidden": "true", class: className })) svg.setAttribute(k, v);
629
+ for (const [tag, attrs] of nodes) {
630
+ const node = document.createElementNS(SVG_NS, tag);
631
+ for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v);
632
+ svg.append(node);
633
+ }
634
+ return svg;
500
635
  }
501
636
 
502
637
  function button(text: string, className = ""): HTMLButtonElement {
@@ -508,8 +643,12 @@ function button(text: string, className = ""): HTMLButtonElement {
508
643
  }
509
644
 
510
645
  function labelFor(text: string, hint = "", required = false): HTMLElement {
511
- const label = document.createElement("label");
512
- label.append(document.createTextNode(text));
646
+ return fillLabel(document.createElement("label"), text, hint, required);
647
+ }
648
+
649
+ /** A label's words, written again when the dialog turns from a report to a request. */
650
+ function fillLabel(label: HTMLElement, text: string, hint = "", required = false): HTMLElement {
651
+ label.replaceChildren(document.createTextNode(text));
513
652
  if (required) {
514
653
  const star = document.createElement("span");
515
654
  star.className = "req";