PageSourceSearch

https://customer2.ai/js/voice-widget.js?agent=EMBED_KEY

js customer2.ai collected 2026-10-05 11:56:54 UTC 121,661 bytes, 2,439 lines download raw bytes

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.