<!-- SOLUV · part 457 -->
m, s: 0, e: m.sl ? m.sl - 2 : 0 }); continue; }
        const r = re.exec(m.text.slice(m.sl)); if (r && txt.length < 5000) txt.push({ type: 'msg', p, m, s: m.sl + r.index, e: m.sl + r.index + r[0].length });
      }
    }
    results = [...spk, ...heads, ...txt]; ri = 0;
    stats.lastSearchMs = performance.now() - t;
    if (!results.length) { statusEl.textContent = `No match for “${q}”`; nextBtn.disabled = true; nextBtn.textContent = '▶'; return results; }
    nextBtn.disabled = results.length < 2;
    await goResult(0);
    return results;
  }
  async function goResult(i) {
    const r = results[i]; if (!r) return; ri = i;
    const b = books[r.p.b];
    nextBtn.innerHTML = `${i + 1}/${results.length > 999 ? '999+' : results.length}&nbsp;&#9654;`;
    if (r.type === 'page') {
      highlight = null; hitMarker.visible = false;
      statusEl.textContent = `${b.title} · headline`;
      const p = 30 / r.p.hlEm; // headline ~30px, subheads readable
      drift = null; await flyTo(capViewFor(r.p), 2200);
      return;
    }
    highlight = { pid: r.p.id, mi: r.m.i, s: r.s, e: r.e, v: (highlight?.v || 0) + 1 };
    { const h = wp(r.p, 0.22, r.m.ly + 0.62, 0.3); hitMarker.geometry.attributes.position.setXYZ(0, h.x, h.y, h.z); } hitMarker.geometry.attributes.position.needsUpdate = true; hitMarker.visible = true;
    statusEl.textContent = `${b.title} · ${r.p.hl} · ${r.m.spk}`;
    drift = null; await flyTo(nearViewFor(r.m, r.p), 2200);
  }
  wrap.querySelector('.ws-search').addEventListener('submit', e => { e.preventDefault(); qEl.blur(); search(qEl.value); });
  nextBtn.addEventListener('click', () => results.length && goResult((ri + 1) % results.length));

  /* ---------------- live headline API ---------------- */
  function setHeadline(pageId, headline, subheads) {
    const p = pages[pageId]; if (!p) return false;
    if (headline != null) p.hl = String(headline);
    if (subheads) p.sub = subheads.slice(0, 2).map(String);
    p.hsrc = 'live'; p.labelV++; dirty = true; return true;
  }
  const liveStars = { pos: [], col: [], size: [], lines: [], obj: null, lobj: null };
  function addLiveStar(m, p) {
    const prev = p.msgs[p.msgs.length - 2];
    const a = wp(p, 0.22, m.ly + 0.62, 0.3); liveStars.pos.push(a.x, a.y, a.z); liveStars.col.push(...(m.g ? C.gold : C.live)); liveStars.size.push(1.0);

    if (liveStars.obj) { world.remove(liveStars.obj, liveStars.lobj); liveStars.obj.geometry.dispose(); liveStars.lobj.geometry.dispose(); }
    const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.Float32BufferAttribute(liveStars.pos, 3));
    g.setAttribute('color', new THREE.Float32BufferAttribute(liveStars.col, 3)); g.setAttribute('size', new THREE.Float32BufferAttribute(liveStars.size, 1));
    liveStars.obj = new THREE.Points(g, ptsMat); liveStars.obj.frustumCulled = false;
    const lg = new THREE.BufferGeometry(); lg.setAttribute('position', new THREE.Float32BufferAttribute(liveStars.lines, 3));
    liveStars.lobj = new THREE.LineSegments(lg, linkMat); liveStars.lobj.frustumCulled = false; world.add(liveStars.obj, liveStars.lobj);
  }
  // The wall grows upward without a cap: everything on it (anchored to its top) rises by dh, the glass stretches from the ground.
  function growWall(p, dh) {
    world.traverse(o => {
      if (!o.geometry || o.geometry.isInstancedBufferGeometry || o === avatar || o.parent === avatar || !o.geometry.attributes.position) return;
      const a = o.geometry.attributes.position; let ch = false;
      for (let i = 0; i < a.count; i++) { const z = a.getZ(i); if (z > 2) { a.setZ(i, z + dh); ch = true; } }
      if (ch) { a.needsUpdate = true; o.geometry.computeBoundingBox(); o.geometry.computeBoundingSphere(); }
    });
    for (let i = 2; i < liveStars.pos.length; i += 3) if (liveStars.pos[i] > 2) liveStars.pos[i] += dh;
    p.h += dh; p.z += dh; if (p.msgs) for (const m of p.msgs) m.z += dh;
    stats.wallGrowth = (stats.wallGrowth || 0) + dh; dirty = true;
  }
  function addMessage(pageId, speaker, text, simulated = false) { // append to the page's last column; headlines can then be rewritten live
    const p = pages[pageId]; if (!p || !p.on || !p.msgs) return null;
    if (p.locked) { statusEl.textContent = 'Sealed pillar: read-only'; return null; } // locked = read-only in the prototype
    const last = p.msgs[p.msgs.length - 1];
    const sl = speaker.length + 1, full = speaker + ' ' + text.replace(/\s+/g, ' ').trim(); // dense-room tag: 'name text'
    const lines = wrapText(full, L.colChars);
    const lx = last.lx, ly = last.ly + last.n * L.lineH + (last.spk === speaker ? 0 : L.spkGap);
    const need = ly + lines.length * L.lineH + 2 - p.h; if (need > 0 && castle.single) growWall(p, need); // unlimited sky: the wall rises
    const w0 = wp(p, lx, ly);
    const live = p.mode === 'video' && speaker !== 'Narrator', camId = live ? camFor(p, speaker) : null;
    if (live) for (let j = p.msgs.length - 1; j >= 0; j--) { const q =