@convai/web-sdk 1.7.0 → 1.8.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/core/ConvaiClient.d.ts +40 -1
  2. package/dist/core/ConvaiClient.d.ts.map +1 -1
  3. package/dist/core/ConvaiClient.js +276 -167
  4. package/dist/core/ConvaiClient.js.map +1 -1
  5. package/dist/core/connectRequest.d.ts +1 -1
  6. package/dist/core/connectRequest.d.ts.map +1 -1
  7. package/dist/core/connectRequest.js.map +1 -1
  8. package/dist/core/types.d.ts +50 -2
  9. package/dist/core/types.d.ts.map +1 -1
  10. package/dist/core/types.js.map +1 -1
  11. package/dist/react/components/rtc-widget/components/AudioVisualizer.d.ts.map +1 -1
  12. package/dist/react/components/rtc-widget/components/AudioVisualizer.js +28 -1
  13. package/dist/react/components/rtc-widget/components/AudioVisualizer.js.map +1 -1
  14. package/dist/react/components/rtc-widget/components/conviComponents/VoiceModeOverlay.d.ts.map +1 -1
  15. package/dist/react/components/rtc-widget/components/conviComponents/VoiceModeOverlay.js +93 -49
  16. package/dist/react/components/rtc-widget/components/conviComponents/VoiceModeOverlay.js.map +1 -1
  17. package/dist/react/hooks/useConvaiClient.d.ts.map +1 -1
  18. package/dist/react/hooks/useConvaiClient.js +1 -0
  19. package/dist/react/hooks/useConvaiClient.js.map +1 -1
  20. package/dist/vanilla/AudioRenderer.d.ts +3 -1
  21. package/dist/vanilla/AudioRenderer.d.ts.map +1 -1
  22. package/dist/vanilla/AudioRenderer.js +2 -2
  23. package/dist/vanilla/AudioRenderer.js.map +1 -1
  24. package/dist/vanilla/ConvaiWidget.d.ts.map +1 -1
  25. package/dist/vanilla/ConvaiWidget.js +602 -114
  26. package/dist/vanilla/ConvaiWidget.js.map +1 -1
  27. package/dist/vanilla/icons.d.ts.map +1 -1
  28. package/dist/vanilla/icons.js +62 -15
  29. package/dist/vanilla/icons.js.map +1 -1
  30. package/dist/vanilla/index.d.ts +1 -1
  31. package/dist/vanilla/index.d.ts.map +1 -1
  32. package/dist/vanilla/index.js.map +1 -1
  33. package/dist/vanilla/styles.d.ts +18 -1
  34. package/dist/vanilla/styles.d.ts.map +1 -1
  35. package/dist/vanilla/styles.js +105 -29
  36. package/dist/vanilla/styles.js.map +1 -1
  37. package/dist/vanilla/types.d.ts +74 -0
  38. package/dist/vanilla/types.d.ts.map +1 -1
  39. package/dist/vanilla/types.js.map +1 -1
  40. package/dist/version.d.ts +1 -1
  41. package/dist/version.d.ts.map +1 -1
  42. package/dist/version.js +1 -1
  43. package/dist/version.js.map +1 -1
  44. package/package.json +14 -7
@@ -3,9 +3,104 @@
3
3
  * Ports the React ConvaiWidget to vanilla TypeScript with DOM manipulation
4
4
  */
5
5
  import { AudioRenderer } from "./AudioRenderer";
6
- import { aeroTheme, injectGlobalStyles } from "./styles";
6
+ import { aeroTheme, injectGlobalStyles, injectKeyframes, } from "./styles";
7
7
  import { Icons } from "./icons";
8
8
  import { ConvaiClient } from "../core/ConvaiClient";
9
+ /**
10
+ * Positioning declarations for the widget root, keyed by `placement`. The
11
+ * `bottom-right` entry must stay byte-identical to the literals it replaces --
12
+ * the inline-style parity suite snapshots the resolved `cssText` and compares
13
+ * it to a committed fixture.
14
+ */
15
+ /** Gap between a fixed-placement widget and the viewport edge it anchors to. */
16
+ const PLACEMENT_INSET_PX = 24;
17
+ const PLACEMENT_CSS = {
18
+ "bottom-right": `position: fixed; bottom: ${PLACEMENT_INSET_PX}px; right: ${PLACEMENT_INSET_PX}px;`,
19
+ "bottom-left": `position: fixed; bottom: ${PLACEMENT_INSET_PX}px; left: ${PLACEMENT_INSET_PX}px;`,
20
+ "top-right": `position: fixed; top: ${PLACEMENT_INSET_PX}px; right: ${PLACEMENT_INSET_PX}px;`,
21
+ "top-left": `position: fixed; top: ${PLACEMENT_INSET_PX}px; left: ${PLACEMENT_INSET_PX}px;`,
22
+ inline: "position: relative;",
23
+ };
24
+ /** The open panel's preferred size. Capped by `PANEL_MAX_*` — see below. */
25
+ const PANEL_W_PX = 400;
26
+ const PANEL_H_PX = 600;
27
+ /**
28
+ * A ceiling on the open panel, so it can never be clipped by the viewport.
29
+ *
30
+ * The panel used to be a flat 400x600. The widget is `position: fixed`
31
+ * anchored `PLACEMENT_INSET_PX` from one edge, so on any viewport narrower
32
+ * than 448px — or shorter than 648px — the panel ran past the opposite edge
33
+ * and was silently cut off, with no scrollbar to reveal it, because fixed
34
+ * elements do not contribute to scrollable overflow. That is most phones: at
35
+ * 393px (iPhone 15 Pro) 30px of the panel sat off-screen, at 360px 63px did,
36
+ * and in landscape 233px of it — the entire header, including the close
37
+ * button — sat above the viewport, leaving nothing on screen to dismiss it.
38
+ *
39
+ * Expressed as `max-width`/`max-height` rather than folding the cap into the
40
+ * width with `min()`. Both compute the same thing, but they fail differently:
41
+ * a parser that does not know `min()` drops the whole declaration and the
42
+ * panel is left with no width at all, which is worse than the bug. An unknown
43
+ * `max-width` drops only the ceiling and leaves the previous behaviour intact.
44
+ * (Not hypothetical — happy-dom, which this package's own tests run in, parses
45
+ * the `max-width` form and discards the `min()` one.)
46
+ *
47
+ * Dynamic viewport units so mobile browser chrome is accounted for as it shows
48
+ * and hides, rather than sizing against a viewport the user cannot fully see.
49
+ * Applied to the container permanently rather than per state: the collapsed
50
+ * bubble is far below the ceiling, so it is unaffected.
51
+ */
52
+ const PANEL_MAX_W = `calc(100dvw - ${PLACEMENT_INSET_PX * 2}px)`;
53
+ const PANEL_MAX_H = `calc(100dvh - ${PLACEMENT_INSET_PX * 2}px)`;
54
+ /** `convai-error{code:'mic-permission'}` per the embed's error-handling contract. */
55
+ const MIC_PERMISSION_ERROR_CODE = "mic-permission";
56
+ /**
57
+ * Distinguishes a microphone-permission denial from any other audio failure.
58
+ *
59
+ * Traced (not assumed): `client.audioControls.unmuteAudio()` delegates to
60
+ * `AudioManager.enableAudio()`, which calls
61
+ * `room.localParticipant.setMicrophoneEnabled(true)`. That's LiveKit's
62
+ * `setTrackEnabled()`, which on failure does `throw e` unchanged, from a
63
+ * `catch` around `createLocalTracks()`, which itself does `throw e` unchanged
64
+ * around `navigator.mediaDevices.getUserMedia()`. `AudioManager.enableAudio()`
65
+ * also just does `throw error` in its own catch. Nothing on that path wraps or
66
+ * replaces the error, so a user's permission denial reaches here as the exact
67
+ * `DOMException` `getUserMedia()` produced, with `.name === 'NotAllowedError'`
68
+ * (`'PermissionDeniedError'` is the old pre-spec name some browsers used).
69
+ */
70
+ function isMicPermissionDenial(error) {
71
+ const name = error?.name;
72
+ return name === "NotAllowedError" || name === "PermissionDeniedError";
73
+ }
74
+ /**
75
+ * Wraps a mic-permission denial so a consumer of the client's `error`
76
+ * channel can tell it apart from a generic failure without knowing anything
77
+ * about `DOMException` internals -- it only has to check `error.code`. The
78
+ * original error is preserved on `.cause` for anyone who wants the detail.
79
+ */
80
+ function toMicPermissionError(cause) {
81
+ const err = new Error("Microphone access was denied. Continuing in text-only mode.");
82
+ err.name = "ConvaiMicPermissionError";
83
+ err.code = MIC_PERMISSION_ERROR_CODE;
84
+ err.cause = cause;
85
+ return err;
86
+ }
87
+ /**
88
+ * Routes a failed `enableAudio()`/`unmuteAudio()` call onto the client's
89
+ * *existing* `error` channel -- the same one `ConvaiClient` already uses --
90
+ * instead of letting it become an unhandled rejection (the click-handler
91
+ * site) or vanish into a bare `console.error` (the auto-voice-mode sites).
92
+ * `console.error` is kept alongside for local debugging; this just adds the
93
+ * event emission next to it. `client.emit` is called defensively since it
94
+ * isn't part of the public `IConvaiClient` surface -- see the `emit?` field
95
+ * on `VanillaWidgetOptions.convaiClient` in `./types.ts`.
96
+ */
97
+ function reportMicFailure(client, logLabel, error) {
98
+ console.error(logLabel, error);
99
+ const payload = isMicPermissionDenial(error)
100
+ ? toMicPermissionError(error)
101
+ : error;
102
+ client.emit?.("error", payload);
103
+ }
9
104
  /**
10
105
  * Create a Convai chat widget in the specified container
11
106
  *
@@ -42,6 +137,21 @@ import { ConvaiClient } from "../core/ConvaiClient";
42
137
  * ```
43
138
  */
44
139
  export function createConvaiWidget(container, options) {
140
+ /**
141
+ * Where global styles and `@keyframes` are injected.
142
+ *
143
+ * An explicit `options.styleTarget` wins and is used verbatim. Otherwise we
144
+ * fall back to deriving it from the container's current root — which is what
145
+ * every existing caller relies on, but only works when the container is
146
+ * already attached to its final root. A caller that builds a detached
147
+ * subtree and appends it to a shadow root afterwards must pass
148
+ * `styleTarget` explicitly, or the styles land in `document.head` and the
149
+ * shadow tree renders unstyled with no error.
150
+ */
151
+ const styleTarget = options.styleTarget ??
152
+ (container.getRootNode() instanceof ShadowRoot
153
+ ? container.getRootNode()
154
+ : document);
45
155
  // Create client if not provided - guarantee it's defined
46
156
  const client = options.convaiClient ||
47
157
  (() => {
@@ -61,8 +171,14 @@ export function createConvaiWidget(container, options) {
61
171
  });
62
172
  })();
63
173
  const { showVideo = true, showScreenShare = true, defaultVoiceMode = true, onConnect, onDisconnect, onMessage, } = options;
174
+ // Unknown/typo'd values fall back to the default rather than throwing --
175
+ // a bad `placement` attribute on a customer's page must not blank the
176
+ // widget.
177
+ const placement = options.placement && options.placement in PLACEMENT_CSS
178
+ ? options.placement
179
+ : "bottom-right";
64
180
  // Inject global styles
65
- injectGlobalStyles();
181
+ injectGlobalStyles(styleTarget);
66
182
  // State
67
183
  let isOpen = false;
68
184
  let isSettingsOpen = false;
@@ -76,6 +192,22 @@ export function createConvaiWidget(container, options) {
76
192
  let hasEnteredDefaultVoiceMode = false;
77
193
  /** True after we have connected at least once; used to call reconnect() instead of connect() when opening after disconnect */
78
194
  let hasConnectedBefore = false;
195
+ /**
196
+ * True from the moment an open is requested on a *disconnected* client
197
+ * until that open either completes or is cancelled.
198
+ *
199
+ * `isOpen` alone cannot express this: the first open awaits a real
200
+ * `connect()` (0.5-3s in production) and only flips `isOpen` afterwards, so
201
+ * for that whole window the widget is neither open nor idle. Without this
202
+ * flag `close()` sees `isOpen === false` and no-ops -- and the connect then
203
+ * pops the panel open behind the user -- while `toggle()` sees the same
204
+ * `false`, starts a *second* open, and lands on the already-connecting
205
+ * branch that opens immediately. Every entry point below treats
206
+ * `pendingOpen` as "open" for the purpose of deciding what a close/toggle
207
+ * means, and `handleToggle` re-checks it before committing `setIsOpen(true)`
208
+ * so a cancellation during the await is honoured.
209
+ */
210
+ let pendingOpen = false;
79
211
  // DOM elements (will be created below)
80
212
  let rootElement;
81
213
  let morphingContainer;
@@ -89,6 +221,13 @@ export function createConvaiWidget(container, options) {
89
221
  let settingsTray;
90
222
  let floatingVideo;
91
223
  let voiceModeOverlay;
224
+ let connectingOverlay;
225
+ /**
226
+ * Elements captured at creation. IDs are scoped per DOM tree, so
227
+ * document.getElementById() returns null when the widget is mounted
228
+ * inside a shadow root. Every element here is created in this file.
229
+ */
230
+ const refs = {};
92
231
  // Audio Analysis State
93
232
  let audioContext = null;
94
233
  let analyzer = null;
@@ -97,15 +236,31 @@ export function createConvaiWidget(container, options) {
97
236
  let source = null;
98
237
  // Fetch character info - matches React useCharacterInfo hook
99
238
  const fetchCharacterInfo = async () => {
100
- if (!client.apiKey || !client.characterId)
239
+ // Prefer authToken over apiKey, matching the precedence ConvaiClient
240
+ // uses when building /connect headers -- a client carrying both must
241
+ // behave identically in both places.
242
+ //
243
+ // The two credentials travel in *different* headers: api.convai.com reads
244
+ // an auth token from API-AUTH-TOKEN and an API key from X-API-Key, and
245
+ // answers an API key sent as API-AUTH-TOKEN with `400 Your session has
246
+ // expired.` Because the failure is swallowed below, sending the wrong one
247
+ // showed up only as a header stuck on the fallback name.
248
+ const credential = client.authToken ?? client.apiKey;
249
+ if (!credential || !client.characterId)
101
250
  return;
251
+ const headers = {
252
+ "Content-Type": "application/json",
253
+ };
254
+ if (client.authToken) {
255
+ headers["API-AUTH-TOKEN"] = client.authToken;
256
+ }
257
+ else {
258
+ headers["X-API-Key"] = credential;
259
+ }
102
260
  try {
103
261
  const response = await fetch("https://api.convai.com/character/get", {
104
262
  method: "POST",
105
- headers: {
106
- "Content-Type": "application/json",
107
- "API-AUTH-TOKEN": client.apiKey,
108
- },
263
+ headers,
109
264
  body: JSON.stringify({ charID: client.characterId }),
110
265
  });
111
266
  if (response.ok) {
@@ -129,23 +284,34 @@ export function createConvaiWidget(container, options) {
129
284
  rootElement = document.createElement("div");
130
285
  rootElement.className = "convai-widget";
131
286
  rootElement.style.cssText = `
132
- position: fixed;
133
- bottom: 1.5rem;
134
- right: 1.5rem;
135
- z-index: ${aeroTheme.zIndex.modal};
136
- font-family: ${aeroTheme.typography.fontFamily.primary};
287
+ ${PLACEMENT_CSS[placement]}
288
+ z-index: var(--convai-z-index, ${aeroTheme.zIndex.modal});
289
+ font-family: var(--convai-font, ${aeroTheme.typography.fontFamily.primary});
137
290
  `;
138
291
  // Morphing container
139
292
  morphingContainer = document.createElement("div");
293
+ // `part="launcher"` is precise only while collapsed: this same element
294
+ // morphs into the open panel (400x600, capped to the viewport — see PANEL_MAX_W)
295
+ // and carries that panel's background,
296
+ // radius and shadow. See the `part="panel"` call site below for the full
297
+ // semantics a host needs to know.
298
+ morphingContainer.setAttribute("part", "launcher");
299
+ // NOTE on border-radius: deliberately NOT themable. `--convai-radius`
300
+ // drives the open panel only. One token cannot serve both geometries --
301
+ // `--convai-radius: 8px` would squircle the circular launcher, and `50%`
302
+ // would turn the panel into an ellipse. A dedicated
303
+ // `--convai-launcher-radius` can be added additively later if wanted.
140
304
  morphingContainer.style.cssText = `
141
305
  position: relative;
142
- width: 4rem;
143
- height: 4rem;
144
- background: ${aeroTheme.colors.glass.backdrop};
306
+ width: var(--convai-launcher-size, 64px);
307
+ height: var(--convai-launcher-size, 64px);
308
+ max-width: ${PANEL_MAX_W};
309
+ max-height: ${PANEL_MAX_H};
310
+ background: var(--convai-panel-bg, ${aeroTheme.colors.glass.backdrop});
145
311
  backdrop-filter: ${aeroTheme.glass.backdrop};
146
312
  border: ${aeroTheme.glass.border};
147
313
  border-radius: 50%;
148
- box-shadow: ${aeroTheme.shadows.glass};
314
+ box-shadow: var(--convai-shadow, ${aeroTheme.shadows.glass});
149
315
  transition: all 0.3s ease-in-out;
150
316
  overflow: hidden;
151
317
  display: flex;
@@ -166,10 +332,39 @@ export function createConvaiWidget(container, options) {
166
332
  transform: scale(1);
167
333
  `;
168
334
  const convaiLogo = Icons.ConvaiLogo("xl", "idle");
169
- convaiLogo.style.color = aeroTheme.colors.convai.light;
335
+ convaiLogo.style.color = `var(--convai-accent, ${aeroTheme.colors.convai.light})`;
170
336
  buttonContent.appendChild(convaiLogo);
171
337
  // Chat content
172
338
  chatContent = document.createElement("div");
339
+ // ----------------------------------------------------------------------
340
+ // `launcher` / `panel` part semantics. Read before writing theming docs.
341
+ //
342
+ // This widget morphs rather than swapping trees: `morphingContainer` IS
343
+ // the 64px circle AND the open panel, animating between them (see
344
+ // setIsOpen). It therefore owns every painted surface property of both
345
+ // states -- background, border, border-radius, box-shadow. `buttonContent`
346
+ // and `chatContent` are transparent `inset: 0` overlays that only
347
+ // cross-fade their contents.
348
+ //
349
+ // So, precisely:
350
+ // ::part(launcher) -> morphingContainer. Paints the collapsed circle
351
+ // AND the open panel; there is no rule that hits
352
+ // only one. This is the surface part.
353
+ // ::part(panel) -> chatContent. The open state's *content layer*:
354
+ // useful for padding, colour, font, opacity. It has
355
+ // no background, no border and no radius of its
356
+ // own, so `::part(panel){border-radius}` does
357
+ // nothing -- set radius via ::part(launcher) or
358
+ // `--convai-radius` (which is panel-scoped).
359
+ //
360
+ // Assessed and deliberately not "fixed": moving `launcher` onto
361
+ // buttonContent would make `::part(launcher){background}` state-specific
362
+ // but would silently break `border-radius` and `box-shadow` on it, since
363
+ // buttonContent is clipped by the container's own radius + overflow:
364
+ // hidden. Correcting this properly needs a separate launcher element
365
+ // instead of a morph -- a rendering change, out of scope here.
366
+ // ----------------------------------------------------------------------
367
+ chatContent.setAttribute("part", "panel");
173
368
  chatContent.style.cssText = `
174
369
  position: absolute;
175
370
  inset: 0;
@@ -190,11 +385,19 @@ export function createConvaiWidget(container, options) {
190
385
  contentElement.style.cssText = `
191
386
  flex: 1;
192
387
  overflow-y: auto;
193
- padding: 1rem;
388
+ padding: 16px;
194
389
  background: transparent;
390
+ position: relative;
195
391
  `;
196
392
  messageListElement = createMessageList();
197
393
  contentElement.appendChild(messageListElement);
394
+ // Connecting overlay -- shown until the bot is ready. Appended last, not
395
+ // to match DOM order (position: absolute + z-index: 100, set inside
396
+ // createConnectingOverlay, is what keeps it on top regardless of source
397
+ // order) but so inserting it doesn't shift every existing sibling's
398
+ // index-based path in the inline-style-parity fixture.
399
+ connectingOverlay = createConnectingOverlay();
400
+ contentElement.appendChild(connectingOverlay);
198
401
  // Footer
199
402
  footerElement = createFooter();
200
403
  chatContent.appendChild(headerElement);
@@ -211,7 +414,7 @@ export function createConvaiWidget(container, options) {
211
414
  container.appendChild(floatingVideo);
212
415
  container.appendChild(rootElement);
213
416
  // Event listeners
214
- morphingContainer.addEventListener("click", handleToggle);
417
+ morphingContainer.addEventListener("click", handleLauncherClick);
215
418
  };
216
419
  // Create Voice Mode Overlay
217
420
  const createVoiceModeOverlay = () => {
@@ -222,13 +425,13 @@ export function createConvaiWidget(container, options) {
222
425
  left: 50%;
223
426
  transform: translate(-50%, -50%);
224
427
  text-align: center;
225
- padding: 1rem;
428
+ padding: 16px;
226
429
  z-index: 10;
227
430
  pointer-events: auto;
228
431
  display: none;
229
432
  flex-direction: column;
230
433
  align-items: center;
231
- gap: 1.5rem;
434
+ gap: 24px;
232
435
  `;
233
436
  // Bars Container
234
437
  const barsContainer = document.createElement("div");
@@ -260,15 +463,17 @@ export function createConvaiWidget(container, options) {
260
463
  const statusContainer = document.createElement("div");
261
464
  const statusTitle = document.createElement("div");
262
465
  statusTitle.id = "voice-mode-title";
466
+ refs.voiceModeTitle = statusTitle;
263
467
  statusTitle.style.cssText = `
264
468
  font-size: 14px;
265
469
  font-weight: 500;
266
- color: ${aeroTheme.colors.text.primary};
267
- margin-bottom: 0.5rem;
470
+ color: var(--convai-panel-fg, ${aeroTheme.colors.text.primary});
471
+ margin-bottom: 8px;
268
472
  `;
269
473
  statusTitle.textContent = "Voice Only Mode";
270
474
  const statusSubtitle = document.createElement("div");
271
475
  statusSubtitle.id = "voice-mode-subtitle";
476
+ refs.voiceModeSubtitle = statusSubtitle;
272
477
  statusSubtitle.style.cssText = `
273
478
  font-size: 12px;
274
479
  color: ${aeroTheme.colors.text.secondary};
@@ -279,6 +484,91 @@ export function createConvaiWidget(container, options) {
279
484
  overlay.appendChild(statusContainer);
280
485
  return overlay;
281
486
  };
487
+ // Create Connecting Overlay -- matches React's inline AnimatePresence
488
+ // block exactly (ConvaiWidget.tsx:681-737): shown whenever
489
+ // `isConnected && !isBotReady`, covering just the content area (not the
490
+ // header/footer) so the chrome stays interactive while the character
491
+ // joins. Persistent in the DOM like voiceModeOverlay above -- toggled via
492
+ // opacity/pointer-events rather than mounted/unmounted, since this file has
493
+ // no AnimatePresence-style exit-animation machinery. Visibility is driven
494
+ // by updateConnectingOverlay().
495
+ const createConnectingOverlay = () => {
496
+ const overlay = document.createElement("div");
497
+ overlay.id = "convai-connecting-overlay";
498
+ overlay.style.cssText = `
499
+ position: absolute;
500
+ inset: 0;
501
+ z-index: 100;
502
+ display: flex;
503
+ flex-direction: column;
504
+ align-items: center;
505
+ justify-content: center;
506
+ gap: 16px;
507
+ background: rgba(255, 255, 255, 0.95);
508
+ opacity: 0;
509
+ pointer-events: none;
510
+ transition: opacity 0.3s;
511
+ `;
512
+ // Keyframes live in the shadow-safe injector (styles.ts), not
513
+ // document.head -- @keyframes defined there do not resolve for shadow
514
+ // content. Idempotent; also called from createSettingsTray().
515
+ injectKeyframes(styleTarget);
516
+ // Spinning logo: a full turn every 2s, linear, infinite.
517
+ const spinWrapper = document.createElement("div");
518
+ spinWrapper.className = "convai-connecting-spin";
519
+ spinWrapper.style.cssText = `
520
+ display: inline-flex;
521
+ animation: convaiConnectingSpin 2s linear infinite;
522
+ `;
523
+ const logo = Icons.ConvaiLogo("xl", "connecting");
524
+ // Themable, like every other accent usage in this file (see
525
+ // theming.test.ts) -- var() with the exact literal React passes
526
+ // (`aeroTheme.colors.convai.light`) as the fallback, so an unset
527
+ // --convai-accent renders identically to the React value.
528
+ logo.style.color = `var(--convai-accent, ${aeroTheme.colors.convai.light})`;
529
+ spinWrapper.appendChild(logo);
530
+ overlay.appendChild(spinWrapper);
531
+ // Pulsing "Connecting to {characterName}..." label.
532
+ const label = document.createElement("div");
533
+ label.id = "convai-connecting-text";
534
+ label.className = "convai-connecting-pulse";
535
+ label.style.cssText = `
536
+ font-size: 14px;
537
+ font-weight: 500;
538
+ color: ${aeroTheme.colors.text.secondary};
539
+ animation: convaiConnectingPulse 2s ease-in-out infinite;
540
+ `;
541
+ refs.connectingLabel = label;
542
+ overlay.appendChild(label);
543
+ return overlay;
544
+ };
545
+ /** Shows/hides the connecting overlay and refreshes its label. Called
546
+ * from updateHeader() so every existing call site (stateChange, botReady,
547
+ * connect/disconnect via stateChange, character-info fetch, ...) stays in
548
+ * sync automatically. */
549
+ const updateConnectingOverlay = () => {
550
+ if (!connectingOverlay)
551
+ return;
552
+ const label = refs.connectingLabel;
553
+ if (label) {
554
+ label.textContent = `Connecting to ${characterName}...`;
555
+ }
556
+ // `isConnecting` matters as much as `isConnected` here. The panel opens
557
+ // on the click that *starts* the connect, so between that click and the
558
+ // transport coming up (a real network round trip — ~3s against the live
559
+ // service) `isConnected` is still false. Gating on it alone left the
560
+ // whole of that window with no overlay at all: an empty message area and
561
+ // a nameless header, i.e. the widget looked broken for exactly as long as
562
+ // it was doing the one thing the user asked for. Then the label appeared
563
+ // *after* the transport was already up, which reads backwards.
564
+ //
565
+ // The embed makes this the normal path rather than an edge case: its
566
+ // launcher click opens the panel immediately by design, so the pre-connect
567
+ // window is always visible to the user.
568
+ const shouldShow = (client.state.isConnected || client.state.isConnecting) && !client.isBotReady;
569
+ connectingOverlay.style.opacity = shouldShow ? "1" : "0";
570
+ connectingOverlay.style.pointerEvents = shouldShow ? "auto" : "none";
571
+ };
282
572
  // Audio Analysis State for Voice Mode
283
573
  let audioLevels = Array(40).fill(0);
284
574
  let targetLevels = Array(40).fill(0.05);
@@ -295,14 +585,14 @@ export function createConvaiWidget(container, options) {
295
585
  // Update colors based on state
296
586
  bars.forEach((bar) => {
297
587
  bar.style.backgroundColor = isTalking
298
- ? aeroTheme.colors.convai.light
588
+ ? `var(--convai-accent, ${aeroTheme.colors.convai.light})`
299
589
  : isListening
300
590
  ? aeroTheme.colors.text.primary
301
591
  : aeroTheme.colors.neutral[400];
302
592
  });
303
593
  // Update Text
304
- const title = document.getElementById("voice-mode-title");
305
- const subtitle = document.getElementById("voice-mode-subtitle");
594
+ const title = refs.voiceModeTitle;
595
+ const subtitle = refs.voiceModeSubtitle;
306
596
  if (title) {
307
597
  title.textContent = isTalking
308
598
  ? "Character Speaking..."
@@ -408,7 +698,15 @@ export function createConvaiWidget(container, options) {
408
698
  updateAudioBars();
409
699
  }
410
700
  catch (e) {
411
- console.error("Audio analysis setup failed:", e);
701
+ // Fourth mic-acquiring call site: this getUserMedia() is independent
702
+ // of client.audioControls (it's a separate analyser-only stream, not
703
+ // routed through AudioManager), but a denial here is the identical
704
+ // failure mode -- most commonly a mid-session permission revocation,
705
+ // since by the time voice mode is entered the mic has usually already
706
+ // been granted via unmuteAudio(). Same helper, same channel, so a
707
+ // consumer sees one consistent signal regardless of which internal
708
+ // path acquired the mic.
709
+ reportMicFailure(client, "Audio analysis setup failed:", e);
412
710
  }
413
711
  };
414
712
  const stopAudioAnalysis = () => {
@@ -425,13 +723,14 @@ export function createConvaiWidget(container, options) {
425
723
  // Create header
426
724
  const createHeader = () => {
427
725
  const header = document.createElement("div");
726
+ header.setAttribute("part", "header");
428
727
  header.style.cssText = `
429
728
  display: flex;
430
729
  align-items: center;
431
730
  justify-content: space-between;
432
- padding: 1rem;
731
+ padding: 16px;
433
732
  border-bottom: 1px solid ${aeroTheme.colors.neutral[200]};
434
- background: white;
733
+ background: var(--convai-panel-bg, white);
435
734
  position: relative;
436
735
  `;
437
736
  // Close button on the left
@@ -439,10 +738,10 @@ export function createConvaiWidget(container, options) {
439
738
  const chevronIcon = Icons.ChevronDown("md");
440
739
  closeButton.appendChild(chevronIcon);
441
740
  closeButton.style.cssText = `
442
- font-size: 1.25rem;
741
+ font-size: 20px;
443
742
  color: ${aeroTheme.colors.text.secondary};
444
743
  cursor: pointer;
445
- padding: 0.25rem;
744
+ padding: 4px;
446
745
  transition: ${aeroTheme.transitions.fast};
447
746
  background: transparent;
448
747
  border: none;
@@ -459,21 +758,22 @@ export function createConvaiWidget(container, options) {
459
758
  transform: translateX(-50%);
460
759
  display: flex;
461
760
  align-items: center;
462
- gap: 0.5rem;
761
+ gap: 8px;
463
762
  font-size: ${aeroTheme.typography.fontSize.base};
464
763
  font-weight: ${aeroTheme.typography.fontWeight.semibold};
465
- color: ${aeroTheme.colors.text.primary};
764
+ color: var(--convai-panel-fg, ${aeroTheme.colors.text.primary});
466
765
  `;
467
766
  titleSection.id = "convai-widget-title";
767
+ refs.widgetTitle = titleSection;
468
768
  // Settings button on the right
469
769
  const settingsButton = document.createElement("button");
470
770
  const moreIcon = Icons.MoreVertical("md");
471
771
  settingsButton.appendChild(moreIcon);
472
772
  settingsButton.style.cssText = `
473
- font-size: 1.5rem;
773
+ font-size: 24px;
474
774
  color: ${aeroTheme.colors.text.secondary};
475
775
  cursor: pointer;
476
- padding: 0.25rem;
776
+ padding: 4px;
477
777
  transition: ${aeroTheme.transitions.fast};
478
778
  background: transparent;
479
779
  border: none;
@@ -490,7 +790,7 @@ export function createConvaiWidget(container, options) {
490
790
  };
491
791
  // Update header with character info
492
792
  const updateHeader = () => {
493
- const titleSection = document.getElementById("convai-widget-title");
793
+ const titleSection = refs.widgetTitle;
494
794
  if (!titleSection)
495
795
  return;
496
796
  titleSection.innerHTML = "";
@@ -499,8 +799,8 @@ export function createConvaiWidget(container, options) {
499
799
  img.src = characterImage;
500
800
  img.alt = characterName;
501
801
  img.style.cssText = `
502
- width: 1.5rem;
503
- height: 1.5rem;
802
+ width: 24px;
803
+ height: 24px;
504
804
  border-radius: 50%;
505
805
  object-fit: cover;
506
806
  border: 1.5px solid ${getBotStatusColor().color};
@@ -530,7 +830,7 @@ export function createConvaiWidget(container, options) {
530
830
  padding: 0;
531
831
  display: inline-flex;
532
832
  align-items: center;
533
- margin-left: 0.5rem;
833
+ margin-left: 8px;
534
834
  outline: none;
535
835
  transition: transform 0.1s ease-out;
536
836
  `;
@@ -551,7 +851,7 @@ export function createConvaiWidget(container, options) {
551
851
  voiceBadge.textContent = "VOICE";
552
852
  voiceBadge.style.cssText = `
553
853
  font-size: 10px;
554
- color: ${aeroTheme.colors.convai.light};
854
+ color: var(--convai-accent, ${aeroTheme.colors.convai.light});
555
855
  font-weight: 500;
556
856
  margin-left: 8px;
557
857
  padding: 2px 6px;
@@ -616,15 +916,21 @@ export function createConvaiWidget(container, options) {
616
916
  listeningTag.insertBefore(dot, listeningTag.firstChild);
617
917
  titleSection.appendChild(listeningTag);
618
918
  }
919
+ // Every call site that refreshes the header (stateChange, botReady,
920
+ // connect/disconnect via stateChange, character-info fetch, ...) also
921
+ // needs the connecting overlay re-evaluated -- it depends on the same
922
+ // `isConnected`/`isBotReady`/`characterName` inputs.
923
+ updateConnectingOverlay();
619
924
  };
620
925
  // Create message list
621
926
  const createMessageList = () => {
622
927
  const list = document.createElement("div");
623
928
  list.id = "convai-message-list";
929
+ list.setAttribute("part", "messages");
624
930
  list.style.cssText = `
625
931
  display: flex;
626
932
  flex-direction: column;
627
- gap: 0.75rem;
933
+ gap: 12px;
628
934
  min-height: 100%;
629
935
  `;
630
936
  return list;
@@ -633,22 +939,23 @@ export function createConvaiWidget(container, options) {
633
939
  const createFooter = () => {
634
940
  const footer = document.createElement("div");
635
941
  footer.style.cssText = `
636
- padding: 1rem;
942
+ padding: 16px;
637
943
  border-top: 1px solid ${aeroTheme.colors.neutral[200]};
638
- background: white;
944
+ background: var(--convai-panel-bg, white);
639
945
  display: flex;
640
- gap: 0.5rem;
946
+ gap: 8px;
641
947
  align-items: center;
642
948
  position: relative;
643
949
  `;
644
950
  // Voice Mode Exit Button (Initially hidden)
645
951
  const voiceExitButton = document.createElement("button");
646
952
  voiceExitButton.id = "convai-voice-exit-btn";
953
+ refs.voiceExitBtn = voiceExitButton;
647
954
  const exitIcon = Icons.PhoneOff("md");
648
955
  voiceExitButton.appendChild(exitIcon);
649
956
  voiceExitButton.style.cssText = `
650
- width: 2.25rem;
651
- height: 2.25rem;
957
+ width: 36px;
958
+ height: 36px;
652
959
  border-radius: 50%;
653
960
  background: ${aeroTheme.colors.error[500]};
654
961
  color: white;
@@ -659,19 +966,31 @@ export function createConvaiWidget(container, options) {
659
966
  margin: 0 auto;
660
967
  border: none;
661
968
  `;
662
- voiceExitButton.addEventListener("click", async () => {
969
+ voiceExitButton.addEventListener("click", () => {
970
+ // Deliberately a *sync* listener. It used to be `async` with a bare
971
+ // `await client.audioControls.muteAudio()` and no try/catch: on the
972
+ // LiveKit path that call rethrows when the room has dropped, so a
973
+ // network blip mid-call plus a tap on exit leaked an unhandled
974
+ // rejection onto the host page *and* skipped the two lines below,
975
+ // stranding the user in the voice overlay with no way out.
976
+ //
977
+ // The mute is not dropped -- `applyVoiceMode()` -> `updateVoiceMode()`
978
+ // performs it (conditionally, if the mic is live) inside the exit
979
+ // branch, where a failure is now reported rather than fatal. Exiting
980
+ // the UI is unconditional either way: the overlay must never outlive
981
+ // the user's decision to leave it.
663
982
  client.sendInterruptMessage();
664
- await client.audioControls.muteAudio(); // Mute on exit
665
983
  isVoiceMode = false;
666
- updateVoiceMode();
984
+ applyVoiceMode();
667
985
  });
668
986
  footer.appendChild(voiceExitButton);
669
987
  // Standard Footer Content (Mic + Input)
670
988
  const standardContent = document.createElement("div");
671
989
  standardContent.id = "convai-footer-standard";
990
+ refs.footerStandard = standardContent;
672
991
  standardContent.style.cssText = `
673
992
  display: flex;
674
- gap: 0.5rem;
993
+ gap: 8px;
675
994
  align-items: center;
676
995
  width: 100%;
677
996
  `;
@@ -684,15 +1003,16 @@ export function createConvaiWidget(container, options) {
684
1003
  align-items: center;
685
1004
  `;
686
1005
  inputElement = document.createElement("input");
1006
+ inputElement.setAttribute("part", "input");
687
1007
  inputElement.type = "text";
688
1008
  inputElement.placeholder = "Conversation";
689
1009
  inputElement.style.cssText = `
690
1010
  width: 100%;
691
- padding: 0.75rem 2.75rem 0.75rem 1rem;
1011
+ padding: 12px 44px 12px 16px;
692
1012
  border-radius: ${aeroTheme.borderRadius.full};
693
1013
  border: 1px solid ${aeroTheme.colors.neutral[300]};
694
1014
  background: ${aeroTheme.colors.glass.medium};
695
- color: ${aeroTheme.colors.text.primary};
1015
+ color: var(--convai-panel-fg, ${aeroTheme.colors.text.primary});
696
1016
  font-size: ${aeroTheme.typography.fontSize.sm};
697
1017
  transition: ${aeroTheme.transitions.fast};
698
1018
  outline: none;
@@ -708,16 +1028,22 @@ export function createConvaiWidget(container, options) {
708
1028
  // Send button
709
1029
  const sendButton = document.createElement("button");
710
1030
  sendButton.id = "convai-send-button";
1031
+ // This button is dual-role -- it sends text when the input has a draft,
1032
+ // and toggles voice mode when empty -- but there is no separate mic
1033
+ // element, so only the send-button part applies. `mic-button` is
1034
+ // reserved for a future version that splits the two; see parts.test.ts.
1035
+ sendButton.setAttribute("part", "send-button");
1036
+ refs.sendButton = sendButton;
711
1037
  const sendIcon = Icons.Send("md");
712
1038
  sendButton.appendChild(sendIcon);
713
1039
  sendButton.style.cssText = `
714
1040
  position: absolute;
715
- right: 0.375rem;
716
- width: 2.25rem;
717
- height: 2.25rem;
1041
+ right: 6px;
1042
+ width: 36px;
1043
+ height: 36px;
718
1044
  border-radius: 50%;
719
1045
  background: transparent; /* Initial transparent for voice toggle */
720
- color: ${aeroTheme.colors.text.primary};
1046
+ color: var(--convai-panel-fg, ${aeroTheme.colors.text.primary});
721
1047
  display: flex;
722
1048
  align-items: center;
723
1049
  justify-content: center;
@@ -732,9 +1058,19 @@ export function createConvaiWidget(container, options) {
732
1058
  else {
733
1059
  // Toggle Voice Mode
734
1060
  client.sendInterruptMessage();
735
- await client.audioControls.unmuteAudio(); // Unmute on enter
736
- isVoiceMode = true;
737
- updateVoiceMode();
1061
+ try {
1062
+ await client.audioControls.unmuteAudio(); // Unmute on enter
1063
+ isVoiceMode = true;
1064
+ // Awaited so this try/catch actually covers it -- bare, the promise
1065
+ // escaped the block it looks like it belongs to.
1066
+ await updateVoiceMode();
1067
+ }
1068
+ catch (error) {
1069
+ // isVoiceMode is only flipped after a successful unmute, so a
1070
+ // denial here leaves the widget in text mode, not stranded
1071
+ // mid-transition into voice mode.
1072
+ reportMicFailure(client, "Failed to enable microphone:", error);
1073
+ }
738
1074
  }
739
1075
  });
740
1076
  inputContainer.appendChild(inputElement);
@@ -751,7 +1087,7 @@ export function createConvaiWidget(container, options) {
751
1087
  position: absolute;
752
1088
  top: 60px;
753
1089
  right: 16px;
754
- background: white;
1090
+ background: var(--convai-panel-bg, white);
755
1091
  border-radius: ${aeroTheme.borderRadius.xl};
756
1092
  box-shadow: ${aeroTheme.shadows.xl};
757
1093
  padding: 0;
@@ -766,17 +1102,7 @@ export function createConvaiWidget(container, options) {
766
1102
  overflow: hidden;
767
1103
  `;
768
1104
  // Add animation keyframes if not present
769
- if (!document.getElementById("convai-widget-keyframes")) {
770
- const style = document.createElement("style");
771
- style.id = "convai-widget-keyframes";
772
- style.textContent = `
773
- @keyframes popIn {
774
- from { opacity: 0; transform: scale(0.95) translateY(-10px); }
775
- to { opacity: 1; transform: scale(1) translateY(0); }
776
- }
777
- `;
778
- document.head.appendChild(style);
779
- }
1105
+ injectKeyframes(styleTarget);
780
1106
  // Horizontal Settings Row Container
781
1107
  const settingsRow = document.createElement("div");
782
1108
  settingsRow.style.cssText = `
@@ -805,7 +1131,7 @@ export function createConvaiWidget(container, options) {
805
1131
  ? "#10b981"
806
1132
  : isDestructive
807
1133
  ? "#ef4444"
808
- : aeroTheme.colors.text.primary};
1134
+ : `var(--convai-panel-fg, ${aeroTheme.colors.text.primary})`};
809
1135
  transition: ${aeroTheme.transitions.fast};
810
1136
  min-width: 50px;
811
1137
  `;
@@ -855,6 +1181,7 @@ export function createConvaiWidget(container, options) {
855
1181
  videoIcon.style.height = "18px";
856
1182
  const videoBtn = createOption(videoIcon, "Video", handleToggleVideo, isVideoVisible);
857
1183
  videoBtn.id = "convai-settings-video-btn";
1184
+ refs.settingsVideoBtn = videoBtn;
858
1185
  settingsRow.appendChild(videoBtn);
859
1186
  }
860
1187
  // Screen Share - only show if connection type is video
@@ -867,6 +1194,7 @@ export function createConvaiWidget(container, options) {
867
1194
  shareIcon.style.height = "18px";
868
1195
  const shareBtn = createOption(shareIcon, "Screen", handleToggleScreenShare, isSharing);
869
1196
  shareBtn.id = "convai-settings-share-btn";
1197
+ refs.settingsShareBtn = shareBtn;
870
1198
  settingsRow.appendChild(shareBtn);
871
1199
  }
872
1200
  // Disconnect
@@ -903,7 +1231,7 @@ export function createConvaiWidget(container, options) {
903
1231
  ? "#10b981"
904
1232
  : isDestructive
905
1233
  ? "#ef4444"
906
- : aeroTheme.colors.text.primary};
1234
+ : `var(--convai-panel-fg, ${aeroTheme.colors.text.primary})`};
907
1235
  transition: ${aeroTheme.transitions.fast};
908
1236
  min-width: 50px;
909
1237
  `;
@@ -953,6 +1281,7 @@ export function createConvaiWidget(container, options) {
953
1281
  videoIcon.style.height = "18px";
954
1282
  const videoBtn = createOption(videoIcon, "Video", handleToggleVideo, isVideoVisible);
955
1283
  videoBtn.id = "convai-settings-video-btn";
1284
+ refs.settingsVideoBtn = videoBtn;
956
1285
  settingsRow.appendChild(videoBtn);
957
1286
  }
958
1287
  // Screen Share - Always show if showScreenShare is true and connection type is video
@@ -965,6 +1294,7 @@ export function createConvaiWidget(container, options) {
965
1294
  shareIcon.style.height = "18px";
966
1295
  const shareBtn = createOption(shareIcon, "Screen", handleToggleScreenShare, isSharing);
967
1296
  shareBtn.id = "convai-settings-share-btn";
1297
+ refs.settingsShareBtn = shareBtn;
968
1298
  settingsRow.appendChild(shareBtn);
969
1299
  }
970
1300
  // Disconnect
@@ -977,6 +1307,12 @@ export function createConvaiWidget(container, options) {
977
1307
  const createFloatingVideo = () => {
978
1308
  const container = document.createElement("div");
979
1309
  container.id = "floating-video-container";
1310
+ // NOTE on z-index: deliberately NOT themable. `--convai-z-index` drives the
1311
+ // widget root only. Both elements are `position: fixed` and this one is
1312
+ // appended *before* the widget root, so on a tie the widget paints on top:
1313
+ // a host raising the token to clear their nav bar would hide the user's own
1314
+ // camera preview behind the panel. Video/screen share are also out of scope
1315
+ // for the embed, which is chat-only.
980
1316
  container.style.cssText = `
981
1317
  position: fixed;
982
1318
  left: 20px;
@@ -1158,12 +1494,16 @@ export function createConvaiWidget(container, options) {
1158
1494
  };
1159
1495
  // Event handlers
1160
1496
  const handleToggle = async () => {
1161
- if (isOpen) {
1162
- // Just close if already open
1497
+ if (isOpen || pendingOpen) {
1498
+ // Already open, or an open is already in flight: nothing to do. The
1499
+ // `pendingOpen` half is what keeps a repeat call from falling through
1500
+ // to the `isConnecting` branch below and opening the panel *before* the
1501
+ // connect it is waiting on has finished.
1163
1502
  return;
1164
1503
  }
1165
1504
  // Connect on first click if not already connected/connecting
1166
1505
  if (!client.state.isConnected && !client.state.isConnecting) {
1506
+ pendingOpen = true;
1167
1507
  try {
1168
1508
  // Use reconnect when opening again after a disconnect (reuses stored config)
1169
1509
  if (hasConnectedBefore && typeof client.reconnect === "function") {
@@ -1172,18 +1512,40 @@ export function createConvaiWidget(container, options) {
1172
1512
  else {
1173
1513
  await client.connect();
1174
1514
  }
1515
+ // Re-checked *after* the await: a close()/toggle()/launcher click
1516
+ // during the connect clears the flag, and that cancellation has to
1517
+ // win. The client stays connected either way -- only the panel is
1518
+ // held back -- so a later open() takes the already-connected branch
1519
+ // and opens instantly.
1520
+ if (!pendingOpen)
1521
+ return;
1175
1522
  setIsOpen(true);
1176
1523
  }
1177
1524
  catch (error) {
1178
1525
  console.error("Failed to connect:", error);
1179
1526
  }
1527
+ finally {
1528
+ pendingOpen = false;
1529
+ }
1180
1530
  }
1181
1531
  else {
1182
1532
  // Just toggle open/close if already connected
1183
1533
  setIsOpen(!isOpen);
1184
1534
  }
1185
1535
  };
1536
+ /** A launcher click while an open is pending means "cancel", same as `toggle()`. */
1537
+ const handleLauncherClick = () => {
1538
+ if (pendingOpen) {
1539
+ handleClose();
1540
+ return;
1541
+ }
1542
+ void handleToggle();
1543
+ };
1186
1544
  const handleClose = () => {
1545
+ // Cancels an in-flight open as well as closing an open panel; see
1546
+ // `pendingOpen`. `setIsOpen(false)` on an already-closed widget just
1547
+ // re-applies the collapsed styles, so this is safe in both cases.
1548
+ pendingOpen = false;
1187
1549
  setIsOpen(false);
1188
1550
  };
1189
1551
  const handleSend = () => {
@@ -1218,7 +1580,7 @@ export function createConvaiWidget(container, options) {
1218
1580
  isVoiceMode = false;
1219
1581
  hasEnteredDefaultVoiceMode = false;
1220
1582
  updateHeader();
1221
- updateVoiceMode();
1583
+ await updateVoiceMode();
1222
1584
  updateSendButton();
1223
1585
  }
1224
1586
  catch (error) {
@@ -1232,7 +1594,11 @@ export function createConvaiWidget(container, options) {
1232
1594
  isVoiceMode = false;
1233
1595
  hasEnteredDefaultVoiceMode = false;
1234
1596
  updateHeader();
1235
- updateVoiceMode();
1597
+ // Not awaited: this function has no try/catch of its own and is wired
1598
+ // straight to a click, so awaiting would only move a rejection from one
1599
+ // unguarded place to another. See the note on `handleDisconnect`'s own
1600
+ // `await client.disconnect()` in the fix report.
1601
+ applyVoiceMode();
1236
1602
  updateSendButton();
1237
1603
  };
1238
1604
  const handleToggleVideo = async () => {
@@ -1271,10 +1637,17 @@ export function createConvaiWidget(container, options) {
1271
1637
  const setIsOpen = async (open) => {
1272
1638
  isOpen = open;
1273
1639
  if (open) {
1274
- morphingContainer.style.width = "400px";
1275
- morphingContainer.style.height = "600px";
1276
- morphingContainer.style.borderRadius = aeroTheme.borderRadius.xl;
1640
+ morphingContainer.style.width = `${PANEL_W_PX}px`;
1641
+ morphingContainer.style.height = `${PANEL_H_PX}px`;
1642
+ morphingContainer.style.borderRadius = `var(--convai-radius, ${aeroTheme.borderRadius.xl})`;
1277
1643
  morphingContainer.style.cursor = "default";
1644
+ // The open panel is a *different* glass treatment from the launcher
1645
+ // bubble, matching the React widget: near-opaque and saturated, so the
1646
+ // host page does not bleed through. At the bubble's 0.8 alpha a dark
1647
+ // host page shows through the panel and reads as grey.
1648
+ morphingContainer.style.background =
1649
+ "var(--convai-panel-bg, rgba(252, 252, 253, 0.95))";
1650
+ morphingContainer.style.backdropFilter = "blur(20px) saturate(180%)";
1278
1651
  buttonContent.style.opacity = "0";
1279
1652
  buttonContent.style.transform = "scale(0.8)";
1280
1653
  buttonContent.style.pointerEvents = "none";
@@ -1290,19 +1663,24 @@ export function createConvaiWidget(container, options) {
1290
1663
  await client.audioControls.unmuteAudio();
1291
1664
  isVoiceMode = true;
1292
1665
  hasEnteredDefaultVoiceMode = true;
1293
- updateVoiceMode();
1666
+ await updateVoiceMode();
1294
1667
  }
1295
1668
  catch (error) {
1296
- console.error("Failed to enter voice mode on open:", error);
1669
+ reportMicFailure(client, "Failed to enter voice mode on open:", error);
1297
1670
  }
1298
1671
  }, 100);
1299
1672
  }
1300
1673
  }
1301
1674
  else {
1302
- morphingContainer.style.width = "4rem";
1303
- morphingContainer.style.height = "4rem";
1675
+ morphingContainer.style.width = "var(--convai-launcher-size, 64px)";
1676
+ morphingContainer.style.height = "var(--convai-launcher-size, 64px)";
1677
+ // Literal, not `--convai-radius` — see the creation site: that token is
1678
+ // the *panel* radius, and reusing it here would squircle the launcher.
1304
1679
  morphingContainer.style.borderRadius = "50%";
1305
1680
  morphingContainer.style.cursor = "pointer";
1681
+ // Back to the launcher bubble's lighter glass — see the open branch.
1682
+ morphingContainer.style.background = `var(--convai-panel-bg, ${aeroTheme.colors.glass.backdrop})`;
1683
+ morphingContainer.style.backdropFilter = aeroTheme.glass.backdrop;
1306
1684
  buttonContent.style.opacity = "1";
1307
1685
  buttonContent.style.transform = "scale(1)";
1308
1686
  buttonContent.style.pointerEvents = "auto";
@@ -1316,7 +1694,7 @@ export function createConvaiWidget(container, options) {
1316
1694
  settingsTray.style.display = open ? "flex" : "none";
1317
1695
  };
1318
1696
  const updateSendButton = () => {
1319
- const sendButton = document.getElementById("convai-send-button");
1697
+ const sendButton = refs.sendButton;
1320
1698
  if (!sendButton)
1321
1699
  return;
1322
1700
  // Clear previous content
@@ -1324,8 +1702,15 @@ export function createConvaiWidget(container, options) {
1324
1702
  if (inputValue.length > 0) {
1325
1703
  // Show Send Button
1326
1704
  sendButton.appendChild(Icons.Send("md"));
1327
- sendButton.style.background = aeroTheme.colors.text.primary;
1328
- sendButton.style.color = "white";
1705
+ sendButton.style.background = `var(--convai-accent, ${aeroTheme.colors.text.primary})`;
1706
+ // The label sits *on* the accent fill, so it must follow the panel
1707
+ // surface colour rather than stay a hardcoded "white" -- a host setting
1708
+ // a pale --convai-accent would otherwise get white-on-white. `white` is
1709
+ // byte-identical to the fallback --convai-panel-bg already carries at
1710
+ // its three other sites, so the default rendering is unchanged.
1711
+ // Pairing requirement for hosts: --convai-accent and --convai-panel-bg
1712
+ // must contrast with each other, since this is the one place they meet.
1713
+ sendButton.style.color = "var(--convai-panel-bg, white)";
1329
1714
  sendButton.style.border = "none";
1330
1715
  sendButton.title = "Send";
1331
1716
  }
@@ -1333,7 +1718,7 @@ export function createConvaiWidget(container, options) {
1333
1718
  // Show Voice Mode Toggle
1334
1719
  sendButton.appendChild(Icons.Waveform("md"));
1335
1720
  sendButton.style.background = "transparent";
1336
- sendButton.style.color = aeroTheme.colors.text.primary;
1721
+ sendButton.style.color = `var(--convai-panel-fg, ${aeroTheme.colors.text.primary})`;
1337
1722
  sendButton.style.border = `1px solid ${aeroTheme.colors.neutral[300]}`;
1338
1723
  sendButton.title = "Voice Mode";
1339
1724
  }
@@ -1364,8 +1749,8 @@ export function createConvaiWidget(container, options) {
1364
1749
  }
1365
1750
  };
1366
1751
  const updateVoiceMode = async () => {
1367
- const standardFooter = document.getElementById("convai-footer-standard");
1368
- const voiceExitBtn = document.getElementById("convai-voice-exit-btn");
1752
+ const standardFooter = refs.footerStandard;
1753
+ const voiceExitBtn = refs.voiceExitBtn;
1369
1754
  if (isVoiceMode) {
1370
1755
  // Show Voice Overlay
1371
1756
  if (voiceModeOverlay)
@@ -1391,9 +1776,22 @@ export function createConvaiWidget(container, options) {
1391
1776
  startAudioAnalysis();
1392
1777
  }
1393
1778
  else {
1394
- // Ensure microphone is muted when not in voice mode
1779
+ // Ensure microphone is muted when not in voice mode.
1780
+ //
1781
+ // This await is the only one in this function that can reject, and
1782
+ // everything below it -- hiding the overlay, restoring the standard
1783
+ // footer, updating the header, stopping the analyser -- is the only
1784
+ // route out of the voice UI. So a failed mute must never short-circuit
1785
+ // it: report it and carry on tearing the overlay down. (LiveKit's
1786
+ // setMicrophoneEnabled(false) rethrows on a dropped room, which is
1787
+ // exactly when the user is most likely to be hitting exit.)
1395
1788
  if (!client.audioControls.isAudioMuted) {
1396
- await client.audioControls.muteAudio();
1789
+ try {
1790
+ await client.audioControls.muteAudio();
1791
+ }
1792
+ catch (error) {
1793
+ reportMicFailure(client, "Failed to mute microphone:", error);
1794
+ }
1397
1795
  }
1398
1796
  // Hide Overlay
1399
1797
  if (voiceModeOverlay)
@@ -1425,6 +1823,24 @@ export function createConvaiWidget(container, options) {
1425
1823
  stopAudioAnalysis();
1426
1824
  }
1427
1825
  };
1826
+ /**
1827
+ * Fire-and-forget `updateVoiceMode()` for the call sites that are
1828
+ * synchronous (DOM listeners, the constructor tail) and therefore have no
1829
+ * caller to catch anything it rejects with.
1830
+ *
1831
+ * `updateVoiceMode()` is `async`, so a bare call there would put a floating
1832
+ * promise on the host page. Its one rejecting await is now guarded
1833
+ * internally, which makes this belt-and-braces -- but it is the guarantee
1834
+ * that keeps a future await added inside `updateVoiceMode()` from silently
1835
+ * reintroducing the unhandled rejection this replaces. Routed through
1836
+ * `reportMicFailure` so a failure is observable on the client's `error`
1837
+ * channel rather than console-only.
1838
+ */
1839
+ const applyVoiceMode = () => {
1840
+ void updateVoiceMode().catch((error) => {
1841
+ reportMicFailure(client, "Failed to apply voice mode:", error);
1842
+ });
1843
+ };
1428
1844
  // Microphone button removed - only controlled via voice mode now
1429
1845
  // Helper for Markdown - matches React MarkdownRenderer.tsx
1430
1846
  const renderMarkdown = (text, container) => {
@@ -1459,7 +1875,7 @@ export function createConvaiWidget(container, options) {
1459
1875
  strong.textContent = match[2];
1460
1876
  strong.style.cssText = `
1461
1877
  font-weight: 600;
1462
- color: ${aeroTheme.colors.text.primary};
1878
+ color: var(--convai-panel-fg, ${aeroTheme.colors.text.primary});
1463
1879
  `;
1464
1880
  parts.push(strong);
1465
1881
  }
@@ -1526,7 +1942,7 @@ export function createConvaiWidget(container, options) {
1526
1942
  height: 100%;
1527
1943
  color: ${aeroTheme.colors.text.secondary};
1528
1944
  text-align: center;
1529
- padding: 2rem;
1945
+ padding: 32px;
1530
1946
  `;
1531
1947
  emptyState.innerHTML = `
1532
1948
  <p style="font-weight: 500; margin-bottom: 8px;">Start a conversation</p>
@@ -1548,6 +1964,7 @@ export function createConvaiWidget(container, options) {
1548
1964
  contain: layout style paint;
1549
1965
  `;
1550
1966
  const bubble = document.createElement("div");
1967
+ bubble.setAttribute("part", msg.isUser ? "message message-user" : "message message-bot");
1551
1968
  // Add data attribute to identify user messages for debugging/styling
1552
1969
  if (msg.isUser) {
1553
1970
  bubble.setAttribute("data-message-type", "user");
@@ -1555,6 +1972,10 @@ export function createConvaiWidget(container, options) {
1555
1972
  // Set base styles first - explicitly set background to match React UserBubble exactly
1556
1973
  // CRITICAL: Use the exact same background for both user and bot messages
1557
1974
  const backgroundColor = "rgba(252, 252, 253, 0.95)";
1975
+ // NOTE on border-radius: deliberately NOT themable. `--convai-radius` is
1976
+ // the open panel's radius; a bubble's asymmetric tail corner is a
1977
+ // different geometry (`50%` would make ellipses of these). A dedicated
1978
+ // `--convai-bubble-radius` can be added additively later if wanted.
1558
1979
  bubble.style.cssText = `
1559
1980
  padding: 12px;
1560
1981
  border-radius: ${msg.isUser ? "12px 12px 4px 12px" : "12px 12px 12px 4px"};
@@ -1563,8 +1984,8 @@ export function createConvaiWidget(container, options) {
1563
1984
  backdrop-filter: blur(20px) saturate(180%);
1564
1985
  border: 1px solid rgba(0, 0, 0, 0.06);
1565
1986
  box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.8) inset, 0 2px 8px rgba(0, 0, 0, 0.08);
1566
- color: ${aeroTheme.colors.text.primary};
1567
- font-family: ${aeroTheme.typography.fontFamily.body};
1987
+ color: var(--convai-panel-fg, ${aeroTheme.colors.text.primary});
1988
+ font-family: var(--convai-font, ${aeroTheme.typography.fontFamily.body});
1568
1989
  font-size: 14px;
1569
1990
  line-height: 1.5;
1570
1991
  word-wrap: break-word;
@@ -1596,8 +2017,8 @@ export function createConvaiWidget(container, options) {
1596
2017
  // Logo/Icon - matches React LogoWrapper
1597
2018
  const logoContainer = document.createElement("div");
1598
2019
  logoContainer.style.cssText = `
1599
- width: 1rem;
1600
- height: 1rem;
2020
+ width: 16px;
2021
+ height: 16px;
1601
2022
  display: flex;
1602
2023
  align-items: center;
1603
2024
  justify-content: center;
@@ -1624,10 +2045,10 @@ export function createConvaiWidget(container, options) {
1624
2045
  const nameLabel = document.createElement("span");
1625
2046
  nameLabel.textContent = msg.sender === "User" ? "You" : msg.sender;
1626
2047
  nameLabel.style.cssText = `
1627
- font-size: 0.75rem;
2048
+ font-size: 12px;
1628
2049
  color: ${msg.isUser ? aeroTheme.colors.convai.dark : "#10b981"};
1629
2050
  font-weight: 500;
1630
- font-family: ${aeroTheme.typography.fontFamily.body};
2051
+ font-family: var(--convai-font, ${aeroTheme.typography.fontFamily.body});
1631
2052
  isolation: isolate;
1632
2053
  contain: layout style paint;
1633
2054
  `;
@@ -1649,11 +2070,11 @@ export function createConvaiWidget(container, options) {
1649
2070
  const timestamp = document.createElement("span");
1650
2071
  timestamp.textContent = msg.timestamp;
1651
2072
  timestamp.style.cssText = `
1652
- font-size: 0.75rem;
2073
+ font-size: 12px;
1653
2074
  color: ${aeroTheme.colors.text.secondary};
1654
2075
  margin-top: 4px;
1655
2076
  align-self: ${msg.isUser ? "flex-end" : "flex-start"};
1656
- font-family: ${aeroTheme.typography.fontFamily.body};
2077
+ font-family: var(--convai-font, ${aeroTheme.typography.fontFamily.body});
1657
2078
  `;
1658
2079
  messageWrapper.appendChild(timestamp);
1659
2080
  }
@@ -1730,7 +2151,10 @@ export function createConvaiWidget(container, options) {
1730
2151
  isVideoVisible = false;
1731
2152
  hasEnteredDefaultVoiceMode = false;
1732
2153
  updateHeader();
1733
- updateVoiceMode();
2154
+ // Synchronous listener: nothing here can catch a rejection, and the
2155
+ // mute inside is the one most likely to fail (the room has just
2156
+ // dropped -- that is why we are collapsing).
2157
+ applyVoiceMode();
1734
2158
  }
1735
2159
  // Auto-enter voice mode on first connection if defaultVoiceMode is true
1736
2160
  if (client.state.isConnected &&
@@ -1743,10 +2167,10 @@ export function createConvaiWidget(container, options) {
1743
2167
  await client.audioControls.unmuteAudio();
1744
2168
  isVoiceMode = true;
1745
2169
  hasEnteredDefaultVoiceMode = true;
1746
- updateVoiceMode();
2170
+ await updateVoiceMode();
1747
2171
  }
1748
2172
  catch (error) {
1749
- console.error("Failed to enter voice mode:", error);
2173
+ reportMicFailure(client, "Failed to enter voice mode:", error);
1750
2174
  }
1751
2175
  }, 100);
1752
2176
  }
@@ -1756,9 +2180,9 @@ export function createConvaiWidget(container, options) {
1756
2180
  client.videoControls.on("videoStateChange", (videoState) => {
1757
2181
  if (videoState.isVideoEnabled !== undefined) {
1758
2182
  isVideoVisible = videoState.isVideoEnabled;
1759
- updateVoiceMode();
2183
+ applyVoiceMode(); // synchronous listener; see applyVoiceMode()
1760
2184
  // Update tray button state
1761
- const videoBtn = document.getElementById("convai-settings-video-btn");
2185
+ const videoBtn = refs.settingsVideoBtn;
1762
2186
  if (videoBtn) {
1763
2187
  const isActive = isVideoVisible;
1764
2188
  videoBtn.style.backgroundColor = isActive
@@ -1766,7 +2190,7 @@ export function createConvaiWidget(container, options) {
1766
2190
  : "transparent";
1767
2191
  videoBtn.style.color = isActive
1768
2192
  ? "#10b981"
1769
- : aeroTheme.colors.text.primary;
2193
+ : `var(--convai-panel-fg, ${aeroTheme.colors.text.primary})`;
1770
2194
  // Update Icon - first child is icon container
1771
2195
  const iconContainer = videoBtn.querySelector("div");
1772
2196
  if (iconContainer) {
@@ -1783,7 +2207,7 @@ export function createConvaiWidget(container, options) {
1783
2207
  client.screenShareControls.on("screenShareStateChange", (screenShareState) => {
1784
2208
  if (screenShareState.isScreenShareActive !== undefined) {
1785
2209
  const isSharing = screenShareState.isScreenShareActive;
1786
- const shareBtn = document.getElementById("convai-settings-share-btn");
2210
+ const shareBtn = refs.settingsShareBtn;
1787
2211
  if (shareBtn) {
1788
2212
  const isActive = isSharing;
1789
2213
  shareBtn.style.backgroundColor = isActive
@@ -1791,7 +2215,7 @@ export function createConvaiWidget(container, options) {
1791
2215
  : "transparent";
1792
2216
  shareBtn.style.color = isActive
1793
2217
  ? "#10b981"
1794
- : aeroTheme.colors.text.primary;
2218
+ : `var(--convai-panel-fg, ${aeroTheme.colors.text.primary})`;
1795
2219
  // Update Icon - first child is icon container
1796
2220
  const iconContainer = shareBtn.querySelector("div");
1797
2221
  if (iconContainer) {
@@ -1821,8 +2245,13 @@ export function createConvaiWidget(container, options) {
1821
2245
  client.on("connect", () => {
1822
2246
  hasConnectedBefore = true;
1823
2247
  // Initialize audio renderer on connection
2248
+ // NOTE: the audio elements live under rootElement, so playback ends if
2249
+ // the host *removes or re-parents* its mount container mid-call. Merely
2250
+ // hiding it (display: none / visibility: hidden) does NOT stop
2251
+ // HTMLMediaElement playback in any browser — an earlier version of this
2252
+ // comment claimed otherwise and was wrong.
1824
2253
  if (client.room && !audioRenderer) {
1825
- audioRenderer = new AudioRenderer(client.room);
2254
+ audioRenderer = new AudioRenderer(client.room, { container: rootElement });
1826
2255
  }
1827
2256
  // Fetch character info
1828
2257
  fetchCharacterInfo();
@@ -1847,21 +2276,80 @@ export function createConvaiWidget(container, options) {
1847
2276
  // Initialize
1848
2277
  createDOM();
1849
2278
  setupClientListeners();
2279
+ // Paint the connecting overlay's initial state. updateHeader() (which also
2280
+ // calls updateConnectingOverlay()) is otherwise only reached via a client
2281
+ // event or a successful character-info fetch, so a widget constructed
2282
+ // already connected-but-not-ready would show nothing until one of those
2283
+ // fires without this.
2284
+ updateConnectingOverlay();
1850
2285
  // Set initial button state
1851
2286
  updateSendButton(); // Show voice mode button initially since input is empty
1852
2287
  // Set initial voice mode UI if defaultVoiceMode is true
1853
2288
  if (defaultVoiceMode) {
1854
- updateVoiceMode();
2289
+ applyVoiceMode(); // synchronous constructor tail; see applyVoiceMode()
1855
2290
  }
1856
2291
  // If already connected, initialize audio renderer and fetch character info
2292
+ // NOTE: same lifetime caveat as the "connect" handler above — the audio
2293
+ // elements are torn down with rootElement if the host detaches it.
1857
2294
  if (client.state.isConnected && client.room) {
1858
- audioRenderer = new AudioRenderer(client.room);
2295
+ audioRenderer = new AudioRenderer(client.room, { container: rootElement });
1859
2296
  fetchCharacterInfo();
1860
2297
  }
1861
2298
  // Return widget instance
1862
2299
  const widget = {
1863
2300
  element: rootElement,
1864
2301
  client: client,
2302
+ // `isOpen` reports *requested* state, which on a warm client is the same
2303
+ // thing as rendered state: `setIsOpen()` runs synchronously. The one case
2304
+ // where they differ is the first open on a disconnected client, where the
2305
+ // panel is committed only after `connect()` resolves; reporting `false`
2306
+ // for those seconds would tell an embed reconciling its own
2307
+ // convai-open/convai-close events that a widget it just opened is closed.
2308
+ // `pendingOpen` is cleared by every cancellation path, so this never
2309
+ // reports open for a request that was called off.
2310
+ get isOpen() {
2311
+ return isOpen || pendingOpen;
2312
+ },
2313
+ // Drives the exact same path the launcher click does: `handleToggle`
2314
+ // (connect-on-first-open, then `setIsOpen(true)`). Idempotent against an
2315
+ // in-flight open as well as an open panel, so repeat calls never start a
2316
+ // second connect.
2317
+ open: () => {
2318
+ if (isOpen || pendingOpen)
2319
+ return;
2320
+ void handleToggle();
2321
+ },
2322
+ // Drives the same path the header's close button does: `handleClose` ->
2323
+ // `setIsOpen(false)`. Also cancels an open that is still waiting on
2324
+ // `connect()`; a redundant `close()` is a no-op.
2325
+ close: () => {
2326
+ if (!isOpen && !pendingOpen)
2327
+ return;
2328
+ handleClose();
2329
+ },
2330
+ toggle: () => {
2331
+ if (isOpen || pendingOpen) {
2332
+ handleClose();
2333
+ }
2334
+ else {
2335
+ void handleToggle();
2336
+ }
2337
+ },
2338
+ // Pushes a synthetic ChatMessage straight onto `client.chatMessages` and
2339
+ // re-renders via the widget's existing `updateMessageList()`. It never
2340
+ // calls a client send method (e.g. `sendUserTextMessage`), so the
2341
+ // message never reaches the character and is never billed as an LLM
2342
+ // turn. The `local-` id keeps it from colliding with a server-issued id.
2343
+ appendMessage: (message) => {
2344
+ const chatMessage = {
2345
+ id: `local-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`,
2346
+ type: message.sender === "user" ? "user-llm-text" : "bot-llm-text",
2347
+ content: message.content,
2348
+ timestamp: new Date().toISOString(),
2349
+ };
2350
+ client.chatMessages.push(chatMessage);
2351
+ updateMessageList();
2352
+ },
1865
2353
  destroy: () => {
1866
2354
  // Cleanup audio renderer
1867
2355
  if (audioRenderer) {
@@ -1869,7 +2357,7 @@ export function createConvaiWidget(container, options) {
1869
2357
  audioRenderer = null;
1870
2358
  }
1871
2359
  // Remove event listeners
1872
- morphingContainer.removeEventListener("click", handleToggle);
2360
+ morphingContainer.removeEventListener("click", handleLauncherClick);
1873
2361
  // Remove DOM elements
1874
2362
  if (rootElement.parentElement) {
1875
2363
  rootElement.remove();