@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sphoro/bugcatch-web-sdk",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Drop-in browser SDK — report bugs with console, network and environment context, without the extension.",
5
5
  "type": "module",
6
6
  "main": "dist/bugcatch.es.js",
package/src/accent.js ADDED
@@ -0,0 +1,38 @@
1
+ // The colour the widget's buttons wear, and the colour of the words on them.
2
+ //
3
+ // With no `accent` configured the widget is ink: a black pill on a light page
4
+ // and a white one on a dark page, which sits on any brand without arguing with
5
+ // it. A configured accent is the host's own colour, and the text on it is
6
+ // whichever of white or ink can be read against it — a pale brand colour with
7
+ // white words on it is a button nobody can read.
8
+
9
+ export const INK = '#0e0e10';
10
+ export const PAPER = '#ffffff';
11
+
12
+ /**
13
+ * White or ink, whichever reads on `color`. Only colours this can parse (#rgb,
14
+ * #rrggbb, rgb()) are measured; anything else (a name, hsl(), a variable) gets
15
+ * white, which is what every accent got before this existed.
16
+ */
17
+ export function readableOn(color) {
18
+ const rgb = parse(color);
19
+ if (!rgb) return PAPER;
20
+ const [r, g, b] = rgb.map((c) => {
21
+ const s = c / 255;
22
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
23
+ });
24
+ const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
25
+ // The crossover where ink starts to out-contrast white.
26
+ return luminance > 0.179 ? INK : PAPER;
27
+ }
28
+
29
+ function parse(color) {
30
+ const s = String(color || '').trim();
31
+ let m = /^#([0-9a-f]{3})$/i.exec(s);
32
+ if (m) return [...m[1]].map((h) => parseInt(h + h, 16));
33
+ m = /^#([0-9a-f]{6})$/i.exec(s);
34
+ if (m) return [0, 2, 4].map((i) => parseInt(m[1].slice(i, i + 2), 16));
35
+ m = /^rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/i.exec(s);
36
+ if (m) return [m[1], m[2], m[3]].map(Number);
37
+ return null;
38
+ }
@@ -37,6 +37,8 @@
37
37
  // yet, which is its rule and not ours — the card rings silently rather than not
38
38
  // appearing.
39
39
 
40
+ import { readableOn, INK, PAPER } from './accent.js';
41
+
40
42
  /**
41
43
  * A quiet double-pulse, once every three seconds.
42
44
  *
@@ -157,8 +159,8 @@ const BUTTON = [
157
159
  'flex:1',
158
160
  'padding:9px 12px',
159
161
  'border:0',
160
- 'border-radius:8px',
161
- 'font:600 13px/1.4 inherit',
162
+ 'border-radius:999px',
163
+ 'font:560 13px/1.4 inherit',
162
164
  'cursor:pointer',
163
165
  ].join(';');
164
166
 
@@ -168,7 +170,8 @@ const BUTTON = [
168
170
  * @param {object} opts
169
171
  * @param {object} opts.call The `incomingCall` object off the update stream —
170
172
  * `callId`, `from`, `project`, `reference`, `ringsUntil`, `recorded`.
171
- * @param {string} opts.accent The integration's own colour, for Answer.
173
+ * @param {string|null} opts.accent The integration's own colour, for Answer;
174
+ * null is the ink default, which on this dark card is a white button.
172
175
  * @param {string} [opts.position] Same two words the toast takes.
173
176
  * @param {() => void} opts.onAnswer Pressed Answer.
174
177
  * @param {() => void} opts.onDecline Pressed Decline.
@@ -181,7 +184,7 @@ const BUTTON = [
181
184
  * an ordinary dismissal; the caller passes one only for its own logging.
182
185
  */
183
186
  export function showIncomingCall({
184
- call, accent = '#5b6ef5', position = 'bottom-right',
187
+ call, accent = null, position = 'bottom-right',
185
188
  onAnswer, onDecline, onExpire, touched = () => false,
186
189
  }) {
187
190
  if (typeof document === 'undefined' || !document.body) return () => {};
@@ -200,13 +203,12 @@ export function showIncomingCall({
200
203
  'z-index:2147483647',
201
204
  'width:300px',
202
205
  'max-width:calc(100vw - 40px)',
203
- 'padding:14px',
204
- 'border-radius:12px',
205
- 'background:#1b1e26',
206
- 'color:#eceef2',
206
+ 'padding:16px',
207
+ 'border-radius:16px',
208
+ 'background:#111113',
209
+ 'color:#f3f2ef',
207
210
  'font:13px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif',
208
- 'box-shadow:0 18px 44px rgba(0,0,0,0.5)',
209
- `border:1px solid ${accent}`,
211
+ `box-shadow:0 0 0 1px ${accent ? `color-mix(in srgb, ${accent} 55%, transparent)` : 'rgba(255,255,255,.12)'},0 24px 50px -12px rgba(0,0,0,.6)`,
210
212
  'opacity:0',
211
213
  'transform:translateY(8px)',
212
214
  'transition:opacity .2s ease,transform .2s ease',
@@ -219,23 +221,23 @@ export function showIncomingCall({
219
221
  heading.textContent = `${call.from || 'Support'} is calling`;
220
222
 
221
223
  const about = document.createElement('div');
222
- about.style.cssText = 'color:#a4abba;margin-bottom:10px';
224
+ about.style.cssText = 'color:#b3b0aa;margin-bottom:10px';
223
225
  about.textContent = call.project
224
226
  ? `About your report ${call.reference} · ${call.project}`
225
227
  : `About your report ${call.reference}`;
226
228
 
227
229
  const countdown = document.createElement('div');
228
- countdown.style.cssText = 'color:#7d8598;font-size:12px;margin-bottom:10px';
230
+ countdown.style.cssText = 'color:#8f8c86;font-size:12px;margin-bottom:10px';
229
231
 
230
232
  const answer = document.createElement('button');
231
233
  answer.type = 'button';
232
234
  answer.textContent = 'Answer';
233
- answer.style.cssText = `${BUTTON};background:${accent};color:#fff`;
235
+ answer.style.cssText = `${BUTTON};background:${accent || PAPER};color:${accent ? readableOn(accent) : INK}`;
234
236
 
235
237
  const decline = document.createElement('button');
236
238
  decline.type = 'button';
237
239
  decline.textContent = 'Not now';
238
- decline.style.cssText = `${BUTTON};background:#2b2f3a;color:#c8cedb`;
240
+ decline.style.cssText = `${BUTTON};background:rgba(255,255,255,.08);color:#f3f2ef`;
239
241
 
240
242
  const actions = document.createElement('div');
241
243
  actions.style.cssText = 'display:flex;gap:8px';
@@ -244,7 +246,7 @@ export function showIncomingCall({
244
246
  el.append(heading, about, countdown);
245
247
  if (call.recorded) {
246
248
  const kept = document.createElement('div');
247
- kept.style.cssText = 'color:#7d8598;font-size:12px;margin-bottom:10px';
249
+ kept.style.cssText = 'color:#8f8c86;font-size:12px;margin-bottom:10px';
248
250
  kept.textContent = 'This call will be recorded.';
249
251
  el.append(kept);
250
252
  }
package/src/index.d.ts CHANGED
@@ -196,8 +196,12 @@ export interface BugCatchConfig {
196
196
  widgetPosition?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
197
197
  /** 'auto' follows the visitor's system setting. Default 'auto'. */
198
198
  theme?: 'auto' | 'light' | 'dark';
199
- /** Any CSS colour. Default '#4F46E5'. */
200
- accent?: string;
199
+ /**
200
+ * Any CSS colour, for the launcher, the buttons and the assistant's orb. The
201
+ * words on it are white or near-black, whichever reads. Default null: ink,
202
+ * which is a black button on light and a white one on dark.
203
+ */
204
+ accent?: string | null;
201
205
  /** Offer screenshot / recording / file attach in the widget. Default true. */
202
206
  attachments?: boolean;
203
207
  /**
@@ -462,6 +466,25 @@ export interface MyReport {
462
466
  project: string;
463
467
  workspace: string;
464
468
  attachments: number;
469
+ /** Their score for how it was handled, 1–5, or null when they have not rated it. */
470
+ rating: number | null;
471
+ /** Settled, on a project that asks for ratings — so a list can say "rate it". */
472
+ ratingAsked: boolean;
473
+ }
474
+
475
+ /**
476
+ * A reporter's own rating of how a settled report was handled.
477
+ *
478
+ * `current` is false when the report has been reopened and settled again since
479
+ * it was given — the rating stands, but it was for an earlier fix.
480
+ */
481
+ export interface ReportRating {
482
+ /** 1–5, five best. */
483
+ score: number;
484
+ comment: string | null;
485
+ ratedAt: string;
486
+ via: 'widget' | 'portal' | 'email';
487
+ current: boolean;
465
488
  }
466
489
 
467
490
  /** A report of this browser's changed status. */
@@ -552,6 +575,18 @@ export interface ReportDetail {
552
575
  attachments: { type: string; mime: string; createdAt: string; url: string | null }[];
553
576
  /** Every step from filing to now, oldest first, both sides' messages included. */
554
577
  timeline: ReportTimelineEntry[];
578
+ /** When it was last settled, or null while it is open. */
579
+ resolvedAt: string | null;
580
+ /** Their answer to "does the fix work?", or null when they have not given one. */
581
+ verification: 'CONFIRMED' | 'STILL_HAPPENING' | null;
582
+ /** Their rating of how it was handled, or null. See rate(). */
583
+ rating: ReportRating | null;
584
+ /**
585
+ * Whether a rating is being asked for: the report is settled and the project
586
+ * has not switched ratings off. The widget draws its stars only when this is
587
+ * true; an integration's own view should do the same.
588
+ */
589
+ ratingAsked: boolean;
555
590
  /**
556
591
  * The same figure they were given when they filed it, so coming back to check
557
592
  * is not a worse experience than not checking. Null on a settled report — the
@@ -787,6 +822,12 @@ export declare class BugCatchClient {
787
822
  endCall(): this;
788
823
  /** Tell the team whether their fix worked. STILL_HAPPENING re-sends this browser's context. */
789
824
  verifyFix(bugId: string, feedback: { result: 'CONFIRMED' | 'STILL_HAPPENING'; resolvedAt: string; note?: string }): Promise<{ verification: string; status: string }>;
825
+ /**
826
+ * Rate how a settled report was handled, 1–5, five best. Rating again
827
+ * replaces the earlier score. Rejects while the report is open, or on a
828
+ * project that does not ask for ratings (`ratingAsked` is false).
829
+ */
830
+ rate(bugId: string, rating: { score: 1 | 2 | 3 | 4 | 5; comment?: string }): Promise<ReportRating>;
790
831
  /** One report with its full timeline, or null if this browser cannot prove it filed it. */
791
832
  myReport(bugId: string): Promise<ReportDetail | null>;
792
833
  /**
package/src/index.js CHANGED
@@ -6,7 +6,7 @@ import { mountWidget } from './widget.js';
6
6
  import { uploadAttachment, normalizeMime, REPORT_MIMES } from './capture.js';
7
7
  import {
8
8
  watchUpdates, createUpdateNotifier, fetchMyReports, fetchReport, writeToken, readToken,
9
- clearToken, scopeReports, sendMessage, verifyFix, callKindsForProject,
9
+ clearToken, scopeReports, sendMessage, verifyFix, rateReport, callKindsForProject,
10
10
  reportByCall, liveCallAvailable, requestLiveCall, giveUpLiveCall,
11
11
  answerIncomingCall, declineIncomingCall,
12
12
  readUnread, rememberUpdate, forgetUpdate, forgetReportUpdates, forgetAllUpdates,
@@ -49,7 +49,9 @@ const DEFAULTS = {
49
49
  widgetPosition: 'bottom-right',
50
50
  // 'auto' follows the visitor's system setting; 'light' / 'dark' pin it.
51
51
  theme: 'auto',
52
- accent: '#4F46E5',
52
+ // null is ink: black buttons on light, white on dark (accent.js). Any CSS
53
+ // colour makes the buttons, the launcher and the assistant's orb that colour.
54
+ accent: null,
53
55
  // Draw what the project's team is telling visitors — written on the Widget
54
56
  // announcements settings page — in the panel, and beside the button for the
55
57
  // ones written for that. false draws nothing; announcements() still answers,
@@ -456,6 +458,28 @@ class BugCatchClient {
456
458
  this.notifier.render(unread);
457
459
  }
458
460
 
461
+ /**
462
+ * Tell the team whether their fix worked. STILL_HAPPENING reopens the report
463
+ * and re-sends this browser's context with it.
464
+ */
465
+ async verifyFix(bugId, { result, resolvedAt, note }) {
466
+ const context = result === 'STILL_HAPPENING' ? await this.#payload({}) : {};
467
+ return verifyFix({ apiBase: this.config.apiBase, bugId, result, resolvedAt, note, metadata: context.metadata, logs: context.logs });
468
+ }
469
+
470
+ /**
471
+ * Rate how a settled report was handled, 1 to 5, five best.
472
+ *
473
+ * await BugCatch.rate(report.id, { score: 4, comment: 'Quick, thanks' });
474
+ *
475
+ * Only once the report is RESOLVED or CLOSED, on a project that asks for
476
+ * ratings — `myReport(id).ratingAsked` says whether it does. Rating again
477
+ * replaces the earlier score rather than adding a second one.
478
+ */
479
+ async rate(bugId, { score, comment } = {}) {
480
+ return rateReport({ apiBase: this.config.apiBase, bugId, score, comment });
481
+ }
482
+
459
483
  /**
460
484
  * One report in full, with its timeline — every step from filing to now.
461
485
  *
@@ -464,11 +488,6 @@ class BugCatchClient {
464
488
  *
465
489
  * Resolves to null when this browser cannot prove it filed that report.
466
490
  */
467
- async verifyFix(bugId, { result, resolvedAt, note }) {
468
- const context = result === 'STILL_HAPPENING' ? await this.#payload({}) : {};
469
- return verifyFix({ apiBase: this.config.apiBase, bugId, result, resolvedAt, note, metadata: context.metadata, logs: context.logs });
470
- }
471
-
472
491
  async myReport(bugId) {
473
492
  const report = await fetchReport({ apiBase: this.config.apiBase, bugId });
474
493
  this.#notePhase(bugId, report?.assistant);
@@ -17,6 +17,8 @@ export const retry = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
17
17
  // The assistant's mark: a four-point spark, drawn wherever it speaks.
18
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
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>';
20
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>';
21
23
 
22
24
  /* The sphoro lockup, as outlines rather than text.
@@ -44,10 +44,11 @@ import { mountThread } from './thread.js';
44
44
  * to load code of its own.
45
45
  */
46
46
  export function openPanel({
47
- root, host, dock, client, onClose, preloadCall = () => {},
47
+ root, host, dock, client, onClose, preloadCall = () => {}, themeControls = null,
48
48
  }) {
49
49
  addStyles(root);
50
50
  const cfg = client.config;
51
+ const themeLabel = () => themeControls?.label() || '';
51
52
 
52
53
  const scrim = document.createElement('div');
53
54
  // The panel opens out of whichever corner the dock is in, clearing it by the
@@ -59,8 +60,11 @@ export function openPanel({
59
60
  <div class="panel" role="dialog" aria-modal="true" aria-labelledby="bc-h">
60
61
  ${headHtml({
61
62
  id: 'bc-h',
62
- title: 'Tell us what went wrong',
63
- sub: "Our assistant starts looking right away. We attach the page details for you.",
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(),
64
68
  })}
65
69
  <div class="body">
66
70
  <div class="slot" role="alert"></div>
@@ -133,6 +137,7 @@ export function openPanel({
133
137
  say,
134
138
  close,
135
139
  preloadCall,
140
+ themeLabel,
136
141
  onDestroy: (fn) => { cleanups.push(fn); },
137
142
  // compose.js
138
143
  refreshSendState: null,
@@ -218,6 +223,15 @@ export function openPanel({
218
223
  document.addEventListener('keydown', onKeydown, true);
219
224
  scrim.addEventListener('click', (e) => { if (e.target === scrim && !ctx.sending) close(); });
220
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
+ });
221
235
 
222
236
  /** The banner at the top of the form: an error, or with `info`, a notice. */
223
237
  function say(text, kind = 'err') {
@@ -100,7 +100,7 @@ export function messageHtml(message, state = '') {
100
100
  let avatar = '';
101
101
  if (automated) {
102
102
  who = '<span class="msg-who auto">Assistant <span class="tag">AI</span></span>';
103
- avatar = `<span class="av bot" aria-hidden="true">${ICONS.sparkle}</span>`;
103
+ avatar = `<span class="av bot orb" aria-hidden="true"></span>`;
104
104
  } else if (!mine) {
105
105
  const name = message.author || 'Support team';
106
106
  who = `<span class="msg-who">${escapeHtml(name)}</span>`;
@@ -140,7 +140,7 @@ export function messageHtml(message, state = '') {
140
140
  export function typingHtml() {
141
141
  return `
142
142
  <div class="typing-row">
143
- <span class="av bot" aria-hidden="true">${ICONS.sparkle}</span>
143
+ <span class="av bot orb" aria-hidden="true"></span>
144
144
  <span class="msg-who auto">Assistant <span class="tag">AI</span></span>
145
145
  <div class="bubble typing" role="img" aria-label="The assistant is working on it"><i></i><i></i><i></i></div>
146
146
  </div>`;
@@ -18,14 +18,18 @@ export function mountPicks(ctx) {
18
18
  function drawPicks() {
19
19
  if (!agentIssues.length) { picks.hidden = true; picks.innerHTML = ''; return; }
20
20
  picks.hidden = false;
21
+ // Chips, not cards: they are a shortcut in front of the form, and three
22
+ // two-line cards pushed the form itself off the first screen. The hint is
23
+ // still there — as the chip's tooltip and its accessible description.
21
24
  picks.innerHTML = `
22
25
  <p class="picks-h">Get an answer right away</p>
23
26
  <div class="picks-grid" role="group" aria-label="Common questions">
24
- ${agentIssues.map((issue) => `
27
+ ${agentIssues.map((issue, i) => `
25
28
  <button type="button" class="pick" data-pick="${escapeHtml(issue.key)}"
29
+ ${issue.hint ? `title="${escapeHtml(issue.hint)}" aria-describedby="bc-pick-h${i}"` : ''}
26
30
  aria-pressed="${pickedIssue?.key === issue.key}">
27
31
  <span class="t">${escapeHtml(issue.title)}</span>
28
- ${issue.hint ? `<span class="h">${escapeHtml(issue.hint)}</span>` : ''}
32
+ ${issue.hint ? `<span class="h sr-only" id="bc-pick-h${i}">${escapeHtml(issue.hint)}</span>` : ''}
29
33
  </button>`).join('')}
30
34
  </div>
31
35
  ${pickedIssue ? `<p class="picks-note">${ICONS.check}<span>Our assistant will check your account and reply here. Add anything else below, then send.</span></p>` : ''}`;
package/src/panel/refs.js CHANGED
@@ -137,7 +137,7 @@ export function mountReferences(ctx) {
137
137
  const many = kinds.length > 6;
138
138
  refsBox.innerHTML = `
139
139
  <div class="ask-head">
140
- <span class="avatar" aria-hidden="true">${ICONS.sparkle}</span>
140
+ <span class="avatar orb" aria-hidden="true"></span>
141
141
  <div>
142
142
  <strong>${escapeHtml(head[0])}</strong>
143
143
  <p class="why">${escapeHtml(head[1])}</p>
@@ -6,18 +6,26 @@
6
6
  import * as ICONS from './icons.js';
7
7
 
8
8
  /**
9
- * The panel's header, on every screen. The assistant's avatar carries the
9
+ * The panel's header, on every screen. The assistant's orb carries the
10
10
  * identity; `back` adds the way out of a report to the list of them.
11
+ *
12
+ * `theme` is the name of the theme in force ("Light", "Dark", "System
13
+ * default"); given one, the header carries the button that moves to the next.
14
+ * The button holds all three glyphs and the stylesheet shows the one matching
15
+ * the host element's class, so it is right on every screen without anyone
16
+ * redrawing it. Its press is handled once, on the scrim (index.js).
11
17
  */
12
- export function headHtml({ title, sub, id = '', back = false }) {
18
+ export function headHtml({ title, sub, id = '', back = false, theme = '' }) {
13
19
  return `
14
20
  <div class="head">
15
21
  ${back ? `<button class="icon-btn back" type="button" data-act="back" aria-label="Back to your reports">${ICONS.back}</button>` : ''}
16
- <div class="mark" aria-hidden="true">${ICONS.sparkle}</div>
22
+ <span class="orb mark" aria-hidden="true"></span>
17
23
  <div class="titles">
18
24
  <h2${id ? ` id="${id}"` : ''}>${escapeHtml(title)}</h2>
19
25
  <p>${escapeHtml(sub)}</p>
20
26
  </div>
27
+ ${theme ? `<button class="icon-btn theme" type="button" data-act="theme" title="Theme: ${escapeHtml(theme)}"
28
+ aria-label="Theme: ${escapeHtml(theme)}. Change theme"><span class="m-light">${ICONS.sun}</span><span class="m-dark">${ICONS.moon}</span><span class="m-auto">${ICONS.auto}</span></button>` : ''}
21
29
  <button class="icon-btn" type="button" data-act="close" aria-label="Close">${ICONS.close}</button>
22
30
  </div>`;
23
31
  }