<!-- SOLUV · part 481 -->
lass="sp-title">SOLUV <b>SPACE</b></div><div class="sp-status" role="status">Starting…</div></div>
  <button class="sp-voice" type="button" aria-label="Read aloud and voices">Voice</button>
  <canvas class="sp-canvas" aria-label="Soluv Space: the live lobby tower rising into the stars"></canvas>
  <div class="sp-alt" aria-hidden="true"><div class="sp-alt-bar"><i></i><i></i><i></i><i></i><i></i><b class="sp-alt-mark"></b></div><div class="sp-alt-n">0</div><div class="sp-alt-u">ALT</div></div>
  <a class="sp-archive" hidden target="_blank" rel="noopener"></a>
  <div class="sp-bottom">
  <form class="sp-compose sp-away" autocomplete="off"><input class="sp-input" maxlength="16000" enterkeyhint="send" aria-label="Write to the lobby" placeholder="Checking who you are…"><button class="sp-send" type="submit">Send</button><a class="sp-login" href="/login?next=%2Fsoluv%2Fspace%2F%3Fview%3Dread">Log in to post</a></form>
  <button class="sp-goto" type="button" aria-label="Read the newest lines and post">\u270e Read &amp; post</button>
  <div class="sp-ctrls">
    <button class="sp-btn sp-rise" data-a="rise" aria-label="Ride up the tower">Rise</button>
    <button class="sp-btn" data-a="base" aria-label="Stand at the base looking up">Base</button>
    <button class="sp-btn" data-a="mid" aria-label="Halfway up">Mid</button>
    <button class="sp-btn" data-a="top" aria-label="The crown, looking down">Top</button>
    <button class="sp-btn" data-a="far" aria-label="The whole tower from space">Far</button>
    <button class="sp-btn sp-live" data-a="live" aria-label="Read: one screen-wide column, swipe up and down">Read</button>
    <a class="sp-btn sp-home" href="/soluv/" aria-label="Back to the /soluv tower">Soluv</a>
  </div></div>`;
  container.appendChild(wrap);
  const $ = s => wrap.querySelector(s);
  const canvas = $('canvas'), statusEl = $('.sp-status'), altN = $('.sp-alt-n'), altMark = $('.sp-alt-mark');

  const gl2 = canvas.getContext('webgl2', { antialias: true });
  if (!gl2) { statusEl.textContent = 'This browser has no WebGL2: open the /soluv room instead.'; throw new Error('no WebGL2'); }
  const renderer = new THREE.WebGLRenderer({ canvas, context: gl2, antialias: true, logarithmicDepthBuffer: true });
  const PR = Math.min(2, devicePixelRatio || 1); renderer.setPixelRatio(PR);
  const scene = new THREE.Scene(); scene.background = new THREE.Color(0x000000);
  const camera = new THREE.PerspectiveCamera(55, 1, 0.05, 40000);
  const cssW0 = () => canvas.clientWidth || innerWidth;
  const uT = { value: 0 };
  let need = true, me = null;   // declared first: the wall and the crown ask for a redraw while the scene is built

  /* ---------- deep space: nebula + grid dome, and stars, both travel with the camera (no ceiling anywhere) ---------- */
  const sky = new THREE.Mesh(new THREE.SphereGeometry(15000, 64, 32), new THREE.ShaderMaterial({ vertexShader: SKY_VS, fragmentShader: SKY_FS, uniforms: { uT }, side: THREE.BackSide, depthWrite: false, depthTest: false }));
  sky.name = 'space dome (follows camera)'; sky.renderOrder = -10; scene.add(sky);
  const N = 3600, pos = new Float32Array(N * 3), size = new Float32Array(N), phase = new Float32Array(N), col = new Float32Array(N * 3);
  let seed = 7; const rnd = () => ((seed = Math.imul(seed ^ (seed >>> 15), 2246822507) + 0x9e3779b9 | 0) >>> 0) / 4294967296;
  const bandN = new THREE.Vector3(0.55, 0.30, 0.78).normalize(), v = new THREE.Vector3();
  for (let i = 0; i < N; i++) {
    do { v.set(rnd() * 2 - 1, rnd() * 2 - 1, rnd() * 2 - 1); } while (v.lengthSq() > 1 || v.lengthSq() < 1e-4);
    v.normalize(); if (i % 3 === 0) { v.addScaledVector(bandN, -v.dot(bandN) * 0.85).normalize(); }   // a third crowd the galaxy band
    pos.set([v.x * 12000, v.y * 12000, v.z * 12000], i * 3);
    const b = rnd(); size[i] = b > 0.985 ? 4.2 : b > 0.9 ? 2.6 : 1.3 + rnd(); phase[i] = rnd();
    const t = rnd(); const c = t < 0.15 ? [1.0, 0.85, 0.6] : t < 0.4 ? [0.6, 0.85, 1.0] : [0.95, 0.97, 1.0]; const k = 0.55 + 0.45 * rnd();
    col.set([c[0] * k, c[1] * k, c[2] * k], i * 3);
  }
  const sg = new THREE.BufferGeometry(); sg.setAttribute('position', new THREE.BufferAttribute(pos, 3)); sg.setAttribute('aSize', new THREE.BufferAttribute(size, 1)); sg.setAttribute('aPhase', new THREE.BufferAttribute(phase, 1)); sg.setAttribute('aColor', new THREE.BufferAttribute(col, 3));
  const stars = new THREE.Points(sg, new THREE.ShaderMaterial({ vertexShader: STAR_VS, fragmentShader: STAR_FS, uniforms: { uT, uPR: { value: PR } }, transparent: true, depthWrite: false, depthTest: false, blending: THREE.AdditiveBlending }));
  stars.name = 'stars (screen points, follow camera)'; stars.renderOrder = -9; stars.frustumCulled = false; scene.add(stars);

  /* ---------- the platform: the 2000-block map as an obsidian disc floating in space ---------- */
  const floor = new THREE.Mesh(new THREE.CylinderGeometry(PLATFORM_R, PLATFORM_R, 2 * MINIMUM, 128, 1), new THREE.ShaderMater