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.