@sphoro/bugcatch-web-sdk 0.13.1

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/src/widget.js ADDED
@@ -0,0 +1,2481 @@
1
+ // The floating "Report a bug" button and its form. Rendered in a shadow root
2
+ // so the host page's CSS can't leak in and break it (or vice versa).
3
+ //
4
+ // Everything the panel needs is declared here as custom properties, which is
5
+ // what makes theming a config option rather than a fork: the host element
6
+ // carries the accent colour and the light/dark choice, and the rules below
7
+ // only ever read variables.
8
+
9
+ import {
10
+ captureScreenshot, startRecording, screenshotSupported, captureSupported,
11
+ startVoiceNote, voiceSupported,
12
+ humanSize, humanDuration, normalizeMime, UPLOAD_MIMES, REPORT_MIMES,
13
+ } from './capture.js';
14
+ import { sendMessage } from './updates.js';
15
+
16
+ const POSITIONS = {
17
+ 'bottom-right': 'bottom:20px;right:20px;',
18
+ 'bottom-left': 'bottom:20px;left:20px;',
19
+ 'top-right': 'top:20px;right:20px;',
20
+ 'top-left': 'top:20px;left:20px;',
21
+ };
22
+
23
+ const PRIORITIES = [
24
+ { value: 'LOW', label: 'Low' },
25
+ { value: 'MEDIUM', label: 'Medium' },
26
+ { value: 'HIGH', label: 'High' },
27
+ { value: 'CRITICAL', label: 'Critical' },
28
+ ];
29
+
30
+ const ICONS = {
31
+ camera: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 8.5A1.5 1.5 0 0 1 4.5 7h2.2a1 1 0 0 0 .83-.45l.94-1.4A1 1 0 0 1 9.3 4.7h5.4a1 1 0 0 1 .83.45l.94 1.4a1 1 0 0 0 .83.45h2.2A1.5 1.5 0 0 1 21 8.5v9A1.5 1.5 0 0 1 19.5 19h-15A1.5 1.5 0 0 1 3 17.5z"/><circle cx="12" cy="13" r="3.4"/></svg>',
32
+ video: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="6" width="12.5" height="12" rx="2.2"/><path d="M15.5 10.8l4.2-2.6a.6.6 0 0 1 .92.5v6.6a.6.6 0 0 1-.92.5l-4.2-2.6z"/></svg>',
33
+ paperclip: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 11.5l-7.6 7.6a4.6 4.6 0 0 1-6.5-6.5l8-8a3.2 3.2 0 0 1 4.5 4.5l-8 8a1.8 1.8 0 0 1-2.5-2.5l7.1-7.1"/></svg>',
34
+ close: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>',
35
+ mic: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2.5" width="6" height="11" rx="3"/><path d="M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21M9 21h6"/></svg>',
36
+ send: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12l16-8-6 16-2.6-6.2L4 12Z"/></svg>',
37
+ stopSquare: '<svg viewBox="0 0 24 24" fill="currentColor"><rect x="7" y="7" width="10" height="10" rx="2"/></svg>',
38
+ back: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 5.5L8 12l6.5 6.5"/></svg>',
39
+ phone: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6.2 3.5h3l1.5 3.7-2 1.4a11.5 11.5 0 0 0 5.7 5.7l1.4-2 3.7 1.5v3a1.8 1.8 0 0 1-2 1.8A15.6 15.6 0 0 1 4.4 5.5a1.8 1.8 0 0 1 1.8-2z"/></svg>',
40
+ phoneOff: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6.2 3.5h3l1.5 3.7-2 1.4a11.5 11.5 0 0 0 5.7 5.7l1.4-2 3.7 1.5v3a1.8 1.8 0 0 1-2 1.8A15.6 15.6 0 0 1 4.4 5.5a1.8 1.8 0 0 1 1.8-2z"/><path d="M3 21L21 3"/></svg>',
41
+ bug: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M8 6a4 4 0 0 1 8 0"/><rect x="7" y="7.5" width="10" height="12" rx="5"/><path d="M7 11H3.5M7 15.5H4M17 11h3.5M17 15.5h3M12 8v11.5"/></svg>',
42
+ check: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.8l4.6 4.4L19 7"/></svg>',
43
+ retry: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 12a8 8 0 1 1-2.6-5.9M20 4v4.5h-4.5"/></svg>',
44
+ sun: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4.2"/><path d="M12 2.6v2.2M12 19.2v2.2M2.6 12h2.2M19.2 12h2.2M5.4 5.4l1.6 1.6M17 17l1.6 1.6M18.6 5.4L17 7M7 17l-1.6 1.6"/></svg>',
45
+ moon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M20 13.4A8.2 8.2 0 1 1 10.6 4a6.6 6.6 0 0 0 9.4 9.4z"/></svg>',
46
+ auto: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><rect x="2.8" y="4.4" width="18.4" height="12.6" rx="2.2"/><path d="M8.6 20.2h6.8"/><path d="M12 4.4v12.6"/><path d="M12 7.2a3.6 3.6 0 0 1 0 7.2z" fill="currentColor" stroke="none"/></svg>'};
47
+
48
+ /* The sphoro lockup, as outlines rather than text.
49
+ *
50
+ * This SDK runs inside other people's pages, where Space Grotesk is not loaded
51
+ * and cannot be assumed. Live <text> would render the mark in whatever the host
52
+ * site happens to use — a different typeface on every customer. Outlines are
53
+ * self-contained, so the mark is identical everywhere, at the cost of the path
54
+ * data below.
55
+ *
56
+ * The BUGCATCH half reads --bc-brand-mark, not a bare --accent. Custom properties
57
+ * are exempt from `all: initial`, so they cross the shadow boundary: a host page
58
+ * that happens to define --accent would have repainted our mark in their brand
59
+ * colour. The --bc- prefix is ours and no host is going to set it by accident. */
60
+ 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>';
61
+
62
+ // 'auto' is the default and means "follow the visitor's system setting".
63
+ const THEMES = [
64
+ { value: 'light', label: 'Light', icon: ICONS.sun },
65
+ { value: 'dark', label: 'Dark', icon: ICONS.moon },
66
+ { value: 'auto', label: 'System default', icon: ICONS.auto },
67
+ ];
68
+
69
+ // The visitor's pick outlives the page. Storage is wrapped because a sandboxed
70
+ // iframe or a locked-down privacy mode throws on access rather than returning
71
+ // null, and a theme preference is never worth breaking the widget over.
72
+ const THEME_KEY = 'bugcatch:theme';
73
+
74
+ function readStoredTheme() {
75
+ try {
76
+ const value = localStorage.getItem(THEME_KEY);
77
+ return THEMES.some((t) => t.value === value) ? value : null;
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ function writeStoredTheme(value) {
84
+ try {
85
+ localStorage.setItem(THEME_KEY, value);
86
+ } catch { /* storage denied — the choice just won't outlive the page */ }
87
+ }
88
+
89
+ const css = (accent) => `
90
+ :host { all: initial; }
91
+ :host {
92
+ --bc-accent: ${accent};
93
+ --bc-on-accent: #fff;
94
+ --bc-bg: #fff;
95
+ --bc-elevated: #fff;
96
+ --bc-fg: #14161c;
97
+ --bc-muted: #6b7280;
98
+ --bc-faint: #9aa1ad;
99
+ --bc-border: #e3e6ec;
100
+ --bc-subtle: #f5f6f8;
101
+ --bc-raised: #fff;
102
+ --bc-danger: #dc2626;
103
+ --bc-ok: #059669;
104
+ /* The lockup's own two colours, kept off --bc-accent on purpose: that one is
105
+ the customer's brand, and this mark is ours. Values are the light cut of
106
+ brand/sphoro-bugcatch-primary.svg, pulled up from near-black so the mark
107
+ identifies itself without out-shouting the host's page. */
108
+ --bc-brand-ink: #4b5563;
109
+ --bc-brand-mark: #5647ee;
110
+ --bc-shadow: 0 24px 60px rgba(10,12,16,.28), 0 2px 8px rgba(10,12,16,.08);
111
+ --bc-dur: 240ms;
112
+ --bc-ease: cubic-bezier(.22,1,.36,1);
113
+ }
114
+ :host(.bc-dark) {
115
+ --bc-bg: #14161c;
116
+ --bc-elevated: #1c1f27;
117
+ --bc-fg: #eef0f4;
118
+ --bc-muted: #9aa1ad;
119
+ --bc-faint: #6b7280;
120
+ --bc-border: #2b2f39;
121
+ --bc-subtle: #21242c;
122
+ --bc-raised: #363d4b;
123
+ --bc-brand-ink: #9aa1ad;
124
+ --bc-brand-mark: #9b8fff;
125
+ --bc-shadow: 0 24px 60px rgba(0,0,0,.6), 0 2px 8px rgba(0,0,0,.4);
126
+ }
127
+ @media (prefers-color-scheme: dark) {
128
+ :host(.bc-auto) {
129
+ --bc-bg: #14161c;
130
+ --bc-elevated: #1c1f27;
131
+ --bc-fg: #eef0f4;
132
+ --bc-muted: #9aa1ad;
133
+ --bc-faint: #6b7280;
134
+ --bc-border: #2b2f39;
135
+ --bc-subtle: #21242c;
136
+ --bc-raised: #363d4b;
137
+ --bc-brand-ink: #9aa1ad;
138
+ --bc-brand-mark: #9b8fff;
139
+ --bc-shadow: 0 24px 60px rgba(0,0,0,.6), 0 2px 8px rgba(0,0,0,.4);
140
+ }
141
+ }
142
+
143
+ * { box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif; }
144
+ svg { width: 1em; height: 1em; display: block; }
145
+ /* The hidden attribute has to actually hide. Nearly every element in here is given a
146
+ display by a rule below, and any of them beats the attribute's own UA style
147
+ — which is why .dock and .launcher each had to say so themselves. Said once,
148
+ for all of them, so the next element hidden from JavaScript works. */
149
+ [hidden] { display: none !important; }
150
+
151
+ /* Motion is decoration here — every animation below is an entrance or a state
152
+ hint, so switching them off costs nothing but the polish. */
153
+ @media (prefers-reduced-motion: reduce) {
154
+ *, *::before, *::after {
155
+ animation-duration: .01ms !important;
156
+ animation-iteration-count: 1 !important;
157
+ transition-duration: .01ms !important;
158
+ }
159
+ }
160
+
161
+ @keyframes bc-fade-in { from { opacity: 0 } to { opacity: 1 } }
162
+ @keyframes bc-fade-out { to { opacity: 0 } }
163
+ @keyframes bc-panel-in { from { opacity: 0; transform: translateY(16px) scale(.97) } to { opacity: 1; transform: none } }
164
+ @keyframes bc-panel-out { to { opacity: 0; transform: translateY(8px) scale(.985) } }
165
+ @keyframes bc-launcher-in { from { opacity: 0; transform: translateY(12px) scale(.86) } to { opacity: 1; transform: none } }
166
+ @keyframes bc-item-in { from { opacity: 0; transform: translateY(8px) scale(.92) } to { opacity: 1; transform: none } }
167
+ @keyframes bc-pulse { 0%, 100% { opacity: 1; transform: scale(1) } 50% { opacity: .3; transform: scale(.78) } }
168
+ @keyframes bc-spin { to { transform: rotate(360deg) } }
169
+ @keyframes bc-pop { 0% { transform: scale(.5); opacity: 0 } 65% { transform: scale(1.1) } 100% { transform: scale(1); opacity: 1 } }
170
+ @keyframes bc-nudge { 25% { transform: translateX(-3px) } 75% { transform: translateX(3px) } }
171
+
172
+ /* ---------- dock: theme switch stacked above the launcher ---------- */
173
+ .dock {
174
+ position: fixed; z-index: 2147483000;
175
+ display: flex; flex-direction: column; gap: 10px;
176
+ }
177
+ .dock[hidden] { display: none; }
178
+ /* While the panel is open the switch rises above the scrim so it stays usable
179
+ — the panel shares the shadow root, so a change re-themes the open form. */
180
+ .dock.over { z-index: 2147483002; }
181
+ .dock.at-right { align-items: flex-end; }
182
+ .dock.at-left { align-items: flex-start; }
183
+ /* Anchored to the top, "above the launcher" would be off-screen, so the
184
+ switch goes under it and the launcher stays the outermost thing. */
185
+ .dock.at-top { flex-direction: column-reverse; }
186
+
187
+ .themer {
188
+ display: inline-flex; align-items: center; gap: 2px;
189
+ padding: 3px; border-radius: 999px;
190
+ background: var(--bc-elevated); border: 1px solid var(--bc-border);
191
+ box-shadow: 0 6px 18px rgba(10,12,16,.16), 0 1px 3px rgba(10,12,16,.10);
192
+ animation: bc-launcher-in 420ms var(--bc-ease) both;
193
+ }
194
+ .themer button {
195
+ display: grid; place-items: center;
196
+ width: 28px; height: 28px; padding: 0;
197
+ border: none; border-radius: 999px; background: transparent;
198
+ color: var(--bc-muted); cursor: pointer;
199
+ transition: background var(--bc-dur) var(--bc-ease), color var(--bc-dur) var(--bc-ease), transform 140ms var(--bc-ease);
200
+ }
201
+ .themer button svg { font-size: 15px; }
202
+ .themer button:hover { background: var(--bc-subtle); color: var(--bc-fg); }
203
+ .themer button:active { transform: scale(.92); }
204
+ .themer button[aria-pressed="true"] { background: var(--bc-accent); color: var(--bc-on-accent); }
205
+ .themer button[aria-pressed="true"]:hover { background: var(--bc-accent); filter: brightness(1.06); }
206
+ .themer button:focus-visible { outline: 2px solid var(--bc-accent); outline-offset: 2px; }
207
+
208
+ /* ---------- launcher ---------- */
209
+ .launcher {
210
+ display: inline-flex; align-items: center; gap: 8px;
211
+ padding: 11px 17px; border: none; border-radius: 999px;
212
+ background: var(--bc-accent); color: var(--bc-on-accent);
213
+ font-size: 14px; font-weight: 600; line-height: 1; letter-spacing: -.01em;
214
+ cursor: pointer;
215
+ box-shadow: 0 6px 18px rgba(10,12,16,.22), 0 1px 3px rgba(10,12,16,.16);
216
+ transition: transform var(--bc-dur) var(--bc-ease), box-shadow var(--bc-dur) var(--bc-ease), filter 160ms linear;
217
+ animation: bc-launcher-in 420ms var(--bc-ease) both;
218
+ }
219
+ .launcher svg { font-size: 17px; }
220
+ .launcher:hover { transform: translateY(-2px); box-shadow: 0 12px 28px rgba(10,12,16,.26), 0 2px 6px rgba(10,12,16,.18); filter: brightness(1.06); }
221
+ .launcher:active { transform: translateY(0) scale(.97); transition-duration: 90ms; }
222
+ .launcher:focus-visible { outline: 2px solid var(--bc-accent); outline-offset: 3px; }
223
+ .launcher[hidden] { display: none; }
224
+
225
+ /* ---------- scrim + panel ---------- */
226
+ .scrim {
227
+ position: fixed; inset: 0; z-index: 2147483001;
228
+ background: rgba(10,12,16,.5);
229
+ animation: bc-fade-in 200ms ease both;
230
+ }
231
+ .scrim.closing { animation: bc-fade-out 150ms ease both; }
232
+
233
+ /* The panel belongs to the launcher, so it opens out of that corner rather
234
+ than in the middle of the screen. --bc-dock-h is measured at open time
235
+ because the dock's height depends on whether the launcher is showing. */
236
+ .panel {
237
+ position: fixed;
238
+ width: calc(100vw - 40px); max-width: 440px;
239
+ max-height: calc(100vh - var(--bc-dock-h, 84px) - 52px);
240
+ overflow-y: auto; overscroll-behavior: contain;
241
+ background: var(--bc-bg); color: var(--bc-fg);
242
+ border-radius: 16px; box-shadow: var(--bc-shadow);
243
+ animation: bc-panel-in 320ms var(--bc-ease) both;
244
+ }
245
+ .scrim.at-right .panel { right: 20px; }
246
+ .scrim.at-left .panel { left: 20px; }
247
+ .scrim.at-bottom .panel { bottom: calc(20px + var(--bc-dock-h, 84px) + 12px); }
248
+ .scrim.at-top .panel { top: calc(20px + var(--bc-dock-h, 84px) + 12px); }
249
+ .scrim.closing .panel { animation: bc-panel-out 150ms ease both; }
250
+
251
+ .head { display: flex; align-items: flex-start; gap: 11px; padding: 18px 18px 0; }
252
+ .head .mark {
253
+ flex: none; display: grid; place-items: center;
254
+ width: 32px; height: 32px; border-radius: 9px;
255
+ background: color-mix(in srgb, var(--bc-accent) 13%, transparent);
256
+ color: var(--bc-accent); font-size: 18px;
257
+ }
258
+ .head .titles { flex: 1; min-width: 0; }
259
+ .head h2 { margin: 1px 0 3px; font-size: 16px; font-weight: 650; letter-spacing: -.015em; }
260
+ .head p { margin: 0; font-size: 12.5px; line-height: 1.45; color: var(--bc-muted); }
261
+ .icon-btn {
262
+ flex: none; display: grid; place-items: center;
263
+ width: 28px; height: 28px; padding: 0; font-size: 15px;
264
+ border: none; border-radius: 8px; background: none; color: var(--bc-faint);
265
+ cursor: pointer; transition: background 160ms, color 160ms;
266
+ }
267
+ .icon-btn:hover { background: var(--bc-subtle); color: var(--bc-fg); }
268
+ .icon-btn:focus-visible { outline: 2px solid var(--bc-accent); outline-offset: 1px; }
269
+
270
+ .body { padding: 16px 18px 0; }
271
+
272
+
273
+ label { display: block; font-size: 12px; font-weight: 600; color: var(--bc-fg); margin-bottom: 6px; }
274
+ input[type=text], input[type=email], textarea {
275
+ width: 100%; padding: 10px 12px; font-size: 14px; line-height: 1.45;
276
+ color: var(--bc-fg); background: var(--bc-elevated);
277
+ border: 1px solid var(--bc-border); border-radius: 10px;
278
+ transition: border-color 160ms, box-shadow 160ms;
279
+ }
280
+ input[type=text]::placeholder, input[type=email]::placeholder, textarea::placeholder { color: var(--bc-faint); }
281
+ input[type=text]:focus, input[type=email]:focus, textarea:focus {
282
+ outline: none; border-color: var(--bc-accent);
283
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--bc-accent) 18%, transparent);
284
+ }
285
+ input.invalid { border-color: var(--bc-danger); animation: bc-nudge 240ms ease; }
286
+ textarea { min-height: 84px; resize: vertical; }
287
+ .field { margin-bottom: 14px; }
288
+
289
+ /* ---------- what is already known ----------
290
+ A help surface in front of the form, not a step in it. Everything here is
291
+ dismissible by ignoring it: the reporter can read what is already known and
292
+ leave, tap "this is happening to me too" and leave, or type straight past all
293
+ of it and file the report they came to file. Nothing is ever in their way,
294
+ because a reporter who feels handled rather than helped stops reporting. */
295
+ .known { margin-bottom: 14px; }
296
+ .known .known-h {
297
+ margin: 0 0 8px; font-size: 12px; font-weight: 650; letter-spacing: .02em;
298
+ text-transform: uppercase; color: var(--bc-faint);
299
+ }
300
+ .known .issue {
301
+ display: flex; align-items: flex-start; gap: 10px;
302
+ padding: 10px 12px; margin-bottom: 6px;
303
+ background: var(--bc-subtle); border: 1px solid transparent; border-radius: 10px;
304
+ }
305
+ .known .issue.is-joined { border-color: var(--bc-ok); }
306
+ .known .issue .who { flex: 1; min-width: 0; }
307
+ .known .issue .t {
308
+ display: block; font-size: 13px; font-weight: 620; line-height: 1.4;
309
+ color: var(--bc-fg); overflow-wrap: anywhere;
310
+ }
311
+ .known .issue .meta {
312
+ display: block; margin-top: 3px; font-size: 12px; line-height: 1.45; color: var(--bc-muted);
313
+ }
314
+ .known .issue .join {
315
+ flex: none; padding: 6px 10px; font-size: 12px; font-weight: 620;
316
+ color: var(--bc-accent); background: var(--bc-bg);
317
+ border: 1px solid var(--bc-border); border-radius: 8px; cursor: pointer;
318
+ }
319
+ .known .issue .join:hover { border-color: var(--bc-accent); }
320
+ .known .issue .join:disabled { color: var(--bc-ok); border-color: var(--bc-border); cursor: default; }
321
+ .known .issue .join:focus-visible { outline: 2px solid var(--bc-accent); outline-offset: 2px; }
322
+ .known .ask { display: flex; gap: 8px; margin-top: 8px; }
323
+ .known .ask input { flex: 1; min-width: 0; }
324
+ .known .ask button { flex: none; }
325
+ .known .err { margin: 6px 0 0; font-size: 12px; line-height: 1.45; color: var(--bc-danger); }
326
+
327
+ /* ---------- who is reporting ----------
328
+ A strip above the form rather than a screen in front of it: where signing in
329
+ is optional this is a line the reporter may ignore, and it should read as
330
+ one. Where it is required the composer below is not rendered at all and this
331
+ strip is the whole panel. */
332
+ .whoami {
333
+ display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
334
+ margin-bottom: 14px; padding: 10px 12px;
335
+ font-size: 12.5px; line-height: 1.45; color: var(--bc-muted);
336
+ background: var(--bc-subtle); border-radius: 10px;
337
+ }
338
+ .whoami.asking { display: block; }
339
+ .whoami .who { flex: 1; min-width: 0; }
340
+ .whoami .who strong { color: var(--bc-fg); font-weight: 620; word-break: break-all; }
341
+ .whoami .link {
342
+ padding: 0; font-size: 12.5px; font-weight: 600; line-height: 1.45;
343
+ color: var(--bc-accent); background: none; border: none; cursor: pointer;
344
+ }
345
+ .whoami .link:hover { text-decoration: underline; }
346
+ .whoami .link:disabled { color: var(--bc-faint); cursor: default; text-decoration: none; }
347
+ .whoami .link:focus-visible { outline: 2px solid var(--bc-accent); outline-offset: 2px; border-radius: 4px; }
348
+ .whoami .why { margin: 0 0 10px; font-size: 12.5px; line-height: 1.5; color: var(--bc-muted); }
349
+ .whoami .why strong { color: var(--bc-fg); font-weight: 620; word-break: break-all; }
350
+ .whoami .row { display: flex; gap: 8px; align-items: stretch; }
351
+ .whoami .row input { flex: 1; min-width: 0; }
352
+ .whoami .row button { flex: none; }
353
+ .whoami .after { display: flex; gap: 12px; align-items: center; margin-top: 9px; }
354
+ .whoami .err { color: var(--bc-danger); }
355
+ .whoami .code { letter-spacing: .28em; font-variant-numeric: tabular-nums; }
356
+
357
+ /* ---------- severity ---------- */
358
+ .seg { display: grid; grid-template-columns: repeat(4, 1fr); gap: 3px; padding: 3px; background: var(--bc-subtle); border-radius: 10px; }
359
+ .seg button {
360
+ padding: 7px 4px; font-size: 12.5px; font-weight: 550; line-height: 1;
361
+ color: var(--bc-muted); background: none; border: none; border-radius: 7px;
362
+ cursor: pointer; transition: background 180ms var(--bc-ease), color 180ms, box-shadow 180ms, font-weight 180ms;
363
+ }
364
+ .seg button:hover { color: var(--bc-fg); }
365
+ .seg button[aria-pressed=true] {
366
+ background: var(--bc-raised); color: var(--bc-accent); font-weight: 680;
367
+ box-shadow: 0 1px 3px rgba(10,12,16,.14);
368
+ }
369
+ .seg button:focus-visible { outline: 2px solid var(--bc-accent); outline-offset: -1px; }
370
+
371
+ /* ---------- attachments ---------- */
372
+ .tools { display: flex; flex-wrap: wrap; gap: 6px; }
373
+ .tool {
374
+ display: inline-flex; align-items: center; gap: 6px;
375
+ padding: 8px 12px; font-size: 12.5px; font-weight: 550; line-height: 1;
376
+ color: var(--bc-fg); background: var(--bc-elevated);
377
+ border: 1px solid var(--bc-border); border-radius: 9px; cursor: pointer;
378
+ transition: border-color 160ms, background 160ms, transform 160ms var(--bc-ease);
379
+ }
380
+ .tool svg { font-size: 15px; color: var(--bc-muted); transition: color 160ms; }
381
+ .tool:hover:not(:disabled) { border-color: var(--bc-accent); transform: translateY(-1px); }
382
+ .tool:hover:not(:disabled) svg { color: var(--bc-accent); }
383
+ .tool:active:not(:disabled) { transform: translateY(0) scale(.98); }
384
+ .tool:focus-visible { outline: 2px solid var(--bc-accent); outline-offset: 1px; }
385
+ .tool:disabled { opacity: .45; cursor: not-allowed; }
386
+
387
+ .thumbs { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
388
+ .thumb {
389
+ position: relative; width: 74px; height: 74px; flex: none;
390
+ border-radius: 10px; overflow: hidden;
391
+ border: 1px solid var(--bc-border); background: var(--bc-subtle);
392
+ animation: bc-item-in 300ms var(--bc-ease) both;
393
+ }
394
+ .thumb img, .thumb video { width: 100%; height: 100%; object-fit: cover; display: block; }
395
+ .thumb .kind {
396
+ position: absolute; left: 4px; bottom: 4px;
397
+ display: inline-flex; align-items: center; gap: 3px;
398
+ padding: 2px 5px; border-radius: 5px;
399
+ background: rgba(10,12,16,.72); color: #fff; font-size: 9.5px; font-weight: 600; line-height: 1.3;
400
+ }
401
+ .thumb .drop {
402
+ position: absolute; top: 3px; right: 3px;
403
+ width: 19px; height: 19px; font-size: 11px; border-radius: 6px;
404
+ background: rgba(10,12,16,.68); color: #fff; border: none;
405
+ display: grid; place-items: center; cursor: pointer;
406
+ opacity: 0; transition: opacity 160ms;
407
+ }
408
+ .thumb:hover .drop, .thumb:focus-within .drop { opacity: 1; }
409
+ .thumb .drop:hover { background: var(--bc-danger); }
410
+ .thumb .veil {
411
+ position: absolute; inset: 0; display: grid; place-items: center; gap: 6px;
412
+ background: rgba(10,12,16,.62); color: #fff; font-size: 10.5px; font-weight: 600;
413
+ align-content: center;
414
+ }
415
+ .thumb .veil.err { background: color-mix(in srgb, var(--bc-danger) 82%, transparent); }
416
+ .spinner {
417
+ width: 17px; height: 17px; border-radius: 50%;
418
+ border: 2px solid rgba(255,255,255,.35); border-top-color: #fff;
419
+ animation: bc-spin 720ms linear infinite;
420
+ }
421
+ .bar { width: 46px; height: 3px; border-radius: 2px; background: rgba(255,255,255,.3); overflow: hidden; }
422
+ .bar i { display: block; height: 100%; width: 0; background: #fff; transition: width 180ms linear; }
423
+ .veil button {
424
+ background: none; border: none; color: #fff; font-size: 14px; padding: 2px;
425
+ cursor: pointer; display: grid; place-items: center;
426
+ }
427
+
428
+ /* ---------- messages ---------- */
429
+ .msg {
430
+ display: flex; align-items: flex-start; gap: 7px;
431
+ font-size: 12.5px; line-height: 1.45; padding: 9px 11px; border-radius: 9px; margin-bottom: 12px;
432
+ animation: bc-item-in 240ms var(--bc-ease) both;
433
+ }
434
+ .msg.err { background: color-mix(in srgb, var(--bc-danger) 11%, transparent); color: var(--bc-danger); }
435
+ .msg.info { background: var(--bc-subtle); color: var(--bc-muted); }
436
+
437
+ /* ---------- footer ---------- */
438
+ .foot {
439
+ display: flex; align-items: center; gap: 9px;
440
+ margin-top: 16px; padding: 14px 18px; border-top: 1px solid var(--bc-border);
441
+ background: var(--bc-subtle);
442
+ }
443
+ .foot .spacer { flex: 1; }
444
+ .foot .note { font-size: 11.5px; color: var(--bc-faint); }
445
+ /* Quiet, but still legible: this sits in someone else's product, so it identifies
446
+ where the report goes without competing with their interface. Quiet is bought
447
+ with colour (--bc-brand-ink sits a step below body text) rather than with a
448
+ blanket opacity — the BUGCATCH half is only about 3px of cap height at this
449
+ size, and dimming it on top of that left it as a smudge in both themes. */
450
+ .brand-credit { display: flex; align-items: center; transition: color 0.15s; }
451
+ .bc-brand { height: 18px; width: auto; display: block; color: var(--bc-brand-ink); }
452
+ .brand-credit:hover .bc-brand { color: var(--bc-fg); }
453
+ .brand-credit:focus-visible { outline: 2px solid var(--bc-accent); outline-offset: 3px; border-radius: 4px; }
454
+ button.ghost, button.primary {
455
+ padding: 9px 15px; font-size: 13px; font-weight: 600; line-height: 1;
456
+ border-radius: 9px; cursor: pointer;
457
+ transition: transform 160ms var(--bc-ease), filter 160ms, background 160ms, border-color 160ms;
458
+ }
459
+ button.ghost { background: none; border: 1px solid var(--bc-border); color: var(--bc-muted); }
460
+ button.ghost:hover { color: var(--bc-fg); border-color: var(--bc-faint); }
461
+ button.primary {
462
+ display: inline-flex; align-items: center; gap: 7px;
463
+ background: var(--bc-accent); border: 1px solid var(--bc-accent); color: var(--bc-on-accent);
464
+ }
465
+ button.primary:hover:not(:disabled) { transform: translateY(-1px); filter: brightness(1.07); }
466
+ button.primary:active:not(:disabled) { transform: none; }
467
+ button.ghost:focus-visible, button.primary:focus-visible { outline: 2px solid var(--bc-accent); outline-offset: 2px; }
468
+ button:disabled { opacity: .5; cursor: not-allowed; }
469
+ button.primary .spinner { border-color: rgba(255,255,255,.4); border-top-color: #fff; width: 13px; height: 13px; }
470
+
471
+ /* ---------- success ---------- */
472
+ /* ---------- your reports ---------- */
473
+ .history { padding: 6px 20px 18px; max-height: 340px; overflow-y: auto; }
474
+ /* On the conversation screen the inner thread does the scrolling, so this
475
+ wrapper must not — two nested scrollers means the composer drifts out of
476
+ reach exactly when someone reaches for it. */
477
+ .history.chatting {
478
+ max-height: none; overflow: visible; display: flex; flex-direction: column;
479
+ }
480
+ .history .row {
481
+ display: flex; align-items: flex-start; gap: 10px;
482
+ padding: 11px 0; border-bottom: 1px solid var(--bc-border);
483
+ }
484
+ .history .row:last-child { border-bottom: 0; }
485
+ .history .who { flex: 1; min-width: 0; }
486
+ .history .ttl {
487
+ display: block; font-size: 13px; font-weight: 550; color: var(--bc-fg);
488
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
489
+ }
490
+ .history .sub { display: block; margin-top: 2px; font-size: 11.5px; color: var(--bc-faint); }
491
+ .history .empty { padding: 26px 0; text-align: center; font-size: 12.5px; color: var(--bc-muted); }
492
+ /* A history row is a button now — it opens the report — so the UA styling has
493
+ to go, and the hit area has to read as one. */
494
+ .history button.row {
495
+ width: 100%; background: none; border: 0; border-bottom: 1px solid var(--bc-border);
496
+ font: inherit; color: inherit; text-align: left; cursor: pointer;
497
+ border-radius: 8px; padding: 11px 8px; margin: 0 -8px; width: calc(100% + 16px);
498
+ }
499
+ .history button.row:hover { background: var(--bc-subtle); }
500
+ .history button.row:focus-visible { outline: 2px solid var(--bc-accent); outline-offset: -2px; }
501
+
502
+ /* ---------- one report, and what happened to it ---------- */
503
+ .report-head {
504
+ display: grid; grid-template-columns: 1fr auto; gap: 2px 10px;
505
+ padding: 4px 0 14px; border-bottom: 1px solid var(--bc-border);
506
+ }
507
+ .report-head .ttl { font-size: 13.5px; font-weight: 600; color: var(--bc-fg); }
508
+ .report-head .sub { font-size: 11.5px; color: var(--bc-faint); }
509
+ .report-head .pill { grid-row: 1 / span 2; align-self: center; }
510
+
511
+ /* The thread scrolls; the composer under it does not. Both live inside the
512
+ panel, which stops scrolling as a whole on this screen — a composer that
513
+ scrolls away is a composer people think isn't there. */
514
+ .thread {
515
+ display: flex; flex-direction: column; gap: 10px;
516
+ padding: 14px 0 8px; overflow-y: auto; overscroll-behavior: contain;
517
+ max-height: min(46vh, 330px);
518
+ }
519
+
520
+ /* A status change is not a message and is not drawn as one: no side, no
521
+ bubble, no author. Centred and quiet, the way every chat marks the things
522
+ that happened rather than the things that were said. */
523
+ .sys {
524
+ align-self: center; max-width: 90%; text-align: center;
525
+ font-size: 11.5px; line-height: 1.45; color: var(--bc-faint);
526
+ }
527
+ .sys .dot {
528
+ display: inline-block; width: 6px; height: 6px; margin-right: 6px;
529
+ border-radius: 50%; background: var(--bc-muted); vertical-align: middle;
530
+ }
531
+ .sys.k-status .dot { background: #4da3ff; }
532
+ .sys.k-severity .dot { background: #e8912a; }
533
+ .sys.k-assignment .dot { background: #8b7dff; }
534
+ .sys.k-filed .dot { background: var(--bc-faint); }
535
+ .icon-btn.back { margin-right: 2px; }
536
+
537
+ /* ---------- the conversation ----------
538
+
539
+ A status change and a message are different things and are drawn as such: a
540
+ change is a line on the rail above, a message is a bubble with a side. The
541
+ reporter's own words sit right and accented, the team's left and neutral —
542
+ the arrangement every chat has taught people to read without a legend. */
543
+ .msgs { display: flex; flex-direction: column; gap: 10px; padding: 4px 0 6px; }
544
+ .msg-row { display: flex; flex-direction: column; max-width: 84%; gap: 4px; }
545
+ .msg-row.mine { align-self: flex-end; align-items: flex-end; }
546
+ .msg-row.theirs { align-self: flex-start; }
547
+ .msg-who { font-size: 11px; font-weight: 600; color: var(--bc-faint); padding: 0 2px; }
548
+ .bubble {
549
+ padding: 8px 11px; border-radius: 12px; font-size: 12.5px; line-height: 1.5;
550
+ background: var(--bc-subtle); color: var(--bc-fg);
551
+ border: 1px solid var(--bc-border);
552
+ }
553
+ /* pre-wrap belongs to the text, not the box — on the box it would also render
554
+ the markup's own indentation. */
555
+ .msg-text { display: block; white-space: pre-wrap; overflow-wrap: anywhere; }
556
+ .msg-row.mine .bubble {
557
+ background: var(--bc-accent); border-color: var(--bc-accent); color: var(--bc-on-accent);
558
+ border-bottom-right-radius: 4px;
559
+ }
560
+ .msg-row.theirs .bubble { border-bottom-left-radius: 4px; }
561
+ /* A reply nobody wrote. Outlined rather than recoloured — it is still the
562
+ team's side of the conversation — with the label above carrying the meaning,
563
+ so it reads the same to someone who cannot see the border at all. */
564
+ .msg-row.auto .bubble { border-style: dashed; }
565
+ .msg-who.auto { color: var(--bc-accent); }
566
+ /* An attachment-only message has no text to wrap, so the bubble would be an
567
+ empty coloured box around it. */
568
+ .bubble.silent { padding: 0; background: none; border: 0; }
569
+ .msg-when { font-size: 10.5px; color: var(--bc-faint); padding: 0 2px; }
570
+ .msg-row.pending { opacity: .55; }
571
+ .msg-row.failed .bubble { border: 1px solid var(--bc-danger); }
572
+
573
+ .att { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; }
574
+ .bubble.silent .att { margin-top: 0; }
575
+ .att img {
576
+ display: block; max-width: 100%; max-height: 190px; border-radius: 9px;
577
+ border: 1px solid var(--bc-border); object-fit: cover; cursor: zoom-in;
578
+ }
579
+ .att audio { width: 232px; max-width: 100%; height: 34px; }
580
+ .att video { max-width: 100%; max-height: 190px; border-radius: 9px; }
581
+ .att .file {
582
+ display: flex; align-items: center; gap: 8px; text-decoration: none;
583
+ padding: 8px 10px; border-radius: 9px; font-size: 12px;
584
+ background: var(--bc-elevated); border: 1px solid var(--bc-border); color: var(--bc-fg);
585
+ }
586
+ .att .file:hover { border-color: var(--bc-accent); }
587
+ .att .file .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
588
+ .att .file .sz { flex: none; color: var(--bc-faint); font-size: 11px; }
589
+
590
+ /* ---------- the composer ---------- */
591
+ .composer {
592
+ position: sticky; bottom: 0; margin-top: auto;
593
+ padding: 10px 0 2px;
594
+ /* The panel's own background, not --bc-elevated: the two are the same white
595
+ in light mode but differ in dark, where a raised composer reads as a band
596
+ laid over the panel rather than the foot of it. */
597
+ background: var(--bc-bg);
598
+ border-top: 1px solid var(--bc-border);
599
+ }
600
+ .composer .row { display: flex; align-items: flex-end; gap: 8px; }
601
+ .composer textarea {
602
+ flex: 1; min-height: 38px; max-height: 120px; resize: none; padding: 9px 11px;
603
+ font: inherit; font-size: 12.5px; line-height: 1.45;
604
+ color: var(--bc-fg); background: var(--bc-subtle);
605
+ border: 1px solid var(--bc-border); border-radius: 10px;
606
+ }
607
+ .composer textarea:focus { outline: none; border-color: var(--bc-accent); }
608
+ .composer .tools { display: flex; gap: 4px; }
609
+ .composer .tool {
610
+ width: 34px; height: 34px; display: grid; place-items: center; cursor: pointer;
611
+ border-radius: 9px; border: 1px solid transparent; background: none;
612
+ color: var(--bc-muted);
613
+ }
614
+ .composer .tool:hover { background: var(--bc-subtle); color: var(--bc-fg); }
615
+ .composer .tool:disabled { opacity: .4; cursor: default; }
616
+ .composer .tool.recording { color: var(--bc-danger); background: color-mix(in srgb, var(--bc-danger) 12%, transparent); }
617
+ .composer .send {
618
+ width: 34px; height: 34px; flex: none; border-radius: 9px; cursor: pointer;
619
+ background: var(--bc-accent); border: 1px solid var(--bc-accent); color: #fff;
620
+ display: grid; place-items: center;
621
+ }
622
+ .composer .send:disabled { opacity: .4; cursor: default; }
623
+ .composer .staged { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 0 8px; }
624
+ .composer .chip {
625
+ display: flex; align-items: center; gap: 6px; max-width: 190px;
626
+ padding: 5px 7px; border-radius: 8px; font-size: 11.5px;
627
+ background: var(--bc-subtle); border: 1px solid var(--bc-border); color: var(--bc-muted);
628
+ }
629
+ .composer .chip .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
630
+ .composer .chip button {
631
+ flex: none; width: 16px; height: 16px; display: grid; place-items: center;
632
+ border: 0; border-radius: 4px; background: none; color: var(--bc-faint); cursor: pointer;
633
+ }
634
+ .composer .chip button:hover { color: var(--bc-danger); }
635
+ .composer .chip.uploading { border-style: dashed; }
636
+ .composer .note { padding: 6px 2px 0; font-size: 11.5px; color: var(--bc-faint); }
637
+ .composer .note.err { color: var(--bc-danger); }
638
+ .reccount { font-variant-numeric: tabular-nums; }
639
+
640
+ /* Status carries the only colour in this list, so it is the thing the eye
641
+ lands on — which is what someone opening this screen came to check. */
642
+ .pill {
643
+ flex: none; font-size: 10.5px; font-weight: 650; letter-spacing: .03em;
644
+ padding: 3px 8px; border-radius: 999px; text-transform: uppercase;
645
+ border: 1px solid currentColor;
646
+ }
647
+ .pill.open { color: #e8912a; }
648
+ .pill.in_progress { color: #4da3ff; }
649
+ .pill.resolved { color: #35b87f; }
650
+ .pill.closed { color: var(--bc-faint); }
651
+
652
+ /* A call in progress, in place of the form.
653
+ ---------------------------------------------------------------------------
654
+ Deliberately not styled as a chat. What is on screen is a transcript of
655
+ something being said out loud, and bubbles would invite the reporter to type
656
+ into it — which is the one thing this surface cannot accept. */
657
+ .call { padding: 20px 22px 14px; animation: bc-fade-in 240ms ease both; }
658
+ .call-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
659
+ .call-head strong { font-size: 14.5px; font-weight: 650; }
660
+ .call-dot {
661
+ width: 8px; height: 8px; border-radius: 50%; background: var(--bc-accent);
662
+ animation: bc-call-pulse 1.6s ease-in-out infinite;
663
+ }
664
+ .call-dot.off { background: var(--bc-faint); animation: none; }
665
+ @keyframes bc-call-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } }
666
+ /* Said before the first word is, because a voice answering is exactly when
667
+ somebody assumes they have reached a person. */
668
+ .call-note { margin: 0 0 12px; font-size: 12px; color: var(--bc-muted); line-height: 1.5; }
669
+
670
+ /* ---------- a call to a person, on top of the report it is about ---------- */
671
+ /* A bar rather than its own screen, unlike the agent call. There is nothing to
672
+ read during this one — no transcript arrives, because nobody is transcribing
673
+ it — and the thread underneath is exactly what both people are talking about,
674
+ so taking it off screen would be taking away the only shared reference. */
675
+ .livecall {
676
+ display: flex; align-items: center; gap: 9px;
677
+ margin: 12px 0 0; padding: 9px 11px;
678
+ border: 1px solid var(--bc-border); border-radius: 10px;
679
+ background: var(--bc-subtle); font-size: 12.5px; color: var(--bc-fg);
680
+ }
681
+ .livecall .who { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
682
+ .livecall .secs { font-variant-numeric: tabular-nums; color: var(--bc-muted); }
683
+ .livecall .act {
684
+ padding: 5px 10px; border-radius: 999px; border: 1px solid var(--bc-border);
685
+ background: var(--bc-elevated); color: var(--bc-fg);
686
+ font: inherit; font-size: 12px; cursor: pointer;
687
+ }
688
+ .livecall .act:hover { background: var(--bc-subtle); }
689
+ .livecall .act.danger { border-color: transparent; background: var(--bc-danger); color: #fff; }
690
+ .livecall.over { color: var(--bc-muted); }
691
+ .call-note code { font-size: 11px; }
692
+ .call-lines {
693
+ max-height: 240px; overflow-y: auto; display: flex; flex-direction: column; gap: 7px;
694
+ padding: 10px 12px; border: 1px solid var(--bc-line); border-radius: 8px;
695
+ background: var(--bc-surface-2, transparent);
696
+ }
697
+ .call-line { font-size: 13px; line-height: 1.55; overflow-wrap: anywhere; }
698
+ .call-line.agent { color: var(--bc-muted); }
699
+ .call-line.you { color: var(--bc-ink); font-weight: 500; }
700
+ .call-line.you::before { content: 'You: '; color: var(--bc-faint); font-weight: 400; }
701
+ @media (prefers-reduced-motion: reduce) { .call-dot { animation: none; } }
702
+
703
+ .done { padding: 34px 24px 38px; text-align: center; animation: bc-fade-in 240ms ease both; }
704
+ .done .tick {
705
+ width: 46px; height: 46px; margin: 0 auto 14px; border-radius: 50%;
706
+ display: grid; place-items: center; font-size: 24px;
707
+ background: color-mix(in srgb, var(--bc-ok) 14%, transparent); color: var(--bc-ok);
708
+ animation: bc-pop 420ms var(--bc-ease) both;
709
+ }
710
+ .done h3 { margin: 0 0 5px; font-size: 15.5px; font-weight: 650; }
711
+ .done p { margin: 0; font-size: 12.5px; color: var(--bc-muted); }
712
+ .done code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
713
+ /* The likely wait. Set apart from the reference line above it by a rule rather
714
+ than by weight: one is bookkeeping and the other is the answer to the
715
+ question the reporter actually has, and they should not read as one
716
+ paragraph. */
717
+ .done .eta {
718
+ margin: 12px auto 0;
719
+ padding-top: 11px;
720
+ border-top: 1px solid var(--bc-line);
721
+ max-width: 30ch;
722
+ line-height: 1.5;
723
+ }
724
+ .done .eta strong { color: var(--bc-ink); font-weight: 600; }
725
+ /* The same figure on the report the reporter came back to. Sits under the
726
+ status pill in the header block, muted — it is context, not the headline the
727
+ status is. */
728
+ .report-head .eta-line {
729
+ grid-column: 1 / -1;
730
+ font-size: 11.5px;
731
+ color: var(--bc-muted);
732
+ margin-top: 2px;
733
+ }
734
+
735
+ /* ---------- recording bar ---------- */
736
+ .recbar {
737
+ position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%);
738
+ z-index: 2147483002;
739
+ display: flex; align-items: center; gap: 12px;
740
+ padding: 10px 12px 10px 15px; border-radius: 999px;
741
+ background: var(--bc-elevated); color: var(--bc-fg);
742
+ border: 1px solid var(--bc-border); box-shadow: var(--bc-shadow);
743
+ animation: bc-item-in 300ms var(--bc-ease) both;
744
+ }
745
+ .recbar .dot { width: 9px; height: 9px; border-radius: 50%; background: var(--bc-danger); animation: bc-pulse 1.4s ease-in-out infinite; }
746
+ .recbar .time { font-variant-numeric: tabular-nums; font-size: 13px; font-weight: 600; min-width: 38px; }
747
+ .recbar .hint { font-size: 12px; color: var(--bc-muted); }
748
+ .recbar button { padding: 7px 13px; font-size: 12.5px; font-weight: 600; border-radius: 999px; cursor: pointer; }
749
+ .recbar .stop { background: var(--bc-danger); border: 1px solid var(--bc-danger); color: #fff; }
750
+ .recbar .stop:hover { filter: brightness(1.08); }
751
+
752
+ @media (max-width: 520px) {
753
+ .panel { width: calc(100vw - 24px); max-width: none; }
754
+ .scrim.at-right .panel, .scrim.at-left .panel { left: 12px; right: 12px; }
755
+ .recbar .hint { display: none; }
756
+ }
757
+
758
+ /* ---------- phones ----------------------------------------------------------
759
+ This widget runs inside a shadow root on somebody else's site, so its own
760
+ stylesheet is the whole story: the host page cannot correct any of this, and
761
+ anything that goes wrong here goes wrong on their site, in front of their
762
+ visitor, at the moment that visitor is already annoyed enough to file a bug.
763
+ -------------------------------------------------------------------------- */
764
+
765
+ /* 100vh on a phone is the viewport with the browser chrome hidden, so the
766
+ panel could be taller than the space it actually has and put "Send report"
767
+ under the address bar. */
768
+ @supports (height: 100dvh) {
769
+ .panel { max-height: calc(100dvh - var(--bc-dock-h, 84px) - 52px); }
770
+ }
771
+
772
+ /* The dock sits in a corner of a page the widget does not own. On a notched
773
+ phone that corner is under the home indicator, and in landscape it is under
774
+ the camera housing. env() is 0 everywhere else, so the offsets are unchanged
775
+ on hardware that has no insets. */
776
+ .dock {
777
+ padding-bottom: env(safe-area-inset-bottom, 0px);
778
+ padding-right: env(safe-area-inset-right, 0px);
779
+ padding-left: env(safe-area-inset-left, 0px);
780
+ }
781
+
782
+ @media (pointer: coarse) {
783
+ /* iOS zooms the whole page when a field it focuses is under 16px, and does
784
+ not zoom back out. On the host's site that is not the widget's page to
785
+ zoom — the visitor is left on a magnified checkout wondering what they
786
+ touched. !important because this is a property of the device, not a
787
+ preference about type. */
788
+ input, textarea, select { font-size: 16px !important; }
789
+
790
+ /* 44px is the floor a thumb hits reliably. Every control in this form was
791
+ under it, on the one surface in the product designed for someone who is
792
+ not an operator and gets no second attempt. */
793
+ .launcher { min-height: 48px; padding: 14px 20px; }
794
+ .seg button { min-height: 44px; padding: 12px 8px; }
795
+ .tool { min-height: 44px; padding: 12px 14px; }
796
+ button.primary, .foot button { min-height: 44px; padding: 12px 18px; }
797
+ textarea { min-height: 104px; }
798
+
799
+ /* The theme switch and the close button are 28px glyphs whose boxes cannot
800
+ grow without moving the header text beside them, so each keeps its size
801
+ and gets an invisible target drawn around it instead. */
802
+ .themer button, .head .icon-btn, .thumb .x, .brand-credit { position: relative; }
803
+ .themer button::after, .head .icon-btn::after, .thumb .x::after, .brand-credit::after {
804
+ content: '';
805
+ position: absolute;
806
+ inset: -9px;
807
+ }
808
+ /* The close button is the one people reach for in a hurry, and it sits in
809
+ the corner where a thumb is least accurate. */
810
+ .head .icon-btn::after { inset: -12px; }
811
+ /* The themer's three buttons sit side by side, so their expanders would
812
+ overlap each other without a gap to grow into. */
813
+ .themer { gap: 4px; }
814
+
815
+ /* Thumb-sized controls make the form taller, which on a short phone pushed
816
+ "Send report" below the fold of a panel that scrolls — so the one button
817
+ the whole widget exists for was reachable only by scrolling past the
818
+ attachment row. It stays on screen instead. */
819
+ .foot {
820
+ position: sticky;
821
+ bottom: 0;
822
+ margin-top: 16px;
823
+ /* The panel scrolls under this, so the bar cannot be translucent. */
824
+ background: var(--bc-subtle);
825
+ padding-bottom: max(14px, env(safe-area-inset-bottom, 0px));
826
+ }
827
+ }
828
+
829
+ /* Hover is a lie on a touchscreen: it latches on tap and stays. The launcher's
830
+ lift in particular was left applied after the panel closed. */
831
+ @media (hover: none) {
832
+ .launcher:hover { transform: none; filter: none; box-shadow: 0 6px 18px rgba(10,12,16,.22), 0 1px 3px rgba(10,12,16,.16); }
833
+ .tool:hover, .themer button:hover { border-color: var(--bc-border); }
834
+ }
835
+ `;
836
+
837
+ export function mountWidget(client) {
838
+ const host = document.createElement('div');
839
+ host.setAttribute('data-bugcatch', 'widget');
840
+
841
+ // A visitor who has picked a theme outranks the site's init() default: the
842
+ // config says how the widget should look to someone who has never touched
843
+ // it, not how it should look to someone who has.
844
+ let theme = readStoredTheme() || themeValue(client.config.theme);
845
+ host.className = themeClass(theme);
846
+
847
+ const root = host.attachShadow({ mode: 'open' });
848
+ const style = document.createElement('style');
849
+ style.textContent = css(client.config.accent || '#4F46E5');
850
+ root.appendChild(style);
851
+
852
+ const position = POSITIONS[client.config.widgetPosition] ? client.config.widgetPosition : 'bottom-right';
853
+ const dock = document.createElement('div');
854
+ dock.className = `dock ${position.includes('right') ? 'at-right' : 'at-left'}`
855
+ + (position.startsWith('top') ? ' at-top' : '');
856
+ dock.style.cssText = POSITIONS[position];
857
+
858
+ const themer = document.createElement('div');
859
+ themer.className = 'themer';
860
+ themer.setAttribute('role', 'group');
861
+ themer.setAttribute('aria-label', 'Report widget theme');
862
+ themer.innerHTML = THEMES.map((t) => `<button type="button" data-theme="${t.value}" `
863
+ + `title="${t.label}" aria-label="${t.label}" aria-pressed="${t.value === theme}">${t.icon}</button>`).join('');
864
+
865
+ themer.addEventListener('click', (e) => {
866
+ const btn = e.target.closest('button[data-theme]');
867
+ if (!btn || btn.dataset.theme === theme) return;
868
+ theme = btn.dataset.theme;
869
+ // The panel lives in the same shadow root, so an open form re-themes with
870
+ // it rather than waiting for the next open.
871
+ host.className = themeClass(theme);
872
+ writeStoredTheme(theme);
873
+ themer.querySelectorAll('button').forEach((b) => {
874
+ b.setAttribute('aria-pressed', String(b.dataset.theme === theme));
875
+ });
876
+ });
877
+
878
+ const launcher = document.createElement('button');
879
+ launcher.className = 'launcher';
880
+ launcher.type = 'button';
881
+ launcher.innerHTML = `${ICONS.bug}<span>Report a bug</span>`;
882
+ launcher.setAttribute('aria-haspopup', 'dialog');
883
+ launcher.setAttribute('aria-expanded', 'false');
884
+
885
+ let open = null;
886
+ launcher.addEventListener('click', () => {
887
+ // The launcher stays visible with the panel anchored just above it, so a
888
+ // second click reads as "close that" rather than doing nothing.
889
+ if (open) { open.close(); return; }
890
+ dock.classList.add('over');
891
+ launcher.setAttribute('aria-expanded', 'true');
892
+ open = openPanel({
893
+ root,
894
+ host,
895
+ dock,
896
+ client,
897
+ onClose: () => {
898
+ open = null;
899
+ dock.classList.remove('over');
900
+ launcher.setAttribute('aria-expanded', 'false');
901
+ },
902
+ });
903
+ });
904
+
905
+ dock.append(themer, launcher);
906
+ root.appendChild(dock);
907
+
908
+ document.body.appendChild(host);
909
+ return () => { open?.destroy(); host.remove(); };
910
+ }
911
+
912
+ /** Normalises a configured theme to one of the three the switch offers. */
913
+ function themeValue(theme) {
914
+ return theme === 'dark' || theme === 'light' ? theme : 'auto';
915
+ }
916
+
917
+ function themeClass(theme) {
918
+ if (theme === 'dark') return 'bc-dark';
919
+ if (theme === 'light') return 'bc-light';
920
+ return 'bc-auto';
921
+ }
922
+
923
+ function openPanel({ root, host, dock, client, onClose }) {
924
+ const cfg = client.config;
925
+ const canCapture = cfg.attachments !== false;
926
+ const canShoot = canCapture && screenshotSupported();
927
+ const canRecord = canCapture && captureSupported();
928
+
929
+ // Uploaded as they are added rather than on send: the upload is the slow
930
+ // part, and doing it while the reporter is still typing means Send is
931
+ // instant and a failure is visible next to the thing that failed.
932
+ const items = [];
933
+ let priority = 'MEDIUM';
934
+ let recorder = null;
935
+ let sending = false;
936
+
937
+ const scrim = document.createElement('div');
938
+ // The panel opens out of whichever corner the dock is in, clearing it by the
939
+ // dock's measured height rather than a guess at the launcher's size.
940
+ scrim.className = `scrim ${dock.classList.contains('at-left') ? 'at-left' : 'at-right'} `
941
+ + `${dock.classList.contains('at-top') ? 'at-top' : 'at-bottom'}`;
942
+ scrim.style.setProperty('--bc-dock-h', `${Math.round(dock.getBoundingClientRect().height)}px`);
943
+ scrim.innerHTML = `
944
+ <div class="panel" role="dialog" aria-modal="true" aria-labelledby="bc-h">
945
+ <div class="head">
946
+ <div class="mark">${ICONS.bug}</div>
947
+ <div class="titles">
948
+ <h2 id="bc-h">Report a bug</h2>
949
+ <p>We'll attach what the page was doing, so you don't have to explain it.</p>
950
+ </div>
951
+ <button class="icon-btn" type="button" data-act="close" aria-label="Close">${ICONS.close}</button>
952
+ </div>
953
+ <div class="body">
954
+ <div class="slot"></div>
955
+ <div class="whoami" hidden></div>
956
+ <div class="known" hidden></div>
957
+ <div class="compose">
958
+ <div class="field">
959
+ <label for="bc-title">What went wrong?</label>
960
+ <input id="bc-title" type="text" placeholder="Checkout button does nothing" autocomplete="off" />
961
+ </div>
962
+ <div class="field">
963
+ <label for="bc-desc">Any detail that helps <span style="font-weight:450;color:var(--bc-faint)">(optional)</span></label>
964
+ <textarea id="bc-desc" placeholder="I clicked Pay and nothing happened."></textarea>
965
+ </div>
966
+ <div class="field">
967
+ <label id="bc-prio-l">Severity</label>
968
+ <div class="seg" role="group" aria-labelledby="bc-prio-l">
969
+ ${PRIORITIES.map((p) => `
970
+ <button type="button" data-prio="${p.value}" aria-pressed="${p.value === priority}">${p.label}</button>
971
+ `).join('')}
972
+ </div>
973
+ </div>
974
+ ${canCapture ? `
975
+ <div class="field">
976
+ <label>Attach <span style="font-weight:450;color:var(--bc-faint)">(optional)</span></label>
977
+ <div class="tools">
978
+ ${canShoot ? `<button class="tool" type="button" data-act="shot">${ICONS.camera}Screenshot</button>` : ''}
979
+ ${canRecord ? `<button class="tool" type="button" data-act="rec">${ICONS.video}Record</button>` : ''}
980
+ <button class="tool" type="button" data-act="file">${ICONS.paperclip}File</button>
981
+ </div>
982
+ <div class="thumbs"></div>
983
+ <input type="file" accept="${REPORT_MIMES.join(',')}" multiple hidden />
984
+ </div>` : ''}
985
+ </div>
986
+ </div>
987
+ <div class="foot">
988
+ <a class="brand-credit" href="https://bugcatch.sphoro.com" target="_blank" rel="noopener noreferrer" aria-label="BugCatch">${BRAND}</a>
989
+ <span class="note"></span>
990
+ <span class="spacer"></span>
991
+ ${client.hasReports() ? '<button class="ghost" type="button" data-act="history">Your reports</button>' : ''}
992
+ <button class="ghost" type="button" data-act="cancel">Cancel</button>
993
+ <!-- Hidden until the API confirms this project can take a call, which
994
+ most cannot. Drawn here rather than as a second launcher because it
995
+ is the same errand as Send report — the difference is only whether
996
+ the reporter would rather say it than type it. -->
997
+ <button class="ghost" type="button" data-act="call" hidden>Talk to us</button>
998
+ <button class="primary" type="button" data-act="send">Send report</button>
999
+ </div>
1000
+ </div>`;
1001
+
1002
+ const $ = (sel) => scrim.querySelector(sel);
1003
+ const titleInput = $('#bc-title');
1004
+ const slot = $('.slot');
1005
+ const thumbs = $('.thumbs');
1006
+ const filePicker = scrim.querySelector('input[type=file]');
1007
+ const sendBtn = $('[data-act="send"]');
1008
+ const callBtn = $('[data-act="call"]');
1009
+ const note = $('.note');
1010
+ const compose = $('.compose');
1011
+ const whoami = $('.whoami');
1012
+ const known = $('.known');
1013
+ const historyBtn = $('[data-act="history"]');
1014
+
1015
+ /* ---------- lifecycle ---------- */
1016
+
1017
+ const previouslyFocused = document.activeElement;
1018
+ const rootStyle = document.documentElement.style;
1019
+ const previousOverflow = rootStyle.overflow;
1020
+ rootStyle.overflow = 'hidden';
1021
+
1022
+ let closed = false;
1023
+ // Assigned further down, once `showReport` exists to be called back into.
1024
+ let stopListening = null;
1025
+ function destroy() {
1026
+ if (closed) return;
1027
+ closed = true;
1028
+ recorder?.cancel();
1029
+ // Stop listening before the DOM goes: a frame arriving after this would
1030
+ // redraw a panel that is no longer on the page.
1031
+ stopListening?.();
1032
+ items.forEach((it) => it.previewUrl && URL.revokeObjectURL(it.previewUrl));
1033
+ rootStyle.overflow = previousOverflow;
1034
+ document.removeEventListener('keydown', onKeydown, true);
1035
+ scrim.remove();
1036
+ root.querySelector('.recbar')?.remove();
1037
+ dock.hidden = false;
1038
+ host.style.opacity = '';
1039
+ onClose?.();
1040
+ }
1041
+
1042
+ function close() {
1043
+ if (closed) return;
1044
+ // Let the exit animation play, but never wait on an event that a
1045
+ // reduced-motion user's near-zero animation might deliver unpredictably.
1046
+ scrim.classList.add('closing');
1047
+ setTimeout(destroy, 160);
1048
+ try { previouslyFocused?.focus?.(); } catch { /* element may be gone */ }
1049
+ }
1050
+
1051
+ function onKeydown(e) {
1052
+ if (e.key === 'Escape') {
1053
+ e.stopPropagation();
1054
+ if (recorder) { stopRecording({ keep: false }); return; }
1055
+ if (!sending) close();
1056
+ return;
1057
+ }
1058
+ if (e.key !== 'Tab' || recorder) return;
1059
+ // Focus trap: the panel is modal, so Tab must not reach the host page.
1060
+ const focusable = [...scrim.querySelectorAll(
1061
+ 'button:not(:disabled), input:not([hidden]), textarea, select, [href]',
1062
+ )].filter((el) => el.offsetParent !== null || el === document.activeElement);
1063
+ if (!focusable.length) return;
1064
+ const first = focusable[0];
1065
+ const last = focusable[focusable.length - 1];
1066
+ const active = scrim.getRootNode().activeElement;
1067
+ if (e.shiftKey && active === first) { e.preventDefault(); last.focus(); }
1068
+ else if (!e.shiftKey && active === last) { e.preventDefault(); first.focus(); }
1069
+ }
1070
+
1071
+ document.addEventListener('keydown', onKeydown, true);
1072
+ scrim.addEventListener('click', (e) => { if (e.target === scrim && !sending) close(); });
1073
+ $('[data-act="close"]').addEventListener('click', close);
1074
+ $('[data-act="cancel"]').addEventListener('click', close);
1075
+
1076
+ /* ---------- severity ---------- */
1077
+
1078
+ scrim.querySelectorAll('[data-prio]').forEach((btn) => {
1079
+ btn.addEventListener('click', () => {
1080
+ priority = btn.dataset.prio;
1081
+ scrim.querySelectorAll('[data-prio]').forEach((b) => {
1082
+ b.setAttribute('aria-pressed', String(b === btn));
1083
+ });
1084
+ });
1085
+ });
1086
+
1087
+ /* ---------- attachments ---------- */
1088
+
1089
+ function say(text, kind = 'err') {
1090
+ slot.innerHTML = text ? `<div class="msg ${kind}">${escapeHtml(text)}</div>` : '';
1091
+ }
1092
+
1093
+ function refreshSendState() {
1094
+ const busy = items.some((it) => it.status === 'uploading');
1095
+ sendBtn.disabled = sending || busy;
1096
+ note.textContent = busy ? 'Waiting for the upload to finish…' : '';
1097
+ scrim.querySelectorAll('.tool').forEach((t) => {
1098
+ t.disabled = sending || items.length >= (cfg.maxAttachments ?? 5);
1099
+ });
1100
+ }
1101
+
1102
+ function drawThumb(item) {
1103
+ const el = item.el;
1104
+ const label = item.kind === 'VIDEO' ? 'Video' : 'Shot';
1105
+ const preview = item.kind === 'VIDEO'
1106
+ ? `<video src="${item.previewUrl}" muted playsinline preload="metadata"></video>`
1107
+ : `<img src="${item.previewUrl}" alt="" />`;
1108
+
1109
+ let veil = '';
1110
+ if (item.status === 'uploading') {
1111
+ veil = `<div class="veil"><div class="spinner"></div><div class="bar"><i style="width:${Math.round(item.progress * 100)}%"></i></div></div>`;
1112
+ } else if (item.status === 'failed') {
1113
+ veil = `<div class="veil err"><button type="button" data-act="retry" aria-label="Retry upload">${ICONS.retry}</button>Failed</div>`;
1114
+ }
1115
+
1116
+ el.innerHTML = `
1117
+ ${preview}
1118
+ <span class="kind">${label} · ${humanSize(item.size)}</span>
1119
+ <button class="drop" type="button" data-act="drop" aria-label="Remove ${label.toLowerCase()}">${ICONS.close}</button>
1120
+ ${veil}`;
1121
+
1122
+ el.querySelector('[data-act="drop"]').addEventListener('click', () => removeItem(item));
1123
+ el.querySelector('[data-act="retry"]')?.addEventListener('click', () => upload(item));
1124
+ }
1125
+
1126
+ function removeItem(item) {
1127
+ const i = items.indexOf(item);
1128
+ if (i >= 0) items.splice(i, 1);
1129
+ if (item.previewUrl) URL.revokeObjectURL(item.previewUrl);
1130
+ item.el.remove();
1131
+ refreshSendState();
1132
+ }
1133
+
1134
+ async function upload(item) {
1135
+ item.status = 'uploading';
1136
+ item.progress = 0;
1137
+ drawThumb(item);
1138
+ refreshSendState();
1139
+ try {
1140
+ const { storageKey, mime } = await client.uploadAttachment(item.blob, item.mime, (p) => {
1141
+ item.progress = p;
1142
+ // Only the width changes while uploading — repainting the whole tile
1143
+ // would restart the entrance animation on every progress event.
1144
+ const fill = item.el.querySelector('.bar i');
1145
+ if (fill) fill.style.width = `${Math.round(p * 100)}%`;
1146
+ });
1147
+ item.storageKey = storageKey;
1148
+ item.mime = mime;
1149
+ item.status = 'ready';
1150
+ } catch (err) {
1151
+ item.status = 'failed';
1152
+ say(err.message.replace('[BugCatch] ', ''));
1153
+ }
1154
+ drawThumb(item);
1155
+ refreshSendState();
1156
+ }
1157
+
1158
+ function addItem({ blob, mime, kind }) {
1159
+ const max = cfg.maxAttachmentBytes ?? 25 * 1024 * 1024;
1160
+ if (blob.size > max) {
1161
+ say(`That file is ${humanSize(blob.size)} — the limit is ${humanSize(max)}.`);
1162
+ return;
1163
+ }
1164
+ // Held to the report set, not the full upload set. A report's attachments
1165
+ // are typed SCREENSHOT or VIDEO, so a document accepted here would be filed
1166
+ // as a screenshot and render as a broken image. Documents and voice notes
1167
+ // go on a message instead, where they keep their own type.
1168
+ const normalized = normalizeMime(mime || blob.type, REPORT_MIMES);
1169
+ if (!normalized) {
1170
+ say('Only PNG, JPEG, WebP, WebM and MP4 files can be attached to a report.');
1171
+ return;
1172
+ }
1173
+ const item = {
1174
+ blob, kind, mime: normalized, size: blob.size,
1175
+ previewUrl: URL.createObjectURL(blob),
1176
+ status: 'uploading', progress: 0, storageKey: null,
1177
+ el: document.createElement('div'),
1178
+ };
1179
+ item.el.className = 'thumb';
1180
+ items.push(item);
1181
+ thumbs.appendChild(item.el);
1182
+ say('');
1183
+ upload(item);
1184
+ }
1185
+
1186
+ /* Hide our own UI so the capture shows the page, not this dialog. */
1187
+ async function withWidgetHidden(fn) {
1188
+ host.style.opacity = '0';
1189
+ host.style.pointerEvents = 'none';
1190
+ // One frame so the browser has actually painted the hidden state before
1191
+ // the picker — and the frame we grab — is taken.
1192
+ await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
1193
+ try {
1194
+ return await fn();
1195
+ } finally {
1196
+ host.style.opacity = '';
1197
+ host.style.pointerEvents = '';
1198
+ }
1199
+ }
1200
+
1201
+ scrim.querySelector('[data-act="shot"]')?.addEventListener('click', async () => {
1202
+ try {
1203
+ const shot = await withWidgetHidden(captureScreenshot);
1204
+ addItem(shot);
1205
+ } catch (err) {
1206
+ // NotAllowedError is the user dismissing the picker — not a failure.
1207
+ if (err?.name !== 'NotAllowedError') say(`Screenshot failed: ${err.message}`);
1208
+ }
1209
+ });
1210
+
1211
+ scrim.querySelector('[data-act="file"]')?.addEventListener('click', () => filePicker.click());
1212
+ filePicker?.addEventListener('change', () => {
1213
+ const room = (cfg.maxAttachments ?? 5) - items.length;
1214
+ [...filePicker.files].slice(0, Math.max(room, 0)).forEach((file) => {
1215
+ addItem({
1216
+ blob: file,
1217
+ mime: file.type,
1218
+ kind: file.type.startsWith('video/') ? 'VIDEO' : 'SCREENSHOT',
1219
+ });
1220
+ });
1221
+ filePicker.value = '';
1222
+ });
1223
+
1224
+ /* ---------- recording ---------- */
1225
+
1226
+ scrim.querySelector('[data-act="rec"]')?.addEventListener('click', async () => {
1227
+ const maxMs = cfg.maxRecordingMs ?? 120_000;
1228
+ let bar;
1229
+ try {
1230
+ recorder = await startRecording({
1231
+ maxMs,
1232
+ onTick: (s) => { if (bar) bar.querySelector('.time').textContent = humanDuration(s); },
1233
+ onAutoStop: () => { if (recorder) stopRecording({ keep: true }); },
1234
+ });
1235
+ } catch (err) {
1236
+ if (err?.name !== 'NotAllowedError') say(`Recording failed: ${err.message}`);
1237
+ return;
1238
+ }
1239
+
1240
+ // Step out of the way for the duration: a recording of this dialog sitting
1241
+ // over the page is not what anyone means by "record the bug".
1242
+ scrim.style.display = 'none';
1243
+ dock.hidden = true;
1244
+
1245
+ bar = document.createElement('div');
1246
+ bar.className = 'recbar';
1247
+ bar.innerHTML = `
1248
+ <span class="dot"></span>
1249
+ <span class="time">0:00</span>
1250
+ <span class="hint">Recording — up to ${humanDuration(Math.round(maxMs / 1000))}</span>
1251
+ <button class="stop" type="button">Stop</button>
1252
+ <button class="ghost" type="button">Discard</button>`;
1253
+ bar.querySelector('.stop').addEventListener('click', () => stopRecording({ keep: true }));
1254
+ bar.querySelector('.ghost').addEventListener('click', () => stopRecording({ keep: false }));
1255
+ root.appendChild(bar);
1256
+ bar.querySelector('.stop').focus();
1257
+ });
1258
+
1259
+ async function stopRecording({ keep }) {
1260
+ const rec = recorder;
1261
+ if (!rec) return;
1262
+ recorder = null;
1263
+ root.querySelector('.recbar')?.remove();
1264
+ scrim.style.display = '';
1265
+ dock.hidden = false;
1266
+
1267
+ if (!keep) { rec.cancel(); titleInput.focus(); return; }
1268
+ try {
1269
+ const clip = await rec.stop();
1270
+ if (clip.blob.size) addItem(clip);
1271
+ else say('That recording came back empty — nothing was captured.');
1272
+ } catch (err) {
1273
+ say(`Recording failed: ${err.message}`);
1274
+ }
1275
+ titleInput.focus();
1276
+ }
1277
+
1278
+ /* ---------- send ---------- */
1279
+
1280
+ sendBtn.addEventListener('click', async () => {
1281
+ const title = titleInput.value;
1282
+ if (!title.trim()) {
1283
+ say('Please describe what went wrong.');
1284
+ titleInput.classList.add('invalid');
1285
+ titleInput.focus();
1286
+ setTimeout(() => titleInput.classList.remove('invalid'), 500);
1287
+ return;
1288
+ }
1289
+
1290
+ sending = true;
1291
+ say('');
1292
+ refreshSendState();
1293
+ const original = sendBtn.innerHTML;
1294
+ sendBtn.innerHTML = '<span class="spinner"></span>Sending…';
1295
+
1296
+ try {
1297
+ const res = await client.report({
1298
+ title,
1299
+ description: $('#bc-desc').value,
1300
+ priority,
1301
+ attachments: items
1302
+ .filter((it) => it.status === 'ready')
1303
+ .map((it) => ({ type: it.kind, storageKey: it.storageKey, mime: it.mime })),
1304
+ });
1305
+ succeed(res);
1306
+ } catch (err) {
1307
+ sending = false;
1308
+ sendBtn.innerHTML = original;
1309
+ say(err.message.replace('[BugCatch] ', ''));
1310
+ refreshSendState();
1311
+ // One reason a send fails is that this browser was signed out while the
1312
+ // panel sat open — the same address signed in elsewhere. The message
1313
+ // above already says so; this puts the panel back in step with it
1314
+ // instead of leaving a Send button that will fail the same way again.
1315
+ if (login.mode !== 'off') refreshLogin().catch(() => {});
1316
+ }
1317
+ });
1318
+
1319
+ scrim.querySelector('[data-act="history"]')?.addEventListener('click', showHistory);
1320
+
1321
+ /* ---------- what is already known ---------- */
1322
+
1323
+ // The list as it was last fetched, and who has said what about it. Held here
1324
+ // rather than re-read from the DOM so a re-render after a join does not have
1325
+ // to parse its own markup back out.
1326
+ let issues = [];
1327
+ let issueError = '';
1328
+ let askingFor = null;
1329
+ let matchAbort = null;
1330
+
1331
+ // Asked once when the dialog opens, like the call button above and for the
1332
+ // same reason: it is one request per opened widget rather than one per page
1333
+ // load of every site running this SDK, and most projects will answer with an
1334
+ // empty list forever.
1335
+ function loadKnownIssues() {
1336
+ if (cfg.knownIssues === false) return;
1337
+ client.knownIssues()
1338
+ .then((rows) => { issues = rows; drawKnown(); })
1339
+ .catch(() => { /* a help panel that cannot load is simply not drawn */ });
1340
+ }
1341
+
1342
+ // Re-run as the title is typed, so the panel narrows to what they are
1343
+ // actually describing. Debounced, and the previous request is aborted rather
1344
+ // than left to land: a fast typist otherwise renders whichever response comes
1345
+ // back last, which is not necessarily the one matching the box.
1346
+ let matchTimer = null;
1347
+ function matchKnown(query) {
1348
+ if (cfg.knownIssues === false) return;
1349
+ clearTimeout(matchTimer);
1350
+ matchAbort?.abort();
1351
+ const q = (query || '').trim();
1352
+ if (q.length < 3) { loadKnownIssues(); return; }
1353
+ matchTimer = setTimeout(() => {
1354
+ matchAbort = new AbortController();
1355
+ client.matchKnownIssues(q, { signal: matchAbort.signal })
1356
+ .then((rows) => {
1357
+ // An empty result means "nothing like this is known", which is not
1358
+ // the same as "nothing is known" — fall back to the full list rather
1359
+ // than blanking a panel the reporter was reading.
1360
+ if (rows.length) { issues = rows; drawKnown(); }
1361
+ })
1362
+ .catch(() => {});
1363
+ }, 250);
1364
+ }
1365
+
1366
+ function drawKnown() {
1367
+ if (!issues.length) { known.hidden = true; known.innerHTML = ''; return; }
1368
+ known.hidden = false;
1369
+ known.innerHTML = `
1370
+ <p class="known-h">Already reported</p>
1371
+ ${issues.map((issue) => `
1372
+ <div class="issue ${issue.joined ? 'is-joined' : ''}">
1373
+ <span class="who">
1374
+ <span class="t">${escapeHtml(issue.title)}</span>
1375
+ <span class="meta">${escapeHtml(knownMeta(issue))}</span>
1376
+ </span>
1377
+ <button class="join" type="button" data-join="${escapeHtml(issue.id)}"
1378
+ ${issue.joined ? 'disabled' : ''}>${issue.joined ? 'Following' : 'Me too'}</button>
1379
+ </div>
1380
+ ${askingFor === issue.id ? `
1381
+ <div class="ask">
1382
+ <input type="email" class="join-email" placeholder="you@example.com"
1383
+ aria-label="Your email, so we can tell you when this is fixed" />
1384
+ <button class="ghost" type="button" data-join-confirm="${escapeHtml(issue.id)}">Follow</button>
1385
+ </div>` : ''}
1386
+ `).join('')}
1387
+ ${issueError ? `<p class="err">${escapeHtml(issueError)}</p>` : ''}`;
1388
+ }
1389
+
1390
+ // What a reporter is told about an issue beside its title. Deliberately not
1391
+ // its severity: how bad the team thinks this is, published to everybody who
1392
+ // opens the widget, is an internal judgement that reads as a promise.
1393
+ function knownMeta(issue) {
1394
+ const state = issue.status === 'IN_PROGRESS' ? 'Being worked on now' : 'Not fixed yet';
1395
+ // The count is dropped entirely at zero rather than written as "0 people",
1396
+ // or as a bare "Reported" that says nothing the heading above has not
1397
+ // already said. Nobody having pressed the button yet is not a fact about
1398
+ // this issue worth a reporter's attention.
1399
+ const others = issue.affectedCount === 0
1400
+ ? ''
1401
+ : ` · ${issue.affectedCount} ${issue.affectedCount === 1 ? 'person has' : 'people have'} said this is happening to them`;
1402
+ return `${state} · reported ${relativeDay(issue.since)}${others}`;
1403
+ }
1404
+
1405
+ known.addEventListener('click', async (e) => {
1406
+ const start = e.target.closest('[data-join]');
1407
+ const confirm = e.target.closest('[data-join-confirm]');
1408
+ if (!start && !confirm) return;
1409
+
1410
+ const bugId = (confirm || start).getAttribute(confirm ? 'data-join-confirm' : 'data-join');
1411
+ issueError = '';
1412
+
1413
+ // An address is what joining buys them — being told when it is fixed — so
1414
+ // ask for one when neither a sign-in nor identify() has supplied it, rather
1415
+ // than recording somebody nobody can ever write to.
1416
+ const email = known.querySelector('.join-email')?.value?.trim()
1417
+ || client.signedInAs()?.email
1418
+ || client.config.user?.email;
1419
+ if (!email) {
1420
+ askingFor = bugId;
1421
+ drawKnown();
1422
+ known.querySelector('.join-email')?.focus();
1423
+ return;
1424
+ }
1425
+
1426
+ try {
1427
+ const result = await client.meToo(bugId, { email });
1428
+ askingFor = null;
1429
+ issues = issues.map((issue) => (issue.id === bugId
1430
+ ? { ...issue, joined: true, affectedCount: result.affectedCount ?? issue.affectedCount }
1431
+ : issue));
1432
+ drawKnown();
1433
+ } catch (err) {
1434
+ issueError = err.message.replace('[BugCatch] ', '');
1435
+ drawKnown();
1436
+ }
1437
+ });
1438
+
1439
+ loadKnownIssues();
1440
+ titleInput.addEventListener('input', () => matchKnown(titleInput.value));
1441
+
1442
+ /* ---------- talk to us ---------- */
1443
+
1444
+ // Asked once, when the dialog opens, and the button stays hidden unless the
1445
+ // answer is yes. Doing it here rather than at init keeps one request off the
1446
+ // page load of every site this SDK is on, for a button most of them will
1447
+ // never show.
1448
+ // Which kind, not merely whether. A deployment offers the triage agent or a
1449
+ // person, never both, and the two are wholly different promises: one is a
1450
+ // machine that answers instantly, the other rings a room and may go
1451
+ // unanswered. The button says which, because a reporter told "talk to us" who
1452
+ // gets a robot — or who waits through twenty seconds of ringing expecting an
1453
+ // instant answer — has been misled by this widget rather than by the product.
1454
+ let callKind = null;
1455
+ function offerCall() {
1456
+ client.callKind()
1457
+ .then((kind) => {
1458
+ if (!kind) return;
1459
+ callKind = kind;
1460
+ callBtn.textContent = kind === 'live' ? 'Talk to the team' : 'Talk to us';
1461
+ // Never over a panel that is still asking who this is: the call would
1462
+ // file a report, and on a project that requires sign-in that is the one
1463
+ // thing this panel is holding back.
1464
+ callBtn.hidden = mustSignIn();
1465
+ })
1466
+ .catch(() => { /* not available is the safe answer to every failure */ });
1467
+ }
1468
+ offerCall();
1469
+
1470
+ /* ---------- who is reporting ---------- */
1471
+
1472
+ // Three states, and the project picks which: 'off' is every project that has
1473
+ // not asked for this and draws nothing at all; 'optional' offers a sign-in
1474
+ // above the form that the reporter may ignore; 'required' replaces the form
1475
+ // with it until they have.
1476
+ //
1477
+ // Asked on every open rather than remembered for the page, because the answer
1478
+ // includes whether this browser's session is still live — and it can end
1479
+ // somewhere else, when the same address signs in on another device. A widget
1480
+ // holding a cached "signed in as you" would find out at the moment the report
1481
+ // failed, which is the worst moment to discover it.
1482
+ let login = { mode: client.lastLoginMode || 'off', signedIn: null };
1483
+ // 'idle' — showing who they are; 'email' / 'code' — the two steps of getting there.
1484
+ let step = 'idle';
1485
+ let pendingEmail = client.config.user?.email || '';
1486
+ let pendingCode = '';
1487
+ let signInBusy = false;
1488
+ let signInError = '';
1489
+
1490
+ const mustSignIn = () => login.mode === 'required' && !login.signedIn;
1491
+
1492
+ /** Show or hide everything that only makes sense once we know who is filing. */
1493
+ function applyGate() {
1494
+ const shut = mustSignIn();
1495
+ compose.hidden = shut;
1496
+ sendBtn.hidden = shut;
1497
+ if (historyBtn) historyBtn.hidden = shut;
1498
+ // The call offer is the same errand as the form, so it goes with it — and
1499
+ // comes back only if the API had already said this project can take one.
1500
+ if (shut) callBtn.hidden = true;
1501
+ else if (callKind) callBtn.hidden = false;
1502
+ }
1503
+
1504
+ function drawWhoami() {
1505
+ if (login.mode === 'off') {
1506
+ whoami.hidden = true;
1507
+ whoami.innerHTML = '';
1508
+ return;
1509
+ }
1510
+ whoami.hidden = false;
1511
+ whoami.classList.toggle('asking', step !== 'idle');
1512
+
1513
+ const problem = signInError ? `<div class="after err">${escapeHtml(signInError)}</div>` : '';
1514
+
1515
+ if (step === 'idle') {
1516
+ if (login.signedIn) {
1517
+ whoami.innerHTML = `<span class="who">Signed in as <strong>${escapeHtml(login.signedIn.email)}</strong></span>`
1518
+ + '<button type="button" class="link" data-act="signout">Sign out</button>';
1519
+ return;
1520
+ }
1521
+ // An address the host application passed for its own signed-in user is
1522
+ // not nothing — the report will carry it — so saying "anonymously" over
1523
+ // the top of one would be untrue. It is simply not proved, which is what
1524
+ // the offer beside it is for.
1525
+ const claimed = client.config.user?.email;
1526
+ whoami.innerHTML = `<span class="who">${login.mode === 'required'
1527
+ ? 'Sign in to report a bug here.'
1528
+ : claimed
1529
+ ? `Reporting as <strong>${escapeHtml(claimed)}</strong>.`
1530
+ : 'Reporting anonymously — sign in and we can tell you what happens next.'}</span>`
1531
+ + '<button type="button" class="link" data-act="signin">Sign in</button>';
1532
+ return;
1533
+ }
1534
+
1535
+ if (step === 'email') {
1536
+ whoami.innerHTML = `
1537
+ <p class="why">Sign in with any email address — we'll send you a code.
1538
+ It's how you hear back about this report${login.mode === 'required' ? ', and this project asks for it' : ''}.</p>
1539
+ <div class="row">
1540
+ <input type="email" data-fld="email" value="${escapeHtml(pendingEmail)}"
1541
+ placeholder="you@example.com" autocomplete="email" spellcheck="false"
1542
+ aria-label="Your email address" ${signInBusy ? 'disabled' : ''} />
1543
+ <button type="button" class="tool" data-act="code" ${signInBusy ? 'disabled' : ''}>
1544
+ ${signInBusy ? 'Sending…' : 'Send code'}
1545
+ </button>
1546
+ </div>
1547
+ ${problem}
1548
+ ${login.mode === 'optional'
1549
+ ? '<div class="after"><button type="button" class="link" data-act="skip">Not now</button></div>'
1550
+ : ''}`;
1551
+ return;
1552
+ }
1553
+
1554
+ whoami.innerHTML = `
1555
+ <p class="why">We sent a code to <strong>${escapeHtml(pendingEmail)}</strong>. It expires in ten minutes.</p>
1556
+ <div class="row">
1557
+ <input type="text" class="code" data-fld="code" value="${escapeHtml(pendingCode)}"
1558
+ placeholder="123456" inputmode="numeric" autocomplete="one-time-code"
1559
+ maxlength="8" aria-label="The code we emailed you" ${signInBusy ? 'disabled' : ''} />
1560
+ <button type="button" class="tool" data-act="confirm" ${signInBusy ? 'disabled' : ''}>
1561
+ ${signInBusy ? 'Checking…' : 'Sign in'}
1562
+ </button>
1563
+ </div>
1564
+ ${problem}
1565
+ <div class="after">
1566
+ <button type="button" class="link" data-act="resend" ${signInBusy ? 'disabled' : ''}>Send it again</button>
1567
+ <button type="button" class="link" data-act="signin-back" ${signInBusy ? 'disabled' : ''}>Use another address</button>
1568
+ </div>`;
1569
+ }
1570
+
1571
+ /** Redraw, and put the cursor where the reporter is being asked to type. */
1572
+ function drawSignIn({ focus = false } = {}) {
1573
+ drawWhoami();
1574
+ applyGate();
1575
+ if (!focus || signInBusy) return;
1576
+ const field = whoami.querySelector('[data-fld]');
1577
+ if (field) { field.focus(); field.setSelectionRange?.(field.value.length, field.value.length); }
1578
+ }
1579
+
1580
+ // Typed values survive a redraw because they are held here, not read back off
1581
+ // a node that the next render replaces.
1582
+ whoami.addEventListener('input', (e) => {
1583
+ const field = e.target.closest('[data-fld]');
1584
+ if (!field) return;
1585
+ if (field.dataset.fld === 'email') pendingEmail = field.value;
1586
+ else pendingCode = field.value;
1587
+ });
1588
+
1589
+ whoami.addEventListener('keydown', (e) => {
1590
+ if (e.key !== 'Enter' || !e.target.closest('[data-fld]')) return;
1591
+ e.preventDefault();
1592
+ if (step === 'email') requestCode();
1593
+ else confirmCode();
1594
+ });
1595
+
1596
+ async function requestCode({ resent = false } = {}) {
1597
+ const email = pendingEmail.trim();
1598
+ if (!email) { signInError = 'Enter the address to send the code to.'; drawSignIn({ focus: true }); return; }
1599
+
1600
+ signInBusy = true; signInError = ''; drawSignIn();
1601
+ try {
1602
+ await client.signIn(email);
1603
+ pendingEmail = email;
1604
+ if (!resent) pendingCode = '';
1605
+ step = 'code';
1606
+ } catch (err) {
1607
+ signInError = err.message;
1608
+ } finally {
1609
+ signInBusy = false;
1610
+ drawSignIn({ focus: true });
1611
+ }
1612
+ }
1613
+
1614
+ async function confirmCode() {
1615
+ const code = pendingCode.trim();
1616
+ if (!code) { signInError = 'Enter the code we emailed you.'; drawSignIn({ focus: true }); return; }
1617
+
1618
+ signInBusy = true; signInError = ''; drawSignIn();
1619
+ try {
1620
+ const who = await client.confirmSignIn(pendingEmail.trim(), code);
1621
+ login.signedIn = who;
1622
+ step = 'idle';
1623
+ pendingCode = '';
1624
+ say('');
1625
+ // Signing in is what makes a call possible on most projects — there is
1626
+ // now somebody to attribute it to — so the offer is asked for again
1627
+ // rather than staying hidden until the panel is reopened.
1628
+ offerCall();
1629
+ } catch (err) {
1630
+ signInError = err.message;
1631
+ } finally {
1632
+ signInBusy = false;
1633
+ drawSignIn({ focus: step !== 'idle' });
1634
+ }
1635
+ }
1636
+
1637
+ whoami.addEventListener('click', async (e) => {
1638
+ const btn = e.target.closest('button[data-act]');
1639
+ if (!btn) return;
1640
+ const act = btn.dataset.act;
1641
+
1642
+ if (act === 'signin') { step = 'email'; signInError = ''; drawSignIn({ focus: true }); return; }
1643
+ if (act === 'skip') { step = 'idle'; signInError = ''; drawSignIn(); return; }
1644
+ // Namespaced: the report screen has a back button of its own, and the two
1645
+ // must not be one selector away from each other.
1646
+ if (act === 'signin-back') { step = 'email'; signInError = ''; pendingCode = ''; drawSignIn({ focus: true }); return; }
1647
+ if (act === 'code') { requestCode(); return; }
1648
+ if (act === 'resend') { requestCode({ resent: true }); return; }
1649
+ if (act === 'confirm') { confirmCode(); return; }
1650
+ if (act === 'signout') {
1651
+ signInBusy = true; drawSignIn();
1652
+ try {
1653
+ await client.signOut();
1654
+ } finally {
1655
+ login.signedIn = null;
1656
+ signInBusy = false;
1657
+ // On a project that requires it, signing out puts the form away again
1658
+ // and asks — which is what applyGate does off the back of this.
1659
+ step = login.mode === 'required' ? 'email' : 'idle';
1660
+ drawSignIn({ focus: step !== 'idle' });
1661
+ }
1662
+ }
1663
+ });
1664
+
1665
+ /** Re-read the project's setting and this browser's session, and redraw. */
1666
+ async function refreshLogin({ announce = false } = {}) {
1667
+ const state = await client.loginState();
1668
+ login = { mode: state.mode, signedIn: state.signedIn };
1669
+ client.lastLoginMode = state.mode;
1670
+ // Nothing to type on a project that requires sign-in and has nobody in it.
1671
+ if (mustSignIn() && step === 'idle') step = 'email';
1672
+ if (login.signedIn) step = 'idle';
1673
+ if (announce && state.signedOutReason) say(state.signedOutReason, 'info');
1674
+ drawSignIn();
1675
+ }
1676
+
1677
+ refreshLogin({ announce: true }).catch(() => { /* a widget that can't ask stays open */ });
1678
+
1679
+ callBtn.addEventListener('click', async () => {
1680
+ sending = true;
1681
+ say('');
1682
+ refreshSendState();
1683
+ callBtn.disabled = true;
1684
+ callBtn.innerHTML = `<span class="spinner"></span>${
1685
+ callKind === 'live' ? 'Ringing…' : 'Connecting…'}`;
1686
+
1687
+ try {
1688
+ // No id: this files the report from what the page was doing and calls
1689
+ // about it, in one round trip.
1690
+ const handle = await client.startCall();
1691
+ showCall(handle);
1692
+ } catch (err) {
1693
+ sending = false;
1694
+ callBtn.disabled = false;
1695
+ callBtn.textContent = callKind === 'live' ? 'Talk to the team' : 'Talk to us';
1696
+ refreshSendState();
1697
+ // A report filed with no call is still a report. Saying "the call
1698
+ // failed" and nothing else would leave the reporter thinking their
1699
+ // context went nowhere, and pressing Send again would file it twice.
1700
+ if (err.filed) succeed(err.filed);
1701
+ else {
1702
+ say(err.message.replace('[BugCatch] ', ''));
1703
+ // The same follow-up the Send button does, because a call can fail for
1704
+ // the same reason a report can: this browser was signed out while the
1705
+ // panel sat open. Without it the message says sign in while the row
1706
+ // under it still names who signed in, which reads as a broken widget.
1707
+ if (login.mode !== 'off') refreshLogin().catch(() => {});
1708
+ }
1709
+ }
1710
+ });
1711
+
1712
+ /**
1713
+ * The call, once it is connected.
1714
+ *
1715
+ * Replaces the panel for the same reason the history view does: it is the
1716
+ * same dialog answering a different question, and the reporter got here from
1717
+ * one launcher. There is no way back to the form — the report has already
1718
+ * been filed, and a Cancel that implied otherwise would be a lie.
1719
+ */
1720
+ function showCall(handle) {
1721
+ const panel = scrim.querySelector('.panel');
1722
+ const ref = handle?.bugId ? String(handle.bugId).slice(0, 8) : null;
1723
+ // Read off the handle rather than off `callKind`: the handle is what actually
1724
+ // happened, and it is the only one of the two that cannot be stale.
1725
+ const live = Boolean(handle?.live);
1726
+
1727
+ panel.innerHTML = `
1728
+ <div class="call">
1729
+ <div class="call-head">
1730
+ <span class="call-dot" aria-hidden="true"></span>
1731
+ <strong class="call-state">${live ? 'Ringing…' : 'Connecting…'}</strong>
1732
+ </div>
1733
+ <p class="call-note">
1734
+ ${live
1735
+ ? `We're ringing the team now. Someone will pick up if they're free — your report${
1736
+ ref ? ` <code>${escapeHtml(ref)}</code>` : ''} is already filed either way, so nothing is lost if nobody answers.`
1737
+ : `You're talking to our automated assistant, not a person. What you say is
1738
+ added to your report${ref ? ` <code>${escapeHtml(ref)}</code>` : ''} for the team to read.`}
1739
+ </p>
1740
+ <div class="call-lines" role="log" aria-live="polite"></div>
1741
+ <div class="foot">
1742
+ <span class="spacer"></span>
1743
+ <button class="primary" type="button" data-act="hangup">${live ? 'Cancel' : 'Hang up'}</button>
1744
+ </div>
1745
+ </div>`;
1746
+
1747
+ const stateEl = panel.querySelector('.call-state');
1748
+ const linesEl = panel.querySelector('.call-lines');
1749
+ const dot = panel.querySelector('.call-dot');
1750
+
1751
+ // Two vocabularies, because the two calls are two different experiences. An
1752
+ // agent call is connected or it failed; a live one spends its first twenty
1753
+ // seconds in a state the agent call has no equivalent for — ringing — and can
1754
+ // end by nobody picking up, which is not a failure and must not read as one.
1755
+ const WORDING = live ? {
1756
+ ringing: 'Ringing the team…',
1757
+ connecting: 'Someone answered — connecting…',
1758
+ connected: 'Connected',
1759
+ missed: 'Nobody was free',
1760
+ ended: 'Call ended',
1761
+ failed: 'The call dropped',
1762
+ } : {
1763
+ connecting: 'Connecting…',
1764
+ listening: 'Listening',
1765
+ speaking: 'Assistant is speaking',
1766
+ ended: 'Call ended',
1767
+ failed: 'The call dropped',
1768
+ };
1769
+
1770
+ // `missed` is over too, and is the ordinary way a live call ends when the
1771
+ // team is busy. Left out of this list it would leave the dialog showing
1772
+ // "Nobody was free" above a Cancel button for a call that has already
1773
+ // stopped.
1774
+ const OVER = ['ended', 'failed', 'missed'];
1775
+
1776
+ const offState = client.on('call', ({ state }) => {
1777
+ stateEl.textContent = WORDING[state] || state;
1778
+ dot.classList.toggle('off', OVER.includes(state));
1779
+ if (OVER.includes(state)) {
1780
+ panel.querySelector('[data-act="hangup"]').textContent = 'Close';
1781
+ if (state === 'missed') {
1782
+ // Said plainly, and said as the non-event it is. The report is filed
1783
+ // and the thread already says the call went unanswered, so the one
1784
+ // thing left to tell them is that they have not lost anything.
1785
+ const note = panel.querySelector('.call-note');
1786
+ if (note) {
1787
+ note.textContent = 'Nobody was free to pick up just now. Your report is filed '
1788
+ + 'and the team will reply in the chat.';
1789
+ }
1790
+ }
1791
+ }
1792
+ });
1793
+
1794
+ // One element per turn, replaced while a turn is still being recognised —
1795
+ // appending every partial would scroll the reporter's own half-sentence
1796
+ // past them as they said it.
1797
+ let pending = null;
1798
+ const offLines = client.on('call-transcript', ({ role, text, final }) => {
1799
+ if (!text && !final) return;
1800
+ if (role === 'agent') {
1801
+ // The agent arrives as deltas; they belong on one line.
1802
+ if (!pending || pending.dataset.role !== 'agent') {
1803
+ pending = document.createElement('div');
1804
+ pending.className = 'call-line agent';
1805
+ pending.dataset.role = 'agent';
1806
+ linesEl.appendChild(pending);
1807
+ }
1808
+ if (text) pending.textContent += text;
1809
+ if (final) pending = null;
1810
+ } else {
1811
+ if (!pending || pending.dataset.role !== 'reporter') {
1812
+ pending = document.createElement('div');
1813
+ pending.className = 'call-line you';
1814
+ pending.dataset.role = 'reporter';
1815
+ linesEl.appendChild(pending);
1816
+ }
1817
+ pending.textContent = text;
1818
+ if (final) pending = null;
1819
+ }
1820
+ linesEl.scrollTop = linesEl.scrollHeight;
1821
+ });
1822
+
1823
+ panel.querySelector('[data-act="hangup"]').addEventListener('click', () => {
1824
+ offState();
1825
+ offLines();
1826
+ client.endCall();
1827
+ close();
1828
+ });
1829
+ }
1830
+
1831
+ /**
1832
+ * The confirmation, and the one answer somebody actually wants from it.
1833
+ *
1834
+ * "How long will this take" is the first thing anybody thinks after reporting
1835
+ * a bug, and until now the answer was nothing at all — so the honest reading
1836
+ * of a confirmation screen was "into the void". The API computes the figure
1837
+ * on the same request that files the report, precisely so it can be said
1838
+ * here: an estimate that needed a second call would arrive after this panel
1839
+ * had closed itself.
1840
+ *
1841
+ * Absent whenever the project has decided not to tell reporters, or has
1842
+ * nothing to base a figure on. Nothing is drawn in that case — no "unknown",
1843
+ * no placeholder — because a confirmation that admits it cannot answer is
1844
+ * worse than one that does not raise the question.
1845
+ *
1846
+ * The dwell is longer when there is something to read. Two seconds is right
1847
+ * for one line and is not enough for two, and a panel that closes over the
1848
+ * sentence somebody started reading is the reason they open it again.
1849
+ */
1850
+ function succeed(res) {
1851
+ const ref = res?.id ? String(res.id).slice(0, 8) : null;
1852
+ const eta = res?.estimate?.label ? String(res.estimate.label) : null;
1853
+ scrim.querySelector('.panel').innerHTML = `
1854
+ <div class="done">
1855
+ <div class="tick">${ICONS.check}</div>
1856
+ <h3>Thanks — that's on its way.</h3>
1857
+ <p>${ref ? `Filed as <code>${escapeHtml(ref)}</code>.` : 'The team has what they need to reproduce it.'}</p>
1858
+ ${eta ? `<p class="eta">Reports like this usually take <strong>${escapeHtml(eta)}</strong>. We'll let you know when it moves.</p>` : ''}
1859
+ </div>`;
1860
+ setTimeout(close, eta ? 4000 : 2000);
1861
+ }
1862
+
1863
+ /**
1864
+ * The reporter's own history: what they have filed and where each one got to.
1865
+ *
1866
+ * Replaces the panel rather than opening a second surface — it is the same
1867
+ * dialog answering a different question, and the reporter came here from one
1868
+ * launcher.
1869
+ */
1870
+ async function showHistory() {
1871
+ const panel = scrim.querySelector('.panel');
1872
+ panel.innerHTML = `
1873
+ <div class="head">
1874
+ <div class="mark">${ICONS.bug}</div>
1875
+ <div class="titles">
1876
+ <h2>Your reports</h2>
1877
+ <p>Everything you've reported, and where each one got to.</p>
1878
+ </div>
1879
+ <button class="icon-btn" type="button" data-act="close" aria-label="Close">${ICONS.close}</button>
1880
+ </div>
1881
+ <div class="history"><div class="empty">Loading…</div></div>`;
1882
+
1883
+ panel.querySelector('[data-act="close"]').addEventListener('click', close);
1884
+
1885
+ let reports = [];
1886
+ try {
1887
+ ({ reports } = await client.myReports());
1888
+ } catch {
1889
+ panel.querySelector('.history').innerHTML =
1890
+ '<div class="empty">Couldn\'t load your reports just now.</div>';
1891
+ return;
1892
+ }
1893
+
1894
+ // The panel can be closed while the request is in flight.
1895
+ const list = panel.querySelector('.history');
1896
+ if (!list) return;
1897
+
1898
+ list.innerHTML = reports.length
1899
+ ? reports.map((r) => `
1900
+ <button class="row" type="button" data-report="${escapeHtml(r.id)}">
1901
+ <span class="who">
1902
+ <span class="ttl">${escapeHtml(r.title)}</span>
1903
+ <span class="sub">${escapeHtml(r.reference)} · ${escapeHtml(relativeDay(r.createdAt))}</span>
1904
+ </span>
1905
+ <span class="pill ${escapeHtml(String(r.status).toLowerCase())}">${escapeHtml(statusWord(r.status))}</span>
1906
+ </button>`).join('')
1907
+ : '<div class="empty">You haven\'t reported anything yet.</div>';
1908
+
1909
+ for (const row of list.querySelectorAll('[data-report]')) {
1910
+ row.addEventListener('click', () => showReport(row.dataset.report));
1911
+ }
1912
+ }
1913
+
1914
+ /**
1915
+ * One report as a conversation.
1916
+ *
1917
+ * This screen used to be a timeline: everything that had happened, read-only,
1918
+ * newest first. It answered "what happened to my report" and nothing else —
1919
+ * so when the team's reply was a question ("which browser?"), the person best
1920
+ * placed to answer it was looking straight at it with no way to.
1921
+ *
1922
+ * Now it is a thread. Status changes still appear, as the quiet centred lines
1923
+ * that mark events in any chat; the messages between the two sides are
1924
+ * bubbles either side of them; and there is a box at the bottom. Oldest at
1925
+ * the top, because that is the direction a conversation is read.
1926
+ */
1927
+ async function showReport(bugId) {
1928
+ const panel = scrim.querySelector('.panel');
1929
+ panel.innerHTML = `
1930
+ <div class="head">
1931
+ <button class="icon-btn back" type="button" data-act="back" aria-label="Back to your reports">${ICONS.back}</button>
1932
+ <div class="titles">
1933
+ <h2>Your report</h2>
1934
+ <p>Everything that has happened, and a way to answer.</p>
1935
+ </div>
1936
+ <button class="icon-btn" type="button" data-act="close" aria-label="Close">${ICONS.close}</button>
1937
+ </div>
1938
+ <div class="history"><div class="empty">Loading…</div></div>`;
1939
+
1940
+ panel.querySelector('[data-act="close"]').addEventListener('click', close);
1941
+ panel.querySelector('[data-act="back"]').addEventListener('click', showHistory);
1942
+
1943
+ let report = null;
1944
+ try {
1945
+ report = await client.myReport(bugId);
1946
+ } catch {
1947
+ report = null;
1948
+ }
1949
+
1950
+ const body = panel.querySelector('.history');
1951
+ // The panel can be closed, or navigated away from, while this is in flight.
1952
+ if (!body || !panel.querySelector('[data-act="back"]')) return;
1953
+
1954
+ if (!report) {
1955
+ body.innerHTML = '<div class="empty">Couldn\'t load that report just now.</div>';
1956
+ return;
1957
+ }
1958
+
1959
+ openReportId = bugId;
1960
+ // Reading the report is reading its updates. Leaving a "your report was
1961
+ // fixed" card up over the screen where they are reading that it was fixed
1962
+ // would be the notification arguing with itself.
1963
+ client.markReportRead(bugId);
1964
+ body.classList.add('chatting');
1965
+ body.innerHTML = `
1966
+ <div class="report-head">
1967
+ <span class="ttl">${escapeHtml(report.title)}</span>
1968
+ <span class="sub">${escapeHtml(report.reference)} · ${escapeHtml(report.project || '')}</span>
1969
+ <span class="pill ${escapeHtml(String(report.status).toLowerCase())}">${escapeHtml(statusWord(report.status))}</span>
1970
+ ${report.estimate?.label
1971
+ // The same figure they were given when they filed it, so coming back
1972
+ // to check is not a worse experience than not checking. Absent on a
1973
+ // report that has already been settled — the API stops sending it —
1974
+ // because a prediction beside a finished report is either redundant
1975
+ // or an argument.
1976
+ ? `<span class="eta-line">Usually takes ${escapeHtml(String(report.estimate.label))}</span>`
1977
+ : ''}
1978
+ </div>
1979
+ <div class="thread">${threadHtml(report.timeline)}</div>`;
1980
+
1981
+ const thread = body.querySelector('.thread');
1982
+ scrollToLatest(thread);
1983
+ if (report.canReply) mountComposer(body, thread, bugId);
1984
+ }
1985
+
1986
+ /**
1987
+ * Redraw the open thread in place, keeping whatever is half-typed.
1988
+ *
1989
+ * Rebuilding the whole screen on every incoming message would be simpler and
1990
+ * would also throw away a draft, a staged file and the scroll position — for
1991
+ * someone mid-sentence, a reply arriving would look like the widget crashed.
1992
+ */
1993
+ async function refreshThread(bugId) {
1994
+ const body = scrim.querySelector('.history.chatting');
1995
+ const thread = body?.querySelector('.thread');
1996
+ if (!thread || openReportId !== bugId) return;
1997
+
1998
+ let report = null;
1999
+ try {
2000
+ report = await client.myReport(bugId);
2001
+ } catch { /* a failed refresh leaves what is on screen, which is not wrong */ }
2002
+ if (!report || openReportId !== bugId) return;
2003
+
2004
+ const pinned = atLatest(thread);
2005
+ thread.innerHTML = threadHtml(report.timeline);
2006
+ // Only follow the conversation down if they were already at the bottom.
2007
+ // Yanking someone back from something they scrolled up to read is worse
2008
+ // than making them scroll.
2009
+ if (pinned) scrollToLatest(thread);
2010
+
2011
+ const pill = body.querySelector('.report-head .pill');
2012
+ if (pill) {
2013
+ pill.className = `pill ${String(report.status).toLowerCase()}`;
2014
+ pill.textContent = statusWord(report.status);
2015
+ }
2016
+ }
2017
+
2018
+ /**
2019
+ * The box at the bottom: text, files, and a voice note.
2020
+ *
2021
+ * Files are uploaded as they are picked, not on send, so the wait happens
2022
+ * while the person is still writing rather than after they have pressed the
2023
+ * button — and so a failed upload is visible as a failed chip instead of a
2024
+ * message that never went.
2025
+ */
2026
+ function mountComposer(body, thread, bugId) {
2027
+ const composer = document.createElement('div');
2028
+ composer.className = 'composer';
2029
+ composer.innerHTML = `
2030
+ <div class="staged" hidden></div>
2031
+ <div class="row">
2032
+ <div class="tools">
2033
+ <button class="tool" type="button" data-act="attach" title="Attach a file" aria-label="Attach a file">${ICONS.paperclip}</button>
2034
+ <button class="tool" type="button" data-act="voice" title="Record a voice note" aria-label="Record a voice note">${ICONS.mic}</button>
2035
+ <button class="tool" type="button" data-act="livecall" title="Call the team" aria-label="Call the team" hidden>${ICONS.phone}</button>
2036
+ </div>
2037
+ <textarea rows="1" placeholder="Write a reply…" aria-label="Write a reply"></textarea>
2038
+ <button class="send" type="button" data-act="send" title="Send" aria-label="Send" disabled>${ICONS.send}</button>
2039
+ </div>
2040
+ <div class="note" hidden></div>
2041
+ <input type="file" hidden multiple accept="${UPLOAD_MIMES.join(',')}">`;
2042
+ body.appendChild(composer);
2043
+
2044
+ const staged = composer.querySelector('.staged');
2045
+ const input = composer.querySelector('textarea');
2046
+ const sendBtn = composer.querySelector('[data-act="send"]');
2047
+ const voiceBtn = composer.querySelector('[data-act="voice"]');
2048
+ const liveBtn = composer.querySelector('[data-act="livecall"]');
2049
+ const picker = composer.querySelector('input[type=file]');
2050
+ const note = composer.querySelector('.note');
2051
+
2052
+ // Uploaded files waiting to ride on the next message.
2053
+ const files = [];
2054
+ let recorder = null;
2055
+ let sending = false;
2056
+
2057
+ function say(text, kind = '') {
2058
+ note.hidden = !text;
2059
+ note.className = `note ${kind}`;
2060
+ note.textContent = text || '';
2061
+ }
2062
+
2063
+ function refresh() {
2064
+ const ready = files.filter((f) => f.storageKey);
2065
+ sendBtn.disabled = sending || (!input.value.trim() && !ready.length);
2066
+ staged.hidden = !files.length;
2067
+ staged.innerHTML = files.map((f, i) => `
2068
+ <span class="chip ${f.storageKey ? '' : 'uploading'}">
2069
+ <span class="nm">${escapeHtml(f.name)}</span>
2070
+ <span class="sz">${f.storageKey ? humanSize(f.size) : 'sending…'}</span>
2071
+ <button type="button" data-drop="${i}" aria-label="Remove ${escapeHtml(f.name)}">${ICONS.close}</button>
2072
+ </span>`).join('');
2073
+ for (const btn of staged.querySelectorAll('[data-drop]')) {
2074
+ btn.addEventListener('click', () => {
2075
+ files.splice(Number(btn.dataset.drop), 1);
2076
+ refresh();
2077
+ });
2078
+ }
2079
+ }
2080
+
2081
+ /** Upload one blob and stage it. Its chip is on screen for the whole trip. */
2082
+ async function stage(blob, mime, name) {
2083
+ const entry = { name, size: blob.size, storageKey: null };
2084
+ files.push(entry);
2085
+ refresh();
2086
+ try {
2087
+ const { storageKey, mime: stored } = await client.uploadAttachment(blob, mime);
2088
+ entry.storageKey = storageKey;
2089
+ entry.mime = stored;
2090
+ } catch (err) {
2091
+ files.splice(files.indexOf(entry), 1);
2092
+ say(err.message || 'That file could not be sent.', 'err');
2093
+ }
2094
+ refresh();
2095
+ }
2096
+
2097
+ input.addEventListener('input', () => {
2098
+ // Grow with the text, up to the CSS cap. A reply about a bug is often
2099
+ // three lines, and a one-line box makes people write one line.
2100
+ input.style.height = 'auto';
2101
+ input.style.height = `${Math.min(input.scrollHeight, 120)}px`;
2102
+ refresh();
2103
+ });
2104
+
2105
+ // Enter sends, Shift+Enter breaks the line — the chat convention. Anyone
2106
+ // who wants a paragraph gets it; nobody has to hunt for the button.
2107
+ input.addEventListener('keydown', (e) => {
2108
+ if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
2109
+ e.preventDefault();
2110
+ send();
2111
+ }
2112
+ });
2113
+
2114
+ composer.querySelector('[data-act="attach"]').addEventListener('click', () => picker.click());
2115
+ picker.addEventListener('change', () => {
2116
+ say('');
2117
+ for (const file of picker.files) {
2118
+ if (file.size > client.config.maxAttachmentBytes) {
2119
+ say(`${file.name} is larger than ${humanSize(client.config.maxAttachmentBytes)}.`, 'err');
2120
+ continue;
2121
+ }
2122
+ stage(file, file.type, file.name);
2123
+ }
2124
+ picker.value = '';
2125
+ });
2126
+
2127
+ if (!voiceSupported()) {
2128
+ voiceBtn.disabled = true;
2129
+ voiceBtn.title = 'This browser cannot record audio';
2130
+ }
2131
+
2132
+ voiceBtn.addEventListener('click', async () => {
2133
+ if (recorder) {
2134
+ const handle = recorder;
2135
+ recorder = null;
2136
+ voiceBtn.classList.remove('recording');
2137
+ voiceBtn.innerHTML = ICONS.mic;
2138
+ say('');
2139
+ const { blob, mime } = await handle.stop();
2140
+ if (blob.size) stage(blob, mime, 'Voice note');
2141
+ return;
2142
+ }
2143
+ try {
2144
+ say('');
2145
+ recorder = await startVoiceNote({
2146
+ maxMs: client.config.maxRecordingMs,
2147
+ onTick: (s) => say(`Recording — ${humanDuration(s)}. Tap the mic to stop.`),
2148
+ onAutoStop: () => voiceBtn.click(),
2149
+ });
2150
+ voiceBtn.classList.add('recording');
2151
+ voiceBtn.innerHTML = ICONS.stopSquare;
2152
+ } catch {
2153
+ recorder = null;
2154
+ say('The microphone is not available — check the browser permission.', 'err');
2155
+ }
2156
+ });
2157
+
2158
+ /* ---------- calling the team ---------- */
2159
+
2160
+ // Asked once, when a report is opened, and the button stays hidden unless
2161
+ // the answer is yes. Most workspaces have live calling off, and a phone
2162
+ // icon that apologises on press is worse than no phone icon.
2163
+ client.canCallTeam(bugId)
2164
+ .then((available) => { if (available) liveBtn.hidden = false; })
2165
+ .catch(() => { /* not available is the safe answer to every failure */ });
2166
+
2167
+ liveBtn.addEventListener('click', async () => {
2168
+ liveBtn.disabled = true;
2169
+ say('');
2170
+ try {
2171
+ const handle = await client.startLiveCall(bugId);
2172
+ showLiveCall(body, thread, handle);
2173
+ } catch (err) {
2174
+ say(err.message.replace('[BugCatch] ', ''), 'err');
2175
+ } finally {
2176
+ liveBtn.disabled = false;
2177
+ }
2178
+ });
2179
+
2180
+ sendBtn.addEventListener('click', send);
2181
+
2182
+ async function send() {
2183
+ const text = input.value.trim();
2184
+ const ready = files.filter((f) => f.storageKey);
2185
+ if (sending || (!text && !ready.length)) return;
2186
+
2187
+ sending = true;
2188
+ refresh();
2189
+ say('');
2190
+
2191
+ // Shown immediately, greyed, and replaced by the refresh below. Waiting
2192
+ // for the round trip before anything appears makes a slow network look
2193
+ // like a dropped message, which is when people send it twice.
2194
+ thread.insertAdjacentHTML('beforeend', messageHtml({
2195
+ from: 'reporter',
2196
+ body: text,
2197
+ at: new Date().toISOString(),
2198
+ attachments: ready.map((f) => ({ type: 'FILE', name: f.name, size: f.size })),
2199
+ }, 'pending'));
2200
+ scrollToLatest(thread);
2201
+
2202
+ try {
2203
+ await sendMessage({
2204
+ apiBase: client.config.apiBase,
2205
+ bugId,
2206
+ body: text,
2207
+ attachments: ready.map((f) => ({
2208
+ storageKey: f.storageKey, mime: f.mime, name: f.name, size: f.size,
2209
+ })),
2210
+ });
2211
+ input.value = '';
2212
+ input.style.height = 'auto';
2213
+ files.length = 0;
2214
+ await refreshThread(bugId);
2215
+ } catch (err) {
2216
+ thread.querySelector('.msg-row.pending')?.classList.replace('pending', 'failed');
2217
+ say(err.message || 'That could not be sent — try again.', 'err');
2218
+ } finally {
2219
+ sending = false;
2220
+ refresh();
2221
+ }
2222
+ }
2223
+
2224
+ refresh();
2225
+ }
2226
+
2227
+ /**
2228
+ * The call, on top of the report it is about.
2229
+ *
2230
+ * A bar rather than a screen of its own, which is the opposite of what the
2231
+ * agent call does — and for a reason that only applies to this kind. An agent
2232
+ * call has a transcript arriving line by line, which is worth a whole panel;
2233
+ * this one has nothing to show, because nobody is listening to it but the
2234
+ * person on the other end. What is worth looking at during a live call is the
2235
+ * report, which is what both people are talking about.
2236
+ */
2237
+ function showLiveCall(body, thread, handle) {
2238
+ const bar = document.createElement('div');
2239
+ bar.className = 'livecall';
2240
+ bar.innerHTML = `
2241
+ <span class="call-dot" aria-hidden="true"></span>
2242
+ <span class="who" role="status" aria-live="polite">Calling the team…</span>
2243
+ <span class="secs" hidden></span>
2244
+ <button class="act" type="button" data-act="mute" hidden>Mute</button>
2245
+ <button class="act danger" type="button" data-act="hangup">Cancel</button>`;
2246
+ body.insertBefore(bar, thread);
2247
+
2248
+ const who = bar.querySelector('.who');
2249
+ const secs = bar.querySelector('.secs');
2250
+ const dot = bar.querySelector('.call-dot');
2251
+ const muteBtn = bar.querySelector('[data-act="mute"]');
2252
+ const hangBtn = bar.querySelector('[data-act="hangup"]');
2253
+
2254
+ let ticker = null;
2255
+ const stopTicking = () => { if (ticker) { clearInterval(ticker); ticker = null; } };
2256
+
2257
+ /** Left up rather than removed, so somebody who looked away can see how it
2258
+ * ended — and dismissed by hand, or by the next call replacing it. */
2259
+ const settle = (text) => {
2260
+ stopTicking();
2261
+ dot.classList.add('off');
2262
+ bar.classList.add('over');
2263
+ who.textContent = text;
2264
+ secs.hidden = true;
2265
+ muteBtn.hidden = true;
2266
+ hangBtn.textContent = 'Dismiss';
2267
+ hangBtn.classList.remove('danger');
2268
+ };
2269
+
2270
+ const off = client.on('call', ({ state, live }) => {
2271
+ // The agent call emits on the same channel. Its own panel is listening.
2272
+ if (!live) return;
2273
+
2274
+ if (state === 'ringing') who.textContent = 'Calling the team…';
2275
+
2276
+ if (state === 'connecting') {
2277
+ who.textContent = 'Someone answered — connecting…';
2278
+ }
2279
+
2280
+ if (state === 'connected') {
2281
+ who.textContent = "You're on a call with the team";
2282
+ muteBtn.hidden = false;
2283
+ secs.hidden = false;
2284
+ const from = Date.now();
2285
+ stopTicking();
2286
+ // Started on connect rather than on answer: the seconds a person is
2287
+ // charged for looking at are the ones they could hear.
2288
+ ticker = setInterval(() => {
2289
+ secs.textContent = humanDuration(Math.floor((Date.now() - from) / 1000));
2290
+ }, 1000);
2291
+ secs.textContent = '0s';
2292
+ }
2293
+
2294
+ if (state === 'missed') {
2295
+ settle('Nobody was free. Send a message instead and the team will pick it up.');
2296
+ }
2297
+ if (state === 'ended') settle('Call ended.');
2298
+ if (state === 'failed') settle('The call dropped.');
2299
+ });
2300
+
2301
+ muteBtn.addEventListener('click', () => {
2302
+ const muted = handle.setMuted(!handle.isMuted());
2303
+ muteBtn.textContent = muted ? 'Unmute' : 'Mute';
2304
+ // Announced rather than only shown: somebody who muted by accident finds
2305
+ // out when the other person stops replying, which is a long way in.
2306
+ who.textContent = muted ? "You're muted" : "You're on a call with the team";
2307
+ });
2308
+
2309
+ hangBtn.addEventListener('click', () => {
2310
+ off();
2311
+ stopTicking();
2312
+ client.endCall();
2313
+ bar.remove();
2314
+ // The note about the call lands in the thread a moment after it ends, and
2315
+ // the reporter is looking straight at where it goes.
2316
+ refreshThread(openReportId);
2317
+ });
2318
+ }
2319
+
2320
+ // The report currently open, if any. A live update for a different report
2321
+ // must not redraw the one being read.
2322
+ let openReportId = null;
2323
+
2324
+ // Re-read the open thread when something happens to it. Without this the
2325
+ // reader is looking at a conversation that stopped being true while they read
2326
+ // it — the very screen where a stale view is most obvious, and now the screen
2327
+ // where the other side may be typing.
2328
+ stopListening = client.on('update', (event) => {
2329
+ if (!openReportId || event?.bugId !== openReportId) return;
2330
+ // Already on screen, so already read — and marking it here, before the
2331
+ // client draws its notifications, is what keeps a card from appearing over
2332
+ // the thread that is about to show the same thing.
2333
+ client.markReportRead(openReportId);
2334
+ refreshThread(openReportId);
2335
+ });
2336
+
2337
+ root.appendChild(scrim);
2338
+ refreshSendState();
2339
+ titleInput.focus();
2340
+
2341
+ return { destroy, close };
2342
+ }
2343
+
2344
+ /**
2345
+ * The whole thread, oldest first.
2346
+ *
2347
+ * The API hands back one ordered list holding both kinds of entry, which is
2348
+ * what keeps a reply sitting under the status change that prompted it. Entries
2349
+ * carrying `from` are messages; the rest are things that happened.
2350
+ */
2351
+ function threadHtml(timeline) {
2352
+ const entries = timeline || [];
2353
+ if (!entries.length) return '';
2354
+ return entries.map((e) => (e.from ? messageHtml({
2355
+ from: e.from,
2356
+ author: e.reply?.author,
2357
+ automated: e.reply?.automated,
2358
+ body: e.reply?.body,
2359
+ at: e.at,
2360
+ attachments: e.reply?.attachments,
2361
+ }) : systemHtml(e))).join('');
2362
+ }
2363
+
2364
+ /**
2365
+ * Something that happened, rather than something someone said.
2366
+ *
2367
+ * `summary` is written by the API, which knows what changed and phrases it for
2368
+ * the person who reported it — so the wording here matches the toast and the
2369
+ * portal exactly rather than being a third attempt at the same sentence.
2370
+ */
2371
+ function systemHtml(entry) {
2372
+ return `
2373
+ <div class="sys k-${escapeHtml(entry.kind)}">
2374
+ <span class="dot" aria-hidden="true"></span>${escapeHtml(entry.summary || '')}
2375
+ · ${escapeHtml(relativeDay(entry.at))}
2376
+ </div>`;
2377
+ }
2378
+
2379
+ /**
2380
+ * One bubble. `state` marks a message that is still in flight, or failed.
2381
+ *
2382
+ * A reply that was sent automatically says so, above the words rather than
2383
+ * after them. The person reading it is deciding whether their problem has been
2384
+ * understood, and that decision is different when nobody has read the report —
2385
+ * so it is not a footnote, and it is not left to be inferred from the tone.
2386
+ */
2387
+ function messageHtml(message, state = '') {
2388
+ const mine = message.from === 'reporter';
2389
+ const body = message.body || '';
2390
+ const attachments = message.attachments || [];
2391
+ const automated = !mine && Boolean(message.automated);
2392
+
2393
+ // The text goes in its own element rather than as a bare child of .bubble:
2394
+ // it is the only part that wants `white-space: pre-wrap`, and with that rule
2395
+ // on the bubble the template's own newlines and indentation render as a
2396
+ // blank line above and below every message.
2397
+ const text = body ? `<span class="msg-text">${escapeHtml(body)}</span>` : '';
2398
+ const files = attachments.length
2399
+ ? `<div class="att">${attachments.map(attachmentHtml).join('')}</div>`
2400
+ : '';
2401
+
2402
+ // Named, or labelled, never both and never neither: an automated message has
2403
+ // no author to name, and naming one would be the lie the label exists to
2404
+ // prevent.
2405
+ let who = '';
2406
+ if (automated) who = '<span class="msg-who auto">Automatic reply</span>';
2407
+ else if (!mine && message.author) who = `<span class="msg-who">${escapeHtml(message.author)}</span>`;
2408
+
2409
+ return `
2410
+ <div class="msg-row ${mine ? 'mine' : 'theirs'} ${automated ? 'auto' : ''} ${state}">
2411
+ ${who}
2412
+ <div class="bubble ${body ? '' : 'silent'}">${text}${files}</div>
2413
+ <span class="msg-when">${escapeHtml(relativeDay(message.at))}</span>
2414
+ </div>`;
2415
+ }
2416
+
2417
+ /**
2418
+ * One attachment, rendered as what it is.
2419
+ *
2420
+ * An image people can see, a voice note they can play and a document they can
2421
+ * download are three different things, and collapsing them all to a link is
2422
+ * how a chat stops feeling like one. A pending upload has no URL yet, so it
2423
+ * falls through to the file row with nothing to click.
2424
+ */
2425
+ function attachmentHtml(a) {
2426
+ const name = escapeHtml(a.name || 'attachment');
2427
+ if (!a.url) return `<span class="file"><span class="nm">${name}</span></span>`;
2428
+ const url = escapeHtml(a.url);
2429
+
2430
+ if (a.type === 'SCREENSHOT') {
2431
+ return `<a href="${url}" target="_blank" rel="noopener noreferrer"><img src="${url}" alt="${name}" loading="lazy"></a>`;
2432
+ }
2433
+ if (a.type === 'AUDIO') {
2434
+ return `<audio controls preload="metadata" src="${url}"></audio>`;
2435
+ }
2436
+ if (a.type === 'VIDEO') {
2437
+ return `<video controls preload="metadata" src="${url}"></video>`;
2438
+ }
2439
+ return `
2440
+ <a class="file" href="${url}" target="_blank" rel="noopener noreferrer" download>
2441
+ <span class="nm">${name}</span>
2442
+ ${a.size ? `<span class="sz">${escapeHtml(humanSize(a.size))}</span>` : ''}
2443
+ </a>`;
2444
+ }
2445
+
2446
+ /** Whether the reader is at the foot of the thread, within a line or two. */
2447
+ function atLatest(thread) {
2448
+ return thread.scrollHeight - thread.scrollTop - thread.clientHeight < 40;
2449
+ }
2450
+
2451
+ function scrollToLatest(thread) {
2452
+ thread.scrollTop = thread.scrollHeight;
2453
+ }
2454
+
2455
+ /** Status as a word a reporter would use, not the enum the database stores. */
2456
+ function statusWord(status) {
2457
+ return {
2458
+ OPEN: 'Open',
2459
+ IN_PROGRESS: 'In progress',
2460
+ RESOLVED: 'Fixed',
2461
+ CLOSED: 'Closed',
2462
+ }[status] || String(status || '').toLowerCase();
2463
+ }
2464
+
2465
+ /** "today" / "3 days ago" — enough for a history list, no library. */
2466
+ function relativeDay(iso) {
2467
+ const then = new Date(iso).getTime();
2468
+ if (!Number.isFinite(then)) return '';
2469
+ const days = Math.floor((Date.now() - then) / 86400000);
2470
+ if (days <= 0) return 'today';
2471
+ if (days === 1) return 'yesterday';
2472
+ if (days < 30) return `${days} days ago`;
2473
+ const months = Math.floor(days / 30);
2474
+ return months === 1 ? 'a month ago' : `${months} months ago`;
2475
+ }
2476
+
2477
+ function escapeHtml(s) {
2478
+ return String(s).replace(/[&<>"']/g, (c) => (
2479
+ { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
2480
+ ));
2481
+ }