// v3-dialogue.jsx, Voice transcript system + Cortex placement helpers.
// Word-by-word reveal that reads like a real-time voice call transcription,
// the canonical 220-node Cortex orb as Lantic's presence, plus scene chrome.

const {
  BRAND, SAT, SF, MONO,
  useReducedMotion, useInView, useViewport,
  clamp01, easeOutCubic, easeInOutCubic,
  Cortex,
} = window;

// v3 keyframes
if (typeof document !== 'undefined' && !document.getElementById('v3-kf')) {
  const s = document.createElement('style'); s.id = 'v3-kf';
  s.textContent = `
    @keyframes v3-word-in    { 0%{opacity:0;transform:translateY(6px);filter:blur(4px)} 100%{opacity:1;transform:translateY(0);filter:blur(0)} }
    @keyframes v3-hero-rise  { 0%{opacity:0;transform:translateY(22px)} 100%{opacity:1;transform:translateY(0)} }
    @keyframes v3-pulse-orb  { 0%,100%{transform:scale(1);opacity:1} 50%{transform:scale(1.12);opacity:.82} }
    @keyframes v3-glow-soft  { 0%,100%{opacity:.55} 50%{opacity:.95} }
    @keyframes v3-drift      { 0%{transform:translateY(0)} 50%{transform:translateY(-4px)} 100%{transform:translateY(0)} }
    @keyframes v3-ring-out   { 0%{transform:scale(.7);opacity:.85} 100%{transform:scale(2.4);opacity:0} }
    @keyframes v3-particles  { 0%{transform:translateY(0) translateX(0);opacity:0} 10%,90%{opacity:.55} 100%{transform:translateY(-32px) translateX(8px);opacity:0} }
    @keyframes v3-shimmer    { 0%,100%{opacity:.55} 50%{opacity:1} }
    @keyframes v3-tick-dot   { 0%,100%{opacity:.25} 50%{opacity:1} }
    @keyframes v3-bar-1      { 0%,100%{transform:scaleY(.20)} 50%{transform:scaleY(.85)} }
    @keyframes v3-bar-2      { 0%,100%{transform:scaleY(.40)} 50%{transform:scaleY(1.00)} }
    @keyframes v3-bar-3      { 0%,100%{transform:scaleY(.30)} 50%{transform:scaleY(.70)} }
    @keyframes v3-bar-4      { 0%,100%{transform:scaleY(.55)} 50%{transform:scaleY(.95)} }
    @keyframes v3-bar-5      { 0%,100%{transform:scaleY(.25)} 50%{transform:scaleY(.65)} }
    @keyframes v3-cursor     { 0%,49%{opacity:1} 50%,100%{opacity:0} }
  `;
  document.head.appendChild(s);
}

// ─────────────────────────────────────────────────────────────────
// CortexOverlay, the canonical 220-node Cortex positioned over a scene image.
// Pulses brighter (glowAlpha + scale) while `speaking` is true.
// ─────────────────────────────────────────────────────────────────
function CortexOverlay({ x = 60, y = 50, size = 200, speaking = false, dimmed = false }) {
  const reduced = useReducedMotion();
  return (
    <div style={{
      position: 'absolute', left: `${x}%`, top: `${y}%`,
      width: size, height: size, transform: 'translate(-50%, -50%)',
      pointerEvents: 'none', zIndex: 4,
      opacity: dimmed ? 0.78 : 1, transition: 'opacity 600ms ease',
    }}>
      {/* speaking ring */}
      {speaking && !reduced && (
        <div style={{
          position: 'absolute', inset: -10, borderRadius: '50%',
          border: `1px solid ${BRAND.skyLight}`,
          animation: 'v3-ring-out 2.6s ease-out infinite',
        }}/>
      )}
      {/* scale wrapper: gently larger while speaking */}
      <div style={{
        width: '100%', height: '100%',
        transform: speaking ? 'scale(1.05)' : 'scale(1)',
        transition: 'transform 480ms cubic-bezier(.22,1,.36,1)',
      }}>
        <Cortex size={size} animate={!reduced} glowLobe={speaking ? BRAND.skyLight : null} glowAlpha={speaking ? 0.85 : 0.55}/>
      </div>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────────
// Voice waveform, five animated bars; only animates while `active`.
// ─────────────────────────────────────────────────────────────────
function VoiceBars({ active, color = BRAND.skyLight, size = 12 }) {
  const reduced = useReducedMotion();
  return (
    <span style={{
      display: 'inline-flex', alignItems: 'center', gap: 2,
      height: size,
    }}>
      {[1,2,3,4,5].map(i => (
        <span key={i} style={{
          display: 'block',
          width: 2, height: size, borderRadius: 2,
          background: color,
          transformOrigin: 'center',
          opacity: active ? 1 : 0.35,
          animation: (active && !reduced) ? `v3-bar-${i} ${0.8 + i * 0.07}s ease-in-out ${i * 0.05}s infinite` : 'none',
          transform: 'scaleY(0.4)',
          transition: 'opacity 200ms ease',
        }}/>
      ))}
    </span>
  );
}

// ─────────────────────────────────────────────────────────────────
// useVoiceTranscript, line-by-line, word-by-word playback driver.
// Returns: {lineIdx, wordIdx, isLineDone, isAllDone, restart}
// Word reveal cadence ~ 180ms/word, line gap ~ 650ms.
// Starts as soon as `start` flips true.
// ─────────────────────────────────────────────────────────────────
function useVoiceTranscript(lines, { start = true, wordMs = 180, lineGapMs = 700, loop = true, loopGapMs = 1800 } = {}) {
  const [lineIdx, setLineIdx] = React.useState(0);
  const [wordIdx, setWordIdx] = React.useState(0);
  const [done, setDone]       = React.useState(false);
  const [loopN, setLoopN]     = React.useState(0);
  const reduced = useReducedMotion();
  const wordsPerLine = React.useMemo(() => lines.map(l => l.text.split(/\s+/).length), [lines]);

  React.useEffect(() => {
    if (!start) return;
    if (reduced) { setLineIdx(lines.length - 1); setWordIdx(wordsPerLine[lines.length - 1] || 0); setDone(true); return; }
    let cancelled = false;
    const run = async () => {
      do {
        for (let li = 0; li < lines.length; li++) {
          if (cancelled) return;
          setLineIdx(li);
          const n = wordsPerLine[li] || 1;
          for (let wi = 0; wi <= n; wi++) {
            if (cancelled) return;
            setWordIdx(wi);
            await new Promise(r => setTimeout(r, wordMs));
          }
          await new Promise(r => setTimeout(r, lineGapMs));
        }
        if (loop) {
          await new Promise(r => setTimeout(r, loopGapMs));
          setLoopN(n => n + 1);
        } else {
          if (!cancelled) setDone(true);
          break;
        }
      } while (loop && !cancelled);
    };
    run();
    return () => { cancelled = true; };
  }, [start, lines, wordMs, lineGapMs, loop, loopGapMs, reduced]);

  const restart = React.useCallback(() => { setLineIdx(0); setWordIdx(0); setDone(false); }, []);
  return { lineIdx, wordIdx, done, loopN, restart };
}

// ─────────────────────────────────────────────────────────────────
// VoiceLine, one transcript line with speaker chip, waveform, word reveal.
// `state` = 'past' | 'active' | 'upcoming'.
// `wordIdx` only meaningful when active.
// ─────────────────────────────────────────────────────────────────
function VoiceLine({ who, text, state, wordIdx, compact = false }) {
  const isLantic = who === 'L' || who === 'L2';
  const speakerName = who === 'L'  ? 'Lantic' :
                      who === 'L2' ? 'Mike’s Lantic' :
                      who === 'P2' ? 'Mike' : 'You';
  const speakerColor = isLantic ? BRAND.skyLight : '#FBBF24';
  const dotColor     = who === 'L2' ? '#A78BFA' : speakerColor;
  const isActive = state === 'active';
  const isPast   = state === 'past';
  const isUpcoming = state === 'upcoming';

  const words = React.useMemo(() => text.split(/(\s+)/), [text]);
  let revealedWordCount = 0;
  const wordSpans = words.map((w, i) => {
    if (w.match(/\s+/)) return <span key={i}>{w}</span>;
    revealedWordCount += 1;
    const shouldShow = isPast || (isActive && revealedWordCount <= wordIdx);
    return (
      <span key={i} style={{
        display: shouldShow ? 'inline' : 'none',
      }}>{w}</span>
    );
  });

  return (
    <div style={{
      display: isUpcoming ? 'none' : 'flex', flexDirection: 'column',
      marginBottom: compact ? 12 : 16,
      opacity: isPast ? 0.55 : 1,
      maxWidth: '100%',
    }}>
      {/* speaker chip + voice bars */}
      <div style={{
        display: 'inline-flex', alignItems: 'center', gap: 9, marginBottom: 6,
        height: 18,
      }}>
        <span style={{
          width: 7, height: 7, borderRadius: 4, background: dotColor,
          boxShadow: isActive ? `0 0 12px ${dotColor}` : 'none',
          animation: isActive ? 'v3-tick-dot 1.2s ease-in-out infinite' : 'none',
        }}/>
        <span style={{
          fontFamily: MONO, fontSize: 10.5, fontWeight: 600,
          letterSpacing: '0.22em', textTransform: 'uppercase',
          color: isActive ? BRAND.ink : (isLantic ? BRAND.skyMist : BRAND.sub),
          transition: 'color 400ms ease',
        }}>{speakerName}</span>
        <VoiceBars active={isActive} color={isLantic ? BRAND.skyLight : '#FBBF24'} size={10}/>
      </div>

      <div style={{
        fontFamily: SF,
        fontSize: compact ? 18 : 22, lineHeight: 1.35,
        fontWeight: isLantic ? 400 : 500,
        color: isLantic ? '#E0F2FE' : BRAND.ink,
        letterSpacing: -0.2,
        textShadow: isLantic
          ? `0 0 18px ${BRAND.skyDark}cc, 0 2px 12px rgba(0,0,0,.85), 0 0 1px rgba(0,0,0,.9)`
          : `0 2px 12px rgba(0,0,0,.92), 0 0 1px rgba(0,0,0,.95)`,
        fontStyle: isLantic ? 'italic' : 'normal',
        textWrap: 'pretty',
      }}>
        {wordSpans}
        {isActive && wordIdx < words.filter(w => !w.match(/\s+/)).length && (
          <span style={{
            display: 'inline-block', marginLeft: 2,
            width: 2, height: '1em', verticalAlign: '-0.18em',
            background: isLantic ? BRAND.skyLight : '#FBBF24',
            animation: 'v3-cursor 1.05s steps(2) infinite',
          }}/>
        )}
      </div>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────────
// VoiceTranscript, full transcript with word-by-word reveal.
// `start` flips true when the scene wants playback to begin.
// ─────────────────────────────────────────────────────────────────
function VoiceTranscript({ lines, start = true, compact = false, onActiveSpeakerChange }) {
  const { lineIdx, wordIdx, done } = useVoiceTranscript(lines, { start });
  React.useEffect(() => {
    if (!onActiveSpeakerChange) return;
    const cur = lines[lineIdx];
    onActiveSpeakerChange(cur?.who || null);
  }, [lineIdx, lines, onActiveSpeakerChange]);

  // Real-time voice transcription style: show ONLY the active line, with words
  // appearing one-by-one. After each line finishes its line-gap, the next line
  // replaces it. The previous line is shown briefly dimmed above for continuity.
  const prev = lineIdx > 0 ? lines[lineIdx - 1] : null;
  const cur  = lines[lineIdx];

  if (!cur) return null;
  return (
    <div style={{ display: 'flex', flexDirection: 'column', width: '100%' }}>
      {prev && (
        <VoiceLine key={`prev-${lineIdx - 1}`} who={prev.who} text={prev.text}
          state="past" wordIdx={0} compact={compact}/>
      )}
      <VoiceLine key={`cur-${lineIdx}`} who={cur.who} text={cur.text}
        state="active" wordIdx={wordIdx} compact={compact}/>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────────
// SceneMeta, top-left tag.
// ─────────────────────────────────────────────────────────────────
function SceneMeta({ n, time, persona, location, visible = true }) {
  return (
    <div style={{
      position: 'absolute', top: 36, left: 36, zIndex: 5,
      display: 'flex', flexDirection: 'column', gap: 6,
      opacity: visible ? 1 : 0,
      transform: visible ? 'translateY(0)' : 'translateY(-8px)',
      transition: 'opacity 800ms ease, transform 800ms ease',
    }}>
      <div style={{
        display: 'inline-flex', alignItems: 'center', gap: 12,
        fontFamily: MONO, fontSize: 11, color: BRAND.skyMist,
        letterSpacing: '0.22em', fontWeight: 600, textTransform: 'uppercase',
      }}>
        <span style={{
          padding: '4px 9px', borderRadius: 4,
          background: 'rgba(56,189,248,.10)', border: '1px solid rgba(56,189,248,.22)',
          backdropFilter: 'blur(10px)', WebkitBackdropFilter: 'blur(10px)',
        }}>Scene {n}</span>
        <span>{time}</span>
        <span style={{ color: BRAND.dim }}>·</span>
        <span style={{ color: BRAND.sub }}>{location}</span>
      </div>
      <div style={{
        fontFamily: SAT, fontSize: 36, fontWeight: 600, letterSpacing: -1.2,
        color: BRAND.ink, lineHeight: 1,
        textShadow: '0 4px 28px rgba(0,0,0,.6)',
      }}>{persona}</div>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────────
// OutcomeTag, bottom strip.
// ─────────────────────────────────────────────────────────────────
function OutcomeTag({ text, visible = true }) {
  return (
    <div style={{
      position: 'absolute', bottom: 36, left: 36, right: 36, zIndex: 5,
      display: 'flex', alignItems: 'center', gap: 14,
      opacity: visible ? 1 : 0,
      transform: visible ? 'translateY(0)' : 'translateY(12px)',
      transition: 'opacity 900ms ease, transform 900ms ease',
    }}>
      <div style={{
        width: 38, height: 1, background: BRAND.skyLight,
        boxShadow: `0 0 8px ${BRAND.skyLight}`,
      }}/>
      <div style={{
        fontFamily: MONO, fontSize: 11.5, color: BRAND.skyMist,
        letterSpacing: '0.18em', fontWeight: 500, textTransform: 'uppercase',
      }}>What just got handled</div>
      <div style={{
        fontFamily: SF, fontSize: 17, color: BRAND.ink,
        fontWeight: 500, letterSpacing: -0.2, fontStyle: 'italic',
        textShadow: '0 4px 20px rgba(0,0,0,.6)',
      }}>{text}</div>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────────
// SceneImage, full-bleed cinematic still or video.
// If src ends in .mp4/.webm, render <video>; else <img>.
// ─────────────────────────────────────────────────────────────────
function SceneImage({ src, alt, parallax = 0, motionScale = 1, video = null, objectFit = 'cover', objectPosition = '50% 50%' }) {
  const reduced = useReducedMotion();
  const isVideo = !!video || (src && /\.(mp4|webm|mov)$/i.test(src));
  const url = video || src;
  return (
    <div style={{ position: 'absolute', inset: 0, overflow: 'hidden', zIndex: 0 }}>
      {url ? (
        isVideo ? (
          <video src={url} autoPlay loop muted playsInline style={{
            position: 'absolute', inset: 0, width: '100%', height: '100%',
            objectFit, objectPosition,
            transform: `scale(${objectFit === 'cover' ? 1.06 + 0.04 * motionScale : 1}) translateY(${parallax * 0.08 * motionScale}%)`,
            filter: 'brightness(0.86) contrast(1.05) saturate(1.05)',
          }}/>
        ) : (
          <img src={url} alt={alt} style={{
            position: 'absolute', inset: 0, width: '100%', height: '100%',
            objectFit, objectPosition,
            transform: `scale(${objectFit === 'cover' ? 1.06 + 0.04 * motionScale : 1}) translateY(${parallax * 0.08 * motionScale}%)`,
            filter: 'brightness(1.08) contrast(1.04) saturate(1.06)',
          }}/>
        )
      ) : (
        <PlaceholderFrame alt={alt}/>
      )}
      <div style={{ position: 'absolute', inset: 0,
        background: 'linear-gradient(180deg, rgba(5,11,23,.65) 0%, rgba(5,11,23,.10) 30%, rgba(5,11,23,.10) 60%, rgba(5,11,23,.80) 100%)',
        pointerEvents: 'none' }}/>
      <div style={{ position: 'absolute', inset: 0,
        background: 'linear-gradient(90deg, rgba(5,11,23,.55) 0%, rgba(5,11,23,.15) 30%, rgba(5,11,23,.05) 60%, rgba(5,11,23,.45) 100%)',
        pointerEvents: 'none' }}/>
      {!reduced && <FilmGrain/>}
    </div>
  );
}

function FilmGrain() {
  return (
    <div style={{
      position: 'absolute', inset: 0, pointerEvents: 'none',
      mixBlendMode: 'overlay', opacity: 0.06,
      backgroundImage:
        `radial-gradient(circle at 20% 30%, rgba(255,255,255,.4) 0.5px, transparent 1px),
         radial-gradient(circle at 70% 60%, rgba(255,255,255,.3) 0.5px, transparent 1px),
         radial-gradient(circle at 40% 80%, rgba(255,255,255,.35) 0.5px, transparent 1px)`,
      backgroundSize: '3px 3px, 4px 4px, 5px 5px',
    }}/>
  );
}

function PlaceholderFrame({ alt }) {
  return (
    <div style={{
      position: 'absolute', inset: 0,
      background: `linear-gradient(135deg, #0F1B2D 0%, #1A2B47 30%, #0B1424 65%, #050B17 100%)`,
    }}>
      <div style={{
        position: 'absolute', inset: 0, opacity: 0.08,
        backgroundImage: 'repeating-linear-gradient(45deg, transparent 0 16px, rgba(56,189,248,.6) 16px 17px)',
      }}/>
      <div style={{
        position: 'absolute', left: '50%', top: '50%', transform: 'translate(-50%, -50%)',
        fontFamily: MONO, fontSize: 11, color: BRAND.dim,
        letterSpacing: '0.2em', textTransform: 'uppercase', textAlign: 'center',
        padding: '20px 24px', border: '1px dashed rgba(255,255,255,.18)', borderRadius: 4,
        maxWidth: 480, lineHeight: 1.6,
      }}>
        Cinematic still / video<br/>
        <span style={{ color: BRAND.sub, textTransform: 'none', letterSpacing: 'normal', fontSize: 11 }}>
          {alt}
        </span>
      </div>
    </div>
  );
}

function AmbientParticles({ density = 12, intensity = 1 }) {
  const reduced = useReducedMotion();
  if (reduced || intensity <= 0) return null;
  const items = React.useMemo(() => {
    return Array.from({ length: Math.round(density * intensity) }).map((_, i) => ({
      l: Math.random() * 100,
      t: 30 + Math.random() * 60,
      d: 4 + Math.random() * 5,
      delay: Math.random() * 4,
      s: 1 + Math.random() * 2,
    }));
  }, [density, intensity]);
  return (
    <div style={{ position: 'absolute', inset: 0, pointerEvents: 'none', zIndex: 2, overflow: 'hidden' }}>
      {items.map((p, i) => (
        <span key={i} style={{
          position: 'absolute', left: `${p.l}%`, top: `${p.t}%`,
          width: p.s, height: p.s, borderRadius: '50%',
          background: BRAND.skyMist,
          boxShadow: `0 0 ${4 + p.s * 2}px ${BRAND.skyLight}`,
          opacity: 0.35,
          animation: `v3-particles ${p.d}s ease-in-out ${p.delay}s infinite`,
        }}/>
      ))}
    </div>
  );
}

// ─────────────────────────────────────────────────────────────────
// useSpeech, Web Speech API wrapper. Returns {speak, stop, enabled, toggle, ready}.
// `enabled` defaults to FALSE (browser autoplay rules); toggle on user click.
// `ready` flips true when voices are loaded.
// ─────────────────────────────────────────────────────────────────
function useSpeech() {
  const [enabled, setEnabled] = React.useState(false);
  const [ready, setReady]     = React.useState(false);
  const voicesRef = React.useRef({ lantic: null, person: null });
  const synth = typeof window !== 'undefined' ? window.speechSynthesis : null;

  React.useEffect(() => {
    if (!synth) return;
    const pick = () => {
      const list = synth.getVoices();
      if (!list.length) return;
      // Prefer en-GB / en-AU for Lantic (warm), en-US for person.
      const en = list.filter(v => v.lang.startsWith('en'));
      voicesRef.current.lantic = en.find(v => /Daniel|Samantha|Karen|en-GB|Google UK English/i.test(v.name)) ||
                                  en.find(v => v.lang === 'en-GB') ||
                                  en[0];
      voicesRef.current.person = en.find(v => /Alex|Fred|en-US|Google US English Male/i.test(v.name)) ||
                                  en.find(v => v.lang === 'en-US') ||
                                  en[0];
      setReady(true);
    };
    pick();
    synth.addEventListener('voiceschanged', pick);
    return () => synth.removeEventListener('voiceschanged', pick);
  }, [synth]);

  const speak = React.useCallback((text, who = 'L') => {
    if (!synth || !enabled) return;
    synth.cancel();
    const u = new SpeechSynthesisUtterance(text);
    const isLantic = who === 'L' || who === 'L2';
    u.voice = isLantic ? voicesRef.current.lantic : voicesRef.current.person;
    u.rate = isLantic ? 1.05 : 1.0;
    u.pitch = isLantic ? 1.0 : 0.95;
    u.volume = 0.85;
    synth.speak(u);
  }, [synth, enabled]);

  const stop = React.useCallback(() => { if (synth) synth.cancel(); }, [synth]);
  const toggle = React.useCallback(() => {
    setEnabled(v => {
      const next = !v;
      if (!next && synth) synth.cancel();
      return next;
    });
  }, [synth]);

  return { speak, stop, enabled, toggle, ready };
}

// ─────────────────────────────────────────────────────────────────
// FloatingDialogue, places each speaker's text near where they are in the image.
// Lantic line floats near the Cortex orb. Builder/person line floats near their face.
// No boxes, just text with strong shadow for legibility over the cinematic image.
// Auto-loops the conversation continuously.
// ─────────────────────────────────────────────────────────────────
function FloatingDialogue({ scene, start, onActiveSpeakerChange, compact = false, speak, audioEnabled = false }) {
  const lines = scene.dialogue;
  const { lineIdx, wordIdx } = useVoiceTranscript(lines, { start, wordMs: 190, lineGapMs: 850, loop: true, loopGapMs: 2400 });
  const cur = lines[lineIdx];
  const isLantic = cur ? (cur.who === 'L' || cur.who === 'L2') : false;
  const protagonist = scene.protagonist || 'You';

  // Audio playback: if a line has `audio: 'path/to/file.mp3'`, play it when active.
  // Drop MP3s into v3/assets/audio/ and add the path to each line in v3-data.jsx — no other code change required.
  const audioRef = React.useRef(null);
  React.useEffect(() => {
    if (!audioRef.current) audioRef.current = new Audio();
    const el = audioRef.current;
    el.pause();
    if (audioEnabled && cur && cur.audio) {
      el.src = cur.audio;
      el.currentTime = 0;
      el.play().catch(() => { /* user gesture not granted yet */ });
    }
    return () => { try { el.pause(); } catch(e) {} };
  }, [lineIdx, audioEnabled]);

  React.useEffect(() => {
    if (onActiveSpeakerChange) onActiveSpeakerChange(cur?.who || null);
  }, [cur, onActiveSpeakerChange]);

  // (speech disabled, re-enable by uncommenting)
  // React.useEffect(() => {
  //   if (!cur || !speak) return;
  //   speak(cur.text, cur.who);
  // }, [lineIdx, cur, speak]);

  const orbPos = scene.image?.orbPos || { x: 63, y: 56 };
  // On phones/tablets the floating lines are centered with full safe width so they
  // read on one line where possible and never run off the side or get clipped by the
  // next section. Desktop keeps the art-directed positions over the image.
  // On phones/tablets: Lantic speaks low over the table (below the orb), wide and
  // centered. Dave speaks from the empty space to the RIGHT of his head, like the
  // original desktop framing, so the text never lands on him.
  const lanticTextPos = compact
    ? { x: 50, y: 70 }
    : (scene.image?.lanticTextPos || { x: orbPos.x, y: Math.min(orbPos.y + 22, 88) });
  const builderPos = compact
    ? { x: 73, y: 15 }
    : (scene.image?.builderPos || { x: 30, y: 14 });

  if (!start || !cur) return null;
  return (
    <>
      <FloatingLine
        key={`L${lineIdx}`}
        who={cur.who}
        text={cur.text}
        wordIdx={wordIdx}
        x={isLantic ? lanticTextPos.x : builderPos.x}
        y={isLantic ? lanticTextPos.y : builderPos.y}
        align={isLantic ? 'below' : 'below'}
        compact={compact}
        protagonist={protagonist}
      />
    </>
  );
}

// One floating line of speech, no box.
function FloatingLine({ who, text, wordIdx, x, y, align = 'below', compact = false, protagonist = 'You' }) {
  const isLantic = who === 'L' || who === 'L2';
  const speakerName = who === 'L'  ? 'Lantic' :
                      who === 'L2' ? 'Mike\u2019s Lantic' :
                      who === 'P2' ? 'Mike' : protagonist;
  const dotColor = who === 'L2' ? '#A78BFA' : (isLantic ? BRAND.skyLight : '#FBBF24');

  const words = React.useMemo(() => text.split(/(\s+)/), [text]);
  // Lantic gets the full width low over the table; Dave's sits in the narrower
  // empty space beside his head, so it stays off his body.
  const compactW = isLantic ? 'min(92vw, 440px)' : 'min(52vw, 195px)';
  let revealedWordCount = 0;
  const wordSpans = words.map((w, i) => {
    if (w.match(/\s+/)) return <span key={i}>{w}</span>;
    revealedWordCount += 1;
    const shouldShow = revealedWordCount <= wordIdx;
    return <span key={i} style={{ display: shouldShow ? 'inline' : 'none' }}>{w}</span>;
  });

  // Lantic speaks just under the orb and grows downward as the words reveal.
  const lanticBottom = false;
  return (
    <div style={{
      position: 'absolute',
      left: `${x}%`,
      top: lanticBottom ? 'auto' : `${y}%`,
      bottom: lanticBottom ? '13%' : 'auto',
      transform: 'translate(-50%, 0)',
      zIndex: 6, pointerEvents: 'none',
      maxWidth: compact ? compactW : 520,
      width: compact ? compactW : 'auto',
      textAlign: 'center',
    }}>
      {/* small connector dot, suggests the source */}
      <div style={{
        display: 'inline-flex', alignItems: 'center', gap: 8, marginBottom: 8,
      }}>
        <span style={{
          width: 8, height: 8, borderRadius: 5, background: dotColor,
          boxShadow: `0 0 10px ${dotColor}, 0 0 22px ${dotColor}88`,
          animation: 'v3-tick-dot 1.1s ease-in-out infinite',
        }}/>
        <span style={{
          fontFamily: MONO, fontSize: 10, fontWeight: 700,
          letterSpacing: '0.24em', textTransform: 'uppercase',
          color: isLantic ? BRAND.skyMist : '#FDE68A',
          textShadow: '0 0 8px rgba(0,0,0,.9), 0 2px 10px rgba(0,0,0,.6)',
        }}>{speakerName}</span>
        <VoiceBars active={true} color={isLantic ? BRAND.skyLight : '#FBBF24'} size={9}/>
      </div>
      <div style={{
        fontFamily: SF,
        fontSize: compact ? 17 : 21,
        lineHeight: compact ? 1.42 : 1.32,
        fontWeight: isLantic ? 500 : 600,
        color: isLantic ? '#E0F2FE' : BRAND.ink,
        letterSpacing: -0.2,
        textShadow: isLantic
          ? '0 0 20px rgba(8,145,178,.7), 0 2px 18px rgba(0,0,0,.92), 0 0 2px rgba(0,0,0,.95)'
          : '0 2px 18px rgba(0,0,0,.95), 0 0 2px rgba(0,0,0,.95), 0 0 14px rgba(120,53,15,.5)',
        fontStyle: isLantic ? 'italic' : 'normal',
        textWrap: 'pretty',
      }}>
        {wordSpans}
      </div>
    </div>
  );
}

Object.assign(window, {
  CortexOverlay, VoiceBars, VoiceLine, VoiceTranscript, useVoiceTranscript,
  SceneMeta, OutcomeTag, SceneImage, AmbientParticles,
  FloatingDialogue, useSpeech,
});

