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