@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.
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.20.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,34 @@ 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
+ /** When it was resolved or closed; null while it is open. */
474
+ resolvedAt: string | null;
475
+ /**
476
+ * Whether the widget's first screen asks for this report's rating: 'ask' with
477
+ * a Not now, 'require' before another report can be sent from the widget, or
478
+ * null. Set by the project (Settings → Resolutions → Where the widget asks),
479
+ * for reports settled in the last 30 days and not yet rated for this fix.
480
+ */
481
+ ratingDue: 'ask' | 'require' | null;
482
+ }
483
+
484
+ /**
485
+ * A reporter's own rating of how a settled report was handled.
486
+ *
487
+ * `current` is false when the report has been reopened and settled again since
488
+ * it was given — the rating stands, but it was for an earlier fix.
489
+ */
490
+ export interface ReportRating {
491
+ /** 1–5, five best. */
492
+ score: number;
493
+ comment: string | null;
494
+ ratedAt: string;
495
+ via: 'widget' | 'portal' | 'email';
496
+ current: boolean;
465
497
  }
466
498
 
467
499
  /** A report of this browser's changed status. */
@@ -552,6 +584,18 @@ export interface ReportDetail {
552
584
  attachments: { type: string; mime: string; createdAt: string; url: string | null }[];
553
585
  /** Every step from filing to now, oldest first, both sides' messages included. */
554
586
  timeline: ReportTimelineEntry[];
587
+ /** When it was last settled, or null while it is open. */
588
+ resolvedAt: string | null;
589
+ /** Their answer to "does the fix work?", or null when they have not given one. */
590
+ verification: 'CONFIRMED' | 'STILL_HAPPENING' | null;
591
+ /** Their rating of how it was handled, or null. See rate(). */
592
+ rating: ReportRating | null;
593
+ /**
594
+ * Whether a rating is being asked for: the report is settled and the project
595
+ * has not switched ratings off. The widget draws its stars only when this is
596
+ * true; an integration's own view should do the same.
597
+ */
598
+ ratingAsked: boolean;
555
599
  /**
556
600
  * The same figure they were given when they filed it, so coming back to check
557
601
  * is not a worse experience than not checking. Null on a settled report — the
@@ -787,6 +831,12 @@ export declare class BugCatchClient {
787
831
  endCall(): this;
788
832
  /** Tell the team whether their fix worked. STILL_HAPPENING re-sends this browser's context. */
789
833
  verifyFix(bugId: string, feedback: { result: 'CONFIRMED' | 'STILL_HAPPENING'; resolvedAt: string; note?: string }): Promise<{ verification: string; status: string }>;
834
+ /**
835
+ * Rate how a settled report was handled, 1–5, five best. Rating again
836
+ * replaces the earlier score. Rejects while the report is open, or on a
837
+ * project that does not ask for ratings (`ratingAsked` is false).
838
+ */
839
+ rate(bugId: string, rating: { score: 1 | 2 | 3 | 4 | 5; comment?: string }): Promise<ReportRating>;
790
840
  /** One report with its full timeline, or null if this browser cannot prove it filed it. */
791
841
  myReport(bugId: string): Promise<ReportDetail | null>;
792
842
  /**
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);
package/src/panel/call.js CHANGED
@@ -34,8 +34,9 @@ export function mountCallOffer(ctx) {
34
34
  ctx.preloadCall();
35
35
  // Never over a panel that is still asking who this is: the call would
36
36
  // file a report, and on a project that requires sign-in that is the one
37
- // thing this panel is holding back.
38
- callBtn.hidden = ctx.mustSignIn();
37
+ // thing this panel is holding back. Nor over one holding the form for
38
+ // a rating (rate-due.js), for the same reason.
39
+ callBtn.hidden = ctx.mustSignIn() || ctx.mustRate();
39
40
  })
40
41
  .catch(() => { /* not available is the safe answer to every failure */ });
41
42
  }
@@ -132,7 +132,9 @@ export function mountCompose(ctx) {
132
132
  void persistDraft();
133
133
  };
134
134
  }).catch(() => { draftStatus.textContent = 'Draft storage is unavailable. Keep this tab open.'; })
135
- .finally(() => { compose.inert = false; if (!draftStatus.querySelector('[data-resume]')) draftLoaded = true; });
135
+ // Back to whatever the rating card wants: it may have held the form
136
+ // while storage was being read (rate-due.js).
137
+ .finally(() => { compose.inert = ctx.mustRate(); if (!draftStatus.querySelector('[data-resume]')) draftLoaded = true; });
136
138
  }
137
139
 
138
140
  // Saved on the way out, before anything else is taken down.
@@ -173,8 +175,10 @@ export function mountCompose(ctx) {
173
175
 
174
176
  function refreshSendState() {
175
177
  const busy = items.some((it) => it.status === 'uploading');
176
- sendBtn.disabled = ctx.sending || busy || items.some((it) => it.status === 'failed');
177
- note.textContent = busy ? 'Waiting for the upload to finish…' : items.some((it) => it.status === 'failed') ? 'Retry or remove failed attachments before sending.' : '';
178
+ const held = ctx.mustRate();
179
+ sendBtn.disabled = ctx.sending || busy || held || items.some((it) => it.status === 'failed');
180
+ note.textContent = held ? 'Rate your last report to send a new one.'
181
+ : busy ? 'Waiting for the upload to finish…' : items.some((it) => it.status === 'failed') ? 'Retry or remove failed attachments before sending.' : '';
178
182
  const max = cfg.maxAttachments ?? 5;
179
183
  scrim.querySelectorAll('.tool').forEach((t) => {
180
184
  t.disabled = ctx.sending || items.length >= max;
@@ -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.
@@ -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
+ // rate-due.js "how did we do?" about a settled report, above the form
11
12
  // refs.js "which record is this about", asked at Send
12
13
  // notices.js what the project's team is telling visitors, at the top
13
14
  // known.js what the project already knows is broken, and "me too"
@@ -37,6 +38,7 @@ import { mountPicks } from './picks.js';
37
38
  import { mountCallOffer } from './call.js';
38
39
  import { mountSignIn } from './signin.js';
39
40
  import { mountThread } from './thread.js';
41
+ import { mountRateDue } from './rate-due.js';
40
42
 
41
43
  /**
42
44
  * Open the panel over the page. `preloadCall` fetches the call code ahead of a
@@ -44,10 +46,11 @@ import { mountThread } from './thread.js';
44
46
  * to load code of its own.
45
47
  */
46
48
  export function openPanel({
47
- root, host, dock, client, onClose, preloadCall = () => {},
49
+ root, host, dock, client, onClose, preloadCall = () => {}, themeControls = null,
48
50
  }) {
49
51
  addStyles(root);
50
52
  const cfg = client.config;
53
+ const themeLabel = () => themeControls?.label() || '';
51
54
 
52
55
  const scrim = document.createElement('div');
53
56
  // The panel opens out of whichever corner the dock is in, clearing it by the
@@ -59,11 +62,17 @@ export function openPanel({
59
62
  <div class="panel" role="dialog" aria-modal="true" aria-labelledby="bc-h">
60
63
  ${headHtml({
61
64
  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.",
65
+ // Not "Tell us what went wrong": that is the first field's own label,
66
+ // 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.',
69
+ theme: themeLabel(),
64
70
  })}
65
71
  <div class="body">
66
72
  <div class="slot" role="alert"></div>
73
+ <!-- A settled report still waiting for its rating. Drawn only when the
74
+ project asks for one here; see rate-due.js. -->
75
+ <div class="rate-due fix-rating" tabindex="-1" hidden></div>
67
76
  <!-- What the project's team is telling visitors. Drawn only when it
68
77
  has published something. -->
69
78
  <div class="notices" hidden></div>
@@ -127,12 +136,14 @@ export function openPanel({
127
136
  notices: $('.notices'),
128
137
  known: $('.known'),
129
138
  picks: $('.picks'),
139
+ rateDue: $('.rate-due'),
130
140
  // A report or a call is on its way. Escape and a click outside wait for it.
131
141
  sending: false,
132
142
  closed: false,
133
143
  say,
134
144
  close,
135
145
  preloadCall,
146
+ themeLabel,
136
147
  onDestroy: (fn) => { cleanups.push(fn); },
137
148
  // compose.js
138
149
  refreshSendState: null,
@@ -150,6 +161,8 @@ export function openPanel({
150
161
  // signin.js
151
162
  mustSignIn: null,
152
163
  recheckSignIn: null,
164
+ // rate-due.js
165
+ mustRate: () => false,
153
166
  // thread.js
154
167
  succeed: null,
155
168
  refreshThread: null,
@@ -166,6 +179,7 @@ export function openPanel({
166
179
  mountCallOffer(ctx);
167
180
  mountSignIn(ctx);
168
181
  mountThread(ctx);
182
+ mountRateDue(ctx);
169
183
 
170
184
  /* ---------- lifecycle ---------- */
171
185
 
@@ -218,6 +232,15 @@ export function openPanel({
218
232
  document.addEventListener('keydown', onKeydown, true);
219
233
  scrim.addEventListener('click', (e) => { if (e.target === scrim && !ctx.sending) close(); });
220
234
  $('[data-act="close"]').addEventListener('click', close);
235
+ // On the scrim rather than the button: every screen draws its own header, and
236
+ // the scrim is the one element all of them are drawn inside.
237
+ scrim.addEventListener('click', (e) => {
238
+ const btn = e.target.closest('[data-act="theme"]');
239
+ if (!btn || !themeControls) return;
240
+ const next = themeControls.cycle();
241
+ btn.title = `Theme: ${next.label}`;
242
+ btn.setAttribute('aria-label', `Theme: ${next.label}. Change theme`);
243
+ });
221
244
 
222
245
  /** The banner at the top of the form: an error, or with `info`, a notice. */
223
246
  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>` : ''}`;
@@ -0,0 +1,155 @@
1
+ // The rating a settled report is still waiting for, asked on the first screen,
2
+ // above the form, rather than only inside Your reports, which few reporters
3
+ // ever open again.
4
+ //
5
+ // How hard it asks is the project's choice (Settings → Resolutions → Where the
6
+ // widget asks), sent with each report as `ratingDue`:
7
+ //
8
+ // 'ask' a card with a Not now.
9
+ // 'require' the same card, and the form waits: Send and Talk to the team
10
+ // come back once the report is rated.
11
+ //
12
+ // One report per open, the one settled most recently. A reporter with three
13
+ // settled reports is not handed three questions at the door; the next one is
14
+ // waiting the next time they come.
15
+
16
+ import * as ICONS from './icons.js';
17
+ import { escapeHtml, relativeDay } from './render.js';
18
+ import { drawRating } from './rating.js';
19
+
20
+ // The reports somebody pressed Not now on, on this device. Kept so the card
21
+ // does not come back on every open; a project that wants an answer regardless
22
+ // asks with 'require', which this list does not excuse.
23
+ const LATER_KEY = 'bugcatch.rateLater';
24
+ const LATER_MAX = 50;
25
+
26
+ function putOffList() {
27
+ try {
28
+ const list = JSON.parse(window.localStorage.getItem(LATER_KEY) || '[]');
29
+ return Array.isArray(list) ? list : [];
30
+ } catch {
31
+ return [];
32
+ }
33
+ }
34
+
35
+ function putOff(bugId) {
36
+ try {
37
+ const list = putOffList().filter((id) => id !== bugId);
38
+ list.push(bugId);
39
+ window.localStorage.setItem(LATER_KEY, JSON.stringify(list.slice(-LATER_MAX)));
40
+ } catch { /* private mode: the card comes back next time, which is all that is lost */ }
41
+ }
42
+
43
+ /** The report to ask about now, or null. Exported for the tests. */
44
+ export function pickDue(reports, putOffIds = putOffList()) {
45
+ const later = new Set(putOffIds);
46
+ const due = (reports || []).filter((r) => (r.ratingDue === 'require'
47
+ || (r.ratingDue === 'ask' && !later.has(r.id))));
48
+ if (!due.length) return null;
49
+ const settledAt = (r) => new Date(r.resolvedAt || r.updatedAt || 0).getTime() || 0;
50
+ return due.reduce((latest, r) => (settledAt(r) > settledAt(latest) ? r : latest));
51
+ }
52
+
53
+ export function mountRateDue(ctx) {
54
+ const {
55
+ client, rateDue: card, compose, picks, known, callBtn, titleInput, scrim,
56
+ } = ctx;
57
+
58
+ let held = false;
59
+ ctx.mustRate = () => held;
60
+
61
+ /**
62
+ * Hold the form, or let it go. Held, it stays on screen, dimmed: what they
63
+ * came to do is still in sight, one rating away.
64
+ */
65
+ function hold(on) {
66
+ held = on;
67
+ for (const el of [compose, picks, known]) {
68
+ el.classList.toggle('held', on);
69
+ el.inert = on;
70
+ }
71
+ if (on) callBtn.hidden = true;
72
+ else if (ctx.callOffered() && !ctx.mustSignIn()) callBtn.hidden = false;
73
+ ctx.refreshSendState();
74
+ }
75
+
76
+ function release() {
77
+ if (held) hold(false);
78
+ }
79
+
80
+ function dismiss() {
81
+ card.hidden = true;
82
+ card.replaceChildren();
83
+ release();
84
+ titleInput.focus();
85
+ }
86
+
87
+ function draw(report) {
88
+ const required = report.ratingDue === 'require';
89
+ const when = report.resolvedAt ? relativeDay(report.resolvedAt) : '';
90
+ const done = report.status === 'CLOSED' ? 'closed' : 'fixed';
91
+ card.hidden = false;
92
+ card.classList.toggle('is-required', required);
93
+ card.innerHTML = `
94
+ <div class="rd-head">
95
+ <span class="rd-eyebrow">${required ? 'Before you report something new' : `Your report was ${done}`}</span>
96
+ ${required ? '' : '<button type="button" class="link rd-later" data-act="rate-later">Not now</button>'}
97
+ </div>
98
+ <p class="rd-report">
99
+ <span class="rd-ico" aria-hidden="true">${ICONS.check}</span>
100
+ <span class="rd-ttl">${escapeHtml(report.title)}</span>
101
+ <span class="rd-sub">${escapeHtml(report.reference)}${when ? ` · ${done} ${escapeHtml(when)}` : ''}</span>
102
+ </p>
103
+ <div class="rd-stars"></div>`;
104
+
105
+ card.querySelector('[data-act="rate-later"]')?.addEventListener('click', () => {
106
+ putOff(report.id);
107
+ dismiss();
108
+ });
109
+
110
+ drawRating(card.querySelector('.rd-stars'), {
111
+ client,
112
+ report,
113
+ rating: null,
114
+ question: required ? 'How did we handle it? Rate it to send a new one.' : 'How did we do?',
115
+ onRated: () => {
116
+ card.querySelector('.rd-eyebrow').textContent = `Your report was ${done}`;
117
+ card.querySelector('[data-act="skip"]')?.remove();
118
+ card.classList.remove('is-required');
119
+ release();
120
+ },
121
+ // The rating could not be sent. A held form must not turn into a locked
122
+ // door because of our outage: offer the way past.
123
+ onError: () => {
124
+ if (!held || card.querySelector('[data-act="skip"]')) return;
125
+ const skip = document.createElement('button');
126
+ skip.type = 'button';
127
+ skip.className = 'link rd-later';
128
+ skip.dataset.act = 'skip';
129
+ skip.textContent = 'Skip for now';
130
+ skip.addEventListener('click', dismiss);
131
+ card.querySelector('.rd-head').append(skip);
132
+ },
133
+ });
134
+
135
+ if (required) {
136
+ hold(true);
137
+ // Focus moves to the card, out of the form that just went inert. The
138
+ // card, not its first star: a focused star lights up, and one lit star
139
+ // reads as a rating of 1 somebody never gave.
140
+ const active = scrim.getRootNode().activeElement;
141
+ if (!active || !card.contains(active)) card.focus();
142
+ }
143
+ }
144
+
145
+ // Only a browser that has filed something has anything to rate. A failed
146
+ // read asks nothing and holds nothing: the form is what they came for.
147
+ if (!client.hasReports()) return;
148
+ client.myReports()
149
+ .then(({ reports }) => {
150
+ if (ctx.closed) return;
151
+ const report = pickDue(reports);
152
+ if (report) draw(report);
153
+ })
154
+ .catch(() => {});
155
+ }