<!-- SOLUV · part 451 -->
    zoomAt(after.mx, after.my, before.d / after.d);
        const now = performance.now(), dt = Math.max(1, now - (mom.zt || now)) / 1000; mom.zt = now;
        if (dt < 0.1) mom.vz = mom.vz * 0.65 + 0.35 * Math.log(before.d / after.d) / dt; mom.zx = after.mx; mom.zy = after.my;
      }
      trackPan();
      let da = after.ang - before.ang; if (da > Math.PI) da -= 2 * Math.PI; if (da < -Math.PI) da += 2 * Math.PI;
      if (Math.abs(da) < 0.3) { st.yaw += da; applyCamera(); }
    }
  });
  const up = e => {
    if (tap && e.type === 'pointerup' && pointers.size === 1 && performance.now() - tap.t < 350 && Math.hypot(e.offsetX - tap.x, e.offsetY - tap.y) < 10) { mom.vx = mom.vy = 0; tapAt(tap.x, tap.y); }
    tap = null;
    pointers.delete(e.pointerId);
    if (performance.now() - mom.lastT > 80) { mom.vx = mom.vy = 0; } // finger rested before lifting: no fling
    if (pointers.size) mom.lastT = 0;
  };
  canvas.addEventListener('pointerup', up); canvas.addEventListener('pointercancel', up);
  canvas.addEventListener('contextmenu', e => e.preventDefault());
  canvas.addEventListener('wheel', e => { // wheel feeds zoom velocity, so it glides instead of stepping
    e.preventDefault(); anim = null; drift = null;
    mom.vz += clamp(e.deltaY * (e.ctrlKey ? 0.01 : 0.0015), -0.6, 0.6) * 7; mom.zx = e.offsetX; mom.zy = e.offsetY;
  }, { passive: false });
  function stepMomentum(dt) {
    if (pointers.size) return;
    const pan = Math.hypot(mom.vx, mom.vy), decay = Math.exp(-dt * 3.2);
    if (pan > 0.02 * st.dist) { st.tx += mom.vx * dt; st.ty += mom.vy * dt; mom.vx *= decay; mom.vy *= decay; applyCamera(); } else { mom.vx = mom.vy = 0; }
    if (Math.abs(mom.vz) > 0.01) { zoomAt(mom.zx, mom.zy, Math.exp(mom.vz * dt)); mom.vz *= Math.exp(-dt * 6); } else mom.vz = 0;
  }
  wrap.querySelector('.ws-ctrls').addEventListener('click', e => {
    const a = e.target.closest('button')?.dataset.a; if (!a) return;
    if (a === 'in') flyTo({ ...st, dist: clamp(st.dist / 2.5, DMIN, DMAX) }, 450);
    if (a === 'out') flyTo({ ...st, dist: clamp(st.dist * 2.5, DMIN, DMAX) }, 450);
    if (a === 'view') cycleView();
    if (a === 'live') goMostActive();
    if (a === 'home') { drift = null; flyTo(homeState(), 1600); }
    if (a === 'np') { const all = index.totals.pages || pages.length, pre = [1, 12, 60, 300, 1000, all], i = pre.findIndex(v => v > sel.length); setPillars(pre[i < 0 ? 0 : i]); }
  });

  function flyTo(to, ms = 1400) {
    const from = { ...st }; let dy = to.yaw - from.yaw; while (dy > Math.PI) dy -= 2 * Math.PI; while (dy < -Math.PI) dy += 2 * Math.PI;
    to = { ...from, ...to, yaw: from.yaw + dy };
    const la = Math.log(from.dist), lb = Math.log(to.dist), dxy = Math.hypot(to.tx - from.tx, to.ty - from.ty);
    const need = Math.log(Math.max(from.dist, to.dist, dxy * 1.1)), bump = Math.max(0, need - Math.max(la, lb));
    return new Promise(res => { anim = { from, to, la, lb, bump, start: performance.now(), ms, res }; dirty = true; });
  }
  function stepAnim(now) {
    const a = anim, t = clamp((now - a.start) / a.ms, 0, 1), e = t * t * t * (t * (t * 6 - 15) + 10); // smootherstep: zero velocity and acceleration at both ends
    for (const k of ['tx', 'ty', 'tz', 'yaw', 'pitch']) st[k] = a.from[k] + (a.to[k] - a.from[k]) * e;
    st.dist = Math.exp(a.la + (a.lb - a.la) * e + a.bump * Math.sin(Math.PI * e));
    applyCamera();
    if (t >= 1) { anim = null; a.res(); }
  }
  /* ---------------- activity: where the library is alive right now ---------------- */
  let anyActive = false;
  function stepActivity(dt, now) {
    glowMat.uniforms.uTime.value = now / 1000;
    const arr = glow.geometry.attributes.act.array; let active = false;
    const decay = Math.exp(-dt / (opts.activityHalfLife ?? 45) * Math.LN2);
    for (let i = 0; i < sel.length; i++) {
      const p = sel[i]; if (p.act > 0.001) { const was = p.act > 0.25; p.act *= decay; if (was !== p.act > 0.25) { books[p.b].liveDirty = true; if (!(p.act > 0.25)) { p.newLines = 0; p.hl = p.baseHl; p.sub = p.baseSub.slice(); p.labelV++; } } active = true; } else p.act = 0;
      arr[i] = p.act;
    }
    for (const b of books) if (b.liveDirty) { b.liveDirty = false; b.liveN = b.pages.filter(id => pages[id].act > 0.25).length; }
    glow.geometry.attributes.act.needsUpdate = true;
    if (active || anyActive) dirty = true; // pulsing needs frames only while something is alive
    anyActive = active;
  }
  function bumpActivity(p, amount = 0.45) { const was = p.act > 0.25; p.act = Math.min(1, p.act + amount); if (was !== p.act > 0.25) books[p.b].liveDirty = true; dirty = true; }
  function mostActive() { return sel.filter(p => p.act > 0.05).sort((a, b) => b.act - a.act); }
  let liveIdx = 0;
  function joinPage(p) { // glide to where the conversation is happening: the newest line
    if (!p) return; drift = null;
    if (!p.msgs) { requestTile(p.tile, true); return flyTo(capViewFor(p), 2200); }
    const m = p.msgs[p.msgs.length -