@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/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@sphoro/bugcatch-web-sdk",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "description": "Drop-in browser SDK — report bugs with console, network and environment context, without the extension.",
5
5
  "type": "module",
6
6
  "main": "dist/bugcatch.es.js",
7
7
  "module": "dist/bugcatch.es.js",
8
- "browser": "dist/bugcatch.umd.js",
8
+ "browser": "dist/bugcatch.standalone.umd.js",
9
9
  "unpkg": "dist/bugcatch.umd.js",
10
10
  "jsdelivr": "dist/bugcatch.umd.js",
11
11
  "types": "src/index.d.ts",
@@ -15,7 +15,7 @@
15
15
  "import": "./dist/bugcatch.es.js",
16
16
  "default": "./dist/bugcatch.es.js"
17
17
  },
18
- "./dist/bugcatch.umd.js": "./dist/bugcatch.umd.js",
18
+ "./dist/*": "./dist/*",
19
19
  "./package.json": "./package.json"
20
20
  },
21
21
  "files": [
@@ -24,9 +24,9 @@
24
24
  ],
25
25
  "sideEffects": false,
26
26
  "scripts": {
27
- "build": "vite build",
28
- "dev": "vite build --watch",
29
- "prepublishOnly": "vite build"
27
+ "build": "node scripts/build.mjs",
28
+ "dev": "node scripts/build.mjs --watch",
29
+ "prepublishOnly": "node scripts/build.mjs"
30
30
  },
31
31
  "keywords": [
32
32
  "bug-report",
package/src/accent.js ADDED
@@ -0,0 +1,38 @@
1
+ // The colour the widget's buttons wear, and the colour of the words on them.
2
+ //
3
+ // With no `accent` configured the widget is ink: a black pill on a light page
4
+ // and a white one on a dark page, which sits on any brand without arguing with
5
+ // it. A configured accent is the host's own colour, and the text on it is
6
+ // whichever of white or ink can be read against it — a pale brand colour with
7
+ // white words on it is a button nobody can read.
8
+
9
+ export const INK = '#0e0e10';
10
+ export const PAPER = '#ffffff';
11
+
12
+ /**
13
+ * White or ink, whichever reads on `color`. Only colours this can parse (#rgb,
14
+ * #rrggbb, rgb()) are measured; anything else (a name, hsl(), a variable) gets
15
+ * white, which is what every accent got before this existed.
16
+ */
17
+ export function readableOn(color) {
18
+ const rgb = parse(color);
19
+ if (!rgb) return PAPER;
20
+ const [r, g, b] = rgb.map((c) => {
21
+ const s = c / 255;
22
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
23
+ });
24
+ const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
25
+ // The crossover where ink starts to out-contrast white.
26
+ return luminance > 0.179 ? INK : PAPER;
27
+ }
28
+
29
+ function parse(color) {
30
+ const s = String(color || '').trim();
31
+ let m = /^#([0-9a-f]{3})$/i.exec(s);
32
+ if (m) return [...m[1]].map((h) => parseInt(h + h, 16));
33
+ m = /^#([0-9a-f]{6})$/i.exec(s);
34
+ if (m) return [0, 2, 4].map((i) => parseInt(m[1].slice(i, i + 2), 16));
35
+ m = /^rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/i.exec(s);
36
+ if (m) return [m[1], m[2], m[3]].map(Number);
37
+ return null;
38
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * What the project's team is telling the people using its widget — "UPI
3
+ * payments are delayed today, no need to report it" — written on the Widget
4
+ * announcements settings page and changed there whenever it needs to be,
5
+ * without the site redeploying anything.
6
+ *
7
+ * The API decides what is live (GET /ingest/announcements): published, and in
8
+ * date for somebody on earth. This file decides the two things only the browser
9
+ * can — whether it is in date on the visitor's own day, and whether this
10
+ * visitor has closed it — and keeps the answer for a few minutes.
11
+ *
12
+ * Kept, unlike the other things the widget asks about, because this is the one
13
+ * asked on page load: a card beside the button has to be known before anybody
14
+ * presses it. Without the cache that is one request per page view on every site
15
+ * running the SDK, for an answer that is an empty list on nearly all of them.
16
+ * With it, a browser asks at most once every CACHE_MS, and a notice written now
17
+ * reaches a visitor within that.
18
+ *
19
+ * Every failure is an empty list. A notice is never worth a broken widget.
20
+ */
21
+
22
+ const CACHE_MS = 5 * 60_000;
23
+ const SEEN_KEY = 'bugcatch:announcements:seen';
24
+ // More than can ever be live at once; the entry that falls off is one whose
25
+ // notice ended long ago, and the cost of being wrong is closing it twice.
26
+ const SEEN_LIMIT = 40;
27
+
28
+ const TONES = ['info', 'success', 'warning', 'critical'];
29
+
30
+ const cacheKey = (projectId) => `bugcatch:announcements:${projectId}`;
31
+
32
+ export async function fetchAnnouncements({
33
+ apiBase, apiKey, projectId, fresh = false,
34
+ }) {
35
+ if (!fresh) {
36
+ const held = readCache(projectId);
37
+ if (held) return held;
38
+ }
39
+ try {
40
+ const res = await fetch(
41
+ `${apiBase}/ingest/announcements?projectId=${encodeURIComponent(projectId)}`,
42
+ { headers: { 'X-BugCatch-Key': apiKey } },
43
+ );
44
+ // Kept even when refused. An API from before this route answers 404 on
45
+ // every page, and asking it again on the next one changes nothing.
46
+ const list = res.ok ? cleanList((await res.json())?.announcements) : [];
47
+ writeCache(projectId, list);
48
+ return list;
49
+ } catch {
50
+ // Offline, or blocked by a CSP — not kept, so the next page tries again.
51
+ return [];
52
+ }
53
+ }
54
+
55
+ /** What to draw: in date where the visitor is, and not closed by them. */
56
+ export function visibleAnnouncements(list, { on = localDay(), seen = readSeen() } = {}) {
57
+ return (list || []).filter((a) => inDate(a, on) && !(a.dismissible && seen.includes(seenKey(a))));
58
+ }
59
+
60
+ /** Remember that this visitor closed it, until a "show it again" bumps the revision. */
61
+ export function dismissAnnouncement(a) {
62
+ if (!a?.id || !a.dismissible) return;
63
+ try {
64
+ const key = seenKey(a);
65
+ const next = [...readSeen().filter((k) => k !== key), key];
66
+ localStorage.setItem(SEEN_KEY, JSON.stringify(next.slice(-SEEN_LIMIT)));
67
+ } catch { /* storage denied — it still goes away for this page */ }
68
+ }
69
+
70
+ /**
71
+ * Whether a link is one to draw at all. The API refuses anything else, and this
72
+ * is the second lock on the same door: the widget renders it as a link on the
73
+ * customer's site, and `javascript:` there is a script run by whoever wrote it.
74
+ */
75
+ export function safeHref(href) {
76
+ if (typeof href !== 'string' || !href) return null;
77
+ if (href.startsWith('//')) return null;
78
+ if (href.startsWith('/')) return href;
79
+ return /^(https?:\/\/|mailto:)./i.test(href) ? href : null;
80
+ }
81
+
82
+ /** Today where the visitor is, as YYYY-MM-DD — never UTC; see the API's dayIn(). */
83
+ export function localDay(now = new Date()) {
84
+ const pad = (n) => String(n).padStart(2, '0');
85
+ return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
86
+ }
87
+
88
+ function inDate(a, on) {
89
+ if (a.startsOn && on < a.startsOn) return false;
90
+ if (a.endsOn && on > a.endsOn) return false;
91
+ return true;
92
+ }
93
+
94
+ function seenKey(a) {
95
+ return `${a.id}#${a.revision || 0}`;
96
+ }
97
+
98
+ /** The shape the widget draws, from whatever came over the wire. */
99
+ function cleanList(rows) {
100
+ return (Array.isArray(rows) ? rows : [])
101
+ .filter((a) => a && typeof a.id === 'string' && typeof a.title === 'string' && a.title.trim())
102
+ .map((a) => {
103
+ const placement = a.placement === 'launcher' ? 'launcher' : 'panel';
104
+ const href = safeHref(a.ctaHref);
105
+ return {
106
+ id: a.id,
107
+ revision: Number.isInteger(a.revision) ? a.revision : 0,
108
+ title: a.title,
109
+ body: typeof a.body === 'string' ? a.body : '',
110
+ tone: TONES.includes(a.tone) ? a.tone : 'info',
111
+ // Both halves, or neither, as the API saves them.
112
+ ctaLabel: href && typeof a.ctaLabel === 'string' ? a.ctaLabel : '',
113
+ ctaHref: href && typeof a.ctaLabel === 'string' ? href : '',
114
+ placement,
115
+ dismissible: placement === 'launcher' || a.dismissible !== false,
116
+ startsOn: typeof a.startsOn === 'string' ? a.startsOn : '',
117
+ endsOn: typeof a.endsOn === 'string' ? a.endsOn : '',
118
+ };
119
+ });
120
+ }
121
+
122
+ function readSeen() {
123
+ try {
124
+ const parsed = JSON.parse(localStorage.getItem(SEEN_KEY) || '[]');
125
+ return Array.isArray(parsed) ? parsed.filter((k) => typeof k === 'string') : [];
126
+ } catch {
127
+ return [];
128
+ }
129
+ }
130
+
131
+ function readCache(projectId) {
132
+ try {
133
+ const held = JSON.parse(localStorage.getItem(cacheKey(projectId)) || 'null');
134
+ if (!held || !Array.isArray(held.list) || typeof held.at !== 'number') return null;
135
+ // A clock moved backwards reads as fresh forever without the second half.
136
+ const age = Date.now() - held.at;
137
+ return age >= 0 && age < CACHE_MS ? held.list : null;
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
142
+
143
+ function writeCache(projectId, list) {
144
+ try {
145
+ localStorage.setItem(cacheKey(projectId), JSON.stringify({ at: Date.now(), list }));
146
+ } catch { /* not kept — the next page asks again */ }
147
+ }
package/src/busy.js ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * What somebody is told when the API refuses a request for coming too fast
3
+ * (HTTP 429).
4
+ *
5
+ * The API's own refusal is already written for a stranger — "Too many reports
6
+ * are being sent from this site at the moment. Please try again in a minute."
7
+ * — and is used wherever it arrived. This is for when it did not, such as a
8
+ * proxy answering 429 with a page of HTML: the person still reads a sentence
9
+ * saying that waiting will fix it, rather than "HTTP 429", which reads as
10
+ * something they broke.
11
+ */
12
+ export const TOO_MANY_REQUESTS =
13
+ 'Too many requests are being sent from here at the moment. Please try again in a minute.';
14
+
15
+ /** The sentence for a 429: the server's own when there was one. */
16
+ export function tooManyMessage(detail) {
17
+ return detail || TOO_MANY_REQUESTS;
18
+ }
package/src/call.js ADDED
@@ -0,0 +1,7 @@
1
+ // Everything a call needs, as one part that is fetched only when a call is
2
+ // offered, placed, or rings (lazy.js). Nothing is added here: the files it
3
+ // gathers stay where they are, because the dashboard imports live-call.js by
4
+ // that path to take the other end of the same call.
5
+
6
+ export { joinLiveCall, liveCallSupported, openMicrophone } from './live-call.js';
7
+ export { showIncomingCall } from './incoming-call.js';
package/src/capture.js CHANGED
@@ -6,6 +6,8 @@
6
6
  // purpose: it would need a heavy dependency and would still silently miss
7
7
  // cross-origin iframes, canvas and video, which is worse than not offering it.
8
8
 
9
+ import { tooManyMessage } from './busy.js';
10
+
9
11
  /**
10
12
  * Pristine references, taken at module-evaluation time — which happens on
11
13
  * import, before init() patches anything.
@@ -252,6 +254,13 @@ export async function uploadAttachment({ apiBase, apiKey, blob, mime, onProgress
252
254
  headers: { 'Content-Type': 'application/json', 'X-BugCatch-Key': apiKey },
253
255
  body: JSON.stringify({ mime: type }),
254
256
  });
257
+ if (res.status === 429) {
258
+ // Uploading faster than the API allows. The server's sentence when it sent
259
+ // one; either way words, not a status code, because the widget shows this.
260
+ let detail = '';
261
+ try { detail = (await res.json())?.message || ''; } catch { /* not JSON */ }
262
+ throw new Error(`[BugCatch] ${tooManyMessage(typeof detail === 'string' ? detail : '')}`);
263
+ }
255
264
  if (!res.ok) throw new Error(`[BugCatch] could not start the upload (HTTP ${res.status}).`);
256
265
  const { key, uploadUrl } = await res.json();
257
266
 
package/src/chunks.js ADDED
@@ -0,0 +1,56 @@
1
+ // What the two loaders (lazy.js, umd/lazy.js) share: how a part of the
2
+ // script-tag build that arrived on its own is found, and what a part that
3
+ // never arrived is called.
4
+ //
5
+ // The ES build does not use the registry: a bundler splits it at its import()
6
+ // calls and wires the pieces back together itself. A <script> tag has no such
7
+ // machinery, so each part of that build is a plain script that, when it runs,
8
+ // leaves its exports under a name on `window` — and umd/lazy.js, having added
9
+ // the script, reads them from there once it has loaded.
10
+ //
11
+ // The name carries the version, so a part left in a cache by an older release
12
+ // is never mistaken for this one's: it registers under its own version, this
13
+ // release finds nothing under its, and says so rather than running code that
14
+ // expects a different client.
15
+
16
+ // Replaced at build time from package.json; see vite.config.js.
17
+ const VERSION = typeof __SDK_VERSION__ === 'string' ? __SDK_VERSION__ : '0.0.0-dev';
18
+
19
+ const REGISTRY = '__bugcatchChunks';
20
+
21
+ const key = (name) => `${name}@${VERSION}`;
22
+
23
+ /** Called by a part as its script runs: here is what I export. */
24
+ export function registerChunk(name, exports) {
25
+ const registry = window[REGISTRY] || (window[REGISTRY] = {});
26
+ registry[key(name)] = exports;
27
+ }
28
+
29
+ /** What a part registered, once its script has run; undefined until then. */
30
+ export function registeredChunk(name) {
31
+ return typeof window !== 'undefined' ? window[REGISTRY]?.[key(name)] : undefined;
32
+ }
33
+
34
+ /**
35
+ * A part that could not be loaded, said twice: the details for whoever is
36
+ * debugging the page, in the console, and a sentence for the person using it,
37
+ * as the error — which is what the panel shows when a call cannot start.
38
+ */
39
+ export function chunkUnavailable(detail, cause) {
40
+ // eslint-disable-next-line no-console
41
+ console.warn(`[BugCatch] ${detail}`, ...(cause ? [cause] : []));
42
+ const err = new Error('[BugCatch] part of the widget could not be loaded — check the connection and try again.');
43
+ if (cause) err.cause = cause;
44
+ return err;
45
+ }
46
+
47
+ /** One fetch per part at a time; a failure is forgotten, so the next ask retries. */
48
+ export function once(load) {
49
+ let pending = null;
50
+ return (...args) => {
51
+ pending ||= load(...args).catch((err) => { pending = null; throw err; });
52
+ return pending;
53
+ };
54
+ }
55
+
56
+ export { VERSION as CHUNK_VERSION };
package/src/gesture.js ADDED
@@ -0,0 +1,33 @@
1
+ // Whether anybody has touched this page yet — for the incoming-call card,
2
+ // which may only make a sound once somebody has.
3
+ //
4
+ // Tracked rather than asked, because there is nothing to ask: a browser will
5
+ // tell you an AudioContext is suspended, but only after you have built one —
6
+ // and building one on a page with no interaction is what writes
7
+ //
8
+ // The AudioContext was not allowed to start. It must be resumed (or
9
+ // created) after a user gesture on the page.
10
+ //
11
+ // into the console. This SDK runs on somebody else's site, and that console
12
+ // belongs to whoever is trying to debug their own product. A warning we cannot
13
+ // act on, for a sound that was never going to play, is not ours to put there.
14
+ //
15
+ // Here, in the script every page loads, rather than beside the card: the card's
16
+ // code is fetched when a call rings (lazy.js), and listeners added then would
17
+ // have missed every click that came before it.
18
+
19
+ let touched = false;
20
+ if (typeof window !== 'undefined' && window.addEventListener) {
21
+ for (const evt of ['pointerdown', 'keydown', 'touchstart']) {
22
+ // Capture, and it matters more here than it would in our own app: this runs
23
+ // on somebody else's page, whose handlers we have never seen and one of
24
+ // which may well stop a click before it reaches the window.
25
+ window.addEventListener(evt, () => { touched = true; }, {
26
+ once: true, capture: true, passive: true,
27
+ });
28
+ }
29
+ }
30
+
31
+ export function pageTouched() {
32
+ return touched;
33
+ }
package/src/icons.js ADDED
@@ -0,0 +1,18 @@
1
+ // The handful of glyphs the launcher draws before the panel exists: its own
2
+ // two, the theme switch's three, what the load-failure card needs, and the
3
+ // tones of an announcement beside the button (notice.js). Every other icon
4
+ // lives with the panel (panel/icons.js), which re-exports these.
5
+ //
6
+ // One export per glyph rather than one object, so a bundle carries only the
7
+ // ones something draws — imported as `* as ICONS` and read as ICONS.chat.
8
+
9
+ export const 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>';
10
+ export const 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>';
11
+ export const 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>';
12
+ export const 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>';
13
+ export const 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>';
14
+ export const 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>';
15
+ export const 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>';
16
+ export const 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>';
17
+ export const info = '<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 11v5.5M12 7.7h.01"/></svg>';
18
+ export const warn = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.3 4.2a2 2 0 0 1 3.4 0l7.6 13.1a2 2 0 0 1-1.7 3H4.4a2 2 0 0 1-1.7-3z"/><path d="M12 9.5v4.2M12 17h.01"/></svg>';
package/src/identity.js CHANGED
@@ -11,14 +11,17 @@
11
11
  * signed in and which loan is on screen. Passed as `loanId`, `applicationId` or
12
12
  * `customerNumber`, the API reads them as identifiers the app itself asserted
13
13
  * (reference source `identify`), which is what lets bugcatch look the customer
14
- * up in the lender's own system over MCP without asking the reporter — and lets
15
- * that lookup count as a trusted join. Passed under any other name they are
16
- * still sent, but only as metadata a person can read.
14
+ * up in the lender's own system over MCP without asking the reporter. They are
15
+ * a lookup, not proof: the widget key is public, so the Sphoro Agent treats the
16
+ * customer as proved only when the host's server signed the `id` (`userHash`,
17
+ * checked by the API against the project's identity secret) or the reporter
18
+ * verified their email with a code. Passed under any other name they are still
19
+ * sent, but only as metadata a person can read.
17
20
  *
18
21
  * WHERE THEY GO. The signed-in user's fields travel as `metadata.user`, which is
19
- * where the API has always read `id` and where the ownership check reads `phone`
20
- * and `email`. The current screen travels as `metadata.context`. Both shapes are
21
- * mirrored by bugcatch-api/src/settings/reference-extract.ts identifyReferences.
22
+ * where the API has always read `id` (and `userHash`). The current screen
23
+ * travels as `metadata.context`. Both shapes are mirrored by
24
+ * bugcatch-api/src/settings/reference-extract.ts identifyReferences.
22
25
  */
23
26
 
24
27
  /** Longest identifier the API keeps; anything longer is dropped there too. */
@@ -98,8 +101,9 @@ const RESERVED_USER = new Set(['id', 'userId', ...USER_TEXT, ...Object.values(ID
98
101
  * id string | number the customer's own account id in your system
99
102
  * email string threads their reports; where "resolved" mail goes
100
103
  * name string
101
- * phone string | number used, with email, to confirm the reporter owns
102
- * the record bugcatch looked up
104
+ * phone string | number for your team to read; never proof of who they are
105
+ * userHash string HMAC-SHA256(identity secret, id), made on your
106
+ * server — what makes `id` proof rather than a claim
103
107
  * loanId string | number (alias loanNumber)
104
108
  * applicationId string | number
105
109
  * customerNumber string | number (alias customerId)
@@ -203,6 +207,7 @@ export const KNOWN_OPTIONS = [
203
207
  'user', 'context', 'updates', 'updateToast', 'onUpdate', 'voice', 'onCallState',
204
208
  'onIncomingCall', 'onReportSubmitted', 'onAssistantPhase', 'onError', 'debug',
205
209
  'drafts', 'knownIssues', 'agentIssues', 'release', 'environment', 'buildId',
210
+ 'chunkBase', 'announcements',
206
211
  ];
207
212
 
208
213
  const CALLBACK_OPTIONS = [
@@ -37,6 +37,8 @@
37
37
  // yet, which is its rule and not ours — the card rings silently rather than not
38
38
  // appearing.
39
39
 
40
+ import { readableOn, INK, PAPER } from './accent.js';
41
+
40
42
  /**
41
43
  * A quiet double-pulse, once every three seconds.
42
44
  *
@@ -53,32 +55,6 @@ const RING = {
53
55
  ],
54
56
  };
55
57
 
56
- /**
57
- * Whether anybody has touched this page yet.
58
- *
59
- * Tracked rather than asked, because there is nothing to ask: a browser will
60
- * tell you an AudioContext is suspended, but only after you have built one —
61
- * and building one on a page with no interaction is what writes
62
- *
63
- * The AudioContext was not allowed to start. It must be resumed (or
64
- * created) after a user gesture on the page.
65
- *
66
- * into the console. This SDK runs on somebody else's site, and that console
67
- * belongs to whoever is trying to debug their own product. A warning we cannot
68
- * act on, for a sound that was never going to play, is not ours to put there.
69
- */
70
- let touched = false;
71
- if (typeof window !== 'undefined' && window.addEventListener) {
72
- for (const evt of ['pointerdown', 'keydown', 'touchstart']) {
73
- // Capture, and it matters more here than it would in our own app: this runs
74
- // on somebody else's page, whose handlers we have never seen and one of
75
- // which may well stop a click before it reaches the window.
76
- window.addEventListener(evt, () => { touched = true; }, {
77
- once: true, capture: true, passive: true,
78
- });
79
- }
80
- }
81
-
82
58
  /**
83
59
  * The same question asked of the browser rather than of ourselves.
84
60
  *
@@ -104,8 +80,8 @@ function activatedByBrowser() {
104
80
  * permission, and the correct behaviour there is a card that appears in silence
105
81
  * rather than no card.
106
82
  */
107
- function startRinging() {
108
- if (!touched && !activatedByBrowser()) return () => {};
83
+ function startRinging(touched) {
84
+ if (!touched() && !activatedByBrowser()) return () => {};
109
85
 
110
86
  const Ctx = typeof window !== 'undefined' && (window.AudioContext || window.webkitAudioContext);
111
87
  if (!Ctx) return () => {};
@@ -183,8 +159,8 @@ const BUTTON = [
183
159
  'flex:1',
184
160
  'padding:9px 12px',
185
161
  'border:0',
186
- 'border-radius:8px',
187
- 'font:600 13px/1.4 inherit',
162
+ 'border-radius:999px',
163
+ 'font:560 13px/1.4 inherit',
188
164
  'cursor:pointer',
189
165
  ].join(';');
190
166
 
@@ -194,17 +170,22 @@ const BUTTON = [
194
170
  * @param {object} opts
195
171
  * @param {object} opts.call The `incomingCall` object off the update stream —
196
172
  * `callId`, `from`, `project`, `reference`, `ringsUntil`, `recorded`.
197
- * @param {string} opts.accent The integration's own colour, for Answer.
173
+ * @param {string|null} opts.accent The integration's own colour, for Answer;
174
+ * null is the ink default, which on this dark card is a white button.
198
175
  * @param {string} [opts.position] Same two words the toast takes.
199
176
  * @param {() => void} opts.onAnswer Pressed Answer.
200
177
  * @param {() => void} opts.onDecline Pressed Decline.
201
178
  * @param {() => void} [opts.onExpire] Nobody pressed anything in time.
179
+ * @param {() => boolean} [opts.touched] Whether anybody has touched the page
180
+ * yet, which is when a sound may be made (gesture.js). Asked of the caller
181
+ * because this file is fetched when a call rings, and could not have been
182
+ * listening for the clicks that came before.
202
183
  * @returns {(reason?: string) => void} Take it away. Called with no reason on
203
184
  * an ordinary dismissal; the caller passes one only for its own logging.
204
185
  */
205
186
  export function showIncomingCall({
206
- call, accent = '#5b6ef5', position = 'bottom-right',
207
- onAnswer, onDecline, onExpire,
187
+ call, accent = null, position = 'bottom-right',
188
+ onAnswer, onDecline, onExpire, touched = () => false,
208
189
  }) {
209
190
  if (typeof document === 'undefined' || !document.body) return () => {};
210
191
 
@@ -222,13 +203,12 @@ export function showIncomingCall({
222
203
  'z-index:2147483647',
223
204
  'width:300px',
224
205
  'max-width:calc(100vw - 40px)',
225
- 'padding:14px',
226
- 'border-radius:12px',
227
- 'background:#1b1e26',
228
- 'color:#eceef2',
206
+ 'padding:16px',
207
+ 'border-radius:16px',
208
+ 'background:#111113',
209
+ 'color:#f3f2ef',
229
210
  'font:13px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif',
230
- 'box-shadow:0 18px 44px rgba(0,0,0,0.5)',
231
- `border:1px solid ${accent}`,
211
+ `box-shadow:0 0 0 1px ${accent ? `color-mix(in srgb, ${accent} 55%, transparent)` : 'rgba(255,255,255,.12)'},0 24px 50px -12px rgba(0,0,0,.6)`,
232
212
  'opacity:0',
233
213
  'transform:translateY(8px)',
234
214
  'transition:opacity .2s ease,transform .2s ease',
@@ -241,23 +221,23 @@ export function showIncomingCall({
241
221
  heading.textContent = `${call.from || 'Support'} is calling`;
242
222
 
243
223
  const about = document.createElement('div');
244
- about.style.cssText = 'color:#a4abba;margin-bottom:10px';
224
+ about.style.cssText = 'color:#b3b0aa;margin-bottom:10px';
245
225
  about.textContent = call.project
246
226
  ? `About your report ${call.reference} · ${call.project}`
247
227
  : `About your report ${call.reference}`;
248
228
 
249
229
  const countdown = document.createElement('div');
250
- countdown.style.cssText = 'color:#7d8598;font-size:12px;margin-bottom:10px';
230
+ countdown.style.cssText = 'color:#8f8c86;font-size:12px;margin-bottom:10px';
251
231
 
252
232
  const answer = document.createElement('button');
253
233
  answer.type = 'button';
254
234
  answer.textContent = 'Answer';
255
- answer.style.cssText = `${BUTTON};background:${accent};color:#fff`;
235
+ answer.style.cssText = `${BUTTON};background:${accent || PAPER};color:${accent ? readableOn(accent) : INK}`;
256
236
 
257
237
  const decline = document.createElement('button');
258
238
  decline.type = 'button';
259
239
  decline.textContent = 'Not now';
260
- decline.style.cssText = `${BUTTON};background:#2b2f3a;color:#c8cedb`;
240
+ decline.style.cssText = `${BUTTON};background:rgba(255,255,255,.08);color:#f3f2ef`;
261
241
 
262
242
  const actions = document.createElement('div');
263
243
  actions.style.cssText = 'display:flex;gap:8px';
@@ -266,14 +246,14 @@ export function showIncomingCall({
266
246
  el.append(heading, about, countdown);
267
247
  if (call.recorded) {
268
248
  const kept = document.createElement('div');
269
- kept.style.cssText = 'color:#7d8598;font-size:12px;margin-bottom:10px';
249
+ kept.style.cssText = 'color:#8f8c86;font-size:12px;margin-bottom:10px';
270
250
  kept.textContent = 'This call will be recorded.';
271
251
  el.append(kept);
272
252
  }
273
253
  el.append(actions);
274
254
  document.body.appendChild(el);
275
255
 
276
- const stopRinging = startRinging();
256
+ const stopRinging = startRinging(touched);
277
257
  let closed = false;
278
258
  let ticker = null;
279
259