@convai/web-sdk 1.7.0-beta.2 → 1.8.0-beta.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 (43) 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 +575 -111
  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.js +1 -1
  42. package/dist/version.js.map +1 -1
  43. 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,14 +236,18 @@ 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
+ const credential = client.authToken ?? client.apiKey;
243
+ if (!credential || !client.characterId)
101
244
  return;
102
245
  try {
103
246
  const response = await fetch("https://api.convai.com/character/get", {
104
247
  method: "POST",
105
248
  headers: {
106
249
  "Content-Type": "application/json",
107
- "API-AUTH-TOKEN": client.apiKey,
250
+ "API-AUTH-TOKEN": credential,
108
251
  },
109
252
  body: JSON.stringify({ charID: client.characterId }),
110
253
  });
@@ -129,23 +272,34 @@ export function createConvaiWidget(container, options) {
129
272
  rootElement = document.createElement("div");
130
273
  rootElement.className = "convai-widget";
131
274
  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};
275
+ ${PLACEMENT_CSS[placement]}
276
+ z-index: var(--convai-z-index, ${aeroTheme.zIndex.modal});
277
+ font-family: var(--convai-font, ${aeroTheme.typography.fontFamily.primary});
137
278
  `;
138
279
  // Morphing container
139
280
  morphingContainer = document.createElement("div");
281
+ // `part="launcher"` is precise only while collapsed: this same element
282
+ // morphs into the open panel (400x600, capped to the viewport — see PANEL_MAX_W)
283
+ // and carries that panel's background,
284
+ // radius and shadow. See the `part="panel"` call site below for the full
285
+ // semantics a host needs to know.
286
+ morphingContainer.setAttribute("part", "launcher");
287
+ // NOTE on border-radius: deliberately NOT themable. `--convai-radius`
288
+ // drives the open panel only. One token cannot serve both geometries --
289
+ // `--convai-radius: 8px` would squircle the circular launcher, and `50%`
290
+ // would turn the panel into an ellipse. A dedicated
291
+ // `--convai-launcher-radius` can be added additively later if wanted.
140
292
  morphingContainer.style.cssText = `
141
293
  position: relative;
142
- width: 4rem;
143
- height: 4rem;
144
- background: ${aeroTheme.colors.glass.backdrop};
294
+ width: var(--convai-launcher-size, 64px);
295
+ height: var(--convai-launcher-size, 64px);
296
+ max-width: ${PANEL_MAX_W};
297
+ max-height: ${PANEL_MAX_H};
298
+ background: var(--convai-panel-bg, ${aeroTheme.colors.glass.backdrop});
145
299
  backdrop-filter: ${aeroTheme.glass.backdrop};
146
300
  border: ${aeroTheme.glass.border};
147
301
  border-radius: 50%;
148
- box-shadow: ${aeroTheme.shadows.glass};
302
+ box-shadow: var(--convai-shadow, ${aeroTheme.shadows.glass});
149
303
  transition: all 0.3s ease-in-out;
150
304
  overflow: hidden;
151
305
  display: flex;
@@ -166,10 +320,39 @@ export function createConvaiWidget(container, options) {
166
320
  transform: scale(1);
167
321
  `;
168
322
  const convaiLogo = Icons.ConvaiLogo("xl", "idle");
169
- convaiLogo.style.color = aeroTheme.colors.convai.light;
323
+ convaiLogo.style.color = `var(--convai-accent, ${aeroTheme.colors.convai.light})`;
170
324
  buttonContent.appendChild(convaiLogo);
171
325
  // Chat content
172
326
  chatContent = document.createElement("div");
327
+ // ----------------------------------------------------------------------
328
+ // `launcher` / `panel` part semantics. Read before writing theming docs.
329
+ //
330
+ // This widget morphs rather than swapping trees: `morphingContainer` IS
331
+ // the 64px circle AND the open panel, animating between them (see
332
+ // setIsOpen). It therefore owns every painted surface property of both
333
+ // states -- background, border, border-radius, box-shadow. `buttonContent`
334
+ // and `chatContent` are transparent `inset: 0` overlays that only
335
+ // cross-fade their contents.
336
+ //
337
+ // So, precisely:
338
+ // ::part(launcher) -> morphingContainer. Paints the collapsed circle
339
+ // AND the open panel; there is no rule that hits
340
+ // only one. This is the surface part.
341
+ // ::part(panel) -> chatContent. The open state's *content layer*:
342
+ // useful for padding, colour, font, opacity. It has
343
+ // no background, no border and no radius of its
344
+ // own, so `::part(panel){border-radius}` does
345
+ // nothing -- set radius via ::part(launcher) or
346
+ // `--convai-radius` (which is panel-scoped).
347
+ //
348
+ // Assessed and deliberately not "fixed": moving `launcher` onto
349
+ // buttonContent would make `::part(launcher){background}` state-specific
350
+ // but would silently break `border-radius` and `box-shadow` on it, since
351
+ // buttonContent is clipped by the container's own radius + overflow:
352
+ // hidden. Correcting this properly needs a separate launcher element
353
+ // instead of a morph -- a rendering change, out of scope here.
354
+ // ----------------------------------------------------------------------
355
+ chatContent.setAttribute("part", "panel");
173
356
  chatContent.style.cssText = `
174
357
  position: absolute;
175
358
  inset: 0;
@@ -190,11 +373,19 @@ export function createConvaiWidget(container, options) {
190
373
  contentElement.style.cssText = `
191
374
  flex: 1;
192
375
  overflow-y: auto;
193
- padding: 1rem;
376
+ padding: 16px;
194
377
  background: transparent;
378
+ position: relative;
195
379
  `;
196
380
  messageListElement = createMessageList();
197
381
  contentElement.appendChild(messageListElement);
382
+ // Connecting overlay -- shown until the bot is ready. Appended last, not
383
+ // to match DOM order (position: absolute + z-index: 100, set inside
384
+ // createConnectingOverlay, is what keeps it on top regardless of source
385
+ // order) but so inserting it doesn't shift every existing sibling's
386
+ // index-based path in the inline-style-parity fixture.
387
+ connectingOverlay = createConnectingOverlay();
388
+ contentElement.appendChild(connectingOverlay);
198
389
  // Footer
199
390
  footerElement = createFooter();
200
391
  chatContent.appendChild(headerElement);
@@ -211,7 +402,7 @@ export function createConvaiWidget(container, options) {
211
402
  container.appendChild(floatingVideo);
212
403
  container.appendChild(rootElement);
213
404
  // Event listeners
214
- morphingContainer.addEventListener("click", handleToggle);
405
+ morphingContainer.addEventListener("click", handleLauncherClick);
215
406
  };
216
407
  // Create Voice Mode Overlay
217
408
  const createVoiceModeOverlay = () => {
@@ -222,13 +413,13 @@ export function createConvaiWidget(container, options) {
222
413
  left: 50%;
223
414
  transform: translate(-50%, -50%);
224
415
  text-align: center;
225
- padding: 1rem;
416
+ padding: 16px;
226
417
  z-index: 10;
227
418
  pointer-events: auto;
228
419
  display: none;
229
420
  flex-direction: column;
230
421
  align-items: center;
231
- gap: 1.5rem;
422
+ gap: 24px;
232
423
  `;
233
424
  // Bars Container
234
425
  const barsContainer = document.createElement("div");
@@ -260,15 +451,17 @@ export function createConvaiWidget(container, options) {
260
451
  const statusContainer = document.createElement("div");
261
452
  const statusTitle = document.createElement("div");
262
453
  statusTitle.id = "voice-mode-title";
454
+ refs.voiceModeTitle = statusTitle;
263
455
  statusTitle.style.cssText = `
264
456
  font-size: 14px;
265
457
  font-weight: 500;
266
- color: ${aeroTheme.colors.text.primary};
267
- margin-bottom: 0.5rem;
458
+ color: var(--convai-panel-fg, ${aeroTheme.colors.text.primary});
459
+ margin-bottom: 8px;
268
460
  `;
269
461
  statusTitle.textContent = "Voice Only Mode";
270
462
  const statusSubtitle = document.createElement("div");
271
463
  statusSubtitle.id = "voice-mode-subtitle";
464
+ refs.voiceModeSubtitle = statusSubtitle;
272
465
  statusSubtitle.style.cssText = `
273
466
  font-size: 12px;
274
467
  color: ${aeroTheme.colors.text.secondary};
@@ -279,6 +472,79 @@ export function createConvaiWidget(container, options) {
279
472
  overlay.appendChild(statusContainer);
280
473
  return overlay;
281
474
  };
475
+ // Create Connecting Overlay -- matches React's inline AnimatePresence
476
+ // block exactly (ConvaiWidget.tsx:681-737): shown whenever
477
+ // `isConnected && !isBotReady`, covering just the content area (not the
478
+ // header/footer) so the chrome stays interactive while the character
479
+ // joins. Persistent in the DOM like voiceModeOverlay above -- toggled via
480
+ // opacity/pointer-events rather than mounted/unmounted, since this file has
481
+ // no AnimatePresence-style exit-animation machinery. Visibility is driven
482
+ // by updateConnectingOverlay().
483
+ const createConnectingOverlay = () => {
484
+ const overlay = document.createElement("div");
485
+ overlay.id = "convai-connecting-overlay";
486
+ overlay.style.cssText = `
487
+ position: absolute;
488
+ inset: 0;
489
+ z-index: 100;
490
+ display: flex;
491
+ flex-direction: column;
492
+ align-items: center;
493
+ justify-content: center;
494
+ gap: 16px;
495
+ background: rgba(255, 255, 255, 0.95);
496
+ opacity: 0;
497
+ pointer-events: none;
498
+ transition: opacity 0.3s;
499
+ `;
500
+ // Keyframes live in the shadow-safe injector (styles.ts), not
501
+ // document.head -- @keyframes defined there do not resolve for shadow
502
+ // content. Idempotent; also called from createSettingsTray().
503
+ injectKeyframes(styleTarget);
504
+ // Spinning logo: a full turn every 2s, linear, infinite.
505
+ const spinWrapper = document.createElement("div");
506
+ spinWrapper.className = "convai-connecting-spin";
507
+ spinWrapper.style.cssText = `
508
+ display: inline-flex;
509
+ animation: convaiConnectingSpin 2s linear infinite;
510
+ `;
511
+ const logo = Icons.ConvaiLogo("xl", "connecting");
512
+ // Themable, like every other accent usage in this file (see
513
+ // theming.test.ts) -- var() with the exact literal React passes
514
+ // (`aeroTheme.colors.convai.light`) as the fallback, so an unset
515
+ // --convai-accent renders identically to the React value.
516
+ logo.style.color = `var(--convai-accent, ${aeroTheme.colors.convai.light})`;
517
+ spinWrapper.appendChild(logo);
518
+ overlay.appendChild(spinWrapper);
519
+ // Pulsing "Connecting to {characterName}..." label.
520
+ const label = document.createElement("div");
521
+ label.id = "convai-connecting-text";
522
+ label.className = "convai-connecting-pulse";
523
+ label.style.cssText = `
524
+ font-size: 14px;
525
+ font-weight: 500;
526
+ color: ${aeroTheme.colors.text.secondary};
527
+ animation: convaiConnectingPulse 2s ease-in-out infinite;
528
+ `;
529
+ refs.connectingLabel = label;
530
+ overlay.appendChild(label);
531
+ return overlay;
532
+ };
533
+ /** Shows/hides the connecting overlay and refreshes its label. Called
534
+ * from updateHeader() so every existing call site (stateChange, botReady,
535
+ * connect/disconnect via stateChange, character-info fetch, ...) stays in
536
+ * sync automatically. */
537
+ const updateConnectingOverlay = () => {
538
+ if (!connectingOverlay)
539
+ return;
540
+ const label = refs.connectingLabel;
541
+ if (label) {
542
+ label.textContent = `Connecting to ${characterName}...`;
543
+ }
544
+ const shouldShow = client.state.isConnected && !client.isBotReady;
545
+ connectingOverlay.style.opacity = shouldShow ? "1" : "0";
546
+ connectingOverlay.style.pointerEvents = shouldShow ? "auto" : "none";
547
+ };
282
548
  // Audio Analysis State for Voice Mode
283
549
  let audioLevels = Array(40).fill(0);
284
550
  let targetLevels = Array(40).fill(0.05);
@@ -295,14 +561,14 @@ export function createConvaiWidget(container, options) {
295
561
  // Update colors based on state
296
562
  bars.forEach((bar) => {
297
563
  bar.style.backgroundColor = isTalking
298
- ? aeroTheme.colors.convai.light
564
+ ? `var(--convai-accent, ${aeroTheme.colors.convai.light})`
299
565
  : isListening
300
566
  ? aeroTheme.colors.text.primary
301
567
  : aeroTheme.colors.neutral[400];
302
568
  });
303
569
  // Update Text
304
- const title = document.getElementById("voice-mode-title");
305
- const subtitle = document.getElementById("voice-mode-subtitle");
570
+ const title = refs.voiceModeTitle;
571
+ const subtitle = refs.voiceModeSubtitle;
306
572
  if (title) {
307
573
  title.textContent = isTalking
308
574
  ? "Character Speaking..."
@@ -408,7 +674,15 @@ export function createConvaiWidget(container, options) {
408
674
  updateAudioBars();
409
675
  }
410
676
  catch (e) {
411
- console.error("Audio analysis setup failed:", e);
677
+ // Fourth mic-acquiring call site: this getUserMedia() is independent
678
+ // of client.audioControls (it's a separate analyser-only stream, not
679
+ // routed through AudioManager), but a denial here is the identical
680
+ // failure mode -- most commonly a mid-session permission revocation,
681
+ // since by the time voice mode is entered the mic has usually already
682
+ // been granted via unmuteAudio(). Same helper, same channel, so a
683
+ // consumer sees one consistent signal regardless of which internal
684
+ // path acquired the mic.
685
+ reportMicFailure(client, "Audio analysis setup failed:", e);
412
686
  }
413
687
  };
414
688
  const stopAudioAnalysis = () => {
@@ -425,13 +699,14 @@ export function createConvaiWidget(container, options) {
425
699
  // Create header
426
700
  const createHeader = () => {
427
701
  const header = document.createElement("div");
702
+ header.setAttribute("part", "header");
428
703
  header.style.cssText = `
429
704
  display: flex;
430
705
  align-items: center;
431
706
  justify-content: space-between;
432
- padding: 1rem;
707
+ padding: 16px;
433
708
  border-bottom: 1px solid ${aeroTheme.colors.neutral[200]};
434
- background: white;
709
+ background: var(--convai-panel-bg, white);
435
710
  position: relative;
436
711
  `;
437
712
  // Close button on the left
@@ -439,10 +714,10 @@ export function createConvaiWidget(container, options) {
439
714
  const chevronIcon = Icons.ChevronDown("md");
440
715
  closeButton.appendChild(chevronIcon);
441
716
  closeButton.style.cssText = `
442
- font-size: 1.25rem;
717
+ font-size: 20px;
443
718
  color: ${aeroTheme.colors.text.secondary};
444
719
  cursor: pointer;
445
- padding: 0.25rem;
720
+ padding: 4px;
446
721
  transition: ${aeroTheme.transitions.fast};
447
722
  background: transparent;
448
723
  border: none;
@@ -459,21 +734,22 @@ export function createConvaiWidget(container, options) {
459
734
  transform: translateX(-50%);
460
735
  display: flex;
461
736
  align-items: center;
462
- gap: 0.5rem;
737
+ gap: 8px;
463
738
  font-size: ${aeroTheme.typography.fontSize.base};
464
739
  font-weight: ${aeroTheme.typography.fontWeight.semibold};
465
- color: ${aeroTheme.colors.text.primary};
740
+ color: var(--convai-panel-fg, ${aeroTheme.colors.text.primary});
466
741
  `;
467
742
  titleSection.id = "convai-widget-title";
743
+ refs.widgetTitle = titleSection;
468
744
  // Settings button on the right
469
745
  const settingsButton = document.createElement("button");
470
746
  const moreIcon = Icons.MoreVertical("md");
471
747
  settingsButton.appendChild(moreIcon);
472
748
  settingsButton.style.cssText = `
473
- font-size: 1.5rem;
749
+ font-size: 24px;
474
750
  color: ${aeroTheme.colors.text.secondary};
475
751
  cursor: pointer;
476
- padding: 0.25rem;
752
+ padding: 4px;
477
753
  transition: ${aeroTheme.transitions.fast};
478
754
  background: transparent;
479
755
  border: none;
@@ -490,7 +766,7 @@ export function createConvaiWidget(container, options) {
490
766
  };
491
767
  // Update header with character info
492
768
  const updateHeader = () => {
493
- const titleSection = document.getElementById("convai-widget-title");
769
+ const titleSection = refs.widgetTitle;
494
770
  if (!titleSection)
495
771
  return;
496
772
  titleSection.innerHTML = "";
@@ -499,8 +775,8 @@ export function createConvaiWidget(container, options) {
499
775
  img.src = characterImage;
500
776
  img.alt = characterName;
501
777
  img.style.cssText = `
502
- width: 1.5rem;
503
- height: 1.5rem;
778
+ width: 24px;
779
+ height: 24px;
504
780
  border-radius: 50%;
505
781
  object-fit: cover;
506
782
  border: 1.5px solid ${getBotStatusColor().color};
@@ -530,7 +806,7 @@ export function createConvaiWidget(container, options) {
530
806
  padding: 0;
531
807
  display: inline-flex;
532
808
  align-items: center;
533
- margin-left: 0.5rem;
809
+ margin-left: 8px;
534
810
  outline: none;
535
811
  transition: transform 0.1s ease-out;
536
812
  `;
@@ -551,7 +827,7 @@ export function createConvaiWidget(container, options) {
551
827
  voiceBadge.textContent = "VOICE";
552
828
  voiceBadge.style.cssText = `
553
829
  font-size: 10px;
554
- color: ${aeroTheme.colors.convai.light};
830
+ color: var(--convai-accent, ${aeroTheme.colors.convai.light});
555
831
  font-weight: 500;
556
832
  margin-left: 8px;
557
833
  padding: 2px 6px;
@@ -616,15 +892,21 @@ export function createConvaiWidget(container, options) {
616
892
  listeningTag.insertBefore(dot, listeningTag.firstChild);
617
893
  titleSection.appendChild(listeningTag);
618
894
  }
895
+ // Every call site that refreshes the header (stateChange, botReady,
896
+ // connect/disconnect via stateChange, character-info fetch, ...) also
897
+ // needs the connecting overlay re-evaluated -- it depends on the same
898
+ // `isConnected`/`isBotReady`/`characterName` inputs.
899
+ updateConnectingOverlay();
619
900
  };
620
901
  // Create message list
621
902
  const createMessageList = () => {
622
903
  const list = document.createElement("div");
623
904
  list.id = "convai-message-list";
905
+ list.setAttribute("part", "messages");
624
906
  list.style.cssText = `
625
907
  display: flex;
626
908
  flex-direction: column;
627
- gap: 0.75rem;
909
+ gap: 12px;
628
910
  min-height: 100%;
629
911
  `;
630
912
  return list;
@@ -633,22 +915,23 @@ export function createConvaiWidget(container, options) {
633
915
  const createFooter = () => {
634
916
  const footer = document.createElement("div");
635
917
  footer.style.cssText = `
636
- padding: 1rem;
918
+ padding: 16px;
637
919
  border-top: 1px solid ${aeroTheme.colors.neutral[200]};
638
- background: white;
920
+ background: var(--convai-panel-bg, white);
639
921
  display: flex;
640
- gap: 0.5rem;
922
+ gap: 8px;
641
923
  align-items: center;
642
924
  position: relative;
643
925
  `;
644
926
  // Voice Mode Exit Button (Initially hidden)
645
927
  const voiceExitButton = document.createElement("button");
646
928
  voiceExitButton.id = "convai-voice-exit-btn";
929
+ refs.voiceExitBtn = voiceExitButton;
647
930
  const exitIcon = Icons.PhoneOff("md");
648
931
  voiceExitButton.appendChild(exitIcon);
649
932
  voiceExitButton.style.cssText = `
650
- width: 2.25rem;
651
- height: 2.25rem;
933
+ width: 36px;
934
+ height: 36px;
652
935
  border-radius: 50%;
653
936
  background: ${aeroTheme.colors.error[500]};
654
937
  color: white;
@@ -659,19 +942,31 @@ export function createConvaiWidget(container, options) {
659
942
  margin: 0 auto;
660
943
  border: none;
661
944
  `;
662
- voiceExitButton.addEventListener("click", async () => {
945
+ voiceExitButton.addEventListener("click", () => {
946
+ // Deliberately a *sync* listener. It used to be `async` with a bare
947
+ // `await client.audioControls.muteAudio()` and no try/catch: on the
948
+ // LiveKit path that call rethrows when the room has dropped, so a
949
+ // network blip mid-call plus a tap on exit leaked an unhandled
950
+ // rejection onto the host page *and* skipped the two lines below,
951
+ // stranding the user in the voice overlay with no way out.
952
+ //
953
+ // The mute is not dropped -- `applyVoiceMode()` -> `updateVoiceMode()`
954
+ // performs it (conditionally, if the mic is live) inside the exit
955
+ // branch, where a failure is now reported rather than fatal. Exiting
956
+ // the UI is unconditional either way: the overlay must never outlive
957
+ // the user's decision to leave it.
663
958
  client.sendInterruptMessage();
664
- await client.audioControls.muteAudio(); // Mute on exit
665
959
  isVoiceMode = false;
666
- updateVoiceMode();
960
+ applyVoiceMode();
667
961
  });
668
962
  footer.appendChild(voiceExitButton);
669
963
  // Standard Footer Content (Mic + Input)
670
964
  const standardContent = document.createElement("div");
671
965
  standardContent.id = "convai-footer-standard";
966
+ refs.footerStandard = standardContent;
672
967
  standardContent.style.cssText = `
673
968
  display: flex;
674
- gap: 0.5rem;
969
+ gap: 8px;
675
970
  align-items: center;
676
971
  width: 100%;
677
972
  `;
@@ -684,15 +979,16 @@ export function createConvaiWidget(container, options) {
684
979
  align-items: center;
685
980
  `;
686
981
  inputElement = document.createElement("input");
982
+ inputElement.setAttribute("part", "input");
687
983
  inputElement.type = "text";
688
984
  inputElement.placeholder = "Conversation";
689
985
  inputElement.style.cssText = `
690
986
  width: 100%;
691
- padding: 0.75rem 2.75rem 0.75rem 1rem;
987
+ padding: 12px 44px 12px 16px;
692
988
  border-radius: ${aeroTheme.borderRadius.full};
693
989
  border: 1px solid ${aeroTheme.colors.neutral[300]};
694
990
  background: ${aeroTheme.colors.glass.medium};
695
- color: ${aeroTheme.colors.text.primary};
991
+ color: var(--convai-panel-fg, ${aeroTheme.colors.text.primary});
696
992
  font-size: ${aeroTheme.typography.fontSize.sm};
697
993
  transition: ${aeroTheme.transitions.fast};
698
994
  outline: none;
@@ -708,16 +1004,22 @@ export function createConvaiWidget(container, options) {
708
1004
  // Send button
709
1005
  const sendButton = document.createElement("button");
710
1006
  sendButton.id = "convai-send-button";
1007
+ // This button is dual-role -- it sends text when the input has a draft,
1008
+ // and toggles voice mode when empty -- but there is no separate mic
1009
+ // element, so only the send-button part applies. `mic-button` is
1010
+ // reserved for a future version that splits the two; see parts.test.ts.
1011
+ sendButton.setAttribute("part", "send-button");
1012
+ refs.sendButton = sendButton;
711
1013
  const sendIcon = Icons.Send("md");
712
1014
  sendButton.appendChild(sendIcon);
713
1015
  sendButton.style.cssText = `
714
1016
  position: absolute;
715
- right: 0.375rem;
716
- width: 2.25rem;
717
- height: 2.25rem;
1017
+ right: 6px;
1018
+ width: 36px;
1019
+ height: 36px;
718
1020
  border-radius: 50%;
719
1021
  background: transparent; /* Initial transparent for voice toggle */
720
- color: ${aeroTheme.colors.text.primary};
1022
+ color: var(--convai-panel-fg, ${aeroTheme.colors.text.primary});
721
1023
  display: flex;
722
1024
  align-items: center;
723
1025
  justify-content: center;
@@ -732,9 +1034,19 @@ export function createConvaiWidget(container, options) {
732
1034
  else {
733
1035
  // Toggle Voice Mode
734
1036
  client.sendInterruptMessage();
735
- await client.audioControls.unmuteAudio(); // Unmute on enter
736
- isVoiceMode = true;
737
- updateVoiceMode();
1037
+ try {
1038
+ await client.audioControls.unmuteAudio(); // Unmute on enter
1039
+ isVoiceMode = true;
1040
+ // Awaited so this try/catch actually covers it -- bare, the promise
1041
+ // escaped the block it looks like it belongs to.
1042
+ await updateVoiceMode();
1043
+ }
1044
+ catch (error) {
1045
+ // isVoiceMode is only flipped after a successful unmute, so a
1046
+ // denial here leaves the widget in text mode, not stranded
1047
+ // mid-transition into voice mode.
1048
+ reportMicFailure(client, "Failed to enable microphone:", error);
1049
+ }
738
1050
  }
739
1051
  });
740
1052
  inputContainer.appendChild(inputElement);
@@ -751,7 +1063,7 @@ export function createConvaiWidget(container, options) {
751
1063
  position: absolute;
752
1064
  top: 60px;
753
1065
  right: 16px;
754
- background: white;
1066
+ background: var(--convai-panel-bg, white);
755
1067
  border-radius: ${aeroTheme.borderRadius.xl};
756
1068
  box-shadow: ${aeroTheme.shadows.xl};
757
1069
  padding: 0;
@@ -766,17 +1078,7 @@ export function createConvaiWidget(container, options) {
766
1078
  overflow: hidden;
767
1079
  `;
768
1080
  // 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
- }
1081
+ injectKeyframes(styleTarget);
780
1082
  // Horizontal Settings Row Container
781
1083
  const settingsRow = document.createElement("div");
782
1084
  settingsRow.style.cssText = `
@@ -805,7 +1107,7 @@ export function createConvaiWidget(container, options) {
805
1107
  ? "#10b981"
806
1108
  : isDestructive
807
1109
  ? "#ef4444"
808
- : aeroTheme.colors.text.primary};
1110
+ : `var(--convai-panel-fg, ${aeroTheme.colors.text.primary})`};
809
1111
  transition: ${aeroTheme.transitions.fast};
810
1112
  min-width: 50px;
811
1113
  `;
@@ -855,6 +1157,7 @@ export function createConvaiWidget(container, options) {
855
1157
  videoIcon.style.height = "18px";
856
1158
  const videoBtn = createOption(videoIcon, "Video", handleToggleVideo, isVideoVisible);
857
1159
  videoBtn.id = "convai-settings-video-btn";
1160
+ refs.settingsVideoBtn = videoBtn;
858
1161
  settingsRow.appendChild(videoBtn);
859
1162
  }
860
1163
  // Screen Share - only show if connection type is video
@@ -867,6 +1170,7 @@ export function createConvaiWidget(container, options) {
867
1170
  shareIcon.style.height = "18px";
868
1171
  const shareBtn = createOption(shareIcon, "Screen", handleToggleScreenShare, isSharing);
869
1172
  shareBtn.id = "convai-settings-share-btn";
1173
+ refs.settingsShareBtn = shareBtn;
870
1174
  settingsRow.appendChild(shareBtn);
871
1175
  }
872
1176
  // Disconnect
@@ -903,7 +1207,7 @@ export function createConvaiWidget(container, options) {
903
1207
  ? "#10b981"
904
1208
  : isDestructive
905
1209
  ? "#ef4444"
906
- : aeroTheme.colors.text.primary};
1210
+ : `var(--convai-panel-fg, ${aeroTheme.colors.text.primary})`};
907
1211
  transition: ${aeroTheme.transitions.fast};
908
1212
  min-width: 50px;
909
1213
  `;
@@ -953,6 +1257,7 @@ export function createConvaiWidget(container, options) {
953
1257
  videoIcon.style.height = "18px";
954
1258
  const videoBtn = createOption(videoIcon, "Video", handleToggleVideo, isVideoVisible);
955
1259
  videoBtn.id = "convai-settings-video-btn";
1260
+ refs.settingsVideoBtn = videoBtn;
956
1261
  settingsRow.appendChild(videoBtn);
957
1262
  }
958
1263
  // Screen Share - Always show if showScreenShare is true and connection type is video
@@ -965,6 +1270,7 @@ export function createConvaiWidget(container, options) {
965
1270
  shareIcon.style.height = "18px";
966
1271
  const shareBtn = createOption(shareIcon, "Screen", handleToggleScreenShare, isSharing);
967
1272
  shareBtn.id = "convai-settings-share-btn";
1273
+ refs.settingsShareBtn = shareBtn;
968
1274
  settingsRow.appendChild(shareBtn);
969
1275
  }
970
1276
  // Disconnect
@@ -977,6 +1283,12 @@ export function createConvaiWidget(container, options) {
977
1283
  const createFloatingVideo = () => {
978
1284
  const container = document.createElement("div");
979
1285
  container.id = "floating-video-container";
1286
+ // NOTE on z-index: deliberately NOT themable. `--convai-z-index` drives the
1287
+ // widget root only. Both elements are `position: fixed` and this one is
1288
+ // appended *before* the widget root, so on a tie the widget paints on top:
1289
+ // a host raising the token to clear their nav bar would hide the user's own
1290
+ // camera preview behind the panel. Video/screen share are also out of scope
1291
+ // for the embed, which is chat-only.
980
1292
  container.style.cssText = `
981
1293
  position: fixed;
982
1294
  left: 20px;
@@ -1158,12 +1470,16 @@ export function createConvaiWidget(container, options) {
1158
1470
  };
1159
1471
  // Event handlers
1160
1472
  const handleToggle = async () => {
1161
- if (isOpen) {
1162
- // Just close if already open
1473
+ if (isOpen || pendingOpen) {
1474
+ // Already open, or an open is already in flight: nothing to do. The
1475
+ // `pendingOpen` half is what keeps a repeat call from falling through
1476
+ // to the `isConnecting` branch below and opening the panel *before* the
1477
+ // connect it is waiting on has finished.
1163
1478
  return;
1164
1479
  }
1165
1480
  // Connect on first click if not already connected/connecting
1166
1481
  if (!client.state.isConnected && !client.state.isConnecting) {
1482
+ pendingOpen = true;
1167
1483
  try {
1168
1484
  // Use reconnect when opening again after a disconnect (reuses stored config)
1169
1485
  if (hasConnectedBefore && typeof client.reconnect === "function") {
@@ -1172,18 +1488,40 @@ export function createConvaiWidget(container, options) {
1172
1488
  else {
1173
1489
  await client.connect();
1174
1490
  }
1491
+ // Re-checked *after* the await: a close()/toggle()/launcher click
1492
+ // during the connect clears the flag, and that cancellation has to
1493
+ // win. The client stays connected either way -- only the panel is
1494
+ // held back -- so a later open() takes the already-connected branch
1495
+ // and opens instantly.
1496
+ if (!pendingOpen)
1497
+ return;
1175
1498
  setIsOpen(true);
1176
1499
  }
1177
1500
  catch (error) {
1178
1501
  console.error("Failed to connect:", error);
1179
1502
  }
1503
+ finally {
1504
+ pendingOpen = false;
1505
+ }
1180
1506
  }
1181
1507
  else {
1182
1508
  // Just toggle open/close if already connected
1183
1509
  setIsOpen(!isOpen);
1184
1510
  }
1185
1511
  };
1512
+ /** A launcher click while an open is pending means "cancel", same as `toggle()`. */
1513
+ const handleLauncherClick = () => {
1514
+ if (pendingOpen) {
1515
+ handleClose();
1516
+ return;
1517
+ }
1518
+ void handleToggle();
1519
+ };
1186
1520
  const handleClose = () => {
1521
+ // Cancels an in-flight open as well as closing an open panel; see
1522
+ // `pendingOpen`. `setIsOpen(false)` on an already-closed widget just
1523
+ // re-applies the collapsed styles, so this is safe in both cases.
1524
+ pendingOpen = false;
1187
1525
  setIsOpen(false);
1188
1526
  };
1189
1527
  const handleSend = () => {
@@ -1218,7 +1556,7 @@ export function createConvaiWidget(container, options) {
1218
1556
  isVoiceMode = false;
1219
1557
  hasEnteredDefaultVoiceMode = false;
1220
1558
  updateHeader();
1221
- updateVoiceMode();
1559
+ await updateVoiceMode();
1222
1560
  updateSendButton();
1223
1561
  }
1224
1562
  catch (error) {
@@ -1232,7 +1570,11 @@ export function createConvaiWidget(container, options) {
1232
1570
  isVoiceMode = false;
1233
1571
  hasEnteredDefaultVoiceMode = false;
1234
1572
  updateHeader();
1235
- updateVoiceMode();
1573
+ // Not awaited: this function has no try/catch of its own and is wired
1574
+ // straight to a click, so awaiting would only move a rejection from one
1575
+ // unguarded place to another. See the note on `handleDisconnect`'s own
1576
+ // `await client.disconnect()` in the fix report.
1577
+ applyVoiceMode();
1236
1578
  updateSendButton();
1237
1579
  };
1238
1580
  const handleToggleVideo = async () => {
@@ -1271,10 +1613,17 @@ export function createConvaiWidget(container, options) {
1271
1613
  const setIsOpen = async (open) => {
1272
1614
  isOpen = open;
1273
1615
  if (open) {
1274
- morphingContainer.style.width = "400px";
1275
- morphingContainer.style.height = "600px";
1276
- morphingContainer.style.borderRadius = aeroTheme.borderRadius.xl;
1616
+ morphingContainer.style.width = `${PANEL_W_PX}px`;
1617
+ morphingContainer.style.height = `${PANEL_H_PX}px`;
1618
+ morphingContainer.style.borderRadius = `var(--convai-radius, ${aeroTheme.borderRadius.xl})`;
1277
1619
  morphingContainer.style.cursor = "default";
1620
+ // The open panel is a *different* glass treatment from the launcher
1621
+ // bubble, matching the React widget: near-opaque and saturated, so the
1622
+ // host page does not bleed through. At the bubble's 0.8 alpha a dark
1623
+ // host page shows through the panel and reads as grey.
1624
+ morphingContainer.style.background =
1625
+ "var(--convai-panel-bg, rgba(252, 252, 253, 0.95))";
1626
+ morphingContainer.style.backdropFilter = "blur(20px) saturate(180%)";
1278
1627
  buttonContent.style.opacity = "0";
1279
1628
  buttonContent.style.transform = "scale(0.8)";
1280
1629
  buttonContent.style.pointerEvents = "none";
@@ -1290,19 +1639,24 @@ export function createConvaiWidget(container, options) {
1290
1639
  await client.audioControls.unmuteAudio();
1291
1640
  isVoiceMode = true;
1292
1641
  hasEnteredDefaultVoiceMode = true;
1293
- updateVoiceMode();
1642
+ await updateVoiceMode();
1294
1643
  }
1295
1644
  catch (error) {
1296
- console.error("Failed to enter voice mode on open:", error);
1645
+ reportMicFailure(client, "Failed to enter voice mode on open:", error);
1297
1646
  }
1298
1647
  }, 100);
1299
1648
  }
1300
1649
  }
1301
1650
  else {
1302
- morphingContainer.style.width = "4rem";
1303
- morphingContainer.style.height = "4rem";
1651
+ morphingContainer.style.width = "var(--convai-launcher-size, 64px)";
1652
+ morphingContainer.style.height = "var(--convai-launcher-size, 64px)";
1653
+ // Literal, not `--convai-radius` — see the creation site: that token is
1654
+ // the *panel* radius, and reusing it here would squircle the launcher.
1304
1655
  morphingContainer.style.borderRadius = "50%";
1305
1656
  morphingContainer.style.cursor = "pointer";
1657
+ // Back to the launcher bubble's lighter glass — see the open branch.
1658
+ morphingContainer.style.background = `var(--convai-panel-bg, ${aeroTheme.colors.glass.backdrop})`;
1659
+ morphingContainer.style.backdropFilter = aeroTheme.glass.backdrop;
1306
1660
  buttonContent.style.opacity = "1";
1307
1661
  buttonContent.style.transform = "scale(1)";
1308
1662
  buttonContent.style.pointerEvents = "auto";
@@ -1316,7 +1670,7 @@ export function createConvaiWidget(container, options) {
1316
1670
  settingsTray.style.display = open ? "flex" : "none";
1317
1671
  };
1318
1672
  const updateSendButton = () => {
1319
- const sendButton = document.getElementById("convai-send-button");
1673
+ const sendButton = refs.sendButton;
1320
1674
  if (!sendButton)
1321
1675
  return;
1322
1676
  // Clear previous content
@@ -1324,8 +1678,15 @@ export function createConvaiWidget(container, options) {
1324
1678
  if (inputValue.length > 0) {
1325
1679
  // Show Send Button
1326
1680
  sendButton.appendChild(Icons.Send("md"));
1327
- sendButton.style.background = aeroTheme.colors.text.primary;
1328
- sendButton.style.color = "white";
1681
+ sendButton.style.background = `var(--convai-accent, ${aeroTheme.colors.text.primary})`;
1682
+ // The label sits *on* the accent fill, so it must follow the panel
1683
+ // surface colour rather than stay a hardcoded "white" -- a host setting
1684
+ // a pale --convai-accent would otherwise get white-on-white. `white` is
1685
+ // byte-identical to the fallback --convai-panel-bg already carries at
1686
+ // its three other sites, so the default rendering is unchanged.
1687
+ // Pairing requirement for hosts: --convai-accent and --convai-panel-bg
1688
+ // must contrast with each other, since this is the one place they meet.
1689
+ sendButton.style.color = "var(--convai-panel-bg, white)";
1329
1690
  sendButton.style.border = "none";
1330
1691
  sendButton.title = "Send";
1331
1692
  }
@@ -1333,7 +1694,7 @@ export function createConvaiWidget(container, options) {
1333
1694
  // Show Voice Mode Toggle
1334
1695
  sendButton.appendChild(Icons.Waveform("md"));
1335
1696
  sendButton.style.background = "transparent";
1336
- sendButton.style.color = aeroTheme.colors.text.primary;
1697
+ sendButton.style.color = `var(--convai-panel-fg, ${aeroTheme.colors.text.primary})`;
1337
1698
  sendButton.style.border = `1px solid ${aeroTheme.colors.neutral[300]}`;
1338
1699
  sendButton.title = "Voice Mode";
1339
1700
  }
@@ -1364,8 +1725,8 @@ export function createConvaiWidget(container, options) {
1364
1725
  }
1365
1726
  };
1366
1727
  const updateVoiceMode = async () => {
1367
- const standardFooter = document.getElementById("convai-footer-standard");
1368
- const voiceExitBtn = document.getElementById("convai-voice-exit-btn");
1728
+ const standardFooter = refs.footerStandard;
1729
+ const voiceExitBtn = refs.voiceExitBtn;
1369
1730
  if (isVoiceMode) {
1370
1731
  // Show Voice Overlay
1371
1732
  if (voiceModeOverlay)
@@ -1391,9 +1752,22 @@ export function createConvaiWidget(container, options) {
1391
1752
  startAudioAnalysis();
1392
1753
  }
1393
1754
  else {
1394
- // Ensure microphone is muted when not in voice mode
1755
+ // Ensure microphone is muted when not in voice mode.
1756
+ //
1757
+ // This await is the only one in this function that can reject, and
1758
+ // everything below it -- hiding the overlay, restoring the standard
1759
+ // footer, updating the header, stopping the analyser -- is the only
1760
+ // route out of the voice UI. So a failed mute must never short-circuit
1761
+ // it: report it and carry on tearing the overlay down. (LiveKit's
1762
+ // setMicrophoneEnabled(false) rethrows on a dropped room, which is
1763
+ // exactly when the user is most likely to be hitting exit.)
1395
1764
  if (!client.audioControls.isAudioMuted) {
1396
- await client.audioControls.muteAudio();
1765
+ try {
1766
+ await client.audioControls.muteAudio();
1767
+ }
1768
+ catch (error) {
1769
+ reportMicFailure(client, "Failed to mute microphone:", error);
1770
+ }
1397
1771
  }
1398
1772
  // Hide Overlay
1399
1773
  if (voiceModeOverlay)
@@ -1425,6 +1799,24 @@ export function createConvaiWidget(container, options) {
1425
1799
  stopAudioAnalysis();
1426
1800
  }
1427
1801
  };
1802
+ /**
1803
+ * Fire-and-forget `updateVoiceMode()` for the call sites that are
1804
+ * synchronous (DOM listeners, the constructor tail) and therefore have no
1805
+ * caller to catch anything it rejects with.
1806
+ *
1807
+ * `updateVoiceMode()` is `async`, so a bare call there would put a floating
1808
+ * promise on the host page. Its one rejecting await is now guarded
1809
+ * internally, which makes this belt-and-braces -- but it is the guarantee
1810
+ * that keeps a future await added inside `updateVoiceMode()` from silently
1811
+ * reintroducing the unhandled rejection this replaces. Routed through
1812
+ * `reportMicFailure` so a failure is observable on the client's `error`
1813
+ * channel rather than console-only.
1814
+ */
1815
+ const applyVoiceMode = () => {
1816
+ void updateVoiceMode().catch((error) => {
1817
+ reportMicFailure(client, "Failed to apply voice mode:", error);
1818
+ });
1819
+ };
1428
1820
  // Microphone button removed - only controlled via voice mode now
1429
1821
  // Helper for Markdown - matches React MarkdownRenderer.tsx
1430
1822
  const renderMarkdown = (text, container) => {
@@ -1459,7 +1851,7 @@ export function createConvaiWidget(container, options) {
1459
1851
  strong.textContent = match[2];
1460
1852
  strong.style.cssText = `
1461
1853
  font-weight: 600;
1462
- color: ${aeroTheme.colors.text.primary};
1854
+ color: var(--convai-panel-fg, ${aeroTheme.colors.text.primary});
1463
1855
  `;
1464
1856
  parts.push(strong);
1465
1857
  }
@@ -1526,7 +1918,7 @@ export function createConvaiWidget(container, options) {
1526
1918
  height: 100%;
1527
1919
  color: ${aeroTheme.colors.text.secondary};
1528
1920
  text-align: center;
1529
- padding: 2rem;
1921
+ padding: 32px;
1530
1922
  `;
1531
1923
  emptyState.innerHTML = `
1532
1924
  <p style="font-weight: 500; margin-bottom: 8px;">Start a conversation</p>
@@ -1548,6 +1940,7 @@ export function createConvaiWidget(container, options) {
1548
1940
  contain: layout style paint;
1549
1941
  `;
1550
1942
  const bubble = document.createElement("div");
1943
+ bubble.setAttribute("part", msg.isUser ? "message message-user" : "message message-bot");
1551
1944
  // Add data attribute to identify user messages for debugging/styling
1552
1945
  if (msg.isUser) {
1553
1946
  bubble.setAttribute("data-message-type", "user");
@@ -1555,6 +1948,10 @@ export function createConvaiWidget(container, options) {
1555
1948
  // Set base styles first - explicitly set background to match React UserBubble exactly
1556
1949
  // CRITICAL: Use the exact same background for both user and bot messages
1557
1950
  const backgroundColor = "rgba(252, 252, 253, 0.95)";
1951
+ // NOTE on border-radius: deliberately NOT themable. `--convai-radius` is
1952
+ // the open panel's radius; a bubble's asymmetric tail corner is a
1953
+ // different geometry (`50%` would make ellipses of these). A dedicated
1954
+ // `--convai-bubble-radius` can be added additively later if wanted.
1558
1955
  bubble.style.cssText = `
1559
1956
  padding: 12px;
1560
1957
  border-radius: ${msg.isUser ? "12px 12px 4px 12px" : "12px 12px 12px 4px"};
@@ -1563,8 +1960,8 @@ export function createConvaiWidget(container, options) {
1563
1960
  backdrop-filter: blur(20px) saturate(180%);
1564
1961
  border: 1px solid rgba(0, 0, 0, 0.06);
1565
1962
  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};
1963
+ color: var(--convai-panel-fg, ${aeroTheme.colors.text.primary});
1964
+ font-family: var(--convai-font, ${aeroTheme.typography.fontFamily.body});
1568
1965
  font-size: 14px;
1569
1966
  line-height: 1.5;
1570
1967
  word-wrap: break-word;
@@ -1596,8 +1993,8 @@ export function createConvaiWidget(container, options) {
1596
1993
  // Logo/Icon - matches React LogoWrapper
1597
1994
  const logoContainer = document.createElement("div");
1598
1995
  logoContainer.style.cssText = `
1599
- width: 1rem;
1600
- height: 1rem;
1996
+ width: 16px;
1997
+ height: 16px;
1601
1998
  display: flex;
1602
1999
  align-items: center;
1603
2000
  justify-content: center;
@@ -1624,10 +2021,10 @@ export function createConvaiWidget(container, options) {
1624
2021
  const nameLabel = document.createElement("span");
1625
2022
  nameLabel.textContent = msg.sender === "User" ? "You" : msg.sender;
1626
2023
  nameLabel.style.cssText = `
1627
- font-size: 0.75rem;
2024
+ font-size: 12px;
1628
2025
  color: ${msg.isUser ? aeroTheme.colors.convai.dark : "#10b981"};
1629
2026
  font-weight: 500;
1630
- font-family: ${aeroTheme.typography.fontFamily.body};
2027
+ font-family: var(--convai-font, ${aeroTheme.typography.fontFamily.body});
1631
2028
  isolation: isolate;
1632
2029
  contain: layout style paint;
1633
2030
  `;
@@ -1649,11 +2046,11 @@ export function createConvaiWidget(container, options) {
1649
2046
  const timestamp = document.createElement("span");
1650
2047
  timestamp.textContent = msg.timestamp;
1651
2048
  timestamp.style.cssText = `
1652
- font-size: 0.75rem;
2049
+ font-size: 12px;
1653
2050
  color: ${aeroTheme.colors.text.secondary};
1654
2051
  margin-top: 4px;
1655
2052
  align-self: ${msg.isUser ? "flex-end" : "flex-start"};
1656
- font-family: ${aeroTheme.typography.fontFamily.body};
2053
+ font-family: var(--convai-font, ${aeroTheme.typography.fontFamily.body});
1657
2054
  `;
1658
2055
  messageWrapper.appendChild(timestamp);
1659
2056
  }
@@ -1730,7 +2127,10 @@ export function createConvaiWidget(container, options) {
1730
2127
  isVideoVisible = false;
1731
2128
  hasEnteredDefaultVoiceMode = false;
1732
2129
  updateHeader();
1733
- updateVoiceMode();
2130
+ // Synchronous listener: nothing here can catch a rejection, and the
2131
+ // mute inside is the one most likely to fail (the room has just
2132
+ // dropped -- that is why we are collapsing).
2133
+ applyVoiceMode();
1734
2134
  }
1735
2135
  // Auto-enter voice mode on first connection if defaultVoiceMode is true
1736
2136
  if (client.state.isConnected &&
@@ -1743,10 +2143,10 @@ export function createConvaiWidget(container, options) {
1743
2143
  await client.audioControls.unmuteAudio();
1744
2144
  isVoiceMode = true;
1745
2145
  hasEnteredDefaultVoiceMode = true;
1746
- updateVoiceMode();
2146
+ await updateVoiceMode();
1747
2147
  }
1748
2148
  catch (error) {
1749
- console.error("Failed to enter voice mode:", error);
2149
+ reportMicFailure(client, "Failed to enter voice mode:", error);
1750
2150
  }
1751
2151
  }, 100);
1752
2152
  }
@@ -1756,9 +2156,9 @@ export function createConvaiWidget(container, options) {
1756
2156
  client.videoControls.on("videoStateChange", (videoState) => {
1757
2157
  if (videoState.isVideoEnabled !== undefined) {
1758
2158
  isVideoVisible = videoState.isVideoEnabled;
1759
- updateVoiceMode();
2159
+ applyVoiceMode(); // synchronous listener; see applyVoiceMode()
1760
2160
  // Update tray button state
1761
- const videoBtn = document.getElementById("convai-settings-video-btn");
2161
+ const videoBtn = refs.settingsVideoBtn;
1762
2162
  if (videoBtn) {
1763
2163
  const isActive = isVideoVisible;
1764
2164
  videoBtn.style.backgroundColor = isActive
@@ -1766,7 +2166,7 @@ export function createConvaiWidget(container, options) {
1766
2166
  : "transparent";
1767
2167
  videoBtn.style.color = isActive
1768
2168
  ? "#10b981"
1769
- : aeroTheme.colors.text.primary;
2169
+ : `var(--convai-panel-fg, ${aeroTheme.colors.text.primary})`;
1770
2170
  // Update Icon - first child is icon container
1771
2171
  const iconContainer = videoBtn.querySelector("div");
1772
2172
  if (iconContainer) {
@@ -1783,7 +2183,7 @@ export function createConvaiWidget(container, options) {
1783
2183
  client.screenShareControls.on("screenShareStateChange", (screenShareState) => {
1784
2184
  if (screenShareState.isScreenShareActive !== undefined) {
1785
2185
  const isSharing = screenShareState.isScreenShareActive;
1786
- const shareBtn = document.getElementById("convai-settings-share-btn");
2186
+ const shareBtn = refs.settingsShareBtn;
1787
2187
  if (shareBtn) {
1788
2188
  const isActive = isSharing;
1789
2189
  shareBtn.style.backgroundColor = isActive
@@ -1791,7 +2191,7 @@ export function createConvaiWidget(container, options) {
1791
2191
  : "transparent";
1792
2192
  shareBtn.style.color = isActive
1793
2193
  ? "#10b981"
1794
- : aeroTheme.colors.text.primary;
2194
+ : `var(--convai-panel-fg, ${aeroTheme.colors.text.primary})`;
1795
2195
  // Update Icon - first child is icon container
1796
2196
  const iconContainer = shareBtn.querySelector("div");
1797
2197
  if (iconContainer) {
@@ -1821,8 +2221,13 @@ export function createConvaiWidget(container, options) {
1821
2221
  client.on("connect", () => {
1822
2222
  hasConnectedBefore = true;
1823
2223
  // Initialize audio renderer on connection
2224
+ // NOTE: the audio elements live under rootElement, so playback ends if
2225
+ // the host *removes or re-parents* its mount container mid-call. Merely
2226
+ // hiding it (display: none / visibility: hidden) does NOT stop
2227
+ // HTMLMediaElement playback in any browser — an earlier version of this
2228
+ // comment claimed otherwise and was wrong.
1824
2229
  if (client.room && !audioRenderer) {
1825
- audioRenderer = new AudioRenderer(client.room);
2230
+ audioRenderer = new AudioRenderer(client.room, { container: rootElement });
1826
2231
  }
1827
2232
  // Fetch character info
1828
2233
  fetchCharacterInfo();
@@ -1847,21 +2252,80 @@ export function createConvaiWidget(container, options) {
1847
2252
  // Initialize
1848
2253
  createDOM();
1849
2254
  setupClientListeners();
2255
+ // Paint the connecting overlay's initial state. updateHeader() (which also
2256
+ // calls updateConnectingOverlay()) is otherwise only reached via a client
2257
+ // event or a successful character-info fetch, so a widget constructed
2258
+ // already connected-but-not-ready would show nothing until one of those
2259
+ // fires without this.
2260
+ updateConnectingOverlay();
1850
2261
  // Set initial button state
1851
2262
  updateSendButton(); // Show voice mode button initially since input is empty
1852
2263
  // Set initial voice mode UI if defaultVoiceMode is true
1853
2264
  if (defaultVoiceMode) {
1854
- updateVoiceMode();
2265
+ applyVoiceMode(); // synchronous constructor tail; see applyVoiceMode()
1855
2266
  }
1856
2267
  // If already connected, initialize audio renderer and fetch character info
2268
+ // NOTE: same lifetime caveat as the "connect" handler above — the audio
2269
+ // elements are torn down with rootElement if the host detaches it.
1857
2270
  if (client.state.isConnected && client.room) {
1858
- audioRenderer = new AudioRenderer(client.room);
2271
+ audioRenderer = new AudioRenderer(client.room, { container: rootElement });
1859
2272
  fetchCharacterInfo();
1860
2273
  }
1861
2274
  // Return widget instance
1862
2275
  const widget = {
1863
2276
  element: rootElement,
1864
2277
  client: client,
2278
+ // `isOpen` reports *requested* state, which on a warm client is the same
2279
+ // thing as rendered state: `setIsOpen()` runs synchronously. The one case
2280
+ // where they differ is the first open on a disconnected client, where the
2281
+ // panel is committed only after `connect()` resolves; reporting `false`
2282
+ // for those seconds would tell an embed reconciling its own
2283
+ // convai-open/convai-close events that a widget it just opened is closed.
2284
+ // `pendingOpen` is cleared by every cancellation path, so this never
2285
+ // reports open for a request that was called off.
2286
+ get isOpen() {
2287
+ return isOpen || pendingOpen;
2288
+ },
2289
+ // Drives the exact same path the launcher click does: `handleToggle`
2290
+ // (connect-on-first-open, then `setIsOpen(true)`). Idempotent against an
2291
+ // in-flight open as well as an open panel, so repeat calls never start a
2292
+ // second connect.
2293
+ open: () => {
2294
+ if (isOpen || pendingOpen)
2295
+ return;
2296
+ void handleToggle();
2297
+ },
2298
+ // Drives the same path the header's close button does: `handleClose` ->
2299
+ // `setIsOpen(false)`. Also cancels an open that is still waiting on
2300
+ // `connect()`; a redundant `close()` is a no-op.
2301
+ close: () => {
2302
+ if (!isOpen && !pendingOpen)
2303
+ return;
2304
+ handleClose();
2305
+ },
2306
+ toggle: () => {
2307
+ if (isOpen || pendingOpen) {
2308
+ handleClose();
2309
+ }
2310
+ else {
2311
+ void handleToggle();
2312
+ }
2313
+ },
2314
+ // Pushes a synthetic ChatMessage straight onto `client.chatMessages` and
2315
+ // re-renders via the widget's existing `updateMessageList()`. It never
2316
+ // calls a client send method (e.g. `sendUserTextMessage`), so the
2317
+ // message never reaches the character and is never billed as an LLM
2318
+ // turn. The `local-` id keeps it from colliding with a server-issued id.
2319
+ appendMessage: (message) => {
2320
+ const chatMessage = {
2321
+ id: `local-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`,
2322
+ type: message.sender === "user" ? "user-llm-text" : "bot-llm-text",
2323
+ content: message.content,
2324
+ timestamp: new Date().toISOString(),
2325
+ };
2326
+ client.chatMessages.push(chatMessage);
2327
+ updateMessageList();
2328
+ },
1865
2329
  destroy: () => {
1866
2330
  // Cleanup audio renderer
1867
2331
  if (audioRenderer) {
@@ -1869,7 +2333,7 @@ export function createConvaiWidget(container, options) {
1869
2333
  audioRenderer = null;
1870
2334
  }
1871
2335
  // Remove event listeners
1872
- morphingContainer.removeEventListener("click", handleToggle);
2336
+ morphingContainer.removeEventListener("click", handleLauncherClick);
1873
2337
  // Remove DOM elements
1874
2338
  if (rootElement.parentElement) {
1875
2339
  rootElement.remove();