@sphoro/bugcatch-web-sdk 0.18.0 → 0.20.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.
@@ -1,7 +1,8 @@
1
1
  // What happens to a report once it is filed: the confirmation, the
2
2
  // reporter's list of everything they have filed, and one report as a
3
3
  // conversation — with the assistant's live card over it, the "does the fix
4
- // work" question under it, and the reply box at the bottom (composer.js).
4
+ // work" and "how did we do" questions under it, and the reply box at the
5
+ // bottom (composer.js).
5
6
 
6
7
  import * as ICONS from './icons.js';
7
8
  import { threadHtml, typingHtml } from './messages.js';
@@ -10,11 +11,23 @@ import {
10
11
  atLatest, scrollToLatest,
11
12
  } from './render.js';
12
13
  import { mountComposer } from './composer.js';
14
+ import { drawRating, ratingKey } from './rating.js';
13
15
 
14
16
  // What each open thread was last drawn from, so a poll that brings nothing new
15
17
  // leaves the DOM (and the reader's place in it) alone.
16
18
  const threadDrawn = new WeakMap();
17
19
 
20
+ /**
21
+ * The rating's word on a history row: the score they gave, or a nudge on a
22
+ * settled report that is waiting for one. Nothing on an open report.
23
+ */
24
+ function historyRating(r) {
25
+ if (r.rating) {
26
+ return ` · <span class="rated" aria-label="You rated this ${Number(r.rating)} out of 5">${ICONS.star}${Number(r.rating)}</span>`;
27
+ }
28
+ return r.ratingAsked ? ' · <span class="rate-ask">Rate it</span>' : '';
29
+ }
30
+
18
31
  export function mountThread(ctx) {
19
32
  const {
20
33
  client, scrim, close,
@@ -88,7 +101,7 @@ export function mountThread(ctx) {
88
101
  openReportId = null;
89
102
  panel.classList.remove('is-chat');
90
103
  panel.innerHTML = `
91
- ${headHtml({ title: 'Your reports', sub: "Everything you've reported, and where each one got to." })}
104
+ ${headHtml({ title: 'Your reports', sub: 'Where each one has got to.', theme: ctx.themeLabel() })}
92
105
  <div class="history" aria-busy="true">${skeletonRows(3)}</div>`;
93
106
 
94
107
  panel.querySelector('[data-act="close"]').addEventListener('click', close);
@@ -113,7 +126,7 @@ export function mountThread(ctx) {
113
126
  <span class="row-ico" aria-hidden="true">${ICONS.chat}</span>
114
127
  <span class="who">
115
128
  <span class="ttl">${escapeHtml(r.title)}</span>
116
- <span class="sub">${escapeHtml(r.reference)} · ${escapeHtml(relativeDay(r.createdAt))}</span>
129
+ <span class="sub">${escapeHtml(r.reference)} · ${escapeHtml(relativeDay(r.createdAt))}${historyRating(r)}</span>
117
130
  </span>
118
131
  <span class="pill ${escapeHtml(String(r.status).toLowerCase())}">${escapeHtml(statusWord(r.status))}</span>
119
132
  <span class="chev" aria-hidden="true">${ICONS.chevronRight}</span>
@@ -146,7 +159,8 @@ export function mountThread(ctx) {
146
159
  ${headHtml({
147
160
  back: true,
148
161
  title: fresh ? 'Looking into it' : 'Your report',
149
- sub: fresh ? 'Our assistant is on it — it may ask you a question here.' : 'Everything that has happened, and a way to answer.',
162
+ sub: fresh ? 'It may ask you a question here.' : 'Updates, and a way to reply.',
163
+ theme: ctx.themeLabel(),
150
164
  })}
151
165
  <div class="history chatting" aria-busy="true"><div class="thread">${skeletonBubbles()}</div></div>`;
152
166
 
@@ -199,6 +213,7 @@ export function mountThread(ctx) {
199
213
  threadDrawn.set(thread, drawn);
200
214
  scrollToLatest(thread);
201
215
  mountVerification(body, report);
216
+ mountRating(body, report);
202
217
  if (report.canReply) mountComposer(ctx, body, thread, bugId);
203
218
  mountAssistant(body, report, bugId);
204
219
  // Again, now the cards above it have taken their share of the height: the
@@ -246,9 +261,8 @@ export function mountThread(ctx) {
246
261
  const content = `
247
262
  <div class="as-body${previous && previous !== a.phase ? ' entering' : ''}">
248
263
  <div class="as-top">
249
- <span class="as-orb" aria-hidden="true">${a.phase === 'analyzing' ? '<span class="as-spin"></span>'
250
- : a.phase === 'calling' ? ICONS.phone
251
- : a.phase === 'resolved' ? ICONS.check : ICONS.sparkle}</span>
264
+ <span class="as-orb${a.phase === 'calling' || a.phase === 'resolved' ? '' : ' orb'}" aria-hidden="true">${
265
+ a.phase === 'calling' ? ICONS.phone : a.phase === 'resolved' ? ICONS.check : ''}</span>
252
266
  <div class="as-text">
253
267
  <div class="as-title">${escapeHtml(a.title)}</div>
254
268
  <div class="as-detail">${escapeHtml(a.detail)}</div>
@@ -314,6 +328,32 @@ export function mountThread(ctx) {
314
328
  body.querySelector('.report-head')?.after(box);
315
329
  }
316
330
 
331
+ /**
332
+ * "How did we do?" — five stars under a settled report.
333
+ *
334
+ * Only where the API says a rating is being asked for: the report is settled
335
+ * and the project has not switched ratings off. The box itself is rating.js.
336
+ *
337
+ * Redrawn only when the rating itself changed. The thread refreshes on every
338
+ * update and every few seconds while the assistant works, and a box rebuilt
339
+ * on each of those would eat the note somebody was halfway through.
340
+ */
341
+ function mountRating(body, report) {
342
+ let box = body.querySelector('.fix-rating');
343
+ const settled = ['RESOLVED', 'CLOSED'].includes(report.status);
344
+ if (!settled || !report.ratingAsked) {
345
+ box?.remove();
346
+ return;
347
+ }
348
+ if (box && box.dataset.key === ratingKey(report.rating)) return;
349
+ if (!box) {
350
+ box = document.createElement('div');
351
+ box.className = 'fix-rating';
352
+ (body.querySelector('.fix-verification') || body.querySelector('.report-head'))?.after(box);
353
+ }
354
+ drawRating(box, { client, report, rating: report.rating || null });
355
+ }
356
+
317
357
  /**
318
358
  * Redraw the open thread in place, keeping whatever is half-typed.
319
359
  *
@@ -334,6 +374,7 @@ export function mountThread(ctx) {
334
374
  if (ctx.closed || !report || openReportId !== bugId) return;
335
375
 
336
376
  mountVerification(body, report);
377
+ mountRating(body, report);
337
378
  const pinned = atLatest(thread);
338
379
  const html = threadHtml(report.timeline);
339
380
  // Only rewritten when something changed, so a poll that brings nothing new
package/src/updates.js CHANGED
@@ -12,6 +12,7 @@
12
12
  // filed them.
13
13
 
14
14
  import { tooManyMessage } from './busy.js';
15
+ import { readableOn, INK, PAPER } from './accent.js';
15
16
 
16
17
  const TOKEN_KEY = 'bugcatch.watch';
17
18
  const BACKOFF_MS = [2000, 5000, 15000, 30000, 60000];
@@ -658,9 +659,21 @@ const STATUS_COPY = {
658
659
  */
659
660
  const KIND_ACCENT = {
660
661
  status: null, // falls back to the host's configured accent
661
- severity: '#e8912a',
662
- assignment: '#4da3ff',
663
- reply: '#35b87f',
662
+ severity: '#f2a54a',
663
+ assignment: '#79a8ff',
664
+ reply: '#4fd19a',
665
+ };
666
+
667
+ // The cards are always dark — they float over pages of every colour, and a
668
+ // near-black card with a hairline reads on all of them. The colours are the
669
+ // product's dark palette.
670
+ const CARD = {
671
+ bg: '#111113',
672
+ fg: '#f3f2ef',
673
+ muted: '#b3b0aa',
674
+ faint: '#8f8c86',
675
+ ring: 'rgba(255,255,255,.09)',
676
+ brand: '#a59cff',
664
677
  };
665
678
 
666
679
  /**
@@ -736,7 +749,7 @@ function dockOffset() {
736
749
  * instead of a notification floating over the report it is about.
737
750
  */
738
751
  export function createUpdateNotifier({
739
- accent = '#4F46E5',
752
+ accent = null,
740
753
  position = 'bottom-right',
741
754
  onRead = null,
742
755
  onReadAll = null,
@@ -784,17 +797,23 @@ export function createUpdateNotifier({
784
797
  setTimeout(() => el.remove(), 250);
785
798
  }
786
799
 
800
+ // On a dark card the ink default is a white pill with ink words; a host's
801
+ // own colour keeps whichever words read on it.
802
+ const primaryBg = accent || PAPER;
803
+ const primaryFg = accent ? readableOn(accent) : INK;
804
+
787
805
  function button(label, primary) {
788
806
  const el = document.createElement('button');
789
807
  el.type = 'button';
790
808
  el.textContent = label;
791
809
  el.style.cssText = [
792
- 'padding:5px 11px',
793
- `border:${primary ? '0' : '1px solid rgba(236,238,242,.24)'}`,
794
- 'border-radius:6px',
795
- `background:${primary ? accent : 'transparent'}`,
796
- `color:${primary ? '#fff' : '#eceef2'}`,
797
- 'font:600 12px/1.4 inherit',
810
+ 'padding:6px 12px',
811
+ 'border:0',
812
+ `box-shadow:${primary ? 'none' : `inset 0 0 0 1px ${CARD.ring}`}`,
813
+ 'border-radius:999px',
814
+ `background:${primary ? primaryBg : 'rgba(255,255,255,.06)'}`,
815
+ `color:${primary ? primaryFg : CARD.fg}`,
816
+ 'font:560 12px/1.4 inherit',
798
817
  'cursor:pointer',
799
818
  'white-space:nowrap',
800
819
  ].join(';');
@@ -807,13 +826,12 @@ export function createUpdateNotifier({
807
826
  el.style.cssText = [
808
827
  'box-sizing:border-box',
809
828
  'width:100%',
810
- 'padding:12px 14px',
811
- 'border-radius:10px',
812
- 'background:#1b1e26',
813
- 'color:#eceef2',
829
+ 'padding:13px 14px',
830
+ 'border-radius:14px',
831
+ `background:${CARD.bg}`,
832
+ `color:${CARD.fg}`,
814
833
  'font:13px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif',
815
- 'box-shadow:0 12px 30px rgba(0,0,0,0.45)',
816
- `border-left:3px solid ${KIND_ACCENT[record?.kind] || accent}`,
834
+ `box-shadow:0 0 0 1px ${CARD.ring},0 20px 44px -12px rgba(0,0,0,.55),0 4px 12px rgba(0,0,0,.25)`,
817
835
  'opacity:0',
818
836
  `transform:translateY(${atTop ? -8 : 8}px)`,
819
837
  'transition:opacity .2s ease,transform .2s ease',
@@ -821,25 +839,33 @@ export function createUpdateNotifier({
821
839
  ].join(';');
822
840
 
823
841
  const title = document.createElement('div');
824
- title.style.cssText = 'font-weight:600;margin-bottom:2px';
825
- title.textContent = updateHeadline(record);
842
+ title.style.cssText = 'display:flex;align-items:baseline;gap:8px;font-weight:600;margin-bottom:2px';
843
+ // The kind's colour, as a dot before the headline rather than a stripe
844
+ // down the card: a fix and a severity change are still told apart.
845
+ const dot = document.createElement('span');
846
+ dot.setAttribute('aria-hidden', 'true');
847
+ dot.style.cssText = `flex:none;width:7px;height:7px;border-radius:50%;transform:translateY(-1px);background:${
848
+ KIND_ACCENT[record?.kind] || accent || CARD.brand}`;
849
+ const headline = document.createElement('span');
850
+ headline.textContent = updateHeadline(record);
851
+ title.append(dot, headline);
826
852
 
827
853
  const body = document.createElement('div');
828
854
  // A reply is the one kind whose detail is worth more room than a title, and
829
855
  // it is the only text here the team wrote rather than the reporter.
830
856
  body.style.cssText = record?.kind === 'reply'
831
- ? 'color:#a4abba;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden'
832
- : 'color:#a4abba';
857
+ ? `color:${CARD.muted};padding-left:15px;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden`
858
+ : `color:${CARD.muted};padding-left:15px`;
833
859
  // textContent, not innerHTML: this is text a person typed, being written
834
860
  // back into somebody else's page.
835
861
  body.textContent = updateDetail(record);
836
862
 
837
863
  const actions = document.createElement('div');
838
864
  actions.style.cssText =
839
- 'display:flex;align-items:center;gap:8px;margin-top:10px;flex-wrap:wrap';
865
+ 'display:flex;align-items:center;gap:8px;margin-top:10px;padding-left:15px;flex-wrap:wrap';
840
866
 
841
867
  const when = document.createElement('span');
842
- when.style.cssText = 'color:#7b8394;font-size:11px;margin-right:auto';
868
+ when.style.cssText = `color:${CARD.faint};font-size:11px;margin-right:auto`;
843
869
  when.textContent = agoLabel(record?.at);
844
870
 
845
871
  const read = button('Mark as read', false);
@@ -871,12 +897,12 @@ export function createUpdateNotifier({
871
897
  'display:flex',
872
898
  'align-items:center',
873
899
  'gap:8px',
874
- 'padding:8px 12px',
875
- 'border-radius:10px',
876
- 'background:#1b1e26',
877
- 'color:#a4abba',
900
+ 'padding:8px 10px 8px 14px',
901
+ 'border-radius:999px',
902
+ `background:${CARD.bg}`,
903
+ `color:${CARD.muted}`,
878
904
  'font:12px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif',
879
- 'box-shadow:0 12px 30px rgba(0,0,0,0.45)',
905
+ `box-shadow:0 0 0 1px ${CARD.ring},0 12px 30px -10px rgba(0,0,0,.5)`,
880
906
  'pointer-events:auto',
881
907
  ].join(';');
882
908
 
@@ -1064,6 +1090,30 @@ async function readJoinError(res) {
1064
1090
  return message || `Could not add you to this issue (HTTP ${res.status})`;
1065
1091
  }
1066
1092
 
1093
+ /**
1094
+ * Rate how a settled report was handled, out of five, with a sentence if they
1095
+ * want one. Rating again replaces the earlier score.
1096
+ *
1097
+ * Same credential as everything else here: the watch token, which is proof of
1098
+ * having filed the report. `?projectId=` goes with it so a token that reaches
1099
+ * more than one project is narrowed to the one this widget is on.
1100
+ */
1101
+ export async function rateReport({ apiBase, bugId, score, comment }) {
1102
+ const token = readToken();
1103
+ if (!token) throw new Error('This browser cannot access that report.');
1104
+ const res = await fetch(`${apiBase}/ingest/watch/reports/${encodeURIComponent(bugId)}/rating${projectQuery()}`, {
1105
+ method: 'POST',
1106
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
1107
+ body: JSON.stringify({ score, ...(comment ? { comment } : {}) }),
1108
+ });
1109
+ const data = await res.json().catch(() => ({}));
1110
+ if (!res.ok) {
1111
+ const message = Array.isArray(data.message) ? data.message.join(', ') : data.message;
1112
+ throw new Error(message || 'Could not send your rating. Please try again.');
1113
+ }
1114
+ return data;
1115
+ }
1116
+
1067
1117
  export async function verifyFix({ apiBase, bugId, ...feedback }) {
1068
1118
  const token = readToken();
1069
1119
  if (!token) throw new Error('This browser cannot access that report.');