@sphoro/bugcatch-web-sdk 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +92 -5
  2. package/dist/bugcatch-call.es.js +586 -0
  3. package/dist/bugcatch-call.es.js.map +1 -0
  4. package/dist/bugcatch-call.umd.js +2 -0
  5. package/dist/bugcatch-call.umd.js.map +1 -0
  6. package/dist/bugcatch-core.es.js +3214 -0
  7. package/dist/bugcatch-core.es.js.map +1 -0
  8. package/dist/bugcatch-panel.es.js +2402 -0
  9. package/dist/bugcatch-panel.es.js.map +1 -0
  10. package/dist/bugcatch-panel.umd.js +1240 -0
  11. package/dist/bugcatch-panel.umd.js.map +1 -0
  12. package/dist/bugcatch.es.js +2 -5522
  13. package/dist/bugcatch.es.js.map +1 -1
  14. package/dist/bugcatch.standalone.umd.js +1536 -0
  15. package/dist/bugcatch.standalone.umd.js.map +1 -0
  16. package/dist/bugcatch.umd.js +89 -1183
  17. package/dist/bugcatch.umd.js.map +1 -1
  18. package/package.json +6 -6
  19. package/src/agent-issues.js +30 -0
  20. package/src/announcements.js +147 -0
  21. package/src/busy.js +18 -0
  22. package/src/call.js +7 -0
  23. package/src/capture.js +9 -0
  24. package/src/chunks.js +56 -0
  25. package/src/gesture.js +33 -0
  26. package/src/icons.js +18 -0
  27. package/src/identity.js +14 -9
  28. package/src/incoming-call.js +8 -30
  29. package/src/index.d.ts +50 -0
  30. package/src/index.js +170 -37
  31. package/src/lazy.js +23 -0
  32. package/src/notice.js +68 -0
  33. package/src/panel/call.js +242 -0
  34. package/src/panel/compose.js +416 -0
  35. package/src/panel/composer.js +235 -0
  36. package/src/panel/icons.js +34 -0
  37. package/src/panel/index.js +251 -0
  38. package/src/panel/known.js +130 -0
  39. package/src/panel/messages.js +176 -0
  40. package/src/panel/notices.js +34 -0
  41. package/src/panel/picks.js +61 -0
  42. package/src/panel/refs.js +321 -0
  43. package/src/panel/render.js +124 -0
  44. package/src/panel/signin.js +228 -0
  45. package/src/panel/styles.js +960 -0
  46. package/src/panel/thread.js +384 -0
  47. package/src/session.js +8 -3
  48. package/src/umd/call.js +6 -0
  49. package/src/umd/lazy.js +98 -0
  50. package/src/umd/panel.js +6 -0
  51. package/src/updates.js +20 -7
  52. package/src/widget.js +243 -3164
package/src/widget.js CHANGED
@@ -1,20 +1,20 @@
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).
1
+ // The floating "Report a bug" button. Rendered in a shadow root so the host
2
+ // page's CSS can't leak in and break it (or vice versa).
3
+ //
4
+ // Only the button is in the script every page loads: the dock, its theme
5
+ // switch and the launcher. The panel the launcher opens is most of the widget's
6
+ // code and nearly all of its CSS, most visitors never open it, and so it is
7
+ // fetched the first time it is wanted — or a little before, once the page has
8
+ // gone quiet or a pointer reaches the button (panel/, and lazy.js for how).
3
9
  //
4
10
  // Everything the panel needs is declared here as custom properties, which is
5
11
  // 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.
12
+ // carries the accent colour and the light/dark choice, and the rules below —
13
+ // and the panel's own, which arrive with it — only ever read variables.
8
14
 
9
- import {
10
- captureScreenshot, startRecording, screenshotSupported, captureSupported,
11
- startVoiceNote, voiceSupported,
12
- humanSize, humanDuration, normalizeMime, UPLOAD_MIMES, REPORT_MIMES,
13
- } from './capture.js';
14
- import { draftKey, draftOperation, readDraft } from './drafts.js';
15
- import { readSession } from './session.js';
16
- import { sendMessage } from './updates.js';
17
- import { readReferences, validateReference } from './references.js';
15
+ import * as ICONS from './icons.js';
16
+ import { loadPanel, loadCall } from './lazy.js';
17
+ import { noticeNode } from './notice.js';
18
18
 
19
19
  const POSITIONS = {
20
20
  'bottom-right': 'bottom:20px;right:20px;',
@@ -23,56 +23,6 @@ const POSITIONS = {
23
23
  'top-left': 'top:20px;left:20px;',
24
24
  };
25
25
 
26
- const PRIORITIES = [
27
- { value: 'LOW', label: 'Low' },
28
- { value: 'MEDIUM', label: 'Medium' },
29
- { value: 'HIGH', label: 'High' },
30
- { value: 'CRITICAL', label: 'Critical' },
31
- ];
32
-
33
- const ICONS = {
34
- 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>',
35
- 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>',
36
- 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>',
37
- 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>',
38
- 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>',
39
- 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>',
40
- stopSquare: '<svg viewBox="0 0 24 24" fill="currentColor"><rect x="7" y="7" width="10" height="10" rx="2"/></svg>',
41
- 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>',
42
- 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>',
43
- 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>',
44
- 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>',
45
- 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>',
46
- 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>',
47
- 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>',
48
- 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>',
49
- 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>',
50
- // The assistant's mark: a four-point spark, drawn wherever it speaks.
51
- sparkle: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.5c.5 4.6 2.9 7 7.5 7.5v.1c-4.6.5-7 2.9-7.5 7.5h-.1c-.5-4.6-2.9-7-7.5-7.5V10c4.6-.5 7-2.9 7.5-7.5z"/><path d="M18.6 15.2c.2 1.9 1.2 2.9 3.1 3.1v.1c-1.9.2-2.9 1.2-3.1 3.1h-.1c-.2-1.9-1.2-2.9-3.1-3.1v-.1c1.9-.2 2.9-1.2 3.1-3.1z" opacity=".7"/></svg>',
52
- chat: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M4 11.5a7.5 7 0 0 1 7.5-7h1a7.5 7 0 0 1 0 14H9l-4.2 2.6a.5.5 0 0 1-.76-.5l.5-3.1A7 7 0 0 1 4 11.5z"/><path d="M8.5 11.5h.01M12 11.5h.01M15.5 11.5h.01" stroke-width="2.6"/></svg>',
53
- chevronDown: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9.5l6 6 6-6"/></svg>',
54
- chevronRight: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.5 6l6 6-6 6"/></svg>',
55
- alert: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5.2M12 16.3h.01"/></svg>',
56
- inbox: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 13.5l2.6-7.2A1.5 1.5 0 0 1 7.5 5.3h9a1.5 1.5 0 0 1 1.4 1L20.5 13.5V18a1.5 1.5 0 0 1-1.5 1.5H5A1.5 1.5 0 0 1 3.5 18z"/><path d="M3.5 13.5h4.8l1.2 2.2h5l1.2-2.2h4.8"/></svg>',
57
- };
58
-
59
- /* The assistant's avatar, wherever it appears. */
60
- const AVATAR = `<span class="avatar" aria-hidden="true">${ICONS.sparkle}</span>`;
61
-
62
- /* The sphoro lockup, as outlines rather than text.
63
- *
64
- * This SDK runs inside other people's pages, where Space Grotesk is not loaded
65
- * and cannot be assumed. Live <text> would render the mark in whatever the host
66
- * site happens to use — a different typeface on every customer. Outlines are
67
- * self-contained, so the mark is identical everywhere, at the cost of the path
68
- * data below.
69
- *
70
- * The BUGCATCH half reads --bc-brand-mark, not a bare --accent. Custom properties
71
- * are exempt from `all: initial`, so they cross the shadow boundary: a host page
72
- * that happens to define --accent would have repainted our mark in their brand
73
- * colour. The --bc- prefix is ours and no host is going to set it by accident. */
74
- 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>';
75
-
76
26
  // 'auto' is the default and means "follow the visitor's system setting".
77
27
  const THEMES = [
78
28
  { value: 'light', label: 'Light', icon: ICONS.sun },
@@ -100,6 +50,8 @@ function writeStoredTheme(value) {
100
50
  } catch { /* storage denied — the choice just won't outlive the page */ }
101
51
  }
102
52
 
53
+ // The launcher's stylesheet: the theme, the base rules, the dock. The panel's
54
+ // arrives with the panel (panel/styles.js) and is added after this one.
103
55
  const css = (accent) => `
104
56
  :host { all: initial; }
105
57
  :host {
@@ -115,6 +67,7 @@ const css = (accent) => `
115
67
  --bc-raised: #fff;
116
68
  --bc-danger: #dc2626;
117
69
  --bc-ok: #059669;
70
+ --bc-warn: #b45309;
118
71
  /* The lockup's own two colours, kept off --bc-accent on purpose: that one is
119
72
  the customer's brand, and this mark is ours. Values are the light cut of
120
73
  brand/sphoro-bugcatch-primary.svg, pulled up from near-black so the mark
@@ -150,6 +103,7 @@ const css = (accent) => `
150
103
  --bc-border: #2a2e38;
151
104
  --bc-subtle: #1f222a;
152
105
  --bc-raised: #363d4b;
106
+ --bc-warn: #f59e0b;
153
107
  --bc-brand-ink: #9aa1ad;
154
108
  --bc-brand-mark: #9b8fff;
155
109
  --bc-shadow: 0 24px 64px rgba(0,0,0,.6), 0 4px 14px rgba(0,0,0,.4);
@@ -166,6 +120,7 @@ const css = (accent) => `
166
120
  --bc-border: #2a2e38;
167
121
  --bc-subtle: #1f222a;
168
122
  --bc-raised: #363d4b;
123
+ --bc-warn: #f59e0b;
169
124
  --bc-brand-ink: #9aa1ad;
170
125
  --bc-brand-mark: #9b8fff;
171
126
  --bc-shadow: 0 24px 64px rgba(0,0,0,.6), 0 4px 14px rgba(0,0,0,.4);
@@ -202,21 +157,8 @@ button:focus-visible, a:focus-visible, select:focus-visible, [tabindex]:focus-vi
202
157
  }
203
158
  }
204
159
 
205
- @keyframes bc-fade-in { from { opacity: 0 } to { opacity: 1 } }
206
- @keyframes bc-fade-out { to { opacity: 0 } }
207
- @keyframes bc-panel-in { from { opacity: 0; transform: translateY(14px) scale(.965) } to { opacity: 1; transform: none } }
208
- @keyframes bc-panel-out { to { opacity: 0; transform: translateY(10px) scale(.975) } }
209
- @keyframes bc-sheet-in { from { transform: translateY(100%) } to { transform: none } }
210
- @keyframes bc-sheet-out { to { transform: translateY(100%) } }
211
160
  @keyframes bc-launcher-in { from { opacity: 0; transform: translateY(12px) scale(.86) } to { opacity: 1; transform: none } }
212
161
  @keyframes bc-item-in { from { opacity: 0; transform: translateY(8px) scale(.96) } to { opacity: 1; transform: none } }
213
- @keyframes bc-rise { from { opacity: 0; transform: translateY(6px) } to { opacity: 1; transform: none } }
214
- @keyframes bc-pulse { 0%, 100% { opacity: 1; transform: scale(1) } 50% { opacity: .3; transform: scale(.78) } }
215
- @keyframes bc-spin { to { transform: rotate(360deg) } }
216
- @keyframes bc-pop { 0% { transform: scale(.5); opacity: 0 } 65% { transform: scale(1.1) } 100% { transform: scale(1); opacity: 1 } }
217
- @keyframes bc-nudge { 25% { transform: translateX(-3px) } 75% { transform: translateX(3px) } }
218
- @keyframes bc-typing { 0%, 60%, 100% { transform: translateY(0); opacity: .45 } 30% { transform: translateY(-3px); opacity: 1 } }
219
- @keyframes bc-shimmer { from { background-position: 200% 0 } to { background-position: -200% 0 } }
220
162
 
221
163
  /* ---------- dock: theme switch stacked above the launcher ---------- */
222
164
  .dock {
@@ -281,318 +223,9 @@ button:focus-visible, a:focus-visible, select:focus-visible, [tabindex]:focus-vi
281
223
  .launcher:focus-visible { outline-offset: 3px; }
282
224
  .launcher[hidden] { display: none; }
283
225
 
284
- /* ---------- scrim + panel ---------- */
285
- .scrim {
286
- position: fixed; inset: 0; z-index: 2147483001;
287
- background: rgba(10,12,16,.32);
288
- animation: bc-fade-in 200ms ease both;
289
- }
290
- .scrim.closing { animation: bc-fade-out 170ms ease both; }
291
-
292
- /* The panel belongs to the launcher, so it opens out of that corner rather
293
- than in the middle of the screen. --bc-dock-h is measured at open time
294
- because the dock's height depends on whether the launcher is showing.
295
- A column: the header and footer stay put and only the middle scrolls. */
296
- .panel {
297
- position: fixed;
298
- display: flex; flex-direction: column;
299
- width: calc(100vw - 40px); max-width: 420px;
300
- max-height: calc(100vh - var(--bc-dock-h, 84px) - 52px);
301
- overflow: hidden;
302
- background: var(--bc-bg); color: var(--bc-fg);
303
- border-radius: var(--bc-r-xl); box-shadow: var(--bc-shadow);
304
- border: 1px solid color-mix(in srgb, var(--bc-border) 70%, transparent);
305
- animation: bc-panel-in 340ms var(--bc-ease) both;
306
- }
307
- /* A conversation wants a height of its own, so the thread fills the panel
308
- rather than shrinking to its first two messages. */
309
- .panel.is-chat { height: 640px; }
310
- .scrim.at-right .panel { right: 20px; transform-origin: bottom right; }
311
- .scrim.at-left .panel { left: 20px; transform-origin: bottom left; }
312
- .scrim.at-bottom .panel { bottom: calc(20px + var(--bc-dock-h, 84px) + 12px); }
313
- .scrim.at-top .panel { top: calc(20px + var(--bc-dock-h, 84px) + 12px); transform-origin: top right; }
314
- .scrim.closing .panel { animation: bc-panel-out 170ms ease both; }
315
-
316
- /* ---------- header ---------- */
317
- .head {
318
- flex: none; position: relative;
319
- display: flex; align-items: center; gap: 12px;
320
- padding: 16px 14px 14px var(--bc-pad);
321
- background: linear-gradient(180deg, var(--bc-accent-soft), var(--bc-bg));
322
- border-bottom: 1px solid var(--bc-border);
323
- }
324
- .head .mark, .avatar {
325
- flex: none; display: grid; place-items: center; position: relative;
326
- width: 38px; height: 38px; border-radius: 50%;
327
- background: linear-gradient(140deg, var(--bc-accent), color-mix(in srgb, var(--bc-accent) 60%, #fff));
328
- color: var(--bc-on-accent); font-size: 19px;
329
- box-shadow: 0 4px 12px color-mix(in srgb, var(--bc-accent) 30%, transparent);
330
- }
331
- /* The little "online" dot says someone — or something — is on the other end. */
332
- .head .mark::after {
333
- content: ''; position: absolute; right: -1px; bottom: -1px;
334
- width: 11px; height: 11px; border-radius: 50%;
335
- background: #22c55e; border: 2px solid var(--bc-bg);
336
- }
337
- .head .titles { flex: 1; min-width: 0; }
338
- .head h2 {
339
- margin: 0 0 2px; font-size: var(--bc-fs-lg); font-weight: 680; letter-spacing: -.02em; line-height: 1.25;
340
- overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
341
- }
342
- .head p { margin: 0; font-size: var(--bc-fs-sm); line-height: 1.45; color: var(--bc-muted); }
343
- .icon-btn {
344
- flex: none; display: grid; place-items: center;
345
- width: 32px; height: 32px; padding: 0; font-size: 16px;
346
- border: none; border-radius: 10px; background: none; color: var(--bc-muted);
347
- cursor: pointer; transition: background 160ms, color 160ms, transform 160ms var(--bc-ease);
348
- }
349
- .icon-btn:hover { background: color-mix(in srgb, var(--bc-fg) 7%, transparent); color: var(--bc-fg); }
350
- .icon-btn:active { transform: scale(.92); }
351
- .icon-btn.back { margin: 0 -6px 0 -8px; }
352
-
353
- .body {
354
- flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain;
355
- padding: 16px var(--bc-pad) 6px;
356
- }
357
-
358
- label { display: block; font-size: var(--bc-fs-sm); font-weight: 620; color: var(--bc-fg); margin-bottom: 7px; }
359
- label .opt { font-weight: 450; color: var(--bc-faint); }
360
- input[type=text], input[type=email], textarea {
361
- width: 100%; padding: 11px 13px; font-size: var(--bc-fs-md); line-height: 1.45;
362
- color: var(--bc-fg); background: var(--bc-elevated);
363
- border: 1px solid var(--bc-border); border-radius: var(--bc-r-md);
364
- transition: border-color 160ms, box-shadow 160ms, background 160ms;
365
- }
366
- input[type=text]::placeholder, input[type=email]::placeholder, textarea::placeholder { color: var(--bc-faint); }
367
- input[type=text]:hover, input[type=email]:hover, textarea:hover { border-color: color-mix(in srgb, var(--bc-fg) 18%, var(--bc-border)); }
368
- input[type=text]:focus, input[type=email]:focus, textarea:focus {
369
- outline: none; border-color: var(--bc-accent); box-shadow: var(--bc-ring);
370
- }
371
- input.invalid, input.invalid:focus, textarea.invalid {
372
- border-color: var(--bc-danger); animation: bc-nudge 240ms ease;
373
- box-shadow: 0 0 0 3px color-mix(in srgb, var(--bc-danger) 16%, transparent);
374
- }
375
- .field-err {
376
- display: flex; align-items: center; gap: 6px;
377
- margin: 7px 0 0; font-size: var(--bc-fs-xs); font-weight: 550; color: var(--bc-danger);
378
- animation: bc-rise 200ms var(--bc-ease) both;
379
- }
380
- .field-err svg { font-size: 13px; flex: none; }
381
- textarea { min-height: 88px; resize: vertical; }
382
- .field { margin-bottom: 16px; }
383
- .field-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
384
- .field-head .count { font-size: var(--bc-fs-xs); color: var(--bc-faint); font-variant-numeric: tabular-nums; }
385
-
386
- /* The draft line: a quiet note, or a card offering to pick up where they left off. */
387
- .compose > .note { font-size: var(--bc-fs-xs); color: var(--bc-faint); margin: -4px 0 10px; }
388
- .compose > .note:empty { display: none; }
389
- .compose > .note:has(button) {
390
- display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px;
391
- margin: 0 0 14px; padding: 10px 12px; border-radius: var(--bc-r-md);
392
- background: var(--bc-accent-soft); border: 1px solid var(--bc-accent-line);
393
- font-size: var(--bc-fs-sm); color: var(--bc-fg);
394
- }
395
- .compose > .note button {
396
- padding: 5px 11px; font: inherit; font-size: var(--bc-fs-xs); font-weight: 620;
397
- border-radius: 999px; cursor: pointer;
398
- border: 1px solid var(--bc-accent); background: var(--bc-accent); color: var(--bc-on-accent);
399
- }
400
- .compose > .note button[data-discard] { background: transparent; color: var(--bc-muted); border-color: var(--bc-border); }
401
-
402
- /* ---------- what is already known ----------
403
- A help surface in front of the form, not a step in it. Everything here is
404
- dismissible by ignoring it: the reporter can read what is already known and
405
- leave, tap "this is happening to me too" and leave, or type straight past all
406
- of it and file the report they came to file. Nothing is ever in their way,
407
- because a reporter who feels handled rather than helped stops reporting. */
408
- .known { margin-bottom: 16px; }
409
- .known .known-h {
410
- margin: 0 0 8px; font-size: 11px; font-weight: 680; letter-spacing: .06em;
411
- text-transform: uppercase; color: var(--bc-faint);
412
- }
413
- .known .issue {
414
- display: flex; align-items: center; gap: 10px;
415
- padding: 10px 12px; margin-bottom: 6px;
416
- background: var(--bc-subtle); border: 1px solid var(--bc-border); border-radius: var(--bc-r-md);
417
- animation: bc-rise 240ms var(--bc-ease) both;
418
- }
419
- .known .issue.is-joined { border-color: color-mix(in srgb, var(--bc-ok) 55%, var(--bc-border)); }
420
- .known .issue .who { flex: 1; min-width: 0; }
421
- .known .issue .t {
422
- display: block; font-size: 13px; font-weight: 620; line-height: 1.4;
423
- color: var(--bc-fg); overflow-wrap: anywhere;
424
- }
425
- .known .issue .meta {
426
- display: block; margin-top: 3px; font-size: var(--bc-fs-xs); line-height: 1.45; color: var(--bc-muted);
427
- }
428
- .known .issue .join {
429
- flex: none; padding: 7px 12px; font-size: 12px; font-weight: 620;
430
- color: var(--bc-accent); background: var(--bc-bg);
431
- border: 1px solid var(--bc-accent-line); border-radius: 999px; cursor: pointer;
432
- transition: background 160ms, border-color 160ms;
433
- }
434
- .known .issue .join:hover { border-color: var(--bc-accent); background: var(--bc-accent-soft); }
435
- .known .issue .join:disabled { color: var(--bc-ok); border-color: var(--bc-border); background: var(--bc-bg); cursor: default; opacity: 1; }
436
- .known .ask { display: flex; gap: 8px; margin: 2px 0 8px; }
437
- .known .ask input { flex: 1; min-width: 0; }
438
- .known .ask button { flex: none; }
439
- .known .err { margin: 6px 0 0; font-size: 12px; line-height: 1.45; color: var(--bc-danger); }
440
-
441
- /* ---------- who is reporting ----------
442
- A strip above the form rather than a screen in front of it: where signing in
443
- is optional this is a line the reporter may ignore, and it should read as
444
- one. Where it is required the composer below is not rendered at all and this
445
- strip is the whole panel. */
446
- .whoami {
447
- display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
448
- margin-bottom: 16px; padding: 10px 12px;
449
- font-size: var(--bc-fs-sm); line-height: 1.45; color: var(--bc-muted);
450
- background: var(--bc-subtle); border: 1px solid var(--bc-border); border-radius: var(--bc-r-md);
451
- }
452
- .whoami.asking { display: block; padding: 14px; }
453
- .whoami .who { flex: 1; min-width: 0; }
454
- .whoami .who strong { color: var(--bc-fg); font-weight: 620; word-break: break-all; }
455
- .link {
456
- padding: 0; font: inherit; font-size: var(--bc-fs-sm); font-weight: 620; line-height: 1.45;
457
- color: var(--bc-accent); background: none; border: none; cursor: pointer; border-radius: 4px;
458
- }
459
- .link:hover { text-decoration: underline; text-underline-offset: 2px; }
460
- .link:disabled { color: var(--bc-faint); cursor: default; text-decoration: none; }
461
- .whoami .why { margin: 0 0 10px; font-size: var(--bc-fs-sm); line-height: 1.5; color: var(--bc-muted); }
462
- .whoami .why strong { color: var(--bc-fg); font-weight: 620; word-break: break-all; }
463
- .whoami .row { display: flex; gap: 8px; align-items: stretch; }
464
- .whoami .row input { flex: 1; min-width: 0; }
465
- .whoami .row button { flex: none; }
466
- .whoami .after { display: flex; gap: 14px; align-items: center; margin-top: 9px; }
467
- .whoami .err { color: var(--bc-danger); }
468
- .whoami .code { letter-spacing: .28em; font-variant-numeric: tabular-nums; }
469
-
470
- /* ---------- which record is this about ----------
471
- Drawn only after Send has been pressed and only when what they wrote did not
472
- already answer it, so most people never see this at all. Presented as the
473
- assistant asking one friendly follow-up, not as a form field that appeared. */
474
- .refs {
475
- position: relative;
476
- margin-bottom: 16px; padding: 14px;
477
- font-size: var(--bc-fs-sm); line-height: 1.45; color: var(--bc-muted);
478
- background: var(--bc-accent-soft);
479
- border: 1px solid var(--bc-accent-line); border-radius: var(--bc-r-lg);
480
- animation: bc-item-in 280ms var(--bc-ease) both;
481
- }
482
- .refs .ask-head { display: flex; align-items: flex-start; gap: 10px; margin-bottom: 11px; }
483
- .refs .ask-head .avatar { width: 28px; height: 28px; font-size: 14px; box-shadow: none; }
484
- .refs .ask-head strong { display: block; font-size: 13.5px; font-weight: 650; color: var(--bc-fg); margin-bottom: 1px; }
485
- .refs .why { margin: 0; font-size: var(--bc-fs-sm); line-height: 1.5; color: var(--bc-muted); }
486
- .refs .row { display: flex; gap: 8px; align-items: stretch; }
487
- .refs .row input { flex: 1; min-width: 0; background: var(--bc-bg); }
488
- .refs select {
489
- flex: none; max-width: 45%; padding: 10px 10px; font-size: 13px;
490
- color: var(--bc-fg); background: var(--bc-bg);
491
- border: 1px solid var(--bc-border); border-radius: var(--bc-r-md);
492
- }
493
- .refs select:focus { outline: none; border-color: var(--bc-accent); box-shadow: var(--bc-ring); }
494
- .refs .chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 9px; }
495
- .refs .chips button {
496
- padding: 5px 11px; font: inherit; font-size: 12px; font-weight: 600; border-radius: 999px; cursor: pointer;
497
- color: var(--bc-muted); background: var(--bc-bg); border: 1px solid var(--bc-border);
498
- transition: background 160ms, color 160ms, border-color 160ms;
499
- }
500
- .refs .chips button[aria-pressed="true"] { color: var(--bc-on-accent); background: var(--bc-accent); border-color: var(--bc-accent); }
501
- .refs .hint { margin: 8px 0 0; font-size: 12px; line-height: 1.45; color: var(--bc-muted); }
502
- .refs .after { display: flex; gap: 12px; align-items: center; margin-top: 9px; }
503
- .refs .err { color: var(--bc-danger); font-weight: 550; }
504
- .refs .link { margin-top: 10px; font-size: 12px; color: var(--bc-muted); }
505
- .refs .link:hover { color: var(--bc-fg); }
506
- .refs .chips.cands button { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-weight: 550; }
507
- /* "We read your ID" — under the description, while they type. */
508
- .ref-found {
509
- display: flex; align-items: center; gap: 6px;
510
- margin: -6px 0 14px; font-size: 12px; line-height: 1.45; color: var(--bc-muted);
511
- }
512
- .ref-found svg { width: 14px; height: 14px; flex: none; color: var(--bc-ok); }
513
- .ref-found b { font-weight: 600; color: var(--bc-fg); }
514
-
515
- /* ---------- severity ---------- */
516
- .seg { display: grid; grid-template-columns: repeat(4, 1fr); gap: 3px; padding: 3px; background: var(--bc-subtle); border: 1px solid var(--bc-border); border-radius: var(--bc-r-md); }
517
- .seg button {
518
- display: inline-flex; align-items: center; justify-content: center; gap: 6px;
519
- padding: 8px 4px; font-size: var(--bc-fs-sm); font-weight: 550; line-height: 1;
520
- color: var(--bc-muted); background: none; border: none; border-radius: 9px;
521
- cursor: pointer; transition: background 180ms var(--bc-ease), color 180ms, box-shadow 180ms;
522
- }
523
- .seg button::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: var(--sev, var(--bc-faint)); opacity: .55; transition: opacity 180ms; }
524
- .seg button[data-prio="LOW"] { --sev: #94a3b8; }
525
- .seg button[data-prio="MEDIUM"] { --sev: #3b82f6; }
526
- .seg button[data-prio="HIGH"] { --sev: #f59e0b; }
527
- .seg button[data-prio="CRITICAL"] { --sev: #ef4444; }
528
- .seg button:hover { color: var(--bc-fg); }
529
- .seg button[aria-pressed=true] {
530
- background: var(--bc-raised); color: var(--bc-fg); font-weight: 650;
531
- box-shadow: 0 1px 3px rgba(10,12,16,.14), 0 0 0 1px color-mix(in srgb, var(--bc-border) 60%, transparent);
532
- }
533
- .seg button[aria-pressed=true]::before { opacity: 1; }
534
- .seg button:focus-visible { outline-offset: -2px; }
535
-
536
- /* ---------- attachments ---------- */
537
- .tools { display: grid; grid-template-columns: repeat(auto-fit, minmax(0, 1fr)); gap: 8px; }
538
- .tool {
539
- display: inline-flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px;
540
- padding: 12px 8px; font-size: 12px; font-weight: 600; line-height: 1;
541
- color: var(--bc-fg); background: var(--bc-elevated);
542
- border: 1px dashed color-mix(in srgb, var(--bc-fg) 20%, var(--bc-border)); border-radius: var(--bc-r-md); cursor: pointer;
543
- transition: border-color 160ms, background 160ms, color 160ms, transform 160ms var(--bc-ease);
544
- }
545
- .tool svg { font-size: 19px; color: var(--bc-muted); transition: color 160ms; }
546
- .tool:hover:not(:disabled) { border-color: var(--bc-accent); border-style: solid; background: var(--bc-accent-soft); transform: translateY(-1px); }
547
- .tool:hover:not(:disabled) svg { color: var(--bc-accent); }
548
- .tool:active:not(:disabled) { transform: translateY(0) scale(.98); }
549
- .tool:disabled { opacity: .45; cursor: not-allowed; }
550
- .attach-hint { margin: 8px 0 0; font-size: var(--bc-fs-xs); color: var(--bc-faint); }
551
-
552
- .thumbs { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
553
- .thumbs:empty { display: none; }
554
- .thumb {
555
- position: relative; width: 76px; height: 76px; flex: none;
556
- border-radius: var(--bc-r-md); overflow: hidden;
557
- border: 1px solid var(--bc-border); background: var(--bc-subtle);
558
- box-shadow: var(--bc-shadow-sm);
559
- animation: bc-item-in 300ms var(--bc-ease) both;
560
- }
561
- .thumb img, .thumb video { width: 100%; height: 100%; object-fit: cover; display: block; }
562
- .thumb .kind {
563
- position: absolute; left: 4px; bottom: 4px;
564
- display: inline-flex; align-items: center; gap: 3px;
565
- padding: 2px 5px; border-radius: 5px;
566
- background: rgba(10,12,16,.72); color: #fff; font-size: 9.5px; font-weight: 600; line-height: 1.3;
567
- }
568
- .thumb .drop {
569
- position: absolute; top: 4px; right: 4px;
570
- width: 20px; height: 20px; font-size: 11px; border-radius: 50%;
571
- background: rgba(10,12,16,.7); color: #fff; border: none;
572
- display: grid; place-items: center; cursor: pointer;
573
- opacity: 0; transition: opacity 160ms, background 160ms;
574
- }
575
- .thumb:hover .drop, .thumb:focus-within .drop { opacity: 1; }
576
- .thumb .drop:hover { background: var(--bc-danger); }
577
- .thumb .veil {
578
- position: absolute; inset: 0; display: grid; place-items: center; gap: 6px;
579
- background: rgba(10,12,16,.62); color: #fff; font-size: 10.5px; font-weight: 600;
580
- align-content: center;
581
- }
582
- .thumb .veil.err { background: color-mix(in srgb, var(--bc-danger) 82%, transparent); }
583
- .spinner {
584
- width: 17px; height: 17px; border-radius: 50%;
585
- border: 2px solid rgba(255,255,255,.35); border-top-color: #fff;
586
- animation: bc-spin 720ms linear infinite;
587
- }
588
- .bar { width: 46px; height: 3px; border-radius: 2px; background: rgba(255,255,255,.3); overflow: hidden; }
589
- .bar i { display: block; height: 100%; width: 0; background: #fff; transition: width 180ms linear; }
590
- .veil button {
591
- background: none; border: none; color: #fff; font-size: 14px; padding: 2px;
592
- cursor: pointer; display: grid; place-items: center;
593
- }
594
-
595
- /* ---------- messages ---------- */
226
+ /* ---------- messages ----------
227
+ The panel's banner, and the load-failure card below — which is drawn when
228
+ the panel's own sheet never arrived, so these rules live in this one. */
596
229
  .msg {
597
230
  display: flex; align-items: flex-start; gap: 8px;
598
231
  font-size: var(--bc-fs-sm); line-height: 1.45; padding: 10px 12px; border-radius: var(--bc-r-md); margin-bottom: 14px;
@@ -602,543 +235,51 @@ textarea { min-height: 88px; resize: vertical; }
602
235
  .msg.err { background: color-mix(in srgb, var(--bc-danger) 10%, var(--bc-bg)); color: var(--bc-danger); border: 1px solid color-mix(in srgb, var(--bc-danger) 30%, transparent); }
603
236
  .msg.info { background: var(--bc-subtle); color: var(--bc-muted); border: 1px solid var(--bc-border); }
604
237
 
605
- /* ---------- footer ---------- */
606
- .foot {
607
- flex: none;
608
- display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
609
- padding: 12px var(--bc-pad); border-top: 1px solid var(--bc-border);
610
- background: var(--bc-bg);
611
- }
612
- .foot .spacer { flex: 1; }
613
- .foot .note { flex-basis: 100%; order: -1; font-size: var(--bc-fs-xs); color: var(--bc-muted); }
614
- .foot .note:empty { display: none; }
615
- /* Quiet, but still legible: this sits in someone else's product, so it identifies
616
- where the report goes without competing with their interface. Quiet is bought
617
- with colour (--bc-brand-ink sits a step below body text) rather than with a
618
- blanket opacity — the BUGCATCH half is only about 3px of cap height at this
619
- size, and dimming it on top of that left it as a smudge in both themes. */
620
- .powered {
621
- flex: none; display: flex; align-items: center; justify-content: center; gap: 6px;
622
- padding: 7px var(--bc-pad) 9px; font-size: 10.5px; color: var(--bc-faint);
623
- background: var(--bc-subtle); border-top: 1px solid var(--bc-border);
624
- }
625
- .brand-credit { display: flex; align-items: center; transition: color 0.15s; border-radius: 4px; }
626
- .bc-brand { height: 16px; width: auto; display: block; color: var(--bc-brand-ink); }
627
- .brand-credit:hover .bc-brand { color: var(--bc-fg); }
628
- button.ghost, button.primary {
629
- display: inline-flex; align-items: center; justify-content: center; gap: 7px;
630
- height: 38px; padding: 0 15px; font: inherit; font-size: 13px; font-weight: 620; line-height: 1;
631
- border-radius: 999px; cursor: pointer; white-space: nowrap;
632
- transition: transform 160ms var(--bc-ease), filter 160ms, background 160ms, border-color 160ms, color 160ms, box-shadow 160ms;
633
- }
634
- button.ghost { background: var(--bc-bg); border: 1px solid var(--bc-border); color: var(--bc-fg); }
635
- button.ghost:hover:not(:disabled) { background: var(--bc-subtle); border-color: color-mix(in srgb, var(--bc-fg) 20%, var(--bc-border)); }
636
- button.ghost svg, button.primary svg { font-size: 15px; }
637
- button.primary {
638
- padding: 0 18px;
639
- background: var(--bc-accent); border: 1px solid var(--bc-accent); color: var(--bc-on-accent);
640
- box-shadow: 0 4px 12px color-mix(in srgb, var(--bc-accent) 30%, transparent);
641
- }
642
- button.primary:hover:not(:disabled) { transform: translateY(-1px); filter: brightness(1.07); }
643
- button.primary:active:not(:disabled) { transform: none; }
644
- button:disabled { opacity: .5; cursor: not-allowed; }
645
- button.primary:disabled { box-shadow: none; }
646
- button.primary .spinner { border-color: rgba(255,255,255,.4); border-top-color: #fff; width: 14px; height: 14px; }
647
- button.quiet {
648
- padding: 0 4px; height: 38px; font: inherit; font-size: 13px; font-weight: 600;
649
- border: none; background: none; color: var(--bc-muted); cursor: pointer; border-radius: 8px;
650
- }
651
- button.quiet:hover { color: var(--bc-accent); }
652
-
653
- /* ---------- your reports ---------- */
654
- .history {
655
- flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain;
656
- padding: 8px 12px 14px;
657
- }
658
- /* On the conversation screen the inner thread does the scrolling, so this
659
- wrapper must not — two nested scrollers means the composer drifts out of
660
- reach exactly when someone reaches for it. */
661
- .history.chatting {
662
- overflow: hidden; display: flex; flex-direction: column; padding: 0;
663
- }
664
- .history .row {
665
- display: flex; align-items: center; gap: 12px;
666
- padding: 12px 10px; border-bottom: 1px solid var(--bc-border);
667
- }
668
- .history .row:last-child { border-bottom: 0; }
669
- .history .who { flex: 1; min-width: 0; }
670
- .history .ttl {
671
- display: block; font-size: 13.5px; font-weight: 600; color: var(--bc-fg);
672
- overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
673
- }
674
- .history .sub { display: block; margin-top: 3px; font-size: var(--bc-fs-xs); color: var(--bc-faint); }
675
- .history .row-ico {
676
- flex: none; display: grid; place-items: center; width: 34px; height: 34px; border-radius: 10px;
677
- background: var(--bc-subtle); color: var(--bc-muted); font-size: 16px;
678
- }
679
- .history .chev { flex: none; color: var(--bc-faint); font-size: 15px; transition: transform 160ms var(--bc-ease), color 160ms; }
680
- .empty {
681
- display: flex; flex-direction: column; align-items: center; gap: 10px;
682
- padding: 40px 16px; text-align: center; font-size: var(--bc-fs-sm); color: var(--bc-muted);
683
- }
684
- .empty .empty-ico {
685
- display: grid; place-items: center; width: 44px; height: 44px; border-radius: 50%;
686
- background: var(--bc-accent-soft); color: var(--bc-accent); font-size: 20px;
687
- }
688
- .skel {
689
- display: block; height: 12px; border-radius: 6px; margin: 6px 0;
690
- background: linear-gradient(90deg, var(--bc-subtle) 25%, color-mix(in srgb, var(--bc-fg) 8%, var(--bc-subtle)) 50%, var(--bc-subtle) 75%);
691
- background-size: 200% 100%; animation: bc-shimmer 1.4s linear infinite;
692
- }
693
- /* A history row is a button now — it opens the report — so the UA styling has
694
- to go, and the hit area has to read as one. */
695
- .history button.row {
696
- width: 100%; background: none; border: 0; margin-bottom: 2px;
697
- font: inherit; color: inherit; text-align: left; cursor: pointer;
698
- border-radius: var(--bc-r-md); transition: background 160ms;
699
- animation: bc-rise 260ms var(--bc-ease) both;
700
- }
701
- .history button.row:hover { background: var(--bc-subtle); }
702
- .history button.row:hover .chev { transform: translateX(2px); color: var(--bc-accent); }
703
- .history button.row:focus-visible { outline-offset: -2px; }
704
-
705
- /* ---------- one report, and what happened to it ---------- */
706
- .report-head {
707
- flex: none;
708
- display: grid; grid-template-columns: 1fr auto; gap: 2px 10px; align-items: center;
709
- padding: 10px var(--bc-pad); border-bottom: 1px solid var(--bc-border);
710
- background: var(--bc-bg);
711
- }
712
- .report-head .ttl { font-size: 13px; font-weight: 620; color: var(--bc-fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
713
- .report-head .sub { font-size: var(--bc-fs-xs); color: var(--bc-faint); }
714
- .report-head .pill { grid-row: 1 / span 2; grid-column: 2; align-self: center; }
715
-
716
- /* The thread scrolls; the composer under it does not. Both live inside the
717
- panel, which stops scrolling as a whole on this screen — a composer that
718
- scrolls away is a composer people think isn't there. */
719
- .thread {
720
- flex: 1 1 auto; min-height: 140px;
721
- display: flex; flex-direction: column; gap: 4px;
722
- padding: 14px var(--bc-pad) 12px; overflow-y: auto; overscroll-behavior: contain;
723
- }
724
-
725
- /* The day a run of messages happened on, as a centred label on a rule. */
726
- .day-sep {
727
- display: flex; align-items: center; gap: 10px; margin: 10px 0 6px;
728
- font-size: 11px; font-weight: 620; letter-spacing: .02em; color: var(--bc-faint);
729
- }
730
- .day-sep::before, .day-sep::after { content: ''; flex: 1; height: 1px; background: var(--bc-border); }
731
-
732
- /* A status change is not a message and is not drawn as one: no side, no
733
- bubble, no author. Centred and quiet, the way every chat marks the things
734
- that happened rather than the things that were said. */
735
- .sys {
736
- align-self: center; max-width: 90%; text-align: center; margin: 6px 0;
737
- padding: 4px 10px; border-radius: 999px; background: var(--bc-subtle);
738
- font-size: var(--bc-fs-xs); line-height: 1.45; color: var(--bc-muted);
739
- animation: bc-fade-in 240ms ease both;
740
- }
741
- .sys .dot {
742
- display: inline-block; width: 6px; height: 6px; margin-right: 6px;
743
- border-radius: 50%; background: var(--bc-muted); vertical-align: middle;
744
- }
745
- .sys .t { color: var(--bc-faint); }
746
- .sys.k-status .dot { background: #4da3ff; }
747
- .sys.k-severity .dot { background: #e8912a; }
748
- .sys.k-assignment .dot { background: #8b7dff; }
749
- .sys.k-filed .dot { background: var(--bc-faint); }
750
-
751
- /* ---------- the conversation ----------
752
-
753
- A status change and a message are different things and are drawn as such: a
754
- change is a line on the rail above, a message is a bubble with a side. The
755
- reporter's own words sit right and accented, the other side's left beside an
756
- avatar that says who it is — the arrangement every chat has taught people to
757
- read without a legend. */
758
- .msg-row, .typing-row {
759
- display: grid; grid-template-columns: 28px minmax(0, 1fr); column-gap: 8px;
760
- max-width: 88%; margin-top: 10px;
761
- animation: bc-rise 260ms var(--bc-ease) both;
762
- }
763
- .msg-row.cont { margin-top: 3px; }
764
- .msg-row > *, .typing-row > * { grid-column: 2; }
765
- /* Fixed rows, so the avatar lines up with the bubble whatever else the
766
- message carries (a name above it, answers or a time below it). */
767
- .msg-row > .msg-who, .typing-row > .msg-who { grid-row: 1; }
768
- .msg-row > .bubble, .typing-row > .bubble { grid-row: 2; }
769
- .msg-row > .quick { grid-row: 3; }
770
- .msg-row > .msg-when { grid-row: 4; }
771
- .msg-row > .av, .typing-row > .av { grid-column: 1; grid-row: 2; align-self: end; }
772
- /* In a run of messages the avatar sits by the last bubble and the time under
773
- it, the way a chat marks where one turn ends. */
774
- .msg-row:has(+ .msg-row.cont) > .av { visibility: hidden; }
775
- .msg-row:has(+ .msg-row.cont) > .msg-when { display: none; }
776
- .msg-row.theirs, .typing-row { align-self: flex-start; }
777
- .msg-row.mine { align-self: flex-end; grid-template-columns: minmax(0, 1fr); justify-items: end; }
778
- .msg-row.mine > * { grid-column: 1; }
779
- .av {
780
- display: grid; place-items: center; width: 28px; height: 28px; border-radius: 50%;
781
- font-size: 11px; font-weight: 700; letter-spacing: .02em;
782
- background: color-mix(in srgb, var(--bc-fg) 10%, var(--bc-bg)); color: var(--bc-fg);
783
- }
784
- .av.bot {
785
- background: linear-gradient(140deg, var(--bc-accent), color-mix(in srgb, var(--bc-accent) 60%, #fff));
786
- color: var(--bc-on-accent); font-size: 14px;
787
- }
788
- .msg-who { font-size: var(--bc-fs-xs); font-weight: 620; color: var(--bc-muted); padding: 0 4px 4px; }
789
- .msg-who.auto { color: var(--bc-accent); display: inline-flex; align-items: center; gap: 5px; }
790
- .msg-who .tag {
791
- padding: 1px 6px; border-radius: 999px; font-size: 9.5px; font-weight: 700; letter-spacing: .04em;
792
- text-transform: uppercase; background: var(--bc-accent-soft); color: var(--bc-accent);
793
- }
794
- .bubble {
795
- justify-self: start; max-width: 100%;
796
- padding: 9px 13px; border-radius: 18px; font-size: 13.5px; line-height: 1.5;
797
- background: var(--bc-subtle); color: var(--bc-fg);
798
- border: 1px solid var(--bc-border);
799
- border-bottom-left-radius: 6px;
800
- }
801
- /* pre-wrap belongs to the text, not the box — on the box it would also render
802
- the markup's own indentation. */
803
- .msg-text { display: block; white-space: pre-wrap; overflow-wrap: anywhere; }
804
- .msg-row.mine .bubble {
805
- justify-self: end;
806
- background: var(--bc-accent); border-color: var(--bc-accent); color: var(--bc-on-accent);
807
- border-bottom-left-radius: 18px; border-bottom-right-radius: 6px;
808
- }
809
- .msg-row.cont.theirs .bubble { border-top-left-radius: 6px; }
810
- .msg-row.cont.mine .bubble { border-top-right-radius: 6px; }
811
- /* A reply nobody wrote: tinted with the accent and labelled "Assistant" above
812
- it, so it reads the same to someone who cannot see the colour at all. */
813
- .msg-row.auto .bubble { background: var(--bc-accent-soft); border-color: var(--bc-accent-line); }
814
- /* An attachment-only message has no text to wrap, so the bubble would be an
815
- empty coloured box around it. */
816
- .bubble.silent { padding: 0; background: none !important; border: 0; }
817
- .msg-when { font-size: 10.5px; color: var(--bc-faint); padding: 4px 4px 0; }
818
- .msg-row.pending { opacity: .6; }
819
- .msg-row.pending .msg-when::after { content: ' · Sending…'; }
820
- .msg-row.failed .bubble { border: 1px solid var(--bc-danger); }
821
- .msg-row.failed .msg-when { color: var(--bc-danger); }
822
- .msg-row.failed .msg-when::after { content: ' · Not sent'; }
823
-
824
- /* The assistant, thinking. Three dots in a bubble where its next message will be. */
825
- .typing { display: inline-flex; align-items: center; gap: 4px; padding: 12px 14px; justify-self: start; }
826
- .typing i {
827
- width: 7px; height: 7px; border-radius: 50%; background: var(--bc-accent); opacity: .45;
828
- animation: bc-typing 1.2s ease-in-out infinite;
829
- }
830
- .typing i:nth-child(2) { animation-delay: .15s; }
831
- .typing i:nth-child(3) { animation-delay: .3s; }
832
- .typing-row .msg-when { padding-top: 4px; }
833
-
834
- .att { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; }
835
- .bubble.silent .att { margin-top: 0; }
836
- .att img {
837
- display: block; max-width: 100%; max-height: 190px; border-radius: var(--bc-r-md);
838
- border: 1px solid var(--bc-border); object-fit: cover; cursor: zoom-in;
839
- }
840
- .att audio { width: 232px; max-width: 100%; height: 34px; }
841
- .att video { max-width: 100%; max-height: 190px; border-radius: var(--bc-r-md); }
842
- .att .file {
843
- display: flex; align-items: center; gap: 8px; text-decoration: none;
844
- padding: 8px 10px; border-radius: 10px; font-size: 12px;
845
- background: var(--bc-elevated); border: 1px solid var(--bc-border); color: var(--bc-fg);
846
- }
847
- .att .file:hover { border-color: var(--bc-accent); }
848
- .att .file .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
849
- .att .file .sz { flex: none; color: var(--bc-faint); font-size: 11px; }
850
-
851
- /* ---------- the assistant's live card ----------
852
- A status header pinned between the report and the conversation: where the
853
- investigation is, as words and as a stepper. It does not scroll with the
854
- thread, because "is anything happening?" is the question on screen the
855
- whole time. */
856
- .assistant {
857
- flex: none; position: relative; overflow: hidden;
858
- margin: 10px 12px 0; padding: 12px 14px 12px;
859
- border-radius: var(--bc-r-lg); border: 1px solid var(--bc-border);
860
- background: var(--bc-subtle);
861
- transition: background 400ms ease, border-color 400ms ease;
862
- }
863
- .assistant.analyzing, .assistant.confirming, .assistant.needs_info {
864
- background: var(--bc-accent-soft); border-color: var(--bc-accent-line);
865
- }
866
- .assistant.resolved { background: color-mix(in srgb, var(--bc-ok) 9%, var(--bc-bg)); border-color: color-mix(in srgb, var(--bc-ok) 35%, var(--bc-border)); }
867
- /* On a call: the call's own card is what they are watching. This one states
868
- what is happening and stops moving — no spinner, no travelling bar. */
869
- .assistant.calling { background: var(--bc-bg); }
870
- .assistant.calling .as-progress i { transition: none; }
871
- .as-body.entering { animation: bc-rise 360ms var(--bc-ease) both; }
872
- .as-top { display: flex; gap: 11px; align-items: center; }
873
- .as-orb {
874
- flex: none; width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center;
875
- color: var(--bc-on-accent); font-size: 15px;
876
- background: linear-gradient(140deg, var(--bc-accent), color-mix(in srgb, var(--bc-accent) 60%, #fff));
877
- }
878
- .assistant.resolved .as-orb { background: var(--bc-ok); }
879
- .assistant.reviewing .as-orb { background: color-mix(in srgb, var(--bc-fg) 55%, var(--bc-bg)); }
880
- .as-spin { width: 16px; height: 16px; border-radius: 50%; border: 2px solid rgba(255,255,255,.4); border-top-color: #fff; animation: as-rot .8s linear infinite; }
881
- @keyframes as-rot { to { transform: rotate(360deg); } }
882
- .as-text { min-width: 0; flex: 1; }
883
- .as-title { font-weight: 650; font-size: 13.5px; letter-spacing: -.01em; color: var(--bc-fg); }
884
- .as-detail { font-size: 12px; line-height: 1.45; color: var(--bc-muted); margin-top: 2px; }
885
- .as-progress {
886
- height: 4px; margin: 12px 0 0; border-radius: 999px; overflow: hidden;
887
- background: color-mix(in srgb, var(--bc-fg) 9%, transparent);
888
- }
889
- .as-progress i {
890
- display: block; height: 100%; border-radius: inherit; background: var(--bc-accent);
891
- transition: width 700ms var(--bc-ease);
892
- }
893
- .assistant.resolved .as-progress i { background: var(--bc-ok); }
894
- .assistant.analyzing .as-progress i {
895
- background: linear-gradient(90deg, var(--bc-accent) 0%, color-mix(in srgb, var(--bc-accent) 45%, #fff) 50%, var(--bc-accent) 100%);
896
- background-size: 200% 100%; animation: bc-shimmer 1.6s linear infinite;
897
- }
898
- .as-steps {
899
- list-style: none; display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px;
900
- margin: 8px 0 0; padding: 0; font-size: 10.5px; color: var(--bc-faint);
901
- }
902
- .as-steps li { display: flex; align-items: center; gap: 5px; min-width: 0; white-space: nowrap; }
903
- .as-steps li .lbl { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
904
- .as-steps li .dot { flex: none; width: 8px; height: 8px; border-radius: 50%; border: 1.5px solid currentColor; }
905
- .as-steps li.done { color: var(--bc-muted); }
906
- .as-steps li.done .dot { background: var(--bc-accent); border-color: var(--bc-accent); }
907
- .assistant.resolved .as-steps li.done .dot { background: var(--bc-ok); border-color: var(--bc-ok); }
908
- .as-steps li.active { color: var(--bc-fg); font-weight: 650; }
909
- .as-steps li.active .dot { border-color: var(--bc-accent); background: var(--bc-bg); animation: as-pulse 1.2s ease-in-out infinite; }
910
- @keyframes as-pulse { 50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--bc-accent) 25%, transparent); } }
911
- @media (prefers-reduced-motion: reduce) { .as-spin, .as-steps li.active .dot, .assistant.analyzing .as-progress i, .typing i { animation: none; } }
912
-
913
- /* Quick answers under the confirm question — only on the latest message. */
914
- .msg-row .quick { display: none; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
915
- .msg-row:last-child .quick { display: flex; }
916
- .quick button {
917
- display: inline-flex; align-items: center; gap: 6px;
918
- font: inherit; font-size: 13px; font-weight: 600; padding: 8px 14px; border-radius: 999px; cursor: pointer;
919
- border: 1px solid var(--bc-accent); background: var(--bc-bg); color: var(--bc-accent);
920
- transition: background 160ms, color 160ms, transform 160ms var(--bc-ease);
921
- animation: bc-item-in 300ms var(--bc-ease) both;
922
- }
923
- .quick button:first-child { background: var(--bc-accent); color: var(--bc-on-accent); }
924
- .quick button:nth-child(2) { animation-delay: 60ms; }
925
- .quick button svg { font-size: 13px; }
926
- .quick button:hover { transform: translateY(-1px); background: var(--bc-accent-soft); }
927
- .quick button:first-child:hover { background: var(--bc-accent); filter: brightness(1.07); }
928
-
929
- /* ---------- the composer ---------- */
930
- .composer {
931
- flex: none; position: relative;
932
- padding: 10px 12px max(12px, env(safe-area-inset-bottom, 0px));
933
- /* The panel's own background, not --bc-elevated: the two are the same white
934
- in light mode but differ in dark, where a raised composer reads as a band
935
- laid over the panel rather than the foot of it. */
936
- background: var(--bc-bg);
937
- border-top: 1px solid var(--bc-border);
938
- }
939
- /* One rounded field holding the tools, the text and the send button — the
940
- shape every chat box has, so nobody has to look for where to type. */
941
- .composer .row {
942
- display: flex; align-items: flex-end; gap: 4px;
943
- padding: 5px; border-radius: 22px;
944
- background: var(--bc-subtle); border: 1px solid var(--bc-border);
945
- transition: border-color 160ms, box-shadow 160ms;
946
- }
947
- .composer .row:focus-within { border-color: var(--bc-accent); box-shadow: var(--bc-ring); }
948
- .composer textarea {
949
- flex: 1; min-width: 0; min-height: 34px; max-height: 120px; resize: none; padding: 7px 6px;
950
- font: inherit; font-size: 13.5px; line-height: 1.45;
951
- color: var(--bc-fg); background: transparent; border: 0; border-radius: 0;
952
- }
953
- .composer textarea:hover, .composer textarea:focus { border: 0; box-shadow: none; outline: none; }
954
- .composer .tools { display: flex; gap: 0; grid-template-columns: none; }
955
- .composer .tool {
956
- width: 34px; height: 34px; min-height: 0; padding: 0; display: grid; place-items: center; cursor: pointer;
957
- border-radius: 50%; border: 0; background: none; font-size: 17px;
958
- color: var(--bc-muted);
959
- }
960
- .composer .tool svg { font-size: 17px; }
961
- .composer .tool:hover:not(:disabled) { background: color-mix(in srgb, var(--bc-fg) 8%, transparent); color: var(--bc-fg); border: 0; transform: none; }
962
- .composer .tool:hover:not(:disabled) svg { color: var(--bc-fg); }
963
- .composer .tool:disabled { opacity: .4; cursor: default; }
964
- .composer .tool.recording { color: var(--bc-danger); background: color-mix(in srgb, var(--bc-danger) 12%, transparent); }
965
- .composer .tool.recording svg { color: var(--bc-danger); }
966
- .composer .send {
967
- width: 34px; height: 34px; flex: none; border-radius: 50%; cursor: pointer; font-size: 16px;
968
- background: var(--bc-accent); border: 0; color: var(--bc-on-accent);
969
- display: grid; place-items: center;
970
- transition: transform 160ms var(--bc-ease), opacity 160ms, filter 160ms;
971
- }
972
- .composer .send:hover:not(:disabled) { filter: brightness(1.08); transform: scale(1.05); }
973
- .composer .send:disabled { opacity: .35; cursor: default; background: var(--bc-faint); }
974
- .composer .staged { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 0 8px; }
975
- .composer .chip {
976
- display: flex; align-items: center; gap: 6px; max-width: 200px;
977
- padding: 5px 7px 5px 10px; border-radius: 999px; font-size: var(--bc-fs-xs);
978
- background: var(--bc-subtle); border: 1px solid var(--bc-border); color: var(--bc-muted);
238
+ /* ---------- announcements ----------
239
+ What the project's team is telling visitors (notice.js): beside the launcher
240
+ for the ones written for that, and at the top of the panel for all of them.
241
+ In this sheet because the card beside the button exists before the panel's
242
+ does. The tone is a colour and an icon; the site's accent is the info one. */
243
+ .notice {
244
+ --tone: var(--bc-accent);
245
+ display: flex; align-items: flex-start; gap: 10px;
246
+ padding: 12px 12px 12px 14px; border-radius: var(--bc-r-md);
247
+ background: color-mix(in srgb, var(--tone) 7%, var(--bc-elevated));
248
+ border: 1px solid color-mix(in srgb, var(--tone) 28%, var(--bc-border));
249
+ color: var(--bc-fg);
250
+ font-size: var(--bc-fs-sm); line-height: 1.45; text-align: left;
979
251
  animation: bc-item-in 240ms var(--bc-ease) both;
980
252
  }
981
- .composer .chip .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--bc-fg); }
982
- .composer .chip button {
983
- flex: none; width: 18px; height: 18px; display: grid; place-items: center;
984
- border: 0; border-radius: 50%; background: none; color: var(--bc-faint); cursor: pointer;
985
- }
986
- .composer .chip button:hover { color: var(--bc-danger); }
987
- .composer .chip.uploading { border-style: dashed; }
988
- .composer .note { padding: 6px 6px 0; font-size: var(--bc-fs-xs); color: var(--bc-muted); }
989
- .composer .note.err { color: var(--bc-danger); }
990
- .reccount { font-variant-numeric: tabular-nums; }
991
-
992
- /* Status carries the only colour in this list, so it is the thing the eye
993
- lands on — which is what someone opening this screen came to check. */
994
- .pill {
995
- flex: none; display: inline-flex; align-items: center; gap: 5px;
996
- font-size: 11px; font-weight: 650;
997
- padding: 3px 9px; border-radius: 999px;
998
- color: var(--bc-muted); background: var(--bc-subtle);
999
- }
1000
- .pill::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
1001
- .pill.open { color: #c2410c; background: color-mix(in srgb, #f59e0b 14%, var(--bc-bg)); }
1002
- .pill.in_progress { color: #1d6fd1; background: color-mix(in srgb, #3b82f6 14%, var(--bc-bg)); }
1003
- .pill.resolved { color: #0f8a5a; background: color-mix(in srgb, #10b981 14%, var(--bc-bg)); }
1004
- .pill.closed { color: var(--bc-muted); }
1005
- :host(.bc-dark) .pill.open { color: #fbbf24; }
1006
- :host(.bc-dark) .pill.in_progress { color: #7cb8ff; }
1007
- :host(.bc-dark) .pill.resolved { color: #4ade9a; }
1008
- @media (prefers-color-scheme: dark) {
1009
- :host(.bc-auto) .pill.open { color: #fbbf24; }
1010
- :host(.bc-auto) .pill.in_progress { color: #7cb8ff; }
1011
- :host(.bc-auto) .pill.resolved { color: #4ade9a; }
1012
- }
1013
-
1014
- /* ---------- does the fix work ---------- */
1015
- .fix-verification {
1016
- flex: none; margin: 10px 12px 0; padding: 12px 14px;
1017
- border-radius: var(--bc-r-lg); border: 1px solid color-mix(in srgb, var(--bc-ok) 35%, var(--bc-border));
1018
- background: color-mix(in srgb, var(--bc-ok) 8%, var(--bc-bg));
1019
- font-size: var(--bc-fs-sm); color: var(--bc-fg);
1020
- animation: bc-rise 280ms var(--bc-ease) both;
1021
- }
1022
- .fix-verification p { margin: 0 0 8px; font-weight: 620; }
1023
- .fix-verification textarea { min-height: 56px; margin-bottom: 8px; font-size: 13px; background: var(--bc-bg); }
1024
- .fix-verification button {
1025
- padding: 7px 13px; font: inherit; font-size: 12.5px; font-weight: 620; border-radius: 999px; cursor: pointer;
1026
- border: 1px solid var(--bc-border); background: var(--bc-bg); color: var(--bc-fg);
1027
- }
1028
- .fix-verification button[data-result="CONFIRMED"] { background: var(--bc-ok); border-color: var(--bc-ok); color: #fff; }
1029
- .fix-verification .feedback-status { margin: 6px 0 0; font-weight: 450; color: var(--bc-danger); font-size: var(--bc-fs-xs); }
1030
- .fix-verification .feedback-status:empty { display: none; }
1031
-
1032
- /* A call in progress, in place of the form.
1033
- ---------------------------------------------------------------------------
1034
- Deliberately not styled as a chat. Nothing arrives here to read — the
1035
- conversation is happening out loud — so this is a state, a line of
1036
- reassurance about the report, and a way to hang up. */
1037
- .call { display: flex; flex-direction: column; padding: 26px 0 0; animation: bc-fade-in 240ms ease both; }
1038
- .call-head, .call-note { padding: 0 var(--bc-pad); }
1039
- .call-head { display: flex; align-items: center; gap: 9px; margin-bottom: 8px; }
1040
- .call-head strong { font-size: 15.5px; font-weight: 680; letter-spacing: -.01em; }
1041
- .call-dot {
1042
- width: 9px; height: 9px; border-radius: 50%; background: var(--bc-accent);
1043
- animation: bc-call-pulse 1.6s ease-in-out infinite;
1044
- }
1045
- .call-dot.off { background: var(--bc-faint); animation: none; }
1046
- @keyframes bc-call-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } }
1047
- /* Said while it is still ringing, because that is when somebody is deciding
1048
- whether it is worth waiting. */
1049
- .call-note { margin: 0 0 18px; font-size: 13px; color: var(--bc-muted); line-height: 1.55; }
1050
-
1051
- /* ---------- the same call, on top of the report it is about ---------- */
1052
- /* A bar rather than its own screen, unlike the one the composer opens. The
1053
- thread underneath is exactly what both people are talking about, so taking it
1054
- off screen would be taking away the only shared reference. */
1055
- .livecall {
1056
- flex: none;
1057
- display: flex; align-items: center; gap: 9px;
1058
- margin: 10px 12px 0; padding: 9px 11px;
1059
- border: 1px solid var(--bc-accent-line); border-radius: var(--bc-r-md);
1060
- background: var(--bc-accent-soft); font-size: var(--bc-fs-sm); color: var(--bc-fg);
1061
- animation: bc-rise 240ms var(--bc-ease) both;
1062
- }
1063
- .livecall .who { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1064
- .livecall .call-kept { font-size: 11px; opacity: .75; }
1065
- .livecall .secs { font-variant-numeric: tabular-nums; color: var(--bc-muted); }
1066
- .livecall .act {
1067
- padding: 5px 11px; border-radius: 999px; border: 1px solid var(--bc-border);
1068
- background: var(--bc-bg); color: var(--bc-fg);
1069
- font: inherit; font-size: 12px; font-weight: 600; cursor: pointer;
1070
- }
1071
- .livecall .act:hover { background: var(--bc-subtle); }
1072
- .livecall .act.danger { border-color: transparent; background: var(--bc-danger); color: #fff; }
1073
- .livecall.over { color: var(--bc-muted); background: var(--bc-subtle); border-color: var(--bc-border); }
1074
- .call-note code { font-size: 11px; }
1075
- @media (prefers-reduced-motion: reduce) { .call-dot { animation: none; } }
1076
-
1077
- /* div.done, not .done: the assistant's stepper marks finished steps li.done,
1078
- and this screen's padding was landing on every one of them. */
1079
- div.done { padding: 44px 26px 44px; text-align: center; animation: bc-fade-in 240ms ease both; }
1080
- .done .tick {
1081
- width: 56px; height: 56px; margin: 0 auto 16px; border-radius: 50%;
1082
- display: grid; place-items: center; font-size: 26px;
1083
- background: color-mix(in srgb, var(--bc-ok) 14%, transparent); color: var(--bc-ok);
1084
- box-shadow: 0 0 0 8px color-mix(in srgb, var(--bc-ok) 6%, transparent);
1085
- animation: bc-pop 420ms var(--bc-ease) both;
1086
- }
1087
- .done h3 { margin: 0 0 6px; font-size: 17px; font-weight: 680; letter-spacing: -.015em; }
1088
- .done p { margin: 0; font-size: 13px; line-height: 1.5; color: var(--bc-muted); }
1089
- .done code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; padding: 1px 5px; border-radius: 5px; background: var(--bc-subtle); color: var(--bc-fg); }
1090
- /* The likely wait. Set apart from the reference line above it by a rule rather
1091
- than by weight: one is bookkeeping and the other is the answer to the
1092
- question the reporter actually has, and they should not read as one
1093
- paragraph. */
1094
- .done .eta {
1095
- margin: 14px auto 0;
1096
- padding-top: 12px;
1097
- border-top: 1px solid var(--bc-border);
1098
- max-width: 30ch;
1099
- line-height: 1.5;
1100
- }
1101
- .done .eta strong { color: var(--bc-fg); font-weight: 620; }
1102
- /* The same figure on the report the reporter came back to. Sits under the
1103
- status pill in the header block, muted — it is context, not the headline the
1104
- status is. */
1105
- .report-head .eta-line {
1106
- grid-column: 1 / -1;
1107
- font-size: var(--bc-fs-xs);
1108
- color: var(--bc-muted);
1109
- margin-top: 2px;
253
+ .notice.tone-success { --tone: var(--bc-ok); }
254
+ .notice.tone-warning { --tone: var(--bc-warn); }
255
+ .notice.tone-critical { --tone: var(--bc-danger); }
256
+ .notice-ico { flex: none; color: var(--tone); font-size: 16px; margin-top: 1px; }
257
+ .notice-text { flex: 1; min-width: 0; overflow-wrap: anywhere; }
258
+ .notice-text strong { display: block; font-weight: 650; letter-spacing: -.005em; }
259
+ .notice-text p { margin: 2px 0 0; color: var(--bc-muted); white-space: pre-line; }
260
+ .notice-cta {
261
+ display: inline-block; margin-top: 6px;
262
+ color: var(--tone); font-weight: 620; text-decoration: none;
263
+ }
264
+ .notice-cta:hover { text-decoration: underline; text-underline-offset: 2px; }
265
+ .notice-x {
266
+ flex: none; display: grid; place-items: center;
267
+ width: 26px; height: 26px; margin: -4px -4px 0 0; padding: 0;
268
+ border: 0; border-radius: 999px; background: transparent;
269
+ color: var(--bc-muted); cursor: pointer;
1110
270
  }
1111
-
1112
- /* ---------- recording bar ---------- */
1113
- .recbar {
1114
- position: fixed; left: 50%; bottom: calc(24px + env(safe-area-inset-bottom, 0px)); transform: translateX(-50%);
1115
- z-index: 2147483002;
1116
- display: flex; align-items: center; gap: 12px;
1117
- padding: 8px 8px 8px 16px; border-radius: 999px;
1118
- background: var(--bc-elevated); color: var(--bc-fg);
1119
- border: 1px solid var(--bc-border); box-shadow: var(--bc-shadow);
1120
- animation: bc-item-in 300ms var(--bc-ease) both;
271
+ .notice-x:hover { background: color-mix(in srgb, var(--tone) 12%, transparent); color: var(--bc-fg); }
272
+ .notice-x svg { font-size: 14px; }
273
+ /* Beside the button it floats over somebody else's page: opaque, lifted, and
274
+ never taller than a glance. The whole text is in the panel. */
275
+ .notice.at-launcher {
276
+ width: min(320px, calc(100vw - 40px));
277
+ box-shadow: 0 10px 28px rgba(10,12,16,.14), 0 1px 3px rgba(10,12,16,.08);
1121
278
  }
1122
- .recbar .dot { width: 9px; height: 9px; border-radius: 50%; background: var(--bc-danger); animation: bc-pulse 1.4s ease-in-out infinite; }
1123
- .recbar .time { font-variant-numeric: tabular-nums; font-size: 13px; font-weight: 650; min-width: 38px; }
1124
- .recbar .hint { font-size: 12px; color: var(--bc-muted); }
1125
- .recbar button { height: 34px; padding: 0 14px; font-size: 12.5px; font-weight: 620; border-radius: 999px; cursor: pointer; }
1126
- .recbar .stop { background: var(--bc-danger); border: 1px solid var(--bc-danger); color: #fff; }
1127
- .recbar .stop:hover { filter: brightness(1.08); }
1128
-
1129
- /* ---------- phones ----------------------------------------------------------
1130
- This widget runs inside a shadow root on somebody else's site, so its own
1131
- stylesheet is the whole story: the host page cannot correct any of this, and
1132
- anything that goes wrong here goes wrong on their site, in front of their
1133
- visitor, at the moment that visitor is already annoyed enough to file a bug.
1134
- -------------------------------------------------------------------------- */
1135
-
1136
- /* 100vh on a phone is the viewport with the browser chrome hidden, so the
1137
- panel could be taller than the space it actually has and put "Send report"
1138
- under the address bar. */
1139
- @supports (height: 100dvh) {
1140
- .panel { max-height: calc(100dvh - var(--bc-dock-h, 84px) - 52px); }
279
+ .notice.at-launcher .notice-text p {
280
+ display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden;
1141
281
  }
282
+ .notice.at-panel { margin-bottom: 10px; }
1142
283
 
1143
284
  /* The dock sits in a corner of a page the widget does not own. On a notched
1144
285
  phone that corner is under the home indicator, and in landscape it is under
@@ -1150,56 +291,19 @@ div.done { padding: 44px 26px 44px; text-align: center; animation: bc-fade-in 24
1150
291
  padding-left: env(safe-area-inset-left, 0px);
1151
292
  }
1152
293
 
1153
- /* Short desktop windows: the chat height gives way to the space there is. */
1154
- @media (max-height: 820px) { .panel.is-chat { height: calc(100vh - var(--bc-dock-h, 84px) - 52px); } }
1155
-
1156
- /* A phone gets the whole screen: a sheet that slides up from the bottom, with
1157
- the notch and the home indicator kept clear. The dock steps aside while it
1158
- is open — the sheet has its own close button, and a launcher floating over
1159
- the send button would be in the way of the one thing that matters. */
294
+ /* The dock's own share of the phone and touch rules; the panel's sheet carries
295
+ the rest. */
1160
296
  @media (max-width: 480px) {
1161
- .scrim { background: rgba(10,12,16,.4); }
1162
- .panel, .scrim.at-right .panel, .scrim.at-left .panel, .scrim.at-bottom .panel, .scrim.at-top .panel {
1163
- inset: 0; width: 100%; max-width: none; height: 100%; max-height: none;
1164
- border-radius: 0; border: 0;
1165
- animation: bc-sheet-in 380ms var(--bc-ease) both;
1166
- }
1167
- @supports (height: 100dvh) { .panel { height: 100dvh; max-height: none; } }
1168
- .panel.is-chat { height: 100%; }
1169
- .scrim.closing .panel { animation: bc-sheet-out 200ms ease both; }
1170
- .head { padding-top: max(14px, env(safe-area-inset-top, 0px)); padding-left: max(var(--bc-pad), env(safe-area-inset-left, 0px)); padding-right: max(12px, env(safe-area-inset-right, 0px)); }
1171
- .foot, .powered { padding-bottom: max(10px, env(safe-area-inset-bottom, 0px)); }
1172
- .foot + .powered { padding-bottom: max(9px, env(safe-area-inset-bottom, 0px)); }
1173
- .foot:has(+ .powered) { padding-bottom: 12px; }
297
+ /* The panel is a full-screen sheet with its own close button, and a launcher
298
+ floating over its send button would be in the way. */
1174
299
  .dock.over { display: none; }
1175
- .recbar .hint { display: none; }
1176
- .msg-row, .typing-row { max-width: 92%; }
1177
300
  }
1178
-
1179
301
  @media (pointer: coarse) {
1180
- /* iOS zooms the whole page when a field it focuses is under 16px, and does
1181
- not zoom back out. On the host's site that is not the widget's page to
1182
- zoom — the visitor is left on a magnified checkout wondering what they
1183
- touched. !important because this is a property of the device, not a
1184
- preference about type. */
1185
- input, textarea, select { font-size: 16px !important; }
1186
-
1187
- /* 44px is the floor a thumb hits reliably. Every control in this form was
1188
- under it, on the one surface in the product designed for someone who is
1189
- not an operator and gets no second attempt. */
1190
- .seg button { min-height: 44px; }
1191
- .tool { min-height: 56px; }
1192
- button.primary, button.ghost { height: 44px; }
1193
- .composer .send, .composer .tool { width: 40px; height: 40px; min-height: 0; }
1194
- textarea { min-height: 104px; }
1195
- .composer textarea { min-height: 40px; }
1196
-
1197
- /* The theme switch and the close button are small glyphs whose boxes cannot
1198
- grow without moving the header text beside them, so each keeps its size
1199
- and gets an invisible target drawn around it instead. */
1200
- .themer button, .head .icon-btn, .thumb .drop, .brand-credit { position: relative; }
1201
- .thumb .drop { position: absolute; opacity: 1; }
1202
- .themer button::after, .head .icon-btn::after, .thumb .drop::after, .brand-credit::after {
302
+ /* The theme switch's buttons are small glyphs whose boxes cannot grow without
303
+ moving the launcher under them, so each keeps its size and gets an
304
+ invisible target drawn around it instead. */
305
+ .themer button { position: relative; }
306
+ .themer button::after {
1203
307
  content: '';
1204
308
  position: absolute;
1205
309
  inset: -8px;
@@ -1207,14 +311,37 @@ div.done { padding: 44px 26px 44px; text-align: center; animation: bc-fade-in 24
1207
311
  /* The themer's three buttons sit side by side, so their expanders would
1208
312
  overlap each other without a gap to grow into. */
1209
313
  .themer { gap: 4px; }
314
+ .notice-x { width: 36px; height: 36px; margin: -9px -8px -6px 0; }
1210
315
  }
1211
-
1212
316
  /* Hover is a lie on a touchscreen: it latches on tap and stays. The launcher's
1213
317
  lift in particular was left applied after the panel closed. */
1214
318
  @media (hover: none) {
1215
319
  .launcher:hover { transform: none; filter: none; }
1216
- .tool:hover:not(:disabled), .themer button:hover { transform: none; }
1217
- }
320
+ .themer button:hover { transform: none; }
321
+ }
322
+
323
+ /* ---------- while the panel's code is on its way ----------
324
+ Usually it is already here — it is fetched once the page is idle, and again
325
+ as a pointer reaches the button. On a slow first press the launcher says it
326
+ heard, rather than looking as though it did nothing. */
327
+ .launcher[aria-busy="true"] { cursor: progress; }
328
+ .launcher[aria-busy="true"] .ico { animation: bc-busy 700ms ease-in-out infinite alternate; }
329
+ @keyframes bc-busy { to { opacity: .4; } }
330
+
331
+ /* ---------- the panel's code would not load ----------
332
+ Said above the launcher, in the form's own error style, with the retry: the
333
+ network dropped, or a Content-Security-Policy refused the file. */
334
+ .loadfail {
335
+ margin: 0; max-width: min(320px, calc(100vw - 40px));
336
+ align-items: center; box-shadow: var(--bc-shadow-sm);
337
+ }
338
+ .loadfail > span { flex: 1; }
339
+ .loadfail button {
340
+ flex: none; padding: 2px 4px; border: 0; border-radius: 6px;
341
+ background: none; color: inherit; font: inherit; font-weight: 650; cursor: pointer;
342
+ }
343
+ .loadfail button[data-act="retry"] { text-decoration: underline; text-underline-offset: 2px; }
344
+ .loadfail button svg { font-size: 13px; margin: 0; }
1218
345
  `;
1219
346
 
1220
347
  export function mountWidget(client) {
@@ -1266,2243 +393,195 @@ export function mountWidget(client) {
1266
393
  launcher.setAttribute('aria-haspopup', 'dialog');
1267
394
  launcher.setAttribute('aria-expanded', 'false');
1268
395
 
396
+ // The panel on screen — or on its way, while its code is fetched. Set the
397
+ // moment the launcher is pressed, so a second press before it arrives means
398
+ // "never mind" exactly as it does on an open panel, and isOpen() already
399
+ // says yes: it has been asked for.
1269
400
  let open = null;
1270
401
  launcher.addEventListener('click', () => {
1271
402
  // The launcher stays visible with the panel anchored just above it, so a
1272
403
  // second click reads as "close that" rather than doing nothing.
1273
404
  if (open) { open.close(); return; }
1274
- dock.classList.add('over');
1275
- launcher.setAttribute('aria-expanded', 'true');
1276
- open = openPanel({
1277
- root,
1278
- host,
1279
- dock,
1280
- client,
1281
- onClose: () => {
1282
- open = null;
1283
- dock.classList.remove('over');
1284
- launcher.setAttribute('aria-expanded', 'false');
1285
- },
1286
- });
1287
- });
1288
-
1289
- dock.append(themer, launcher);
1290
- root.appendChild(dock);
1291
-
1292
- document.body.appendChild(host);
1293
- // Integration hook: BugCatch.open() / close() / isOpen() drive the same
1294
- // launcher a person would press, so there is one way in to the panel.
1295
- client.widgetControls = {
1296
- open: () => { if (!open) launcher.click(); },
1297
- close: () => { open?.close(); },
1298
- isOpen: () => Boolean(open),
1299
- };
1300
- return () => { open?.destroy(); host.remove(); client.widgetControls = null; };
1301
- }
1302
-
1303
- /** Normalises a configured theme to one of the three the switch offers. */
1304
- function themeValue(theme) {
1305
- return theme === 'dark' || theme === 'light' ? theme : 'auto';
1306
- }
1307
-
1308
- function themeClass(theme) {
1309
- if (theme === 'dark') return 'bc-dark';
1310
- if (theme === 'light') return 'bc-light';
1311
- return 'bc-auto';
1312
- }
1313
-
1314
- function openPanel({ root, host, dock, client, onClose }) {
1315
- const cfg = client.config;
1316
- const canCapture = cfg.attachments !== false;
1317
- const canShoot = canCapture && screenshotSupported();
1318
- const canRecord = canCapture && captureSupported();
1319
-
1320
- // Uploaded as they are added rather than on send: the upload is the slow
1321
- // part, and doing it while the reporter is still typing means Send is
1322
- // instant and a failure is visible next to the thing that failed.
1323
- const items = [];
1324
- let priority = 'MEDIUM';
1325
- let recorder = null;
1326
- let sending = false;
1327
-
1328
- const scrim = document.createElement('div');
1329
- // The panel opens out of whichever corner the dock is in, clearing it by the
1330
- // dock's measured height rather than a guess at the launcher's size.
1331
- scrim.className = `scrim ${dock.classList.contains('at-left') ? 'at-left' : 'at-right'} `
1332
- + `${dock.classList.contains('at-top') ? 'at-top' : 'at-bottom'}`;
1333
- scrim.style.setProperty('--bc-dock-h', `${Math.round(dock.getBoundingClientRect().height)}px`);
1334
- scrim.innerHTML = `
1335
- <div class="panel" role="dialog" aria-modal="true" aria-labelledby="bc-h">
1336
- ${headHtml({
1337
- id: 'bc-h',
1338
- title: 'Tell us what went wrong',
1339
- sub: "Our assistant starts looking right away. We attach the page details for you.",
1340
- })}
1341
- <div class="body">
1342
- <div class="slot" role="alert"></div>
1343
- <div class="whoami" hidden></div>
1344
- <div class="known" hidden></div>
1345
- <div class="compose">
1346
- <div class="field">
1347
- <label for="bc-title">What went wrong?</label>
1348
- <input id="bc-title" type="text" placeholder="e.g. The checkout button does nothing" autocomplete="off"
1349
- aria-describedby="bc-title-err" />
1350
- <p class="field-err" id="bc-title-err" hidden></p>
1351
- </div>
1352
- <div class="field">
1353
- <label for="bc-desc">Anything else that helps <span class="opt">(optional)</span></label>
1354
- <textarea id="bc-desc" placeholder="What you were doing, what you expected, what happened instead."></textarea>
1355
- </div>
1356
- <!-- Drawn only when this project asks for an identifier and the report
1357
- does not already carry one. Between the description and the
1358
- severity because that is where it belongs in the reading order:
1359
- it is a question about what they just typed. -->
1360
- <div class="refs" hidden></div>
1361
- <div class="field">
1362
- <label id="bc-prio-l">How much is it getting in your way?</label>
1363
- <div class="seg" role="group" aria-labelledby="bc-prio-l">
1364
- ${PRIORITIES.map((p) => `
1365
- <button type="button" data-prio="${p.value}" aria-pressed="${p.value === priority}">${p.label}</button>
1366
- `).join('')}
1367
- </div>
1368
- </div>
1369
- ${canCapture ? `
1370
- <div class="field">
1371
- <div class="field-head">
1372
- <label id="bc-att-l">Show us <span class="opt">(optional)</span></label>
1373
- <span class="count" aria-live="polite"></span>
1374
- </div>
1375
- <div class="tools" role="group" aria-labelledby="bc-att-l">
1376
- ${canShoot ? `<button class="tool" type="button" data-act="shot">${ICONS.camera}Screenshot</button>` : ''}
1377
- ${canRecord ? `<button class="tool" type="button" data-act="rec">${ICONS.video}Record screen</button>` : ''}
1378
- <button class="tool" type="button" data-act="file">${ICONS.paperclip}Upload file</button>
1379
- </div>
1380
- <div class="thumbs"></div>
1381
- <input type="file" accept="${REPORT_MIMES.join(',')}" multiple hidden />
1382
- </div>` : ''}
1383
- </div>
1384
- </div>
1385
- <div class="foot">
1386
- <span class="note" aria-live="polite"></span>
1387
- ${client.hasReports() ? '<button class="quiet" type="button" data-act="history">Your reports</button>' : ''}
1388
- <span class="spacer"></span>
1389
- <!-- Hidden until the API confirms this project can take a call, which
1390
- most cannot. Drawn here rather than as a second launcher because it
1391
- is the same errand as Send report — the difference is only whether
1392
- the reporter would rather say it than type it. -->
1393
- <button class="ghost" type="button" data-act="call" hidden>${ICONS.phone}Talk to the team</button>
1394
- <button class="primary" type="button" data-act="send">Send report ${ICONS.send}</button>
1395
- </div>
1396
- <div class="powered">Powered by
1397
- <a class="brand-credit" href="https://bugcatch.sphoro.com" target="_blank" rel="noopener noreferrer" aria-label="BugCatch">${BRAND}</a>
1398
- </div>
1399
- </div>`;
1400
-
1401
- const $ = (sel) => scrim.querySelector(sel);
1402
- const titleInput = $('#bc-title');
1403
- // Held, not looked up: the history and report screens replace the panel's
1404
- // markup, and a draft saved on the way out must still read what was typed.
1405
- const descInput = $('#bc-desc');
1406
- const slot = $('.slot');
1407
- const thumbs = $('.thumbs');
1408
- const filePicker = scrim.querySelector('input[type=file]');
1409
- const sendBtn = $('[data-act="send"]');
1410
- const callBtn = $('[data-act="call"]');
1411
- const note = $('.note');
1412
- const compose = $('.compose');
1413
- const whoami = $('.whoami');
1414
- const refsBox = $('.refs');
1415
- const known = $('.known');
1416
- const historyBtn = $('[data-act="history"]');
1417
-
1418
- const draftStatus = document.createElement('div');
1419
- draftStatus.setAttribute('role', 'status');
1420
- draftStatus.className = 'note';
1421
- compose.prepend(draftStatus);
1422
- const savedDraftKey = draftKey(cfg, readSession(cfg.projectId)?.email);
1423
- let draftLoaded = cfg.drafts === false;
1424
- let draftSubmitted = false;
1425
- let draftWrites = Promise.resolve();
1426
- let draftTimer;
1427
- function persistDraft() {
1428
- clearTimeout(draftTimer);
1429
- if (!draftLoaded || draftSubmitted || cfg.drafts === false) return draftWrites;
1430
- const value = titleInput.value || descInput.value || items.length ? {
1431
- title: titleInput.value, description: descInput.value, priority,
1432
- items: items.map(({ blob, mime, kind }) => ({ blob, mime, kind })),
1433
- } : null;
1434
- draftWrites = draftWrites.catch(() => {}).then(() => draftOperation(savedDraftKey, value))
1435
- .then(() => { draftStatus.textContent = value ? 'Draft saved on this device' : ''; })
1436
- .catch(() => { draftStatus.textContent = 'Draft could not be saved. Keep this tab open.'; });
1437
- return draftWrites;
1438
- }
1439
- function scheduleDraft() { clearTimeout(draftTimer); draftTimer = setTimeout(persistDraft, 250); }
1440
- compose.addEventListener('input', scheduleDraft);
1441
- compose.addEventListener('change', scheduleDraft);
1442
- const onPageHide = () => { void persistDraft(); };
1443
- window.addEventListener('pagehide', onPageHide);
1444
- function reconnectUploads() { items.filter((it) => it.status === 'failed').forEach(upload); }
1445
- window.addEventListener('online', reconnectUploads);
1446
- if (cfg.drafts !== false) {
1447
- // Disable composition briefly so a late storage read cannot replace new text.
1448
- compose.inert = true;
1449
- readDraft(savedDraftKey).then((draft) => {
1450
- if (closed || !draft) return;
1451
- draftStatus.innerHTML = '<span>You have an unfinished report on this device.</span> <button type="button" data-resume>Resume report</button> <button type="button" data-discard>Discard</button>';
1452
- draftStatus.querySelector('[data-resume]').onclick = () => {
1453
- titleInput.value = draft.title || '';
1454
- descInput.value = draft.description || '';
1455
- priority = PRIORITIES.some((p) => p.value === draft.priority) ? draft.priority : 'MEDIUM';
1456
- scrim.querySelectorAll('[data-prio]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.prio === priority)));
1457
- for (const item of (draft.items || []).slice(0, cfg.maxAttachments ?? 5)) addItem(item);
1458
- draftLoaded = true;
1459
- void persistDraft();
1460
- };
1461
- draftStatus.querySelector('[data-discard]').onclick = () => {
1462
- draftLoaded = true;
1463
- void persistDraft();
1464
- };
1465
- }).catch(() => { draftStatus.textContent = 'Draft storage is unavailable. Keep this tab open.'; })
1466
- .finally(() => { compose.inert = false; if (!draftStatus.querySelector('[data-resume]')) draftLoaded = true; });
1467
- }
1468
-
1469
- /* ---------- lifecycle ---------- */
1470
-
1471
- const previouslyFocused = document.activeElement;
1472
- const rootStyle = document.documentElement.style;
1473
- const previousOverflow = rootStyle.overflow;
1474
- rootStyle.overflow = 'hidden';
1475
-
1476
- let closed = false;
1477
- // Assigned further down, once `showReport` exists to be called back into.
1478
- let stopListening = null;
1479
- function destroy() {
1480
- if (closed) return;
1481
- void persistDraft();
1482
- closed = true;
1483
- clearTimeout(draftTimer);
1484
- window.removeEventListener('pagehide', onPageHide);
1485
- window.removeEventListener('online', reconnectUploads);
1486
- recorder?.cancel();
1487
- // Stop listening before the DOM goes: a frame arriving after this would
1488
- // redraw a panel that is no longer on the page.
1489
- stopListening?.();
1490
- items.forEach((it) => it.previewUrl && URL.revokeObjectURL(it.previewUrl));
1491
- rootStyle.overflow = previousOverflow;
1492
- document.removeEventListener('keydown', onKeydown, true);
1493
- scrim.remove();
1494
- root.querySelector('.recbar')?.remove();
1495
- dock.hidden = false;
1496
- host.style.opacity = '';
1497
- onClose?.();
1498
- }
1499
-
1500
- function close() {
1501
- if (closed) return;
1502
- // Let the exit animation play, but never wait on an event that a
1503
- // reduced-motion user's near-zero animation might deliver unpredictably.
1504
- scrim.classList.add('closing');
1505
- setTimeout(destroy, 160);
1506
- try { previouslyFocused?.focus?.(); } catch { /* element may be gone */ }
1507
- }
1508
-
1509
- function onKeydown(e) {
1510
- if (e.key === 'Escape') {
1511
- e.stopPropagation();
1512
- if (recorder) { stopRecording({ keep: false }); return; }
1513
- if (!sending) close();
1514
- return;
1515
- }
1516
- if (e.key !== 'Tab' || recorder) return;
1517
- // Focus trap: the panel is modal, so Tab must not reach the host page.
1518
- const focusable = [...scrim.querySelectorAll(
1519
- 'button:not(:disabled), input:not([hidden]), textarea, select, [href]',
1520
- )].filter((el) => el.offsetParent !== null || el === document.activeElement);
1521
- if (!focusable.length) return;
1522
- const first = focusable[0];
1523
- const last = focusable[focusable.length - 1];
1524
- const active = scrim.getRootNode().activeElement;
1525
- if (e.shiftKey && active === first) { e.preventDefault(); last.focus(); }
1526
- else if (!e.shiftKey && active === last) { e.preventDefault(); first.focus(); }
1527
- }
1528
-
1529
- document.addEventListener('keydown', onKeydown, true);
1530
- scrim.addEventListener('click', (e) => { if (e.target === scrim && !sending) close(); });
1531
- $('[data-act="close"]').addEventListener('click', close);
1532
-
1533
- /* ---------- severity ---------- */
1534
-
1535
- scrim.querySelectorAll('[data-prio]').forEach((btn) => {
1536
- btn.addEventListener('click', () => {
1537
- priority = btn.dataset.prio;
1538
- scheduleDraft();
1539
- scrim.querySelectorAll('[data-prio]').forEach((b) => {
1540
- b.setAttribute('aria-pressed', String(b === btn));
1541
- });
1542
- });
405
+ open = openPanelWhenLoaded();
1543
406
  });
1544
407
 
1545
- /* ---------- attachments ---------- */
1546
-
1547
- function say(text, kind = 'err') {
1548
- slot.innerHTML = text
1549
- ? `<div class="msg ${kind}">${kind === 'err' ? ICONS.alert : ''}<span>${escapeHtml(text)}</span></div>`
1550
- : '';
1551
- }
1552
-
1553
- // The one required field says what is missing right under itself, rather
1554
- // than in a banner the reporter has to connect back to it.
1555
- const titleErr = $('#bc-title-err');
1556
- function flagTitle(text) {
1557
- titleErr.hidden = !text;
1558
- titleErr.innerHTML = text ? `${ICONS.alert}<span>${escapeHtml(text)}</span>` : '';
1559
- titleInput.classList.toggle('invalid', Boolean(text));
1560
- titleInput.setAttribute('aria-invalid', String(Boolean(text)));
1561
- }
1562
- titleInput.addEventListener('input', () => { if (titleInput.value.trim()) flagTitle(''); });
1563
-
1564
- function refreshSendState() {
1565
- const busy = items.some((it) => it.status === 'uploading');
1566
- sendBtn.disabled = sending || busy || items.some((it) => it.status === 'failed');
1567
- note.textContent = busy ? 'Waiting for the upload to finish…' : items.some((it) => it.status === 'failed') ? 'Retry or remove failed attachments before sending.' : '';
1568
- const max = cfg.maxAttachments ?? 5;
1569
- scrim.querySelectorAll('.tool').forEach((t) => {
1570
- t.disabled = sending || items.length >= max;
1571
- });
1572
- const count = $('.field-head .count');
1573
- if (count) count.textContent = items.length ? `${items.length} of ${max}` : '';
1574
- }
1575
-
1576
- function drawThumb(item) {
1577
- const el = item.el;
1578
- const label = item.kind === 'VIDEO' ? 'Video' : 'Shot';
1579
- const preview = item.kind === 'VIDEO'
1580
- ? `<video src="${item.previewUrl}" muted playsinline preload="metadata"></video>`
1581
- : `<img src="${item.previewUrl}" alt="" />`;
1582
-
1583
- let veil = '';
1584
- if (item.status === 'uploading') {
1585
- veil = `<div class="veil"><div class="spinner"></div><div class="bar"><i style="width:${Math.round(item.progress * 100)}%"></i></div></div>`;
1586
- } else if (item.status === 'failed') {
1587
- veil = `<div class="veil err"><button type="button" data-act="retry" aria-label="Retry upload">${ICONS.retry}</button>Failed</div>`;
1588
- }
1589
-
1590
- el.innerHTML = `
1591
- ${preview}
1592
- <span class="kind">${label} · ${humanSize(item.size)}</span>
1593
- <button class="drop" type="button" data-act="drop" aria-label="Remove ${label.toLowerCase()}">${ICONS.close}</button>
1594
- ${veil}`;
1595
-
1596
- el.querySelector('[data-act="drop"]').addEventListener('click', () => removeItem(item));
1597
- el.querySelector('[data-act="retry"]')?.addEventListener('click', () => upload(item));
1598
- }
1599
-
1600
- function removeItem(item) {
1601
- const i = items.indexOf(item);
1602
- if (i >= 0) items.splice(i, 1);
1603
- if (item.previewUrl) URL.revokeObjectURL(item.previewUrl);
1604
- item.el.remove();
1605
- void persistDraft();
1606
- refreshSendState();
1607
- }
1608
-
1609
- async function upload(item) {
1610
- item.status = 'uploading';
1611
- item.progress = 0;
1612
- drawThumb(item);
1613
- refreshSendState();
1614
- try {
1615
- const { storageKey, mime } = await client.uploadAttachment(item.blob, item.mime, (p) => {
1616
- item.progress = p;
1617
- // Only the width changes while uploading — repainting the whole tile
1618
- // would restart the entrance animation on every progress event.
1619
- const fill = item.el.querySelector('.bar i');
1620
- if (fill) fill.style.width = `${Math.round(p * 100)}%`;
1621
- });
1622
- item.storageKey = storageKey;
1623
- item.mime = mime;
1624
- item.status = 'ready';
1625
- } catch (err) {
1626
- item.status = 'failed';
1627
- say(err.message.replace('[BugCatch] ', ''));
1628
- }
1629
- drawThumb(item);
1630
- refreshSendState();
1631
- }
1632
-
1633
- function addItem({ blob, mime, kind }) {
1634
- const max = cfg.maxAttachmentBytes ?? 25 * 1024 * 1024;
1635
- if (blob.size > max) {
1636
- say(`That file is ${humanSize(blob.size)} — the limit is ${humanSize(max)}.`);
1637
- return;
1638
- }
1639
- // Held to the report set, not the full upload set. A report's attachments
1640
- // are typed SCREENSHOT or VIDEO, so a document accepted here would be filed
1641
- // as a screenshot and render as a broken image. Documents and voice notes
1642
- // go on a message instead, where they keep their own type.
1643
- const normalized = normalizeMime(mime || blob.type, REPORT_MIMES);
1644
- if (!normalized) {
1645
- say('Only PNG, JPEG, WebP, WebM and MP4 files can be attached to a report.');
1646
- return;
1647
- }
1648
- const item = {
1649
- blob, kind, mime: normalized, size: blob.size,
1650
- previewUrl: URL.createObjectURL(blob),
1651
- status: 'uploading', progress: 0, storageKey: null,
1652
- el: document.createElement('div'),
408
+ function openPanelWhenLoaded() {
409
+ hideLoadFailure();
410
+ // The panel says it again, at the top, with the rest of what is live.
411
+ hideNotice();
412
+ let panel = null;
413
+ let cancelled = false;
414
+ const cancel = () => {
415
+ cancelled = true;
416
+ launcher.removeAttribute('aria-busy');
417
+ open = null;
418
+ };
419
+ const pending = {
420
+ close: () => (panel ? panel.close() : cancel()),
421
+ destroy: () => (panel ? panel.destroy() : cancel()),
1653
422
  };
1654
- item.el.className = 'thumb';
1655
- items.push(item);
1656
- thumbs.appendChild(item.el);
1657
- say('');
1658
- void persistDraft();
1659
- upload(item);
1660
- }
1661
-
1662
- /* Hide our own UI so the capture shows the page, not this dialog. */
1663
- async function withWidgetHidden(fn) {
1664
- host.style.opacity = '0';
1665
- host.style.pointerEvents = 'none';
1666
- // One frame so the browser has actually painted the hidden state before
1667
- // the picker — and the frame we grab — is taken.
1668
- await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
1669
- try {
1670
- return await fn();
1671
- } finally {
1672
- host.style.opacity = '';
1673
- host.style.pointerEvents = '';
1674
- }
1675
- }
1676
-
1677
- scrim.querySelector('[data-act="shot"]')?.addEventListener('click', async () => {
1678
- try {
1679
- const shot = await withWidgetHidden(captureScreenshot);
1680
- addItem(shot);
1681
- } catch (err) {
1682
- // NotAllowedError is the user dismissing the picker — not a failure.
1683
- if (err?.name !== 'NotAllowedError') say(`Screenshot failed: ${err.message}`);
1684
- }
1685
- });
1686
423
 
1687
- scrim.querySelector('[data-act="file"]')?.addEventListener('click', () => filePicker.click());
1688
- filePicker?.addEventListener('change', () => {
1689
- const room = (cfg.maxAttachments ?? 5) - items.length;
1690
- [...filePicker.files].slice(0, Math.max(room, 0)).forEach((file) => {
1691
- addItem({
1692
- blob: file,
1693
- mime: file.type,
1694
- kind: file.type.startsWith('video/') ? 'VIDEO' : 'SCREENSHOT',
424
+ launcher.setAttribute('aria-busy', 'true');
425
+ loadPanel(client.config.chunkBase).then(({ openPanel }) => {
426
+ if (cancelled) return;
427
+ launcher.removeAttribute('aria-busy');
428
+ dock.classList.add('over');
429
+ launcher.setAttribute('aria-expanded', 'true');
430
+ panel = openPanel({
431
+ root,
432
+ host,
433
+ dock,
434
+ client,
435
+ preloadCall,
436
+ onClose: () => {
437
+ open = null;
438
+ dock.classList.remove('over');
439
+ launcher.setAttribute('aria-expanded', 'false');
440
+ },
1695
441
  });
442
+ }, () => {
443
+ if (cancelled) return;
444
+ cancel();
445
+ showLoadFailure();
1696
446
  });
1697
- filePicker.value = '';
1698
- });
1699
-
1700
- /* ---------- recording ---------- */
1701
-
1702
- scrim.querySelector('[data-act="rec"]')?.addEventListener('click', async () => {
1703
- const maxMs = cfg.maxRecordingMs ?? 120_000;
1704
- let bar;
1705
- try {
1706
- recorder = await startRecording({
1707
- maxMs,
1708
- onTick: (s) => { if (bar) bar.querySelector('.time').textContent = humanDuration(s); },
1709
- onAutoStop: () => { if (recorder) stopRecording({ keep: true }); },
1710
- });
1711
- } catch (err) {
1712
- if (err?.name !== 'NotAllowedError') say(`Recording failed: ${err.message}`);
1713
- return;
1714
- }
1715
-
1716
- // Step out of the way for the duration: a recording of this dialog sitting
1717
- // over the page is not what anyone means by "record the bug".
1718
- scrim.style.display = 'none';
1719
- dock.hidden = true;
1720
-
1721
- bar = document.createElement('div');
1722
- bar.className = 'recbar';
1723
- bar.innerHTML = `
1724
- <span class="dot"></span>
1725
- <span class="time">0:00</span>
1726
- <span class="hint">Recording — up to ${humanDuration(Math.round(maxMs / 1000))}</span>
1727
- <button class="stop" type="button">Stop</button>
1728
- <button class="ghost" type="button">Discard</button>`;
1729
- bar.querySelector('.stop').addEventListener('click', () => stopRecording({ keep: true }));
1730
- bar.querySelector('.ghost').addEventListener('click', () => stopRecording({ keep: false }));
1731
- root.appendChild(bar);
1732
- bar.querySelector('.stop').focus();
1733
- });
1734
-
1735
- async function stopRecording({ keep }) {
1736
- const rec = recorder;
1737
- if (!rec) return;
1738
- recorder = null;
1739
- root.querySelector('.recbar')?.remove();
1740
- scrim.style.display = '';
1741
- dock.hidden = false;
1742
-
1743
- if (!keep) { rec.cancel(); titleInput.focus(); return; }
1744
- try {
1745
- const clip = await rec.stop();
1746
- if (clip.blob.size) addItem(clip);
1747
- else say('That recording came back empty — nothing was captured.');
1748
- } catch (err) {
1749
- say(`Recording failed: ${err.message}`);
1750
- }
1751
- titleInput.focus();
1752
- }
1753
-
1754
- /* ---------- which record is this about ---------- */
1755
-
1756
- // What this project asks for, and what the reporter has answered.
1757
- //
1758
- // The question is asked at Send and not before, which is the whole design of
1759
- // it. A form that opens with "loan number" above "what went wrong" tells
1760
- // somebody they have paperwork to do before they may complain, and most of
1761
- // them will not — while the same question, asked once, after they have said
1762
- // their piece, and only when what they said did not already contain the
1763
- // answer, is a reasonable thing to be asked. Most reporters never see it: the
1764
- // loan number was in the sentence.
1765
- let refConfig = { policy: 'off', fields: [] };
1766
- let refOpen = false;
1767
- let refChoice = null; // which field they are answering; null = not picked yet
1768
- let refValue = '';
1769
- let refError = '';
1770
- // What we read in their text but could not place for sure, offered as taps:
1771
- // [{ value, kinds: [{ key, label }] }]. `refMode` says why the card is open —
1772
- // 'confirm' when the text had two answers, 'suggest' when it had an unlabelled
1773
- // one, 'ask' when it had none.
1774
- let refCandidates = [];
1775
- let refMode = 'ask';
1776
- // Set once they have answered or explicitly skipped, so Send stops asking.
1777
- let refSettled = false;
1778
- // Set once we have said "that does not look right" about the current value.
1779
- // Its own flag rather than reusing refSettled, which was the first attempt
1780
- // and was a loop: the warning returns early, the next press re-validates the
1781
- // same unchanged value, warns again, and the report can never be sent. One
1782
- // warning per value, and then it is their call.
1783
- let refWarned = false;
1784
-
1785
- client.referenceFields()
1786
- .then((cfg) => { refConfig = cfg; showFound(); })
1787
- .catch(() => { /* off is the safe answer to every failure */ });
1788
-
1789
- /** What the text says: found (sure), conflicts (ask which), loose (offer). */
1790
- function readText() {
1791
- if (refConfig.policy === 'off' || !refConfig.fields.length) return { found: [], conflicts: [], loose: [] };
1792
- return readReferences(refConfig.fields, [titleInput.value, descInput.value]);
447
+ return pending;
1793
448
  }
1794
449
 
1795
- /** Sure enough to send without a question: something found, nothing contested. */
1796
- function textAnswers(read = readText()) {
1797
- return read.found.length > 0 && read.conflicts.length === 0;
1798
- }
1799
-
1800
- /** The answer to send, or an empty list. */
1801
- function answeredReference() {
1802
- if (!refChoice || !refValue.trim()) return [];
1803
- return [{ key: refChoice, value: refValue.trim().slice(0, 120) }];
1804
- }
1805
-
1806
- // A quiet line under the description while they type, so somebody who put
1807
- // their loan number in the sentence can see it was read and does not go
1808
- // looking for a box to type it into again.
1809
- const foundNote = document.createElement('p');
1810
- foundNote.className = 'ref-found';
1811
- foundNote.setAttribute('aria-live', 'polite');
1812
- foundNote.hidden = true;
1813
- descInput.closest('.field').after(foundNote);
1814
- let foundTimer;
1815
- function showFound() {
1816
- const read = readText();
1817
- if (textAnswers(read)) {
1818
- const list = read.found.slice(0, 2).map((r) => `${escapeHtml(r.label)} <b>${escapeHtml(shortId(r.value))}</b>`).join(', ');
1819
- foundNote.innerHTML = `${ICONS.check}<span>Found your ${list} — no need to add it again.</span>`;
1820
- foundNote.hidden = false;
1821
- // They have answered in the text; a card still asking would be not listening.
1822
- if (refOpen && !answeredReference().length) { refOpen = false; drawRefs(); }
1823
- } else {
1824
- foundNote.hidden = true;
1825
- foundNote.innerHTML = '';
1826
- }
1827
- }
1828
- const queueFound = () => { clearTimeout(foundTimer); foundTimer = setTimeout(showFound, 250); };
1829
- titleInput.addEventListener('input', queueFound);
1830
- descInput.addEventListener('input', queueFound);
450
+ /* ---------- fetched before it is asked for ---------- */
1831
451
 
1832
- /** A UUID is long; the chip shows its ends, the title shows all of it. */
1833
- function shortId(value) {
1834
- return value.length > 20 ? `${value.slice(0, 8)}…${value.slice(-4)}` : value;
452
+ // The first press should open the panel, not wait on the network for it. So
453
+ // its code is fetched once the page has finished loading and gone quiet —
454
+ // never competing with the host's own first paint — and again, a no-op by
455
+ // then, the moment a pointer, a finger or the keyboard reaches the button.
456
+ // A failure is quiet here: the press that follows retries, and says so.
457
+ const preload = () => { loadPanel(client.config.chunkBase).catch(() => {}); };
458
+ for (const evt of ['pointerenter', 'touchstart', 'focus']) {
459
+ launcher.addEventListener(evt, preload, { once: true, passive: true });
1835
460
  }
461
+ const cancelIdle = whenIdleAfterLoad(preload);
1836
462
 
1837
- function candidateFor(value) {
1838
- const v = value.trim().toLowerCase();
1839
- return refCandidates.find((c) => c.value.toLowerCase() === v) || null;
463
+ function preloadCall() {
464
+ loadCall(client.config.chunkBase).catch(() => {});
1840
465
  }
1841
466
 
1842
- /** Pick a candidate: its value, and its kind when only one kind fits. */
1843
- function takeCandidate(c) {
1844
- refValue = c.value;
1845
- if (c.kinds.length === 1) refChoice = c.kinds[0].key;
1846
- else if (!c.kinds.some((k) => k.key === refChoice)) refChoice = null;
1847
- refError = '';
1848
- refWarned = false;
1849
- }
467
+ /* ---------- when the panel's code would not come ---------- */
1850
468
 
1851
- function drawRefs() {
1852
- if (!refOpen) { refsBox.hidden = true; refsBox.innerHTML = ''; return; }
1853
- const field = refConfig.fields.find((f) => f.key === refChoice) || null;
1854
- const optional = refConfig.policy !== 'required';
1855
- const current = candidateFor(refValue);
1856
- // Kinds worth offering: the ones the picked value could be, when we know;
1857
- // every kind otherwise. Never zero — a guess of ours must not lock out the
1858
- // right answer.
1859
- const kinds = current && current.kinds.length > 1
1860
- ? refConfig.fields.filter((f) => current.kinds.some((k) => k.key === f.key))
1861
- : refConfig.fields;
1862
- // Two loan numbers: the kind is not in question, only which one — so the
1863
- // kind chips would be a second question nobody needs to answer.
1864
- const sharedKind = refCandidates.length > 1
1865
- && refCandidates.every((c) => c.kinds.length === 1 && c.kinds[0].key === refCandidates[0].kinds[0].key);
1866
-
1867
- const head = {
1868
- confirm: refCandidates.length > 1
1869
- ? ['Which one is this about?', 'We found more than one ID in your message. Pick the one this problem is about.']
1870
- : ['Is this the right ID?', 'This could be more than one kind of ID. Tell us which it is.'],
1871
- suggest: ['Is this your ID?', 'We spotted this in your message. Tell us what it is so we can find your record.'],
1872
- ask: optional
1873
- ? ['One more thing', 'So we can find your record — this helps us answer faster.']
1874
- : ['We need one detail', 'We need one of these to look this up.'],
1875
- }[refMode];
1876
-
1877
- refsBox.hidden = false;
1878
- // A handful of kinds read best as chips to tap; a long list goes back to
1879
- // a select so the card does not turn into a wall of pills.
1880
- const many = kinds.length > 6;
1881
- refsBox.innerHTML = `
1882
- <div class="ask-head">
1883
- <span class="avatar" aria-hidden="true">${ICONS.sparkle}</span>
1884
- <div>
1885
- <strong>${escapeHtml(head[0])}</strong>
1886
- <p class="why">${escapeHtml(head[1])}</p>
1887
- </div>
1888
- </div>
1889
- ${refCandidates.length > 1 ? `
1890
- <div class="chips cands" role="group" aria-label="IDs found in your message">
1891
- ${refCandidates.map((c, i) => `
1892
- <button type="button" data-ref-cand="${i}" title="${escapeHtml(c.value)}"
1893
- aria-pressed="${!!current && current === c}">${escapeHtml(shortId(c.value))}</button>
1894
- `).join('')}
1895
- </div>` : ''}
1896
- ${kinds.length > 1 && !many && !sharedKind ? `
1897
- <div class="chips" role="group" aria-label="Which detail you are giving">
1898
- ${kinds.map((f) => `
1899
- <button type="button" data-ref-kind="${escapeHtml(f.key)}" aria-pressed="${!!field && f.key === field.key}">${escapeHtml(f.label)}</button>
1900
- `).join('')}
1901
- </div>` : ''}
1902
- <div class="row">
1903
- ${many ? `
1904
- <select class="ref-kind" aria-label="Which detail you are giving">
1905
- ${field ? '' : '<option value="" selected disabled>What is it?</option>'}
1906
- ${kinds.map((f) => `
1907
- <option value="${escapeHtml(f.key)}" ${field && f.key === field.key ? 'selected' : ''}>${escapeHtml(f.label)}</option>
1908
- `).join('')}
1909
- </select>` : ''}
1910
- <input class="ref-value${refError ? ' invalid' : ''}" type="${field?.inputMode === 'email' ? 'email' : 'text'}"
1911
- inputmode="${escapeHtml(field?.inputMode === 'numeric' || field?.inputMode === 'tel' ? 'numeric' : 'text')}"
1912
- value="${escapeHtml(refValue)}"
1913
- placeholder="${escapeHtml(field ? (field.placeholder || field.label) : 'Your ID')}"
1914
- aria-label="${escapeHtml(field ? field.label : 'Your ID')}" ${refError ? 'aria-invalid="true"' : ''} />
1915
- </div>
1916
- ${field?.hint ? `<p class="hint">${escapeHtml(field.hint)}</p>` : ''}
1917
- ${refError ? `<p class="field-err" role="alert">${ICONS.alert}<span>${escapeHtml(refError)}</span></p>` : ''}
1918
- ${optional ? `<button type="button" class="link" data-act="ref-skip">${
1919
- refMode === 'ask' ? 'I don\'t have this — send without it' : 'Not sure — send as it is'}</button>` : ''}`;
1920
-
1921
- refsBox.querySelectorAll('[data-ref-cand]').forEach((chip) => {
1922
- chip.addEventListener('click', () => {
1923
- takeCandidate(refCandidates[Number(chip.dataset.refCand)]);
1924
- drawRefs();
1925
- });
1926
- });
1927
- refsBox.querySelectorAll('[data-ref-kind]').forEach((chip) => {
1928
- chip.addEventListener('click', () => {
1929
- refChoice = chip.dataset.refKind;
1930
- refError = '';
1931
- refWarned = false;
1932
- drawRefs();
1933
- });
1934
- });
1935
- const value = refsBox.querySelector('.ref-value');
1936
- value.addEventListener('input', () => {
1937
- refValue = value.value;
1938
- refError = '';
1939
- // A changed value has not been warned about yet.
1940
- refWarned = false;
1941
- });
1942
- // Enter in the box is the same gesture as pressing Send, which is what
1943
- // somebody who has just been asked one question expects it to be.
1944
- value.addEventListener('keydown', (e) => {
1945
- if (e.key === 'Enter') { e.preventDefault(); sendBtn.click(); }
469
+ let failure = null;
470
+ function showLoadFailure() {
471
+ hideLoadFailure();
472
+ failure = document.createElement('div');
473
+ failure.className = 'msg err loadfail';
474
+ failure.setAttribute('role', 'alert');
475
+ failure.innerHTML = `${ICONS.alert}<span>The report form didn't load. Check your connection and try again.</span>`
476
+ + '<button type="button" data-act="retry">Try again</button>'
477
+ + `<button type="button" data-act="dismiss" aria-label="Dismiss">${ICONS.close}</button>`;
478
+ failure.querySelector('[data-act="retry"]').addEventListener('click', () => {
479
+ // Focus to the launcher first: the button pressed is about to go.
480
+ launcher.focus();
481
+ if (!open) open = openPanelWhenLoaded();
1946
482
  });
1947
- refsBox.querySelector('.ref-kind')?.addEventListener('change', (e) => {
1948
- refChoice = e.target.value;
1949
- refError = '';
1950
- drawRefs();
1951
- refsBox.querySelector('.ref-value')?.focus();
483
+ failure.querySelector('[data-act="dismiss"]').addEventListener('click', () => {
484
+ launcher.focus();
485
+ hideLoadFailure();
1952
486
  });
1953
- refsBox.querySelector('[data-act="ref-skip"]')?.addEventListener('click', () => {
1954
- // Skipping is answering. Without this the panel asks again on the next
1955
- // press and the reporter is in a loop with a button that does nothing.
1956
- // The text still goes; the server reads it either way.
1957
- refSettled = true;
1958
- refOpen = false;
1959
- refChoice = null;
1960
- refValue = '';
1961
- refError = '';
1962
- refWarned = false;
1963
- drawRefs();
1964
- say('');
1965
- });
1966
-
1967
- // Focus where the next decision is: the kind, when that is all that is
1968
- // missing; the box otherwise.
1969
- const next = !field && refValue ? refsBox.querySelector('[data-ref-kind], .ref-kind') : null;
1970
- (next || value).focus();
487
+ // First in the dock, so it sits furthest from the corner: above the theme
488
+ // switch in a bottom dock, below it in a top one.
489
+ dock.prepend(failure);
1971
490
  }
1972
491
 
1973
- /**
1974
- * Open the question, pre-filled with the best of what the text offered.
1975
- *
1976
- * Conflicts first — the text said two things and only the reporter knows
1977
- * which — then loose IDs, which are probably the answer and only need a
1978
- * name. Nothing, and it is the plain question it always was.
1979
- */
1980
- function openRefs(read) {
1981
- refCandidates = read.conflicts.length ? read.conflicts : read.loose.slice(0, 4);
1982
- refMode = read.conflicts.length ? 'confirm' : read.loose.length ? 'suggest' : 'ask';
1983
- refOpen = true;
1984
- if (refCandidates.length) {
1985
- // One contested value: pre-fill it so they only choose what it is. Two
1986
- // or more: pick nothing, because picking is the question.
1987
- if (refCandidates.length === 1) takeCandidate(refCandidates[0]);
1988
- else {
1989
- refValue = '';
1990
- const [first] = refCandidates;
1991
- const same = refCandidates.every((c) => c.kinds.length === 1 && c.kinds[0].key === first.kinds[0].key);
1992
- refChoice = same ? first.kinds[0].key : null;
1993
- }
1994
- } else {
1995
- refChoice = refChoice || refConfig.fields[0].key;
1996
- }
1997
- drawRefs();
492
+ function hideLoadFailure() {
493
+ failure?.remove();
494
+ failure = null;
1998
495
  }
1999
496
 
2000
- /**
2001
- * Whether Send should stop and ask.
2002
- *
2003
- * Never when the text answered it outright. Once when the text said two
2004
- * things or something unlabelled — on every policy, because sending the
2005
- * wrong record's id with confidence is worse than one extra tap. And the
2006
- * plain question on `optional`/`required` projects as before.
2007
- */
2008
- function needsReference() {
2009
- if (refConfig.policy === 'off' || !refConfig.fields.length) return false;
2010
- if (answeredReference().length) return false;
2011
- const read = readText();
2012
- if (textAnswers(read)) return false;
2013
- if (refSettled && refConfig.policy !== 'required') return false;
2014
- return read;
2015
- }
2016
-
2017
- /* ---------- send ---------- */
2018
497
 
2019
- sendBtn.addEventListener('click', async () => {
2020
- const title = titleInput.value;
2021
- if (!title.trim()) {
2022
- flagTitle('Add a few words about what went wrong — a short sentence is plenty.');
2023
- titleInput.focus();
2024
- return;
2025
- }
498
+ /* ---------- what the project is telling visitors ---------- */
2026
499
 
2027
- // Ask, if this project asks and nothing they wrote answered it. The first
2028
- // press opens the question rather than sending; the second sends. That
2029
- // costs one extra press on projects that want an identifier, and it is the
2030
- // only shape that does not put paperwork in front of a person who came here
2031
- // to say something is broken.
2032
- //
2033
- // First, though: a value in the box but no kind picked. Asked rather than
2034
- // guessed — a wrong kind joins on the wrong table.
2035
- if (refOpen && refValue.trim() && !refChoice) {
2036
- refError = 'Pick what this ID is.';
2037
- drawRefs();
2038
- return;
2039
- }
2040
-
2041
- const read = needsReference();
2042
- if (read) {
2043
- if (!refOpen) openRefs(read); else drawRefs();
2044
- say('');
2045
- return;
2046
- }
2047
-
2048
- // They chose a field and typed something that is not that shape. Advisory
2049
- // on the server — a value that fails is still what they believe identifies
2050
- // them, and it is kept — but worth one chance to fix a typo here, where the
2051
- // number is still on screen.
2052
- if (refChoice && refValue.trim()) {
2053
- const field = refConfig.fields.find((f) => f.key === refChoice);
2054
- if (!refWarned && field && !validateReference(field, refValue)) {
2055
- refError = `That does not look like a ${field.label.toLowerCase()}. Press Send again to use it anyway.`;
2056
- // Once, and then it is their call. Our idea of the shape of somebody's
2057
- // account number is not better than theirs, and refusing outright is
2058
- // how a genuine reference gets lost — so the second press sends it and
2059
- // the API keeps it, marked as not matching the expected format.
2060
- refWarned = true;
2061
- drawRefs();
2062
- return;
2063
- }
2064
- }
2065
-
2066
- sending = true;
2067
- say('');
2068
- refreshSendState();
2069
- const original = sendBtn.innerHTML;
2070
- sendBtn.innerHTML = '<span class="spinner" aria-hidden="true"></span>Sending…';
2071
-
2072
- try {
2073
- const res = await client.report({
2074
- title,
2075
- description: descInput.value,
2076
- priority,
2077
- references: answeredReference(),
2078
- attachments: items
2079
- .filter((it) => it.status === 'ready')
2080
- .map((it) => ({ type: it.kind, storageKey: it.storageKey, mime: it.mime })),
2081
- });
2082
- if (res?.cancelled) { sending = false; sendBtn.innerHTML = original; refreshSendState(); return; }
2083
- draftSubmitted = true;
2084
- clearTimeout(draftTimer);
2085
- await draftWrites;
2086
- if (cfg.drafts !== false) await draftOperation(savedDraftKey, null).catch(() => {});
2087
- succeed(res);
2088
- } catch (err) {
2089
- sending = false;
2090
- sendBtn.innerHTML = original;
2091
- say(err.message.replace('[BugCatch] ', ''));
2092
- refreshSendState();
2093
- // One reason a send fails is that this browser was signed out while the
2094
- // panel sat open — the same address signed in elsewhere. The message
2095
- // above already says so; this puts the panel back in step with it
2096
- // instead of leaving a Send button that will fail the same way again.
2097
- if (login.mode !== 'off') refreshLogin().catch(() => {});
2098
- }
2099
- });
2100
-
2101
- scrim.querySelector('[data-act="history"]')?.addEventListener('click', showHistory);
2102
-
2103
- /* ---------- what is already known ---------- */
2104
-
2105
- // The list as it was last fetched, and who has said what about it. Held here
2106
- // rather than re-read from the DOM so a re-render after a join does not have
2107
- // to parse its own markup back out.
2108
- let issues = [];
2109
- let issueError = '';
2110
- let askingFor = null;
2111
- let matchAbort = null;
2112
-
2113
- // Asked once when the dialog opens, like the call button above and for the
2114
- // same reason: it is one request per opened widget rather than one per page
2115
- // load of every site running this SDK, and most projects will answer with an
2116
- // empty list forever.
2117
- function loadKnownIssues() {
2118
- if (cfg.knownIssues === false) return;
2119
- client.knownIssues()
2120
- .then((rows) => { issues = rows; drawKnown(); })
2121
- .catch(() => { /* a help panel that cannot load is simply not drawn */ });
2122
- }
2123
-
2124
- // Re-run as the title is typed, so the panel narrows to what they are
2125
- // actually describing. Debounced, and the previous request is aborted rather
2126
- // than left to land: a fast typist otherwise renders whichever response comes
2127
- // back last, which is not necessarily the one matching the box.
2128
- let matchTimer = null;
2129
- function matchKnown(query) {
2130
- if (cfg.knownIssues === false) return;
2131
- clearTimeout(matchTimer);
2132
- matchAbort?.abort();
2133
- const q = (query || '').trim();
2134
- if (q.length < 3) { loadKnownIssues(); return; }
2135
- matchTimer = setTimeout(() => {
2136
- matchAbort = new AbortController();
2137
- client.matchKnownIssues(q, { signal: matchAbort.signal })
2138
- .then((rows) => {
2139
- // An empty result means "nothing like this is known", which is not
2140
- // the same as "nothing is known" — fall back to the full list rather
2141
- // than blanking a panel the reporter was reading.
2142
- if (rows.length) { issues = rows; drawKnown(); }
2143
- })
2144
- .catch(() => {});
2145
- }, 250);
2146
- }
2147
-
2148
- function drawKnown() {
2149
- if (!issues.length) { known.hidden = true; known.innerHTML = ''; return; }
2150
- known.hidden = false;
2151
- known.innerHTML = `
2152
- <p class="known-h">Already reported</p>
2153
- ${issues.map((issue) => `
2154
- <div class="issue ${issue.joined ? 'is-joined' : ''}">
2155
- <span class="who">
2156
- <span class="t">${escapeHtml(issue.title)}</span>
2157
- <span class="meta">${escapeHtml(knownMeta(issue))}</span>
2158
- </span>
2159
- <button class="join" type="button" data-join="${escapeHtml(issue.id)}"
2160
- ${issue.joined ? 'disabled' : ''}>${issue.joined ? 'Following' : 'Me too'}</button>
2161
- </div>
2162
- ${askingFor === issue.id ? `
2163
- <div class="ask">
2164
- <input type="email" class="join-email" placeholder="you@example.com"
2165
- aria-label="Your email, so we can tell you when this is fixed" />
2166
- <button class="ghost" type="button" data-join-confirm="${escapeHtml(issue.id)}">Follow</button>
2167
- </div>` : ''}
2168
- `).join('')}
2169
- ${issueError ? `<p class="err">${escapeHtml(issueError)}</p>` : ''}`;
2170
- }
2171
-
2172
- // What a reporter is told about an issue beside its title. Deliberately not
2173
- // its severity: how bad the team thinks this is, published to everybody who
2174
- // opens the widget, is an internal judgement that reads as a promise.
2175
- function knownMeta(issue) {
2176
- const state = issue.status === 'IN_PROGRESS' ? 'Being worked on now' : 'Not fixed yet';
2177
- // The count is dropped entirely at zero rather than written as "0 people",
2178
- // or as a bare "Reported" that says nothing the heading above has not
2179
- // already said. Nobody having pressed the button yet is not a fact about
2180
- // this issue worth a reporter's attention.
2181
- const others = issue.affectedCount === 0
2182
- ? ''
2183
- : ` · ${issue.affectedCount} ${issue.affectedCount === 1 ? 'person has' : 'people have'} said this is happening to them`;
2184
- return `${state} · reported ${relativeDay(issue.since)}${others}`;
2185
- }
2186
-
2187
- known.addEventListener('click', async (e) => {
2188
- const start = e.target.closest('[data-join]');
2189
- const confirm = e.target.closest('[data-join-confirm]');
2190
- if (!start && !confirm) return;
2191
-
2192
- const bugId = (confirm || start).getAttribute(confirm ? 'data-join-confirm' : 'data-join');
2193
- issueError = '';
2194
-
2195
- // An address is what joining buys them — being told when it is fixed — so
2196
- // ask for one when neither a sign-in nor identify() has supplied it, rather
2197
- // than recording somebody nobody can ever write to.
2198
- const email = known.querySelector('.join-email')?.value?.trim()
2199
- || client.signedInAs()?.email
2200
- || client.config.user?.email;
2201
- if (!email) {
2202
- askingFor = bugId;
2203
- drawKnown();
2204
- known.querySelector('.join-email')?.focus();
2205
- return;
2206
- }
2207
-
2208
- try {
2209
- const result = await client.meToo(bugId, { email });
2210
- askingFor = null;
2211
- issues = issues.map((issue) => (issue.id === bugId
2212
- ? { ...issue, joined: true, affectedCount: result.affectedCount ?? issue.affectedCount }
2213
- : issue));
2214
- drawKnown();
2215
- } catch (err) {
2216
- issueError = err.message.replace('[BugCatch] ', '');
2217
- drawKnown();
2218
- }
2219
- });
2220
-
2221
- loadKnownIssues();
2222
- titleInput.addEventListener('input', () => matchKnown(titleInput.value));
2223
-
2224
- /* ---------- talk to the team ---------- */
2225
-
2226
- // Asked once, when the dialog opens, and the button stays hidden unless the
2227
- // answer is yes. Doing it here rather than at init keeps one request off the
2228
- // page load of every site this SDK is on, for a button most of them will
2229
- // never show.
2230
- //
2231
- // The wording is fixed because there is one kind of call: it rings a room and
2232
- // may go unanswered. A button that said "talk to us" would have somebody
2233
- // expecting an instant answer sit through twenty seconds of ringing.
2234
- let callOffered = false;
2235
- function offerCall() {
2236
- client.callKind()
2237
- .then((kind) => {
2238
- if (!kind) return;
2239
- callOffered = true;
2240
- // Never over a panel that is still asking who this is: the call would
2241
- // file a report, and on a project that requires sign-in that is the one
2242
- // thing this panel is holding back.
2243
- callBtn.hidden = mustSignIn();
2244
- })
2245
- .catch(() => { /* not available is the safe answer to every failure */ });
2246
- }
2247
- offerCall();
2248
-
2249
- /* ---------- who is reporting ---------- */
2250
-
2251
- // Three states, and the project picks which: 'off' is every project that has
2252
- // not asked for this and draws nothing at all; 'optional' offers a sign-in
2253
- // above the form that the reporter may ignore; 'required' replaces the form
2254
- // with it until they have.
2255
- //
2256
- // Asked on every open rather than remembered for the page, because the answer
2257
- // includes whether this browser's session is still live — and it can end
2258
- // somewhere else, when the same address signs in on another device. A widget
2259
- // holding a cached "signed in as you" would find out at the moment the report
2260
- // failed, which is the worst moment to discover it.
2261
- let login = { mode: client.lastLoginMode || 'off', signedIn: null };
2262
- // 'idle' — showing who they are; 'email' / 'code' — the two steps of getting there.
2263
- let step = 'idle';
2264
- let pendingEmail = client.config.user?.email || '';
2265
- let pendingCode = '';
2266
- let signInBusy = false;
2267
- let signInError = '';
2268
-
2269
- const mustSignIn = () => login.mode === 'required' && !login.signedIn;
2270
-
2271
- /** Show or hide everything that only makes sense once we know who is filing. */
2272
- function applyGate() {
2273
- const shut = mustSignIn();
2274
- compose.hidden = shut;
2275
- sendBtn.hidden = shut;
2276
- if (historyBtn) historyBtn.hidden = shut;
2277
- // The call offer is the same errand as the form, so it goes with it — and
2278
- // comes back only if the API had already said this project can take one.
2279
- if (shut) callBtn.hidden = true;
2280
- else if (callOffered) callBtn.hidden = false;
2281
- }
2282
-
2283
- function drawWhoami() {
2284
- if (login.mode === 'off') {
2285
- whoami.hidden = true;
2286
- whoami.innerHTML = '';
2287
- return;
2288
- }
2289
- whoami.hidden = false;
2290
- whoami.classList.toggle('asking', step !== 'idle');
2291
-
2292
- const problem = signInError ? `<div class="after err">${escapeHtml(signInError)}</div>` : '';
2293
-
2294
- if (step === 'idle') {
2295
- if (login.signedIn) {
2296
- whoami.innerHTML = `<span class="who">Signed in as <strong>${escapeHtml(login.signedIn.email)}</strong></span>`
2297
- + '<button type="button" class="link" data-act="signout">Sign out</button>';
2298
- return;
2299
- }
2300
- // An address the host application passed for its own signed-in user is
2301
- // not nothing — the report will carry it — so saying "anonymously" over
2302
- // the top of one would be untrue. It is simply not proved, which is what
2303
- // the offer beside it is for.
2304
- const claimed = client.config.user?.email;
2305
- whoami.innerHTML = `<span class="who">${login.mode === 'required'
2306
- ? 'Sign in to report a bug here.'
2307
- : claimed
2308
- ? `Reporting as <strong>${escapeHtml(claimed)}</strong>.`
2309
- : 'Reporting anonymously — sign in and we can tell you what happens next.'}</span>`
2310
- + '<button type="button" class="link" data-act="signin">Sign in</button>';
2311
- return;
2312
- }
2313
-
2314
- if (step === 'email') {
2315
- whoami.innerHTML = `
2316
- <p class="why">Sign in with any email address — we'll send you a code.
2317
- It's how you hear back about this report${login.mode === 'required' ? ', and this project asks for it' : ''}.</p>
2318
- <div class="row">
2319
- <input type="email" data-fld="email" value="${escapeHtml(pendingEmail)}"
2320
- placeholder="you@example.com" autocomplete="email" spellcheck="false"
2321
- aria-label="Your email address" ${signInBusy ? 'disabled' : ''} />
2322
- <button type="button" class="primary" data-act="code" ${signInBusy ? 'disabled' : ''}>
2323
- ${signInBusy ? 'Sending…' : 'Send code'}
2324
- </button>
2325
- </div>
2326
- ${problem}
2327
- ${login.mode === 'optional'
2328
- ? '<div class="after"><button type="button" class="link" data-act="skip">Not now</button></div>'
2329
- : ''}`;
2330
- return;
2331
- }
2332
-
2333
- whoami.innerHTML = `
2334
- <p class="why">We sent a code to <strong>${escapeHtml(pendingEmail)}</strong>. It expires in ten minutes.</p>
2335
- <div class="row">
2336
- <input type="text" class="code" data-fld="code" value="${escapeHtml(pendingCode)}"
2337
- placeholder="123456" inputmode="numeric" autocomplete="one-time-code"
2338
- maxlength="8" aria-label="The code we emailed you" ${signInBusy ? 'disabled' : ''} />
2339
- <button type="button" class="primary" data-act="confirm" ${signInBusy ? 'disabled' : ''}>
2340
- ${signInBusy ? 'Checking…' : 'Sign in'}
2341
- </button>
2342
- </div>
2343
- ${problem}
2344
- <div class="after">
2345
- <button type="button" class="link" data-act="resend" ${signInBusy ? 'disabled' : ''}>Send it again</button>
2346
- <button type="button" class="link" data-act="signin-back" ${signInBusy ? 'disabled' : ''}>Use another address</button>
2347
- </div>`;
2348
- }
2349
-
2350
- /** Redraw, and put the cursor where the reporter is being asked to type. */
2351
- function drawSignIn({ focus = false } = {}) {
2352
- drawWhoami();
2353
- applyGate();
2354
- if (!focus || signInBusy) return;
2355
- const field = whoami.querySelector('[data-fld]');
2356
- if (field) { field.focus(); field.setSelectionRange?.(field.value.length, field.value.length); }
2357
- }
2358
-
2359
- // Typed values survive a redraw because they are held here, not read back off
2360
- // a node that the next render replaces.
2361
- whoami.addEventListener('input', (e) => {
2362
- const field = e.target.closest('[data-fld]');
2363
- if (!field) return;
2364
- if (field.dataset.fld === 'email') pendingEmail = field.value;
2365
- else pendingCode = field.value;
2366
- });
2367
-
2368
- whoami.addEventListener('keydown', (e) => {
2369
- if (e.key !== 'Enter' || !e.target.closest('[data-fld]')) return;
2370
- e.preventDefault();
2371
- if (step === 'email') requestCode();
2372
- else confirmCode();
2373
- });
2374
-
2375
- async function requestCode({ resent = false } = {}) {
2376
- const email = pendingEmail.trim();
2377
- if (!email) { signInError = 'Enter the address to send the code to.'; drawSignIn({ focus: true }); return; }
2378
-
2379
- signInBusy = true; signInError = ''; drawSignIn();
2380
- try {
2381
- await client.signIn(email);
2382
- pendingEmail = email;
2383
- if (!resent) pendingCode = '';
2384
- step = 'code';
2385
- } catch (err) {
2386
- signInError = err.message;
2387
- } finally {
2388
- signInBusy = false;
2389
- drawSignIn({ focus: true });
2390
- }
2391
- }
2392
-
2393
- async function confirmCode() {
2394
- const code = pendingCode.trim();
2395
- if (!code) { signInError = 'Enter the code we emailed you.'; drawSignIn({ focus: true }); return; }
2396
-
2397
- signInBusy = true; signInError = ''; drawSignIn();
2398
- try {
2399
- const who = await client.confirmSignIn(pendingEmail.trim(), code);
2400
- login.signedIn = who;
2401
- step = 'idle';
2402
- pendingCode = '';
2403
- say('');
2404
- // Signing in is what makes a call possible on most projects — there is
2405
- // now somebody to attribute it to — so the offer is asked for again
2406
- // rather than staying hidden until the panel is reopened.
2407
- offerCall();
2408
- } catch (err) {
2409
- signInError = err.message;
2410
- } finally {
2411
- signInBusy = false;
2412
- drawSignIn({ focus: step !== 'idle' });
2413
- }
2414
- }
2415
-
2416
- whoami.addEventListener('click', async (e) => {
2417
- const btn = e.target.closest('button[data-act]');
2418
- if (!btn) return;
2419
- const act = btn.dataset.act;
2420
-
2421
- if (act === 'signin') { step = 'email'; signInError = ''; drawSignIn({ focus: true }); return; }
2422
- if (act === 'skip') { step = 'idle'; signInError = ''; drawSignIn(); return; }
2423
- // Namespaced: the report screen has a back button of its own, and the two
2424
- // must not be one selector away from each other.
2425
- if (act === 'signin-back') { step = 'email'; signInError = ''; pendingCode = ''; drawSignIn({ focus: true }); return; }
2426
- if (act === 'code') { requestCode(); return; }
2427
- if (act === 'resend') { requestCode({ resent: true }); return; }
2428
- if (act === 'confirm') { confirmCode(); return; }
2429
- if (act === 'signout') {
2430
- signInBusy = true; drawSignIn();
2431
- try {
2432
- await client.signOut();
2433
- } finally {
2434
- login.signedIn = null;
2435
- signInBusy = false;
2436
- // On a project that requires it, signing out puts the form away again
2437
- // and asks — which is what applyGate does off the back of this.
2438
- step = login.mode === 'required' ? 'email' : 'idle';
2439
- drawSignIn({ focus: step !== 'idle' });
2440
- }
2441
- }
2442
- });
2443
-
2444
- /** Re-read the project's setting and this browser's session, and redraw. */
2445
- async function refreshLogin({ announce = false } = {}) {
2446
- const state = await client.loginState();
2447
- login = { mode: state.mode, signedIn: state.signedIn };
2448
- client.lastLoginMode = state.mode;
2449
- // Nothing to type on a project that requires sign-in and has nobody in it.
2450
- if (mustSignIn() && step === 'idle') step = 'email';
2451
- if (login.signedIn) step = 'idle';
2452
- if (announce && state.signedOutReason) say(state.signedOutReason, 'info');
2453
- drawSignIn();
2454
- }
2455
-
2456
- refreshLogin({ announce: true }).catch(() => { /* a widget that can't ask stays open */ });
2457
-
2458
- callBtn.addEventListener('click', async () => {
2459
- sending = true;
2460
- say('');
2461
- refreshSendState();
2462
- callBtn.disabled = true;
2463
- callBtn.innerHTML = '<span class="spinner"></span>Ringing…';
2464
-
2465
- try {
2466
- // No id: this files the report from what the page was doing and rings the
2467
- // team about it, in one round trip.
2468
- const handle = await client.startCall();
2469
- showCall(handle);
2470
- } catch (err) {
2471
- sending = false;
2472
- callBtn.disabled = false;
2473
- callBtn.innerHTML = `${ICONS.phone}Talk to the team`;
2474
- refreshSendState();
2475
- // A report filed with no call is still a report. Saying "the call
2476
- // failed" and nothing else would leave the reporter thinking their
2477
- // context went nowhere, and pressing Send again would file it twice.
2478
- if (err.filed) succeed(err.filed, { callFailed: err.reason || 'The call could not be started.' });
2479
- else {
2480
- say(err.message.replace('[BugCatch] ', ''));
2481
- // The same follow-up the Send button does, because a call can fail for
2482
- // the same reason a report can: this browser was signed out while the
2483
- // panel sat open. Without it the message says sign in while the row
2484
- // under it still names who signed in, which reads as a broken widget.
2485
- if (login.mode !== 'off') refreshLogin().catch(() => {});
2486
- }
2487
- }
2488
- });
2489
-
2490
- /**
2491
- * The call, once the team is being rung.
2492
- *
2493
- * Replaces the panel for the same reason the history view does: it is the
2494
- * same dialog answering a different question, and the reporter got here from
2495
- * one launcher. There is no way back to the form — the report has already
2496
- * been filed, and a Cancel that implied otherwise would be a lie.
2497
- */
2498
- function showCall(handle) {
2499
- const panel = scrim.querySelector('.panel');
2500
- const ref = handle?.bugId ? String(handle.bugId).slice(0, 8) : null;
2501
-
2502
- panel.innerHTML = `
2503
- <div class="call">
2504
- <div class="call-head">
2505
- <span class="call-dot" aria-hidden="true"></span>
2506
- <strong class="call-state">Ringing…</strong>
2507
- </div>
2508
- <p class="call-note">
2509
- We're ringing the team now. Someone will pick up if they're free — your report${
2510
- ref ? ` <code>${escapeHtml(ref)}</code>` : ''} is already filed either way, so nothing is lost if nobody answers.
2511
- </p>
2512
- <div class="foot">
2513
- <span class="spacer"></span>
2514
- <button class="primary" type="button" data-act="hangup">Cancel</button>
2515
- </div>
2516
- </div>`;
2517
-
2518
- const stateEl = panel.querySelector('.call-state');
2519
- const dot = panel.querySelector('.call-dot');
2520
-
2521
- // A call spends its first twenty seconds ringing, and can end by nobody
2522
- // picking up — which is not a failure and must not read as one.
2523
- const WORDING = {
2524
- ringing: 'Ringing the team…',
2525
- connecting: 'Someone answered — connecting…',
2526
- connected: 'Connected',
2527
- missed: 'Nobody was free',
2528
- ended: 'Call ended',
2529
- failed: 'The call dropped',
2530
- };
2531
-
2532
- // `missed` is over too, and is the ordinary way a live call ends when the
2533
- // team is busy. Left out of this list it would leave the dialog showing
2534
- // "Nobody was free" above a Cancel button for a call that has already
2535
- // stopped.
2536
- const OVER = ['ended', 'failed', 'missed'];
2537
-
2538
- const offState = client.on('call', ({ state }) => {
2539
- stateEl.textContent = WORDING[state] || state;
2540
- dot.classList.toggle('off', OVER.includes(state));
2541
- if (OVER.includes(state)) {
2542
- panel.querySelector('[data-act="hangup"]').textContent = 'Close';
2543
- if (state === 'missed') {
2544
- // Said plainly, and said as the non-event it is. The report is filed
2545
- // and the thread already says the call went unanswered, so the one
2546
- // thing left to tell them is that they have not lost anything.
2547
- const note = panel.querySelector('.call-note');
2548
- if (note) {
2549
- note.textContent = 'Nobody was free to pick up just now. Your report is filed '
2550
- + 'and the team will reply in the chat.';
2551
- }
2552
- }
2553
- }
2554
- });
2555
-
2556
- panel.querySelector('[data-act="hangup"]').addEventListener('click', () => {
2557
- offState();
2558
- client.endCall();
2559
- close();
2560
- });
2561
- }
2562
-
2563
- /**
2564
- * The confirmation, and the one answer somebody actually wants from it.
2565
- *
2566
- * "How long will this take" is the first thing anybody thinks after reporting
2567
- * a bug, and until now the answer was nothing at all — so the honest reading
2568
- * of a confirmation screen was "into the void". The API computes the figure
2569
- * on the same request that files the report, precisely so it can be said
2570
- * here: an estimate that needed a second call would arrive after this panel
2571
- * had closed itself.
2572
- *
2573
- * Absent whenever the project has decided not to tell reporters, or has
2574
- * nothing to base a figure on. Nothing is drawn in that case — no "unknown",
2575
- * no placeholder — because a confirmation that admits it cannot answer is
2576
- * worse than one that does not raise the question.
2577
- *
2578
- * The dwell is longer when there is something to read. Two seconds is right
2579
- * for one line and is not enough for two, and a panel that closes over the
2580
- * sentence somebody started reading is the reason they open it again.
2581
- */
2582
- function succeed(res, { callFailed = null } = {}) {
2583
- // The assistant: straight into the report's conversation, where the
2584
- // investigation shows itself working ("Analyzing the issue…") and asks
2585
- // what it needs. The plain "Thanks" below stays for when that view cannot
2586
- // load — a report filed without a way to follow it.
2587
- if (res?.id && !callFailed) {
2588
- showReport(res.id, { fresh: true }).then((ok) => { if (!ok) doneScreen(res, { callFailed }); });
2589
- return;
2590
- }
2591
- doneScreen(res, { callFailed });
2592
- }
2593
-
2594
- function doneScreen(res, { callFailed = null } = {}) {
2595
- const ref = res?.id ? String(res.id).slice(0, 8) : null;
2596
- const eta = res?.estimate?.label ? String(res.estimate.label) : null;
2597
- // A press of Call that filed the report and then could not ring anybody.
2598
- // Shown as a plain "Thanks" it read as a call that had quietly worked, and
2599
- // the reporter sat waiting for a phone that was never going to ring.
2600
- const failed = callFailed
2601
- ? `<p class="eta">${escapeHtml(callFailed)} Your report is filed, and the team will reply in the chat.</p>`
2602
- : '';
2603
- scrim.querySelector('.panel').innerHTML = `
2604
- <div class="done">
2605
- <div class="tick">${ICONS.check}</div>
2606
- <h3>Thanks — that's on its way.</h3>
2607
- <p>${ref ? `Filed as <code>${escapeHtml(ref)}</code>.` : 'The team has what they need to reproduce it.'}</p>
2608
- ${failed}
2609
- ${eta ? `<p class="eta">Reports like this usually take <strong>${escapeHtml(eta)}</strong>. We'll let you know when it moves.</p>` : ''}
2610
- </div>`;
2611
- setTimeout(close, failed ? 6000 : eta ? 4000 : 2000);
2612
- }
2613
-
2614
- /**
2615
- * The reporter's own history: what they have filed and where each one got to.
2616
- *
2617
- * Replaces the panel rather than opening a second surface — it is the same
2618
- * dialog answering a different question, and the reporter came here from one
2619
- * launcher.
2620
- */
2621
- async function showHistory() {
2622
- const panel = scrim.querySelector('.panel');
2623
- clearTimeout(assistantTimer);
2624
- openReportId = null;
2625
- panel.classList.remove('is-chat');
2626
- panel.innerHTML = `
2627
- ${headHtml({ title: 'Your reports', sub: "Everything you've reported, and where each one got to." })}
2628
- <div class="history" aria-busy="true">${skeletonRows(3)}</div>`;
2629
-
2630
- panel.querySelector('[data-act="close"]').addEventListener('click', close);
2631
-
2632
- let reports = [];
2633
- try {
2634
- ({ reports } = await client.myReports());
2635
- } catch {
2636
- const failed = panel.querySelector('.history');
2637
- if (failed) failed.innerHTML = emptyHtml('Couldn\'t load your reports just now.', ICONS.alert);
2638
- return;
2639
- }
2640
-
2641
- // The panel can be closed while the request is in flight.
2642
- const list = panel.querySelector('.history');
2643
- if (!list) return;
2644
-
2645
- list.removeAttribute('aria-busy');
2646
- list.innerHTML = reports.length
2647
- ? reports.map((r, i) => `
2648
- <button class="row" type="button" data-report="${escapeHtml(r.id)}" style="animation-delay:${Math.min(i, 8) * 30}ms">
2649
- <span class="row-ico" aria-hidden="true">${ICONS.chat}</span>
2650
- <span class="who">
2651
- <span class="ttl">${escapeHtml(r.title)}</span>
2652
- <span class="sub">${escapeHtml(r.reference)} · ${escapeHtml(relativeDay(r.createdAt))}</span>
2653
- </span>
2654
- <span class="pill ${escapeHtml(String(r.status).toLowerCase())}">${escapeHtml(statusWord(r.status))}</span>
2655
- <span class="chev" aria-hidden="true">${ICONS.chevronRight}</span>
2656
- </button>`).join('')
2657
- : emptyHtml('You haven\'t reported anything yet.', ICONS.inbox);
2658
-
2659
- for (const row of list.querySelectorAll('[data-report]')) {
2660
- row.addEventListener('click', () => showReport(row.dataset.report));
2661
- }
2662
- }
2663
-
2664
- /**
2665
- * One report as a conversation.
2666
- *
2667
- * This screen used to be a timeline: everything that had happened, read-only,
2668
- * newest first. It answered "what happened to my report" and nothing else —
2669
- * so when the team's reply was a question ("which browser?"), the person best
2670
- * placed to answer it was looking straight at it with no way to.
2671
- *
2672
- * Now it is a thread. Status changes still appear, as the quiet centred lines
2673
- * that mark events in any chat; the messages between the two sides are
2674
- * bubbles either side of them; and there is a box at the bottom. Oldest at
2675
- * the top, because that is the direction a conversation is read.
2676
- */
2677
- async function showReport(bugId, { fresh = false } = {}) {
2678
- const panel = scrim.querySelector('.panel');
2679
- clearTimeout(assistantTimer);
2680
- panel.classList.add('is-chat');
2681
- panel.innerHTML = `
2682
- ${headHtml({
2683
- back: true,
2684
- title: fresh ? 'Looking into it' : 'Your report',
2685
- sub: fresh ? 'Our assistant is on it — it may ask you a question here.' : 'Everything that has happened, and a way to answer.',
2686
- })}
2687
- <div class="history chatting" aria-busy="true"><div class="thread">${skeletonBubbles()}</div></div>`;
2688
-
2689
- panel.querySelector('[data-act="close"]').addEventListener('click', close);
2690
- panel.querySelector('[data-act="back"]').addEventListener('click', showHistory);
2691
-
2692
- let report = null;
2693
- try {
2694
- report = await client.myReport(bugId);
2695
- } catch {
2696
- report = null;
2697
- }
2698
-
2699
- const body = panel.querySelector('.history');
2700
- // The panel can be closed, or navigated away from, while this is in flight.
2701
- if (!body || !panel.querySelector('[data-act="back"]')) return false;
2702
-
2703
- if (!report) {
2704
- if (fresh) { panel.classList.remove('is-chat'); return false; }
2705
- body.removeAttribute('aria-busy');
2706
- body.innerHTML = emptyHtml('Couldn\'t load that report just now.', ICONS.alert);
2707
- return false;
2708
- }
2709
-
2710
- openReportId = bugId;
2711
- // Reading the report is reading its updates. Leaving a "your report was
2712
- // fixed" card up over the screen where they are reading that it was fixed
2713
- // would be the notification arguing with itself.
2714
- client.markReportRead(bugId);
2715
- const drawn = threadHtml(report.timeline);
2716
- body.classList.add('chatting');
2717
- body.removeAttribute('aria-busy');
2718
- body.innerHTML = `
2719
- <div class="report-head">
2720
- <span class="ttl">${escapeHtml(report.title)}</span>
2721
- <span class="sub">${escapeHtml(report.reference)} · ${escapeHtml(report.project || '')}</span>
2722
- <span class="pill ${escapeHtml(String(report.status).toLowerCase())}">${escapeHtml(statusWord(report.status))}</span>
2723
- ${report.estimate?.label
2724
- // The same figure they were given when they filed it, so coming back
2725
- // to check is not a worse experience than not checking. Absent on a
2726
- // report that has already been settled — the API stops sending it —
2727
- // because a prediction beside a finished report is either redundant
2728
- // or an argument.
2729
- ? `<span class="eta-line">Usually takes ${escapeHtml(String(report.estimate.label))}</span>`
2730
- : ''}
2731
- </div>
2732
- <div class="thread" role="log" aria-live="polite" aria-label="Conversation">${drawn}</div>`;
2733
-
2734
- const thread = body.querySelector('.thread');
2735
- threadDrawn.set(thread, drawn);
2736
- scrollToLatest(thread);
2737
- mountVerification(body, report);
2738
- if (report.canReply) mountComposer(body, thread, bugId);
2739
- mountAssistant(body, report, bugId);
2740
- // Again, now the cards above it have taken their share of the height: the
2741
- // latest message — and any quick answers under it — is what they came for.
2742
- scrollToLatest(thread);
2743
- return true;
2744
- }
2745
-
2746
- /**
2747
- * The assistant's live card: where the investigation is, and a gentle
2748
- * refresh while it works. Analyzing polls every few seconds until the
2749
- * outcome lands — a question, a found cause, or a hand-off — so the
2750
- * reporter watches it happen rather than wondering whether anything is.
2751
- */
2752
- let assistantTimer = null;
2753
- function mountAssistant(body, report, bugId) {
2754
- clearTimeout(assistantTimer);
2755
- const a = report.assistant;
2756
- const thread = body.querySelector('.thread');
2757
- let card = body.querySelector('.assistant');
2758
- if (!a) {
2759
- card?.remove();
2760
- thread?.querySelector('.typing-row')?.remove();
2761
- return;
2762
- }
2763
-
2764
- // Updated in place rather than rebuilt: the card is polled every few
2765
- // seconds while the assistant works, and a card that re-entered on every
2766
- // poll would flicker — and re-announce itself to a screen reader — for
2767
- // nothing. It only animates when the phase actually moves.
2768
- const previous = card?.dataset.phase || null;
2769
- if (!card) {
2770
- card = document.createElement('div');
2771
- card.setAttribute('role', 'status');
2772
- card.setAttribute('aria-live', 'polite');
2773
- body.querySelector('.report-head')?.after(card);
2774
- }
2775
- card.className = `assistant ${escapeHtml(a.phase)}`;
2776
- card.dataset.phase = a.phase;
2777
-
2778
- const steps = a.steps || [];
2779
- const done = steps.filter((st) => st.state === 'done').length;
2780
- const active = steps.some((st) => st.state === 'active') ? 0.5 : 0;
2781
- const pct = steps.length ? Math.round(((done + active) / steps.length) * 100) : 0;
2782
- const content = `
2783
- <div class="as-body${previous && previous !== a.phase ? ' entering' : ''}">
2784
- <div class="as-top">
2785
- <span class="as-orb" aria-hidden="true">${a.phase === 'analyzing' ? '<span class="as-spin"></span>'
2786
- : a.phase === 'calling' ? ICONS.phone
2787
- : a.phase === 'resolved' ? ICONS.check : ICONS.sparkle}</span>
2788
- <div class="as-text">
2789
- <div class="as-title">${escapeHtml(a.title)}</div>
2790
- <div class="as-detail">${escapeHtml(a.detail)}</div>
2791
- </div>
2792
- </div>
2793
- </div>
2794
- ${steps.length ? `
2795
- <div class="as-progress" role="progressbar" aria-label="Progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${pct}"><i style="width:${pct}%"></i></div>
2796
- <ol class="as-steps">${steps.map((st) => `<li class="${escapeHtml(st.state)}"${st.state === 'active' ? ' aria-current="step"' : ''}><span class="dot"></span><span class="lbl">${escapeHtml(st.label)}</span></li>`).join('')}</ol>` : ''}`;
2797
- const key = JSON.stringify([a.phase, a.title, a.detail, steps]);
2798
- if (card.dataset.key !== key) {
2799
- const bar = card.querySelector('.as-progress i');
2800
- const from = bar?.style.width;
2801
- card.innerHTML = content;
2802
- card.dataset.key = key;
2803
- // Let the bar travel from where it was to where it is now.
2804
- const next = card.querySelector('.as-progress i');
2805
- if (next && from) {
2806
- next.style.width = from;
2807
- requestAnimationFrame(() => { next.style.width = `${pct}%`; });
2808
- }
2809
- }
2810
-
2811
- // While it works, a "thinking" bubble sits where its next message will land.
2812
- // Never during a call: somebody is on the phone about this report, nothing
2813
- // is being analysed (the analysis waits for the transcript), and a bubble
2814
- // typing away underneath a live call is the assistant talking over them.
2815
- if (thread) {
2816
- const pinned = atLatest(thread);
2817
- const typing = thread.querySelector('.typing-row');
2818
- if (a.phase !== 'analyzing') typing?.remove();
2819
- else if (!typing || typing !== thread.lastElementChild) {
2820
- typing?.remove();
2821
- thread.insertAdjacentHTML('beforeend', typingHtml());
2822
- if (pinned) scrollToLatest(thread);
2823
- }
2824
- }
2825
-
2826
- if (a.phase === 'analyzing') {
2827
- assistantTimer = setTimeout(() => refreshThread(bugId), 3000);
2828
- } else if (a.phase === 'calling') {
2829
- // Slower: the call's own card is what the reporter is watching, and this
2830
- // only has to notice when the call ends and the analysis begins.
2831
- assistantTimer = setTimeout(() => refreshThread(bugId), 10000);
2832
- } else if (a.phase === 'needs_info' && previous !== 'needs_info') {
2833
- body.querySelector('.composer textarea')?.focus();
2834
- }
2835
- }
2836
-
2837
- /**
2838
- * Redraw the open thread in place, keeping whatever is half-typed.
2839
- *
2840
- * Rebuilding the whole screen on every incoming message would be simpler and
2841
- * would also throw away a draft, a staged file and the scroll position — for
2842
- * someone mid-sentence, a reply arriving would look like the widget crashed.
2843
- */
2844
- function mountVerification(body, report) {
2845
- body.querySelector('.fix-verification')?.remove();
2846
- if (!report.resolvedAt || !['RESOLVED', 'CLOSED'].includes(report.status)) return;
2847
- const box = document.createElement('div'); box.className = 'fix-verification';
2848
- if (report.verification) { box.textContent = report.verification === 'CONFIRMED' ? 'You confirmed that this fix works.' : 'Your feedback was recorded.'; }
2849
- else {
2850
- box.innerHTML = '<p>Does the fix work for you?</p><textarea maxlength="4000" aria-label="Fix feedback" placeholder="What happens when you try again? (optional)"></textarea><button type="button" data-result="CONFIRMED">It works now</button> <button type="button" data-result="STILL_HAPPENING">Still happening</button><p class="feedback-status" role="status"></p>';
2851
- box.querySelectorAll('[data-result]').forEach((btn) => { btn.onclick = async () => {
2852
- box.querySelectorAll('button').forEach((b) => { b.disabled = true; });
2853
- try { await client.verifyFix(report.id, { result: btn.dataset.result, resolvedAt: report.resolvedAt, note: box.querySelector('textarea').value }); await showReport(report.id); }
2854
- catch (err) { box.querySelector('.feedback-status').textContent = err.message; box.querySelectorAll('button').forEach((b) => { b.disabled = false; }); }
2855
- }; });
2856
- }
2857
- body.querySelector('.report-head')?.after(box);
2858
- }
2859
-
2860
- async function refreshThread(bugId) {
2861
- const body = scrim.querySelector('.history.chatting');
2862
- const thread = body?.querySelector('.thread');
2863
- if (!thread || openReportId !== bugId) return;
2864
-
2865
- let report = null;
2866
- try {
2867
- report = await client.myReport(bugId);
2868
- } catch { /* a failed refresh leaves what is on screen, which is not wrong */ }
2869
- if (!report || openReportId !== bugId) return;
2870
-
2871
- mountVerification(body, report);
2872
- const pinned = atLatest(thread);
2873
- const html = threadHtml(report.timeline);
2874
- // Only rewritten when something changed, so a poll that brings nothing new
2875
- // does not replay every bubble's entrance or re-announce the log.
2876
- if (threadDrawn.get(thread) !== html) {
2877
- thread.innerHTML = html;
2878
- threadDrawn.set(thread, html);
2879
- }
2880
- mountAssistant(body, report, bugId);
2881
- // Only follow the conversation down if they were already at the bottom.
2882
- // Yanking someone back from something they scrolled up to read is worse
2883
- // than making them scroll.
2884
- if (pinned) scrollToLatest(thread);
2885
-
2886
- const pill = body.querySelector('.report-head .pill');
2887
- if (pill) {
2888
- pill.className = `pill ${String(report.status).toLowerCase()}`;
2889
- pill.textContent = statusWord(report.status);
2890
- }
2891
- }
2892
-
2893
- /**
2894
- * The box at the bottom: text, files, and a voice note.
2895
- *
2896
- * Files are uploaded as they are picked, not on send, so the wait happens
2897
- * while the person is still writing rather than after they have pressed the
2898
- * button — and so a failed upload is visible as a failed chip instead of a
2899
- * message that never went.
2900
- */
2901
- function mountComposer(body, thread, bugId) {
2902
- const composer = document.createElement('div');
2903
- composer.className = 'composer';
2904
- composer.innerHTML = `
2905
- <div class="staged" hidden></div>
2906
- <div class="row">
2907
- <div class="tools">
2908
- ${cfg.attachments === false ? '' : `
2909
- <button class="tool" type="button" data-act="attach" title="Attach a file" aria-label="Attach a file">${ICONS.paperclip}</button>
2910
- <button class="tool" type="button" data-act="voice" title="Record a voice note" aria-label="Record a voice note">${ICONS.mic}</button>`}
2911
- <button class="tool" type="button" data-act="livecall" title="Call the team" aria-label="Call the team" hidden>${ICONS.phone}</button>
2912
- </div>
2913
- <textarea rows="1" placeholder="Write a reply…" aria-label="Write a reply"></textarea>
2914
- <button class="send" type="button" data-act="send" title="Send" aria-label="Send" disabled>${ICONS.send}</button>
2915
- </div>
2916
- <div class="note" hidden></div>
2917
- <input type="file" hidden multiple accept="${UPLOAD_MIMES.join(',')}">`;
2918
- body.appendChild(composer);
2919
-
2920
- const staged = composer.querySelector('.staged');
2921
- const input = composer.querySelector('textarea');
2922
- const sendBtn = composer.querySelector('[data-act="send"]');
2923
- const voiceBtn = composer.querySelector('[data-act="voice"]');
2924
- const liveBtn = composer.querySelector('[data-act="livecall"]');
2925
- const picker = composer.querySelector('input[type=file]');
2926
- const note = composer.querySelector('.note');
2927
-
2928
- // Uploaded files waiting to ride on the next message.
2929
- const files = [];
2930
- let recorder = null;
2931
- let sending = false;
2932
-
2933
- function say(text, kind = '') {
2934
- note.hidden = !text;
2935
- note.className = `note ${kind}`;
2936
- note.textContent = text || '';
2937
- }
2938
-
2939
- function refresh() {
2940
- const ready = files.filter((f) => f.storageKey);
2941
- sendBtn.disabled = sending || (!input.value.trim() && !ready.length);
2942
- staged.hidden = !files.length;
2943
- staged.innerHTML = files.map((f, i) => `
2944
- <span class="chip ${f.storageKey ? '' : 'uploading'}">
2945
- <span class="nm">${escapeHtml(f.name)}</span>
2946
- <span class="sz">${f.storageKey ? humanSize(f.size) : 'sending…'}</span>
2947
- <button type="button" data-drop="${i}" aria-label="Remove ${escapeHtml(f.name)}">${ICONS.close}</button>
2948
- </span>`).join('');
2949
- for (const btn of staged.querySelectorAll('[data-drop]')) {
2950
- btn.addEventListener('click', () => {
2951
- files.splice(Number(btn.dataset.drop), 1);
2952
- refresh();
2953
- });
2954
- }
2955
- }
2956
-
2957
- /** Upload one blob and stage it. Its chip is on screen for the whole trip. */
2958
- async function stage(blob, mime, name) {
2959
- const entry = { name, size: blob.size, storageKey: null };
2960
- files.push(entry);
2961
- refresh();
2962
- try {
2963
- const { storageKey, mime: stored } = await client.uploadAttachment(blob, mime);
2964
- entry.storageKey = storageKey;
2965
- entry.mime = stored;
2966
- } catch (err) {
2967
- files.splice(files.indexOf(entry), 1);
2968
- say(err.message || 'That file could not be sent.', 'err');
2969
- }
2970
- refresh();
2971
- }
2972
-
2973
- input.addEventListener('input', () => {
2974
- // Grow with the text, up to the CSS cap. A reply about a bug is often
2975
- // three lines, and a one-line box makes people write one line.
2976
- input.style.height = 'auto';
2977
- input.style.height = `${Math.min(input.scrollHeight, 120)}px`;
2978
- refresh();
2979
- });
2980
-
2981
- // Enter sends, Shift+Enter breaks the line — the chat convention. Anyone
2982
- // who wants a paragraph gets it; nobody has to hunt for the button.
2983
- input.addEventListener('keydown', (e) => {
2984
- if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
2985
- e.preventDefault();
2986
- send();
2987
- }
2988
- });
2989
-
2990
- // Absent when the host set `attachments: false` — which the report form
2991
- // has always honoured and this composer did not, so a host keeping files
2992
- // and microphone audio outside its compliance perimeter still had both
2993
- // offered in the follow-up thread.
2994
- composer.querySelector('[data-act="attach"]')?.addEventListener('click', () => picker.click());
2995
- picker.addEventListener('change', () => {
2996
- say('');
2997
- for (const file of picker.files) {
2998
- if (file.size > client.config.maxAttachmentBytes) {
2999
- say(`${file.name} is larger than ${humanSize(client.config.maxAttachmentBytes)}.`, 'err');
3000
- continue;
3001
- }
3002
- stage(file, file.type, file.name);
3003
- }
3004
- picker.value = '';
3005
- });
3006
-
3007
- if (voiceBtn && !voiceSupported()) {
3008
- voiceBtn.disabled = true;
3009
- voiceBtn.title = 'This browser cannot record audio';
3010
- }
3011
-
3012
- voiceBtn?.addEventListener('click', async () => {
3013
- if (recorder) {
3014
- const handle = recorder;
3015
- recorder = null;
3016
- voiceBtn.classList.remove('recording');
3017
- voiceBtn.innerHTML = ICONS.mic;
3018
- say('');
3019
- const { blob, mime } = await handle.stop();
3020
- if (blob.size) stage(blob, mime, 'Voice note');
3021
- return;
3022
- }
3023
- try {
3024
- say('');
3025
- recorder = await startVoiceNote({
3026
- maxMs: client.config.maxRecordingMs,
3027
- onTick: (s) => say(`Recording — ${humanDuration(s)}. Tap the mic to stop.`),
3028
- onAutoStop: () => voiceBtn.click(),
3029
- });
3030
- voiceBtn.classList.add('recording');
3031
- voiceBtn.innerHTML = ICONS.stopSquare;
3032
- } catch {
3033
- recorder = null;
3034
- say('The microphone is not available — check the browser permission.', 'err');
3035
- }
3036
- });
3037
-
3038
- /* ---------- calling the team ---------- */
3039
-
3040
- // Asked once, when a report is opened, and the button stays hidden unless
3041
- // the answer is yes. Most workspaces have live calling off, and a phone
3042
- // icon that apologises on press is worse than no phone icon.
3043
- client.canCallTeam(bugId)
3044
- .then((available) => { if (available) liveBtn.hidden = false; })
3045
- .catch(() => { /* not available is the safe answer to every failure */ });
3046
-
3047
- liveBtn.addEventListener('click', async () => {
3048
- liveBtn.disabled = true;
3049
- say('');
3050
- try {
3051
- const handle = await client.startLiveCall(bugId);
3052
- showLiveCall(body, thread, handle);
3053
- } catch (err) {
3054
- say(err.message.replace('[BugCatch] ', ''), 'err');
3055
- } finally {
3056
- liveBtn.disabled = false;
3057
- }
3058
- });
3059
-
3060
- sendBtn.addEventListener('click', send);
3061
-
3062
- // The confirm question's quick answers: the same as typing and sending.
3063
- thread.addEventListener('click', (e) => {
3064
- const quick = e.target.closest?.('[data-quick]');
3065
- if (!quick || sending) return;
3066
- input.value = quick.dataset.quick;
3067
- send();
500
+ // An announcement written for beside the button, asked for once the page has
501
+ // gone quiet — like the panel's code, never competing with the host's first
502
+ // paint. The answer is kept a few minutes per browser (announcements.js), so
503
+ // this is not a request per page view. Shown until it is closed, the panel
504
+ // opens, or it is over.
505
+ let notice = null;
506
+ function showNotice(a) {
507
+ hideNotice();
508
+ notice = noticeNode(a, {
509
+ where: 'launcher',
510
+ onDismiss: (closed) => {
511
+ client.dismissAnnouncement(closed);
512
+ launcher.focus();
513
+ hideNotice();
514
+ },
3068
515
  });
3069
-
3070
- async function send() {
3071
- const text = input.value.trim();
3072
- const ready = files.filter((f) => f.storageKey);
3073
- if (sending || (!text && !ready.length)) return;
3074
-
3075
- sending = true;
3076
- refresh();
3077
- say('');
3078
-
3079
- // Shown immediately, greyed, and replaced by the refresh below. Waiting
3080
- // for the round trip before anything appears makes a slow network look
3081
- // like a dropped message, which is when people send it twice.
3082
- thread.insertAdjacentHTML('beforeend', messageHtml({
3083
- from: 'reporter',
3084
- body: text,
3085
- at: new Date().toISOString(),
3086
- attachments: ready.map((f) => ({ type: 'FILE', name: f.name, size: f.size })),
3087
- }, 'pending'));
3088
- scrollToLatest(thread, { smooth: true });
3089
-
3090
- try {
3091
- await sendMessage({
3092
- apiBase: client.config.apiBase,
3093
- bugId,
3094
- body: text,
3095
- attachments: ready.map((f) => ({
3096
- storageKey: f.storageKey, mime: f.mime, name: f.name, size: f.size,
3097
- })),
3098
- });
3099
- input.value = '';
3100
- input.style.height = 'auto';
3101
- files.length = 0;
3102
- await refreshThread(bugId);
3103
- } catch (err) {
3104
- thread.querySelector('.msg-row.pending')?.classList.replace('pending', 'failed');
3105
- say(err.message || 'That could not be sent — try again.', 'err');
3106
- } finally {
3107
- sending = false;
3108
- refresh();
3109
- }
3110
- }
3111
-
3112
- refresh();
516
+ // After the load-failure card if there is one, so that stays outermost.
517
+ if (failure) failure.after(notice);
518
+ else dock.prepend(notice);
3113
519
  }
3114
520
 
3115
- /**
3116
- * The call, on top of the report it is about.
3117
- *
3118
- * A bar rather than a screen of its own, unlike the one the composer's button
3119
- * opens — and for a reason that only applies here. A call has nothing to show
3120
- * while it runs, because nobody is listening to it but the person on the
3121
- * other end, so what is worth looking at is the report both people are
3122
- * talking about.
3123
- */
3124
- function showLiveCall(body, thread, handle) {
3125
- const bar = document.createElement('div');
3126
- bar.className = 'livecall';
3127
- bar.innerHTML = `
3128
- <span class="call-dot" aria-hidden="true"></span>
3129
- <span class="who" role="status" aria-live="polite">Calling the team…</span>
3130
- <span class="secs" hidden></span>
3131
- ${handle?.recorded ? '<span class="call-kept">This call is recorded.</span>' : ''}
3132
- <button class="act" type="button" data-act="mute" hidden>Mute</button>
3133
- <button class="act danger" type="button" data-act="hangup">Cancel</button>`;
3134
- body.insertBefore(bar, thread);
3135
-
3136
- const who = bar.querySelector('.who');
3137
- const secs = bar.querySelector('.secs');
3138
- const dot = bar.querySelector('.call-dot');
3139
- const muteBtn = bar.querySelector('[data-act="mute"]');
3140
- const hangBtn = bar.querySelector('[data-act="hangup"]');
3141
-
3142
- let ticker = null;
3143
- const stopTicking = () => { if (ticker) { clearInterval(ticker); ticker = null; } };
3144
-
3145
- /** Left up rather than removed, so somebody who looked away can see how it
3146
- * ended — and dismissed by hand, or by the next call replacing it. */
3147
- const settle = (text) => {
3148
- stopTicking();
3149
- dot.classList.add('off');
3150
- bar.classList.add('over');
3151
- who.textContent = text;
3152
- secs.hidden = true;
3153
- muteBtn.hidden = true;
3154
- hangBtn.textContent = 'Dismiss';
3155
- hangBtn.classList.remove('danger');
3156
- };
3157
-
3158
- const off = client.on('call', ({ state }) => {
3159
- if (state === 'ringing') who.textContent = 'Calling the team…';
3160
-
3161
- if (state === 'connecting') {
3162
- who.textContent = 'Someone answered — connecting…';
3163
- }
3164
-
3165
- if (state === 'connected') {
3166
- who.textContent = "You're on a call with the team";
3167
- muteBtn.hidden = false;
3168
- secs.hidden = false;
3169
- const from = Date.now();
3170
- stopTicking();
3171
- // Started on connect rather than on answer: the seconds a person is
3172
- // charged for looking at are the ones they could hear.
3173
- ticker = setInterval(() => {
3174
- secs.textContent = humanDuration(Math.floor((Date.now() - from) / 1000));
3175
- }, 1000);
3176
- secs.textContent = '0s';
3177
- }
3178
-
3179
- if (state === 'missed') {
3180
- settle('Nobody was free. Send a message instead and the team will pick it up.');
3181
- }
3182
- if (state === 'ended') settle('Call ended.');
3183
- if (state === 'failed') settle('The call dropped.');
3184
- });
3185
-
3186
- muteBtn.addEventListener('click', () => {
3187
- const muted = handle.setMuted(!handle.isMuted());
3188
- muteBtn.textContent = muted ? 'Unmute' : 'Mute';
3189
- // Announced rather than only shown: somebody who muted by accident finds
3190
- // out when the other person stops replying, which is a long way in.
3191
- who.textContent = muted ? "You're muted" : "You're on a call with the team";
3192
- });
3193
-
3194
- hangBtn.addEventListener('click', () => {
3195
- off();
3196
- stopTicking();
3197
- client.endCall();
3198
- bar.remove();
3199
- // The note about the call lands in the thread a moment after it ends, and
3200
- // the reporter is looking straight at where it goes.
3201
- refreshThread(openReportId);
3202
- });
521
+ function hideNotice() {
522
+ notice?.remove();
523
+ notice = null;
3203
524
  }
3204
525
 
3205
- // The report currently open, if any. A live update for a different report
3206
- // must not redraw the one being read.
3207
- let openReportId = null;
3208
-
3209
- // Re-read the open thread when something happens to it. Without this the
3210
- // reader is looking at a conversation that stopped being true while they read
3211
- // it — the very screen where a stale view is most obvious, and now the screen
3212
- // where the other side may be typing.
3213
- stopListening = client.on('update', (event) => {
3214
- if (!openReportId || event?.bugId !== openReportId) return;
3215
- // Already on screen, so already read — and marking it here, before the
3216
- // client draws its notifications, is what keeps a card from appearing over
3217
- // the thread that is about to show the same thing.
3218
- client.markReportRead(openReportId);
3219
- refreshThread(openReportId);
526
+ const cancelNotice = client.config.announcements === false ? () => {} : whenIdleAfterLoad(() => {
527
+ client.announcements()
528
+ .then((list) => {
529
+ const beside = list.find((a) => a.placement === 'launcher');
530
+ if (beside && !open && host.isConnected) showNotice(beside);
531
+ })
532
+ .catch(() => { /* no card is the answer to every failure */ });
3220
533
  });
3221
534
 
3222
- root.appendChild(scrim);
3223
- refreshSendState();
3224
- titleInput.focus();
3225
-
3226
- return { destroy, close };
3227
- }
3228
-
3229
- /**
3230
- * The whole thread, oldest first.
3231
- *
3232
- * The API hands back one ordered list holding both kinds of entry, which is
3233
- * what keeps a reply sitting under the status change that prompted it. Entries
3234
- * carrying `from` are messages; the rest are things that happened.
3235
- */
3236
- function threadHtml(timeline) {
3237
- const entries = timeline || [];
3238
- if (!entries.length) return '';
3239
- let day = null;
3240
- let prev = null;
3241
- return entries.map((e) => {
3242
- // A rule with the date on it whenever the calendar day turns, the way a
3243
- // chat marks that the next message came later.
3244
- const d = dayKey(e.at);
3245
- const sep = d && d !== day ? `<div class="day-sep" role="separator">${escapeHtml(dayLabel(e.at))}</div>` : '';
3246
- if (sep) { day = d; prev = null; }
3247
- if (!e.from) { prev = null; return sep + systemHtml(e); }
3248
- const message = {
3249
- from: e.from,
3250
- author: e.reply?.author,
3251
- automated: e.reply?.automated,
3252
- body: e.reply?.body,
3253
- at: e.at,
3254
- attachments: e.reply?.attachments,
3255
- };
3256
- // Consecutive messages from the same voice within a few minutes are one
3257
- // turn: one avatar, one name, the bubbles stacked under it.
3258
- const who = speakerOf(message);
3259
- const cont = prev && prev.who === who
3260
- && Math.abs(new Date(e.at).getTime() - new Date(prev.at).getTime()) < 5 * 60_000;
3261
- prev = { who, at: e.at };
3262
- return sep + messageHtml(message, cont ? 'cont' : '');
3263
- }).join('');
3264
- }
3265
-
3266
- /** Which voice a message is in, for grouping: the reporter, the assistant, or a named person. */
3267
- function speakerOf(message) {
3268
- if (message.from === 'reporter') return 'me';
3269
- if (message.automated) return 'assistant';
3270
- return `team:${message.author || ''}`;
3271
- }
3272
-
3273
- /**
3274
- * Something that happened, rather than something someone said.
3275
- *
3276
- * `summary` is written by the API, which knows what changed and phrases it for
3277
- * the person who reported it — so the wording here matches the toast and the
3278
- * portal exactly rather than being a third attempt at the same sentence.
3279
- */
3280
- function systemHtml(entry) {
3281
- const when = clockTime(entry.at);
3282
- return `
3283
- <div class="sys k-${escapeHtml(entry.kind)}">
3284
- <span class="dot" aria-hidden="true"></span>${escapeHtml(entry.summary || '')}${when ? `
3285
- <span class="t">· ${escapeHtml(when)}</span>` : ''}
3286
- </div>`;
3287
- }
3288
-
3289
- /**
3290
- * One bubble. `state` marks a message that is still in flight, or failed, or
3291
- * one that continues the turn above it.
3292
- *
3293
- * A reply that was sent automatically says so, above the words rather than
3294
- * after them. The person reading it is deciding whether their problem has been
3295
- * understood, and that decision is different when nobody has read the report —
3296
- * so it is not a footnote, and it is not left to be inferred from the tone.
3297
- */
3298
- function messageHtml(message, state = '') {
3299
- const mine = message.from === 'reporter';
3300
- const body = message.body || '';
3301
- const attachments = message.attachments || [];
3302
- const automated = !mine && Boolean(message.automated);
3303
-
3304
- // The text goes in its own element rather than as a bare child of .bubble:
3305
- // it is the only part that wants `white-space: pre-wrap`, and with that rule
3306
- // on the bubble the template's own newlines and indentation render as a
3307
- // blank line above and below every message.
3308
- const text = body ? `<span class="msg-text">${escapeHtml(body)}</span>` : '';
3309
- const files = attachments.length
3310
- ? `<div class="att">${attachments.map(attachmentHtml).join('')}</div>`
3311
- : '';
3312
-
3313
- // Named, or labelled, never both and never neither: an automated message has
3314
- // no author to name, and naming one would be the lie the label exists to
3315
- // prevent. The avatar says the same thing at a glance.
3316
- let who = '';
3317
- let avatar = '';
3318
- if (automated) {
3319
- who = '<span class="msg-who auto">Assistant <span class="tag">AI</span></span>';
3320
- avatar = `<span class="av bot" aria-hidden="true">${ICONS.sparkle}</span>`;
3321
- } else if (!mine) {
3322
- const name = message.author || 'Support team';
3323
- who = `<span class="msg-who">${escapeHtml(name)}</span>`;
3324
- avatar = `<span class="av" aria-hidden="true">${escapeHtml(initials(name))}</span>`;
3325
- }
3326
- if (state.includes('cont')) who = '';
3327
-
3328
- // The assistant's one answer ends by inviting a correction (check-rules
3329
- // confirmMessage). One tap sends it, and what comes back goes to the person
3330
- // who now owns the report rather than to another automated round — so the
3331
- // wording is "that's right" and "that's not it", not a yes/no question.
3332
- const confirm = automated && /it goes to the same person/i.test(body)
3333
- ? `<div class="quick" role="group" aria-label="Answer">
3334
- <button type="button" data-quick="Yes, this is the problem">${ICONS.check}That's right</button>
3335
- <button type="button" data-quick="No, that is not the problem">That's not it</button>
3336
- </div>`
3337
- : '';
3338
-
3339
- return `
3340
- <div class="msg-row ${mine ? 'mine' : 'theirs'} ${automated ? 'auto' : ''} ${state}">
3341
- ${avatar}
3342
- ${who}
3343
- <div class="bubble ${body ? '' : 'silent'}">${text}${files}</div>
3344
- ${confirm}
3345
- <span class="msg-when">${escapeHtml(clockTime(message.at) || relativeDay(message.at))}</span>
3346
- </div>`;
3347
- }
3348
-
3349
- /** The assistant, thinking: where its next message will appear. */
3350
- function typingHtml() {
3351
- return `
3352
- <div class="typing-row">
3353
- <span class="av bot" aria-hidden="true">${ICONS.sparkle}</span>
3354
- <span class="msg-who auto">Assistant <span class="tag">AI</span></span>
3355
- <div class="bubble typing" role="img" aria-label="The assistant is working on it"><i></i><i></i><i></i></div>
3356
- </div>`;
3357
- }
3358
-
3359
- /**
3360
- * The panel's header, on every screen. The assistant's avatar carries the
3361
- * identity; `back` adds the way out of a report to the list of them.
3362
- */
3363
- function headHtml({ title, sub, id = '', back = false }) {
3364
- return `
3365
- <div class="head">
3366
- ${back ? `<button class="icon-btn back" type="button" data-act="back" aria-label="Back to your reports">${ICONS.back}</button>` : ''}
3367
- <div class="mark" aria-hidden="true">${ICONS.sparkle}</div>
3368
- <div class="titles">
3369
- <h2${id ? ` id="${id}"` : ''}>${escapeHtml(title)}</h2>
3370
- <p>${escapeHtml(sub)}</p>
3371
- </div>
3372
- <button class="icon-btn" type="button" data-act="close" aria-label="Close">${ICONS.close}</button>
3373
- </div>`;
3374
- }
3375
-
3376
- function emptyHtml(text, icon) {
3377
- return `<div class="empty"><span class="empty-ico" aria-hidden="true">${icon}</span>${escapeHtml(text)}</div>`;
3378
- }
3379
-
3380
- /** Placeholder rows while the list loads — the shape of what is coming. */
3381
- function skeletonRows(n) {
3382
- return Array.from({ length: n }, () => `
3383
- <div class="row" aria-hidden="true">
3384
- <span class="row-ico"></span>
3385
- <span class="who"><span class="skel" style="width:70%"></span><span class="skel" style="width:40%;height:9px"></span></span>
3386
- </div>`).join('') + '<span class="sr-only">Loading…</span>';
3387
- }
3388
-
3389
- function skeletonBubbles() {
3390
- return `
3391
- <div aria-hidden="true" style="display:flex;flex-direction:column;gap:10px;padding-top:6px">
3392
- <span class="skel" style="width:42%;height:34px;border-radius:16px;align-self:center"></span>
3393
- <span class="skel" style="width:66%;height:52px;border-radius:18px"></span>
3394
- <span class="skel" style="width:48%;height:38px;border-radius:18px;align-self:flex-end"></span>
3395
- </div>
3396
- <span class="sr-only">Loading…</span>`;
3397
- }
3398
-
3399
- // What each open thread was last drawn from, so a poll that brings nothing new
3400
- // leaves the DOM (and the reader's place in it) alone.
3401
- const threadDrawn = new WeakMap();
3402
-
3403
- function initials(name) {
3404
- const parts = String(name).trim().split(/\s+/).filter(Boolean);
3405
- if (!parts.length) return '?';
3406
- return (parts[0][0] + (parts.length > 1 ? parts[parts.length - 1][0] : '')).toUpperCase();
3407
- }
535
+ dock.append(themer, launcher);
536
+ root.appendChild(dock);
3408
537
 
3409
- function dayKey(iso) {
3410
- const d = new Date(iso);
3411
- return Number.isFinite(d.getTime()) ? `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}` : null;
538
+ document.body.appendChild(host);
539
+ // Integration hook: BugCatch.open() / close() / isOpen() drive the same
540
+ // launcher a person would press, so there is one way in to the panel.
541
+ client.widgetControls = {
542
+ open: () => { if (!open) launcher.click(); },
543
+ close: () => { open?.close(); },
544
+ isOpen: () => Boolean(open),
545
+ };
546
+ return () => {
547
+ cancelIdle(); cancelNotice(); open?.destroy(); host.remove(); client.widgetControls = null;
548
+ };
3412
549
  }
3413
550
 
3414
- /** "Today" / "Yesterday" / "Mon, 14 Sep" — the label on a day separator. */
3415
- function dayLabel(iso) {
3416
- const d = new Date(iso);
3417
- const today = new Date();
3418
- const yesterday = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1);
3419
- if (dayKey(d) === dayKey(today)) return 'Today';
3420
- if (dayKey(d) === dayKey(yesterday)) return 'Yesterday';
3421
- try {
3422
- return d.toLocaleDateString(undefined, {
3423
- weekday: 'short', day: 'numeric', month: 'short',
3424
- ...(d.getFullYear() !== today.getFullYear() ? { year: 'numeric' } : {}),
3425
- });
3426
- } catch {
3427
- return relativeDay(iso);
3428
- }
551
+ /** Normalises a configured theme to one of the three the switch offers. */
552
+ function themeValue(theme) {
553
+ return theme === 'dark' || theme === 'light' ? theme : 'auto';
3429
554
  }
3430
555
 
3431
- /** "14:05" or "2:05 PM", in the reader's own convention. */
3432
- function clockTime(iso) {
3433
- const d = new Date(iso);
3434
- if (!Number.isFinite(d.getTime())) return '';
3435
- try {
3436
- return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
3437
- } catch {
3438
- return '';
3439
- }
556
+ function themeClass(theme) {
557
+ if (theme === 'dark') return 'bc-dark';
558
+ if (theme === 'light') return 'bc-light';
559
+ return 'bc-auto';
3440
560
  }
3441
561
 
3442
562
  /**
3443
- * One attachment, rendered as what it is.
3444
- *
3445
- * An image people can see, a voice note they can play and a document they can
3446
- * download are three different things, and collapsing them all to a link is
3447
- * how a chat stops feeling like one. A pending upload has no URL yet, so it
3448
- * falls through to the file row with nothing to click.
563
+ * Run `fn` once the page has loaded and the browser has a moment to spare.
564
+ * Returns a cancel, for a widget torn down before then.
3449
565
  */
3450
- function attachmentHtml(a) {
3451
- const name = escapeHtml(a.name || 'attachment');
3452
- if (!a.url) return `<span class="file"><span class="nm">${name}</span></span>`;
3453
- const url = escapeHtml(a.url);
3454
-
3455
- if (a.type === 'SCREENSHOT') {
3456
- return `<a href="${url}" target="_blank" rel="noopener noreferrer"><img src="${url}" alt="${name}" loading="lazy"></a>`;
3457
- }
3458
- if (a.type === 'AUDIO') {
3459
- return `<audio controls preload="metadata" src="${url}"></audio>`;
3460
- }
3461
- if (a.type === 'VIDEO') {
3462
- return `<video controls preload="metadata" src="${url}"></video>`;
566
+ function whenIdleAfterLoad(fn) {
567
+ let cancel = () => {};
568
+ const idle = () => {
569
+ if (typeof requestIdleCallback === 'function') {
570
+ // The timeout is a ceiling, not a delay: a page that is never idle still
571
+ // gets its panel fetched, a few seconds in.
572
+ const id = requestIdleCallback(fn, { timeout: 5000 });
573
+ cancel = () => cancelIdleCallback(id);
574
+ } else {
575
+ // Safari before 18 has no idle callback; a short wait after load is the
576
+ // nearest thing.
577
+ const id = setTimeout(fn, 1500);
578
+ cancel = () => clearTimeout(id);
579
+ }
580
+ };
581
+ if (document.readyState === 'complete') idle();
582
+ else {
583
+ window.addEventListener('load', idle, { once: true });
584
+ cancel = () => window.removeEventListener('load', idle);
3463
585
  }
3464
- return `
3465
- <a class="file" href="${url}" target="_blank" rel="noopener noreferrer" download>
3466
- <span class="nm">${name}</span>
3467
- ${a.size ? `<span class="sz">${escapeHtml(humanSize(a.size))}</span>` : ''}
3468
- </a>`;
3469
- }
3470
-
3471
- /** Whether the reader is at the foot of the thread, within a line or two. */
3472
- function atLatest(thread) {
3473
- return thread.scrollHeight - thread.scrollTop - thread.clientHeight < 40;
3474
- }
3475
-
3476
- function scrollToLatest(thread, { smooth = false } = {}) {
3477
- const calm = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
3478
- if (smooth && !calm && thread.scrollTo) thread.scrollTo({ top: thread.scrollHeight, behavior: 'smooth' });
3479
- else thread.scrollTop = thread.scrollHeight;
3480
- }
3481
-
3482
- /** Status as a word a reporter would use, not the enum the database stores. */
3483
- function statusWord(status) {
3484
- return {
3485
- OPEN: 'Open',
3486
- IN_PROGRESS: 'In progress',
3487
- RESOLVED: 'Fixed',
3488
- CLOSED: 'Closed',
3489
- }[status] || String(status || '').toLowerCase();
3490
- }
3491
-
3492
- /** "today" / "3 days ago" — enough for a history list, no library. */
3493
- function relativeDay(iso) {
3494
- const then = new Date(iso).getTime();
3495
- if (!Number.isFinite(then)) return '';
3496
- const days = Math.floor((Date.now() - then) / 86400000);
3497
- if (days <= 0) return 'today';
3498
- if (days === 1) return 'yesterday';
3499
- if (days < 30) return `${days} days ago`;
3500
- const months = Math.floor(days / 30);
3501
- return months === 1 ? 'a month ago' : `${months} months ago`;
3502
- }
3503
-
3504
- function escapeHtml(s) {
3505
- return String(s).replace(/[&<>"']/g, (c) => (
3506
- { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
3507
- ));
586
+ return () => cancel();
3508
587
  }