@lightworkai.official/debug-capture 0.12.7 → 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/server.d.ts +48 -0
- package/dist/server.mjs +34 -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/server.ts +78 -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/dist/ui/strings.d.ts
CHANGED
|
@@ -64,5 +64,24 @@ export interface Strings {
|
|
|
64
64
|
handoffFailed: string;
|
|
65
65
|
handoffBlocked: string;
|
|
66
66
|
handoffOpen: string;
|
|
67
|
+
chooseTitle: string;
|
|
68
|
+
chooseProblem: string;
|
|
69
|
+
chooseProblemHint: string;
|
|
70
|
+
chooseRequest: string;
|
|
71
|
+
chooseRequestHint: string;
|
|
72
|
+
requestTitle: string;
|
|
73
|
+
requestTypeLabel: string;
|
|
74
|
+
/** Carries `{time}`. */
|
|
75
|
+
requestTarget: string;
|
|
76
|
+
requestSummaryLabel: string;
|
|
77
|
+
requestSummaryPlaceholder: string;
|
|
78
|
+
requestDetailLabel: string;
|
|
79
|
+
requestDetailPlaceholder: string;
|
|
80
|
+
requestSubmit: string;
|
|
81
|
+
requestSubmitted: string;
|
|
82
|
+
back: string;
|
|
83
|
+
/** Carry `{n}`. */
|
|
84
|
+
days: string;
|
|
85
|
+
hours: string;
|
|
67
86
|
}
|
|
68
87
|
export declare function strings(locale: "th" | "en" | undefined): Strings;
|
package/dist/ui/styles.d.ts
CHANGED
|
@@ -10,4 +10,4 @@
|
|
|
10
10
|
* Everything is explicit for the same reason: the host's `body` font and colours
|
|
11
11
|
* do not inherit past the boundary, so stating them is not belt-and-braces.
|
|
12
12
|
*/
|
|
13
|
-
export declare const CSS = "\n:host { all: initial; }\n* { box-sizing: border-box; }\n\n.backdrop {\n position: fixed; inset: 0; z-index: 2147483000;\n display: flex; align-items: center; justify-content: center;\n padding: 24px; background: rgba(15, 23, 42, 0.55);\n font-family: system-ui, -apple-system, \"Segoe UI\", Roboto, \"Noto Sans Thai\", sans-serif;\n color: #0f172a;\n}\n.panel {\n display: flex; flex-direction: column; gap: 16px;\n width: min(820px, 100%); max-height: 90vh; overflow-y: auto;\n /* No bottom padding: the footer supplies its own and sits flush, so it can\n stick to the panel's edge without a gap under it. */\n padding: 24px 24px 0; border-radius: 16px; background: #fff;\n box-shadow: 0 24px 60px rgba(15, 23, 42, 0.28);\n}\n.head { display: flex; align-items: flex-start; gap: 12px; }\nh2 { margin: 0; font-size: 20px; font-weight: 700; flex: 1; }\nlabel { display: block; font-size: 14px; font-weight: 600; margin-bottom: 6px; }\n.req { color: #dc2626; }\n.hint { font-weight: 400; color: #64748b; }\ninput[type=\"text\"], textarea {\n width: 100%; padding: 10px 12px; font: inherit; font-size: 14px;\n border: 1px solid #cbd5e1; border-radius: 10px; background: #fff; color: inherit;\n}\ninput[type=\"text\"]:focus, textarea:focus {\n outline: none; border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.18);\n}\ntextarea { min-height: 88px; resize: vertical; }\n\n.meta {\n padding: 12px 14px; border-radius: 10px; background: #f8fafc;\n font-size: 13px; line-height: 1.7; color: #475569; word-break: break-all;\n}\n.meta b { color: #0f172a; font-weight: 600; }\n\n.tools { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }\n.sep { width: 1px; align-self: stretch; background: #e2e8f0; margin: 0 4px; }\nbutton {\n display: inline-flex; align-items: center; gap: 6px; cursor: pointer;\n padding: 8px 14px; font: inherit; font-size: 14px; font-weight: 500;\n border: 1px solid #cbd5e1; border-radius: 10px; background: #fff; color: inherit;\n}\nbutton:hover:not(:disabled) { background: #f1f5f9; }\nbutton:disabled { opacity: 0.5; cursor: not-allowed; }\nbutton[aria-pressed=\"true\"] { border-color: #2563eb; background: #eff6ff; color: #1d4ed8; }\nbutton.primary { background: #2563eb; border-color: #2563eb; color: #fff; }\nbutton.primary:hover:not(:disabled) { background: #1d4ed8; }\nbutton.icon { padding: 6px 10px; border-color: transparent; font-size: 18px; line-height: 1; }\n\n/* The on-image text editor. Absolutely placed over the canvas and matched to\n the shape it becomes \u2014 same weight, same red, same white plate \u2014 so what you\n type is what gets drawn, at the position you clicked. Sized in JS, because\n the font size tracks the image's own scale. */\n.text-edit {\n position: absolute; z-index: 2; margin: 0; padding: 2px 4px;\n font-family: system-ui, sans-serif; font-weight: 600; line-height: 1.25;\n color: #e11d48; background: rgba(255, 255, 255, 0.92);\n border: 1px dashed #e11d48; border-radius: 4px; outline: none;\n box-shadow: 0 2px 10px rgba(15, 23, 42, 0.18);\n}\n.text-edit::placeholder { color: rgba(225, 29, 72, 0.45); }\n\ncanvas { width: 100%; height: auto; display: block; border: 1px solid #e2e8f0; border-radius: 10px; touch-action: none; }\n.shot { position: relative; }\n.status { font-size: 13px; color: #64748b; }\n.status[data-tone=\"error\"] { color: #dc2626; }\n/* Pinned to the bottom of the scrolling panel.\n A screenshot is tall \u2014 often taller than the viewport \u2014 so \u0E41\u0E08\u0E49\u0E07\u0E1B\u0E31\u0E0D\u0E2B\u0E32 sat\n below the fold and the last step of filing a report was \"scroll to the end\n and find the button\". Sticky, with its own ground and a rule above it so the\n annotated image scrolls underneath rather than bleeding into it. */\n.foot {\n position: sticky; bottom: 0; z-index: 3;\n display: flex; align-items: center; gap: 12px;\n margin: 4px -24px 0; padding: 12px 24px;\n background: #fff; border-top: 1px solid #e2e8f0;\n border-radius: 0 0 16px 16px;\n}\n.foot .grow { flex: 1; }\n";
|
|
13
|
+
export declare const CSS = "\n:host { all: initial; }\n* { box-sizing: border-box; }\n\n.backdrop {\n position: fixed; inset: 0; z-index: 2147483000;\n display: flex; align-items: center; justify-content: center;\n padding: 24px; background: rgba(15, 23, 42, 0.55);\n font-family: system-ui, -apple-system, \"Segoe UI\", Roboto, \"Noto Sans Thai\", sans-serif;\n color: #0f172a;\n}\n.panel {\n display: flex; flex-direction: column; gap: 16px;\n width: min(820px, 100%); max-height: 90vh; overflow-y: auto;\n /* No bottom padding: the footer supplies its own and sits flush, so it can\n stick to the panel's edge without a gap under it. */\n padding: 24px 24px 0; border-radius: 16px; background: #fff;\n box-shadow: 0 24px 60px rgba(15, 23, 42, 0.28);\n}\n.head { display: flex; align-items: flex-start; gap: 12px; }\nh2 { margin: 0; font-size: 20px; font-weight: 700; flex: 1; }\nlabel { display: block; font-size: 14px; font-weight: 600; margin-bottom: 6px; }\n.req { color: #dc2626; }\n.hint { font-weight: 400; color: #64748b; }\ninput[type=\"text\"], textarea, select {\n width: 100%; padding: 10px 12px; font: inherit; font-size: 14px;\n border: 1px solid #cbd5e1; border-radius: 10px; background: #fff; color: inherit;\n}\ninput[type=\"text\"]:focus, textarea:focus, select:focus {\n outline: none; border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.18);\n}\ntextarea { min-height: 88px; resize: vertical; }\n\n.meta {\n padding: 12px 14px; border-radius: 10px; background: #f8fafc;\n font-size: 13px; line-height: 1.7; color: #475569; word-break: break-all;\n}\n.meta b { color: #0f172a; font-weight: 600; }\n\n.tools { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }\n.sep { width: 1px; align-self: stretch; background: #e2e8f0; margin: 0 4px; }\nbutton {\n display: inline-flex; align-items: center; gap: 6px; cursor: pointer;\n padding: 8px 14px; font: inherit; font-size: 14px; font-weight: 500;\n border: 1px solid #cbd5e1; border-radius: 10px; background: #fff; color: inherit;\n}\nbutton:hover:not(:disabled) { background: #f1f5f9; }\nbutton:disabled { opacity: 0.5; cursor: not-allowed; }\nbutton[aria-pressed=\"true\"] { border-color: #2563eb; background: #eff6ff; color: #1d4ed8; }\nbutton.primary { background: #2563eb; border-color: #2563eb; color: #fff; }\nbutton.primary:hover:not(:disabled) { background: #1d4ed8; }\nbutton.icon { padding: 6px 10px; border-color: transparent; font-size: 18px; line-height: 1; }\n\n/* The on-image text editor. Absolutely placed over the canvas and matched to\n the shape it becomes \u2014 same weight, same red, same white plate \u2014 so what you\n type is what gets drawn, at the position you clicked. Sized in JS, because\n the font size tracks the image's own scale. */\n.text-edit {\n position: absolute; z-index: 2; margin: 0; padding: 2px 4px;\n font-family: system-ui, sans-serif; font-weight: 600; line-height: 1.25;\n color: #e11d48; background: rgba(255, 255, 255, 0.92);\n border: 1px dashed #e11d48; border-radius: 4px; outline: none;\n box-shadow: 0 2px 10px rgba(15, 23, 42, 0.18);\n}\n.text-edit::placeholder { color: rgba(225, 29, 72, 0.45); }\n\ncanvas { width: 100%; height: auto; display: block; border: 1px solid #e2e8f0; border-radius: 10px; touch-action: none; }\n.shot { position: relative; }\n.status { font-size: 13px; color: #64748b; }\n.status[data-tone=\"error\"] { color: #dc2626; }\n/* Pinned to the bottom of the scrolling panel.\n A screenshot is tall \u2014 often taller than the viewport \u2014 so \u0E41\u0E08\u0E49\u0E07\u0E1B\u0E31\u0E0D\u0E2B\u0E32 sat\n below the fold and the last step of filing a report was \"scroll to the end\n and find the button\". Sticky, with its own ground and a rule above it so the\n annotated image scrolls underneath rather than bleeding into it. */\n.foot {\n position: sticky; bottom: 0; z-index: 3;\n display: flex; align-items: center; gap: 12px;\n margin: 4px -24px 0; padding: 12px 24px;\n background: #fff; border-top: 1px solid #e2e8f0;\n border-radius: 0 0 16px 16px;\n}\n.foot .grow { flex: 1; }\n\n/* \u0E02\u0E2D\u0E43\u0E2B\u0E49\u0E17\u0E35\u0E21\u0E14\u0E33\u0E40\u0E19\u0E34\u0E19\u0E01\u0E32\u0E23: the chooser and the request form \u2014 a smaller dialog, no image in it. */\n.panel.narrow { width: min(560px, 100%); }\n.choices { display: grid; gap: 10px; padding-bottom: 24px; }\nbutton.choice {\n display: flex; flex-direction: column; align-items: flex-start; gap: 2px;\n width: 100%; padding: 14px 16px; text-align: left; border-radius: 12px;\n}\nbutton.choice b { font-size: 15px; font-weight: 600; }\nbutton.choice span { font-size: 13px; font-weight: 400; color: #64748b; }\nbutton.choice:focus-visible { outline: none; border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.18); }\n.panel.narrow p { margin: 0; }\n.ask-type { font-size: 14px; font-weight: 600; margin-top: -8px; }\n";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lightworkai.official/debug-capture",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Report-a-problem widget: rolling network/console/action capture, optional rrweb session replay, annotated screenshot, and submission to a Lightwork Support host",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"module": "./dist/index.mjs",
|
package/src/embed.ts
CHANGED
|
@@ -66,7 +66,13 @@ if (data.host && data.realmKey) {
|
|
|
66
66
|
locale: data.locale === "en" ? "en" : "th",
|
|
67
67
|
...(data.replay !== undefined ? { replay: { seconds: Number(data.replay) || undefined } } : {}),
|
|
68
68
|
});
|
|
69
|
-
|
|
69
|
+
/*
|
|
70
|
+
* In <head>, the script runs before there is a <body> — and installing then
|
|
71
|
+
* threw on the first observer set on it, so the page had no reporter and no
|
|
72
|
+
* `lwReportProblem` either. Wait for the document when it is not there yet.
|
|
73
|
+
*/
|
|
74
|
+
if (document.body) installDebugCapture();
|
|
75
|
+
else document.addEventListener("DOMContentLoaded", () => installDebugCapture(), { once: true });
|
|
70
76
|
}
|
|
71
77
|
|
|
72
78
|
// The only handle a no-bundler page gets. Call it from a button's onclick —
|
package/src/index.ts
CHANGED
|
@@ -22,6 +22,8 @@ export { launch, onLaunch, onCrash, emitCrash } from "./reporter";
|
|
|
22
22
|
export { buildBundle, bundleToMarkdown, hasCause, bundleFileName, toCurl } from "./bundle";
|
|
23
23
|
export { submitBundle } from "./submit";
|
|
24
24
|
export type { FiledTicket } from "./submit";
|
|
25
|
+
export { loadRequestTypes, requestTypesNow, submitRequest } from "./requests";
|
|
26
|
+
export type { RequestTypeOption } from "./requests";
|
|
25
27
|
export { errorSignature } from "./signature";
|
|
26
28
|
export { trimToBudget, BUNDLE_BUDGET_BYTES } from "./budget";
|
|
27
29
|
export { captureScreen } from "./screenshot";
|
package/src/install.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { launch, onCrash } from "./reporter";
|
|
|
13
13
|
import { strings } from "./ui/strings";
|
|
14
14
|
import { showToast } from "./ui/toast";
|
|
15
15
|
import { defineReporterElement, REPORTER_TAG } from "./ui/element";
|
|
16
|
+
import { loadRequestTypes } from "./requests";
|
|
16
17
|
|
|
17
18
|
let armed = false;
|
|
18
19
|
|
|
@@ -59,4 +60,13 @@ export function installDebugCapture(options: InstallOptions = {}): void {
|
|
|
59
60
|
if (!document.querySelector(REPORTER_TAG)) {
|
|
60
61
|
document.body.append(document.createElement(REPORTER_TAG));
|
|
61
62
|
}
|
|
63
|
+
|
|
64
|
+
/*
|
|
65
|
+
* What the desk opens to its reporters, read once the page has settled —
|
|
66
|
+
* so a press of the button can offer ขอให้ทีมดำเนินการ without waiting
|
|
67
|
+
* (requests.ts). Late on purpose: it is never what the page came for.
|
|
68
|
+
*/
|
|
69
|
+
const idle = (window as Window & { requestIdleCallback?: (cb: () => void, o?: { timeout: number }) => number }).requestIdleCallback;
|
|
70
|
+
if (idle) idle(() => void loadRequestTypes(), { timeout: 5000 });
|
|
71
|
+
else setTimeout(() => void loadRequestTypes(), 2000);
|
|
62
72
|
}
|
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/server.ts
CHANGED
|
@@ -17,12 +17,56 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { createHmac, randomUUID } from "node:crypto";
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* One fact about a person's WORK, as the host knows it — their unit, their
|
|
22
|
+
* position — with the host's own label for it. The support desk shows these
|
|
23
|
+
* beside the person's name and records them with what the person does (an
|
|
24
|
+
* event registration takes them as its own), so nobody types their unit into
|
|
25
|
+
* a form the host could have filled.
|
|
26
|
+
*
|
|
27
|
+
* `key` is stable across people (it names a column in an export); `label` is
|
|
28
|
+
* what a person reads, in the host's language. Order matters: the first is
|
|
29
|
+
* the one drawn under a name.
|
|
30
|
+
*/
|
|
31
|
+
export interface SupportProfileFact {
|
|
32
|
+
key: string;
|
|
33
|
+
label: string;
|
|
34
|
+
value: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
20
37
|
/** Whoever the host has authenticated. Email is the account key. */
|
|
21
38
|
export interface SupportUser {
|
|
22
39
|
/** The host's own id for this person, recorded so a changed email still matches. */
|
|
23
40
|
id?: string | null;
|
|
24
41
|
email: string;
|
|
25
42
|
name?: string | null;
|
|
43
|
+
/** What the host knows about their work — most important first. Optional; bounded on the way in. */
|
|
44
|
+
profile?: SupportProfileFact[] | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** How much of a profile rides in a token. It travels in a URL, so it is small on purpose. */
|
|
48
|
+
export const PROFILE_MAX_FACTS = 10;
|
|
49
|
+
const PROFILE_KEY = /^[a-z][a-z0-9_]{0,31}$/;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A profile made safe to sign: known shape, stable keys, trimmed, bounded.
|
|
53
|
+
* A fact with no value is left out (an empty unit is not a unit). Null when
|
|
54
|
+
* there is nothing to say — the support side then keeps what it had.
|
|
55
|
+
*/
|
|
56
|
+
export function cleanProfile(profile: SupportProfileFact[] | null | undefined): SupportProfileFact[] | null {
|
|
57
|
+
if (!Array.isArray(profile)) return null;
|
|
58
|
+
const out: SupportProfileFact[] = [];
|
|
59
|
+
const seen = new Set<string>();
|
|
60
|
+
for (const f of profile) {
|
|
61
|
+
const key = typeof f?.key === "string" ? f.key.trim() : "";
|
|
62
|
+
const value = typeof f?.value === "string" ? f.value.trim() : "";
|
|
63
|
+
if (!PROFILE_KEY.test(key) || !value || seen.has(key)) continue;
|
|
64
|
+
seen.add(key);
|
|
65
|
+
const label = typeof f.label === "string" && f.label.trim() ? f.label.trim().slice(0, 40) : key;
|
|
66
|
+
out.push({ key, label, value: value.slice(0, 120) });
|
|
67
|
+
if (out.length >= PROFILE_MAX_FACTS) break;
|
|
68
|
+
}
|
|
69
|
+
return out.length ? out : null;
|
|
26
70
|
}
|
|
27
71
|
|
|
28
72
|
/**
|
|
@@ -65,6 +109,8 @@ export function createSupportToken(options: {
|
|
|
65
109
|
sub: options.user.id?.trim() || email,
|
|
66
110
|
email,
|
|
67
111
|
name: options.user.name?.trim() || null,
|
|
112
|
+
// Only when there is one: an older support desk ignores the claim, a newer one records it.
|
|
113
|
+
...(cleanProfile(options.user.profile) ? { profile: cleanProfile(options.user.profile) } : {}),
|
|
68
114
|
// Spent once on arrival; without it the freshness window is a replay window.
|
|
69
115
|
jti: randomUUID(),
|
|
70
116
|
iat: now,
|
|
@@ -74,6 +120,38 @@ export function createSupportToken(options: {
|
|
|
74
120
|
return { token: `${body}.${createHmac("sha256", options.secret).update(body).digest("base64url")}`, expiresIn };
|
|
75
121
|
}
|
|
76
122
|
|
|
123
|
+
/**
|
|
124
|
+
* The address that signs a person into the support desk and lands them on a
|
|
125
|
+
* page of it — for a host that sends people there by LINK rather than from
|
|
126
|
+
* its own button (a notice on a portal, a "sign in" door the desk bounces a
|
|
127
|
+
* signed-out reader to). The token is minted here, on the server, at the
|
|
128
|
+
* moment of the redirect; it lives two minutes and is spent on arrival.
|
|
129
|
+
*
|
|
130
|
+
* redirect(supportSignInUrl({ host, realmKey, secret, user, returnPath: "/e/อบรม-a4f2" }))
|
|
131
|
+
*
|
|
132
|
+
* `returnPath` must be a path on the support desk; the desk decides again
|
|
133
|
+
* where it may land (its own reporter pages, or an event).
|
|
134
|
+
*/
|
|
135
|
+
export function supportSignInUrl(options: {
|
|
136
|
+
/** The support desk's origin, e.g. https://support.example.com */
|
|
137
|
+
host: string;
|
|
138
|
+
/** The realm's public key. */
|
|
139
|
+
realmKey: string;
|
|
140
|
+
secret: string;
|
|
141
|
+
user: SupportUser;
|
|
142
|
+
returnPath?: string | null;
|
|
143
|
+
ttlSeconds?: number;
|
|
144
|
+
}): string {
|
|
145
|
+
const { token } = createSupportToken({ secret: options.secret, user: options.user, ttlSeconds: options.ttlSeconds });
|
|
146
|
+
const url = new URL("/sso", options.host.replace(/\/+$/, ""));
|
|
147
|
+
url.searchParams.set("realm", options.realmKey);
|
|
148
|
+
url.searchParams.set("token", token);
|
|
149
|
+
const back = options.returnPath?.trim();
|
|
150
|
+
// A path, never an address: the desk would refuse anything else anyway, so say so here first.
|
|
151
|
+
if (back && back.startsWith("/") && !back.startsWith("//")) url.searchParams.set("return", back);
|
|
152
|
+
return url.toString();
|
|
153
|
+
}
|
|
154
|
+
|
|
77
155
|
/**
|
|
78
156
|
* A ready-made route handler, for the common case.
|
|
79
157
|
*
|
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
|
`;
|