<!-- SOLUV · part 492 -->
onst nm = shortName(m, king); prefix.push({ s: nm, k: king ? 'ktag' : 'tag', w: mw(nm, true), gap: 0.3 }); }
      const tail = m.recLink ? '\u23fa watch their full part' : null;
      const extra = (newspk ? 'n' : '') + (slot || '') + (tail ? 'r' : '');
      if (!m._wrap || m._wrapKey !== extra) { m._wrap = wrapMessage(prefix, m.text, king ? 'ktext' : 'text', maxW, tail); m._wrapKey = extra; }
      m._wrap.forEach((runs, i) => all.push({ runs, newspk: newspk && i === 0, color: tagColor(key), mi, ext: runs.reduce((a, r) => Math.max(a, r.x + (r.w || 0)), 0) + 0.3, key: m.id + ':' + i + ':' + extra }));
    });
    // capacity: the glass holds Rmax rows. Older whole messages beyond that go to the archive (link pinned at the top).
    let first = 0; const room4 = Rmax - 1;                          // one row for the link to what does not fit
    if (all.length > room4) { first = all.length - room4; const mi0 = all[first].mi; if (first > 0 && all[first - 1].mi === mi0) { while (first < all.length && all[first].mi === mi0) first++; } }
    const lines = first ? all.slice(first) : all;
    S.archivedMsgs = first ? lines[0].mi : 0; S.archivedLines = first; S.first = first;
    // the link to what does not fit sits right after the last message that fits: the far (top) end of the table
    if (first && archiveUrl) { lines.unshift({ runs: [], more: true, ext: TEXT_W, key: 'more' + S.archivedMsgs }); S.first = first - 1; }
    S.lines = lines;
    S.R = Rmax; S.liveRows = S.R; S.A = -1;                         // the glass is its full size from the start
    S.Hem = Math.max(MIN_Hem, L.PADB + S.R * L.LINE + 1 + HEAD);
    S.textBottom = L.PLINTH + L.PADB; S.textTop = S.textBottom + S.R * L.LINE;
    buildSlab(); placeTiles();
    root.dispatchEvent({ type: 'change' });
  }
  const rowTop = r => S.textTop - r * L.LINE;
  // the one column sits on the BOTTOM of the glass: newest line at eye level, the story rising above it to the top,
  // where the archive link waits once it is full. base = row of the oldest line shown.
  const baseRow = () => S.R - S.lines.length;
  function cell(c, r) {
    if (!S.msgs.length) return r === S.R - 3 ? { k: 'notice', key: 'n' + S.status.state + S.status.text, ext: TEXT_W } : r > S.R - 3 ? null : { k: 'blank', key: 'b' };
    const ln = S.lines[r - baseRow()];
    if (!ln) return { k: 'blank', key: 'b' };
    if (ln.more) return { k: 'more', key: ln.key };
    return { k: 'live', ln, key: ln.key };
  }

  /* ----- slab: re-extruded whenever the conversation grows ----- */
  function buildSlab() {
    for (const o of [...slab.children]) { slab.remove(o); if (o.geometry) o.geometry.dispose(); }
    const W = Wem * EM, H = S.Hem * EM, D = L.DEPTH * EM, y0 = L.PLINTH * EM, e = L.EDGE * EM;
    const add = (geo, mat, x, y, z, name, order) => { const m = new T.Mesh(geo, mat); m.position.set(x, y, z); m.name = name; if (order !== undefined) m.renderOrder = order; slab.add(m); return m; };
    add(new T.BoxGeometry(W + 8 * EM, y0, D + 10 * EM), plinthMat, 0, y0 / 2, 0, 'plinth');
    add(new T.BoxGeometry(W + 8 * EM, EM, EM), rimMat, 0, y0 - EM / 2, D / 2 + 5 * EM - EM / 2, 'plinth gold rim');
    glass = add(new T.BoxGeometry(W, H, D), glassMat, 0, y0 + H / 2, 0, 'glass slab', 1);
    glassMat.uniforms.uSize.value.set(Wem, S.Hem);
    { const r = S.R - 1, gx = colX() + Wem / 2;   // glass-local em (0..Wem, 0..Hem): glow behind the newest lines
      const yTop = rowTop(Math.max(0, r - 12)) - L.PLINTH, yBot = rowTop(r + 1) - L.PLINTH; glassMat.uniforms.uLiveR.value.set(gx, yBot, gx + TEXT_W, yTop); }
    glass.onBeforeRender = (renderer, scene, camera) => update(camera, renderer);
    add(new T.BoxGeometry(W + 40 * EM, H + 40 * EM, EM), haloMat, 0, y0 + H / 2, -D / 2 - 1.5 * EM, 'halo', 0);
    haloMat.uniforms.uSize.value.set(Wem + 40, S.Hem + 40); haloMat.uniforms.uInner.value.set(Wem, S.Hem);
    for (const z of [D / 2, -D / 2]) {
      add(new T.BoxGeometry(W + e, e, e), edgeMat, 0, y0 + H, z, 'edge', 2);
      add(new T.BoxGeometry(W + e, e, e), edgeMat, 0, y0, z, 'edge', 2);
      add(new T.BoxGeometry(e, H, e), edgeMat, -W / 2, y0 + H / 2, z, 'edge', 2);
      add(new T.BoxGeometry(e, H, e), edgeMat, W / 2, y0 + H / 2, z, 'edge', 2);
    }
    for (const [x, y] of [[-W / 2, y0], [W / 2, y0], [-W / 2, y0 + H], [W / 2, y0 + H]]) add(new T.BoxGeometry(e, e, D), edgeMat, x, y, 0, 'edge', 2);
    if (opts.avatar && !avatar) { avatar = createAvatar(T); root.add(avatar); }
    if (avatar) avatar.position.set(-W / 2 - 9 * EM, 0, D / 2 + 3 * EM);   // on the map, beside the wall's left foot
    slab.children.forEach(o => applyMinimum(o, T));
    activity();
  }
  function activity() { const age = Date.now() - (S.lastMsgT || 0); glassMat.uniforms.uActivity.value = !S.lines.length ? 0.25 : age < 15 * 60000 ? 1.0 : age < 6 * 3600000 ? 0.6 : 0.4; }

  /* ----- text tiles: column x 16-row chunks; each row belongs to exactly one tile, tiles never overlap ----- */
  const ti