@sphoro/bugcatch-web-sdk 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +92 -5
  2. package/dist/bugcatch-call.es.js +586 -0
  3. package/dist/bugcatch-call.es.js.map +1 -0
  4. package/dist/bugcatch-call.umd.js +2 -0
  5. package/dist/bugcatch-call.umd.js.map +1 -0
  6. package/dist/bugcatch-core.es.js +3214 -0
  7. package/dist/bugcatch-core.es.js.map +1 -0
  8. package/dist/bugcatch-panel.es.js +2402 -0
  9. package/dist/bugcatch-panel.es.js.map +1 -0
  10. package/dist/bugcatch-panel.umd.js +1240 -0
  11. package/dist/bugcatch-panel.umd.js.map +1 -0
  12. package/dist/bugcatch.es.js +2 -5522
  13. package/dist/bugcatch.es.js.map +1 -1
  14. package/dist/bugcatch.standalone.umd.js +1536 -0
  15. package/dist/bugcatch.standalone.umd.js.map +1 -0
  16. package/dist/bugcatch.umd.js +89 -1183
  17. package/dist/bugcatch.umd.js.map +1 -1
  18. package/package.json +6 -6
  19. package/src/agent-issues.js +30 -0
  20. package/src/announcements.js +147 -0
  21. package/src/busy.js +18 -0
  22. package/src/call.js +7 -0
  23. package/src/capture.js +9 -0
  24. package/src/chunks.js +56 -0
  25. package/src/gesture.js +33 -0
  26. package/src/icons.js +18 -0
  27. package/src/identity.js +14 -9
  28. package/src/incoming-call.js +8 -30
  29. package/src/index.d.ts +50 -0
  30. package/src/index.js +170 -37
  31. package/src/lazy.js +23 -0
  32. package/src/notice.js +68 -0
  33. package/src/panel/call.js +242 -0
  34. package/src/panel/compose.js +416 -0
  35. package/src/panel/composer.js +235 -0
  36. package/src/panel/icons.js +34 -0
  37. package/src/panel/index.js +251 -0
  38. package/src/panel/known.js +130 -0
  39. package/src/panel/messages.js +176 -0
  40. package/src/panel/notices.js +34 -0
  41. package/src/panel/picks.js +61 -0
  42. package/src/panel/refs.js +321 -0
  43. package/src/panel/render.js +124 -0
  44. package/src/panel/signin.js +228 -0
  45. package/src/panel/styles.js +960 -0
  46. package/src/panel/thread.js +384 -0
  47. package/src/session.js +8 -3
  48. package/src/umd/call.js +6 -0
  49. package/src/umd/lazy.js +98 -0
  50. package/src/umd/panel.js +6 -0
  51. package/src/updates.js +20 -7
  52. package/src/widget.js +243 -3164
@@ -0,0 +1,34 @@
1
+ // The panel's glyphs: the launcher's set (../icons.js) and everything the
2
+ // form, the thread and the call screens draw.
3
+
4
+ export * from '../icons.js';
5
+
6
+ export const 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>';
7
+ export const 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>';
8
+ export const 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>';
9
+ export const 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>';
10
+ export const 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>';
11
+ export const stopSquare = '<svg viewBox="0 0 24 24" fill="currentColor"><rect x="7" y="7" width="10" height="10" rx="2"/></svg>';
12
+ export const 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>';
13
+ export const 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>';
14
+ export const 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>';
15
+ export const 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>';
16
+ export const 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>';
17
+ // The assistant's mark: a four-point spark, drawn wherever it speaks.
18
+ export const 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>';
19
+ export const 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>';
20
+ export const 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>';
21
+
22
+ /* The sphoro lockup, as outlines rather than text.
23
+ *
24
+ * This SDK runs inside other people's pages, where Space Grotesk is not loaded
25
+ * and cannot be assumed. Live <text> would render the mark in whatever the host
26
+ * site happens to use — a different typeface on every customer. Outlines are
27
+ * self-contained, so the mark is identical everywhere, at the cost of the path
28
+ * data below.
29
+ *
30
+ * The BUGCATCH half reads --bc-brand-mark, not a bare --accent. Custom properties
31
+ * are exempt from `all: initial`, so they cross the shadow boundary: a host page
32
+ * that happens to define --accent would have repainted our mark in their brand
33
+ * colour. The --bc- prefix is ours and no host is going to set it by accident. */
34
+ export const BRAND = '<svg class="bc-brand" viewBox="0 0 254 60" fill="none" role="img" aria-label="sphoro BugCatch"><g fill="currentColor"><path transform="translate(0.00 40.00) scale(0.04000 -0.04000)" d="M276.00 -14.00Q179.00 -14.00 117.00 28.00Q55.00 70.00 42.00 148.00L158.00 178.00Q165.00 143.00 181.50 123.00Q198.00 103.00 222.50 94.50Q247.00 86.00 276.00 86.00Q320.00 86.00 341.00 101.50Q362.00 117.00 362.00 140.00Q362.00 163.00 342.00 175.50Q322.00 188.00 278.00 196.00L250.00 201.00Q198.00 211.00 155.00 228.50Q112.00 246.00 86.00 277.00Q60.00 308.00 60.00 357.00Q60.00 431.00 114.00 470.50Q168.00 510.00 256.00 510.00Q339.00 510.00 394.00 473.00Q449.00 436.00 466.00 376.00L349.00 340.00Q341.00 378.00 316.50 394.00Q292.00 410.00 256.00 410.00Q220.00 410.00 201.00 397.50Q182.00 385.00 182.00 363.00Q182.00 339.00 202.00 327.50Q222.00 316.00 256.00 310.00L284.00 305.00Q340.00 295.00 385.50 278.50Q431.00 262.00 457.50 231.50Q484.00 201.00 484.00 149.00Q484.00 71.00 427.50 28.50Q371.00 -14.00 276.00 -14.00Z"/><path transform="translate(19.56 40.00) scale(0.04000 -0.04000)" d="M70.00 -200.00V496.00H194.00V436.00H212.00Q229.00 465.00 265.00 487.50Q301.00 510.00 368.00 510.00Q428.00 510.00 479.00 480.50Q530.00 451.00 561.00 394.00Q592.00 337.00 592.00 256.00V240.00Q592.00 159.00 561.00 102.00Q530.00 45.00 479.00 15.50Q428.00 -14.00 368.00 -14.00Q323.00 -14.00 292.50 -3.50Q262.00 7.00 243.50 23.50Q225.00 40.00 214.00 57.00H196.00V-200.00ZM330.00 96.00Q389.00 96.00 427.50 133.50Q466.00 171.00 466.00 243.00V253.00Q466.00 325.00 427.00 362.50Q388.00 400.00 330.00 400.00Q272.00 400.00 233.00 362.50Q194.00 325.00 194.00 253.00V243.00Q194.00 171.00 233.00 133.50Q272.00 96.00 330.00 96.00Z"/><path transform="translate(43.68 40.00) scale(0.04000 -0.04000)" d="M70.00 0.00V700.00H196.00V435.00H214.00Q222.00 451.00 239.00 467.00Q256.00 483.00 284.50 493.50Q313.00 504.00 357.00 504.00Q415.00 504.00 458.50 477.50Q502.00 451.00 526.00 404.50Q550.00 358.00 550.00 296.00V0.00H424.00V286.00Q424.00 342.00 396.50 370.00Q369.00 398.00 318.00 398.00Q260.00 398.00 228.00 359.50Q196.00 321.00 196.00 252.00V0.00Z"/><path transform="translate(66.92 40.00) scale(0.04000 -0.04000)" d="M306.00 -14.00Q232.00 -14.00 173.00 16.00Q114.00 46.00 80.00 103.00Q46.00 160.00 46.00 240.00V256.00Q46.00 336.00 80.00 393.00Q114.00 450.00 173.00 480.00Q232.00 510.00 306.00 510.00Q380.00 510.00 439.00 480.00Q498.00 450.00 532.00 393.00Q566.00 336.00 566.00 256.00V240.00Q566.00 160.00 532.00 103.00Q498.00 46.00 439.00 16.00Q380.00 -14.00 306.00 -14.00ZM306.00 98.00Q364.00 98.00 402.00 135.50Q440.00 173.00 440.00 243.00V253.00Q440.00 323.00 402.50 360.50Q365.00 398.00 306.00 398.00Q248.00 398.00 210.00 360.50Q172.00 323.00 172.00 253.00V243.00Q172.00 173.00 210.00 135.50Q248.00 98.00 306.00 98.00Z"/><path transform="translate(90.00 40.00) scale(0.04000 -0.04000)" d="M70.00 0.00V496.00H194.00V440.00H212.00Q223.00 470.00 248.50 484.00Q274.00 498.00 308.00 498.00H368.00V386.00H306.00Q258.00 386.00 227.00 360.50Q196.00 335.00 196.00 282.00V0.00Z"/><path transform="translate(104.44 40.00) scale(0.04000 -0.04000)" d="M306.00 -14.00Q232.00 -14.00 173.00 16.00Q114.00 46.00 80.00 103.00Q46.00 160.00 46.00 240.00V256.00Q46.00 336.00 80.00 393.00Q114.00 450.00 173.00 480.00Q232.00 510.00 306.00 510.00Q380.00 510.00 439.00 480.00Q498.00 450.00 532.00 393.00Q566.00 336.00 566.00 256.00V240.00Q566.00 160.00 532.00 103.00Q498.00 46.00 439.00 16.00Q380.00 -14.00 306.00 -14.00ZM306.00 98.00Q364.00 98.00 402.00 135.50Q440.00 173.00 440.00 243.00V253.00Q440.00 323.00 402.50 360.50Q365.00 398.00 306.00 398.00Q248.00 398.00 210.00 360.50Q172.00 323.00 172.00 253.00V243.00Q172.00 173.00 210.00 135.50Q248.00 98.00 306.00 98.00Z"/></g><rect x="139.52" y="16" width="1.6" height="26" rx="0.8" fill="var(--bc-brand-mark, #5647ee)" opacity="0.4"/><g fill="var(--bc-brand-mark, #5647ee)"><path transform="translate(154.52 35.00) scale(0.01400 -0.01400)" d="M46.35 0.00V105.85H139.75V594.15H46.35V700.00H404.08Q467.38 700.00 514.79 678.41Q562.20 656.83 588.70 617.33Q615.20 577.83 615.20 523.35V513.70Q615.20 465.52 597.03 434.76Q578.85 404.00 553.91 387.24Q528.98 370.48 506.28 363.30V346.35Q528.98 340.35 555.24 323.50Q581.50 306.65 600.00 275.48Q618.50 244.30 618.50 194.48V184.48Q618.50 126.65 591.41 85.24Q564.33 43.83 516.83 21.91Q469.33 0.00 406.85 0.00ZM259.85 109.15H392.60Q441.20 109.15 469.80 132.60Q498.40 156.05 498.40 198.55V208.20Q498.40 250.70 470.21 274.15Q442.02 297.60 392.60 297.60H259.85ZM259.85 406.75H391.65Q437.55 406.75 466.32 430.11Q495.10 453.48 495.10 493.80V503.80Q495.10 544.95 466.74 567.90Q438.37 590.85 391.65 590.85H259.85Z"/><path transform="translate(167.00 35.00) scale(0.01400 -0.01400)" d="M335.30 -14.00Q251.22 -14.00 190.60 16.63Q129.97 47.25 97.56 104.96Q65.15 162.68 65.15 243.03V700.00H185.25V239.73Q185.25 170.48 224.03 131.99Q262.80 93.50 335.30 93.50Q407.80 93.50 446.57 131.99Q485.35 170.48 485.35 239.73V700.00H605.45V243.03Q605.45 162.68 573.13 104.96Q540.80 47.25 480.09 16.63Q419.38 -14.00 335.30 -14.00Z"/><path transform="translate(179.60 35.00) scale(0.01400 -0.01400)" d="M311.85 -14.00Q238.45 -14.00 179.69 18.50Q120.92 51.00 86.69 113.67Q52.45 176.35 52.45 266.87V433.13Q52.45 569.00 128.01 641.50Q203.57 714.00 332.35 714.00Q460.13 714.00 529.23 645.59Q598.33 577.17 598.33 460.87V456.70H479.87V464.52Q479.87 504.67 463.76 536.86Q447.65 569.05 414.99 587.77Q382.32 606.50 332.35 606.50Q257.63 606.50 215.09 560.70Q172.55 514.90 172.55 435.13V264.87Q172.55 185.93 215.09 138.89Q257.63 91.85 334.00 91.85Q410.02 91.85 445.12 132.83Q480.22 173.80 480.22 237.92V249.67H302.85V351.88H598.33V0.00H487.52V69.10H470.57Q462.70 50.87 446.06 31.44Q429.42 12.00 397.64 -1.00Q365.85 -14.00 311.85 -14.00Z"/><path transform="translate(192.05 35.00) scale(0.01400 -0.01400)" d="M332.35 -14.00Q203.57 -14.00 128.01 58.50Q52.45 131.00 52.45 266.87V433.13Q52.45 569.00 128.01 641.50Q203.57 714.00 332.35 714.00Q460.13 714.00 529.58 643.85Q599.03 573.70 599.03 451.57V445.75H480.57V455.22Q480.57 521.42 444.15 563.96Q407.72 606.50 332.35 606.50Q257.63 606.50 215.09 560.70Q172.55 514.90 172.55 435.13V264.87Q172.55 185.93 215.09 139.71Q257.63 93.50 332.35 93.50Q407.72 93.50 444.15 136.45Q480.57 179.40 480.57 244.78V262.25H599.03V248.43Q599.03 126.30 529.58 56.15Q460.13 -14.00 332.35 -14.00Z"/><path transform="translate(204.25 35.00) scale(0.01400 -0.01400)" d="M19.75 0.00 211.62 700.00H421.33L613.20 0.00H489.27L447.42 159.60H185.53L143.68 0.00ZM214.25 270.40H418.70L324.95 624.87H308.00Z"/><path transform="translate(216.31 35.00) scale(0.01400 -0.01400)" d="M233.95 0.00V590.85H24.70V700.00H563.30V590.85H354.05V0.00Z"/><path transform="translate(227.75 35.00) scale(0.01400 -0.01400)" d="M332.35 -14.00Q203.57 -14.00 128.01 58.50Q52.45 131.00 52.45 266.87V433.13Q52.45 569.00 128.01 641.50Q203.57 714.00 332.35 714.00Q460.13 714.00 529.58 643.85Q599.03 573.70 599.03 451.57V445.75H480.57V455.22Q480.57 521.42 444.15 563.96Q407.72 606.50 332.35 606.50Q257.63 606.50 215.09 560.70Q172.55 514.90 172.55 435.13V264.87Q172.55 185.93 215.09 139.71Q257.63 93.50 332.35 93.50Q407.72 93.50 444.15 136.45Q480.57 179.40 480.57 244.78V262.25H599.03V248.43Q599.03 126.30 529.58 56.15Q460.13 -14.00 332.35 -14.00Z"/><path transform="translate(239.95 35.00) scale(0.01400 -0.01400)" d="M69.50 0.00V700.00H189.60V405.58H466.40V700.00H586.50V0.00H466.40V296.42H189.60V0.00Z"/></g></svg>';
@@ -0,0 +1,251 @@
1
+ // The report panel: the dialog the launcher opens, and everything on it.
2
+ //
3
+ // None of this is in the script every page loads. Most visitors never press
4
+ // the button, and the panel is most of the widget's code and nearly all of its
5
+ // CSS, so it is fetched the first time it is wanted — see widget.js for when,
6
+ // and lazy.js for how.
7
+ //
8
+ // One dialog doing several jobs, one file each:
9
+ //
10
+ // compose.js the report form: drafts, severity, attachments, recording, Send
11
+ // refs.js "which record is this about", asked at Send
12
+ // notices.js what the project's team is telling visitors, at the top
13
+ // known.js what the project already knows is broken, and "me too"
14
+ // picks.js questions the assistant answers by itself, as one tap
15
+ // signin.js who is reporting, proved with a one-time code
16
+ // call.js the call offer, the ringing screen, and the bar over a thread
17
+ // thread.js the reporter's reports, one of them as a conversation
18
+ // composer.js the reply box under that conversation
19
+ //
20
+ // with markup they share in render.js and messages.js.
21
+ //
22
+ // They were one closure, and what they share is still one closure's worth of
23
+ // state: `ctx`, built below. It is passed rather than kept in module variables
24
+ // because two panels must never share it — a widget torn down and mounted again
25
+ // by a second init(), or a page running BugCatch.Client twice, would otherwise
26
+ // find the last panel's draft and the last panel's report in the new one.
27
+
28
+ import * as ICONS from './icons.js';
29
+ import { BRAND } from './icons.js';
30
+ import { PANEL_CSS } from './styles.js';
31
+ import { headHtml, escapeHtml } from './render.js';
32
+ import { composeHtml, mountCompose } from './compose.js';
33
+ import { mountReferences } from './refs.js';
34
+ import { mountNotices } from './notices.js';
35
+ import { mountKnownIssues } from './known.js';
36
+ import { mountPicks } from './picks.js';
37
+ import { mountCallOffer } from './call.js';
38
+ import { mountSignIn } from './signin.js';
39
+ import { mountThread } from './thread.js';
40
+
41
+ /**
42
+ * Open the panel over the page. `preloadCall` fetches the call code ahead of a
43
+ * call being placed; the launcher hands it over because the panel has no way
44
+ * to load code of its own.
45
+ */
46
+ export function openPanel({
47
+ root, host, dock, client, onClose, preloadCall = () => {},
48
+ }) {
49
+ addStyles(root);
50
+ const cfg = client.config;
51
+
52
+ const scrim = document.createElement('div');
53
+ // The panel opens out of whichever corner the dock is in, clearing it by the
54
+ // dock's measured height rather than a guess at the launcher's size.
55
+ scrim.className = `scrim ${dock.classList.contains('at-left') ? 'at-left' : 'at-right'} `
56
+ + `${dock.classList.contains('at-top') ? 'at-top' : 'at-bottom'}`;
57
+ scrim.style.setProperty('--bc-dock-h', `${Math.round(dock.getBoundingClientRect().height)}px`);
58
+ scrim.innerHTML = `
59
+ <div class="panel" role="dialog" aria-modal="true" aria-labelledby="bc-h">
60
+ ${headHtml({
61
+ id: 'bc-h',
62
+ title: 'Tell us what went wrong',
63
+ sub: "Our assistant starts looking right away. We attach the page details for you.",
64
+ })}
65
+ <div class="body">
66
+ <div class="slot" role="alert"></div>
67
+ <!-- What the project's team is telling visitors. Drawn only when it
68
+ has published something. -->
69
+ <div class="notices" hidden></div>
70
+ <div class="whoami" hidden></div>
71
+ <!-- The questions the assistant can answer from their account and
72
+ close by itself. Drawn only when the project offers any. -->
73
+ <div class="picks" hidden></div>
74
+ <div class="known" hidden></div>
75
+ ${composeHtml(cfg)}
76
+ </div>
77
+ <div class="foot">
78
+ <span class="note" aria-live="polite"></span>
79
+ ${client.hasReports() ? '<button class="quiet" type="button" data-act="history">Your reports</button>' : ''}
80
+ <span class="spacer"></span>
81
+ <!-- Hidden until the API confirms this project can take a call, which
82
+ most cannot. Drawn here rather than as a second launcher because it
83
+ is the same errand as Send report — the difference is only whether
84
+ the reporter would rather say it than type it. -->
85
+ <button class="ghost" type="button" data-act="call" hidden>${ICONS.phone}Talk to the team</button>
86
+ <button class="primary" type="button" data-act="send">Send report ${ICONS.send}</button>
87
+ </div>
88
+ <div class="powered">Powered by
89
+ <a class="brand-credit" href="https://bugcatch.sphoro.com" target="_blank" rel="noopener noreferrer" aria-label="BugCatch">${BRAND}</a>
90
+ </div>
91
+ </div>`;
92
+
93
+ const $ = (sel) => scrim.querySelector(sel);
94
+ const slot = $('.slot');
95
+
96
+ // Run as the panel goes, in the order the parts added them: the draft saved,
97
+ // a recording stopped, timers and listeners dropped.
98
+ const cleanups = [];
99
+
100
+ /**
101
+ * What the parts share.
102
+ *
103
+ * The nodes are looked up once and held, not looked up again: the history
104
+ * and report screens replace the panel's markup, and a draft saved on the way
105
+ * out must still read what was typed. The functions left null are filled in
106
+ * by the part that owns them (named beside each), and nothing calls one until
107
+ * every part has mounted — they are reached from events and replies, and from
108
+ * the end of this function.
109
+ */
110
+ const ctx = {
111
+ client,
112
+ cfg,
113
+ root,
114
+ host,
115
+ dock,
116
+ scrim,
117
+ $,
118
+ titleInput: $('#bc-title'),
119
+ descInput: $('#bc-desc'),
120
+ compose: $('.compose'),
121
+ sendBtn: $('[data-act="send"]'),
122
+ callBtn: $('[data-act="call"]'),
123
+ historyBtn: $('[data-act="history"]'),
124
+ note: $('.foot .note'),
125
+ whoami: $('.whoami'),
126
+ refsBox: $('.refs'),
127
+ notices: $('.notices'),
128
+ known: $('.known'),
129
+ picks: $('.picks'),
130
+ // A report or a call is on its way. Escape and a click outside wait for it.
131
+ sending: false,
132
+ closed: false,
133
+ say,
134
+ close,
135
+ preloadCall,
136
+ onDestroy: (fn) => { cleanups.push(fn); },
137
+ // compose.js
138
+ refreshSendState: null,
139
+ isRecording: null,
140
+ stopRecording: null,
141
+ // refs.js
142
+ holdForReference: null,
143
+ answeredReference: null,
144
+ // picks.js
145
+ pickedIssue: null,
146
+ // call.js
147
+ offerCall: null,
148
+ callOffered: null,
149
+ showLiveCall: null,
150
+ // signin.js
151
+ mustSignIn: null,
152
+ recheckSignIn: null,
153
+ // thread.js
154
+ succeed: null,
155
+ refreshThread: null,
156
+ openReportId: null,
157
+ };
158
+
159
+ /* ---------- the parts, in the order they ask the API anything ---------- */
160
+
161
+ mountCompose(ctx);
162
+ mountReferences(ctx);
163
+ mountNotices(ctx);
164
+ mountKnownIssues(ctx);
165
+ mountPicks(ctx);
166
+ mountCallOffer(ctx);
167
+ mountSignIn(ctx);
168
+ mountThread(ctx);
169
+
170
+ /* ---------- lifecycle ---------- */
171
+
172
+ const previouslyFocused = document.activeElement;
173
+ const rootStyle = document.documentElement.style;
174
+ const previousOverflow = rootStyle.overflow;
175
+ rootStyle.overflow = 'hidden';
176
+
177
+ function destroy() {
178
+ if (ctx.closed) return;
179
+ cleanups.forEach((fn) => fn());
180
+ ctx.closed = true;
181
+ rootStyle.overflow = previousOverflow;
182
+ document.removeEventListener('keydown', onKeydown, true);
183
+ scrim.remove();
184
+ dock.hidden = false;
185
+ host.style.opacity = '';
186
+ onClose?.();
187
+ }
188
+
189
+ function close() {
190
+ if (ctx.closed) return;
191
+ // Let the exit animation play, but never wait on an event that a
192
+ // reduced-motion user's near-zero animation might deliver unpredictably.
193
+ scrim.classList.add('closing');
194
+ setTimeout(destroy, 160);
195
+ try { previouslyFocused?.focus?.(); } catch { /* element may be gone */ }
196
+ }
197
+
198
+ function onKeydown(e) {
199
+ if (e.key === 'Escape') {
200
+ e.stopPropagation();
201
+ if (ctx.isRecording()) { ctx.stopRecording({ keep: false }); return; }
202
+ if (!ctx.sending) close();
203
+ return;
204
+ }
205
+ if (e.key !== 'Tab' || ctx.isRecording()) return;
206
+ // Focus trap: the panel is modal, so Tab must not reach the host page.
207
+ const focusable = [...scrim.querySelectorAll(
208
+ 'button:not(:disabled), input:not([hidden]), textarea, select, [href]',
209
+ )].filter((el) => el.offsetParent !== null || el === document.activeElement);
210
+ if (!focusable.length) return;
211
+ const first = focusable[0];
212
+ const last = focusable[focusable.length - 1];
213
+ const active = scrim.getRootNode().activeElement;
214
+ if (e.shiftKey && active === first) { e.preventDefault(); last.focus(); }
215
+ else if (!e.shiftKey && active === last) { e.preventDefault(); first.focus(); }
216
+ }
217
+
218
+ document.addEventListener('keydown', onKeydown, true);
219
+ scrim.addEventListener('click', (e) => { if (e.target === scrim && !ctx.sending) close(); });
220
+ $('[data-act="close"]').addEventListener('click', close);
221
+
222
+ /** The banner at the top of the form: an error, or with `info`, a notice. */
223
+ function say(text, kind = 'err') {
224
+ slot.innerHTML = text
225
+ ? `<div class="msg ${kind}">${kind === 'err' ? ICONS.alert : ''}<span>${escapeHtml(text)}</span></div>`
226
+ : '';
227
+ }
228
+
229
+ root.appendChild(scrim);
230
+ ctx.refreshSendState();
231
+ ctx.titleInput.focus();
232
+
233
+ return { destroy, close };
234
+ }
235
+
236
+ /**
237
+ * Add the panel's stylesheet to the widget's shadow root, once.
238
+ *
239
+ * Straight after the launcher's sheet rather than at the end of the root, so
240
+ * the two stay together at the top and the panel's rules keep their place after
241
+ * the launcher's in the cascade — the order they had as one stylesheet.
242
+ */
243
+ function addStyles(root) {
244
+ if (root.querySelector('style[data-panel]')) return;
245
+ const style = document.createElement('style');
246
+ style.setAttribute('data-panel', '');
247
+ style.textContent = PANEL_CSS;
248
+ const launcherSheet = root.querySelector('style');
249
+ if (launcherSheet) launcherSheet.after(style);
250
+ else root.prepend(style);
251
+ }
@@ -0,0 +1,130 @@
1
+ // What is already known: the project's open issues that look like what the
2
+ // reporter is describing, above the form, with "me too" instead of a second
3
+ // report about the same thing.
4
+
5
+ import { escapeHtml, relativeDay } from './render.js';
6
+
7
+ export function mountKnownIssues(ctx) {
8
+ const {
9
+ client, cfg, titleInput, known,
10
+ } = ctx;
11
+
12
+ // The list as it was last fetched, and who has said what about it. Held here
13
+ // rather than re-read from the DOM so a re-render after a join does not have
14
+ // to parse its own markup back out.
15
+ let issues = [];
16
+ let issueError = '';
17
+ let askingFor = null;
18
+ let matchAbort = null;
19
+
20
+ // Asked once when the dialog opens, like the call offer (call.js) and for the
21
+ // same reason: it is one request per opened widget rather than one per page
22
+ // load of every site running this SDK, and most projects will answer with an
23
+ // empty list forever.
24
+ function loadKnownIssues() {
25
+ if (cfg.knownIssues === false) return;
26
+ client.knownIssues()
27
+ .then((rows) => { issues = rows; drawKnown(); })
28
+ .catch(() => { /* a help panel that cannot load is simply not drawn */ });
29
+ }
30
+
31
+ // Re-run as the title is typed, so the panel narrows to what they are
32
+ // actually describing. Debounced, and the previous request is aborted rather
33
+ // than left to land: a fast typist otherwise renders whichever response comes
34
+ // back last, which is not necessarily the one matching the box.
35
+ let matchTimer = null;
36
+ function matchKnown(query) {
37
+ if (cfg.knownIssues === false) return;
38
+ clearTimeout(matchTimer);
39
+ matchAbort?.abort();
40
+ const q = (query || '').trim();
41
+ if (q.length < 3) { loadKnownIssues(); return; }
42
+ matchTimer = setTimeout(() => {
43
+ matchAbort = new AbortController();
44
+ client.matchKnownIssues(q, { signal: matchAbort.signal })
45
+ .then((rows) => {
46
+ // An empty result means "nothing like this is known", which is not
47
+ // the same as "nothing is known" — fall back to the full list rather
48
+ // than blanking a panel the reporter was reading.
49
+ if (rows.length) { issues = rows; drawKnown(); }
50
+ })
51
+ .catch(() => {});
52
+ }, 250);
53
+ }
54
+
55
+ function drawKnown() {
56
+ if (!issues.length) { known.hidden = true; known.innerHTML = ''; return; }
57
+ known.hidden = false;
58
+ known.innerHTML = `
59
+ <p class="known-h">Already reported</p>
60
+ ${issues.map((issue) => `
61
+ <div class="issue ${issue.joined ? 'is-joined' : ''}">
62
+ <span class="who">
63
+ <span class="t">${escapeHtml(issue.title)}</span>
64
+ <span class="meta">${escapeHtml(knownMeta(issue))}</span>
65
+ </span>
66
+ <button class="join" type="button" data-join="${escapeHtml(issue.id)}"
67
+ ${issue.joined ? 'disabled' : ''}>${issue.joined ? 'Following' : 'Me too'}</button>
68
+ </div>
69
+ ${askingFor === issue.id ? `
70
+ <div class="ask">
71
+ <input type="email" class="join-email" placeholder="you@example.com"
72
+ aria-label="Your email, so we can tell you when this is fixed" />
73
+ <button class="ghost" type="button" data-join-confirm="${escapeHtml(issue.id)}">Follow</button>
74
+ </div>` : ''}
75
+ `).join('')}
76
+ ${issueError ? `<p class="err">${escapeHtml(issueError)}</p>` : ''}`;
77
+ }
78
+
79
+ // What a reporter is told about an issue beside its title. Deliberately not
80
+ // its severity: how bad the team thinks this is, published to everybody who
81
+ // opens the widget, is an internal judgement that reads as a promise.
82
+ function knownMeta(issue) {
83
+ const state = issue.status === 'IN_PROGRESS' ? 'Being worked on now' : 'Not fixed yet';
84
+ // The count is dropped entirely at zero rather than written as "0 people",
85
+ // or as a bare "Reported" that says nothing the heading above has not
86
+ // already said. Nobody having pressed the button yet is not a fact about
87
+ // this issue worth a reporter's attention.
88
+ const others = issue.affectedCount === 0
89
+ ? ''
90
+ : ` · ${issue.affectedCount} ${issue.affectedCount === 1 ? 'person has' : 'people have'} said this is happening to them`;
91
+ return `${state} · reported ${relativeDay(issue.since)}${others}`;
92
+ }
93
+
94
+ known.addEventListener('click', async (e) => {
95
+ const start = e.target.closest('[data-join]');
96
+ const confirm = e.target.closest('[data-join-confirm]');
97
+ if (!start && !confirm) return;
98
+
99
+ const bugId = (confirm || start).getAttribute(confirm ? 'data-join-confirm' : 'data-join');
100
+ issueError = '';
101
+
102
+ // An address is what joining buys them — being told when it is fixed — so
103
+ // ask for one when neither a sign-in nor identify() has supplied it, rather
104
+ // than recording somebody nobody can ever write to.
105
+ const email = known.querySelector('.join-email')?.value?.trim()
106
+ || client.signedInAs()?.email
107
+ || client.config.user?.email;
108
+ if (!email) {
109
+ askingFor = bugId;
110
+ drawKnown();
111
+ known.querySelector('.join-email')?.focus();
112
+ return;
113
+ }
114
+
115
+ try {
116
+ const result = await client.meToo(bugId, { email });
117
+ askingFor = null;
118
+ issues = issues.map((issue) => (issue.id === bugId
119
+ ? { ...issue, joined: true, affectedCount: result.affectedCount ?? issue.affectedCount }
120
+ : issue));
121
+ drawKnown();
122
+ } catch (err) {
123
+ issueError = err.message.replace('[BugCatch] ', '');
124
+ drawKnown();
125
+ }
126
+ });
127
+
128
+ loadKnownIssues();
129
+ titleInput.addEventListener('input', () => matchKnown(titleInput.value));
130
+ }
@@ -0,0 +1,176 @@
1
+ // The thread's markup: one entry per event or message, oldest first, grouped
2
+ // into turns the way a chat groups them. Strings in, strings out — the thread
3
+ // screen (thread.js) decides when to draw them and what to keep on screen.
4
+
5
+ import * as ICONS from './icons.js';
6
+ import { SOLVED_PROMPT } from '../agent-issues.js';
7
+ import { humanSize } from '../capture.js';
8
+ import {
9
+ escapeHtml, dayKey, dayLabel, clockTime, relativeDay, initials,
10
+ } from './render.js';
11
+
12
+ /**
13
+ * The whole thread, oldest first.
14
+ *
15
+ * The API hands back one ordered list holding both kinds of entry, which is
16
+ * what keeps a reply sitting under the status change that prompted it. Entries
17
+ * carrying `from` are messages; the rest are things that happened.
18
+ */
19
+ export function threadHtml(timeline) {
20
+ const entries = timeline || [];
21
+ if (!entries.length) return '';
22
+ let day = null;
23
+ let prev = null;
24
+ return entries.map((e) => {
25
+ // A rule with the date on it whenever the calendar day turns, the way a
26
+ // chat marks that the next message came later.
27
+ const d = dayKey(e.at);
28
+ const sep = d && d !== day ? `<div class="day-sep" role="separator">${escapeHtml(dayLabel(e.at))}</div>` : '';
29
+ if (sep) { day = d; prev = null; }
30
+ if (!e.from) { prev = null; return sep + systemHtml(e); }
31
+ const message = {
32
+ from: e.from,
33
+ author: e.reply?.author,
34
+ automated: e.reply?.automated,
35
+ body: e.reply?.body,
36
+ at: e.at,
37
+ attachments: e.reply?.attachments,
38
+ };
39
+ // Consecutive messages from the same voice within a few minutes are one
40
+ // turn: one avatar, one name, the bubbles stacked under it.
41
+ const who = speakerOf(message);
42
+ const cont = prev && prev.who === who
43
+ && Math.abs(new Date(e.at).getTime() - new Date(prev.at).getTime()) < 5 * 60_000;
44
+ prev = { who, at: e.at };
45
+ return sep + messageHtml(message, cont ? 'cont' : '');
46
+ }).join('');
47
+ }
48
+
49
+ /** Which voice a message is in, for grouping: the reporter, the assistant, or a named person. */
50
+ function speakerOf(message) {
51
+ if (message.from === 'reporter') return 'me';
52
+ if (message.automated) return 'assistant';
53
+ return `team:${message.author || ''}`;
54
+ }
55
+
56
+ /**
57
+ * Something that happened, rather than something someone said.
58
+ *
59
+ * `summary` is written by the API, which knows what changed and phrases it for
60
+ * the person who reported it — so the wording here matches the toast and the
61
+ * portal exactly rather than being a third attempt at the same sentence.
62
+ */
63
+ function systemHtml(entry) {
64
+ const when = clockTime(entry.at);
65
+ return `
66
+ <div class="sys k-${escapeHtml(entry.kind)}">
67
+ <span class="dot" aria-hidden="true"></span>${escapeHtml(entry.summary || '')}${when ? `
68
+ <span class="t">· ${escapeHtml(when)}</span>` : ''}
69
+ </div>`;
70
+ }
71
+
72
+ /**
73
+ * One bubble. `state` marks a message that is still in flight, or failed, or
74
+ * one that continues the turn above it.
75
+ *
76
+ * A reply that was sent automatically says so, above the words rather than
77
+ * after them. The person reading it is deciding whether their problem has been
78
+ * understood, and that decision is different when nobody has read the report —
79
+ * so it is not a footnote, and it is not left to be inferred from the tone.
80
+ */
81
+ export function messageHtml(message, state = '') {
82
+ const mine = message.from === 'reporter';
83
+ const body = message.body || '';
84
+ const attachments = message.attachments || [];
85
+ const automated = !mine && Boolean(message.automated);
86
+
87
+ // The text goes in its own element rather than as a bare child of .bubble:
88
+ // it is the only part that wants `white-space: pre-wrap`, and with that rule
89
+ // on the bubble the template's own newlines and indentation render as a
90
+ // blank line above and below every message.
91
+ const text = body ? `<span class="msg-text">${escapeHtml(body)}</span>` : '';
92
+ const files = attachments.length
93
+ ? `<div class="att">${attachments.map(attachmentHtml).join('')}</div>`
94
+ : '';
95
+
96
+ // Named, or labelled, never both and never neither: an automated message has
97
+ // no author to name, and naming one would be the lie the label exists to
98
+ // prevent. The avatar says the same thing at a glance.
99
+ let who = '';
100
+ let avatar = '';
101
+ if (automated) {
102
+ who = '<span class="msg-who auto">Assistant <span class="tag">AI</span></span>';
103
+ avatar = `<span class="av bot" aria-hidden="true">${ICONS.sparkle}</span>`;
104
+ } else if (!mine) {
105
+ const name = message.author || 'Support team';
106
+ who = `<span class="msg-who">${escapeHtml(name)}</span>`;
107
+ avatar = `<span class="av" aria-hidden="true">${escapeHtml(initials(name))}</span>`;
108
+ }
109
+ if (state.includes('cont')) who = '';
110
+
111
+ // The assistant's one answer ends by inviting a correction (check-rules
112
+ // confirmMessage). One tap sends it, and what comes back goes to the person
113
+ // who now owns the report rather than to another automated round — so the
114
+ // wording is "that's right" and "that's not it", not a yes/no question.
115
+ // The Sphoro Agent's answer ends by asking whether it settled things
116
+ // (SOLVED_PROMPT). Yes closes the report; No puts it in front of a person.
117
+ const confirm = automated && /it goes to the same person/i.test(body)
118
+ ? `<div class="quick" role="group" aria-label="Answer">
119
+ <button type="button" data-quick="Yes, this is the problem">${ICONS.check}That's right</button>
120
+ <button type="button" data-quick="No, that is not the problem">That's not it</button>
121
+ </div>`
122
+ : automated && SOLVED_PROMPT.test(body)
123
+ ? `<div class="quick" role="group" aria-label="Did this answer your question?">
124
+ <button type="button" data-quick="Yes, that answers it">${ICONS.check}Yes, that answers it</button>
125
+ <button type="button" data-quick="No, I still need help">No, I still need help</button>
126
+ </div>`
127
+ : '';
128
+
129
+ return `
130
+ <div class="msg-row ${mine ? 'mine' : 'theirs'} ${automated ? 'auto' : ''} ${state}">
131
+ ${avatar}
132
+ ${who}
133
+ <div class="bubble ${body ? '' : 'silent'}">${text}${files}</div>
134
+ ${confirm}
135
+ <span class="msg-when">${escapeHtml(clockTime(message.at) || relativeDay(message.at))}</span>
136
+ </div>`;
137
+ }
138
+
139
+ /** The assistant, thinking: where its next message will appear. */
140
+ export function typingHtml() {
141
+ return `
142
+ <div class="typing-row">
143
+ <span class="av bot" aria-hidden="true">${ICONS.sparkle}</span>
144
+ <span class="msg-who auto">Assistant <span class="tag">AI</span></span>
145
+ <div class="bubble typing" role="img" aria-label="The assistant is working on it"><i></i><i></i><i></i></div>
146
+ </div>`;
147
+ }
148
+
149
+ /**
150
+ * One attachment, rendered as what it is.
151
+ *
152
+ * An image people can see, a voice note they can play and a document they can
153
+ * download are three different things, and collapsing them all to a link is
154
+ * how a chat stops feeling like one. A pending upload has no URL yet, so it
155
+ * falls through to the file row with nothing to click.
156
+ */
157
+ function attachmentHtml(a) {
158
+ const name = escapeHtml(a.name || 'attachment');
159
+ if (!a.url) return `<span class="file"><span class="nm">${name}</span></span>`;
160
+ const url = escapeHtml(a.url);
161
+
162
+ if (a.type === 'SCREENSHOT') {
163
+ return `<a href="${url}" target="_blank" rel="noopener noreferrer"><img src="${url}" alt="${name}" loading="lazy"></a>`;
164
+ }
165
+ if (a.type === 'AUDIO') {
166
+ return `<audio controls preload="metadata" src="${url}"></audio>`;
167
+ }
168
+ if (a.type === 'VIDEO') {
169
+ return `<video controls preload="metadata" src="${url}"></video>`;
170
+ }
171
+ return `
172
+ <a class="file" href="${url}" target="_blank" rel="noopener noreferrer" download>
173
+ <span class="nm">${name}</span>
174
+ ${a.size ? `<span class="sz">${escapeHtml(humanSize(a.size))}</span>` : ''}
175
+ </a>`;
176
+ }
@@ -0,0 +1,34 @@
1
+ // What the project's team is telling visitors, at the top of the form: the place
2
+ // to say "we know, it's being fixed" before somebody types it out.
3
+
4
+ import { noticeNode } from '../notice.js';
5
+
6
+ // A panel opened to report a bug, not to read news. A project with more live
7
+ // than this has written a page, and the newest are the ones still true — the
8
+ // API sends them newest first.
9
+ const MOST = 3;
10
+
11
+ export function mountNotices(ctx) {
12
+ const { client, cfg, notices } = ctx;
13
+ let list = [];
14
+
15
+ function draw() {
16
+ const shown = list.slice(0, MOST);
17
+ notices.replaceChildren(...shown.map((a) => noticeNode(a, { where: 'panel', onDismiss })));
18
+ notices.hidden = shown.length === 0;
19
+ }
20
+
21
+ function onDismiss(a) {
22
+ client.dismissAnnouncement(a);
23
+ list = list.filter((x) => x !== a);
24
+ draw();
25
+ // The button pressed has gone; the form is where they were headed.
26
+ ctx.titleInput.focus();
27
+ }
28
+
29
+ if (cfg.announcements !== false && typeof client.announcements === 'function') {
30
+ client.announcements()
31
+ .then((rows) => { if (!ctx.closed) { list = rows; draw(); } })
32
+ .catch(() => { /* a panel with no notices is still a panel that files reports */ });
33
+ }
34
+ }