// Main METASPEAK app

const { useState, useEffect, useRef, useCallback } = React;

const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
  "char1Name": "Wira",
  "char1Variant": "cobalt",
  "char1Color": "#4ba3c7",
  "char1Style": "warm and knowledgeable",
  "char1Personality": "Speaks first. A confident travel host who knows places intimately and speaks with quiet authority.",
  "char1ElevenVoiceId": "",
  "char1ScaleX": 1, "char1ScaleY": 1, "char1ScaleZ": 1,
  "char1PosX": -1.05, "char1PosY": 0, "char1PosZ": 0.2,
  "char1RotY": 10,

  "char2Name": "Manja",
  "char2Variant": "rose",
  "char2Color": "#d968a6",
  "char2Style": "playful and curious",
  "char2Personality": "Speaks second. The funny sidekick — asks follow-ups, tells small jokes, drops personal anecdotes.",
  "char2ElevenVoiceId": "",
  "char2ScaleX": 1, "char2ScaleY": 1, "char2ScaleZ": 1,
  "char2PosX": 1.05, "char2PosY": 0, "char2PosZ": 0.2,
  "char2RotY": -10,

  "envPreset": "studio",
  "envAccent": "#4ba3c7",
  "envAmbient": 0.35,
  "envKey": 1.4,
  "envFog": 0.4,
  "cameraDistance": 8.8,
  "cameraHeight": 2.4,
  "cameraOrbit": 0,
  "cameraTilt": 0,
  "cameraFov": 34,

  "claudeKey": "",
  "elevenlabsKey": "",
  "lemonfoxKey": "",
  "pixabayKey": "",
  "cloudinaryCloudName": "",
  "cloudinaryApiKey": "",
  "cloudinaryApiSecret": "",
  "cloudinaryFolder": "",
  "useWikimedia": true,

  "voiceRate": 1.0,
  "voicePitch": 1.0,
  "stageGlow": 1.0,
  "lipsyncIntensity": 1.0,
  "bloomStrength": 0.55,
  "vignetteEnabled": true,
  "vignetteDarkness": 0.55,
  "vignetteOffset": 1.1,
  "chromaEnabled": true,
  "chromaOffset": 0.0012,
  "gradeEnabled": true,
  "gradeSaturation": 1.05,
  "gradeContrast": 1.04,
  "gradeBrightness": 0,
  "grainEnabled": true,
  "grainIntensity": 0.025,
  "cinematicEnabled": true,
  "cinematicIntensity": 1.0,
  "cinematicSpeakerDolly": 0.55,
  "cinematicSpeakerZoom": -2.5,
  "cinematicBreathAmp": 0.08,
  "envScene": "",
  "envScale": 1.0,
  "envPosX": 0, "envPosY": 0, "envPosZ": 0,
  "envRotY": 0,
  "envMatRoughness": 1.0, "envMatMetalness": 1.0,
  "envMatEmissive": 1.0,
  "envScreenMesh": "",
  "hideDefaultStage": false,
  "hdriPreset": "",
  "hdriIntensity": 1.0,
  "hdriBackgroundVisible": false,
  "hdriBackgroundBlur": 0.4,
  "aaMode": "fxaa",
  "aoEnabled": false,
  "aoIntensity": 1.0,
  "aoRadius": 0.5,
  "aoQuality": "normal",
  "topicTreeJSON": ""
}/*EDITMODE-END*/;

function App() {
  // Merge localStorage backup into defaults so settings survive reload
  // (avoids the EDITMODE 413 issue when many tweaks are set)
  const [bootDefaults] = useState(() => {
    try {
      const saved = window.PresetUtils?.loadFromLocalStorage?.();
      if (saved && typeof saved === 'object') {
        return { ...TWEAK_DEFAULTS, ...saved };
      }
    } catch {}
    return TWEAK_DEFAULTS;
  });

  // Save tweak changes to localStorage as a backup
  // (intercepts setKey via a wrapper render)
  return (
    <MSPanel defaults={bootDefaults} render={(ctx) => {
      // Mirror to localStorage on every render
      window.PresetUtils?.saveToLocalStorage?.(ctx.values);
      const wrappedSet = (k, v) => {
        ctx.setKey(k, v);
      };
      // In admin mode we don't render the canvas Scene (saves GPU). But the
      // Tweaks panel still calls window.metaspeak.loadGLB / loadEnvironment /
      // loadHDRI, etc. Without those, every upload throws "Scene not ready".
      // The admin mode handlers cache uploads to IndexedDB; the actual
      // chatbot.html (in iframe or standalone) reads from cache on mount.
      if (ctx.panel?.role === 'canvas' && ctx.panel?.mode === 'admin') {
        return <AdminUploadAPI tw={ctx.values} setTw={wrappedSet} />;
      }
      return <Scene tw={ctx.values} setTw={wrappedSet} setMulti={ctx.setMulti} panel={ctx.panel} bootDefaults={bootDefaults} />;
    }} />
  );
}

// AdminUploadAPI — in admin mode, exposes file-upload handlers on
// window.metaspeak that write directly to IndexedDB (the same store the
// chatbot reads from on mount). This decouples admin from the WebGL
// renderer: admin can upload + configure without ever creating a scene,
// and the chatbot picks up the latest cached files when it loads.
//
// The IDB schema mirrors what loadGLBFromBuffer's `cache` block writes,
// what restoreEnvironment reads, and what restoreHDRI reads — so the
// chatbot doesn't need any code change to consume admin-cached uploads.
function AdminUploadAPI({ tw, setTw }) {
  // Initialize to the same shape Scene uses by default so CharacterCard
  // (which reads loaded.source unconditionally) doesn't crash when no
  // model is cached yet.
  const [char1Loaded, setChar1Loaded] = useState({ source: 'procedural', name: '' });
  const [char2Loaded, setChar2Loaded] = useState({ source: 'procedural', name: '' });
  const [envLoaded, setEnvLoaded] = useState(null);
  const [hdriLoaded, setHdriLoaded] = useState(null);
  const [loadingChar, setLoadingChar] = useState(null);

  // Hydrate the loaded-state UI from existing IDB caches on mount, so the
  // Tweaks panel correctly shows "Wira loaded — wira.vrm" after refresh.
  useEffect(() => {
    let cancelled = false;
    (async () => {
      try {
        const [c1, c2, env, hdri] = await Promise.all([
          window.PresetUtils?.loadGLBFromCache?.('char1'),
          window.PresetUtils?.loadGLBFromCache?.('char2'),
          window.PresetUtils?.loadAnimFromCache?.('env', 'main'),
          window.PresetUtils?.loadAnimFromCache?.('hdri', 'main'),
        ]);
        if (cancelled) return;
        if (c1?.name) setChar1Loaded({ source: /\.vrm$/i.test(c1.name) ? 'vrm' : 'glb', name: c1.name });
        if (c2?.name) setChar2Loaded({ source: /\.vrm$/i.test(c2.name) ? 'vrm' : 'glb', name: c2.name });
        if (env?.name) setEnvLoaded({ name: env.name, materials: 0, meshList: [], screenMesh: tw.envScreenMesh || null });
        if (hdri?.name) setHdriLoaded({ name: hdri.name, source: 'upload' });
      } catch (e) { console.warn('Admin cache hydrate failed', e); }
    })();
    return () => { cancelled = true; };
  }, []);

  // Generic IDB cache-only handlers. Same on-disk format as the kiosk's
  // own load functions, just without the WebGL processing step.
  useEffect(() => {
    window.metaspeak = window.metaspeak || {};

    window.metaspeak.loadGLB = async (file, slot) => {
      if (!file) return;
      setLoadingChar(slot);
      try {
        const buf = await file.arrayBuffer();
        if (buf.byteLength > 50 * 1024 * 1024) {
          throw new Error('File too large for browser cache (max 50MB)');
        }
        await window.PresetUtils.saveGLB(slot, file.name, buf);
        const stateUpdate = { source: /\.vrm$/i.test(file.name) ? 'vrm' : 'glb', name: file.name };
        if (slot === 'char1') setChar1Loaded(stateUpdate); else setChar2Loaded(stateUpdate);
      } catch (e) {
        console.error('Admin GLB cache failed', e);
        alert(`Could not save model:\n${e.message}`);
      } finally { setLoadingChar(null); }
    };

    window.metaspeak.loadGLBFromURL = async (url, slot) => {
      if (!url || !url.trim()) return;
      setLoadingChar(slot);
      try {
        const buf = await window.PresetUtils.loadGLBFromURL(url.trim());
        const name = url.split('/').pop().split('?')[0] || 'remote.glb';
        await window.PresetUtils.saveGLB(slot, name, buf);
        const stateUpdate = { source: /\.vrm$/i.test(name) ? 'vrm' : 'glb', name };
        if (slot === 'char1') setChar1Loaded(stateUpdate); else setChar2Loaded(stateUpdate);
      } catch (e) {
        console.error('Admin URL load failed', e);
        alert(`Failed to fetch GLB from URL:\n${e.message}`);
      } finally { setLoadingChar(null); }
    };

    window.metaspeak.clearGLB = (slot) => {
      window.PresetUtils?.clearGLBCache?.(slot).catch(() => {});
      const procedural = { source: 'procedural', name: '' };
      if (slot === 'char1') setChar1Loaded(procedural); else setChar2Loaded(procedural);
    };

    window.metaspeak.loadEnvironment = async (buf, name) => {
      try {
        if (buf.byteLength > 50 * 1024 * 1024) {
          throw new Error('Environment too large for cache (max 50MB)');
        }
        await window.PresetUtils.saveAnim('env', 'main', name, buf);
        // Without a live scene we can't enumerate meshes; the chatbot
        // will populate meshList when it actually loads the GLB.
        setEnvLoaded({ name, materials: 0, meshList: [], screenMesh: tw.envScreenMesh || null });
      } catch (e) {
        console.error('Admin env cache failed', e);
        alert(`Could not save environment:\n${e.message}`);
      }
    };

    window.metaspeak.unloadEnvironment = () => {
      window.PresetUtils?.clearAnimCache?.('env', 'main').catch(() => {});
      setEnvLoaded(null);
    };

    window.metaspeak.setEnvScreenMesh = (meshName) => {
      // Persist via tweak; chatbot will resolve meshName against actual
      // mesh list when it loads the env GLB.
      setTw('envScreenMesh', meshName || '');
      setEnvLoaded(prev => prev ? { ...prev, screenMesh: meshName } : prev);
    };

    window.metaspeak.highlightEnvMesh = () => {
      // No-op in admin mode (no live scene to highlight)
    };

    window.metaspeak.loadHDRIPreset = async (presetId) => {
      const preset = window.MetaspeakHDRI?.HDRI_PRESETS?.[presetId];
      if (!preset) throw new Error('Unknown preset: ' + presetId);
      // Clear any cached upload so the preset takes effect on next chatbot mount
      try { await window.PresetUtils.clearAnimCache('hdri', 'main'); } catch {}
      setTw('hdriPreset', presetId);
      setHdriLoaded({ name: preset.label, source: 'preset', presetId });
    };

    window.metaspeak.loadHDRIFromBuffer = async (buf, name) => {
      try {
        if (buf.byteLength > 30 * 1024 * 1024) {
          throw new Error('HDRI too large for cache (max 30MB)');
        }
        await window.PresetUtils.saveAnim('hdri', 'main', name, buf);
        setTw('hdriPreset', '');
        setHdriLoaded({ name, source: 'upload' });
      } catch (e) {
        console.error('Admin HDRI cache failed', e);
        alert(`Could not save HDRI:\n${e.message}`);
      }
    };

    window.metaspeak.unloadHDRI = () => {
      window.PresetUtils?.clearAnimCache?.('hdri', 'main').catch(() => {});
      setTw('hdriPreset', '');
      setHdriLoaded(null);
    };

    // ── Animation slot handlers (IDB-only, no playback in admin) ──────
    // The actual clip retargeting requires a loaded VRM in a live scene,
    // which doesn't exist in admin. We just cache the FBX/GLB buffer; the
    // chatbot's animation-restore effect will load it when it mounts.
    window.metaspeak.loadAnimation = async (charSlot, animSlot, arrayBuffer, fileName) => {
      if (!arrayBuffer) return;
      if (arrayBuffer.byteLength > 30 * 1024 * 1024) {
        throw new Error('Animation too large for cache (max 30MB)');
      }
      await window.PresetUtils.saveAnim(charSlot, animSlot, fileName, arrayBuffer);
    };

    window.metaspeak.loadAnimationFromURL = async (charSlot, animSlot, url) => {
      if (!url || !url.trim()) throw new Error('No URL provided');
      // Reuse the GLB URL fetcher — it handles CORS rewriting + proxy fallback
      // and just returns an ArrayBuffer regardless of file extension.
      const buf = await window.PresetUtils.loadGLBFromURL(url.trim());
      const name = url.split('/').pop().split('?')[0] || 'remote.fbx';
      await window.PresetUtils.saveAnim(charSlot, animSlot, name, buf);
      return name;
    };

    window.metaspeak.unloadAnimation = (charSlot, animSlot) => {
      window.PresetUtils?.clearAnimCache?.(charSlot, animSlot).catch(() => {});
    };

    // ── Environment URL load (admin-mode helper) ───────────────────────
    // Mirror of loadEnvironment but fetches from a URL first. Used by the
    // CustomEnvironmentEditor's "...or load from URL" field.
    window.metaspeak.loadEnvironmentFromURL = async (url) => {
      if (!url || !url.trim()) throw new Error('No URL provided');
      const buf = await window.PresetUtils.loadGLBFromURL(url.trim());
      const name = url.split('/').pop().split('?')[0] || 'remote-env.glb';
      await window.metaspeak.loadEnvironment(buf, name);
      return name;
    };

    // ── HDRI URL load (admin-mode helper) ──────────────────────────────
    window.metaspeak.loadHDRIFromURL = async (url) => {
      if (!url || !url.trim()) throw new Error('No URL provided');
      const buf = await window.PresetUtils.loadGLBFromURL(url.trim());
      const name = url.split('/').pop().split('?')[0] || 'remote.hdr';
      await window.metaspeak.loadHDRIFromBuffer(buf, name);
      return name;
    };

    window.metaspeak.getLoadedState = () => ({
      char1Loaded, char2Loaded, loadingChar, envLoaded, hdriLoaded,
    });
  }, [char1Loaded, char2Loaded, envLoaded, hdriLoaded, loadingChar, tw.envScreenMesh]);

  // Headless component — renders nothing
  return null;
}

function Scene({ tw, setTw, setMulti, panel }) {
  const twRef = useRef(tw);
  useEffect(() => { twRef.current = tw; }, [tw]);

  const canvasRef = useRef(null);
  const sceneRef = useRef(null);
  const [chat, setChat] = useState([]);
  const chatRef = useRef([]);
  useEffect(() => { chatRef.current = chat; }, [chat]);

  const [menu, setMenu] = useState({ title: '', options: [] });
  const [listening, setListening] = useState(false);
  const [partialTranscript, setPartialTranscript] = useState('');
  const [speaking, setSpeaking] = useState(false);
  const [speakerName, setSpeakerName] = useState(null);
  const [busy, setBusy] = useState(false);
  const [stack, setStack] = useState(['ROOT']);
  const [started, setStarted] = useState(false);
  const [chatOpen, setChatOpen] = useState(true);

  // Per-character file upload (kept in memory only; not persisted)
  const [char1File, setChar1File] = useState(null);
  const [char2File, setChar2File] = useState(null);
  // Loaded character refs (replaces procedural avatar)
  const [char1Loaded, setChar1Loaded] = useState({ source: 'procedural', name: '' });
  const [char2Loaded, setChar2Loaded] = useState({ source: 'procedural', name: '' });
  const [loadingChar, setLoadingChar] = useState(null);
  const [envLoaded, setEnvLoaded] = useState(null);  // { name, materials } or null
  const [hdriLoaded, setHdriLoaded] = useState(null); // { name, source, presetId? }

  const recRef = useRef(null);
  const finalTxtRef = useRef('');

  // Hydrate TopicTree from saved settings (if user customized it)
  useEffect(() => {
    if (!window.TopicTree?.replaceTree) return;
    const savedTreeJSON = tw.topicTreeJSON;
    if (!savedTreeJSON || !savedTreeJSON.trim()) return;
    try {
      const parsed = JSON.parse(savedTreeJSON);
      if (parsed?.nodes?.root) {
        window.TopicTree.replaceTree(parsed);
        console.log('[TopicTree] Hydrated custom tree from saved settings');
      }
    } catch (e) {
      console.warn('[TopicTree] Saved JSON is invalid, using default:', e.message);
    }
  }, []);  // run once on mount

  // ---- init Three.js ----
  useEffect(() => {
    if (!canvasRef.current) return;
    if (sceneRef.current) return;
    const canvas = canvasRef.current;
    let renderer;
    try {
      renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false, preserveDrawingBuffer: true });
    } catch (e) { console.error('WebGL init failed', e); return; }
    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
    renderer.shadowMap.enabled = true;
    renderer.shadowMap.type = THREE.PCFSoftShadowMap;
    renderer.toneMapping = THREE.ACESFilmicToneMapping;
    renderer.toneMappingExposure = 1.05;
    renderer.outputColorSpace = THREE.SRGBColorSpace;

    const scene = new THREE.Scene();
    scene.background = new THREE.Color(0x05060a);
    scene.fog = new THREE.Fog(0x05060a, 9, 24);

    const camera = new THREE.PerspectiveCamera(34, 1, 0.1, 100);

    // Base camera anchor — derived from user's Camera tab settings.
    // The cinematic controller layers procedural motion on top of this.
    const cameraAnchor = {
      position: new THREE.Vector3(0, 2.4, 8.8),
      lookAt: new THREE.Vector3(0, 1.55, 0),
      fov: 34,
    };
    function applyCameraFromTw() {
      const t = twRef.current;
      const dist = t.cameraDistance ?? 8.8;
      const orbit = (t.cameraOrbit ?? 0) * Math.PI / 180;
      const tilt = (t.cameraTilt ?? 0) * Math.PI / 180;
      const x = Math.sin(orbit) * dist;
      const z = Math.cos(orbit) * dist;
      const y = (t.cameraHeight ?? 2.4) + Math.sin(tilt) * dist * 0.4;
      cameraAnchor.position.set(x, y, z);
      cameraAnchor.fov = t.cameraFov ?? 34;
      // If cinematic is disabled, write directly to the camera so the
      // panel sliders remain WYSIWYG. Otherwise let the cinematic
      // controller compose the final transform from this anchor.
      if (!cinematic || !cinematic.enabled || (twRef.current?.cinematicIntensity ?? 1) <= 0) {
        camera.position.copy(cameraAnchor.position);
        camera.lookAt(cameraAnchor.lookAt);
        camera.fov = cameraAnchor.fov;
        camera.updateProjectionMatrix();
      }
    }

    // Cinematic camera controller — declared later but referenced above.
    // Instantiated after stage build so it can read character positions.
    let cinematic = null;

    applyCameraFromTw();
    sceneRef.current && (sceneRef.current.applyCameraFromTw = applyCameraFromTw);

    let stage, lira, kiro;
    try {
      stage = StageFactory.build(scene);
      lira = AvatarFactory.buildAvatar(twRef.current.char1Variant || 'cobalt');
      kiro = AvatarFactory.buildAvatar(twRef.current.char2Variant || 'rose');
      scene.add(lira.group); scene.add(kiro.group);
      applyTransform(lira.group, 'char1');
      applyTransform(kiro.group, 'char2');
      stage.applyEnv({ preset: twRef.current.envPreset });
    } catch (e) { console.error('Scene build error:', e); }

    // User-uploaded environment manager. Knows about `stage` so it can
    // hide default geometry on load and share the screen canvas texture
    // with the user's designated screen mesh.
    let userEnv = null;
    if (window.MetaspeakEnvironment?.EnvironmentManager) {
      userEnv = new window.MetaspeakEnvironment.EnvironmentManager(scene, { stage });
      (async () => {
        try {
          const cached = await window.PresetUtils?.loadAnimFromCache?.('env', 'main');
          if (cached?.buffer) {
            const result = await userEnv.load(cached.buffer, cached.name || 'environment.glb');
            const t = twRef.current;
            userEnv.setTransform({
              posX: t.envPosX ?? 0, posY: t.envPosY ?? 0, posZ: t.envPosZ ?? 0,
              scale: t.envScale ?? 1, rotY: t.envRotY ?? 0,
            });
            userEnv.applyMaterialOverrides({
              roughness: t.envMatRoughness ?? 1,
              metalness: t.envMatMetalness ?? 1,
              emissive: t.envMatEmissive ?? 1,
            });
            // Pick screen mesh: prefer saved choice, fall back to first auto-detected
            const savedMesh = t.envScreenMesh;
            const targetMesh = savedMesh || result.screenCandidates[0];
            if (targetMesh) userEnv.setScreenMesh(targetMesh);
            setEnvLoaded({
              name: cached.name,
              materials: userEnv.materials.length,
              meshList: userEnv.getMeshList(),
              screenMesh: userEnv.screenMesh ? userEnv.meshes.find(m => m.uuid === userEnv.screenMesh.uuid)?.name : null,
              screenCandidates: result.screenCandidates,
            });
            console.log(`[Environment] restored from cache: ${cached.name}`);
          }
        } catch (e) { console.warn('Environment restore failed', e); }
      })();
    }

    // ── HDRI Environment Map (image-based lighting) ────────────────────
    // Provides realistic reflections + ambient color for all PBR materials.
    // Independently loadable from the user-uploaded environment GLB so you
    // can mix any HDRI sky with any 3D venue. Cached to IDB at key 'hdri:main'.
    let hdri = null;
    if (window.MetaspeakHDRI?.HDRIManager) {
      hdri = new window.MetaspeakHDRI.HDRIManager(renderer, scene);
      (async () => {
        try {
          // Try restoring uploaded HDRI from cache
          const cached = await window.PresetUtils?.loadAnimFromCache?.('hdri', 'main');
          if (cached?.buffer) {
            await hdri.loadFromBuffer(cached.buffer, cached.name || 'env.hdr');
            const t = twRef.current;
            hdri.setIntensity(t.hdriIntensity ?? 1);
            hdri.setBackgroundVisible(t.hdriBackgroundVisible !== false);
            hdri.setBackgroundBlur(t.hdriBackgroundBlur ?? 0.4);
            setHdriLoaded({ name: cached.name, source: 'upload' });
            return;
          }
          // No cached upload — apply preset if user picked one
          const presetId = twRef.current?.hdriPreset;
          if (presetId && window.MetaspeakHDRI.HDRI_PRESETS[presetId]) {
            const preset = window.MetaspeakHDRI.HDRI_PRESETS[presetId];
            await hdri.loadFromURL(preset.url, preset.label);
            const t = twRef.current;
            hdri.setIntensity(t.hdriIntensity ?? 1);
            hdri.setBackgroundVisible(t.hdriBackgroundVisible === true);
            hdri.setBackgroundBlur(t.hdriBackgroundBlur ?? 0.4);
            setHdriLoaded({ name: preset.label, source: 'preset', presetId });
          }
        } catch (e) { console.warn('HDRI restore failed', e); }
      })();
    }

    sceneRef.current = { renderer, scene, camera, stage, char1: lira, char2: kiro, userEnv, hdri };
    sceneRef.current.applyCameraFromTw = applyCameraFromTw;

    // ── Bloom post-processing ──────────────────────────────────────────
    // EffectComposer chain: render → bloom → output (sRGB conversion).
    // Bloom strength + threshold + radius are tunable via window.metaspeak.
    // Cheaply: ~0.5ms per frame on integrated GPU, ~0.1ms on dedicated.
    let composer = null;
    let bloomPass = null;
    let vignettePass = null;
    let chromaPass = null;
    let colorGradePass = null;
    let grainPass = null;
    let gtaoPass = null;       // ambient occlusion (opt-in, off by default)
    let fxaaPass = null;       // cheap anti-aliasing
    let smaaPass = null;       // higher-quality anti-aliasing
    if (window.EffectComposer && window.UnrealBloomPass) {
      try {
        composer = new window.EffectComposer(renderer);
        composer.addPass(new window.RenderPass(scene, camera));

        // ── Ambient Occlusion (GTAO) ─────────────────────────────────
        // Ground Truth AO darkens crevices and contact areas for added
        // realism on detailed environment GLBs. Stylized characters
        // benefit less, but custom stage models with detailed geometry
        // (theatre seats, beams, floor textures) get noticeable depth.
        // Disabled by default since it costs 3-5ms on integrated GPUs.
        if (window.GTAOPass) {
          gtaoPass = new window.GTAOPass(scene, camera, window.innerWidth, window.innerHeight);
          gtaoPass.enabled = false;   // toggled via panel
          // Default tuning: subtle, not "dirt smudge" look
          gtaoPass.blendIntensity = 1.0;
          if (gtaoPass.updateGtaoMaterial) {
            gtaoPass.updateGtaoMaterial({
              radius: 0.5,
              distanceExponent: 1.5,
              thickness: 0.3,
              scale: 1.0,
              samples: 16,
              distanceFallOff: 1.0,
            });
          }
          composer.addPass(gtaoPass);
        }

        // ── Bloom ────────────────────────────────────────────────────
        // strength=0.55 is a moderate glow — neon edges, screen pop, but no
        // wash-out. threshold=0.85 means only bright pixels (lights, screen)
        // bloom; clothing and skin stay clean.
        bloomPass = new window.UnrealBloomPass(
          new THREE.Vector2(window.innerWidth, window.innerHeight),
          0.55,   // strength
          0.6,    // radius
          0.85    // threshold (luminance > this gets bloomed)
        );
        composer.addPass(bloomPass);

        // Cinematic post-FX chain — added between bloom and the output
        // pass. Order matters: color grade should come before grain so
        // grain isn't tinted; chromatic aberration before vignette so
        // edges still get darkened.
        const C = window.MetaspeakCinematic;
        if (window.ShaderPass && C) {
          colorGradePass = new window.ShaderPass(C.ColorGradeShader);
          composer.addPass(colorGradePass);

          chromaPass = new window.ShaderPass(C.ChromaticAberrationShader);
          composer.addPass(chromaPass);

          vignettePass = new window.ShaderPass(C.VignetteShader);
          composer.addPass(vignettePass);

          grainPass = new window.ShaderPass(C.FilmGrainShader);
          composer.addPass(grainPass);
        }

        // ── Anti-aliasing (final pass) ───────────────────────────────
        // Both FXAA and SMAA are added; only one is enabled at runtime
        // depending on user choice (Voice/Scene panel toggle). When
        // EffectComposer is in use, native MSAA is bypassed, so a post-AA
        // pass is needed to avoid jaggies on character silhouettes.
        if (window.ShaderPass && window.FXAAShader) {
          fxaaPass = new window.ShaderPass(window.FXAAShader);
          const dpr = renderer.getPixelRatio();
          fxaaPass.material.uniforms.resolution.value.set(
            1 / (window.innerWidth * dpr),
            1 / (window.innerHeight * dpr)
          );
          fxaaPass.enabled = false;   // off by default — chosen at runtime
          composer.addPass(fxaaPass);
        }
        if (window.SMAAPass) {
          const dpr = renderer.getPixelRatio();
          smaaPass = new window.SMAAPass(window.innerWidth * dpr, window.innerHeight * dpr);
          smaaPass.enabled = false;
          composer.addPass(smaaPass);
        }

        if (window.OutputPass) {
          composer.addPass(new window.OutputPass());
        }
      } catch (e) {
        console.warn('Composer setup failed, falling back to plain renderer', e);
        composer = null;
      }
    }
    sceneRef.current.composer = composer;
    sceneRef.current.bloomPass = bloomPass;
    sceneRef.current.vignettePass = vignettePass;
    sceneRef.current.chromaPass = chromaPass;
    sceneRef.current.colorGradePass = colorGradePass;
    sceneRef.current.grainPass = grainPass;
    sceneRef.current.gtaoPass = gtaoPass;
    sceneRef.current.fxaaPass = fxaaPass;
    sceneRef.current.smaaPass = smaaPass;

    function resize() {
      const w = canvas.clientWidth || canvas.parentElement.clientWidth;
      const h = canvas.clientHeight || canvas.parentElement.clientHeight;
      if (!w || !h) return;
      renderer.setSize(w, h, false);
      camera.aspect = w / h; camera.updateProjectionMatrix();
      if (composer) composer.setSize(w, h);
      // FXAA needs explicit resolution uniform update — it samples by
      // pixel offsets calculated from this. Without it, FXAA looks fuzzy
      // or wrong after resize.
      if (fxaaPass) {
        const dpr = renderer.getPixelRatio();
        fxaaPass.material.uniforms.resolution.value.set(1 / (w * dpr), 1 / (h * dpr));
      }
      // GTAO has its own internal render targets keyed off w/h
      if (gtaoPass) gtaoPass.setSize(w, h);
    }
    resize();
    const ro = new ResizeObserver(resize); ro.observe(canvas);
    window.addEventListener('resize', resize);

    // ─── Cinematic camera controller ────────────────────────────────
    // Reads user's panel settings as the "anchor" pose (via cameraAnchor),
    // then layers procedural motion: speaker focus dolly, idle breathing,
    // beat-driven punches, parallax. Disabled by setting intensity=0.
    if (window.MetaspeakCinematic?.CinematicCamera) {
      cinematic = new window.MetaspeakCinematic.CinematicCamera(
        camera,
        () => cameraAnchor
      );
      sceneRef.current.cinematic = cinematic;
    }

    let last = performance.now(), raf;
    function renderFrame() {
      const now = performance.now();
      const dt = Math.min(0.05, (now - last) / 1000);
      last = now;
      try {
        const sc = sceneRef.current;
        if (sc?.char1) sc.char1.update(dt);
        if (sc?.char2) sc.char2.update(dt);
        if (sc?.userEnv) sc.userEnv.update(dt);   // mesh highlight pulse
        if (sc?.stage) {
          sc.stage.updateScreen(dt);
          sc.stage.screenLight.intensity = 0.3 + 0.5 * (twRef.current?.stageGlow ?? 1);
        }

        // Cinematic camera tick — feed in current character world positions
        // so speaker focus and parallax can compute correctly. Use the
        // root mesh position as the speaker reference point.
        if (cinematic) {
          const tw = twRef.current;
          cinematic.setIntensity(tw?.cinematicIntensity ?? 1);
          cinematic.setEnabled(tw?.cinematicEnabled !== false);
          // Read panel-tunable cinematic config
          if (tw) {
            cinematic.cfg.speakerOffsetMul = tw.cinematicSpeakerDolly ?? 0.55;
            cinematic.cfg.speakerFovDelta = tw.cinematicSpeakerZoom ?? -2.5;
            cinematic.cfg.breathAmp.x = (tw.cinematicBreathAmp ?? 0.08);
            cinematic.cfg.breathAmp.y = (tw.cinematicBreathAmp ?? 0.08) * 0.6;
            cinematic.cfg.breathAmp.z = (tw.cinematicBreathAmp ?? 0.08) * 0.5;
          }
          const charPos = {
            char1: sc?.char1?.group?.position,
            char2: sc?.char2?.group?.position,
          };
          cinematic.update(dt, charPos);
        }

        // Apply bloom strength tweak each frame so panel changes are live
        if (sc?.bloomPass && twRef.current?.bloomStrength != null) {
          sc.bloomPass.strength = twRef.current.bloomStrength;
        }
        // Live-tunable post-FX params from the Scene tab
        const tw = twRef.current;
        if (tw && sc?.vignettePass) {
          sc.vignettePass.uniforms.darkness.value = tw.vignetteDarkness ?? 0.7;
          sc.vignettePass.uniforms.offset.value = tw.vignetteOffset ?? 1.1;
          sc.vignettePass.enabled = (tw.vignetteEnabled !== false) && (tw.vignetteDarkness ?? 0.7) > 0;
        }
        if (tw && sc?.chromaPass) {
          sc.chromaPass.uniforms.offset.value = tw.chromaOffset ?? 0.0015;
          sc.chromaPass.enabled = (tw.chromaEnabled !== false) && (tw.chromaOffset ?? 0.0015) > 0;
        }
        if (tw && sc?.colorGradePass) {
          sc.colorGradePass.uniforms.saturation.value = tw.gradeSaturation ?? 1.05;
          sc.colorGradePass.uniforms.contrast.value = tw.gradeContrast ?? 1.04;
          sc.colorGradePass.uniforms.brightness.value = tw.gradeBrightness ?? 0;
          sc.colorGradePass.enabled = tw.gradeEnabled !== false;
        }
        if (tw && sc?.grainPass) {
          sc.grainPass.uniforms.time.value = now / 1000;
          sc.grainPass.uniforms.intensity.value = tw.grainIntensity ?? 0.03;
          sc.grainPass.enabled = (tw.grainEnabled !== false) && (tw.grainIntensity ?? 0.03) > 0;
        }
        // Ambient occlusion (GTAO) — opt-in, costly on low-end GPUs
        if (tw && sc?.gtaoPass) {
          sc.gtaoPass.enabled = !!tw.aoEnabled;
          if (sc.gtaoPass.enabled && sc.gtaoPass.updateGtaoMaterial) {
            // Live-tune AO intensity + radius. blendIntensity multiplies
            // the final AO contribution; radius controls how far each
            // sample searches. Larger radius = bigger soft shadows.
            sc.gtaoPass.blendIntensity = tw.aoIntensity ?? 1.0;
            // updateGtaoMaterial is somewhat expensive; throttle by only
            // calling when we actually need to (cheap dirty-bit pattern)
            const r = tw.aoRadius ?? 0.5;
            if (sc.gtaoPass._lastRadius !== r) {
              sc.gtaoPass.updateGtaoMaterial({
                radius: r,
                distanceExponent: 1.5,
                thickness: 0.3,
                scale: 1.0,
                samples: tw.aoQuality === 'high' ? 32 : 16,
                distanceFallOff: 1.0,
              });
              sc.gtaoPass._lastRadius = r;
            }
          }
        }
        // Anti-aliasing — only one of FXAA/SMAA active at a time
        if (tw && (sc?.fxaaPass || sc?.smaaPass)) {
          const mode = tw.aaMode || 'fxaa';   // 'off' | 'fxaa' | 'smaa'
          if (sc.fxaaPass) sc.fxaaPass.enabled = (mode === 'fxaa');
          if (sc.smaaPass) sc.smaaPass.enabled = (mode === 'smaa');
        }

        if (sc?.composer) {
          sc.composer.render();
        } else {
          renderer.render(scene, camera);
        }
      } catch (e) { console.warn(e); }
    }
    function rafLoop() { renderFrame(); raf = requestAnimationFrame(rafLoop); }
    raf = requestAnimationFrame(rafLoop);
    const intervalId = setInterval(() => {
      if (performance.now() - last > 80) renderFrame();
    }, 33);

    return () => {
      cancelAnimationFrame(raf);
      clearInterval(intervalId);
      ro.disconnect();
      window.removeEventListener('resize', resize);
      userEnv?.unload?.();
      hdri?.dispose?.();
      renderer.dispose();
      sceneRef.current = null;
    };
  }, []);

  // helper: apply per-character transform from current tweaks
  function applyTransform(group, prefix) {
    const t = twRef.current;
    group.position.set(t[prefix + 'PosX'], t[prefix + 'PosY'], t[prefix + 'PosZ']);
    group.scale.set(t[prefix + 'ScaleX'], t[prefix + 'ScaleY'], t[prefix + 'ScaleZ']);
    group.rotation.y = (t[prefix + 'RotY'] || 0) * Math.PI / 180;
  }

  // Re-apply transforms whenever they change
  useEffect(() => {
    const sc = sceneRef.current; if (!sc) return;
    if (sc.char1) applyTransform(sc.char1.group, 'char1');
    if (sc.char2) applyTransform(sc.char2.group, 'char2');
  }, [tw.char1PosX, tw.char1PosY, tw.char1PosZ, tw.char1ScaleX, tw.char1ScaleY, tw.char1ScaleZ, tw.char1RotY,
       tw.char2PosX, tw.char2PosY, tw.char2PosZ, tw.char2ScaleX, tw.char2ScaleY, tw.char2ScaleZ, tw.char2RotY]);

  useEffect(() => {
    const sc = sceneRef.current;
    if (sc?.applyCameraFromTw) sc.applyCameraFromTw();
  }, [tw.cameraDistance, tw.cameraHeight, tw.cameraOrbit, tw.cameraTilt, tw.cameraFov]);

  // Apply env
  useEffect(() => {
    const sc = sceneRef.current; if (!sc?.stage) return;
    sc.stage.applyEnv({ preset: tw.envPreset, accent: tw.envAccent, ambient: tw.envAmbient, keyI: tw.envKey, fogDensity: tw.envFog });
  }, [tw.envPreset, tw.envAccent, tw.envAmbient, tw.envKey, tw.envFog]);

  // Replace avatar variant when changed (procedural only)
  useEffect(() => {
    const sc = sceneRef.current; if (!sc) return;
    if (char1Loaded.source !== 'procedural') return;
    if (!sc.char1) return;
    sc.scene.remove(sc.char1.group);
    sc.char1 = AvatarFactory.buildAvatar(tw.char1Variant || 'cobalt');
    sc.scene.add(sc.char1.group);
    applyTransform(sc.char1.group, 'char1');
  }, [tw.char1Variant]);
  useEffect(() => {
    const sc = sceneRef.current; if (!sc) return;
    if (char2Loaded.source !== 'procedural') return;
    if (!sc.char2) return;
    sc.scene.remove(sc.char2.group);
    sc.char2 = AvatarFactory.buildAvatar(tw.char2Variant || 'rose');
    sc.scene.add(sc.char2.group);
    applyTransform(sc.char2.group, 'char2');
  }, [tw.char2Variant]);

  // Handle GLB / VRM file load (from File or ArrayBuffer).
  // Smart routing: .vrm → VRMCharacterLoader (full VRM features incl. springs,
  // expressions, lookAt, viseme lip-sync). Anything else → original
  // CharacterLoader (Mixamo/RPM glTF path). For .vrm files VRMCharacterLoader
  // also handles VRMUtils.rotateVRM0() so the bear stops facing backwards.
  async function loadGLBFromBuffer(buf, name, slot, { cache = true } = {}) {
    setLoadingChar(slot);
    try {
      if (!window.GLTFLoader) throw new Error('GLTFLoader not ready yet — reload page');
      const isVRM = /\.vrm$/i.test(name || '');
      const Loader = isVRM ? window.VRMCharacterLoader : CharacterLoader;
      if (isVRM && !window.VRMCharacterLoader) {
        throw new Error('VRM loader not ready — reload page');
      }
      const wrapper = await Loader.load({ arrayBuffer: buf });
      const sc = sceneRef.current; if (!sc) throw new Error('Scene not ready');

      if (slot === 'char1') {
        if (sc.char1) sc.scene.remove(sc.char1.group);
        sc.char1 = wrapper;
        sc.scene.add(wrapper.group);
        applyTransform(wrapper.group, 'char1');
        setChar1Loaded({ source: wrapper.isVRM ? 'vrm' : 'glb', name });
      } else {
        if (sc.char2) sc.scene.remove(sc.char2.group);
        sc.char2 = wrapper;
        sc.scene.add(wrapper.group);
        applyTransform(wrapper.group, 'char2');
        setChar2Loaded({ source: wrapper.isVRM ? 'vrm' : 'glb', name });
      }

      // Cache to IndexedDB so it survives reload
      if (cache && PresetUtils && buf.byteLength < 50 * 1024 * 1024) {
        try { await PresetUtils.saveGLB(slot, name, buf); } catch (e) { console.warn('GLB cache save failed', e); }
      }
    } catch (e) {
      console.error('GLB load failed', e);
      alert(`Failed to load ${slot === 'char1' ? 'Character 1' : 'Character 2'}:\n${e.message}\n\nMake sure your file is a valid .vrm / .glb / .gltf and the page has finished loading.`);
    } finally {
      setLoadingChar(null);
    }
  }

  async function loadGLB(file, slot) {
    if (!file) return;
    try {
      const buf = await file.arrayBuffer();
      await loadGLBFromBuffer(buf, file.name, slot);
    } catch (e) {
      console.error(e);
      alert(`Could not read file: ${e.message}`);
      setLoadingChar(null);
    }
  }

  async function loadGLBFromURL(url, slot) {
    if (!url || !url.trim()) return;
    setLoadingChar(slot);
    try {
      const buf = await PresetUtils.loadGLBFromURL(url.trim());
      const name = url.split('/').pop().split('?')[0] || 'remote.glb';
      await loadGLBFromBuffer(buf, name, slot);
    } catch (e) {
      console.error('URL GLB load failed', e);
      alert(`Failed to fetch GLB from URL:\n${e.message}\n\nThe URL must be public and CORS-enabled (e.g. raw GitHub / Dropbox direct links / Cloudflare).`);
      setLoadingChar(null);
    }
  }

  // Restore cached GLBs from IndexedDB on first mount (after scene is ready)
  useEffect(() => {
    if (!sceneRef.current) return;
    let cancelled = false;
    (async () => {
      try {
        const c1 = await PresetUtils.loadGLBFromCache('char1');
        if (!cancelled && c1?.buffer) {
          await loadGLBFromBuffer(c1.buffer, c1.name, 'char1', { cache: false });
        }
        const c2 = await PresetUtils.loadGLBFromCache('char2');
        if (!cancelled && c2?.buffer) {
          await loadGLBFromBuffer(c2.buffer, c2.name, 'char2', { cache: false });
        }
      } catch (e) { console.warn('GLB cache restore failed', e); }
    })();
    return () => { cancelled = true; };
  }, [sceneRef.current]);

  function clearGLB(slot) {
    const sc = sceneRef.current; if (!sc) return;
    PresetUtils.clearGLBCache(slot).catch(() => {});
    if (slot === 'char1') {
      if (sc.char1) sc.scene.remove(sc.char1.group);
      sc.char1 = AvatarFactory.buildAvatar(tw.char1Variant || 'cobalt');
      sc.scene.add(sc.char1.group);
      applyTransform(sc.char1.group, 'char1');
      setChar1File(null);
      setChar1Loaded({ source: 'procedural', name: '' });
    } else {
      if (sc.char2) sc.scene.remove(sc.char2.group);
      sc.char2 = AvatarFactory.buildAvatar(tw.char2Variant || 'rose');
      sc.scene.add(sc.char2.group);
      applyTransform(sc.char2.group, 'char2');
      setChar2File(null);
      setChar2Loaded({ source: 'procedural', name: '' });
    }
  }

  // Expose loaders on window so the panel-context Scene (which has no canvas/scene)
  // can delegate to the main Scene that owns the canvas. Re-bind whenever the
  // panel state or scene ref changes.
  // Load animation clip into a character's slot (legacy 5-slot mode).
  // Auto-caches to IndexedDB so it survives page reload.
  async function loadAnimation(charSlot, animSlot, arrayBuffer, fileName, opts = {}) {
    const sc = sceneRef.current;
    const target = charSlot === 'char1' ? sc?.char1 : sc?.char2;
    if (!target || !target.loadAnimationFromBuffer) {
      throw new Error('Load a .glb model first');
    }
    await target.loadAnimationFromBuffer(arrayBuffer, animSlot, fileName);
    // If this is the idle slot, switch to it now
    if (animSlot === 'idle') target.setState?.('idle');
    // Cache to IndexedDB unless caller opts out (e.g. during restore-from-cache)
    if (opts.cache !== false && window.PresetUtils && arrayBuffer.byteLength < 50 * 1024 * 1024) {
      try {
        await window.PresetUtils.saveAnim(charSlot, animSlot, fileName, arrayBuffer);
      } catch (e) { console.warn('Anim cache save failed', e); }
    }
  }

  // Reset/unload an animation slot — stops the action, removes the clip,
  // and clears the cached copy so a page reload won't restore it.
  function unloadAnimation(charSlot, animSlot) {
    const sc = sceneRef.current;
    const target = charSlot === 'char1' ? sc?.char1 : sc?.char2;
    if (!target?.unloadSlot) return;
    target.unloadSlot(animSlot);
    // Drop from IndexedDB so a refresh doesn't bring it back
    if (window.PresetUtils?.clearAnimCache) {
      window.PresetUtils.clearAnimCache(charSlot, animSlot).catch(() => {});
    }
  }

  // Restore cached animations from IndexedDB after BOTH characters mount
  // (animations need a target VRM/GLB; otherwise loadAnimationFromBuffer fails).
  // We trigger this whenever char1 or char2 successfully loads.
  useEffect(() => {
    if (!sceneRef.current) return;
    const sc = sceneRef.current;
    let cancelled = false;

    async function restoreAnimsForChar(charSlot) {
      const target = charSlot === 'char1' ? sc.char1 : sc.char2;
      if (!target?.loadAnimationFromBuffer) return;
      const slots = ['idle', 'talking', 'listening', 'gestureA', 'gestureB'];
      for (const animSlot of slots) {
        if (cancelled) return;
        try {
          const cached = await window.PresetUtils?.loadAnimFromCache?.(charSlot, animSlot);
          if (!cached?.buffer) continue;
          await loadAnimation(charSlot, animSlot, cached.buffer, cached.name, { cache: false });
          console.log(`[anim restore] ${charSlot}.${animSlot} ← "${cached.name}"`);
        } catch (e) { /* swallow per-slot failures */ }
      }
    }

    if (char1Loaded?.source === 'vrm' || char1Loaded?.source === 'glb') {
      restoreAnimsForChar('char1');
    }
    if (char2Loaded?.source === 'vrm' || char2Loaded?.source === 'glb') {
      restoreAnimsForChar('char2');
    }
    return () => { cancelled = true; };
  }, [char1Loaded?.name, char2Loaded?.name]);

  // ── User Environment (custom .glb world) ───────────────────────────
  async function loadEnvironment(arrayBuffer, fileName) {
    const sc = sceneRef.current;
    if (!sc?.userEnv) throw new Error('Environment manager not ready');
    const result = await sc.userEnv.load(arrayBuffer, fileName);
    const t = twRef.current;
    sc.userEnv.setTransform({
      posX: t.envPosX ?? 0, posY: t.envPosY ?? 0, posZ: t.envPosZ ?? 0,
      scale: t.envScale ?? 1, rotY: t.envRotY ?? 0,
    });
    sc.userEnv.applyMaterialOverrides({
      roughness: t.envMatRoughness ?? 1,
      metalness: t.envMatMetalness ?? 1,
      emissive: t.envMatEmissive ?? 1,
    });
    // Pick screen mesh: prefer saved choice, fall back to first auto-detected.
    // Persist the auto-pick if we made one so refresh keeps the same screen.
    const savedMesh = t.envScreenMesh;
    let chosen = null;
    if (savedMesh && sc.userEnv.setScreenMesh(savedMesh)) chosen = savedMesh;
    else if (result.screenCandidates[0] && sc.userEnv.setScreenMesh(result.screenCandidates[0])) {
      chosen = result.screenCandidates[0];
      setTw('envScreenMesh', chosen);
    }
    // Cache binary to IDB so refresh restores
    if (window.PresetUtils?.saveAnim && arrayBuffer.byteLength < 60 * 1024 * 1024) {
      try { await window.PresetUtils.saveAnim('env', 'main', fileName, arrayBuffer); }
      catch (e) { console.warn('Env cache save failed', e); }
    }
    setEnvLoaded({
      name: fileName,
      materials: sc.userEnv.materials.length,
      meshList: sc.userEnv.getMeshList(),
      screenMesh: chosen,
      screenCandidates: result.screenCandidates,
    });
  }

  function unloadEnvironment() {
    const sc = sceneRef.current;
    sc?.userEnv?.unload();
    if (window.PresetUtils?.clearAnimCache) {
      window.PresetUtils.clearAnimCache('env', 'main').catch(() => {});
    }
    setTw('envScreenMesh', '');
    setEnvLoaded(null);
  }

  // Imperative setter for screen mesh — called from picker UI
  function setEnvScreenMesh(meshName) {
    const sc = sceneRef.current;
    if (!sc?.userEnv) return;
    if (sc.userEnv.setScreenMesh(meshName)) {
      setTw('envScreenMesh', meshName || '');
      setEnvLoaded(prev => prev ? { ...prev, screenMesh: meshName } : prev);
    }
  }

  // Highlight a mesh in the picker (hover preview). null clears.
  function highlightEnvMesh(meshName) {
    sceneRef.current?.userEnv?.highlightMesh(meshName);
  }

  // ── HDRI: load preset / upload / clear ──────────────────────────────
  async function loadHDRIPreset(presetId) {
    const sc = sceneRef.current;
    if (!sc?.hdri) throw new Error('HDRI manager not ready');
    const preset = window.MetaspeakHDRI?.HDRI_PRESETS?.[presetId];
    if (!preset) throw new Error('Unknown preset: ' + presetId);
    await sc.hdri.loadFromURL(preset.url, preset.label);
    const t = twRef.current;
    sc.hdri.setIntensity(t.hdriIntensity ?? 1);
    sc.hdri.setBackgroundVisible(t.hdriBackgroundVisible === true);
    sc.hdri.setBackgroundBlur(t.hdriBackgroundBlur ?? 0.4);
    // Picking a preset clears any previously cached upload
    if (window.PresetUtils?.clearAnimCache) {
      window.PresetUtils.clearAnimCache('hdri', 'main').catch(() => {});
    }
    setTw('hdriPreset', presetId);
    setHdriLoaded({ name: preset.label, source: 'preset', presetId });
  }

  async function loadHDRIFromBuffer(arrayBuffer, fileName) {
    const sc = sceneRef.current;
    if (!sc?.hdri) throw new Error('HDRI manager not ready');
    await sc.hdri.loadFromBuffer(arrayBuffer, fileName);
    const t = twRef.current;
    sc.hdri.setIntensity(t.hdriIntensity ?? 1);
    sc.hdri.setBackgroundVisible(t.hdriBackgroundVisible !== false);
    sc.hdri.setBackgroundBlur(t.hdriBackgroundBlur ?? 0.4);
    // Cache upload
    if (window.PresetUtils?.saveAnim && arrayBuffer.byteLength < 30 * 1024 * 1024) {
      try { await window.PresetUtils.saveAnim('hdri', 'main', fileName, arrayBuffer); }
      catch (e) { console.warn('HDRI cache save failed', e); }
    }
    setTw('hdriPreset', '');   // clear preset since user uploaded custom
    setHdriLoaded({ name: fileName, source: 'upload' });
  }

  function unloadHDRI() {
    const sc = sceneRef.current;
    sc?.hdri?.unload();
    if (window.PresetUtils?.clearAnimCache) {
      window.PresetUtils.clearAnimCache('hdri', 'main').catch(() => {});
    }
    setTw('hdriPreset', '');
    setHdriLoaded(null);
  }

  // Hot-apply HDRI display tweaks
  useEffect(() => {
    const sc = sceneRef.current;
    if (!sc?.hdri?.isLoaded?.()) return;
    sc.hdri.setIntensity(tw.hdriIntensity ?? 1);
    sc.hdri.setBackgroundVisible(tw.hdriBackgroundVisible === true);
    sc.hdri.setBackgroundBlur(tw.hdriBackgroundBlur ?? 0.4);
  }, [tw.hdriIntensity, tw.hdriBackgroundVisible, tw.hdriBackgroundBlur]);

  // Hot-apply transform and material overrides whenever Scene tab sliders change
  useEffect(() => {
    const sc = sceneRef.current;
    if (!sc?.userEnv?.isLoaded?.()) return;
    sc.userEnv.setTransform({
      posX: tw.envPosX ?? 0, posY: tw.envPosY ?? 0, posZ: tw.envPosZ ?? 0,
      scale: tw.envScale ?? 1, rotY: tw.envRotY ?? 0,
    });
  }, [tw.envPosX, tw.envPosY, tw.envPosZ, tw.envScale, tw.envRotY]);

  useEffect(() => {
    const sc = sceneRef.current;
    if (!sc?.userEnv?.isLoaded?.()) return;
    sc.userEnv.applyMaterialOverrides({
      roughness: tw.envMatRoughness ?? 1,
      metalness: tw.envMatMetalness ?? 1,
      emissive: tw.envMatEmissive ?? 1,
    });
  }, [tw.envMatRoughness, tw.envMatMetalness, tw.envMatEmissive]);

  useEffect(() => {
    if (panel && panel.role === 'panel') return; // only the canvas-owning Scene exposes
    window.metaspeak = window.metaspeak || {};
    window.metaspeak.loadGLB = loadGLB;
    window.metaspeak.loadGLBFromURL = loadGLBFromURL;
    window.metaspeak.clearGLB = clearGLB;
    window.metaspeak.loadAnimation = loadAnimation;
    window.metaspeak.unloadAnimation = unloadAnimation;
    // URL-based animation loader: fetch FBX/GLB then route through the
    // normal loadAnimation path so retargeting + caching happen as usual.
    window.metaspeak.loadAnimationFromURL = async (charSlot, animSlot, url) => {
      if (!url || !url.trim()) throw new Error('No URL provided');
      const buf = await PresetUtils.loadGLBFromURL(url.trim());
      const name = url.split('/').pop().split('?')[0] || 'remote.fbx';
      await loadAnimation(charSlot, animSlot, buf, name);
      return name;
    };
    window.metaspeak.loadEnvironment = loadEnvironment;
    window.metaspeak.unloadEnvironment = unloadEnvironment;
    window.metaspeak.loadEnvironmentFromURL = async (url) => {
      if (!url || !url.trim()) throw new Error('No URL provided');
      const buf = await PresetUtils.loadGLBFromURL(url.trim());
      const name = url.split('/').pop().split('?')[0] || 'remote-env.glb';
      await loadEnvironment(buf, name);
      return name;
    };
    window.metaspeak.setEnvScreenMesh = setEnvScreenMesh;
    window.metaspeak.highlightEnvMesh = highlightEnvMesh;
    window.metaspeak.loadHDRIPreset = loadHDRIPreset;
    window.metaspeak.loadHDRIFromBuffer = loadHDRIFromBuffer;
    window.metaspeak.loadHDRIFromURL = async (url) => {
      if (!url || !url.trim()) throw new Error('No URL provided');
      const buf = await PresetUtils.loadGLBFromURL(url.trim());
      const name = url.split('/').pop().split('?')[0] || 'remote.hdr';
      await loadHDRIFromBuffer(buf, name);
      return name;
    };
    window.metaspeak.unloadHDRI = unloadHDRI;
    window.metaspeak.getLoadedState = () => ({
      char1Loaded, char2Loaded, loadingChar, envLoaded, hdriLoaded
    });
  });

  // ---- speech recognition ----
  useEffect(() => {
    const rec = VoiceController.createSTT();
    if (!rec) return;
    rec.onresult = (e) => {
      let interim = '', finalTxt = '';
      for (let i = 0; i < e.results.length; i++) {
        const t = e.results[i][0].transcript;
        if (e.results[i].isFinal) finalTxt += t;
        else interim += t;
      }
      finalTxtRef.current = finalTxt;
      setPartialTranscript(finalTxt + interim);
    };
    rec.onend = () => {
      setListening(false);
      const txt = (finalTxtRef.current || '').trim();
      finalTxtRef.current = '';
      setPartialTranscript('');
      if (txt) handleUserInput(txt);
    };
    rec.onerror = (e) => { console.warn('STT error', e); setListening(false); setPartialTranscript(''); };
    recRef.current = rec;
  }, []);

  useEffect(() => {
    if ('speechSynthesis' in window) {
      window.speechSynthesis.getVoices();
      window.speechSynthesis.onvoiceschanged = () => window.speechSynthesis.getVoices();
    }
  }, []);

  // ---- conversation ----
  async function speakBeat(beat) {
    setBusy(true); setSpeaking(true);
    const sc = sceneRef.current;
    const cur = twRef.current || TWEAK_DEFAULTS;
    const rate = Number.isFinite(cur.voiceRate) ? cur.voiceRate : 1;
    const pitchBase = Number.isFinite(cur.voicePitch) ? cur.voicePitch : 1;
    const lipInt = Number.isFinite(cur.lipsyncIntensity) ? cur.lipsyncIntensity : 1;

    if (beat.image_query) {
      // Look up tree node for image_url override + structured tag candidates
      const treeNode = beat._nodeId && window.TopicTree?.getNode?.(beat._nodeId);

      // Build Cloudinary tag preference: try the node ID first (e.g.
      // "beach_langkawi", "food_nasi_lemak") since users tag photos to
      // match tree taxonomy. Fall back to image_query keywords if no match.
      // Also try parent ID (e.g. "beach", "food") as a broader fallback.
      const preferredTags = [];
      if (beat._nodeId && beat._nodeId !== 'root') {
        preferredTags.push(beat._nodeId);
        // Extract parent: "beach_langkawi" → "beach"
        const parts = beat._nodeId.split('_');
        if (parts.length > 1) preferredTags.push(parts[0]);
        // Also bare destination word: "beach_langkawi" → "langkawi"
        if (parts.length > 1) preferredTags.push(parts.slice(1).join('_'));
      }

      const img = await PixabayClient.search(beat.image_query, {
        imageUrl: treeNode?.image_url,
        preferredTags,
        pixabayKey: cur.pixabayKey,
        cloudinaryCloudName: cur.cloudinaryCloudName,
        cloudinaryApiKey: cur.cloudinaryApiKey,
        cloudinaryApiSecret: cur.cloudinaryApiSecret,
        cloudinaryFolder: cur.cloudinaryFolder,
        useWikimedia: cur.useWikimedia !== false,
      });
      if (img?.url && sc) sc.stage.setScreenImage(img.url, beat.image_query);
      // Telemetry: log which provider served the image (helps tune the
      // search chain — e.g. if Cloudinary is rarely hitting, the user's
      // tags don't match topic IDs)
      window.MetaspeakTelemetry?.log('image_fetched', {
        source: img?.source || 'none',
        topic: beat?._nodeId,
        query: beat.image_query,
      });
    }

    // back-compat: legacy keys
    const c1Text = beat.char1 || beat.lira;
    const c2Text = beat.char2 || beat.kiro;

    setSpeakerName(cur.char1Name);
    setChat(c => [...c, { role: 'char1', text: c1Text, t: Date.now() }]);
    // Cinematic: focus camera on Wira (char1). The previous build added a
    // `punch()` here for "energy" but that produced a visible shake on
    // every switch, which felt earthquake-y rather than cinematic. Punches
    // are now reserved for explicit cuts (start of session, back-to-menu).
    sc?.cinematic?.focusSpeaker('char1', 1);
    if (sc) {
      sc.char2.setLookAt(new THREE.Vector3(cur.char1PosX, 1.85, 0));
      sc.char1.setLookAt(new THREE.Vector3(0, 1.7, 5));
      // Animation state: char1 talking, char2 listening
      sc.char1.setState?.('talking') || sc.char1.triggerGesture?.(Math.random() < 0.5 ? 'gestureA' : 'gestureB');
      sc.char2.setState?.('listening');
    }
    // Telemetry: log voice call (provider + char count for quota tracking)
    window.MetaspeakTelemetry?.log('voice_call', {
      provider: cur.elevenlabsKey ? 'elevenlabs' : 'browser',
      character: 'char1',
      chars: (c1Text || '').length,
    });
    await VoiceController.speak({
      text: c1Text, voiceKey: 'female', rate, pitch: pitchBase * 1.05,
      elevenlabsKey: cur.elevenlabsKey,
      elevenlabsVoiceId: cur.char1ElevenVoiceId,
      onAmplitude: (a) => sc?.char1.setMouthOpen(a * lipInt),
    });
    sc?.char1.setMouthOpen(0);
    await new Promise(r => setTimeout(r, 280));

    setSpeakerName(cur.char2Name);
    setChat(c => [...c, { role: 'char2', text: c2Text, t: Date.now() }]);
    // Cinematic: shift focus to Manja (char2) — smooth dolly, no punch
    sc?.cinematic?.focusSpeaker('char2', 1);
    if (sc) {
      sc.char1.setLookAt(new THREE.Vector3(cur.char2PosX, 1.85, 0));
      sc.char2.setLookAt(new THREE.Vector3(0, 1.7, 5));
      sc.char2.setState?.('talking') || sc.char2.triggerGesture?.(Math.random() < 0.5 ? 'gestureA' : 'gestureB');
      sc.char1.setState?.('listening');
    }
    window.MetaspeakTelemetry?.log('voice_call', {
      provider: cur.elevenlabsKey ? 'elevenlabs' : 'browser',
      character: 'char2',
      chars: (c2Text || '').length,
    });
    await VoiceController.speak({
      text: c2Text, voiceKey: 'male', rate, pitch: pitchBase * 0.92,
      elevenlabsKey: cur.elevenlabsKey,
      elevenlabsVoiceId: cur.char2ElevenVoiceId,
      onAmplitude: (a) => sc?.char2.setMouthOpen(a * lipInt),
    });
    sc?.char2.setMouthOpen(0);

    // End of beat: clear cinematic speaker focus → camera eases back
    // to neutral framing while idle breathing continues.
    sc?.cinematic?.clearSpeaker();
    if (sc) {
      sc.char1.setLookAt(new THREE.Vector3(0, 1.7, 5));
      sc.char2.setLookAt(new THREE.Vector3(0, 1.7, 5));
      sc.char1.setState?.('idle');
      sc.char2.setState?.('idle');
    }

    const opts = [...(beat.menu_options || [])];
    if (beat.include_back) opts.push('← Back to Main Menu');
    setMenu({ title: beat.menu_title, options: opts });
    setSpeaking(false); setSpeakerName(null); setBusy(false);
  }

  // Synchronous lock against rapid double-clicks. The busy state setter is
  // async, so two clicks within the same React commit cycle can both pass
  // the `if (busy) return` check. This ref flips immediately on entry.
  const inputLockRef = useRef(false);

  async function handleUserInput(text) {
    if (busy || inputLockRef.current) return;
    inputLockRef.current = true;
    try {
      const cur = twRef.current;
      const isBack = /back to main|main menu|go back/i.test(text) || text === '← Back to Main Menu';
      setChat(c => [...c, { role: 'user', text, t: Date.now() }]);
      let beat;
      const char1 = { name: cur.char1Name, style: cur.char1Style, personality: cur.char1Personality };
      const char2 = { name: cur.char2Name, style: cur.char2Style, personality: cur.char2Personality };
      try {
        if (isBack) {
          window.MetaspeakTelemetry?.log('topic_visited', { topic: 'root', title: 'Main Menu', input: 'back' });
          setStack(['ROOT']);
          beat = await DialogueManager.rootBeat({ claudeApiKey: cur.claudeKey, char1, char2 });
        } else {
          beat = await DialogueManager.generate({
            userInput: text,
            history: chatRef.current.map(c => ({ role: c.role, text: c.text })),
            currentNode: stack[stack.length - 1],
            claudeApiKey: cur.claudeKey,
            char1, char2,
          });
          // Log the topic node we just navigated to. beat._nodeId is the
          // resolved tree node (e.g. 'beach_langkawi'); fall back to the
          // raw input text if we couldn't match a tree node.
          window.MetaspeakTelemetry?.log('topic_visited', {
            topic: beat?._nodeId || text,
            title: beat?._nodeTitle || text,
            fromNode: stack[stack.length - 1],
            // Was this a tree-matched click or freeform speech?
            kind: beat?._matched ? 'tree' : 'freeform',
          });
          setStack(s => [...s, text]);
        }
        await speakBeat(beat);
      } catch (e) {
        window.MetaspeakTelemetry?.log('error', { where: 'handleUserInput', input: text, message: e.message });
        throw e;
      }
    } finally {
      inputLockRef.current = false;
    }
  }

  async function startListening() {
    if (busy || listening) return;
    if (!started) { startSession(); return; }
    VoiceController.cancel();
    finalTxtRef.current = '';
    setPartialTranscript('');
    const lemonKey = twRef.current?.lemonfoxKey;
    if (lemonKey) {
      const ok = await VoiceController.startMicRecording();
      if (!ok) { alert('Microphone access denied. Enable mic to use voice.'); return; }
      setListening(true);
      setPartialTranscript('Recording…');
      return;
    }
    if (!recRef.current) {
      const txt = prompt('Voice not supported in this browser. Add a Lemonfox key in Tweaks, or type:');
      if (txt) handleUserInput(txt);
      return;
    }
    try { setListening(true); recRef.current.start(); }
    catch (e) { console.warn(e); setListening(false); }
  }

  async function stopListening() {
    if (!listening) return;
    const lemonKey = twRef.current?.lemonfoxKey;
    if (lemonKey) {
      setListening(false);
      setPartialTranscript('Transcribing…');
      const result = await VoiceController.stopMicRecording();
      if (!result || result.durationSec < 0.4) { setPartialTranscript(''); return; }
      const txt = await VoiceController.transcribeLemonfox({ blob: result.blob, apiKey: lemonKey });
      setPartialTranscript('');
      if (txt) handleUserInput(txt);
      else alert('Transcription failed. Check your Lemonfox key.');
      return;
    }
    if (recRef.current) { try { recRef.current.stop(); } catch {} }
  }

  async function startSession() {
    if (started) return;
    setStarted(true);
    window.MetaspeakTelemetry?.startSession({
      hasClaudeKey: !!twRef.current?.claudeKey,
      hasElevenKey: !!twRef.current?.elevenlabsKey,
      hasCloudinary: !!twRef.current?.cloudinaryCloudName,
    });
    const cur = twRef.current;
    const char1 = { name: cur.char1Name, style: cur.char1Style, personality: cur.char1Personality };
    const char2 = { name: cur.char2Name, style: cur.char2Style, personality: cur.char2Personality };
    try {
      const beat = await DialogueManager.rootBeat({ claudeApiKey: cur.claudeKey, char1, char2 });
      await speakBeat(beat);
    } catch (e) {
      window.MetaspeakTelemetry?.log('error', { where: 'startSession', message: e.message });
      throw e;
    }
  }

  // ===== If panel context is present, render the tabbed panel content =====
  // Render PanelContent only when this Scene call is for the panel role.
  // Canvas-role calls fall through to the WebGL UI below.
  if (panel && panel.role === 'panel') {
    // Delegate GLB ops to the canvas-owning Scene via window.metaspeak,
    // and read live loaded state from it.
    const ms = window.metaspeak || {};
    const live = ms.getLoadedState ? ms.getLoadedState() : { char1Loaded, char2Loaded, loadingChar };
    const tweaksContent = <PanelContent
      tw={tw} setTw={setTw} setMulti={setMulti}
      activeTab={panel.activeTab}
      char1File={char1File} setChar1File={setChar1File}
      char2File={char2File} setChar2File={setChar2File}
      char1Loaded={live.char1Loaded} char2Loaded={live.char2Loaded}
      onLoadGLB={(f, slot) => ms.loadGLB ? ms.loadGLB(f, slot) : alert('Scene not ready yet — wait a moment')}
      onClearGLB={(slot) => ms.clearGLB ? ms.clearGLB(slot) : null}
      onLoadGLBFromURL={(url, slot) => ms.loadGLBFromURL ? ms.loadGLBFromURL(url, slot) : alert('Scene not ready yet')}
      loadingChar={live.loadingChar}
      envLoaded={live.envLoaded}
      onLoadEnv={(buf, name) => ms.loadEnvironment ? ms.loadEnvironment(buf, name) : alert('Scene not ready yet')}
      onUnloadEnv={() => ms.unloadEnvironment ? ms.unloadEnvironment() : null}
      onLoadEnvFromURL={(url) => ms.loadEnvironmentFromURL ? ms.loadEnvironmentFromURL(url) : Promise.reject(new Error('Scene not ready'))}
      onSetScreenMesh={(name) => ms.setEnvScreenMesh ? ms.setEnvScreenMesh(name) : null}
      onHighlightMesh={(name) => ms.highlightEnvMesh ? ms.highlightEnvMesh(name) : null}
      hdriLoaded={live.hdriLoaded}
      onLoadHDRIPreset={(id) => ms.loadHDRIPreset ? ms.loadHDRIPreset(id) : alert('Scene not ready')}
      onLoadHDRI={(buf, name) => ms.loadHDRIFromBuffer ? ms.loadHDRIFromBuffer(buf, name) : alert('Scene not ready')}
      onLoadHDRIFromURL={(url) => ms.loadHDRIFromURL ? ms.loadHDRIFromURL(url) : Promise.reject(new Error('Scene not ready'))}
      onUnloadHDRI={() => ms.unloadHDRI ? ms.unloadHDRI() : null}
      onLoadAnimationFromURL={(charSlot, animSlot, url) => ms.loadAnimationFromURL ? ms.loadAnimationFromURL(charSlot, animSlot, url) : Promise.reject(new Error('Scene not ready'))}
    />;

    // In admin mode, render the futuristic dashboard with PanelContent
    // available as one of its tabs. In combo mode, just the panel as before.
    if (panel.mode === 'admin' && window.MetaspeakDashboard?.AdminDashboard) {
      // The Tweaks tab inside the dashboard needs to switch sub-tabs. We
      // render a simplified inline tab-switcher above the PanelContent so
      // the dashboard's "Tweaks" tab becomes a tabbed panel of its own.
      const tabs = [
        { id: 'characters', label: 'Cast' },
        { id: 'behaviour', label: 'Voice' },
        { id: 'environment', label: 'Scene' },
        { id: 'camera', label: 'Camera' },
        { id: 'dialogue', label: 'Dialogue' },
        { id: 'api', label: 'API' },
      ];
      const tweaksWithTabs = (
        <>
          <div style={{
            display:'flex', gap:'4px', marginBottom:'14px',
            borderBottom:'1px solid rgba(120,180,255,0.1)',
            paddingBottom:'10px',
          }}>
            {tabs.map(t => {
              const active = panel.activeTab === t.id;
              return (
                <button key={t.id}
                  onClick={() => panel.setActiveTab?.(t.id)}
                  style={{
                    padding:'6px 14px', fontSize:'11px',
                    background: active ? 'rgba(124,154,255,0.15)' : 'transparent',
                    color: active ? '#a8c0ff' : '#8aa0bd',
                    border:'1px solid ' + (active ? 'rgba(124,154,255,0.4)' : 'transparent'),
                    borderRadius:'3px', cursor:'pointer',
                    fontFamily:'ui-monospace,Menlo,Consolas,monospace',
                    letterSpacing:'1px',
                    textTransform:'uppercase',
                  }}>{t.label}</button>
              );
            })}
          </div>
          {tweaksContent}
        </>
      );
      return <window.MetaspeakDashboard.AdminDashboard tweaksContent={tweaksWithTabs} />;
    }

    return tweaksContent;
  }

  // ===== Main scene UI =====
  return (
    <div className="ms-root">
      <canvas ref={canvasRef} className="ms-canvas"></canvas>
      <div className="ms-vignette"></div>

      <div className="ms-topbar">
        <div className="ms-brand">
          <div className="ms-brand-dot"></div>
          <div className="ms-brand-text">
            <div className="ms-brand-name">METASPEAK</div>
            <div className="ms-brand-sub">Voice-Driven 3D · {tw.char1Name} & {tw.char2Name}</div>
          </div>
        </div>
        <div className="ms-status">
          <div className={`ms-status-dot ${speaking ? 'speaking' : listening ? 'listening' : 'idle'}`}></div>
          <span>{speaking ? `${speakerName} speaking…` : listening ? 'Listening…' : busy ? 'Thinking…' : started ? 'Ready' : 'Idle'}</span>
        </div>
      </div>

      {chatOpen && (
        <div className="ms-chat">
          <div className="ms-chat-header">
            <div className="ms-chat-title">SESSION TRANSCRIPT</div>
            <div className="ms-chat-time">{new Date().toLocaleString('en-US', { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}</div>
            <button className="ms-chat-close" onClick={() => setChatOpen(false)}>×</button>
          </div>
          <div className="ms-chat-body">
            {chat.length === 0 && (
              <div className="ms-chat-empty">Press the mic and start talking,<br/>or pick a topic from the menu below.</div>
            )}
            {chat.map((m, i) => (
              <div key={i} className={`ms-msg ms-msg-${m.role}`}>
                <span className="ms-msg-label">
                  {m.role === 'user' ? 'You' : m.role === 'char1' || m.role === 'lira' ? tw.char1Name : m.role === 'char2' || m.role === 'kiro' ? tw.char2Name : 'System'}
                </span>
                <span className="ms-msg-text">{m.text}</span>
              </div>
            ))}
            {partialTranscript && (
              <div className="ms-msg ms-msg-user ms-msg-partial">
                <span className="ms-msg-label">You</span>
                <span className="ms-msg-text">{partialTranscript}…</span>
              </div>
            )}
          </div>
        </div>
      )}
      {!chatOpen && (
        <button className="ms-chat-toggle" onClick={() => setChatOpen(true)}>
          <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
        </button>
      )}

      {!started && (
        <div className="ms-welcome">
          <div className="ms-welcome-card">
            <div className="ms-welcome-eyebrow">A WEB-NATIVE 3D AVATAR EXPERIENCE</div>
            <h1>Talk to {tw.char1Name} & {tw.char2Name}.</h1>
            <p>Two AI hosts. Real-time voice. A 3D screen that fetches imagery from whatever you say. Push the mic, ask anything — they'll show you.</p>
            <button className="ms-welcome-cta" onClick={startSession}>
              <svg width="16" height="16" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3" fill="currentColor"/></svg>
              Begin Session
            </button>
            <div className="ms-welcome-hint">Hold the mic and speak. Release when done.</div>
          </div>
        </div>
      )}

      {menu.options.length > 0 && started && !speaking && (
        <div className="ms-menu">
          <div className="ms-menu-title">{menu.title}</div>
          <div className="ms-menu-options">
            {menu.options.map((opt, i) => (
              <button key={opt + i}
                className={`ms-menu-opt ${opt.startsWith('←') ? 'ms-menu-back' : ''}`}
                disabled={busy}
                onClick={() => handleUserInput(opt)}
                style={{ animationDelay: `${i * 60}ms` }}>
                {opt}
              </button>
            ))}
          </div>
        </div>
      )}

      <div className="ms-mic-wrap">
        <button className={`ms-mic ${listening ? 'recording' : ''} ${busy && !listening ? 'disabled' : ''}`}
          onMouseDown={startListening}
          onMouseUp={stopListening}
          onTouchStart={(e) => { e.preventDefault(); startListening(); }}
          onTouchEnd={(e) => { e.preventDefault(); stopListening(); }}
          disabled={busy && !listening}>
          <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2">
            <rect x="9" y="2" width="6" height="13" rx="3" fill="currentColor" stroke="none"/>
            <path d="M5 11a7 7 0 0 0 14 0M12 18v3M8 21h8"/>
          </svg>
          {listening && <span className="ms-mic-pulse"></span>}
        </button>
        <div className="ms-mic-label">{listening ? 'RELEASE TO SEND' : busy ? 'PLEASE WAIT' : 'PUSH TO TALK'}</div>
      </div>
    </div>
  );
}

// ============== CUSTOM ENVIRONMENT EDITOR ==============

// Upload + preview + transform + material editor for user-uploaded
// .glb scenes. Replaces (visually augments — coexists with) the
// default stage with whatever world the user provides.
function CustomEnvironmentEditor({ tw, setTw, envLoaded, onLoadEnv, onUnloadEnv, onSetScreenMesh, onHighlightMesh, onLoadEnvFromURL }) {
  const [busy, setBusy] = React.useState(false);
  const [error, setError] = React.useState(null);
  const [pickerOpen, setPickerOpen] = React.useState(false);
  const [urlInput, setUrlInput] = React.useState('');
  const inputRef = React.useRef(null);

  const onFile = async (file) => {
    if (!file) return;
    setError(null);
    setBusy(true);
    try {
      const buf = await file.arrayBuffer();
      // If replacing existing env, confirm first
      if (envLoaded && !confirm('Replace the current environment? Old one will be cleared.')) {
        setBusy(false);
        return;
      }
      await onLoadEnv?.(buf, file.name);
    } catch (e) {
      console.error('Env load failed', e);
      setError(e.message || 'Load failed');
    } finally {
      setBusy(false);
    }
  };

  const onUnload = () => {
    if (!confirm('Remove the loaded environment? You can re-upload it anytime.')) return;
    onUnloadEnv?.();
  };

  // Cleanup highlight when picker closes
  React.useEffect(() => {
    if (!pickerOpen) onHighlightMesh?.(null);
  }, [pickerOpen]);

  return (
    <>
      <div className="mst-help" style={{marginTop:0,marginBottom:'10px',lineHeight:1.5}}>
        Upload a <code>.glb</code> / <code>.gltf</code> scene as your custom backdrop —
        replaces the default stage cleanly. Designate any mesh inside as the "LED screen"
        and topic-specific images render on it. Position and material sliders apply live.
        Survives page reload via local cache.
      </div>

      <div style={{
        padding: '10px',
        border: '1px dashed rgba(120,180,255,0.3)',
        borderRadius: '6px',
        background: envLoaded ? 'rgba(120,255,160,0.06)' : 'rgba(120,180,255,0.04)',
        marginBottom: '10px',
      }}>
        {envLoaded ? (
          <>
            <div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:'6px'}}>
              <div style={{color:'#9be0a8',fontSize:'12px'}}>
                ✓ <b>{envLoaded.name}</b>
              </div>
              <div style={{color:'#9bb0c8',fontSize:'10.5px'}}>
                {envLoaded.materials} material{envLoaded.materials === 1 ? '' : 's'}
                {envLoaded.meshList && ` · ${envLoaded.meshList.length} meshes`}
              </div>
            </div>
            <div style={{display:'flex',gap:'8px'}}>
              <button onClick={() => inputRef.current?.click()} disabled={busy} style={{
                flex:1,padding:'6px 10px',fontSize:'11px',
                background:'rgba(120,180,255,0.1)',color:'#cfe5ff',
                border:'1px solid rgba(120,180,255,0.3)',borderRadius:'4px',cursor:'pointer',
              }}>
                Replace
              </button>
              <button onClick={onUnload} style={{
                flex:1,padding:'6px 10px',fontSize:'11px',
                background:'rgba(255,120,120,0.1)',color:'#ffb0b0',
                border:'1px solid rgba(255,120,120,0.3)',borderRadius:'4px',cursor:'pointer',
              }}>
                Remove
              </button>
            </div>
          </>
        ) : (
          <button onClick={() => inputRef.current?.click()} disabled={busy} style={{
            width:'100%',padding:'14px',fontSize:'12px',
            background:'rgba(120,180,255,0.08)',color:'#cfe5ff',
            border:'1px solid rgba(120,180,255,0.3)',borderRadius:'4px',cursor:'pointer',
          }}>
            {busy ? 'Loading…' : '+ Upload .glb / .gltf'}
          </button>
        )}
        <input
          ref={inputRef} type="file"
          accept=".glb,.gltf,model/gltf-binary,model/gltf+json"
          style={{display:'none'}}
          onChange={(e) => onFile(e.target.files?.[0])}
        />
        {error && (
          <div style={{marginTop:'6px',color:'#ff9090',fontSize:'10.5px'}}>
            ✗ {error}
          </div>
        )}
      </div>

      {/* URL load — for self-hosted GLBs (Cloudflare R2, GitHub raw, etc.)
          Same flow as file upload, just fetches first. */}
      <div style={{marginBottom:'12px'}}>
        <div style={{fontSize:'10.5px',color:'#9bb0c8',marginBottom:'4px'}}>
          …or load from URL
        </div>
        <div style={{display:'flex',gap:'6px'}}>
          <input
            type="url"
            value={urlInput}
            placeholder="https://.../stage.glb"
            onChange={(e) => setUrlInput(e.target.value)}
            disabled={busy}
            style={{
              flex:1,
              background:'rgba(255,255,255,0.04)',
              border:'1px solid rgba(255,255,255,0.08)',
              borderRadius:'6px',
              padding:'7px 9px',
              color:'#e8eef5',
              fontSize:'12px',
              outline:'none',
            }}
          />
          <button
            type="button"
            disabled={busy || !urlInput.trim() || !onLoadEnvFromURL}
            onClick={async () => {
              if (!urlInput.trim()) return;
              if (envLoaded && !confirm('Replace the current environment? Old one will be cleared.')) return;
              setBusy(true);
              setError(null);
              try {
                await onLoadEnvFromURL(urlInput.trim());
              } catch (e) {
                console.error('Env URL load failed', e);
                setError(e.message || 'URL load failed');
              } finally {
                setBusy(false);
              }
            }}
            style={{
              background:'rgba(120,180,255,0.2)',
              border:'1px solid rgba(120,180,255,0.4)',
              borderRadius:'6px',
              padding:'7px 12px',
              color:'#cfe5ff',
              fontSize:'11px',
              fontWeight:600,
              cursor: busy ? 'wait' : 'pointer',
              whiteSpace:'nowrap',
              opacity: (busy || !urlInput.trim()) ? 0.5 : 1,
            }}
          >
            {busy ? '…' : 'Load'}
          </button>
        </div>
        <div className="mst-help" style={{marginTop:'4px',fontSize:'10px'}}>
          Public URL only. Try GitHub raw, Cloudflare R2, or any CORS-enabled host.
        </div>
      </div>

      {envLoaded && (
        <>
          {/* ─── LED Screen Mesh Picker ─────────────────────────── */}
          <div style={{
            fontSize:'10.5px',color:'#9bb0c8',
            textTransform:'uppercase',letterSpacing:'0.5px',
            marginTop:'8px',marginBottom:'6px',
          }}>LED Screen Mesh</div>
          <div className="mst-help" style={{marginTop:0,marginBottom:'8px',lineHeight:1.5,fontSize:'10.5px'}}>
            Choose which mesh inside your GLB displays the kiosk's topic images.
            Hover over an option to see it pulse blue in the 3D scene.
            {envLoaded.screenCandidates?.length > 0 && (
              <> Auto-detected: <b>{envLoaded.screenCandidates.join(', ')}</b></>
            )}
          </div>
          <div style={{
            padding:'8px 10px',
            background:'rgba(120,180,255,0.05)',
            border:'1px solid rgba(120,180,255,0.2)',
            borderRadius:'4px',
            marginBottom:'8px',
            fontSize:'11px',
            display:'flex',justifyContent:'space-between',alignItems:'center',
          }}>
            <span style={{color:'#cfe5ff'}}>
              {envLoaded.screenMesh ? (
                <>📺 <b>{envLoaded.screenMesh}</b></>
              ) : (
                <span style={{color:'#9bb0c8'}}>(no screen assigned)</span>
              )}
            </span>
            <button onClick={() => setPickerOpen(!pickerOpen)} style={{
              padding:'4px 10px',fontSize:'10.5px',
              background:'rgba(120,180,255,0.15)',color:'#cfe5ff',
              border:'1px solid rgba(120,180,255,0.3)',borderRadius:'3px',cursor:'pointer',
            }}>
              {pickerOpen ? '✕ Close' : 'Pick mesh'}
            </button>
          </div>

          {pickerOpen && envLoaded.meshList && (
            <div style={{
              maxHeight:'240px',overflowY:'auto',
              border:'1px solid rgba(120,180,255,0.2)',
              borderRadius:'4px',marginBottom:'10px',
              background:'rgba(8,12,20,0.5)',
            }}>
              {envLoaded.meshList.length === 0 && (
                <div style={{padding:'12px',color:'#9bb0c8',fontSize:'10.5px',textAlign:'center'}}>
                  No meshes in this GLB.
                </div>
              )}
              {envLoaded.screenMesh && (
                <div
                  onClick={() => { onSetScreenMesh?.(null); setPickerOpen(false); }}
                  style={{
                    padding:'8px 10px',cursor:'pointer',fontSize:'11px',
                    color:'#ffb0b0',
                    borderBottom:'1px solid rgba(120,180,255,0.1)',
                  }}
                >
                  ✕ Clear screen designation
                </div>
              )}
              {envLoaded.meshList.map((m) => (
                <div
                  key={m.name}
                  onMouseEnter={() => onHighlightMesh?.(m.name)}
                  onMouseLeave={() => onHighlightMesh?.(null)}
                  onClick={() => {
                    onSetScreenMesh?.(m.name);
                    setPickerOpen(false);
                  }}
                  style={{
                    padding:'7px 10px',cursor:'pointer',fontSize:'11px',
                    background: envLoaded.screenMesh === m.name
                      ? 'rgba(120,255,160,0.12)' : 'transparent',
                    color: envLoaded.screenMesh === m.name ? '#9be0a8' : '#cfe5ff',
                    borderBottom:'1px solid rgba(120,180,255,0.05)',
                    display:'flex',justifyContent:'space-between',alignItems:'center',
                  }}
                >
                  <span>
                    {envLoaded.screenMesh === m.name ? '✓ ' : ''}
                    {m.name}
                  </span>
                  {m.isScreenCandidate && (
                    <span style={{fontSize:'9px',color:'#ffd970'}}>⭐ candidate</span>
                  )}
                </div>
              ))}
            </div>
          )}

          {/* ─── Transform sliders ──────────────────────────────── */}
          <div style={{
            fontSize:'10.5px',color:'#9bb0c8',
            textTransform:'uppercase',letterSpacing:'0.5px',
            marginTop:'12px',marginBottom:'6px',
          }}>Environment Transform</div>
          <MSSlider label="Scale" value={tw.envScale ?? 1} min={0.05} max={10} step={0.05} unit="×" onChange={(v) => setTw('envScale', v)} />
          <MSSlider label="Position X (left/right)" value={tw.envPosX ?? 0} min={-10} max={10} step={0.1} unit="m" onChange={(v) => setTw('envPosX', v)} />
          <MSSlider label="Position Y (up/down)" value={tw.envPosY ?? 0} min={-5} max={5} step={0.05} unit="m" onChange={(v) => setTw('envPosY', v)} />
          <MSSlider label="Position Z (front/back)" value={tw.envPosZ ?? 0} min={-15} max={15} step={0.1} unit="m" onChange={(v) => setTw('envPosZ', v)} />
          <MSSlider label="Rotation Y" value={tw.envRotY ?? 0} min={-180} max={180} step={5} unit="°" onChange={(v) => setTw('envRotY', v)} />

          {/* ─── Avatar floor alignment helpers ─────────────────── */}
          <div style={{
            fontSize:'10.5px',color:'#9bb0c8',
            textTransform:'uppercase',letterSpacing:'0.5px',
            marginTop:'12px',marginBottom:'6px',
          }}>Avatar Floor Alignment</div>
          <div className="mst-help" style={{marginTop:0,marginBottom:'8px',lineHeight:1.5,fontSize:'10.5px'}}>
            If avatars float above or sink into your stage, raise/lower each character
            individually to sit on the surface. These mirror the Cast tab's Y settings.
          </div>
          <MSSlider label="Wira Y" value={tw.char1PosY ?? 0} min={-3} max={3} step={0.02} unit="m" onChange={(v) => setTw('char1PosY', v)} />
          <MSSlider label="Manja Y" value={tw.char2PosY ?? 0} min={-3} max={3} step={0.02} unit="m" onChange={(v) => setTw('char2PosY', v)} />

          {/* ─── Material multipliers ───────────────────────────── */}
          <div style={{
            fontSize:'10.5px',color:'#9bb0c8',
            textTransform:'uppercase',letterSpacing:'0.5px',
            marginTop:'12px',marginBottom:'6px',
          }}>Materials (multipliers on originals)</div>
          <MSSlider label="Roughness" value={tw.envMatRoughness ?? 1} min={0} max={2} step={0.05} unit="×" onChange={(v) => setTw('envMatRoughness', v)} />
          <MSSlider label="Metalness" value={tw.envMatMetalness ?? 1} min={0} max={3} step={0.05} unit="×" onChange={(v) => setTw('envMatMetalness', v)} />
          <MSSlider label="Emissive intensity" value={tw.envMatEmissive ?? 1} min={0} max={5} step={0.05} unit="×" onChange={(v) => setTw('envMatEmissive', v)} />
        </>
      )}
    </>
  );
}

// ============== HDRI EDITOR ==============

// HDRI = environment map for image-based lighting. Provides realistic
// reflections + ambient color from a captured 360° photo. Choose from
// built-in CC0 presets or upload your own .hdr file.
function HDRIEditor({ tw, setTw, hdriLoaded, onLoadPreset, onLoadHDRI, onLoadHDRIFromURL, onUnloadHDRI }) {
  const [busy, setBusy] = React.useState(false);
  const [error, setError] = React.useState(null);
  const [urlInput, setUrlInput] = React.useState('');
  const inputRef = React.useRef(null);

  const presets = window.MetaspeakHDRI?.HDRI_PRESETS || {};
  const presetIds = Object.keys(presets);

  const onFile = async (file) => {
    if (!file) return;
    setError(null);
    setBusy(true);
    try {
      const buf = await file.arrayBuffer();
      await onLoadHDRI?.(buf, file.name);
    } catch (e) {
      console.error('HDRI load failed', e);
      setError(e.message || 'Load failed');
    } finally {
      setBusy(false);
    }
  };

  const onPickPreset = async (id) => {
    setError(null);
    setBusy(true);
    try {
      await onLoadPreset?.(id);
    } catch (e) {
      console.error('Preset load failed', e);
      setError(e.message || 'Load failed');
    } finally {
      setBusy(false);
    }
  };

  const onClear = () => {
    if (!confirm('Remove HDRI lighting?')) return;
    onUnloadHDRI?.();
  };

  return (
    <>
      <div className="mst-help" style={{marginTop:0,marginBottom:'10px',lineHeight:1.5}}>
        HDRI provides realistic reflections and ambient color for all materials.
        Pick a preset or upload your own <code>.hdr</code> file. Optionally show
        the sky as the scene background.
      </div>

      {/* Status row */}
      <div style={{
        padding:'8px 10px',
        background: hdriLoaded ? 'rgba(120,255,160,0.06)' : 'rgba(120,180,255,0.04)',
        border:'1px solid ' + (hdriLoaded ? 'rgba(120,255,160,0.2)' : 'rgba(120,180,255,0.15)'),
        borderRadius:'4px',
        marginBottom:'10px',
        fontSize:'11px',
        display:'flex',justifyContent:'space-between',alignItems:'center',
      }}>
        <span style={{color: hdriLoaded ? '#9be0a8' : '#9bb0c8'}}>
          {hdriLoaded ? (
            <>🌅 <b>{hdriLoaded.name}</b>
              <span style={{color:'#9bb0c8',fontSize:'9.5px',marginLeft:'6px'}}>
                ({hdriLoaded.source})
              </span>
            </>
          ) : (
            <>(no HDRI loaded — using default lighting)</>
          )}
        </span>
        {hdriLoaded && (
          <button onClick={onClear} style={{
            padding:'3px 8px',fontSize:'10px',
            background:'rgba(255,120,120,0.1)',color:'#ffb0b0',
            border:'1px solid rgba(255,120,120,0.3)',borderRadius:'3px',cursor:'pointer',
          }}>
            Clear
          </button>
        )}
      </div>

      {/* Preset grid */}
      <div style={{
        fontSize:'10.5px',color:'#9bb0c8',
        textTransform:'uppercase',letterSpacing:'0.5px',
        marginBottom:'6px',
      }}>Presets</div>
      <div style={{
        display:'grid', gridTemplateColumns:'1fr 1fr', gap:'6px',
        marginBottom:'10px',
      }}>
        {presetIds.map(id => {
          const p = presets[id];
          const active = hdriLoaded?.presetId === id;
          return (
            <button
              key={id}
              onClick={() => onPickPreset(id)}
              disabled={busy}
              title={p.desc}
              style={{
                padding:'8px',fontSize:'10.5px',
                background: active ? 'rgba(120,255,160,0.12)' : 'rgba(120,180,255,0.05)',
                color: active ? '#9be0a8' : '#cfe5ff',
                border:'1px solid ' + (active ? 'rgba(120,255,160,0.4)' : 'rgba(120,180,255,0.2)'),
                borderRadius:'4px',cursor:'pointer',
                textAlign:'left',
              }}
            >
              {active ? '✓ ' : ''}{p.label}
            </button>
          );
        })}
      </div>

      {/* Custom upload */}
      <div style={{
        fontSize:'10.5px',color:'#9bb0c8',
        textTransform:'uppercase',letterSpacing:'0.5px',
        marginTop:'10px',marginBottom:'6px',
      }}>Custom Upload</div>
      <button onClick={() => inputRef.current?.click()} disabled={busy} style={{
        width:'100%',padding:'8px',fontSize:'11px',
        background:'rgba(120,180,255,0.08)',color:'#cfe5ff',
        border:'1px dashed rgba(120,180,255,0.3)',borderRadius:'4px',cursor:'pointer',
        marginBottom:'10px',
      }}>
        {busy ? 'Loading…' : '+ Upload .hdr file'}
      </button>
      <input
        ref={inputRef} type="file"
        accept=".hdr,image/vnd.radiance"
        style={{display:'none'}}
        onChange={(e) => onFile(e.target.files?.[0])}
      />
      {error && (
        <div style={{marginTop:'-4px',marginBottom:'8px',color:'#ff9090',fontSize:'10.5px'}}>
          ✗ {error}
        </div>
      )}

      {/* URL load — for self-hosted HDRIs (Cloudflare R2, Poly Haven, etc.)
          Fetches the .hdr file then routes it through the same loader as
          the file picker. CORS-aware (proxy fallback for blocked hosts). */}
      <div style={{marginBottom:'10px'}}>
        <div style={{fontSize:'10.5px',color:'#9bb0c8',marginBottom:'4px'}}>
          …or load from URL
        </div>
        <div style={{display:'flex',gap:'6px'}}>
          <input
            type="url"
            value={urlInput}
            placeholder="https://.../environment.hdr"
            onChange={(e) => setUrlInput(e.target.value)}
            disabled={busy}
            style={{
              flex:1,
              background:'rgba(255,255,255,0.04)',
              border:'1px solid rgba(255,255,255,0.08)',
              borderRadius:'6px',
              padding:'7px 9px',
              color:'#e8eef5',
              fontSize:'12px',
              outline:'none',
            }}
          />
          <button
            type="button"
            disabled={busy || !urlInput.trim() || !onLoadHDRIFromURL}
            onClick={async () => {
              if (!urlInput.trim()) return;
              setBusy(true);
              setError(null);
              try {
                await onLoadHDRIFromURL(urlInput.trim());
              } catch (e) {
                console.error('HDRI URL load failed', e);
                setError(e.message || 'URL load failed');
              } finally {
                setBusy(false);
              }
            }}
            style={{
              background:'rgba(120,180,255,0.2)',
              border:'1px solid rgba(120,180,255,0.4)',
              borderRadius:'6px',
              padding:'7px 12px',
              color:'#cfe5ff',
              fontSize:'11px',
              fontWeight:600,
              cursor: busy ? 'wait' : 'pointer',
              whiteSpace:'nowrap',
              opacity: (busy || !urlInput.trim()) ? 0.5 : 1,
            }}
          >
            {busy ? '…' : 'Load'}
          </button>
        </div>
      </div>

      <div className="mst-help" style={{lineHeight:1.5,fontSize:'10px',marginBottom:'10px'}}>
        Free HDRIs at <code>polyhaven.com/hdris</code>. Download the 1k or 2k version
        for good quality + small file size.
      </div>

      {/* Display tuning sliders (only useful if loaded) */}
      {hdriLoaded && (
        <>
          <div style={{
            fontSize:'10.5px',color:'#9bb0c8',
            textTransform:'uppercase',letterSpacing:'0.5px',
            marginTop:'12px',marginBottom:'6px',
          }}>Display</div>
          <MSSlider label="Lighting intensity" value={tw.hdriIntensity ?? 1} min={0} max={3} step={0.05} unit="×" onChange={(v) => setTw('hdriIntensity', v)} />
          <MSToggle label="Show as background sky"
            value={tw.hdriBackgroundVisible === true}
            onChange={(v) => setTw('hdriBackgroundVisible', v)}
            hint={<>If off, HDRI is invisible but still lights the scene.</>} />
          {tw.hdriBackgroundVisible && (
            <MSSlider label="Background blur" value={tw.hdriBackgroundBlur ?? 0.4} min={0} max={1} step={0.02} onChange={(v) => setTw('hdriBackgroundBlur', v)} />
          )}
        </>
      )}
    </>
  );
}

// ============== PANEL CONTENT (tabbed) ==============

function PanelContent({ tw, setTw, setMulti, activeTab, char1File, setChar1File, char2File, setChar2File,
                        char1Loaded, char2Loaded, onLoadGLB, onClearGLB, onLoadGLBFromURL, loadingChar,
                        envLoaded, onLoadEnv, onUnloadEnv, onLoadEnvFromURL, onSetScreenMesh, onHighlightMesh,
                        hdriLoaded, onLoadHDRIPreset, onLoadHDRI, onLoadHDRIFromURL, onUnloadHDRI,
                        onLoadAnimationFromURL }) {
  if (activeTab === 'characters') {
    return (
      <>
        <CharacterCard
          slot="char1" idx={1}
          tw={tw} setTw={setTw}
          file={char1File} setFile={setChar1File}
          loaded={char1Loaded}
          onLoadGLB={onLoadGLB} onClearGLB={onClearGLB} onLoadGLBFromURL={onLoadGLBFromURL}
          onLoadAnimationFromURL={onLoadAnimationFromURL}
          loading={loadingChar === 'char1'}
        />
        <CharacterCard
          slot="char2" idx={2}
          tw={tw} setTw={setTw}
          file={char2File} setFile={setChar2File}
          loaded={char2Loaded}
          onLoadGLB={onLoadGLB} onClearGLB={onClearGLB} onLoadGLBFromURL={onLoadGLBFromURL}
          onLoadAnimationFromURL={onLoadAnimationFromURL}
          loading={loadingChar === 'char2'}
        />
        <MSSaveButton label="Save Cast settings" values={tw} />
      </>
    );
  }

  if (activeTab === 'behaviour') {
    return (
      <>
        <div className="mst-section">
          <div className="mst-sec-h">Personality &amp; Style<span className="mst-sec-tag">AI Prompt</span></div>
          <MSTextarea label={`${tw.char1Name} – speech style`}
            value={tw.char1Style}
            placeholder="e.g. warm, knowledgeable, like a TV travel host"
            onChange={(v) => setTw('char1Style', v)} />
          <MSTextarea label={`${tw.char1Name} – personality`}
            rows={3} value={tw.char1Personality}
            placeholder="Tone, behaviour, quirks…"
            onChange={(v) => setTw('char1Personality', v)} />
          <div style={{height:'10px'}}></div>
          <MSTextarea label={`${tw.char2Name} – speech style`}
            value={tw.char2Style}
            placeholder="e.g. playful, curious sidekick"
            onChange={(v) => setTw('char2Style', v)} />
          <MSTextarea label={`${tw.char2Name} – personality`}
            rows={3} value={tw.char2Personality}
            placeholder="Tone, behaviour, quirks…"
            onChange={(v) => setTw('char2Personality', v)} />
          <div className="mst-help">These feed directly into the system prompt — describe how each host talks, what they joke about, how they relate to each other.</div>
        </div>

        <div className="mst-section">
          <div className="mst-sec-h">ElevenLabs voice IDs<span className="mst-sec-tag">Per character</span></div>
          <MSText label={`${tw.char1Name} voice ID`}
            value={tw.char1ElevenVoiceId}
            placeholder="e.g. 21m00Tcm4TlvDq8ikWAM"
            onChange={(v) => setTw('char1ElevenVoiceId', v)} />
          <MSText label={`${tw.char2Name} voice ID`}
            value={tw.char2ElevenVoiceId}
            placeholder="e.g. TxGEqnHWrfWFTfGW9XjX"
            onChange={(v) => setTw('char2ElevenVoiceId', v)} />
          <div className="mst-help">Paste an ElevenLabs voice ID to override the default voice. Find IDs at <b>elevenlabs.io/voice-library</b>. Requires the ElevenLabs API key in the <b>API</b> tab.</div>
        </div>

        <div className="mst-section">
          <div className="mst-sec-h">Voice Tuning</div>
          <MSSlider label="Speech rate" value={tw.voiceRate ?? 1} min={0.6} max={1.6} step={0.05} unit="×" onChange={(v) => setTw('voiceRate', v)} />
          <MSSlider label="Pitch" value={tw.voicePitch ?? 1} min={0.6} max={1.5} step={0.05} unit="×" onChange={(v) => setTw('voicePitch', v)} />
          <MSSlider label="Lipsync intensity" value={tw.lipsyncIntensity ?? 1} min={0} max={2} step={0.05} unit="×" onChange={(v) => setTw('lipsyncIntensity', v)} />
        </div>
        <MSSaveButton label="Save Voice settings" values={tw} />
      </>
    );
  }

  if (activeTab === 'environment') {
    const presets = [
      { id: 'studio',   label: 'Studio',   sw: 'linear-gradient(180deg,#1a3a4c,#0a1f2c)' },
      { id: 'sunset',   label: 'Sunset',   sw: 'linear-gradient(180deg,#ff8c5a,#3a2030)' },
      { id: 'night',    label: 'Night',    sw: 'linear-gradient(180deg,#1a2050,#02030a)' },
      { id: 'neon',     label: 'Neon',     sw: 'linear-gradient(180deg,#ff40c0,#0a0214)' },
      { id: 'daylight', label: 'Daylight', sw: 'linear-gradient(180deg,#dde8f0,#aabec8)' },
      { id: 'forest',   label: 'Forest',   sw: 'linear-gradient(180deg,#80c060,#0a1a14)' },
    ];
    return (
      <>
        <div className="mst-section">
          <div className="mst-sec-h">Environment Preset</div>
          <div className="mst-presets" style={{gridTemplateColumns:'repeat(3,1fr)'}}>
            {presets.map(p => (
              <button key={p.id}
                className={`mst-preset ${tw.envPreset === p.id ? 'active' : ''}`}
                onClick={() => setTw('envPreset', p.id)}>
                <div className="mst-preset-swatch" style={{background:p.sw}}></div>
                {p.label}
              </button>
            ))}
          </div>
        </div>
        <div className="mst-section">
          <div className="mst-sec-h">Lighting &amp; Fog</div>
          <MSColor label="Accent color" value={tw.envAccent} onChange={(v) => setTw('envAccent', v)} />
          <MSSlider label="Ambient light" value={tw.envAmbient ?? 0.35} min={0} max={1.2} step={0.05} onChange={(v) => setTw('envAmbient', v)} />
          <MSSlider label="Key light" value={tw.envKey ?? 1.4} min={0} max={3} step={0.05} onChange={(v) => setTw('envKey', v)} />
          <MSSlider label="Fog density" value={tw.envFog ?? 0.4} min={0} max={1} step={0.02} onChange={(v) => setTw('envFog', v)} />
          <MSSlider label="Stage glow" value={tw.stageGlow ?? 1} min={0} max={2} step={0.05} unit="×" onChange={(v) => setTw('stageGlow', v)} />
        </div>

        <div className="mst-section">
          <div className="mst-sec-h">Post-FX<span className="mst-sec-tag">Cinematic</span></div>
          <div className="mst-help" style={{marginTop:0,marginBottom:'8px',lineHeight:1.5}}>
            Layered shader effects in this order: bloom → color grade → chromatic aberration → vignette → film grain.
            Each is independently tunable. Set any slider to 0 to disable that pass.
          </div>
          <MSSlider label="Bloom strength" value={tw.bloomStrength ?? 0.55} min={0} max={1.5} step={0.05} unit="×" onChange={(v) => setTw('bloomStrength', v)} />
          <MSSlider label="Vignette darkness" value={tw.vignetteDarkness ?? 0.55} min={0} max={1.5} step={0.05} onChange={(v) => setTw('vignetteDarkness', v)} />
          <MSSlider label="Vignette falloff" value={tw.vignetteOffset ?? 1.1} min={0.5} max={2} step={0.05} onChange={(v) => setTw('vignetteOffset', v)} />
          <MSSlider label="Chromatic aberration" value={tw.chromaOffset ?? 0.0012} min={0} max={0.005} step={0.0001} fmt={(v) => v.toFixed(4)} onChange={(v) => setTw('chromaOffset', v)} />
          <MSSlider label="Film grain" value={tw.grainIntensity ?? 0.025} min={0} max={0.15} step={0.005} onChange={(v) => setTw('grainIntensity', v)} />
        </div>

        <div className="mst-section">
          <div className="mst-sec-h">Color Grade<span className="mst-sec-tag">Live</span></div>
          <div className="mst-help" style={{marginTop:0,marginBottom:'8px',lineHeight:1.5}}>
            Tropical-warm defaults. Crank saturation for vivid Malaysian colours, raise contrast for
            cinematic punch. The tint is fixed warm (slight magenta-yellow); adjust in code if you
            want a different look.
          </div>
          <MSSlider label="Saturation" value={tw.gradeSaturation ?? 1.05} min={0} max={2} step={0.02} unit="×" onChange={(v) => setTw('gradeSaturation', v)} />
          <MSSlider label="Contrast" value={tw.gradeContrast ?? 1.04} min={0.5} max={1.6} step={0.02} unit="×" onChange={(v) => setTw('gradeContrast', v)} />
          <MSSlider label="Brightness" value={tw.gradeBrightness ?? 0} min={-0.2} max={0.2} step={0.01} onChange={(v) => setTw('gradeBrightness', v)} />
        </div>

        <div className="mst-section">
          <div className="mst-sec-h">Anti-Aliasing<span className="mst-sec-tag">Smooth edges</span></div>
          <div className="mst-help" style={{marginTop:0,marginBottom:'8px',lineHeight:1.5}}>
            When post-processing is on (bloom etc.), the renderer's native MSAA is bypassed.
            FXAA is fast (~0.3ms) and handles most jaggies. SMAA is slower (~0.5ms) but cleaner
            on diagonal edges. Off saves GPU but characters will have visible jagged silhouettes.
          </div>
          <div style={{display:'flex',gap:'6px',marginBottom:'8px'}}>
            {[
              {id:'off', label:'Off', desc:'fastest'},
              {id:'fxaa', label:'FXAA', desc:'recommended'},
              {id:'smaa', label:'SMAA', desc:'best quality'},
            ].map(opt => {
              const active = (tw.aaMode || 'fxaa') === opt.id;
              return (
                <button key={opt.id} onClick={() => setTw('aaMode', opt.id)} style={{
                  flex:1,padding:'8px 4px',fontSize:'11px',
                  background: active ? 'rgba(120,255,160,0.12)' : 'rgba(120,180,255,0.05)',
                  color: active ? '#9be0a8' : '#cfe5ff',
                  border:'1px solid ' + (active ? 'rgba(120,255,160,0.4)' : 'rgba(120,180,255,0.2)'),
                  borderRadius:'4px',cursor:'pointer',
                }}>
                  {active ? '✓ ' : ''}{opt.label}
                  <div style={{fontSize:'9px',color:'#9bb0c8',marginTop:'2px'}}>{opt.desc}</div>
                </button>
              );
            })}
          </div>
        </div>

        <div className="mst-section">
          <div className="mst-sec-h">Ambient Occlusion<span className="mst-sec-tag">GTAO · costly</span></div>
          <div className="mst-help" style={{marginTop:0,marginBottom:'8px',lineHeight:1.5}}>
            Adds soft contact shadows where surfaces meet — depth in crevices, under furniture,
            between objects. Most useful when you've loaded a detailed environment GLB.
            Adds 3-5ms per frame on integrated GPUs; disable if your kiosk hardware struggles.
          </div>
          <MSToggle label="Enable AO"
            value={!!tw.aoEnabled}
            onChange={(v) => setTw('aoEnabled', v)}
            hint={<>Off by default — turn on after testing performance on your target kiosk.</>} />
          {tw.aoEnabled && (
            <>
              <MSSlider label="AO intensity" value={tw.aoIntensity ?? 1.0} min={0} max={2} step={0.05} unit="×" onChange={(v) => setTw('aoIntensity', v)} />
              <MSSlider label="AO radius" value={tw.aoRadius ?? 0.5} min={0.1} max={2} step={0.05} unit="m" onChange={(v) => setTw('aoRadius', v)} />
              <div style={{display:'flex',gap:'6px',marginTop:'8px'}}>
                {['normal','high'].map(q => {
                  const active = (tw.aoQuality || 'normal') === q;
                  return (
                    <button key={q} onClick={() => setTw('aoQuality', q)} style={{
                      flex:1,padding:'6px',fontSize:'10.5px',
                      background: active ? 'rgba(120,255,160,0.12)' : 'rgba(120,180,255,0.05)',
                      color: active ? '#9be0a8' : '#cfe5ff',
                      border:'1px solid ' + (active ? 'rgba(120,255,160,0.4)' : 'rgba(120,180,255,0.2)'),
                      borderRadius:'4px',cursor:'pointer',
                      textTransform:'capitalize',
                    }}>
                      {active ? '✓ ' : ''}{q}
                      <span style={{fontSize:'9px',color:'#9bb0c8',marginLeft:'4px'}}>
                        ({q === 'normal' ? '16' : '32'} samples)
                      </span>
                    </button>
                  );
                })}
              </div>
            </>
          )}
        </div>

        <div className="mst-section">
          <div className="mst-sec-h">Cinematic Camera<span className="mst-sec-tag">Live</span></div>
          <div className="mst-help" style={{marginTop:0,marginBottom:'8px',lineHeight:1.5}}>
            Procedural camera motion layered on top of your Camera tab settings.
            Speaker focus dollies toward whoever is talking, idle breathing keeps
            the frame alive, beat punches add subtle energy on dialogue cuts.
            Set intensity to 0 to disable entirely.
          </div>
          <MSSlider label="Cinematic intensity" value={tw.cinematicIntensity ?? 1} min={0} max={1.5} step={0.05} unit="×" onChange={(v) => setTw('cinematicIntensity', v)} />
          <MSSlider label="Speaker dolly amount" value={tw.cinematicSpeakerDolly ?? 0.55} min={0} max={1.5} step={0.05} unit="m" onChange={(v) => setTw('cinematicSpeakerDolly', v)} />
          <MSSlider label="Speaker zoom-in" value={tw.cinematicSpeakerZoom ?? -2.5} min={-8} max={0} step={0.25} unit="°" onChange={(v) => setTw('cinematicSpeakerZoom', v)} />
          <MSSlider label="Idle breathing" value={tw.cinematicBreathAmp ?? 0.08} min={0} max={0.3} step={0.01} unit="m" onChange={(v) => setTw('cinematicBreathAmp', v)} />
        </div>

        <div className="mst-section">
          <div className="mst-sec-h">Custom Environment<span className="mst-sec-tag">.glb / .gltf</span></div>
          <CustomEnvironmentEditor tw={tw} setTw={setTw}
            envLoaded={envLoaded}
            onLoadEnv={onLoadEnv} onUnloadEnv={onUnloadEnv}
            onLoadEnvFromURL={onLoadEnvFromURL}
            onSetScreenMesh={onSetScreenMesh}
            onHighlightMesh={onHighlightMesh} />
        </div>

        <div className="mst-section">
          <div className="mst-sec-h">HDRI Lighting<span className="mst-sec-tag">.hdr</span></div>
          <HDRIEditor tw={tw} setTw={setTw}
            hdriLoaded={hdriLoaded}
            onLoadPreset={onLoadHDRIPreset}
            onLoadHDRI={onLoadHDRI}
            onLoadHDRIFromURL={onLoadHDRIFromURL}
            onUnloadHDRI={onUnloadHDRI} />
        </div>

        <MSSaveButton label="Save Scene settings" values={tw} />
      </>
    );
  }

  if (activeTab === 'camera') {
    return (
      <>
        <div className="mst-section">
          <div className="mst-sec-h">Camera Angle<span className="mst-sec-tag">Live</span></div>
          <MSSlider label="Orbit (yaw)"
            value={tw.cameraOrbit ?? 0} min={-90} max={90} step={1} unit="°"
            onChange={(v) => setTw('cameraOrbit', v)} />
          <MSSlider label="Tilt (pitch)"
            value={tw.cameraTilt ?? 0} min={-30} max={30} step={1} unit="°"
            onChange={(v) => setTw('cameraTilt', v)} />
          <MSSlider label="Distance"
            value={tw.cameraDistance ?? 8.8} min={4} max={16} step={0.1} unit="m"
            onChange={(v) => setTw('cameraDistance', v)} />
          <MSSlider label="Height"
            value={tw.cameraHeight ?? 2.4} min={0.5} max={5} step={0.1} unit="m"
            onChange={(v) => setTw('cameraHeight', v)} />
          <MSSlider label="Field of view"
            value={tw.cameraFov ?? 34} min={20} max={70} step={1} unit="°"
            onChange={(v) => setTw('cameraFov', v)} />
          <div className="mst-help">Live camera. Orbit rotates around the cast; tilt adjusts vertical angle. Use the presets below for quick framing.</div>
        </div>
        <div className="mst-section">
          <div className="mst-sec-h">Quick Framing</div>
          <div className="mst-presets" style={{gridTemplateColumns:'repeat(2,1fr)'}}>
            {[
              {l:'Wide stage', d:{cameraOrbit:0,cameraTilt:0,cameraDistance:9.5,cameraHeight:2.6,cameraFov:38}},
              {l:'Two-shot', d:{cameraOrbit:0,cameraTilt:-2,cameraDistance:7,cameraHeight:1.9,cameraFov:30}},
              {l:'From left', d:{cameraOrbit:-25,cameraTilt:0,cameraDistance:8,cameraHeight:2.2,cameraFov:32}},
              {l:'From right', d:{cameraOrbit:25,cameraTilt:0,cameraDistance:8,cameraHeight:2.2,cameraFov:32}},
              {l:'Low hero', d:{cameraOrbit:-10,cameraTilt:8,cameraDistance:7.5,cameraHeight:1,cameraFov:36}},
              {l:'Top-down', d:{cameraOrbit:0,cameraTilt:-22,cameraDistance:9,cameraHeight:5,cameraFov:34}},
            ].map((p,i) => (
              <button key={i} className="mst-preset" onClick={() => {
                Object.entries(p.d).forEach(([k,v]) => setTw(k, v));
              }}>
                <div className="mst-preset-swatch" style={{background:'rgba(255,255,255,0.05)',display:'flex',alignItems:'center',justifyContent:'center',color:'rgba(255,255,255,.5)',fontSize:'10px'}}>{p.l}</div>
                {p.l}
              </button>
            ))}
          </div>
        </div>
        <MSSaveButton label="Save Camera settings" values={tw} />
      </>
    );
  }

  if (activeTab === 'dialogue') {
    return <DialogueTab tw={tw} setTw={setTw} />;
  }

  if (activeTab === 'api') {
    return (
      <>
        <div className="mst-section">
          <div className="mst-sec-h">API Keys<span className="mst-sec-tag">Optional</span></div>
          <MSPassword label="Anthropic API key"
            value={tw.claudeKey}
            placeholder="sk-ant-..."
            onChange={(v) => setTw('claudeKey', v)}
            hint={<>Direct dialogue calls. Falls back to in-app Claude if blank.</>} />
          <MSPassword label="ElevenLabs API key"
            value={tw.elevenlabsKey}
            placeholder="xi-..."
            onChange={(v) => setTw('elevenlabsKey', v)}
            hint={<>High-quality TTS for both hosts. Falls back to browser TTS.</>} />
          <MSPassword label="Lemonfox API key"
            value={tw.lemonfoxKey}
            placeholder="..."
            onChange={(v) => setTw('lemonfoxKey', v)}
            hint={<>Whisper STT. Falls back to browser SpeechRecognition.</>} />
          <MSPassword label="Pixabay API key"
            value={tw.pixabayKey}
            placeholder="public Pixabay key"
            onChange={(v) => setTw('pixabayKey', v)}
            hint={<>Stock imagery for the 3D screen. Get free key at pixabay.com/api.</>} />
        </div>

        <div className="mst-section">
          <div className="mst-sec-h">Cloudinary<span className="mst-sec-tag">Optional</span></div>
          <div className="mst-help" style={{marginTop:0,marginBottom:'10px',lineHeight:1.5}}>
            Use Cloudinary for curated tourism images (overrides Pixabay when configured).
            <br/><br/>
            <b>Setup (BOTH required):</b><br/>
            1. Settings → Security → enable <b>"Resource list"</b> for delivery.<br/>
            2. Settings → Upload → set <b>Default Access mode = Public</b><br/>
            (existing assets: open each → Manage → Access mode → Public)<br/>
            3. Upload photos and tag with topic keywords matching your tree node IDs:
              e.g. <code>langkawi</code>, <code>beach</code>, <code>nasi_lemak</code>.<br/>
            <br/>
            Without step 2 you'll get 401 errors and the kiosk falls back to Pixabay.
          </div>
          <MSText label="Cloud name"
            value={tw.cloudinaryCloudName}
            placeholder="your-cloud-name"
            onChange={(v) => setTw('cloudinaryCloudName', v)}
            hint={<>Found at top of your Cloudinary dashboard.</>} />
        </div>

        <div className="mst-section">
          <div className="mst-sec-h">Wikimedia Commons<span className="mst-sec-tag">Free · No key</span></div>
          <div className="mst-help" style={{marginTop:0,marginBottom:'10px',lineHeight:1.5}}>
            Wikipedia's image library — strong for Malaysian landmarks, heritage sites,
            food, and cultural events. CC-licensed, no API key, no rate limits.
            Sits between Cloudinary (curated) and Pixabay (stock) in the fallback chain:
            <br/><br/>
            <code style={{fontSize:'10px'}}>Cloudinary → <b>Wikimedia</b> → Pixabay → placeholder</code>
            <br/><br/>
            The kiosk auto-appends "Malaysia" to queries for region-specific results.
          </div>
          <MSToggle label="Use Wikimedia Commons"
            value={tw.useWikimedia !== false}
            onChange={(v) => setTw('useWikimedia', v)}
            hint={<>Disable if you prefer Pixabay's stock-photo aesthetic over Wikipedia's documentary feel.</>} />
        </div>

        <PresetSection tw={tw} setTw={setTw} />
        <MSSaveButton label="Save API settings" values={tw} />
      </>
    );
  }

  return null;
}

// ────── Dialogue tab — edit topic tree (hybrid LLM + tree branching) ──────
// Lets the user edit the JSON topic tree at runtime. Changes apply on Save:
// the next dialogue beat picks them up immediately. Reset restores the
// default Malaysia Tourism tree.
function DialogueTab({ tw, setTw }) {
  const [json, setJson] = React.useState('');
  const [status, setStatus] = React.useState('');
  const [stats, setStats] = React.useState({ nodes: 0, root: 0, terminals: 0 });

  // Pull from window.TopicTree on mount
  const refreshFromTree = () => {
    if (!window.TopicTree) {
      setStatus('TopicTree not loaded — reload page');
      return;
    }
    const exported = window.TopicTree.exportTree
      ? window.TopicTree.exportTree()
      : { nodes: window.TopicTree._raw?.nodes || {} };
    setJson(JSON.stringify(exported, null, 2));
    updateStats(exported);
  };

  React.useEffect(() => { refreshFromTree(); }, []);

  const updateStats = (treeObj) => {
    if (!treeObj?.nodes) { setStats({ nodes: 0, root: 0, terminals: 0 }); return; }
    const ids = Object.keys(treeObj.nodes);
    const terminals = ids.filter(id => treeObj.nodes[id].terminal).length;
    setStats({
      nodes: ids.length,
      root: treeObj.nodes.root ? 1 : 0,
      terminals,
    });
  };

  const onSave = () => {
    try {
      const parsed = JSON.parse(json);
      if (!parsed.nodes || !parsed.nodes.root) {
        setStatus('✗ Tree must have a "root" node');
        return;
      }
      // Some legacy exports may not have replaceTree-compatible shape
      if (window.TopicTree?.replaceTree) {
        window.TopicTree.replaceTree(parsed);
      } else {
        setStatus('✗ Old TopicTree version — replaceTree missing. Reload page.');
        return;
      }
      updateStats(parsed);
      setStatus('✓ Tree updated. Next dialogue beat will use it.');
      // Persist to settings so it survives reload
      setTw('topicTreeJSON', json);
    } catch (e) {
      setStatus('✗ Invalid JSON: ' + e.message);
    }
  };

  const onReset = () => {
    if (!confirm('Reset topic tree to Malaysia Tourism default?')) return;
    if (window.TopicTree?.resetToDefault) {
      window.TopicTree.resetToDefault();
      refreshFromTree();
      setStatus('✓ Reset to default tree');
      setTw('topicTreeJSON', '');
    }
  };

  return (
    <>
      <div className="mst-section">
        <div className="mst-sec-h">
          Topic Tree <span className="mst-sec-tag">{stats.nodes} nodes</span>
        </div>
        <div className="mst-help" style={{marginTop:0,marginBottom:'10px',lineHeight:1.5}}>
          The dialogue is hybrid: this tree defines the menu structure, while Claude
          generates the actual lines for each topic. Edit the JSON to add new
          destinations, change menu options, or rewrite the topic guidance fed
          to Claude. <b>Save</b> applies changes to the next dialogue beat.
        </div>
        <div style={{
          display:'flex', gap:'8px', marginBottom:'8px', fontSize:'10.5px', color:'#9bb0c8',
        }}>
          <span>📍 root: {stats.root ? '✓' : '✗'}</span>
          <span>🌿 terminals: {stats.terminals}</span>
        </div>

        {stats.nodes > 0 && (() => {
          // Live preview of the active root menu — proves tree is applied
          try {
            const treeObj = JSON.parse(json);
            const rootNode = treeObj?.nodes?.root;
            if (!rootNode?.options?.length) return null;
            const optionTitles = rootNode.options
              .map(id => treeObj.nodes[id]?.title)
              .filter(Boolean);
            return (
              <div style={{
                marginBottom:'10px', padding:'8px 10px',
                background:'rgba(120,180,255,0.05)',
                border:'1px solid rgba(120,180,255,0.15)',
                borderRadius:'6px', fontSize:'10.5px',
              }}>
                <div style={{color:'#9bb0c8', marginBottom:'4px'}}>Active main menu (live):</div>
                <div style={{color:'#cfe5ff'}}>
                  <b>{rootNode.title || 'root'}</b>
                  {' → '}
                  {optionTitles.join(' · ')}
                </div>
              </div>
            );
          } catch (e) { return null; }
        })()}

        <textarea
          value={json}
          onChange={(e) => setJson(e.target.value)}
          spellCheck={false}
          style={{
            width:'100%', minHeight:'380px', maxHeight:'480px',
            background:'rgba(0,0,0,0.3)', color:'#cfe5ff',
            border:'1px solid rgba(255,255,255,0.1)', borderRadius:'6px',
            padding:'10px', fontFamily:'ui-monospace, "SF Mono", Menlo, monospace',
            fontSize:'11px', lineHeight:1.5, resize:'vertical',
          }} />
        <div style={{display:'flex',gap:'6px',marginTop:'8px'}}>
          <button onClick={onSave} style={{
            flex:1, padding:'8px', background:'rgba(120,255,160,0.15)',
            border:'1px solid rgba(120,255,160,0.4)', borderRadius:'5px',
            color:'#9be0a8', fontSize:'12px', fontWeight:600, cursor:'pointer',
          }}>
            💾 Save & Apply
          </button>
          <button onClick={refreshFromTree} title="Reload from current TopicTree" style={{
            padding:'8px 12px', background:'rgba(255,255,255,0.04)',
            border:'1px solid rgba(255,255,255,0.1)', borderRadius:'5px',
            color:'#cfe5ff', fontSize:'12px', cursor:'pointer',
          }}>↻ Reload</button>
          <button onClick={onReset} style={{
            padding:'8px 12px', background:'rgba(255,140,140,0.08)',
            border:'1px solid rgba(255,140,140,0.3)', borderRadius:'5px',
            color:'#ffa8a8', fontSize:'12px', cursor:'pointer',
          }}>↺ Reset to default</button>
        </div>
        {status && (
          <div style={{
            marginTop:'8px', padding:'7px 10px',
            background: status.startsWith('✓') ? 'rgba(120,255,160,0.06)' : 'rgba(255,140,140,0.08)',
            border: '1px solid ' + (status.startsWith('✓') ? 'rgba(120,255,160,0.3)' : 'rgba(255,140,140,0.3)'),
            borderRadius:'4px', fontSize:'10.5px',
            color: status.startsWith('✓') ? '#9be0a8' : '#ffa8a8',
          }}>
            {status}
          </div>
        )}
      </div>

      <div className="mst-section">
        <div className="mst-sec-h">Schema Cheatsheet</div>
        <div className="mst-help" style={{marginTop:0,lineHeight:1.6,fontSize:'10.5px'}}>
          Each node entry under <code>nodes</code> can have:<br/>
          • <code>title</code> — shown in menu buttons<br/>
          • <code>context</code> — guidance fed to Claude (what to mention)<br/>
          • <code>options</code> — array of child node IDs (becomes the menu)<br/>
          • <code>triggers</code> — array of regex strings ("/foo/i") matching free-form input<br/>
          • <code>terminal: true</code> — leaf node, no further drilling<br/>
          • <code>intro: true</code> — flag the welcome node<br/>
          • <code>image_url</code> — optional fixed image (overrides Pixabay/Cloudinary)<br/>
          The <code>root</code> node is required.
        </div>
      </div>
    </>
  );
}

function PresetSection({ tw, setTw }) {
  const importRef = React.useRef();
  const [savedAt, setSavedAt] = React.useState(null);
  return (
    <div className="mst-section">
      <div className="mst-sec-h">Save / Load Preset<span className="mst-sec-tag">Local</span></div>
      <div className="mst-help" style={{marginTop:0,marginBottom:'10px'}}>
        Settings are auto-saved to your browser on every change. Use the buttons below to export a preset file you can re-import later, or to reset everything.
      </div>
      <div style={{display:'grid',gridTemplateColumns:'1fr 1fr',gap:'8px'}}>
        <button className="mst-preset"
          onClick={() => {
            window.PresetUtils.downloadPreset(tw);
            setSavedAt(Date.now());
          }}>
          <div className="mst-preset-swatch" style={{background:'linear-gradient(135deg,#3a8a4a,#1e4a28)',display:'flex',alignItems:'center',justifyContent:'center',color:'#cfeacc',fontSize:'10px'}}>SAVE</div>
          Export preset
        </button>
        <button className="mst-preset"
          onClick={() => {
            window.PresetUtils.downloadPresetWithKeys(tw);
          }}>
          <div className="mst-preset-swatch" style={{background:'linear-gradient(135deg,#8a6a1e,#4a3a14)',display:'flex',alignItems:'center',justifyContent:'center',color:'#f0d99a',fontSize:'10px'}}>+ KEYS</div>
          Export with keys
        </button>
        <button className="mst-preset"
          onClick={() => importRef.current?.click()}>
          <div className="mst-preset-swatch" style={{background:'linear-gradient(135deg,#3a6a8a,#1e3a4a)',display:'flex',alignItems:'center',justifyContent:'center',color:'#cfe5f0',fontSize:'10px'}}>LOAD</div>
          Import preset
        </button>
        <button className="mst-preset"
          onClick={() => {
            if (!confirm('Reset all settings to defaults? This will not delete uploaded GLB models.')) return;
            window.PresetUtils.clearLocalStorage();
            location.reload();
          }}>
          <div className="mst-preset-swatch" style={{background:'linear-gradient(135deg,#8a3a3a,#4a1e1e)',display:'flex',alignItems:'center',justifyContent:'center',color:'#f0cfcf',fontSize:'10px'}}>RESET</div>
          Reset all
        </button>
      </div>
      <input ref={importRef} type="file" accept=".json,application/json" style={{display:'none'}}
        onChange={async (e) => {
          const f = e.target.files?.[0]; if (!f) return;
          try {
            const data = await window.PresetUtils.readPresetFile(f);
            for (const k of Object.keys(data)) setTw(k, data[k]);
            setSavedAt(Date.now());
            alert('Preset imported. Settings updated live.');
          } catch (err) {
            alert(`Import failed: ${err.message}`);
          }
          e.target.value = '';
        }} />
      {savedAt && (
        <div className="mst-help" style={{marginTop:'8px',color:'#7fd17f'}}>
          ✓ Done at {new Date(savedAt).toLocaleTimeString()}
        </div>
      )}
    </div>
  );
}

function CharacterCard({ slot, idx, tw, setTw, file, setFile, loaded, onLoadGLB, onClearGLB, onLoadGLBFromURL, onLoadAnimationFromURL, loading }) {
  const N = (k) => slot + k;
  const variants = ['cobalt', 'rose', 'gold', 'jade', 'violet', 'amber'];
  const [urlInput, setUrlInput] = React.useState('');
  return (
    <div className="mst-section">
      <div className="mst-sec-h">
        Character {idx}
        <span className="mst-sec-tag" style={{color: tw[N('Color')]}}>
          {loaded.source === 'vrm' ? 'VRM' : loaded.source === 'glb' ? 'GLB' : 'PROCEDURAL'}
        </span>
      </div>
      <div className="mst-char-card">
        <div className="mst-char-card-h">
          <div className="mst-char-dot" style={{background: tw[N('Color')], color: tw[N('Color')]}}></div>
          <div className="mst-char-card-name">{tw[N('Name')] || `Character ${idx}`}</div>
        </div>

        <MSText label="Display name" value={tw[N('Name')]}
          placeholder={`Character ${idx} name`}
          onChange={(v) => setTw(N('Name'), v)} />

        <MSFileUpload label="3D model (.vrm / .glb / .gltf)"
          accept=".vrm,.glb,.gltf"
          fileName={loading ? 'Loading…' : loaded.name}
          onFile={(f) => { setFile(f); onLoadGLB(f, slot); }}
          onClear={() => onClearGLB(slot)} />

        <MSField label="…or load from URL">
          <div style={{display:'flex',gap:'6px'}}>
            <input type="url"
              value={urlInput}
              placeholder="https://.../wira.vrm"
              onChange={(e) => setUrlInput(e.target.value)}
              style={{flex:'1',background:'rgba(255,255,255,0.04)',border:'1px solid rgba(255,255,255,0.08)',borderRadius:'6px',padding:'7px 9px',color:'#e8eef5',fontSize:'12px',outline:'none'}} />
            <button onClick={() => { onLoadGLBFromURL(urlInput, slot); }}
              disabled={loading || !urlInput.trim()}
              style={{background:'rgba(120,180,255,0.2)',border:'1px solid rgba(120,180,255,0.4)',borderRadius:'6px',padding:'7px 12px',color:'#cfe5ff',fontSize:'11px',fontWeight:600,cursor:'pointer',whiteSpace:'nowrap'}}>
              Load
            </button>
          </div>
          <div className="mst-help" style={{marginTop:'4px'}}>Public URL only. Accepts .vrm / .glb / .gltf. Try GitHub raw, Cloudflare R2, or any CORS-enabled host.</div>
        </MSField>

        {loaded.source === 'procedural' && (
          <MSField label="Procedural style">
            <div className="mst-grid3">
              {variants.map(v => (
                <button key={v}
                  className={`mst-preset ${tw[N('Variant')] === v ? 'active' : ''}`}
                  onClick={() => setTw(N('Variant'), v)}
                  style={{padding:'6px 4px',fontSize:'10px'}}>
                  {v}
                </button>
              ))}
            </div>
          </MSField>
        )}

        <MSColor label="Accent color" value={tw[N('Color')]} onChange={(v) => setTw(N('Color'), v)} />

        <MSXYZ label="Position"
          x={tw[N('PosX')]} y={tw[N('PosY')]} z={tw[N('PosZ')]}
          step={0.05}
          onChange={(d) => {
            const edits = {};
            if ('x' in d) edits[N('PosX')] = d.x;
            if ('y' in d) edits[N('PosY')] = d.y;
            if ('z' in d) edits[N('PosZ')] = d.z;
            setMultiHelper(edits, setTw);
          }} />

        <MSXYZ label="Scale (XYZ)"
          x={tw[N('ScaleX')]} y={tw[N('ScaleY')]} z={tw[N('ScaleZ')]}
          step={0.05}
          onChange={(d) => {
            const edits = {};
            if ('x' in d) edits[N('ScaleX')] = d.x;
            if ('y' in d) edits[N('ScaleY')] = d.y;
            if ('z' in d) edits[N('ScaleZ')] = d.z;
            setMultiHelper(edits, setTw);
          }} />

        <MSSlider label="Uniform scale" value={tw[N('ScaleX')] ?? 1} min={0.05} max={20} step={0.05} unit="×"
          onChange={(v) => {
            setMultiHelper({ [N('ScaleX')]: v, [N('ScaleY')]: v, [N('ScaleZ')]: v }, setTw);
          }} />
        <button onClick={() => setMultiHelper({ [N('ScaleX')]: 1, [N('ScaleY')]: 1, [N('ScaleZ')]: 1 }, setTw)}
          style={{marginTop:'4px',width:'100%',background:'rgba(255,255,255,0.04)',border:'1px solid rgba(255,255,255,0.08)',borderRadius:'6px',padding:'5px 8px',color:'#a8b8cc',fontSize:'10px',cursor:'pointer'}}>
          Reset scale to 1×
        </button>

        <MSSlider label="Y rotation" value={tw[N('RotY')] ?? 0} min={-180} max={180} step={1} unit="°"
          onChange={(v) => setTw(N('RotY'), v)} />

        <AnimationSlots slot={slot} loaded={loaded} onLoadFromURL={onLoadAnimationFromURL} />
      </div>
    </div>
  );
}


// Animation slot loaders — drop FBX from Mixamo, glTF/GLB from anywhere.
// 5 fixed slots wired to dialogue states: Idle, Talking, Listening, plus
// two free-form Gesture slots for future use (Gesture A/B can be triggered
// via the API for specific reactions).
function AnimationSlots({ slot, loaded, onLoadFromURL }) {
  // status: { [slotKey]: { name, status: 'loading'|'loaded'|'error', error? } }
  const [status, setStatus] = React.useState({});
  // Per-slot URL inputs — kept local so each row remembers what was typed
  // even before user clicks Load.
  const [urlInputs, setUrlInputs] = React.useState({});

  const slots = [
    { key: 'idle',      label: 'Idle',      hint: 'when nothing is happening' },
    { key: 'talking',   label: 'Talking',   hint: 'while this character speaks' },
    { key: 'listening', label: 'Listening', hint: 'while the other character speaks' },
    { key: 'gestureA',  label: 'Gesture A', hint: 'free slot — point / wave / etc.' },
    { key: 'gestureB',  label: 'Gesture B', hint: 'free slot — nod / shrug / etc.' },
  ];

  // Hydrate UI status from IDB cache: if user previously loaded an
  // animation, show the filename + green "loaded" border. The actual clip
  // restoration is handled by Scene's animation-restore effect.
  React.useEffect(() => {
    let cancelled = false;
    (async () => {
      const next = {};
      for (const s of slots) {
        try {
          const cached = await window.PresetUtils?.loadAnimFromCache?.(slot, s.key);
          if (cached?.name) {
            next[s.key] = { name: cached.name, status: 'loaded' };
          }
        } catch (e) { /* silent */ }
      }
      if (!cancelled && Object.keys(next).length) {
        setStatus(next);
      }
    })();
    return () => { cancelled = true; };
  }, [slot]);

  if (loaded.source !== 'glb' && loaded.source !== 'vrm') {
    return (
      <div className="mst-help" style={{marginTop:'10px',padding:'8px 10px',background:'rgba(120,180,255,0.05)',borderLeft:'2px solid rgba(120,180,255,0.4)',borderRadius:'4px'}}>
        Load a .vrm / .glb model first to enable animation slots.
      </div>
    );
  }

  const onLoad = async (slotKey, file) => {
    if (!file) return;
    setStatus(prev => ({ ...prev, [slotKey]: { name: file.name, status: 'loading' } }));
    try {
      const buf = await file.arrayBuffer();
      const ms = window.metaspeak || {};
      if (!ms.loadAnimation) throw new Error('Scene not ready');
      await ms.loadAnimation(slot, slotKey, buf, file.name);
      setStatus(prev => ({ ...prev, [slotKey]: { name: file.name, status: 'loaded' } }));
    } catch (err) {
      console.error('Animation load failed', err);
      setStatus(prev => ({ ...prev, [slotKey]: { name: file.name, status: 'error', error: err.message } }));
    }
  };

  const onLoadURL = async (slotKey, url) => {
    if (!url || !url.trim()) return;
    if (!onLoadFromURL) {
      setStatus(prev => ({ ...prev, [slotKey]: { name: url, status: 'error', error: 'URL load not available — scene not ready' } }));
      return;
    }
    const filename = url.split('/').pop().split('?')[0] || 'remote.fbx';
    setStatus(prev => ({ ...prev, [slotKey]: { name: filename, status: 'loading' } }));
    try {
      // Returned name may differ from filename (e.g. content-disposition);
      // use what the loader actually saved.
      const savedName = await onLoadFromURL(slot, slotKey, url.trim()) || filename;
      setStatus(prev => ({ ...prev, [slotKey]: { name: savedName, status: 'loaded' } }));
      setUrlInputs(prev => ({ ...prev, [slotKey]: '' }));
    } catch (err) {
      console.error('Animation URL load failed', err);
      setStatus(prev => ({ ...prev, [slotKey]: { name: filename, status: 'error', error: err.message } }));
    }
  };

  const onReset = (slotKey) => {
    const ms = window.metaspeak || {};
    ms.unloadAnimation?.(slot, slotKey);
    setStatus(prev => {
      const next = { ...prev };
      delete next[slotKey];
      return next;
    });
  };

  return (
    <div style={{marginTop:'12px',padding:'10px 12px',background:'rgba(0,0,0,0.2)',border:'1px solid rgba(255,255,255,0.06)',borderRadius:'8px'}}>
      <div style={{fontSize:'11px',fontWeight:700,color:'#cfe5ff',marginBottom:'10px',textTransform:'uppercase',letterSpacing:'0.5px'}}>
        🎬 Animation Slots
      </div>

      {slots.map(s => {
        const st = status[s.key];
        const loaded = st?.status === 'loaded';
        const loading = st?.status === 'loading';
        const errored = st?.status === 'error';
        return (
          <div key={s.key} style={{marginBottom:'10px'}}>
            <div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:'3px'}}>
              <label style={{fontSize:'11px',fontWeight:600,color:'#cfe5ff'}}>{s.label}</label>
              {loaded && (
                <button
                  onClick={() => onReset(s.key)}
                  title={`Reset ${s.label} slot`}
                  style={{
                    background:'rgba(255,140,140,0.08)', border:'1px solid rgba(255,140,140,0.3)',
                    borderRadius:'4px', padding:'1px 7px', fontSize:'10px', color:'#ffa8a8',
                    cursor:'pointer', lineHeight: '14px',
                  }}>
                  ↺ Reset
                </button>
              )}
            </div>
            <div style={{fontSize:'9.5px',color:'#7a90a8',marginBottom:'4px',fontStyle:'italic'}}>{s.hint}</div>

            <FileSlotInput
              accept=".fbx,.glb,.gltf"
              loaded={loaded}
              loading={loading}
              errored={errored}
              fileName={st?.name}
              error={st?.error}
              onFile={(f) => onLoad(s.key, f)}
            />

            {/* Compact URL load row — fits below the file picker. Stays
                hidden if the slot already has a loaded animation, since
                the user can Reset first if they want a different one. */}
            {!loaded && (
              <div style={{display:'flex',gap:'4px',marginTop:'4px'}}>
                <input
                  type="url"
                  value={urlInputs[s.key] || ''}
                  placeholder="https://.../animation.fbx"
                  onChange={(e) => setUrlInputs(prev => ({ ...prev, [s.key]: e.target.value }))}
                  disabled={loading}
                  style={{
                    flex:1,
                    background:'rgba(255,255,255,0.04)',
                    border:'1px solid rgba(255,255,255,0.08)',
                    borderRadius:'4px',
                    padding:'4px 7px',
                    color:'#e8eef5',
                    fontSize:'10.5px',
                    outline:'none',
                  }}
                />
                <button
                  type="button"
                  disabled={loading || !(urlInputs[s.key] || '').trim()}
                  onClick={() => onLoadURL(s.key, urlInputs[s.key])}
                  style={{
                    background:'rgba(120,180,255,0.15)',
                    border:'1px solid rgba(120,180,255,0.3)',
                    borderRadius:'4px',
                    padding:'4px 9px',
                    color:'#cfe5ff',
                    fontSize:'10px',
                    fontWeight:600,
                    cursor: loading ? 'wait' : 'pointer',
                    whiteSpace:'nowrap',
                    opacity: (loading || !(urlInputs[s.key] || '').trim()) ? 0.5 : 1,
                  }}
                >
                  Load URL
                </button>
              </div>
            )}
          </div>
        );
      })}

      <div className="mst-help" style={{marginTop:'10px',fontSize:'10px',lineHeight:1.5,color:'#7a90a8'}}>
        Tip: Mixamo FBX (Without Skin, 30fps) drops in directly — no conversion needed.
      </div>
    </div>
  );
}

// Compact file-input row with three states: empty / loaded / error.
function FileSlotInput({ accept, loaded, loading, errored, fileName, error, onFile }) {
  const inputRef = React.useRef();
  const trigger = () => inputRef.current?.click();
  return (
    <div
      onClick={!loading ? trigger : undefined}
      style={{
        display:'flex', alignItems:'center', gap:'8px',
        padding:'6px 9px',
        background:
          errored ? 'rgba(255,120,120,0.08)'
          : loaded ? 'rgba(120,255,160,0.06)'
          : 'rgba(255,255,255,0.03)',
        border: '1px ' + (errored ? 'solid' : loaded ? 'solid' : 'dashed') + ' '
          + (errored ? 'rgba(255,120,120,0.35)'
            : loaded  ? 'rgba(120,255,160,0.35)'
            : 'rgba(255,255,255,0.15)'),
        borderRadius:'6px',
        cursor: loading ? 'wait' : 'pointer',
        fontSize:'11px',
        minHeight:'30px',
      }}
    >
      <input
        ref={inputRef}
        type="file"
        accept={accept}
        onChange={(e) => {
          const f = e.target.files?.[0];
          if (f) onFile(f);
          e.target.value = '';
        }}
        style={{display:'none'}}
      />
      {loading ? (
        <span style={{color:'#a8b8cc'}}>⏳ Loading {fileName}…</span>
      ) : loaded ? (
        <>
          <span style={{color:'#9be0a8'}}>✓</span>
          <span style={{color:'#cfe5ff', overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap'}} title={fileName}>
            {fileName}
          </span>
        </>
      ) : errored ? (
        <>
          <span style={{color:'#ff8a8a'}}>✗</span>
          <span style={{color:'#ffa8a8', overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap'}} title={error}>
            {fileName} — {error}
          </span>
        </>
      ) : (
        <span style={{color:'#7a90a8'}}>+ Choose .fbx / .glb / .gltf file</span>
      )}
    </div>
  );
}

function setMultiHelper(edits, setTw) {
  for (const k in edits) setTw(k, edits[k]);
}

function mount() {
  const root = ReactDOM.createRoot(document.getElementById('root'));
  root.render(<App />);
}
if (window.THREE) mount();
else window.addEventListener('three-ready', mount, { once: true });
