@convai/web-sdk 1.8.0-beta.8 → 1.8.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 (113) hide show
  1. package/README.md +45 -213
  2. package/dist/core/ConvaiClient.d.ts +1 -190
  3. package/dist/core/ConvaiClient.d.ts.map +1 -1
  4. package/dist/core/ConvaiClient.js +286 -1306
  5. package/dist/core/ConvaiClient.js.map +1 -1
  6. package/dist/core/MessageHandler.d.ts +1 -19
  7. package/dist/core/MessageHandler.d.ts.map +1 -1
  8. package/dist/core/MessageHandler.js +27 -378
  9. package/dist/core/MessageHandler.js.map +1 -1
  10. package/dist/core/connectRequest.d.ts +4 -17
  11. package/dist/core/connectRequest.d.ts.map +1 -1
  12. package/dist/core/connectRequest.js +17 -28
  13. package/dist/core/connectRequest.js.map +1 -1
  14. package/dist/core/index.d.ts +1 -8
  15. package/dist/core/index.d.ts.map +1 -1
  16. package/dist/core/index.js +1 -13
  17. package/dist/core/index.js.map +1 -1
  18. package/dist/core/logicalTurn.d.ts +6 -4
  19. package/dist/core/logicalTurn.d.ts.map +1 -1
  20. package/dist/core/logicalTurn.js +4 -29
  21. package/dist/core/logicalTurn.js.map +1 -1
  22. package/dist/core/modelOutput.d.ts.map +1 -1
  23. package/dist/core/modelOutput.js +2 -2
  24. package/dist/core/modelOutput.js.map +1 -1
  25. package/dist/core/types.d.ts +22 -860
  26. package/dist/core/types.d.ts.map +1 -1
  27. package/dist/core/types.js.map +1 -1
  28. package/dist/embed/ConvaiChatElement.d.ts +1 -77
  29. package/dist/embed/ConvaiChatElement.d.ts.map +1 -1
  30. package/dist/embed/ConvaiChatElement.js +54 -433
  31. package/dist/embed/ConvaiChatElement.js.map +1 -1
  32. package/dist/embed/chat-embed-v1.js +35 -136
  33. package/dist/embed/chat-embed-v1.js.map +4 -4
  34. package/dist/react/components/ConvaiWidget.d.ts.map +1 -1
  35. package/dist/react/components/ConvaiWidget.js +1 -6
  36. package/dist/react/components/ConvaiWidget.js.map +1 -1
  37. package/dist/react/components/rtc-widget/components/AudioVisualizer.d.ts.map +1 -1
  38. package/dist/react/components/rtc-widget/components/AudioVisualizer.js +1 -28
  39. package/dist/react/components/rtc-widget/components/AudioVisualizer.js.map +1 -1
  40. package/dist/react/components/rtc-widget/components/MarkdownRenderer.d.ts +0 -7
  41. package/dist/react/components/rtc-widget/components/MarkdownRenderer.d.ts.map +1 -1
  42. package/dist/react/components/rtc-widget/components/MarkdownRenderer.js +46 -135
  43. package/dist/react/components/rtc-widget/components/MarkdownRenderer.js.map +1 -1
  44. package/dist/react/components/rtc-widget/components/UserMessage.js +1 -1
  45. package/dist/react/components/rtc-widget/components/UserMessage.js.map +1 -1
  46. package/dist/react/components/rtc-widget/components/conviComponents/VoiceModeOverlay.d.ts.map +1 -1
  47. package/dist/react/components/rtc-widget/components/conviComponents/VoiceModeOverlay.js +49 -93
  48. package/dist/react/components/rtc-widget/components/conviComponents/VoiceModeOverlay.js.map +1 -1
  49. package/dist/react/hooks/useConvaiClient.d.ts.map +1 -1
  50. package/dist/react/hooks/useConvaiClient.js +2 -17
  51. package/dist/react/hooks/useConvaiClient.js.map +1 -1
  52. package/dist/react/index.d.ts +1 -4
  53. package/dist/react/index.d.ts.map +1 -1
  54. package/dist/react/index.js +1 -8
  55. package/dist/react/index.js.map +1 -1
  56. package/dist/vanilla/AudioRenderer.d.ts +1 -3
  57. package/dist/vanilla/AudioRenderer.d.ts.map +1 -1
  58. package/dist/vanilla/AudioRenderer.js +2 -8
  59. package/dist/vanilla/AudioRenderer.js.map +1 -1
  60. package/dist/vanilla/ConvaiWidget.d.ts.map +1 -1
  61. package/dist/vanilla/ConvaiWidget.js +302 -1122
  62. package/dist/vanilla/ConvaiWidget.js.map +1 -1
  63. package/dist/vanilla/WidgetEventSubscriptions.d.ts +1 -1
  64. package/dist/vanilla/WidgetEventSubscriptions.d.ts.map +1 -1
  65. package/dist/vanilla/WidgetEventSubscriptions.js +0 -5
  66. package/dist/vanilla/WidgetEventSubscriptions.js.map +1 -1
  67. package/dist/vanilla/icons.d.ts.map +1 -1
  68. package/dist/vanilla/icons.js +15 -62
  69. package/dist/vanilla/icons.js.map +1 -1
  70. package/dist/vanilla/index.d.ts +2 -6
  71. package/dist/vanilla/index.d.ts.map +1 -1
  72. package/dist/vanilla/index.js +1 -5
  73. package/dist/vanilla/index.js.map +1 -1
  74. package/dist/vanilla/styles.d.ts +1 -18
  75. package/dist/vanilla/styles.d.ts.map +1 -1
  76. package/dist/vanilla/styles.js +29 -141
  77. package/dist/vanilla/styles.js.map +1 -1
  78. package/dist/vanilla/types.d.ts +0 -74
  79. package/dist/vanilla/types.d.ts.map +1 -1
  80. package/dist/vanilla/types.js.map +1 -1
  81. package/dist/version.d.ts +1 -1
  82. package/dist/version.d.ts.map +1 -1
  83. package/dist/version.js +1 -1
  84. package/dist/version.js.map +1 -1
  85. package/package.json +17 -44
  86. package/dist/core/CharacterRoster.d.ts +0 -84
  87. package/dist/core/CharacterRoster.d.ts.map +0 -1
  88. package/dist/core/CharacterRoster.js +0 -348
  89. package/dist/core/CharacterRoster.js.map +0 -1
  90. package/dist/core/CharacterVersionManager.d.ts +0 -100
  91. package/dist/core/CharacterVersionManager.d.ts.map +0 -1
  92. package/dist/core/CharacterVersionManager.js +0 -227
  93. package/dist/core/CharacterVersionManager.js.map +0 -1
  94. package/dist/core/ConvaiRoomError.d.ts +0 -48
  95. package/dist/core/ConvaiRoomError.d.ts.map +0 -1
  96. package/dist/core/ConvaiRoomError.js +0 -111
  97. package/dist/core/ConvaiRoomError.js.map +0 -1
  98. package/dist/core/SSESession.d.ts +0 -59
  99. package/dist/core/SSESession.d.ts.map +0 -1
  100. package/dist/core/SSESession.js +0 -289
  101. package/dist/core/SSESession.js.map +0 -1
  102. package/dist/core/characterReference.d.ts +0 -40
  103. package/dist/core/characterReference.d.ts.map +0 -1
  104. package/dist/core/characterReference.js +0 -72
  105. package/dist/core/characterReference.js.map +0 -1
  106. package/dist/core/rosterRequest.d.ts +0 -57
  107. package/dist/core/rosterRequest.d.ts.map +0 -1
  108. package/dist/core/rosterRequest.js +0 -210
  109. package/dist/core/rosterRequest.js.map +0 -1
  110. package/dist/utils/inlineMarkdown.d.ts +0 -60
  111. package/dist/utils/inlineMarkdown.d.ts.map +0 -1
  112. package/dist/utils/inlineMarkdown.js +0 -183
  113. package/dist/utils/inlineMarkdown.js.map +0 -1
@@ -3,107 +3,11 @@
3
3
  * Ports the React ConvaiWidget to vanilla TypeScript with DOM manipulation
4
4
  */
5
5
  import { AudioRenderer } from "./AudioRenderer.js";
6
- import { aeroTheme, injectGlobalStyles, injectKeyframes, } from "./styles.js";
6
+ import { aeroTheme, injectGlobalStyles } from "./styles.js";
7
7
  import { Icons } from "./icons.js";
8
8
  import { WidgetEventSubscriptions } from "./WidgetEventSubscriptions.js";
9
9
  import { ConvaiClient } from "../core/ConvaiClient.js";
10
- import { parseInlineMarkdown, compactUrl } from "../utils/inlineMarkdown.js";
11
10
  import { freshPublicationGrantOptions } from "../core/publishedChat.js";
12
- /**
13
- * Positioning declarations for the widget root, keyed by `placement`. The
14
- * `bottom-right` entry must stay byte-identical to the literals it replaces --
15
- * the inline-style parity suite snapshots the resolved `cssText` and compares
16
- * it to a committed fixture.
17
- */
18
- /** Gap between a fixed-placement widget and the viewport edge it anchors to. */
19
- const PLACEMENT_INSET_PX = 24;
20
- const PLACEMENT_CSS = {
21
- "bottom-right": `position: fixed; bottom: ${PLACEMENT_INSET_PX}px; right: ${PLACEMENT_INSET_PX}px;`,
22
- "bottom-left": `position: fixed; bottom: ${PLACEMENT_INSET_PX}px; left: ${PLACEMENT_INSET_PX}px;`,
23
- "top-right": `position: fixed; top: ${PLACEMENT_INSET_PX}px; right: ${PLACEMENT_INSET_PX}px;`,
24
- "top-left": `position: fixed; top: ${PLACEMENT_INSET_PX}px; left: ${PLACEMENT_INSET_PX}px;`,
25
- inline: "position: relative;",
26
- };
27
- /** The open panel's preferred size. Capped by `PANEL_MAX_*` — see below. */
28
- const PANEL_W_PX = 400;
29
- const PANEL_H_PX = 600;
30
- /**
31
- * A ceiling on the open panel, so it can never be clipped by the viewport.
32
- *
33
- * The panel used to be a flat 400x600. The widget is `position: fixed`
34
- * anchored `PLACEMENT_INSET_PX` from one edge, so on any viewport narrower
35
- * than 448px — or shorter than 648px — the panel ran past the opposite edge
36
- * and was silently cut off, with no scrollbar to reveal it, because fixed
37
- * elements do not contribute to scrollable overflow. That is most phones: at
38
- * 393px (iPhone 15 Pro) 30px of the panel sat off-screen, at 360px 63px did,
39
- * and in landscape 233px of it — the entire header, including the close
40
- * button — sat above the viewport, leaving nothing on screen to dismiss it.
41
- *
42
- * Expressed as `max-width`/`max-height` rather than folding the cap into the
43
- * width with `min()`. Both compute the same thing, but they fail differently:
44
- * a parser that does not know `min()` drops the whole declaration and the
45
- * panel is left with no width at all, which is worse than the bug. An unknown
46
- * `max-width` drops only the ceiling and leaves the previous behaviour intact.
47
- * (Not hypothetical — happy-dom, which this package's own tests run in, parses
48
- * the `max-width` form and discards the `min()` one.)
49
- *
50
- * Dynamic viewport units so mobile browser chrome is accounted for as it shows
51
- * and hides, rather than sizing against a viewport the user cannot fully see.
52
- * Applied to the container permanently rather than per state: the collapsed
53
- * bubble is far below the ceiling, so it is unaffected.
54
- */
55
- const PANEL_MAX_W = `calc(100dvw - ${PLACEMENT_INSET_PX * 2}px)`;
56
- const PANEL_MAX_H = `calc(100dvh - ${PLACEMENT_INSET_PX * 2}px)`;
57
- /** `convai-error{code:'mic-permission'}` per the embed's error-handling contract. */
58
- const MIC_PERMISSION_ERROR_CODE = "mic-permission";
59
- /**
60
- * Distinguishes a microphone-permission denial from any other audio failure.
61
- *
62
- * Traced (not assumed): `client.audioControls.unmuteAudio()` delegates to
63
- * `AudioManager.enableAudio()`, which calls
64
- * `room.localParticipant.setMicrophoneEnabled(true)`. That's LiveKit's
65
- * `setTrackEnabled()`, which on failure does `throw e` unchanged, from a
66
- * `catch` around `createLocalTracks()`, which itself does `throw e` unchanged
67
- * around `navigator.mediaDevices.getUserMedia()`. `AudioManager.enableAudio()`
68
- * also just does `throw error` in its own catch. Nothing on that path wraps or
69
- * replaces the error, so a user's permission denial reaches here as the exact
70
- * `DOMException` `getUserMedia()` produced, with `.name === 'NotAllowedError'`
71
- * (`'PermissionDeniedError'` is the old pre-spec name some browsers used).
72
- */
73
- function isMicPermissionDenial(error) {
74
- const name = error?.name;
75
- return name === "NotAllowedError" || name === "PermissionDeniedError";
76
- }
77
- /**
78
- * Wraps a mic-permission denial so a consumer of the client's `error`
79
- * channel can tell it apart from a generic failure without knowing anything
80
- * about `DOMException` internals -- it only has to check `error.code`. The
81
- * original error is preserved on `.cause` for anyone who wants the detail.
82
- */
83
- function toMicPermissionError(cause) {
84
- const err = new Error("Microphone access was denied. Continuing in text-only mode.");
85
- err.name = "ConvaiMicPermissionError";
86
- err.code = MIC_PERMISSION_ERROR_CODE;
87
- err.cause = cause;
88
- return err;
89
- }
90
- /**
91
- * Routes a failed `enableAudio()`/`unmuteAudio()` call onto the client's
92
- * *existing* `error` channel -- the same one `ConvaiClient` already uses --
93
- * instead of letting it become an unhandled rejection (the click-handler
94
- * site) or vanish into a bare `console.error` (the auto-voice-mode sites).
95
- * `console.error` is kept alongside for local debugging; this just adds the
96
- * event emission next to it. `client.emit` is called defensively since it
97
- * isn't part of the public `IConvaiClient` surface -- see the `emit?` field
98
- * on `VanillaWidgetOptions.convaiClient` in `./types.ts`.
99
- */
100
- function reportMicFailure(client, logLabel, error) {
101
- console.error(logLabel, error);
102
- const payload = isMicPermissionDenial(error)
103
- ? toMicPermissionError(error)
104
- : error;
105
- client.emit?.("error", payload);
106
- }
107
11
  /**
108
12
  * Create a Convai chat widget in the specified container
109
13
  *
@@ -140,21 +44,6 @@ function reportMicFailure(client, logLabel, error) {
140
44
  * ```
141
45
  */
142
46
  export function createConvaiWidget(container, options) {
143
- /**
144
- * Where global styles and `@keyframes` are injected.
145
- *
146
- * An explicit `options.styleTarget` wins and is used verbatim. Otherwise we
147
- * fall back to deriving it from the container's current root — which is what
148
- * every existing caller relies on, but only works when the container is
149
- * already attached to its final root. A caller that builds a detached
150
- * subtree and appends it to a shadow root afterwards must pass
151
- * `styleTarget` explicitly, or the styles land in `document.head` and the
152
- * shadow tree renders unstyled with no error.
153
- */
154
- const styleTarget = options.styleTarget ??
155
- (container.getRootNode() instanceof ShadowRoot
156
- ? container.getRootNode()
157
- : document);
158
47
  // Create client if not provided - guarantee it's defined
159
48
  const client = options.convaiClient ||
160
49
  (() => {
@@ -174,18 +63,8 @@ export function createConvaiWidget(container, options) {
174
63
  });
175
64
  })();
176
65
  const { showVideo = true, showScreenShare = true, defaultVoiceMode = true, requestPublicationGrant, publishedChatConnectOptions, publishedChatDescriptor, onConnect, onDisconnect, onMessage, } = options;
177
- // Unknown/typo'd values fall back to the default rather than throwing --
178
- // a bad `placement` attribute on a customer's page must not blank the
179
- // widget.
180
- const placement = options.placement && options.placement in PLACEMENT_CSS
181
- ? options.placement
182
- : "bottom-right";
183
66
  // Inject global styles
184
- injectGlobalStyles(styleTarget);
185
- // Keyframes were injected lazily by the connecting overlay and the settings
186
- // tray, but a link-preview skeleton can render before either exists — a
187
- // replayed transcript paints message bubbles immediately. Idempotent.
188
- injectKeyframes(styleTarget);
67
+ injectGlobalStyles();
189
68
  // State
190
69
  let isOpen = false;
191
70
  let isSettingsOpen = false;
@@ -201,22 +80,8 @@ export function createConvaiWidget(container, options) {
201
80
  let hasEnteredDefaultVoiceMode = false;
202
81
  /** True after we have connected at least once; used to call reconnect() instead of connect() when opening after disconnect */
203
82
  let hasConnectedBefore = false;
204
- /**
205
- * True from the moment an open is requested on a *disconnected* client
206
- * until that open either completes or is cancelled.
207
- *
208
- * `isOpen` alone cannot express this: the first open awaits a real
209
- * `connect()` (0.5-3s in production) and only flips `isOpen` afterwards, so
210
- * for that whole window the widget is neither open nor idle. Without this
211
- * flag `close()` sees `isOpen === false` and no-ops -- and the connect then
212
- * pops the panel open behind the user -- while `toggle()` sees the same
213
- * `false`, starts a *second* open, and lands on the already-connecting
214
- * branch that opens immediately. Every entry point below treats
215
- * `pendingOpen` as "open" for the purpose of deciding what a close/toggle
216
- * means, and `handleToggle` re-checks it before committing `setIsOpen(true)`
217
- * so a cancellation during the await is honoured.
218
- */
219
- let pendingOpen = false;
83
+ /** Prevent repeated open clicks from minting multiple one-time publication grants. */
84
+ let isStartingConnection = false;
220
85
  /** Prevent an in-flight publication grant request from connecting after teardown. */
221
86
  let isDestroyed = false;
222
87
  const eventSubscriptions = new WidgetEventSubscriptions(() => isDestroyed);
@@ -233,13 +98,6 @@ export function createConvaiWidget(container, options) {
233
98
  let settingsTray;
234
99
  let floatingVideo;
235
100
  let voiceModeOverlay;
236
- let connectingOverlay;
237
- /**
238
- * Elements captured at creation. IDs are scoped per DOM tree, so
239
- * document.getElementById() returns null when the widget is mounted
240
- * inside a shadow root. Every element here is created in this file.
241
- */
242
- const refs = {};
243
101
  // Audio Analysis State
244
102
  let audioContext = null;
245
103
  let analyzer = null;
@@ -250,31 +108,15 @@ export function createConvaiWidget(container, options) {
250
108
  const fetchCharacterInfo = async () => {
251
109
  if (publishedChatDescriptor)
252
110
  return;
253
- // Prefer authToken over apiKey, matching the precedence ConvaiClient
254
- // uses when building /connect headers -- a client carrying both must
255
- // behave identically in both places.
256
- //
257
- // The two credentials travel in *different* headers: api.convai.com reads
258
- // an auth token from API-AUTH-TOKEN and an API key from X-API-Key, and
259
- // answers an API key sent as API-AUTH-TOKEN with `400 Your session has
260
- // expired.` Because the failure is swallowed below, sending the wrong one
261
- // showed up only as a header stuck on the fallback name.
262
- const credential = client.authToken ?? client.apiKey;
263
- if (!credential || !client.characterId)
111
+ if (!client.apiKey || !client.characterId)
264
112
  return;
265
- const headers = {
266
- "Content-Type": "application/json",
267
- };
268
- if (client.authToken) {
269
- headers["API-AUTH-TOKEN"] = client.authToken;
270
- }
271
- else {
272
- headers["X-API-Key"] = credential;
273
- }
274
113
  try {
275
114
  const response = await fetch("https://api.convai.com/character/get", {
276
115
  method: "POST",
277
- headers,
116
+ headers: {
117
+ "Content-Type": "application/json",
118
+ "API-AUTH-TOKEN": client.apiKey,
119
+ },
278
120
  body: JSON.stringify({ charID: client.characterId }),
279
121
  });
280
122
  if (response.ok) {
@@ -298,34 +140,23 @@ export function createConvaiWidget(container, options) {
298
140
  rootElement = document.createElement("div");
299
141
  rootElement.className = "convai-widget";
300
142
  rootElement.style.cssText = `
301
- ${PLACEMENT_CSS[placement]}
302
- z-index: var(--convai-z-index, ${aeroTheme.zIndex.modal});
303
- font-family: var(--convai-font, ${aeroTheme.typography.fontFamily.primary});
143
+ position: fixed;
144
+ bottom: 1.5rem;
145
+ right: 1.5rem;
146
+ z-index: ${aeroTheme.zIndex.modal};
147
+ font-family: ${aeroTheme.typography.fontFamily.primary};
304
148
  `;
305
149
  // Morphing container
306
150
  morphingContainer = document.createElement("div");
307
- // `part="launcher"` is precise only while collapsed: this same element
308
- // morphs into the open panel (400x600, capped to the viewport — see PANEL_MAX_W)
309
- // and carries that panel's background,
310
- // radius and shadow. See the `part="panel"` call site below for the full
311
- // semantics a host needs to know.
312
- morphingContainer.setAttribute("part", "launcher");
313
- // NOTE on border-radius: deliberately NOT themable. `--convai-radius`
314
- // drives the open panel only. One token cannot serve both geometries --
315
- // `--convai-radius: 8px` would squircle the circular launcher, and `50%`
316
- // would turn the panel into an ellipse. A dedicated
317
- // `--convai-launcher-radius` can be added additively later if wanted.
318
151
  morphingContainer.style.cssText = `
319
152
  position: relative;
320
- width: var(--convai-launcher-size, 64px);
321
- height: var(--convai-launcher-size, 64px);
322
- max-width: ${PANEL_MAX_W};
323
- max-height: ${PANEL_MAX_H};
324
- background: var(--convai-panel-bg, ${aeroTheme.colors.glass.backdrop});
153
+ width: 4rem;
154
+ height: 4rem;
155
+ background: ${aeroTheme.colors.glass.backdrop};
325
156
  backdrop-filter: ${aeroTheme.glass.backdrop};
326
157
  border: ${aeroTheme.glass.border};
327
158
  border-radius: 50%;
328
- box-shadow: var(--convai-shadow, ${aeroTheme.shadows.glass});
159
+ box-shadow: ${aeroTheme.shadows.glass};
329
160
  transition: all 0.3s ease-in-out;
330
161
  overflow: hidden;
331
162
  display: flex;
@@ -346,39 +177,10 @@ export function createConvaiWidget(container, options) {
346
177
  transform: scale(1);
347
178
  `;
348
179
  const convaiLogo = Icons.ConvaiLogo("xl", "idle");
349
- convaiLogo.style.color = `var(--convai-accent, ${aeroTheme.colors.convai.light})`;
180
+ convaiLogo.style.color = aeroTheme.colors.convai.light;
350
181
  buttonContent.appendChild(convaiLogo);
351
182
  // Chat content
352
183
  chatContent = document.createElement("div");
353
- // ----------------------------------------------------------------------
354
- // `launcher` / `panel` part semantics. Read before writing theming docs.
355
- //
356
- // This widget morphs rather than swapping trees: `morphingContainer` IS
357
- // the 64px circle AND the open panel, animating between them (see
358
- // setIsOpen). It therefore owns every painted surface property of both
359
- // states -- background, border, border-radius, box-shadow. `buttonContent`
360
- // and `chatContent` are transparent `inset: 0` overlays that only
361
- // cross-fade their contents.
362
- //
363
- // So, precisely:
364
- // ::part(launcher) -> morphingContainer. Paints the collapsed circle
365
- // AND the open panel; there is no rule that hits
366
- // only one. This is the surface part.
367
- // ::part(panel) -> chatContent. The open state's *content layer*:
368
- // useful for padding, colour, font, opacity. It has
369
- // no background, no border and no radius of its
370
- // own, so `::part(panel){border-radius}` does
371
- // nothing -- set radius via ::part(launcher) or
372
- // `--convai-radius` (which is panel-scoped).
373
- //
374
- // Assessed and deliberately not "fixed": moving `launcher` onto
375
- // buttonContent would make `::part(launcher){background}` state-specific
376
- // but would silently break `border-radius` and `box-shadow` on it, since
377
- // buttonContent is clipped by the container's own radius + overflow:
378
- // hidden. Correcting this properly needs a separate launcher element
379
- // instead of a morph -- a rendering change, out of scope here.
380
- // ----------------------------------------------------------------------
381
- chatContent.setAttribute("part", "panel");
382
184
  chatContent.style.cssText = `
383
185
  position: absolute;
384
186
  inset: 0;
@@ -399,19 +201,11 @@ export function createConvaiWidget(container, options) {
399
201
  contentElement.style.cssText = `
400
202
  flex: 1;
401
203
  overflow-y: auto;
402
- padding: 16px;
204
+ padding: 1rem;
403
205
  background: transparent;
404
- position: relative;
405
206
  `;
406
207
  messageListElement = createMessageList();
407
208
  contentElement.appendChild(messageListElement);
408
- // Connecting overlay -- shown until the bot is ready. Appended last, not
409
- // to match DOM order (position: absolute + z-index: 100, set inside
410
- // createConnectingOverlay, is what keeps it on top regardless of source
411
- // order) but so inserting it doesn't shift every existing sibling's
412
- // index-based path in the inline-style-parity fixture.
413
- connectingOverlay = createConnectingOverlay();
414
- contentElement.appendChild(connectingOverlay);
415
209
  // Footer
416
210
  footerElement = createFooter();
417
211
  chatContent.appendChild(headerElement);
@@ -428,31 +222,9 @@ export function createConvaiWidget(container, options) {
428
222
  container.appendChild(floatingVideo);
429
223
  container.appendChild(rootElement);
430
224
  // Event listeners
431
- morphingContainer.addEventListener("click", handleLauncherClick);
225
+ morphingContainer.addEventListener("click", handleToggle);
432
226
  };
433
227
  // Create Voice Mode Overlay
434
- /**
435
- * Voice mode, matched to the React overlay (VoiceModeOverlay.tsx) rather
436
- * than to what this file used to draw.
437
- *
438
- * The two had diverged badly: React centres the character's avatar inside
439
- * two counter-rotating liquid-gradient layers and paints the user's
440
- * microphone as three overlapping sine waves on a canvas, while this file
441
- * drew forty grey vertical bars and no avatar at all. Same product, two
442
- * unrelated screens.
443
- *
444
- * What is reproduced here, value for value: the 120px avatar with its 3px
445
- * rgba(255,255,255,0.2) ring and letter fallback, the 126px conic and
446
- * radial gradient layers (20s clockwise / 15s anticlockwise, blurred 8px
447
- * and 6px), the 250x80 canvas carrying three waves, and the status text at
448
- * 16px/600 over 13px with React's wording.
449
- *
450
- * The one deliberate omission is the `AudioVisualizer` ring that React
451
- * fades in behind the avatar while the character speaks: it is a 364-line
452
- * component wired directly to a LiveKit `Room`, and porting it is its own
453
- * change. The avatar still breathes while the character speaks, so the
454
- * speaking state reads without it.
455
- */
456
228
  const createVoiceModeOverlay = () => {
457
229
  const overlay = document.createElement("div");
458
230
  overlay.style.cssText = `
@@ -461,383 +233,171 @@ export function createConvaiWidget(container, options) {
461
233
  left: 50%;
462
234
  transform: translate(-50%, -50%);
463
235
  text-align: center;
464
- padding: 16px;
236
+ padding: 1rem;
465
237
  z-index: 10;
466
238
  pointer-events: auto;
467
239
  display: none;
468
240
  flex-direction: column;
469
241
  align-items: center;
470
- gap: 32px;
471
- `;
472
- // --- Avatar, with the two rotating gradient layers behind it ----------
473
- const avatarWrap = document.createElement("div");
474
- avatarWrap.style.cssText = `
475
- position: relative;
476
- display: flex;
477
- align-items: center;
478
- justify-content: center;
479
- isolation: isolate;
480
- `;
481
- const avatarStack = document.createElement("div");
482
- avatarStack.className = "convai-voice-avatar";
483
- refs.voiceModeAvatar = avatarStack;
484
- avatarStack.style.cssText = `
485
- position: relative;
486
- z-index: 10;
487
- border-radius: 50%;
488
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
489
- background: white;
490
- display: flex;
491
- align-items: center;
492
- justify-content: center;
493
- `;
494
- const accent = `var(--convai-accent, ${aeroTheme.colors.convai.light})`;
495
- const layer1 = document.createElement("div");
496
- layer1.className = "convai-voice-gradient";
497
- layer1.style.cssText = `
498
- position: absolute;
499
- width: 126px;
500
- height: 126px;
501
- border-radius: 50%;
502
- background: conic-gradient(
503
- from 0deg,
504
- ${aeroTheme.colors.convai.light}00 0%,
505
- ${aeroTheme.colors.convai.light}40 25%,
506
- ${aeroTheme.colors.convai.dark}60 50%,
507
- ${aeroTheme.colors.convai.light}40 75%,
508
- ${aeroTheme.colors.convai.light}00 100%
509
- );
510
- opacity: 0.8;
511
- filter: blur(8px);
512
- z-index: -2;
513
- animation: convaiVoiceSpin 20s linear infinite;
514
- `;
515
- const layer2 = document.createElement("div");
516
- layer2.className = "convai-voice-gradient";
517
- layer2.style.cssText = `
518
- position: absolute;
519
- width: 126px;
520
- height: 126px;
521
- border-radius: 50%;
522
- background:
523
- radial-gradient(ellipse 80% 100% at 30% 40%,
524
- ${aeroTheme.colors.convai.light}50,
525
- ${aeroTheme.colors.convai.light}30 40%,
526
- transparent 70%),
527
- radial-gradient(ellipse 80% 100% at 70% 60%,
528
- ${aeroTheme.colors.convai.dark}50,
529
- ${aeroTheme.colors.convai.dark}30 40%,
530
- transparent 70%);
531
- opacity: 0.9;
532
- filter: blur(6px);
533
- z-index: -1;
534
- animation: convaiVoiceSpinReverse 15s linear infinite;
242
+ gap: 1.5rem;
535
243
  `;
536
- // The image is clipped by its own container so the gradients above stay
537
- // visible outside it — React nests it the same way for the same reason.
538
- const avatarClip = document.createElement("div");
539
- avatarClip.style.cssText = `
540
- position: relative;
541
- border-radius: 50%;
542
- overflow: hidden;
543
- border: 3px solid rgba(255, 255, 255, 0.2);
544
- width: 120px;
545
- height: 120px;
244
+ // Bars Container
245
+ const barsContainer = document.createElement("div");
246
+ barsContainer.id = "voice-bars-container";
247
+ barsContainer.style.cssText = `
546
248
  display: flex;
547
249
  align-items: center;
548
250
  justify-content: center;
251
+ gap: 2px;
252
+ height: 80px;
253
+ max-width: 300px;
549
254
  `;
550
- refs.voiceModeAvatarClip = avatarClip;
551
- avatarStack.append(layer1, layer2, avatarClip);
552
- avatarWrap.appendChild(avatarStack);
553
- overlay.appendChild(avatarWrap);
554
- // --- The user's microphone, as three waves on a canvas ----------------
555
- const canvas = document.createElement("canvas");
556
- canvas.className = "convai-voice-wave";
557
- canvas.style.cssText = `width: 250px; height: 80px;`;
558
- refs.voiceModeCanvas = canvas;
559
- overlay.appendChild(canvas);
560
- // --- Status text ------------------------------------------------------
255
+ // Create 40 bars
256
+ for (let i = 0; i < 40; i++) {
257
+ const bar = document.createElement("div");
258
+ bar.className = "voice-bar";
259
+ bar.style.cssText = `
260
+ width: 3px;
261
+ height: 15px;
262
+ background-color: ${aeroTheme.colors.neutral[400]};
263
+ border-radius: 1.5px;
264
+ transition: height 0.08s ease-out, background-color 0.2s;
265
+ transform-origin: center;
266
+ `;
267
+ barsContainer.appendChild(bar);
268
+ }
269
+ overlay.appendChild(barsContainer);
270
+ // Status Text
561
271
  const statusContainer = document.createElement("div");
562
272
  const statusTitle = document.createElement("div");
563
273
  statusTitle.id = "voice-mode-title";
564
- refs.voiceModeTitle = statusTitle;
565
274
  statusTitle.style.cssText = `
566
- font-size: 16px;
567
- font-weight: 600;
568
- color: var(--convai-panel-fg, ${aeroTheme.colors.text.primary});
569
- margin-bottom: 8px;
275
+ font-size: 14px;
276
+ font-weight: 500;
277
+ color: ${aeroTheme.colors.text.primary};
278
+ margin-bottom: 0.5rem;
570
279
  `;
571
- statusTitle.textContent = "Voice Mode";
280
+ statusTitle.textContent = "Voice Only Mode";
572
281
  const statusSubtitle = document.createElement("div");
573
282
  statusSubtitle.id = "voice-mode-subtitle";
574
- refs.voiceModeSubtitle = statusSubtitle;
575
283
  statusSubtitle.style.cssText = `
576
- font-size: 13px;
284
+ font-size: 12px;
577
285
  color: ${aeroTheme.colors.text.secondary};
578
286
  `;
579
- statusSubtitle.textContent = "Tap microphone to talk";
580
- statusContainer.append(statusTitle, statusSubtitle);
287
+ statusSubtitle.textContent = "Press and hold the microphone to talk";
288
+ statusContainer.appendChild(statusTitle);
289
+ statusContainer.appendChild(statusSubtitle);
581
290
  overlay.appendChild(statusContainer);
582
- void accent;
583
291
  return overlay;
584
292
  };
585
- /**
586
- * Fills the voice-mode avatar from the character info fetch, or falls back
587
- * to the character's initial the way React does. Called from
588
- * updateHeader(), so it tracks a late `fetchCharacterInfo()` rather than
589
- * being fixed at construction.
590
- */
591
- const updateVoiceModeAvatar = () => {
592
- const clip = refs.voiceModeAvatarClip;
593
- if (!clip)
594
- return;
595
- const wanted = characterImage || `letter:${characterName}`;
596
- if (clip.dataset.filledWith === wanted)
597
- return;
598
- clip.dataset.filledWith = wanted;
599
- clip.replaceChildren();
600
- if (characterImage) {
601
- const img = document.createElement("img");
602
- img.src = characterImage;
603
- img.alt = characterName;
604
- img.style.cssText = `width: 120px; height: 120px; object-fit: cover; display: block;`;
605
- clip.appendChild(img);
606
- return;
607
- }
608
- const letter = document.createElement("div");
609
- letter.style.cssText = `
610
- width: 120px;
611
- height: 120px;
612
- background-color: ${aeroTheme.colors.convai.light}20;
613
- display: flex;
614
- align-items: center;
615
- justify-content: center;
616
- font-size: 48px;
617
- font-weight: 600;
618
- color: var(--convai-accent, ${aeroTheme.colors.convai.light});
619
- `;
620
- letter.textContent = characterName[0]?.toUpperCase() || "C";
621
- clip.appendChild(letter);
622
- };
623
- // Create Connecting Overlay -- matches React's inline AnimatePresence
624
- // block exactly (ConvaiWidget.tsx:681-737): shown whenever
625
- // `isConnected && !isBotReady`, covering just the content area (not the
626
- // header/footer) so the chrome stays interactive while the character
627
- // joins. Persistent in the DOM like voiceModeOverlay above -- toggled via
628
- // opacity/pointer-events rather than mounted/unmounted, since this file has
629
- // no AnimatePresence-style exit-animation machinery. Visibility is driven
630
- // by updateConnectingOverlay().
631
- const createConnectingOverlay = () => {
632
- const overlay = document.createElement("div");
633
- overlay.id = "convai-connecting-overlay";
634
- overlay.style.cssText = `
635
- position: absolute;
636
- inset: 0;
637
- z-index: 100;
638
- display: flex;
639
- flex-direction: column;
640
- align-items: center;
641
- justify-content: center;
642
- gap: 16px;
643
- background: rgba(255, 255, 255, 0.95);
644
- opacity: 0;
645
- pointer-events: none;
646
- transition: opacity 0.3s;
647
- `;
648
- // Keyframes live in the shadow-safe injector (styles.ts), not
649
- // document.head -- @keyframes defined there do not resolve for shadow
650
- // content. Idempotent; also called from createSettingsTray().
651
- injectKeyframes(styleTarget);
652
- // Spinning logo: a full turn every 2s, linear, infinite.
653
- const spinWrapper = document.createElement("div");
654
- spinWrapper.className = "convai-connecting-spin";
655
- spinWrapper.style.cssText = `
656
- display: inline-flex;
657
- animation: convaiConnectingSpin 2s linear infinite;
658
- `;
659
- const logo = Icons.ConvaiLogo("xl", "connecting");
660
- // Themable, like every other accent usage in this file (see
661
- // theming.test.ts) -- var() with the exact literal React passes
662
- // (`aeroTheme.colors.convai.light`) as the fallback, so an unset
663
- // --convai-accent renders identically to the React value.
664
- logo.style.color = `var(--convai-accent, ${aeroTheme.colors.convai.light})`;
665
- spinWrapper.appendChild(logo);
666
- overlay.appendChild(spinWrapper);
667
- // Pulsing "Connecting to {characterName}..." label.
668
- const label = document.createElement("div");
669
- label.id = "convai-connecting-text";
670
- label.className = "convai-connecting-pulse";
671
- label.style.cssText = `
672
- font-size: 14px;
673
- font-weight: 500;
674
- color: ${aeroTheme.colors.text.secondary};
675
- animation: convaiConnectingPulse 2s ease-in-out infinite;
676
- `;
677
- refs.connectingLabel = label;
678
- overlay.appendChild(label);
679
- return overlay;
680
- };
681
- /** Shows/hides the connecting overlay and refreshes its label. Called
682
- * from updateHeader() so every existing call site (stateChange, botReady,
683
- * connect/disconnect via stateChange, character-info fetch, ...) stays in
684
- * sync automatically. */
685
- const updateConnectingOverlay = () => {
686
- if (!connectingOverlay)
687
- return;
688
- const label = refs.connectingLabel;
689
- if (label) {
690
- label.textContent = `Connecting to ${characterName}...`;
691
- }
692
- // `isConnecting` matters as much as `isConnected` here. The panel opens
693
- // on the click that *starts* the connect, so between that click and the
694
- // transport coming up (a real network round trip — ~3s against the live
695
- // service) `isConnected` is still false. Gating on it alone left the
696
- // whole of that window with no overlay at all: an empty message area and
697
- // a nameless header, i.e. the widget looked broken for exactly as long as
698
- // it was doing the one thing the user asked for. Then the label appeared
699
- // *after* the transport was already up, which reads backwards.
700
- //
701
- // The embed makes this the normal path rather than an edge case: its
702
- // launcher click opens the panel immediately by design, so the pre-connect
703
- // window is always visible to the user.
704
- const shouldShow = (client.state.isConnected || client.state.isConnecting) && !client.isBotReady;
705
- connectingOverlay.style.opacity = shouldShow ? "1" : "0";
706
- connectingOverlay.style.pointerEvents = shouldShow ? "auto" : "none";
707
- };
708
293
  // Audio Analysis State for Voice Mode
709
294
  let audioLevels = Array(40).fill(0);
295
+ let targetLevels = Array(40).fill(0.05);
296
+ let currentLevels = Array(40).fill(0.05);
710
297
  let startTime = 0;
711
- /**
712
- * Paints the three overlapping sine waves React draws for the user's
713
- * microphone (VoiceModeOverlay.tsx's `drawWaves`), including its two
714
- * performance guards, which matter more here than there: this canvas sits
715
- * over whatever the host page is rendering, and every repaint forces the
716
- * compositor to re-blend the whole panel. So it runs at 30fps rather than
717
- * 60, and stops entirely while the input is silent — the static line is
718
- * already on the canvas at that point.
719
- */
720
- let waveFrameParity = 0;
721
- let waveWasSilent = false;
722
- const drawWaves = () => {
723
- const canvas = refs.voiceModeCanvas;
724
- if (!canvas)
725
- return;
726
- waveFrameParity ^= 1;
727
- if (waveFrameParity)
728
- return;
729
- let sum = 0;
730
- for (let i = 0; i < audioLevels.length; i++)
731
- sum += audioLevels[i];
732
- const silent = sum / audioLevels.length < 0.012;
733
- if (silent && waveWasSilent)
734
- return;
735
- waveWasSilent = silent;
736
- const dpr = Math.min(window.devicePixelRatio || 1, 2);
737
- if (canvas.width !== 250 * dpr) {
738
- canvas.width = 250 * dpr;
739
- canvas.height = 80 * dpr;
740
- }
741
- const ctx = canvas.getContext("2d");
742
- if (!ctx)
743
- return;
744
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
745
- ctx.clearRect(0, 0, 250, 80);
746
- const centerY = 40;
747
- const waveWidth = 250;
748
- const segments = 40;
749
- const avgLevel = sum / audioLevels.length;
750
- const waveColors = [
751
- aeroTheme.colors.convai.light,
752
- aeroTheme.colors.convai.light,
753
- "#34d399",
754
- ];
755
- ctx.lineCap = "round";
756
- ctx.lineJoin = "round";
757
- for (let waveIndex = 2; waveIndex >= 0; waveIndex--) {
758
- ctx.beginPath();
759
- for (let i = 0; i <= segments; i++) {
760
- const x = (i / segments) * waveWidth;
761
- const levelIndex = Math.floor((i / segments) * (audioLevels.length - 1));
762
- const level = audioLevels[levelIndex] || 0;
763
- const phase = (Date.now() / 500 + i * 0.2 + waveIndex * 1.5) % (Math.PI * 2);
764
- const baseAmplitude = avgLevel > 0.05 ? 2 + waveIndex * 1.5 : 0;
765
- const amplitude = baseAmplitude + level * (25 - baseAmplitude);
766
- const y = centerY + Math.sin(phase) * amplitude;
767
- if (i === 0)
768
- ctx.moveTo(x, y);
769
- else
770
- ctx.lineTo(x, y);
771
- }
772
- ctx.strokeStyle = waveColors[waveIndex];
773
- ctx.globalAlpha = 0.18 * (0.3 + avgLevel);
774
- ctx.lineWidth = 2.5 - waveIndex * 0.4 + 8 * avgLevel + 3;
775
- ctx.stroke();
776
- ctx.globalAlpha = 0.95 - waveIndex * 0.15;
777
- ctx.lineWidth = 2.5 - waveIndex * 0.4;
778
- ctx.stroke();
779
- }
780
- ctx.globalAlpha = 1;
781
- };
782
- /**
783
- * The voice-mode frame loop. Keeps the name `updateAudioBars` because
784
- * every call site refers to it; what it drives is now the avatar, the
785
- * canvas and the status text rather than forty bars.
786
- */
298
+ // Audio Analysis Logic
787
299
  const updateAudioBars = () => {
788
300
  if (!voiceModeOverlay)
789
301
  return;
302
+ const bars = voiceModeOverlay.querySelectorAll(".voice-bar");
790
303
  const isTalking = client.state.isSpeaking;
791
- const isListening = client.state.isListening;
792
- const micOpen = !client.audioControls.isAudioMuted;
793
- // The avatar breathes while the character speaks — React drives the same
794
- // thing with a framer-motion scale keyframe.
795
- const avatar = refs.voiceModeAvatar;
796
- if (avatar) {
797
- const wanted = isTalking ? "convaiVoiceBreathe 3s ease-in-out infinite" : "";
798
- if (avatar.style.animation !== wanted)
799
- avatar.style.animation = wanted;
800
- }
801
- // React's exact wording, including "Voice Mode" rather than the
802
- // "Voice Only Mode" this file used to show.
803
- const title = refs.voiceModeTitle;
804
- const subtitle = refs.voiceModeSubtitle;
304
+ const isListening = client.state.isListening; // User is speaking (from user-started-speaking)
305
+ const isAnimating = isListening || isTalking;
306
+ // Update colors based on state
307
+ bars.forEach((bar) => {
308
+ bar.style.backgroundColor = isTalking
309
+ ? aeroTheme.colors.convai.light
310
+ : isListening
311
+ ? aeroTheme.colors.text.primary
312
+ : aeroTheme.colors.neutral[400];
313
+ });
314
+ // Update Text
315
+ const title = document.getElementById("voice-mode-title");
316
+ const subtitle = document.getElementById("voice-mode-subtitle");
805
317
  if (title) {
806
318
  title.textContent = isTalking
807
- ? `${characterName} Speaking...`
808
- : isListening || micOpen
319
+ ? "Character Speaking..."
320
+ : isListening
809
321
  ? "Listening..."
810
- : "Voice Mode";
322
+ : "Voice Only Mode";
811
323
  }
812
324
  if (subtitle) {
813
325
  subtitle.textContent =
814
- isListening || micOpen || isTalking ? "Audio active" : "Tap microphone to talk";
326
+ isListening || isTalking
327
+ ? "Audio active"
328
+ : "Press and hold the microphone to talk";
815
329
  }
816
- if ((isListening || micOpen) && analyzer && dataArray) {
330
+ // Animation Logic - Matches React version (animate when user or bot is active)
331
+ if ((isListening || !client.audioControls.isAudioMuted) && analyzer && dataArray) {
332
+ // Use time domain data (waveform) instead of frequency
817
333
  // @ts-ignore - TypeScript strict mode issue with Uint8Array type
818
334
  analyzer.getByteTimeDomainData(dataArray);
335
+ // Calculate RMS (Root Mean Square) for volume
819
336
  let sum = 0;
820
337
  for (let i = 0; i < dataArray.length; i++) {
821
- const normalized = (dataArray[i] - 128) / 128;
338
+ const normalized = (dataArray[i] - 128) / 128; // Center around 0
822
339
  sum += normalized * normalized;
823
340
  }
824
341
  const rms = Math.sqrt(sum / dataArray.length);
825
- const volume = Math.min(1, rms * 3);
826
- for (let i = 0; i < audioLevels.length; i++) {
827
- const position = i / audioLevels.length;
342
+ // Apply some scaling and clamping
343
+ const volume = Math.min(1, rms * 3); // Boost sensitivity
344
+ // Create left-to-right wave effect
345
+ const minHeight = 8;
346
+ const maxHeight = 70;
347
+ bars.forEach((bar, i) => {
348
+ // Progressive wave from left to right
349
+ const position = i / 40; // 0 to 1 from left to right
828
350
  const wavePhase = Date.now() / 300 + position * Math.PI * 2;
829
- const waveVariation = Math.sin(wavePhase) * 0.15 + 0.85;
830
- audioLevels[i] = volume * waveVariation;
351
+ const waveVariation = Math.sin(wavePhase) * 0.15 + 0.85; // 0.7 to 1.0
352
+ const level = volume * waveVariation;
353
+ const height = minHeight + level * (maxHeight - minHeight);
354
+ bar.style.height = `${Math.max(minHeight, height)}px`;
355
+ });
356
+ }
357
+ else if (isTalking) {
358
+ // Simulate speaking bars with natural speech patterns
359
+ const elapsed = (Date.now() - startTime) / 1000; // seconds
360
+ // Generate new random target levels occasionally (simulating syllables/words)
361
+ if (Math.random() < 0.08) {
362
+ // 8% chance per frame = ~5 times per second
363
+ targetLevels = Array(40)
364
+ .fill(0)
365
+ .map((_, i) => {
366
+ // More variation in the middle bars, less on edges for natural spread
367
+ const position = i / 40;
368
+ const centerWeight = 1 - Math.abs(position - 0.5) * 0.5;
369
+ // Random peaks and valleys like speech patterns
370
+ const randomPeak = 0.2 + Math.random() * 0.7; // 0.2 to 0.9
371
+ // Add some neighbor correlation so bars don't jump independently
372
+ const prevTarget = targetLevels[i] || 0.3;
373
+ const correlation = prevTarget * 0.4 + randomPeak * 0.6;
374
+ return correlation * centerWeight;
375
+ });
831
376
  }
377
+ // Smoothly interpolate current levels toward targets (organic movement)
378
+ currentLevels = currentLevels.map((current, i) => {
379
+ const target = targetLevels[i];
380
+ const speed = 0.2; // Smooth but responsive
381
+ return current + (target - current) * speed;
382
+ });
383
+ // Apply a gentle fade-in for the first 0.3 seconds
384
+ const fadeIn = Math.min(1, elapsed / 0.3);
385
+ const minHeight = 8;
386
+ const maxHeight = 70;
387
+ bars.forEach((bar, i) => {
388
+ // Small random jitter for micro-variation
389
+ const microJitter = 0.95 + Math.random() * 0.1; // 0.95 to 1.05
390
+ const level = Math.max(0.05, Math.min(1, currentLevels[i] * fadeIn * microJitter));
391
+ const height = minHeight + level * (maxHeight - minHeight);
392
+ bar.style.height = `${Math.max(minHeight, height)}px`;
393
+ });
832
394
  }
833
395
  else {
834
- // The waves settle to a flat line rather than animating on the
835
- // character's behalf: React paints this canvas from the microphone
836
- // only, and the character's own speech is the avatar's job.
837
- for (let i = 0; i < audioLevels.length; i++)
838
- audioLevels[i] = 0;
396
+ // Reset to idle state
397
+ bars.forEach((bar) => {
398
+ bar.style.height = "15px";
399
+ });
839
400
  }
840
- drawWaves();
841
401
  rafId = requestAnimationFrame(updateAudioBars);
842
402
  };
843
403
  const startAudioAnalysis = async () => {
@@ -859,15 +419,7 @@ export function createConvaiWidget(container, options) {
859
419
  updateAudioBars();
860
420
  }
861
421
  catch (e) {
862
- // Fourth mic-acquiring call site: this getUserMedia() is independent
863
- // of client.audioControls (it's a separate analyser-only stream, not
864
- // routed through AudioManager), but a denial here is the identical
865
- // failure mode -- most commonly a mid-session permission revocation,
866
- // since by the time voice mode is entered the mic has usually already
867
- // been granted via unmuteAudio(). Same helper, same channel, so a
868
- // consumer sees one consistent signal regardless of which internal
869
- // path acquired the mic.
870
- reportMicFailure(client, "Audio analysis setup failed:", e);
422
+ console.error("Audio analysis setup failed:", e);
871
423
  }
872
424
  };
873
425
  const stopAudioAnalysis = () => {
@@ -884,14 +436,13 @@ export function createConvaiWidget(container, options) {
884
436
  // Create header
885
437
  const createHeader = () => {
886
438
  const header = document.createElement("div");
887
- header.setAttribute("part", "header");
888
439
  header.style.cssText = `
889
440
  display: flex;
890
441
  align-items: center;
891
442
  justify-content: space-between;
892
- padding: 16px;
443
+ padding: 1rem;
893
444
  border-bottom: 1px solid ${aeroTheme.colors.neutral[200]};
894
- background: var(--convai-panel-bg, white);
445
+ background: white;
895
446
  position: relative;
896
447
  `;
897
448
  // Close button on the left
@@ -899,10 +450,10 @@ export function createConvaiWidget(container, options) {
899
450
  const chevronIcon = Icons.ChevronDown("md");
900
451
  closeButton.appendChild(chevronIcon);
901
452
  closeButton.style.cssText = `
902
- font-size: 20px;
453
+ font-size: 1.25rem;
903
454
  color: ${aeroTheme.colors.text.secondary};
904
455
  cursor: pointer;
905
- padding: 4px;
456
+ padding: 0.25rem;
906
457
  transition: ${aeroTheme.transitions.fast};
907
458
  background: transparent;
908
459
  border: none;
@@ -919,22 +470,21 @@ export function createConvaiWidget(container, options) {
919
470
  transform: translateX(-50%);
920
471
  display: flex;
921
472
  align-items: center;
922
- gap: 8px;
473
+ gap: 0.5rem;
923
474
  font-size: ${aeroTheme.typography.fontSize.base};
924
475
  font-weight: ${aeroTheme.typography.fontWeight.semibold};
925
- color: var(--convai-panel-fg, ${aeroTheme.colors.text.primary});
476
+ color: ${aeroTheme.colors.text.primary};
926
477
  `;
927
478
  titleSection.id = "convai-widget-title";
928
- refs.widgetTitle = titleSection;
929
479
  // Settings button on the right
930
480
  const settingsButton = document.createElement("button");
931
481
  const moreIcon = Icons.MoreVertical("md");
932
482
  settingsButton.appendChild(moreIcon);
933
483
  settingsButton.style.cssText = `
934
- font-size: 24px;
484
+ font-size: 1.5rem;
935
485
  color: ${aeroTheme.colors.text.secondary};
936
486
  cursor: pointer;
937
- padding: 4px;
487
+ padding: 0.25rem;
938
488
  transition: ${aeroTheme.transitions.fast};
939
489
  background: transparent;
940
490
  border: none;
@@ -951,7 +501,7 @@ export function createConvaiWidget(container, options) {
951
501
  };
952
502
  // Update header with character info
953
503
  const updateHeader = () => {
954
- const titleSection = refs.widgetTitle;
504
+ const titleSection = document.getElementById("convai-widget-title");
955
505
  if (!titleSection)
956
506
  return;
957
507
  titleSection.innerHTML = "";
@@ -960,8 +510,8 @@ export function createConvaiWidget(container, options) {
960
510
  img.src = characterImage;
961
511
  img.alt = characterName;
962
512
  img.style.cssText = `
963
- width: 24px;
964
- height: 24px;
513
+ width: 1.5rem;
514
+ height: 1.5rem;
965
515
  border-radius: 50%;
966
516
  object-fit: cover;
967
517
  border: 1.5px solid ${getBotStatusColor().color};
@@ -991,7 +541,7 @@ export function createConvaiWidget(container, options) {
991
541
  padding: 0;
992
542
  display: inline-flex;
993
543
  align-items: center;
994
- margin-left: 8px;
544
+ margin-left: 0.5rem;
995
545
  outline: none;
996
546
  transition: transform 0.1s ease-out;
997
547
  `;
@@ -1012,7 +562,7 @@ export function createConvaiWidget(container, options) {
1012
562
  voiceBadge.textContent = "VOICE";
1013
563
  voiceBadge.style.cssText = `
1014
564
  font-size: 10px;
1015
- color: var(--convai-accent, ${aeroTheme.colors.convai.light});
565
+ color: ${aeroTheme.colors.convai.light};
1016
566
  font-weight: 500;
1017
567
  margin-left: 8px;
1018
568
  padding: 2px 6px;
@@ -1077,22 +627,15 @@ export function createConvaiWidget(container, options) {
1077
627
  listeningTag.insertBefore(dot, listeningTag.firstChild);
1078
628
  titleSection.appendChild(listeningTag);
1079
629
  }
1080
- // Every call site that refreshes the header (stateChange, botReady,
1081
- // connect/disconnect via stateChange, character-info fetch, ...) also
1082
- // needs the connecting overlay re-evaluated -- it depends on the same
1083
- // `isConnected`/`isBotReady`/`characterName` inputs.
1084
- updateConnectingOverlay();
1085
- updateVoiceModeAvatar();
1086
630
  };
1087
631
  // Create message list
1088
632
  const createMessageList = () => {
1089
633
  const list = document.createElement("div");
1090
634
  list.id = "convai-message-list";
1091
- list.setAttribute("part", "messages");
1092
635
  list.style.cssText = `
1093
636
  display: flex;
1094
637
  flex-direction: column;
1095
- gap: 12px;
638
+ gap: 0.75rem;
1096
639
  min-height: 100%;
1097
640
  `;
1098
641
  return list;
@@ -1101,23 +644,22 @@ export function createConvaiWidget(container, options) {
1101
644
  const createFooter = () => {
1102
645
  const footer = document.createElement("div");
1103
646
  footer.style.cssText = `
1104
- padding: 16px;
647
+ padding: 1rem;
1105
648
  border-top: 1px solid ${aeroTheme.colors.neutral[200]};
1106
- background: var(--convai-panel-bg, white);
649
+ background: white;
1107
650
  display: flex;
1108
- gap: 8px;
651
+ gap: 0.5rem;
1109
652
  align-items: center;
1110
653
  position: relative;
1111
654
  `;
1112
655
  // Voice Mode Exit Button (Initially hidden)
1113
656
  const voiceExitButton = document.createElement("button");
1114
657
  voiceExitButton.id = "convai-voice-exit-btn";
1115
- refs.voiceExitBtn = voiceExitButton;
1116
658
  const exitIcon = Icons.PhoneOff("md");
1117
659
  voiceExitButton.appendChild(exitIcon);
1118
660
  voiceExitButton.style.cssText = `
1119
- width: 36px;
1120
- height: 36px;
661
+ width: 2.25rem;
662
+ height: 2.25rem;
1121
663
  border-radius: 50%;
1122
664
  background: ${aeroTheme.colors.error[500]};
1123
665
  color: white;
@@ -1128,31 +670,19 @@ export function createConvaiWidget(container, options) {
1128
670
  margin: 0 auto;
1129
671
  border: none;
1130
672
  `;
1131
- voiceExitButton.addEventListener("click", () => {
1132
- // Deliberately a *sync* listener. It used to be `async` with a bare
1133
- // `await client.audioControls.muteAudio()` and no try/catch: on the
1134
- // LiveKit path that call rethrows when the room has dropped, so a
1135
- // network blip mid-call plus a tap on exit leaked an unhandled
1136
- // rejection onto the host page *and* skipped the two lines below,
1137
- // stranding the user in the voice overlay with no way out.
1138
- //
1139
- // The mute is not dropped -- `applyVoiceMode()` -> `updateVoiceMode()`
1140
- // performs it (conditionally, if the mic is live) inside the exit
1141
- // branch, where a failure is now reported rather than fatal. Exiting
1142
- // the UI is unconditional either way: the overlay must never outlive
1143
- // the user's decision to leave it.
673
+ voiceExitButton.addEventListener("click", async () => {
1144
674
  client.sendInterruptMessage();
675
+ await client.audioControls.muteAudio(); // Mute on exit
1145
676
  isVoiceMode = false;
1146
- applyVoiceMode();
677
+ updateVoiceMode();
1147
678
  });
1148
679
  footer.appendChild(voiceExitButton);
1149
680
  // Standard Footer Content (Mic + Input)
1150
681
  const standardContent = document.createElement("div");
1151
682
  standardContent.id = "convai-footer-standard";
1152
- refs.footerStandard = standardContent;
1153
683
  standardContent.style.cssText = `
1154
684
  display: flex;
1155
- gap: 8px;
685
+ gap: 0.5rem;
1156
686
  align-items: center;
1157
687
  width: 100%;
1158
688
  `;
@@ -1165,16 +695,15 @@ export function createConvaiWidget(container, options) {
1165
695
  align-items: center;
1166
696
  `;
1167
697
  inputElement = document.createElement("input");
1168
- inputElement.setAttribute("part", "input");
1169
698
  inputElement.type = "text";
1170
699
  inputElement.placeholder = "Conversation";
1171
700
  inputElement.style.cssText = `
1172
701
  width: 100%;
1173
- padding: 12px 44px 12px 16px;
702
+ padding: 0.75rem 2.75rem 0.75rem 1rem;
1174
703
  border-radius: ${aeroTheme.borderRadius.full};
1175
704
  border: 1px solid ${aeroTheme.colors.neutral[300]};
1176
705
  background: ${aeroTheme.colors.glass.medium};
1177
- color: var(--convai-panel-fg, ${aeroTheme.colors.text.primary});
706
+ color: ${aeroTheme.colors.text.primary};
1178
707
  font-size: ${aeroTheme.typography.fontSize.sm};
1179
708
  transition: ${aeroTheme.transitions.fast};
1180
709
  outline: none;
@@ -1190,22 +719,16 @@ export function createConvaiWidget(container, options) {
1190
719
  // Send button
1191
720
  const sendButton = document.createElement("button");
1192
721
  sendButton.id = "convai-send-button";
1193
- // This button is dual-role -- it sends text when the input has a draft,
1194
- // and toggles voice mode when empty -- but there is no separate mic
1195
- // element, so only the send-button part applies. `mic-button` is
1196
- // reserved for a future version that splits the two; see parts.test.ts.
1197
- sendButton.setAttribute("part", "send-button");
1198
- refs.sendButton = sendButton;
1199
722
  const sendIcon = Icons.Send("md");
1200
723
  sendButton.appendChild(sendIcon);
1201
724
  sendButton.style.cssText = `
1202
725
  position: absolute;
1203
- right: 6px;
1204
- width: 36px;
1205
- height: 36px;
726
+ right: 0.375rem;
727
+ width: 2.25rem;
728
+ height: 2.25rem;
1206
729
  border-radius: 50%;
1207
730
  background: transparent; /* Initial transparent for voice toggle */
1208
- color: var(--convai-panel-fg, ${aeroTheme.colors.text.primary});
731
+ color: ${aeroTheme.colors.text.primary};
1209
732
  display: flex;
1210
733
  align-items: center;
1211
734
  justify-content: center;
@@ -1221,19 +744,9 @@ export function createConvaiWidget(container, options) {
1221
744
  client.connectionType !== "text") {
1222
745
  // Toggle Voice Mode
1223
746
  client.sendInterruptMessage();
1224
- try {
1225
- await client.audioControls.unmuteAudio(); // Unmute on enter
1226
- isVoiceMode = true;
1227
- // Awaited so this try/catch actually covers it -- bare, the promise
1228
- // escaped the block it looks like it belongs to.
1229
- await updateVoiceMode();
1230
- }
1231
- catch (error) {
1232
- // isVoiceMode is only flipped after a successful unmute, so a
1233
- // denial here leaves the widget in text mode, not stranded
1234
- // mid-transition into voice mode.
1235
- reportMicFailure(client, "Failed to enable microphone:", error);
1236
- }
747
+ await client.audioControls.unmuteAudio(); // Unmute on enter
748
+ isVoiceMode = true;
749
+ updateVoiceMode();
1237
750
  }
1238
751
  });
1239
752
  inputContainer.appendChild(inputElement);
@@ -1250,7 +763,7 @@ export function createConvaiWidget(container, options) {
1250
763
  position: absolute;
1251
764
  top: 60px;
1252
765
  right: 16px;
1253
- background: var(--convai-panel-bg, white);
766
+ background: white;
1254
767
  border-radius: ${aeroTheme.borderRadius.xl};
1255
768
  box-shadow: ${aeroTheme.shadows.xl};
1256
769
  padding: 0;
@@ -1265,7 +778,17 @@ export function createConvaiWidget(container, options) {
1265
778
  overflow: hidden;
1266
779
  `;
1267
780
  // Add animation keyframes if not present
1268
- injectKeyframes(styleTarget);
781
+ if (!document.getElementById("convai-widget-keyframes")) {
782
+ const style = document.createElement("style");
783
+ style.id = "convai-widget-keyframes";
784
+ style.textContent = `
785
+ @keyframes popIn {
786
+ from { opacity: 0; transform: scale(0.95) translateY(-10px); }
787
+ to { opacity: 1; transform: scale(1) translateY(0); }
788
+ }
789
+ `;
790
+ document.head.appendChild(style);
791
+ }
1269
792
  // Horizontal Settings Row Container
1270
793
  const settingsRow = document.createElement("div");
1271
794
  settingsRow.style.cssText = `
@@ -1294,7 +817,7 @@ export function createConvaiWidget(container, options) {
1294
817
  ? "#10b981"
1295
818
  : isDestructive
1296
819
  ? "#ef4444"
1297
- : `var(--convai-panel-fg, ${aeroTheme.colors.text.primary})`};
820
+ : aeroTheme.colors.text.primary};
1298
821
  transition: ${aeroTheme.transitions.fast};
1299
822
  min-width: 50px;
1300
823
  `;
@@ -1346,7 +869,6 @@ export function createConvaiWidget(container, options) {
1346
869
  videoIcon.style.height = "18px";
1347
870
  const videoBtn = createOption(videoIcon, "Video", handleToggleVideo, isVideoVisible);
1348
871
  videoBtn.id = "convai-settings-video-btn";
1349
- refs.settingsVideoBtn = videoBtn;
1350
872
  settingsRow.appendChild(videoBtn);
1351
873
  }
1352
874
  // Screen Share - only show if connection type is video
@@ -1359,7 +881,6 @@ export function createConvaiWidget(container, options) {
1359
881
  shareIcon.style.height = "18px";
1360
882
  const shareBtn = createOption(shareIcon, "Screen", handleToggleScreenShare, isSharing);
1361
883
  shareBtn.id = "convai-settings-share-btn";
1362
- refs.settingsShareBtn = shareBtn;
1363
884
  settingsRow.appendChild(shareBtn);
1364
885
  }
1365
886
  // Disconnect
@@ -1398,7 +919,7 @@ export function createConvaiWidget(container, options) {
1398
919
  ? "#10b981"
1399
920
  : isDestructive
1400
921
  ? "#ef4444"
1401
- : `var(--convai-panel-fg, ${aeroTheme.colors.text.primary})`};
922
+ : aeroTheme.colors.text.primary};
1402
923
  transition: ${aeroTheme.transitions.fast};
1403
924
  min-width: 50px;
1404
925
  `;
@@ -1450,7 +971,6 @@ export function createConvaiWidget(container, options) {
1450
971
  videoIcon.style.height = "18px";
1451
972
  const videoBtn = createOption(videoIcon, "Video", handleToggleVideo, isVideoVisible);
1452
973
  videoBtn.id = "convai-settings-video-btn";
1453
- refs.settingsVideoBtn = videoBtn;
1454
974
  settingsRow.appendChild(videoBtn);
1455
975
  }
1456
976
  // Screen Share - Always show if showScreenShare is true and connection type is video
@@ -1463,7 +983,6 @@ export function createConvaiWidget(container, options) {
1463
983
  shareIcon.style.height = "18px";
1464
984
  const shareBtn = createOption(shareIcon, "Screen", handleToggleScreenShare, isSharing);
1465
985
  shareBtn.id = "convai-settings-share-btn";
1466
- refs.settingsShareBtn = shareBtn;
1467
986
  settingsRow.appendChild(shareBtn);
1468
987
  }
1469
988
  // Disconnect
@@ -1478,12 +997,6 @@ export function createConvaiWidget(container, options) {
1478
997
  const createFloatingVideo = () => {
1479
998
  const container = document.createElement("div");
1480
999
  container.id = "floating-video-container";
1481
- // NOTE on z-index: deliberately NOT themable. `--convai-z-index` drives the
1482
- // widget root only. Both elements are `position: fixed` and this one is
1483
- // appended *before* the widget root, so on a tie the widget paints on top:
1484
- // a host raising the token to clear their nav bar would hide the user's own
1485
- // camera preview behind the panel. Video/screen share are also out of scope
1486
- // for the embed, which is chat-only.
1487
1000
  container.style.cssText = `
1488
1001
  position: fixed;
1489
1002
  left: 20px;
@@ -1665,18 +1178,16 @@ export function createConvaiWidget(container, options) {
1665
1178
  };
1666
1179
  // Event handlers
1667
1180
  const handleToggle = async () => {
1668
- if (isDestroyed)
1181
+ if (isDestroyed || isStartingConnection) {
1669
1182
  return;
1670
- if (isOpen || pendingOpen) {
1671
- // Already open, or an open is already in flight: nothing to do. The
1672
- // `pendingOpen` half is what keeps a repeat call from falling through
1673
- // to the `isConnecting` branch below and opening the panel *before* the
1674
- // connect it is waiting on has finished.
1183
+ }
1184
+ if (isOpen) {
1185
+ // Just close if already open
1675
1186
  return;
1676
1187
  }
1677
1188
  // Connect on first click if not already connected/connecting
1678
1189
  if (!client.state.isConnected && !client.state.isConnecting) {
1679
- pendingOpen = true;
1190
+ isStartingConnection = true;
1680
1191
  try {
1681
1192
  if (requestPublicationGrant || client.isPublishedChatSession) {
1682
1193
  if (!requestPublicationGrant) {
@@ -1706,20 +1217,6 @@ export function createConvaiWidget(container, options) {
1706
1217
  await client.disconnect();
1707
1218
  return;
1708
1219
  }
1709
- // Re-checked *after* the await: a close()/toggle()/launcher click
1710
- // during the connect clears the flag, and that cancellation has to
1711
- // win. The client stays connected either way -- only the panel is
1712
- // held back -- so a later open() takes the already-connected branch
1713
- // and opens instantly.
1714
- if (isDestroyed) {
1715
- // destroy() can also run after the grant is exchanged but before
1716
- // Core or LiveKit finishes connecting. Disconnect that late session
1717
- // instead of leaving an invisible room alive.
1718
- await client.disconnect();
1719
- return;
1720
- }
1721
- if (!pendingOpen)
1722
- return;
1723
1220
  setIsOpen(true);
1724
1221
  }
1725
1222
  catch (error) {
@@ -1728,7 +1225,7 @@ export function createConvaiWidget(container, options) {
1728
1225
  }
1729
1226
  }
1730
1227
  finally {
1731
- pendingOpen = false;
1228
+ isStartingConnection = false;
1732
1229
  }
1733
1230
  }
1734
1231
  else {
@@ -1736,29 +1233,11 @@ export function createConvaiWidget(container, options) {
1736
1233
  setIsOpen(!isOpen);
1737
1234
  }
1738
1235
  };
1739
- /** A launcher click while an open is pending means "cancel", same as `toggle()`. */
1740
- const handleLauncherClick = () => {
1741
- if (pendingOpen) {
1742
- handleClose();
1743
- return;
1744
- }
1745
- void handleToggle();
1746
- };
1747
1236
  const handleClose = () => {
1748
- // Cancels an in-flight open as well as closing an open panel; see
1749
- // `pendingOpen`. `setIsOpen(false)` on an already-closed widget just
1750
- // re-applies the collapsed styles, so this is safe in both cases.
1751
- pendingOpen = false;
1752
1237
  setIsOpen(false);
1753
1238
  };
1754
- // Published sessions must wait for Core's bot-ready handshake. A normal
1755
- // authenticated text session still gets the text-only UI, but can send as
1756
- // soon as it connects.
1757
- const requiresBotReadyForText = () => Boolean(requestPublicationGrant) || Boolean(client.isPublishedChatSession);
1758
- const isTextInputReady = () => client.state.isConnected &&
1759
- (!requiresBotReadyForText() || client.isBotReady);
1760
1239
  const handleSend = () => {
1761
- if (inputValue.trim() && isTextInputReady()) {
1240
+ if (inputValue.trim() && client.state.isConnected && client.isBotReady) {
1762
1241
  client.sendUserTextMessage(inputValue);
1763
1242
  inputValue = "";
1764
1243
  inputElement.value = "";
@@ -1789,7 +1268,7 @@ export function createConvaiWidget(container, options) {
1789
1268
  isVoiceMode = false;
1790
1269
  hasEnteredDefaultVoiceMode = false;
1791
1270
  updateHeader();
1792
- await updateVoiceMode();
1271
+ updateVoiceMode();
1793
1272
  updateSendButton();
1794
1273
  }
1795
1274
  catch (error) {
@@ -1803,11 +1282,7 @@ export function createConvaiWidget(container, options) {
1803
1282
  isVoiceMode = false;
1804
1283
  hasEnteredDefaultVoiceMode = false;
1805
1284
  updateHeader();
1806
- // Not awaited: this function has no try/catch of its own and is wired
1807
- // straight to a click, so awaiting would only move a rejection from one
1808
- // unguarded place to another. See the note on `handleDisconnect`'s own
1809
- // `await client.disconnect()` in the fix report.
1810
- applyVoiceMode();
1285
+ updateVoiceMode();
1811
1286
  updateSendButton();
1812
1287
  };
1813
1288
  const handleToggleVideo = async () => {
@@ -1846,17 +1321,10 @@ export function createConvaiWidget(container, options) {
1846
1321
  const setIsOpen = async (open) => {
1847
1322
  isOpen = open;
1848
1323
  if (open) {
1849
- morphingContainer.style.width = `${PANEL_W_PX}px`;
1850
- morphingContainer.style.height = `${PANEL_H_PX}px`;
1851
- morphingContainer.style.borderRadius = `var(--convai-radius, ${aeroTheme.borderRadius.xl})`;
1324
+ morphingContainer.style.width = "400px";
1325
+ morphingContainer.style.height = "600px";
1326
+ morphingContainer.style.borderRadius = aeroTheme.borderRadius.xl;
1852
1327
  morphingContainer.style.cursor = "default";
1853
- // The open panel is a *different* glass treatment from the launcher
1854
- // bubble, matching the React widget: near-opaque and saturated, so the
1855
- // host page does not bleed through. At the bubble's 0.8 alpha a dark
1856
- // host page shows through the panel and reads as grey.
1857
- morphingContainer.style.background =
1858
- "var(--convai-panel-bg, rgba(252, 252, 253, 0.95))";
1859
- morphingContainer.style.backdropFilter = "blur(20px) saturate(180%)";
1860
1328
  buttonContent.style.opacity = "0";
1861
1329
  buttonContent.style.transform = "scale(0.8)";
1862
1330
  buttonContent.style.pointerEvents = "none";
@@ -1874,24 +1342,19 @@ export function createConvaiWidget(container, options) {
1874
1342
  await client.audioControls.unmuteAudio();
1875
1343
  isVoiceMode = true;
1876
1344
  hasEnteredDefaultVoiceMode = true;
1877
- await updateVoiceMode();
1345
+ updateVoiceMode();
1878
1346
  }
1879
1347
  catch (error) {
1880
- reportMicFailure(client, "Failed to enter voice mode on open:", error);
1348
+ console.error("Failed to enter voice mode on open:", error);
1881
1349
  }
1882
1350
  }, 100);
1883
1351
  }
1884
1352
  }
1885
1353
  else {
1886
- morphingContainer.style.width = "var(--convai-launcher-size, 64px)";
1887
- morphingContainer.style.height = "var(--convai-launcher-size, 64px)";
1888
- // Literal, not `--convai-radius` — see the creation site: that token is
1889
- // the *panel* radius, and reusing it here would squircle the launcher.
1354
+ morphingContainer.style.width = "4rem";
1355
+ morphingContainer.style.height = "4rem";
1890
1356
  morphingContainer.style.borderRadius = "50%";
1891
1357
  morphingContainer.style.cursor = "pointer";
1892
- // Back to the launcher bubble's lighter glass — see the open branch.
1893
- morphingContainer.style.background = `var(--convai-panel-bg, ${aeroTheme.colors.glass.backdrop})`;
1894
- morphingContainer.style.backdropFilter = aeroTheme.glass.backdrop;
1895
1358
  buttonContent.style.opacity = "1";
1896
1359
  buttonContent.style.transform = "scale(1)";
1897
1360
  buttonContent.style.pointerEvents = "auto";
@@ -1905,52 +1368,35 @@ export function createConvaiWidget(container, options) {
1905
1368
  settingsTray.style.display = open ? "flex" : "none";
1906
1369
  };
1907
1370
  const updateSendButton = () => {
1908
- const sendButton = refs.sendButton;
1371
+ const sendButton = document.getElementById("convai-send-button");
1909
1372
  if (!sendButton)
1910
1373
  return;
1911
1374
  // Clear previous content
1912
1375
  sendButton.innerHTML = "";
1913
- // A published session must wait for Core's bot-ready handshake before
1914
- // accepting text. Preserve the established interactive-widget behavior
1915
- // for authenticated voice/video sessions.
1916
- const isPublishedTextChat = requiresBotReadyForText();
1917
- const textInputReady = isTextInputReady();
1918
- inputElement.disabled = isPublishedTextChat && !textInputReady;
1376
+ const canSendText = client.state.isConnected && client.isBotReady;
1377
+ inputElement.disabled = !canSendText;
1919
1378
  if (inputValue.length > 0 ||
1920
1379
  requestPublicationGrant ||
1921
1380
  client.connectionType === "text") {
1922
1381
  // Show Send Button
1923
1382
  sendButton.appendChild(Icons.Send("md"));
1924
- sendButton.style.background = `var(--convai-accent, ${aeroTheme.colors.text.primary})`;
1925
- // The label sits *on* the accent fill, so it must follow the panel
1926
- // surface colour rather than stay a hardcoded "white" -- a host setting
1927
- // a pale --convai-accent would otherwise get white-on-white. `white` is
1928
- // byte-identical to the fallback --convai-panel-bg already carries at
1929
- // its three other sites, so the default rendering is unchanged.
1930
- // Pairing requirement for hosts: --convai-accent and --convai-panel-bg
1931
- // must contrast with each other, since this is the one place they meet.
1932
- sendButton.style.color = "var(--convai-panel-bg, white)";
1383
+ sendButton.style.background = aeroTheme.colors.text.primary;
1384
+ sendButton.style.color = "white";
1933
1385
  sendButton.style.border = "none";
1934
- sendButton.disabled =
1935
- isPublishedTextChat && (inputValue.length === 0 || !textInputReady);
1936
- if (isPublishedTextChat) {
1937
- sendButton.style.opacity =
1938
- inputValue.length > 0 && textInputReady ? "1" : "0.5";
1939
- sendButton.title = textInputReady
1940
- ? inputValue.length > 0
1941
- ? "Send"
1942
- : "Type a message"
1943
- : "Waiting for character";
1944
- }
1945
- else {
1946
- sendButton.title = "Send";
1947
- }
1386
+ sendButton.disabled = inputValue.length === 0 || !canSendText;
1387
+ sendButton.style.opacity =
1388
+ inputValue.length > 0 && canSendText ? "1" : "0.5";
1389
+ sendButton.title = canSendText
1390
+ ? inputValue.length > 0
1391
+ ? "Send"
1392
+ : "Type a message"
1393
+ : "Waiting for character";
1948
1394
  }
1949
1395
  else {
1950
1396
  // Show Voice Mode Toggle
1951
1397
  sendButton.appendChild(Icons.Waveform("md"));
1952
1398
  sendButton.style.background = "transparent";
1953
- sendButton.style.color = `var(--convai-panel-fg, ${aeroTheme.colors.text.primary})`;
1399
+ sendButton.style.color = aeroTheme.colors.text.primary;
1954
1400
  sendButton.style.border = `1px solid ${aeroTheme.colors.neutral[300]}`;
1955
1401
  sendButton.style.opacity = "1";
1956
1402
  sendButton.disabled = false;
@@ -1983,8 +1429,8 @@ export function createConvaiWidget(container, options) {
1983
1429
  }
1984
1430
  };
1985
1431
  const updateVoiceMode = async () => {
1986
- const standardFooter = refs.footerStandard;
1987
- const voiceExitBtn = refs.voiceExitBtn;
1432
+ const standardFooter = document.getElementById("convai-footer-standard");
1433
+ const voiceExitBtn = document.getElementById("convai-voice-exit-btn");
1988
1434
  if (isVoiceMode) {
1989
1435
  // Show Voice Overlay
1990
1436
  if (voiceModeOverlay)
@@ -2004,26 +1450,15 @@ export function createConvaiWidget(container, options) {
2004
1450
  updateHeader();
2005
1451
  // Reset animation state for voice mode
2006
1452
  startTime = Date.now();
1453
+ currentLevels = Array(40).fill(0.05);
1454
+ targetLevels = Array(40).fill(0.05);
2007
1455
  // Start Audio Analysis
2008
1456
  startAudioAnalysis();
2009
1457
  }
2010
1458
  else {
2011
- // Ensure microphone is muted when not in voice mode.
2012
- //
2013
- // This await is the only one in this function that can reject, and
2014
- // everything below it -- hiding the overlay, restoring the standard
2015
- // footer, updating the header, stopping the analyser -- is the only
2016
- // route out of the voice UI. So a failed mute must never short-circuit
2017
- // it: report it and carry on tearing the overlay down. (LiveKit's
2018
- // setMicrophoneEnabled(false) rethrows on a dropped room, which is
2019
- // exactly when the user is most likely to be hitting exit.)
1459
+ // Ensure microphone is muted when not in voice mode
2020
1460
  if (!client.audioControls.isAudioMuted) {
2021
- try {
2022
- await client.audioControls.muteAudio();
2023
- }
2024
- catch (error) {
2025
- reportMicFailure(client, "Failed to mute microphone:", error);
2026
- }
1461
+ await client.audioControls.muteAudio();
2027
1462
  }
2028
1463
  // Hide Overlay
2029
1464
  if (voiceModeOverlay)
@@ -2055,31 +1490,9 @@ export function createConvaiWidget(container, options) {
2055
1490
  stopAudioAnalysis();
2056
1491
  }
2057
1492
  };
2058
- /**
2059
- * Fire-and-forget `updateVoiceMode()` for the call sites that are
2060
- * synchronous (DOM listeners, the constructor tail) and therefore have no
2061
- * caller to catch anything it rejects with.
2062
- *
2063
- * `updateVoiceMode()` is `async`, so a bare call there would put a floating
2064
- * promise on the host page. Its one rejecting await is now guarded
2065
- * internally, which makes this belt-and-braces -- but it is the guarantee
2066
- * that keeps a future await added inside `updateVoiceMode()` from silently
2067
- * reintroducing the unhandled rejection this replaces. Routed through
2068
- * `reportMicFailure` so a failure is observable on the client's `error`
2069
- * channel rather than console-only.
2070
- */
2071
- const applyVoiceMode = () => {
2072
- void updateVoiceMode().catch((error) => {
2073
- reportMicFailure(client, "Failed to apply voice mode:", error);
2074
- });
2075
- };
2076
1493
  // Microphone button removed - only controlled via voice mode now
2077
1494
  // Helper for Markdown - matches React MarkdownRenderer.tsx
2078
- // `media` renders inline image/video previews under a media link. Bot messages
2079
- // want them; the user's own bubble does not — you already know what you typed,
2080
- // and a preview there would let a URL a user pasted embed remote media into
2081
- // the transcript. Links stay clickable either way.
2082
- const renderMarkdown = (text, container, { media = true } = {}) => {
1495
+ const renderMarkdown = (text, container) => {
2083
1496
  if (!text)
2084
1497
  return;
2085
1498
  // Handle both actual newlines and \n escape sequences
@@ -2087,183 +1500,59 @@ export function createConvaiWidget(container, options) {
2087
1500
  const lines = normalizedText.split("\n");
2088
1501
  lines.forEach((line, lineIndex) => {
2089
1502
  // Process markdown within each line
2090
- // Bold, italic, links, images and bare URLs — see utils/inlineMarkdown.
2091
- // `cdn.example.com/a/b/c.png` -> `cdn.example.com · c.png`
2092
- const describeUrl = (url) => {
2093
- try {
2094
- const { host, pathname } = new URL(url);
2095
- const file = pathname.split("/").filter(Boolean).pop();
2096
- return file ? `${host} · ${file}` : host;
2097
- }
2098
- catch {
2099
- return url;
2100
- }
2101
- };
2102
- /**
2103
- * A preview card that reserves its space before the media arrives, so the
2104
- * bubble does not jump when an image decodes, and collapses to one muted
2105
- * line if the URL does not resolve — a character can easily invent one,
2106
- * and an empty player in the transcript reads as a broken widget.
2107
- *
2108
- * Chrome is derived from the inherited text colour: the widget is embedded
2109
- * in someone else's page, on a background this code cannot know.
2110
- */
2111
- const mediaPreview = (src, alt, media) => {
2112
- const card = document.createElement("span");
2113
- card.setAttribute("part", "message-media-card");
2114
- card.style.cssText = `
2115
- display: block;
2116
- position: relative;
2117
- margin-top: 6px;
2118
- max-width: 320px;
2119
- aspect-ratio: 16 / 10;
2120
- border-radius: 8px;
2121
- overflow: hidden;
2122
- background: color-mix(in srgb, currentColor 6%, transparent);
2123
- box-shadow: inset 0 0 0 1px color-mix(in srgb, currentColor 10%, transparent);
2124
- `;
2125
- const el = media === "image"
2126
- ? Object.assign(document.createElement("img"), { src, alt, loading: "lazy" })
2127
- : Object.assign(document.createElement("video"), { src, controls: true, preload: "metadata" });
2128
- el.setAttribute("part", "message-media");
2129
- el.style.cssText = `
2130
- display: block;
2131
- width: 100%;
2132
- height: 100%;
2133
- object-fit: ${media === "image" ? "cover" : "contain"};
2134
- border-radius: 8px;
2135
- opacity: 0;
2136
- transition: opacity .18s ease;
2137
- `;
2138
- const skeleton = document.createElement("span");
2139
- skeleton.setAttribute("part", "message-media-skeleton");
2140
- skeleton.setAttribute("aria-hidden", "true");
2141
- skeleton.dataset.convaiSkeleton = "";
2142
- skeleton.style.cssText = `
2143
- position: absolute;
2144
- inset: 0;
2145
- border-radius: 8px;
2146
- background: color-mix(in srgb, currentColor 8%, transparent);
2147
- background-image: linear-gradient(90deg, transparent, color-mix(in srgb, currentColor 12%, transparent), transparent);
2148
- background-size: 200% 100%;
2149
- animation: convai-preview-shimmer 1.4s ease-in-out infinite;
2150
- `;
2151
- const caption = document.createElement("span");
2152
- caption.setAttribute("part", "message-media-caption");
2153
- caption.textContent = describeUrl(src);
2154
- caption.style.cssText = `
2155
- display: block;
2156
- margin-top: 4px;
2157
- font-size: 11px;
2158
- opacity: .7;
2159
- white-space: nowrap;
2160
- overflow: hidden;
2161
- text-overflow: ellipsis;
2162
- `;
2163
- const settled = () => {
2164
- el.style.opacity = "1";
2165
- skeleton.remove();
2166
- };
2167
- el.addEventListener(media === "image" ? "load" : "loadedmetadata", settled);
2168
- el.addEventListener("error", () => {
2169
- const dead = document.createElement("span");
2170
- dead.setAttribute("part", "message-media-unavailable");
2171
- dead.textContent = `Preview unavailable — ${describeUrl(src)}`;
2172
- dead.style.cssText = `
2173
- display: block;
2174
- margin-top: 6px;
2175
- font-size: 11px;
2176
- opacity: .55;
2177
- font-style: italic;
2178
- white-space: nowrap;
2179
- overflow: hidden;
2180
- text-overflow: ellipsis;
2181
- `;
2182
- card.replaceWith(dead);
2183
- caption.remove();
2184
- });
2185
- card.appendChild(el);
2186
- card.appendChild(skeleton);
2187
- const frag = document.createDocumentFragment();
2188
- frag.appendChild(card);
2189
- frag.appendChild(caption);
2190
- return frag;
2191
- };
2192
1503
  const processLine = (text) => {
2193
1504
  const parts = [];
2194
- for (const node of parseInlineMarkdown(text)) {
2195
- switch (node.kind) {
2196
- case "strong": {
2197
- // matches React UserBubble styling
2198
- const strong = document.createElement("strong");
2199
- strong.textContent = node.text;
2200
- strong.style.cssText = `
2201
- font-weight: 600;
2202
- color: var(--convai-panel-fg, ${aeroTheme.colors.text.primary});
2203
- `;
2204
- parts.push(strong);
2205
- break;
2206
- }
2207
- case "em": {
2208
- const em = document.createElement("em");
2209
- em.textContent = node.text;
2210
- em.style.cssText = `font-style: italic;`;
2211
- parts.push(em);
2212
- break;
2213
- }
2214
- case "link": {
2215
- const a = document.createElement("a");
2216
- a.href = node.href;
2217
- a.textContent = node.label === node.href ? compactUrl(node.label) : node.label;
2218
- a.target = "_blank";
2219
- a.rel = "noopener noreferrer";
2220
- a.setAttribute("part", "message-link");
2221
- a.style.cssText = `
2222
- color: #10b981;
2223
- text-decoration: underline;
2224
- word-break: break-all;
2225
- `;
2226
- parts.push(a);
2227
- if (media && node.media)
2228
- parts.push(mediaPreview(node.href, node.label, node.media));
2229
- break;
1505
+ let remaining = text;
1506
+ // Process bold and italic markdown
1507
+ // Bold: **text** or __text__
1508
+ // Italic: *text* or _text_
1509
+ const markdownRegex = /(\*\*|__)(.*?)\1|\*([^*]+)\*|_([^_]+)_/g;
1510
+ let lastIndex = 0;
1511
+ let match;
1512
+ while ((match = markdownRegex.exec(remaining)) !== null) {
1513
+ // Add text before the match
1514
+ if (match.index > lastIndex) {
1515
+ const textBefore = remaining.substring(lastIndex, match.index);
1516
+ if (textBefore) {
1517
+ parts.push(document.createTextNode(textBefore));
2230
1518
  }
2231
- case "blocked": {
2232
- const blocked = document.createElement("span");
2233
- blocked.setAttribute("part", "message-link-blocked");
2234
- blocked.textContent = node.label;
2235
- blocked.title = `Link blocked: ${node.href}`;
2236
- blocked.style.cssText = `
2237
- opacity: .75;
2238
- text-decoration: underline dotted;
2239
- cursor: help;
2240
- `;
2241
- parts.push(blocked);
2242
- break;
2243
- }
2244
- case "image": {
2245
- if (media) {
2246
- parts.push(mediaPreview(node.src, node.alt, "image"));
2247
- break;
2248
- }
2249
- // With previews off, an image still has to say what it pointed at,
2250
- // so it degrades to the same anchor a plain link would render.
2251
- const imgLink = document.createElement("a");
2252
- imgLink.href = node.src;
2253
- imgLink.textContent = node.alt || node.src;
2254
- imgLink.target = "_blank";
2255
- imgLink.rel = "noopener noreferrer";
2256
- imgLink.setAttribute("part", "message-link");
2257
- imgLink.style.cssText = `
2258
- color: #10b981;
2259
- text-decoration: underline;
2260
- word-break: break-all;
2261
- `;
2262
- parts.push(imgLink);
2263
- break;
2264
- }
2265
- default:
2266
- parts.push(document.createTextNode(node.text));
1519
+ }
1520
+ // Determine if it's bold or italic
1521
+ if (match[1] && match[2]) {
1522
+ // Bold text (** or __) - matches React UserBubble styling
1523
+ const strong = document.createElement("strong");
1524
+ strong.textContent = match[2];
1525
+ strong.style.cssText = `
1526
+ font-weight: 600;
1527
+ color: ${aeroTheme.colors.text.primary};
1528
+ `;
1529
+ parts.push(strong);
1530
+ }
1531
+ else if (match[3]) {
1532
+ // Italic text (*) - matches React UserBubble styling
1533
+ const em = document.createElement("em");
1534
+ em.textContent = match[3];
1535
+ em.style.cssText = `
1536
+ font-style: italic;
1537
+ `;
1538
+ parts.push(em);
1539
+ }
1540
+ else if (match[4]) {
1541
+ // Italic text (_) - matches React UserBubble styling
1542
+ const em = document.createElement("em");
1543
+ em.textContent = match[4];
1544
+ em.style.cssText = `
1545
+ font-style: italic;
1546
+ `;
1547
+ parts.push(em);
1548
+ }
1549
+ lastIndex = match.index + match[0].length;
1550
+ }
1551
+ // Add remaining text
1552
+ if (lastIndex < remaining.length) {
1553
+ const textAfter = remaining.substring(lastIndex);
1554
+ if (textAfter) {
1555
+ parts.push(document.createTextNode(textAfter));
2267
1556
  }
2268
1557
  }
2269
1558
  return parts;
@@ -2302,7 +1591,7 @@ export function createConvaiWidget(container, options) {
2302
1591
  height: 100%;
2303
1592
  color: ${aeroTheme.colors.text.secondary};
2304
1593
  text-align: center;
2305
- padding: 32px;
1594
+ padding: 2rem;
2306
1595
  `;
2307
1596
  emptyState.innerHTML = `
2308
1597
  <p style="font-weight: 500; margin-bottom: 8px;">Start a conversation</p>
@@ -2324,7 +1613,6 @@ export function createConvaiWidget(container, options) {
2324
1613
  contain: layout style paint;
2325
1614
  `;
2326
1615
  const bubble = document.createElement("div");
2327
- bubble.setAttribute("part", msg.isUser ? "message message-user" : "message message-bot");
2328
1616
  // Add data attribute to identify user messages for debugging/styling
2329
1617
  if (msg.isUser) {
2330
1618
  bubble.setAttribute("data-message-type", "user");
@@ -2332,10 +1620,6 @@ export function createConvaiWidget(container, options) {
2332
1620
  // Set base styles first - explicitly set background to match React UserBubble exactly
2333
1621
  // CRITICAL: Use the exact same background for both user and bot messages
2334
1622
  const backgroundColor = "rgba(252, 252, 253, 0.95)";
2335
- // NOTE on border-radius: deliberately NOT themable. `--convai-radius` is
2336
- // the open panel's radius; a bubble's asymmetric tail corner is a
2337
- // different geometry (`50%` would make ellipses of these). A dedicated
2338
- // `--convai-bubble-radius` can be added additively later if wanted.
2339
1623
  bubble.style.cssText = `
2340
1624
  padding: 12px;
2341
1625
  border-radius: ${msg.isUser ? "12px 12px 4px 12px" : "12px 12px 12px 4px"};
@@ -2344,8 +1628,8 @@ export function createConvaiWidget(container, options) {
2344
1628
  backdrop-filter: blur(20px) saturate(180%);
2345
1629
  border: 1px solid rgba(0, 0, 0, 0.06);
2346
1630
  box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.8) inset, 0 2px 8px rgba(0, 0, 0, 0.08);
2347
- color: var(--convai-panel-fg, ${aeroTheme.colors.text.primary});
2348
- font-family: var(--convai-font, ${aeroTheme.typography.fontFamily.body});
1631
+ color: ${aeroTheme.colors.text.primary};
1632
+ font-family: ${aeroTheme.typography.fontFamily.body};
2349
1633
  font-size: 14px;
2350
1634
  line-height: 1.5;
2351
1635
  word-wrap: break-word;
@@ -2377,8 +1661,8 @@ export function createConvaiWidget(container, options) {
2377
1661
  // Logo/Icon - matches React LogoWrapper
2378
1662
  const logoContainer = document.createElement("div");
2379
1663
  logoContainer.style.cssText = `
2380
- width: 16px;
2381
- height: 16px;
1664
+ width: 1rem;
1665
+ height: 1rem;
2382
1666
  display: flex;
2383
1667
  align-items: center;
2384
1668
  justify-content: center;
@@ -2405,10 +1689,10 @@ export function createConvaiWidget(container, options) {
2405
1689
  const nameLabel = document.createElement("span");
2406
1690
  nameLabel.textContent = msg.sender === "User" ? "You" : msg.sender;
2407
1691
  nameLabel.style.cssText = `
2408
- font-size: 12px;
1692
+ font-size: 0.75rem;
2409
1693
  color: ${msg.isUser ? aeroTheme.colors.convai.dark : "#10b981"};
2410
1694
  font-weight: 500;
2411
- font-family: var(--convai-font, ${aeroTheme.typography.fontFamily.body});
1695
+ font-family: ${aeroTheme.typography.fontFamily.body};
2412
1696
  isolation: isolate;
2413
1697
  contain: layout style paint;
2414
1698
  `;
@@ -2422,7 +1706,7 @@ export function createConvaiWidget(container, options) {
2422
1706
  white-space: pre-wrap;
2423
1707
  word-wrap: break-word;
2424
1708
  `;
2425
- renderMarkdown(msg.text, contentDiv, { media: !msg.isUser });
1709
+ renderMarkdown(msg.text, contentDiv);
2426
1710
  bubble.appendChild(contentDiv);
2427
1711
  messageWrapper.appendChild(bubble);
2428
1712
  // Timestamp - matches React exactly
@@ -2430,11 +1714,11 @@ export function createConvaiWidget(container, options) {
2430
1714
  const timestamp = document.createElement("span");
2431
1715
  timestamp.textContent = msg.timestamp;
2432
1716
  timestamp.style.cssText = `
2433
- font-size: 12px;
1717
+ font-size: 0.75rem;
2434
1718
  color: ${aeroTheme.colors.text.secondary};
2435
1719
  margin-top: 4px;
2436
1720
  align-self: ${msg.isUser ? "flex-end" : "flex-start"};
2437
- font-family: var(--convai-font, ${aeroTheme.typography.fontFamily.body});
1721
+ font-family: ${aeroTheme.typography.fontFamily.body};
2438
1722
  `;
2439
1723
  messageWrapper.appendChild(timestamp);
2440
1724
  }
@@ -2478,8 +1762,6 @@ export function createConvaiWidget(container, options) {
2478
1762
  // Setup event listeners for client state changes
2479
1763
  const setupClientListeners = () => {
2480
1764
  eventSubscriptions.register((listener) => client.on("stateChange", listener), () => {
2481
- // Published chat (connectionType "text") has no microphone: never let
2482
- // a voice-mode transition leave the panel in a mode it cannot honour.
2483
1765
  if (client.connectionType === "text") {
2484
1766
  if (isVoiceMode) {
2485
1767
  isVoiceMode = false;
@@ -2507,6 +1789,8 @@ export function createConvaiWidget(container, options) {
2507
1789
  // Reset when not speaking
2508
1790
  if (startTime && !client.state.isSpeaking) {
2509
1791
  startTime = 0;
1792
+ currentLevels = Array(40).fill(0.05);
1793
+ targetLevels = Array(40).fill(0.05);
2510
1794
  }
2511
1795
  }
2512
1796
  // Auto-collapse when disconnected
@@ -2519,14 +1803,12 @@ export function createConvaiWidget(container, options) {
2519
1803
  isVideoVisible = false;
2520
1804
  hasEnteredDefaultVoiceMode = false;
2521
1805
  updateHeader();
2522
- // Synchronous listener: nothing here can catch a rejection, and the
2523
- // mute inside is the one most likely to fail (the room has just
2524
- // dropped -- that is why we are collapsing).
2525
- applyVoiceMode();
1806
+ updateVoiceMode();
2526
1807
  }
2527
1808
  // Auto-enter voice mode on first connection if defaultVoiceMode is true
2528
1809
  if (client.state.isConnected &&
2529
1810
  defaultVoiceMode &&
1811
+ client.connectionType !== "text" &&
2530
1812
  isOpen &&
2531
1813
  !hasEnteredDefaultVoiceMode &&
2532
1814
  !isVoiceMode) {
@@ -2535,10 +1817,10 @@ export function createConvaiWidget(container, options) {
2535
1817
  await client.audioControls.unmuteAudio();
2536
1818
  isVoiceMode = true;
2537
1819
  hasEnteredDefaultVoiceMode = true;
2538
- await updateVoiceMode();
1820
+ updateVoiceMode();
2539
1821
  }
2540
1822
  catch (error) {
2541
- reportMicFailure(client, "Failed to enter voice mode:", error);
1823
+ console.error("Failed to enter voice mode:", error);
2542
1824
  }
2543
1825
  }, 100);
2544
1826
  }
@@ -2548,9 +1830,9 @@ export function createConvaiWidget(container, options) {
2548
1830
  eventSubscriptions.register((listener) => client.videoControls.on("videoStateChange", listener), (videoState) => {
2549
1831
  if (videoState.isVideoEnabled !== undefined) {
2550
1832
  isVideoVisible = videoState.isVideoEnabled;
2551
- applyVoiceMode(); // synchronous listener; see applyVoiceMode()
1833
+ updateVoiceMode();
2552
1834
  // Update tray button state
2553
- const videoBtn = refs.settingsVideoBtn;
1835
+ const videoBtn = document.getElementById("convai-settings-video-btn");
2554
1836
  if (videoBtn) {
2555
1837
  const isActive = isVideoVisible;
2556
1838
  videoBtn.style.backgroundColor = isActive
@@ -2558,7 +1840,7 @@ export function createConvaiWidget(container, options) {
2558
1840
  : "transparent";
2559
1841
  videoBtn.style.color = isActive
2560
1842
  ? "#10b981"
2561
- : `var(--convai-panel-fg, ${aeroTheme.colors.text.primary})`;
1843
+ : aeroTheme.colors.text.primary;
2562
1844
  // Update Icon - first child is icon container
2563
1845
  const iconContainer = videoBtn.querySelector("div");
2564
1846
  if (iconContainer) {
@@ -2572,10 +1854,10 @@ export function createConvaiWidget(container, options) {
2572
1854
  }
2573
1855
  });
2574
1856
  // Screen share state change listener
2575
- client.screenShareControls.on("screenShareStateChange", (screenShareState) => {
1857
+ eventSubscriptions.register((listener) => client.screenShareControls.on("screenShareStateChange", listener), (screenShareState) => {
2576
1858
  if (screenShareState.isScreenShareActive !== undefined) {
2577
1859
  const isSharing = screenShareState.isScreenShareActive;
2578
- const shareBtn = refs.settingsShareBtn;
1860
+ const shareBtn = document.getElementById("convai-settings-share-btn");
2579
1861
  if (shareBtn) {
2580
1862
  const isActive = isSharing;
2581
1863
  shareBtn.style.backgroundColor = isActive
@@ -2583,7 +1865,7 @@ export function createConvaiWidget(container, options) {
2583
1865
  : "transparent";
2584
1866
  shareBtn.style.color = isActive
2585
1867
  ? "#10b981"
2586
- : `var(--convai-panel-fg, ${aeroTheme.colors.text.primary})`;
1868
+ : aeroTheme.colors.text.primary;
2587
1869
  // Update Icon - first child is icon container
2588
1870
  const iconContainer = shareBtn.querySelector("div");
2589
1871
  if (iconContainer) {
@@ -2600,6 +1882,11 @@ export function createConvaiWidget(container, options) {
2600
1882
  });
2601
1883
  eventSubscriptions.register((listener) => client.on("messagesChange", listener), () => {
2602
1884
  updateMessageList();
1885
+ // Call onMessage callback if provided
1886
+ if (onMessage && client.chatMessages.length > 0) {
1887
+ const lastMessage = client.chatMessages[client.chatMessages.length - 1];
1888
+ onMessage(lastMessage);
1889
+ }
2603
1890
  });
2604
1891
  // Bot ready listener - updates UI when bot becomes ready
2605
1892
  eventSubscriptions.register((listener) => client.on("botReady", listener), () => {
@@ -2609,13 +1896,8 @@ export function createConvaiWidget(container, options) {
2609
1896
  eventSubscriptions.register((listener) => client.on("connect", listener), () => {
2610
1897
  hasConnectedBefore = true;
2611
1898
  // Initialize audio renderer on connection
2612
- // NOTE: the audio elements live under rootElement, so playback ends if
2613
- // the host *removes or re-parents* its mount container mid-call. Merely
2614
- // hiding it (display: none / visibility: hidden) does NOT stop
2615
- // HTMLMediaElement playback in any browser — an earlier version of this
2616
- // comment claimed otherwise and was wrong.
2617
1899
  if (client.room && !audioRenderer) {
2618
- audioRenderer = new AudioRenderer(client.room, { container: rootElement });
1900
+ audioRenderer = new AudioRenderer(client.room);
2619
1901
  }
2620
1902
  // Fetch character info
2621
1903
  fetchCharacterInfo();
@@ -2636,127 +1918,25 @@ export function createConvaiWidget(container, options) {
2636
1918
  onDisconnect();
2637
1919
  }
2638
1920
  });
2639
- // `message` carries the row actually updated. That can differ from the
2640
- // list tail when correlated v2 streams interleave, which is why onMessage
2641
- // no longer reads the last element of chatMessages.
2642
- eventSubscriptions.register((listener) => client.on("message", listener), (message) => {
2643
- const row = message;
2644
- if (onMessage && row) {
2645
- onMessage(row);
2646
- }
2647
- });
2648
- // Screen share state change listener
2649
- eventSubscriptions.register((listener) => client.screenShareControls.on("screenShareStateChange", listener), (screenShareState) => {
2650
- if (screenShareState.isScreenShareActive !== undefined) {
2651
- const isSharing = screenShareState.isScreenShareActive;
2652
- const shareBtn = document.getElementById("convai-settings-share-btn");
2653
- if (shareBtn) {
2654
- const isActive = isSharing;
2655
- shareBtn.style.backgroundColor = isActive
2656
- ? "rgba(16, 185, 129, 0.15)"
2657
- : "transparent";
2658
- shareBtn.style.color = isActive
2659
- ? "#10b981"
2660
- : aeroTheme.colors.text.primary;
2661
- // Update Icon - first child is icon container
2662
- const iconContainer = shareBtn.querySelector("div");
2663
- if (iconContainer) {
2664
- iconContainer.innerHTML = "";
2665
- const icon = isActive
2666
- ? Icons.StopScreenShare("md")
2667
- : Icons.ScreenShare("md");
2668
- icon.style.width = "18px";
2669
- icon.style.height = "18px";
2670
- iconContainer.appendChild(icon);
2671
- }
2672
- }
2673
- }
2674
- });
2675
1921
  };
2676
1922
  // Initialize
2677
1923
  createDOM();
2678
1924
  setupClientListeners();
2679
- // Paint the connecting overlay's initial state. updateHeader() (which also
2680
- // calls updateConnectingOverlay()) is otherwise only reached via a client
2681
- // event or a successful character-info fetch, so a widget constructed
2682
- // already connected-but-not-ready would show nothing until one of those
2683
- // fires without this.
2684
- updateConnectingOverlay();
2685
1925
  // Set initial button state
2686
1926
  updateSendButton(); // Show voice mode button initially since input is empty
2687
1927
  // Set initial voice mode UI if defaultVoiceMode is true
2688
1928
  if (defaultVoiceMode) {
2689
- applyVoiceMode(); // synchronous constructor tail; see applyVoiceMode()
1929
+ updateVoiceMode();
2690
1930
  }
2691
1931
  // If already connected, initialize audio renderer and fetch character info
2692
- // NOTE: same lifetime caveat as the "connect" handler above — the audio
2693
- // elements are torn down with rootElement if the host detaches it.
2694
1932
  if (client.state.isConnected && client.room) {
2695
- audioRenderer = new AudioRenderer(client.room, { container: rootElement });
1933
+ audioRenderer = new AudioRenderer(client.room);
2696
1934
  fetchCharacterInfo();
2697
1935
  }
2698
1936
  // Return widget instance
2699
1937
  const widget = {
2700
1938
  element: rootElement,
2701
1939
  client: client,
2702
- // `isOpen` reports *requested* state, which on a warm client is the same
2703
- // thing as rendered state: `setIsOpen()` runs synchronously. The one case
2704
- // where they differ is the first open on a disconnected client, where the
2705
- // panel is committed only after `connect()` resolves; reporting `false`
2706
- // for those seconds would tell an embed reconciling its own
2707
- // convai-open/convai-close events that a widget it just opened is closed.
2708
- // `pendingOpen` is cleared by every cancellation path, so this never
2709
- // reports open for a request that was called off.
2710
- get isOpen() {
2711
- return isOpen || pendingOpen;
2712
- },
2713
- // Drives the exact same path the launcher click does: `handleToggle`
2714
- // (connect-on-first-open, then `setIsOpen(true)`). Idempotent against an
2715
- // in-flight open as well as an open panel, so repeat calls never start a
2716
- // second connect.
2717
- open: () => {
2718
- if (isOpen || pendingOpen)
2719
- return;
2720
- void handleToggle();
2721
- },
2722
- // Drives the same path the header's close button does: `handleClose` ->
2723
- // `setIsOpen(false)`. Also cancels an open that is still waiting on
2724
- // `connect()`; a redundant `close()` is a no-op.
2725
- close: () => {
2726
- if (!isOpen && !pendingOpen)
2727
- return;
2728
- handleClose();
2729
- },
2730
- toggle: () => {
2731
- if (isOpen || pendingOpen) {
2732
- handleClose();
2733
- }
2734
- else {
2735
- void handleToggle();
2736
- }
2737
- },
2738
- // Pushes a synthetic ChatMessage straight onto `client.chatMessages` and
2739
- // re-renders via the widget's existing `updateMessageList()`. It never
2740
- // calls a client send method (e.g. `sendUserTextMessage`), so the
2741
- // message never reaches the character and is never billed as an LLM
2742
- // turn. The `local-` id keeps it from colliding with a server-issued id.
2743
- appendMessage: (message) => {
2744
- // Shared core path, so React and vanilla insert identically. A
2745
- // hand-rolled IConvaiClient without it falls back to the old local push.
2746
- if (typeof client.appendMessage === "function") {
2747
- client.appendMessage(message);
2748
- }
2749
- else {
2750
- const chatMessage = {
2751
- id: `local-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`,
2752
- type: message.sender === "user" ? "user-llm-text" : "bot-llm-text",
2753
- content: message.content,
2754
- timestamp: new Date().toISOString(),
2755
- };
2756
- client.chatMessages.push(chatMessage);
2757
- }
2758
- updateMessageList();
2759
- },
2760
1940
  destroy: () => {
2761
1941
  isDestroyed = true;
2762
1942
  // Cleanup audio renderer
@@ -2765,7 +1945,7 @@ export function createConvaiWidget(container, options) {
2765
1945
  audioRenderer = null;
2766
1946
  }
2767
1947
  // Remove event listeners
2768
- morphingContainer.removeEventListener("click", handleLauncherClick);
1948
+ morphingContainer.removeEventListener("click", handleToggle);
2769
1949
  // Remove DOM elements
2770
1950
  if (rootElement.parentElement) {
2771
1951
  rootElement.remove();