<!-- SOLUV · part 485 -->
ngth, x, y, span, t: performance.now() }; }
  function move(a, b) {
    const dx = b.x - a.x, dy = b.y - a.y;
    if (reading && b.n === 1) { const k = EM / READ_PPE; st.y += dy * k; fling = dy * k / Math.max(1, (performance.now() - (a.t || 0))) * 1000; scrollClamp(); applyCamera(); return; }
    if (reading) setReading(false);
    if (b.n === 1) { const k = camera.fov * Math.PI / 180 / cssH(); st.yaw -= dx * k; st.pitch += dy * k; }
    else { const s = speed(); st.y += dy * s; if (a.span > 0 && b.span > 0) { const f = (b.span - a.span) * s * 1.5; st.x += fwd.x * f; st.y += fwd.y * f; st.z += fwd.z * f; } const r = new THREE.Vector3(Math.cos(st.yaw), 0, Math.sin(st.yaw)); st.x -= r.x * dx * s * 0.5; st.z -= r.z * dx * s * 0.5; }
    applyCamera();
  }
  wrap.querySelector('.sp-ctrls').addEventListener('click', e => {
    const b = e.target.closest('[data-a]'); if (!b) return; const a = b.dataset.a;
    if (a !== 'live') setReading(false);
    if (a === 'live') { setReading(true); flyTo(views.live(), 1200); }
    else if (a === 'rise') { if (rise) stopMotion(); else { flyTo(views.base(), 900); setTimeout(startRise, 950); } }
    else if (views[a] && a !== 'live') flyTo(views[a](), a === 'far' || a === 'top' ? 2600 : 1600);
  });

  /* ---------- composer: posts into the real lobby with your own login (only near the tower) ---------- */
  const form = $('.sp-compose'), input = $('.sp-input'), send = $('.sp-send'), loginA = $('.sp-login'), gotoBtn = $('.sp-goto'), archA = $('.sp-archive');
  let nearState;
  const NEAR_M = 14;
  gotoBtn.addEventListener('click', () => { setReading(true); flyTo(views.live(), 1200); });
  async function refreshMe() {
    if (wall.wall.status.state === 'idle') return;   // the wall has not started (data thrift): ask later
    try { me = await wall.wall.whoAmI(); }
    catch (e) { input.disabled = true; send.disabled = true; input.placeholder = 'Cannot check your login: ' + e.message; console.error('[soluv-space] ' + e.message); return; }
    const guest = !me.username; form.classList.toggle('sp-guest', guest); input.disabled = guest; send.disabled = guest;
    input.placeholder = guest ? 'Watching: log in to post' : 'Write to the Lobby as ' + (me.king ? 'The Epistolary \u265b' : me.name) + '…';
  }
  /* one send path for Send, Enter and soluvKeys.post: the wall's own POST with YOUR session + CSRF, the lobby's normal limits */
  async function sendText(text, fromComposer) {
    text = String(text == null ? '' : text);
    if (!text.trim()) return { ok: false, error: 'Write something first' };
    send.disabled = true; const keep = input.placeholder; input.placeholder = 'Sending…';
    const r = await wall.wall.post(text).catch(err => ({ ok: false, error: err.message }));
    send.disabled = form.classList.contains('sp-guest'); input.placeholder = keep;
    if (r.login) r.error = 'Log in to post';
    api.lastPost = { ok: !!r.ok, error: r.error || null, id: r.id || null, at: Date.now() };
    if (r.ok) { if (fromComposer) input.value = ''; statusEl.textContent = 'Sent to the Lobby \u2713 (on the tower and on the site)'; statusEl.dataset.state = 'live'; setTimeout(refreshStatus, 4000); }
    else { statusEl.textContent = 'Not sent: ' + r.error; statusEl.dataset.state = 'error'; if (!r.login) console.error('[soluv-space] post failed: ' + r.error); else refreshMe(); }
    return r.ok ? { ok: true, id: r.id || null } : { ok: false, status: r.status || null, error: r.error };
  }
  form.addEventListener('submit', e => { e.preventDefault(); sendText(input.value, true); });

  /* TRANSCEIVER: read new lines aloud in each speaker's own chosen voice; mic dictation into this box */
  const voice = createTransceiver({ container: wrap, whoAmI: () => wall.wall.whoAmI() });
  voice.attach(wall); voice.setWalls(walls);
  { const mic = voice.micButton(input); if (mic) form.insertBefore(mic, send); }
  $('.sp-voice').addEventListener('click', () => voice.toggle(walls));
  function updateHud() {
    const d = wall.wall.distanceTo(camera.position), near = d <= NEAR_M;
    if (near !== nearState) { nearState = near; form.classList.toggle('sp-away', !near); gotoBtn.classList.toggle('sp-away', near); if (!near && document.activeElement === input) input.blur(); }
    api.near = near; api.nearDistance = d;
    const alt = Math.max(0, camera.position.y); const s = String(Math.round(alt)); if (altN.textContent !== s) altN.textContent = s;
    altMark.style.bottom = (Math.min(1, alt / Math.max(1, topY)) * 100).toFixed(2) + '%';
    const ar = wall.wall.archive, showAr = !!(ar && ar.messages > 0 && ar.url);
    if (showAr) { const t = '\u25b2 ' + ar.messages + ' older messages did not fit \u00b7 open the full chat'; if (archA.textContent !== t) archA.textContent = t; if (archA.getAttribute('href') !== ar.url) archA.href = ar.url; }
    archA.hidden = !showAr;
  }
  function refreshStatus() {
    const s = wall.wall.status, i = wall.wall.info();
    statusEl.textContent = 'The Lobb