<!-- SOLUV · part 663 -->
== 'w') { haltAuto(); rideBy(step); }
    else if (k === 'ArrowDown' || k === 's') { haltAuto(); rideBy(-step); }
    else if (k === 'PageUp') { haltAuto(); rideBy(vis * 0.9); }
    else if (k === 'PageDown') { haltAuto(); rideBy(-vis * 0.9); }
    else if (k === 'ArrowLeft' || k === 'a') { haltAuto(); st.tx -= r.x * side; st.tz -= r.z * side; applyCamera(); }
    else if (k === 'ArrowRight' || k === 'd') { haltAuto(); st.tx += r.x * side; st.tz += r.z * side; applyCamera(); }
    else if (k === 'Home') goTop();
    else if (k === 'End') flyTo(views.live());
    else if (k === '+' || k === '=') flyTo({ ...st, dist: st.dist / 1.8 }, 350);
    else if (k === '-' || k === '_') flyTo({ ...st, dist: st.dist * 1.8 }, 350);
    else if (k === 'Enter') { if (!form.classList.contains('sw-away') && !input.disabled) input.focus(); else flyTo(views.live(), 1400); }
    else if (k === 'Escape') { if (voice.panel.hidden) return false; voice.panel.hidden = true; }
    else return false;
    return true;
  }
  addEventListener('keydown', e => {
    if (e.metaKey || e.ctrlKey || e.altKey || e.defaultPrevented) return;
    const tgt = e.target;
    if (tgt && tgt.closest && tgt.closest('input,textarea,select,[contenteditable]')) { if (e.key === 'Escape') tgt.blur(); return; }   // typing is never steering
    if (tgt && tgt.closest && tgt.closest('button,a') && (e.key === 'Enter' || e.key === ' ')) return;                             // let focused buttons work
    if (keyAction(e.key)) e.preventDefault();
  });
  function gesture() { const p = [...ptr.values()]; if (!p.length) return null; const x = p.reduce((a, b) => a + b.x, 0) / p.length, y = p.reduce((a, b) => a + b.y, 0) / p.length; const span = p.length > 1 ? Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y) : 0; return { n: p.length, x, y, span, t: performance.now() }; }
  function move(a, b) {
    const dx = b.x - a.x, dy = b.y - a.y, k = 2 * st.dist * tanH() / cssH();
    if (b.n === 1 && st.dist <= 220 && st.pitch <= 0.9) { const ms = Math.max(1, b.t - (a.t || b.t - 16)); panV = dy * k / ms * 1000; panT = b.t; }   // swipe speed (for momentum)
    if (b.n === 1 && st.dist > 220) { st.yaw -= dx * 0.005; st.pitch += dy * 0.004; }
    else { const r = new THREE.Vector3(Math.cos(st.yaw), 0, -Math.sin(st.yaw)); st.tx -= r.x * dx * k; st.tz -= r.z * dx * k; st.ty += dy * k * (st.pitch > 0.9 ? 0 : 1); if (st.pitch > 0.9) { st.tx -= Math.sin(st.yaw) * dy * k; st.tz -= Math.cos(st.yaw) * dy * k; } }
    if (b.n > 1 && a.span > 0 && b.span > 0) st.dist *= a.span / b.span;
    applyCamera();
  }
  wrap.querySelector('.sw-ctrls').addEventListener('click', e => {
    const b = e.target.closest('[data-a]'); if (!b) return; const a = b.dataset.a;
    if (a === 'in') flyTo({ ...st, dist: st.dist / 1.8 }, 350);
    else if (a === 'out') flyTo({ ...st, dist: st.dist * 1.8 }, 350);
    else if (a === 'live') flyTo(views.live());
    else if (a === 'far') flyTo(views.far());
    else if (a === 'map') flyTo(views.map(), 1800);
    else if (a === 'glide') { if (glide) stopMotion(); else startGlide(); }
    else if (a === 'place') togglePlace();
    else if (a === 'voice') voice.toggle(walls);
    else if (a === 'next') { const base = walls.filter(w => !w.userData.placed); const i = (base.indexOf(current) + 1) % base.length; current = base[i]; history.replaceState(null, '', '/soluv/' + (i + 1) + '/' + location.search); refreshStatus(); refreshMe(); flyTo(views.far(), 1800); }
    wrap.querySelectorAll('[data-a=glide]').forEach(x => x.classList.toggle('sw-on', !!glide));
  });

  /* composer: posts into the current wall's real room with your own login (never as anyone else) */
  const form = wrap.querySelector('.sw-compose'), input = form.querySelector('.sw-input'), send = form.querySelector('.sw-send'), loginA = form.querySelector('.sw-login');
  const gotoBtn = wrap.querySelector('.sw-goto'), archA = wrap.querySelector('.sw-archive');
  let me = null;
  /* PROXIMITY: the composer appears when you are standing near a wall (within NEAR_M of its solid box, anywhere along
     the whole 40-wide face, so many people fit side by side; people never collide with each other, only with the wall).
     Your name is never typed: the room's own server reads it from your logged-in session when you post. */
  const NEAR_M = 14; let nearW;   // undefined: the first frame always sets the composer state
  function spotQuery() { const r = v => Math.round(v * 100) / 100; return 'at=' + [st.tx, st.ty, st.tz, st.dist, st.yaw, st.pitch].map(r).join(','); }
  function loginHref() { const q = new URLSearchParams(location.search); q.delete('at'); q.delete('view'); const base = location.pathname + (q.toString() ? '?' + q + '&' : '?'); return '/login?next=' + encodeURIComponent(base + spotQuery()); }
  function updateProximity() {
    let best = null, bd = Infinity;
    for (const w of walls) { const d = w.wall.distanceTo(camera.position); if (d < bd) { bd = d; best = w; } }
    const near 