PageSourceSearch

https://reivaultpro.com/js/onyx-agent.js

js reivaultpro.com collected 2026-10-09 22:18:11 UTC 60,090 bytes, 1,672 lines download raw bytes

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.