@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.
- package/README.md +54 -1
- package/dist/bugcatch-call.umd.js +1 -1
- package/dist/bugcatch-core.es.js +652 -497
- package/dist/bugcatch-core.es.js.map +1 -1
- package/dist/bugcatch-panel.es.js +1307 -964
- package/dist/bugcatch-panel.es.js.map +1 -1
- package/dist/bugcatch-panel.umd.js +291 -158
- package/dist/bugcatch-panel.umd.js.map +1 -1
- package/dist/bugcatch.standalone.umd.js +284 -151
- package/dist/bugcatch.standalone.umd.js.map +1 -1
- package/dist/bugcatch.umd.js +8 -8
- package/dist/bugcatch.umd.js.map +1 -1
- package/package.json +1 -1
- package/src/feedback.js +94 -0
- package/src/identity.js +1 -1
- package/src/index.d.ts +65 -6
- package/src/index.js +111 -7
- package/src/panel/call.js +3 -2
- package/src/panel/compose.js +8 -4
- package/src/panel/feedback.js +205 -0
- package/src/panel/icons.js +6 -0
- package/src/panel/index.js +91 -12
- package/src/panel/rate-due.js +155 -0
- package/src/panel/rating.js +87 -0
- package/src/panel/signin.js +1 -1
- package/src/panel/styles.js +75 -0
- package/src/panel/thread.js +3 -72
- package/src/widget.js +14 -1
package/src/panel/icons.js
CHANGED
|
@@ -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>';
|
package/src/panel/index.js
CHANGED
|
@@ -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:
|
|
66
|
-
sub:
|
|
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.
|
|
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
|
+
}
|
package/src/panel/signin.js
CHANGED
|
@@ -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() {
|
package/src/panel/styles.js
CHANGED
|
@@ -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
|
package/src/panel/thread.js
CHANGED
|
@@ -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.
|
|
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
|
/**
|