@sphoro/bugcatch-web-sdk 0.19.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.
@@ -34,3 +34,9 @@ export const inbox = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
34
34
  * that happens to define --accent would have repainted our mark in their brand
35
35
  * colour. The --bc- prefix is ours and no host is going to set it by accident. */
36
36
  export const BRAND = '<svg class="bc-brand" viewBox="0 0 254 60" fill="none" role="img" aria-label="sphoro BugCatch"><g fill="currentColor"><path transform="translate(0.00 40.00) scale(0.04000 -0.04000)" d="M276.00 -14.00Q179.00 -14.00 117.00 28.00Q55.00 70.00 42.00 148.00L158.00 178.00Q165.00 143.00 181.50 123.00Q198.00 103.00 222.50 94.50Q247.00 86.00 276.00 86.00Q320.00 86.00 341.00 101.50Q362.00 117.00 362.00 140.00Q362.00 163.00 342.00 175.50Q322.00 188.00 278.00 196.00L250.00 201.00Q198.00 211.00 155.00 228.50Q112.00 246.00 86.00 277.00Q60.00 308.00 60.00 357.00Q60.00 431.00 114.00 470.50Q168.00 510.00 256.00 510.00Q339.00 510.00 394.00 473.00Q449.00 436.00 466.00 376.00L349.00 340.00Q341.00 378.00 316.50 394.00Q292.00 410.00 256.00 410.00Q220.00 410.00 201.00 397.50Q182.00 385.00 182.00 363.00Q182.00 339.00 202.00 327.50Q222.00 316.00 256.00 310.00L284.00 305.00Q340.00 295.00 385.50 278.50Q431.00 262.00 457.50 231.50Q484.00 201.00 484.00 149.00Q484.00 71.00 427.50 28.50Q371.00 -14.00 276.00 -14.00Z"/><path transform="translate(19.56 40.00) scale(0.04000 -0.04000)" d="M70.00 -200.00V496.00H194.00V436.00H212.00Q229.00 465.00 265.00 487.50Q301.00 510.00 368.00 510.00Q428.00 510.00 479.00 480.50Q530.00 451.00 561.00 394.00Q592.00 337.00 592.00 256.00V240.00Q592.00 159.00 561.00 102.00Q530.00 45.00 479.00 15.50Q428.00 -14.00 368.00 -14.00Q323.00 -14.00 292.50 -3.50Q262.00 7.00 243.50 23.50Q225.00 40.00 214.00 57.00H196.00V-200.00ZM330.00 96.00Q389.00 96.00 427.50 133.50Q466.00 171.00 466.00 243.00V253.00Q466.00 325.00 427.00 362.50Q388.00 400.00 330.00 400.00Q272.00 400.00 233.00 362.50Q194.00 325.00 194.00 253.00V243.00Q194.00 171.00 233.00 133.50Q272.00 96.00 330.00 96.00Z"/><path transform="translate(43.68 40.00) scale(0.04000 -0.04000)" d="M70.00 0.00V700.00H196.00V435.00H214.00Q222.00 451.00 239.00 467.00Q256.00 483.00 284.50 493.50Q313.00 504.00 357.00 504.00Q415.00 504.00 458.50 477.50Q502.00 451.00 526.00 404.50Q550.00 358.00 550.00 296.00V0.00H424.00V286.00Q424.00 342.00 396.50 370.00Q369.00 398.00 318.00 398.00Q260.00 398.00 228.00 359.50Q196.00 321.00 196.00 252.00V0.00Z"/><path transform="translate(66.92 40.00) scale(0.04000 -0.04000)" d="M306.00 -14.00Q232.00 -14.00 173.00 16.00Q114.00 46.00 80.00 103.00Q46.00 160.00 46.00 240.00V256.00Q46.00 336.00 80.00 393.00Q114.00 450.00 173.00 480.00Q232.00 510.00 306.00 510.00Q380.00 510.00 439.00 480.00Q498.00 450.00 532.00 393.00Q566.00 336.00 566.00 256.00V240.00Q566.00 160.00 532.00 103.00Q498.00 46.00 439.00 16.00Q380.00 -14.00 306.00 -14.00ZM306.00 98.00Q364.00 98.00 402.00 135.50Q440.00 173.00 440.00 243.00V253.00Q440.00 323.00 402.50 360.50Q365.00 398.00 306.00 398.00Q248.00 398.00 210.00 360.50Q172.00 323.00 172.00 253.00V243.00Q172.00 173.00 210.00 135.50Q248.00 98.00 306.00 98.00Z"/><path transform="translate(90.00 40.00) scale(0.04000 -0.04000)" d="M70.00 0.00V496.00H194.00V440.00H212.00Q223.00 470.00 248.50 484.00Q274.00 498.00 308.00 498.00H368.00V386.00H306.00Q258.00 386.00 227.00 360.50Q196.00 335.00 196.00 282.00V0.00Z"/><path transform="translate(104.44 40.00) scale(0.04000 -0.04000)" d="M306.00 -14.00Q232.00 -14.00 173.00 16.00Q114.00 46.00 80.00 103.00Q46.00 160.00 46.00 240.00V256.00Q46.00 336.00 80.00 393.00Q114.00 450.00 173.00 480.00Q232.00 510.00 306.00 510.00Q380.00 510.00 439.00 480.00Q498.00 450.00 532.00 393.00Q566.00 336.00 566.00 256.00V240.00Q566.00 160.00 532.00 103.00Q498.00 46.00 439.00 16.00Q380.00 -14.00 306.00 -14.00ZM306.00 98.00Q364.00 98.00 402.00 135.50Q440.00 173.00 440.00 243.00V253.00Q440.00 323.00 402.50 360.50Q365.00 398.00 306.00 398.00Q248.00 398.00 210.00 360.50Q172.00 323.00 172.00 253.00V243.00Q172.00 173.00 210.00 135.50Q248.00 98.00 306.00 98.00Z"/></g><rect x="139.52" y="16" width="1.6" height="26" rx="0.8" fill="var(--bc-brand-mark, #5647ee)" opacity="0.4"/><g fill="var(--bc-brand-mark, #5647ee)"><path transform="translate(154.52 35.00) scale(0.01400 -0.01400)" d="M46.35 0.00V105.85H139.75V594.15H46.35V700.00H404.08Q467.38 700.00 514.79 678.41Q562.20 656.83 588.70 617.33Q615.20 577.83 615.20 523.35V513.70Q615.20 465.52 597.03 434.76Q578.85 404.00 553.91 387.24Q528.98 370.48 506.28 363.30V346.35Q528.98 340.35 555.24 323.50Q581.50 306.65 600.00 275.48Q618.50 244.30 618.50 194.48V184.48Q618.50 126.65 591.41 85.24Q564.33 43.83 516.83 21.91Q469.33 0.00 406.85 0.00ZM259.85 109.15H392.60Q441.20 109.15 469.80 132.60Q498.40 156.05 498.40 198.55V208.20Q498.40 250.70 470.21 274.15Q442.02 297.60 392.60 297.60H259.85ZM259.85 406.75H391.65Q437.55 406.75 466.32 430.11Q495.10 453.48 495.10 493.80V503.80Q495.10 544.95 466.74 567.90Q438.37 590.85 391.65 590.85H259.85Z"/><path transform="translate(167.00 35.00) scale(0.01400 -0.01400)" d="M335.30 -14.00Q251.22 -14.00 190.60 16.63Q129.97 47.25 97.56 104.96Q65.15 162.68 65.15 243.03V700.00H185.25V239.73Q185.25 170.48 224.03 131.99Q262.80 93.50 335.30 93.50Q407.80 93.50 446.57 131.99Q485.35 170.48 485.35 239.73V700.00H605.45V243.03Q605.45 162.68 573.13 104.96Q540.80 47.25 480.09 16.63Q419.38 -14.00 335.30 -14.00Z"/><path transform="translate(179.60 35.00) scale(0.01400 -0.01400)" d="M311.85 -14.00Q238.45 -14.00 179.69 18.50Q120.92 51.00 86.69 113.67Q52.45 176.35 52.45 266.87V433.13Q52.45 569.00 128.01 641.50Q203.57 714.00 332.35 714.00Q460.13 714.00 529.23 645.59Q598.33 577.17 598.33 460.87V456.70H479.87V464.52Q479.87 504.67 463.76 536.86Q447.65 569.05 414.99 587.77Q382.32 606.50 332.35 606.50Q257.63 606.50 215.09 560.70Q172.55 514.90 172.55 435.13V264.87Q172.55 185.93 215.09 138.89Q257.63 91.85 334.00 91.85Q410.02 91.85 445.12 132.83Q480.22 173.80 480.22 237.92V249.67H302.85V351.88H598.33V0.00H487.52V69.10H470.57Q462.70 50.87 446.06 31.44Q429.42 12.00 397.64 -1.00Q365.85 -14.00 311.85 -14.00Z"/><path transform="translate(192.05 35.00) scale(0.01400 -0.01400)" d="M332.35 -14.00Q203.57 -14.00 128.01 58.50Q52.45 131.00 52.45 266.87V433.13Q52.45 569.00 128.01 641.50Q203.57 714.00 332.35 714.00Q460.13 714.00 529.58 643.85Q599.03 573.70 599.03 451.57V445.75H480.57V455.22Q480.57 521.42 444.15 563.96Q407.72 606.50 332.35 606.50Q257.63 606.50 215.09 560.70Q172.55 514.90 172.55 435.13V264.87Q172.55 185.93 215.09 139.71Q257.63 93.50 332.35 93.50Q407.72 93.50 444.15 136.45Q480.57 179.40 480.57 244.78V262.25H599.03V248.43Q599.03 126.30 529.58 56.15Q460.13 -14.00 332.35 -14.00Z"/><path transform="translate(204.25 35.00) scale(0.01400 -0.01400)" d="M19.75 0.00 211.62 700.00H421.33L613.20 0.00H489.27L447.42 159.60H185.53L143.68 0.00ZM214.25 270.40H418.70L324.95 624.87H308.00Z"/><path transform="translate(216.31 35.00) scale(0.01400 -0.01400)" d="M233.95 0.00V590.85H24.70V700.00H563.30V590.85H354.05V0.00Z"/><path transform="translate(227.75 35.00) scale(0.01400 -0.01400)" d="M332.35 -14.00Q203.57 -14.00 128.01 58.50Q52.45 131.00 52.45 266.87V433.13Q52.45 569.00 128.01 641.50Q203.57 714.00 332.35 714.00Q460.13 714.00 529.58 643.85Q599.03 573.70 599.03 451.57V445.75H480.57V455.22Q480.57 521.42 444.15 563.96Q407.72 606.50 332.35 606.50Q257.63 606.50 215.09 560.70Q172.55 514.90 172.55 435.13V264.87Q172.55 185.93 215.09 139.71Q257.63 93.50 332.35 93.50Q407.72 93.50 444.15 136.45Q480.57 179.40 480.57 244.78V262.25H599.03V248.43Q599.03 126.30 529.58 56.15Q460.13 -14.00 332.35 -14.00Z"/><path transform="translate(239.95 35.00) scale(0.01400 -0.01400)" d="M69.50 0.00V700.00H189.60V405.58H466.40V700.00H586.50V0.00H466.40V296.42H189.60V0.00Z"/></g></svg>';
37
+ // "Give feedback": the switch's glyph and the four kinds of feedback.
38
+ export const feedback = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 5.5h15a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1h-8l-4.3 3.2a.5.5 0 0 1-.8-.4v-2.8H4.5a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/><path d="M12 8.4l.9 1.9 2 .3-1.5 1.4.4 2-1.8-1-1.8 1 .4-2-1.5-1.4 2-.3z"/></svg>';
39
+ export const bulb = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 17.5h6M10 21h4"/><path d="M12 3a6 6 0 0 0-3.6 10.8c.6.5 1 1.2 1 2V17h5.2v-1.2c0-.8.4-1.5 1-2A6 6 0 0 0 12 3z"/></svg>';
40
+ export const heart = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20s-7.5-4.6-7.5-10.2A4.3 4.3 0 0 1 12 7.2a4.3 4.3 0 0 1 7.5 2.6C19.5 15.4 12 20 12 20z"/></svg>';
41
+ export const question = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M9.6 9.4a2.5 2.5 0 0 1 4.8.9c0 1.7-2.4 2.2-2.4 3.6M12 17h.01"/></svg>';
42
+ export const dots = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.8" stroke-linecap="round"><path d="M6.5 12h.01M12 12h.01M17.5 12h.01"/></svg>';
@@ -8,6 +8,8 @@
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
12
+ // rate-due.js "how did we do?" about a settled report, above the form
11
13
  // refs.js "which record is this about", asked at Send
12
14
  // notices.js what the project's team is telling visitors, at the top
13
15
  // known.js what the project already knows is broken, and "me too"
@@ -37,6 +39,17 @@ import { mountPicks } from './picks.js';
37
39
  import { mountCallOffer } from './call.js';
38
40
  import { mountSignIn } from './signin.js';
39
41
  import { mountThread } from './thread.js';
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
+ };
40
53
 
41
54
  /**
42
55
  * Open the panel over the page. `preloadCall` fetches the call code ahead of a
@@ -44,11 +57,17 @@ import { mountThread } from './thread.js';
44
57
  * to load code of its own.
45
58
  */
46
59
  export function openPanel({
47
- root, host, dock, client, onClose, preloadCall = () => {}, themeControls = null,
60
+ root, host, dock, client, onClose, preloadCall = () => {}, themeControls = null, mode: askedMode = 'report',
48
61
  }) {
49
62
  addStyles(root);
50
63
  const cfg = client.config;
51
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';
52
71
 
53
72
  const scrim = document.createElement('div');
54
73
  // The panel opens out of whichever corner the dock is in, clearing it by the
@@ -57,37 +76,47 @@ export function openPanel({
57
76
  + `${dock.classList.contains('at-top') ? 'at-top' : 'at-bottom'}`;
58
77
  scrim.style.setProperty('--bc-dock-h', `${Math.round(dock.getBoundingClientRect().height)}px`);
59
78
  scrim.innerHTML = `
60
- <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}">
61
80
  ${headHtml({
62
81
  id: 'bc-h',
63
82
  // Not "Tell us what went wrong": that is the first field's own label,
64
83
  // 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.',
84
+ title: MODES[mode].title,
85
+ sub: MODES[mode].sub,
67
86
  theme: themeLabel(),
68
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>` : ''}
69
93
  <div class="body">
70
94
  <div class="slot" role="alert"></div>
95
+ <!-- A settled report still waiting for its rating. Drawn only when the
96
+ project asks for one here; see rate-due.js. -->
97
+ <div class="rate-due fix-rating bug-only" tabindex="-1" hidden></div>
71
98
  <!-- What the project's team is telling visitors. Drawn only when it
72
99
  has published something. -->
73
100
  <div class="notices" hidden></div>
74
- <div class="whoami" hidden></div>
101
+ <div class="whoami bug-only" hidden></div>
75
102
  <!-- The questions the assistant can answer from their account and
76
103
  close by itself. Drawn only when the project offers any. -->
77
- <div class="picks" hidden></div>
78
- <div class="known" hidden></div>
104
+ <div class="picks bug-only" hidden></div>
105
+ <div class="known bug-only" hidden></div>
79
106
  ${composeHtml(cfg)}
107
+ ${offerFeedback ? feedbackHtml() : ''}
80
108
  </div>
81
109
  <div class="foot">
82
- <span class="note" aria-live="polite"></span>
110
+ <span class="note bug-only" aria-live="polite"></span>
83
111
  ${client.hasReports() ? '<button class="quiet" type="button" data-act="history">Your reports</button>' : ''}
84
112
  <span class="spacer"></span>
85
113
  <!-- Hidden until the API confirms this project can take a call, which
86
114
  most cannot. Drawn here rather than as a second launcher because it
87
115
  is the same errand as Send report — the difference is only whether
88
116
  the reporter would rather say it than type it. -->
89
- <button class="ghost" type="button" data-act="call" hidden>${ICONS.phone}Talk to the team</button>
90
- <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>` : ''}
91
120
  </div>
92
121
  <div class="powered">Powered by
93
122
  <a class="brand-credit" href="https://bugcatch.sphoro.com" target="_blank" rel="noopener noreferrer" aria-label="BugCatch">${BRAND}</a>
@@ -131,6 +160,7 @@ export function openPanel({
131
160
  notices: $('.notices'),
132
161
  known: $('.known'),
133
162
  picks: $('.picks'),
163
+ rateDue: $('.rate-due'),
134
164
  // A report or a call is on its way. Escape and a click outside wait for it.
135
165
  sending: false,
136
166
  closed: false,
@@ -155,6 +185,10 @@ export function openPanel({
155
185
  // signin.js
156
186
  mustSignIn: null,
157
187
  recheckSignIn: null,
188
+ // rate-due.js
189
+ mustRate: () => false,
190
+ // feedback.js
191
+ focusFeedback: () => {},
158
192
  // thread.js
159
193
  succeed: null,
160
194
  refreshThread: null,
@@ -171,6 +205,50 @@ export function openPanel({
171
205
  mountCallOffer(ctx);
172
206
  mountSignIn(ctx);
173
207
  mountThread(ctx);
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
+ }
174
252
 
175
253
  /* ---------- lifecycle ---------- */
176
254
 
@@ -242,9 +320,10 @@ export function openPanel({
242
320
 
243
321
  root.appendChild(scrim);
244
322
  ctx.refreshSendState();
245
- ctx.titleInput.focus();
323
+ if (mode === 'feedback') ctx.focusFeedback();
324
+ else ctx.titleInput.focus();
246
325
 
247
- return { destroy, close };
326
+ return { destroy, close, setMode };
248
327
  }
249
328
 
250
329
  /**
@@ -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
+ }
@@ -0,0 +1,87 @@
1
+ // "How did we do?" — five stars and, after them, an optional note. Drawn under
2
+ // a settled report's conversation (thread.js) and above the form on the first
3
+ // screen (rate-due.js), so both ask the same question the same way.
4
+
5
+ import * as ICONS from './icons.js';
6
+ import { escapeHtml } from './render.js';
7
+
8
+ /** What a drawn rating box was drawn from, so a refresh can tell if it moved. */
9
+ export function ratingKey(rating) {
10
+ return JSON.stringify(rating ? [rating.score, rating.current, rating.comment || null] : null);
11
+ }
12
+
13
+ /**
14
+ * Draw the stars into `box` for `report`, with the reader's `rating` so far.
15
+ *
16
+ * One tap sends the score; a note is offered after it rather than before,
17
+ * because a form that asks for a sentence first is a form people close, and
18
+ * the score is the part that counts. `onRated` hears each saved rating and
19
+ * `onError` each refusal, for a caller that holds something until it lands.
20
+ */
21
+ export function drawRating(box, {
22
+ client, report, rating, justRated = false, question = null, onRated = null, onError = null,
23
+ }) {
24
+ box.dataset.key = ratingKey(rating);
25
+ // A rating given for an earlier fix stands, but it is not this fix's: the
26
+ // stars start empty and the sentence says what the old score was.
27
+ const shown = rating?.current ? rating.score : 0;
28
+ const asked = !rating
29
+ ? (question || 'How did we do?')
30
+ : !rating.current
31
+ ? `You rated an earlier fix ${rating.score} out of 5. How did this one go?`
32
+ : justRated
33
+ ? 'Thanks — the team will see that.'
34
+ : `You rated this ${rating.score} out of 5.`;
35
+ const askNote = justRated && rating?.current && !rating.comment;
36
+ box.innerHTML = `
37
+ <p class="fr-q">${escapeHtml(asked)}</p>
38
+ <div class="stars" role="group" aria-label="Rate how this was handled, 1 to 5">${[1, 2, 3, 4, 5].map((n) => `
39
+ <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('')}
40
+ </div>
41
+ ${rating?.current && rating.comment ? `<p class="fr-said">“${escapeHtml(rating.comment)}”</p>` : ''}
42
+ ${askNote ? `
43
+ <div class="fr-note">
44
+ <textarea maxlength="2000" aria-label="Add a note to your rating" placeholder="${rating.score <= 2 ? 'What went wrong? (optional)' : 'Anything to add? (optional)'}"></textarea>
45
+ <button type="button" data-act="rating-note">Add note</button>
46
+ </div>` : ''}
47
+ ${rating ? '' : '<p class="fr-hint">1 is poor, 5 is great.</p>'}
48
+ <p class="feedback-status" role="status"></p>`;
49
+
50
+ const stars = [...box.querySelectorAll('[data-score]')];
51
+ const paint = (upTo) => stars.forEach((b) => b.classList.toggle('on', Number(b.dataset.score) <= upTo));
52
+ const lock = (on) => box.querySelectorAll('button, textarea').forEach((el) => { el.disabled = on; });
53
+ const status = box.querySelector('.feedback-status');
54
+
55
+ async function send(score, comment) {
56
+ lock(true);
57
+ status.textContent = '';
58
+ try {
59
+ const saved = await client.rate(report.id, { score, comment });
60
+ drawRating(box, {
61
+ client, report, rating: saved, justRated: true, question, onRated, onError,
62
+ });
63
+ box.querySelector('.fr-note textarea')?.focus();
64
+ onRated?.(saved);
65
+ } catch (err) {
66
+ status.textContent = err.message;
67
+ lock(false);
68
+ paint(shown);
69
+ onError?.(err);
70
+ }
71
+ }
72
+
73
+ for (const btn of stars) {
74
+ const n = Number(btn.dataset.score);
75
+ btn.addEventListener('mouseenter', () => paint(n));
76
+ btn.addEventListener('focus', () => paint(n));
77
+ btn.addEventListener('blur', () => paint(shown));
78
+ btn.addEventListener('click', () => send(n));
79
+ }
80
+ box.querySelector('.stars').addEventListener('mouseleave', () => paint(shown));
81
+
82
+ box.querySelector('[data-act="rating-note"]')?.addEventListener('click', () => {
83
+ const note = box.querySelector('.fr-note textarea').value.trim();
84
+ if (!note) { box.querySelector('.fr-note')?.remove(); return; }
85
+ send(rating.score, note);
86
+ });
87
+ }
@@ -37,7 +37,7 @@ export function mountSignIn(ctx) {
37
37
  // The call offer is the same errand as the form, so it goes with it — and
38
38
  // comes back only if the API had already said this project can take one.
39
39
  if (shut) callBtn.hidden = true;
40
- else if (ctx.callOffered()) callBtn.hidden = false;
40
+ else if (ctx.callOffered() && !ctx.mustRate()) callBtn.hidden = false;
41
41
  }
42
42
 
43
43
  function drawWhoami() {
@@ -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. */
@@ -831,6 +878,34 @@ button.quiet:hover { color: var(--bc-fg); text-decoration-color: color-mix(in sr
831
878
  .history .sub .rated svg { width: 11px; height: 11px; color: #f0a81c; }
832
879
  .history .sub .rate-ask { color: var(--bc-fg); font-weight: 600; }
833
880
 
881
+ /* ---------- a settled report waiting for its rating ----------
882
+ Above the form on the first screen: the same stars as under a report's
883
+ conversation, with the report they are about named on top. */
884
+ .rate-due { margin: 0 0 14px; }
885
+ .rate-due:focus { outline: none; }
886
+ .rate-due:focus-visible { box-shadow: var(--bc-ring); }
887
+ .rate-due .rd-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin: 0 0 8px; }
888
+ .rate-due .rd-eyebrow {
889
+ font-size: 11px; font-weight: 600; letter-spacing: .07em; text-transform: uppercase; color: var(--bc-faint);
890
+ }
891
+ .rate-due.is-required .rd-eyebrow { color: var(--bc-fg); }
892
+ .rate-due .rd-later { font-size: var(--bc-fs-xs); color: var(--bc-muted); }
893
+ .rate-due .rd-report {
894
+ display: grid; grid-template-columns: auto minmax(0, 1fr); column-gap: 9px; align-items: center;
895
+ margin: 0 0 10px; padding: 0 0 10px; border-bottom: 1px solid var(--bc-border);
896
+ }
897
+ .rate-due .rd-ico {
898
+ grid-row: span 2; display: inline-grid; place-items: center; width: 24px; height: 24px; border-radius: 999px;
899
+ background: color-mix(in srgb, var(--bc-ok) 14%, transparent); color: var(--bc-ok);
900
+ }
901
+ .rate-due .rd-ico svg { width: 13px; height: 13px; }
902
+ .rate-due .rd-ttl { font-weight: 620; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
903
+ .rate-due .rd-sub { font-size: var(--bc-fs-xs); color: var(--bc-muted); }
904
+
905
+ /* What a required rating is holding: still in sight, plainly not yet. */
906
+ .held { opacity: .42; transition: opacity 200ms ease; }
907
+ @media (prefers-reduced-motion: reduce) { .held { transition: none; } }
908
+
834
909
  /* A call in progress, in place of the form.
835
910
  ---------------------------------------------------------------------------
836
911
  Deliberately not styled as a chat. Nothing arrives here to read — the
@@ -11,16 +11,12 @@ import {
11
11
  atLatest, scrollToLatest,
12
12
  } from './render.js';
13
13
  import { mountComposer } from './composer.js';
14
+ import { drawRating, ratingKey } from './rating.js';
14
15
 
15
16
  // What each open thread was last drawn from, so a poll that brings nothing new
16
17
  // leaves the DOM (and the reader's place in it) alone.
17
18
  const threadDrawn = new WeakMap();
18
19
 
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
20
  /**
25
21
  * The rating's word on a history row: the score they gave, or a nudge on a
26
22
  * settled report that is waiting for one. Nothing on an open report.
@@ -336,10 +332,7 @@ export function mountThread(ctx) {
336
332
  * "How did we do?" — five stars under a settled report.
337
333
  *
338
334
  * 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.
335
+ * and the project has not switched ratings off. The box itself is rating.js.
343
336
  *
344
337
  * Redrawn only when the rating itself changed. The thread refreshes on every
345
338
  * update and every few seconds while the assistant works, and a box rebuilt
@@ -358,69 +351,7 @@ export function mountThread(ctx) {
358
351
  box.className = 'fix-rating';
359
352
  (body.querySelector('.fix-verification') || body.querySelector('.report-head'))?.after(box);
360
353
  }
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
- });
354
+ drawRating(box, { client, report, rating: report.rating || null });
424
355
  }
425
356
 
426
357
  /**