@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,693 @@
1
+ // Rolling "steps to reproduce" trail. Records recent clicks, field changes, and
2
+ // route navigations into a bounded ring buffer. Privacy-safe: only element
3
+ // labels and field names are kept — never typed values.
4
+
5
+ import { captureOption, overlaySelectors } from '../config';
6
+ import { redactLabel, redactUrl } from './redact';
7
+ import type { ActionKind, ActionTrailEntry } from '../types';
8
+
9
+ // Room for a merged "คลิก … → /route" row (a 40-char label + destination).
10
+ const MAX_DETAIL_LEN = 120;
11
+ // Per-label cap — keeps a container's text from flooding the label even when the
12
+ // element is legitimately clickable (a card / list row).
13
+ const LABEL_MAX = 40;
14
+
15
+ const cap = (s: string, max: number): string => (s.length > max ? `${s.slice(0, max)}…` : s);
16
+ const decodeForDisplay = (s: string): string => {
17
+ try {
18
+ return decodeURIComponent(s);
19
+ } catch {
20
+ return s;
21
+ }
22
+ };
23
+
24
+ let buffer: ActionTrailEntry[] = [];
25
+ let counter = 0;
26
+ let installed = false;
27
+
28
+ function push(
29
+ kind: ActionKind,
30
+ detail: string,
31
+ source?: string,
32
+ html?: string,
33
+ ): ActionTrailEntry | null {
34
+ const trimmed = cap(detail, MAX_DETAIL_LEN);
35
+ const last = buffer[buffer.length - 1];
36
+
37
+ // Collapse click → navigate: a click that immediately caused this navigation is
38
+ // ONE action, not two rows. Fold the real destination into the click row's
39
+ // visible detail and drop the redundant "ไปที่ …" row.
40
+ if (kind === 'navigate' && last?.kind === 'click') {
41
+ const navFull = trimmed.replace(/^ไปที่\s*/, '');
42
+ // App Router double-fires pushState+replaceState for the same url — if we've
43
+ // already folded this destination in, just refresh the timestamp.
44
+ if (last.detail.includes(`→ ${navFull}`)) {
45
+ last.at = new Date().toISOString();
46
+ return last;
47
+ }
48
+ const navPath = navFull.split(/[?#]/)[0] ?? navFull;
49
+ const clickDest = last.source
50
+ ? decodeForDisplay(last.source.replace(/^→\s*/, '')).split(/[?#]/)[0]
51
+ : null;
52
+ if (clickDest && (navPath === clickDest || navPath.startsWith(clickDest))) {
53
+ last.detail = cap(`${last.detail} → ${navFull}`, MAX_DETAIL_LEN);
54
+ last.source = undefined; // now shown inline in detail — avoid double display
55
+ last.at = new Date().toISOString();
56
+ return last;
57
+ }
58
+ }
59
+
60
+ // Collapse a consecutive duplicate instead of adding a new row. Navigations
61
+ // routinely double-fire, so those just refresh the timestamp; repeated
62
+ // clicks/edits keep a ×count — a genuine signal (e.g. a user clicked a dead
63
+ // button several times).
64
+ if (last && last.kind === kind && last.detail === trimmed) {
65
+ last.at = new Date().toISOString();
66
+ if (kind !== 'navigate') last.count = (last.count ?? 1) + 1;
67
+ return last;
68
+ }
69
+ counter += 1;
70
+ const entry: ActionTrailEntry = {
71
+ id: `act_${counter}`,
72
+ at: new Date().toISOString(),
73
+ kind,
74
+ detail: trimmed,
75
+ count: 1,
76
+ source,
77
+ html: claimBudget(html),
78
+ };
79
+ buffer.push(entry);
80
+ while (buffer.length > captureOption('maxActions')) releaseBudget(buffer.shift());
81
+ return entry;
82
+ }
83
+
84
+ // ── Snapshot size budget ─────────────────────────────────────────────────────
85
+ // Every snapshot ships inside the ticket payload, and this file now captures up
86
+ // to three per action (control, its after-state, any overlay it opened). Without
87
+ // a ceiling a long session on a dialog-heavy page could push a multi-megabyte
88
+ // report through the ticket API. Oldest-out: evicting a trail row returns its
89
+ // bytes, so a long session keeps capturing rather than going text-only forever
90
+ // after one big dialog.
91
+ const HTML_BUDGET = 200_000;
92
+ let htmlBytes = 0;
93
+
94
+ function claimBudget(html: string | undefined): string | undefined {
95
+ if (!html) return undefined;
96
+ if (htmlBytes + html.length > HTML_BUDGET) return undefined;
97
+ htmlBytes += html.length;
98
+ return html;
99
+ }
100
+
101
+ function releaseBudget(entry: ActionTrailEntry | undefined): void {
102
+ if (!entry) return;
103
+ htmlBytes -= (entry.html?.length ?? 0) + (entry.afterHtml?.length ?? 0);
104
+ if (htmlBytes < 0) htmlBytes = 0;
105
+ }
106
+
107
+ /** Display suffix for a collapsed repeated action, e.g. " (×4)". Empty for a
108
+ * single occurrence — shared by the report text + the in-dialog preview. */
109
+ export function actionCountSuffix(count?: number): string {
110
+ return count && count > 1 ? ` (×${count})` : '';
111
+ }
112
+
113
+ // Skip the reporter's own UI (button, modal, annotator canvas) — interacting
114
+ // with the report tool isn't part of reproducing the user's issue.
115
+ function isReporterUi(target: EventTarget | null): boolean {
116
+ return target instanceof Element && Boolean(target.closest('[data-debug-reporter]'));
117
+ }
118
+
119
+ // Which elements count as a "click target": a native/ARIA control, or a custom
120
+ // clickable element (a React onClick within a few hops). A plain layout container
121
+ // with neither is an accidental / background click and is skipped.
122
+ const INTERACTIVE_SELECTOR =
123
+ 'button, a[href], [role="button"], [role="tab"], [role="menuitem"], [role="option"], [role="switch"], [role="checkbox"], [role="radio"], [role="link"], summary, input, select, textarea';
124
+
125
+ // Friendly Thai nouns so a step reads "คลิก ปุ่ม …" / "คลิก ลิงก์ …" instead of a
126
+ // raw DOM tag. Role wins over tag (a <div role="button"> is a ปุ่ม).
127
+ const NOUN_BY_TAG: Record<string, string> = {
128
+ button: 'ปุ่ม',
129
+ a: 'ลิงก์',
130
+ input: 'ช่อง',
131
+ textarea: 'ช่อง',
132
+ select: 'ตัวเลือก',
133
+ summary: 'ส่วนขยาย',
134
+ };
135
+ const NOUN_BY_ROLE: Record<string, string> = {
136
+ button: 'ปุ่ม',
137
+ tab: 'แท็บ',
138
+ menuitem: 'เมนู',
139
+ option: 'ตัวเลือก',
140
+ switch: 'สวิตช์',
141
+ checkbox: 'ช่องเลือก',
142
+ radio: 'ตัวเลือก',
143
+ link: 'ลิงก์',
144
+ };
145
+
146
+ function nounOf(el: Element): string {
147
+ const role = el.getAttribute('role');
148
+ if (role && NOUN_BY_ROLE[role]) return NOUN_BY_ROLE[role];
149
+ // Custom clickable div/span (React onClick) has no mapping → read as "ปุ่ม".
150
+ return NOUN_BY_TAG[el.tagName.toLowerCase()] ?? 'ปุ่ม';
151
+ }
152
+
153
+ // Nearest element (self + a few DOM ancestors) carrying a React onClick — catches
154
+ // custom clickable div/span (nav cards, list rows) while ignoring layout-only
155
+ // containers. Bounded hops so a far page-level handler doesn't falsely qualify.
156
+ function nearestReactClickable(el: Element, maxHops: number): Element | null {
157
+ try {
158
+ let node: Element | null = el;
159
+ let hops = 0;
160
+ while (node && hops <= maxHops) {
161
+ const fiber = fiberOf(node);
162
+ if (fiber && typeof fiber.memoizedProps?.onClick === 'function') return node;
163
+ node = node.parentElement;
164
+ hops += 1;
165
+ }
166
+ } catch {
167
+ // Fiber internals absent/changed — treat as not clickable.
168
+ }
169
+ return null;
170
+ }
171
+
172
+ // Accessible name, preferring explicit labels over the (potentially huge)
173
+ // descendant text. Only falls back to `textContent` for leaf-ish controls; a link
174
+ // with no text uses its (decoded) destination path.
175
+ function accessibleName(el: Element): string {
176
+ const aria = el.getAttribute('aria-label')?.trim();
177
+ if (aria) return aria;
178
+ const title = el.getAttribute('title')?.trim();
179
+ if (title) return title;
180
+ if (
181
+ el instanceof HTMLInputElement ||
182
+ el instanceof HTMLTextAreaElement ||
183
+ el instanceof HTMLSelectElement
184
+ ) {
185
+ return (el.getAttribute('placeholder') || el.getAttribute('name') || '').trim();
186
+ }
187
+ if (el instanceof HTMLImageElement) return (el.getAttribute('alt') || '').trim();
188
+ const text = (el.textContent ?? '').replace(/\s+/g, ' ').trim();
189
+ if (text) return text;
190
+ if (el instanceof HTMLAnchorElement) {
191
+ const href = el.getAttribute('href');
192
+ if (href?.startsWith('/')) return decodeForDisplay(href);
193
+ }
194
+ return '';
195
+ }
196
+
197
+ // The element a click should be attributed to: a real control, or a custom
198
+ // clickable element — else null (a click on a layout container / empty page area
199
+ // is not a reproduction step).
200
+ function resolveClickTarget(target: EventTarget | null): Element | null {
201
+ if (!(target instanceof Element)) return null;
202
+ const el = target.closest(INTERACTIVE_SELECTOR) ?? nearestReactClickable(target, 4);
203
+ if (!el) return null;
204
+ const tag = el.tagName.toLowerCase();
205
+ if (tag === 'html' || tag === 'body') return null;
206
+ return el;
207
+ }
208
+
209
+ function describeLabel(el: Element): string | null {
210
+ const name = redactLabel(accessibleName(el));
211
+ if (!name) return null; // icon-only with no aria/title/href → nothing to say → skip
212
+ return `${nounOf(el)} “${cap(name, LABEL_MAX)}”`;
213
+ }
214
+
215
+ // ── Element HTML snapshot ─────────────────────────────────────────────────────
216
+ // A small, sanitized + redacted outerHTML of the clicked control so the admin
217
+ // trail can render the ACTUAL element (styled by the app's own Tailwind, since
218
+ // it's viewed in-app). Bounded — a giant card falls back to a text-only row.
219
+ const MAX_RAW_HTML = 6000; // skip capture when the element's raw HTML exceeds this
220
+ const MAX_HTML = 4000; // drop the snapshot if still big after cleaning
221
+
222
+ // Classes that would let a captured element escape its preview box (positioning /
223
+ // full-viewport size). Stripped so a snapshot can never cover the admin page.
224
+ const ESCAPE_CLASS_RE =
225
+ /(?:^|\s)!?(?:fixed|absolute|sticky|inset-\S+|z-\S+|top-\S+|bottom-\S+|left-\S+|right-\S+|w-screen|h-screen|min-h-screen|max-h-screen|translate-\S+|scale-\S+)(?=\s|$)/g;
226
+
227
+ function sanitizeCaptureHtml(root: Element): void {
228
+ // Drop elements that can't / shouldn't render in a static preview.
229
+ root
230
+ .querySelectorAll('script, style, link, iframe, object, embed, noscript, canvas, video, audio')
231
+ .forEach((n) => n.remove());
232
+ const nodes: Element[] = [root, ...Array.from(root.querySelectorAll('*'))];
233
+ for (const node of nodes) {
234
+ for (const attr of Array.from(node.attributes)) {
235
+ const name = attr.name.toLowerCase();
236
+ // Strip handlers, input values, and data-* (may carry PII); keep class/style.
237
+ if (
238
+ name.startsWith('on') ||
239
+ name === 'srcdoc' ||
240
+ name === 'value' ||
241
+ name.startsWith('data-')
242
+ ) {
243
+ node.removeAttribute(attr.name);
244
+ }
245
+ }
246
+ if (node instanceof HTMLAnchorElement) node.setAttribute('href', '#');
247
+ if (node instanceof HTMLImageElement) node.removeAttribute('src'); // avoid broken/auth-gated loads
248
+ const cls = node.getAttribute('class');
249
+ if (cls) {
250
+ const cleaned = cls.replace(ESCAPE_CLASS_RE, ' ').replace(/\s+/g, ' ').trim();
251
+ if (cleaned) node.setAttribute('class', cleaned);
252
+ else node.removeAttribute('class');
253
+ }
254
+ }
255
+ // Redact visible text (a name / number a container might carry).
256
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
257
+ let text = walker.nextNode();
258
+ while (text) {
259
+ if (text.nodeValue) text.nodeValue = redactLabel(text.nodeValue);
260
+ text = walker.nextNode();
261
+ }
262
+ }
263
+
264
+ function captureElementHtml(el: Element, maxRaw: number, maxOut: number): string | undefined {
265
+ try {
266
+ const raw = el.outerHTML;
267
+ if (!raw || raw.length > maxRaw) return undefined; // too big → text-only
268
+ const clone = el.cloneNode(true) as Element;
269
+ sanitizeCaptureHtml(clone);
270
+ const html = clone.outerHTML;
271
+ return html && html.length <= maxOut ? html : undefined;
272
+ } catch {
273
+ return undefined;
274
+ }
275
+ }
276
+
277
+ const captureClickHtml = (el: Element): string | undefined =>
278
+ captureElementHtml(el, MAX_RAW_HTML, MAX_HTML);
279
+
280
+ // A dialog is the whole point of the click that opened it, so it gets a bigger
281
+ // allowance than a button — but still bounded, and still subject to the shared
282
+ // budget above.
283
+ const MAX_RAW_OVERLAY_HTML = 150_000;
284
+ const MAX_OVERLAY_HTML = 40_000;
285
+
286
+ const captureOverlayHtml = (el: Element): string | undefined =>
287
+ captureElementHtml(el, MAX_RAW_OVERLAY_HTML, MAX_OVERLAY_HTML);
288
+
289
+ function describeField(target: EventTarget | null): string | null {
290
+ if (
291
+ !(target instanceof HTMLInputElement) &&
292
+ !(target instanceof HTMLTextAreaElement) &&
293
+ !(target instanceof HTMLSelectElement)
294
+ ) {
295
+ return null;
296
+ }
297
+ const name =
298
+ target.name ||
299
+ target.id ||
300
+ target.getAttribute('aria-label') ||
301
+ target.getAttribute('placeholder') ||
302
+ target.tagName.toLowerCase();
303
+ // Value is never recorded — only that the field changed (name is redacted too).
304
+ return `ช่อง “${redactLabel(name)}”`;
305
+ }
306
+
307
+ // ── Nav-target attribution (best-effort, read-only, fully guarded) ───────────
308
+ // Enriches a click with WHERE it leads — resolved the SAME way in dev / uat /
309
+ // prod:
310
+ // • a link's DOM `href` (cleanest), else
311
+ // • the target parsed from the nearest `onClick` handler — the string literal
312
+ // survives minification, so `router.push('/x')` still resolves in prod.
313
+ // Deliberately NO dev-only React debug info: `_debugOwner` (component name) and
314
+ // `_debugSource` (file:line) are stripped from production builds and names are
315
+ // minified, so they'd behave differently per environment — which we don't want.
316
+ // A source file:line that works in prod would require a build-time JSX
317
+ // annotation (à la Sentry) — a separate build-infra change.
318
+ const MAX_SOURCE_LEN = 80;
319
+
320
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
321
+ type Fiber = any;
322
+
323
+ function fiberOf(node: Element): Fiber | null {
324
+ const key = Object.keys(node).find(
325
+ (k) => k.startsWith('__reactFiber$') || k.startsWith('__reactInternalInstance$'),
326
+ );
327
+ return key ? (node as unknown as Record<string, Fiber>)[key] : null;
328
+ }
329
+
330
+ function navTargetOf(onClick: unknown): string | null {
331
+ if (typeof onClick !== 'function') return null;
332
+ try {
333
+ const src = Function.prototype.toString.call(onClick);
334
+ // router.push('/x') / .replace('/x') / redirect('/x') — the string literal
335
+ // survives minification even when the router identifier is mangled.
336
+ const match =
337
+ src.match(/\.(?:push|replace)\(\s*['"`]([^'"`]+)['"`]/) ??
338
+ src.match(/redirect\(\s*['"`]([^'"`]+)['"`]/);
339
+ return match?.[1] ?? null;
340
+ } catch {
341
+ return null;
342
+ }
343
+ }
344
+
345
+ function navLabel(target: string | null): string | undefined {
346
+ if (!target) return undefined;
347
+ const trimmed = target.length > MAX_SOURCE_LEN ? `${target.slice(0, MAX_SOURCE_LEN)}…` : target;
348
+ return `→ ${trimmed}`;
349
+ }
350
+
351
+ function describeClickSource(el: Element): string | undefined {
352
+ try {
353
+ // Prefer the link's DOM href — cleanest, and identical across environments.
354
+ const anchorHref = el.closest('a[href]')?.getAttribute('href');
355
+ if (anchorHref && anchorHref.startsWith('/')) return navLabel(anchorHref);
356
+
357
+ // Otherwise, the nearest onClick's target (e.g. a `div` that router.push-es).
358
+ let fiber = fiberOf(el);
359
+ let hops = 0;
360
+ while (fiber && hops < 30) {
361
+ if (typeof fiber.memoizedProps?.onClick === 'function') {
362
+ return navLabel(navTargetOf(fiber.memoizedProps.onClick));
363
+ }
364
+ fiber = fiber.return;
365
+ hops += 1;
366
+ }
367
+ return undefined;
368
+ } catch {
369
+ return undefined;
370
+ }
371
+ }
372
+
373
+ // ── Consequence capture ──────────────────────────────────────────────────────
374
+ // A click's resting snapshot says what was PRESSED; it says nothing about what
375
+ // pressing it did. Reports kept arriving as "คลิก ตราประทับ" with no sign of the
376
+ // dialog that opened, the button going into its loading state, or the error
377
+ // toast that followed — all of which the user saw and none of which was in the
378
+ // log. These two observers record that half.
379
+
380
+ /** Things that APPEAR over the page and are the visible result of an action.
381
+ * Radix (dialog / sheet / dropdown / popover) lands on the ARIA roles; sonner
382
+ * toasts carry their own attribute and matter most of all — an error toast is
383
+ * usually the report's whole subject. */
384
+ const OVERLAY_SELECTOR_FALLBACK =
385
+ '[role="dialog"], [role="alertdialog"], [role="menu"], [role="listbox"], [role="status"], [role="alert"]';
386
+
387
+ /** ARIA roles cover most component libraries; a host can name its own. */
388
+ function overlaySelector(): string {
389
+ const custom = overlaySelectors();
390
+ return custom.length ? custom.join(', ') : OVERLAY_SELECTOR_FALLBACK;
391
+ }
392
+
393
+ function overlayNoun(el: Element): string {
394
+ const role = el.getAttribute('role');
395
+ if (role === 'status' || role === 'alert') return 'ข้อความแจ้งเตือน';
396
+ if (role === 'menu') return 'เมนู';
397
+ if (role === 'listbox') return 'รายการตัวเลือก';
398
+ if (role === 'alertdialog') return 'กล่องยืนยัน';
399
+ return 'กล่องโต้ตอบ';
400
+ }
401
+
402
+ /** Accessible title of an overlay, preferring the labelled title over its full
403
+ * body text (a dialog's textContent is the entire form). */
404
+ function overlayTitle(el: Element): string {
405
+ const aria = el.getAttribute('aria-label')?.trim();
406
+ if (aria) return aria;
407
+ const labelledBy = el.getAttribute('aria-labelledby');
408
+ if (labelledBy) {
409
+ const label = labelledBy
410
+ .split(/\s+/)
411
+ .map((id) => document.getElementById(id)?.textContent?.trim() ?? '')
412
+ .filter(Boolean)
413
+ .join(' ');
414
+ if (label) return label;
415
+ }
416
+ const heading = el.querySelector('[data-slot="dialog-title"], h1, h2, h3, [role="heading"]');
417
+ const headingText = heading?.textContent?.replace(/\s+/g, ' ').trim();
418
+ if (headingText) return headingText;
419
+ // Toasts have no heading — their body IS the message.
420
+ return (el.textContent ?? '').replace(/\s+/g, ' ').trim();
421
+ }
422
+
423
+ function overlayDetail(el: Element): string {
424
+ const title = redactLabel(overlayTitle(el));
425
+ const noun = overlayNoun(el);
426
+ return title ? `เปิด ${noun} “${cap(title, LABEL_MAX)}”` : `เปิด ${noun}`;
427
+ }
428
+
429
+ // An overlay is mounted EMPTY and fills in from an API call, so snapshotting it
430
+ // the instant it appears captures a skeleton and the title "กำลังโหลด…" — the
431
+ // one state nobody needs.
432
+ //
433
+ // "Stopped changing" is NOT the signal. While a request is in flight the DOM is
434
+ // perfectly still — that stillness IS the loading state, so a quiet-period
435
+ // detector fires at exactly the wrong moment. The overlay has to be quiet AND
436
+ // not be showing anything that says it is still working.
437
+
438
+ /** Marks a subtree as still working. Covers the two ways this app shows it —
439
+ * ARIA state and skeleton classes — plus the literal text, since a plain
440
+ * "กำลังโหลด…" div carries no attribute at all. */
441
+ const LOADING_SELECTOR =
442
+ '[aria-busy="true"], [role="progressbar"], [data-loading="true"], .animate-pulse, .animate-spin';
443
+ const LOADING_TEXT_RE = /กำลังโหลด|กำลังค้นหา|กำลังดำเนินการ|\bloading\b/i;
444
+
445
+ function looksLoading(el: Element): boolean {
446
+ if (el.querySelector(LOADING_SELECTOR)) return true;
447
+ return LOADING_TEXT_RE.test(el.textContent ?? '');
448
+ }
449
+
450
+ /** Quiet period required before a snapshot — spans the gap between a response
451
+ * landing and the list painting. */
452
+ const OVERLAY_SETTLE_MS = 400;
453
+ /** How often to re-check. A fetch finishing produces mutations, but the LAST
454
+ * meaningful moment (the spinner being removed) can be several frames earlier
455
+ * than the quiet window closes, so this is polled rather than event-driven. */
456
+ const OVERLAY_POLL_MS = 250;
457
+ /** Hard stop. Generous, because a slow dialog is exactly the one worth seeing;
458
+ * at the deadline we capture whatever is on screen, loading or not — a
459
+ * mid-flight snapshot beats none. */
460
+ const OVERLAY_MAX_WAIT_MS = 8000;
461
+
462
+ /** Snapshot `el` once it has both settled and stopped claiming to load,
463
+ * amending the row already pushed so the trail keeps its true ordering. */
464
+ function captureOverlayWhenSettled(el: Element, entry: ActionTrailEntry): void {
465
+ if (typeof MutationObserver === 'undefined') {
466
+ entry.html = claimBudget(captureOverlayHtml(el));
467
+ return;
468
+ }
469
+ let lastChangeAt = Date.now();
470
+ let done = false;
471
+
472
+ const stop = () => {
473
+ done = true;
474
+ observer.disconnect();
475
+ window.clearInterval(poll);
476
+ window.clearTimeout(deadline);
477
+ };
478
+
479
+ const finish = () => {
480
+ if (done) return;
481
+ stop();
482
+ // Dismissed before it ever settled — nothing left to photograph, and the
483
+ // row still records that it opened.
484
+ if (!el.isConnected) return;
485
+ // Re-read the title too: it was "กำลังโหลด…" when the row was pushed.
486
+ entry.detail = cap(overlayDetail(el), MAX_DETAIL_LEN);
487
+ entry.html = claimBudget(captureOverlayHtml(el));
488
+ };
489
+
490
+ const observer = new MutationObserver(() => {
491
+ lastChangeAt = Date.now();
492
+ });
493
+ observer.observe(el, {
494
+ childList: true,
495
+ subtree: true,
496
+ attributes: true,
497
+ characterData: true,
498
+ });
499
+
500
+ const poll = window.setInterval(() => {
501
+ if (done) return;
502
+ if (!el.isConnected) {
503
+ stop();
504
+ return;
505
+ }
506
+ const quiet = Date.now() - lastChangeAt >= OVERLAY_SETTLE_MS;
507
+ if (quiet && !looksLoading(el)) finish();
508
+ }, OVERLAY_POLL_MS);
509
+
510
+ const deadline = window.setTimeout(finish, OVERLAY_MAX_WAIT_MS);
511
+ }
512
+
513
+ function recordOverlay(el: Element): void {
514
+ if (isReporterUi(el)) return;
515
+ // Pushed NOW so the row sits in the right place in the sequence; the snapshot
516
+ // and final title arrive a moment later, once the content has loaded.
517
+ const entry = push('ui', overlayDetail(el));
518
+ if (entry) captureOverlayWhenSettled(el, entry);
519
+ }
520
+
521
+ function installOverlayWatcher(): void {
522
+ if (typeof MutationObserver === 'undefined') return;
523
+ const seen = new WeakSet<Element>();
524
+ const observer = new MutationObserver((mutations) => {
525
+ for (const mutation of mutations) {
526
+ for (const node of Array.from(mutation.addedNodes)) {
527
+ if (!(node instanceof Element)) continue;
528
+ // A portal mounts the whole overlay as ONE added node, so check the node
529
+ // itself before searching inside it.
530
+ const selector = overlaySelector();
531
+ const roots = node.matches(selector) ? [node] : Array.from(node.querySelectorAll(selector));
532
+ for (const root of roots) {
533
+ // Radix mounts a dialog hidden then reveals it, so the same element can
534
+ // surface twice; WeakSet keeps one row per element without leaking.
535
+ if (seen.has(root)) continue;
536
+ seen.add(root);
537
+ recordOverlay(root);
538
+ }
539
+ }
540
+ }
541
+ });
542
+ observer.observe(document.body, { childList: true, subtree: true });
543
+ }
544
+
545
+ // ── Post-click state of the clicked control ──────────────────────────────────
546
+
547
+ /** How long to wait for a click to visibly change its control. Long enough for
548
+ * a request to put a button into its loading state, short enough that an
549
+ * unrelated later re-render is not misattributed to this click. */
550
+ const AFTER_WATCH_MS = 1600;
551
+ /** The after-state is a second copy of the same control, so it is capped
552
+ * tighter than the original — it exists to show a state flip, not detail. */
553
+ const MAX_AFTER_HTML = 2500;
554
+
555
+ interface ControlState {
556
+ disabled: boolean;
557
+ busy: boolean;
558
+ text: string;
559
+ }
560
+
561
+ function readControlState(el: Element): ControlState {
562
+ return {
563
+ disabled:
564
+ el.hasAttribute('disabled') || el.getAttribute('aria-disabled') === 'true',
565
+ busy: el.getAttribute('aria-busy') === 'true',
566
+ text: cap((el.textContent ?? '').replace(/\s+/g, ' ').trim(), LABEL_MAX),
567
+ };
568
+ }
569
+
570
+ /** Thai description of the flip, or null when nothing meaningful changed.
571
+ * Ordered by how much each fact explains: gone > disabled > busy > relabelled. */
572
+ function describeStateChange(before: ControlState, after: ControlState): string | null {
573
+ if (!before.disabled && after.disabled) return 'ปุ่มถูกปิดใช้งานหลังคลิก';
574
+ if (!before.busy && after.busy) return 'ปุ่มเข้าสู่สถานะกำลังโหลด';
575
+ if (before.text !== after.text && after.text) {
576
+ return `ข้อความเปลี่ยนเป็น “${after.text}”`;
577
+ }
578
+ return null;
579
+ }
580
+
581
+ /** Watch the clicked control briefly and record the first meaningful change. */
582
+ function watchAfterState(el: Element, entry: ActionTrailEntry): void {
583
+ if (typeof MutationObserver === 'undefined') return;
584
+ const before = readControlState(el);
585
+ let settled = false;
586
+
587
+ const finish = (note: string, capture: boolean) => {
588
+ if (settled) return;
589
+ settled = true;
590
+ observer.disconnect();
591
+ window.clearTimeout(timer);
592
+ entry.afterNote = note;
593
+ if (capture) {
594
+ entry.afterHtml = claimBudget(captureElementHtml(el, MAX_RAW_HTML, MAX_AFTER_HTML));
595
+ }
596
+ };
597
+
598
+ const observer = new MutationObserver(() => {
599
+ // Removed from the page — a dialog closed over it, or the view swapped.
600
+ // Nothing left to snapshot, but the fact itself is worth recording.
601
+ if (!el.isConnected) {
602
+ finish('องค์ประกอบหายไปจากหน้าจอ', false);
603
+ return;
604
+ }
605
+ const note = describeStateChange(before, readControlState(el));
606
+ if (note) finish(note, true);
607
+ });
608
+ observer.observe(el, {
609
+ attributes: true,
610
+ attributeFilter: ['disabled', 'aria-disabled', 'aria-busy', 'class'],
611
+ childList: true,
612
+ subtree: true,
613
+ characterData: true,
614
+ });
615
+ // Detaching a subtree does not notify an observer bound to it, so the removal
616
+ // case needs its own check at the deadline.
617
+ const timer = window.setTimeout(() => {
618
+ if (!settled && !el.isConnected) {
619
+ finish('องค์ประกอบหายไปจากหน้าจอ', false);
620
+ return;
621
+ }
622
+ settled = true;
623
+ observer.disconnect();
624
+ }, AFTER_WATCH_MS);
625
+ }
626
+
627
+ export function installActionTrail(): void {
628
+ if (installed || typeof window === 'undefined') return;
629
+ installed = true;
630
+
631
+ document.addEventListener(
632
+ 'click',
633
+ (event) => {
634
+ if (isReporterUi(event.target)) return;
635
+ const el = resolveClickTarget(event.target);
636
+ if (!el) return;
637
+ const label = describeLabel(el);
638
+ if (!label) return;
639
+ const entry = push('click', `คลิก ${label}`, describeClickSource(el), captureClickHtml(el));
640
+ // Record what the click DID to the control (loading / disabled / gone).
641
+ if (entry) watchAfterState(el, entry);
642
+ },
643
+ { capture: true, passive: true },
644
+ );
645
+
646
+ document.addEventListener(
647
+ 'change',
648
+ (event) => {
649
+ if (isReporterUi(event.target)) return;
650
+ const detail = describeField(event.target);
651
+ if (detail) push('input', `แก้ไข ${detail}`);
652
+ },
653
+ { capture: true, passive: true },
654
+ );
655
+
656
+ document.addEventListener(
657
+ 'submit',
658
+ (event) => {
659
+ if (isReporterUi(event.target)) return;
660
+ push('submit', 'ส่งฟอร์ม');
661
+ },
662
+ { capture: true, passive: true },
663
+ );
664
+
665
+ // Route changes — App Router navigates via history.pushState/replaceState.
666
+ // Decode for display so Thai query params read as text, not `%E0%B8…`.
667
+ const record = () =>
668
+ push('navigate', `ไปที่ ${decodeForDisplay(redactUrl(location.pathname + location.search))}`);
669
+ const originalPush = history.pushState.bind(history);
670
+ const originalReplace = history.replaceState.bind(history);
671
+ history.pushState = ((...args: Parameters<History['pushState']>) => {
672
+ const result = originalPush(...args);
673
+ record();
674
+ return result;
675
+ }) as History['pushState'];
676
+ history.replaceState = ((...args: Parameters<History['replaceState']>) => {
677
+ const result = originalReplace(...args);
678
+ record();
679
+ return result;
680
+ }) as History['replaceState'];
681
+ window.addEventListener('popstate', record);
682
+
683
+ installOverlayWatcher();
684
+ }
685
+
686
+ export function getActionTrail(): ActionTrailEntry[] {
687
+ return buffer.map((entry) => ({ ...entry }));
688
+ }
689
+
690
+ export function clearActionTrail(): void {
691
+ buffer = [];
692
+ htmlBytes = 0;
693
+ }