@sphoro/bugcatch-web-sdk 0.18.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.
@@ -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';
@@ -15,6 +16,22 @@ import { mountComposer } from './composer.js';
15
16
  // leaves the DOM (and the reader's place in it) alone.
16
17
  const threadDrawn = new WeakMap();
17
18
 
19
+ /** What a drawn rating box was drawn from, so a refresh can tell if it moved. */
20
+ function ratingKey(rating) {
21
+ return JSON.stringify(rating ? [rating.score, rating.current, rating.comment || null] : null);
22
+ }
23
+
24
+ /**
25
+ * The rating's word on a history row: the score they gave, or a nudge on a
26
+ * settled report that is waiting for one. Nothing on an open report.
27
+ */
28
+ function historyRating(r) {
29
+ if (r.rating) {
30
+ return ` · <span class="rated" aria-label="You rated this ${Number(r.rating)} out of 5">${ICONS.star}${Number(r.rating)}</span>`;
31
+ }
32
+ return r.ratingAsked ? ' · <span class="rate-ask">Rate it</span>' : '';
33
+ }
34
+
18
35
  export function mountThread(ctx) {
19
36
  const {
20
37
  client, scrim, close,
@@ -88,7 +105,7 @@ export function mountThread(ctx) {
88
105
  openReportId = null;
89
106
  panel.classList.remove('is-chat');
90
107
  panel.innerHTML = `
91
- ${headHtml({ title: 'Your reports', sub: "Everything you've reported, and where each one got to." })}
108
+ ${headHtml({ title: 'Your reports', sub: 'Where each one has got to.', theme: ctx.themeLabel() })}
92
109
  <div class="history" aria-busy="true">${skeletonRows(3)}</div>`;
93
110
 
94
111
  panel.querySelector('[data-act="close"]').addEventListener('click', close);
@@ -113,7 +130,7 @@ export function mountThread(ctx) {
113
130
  <span class="row-ico" aria-hidden="true">${ICONS.chat}</span>
114
131
  <span class="who">
115
132
  <span class="ttl">${escapeHtml(r.title)}</span>
116
- <span class="sub">${escapeHtml(r.reference)} · ${escapeHtml(relativeDay(r.createdAt))}</span>
133
+ <span class="sub">${escapeHtml(r.reference)} · ${escapeHtml(relativeDay(r.createdAt))}${historyRating(r)}</span>
117
134
  </span>
118
135
  <span class="pill ${escapeHtml(String(r.status).toLowerCase())}">${escapeHtml(statusWord(r.status))}</span>
119
136
  <span class="chev" aria-hidden="true">${ICONS.chevronRight}</span>
@@ -146,7 +163,8 @@ export function mountThread(ctx) {
146
163
  ${headHtml({
147
164
  back: true,
148
165
  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.',
166
+ sub: fresh ? 'It may ask you a question here.' : 'Updates, and a way to reply.',
167
+ theme: ctx.themeLabel(),
150
168
  })}
151
169
  <div class="history chatting" aria-busy="true"><div class="thread">${skeletonBubbles()}</div></div>`;
152
170
 
@@ -199,6 +217,7 @@ export function mountThread(ctx) {
199
217
  threadDrawn.set(thread, drawn);
200
218
  scrollToLatest(thread);
201
219
  mountVerification(body, report);
220
+ mountRating(body, report);
202
221
  if (report.canReply) mountComposer(ctx, body, thread, bugId);
203
222
  mountAssistant(body, report, bugId);
204
223
  // Again, now the cards above it have taken their share of the height: the
@@ -246,9 +265,8 @@ export function mountThread(ctx) {
246
265
  const content = `
247
266
  <div class="as-body${previous && previous !== a.phase ? ' entering' : ''}">
248
267
  <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>
268
+ <span class="as-orb${a.phase === 'calling' || a.phase === 'resolved' ? '' : ' orb'}" aria-hidden="true">${
269
+ a.phase === 'calling' ? ICONS.phone : a.phase === 'resolved' ? ICONS.check : ''}</span>
252
270
  <div class="as-text">
253
271
  <div class="as-title">${escapeHtml(a.title)}</div>
254
272
  <div class="as-detail">${escapeHtml(a.detail)}</div>
@@ -314,6 +332,97 @@ export function mountThread(ctx) {
314
332
  body.querySelector('.report-head')?.after(box);
315
333
  }
316
334
 
335
+ /**
336
+ * "How did we do?" — five stars under a settled report.
337
+ *
338
+ * Only where the API says a rating is being asked for: the report is settled
339
+ * and the project has not switched ratings off. One tap sends the score; a
340
+ * note is offered after it rather than before, because a form that asks for
341
+ * a sentence first is a form people close, and the score is the part that
342
+ * counts.
343
+ *
344
+ * Redrawn only when the rating itself changed. The thread refreshes on every
345
+ * update and every few seconds while the assistant works, and a box rebuilt
346
+ * on each of those would eat the note somebody was halfway through.
347
+ */
348
+ function mountRating(body, report) {
349
+ let box = body.querySelector('.fix-rating');
350
+ const settled = ['RESOLVED', 'CLOSED'].includes(report.status);
351
+ if (!settled || !report.ratingAsked) {
352
+ box?.remove();
353
+ return;
354
+ }
355
+ if (box && box.dataset.key === ratingKey(report.rating)) return;
356
+ if (!box) {
357
+ box = document.createElement('div');
358
+ box.className = 'fix-rating';
359
+ (body.querySelector('.fix-verification') || body.querySelector('.report-head'))?.after(box);
360
+ }
361
+ drawRating(box, report, report.rating || null);
362
+ }
363
+
364
+ function drawRating(box, report, rating, { justRated = false } = {}) {
365
+ box.dataset.key = ratingKey(rating);
366
+ // A rating given for an earlier fix stands, but it is not this fix's: the
367
+ // stars start empty and the sentence says what the old score was.
368
+ const shown = rating?.current ? rating.score : 0;
369
+ const question = !rating
370
+ ? 'How did we do?'
371
+ : !rating.current
372
+ ? `You rated an earlier fix ${rating.score} out of 5. How did this one go?`
373
+ : justRated
374
+ ? 'Thanks — the team will see that.'
375
+ : `You rated this ${rating.score} out of 5.`;
376
+ const askNote = justRated && rating?.current && !rating.comment;
377
+ box.innerHTML = `
378
+ <p class="fr-q">${escapeHtml(question)}</p>
379
+ <div class="stars" role="group" aria-label="Rate how this was handled, 1 to 5">${[1, 2, 3, 4, 5].map((n) => `
380
+ <button type="button" data-score="${n}" aria-pressed="${n === shown}" aria-label="${n} out of 5${n === 1 ? ', poor' : n === 5 ? ', great' : ''}" class="${n <= shown ? 'on' : ''}">${ICONS.star}</button>`).join('')}
381
+ </div>
382
+ ${rating?.current && rating.comment ? `<p class="fr-said">“${escapeHtml(rating.comment)}”</p>` : ''}
383
+ ${askNote ? `
384
+ <div class="fr-note">
385
+ <textarea maxlength="2000" aria-label="Add a note to your rating" placeholder="${rating.score <= 2 ? 'What went wrong? (optional)' : 'Anything to add? (optional)'}"></textarea>
386
+ <button type="button" data-act="rating-note">Add note</button>
387
+ </div>` : ''}
388
+ ${rating ? '' : '<p class="fr-hint">1 is poor, 5 is great.</p>'}
389
+ <p class="feedback-status" role="status"></p>`;
390
+
391
+ const stars = [...box.querySelectorAll('[data-score]')];
392
+ const paint = (upTo) => stars.forEach((b) => b.classList.toggle('on', Number(b.dataset.score) <= upTo));
393
+ const lock = (on) => box.querySelectorAll('button, textarea').forEach((el) => { el.disabled = on; });
394
+ const status = box.querySelector('.feedback-status');
395
+
396
+ async function send(score, comment) {
397
+ lock(true);
398
+ status.textContent = '';
399
+ try {
400
+ const saved = await client.rate(report.id, { score, comment });
401
+ drawRating(box, report, saved, { justRated: true });
402
+ box.querySelector('.fr-note textarea')?.focus();
403
+ } catch (err) {
404
+ status.textContent = err.message;
405
+ lock(false);
406
+ paint(shown);
407
+ }
408
+ }
409
+
410
+ for (const btn of stars) {
411
+ const n = Number(btn.dataset.score);
412
+ btn.addEventListener('mouseenter', () => paint(n));
413
+ btn.addEventListener('focus', () => paint(n));
414
+ btn.addEventListener('blur', () => paint(shown));
415
+ btn.addEventListener('click', () => send(n));
416
+ }
417
+ box.querySelector('.stars').addEventListener('mouseleave', () => paint(shown));
418
+
419
+ box.querySelector('[data-act="rating-note"]')?.addEventListener('click', () => {
420
+ const note = box.querySelector('.fr-note textarea').value.trim();
421
+ if (!note) { box.querySelector('.fr-note')?.remove(); return; }
422
+ send(rating.score, note);
423
+ });
424
+ }
425
+
317
426
  /**
318
427
  * Redraw the open thread in place, keeping whatever is half-typed.
319
428
  *
@@ -334,6 +443,7 @@ export function mountThread(ctx) {
334
443
  if (ctx.closed || !report || openReportId !== bugId) return;
335
444
 
336
445
  mountVerification(body, report);
446
+ mountRating(body, report);
337
447
  const pinned = atLatest(thread);
338
448
  const html = threadHtml(report.timeline);
339
449
  // 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.');