@sphoro/bugcatch-web-sdk 0.20.0 → 0.21.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.
@@ -8,6 +8,7 @@
8
8
  // One dialog doing several jobs, one file each:
9
9
  //
10
10
  // compose.js the report form: drafts, severity, attachments, recording, Send
11
+ // feedback.js the other form: "Give feedback", for what is not a bug
11
12
  // rate-due.js "how did we do?" about a settled report, above the form
12
13
  // refs.js "which record is this about", asked at Send
13
14
  // notices.js what the project's team is telling visitors, at the top
@@ -39,6 +40,16 @@ import { mountCallOffer } from './call.js';
39
40
  import { mountSignIn } from './signin.js';
40
41
  import { mountThread } from './thread.js';
41
42
  import { mountRateDue } from './rate-due.js';
43
+ import { feedbackHtml, mountFeedback } from './feedback.js';
44
+
45
+ /**
46
+ * What the header says on each of the two forms. The header names the errand;
47
+ * the switch under it is how to change errands.
48
+ */
49
+ const MODES = {
50
+ report: { title: 'Report a bug', sub: 'We attach the page details for you.' },
51
+ feedback: { title: 'Give feedback', sub: 'The team reads every note.' },
52
+ };
42
53
 
43
54
  /**
44
55
  * Open the panel over the page. `preloadCall` fetches the call code ahead of a
@@ -46,11 +57,17 @@ import { mountRateDue } from './rate-due.js';
46
57
  * to load code of its own.
47
58
  */
48
59
  export function openPanel({
49
- root, host, dock, client, onClose, preloadCall = () => {}, themeControls = null,
60
+ root, host, dock, client, onClose, preloadCall = () => {}, themeControls = null, mode: askedMode = 'report',
50
61
  }) {
51
62
  addStyles(root);
52
63
  const cfg = client.config;
53
64
  const themeLabel = () => themeControls?.label() || '';
65
+ // Drawn straight away rather than once the API has answered, because nearly
66
+ // every project has it on: a switch that arrived a beat after the form would
67
+ // push the form down under the reader's cursor. The rare project that has it
68
+ // off loses it again when the answer comes (below).
69
+ const offerFeedback = cfg.feedback !== false;
70
+ let mode = offerFeedback && askedMode === 'feedback' ? 'feedback' : 'report';
54
71
 
55
72
  const scrim = document.createElement('div');
56
73
  // The panel opens out of whichever corner the dock is in, clearing it by the
@@ -59,40 +76,47 @@ export function openPanel({
59
76
  + `${dock.classList.contains('at-top') ? 'at-top' : 'at-bottom'}`;
60
77
  scrim.style.setProperty('--bc-dock-h', `${Math.round(dock.getBoundingClientRect().height)}px`);
61
78
  scrim.innerHTML = `
62
- <div class="panel" role="dialog" aria-modal="true" aria-labelledby="bc-h">
79
+ <div class="panel" role="dialog" aria-modal="true" aria-labelledby="bc-h" data-mode="${mode}">
63
80
  ${headHtml({
64
81
  id: 'bc-h',
65
82
  // Not "Tell us what went wrong": that is the first field's own label,
66
83
  // a few pixels below. The header names the errand the launcher named.
67
- title: 'Report a bug',
68
- sub: 'We attach the page details for you.',
84
+ title: MODES[mode].title,
85
+ sub: MODES[mode].sub,
69
86
  theme: themeLabel(),
70
87
  })}
88
+ ${offerFeedback ? `
89
+ <div class="modes" role="group" aria-label="What would you like to do?">
90
+ <button type="button" data-mode="report" aria-pressed="${mode === 'report'}">${ICONS.bug}Report a bug</button>
91
+ <button type="button" data-mode="feedback" aria-pressed="${mode === 'feedback'}">${ICONS.feedback}Give feedback</button>
92
+ </div>` : ''}
71
93
  <div class="body">
72
94
  <div class="slot" role="alert"></div>
73
95
  <!-- A settled report still waiting for its rating. Drawn only when the
74
96
  project asks for one here; see rate-due.js. -->
75
- <div class="rate-due fix-rating" tabindex="-1" hidden></div>
97
+ <div class="rate-due fix-rating bug-only" tabindex="-1" hidden></div>
76
98
  <!-- What the project's team is telling visitors. Drawn only when it
77
99
  has published something. -->
78
100
  <div class="notices" hidden></div>
79
- <div class="whoami" hidden></div>
101
+ <div class="whoami bug-only" hidden></div>
80
102
  <!-- The questions the assistant can answer from their account and
81
103
  close by itself. Drawn only when the project offers any. -->
82
- <div class="picks" hidden></div>
83
- <div class="known" hidden></div>
104
+ <div class="picks bug-only" hidden></div>
105
+ <div class="known bug-only" hidden></div>
84
106
  ${composeHtml(cfg)}
107
+ ${offerFeedback ? feedbackHtml() : ''}
85
108
  </div>
86
109
  <div class="foot">
87
- <span class="note" aria-live="polite"></span>
110
+ <span class="note bug-only" aria-live="polite"></span>
88
111
  ${client.hasReports() ? '<button class="quiet" type="button" data-act="history">Your reports</button>' : ''}
89
112
  <span class="spacer"></span>
90
113
  <!-- Hidden until the API confirms this project can take a call, which
91
114
  most cannot. Drawn here rather than as a second launcher because it
92
115
  is the same errand as Send report — the difference is only whether
93
116
  the reporter would rather say it than type it. -->
94
- <button class="ghost" type="button" data-act="call" hidden>${ICONS.phone}Talk to the team</button>
95
- <button class="primary" type="button" data-act="send">Send report ${ICONS.send}</button>
117
+ <button class="ghost bug-only" type="button" data-act="call" hidden>${ICONS.phone}Talk to the team</button>
118
+ <button class="primary bug-only" type="button" data-act="send">Send report ${ICONS.send}</button>
119
+ ${offerFeedback ? `<button class="primary fb-only" type="button" data-act="send-feedback">Send feedback ${ICONS.send}</button>` : ''}
96
120
  </div>
97
121
  <div class="powered">Powered by
98
122
  <a class="brand-credit" href="https://bugcatch.sphoro.com" target="_blank" rel="noopener noreferrer" aria-label="BugCatch">${BRAND}</a>
@@ -163,6 +187,8 @@ export function openPanel({
163
187
  recheckSignIn: null,
164
188
  // rate-due.js
165
189
  mustRate: () => false,
190
+ // feedback.js
191
+ focusFeedback: () => {},
166
192
  // thread.js
167
193
  succeed: null,
168
194
  refreshThread: null,
@@ -180,6 +206,49 @@ export function openPanel({
180
206
  mountSignIn(ctx);
181
207
  mountThread(ctx);
182
208
  mountRateDue(ctx);
209
+ if (offerFeedback) mountFeedback(ctx);
210
+
211
+ /* ---------- report a bug, or give feedback ---------- */
212
+
213
+ /**
214
+ * Move between the two forms. Only while the forms are on screen: history,
215
+ * a report's conversation and the call screens replace the panel's markup,
216
+ * and there is no form there to switch to. Nothing typed into either form is
217
+ * lost by switching — both stay in the page, one of them hidden.
218
+ */
219
+ function setMode(next, { focus = true } = {}) {
220
+ const want = next === 'feedback' && $('.modes') ? 'feedback' : 'report';
221
+ const panel = scrim.querySelector('.panel');
222
+ if (ctx.closed || ctx.sending || !panel || !scrim.querySelector('.modes')) return;
223
+ if (want === 'feedback' && ctx.isRecording()) return;
224
+ mode = want;
225
+ panel.dataset.mode = want;
226
+ panel.querySelector('#bc-h').textContent = MODES[want].title;
227
+ panel.querySelector('.head .titles p').textContent = MODES[want].sub;
228
+ scrim.querySelectorAll('.modes [data-mode]').forEach((b) => {
229
+ b.setAttribute('aria-pressed', String(b.dataset.mode === want));
230
+ });
231
+ say('');
232
+ if (focus) (want === 'feedback' ? ctx.focusFeedback() : ctx.titleInput.focus());
233
+ }
234
+ scrim.querySelectorAll('.modes [data-mode]').forEach((btn) => {
235
+ btn.addEventListener('click', () => setMode(btn.dataset.mode));
236
+ });
237
+
238
+ // The project's own answer. Off — or an API too old to say — takes the
239
+ // switch away, and with it a feedback form the server would refuse.
240
+ if (offerFeedback) {
241
+ client.feedbackEnabled().then((on) => {
242
+ // Mid-send, the form stays for this open: the server's refusal says why,
243
+ // which beats a form vanishing from under the button just pressed.
244
+ if (on || ctx.closed || ctx.sending) return;
245
+ const wasFeedback = mode === 'feedback';
246
+ setMode('report', { focus: wasFeedback });
247
+ scrim.querySelector('.modes')?.remove();
248
+ scrim.querySelector('.feedback')?.remove();
249
+ scrim.querySelector('[data-act="send-feedback"]')?.remove();
250
+ });
251
+ }
183
252
 
184
253
  /* ---------- lifecycle ---------- */
185
254
 
@@ -251,9 +320,10 @@ export function openPanel({
251
320
 
252
321
  root.appendChild(scrim);
253
322
  ctx.refreshSendState();
254
- ctx.titleInput.focus();
323
+ if (mode === 'feedback') ctx.focusFeedback();
324
+ else ctx.titleInput.focus();
255
325
 
256
- return { destroy, close };
326
+ return { destroy, close, setMode };
257
327
  }
258
328
 
259
329
  /**
@@ -306,6 +306,53 @@ textarea { min-height: 76px; resize: vertical; }
306
306
  .seg button[aria-pressed=true]::before { opacity: 1; }
307
307
  .seg button:focus-visible { outline-offset: -2px; }
308
308
 
309
+ /* ---------- report a bug, or give feedback ----------
310
+ The same stone track as the severity control, two wide segments, under the
311
+ header so it stays put while the form scrolls. Each form's own parts carry
312
+ bug-only or fb-only; the other screens (history, a conversation, a call)
313
+ carry neither, so a mode left on the panel cannot hide anything of theirs. */
314
+ .modes {
315
+ flex: none; display: grid; grid-template-columns: 1fr 1fr; gap: 2px;
316
+ margin: 12px var(--bc-pad) 0; padding: 3px; background: var(--bc-stone); border-radius: 999px;
317
+ }
318
+ .modes button {
319
+ display: inline-flex; align-items: center; justify-content: center; gap: 7px; min-width: 0;
320
+ height: 32px; padding: 0 8px; font: inherit; font-size: var(--bc-fs-sm); font-weight: 520; line-height: 1; white-space: nowrap;
321
+ color: var(--bc-muted); background: none; border: none; border-radius: 999px;
322
+ cursor: pointer; transition: background 180ms var(--bc-ease), color 180ms, box-shadow 180ms;
323
+ }
324
+ .modes button svg { font-size: 15px; flex: none; }
325
+ .modes button:hover { color: var(--bc-fg); }
326
+ .modes button[aria-pressed=true] {
327
+ background: var(--bc-raised); color: var(--bc-fg); font-weight: 600;
328
+ box-shadow: 0 1px 2px rgba(20,18,14,.10), 0 0 0 .5px rgba(20,18,14,.08);
329
+ }
330
+ .modes button:focus-visible { outline-offset: -2px; }
331
+ .panel[data-mode="feedback"] .bug-only,
332
+ .panel:not([data-mode="feedback"]) .fb-only { display: none !important; }
333
+
334
+ /* ---------- feedback ---------- */
335
+ .feedback { animation: bc-fade-in 200ms ease both; }
336
+ .feedback textarea { min-height: 96px; }
337
+ .feedback .kinds { display: flex; flex-wrap: wrap; gap: 6px; }
338
+ .feedback .kinds .tool[aria-pressed=true] { background: var(--bc-accent); border-color: var(--bc-accent); color: var(--bc-on-accent); }
339
+ .feedback .kinds .tool[aria-pressed=true] svg { color: currentColor; }
340
+ .faces { display: flex; gap: 6px; }
341
+ .face {
342
+ flex: 1 1 0; display: grid; place-items: center; height: 42px; padding: 0;
343
+ font: inherit; font-size: 24px; color: var(--bc-muted); background: var(--bc-stone);
344
+ border: 1px solid transparent; border-radius: var(--bc-r-md); cursor: pointer;
345
+ transition: background 160ms, color 160ms, transform 160ms var(--bc-ease);
346
+ }
347
+ .face:hover { color: var(--bc-fg); background: color-mix(in srgb, var(--bc-fg) 8%, var(--bc-stone)); }
348
+ .face:active { transform: scale(.94); }
349
+ .face[aria-pressed=true] { color: var(--bc-on-accent); background: var(--bc-accent); }
350
+ .face:focus-visible { outline-offset: 2px; }
351
+ .face-word { font-weight: 560; color: var(--bc-muted); }
352
+ .fb-as { margin: 0; font-size: var(--bc-fs-xs); line-height: 1.45; color: var(--bc-muted); }
353
+ .fb-as strong { color: var(--bc-fg); font-weight: 600; word-break: break-all; }
354
+ .fb-who:empty { display: none; }
355
+
309
356
  /* ---------- attachments ----------
310
357
  Three ring pills in a row: optional, so they take one line, not a band of
311
358
  dashed drop zones. */
package/src/widget.js CHANGED
@@ -423,7 +423,13 @@ export function mountWidget(client) {
423
423
  open = openPanelWhenLoaded();
424
424
  });
425
425
 
426
+ // Which form the next panel opens on: BugCatch.open({ mode }) sets it just
427
+ // before pressing the launcher, and a person pressing it gets the report form.
428
+ let nextMode = 'report';
429
+
426
430
  function openPanelWhenLoaded() {
431
+ let mode = nextMode;
432
+ nextMode = 'report';
427
433
  hideLoadFailure();
428
434
  // The panel says it again, at the top, with the rest of what is live.
429
435
  hideNotice();
@@ -437,6 +443,8 @@ export function mountWidget(client) {
437
443
  const pending = {
438
444
  close: () => (panel ? panel.close() : cancel()),
439
445
  destroy: () => (panel ? panel.destroy() : cancel()),
446
+ // Asked again while it is on its way, or once it is open.
447
+ setMode: (m) => { if (panel) panel.setMode?.(m); else mode = m; },
440
448
  };
441
449
 
442
450
  launcher.setAttribute('aria-busy', 'true');
@@ -452,6 +460,7 @@ export function mountWidget(client) {
452
460
  client,
453
461
  preloadCall,
454
462
  themeControls,
463
+ mode,
455
464
  onClose: () => {
456
465
  open = null;
457
466
  dock.classList.remove('over');
@@ -558,7 +567,11 @@ export function mountWidget(client) {
558
567
  // Integration hook: BugCatch.open() / close() / isOpen() drive the same
559
568
  // launcher a person would press, so there is one way in to the panel.
560
569
  client.widgetControls = {
561
- open: () => { if (!open) launcher.click(); },
570
+ open: (mode) => {
571
+ if (open) { if (mode) open.setMode(mode); return; }
572
+ nextMode = mode || 'report';
573
+ launcher.click();
574
+ },
562
575
  close: () => { open?.close(); },
563
576
  isOpen: () => Boolean(open),
564
577
  };