@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.
- package/README.md +241 -0
- package/dist/budget.d.ts +22 -0
- package/dist/bundle.d.ts +20 -0
- package/dist/capture/actionTrail.d.ts +7 -0
- package/dist/capture/cause.d.ts +3 -0
- package/dist/capture/consoleBuffer.d.ts +4 -0
- package/dist/capture/crashWatcher.d.ts +1 -0
- package/dist/capture/networkBuffer.d.ts +4 -0
- package/dist/capture/redact.d.ts +9 -0
- package/dist/capture/stepCorrelation.d.ts +41 -0
- package/dist/config.d.ts +217 -0
- package/dist/context.d.ts +2 -0
- package/dist/debug-capture.js +116 -0
- package/dist/embed.d.ts +1 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.mjs +129 -0
- package/dist/install.d.ts +5 -0
- package/dist/mytickets/api.d.ts +115 -0
- package/dist/mytickets/format.d.ts +84 -0
- package/dist/mytickets/sanitize.d.ts +66 -0
- package/dist/mytickets/strings.d.ts +74 -0
- package/dist/mytickets/toolbar.d.ts +17 -0
- package/dist/reporter.d.ts +27 -0
- package/dist/screenshot.d.ts +7 -0
- package/dist/signature.d.ts +18 -0
- package/dist/submit.d.ts +8 -0
- package/dist/types.d.ts +175 -0
- package/dist/ui/annotator.d.ts +34 -0
- package/dist/ui/arrow.d.ts +25 -0
- package/dist/ui/element.d.ts +9 -0
- package/dist/ui/strings.d.ts +42 -0
- package/dist/ui/styles.d.ts +13 -0
- package/dist/ui/toast.d.ts +11 -0
- package/package.json +53 -0
- package/src/budget.ts +62 -0
- package/src/bundle.ts +274 -0
- package/src/capture/actionTrail.ts +693 -0
- package/src/capture/cause.ts +49 -0
- package/src/capture/consoleBuffer.ts +80 -0
- package/src/capture/crashWatcher.ts +61 -0
- package/src/capture/networkBuffer.ts +315 -0
- package/src/capture/redact.ts +117 -0
- package/src/capture/stepCorrelation.ts +160 -0
- package/src/config.ts +299 -0
- package/src/context.ts +81 -0
- package/src/embed.ts +35 -0
- package/src/index.ts +109 -0
- package/src/install.ts +59 -0
- package/src/mytickets/api.ts +226 -0
- package/src/mytickets/format.ts +191 -0
- package/src/mytickets/sanitize.ts +221 -0
- package/src/mytickets/strings.ts +217 -0
- package/src/mytickets/toolbar.ts +48 -0
- package/src/reporter.ts +53 -0
- package/src/screenshot.ts +238 -0
- package/src/signature.ts +40 -0
- package/src/styles.css +400 -0
- package/src/submit.ts +143 -0
- package/src/types.ts +169 -0
- package/src/ui/annotator.ts +698 -0
- package/src/ui/arrow.ts +62 -0
- package/src/ui/element.ts +362 -0
- package/src/ui/strings.ts +113 -0
- package/src/ui/styles.ts +96 -0
- 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
|
+
}
|