<!-- SOLUV · part 662 -->
thWorld; };
  const clampTy = y => Math.max(0.05, Math.min(wallTop() + 2, y));
  function haltAuto() { anim = null; glide = null; flingTy = 0; userMoved = true; }
  function rideBy(dy) { tyGoal = clampTy((tyGoal === null ? st.ty : tyGoal) + dy); need = true; return tyGoal; }
  function readView(y, w = current) { const d = distForPpe(12), vis = 2 * d * tanH(); const a = w.wall.lineAnchor(); return at(w, a ? a.local.x : 0, Math.max(vis * 0.45, y), d); }
  function goTop() { const t = current.wall.textRect(), vis = 2 * distForPpe(12) * tanH(); flyTo(readView(t.y1 - vis * 0.4)); }
  function goLine(i) { const a = current.wall.lineAnchor(i); if (!a) return false; flyTo(readView(a.local.y)); return true; }

  /* glide: a slow pass across the wall at reading size, band by band (zig-zag when a band is taller than the screen).
     Text is fixed on the wall, so nothing can overlap while moving. */
  function startGlide() {
    const w = current; const t = w.wall.textRect(); if (!w.wall.info().lines) { flyTo(views.far(w)); return; }
    const d = distForPpe(11), half = d * tanH();
    glide = { w, d, half, col: 0, y: t.y1 - half * 0.85, t: performance.now() };
    Object.assign(st, at(w, w.wall.colCenterX(0), glide.y, d)); applyCamera();
  }
  function stepGlide(now) {
    const g = glide, dt = Math.min(0.1, (now - g.t) / 1000); g.t = now; const t = g.w.wall.textRect();
    g.y -= dt * 6.5 * EM;                                    // ~5 lines per second, top to bottom
    if (g.y < t.y0 + g.half * 0.85) { g.col = (g.col + 1) % (g.w.wall.lanes || g.w.wall.cols); g.y = t.y1 - g.half * 0.85; }
    Object.assign(st, at(g.w, g.w.wall.colCenterX(g.col), Math.max(g.y, g.half * 0.5), g.d)); applyCamera();
  }

  /* ---------- touch: one finger pans (orbits when far), two fingers pinch + pan; wheel zooms ---------- */
  const ptr = new Map(); let last = null;
  let tap0 = null;
  canvas.addEventListener('pointerdown', e => { tap0 = ptr.size ? null : { x: e.clientX, y: e.clientY, t: performance.now() }; });
  canvas.addEventListener('pointerup', e => { if (!place && tap0 && Math.hypot(e.clientX - tap0.x, e.clientY - tap0.y) < 10) archiveTap(e.clientX, e.clientY); });
  function archiveTap(cx, cy) {
    const w = current, ar = w.wall.archiveRect && w.wall.archiveRect(); if (!ar) return;
    const r = canvas.getBoundingClientRect(), ray = new THREE.Raycaster(); ray.setFromCamera(new THREE.Vector2(((cx - r.left) / r.width) * 2 - 1, -((cy - r.top) / r.height) * 2 + 1), camera);
    const inv = w.matrixWorld.clone().invert(), o = ray.ray.origin.clone().applyMatrix4(inv), d = ray.ray.direction.clone().transformDirection(inv);
    const zf = w.wall.colliderLocal().max.z - 5 * 0.13; if (Math.abs(d.z) < 1e-6) return; const k = (zf - o.z) / d.z; if (k <= 0) return;
    const p = o.addScaledVector(d, k); if (p.x >= ar.x0 && p.x <= ar.x1 && p.y >= ar.y0 && p.y <= ar.y1) window.open(w.wall.archive.url, '_blank', 'noopener');
  }
  canvas.addEventListener('pointerup', e => { if (place && tap0 && Math.hypot(e.clientX - tap0.x, e.clientY - tap0.y) < 10 && performance.now() - tap0.t < 600) placeTap(e.clientX, e.clientY); tap0 = null; });
  canvas.addEventListener('pointerdown', e => { userMoved = true; canvas.setPointerCapture(e.pointerId); ptr.set(e.pointerId, { x: e.clientX, y: e.clientY }); stopMotion(); last = gesture(); });
  canvas.addEventListener('pointermove', e => { if (!ptr.has(e.pointerId)) return; ptr.set(e.pointerId, { x: e.clientX, y: e.clientY }); const g = gesture(); if (last && g && g.n === last.n) move(last, g); last = g; });
  const up = e => { ptr.delete(e.pointerId); last = gesture(); if (!ptr.size && e.type === 'pointerup' && performance.now() - panT < 90 && Math.abs(panV) > pxWorld() * 60) flingTy = panV; panV = 0; };
  canvas.addEventListener('pointerup', up); canvas.addEventListener('pointercancel', up);
  // wheel / two-finger scroll = ride up and down the tower; Ctrl+wheel (trackpad pinch) or Alt+wheel = closer / farther
  canvas.addEventListener('wheel', e => {
    e.preventDefault(); haltAuto();
    const px = e.deltaY * (e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? cssH() : 1);
    if (e.ctrlKey || e.altKey) { tyGoal = null; st.dist *= Math.exp(px * 0.0015); applyCamera(); return; }
    rideBy(-px * pxWorld());                                            // scroll down = down the tower (toward the newest line)
  }, { passive: false });

  /* KEYBOARD (ignored while typing in a box): arrows/WASD ride and slide, PageUp/PageDown a screen, Home top, End newest,
     + / - closer / farther, Enter = write (walks up to the wall first if you are away), Escape closes the voice panel. */
  function keyAction(key) {
    const k = key.length === 1 ? key.toLowerCase() : key, vis = 2 * st.dist * tanH(), step = Math.max(L.LINE * EM * 3, vis * 0.08);
    const r = new THREE.Vector3(Math.cos(st.yaw), 0, -Math.sin(st.yaw)), side = Math.max(0.3, vis * 0.08 * cssW() / cssH());
    if (k === 'ArrowUp' || k =