@sphoro/bugcatch-web-sdk 0.17.0 → 0.19.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 (52) hide show
  1. package/README.md +104 -6
  2. package/dist/bugcatch-call.es.js +587 -0
  3. package/dist/bugcatch-call.es.js.map +1 -0
  4. package/dist/bugcatch-call.umd.js +2 -0
  5. package/dist/bugcatch-call.umd.js.map +1 -0
  6. package/dist/bugcatch-core.es.js +3292 -0
  7. package/dist/bugcatch-core.es.js.map +1 -0
  8. package/dist/bugcatch-panel.es.js +2531 -0
  9. package/dist/bugcatch-panel.es.js.map +1 -0
  10. package/dist/bugcatch-panel.umd.js +1311 -0
  11. package/dist/bugcatch-panel.umd.js.map +1 -0
  12. package/dist/bugcatch.es.js +2 -5625
  13. package/dist/bugcatch.es.js.map +1 -1
  14. package/dist/bugcatch.standalone.umd.js +1619 -0
  15. package/dist/bugcatch.standalone.umd.js.map +1 -0
  16. package/dist/bugcatch.umd.js +195 -1318
  17. package/dist/bugcatch.umd.js.map +1 -1
  18. package/package.json +6 -6
  19. package/src/accent.js +38 -0
  20. package/src/announcements.js +147 -0
  21. package/src/busy.js +18 -0
  22. package/src/call.js +7 -0
  23. package/src/capture.js +9 -0
  24. package/src/chunks.js +56 -0
  25. package/src/gesture.js +33 -0
  26. package/src/icons.js +18 -0
  27. package/src/identity.js +13 -8
  28. package/src/incoming-call.js +25 -45
  29. package/src/index.d.ts +93 -2
  30. package/src/index.js +164 -42
  31. package/src/lazy.js +23 -0
  32. package/src/notice.js +68 -0
  33. package/src/panel/call.js +242 -0
  34. package/src/panel/compose.js +416 -0
  35. package/src/panel/composer.js +235 -0
  36. package/src/panel/icons.js +36 -0
  37. package/src/panel/index.js +265 -0
  38. package/src/panel/known.js +130 -0
  39. package/src/panel/messages.js +176 -0
  40. package/src/panel/notices.js +34 -0
  41. package/src/panel/picks.js +65 -0
  42. package/src/panel/refs.js +321 -0
  43. package/src/panel/render.js +132 -0
  44. package/src/panel/signin.js +228 -0
  45. package/src/panel/styles.js +1016 -0
  46. package/src/panel/thread.js +494 -0
  47. package/src/session.js +8 -3
  48. package/src/umd/call.js +6 -0
  49. package/src/umd/lazy.js +98 -0
  50. package/src/umd/panel.js +6 -0
  51. package/src/updates.js +97 -34
  52. package/src/widget.js +385 -3376
@@ -0,0 +1,235 @@
1
+ // The reply box under a report's thread.
2
+
3
+ import * as ICONS from './icons.js';
4
+ import {
5
+ startVoiceNote, voiceSupported, humanSize, humanDuration, UPLOAD_MIMES,
6
+ } from '../capture.js';
7
+ import { messageHtml } from './messages.js';
8
+ import { escapeHtml, scrollToLatest } from './render.js';
9
+
10
+ /**
11
+ * The box at the bottom: text, files, and a voice note.
12
+ *
13
+ * Files are uploaded as they are picked, not on send, so the wait happens
14
+ * while the person is still writing rather than after they have pressed the
15
+ * button — and so a failed upload is visible as a failed chip instead of a
16
+ * message that never went.
17
+ */
18
+ export function mountComposer(ctx, body, thread, bugId) {
19
+ const { client, cfg } = ctx;
20
+ const composer = document.createElement('div');
21
+ composer.className = 'composer';
22
+ composer.innerHTML = `
23
+ <div class="staged" hidden></div>
24
+ <div class="row">
25
+ <div class="tools">
26
+ ${cfg.attachments === false ? '' : `
27
+ <button class="tool" type="button" data-act="attach" title="Attach a file" aria-label="Attach a file">${ICONS.paperclip}</button>
28
+ <button class="tool" type="button" data-act="voice" title="Record a voice note" aria-label="Record a voice note">${ICONS.mic}</button>`}
29
+ <button class="tool" type="button" data-act="livecall" title="Call the team" aria-label="Call the team" hidden>${ICONS.phone}</button>
30
+ </div>
31
+ <textarea rows="1" placeholder="Write a reply…" aria-label="Write a reply"></textarea>
32
+ <button class="send" type="button" data-act="send" title="Send" aria-label="Send" disabled>${ICONS.send}</button>
33
+ </div>
34
+ <div class="note" hidden></div>
35
+ <input type="file" hidden multiple accept="${UPLOAD_MIMES.join(',')}">`;
36
+ body.appendChild(composer);
37
+
38
+ const staged = composer.querySelector('.staged');
39
+ const input = composer.querySelector('textarea');
40
+ const sendBtn = composer.querySelector('[data-act="send"]');
41
+ const voiceBtn = composer.querySelector('[data-act="voice"]');
42
+ const liveBtn = composer.querySelector('[data-act="livecall"]');
43
+ const picker = composer.querySelector('input[type=file]');
44
+ const note = composer.querySelector('.note');
45
+
46
+ // Uploaded files waiting to ride on the next message.
47
+ const files = [];
48
+ let recorder = null;
49
+ let sending = false;
50
+
51
+ function say(text, kind = '') {
52
+ note.hidden = !text;
53
+ note.className = `note ${kind}`;
54
+ note.textContent = text || '';
55
+ }
56
+
57
+ function refresh() {
58
+ const ready = files.filter((f) => f.storageKey);
59
+ sendBtn.disabled = sending || (!input.value.trim() && !ready.length);
60
+ staged.hidden = !files.length;
61
+ staged.innerHTML = files.map((f, i) => `
62
+ <span class="chip ${f.storageKey ? '' : 'uploading'}">
63
+ <span class="nm">${escapeHtml(f.name)}</span>
64
+ <span class="sz">${f.storageKey ? humanSize(f.size) : 'sending…'}</span>
65
+ <button type="button" data-drop="${i}" aria-label="Remove ${escapeHtml(f.name)}">${ICONS.close}</button>
66
+ </span>`).join('');
67
+ for (const btn of staged.querySelectorAll('[data-drop]')) {
68
+ btn.addEventListener('click', () => {
69
+ files.splice(Number(btn.dataset.drop), 1);
70
+ refresh();
71
+ });
72
+ }
73
+ }
74
+
75
+ /** Upload one blob and stage it. Its chip is on screen for the whole trip. */
76
+ async function stage(blob, mime, name) {
77
+ const entry = { name, size: blob.size, storageKey: null };
78
+ files.push(entry);
79
+ refresh();
80
+ try {
81
+ const { storageKey, mime: stored } = await client.uploadAttachment(blob, mime);
82
+ entry.storageKey = storageKey;
83
+ entry.mime = stored;
84
+ } catch (err) {
85
+ files.splice(files.indexOf(entry), 1);
86
+ say(err.message || 'That file could not be sent.', 'err');
87
+ }
88
+ refresh();
89
+ }
90
+
91
+ input.addEventListener('input', () => {
92
+ // Grow with the text, up to the CSS cap. A reply about a bug is often
93
+ // three lines, and a one-line box makes people write one line.
94
+ input.style.height = 'auto';
95
+ input.style.height = `${Math.min(input.scrollHeight, 120)}px`;
96
+ refresh();
97
+ });
98
+
99
+ // Enter sends, Shift+Enter breaks the line — the chat convention. Anyone
100
+ // who wants a paragraph gets it; nobody has to hunt for the button.
101
+ input.addEventListener('keydown', (e) => {
102
+ if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
103
+ e.preventDefault();
104
+ send();
105
+ }
106
+ });
107
+
108
+ // Absent when the host set `attachments: false` — which the report form
109
+ // has always honoured and this composer did not, so a host keeping files
110
+ // and microphone audio outside its compliance perimeter still had both
111
+ // offered in the follow-up thread.
112
+ composer.querySelector('[data-act="attach"]')?.addEventListener('click', () => picker.click());
113
+ picker.addEventListener('change', () => {
114
+ say('');
115
+ for (const file of picker.files) {
116
+ if (file.size > client.config.maxAttachmentBytes) {
117
+ say(`${file.name} is larger than ${humanSize(client.config.maxAttachmentBytes)}.`, 'err');
118
+ continue;
119
+ }
120
+ stage(file, file.type, file.name);
121
+ }
122
+ picker.value = '';
123
+ });
124
+
125
+ if (voiceBtn && !voiceSupported()) {
126
+ voiceBtn.disabled = true;
127
+ voiceBtn.title = 'This browser cannot record audio';
128
+ }
129
+
130
+ voiceBtn?.addEventListener('click', async () => {
131
+ if (recorder) {
132
+ const handle = recorder;
133
+ recorder = null;
134
+ voiceBtn.classList.remove('recording');
135
+ voiceBtn.innerHTML = ICONS.mic;
136
+ say('');
137
+ const { blob, mime } = await handle.stop();
138
+ if (blob.size) stage(blob, mime, 'Voice note');
139
+ return;
140
+ }
141
+ try {
142
+ say('');
143
+ recorder = await startVoiceNote({
144
+ maxMs: client.config.maxRecordingMs,
145
+ onTick: (s) => say(`Recording — ${humanDuration(s)}. Tap the mic to stop.`),
146
+ onAutoStop: () => voiceBtn.click(),
147
+ });
148
+ voiceBtn.classList.add('recording');
149
+ voiceBtn.innerHTML = ICONS.stopSquare;
150
+ } catch {
151
+ recorder = null;
152
+ say('The microphone is not available — check the browser permission.', 'err');
153
+ }
154
+ });
155
+
156
+ /* ---------- calling the team ---------- */
157
+
158
+ // Asked once, when a report is opened, and the button stays hidden unless
159
+ // the answer is yes. Most workspaces have live calling off, and a phone
160
+ // icon that apologises on press is worse than no phone icon.
161
+ client.canCallTeam(bugId)
162
+ .then((available) => {
163
+ if (!available) return;
164
+ liveBtn.hidden = false;
165
+ // Offered, so fetch what placing it needs now: the press should ring,
166
+ // not wait for code.
167
+ ctx.preloadCall();
168
+ })
169
+ .catch(() => { /* not available is the safe answer to every failure */ });
170
+
171
+ liveBtn.addEventListener('click', async () => {
172
+ liveBtn.disabled = true;
173
+ say('');
174
+ try {
175
+ const handle = await client.startLiveCall(bugId);
176
+ ctx.showLiveCall(body, thread, handle);
177
+ } catch (err) {
178
+ say(err.message.replace('[BugCatch] ', ''), 'err');
179
+ } finally {
180
+ liveBtn.disabled = false;
181
+ }
182
+ });
183
+
184
+ sendBtn.addEventListener('click', send);
185
+
186
+ // The confirm question's quick answers: the same as typing and sending.
187
+ thread.addEventListener('click', (e) => {
188
+ const quick = e.target.closest?.('[data-quick]');
189
+ if (!quick || sending) return;
190
+ input.value = quick.dataset.quick;
191
+ send();
192
+ });
193
+
194
+ async function send() {
195
+ const text = input.value.trim();
196
+ const ready = files.filter((f) => f.storageKey);
197
+ if (sending || (!text && !ready.length)) return;
198
+
199
+ sending = true;
200
+ refresh();
201
+ say('');
202
+
203
+ // Shown immediately, greyed, and replaced by the refresh below. Waiting
204
+ // for the round trip before anything appears makes a slow network look
205
+ // like a dropped message, which is when people send it twice.
206
+ thread.insertAdjacentHTML('beforeend', messageHtml({
207
+ from: 'reporter',
208
+ body: text,
209
+ at: new Date().toISOString(),
210
+ attachments: ready.map((f) => ({ type: 'FILE', name: f.name, size: f.size })),
211
+ }, 'pending'));
212
+ scrollToLatest(thread, { smooth: true });
213
+
214
+ try {
215
+ await client.replyToReport(bugId, {
216
+ body: text,
217
+ attachments: ready.map((f) => ({
218
+ storageKey: f.storageKey, mime: f.mime, name: f.name, size: f.size,
219
+ })),
220
+ });
221
+ input.value = '';
222
+ input.style.height = 'auto';
223
+ files.length = 0;
224
+ await ctx.refreshThread(bugId);
225
+ } catch (err) {
226
+ thread.querySelector('.msg-row.pending')?.classList.replace('pending', 'failed');
227
+ say(err.message || 'That could not be sent — try again.', 'err');
228
+ } finally {
229
+ sending = false;
230
+ refresh();
231
+ }
232
+ }
233
+
234
+ refresh();
235
+ }
@@ -0,0 +1,36 @@
1
+ // The panel's glyphs: the launcher's set (../icons.js) and everything the
2
+ // form, the thread and the call screens draw.
3
+
4
+ export * from '../icons.js';
5
+
6
+ export const camera = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 8.5A1.5 1.5 0 0 1 4.5 7h2.2a1 1 0 0 0 .83-.45l.94-1.4A1 1 0 0 1 9.3 4.7h5.4a1 1 0 0 1 .83.45l.94 1.4a1 1 0 0 0 .83.45h2.2A1.5 1.5 0 0 1 21 8.5v9A1.5 1.5 0 0 1 19.5 19h-15A1.5 1.5 0 0 1 3 17.5z"/><circle cx="12" cy="13" r="3.4"/></svg>';
7
+ export const video = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="6" width="12.5" height="12" rx="2.2"/><path d="M15.5 10.8l4.2-2.6a.6.6 0 0 1 .92.5v6.6a.6.6 0 0 1-.92.5l-4.2-2.6z"/></svg>';
8
+ export const paperclip = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 11.5l-7.6 7.6a4.6 4.6 0 0 1-6.5-6.5l8-8a3.2 3.2 0 0 1 4.5 4.5l-8 8a1.8 1.8 0 0 1-2.5-2.5l7.1-7.1"/></svg>';
9
+ export const mic = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2.5" width="6" height="11" rx="3"/><path d="M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21M9 21h6"/></svg>';
10
+ export const send = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12l16-8-6 16-2.6-6.2L4 12Z"/></svg>';
11
+ export const stopSquare = '<svg viewBox="0 0 24 24" fill="currentColor"><rect x="7" y="7" width="10" height="10" rx="2"/></svg>';
12
+ export const back = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 5.5L8 12l6.5 6.5"/></svg>';
13
+ export const phone = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6.2 3.5h3l1.5 3.7-2 1.4a11.5 11.5 0 0 0 5.7 5.7l1.4-2 3.7 1.5v3a1.8 1.8 0 0 1-2 1.8A15.6 15.6 0 0 1 4.4 5.5a1.8 1.8 0 0 1 1.8-2z"/></svg>';
14
+ export const phoneOff = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6.2 3.5h3l1.5 3.7-2 1.4a11.5 11.5 0 0 0 5.7 5.7l1.4-2 3.7 1.5v3a1.8 1.8 0 0 1-2 1.8A15.6 15.6 0 0 1 4.4 5.5a1.8 1.8 0 0 1 1.8-2z"/><path d="M3 21L21 3"/></svg>';
15
+ export const bug = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M8 6a4 4 0 0 1 8 0"/><rect x="7" y="7.5" width="10" height="12" rx="5"/><path d="M7 11H3.5M7 15.5H4M17 11h3.5M17 15.5h3M12 8v11.5"/></svg>';
16
+ export const retry = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 12a8 8 0 1 1-2.6-5.9M20 4v4.5h-4.5"/></svg>';
17
+ // The assistant's mark: a four-point spark, drawn wherever it speaks.
18
+ export const sparkle = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.5c.5 4.6 2.9 7 7.5 7.5v.1c-4.6.5-7 2.9-7.5 7.5h-.1c-.5-4.6-2.9-7-7.5-7.5V10c4.6-.5 7-2.9 7.5-7.5z"/><path d="M18.6 15.2c.2 1.9 1.2 2.9 3.1 3.1v.1c-1.9.2-2.9 1.2-3.1 3.1h-.1c-.2-1.9-1.2-2.9-3.1-3.1v-.1c1.9-.2 2.9-1.2 3.1-3.1z" opacity=".7"/></svg>';
19
+ export const chevronRight = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.5 6l6 6-6 6"/></svg>';
20
+ // A rating star. Filled; the empty and chosen states are colour, set in CSS.
21
+ export const star = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.9l2.75 5.82 6.37.76-4.7 4.37 1.24 6.3L12 17.03l-5.66 3.12 1.24-6.3-4.7-4.37 6.37-.76z"/></svg>';
22
+ export const inbox = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 13.5l2.6-7.2A1.5 1.5 0 0 1 7.5 5.3h9a1.5 1.5 0 0 1 1.4 1L20.5 13.5V18a1.5 1.5 0 0 1-1.5 1.5H5A1.5 1.5 0 0 1 3.5 18z"/><path d="M3.5 13.5h4.8l1.2 2.2h5l1.2-2.2h4.8"/></svg>';
23
+
24
+ /* The sphoro lockup, as outlines rather than text.
25
+ *
26
+ * This SDK runs inside other people's pages, where Space Grotesk is not loaded
27
+ * and cannot be assumed. Live <text> would render the mark in whatever the host
28
+ * site happens to use — a different typeface on every customer. Outlines are
29
+ * self-contained, so the mark is identical everywhere, at the cost of the path
30
+ * data below.
31
+ *
32
+ * The BUGCATCH half reads --bc-brand-mark, not a bare --accent. Custom properties
33
+ * are exempt from `all: initial`, so they cross the shadow boundary: a host page
34
+ * that happens to define --accent would have repainted our mark in their brand
35
+ * colour. The --bc- prefix is ours and no host is going to set it by accident. */
36
+ export const BRAND = '<svg class="bc-brand" viewBox="0 0 254 60" fill="none" role="img" aria-label="sphoro BugCatch"><g fill="currentColor"><path transform="translate(0.00 40.00) scale(0.04000 -0.04000)" d="M276.00 -14.00Q179.00 -14.00 117.00 28.00Q55.00 70.00 42.00 148.00L158.00 178.00Q165.00 143.00 181.50 123.00Q198.00 103.00 222.50 94.50Q247.00 86.00 276.00 86.00Q320.00 86.00 341.00 101.50Q362.00 117.00 362.00 140.00Q362.00 163.00 342.00 175.50Q322.00 188.00 278.00 196.00L250.00 201.00Q198.00 211.00 155.00 228.50Q112.00 246.00 86.00 277.00Q60.00 308.00 60.00 357.00Q60.00 431.00 114.00 470.50Q168.00 510.00 256.00 510.00Q339.00 510.00 394.00 473.00Q449.00 436.00 466.00 376.00L349.00 340.00Q341.00 378.00 316.50 394.00Q292.00 410.00 256.00 410.00Q220.00 410.00 201.00 397.50Q182.00 385.00 182.00 363.00Q182.00 339.00 202.00 327.50Q222.00 316.00 256.00 310.00L284.00 305.00Q340.00 295.00 385.50 278.50Q431.00 262.00 457.50 231.50Q484.00 201.00 484.00 149.00Q484.00 71.00 427.50 28.50Q371.00 -14.00 276.00 -14.00Z"/><path transform="translate(19.56 40.00) scale(0.04000 -0.04000)" d="M70.00 -200.00V496.00H194.00V436.00H212.00Q229.00 465.00 265.00 487.50Q301.00 510.00 368.00 510.00Q428.00 510.00 479.00 480.50Q530.00 451.00 561.00 394.00Q592.00 337.00 592.00 256.00V240.00Q592.00 159.00 561.00 102.00Q530.00 45.00 479.00 15.50Q428.00 -14.00 368.00 -14.00Q323.00 -14.00 292.50 -3.50Q262.00 7.00 243.50 23.50Q225.00 40.00 214.00 57.00H196.00V-200.00ZM330.00 96.00Q389.00 96.00 427.50 133.50Q466.00 171.00 466.00 243.00V253.00Q466.00 325.00 427.00 362.50Q388.00 400.00 330.00 400.00Q272.00 400.00 233.00 362.50Q194.00 325.00 194.00 253.00V243.00Q194.00 171.00 233.00 133.50Q272.00 96.00 330.00 96.00Z"/><path transform="translate(43.68 40.00) scale(0.04000 -0.04000)" d="M70.00 0.00V700.00H196.00V435.00H214.00Q222.00 451.00 239.00 467.00Q256.00 483.00 284.50 493.50Q313.00 504.00 357.00 504.00Q415.00 504.00 458.50 477.50Q502.00 451.00 526.00 404.50Q550.00 358.00 550.00 296.00V0.00H424.00V286.00Q424.00 342.00 396.50 370.00Q369.00 398.00 318.00 398.00Q260.00 398.00 228.00 359.50Q196.00 321.00 196.00 252.00V0.00Z"/><path transform="translate(66.92 40.00) scale(0.04000 -0.04000)" d="M306.00 -14.00Q232.00 -14.00 173.00 16.00Q114.00 46.00 80.00 103.00Q46.00 160.00 46.00 240.00V256.00Q46.00 336.00 80.00 393.00Q114.00 450.00 173.00 480.00Q232.00 510.00 306.00 510.00Q380.00 510.00 439.00 480.00Q498.00 450.00 532.00 393.00Q566.00 336.00 566.00 256.00V240.00Q566.00 160.00 532.00 103.00Q498.00 46.00 439.00 16.00Q380.00 -14.00 306.00 -14.00ZM306.00 98.00Q364.00 98.00 402.00 135.50Q440.00 173.00 440.00 243.00V253.00Q440.00 323.00 402.50 360.50Q365.00 398.00 306.00 398.00Q248.00 398.00 210.00 360.50Q172.00 323.00 172.00 253.00V243.00Q172.00 173.00 210.00 135.50Q248.00 98.00 306.00 98.00Z"/><path transform="translate(90.00 40.00) scale(0.04000 -0.04000)" d="M70.00 0.00V496.00H194.00V440.00H212.00Q223.00 470.00 248.50 484.00Q274.00 498.00 308.00 498.00H368.00V386.00H306.00Q258.00 386.00 227.00 360.50Q196.00 335.00 196.00 282.00V0.00Z"/><path transform="translate(104.44 40.00) scale(0.04000 -0.04000)" d="M306.00 -14.00Q232.00 -14.00 173.00 16.00Q114.00 46.00 80.00 103.00Q46.00 160.00 46.00 240.00V256.00Q46.00 336.00 80.00 393.00Q114.00 450.00 173.00 480.00Q232.00 510.00 306.00 510.00Q380.00 510.00 439.00 480.00Q498.00 450.00 532.00 393.00Q566.00 336.00 566.00 256.00V240.00Q566.00 160.00 532.00 103.00Q498.00 46.00 439.00 16.00Q380.00 -14.00 306.00 -14.00ZM306.00 98.00Q364.00 98.00 402.00 135.50Q440.00 173.00 440.00 243.00V253.00Q440.00 323.00 402.50 360.50Q365.00 398.00 306.00 398.00Q248.00 398.00 210.00 360.50Q172.00 323.00 172.00 253.00V243.00Q172.00 173.00 210.00 135.50Q248.00 98.00 306.00 98.00Z"/></g><rect x="139.52" y="16" width="1.6" height="26" rx="0.8" fill="var(--bc-brand-mark, #5647ee)" opacity="0.4"/><g fill="var(--bc-brand-mark, #5647ee)"><path transform="translate(154.52 35.00) scale(0.01400 -0.01400)" d="M46.35 0.00V105.85H139.75V594.15H46.35V700.00H404.08Q467.38 700.00 514.79 678.41Q562.20 656.83 588.70 617.33Q615.20 577.83 615.20 523.35V513.70Q615.20 465.52 597.03 434.76Q578.85 404.00 553.91 387.24Q528.98 370.48 506.28 363.30V346.35Q528.98 340.35 555.24 323.50Q581.50 306.65 600.00 275.48Q618.50 244.30 618.50 194.48V184.48Q618.50 126.65 591.41 85.24Q564.33 43.83 516.83 21.91Q469.33 0.00 406.85 0.00ZM259.85 109.15H392.60Q441.20 109.15 469.80 132.60Q498.40 156.05 498.40 198.55V208.20Q498.40 250.70 470.21 274.15Q442.02 297.60 392.60 297.60H259.85ZM259.85 406.75H391.65Q437.55 406.75 466.32 430.11Q495.10 453.48 495.10 493.80V503.80Q495.10 544.95 466.74 567.90Q438.37 590.85 391.65 590.85H259.85Z"/><path transform="translate(167.00 35.00) scale(0.01400 -0.01400)" d="M335.30 -14.00Q251.22 -14.00 190.60 16.63Q129.97 47.25 97.56 104.96Q65.15 162.68 65.15 243.03V700.00H185.25V239.73Q185.25 170.48 224.03 131.99Q262.80 93.50 335.30 93.50Q407.80 93.50 446.57 131.99Q485.35 170.48 485.35 239.73V700.00H605.45V243.03Q605.45 162.68 573.13 104.96Q540.80 47.25 480.09 16.63Q419.38 -14.00 335.30 -14.00Z"/><path transform="translate(179.60 35.00) scale(0.01400 -0.01400)" d="M311.85 -14.00Q238.45 -14.00 179.69 18.50Q120.92 51.00 86.69 113.67Q52.45 176.35 52.45 266.87V433.13Q52.45 569.00 128.01 641.50Q203.57 714.00 332.35 714.00Q460.13 714.00 529.23 645.59Q598.33 577.17 598.33 460.87V456.70H479.87V464.52Q479.87 504.67 463.76 536.86Q447.65 569.05 414.99 587.77Q382.32 606.50 332.35 606.50Q257.63 606.50 215.09 560.70Q172.55 514.90 172.55 435.13V264.87Q172.55 185.93 215.09 138.89Q257.63 91.85 334.00 91.85Q410.02 91.85 445.12 132.83Q480.22 173.80 480.22 237.92V249.67H302.85V351.88H598.33V0.00H487.52V69.10H470.57Q462.70 50.87 446.06 31.44Q429.42 12.00 397.64 -1.00Q365.85 -14.00 311.85 -14.00Z"/><path transform="translate(192.05 35.00) scale(0.01400 -0.01400)" d="M332.35 -14.00Q203.57 -14.00 128.01 58.50Q52.45 131.00 52.45 266.87V433.13Q52.45 569.00 128.01 641.50Q203.57 714.00 332.35 714.00Q460.13 714.00 529.58 643.85Q599.03 573.70 599.03 451.57V445.75H480.57V455.22Q480.57 521.42 444.15 563.96Q407.72 606.50 332.35 606.50Q257.63 606.50 215.09 560.70Q172.55 514.90 172.55 435.13V264.87Q172.55 185.93 215.09 139.71Q257.63 93.50 332.35 93.50Q407.72 93.50 444.15 136.45Q480.57 179.40 480.57 244.78V262.25H599.03V248.43Q599.03 126.30 529.58 56.15Q460.13 -14.00 332.35 -14.00Z"/><path transform="translate(204.25 35.00) scale(0.01400 -0.01400)" d="M19.75 0.00 211.62 700.00H421.33L613.20 0.00H489.27L447.42 159.60H185.53L143.68 0.00ZM214.25 270.40H418.70L324.95 624.87H308.00Z"/><path transform="translate(216.31 35.00) scale(0.01400 -0.01400)" d="M233.95 0.00V590.85H24.70V700.00H563.30V590.85H354.05V0.00Z"/><path transform="translate(227.75 35.00) scale(0.01400 -0.01400)" d="M332.35 -14.00Q203.57 -14.00 128.01 58.50Q52.45 131.00 52.45 266.87V433.13Q52.45 569.00 128.01 641.50Q203.57 714.00 332.35 714.00Q460.13 714.00 529.58 643.85Q599.03 573.70 599.03 451.57V445.75H480.57V455.22Q480.57 521.42 444.15 563.96Q407.72 606.50 332.35 606.50Q257.63 606.50 215.09 560.70Q172.55 514.90 172.55 435.13V264.87Q172.55 185.93 215.09 139.71Q257.63 93.50 332.35 93.50Q407.72 93.50 444.15 136.45Q480.57 179.40 480.57 244.78V262.25H599.03V248.43Q599.03 126.30 529.58 56.15Q460.13 -14.00 332.35 -14.00Z"/><path transform="translate(239.95 35.00) scale(0.01400 -0.01400)" d="M69.50 0.00V700.00H189.60V405.58H466.40V700.00H586.50V0.00H466.40V296.42H189.60V0.00Z"/></g></svg>';
@@ -0,0 +1,265 @@
1
+ // The report panel: the dialog the launcher opens, and everything on it.
2
+ //
3
+ // None of this is in the script every page loads. Most visitors never press
4
+ // the button, and the panel is most of the widget's code and nearly all of its
5
+ // CSS, so it is fetched the first time it is wanted — see widget.js for when,
6
+ // and lazy.js for how.
7
+ //
8
+ // One dialog doing several jobs, one file each:
9
+ //
10
+ // compose.js the report form: drafts, severity, attachments, recording, Send
11
+ // refs.js "which record is this about", asked at Send
12
+ // notices.js what the project's team is telling visitors, at the top
13
+ // known.js what the project already knows is broken, and "me too"
14
+ // picks.js questions the assistant answers by itself, as one tap
15
+ // signin.js who is reporting, proved with a one-time code
16
+ // call.js the call offer, the ringing screen, and the bar over a thread
17
+ // thread.js the reporter's reports, one of them as a conversation
18
+ // composer.js the reply box under that conversation
19
+ //
20
+ // with markup they share in render.js and messages.js.
21
+ //
22
+ // They were one closure, and what they share is still one closure's worth of
23
+ // state: `ctx`, built below. It is passed rather than kept in module variables
24
+ // because two panels must never share it — a widget torn down and mounted again
25
+ // by a second init(), or a page running BugCatch.Client twice, would otherwise
26
+ // find the last panel's draft and the last panel's report in the new one.
27
+
28
+ import * as ICONS from './icons.js';
29
+ import { BRAND } from './icons.js';
30
+ import { PANEL_CSS } from './styles.js';
31
+ import { headHtml, escapeHtml } from './render.js';
32
+ import { composeHtml, mountCompose } from './compose.js';
33
+ import { mountReferences } from './refs.js';
34
+ import { mountNotices } from './notices.js';
35
+ import { mountKnownIssues } from './known.js';
36
+ import { mountPicks } from './picks.js';
37
+ import { mountCallOffer } from './call.js';
38
+ import { mountSignIn } from './signin.js';
39
+ import { mountThread } from './thread.js';
40
+
41
+ /**
42
+ * Open the panel over the page. `preloadCall` fetches the call code ahead of a
43
+ * call being placed; the launcher hands it over because the panel has no way
44
+ * to load code of its own.
45
+ */
46
+ export function openPanel({
47
+ root, host, dock, client, onClose, preloadCall = () => {}, themeControls = null,
48
+ }) {
49
+ addStyles(root);
50
+ const cfg = client.config;
51
+ const themeLabel = () => themeControls?.label() || '';
52
+
53
+ const scrim = document.createElement('div');
54
+ // The panel opens out of whichever corner the dock is in, clearing it by the
55
+ // dock's measured height rather than a guess at the launcher's size.
56
+ scrim.className = `scrim ${dock.classList.contains('at-left') ? 'at-left' : 'at-right'} `
57
+ + `${dock.classList.contains('at-top') ? 'at-top' : 'at-bottom'}`;
58
+ scrim.style.setProperty('--bc-dock-h', `${Math.round(dock.getBoundingClientRect().height)}px`);
59
+ scrim.innerHTML = `
60
+ <div class="panel" role="dialog" aria-modal="true" aria-labelledby="bc-h">
61
+ ${headHtml({
62
+ id: 'bc-h',
63
+ // Not "Tell us what went wrong": that is the first field's own label,
64
+ // a few pixels below. The header names the errand the launcher named.
65
+ title: 'Report a bug',
66
+ sub: 'We attach the page details for you.',
67
+ theme: themeLabel(),
68
+ })}
69
+ <div class="body">
70
+ <div class="slot" role="alert"></div>
71
+ <!-- What the project's team is telling visitors. Drawn only when it
72
+ has published something. -->
73
+ <div class="notices" hidden></div>
74
+ <div class="whoami" hidden></div>
75
+ <!-- The questions the assistant can answer from their account and
76
+ close by itself. Drawn only when the project offers any. -->
77
+ <div class="picks" hidden></div>
78
+ <div class="known" hidden></div>
79
+ ${composeHtml(cfg)}
80
+ </div>
81
+ <div class="foot">
82
+ <span class="note" aria-live="polite"></span>
83
+ ${client.hasReports() ? '<button class="quiet" type="button" data-act="history">Your reports</button>' : ''}
84
+ <span class="spacer"></span>
85
+ <!-- Hidden until the API confirms this project can take a call, which
86
+ most cannot. Drawn here rather than as a second launcher because it
87
+ is the same errand as Send report — the difference is only whether
88
+ the reporter would rather say it than type it. -->
89
+ <button class="ghost" type="button" data-act="call" hidden>${ICONS.phone}Talk to the team</button>
90
+ <button class="primary" type="button" data-act="send">Send report ${ICONS.send}</button>
91
+ </div>
92
+ <div class="powered">Powered by
93
+ <a class="brand-credit" href="https://bugcatch.sphoro.com" target="_blank" rel="noopener noreferrer" aria-label="BugCatch">${BRAND}</a>
94
+ </div>
95
+ </div>`;
96
+
97
+ const $ = (sel) => scrim.querySelector(sel);
98
+ const slot = $('.slot');
99
+
100
+ // Run as the panel goes, in the order the parts added them: the draft saved,
101
+ // a recording stopped, timers and listeners dropped.
102
+ const cleanups = [];
103
+
104
+ /**
105
+ * What the parts share.
106
+ *
107
+ * The nodes are looked up once and held, not looked up again: the history
108
+ * and report screens replace the panel's markup, and a draft saved on the way
109
+ * out must still read what was typed. The functions left null are filled in
110
+ * by the part that owns them (named beside each), and nothing calls one until
111
+ * every part has mounted — they are reached from events and replies, and from
112
+ * the end of this function.
113
+ */
114
+ const ctx = {
115
+ client,
116
+ cfg,
117
+ root,
118
+ host,
119
+ dock,
120
+ scrim,
121
+ $,
122
+ titleInput: $('#bc-title'),
123
+ descInput: $('#bc-desc'),
124
+ compose: $('.compose'),
125
+ sendBtn: $('[data-act="send"]'),
126
+ callBtn: $('[data-act="call"]'),
127
+ historyBtn: $('[data-act="history"]'),
128
+ note: $('.foot .note'),
129
+ whoami: $('.whoami'),
130
+ refsBox: $('.refs'),
131
+ notices: $('.notices'),
132
+ known: $('.known'),
133
+ picks: $('.picks'),
134
+ // A report or a call is on its way. Escape and a click outside wait for it.
135
+ sending: false,
136
+ closed: false,
137
+ say,
138
+ close,
139
+ preloadCall,
140
+ themeLabel,
141
+ onDestroy: (fn) => { cleanups.push(fn); },
142
+ // compose.js
143
+ refreshSendState: null,
144
+ isRecording: null,
145
+ stopRecording: null,
146
+ // refs.js
147
+ holdForReference: null,
148
+ answeredReference: null,
149
+ // picks.js
150
+ pickedIssue: null,
151
+ // call.js
152
+ offerCall: null,
153
+ callOffered: null,
154
+ showLiveCall: null,
155
+ // signin.js
156
+ mustSignIn: null,
157
+ recheckSignIn: null,
158
+ // thread.js
159
+ succeed: null,
160
+ refreshThread: null,
161
+ openReportId: null,
162
+ };
163
+
164
+ /* ---------- the parts, in the order they ask the API anything ---------- */
165
+
166
+ mountCompose(ctx);
167
+ mountReferences(ctx);
168
+ mountNotices(ctx);
169
+ mountKnownIssues(ctx);
170
+ mountPicks(ctx);
171
+ mountCallOffer(ctx);
172
+ mountSignIn(ctx);
173
+ mountThread(ctx);
174
+
175
+ /* ---------- lifecycle ---------- */
176
+
177
+ const previouslyFocused = document.activeElement;
178
+ const rootStyle = document.documentElement.style;
179
+ const previousOverflow = rootStyle.overflow;
180
+ rootStyle.overflow = 'hidden';
181
+
182
+ function destroy() {
183
+ if (ctx.closed) return;
184
+ cleanups.forEach((fn) => fn());
185
+ ctx.closed = true;
186
+ rootStyle.overflow = previousOverflow;
187
+ document.removeEventListener('keydown', onKeydown, true);
188
+ scrim.remove();
189
+ dock.hidden = false;
190
+ host.style.opacity = '';
191
+ onClose?.();
192
+ }
193
+
194
+ function close() {
195
+ if (ctx.closed) return;
196
+ // Let the exit animation play, but never wait on an event that a
197
+ // reduced-motion user's near-zero animation might deliver unpredictably.
198
+ scrim.classList.add('closing');
199
+ setTimeout(destroy, 160);
200
+ try { previouslyFocused?.focus?.(); } catch { /* element may be gone */ }
201
+ }
202
+
203
+ function onKeydown(e) {
204
+ if (e.key === 'Escape') {
205
+ e.stopPropagation();
206
+ if (ctx.isRecording()) { ctx.stopRecording({ keep: false }); return; }
207
+ if (!ctx.sending) close();
208
+ return;
209
+ }
210
+ if (e.key !== 'Tab' || ctx.isRecording()) return;
211
+ // Focus trap: the panel is modal, so Tab must not reach the host page.
212
+ const focusable = [...scrim.querySelectorAll(
213
+ 'button:not(:disabled), input:not([hidden]), textarea, select, [href]',
214
+ )].filter((el) => el.offsetParent !== null || el === document.activeElement);
215
+ if (!focusable.length) return;
216
+ const first = focusable[0];
217
+ const last = focusable[focusable.length - 1];
218
+ const active = scrim.getRootNode().activeElement;
219
+ if (e.shiftKey && active === first) { e.preventDefault(); last.focus(); }
220
+ else if (!e.shiftKey && active === last) { e.preventDefault(); first.focus(); }
221
+ }
222
+
223
+ document.addEventListener('keydown', onKeydown, true);
224
+ scrim.addEventListener('click', (e) => { if (e.target === scrim && !ctx.sending) close(); });
225
+ $('[data-act="close"]').addEventListener('click', close);
226
+ // On the scrim rather than the button: every screen draws its own header, and
227
+ // the scrim is the one element all of them are drawn inside.
228
+ scrim.addEventListener('click', (e) => {
229
+ const btn = e.target.closest('[data-act="theme"]');
230
+ if (!btn || !themeControls) return;
231
+ const next = themeControls.cycle();
232
+ btn.title = `Theme: ${next.label}`;
233
+ btn.setAttribute('aria-label', `Theme: ${next.label}. Change theme`);
234
+ });
235
+
236
+ /** The banner at the top of the form: an error, or with `info`, a notice. */
237
+ function say(text, kind = 'err') {
238
+ slot.innerHTML = text
239
+ ? `<div class="msg ${kind}">${kind === 'err' ? ICONS.alert : ''}<span>${escapeHtml(text)}</span></div>`
240
+ : '';
241
+ }
242
+
243
+ root.appendChild(scrim);
244
+ ctx.refreshSendState();
245
+ ctx.titleInput.focus();
246
+
247
+ return { destroy, close };
248
+ }
249
+
250
+ /**
251
+ * Add the panel's stylesheet to the widget's shadow root, once.
252
+ *
253
+ * Straight after the launcher's sheet rather than at the end of the root, so
254
+ * the two stay together at the top and the panel's rules keep their place after
255
+ * the launcher's in the cascade — the order they had as one stylesheet.
256
+ */
257
+ function addStyles(root) {
258
+ if (root.querySelector('style[data-panel]')) return;
259
+ const style = document.createElement('style');
260
+ style.setAttribute('data-panel', '');
261
+ style.textContent = PANEL_CSS;
262
+ const launcherSheet = root.querySelector('style');
263
+ if (launcherSheet) launcherSheet.after(style);
264
+ else root.prepend(style);
265
+ }