@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
package/src/styles.css ADDED
@@ -0,0 +1,400 @@
1
+ /*
2
+ * The panel's stylesheet — one copy, in the core, shared by every framework
3
+ * package.
4
+ *
5
+ * It could have lived as scoped styles inside each framework's components, and
6
+ * that reads nicer in a Vue SFC. But it would then exist three times, and
7
+ * "change a colour in three files and hope you found them all" is the kind of
8
+ * maintenance bill this package is being restructured to stop paying. The
9
+ * components are framework-native; what they LOOK like is shared.
10
+ *
11
+ * Every class is `lw-` prefixed and every colour comes from a custom property,
12
+ * so a host restyles the panel by setting variables rather than by fighting
13
+ * specificity:
14
+ *
15
+ * .my-app { --lw-primary: #7c3aed; --lw-accent: #6d28d9; }
16
+ */
17
+
18
+ .lw-panel {
19
+ --lw-text: #1f2937;
20
+ --lw-heading: #0f172a;
21
+ --lw-muted: #6b7280;
22
+ --lw-muted-strong: #4b5563;
23
+ --lw-faint: #9ca3af;
24
+ --lw-border: #e5e7eb;
25
+ --lw-border-soft: #f3f4f6;
26
+ --lw-surface: #fff;
27
+ --lw-surface-muted: #f9fafb;
28
+ --lw-accent: #1c5cab;
29
+ --lw-accent-soft: #eef4fb;
30
+ --lw-link: #1c5cab;
31
+ --lw-primary: #00264b;
32
+ --lw-primary-hover: #013a70;
33
+
34
+ font-size: 14px;
35
+ line-height: 1.5;
36
+ color: var(--lw-text);
37
+ }
38
+
39
+ .lw-panel *,
40
+ .lw-panel *::before,
41
+ .lw-panel *::after { box-sizing: border-box; }
42
+
43
+ .lw-panel__title { margin: 0 0 4px; font-size: 20px; font-weight: 700; color: var(--lw-heading); }
44
+ .lw-panel__intro { margin: 0 0 16px; color: var(--lw-muted); }
45
+
46
+ /* ---- controls ---- */
47
+ .lw-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 16px; }
48
+ .lw-search { position: relative; display: inline-flex; align-items: center; }
49
+ .lw-search__icon {
50
+ position: absolute; left: 8px; top: 50%; transform: translateY(-50%);
51
+ pointer-events: none; color: var(--lw-faint);
52
+ }
53
+ .lw-input {
54
+ font: inherit; color: inherit; background: var(--lw-surface);
55
+ border: 1px solid var(--lw-border); border-radius: 6px; padding: 6px 8px;
56
+ }
57
+ .lw-input:focus { outline: none; border-color: #7796b8; box-shadow: 0 0 0 3px rgba(0, 38, 75, 0.1); }
58
+ .lw-search__field { width: 240px; padding-left: 30px; }
59
+ .lw-count { margin-left: auto; color: var(--lw-muted); }
60
+
61
+ /* ---- states ---- */
62
+ .lw-state {
63
+ display: flex; align-items: center; gap: 8px; margin: 0; padding: 24px;
64
+ border: 1px solid var(--lw-border); border-radius: 10px; color: var(--lw-muted);
65
+ }
66
+ .lw-state--error { color: #dc2626; }
67
+ .lw-retry { border: 0; background: none; padding: 0; font: inherit; color: var(--lw-link); cursor: pointer; }
68
+ .lw-skeleton { height: 40px; border-radius: 6px; background: var(--lw-border-soft); animation: lw-pulse 1.6s ease-in-out infinite; }
69
+ .lw-skeleton + .lw-skeleton { margin-top: 8px; }
70
+ @keyframes lw-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.55; } }
71
+
72
+ /* ---- table ---- */
73
+ .lw-tablewrap { overflow-x: auto; border: 1px solid var(--lw-border); border-radius: 10px; background: var(--lw-surface); }
74
+ .lw-table { width: 100%; border-collapse: collapse; }
75
+ .lw-th {
76
+ padding: 10px 12px; text-align: left; white-space: nowrap;
77
+ background: var(--lw-surface-muted); border-bottom: 1px solid var(--lw-border);
78
+ font-size: 12px; font-weight: 600; color: var(--lw-muted);
79
+ cursor: pointer; user-select: none;
80
+ }
81
+ .lw-th:hover { color: var(--lw-heading); }
82
+ .lw-arrow { color: var(--lw-faint); font-size: 10px; }
83
+ .lw-th[aria-sort] .lw-arrow { color: var(--lw-heading); }
84
+ .lw-table td { padding: 10px 12px; white-space: nowrap; }
85
+ .lw-row { border-top: 1px solid var(--lw-border-soft); cursor: pointer; }
86
+ .lw-row:hover { background: var(--lw-surface-muted); }
87
+ .lw-title { max-width: 420px; overflow: hidden; text-overflow: ellipsis; font-weight: 500; color: var(--lw-text); }
88
+ .lw-num { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; font-weight: 500; color: var(--lw-accent); }
89
+ .lw-dim { font-size: 12px; color: var(--lw-muted); }
90
+ .lw-dash { color: var(--lw-border); }
91
+ .lw-empty { padding: 40px 24px; text-align: center; color: var(--lw-faint); white-space: normal; }
92
+
93
+ /* ---- pills ---- */
94
+ .lw-chip { display: inline-block; border-radius: 4px; padding: 2px 8px; font-size: 12px; background: var(--lw-accent-soft); color: var(--lw-accent); }
95
+ .lw-pill {
96
+ display: inline-flex; align-items: center; gap: 4px; white-space: nowrap;
97
+ border: 1px solid transparent; border-radius: 999px; padding: 2px 8px; font-size: 12px;
98
+ }
99
+ .lw-pill--awaiting { border-color: #fcd34d; background: #fffbeb; color: #b45309; font-weight: 500; }
100
+ .lw-pill--solution { border-color: #a7f3d0; background: #ecfdf5; color: #047857; }
101
+ .lw-pill--replied { border-color: #cddcee; background: var(--lw-accent-soft); color: var(--lw-accent); }
102
+ .lw-status { display: inline-block; white-space: nowrap; border-radius: 999px; padding: 2px 8px; font-size: 12px; }
103
+ .lw-muted { font-size: 12px; color: var(--lw-border); }
104
+
105
+ /* ---- pager ---- */
106
+ .lw-pager { display: flex; align-items: center; justify-content: flex-end; gap: 8px; margin-top: 12px; font-size: 13px; color: var(--lw-muted); }
107
+ /* inline-flex, like every other button here that carries an icon: as plain
108
+ inline content the ✕ and its label are two words that wrap, and ยกเลิก ended
109
+ up on a line of its own under the icon. */
110
+ .lw-btn {
111
+ display: inline-flex; align-items: center; gap: 6px; white-space: nowrap;
112
+ padding: 6px 12px; border: 1px solid var(--lw-border); border-radius: 6px;
113
+ background: var(--lw-surface); font: inherit; cursor: pointer;
114
+ }
115
+ .lw-btn:hover:not(:disabled) { background: var(--lw-border-soft); }
116
+ .lw-btn:disabled { opacity: 0.5; cursor: not-allowed; }
117
+
118
+ /* ---- detail ---- */
119
+ .lw-detail { display: flex; flex-direction: column; gap: 16px; }
120
+ .lw-back {
121
+ align-self: flex-start; display: inline-flex; align-items: center; gap: 4px;
122
+ border: 0; background: none; padding: 0; font: inherit; color: var(--lw-link); cursor: pointer;
123
+ }
124
+ .lw-back:hover { text-decoration: underline; }
125
+ .lw-card { padding: 16px; border: 1px solid var(--lw-border); border-radius: 10px; background: var(--lw-surface); }
126
+ .lw-card__heading { margin: 0; font-size: 17px; font-weight: 600; color: var(--lw-heading); }
127
+ .lw-card__title { margin: 0 0 10px; font-size: 15px; font-weight: 600; color: var(--lw-heading); }
128
+ .lw-facts { display: flex; flex-wrap: wrap; gap: 6px 16px; margin-top: 10px; font-size: 12px; color: var(--lw-muted); }
129
+ .lw-key {
130
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
131
+ border: 1px solid var(--lw-border); border-radius: 999px; padding: 2px 8px; background: var(--lw-surface-muted);
132
+ }
133
+ .lw-intro { margin-top: 12px; }
134
+ .lw-route { margin: 10px 0 0; font-size: 12px; color: var(--lw-muted); word-break: break-all; }
135
+ .lw-route a { color: var(--lw-link); }
136
+ .lw-shot { display: block; max-height: 520px; max-width: 100%; width: auto; border: 1px solid var(--lw-border); border-radius: 8px; }
137
+ .lw-shot + .lw-shot { margin-top: 8px; }
138
+
139
+ /* ---- body (sanitised HTML from the thread) ---- */
140
+ .lw-body { color: var(--lw-text); word-break: break-word; }
141
+ .lw-body p { margin: 0 0 8px; }
142
+ .lw-body p:last-child { margin-bottom: 0; }
143
+ .lw-body ul, .lw-body ol { margin: 4px 0; padding-left: 22px; }
144
+ .lw-body a { color: var(--lw-link); text-decoration: underline; }
145
+ /* Small, and clickable. A screenshot at full width pushes every other message
146
+ off the screen and turns the conversation into a gallery; capping it is only
147
+ reasonable because the full size is one click away, and `zoom-in` says so
148
+ before you try. Matches the app's own thread. */
149
+ .lw-body img {
150
+ margin: 4px 0; max-height: 160px; max-width: 100%; width: auto; height: auto;
151
+ object-fit: contain; border: 1px solid var(--lw-border); border-radius: 8px;
152
+ cursor: zoom-in;
153
+ }
154
+
155
+ /* ---- full-size view of an image from a message ---- */
156
+ .lw-zoom {
157
+ position: fixed; inset: 0; z-index: 2147482950;
158
+ display: flex; align-items: center; justify-content: center;
159
+ padding: 24px; background: rgba(15, 23, 42, 0.72); cursor: zoom-out;
160
+ }
161
+ .lw-zoom img { max-height: 85dvh; max-width: 100%; width: auto; border-radius: 8px; }
162
+ .lw-zoom__close {
163
+ position: absolute; top: 16px; right: 20px;
164
+ border: 0; background: none; padding: 4px; line-height: 1;
165
+ color: #fff; font-size: 24px; cursor: pointer;
166
+ }
167
+
168
+ /* ---- hero ---- */
169
+ .lw-hero { display: flex; align-items: flex-start; gap: 12px; padding: 16px; border: 1px solid; border-radius: 10px; }
170
+ .lw-hero__icon { width: 22px; height: 22px; flex-shrink: 0; }
171
+ .lw-hero__body { flex: 1; min-width: 0; }
172
+ .lw-hero__kicker { margin: 0; font-size: 11px; font-weight: 500; letter-spacing: 0.04em; text-transform: uppercase; color: var(--lw-faint); }
173
+ .lw-hero__headline { margin: 0; font-size: 16px; font-weight: 600; }
174
+ .lw-hero__nudge { margin: 4px 0 0; font-size: 12px; font-weight: 500; color: #b45309; }
175
+ .lw-hero__note {
176
+ display: flex; align-items: flex-start; gap: 8px; margin-top: 12px; padding: 12px;
177
+ border: 1px solid #fff; border-radius: 8px; background: rgba(255, 255, 255, 0.7);
178
+ }
179
+ .lw-hero__wrench { width: 16px; height: 16px; flex-shrink: 0; color: #059669; }
180
+
181
+ /* ---- timeline ---- */
182
+ .lw-timeline { margin: 0; padding: 0; list-style: none; }
183
+ .lw-node { display: flex; gap: 12px; }
184
+ .lw-rail { position: relative; display: flex; width: 14px; flex-shrink: 0; justify-content: center; }
185
+ .lw-line { position: absolute; top: 8px; bottom: -8px; left: 50%; width: 2px; transform: translateX(-50%); background: #a7bdd6; border-radius: 999px; }
186
+ /* Dashed, so what has happened and what is still to come read differently. */
187
+ .lw-line--pending { background: repeating-linear-gradient(to bottom, #d1d5db 0 4px, transparent 4px 8px); border-radius: 0; }
188
+ .lw-dot {
189
+ position: relative; z-index: 1; margin-top: 2px; display: flex; height: 14px; width: 14px;
190
+ flex-shrink: 0; align-items: center; justify-content: center; border-radius: 999px; border: 2px solid;
191
+ }
192
+ .lw-dot--done { border-color: #4a7cb5; background: #4a7cb5; }
193
+ .lw-dot--current { border-color: var(--lw-accent); background: var(--lw-surface); }
194
+ .lw-dot--pending { border-color: #d1d5db; background: var(--lw-surface); }
195
+ .lw-dot--reopened { border-color: #f59e0b; background: #f59e0b; }
196
+ .lw-pip { height: 6px; width: 6px; border-radius: 999px; background: var(--lw-accent); }
197
+ .lw-node__text { min-width: 0; padding-bottom: 24px; }
198
+ .lw-node__text--last { padding-bottom: 2px; }
199
+ .lw-node__label { display: flex; align-items: center; gap: 4px; font-size: 14px; font-weight: 500; line-height: 20px; color: var(--lw-text); }
200
+ .lw-node__label--pending { color: var(--lw-faint); }
201
+ .lw-node__label--reopened { color: #b45309; }
202
+ .lw-node__icon { width: 12px; height: 12px; }
203
+ .lw-node__time { font-size: 12px; line-height: 20px; color: var(--lw-faint); }
204
+
205
+ /* ---- reopen ---- */
206
+ .lw-reopen { padding: 16px; border: 1px solid #fde68a; border-radius: 10px; background: #fffbeb; }
207
+ .lw-reopen__row { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; }
208
+ .lw-reopen__hint { color: #92400e; font-size: 13px; }
209
+ .lw-reopen__title { margin: 0 0 8px; font-weight: 500; color: #92400e; }
210
+ .lw-reopen__note { width: 100%; padding: 10px 12px; font: inherit; border: 1px solid #fde68a; border-radius: 10px; resize: vertical; }
211
+ .lw-reopen__actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; }
212
+ .lw-reopen__open, .lw-reopen__confirm, .lw-reopen__cancel {
213
+ display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; padding: 6px 12px; font: inherit; border-radius: 6px; cursor: pointer;
214
+ }
215
+ .lw-reopen__open { border: 1px solid #fcd34d; background: var(--lw-surface); color: #b45309; font-weight: 500; }
216
+ .lw-reopen__confirm { border: 1px solid #d97706; background: #d97706; color: #fff; font-weight: 500; }
217
+ .lw-reopen__confirm:disabled { opacity: 0.5; cursor: not-allowed; }
218
+ .lw-reopen__cancel { border: 0; background: none; color: var(--lw-muted); }
219
+
220
+ /* ---- conversation ---- */
221
+ /* Capped, so a long conversation does not push the composer off the screen.
222
+ A window, not a page: ~320px shows the last few exchanges and leaves the
223
+ reply box and everything under it reachable without scrolling the document.
224
+ `dvh` rather than `vh` — on a phone the address bar makes them differ, and
225
+ the difference is exactly the part that would hide the reply box. */
226
+ .lw-thread {
227
+ display: flex; flex-direction: column; gap: 12px;
228
+ max-height: min(45dvh, 320px); overflow-y: auto;
229
+ /* Room for the scrollbar so it never sits on top of a message bubble. */
230
+ padding-right: 4px;
231
+ }
232
+ .lw-thread__empty { margin: 0; font-size: 12px; color: var(--lw-border); }
233
+ /* The original's bubble, to the pixel: the meta line lives INSIDE a bordered
234
+ box, not floating above it, and the tone is set by whose message it is. */
235
+ .lw-msg { display: flex; justify-content: flex-start; }
236
+ .lw-msg--mine { justify-content: flex-end; }
237
+ .lw-msg__bubble {
238
+ max-width: 85%;
239
+ padding: 8px 12px;
240
+ border: 1px solid var(--lw-border);
241
+ border-radius: 8px;
242
+ background: var(--lw-surface-muted);
243
+ word-break: break-word;
244
+ }
245
+ .lw-msg--mine .lw-msg__bubble { border-color: #cddcee; background: var(--lw-accent-soft); }
246
+ .lw-msg__meta {
247
+ display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
248
+ margin: 0 0 2px; font-size: 10px; color: var(--lw-muted);
249
+ }
250
+ .lw-msg__who { font-weight: 500; color: var(--lw-muted-strong); }
251
+ /* The role chip the original puts beside a reporter's name. */
252
+ .lw-msg__role {
253
+ border-radius: 4px; padding: 0 4px; font-size: 9px;
254
+ background: rgba(255, 255, 255, 0.7); color: var(--lw-faint);
255
+ }
256
+ .lw-msg__edited { font-style: italic; color: var(--lw-faint); }
257
+ /* The pencil sits at the end of the meta line, as it does in the app's thread. */
258
+ .lw-msg__edit {
259
+ margin-left: auto; display: inline-flex; align-items: center; gap: 2px;
260
+ border: 0; background: none; padding: 1px 4px; border-radius: 4px;
261
+ font: inherit; font-size: 10px; color: var(--lw-muted); cursor: pointer;
262
+ }
263
+ .lw-msg__edit:hover { color: var(--lw-heading); background: rgba(255, 255, 255, 0.6); }
264
+ .lw-msg__editing { margin-top: 4px; }
265
+ .lw-msg__editActions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; margin-top: 6px; }
266
+ .lw-composer { margin-top: 12px; }
267
+ .lw-composer__actions { display: flex; justify-content: flex-end; margin-top: 8px; }
268
+ .lw-send {
269
+ display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; padding: 8px 14px; font: inherit; font-weight: 500;
270
+ border: 1px solid var(--lw-primary); border-radius: 8px; background: var(--lw-primary); color: #fff; cursor: pointer;
271
+ }
272
+ .lw-send:hover:not(:disabled) { background: var(--lw-primary-hover); }
273
+ .lw-send:disabled { opacity: 0.5; cursor: not-allowed; }
274
+
275
+ /* ---- annotator ---- */
276
+ .lw-annotator {
277
+ position: fixed; inset: 0; z-index: 2147482900;
278
+ display: flex; align-items: center; justify-content: center;
279
+ padding: 24px; background: rgba(15, 23, 42, 0.55);
280
+ }
281
+ .lw-annotator__panel {
282
+ display: flex; flex-direction: column; gap: 12px;
283
+ width: min(820px, 100%); max-height: 90dvh;
284
+ padding: 20px; border-radius: 16px; background: var(--lw-surface);
285
+ box-shadow: 0 24px 60px rgba(15, 23, 42, 0.28);
286
+ }
287
+ .lw-annotator__head { display: flex; align-items: flex-start; gap: 12px; }
288
+ .lw-annotator__title { margin: 0; font-size: 16px; font-weight: 600; color: var(--lw-heading); }
289
+ .lw-annotator__desc { margin: 4px 0 0; font-size: 13px; color: var(--lw-muted); word-break: break-word; }
290
+ .lw-annotator__hint { margin: 0; font-size: 12px; color: var(--lw-muted); }
291
+ .lw-annotator__close {
292
+ border: 0; background: none; padding: 4px; line-height: 1; cursor: pointer;
293
+ color: var(--lw-muted); font-size: 18px;
294
+ }
295
+ .lw-annotator__close:hover { color: var(--lw-heading); }
296
+ .lw-annotator__tools { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
297
+ .lw-annotator__tools .lw-grow { flex: 1; }
298
+ .lw-annotator__canvas { flex: 1; min-height: 0; }
299
+ /* Contained, not stretched. A canvas is a replaced element, so `max-width` with
300
+ `height:auto` scales it proportionally — `width:100%` blew a small screenshot
301
+ up to the dialog's width and squashed its aspect ratio on the way. */
302
+ /* Contained, not stretched, and never taller than the dialog can hold — the
303
+ app's annotator caps its working surface the same way. A canvas is a replaced
304
+ element, so max-width/max-height with `height:auto` scale it proportionally. */
305
+ .lw-annotator__canvas canvas {
306
+ display: block; margin: 0 auto;
307
+ max-width: 100%; max-height: min(52dvh, 420px);
308
+ width: auto; height: auto; touch-action: none;
309
+ }
310
+ .lw-annotator__actions { display: flex; align-items: center; gap: 8px; }
311
+ .lw-annotator__actions .lw-grow { flex: 1; }
312
+ /*
313
+ * The app's toolbar, to the pixel — including its two states, which are not the
314
+ * obvious ones: a tool sits TINTED at rest (`bg-accent`) and goes SOLID when
315
+ * armed (`bg-primary`, white text). Ours had it the other way round, white at
316
+ * rest and a light tint when armed, which read as a different control set.
317
+ */
318
+ button.lw-atool {
319
+ display: inline-flex; align-items: center; gap: 6px; white-space: nowrap;
320
+ padding: 4px 10px; font: inherit; font-size: 12px;
321
+ border: 1px solid var(--lw-border); border-radius: 6px;
322
+ background: var(--lw-accent-soft); color: var(--lw-heading); cursor: pointer;
323
+ transition: background-color 0.15s, border-color 0.15s, color 0.15s;
324
+ }
325
+ button.lw-atool:hover:not(:disabled) { background: #e2ecf8; }
326
+ button.lw-atool:disabled { opacity: 0.5; cursor: not-allowed; }
327
+ button.lw-atool[aria-pressed="true"] {
328
+ border-color: var(--lw-primary); background: var(--lw-primary); color: #fff;
329
+ }
330
+
331
+ /*
332
+ * ล้างทั้งหมด is not a tool, and the app does not dress it like one — it is an
333
+ * outline button over there, so it is one here too.
334
+ */
335
+ button.lw-atool--plain {
336
+ background: var(--lw-surface); color: var(--lw-muted-strong);
337
+ }
338
+ button.lw-atool--plain:hover:not(:disabled) { background: var(--lw-surface-muted); }
339
+
340
+ .lw-annotator__canvasbox {
341
+ /* `relative`, and it is load-bearing. The annotator's text tool appends a
342
+ real <input> to the canvas's PARENT and positions it absolutely over the
343
+ spot that was clicked; without a positioned ancestor it escapes to the
344
+ backdrop and lands at the bottom of the dialog. */
345
+ position: relative;
346
+ overflow: auto; padding: 8px;
347
+ border: 1px solid var(--lw-border); border-radius: 10px;
348
+ background: var(--lw-surface-muted);
349
+ }
350
+
351
+ /* The on-image text editor, matched to the shape it becomes — same weight, same
352
+ red, same white plate — so what you type is what gets drawn, where you
353
+ clicked. Sized in JS, because the font tracks the image's own scale.
354
+ These rules used to live ONLY in the reporter dialog's shadow stylesheet, so
355
+ in a framework dialog the input had no position at all. */
356
+ .lw-annotator .text-edit {
357
+ position: absolute; z-index: 2; margin: 0; padding: 2px 4px;
358
+ font-family: system-ui, sans-serif; font-weight: 600; line-height: 1.25;
359
+ color: #f44336; background: rgba(255, 255, 255, 0.92);
360
+ border: 1px dashed #f44336; border-radius: 4px; outline: none;
361
+ box-shadow: 0 2px 10px rgba(15, 23, 42, 0.18);
362
+ }
363
+ .lw-annotator .text-edit::placeholder { color: rgba(244, 67, 54, 0.45); }
364
+
365
+ /* ---- editor ---- */
366
+ .lw-editor { border: 1px solid var(--lw-border); border-radius: 10px; background: var(--lw-surface); overflow: hidden; }
367
+ .lw-editor--disabled { opacity: 0.6; }
368
+ .lw-editor__tools {
369
+ display: flex; align-items: center; gap: 2px; flex-wrap: wrap; padding: 6px 8px;
370
+ border-bottom: 1px solid var(--lw-border); background: var(--lw-surface-muted);
371
+ }
372
+ .lw-editor__sep { width: 1px; align-self: stretch; margin: 2px 4px; background: var(--lw-border); }
373
+ .lw-editor__uploading { margin-left: 4px; font-size: 11px; color: var(--lw-muted); }
374
+ .lw-editor__content { padding: 10px 12px; }
375
+ .lw-tool {
376
+ display: inline-flex; align-items: center; justify-content: center; min-width: 30px; height: 28px;
377
+ padding: 0 7px; border: 1px solid transparent; border-radius: 6px; background: transparent;
378
+ color: var(--lw-muted-strong); cursor: pointer; font: inherit;
379
+ }
380
+ .lw-tool:hover:not(:disabled) { background: var(--lw-border); }
381
+ .lw-tool:disabled { opacity: 0.5; cursor: not-allowed; }
382
+ .lw-tool--on { background: var(--lw-accent-soft); color: var(--lw-accent); }
383
+
384
+ /* TipTap owns this node, so it is styled by class rather than by nesting. */
385
+ .lw-editor-body { min-height: 92px; max-height: 320px; overflow-y: auto; outline: none; word-break: break-word; color: var(--lw-text); }
386
+ .lw-editor-body p { margin: 0 0 8px; }
387
+ .lw-editor-body p:last-child { margin-bottom: 0; }
388
+ .lw-editor-body ul, .lw-editor-body ol { margin: 4px 0; padding-left: 22px; }
389
+ .lw-editor-body a { color: var(--lw-link); text-decoration: underline; }
390
+ .lw-editor-body img { max-width: 100%; height: auto; border-radius: 8px; margin: 4px 0; }
391
+ /* The placeholder extension marks the first empty node; this draws it. */
392
+ .lw-editor-body p.is-editor-empty:first-child::before {
393
+ content: attr(data-placeholder); float: left; height: 0; pointer-events: none; color: var(--lw-faint);
394
+ }
395
+
396
+ @media (max-width: 640px) {
397
+ .lw-search__field { width: 100%; }
398
+ .lw-count { margin-left: 0; }
399
+ .lw-msg { max-width: 100%; }
400
+ }
package/src/submit.ts ADDED
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Filing a bundle as a ticket on a Lightwork Support host.
3
+ *
4
+ * Replaces the original's `ticketService`, which posted multipart to a bespoke
5
+ * `/support-tickets` endpoint on the same app's API. This posts JSON to the
6
+ * public widget ingest, authenticated by the realm's embed key — no login, no
7
+ * cookie, and nothing here that a reader of the bundle could not already see.
8
+ */
9
+ import { compressScreenshot } from "./screenshot";
10
+ import { appInfo, getConfig } from "./config";
11
+ import { errorSignature } from "./signature";
12
+ import { trimToBudget, BUNDLE_BUDGET_BYTES } from "./budget";
13
+ import type { DebugBundle } from "./types";
14
+
15
+ export interface FiledTicket {
16
+ id: string;
17
+ number: number;
18
+ status: string;
19
+ }
20
+
21
+ /**
22
+ * Base64 for a payload of any size.
23
+ *
24
+ * This was `btoa(String.fromCharCode(...bytes))`, which spreads every byte into
25
+ * the argument list — and an argument list has a hard limit. It worked on the
26
+ * tiny bundles a test produces and threw "Maximum call stack size exceeded" on
27
+ * a real one, at the last possible moment: the user has written the report,
28
+ * drawn on the screenshot, and pressed send.
29
+ *
30
+ * Chunked, so the argument count is bounded no matter how large the bundle is.
31
+ */
32
+ const CHUNK = 0x8000;
33
+
34
+ export function toBase64(bytes: Uint8Array): string {
35
+ let binary = "";
36
+ for (let i = 0; i < bytes.length; i += CHUNK) {
37
+ binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
38
+ }
39
+ return btoa(binary);
40
+ }
41
+
42
+ /** A data URI for the JSON bundle, so the bulk lands in object storage. */
43
+ function bundleAttachment(bundle: DebugBundle): { dataUri: string; filename: string } {
44
+ // btoa is latin1-only and a bundle is full of Thai. Encode to bytes first.
45
+ const base64 = toBase64(new TextEncoder().encode(JSON.stringify(bundle)));
46
+ return { dataUri: `data:application/json;base64,${base64}`, filename: "debug-bundle.json" };
47
+ }
48
+
49
+ export async function submitBundle(title: string, bundle: DebugBundle): Promise<FiledTicket> {
50
+ const config = getConfig();
51
+ const trimmed = trimToBudget(bundle, BUNDLE_BUDGET_BYTES);
52
+ const { context, note } = trimmed;
53
+
54
+ const attachments: { dataUri: string; filename?: string }[] = [];
55
+ if (trimmed.screenshotDataUrl) {
56
+ // Resized and re-encoded before it goes: a raw capture of a large display
57
+ // is over the server's per-file cap on its own.
58
+ const shot = await compressScreenshot(trimmed.screenshotDataUrl);
59
+ attachments.push({ dataUri: shot, filename: "screenshot.jpg" });
60
+ }
61
+ attachments.push(bundleAttachment(trimmed));
62
+
63
+ /*
64
+ * `context` is a jsonb column on the ticket row, read back by every query that
65
+ * returns the ticket — so it gets the SUMMARY, not the bundle. The full thing
66
+ * travels as an attachment, which lands in object storage and is fetched only
67
+ * when somebody opens it.
68
+ *
69
+ * `signature` is the exception that must be here: the backend's
70
+ * computeSignature reads `context.signature` to merge duplicate reports, and
71
+ * it cannot reach inside an attachment to find it.
72
+ */
73
+ const signature = errorSignature(trimmed);
74
+ const body = {
75
+ title,
76
+ // The user's own words, and nothing else. This used to fall back to the
77
+ // markdown report, which arrived on the ticket as an unreadable wall —
78
+ // headings, backticks and every captured URL run together, with the actual
79
+ // complaint nowhere in it. The original sends the note or omits the field,
80
+ // and everything technical belongs in the structured data below.
81
+ description: note.trim() || undefined,
82
+ // Which module broke, if the app says; otherwise which app. Both are
83
+ // things the app states about itself — neither is inferred from a URL.
84
+ category: context.module?.label || appInfo().name,
85
+ routeUrl: context.route.href,
86
+ priority: "MEDIUM",
87
+ requesterEmail: context.user.email ?? undefined,
88
+ requesterName: context.user.fullName ?? undefined,
89
+ customFields: {
90
+ unit: context.user.unit ?? "",
91
+ appVersion: context.app.version,
92
+ environment: context.app.environment,
93
+ reasonType: trimmed.reason.type,
94
+ appName: appInfo().name,
95
+ module: context.module?.label ?? "",
96
+ },
97
+ context: {
98
+ ...(signature ? { signature } : {}),
99
+ capturedAt: context.capturedAt,
100
+ reason: trimmed.reason,
101
+ cause: trimmed.cause,
102
+ actionTrail: trimmed.actionTrail,
103
+ client: context.client,
104
+ performance: context.performance,
105
+ apiHeaders: context.apiHeaders,
106
+ extra: context.extra,
107
+ counts: {
108
+ network: trimmed.network.length,
109
+ console: trimmed.console.length,
110
+ actions: trimmed.actionTrail.length,
111
+ },
112
+ screenshotMethod: trimmed.screenshotMethod,
113
+ },
114
+ attachments,
115
+ };
116
+
117
+ // The widget ingest mounts at the ROOT of the support app, beside the
118
+ // authenticated /api routes rather than inside them, because it is the one
119
+ // surface a public key reaches. Assuming the prefix cost a 404 that no unit
120
+ // test could have caught — it only appears against a real server.
121
+ const res = await fetch(`${config.host.replace(/\/$/, "")}/ingest/tickets`, {
122
+ method: "POST",
123
+ headers: { "content-type": "application/json", "x-realm-key": config.realmKey },
124
+ body: JSON.stringify(body),
125
+ });
126
+
127
+ if (!res.ok) {
128
+ // The ingest endpoint answers 429 with a retry-after; anything else carries
129
+ // an { error } body. Surface whichever it is — "failed" alone sends someone
130
+ // reading a network tab they may not be able to open.
131
+ const retryAfter = res.headers.get("retry-after");
132
+ const detail = await res
133
+ .json()
134
+ .then((b: { error?: string }) => b.error)
135
+ .catch(() => null);
136
+ throw new Error(
137
+ res.status === 429
138
+ ? `Too many reports — try again in ${retryAfter ?? "a moment"}s`
139
+ : (detail ?? `Report failed (${res.status})`),
140
+ );
141
+ }
142
+ return (await res.json()) as FiledTicket;
143
+ }