@lightworkai.official/debug-capture 0.6.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.
Files changed (65) hide show
  1. package/README.md +241 -0
  2. package/dist/budget.d.ts +22 -0
  3. package/dist/bundle.d.ts +20 -0
  4. package/dist/capture/actionTrail.d.ts +7 -0
  5. package/dist/capture/cause.d.ts +3 -0
  6. package/dist/capture/consoleBuffer.d.ts +4 -0
  7. package/dist/capture/crashWatcher.d.ts +1 -0
  8. package/dist/capture/networkBuffer.d.ts +4 -0
  9. package/dist/capture/redact.d.ts +9 -0
  10. package/dist/capture/stepCorrelation.d.ts +41 -0
  11. package/dist/config.d.ts +217 -0
  12. package/dist/context.d.ts +2 -0
  13. package/dist/debug-capture.js +116 -0
  14. package/dist/embed.d.ts +1 -0
  15. package/dist/index.d.ts +45 -0
  16. package/dist/index.mjs +129 -0
  17. package/dist/install.d.ts +5 -0
  18. package/dist/mytickets/api.d.ts +115 -0
  19. package/dist/mytickets/format.d.ts +84 -0
  20. package/dist/mytickets/sanitize.d.ts +66 -0
  21. package/dist/mytickets/strings.d.ts +74 -0
  22. package/dist/mytickets/toolbar.d.ts +17 -0
  23. package/dist/reporter.d.ts +27 -0
  24. package/dist/screenshot.d.ts +7 -0
  25. package/dist/signature.d.ts +18 -0
  26. package/dist/submit.d.ts +8 -0
  27. package/dist/types.d.ts +175 -0
  28. package/dist/ui/annotator.d.ts +34 -0
  29. package/dist/ui/arrow.d.ts +25 -0
  30. package/dist/ui/element.d.ts +9 -0
  31. package/dist/ui/strings.d.ts +42 -0
  32. package/dist/ui/styles.d.ts +13 -0
  33. package/dist/ui/toast.d.ts +11 -0
  34. package/package.json +53 -0
  35. package/src/budget.ts +62 -0
  36. package/src/bundle.ts +274 -0
  37. package/src/capture/actionTrail.ts +693 -0
  38. package/src/capture/cause.ts +49 -0
  39. package/src/capture/consoleBuffer.ts +80 -0
  40. package/src/capture/crashWatcher.ts +61 -0
  41. package/src/capture/networkBuffer.ts +315 -0
  42. package/src/capture/redact.ts +117 -0
  43. package/src/capture/stepCorrelation.ts +160 -0
  44. package/src/config.ts +299 -0
  45. package/src/context.ts +81 -0
  46. package/src/embed.ts +35 -0
  47. package/src/index.ts +109 -0
  48. package/src/install.ts +59 -0
  49. package/src/mytickets/api.ts +226 -0
  50. package/src/mytickets/format.ts +191 -0
  51. package/src/mytickets/sanitize.ts +221 -0
  52. package/src/mytickets/strings.ts +217 -0
  53. package/src/mytickets/toolbar.ts +48 -0
  54. package/src/reporter.ts +53 -0
  55. package/src/screenshot.ts +238 -0
  56. package/src/signature.ts +40 -0
  57. package/src/styles.css +400 -0
  58. package/src/submit.ts +143 -0
  59. package/src/types.ts +169 -0
  60. package/src/ui/annotator.ts +698 -0
  61. package/src/ui/arrow.ts +62 -0
  62. package/src/ui/element.ts +362 -0
  63. package/src/ui/strings.ts +113 -0
  64. package/src/ui/styles.ts +96 -0
  65. package/src/ui/toast.ts +138 -0
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The reporter's own words. Thai by default because that is who files these,
3
+ * with English for hosts that need it.
4
+ *
5
+ * Deliberately a flat map, not a translation framework: this is one dialog, and
6
+ * an i18n dependency in a package every host installs would be a tax on all of
7
+ * them for thirty strings.
8
+ */
9
+ export interface Strings {
10
+ title: string;
11
+ summaryLabel: string;
12
+ summaryRequired: string;
13
+ summaryPlaceholder: string;
14
+ detailLabel: string;
15
+ detailPlaceholder: string;
16
+ annotateTitle: string;
17
+ annotateHint: string;
18
+ /** Footer of the standalone annotator dialog — NOT the reporter's own submit. */
19
+ annotateApply: string;
20
+ cancel: string;
21
+ toolRect: string;
22
+ toolArrow: string;
23
+ toolHighlight: string;
24
+ toolText: string;
25
+ deleteSelected: string;
26
+ clearAll: string;
27
+ annotateHelp: string;
28
+ capturing: string;
29
+ noScreenshot: string;
30
+ cancelled: string;
31
+ close: string;
32
+ submit: string;
33
+ submitting: string;
34
+ submitted: string;
35
+ crashed: string;
36
+ crashAction: string;
37
+ failed: string;
38
+ captured: string;
39
+ reporter: string;
40
+ page: string;
41
+ }
42
+ export declare function strings(locale: "th" | "en" | undefined): Strings;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The dialog's stylesheet, as a string for the shadow root.
3
+ *
4
+ * A shadow root is the whole reason this is a custom element. The widget drops
5
+ * into apps we do not control, next to Tailwind or Bootstrap or Angular
6
+ * Material, and any of them would otherwise reach in and restyle it — or be
7
+ * restyled BY it. Inside a shadow root neither can happen, so there is no reset
8
+ * to fight, no specificity race, and no class-name prefix scheme to maintain.
9
+ *
10
+ * Everything is explicit for the same reason: the host's `body` font and colours
11
+ * do not inherit past the boundary, so stating them is not belt-and-braces.
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";
@@ -0,0 +1,11 @@
1
+ export interface ToastAction {
2
+ label: string;
3
+ /**
4
+ * Runs inside the click, so it can open screen capture. That is the whole
5
+ * point of offering a crash as a toast with a button rather than opening the
6
+ * reporter on its own: a browser grants capture to a real click and nothing
7
+ * else.
8
+ */
9
+ onClick: () => void;
10
+ }
11
+ export declare function showToast(message: string, tone?: "success" | "error", action?: ToastAction): void;
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@lightworkai.official/debug-capture",
3
+ "version": "0.6.0",
4
+ "description": "Report-a-problem widget: rolling network/console/action capture, annotated screenshot, and submission to a Lightwork Support host",
5
+ "type": "module",
6
+ "module": "./dist/index.mjs",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.mjs",
12
+ "default": "./dist/index.mjs"
13
+ },
14
+ "./embed": "./dist/debug-capture.js",
15
+ "./style.css": "./src/styles.css"
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "src",
20
+ "!src/**/*.test.ts",
21
+ "!src/test"
22
+ ],
23
+ "scripts": {
24
+ "build": "rm -rf dist && bun run build:esm && bun run build:iife && bun run build:types",
25
+ "build:esm": "bun build src/index.ts --outfile dist/index.mjs --format esm --minify",
26
+ "build:iife": "bun build src/embed.ts --outfile dist/debug-capture.js --format iife --minify",
27
+ "build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist",
28
+ "typecheck": "tsc -p tsconfig.json --noEmit",
29
+ "test": "bun test src"
30
+ },
31
+ "devDependencies": {
32
+ "bun-types": "latest",
33
+ "happy-dom": "^20.14.0",
34
+ "typescript": "^5.6.0"
35
+ },
36
+ "main": "./dist/index.mjs",
37
+ "keywords": [
38
+ "bug-report",
39
+ "debug",
40
+ "screenshot",
41
+ "network-capture",
42
+ "console-capture",
43
+ "support",
44
+ "helpdesk",
45
+ "ticket",
46
+ "annotate"
47
+ ],
48
+ "license": "MIT",
49
+ "author": "Lightwork AI",
50
+ "publishConfig": {
51
+ "access": "public"
52
+ }
53
+ }
package/src/budget.ts ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Keeping a bundle inside what the server will accept.
3
+ *
4
+ * The capture buffers hold full response bodies, which is the point — a 500's
5
+ * body is usually the answer. But a long session can hold megabytes of them, and
6
+ * the ingest endpoint caps an attachment at 5 MB and the request at 10 MB total.
7
+ * Arriving over the limit means the report is refused at the very moment
8
+ * somebody finally bothered to file one.
9
+ *
10
+ * So trim, in the order that loses the least: response bodies are the biggest
11
+ * and least often needed, oldest first, because the failure that prompted the
12
+ * report is at the END of the buffer. Only if that is not enough do whole
13
+ * entries go, and the trail of actions is never touched — it is small, and it is
14
+ * the part a human actually reads.
15
+ */
16
+ import type { DebugBundle, CapturedRequest } from "./types";
17
+
18
+ /**
19
+ * Well under the 5 MB attachment cap. The gap is deliberate: base64 adds a
20
+ * third, and the screenshot is in the same request.
21
+ */
22
+ export const BUNDLE_BUDGET_BYTES = 2_000_000;
23
+
24
+ const OMITTED = "[body dropped — report size budget]";
25
+
26
+ function sizeOf(bundle: DebugBundle): number {
27
+ // The screenshot is a separate attachment; measuring it here would trim
28
+ // bodies to make room for something that is not in this budget.
29
+ const { screenshotDataUrl: _omit, ...rest } = bundle;
30
+ return JSON.stringify(rest).length;
31
+ }
32
+
33
+ export function trimToBudget(bundle: DebugBundle, budget = BUNDLE_BUDGET_BYTES): DebugBundle {
34
+ if (sizeOf(bundle) <= budget) return bundle;
35
+
36
+ // Copy before mutating: the buffers are live and the user may file again.
37
+ const network: CapturedRequest[] = bundle.network.map((r) => ({ ...r }));
38
+ const out: DebugBundle = { ...bundle, network };
39
+
40
+ // Oldest first — the request that broke things is the most recent one.
41
+ for (const entry of network) {
42
+ if (sizeOf(out) <= budget) return out;
43
+ if (typeof entry.responseSnippet === "string" && entry.responseSnippet.length > OMITTED.length) {
44
+ entry.responseSnippet = OMITTED;
45
+ }
46
+ if (typeof entry.requestBody === "string" && entry.requestBody.length > OMITTED.length) {
47
+ entry.requestBody = OMITTED;
48
+ }
49
+ }
50
+
51
+ // Still too big: drop whole requests from the oldest end. Keep the newest,
52
+ // which is where the failure is.
53
+ while (out.network.length > 1 && sizeOf(out) > budget) {
54
+ out.network = out.network.slice(1);
55
+ }
56
+
57
+ // Console last, same reasoning.
58
+ while (out.console.length > 1 && sizeOf(out) > budget) {
59
+ out.console = out.console.slice(1);
60
+ }
61
+ return out;
62
+ }
package/src/bundle.ts ADDED
@@ -0,0 +1,274 @@
1
+ // Bundle assembly + output. Turns the collected context/cause/actions/network/
2
+ // console/screenshot into a Markdown report — for the clipboard, and as the
3
+ // human-readable half of what gets filed.
4
+ //
5
+ // The original also built a .zip for download. That was a second delivery path
6
+ // for people who could not reach the ticket system; ours submits directly, so
7
+ // the zip (and JSZip with it) is gone rather than shipped unused.
8
+ // + curl/console/steps logs.
9
+
10
+ import { actionCountSuffix, getActionTrail } from './capture/actionTrail';
11
+ import { causeCount, computeCause } from './capture/cause';
12
+ import { getConsoleLog } from './capture/consoleBuffer';
13
+ import { collectContext } from './context';
14
+ import { getNetworkLog } from './capture/networkBuffer';
15
+ import type {
16
+ ActionTrailEntry,
17
+ CapturedConsoleEntry,
18
+ CapturedRequest,
19
+ CauseSummary,
20
+ DebugBundle,
21
+ DebugReason,
22
+ ErroredQuery,
23
+ ScreenshotMethod,
24
+ } from './types';
25
+
26
+ const SCHEMA_VERSION = 2;
27
+
28
+ const REASON_LABELS: Record<DebugReason['type'], string> = {
29
+ manual: 'ผู้ใช้แจ้งเอง',
30
+ 'runtime-error': 'ข้อผิดพลาด runtime (auto)',
31
+ unhandledrejection: 'Promise rejection (auto)',
32
+ 'framework-error': 'หน้าจอค้าง/พัง (auto)',
33
+ };
34
+
35
+ export interface BuildBundleInput {
36
+ note: string;
37
+ reason: DebugReason;
38
+ screenshotDataUrl: string | null;
39
+ screenshotMethod: ScreenshotMethod;
40
+ erroredQueries: ErroredQuery[];
41
+ }
42
+
43
+ /** Snapshot the live buffers + context into an immutable bundle. */
44
+ export function buildBundle(input: BuildBundleInput): DebugBundle {
45
+ const network = getNetworkLog();
46
+ const consoleLog = getConsoleLog();
47
+ return {
48
+ context: collectContext(),
49
+ reason: input.reason,
50
+ note: input.note,
51
+ cause: computeCause(network, consoleLog, input.erroredQueries),
52
+ actionTrail: getActionTrail(),
53
+ network,
54
+ console: consoleLog,
55
+ screenshotDataUrl: input.screenshotDataUrl,
56
+ screenshotMethod: input.screenshotMethod,
57
+ meta: {
58
+ generatedBy: 'lightwork-debug-capture',
59
+ schemaVersion: SCHEMA_VERSION,
60
+ },
61
+ };
62
+ }
63
+
64
+ function escapeSingleQuotes(value: string): string {
65
+ return value.replace(/'/g, `'\\''`);
66
+ }
67
+
68
+ export function toCurl(req: CapturedRequest): string {
69
+ const parts = [`curl -X ${req.method} '${req.url}'`];
70
+ for (const [key, value] of Object.entries(req.requestHeaders)) {
71
+ parts.push(` -H '${key}: ${escapeSingleQuotes(value)}'`);
72
+ }
73
+ if (req.requestBody) {
74
+ parts.push(` --data '${escapeSingleQuotes(req.requestBody)}'`);
75
+ }
76
+ return parts.join(' \\\n');
77
+ }
78
+
79
+ function reqStatus(req: CapturedRequest): string {
80
+ if (req.error) return `ERR ${req.error}`;
81
+ return `${req.status ?? '—'} ${req.statusText ?? ''}`.trim();
82
+ }
83
+
84
+ function networkToText(requests: CapturedRequest[]): string {
85
+ if (requests.length === 0) return 'No network activity captured.';
86
+ return requests
87
+ .map((req) => {
88
+ const dur = req.durationMs != null ? `${req.durationMs}ms` : '—';
89
+ const header = `# [${reqStatus(req)}] ${req.method} ${req.url} (${dur}, ${req.startedAt})`;
90
+ const snippet = req.responseSnippet ? `\n# response: ${req.responseSnippet}` : '';
91
+ return `${header}\n${toCurl(req)}${snippet}`;
92
+ })
93
+ .join('\n\n');
94
+ }
95
+
96
+ function consoleToText(entries: CapturedConsoleEntry[]): string {
97
+ if (entries.length === 0) return 'No console output captured.';
98
+ return entries
99
+ .map((entry) => `[${entry.at}] ${entry.level.toUpperCase()}: ${entry.message}`)
100
+ .join('\n');
101
+ }
102
+
103
+ function stepsToText(entries: ActionTrailEntry[]): string {
104
+ if (entries.length === 0) return 'No user actions captured.';
105
+ // Numbering counts only the reporter's OWN actions, so the numbered lines are
106
+ // exactly the steps to repeat. App reactions (a dialog opening, a toast) are
107
+ // indented underneath as "↳" — what should happen back, not a step to redo.
108
+ let actionNo = 0;
109
+ return entries
110
+ .map((entry) => {
111
+ const src = entry.source ? ` (${entry.source})` : '';
112
+ const label = `${entry.detail}${actionCountSuffix(entry.count)}${src}`;
113
+ if (entry.kind === 'ui') return ` ↳ [${entry.at}] ${label}`;
114
+ actionNo += 1;
115
+ const line = `${actionNo}. [${entry.at}] ${label}`;
116
+ // The control's state flip after the click — the difference between
117
+ // "pressed บันทึก" and "pressed บันทึก and it went into loading".
118
+ return entry.afterNote ? `${line}\n ↳ ${entry.afterNote}` : line;
119
+ })
120
+ .join('\n');
121
+ }
122
+
123
+ function causeToLines(cause: CauseSummary): string[] {
124
+ const lines: string[] = [];
125
+ for (const req of cause.failedRequests) {
126
+ lines.push(`✗ ${req.status ?? req.error ?? 'ERR'} ${req.method} ${req.url}`);
127
+ }
128
+ for (const err of cause.errors) lines.push(`⚠ ${err}`);
129
+ for (const query of cause.erroredQueries) lines.push(`⚠ React Query error: ${query.key} — ${query.error}`);
130
+ return lines;
131
+ }
132
+
133
+ function summaryLines(bundle: DebugBundle): string[] {
134
+ const { context: ctx } = bundle;
135
+ return [
136
+ `User : ${ctx.user.fullName ?? '—'} (${ctx.user.email ?? '—'})`,
137
+ `Unit : ${ctx.user.unit ?? '—'}`,
138
+ `Roles : ${ctx.user.roles?.join(', ') || '—'}`,
139
+ `Route : ${ctx.route.href}`,
140
+ `Module : ${ctx.module ? `${ctx.module.label} (${ctx.module.key})` : "—"}`,
141
+ `App : ${ctx.app.name} v${ctx.app.version} · ${ctx.app.environment}`,
142
+ `Memory : ${ctx.performance.memoryUsedMb ?? '—'}/${ctx.performance.memoryLimitMb ?? '—'} MB`,
143
+ `Client : ${ctx.client.userAgent}`,
144
+ `Viewport : ${ctx.client.viewport.width}×${ctx.client.viewport.height} @${ctx.client.viewport.dpr}x`,
145
+ `Reason : ${REASON_LABELS[bundle.reason.type]}${bundle.reason.message ? ` — ${bundle.reason.message}` : ''}`,
146
+ `Captured : ${bundle.network.length} requests · ${bundle.console.length} console · ${bundle.actionTrail.length} actions · screenshot=${bundle.screenshotMethod}`,
147
+ ];
148
+ }
149
+
150
+ /** Ticket-friendly Markdown report (excludes the screenshot binary). */
151
+ export function bundleToMarkdown(bundle: DebugBundle): string {
152
+ const cause = causeToLines(bundle.cause);
153
+ const md: string[] = [
154
+ `## 🐞 ${bundle.context.app.name} — Debug Report`,
155
+ `**Generated:** ${bundle.context.capturedAt}`,
156
+ '',
157
+ ...summaryLines(bundle).map((line) => `- ${line.replace(/\s{2,}:/, ':')}`),
158
+ '',
159
+ `**หมายเหตุจากผู้แจ้ง:** ${bundle.note.trim() || '(ไม่มี)'}`,
160
+ ];
161
+
162
+ if (cause.length > 0) {
163
+ md.push('', '### ⚠ สาเหตุที่น่าจะเป็น', '```', ...cause, '```');
164
+ }
165
+
166
+ md.push(
167
+ '',
168
+ `<details><summary>ขั้นตอน (${bundle.actionTrail.length})</summary>`,
169
+ '',
170
+ '```',
171
+ stepsToText(bundle.actionTrail),
172
+ '```',
173
+ '</details>',
174
+ '',
175
+ `<details><summary>Network (${bundle.network.length})</summary>`,
176
+ '',
177
+ '```',
178
+ networkToText(bundle.network),
179
+ '```',
180
+ '</details>',
181
+ '',
182
+ `<details><summary>Console (${bundle.console.length})</summary>`,
183
+ '',
184
+ '```',
185
+ consoleToText(bundle.console),
186
+ '```',
187
+ '</details>',
188
+ '',
189
+ '_ข้อมูลลับ (token, cookie) ถูกปกปิดอัตโนมัติ · ภาพหน้าจออยู่ในไฟล์ .zip_',
190
+ );
191
+ return md.join('\n');
192
+ }
193
+
194
+ function readmeText(bundle: DebugBundle): string {
195
+ return [
196
+ `🐞 ${bundle.context.app.name} — Debug Report`,
197
+ `Generated: ${bundle.context.capturedAt}`,
198
+ '',
199
+ ...summaryLines(bundle),
200
+ '',
201
+ `Note: ${bundle.note.trim() || '(none)'}`,
202
+ '',
203
+ 'Files:',
204
+ `- screenshot.png Screenshot at time of report (method: ${bundle.screenshotMethod})`,
205
+ '- report.md Ticket-friendly Markdown report',
206
+ '- report.json Full structured bundle (machine-readable)',
207
+ '- steps.txt Recent user actions (steps to reproduce)',
208
+ '- network.curl.txt Recent requests as reproducible curl commands',
209
+ '- console.log.txt Recent console output + uncaught errors',
210
+ '',
211
+ 'Secrets (authorization headers, cookies, token query params) are redacted.',
212
+ ].join('\n');
213
+ }
214
+
215
+ /** `2026-08-10_17-42-05` — sortable, and safe in a filename on every platform. */
216
+ function fileStamp(iso: string): string {
217
+ return iso.replace(/[:.]/g, '-').replace('T', '_').slice(0, 19);
218
+ }
219
+
220
+ export function bundleFileName(bundle: DebugBundle, ext: string): string {
221
+ return `debug-report_${fileStamp(bundle.context.capturedAt)}.${ext}`;
222
+ }
223
+
224
+ export function hasCause(bundle: DebugBundle): boolean {
225
+ return causeCount(bundle.cause) > 0;
226
+ }
227
+
228
+ export function downloadBlob(blob: Blob, filename: string): void {
229
+ const url = URL.createObjectURL(blob);
230
+ const anchor = document.createElement('a');
231
+ anchor.href = url;
232
+ anchor.download = filename;
233
+ document.body.appendChild(anchor);
234
+ anchor.click();
235
+ anchor.remove();
236
+ window.setTimeout(() => URL.revokeObjectURL(url), 1000);
237
+ }
238
+
239
+ /** Copy a PNG data URL to the clipboard as an actual image (pasteable into
240
+ * chat apps), not as base64 text. */
241
+ export async function copyImageToClipboard(dataUrl: string): Promise<boolean> {
242
+ if (!navigator.clipboard || typeof ClipboardItem === 'undefined') return false;
243
+ try {
244
+ const blob = await (await fetch(dataUrl)).blob();
245
+ await navigator.clipboard.write([new ClipboardItem({ [blob.type || 'image/png']: blob })]);
246
+ return true;
247
+ } catch {
248
+ return false;
249
+ }
250
+ }
251
+
252
+ export async function copyTextToClipboard(text: string): Promise<boolean> {
253
+ try {
254
+ if (navigator.clipboard?.writeText) {
255
+ await navigator.clipboard.writeText(text);
256
+ return true;
257
+ }
258
+ } catch {
259
+ // fall through to legacy path
260
+ }
261
+ try {
262
+ const textarea = document.createElement('textarea');
263
+ textarea.value = text;
264
+ textarea.style.position = 'fixed';
265
+ textarea.style.opacity = '0';
266
+ document.body.appendChild(textarea);
267
+ textarea.select();
268
+ const ok = document.execCommand('copy');
269
+ textarea.remove();
270
+ return ok;
271
+ } catch {
272
+ return false;
273
+ }
274
+ }