<!-- SOLUV · part 490 -->
ring(text).split('\n').forEach((para, pi) => {
    if (pi > 0) push();
    let pend = ' ';
    for (const tok of para.split(/(\s+)/)) {
      if (!tok) continue;
      if (/^\s+$/.test(tok)) { pend = tok.replace(/\t/g, '    '); continue; }
      word(tok, kind, pend); pend = ' ';
    }
  });
  if (tail) word(tail, 'rec', ' ');
  push();
  return lines;
}

/* ---------- shaders (GLSL that compiles on three r128 and r186; log-depth safe) ---------- */
const VS = `#include <common>
#include <logdepthbuf_pars_vertex>
uniform float uEm; uniform vec2 uSize;
varying vec3 vL; varying vec3 vN; varying vec3 vV;
void main(){
  vL = vec3(position.x / uEm + uSize.x * 0.5, position.y / uEm + uSize.y * 0.5, position.z / uEm);
  vec4 wp = modelMatrix * vec4(position, 1.0);
  vN = normalize(mat3(modelMatrix) * normal); vV = cameraPosition - wp.xyz;
  gl_Position = projectionMatrix * viewMatrix * wp;
  #include <logdepthbuf_vertex>
}`;
const GLASS_FS = `#include <logdepthbuf_pars_fragment>
uniform vec2 uSize; uniform float uPadX, uColW, uGut, uCols, uHead, uPadB; uniform vec4 uLiveR; uniform float uActivity;
varying vec3 vL; varying vec3 vN; varying vec3 vV;
void main(){
  #include <logdepthbuf_fragment>
  vec3 col = vec3(0.008, 0.024, 0.045);
  float t = clamp(vL.y / uSize.y, 0.0, 1.0);
  col += vec3(0.0, 0.035, 0.06) * (1.0 - t);
  float d = min(min(vL.x, uSize.x - vL.x), min(vL.y, uSize.y - vL.y));
  float eg = exp(-max(d, 0.0) / 6.0);
  col += vec3(0.12, 0.70, 0.95) * eg * 0.32;
  float fr = pow(1.0 - abs(dot(normalize(vN), normalize(vV))), 2.5);
  col += vec3(0.15, 0.75, 1.0) * fr * 0.22;
  float a = 0.82 + eg * 0.12;
  if (abs(vN.z) > 0.5) {
    float x = vL.x - uPadX, cell = uColW + uGut, m = mod(x, cell) - (uColW + uGut * 0.5);
    float fw = max(fwidth(vL.x), 1e-4);
    float g = (x > uColW && x < uCols * cell - uGut) ? (1.0 - smoothstep(0.05, 0.05 + fw * 1.5, abs(m))) : 0.0;
    col += vec3(0.05, 0.30, 0.40) * g * 0.8;
    col += vec3(0.0, 0.05, 0.07) * (0.5 + 0.5 * sin(vL.y * 4.8332)) * 0.22;
    float hy = uSize.y - uHead; float fy = max(fwidth(vL.y), 1e-4);
    col += vec3(0.25, 0.85, 1.0) * (1.0 - smoothstep(0.15, 0.15 + fy * 1.5, abs(vL.y - hy))) * 0.7;
    // activity glow around the newest lines (the live edge), brighter when the room is talking
    vec2 lc = vec2(clamp(vL.x, uLiveR.x, uLiveR.z), clamp(vL.y, uLiveR.y, uLiveR.w));
    float ld = length(vL.xy - lc);
    col += vec3(0.10, 0.85, 0.65) * uActivity * (ld <= 0.0 ? 0.12 : exp(-ld / 6.0) * 0.30);
  }
  gl_FragColor = vec4(col, a);
}`;
const HALO_FS = `#include <logdepthbuf_pars_fragment>
uniform vec2 uSize; uniform vec2 uInner;
varying vec3 vL; varying vec3 vN; varying vec3 vV;
void main(){
  #include <logdepthbuf_fragment>
  vec2 p = abs(vL.xy - uSize * 0.5) - uInner * 0.5;
  float d = length(max(p, 0.0));
  float a = (d <= 0.0 ? 0.18 : exp(-d / 3.2) * 0.55);
  gl_FragColor = vec4(vec3(0.20, 0.80, 1.0) * a, a);
}`;

/* ---------- avatar: 12 minimums tall (1.56), eyes exactly one minimum (0.13) ---------- */
export function createAvatar(T, color = 0x2f5fb0) {
  const g = new T.Group(); g.name = 'avatar (12 minimums = 1.56)';
  const mat = c => new T.MeshBasicMaterial({ color: c });
  const box = (w, h, d, x, y, z, m, name) => { const b = new T.Mesh(new T.BoxGeometry(w * EM, h * EM, d * EM), m); b.position.set(x * EM, y * EM, z * EM); b.name = name; g.add(b); return b; };
  const skin = mat(0xe0b58a), body = mat(color), legs = mat(0x1d2440), eye = new T.MeshBasicMaterial({ color: 0x7ff6ff });
  box(1.4, 5, 1.4, -0.9, 2.5, 0, legs, 'leg'); box(1.4, 5, 1.4, 0.9, 2.5, 0, legs, 'leg');
  box(4, 4.5, 2, 0, 7.25, 0, body, 'torso'); box(1, 4.2, 1, -2.6, 7.3, 0, body, 'arm'); box(1, 4.2, 1, 2.6, 7.3, 0, body, 'arm');
  box(2.6, 2.5, 2.4, 0, 10.75, 0, skin, 'head');
  box(1, 1, 1, -0.6, 11, 1.2, eye, 'eye (one minimum)'); box(1, 1, 1, 0.6, 11, 1.2, eye, 'eye (one minimum)');
  return g;
}

/* ---------- the wall ----------
 * Face: width = opts.width (default 40 units = 4 gold blocks), height starts at opts.minHeight (40 = 4 gold blocks)
 * and grows upward with the conversation (no ceiling). The face is filled edge to edge with dense 26-em columns:
 *   top: headline band (SOLUV N, route, live status, access/mode/cams placeholders)
 *   below: the conversation in dense columns, read top to bottom, left to right; newest at the bottom right
 * The whole face is ONE story (wall 1: the site lobby), top to bottom; when it outgrows the face the wall rises.
 */
export function createChatWall(opts = {}) {
  const T = opts.THREE || (typeof window !== 'undefined' && window.THREE);
  if (!T || !T.Group) throw new Error('createChatWall needs THREE: pass { THREE } (the page\'s own three.js)');
  const room = Math.max(1, opts.room | 0 || 1);
  const route = opts.route || ('/soluv/' + room);
  const title = opts.title || (room === 1 ? 'The Lobby' : room === 2 ? 'The Soluv Room' : 'Room ' + room);
  // wall 1 = the site LOBBY itself (same messages