<!-- SOLUV · part 491 -->
, same posts, not a copy); wall 2 = Playa's /soluv room; others: not connected yet
  const origin = opts.origin || '';
  const source = opts.source !== undefined ? opts.source
    : opts.api ? { type: 'api', base: opts.api }
    : room === 1 ? { type: 'lobby', base: origin + '/api/chat/lobby', name: 'Lobby' }
    : room === 2 ? { type: 'api', base: origin + '/api/soluv-room', name: 'Soluv room' } : null;
  const fontUrl = opts.fontUrl || new URL('../fonts/RobotoCondensed-var.woff2', MODULE_URL).href;
  const meta = Object.assign({ mode: 'text', access: 'text', maxCams: 20, cams: 0 }, opts.meta || {});
  const testLabel = opts.testLabel || null;
  // King's law: 40 wide x 2000 tall (200 gold blocks) from the start; the glass does not grow. When the chat outgrows it, the oldest part
  // becomes the archive (a link pinned at the top opens the full room) and the newest keeps flowing on the glass.
  const WIDTH = Math.max(8, +opts.width || 4 * BLOCK), MIN_H = Math.max(8, +opts.minHeight || 200 * BLOCK), MAX_H = Math.max(MIN_H, +opts.maxHeight || MIN_H);
  const archiveUrl = opts.archiveUrl || (source && source.type === 'lobby' ? origin + '/chat/lobby' : source && source.type === 'api' ? origin + '/soluv/room.html' : null);

  const root = new T.Group(); root.name = 'soluv chat wall ' + room;
  const p = opts.position || [0, 0, 0];
  if (Array.isArray(p)) root.position.set(p[0] || 0, p[1] || 0, p[2] || 0); else root.position.copy(p);
  if (opts.rotation && opts.rotation.isEuler) root.rotation.copy(opts.rotation); else root.rotation.y = +opts.rotationY || (Array.isArray(opts.rotation) ? opts.rotation[1] : 0) || 0;
  const scale = Math.max(1, +opts.scale || 1);           // THE LAW: never smaller than the minimum
  root.scale.setScalar(scale);
  const slab = new T.Group(); slab.name = 'slab'; root.add(slab);
  const textGroup = new T.Group(); textGroup.name = 'text'; root.add(textGroup);

  // ONE column, top to bottom, across the full width (never folds into columns). Textures are cut into 26-em lanes
  // x 16-row chunks only so each stays small and loads by visibility; the text itself is one continuous column.
  const Wem = WIDTH / EM;
  const COLS = 1, PADX = L.PADX_MIN, TEXT_W = Wem - 2 * PADX, SEG_W = L.COL_W, SEGS = Math.ceil(TEXT_W / SEG_W);
  const MIN_Hem = MIN_H / EM, MAX_Hem = MAX_H / EM;
  const HEAD = opts.header ? L.HEAD : 2;                  // King: one table all the way up, nothing extra on the wall
  const Rmin = Math.max(L.LIVE_MIN_ROWS + 1, Math.floor((MIN_Hem - HEAD - L.PADB - 1) / L.LINE));
  const Rmax = Math.max(Rmin, Math.floor((MAX_Hem - HEAD - L.PADB - 1) / L.LINE));
  const colX = () => -Wem / 2 + PADX;
  const laneX = i => colX() + i * SEG_W;

  const S = {
    msgs: [], lines: [], ids: new Set(), R: Rmin, liveRows: L.LIVE_MIN_ROWS, A: 0, Hem: MIN_Hem, textTop: 0, textBottom: 0,
    status: { state: source ? 'idle' : 'none', text: source ? 'Waiting until you look at it' : 'No room connected to ' + route + ' yet' },
    started: false, lastId: 0, cursor: 0, es: null, bytes: 0, sseBytes: 0, archBytes: 0, frame: -1, pending: 0, sessions: new Map(),
    tiles: new Map(), texBytes: 0, renders: 0, errors: [], arch: [], lastMsgT: 0,
  };
  const materials = [];

  const glassMat = new T.ShaderMaterial({ vertexShader: VS, fragmentShader: GLASS_FS, transparent: true, depthWrite: true, // writes depth: text of a wall behind never shows through
    uniforms: { uEm: { value: EM }, uSize: { value: new T.Vector2(1, 1) }, uPadX: { value: PADX }, uColW: { value: TEXT_W }, uGut: { value: 0 }, uCols: { value: 1 }, uHead: { value: HEAD }, uPadB: { value: L.PADB }, uLiveR: { value: new T.Vector4(0, 0, 0, 0) }, uActivity: { value: 0.3 } } });
  glassMat.extensions = { derivatives: true };
  const haloMat = new T.ShaderMaterial({ vertexShader: VS, fragmentShader: HALO_FS, transparent: true, depthWrite: false, blending: T.AdditiveBlending,
    uniforms: { uEm: { value: EM }, uSize: { value: new T.Vector2(1, 1) }, uInner: { value: new T.Vector2(1, 1) } } });
  const edgeMat = new T.MeshBasicMaterial({ color: 0x5ff4ff }); edgeMat.toneMapped = false;
  const plinthMat = new T.MeshBasicMaterial({ color: 0x070d18 });
  const rimMat = new T.MeshBasicMaterial({ color: 0xc9a227 }); rimMat.toneMapped = false;   // gold rim: it stands on Playa's gold blocks
  materials.push(glassMat, haloMat, edgeMat, plinthMat, rimMat);
  let glass = null, avatar = null;

  /* ----- layout ----- */
  function relayout() {
    const maxW = TEXT_W - 0.3;
    const all = []; let lastSpk = null;
    S.msgs.forEach((m, mi) => {
      const key = m.user || ('guest:' + (m.from || 'Guest'));
      const king = !!m.king || String(m.user || '').toLowerCase() === KING.toLowerCase();
      const newspk = key !== lastSpk; lastSpk = key;
      const prefix = [];
      const slot = m.live ? 'cam' : (m.lastFrame ? 'still' : null);
      if (slot) prefix.push({ s: slot, k: slot, w: 1.25, gap: 0.3 });
      if (newspk) { c