@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.
- package/README.md +104 -6
- package/dist/bugcatch-call.es.js +587 -0
- package/dist/bugcatch-call.es.js.map +1 -0
- package/dist/bugcatch-call.umd.js +2 -0
- package/dist/bugcatch-call.umd.js.map +1 -0
- package/dist/bugcatch-core.es.js +3292 -0
- package/dist/bugcatch-core.es.js.map +1 -0
- package/dist/bugcatch-panel.es.js +2531 -0
- package/dist/bugcatch-panel.es.js.map +1 -0
- package/dist/bugcatch-panel.umd.js +1311 -0
- package/dist/bugcatch-panel.umd.js.map +1 -0
- package/dist/bugcatch.es.js +2 -5625
- package/dist/bugcatch.es.js.map +1 -1
- package/dist/bugcatch.standalone.umd.js +1619 -0
- package/dist/bugcatch.standalone.umd.js.map +1 -0
- package/dist/bugcatch.umd.js +195 -1318
- package/dist/bugcatch.umd.js.map +1 -1
- package/package.json +6 -6
- package/src/accent.js +38 -0
- package/src/announcements.js +147 -0
- package/src/busy.js +18 -0
- package/src/call.js +7 -0
- package/src/capture.js +9 -0
- package/src/chunks.js +56 -0
- package/src/gesture.js +33 -0
- package/src/icons.js +18 -0
- package/src/identity.js +13 -8
- package/src/incoming-call.js +25 -45
- package/src/index.d.ts +93 -2
- package/src/index.js +164 -42
- package/src/lazy.js +23 -0
- package/src/notice.js +68 -0
- package/src/panel/call.js +242 -0
- package/src/panel/compose.js +416 -0
- package/src/panel/composer.js +235 -0
- package/src/panel/icons.js +36 -0
- package/src/panel/index.js +265 -0
- package/src/panel/known.js +130 -0
- package/src/panel/messages.js +176 -0
- package/src/panel/notices.js +34 -0
- package/src/panel/picks.js +65 -0
- package/src/panel/refs.js +321 -0
- package/src/panel/render.js +132 -0
- package/src/panel/signin.js +228 -0
- package/src/panel/styles.js +1016 -0
- package/src/panel/thread.js +494 -0
- package/src/session.js +8 -3
- package/src/umd/call.js +6 -0
- package/src/umd/lazy.js +98 -0
- package/src/umd/panel.js +6 -0
- package/src/updates.js +97 -34
- 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
|
+
}
|