@lightworkai.official/debug-capture 0.12.8 → 0.13.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/debug-capture.js +18 -5
- package/dist/index.d.ts +2 -0
- package/dist/index.mjs +29 -16
- package/dist/requests.d.ts +29 -0
- package/dist/submit.d.ts +11 -0
- package/dist/ui/strings.d.ts +19 -0
- package/dist/ui/styles.d.ts +1 -1
- package/package.json +1 -1
- package/src/embed.ts +7 -1
- package/src/index.ts +2 -0
- package/src/install.ts +10 -0
- package/src/requests.ts +106 -0
- package/src/submit.ts +19 -16
- package/src/ui/element.ts +139 -0
- package/src/ui/strings.ts +57 -0
- package/src/ui/styles.ts +15 -2
package/src/requests.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ขอให้ทีมดำเนินการ — asking the team for something, from the app.
|
|
3
|
+
*
|
|
4
|
+
* A report is "something broke": it carries a screenshot and the page's logs,
|
|
5
|
+
* because those are what make it reproducible. A request is "please do this"
|
|
6
|
+
* — reset my password, give me access — and nothing about the screen helps,
|
|
7
|
+
* so it asks for no screen share and sends no bundle. The desk decides what
|
|
8
|
+
* may be asked (its request types opened to reporters, GET
|
|
9
|
+
* /ingest/request-types); where it opens none, the widget is exactly what it
|
|
10
|
+
* was.
|
|
11
|
+
*
|
|
12
|
+
* Read AHEAD of a launch, not at it: `getDisplayMedia` must be called inside
|
|
13
|
+
* the click that opened the widget, so a launch cannot wait on the network to
|
|
14
|
+
* find out whether to ask "report or request?" first. Installing the widget
|
|
15
|
+
* starts the read; a launch uses whatever has arrived.
|
|
16
|
+
*/
|
|
17
|
+
import { getConfig, appInfo, resolveModule } from "./config";
|
|
18
|
+
import { failureOf, identityHeader, ReportError, type FiledTicket } from "./submit";
|
|
19
|
+
|
|
20
|
+
export interface RequestTypeOption {
|
|
21
|
+
id: string;
|
|
22
|
+
name: string;
|
|
23
|
+
description: string;
|
|
24
|
+
/** How long the team means to take, in hours — null when it has not said. */
|
|
25
|
+
targetHours: number | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let known: RequestTypeOption[] | null = null;
|
|
29
|
+
let reading: Promise<RequestTypeOption[]> | null = null;
|
|
30
|
+
|
|
31
|
+
const hostOf = () => getConfig().host.replace(/\/$/, "");
|
|
32
|
+
|
|
33
|
+
/** What the desk opens to its reporters, as far as is known right now — null before the first read lands. */
|
|
34
|
+
export function requestTypesNow(): RequestTypeOption[] | null {
|
|
35
|
+
return known;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Ask the desk once. Never throws: a desk that answers nothing useful — an
|
|
40
|
+
* older one without the endpoint (404), a network blip — offers nothing, and
|
|
41
|
+
* the widget stays a report button.
|
|
42
|
+
*/
|
|
43
|
+
export function loadRequestTypes(): Promise<RequestTypeOption[]> {
|
|
44
|
+
if (reading) return reading;
|
|
45
|
+
reading = (async () => {
|
|
46
|
+
try {
|
|
47
|
+
const res = await fetch(`${hostOf()}/ingest/request-types`, { headers: { "x-realm-key": getConfig().realmKey } });
|
|
48
|
+
const body = res.ok ? ((await res.json()) as { types?: unknown }) : null;
|
|
49
|
+
known = Array.isArray(body?.types) ? body.types.filter(isOption) : [];
|
|
50
|
+
} catch {
|
|
51
|
+
known = [];
|
|
52
|
+
// A failed read may be tried again on the next launch.
|
|
53
|
+
reading = null;
|
|
54
|
+
}
|
|
55
|
+
return known ?? [];
|
|
56
|
+
})();
|
|
57
|
+
return reading;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isOption(t: unknown): t is RequestTypeOption {
|
|
61
|
+
const o = t as Partial<RequestTypeOption> | null;
|
|
62
|
+
return !!o && typeof o.id === "string" && typeof o.name === "string";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Forget what was read — for tests, and for a host that changes realm. */
|
|
66
|
+
export function resetRequestTypes(): void {
|
|
67
|
+
known = null;
|
|
68
|
+
reading = null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Send a request. The same door as a report (POST /ingest/tickets), with the
|
|
73
|
+
* type it asks for — the desk starts its steps and never folds two people's
|
|
74
|
+
* same request into one. Who is asking comes from the identity token when the
|
|
75
|
+
* host has one, as for a report.
|
|
76
|
+
*/
|
|
77
|
+
export async function submitRequest(input: { ticketTypeId: string; title: string; description: string }): Promise<FiledTicket> {
|
|
78
|
+
const config = getConfig();
|
|
79
|
+
let user: { email?: string | null; fullName?: string | null } = {};
|
|
80
|
+
try {
|
|
81
|
+
user = config.user?.() ?? {};
|
|
82
|
+
} catch {
|
|
83
|
+
user = {};
|
|
84
|
+
}
|
|
85
|
+
const here = typeof window === "undefined" ? null : window.location;
|
|
86
|
+
const body = {
|
|
87
|
+
title: input.title,
|
|
88
|
+
description: input.description.trim() || undefined,
|
|
89
|
+
ticketTypeId: input.ticketTypeId,
|
|
90
|
+
appName: appInfo().name,
|
|
91
|
+
category: here ? resolveModule(here.pathname)?.label : undefined,
|
|
92
|
+
routeUrl: here?.href,
|
|
93
|
+
priority: "MEDIUM",
|
|
94
|
+
requesterEmail: user.email ?? undefined,
|
|
95
|
+
requesterName: user.fullName ?? undefined,
|
|
96
|
+
};
|
|
97
|
+
const res = await fetch(`${hostOf()}/ingest/tickets`, {
|
|
98
|
+
method: "POST",
|
|
99
|
+
headers: { "content-type": "application/json", "x-realm-key": config.realmKey, ...(await identityHeader()) },
|
|
100
|
+
body: JSON.stringify(body),
|
|
101
|
+
}).catch(() => {
|
|
102
|
+
throw new ReportError("request never left the browser", "offline");
|
|
103
|
+
});
|
|
104
|
+
if (!res.ok) throw await failureOf(res);
|
|
105
|
+
return (await res.json()) as FiledTicket;
|
|
106
|
+
}
|
package/src/submit.ts
CHANGED
|
@@ -76,7 +76,7 @@ export function bundleAttachment(bundle: DebugBundle): { dataUri: string; filena
|
|
|
76
76
|
* endpoint is having a bad day — a bug report is the last thing that should
|
|
77
77
|
* fail because of a side quest.
|
|
78
78
|
*/
|
|
79
|
-
async function identityHeader(): Promise<Record<string, string>> {
|
|
79
|
+
export async function identityHeader(): Promise<Record<string, string>> {
|
|
80
80
|
const provider = getConfig().identity;
|
|
81
81
|
if (!provider) return {};
|
|
82
82
|
try {
|
|
@@ -220,20 +220,23 @@ export async function submitBundle(title: string, bundle: DebugBundle): Promise<
|
|
|
220
220
|
throw new ReportError("report never left the browser", "offline");
|
|
221
221
|
});
|
|
222
222
|
|
|
223
|
-
if (!res.ok)
|
|
224
|
-
// The ingest endpoint answers 429 with a retry-after; anything else carries
|
|
225
|
-
// an { error } body. Surface whichever it is — "failed" alone sends someone
|
|
226
|
-
// reading a network tab they may not be able to open.
|
|
227
|
-
const retryAfter = res.headers.get("retry-after");
|
|
228
|
-
const detail = await res
|
|
229
|
-
.json()
|
|
230
|
-
.then((b: { error?: string }) => b.error)
|
|
231
|
-
.catch(() => null);
|
|
232
|
-
if (res.status === 429) throw new ReportError("rate limited", "rateLimited", retryAfter ?? undefined);
|
|
233
|
-
// 502/503/504 come from whatever is in front of the desk while it is being
|
|
234
|
-
// replaced, and the body is that thing's error page, not a sentence for us.
|
|
235
|
-
if (UNAVAILABLE.has(res.status)) throw new ReportError("desk unavailable", "unavailable");
|
|
236
|
-
throw new ReportError(detail ?? `Report failed (${res.status})`, "rejected", undefined, res.status);
|
|
237
|
-
}
|
|
223
|
+
if (!res.ok) throw await failureOf(res);
|
|
238
224
|
return (await res.json()) as FiledTicket;
|
|
239
225
|
}
|
|
226
|
+
|
|
227
|
+
/** The send that did not go through, as one of the four failures — shared with a request's send (requests.ts). */
|
|
228
|
+
export async function failureOf(res: Response): Promise<ReportError> {
|
|
229
|
+
// The ingest endpoint answers 429 with a retry-after; anything else carries
|
|
230
|
+
// an { error } body. Surface whichever it is — "failed" alone sends someone
|
|
231
|
+
// reading a network tab they may not be able to open.
|
|
232
|
+
const retryAfter = res.headers.get("retry-after");
|
|
233
|
+
const detail = await res
|
|
234
|
+
.json()
|
|
235
|
+
.then((b: { error?: string }) => b.error)
|
|
236
|
+
.catch(() => null);
|
|
237
|
+
if (res.status === 429) return new ReportError("rate limited", "rateLimited", retryAfter ?? undefined);
|
|
238
|
+
// 502/503/504 come from whatever is in front of the desk while it is being
|
|
239
|
+
// replaced, and the body is that thing's error page, not a sentence for us.
|
|
240
|
+
if (UNAVAILABLE.has(res.status)) return new ReportError("desk unavailable", "unavailable");
|
|
241
|
+
return new ReportError(detail ?? `Report failed (${res.status})`, "rejected", undefined, res.status);
|
|
242
|
+
}
|
package/src/ui/element.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { buildBundle } from "../bundle";
|
|
|
13
13
|
import { getConfig } from "../config";
|
|
14
14
|
import { onLaunch } from "../reporter";
|
|
15
15
|
import { captureScreen } from "../screenshot";
|
|
16
|
+
import { loadRequestTypes, requestTypesNow, submitRequest, type RequestTypeOption } from "../requests";
|
|
16
17
|
import { ReportError, submitBundle } from "../submit";
|
|
17
18
|
import type { DebugBundle, DebugReason, ErroredQuery } from "../types";
|
|
18
19
|
import { createAnnotator, type Annotator, type AnnotatorTool } from "./annotator";
|
|
@@ -120,6 +121,26 @@ function createReporterClass(): CustomElementConstructor {
|
|
|
120
121
|
this.busy = true;
|
|
121
122
|
const t = strings(getConfig().locale);
|
|
122
123
|
|
|
124
|
+
/*
|
|
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.
|
|
130
|
+
*/
|
|
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
|
+
}
|
|
139
|
+
await this.report(reason, t);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** A report: the screen first, then the form — see open(). Called inside a click, with nothing awaited before the capture. */
|
|
143
|
+
private async report(reason: DebugReason, t: Strings): Promise<void> {
|
|
123
144
|
/*
|
|
124
145
|
* Capture BEFORE anything of ours is on screen.
|
|
125
146
|
*
|
|
@@ -147,6 +168,108 @@ function createReporterClass(): CustomElementConstructor {
|
|
|
147
168
|
this.renderForm(t);
|
|
148
169
|
}
|
|
149
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();
|
|
271
|
+
}
|
|
272
|
+
|
|
150
273
|
private close(): void {
|
|
151
274
|
this.annotator?.destroy();
|
|
152
275
|
this.annotator = null;
|
|
@@ -360,6 +483,22 @@ function el(tag: string, className = "", text = ""): HTMLElement {
|
|
|
360
483
|
return node;
|
|
361
484
|
}
|
|
362
485
|
|
|
486
|
+
/** "3 วัน" or "4 ชั่วโมง" — the way the desk's setting offers it. */
|
|
487
|
+
function hoursWords(t: Strings, h: number): string {
|
|
488
|
+
return h >= 24 && h % 24 === 0 ? t.days.replace("{n}", String(h / 24)) : t.hours.replace("{n}", String(h));
|
|
489
|
+
}
|
|
490
|
+
|
|
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
|
+
|
|
363
502
|
function button(text: string, className = ""): HTMLButtonElement {
|
|
364
503
|
const node = document.createElement("button");
|
|
365
504
|
node.type = "button";
|
package/src/ui/strings.ts
CHANGED
|
@@ -64,6 +64,29 @@ export interface Strings {
|
|
|
64
64
|
handoffFailed: string;
|
|
65
65
|
handoffBlocked: string;
|
|
66
66
|
handoffOpen: string;
|
|
67
|
+
/*
|
|
68
|
+
* ขอให้ทีมดำเนินการ — offered beside แจ้งปัญหา when the desk opens request
|
|
69
|
+
* types to its reporters (requests.ts). A request asks for no screenshot.
|
|
70
|
+
*/
|
|
71
|
+
chooseTitle: string;
|
|
72
|
+
chooseProblem: string;
|
|
73
|
+
chooseProblemHint: string;
|
|
74
|
+
chooseRequest: string;
|
|
75
|
+
chooseRequestHint: string;
|
|
76
|
+
requestTitle: string;
|
|
77
|
+
requestTypeLabel: string;
|
|
78
|
+
/** Carries `{time}`. */
|
|
79
|
+
requestTarget: string;
|
|
80
|
+
requestSummaryLabel: string;
|
|
81
|
+
requestSummaryPlaceholder: string;
|
|
82
|
+
requestDetailLabel: string;
|
|
83
|
+
requestDetailPlaceholder: string;
|
|
84
|
+
requestSubmit: string;
|
|
85
|
+
requestSubmitted: string;
|
|
86
|
+
back: string;
|
|
87
|
+
/** Carry `{n}`. */
|
|
88
|
+
days: string;
|
|
89
|
+
hours: string;
|
|
67
90
|
}
|
|
68
91
|
|
|
69
92
|
const th: Strings = {
|
|
@@ -109,6 +132,23 @@ const th: Strings = {
|
|
|
109
132
|
handoffFailed: "เปิดรายการที่แจ้งไว้ไม่สำเร็จ",
|
|
110
133
|
handoffBlocked: "เบราว์เซอร์บล็อกการเปิดแท็บใหม่",
|
|
111
134
|
handoffOpen: "เปิดในแท็บใหม่",
|
|
135
|
+
chooseTitle: "ต้องการให้ทีมช่วยเรื่องอะไร",
|
|
136
|
+
chooseProblem: "แจ้งปัญหา",
|
|
137
|
+
chooseProblemHint: "มีอะไรเสียหรือใช้งานไม่ได้ — แนบภาพหน้าจอ",
|
|
138
|
+
chooseRequest: "ขอให้ทีมดำเนินการ",
|
|
139
|
+
chooseRequestHint: "ขอสิทธิ์ ขอแก้ข้อมูล หรือขอให้ทีมทำบางอย่าง",
|
|
140
|
+
requestTitle: "ขอให้ทีมดำเนินการ",
|
|
141
|
+
requestTypeLabel: "เรื่องที่ขอ",
|
|
142
|
+
requestTarget: "ทีมตั้งใจให้เสร็จภายใน {time}",
|
|
143
|
+
requestSummaryLabel: "หัวข้อ",
|
|
144
|
+
requestSummaryPlaceholder: "ขออะไร — สั้น ๆ",
|
|
145
|
+
requestDetailLabel: "รายละเอียด (ไม่บังคับ)",
|
|
146
|
+
requestDetailPlaceholder: "สิ่งที่ทีมต้องรู้เพื่อดำเนินการ",
|
|
147
|
+
requestSubmit: "ส่งคำขอ",
|
|
148
|
+
requestSubmitted: "ส่งคำขอแล้ว",
|
|
149
|
+
back: "ย้อนกลับ",
|
|
150
|
+
days: "{n} วัน",
|
|
151
|
+
hours: "{n} ชั่วโมง",
|
|
112
152
|
};
|
|
113
153
|
|
|
114
154
|
const en: Strings = {
|
|
@@ -154,6 +194,23 @@ const en: Strings = {
|
|
|
154
194
|
handoffFailed: "Could not open your reports",
|
|
155
195
|
handoffBlocked: "Your browser blocked the new tab",
|
|
156
196
|
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",
|
|
202
|
+
requestTitle: "Ask the team",
|
|
203
|
+
requestTypeLabel: "What you need",
|
|
204
|
+
requestTarget: "The team aims to finish within {time}",
|
|
205
|
+
requestSummaryLabel: "Subject",
|
|
206
|
+
requestSummaryPlaceholder: "What do you need — briefly",
|
|
207
|
+
requestDetailLabel: "Details (optional)",
|
|
208
|
+
requestDetailPlaceholder: "What the team needs to know to do it",
|
|
209
|
+
requestSubmit: "Send request",
|
|
210
|
+
requestSubmitted: "Request sent",
|
|
211
|
+
back: "Back",
|
|
212
|
+
days: "{n} days",
|
|
213
|
+
hours: "{n} hours",
|
|
157
214
|
};
|
|
158
215
|
|
|
159
216
|
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 {
|
|
37
|
+
input[type="text"], 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 {
|
|
41
|
+
input[type="text"]: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; }
|
|
@@ -93,4 +93,17 @@ canvas { width: 100%; height: auto; display: block; border: 1px solid #e2e8f0; b
|
|
|
93
93
|
border-radius: 0 0 16px 16px;
|
|
94
94
|
}
|
|
95
95
|
.foot .grow { flex: 1; }
|
|
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;
|
|
103
|
+
}
|
|
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; }
|
|
96
109
|
`;
|