PageSourceSearch

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

js customer2.ai collected 2026-10-05 11:56:51 UTC 207,752 bytes, 3,848 lines download raw bytes

1/**
2 * C2AI Chat Widget  v2.3.0
3 *
4 * v2.3.0 — SSE token streaming: replies render token-by-token via the new
5 *   /messages/stream endpoint (streamSend), falling back to the async poll path on
6 *   any transport failure or a JSON response (cached reply / demo cap / hold /
7 *   over-capacity). Old cached builds keep using the async path unchanged.
8 *
9 * v2.2.0 — entry auto-starts an instant chat (no "Chat Instantly / Account Support"
10 *   choice); a small "previous chat?" link above the composer opens the identity form
11 *   to recall a prior account conversation. Instant-start is deferred to first panel
12 *   open so a visitor who never opens the chat never creates a session.
13 *
14 * v2.1.0 — persistent anonymous sessions + Close/Minimize, current-page awareness,
15 *   and a theme-aware (light/dark) custom-orb-image backdrop. Bump kept in lockstep
16 *   with WidgetEmbed::CHAT_VERSION so the ?v= cache-bust serves this build.
17 *
18 * Self-contained embeddable chat + browser-voice widget. No external
19 * dependencies. Embed on any site with a single script tag:
20 *
21 *   <script src="https://customer2.ai/js/chat-widget.js?agent=EMBED_KEY" defer></script>
22 *
23 * EMBED_KEY is the per-agent UUID from the agent's Widget tab in the customer
24 * dashboard. It may be the agent's chat key OR its voice key — the server
25 * resolves either (ChatWidgetController::resolveAgent) and its /config response
26 * says which channels to build, so a voice-only agent keeps the UUID it has
27 * already pasted.
28 *
29 * ---------------------------------------------------------------------------
30 * v2.0.0 — TWO CHANNELS, ONE EMBED. WHAT CHANGED AND WHY
31 * ---------------------------------------------------------------------------
32 * Chat and browser-voice used to be two unrelated embeds: two launchers, two
33 * panels, two /config endpoints. A visitor who wanted to stop typing and start
34 * talking had no path between them. v2 ports the approved Claude Design mock
35 * ("Chat Widget.dc.html"): a launcher PILL carrying the agent orb, a light 394px
36 * chat panel whose header holds a "Voice Chat" button, and a full-screen voice
37 * overlay behind it.
38 *
39 * ⚠️ TWO RENDERERS LIVE IN THIS FILE, AND THAT IS THE ROLLBACK.
40 * This script is served from a CONSTANT path (`?v=` is only a cache-bust), so a
41 * tag already pasted on a customer site picks up whatever we deploy — there is
42 * no per-customer opt-in and no way to recall a copy. The server therefore sends
43 * a `render` field on /config, driven by the `chat.widget_v2_enabled` setting in
44 * the admin Environment Settings panel:
45 *
46 *   render === 'v2'  → the design below (default)
47 *   render === 'v1'  → the pre-2026-08-25 chat-only design, unchanged
48 *
49 * Flipping that toggle reverts every live embed with NO deploy and NO change to
50 * the pasted tag. The v1 path is kept for exactly that reason, not for tidiness.
51 *
52 * 🗓️ SUNSET: delete the v1 renderer once v2 has run clean in production for 30
53 * days (i.e. on or after 2026-09-24), then drop `chat.widget_v2_enabled` from
54 * SettingsRegistry. If you are reading this after that date and v1 is still
55 * here, removing it IS the task.
56 *
57 * ---------------------------------------------------------------------------
58 * HOW THE TWO RENDERERS COEXIST WITHOUT DRIFTING
59 * ---------------------------------------------------------------------------
60 * Everything that could bite us if it diverged is SHARED, verbatim, by both:
61 * the transport (jsonPost/jsonGet), the stored identity (session / contact /
62 * remember-token / consent), the session lifecycle, the pre-chat gate + OTP
63 * flow, the live-interrupt poll, and the whole coalescing/idempotent/async turn
64 * machinery. None of that is duplicated — a fix lands once.
65 *
66 * The renderers differ in exactly four places, and only these:
67 *   1. THE STYLESHEET            — stylesV1() / stylesV2()
68 *   2. THE CHROME                — chromeV1() / chromeV2() (launcher, header,
69 *                                  composer wrapper, footer)
70 *   3. THE MOUNT TARGET          — v1 injects into document.head + document.body
71 *                                  with #c2ai-* ids; v2 renders inside ONE SHADOW
72 *                                  ROOT (see below)
73 *   4. THE VOICE CHANNEL         — v2 only
74 *
75 * That works because the shared layer talks to the DOM only through the
76 * module-level element handles (messagesEl / typingEl / inputEl / sendBtn /
77 * gateEl / resetEl) and through semantic `#c2ai-*` / `.c2ai-*` names. v2
78 * populates the SAME handles with its own shadow nodes and reuses the SAME
79 * names with a different stylesheet, so the message builders, the gate markup
80 * and the typing indicator are literally the same code in both designs.
81 *
82 * ⚠️ Corollary for anyone editing: do NOT reach for document.getElementById or
83 * document.querySelector in shared code. In v2 the nodes are not in the
84 * document. Use the module-level handles, styleTarget(), or byId().
85 *
86 * ---------------------------------------------------------------------------
87 * FOUR MORE THINGS WORTH KNOWING
88 * ---------------------------------------------------------------------------
89 * 1. V2 RENDERS IN A SHADOW ROOT. v1 injected into document.body with #c2ai-*
90 *    ids and a <style> in document.head. On a third-party site that is a coin
91 *    flip — the host's own `button {}` / `div {}` / `input {}` rules bleed
92 *    straight in, and the mock's hairlines, 18px radii and pill composer are not
93 *    survivable that way. v2's markup lives under one shadow host, so the host
94 *    page's CSS cannot reach it and ours cannot leak out. Same decision
95 *    voice-widget.js v3 made. Corollary: no rule in the v2 sheet can be
96 *    overridden from the embedding site, which is intentional.
97 *
98 * 2. MATERIAL SYMBOLS → INLINE SVG. The mock draws its glyphs with the Material
99 *    Symbols webfont. We do not inject a webfont onto someone else's page (an
100 *    extra third-party request plus a CSP hazard on strict embedding sites), so
101 *    every glyph here is an inline SVG path. Same deviation, same reason, as
102 *    voice-widget.js.
103 *
104 * 3. THE COMPOSER STAYS A <textarea>
104. The mock draws a single-line <input>. A
105 *    textarea is what gives us auto-grow, Enter-to-send and Shift+Enter for a
106 *    newline; dropping that would be a functional regression dressed as a style
107 *    change. It is styled to look exactly like the mock's pill.
108 *
109 * 4. THE ORB IS THERE FROM THE START, AND NOTHING IS EVER SWAPPED. <agent-orb>
110 *    is mounted into the launcher on the first paint — an undefined custom
111 *    element is inert but legal, and the browser upgrades it IN PLACE the moment
112 *    agent-orb.js defines it. Until that lands, the clip's ::before shows the
113 *    orb's own colour, so the slot is never an empty hole and nothing visibly
114 *    changes shape.
115 *
116 *    This was LAZY at first (a gradient disc, swapped for the real orb on first
117 *    panel open). It worked, but the swap happened under the visitor's cursor
118 *    seconds after load and read as a glitch, so it was reverted (Steven,
119 *    2026-08-25).
120 *
121 *    ⚠️ THE COST IS REAL AND DELIBERATE: three.js + agent-orb.js are ~590 KB,
122 *    now fetched on EVERY page view of a customer's site whether or not the
123 *    visitor ever opens the widget. Do not "optimise" this back to lazy without
124 *    talking to Steven — the whole point is that the orb is not late.
125 */
126(function () {
127    'use strict';
128
129    // -------------------------------------------------------------------------
130    // Config — resolved from the script's own src so the widget works in
131    // preview environments without a hard-coded production URL.
132    // -------------------------------------------------------------------------
133
134    var BASE_URL = 'https://customer2.ai'; // fallback
135    var agentKey = null;
136    var hostScript = null;   // the <script> tag that carried the key — see POSITION
137    var position = 'bottom-right';
138
139    // Optional `data-api-origin` override — the origin to broker /config, the
140    // session endpoints and the voice /token against, when that is NOT the
141    // origin this script was served from.
142    //
143    // Why it exists: a preview app cannot mint a voice token (the voiceserver
144    // relay resolves agents against PRODUCTION by id, and the preview DB has no
145    // voice credentials), so the only way to exercise a real call from a preview
146    // build is to point the widget's API at production. Mirrors
147    // voice-widget.js's resolveApiOrigin(). Absent on every customer embed.
148    var apiOrigin = null;
149
150    // Optional `data-extra-css` — extra CSS appended to the END of the widget's
151    // own stylesheet, so a host integration can override our positioning.
152    //
153    // ⚠️ THIS EXISTS BECAUSE v2 RENDERS IN A SHADOW ROOT. A host page used to be
154    // able to move the launcher by injecting `#c2ai-widget{…!important}` into
155    // document.head — that is how public/js/shopify-storefront.js implements its
156    // "mid" (vertical-centre) placement option. Document-level CSS cannot cross a
157    // shadow boundary no matter how many !importants it carries, so that override
158    // silently stopped working for v2 and every merchant who picked it snapped
159    // back to the corner. This is the sanctioned replacement.
160    //
161    // Not a privilege boundary: whoever writes this attribute already controls
162    // the page and could load any script they liked. It is only ever authored by
163    // our own first-party integrations.
164    var extraCss = '';
165
166    // Which renderer /config told us to use, and the shadow root v2 lives in.
167    // `shadow` stays null in v1 — styleTarget()/byId() both key off that.
168    var renderMode = 'v2';
169    var shadow = null;
170    // Normally the same root as `shadow`. Inline mode splits the launcher into
171    // its own root at the <script> tag's position — see attachShadowRoots().
172    var shadowLauncher = null;
173
174    // -------------------------------------------------------------------------
175    // POSITION
176    //
177    // Read off `data-position` on the SAME <script> tag that carried the embed
178    // key. Five fixed corners plus `inline`, which mounts the launcher where the
179    // tag itself sits so it can live in a nav or a hero like an ordinary button.
180    //
181    // ⚠️ ABSENT MUST BEHAVE EXACTLY LIKE 'bottom-right'. This file is pasted on
182    // customer sites and no copy of it can ever be recalled, so every tag already
183    // out there — all of which predate this attribute — has to keep rendering
184    // where it renders today. That is why the default is not merely the first
185    // entry in the list: it is the code path that runs when the attribute is
186    // missing, and positionCss() returns NOTHING for it, leaving the original
187    // bottom/right rule in the base stylesheet untouched.
188    //
189    // Keep POSITIONS in sync with App\Agents\WidgetEmbed::POSITIONS.
190    // -------------------------------------------------------------------------
191    // OP #2210 — customer2ai.com is being retired as a public domain (it becomes
192    // staging). Embeds pasted with the old <script src> must keep working, so a
193    // legacy origin is mapped to customer2.ai for EVERY API call and asset, and
194    // LEGACY_LOADER tags the first /config request (?loader=legacy) so we can
195    // see which sites still need their embed code updated.
196    var LEGACY_HOSTS = { 'customer2ai.com': 1, 'www.customer2ai.com': 1 };
197    var LEGACY_LOADER = false;
198    // Never remap when the PAGE itself is on a legacy host: that is staging
199    // (or the office on the old domain), whose widget must keep calling itself
200    // — never production.
201    var ON_LEGACY_PAGE = !!LEGACY_HOSTS[(location.hostname || '').toLowerCase()];
202    function canonicalOrigin(o) {
203        if (!o || ON_LEGACY_PAGE) { return o; }
204        var h = o.replace(/^https?:\/\//i, '').replace(/[\/:].*$/, '').toLowerCase();
205        if (LEGACY_HOSTS[h]) { LEGACY_LOADER = true; return 'https://customer2.ai'; }
206        return o;
207    }
208    var POSITIONS = ['bottom-right', 'bottom-left', 'bottom-center', 'top-right', 'top-left', 'inline'];
209
210    (function resolveFromScript() {
211        var scripts = document.querySelectorAll('script[src*="chat-widget.js"]');
212        for (var i = 0; i < scripts.length; i++) {
213            var src = scripts[i].getAttribute('src') || '';
214            var origin = src.match(/^(https?:\/\/[^\/]+)/i);
215            if (origin) {
216                BASE_URL = canonicalOrigin(origin[1]);
217            }
218            var key = src.match(/[?&]agent=([0-9a-f\-]{36})/i);
219            if (key) {
220                agentKey = key[1];
221            }
222            if (agentKey) {
223                // Only honour attributes from the tag that carried the key — a page
224                // with two embeds must not have one widget read the other's options.
225                hostScript = scripts[i];
226                position = resolvePosition(scripts[i]);
227                apiOrigin = canonicalOrigin(resolveApiOrigin(scripts[i]));
228                extraCss = scripts[i].getAttribute('data-extra-css') || '';
229                break;
230            }
231        }
232        if (!agentKey) {
233            console.warn('[C2AI] chat-widget.js: no ?agent=UUID found in script src. Widget will not load.');
234        }
235    })();
236
237    /**
238     * `data-api-origin` — an https origin to broker the API against instead of
239     * the one this script came from. See the declaration of apiOrigin above.
240     *
241     * Shape-validated only, and deliberately so: this attribute is authored by
242     * whoever writes the page, and a page that can set it can already load any
243     * script it likes, so there is no privilege to protect HERE. The real gate
244     * is server-side — the first-party page that offers this as a query param
245     * (VoiceWidgetController::embedDemoPage) allow-lists the value against our
246     * own deployments before it ever reaches the markup.
247     *
248     * http is rejected outright: a mixed-content downgrade on a page that is
249     * about to ask for microphone permission is not a trade worth making.
250     */
251    function resolveApiOrigin(tag) {
252        var raw = (tag.getAttribute('data-api-origin') || '').trim();
253
254        if (raw === '') {
255            return null;
256        }
257
258        if (!/^https:\/\/[a-z0-9.-]+(:\d+)?$/i.test(raw)) {
259            console.warn('[C2AI] chat-widget.js: ignoring malformed data-api-origin "' + raw + '".');
260
261            return null;
262        }
263
264        return raw.replace(/\/+$/, '');
265    }
266
267    function resolvePosition(tag) {
268        var raw = (tag.getAttribute('data-position') || '').trim().toLowerCase();
269
270        if (raw === '') {
271            return 'bottom-right';
272        }
273
274        if (POSITIONS.indexOf(raw) === -1) {
275            console.warn('[C2AI] chat-widget.js: unknown data-position "' + raw + '". Using bottom-right.');
276
277            return 'bottom-right';
278        }
279
280        // Inline needs somewhere in the body to sit. A tag in <head> has no
281        // rendered position of its own, so honouring "inline" there would mount an
282        // invisible widget — fall back to the corner and say why.
283        if (raw === 'inline' && ! isInlineMountable(tag)) {
284            console.warn('[C2AI] chat-widget.js: data-position="inline" needs the <script> tag to sit in the <body> where you want the widget. Using bottom-right.');
285
286            return 'bottom-right';
287        }
288
289        return raw;
290    }
291
292    function isInlineMountable(tag) {
293        if (! tag || ! tag.parentNode) {
294            return false;
295        }
296
297        for (var node = tag.parentNode; node; node = node.parentNode) {
298            if (node.nodeName === 'HEAD') {
299                return false;
300            }
301            if (node.nodeName === 'BODY') {
302                return true;
303            }
304        }
305
306        return false;
307    }
308
309    if (!agentKey) return;
310
311    // apiOrigin wins when the page supplied one (see resolveApiOrigin), else the
312    // origin this script was served from — so a customer embed is unchanged.
313    var API_ROOT = apiOrigin || BASE_URL;
314    var API_BASE = API_ROOT + '/api/v1/chat/widget/';
315    // The voice channel brokers its token from the sibling voice-widget API,
316    // keyed by the voice_embed_key that /config hands over (only when a call is
317    // actually placeable from this origin — see ChatWidgetController::config).
318    var VOICE_API_BASE = API_ROOT + '/api/v1/voice/widget/';
319    // Lazily-injected shared assets, all served from THIS script's origin (not
320    // apiOrigin — the API may be brokered elsewhere but the code must not be).
321    // The voice ENGINE. Loaded keyless (no ?agent=), which short-circuits that
322    // file's own bootstrap and leaves only window.C2AIVoice — so we reuse its
323    // token brokering, refresh, mute and teardown instead of copying
324    // billing-adjacent code here.
325    // ⚠️ `?v=` must match App\Agents\WidgetEmbed::VOICE_VERSION: a visitor may
326    // already hold a cached voice-widget.js from a standalone embed, and the
327    // build before 3.2.0 does not export talkMeter().
328    var VOICE_SCRIPT_URL = BASE_URL + '/js/voice-widget.js?v=3.2.0';
329    // ONE tag is enough: agent-orb.js resolves three.js relative to its OWN src
330    // (see its THREE_SRC note), precisely so a root-absolute path can't 404
331    // against a customer's domain. So we must load it from an ABSOLUTE url and
332    // then leave three.js to it.
333    var ORB_URL = BASE_URL + '/js/agent-orb.js?v=2';
334    var MAX_RETRIES = 2;
335    // Coalescing window (ms): messages the visitor sends in quick succession
336    // (e.g. a phone number split across a few sends) are buffered and posted as
337    // ONE turn instead of a reply per fragment. Set to 0 to disable (each send
338    // posts on its own). Kept modest so a single message isn't sluggish.
339    var COALESCE_MS = 800;
340
341    // SSE token streaming. STREAM_SUPPORTED gates on the fetch + streams APIs; when
342    // absent (older browsers) the widget uses the async poll path unchanged. Set
343    // STREAM_ENABLED=false to force the async path for everyone (client kill-switch).
344    var STREAM_ENABLED   = true;
345    var STREAM_SUPPORTED = (typeof window.fetch === 'function'
346        && typeof window.ReadableStream === 'function'
347        && typeof window.TextDecoder === 'function');
348    // Live-interrupt poll cadence (ms): while a session is active AND the panel
349    // is open, poll the read-only messages/since endpoint for assistant/agent
350    // messages appended out-of-band (e.g. by a background job). Zero deps, raw
351    // XHR — no websockets. Cheap: the server no-ops when the feature is off.
352    var POLL_MS = 6000;
353    // Async-turn result polling. 900ms keeps a normal reply feeling immediate
354    // (vs. waiting up to POLL_MS for the live-poll); TURN_POLL_TIMEOUT_MS is above
355    // the job's 250s timeout so we only fall back to the live-poll if the job
356    // really did outlive its own deadline.
357    // Backoff schedule (ms since the turn started -> poll interval). Most replies
358    // land in the first few seconds, so poll fast early and decay hard after.
359    // A flat 900ms would have issued ~290 requests over the full window and eaten
360    // the visitor's entire rate-limit budget; this issues ~40.
361    var TURN_POLL_STEPS = [
362        { untilMs: 4000,   everyMs: 400  },   // 0-4s    — tight: catch a fast turn within ~400ms of done
363        { untilMs: 12000,  everyMs: 900  },   // 4-12s   — most turns land here
364        { untilMs: 30000,  everyMs: 2000 },   // 12-30s
365        { untilMs: 90000,  everyMs: 5000 },   // 30-90s
366        { untilMs: 260000, everyMs: 10000 }   // 90s+    — long tail / ai2 overflow
367    ];
368    // Sizing: chat-turn-poll allows 180/min per session (~333ms floor). The tight
369    // 400ms lane runs only for the first 4s (~10 polls) then ramps, so a turn's whole
370    // poll stream stays well under the cap even across several quick messages.
371    var TURN_POLL_TIMEOUT_MS = 260000;
372    // Throttled (429) or opaque/CORS-blocked failure (status 0): back off hard.
373    var TURN_POLL_BACKOFF_MS = 15000;
374
375    function turnPollInterval(waitedMs) {
376        for (var i = 0; i < TURN_POLL_STEPS.length; i++) {
377            if (waitedMs < TURN_POLL_STEPS[i].untilMs) { return TURN_POLL_STEPS[i].everyMs; }
378        }
379        return TURN_POLL_STEPS[TURN_POLL_STEPS.length - 1].everyMs;
380    }
381    var SESSION_KEY_PREFIX = 'c2ai_session_';
382    var CONTACT_KEY_PREFIX = 'c2ai_contact_';
383    var VERIFY_KEY_PREFIX  = 'c2ai_verify_';
384    var CONSENT_KEY_PREFIX = 'c2ai_consent_';
385
386    // -------------------------------------------------------------------------
387    // State
388    // -------------------------------------------------------------------------
389
390    var state = {
391        config: null,
392        quickPrompts: [],      // sanitized [{label, message}] — see showWelcome()
393        sessionId: null,
394        sessionUuid: null,
395        isOpen: false,
396        isLoading: false,
397        retryCount: 0,
398        pendingParts: [],      // buffered sends awaiting the coalesce flush
399        coalesceTimer: null,   // debounce handle for the pending flush
400        turnGen: 0,            // bumped on reset; in-flight callbacks bail if it changed
401        contact: null,
402        verified: false,       // this session may recall cross-channel memory
403        rememberToken: null,   // remember-this-device token (localStorage)
404        consented: false,      // agreed to the SMS + Email policies
405        entryMode: null,       // 'instant' | 'support' — how the visitor entered (v2), sent to the server as `mode`
406        turnstileToken: null,  // Cloudflare Turnstile token from the pre-chat gate (website agent)
407        turnstileWidgetId: null, // live Turnstile instance, so it can be released on gate rebuild
408        lastSeenId: 0,         // highest chat_message id already rendered (live-poll cursor)
409        pollTimer: null,       // setInterval handle for the live-interrupt poll
410    };
411
412    var SESSION_STORAGE_KEY = SESSION_KEY_PREFIX + agentKey;
413    var CONTACT_STORAGE_KEY = CONTACT_KEY_PREFIX + agentKey;
414    var VERIFY_STORAGE_KEY  = VERIFY_KEY_PREFIX + agentKey;
415    var CONSENT_STORAGE_KEY = CONSENT_KEY_PREFIX + agentKey;
416
417    // Bump when the stored-session shape or the resume behaviour changes. A stored
418    // payload whose version does not match is DISCARDED on load, so a browser holding
419    // an old widget's data can never resume it against new code (cache-busting).
420    var STORAGE_VERSION = 2;
421
422    // Persist the session in localStorage so the chat + history survive page
423    // navigations, reloads, and return visits — until the visitor clicks Close. (Was
424    // sessionStorage, which died on tab close and lost history across pages.)
425    function loadStoredSession() {
426        try {
427            // One-time cleanup of the pre-v2 sessionStorage key so a stale in-tab
428            // session can't shadow the localStorage one.
429            sessionStorage.removeItem(SESSION_STORAGE_KEY);
430        } catch (e) { /* ignore */ }
431        try {
432            var raw = localStorage.getItem(SESSION_STORAGE_KEY);
433            if (!raw) { return; }
434            var parsed = JSON.parse(raw);
435            if (!parsed || parsed.v !== STORAGE_VERSION) {
436                // Missing/old version → discard rather than resume against new code.
437                localStorage.removeItem(SESSION_STORAGE_KEY);
438                return;
439            }
440            if (parsed.sessionId && parsed.sessionUuid) {
441                state.sessionId   = parsed.sessionId;
442                state.sessionUuid = parsed.sessionUuid;
443                // Remembered panel state so a chat that was OPEN on the prior page
444                // re-opens on the next one; a minimized chat stays minimized.
445                state._wasOpen = !!parsed.open;
446                // Only a session RESUMED from storage has history to repaint. A
447                // freshly-created (same-page) session renders its turns live, so
448                // renderHistory must NOT run for it (or a minimize→reopen would
449                // re-append the transcript already in the DOM). See renderHistory.
450                state._resumedFromStorage = true;
451            }
452        } catch (e) { /* ignore */ }
453    }
454
455    function saveSession() {
456        try {
457            localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify({
458                v:           STORAGE_VERSION,
459                sessionId:   state.sessionId,
460                sessionUuid: state.sessionUuid,
461                open:        !!state.isOpen,
462            }));
463        } catch (e) { /* ignore */ }
464    }
465
466    function clearSession() {
467        try { localStorage.removeItem(SESSION_STORAGE_KEY); } catch (e) { /* ignore */ }
468        try { sessionStorage.removeItem(SESSION_STORAGE_KEY); } catch (e) { /* ignore */ }
469        state.sessionId   = null;
470        state.sessionUuid = null;
471    }
472
473    // Persistent (localStorage) contact identity — remembers a returning visitor
474    // across visits so their chat reattaches to the same cross-channel memory.
475    function loadStoredContact() {
476        try {
477            var raw = localStorage.getItem(CONTACT_STORAGE_KEY);
478            if (raw) {
479                var parsed = JSON.parse(raw);
480                if (parsed && (parsed.email || parsed.phone)) {
481                    state.contact = {
482                        name:  parsed.name  || null,
483                        // Absent for a visitor remembered from before v2 asked for
484                        // them, and for v1 — ensureSession simply omits them then.
485                        first_name: parsed.first_name || null,
486                        last_name:  parsed.last_name  || null,
487                        email: parsed.email || null,
488                        phone: parsed.phone || null
489                    };
490                }
491            }
492        } catch (e) { /* ignore */ }
493    }
494
495    function saveContact(contact) {
496        state.contact = contact;
497        try { localStorage.setItem(CONTACT_STORAGE_KEY, JSON.stringify(contact)); } catch (e) { /* ignore */ }
498    }
499
500    // Remember-this-device verification token, issued by the server after a
501    // successful code check. Replayed on startSession so a return visit within
502    // the token's TTL recalls cross-channel memory without re-entering a code.
503    // The server binds it to the exact identity, so it can't be replayed for a
504    // different email/phone.
505    function loadStoredVerifyToken() {
506        try {
507            var raw = localStorage.getItem(VERIFY_STORAGE_KEY);
508            if (raw) { state.rememberToken = raw; }
509        } catch (e) { /* ignore */ }
510    }
511
512    function saveVerifyToken(token) {
513        state.rememberToken = token || null;
514        try {
515            if (token) { localStorage.setItem(VERIFY_STORAGE_KEY, token); }
516            else { localStorage.removeItem(VERIFY_STORAGE_KEY); }
517        } catch (e) { /* ignore */ }
518    }
519
520    // Policy consent (SMS + Email). Remembered so a returning visitor who has
521    // already agreed isn't re-prompted; the server still re-records it on each
522    // session for an auditable proof-of-consent trail.
523    function loadStoredConsent() {
524        try { if (localStorage.getItem(CONSENT_STORAGE_KEY) === '1') { state.consented = true; } } catch (e) { /* ignore */ }
525    }
526
527    function saveConsent(agreed) {
528        state.consented = !!agreed;
529        try {
530            if (agreed) { localStorage.setItem(CONSENT_STORAGE_KEY, '1'); }
531            else { localStorage.removeItem(CONSENT_STORAGE_KEY); }
532        } catch (e) { /* ignore */ }
533    }
534
535    // -------------------------------------------------------------------------
536    // API helpers
537    // -------------------------------------------------------------------------
538
539    function apiUrl(path) {
540        return API_BASE + agentKey + '/' + path;
541    }
542
543    function jsonPost(url, body, callback) {
544        var xhr = new XMLHttpRequest();
545        xhr.open('POST', url, true);
546        xhr.setRequestHeader('Content-Type', 'application/json');
547        xhr.setRequestHeader('Accept', 'application/json');
548        xhr.timeout = 65000;
549        xhr.onload = function () {
550            try {
551                var data = JSON.parse(xhr.responseText);
552                if (xhr.status >= 200 && xhr.status < 300) {
553                    callback(null, data);
554                } else {
555                    callback(data.error || 'Request failed (' + xhr.status + ')', null);
556                }
557            } catch (e) {
558                callback('Invalid response from server.', null);
559            }
560        };
561        xhr.onerror = xhr.ontimeout = function () {
562            callback('Network error or timeout. Please try again.', null);
563        };
564        xhr.send(JSON.stringify(body));
565        return xhr;
566    }
567
568    function jsonGet(url, callback) {
569        var xhr = new XMLHttpRequest();
570        xhr.open('GET', url, true);
571        xhr.setRequestHeader('Accept', 'application/json');
572        xhr.timeout = 10000;
573        xhr.onload = function () {
574            try {
575                var data = JSON.parse(xhr.responseText);
576                if (xhr.status >= 200 && xhr.status < 300) {
577                    callback(null, data, xhr.status);
578                } else {
579                    // 3rd arg added 2026-08-07: pollTurn must be able to SEE a 429.
580                    // Without it every non-2xx collapsed to one opaque error and the
581                    // poller kept hammering a throttled endpoint. Existing callers
582                    // take (err, data) and ignore the extra argument.
583                    callback('Agent not available.', null, xhr.status);
584                }
585            } catch (e) {
586                callback('Invalid configuration response.', null, xhr.status);
587            }
588        };
589        xhr.onerror = xhr.ontimeout = function () { callback('Network error.', null, 0); };
590        xhr.send();
591        return xhr;
592    }
593
594    // -------------------------------------------------------------------------
595    // DOM helpers
596    //
597    // styleTarget() and byId() are the ONLY sanctioned ways for shared code to
598    // reach the widget's container. In v2 the markup lives inside a shadow root
599    // and is NOT in the document, so document.getElementById / querySelector
600    // find nothing — a v1 habit that fails silently rather than loudly. Both
601    // return document-level nodes in v1, so v1's behaviour is unchanged.
602    //
603    // (Everything that needs the MOUNT target goes through mountWidget, which
604    // owns the inline-mode split — so there is deliberately no root() helper.)
605    // -------------------------------------------------------------------------
606
607    /** Where the widget's <style> goes (shadow root in v2, <head> in v1). */
608    function styleTarget() {
609        return shadow || document.head;
610    }
611
612    /** Scoped lookup — never document.getElementById in shared code. */
613    function byId(id) {
614        return shadow ? shadow.getElementById(id) : document.getElementById(id);
615    }
616
617    function el(tag, attrs, children) {
618        var node = document.createElement(tag);
619        if (attrs) {
620            for (var k in attrs) {
621                if (!Object.prototype.hasOwnProperty.call(attrs, k)) continue;
622                if (k === 'class') node.className = attrs[k];
623                else if (k === 'style') node.style.cssText = attrs[k];
624                else node.setAttribute(k, attrs[k]);
625                // Note: no 'html' key — use DOM construction (appendBotMessage)
626                // to avoid innerHTML sinks.
627            }
628        }
629        if (children) {
630            for (var i = 0; i < children.length; i++) {
631                var c = children[i];
632                if (typeof c === 'string') node.appendChild(document.createTextNode(c));
633                else if (c) node.appendChild(c);
634            }
635        }
636        return node;
637    }
638
639    // -------------------------------------------------------------------------
640    // Message formatting — returns a DocumentFragment; no innerHTML used.
641    // Supports **bold** and newline → <br>.
642    // -------------------------------------------------------------------------
643
644    function formatMessageFragment(text) {
645        var fragment = document.createDocumentFragment();
646        var lines = String(text).split('\n');
647        for (var li = 0; li < lines.length; li++) {
648            if (li > 0) fragment.appendChild(document.createElement('br'));
649            // Split on **bold** markers — odd-indexed parts are bold.
650            var parts = lines[li].split(/\*\*(.+?)\*\*/g);
651            for (var pi = 0; pi < parts.length; pi++) {
652                if (parts[pi] === '') continue;
653                if (pi % 2 === 1) {
654                    var strong = document.createElement('strong');
655                    strong.appendChild(document.createTextNode(parts[pi]));
656                    fragment.appendChild(strong);
657                } else {
658                    fragment.appendChild(document.createTextNode(parts[pi]));
659                }
660            }
661        }
662        return fragment;
663    }
664
665    // -------------------------------------------------------------------------
666    // Styles — injected once; primaryColor is validated #rrggbb before use.
667    // -------------------------------------------------------------------------
668
669    /**
670     * Overrides that move the widget off its default corner.
671     *
672     * ⚠️ RETURNS AN EMPTY STRING FOR 'bottom-right' ON PURPOSE. The base sheet
673     * below IS bottom-right, so the default — which is also what every tag pasted
674     * before this feature existed resolves to — emits no override at all and the
675     * rendered CSS is byte-identical to the pre-2026-08-19 widget. Do not "tidy"
676     * this into a table with a bottom-right row.
677     *
678     * These are appended AFTER the base sheet, including after its mobile media
679     * query, so each position must restate its own mobile rules — otherwise the
680     * base `@media (max-width: 430px)` block would drag the widget back to the
681     * right edge on phones. Equal specificity, later wins.
682     *
683     * Every rule resets the axis it is not using (`right: auto`, `top: auto`, …).
684     * Setting only `left` would leave the base `right: 24px` in force and stretch
685     * the element across the viewport.
686     */
687    function positionCss(pos) {
688        if (pos === 'bottom-right') {
689            return '';
690        }
691
692        // INLINE: the launcher sits in the page like a button, but the PANEL still
693        // opens bottom-right (Steven, 2026-08-19). A 360px conversation panel
694        // expanding out of a nav bar wrecks the header it was put in; anchoring it
695        // to the viewport keeps the host layout untouched no matter where the
696        // launcher landed. Its own z-index, because a static parent creates no
697        // stacking context and the parent's z-index is inert.
698        if (pos === 'inline') {
699            return [
700                '#c2ai-widget { position: static; display: inline-flex; vertical-align: middle; top: auto; right: auto; bottom: auto; left: auto; }',
701                '#c2ai-panel { position: fixed; z-index: 2147483647; bottom: 24px; right: 24px; top: auto; left: auto; transform-origin: bottom right; }',
702                '@media (max-width: 430px) {',
703                '  #c2ai-widget { top: auto; right: auto; bottom: auto; left: auto; }',
704                '  #c2ai-panel { width: calc(100vw - 16px); right: 8px; left: auto; }',
705                '}',
706            ].join('\n');
707        }
708
709        var LAYOUTS = {
710            'bottom-left': {
711                widget: 'bottom: 24px; left: 24px; top: auto; right: auto;',
712                panel: 'bottom: 68px; left: 0; top: auto; right: auto; transform-origin: bottom left;',
713                widgetMobile: 'bottom: 16px; left: 8px; top: auto; right: auto;',
714                panelMobile: 'width: calc(100vw - 16px); left: 0; right: auto;',
715            },
716            'bottom-center': {
717                widget: 'bottom: 24px; left: 50%; top: auto; right: auto; transform: translateX(-50%);',
718                // margin-left rather than a translateX: the panel's transform is
719                // already carrying the open/close animation, and stacking a centring
720                // translate into it means restating the animation in both states.
721                panel: 'bottom: 68px; left: 50%; margin-left: -180px; top: auto; right: auto; transform-origin: bottom center;',
722                widgetMobile: 'bottom: 16px; left: 50%; top: auto; right: auto; transform: translateX(-50%);',
723                panelMobile: 'width: calc(100vw - 16px); left: 50%; margin-left: calc(-50vw + 8px); right: auto;',
724            },
725            'top-right': {
726                widget: 'top: 24px; right: 24px; bottom: auto; left: auto;',
727                panel: 'top: 68px; right: 0; bottom: auto; left: auto; transform-origin: top right;',
728                widgetMobile: 'top: 16px; right: 8px; bottom: auto; left: auto;',
729                panelMobile: 'width: calc(100vw - 16px); right: 0; left: auto;',
730            },
731            'top-left': {
732                widget: 'top: 24px; left: 24px; bottom: auto; right: auto;',
733                panel: 'top: 68px; left: 0; bottom: auto; right: auto; transform-origin: top left;',
734                widgetMobile: 'top: 16px; left: 8px; bottom: auto; right: auto;',
735                panelMobile: 'width: calc(100vw - 16px); left: 0; right: auto;',
736            },
737        };
738
739        // hasOwnProperty, not a bare lookup: `LAYOUTS['constructor']` is truthy and
740        // would emit `.c2ai-root { undefined }`. Unreachable while pos is whitelist-
741        // validated upstream, but this function is one refactor away from not being.
742        if (! Object.prototype.hasOwnProperty.call(LAYOUTS, pos)) {
743            return '';
744        }
745
746        var layout = LAYOUTS[pos];
747
748        var lines = [
749            '#c2ai-widget { ' + layout.widget + ' }',
750            '#c2ai-panel { ' + layout.panel + ' }',
751        ];
752
753        // Top-anchored panels open DOWNWARD, so the closed state has to sit above
754        // its resting place rather than below it — otherwise it animates in from
755        // the wrong side and reads as a glitch. The open state is unchanged.
756        if (pos.indexOf('top-') === 0) {
757            lines.push('#c2ai-panel:not(.c2ai-open) { transform: scale(0.9) translateY(-12px); }');
758        }
759
760        lines.push('@media (max-width: 430px) {');
761        lines.push('  #c2ai-widget { ' + layout.widgetMobile + ' }');
762        lines.push('  #c2ai-panel { ' + layout.panelMobile + ' }');
763        lines.push('}');
764
765        return lines.join('\n');
766    }
767
768    /**
769     * Build + attach the widget stylesheet for the active renderer.
770     *
771     * v1 lands in document.head (its markup is in the document and its rules are
772     * #id-scoped); v2 lands INSIDE the shadow root, where it cannot leak out and
773     * the host page cannot reach in. styleTarget() picks the right one.
774     */
775    function injectStyles(primaryColor) {
776        if (byId('c2ai-widget-styles')) return;
777
778        var style = el('style', { id: 'c2ai-widget-styles' });
779        style.textContent = widgetCss(primaryColor);
780        styleTarget().appendChild(style);
781    }
782
783    /**
784     * The full sheet for the active renderer, with any host override last.
785     *
786     * extraCss goes at the very END so a host integration wins on equal
787     * specificity without needing !important — and after positionCss*(), which
788     * is itself an override block.
789     */
790    function widgetCss(primaryColor) {
791        var css = (renderMode === 'v2')
792            ? stylesV2(primaryColor)
793            : stylesV1(primaryColor);
794
795        return extraCss === '' ? css : (css + '\n' + extraCss);
796    }
797
798    /**
799     * The pre-2026-08-25 chat-only stylesheet. UNCHANGED — this is the rollback
800     * path (see the file header), so it is not a place to tidy anything up.
801     */
802    function stylesV1(primaryColor) {
803        var rules = [
804            '#c2ai-widget { position: fixed; bottom: 24px; right: 24px; z-index: 2147483647; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; }',
805            '#c2ai-bubble { width: 56px; height: 56px; border-radius: 50%; background: ' + primaryColor + '; color: #fff; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 16px rgba(0,0,0,0.18); transition: transform 0.2s; outline: none; }',
806            '#c2ai-bubble:hover { transform: scale(1.08); }',
807            '#c2ai-bubble svg { width: 26px;
807 height: 26px; fill: #fff; }',
808            '#c2ai-panel { position: absolute; bottom: 68px; right: 0; width: 360px; max-height: 520px; border-radius: 16px; background: #fff; box-shadow: 0 8px 32px rgba(0,0,0,0.16); display: flex; flex-direction: column; overflow: hidden; transform: scale(0.9) translateY(12px); opacity: 0; pointer-events: none; transition: transform 0.22s cubic-bezier(.4,0,.2,1), opacity 0.22s; transform-origin: bottom right; }',
809            '#c2ai-panel.c2ai-open { transform: scale(1) translateY(0); opacity: 1; pointer-events: all; }',
810            '#c2ai-header { padding: 14px 16px; background: ' + primaryColor + '; color: #fff; display: flex; align-items: center; gap: 10px; flex-shrink: 0; }',
811            '#c2ai-avatar { width: 36px; height: 36px; border-radius: 50%; object-fit: cover; background: rgba(255,255,255,0.2); flex-shrink: 0; }',
812            '#c2ai-avatar-placeholder { width: 36px; height: 36px; border-radius: 50%; background: rgba(255,255,255,0.25); display: flex; align-items: center; justify-content: center; flex-shrink: 0; }',
813            '#c2ai-avatar-placeholder svg { width: 20px; height: 20px; fill: #fff; }',
814            '#c2ai-header-name { font-weight: 600; font-size: 15px; flex: 1; }',
815            '#c2ai-close-btn, #c2ai-min-btn { background: none; border: none; color: rgba(255,255,255,0.85); cursor: pointer; padding: 4px; border-radius: 4px; display: flex; align-items: center; }',
816            '#c2ai-close-btn:hover, #c2ai-min-btn:hover { color: #fff; background: rgba(255,255,255,0.15); }',
817            '#c2ai-close-btn svg, #c2ai-min-btn svg { width: 18px; height: 18px; fill: currentColor; }',
818            '#c2ai-messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 10px; background: #f9f9fb; }',
819            '.c2ai-msg { max-width: 82%; padding: 10px 13px; border-radius: 14px; line-height: 1.45; word-break: break-word; }',
820            '.c2ai-msg-bot { align-self: flex-start; background: #fff; color: #1a1a2e; border: 1px solid #e8e8ee; border-bottom-left-radius: 4px; }',
821            '.c2ai-msg-user { align-self: flex-end; background: ' + primaryColor + '; color: #fff; border-bottom-right-radius: 4px; }',
822            '.c2ai-msg-error { align-self: center; background: #fef2f2; color: #b91c1c; border: 1px solid #fecaca; font-size: 12px; border-radius: 8px; max-width: 95%; }',
823            '#c2ai-typing { display: none; align-items: center; gap: 4px; padding: 10px 13px; background: #fff; border: 1px solid #e8e8ee; border-radius: 14px; border-bottom-left-radius: 4px; align-self: flex-start; max-width: 260px; }',
824            '#c2ai-typing.c2ai-visible { display: flex; }',
825            '#c2ai-progress { font-size: 12px; color: #8a8a94; margin-left: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }',
826            '#c2ai-progress:empty { display: none; margin: 0; }',
827            '.c2ai-dot { width: 7px; height: 7px; border-radius: 50%; background: #aaa; animation: c2ai-bounce 1.2s infinite; }',
828            '.c2ai-dot:nth-child(2) { animation-delay: 0.2s; }',
829            '.c2ai-dot:nth-child(3) { animation-delay: 0.4s; }',
830            '@keyframes c2ai-bounce { 0%,80%,100%{transform:scale(0.7);opacity:0.5} 40%{transform:scale(1);opacity:1} }',
831            '#c2ai-footer { padding: 10px 12px; border-top: 1px solid #ebebef; background: #fff; display: flex; align-items: flex-end; gap: 8px; flex-shrink: 0; }',
832            '#c2ai-input { flex: 1; resize: none; border: 1px solid #d4d4dc; border-radius: 10px; padding: 9px 12px; font-size: 14px; font-family: inherit; outline: none; max-height: 96px; min-height: 40px; line-height: 1.4; transition: border-color 0.15s; background: #fafafc; }',
833            '#c2ai-input:focus { border-color: ' + primaryColor + '; background: #fff; }',
834            '#c2ai-send { width: 36px; height: 36px; border-radius: 50%; background: ' + primaryColor + '; color: #fff; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: opacity 0.15s; outline: none; }',
835            '#c2ai-send:disabled { opacity: 0.45;
835 cursor: not-allowed; }',
836            '#c2ai-send:not(:disabled):hover { opacity: 0.88; }',
837            '#c2ai-send svg { width: 17px; height: 17px; fill: #fff; }',
838            '#c2ai-branding { text-align: center; font-size: 11px; color: #aaa; padding: 6px; flex-shrink: 0; }',
839            '#c2ai-branding a { color: inherit; text-decoration: none; }',
840            '#c2ai-branding a:hover { text-decoration: underline; }',
841            '#c2ai-branding .c2ai-reset { color: #b0b0b8; cursor: pointer; }',
842            '#c2ai-branding .c2ai-reset:hover { color: #888; text-decoration: underline; }',
843            '.c2ai-gate { align-self: stretch; background: #fff; border: 1px solid #e8e8ee; border-radius: 12px; padding: 14px; display: flex; flex-direction: column; gap: 8px; }',
844            '.c2ai-gate-title { font-weight: 600; color: #1a1a2e; }',
845            '.c2ai-gate-field { border: 1px solid #d4d4dc; border-radius: 8px; padding: 9px 11px; font-size: 14px; font-family: inherit; outline: none; }',
846            '.c2ai-gate-field:focus { border-color: ' + primaryColor + '; }',
847            '.c2ai-gate-note { font-size: 12px; color: #888; }',
848            '.c2ai-gate-error { font-size: 12px; color: #b91c1c; }',
849            '.c2ai-gate-btn { background: ' + primaryColor + '; color: #fff; border: none; border-radius: 8px; padding: 9px 12px; font-size: 14px; cursor: pointer; font-family: inherit; }',
850            '.c2ai-gate-btn:hover { opacity: 0.9; }',
851            '.c2ai-gate-btn:disabled { opacity: 0.5; cursor: not-allowed; }',
852            '.c2ai-gate-skip { background: transparent; color: #888; border: 1px solid #e8e8ee; }',
853            '.c2ai-gate-skip:hover { background: #f4f4f7; opacity: 1; }',
854            '.c2ai-gate-consent { display: flex; align-items: flex-start; gap: 8px; font-size: 13px; line-height: 1.4; color: #444; cursor: pointer; }',
855            '.c2ai-gate-consent input { margin-top: 2px; flex-shrink: 0; }',
856            '.c2ai-gate-consent a { color: ' + primaryColor + '; text-decoration: underline; }',
857            '@media (max-width: 430px) { #c2ai-panel { width: calc(100vw - 16px); right: 0; } #c2ai-widget { right: 8px; bottom: 16px; } }',
858        ];
859
860        // Appended LAST so it overrides the base anchors above — including the mobile
861        // block on the line before. PUSHED rather than listed as an array entry so the
862        // default corner emits nothing at all: an empty entry would still contribute a
863        // trailing newline, and the bar for the default path is a byte-identical sheet.
864        var overrides = positionCss(position);
865
866        if (overrides !== '') {
867            rules.push(overrides);
868        }
869
870        return rules.join('\n');
871    }
872
873    // -------------------------------------------------------------------------
874    // v2 stylesheet — the port of "Chat Widget.dc.html"
875    //
876    // Values come straight from the mock. Two things to know before editing:
877    //
878    //  • This sheet lives INSIDE the shadow root, so it needs no defensive
879    //    specificity and cannot be overridden from the host page. That is why a
880    //    bare `input { … }` reset is safe here and would be reckless in v1.
881    //  • $primaryColor is the customer's own accent (validated 6-char hex,
882    //    server-side AND client-side). Everything else is fixed brand palette,
883    //    matching the design system's tokens:
884    //      ink #080C14 · muted #64748B · hairline rgba(107,114,128,.16)
885    //      surface #F5F5F5 · online #22C55E · danger #EF4444
886    // -------------------------------------------------------------------------
887
888    function stylesV2(primaryColor) {
889        var INK = '#080C14';
890        var MUTED = '#64748B';
891        var BODY = '#374151';
892        var HAIR = 'rgba(107,114,128,0.16)';
893        var HAIR_STRONG = 'rgba(107,114,128,0.24)';
894        var SURFACE = '#F5F5F5';
895        var ONLINE = '#22C55E';
896        var DANGER = '#EF4444';
897        var IDLE = '#9CA3AF';
898        var CHIP = '#F1F2F4';
899        var FONT = "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif";
900
901        var rules = [
902            // ---- host + reset ------------------------------------------------
903            // The host element is the only thing the page sees. `all: initial`
904            // stops inherited font-size/line-height/direction from the host from
905            // reaching in; shadow encapsulation already handles selectors.
906            ':host { all: initial; }',
907            '*, *::before, *::after { box-sizing: border-box; }',
908            '#c2ai-widget { position: fixed; bottom: 26px;
908 right: 26px; z-index: 2147483647; font-family: ' + FONT + '; font-size: 14px; color: ' + INK + '; -webkit-font-smoothing: antialiased; }',
909
910            // ---- launcher pill ----------------------------------------------
911            '#c2ai-bubble { display: flex; align-items: center; gap: 9px; height: 64px; padding: 9px 22px 9px 9px; border: 1px solid rgba(107,114,128,0.18); border-radius: 9999px; background: #fff; box-shadow: 0 10px 30px -12px rgba(10,16,34,0.28); cursor: pointer; font-family: inherit; transition: box-shadow .3s ease, border-color .3s ease; }',
912            '#c2ai-bubble:hover { box-shadow: 0 16px 38px -12px rgba(10,16,34,0.34); border-color: rgba(124,58,237,0.45); }',
913            '#c2ai-bubble:focus-visible { outline: 2px solid ' + primaryColor + '; outline-offset: 2px; }',
914            '.c2ai-orb-slot { position: relative; width: 46px; height: 46px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; }',
915            // The halo is decorative and sits BEHIND the orb.
916            '.c2ai-orb-halo { position: absolute; top: -5px; right: -5px; bottom: -5px; left: -5px; border-radius: 9999px; background: radial-gradient(circle, rgba(124,58,237,0.30), rgba(6,182,212,0.08) 70%); animation: c2ai-halo 2.6s ease-in-out infinite; }',
917            '.c2ai-orb { position: relative; width: 100%; height: 100%; border-radius: 9999px; overflow: hidden; display: flex; align-items: center; justify-content: center; }',
918            // The orb's colour sits on a ::before UNDERNEATH <agent-orb>, not on a
919            // sibling that gets swapped out. That is what removes the visible
920            // "gradient sphere, then orb" change: <agent-orb> is in the DOM from the
921            // first paint and the browser upgrades it IN PLACE the moment
922            // agent-orb.js defines the element, so nothing is ever replaced.
923            //
924            // A PSEUDO-ELEMENT on purpose: `filter` applies to an element AND its
925            // children, so hue-rotate on .c2ai-orb itself would re-tint the orb's own
926            // canvas on top of the hue agent-orb already applied — a double rotation.
927            // ::before is not a child, so it tints only itself.
928            //
929            // Gradient copied verbatim from agent-orb.js's _fallback() — keep in sync.
930            '.c2ai-orb::before { content: ""; position: absolute; top: 0; right: 0; bottom: 0; left: 0; border-radius: 9999px; background: radial-gradient(circle at 42% 38%, #6ee7d7 0%, #22b8a6 30%, #0D9488 52%, rgba(6,182,212,0.35) 74%, transparent 92%); filter: hue-rotate(var(--c2ai-hue, 0deg)); }',
931            // Above the ::before. agent-orb sizes itself from its `size` attribute and
932            // the clip's overflow:hidden crops it — the mock's crop to the dense centre.
933            '.c2ai-orb agent-orb { position: relative; }',
934            // Custom orb image: the whole image is shown (object-fit: contain) with a
935            // little padding so a logo sits nicely inside the circle, over a solid
936            // background colour (set inline per agent; the clip default fills white so
937            // a transparent PNG/SVG never lets the orb show through).
938            '.c2ai-orb-img { position: absolute; top: 0; right: 0; bottom: 0; left: 0; width: 100%; height: 100%; box-sizing: border-box; padding: 14%; object-fit: contain; object-position: center; display: block; }',
939            // When a custom image is used, KILL the generated-orb gradient underneath
940            // it — that pseudo-element was what showed through a transparent image.
941            '.c2ai-orb--img::before { display: none !important; }',
942            // A solid clip fill for the image (overridden inline by the agent\'s chosen
943            // background colour). This is the circle a transparent logo sits on.
944            // Custom-image backdrop is theme-aware (system light/dark) so a light logo
945            // isn't lost on white. NOT pure #fff — a neutral tone + hairline ring give
946            // any logo an edge. Image pixels are untouched (full-color logos stay
947            // correct). A customer-set per-mode colour feeds --c2ai-orb-bg-light /
948            // --c2ai-orb-bg-dark (set inline in orbSlot);
948 each rule reads its own prop.
949            '.c2ai-orb--img { background: var(--c2ai-orb-bg-light, #f0f2f5); box-shadow: inset 0 0 0 1px rgba(0,0,0,0.06); }',
950            '@media (prefers-color-scheme: dark) { .c2ai-orb--img { background: var(--c2ai-orb-bg-dark, #1b2330); box-shadow: inset 0 0 0 1px rgba(255,255,255,0.10); } }',
951            // What the launcher shows until three.js is fetched on first open.
952            //
953            // ⚠️ COPIED VERBATIM FROM agent-orb.js's OWN _fallback(): the same
954            // teal radial-gradient, tinted the same way — as a hue-rotate FILTER
955            // over a fixed palette, not as an absolute hsl() hue. An hsl(var(--hue))
956            // version looked plausible but meant something completely different
957            // from the real orb, so the placeholder visibly jumped to another
958            // colour the moment the asset landed. If you change one, change both.
959            '.c2ai-presence { position: absolute; right: -1px; bottom: -1px; width: 12px; height: 12px; border-radius: 9999px; background: ' + ONLINE + '; border: 2px solid #fff; }',
960            '.c2ai-launch-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; text-align: left; }',
961            '.c2ai-launch-title { font-size: 13.5px; font-weight: 600; letter-spacing: -0.01em; color: ' + INK + '; white-space: nowrap; }',
962            '.c2ai-launch-sub { font-size: 11.5px; color: ' + MUTED + '; white-space: nowrap; }',
963
964            // ---- panel -------------------------------------------------------
965            // Restates font/size/colour rather than inheriting from #c2ai-widget:
966            // in inline mode the panel is MOVED OUT of that element into the
967            // body-mounted root, whose host is `all: initial` — so inheriting
968            // would render the whole panel in the browser default serif. Every
969            // `font-family: inherit` inside the panel resolves through this.
970            '#c2ai-panel { position: absolute; bottom: 78px; right: 0; width: 394px; max-width: calc(100vw - 40px); display: flex; flex-direction: column; border: 1px solid ' + HAIR + '; border-radius: 18px; background: #fff; box-shadow: 0 26px 64px -22px rgba(10,16,34,0.34); overflow: hidden; font-family: ' + FONT + '; font-size: 14px; color: ' + INK + '; transform: translate3d(0,14px,0) scale(0.985); opacity: 0; pointer-events: none; transition: transform .24s ease, opacity .24s ease, visibility .24s ease; transform-origin: bottom right; }',
971            '#c2ai-panel.c2ai-open { transform: none; opacity: 1; pointer-events: all; }',
972            // visibility, not just opacity — FOR KEYBOARD REACHABILITY.
973            //
974            // `opacity: 0; pointer-events: none` hides a panel from the mouse and
975            // the eye but leaves every control in the TAB ORDER. Without this a
976            // visitor tabbing through the host page would land, invisibly, on the
977            // gate's name/email/phone inputs, the consent checkbox, the policy
978            // links, "Start chat", the composer, the send button, the minimize
979            // chevron and "Voice Chat" — inside a panel that looks closed.
980            //
981            // ⚠️ It does NOT stop the orb's render loop, and an earlier version of
982            // this comment claimed it did. agent-orb.js gates its shared rAF on
983            // IntersectionObserver, which is purely GEOMETRIC — a
984            // `visibility: hidden` element still has a box and still intersects,
985            // so it stays "visible" to the observer. Only `display: none` would
986            // flip it, and that would kill the open/close transition. The idle
987            // draw loop is inherent to the design's live launcher orb anyway (that
988            // one is on screen the whole time), so the panel's orb adds one more
989            // object to a loop that is already running, not a new loop.
990            //
991            // `visibility` is in the transition list above ON PURPOSE. It animates
992            // as a discrete step, which means hidden is applied at the END of the
993            // .24s when closing (so the fade-out is still seen) and immediately
994            // when opening. Drop it from that list and the panel vanishes instantly
995            // on close instead of fading.
996            '#c2ai-panel:not(.c2ai-open) { visibility: hidden; }',
997
998            // ---- header ------------------------------------------------------
999            '#c2ai-header { display: flex; align-items: center; gap: 11px; padding: 12px 12px 12px 14px; border-bottom: 1px solid rgba(107,114,128,0.14); background: #fff; }',
1000            '#c2ai-header .c2ai-orb-slot { width: 34px; height: 34px; }',
1001            '#c2ai-header-meta { display: flex; flex-direction: column; gap: 1px; min-width: 0; flex: 1; }',
1002            '#c2ai-header-name { font-size: 13.5px; font-weight: 600; letter-spacing: -0.01em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }',
1003            '#c2ai-header-status { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; color: ' + MUTED + '; }',
1004            '#c2ai-header-status .c2ai-dot-live { width: 6px; height: 6px; border-radius: 9999px; background: ' + ONLINE + '; flex-shrink: 0; }',
1005            // "Voice Chat" — the only entry point to the voice channel.
1006            '#c2ai-voice-btn { flex-shrink: 0; display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 13px; border: 0; border-radius: 9999px; background: ' + primaryColor + '; color: #fff; font-family: inherit; font-size: 12px; font-weight: 600; cursor: pointer; transition: filter .3s ease; }',
1007            '#c2ai-voice-btn:hover { filter: brightness(0.85); }',
1008            '#c2ai-voice-btn svg { width: 16px; height: 16px; fill: currentColor; }',
1009            '#c2ai-close-btn, #c2ai-min-btn { flex-shrink: 0; width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 9999px; background: none; color: ' + IDLE + '; cursor: pointer; transition: background .3s ease, color .3s ease; }',
1010            '#c2ai-close-btn:hover, #c2ai-min-btn:hover { background: ' + CHIP + '; color: ' + INK + '; }',
1011            '#c2ai-close-btn svg, #c2ai-min-btn svg { width: 19px; height: 19px; fill: currentColor; }',
1012
1013            // ---- messages ----------------------------------------------------
1014            // max() floor: on a very short viewport (a phone in landscape, or an
1015            // iframe) `100vh - 300px` goes negative and the transcript collapses
1016            // to nothing. 180px always leaves a couple of messages visible.
1017            '#c2ai-messages { display: flex; flex-direction: column; gap: 10px; height: max(180px, min(430px, calc(100vh - 300px))); overflow-y: auto; padding: 16px 14px 8px; background: ' + SURFACE + '; }',
1018            '.c2ai-msg { max-width: 82%; padding: 11px 14px; border-radius: 14px; font-size: 13px; line-height: 1.5; word-break: break-word; animation: c2ai-msg-in .22s ease both; }',
1019            '.c2ai-msg-bot { align-self: flex-start; background: #fff; border: 1px solid ' + HAIR + '; color: ' + BODY + '; }',
1020            '.c2ai-msg-user { align-self: flex-end; background: ' + primaryColor + '; border: 1px solid ' + primaryColor + '; color: #fff; }',
1021            '.c2ai-msg-error { align-self: center; max-width: 95%; background: #FEF2F2; border: 1px solid #FECACA; color: #B91C1C; font-size: 12px; border-radius: 10px; }',
1022            // Quick-prompt pills: right-aligned on the visitor's side, outlined in the brand colour.
1023            '.c2ai-quick { align-self: flex-end; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; max-width: 82%; animation: c2ai-msg-in .22s ease both; }',
1024            '.c2ai-quick-btn { font: inherit; font-size: 12.5px; font-weight: 500; line-height: 1.3; padding: 7px 14px; border-radius: 999px; border: 1px solid ' + primaryColor + '; background: #fff; color: ' + primaryColor + '; cursor: pointer; text-align: right; word-break: break-word; transition: background .15s, color .15s; }',
1025            '.c2ai-quick-btn:hover, .c2ai-quick-btn:focus-visible { background: ' + primaryColor + '; color: #fff; outline: none; }',
1026            // Handoff marker: the chat session really did end when voice started,
1027            // so the thread says so rather than pretending it continued.
1028            '.c2ai-divider { align-self: stretch; display: flex; align-items: center; gap: 10px; padding: 2px 0; font-size: 10px; font-weight: 500; letter-spacing: 0.06em; text-transform: uppercase; color: ' + IDLE + '; animation: c2ai-msg-in .22s ease both; }',
1029            '.c2ai-divider::before, .c2ai-divider::after { content: ""; flex: 1; height: 1px; background: ' + HAIR + '; }',
1030            '#c2ai-typing { display: none; align-items: center; gap: 4px; align-self: flex-start; padding: 13px 15px; border: 1px solid ' + HAIR + '; border-radius: 14px; background: #fff; }',
1031            '#c2ai-typing.c2ai-visible { display: inline-flex; }',
1032            '.c2ai-dot { width: 6px; height: 6px; border-radius: 9999px; background: ' + IDLE + '; animation: c2ai-dots 1.2s ease-in-out infinite; }',
1033            '.c2ai-dot:nth-child(2) { animation-delay: .18s; }',
1034            '.c2ai-dot:nth-child(3) { animation-delay: .36s; }',
1035            // Progress "heartbeat" (merged from main 2026-08-25): the tool
1036            // loop's latest phase line, shown INSIDE the typing pill next to the
1037            // dots. v1 got these rules from main; v2 needs its own, or the text
1038            // renders unstyled and blows the pill — which is sized for three dots
1039            // — past the 394px panel. max-width + ellipsis keeps it a pill.
1040            '#c2ai-progress { margin-left: 4px; max-width: 220px; font-size: 12px; color: ' + MUTED + '; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }',
1041            '#c2ai-progress:empty { display: none; margin: 0; }',
1042
1043            // ---- pre-chat gate (same markup as v1, restyled) -----------------
1044            '.c2ai-gate { align-self: stretch; display: flex; flex-direction: column; gap: 11px; padding: 16px; border: 1px solid ' + HAIR + '; border-radius: 14px; background: #fff; animation: c2ai-msg-in .22s ease both; }',
1045            '.c2ai-gate-title { font-size: 13.5px; font-weight: 600; letter-spacing: -0.01em; color: ' + INK + '; }',
1046            '.c2ai-gate-field { height: 42px; padding: 0 13px; border: 1px solid ' + HAIR_STRONG + '; border-radius: 11px; background: #fff; font-family: inherit; font-size: 13px; color: ' + INK + '; outline: none; transition: border-color .3s ease; }',
1047            '.c2ai-gate-field:focus { border-color: ' + primaryColor + '; }',
1048            '.c2ai-gate-note { font-size: 11.5px; line-height: 1.5; color: ' + MUTED + '; }',
1049            // Second note in the gate is the fine print — one step quieter.
1050            '.c2ai-gate-note + .c2ai-gate-note, .c2ai-gate-consent + .c2ai-gate-note { font-size: 11px; color: ' + IDLE + '; }',
1051            '.c2ai-gate-error { font-size: 12px; line-height: 1.5; color: #B91C1C; }',
1052            '.c2ai-gate-btn { display: inline-flex; align-items: center; justify-content: center; height: 42px; border: 0; border-radius: 9999px; background: ' + primaryColor + '; color: #fff; font-family: inherit; font-size: 13px; font-weight: 600; cursor: pointer; transition: filter .3s ease; }',
1053            '.c2ai-gate-btn:hover:not(:disabled) { filter: brightness(0.85); }',
1054            // The mock's disabled "Start Chatting" state.
1055            '.c2ai-gate-btn:disabled { background: ' + CHIP + '; color: ' + IDLE + '; cursor: default; }',
1056            '.c2ai-gate-skip { background: transparent; color: ' + MUTED + '; border: 1px solid ' + HAIR + '; }',
1057            '.c2ai-gate-skip:hover:not(:disabled) { background: ' + CHIP + '; filter: none; }',
1058            '.c2ai-gate-consent { display: flex; align-items: flex-start; gap: 9px; font-size: 12px; line-height: 1.5; color: ' + BODY + '; cursor: pointer; user-select: none; }',
1059            // appearance:none turns the native box into the mock's rounded chip.
1060            // The tick is a background image so no extra element is needed and the
1061            // input stays a real focusable checkbox for assistive tech.
1062            '.c2ai-gate-consent input { appearance: none; -webkit-appearance: none; flex-shrink: 0; width: 18px; height: 18px; margin: 1px 0 0; border: 1px solid rgba(107,114,128,0.4); border-radius: 5px; background: #fff; cursor: pointer; transition: background .3s ease, border-color .3s ease; }',
1063            '.c2ai-gate-consent input:checked { background: ' + primaryColor + ' url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 24 24\' fill=\'%23fff\'%3E%3Cpath d=\'M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z\'/%3E%3C/svg%3E") center/13px no-repeat; border-color: ' + primaryColor + '; }',
1064            '.c2ai-gate-consent input:focus-visible { outline: 2px solid ' + primaryColor + '; outline-offset: 2px; }',
1065            '.c2ai-gate-consent a { color: ' + primaryColor + '; text-decoration: underline; }',
1066            // Cloudflare's own widget — give it breathing room, never restyle it.
1067            '.cf-turnstile { min-height: 65px; }',
1068
1069            // ---- composer ----------------------------------------------------
1070            '#c2ai-footer { padding: 12px 14px 6px; background: #fff; }',
1071            '#c2ai-composer { display: flex; align-items: center; gap: 8px; min-height: 50px; padding: 6px 6px 6px 18px; border: 1px solid ' + HAIR_STRONG + '; border-radius: 25px; transition: border-color .3s ease; }',
1072            '#c2ai-composer:focus-within { border-color: ' + primaryColor + '; }',
1073            // "previous chat?" link — small, centered, muted; sits above the composer.
1074            '#c2ai-prevchat { display: block; margin: 0 auto 8px; padding: 2px 10px; border: 0; background: none; color: ' + IDLE + '; font-family: inherit; font-size: 12px; line-height: 1.4; cursor: pointer; opacity: .9; transition: color .2s ease, opacity .2s ease; }',
1075            '#c2ai-prevchat:hover { color: ' + primaryColor + '; opacity: 1; text-decoration: underline; }',
1076            // A textarea, not an input — see header note 3. Styled flat so it
1077            // reads as the mock's single-line pill until the text wraps.
1078            '#c2ai-input { flex: 1; min-width: 0; max-height: 96px; padding: 0; border: 0; outline: none; background: transparent; resize: none; overflow-y: auto; font-family: inherit; font-size: 13.5px; line-height: 1.5; color: ' + INK + '; }',
1079            '#c2ai-input::placeholder { color: ' + IDLE + '; }',
1080            '#c2ai-input:disabled { color: ' + IDLE + '; }',
1081            '#c2ai-send { flex-shrink: 0; width: 34px; height: 34px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 9999px; background: ' + primaryColor + '; color: #fff; cursor: pointer; transition: background .3s ease, color .3s ease; }',
1082            '#c2ai-send svg { width: 18px; height: 18px; fill: currentColor; }',
1083            // Idle (nothing typed) is the mock's grey chip, not a dimmed button.
1084            '#c2ai-send:disabled { background: ' + CHIP + '; color: ' + IDLE + '; cursor: default; }',
1085
1086            // ---- footer ------------------------------------------------------
1087            '#c2ai-branding { display: flex; align-items: center; justify-content: center; gap: 6px; padding: 8px 14px 12px; background: #fff; font-size: 10px; font-weight: 500; letter-spacing: 0.05em; text-transform: uppercase; color: #B6BCC6; }',
1088            '#c2ai-branding a { color: inherit; text-decoration: none; }',
1089            '#c2ai-branding a:hover { text-decoration: underline; }',
1090            '#c2ai-branding .c2ai-reset { color: ' + IDLE + '; cursor: pointer; }',
1091            '#c2ai-branding .c2ai-reset:hover { color: ' + MUTED + '; text-decoration: underline; }',
1092
1093            // ---- voice overlay ----------------------------------------------
1094            // Full-screen by design (see the mock). Fixed + inset:0 inside the
1095            // shadow root, so the host page's stacking contexts cannot clip it.
1096            // Longhands, NOT `inset: 0`. attachShadow shipped ~2016-2018 but
1097            // `inset` only ~2021, so v2Supported() admits a tier of browsers
1098            // (Safari 12-14, Chrome 60-86) where the shorthand is dropped and this
1099            // becomes a ZERO-SIZE fixed box — an invisible full-screen call UI with
1100            // a live microphone. The longhands work everywhere shadow DOM does.
1101            '#c2ai-voice { position: fixed; top: 0; right: 0; bottom: 0; left: 0; z-index: 2147483647; display: flex; flex-direction: column; background: radial-gradient(70% 60% at 50% 100%, rgba(6,182,212,0.16) 0%, rgba(6,182,212,0) 62%), rgba(255,255,255,0.82); -webkit-backdrop-filter: blur(14px); backdrop-filter: blur(14px); color: #0A1022; font-family: ' + FONT + '; animation: c2ai-fade .26s ease both; }',
1102            '#c2ai-voice-top { display: flex; align-items: center; justify-content: flex-end; padding: 20px 26px; }',
1103            '#c2ai-voice-stage { flex: 1; min-height: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 26px; padding: 0 24px; text-align: center; }',
1104            '#c2ai-voice-stage .c2ai-orb-slot { width: 216px; height: 216px; }',
1105            '#c2ai-voice-stage .c2ai-orb-halo { top: -14px; right: -14px; bottom: -14px; left: -14px; background: radial-gradient(circle, rgba(124,58,237,0.28), rgba(6,182,212,0.07) 72%); animation-duration: 3s; }',
1106            '#c2ai-voice-status { display: inline-flex; align-items: baseline; gap: 1px; font-size: 16px; color: rgba(10,16,34,0.78); }',
1107            '#c2ai-voice-status .c2ai-dot { width: auto; height: auto; background: none; border-radius: 0; }',
1108            '#c2ai-voice-hint { max-width: 420px; font-size: 13px; line-height: 1.55; color: ' + MUTED + '; }',
1109            '#c2ai-voice-controls { display: flex; align-items: center; justify-content: center; gap: 14px; padding-bottom: 44px; }',
1110            '.c2ai-vbtn { width: 54px; height: 54px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 9999px; background: none; box-shadow: inset 0 0 0 1px rgba(10,16,34,0.18); color: rgba(10,16,34,0.65); cursor: pointer; transition: background .3s ease, color .3s ease; }',
1111            '.c2ai-vbtn:hover { background: rgba(10,16,34,0.05); color: #0A1022; }',
1112            '.c2ai-vbtn svg { width: 21px; height: 21px; fill: currentColor; }',
1113            // Muted is a filled state, not a dimmed one — it must be unmissable.
1114            '.c2ai-vbtn.c2ai-muted { background: #0A1022; color: #fff; box-shadow: none; }',
1115            '#c2ai-voice-end { width: auto; gap: 9px; padding: 0 26px; border-radius: 9999px; background: ' + DANGER + '; box-shadow: none; color: #fff; font-family: inherit; font-size: 14px; font-weight: 600; }',
1116            '#c2ai-voice-end:hover { background: rgba(239,68,68,0.82); color: #fff; }',
1117            '#c2ai-voice-close { width: 34px; height: 34px; box-shadow: inset 0 0 0 1px rgba(10,16,34,0.16); color: rgba(10,16,34,0.6); }',
1118            '#c2ai-voice-close svg { width: 20px; height: 20px; }',
1119            '#c2ai-voice-brand { display: flex; align-items: center; justify-content: center; gap: 6px; padding-bottom: 18px; font-size: 9.5px; font-weight: 500; letter-spacing: 0.06em; text-transform: uppercase; color: #B6BCC6; }',
1120            '#c2ai-voice-error { max-width: 420px; font-size: 13px; line-height: 1.55; color: #B91C1C; }',
1121
1122            // ---- keyframes ---------------------------------------------------
1123            '@keyframes c2ai-msg-in { from { opacity: 0; transform: translate3d(0,6px,0); } to { opacity: 1; transform: none; } }',
1124            '@keyframes c2ai-dots { 0%, 20% { opacity: .25; } 50% { opacity: 1; } 100% { opacity: .25; } }',
1125            '@keyframes c2ai-halo { 0%, 100% { transform: scale(1); opacity: .55; } 50% { transform: scale(1.16); opacity: .15; } }',
1126            '@keyframes c2ai-fade { from { opacity: 0; } to { opacity: 1; } }',
1127            // Honour the visitor's OS setting. The orb self-degrades separately.
1128            '@media (prefers-reduced-motion: reduce) { #c2ai-widget *, #c2ai-voice * { animation: none !important; transition: none !important; } }',
1129
1130            // ---- mobile ------------------------------------------------------
1131            // The panel goes full-bleed rather than merely narrower: a 394px card
1132            // with 20px of page showing either side reads as broken, not floating.
1133            '@media (max-width: 460px) {'
1134                + ' #c2ai-widget { right: 12px; bottom: 12px; }'
1135                // max-width MUST be reset too: the base rule's calc(100vw - 40px)
1136                // would clamp this straight back and the full-bleed never happens.
1137                + ' #c2ai-panel { width: calc(100vw - 24px); max-width: none; }'
1138                + ' #c2ai-messages { height: max(180px, min(430px, calc(100vh - 240px))); }'
1139                + ' #c2ai-bubble { height: 58px; padding: 6px 18px 6px 6px; }'
1140                + ' #c2ai-voice-stage .c2ai-orb-slot { width: 168px; height: 168px; }'
1141                + ' #c2ai-voice-controls { gap: 10px; padding-bottom: 32px; }'
1142            + ' }',
1143        ];
1144
1145        // Appended last for the same reason as v1's — later wins at equal
1146        // specificity, including over the mobile block above.
1147        var overrides = positionCssV2(position);
1148
1149        if (overrides !== '') {
1150            rules.push(overrides);
1151        }
1152
1153        return rules.join('\n');
1154    }
1155
1156    /**
1157     * v2's position overrides.
1158     *
1159     * A SEPARATE function from positionCss(), not a shared one, because every
1160     * number in it is a v2 metric: the panel is 394px not 360px, it clears a
1161     * 64px launcher pill not a 56px bubble, and the mobile breakpoint is 460px
1162     * not 430px. Feeding v1's table into the v2 sheet would half-apply v1's
1163     * geometry and fight the base rules — which is precisely the kind of silent
1164     * drift the two-renderer split exists to avoid.
1165     *
1166     * Same contract as v1's: returns '' for the default corner so the default
1167     * path emits no override at all.
1168     */
1169    function positionCssV2(pos) {
1170        if (pos === 'bottom-right') {
1171            return '';
1172        }
1173
1174        // INLINE: the launcher sits in the page like a button, but the PANEL
1175        // still opens bottom-right (Steven, 2026-08-19) — a 394px panel
1176        // expanding out of a nav bar wrecks the header it was put in. Anchoring
1177        // it to the viewport keeps the host layout untouched wherever the
1178        // launcher landed. See mountWidget() for why the panel is also moved to
1179        // its own root in this mode.
1180        if (pos === 'inline') {
1181            return [
1182                '#c2ai-widget { position: static; display: inline-flex; vertical-align: middle; top: auto; right: auto; bottom: auto; left: auto; }',
1183                '#c2ai-panel { position: fixed; z-index: 2147483647; bottom: 26px; right: 26px; top: auto; left: auto; transform-origin: bottom right; }',
1184                '@media (max-width: 460px) {',
1185                '  #c2ai-widget { top: auto; right: auto; bottom: auto; left: auto; }',
1186                '  #c2ai-panel { width: calc(100vw - 24px); right: 12px; left: auto; }',
1187                '}',
1188            ].join('\n');
1189        }
1190
1191        var LAYOUTS = {
1192            'bottom-left': {
1193                widget: 'bottom: 26px; left: 26px; top: auto; right: auto;',
1194                panel: 'bottom: 78px; left: 0; top: auto; right: auto; transform-origin: bottom left;',
1195                widgetMobile: 'bottom: 12px; left: 12px; top: auto; right: auto;',
1196                panelMobile: 'width: calc(100vw - 24px); left: 0; right: auto;',
1197            },
1198            'bottom-center': {
1199                widget: 'bottom: 26px; left: 50%; top: auto; right: auto; transform: translateX(-50%);',
1200                // margin-left rather than a translateX: the panel's transform is
1201                // already carrying the open/close animation, and stacking a
1202                // centring translate into it means restating the animation in
1203                // both states. -197px is half of 394px.
1204                panel: 'bottom: 78px; left: 50%; margin-left: -197px; top: auto; right: auto; transform-origin: bottom center;',
1205                widgetMobile: 'bottom: 12px; left: 50%; top: auto; right: auto; transform: translateX(-50%);',
1206                panelMobile: 'width: calc(100vw - 24px); left: 50%; margin-left: calc(-50vw + 12px); right: auto;',
1207            },
1208            'top-right': {
1209                widget: 'top: 26px; right: 26px; bottom: auto; left: auto;',
1210                panel: 'top: 78px; right: 0; bottom: auto; left: auto; transform-origin: top right;',
1211                widgetMobile: 'top: 12px; right: 12px; bottom: auto; left: auto;',
1212                panelMobile: 'width: calc(100vw - 24px); right: 0; left: auto;',
1213            },
1214            'top-left': {
1215                widget: 'top: 26px; left: 26px; bottom: auto; right: auto;',
1216                panel: 'top: 78px; left: 0; bottom: auto; right: auto; transform-origin: top left;',
1217                widgetMobile: 'top: 12px; left: 12px; bottom: auto; right: auto;',
1218                panelMobile: 'width: calc(100vw - 24px); left: 0; right: auto;',
1219            },
1220        };
1221
1222        // hasOwnProperty, not a bare lookup: `LAYOUTS['constructor']` is truthy
1223        // and would emit `#c2ai-widget { undefined }`. Unreachable while pos is
1224        // whitelist-validated upstream, but this is one refactor away from not.
1225        if (! Object.prototype.hasOwnProperty.call(LAYOUTS, pos)) {
1226            return '';
1227        }
1228
1229        var layout = LAYOUTS[pos];
1230
1231        var lines = [
1232            '#c2ai-widget { ' + layout.widget + ' }',
1233            '#c2ai-panel { ' + layout.panel + ' }',
1234        ];
1235
1236        // Top-anchored panels open DOWNWARD, so the closed state has to sit
1237        // ABOVE its resting place rather than below it — otherwise it animates in
1238        // from the wrong side and reads as a glitch. Mirrors the base sheet's
1239        // closed transform, negated.
1240        if (pos.indexOf('top-') === 0) {
1241            lines.push('#c2ai-panel:not(.c2ai-open) { transform: translate3d(0,-14px,0) scale(0.985); }');
1242        }
1243
1244        lines.push('@media (max-width: 460px) {');
1245        lines.push('  #c2ai-widget { ' + layout.widgetMobile + ' }');
1246        lines.push('  #c2ai-panel { ' + layout.panelMobile + ' }');
1247        lines.push('}');
1248
1249        return lines.join('\n');
1250    }
1251
1252    // -------------------------------------------------------------------------
1253    // Build DOM
1254    // -------------------------------------------------------------------------
1255
1256    var widget, bubble, panel, messagesEl, typingEl, progressEl, inputEl, sendBtn, gateEl = null, resetEl = null, prevChatLink = null;
1257
1258    /**
1259     * Put the assembled widget in the document.
1260     *
1261     * Every fixed position appends to <body> exactly as this widget always has —
1262     * the anchoring is pure CSS, so the DOM parent is irrelevant to where it lands
1263     * and body keeps it clear of any host `overflow: hidden` or transformed
1264     * ancestor that would otherwise clip a fixed child.
1265     *
1266     * `inline` is the exception: it inserts immediately AFTER the <script> tag that
1267     * loaded it, so the launcher appears exactly where the customer pasted the tag.
1268     * That works with `defer` because a deferred script still sits at its own place
1269     * in the parsed DOM when it runs — we just never rely on document.currentScript,
1270     * which IS null for deferred execution.
1271     *
1272     * ⚠️ IN INLINE MODE THE PANEL IS SPLIT OFF TO <body> AND THAT IS NOT OPTIONAL.
1273     * Only the bubble goes in the customer's markup. `position: fixed` is viewport-
1274     * relative ONLY while no ancestor establishes a containing block, and `transform`,
1275     * `filter`, `backdrop-filter`, `perspective`, `will-change: transform` and
1276     * `contain: paint` all do — which is a precise description of the sticky, blurred,
1277     * animated nav bars inline mode exists to be dropped into. Left inside one, the
1278     * panel would be positioned against the nav instead of the viewport, clipped by
1279     * its `overflow: hidden`, and its z-index trapped in the nav's stacking context so
1280     * any host overlay would paint over it. Detaching the panel costs nothing: it is
1281     * absolutely/fixed positioned in every mode, so it never participated in the
1282     * bubble's layout, and every reference to it here is a closure variable that does
1283     * not care where it lives.
1284     *
1285     * The head/no-parent case was already rejected in resolvePosition(), so by here
1286     * hostScript is known-mountable; the guard is belt-and-braces against a host
1287     * page removing its own script tag before DOMContentLoaded.
1288     */
1289    function mountWidget(node) {
1290        // ---- v2: everything lives inside shadow roots --------------------
1291        // The roots were created in init() (injectStyles needs one), so this
1292        // only decides WHICH root each part goes into.
1293        //
1294        // ⚠️ Shadow DOM does NOT create a containing block, so the inline-mode
1295        // hazard documented above is EXACTLY the same here — a shadow host
1296        // dropped inside a transformed/blurred nav still traps a `position:
1297        // fixed` descendant. So inline mode still splits the panel off, it just
1298        // splits it into the body-mounted root instead of into <body> directly.
1299        // positionCssV2('inline') makes the panel `position: fixed`, which is
1300        // what lets it live in a different root from its launcher at all.
1301        if (renderMode === 'v2') {
1302            if (position === 'inline' && shadowLauncher !== shadow) {
1303                if (panel && panel.parentNode === node) {
1304                    shadow.appendChild(panel);
1305                }
1306
1307                shadowLauncher.appendChild(node);
1308
1309                return;
1310            }
1311
1312            shadow.appendChild(node);
1313
1314            return;
1315        }
1316
1317        // ---- v1: unchanged ------------------------------------------------
1318        if (position === 'inline' && hostScript && hostScript.parentNode) {
1319            if (panel && panel.parentNode === node) {
1320                document.body.appendChild(panel);
1321            }
1322
1323            hostScript.parentNode.insertBefore(node, hostScript.nextSibling);
1324
1325            return;
1326        }
1327
1328        document.body.appendChild(node);
1329    }
1330
1331    /**
1332     * Create the shadow root(s) v2 renders into, and attach the stylesheet.
1333     *
1334     * ONE root normally. Inline mode needs TWO — the launcher's has to sit at
1335     * the <script> tag's own position in the customer's markup, while the panel
1336     * and the voice overlay must be anchored to the viewport from a host on
1337     * <body> (see mountWidget). Both get the same sheet; it is a string, so the
1338     * second copy costs nothing worth optimising.
1339     *
1340     * `mode: 'open'` rather than 'closed' deliberately: it changes nothing about
1341     * encapsulation (CSS cannot cross either way regardless) and it keeps the
1342     * widget inspectable from the console, which is most of how these get
1343     * debugged on a live customer site.
1344     */
1345    function attachShadowRoots(primaryColor) {
1346        shadow = makeShadowHost(document.body, null);
1347        shadowLauncher = shadow;
1348
1349        if (position === 'inline' && hostScript && hostScript.parentNode) {
1350            // A deferred script still sits at its own place in the parsed DOM
1351            // when it runs, so hostScript.nextSibling is the paste point. We
1352            // never rely on document.currentScript, which IS null for defer.
1353            shadowLauncher = makeShadowHost(hostScript.parentNode, hostScript.nextSibling);
1354        }
1355
1356        // injectStyles reads styleTarget() (=== shadow), so seed the main root
1357        // first, then mirror the same sheet into the launcher root if separate.
1358        injectStyles(primaryColor);
1359
1360        if (shadowLauncher !== shadow) {
1361            var mirror = el('style', { id: 'c2ai-widget-styles' });
1362            mirror.textContent = widgetCss(primaryColor);
1363            shadowLauncher.appendChild(mirror);
1364        }
1365    }
1366
1367    /**
1368     * Can this browser run the v2 renderer at all?
1369     *
1370     * Checked BEFORE any v2 DOM is built, so an unsupported browser takes the v1
1371     * path instead of throwing halfway through. Promise is included because v2's
1372     * lazy loaders (loadOrb / loadVoiceEngine) return one and v1 does not use
1373     * them — this file's pre-v2 revision had zero Promise references.
1374     */
1375    function v2Supported() {
1376        return !!(
1377            window.Promise
1378            && document.body
1379            && typeof document.body.attachShadow === 'function'
1380        );
1381    }
1382
1383    /** A display:contents shadow host, so it adds no box of its own. */
1384    function makeShadowHost(parent, before) {
1385        var host = el('div', { class: 'c2ai-host' });
1386        // display:contents on the HOST (not in the sheet — the sheet is inside
1387        // the root and cannot style its own host from there without :host, and
1388        // the host must not introduce a layout box either way).
1389        host.style.cssText = 'display: contents;';
1390
1391        if (before) {
1392            parent.insertBefore(host, before);
1393        } else {
1394            parent.appendChild(host);
1395        }
1396
1397        return host.attachShadow({ mode: 'open' });
1398    }
1399
1400    /**
1401     * Assemble + mount the widget.
1402     *
1403     * The parts the shared layer touches — messagesEl / typingEl / inputEl /
1404     * sendBtn / resetEl — are built ONCE here for both renderers, so the message
1405     * builders, the gate flow and the turn machinery are the same code in both
1406     * designs (see the file header). Only the chrome around them differs, and
1407     * that is all chromeV1() / chromeV2() are responsible for.
1408     */
1409    function buildWidget(config) {
1410        widget = el('div', { id: 'c2ai-widget', 'aria-label': 'Chat widget' });
1411
1412        // ---- shared parts ---------------------------------------------------
1413        messagesEl = el('div', { id: 'c2ai-messages', role: 'log', 'aria-live': 'polite' });
1414        // Progress "heartbeat" line ("Looking that up…") shown next to the dots while
1415        // the agent works; cleared when the turn finishes.
1416        progressEl = el('span', { id: 'c2ai-progress', 'aria-live': 'polite' }, ['']);
1417        typingEl   = el('div', { id: 'c2ai-typing', 'aria-label': 'Agent is typing', 'aria-hidden': 'true' }, [
1418            el('div', { class: 'c2ai-dot' }),
1419            el('div', { class: 'c2ai-dot' }),
1420            el('div', { class: 'c2ai-dot' }),
1421            progressEl,
1422        ]);
1423
1424        inputEl = el('textarea', {
1425            id: 'c2ai-input',
1426            placeholder: 'Type a message...',
1427            rows: '1',
1428            'aria-label': 'Chat message input',
1429            maxlength: '5000',
1430        });
1431        inputEl.addEventListener('keydown', onInputKeydown);
1432        inputEl.addEventListener('input', autoResizeInput);
1433
1434        // v2's send glyph is the mock's arrow_upward; v1 keeps its paper plane.
1435        sendBtn = el('button', { id: 'c2ai-send', 'aria-label': 'Send message', disabled: 'true' }, [
1436            svgIcon(renderMode === 'v2' ? 'arrow-up' : 'send'),
1437        ]);
1438        sendBtn.addEventListener('click', sendMessage);
1439        inputEl.addEventListener('input', function () {
1440            sendBtn.disabled = inputEl.value.trim() === '';
1441        });
1442
1443        // "Not you?" — lets a different person on a shared/public device sign
1444        // the remembered visitor out and start clean (light-grey, only shown
1445        // once there is an identity to clear; toggled by refreshResetLink).
1446        var resetLink = el('a', { class: 'c2ai-reset', href: '#', role: 'button', 'aria-label': 'Not you? Reset this chat' }, ['Not you?']);
1447        resetLink.addEventListener('click', function (e) { if (e && e.preventDefault) { e.preventDefault(); } resetWidget(); });
1448
1449        // ---- per-renderer chrome --------------------------------------------
1450        var chrome = (renderMode === 'v2')
1451            ? chromeV2(config, resetLink)
1452            : chromeV1(config, resetLink);
1453
1454        bubble = chrome.bubble;
1455
1456        // VOICE-ONLY AGENT: there is no chat panel at all — the launcher opens
1457        // the call directly. Everything above was still built because the shared
1458        // layer holds references to it; it simply never gets mounted, and no
1459        // chat endpoint is ever called (the server refuses them for this agent
1460        // anyway — see ChatWidgetController::resolveChatAgent).
1461        if (!chrome.panel) {
1462            panel = null;
1463            widget.appendChild(bubble);
1464            mountWidget(widget);
1465
1466            return;
1467        }
1468
1469        panel = chrome.panel;
1470
1471        panel.appendChild(chrome.header);
1472        panel.appendChild(messagesEl);
1473        // The typing indicator MUST live INSIDE the messages container: every
1474        // message/gate insert does messagesEl.insertBefore(x, typingEl), which
1475        // throws NotFoundError if typingEl isn't a child of messagesEl. (It was a
1476        // panel child since the original 2026-06-10 widget commit — that bug
1477        // broke the welcome message, the pre-chat gate, and every send.)
1478        messagesEl.appendChild(typingEl);
1479        panel.appendChild(chrome.footer);
1480        panel.appendChild(chrome.branding);
1481
1482        widget.appendChild(panel);
1483        widget.appendChild(bubble);
1484        mountWidget(widget);
1485
1486        // Show the welcome message (+ quick prompts) immediately so the panel has content.
1487        showWelcome(config.welcome_message);
1488
1489        // Pre-chat entry. A signed-in website visitor (server sets config.authenticated)
1490        // NEVER sees it — we already know them and the server attaches their identity
1491        // on session start. A returning visitor who already provided contact AND agreed
1492        // to the policies skips it. So does anyone with a session already in progress
1493        // (an in-tab reload after starting an instant chat) — re-prompting them would
1494        // paint a form over their live conversation. Everyone else sees the entry:
1495        // the "Chat Instantly / Account Support" choice (v2, when enabled) or the
1496        // identity gate.
1497        if (!config.authenticated && !state.sessionId && (!state.contact || !state.consented)) {
1498            showEntry();
1499        }
1500
1501        refreshResetLink();
1502    }
1503
1504    /**
1505     * v1 chrome — the pre-2026-08-25 chat-only design. UNCHANGED (rollback path).
1506     *
1507     * @return {{bubble: Element, panel: Element, header: Element, footer: Element, branding: Element}}
1508     */
1509    function chromeV1(config, resetLink) {
1510        var bubbleEl = el('button', {
1511            id: 'c2ai-bubble',
1512            'aria-label': 'Open chat',
1513            'aria-expanded': 'false',
1514        }, [svgIcon('chat')]);
1515        bubbleEl.addEventListener('click', togglePanel);
1516
1517        var panelEl = el('div', { id: 'c2ai-panel', role: 'dialog', 'aria-label': config.bot_name });
1518
1519        // Header — only accept HTTPS avatar URLs as a defense-in-depth check
1520        // (the server also validates this, but we guard client-side too).
1521        var safeAvatarUrl = (config.avatar_url && /^https:\/\//i.test(config.avatar_url))
1522            ? config.avatar_url
1523            : null;
1524
1525        var headerAvatar = safeAvatarUrl
1526            ? el('img', { id: 'c2ai-avatar', src: safeAvatarUrl, alt: config.bot_name })
1527            : el('div', { id: 'c2ai-avatar-placeholder' }, [svgIcon('bot')]);
1528
1529        // Minimize (chevron) hides the panel + keeps the session; Close (X) ends it.
1530        var minBtn = el('button', { id: 'c2ai-min-btn', 'aria-label': 'Minim
1530ize chat', title: 'Minimize' }, [svgIcon('chevron-down')]);
1531        minBtn.addEventListener('click', closePanel);
1532        var closeBtn = el('button', { id: 'c2ai-close-btn', 'aria-label': 'Close chat', title: 'Close chat' }, [svgIcon('close')]);
1533        closeBtn.addEventListener('click', closeChat);
1534
1535        resetEl = el('span', { class: 'c2ai-reset-wrap' }, [' | ', resetLink]);
1536
1537        return {
1538            bubble: bubbleEl,
1539            panel: panelEl,
1540            header: el('div', { id: 'c2ai-header' }, [
1541                headerAvatar,
1542                el('span', { id: 'c2ai-header-name' }, [config.bot_name]),
1543                minBtn,
1544                closeBtn,
1545            ]),
1546            footer: el('div', { id: 'c2ai-footer' }, [inputEl, sendBtn]),
1547            branding: el('div', { id: 'c2ai-branding' }, [
1548                'Powered by ', el('a', { href: 'https://customer2.ai', target: '_blank', rel: 'noopener' }, ['Customer2AI']),
1549                resetEl,
1550            ]),
1551        };
1552    }
1553
1554    /**
1555     * v2 chrome — the port of "Chat Widget.dc.html".
1556     *
1557     * Returns panel:null for a voice-only agent, which is how buildWidget knows
1558     * to mount the launcher alone.
1559     *
1560     * @return {{bubble: Element, panel: ?Element, header: ?Element, footer: ?Element, branding: ?Element}}
1561     */
1562    function chromeV2(config, resetLink) {
1563        var chatOn  = config.channels.chat;
1564        var voiceOn = config.channels.voice;
1565
1566        // ---- launcher pill --------------------------------------------------
1567        // The orb slot carries the presence dot in the launcher only; inside the
1568        // header the status line already says "Online".
1569        var bubbleEl = el('button', {
1570            id: 'c2ai-bubble',
1571            'aria-label': config.launcher_label,
1572            'aria-expanded': 'false',
1573        }, [
1574            orbSlot(config.orb_hue, 92, true, config.orb_image_url, config.orb_image_bg_color, config.orb_image_bg_color_dark),
1575            el('span', { class: 'c2ai-launch-text' }, [
1576                el('span', { class: 'c2ai-launch-title' }, [config.launcher_label]),
1577                el('span', { class: 'c2ai-launch-sub' }, [
1578                    chatOn ? 'Online · Replies instantly' : 'Online · Tap to talk',
1579                ]),
1580            ]),
1581        ]);
1582
1583        // Voice-only: the launcher IS the call button. No panel, no transcript.
1584        if (!chatOn) {
1585            bubbleEl.addEventListener('click', function () { openVoice(); });
1586
1587            return { bubble: bubbleEl, panel: null, header: null, footer: null, branding: null };
1588        }
1589
1590        bubbleEl.addEventListener('click', togglePanel);
1591
1592        var panelEl = el('div', { id: 'c2ai-panel', role: 'dialog', 'aria-label': config.bot_name });
1593
1594        // ---- header ---------------------------------------------------------
1595        // Minimize (chevron) HIDES the panel but keeps the session + history; Close (X)
1596        // ENDS the session and returns to the start screen.
1597        var minBtn = el('button', { id: 'c2ai-min-btn', 'aria-label': 'Minimize chat', title: 'Minimize' }, [svgIcon('chevron-down')]);
1598        minBtn.addEventListener('click', closePanel);
1599        var closeBtn = el('button', { id: 'c2ai-close-btn', 'aria-label': 'Close chat', title: 'Close chat' }, [svgIcon('close')]);
1600        closeBtn.addEventListener('click', closeChat);
1601
1602        var headerKids = [
1603            // Open-panel image falls back to the launcher image, then the orb.
1604            orbSlot(config.orb_hue, 52, false, config.orb_open_image_url || config.orb_image_url, config.orb_image_bg_color, config.orb_image_bg_color_dark),
1605            el('div', { id: 'c2ai-header-meta' }, [
1606                el('span', { id: 'c2ai-header-name' }, [config.bot_name]),
1607                el('span', { id: 'c2ai-header-status' }, [
1608                    el('span', { class: 'c2ai-dot-live' }),
1609                    'Online · Replies instantly',
1610                ]),
1611            ]),
1612        ];
1613
1614        // The ONLY entry point to the voice channel. Rendered only when /config
1615        // handed over a voice key, which it does only when a call is actually
1616        // placeable from this origin — so this button is never a dead end.
1617        //
1618        // Deliberately NOT disabled while the pre-chat form is showing: an
1619        // unidentified visitor gets an anonymous call, exactly as the standalone
1620        // voice widget has always allowed via its Skip option.
1621        if (voiceOn) {
1622            var voiceBtn = el('button', {
1623                id: 'c2ai-voice-btn',
1624                type: 'button',
1625                title: 'Switch to a live voice chat',
1626            }, [svgIcon('graphic-eq'), 'Voice Chat']);
1627            voiceBtn.addEventListener('click', function () { openVoice(); });
1628            headerKids.push(voiceBtn);
1629        }
1630
1631        headerKids.push(minBtn);
1632        headerKids.push(closeBtn);
1633
1634        resetEl = el('span', { class: 'c2ai-reset-wrap' }, [' · ', resetLink]);
1635
1636        return {
1637            bubble: bubbleEl,
1638            panel: panelEl,
1639            header: el('div', { id: 'c2ai-header' }, headerKids),
1640            // The composer wrapper is what makes the textarea read as the mock's
1641            // pill — the textarea itself is styled flat inside it.
1642            footer: el('div', { id: 'c2ai-footer' }, [
1643                // "previous chat?" — pinned above the composer, hidden until an
1644                // anonymous visitor auto-starts an instant chat (see showEntry). It
1645                // opens the identity form to recall a previous account conversation.
1646                (prevChatLink = el('button', {
1647                    id: 'c2ai-prevchat', type: 'button', style: 'display:none;',
1648                }, ['previous chat?'])),
1649                el('div', { id: 'c2ai-composer' }, [inputEl, sendBtn]),
1650            ]),
1651            branding: el('div', { id: 'c2ai-branding' }, [
1652                el('a', { href: 'https://customer2.ai', target: '_blank', rel: 'noopener' }, ['Powered by Customer2AI']),
1653                resetEl,
1654            ]),
1655        };
1656    }
1657
1658    // -------------------------------------------------------------------------
1659    // The agent orb
1660    //
1661    // EAGER, and mounted without a swap. <agent-orb> goes into the DOM on the
1662    // first paint and the browser upgrades it in place when agent-orb.js lands
1663    // (kicked off from init, see loadOrb's caller). The clip's ::before carries
1664    // the orb's colour meanwhile, so there is no empty circle and no visible
1665    // substitution. See note 4 in the file header for why this is not lazy, and
1666    // what that costs.
1667    // -------------------------------------------------------------------------
1668
1669    var _orbPromise = null;
1670    // Set the moment any slot mounts a REAL <agent-orb> (as opposed to a custom
1671    // image). init() uses it to skip fetching three.js entirely when the launcher
1672    // and header are both custom images — voice still loads it on demand.
1673    var _orbNeeded = false;
1674
1675    /**
1676     * Build an orb slot: halo + clip + the real <agent-orb> (or a custom image).
1677     *
1678     * NOTHING IS SWAPPED. <agent-orb> is mounted immediately and the browser
1679     * upgrades it in place once agent-orb.js defines the element; until then the
1680     * clip's ::before shows the orb's colour, so the slot is never an empty hole.
1681     *
1682     * This replaced a lazy scheme that mounted a gradient disc and swapped in the
1683     * real orb on first panel open. It worked, but the swap happened UNDER THE
1684     * VISITOR'S CURSOR seconds after load, which read as a glitch (Steven,
1685     * 2026-08-25). Now the only visible change is a sub-second refinement at page
1686     * load, before anyone has interacted.
1687     *
1688     * `size` is explicit per call site rather than measured: at build time the
1689     * slot is not laid out yet, so offsetWidth is 0. Values are the mock's —
1690     * launcher 92 in a 46px clip, header 52 in 34px, voice 330 in 216px.
1691     *
1692     * `hue` arrives pre-validated against /^\d{1,3}(\.\d+)?deg$/ (see init), so
1693     * it is safe in a CSS context.
1694     */
1695    function orbSlot(hue, size, withPresence, imageUrl, bgColor, bgColorDark) {
1696        var slot = el('div', { class: 'c2ai-orb-slot' });
1697        slot.appendChild(el('span', { class: 'c2ai-orb-halo', 'aria-hidden': 'true' }));
1698
1699        var clip = el('span', { class: 'c2ai-orb', 'data-hue': hue });
1700        // setProperty rather than the style shorthand: a custom property is not
1701        // a recognised CSS property name, so cssText/style.foo silently drop it.
1702        // Stored as the full ANGLE ('268deg') because it feeds hue-rotate().
1703        try { clip.style.setProperty('--c2ai-hue', hue); } catch (e) { /* ignore */ }
1704
1705        // A custom image (HTTPS-validated upstream) REPLACES the generated orb. It is
1706        // shown whole (object-fit: contain) with padding over a solid background, and
1707        // the `.c2ai-orb--img` modifier kills the orb gradient underneath 
1707so a
1708        // transparent PNG/SVG never lets the orb show through. bgColor is a validated
1709        // #rrggbb (defaults to white in CSS). On a load error the <img> is dropped and
1710        // the modifier removed so the slot falls back to the generated orb gradient.
1711        if (imageUrl && /^https:\/\//i.test(imageUrl)) {
1712            clip.className = 'c2ai-orb c2ai-orb--img';
1713            // Per-mode backdrop as CSS custom props — the .c2ai-orb--img rule (+ its
1714            // prefers-color-scheme override) reads --c2ai-orb-bg-light / -dark and falls
1715            // back to the neutral defaults when a prop is unset. Setting a prop, not
1716            // `background` directly, is what lets the light/dark CSS switch on system theme.
1717            try {
1718                if (bgColor && /^#[0-9a-f]{6}$/i.test(bgColor)) { clip.style.setProperty('--c2ai-orb-bg-light', bgColor); }
1719                if (bgColorDark && /^#[0-9a-f]{6}$/i.test(bgColorDark)) { clip.style.setProperty('--c2ai-orb-bg-dark', bgColorDark); }
1720            } catch (e) { /* ignore */ }
1721            var img = el('img', { class: 'c2ai-orb-img', src: imageUrl, alt: '', 'aria-hidden': 'true' });
1722            img.addEventListener('error', function () {
1723                if (img.parentNode) { img.parentNode.removeChild(img); }
1724                // Fall back to the generated orb: drop the modifier (re-shows the
1725                // gradient) and clear the per-mode backdrop props.
1726                clip.className = 'c2ai-orb';
1727                try {
1728                    clip.style.removeProperty('--c2ai-orb-bg-light');
1729                    clip.style.removeProperty('--c2ai-orb-bg-dark');
1730                } catch (e) { /* ignore */ }
1731            });
1732            clip.appendChild(img);
1733        } else {
1734            // Mounted NOW, before agent-orb.js has loaded. An undefined custom
1735            // element is inert but legal, and the browser upgrades it in place the
1736            // instant customElements.define() runs.
1737            _orbNeeded = true;
1738            var orb = document.createElement('agent-orb');
1739            orb.setAttribute('size', String(size));
1740            orb.setAttribute('energy', '0.16');
1741            orb.setAttribute('hue', hue);
1742            clip.appendChild(orb);
1743        }
1744
1745        slot.appendChild(clip);
1746
1747        if (withPresence) {
1748            slot.appendChild(el('span', { class: 'c2ai-presence', 'aria-hidden': 'true' }));
1749        }
1750
1751        return slot;
1752    }
1753
1754    /**
1755     * Fetch agent-orb.js once. Resolves false when it is unavailable, and the
1756     * fallback disc simply stays — an orb is decoration, never a blocker.
1757     *
1758     * ONE script tag on purpose: agent-orb.js resolves three.js relative to its
1759     * own src (see its THREE_SRC note) specifically so it works on a third-party
1760     * site, so pre-injecting three.js ourselves would be redundant.
1761     */
1762    function loadOrb() {
1763        if (_orbPromise) {
1764            return _orbPromise;
1765        }
1766
1767        _orbPromise = new Promise(function (resolve) {
1768            if (window.customElements && customElements.get('agent-orb')) {
1769                resolve(true);
1770
1771                return;
1772            }
1773
1774            var s = document.createElement('script');
1775            s.src = ORB_URL;
1776            s.async = true;
1777            // 'load' also fires when the script downloaded but failed to PARSE
1778            // (agent-orb.js is ES2015+; an old browser chokes on `class`), so
1779            // success is "the element actually registered", not "the tag loaded".
1780            s.onload = function () {
1781                resolve(!!(window.customElements && customElements.get('agent-orb')));
1782            };
1783            s.onerror = function () { resolve(false); };
1784            document.head.appendChild(s);
1785        });
1786
1787        return _orbPromise;
1788    }
1789
1790    // =========================================================================
1791    // THE VOICE CHANNEL (v2 only)
1792    //
1793    // ── WHAT HAPPENS WHEN THE VISITOR CLICKS "VOICE CHAT" ────────────────────
1794    // 1. The CHAT SESSION IS ENDED. Not paused — ended. That is the product
1795    //    decision (Steven, 2026-08-25) and it is what makes the memory work:
1796    //    the voice leg carries the visitor's email/phone as Twilio custom
1797    //    params, so the relay resolves the SAME end-customer and the call reads
1798    //    the chat it just followed through cross-channel memory
1799    //    (App\Memory\ChannelMemory). When the call ends, the next message starts
1800    //    a NEW chat session which likewise recalls the call.
1801    // 2. The transcript STAYS ON SCREEN with a divider. The session really did
1802    //    end, so the thread says so rather than pretending it continued.
1803    // 3. The engine is voice-widget.js, injected keyless. We do not reimplement
1804    //    token brokering here.
1805    //
1806    // ⚠️ WHAT THIS DELIBERATELY DOES NOT DO: it does not render call turns. The
1807    // mock's line "Everything we cover is saved back to your chat" was removed
1808    // from the design because nothing in the stack can deliver it to a browser —
1809    // the Twilio Voice SDK leg carries AUDIO ONLY, and turn text lives in the
1810    // separate voiceserver repo. The agent remembers the call; the visitor does
1811    // not see a transcript of it. Do not re-add that copy without building the
1812    // feed first.
1813    // =========================================================================
1814
1815    /** The agent's voice embed key, or null when voice isn't available here. */
1816    var voiceKey = null;
1817
1818    /** Validated orb hue (a bare CSS angle). Set in init(); never read raw. */
1819    var orbHueSafe = '268deg';
1820
1821    /**
1822     * Whether `inert` can take the chat panel out of the Tab order while the
1823     * voice overlay is up. Newer than shadow DOM, so v2 can run without it —
1824     * absent just means no focus trap, which is the pre-fix behaviour.
1825     */
1826    var INERT_SUPPORTED = (typeof HTMLElement !== 'undefined')
1827        && HTMLElement.prototype
1828        && ('inert' in HTMLElement.prototype);
1829
1830    /** Overlay state for one call. Reset by teardownVoice(). */
1831    var voice = {
1832        opening: false,    // re-entrancy latch, set before any overlay exists
1833        el: null,          // the overlay root
1834        call: null,        // the C2AIVoice call controller
1835        orbClip: null,
1836        statusEl: null,
1837        hintEl: null,
1838        muteBtn: null,
1839        raf: 0,
1840        meter: null,
1841        mic: null,
1842        micData: null,
1843        remote: null,
1844        remoteData: null,
1845        seq: 0,            // supersedes a stale attempt's late callbacks
1846        onKey: null,
1847    };
1848
1849    var _voicePromise = null;
1850
1851    /**
1852     * Inject voice-widget.js (keyless) and resolve window.C2AIVoice.
1853     *
1854     * Resolves null when the engine is unavailable, and the overlay then shows
1855     * an error rather than a dead orb. Never throws.
1856     */
1857    function loadVoiceEngine() {
1858        if (_voicePromise) {
1859            return _voicePromise;
1860        }
1861
1862        _voicePromise = new Promise(function (resolve) {
1863            if (window.C2AIVoice && typeof window.C2AIVoice.startCall === 'function') {
1864                resolve(window.C2AIVoice);
1865
1866                return;
1867            }
1868
1869            var s = document.createElement('script');
1870            s.src = VOICE_SCRIPT_URL;
1871            s.async = true;
1872            s.onload = function () {
1873                // 'load' also fires when the file downloaded but failed to
1874                // parse, so success is "the API is actually there".
1875                resolve((window.C2AIVoice && typeof window.C2AIVoice.startCall === 'function')
1876                    ? window.C2AIVoice
1877                    : null);
1878            };
1879            s.onerror = function () { resolve(null); };
1880            document.head.appendChild(s);
1881        });
1882
1883        return _voicePromise;
1884    }
1885
1886    /**
1887     * Open the voice overlay and place the call.
1888     *
1889     * Reachable from the header button (chat + voice) and from the launcher
1890     * itself (voice-only). Always clickable by product decision — an
1891     * unidentified visitor simply gets an anonymous call, exactly as the
1892     * standalone voice widget has always allowed via its Skip option.
1893     */
1894    function openVoice() {
1895        // `opening` and not just `el`: voice.el is only set on the LAST line of
1896        // buildVoiceOverlay, so guarding on it alone happens to work today only
1897        // because steps 1-3 below are fully synchronous. One `.then()` added
1898        // anywhere in that stretch would make double-click open two overlays.
1899        if (!voiceKey || voice.el || voice.opening) { return; }
1900
1901        voice.opening = true;
1902
1903        var seq = ++voice.seq;
1904
1905        // ---- 1. end the chat session ------------------------------------
1906        // Best-effort and fire-and-forget: the call must not wait on it, and a
1907        // failure here only means the session is swept as idle later.
1908        if (state.sessionId && state.sessionUuid) {
1909            try {
1910                jsonPost(apiUrl('sessions/' + state.sessionId + '/end'), { session_uuid: state.sessionUuid }, function () {});
1911            } catch (e) { /* ignore */ }
1912        }
1913
1914        // Drop the session locally so the next message opens a fresh one. The
1915        // stored identity (contact / consent / remember-token) is deliberately
1916        // KEPT — this is the same person, mid-conversation, not a reset.
1917        clearSession();
1918        stopPolling();
1919        state.turnGen++;              // invalidate any in-flight turn callback
1920        state.pendingParts = [];
1921        state.isLoading = false;
1922        if (state.coalesceTimer) { clearTimeout(state.coalesceTimer); state.coalesceTimer = null; }
1923
1924        // ---- 2. mark the handoff in the thread ---------------------------
1925        if (panel) {
1926            hideTyping();
1927            appendDivider('Voice chat');
1928
1929            // The gate is mid-flight and its session just went away underneath
1930            // it. An OTP step left on screen would post to a dead session id and
1931            // tell the visitor their correct code was wrong, so rebuild the gate
1932            // from its first step instead. Stored contact/consent survive in
1933            // localStorage, so a returning visitor still skips it entirely.
1934            if (gateEl) {
1935                releaseTurnstile();
1936                if (gateEl.parentNode) { gateEl.parentNode.removeChild(gateEl); }
1937                gateEl = null;
1938                showGate();
1939            }
1940
1941            // The composer is meaningless while the overlay is up.
1942            if (inputEl) { inputEl.disabled = true; }
1943            if (sendBtn) { sendBtn.disabled = true; }
1944
1945            // FOCUS TRAP. aria-modal="true" on the overlay is advisory for
1946            // assistive tech only — it does nothing to the Tab order, and the
1947            // panel is still `.c2ai-open` underneath. Without this, tabbing from
1948            // the overlay reaches the panel's minimize chevron, and Enter there
1949            // runs closePanel() DURING A LIVE CALL: the panel hides and the
1950            // launcher un-hides behind the overlay.
1951            //
1952            // Feature-detected — `inert` is newer than shadow DOM, so an older
1953            // browser simply keeps today's behaviour rather than breaking.
1954            if (INERT_SUPPORTED) { panel.inert = true; }
1955        }
1956
1957        // ---- 3. build the overlay ---------------------------------------
1958        buildVoiceOverlay();
1959
1960        // ---- 4. connect --------------------------------------------------
1961        loadVoiceEngine().then(function (engine) {
1962            if (seq !== voice.seq || !voice.el) { return; }   // closed while loading
1963
1964            if (!engine) {
1965                showVoiceError('The voice service could not be loaded. Please try again.');
1966
1967                return;
1968            }
1969
1970            var params = {};
1971            if (state.contact && state.contact.email) { params.email = state.contact.email; }
1972            if (state.contact && state.contact.phone) { params.phone = state.contact.phone; }
1973
1974            voice.call = engine.startCall({
1975                tokenUrl: VOICE_API_BASE + voiceKey + '/token',
1976                // Omitted entirely when we know nothing — an anonymous call,
1977                // rather than a call keyed to empty strings.
1978                connectParams: Object.keys(params).length ? params : undefined,
1979                onCall: function (tc) {
1980                    if (seq !== voice.seq) { return; }
1981                    attachVoiceMeters(engine, tc);
1982                },
1983                onStatus: function (s) {
1984                    if (seq !== voice.seq) { return; }
1985                    if (s === 'in-call') {
1986                        setVoiceStatus('Listening');
1987                    } else if (s === 'ended') {
1988                        // The far end hung up (or the token expired) — return to
1989                        // chat rather than leaving a dead orb on screen.
1990                        closeVoice();
1991                    } else if (s === 'error') {
1992                        stopVoiceMeters();
1993                    }
1994                },
1995                onError: function (e) {
1996                    if (seq !== voice.seq) { return; }
1997                    // The token endpoint's own message is the useful one (demo
1998                    // cap reached, account on hold, domain not authorised,
1999                    // service unavailable) — surface it, not a generic string.
2000                    showVoiceError((e && e.message) ? e.message : 'Could not connect the call.');
2001                },
2002            });
2003        });
2004    }
2005
2006    /**
2007     * Tear the overlay down and hand back to chat.
2008     *
2009     * Every exit routes here — the End button, the keyboard button, the close
2010     * chevron, Escape, a backdrop click, and the SDK's own 'ended'. Bumping
2011     * voice.seq first means a late callback from the call being hung up cannot
2012     * repaint an overlay that is already gone.
2013     */
2014    function closeVoice() {
2015        voice.seq++;
2016
2017        stopVoiceMeters();
2018
2019        if (voice.call) {
2020            try { voice.call.hangup(); } catch (e) { /* already down */ }
2021        }
2022
2023        if (voice.onKey) {
2024            document.removeEventListener('keydown', voice.onKey);
2025        }
2026
2027        if (voice.el && voice.el.parentNode) {
2028            voice.el.parentNode.removeChild(voice.el);
2029        }
2030
2031        teardownVoice();
2032
2033        // Back to the chat panel. A voice-only agent has no panel, so it just
2034        // returns to the launcher.
2035        if (panel) {
2036            if (INERT_SUPPORTED) { panel.inert = false; }
2037
2038            // ⚠️ Only re-enable the composer when the pre-chat gate is NOT
2039            // showing. Unconditionally enabling it would let a visitor who
2040            // opened voice from the form stage type straight past the gate —
2041            // skipping the contact + consent capture the gate exists to make
2042            // unavoidable.
2043            if (!gateEl) {
2044                if (inputEl) {
2045                    inputEl.disabled = false;
2046                    // Restore the placeholder here, not in hideGate(): hideGate
2047                    // returns early while the overlay is up (so it cannot unlock
2048                    // the composer mid-call), which would otherwise leave the
2049                    // visitor with a live composer still reading "Enter your
2050                    // details above to start...".
2051                    inputEl.placeholder = 'Type a message...';
2052                    inputEl.focus();
2053                }
2054                if (sendBtn) { sendBtn.disabled = inputEl ? inputEl.value.trim() === '' : true; }
2055            }
2056            scrollToBottom();
2057        }
2058    }
2059
2060    function teardownVoice() {
2061        voice.opening = false;
2062        voice.el = null;
2063        voice.call = null;
2064        voice.orbClip = null;
2065        voice.statusEl = null;
2066        voice.hintEl = null;
2067        voice.muteBtn = null;
2068        voice.meter = null;
2069        voice.mic = null;
2070        voice.micData = null;
2071        voice.remote = null;
2072        voice.remoteData = null;
2073        voice.onKey = null;
2074    }
2075
2076    function buildVoiceOverlay() {
2077        var closeBtn = el('button', { id: 'c2ai-voice-close', class: 'c2ai-vbtn', type: 'button', 'aria-label': 'Back to chat' }, [svgIcon('close')]);
2078        closeBtn.addEventListener('click', closeVoice);
2079
2080        // orbHueSafe, not state.config.orb_hue — state.config holds the RAW
2081        // server payload, and this value lands in a CSS custom property.
2082        var slot = orbSlot(orbHueSafe, 330, false);
2083        voice.orbClip = slot.querySelector('.c2ai-orb');
2084
2085        // aria-live so a screen-reader user hears the state change; the animated
2086        // ellipsis is decorative and hidden from it.
2087        voice.statusEl = el('span', { id: 'c2ai-voice-status', 'aria-live': 'polite' }, ['Connecting']);
2088        voice.statusEl.appendChild(el('span', { class: 'c2ai-dot', 'aria-hidden': 'true' }, ['.']));
2089        voice.statusEl.appendChild(el('span', { class: 'c2ai-dot', 'aria-hidden': 'true' }, ['.']));
2090        voice.statusEl.appendChild(el('span', { class: 'c2ai-dot', 'aria-hidden': 'true' }, ['.']));
2091
2092        // The mock's second sentence ("Everything we cover is saved back to your
2093        // chat") is deliberately absent — see the section header.
2094        voice.hintEl = el('span', { id: 'c2ai-voice-hint' }, ['Speak naturally — you can interrupt anytime.']);
2095
2096        voice.muteBtn = el('button', { class: 'c2ai-vbtn', type: 'button', title: 'Mute', 'aria-label': 'Mute microphone' }, [svgIcon('mic')]);
2097        voice.muteBtn.addEventListener('click', toggleVoiceMute);
2098
2099        var endBtn = el('button', { id: 'c2ai-voice-end', class: 'c2ai-vbtn', type: 'button' }, [svgIcon('call-end'), 'End Voice Chat']);
2100        endBtn.addEventListener('click', closeVoice);
2101
2102        // Per the mock, the keyboard button is also an exit — it means "go back
2103        // to typing", which is the same action as ending the call.
2104        var kbBtn = el('button', { class: 'c2ai-vbtn', type: 'button', title: 'Switch to text', 'aria-label': 'Switch back to text chat' }, [svgIcon('keyboard')]);
2105        kbBtn.addEventListener('click', closeVoice);
2106
2107        var controls = [voice.muteBtn, endBtn];
2108        // A voice-only agent has no text chat to switch back TO, so offering
2109        // "switch to text" there would be a lie.
2110        if (panel) { controls.push(kbBtn); }
2111
2112        var overlay = el('div', {
2113            id: 'c2ai-voice',
2114            role: 'dialog',
2115            'aria-modal': 'true',
2116            'aria-label': 'Voice chat',
2117        }, [
2118            el('div', { id: 'c2ai-voice-top' }, [closeBtn]),
2119            el('div', { id: 'c2ai-voice-stage' }, [slot, voice.statusEl, voice.hintEl]),
2120            el('div', { id: 'c2ai-voice-controls' }, controls),
2121            el('div', { id: 'c2ai-voice-brand' }, ['Powered by Customer2AI']),
2122        ]);
2123
2124        // A full-screen overlay on someone else's page must never trap the
2125        // visitor: Escape and a click on the backdrop both exit, on top of the
2126        // mock's four buttons.
2127        voice.onKey = function (e) {
2128            if (e.key === 'Escape' || e.key === 'Esc') { closeVoice(); }
2129        };
2130        document.addEventListener('keydown', voice.onKey);
2131
2132        overlay.addEventListener('click', function (e) {
2133            // Only a click on the backdrop ITSELF, never one that bubbled up
2134            // from a control.
2135            if (e.target === overlay) { closeVoice(); }
2136        });
2137
2138        voice.el = overlay;
2139        shadow.appendChild(overlay);
2140
2141        // Move focus into the overlay so Escape and Tab act on it immediately.
2142        try { closeBtn.focus(); } catch (e) { /* ignore */ }
2143    }
2144
2145    function setVoiceStatus(label) {
2146        if (!voice.statusEl) { return; }
2147        // firstChild is the text node; the three dots after it are untouched.
2148        if (voice.statusEl.firstChild) {
2149            voice.statusEl.firstChild.nodeValue = label;
2150        }
2151    }
2152
2153    function showVoiceError(message) {
2154        if (!voice.el) { return; }
2155        stopVoiceMeters();
2156        setVoiceStatus('Not connected');
2157        if (voice.hintEl) {
2158            voice.hintEl.id = 'c2ai-voice-error';
2159            voice.hintEl.textContent = String(message);
2160        }
2161    }
2162
2163    /**
2164     * Wire the call's audio to the orb and the talk-state label.
2165     *
2166     * The orb is fed the REMOTE stream, so it animates when the AGENT speaks;
2167     * the visitor's own mic only drives the talk state. Same wiring as
2168     * voice-widget.js's attachMeters(), using the same exported helpers.
2169     */
2170    function attachVoiceMeters(engine, tc) {
2171        // This runs long after the click that opened the overlay, so a
2172        // just-created AudioContext can still be 'suspended' under the autoplay
2173        // policy and both analysers would read silent zeros for the whole call.
2174        try {
2175            var ctx = engine.audioContext && engine.audioContext();
2176            if (ctx && ctx.state === 'suspended') {
2177                ctx.resume().catch(function () { /* meters idle; audio unaffected */ });
2178            }
2179        } catch (e) { /* ignore */ }
2180
2181        voice.remote = engine.analyserFor(engine.streamFrom(tc, 'getRemoteStream'));
2182        if (voice.remote) {
2183            voice.remoteData = new Uint8Array(voice.remote.frequencyBinCount);
2184            feedOrb(voice.remote);
2185        }
2186
2187        voice.mic = engine.analyserFor(engine.streamFrom(tc, 'getLocalStream'));
2188        if (voice.mic) {
2189            voice.micData = new Uint8Array(voice.mic.frequencyBinCount);
2190        }
2191
2192        // Feature-detected: a visitor may hold a voice-widget.js cached from
2193        // before 3.2.0, which has no talkMeter. The call still works; the label
2194        // just stays on "Listening" instead of tracking who is talking.
2195        if (typeof engine.talkMeter === 'function' && (voice.mic || voice.remote)) {
2196            voice.meter = engine.talkMeter();
2197            if (!voice.raf) {
2198                voice.raf = requestAnimationFrame(voiceLevelLoop);
2199            }
2200        }
2201    }
2202
2203    /** Hand the orb the agent's audio, now or as soon as the bundle lands. */
2204    function feedOrb(analyser) {
2205        var orb = voice.orbClip && voice.orbClip.querySelector('agent-orb');
2206
2207        if (orb && typeof orb.setAudioAnalyser === 'function') {
2208            orb.setAudioAnalyser(analyser);
2209
2210            return;
2211        }
2212
2213        // The call connected before the ~590 KB orb finished downloading —
2214        // attach once it lands, if this call is still live.
2215        //
2216        // seq-guarded like every other async voice callback: open → close →
2217        // reopen while the orb is still downloading would otherwise attach the
2218        // FIRST call's analyser to the SECOND call's orb, so the orb would
2219        // animate to audio from a call that has ended.
2220        var seq = voice.seq;
2221
2222        loadOrb().then(function (ok) {
2223            if (!ok || !voice.el || seq !== voice.seq) { return; }
2224            var late = voice.orbClip && voice.orbClip.querySelector('agent-orb');
2225            if (late && typeof late.setAudioAnalyser === 'function') {
2226                late.setAudioAnalyser(analyser);
2227            }
2228        });
2229    }
2230
2231    function voiceLevelLoop() {
2232        if (!voice.el || !voice.meter) { voice.raf = 0; return; }
2233
2234        voice.raf = requestAnimationFrame(voiceLevelLoop);
2235
2236        var muted = !!(voice.call && voice.call.isMuted && voice.call.isMuted());
2237        var lvl = voice.meter.read(voice.mic, voice.micData, voice.remote, voice.remoteData, muted);
2238
2239        setVoiceStatus(muted ? 'Muted' : LABELS[lvl.talk] || 'Listening');
2240    }
2241
2242    var LABELS = { listening: 'Listening', thinking: 'Thinking', speaking: 'Speaking' };
2243
2244    function stopVoiceMeters() {
2245        if (voice.raf) { cancelAnimationFrame(voice.raf); voice.raf = 0; }
2246        voice.meter = null;
2247    }
2248
2249    function toggleVoiceMute() {
2250        if (!voice.call || typeof voice.call.mute !== 'function') { return; }
2251
2252        var next = !(voice.call.isMuted && voice.call.isMuted());
2253        try { voice.call.mute(next); } catch (e) { return; }
2254
2255        voice.muteBtn.className = 'c2ai-vbtn' + (next ? ' c2ai-muted' : '');
2256        voice.muteBtn.title = next ? 'Unmute' : 'Mute';
2257        voice.muteBtn.setAttribute('aria-label', next ? 'Unmute microphone' : 'Mute microphone');
2258        setIcon(voice.muteBtn, next ? 'mic-off' : 'mic');
2259    }
2260
2261    /** Replace a button's glyph, keeping any text label after it. */
2262    function setIcon(btn, name) {
2263        var old = btn.querySelector('svg');
2264        if (old) { btn.replaceChild(svgIcon(name), old); }
2265    }
2266
2267    // -------------------------------------------------------------------------
2268    // Panel open/close
2269    // -------------------------------------------------------------------------
2270
2271    function togglePanel() {
2272        state.isOpen ? closePanel() : openPanel();
2273    }
2274
2275    function openPanel() {
2276        state.isOpen = true;
2277        panel.classList.add('c2ai-open');
2278        bubble.setAttribute('aria-expanded', 'true');
2279
2280        if (renderMode === 'v2') {
2281            // The mock shows launcher and panel as mutually exclusive states, so
2282            // the pill HIDES rather than morphing into an X (there is a minimize
2283            // chevron in the header for closing). visibility+opacity rather than
2284            // display:none so it can transition and keeps its layout box in
2285            // inline mode, where removing it would reflow the host's nav.
2286            bubble.style.opacity = '0';
2287            bubble.style.visibility = 'hidden';
2288            bubble.style.pointerEvents = 'none';
2289        } else {
2290            bubble.innerHTML = svgIcon('close').outerHTML;
2291        }
2292
2293        inputEl.focus();
2294        // Auto-start the instant chat on FIRST open (deferred from showEntry so a
2295        // visitor who never opens the panel never creates a session). Guarded on no
2296        // existing session so a restored/verified one is never replaced.
2297        if (state._instantPending && !state.sessionId) {
2298            state._instantPending = false;
2299            startInstant();
2300        }
2301        // Repaint the transcript for a RESUMED session (localStorage kept the id) so
2302        // history survives page navigations. No-op once done / when there's no session.
2303        renderHistory();
2304        scrollToBottom();
2305        startPolling(); // resume the live-interrupt poll if a session already exists
2306        if (state.sessionId) { saveSession(); } // remember it's open for the next page
2307    }
2308
2309    function closePanel() {
2310        state.isOpen = false;
2311        panel.classList.remove('c2ai-open');
2312        bubble.setAttribute('aria-expanded', 'false');
2313
2314        if (renderMode === 'v2') {
2315            bubble.style.opacity = '';
2316            bubble.style.visibility = '';
2317            bubble.style.pointerEvents = '';
2318        } else {
2319            bubble.innerHTML = svgIcon('chat').outerHTML;
2320        }
2321
2322        stopPolling(); // don't poll while the panel is closed (session lives on — this is Minimize)
2323        if (state.sessionId) { saveSession(); } // remember it's minimized for the next page
2324    }
2325
2326    // -------------------------------------------------------------------------
2327    // Messages — all use DOM construction; no innerHTML.
2328    // -------------------------------------------------------------------------
2329
2330    function appendBotMessage(text) {
2331        var msg = el('div', { class: 'c2ai-msg c2ai-msg-bot' });
2332        msg.appendChild(formatMessageFragment(text));
2333        messagesEl.insertBefore(msg, typingEl);
2334        scrollToBottom();
2335        return msg;
2336    }
2337
2338    // The welcome bubble + (v2) the agent's quick-prompt pills. Every "fresh chat"
2339    // path calls this so the pills can't drift between first open, reset and close.
2340    function showWelcome(text) {
2341        appendBotMessage(text || (state.config && state.config.welcome_message) || 'Hi! How can I help you today?');
2342        // A session resumed from storage is mid-conversation — its history is about
2343        // to repaint, so no pills.
2344        if (state._resumedFromStorage && state.sessionId) { return; }
2345        appendQuickPrompts();
2346    }
2347
2348    // Pills sit right-aligned (the visitor's side). A click sends the message
2349    // exactly as if typed; appendUserMessage() then removes them.
2350    function appendQuickPrompts() {
2351        removeQuickPrompts();
2352        if (!state.quickPrompts || !state.quickPrompts.length) { return; }
2353        var wrap = el('div', { class: 'c2ai-quick', id: 'c2ai-quick' });
2354        state.quickPrompts.forEach(function (p) {
2355            var btn = el('button', { type: 'button', class: 'c2ai-quick-btn' }, [p.label]);
2356            btn.addEventListener('click', function () {
2357                if (inputEl.disabled) { return; } // identity form is up — finish it first
2358                inputEl.value = p.message;
2359                sendMessage();
2360            });
2361            wrap.appendChild(btn);
2362        });
2363        messagesEl.insertBefore(wrap, typingEl);
2364        scrollToBottom();
2365    }
2366
2367    function removeQuickPrompts() {
2368        var wrap = messagesEl && messagesEl.querySelector('#c2ai-quick');
2369        if (wrap) { wrap.parentNode.removeChild(wrap); }
2370    }
2371
2372    function appendUserMessage(text) {
2373        removeQuickPrompts();
2374        var msg = el('div', { class: 'c2ai-msg c2ai-msg-user' }, [String(text)]);
2375        messagesEl.insertBefore(msg, typingEl);
2376        scrollToBottom();
2377        return msg;
2378    }
2379
2380    function appendErrorMessage(text) {
2381        var msg = el('div', { class: 'c2ai-msg c2ai-msg-error' }, [String(text)]);
2382        messagesEl.insertBefore(msg, typingEl);
2383        scrollToBottom();
2384        return msg;
2385    }
2386
2387    /**
2388     * A labelled rule across the thread. Used for the chat→voice handoff, where
2389     * the session genuinely ended and the transcript above it belongs to a
2390     * finished session — so the thread says so instead of implying continuity.
2391     *
2392     * v2 only in practice (v1 has no voice channel and so never calls this), but
2393     * harmless in v1: the class simply has no rule in that sheet.
2394     */
2395    function appendDivider(label) {
2396        var row = el('div', { class: 'c2ai-divider', role: 'separator' }, [String(label)]);
2397        messagesEl.insertBefore(row, typingEl);
2398        scrollToBottom();
2399        return row;
2400    }
2401
2402    function showTyping() {
2403        typingEl.classList.add('c2ai-visible');
2404        typingEl.setAttribute('aria-hidden', 'false');
2405        scrollToBottom();
2406    }
2407
2408    function hideTyping() {
2409        typingEl.classList.remove('c2ai-visible');
2410        typingEl.setAttribute('aria-hidden', 'true');
2411        setProgress('');
2412    }
2413
2414    // Universal progress heartbeat: show the latest generic phase line ("Looking
2415    // that up…") the server emitted while the agent works. Cleared on turn end.
2416    function setProgress(text) {
2417        if (!progressEl) return;
2418        var next = typeof text === 'string' ? text : '';
2419        if (progressEl.textContent !== next) {
2420            progressEl.textContent = next;
2421        }
2422    }
2423
2424    function scrollToBottom() {
2425        setTimeout(function () {
2426            messagesEl.scrollTop = messagesEl.scrollHeight;
2427        }, 30);
2428    }
2429
2430    // -------------------------------------------------------------------------
2431    // Input helpers
2432    // -------------------------------------------------------------------------
2433
2434    function onInputKeydown(e) {
2435        if (e.key === 'Enter' && !e.shiftKey) {
2436            e.preventDefault();
2437            // No isLoading guard: sends while a turn is in flight are captured and
2438            // coalesced (buffered, flushed after the current turn) rather than dropped.
2439            if (!sendBtn.disabled) sendMessage();
2440        }
2441    }
2442
2443    function autoResizeInput() {
2444        inputEl.style.height = 'auto';
2445        inputEl.style.height = Math.min(inputEl.scrollHeight, 96) + 'px';
2446    }
2447
2448    // -------------------------------------------------------------------------
2449    // Pre-chat identity gate — collects email or phone so the conversation can
2450    // join the same person's cross-channel memory. Returning visitors skip it.
2451    // -------------------------------------------------------------------------
2452
2453    // Load Cloudflare's Turnstile api.js once (explicit-render mode), then run cb
2454    // when window.turnstile is ready. Runs onFail if the script is blocked (ad/tracker
2455    // blocker) or never initialises within 10s. No-op re-entrancy safe.
2456    function loadTurnstileScript(cb, onFail) {
2457        if (window.turnstile) { cb(); return; }
2458        var existing = document.getElementById('c2ai-cf-turnstile-api');
2459        if (existing) {
2460            var poll = setInterval(function () {
2461                if (window.turnstile) { clearInterval(poll);
2461 cb(); }
2462            }, 100);
2463            setTimeout(function () {
2464                clearInterval(poll);
2465                if (!window.turnstile && onFail) { onFail(); }
2466            }, 10000);
2467            return;
2468        }
2469        var s = document.createElement('script');
2470        s.id = 'c2ai-cf-turnstile-api';
2471        s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
2472        s.async = true;
2473        s.defer = true;
2474        s.onload = function () { if (window.turnstile) { cb(); } else if (onFail) { onFail(); } };
2475        s.onerror = function () { if (onFail) { onFail(); } };
2476        document.head.appendChild(s);
2477    }
2478
2479    // -------------------------------------------------------------------------
2480    // Entry point for an anonymous visitor.
2481    //
2482    // v2 → ALWAYS auto-start an instant chat (no two-button choice; instant_enabled
2483    // no longer gates the entry — product decision) and show a small "previous chat?"
2484    // link above the composer that opens the identity form to recall a prior account
2485    // conversation. v1 (legacy renderer) keeps opening on the identity gate.
2486    // -------------------------------------------------------------------------
2487    function showEntry() {
2488        if (renderMode === 'v2') {
2489            // Defer the instant-session start to the FIRST panel open (openPanel) —
2490            // this runs at widget init while the launcher is still collapsed, so
2491            // starting a session here would create one for every page visitor who
2492            // never opens the chat (and fire Turnstile on every page load
2492).
2493            state._instantPending = true;
2494            showPrevChatLink();
2495
2496            return;
2497        }
2498        showGate();
2499    }
2500
2501    function showPrevChatLink() {
2502        if (!prevChatLink) { return; }
2503        if (!prevChatLink._wired) {
2504            prevChatLink.addEventListener('click', previousChat);
2505            prevChatLink._wired = true;
2506        }
2507        prevChatLink.style.display = '';
2508    }
2509
2510    function hidePrevChatLink() {
2511        if (prevChatLink) { prevChatLink.style.display = 'none'; }
2512    }
2513
2514    // "previous chat?" → switch to the account/identity form to recall a previous
2515    // conversation. This REPLACES the just-started anonymous instant session: the
2516    // server won't re-send a one-time code to an already-started contactless session
2517    // (ensureSession reuses it and never re-sends contact), so the gate must mint a
2518    // FRESH session carrying the contact the visitor enters — exactly the pre-instant
2519    // identity path. An un-typed instant session is empty; a mid-chat click discards
2520    // the current anonymous chat, which is why the click is a deliberate labelled action.
2521    function previousChat() {
2522        hidePrevChatLink();
2523        // End the anonymous instant session server-side (best-effort) and drop it
2524        // client-side so ensureSession() mints a fresh one with the entered contact.
2525        if (state.sessionId && state.sessionUuid) {
2526            try { jsonPost(apiUrl('sessions/' + state.sessionId + '/end'), { session_uuid: state.sessionUuid }, function () {}); } catch (e) { /* ignore */ }
2527        }
2528        clearSession();
2529        // Release any live Turnstile widget + drop its single-use token before the DOM
2530        // wipe below removes the challenge container — otherwise the instance is orphaned
2531        // in Cloudflare's registry and a stale token gets replayed on the next start.
2532        releaseTurnstile();
2533        state.turnstileToken = null;
2534        state.turnGen++;                 // invalidate any in-flight instant-start callback
2535        state.isLoading        = false;
2536        state.pendingParts     = [];
2537        state._historyRendered = false;
2538        if (state.coalesceTimer) { clearTimeout(state.coalesceTimer); state.coalesceTimer = null; }
2539        stopPolling();
2540        state.lastSeenId = 0;
2541        // Rebuild the transcript to just the welcome, then show the identity form.
2542        while (messagesEl.firstChild) { messagesEl.removeChild(messagesEl.firstChild); }
2543        messagesEl.appendChild(typingEl);
2544        gateEl = null;
2545        hideTyping();
2546        sendBtn.disabled = true;
2547        appendBotMessage((state.config && state.config.welcome_message) || 'Hi! How can I help you today?');
2548        showGate();
2549    }
2550
2551    // Create an empty gate overlay in the transcript if one is not already up, so
2552    // setGateContent() has somewhere to paint. Auto-instant no longer goes through
2553    // showChoice (which used to build the gate first), so the Turnstile branch below
2554    // must ensure its own.
2555    function ensureGate() {
2556        if (gateEl) { return; }
2557        removeQuickPrompts(); // no clickable-looking pills behind a gate
2558        gateEl = el('div', { class: 'c2ai-gate' }, []);
2559        messagesEl.insertBefore(gateEl, typingEl);
2560    }
2561
2562    // "Chat Instantly": start a session with NO contact and NO consent. On the
2563    // website agent (the only one challenged) run Turnstile first so startSession
2564    // is not rejected; a legit visitor solves the managed challenge automatically.
2565    // Everything is turnGen-guarded like the verification flow — a "Not you?" or a
2566    // voice switch landing mid-start must not resurrect this step.
2567    function startInstant() {
2568        var gen = state.turnGen;
2569
2570        var siteKey = state.config && state.config.turnstile_site_key;
2571        if (!siteKey || state.turnstileToken) {
2572            beginInstantSession(gen);
2573
2574            return;
2575        }
2576
2577        var blockedMsg = 'Could not load the spam check. Please allow challenges.cloudflare.com (disable any ad/tracker blocker for this site) and reload.';
2578        var tsEl  = el('div', { class: 'cf-turnstile' });
2579        var errEl = el('div', { class: 'c2ai-gate-error', 'aria-live': 'polite' });
2580        ensureGate(); // auto-instant path has no pre-built gate; the challenge needs one
2581        inputEl.disabled = true; // block sends until the challenge clears (hideGate re-enables)
2582        setGateContent([
2583            el('div', { class: 'c2ai-gate-title' }, ['One moment...']),
2584            el('div', { class: 'c2ai-gate-note' }, ["Just checking you're human..."]),
2585            tsEl,
2586            errEl,
2587        ]);
2588
2589        loadTurnstileScript(function () {
2590            try {
2591                state.turnstileWidgetId = window.turnstile.render(tsEl, {
2592                    sitekey: siteKey,
2593                    callback: function (token) {
2594                        if (gen !== state.turnGen) { return; }   // superseded — drop
2595                        state.turnstileToken = token;
2596                        beginInstantSession(gen);
2597                    },
2598                    'error-callback': function () {
2599                        if (gen !== state.turnGen) { return; }
2600                        errEl.textContent = blockedMsg;
2601                    },
2602                });
2603            } catch (e) {
2604                errEl.textContent = blockedMsg;
2605            }
2606        }, function () { errEl.textContent = blockedMsg; });
2607    }
2608
2609    function beginInstantSession(gen) {
2610        state.entryMode = 'instant';
2611        setGateContent([
2612            el('div', { class: 'c2ai-gate-title' }, ['One moment...']),
2613            el('div', { class: 'c2ai-gate-note' }, ['Starting your chat.']),
2614        ]);
2615        ensureSession(function (err) {
2616            if (gen !== state.turnGen) { return; }   // superseded — drop
2617            // Whether or not the start succeeded, drop the overlay and let them
2618            // type — sendMessage retries a failed start. No OTP step: an instant
2619            // chat carries no contact to verify.
2620            hideGate();
2621        });
2622    }
2623
2624    function showGate() {
2625        if (gateEl) { return; }
2626        removeQuickPrompts(); // no clickable-looking pills behind a gate
2627        inputEl.disabled = true;
2628        inputEl.placeholder = 'Enter your details above to start...';
2629
2630        var title      = el('div',   { class: 'c2ai-gate-title' }, ['Before we start, how can we reach you?']);
2631        // FIRST + LAST, both OPTIONAL — and only in v2 (Steven, 2026-08-25).
2632        //
2633        // The names are ASKED FOR but never block: email-or-phone plus consent is
2634        // still the only gate, exactly as before. "(optional)" is on both
2635        // placeholders because the contrast with a plain "Email"/"Phone" is what
2636        // tells the visitor which fields actually matter — the note below carries
2637        // the email-or-phone rule.
2638        //
2639        // v1 keeps its single 'Name (optional)' field, unchanged. v1 is the
2640        // kill-switch rollback; its value is being the code that was already
2641        // running, so it does not get new fields at all. The server keeps both
2642        // parts nullable — see ChatWidgetController::composeGateName().
2643        var splitName  = (renderMode === 'v2');
2644        var nameInput  = splitName
2645            ? el('input', { type: 'text', class: 'c2ai-gate-field', placeholder: 'First name (optional)', 'aria-label': 'Your first name', maxlength: '120', autocomplete: 'given-name' })
2646            : el('input', { type: 'text',  class: 'c2ai-gate-field', placeholder: 'Name (optional)', 'aria-label': 'Your name', maxlength: '255' });
2647        var lastInput  = splitName
2648            ? el('input', { type: 'text', class: 'c2ai-gate-field', placeholder: 'Last name (optional)', 'aria-label': 'Your last name', maxlength: '120', autocomplete: 'family-name' })
2649            : null;
2650        var emailInput = el('input', { type: 'email', class: 'c2ai-gate-field', placeholder: 'Email', 'aria-label': 'Your email', maxlength: '255' });
2651        var phoneInput = el('input', { type: 'tel',   class: 'c2ai-gate-field', placeholder: 'Phone', 'aria-label': 'Your phone', maxlength: '32' });
2652        var note       = el('div',   { class: 'c2ai-gate-note' }, ['Enter your email or phone so we can pick up where you left off.']);
2653
2654        // Required policy consent. The links open the SMS + Email policies in a
2655        // new tab; the box must be checked to continue (and is the legal basis
2656        // for sending any verification SMS/email).
2657        var emailPolicyUrl = (state.config && state.config.email_policy_url) || (BASE_URL + '/email-policy');
2658        var smsPolicyUrl   = (state.config && state.config.sms_policy_url)   || (BASE_URL + '/sms-policy');
2659        var consentInput   = el('input', { type: 'checkbox', class: 'c2ai-gate-consent-cb', 'aria-label': 'I agree to the Email and SMS policies' });
2660        var emailLink      = el('a', { href: emailPolicyUrl, target: '_blank', rel: 'noopener' }, ['Email']);
2661        var smsLink        = el('a', { href: smsPolicyUrl,   target: '_blank', rel: 'noopener' }, ['SMS']);
2662        var consentLabel   = el('label', { class: 'c2ai-gate-consent' }, [consentInput, ' I agree to the ', emailLink, ' and ', smsLink, ' policies.']);
2663        var consentNote    = el('div', { class: 'c2ai-gate-note' }, ['Required to chat. You consent to receive verification codes and service messages by SMS/email at the contact you provide. Frequency varies; message and data rates may apply; reply STOP to opt out.']);
2664
2665        var errEl      = el('div',   { class: 'c2ai-gate-error', 'aria-live': 'polite' });
2666        var startBtn   = el('button', { class: 'c2ai-gate-btn', type: 'button' }, ['Start chat']);
2667
2668        // Cloudflare Turnstile anti-spam challenge — rendered ONLY when the server
2669        // supplied a site key (website chat agent, anonymous visitor). The widget
2670        // injects a hidden `cf-turnstile-response` input we read on submit.
2671        var turnstileEl = null;
2672        var siteKey = state.config && state.config.turnstile_site_key;
2673        if (siteKey) {
2674            turnstileEl = el('div', { class: 'cf-turnstile' });
2675
2676            var blockedMsg = 'Could not load the spam check. Please allow challenges.cloudflare.com (disable any ad/tracker blocker for this site) and reload.';
2677
2678            loadTurnstileScript(function () {
2679                try {
2680                    // Keep the widget id so the instance can be released when the
2681                    // gate is torn down and rebuilt (openVoice does that). Without
2682                    // it, each rebuild abandons a Turnstile instance in
2683                    // Cloudflare's own registry.
2684                    state.turnstileWidgetId = window.turnstile.render(turnstileEl, { sitekey: siteKey });
2685                } catch (e) {
2686                    // v1 swallowed this silently. Keep it that way on the v1 path:
2687                    // v1 is the rollback and must stay behaviourally identical.
2688                    if (renderMode === 'v2') { errEl.textContent = blockedMsg; }
2689
2690                    return;
2691                }
2692
2693                // v2 ONLY — see the watchdog note below. On v1 the gate is in the
2694                // document, where Turnstile has always worked, so adding a 4s
2695                // deadline there would only invent a new way to show a scary
2696                // message on a slow connection.
2697                if (renderMode !== 'v2') { return; }
2698
2699                // ⚠️ WATCHDOG — v2 renders this gate inside a SHADOW ROOT.
2700                //
2701                // Turnstile's explicit-render mode takes an element reference, so
2702                // it should be indifferent to living in a shadow tree, and the
2703                // `cf-turnstile-response` input it writes is a child of the
2704                // container we hold. But if it ever ISN'T indifferent, the failure
2705                // mode without this check is the worst kind: an empty box, no
2706                // token, and submitGate() refusing forever with "please complete
2707                // the spam-check challenge" that the visitor cannot complete.
2708                //
2709                // So: if no iframe has appeared shortly after render, say the
2710                // actionable thing instead of nothing. Only reachable on the C2AI
2711                // website agent for anonymous visitors (a customer's own embed is
2712                // never challenged), which is same-origin, so this is our own
2713                // signup funnel — worth a belt.
2714                setTimeout(function () {
2715                    if (turnstileEl && !turnstileEl.querySelector('iframe')) {
2716                        console.warn('[C2AI] Widget: Turnstile did not render. If this is v2, suspect shadow-DOM incompatibility.');
2717                        errEl.textContent = blockedMsg;
2718                    }
2719                }, 4000);
2720            }, function () {
2721                // api.js blocked (ad/tracker blocker) or failed to init — the
2722                // challenge can't be solved, so tell the visitor how to proceed.
2723                errEl.textContent = blockedMsg;
2724            });
2725        }
2726
2727        function turnstileToken() {
2728            if (!turnstileEl) { return null; }
2729            var input = turnstileEl.querySelector('input[name="cf-turnstile-response"]');
2730            return input && input.value ? input.value : '';
2731        }
2732
2733        function submitGate() {
2734            var name  = nameInput.value.trim();
2735            var last  = lastInput ? lastInput.value.trim() : '';
2736            var email = emailInput.value.trim();
2737            var phone = phoneInput.value.trim();
2738            var emailOk = email !== '' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
2739            var phoneOk = phone.replace(/\D+/g, '').length >= 7;
2740
2741            // No name check: first/last are collected, never required. Only
2742            // email-or-phone and consent gate this form.
2743            if (!emailOk && !phoneOk) {
2744                errEl.textContent = 'Please enter a valid email or phone number.';
2745                return;
2746            }
2747            if (!consentInput.checked) {
2748                errEl.textContent = 'Please agree to the Email and SMS policies to continue.';
2749                return;
2750            }
2751            if (turnstileEl && !turnstileToken()) {
2752                errEl.textContent = 'Please complete the spam-check challenge to continue.';
2753                return;
2754            }
2755            state.turnstileToken = turnstileToken();
2756            // `name` stays the composed full name so nothing downstream changes;
2757            // the parts ride alongside it (the server records both — see
2758            // composeGateName). In v1 `last` is '' and this is the old shape.
2759            saveContact({
2760                name:  splitName
2761                    ? (name + (last !== '' ? ' ' + last : '')) || null
2762                    : (name !== '' ? name : null),
2763                first_name: splitName && name !== '' ? name : null,
2764                last_name:  splitName && last !== '' ? last : null,
2765                email: emailOk ? email : null,
2766                phone: phoneOk ? phone : null
2767            });
2768            saveConsent(true);
2769            state.entryMode = 'support';
2770            // Consent recorded -> run identity verification (always available).
2771            beginVerification();
2772        }
2773
2774        // LIVE-DISABLED START BUTTON — v2 only.
2775        //
2776        // The mock draws this button in two states and stylesV2 has the disabled
2777        // grey chip for it, but nothing was ever setting `disabled`, so the only
2778        // feedback was an error message AFTER a click. This is the mock's own
2779        // gating: agree + (email or phone).
2780        //
2781        // The name fields are NOT part of it — they are optional, so a filled or
2782        // empty name must never change whether this button works.
2783        //
2784        // Deliberately does NOT include the Turnstile token either: there is no
2785        // event to observe it, and polling Cloudflare's hidden input on a timer to
2786        // grey a button is not worth it. That one stays a click-time message.
2787        function gateReady() {
2788            var email = emailInput.value.trim();
2789            var phone = phoneInput.value.trim();
2790            var contactOk = (email !== '' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
2791                || phone.replace(/\D+/g, '').length >= 7;
2792
2793            return contactOk && consentInput.checked;
2794        }
2795
2796        function refreshStartBtn() {
2797            startBtn.disabled = !gateReady();
2798        }
2799
2800        function onGateKey(e) { if (e.key === 'Enter') { e.preventDefault(); submitGate(); } }
2801        startBtn.addEventListener('click', submitGate);
2802        nameInput.addEventListener('keydown', onGateKey);
2803        if (lastInput) { lastInput.addEventListener('keydown', onGateKey); }
2804
2805        // v1 keeps an always-enabled button, exactly as it shipped.
2806        // Only the fields gateReady() actually reads are watched — the name inputs
2807        // cannot change the outcome, so listening to them would be dead work.
2808        if (splitName) {
2809            emailInput.addEventListener('input', refreshStartBtn);
2810            phoneInput.addEventListener('input', refreshStartBtn);
2811            consentInput.addEventListener('change', refreshStartBtn);
2812            refreshStartBtn();   // starts disabled
2813        }
2814        emailInput.addEventListener('keydown', onGateKey);
2815        phoneInput.addEventListener('keydown', onGateKey);
2816
2817        var gateNodes = [title, nameInput, lastInput, emailInput, phoneInput, note, consentLabel, consentNote];
2818        if (turnstileEl) { gateNodes.push(turnstileEl); }
2819        gateNodes.push(errEl, startBtn);
2820        gateEl = el('div', { class: 'c2ai-gate' }, gateNodes);
2821        messagesEl.insertBefore(gateEl, typingEl);
2822        scrollToBottom();
2823        // Email, in BOTH renderers: focus the first field the visitor actually has
2824        // to fill. The name fields sit above it but are optional, so landing there
2825        // would put the cursor in a box they can skip.
2826        setTimeout(function () { emailInput.focus(); }, 50);
2827    }
2828
2829    /**
2830     * Release the Cloudflare Turnstile instance, if one is live.
2831     *
2832     * Only relevant to the C2AI website agent (the only one challenged), and only
2833     * when the gate is destroyed and rebuilt rather than simply passed — see
2834     * openVoice(). Best-effort: removing an already-gone widget throws in some
2835     * api.js builds, and failing to tidy up must never block anything.
2836     */
2837    function releaseTurnstile() {
2838        if (!state.turnstileWidgetId || !window.turnstile) { return; }
2839
2840        try { window.turnstile.remove(state.turnstileWidgetId); } catch (e) { /* already gone */ }
2841
2842        state.turnstileWidgetId = null;
2843    }
2844
2845    function hideGate() {
2846        if (gateEl && gateEl.parentNode) { gateEl.parentNode.removeChild(gateEl); }
2847        gateEl = null;
2848        // Gate done on a still-empty chat — bring the pills back.
2849        if (!messagesEl.querySelector('.c2ai-msg-user') && !(state._resumedFromStorage && state.sessionId)) {
2850            appendQuickPrompts();
2851        }
2852
2853        // Leave the composer locked while the voice overlay owns the screen —
2854        // openVoice() disabled it deliberately, and an OTP callback resolving
2855        // mid-call must not quietly unlock it or steal focus out of a modal
2856        // dialog. closeVoice() re-enables it on the way back.
2857        if (voice.el) {
2858            refreshResetLink();
2859
2860            return;
2861        }
2862
2863        inputEl.disabled = false;
2864        inputEl.placeholder = 'Type a message...';
2865        setTimeout(function () { inputEl.focus(); }, 30);
2866        refreshResetLink();
2867    }
2868
2869    // -------------------------------------------------------------------------
2870    // Reset / "Not you?" — sign the remembered visitor out ON THIS DEVICE.
2871    // Clears the stored identity (contact + remember-device token + consent) and
2872    // the session, ends the server session, and returns to the gate so a
2873    // different person (e.g. on a shared/public computer) starts clean. The link
2874    // only shows once there is an identity to clear.
2875    // -------------------------------------------------------------------------
2876
2877    function refreshResetLink() {
2878        if (!resetEl) { return; }
2879        var hasIdentity = !!(state.contact || state.consented || state.sessionId);
2880        resetEl.style.display = hasIdentity ? '' : 'none';
2881    }
2882
2883    function resetWidget() {
2884        // End the current server session (best-effort) so the prior person's
2885        // session is closed rather than left open.
2886        if (state.sessionId && state.sessionUuid) {
2887            try {
2888                jsonPost(apiUrl('sessions/' + state.sessionId + '/end'), { session_uuid: state.sessionUuid }, function () {});
2889            } catch (e) { /* ignore */ }
2890        }
2891
2892        // Forget everything this device remembered about the visitor.
2893        clearSession();
2894        releaseTurnstile();              // don't orphan a live challenge / replay its token
2895        state.turnstileToken = null;
2896        state.contact       = null;
2897        state.verified      = false;
2898        state.consented     = false;
2899        state.rememberToken = null;
2900        state.entryMode     = null;
2901        state.isLoading     = false;
2902        state.pendingParts  = [];
2903        state.turnGen++;   // invalidate any in-flight turn's callback (see doSend)
2904        stopPolling();     // stop the prior visitor's live-interrupt poll
2905        state.lastSeenId = 0; // reset the poll cursor for the next person
2906        if (state.coalesceTimer) { clearTimeout(state.coalesceTimer); state.coalesceTimer = null; }
2907        try { localStorage.removeItem(CONTACT_STORAGE_KEY); } catch (e) { /* ignore */ }
2908        saveVerifyToken(null);
2909        saveConsent(false);
2910
2911        // Rebuild the transcript: clear all messages, keep the typing indicator,
2912        // re-show the welcome + the (consent) gate for the next person.
2913        while (messagesEl.firstChild) { messagesEl.removeChild(messagesEl.firstChild); }
2914        messagesEl.appendChild(typingEl);
2915        gateEl = null;
2916        hideTyping();
2917        sendBtn.disabled = true;
2918        showWelcome();
2919        showEntry();
2920        refreshResetLink();
2921    }
2922
2923    // Repaint the full transcript ONCE for a session resumed from localStorage (page
2924    // navigation kept the id) so history survives across pages. Fetches user +
2925    // assistant messages in order, renders them, then primes the live-poll cursor past
2926    // them. Runs at most once per load, only when a session exists.
2927    function renderHistory() {
2928        // Only for a session RESUMED from storage (a fresh same-page session renders
2929        // its turns live — repainting would DUPLICATE them). One-shot per load.
2930        if (state._historyRendered || !state._resumedFromStorage || !state.sessionId || !state.sessionUuid) { return; }
2931        state._historyRendered = true; // set up-front so a re-open mid-fetch can't double-render
2932        var gen = state.turnGen;
2933        var url = apiUrl('sessions/' + state.sessionId + '/history')
2934            + '?session_uuid=' + encodeURIComponent(state.sessionUuid);
2935        try {
2936            jsonGet(url, function (err, data, httpStatus) {
2937                if (gen !== state.turnGen) { return; }
2938                // The stored session was ended/purged server-side (past its idle TTL):
2939                // drop it so the next send starts a fresh session instead of a dead one.
2940                if (httpStatus === 404) { clearSession(); state._resumedFromStorage = false; appendQuickPrompts(); return; }
2941                if (err || !data || !data.messages || !data.messages.length) { return; }
2942                var msgs = data.messages;
2943                for (var i = 0; i < msgs.length; i++) {
2944                    var m = msgs[i];
2945                    if (!m) { continue; }
2946                    if (m.role === 'user') { appendUserMessage(m.content || ''); }
2947                    else { appendBotMessage(m.content || ''); }
2948                    if (typeof m.id !== 'undefined' && m.id > state.lastSeenId) { state.lastSeenId = m.id; }
2949                }
2950                scrollToBottom();
2951            });
2952        } catch (e) { state._historyRendered = false; /* let a later open retry */ }
2953    }
2954
2955    // Explicit Close (header X): END the session server-side, wipe the transcript, and
2956    // return to the initial start screen — a fresh chat. (Minimize/closePanel only
2957    // HIDES the panel and keeps the session + history.) The remembered contact is KEPT
2958    // so a returning visitor is still recognised on the next chat.
2959    function closeChat() {
2960        if (state.sessionId && state.sessionUuid) {
2961            try { jsonPost(apiUrl('sessions/' + state.sessionId + '/end'), { session_uuid: state.sessionUuid }, function () {}); } catch (e) { /* ignore */ }
2962        }
2963        clearSession();                 // drops session id/uuid + the persisted open flag
2964        releaseTurnstile();             // don't orphan a live challenge / replay its token
2965        state.turnstileToken = null;
2966        state.turnGen++;                // invalidate any in-flight turn callback
2967        state.isLoading        = false;
2968        state.pendingParts     = [];
2969        state._historyRendered = false;
2970        if (state.coalesceTimer) { clearTimeout(state.coalesceTimer); state.coalesceTimer = null; }
2971        stopPolling();
2972        state.lastSeenId = 0;
2973        // Rebuild the transcript back to the welcome + entry, exactly like a first open.
2974        while (messagesEl.firstChild) { messagesEl.removeChild(messagesEl.firstChild); }
2975        messagesEl.appendChild(typingEl);
2976        gateEl = null;
2977        hideTyping();
2978        sendBtn.disabled = true;
2979        showWelcome();
2980        showEntry();
2981        refreshResetLink();
2982        // Close also MINIMIZES: collapse the panel to the launcher bubble instead of
2983        // leaving a fresh welcome screen open. clearSession() already dropped the id,
2984        // so closePanel's saveSession() is a no-op (nothing persisted = fully shut).
2985        closePanel();
2986    }
2987
2988    // -------------------------------------------------------------------------
2989    // Verification flow — unlocks cross-channel memory recall.
2990    //
2991    // After the contact step we start the session (which honours a stored
2992    // remember-device token). If that already verified us, we're done.
2993    // Otherwise the server sends ONE code to BOTH the visitor's SMS and email
2994    // (whichever are on file) and we swap the gate to a code step with a Resend
2995    // option. The visitor can always "Continue without verifying" and chat
2996    // without cross-channel recall; if no channel is available we skip to chat.
2997    // -------------------------------------------------------------------------
2998
2999    function setGateContent(nodes) {
3000        if (!gateEl) { return; }
3001        while (gateEl.firstChild) { gateEl.removeChild(gateEl.firstChild); }
3002        for (var i = 0; i < nodes.length; i++) { if (nodes[i]) { gateEl.appendChild(nodes[i]); } }
3003        scrollToBottom();
3004    }
3005
3006    // ⚠️ EVERY CALLBACK IN THIS FLOW IS turnGen-GUARDED (QA 2026-08-25).
3007    //
3008    // `state.turnGen` is bumped by anything that invalidates the current
3009    // visitor/session: "Not you?" (resetWidget) and, since v2, starting a voice
3010    // call (openVoice ends the chat session). Without the guard, a send-otp reply
3011    // landing AFTER one of those would call setGateContent and paint an "Enter
3012    // your code" step over the rebuilt gate — pointing at a session id that no
3013    // longer exists. Submitting the CORRECT code would then post to
3014    // `sessions/null/verify-otp` and tell the visitor it was wrong, permanently.
3015    function beginVerification() {
3016        var gen = state.turnGen;
3017
3018        setGateContent([
3019            el('div', { class: 'c2ai-gate-title' }, ['One moment...']),
3020            el('div', { class: 'c2ai-gate-note' }, ['Setting up your chat.']),
3021        ]);
3022        ensureSession(function (err) {
3023            if (gen !== state.turnGen) { return; }   // superseded — drop
3024            if (err) {
3025                // Couldn't start a session — let them chat anyway; sendMessage retries.
3026                hideGate();
3027                return;
3028            }
3029            if (state.verified) { hideGate(); return; } // remember-device hit
3030            requestCode(gen);
3031        });
3032    }
3033
3034    function requestCode(gen) {
3035        setGateContent([
3036            el('div', { class: 'c2ai-gate-title' }, ["Verifying it's you..."]),
3037            el('div', { class: 'c2ai-gate-note' }, ['Sending your one-time code.']),
3038        ]);
3039        jsonPost(apiUrl('sessions/' + state.sessionId + '/send-otp'),
3040            { session_uuid: state.sessionUuid },
3041            function (err, data) {
3042                if (gen !== state.turnGen) { return; }   // superseded — drop
3043                if (data && data.reason === 'verified') { state.verified = true; hideGate(); return; }
3044                var channels = (data && data.channels) || [];
3045                if (err || channels.length === 0) {
3046                    // No way to send a code — proceed without cross-channel recall.
3047                    hideGate();
3048                    return;
3049                }
3050                renderCodeStep(channels, (data && data.masked) || {}, gen);
3051            });
3052    }
3053
3054    // Build the "text •••1234 and email ab•••@x.com" phrase from the channels we
3055    // sent to (the code is the same on both — enter it from whichever arrives).
3056    function sentToPhrase(channels, masked) {
3057        var parts = [];
3058        if (channels.indexOf('sms') !== -1 && masked.sms) { parts.push('text ' + masked.sms); }
3059        if (channels.indexOf('email') !== -1 && masked.email) { parts.push('email ' + masked.email); }
3060        return parts.join(' and ') || 'your contact info';
3061    }
3062
3063    function renderCodeStep(channels, masked, gen) {
3064        var title     = el('div',   { class: 'c2ai-gate-title' }
3064, ['Enter your code']);
3065        var note      = el('div',   { class: 'c2ai-gate-note' }, ['We sent a 6-digit code by ' + sentToPhrase(channels, masked) + '. Enter it to recall your history.']);
3066        var codeInput = el('input', { type: 'text', inputmode: 'numeric', class: 'c2ai-gate-field', placeholder: '6-digit code', 'aria-label': 'Verification code', maxlength: '6' });
3067        var errEl     = el('div',   { class: 'c2ai-gate-error', 'aria-live': 'polite' });
3068        var verifyBtn = el('button', { class: 'c2ai-gate-btn', type: 'button' }, ['Verify']);
3069        var resendBtn = el('button', { class: 'c2ai-gate-btn c2ai-gate-skip', type: 'button' }, ['Resend code']);
3070        var skipBtn   = el('button', { class: 'c2ai-gate-btn c2ai-gate-skip', type: 'button' }, ['Continue without verifying']);
3071
3072        function submitCode() {
3073            var code = codeInput.value.replace(/\D+/g, '');
3074            if (code.length < 6) { errEl.textContent = 'Enter the 6-digit code.'; return; }
3075            verifyBtn.disabled = true;
3076            errEl.textContent = '';
3077            jsonPost(apiUrl('sessions/' + state.sessionId + '/verify-otp'),
3078                { session_uuid: state.sessionUuid, code: code },
3079                function (err, data) {
3080                    if (gen !== state.turnGen) { return; }   // superseded — drop
3081                    if (data && data.verified) {
3082                        state.verified = true;
3083                        if (data.remember_token) { saveVerifyToken(data.remember_token); }
3084                        hideGate();
3085                        return;
3086                    }
3087                    verifyBtn.disabled = false;
3088                    errEl.textContent = (typeof err === 'string' && err) ? err : 'That code is incorrect or expired.';
3089                });
3090        }
3091
3092        function resend() {
3093            resendBtn.disabled = true;
3094            errEl.textContent = '';
3095            jsonPost(apiUrl('sessions/' + state.sessionId + '/send-otp'),
3096                { session_uuid: state.sessionUuid },
3097                function (err, data) {
3098                    if (gen !== state.turnGen) { return; }   // superseded — drop
3099                    resendBtn.disabled = false;
3100                    if (data && data.reason === 'verified') { state.verified = true; hideGate(); return; }
3101                    var ch = (data && data.channels) || [];
3102                    if (err || ch.length === 0) { errEl.textContent = "We couldn't resend a code right now."; return; }
3103                    note.textContent = 'We re-sent a 6-digit code by ' + sentToPhrase(ch, (data && data.masked) || {}) + '.';
3104                    errEl.textContent = 'A new code is on its way.';
3105                });
3106        }
3107
3108        function bypass() {
3109            // Guarded like the others: after a supersede there is no session to
3110            // record the choice against, and hideGate() would unlock the composer
3111            // behind a gate that has been rebuilt.
3112            if (gen !== state.turnGen) { return; }
3113            // Record the choice (session-only) then continue unverified.
3114            jsonPost(apiUrl('sessions/' + state.sessionId + '/skip-verification'),
3115                { session_uuid: state.sessionUuid }, function () {});
3116            hideGate();
3117        }
3118
3119        codeInput.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); submitCode(); } });
3120        verifyBtn.addEventListener('click', submitCode);
3121        resendBtn.addEventListener('click', resend);
3122        skipBtn.addEventListener('click', bypass);
3123
3124        setGateContent([title, note, codeInput, errEl, verifyBtn, resendBtn, skipBtn]);
3125        setTimeout(function () { codeInput.focus(); }, 50);
3126    }
3127
3128    // -------------------------------------------------------------------------
3129    // Session lifecycle
3130    // -------------------------------------------------------------------------
3131
3132    function ensureSession(callback) {
3133        if (state.sessionId && state.sessionUuid) {
3134            startPolling(); // resume the live-interrupt poll for an existing session
3135            callback(null);
3136            return;
3137        }
3138        // Single-flight: if a startSession is already in flight, QUEUE this callback
3139        // rather than firing a second one — concurrent/rapid sends would otherwise each
3140        // mint a duplicate session ("multiple chats created at once").
3141        if (!state._startWaiters) { state._startWaiters = []; }
3142        state._startWaiters.push(callback);
3143        if (state._starting) { return; }
3144        state._starting = true;
3145        var startBody = {};
3146        if (state.contact) {
3147            if (state.contact.name)  { startBody.name  = state.contact.name; }
3148            // Omitted entirely when unknown (v1, or a visitor remembered from
3149            // before the split), so the server falls back to `name` — its
3150            // composeGateName() handles both shapes.
3151            if (state.contact.first_name) { startBody.first_name = state.contact.first_name; }
3152            if (state.contact.last_name)  { startBody.last_name  = state.contact.last_name; }
3153            if (state.contact.email) { startBody.email = state.contact.email; }
3154            if (state.contact.phone) { startBody.phone = state.contact.phone; }
3155        }
3156        if (state.rememberToken) { startBody.verify_token = state.rememberToken; }
3157        if (state.consented) { startBody.consent = true; }
3158        if (state.turnstileToken) { startBody.cf_turnstile_token = state.turnstileToken; }
3159        // How the visitor entered (v2), for the server's session meta. Observability
3160        // only — the server enforces nothing off it.
3161        if (state.entryMode) { startBody.mode = state.entryMode; }
3162        // Capture the turn generation so a reset ("Not you?") landing while this
3163        // session-create is in flight can't persist a session made for the PRIOR
3164        // visitor's contact — otherwise the next person silently inherits it.
3165        var gen = state.turnGen;
3166        jsonPost(apiUrl('sessions'), startBody, function (err, data) {
3167            state._starting = false;
3168            var waiters = state._startWaiters || [];
3169            state._startWaiters = [];
3170            if (gen !== state.turnGen) { waiters.forEach(function (cb) { cb('superseded'); }); return; } // reset — discard
3171            if (err || !data) {
3172                waiters.forEach(function (cb) { cb(err || 'Could not start session.'); });
3173                return;
3174            }
3175            state.sessionId   = data.session_id;
3176            state.sessionUuid = data.session_uuid;
3177            state.verified    = !!data.verified; // remember-device token honoured server-side
3178            saveSession();
3179            startPolling(); // begin the live-interrupt poll now a session exists
3180            waiters.forEach(function (cb) { cb(null); });
3181        });
3182    }
3183
3184    // -------------------------------------------------------------------------
3185    // Live-interrupt poll — surfaces assistant/agent messages appended to the
3186    // session out-of-band (e.g. by a background job) WITHOUT websockets. Runs
3187    // only while a session exists and the panel is open; every failure is
3188    // swallowed so a poll error can never break the widget.
3189    // -------------------------------------------------------------------------
3190
3191    function startPolling() {
3192        // Only poll when there's a session to poll for and the panel is open.
3193        if (state.pollTimer || !state.sessionId || !state.sessionUuid || !state.isOpen) { return; }
3194        state.pollTimer = setInterval(pollForMessages, POLL_MS);
3195    }
3196
3197    function stopPolling() {
3198        if (state.pollTimer) { clearInterval(state.pollTimer); state.pollTimer = null; }
3199    }
3200
3201    function pollForMessages() {
3202        // Skip while a send is in flight: the direct reply advances lastSeenId,
3203        // so polling mid-turn would risk ordering races / a duplicate render.
3204        if (state.isLoading) { return; }
3205        if (!state.sessionId || !state.sessionUuid) { return; }
3206
3207        var gen = state.turnGen; // reset ("Not you?") invalidates an in-flight poll
3208        var url = apiUrl('sessions/' + state.sessionId + '/messages/since')
3209            + '?after=' + encodeURIComponent(state.lastSeenId)
3210            + '&session_uuid=' + encodeURIComponent(state.sessionUuid);
3211
3212        try {
3213            jsonGet(url, function (err, data) {
3214                if (gen !== state.turnGen) { return; }             // superseded by a reset — drop
3215                if (err || !data || !data.messages) { return; }    // no-op / feature off / error
3216                // First poll of a freshly-loaded/resumed session (cursor still 0,
3217                // before any direct reply set it): treat the WHOLE response as a
3218                // BASELINE — advance the cursor past existing history WITHOUT
3219                // rendering, so we don't dump prior turns or double-render a reply
3220                // whose message_id we never captured. Genuine interrupts render on
3221                // later polls once the cursor is primed.
3222                var priming = (state.lastSeenId === 0);
3223                var msgs = data.messages;
3224                for (var i = 0; i < msgs.length; i++) {
3225                    var m = msgs[i];
3226                    if (!m || typeof m.id === 'undefined') { continue; }
3227                    if (m.id <= state.lastSeenId) { continue; }    // already rendered — skip
3228                    if (!priming) { appendBotMessage(m.content || ''); }
3229                    state.lastSeenId = m.id;
3230                }
3231            });
3232        } catch (e) { /* ignore — a poll error must never break the widget */ }
3233    }
3234
3235    // -------------------------------------------------------------------------
3236    // Send message
3237    // -------------------------------------------------------------------------
3238
3239    function sendMessage() {
3240        var text = inputEl.value.trim();
3241        if (!text) return;
3242
3243        // Show the visitor's message immediately, then BUFFER it: we wait a short
3244        // window (COALESCE_MS) for more fragments and post them as one turn. The
3245        // input is NOT locked, so the visitor can keep adding messages; anything
3246        // that arrives while a post is in flight is queued and flushed after.
3247        inputEl.value        = '';
3248        sendBtn.disabled     = true;
3249        inputEl.style.height = 'auto';
3250
3251        appendUserMessage(text);
3252        state.pendingParts.push(text);
3253        showTyping();
3254        scheduleFlush();
3255    }
3256
3257    // (Re)arm the debounce. A post already in flight defers the flush — doSend's
3258    // completion re-arms it once the current turn finishes.
3259    function scheduleFlush() {
3260        if (state.coalesceTimer) {
3261            clearTimeout(state.coalesceTimer);
3262            state.coalesceTimer = null;
3263        }
3264        if (state.isLoading) return;
3265        state.coalesceTimer = setTimeout(flushPending, COALESCE_MS);
3266    }
3267
3268    // Send everything buffered as a single turn (fragments joined by newlines).
3269    function flushPending() {
3270        state.coalesceTimer = null;
3271        if (state.isLoading) return;               // a turn is already running
3272        if (!state.pendingParts.length) { hideTyping(); return; }
3273
3274        var combined = state.pendingParts.join('\n');
3275        state.pendingParts = [];
3276        state.isLoading = true;
3277        // Claim this turn. A reset (e.g. "Not you?") bumps turnGen; any in-flight
3278        // callback below then bails so a prior visitor's reply can never land in a
3279        // freshly-reset transcript (shared-device privacy).
3280        var gen = state.turnGen;
3281        showTyping();
3282
3283        ensureSession(function (sessionErr) {
3284            if (gen !== state.turnGen) return;     // superseded by a reset — drop
3285            if (sessionErr) {
3286                hideTyping();
3287                state.isLoading = false;
3288                appendErrorMessage('Could not connect to the assistant. Please try again.');
3289                afterTurnComplete();
3290                return;
3291            }
3292
3293            beginSend(combined, gen);
3294        });
3295    }
3296
3297    // Pick the transport for a turn: SSE streaming when the browser supports it and
3298    // it's enabled, else the async poll path. Both hit ADDITIVE server endpoints; the
3299    // async path is the proven fallback so a streaming failure never loses the turn.
3300    function beginSend(text, gen) {
3301        if (STREAM_ENABLED && STREAM_SUPPORTED) {
3302            streamSend(text, gen);
3303        } else {
3304            doSend(text, 0, gen);
3305        }
3306    }
3307
3308    // After a turn resolves (reply or terminal error), re-enable the composer and,
3309    // if more fragments arrived mid-flight, flush them as the next turn.
3310    function afterTurnComplete() {
3311        sendBtn.disabled = inputEl.value.trim() === '';
3312        if (state.pendingParts.length) {
3313            showTyping();
3314            scheduleFlush();
3315        }
3316    }
3317
3318    // Poll a run_id returned by the async send endpoint until it resolves.
3319    // Falls back to the 6s messages/since live-poll if this ever gives up: the
3320    // job persists the assistant reply either way, so the reply is not lost.
3321    function pollTurn(runId, waitedMs, gen) {
3322        if (gen !== state.turnGen) return;          // reset mid-turn — drop
3323
3324        if (waitedMs >= TURN_POLL_TIMEOUT_MS) {
3325            // Give up on the fast path. Do NOT show an error: the live-poll will
3326            // surface the reply when the job finishes. Just release the composer.
3327            hideTyping();
3328            state.isLoading  = false;
3329            state.retryCount = 0;
3330            afterTurnComplete();
3331            return;
3332        }
3333
3334        jsonGet(
3335            apiUrl('sessions/' + state.sessionId + '/turn/' + encodeURIComponent(runId))
3336                + '?session_uuid=' + encodeURIComponent(state.sessionUuid),
3337            function (err, data, httpStatus) {
3338                if (gen !== state.turnGen) return;
3339
3340                // Back off hard on throttling OR an opaque failure.
3341                //
3342                // QA-2026-08-07: HandleCors is skipWhen'd for api/v1/chat/widget/*
3343                // (see AppServiceProvider), so CORS headers come only from the
3344                // controller's corsJson(). A 429 raised by ThrottleRequests never
3345                // reaches the controller, so cross-origin the browser blocks it and
3346                // XHR fires onerror with status 0 — NOT 429. Checking only for 429
3347                // would have been dead code everywhere except the same-origin owner
3348                // test page. Status 0 also covers a genuine network drop, which
3349                // equally warrants backing off.
3350                if (httpStatus === 429 || httpStatus === 0) {
3351                    setTimeout(function () {
3352                        pollTurn(runId, waitedMs + TURN_POLL_BACKOFF_MS, gen);
3353                    }, TURN_POLL_BACKOFF_MS);
3354                    return;
3355                }
3356
3357                var status = data && data.status;
3358
3359                if (status === 'done' && data.reply) {
3360                    hideTyping();
3361                    state.isLoading  = false;
3362                    state.retryCount = 0;
3363                    if (data.message_id && data.message_id > state.lastSeenId) {
3364                        state.lastSeenId = data.message_id;
3365                    }
3366                    appendBotMessage(data.reply);
3367                    afterTurnComplete();
3368                    return;
3369                }
3370
3371                if (status === 'error') {
3372                    hideTyping();
3373                    state.isLoading  = false;
3374                    state.retryCount = 0;
3375                    appendErrorMessage(data.reply || 'The assistant could not respond. Please try again.');
3376                    afterTurnComplete();
3377                    return;
3378                }
3379
3380                // Progress heartbeat: surface the latest generic phase line while
3381                // the turn is still working (dropped automatically on done/error).
3382                if (status === 'pending' && data.progress && data.progress.length) {
3383                    setProgress(String(data.progress[data.progress.length - 1] || ''));
3384                }
3385
3386                // 'pending', 'unknown' (cache not yet written), or a transient
3387                // network error — keep waiting.
3388                var wait = turnPollInterval(waitedMs);
3389                setTimeout(function () {
3390                    pollTurn(runId, waitedMs + wait, gen);
3391                }, wait);
3392            }
3393        );
3394    }
3395
3396    // Capture the page the visitor is viewing so the agent can answer questions about
3397    // it. URL + title ride every turn; the capped visible-text extract only when the
3398    // page changed since we last sent it (re-sent on navigation) to keep turns lean.
3399    // The widget UI lives in a Shadow DOM, so document.body.innerText already excludes
3400    // it. Server caps + sanitises again and frames it as untrusted (PageContextFormatter).
3401    function capturePageContext() {
3402        // Privacy kill switch: /config sets this false when chat.page_context_enabled
3403        // is off, so the page body never even leaves the browser (not just dropped
3404        // server-side). Send nothing at all in that case.
3405        if (state.config && state.config.page_context_enabled === false) { return {}; }
3406        var pg = {};
3407        try { pg.url = String(location.href || '').slice(0, 4096); } catch (e) { /* ignore */ }
3408        try { pg.title = String(document.title || '').slice(0, 1024); } catch (e) { /* ignore */ }
3409        try {
3410            var here = pg.url || '';
3411            if (here && here !== state._pageCtxSentFor) {
3412                var body = document.body;
3413                var txt = (body && (body.innerText || body.textContent)) || '';
3414                txt = String(txt).replace(/\s+/g, ' ');
3415                txt = txt.replace(/^\s+/, '').replace(/\s+$/, '');
3416                if (txt) { pg.text = txt.slice(0, 3000); }
3417            }
3418        } catch (e) { /* text is best-effort — url/title still ship */ }
3419        return pg;
3420    }
3421
3422    // Mark this page's full text as delivered — ONLY after the server accepted the
3423    // turn, so a failed first send re-sends the text on retry instead of losing it.
3424    function markPageCtxSent(pg) {
3425        if (pg && pg.text && pg.url) { state._pageCtxSentFor = pg.url; }
3426    }
3427
3428    // STREAMING send (SSE). POSTs to the additive /messages/stream endpoint and renders
3429    // tokens into a live bot bubble as they arrive. The endpoint may instead answer with
3430    // JSON (cached reply / demo cap / hold / over-capacity → run_id) — handled exactly
3431    // like doSend. Any transport-level failure BEFORE tokens falls back to the async
3432    // path (doSend); a failure AFTER tokens settles and lets the 6s live-poll reconcile,
3433    // so a turn is never lost or double-charged (server idempotency dedups the user turn).
3434    function streamSend(text, gen) {
3435        var pg       = capturePageContext();
3436        var url      = apiUrl('sessions/' + state.sessionId + '/messages/stream');
3437        var body     = JSON.stringify({ session_uuid: state.sessionUuid, message: text, page: pg });
3438        var bubble   = null;   // live bot bubble, created on the first token
3439        var acc      = '';     // accumulated streamed text
3440        var settled  = false;
3441
3442        function fallbackToAsync() {
3443            if (settled) return;
3444            settled = true;
3445            doSend(text, 0, gen);
3446        }
3447        function ensureBubble() {
3448            if (!bubble) { hideTyping(); bubble = appendBotMessage(''); }
3449        }
3450        function renderAcc() {
3451            if (!bubble) return;
3452            bubble.innerHTML = '';
3453            bubble.appendChild(formatMessageFragment(acc));
3454            scrollToBottom();
3455        }
3456        function finish() {
3457            if (settled) return;
3458            settled = true;
3459            hideTyping();
3460            state.isLoading  = false;
3461            state.retryCount = 0;
3462            afterTurnComplete();
3463        }
3464        function removeBubble() {
3465            if (bubble && bubble.parentNode) {
3466                try { bubble.parentNode.removeChild(bubble); } catch (e) {}
3467            }
3468            bubble = null;
3469        }
3470        // Abnormal close (socket dropped mid-stream, or stream ended without a 'done'
3471        // frame carrying the message_id). The server ran under ignore_user_abort so the
3472        // authoritative FILTERED reply is persisted — drop the partial optimistic bubble
3473        // and let the 6s live-poll render the persisted reply once. We do NOT advance
3474        // lastSeenId (we have no id), so the poll renders it; removing the partial avoids
3475        // the double bubble AND guarantees the shown text is the filtered final.
3476        function finishAbnormal() {
3477            if (settled) return;
3478            settled = true;
3479            removeBubble();
3480            hideTyping();
3481            state.isLoading  = false;
3482            state.retryCount = 0;
3483            afterTurnComplete();
3484        }
3485        function cancelBody(resp) {
3486            try { if (resp && resp.body && resp.body.cancel) { resp.body.cancel(); } } catch (e) {}
3487        }
3488
3489        window.fetch(url, {
3490            method:  'POST',
3491            headers: { 'Content-Type': 'application/json', 'Accept': 'text/event-stream' },
3492            body:    body
3493        }).then(function (resp) {
3494            if (gen !== state.turnGen) { settled = true; cancelBody(resp); return; }
3495            var ct = (resp.headers && resp.headers.get('Content-Type')) || '';
3496
3497            // JSON answer (guards): cached reply, demo cap / hold, or over-capacity run_id.
3498            if (ct.indexOf('application/json') !== -1) {
3499                return resp.json().then(function (data) {
3500                    if (gen !== state.turnGen) { settled = true; return; }
3501                    markPageCtxSent(pg);
3502                    if (data && data.run_id) { settled = true; pollTurn(data.run_id, 0, gen); return; }
3503                    if (data && data.reply) {
3504                        settled = true;
3505                        hideTyping();
3506                        state.isLoading  = false;
3507                        state.retryCount = 0;
3508                        if (data.message_id && data.message_id > state.lastSeenId) {
3509                            state.lastSeenId = data.message_id;
3510                        }
3511                        appendBotMessage(data.reply);
3512                        afterTurnComplete();
3513                        return;
3514                    }
3515                    fallbackToAsync();
3516                });
3517            }
3518
3519            if (!resp.ok || ct.indexOf('text/event-stream') === -1 || !resp.body || !resp.body.getReader) {
3520                fallbackToAsync();
3521                return;
3522            }
3523
3524            markPageCtxSent(pg);
3525            var reader  = resp.body.getReader();
3526            var decoder = new TextDecoder();
3527            var buf     = '';
3528
3529            function handleFrame(frame) {
3530                var lines = frame.split('\n');
3531                var ev = 'message', dataStr = '';
3532                for (var i = 0; i < lines.length; i++) {
3533                    if (lines[i].indexOf('event:') === 0)      { ev = lines[i].slice(6).trim(); }
3534                    else if (lines[i].indexOf('data:') === 0)  { dataStr += lines[i].slice(5).trim(); }
3535                }
3536                if (!dataStr) return;
3537                var d; try { d = JSON.parse(dataStr); } catch (e) { return; }
3538
3539                if (ev === 'token') {
3540                    ensureBubble();
3541                    acc += (d.delta || '');
3542                    renderAcc();
3543                } else if (ev === 'replace') {
3544                    ensureBubble();
3545                    acc = String(d.content || '');
3546                    renderAcc();
3547                } else if (ev === 'done') {
3548                    if (d.message_id && d.message_id > state.lastSeenId) {
3549                        state.lastSeenId = d.message_id;
3550                    }
3551                    finish();
3552                } else if (ev === 'progress') {
3553                    // Working phase during tool execution (before tokens stream). Only
3554                    // shown while no answer bubble exists yet; cleared once tokens arrive.
3555                    if (!bubble && d && d.phase) { setProgress(String(d.phase)); }
3556                } else if (ev === 'error') {
3557                    // The turn failed server-side (no reply persisted). Drop any partial
3558                    // optimistic bubble and surface the error — never leave truncated,
3559                    // pre-filter text on screen as if it were the answer.
3560                    removeBubble();
3561                    hideTyping();
3562                    appendErrorMessage((d && d.message) || 'The assistant could not respond. Please try again.');
3563                    finish();
3564                }
3565            }
3566
3567            function pump() {
3568                return reader.read().then(function (res) {
3569                    if (gen !== state.turnGen) { settled = true; try { reader.cancel(); } catch (e) {} return; }
3570                    if (res.done) { finishAbnormal(); return; }   // closed w/o explicit 'done' → drop partial; live-poll renders the persisted reply
3571                    buf += decoder.decode(res.value, { stream: true });
3572                    var idx;
3573                    while ((idx = buf.indexOf('\n\n')) !== -1) {
3574                        var frame = buf.slice(0, idx);
3575                        buf = buf.slice(idx + 2);
3576                        handleFrame(frame);
3577                        if (settled) { try { reader.cancel(); } catch (e) {} return; }
3578                    }
3579                    return pump();
3580                });
3581            }
3582            return pump();
3583        }).catch(function () {
3584            // Transport error. If tokens already showed, drop the partial and let the
3585            // live-poll render the persisted (filtered) reply; otherwise fall back to the
3586            // async path so the turn is never lost.
3587            if (settled) return;
3588            if (bubble || acc) { finishAbnormal(); } else { fallbackToAsync(); }
3589        });
3590    }
3591
3592    function doSend(text, attempt, gen) {
3593        // Capture ONCE per attempt and reuse in the success handler, so the "sent"
3594        // marker below refers to exactly the payload that was accepted.
3595        var pg = capturePageContext();
3596        jsonPost(
3597            apiUrl('sessions/' + state.sessionId + '/messages-async'),
3598            { session_uuid: state.sessionUuid, message: text, page: pg },
3599            function (err, data) {
3600                // A reset happened while this request was in flight — drop the
3601                // response so it can't append into the new person's transcript or
3602                // re-arm a flush. resetWidget already cleaned up isLoading/buffer.
3603                if (gen !== state.turnGen) return;
3604
3605                // Async accepted — poll for the result. The fpm worker was
3606                // released the moment this returned.
3607                if (data && data.run_id) {
3608                    markPageCtxSent(pg); // text delivered; follow-ups on this page send url/title only
3609                    pollTurn(data.run_id, 0, gen);
3610                    return;
3611                }
3612
3613                // Demo-cap / account-hold refusals still come back synchronously
3614                // in the plain {reply} shape, as does the legacy sync endpoint.
3615                if (data && data.reply) {
3616                    markPageCtxSent(pg);
3617                    hideTyping();
3618                    state.isLoading  = false;
3619                    state.retryCount = 0;
3620                    // Advance the live-poll cursor past this direct reply so the
3621                    // poll never re-renders it (server returns message_id).
3622                    if (data.message_id && data.message_id > state.lastSeenId) {
3623                        state.lastSeenId = data.message_id;
3624                    }
3625                    appendBotMessage(data.reply);
3626                    afterTurnComplete();
3627                    return;
3628                }
3629
3630                if (attempt < MAX_RETRIES) {
3631                    // Keep isLoading=true and typing visible during the retry delay.
3632                    // New fragments the visitor sends meanwhile are buffered and
3633                    // flushed by afterTurnComplete once this turn resolves.
3634                    state.isLoading = true;
3635                    setTimeout(function () {
3636                        if (gen !== state.turnGen) return;   // reset during the retry gap
3637                        doSend(text, attempt + 1, gen);
3638                    }, 1500);
3639                } else {
3640                    hideTyping();
3641                    state.isLoading  = false;
3642                    state.retryCount = 0;
3643                    appendErrorMessage(err || 'The assistant could not respond. Please refresh and try again.');
3644                    afterTurnComplete();
3645                }
3646            }
3647        );
3648    }
3649
3650    // -------------------------------------------------------------------------
3651    // SVG icons
3652    // -------------------------------------------------------------------------
3653
3654    /**
3655     * Every glyph the widget draws, as an inline SVG path.
3656     *
3657     * The v2 mock specifies the Material Symbols webfont; these are the same
3658     * glyphs as paths instead. We do not inject a webfont onto a customer's page
3659     * — an extra third-party request plus a CSP hazard on strict embedding sites
3660     * — and voice-widget.js made the identical call for the identical reason.
3661     * Paths are the 24x24 Material Symbols outlines, so the shapes match the
3662     * mock exactly.
3663     */
3664    function svgIcon(name) {
3665        var paths = {
3666            chat:  'M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 12H6v-2h12v2zm0-3H6V9h12v2zm0-3H6V6h12v2z',
3667            close: 'M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z',
3668            send:  'M2.01 21L23 12 2.01 3 2 10l15 2-15 2z',
3669            bot:   'M12 2a2 2 0 012 2c0 .74-.4 1.38-1 1.73V7h1a7 7 0 017 7H3a7 7 0 017-7h1V5.73c-.6-.35-1-.99-1-1.73a2 2 0 012-2zM7 14v2h2v-2H7zm8 0v2h2v-2h-2zM8 19a2 2 0 100 4 2 2 0 000-4zm8 0a2 2 0 100 4 2 2 0 000-4z',
3670            // ---- v2 ----------------------------------------------------------
3671            // graphic_eq — the "Voice Chat" button.
3672            'graphic-eq':   'M7 18h2V6H7v12zm4 4h2V2h-2v20zm-8-8h2v-4H3v4zm12 4h2V6h-2v12zm4-8v4h2v-4h-2z',
3673            // expand_more — minimize the panel (NOT close; the session lives on).
3674            'chevron-down': 'M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z',
3675            // arrow_upward — send. Distinct from v1's paper plane by design.
3676            'arrow-up':     'M4 12l1.41 1.41L11 7.83V20h2V7.83l5.58 5.59L20 12l-8-8-8 8z',
3677            mic:            'M12 14c1.66 0 2.99-1.34 2.99-3L15 5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.3-3c0 3-2.54 5.1-5.3 5.1S6.7 14 6.7 11H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c3.28-.49 6-3.31 6-6.72h-1.7z',
3678            'mic-off':      'M19 11h-1.7c0 .74-.16 1.43-.43 2.05l1.23 1.23c.56-.98.9-2.09.9-3.28zm-4.02.17c0-.06.02-.11.02-.17V5c0-1.66-1.34-3-3-3S9 3.34 9 5v.18l5.98 5.99zM4.27 3L3 4.27l6.01 6.01V11c0 1.66 1.33 3 2.99 3 .22 0 .44-.03.65-.08l1.66 1.66c-.71.33-1.5.52-2.31.52-2.76 0-5.3-2.1-5.3-5.1H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c.91-.13 1.77-.45 2.54-.9L19.73 21 21 19.73 4.27 3z',
3679            'call-end':     'M12 9c-1.6 0-3.15.25-4.6.72v3.1c0 .39-.23.74-.56.9-.98.49-1.87 1.12-2.66 1.85-.18.18-.43.28-.7.28-.28 0-.53-.11-.71-.29L.29 13.08c-.18-.17-.29-.42-.29-.7 0-.28.11-.53.29-.71C3.34 8.78 7.46 7 12 7s8.66 1.78 11.71 4.67c.18.18.29.43.29.71 0 .28-.11.53-.29.71l-2.48 2.48c-.18.18-.43.29-.71.29-.27 0-.52-.11-.7-.28-.79-.74-1.69-1.36-2.67-1.85-.33-.16-.56-.51-.56-.9v-3.1C15.15 9.25 13.6 9 12 9z',
3680            keyboard:       'M20 5H4c-1.1 0-1.99.9-1.99 2L2 17c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm-9 3h2v2h-2V8zm0 3h2v2h-2v-2zM8 8h2v2H8V8zm0 3h2v2H8v-2zm-1 2H5v-2h2v2zm0-3H5V8h2v2zm9 7H8v-2h8v2zm0-4h-2v-2h2v2zm0-3h-2V8h2v2zm3 3h-2v-2h2v2zm0-3h-2V8h2v2z',
3681        };
3682        var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
3683        svg.setAttribute('viewBox', '0 0 24 24');
3684        svg.setAttribute('aria-hidden', 'true');
3685        var path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
3686        path.setAttribute('d', paths[name] || paths.chat);
3687        svg.appendChild(path);
3688        return svg;
3689    }
3690
3691    // -------------------------------------------------------------------------
3692    // Init
3693    // -------------------------------------------------------------------------
3694
3695    function init() {
3696        loadStoredSession();
3697        loadStoredContact();
3698        loadStoredVerifyToken();
3699        loadStoredConsent();
3700
3701        jsonGet(apiUrl('config') + (LEGACY_LOADER ? '?loader=legacy' : ''), function (err, config) {
3702            if (err || !config) {
3703                console.warn('[C2AI] Widget: could not load agent config.', err);
3704                return;
3705            }
3706
3707            state.config = config;
3708
3709            // Which renderer. Anything other than an explicit 'v1' is v2, so an
3710            // OLD cached copy of this file talking to a NEW server, or vice
3711            // versa, degrades to a working widget rather than a blank one.
3712            renderMode = (config.render === 'v1') ? 'v1' : 'v2';
3713
3714            // ⚠️ QA 2026-08-25 — FALL BACK TO v1 ON A BROWSER THAT CANNOT RUN v2.
3715            //
3716            // v2 needs attachShadow (IE11, Edge <79, Safari <10.1, older Android
3717            // WebViews lack it) and Promise. Without this guard attachShadow
3718            // throws inside this callback and NO widget renders at all — those
3719            // visitors would go from a working chat widget to nothing, silently,
3720            // on someone else's site.
3721            //
3722            // v1 needs neither: it predates both and mounts with plain DOM.
3723            if (renderMode === 'v2' && !v2Supported()) {
3724                console.warn('[C2AI] Widget: browser lacks shadow DOM support — using the v1 renderer.');
3725                renderMode = 'v1';
3726            }
3727
3728            // A voice-only agent has NO v1 to fall back to (v1 is chat-only and
3729            // would build a panel whose every call 404s). The server pins these
3730            // to v2 for the same reason; if we got here we cannot render at all.
3731            if (renderMode === 'v1' && config.channels && config.channels.chat === false) {
3732                console.warn('[C2AI] Widget: this agent is voice-only and needs the v2 renderer.');
3733
3734                return;
3735            }
3736
3737            // Defense in depth: validate primary_color before injecting into CSS
3738            // (the server also validates, but guard client-side too).
3739            var primaryColor = /^#[0-9a-f]{6}$/i.test(config.primary_color)
3740                ? config.primary_color
3741                : '#6d28d9';
3742
3743            // Same rule for the orb hue, which also lands in a CSS context.
3744            var orbHue = /^\d{1,3}(\.\d+)?deg$/.test(String(config.orb_hue || ''))
3745                ? config.orb_hue
3746                : '268deg';
3747            orbHueSafe = orbHue;
3748
3749            // Channels. Defaulting chat to TRUE when the server did not say is
3750            // deliberate: an old server (no `channels` field) is a chat-only
3751            // server, and the widget must keep working against it.
3752            var channels = config.channels || {};
3753            var chatOn = (typeof channels.chat === 'boolean') ? channels.chat : true;
3754            var voiceOn = channels.voice === true && typeof config.voice_embed_key === 'string';
3755
3756            // Nothing to render — no chat AND no placeable voice. Bail rather
3757            // than mount a launcher that cannot do anything.
3758            if (!chatOn && !voiceOn) {
3759                console.warn('[C2AI] Widget: agent has no chat or voice channel enabled.');
3760
3761                return;
3762            }
3763
3764            voiceKey = voiceOn ? config.voice_embed_key : null;
3765
3766            // Pass a copy with the validated values so buildWidget never reads
3767            // an unchecked field.
3768            var safeConfig = {
3769                bot_name:        config.bot_name        || 'Assistant',
3770                welcome_message: config.welcome_message || 'Hi! How can I help you today?',
3771                primary_color:   primaryColor,
3772                avatar_url:      config.avatar_url || null,
3773                orb_hue:         orbHue,
3774                // 'Speak with us' matches the server's own no-name fallback, so an
3775                // OLD server that does not send launcher_label reads the same as a
3776                // new one with an unnamed agent. Never falls back to a name we hold
3777                // client-side — bot_name's chain ends at the agent's internal record
3778                // name, which must not appear on the launcher.
3779                launcher_label:  String(config.launcher_label || 'Speak with us').slice(0, 80),
3780                // Custom orb images — HTTPS-only, same defense-in-depth as the
3781                // avatar (the server validates too). null → the generated orb.
3782                orb_image_url:      (config.orb_image_url && /^https:\/\//i.test(config.orb_image_url)) ? config.orb_image_url : null,
3783                orb_open_image_url: (config.orb_open_image_url && /^https:\/\//i.test(config.orb_open_image_url)) ? config.orb_open_image_url : null,
3784                // Backdrop behind a custom orb image, per system theme — both validated
3785                // #rrggbb so they are safe as CSS custom props; null → the neutral
3786                // per-mode default. _dark is the prefers-color-scheme override.
3787                orb_image_bg_color: (config.orb_image_bg_color && /^#[0-9a-f]{6}$/i.test(config.orb_image_bg_color)) ? config.orb_image_bg_color : null,
3788                orb_image_bg_color_dark: (config.orb_image_bg_color_dark && /^#[0-9a-f]{6}$/i.test(config.orb_image_bg_color_dark)) ? config.orb_image_bg_color_dark : null,
3789                // Instant-chat entry choice. Defaults ON when an older server omits
3790                // it — the choice screen just starts a session, which any server
3791                // supports. Labels are clamped (the server clamps too).
3792                instant_enabled: config.instant_enabled !== false,
3793                instant_label:   String(config.instant_label || 'Chat Instantly').slice(0, 60),
3794                support_label:   String(config.support_label || 'Account Support').slice(0, 60),
3795                // Passed through so the entry choice can run the anti-spam challenge
3796                // before an instant (formless) session on the website agent.
3797                turnstile_site_key: config.turnstile_site_key || null,
3798                channels:        { chat: chatOn, voice: voiceOn },
3799            };
3800
3801            if (renderMode === 'v2') {
3802                // Creates the root(s) AND injects the sheet into them.
3803                attachShadowRoots(primaryColor);
3804            } else {
3805                injectStyles(primaryColor);
3806            }
3807
3808            // Quick-prompt pills: v2 only (v1 is the unchanged rollback path).
3809            // Clamped to the server's limits (3 / 30 / 500) — belt and braces.
3810            state.quickPrompts = (renderMode === 'v2' && Array.isArray(config.quick_prompts))
3811                ? config.quick_prompts.slice(0, 3).filter(function (p) {
3812                    return p && typeof p.label === 'string' && typeof p.message === 'string'
3813                        && p.label.trim() !== '' && p.message.trim() !== '';
3814                }).map(function (p) {
3815                    return { label: p.label.trim().slice(0, 30), message: p.message.trim().slice(0, 500) };
3816                })
3817                : [];
3818
3819            buildWidget(safeConfig);
3820
3821            // EAGER orb fetch, not on first panel open — but ONLY if a real
3822            // <agent-orb> was actually mounted (orbSlot sets _orbNeeded). A launcher
3823            // AND header that are both custom images need no three.js at all, so we
3824            // skip the ~590 KB entirely; voice still loads it on demand from the
3825            // overlay. When the orb IS used, mounting <agent-orb> up front and
3826            // letting the browser upgrade it in place is the deliberate trade for
3827            // the orb being there from the start (Steven, 2026-08-25). Runs after
3828            // buildWidget so orbSlot has had its say; still async and non-blocking.
3829            if (renderMode === 'v2' && _orbNeeded) {
3830                loadOrb();
3831            }
3832
3833            // Auto-reopen across pages: a chat that was OPEN when the visitor navigated
3834            // re-opens here (openPanel repaints the saved history); a minimized chat
3835            // stays minimized as the launcher.
3836            if (chatOn && state.sessionId && state._wasOpen) {
3837                try { openPanel(); } catch (e) { /* ignore */ }
3838            }
3839        });
3840    }
3841
3842    if (document.readyState === 'loading') {
3843        document.addEventListener('DOMContentLoaded', init);
3844    } else {
3845        init();
3846    }
3847
3848})();

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.