1/** 2 * C2AI Voice Widget v3.2.0 3 * 4 * --------------------------------------------------------------------------- 5 * v3.2.0 (2026-08-25) â NOW ALSO THE VOICE ENGINE FOR chat-widget.js v2 6 * --------------------------------------------------------------------------- 7 * chat-widget.js v2 merged chat + voice into ONE embed, and its voice half is a 8 * full-screen overlay rather than this panel. Rather than duplicate the call 9 * engine there (token brokering, refresh, mute, teardown â all billing-adjacent), 10 * the chat widget injects THIS file with NO ?agent= key, which short-circuits the 11 * bootstrap at the bottom and leaves only window.C2AIVoice behind for it to use. 12 * 13 * So this file now has two jobs: the standalone widget it has always been, AND a 14 * library. What changed for the library role, and nothing else did: 15 * ⢠bandLevel() + the talk-state rule moved from mountWidget's closure to 16 * module scope as talkMeter(), and levelLoop() now calls it. Same thresholds, 17 * same behaviour â but there is exactly ONE definition of 18 * Listening/Thinking/Speaking across both widgets instead of two. 19 * ⢠talkMeter / bandLevel / audioContext added to the window.C2AIVoice exports. 20 * 21 * The standalone widget's own UI, gate, transcript and call flow are UNCHANGED. 22 * 23 * â ï¸ NOTE FOR THE PANEL UI: as of widget v2 the dashboard no longer offers a 24 * standalone voice embed â an agent ships ONE tag (see App\Agents\WidgetEmbed:: 25 * forAgent). This panel is still served for the tags already pasted on customer 26 * sites, which cannot be recalled, so it must keep working. Do not delete it. 27 * 28 * Embeddable browser voice agent (WebRTC) â the voice sibling of 29 * chat-widget.js. Embed on any site with a single script tag: 30 * 31 * <script src="https://customer2.ai/js/voice-widget.js?agent=EMBED_KEY" defer></script> 32 * 33 * The EMBED_KEY is the per-agent UUID found under "Voice Widget" in the 34 * customer dashboard. 35 * 36 * How it works: the widget fetches a short-lived Twilio Access Token from the 37 * Nexus API, then uses the self-hosted Twilio Voice SDK (twilio-voice.min.js, 38 * vendored next to this file by `npm run build:voice-widget`) to open a WebRTC 39 * call straight to Twilio. Audio never flows through Nexus â only the token does. 40 * 41 * Also exposes window.C2AIVoice.startCall({...}) so the authenticated dashboard 42 * "test your agent" page can reuse the exact same call logic with a different 43 * token source. 44 * 45 * --------------------------------------------------------------------------- 46 * v3.1.0 (placement) â WHAT CHANGED AND WHY 47 * --------------------------------------------------------------------------- 48 * The launcher had exactly one possible position, hard-coded into .c2ai-root. 49 * It now reads an optional `data-position` off its own <script> tag: five fixed 50 * corners plus `inline`, which mounts it where the tag sits so it can live in a 51 * nav or a hero like an ordinary button. See the POSITION block below. 52 * 53 * â ï¸ ABSENT MUST BEHAVE EXACTLY LIKE bottom-right, byte for byte â this file is 54 * pasted on customer sites and no copy of it can ever be recalled. Every code 55 * path added for this is gated so the default emits no extra CSS and takes the 56 * original mount. 57 * 58 * --------------------------------------------------------------------------- 59 * v3.0.0 (light design pass) â WHAT CHANGED AND WHY 60 * --------------------------------------------------------------------------- 61 * v2's panel was a 320px dark-glass card. v3 ports the approved Claude Design 62 * mock ("Voice Widget.dc.html"): a LIGHT 499px panel, a launcher that pairs a 63 * white status pill with a haloed orb, a large 126px orb stage with a 64 * live status line, a conversation log, and a composer row. 65 * 66 * Deltas from the mock, and why (each is deliberate â do not "fix" silently): 67 * 68 * a. MATERIAL SYMBOLS â INLINE SVG. The mock draws its chevron and end-call 69 * glyphs with the Material Symbols webfont. We do not inject webfonts onto 70 * someone else's page (extra third-party request + a CSP hazard on strict
71 * embedding sites), so the same two glyphs are inline SVG paths. Visually 72 * identical, zero network cost. 73 * 74 * b. THE MOCK HAS NO IDENTITY GATE â WE KEEP IT. A WebRTC leg carries no phone 75 * number, so the pre-call email/phone form is the ONLY thing that keys the 76 * call into cross-channel caller memory (App\Memory\ChannelMemory). Dropping 77 * it would silently make every browser call anonymous. It is restyled to the 78 * light theme and otherwise unchanged. 79 * 80 * c. THE MOCK HAS NO MUTE â WE KEEP IT, IN THE HEADER. Removing mute from a live 81 * microphone session is a functional regression, not a style choice. It sits 82 * beside the minimize chevron so the mock's composer row is untouched. 83 * 84 * d. THE CONVERSATION LOG IS REAL-DATA-ONLY. See the "TRANSCRIPT" note below. 85 * 86 * Four things worth knowing before editing: 87 * 88 * 1. EVERYTHING RENDERS IN A SHADOW ROOT. v1 injected into document.body with 89 * inline styles and #c2ai-* ids. On a third-party site that is a coin flip â 90 * the host's own `button {}` / `div {}` rules bleed straight in. All markup 91 * now lives under one shadow host, so the host page's CSS cannot reach it and 92 * ours cannot leak out. Corollary: no rule in this file can be overridden 93 * from the embedding site, which is intentional. 94 * 95 * 2. THE ORB IS THE SHARED COMPONENT, NOT A COPY. three.js + agent-orb.js are 96 * lazy-loaded from THIS script's origin on first panel open (~590 KB, and only 97 * for a visitor who has already committed to a phone call). The orb is fed the 98 * call's REMOTE audio stream via setAudioAnalyser(), so it animates when the 99 * AGENT speaks; the visitor's own mic level drives the ring on the mute 100 * button. agent-orb.js self-degrades to a CSS gradient with no WebGL, â¤2 101 * cores, or prefers-reduced-motion â that path is expected here. 102 * 103 * 3. THE CALL ENGINE IS UNCHANGED. startCall() below is v1's logic plus one 104 * additive `onCall` callback (the UI needs the Twilio Call object to reach the 105 * media streams). Token brokering, refresh, and teardown behaviour are 106 * byte-for-byte the same â a design pass had no business altering billing- 107 * adjacent code. 108 * 109 * 4. TRANSCRIPT: THE UI IS BUILT, THE FEED IS NOT WIRED YET. The mock shows a 110 * live conversation log and a "you can text instead" composer. Nexus cannot 111 * currently supply either on a browser leg: the widget's whole public API is 112 * /config + /token (VoiceWidgetController), the Twilio Voice SDK carries 113 * AUDIO ONLY, and the turn text lives in the separate voiceserver repo which 114 * never talks to this page. So NOTHING here fabricates conversation content. 115 * 116 * What is wired instead, so the design lights up the moment a feed exists: 117 * ⢠pushMessage() renders a turn into the log; the log region stays hidden 118 * (panel collapses to orb + composer) while there are zero turns. 119 * ⢠If a future SDK/voiceserver emits Twilio call-message events, the 120 * feature-detected call.on('messageReceived') bridge below renders them 121 * with no further change here. 122 * ⢠The text composer is gated on cfg.text_input_enabled (absent today => 123 * off) plus a feature-detected call.sendMessage. When off, the composer 124 * row shows the end-call button alone. 125 * ⢠DEMO MODE ONLY ("Preview with your mic") seeds a sample conversation so 126 * the full design can be reviewed on preview apps, which cannot mint 127 * voice tokens at all. It is labelled on screen and never runs for a 128 * visitor on a customer site. 129 */ 130(function () { 131 'use strict'; 132 133 // ------------------------------------------------------------------------- 134 // Resolve BASE_URL + agent key from this script's own src, so the widget 135 // works in preview environments without a hard-coded production URL. 136 // ------------------------------------------------------------------------- 137 var BASE_URL = 'https://customer2.ai'; // fallback 138 var agentKey = null; 139 var apiOrigin = null; // optional override â see resolveApiOrigin() below 140 var hostScript = null; // the <script> tag that carried the key â see POSITION 141 var position = 'bottom-right'; 142 143 // ------------------------------------------------------------------------- 144 // POSITION 145 // 146 // Read off `data-position` on the SAME <script> tag that carried the embed 147 // key. Five fixed corners plus `inline`, which mounts the launcher where the
148 // tag itself sits so it can live in a nav or a hero like an ordinary button. 149 // 150 // â ï¸ ABSENT MUST BEHAVE EXACTLY LIKE 'bottom-right'. This file is pasted on 151 // customer sites and no copy of it can ever be recalled, so every tag already 152 // out there â all of which predate this attribute â has to keep rendering 153 // where it renders today. positionCss() therefore returns NOTHING for the 154 // default, leaving the original .c2ai-root rule untouched. 155 // 156 // Keep POSITIONS in sync with App\Agents\WidgetEmbed::POSITIONS. 157 // ------------------------------------------------------------------------- 158 // OP #2210 â customer2ai.com is being retired as a public domain (it becomes 159 // staging). Embeds pasted with the old <script src> must keep working, so a 160 // legacy origin is mapped to customer2.ai for EVERY API call and asset, and 161 // LEGACY_LOADER tags the first /config request (?loader=legacy) so we can 162 // see which sites still need their embed code updated. 163 var LEGACY_HOSTS = { 'customer2ai.com': 1, 'www.customer2ai.com': 1 }; 164 var LEGACY_LOADER = false; 165 // Never remap when the PAGE itself is on a legacy host: that is staging 166 // (or the office on the old domain), whose widget must keep calling itself 167 // â never production. 168 var ON_LEGACY_PAGE = !!LEGACY_HOSTS[(location.hostname || '').toLowerCase()]; 169 function canonicalOrigin(o) { 170 if (!o || ON_LEGACY_PAGE) { return o; } 171 var h = o.replace(/^https?:\/\//i, '').replace(/[\/:].*$/, '').toLowerCase(); 172 if (LEGACY_HOSTS[h]) { LEGACY_LOADER = true; return 'https://customer2.ai'; } 173 return o; 174 } 175 var POSITIONS = ['bottom-right', 'bottom-left', 'bottom-center', 'top-right', 'top-left', 'inline']; 176 177 (function resolveFromScript() { 178 var scripts = document.querySelectorAll('script[src*="voice-widget.js"]'); 179 for (var i = 0; i < scripts.length; i++) { 180 var src = scripts[i].getAttribute('src') || ''; 181 var origin = src.match(/^(https?:\/\/[^\/]+)/i); 182 if (origin) { 183 BASE_URL = canonicalOrigin(origin[1]); 184 } 185 var key = src.match(/[?&]agent=([0-9a-f\-]{36})/i); 186 if (key) { 187 agentKey = key[1]; 188 } 189 if (agentKey) { 190 // Only honour an override from the SAME tag that carried the key. 191 hostScript = scripts[i]; 192 apiOrigin = canonicalOrigin(resolveApiOrigin(scripts[i])); 193 position = resolvePosition(scripts[i]); 194 break; 195 } 196 } 197 })(); 198 199 function resolvePosition(tag) { 200 var raw = (tag.getAttribute('data-position') || '').trim().toLowerCase(); 201 202 if (raw === '') { 203 return 'bottom-right'; 204 } 205 206 if (POSITIONS.indexOf(raw) === -1) { 207 console.warn('[C2AI voice] unknown data-position "' + raw + '". Using bottom-right.'); 208 209 return 'bottom-right'; 210 } 211 212 // Inline needs somewhere in the body to sit. A tag in <head> has no rendered 213 // position of its own, so honouring "inline" there would mount an invisible 214 // launcher â fall back to the corner and say why. 215 if (raw === 'inline' && ! isInlineMountable(tag)) { 216 console.warn('[C2AI voice] data-position="inline" needs the <script> tag to sit in the <body> where you want the widget. Using bottom-right.'); 217 218 return 'bottom-right'; 219 } 220 221 return raw; 222 } 223 224 function isInlineMountable(tag) { 225 if (! tag || ! tag.parentNode) { 226 return false; 227 } 228 229 for (var node = tag.parentNode; node; node = node.parentNode) { 230 if (node.nodeName === 'HEAD') { 231 return false; 232 } 233 if (node.nodeName === 'BODY') { 234 return true; 235 } 236 } 237 238 return false; 239 } 240 241 /** 242 * Overrides that move the launcher off its default corner. Appended to the end 243 * of styleSheet(), so equal-specificity rules land after the base and win â 244 * including after its `@media (max-width: 560px)` block, which is why every 245 * non-default position restates its own mobile rules. 246 * 247 * â ï¸ EMPTY FOR 'bottom-right' ON PURPOSE â see the POSITION note above. 248 * 249 * Unlike the chat widget there is no separate panel anchor to mirror: the voice 250 * panel is a normal-flow child of .c2ai-root that replaces the hidden launcher, 251 * so moving the root moves both. `inline` is the one exception, where the root 252 * leaves the viewport coordinate system and the panel has to re-enter it. 253 */
254 function positionCss(pos) { 255 if (pos === 'bottom-right') { 256 return ''; 257 } 258 259 // INLINE: launcher in the page flow, panel still bottom-right (Steven, 260 // 2026-08-19) â a 499px call panel unfolding out of a nav wrecks the header 261 // it was put in. The panel needs its own z-index because a static root 262 // creates no stacking context. 263 if (pos === 'inline') { 264 return [ 265 '.c2ai-root { position: static; display: inline-flex; vertical-align: middle; top: auto; right: auto; bottom: auto; left: auto; }', 266 '.c2ai-panel { position: fixed; z-index: 2147483000; bottom: 26px; right: 26px; top: auto; left: auto; }', 267 '@media (max-width: 560px) {', 268 ' .c2ai-root { top: auto; right: auto; bottom: auto; left: auto; }', 269 // The base mobile block stretches the launcher to `width: 100%` and 270 // pins it right, which is correct for a corner widget filling the 271 // screen edge and wrong for one sitting in a nav cell. 272 ' .c2ai-launch { justify-content: flex-start; width: auto; }', 273 ' .c2ai-panel { left: 16px; right: 16px; bottom: 16px; width: auto; max-width: none; }', 274 '}', 275 ].join('\n'); 276 } 277 278 var LAYOUTS = { 279 'bottom-left': { 280 root: 'bottom: 26px; left: 26px; top: auto; right: auto;', 281 rootMobile: 'left: 16px; right: 16px; bottom: 16px; top: auto;', 282 align: 'flex-start', 283 }, 284 'bottom-center': { 285 root: 'bottom: 26px; left: 50%; top: auto; right: auto; transform: translateX(-50%);', 286 rootMobile: 'left: 16px; right: 16px; bottom: 16px; top: auto; transform: none;', 287 align: 'center', 288 }, 289 'top-right': { 290 root: 'top: 26px; right: 26px; bottom: auto; left: auto;', 291 rootMobile: 'left: 16px; right: 16px; top: 16px; bottom: auto;', 292 align: 'flex-end', 293 }, 294 'top-left': { 295 root: 'top: 26px; left: 26px; bottom: auto; right: auto;', 296 rootMobile: 'left: 16px; right: 16px; top: 16px; bottom: auto;', 297 align: 'flex-start', 298 }, 299 }; 300 301 // hasOwnProperty, not a bare lookup: `LAYOUTS['constructor']` is truthy and 302 // would emit `.c2ai-root { undefined }`. Unreachable while pos is whitelist- 303 // validated upstream, but this function is one refactor away from not being. 304 if (! Object.prototype.hasOwnProperty.call(LAYOUTS, pos)) { 305 return ''; 306 } 307 308 var layout = LAYOUTS[pos]; 309 310 return [ 311 '.c2ai-root { ' + layout.root + ' }', 312 // The root is shrink-to-fit off whichever edge it is pinned to, so the 313 // launcher and the much wider panel would otherwise disagree about which 314 // edge they share once the root is no longer right-anchored. 315 '.c2ai-root { display: flex; flex-direction: column; align-items: ' + layout.align + '; }', 316 '@media (max-width: 560px) {', 317 ' .c2ai-root { ' + layout.rootMobile + ' }', 318 // The base mobile block pins the launcher to the right edge of a 319 // now-full-width root. Left/centre positions have to undo that or they 320 // silently snap back to the right corner on a phone. 321 ' .c2ai-launch { justify-content: ' + layout.align + '; }', 322 '}', 323 ].join('\n'); 324 } 325 326 /** 327 * Optional `data-api-origin` override: serve the widget's JS from one origin 328 * while calling /config + /token on another. 329 * 330 * WHY IT EXISTS: the voice relay resolves an agent's brain by calling back 331 * into the PRODUCTION app, so a per-branch preview app can serve this file 332 * but cannot broker a working call on its own. This attribute lets a preview 333 * (or a customer serving our JS from their own CDN) point the API at the real 334 * origin, which is the only way to exercise the true customer path â real 335 * token, real Twilio, real agent â while iterating on the UI. 336 * 337 * SECURITY: only an absolute https:// origin is accepted, and it is only read 338 * from the same <script> tag that carries the embed key â i.e. it is set by 339 * whoever already controls the page and could replace this script outright, 340 * so it grants no privilege they did not have. It is logged on use so it can 341 * never be quietly in effect. No credentials are sent to it 342 * (`credentials: 'same-origin'` on the token fetch, and the widget endpoints 343 * reply `Access-Control-Allow-Credentials: false`). 344 */ 345 function resolveApiOrigin(tag) { 346 var raw = (tag.getAttribute('data-api-origin') || '').trim(); 347 if (raw === '') { 348 return null; 349 } 350 try { 351 var u = new URL(raw); 352 if (u.protocol !== 'https:') { 353 console.warn('[C2AI voice] data-api-origin must be https â ignoring.'); 354 return null; 355 } 356 console.info('[C2AI voice] API origin overridden to ' + u.origin); 357 return u.origin; 358 } catch (e) { 359 console.warn('[C2AI voice] data-api-origin is not a valid URL â ignoring.'); 360 return null; 361 } 362 } 363 364 var SDK_URL = BASE_URL + '/js/twilio-voice.min.js'; 365 // ?v= busts caches/CDNs still holding the pre-setAudioAnalyser agent-orb.js
366 // (a stale copy degrades silently to an idle orb â guarded, but wrong-looking). 367 // Bump it whenever the widget starts depending on a NEW agent-orb API. 368 var ORB_URL = BASE_URL + '/js/agent-orb.js?v=2'; 369 var THREE_URL = BASE_URL + '/js/vendor/three.min.js'; 370 var BRAND = '#6d28d9'; 371 372 // ------------------------------------------------------------------------- 373 // Twilio SDK loader â inject once, resolve when the global is ready. 374 // ------------------------------------------------------------------------- 375 var _sdkPromise = null; 376 function loadSdk() { 377 if (window.Twilio && window.Twilio.Device) { 378 return Promise.resolve(window.Twilio); 379 } 380 if (_sdkPromise) { 381 return _sdkPromise; 382 } 383 _sdkPromise = new Promise(function (resolve, reject) { 384 var s = document.createElement('script'); 385 s.src = SDK_URL; 386 s.async = true; 387 s.onload = function () { 388 if (window.Twilio && window.Twilio.Device) { 389 resolve(window.Twilio); 390 } else { 391 reject(new Error('Twilio Voice SDK loaded but Twilio.Device is missing.')); 392 } 393 }; 394 s.onerror = function () { reject(new Error('Failed to load Twilio Voice SDK from ' + SDK_URL)); }; 395 document.head.appendChild(s); 396 }); 397 return _sdkPromise; 398 } 399 400 /** 401 * Lazy-load three.js + agent-orb.js (in that order â agent-orb upgrades as 402 * soon as it runs and wants THREE present) from THIS script's origin. 403 * 404 * Deliberately NOT loaded up front: it is ~590 KB that a visitor who never 405 * clicks the launcher must not pay for. Resolves false when either asset 406 * fails, and the panel then simply renders without an orb rather than 407 * blocking the call â audio matters more than the animation. 408 */ 409 var _orbPromise = null; 410 function loadOrb() { 411 if (_orbPromise) { 412 return _orbPromise; 413 } 414 _orbPromise = new Promise(function (resolve) { 415 function inject(src) { 416 return new Promise(function (ok, fail) { 417 var s = document.createElement('script'); 418 s.src = src; 419 s.async = false; // preserve execution order across both tags 420 s.onload = ok; 421 s.onerror = function () { fail(new Error('failed to load ' + src)); }; 422 document.head.appendChild(s); 423 }); 424 } 425 var first = (window.THREE && window.THREE.WebGLRenderer) 426 ? Promise.resolve() 427 : inject(THREE_URL); 428 first 429 .then(function () { 430 return window.customElements && customElements.get('agent-orb') 431 ? Promise.resolve() 432 : inject(ORB_URL); 433 }) 434 .then(function () { 435 // 'load' also fires when the script downloaded but failed to 436 // PARSE (agent-orb.js is ES2015+; an old browser chokes on 437 // `class`). Only report success if the element actually 438 // registered, so the caller's fallback disc renders instead 439 // of an empty stage. 440 resolve(!!(window.customElements && customElements.get('agent-orb'))); 441 }) 442 .catch(function (e) { 443 console.warn('[C2AI voice] orb unavailable:', e.message); 444 resolve(false); 445 }); 446 }); 447 return _orbPromise; 448 } 449 450 // ------------------------------------------------------------------------- 451 // Core call logic, reusable by both the public widget and the dashboard 452 // test page. 453 // 454 // opts.tokenUrl (string) endpoint that returns { token, identity, ttl } 455 // opts.method (string) HTTP method for the token fetch (default POST) 456 // opts.headers (object) extra headers (e.g. CSRF for the dashboard)
457 // opts.body (object) JSON body for the token fetch (optional) 458 // opts.connectParams (object) Twilio custom parameters passed to 459 // device.connect (e.g. { email, phone }) â they 460 // ride the call to the voiceserver as 461 // ConversationRelay parameters. Optional. 462 // opts.onStatus (fn) status string callback: 'connecting'|'in-call'|'ended'|'error' 463 // opts.onError (fn) error callback (Error) 464 // opts.onCall (fn) optional â receives the Twilio Call object once 465 // connected, so a UI can reach its media streams. 466 // 467 // Returns a controller: { hangup(), mute(bool), isMuted() }. 468 // ------------------------------------------------------------------------- 469 function startCall(opts) { 470 var ctrl = { _call: null, _device: null, _refreshTimer: null, _muted: false, _cancelled: false }; 471 var status = opts.onStatus || function () {}; 472 var fail = opts.onError || function (e) { console.error('[C2AI voice]', e); }; 473 474 function fetchToken() { 475 return fetch(opts.tokenUrl, { 476 method: opts.method || 'POST', 477 credentials: 'same-origin', 478 headers: Object.assign({ 'Accept': 'application/json', 'Content-Type': 'application/json' }, opts.headers || {}), 479 body: opts.body ? JSON.stringify(opts.body) : (opts.method === 'GET' ? undefined : '{}'), 480 }).then(function (r) { 481 if (!r.ok) { 482 return r.json().catch(function () { return {}; }).then(function (j) { 483 throw new Error(j.error || ('Token request failed (' + r.status + ')')); 484 }); 485 } 486 return r.json(); 487 }); 488 } 489 490 status('connecting'); 491 492 Promise.all([loadSdk(), fetchToken()]).then(function (results) { 493 // Hung up while the SDK/token were still in flight? Then this call 494 // must never come up. Without this check, "Cancel" during the 495 // connecting state let the call complete invisibly â a billable 496 // Twilio call with a LIVE MICROPHONE and no UI to end it. (QA 497 // 2026-08-11; the hole existed in v1 too but v1's button at least 498 // stayed on screen.) The minted token is discarded unused â no call, 499 // no billing, no mic. 500 if (ctrl._cancelled) { status('ended'); return; } 501 502 var Twilio = results[0]; 503 var data = results[1]; 504 505 var device = new Twilio.Device(data.token, { codecPreferences: ['opus', 'pcmu'] }); 506 ctrl._device = device; 507 508 // Refresh the token ~30s before it expires so long calls don't drop. 509 // Floor the interval to 20s so a tiny TTL can't spin a hot loop, and 510 // hang up after repeated refresh failures rather than letting the 511 // call silently die at expiry with no status change. 512 if (data.ttl && data.ttl > 40) { 513 var refreshMs = Math.max(20000, (data.ttl - 30) * 1000); 514 var refreshFails = 0; 515 ctrl._refreshTimer = setInterval(function () { 516 fetchToken().then(function (fresh) { 517 refreshFails = 0; 518 device.updateToken(fresh.token); 519 }).catch(function (e) { 520 console.warn('[C2AI voice] token refresh failed', e); 521 if (++refreshFails >= 2) { status('error'); fail(e); ctrl.hangup(); } 522 }); 523 }, refreshMs); 524 } 525 526 device.on('error', function (e) { status('error'); fail(e); }); 527 528 device.connect(opts.connectParams ? { params: opts.connectParams } : undefined).then(function (call) { 529 // Second cancellation window: hangup() ran between Device 530 // creation and the connect resolving. Tear the call down 531 // immediately instead of surfacing it. 532 if (ctrl._cancelled) { 533 try { call.disconnect(); } catch (e) {} 534 cleanup(); 535 status('ended'); 536 return; 537 } 538 ctrl._call = call; 539 status('in-call'); 540 if (typeof opts.onCall === 'function') { 541 try { opts.onCall(call); } catch (e) { console.warn('[C2AI voice] onCall handler threw', e); } 542 } 543 call.on('disconnect', function () { cleanup(); status('ended'); }); 544 call.on('cancel', function () { cleanup(); status('ended'); }); 545 }).catch(function (e) { status('error'); fail(e); cleanup(); }); 546 }).catch(function (e) { status('error'); fail(e); }); 547 548 function cleanup() { 549 if (ctrl._refreshTimer) { clearInterval(ctrl._refreshTimer); ctrl._refreshTimer = null; } 550 if (ctrl._device) { try { ctrl._device.destroy(); } catch (e) {} ctrl._device = null; } 551 ctrl._call = null; 552 } 553
554 ctrl.hangup = function () { 555 // Latch first: if the connect chain is still pending, the checks 556 // above see this and abort before any call exists. 557 ctrl._cancelled = true; 558 if (ctrl._call) { try { ctrl._call.disconnect(); } catch (e) {} } 559 cleanup(); 560 }; 561 ctrl.mute = function (on) { 562 ctrl._muted = !!on; 563 if (ctrl._call) { try { ctrl._call.mute(ctrl._muted); } catch (e) {} } 564 }; 565 ctrl.isMuted = function () { return ctrl._muted; }; 566 return ctrl; 567 } 568 569 // ------------------------------------------------------------------------- 570 // Web Audio helpers â one AudioContext for the widget, analysers hung off 571 // whichever MediaStreams the live call exposes. 572 // 573 // The streams come from the Twilio Call object (getRemoteStream / 574 // getLocalStream) rather than a second getUserMedia call: the SDK already 575 // holds the mic, and asking twice would light up a second capture indicator 576 // in the browser chrome for no benefit. Both methods are feature-detected â 577 // an SDK version without them just means an idle orb, not a broken call. 578 // ------------------------------------------------------------------------- 579 var _audioCtx = null; 580 function audioContext() { 581 if (_audioCtx) { 582 return _audioCtx; 583 } 584 var Ctor = window.AudioContext || window.webkitAudioContext; 585 if (!Ctor) { 586 return null; 587 } 588 try { _audioCtx = new Ctor(); } catch (e) { return null; } 589 return _audioCtx; 590 } 591 592 function analyserFor(stream) { 593 var ctx = audioContext(); 594 if (!ctx || !stream || typeof ctx.createMediaStreamSource !== 'function') { 595 return null; 596 } 597 if (!stream.getAudioTracks || stream.getAudioTracks().length === 0) { 598 return null; 599 } 600 try { 601 var an = ctx.createAnalyser(); 602 an.fftSize = 256; // agent-orb's band ranges are tuned for this 603 ctx.createMediaStreamSource(stream).connect(an); 604 // NOT connected to ctx.destination: the Twilio SDK already plays the 605 // remote audio through its own <audio> element. Routing it here too 606 // would double it â audible echo/chorus on every call. 607 return an; 608 } catch (e) { 609 return null; 610 } 611 } 612 613 function streamFrom(call, method) { 614 if (!call || typeof call[method] !== 'function') { 615 return null; 616 } 617 try { return call[method]() || null; } catch (e) { return null; } 618 } 619 620 // ------------------------------------------------------------------------- 621 // TALK STATE â "Listening / Thinking / Speaking" 622 // 623 // Lifted to module scope + exported (2026-08-25) so chat-widget.js's v2 624 // voice overlay derives the label from the SAME rule this panel does. It was 625 // closure-scoped inside mountWidget, which would have meant a second copy of 626 // the audio-level math and the gate thresholds living in the other file â 627 // and a threshold tweak landing in one design but not the other. 628 // 629 // Pure: no closure dependencies, so moving it changed no behaviour here. 630 // ------------------------------------------------------------------------- 631 632 /** Level in the speech band, 0..1. */ 633 function bandLevel(analyser, buf) { 634 if (!analyser || !buf) { return 0; } 635 analyser.getByteFrequencyData(buf); 636 var sum = 0; 637 for (var i = 2; i <= 24 && i < buf.length; i++) { sum += buf[i]; } 638 return Math.min(sum / (23 * 255) * 2.4, 1); 639 } 640 641 /** Below this in the speech band counts as silence. */ 642 var TALK_GATE = 0.06; 643 /** How long after the visitor stops we call the quiet gap "Thinking". */ 644 var TALK_THINK_MS = 1400; 645 646 /** 647 * A stateful talk-state reader. Stateful because "Thinking" is defined by 648 * how long ago the VISITOR last spoke, which a pure function cannot know. 649 * 650 * read() returns { talk: 'listening'|'thinking'|'speaking', mic, remote }. 651 */ 652 function talkMeter() { 653 var lastVisitorAt = 0; 654 655 return { 656 read: function (micAnalyser, micData, remoteAnalyser, remoteData, muted) { 657 var micLevel = (micAnalyser && !muted) ? bandLevel(micAnalyser, micData) : 0; 658 var remoteLevel = remoteAnalyser ? bandLevel(remoteAnalyser, remoteData) : 0; 659 var talk; 660 661 // The agent talking wins; otherwise the visitor. When both are 662 // quiet just after the visitor spoke, the agent is composing a 663 // reply â that gap is what the mock calls "Thinking". 664 if (remoteLevel > TALK_GATE) {
665 talk = 'speaking'; 666 } else if (micLevel > TALK_GATE) { 667 lastVisitorAt = Date.now(); 668 talk = 'listening'; 669 } else if (lastVisitorAt && (Date.now() - lastVisitorAt) < TALK_THINK_MS) { 670 talk = 'thinking'; 671 } else { 672 talk = 'listening'; 673 } 674 675 return { talk: talk, mic: micLevel, remote: remoteLevel }; 676 }, 677 }; 678 } 679 680 // ------------------------------------------------------------------------- 681 // Panel styles. One <style> in the shadow root â see the header note on why 682 // nothing here is reachable from the embedding page. 683 // 684 // 'Inter' leads the stack because the brand uses it and many embedding sites 685 // already load it; we deliberately do NOT inject a webfont (an extra 686 // third-party request on someone else's page, and a CSP hazard). The system 687 // fallback is what most visitors will see. 688 // ------------------------------------------------------------------------- 689 function styleSheet(accent, hue) { 690 // Mock tokens, kept together so a design tweak is a one-line edit. 691 var INK = '#080C14'; // primary text 692 var MUTED = '#64748B'; // secondary text / status line 693 var HAIRLINE = 'rgba(107,114,128,0.2)'; 694 var DARK = '#0A1022'; // end-call button 695 var h = launcherHue(hue); 696 697 var sheet = [ 698 ':host { all: initial; }', 699 '*, *::before, *::after { box-sizing: border-box; }', 700 '.c2ai-root {', 701 ' position: fixed; bottom: 26px; right: 26px; z-index: 2147483000;', 702 ' font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;', 703 ' -webkit-font-smoothing: antialiased; direction: ltr;', 704 '}', 705 '@keyframes c2ai-in { from { opacity: 0; transform: translate3d(0,14px,0) scale(.985); } to { opacity: 1; transform: none; } }', 706 '@keyframes c2ai-msg-in { from { opacity: 0; transform: translate3d(0,6px,0); } to { opacity: 1; transform: none; } }', 707 '@keyframes c2ai-dots { 0%,20% { opacity: .25; } 50% { opacity: 1; } 100% { opacity: .25; } }', 708 '@keyframes c2ai-halo { 0%,100% { transform: scale(1); opacity: .55; } 50% { transform: scale(1.16); opacity: .15; } }', 709 710 /* ---- launcher: status pill + haloed orb ---- */ 711 '.c2ai-launch {', 712 ' display: flex; align-items: center; gap: 12px; cursor: pointer;', 713 ' border: none; background: none; padding: 0; margin: 0; font: inherit;', 714 ' animation: c2ai-in 260ms ease both;', 715 '}', 716 '.c2ai-launch[hidden] { display: none; }', 717 '.c2ai-pill {', 718 ' display: flex; align-items: center; gap: 9px; height: 40px; padding: 0 15px;', 719 ' border-radius: 9999px; background: #FFFFFF; border: 1px solid ' + HAIRLINE + ';', 720 ' box-shadow: 0 6px 20px -8px rgba(10,16,34,.22);', 721 ' transition: transform .18s cubic-bezier(.2,.8,.2,1), box-shadow .18s ease;', 722 '}', 723 '.c2ai-launch:hover .c2ai-pill { transform: translateY(-1px); box-shadow: 0 10px 26px -8px rgba(10,16,34,.3); }', 724 '.c2ai-launch:focus-visible .c2ai-pill { outline: 2px solid ' + accent + '; outline-offset: 3px; }', 725 '.c2ai-dot { width: 7px; height: 7px; border-radius: 9999px; background: #22C55E; flex-shrink: 0; }', 726 '.c2ai-pill-label { font: 500 13px/1.2 inherit; color: ' + INK + '; white-space: nowrap; }', 727 '.c2ai-launch-orb {', 728 ' position: relative; width: 64px; height: 64px; flex-shrink: 0;', 729 ' display: flex; align-items: center; justify-content: center;', 730 '}', 731 /* Launcher orb is a CSS disc, NOT <agent-orb>: the WebGL bundle is 732 ~590 KB and must stay behind the click (see loadOrb). At 60px the 733 plasma detail is invisible anyway, so this reads identically. 734 735 â ï¸ IT MUST BE agent-orb's OWN GRADIENT + hue-rotate, NOT a 736 hand-rolled hsl() ramp. `orb_hue` is an OFFSET, not an absolute 737 hue: agent-orb applies it as `hue-rotate(<orb_hue>)` over a fixed 738 TEAL base (#6ee7d7/#22b8a6/#0D9488, ~172deg). The previous disc fed 739 the same number into `hsl(<orb_hue> ...)` as if it were absolute, so 740 the launcher landed ~172deg away from the orb it sits next to â the 741 launcher read blue while the call panel's orb was violet. Reusing 742 the component's exact stops and filter makes the two agree BY
743 CONSTRUCTION, so a future change to the orb's palette cannot drift 744 them apart again. (Steven, 2026-08-14.) */ 745 '.c2ai-launch-disc {', 746 ' position: relative; width: 60px; height: 60px; border-radius: 9999px;', 747 ' background: radial-gradient(circle at 42% 38%,', 748 ' #6ee7d7 0%, #22b8a6 30%, #0D9488 52%,', 749 ' rgba(6,182,212,0.35) 74%, transparent 92%);', 750 ' filter: hue-rotate(' + h + 'deg);', 751 '}', 752 753 /* ---- shared halo ---- */ 754 '.c2ai-halo {', 755 ' position: absolute; inset: 0; border-radius: 9999px; pointer-events: none;', 756 ' background: radial-gradient(circle, rgba(124,58,237,.35), rgba(6,182,212,.1) 70%);', 757 ' animation: c2ai-halo 2.6s ease-in-out infinite;', 758 '}', 759 '.c2ai-halo.is-lg {', 760 ' inset: -6px; animation-duration: 3s;', 761 ' background: radial-gradient(circle, rgba(124,58,237,.22), rgba(6,182,212,.06) 72%);', 762 '}', 763 764 /* ---- panel ---- */ 765 '.c2ai-panel {', 766 ' width: 499px; max-width: calc(100vw - 40px);', 767 ' display: flex; flex-direction: column;', 768 ' border-radius: 18px; background: #FFFFFF; color: ' + INK + ';', 769 ' border: 1px solid rgba(107,114,128,0.16);', 770 ' box-shadow: 0 26px 64px -22px rgba(10,16,34,.34);', 771 ' overflow: hidden; animation: c2ai-in 240ms ease both;', 772 '}', 773 '.c2ai-panel[hidden] { display: none; }', 774 775 /* ---- header: minimize + mute (left), brand (right) ---- */ 776 '.c2ai-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px 0; }', 777 '.c2ai-head-actions { display: flex; align-items: center; gap: 2px; }', 778 '.c2ai-icon-btn {', 779 ' position: relative; width: 26px; height: 26px; flex-shrink: 0;', 780 ' display: inline-flex; align-items: center; justify-content: center;', 781 ' border: none; background: none; padding: 0; border-radius: 9999px;', 782 ' cursor: pointer; color: #9CA3AF; transition: background .15s ease, color .15s ease;', 783 '}', 784 // MUST come with the display rule above: a UA `[hidden]{display:none}` 785 // loses to our `display:inline-flex`, so muteBtn.hidden would render 786 // a dead mic button on the gate/ended screens. 787 '.c2ai-icon-btn[hidden] { display: none; }', 788 '.c2ai-icon-btn:hover { background: #F4F5F7; color: ' + INK + '; }', 789 '.c2ai-icon-btn:focus-visible { outline: 2px solid ' + accent + '; outline-offset: 2px; }', 790 '.c2ai-icon-btn svg { width: 18px; height: 18px; display: block; fill: currentColor; }', 791 '.c2ai-icon-btn.is-muted { background: #FEF3C7; color: #B45309; }', 792 '.c2ai-brand {', 793 ' display: inline-flex; align-items: center; gap: 5px;', 794 ' font: 500 9.5px/1 inherit; letter-spacing: .06em; text-transform: uppercase; color: #B6BCC6;', 795 '}', 796 '.c2ai-brand img { height: 9px; width: auto; object-fit: contain; filter: invert(1); opacity: .42; }', 797 798 /* ---- stage: orb + status line ---- */ 799 '.c2ai-stage { display: flex; flex-direction: column; align-items: center; gap: 14px; padding: 16px 20px 18px; }', 800 '.c2ai-orb-wrap { position: relative; width: 132px; height: 132px; display: flex; align-items: center; justify-content: center; }', 801 '.c2ai-orb-clip {', 802 ' position: relative; width: 126px; height: 126px; border-radius: 9999px;', 803 ' overflow: hidden; display: flex; align-items: center; justify-content: center;', 804 '}', 805 /* Stands in for <agent-orb> when the WebGL assets fail to load, so it 806 has to be agent-orb's OWN degraded look â same stops, same 807 hue-rotate. See the note on .c2ai-launch-disc for why an hsl() 808 ramp on `orb_hue` was wrong. */ 809 '.c2ai-orb-fallback {', 810 ' width: 126px; height: 126px; border-radius: 9999px;', 811 ' background: radial-gradient(circle at 42% 38%,', 812 ' #6ee7d7 0%, #22b8a6 30%, #0D9488 52%,', 813 ' rgba(6,182,212,0.35) 74%, transparent 92%);', 814 ' filter: hue-rotate(' + h + 'deg);', 815 '}', 816 '.c2ai-status { display: inline-flex; align-items: baseline; gap: 1px; font: 400 15px/1.3 inherit; color: ' + MUTED + '; text-align: center; }', 817 '.c2ai-status.is-error { color: #DC2626; font-size: 13px; line-height: 1.45; display: block; padding: 0 8px; }', 818 '.c2ai-dots span { animation: c2ai-dots 1.2s ease-in-out infinite; }', 819 '.c2ai-dots span:nth-child(2) { animation-delay: .18s; }', 820 '.c2ai-dots span:nth-child(3) { animation-delay: .36s; }', 821 '.c2ai-timer { font-variant-numeric: tabular-nums; color: #9CA3AF; font-size: 12.5px; }', 822 823 /* ---- conversation log ---- */ 824 '.c2ai-log {', 825 ' display: flex; flex-direction: column; gap: 10px;', 826 ' max-height: 266px; height:266px; overflow-y: auto; padding: 14px 16px 4px;', 827 ' -webkit-mask-image: linear-gradient(to bottom, transparent 0, rgba(0,0,0,.35) 16px, #000 46px);', 828 ' mask-image: linear-gradient(to bottom, transparent 0, rgba(0,0,0,.35) 16px, #000 46px);', 829 '}', 830 '.c2ai-log[hidden] { display: none; }', 831 '.c2ai-row { display: flex; animation: c2ai-msg-in 220ms ease both; }', 832 '.c2ai-row.is-caller { justify-content: flex-end; }', 833 '.c2ai-row.is-agent { justify-content: flex-start; }', 834 '.c2ai-bubble {', 835 ' max-width: 84%; padding: 13px 15px; border-radius: 14px;', 836 ' font: 400 13px/1.55 inherit;
836 overflow-wrap: anywhere;', 837 '}', 838 '.c2ai-row.is-caller .c2ai-bubble { background: #F1F2F4; border: 1px solid #F1F2F4; color: #374151; }', 839 '.c2ai-row.is-agent .c2ai-bubble { background: #FFFFFF; border: 1px solid ' + HAIRLINE + '; color: #4B5563; }', 840 841 /* ---- composer ---- */ 842 '.c2ai-composer { display: flex; align-items: center; gap: 12px; padding: 14px 16px 18px; }', 843 '.c2ai-field {', 844 ' flex: 1; min-width: 0; display: flex; align-items: center; height: 52px; padding: 0 20px;', 845 ' border-radius: 9999px; background: #FFFFFF; border: 1px solid rgba(107,114,128,0.22);', 846 ' transition: border-color .15s ease, box-shadow .15s ease;', 847 '}', 848 '.c2ai-field:focus-within { border-color: ' + accent + '; box-shadow: 0 0 0 3px rgba(124,58,237,.12); }', 849 '.c2ai-field input {', 850 ' flex: 1; min-width: 0; height: 22px; background: transparent; border: 0; outline: none;', 851 ' font: 400 13.5px/1.3 inherit; color: ' + INK + '; padding: 0;', 852 '}', 853 '.c2ai-field input::placeholder { color: #9CA3AF; }', 854 '.c2ai-end {', 855 ' position: relative; flex-shrink: 0; width: 52px; height: 52px;', 856 ' display: inline-flex; align-items: center; justify-content: center;', 857 ' border: none; border-radius: 9999px; background: ' + DARK + '; color: #fff; cursor: pointer;', 858 ' transition: background .15s ease, transform .12s ease;', 859 '}', 860 '.c2ai-end:hover { background: ' + INK + '; }', 861 '.c2ai-end:active { transform: scale(.95); }', 862 '.c2ai-end:focus-visible { outline: 2px solid ' + accent + '; outline-offset: 3px; }', 863 '.c2ai-end svg { width: 22px; height: 22px; display: block; fill: currentColor; }', 864 '.c2ai-end.is-start { background: ' + accent + '; }', 865 '.c2ai-end.is-start:hover { filter: brightness(1.08); background: ' + accent + '; }', 866 /* mic-level ring â scaled from the visitor's own input level */ 867 '.c2ai-ring {', 868 ' position: absolute; inset: -4px; border-radius: 9999px; pointer-events: none;', 869 ' box-shadow: 0 0 0 2px rgba(124,58,237,.55);', 870 ' opacity: 0; transform: scale(.9); transition: opacity .12s linear;', 871 '}', 872 '.c2ai-note { padding: 0 16px 14px; font: 400 11.5px/1.4 inherit; color: #9CA3AF; text-align: center; }', 873 874 /* ---- identity gate ---- */ 875 '.c2ai-gate { padding: 4px 20px 20px; }', 876 '.c2ai-gate-title { font: 600 15px/1.4 inherit; color: ' + INK + '; margin-bottom: 4px; letter-spacing: -.01em; }', 877 '.c2ai-gate-sub { font: 400 12.5px/1.45 inherit; color: ' + MUTED + '; margin-bottom: 14px; }', 878 '.c2ai-input {', 879 ' width: 100%; margin-bottom: 9px; padding: 13px 16px; border-radius: 9999px;', 880 ' border: 1px solid rgba(107,114,128,0.22); background: #FFFFFF;', 881 ' color: ' + INK + '; font: 400 13.5px/1.3 inherit;', 882 '}', 883 '.c2ai-input::placeholder { color: #9CA3AF; }', 884 '.c2ai-input:focus { outline: none; border-color: ' + accent + '; box-shadow: 0 0 0 3px rgba(124,58,237,.12); }', 885 '.c2ai-error { font: 500 12.5px/1.45 inherit; color: #DC2626; padding: 0 4px 6px; }', 886 '.c2ai-primary {', 887 ' width: 100%; border: none; border-radius: 9999px; padding: 14px; cursor: pointer;', 888 ' font: 500 14px/1.2 inherit; color: #fff; background: ' + accent + ';', 889 ' transition: filter .15s ease;', 890 '}', 891 '.c2ai-primary:hover { filter: brightness(1.08); }', 892 '.c2ai-primary:focus-visible { outline: 2px solid ' + accent + '; outline-offset: 2px; }', 893 '.c2ai-skip {', 894 ' width: 100%; margin-top: 8px; border: none; background: none; cursor: pointer;', 895 ' color: ' + MUTED + '; font: 400 12px/1.3 inherit; text-decoration: underline;', 896 '}', 897 '.c2ai-skip:hover { color: ' + INK + '; }', 898 899 '@media (max-width: 560px) {', 900 ' .c2ai-root { right: 16px; bottom: 16px; left: 16px; }', 901 ' .c2ai-panel { width: 100%; max-width: none; }', 902 ' .c2ai-launch { justify-content: flex-end; width: 100%; }', 903 '}', 904 // .c2ai-pill carries the launcher's only real movement (hover lift); 905 // .c2ai-ring is scaled from JS every frame, which no media query can 906 // stop â levelLoop checks reduceMotion itself. 907 '@media (prefers-reduced-motion: reduce) {', 908 ' .c2ai-launch, .c2ai-pill, .c2ai-panel, .c2ai-halo, .c2ai-row,', 909 ' .c2ai-dots span, .c2ai-end, .c2ai-icon-btn, .c2ai-field, .c2ai-ring {', 910 ' animation: none !important; transition: none !important;', 911 ' }', 912 '}', 913 ]; 914
915 // Appended LAST so it overrides the anchors above â including the mobile 916 // block. PUSHED rather than listed as an array entry so the default corner 917 // emits nothing at all: an empty entry would still contribute a trailing 918 // newline, and the bar for the default path is a byte-identical stylesheet. 919 var overrides = positionCss(position); 920 921 if (overrides !== '') { 922 sheet.push(overrides); 923 } 924 925 return sheet.join('\n'); 926 } 927 928 /** 929 * The agent's orb hue-rotate OFFSET in degrees, parsed from the validated 930 * `<n>deg` orb_hue that /config serves. 931 * 932 * â ï¸ THIS IS AN OFFSET, NOT AN ABSOLUTE HUE. It is fed to `hue-rotate()` over 933 * agent-orb's fixed teal base â exactly what <agent-orb> does with the same 934 * value â so the CSS discs and the real WebGL orb always land on the same 935 * colour. It used to be treated as an absolute `hsl()` hue, which put the 936 * launcher ~172deg away from the orb. 937 * 938 * Falls back to 0 (agent-orb's own no-hue default = the base teal), NOT to a 939 * brand violet: an agent whose orb renders teal must not get a violet 940 * launcher. 0 also keeps a malformed value from producing `hue-rotate(NaNdeg)`, 941 * which paints nothing. 942 */ 943 function launcherHue(raw) { 944 var m = (typeof raw === 'string') ? raw.match(/^(\d{1,3}(?:\.\d+)?)deg$/) : null; 945 var n = m ? parseFloat(m[1]) : NaN; 946 return (isFinite(n) && n >= 0 && n <= 360) ? n : 0; 947 } 948 949 // Static literals only â see the note below on why this is the sole 950 // innerHTML sink in the file. `chevron` and `end` stand in for the mock's 951 // Material Symbols `expand_more` / `call_end` (see header note (a)). 952 var ICONS = { 953 mic: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 15a3.5 3.5 0 0 0 3.5-3.5V6a3.5 3.5 0 1 0-7 0v5.5A3.5 3.5 0 0 0 12 15Zm6-3.5a6 6 0 0 1-5 5.92V21h-2v-3.58a6 6 0 0 1-5-5.92h2a4 4 0 0 0 8 0h2Z"/></svg>', 954 micOff: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3.28 2 2 3.28l6.5 6.5v1.72a3.5 3.5 0 0 0 5.23 3.04l1.5 1.5A6 6 0 0 1 6 11.5H4a8 8 0 0 0 7 7.92V22h2v-2.58a7.9 7.9 0 0 0 3.16-1.1L20.72 21 22 19.72 3.28 2Zm12.22 9.5V6a3.5 3.5 0 0 0-6.7-1.42l6.6 6.6c.07-.22.1-.45.1-.68Z"/></svg>', 955 end: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 9c-1.9 0-3.7.3-5.4.9v3.3c0 .4-.2.8-.6 1l-2 1.4c-.4.3-1 .3-1.4-.1L.3 12.8a1 1 0 0 1 0-1.4C3.3 8.6 7.4 7 12 7s8.7 1.6 11.7 4.4a1 1 0 0 1 0 1.4l-2.3 2.7c-.4.4-1 .4-1.4.1l-2-1.4a1.2 1.2 0 0 1-.6-1V9.9C15.7 9.3 13.9 9 12 9Z"/></svg>', 956 call: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6.6 10.8c1.1 2.2 3 4 5.2 5.2l2.2-2.2c.3-.3.7-.4 1-.2 1.1.4 2.3.6 3.5.6.6 0 1 .4 1 1V19c0 .6-.4 1-1 1C10.6 20 4 13.4 4 5c0-.6.4-1 1-1h3.8c.6 0 1 .4 1 1 0 1.2.2 2.4.6 3.5.1.4 0 .8-.3 1l-2.5 1.3Z"/></svg>', 957 chevron: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 15.4 5.6 9l1.4-1.4 5 5 5-5L18.4 9Z"/></svg>', 958 }; 959 960 /** 961 * Demo-mode-only sample conversation, lifted from the approved design mock so 962 * the log + composer can be reviewed on preview apps (which cannot mint voice 963 * tokens at all â see the header TRANSCRIPT note). This is the ONLY canned 964 * conversation content in the widget and it is unreachable from a customer 965 * site: mountWidget only seeds it when opts.demo is true, which the public 966 * embed bootstrap never sets. 967 */ 968 var DEMO_SEED = [ 969 { role: 'agent', text: "Northline Plumbing, this is the after-hours dispatcher. What's going on there tonight?" }, 970 { role: 'caller', text: "There's water coming out from under my kitchen sink, a lot of it. It started maybe ten minutes ago." }, 971 { role: 'agent', text: "Okay â first thing, let's stop the water. There's a shut-off valve behind the panel under the sink, usually a small oval handle on the pipe. Turn it clockwise until it stops." }, 972 { role: 'caller', text: 'ok got it, it slowed way down. still dripping though' }, 973 { role: 'agent', text: "That's the line emptying out, that's normal. Put a towel under it and it'll stop on its own in a few minutes. Is the water spreading past the cabinet?" }, 974 { role: 'caller', text: "just the cabinet and about a foot of floor. no basement, we're on a slab." }, 975 { role: 'agent', text: 'Good, that keeps this straightforward. Can I get the service address?' }, 976 { role: 'caller', text: '1428 Redwood Ave, Oakland. 94619.' }, 977 { role: 'agent', text: 'Perfect. I have Marcus on call in your area tonight. He can be there in about forty minutes, or I can hold the 7:30 AM slot tomorrow. Either works.' }, 978 { role: 'caller', text: "tonight. I don't want to leave it like this." }, 979 { role: 'agent', text: "Done â Marcus is on his way, ETA 11:52 PM. After-hours is $145 for the first hour, then parts and labor quoted before he starts any work." }, 980 ]; 981
982 var DEMO_REPLIES = [ 983 "Booked. You'll get a text with the tech's name and ETA in the next minute.", 984 "Got it â I've added that to the job notes so the tech knows before he knocks.", 985 "No problem. I'll keep the line open while you check, then confirm the address with you.", 986 ]; 987 988 // Escape hatch for text we drop into innerHTML-free DOM: everything visitor- 989 // or config-supplied goes through textContent, so there is no HTML sink here. 990 // The only innerHTML use is the ICONS above, which are static literals. 991 992 /** 993 * The whole widget: one shadow host, a launcher, and a panel with five 994 * states (gate â connecting â in-call â ended â error). 995 * 996 * Also exported as window.C2AIVoice.mountPanel so the dashboard test page 997 * renders the EXACT panel a customer's visitors see â one UI, two token 998 * sources â instead of maintaining a lookalike. 999 * 1000 * cfg { bot_name, button_label, primary_color, orb_hue } 1001 * opts.tokenOpts (fn) returns startCall() token options for each attempt: 1002 * { tokenUrl, headers?, method?, body? } 1003 * opts.storage ({load, save}|null) identity persistence. null = the 1004 * gate shows every time and nothing is remembered 1005 * (what the dashboard test page wants). 1006 * opts.demo (bool) adds a "Preview with your mic" path to the gate: 1007 * a getUserMedia-only simulated call â in-call UI, 1008 * timer, mute, and YOUR voice driving the orb â with 1009 * no token and no Twilio leg. Exists so the design 1010 * can be exercised on environments that cannot mint 1011 * tokens (previews). NEVER set by the public embed 1012 * bootstrap; customer sites don't get this path. 1013 * opts.transcriptUrl (string) OPTIONAL. Poll this for the LIVE transcript 1014 * while in-call and render each turn into the log. 1015 * The turns are real: the relay POSTs cumulative 1016 * history to /gateway/converse/prepare every turn and 1017 * nexus persists it (VoiceTranscriptStore), so this is 1018 * a read, not a transcription. Absent (the public 1019 * embed) = no polling and no behaviour change. 1020 */ 1021 function mountWidget(cfg, opts) { 1022 var tokenOpts = opts.tokenOpts; 1023 var storage = opts.storage || null; 1024 var demoMode = !!opts.demo; 1025 var accent = styleColor(cfg.primary_color); 1026 // Server validates this too, but the widget must not trust a response 1027 // body it fetched cross-origin: bare CSS angle only. 1028 var orbHue = (typeof cfg.orb_hue === 'string' && /^\d{1,3}(\.\d+)?deg$/.test(cfg.orb_hue)) 1029 ? cfg.orb_hue 1030 : null; 1031 // Text composer: server opt-in only. /config does not serve this field 1032 // today, so it is OFF for every real visitor until the voiceserver can 1033 // actually carry a text turn (header note 4). Demo mode forces it on. 1034 var textOptIn = (cfg.text_input_enabled === true) || demoMode; 1035 1036 var host = document.createElement('div'); 1037 host.setAttribute('data-c2ai-voice', ''); 1038 var root = host.attachShadow ? host.attachShadow({ mode: 'open' }) : host; 1039 1040 var style = document.createElement('style'); 1041 style.textContent = styleSheet(accent, orbHue); 1042 root.appendChild(style); 1043 1044 var wrap = document.createElement('div'); 1045 wrap.className = 'c2ai-root'; 1046 root.appendChild(wrap); 1047 1048 // ---- launcher: status pill + haloed orb --------------------------- 1049 var launcher = document.createElement('button'); 1050 launcher.type = 'button'; 1051 launcher.className = 'c2ai-launch'; 1052 launcher.setAttribute('aria-label', cfg.button_label || 'Talk to us'); 1053 1054 var pill = document.createElement('span'); 1055 pill.className = 'c2ai-pill'; 1056 var dot = document.createElement('span'); 1057 dot.className = 'c2ai-dot'; 1058 var launchLabel = document.createElement('span'); 1059 launchLabel.className = 'c2ai-pill-label'; 1060 launchLabel.textContent = cfg.button_label || 'Talk to us'; 1061 pill.appendChild(dot); 1062 pill.appendChild(launchLabel); 1063 1064 var launchOrb = document.createElement('span'); 1065 launchOrb.className = 'c2ai-launch-orb'; 1066 var launchHalo = document.createElement('span'); 1067 launchHalo.className = 'c2ai-halo'; 1068 var launchDisc = document.createElement('span'); 1069 launchDisc.className = 'c2ai-launch-disc'; 1070 launchOrb.appendChild(launchHalo); 1071 launchOrb.appendChild(launchDisc); 1072 1073 launcher.appendChild(pill); 1074 launcher.appendChild(launchOrb); 1075 wrap.appendChild(launcher); 1076 1077 // ---- panel ------------------------------------------------------- 1078 var panel = document.createElement('div'); 1079 panel.className = 'c2ai-panel'; 1080 panel.setAttribute('role', 'dialog'); 1081 panel.setAttribute('aria-modal', 'false'); 1082 panel.setAttribute('aria-label', 'Voice call with ' + (cfg.bot_name || 'our assistant')); 1083 panel.hidden = true; 1084 wrap.appendChild(panel); 1085 1086 // Header: minimize + mute on the left, "Powered by" wordmark on the 1087 // right â the agent name is NOT shown here (the mock drops it; the orb 1088 // and the site's own branding carry identity). 1089 var head = document.createElement('div'); 1090 head.className = 'c2ai-head'; 1091 1092 var headActions = document.createElement('div'); 1093 headActions.className = 'c2ai-head-actions'; 1094 1095 var closeBtn = iconButton(ICONS.chevron, 'Minimize', 'c2ai-icon-btn'); 1096 var muteBtn = iconButton(ICONS.mic, 'Mute microphone', 'c2ai-icon-btn'); 1097 muteBtn.hidden = true; // only meaningful once a call is live 1098 var muteRing = document.createElement('span'); 1099 muteRing.className = 'c2ai-ring';
1100 muteBtn.appendChild(muteRing); 1101 1102 headActions.appendChild(closeBtn); 1103 headActions.appendChild(muteBtn); 1104 1105 var brand = document.createElement('div'); 1106 brand.className = 'c2ai-brand'; 1107 brand.appendChild(document.createTextNode('Powered by')); 1108 var brandImg = document.createElement('img'); 1109 brandImg.src = BASE_URL + '/images/logo-white-landscape.png'; 1110 brandImg.alt = 'Customer2.AI'; 1111 // A blocked/404 logo must not leave a broken-image glyph on a customer's 1112 // site; fall back to the wordmark as plain text. 1113 brandImg.addEventListener('error', function () { 1114 brandImg.remove(); 1115 brand.appendChild(document.createTextNode(' Customer2.AI')); 1116 }); 1117 brand.appendChild(brandImg); 1118 1119 head.appendChild(headActions); 1120 head.appendChild(brand); 1121 panel.appendChild(head); 1122 1123 var body = document.createElement('div'); 1124 panel.appendChild(body); 1125 1126 // ---- state machine ------------------------------------------------ 1127 var state = 'gate'; // gate | connecting | in-call | ended | error 1128 1129 // Live-transcript polling. All no-ops unless opts.transcriptUrl is set, 1130 // which the public embed bootstrap never does. 1131 var transcriptUrl = (typeof opts.transcriptUrl === 'string' && opts.transcriptUrl !== '') 1132 ? opts.transcriptUrl 1133 : null; 1134 var transcriptTimer = null; 1135 var transcriptSid = null; // set from the SDK when it exposes one 1136 var transcriptBusy = false; // guards overlapping polls on a slow response 1137 var renderedTurns = 0; // how much of the cumulative history is on screen 1138 var renderedSid = null; // which call those turns belong to 1139 var call = null; // controller from startCall() (or the demo stand-in) 1140 var twilioCall = null; // the raw Twilio Call â needed for sendMessage 1141 var orbEl = null; 1142 var timerId = 0; 1143 var levelRaf = 0; 1144 var startedAt = 0; 1145 var micAnalyser = null; 1146 var micData = null; 1147 var remoteAnalyser = null; 1148 var remoteData = null; 1149 var errorMsg = ''; 1150 var lastDemo = false; // whether the last connect was the mic preview 1151 var refs = {}; 1152 1153 // Conversation log. Empty for a real call today (see header note 4) â 1154 // the region stays hidden until something genuinely pushes a turn. 1155 var messages = []; 1156 var MAX_MESSAGES = 200; // bound the DOM on a long call 1157 var MAX_MESSAGE_CHARS = 2000; // ...and bound each turn (remote input) 1158 var demoReplyIdx = 0; 1159 var attemptSeq = 0; // invalidates callbacks from a superseded attempt 1160 // Read once: the ring is scaled from JS every frame, which the CSS 1161 // reduced-motion block cannot reach. 1162 var reduceMotion = !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches); 1163 1164 // Audio-derived talk state. The mock cycles Listening/Thinking/Speaking 1165 // on a timer because it has no audio; here the SAME three labels are 1166 // driven by the real analysers, so the line means something. 1167 var TALK = { listening: 'Listening', thinking: 'Thinking', speaking: 'Speaking' }; 1168 var ENERGY = { Listening: '0.3', Thinking: '0.55', Speaking: '0.95' }; 1169 var talk = TALK.listening; 1170 // Owns the "how long since the visitor spoke" state that decides 1171 // Thinking. Shared implementation â see talkMeter() at module scope. 1172 var meter = talkMeter(); 1173 1174 function open() { 1175 launcher.hidden = true; 1176 panel.hidden = false; 1177 // A returning visitor with a stored identity skips straight to the 1178 // call, matching v1's behaviour (and the chat widget's). 1179 var stored = storage ? storage.load() : null; 1180 if (stored) { 1181 connect(stored); 1182 } else { 1183 render('gate'); 1184 } 1185 setTimeout(function () { 1186 var f = refs.firstFocus || closeBtn;
1187 if (f && f.focus) { f.focus(); } 1188 }, 60); 1189 } 1190 1191 /** 1192 * Minimize. This ALSO hangs up: the mock's chevron only hides the panel, 1193 * but a hidden panel over a live WebRTC leg is a billable call with an 1194 * open microphone and no visible way to end it â the same hazard the 1195 * connecting-state cancel guard exists for. The button's accessible name 1196 * says so whenever a call is up. 1197 */ 1198 function close() { 1199 // Retire the current attempt so a late callback from it cannot paint 1200 // over a panel the visitor has already dismissed. 1201 attemptSeq++; 1202 if (call) { call.hangup(); call = null; } 1203 // hangup() normally fires 'ended' which stops the timer â but close() 1204 // must not depend on that event arriving (a device torn down mid- 1205 // connect can drop it), so stop it here too. stopTimer is idempotent. 1206 stopTimer(); 1207 teardownMeters(); 1208 // Same reason as stopTimer above, and it matters more here: close() 1209 // bumps attemptSeq first, so the 'ended' this hangup triggers fails the 1210 // mine() guard and never reaches stopTranscriptPoll(). Without this the 1211 // interval outlives the panel and keeps polling for the life of the tab. 1212 stopTranscriptPoll(); 1213 panel.hidden = true; 1214 launcher.hidden = false; 1215 state = 'gate'; 1216 messages = []; 1217 talk = TALK.listening;
1218 launcher.focus(); 1219 } 1220 1221 function render(next) { 1222 // body.replaceChildren() destroys whatever had focus; without this, 1223 // ending a call from the keyboard drops focus to the HOST PAGE's 1224 // body, outside our shadow root. 1225 var hadFocus = ownsFocus(); 1226 state = next; 1227 body.replaceChildren(); 1228 refs = {}; 1229 syncHeader(); 1230 if (next === 'gate') { renderGate(); } else { renderCall(); } 1231 if (hadFocus) { restoreFocus(); } 1232 } 1233 1234 /** 1235 * True when focus currently sits inside the part we are about to rebuild. 1236 * 1237 * â ï¸ RESOLVE THE ROOT FROM `body`, NOT FROM THE CAPTURED `root`. In inline 1238 * mode detachPanel() moves the panel â and therefore `body` â into a SECOND 1239 * shadow tree, and ShadowRoot.activeElement only reports focus inside its own 1240 * tree. Reading the original root there would return null on every check, so 1241 * restoreFocus() would never run and the replaceChildren() below would drop 1242 * focus to the host page â the exact failure this guard exists to prevent, 1243 * on every keyboard-driven state change. 1244 */ 1245 function ownsFocus() { 1246 var r = (body.getRootNode && body.getRootNode()) || root; 1247 var a = r && r.activeElement; 1248 return !!(a && body.contains(a)); 1249 } 1250 1251 function restoreFocus() { 1252 // After a call ends, the primary button PLACES A BILLABLE CALL. Land 1253 // on minimize instead, so a keyboard user still holding Enter cannot 1254 // dial again by accident. 1255 var target = (state === 'ended' || state === 'error') 1256 ? closeBtn 1257 : (refs.firstFocus || refs.input || refs.endBtn || closeBtn); 1258 if (target && typeof target.focus === 'function') { 1259 try { target.focus(); } catch (e) { /* detached mid-render */ } 1260 } 1261 } 1262 1263 /** 1264 * Mute is only offered on a live call; the minimize label follows suit. 1265 * 1266 * Leaving a call ALSO resets the mute control. Every new controller starts 1267 * unmuted (startCall sets _muted = false), but muteBtn lives outside `body` 1268 * so render() never rebuilds it â without this, a visitor who muted call #1 1269 * sees a muted-looking button over a live, UNMUTED call #2, and their first 1270 * click would then mute rather than unmute. 1271 */ 1272 function syncHeader() { 1273 var live = (state === 'in-call'); 1274 muteBtn.hidden = ! live; 1275 closeBtn.setAttribute('aria-label', live ? 'End call and minimize' : 'Minimize'); 1276 closeBtn.title = live ? 'End call and minimize' : 'Minimize'; 1277 if (! live) { resetMute(); } 1278 } 1279 1280 function resetMute() { 1281 muteBtn.classList.remove('is-muted'); 1282 muteBtn.setAttribute('aria-pressed', 'false'); 1283 muteBtn.setAttribute('aria-label', 'Mute microphone'); 1284 muteBtn.title = 'Mute microphone'; 1285 setIcon(muteBtn, ICONS.mic); 1286 muteRing.style.opacity = '0'; 1287 } 1288 1289 // ---- gate --------------------------------------------------------- 1290 function renderGate() { 1291 var box = document.createElement('div'); 1292 box.className = 'c2ai-gate'; 1293 1294 var title = document.createElement('div'); 1295 title.className = 'c2ai-gate-title'; 1296 title.textContent = 'Before we connect'; 1297 1298 var sub = document.createElement('div'); 1299 sub.className = 'c2ai-gate-sub'; 1300 sub.textContent = 'Leave an email or phone so ' + (cfg.bot_name || 'our assistant') + ' remembers you next time. Optional.'; 1301 1302 var email = input('email', 'Email', 255); 1303 var phone = input('tel', 'Phone', 32); 1304 1305 var err = document.createElement('div'); 1306 err.className = 'c2ai-error'; 1307 err.setAttribute('aria-live', 'polite'); 1308 1309 var go = document.createElement('button'); 1310 go.type = 'button'; 1311 go.className = 'c2ai-primary'; 1312 go.textContent = 'Start call'; 1313 1314 var skip = document.createElement('button'); 1315 skip.type = 'button'; 1316 skip.className = 'c2ai-skip'; 1317 skip.textContent = 'Skip and talk anonymously'; 1318 1319 go.addEventListener('click', function () { 1320 var contact = validateContact(email.value, phone.value, err); 1321 if (contact === false) { return; } 1322 if (contact && storage) { storage.save(contact); } 1323 connect(contact); 1324 }); 1325 // DEMO MODE (the dashboard test page): "Skip" lands in the MIC PREVIEW, 1326 // exactly like the "Preview with your mic" link below â NOT a real 1327 // anonymous call. On environments where the voice service can't mint 1328 // tokens (every preview app), the real-call path dead-ends in "The 1329 // voice service is temporarily unavailable", which made Skip a button 1330 // whose only outcome was an error. Owners testing the widget get the 1331 // sample-conversation preview instead (2026-08-13). 1332 //
1333 // PUBLIC EMBEDS ARE UNTOUCHED: opts.demo is never set by the public 1334 // bootstrap, so a real visitor's Skip still places a real anonymous 1335 // call. Routing a visitor into the canned DEMO_SEED conversation would 1336 // present a fake exchange as the business â never do that. 1337 skip.addEventListener('click', function () { 1338 if (demoMode) { demoConnect(); return; } 1339 connect(null); 1340 }); 1341 function onKey(e) { if (e.key === 'Enter') { e.preventDefault(); go.click(); } } 1342 email.addEventListener('keydown', onKey); 1343 phone.addEventListener('keydown', onKey); 1344 1345 box.appendChild(title); 1346 box.appendChild(sub); 1347 box.appendChild(email); 1348 box.appendChild(phone); 1349 box.appendChild(err); 1350 box.appendChild(go); 1351 box.appendChild(skip); 1352 1353 if (demoMode) { 1354 var demo = document.createElement('button'); 1355 demo.type = 'button'; 1356 demo.className = 'c2ai-skip'; 1357 demo.textContent = 'Preview with your mic (no call)'; 1358 demo.addEventListener('click', demoConnect); 1359 box.appendChild(demo); 1360 } 1361 1362 body.appendChild(box); 1363 refs.firstFocus = email; 1364 } 1365 1366 function input(type, placeholder, max) { 1367 var i = document.createElement('input'); 1368 i.type = type; 1369 i.className = 'c2ai-input'; 1370 i.placeholder = placeholder; 1371 i.setAttribute('aria-label', placeholder); 1372 i.maxLength = max; 1373 return i; 1374 } 1375 1376 // ---- call --------------------------------------------------------- 1377 function renderCall() { 1378 body.appendChild(buildStage()); 1379 body.appendChild(buildLog()); 1380 body.appendChild(buildComposer()); 1381 if (lastDemo && state === 'in-call') { 1382 var note = document.createElement('div'); 1383 note.className = 'c2ai-note'; 1384 note.textContent = 'Mic preview â no live call. Sample conversation shown.'; 1385 body.appendChild(note); 1386 } 1387 paintStatus(); 1388 // scrollHeight only reads correctly once the log is in the document. 1389 if (refs.log) { refs.log.scrollTop = refs.log.scrollHeight; } 1390 } 1391 1392 /** Orb stage + the status line ("Listeningâ¦" / "Connectingâ¦" / error). */ 1393 function buildStage() { 1394 var stage = document.createElement('div'); 1395 stage.className = 'c2ai-stage'; 1396 1397 var orbWrap = document.createElement('div'); 1398 orbWrap.className = 'c2ai-orb-wrap'; 1399 1400 var halo = document.createElement('span'); 1401 halo.className = 'c2ai-halo is-lg'; 1402 orbWrap.appendChild(halo); 1403 1404 var clip = document.createElement('span'); 1405 clip.className = 'c2ai-orb-clip'; 1406 1407 // The orb element is created once and reused across renders within a 1408 // single open, so the WebGL context is not rebuilt on every state 1409 // change. It is appended to whichever clip is current. 1410 if (!orbEl) { 1411 orbEl = document.createElement('agent-orb'); 1412 // 248 inside a 126px clip is the mock's deliberate crop: the orb 1413 // renders large and is masked down to its dense centre. 1414 orbEl.setAttribute('size', '248'); 1415 orbEl.setAttribute('energy', ENERGY[talk] || '0.3'); 1416 if (orbHue) { orbEl.setAttribute('hue', orbHue); } 1417 loadOrb().then(function (ok) { 1418 if (!ok && orbEl && !orbEl.firstChild && !orbEl.shadowRoot) { 1419 // No orb assets â draw the same flat disc agent-orb would 1420 // have degraded to, so the stage is never empty. 1421 var f = document.createElement('div'); 1422 f.className = 'c2ai-orb-fallback'; 1423 orbEl.replaceWith(f); 1424 orbEl = f; 1425 } 1426 }); 1427 } 1428 clip.appendChild(orbEl); 1429 orbWrap.appendChild(clip); 1430 stage.appendChild(orbWrap); 1431 1432 var status = document.createElement('div'); 1433 status.className = 'c2ai-status'; 1434 status.setAttribute('aria-live', 'polite'); 1435 stage.appendChild(status); 1436 refs.status = status; 1437 1438 return stage; 1439 } 1440 1441 function paintStatus() { 1442 var s = refs.status; 1443 if (!s) { return; } 1444 s.replaceChildren(); 1445 refs.talkNode = null; 1446 refs.timer = null; 1447 s.classList.toggle('is-error', state === 'error'); 1448 1449 if (state === 'error') { 1450 s.textContent = errorMsg || 'Something went wrong.'; 1451 return; 1452 } 1453 if (state === 'ended') { 1454 s.textContent = 'Call ended'; 1455 return; 1456 } 1457 1458 // connecting | in-call â label + the mock's three animated dots. 1459 var talkNode = document.createTextNode(state === 'connecting' ? 'Connecting' : talk); 1460 s.appendChild(talkNode); 1461 refs.talkNode = talkNode; 1462 1463 var dots = document.createElement('span'); 1464 dots.className = 'c2ai-dots'; 1465 dots.setAttribute('aria-hidden', 'true'); 1466 for (var i = 0; i < 3; i++) { 1467 var d = document.createElement('span'); 1468 d.textContent = '.'; 1469 dots.appendChild(d); 1470 } 1471 s.appendChild(dots); 1472 1473 if (state === 'in-call') { 1474 var t = document.createElement('span'); 1475 t.className = 'c2ai-timer'; 1476 t.textContent = ' · ' + elapsedText(); 1477 // The status node is aria-live; a timer ticking inside it would 1478 // be read every second and bury the talk-state changes the 1479 // region exists to announce. 1480 t.setAttribute('aria-hidden', 'true'); 1481 s.appendChild(t); 1482 refs.timer = t; 1483 } 1484 } 1485 1486 /** 1487 * Swap the talk label without rebuilding the status node â a full repaint 1488 * would restart the dots animation every time the speaker changes. 1489 */ 1490 function setTalk(next) { 1491 if (talk === next) { return; } 1492 talk = next; 1493 if (orbEl && typeof orbEl.setAttribute === 'function') { 1494 orbEl.setAttribute('energy', ENERGY[next] || '0.3'); 1495 } 1496 if (state === 'in-call' && refs.talkNode) { 1497 refs.talkNode.nodeValue = next; 1498 } 1499 } 1500 1501 // ---- conversation log --------------------------------------------- 1502 /** 1503 * Build the log ALREADY POPULATED, before it is inserted into the 1504 * document. This is deliberate: the region is aria-live, so filling it 1505 * after insertion (or rebuilding it wholesale) makes a screen reader 1506 * re-announce every turn in the call. Backfill happens off-document;
1507 * only pushMessage() ever mutates a live region, one row at a time. 1508 */ 1509 function buildLog() { 1510 var log = document.createElement('div'); 1511 log.className = 'c2ai-log'; 1512 log.setAttribute('role', 'log'); 1513 log.setAttribute('aria-live', 'polite'); 1514 log.setAttribute('aria-label', 'Conversation'); 1515 log.hidden = messages.length === 0; 1516 for (var i = 0; i < messages.length; i++) { 1517 log.appendChild(messageRow(messages[i])); 1518 } 1519 refs.log = log; 1520 return log; 1521 } 1522 1523 function messageRow(m) { 1524 var row = document.createElement('div'); 1525 row.className = 'c2ai-row ' + (m.role === 'caller' ? 'is-caller' : 'is-agent'); 1526 var bubble = document.createElement('div'); 1527 bubble.className = 'c2ai-bubble'; 1528 // textContent, never innerHTML â turn text is remote input. 1529 bubble.textContent = m.text; 1530 row.appendChild(bubble); 1531 return row; 1532 } 1533 1534 // ---- live transcript ------------------------------------------------ 1535 1536 /** 1537 * Poll nexus for the turns of the call in progress and render the ones we 1538 * have not shown yet. 1539 * 1540 * WHY POLLING AND NOT A PUSH: the Twilio Voice SDK carries audio only, so 1541 * there is no channel from the relay to this page. The text arrives at 1542 * NEXUS instead (the relay POSTs cumulative history to 1543 * /gateway/converse/prepare every turn, which persists it), so the browser 1544 * reads it from our own origin. 2s is well inside a conversational turn. 1545 * 1546 * The history is CUMULATIVE, so `renderedTurns` is the only state needed â 1547 * append from that index and never re-render what is already on screen. 1548 */ 1549 function startTranscriptPoll() { 1550 if (!transcriptUrl || transcriptTimer) { return; } 1551 pollTranscript(); 1552 transcriptTimer = setInterval(pollTranscript, 2000); 1553 } 1554 1555 function stopTranscriptPoll() { 1556 if (transcriptTimer) { clearInterval(transcriptTimer); transcriptTimer = null; } 1557 transcriptBusy = false; 1558 } 1559 1560 function transcriptEndpoint() { 1561 if (!transcriptSid) { return transcriptUrl; } 1562 return transcriptUrl + (transcriptUrl.indexOf('?') === -1 ? '?' : '&') 1563 + 'call_sid=' + encodeURIComponent(transcriptSid); 1564 } 1565 1566 function pollTranscript() { 1567 if (!transcriptUrl || transcriptBusy) { return; } 1568 transcriptBusy = true; 1569 1570 // AbortController, not just a catch: without a timeout a request that 1571 // never settles (proxy stall, laptop sleep/resume) leaves transcriptBusy 1572 // true forever and every later tick returns early â the transcript would 1573 // stop updating mid-call, silently and unrecoverably. 1574 var ctl = (typeof AbortController !== 'undefined') ? new AbortController() : null; 1575 var killed = ctl ? setTimeout(function () { try { ctl.abort(); } catch (e) {} }, 5000) : null; 1576 var done = function () { 1577 transcriptBusy = false; 1578 if (killed) { clearTimeout(killed); } 1579 }; 1580 1581 fetch(transcriptEndpoint(), { 1582 credentials: 'same-origin', 1583 headers: { 'Accept': 'application/json' }, 1584 signal: ctl ? ctl.signal : undefined, 1585 }).then(function (r) { 1586 return r.ok ? r.json() : null; 1587 }).then(function (data) { 1588 done(); 1589 if (!data || !Array.isArray(data.turns)) { return; } 1590 1591 // ââ KEY ON call_sid, NOT ON LENGTH ââââââââââââââââââââââââââââ 1592 // Length alone cannot tell "more turns" from "a different call". 1593 // Two consequences it got wrong: a finished call's turns painted as 1594 // the new one, and â when both calls had the same turn count â two 1595 // conversations interleaved permanently with no visible cue. 1596 var sid = (typeof data.call_sid === 'string' && data.call_sid !== '') 1597 ? data.call_sid 1598 : null; 1599 1600 // Nothing to show yet (no turns, or the server would not name a 1601 // call). Leave whatever is on screen ALONE â an empty answer used to 1602 // wipe the log mid-call, and since the log is aria-live the whole 1603 // conversation was then re-announced when it came back. 1604 if (!sid || data.turns.length === 0) { return; } 1605 1606 if (sid !== renderedSid) { 1607 renderedSid = sid; 1608 renderedTurns = 0; 1609 messages = []; 1610 if (refs.log) { refs.log.replaceChildren(); refs.log.hidden = true; } 1611 } 1612 1613 for (var i = renderedTurns; i < data.turns.length; i++) { 1614 var t = data.turns[i]; 1615 if (t && typeof t.text === 'string') { 1616 pushMessage(t.role === 'caller' ? 'caller' : 'agent', t.text); 1617 } 1618 } 1619 renderedTurns = data.turns.length; 1620 }).catch(function () { 1621 // Silent by design: a dropped poll must never disturb a live call. 1622 done(); 1623 }); 1624 } 1625 1626 function pushMessage(role, text) { 1627 var clean = (typeof text === 'string') ? text.trim() : ''; 1628 if (clean === '') { return; } 1629 // Cap the TEXT as well as the count: MAX_MESSAGES alone still lets a 1630 // misbehaving feed hand us one multi-megabyte "turn". 1631 var msg = { 1632 role: role === 'caller' ? 'caller' : 'agent', 1633 text: clean.length > MAX_MESSAGE_CHARS ? clean.slice(0, MAX_MESSAGE_CHARS) + 'â¦' : clean, 1634 }; 1635 messages.push(msg); 1636 1637 var dropped = Math.max(0, messages.length - MAX_MESSAGES); 1638 if (dropped) { messages = messages.slice(-MAX_MESSAGES); } 1639 1640 var log = refs.log; 1641 if (!log) { return; } 1642 while (dropped-- > 0 && log.firstChild) { log.removeChild(log.firstChild); } 1643 log.hidden = false;
1644 log.appendChild(messageRow(msg)); 1645 log.scrollTop = log.scrollHeight; 1646 } 1647 1648 // ---- composer ------------------------------------------------------- 1649 function buildComposer() { 1650 var row = document.createElement('div'); 1651 row.className = 'c2ai-composer'; 1652 1653 if (canSendText()) { 1654 var field = document.createElement('div'); 1655 field.className = 'c2ai-field'; 1656 var input = document.createElement('input'); 1657 input.type = 'text'; 1658 input.maxLength = 500; 1659 input.placeholder = 'You can text insteadâ¦'; 1660 input.setAttribute('aria-label', 'Send a text message to the agent'); 1661 input.addEventListener('keydown', function (e) { 1662 if (e.key === 'Enter') { e.preventDefault(); sendText(); } 1663 }); 1664 field.appendChild(input); 1665 row.appendChild(field); 1666 refs.input = input; 1667 } else { 1668 // No text channel: the end button owns the row on its own. 1669 row.style.justifyContent = 'center'; 1670 } 1671 1672 row.appendChild(buildEndButton()); 1673 return row; 1674 } 1675 1676 /** The mock's dark round button â end / cancel / retry, per state. */ 1677 function buildEndButton() { 1678 if (state === 'ended' || state === 'error') { 1679 // Retry on the same path we came from (a mic preview retries as a 1680 // mic preview, a real call as a real call). 1681 var again = iconButton(ICONS.call, state === 'error' ? 'Try again' : 'Call again', 'c2ai-end'); 1682 again.classList.add('is-start'); 1683 again.addEventListener('click', function () { 1684 if (lastDemo) { demoConnect(); return; } 1685 connect(storage ? storage.load() : null); 1686 }); 1687 refs.endBtn = again; 1688 return again; 1689 } 1690 1691 var connecting = (state === 'connecting'); 1692 var end = iconButton(ICONS.end, connecting ? 'Cancel call' : 'End call', 'c2ai-end'); 1693 end.addEventListener('click', function () { 1694 if (call) { call.hangup(); } 1695 if (connecting) { close(); } 1696 }); 1697 refs.endBtn = end; 1698 return end; 1699 } 1700 1701 /** 1702 * Text is offered only when the SERVER opted in (cfg.text_input_enabled) 1703 * AND a live channel exists to carry it. Demo mode stands in for both so 1704 * the design is reviewable where no call can be placed. 1705 */ 1706 function canSendText() { 1707 if (! textOptIn || state !== 'in-call') { return false; } 1708 if (lastDemo) { return true; } 1709 return !!(twilioCall && typeof twilioCall.sendMessage === 'function'); 1710 } 1711 1712 function sendText() { 1713 var input = refs.input; 1714 if (!input) { return; } 1715 var v = input.value.trim(); 1716 if (v === '') { return; } 1717 input.value = ''; 1718 pushMessage('caller', v); 1719 1720 if (lastDemo) { 1721 var reply = DEMO_REPLIES[demoReplyIdx % DEMO_REPLIES.length]; 1722 demoReplyIdx++; 1723 setTimeout(function () { 1724 if (state === 'in-call' && lastDemo) { pushMessage('agent', reply); } 1725 }, 700); 1726 return; 1727 } 1728 1729 try { 1730 twilioCall.sendMessage({ 1731 content: { role: 'caller', text: v }, 1732 contentType: 'application/json', 1733 messageType: 'user-defined-message', 1734 }); 1735 } catch (e) { 1736 console.warn('[C2AI voice] text send failed', e); 1737 } 1738 } 1739 1740 /** 1741 * Bridge Twilio call-message events into the log, IF the SDK build and 1742 * the voice service support them. Entirely feature-detected: on today's 1743 * stack this binds nothing and the log simply stays empty (header note 4). 1744 */ 1745 function bindCallMessages(tc) { 1746 if (!tc || typeof tc.on !== 'function') { return; } 1747 try { 1748 tc.on('messageReceived', function (msg) { 1749 var parsed = parseIncoming(msg); 1750 if (parsed) { pushMessage(parsed.role, parsed.text); } 1751 }); 1752 } catch (e) { /* SDK without call-message support */ } 1753 } 1754 1755 /** Defensive: accept a few plausible payload shapes, ignore anything else. */ 1756 function parseIncoming(msg) { 1757 if (!msg) { return null; } 1758 var c = (msg.content !== undefined && msg.content !== null) ? msg.content : msg; 1759 if (typeof c === 'string') { 1760 try { c = JSON.parse(c); } catch (e) { return { role: 'agent', text: c }; } 1761 } 1762 if (!c || typeof c !== 'object') { return null; } 1763 var text = c.text || c.transcript || c.body; 1764 if (typeof text !== 'string' || text.trim() === '') { return null; } 1765 return { 1766 role: (c.role === 'caller' || c.role === 'user') ? 'caller' : 'agent', 1767 text: text, 1768 }; 1769 } 1770 1771 function iconButton(svg, label, className) { 1772 var b = document.createElement('button'); 1773 b.type = 'button'; 1774 b.className = className || 'c2ai-icon-btn'; 1775 b.setAttribute('aria-label', label); 1776 b.title = label; 1777 setIcon(b, svg); 1778 return b; 1779 } 1780 1781 function setIcon(btn, svg) { 1782 var holder = btn.querySelector('span[data-icon]'); 1783 if (!holder) { 1784 holder = document.createElement('span'); 1785 holder.setAttribute('data-icon', ''); 1786 btn.insertBefore(holder, btn.firstChild); 1787 } 1788 // Static literal from ICONS â never visitor or config input. 1789 holder.innerHTML = svg; 1790 } 1791 1792 // ---- connect ------------------------------------------------------ 1793 function connect(contact) { 1794 // Retire the previous attempt BEFORE tearing it down, so even a 1795 // synchronous status emission during hangup() is already stale. 1796 var seq = ++attemptSeq; 1797 var mine = function () { return seq === attemptSeq; }; 1798 1799 // Never leave a previous attempt's device/mic running unattended:
1800 // `call` is about to be overwritten, and nothing else would ever 1801 // reach the old controller to tear it down. 1802 if (call) { call.hangup(); call = null; } 1803 errorMsg = ''; 1804 lastDemo = false; 1805 messages = []; 1806 renderedTurns = 0; 1807 renderedSid = null; 1808 transcriptSid = null; 1809 twilioCall = null; 1810 talk = TALK.listening; 1811 render('connecting'); 1812 1813 var params = {}; 1814 if (contact && contact.email) { params.email = contact.email; } 1815 if (contact && contact.phone) { params.phone = contact.phone; } 1816 1817 // `seq`/`mine` are declared at the top of this function. The guard 1818 // exists because a SUPERSEDED attempt's late callback (call #1 errors 1819 // â visitor retries â call #2 connects â call #1's Device finally 1820 // emits 'error') would otherwise null out `call` and paint the error 1821 // screen over the LIVE call #2 â an open mic with no End button. 1822 call = startCall(Object.assign({}, tokenOpts(), { 1823 connectParams: Object.keys(params).length ? params : undefined, 1824 onCall: function (tc) { 1825 if (! mine()) { return; } 1826 attachMeters(tc); 1827 // Feature-detected, like every other SDK touchpoint here: the 1828 // Twilio Voice SDK is served from outside this repo, so whether 1829 // an OUTGOING call exposes a CallSid could not be verified. 1830 // When it does we pin the transcript to this exact call; when 1831 // it does not, transcriptSid stays null and the endpoint falls 1832 // back to the newest call on this agent. 1833 try { 1834 var sid = tc && tc.parameters ? tc.parameters.CallSid : null; 1835 if (typeof sid === 'string' && sid !== '') { transcriptSid = sid; } 1836 } catch (e) { /* SDK without call parameters */ } 1837 }, 1838 onStatus: function (s) { 1839 if (! mine()) { return; } 1840 if (s === 'in-call') { 1841 // Full re-render: the composer and the dark button both 1842 // change shape between connecting and in-call. 1843 startTimer(); 1844 render('in-call'); 1845 startTranscriptPoll(); 1846 } else if (s === 'ended') { 1847 stopTimer(); 1848 teardownMeters(); 1849 stopTranscriptPoll(); 1850 call = null; 1851 render('ended'); 1852 } else if (s === 'error') { 1853 stopTimer(); 1854 teardownMeters(); 1855 stopTranscriptPoll(); 1856 call = null; 1857 render('error'); 1858 } 1859 }, 1860 onError: function (e) { 1861 // The token endpoint's own message is the useful one (demo cap 1862 // reached, account on hold, domain not authorised, service 1863 // unavailable) â surface it rather than a generic string. 1864 errorMsg = (e && e.message) ? e.message : 'Could not connect.'; 1865 if (state === 'error') { paintStatus(); } 1866 console.error('[C2AI voice]', e); 1867 }, 1868 })); 1869 } 1870 1871 /** 1872 * The mic preview (demo mode only): a simulated call driven entirely by 1873 * getUserMedia. Same in-call UI, timer, mute and meters â YOUR mic 1874 * plays the role of both streams, so the orb reacts to your own voice. 1875 * No token is requested, no Twilio leg exists, nothing is billable. 1876 * The stand-in controller implements the exact surface the UI touches 1877 * (hangup/mute/isMuted) plus getRemoteStream/getLocalStream so 1878 * attachMeters() works unchanged. 1879 */ 1880 function demoConnect() { 1881 // Retire the previous attempt first, then tear it down â same
1882 // ordering as connect(), so a pending getUserMedia or a late call 1883 // callback from it is already stale by the time it resolves. 1884 var seq = ++attemptSeq; 1885 if (call) { call.hangup(); call = null; } 1886 errorMsg = ''; 1887 lastDemo = true; 1888 twilioCall = null; 1889 talk = TALK.listening; 1890 messages = []; 1891 demoReplyIdx = 0; 1892 // The mic preview shares one log with the real call. A poller left 1893 // running from an earlier real call would otherwise append genuine 1894 // turns underneath the scripted DEMO_SEED exchange â mixing a real 1895 // conversation into a fake one, which is the exact hazard the demo 1896 // path is fenced off to avoid. 1897 stopTranscriptPoll(); 1898 renderedTurns = 0; 1899 renderedSid = null; 1900 transcriptSid = null; 1901 render('connecting'); 1902 1903 if (!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia)) { 1904 errorMsg = 'Microphone access is not available in this browser.'; 1905 render('error'); 1906 return; 1907 } 1908 1909 navigator.mediaDevices.getUserMedia({ audio: true }).then(function (stream) { 1910 // The permission prompt is modeless: the visitor may have closed 1911 // the panel (or started a different connect) while it was up. A 1912 // capture that outlives the UI that asked for it is the same bug 1913 // class as the real engine's _cancelled race â kill it here. 1914 // (seq mismatch = a newer attempt superseded this one.) 1915 if (seq !== attemptSeq || panel.hidden || state !== 'connecting' || !lastDemo) { 1916 stream.getTracks().forEach(function (t) { t.stop(); }); 1917 return; 1918 } 1919 // Demo ONLY: seed the mock's sample conversation so the log and 1920 // composer can be reviewed where no call can be placed. Seeded 1921 // HERE, not before the prompt, so a denied permission doesn't 1922 // leave a finished fake conversation above the error. Never runs 1923 // for a visitor â the public bootstrap never sets opts.demo. 1924 messages = DEMO_SEED.slice(); 1925 var muted = false; 1926 call = { 1927 getRemoteStream: function () { return stream; }, 1928 getLocalStream: function () { return stream; }, 1929 hangup: function () { 1930 stream.getTracks().forEach(function (t) { t.stop(); }); 1931 stopTimer(); 1932 teardownMeters(); 1933 call = null; 1934 render('ended'); 1935 }, 1936 mute: function (on) { 1937 muted = !!on; 1938 // Disabling the track reads as true silence to the 1939 // analysers, so the orb idles and the ring goes dark â 1940 // same behaviour as a muted real call. 1941 stream.getAudioTracks().forEach(function (t) { t.enabled = !muted; }); 1942 }, 1943 isMuted: function () { return muted; }, 1944 }; 1945 startTimer(); 1946 render('in-call'); 1947 attachMeters(call); 1948 }).catch(function () { 1949 if (seq !== attemptSeq) { return; } 1950 errorMsg = 'Microphone permission was denied.'; 1951 render('error'); 1952 }); 1953 } 1954 1955 // ---- meters (orb + mic ring + talk state) ------------------------- 1956 function attachMeters(tc) { 1957 twilioCall = tc; 1958 bindCallMessages(tc); 1959 1960 // This runs long after the click that opened the panel, so a 1961 // just-created AudioContext can start 'suspended' (autoplay policy) 1962 // and both analysers would read silent zeros for the whole call. 1963 // Resuming here is allowed because an active WebRTC capture exists. 1964 var ctx = audioContext(); 1965 if (ctx && ctx.state === 'suspended') { 1966 ctx.resume().catch(function () { /* meters stay idle; audio unaffected */ }); 1967 } 1968 1969 var remote = analyserFor(streamFrom(tc, 'getRemoteStream')); 1970 if (remote) { 1971 remoteAnalyser = remote; 1972 remoteData = new Uint8Array(remote.frequencyBinCount); 1973 if (orbEl && typeof orbEl.setAudioAnalyser === 'function') { 1974 orbEl.setAudioAnalyser(remote); 1975 } else { 1976 // Call connected before the ~590 KB orb bundle finished â 1977 // attach once it lands (if this call is still live) instead 1978 // of leaving the orb on idle sine waves all call long. 1979 loadOrb().then(function (ok) { 1980 if (ok && call && orbEl && typeof orbEl.setAudioAnalyser === 'function') { 1981 orbEl.setAudioAnalyser(remote); 1982 } 1983 }); 1984 } 1985 } 1986 micAnalyser = analyserFor(streamFrom(tc, 'getLocalStream')); 1987 if (micAnalyser) { 1988 micData = new Uint8Array(micAnalyser.frequencyBinCount); 1989 } 1990 if ((micAnalyser || remoteAnalyser) && !levelRaf) { 1991 levelRaf = requestAnimationFrame(levelLoop); 1992 } 1993 1994 // onCall fires AFTER onStatus('in-call'), so the composer was built 1995 // before we knew whether a text channel exists. Rebuild once if that 1996 // answer just changed. (The running timer re-binds to the new node 1997 // on its next tick â startTimer reads refs.timer each time.) 1998 if (state === 'in-call' && canSendText() && !refs.input) { 1999 render('in-call'); 2000 } 2001 } 2002 2003 /** 2004 * Frame loop: mic ring + which side is talking. 2005 * 2006 * The talk decision itself lives in the module-scope talkMeter() (also 2007 * exported for chat-widget.js's voice overlay) so there is exactly ONE 2008 * definition of Listening/Thinking/Speaking across both widgets. 2009 */ 2010 function levelLoop() { 2011 levelRaf = requestAnimationFrame(levelLoop); 2012 2013 var muted = !!(call && call.isMuted()); 2014 var lvl = meter.read(micAnalyser, micData, remoteAnalyser, remoteData, muted); 2015 2016 // muteRing (not refs.*) â the header survives body re-renders, and 2017 // refs is cleared on every render(). 2018 muteRing.style.opacity = lvl.mic.toFixed(3); 2019 if (! reduceMotion) { 2020 muteRing.style.transform = 'scale(' + (0.9 + lvl.mic * 0.22).toFixed(3) + ')'; 2021 } 2022 2023 if (state !== 'in-call') { return; } 2024 2025 setTalk(TALK[lvl.talk]); 2026 } 2027 2028 function teardownMeters() { 2029 if (levelRaf) { cancelAnimationFrame(levelRaf); levelRaf = 0; } 2030 micAnalyser = null; 2031 micData = null; 2032 remoteAnalyser = null; 2033 remoteData = null; 2034 twilioCall = null; 2035 // Fresh meter per call, so the previous call's "visitor last spoke" 2036 // timestamp cannot make the next one open on "Thinking". 2037 meter = talkMeter(); 2038 if (orbEl && typeof orbEl.setAudioAnalyser === 'function') { 2039 orbEl.setAudioAnalyser(null); 2040 } 2041 } 2042 2043 function elapsedText() { 2044 if (!startedAt) { return '0:00'; } 2045 var s = Math.max(0, Math.floor((Date.now() - startedAt) / 1000)); 2046 return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); 2047 } 2048 2049 function startTimer() { 2050 startedAt = Date.now(); 2051 stopTimer(); 2052 timerId = setInterval(function () { 2053 if (!refs.timer) { return; } 2054 refs.timer.textContent = ' · ' + elapsedText(); 2055 }, 1000); 2056 } 2057 2058 function stopTimer() { 2059 if (timerId) { clearInterval(timerId); timerId = 0; } 2060 } 2061 2062 // Mute lives in the header (see header note (c)) and persists across 2063 // re-renders, so it is wired once here rather than per paint. 2064 muteBtn.setAttribute('aria-pressed', 'false'); 2065 muteBtn.addEventListener('click', function () { 2066 if (!call) { return; } 2067 var next = !call.isMuted(); 2068 call.mute(next); 2069 muteBtn.classList.toggle('is-muted', next); 2070 muteBtn.setAttribute('aria-label', next ? 'Unmute microphone' : 'Mute microphone'); 2071 muteBtn.title = next ? 'Unmute microphone' : 'Mute microphone'; 2072 muteBtn.setAttribute('aria-pressed', next ? 'true' : 'false'); 2073 setIcon(muteBtn, next ? ICONS.micOff : ICONS.mic); 2074 // Muted input reads as silent immediately, rather than leaving the 2075 // last ring frame lit until the next animation tick. 2076 if (next) { muteRing.style.opacity = '0'; } 2077 }); 2078 2079 launcher.addEventListener('click', open); 2080 closeBtn.addEventListener('click', close); 2081 root.addEventListener('keydown', function (e) { 2082 if (e.key === 'Escape' && !panel.hidden) { close(); } 2083 }); 2084 2085 mountHost(host, { panel: panel, style: style, onEscape: close }); 2086 } 2087 2088 /** 2089 * Put the shadow host in the document. 2090 * 2091 * Every fixed position appends to <body> exactly as this widget always has â
2092 * the anchoring is pure CSS inside the shadow root, so the DOM parent does not 2093 * affect where it lands, and body keeps it clear of any host `overflow: hidden` 2094 * or transformed ancestor that would clip a fixed child. 2095 * 2096 * `inline` inserts immediately AFTER the <script> tag that loaded it, so the 2097 * launcher appears where the customer pasted the tag. That works with `defer` 2098 * because a deferred script still occupies its own place in the parsed DOM when 2099 * it runs â we never rely on document.currentScript, which IS null under defer. 2100 * 2101 * â ï¸ THE HOST ELEMENT IS DISPLAY:INLINE BY DEFAULT AND THAT MATTERS HERE. For 2102 * a corner position the host is an empty box holding a fixed child, so nobody 2103 * notices. Inline puts it in the page's flow, where an unstyled <div> host would 2104 * introduce a full-width block into a nav. Hence the inline-block on the host â 2105 * :host { all: initial } inside the shadow root cannot set this, because it 2106 * styles the shadow tree's view of the host, not the host's own flow. 2107 * 2108 * â ï¸ IN INLINE MODE THE CALL PANEL MOVES TO ITS OWN <body>-LEVEL SHADOW HOST, 2109 * AND THAT IS NOT OPTIONAL. `position: fixed` is viewport-relative ONLY while no 2110 * ancestor establishes a containing block, and `transform`, `filter`, 2111 * `backdrop-filter`, `perspective`, `will-change: transform` and `contain: paint` 2112 * all do â a precise description of the sticky, blurred, animated nav bars inline 2113 * mode exists to be dropped into. Left inside one, a 499px call panel would be 2114 * positioned against the nav rather than the viewport, clipped by its 2115 * `overflow: hidden`, and its z-index trapped in the nav's stacking context so any 2116 * host overlay would paint over it. (chat-widget.js has the same split; it is 2117 * cheaper there because its panel is a plain node, not a shadow-tree child.) 2118 * 2119 * Consequences of the split, each handled below: 2120 * · the second root needs its OWN copy of the stylesheet â shadow roots share 2121 * nothing; 2122 * · the panel's own text styles are all `font: ⦠inherit`, so it must stay 2123 * under a `.c2ai-root` to pick up the family. `display: contents` on that 2124 * wrapper keeps the inheritance while generating no box of its own (an 2125 * inline-flex wrapper would leave a stray line box at the end of <body>); 2126 * · the Escape handler is bound to the ORIGINAL shadow root, which the panel is 2127 * no longer inside, so the new root gets its own. 2128 */ 2129 function mountHost(host, parts) { 2130 parts = parts || {}; 2131 2132 if (position === 'inline' && hostScript && hostScript.parentNode) { 2133 host.style.display = 'inline-block'; 2134 host.style.verticalAlign = 'middle'; 2135 2136 if (parts.panel && parts.style) { 2137 detachPanel(parts.panel, parts.style, parts.onEscape); 2138 } 2139 2140 hostScript.parentNode.insertBefore(host, hostScript.nextSibling); 2141 2142 return; 2143 } 2144 2145 document.body.appendChild(host); 2146 } 2147 2148 function detachPanel(panel, styleEl, onEscape) { 2149 var panelHost = document.createElement('div'); 2150 panelHost.setAttribute('data-c2ai-voice-panel', ''); 2151 2152 var panelRoot = panelHost.attachShadow 2153 ? panelHost.attachShadow({ mode: 'open' }) 2154 : panelHost; 2155 2156 // Only clone the sheet into a REAL shadow root. Without attachShadow the 2157 // "root" is a plain div in the light DOM, and a second copy of a stylesheet 2158 // whose first rule is `*, *::before, *::after { box-sizing: border-box }` 2159 // would double an already-leaking sheet onto the customer's page. 2160 if (panelHost.attachShadow) { 2161 panelRoot.appendChild(styleEl.cloneNode(true)); 2162 } 2163 2164 var panelWrap = document.createElement('div'); 2165 panelWrap.className = 'c2ai-root'; 2166 panelWrap.style.display = 'contents'; 2167 panelWrap.appendChild(panel); 2168 panelRoot.appendChild(panelWrap); 2169 2170 if (typeof onEscape === 'function') { 2171 panelRoot.addEventListener('keydown', function (e) { 2172 if (e.key === 'Escape' && !panel.hidden) { onEscape(); } 2173 }); 2174 } 2175 2176 document.body.appendChild(panelHost); 2177 } 2178 2179 // ------------------------------------------------------------------------- 2180 // Shared validation + storage helpers. 2181 // ------------------------------------------------------------------------- 2182 function styleColor(c) { 2183 return (typeof c === 'string' && /^#[0-9a-f]{6}$/i.test(c)) ? c : BRAND; 2184 } 2185 2186 /** 2187 * Returns a contact object, null (proceed anonymously), or false (invalid â 2188 * an error was written into errEl and the caller must not proceed). 2189 */ 2190 function validateContact(rawEmail, rawPhone, errEl) { 2191 var email = (rawEmail || '').trim(); 2192 var phone = (rawPhone || '').trim(); 2193 var emailOk = email !== '' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); 2194 var phoneOk = phone.replace(/\D+/g, '').length >= 7; 2195 if (email !== '' && !emailOk) { 2196 if (errEl) { errEl.textContent = 'Please enter a valid email.'; } 2197 return false;
2198 } 2199 if (phone !== '' && !phoneOk) { 2200 if (errEl) { errEl.textContent = 'Please enter a valid phone number.'; } 2201 return false; 2202 } 2203 if (!emailOk && !phoneOk) { 2204 return null; 2205 } 2206 return { email: emailOk ? email : null, phone: phoneOk ? phone : null }; 2207 } 2208 2209 // ------------------------------------------------------------------------- 2210 // Pre-call identity gate â RETAINED FROM v1 for backwards compatibility. 2211 // 2212 // The embedded widget now renders the gate inside its own panel (renderGate 2213 // above), but this standalone popover is still exported: it is the published 2214 // window.C2AIVoice surface and any page driving startCall() itself can use 2215 // it. Both fields are OPTIONAL â "Start call" proceeds with whatever is 2216 // valid, "Skip" proceeds anonymously. Calls onProceed(contact|null). DOM is 2217 // built with textContent only (no innerHTML) so embedding sites cannot be 2218 // XSS'd through it. 2219 // 2220 // opts.color (string) primary-button colour (hex; falls back to brand) 2221 // opts.onSave (fn) optional â called with the contact when valid 2222 // ------------------------------------------------------------------------- 2223 /** 2224 * Where the standalone pre-call gate sits, mirroring the launcher's own corner 2225 * so it appears next to the thing the visitor just clicked. 90px is the launcher's 2226 * height plus a gap â measured from the SAME edge the launcher is pinned to, which 2227 * is why the top positions use `top: 90px` rather than keeping `bottom`. 2228 * 2229 * Inline is the exception and deliberately does not chase the launcher: an inline 2230 * launcher can be anywhere in the document (and can scroll away), so the gate goes 2231 * to the bottom-right corner, matching where the call panel itself opens. 2232 * 2233 * For a non-default position all four edges are set, plus the flow properties: 2234 * positionCss() has already written `.c2ai-root { display: flex; align-items: ⦠}` 2235 * into the same sheet, and the gate wrap carries BOTH classes â left alone, the 2236 * gate box (which has no width of its own) would shrink-wrap instead of filling 2237 * the popover. Leaving an edge unstated is just as bad: the .c2ai-root anchor 2238 * leaks through, and an element with both `top` and `bottom` resolved lands on 2239 * neither. 2240 * 2241 * â ï¸ THE DEFAULT ENTRY IS DELIBERATELY THE OLD TWO-PROPERTY RULE, not the 2242 * four-edge form. positionCss() emits nothing for bottom-right, so there is no 2243 * leak to defend against, and restating `left: auto` would flip the gate's mobile 2244 * anchor: the base `@media` rule sets `.c2ai-root { left: 16px }`, which the old 2245 * CSS let win. Same reason as everywhere else in this file â the default path has 2246 * to come out byte-identical. 2247 */ 2248 function soloAnchorCss(pos) { 2249 var RESET = 'display: block; align-items: initial; '; 2250 2251 var ANCHORS = { 2252 'bottom-right': 'bottom: 90px; right: 26px;', 2253 'bottom-left': RESET + 'top: auto; right: auto; bottom: 90px; left: 26px;', 2254 'bottom-center': RESET + 'top: auto; right: auto; bottom: 90px; left: 50%; transform: translateX(-50%);', 2255 'top-right': RESET + 'top: 90px; right: 26px; bottom: auto; left: auto;', 2256 'top-left': RESET + 'top: 90px; right: auto; bottom: auto; left: 26px;', 2257 // An inline launcher can be anywhere in the document and can scroll out 2258 // of view, so the gate does not chase it â it goes where the call panel 2259 // itself opens. (.c2ai-solo's own `position: fixed` already overrides the 2260 // `position: static` positionCss('inline') writes onto .c2ai-root; the 2261 // reset here is for the display/align-items it also writes.) 2262 'inline': RESET + 'top: auto; right: 26px; bottom: 90px; left: auto;', 2263 }; 2264 2265 return Object.prototype.hasOwnProperty.call(ANCHORS, pos) 2266 ? ANCHORS[pos] 2267 : ANCHORS['bottom-right']; 2268 } 2269
2270 function showIdentityGate(opts, onProceed) { 2271 opts = opts || {}; 2272 var color = styleColor(opts.color); 2273 2274 var host = document.createElement('div'); 2275 var root = host.attachShadow ? host.attachShadow({ mode: 'open' }) : host; 2276 var style = document.createElement('style'); 2277 // The anchor comes LAST so it beats the .c2ai-root rules positionCss() has 2278 // just written â the wrap carries both classes, and without this the gate 2279 // would inherit the launcher's corner offsets and then fight them (a top- 2280 // anchored root plus a bottom-anchored gate resolves to neither). 2281 style.textContent = styleSheet(color, null) 2282 + '\n.c2ai-solo { position: fixed; z-index: 2147483000; width: 340px;' 2283 + ' max-width: calc(100vw - 40px); border-radius: 18px; background: #FFFFFF;' 2284 + ' border: 1px solid rgba(107,114,128,0.16); padding-top: 4px;' 2285 + ' box-shadow: 0 26px 64px -22px rgba(10,16,34,.34); ' 2286 + soloAnchorCss(position) + ' }'; 2287 root.appendChild(style); 2288 2289 var wrap = document.createElement('div'); 2290 wrap.className = 'c2ai-root c2ai-solo'; 2291 root.appendChild(wrap); 2292 2293 var box = document.createElement('div'); 2294 box.className = 'c2ai-gate'; 2295 box.style.paddingTop = '18px'; 2296 wrap.appendChild(box); 2297 2298 var title = document.createElement('div'); 2299 title.className = 'c2ai-gate-title'; 2300 title.textContent = 'Before we connect, how can we reach you?'; 2301 2302 var email = document.createElement('input'); 2303 email.type = 'email'; 2304 email.className = 'c2ai-input'; 2305 email.placeholder = 'Email'; 2306 email.setAttribute('aria-label', 'Email'); 2307 email.maxLength = 255; 2308 2309 var phone = document.createElement('input'); 2310 phone.type = 'tel'; 2311 phone.className = 'c2ai-input'; 2312 phone.placeholder = 'Phone'; 2313 phone.setAttribute('aria-label', 'Phone'); 2314 phone.maxLength = 32; 2315 2316 var err = document.createElement('div'); 2317 err.className = 'c2ai-error'; 2318 err.setAttribute('aria-live', 'polite'); 2319 2320 var startBtn = document.createElement('button'); 2321 startBtn.type = 'button'; 2322 startBtn.className = 'c2ai-primary'; 2323 startBtn.textContent = 'Start call'; 2324 2325 var skipBtn = document.createElement('button'); 2326 skipBtn.type = 'button'; 2327 skipBtn.className = 'c2ai-skip'; 2328 skipBtn.textContent = 'Skip and talk anonymously'; 2329 2330 var done = false; 2331 function finish(contact) { 2332 if (done) { return; } 2333 done = true; 2334 if (host.parentNode) { host.parentNode.removeChild(host); } 2335 onProceed(contact); 2336 } 2337 2338 startBtn.addEventListener('click', function () { 2339 var contact = validateContact(email.value, phone.value, err); 2340 if (contact === false) { return; } 2341 if (contact && typeof opts.onSave === 'function') { opts.onSave(contact); } 2342 finish(contact); 2343 }); 2344 skipBtn.addEventListener('click', function () { finish(null); }); 2345 function onKey(e) { if (e.key === 'Enter') { e.preventDefault(); startBtn.click(); } } 2346 email.addEventListener('keydown', onKey); 2347 phone.addEventListener('keydown', onKey); 2348 2349 box.appendChild(title); 2350 box.appendChild(email); 2351 box.appendChild(phone); 2352 box.appendChild(err); 2353 box.appendChild(startBtn); 2354 box.appendChild(skipBtn); 2355 document.body.appendChild(host); 2356 setTimeout(function () { email.focus(); }, 50); 2357 } 2358 2359 // Public API for the dashboard test page (and advanced embedders). 2360 window.C2AIVoice = { 2361 startCall: startCall, 2362 loadSdk: loadSdk, 2363 loadOrb: loadOrb, 2364 mountPanel: mountWidget, 2365 showIdentityGate: showIdentityGate, 2366 analyserFor: analyserFor, 2367 streamFrom: streamFrom, 2368 // Added 2026-08-25 for chat-widget.js v2, which hosts the voice channel 2369 // in its own full-screen overlay and needs the SAME talk-state rule and 2370 // the same audio-level maths rather than a second copy of them. 2371 talkMeter: talkMeter, 2372 bandLevel: bandLevel, 2373 audioContext: audioContext, 2374 }; 2375 2376 // ------------------------------------------------------------------------- 2377 // Embeddable widget bootstrap â only runs when loaded with ?agent=UUID. 2378 // ------------------------------------------------------------------------- 2379 if (!agentKey) { 2380 return; // No embed key â the script was loaded only for window.C2AIVoice. 2381 } 2382 2383 // â ï¸ ONE STANDALONE WIDGET PER PAGE (QA 2026-08-25). 2384 // 2385 // resolveFromScript() scans the WHOLE document for 2386 // script[src*="voice-widget.js"], so chat-widget.js's "keyless" injection is 2387 // not keyless when a legacy standalone voice tag is also on the page â this 2388 // file's second execution finds THAT tag's ?agent= and would mount a second 2389 // launcher next to the first. (chat-widget.js normally short-circuits on 2390 // window.C2AIVoice, so this only fires if the standalone tag failed to 2391 // execute; the guard costs one boolean.) 2392 if (window.__c2aiVoiceBooted) { 2393 return; 2394 } 2395 window.__c2aiVoiceBooted = true; 2396 2397 var API_BASE = (apiOrigin || BASE_URL) + '/api/v1/voice/widget/'; 2398 2399 // ------------------------------------------------------------------------- 2400 // Persistent (localStorage) caller identity â mirrors chat-widget.js so a 2401 // returning visitor reattaches to the same cross-channel memory and skips 2402 // the pre-call form. A WebRTC leg carries no phone number, so this form is 2403 // the only way the call can be keyed into memory. Identity is OPTIONAL â 2404 // visitors can skip and still talk (the call is then anonymous). 2405 // ------------------------------------------------------------------------- 2406 var CONTACT_STORAGE_KEY = 'c2ai_contact_' + agentKey; 2407 2408 function loadStoredContact() { 2409 try { 2410 var parsed = JSON.parse(localStorage.getItem(CONTACT_STORAGE_KEY) || 'null'); 2411 if (parsed && (parsed.email || parsed.phone)) { 2412 return { email: parsed.email || null, phone: parsed.phone || null }; 2413 } 2414 } catch (e) { /* ignore */ } 2415 return null; 2416 } 2417 2418 function saveContact(contact) { 2419 try { localStorage.setItem(CONTACT_STORAGE_KEY, JSON.stringify(contact)); } catch (e) { /* ignore */ } 2420 } 2421
2422 function init() { 2423 fetch(API_BASE + agentKey + '/config' + (LEGACY_LOADER ? '?loader=legacy' : ''), { headers: { 'Accept': 'application/json' } }) 2424 .then(function (r) { return r.ok ? r.json() : Promise.reject(new Error('config ' + r.status)); }) 2425 .then(function (cfg) { 2426 mountWidget(cfg, { 2427 tokenOpts: function () { return { tokenUrl: API_BASE + agentKey + '/token' }; }, 2428 storage: { load: loadStoredContact, save: saveContact }, 2429 }); 2430 }) 2431 .catch(function (e) { console.warn('[C2AI voice] widget failed to load:', e.message); }); 2432 } 2433 2434 if (document.readyState === 'loading') { 2435 document.addEventListener('DOMContentLoaded', init); 2436 } else { 2437 init(); 2438 } 2439})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.