1/* ================================================================ 2 EBONIE AGENT ENGINE â REI VAULT PRO 3 Photo avatar, voice sync, waveform, blink, lip sync, navigation 4 ================================================================ */ 5 6// ââ STATE âââââââââââââââââââââââââââââââââââââââââââââââââââââ 7let _oxVoice = null; 8let _oxMuted = false; 9let _oxSpeaking = false; 10let _oxPaused = false; 11let _oxStop = false; 12let _oxLipTimer = null; 13let _oxTypeTimer = null; 14let _oxHighlighted = null; 15let _oxCurrentSpeak = null; 16let _oxSpeechId = 0; // incremented each time a new speech is requested; guards stale setTimeout callbacks 17 18// Vault Tour state 19let _vaultTourActive = false; 20let _vaultTourStep = 0; 21let _vaultTourNavTimer = null; 22 23// ââ VOICE PRELOAD âââââââââââââââââââââââââââââââââââââââââââââ 24function oxLoadVoice() { 25 if (!window.speechSynthesis) return false; 26 const voices = window.speechSynthesis.getVoices(); 27 if (!voices.length) return false; 28 29 // Priority list â premium natural female voices first 30 // Google* voices on Android/Chrome, Microsoft Natural on Edge/Windows, iOS built-ins 31 const want = [ 32 'Microsoft Aria Online (Natural)', 33 'Microsoft Jenny Online (Natural)', 34 'Microsoft Michelle Online (Natural)', 35 'Google US English', // Android Chrome primary 36 'Microsoft Aria', 37 'Microsoft Jenny', 38 'Samantha', // macOS / iOS 39 'Karen', // iOS Australian English 40 'Moira', // iOS Irish English 41 'Tessa', // iOS South African 42 'Veena', // iOS Indian English 43 'Google UK English Female', 44 'Microsoft Hazel', 45 'Hazel', 46 'Microsoft Zira', 47 'Zira', 48 ]; 49 50 for (const n of want) { 51 const v = voices.find(v => v.name.includes(n)); 52 if (v) { _oxVoice = v; return true; } 53 } 54 55 // Fallback: prefer en-US online/network voices (more natural), then any English 56 // NEVER select a known male voice â Ebonie must always be female and consistent. 57 const maleNames = ['Alex', 'Daniel', 'Fred', 'Thomas', 'Arthur', 'Rishi', 'Aaron', 'Guy', 'Rocko', 'Reed']; 58 _oxVoice = voices.find(v => v.lang === 'en-US' && v.localService === false && !maleNames.some(m => v.name.includes(m))) 59 || voices.find(v => /female|woman/i.test(v.name) && v.lang === 'en-US') 60 || voices.find(v => v.lang === 'en-US' && !maleNames.some(m => v.name.includes(m))) 61 || voices.find(v => v.lang.startsWith('en') && !maleNames.some(m => v.name.includes(m))) 62 || voices[0]; 63 return !!_oxVoice; 64} 65 66// Retry voice loading â handles slow browser voice population and iOS deferred loading 67let _oxVoiceRetries = 0; 68function oxEnsureVoice(cb) { 69 if (_oxVoice) { cb(); return; } 70 if (oxLoadVoice()) { cb(); return; } 71 if (_oxVoiceRetries < 20) { 72 _oxVoiceRetries++; 73 setTimeout(() => oxEnsureVoice(cb), 200); 74 } else { 75 cb(); // Give up â browser will use default 76 } 77} 78 79if (window.speechSynthesis) { 80 window.speechSynthesis.getVoices(); 81 window.speechSynthesis.onvoiceschanged = () => { _oxVoiceRetries = 0; oxLoadVoice(); }; 82 // Staggered retries â covers Chrome (fast), Safari (slow), iOS (very slow) 83 [50, 150, 400, 800, 1200, 1800, 2500].forEach(ms => setTimeout(oxLoadVoice, ms)); 84} 85 86// ââ TYPEWRITER ââââââââââââââââââââââââââââââââââââââââââââââââ 87function oxType(text) { 88 const el = document.getElementById('oxMsg'); 89 if (!el) return; 90 if (_oxTypeTimer) { clearInterval(_oxTypeTimer); _oxTypeTimer = null; } 91 92 // HTML slides render immediately â clear any stale click handler from prior step 93 if (/<[a-zA-Z]/.test(text)) { 94 el.innerHTML = text; 95 el.style.cursor = ''; 96 el.title = ''; 97 el.onclick = null; 98 return; 99 } 100 101 el.textContent = ''; 102 el.style.cursor = 'pointer'; 103 el.title = 'Click to reveal all'; 104 105 let i = 0; 106 // Adaptive speed: longer scripts type faster so they finish before speech ends 107 const charMs = text.length > 300 ? 8 : text.length > 150 ? 11 : 13; 108 _oxTypeTimer = setInterval(() => { 109 if (i < text.length) { 110 el.textContent += text[i++]; 111 // Auto-scroll to bottom as text grows 112 el.scrollTop = el.scrollHeight; 113 } else { 114 clearInterval(_oxTypeTimer); 115 _oxTypeTimer = null; 116 el.style.cursor = '';
117 el.title = ''; 118 } 119 }, charMs); 120 121 // Click anywhere on the message to skip to the end instantly 122 el.onclick = function skipToEnd() { 123 if (_oxTypeTimer) { 124 clearInterval(_oxTypeTimer); 125 _oxTypeTimer = null; 126 el.textContent = text; 127 el.scrollTop = el.scrollHeight; 128 el.style.cursor = ''; 129 el.title = ''; 130 } 131 el.onclick = null; 132 }; 133} 134 135// ââ SPEECH ââââââââââââââââââââââââââââââââââââââââââââââââââââ 136function _oxSetSpeakingUi(on) { 137 const card = document.getElementById('oxBubble'); 138 const btn = document.getElementById('oxNextBtn'); 139 if (card) on ? card.classList.add('ox-is-speaking') : card.classList.remove('ox-is-speaking'); 140 if (btn) on ? btn.classList.remove('ox-btn-idle') : btn.classList.add('ox-btn-idle'); 141} 142 143function oxSpeak(text) { 144 if (_oxMuted || !window.speechSynthesis) return; 145 window.speechSynthesis.cancel(); 146 147 // Do NOT set _oxSpeaking = true yet â we wait for onstart to confirm the 148 // browser actually accepted the speak() call. On iOS Safari before the first 149 // gesture, speak() is silently rejected and onstart never fires. If we set 150 // _oxSpeaking = true eagerly, _oxUnlockAudio() sees it and skips the replay, 151 // meaning speech never plays even after the user taps "Yes, show me around!". 152 _oxPaused = false; 153 _oxStop = false; 154 oxSetPauseBtn(false); 155 156 const myId = ++_oxSpeechId; 157 const chunks = text.match(/[^.!?]+[.!?]+\s*/g) || [text]; 158 let i = 0; 159 let _started = false; 160 161 function next() { 162 if (_oxStop || i >= chunks.length || myId !== _oxSpeechId) { 163 if (myId === _oxSpeechId && _started) { 164 _oxSpeaking = false; 165 oxLipOff(); 166 _oxSetSpeakingUi(false); 167 } 168 return; 169 } 170 const u = new SpeechSynthesisUtterance(chunks[i].trim()); 171 if (_oxVoice) u.voice = _oxVoice; 172 u.rate = 0.88; 173 u.pitch = 1.05; 174 u.volume = 1.0; 175 176 u.onstart = () => { 177 if (!_started && myId === _oxSpeechId) { 178 _started = true; 179 _oxSpeaking = true; 180 oxLipOn(); 181 _oxSetSpeakingUi(true); 182 } 183 }; 184 u.addEventListener('boundary', e => { 185 if (e.name === 'word' && e.charLength > 0) { 186 _oxLiveWord(chunks[i] ? chunks[i].trim().substr(e.charIndex, e.charLength) : ''); 187 } 188 }); 189 let _onEndFired = false; 190 const estMs = Math.max(3000, chunks[i].trim().length * 65); 191 const _onEndTimer = setTimeout(() => { 192 if (!_onEndFired && myId === _oxSpeechId) { 193 _onEndFired = true; 194 i++; _oxLiveWord(''); setTimeout(next, 160); 195 } 196 }, estMs); 197 u.onend = () => { 198 if (_onEndFired) return; 199 _onEndFired = true; 200 clearTimeout(_onEndTimer); 201 i++; _oxLiveWord(''); setTimeout(next, 160); 202 }; 203 u.onerror = e => { 204 if (_onEndFired) return; 205 _onEndFired = true; 206 clearTimeout(_onEndTimer); 207 if (e.error === 'interrupted' || e.error === 'canceled') return; 208 if (myId === _oxSpeechId) { _oxSpeaking = false; oxLipOff(); _oxSetSpeakingUi(false); } 209 }; 210 window.speechSynthesis.speak(u); 211 } 212 next(); 213 214 // Watchdog: if speak() was silently rejected by the browser (iOS before first 215 // user gesture), reset _oxSpeaking so _oxUnlockAudio can replay on the next tap. 216 setTimeout(function() { 217 if (myId !== _oxSpeechId) return; 218 if (!_started && !window.speechSynthesis.speaking && !window.speechSynthesis.pending) { 219 _oxSpeaking = false; // ensure _oxUnlockAudio sees it as free 220 _oxCurrentSpeak = text; // guarantee it replays on next gesture 221 } 222 }, 600); 223} 224 225function oxStopSpeech() { 226 _oxSpeechId++; // invalidate any pending setTimeout speech callbacks 227 _oxStop = true; 228 _oxSpeaking = false; 229 if (window.speechSynthesis) window.speechSynthesis.cancel(); 230 oxLipOff(); 231 _oxSetSpeakingUi(false); 232} 233 234// Live word display â shows currently spoken word in caption area 235function _oxLiveWord(word) { 236 const el = document.getElementById('oxLiveWord'); 237 if (!el) return; 238 if (word) { 239 el.textContent = word; 240 el.classList.add('active'); 241 } else { 242 el.classList.remove('active'); 243 } 244} 245 246// Chrome keepalive â poke synthesis only if it has genuinely stalled (not speaking) 247let _oxKeepAliveTs = 0; 248setInterval(() => { 249 if (!window.speechSynthesis || !_oxSpeaking) return; 250 const now = Date.now(); 251 if (!window.speechSynthesis.speaking && !window.speechSynthesis.pending) { 252 // Synthesis queue emptied unexpectedly while we think we're still speaking
253 // Do nothing â the utterance onend handler will clean up 254 return; 255 } 256 // Only nudge if speech has been going for >10s AND synthesis reports paused (Chrome stall) 257 if (window.speechSynthesis.paused && (now - _oxKeepAliveTs) > 10000) { 258 _oxKeepAliveTs = now; 259 window.speechSynthesis.resume(); 260 } 261}, 5000); 262 263// ââ PAUSE / RESUME ââââââââââââââââââââââââââââââââââââââââââââ 264function oxSetPauseBtn(paused) { 265 const btn = document.getElementById('oxPauseBtn'); 266 if (!btn) return; 267 btn.textContent = paused ? 'â¶' : 'â¸'; 268 btn.title = paused ? 'Resume' : 'Pause'; 269} 270 271function oxPause() { 272 if (_oxSpeaking && !_oxPaused) { 273 // Pause 274 _oxPaused = true; 275 oxStopSpeech(); 276 oxSetPauseBtn(true); 277 } else if (_oxPaused && _oxCurrentSpeak) { 278 // Resume â replay current section's speech 279 _oxPaused = false; 280 oxSetPauseBtn(false); 281 oxSpeak(_oxCurrentSpeak); 282 } 283} 284 285// Replay current step from the beginning 286function oxReplay() { 287 oxStopSpeech(); 288 _oxPaused = false; 289 oxRun(onyxStep); 290} 291 292// ââ LIP SYNC + WAVEFORM âââââââââââââââââââââââââââââââââââââââ 293function oxLipOn() { 294 if (_oxLipTimer) clearInterval(_oxLipTimer); 295 296 const lipEl = document.getElementById('oxLipEl'); 297 const waveform = document.getElementById('oxWaveform'); 298 const waveBars = document.getElementById('oxWaveBars'); 299 const ringPulse = document.getElementById('oxRingPulse'); 300 301 if (waveform) waveform.classList.add('active'); 302 if (waveBars) waveBars.classList.add('active'); 303 if (ringPulse) ringPulse.classList.add('active'); 304 305 // Speaking-alive enhancements 306 document.querySelector('.ox-avatar-wrap')?.classList.add('speaking'); 307 document.getElementById('oxParticles')?.classList.add('active'); 308 document.getElementById('oxBubble')?.classList.add('ox-speaking'); 309 310 if (!lipEl) return; 311 let isOpen = false; 312 _oxLipTimer = setInterval(() => { 313 isOpen = !isOpen; 314 isOpen ? lipEl.classList.add('open') : lipEl.classList.remove('open'); 315 }, 80 + Math.random() * 60); 316} 317 318function oxLipOff() { 319 if (_oxLipTimer) clearInterval(_oxLipTimer); 320 _oxLipTimer = null; 321 322 const lipEl = document.getElementById('oxLipEl'); 323 const waveform = document.getElementById('oxWaveform'); 324 const waveBars = document.getElementById('oxWaveBars'); 325 const ringPulse = document.getElementById('oxRingPulse'); 326 327 if (lipEl) lipEl.classList.remove('open'); 328 if (waveform) waveform.classList.remove('active'); 329 if (waveBars) waveBars.classList.remove('active'); 330 if (ringPulse) ringPulse.classList.remove('active'); 331 332 // Remove speaking-alive enhancements 333 document.querySelector('.ox-avatar-wrap')?.classList.remove('speaking'); 334 document.getElementById('oxParticles')?.classList.remove('active'); 335 document.getElementById('oxBubble')?.classList.remove('ox-speaking'); 336 _oxLiveWord(''); 337 338 // Remove speaking glow from smart pointer 339 oxPtrSetSpeaking(false); 340} 341 342// ââ BLINK ââââââââââââââââââââââââââââââââââââââââââââââââââââ 343function oxBlink() { 344 const blinkEl = document.getElementById('oxBlinkEl'); 345 if (!blinkEl) { setTimeout(oxBlink, 3000); return; } 346 blinkEl.classList.add('closed'); 347 setTimeout(() => blinkEl.classList.remove('closed'), 115); 348 setTimeout(oxBlink, 2200 + Math.random() * 4800); 349} 350setTimeout(oxBlink, 2000); 351 352// ââ SMART POINTER ENGINE âââââââââââââââââââââââââââââââââââââ 353let _sptrVisible = false; 354let _sptrArrTimer = null; 355let _sptrLblTimer = null; 356 357function _sptrEl() { return document.getElementById('oxSmartPtr'); } 358 359// Resolve first matching element from a selector list 360function _sptrResolve(targets) { 361 if (!targets) return null; 362 const list = Array.isArray(targets) ? targets : [targets]; 363 for (const sel of list) { 364 try { 365 const el = document.querySelector(sel); 366 if (el) return el; 367 } catch(e) {} 368 } 369 return null; 370} 371 372// Calculate scroll-adjusted position for pointer tip to land near element 373function _sptrCalcPos(el) { 374 const rect = el.getBoundingClientRect(); 375 const viewW = window.innerWidth; 376 const viewH = window.innerHeight; 377 378 // Pick X: left 20% of element (or center for narrow elements) 379 let tipX = rect.left + Math.min(rect.width * 0.22, 80); 380 // Keep inside viewport 381 tipX = Math.max(10, Math.min(viewW - 70, tipX)); 382 383 // Pick Y: slightly above element top (tip points at it from above) 384 let tipY = rect.top - 12; 385 // If element is near top (behind header), point from below instead 386 if (rect.top < 130) tipY = rect.bottom + 8; 387 tipY = Math.max(70, Math.min(viewH - 80, tipY)); 388 389 return { x: tipX, y: tipY }; 390} 391 392// Auto-scroll to bring element fully into view, returns ms delay needed 393function _sptrAutoScroll(el) { 394 const rect = el.getBoundingClientRect(); 395 const viewH = window.innerHeight; 396 const header = 80; // header height 397 398 if (rect.top < header || rect.bottom > viewH) { 399 const center = window.scrollY + rect.top - viewH * 0.32; 400 window.scrollTo({ top: Math.max(0, center), behavior: 'smooth' }); 401 return 650; // wait for scroll 402 } 403 return 0; 404} 405 406// Move pointer to an element (or selector list), with scroll + arrive effects 407function oxPtrMoveTo(targets, label) { 408 const ptr = _sptrEl(); 409 if (!ptr) return; 410 411 const el = _sptrResolve(targets); 412 if (!el) { oxPtrHide(); return; } 413
414 const scrollDelay = _sptrAutoScroll(el); 415 416 setTimeout(() => { 417 const pos = _sptrCalcPos(el); 418 419 if (!_sptrVisible) { 420 // First appearance: snap to position, then fade in 421 ptr.style.transition = 'opacity 0.28s ease'; 422 ptr.style.left = pos.x + 'px'; 423 ptr.style.top = pos.y + 'px'; 424 ptr.style.display = 'block'; 425 requestAnimationFrame(() => requestAnimationFrame(() => { 426 ptr.style.transition = 427 'left 0.72s cubic-bezier(0.34,1.56,0.64,1), top 0.72s cubic-bezier(0.34,1.56,0.64,1), opacity 0.28s ease'; 428 ptr.style.opacity = '1'; 429 _sptrVisible = true; 430 })); 431 } else { 432 // Already visible: smooth glide 433 ptr.style.left = pos.x + 'px'; 434 ptr.style.top = pos.y + 'px'; 435 } 436 437 // Arrive effects after movement 438 if (_sptrArrTimer) clearTimeout(_sptrArrTimer); 439 _sptrArrTimer = setTimeout(() => { 440 // Arrival tap + rings 441 ptr.classList.remove('arriving'); 442 void ptr.offsetWidth; // reflow to restart animation 443 ptr.classList.add('arriving'); 444 445 // Update label 446 _sptrSetLabel(ptr, label); 447 448 // Highlight the target element 449 _oxHighlightEl(el); 450 451 setTimeout(() => ptr.classList.remove('arriving'), 900); 452 }, Math.max(scrollDelay, 500)); 453 454 }, scrollDelay > 0 ? 100 : 0); 455} 456 457// Set label text on pointer 458function _sptrSetLabel(ptr, label) { 459 if (_sptrLblTimer) clearTimeout(_sptrLblTimer); 460 ptr.classList.remove('labeled'); 461 let lbl = ptr.querySelector('.ox-sptr-label'); 462 if (!lbl) { 463 lbl = document.createElement('div'); 464 lbl.className = 'ox-sptr-label'; 465 ptr.appendChild(lbl); 466 } 467 if (label) { 468 lbl.textContent = label; 469 _sptrLblTimer = setTimeout(() => ptr.classList.add('labeled'), 50); 470 } else { 471 lbl.textContent = ''; 472 } 473} 474 475// Hide and park the pointer off-screen 476function oxPtrHide() { 477 const ptr = _sptrEl(); 478 if (!ptr) return; 479 if (_sptrArrTimer) clearTimeout(_sptrArrTimer); 480 if (_sptrLblTimer) clearTimeout(_sptrLblTimer); 481 ptr.classList.remove('arriving', 'labeled', 'speaking'); 482 ptr.style.opacity = '0'; 483 setTimeout(() => { 484 if (ptr.style.opacity === '0') ptr.style.display = 'none'; 485 _sptrVisible = false; 486 }, 350); 487 _oxClearHighlight(); 488} 489 490// Set speaking state on pointer (intensified glow) 491function oxPtrSetSpeaking(on) { 492 const ptr = _sptrEl(); 493 if (!ptr) return; 494 on ? ptr.classList.add('speaking') : ptr.classList.remove('speaking'); 495} 496 497// ââ SECTION HIGHLIGHT âââââââââââââââââââââââââââââââââââââââââ 498function _oxHighlightEl(el) { 499 _oxClearHighlight(); 500 if (!el) return; 501 el.classList.add('ox-highlighted'); 502 _oxHighlighted = el; 503 // One-shot arrive flash 504 requestAnimationFrame(() => { 505 el.classList.add('ox-highlight-arrive'); 506 setTimeout(() => el.classList.remove('ox-highlight-arrive'), 800); 507 }); 508} 509 510function _oxClearHighlight() { 511 if (_oxHighlighted) { 512 _oxHighlighted.classList.remove('ox-highlighted', 'ox-highlight-arrive'); 513 _oxHighlighted = null; 514 } 515} 516 517// Legacy API â accepts selector string or element 518function oxHighlight(sel) { 519 if (!sel) { _oxClearHighlight(); return null; } 520 const el = typeof sel === 'string' ? document.querySelector(sel) : sel; 521 if (el) { _oxHighlightEl(el); return el; } 522 return null; 523} 524 525// Legacy stubs (kept for backward compat) 526function oxShowPtr() {} 527function oxHidePtr() { oxPtrHide(); } 528 529// ââ RUN STEP âââââââââââââââââââââââââââââââââââââââââââââââââ 530function _oxMainTourDots(index) { 531 const cardInner = document.querySelector('.ox-card-inner'); 532 if (!cardInner || _vaultTourActive) return; 533 534 const steps = ONYX_STEPS; 535 536 // Only show dots for steps 1+ (step 0 is the offer card) 537 let bar = document.getElementById('oxMainTourDots'); 538 if (index === 0) { 539 if (bar) bar.remove(); 540 return; 541 } 542 543 if (!bar) { 544 bar = document.createElement('div'); 545 bar.id = 'oxMainTourDots'; 546 bar.className = 'ox-main-tour-dots'; 547 // Insert after header (first child), before caption label 548 const header = cardInner.querySelector('.ox-card-header'); 549 if (header && header.nextSibling) { 550 cardInner.insertBefore(bar, header.nextSibling); 551 } else { 552 cardInner.insertBefore(bar, cardInner.firstChild); 553 } 554 } 555 556 // Steps 1 through length-1 (step 0 is skipped in display) 557 const displayIndex = index - 1; 558 bar.innerHTML = steps.slice(1).map((_, i) => 559 `<span class="ox-mtd${i < displayIndex ? ' done' : i === displayIndex ? ' active' : ''}" title="Stop ${i + 2}"></span>` 560 ).join(''); 561} 562 563 564// ================================================================ 565// MAIN TOUR STATE MACHINE 566// oxRun(index) -- render step, speak, scroll/highlight
567// oxNext() -- advance step or fire step.action 568// oxMinimize() -- hide agent, show pill 569// oxRestart() -- restart tour from step 0 570// ================================================================ 571 572function oxRun(index) { 573 var steps = ONYX_STEPS; 574 var step = steps[index]; 575 if (!step) return; 576 577 // Commit immediately so any code reading onyxStep sees the new value 578 onyxStep = index; 579 580 oxStopSpeech(); 581 _oxPaused = false; 582 oxSetPauseBtn(false); 583 584 // Store for replay / unlock-and-resume 585 _oxCurrentSpeak = step.speak || null; 586 587 // -- Next button 588 var nb = document.getElementById('oxNextBtn'); 589 if (nb) { 590 if (step.next) { 591 nb.textContent = step.next; 592 nb.style.display = ''; 593 nb.classList.add('ox-btn-idle'); 594 } else { 595 nb.style.display = 'none'; 596 } 597 } 598 599 // -- Step badge 600 var badge = document.getElementById('oxStepBadge') || document.querySelector('.ox-step-badge'); 601 if (badge) badge.textContent = 'Stop ' + (index + 1) + ' / ' + steps.length; 602 603 // -- Progress dots 604 _oxMainTourDots(index); 605 606 // -- Agent name 607 var agentName = document.getElementById('oxAgentName'); 608 if (agentName) agentName.textContent = index === 0 ? 'Ebonie \u00b7 REI Vault Pro' : 'REI Vault Pro'; 609 610 // -- Welcome-card class (HOME tour step 0 only) 611 // Lifetime step 0 has a normal Next button; never add ox-tour-offer-active there. 612 var bubble = document.getElementById('oxBubble'); 613 var isHomeTour = _oxGetPage() === 'home'; 614 if (bubble) { 615 if (index === 0 && isHomeTour) { 616 bubble.classList.add('ox-vault-welcome-card', 'ox-tour-offer-active'); 617 } else { 618 bubble.classList.remove('ox-vault-welcome-card', 'ox-tour-offer-active'); 619 } 620 bubble.classList.add('on'); 621 } 622 623 // -- Shimmer sweep 624 var cardInner = document.querySelector('.ox-card-inner'); 625 if (cardInner) { 626 cardInner.classList.remove('ox-shimmer'); 627 void cardInner.offsetWidth; 628 cardInner.classList.add('ox-shimmer'); 629 } 630 631 // -- Smart pointer + auto-scroll 632 var targets = step.targets || (step.target ? [step.target] : null); 633 if (targets) { 634 oxPtrMoveTo(targets, step.label || null); 635 } else { 636 oxPtrHide(); 637 } 638 639 // -- Message display 640 var msgText = step.msg || ''; 641 var displayText = /<[a-zA-Z]/.test(msgText) ? msgText : (step.speak || msgText); 642 oxType(displayText); 643 644 // -- Speech + Avatar animation (fires once per step, equivalent to useEffect([currentStep])) 645 // 646 // oxSpeak() speaks synchronously using whatever voice is loaded at call time. 647 // _oxUnlockAudio() replays _oxCurrentSpeak synchronously inside the gesture handler 648 // on the first user tap (Yes / Next / Minimize), which is the iOS-safe pattern. 649 if (step.speak) { 650 // Capture the step index as the generation key â changes on every oxRun() call 651 var genStep = index; 652 653 // Trigger speech + lip-sync + waveform directly 654 if (!_oxMuted) { 655 _oxAudioUnlocked = true; // ensure iOS audio context is marked unlocked before speak() 656 oxSpeak(step.speak); 657 oxPtrSetSpeaking(true); 658 } 659 660 // Show tap-to-hear prompt if autoplay was silently blocked (1.5s grace period) 661 setTimeout(function() { 662 if (onyxStep !== genStep) return; // user has already moved to a different step 663 if (!_oxSpeaking && !_oxMuted && !_oxAudioUnlocked) { 664 _oxCurrentSpeak = step.speak; 665 var muteBtn = document.getElementById('oxMuteBtn'); 666 if (muteBtn) { 667 muteBtn.title = 'Tap to hear Ebonie'; 668 muteBtn.classList.add('ox-audio-prompt'); 669 } 670 } 671 }, 1500); 672 } 673 674 // -- Wink on step 0 675 if (index === 0) { 676 setTimeout(function() { 677 var blinkEl = document.getElementById('oxBlinkEl'); 678 if (blinkEl) { 679 blinkEl.classList.add('closed'); 680 setTimeout(function() { blinkEl.classList.remove('closed'); }, 220); 681 } 682 }, 1500); 683 } 684} 685 686// -- NAVIGATION 687// Timestamp guard prevents touchend+click double-fire on mobile. 688// 350ms window is short enough that two deliberate rapid taps are never blocked. 689var _oxLastNext = 0; 690var _oxLastMin = 0; 691 692function oxNext() { 693 var now = Date.now(); 694 if (now - _oxLastNext < 350) return; 695 _oxLastNext = now; 696 697 _oxAudioUnlocked = true; // mark unlocked â this IS a user gesture 698 _oxUnlockAudio(); 699 oxStopSpeech(); 700 701 // Delegate to vault tour engine when it is active 702 if (_vaultTourActive) { _vaultTourNext(); return; } 703 704 var steps = ONYX_STEPS; 705 var step = steps[onyxStep]; 706 707 // If current step has an action (e.g. navigate to /lifetime-membership), run it 708 if (step && step.action) { 709 step.action(); 710 return; 711 } 712 713 var next = onyxStep + 1; 714 if (next >= steps.length) { 715 oxMinimize(); 716 return; 717 } 718 oxRun(next); 719} 720 721function oxRestart() { 722 oxStopSpeech(); 723 onyxStep = 0; 724 oxRun(0); 725} 726 727// -- MUTE 728function oxMute() { 729 _oxMuted = !_oxMuted; 730 var btn = document.getElementById('oxMuteBtn'); 731 if (btn) btn.textContent = _oxMuted ? '\uD83D\uDD07' : '\uD83D\uDD0A'; 732 if (_oxMuted) oxStopSpeech(); 733} 734 735// -- MINIMIZE 736function oxMinimize() { 737 var now = Date.now(); 738 if (now - _oxLastMin < 500) return; 739 _oxLastMin = now; 740 741 oxStopSpeech(); 742 oxPtrHide(); 743 744 var agent = document.getElementById('oxAgent'); 745 var pill = document.getElementById('oxPill'); 746 var bubble = document.getElementById('oxBubble'); 747 748 if (bubble) bubble.classList.remove('on'); 749 750 if (agent) { 751 agent.style.transition = 'transform 0.38s cubic-bezier(0.4,0,0.2,1), opacity 0.28s ease'; 752 agent.style.transform = 'translateY(115%)'; 753 agent.style.opacity = '0'; 754 } 755 756 setTimeout(function() { 757 if (agent) agent.style.display = 'none'; 758 if (pill) { 759 pill.style.display = 'flex'; 760 pill.style.opacity = '0'; 761 pill.style.transform = 'translateY(18px)'; 762 requestAnimationFrame(function() { 763 requestAnimationFrame(function() { 764 pill.style.transition = 'all 0.38s cubic-bezier(0.34,1.56,0.64,1)'; 765 pill.style.opacity = '1'; 766 pill.style.transform = 'translateY(0)'; 767 }); 768 }); 769 } 770 }, 360); 771 772 try { sessionStorage.setItem('oxMin', '1'); } catch(e) {} 773} 774 775 776// ââ RESTORE âââââââââââââââââââââââââââââââââââââââââââââââââââ 777function oxRestore() { 778 _oxUnlockAudio(); // tapping pill = user gesture, unlock audio 779 const agent = document.getElementById('oxAgent'); 780 const pill = document.getElementById('oxPill'); 781 782 if (pill) { 783 pill.style.transition = 'all 0.22s ease'; 784 pill.style.opacity = '0'; 785 pill.style.transform = 'translateY(14px)'; 786 setTimeout(() => { 787 pill.style.display = 'none'; 788 pill.style.opacity = ''; 789 pill.style.transform = ''; 790 }, 230); 791 } 792 793 if (agent) { 794 agent.style.display = 'flex'; 795 agent.style.opacity = '0'; 796 agent.style.transform = 'translateY(40px)';
797 agent.style.transition = 'none'; 798 requestAnimationFrame(() => requestAnimationFrame(() => { 799 agent.style.transition = 'transform 0.42s cubic-bezier(0.34,1.56,0.64,1), opacity 0.32s ease'; 800 agent.style.transform = 'translateY(0)'; 801 agent.style.opacity = '1'; 802 })); 803 } 804 805 setTimeout(() => { 806 const bubble = document.getElementById('oxBubble'); 807 if (bubble) bubble.classList.add('on'); 808 }, 400); 809 810 try { sessionStorage.removeItem('oxMin'); } catch(e) {} 811} 812 813// ââ SWIPE TO MINIMIZE (MOBILE) ââââââââââââââââââââââââââââââââ 814(function() { 815 document.addEventListener('DOMContentLoaded', () => { 816 const agent = document.getElementById('oxAgent'); 817 if (!agent) return; 818 let sy = 0, st = 0; 819 agent.addEventListener('touchstart', e => { sy = e.touches[0].clientY; st = Date.now(); }, { passive: true }); 820 agent.addEventListener('touchend', e => { 821 if (e.changedTouches[0].clientY - sy > 55 && Date.now() - st < 400) oxMinimize(); 822 }, { passive: true }); 823 }); 824})(); 825 826// ââ KEYBOARD âââââââââââââââââââââââââââââââââââââââââââââââââ 827document.addEventListener('keydown', e => { 828 if (e.key === 'Escape') { 829 const agent = document.getElementById('oxAgent'); 830 const pill = document.getElementById('oxPill'); 831 if (agent && agent.style.display !== 'none') oxMinimize(); 832 else if (pill && pill.style.display === 'flex') oxRestore(); 833 } 834 if (e.key === ' ' && e.ctrlKey) { e.preventDefault(); oxPause(); } 835}); 836 837// ââ CLEANUP âââââââââââââââââââââââââââââââââââââââââââââââââââ 838window.addEventListener('beforeunload', () => window.speechSynthesis && window.speechSynthesis.cancel()); 839window.addEventListener('pagehide', () => window.speechSynthesis && window.speechSynthesis.cancel()); 840 841// Expose public API so inline onclick handlers in step HTML work across all browsers 842window.oxMinimize = oxMinimize; 843window.oxNext = oxNext; 844window.oxMute = oxMute; 845window.oxRestore = oxRestore; 846window.oxRun = oxRun; 847window.oxPause = oxPause; 848window.oxReplay = oxReplay; 849window.oxRestart = oxRestart; 850// Exposed so React components (EbonieAvatar, etc.) can trigger unlock on user gesture 851window.oxUnlockAudio = function() { _oxUnlockAudio(); }; 852 853// ââ YES BUTTON â direct onclick handler for Tour step 0 ââââââââââ 854// Called synchronously inside the user gesture stack on both iOS and desktop. 855// 1. Unlock audio (iOS: speak() must be called in gesture stack) 856// 2. Immediately speak step 1's text so iOS audio context is unlocked 857// 3. Advance to step 1 858var _oxTourYesFired = false; 859window.oxTourYes = function() { 860 if (_oxTourYesFired) return; 861 _oxTourYesFired = true; 862 setTimeout(function() { _oxTourYesFired = false; }, 800); 863 864 // Unlock audio synchronously â critical for iOS Safari 865 _oxAudioUnlocked = true; 866 var muteBtn = document.getElementById('oxMuteBtn'); 867 if (muteBtn) { muteBtn.classList.remove('ox-audio-prompt'); muteBtn.title = 'Mute / Unmute'; } 868 869 // Stop step 0 speech and advance 870 oxStopSpeech(); 871 oxRun(1); 872}; 873 874// Reset onyxStep on SPA navigation (e.g., React Router pushState) so the 875// correct tour starts from the beginning on the new page. 876// The 'oxPageChange' event is dispatched by the React app on route changes. 877window.addEventListener('oxPageChange', () => { 878 onyxStep = 0; 879 oxStopSpeech(); 880 // If agent is visible and not in vault tour, restart the appropriate tour 881 const agent = document.getElementById('oxAgent'); 882 const inVault = typeof _vaultTourActive !== 'undefined' && _vaultTourActive; 883 if (agent && agent.style.display !== 'none' && !inVault) { 884 setTimeout(() => oxRun(0), 300); 885 } 886}); 887 888// ââ AUDIO UNLOCK + REPLAY âââââââââââââââââââââââââââââââââââââ 889// iOS Safari and Chrome/Firefox both block SpeechSynthesis until a user gesture. 890// 891// CRITICAL iOS constraint: speechSynthesis.speak() must be called SYNCHRONOUSLY 892// inside the gesture handler â async callbacks (onend, setTimeout) are rejected. 893// 894// Strategy: 895// 1. On DOMContentLoaded, attempt a silent speak() â works on desktop Chrome/Firefox 896// without any gesture. iOS will silently reject but that's fine. 897// 2. Every user interaction (click, touch, Yes/No/Next/Minimize tap) calls 898// _oxUnlockAudio() which repeats the speak() in the gesture stack. 899// 3. Once unlocked, _oxAudioUnlocked = true and all subsequent calls work normally. 900let _oxAudioUnlocked = false;
901function _oxUnlockAudio() { 902 if (_oxAudioUnlocked || !window.speechSynthesis) return; 903 _oxAudioUnlocked = true; 904 905 // Remove the "tap to hear" pulse from the volume button 906 const muteBtn = document.getElementById('oxMuteBtn'); 907 if (muteBtn) { 908 muteBtn.classList.remove('ox-audio-prompt'); 909 muteBtn.title = 'Mute / Unmute'; 910 } 911 912 if (_oxCurrentSpeak && !_oxSpeaking && !window.speechSynthesis.speaking && !_oxMuted) { 913 // Speak the pending content synchronously inside the gesture handler. 914 // Check both _oxSpeaking (our flag) AND window.speechSynthesis.speaking 915 // (the browser's actual state) to handle the case where iOS rejected the 916 // initial speak() call â _oxSpeaking would be false from the watchdog reset. 917 oxSpeak(_oxCurrentSpeak); 918 } else if (!_oxCurrentSpeak || window.speechSynthesis.speaking) { 919 // Nothing pending (or already speaking) â prime the engine with a silent 920 // utterance so future speak() calls work on Chrome/Firefox. 921 try { 922 const u = new SpeechSynthesisUtterance('.'); 923 u.volume = 0.001; 924 u.rate = 10; 925 u.onerror = () => {}; 926 window.speechSynthesis.speak(u); 927 } catch(e) {} 928 } 929} 930// Catch first touch or click anywhere on the page â covers both mobile and desktop 931document.addEventListener('touchstart', _oxUnlockAudio, { once: true, passive: true }); 932document.addEventListener('click', _oxUnlockAudio, { once: true, passive: true }); 933 934// Early silent unlock attempt â works on desktop without any gesture required. 935// Fires as soon as DOM is ready, before cinematic. iOS will block it (expected). 936// Desktop Chrome/Firefox often permit this without a gesture.
937document.addEventListener('DOMContentLoaded', () => { 938 if (!window.speechSynthesis) return; 939 function _tryPrimeAudio() { 940 try { 941 const u = new SpeechSynthesisUtterance(' '); 942 u.volume = 0.001; 943 u.rate = 10; 944 u.onerror = () => {}; 945 u.onstart = () => { 946 window.speechSynthesis.cancel(); 947 _oxAudioUnlocked = true; 948 }; 949 u.onend = () => { _oxAudioUnlocked = true; }; 950 window.speechSynthesis.speak(u); 951 } catch(e) {} 952 } 953 // Try immediately, then retry after voices load (voice list can be async) 954 _tryPrimeAudio(); 955 setTimeout(_tryPrimeAudio, 600); 956 setTimeout(_tryPrimeAudio, 1400); 957}, { once: true }); 958 959// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 960// SECTION 15 â CINEMATIC POINTER ENHANCEMENTS 961// Section Glow Overlay + Pointer Beam + Trail Sparks 962// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 963 964let _oxGlowTimer = null; 965let _oxBeamTimer = null; 966let _oxLastPtrX = null; 967let _oxLastPtrY = null; 968let _oxSparkSchedule = null; 969 970// Section glow: wraps the highlighted element with a glowing box 971function _oxUpdateSectionGlow(el) { 972 const glow = document.getElementById('oxSectionGlow'); 973 if (!glow) return; 974 if (!el) { glow.classList.remove('active', 'arrive-flash'); return; } 975 976 if (_oxGlowTimer) clearTimeout(_oxGlowTimer); 977 978 const rect = el.getBoundingClientRect(); 979 const pad = 16; 980 glow.style.left = (rect.left + window.scrollX - pad) + 'px'; 981 glow.style.top = (rect.top + window.scrollY - pad) + 'px'; 982 glow.style.width = (rect.width + pad * 2) + 'px'; 983 glow.style.height = (rect.height + pad * 2) + 'px'; 984 glow.style.position = 'absolute'; 985 986 glow.classList.remove('arrive-flash', 'active'); 987 void glow.offsetWidth; // reflow 988 989 _oxGlowTimer = setTimeout(() => { 990 glow.classList.add('active', 'arrive-flash'); 991 setTimeout(() => glow.classList.remove('arrive-flash'), 750); 992 }, 50); 993} 994 995// Update glow position on scroll (while tour is active) 996function _oxGlowFollow() { 997 if (!_oxHighlighted) return; 998 const glow = document.getElementById('oxSectionGlow'); 999 if (!glow || !glow.classList.contains('active')) return; 1000 const rect = _oxHighlighted.getBoundingClientRect(); 1001 const pad = 16; 1002 glow.style.left = (rect.left + window.scrollX - pad) + 'px'; 1003 glow.style.top = (rect.top + window.scrollY - pad) + 'px'; 1004 glow.style.width = (rect.width + pad * 2) + 'px'; 1005 glow.style.height = (rect.height + pad * 2) + 'px'; 1006} 1007window.addEventListener('scroll', _oxGlowFollow, { passive: true }); 1008window.addEventListener('resize', _oxGlowFollow, { passive: true }); 1009 1010// Pointer beam: draws a gold line from avatar base to pointer tip 1011function _oxUpdatePointerBeam(ptrX, ptrY) { 1012 const beam = document.getElementById('oxPtrBeam'); 1013 const agent = document.getElementById('oxAgent'); 1014 if (!beam || !agent || ptrX === null) return; 1015 1016 const agRect = agent.getBoundingClientRect(); 1017 // Source point: top-center of agent card 1018 const srcX = agRect.left + agRect.width * 0.5; 1019 const srcY = agRect.top + agRect.height * 0.1; 1020 1021 const dx = ptrX - srcX; 1022 const dy = ptrY - srcY; 1023 const len = Math.sqrt(dx * dx + dy * dy); 1024 const ang = Math.atan2(dy, dx) * (180 / Math.PI); 1025 1026 beam.style.left = srcX + 'px'; 1027 beam.style.top = (srcY - 1) + 'px'; 1028 beam.style.width = len + 'px'; 1029 beam.style.transform = `rotate(${ang}deg)`; 1030 beam.classList.add('active'); 1031 1032 if (_oxBeamTimer) clearTimeout(_oxBeamTimer); 1033 _oxBeamTimer = setTimeout(() => { 1034 beam.classList.remove('active'); 1035 }, 1200); 1036} 1037 1038// Trail sparks: small gold particles emitted as pointer moves 1039function _oxEmitSparks(x, y, count) { 1040 for (let i = 0; i < count; i++) { 1041 const spark = document.createElement('div'); 1042 spark.className = 'ox-ptr-spark'; 1043 const ang = Math.random() * Math.PI * 2; 1044 const dist = 18 + Math.random() * 32; 1045 spark.style.left = (x + Math.random() * 10 - 5) + 'px'; 1046 spark.style.top = (y + Math.random() * 10 - 5) + 'px'; 1047 spark.style.setProperty('--sx', Math.round(Math.cos(ang) * dist) + 'px'); 1048 spark.style.setProperty('--sy', Math.round(Math.sin(ang) * dist) + 'px'); 1049 spark.style.width = (3 + Math.random() * 4) + 'px'; 1050 spark.style.height = spark.style.width; 1051 document.body.appendChild(spark); 1052 setTimeout(() => spark.remove(), 600); 1053 } 1054} 1055 1056// Hook into existing oxPtrMoveTo to fire sparks + beam + glow 1057const _oxOrigMoveTo = oxPtrMoveTo; 1058oxPtrMoveTo = function(targets, label) { 1059 _oxOrigMoveTo(targets, label); 1060 1061 const el = _sptrResolve(targets); 1062 if (!el) return; 1063 1064 // Sparks at movement midpoint (wait for scroll then snap) 1065 if (_oxSparkSchedule) clearTimeout(_oxSparkSchedule); 1066 _oxSparkSchedule = setTimeout(() => { 1067 const ptr = document.getElementById('oxSmartPtr'); 1068 if (!ptr || ptr.style.display === 'none') return; 1069 const px = parseFloat(ptr.style.left || '0'); 1070 const py = parseFloat(ptr.style.top || '0'); 1071 _oxEmitSparks(px, py, 5 + Math.floor(Math.random() * 4)); 1072 _oxLastPtrX = px; 1073 _oxLastPtrY = py; 1074 _oxUpdatePointerBeam(px, py); 1075 _oxUpdateSectionGlow(el); 1076 }, 820); 1077}; 1078 1079// Also hook oxPtrHide to clear glow 1080const _oxOrigHide = oxPtrHide; 1081oxPtrHide = function() { 1082 _oxOrigHide(); 1083 const glow = document.getElementById('oxSectionGlow'); 1084 if (glow) { glow.classList.remove('active', 'arrive-flash'); } 1085 const beam = document.getElementById('oxPtrBeam'); 1086 if (beam) { beam.classList.remove('active'); } 1087}; 1088 1089// ââ TOUR CATEGORY MAP âââââââââââââââââââââââââââââââââââââââââ 1090// Maps VAULT_TOUR array index to a category label shown in tour HUD 1091const _oxTourCategoryMap = { 1092 0: { icon: 'ð', label: 'Welcome' }, 1093 1: { icon: 'ð§', label: 'Platform' }, 1094 2: { icon: 'ð¤', label: 'AI Tools' }, 1095 3: { icon: 'ð§', label: 'Tools' }, 1096 4: { icon: 'ðï¸', label: 'CRM' }, 1097 5: { icon: 'ð¼', label: 'Wholesale' }, 1098 6: { icon: 'ð¨', label: 'Contractors' }, 1099 7: { icon: 'ð¨', label: 'Contractors' }, 1100 8: { icon: 'ð¨', label: 'Contractors' }, 1101 9: { icon: 'ð¨', label: 'Contractors' }, 1102 10: { icon: 'ð¨', label: 'Contractors' }, 1103 11: { icon: 'ð¨', label: 'Contractors' }, 1104 12: { icon: 'ð', label: 'Learn' }, 1105 13: { icon: 'ð', label: 'Education' }, 1106 14: { icon: 'ð®', label: 'Games' }, 1107 15: { icon: 'ð¢', label: 'CRE Platform' }, 1108 16: { icon: 'ð¬', label: 'Ask Onyx' }, 1109 17: { icon: 'ð°', label: 'Pricing' }, 1110 18: { icon: 'â', label: 'Lifetime' }, 1111 19: { icon: 'ð', label: 'Enter Vault' }, 1112}; 1113 1114// Inject category chip into vault progress bar each step 1115const _oxOrigUpdateProgress = _vaultTourUpdateProgress; 1116_vaultTourUpdateProgress = function(index) { 1117 _oxOrigUpdateProgress(index); 1118 // Add category chip beneath dots 1119 const bar = document.getElementById('oxVaultProgress'); 1120 if (!bar) return; 1121 const cat = _oxTourCategoryMap[index]; 1122 if (!cat) return; 1123 let chip = bar.querySelector('.ox-tour-chip'); 1124 if (!chip) { 1125 chip = document.createElement('div'); 1126 chip.className = 'ox-tour-chip'; 1127 bar.appendChild(chip); 1128 } 1129 chip.innerHTML = `<span>${cat.icon}</span><span>${cat.label}</span>`;
1130}; 1131 1132// ââ VAULT TOUR ENGINE âââââââââââââââââââââââââââââââââââââââââ 1133 1134function oxStartVaultTour() { 1135 if (typeof VAULT_TOUR === 'undefined') return; 1136 _vaultTourActive = true; 1137 _vaultTourStep = 0; 1138 try { sessionStorage.setItem('vaultTourActive', '1'); sessionStorage.setItem('vaultTourStep', '0'); } catch(e) {} 1139 1140 // Restore agent if minimized 1141 const agent = document.getElementById('oxAgent'); 1142 const pill = document.getElementById('oxPill'); 1143 if (agent && (agent.style.display === 'none' || agent.style.opacity === '0')) { 1144 oxRestore(); 1145 } else if (pill && pill.style.display === 'flex') { 1146 oxRestore(); 1147 } 1148 1149 setTimeout(() => _vaultTourRunStep(0), agent && agent.style.display !== 'none' ? 200 : 600); 1150} 1151 1152function _vaultTourRunStep(index) { 1153 if (!_vaultTourActive || typeof VAULT_TOUR === 'undefined') return; 1154 const step = VAULT_TOUR[index]; 1155 if (!step) { _vaultTourEnd(); return; } 1156 1157 // Vault tour never uses the welcome-card aesthetic 1158 const bubble = document.getElementById('oxBubble'); 1159 if (bubble) bubble.classList.remove('ox-vault-welcome-card'); 1160 1161 _vaultTourStep = index; 1162 _oxCurrentSpeak = step.speak || null; 1163 1164 try { 1165 sessionStorage.setItem('vaultTourActive', '1'); 1166 sessionStorage.setItem('vaultTourStep', String(index)); 1167 } catch(e) {} 1168 1169 // Update progress indicator 1170 _vaultTourUpdateProgress(index); 1171 1172 // Navigate if needed (dispatches event that React Router handles) 1173 const currentPath = window.location.pathname; 1174 const targetRoute = step.route || '/'; 1175 1176 function _runContent() { 1177 oxStopSpeech(); 1178 _oxPaused = false; 1179 oxSetPauseBtn(false); 1180 1181 // Update next button + idle pulse 1182 const nb = document.getElementById('oxNextBtn'); 1183 if (nb) { nb.textContent = step.next || 'Next â'; nb.classList.add('ox-btn-idle'); } 1184 1185 // Step counter badge 1186 const badge = document.querySelector('.ox-step-badge'); 1187 if (badge) badge.textContent = (index + 1) + ' / ' + VAULT_TOUR.length; 1188 1189 // Vault tour is never step 0 of main flow â always show just the brand 1190 const agentName = document.getElementById('oxAgentName'); 1191 if (agentName) agentName.textContent = 'REI Vault Pro'; 1192 1193 // Update restore pill step line during tour 1194 const pillStep = document.querySelector('.ox-pill-step'); 1195 if (pillStep) { 1196 pillStep.textContent = 'Stop ' + (index + 1) + ' of ' + VAULT_TOUR.length; 1197 pillStep.classList.add('visible'); 1198 } 1199 1200 // Show bubble 1201 const bubble = document.getElementById('oxBubble'); 1202 if (bubble) bubble.classList.add('on'); 1203 1204 // Shimmer sweep on new step 1205 const cardInner = document.querySelector('.ox-card-inner'); 1206 if (cardInner) { 1207 cardInner.classList.remove('ox-shimmer'); 1208 void cardInner.offsetWidth; 1209 cardInner.classList.add('ox-shimmer'); 1210 } 1211 1212 // If this step requests opening a nav dropdown, click it now 1213 if (step.openNav) { 1214 const navId = 'tour-nav-' + step.openNav.toLowerCase(); 1215 const navBtn = document.getElementById(navId); 1216 if (navBtn) { 1217 setTimeout(() => { 1218 navBtn.click(); 1219 setTimeout(() => oxPtrMoveTo([`#${navId}`], step.label || null), 80); 1220 }, 300); 1221 } 1222 } 1223 1224 // Smart pointer â move to targets, with auto-scroll built in 1225 const targets = step.targets || (step.scrollToId ? ['#' + step.scrollToId] : null); 1226 if (targets) { 1227 oxPtrMoveTo(targets, step.label || null); 1228 } else { 1229 oxPtrHide(); 1230 window.scrollTo({ top: 0, behavior: 'smooth' }); 1231 } 1232 1233 // If step has a scrollTo anchor, scroll to it after pointer settles 1234 if (step.scrollTo) { 1235 setTimeout(() => { 1236 const el = document.querySelector(step.scrollTo); 1237 if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' }); 1238 }, 800); 1239 } 1240 1241 // Contractor tab auto-click â click the matching tab and point to it 1242 if (step.contractorTab) { 1243 setTimeout(() => { 1244 const allBtns = document.querySelectorAll('button'); 1245 const tabLabels = { 1246 'dashboard': 'Dashboard', 1247 'directory': 'Contractors', 1248 'projects': 'Projects', 1249 'bids': 'Bid Compare', 1250 'scope': 'Scope Builder', 1251 'verification': 'Verify', 1252 'materials': 'Material Costs', 1253 'education': 'Education', 1254 'forms': 'Forms' 1255 }; 1256 const label = tabLabels[step.contractorTab]; 1257 if (label) { 1258 const tabBtn = Array.from(allBtns).find(b => b.textContent.trim() === label || b.textContent.trim().startsWith(label)); 1259 if (tabBtn) { 1260 tabBtn.click(); 1261 setTimeout(() => oxPtrMoveTo([tabBtn], step.label || null), 200); 1262 } 1263 } 1264 }, 600); 1265 } 1266 1267 // For the Onyx introduction stop (index 16 in updated array), highlight the Onyx widget 1268 if (index === 16) { 1269 setTimeout(() => { 1270 const onyxEl = document.getElementById('onyxWidget') || document.getElementById('onyxFab') || document.querySelector('.onyx-widget') || document.querySelector('.onyx-fab'); 1271 if (onyxEl) { 1272 _oxHighlightEl(onyxEl); 1273 onyxEl.style.setProperty('--onyx-highlight', '1'); 1274 } 1275 }, 600); 1276 } 1277 1278 // Typewriter shows speak text; for HTML-rich slides (final CTA) msg has the HTML 1279 const displayText = /<[a-zA-Z]/.test(step.msg || '') ? step.msg : (step.speak || step.msg); 1280 oxType(displayText); 1281 if (step.speak) {
1282 const myCallId = ++_oxSpeechId; 1283 setTimeout(() => { 1284 if (myCallId !== _oxSpeechId) return; 1285 if (!_oxAudioUnlocked) { 1286 // Audio is locked until user gesture â save speak text and pulse volume btn 1287 _oxCurrentSpeak = step.speak; 1288 oxPtrSetSpeaking(false); 1289 const muteBtn = document.getElementById('oxMuteBtn'); 1290 if (muteBtn) { 1291 muteBtn.title = 'Tap to hear Ebonie'; 1292 muteBtn.classList.add('ox-audio-prompt'); 1293 } 1294 } else { 1295 oxSpeak(step.speak); 1296 oxPtrSetSpeaking(true); 1297 } 1298 }, 200); 1299 } 1300 1301 // Section 16 â push Onyx concierge suggestions for this tour stop 1302 if (typeof window.onyxTourConcierge === 'function') { 1303 setTimeout(() => window.onyxTourConcierge(index), 700); 1304 } 1305 } 1306 1307 if (targetRoute && currentPath !== targetRoute) { 1308 // Dispatch navigation event to React 1309 window.dispatchEvent(new CustomEvent('oxVaultNavigate', { detail: { route: targetRoute } })); 1310 // Wait for page transition then run content 1311 if (_vaultTourNavTimer) clearTimeout(_vaultTourNavTimer); 1312 _vaultTourNavTimer = setTimeout(_runContent, 1400); 1313 } else { 1314 _runContent(); 1315 } 1316} 1317 1318function _vaultTourNext() { 1319 if (!_vaultTourActive) return; 1320 // Close any open nav dropdown before advancing 1321 _oxCloseNavMenus(); 1322 const step = VAULT_TOUR[_vaultTourStep]; 1323 if (step && step.action) { 1324 step.action(); 1325 _vaultTourEnd(); 1326 return; 1327 } 1328 const next = _vaultTourStep + 1; 1329 if (next >= VAULT_TOUR.length) { _vaultTourEnd(); return; } 1330 _vaultTourRunStep(next); 1331} 1332 1333function _vaultTourEnd() { 1334 _vaultTourActive = false; 1335 _oxCloseNavMenus(); 1336 try { sessionStorage.removeItem('vaultTourActive'); sessionStorage.removeItem('vaultTourStep'); } catch(e) {} 1337 _vaultTourHideProgress(); 1338 oxHighlight(null); 1339 // Revert Next button 1340 const nb = document.getElementById('oxNextBtn'); 1341 if (nb) nb.textContent = 'Next â'; 1342} 1343 1344// Close any open desktop nav dropdowns 1345function _oxCloseNavMenus() { 1346 ['tour-nav-platform', 'tour-nav-tools', 'tour-nav-learn'].forEach(id => { 1347 // Check if the menu panel is currently in the DOM (React conditionally renders it) 1348 const menuPanel = document.getElementById(id + '-menu'); 1349 if (menuPanel && menuPanel.offsetParent !== null) { 1350 const btn = document.getElementById(id); 1351 if (btn) btn.click(); 1352 } 1353 }); 1354} 1355 1356function _vaultTourUpdateProgress(index) { 1357 if (typeof VAULT_TOUR === 'undefined') return; 1358 let bar = document.getElementById('oxVaultProgress'); 1359 if (!bar) { 1360 bar = document.createElement('div'); 1361 bar.id = 'oxVaultProgress'; 1362 bar.className = 'ox-vault-progress'; 1363 const cardInner = document.querySelector('.ox-card-inner'); 1364 if (cardInner) cardInner.insertBefore(bar, cardInner.firstChild); 1365 } 1366 const total = VAULT_TOUR.length; 1367 const pct = Math.round(((index) / (total - 1)) * 100); 1368 const stop = VAULT_TOUR[index]; 1369 bar.innerHTML = ` 1370 <div class="ox-vp-header"> 1371 <span class="ox-vp-label">Vault Tour</span> 1372 <span class="ox-vp-count">${index + 1} / ${total}</span> 1373 </div> 1374 <div class="ox-vp-track"><div class="ox-vp-fill" style="width:${pct}%"></div></div> 1375 <div class="ox-vp-stops">${VAULT_TOUR.map((s, i) => 1376 `<span class="ox-vp-dot${i < index ? ' done' : i === index ? ' active' : ''}" title="${s.label}"></span>` 1377 ).join('')}</div> 1378 `; 1379} 1380 1381function _vaultTourHideProgress() { 1382 const bar = document.getElementById('oxVaultProgress'); 1383 if (bar) bar.remove(); 1384} 1385 1386// Resume vault tour after navigation (called from React via event) 1387window._vaultTourResumeAfterNav = function() { 1388 if (_vaultTourActive && typeof VAULT_TOUR !== 'undefined') { 1389 // Give DOM time to settle 1390 setTimeout(() => _vaultTourRunStep(_vaultTourStep), 400); 1391 } 1392}; 1393 1394// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1395// CINEMATIC OPENING ENGINE â ENTER THE VAULT 1396// Plays once per session. Orchestrates: 1397// 1. Dark vignette fade-in 1398// 2. Gold beam sweep across screen 1399// 3. Vault emblem crest appears (center) 1400// 4. Synthesized vault-unlock sound 1401// 5. Reveal: overlay lifts, Ebonie springs into view 1402// 6. Ebonie greets user after reveal completes 1403// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1404 1405function _oxPlayVaultSound() { 1406 try { 1407 const ctx = new (window.AudioContext || window.webkitAudioContext)(); 1408 const now = ctx.currentTime; 1409 1410 // Low bass rumble â vault door weight 1411 const bass = ctx.createOscillator(); 1412 const bassGain = ctx.createGain(); 1413 bass.type = 'sine'; 1414 bass.frequency.setValueAtTime(60, now); 1415 bass.frequency.exponentialRampToValueAtTime(90, now + 0.6); 1416 bassGain.gain.setValueAtTime(0, now); 1417 bassGain.gain.linearRampToValueAtTime(0.28, now + 0.08); 1418 bassGain.gain.exponentialRampToValueAtTime(0.001, now + 1.4); 1419 bass.connect(bassGain); 1420 bassGain.connect(ctx.destination); 1421 bass.start(now); bass.stop(now + 1.4); 1422 1423 // Mid resonance â vault mechanism turning 1424 const mid = ctx.createOscillator(); 1425 const midGain = ctx.createGain(); 1426 mid.type = 'triangle'; 1427 mid.frequency.setValueAtTime(180, now + 0.2); 1428 mid.frequency.exponentialRampToValueAtTime(320, now + 0.7); 1429 midGain.gain.setValueAtTime(0, now + 0.2); 1430 midGain.gain.linearRampToValueAtTime(0.12, now + 0.3); 1431 midGain.gain.exponentialRampToValueAtTime(0.001, now + 1.1); 1432 mid.connect(midGain); 1433 midGain.connect(ctx.destination); 1434 mid.start(now + 0.2); mid.stop(now + 1.1); 1435 1436 // Gold shimmer â metallic high sweep 1437 const shimmer = ctx.createOscillator(); 1438 const shimmerGain = ctx.createGain(); 1439 shimmer.type = 'triangle'; 1440 shimmer.frequency.setValueAtTime(520, now + 0.5); 1441 shimmer.frequency.exponentialRampToValueAtTime(1100, now + 1.0); 1442 shimmerGain.gain.setValueAtTime(0, now + 0.5); 1443 shimmerGain.gain.linearRampToValueAtTime(0.07, now + 0.65); 1444 shimmerGain.gain.exponentialRampToValueAtTime(0.001, now + 1.2); 1445 shimmer.connect(shimmerGain); 1446 shimmerGain.connect(ctx.destination); 1447 shimmer.start(now + 0.5); shimmer.stop(now + 1.2); 1448 1449 // Vault click â impulsive metallic transient 1450 const bufLen = Math.floor(ctx.sampleRate * 0.06); 1451 const buf = ctx.createBuffer(1, bufLen, ctx.sampleRate); 1452 const data = buf.getChannelData(0); 1453 for (let i = 0; i < bufLen; i++) { 1454 data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / bufLen, 4); 1455 } 1456 const click = ctx.createBufferSource();
1457 const clickFilt = ctx.createBiquadFilter(); 1458 const clickGain = ctx.createGain(); 1459 clickFilt.type = 'bandpass'; 1460 clickFilt.frequency.value = 900; 1461 clickFilt.Q.value = 0.8; 1462 clickGain.gain.setValueAtTime(0.7, now + 0.62); 1463 clickGain.gain.exponentialRampToValueAtTime(0.001, now + 0.68); 1464 click.buffer = buf; 1465 click.connect(clickFilt); 1466 clickFilt.connect(clickGain); 1467 clickGain.connect(ctx.destination); 1468 click.start(now + 0.62); 1469 1470 // Second click â lock engaging 1471 const click2 = ctx.createBufferSource(); 1472 const click2Filt = ctx.createBiquadFilter(); 1473 const click2Gain = ctx.createGain(); 1474 click2Filt.type = 'bandpass'; 1475 click2Filt.frequency.value = 1400; 1476 click2Filt.Q.value = 1.2; 1477 click2Gain.gain.setValueAtTime(0.45, now + 0.72); 1478 click2Gain.gain.exponentialRampToValueAtTime(0.001, now + 0.77); 1479 click2.buffer = buf; 1480 click2.connect(click2Filt); 1481 click2Filt.connect(click2Gain); 1482 click2Gain.connect(ctx.destination); 1483 click2.start(now + 0.72); 1484 1485 setTimeout(() => { try { ctx.close(); } catch(e) {} }, 2500); 1486 } catch(e) {} 1487} 1488 1489function oxCinematicOpen(onComplete) { 1490 const cinema = document.getElementById('oxCinema'); 1491 const agent = document.getElementById('oxAgent'); 1492 1493 // Skip if already seen this session 1494 try { 1495 if (sessionStorage.getItem('oxCinemaSeen') === '1') { 1496 if (cinema) cinema.style.display = 'none'; 1497 if (agent) { 1498 agent.classList.remove('ox-cinema-hidden'); 1499 agent.style.opacity = '0'; 1500 agent.style.transform = 'translateY(60px)'; 1501 agent.style.transition = 'none'; 1502 requestAnimationFrame(() => requestAnimationFrame(() => { 1503 agent.style.transition = 'transform 0.65s cubic-bezier(0.34,1.56,0.64,1), opacity 0.5s ease'; 1504 agent.style.transform = 'translateY(0)'; 1505 agent.style.opacity = '1'; 1506 })); 1507 } 1508 if (onComplete) setTimeout(onComplete, 400); 1509 return; 1510 } 1511 } catch(e) {} 1512 1513 if (!cinema) { 1514 if (onComplete) setTimeout(onComplete, 500); 1515 return; 1516 } 1517 1518 // ââ Phase 1: Activate overlay (dark + crest appear) ââ 1519 cinema.style.opacity = '1'; 1520 cinema.classList.add('ox-cinema-active'); 1521 1522 // Any tap/click during the cinematic counts as audio gesture 1523 cinema.addEventListener('click', _oxUnlockAudio, { once: true, passive: true }); 1524 cinema.addEventListener('touchstart', _oxUnlockAudio, { once: true, passive: true }); 1525 1526 // ââ Phase 2: Sound at peak of beam (450ms in) ââ 1527 setTimeout(() => { 1528 _oxPlayVaultSound(); 1529 }, 450); 1530 1531 // ââ Phase 3: Logo glow on #main-nav logo ââ 1532 setTimeout(() => { 1533 const logo = document.querySelector('#main-nav img'); 1534 if (logo) { 1535 logo.style.transition = 'filter 0.3s ease, box-shadow 0.3s ease'; 1536 logo.style.filter = 'drop-shadow(0 0 18px rgba(224,184,24,0.9)) brightness(1.3)'; 1537 setTimeout(() => { 1538 logo.style.filter = ''; 1539 logo.style.transition = ''; 1540 }, 900); 1541 } 1542 }, 550); 1543 1544 // ââ Phase 4: Begin reveal (overlay lifts) at 1000ms ââ 1545 setTimeout(() => { 1546 cinema.classList.add('ox-cinema-reveal'); 1547 1548 // Spring Ebonie in as darkness lifts 1549 if (agent) { 1550 agent.classList.remove('ox-cinema-hidden'); 1551 void agent.offsetWidth; // force reflow 1552 agent.classList.add('ox-cinema-enter'); 1553 } 1554 1555 // Waveform boot-flash 1556 const waveBars = document.getElementById('oxWaveBars'); 1557 if (waveBars) { 1558 waveBars.style.opacity = '1'; 1559 setTimeout(() => { waveBars.style.opacity = ''; }, 600); 1560 } 1561 }, 1000); 1562 1563 // ââ Phase 5: Cinema done, Ebonie starts ââ 1564 setTimeout(() => { 1565 cinema.style.display = 'none'; 1566 cinema.style.opacity = '0'; 1567 cinema.classList.remove('ox-cinema-active', 'ox-cinema-reveal'); 1568 1569 if (agent) agent.classList.remove('ox-cinema-enter'); 1570 1571 try { sessionStorage.setItem('oxCinemaSeen', '1'); } catch(e) {} 1572 if (onComplete) onComplete(); 1573 }, 1800); 1574} 1575 1576// ââ AUTO START â cinematic then greet ââââââââââââââââââââââââ 1577document.addEventListener('DOMContentLoaded', () => { 1578 // Wire buttons for mobile + desktop. 1579 // touchend fires before click on mobile; preventDefault stops the synthetic 1580 // click. Timestamp dedup (400ms) prevents double-fire without any boolean 1581 // flag that could get stuck if the handler throws. 1582 function _btnOn(id, fn) { 1583 var el = document.getElementById(id); 1584 if (!el) return; 1585 var _lastFire = 0; 1586 function _fire(e) { 1587 if (e && e.cancelable) e.preventDefault(); 1588 var now = Date.now(); 1589 if (now - _lastFire < 400) return; 1590 _lastFire = now; 1591 fn(); 1592 } 1593 el.addEventListener('touchend', _fire, { passive: false }); 1594 el.addEventListener('click', _fire); 1595 } 1596 _btnOn('oxMinBtn', oxMinimize); 1597 _btnOn('oxNextBtn', oxNext); 1598 _btnOn('oxPauseBtn', oxPause); 1599 _btnOn('oxReplayBtn', oxReplay); 1600 _btnOn('oxMuteBtn', function() { 1601 const btn = document.getElementById('oxMuteBtn'); 1602 const isUnlockTap = btn && btn.classList.contains('ox-audio-prompt'); 1603 _oxUnlockAudio();
1604 if (!isUnlockTap) oxMute(); 1605 }); 1606 _btnOn('oxPill', oxRestore); 1607 // Wire tour offer buttons with same reliable touchend+click pattern 1608 _btnOn('oxTourYesBtn', oxTourYes); 1609 _btnOn('oxTourNoBtn', oxMinimize); 1610 1611 const agent = document.getElementById('oxAgent'); 1612 1613 // ââ Card click unlocks audio on Chrome/Firefox desktop ââ 1614 // Wire BEFORE any early returns so vault tour also benefits. 1615 const bubble = document.getElementById('oxBubble'); 1616 if (bubble) { 1617 bubble.addEventListener('click', _oxUnlockAudio, { passive: true }); 1618 bubble.addEventListener('touchstart', _oxUnlockAudio, { once: true, passive: true }); 1619 } 1620 1621 // If minimized, skip all intros and restore pill state 1622 try { 1623 if (sessionStorage.getItem('oxMin') === '1') { 1624 if (agent) { 1625 agent.classList.remove('ox-cinema-hidden'); 1626 agent.style.display = 'none'; 1627 } 1628 const pill = document.getElementById('oxPill'); 1629 if (pill) pill.style.display = 'flex'; 1630 return; 1631 } 1632 } catch(e) {} 1633 1634 // Resume vault tour across navigation â no cinematic needed 1635 try { 1636 if (sessionStorage.getItem('vaultTourActive') === '1') { 1637 if (agent) { 1638 agent.classList.remove('ox-cinema-hidden'); 1639 agent.style.opacity = '1'; 1640 agent.style.transform = 'translateY(0)'; 1641 } 1642 const step = parseInt(sessionStorage.getItem('vaultTourStep') || '0', 10); 1643 _vaultTourActive = true; 1644 _vaultTourStep = step; 1645 setTimeout(() => { 1646 if (bubble) bubble.classList.add('on'); 1647 _vaultTourRunStep(step); 1648 }, 800); 1649 return; 1650 } 1651 } catch(e) {} 1652 1653 // ââ Cinematic opening â then Ebonie greets ââ 1654 // On mobile (â¤640px) auto-minimize on first visit so the widget doesn't 1655 // cover content. User can tap the pill to expand at any time. 1656 if (window.innerWidth <= 640) { 1657 try { sessionStorage.setItem('oxMin', '1'); } catch(e) {} 1658 if (agent) agent.style.display = 'none'; 1659 const pill = document.getElementById('oxPill'); 1660 if (pill) { 1661 pill.style.display = 'flex'; 1662 pill.style.opacity = '1'; 1663 pill.style.transform = 'translateY(0)'; 1664 } 1665 return; 1666 } 1667 1668 oxCinematicOpen(() => { 1669 // Brief pause after reveal before Ebonie speaks 1670 setTimeout(() => oxRun(0), 400); 1671 }); 1672});
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.