Models 44

MiniMax M3.1 Flash

by MiniMaxReasoningClean
Open in new window

Running live in a sandboxed frame. Click inside it before using the keyboard.

How it went

Total time
4 min 5 s
Cost
$0.029
Output tokens
47,849
Spent thinking
30,994 tokens
Speed
195.7 tok/s
Console errors
0
First token after
575 ms
First visible text after
3 min 8 s
Prompt tokens
591
Code size
46.3 KB
Finish reason
stop
Served by
Together
Model id
minimax/minimax-m3.1-flash
Reasoning sent
model default
Max output
524,288 tokens
Generated
Nov 13, 2026

What it was asked

The prompt
“a 3D aquarium with Three.js where the fish swim away when I move the mouse near them (Load Three.js only through this import map: <script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"}}</script>)”
System prompt, sent with every prompt
You are generating a self-contained web demo. It will be rendered automatically in a sandboxed iframe and compared side by side with the output of other AI models given the exact same task.

Output rules:
- Reply with exactly ONE fenced code block containing the complete file: ```html (or ```svg when the task asks for an SVG). No text before or after the code block.
- Everything must be inline: CSS in <style>, JavaScript in <script>. No external files, images, fonts or network requests, except the CDN URLs explicitly allowed by the task.
- Do not use localStorage, sessionStorage, cookies, IndexedDB, alert(), confirm(), prompt() or window.open(): they are blocked in the sandbox and will throw.
- The page must look right in a 1280×800 viewport and adapt gracefully down to 375px wide. No horizontal scrollbar.
- Something meaningful must be visible on first paint: a screenshot is taken automatically about 3 seconds after load, without any interaction.
- Everything that makes the demo interesting must be visible within the first seconds: nothing hidden behind a high score, a later level or a long play session.
- Games must start in a self-playing demo mode (a simple AI plays) behind a "Click or press a key to start" message, so the game can be judged in action without playing.
- The iframe only receives keyboard events after the user clicks it: if the demo uses the keyboard, display a clear "Click or press a key to start" message.
- The code must run with zero errors in the console of the latest Chrome.

Code run-1.html · 1110 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
<title>Pelagic — The Shy Ones</title>
<style>
  :root{
    --ink:#eaf8ff;
    --muted:rgba(208,236,248,.62);
    --accent:#7df0ff;
    --warm:#ffb478;
    --line:rgba(150,235,255,.16);
    --glass:linear-gradient(160deg,rgba(13,35,48,.66),rgba(6,18,26,.38));
  }
  *{box-sizing:border-box}
  html,body{height:100%;margin:0;overflow:hidden;background:#020a12;color:var(--ink);
    font-family:"Inter","SF Pro Text","Segoe UI",system-ui,-apple-system,sans-serif;
    -webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}
  #scene{position:fixed;inset:0;z-index:0}
  canvas{display:block;width:100%;height:100%;touch-action:none}
  .vignette{position:fixed;inset:0;z-index:2;pointer-events:none;
    background:
      radial-gradient(125% 92% at 50% 44%,rgba(0,0,0,0) 38%,rgba(1,7,12,.62) 100%),
      linear-gradient(180deg,rgba(2,10,16,.55) 0%,rgba(0,0,0,0) 22%,rgba(0,0,0,0) 74%,rgba(2,9,14,.55) 100%);}
  .hud{position:fixed;z-index:4;pointer-events:none;user-select:none}

  /* ---------- brand ---------- */
  .brand{top:30px;left:34px;max-width:320px}
  .tag{display:inline-flex;align-items:center;gap:9px;font-size:10.5px;letter-spacing:.24em;
    text-transform:uppercase;color:var(--muted)}
  .pulse{width:7px;height:7px;border-radius:50%;background:var(--accent);
    box-shadow:0 0 0 0 rgba(125,240,255,.55);animation:pulse 2.6s infinite}
  @keyframes pulse{0%{box-shadow:0 0 0 0 rgba(125,240,255,.5)}70%{box-shadow:0 0 0 10px rgba(125,240,255,0)}100%{box-shadow:0 0 0 0 rgba(125,240,255,0)}}
  .brand h1{margin:15px 0 8px;font-size:clamp(34px,4.3vw,58px);line-height:.98;font-weight:250;
    letter-spacing:-.035em;text-shadow:0 6px 34px rgba(0,0,0,.55)}
  .brand h1 em{font-family:Georgia,"Times New Roman",serif;font-style:italic;font-weight:400}
  .brand h1 .dot{color:var(--accent)}
  .sub{margin:0;font-size:13px;line-height:1.6;color:var(--muted);max-width:265px;
    text-shadow:0 2px 16px rgba(0,0,0,.6)}

  /* ---------- stats ---------- */
  .stats{top:28px;right:30px;display:flex;gap:28px;padding:13px 20px;border:1px solid var(--line);
    border-radius:15px;background:var(--glass);backdrop-filter:blur(14px) saturate(1.25)}
  .stat span{display:block;font-size:9px;letter-spacing:.22em;text-transform:uppercase;color:var(--muted);margin-bottom:6px}
  .stat b{font-size:15px;font-weight:500;letter-spacing:.01em}
  #state{color:var(--accent);transition:color .3s}
  #state.alert{color:var(--warm)}

  /* ---------- legend ---------- */
  .legend{left:34px;bottom:30px;display:flex;gap:20px;flex-wrap:wrap;max-width:44vw}
  .legend div{display:flex;align-items:center;gap:8px;font-size:11px;letter-spacing:.05em;color:var(--muted)}
  .legend i{width:8px;height:8px;border-radius:50%;display:block;box-shadow:0 0 10px currentColor}
  .legend em{font-style:normal;opacity:.5;font-size:9.5px;letter-spacing:.14em;text-transform:uppercase}

  /* ---------- controls ---------- */
  .controls{right:30px;bottom:26px;display:flex;gap:9px;pointer-events:auto;z-index:6}
  .btn{display:inline-flex;align-items:center;gap:8px;padding:9px 14px;border-radius:999px;
    border:1px solid var(--line);background:rgba(8,24,34,.5);color:rgba(225,245,255,.82);
    font-size:11.5px;letter-spacing:.04em;cursor:pointer;backdrop-filter:blur(12px);
    transition:background .2s,border-color .2s,color .2s,transform .15s;font-family:inherit}
  .btn:hover{background:rgba(18,52,68,.62);border-color:rgba(125,240,255,.36);color:#eafcff}
  .btn:active{transform:translateY(1px)}
  .btn.active{border-color:rgba(125,240,255,.5);color:#ddfcff;background:rgba(17,66,86,.55);
    box-shadow:0 0 24px rgba(60,200,255,.16) inset}
  .btn svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:1.5}

  /* ---------- hint ---------- */
  .hint{left:50%;bottom:26px;transform:translateX(-50%);display:flex;align-items:center;gap:12px;
    padding:9px 15px 9px 9px;border-radius:999px;border:1px solid var(--line);background:rgba(6,20,29,.55);
    backdrop-filter:blur(12px);font-size:12px;color:var(--muted);white-space:nowrap;z-index:6}
  .chip{font-size:9.5px;letter-spacing:.15em;text-transform:uppercase;color:#03131a;background:var(--accent);
    padding:5px 10px;border-radius:999px;font-weight:700}

  /* ---------- cursor ---------- */
  .cursor{position:fixed;left:0;top:0;width:58px;height:58px;border-radius:50%;z-index:7;pointer-events:none;
    border:1px solid rgba(160,240,255,.5);opacity:0;
    box-shadow:0 0 28px rgba(70,215,255,.2),inset 0 0 24px rgba(70,215,255,.1);
    transform:translate(-50%,-50%) scale(1);transition:transform .25s cubic-bezier(.2,.8,.3,1),opacity .35s,border-color .25s;
    mix-blend-mode:screen}
  .cursor::after{content:"";position:absolute;left:50%;top:50%;width:5px;height:5px;margin:-2.5px 0 0 -2.5px;
    border-radius:50%;background:rgba(200,248,255,.95);box-shadow:0 0 12px rgba(160,240,255,.95)}
  .cursor.auto{border-style:dashed;border-color:rgba(160,240,255,.4)}
  .cursor.auto::before{content:"AUTO";position:absolute;top:calc(100% + 5px);left:50%;transform:translateX(-50%);
    font-size:8.5px;letter-spacing:.2em;color:rgba(160,240,255,.6)}
  .cursor.hot{transform:translate(-50%,-50%) scale(1.35);border-color:rgba(255,190,140,.85);
    box-shadow:0 0 38px rgba(255,160,90,.28),inset 0 0 26px rgba(255,170,100,.14)}

  /* ---------- loader / fallback ---------- */
  #loader{position:fixed;inset:0;z-index:30;display:flex;align-items:center;justify-content:center;
    background:radial-gradient(70% 60% at 50% 45%,#07222e,#01070c 72%);transition:opacity .9s ease,visibility .9s}
  #loader.gone{opacity:0;visibility:hidden}
  .loadbox{text-align:center}
  .loadbox h2{margin:0;font-size:11px;letter-spacing:.34em;text-transform:uppercase;color:rgba(200,235,250,.72);font-weight:500}
  .loadbox .bar{width:170px;height:2px;margin:16px auto 0;background:rgba(255,255,255,.1);overflow:hidden;position:relative}
  .loadbox .bar::after{content:"";position:absolute;inset:0;width:40%;background:linear-gradient(90deg,transparent,#7ef0ff,transparent);
    animation:slide 1.25s infinite ease-in-out}
  @keyframes slide{0%{transform:translateX(-130%)}100%{transform:translateX(330%)}}
  #fallback{position:fixed;inset:0;z-index:40;display:grid;place-items:center;text-align:center;padding:30px;
    background:#03101a;color:var(--muted);font-size:14px}

  @media (max-width:980px){
    .legend{display:none}
    .stats{gap:18px;padding:11px 15px}
  }
  @media (max-width:680px){
    .brand{top:18px;left:18px;max-width:62vw}
    .brand h1{font-size:33px}
    .sub{font-size:11.5px;max-width:225px}
    .stats{top:auto;bottom:126px;right:14px;left:14px;justify-content:space-between;border-radius:13px}
    .stat b{font-size:13.5px}
    .controls{right:14px;left:14px;bottom:18px;justify-content:center}
    .btn{padding:8px 12px;font-size:11px}
    .hint{left:14px;right:14px;bottom:186px;transform:none;justify-content:center;white-space:normal;
      text-align:center;flex-wrap:wrap;row-gap:6px;font-size:11px}
    .chip{font-size:9px}
  }
  @media (max-height:620px){ .sub{display:none} }
</style>
<script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"}}</script>
</head>
<body>
  <div id="scene"></div>
  <div class="vignette"></div>

  <div class="brand hud">
    <div class="tag"><span class="pulse"></span> Pelagic &middot; Tank 01</div>
    <h1>The shy <em>ones</em><span class="dot">.</span></h1>
    <p class="sub">A living reef in miniature. Bring your cursor close and watch the whole school scatter.</p>
  </div>

  <div class="stats hud">
    <div class="stat"><span>Residents</span><b id="count">16</b></div>
    <div class="stat"><span>Water</span><b>24.5&deg;C</b></div>
    <div class="stat"><span>State</span><b id="state">calm</b></div>
  </div>

  <div class="legend hud">
    <div><i style="background:#ff7a33;color:#ff7a33"></i>Ember <em>clown</em></div>
    <div><i style="background:#3f7ff0;color:#3f7ff0"></i>Cobalt <em>tang</em></div>
    <div><i style="background:#ffc542;color:#ffc542"></i>Solar <em>tang</em></div>
    <div><i style="background:#7ef0ff;color:#7ef0ff"></i>Neon <em>tetra</em></div>
  </div>

  <div class="controls hud">
    <button class="btn active" id="autoBtn" title="Toggle the automatic demo cursor (A)">
      <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="7"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3"/></svg>
      Auto demo
    </button>
    <button class="btn" id="rippleBtn" title="Send a ripple through the water">
      <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><circle cx="12" cy="12" r="8"/><path d="M12 12l6-6"/></svg>
      Ripple
    </button>
    <button class="btn" id="addBtn" title="Release a new resident (F)">
      <svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg>
      Resident
    </button>
  </div>

  <div class="hint hud" id="hint">
    <span class="chip" id="chip">Click or press a key to start</span>
    <span id="hintText">then drift your cursor through the water</span>
  </div>

  <div class="cursor" id="cursor"></div>

  <div id="loader"><div class="loadbox"><h2>Filling the tank</h2><div class="bar"></div></div></div>
  <div id="fallback" hidden>Your browser could not start WebGL, so the aquarium cannot be rendered here.</div>

<script type="module">
import * as THREE from 'three';

const $ = (s) => document.querySelector(s);

try { init(); }
catch (err) {
  const l = $('#loader'); if (l) l.classList.add('gone');
  const f = $('#fallback'); if (f) f.hidden = false;
}

function init() {
  const host = $('#scene');
  const VW = () => window.innerWidth;
  const VH = () => window.innerHeight;

  /* ============================ renderer / scene ============================ */
  const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
  renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
  renderer.setSize(VW(), VH());
  renderer.shadowMap.enabled = true;
  renderer.shadowMap.type = THREE.PCFSoftShadowMap;
  renderer.toneMapping = THREE.ACESFilmicToneMapping;
  renderer.toneMappingExposure = 1.14;
  renderer.outputColorSpace = THREE.SRGBColorSpace;
  host.appendChild(renderer.domElement);

  const scene = new THREE.Scene();
  scene.background = new THREE.Color(0x020a12);
  scene.fog = new THREE.FogExp2(0x06212d, 0.017);

  const camera = new THREE.PerspectiveCamera(40, VW() / VH(), 0.1, 120);
  camera.position.set(0.9, 3.35, 15.4);
  const camLook = new THREE.Vector3(0, 2.95, 0);
  camera.lookAt(camLook);

  const maxAniso = renderer.capabilities.getMaxAnisotropy();

  /* ============================ textures ============================ */
  function canvasTex(w, h, draw) {
    const c = document.createElement('canvas'); c.width = w; c.height = h;
    draw(c.getContext('2d'), w, h);
    const t = new THREE.CanvasTexture(c);
    t.colorSpace = THREE.SRGBColorSpace;
    t.anisotropy = maxAniso;
    return t;
  }

  const sandTex = canvasTex(512, 512, (g, w, h) => {
    g.fillStyle = '#c6ab7d'; g.fillRect(0, 0, w, h);
    for (let i = 0; i < 9000; i++) {
      const x = Math.random() * w, y = Math.random() * h, r = Math.random() * 1.7 + 0.3;
      const t = Math.random();
      g.fillStyle = t < 0.42 ? `rgba(255,244,214,${0.04 + Math.random() * 0.24})`
                : t < 0.78 ? `rgba(112,86,54,${0.04 + Math.random() * 0.22})`
                : `rgba(190,215,205,${Math.random() * 0.09})`;
      g.beginPath(); g.arc(x, y, r, 0, 6.2832); g.fill();
    }
    const grd = g.createRadialGradient(w * 0.5, h * 0.5, 40, w * 0.5, h * 0.5, w * 0.72);
    grd.addColorStop(0, 'rgba(255,255,255,0.07)'); grd.addColorStop(1, 'rgba(0,0,0,0.16)');
    g.fillStyle = grd; g.fillRect(0, 0, w, h);
  });
  sandTex.wrapS = sandTex.wrapT = THREE.RepeatWrapping;
  sandTex.repeat.set(5, 3);

  const reflectTex = canvasTex(512, 256, (g, w, h) => {
    g.clearRect(0, 0, w, h);
    for (let i = 0; i < 6; i++) {
      const x = 30 + i * 78 + Math.random() * 40;
      const grd = g.createLinearGradient(x, 0, x + 54, h);
      grd.addColorStop(0, 'rgba(220,248,255,0)');
      grd.addColorStop(0.5, `rgba(215,247,255,${0.10 + Math.random() * 0.07})`);
      grd.addColorStop(1, 'rgba(220,248,255,0)');
      g.save(); g.translate(x, 0); g.rotate(0.30);
      g.fillStyle = grd; g.fillRect(-26, -80, 24, 420); g.restore();
    }
  });

  function band(g, y0, y1, color) {
    g.fillStyle = color;
    g.beginPath();
    g.moveTo(0, y0);
    for (let x = 0; x <= 512; x += 32) g.lineTo(x, y0 + Math.sin(x * 0.05) * 5);
    for (let x = 512; x >= 0; x -= 32) g.lineTo(x, y1 + Math.sin(x * 0.05 + 1.4) * 5);
    g.closePath(); g.fill();
  }

  function fishTexture(kind) {
    return canvasTex(512, 256, (g) => {
      const pal = {
        ember:  ['#ffb04a', '#c8450e'],
        cobalt: ['#5b96ff', '#0b2a80'],
        gold:   ['#ffe271', '#cf7f08'],
        neon:   ['#d9f2fb', '#5f9fbf']
      }[kind];
      const grd = g.createLinearGradient(0, 0, 0, 256);
      grd.addColorStop(0, pal[0]); grd.addColorStop(1, pal[1]);
      g.fillStyle = grd; g.fillRect(0, 0, 512, 256);

      if (kind === 'ember') {
        band(g, 30, 74, 'rgba(30,16,10,.45)');
        band(g, 36, 68, 'rgba(255,248,238,.95)');
        band(g, 146, 186, 'rgba(30,16,10,.45)');
        band(g, 151, 181, 'rgba(255,248,238,.95)');
        band(g, 226, 250, 'rgba(30,16,10,.35)');
        band(g, 229, 247, 'rgba(255,248,238,.9)');
      } else if (kind === 'cobalt') {
        g.fillStyle = 'rgba(5,16,58,.88)';
        g.beginPath(); g.moveTo(0, 140);
        g.bezierCurveTo(120, 104, 240, 186, 360, 142);
        g.lineTo(512, 126); g.lineTo(512, 256); g.lineTo(0, 256); g.closePath(); g.fill();
        g.fillStyle = 'rgba(140,235,255,.6)'; g.fillRect(0, 92, 512, 11);
        g.fillStyle = 'rgba(255,220,120,.35)'; g.fillRect(300, 170, 212, 26);
      } else if (kind === 'gold') {
        g.fillStyle = 'rgba(255,255,255,.16)';
        for (let i = 0; i < 9; i++) g.fillRect(i * 58 + 22, 0, 9, 256);
        g.fillStyle = 'rgba(255,255,255,.30)'; g.fillRect(0, 206, 512, 50);
        g.fillStyle = 'rgba(255,255,255,.12)'; g.fillRect(0, 0, 512, 26);
      } else {
        g.fillStyle = 'rgba(30,225,255,.95)'; g.fillRect(0, 104, 512, 15);
        g.fillStyle = 'rgba(255,64,74,.9)'; g.fillRect(70, 132, 290, 13);
        g.fillStyle = 'rgba(255,255,255,.35)'; g.fillRect(0, 0, 512, 26);
      }
      for (let i = 0; i < 1100; i++) {
        g.fillStyle = `rgba(255,255,255,${Math.random() * 0.045})`;
        g.fillRect(Math.random() * 512, Math.random() * 256, 2, 2);
      }
    });
  }

  /* ============================ lighting ============================ */
  scene.add(new THREE.HemisphereLight(0x9fe4ff, 0x07161d, 0.9));

  const key = new THREE.DirectionalLight(0xe4f7ff, 2.1);
  key.position.set(4.5, 11, 6.5);
  key.castShadow = true;
  key.shadow.mapSize.set(1024, 1024);
  key.shadow.camera.left = -9; key.shadow.camera.right = 9;
  key.shadow.camera.top = 9; key.shadow.camera.bottom = -6;
  key.shadow.camera.near = 1; key.shadow.camera.far = 32;
  key.shadow.bias = -0.0014;
  scene.add(key);

  const rim = new THREE.DirectionalLight(0x3a8cff, 1.0);
  rim.position.set(-8, 4.5, -6); scene.add(rim);

  const topGlow = new THREE.PointLight(0x62e6ff, 42, 22, 2);
  topGlow.position.set(-2.5, 6.3, 0.5); scene.add(topGlow);

  const warmLamp = new THREE.PointLight(0xffa867, 26, 18, 2);
  warmLamp.position.set(5.2, 2.0, 2.2); scene.add(warmLamp);

  const coolLamp = new THREE.PointLight(0x49b6ff, 20, 16, 2);
  coolLamp.position.set(-5.6, 3.4, 2.0); scene.add(coolLamp);

  /* ============================ tank ============================ */
  const TANK = { w: 13.6, h: 6.6, d: 6.8, floor: 0.30, top: 6.55 };

  // sand floor
  const sand = new THREE.Mesh(
    new THREE.PlaneGeometry(TANK.w - 0.2, TANK.d - 0.2),
    new THREE.MeshStandardMaterial({ map: sandTex, roughness: 0.96, metalness: 0.0 })
  );
  sand.rotation.x = -Math.PI / 2;
  sand.position.y = TANK.floor;
  sand.receiveShadow = true;
  scene.add(sand);

  // caustic light on the sand
  const causticUniforms = { uTime: { value: 0 } };
  const causticMat = new THREE.ShaderMaterial({
    uniforms: causticUniforms,
    transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
    vertexShader: `varying vec2 vUv; void main(){ vUv=uv; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);} `,
    fragmentShader: `
      uniform float uTime; varying vec2 vUv;
      void main(){
        vec2 p = (vUv - 0.5) * vec2(13.0, 6.6);
        float t = uTime * 0.45;
        float a = sin(p.x * 1.9 + t) * sin(p.y * 2.3 - t * 0.8);
        float b = sin(p.x * 3.3 - t * 0.6 + sin(p.y * 1.1 + t)) * sin(p.y * 2.9 + t * 0.7);
        float c = pow(abs(a * 0.62 + b * 0.38), 3.0);
        c = smoothstep(0.22, 1.0, c);
        float fade = smoothstep(0.0,0.18,vUv.x) * smoothstep(0.0,0.18,1.0-vUv.x);
        gl_FragColor = vec4(vec3(0.40,0.90,1.0) * c, c * 0.55 * fade);
      }`
  });
  const caustics = new THREE.Mesh(new THREE.PlaneGeometry(TANK.w - 0.25, TANK.d - 0.25), causticMat);
  caustics.rotation.x = -Math.PI / 2;
  caustics.position.y = TANK.floor + 0.012;
  caustics.renderOrder = 1;
  scene.add(caustics);

  // back wall gradient
  const wallUniforms = { uTime: { value: 0 } };
  const wall = new THREE.Mesh(
    new THREE.PlaneGeometry(TANK.w, TANK.h),
    new THREE.ShaderMaterial({
      uniforms: wallUniforms,
      vertexShader: `varying vec2 vUv; void main(){ vUv=uv; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);} `,
      fragmentShader: `
        uniform float uTime; varying vec2 vUv;
        float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1,311.7))) * 43758.5453); }
        void main(){
          vec3 bottom = vec3(0.010,0.048,0.075);
          vec3 top = vec3(0.030,0.205,0.255);
          vec3 col = mix(bottom, top, pow(clamp(vUv.y,0.0,1.0), 1.25));
          float g = exp(-length((vUv - vec2(0.5,0.98)) * vec2(1.5,2.4)) * 3.2);
          col += vec3(0.05,0.22,0.26) * g;
          float bands = 0.5 + 0.5*sin(vUv.x*26.0 + sin(vUv.y*3.0 + uTime*0.18)*2.0);
          col += vec3(0.02,0.07,0.085) * bands * smoothstep(0.25,1.0,vUv.y) * 0.35;
          col += (hash(vUv*vec2(1024.0,768.0)) - 0.5) * 0.014;
          gl_FragColor = vec4(col, 1.0);
        }`
    })
  );
  wall.position.set(0, TANK.floor + TANK.h / 2 - 0.15, -TANK.d / 2);
  scene.add(wall);

  // glass box + edges
  const glassGeo = new THREE.BoxGeometry(TANK.w + 0.2, TANK.h + 0.1, TANK.d + 0.1);
  const glass = new THREE.Mesh(glassGeo, new THREE.MeshPhysicalMaterial({
    color: 0xa8ecff, transparent: true, opacity: 0.055, roughness: 0.06, metalness: 0,
    side: THREE.DoubleSide, depthWrite: false
  }));
  glass.position.y = TANK.floor + TANK.h / 2 - 0.1;
  glass.renderOrder = 6;
  scene.add(glass);

  const edges = new THREE.LineSegments(
    new THREE.EdgesGeometry(glassGeo),
    new THREE.LineBasicMaterial({ color: 0x8fe7ff, transparent: true, opacity: 0.32 })
  );
  edges.position.copy(glass.position);
  edges.renderOrder = 7;
  scene.add(edges);

  // front glass reflection
  const reflection = new THREE.Mesh(
    new THREE.PlaneGeometry(TANK.w, TANK.h),
    new THREE.MeshBasicMaterial({ map: reflectTex, transparent: true, opacity: 0.16,
      blending: THREE.AdditiveBlending, depthWrite: false })
  );
  reflection.position.set(0, TANK.floor + TANK.h / 2 - 0.1, TANK.d / 2 + 0.07);
  reflection.renderOrder = 8;
  scene.add(reflection);

  // water surface (seen from below)
  const surfUniforms = { uTime: { value: 0 } };
  const surface = new THREE.Mesh(
    new THREE.PlaneGeometry(TANK.w - 0.05, TANK.d - 0.05, 40, 20),
    new THREE.ShaderMaterial({
      uniforms: surfUniforms, transparent: true, depthWrite: false, side: THREE.DoubleSide,
      vertexShader: `
        uniform float uTime; varying vec2 vUv; varying vec3 vPos;
        void main(){
          vUv = uv; vec3 p = position;
          p.y += sin(p.x*1.6 + uTime*0.9)*0.035 + sin(p.z*2.2 - uTime*0.7)*0.026 + sin((p.x+p.z)*3.2 + uTime*1.5)*0.012;
          vPos = p; gl_Position = projectionMatrix*modelViewMatrix*vec4(p,1.0);
        }`,
      fragmentShader: `
        uniform float uTime; varying vec2 vUv; varying vec3 vPos;
        void main(){
          float r = sin(vPos.x*1.7 + uTime*0.8) + sin(vPos.z*2.1 - uTime*0.6);
          float sheen = pow(max(r*0.5+0.5,0.0), 6.0);
          vec3 col = mix(vec3(0.04,0.30,0.38), vec3(0.60,0.96,1.0), sheen);
          gl_FragColor = vec4(col, 0.10 + sheen*0.34);
        }`
    })
  );
  surface.geometry.rotateX(-Math.PI / 2);
  surface.position.y = TANK.top;
  surface.renderOrder = 5;
  scene.add(surface);

  // stand
  const wood = new THREE.MeshStandardMaterial({ color: 0x241913, roughness: 0.72, metalness: 0.05 });
  const base = new THREE.Mesh(new THREE.BoxGeometry(14.7, 0.52, 7.7), wood);
  base.position.set(0, -0.26, 0); base.castShadow = true; base.receiveShadow = true; scene.add(base);
  const cabinet = new THREE.Mesh(new THREE.BoxGeometry(13.9, 1.8, 7.0),
    new THREE.MeshStandardMaterial({ color: 0x18110d, roughness: 0.9 }));
  cabinet.position.set(0, -1.42, 0); cabinet.castShadow = true; scene.add(cabinet);
  const trim = new THREE.Mesh(new THREE.BoxGeometry(14.3, 0.1, 7.35),
    new THREE.MeshStandardMaterial({ color: 0x0d1418, roughness: 0.35, metalness: 0.65 }));
  trim.position.set(0, 0.04, 0); scene.add(trim);
  const led = new THREE.Mesh(new THREE.BoxGeometry(13.7, 0.035, 6.95),
    new THREE.MeshBasicMaterial({ color: 0x53d9ff }));
  led.position.set(0, 0.12, 0); scene.add(led);

  /* ============================ decor ============================ */
  const sway = [];

  function makeBlade(height, width, lean, color, phase) {
    const seg = 12, positions = [], uvs = [], indices = [];
    for (let i = 0; i <= seg; i++) {
      const t = i / seg;
      const w = width * (1 - t * 0.82) * (0.5 + 0.5 * Math.sin(Math.min(1, t * 3) * Math.PI * 0.5));
      const cx = lean * t * t + Math.sin(t * 2.4 + phase) * width * 0.8;
      const cz = Math.sin(t * 1.7 + phase * 1.3) * 0.10;
      positions.push(cx - w, t * height, cz, cx + w, t * height, cz);
      uvs.push(0, t, 1, t);
      if (i < seg) { const a = i * 2; indices.push(a, a + 1, a + 2, a + 1, a + 3, a + 2); }
    }
    const g = new THREE.BufferGeometry();
    g.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
    g.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
    g.setIndex(indices);
    g.computeVertexNormals();
    const col = new THREE.Color(color);
    const m = new THREE.Mesh(g, new THREE.MeshStandardMaterial({
      color, roughness: 0.82, side: THREE.DoubleSide,
      emissive: col.clone().multiplyScalar(0.22), emissiveIntensity: 0.55
    }));
    m.castShadow = true;
    return m;
  }

  function makePlants(x, z, count, height, color) {
    const grp = new THREE.Group();
    for (let i = 0; i < count; i++) {
      const h = height * (0.55 + Math.random() * 0.65);
      const b = makeBlade(h, 0.10 + Math.random() * 0.13, (Math.random() - 0.5) * 1.0, color, Math.random() * 6.283);
      b.position.set((Math.random() - 0.5) * 1.0, 0, (Math.random() - 0.5) * 0.6);
      b.rotation.y = Math.random() * Math.PI;
      grp.add(b);
      sway.push({ mesh: b, phase: Math.random() * 6.283, amp: 0.05 + Math.random() * 0.07 });
    }
    grp.position.set(x, TANK.floor - 0.02, z);
    scene.add(grp);
  }

  makePlants(-5.2, -2.5, 9, 3.3, 0x1f8f5a);
  makePlants(-2.9, -2.85, 7, 2.5, 0x2fb37a);
  makePlants(3.5, -2.65, 8, 3.0, 0x189c86);
  makePlants(5.6, -2.0, 6, 2.1, 0x3c6fd0);
  makePlants(-6.1, 1.5, 5, 1.2, 0x2aa36b);
  makePlants(6.0, 1.9, 5, 1.1, 0x2aa36b);
  makePlants(-0.6, -3.0, 4, 1.5, 0x6b3fa0);

  function makeRock(radius, color, seed) {
    const g = new THREE.IcosahedronGeometry(radius, 1);
    const p = g.attributes.position;
    for (let i = 0; i < p.count; i++) {
      const x = p.getX(i), y = p.getY(i), z = p.getZ(i);
      const k = 1 + 0.16 * Math.sin(x * 6.3 + seed) * Math.cos(z * 5.1 - seed);
      p.setXYZ(i, x * k, y * k * 0.82, z * k);
    }
    g.computeVertexNormals();
    const m = new THREE.Mesh(g, new THREE.MeshStandardMaterial({ color, roughness: 0.95, flatShading: true }));
    m.castShadow = true; m.receiveShadow = true;
    return m;
  }
  const rockSpots = [
    [-4.6, -2.2, 0.85, 0x4d5a60], [-5.2, -2.5, 0.5, 0x3f4b52], [-4.0, -2.7, 0.42, 0x596670],
    [4.6, -2.3, 0.9, 0x49555c], [5.3, -2.5, 0.55, 0x3d484f], [4.0, -2.75, 0.4, 0x5b6770],
    [-6.0, 0.4, 0.55, 0x4a565d], [6.1, -0.2, 0.5, 0x445058], [0.4, -3.0, 0.38, 0x59646c]
  ];
  rockSpots.forEach((r, i) => {
    const rock = makeRock(r[2], r[3], i * 1.7);
    rock.position.set(r[0], TANK.floor + r[2] * 0.5, r[1]);
    rock.rotation.set(Math.random(), Math.random() * 6.28, Math.random() * 0.4);
    scene.add(rock);
  });

  function makeCoral(x, z, s, color) {
    const grp = new THREE.Group();
    const col = new THREE.Color(color);
    const mat = new THREE.MeshStandardMaterial({
      color, roughness: 0.62, emissive: col.clone().multiplyScalar(0.2), emissiveIntensity: 0.8
    });
    const up = new THREE.Vector3(0, 1, 0);
    function branch(start, dir, len, rad, depth) {
      const end = start.clone().addScaledVector(dir, len);
      const mesh = new THREE.Mesh(new THREE.CylinderGeometry(rad * 0.66, rad, len, 7, 1), mat);
      mesh.position.copy(start.clone().lerp(end, 0.5));
      mesh.quaternion.setFromUnitVectors(up, dir.clone().normalize());
      mesh.castShadow = true;
      grp.add(mesh);
      if (depth <= 0) return;
      const n = depth > 1 ? 3 : 2;
      for (let i = 0; i < n; i++) {
        const nd = dir.clone().applyAxisAngle(
          new THREE.Vector3(Math.random() - 0.5, 0, Math.random() - 0.5).normalize(),
          (Math.random() - 0.35) * 1.25);
        nd.y = Math.abs(nd.y) * 0.75 + 0.3; nd.normalize();
        branch(end, nd, len * (0.60 + Math.random() * 0.16), rad * 0.62, depth - 1);
      }
    }
    branch(new THREE.Vector3(0, 0, 0),
      new THREE.Vector3((Math.random() - 0.5) * 0.4, 1, (Math.random() - 0.5) * 0.4).normalize(),
      0.95 * s, 0.13 * s, 2);
    grp.position.set(x, TANK.floor - 0.02, z);
    scene.add(grp);
  }
  makeCoral(-3.6, -2.0, 1.0, 0xff7f9e);
  makeCoral(2.6, -2.3, 0.85, 0xffa25e);
  makeCoral(5.4, -0.6, 0.7, 0xc98bff);

  // light shafts
  const shafts = [];
  for (let i = 0; i < 3; i++) {
    const mat = new THREE.MeshBasicMaterial({
      color: 0xbdf4ff, transparent: true, opacity: 0.032,
      blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide
    });
    const cone = new THREE.Mesh(new THREE.ConeGeometry(1.4 + i * 0.35, TANK.h, 28, 1, true), mat);
    cone.position.set(-3.6 + i * 3.3, TANK.floor + TANK.h / 2, -1.4 + i * 1.1);
    cone.rotation.z = (Math.random() - 0.5) * 0.18;
    cone.renderOrder = 4;
    scene.add(cone);
    shafts.push({ mesh: cone, base: 0.032, phase: i * 2.1 });
  }

  /* ============================ bubbles & snow ============================ */
  const bubbleGeo = new THREE.SphereGeometry(1, 10, 8);
  const bubbleMat = new THREE.MeshStandardMaterial({
    color: 0xc8f2ff, roughness: 0.05, metalness: 0.0, transparent: true, opacity: 0.28,
    emissive: 0x215a6b, emissiveIntensity: 0.8
  });
  const vents = [
    { x: -2.3, z: -2.1 }, { x: 1.7, z: -2.5 }, { x: 4.6, z: -1.9 }, { x: -5.6, z: -1.6 }
  ];
  const bubbles = [];
  for (let i = 0; i < 34; i++) {
    const vent = vents[i % vents.length];
    const m = new THREE.Mesh(bubbleGeo, bubbleMat);
    const size = 0.035 + Math.random() * 0.085;
    m.scale.setScalar(size);
    const b = {
      m, x: vent.x + (Math.random() - 0.5) * 0.3, y: 0.4 + Math.random() * 6,
      z: vent.z + (Math.random() - 0.5) * 0.3, size,
      speed: 0.45 + Math.random() * 0.7, phase: Math.random() * 6.283, vent
    };
    m.position.set(b.x, b.y, b.z);
    scene.add(m);
    bubbles.push(b);
  }

  const snowCount = 170;
  const snowPos = new Float32Array(snowCount * 3);
  const snowData = [];
  for (let i = 0; i < snowCount; i++) {
    const x = (Math.random() - 0.5) * (TANK.w - 0.6);
    const y = 0.5 + Math.random() * (TANK.h - 0.8);
    const z = (Math.random() - 0.5) * (TANK.d - 0.6);
    snowPos[i * 3] = x; snowPos[i * 3 + 1] = y; snowPos[i * 3 + 2] = z;
    snowData.push({ speed: 0.05 + Math.random() * 0.12, phase: Math.random() * 6.283 });
  }
  const snowGeo = new THREE.BufferGeometry();
  snowGeo.setAttribute('position', new THREE.BufferAttribute(snowPos, 3));
  const snow = new THREE.Points(snowGeo, new THREE.PointsMaterial({
    color: 0xcaf0ff, size: 0.035, transparent: true, opacity: 0.4, sizeAttenuation: true
  }));
  scene.add(snow);

  /* ============================ fish ============================ */
  const SPECIES = {
    ember:  { tex: fishTexture('ember'),  w: 0.30, h: 0.36, len: 0.72, speed: 1.05, fin: 0xff7f36, glow: 0xff6a1f },
    cobalt: { tex: fishTexture('cobalt'), w: 0.28, h: 0.40, len: 0.78, speed: 1.18, fin: 0x3f6dff, glow: 0x1e49d8 },
    gold:   { tex: fishTexture('gold'),   w: 0.30, h: 0.42, len: 0.74, speed: 1.22, fin: 0xffc542, glow: 0xffae1f },
    neon:   { tex: fishTexture('neon'),   w: 0.13, h: 0.19, len: 0.44, speed: 1.42, fin: 0x9fe8ff, glow: 0x3fd8ff }
  };

  const bodyGeo = new THREE.SphereGeometry(1, 28, 18);
  {
    const p = bodyGeo.attributes.position;
    for (let i = 0; i < p.count; i++) {
      let x = p.getX(i), y = p.getY(i), z = p.getZ(i);
      const taper = 1 - 0.32 * Math.max(0, z) - 0.30 * Math.max(0, -z);
      const belly = 1 + 0.07 * Math.max(0, -y);
      p.setXYZ(i, x * taper * belly, y * taper, z);
    }
    bodyGeo.computeVertexNormals();
  }

  const tailGeo = new THREE.BufferGeometry();
  tailGeo.setAttribute('position', new THREE.Float32BufferAttribute([
    0, 0, 0, 0, 0.62, -0.78, 0, 0, -0.44,
    0, 0, 0, 0, 0, -0.44, 0, -0.62, -0.78
  ], 3));
  tailGeo.computeVertexNormals();

  const dorsalGeo = new THREE.BufferGeometry();
  dorsalGeo.setAttribute('position', new THREE.Float32BufferAttribute([
    0, 0, 0.44, 0, 0.58, -0.14, 0, 0, -0.46
  ], 3));
  dorsalGeo.computeVertexNormals();

  const ventralGeo = new THREE.BufferGeometry();
  ventralGeo.setAttribute('position', new THREE.Float32BufferAttribute([
    0, 0, 0.05, 0, -0.30, -0.28, 0, 0, -0.44
  ], 3));
  ventralGeo.computeVertexNormals();

  const eyeGeo = new THREE.SphereGeometry(1, 12, 10);
  const eyeWhite = new THREE.MeshPhysicalMaterial({ color: 0xf6fdff, roughness: 0.12, clearcoat: 1 });
  const eyeDark = new THREE.MeshPhysicalMaterial({ color: 0x060d12, roughness: 0.08, clearcoat: 1 });

  const fishes = [];

  function createFish(key, pos) {
    const spec = SPECIES[key];
    const root = new THREE.Group();
    const model = new THREE.Group();
    root.add(model);

    const bodyMat = new THREE.MeshPhysicalMaterial({
      map: spec.tex, roughness: 0.34, metalness: 0.04, clearcoat: 0.85, clearcoatRoughness: 0.22,
      sheen: 0.45, sheenColor: new THREE.Color(spec.glow), sheenRoughness: 0.5,
      emissive: new THREE.Color(spec.glow), emissiveIntensity: 0.08
    });
    const body = new THREE.Mesh(bodyGeo, bodyMat);
    body.scale.set(spec.w, spec.h, spec.len);
    body.castShadow = true;
    model.add(body);

    const finMat = new THREE.MeshPhysicalMaterial({
      color: spec.fin, roughness: 0.45, transparent: true, opacity: 0.92, side: THREE.DoubleSide,
      emissive: new THREE.Color(spec.glow), emissiveIntensity: 0.14, depthWrite: false
    });

    const tailPivot = new THREE.Group();
    tailPivot.position.z = -spec.len * 0.95;
    model.add(tailPivot);
    const tail = new THREE.Mesh(tailGeo, finMat);
    tail.scale.setScalar(spec.len);
    tailPivot.add(tail);

    const dorsal = new THREE.Mesh(dorsalGeo, finMat);
    dorsal.position.set(0, spec.h * 0.80, 0);
    dorsal.scale.set(spec.len, spec.len * 1.05, spec.len);
    model.add(dorsal);

    const ventral = new THREE.Mesh(ventralGeo, finMat);
    ventral.position.set(0, -spec.h * 0.72, 0);
    ventral.scale.set(spec.len, spec.len * 0.85, spec.len);
    model.add(ventral);

    const finL = new THREE.Group(); finL.position.set(spec.w * 0.80, -spec.h * 0.08, spec.len * 0.16);
    const finR = new THREE.Group(); finR.position.set(-spec.w * 0.80, -spec.h * 0.08, spec.len * 0.16);
    model.add(finL, finR);
    const pectL = new THREE.Mesh(eyeGeo, finMat);
    pectL.scale.set(spec.len * 0.30, spec.len * 0.045, spec.len * 0.24);
    pectL.position.x = spec.len * 0.13;
    const pectR = pectL.clone(); pectR.scale.x *= -1; pectR.position.x = -spec.len * 0.13;
    finL.add(pectL); finR.add(pectR);

    for (const s of [-1, 1]) {
      const eye = new THREE.Mesh(eyeGeo, eyeWhite);
      eye.position.set(s * spec.w * 0.66, spec.h * 0.30, spec.len * 0.46);
      eye.scale.setScalar(spec.len * 0.13);
      model.add(eye);
      const pupil = new THREE.Mesh(eyeGeo, eyeDark);
      pupil.position.set(s * spec.w * 0.80, spec.h * 0.30, spec.len * 0.505);
      pupil.scale.setScalar(spec.len * 0.072);
      model.add(pupil);
    }

    root.position.copy(pos);
    scene.add(root);

    const fish = {
      root, model, tailPivot, finL, finR, key, spec,
      phase: Math.random() * 6.283, seed: Math.random() * 100,
      vel: new THREE.Vector3(Math.random() - 0.5, (Math.random() - 0.5) * 0.4, Math.random() - 0.5).normalize()
        .multiplyScalar(spec.speed),
      react: 0, turn: 0, lastDir: new THREE.Vector3(0, 0, 1)
    };
    fishes.push(fish);
    return fish;
  }

  const plan = [
    ['ember', -1.3, 3.2, 0.7], ['ember', 2.5, 2.0, -1.1], ['ember', -3.9, 1.7, 0.4], ['ember', 4.4, 4.2, -0.5],
    ['cobalt', 0.7, 4.6, -0.9], ['cobalt', -4.7, 3.9, 0.6], ['cobalt', 3.2, 5.0, 0.3], ['cobalt', 5.1, 1.7, -0.7],
    ['gold', -2.7, 5.1, 1.0], ['gold', 1.6, 1.4, -0.6], ['gold', 4.9, 3.4, 0.8], ['gold', -5.3, 2.4, -0.3],
    ['neon', -0.4, 2.7, 0.5], ['neon', 1.0, 2.3, -0.4], ['neon', -1.7, 3.0, 0.2], ['neon', 2.1, 3.2, 0.7]
  ];
  plan.forEach(p => createFish(p[0], new THREE.Vector3(p[1], p[2], p[3])));

  /* ============================ interaction ============================ */
  const pointer = {
    ndc: new THREE.Vector2(0, 0),
    target: new THREE.Vector2(0, 0),
    px: new THREE.Vector2(VW() / 2, VH() / 2),
    active: false, auto: true
  };
  const raycaster = new THREE.Raycaster();
  const planeZ = new THREE.Plane(new THREE.Vector3(0, 0, 1), -0.8);
  const cursorEl = $('#cursor');
  const chipEl = $('#chip');
  const hintEl = $('#hintText');
  const stateEl = $('#state');
  const countEl = $('#count');
  const autoBtn = $('#autoBtn');

  function setHint(chip, text) { chipEl.textContent = chip; hintEl.textContent = text; }

  function takeControl() {
    if (pointer.auto) {
      pointer.auto = false;
      autoBtn.classList.remove('active');
      cursorEl.classList.remove('auto');
      setHint('You’re in control', 'click the water for a ripple · move close to scatter them');
    }
  }
  function enableAuto() {
    pointer.auto = true; pointer.active = true;
    autoBtn.classList.add('active');
    cursorEl.classList.add('auto');
    setHint('Auto demo', 'a virtual cursor is guiding the school · move to take over');
  }

  const canvas = renderer.domElement;
  canvas.addEventListener('pointermove', (e) => {
    if (e.pointerType === 'touch') return;
    pointer.target.set((e.clientX / VW()) * 2 - 1, -(e.clientY / VH()) * 2 + 1);
    pointer.active = true;
    takeControl();
  });
  canvas.addEventListener('pointerleave', () => { if (!pointer.auto) pointer.active = false; });
  canvas.addEventListener('pointerdown', (e) => {
    pointer.target.set((e.clientX / VW()) * 2 - 1, -(e.clientY / VH()) * 2 + 1);
    pointer.active = true;
    takeControl();
    rippleAtScreen(e.clientX, e.clientY);
  });
  canvas.addEventListener('contextmenu', e => e.preventDefault());

  const ripples = [];
  const rippleRingGeo = new THREE.TorusGeometry(0.44, 0.018, 8, 64);

  function spawnRipple(pos) {
    const group = new THREE.Group();
    group.position.copy(pos);
    const mat = new THREE.MeshBasicMaterial({
      color: 0x9df6ff, transparent: true, opacity: 0.9,
      blending: THREE.AdditiveBlending, depthWrite: false
    });
    const ring = new THREE.Mesh(rippleRingGeo, mat);
    group.add(ring);
    const mat2 = mat.clone(); mat2.opacity = 0.55;
    const ring2 = new THREE.Mesh(rippleRingGeo, mat2);
    ring2.scale.setScalar(0.55);
    group.add(ring2);
    const glow = new THREE.Mesh(new THREE.SphereGeometry(0.5, 16, 12),
      new THREE.MeshBasicMaterial({ color: 0x66e6ff, transparent: true, opacity: 0.16,
        blending: THREE.AdditiveBlending, depthWrite: false }));
    group.add(glow);
    group.quaternion.copy(camera.quaternion);
    scene.add(group);
    ripples.push({ group, ring, ring2, glow, age: 0 });

    for (const f of fishes) {
      const d = f.root.position.distanceTo(pos);
      if (d < 3.6) {
        const dir = f.root.position.clone().sub(pos);
        dir.y += 0.35; dir.normalize();
        f.vel.addScaledVector(dir, 3.0 * (1 - d / 3.6));
        f.react = 1;
      }
    }
    for (let i = 0; i < 7; i++) {
      const b = bubbles[(Math.random() * bubbles.length) | 0];
      b.x = pos.x + (Math.random() - 0.5) * 0.5;
      b.z = pos.z + (Math.random() - 0.5) * 0.4;
      b.y = pos.y + (Math.random() - 0.5) * 0.4;
      b.speed = 0.6 + Math.random() * 0.7;
    }
  }

  function rippleAtScreen(cx, cy) {
    const ndc = new THREE.Vector2((cx / VW()) * 2 - 1, -(cy / VH()) * 2 + 1);
    raycaster.setFromCamera(ndc, camera);
    const hit = new THREE.Vector3();
    if (raycaster.ray.intersectPlane(planeZ, hit)) {
      hit.x = THREE.MathUtils.clamp(hit.x, -6.3, 6.3);
      hit.y = THREE.MathUtils.clamp(hit.y, 0.7, 6.2);
      hit.z = 0.8;
      spawnRipple(hit);
    }
  }

  autoBtn.addEventListener('click', () => {
    if (pointer.auto) { takeControl(); }
    else { enableAuto(); }
  });
  $('#rippleBtn').addEventListener('click', () => {
    takeControl();
    rippleAtScreen(pointer.px.x, pointer.px.y);
  });
  $('#addBtn').addEventListener('click', () => {
    const keys = Object.keys(SPECIES);
    const key = keys[(Math.random() * keys.length) | 0];
    const p = new THREE.Vector3((Math.random() - 0.5) * 4, 1.4 + Math.random() * 3.2, (Math.random() - 0.5) * 2);
    const f = createFish(key, p);
    f.root.scale.setScalar(0.05);
    f.grow = 0;
    countEl.textContent = fishes.length;
    spawnRipple(p.clone());
  });

  window.addEventListener('keydown', (e) => {
    if (e.code === 'Space') { e.preventDefault(); takeControl(); rippleAtScreen(pointer.px.x, pointer.px.y); }
    else if (e.key === 'a' || e.key === 'A') { pointer.auto ? takeControl() : enableAuto(); }
    else if (e.key === 'f' || e.key === 'F') { $('#addBtn').click(); }
    else { takeControl(); }
  });

  /* ============================ loop ============================ */
  const clock = new THREE.Clock();
  let time = 0;

  const vDesired = new THREE.Vector3();
  const vSep = new THREE.Vector3();
  const vProj = new THREE.Vector3();
  const vClosest = new THREE.Vector3();
  const vAway = new THREE.Vector3();
  const vTarget = new THREE.Vector3();
  const vDir = new THREE.Vector3();
  const vTurn = new THREE.Vector3();
  const vPointer = new THREE.Vector3();
  const qTarget = new THREE.Quaternion();
  const FORWARD = new THREE.Vector3(0, 0, 1);
  const clamp = THREE.MathUtils.clamp;

  function animate() {
    requestAnimationFrame(animate);
    const dt = Math.min(clock.getDelta(), 0.05);
    time += dt;

    /* pointer */
    if (pointer.auto) {
      pointer.target.set(
        Math.sin(time * 0.36) * 0.50 + Math.sin(time * 0.13) * 0.14,
        Math.sin(time * 0.27 + 0.8) * 0.24
      );
      pointer.active = true;
    }
    pointer.ndc.lerp(pointer.target, 1 - Math.exp(-dt * 6));
    raycaster.setFromCamera(pointer.ndc, camera);
    pointer.px.set((pointer.ndc.x * 0.5 + 0.5) * VW(), (-pointer.ndc.y * 0.5 + 0.5) * VH());
    cursorEl.style.left = pointer.px.x + 'px';
    cursorEl.style.top = pointer.px.y + 'px';
    cursorEl.style.opacity = pointer.active ? '1' : '0';

    if (!raycaster.ray.intersectPlane(planeZ, vPointer)) {
      vPointer.copy(camera.position).add(raycaster.ray.direction.clone().multiplyScalar(12));
    }

    /* fish behaviour */
    let reacting = 0;
    let nearest = 1e9;
    for (let i = 0; i < fishes.length; i++) {
      const f = fishes[i];
      const p = f.root.position;

      vDesired.set(
        Math.sin(time * 0.29 + f.seed) + Math.sin(time * 0.11 + f.seed * 1.3) * 0.55,
        Math.sin(time * 0.21 + f.seed * 1.9) * 0.55,
        Math.cos(time * 0.25 + f.seed * 0.7)
      );

      vSep.set(0, 0, 0);
      for (let j = 0; j < fishes.length; j++) {
        if (i === j) continue;
        const o = fishes[j].root.position;
        const dx = p.x - o.x, dy = p.y - o.y, dz = p.z - o.z;
        const d2 = dx * dx + dy * dy + dz * dz;
        if (d2 < 9) {
          vDesired.x += (o.x - p.x) * 0.022;
          vDesired.y += (o.y - p.y) * 0.020;
          vDesired.z += (o.z - p.z) * 0.022;
        }
        if (d2 < 1.7 && d2 > 0.0001) {
          const d = Math.sqrt(d2);
          vSep.x += dx / d / d; vSep.y += dy / d / d; vSep.z += dz / d / d;
        }
      }
      vDesired.addScaledVector(vSep, 1.15);

      // proximity to the cursor (screen space) -> flee (world space, perpendicular to view)
      vProj.copy(p).project(camera);
      const sx = (vProj.x * 0.5 + 0.5) * VW();
      const sy = (-vProj.y * 0.5 + 0.5) * VH();
      const dist = Math.hypot(sx - pointer.px.x, sy - pointer.px.y);
      let urge = 0;
      if (pointer.active && dist < 230) {
        raycaster.ray.closestPointToPoint(p, vClosest);
        vAway.copy(p).sub(vClosest);
        if (vAway.lengthSq() < 1e-6) vAway.set(1, 0, 0);
        vAway.normalize();
        const k = 1 - dist / 230;
        urge = k * k;
        vDesired.addScaledVector(vAway, urge * 8.0);
        if (dist > 180) {                       // curiosity: they turn and look first
          vAway.copy(vPointer).sub(p).normalize();
          vDesired.addScaledVector(vAway, 0.38);
        }
        nearest = Math.min(nearest, dist);
      }
      f.react = Math.max(f.react - dt * 0.85);
      f.react = Math.max(f.react, urge);
      if (urge > 0.1) reacting++;

      // keep inside the glass
      if (p.x < -5.9) vDesired.x += (-5.9 - p.x) * 2.2;
      if (p.x > 5.9) vDesired.x -= (p.x - 5.9) * 2.2;
      if (p.y < 1.0) vDesired.y += (1.0 - p.y) * 2.4;
      if (p.y > 6.0) vDesired.y -= (p.y - 6.0) * 2.4;
      if (p.z < -2.8) vDesired.z += (-2.8 - p.z) * 2.2;
      if (p.z > 2.8) vDesired.z -= (p.z - 2.8) * 2.2;

      if (vDesired.lengthSq() < 1e-6) vDesired.set(0, 0, 1);
      vDesired.normalize();

      const maxSpeed = f.spec.speed * (1 + f.react * 1.7);
      vTarget.copy(vDesired).multiplyScalar(maxSpeed);
      f.vel.lerp(vTarget, 1 - Math.exp(-dt * (1.7 + f.react * 3.2)));
      let sp = f.vel.length();
      if (sp < 0.22) f.vel.set(0, 0, 0.35);
      if (sp > maxSpeed * 1.7) { f.vel.setLength(maxSpeed * 1.7); sp = maxSpeed * 1.7; }
      p.addScaledVector(f.vel, dt);
      p.x = clamp(p.x, -6.4, 6.4);
      p.y = clamp(p.y, 0.62, 6.28);
      p.z = clamp(p.z, -2.95, 2.95);

      // orientation + banking
      vDir.copy(f.vel).normalize();
      qTarget.setFromUnitVectors(FORWARD, vDir);
      f.root.quaternion.slerp(qTarget, 1 - Math.exp(-dt * 3.4));
      const turn = vTurn.copy(f.lastDir).cross(vDir).dot(FORWARD);
      f.turn += (clamp(-turn * 1.7, -0.5, 0.5) - f.turn) * (1 - Math.exp(-dt * 6));
      f.model.rotation.z = f.turn;
      f.lastDir.copy(vDir);

      // swimming animation
      f.phase += dt * (3.1 + sp * 2.7);
      f.tailPivot.rotation.y = Math.sin(f.phase) * 0.44 * (0.55 + Math.min(sp, 2.6) * 0.2);
      f.tailPivot.rotation.x = Math.sin(f.phase * 0.5) * 0.05;
      const flap = Math.sin(f.phase * 1.25);
      f.finL.rotation.z = 0.22 + flap * 0.34;
      f.finR.rotation.z = -0.22 - flap * 0.34;
      f.model.position.y = Math.sin(time * 1.15 + f.seed) * 0.022;

      if (f.grow !== undefined && f.grow < 1) {
        f.grow = Math.min(1, f.grow + dt * 1.4);
        const s = 0.05 + 0.95 * (1 - Math.pow(1 - f.grow, 3));
        f.root.scale.setScalar(s);
        if (f.grow >= 1) delete f.grow;
      }
    }

    cursorEl.classList.toggle('hot', nearest < 170 && reacting > 0);

    // status
    const alert = reacting > 0;
    stateEl.textContent = alert ? (reacting > 3 ? 'scattering' : 'alert') : 'calm';
    stateEl.classList.toggle('alert', alert);

    /* environment animation */
    causticUniforms.uTime.value = time;
    wallUniforms.uTime.value = time;
    surfUniforms.uTime.value = time;

    for (const s of shafts) {
      s.mesh.material.opacity = s.base * (0.75 + 0.35 * Math.sin(time * 0.45 + s.phase));
      s.mesh.rotation.z = Math.sin(time * 0.22 + s.phase) * 0.07;
    }

    for (const s of sway) {
      s.mesh.rotation.z = Math.sin(time * 0.72 + s.phase) * s.amp;
      s.mesh.rotation.x = Math.sin(time * 0.53 + s.phase * 1.4) * s.amp * 0.55;
    }

    for (const b of bubbles) {
      b.y += b.speed * dt;
      b.m.position.set(
        b.x + Math.sin(time * 1.25 + b.phase) * 0.10,
        b.y,
        b.z + Math.cos(time * 0.95 + b.phase) * 0.08
      );
      if (b.y > TANK.top - 0.2) {
        b.x = b.vent.x + (Math.random() - 0.5) * 0.35;
        b.z = b.vent.z + (Math.random() - 0.5) * 0.3;
        b.y = TANK.floor + 0.05;
        b.speed = 0.45 + Math.random() * 0.75;
        b.phase = Math.random() * 6.283;
      }
    }

    const sp = snowGeo.attributes.position;
    for (let i = 0; i < snowCount; i++) {
      let y = sp.getY(i) - snowData[i].speed * dt;
      if (y < TANK.floor) y = TANK.top - 0.2;
      sp.setY(i, y);
      sp.setX(i, sp.getX(i) + Math.sin(time * 0.4 + snowData[i].phase) * 0.0009);
    }
    sp.needsUpdate = true;

    for (let i = ripples.length - 1; i >= 0; i--) {
      const r = ripples[i];
      r.age += dt;
      const k = r.age / 1.35;
      const s = 0.45 + r.age * 4.2;
      r.group.scale.setScalar(s);
      r.group.quaternion.copy(camera.quaternion);
      r.ring.material.opacity = Math.max(0, 0.9 * (1 - k));
      r.ring2.material.opacity = Math.max(0, 0.5 * (1 - k) * (1 - k));
      r.glow.material.opacity = Math.max(0, 0.18 * (1 - k));
      if (k >= 1) {
        scene.remove(r.group);
        r.ring.material.dispose(); r.ring2.material.dispose(); r.glow.material.dispose();
        r.glow.geometry.dispose();
        ripples.splice(i, 1);
      }
    }

    // very gentle camera drift
    camera.position.x = 0.9 + Math.sin(time * 0.11) * 0.30;
    camera.position.y = 3.35 + Math.sin(time * 0.14) * 0.09;
    camera.lookAt(camLook);

    renderer.render(scene, camera);
  }

  window.addEventListener('resize', () => {
    camera.aspect = VW() / VH();
    camera.updateProjectionMatrix();
    renderer.setSize(VW(), VH());
  });

  countEl.textContent = fishes.length;
  enableAuto();
  renderer.render(scene, camera);
  animate();
  requestAnimationFrame(() => $('#loader').classList.add('gone'));
}
</script>
</body>
</html>
Reasoning trace (30,994 tokens)
We need produce single HTML file with Three.js import map exactly, complete inline demo. Need think through design carefully. User wants 3D aquarium with Three.js where fish swim away when mouse near them. Need visually impressive, self-contained, use Three.js via import map only. Must be exactly one fenced code block with full file, no text before/after. Need make great demo.

We should build polished 3D aquarium: perspective scene, glass tank with water, sandy substrate, rocks, plants (procedural geometry), fish with animated tails, bubbles, caustics maybe shader plane for sand / tank. Mouse raycasting into tank plane, fish flee from cursor point (3D proximity). UI overlay: minimal elegant heading "Tidepool" or "AQUARIUM / 03", title "Tranquil, until you get close.", hints, small stats, controls buttons (add fish? toggle motion?). Must ensure all interesting content visible in first seconds, self-playing demo mode? This isn't exactly a game but task says games start self-playing demo mode; aquarium is interactive simulation - fish already swim so it's self-playing automatically. Should include "Move your cursor through the tank" instruction, plus on first interaction "Press Space for a ripple" etc. Need mouse near fish leads them fleeing in convincing way: compute pointer ray-plane intersection, each fish computes distance to pointer on x/y in tank coords, repulsion force, flee velocity plus wander, orbit. Use smoothed pointer. Fish swim and avoid walls and each other maybe. Use simple flocking-ish behavior: cohesion loosely, separation, repulsion from pointer. Tail wiggle via vertex animation? Build fish as group with body (lathe/sphere), tail cone, fins, eyes. For attractive look use procedurally-created fish groups with body ellipsoid (SphereGeometry scaled), tail as triangle/fin shape, dorsal fin, pectoral fins, and animated tail rotation and yaw banking. Multiple species/colors: orange clownfish-like, blue tang, yellow tang, neon tetra small, silver fish. Use MeshStandardMaterial with flat shading? Better look: glossy, iridescent colors, eyes black with white ring, gill line. Fish bodies as sphere scaled plus a tail fin mesh using custom BufferGeometry, or a cone shape. Need animate swimming tail. Body group positioned/rotated with direction. Use "tailPivot" at back of body (local -z or +x?) Choose local forward +Z? In Three.js, lookAt aligns -Z. Simpler use local +X forward? Let's define fish body length along Z (nose at z=+0.5, tail at z=-0.5), so lookAt target with object's local +Z facing target? Object3D.lookAt makes -Z axis point at target by default for non-camera objects. So body nose along +Z would face away. Could use body nose -Z and tail +Z. Or build fish along X and rotate to face. Let's build along local +Z and use quaternion setFromUnitVectors(new Vector3(0,0,1), dir) to align +Z to velocity. Then body nose at z=+len*0.5, tail at z=-len*0.5. Body SphereGeometry radius 1 scaled (0.36 width? Use scale set(width, height, length)) body. Tail pivot at z=-length*0.45; tail geometry points extending backwards. Animate tail pivot rotation.y = sin(phase)*0.5 and maybe body wiggle? For turn, fine.

Alternative body geometry custom lathe to shape fish more tapered: use sphere deform vertices: for each vertex position, radius depends on z to taper. We can create BufferGeometry from SphereGeometry and modify each vertex x,y based on z. Example sphere radius 1 centered, position.z between -1,1; scale factor = 1 - 0.4*z? want nose narrow. Let s = sqrt(1-z*z); taper = 1 - 0.25*Math.max(z,0) - 0.15*Math.max(-z,0); pos.x *= s*taper; pos.y *= s*taper; then scale (width, height, length) and translate z maybe. That gives teardrop fish. Tail attached to narrow end at z=-len. Additional dorsal fin triangle along top mid-back, pectoral fins as flattened cones or shape geometry.

Could do nice fish: body Mesh with custom vertex colors? Use MeshPhysicalMaterial color, roughness .3, metalness .05, clearcoat .6; add subtle stripes per species using canvas textures generated inline (canvas allowed, no external images). Fish stripes texture applied to body geometry via uv? Could just create separate stripe meshes (torus segments) or use body material with generated CanvasTexture patterns (vertical stripes, gradient). That would look nice. UV of sphere works. Create canvas 256x128, draw base color gradient, stripes. Need separate texture per species. Set colorSpace = THREE.SRGBColorSpace. Use map and material color white. For tang, yellow with dark pattern? Could make 4 species:
- "Ember" orange with white vertical stripes (clownfish)
- "Azure" blue with black/dark blue pattern
- "Sun" golden yellow
- "Silver" pale blue with horizontal line.
Draw in canvas with gradient and ellipses etc. Add emissive slightly? MeshPhysicalMaterial emissive same color low intensity.

Fins can be material with color tinted darker, transparent .9, DoubleSide.

Tank: big rectangular glass aquarium. Coordinates x width ~ 15, y height ~ 8, z depth ~ 7. Camera at (0, 1.2, 15) looking at (0, 1.2, 0), fov 42, maybe slightly angled to show depth? If front-on, fish positions mostly visible. Can use perspective with camera at (0.8, 2.0, 14.5), lookAt(0,1.4,0). Tank bounds x [-6.6,6.6], y [0.2,6.4], z [-3,3]. Glass walls: MeshPhysicalMaterial transmission? Transmission in three.js might be heavy and obscure. Could make thin planes with transparent material opacity .08, color cyan, roughness .1, side DoubleSide, depthWrite false. Add edges with LineSegments on BoxGeometry via EdgesGeometry with LineBasicMaterial transparent .4. Tank base: wooden or dark slate stand with box, tank floor slab with sand top. Sand: PlaneGeometry with custom shader? Could use MeshStandardMaterial with procedural generated canvas texture (noise dots) for sand; large plane at y=0.2. Add subtle caustic light effect on sand using ShaderMaterial with procedural animated lines and radial falloff, blended additive on top of sand. Also a caustic pattern maybe in tank volume? Use a custom shader plane slightly above sand, fragment shader computing voronoi-ish sin wave caustics, transparent, additive. We can implement simple classic caustics using layered sin noise in fragment shader with time. Uniforms uTime. Use plane rotation.x=-PI/2, y=0.22. Additive blend, depthWrite false, opacity low (0.18). Could look magical.

Water volume: box with MeshPhysicalMaterial transparent blue, opacity .05, side BackSide? Better glass panels. Add a slightly tinted fog: scene.fog = new THREE.FogExp2(0x03283c, .035) but affects sand and fish; nice depth.

Background: dark gradient via CSS behind canvas; scene background color 0x031018. Add back wall of tank with darker blue gradient? Add a plane behind with ShaderMaterial gradient (deep teal). Add light shafts: translucent cones from top, gently rotating; maybe 3 light beams using CylinderGeometry cone with ShaderMaterial additive, opacity 0.04. Or simple SpotLights illuminating from above plus translucent beams. Point lights colored cyan.

Bubbles: small spheres with transparent material, rising with wobble, respawn at bottom from certain points. They add motion.

Plants: procedural seaweed using curved ribbons geometry with vertex sway, and tall green blades. Build plant groups with several blades, each a plane/ribbon with segments, animate vertex positions? Could implement per-frame rotation sway. Use MeshStandardMaterial doubleSide green with emissive. Use CatmullRomCurve3 and TubeGeometry? Seaweed as ribbon: create BufferGeometry strip of segments along curve, animate shader via onBeforeCompile or update CPU each frame. Simpler: build each blade as a tapered cylinder-ish TubeGeometry along a curve and sway group rotation. Seaweed with 6 blades per cluster, varying heights. Colors: dark green/teal/purple. Place along sand at back and sides. Animate sway by setting group.rotation.z = sin(t*0.7 + phase)*0.08, rotation.x likewise.

Rocks: IcosahedronGeometry deformed (flat shaded) grey, clusters at back corners and along bottom. Add coral: branching coral in pink/coral made of cylinders between points. Could build coral recursively with cylinders. Nice visual detail. Maybe a treasure-like amphora? Keep natural.

Add water surface: PlaneGeometry width, depth at y=6.2 with MeshPhysicalMaterial transparent opacity .18 color teal, roughness .1; animate vertex waves? Could use shader material with sin ripples and transparent. Top visible slightly due camera angle. Ripples when click: create expanding torus ring on water surface? Also click in water creates ripple at hit point on the water? More natural: click adds ripple ring on sand/water surface and startles fish nearby. Add ripple rings as horizontal circle (RingGeometry) at hit y near water surface? Could create a ripple in vertical plane? Simpler: clicking in tank creates a ripple ring at the water surface at hit x,z with expanding scale and fading, plus fish get a startle impulse. Show UI "Click to send a ripple". Space key maybe same. Since keyboard events require click and message; we're mostly pointer. We can show "Move your cursor to gently startle the fish · Click for a ripple". Since keyboard optional. Could implement key 'r' toggles ripple? Maybe unnecessary.

Pointer interaction: use Raycaster from mouse normalized, intersect a large invisible plane at y? We need fish flee when cursor near them in screen space (more intuitive) rather than exact 3D world point. Best approach: project fish world position to screen, compute pixel distance to cursor. If within ~170 px, apply repulsion in world based on screen-space direction projected onto world? Could compute pointer ray and nearest point along ray for each fish: project fish onto ray and get distance to ray in world; but "near" in screen terms is intuitive. We can use screen-space repulsion: for each fish, project its position with camera; compute dx = fishScreen.x - mouseScreen.x, dy = fishScreen.y - mouseScreen.y; if distance < radius (e.g., 180px), apply a world-space force away from the world point closest to the ray. Compute ray-plane intersection with plane z = fish.z? For each fish, closest point on pointer ray: ray.closestPointToPoint(fish.position, tmp); then force direction = fish.position - tmp (world). That gives a natural vector from the cursor ray in 3D. Combine screen distance check for the activation radius. This means hovering near a fish (screen) makes it swim away from the cursor along the perpendicular of the ray; good.

Also when pointer enters tank, repulsion. Pointer smoothing: lerp target NDC. Use pointer active only within canvas bounds; on pointer leave, fade influence to 0.

Fish behavior:
- velocity vector
- desired direction from wander (use per-fish phase noise) + school cohesion + separation + alignment? They should look lively, slowly cruise.
- pointer repulsion strong (e.g., 7 units) within radius 2.8 world and screen distance < 180; also startle.
- bounds: keep within x [-6.2,6.2], y [1.0,5.7], z [-2.6,2.6]. Soft steering toward center if outside margins.
- separation from other fish within 1.2 units.
- update position += vel*dt; clamp/wrap? Prefer steering away from edges.
- orientation: quaternion slerp to align +Z with velocity; bank based on turn: roll around local Z? Use rotation.z on model? Since quaternion orientation with +Z forward, we can apply roll via fishBody.rotation.z? Fish group contains bodyPivot; roll group around z axis? Forward +Z, roll axis is Z, so object.rotateZ(bank). We can set bodyRoot.rotation.z = bank, but quaternion orientation on outer group; fine.
- speed between 0.7 and 2.2 depending species; repulsion increases speed.
- tail animation phase += speed * dt * 4; tailPivot.rotation.y = sin(phase)*0.45*(speed factor), also pectoral fins flap.
- slight body yaw wiggle.

Demo mode requirement: Since it's a sim, fish swim automatically. But developer says games must start in self-playing demo mode behind "Click or press a key to start" message. This isn't a game, but to be safe, include an intro overlay / hint badge "Move your cursor — the fish will notice" with subtle "Click or press any key to start" perhaps? Actually requirement says behind a message, but if we overlay and pause the simulation, first screenshot may be blocked. We want first paint showing the aquarium in action. We can show a small unobtrusive pill "Click or press any key to start" that fades on first interaction while fish already swim (self-playing demo). That satisfies both: message visible, demo already running. Perhaps overlay title with "Click to enter the water" could be intrusive. Better: a centered small glassy hint at bottom: "Move your pointer to startle them · Click for a ripple", with an "AUTO-SWIM ACTIVE" badge. The exact phrase "Click or press a key to start" might be desired to satisfy test. Add at bottom center a subtle badge: "Click or press a key to start interacting" that fades after first interaction. Also maybe on-screen overlay panel top-left.

UI design: full screen canvas. Top-left: small brand label "PELAGIC / LIVING TANK", large elegant serif-ish title "The shy ones." with subtitle "A 3D aquarium that notices you." But no external fonts; use system fonts (Georgia for serif accent, -apple-system for sans). Top right: small stats panel with fish count, temperature, calm/stirred state, live indicator. Bottom-left: legend of species with colored dots. Bottom-center: interaction hint pill. Bottom-right: buttons? e.g., "Release another fish" (+), "calm mode" toggle. Include functional controls: click "Release fish" adds a fish; "Lights" toggle day/night? Keep simple, ensure all visible. Could have small round controls with SVG icons inline: pause/resume? Maybe "Add fish" button with plus; "Reset" button. Use HTML overlay.

Need make responsive down to 375. At mobile, title smaller, stats condensed/hide species legend, hint wrap.

Need ensure no horizontal scroll, no external fonts/images.

Need no console errors. Use import map and import * as THREE from 'three'; import maybe RoundedBoxGeometry from addons? Allowed CDN via import map. Could use RoundedBoxGeometry for tank stand edges and UI? Let's use core only to reduce risk, though addons allowed. RoundedBoxGeometry import from 'three/addons/geometries/RoundedBoxGeometry.js' works. It creates nice beveled stand. But maybe avoid extra network. The import map points to CDN, allowed. Use RoundedBoxGeometry for stand and maybe pebbles. We can use core.

Performance: 14-18 fish with geometry shared? Create body geometry per species maybe clone. Use shared geometries for body (teardrop), fins, eyes. Draw calls moderate. Shadows: enable renderer.shadowMap, directional light shadows maybe 1024, but translucent fish cast? Fine. Sand receives. Limit pixel ratio to 1.75.

Postprocessing? Not needed; can add subtle vignette via CSS overlay radial gradient and grain via CSS (SVG data URI? inline CSS radial gradients). Use CSS pseudo-element for vignette.

Scene details:
- Renderer: alpha true? Set background color 0x04121c. Use antialias true. toneMapping ACESFilmic, exposure 1.1, outputColorSpace SRGB.
- Camera: PerspectiveCamera(42, aspect, .1, 100), position (0, 2.2, 15.5), lookAt(0, 2.2, 0). Maybe slight offset x=1.4 gives 3/4 view but tank front edges still visible. Tank width 14, depth 7.5. Camera z distance ~17 with fov 42 gives visible height ~13; tank height 7. We'll frame tank nicely with stand. Maybe camera pos (1.2, 3.0, 16.5), lookAt(0,2.5,0), fov 38. Visible height at z16.5 = 2*16.5*tan(19°)=11.4; tank 7 high fits with margin. Width visible = 11.4*aspect(1.6)=18.2; tank 14 wide fits. Good. UI overlays top-left might overlap tank; camera centered.

Tank geometry:
- interior: x [-6.8,6.8], y from 0.35 to 7.0, z [-3.4,3.4].
- sand floor y=0.3; water top y=6.8.
- glass box: use BoxGeometry(13.8, 6.8, 7.0) with MeshPhysicalMaterial transparent, opacity 0.07, color 0x8fe9ff, roughness 0.05, metalness 0, transmission? transmission 0 but transparent. side DoubleSide, depthWrite false. This gives tint surfaces. But box shows front face over fish with slight tint, okay.
- edges: EdgesGeometry(Box) with LineBasicMaterial color 0x7fe6ff opacity .35; plus brighter corner highlights.
- back panel: plane at z=-3.4 with gradient shader from deep navy bottom to teal top; maybe also subtle noise.
- front glass: plane at z=3.5 opacity .06 with a diagonal reflection streak: create a PlaneGeometry with gradient texture (canvas) transparent, rotated? Create a "reflection" quad slightly in front of glass with canvas texture of soft diagonal white streaks, opacity .18, blending Additive.
- base: RoundedBoxGeometry? tank frame: bottom slab box 14.6 x .5 x 7.8 at y=0, dark walnut wood color 0x2b1a12 with roughness .7; top lip maybe thin. Cabinet below: box 14.2 x 2.4 x 7.2 at y=-1.7 dark wood, plus top trim. But page viewport vertical might cut; camera frame includes tank and maybe a bit of cabinet. Since screen 1280x800 aspect 1.6, tank 14 wide and 6.8 tall fits; stand could be partially visible bottom. Let's set tank bottom at y=0, sand at 0.28, water top 6.6, camera target y=2.9. Stand below y -1.6 to 0.

We could make stand minimal: base slab at y=-0.25 height .5, legs? Actually aesthetic: tank sits on a low plinth with dark stone. Add box 14.8,.6,7.6 at y=-.45 and a slightly inset cabinet 13.6,1.6,6.8 at y=-1.5 (partially out of frame) with a warm LED line under the tank rim (emissive strip) giving glow.

Water surface: at y=6.55, plane 13.6 x 6.8 rotated -PI/2 with custom shader: compute ripples using sin functions, color teal, alpha .22, plus specular-ish sheen. Add moving caustic pattern in sand.

Lighting:
- HemisphereLight(0x9fe8ff, 0x0a1a22, .7)
- DirectionalLight(0xffffff, 1.6) position (4,10,6), cast shadow, shadow camera bounds.
- PointLight cyan at (-5,5,1) intensity 15 distance 14.
- PointLight warm at (5,3,-1) color 0xffc98a intensity 8.
- Small SpotLight from top with penumbra for beams.

Plants: function createSeaweed(x,z,height,color,phase): group at sand y; blades as TubeGeometry along CatmullRomCurve3 with 4 control points and radius .1 tapering? TubeGeometry constant radius. Could create custom tapered tube: use CylinderGeometry? Let's make ribbon geometry with segments and width taper, double side, slight curve. For each blade, create a plane geometry with widthSegments 1 and heightSegments 10, then modify vertices: position along y 0..h, x = curve offset, z small twist. Use MeshStandardMaterial. Each blade group animate rotation z. Simpler custom function createBlade(h,w,lean):
  const geo = new THREE.PlaneGeometry(w, h, 1, 12); translate y h/2; modify positions: for each vertex, t=y/h; x += Math.sin(t*1.6)*w*? Actually blade curve: x = original x + lean*t*t; z = sin(t*3)*.1. Then set mesh rotation.y random and position. Animate mesh.rotation.z = sin(t*0.8+phase)*0.12 and rotation.x small. Add material colors varied.
- Sea grass clusters at back z=-2.6 (x -5,-3.8,3.6,5.2) and front corners (x -6, z 2) smaller so fish remain visible.
- Purple sea fan/coral.

Rocks: create deformed icosahedron, material standard grey with roughness. Positions.

Coral: recursive branch cylinders with coral pink material; create at (-4.9,0.3,-2.6) etc.

Fish species textures: implement makeFishTexture(kind):
 canvas 256x128, fill gradient base.
 clown: orange gradient + 3 white bands with dark outline? Use ctx paths.
 tang: blue gradient + darker wave pattern + yellow tail.
 yellow: golden gradient + subtle stripes.
 tetra: silver-blue gradient + red lower stripe + neon blue line.
We need map mapping on teardrop sphere; UV sphere wraps around; stripes vertical along x will appear as bands around body. Good.

Body geometry: custom teardrop from SphereGeometry(1,24,16). Modify:
 p = vertex; z normalized in [-1,1]; taper = 1 - 0.35*Math.max(0,z) - 0.28*Math.max(0,-z); x*=taper; y*=taper; also flatten belly? y += 0.05? Then mesh scale(width,height,length) where length ~0.85. Body centered, nose at +z*len. Tail pivot at z=-len*0.92.

Fin geometry: create Shape for tail: points (0,0),(0.55,0.42),(0.42,0),(0.55,-0.42) in local XZ plane? Tail fin should be vertical (in XY plane? Fish vertical body y up, forward z; tail fin extends along z and spreads in y). So tail shape in local (z,y) plane with thickness none, at tail pivot z=-len. Build BufferGeometry with vertices forming triangle fork: base (0,0), top (0.65,0.55), mid (0.42,0), bottom(0.65,-0.55) in coordinates where x is backward -Z? Let's define tailPivot at z = -len; tail fin extends further -z. Shape in XY plane then rotate: shape coordinates x-> -z, y->y. Use custom BufferGeometry positions:
 base: (0,0,0) ; top: (0,0.5h,-0.7l) ; notch: (0,0,-0.45l) ; bottom: (0,-0.5h,-0.7l). two triangles: base-top-notch, base-notch-bottom. Add thickness? DoubleSide material so fine. Add slight central ridge? okay.
 Dorsal fin: similar plane along top y, from z -0.1 to .45, triangle.
 Pectoral fins: small flattened cone or plane attached side, animate rotation.
 Anal fin small under body near tail.
 Eyes: small spheres at front sides: position (±(width*0.82), height*0.25, len*0.5). White sphere radius .07 + black pupil .04 slightly outward. Eyes attached to body so they rotate with body.

Fish group hierarchy: fish.root (position/quaternion), fish.model (roll + body). bodyMesh, tailPivot, pectoral fins pivot.

Scale per species: small tetra length .55, tang length .95, clown 1.0.

Fish shadows: enable castShadow on body only maybe; transparent fins no shadow.

Fish count 16. Add 3 neon small tetras, 4 yellow, 4 clown, 5 blue? Colors make lively.

Steering details: Each fish has pos, vel, speedBase, phase, species. Use deterministic pseudo-random? Use Math.random fine.

Update:
 const t = clock.getElapsedTime();
 pointer NDC smoothed.
 for each fish:
   // wander target
   wanderAngle += (Math.random()-0.5)*dt*1.5; desired = (sin(wanderAngle*?)...) better use per-fish noise: desired.set(Math.sin(t*0.33+phase)+..., ...). Use vector wandering: desired = normalize(new Vector3(sin(t*0.27+phase*1.7), sin(t*0.19+phase*2.3)*0.6, cos(t*0.23+phase))).
   // cohesion: average neighbors within 3.2 -> steer toward avg
   // separation within 1.0 -> away
   // pointer repulsion:
     project fish to screen; dxNdc? compute screen px.
     const proj = fish.pos.clone().project(camera); sx=(proj.x*.5+.5)*w; sy=(-proj.y*.5+.5)*h; d=Math.hypot(sx-pointerPx.x, sy-pointerPx.y); if d<180 && pointer.active:
        closest = ray.closestPointToPoint(fish.pos, tmp) (use smoothed ray from pointer NDC)
        away = fish.pos - closest; if length small, use camera right vector or something; normalize.
        strength = (1-d/180)^2 * 8; desired.addScaledVector(away, strength)
        also mark startled=true; state UI.
   // avoid walls
   const margin: if x< -5.6 desired.x += ... etc.
   desired.normalize()
   // steering: vel.lerp(desired*speed, 1 - exp(-dt*1.8)) ; with pointer speed boost
   maxSpeed = base + startled*2
   fish.pos += vel*dt
   // bounds hard clamp soft
   orientation quaternion setFromUnitVectors(FORWARD, velNorm); slerp .12
   bank = clamp(turn rate)...
 Use applyQuaternion to compute turn via cross of old forward vs new? simpler bank = -vel.x? Use difference of desired heading; compute angle = atan2 of cross. Or bank proportional to lateral acceleration: bank = clamp((oldVel.x*newVel.y - oldVel.y*newVel.x)*..., -0.4,0.4). Use signed turn in y: crossY = oldVel.x*newVel.z - oldVel.z*newVel.x. bank = clamp(-crossY*0.5, -.45,.45).

Pointer ray: update on pointermove event. raycaster.setFromCamera(pointerNdc, camera). For closest point each fish each frame, use ray.closestPointToPoint. But note fish far in depth could be "closest" point which yields a vector that mostly is depth difference? closestPointToPoint returns the closest point on the ray to fish; vector fish - closest is perpendicular to ray, so good. If the fish is behind the camera? no.

Could also use world-plane intersection: pointer point on plane z=0 for simpler; then repulsion in x/y. But ray closest is more accurate for depth.

The cursor visual: custom DOM cursor ring that follows mouse (only within canvas). A translucent ring with dashed border and "near" label? At pointer, show a soft glow circle (CSS radial gradient) that expands when fish near. This communicates interaction. But default cursor hidden? Keep default cursor visible plus ring maybe. Use CSS .cursor-ring with border cyan, mix-blend screen, pointer-events none. Update transform translate. Scale when startled. On mobile no cursor; tapping creates ripple startle.

Interaction: pointerdown on canvas: create ripple at hit point: raycast to water surface plane y=6.4? If user clicks near fish, ideally startle nearby fish and create ripple at pointer projected to a plane facing camera. Let's create ripple on the vertical plane z = 0 at hit point (x,y) so it looks like a ripple in the glass? Alternatively water top ripple at (hit.x, 6.45, hit.z). Compute intersection with plane y=6.45 (water surface). If click in tank, x,z derived from ray-plane intersection; if ray doesn't hit (looking upward?), fallback to z=0 plane. Then ring at surface; startle all fish within 4 world units of click (add velocity impulse) and a burst of small bubbles. Since camera looks slightly down, clicking fish at y 3 projects to surface plane y=6.45 behind/in front; the x,z may be odd. But still looks like a ripple at surface. Better create a 3D "ripple sphere" at click location in tank: expanding transparent torus ring oriented facing camera (billboard) — like sonar pulse. Click yields a cyan ring expanding at the clicked fish depth, visibly in the water. That's cool and intuitive: a ripple pulse ring in the tank. Use TorusGeometry(radius .1, tube .02) billboard to camera; scale up and fade over 1.2s. Plus actual water-surface ring maybe not necessary. Also startle fish within 3 units. UI can say "Click for a sonar ripple".

Let's implement ripple mesh: TorusGeometry(1, .025, 8, 48) with MeshBasicMaterial color 0x8ef7ff transparent opacity .9, blending Additive, depthWrite false. Each frame scale = 0.2 + age*4; opacity = (1-age)*0.7; also a second ring offset. Position at click intersection with plane z=0.3 (camera-facing) so ripple around fish depth. billboard: mesh.quaternion.copy(camera.quaternion) each frame (ring in XY plane facing camera). Click position: raycaster.ray.intersectPlane(planeZ0) -> point; clamp in tank bounds.

Also on click, add bubbles burst at that point.

UI "state": "CALM" when no fish startled in last 1.2s; else "STARTLED" with count of fish reacting. Update DOM text. Also show "16 residents".

Buttons:
- "+ Release" adds a fish (random species) with a spawn near center and an intro animation.
- "↺ Reset" resets positions? Could have "Calm" button that toggles auto-demonstration "pointer follow" for mobile? Maybe better "Guide" button: toggles a ghost cursor that automatically moves in a circle so the demo shows fish fleeing without user interaction. That satisfies self-playing demo mode strongly! Add a "Demo glide" toggle button that makes a virtual cursor glide around, causing fish to flee, so even without user moving, fish react. Great idea: The demo should show fleeing in screenshot. But initial screenshot at 3 seconds: if auto-demo cursor is active from start, fish are already fleeing near the ghost cursor. That demonstrates the key feature visually. We can implement an "AUTO" mode active initially: a virtual pointer moves along a smooth Lissajous path across the tank, and the cursor ring shows at that location, labeled "auto". After user moves the mouse, auto mode disengages (user control). Provide button "Auto demo" to re-enable. And a small badge in UI "Auto-demo active — move your pointer to take over". That is exactly self-playing demo mode behind "Click or press a key to start" message? We can show hint "Click or press any key to take control" overlay while auto-demo runs. 

So state: autoDemo = true initially. Virtual pointer NDC moves: x = sin(t*0.42)*0.55 + sin(t*0.17)*0.15, y = sin(t*0.31+1)*0.28. That keeps it within screen. The fish near that screen position flee. The DOM cursor ring shows the auto pointer as a dashed ring (or "AUTO" label). When real pointermove happens (pointerType mouse, movement), set autoDemo=false and update hint. Click/keyboard also ends auto mode and shows "You have control".

But if auto cursor moves constantly, in a screenshot 3 seconds, maybe we see fish scattering. Good. Add visual "ghost cursor" ring visible so people understand.

Implementation: pointer = {ndc: Vector2, target: Vector2, active:false, auto:true}. In frame: if auto, compute autoNdc; else target from events; pointer.ndc.lerp(target, 1-exp(-dt*8)). Actually for auto just set target to auto path; lerp smooth. Update raycaster from smoothed ndc.

Ring DOM position: convert ndc to pixels: x=(ndc.x*.5+.5)*w, y=(-ndc.y*.5+.5)*h. If auto mode, ring class "auto" with text "AUTO" and dashed border. Real: solid border, no text. Scale slightly when fish react.

Hint element bottom center: initially: "Auto-swim demo • move your pointer to take over — click for a ripple". After user interaction: "You're in control — click anywhere for a ripple". Requirement: "Click or press a key to start" message. We can include exact phrase in a small initial chip: "Click or press a key to take control". Let's craft bottom bar with two elements: left status "Click or press a key to take control" (fades after interaction), and hint text. That satisfies.

Header:
Top-left card (not too big): 
<div class="eyebrow"><span class="dot"></span> PELAGIC AQUARIUM · LIVE</div>
<h1>The shy ones<span>.</span></h1>
<p>A living 3D reef. Drift your pointer and watch the school scatter.</p>
Top-right card: stats: Residents 16 / Water 24°C / State Calm.
Bottom-left species legend chips: Ember (orange), Cobalt (blue), Gold (yellow), Neon (cyan).
Bottom-right control buttons: [Auto demo] [+ Resident] maybe [Pause].

Need ensure overlay doesn't block canvas interactions: pointer-events none on panels except buttons.

Tank should be the visual hero; UI subtle.

Let's think of rendering pipeline details and potential errors:
- Import map must be exactly as given? Use `<script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"}}</script>`. Provide exactly.
- Then `<script type="module"> import * as THREE from 'three'; ... </script>`.
- Renderer creation wrapped in try/catch? If WebGL unavailable, display fallback message. But likely fine. Avoid top-level errors. We can wrap everything in init() and catch to show a message in #fallback.
- No external fonts.

Canvas textures: create with document.createElement('canvas'), use CanvasTexture. Set wrapS Repeat? Fine.

Fish textures: We want body UVs from SphereGeometry; after modifying positions, UVs remain. SphereGeometry radius1 widthSegments 24 heightSegments 16. The seam might show but fine.

Make body geometry per species? Shared base geometry, texture differs per species. Use shared geometry for all, and scale differs. Tail geometry shared.

Fish creation function:
function createFish(species, spawn) {
 const root = new THREE.Group();
 const model = new THREE.Group(); root.add(model);
 const bodyMat = new THREE.MeshPhysicalMaterial({map: species.tex, color: 0xffffff, roughness:.38, metalness:.05, clearcoat:.7, clearcoatRoughness:.25, emissive: new THREE.Color(species.emissive), emissiveIntensity:.12});
 const body = new THREE.Mesh(bodyGeo, bodyMat); body.scale.set(spec.w, spec.h, spec.len); body.castShadow = true; model.add(body);
 // belly sheen? maybe add a smaller white ellipse? skip.
 tailPivot = new Group(); tailPivot.position.z = -spec.len*0.92; model.add(tailPivot);
 tail = new Mesh(tailGeo, finMat); tail.scale.set(spec.len*...); etc.
 Wait tailGeo defined in units where tail extends -z 0.7 and y .55. scale by spec.len.
 dorsal fin: mesh with geometry, position y = spec.h*0.85, z=0.0, rotation? define geometry vertices in local coords directly.
 pectoral fins: pivot groups at (±spec.w*0.75, -spec.h*0.1, spec.len*0.15), fin geometry extends outward x and slightly back z; animate rotation.z flap.
 eyes: positions at x=±spec.w*0.72, y=spec.h*0.28, z=spec.len*0.52. Since body scaled sphere at nose narrower; at z=0.52*len, the body x radius ~ taper*sqrt(1-0.27)=~0.8*w. So eye x ~ w*0.7 works. Eye white sphere radius spec.len*.1, black pupil radius *.55 offset outward x by .04 and forward z .02.
 gill line? add a thin torus? Could add a small dark curve at z=spec.len*0.25; skip.
 // Add subtle "fin stripes" using texture? fins use simple material.
 root.position.copy(spawn);
 root.rotation? updated.
}
Species specs: 
 ember: {w:.34,h:.42,len:.82, speed:1.0, tex:orange, fin:0xff7a2a}
 cobalt: {w:.30,h:.44,len:.9}
 gold: {w:.34,h:.46,len:.9}
 neon: {w:.15,h:.22,len:.5}
Maybe body geometry has nose at +Z.

Swim animation: tailPivot.rotation.y = Math.sin(phase)*0.45; Also tail rotation.x slight. Pectoral fins: fin.rotation.y = Math.sin(phase*1.3 + side)*0.3? Fin pivot rotation z flap up/down. Let's define fin geometry extending along +X for right side (x positive) and mirror for left via scale.x = -1 (careful normals; use material DoubleSide, negative scale flips normals but DoubleSide handles). We'll create fin geometry triangle points (0,0,0),(0.55,-0.05,-0.25),(0.15,0.0,-0.4)? Let's keep simple and use shape.

Simplify: pectoral fin = ConeGeometry? Use flattened ellipse (SphereGeometry scaled .18,.03,.28) tinted, placed at side and rotated; flap via rotation.z = sin*0.35. Since it's an ellipsoid it reads as a fin. Good: new Mesh(sphereGeoSmall, finMat) scale (0.22,0.03,0.14) with pivot group at side, fin mesh offset outward.

Dorsal fin: flattened sphere? A triangular fin reads better. Use ShapeGeometry for a triangle in XY plane then rotate to align along body top. Let's do custom BufferGeometry triangles in local coords:
 dorsal: vertices (0,0,0.32len), (0,0.45h,-0.25len), (0,0,-0.35len) — plane in YZ (x=0) so it's vertical; visible from side. Attach at y = h*0.8, z = 0. Use geometry scaled per spec.
 Also a second smaller dorsal? no.
 Ventral fin similar smaller under.

Tail fork geometry as described.

Body scale: the teardrop sphere has radius 1, so width full 2*w? If scale x=w, total width 2w. So set w=.28 gives width .56. Fine.

Fish shadow on sand.

Fish textures canvas details:
function fishTexture(kind):
 const c=document.createElement('canvas'); c.width=256;c.height=128; ctx.
 gradient from top (lighter) to bottom (darker).
 kind 'ember': orange gradient; draw 3 white vertical bands with slight dark border using ctx.fillStyle white and wave path; use ctx.beginPath with quadratic curves to make wavy bands.
 kind 'cobalt': deep blue gradient; draw dark navy wave bands along lower half and a yellow/white accent near tail? Since body texture covers sphere, tail is separate material. draw horizontal lighter stripe along middle and dark blotches.
 kind 'gold': yellow gradient; draw subtle vertical darker stripes and light belly.
 kind 'neon': silver-blue; draw a bright cyan horizontal stripe along midline and red stripe below rear.
 Need UV mapping: sphere UV u wraps around circumference, v from bottom to top. Horizontal stripe in canvas corresponds vertical position on body (belly to back). Vertical bands correspond around circumference - these are like rings around the fish which is like bands perpendicular to body? Clownfish bands are vertical across body, i.e., perpendicular to the long axis. On sphere UV, u is around the circumference (which includes along-body? no). Sphere UV: u goes around longitude, v from pole to pole. With body along Z, the poles are at z=±1 (nose/tail). So v maps along body axis, u around circumference. Vertical stripes in canvas (constant u range) are longitudinal stripes along body — like a fish with stripes running head-to-tail. Clownfish bands are transverse (perpendicular). We want horizontal bands in canvas at specific v (along body). So draw horizontal bands across canvas for clownfish (3 vertical? Let's think: canvas x = u = around circumference; canvas y = v = along body length. A band across the body (perpendicular to length) = constant y region in canvas -> horizontal stripe across canvas. Yes, clownfish: draw horizontal white bands at y=30 and y=90 (near head and mid). Blue tang's black pattern like a large area.
 
So:
 ember: horizontal white bands with wavy edges (y around 30, 85), plus black thin edging.
 cobalt: gradient deep blue; draw a big dark navy shape covering lower-left region (palette pattern); light cyan line along y ~ 40.
 gold: yellow gradient with fine horizontal darker lines? Just gradient + white belly at bottom.
 neon: silver with a cyan horizontal band at y=55 and red band at y=78 (lower rear).
 
Also we could add "scale shimmer" via material clearcoat.

Textures mapped to body - body poles at nose/tail, so UV seam at one side; okay.

Background plane gradient shader:
vertex pass uv; fragment mix bottom (0x03141f) top(0x0b3a4a) plus subtle vertical light shafts and noise.
 Use ShaderMaterial uniforms uTime, colors; but scene fog? ShaderMaterial needs fog: true and include chunks, or just set no fog. Fine, it's back wall.

Water surface shader:
 vertex: compute wave y offset based on position and time; pass world pos. fragment: compute caustic-ish highlights with sin patterns, color mixing teal, alpha .18 + highlight. Side DoubleSide, transparent.
 Since the plane rotated -PI/2, local x,y map to world x,z. We'll compute in vertex using position.x, position.y (plane local) before rotation; simpler create PlaneGeometry then rotateX(-Math.PI/2) on geometry so local coordinates are x,z-ish? After rotateX, positions have y ~0, x and z set. Then vertex shader can use position.x and position.z. Do geometry.rotateX(-Math.PI/2). Then vertex: float w = sin(position.x*1.7 + uTime*0.9)*0.03 + sin(position.z*2.1 - uTime*0.7)*0.02; transformed.y += w.

Caustics on sand: fragment shader on plane (geometry rotated) computing pattern:
 float caustic(vec2 p){ ... } Use layered sin: 
 vec2 i = p; float c = 0.0; float inten=.005; for n 0..4: float t = uTime*(1.0-(3.5/float(n+1))); i = p + vec2(cos(t-i.x)+sin(t+i.y), sin(t-i.y)+cos(t+i.x)); c += 1.0/length(vec2(p.x/(sin(i.x+t)/inten), p.y/(cos(i.y+t)/inten))); c /= 4.0; c = 1.17 - pow(c,1.4); return pow(abs(c),8.0);
 That's the classic shadertoy caustic; it can be heavy but on a small plane ok. Then color = vec3(0.55,0.95,1.0)*c*0.35; alpha = c*0.5? Use additive blending so alpha maybe. Let's implement a simpler caustic: 
 float v = sin(p.x*3.0 + sin(p.y*2.0 + t))*sin(p.y*2.7 + sin(p.x*1.8 - t*0.8));
 v = pow(abs(v), 6.0) ...
 Use combination of two for net-like pattern. Fine.

Light shafts: 3 cones from top water down, with ShaderMaterial fading with height, additive. ConeGeometry(radiusBottom? ConeGeometry(radius, height, 32, 1, true)) rotated? Cone's axis is Y, tip at +height/2, base at -height/2. We want narrow at top, wide at bottom: cone default tip at top (+y) and base at bottom (-y), radius at base = radius. Good. Place at y = (top+bottom)/2, x/z positions. Material: MeshBasicMaterial color 0xbff6ff, transparent, opacity .035, blending Additive, depthWrite false, side DoubleSide. Animate opacity oscillation and rotation.

Bubbles: instanced? Simple Mesh sphere geometry shared with transparent material; 26 bubbles, each has pos, speed, size, wobble phase. MeshBasicMaterial? Use MeshPhysicalMaterial transparent .35 roughness .05 metalness 0 transmission? Keep MeshStandardMaterial color 0xc8f6ff transparent opacity .35, emissive low. Bubble rings: small spheres with emissive highlights. Cheap enough.

Bubbles rise from a few vents (rock holes). Add 3 vents positions. Bubbles spawn at random vents.

Particles (marine snow): tiny points drifting using Points with BufferGeometry and PointsMaterial size .03 color white opacity .4. 200 points. Animate positions slowly in shader? Just update y in CPU each frame for 150 points: fine.

UI and styling:
Use CSS variables. body background radial gradient #041018 etc. Canvas absolute inset 0.
Add film grain overlay via CSS: repeating? Use an inline SVG data URI noise? Could do `background-image: url("data:image/svg+xml,...feTurbulence...")` — that's an inline data URI, allowed (not external). But the rule says no external images; data URI is inline, should be fine. However, safer to skip or use simple CSS gradients. I'll add subtle vignette with radial-gradient and a scanline-free look.

Cursor: `.cursor` fixed 54px circle with border: 1px solid rgba(140,240,255,.55); background radial-gradient; box-shadow 0 0 24px rgba(80,220,255,.25); transform translate(-50%,-50%); pointer-events none; z-index 5; transition width/height .2s, border-color. Use left/top style set each frame. In auto mode add class .auto: border-style dashed; and show a tiny label "AUTO" under via ::after content "AUTO". But on real pointer hide the ring when leaving the window? Keep fade.

Also show an actual DOM crosshair? The ring is enough.

Status card updates: "STATE: CALM" / "STATE: SCATTERING" with color shift.

Interaction hint pill bottom center, backdrop blur.

Buttons bottom right: 
- "Auto demo" button toggles auto mode; when active, highlighted.
- "+ Resident" adds fish.
- "Sound"? No audio to avoid complexity. Maybe "Ripple" button triggers random ripple for mobile users. Good: buttons: [Ripple] [+ Resident] [Auto]. On mobile, these are tap targets.

Keyboard: Space triggers ripple at auto pointer or center; 'a' toggles auto; 'f' adds fish. First keydown sets userControl.

Also handle touch: pointerdown on canvas triggers ripple (and hides hint). pointermove on touch? pointermove during drag could move pointer; fine.

Add loading overlay: since Three loads from CDN quickly, show a "Filling the tank…" loader that fades after init. First paint should have something meaningful: UI + gradient background visible, then the 3D scene appears within ~1s. Screenshot at 3s sees full scene. Loader fades at init.

The first meaningful visible requirement is satisfied.

Let's write the code carefully.

Detailed code structure:

HTML:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/>
<title>Pelagic — The Shy Ones</title>
<style> ... </style>
<script type="importmap">...</script>
</head>
<body>
<div id="app">
  <div id="scene"></div> (canvas appended)
  <div class="vignette"></div>
  <header class="hud top-left">...</header>
  <aside class="hud top-right stats">...</aside>
  <div class="legend hud">...</div>
  <div class="controls hud">buttons</div>
  <div class="hintbar">...</div>
  <div class="cursor"></div>
  <div id="loader">...</div>
  <div id="fallback" hidden>...</div>
</div>
<script type="module"> ... </script>
</body>
</html>

CSS specifics:
* box-sizing border-box; html,body{margin:0;height:100%;overflow:hidden;background:#030b12;color:#e9f7ff;font-family:...}
#scene{position:fixed;inset:0}
canvas{display:block;width:100%;height:100%}
.hud{position:fixed;z-index:4;pointer-events:none;...}
top-left at 34px/30px. Title clamp(28px,3.4vw,54px); font-weight 300; letter-spacing -0.02em; font-family Georgia, 'Times New Roman', serif? Use serif for title to feel editorial: "The shy ones." in Georgia italic? Maybe use sans for modern, with a serif italic for a word. I'll do title sans with weight 300 and the period in accent color.

Card style: background: linear-gradient(...); border:1px solid rgba(255,255,255,.08); backdrop-filter: blur(14px); border-radius:16px; padding 14px 16px; box-shadow.

Top-left maybe no card, just text with subtle text-shadow. Top-right stats card.

Legend bottom-left: flex column of items with colored dot and name + scientific-ish small label. Maybe horizontal wrap.

Controls bottom-right: flex gap 8, pointer-events auto. Buttons: glassy pill with icon.

Hintbar bottom center: fixed bottom 26px left 50% translateX(-50%), pill: "Click or press a key to start" chip + text. But bottom-left legend and bottom-right controls at the same vertical band may collide at 375px. Use media query for max-width 720: legend hidden, controls bottom 18 right 14, hint bar bottom 82 left 14 transform none, top-left header smaller. Good.

At 1280x800, legend bottom-left (x 30), controls bottom-right, hint center.

Cursor: 64px.

Loader: full screen with radial gradient and text "Filling the tank…" + animated dots; fade out when ready. Make sure it doesn't block pointer? pointer-events none.

JS:

import * as THREE from 'three';

const host = document.getElementById('scene');
let renderer, scene, camera;
try { ... } catch(e){ fallback visible; }

Set renderer:
renderer = new THREE.WebGLRenderer({antialias:true, alpha:false, powerPreference:'high-performance'});
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.15;
renderer.outputColorSpace = THREE.SRGBColorSpace;
host.appendChild(renderer.domElement);

scene = new THREE.Scene();
scene.background = new THREE.Color(0x030d15);
scene.fog = new THREE.Fog(0x051c28, 18, 42); (linear fog start 18 so tank at z ~16 from camera? Camera distance to tank ~16; fog start 16 might haze. Use FogExp2(0x06202c, 0.022). With camera at 16 distance, exp2 factor = exp(-(0.022*16)^2)= exp(-0.124)=0.88 - subtle. Good.

camera = new THREE.PerspectiveCamera(40, w/h, .1, 100);
camera.position.set(0.8, 3.1, 16.2); camera.lookAt(0, 2.75, 0);
Maybe tank should fill more: tank width 14 at distance 16: horizontal FOV = 2*atan(tan(20°)*1.6)= 2*atan(.582)=60.2°; visible width at 16 = 2*16*tan(30.1)=18.6 > 14. Good margin 2.3 each side. Height visible = 2*16*tan20 = 11.65; tank height 6.6 + stand 1.6 = 8.2. Leaves room. Camera target y=2.6 means tank center around 3.3; place camera lookAt y=2.8.

Maybe reduce distance to 15.2 for bigger tank: visible width 17.7. Fine.

Tank constants:
const TANK = {w:13.6, h:6.6, d:6.8, floor:0.32, top:6.6};
Interior half w = 6.8, half d = 3.4.

Build:
1. stand:
- plinth: RoundedBox? Use BoxGeometry with slight bevel? Use simple boxes.
   base = Mesh(BoxGeometry(14.6,0.55,7.6), matWood) at y=-0.3; cast/receive shadow.
   cabinet = Mesh(BoxGeometry(13.6,1.9,6.9), matWoodDark) at y=-1.5.
   topTrim = BoxGeometry(14.2,0.12,7.3) at y=-0.02 with metal-ish? Actually sand floor at 0.32; the glass bottom at y=0.1.
   led strip: BoxGeometry(13.9,0.05,7.1) at y=0.02 emissive cyan; plus PointLight? 
2. sand floor: PlaneGeometry(13.5, 6.7, 1,1) rotated -PI/2, at y=0.3, receive shadow, material with canvas sand texture (repeat 6,4) roughness .95.
   sand texture: canvas 256 noise dots beige.
3. back wall: PlaneGeometry(13.6,6.6) at z=-3.4, y=3.3, shader gradient.
4. glass box: BoxGeometry(13.8,6.7,6.9) center y=3.35 (floor .1? if h 6.7 center 3.35 -> bottom 0, top 6.7). Material transparent 0.055 opacity, side DoubleSide, depthWrite false. Since transparent and depthWrite false, sorting issues minimal.
   Use MeshPhysicalMaterial({color:0x9fe9ff, transparent:true, opacity:0.06, roughness:0.08, metalness:0, side:THREE.DoubleSide, depthWrite:false})
5. edges: EdgesGeometry of box → LineSegments with LineBasicMaterial({color:0x8fe7ff, transparent:true, opacity:.35}).
6. rim highlights: top frame edges maybe 4 thin boxes along edges with emissive? Add a top rim: BoxGeometry(14.0,0.08,7.1) at y=6.72 dark metal; and vertical corner posts thin boxes 0.06.
   Actually aquarium usually frameless. Keep glass edges + a thin dark base frame.
7. front reflection: canvas texture 512x512 with diagonal gradient streaks; PlaneGeometry(13.6,6.6) at z=3.46, material transparent additive opacity .18.
8. water surface: PlaneGeometry(13.5,6.7, 40,20) rotated, at y=6.5, shader.
9. bubbles and particles.

Fish: 16 initial.

Update loops with clock.

Let me think about the pointer repulsion in detail to ensure it actually works and fish visibly flee.

For each frame, update raycaster from pointer.ndc.
For each fish:
  fish.pos.clone().project(camera) → screen coords.
  dist = hypot to pointer px.
  if (pointer.inWindow && dist < 190):
     ray.closestPointToPoint(fish.pos, tmpClosest);
     away = fish.pos - tmpClosest; away.y *= 0.8 maybe; if away.lengthSq()<0.0001 use camera-right * sign.
     away.normalize()
     urgency = 1 - dist/190;
     desired.addScaledVector(away, urgency*urgency*6.0);
     fish.startle = Math.max(fish.startle, urgency);
     fish.react = 1;
 Also a subtle "notice" when within 300px: orient toward pointer slightly (curiosity) - fish look at cursor then dart. Could add: if dist<260 and dist>=190: desired.lerp(towardPointer, 0.2). Nice touch: they notice and approach slightly. Implement.

Speed: baseSpeed species (0.7-1.5). desired.normalize(); targetVel = desired * (base + react*2.6); vel.lerp(targetVel, 1-exp(-dt*2.2)); vel.clampLength(0.2, base+3);
pos += vel*dt.

Also keep fish away from the front glass (z>2.8 steer back) so they don't clip.

For the auto-demo, the virtual pointer moves across the screen; fish near it flee. But wait: screen distance computed in pixels with camera projection — the pointer NDC path ±0.55 x corresponds to about center of tank; fine.

The "away" vector from closest point on ray: since the pointer ray goes into the scene, the closest point is roughly at the fish's depth; the away vector is perpendicular to the view direction, i.e., in screen plane — perfect: fish dart sideways/away on screen. 

To also push fish away from the ray in depth, add a component along the camera direction if the fish is very close to the ray in 3D? Not needed.

Also need the fish to not all flee in the same direction causing them to pile up. Separation handles.

Cohesion: for each fish, loop through others O(n²)=256 fine.

Steering implementation details:
const steer = new THREE.Vector3();
for each fish:
  const wander = new Vector3(Math.sin(t*0.31+phase)*0.9, Math.sin(t*0.23+phase*1.7)*0.45, Math.cos(t*0.27+phase*0.6)).normalize();
  // separation
  let sep = new Vector3(), coh = new Vector3(), ali = new Vector3(); count.
  for other: d = pos.distanceTo(other.pos); if d<3: coh += other.pos; ali += other.vel; if d<1.1: sep += (pos-other.pos)/d²
  desired = wander*0.9 + (coh/count - pos)*0.08 + ali/count normalized *0.25 + sep*0.9
  Hmm, cohesion towards group center could pull them to center of tank; fine.
  pointer force added.
  bounds force: if pos.x < -5.7 desired.x += (-5.7 - pos.x)*1.2 etc. Actually boundary: xLimit=6.2; if x < -5.9, desired.x += 1.5*(|x|-5.9). Similarly y: floor 0.95, top 5.9; z limits ±2.9.

Then desired.normalize().

Speed depends on species and startle. Orientation: 
const forward = new THREE.Vector3(0,0,1);
const targetQ = new THREE.Quaternion().setFromUnitVectors(forward, vel.clone().normalize());
root.quaternion.slerp(targetQ, 1-Math.exp(-dt*4));
But slerp with setFromUnitVectors each frame gives a stable shortest rotation. However, roll/banking: add model.rotation.z = bank. The model group inside root: rotation.z rotates around the local z (forward) axis? Local z is forward (since root aligned +Z to velocity). model.rotation.z rotates around model's local z which equals root's local z = forward. Yes, bank around forward axis. 

Bank computation: compute cross of old normalized vel and new normalized vel: crossY? Actually turn axis = oldVel × newVel; the component along forward (z) determines roll: signed = (oldVel × newVel).dot(forward) / (|old||new|). Then bank = -signed*0.8 clamp ±0.5. Smooth with lerp.

Tail phase: phase += dt * (2.6 + speed*2.2). tailPivot.rotation.y = sin(phase)*0.42*(0.7+speed*0.2). Also tail.rotation.x = sin(phase*0.5)*0.06.

Fish bob: model.position.y = sin(t*1.2+phase)*0.02.

Update eyes? They are children, fine.

Slight head yaw? skip.

Click ripple:
function spawnRipple(point) { create group with 2 rings; add to scene; ripples.push({group, age:0}); startle fish within radius 3.2: fish.vel.add(dir*3) plus react=1; spawn burst bubbles near point; }
Ripple update: age += dt; scale = 0.3 + age*3.4; ring material opacity = (1-age/1.3)*0.8; remove after 1.3s. Also make a soft sphere pulse? Maybe add a Sprite-like glow: create a Mesh with SphereGeometry and MeshBasicMaterial transparent additive opacity .15, scale expanding. Nice.

Also ripple should be in the tank; position z fixed.

Bubble burst: create small bubbles at point with velocity up.

Keyboard: window.addEventListener('keydown', e => { if e.code==='Space' preventDefault; trigger ripple at pointer position (or auto pointer); takeControl(); if e.key==='f' addFish(); if e.key==='a' toggleAuto(); });

User control: function takeControl(){ if(auto){ auto=false; update hint text; show toast "You have control"; } }
Pointer move: on canvas pointermove (pointerType==='mouse') → auto=false; pointer.target.set(...); pointer.inWindow = true; pointer.hasReal = true.
pointerleave → pointer.inWindow=false; ring opacity 0.
pointerdown → takeControl(); compute ray-plane intersection z=0.9 (plane through tank center) → ripple.

Buttons: stopPropagation not needed as they're outside canvas? They are overlays with pointer-events auto; canvas behind. Click on button doesn't hit canvas since overlay intercepts. Fine.

Add fish button: createFish with random species at random position near center; fish enters by scaling up from 0? Add a small "splash" ripple and a new bubble burst. Update count text.

Auto demo button toggles auto mode; when enabled, cursor ring shows AUTO.

Stats: residents count, water temp (constant 24.5), state text. Also maybe "flow" indicator. Update every frame (throttle to 10fps).

Now let's design the scene visuals more concretely to look impressive:

Back wall gradient shader:
fragment:
uniform float uTime;
varying vec2 vUv;
float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}
void main(){
 vec3 bottom = vec3(0.012,0.055,0.082);
 vec3 top = vec3(0.035,0.22,0.27);
 vec3 col = mix(bottom, top, pow(vUv.y, 1.3));
 // soft glow center top
 float g = exp(-length((vUv-vec2(0.5,0.95))*vec2(1.4,2.0))*3.0);
 col += vec3(0.05,0.22,0.26)*g;
 // subtle vertical bands (light shafts on back wall)
 col += vec3(0.02,0.08,0.09) * (0.5+0.5*sin(vUv.x*22.0+sin(vUv.y*3.0+uTime*0.2)*2.0)) * smoothstep(0.3,1.0,vUv.y)*0.25;
 // grain
 float n = hash(vUv*vec2(1024.0,768.0));
 col += (n-0.5)*0.012;
 gl_FragColor = vec4(col,1.0);
}

Sand texture: 256 canvas fill #c8b083; random dots with varying alpha; add subtle darker speckles; set repeat(4,2) wrapping Repeat.

Caustic plane above sand: PlaneGeometry(13.4,6.5) rotated, y=0.315, ShaderMaterial transparent additive, depthWrite false, uniforms uTime. Fragment:
vec2 p = vUv*vec2(9.0,5.0);
float t = uTime*0.35;
float c = 0.0;
for(int i=0;i<3;i++){ float fi=float(i); vec2 q = p*(1.0+fi*0.6) + vec2(t*(0.6+fi*0.3), -t*(0.4+fi*0.2)); c += sin(q.x+sin(q.y*1.3+t)) * sin(q.y+cos(q.x*1.1-t)); }
c = pow(abs(c)/3.0, 3.0) * 1.5;
gl_FragColor = vec4(vec3(0.55,0.95,1.0)*c, c*0.55);
With AdditiveBlending, alpha ignored-ish (additive uses src*srcAlpha + dst). Set alpha c*0.55.

Rocks: create function makeRock(radius, color, detail=1): ico = new IcosahedronGeometry(radius, detail); modify positions: p.multiplyScalar(1 + 0.12*Math.sin(p.x*7.3+p.y*4.1)); computeVertexNormals; MeshStandardMaterial({color, roughness:.92, flatShading:true}). Place clusters.

Coral: function branchCoral(origin, scale, color): recursive create cylinders oriented between points. Implementation:
function makeCoral(x,z,s,color){
 const group = new THREE.Group();
 const mat = new MeshStandardMaterial({color, roughness:.65, emissive: color.clone().multiplyScalar(.12)});
 function branch(start, dir, len, rad, depth){
   const end = start.clone().addScaledVector(dir, len);
   const geo = new CylinderGeometry(rad*0.72, rad, len, 7, 1);
   const mesh = new Mesh(geo, mat); mesh.position.copy(start.clone().lerp(end,0.5)); mesh.quaternion.setFromUnitVectors(new Vector3(0,1,0), dir.clone().normalize()); group.add(mesh);
   if(depth<=0) return;
   const n = depth>1?3:2;
   for(let i=0;i<n;i++){
     const nd = dir.clone().applyAxisAngle(new Vector3(Math.random()-.5,0,Math.random()-.5).normalize(), (Math.random()-.35)*1.1).normalize();
     nd.y = Math.abs(nd.y)*0.8+0.25; nd.normalize();
     branch(end, nd, len*(0.62+Math.random()*0.15), rad*0.62, depth-1);
   }
 }
 branch(new Vector3(0,0,0), new Vector3(0,1,0).add(new Vector3((Math.random()-.5)*.3,0,(Math.random()-.5)*.3)).normalize(), 0.9*s, 0.12*s, 2);
 group.position.set(x, 0.3, z); scene.add(group);
}
Coral colors pink/orange/purple. Place 3.

Seaweed: function makePlantCluster(x,z,count,height,color,phase): group; for each blade create geometry:
 const seg=10; const positions=[]; uvs=[]; indices=[];
 for i 0..seg: t=i/seg; y=t*height; w = width*(1-t*0.85); centerX = Math.sin(t*2.2+phase)*width*1.4 + lean*t*t; centerZ = Math.sin(t*1.4+phase*0.7)*0.08;
   push left/right vertices: (centerX - w, y, centerZ), (centerX + w, y, centerZ) with uvs (0,t),(1,t)
 indices for quads.
 Material MeshStandardMaterial({color, roughness:.75, side:DoubleSide, emissive dark}).
 Add slight transparency? no.
 Animate: store blade mesh + baseRot; each frame blade.rotation.z = sin(t*0.8+phase+idx)*0.12*height/2? rotation of a vertical ribbon around its base gives sway. Since geometry starts at y=0 and goes up, rotating around origin (base) works. rotation.x similar.
 Place at y=0.3.

Plant clusters: 
- tall at back-left (-5.4,-2.6) height 3.4 green
- (-3.2,-2.9) height 2.6 darker
- (3.6,-2.7) height 3.0 teal
- (5.6,-2.2) height 2.2 purple-ish
- (-6.2,1.4) small grass cluster height 1.2
- (6.2,1.8) small
Also a few grass tufts along the sand front (short).

Bubbles: vent positions at (-2.2,0.35,-2.2), (1.6,0.35,-2.6), (4.4,0.35,-1.8). Create 30 bubble meshes; each assigned vent, radius .04-.12, y random. Update: y += speed*dt; x = vent.x + sin(t*1.2+phase)*.12; if y>6.3 respawn. material MeshPhysicalMaterial? Use MeshBasicMaterial with color 0xbef0ff transparent opacity .25 plus a second highlight? Keep basic for performance; bubbles will look like faint spheres. Add fresnel-ish via MeshPhongMaterial with shininess 100, transparent opacity .35, specular white. Let's use MeshStandardMaterial({color:0xd9f8ff, roughness:.05, metalness:.0, transparent:true, opacity:.28, emissive:0x1a4a55, emissiveIntensity:.6}). Fine.

Marine snow particles: 160 points, positions random in tank, PointsMaterial({color:0xbfeaff,size:.035,transparent:true,opacity:.35,sizeAttenuation:true}). Update y slowly, wrap.

Light shafts: 3 cones.

Camera subtle motion: camera.position.x = 0.8 + sin(t*0.12)*0.25; camera.position.y = 3.1 + sin(t*0.16)*0.08; lookAt(0,2.8,0). This gives gentle parallax. But pointer ray uses camera each frame so fine. Keep very subtle.

Also tank interior "god rays" from the top.

Now about the initial state: fish distributed and swimming; auto-demo pointer moves. At 3s the screenshot should show the whole thing.

Potential issue: The header title overlapping the tank? Tank occupies center, title top-left over the tank's left top region — but with translucent overlay it's fine.

Let's also add a subtle "tank glass" border highlight.

Let me now consider performance of transparent glass box covering everything: A transparent box with side DoubleSide renders both front and back faces; with opacity .06, the total tint over fish is fine. But transparent objects render after opaque; depthWrite false means no z issues.

Front reflection quad: additive blending, depthWrite false, renderOrder high so it draws over fish. Keep opacity low (.12).

Make sure the background wall is visible: back wall at z=-3.4 inside glass; opaque.

Add distant silhouette? fine.

Let me write the fish texture drawing functions concretely.

function makeTexture(kind){
  const c = document.createElement('canvas'); c.width=512; c.height=256; const g=c.getContext('2d');
  // base gradient
  let top, bot;
  switch(kind){ ember: top='#ff9a3c', bot='#c94a12'; cobalt: top='#3f7ff0', bot='#0d2f86'; gold: top='#ffd95e', bot='#d78a0c'; neon: top='#cfe9f5', bot='#6aa6c4'; }
  const grad=g.createLinearGradient(0,0,0,256); grad.addColorStop(0,top); grad.addColorStop(1,bot);
  g.fillStyle=grad; g.fillRect(0,0,512,256);
  if(kind==='ember'){
    // three white bands along body (v axis = y)
    drawBand(g, 0, 62, 30); drawBand(g, 150, 236, 22); drawBand(g, 240, 268, 18);
  } else if(kind==='cobalt'){
    g.fillStyle='rgba(6,18,60,.85)';
    g.beginPath(); g.moveTo(0,150); g.bezierCurveTo(120,110,240,190,360,150); g.lineTo(512,130); g.lineTo(512,256); g.lineTo(0,256); g.closePath(); g.fill();
    g.fillStyle='rgba(120,230,255,.55)'; g.fillRect(0,96,512,12);
  } else if(kind==='gold'){
    g.fillStyle='rgba(255,255,255,.18)'; for(let i=0;i<8;i++) g.fillRect(i*64+20,0,10,256);
    g.fillStyle='rgba(255,255,255,.35)'; g.fillRect(0,196,512,60);
  } else if(kind==='neon'){
    g.fillStyle='rgba(20,220,255,.95)'; g.fillRect(0,104,512,16);
    g.fillStyle='rgba(255,60,70,.9)'; g.fillRect(60,132,300,14);
    g.fillStyle='rgba(255,255,255,.4)'; g.fillRect(0,0,512,30);
  }
  // scale noise
  for(let i=0;i<900;i++){const x=Math.random()*512,y=Math.random()*256;g.fillStyle=`rgba(255,255,255,${Math.random()*0.05})`;g.fillRect(x,y,2,2);}
  const tex = new THREE.CanvasTexture(c); tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = renderer.capabilities.getMaxAnisotropy(); tex.wrapS=THREE.RepeatWrapping; return tex;
}
Wait: The canvas x = u around circumference, y = v along body. The "belly" is which side? For a sphere, v=0 is at south pole (y=-1) and v=1 at north (y=+1). So canvas y=0 is bottom (belly) and y=256 is top (back). So for the ember's white bands, constant y ranges = transverse bands. Good. For gold's white belly, should be at canvas bottom (y near 256) since belly is v=0 → canvas y=0? Careful: SphereGeometry UV: v = 1 - (iy/heightSegments), with iy=0 at theta=0 → y=+1 (top). So v=1 at top and canvas texture v=0 maps to bottom of image? In WebGL, texture coordinate v=0 corresponds to the first row of the image data, which for a canvas is the TOP row (since canvas is flipped by default: THREE flips textures with flipY=true by default, so v=0 maps to the bottom of the canvas image). Ugh. With flipY=true (default), the image is flipped when uploaded, so canvas top row appears at v=1. So canvas y=0 (top) = sphere v=1 = sphere top (back of fish). Canvas bottom (y=256) = belly. So white belly at canvas bottom (y 196-256) is correct as I wrote. Good: gold's `g.fillRect(0,196,512,60)` is belly. Ember bands across canvas horizontally (fillRect x range full, y range) → correct transverse bands.

drawBand helper with wavy edges: fill white path across full width at y range with sine variation.

Body geometry poles at z=±1: the head pole at z=+1 gets UV seam compressed — texture stripes converge at nose. Acceptable.

Now, "tail" texture: the tail is a separate mesh, use fin material color matching species.

Let me also add a small "gill" curve and a stripe on the head? skip.

Additional nice detail: a couple of tiny "cleaner shrimp"? Too much.

Let's also add a small school of 5 tiny neon tetras near center to make density.

Fish count: 4 ember, 4 cobalt, 4 gold, 4 neon = 16.

Alright, now the CSS and HTML specifics. Let me write it.

Colors: accent #7ef0ff, warm #ffb27a.

Header:
<div class="brand"><span class="pulse"></span> PELAGIC · TANK 01</div>
<h1>The shy ones<span class="dot">.</span></h1>
<p>Move your cursor through the water.<br/>They notice everything.</p>

Stats card:
<div class="stat"><span>Residents</span><b id="count">16</b></div>
<div class="stat"><span>Water</span><b>24.5°C</b></div>
<div class="stat"><span>State</span><b id="state">calm</b></div>

Legend: 4 items each with dot color and name; small caps.

Controls: 
<button id="autoBtn" class="btn active"><svg…/> Auto</button>
<button id="addBtn" class="btn">+ Resident</button>
<button id="rippleBtn" class="btn">◎ Ripple</button>

Hint bar:
<div class="hint" id="hint"><span class="keyhint">Click or press a key to start</span><span class="hintText">then drift your cursor to make them scatter</span></div>

After take control: keyhint text "You're in control", hintText "click the water for a ripple".

Also show a toast? Not needed.

Mobile: hide legend and stats partially.

Let me now write the JS code fully, being careful about syntax.

```js
import * as THREE from 'three';

const host = document.getElementById('scene');
const loader = document.getElementById('loader');
const fallback = document.getElementById('fallback');

let renderer;
try {
  renderer = new THREE.WebGLRenderer({antialias:true, powerPreference:'high-performance'});
} catch (err) {
  loader.classList.add('hide');
  fallback.hidden = false;
  throw err;
}
```
Careful: throwing inside module top-level would log an error. Better: wrap main in try/catch and not rethrow. But if WebGL fails, we show fallback. Let's structure:

```js
function boot(){ ...everything... }
try { boot(); } catch(e){ console.warn(e); loader.classList.add('hide'); fallback.hidden=false; }
```
Console.warn is fine (not an error). Actually "zero errors in console" - console.warn is not an error, but avoid anyway; just show fallback.

I'll write the entire scene inside try { } catch(e) { fallback show }. Using function declarations inside try block is fine (block-scoped function declarations in strict mode modules — function declarations in blocks are block-scoped but still hoisted within the block; fine as we call them within the block).

Better to define everything at top-level and call init in try.

Let me write it linearly with a try/catch around the entire init:

```js
try {
  init();
} catch (e) {
  document.getElementById('loader').classList.add('hide');
  const f=document.getElementById('fallback'); f.hidden=false; f.textContent='...';
}
function init(){ ... }
```
Function init is hoisted; fine.

Now write init in detail.

I'll define constants, textures, geometry, scene building, fish, interactions, animation loop.

Let me write the code.

```js
function init(){
  const host = document.getElementById('scene');
  const W = () => window.innerWidth, H = () => window.innerHeight;

  const renderer = new THREE.WebGLRenderer({antialias:true, powerPreference:'high-performance'});
  renderer.setPixelRatio(Math.min(window.devicePixelRatio||1, 2));
  renderer.setSize(W(),H());
  renderer.shadowMap.enabled = true;
  renderer.shadowMap.type = THREE.PCFSoftShadowMap;
  renderer.toneMapping = THREE.ACESFilmicToneMapping;
  renderer.toneMappingExposure = 1.12;
  renderer.outputColorSpace = THREE.SRGBColorSpace;
  host.appendChild(renderer.domElement);

  const scene = new THREE.Scene();
  scene.background = new THREE.Color(0x03101a);
  scene.fog = new THREE.FogExp2(0x062230, 0.020);

  const camera = new THREE.PerspectiveCamera(40, W()/H(), 0.1, 100);
  camera.position.set(0.9, 3.2, 15.8);
  const camTarget = new THREE.Vector3(0,2.9,0);
  camera.lookAt(camTarget);
  ...
}
```

Materials/geometry helpers.

Note: `renderer.capabilities.getMaxAnisotropy()` after renderer creation.

Let me write the sand texture:

```js
function makeSandTexture(){
  const c=document.createElement('canvas'); c.width=c.height=512; const g=c.getContext('2d');
  g.fillStyle='#c9ad7d'; g.fillRect(0,0,512,512);
  for(let i=0;i<9000;i++){
    const x=Math.random()*512,y=Math.random()*512,r=Math.random()*1.6+0.3;
    const t=Math.random();
    g.fillStyle = t<.4 ? `rgba(255,244,214,${0.05+Math.random()*0.22})` : t<.75 ? `rgba(120,92,58,${0.05+Math.random()*0.2})` : `rgba(200,220,210,${Math.random()*0.08})`;
    g.beginPath(); g.arc(x,y,r,0,6.283); g.fill();
  }
  const tex=new THREE.CanvasTexture(c); tex.colorSpace=THREE.SRGBColorSpace; tex.wrapS=tex.wrapT=THREE.RepeatWrapping; tex.repeat.set(5,3); tex.anisotropy=...; return tex;
}
```
9000 arcs at init is okay (fast enough, ~10ms).

Front reflection texture:
```js
function makeReflectionTexture(){
 const c=document.createElement('canvas'); c.width=512;c.height=256; const g=c.getContext('2d');
 g.clearRect(0,0,512,256);
 // diagonal streaks
 for(let i=0;i<5;i++){
   const x = 40 + i*90 + Math.random()*30;
   const grad = g.createLinearGradient(x, 0, x+70, 256);
   grad.addColorStop(0,'rgba(255,255,255,0)');
   grad.addColorStop(.5,`rgba(210,245,255,${0.10+Math.random()*0.06})`);
   grad.addColorStop(1,'rgba(255,255,255,0)');
   g.fillStyle=grad;
   g.save(); g.translate(x,0); g.rotate(0.35); g.fillRect(-30,-60,26,400); g.restore();
 }
 const tex=new THREE.CanvasTexture(c); tex.colorSpace=THREE.SRGBColorSpace; return tex;
}
```
Rotating fillRect with gradient coordinate is a bit off but visually fine.

Now let's write the whole HTML with CSS. I'll aim for polished.

CSS:
```css
:root{
  --ink:#eaf8ff;
  --muted:rgba(215,240,250,.62);
  --accent:#7df0ff;
  --accent2:#ffb478;
  --glass:rgba(9,26,36,.42);
  --line:rgba(150,235,255,.14);
}
*{box-sizing:border-box}
html,body{height:100%;margin:0;overflow:hidden;background:#030d15;color:var(--ink);font-family:"Inter","Segoe UI",system-ui,-apple-system,sans-serif;-webkit-font-smoothing:antialiased}
#scene{position:fixed;inset:0}
canvas{display:block;width:100%;height:100%;touch-action:none}
.vignette{position:fixed;inset:0;pointer-events:none;z-index:2;background:radial-gradient(120% 90% at 50% 42%, transparent 35%, rgba(1,8,14,.55) 100%);}
```
Also add a top/bottom gradient for HUD legibility.

.hud base: position fixed; z-index 4; pointer-events none; user-select none.

.brand{position:fixed;top:28px;left:32px;z-index:4;pointer-events:none}
.brand .tag{display:inline-flex;align-items:center;gap:8px;font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--muted)}
.pulse{width:7px;height:7px;border-radius:50%;background:var(--accent);box-shadow:0 0 0 0 rgba(125,240,255,.6);animation:pulse 2.4s infinite}
@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(125,240,255,.55)}70%{box-shadow:0 0 0 9px rgba(125,240,255,0)}100%{box-shadow:0 0 0 0 rgba(125,240,255,0)}}
h1{margin:14px 0 6px;font-size:clamp(34px,4.4vw,60px);line-height:.98;font-weight:250;letter-spacing:-.03em;text-shadow:0 4px 30px rgba(0,0,0,.5)}
h1 em{font-family:Georgia,serif;font-style:italic;font-weight:400}
.sub{font-size:13.5px;color:var(--muted);line-height:1.55;max-width:270px}

stats card:
.stats{position:fixed;top:26px;right:30px;z-index:4;display:flex;gap:26px;padding:13px 18px;border:1px solid var(--line);border-radius:14px;background:linear-gradient(160deg,rgba(12,32,44,.62),rgba(7,19,28,.35));backdrop-filter:blur(12px) saturate(1.2);pointer-events:none}
.stat span{display:block;font-size:9.5px;letter-spacing:.2em;text-transform:uppercase;color:var(--muted);margin-bottom:5px}
.stat b{font-size:15px;font-weight:500;letter-spacing:.02em}
#state{color:var(--accent)}
#state.alert{color:var(--accent2)}

.legend{position:fixed;left:32px;bottom:30px;display:flex;gap:18px;z-index:4;pointer-events:none}
.legend div{display:flex;align-items:center;gap:7px;font-size:11px;color:var(--muted);letter-spacing:.04em}
.legend i{width:8px;height:8px;border-radius:50%;display:block}

.controls{position:fixed;right:30px;bottom:28px;display:flex;gap:8px;z-index:5}
.btn{...}
.btn.active{border-color:rgba(125,240,255,.45);color:#d8fbff;background:rgba(20,70,90,.4);box-shadow:0 0 22px rgba(60,200,255,.15) inset}
.btn:hover{...}

.hint{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);z-index:5;display:flex;align-items:center;gap:12px;padding:9px 14px;border-radius:999px;border:1px solid var(--line);background:rgba(6,20,29,.5);backdrop-filter:blur(10px);font-size:12px;color:var(--muted);white-space:nowrap}
.chip{font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:#04131a;background:var(--accent);padding:4px 9px;border-radius:999px;font-weight:700}

cursor:
.cursor{position:fixed;left:0;top:0;width:58px;height:58px;margin:-29px 0 0 -29px;border-radius:50%;border:1px solid rgba(160,240,255,.5);box-shadow:0 0 26px rgba(70,215,255,.22), inset 0 0 22px rgba(70,215,255,.12);pointer-events:none;z-index:6;opacity:0;transition:opacity .35s, width .25s, height .25s, border-color .25s;mix-blend-mode:screen}
.cursor::after{content:"";position:absolute;inset:46%;border-radius:50%;background:rgba(190,245,255,.85);box-shadow:0 0 12px rgba(160,240,255,.9)}
.cursor.auto{border-style:dashed;border-color:rgba(160,240,255,.42)}
.cursor.hot{width:76px;height:76px;margin:-38px 0 0 -38px;border-color:rgba(255,190,140,.85);box-shadow:0 0 34px rgba(255,160,90,.3)}
.cursor.auto::before{content:"AUTO";position:absolute;top:100%;left:50%;transform:translateX(-50%);margin-top:6px;font-size:9px;letter-spacing:.18em;color:rgba(160,240,255,.65)}
Note mix-blend-mode screen on a light? fine.

Loader:
#loader{position:fixed;inset:0;z-index:20;display:grid;place-items:center;background:radial-gradient(80% 60% at 50% 40%, #062330, #02090f);transition:opacity .8s ease}
#loader.hide{opacity:0;pointer-events:none}
.loader-inner{text-align:center}
.loader h2{font-weight:200;letter-spacing:.2em;font-size:13px;text-transform:uppercase;color:var(--muted)}
.loader .dots i{...animation}

Media queries:
@media (max-width:860px){ .stats{gap:16px;padding:10px 14px} .legend{display:none} }
@media (max-width:640px){ .brand{top:18px;left:18px} h1{font-size:32px} .sub{font-size:12px;max-width:210px} .stats{top:auto;bottom:74px;right:14px;left:14px;justify-content:space-between} .controls{right:14px;bottom:20px;left:14px;justify-content:center} .hint{left:14px;right:14px;bottom:124px;transform:none;justify-content:center;white-space:normal;text-align:center;flex-wrap:wrap} }
Hmm, .stats at bottom:74 and controls bottom 20 and hint bottom 124 might overlap: stats height ~48 at bottom 74 → occupies 74-122; hint at bottom 124 → above. OK.

But .hint with keyhint chip and text. Fine.

Now JS details for the "hint" update:
const hintChip=document.getElementById('chip'), hintText=document.getElementById('hintText');
function takeControl(){ if(autoDemo){autoDemo=false; hintChip.textContent='You’re in control'; hintText.textContent='click the water to send a ripple · move closer to scatter the school'; autoBtn.classList.remove('active');} }

Auto demo button toggles: autoDemo=true → hintChip.textContent='Auto demo'; hintText='a virtual cursor is guiding the school'; cursor.classList.add('auto'); and set pointer.active.

Careful with pointer "active" for auto: always active while autoDemo.

Now write the fish update code carefully.

Data arrays: fishes = [], bubbles = [], particles, ripples, plants list with sway data.

Let me write helper: rand(a,b).

Fish spawn positions: random within x [-5.5,5.5], y [1.2,5.6], z [-2.6,2.4].

Let me write createFish(speciesKey, position):

```js
const SPECIES = {
  ember: {tex: emberTex, w:.30, h:.36, len:.72, speed:1.05, fin:0xff7a33, glow:0xff6a1f},
  cobalt:{tex: cobaltTex,w:.28,h:.40,len:.78, speed:1.15, fin:0x2f5bff, glow:0x1e49d8},
  gold: {tex: goldTex,w:.30,h:.42,len:.74, speed:1.2, fin:0xffc542, glow:0xffb01f},
  neon: {tex: neonTex,w:.13,h:.19,len:.44, speed:1.35, fin:0x9fe8ff, glow:0x3fd8ff}
};
```

Body geometry creation:
```js
function makeBodyGeometry(){
  const g = new THREE.SphereGeometry(1, 28, 18);
  const pos = g.attributes.position;
  for(let i=0;i<pos.count;i++){
    let x=pos.getX(i), y=pos.getY(i), z=pos.getZ(i);
    const taper = 1 - 0.32*Math.max(0,z) - 0.30*Math.max(0,-z);
    const belly = 1 + 0.06*Math.max(0,-y);
    x*=taper*belly; y*=taper; z*=1.0;
    pos.setXYZ(i,x,y,z);
  }
  g.computeVertexNormals();
  return g;
}
```
Hmm belly scaling x only when y<0 (bottom fatter). Fine.

Tail geometry:
```js
function makeTailGeometry(){
  const g = new THREE.BufferGeometry();
  const v = new Float32Array([
    0,0,0,
    0,0.62,-0.75,
    0,0,-0.42,
    0,0,0,
    0,0,-0.42,
    0,-0.62,-0.75
  ]);
  g.setAttribute('position', new THREE.BufferAttribute(v,3));
  g.computeVertexNormals();
  return g;
}
```
This creates a forked tail in the YZ plane (x=0), extending backward (-z). DoubleSide material.

Dorsal fin geometry:
```js
function makeFinGeometry(){
  // vertical fin in YZ plane
  const v = new Float32Array([
    0,0,0.42,   // front base
    0,0.52,-0.18, // top
    0,0,-0.42  // back base
  ]);
  ...
}
```
Wait, dorsal fin should sit on top of the body, pointing up and back. Base points at (z=+0.3, y=0) and (z=-0.35,y=0), apex at (z=-0.1,y=0.55). Position the mesh at y = h*0.82. Scale by len.

Pectoral fin: use flattened sphere geometry:
```js
const pectGeo = new THREE.SphereGeometry(1,10,8);
```
then mesh.scale.set(0.22,0.035,0.16) and position offset x.

Eye geometry: sphere radius 1 shared, scaled.

Now the fish object:
```js
function createFish(key, pos){
  const spec=SPECIES[key];
  const root=new THREE.Group(); const model=new THREE.Group(); root.add(model);
  const bodyMat=new THREE.MeshPhysicalMaterial({map:spec.tex, roughness:.35, metalness:.04, clearcoat:.8, clearcoatRoughness:.25, sheen:0.4, sheenColor:new THREE.Color(spec.glow), emissive:new THREE.Color(spec.glow), emissiveIntensity:.07});
  const body=new THREE.Mesh(bodyGeo, bodyMat); body.scale.set(spec.w,spec.h,spec.len); body.castShadow=true; model.add(body);
  const finMat=new THREE.MeshPhysicalMaterial({color:spec.fin, roughness:.45, transparent:true, opacity:.92, side:THREE.DoubleSide, emissive:new THREE.Color(spec.glow), emissiveIntensity:.12, depthWrite:false});
  ...
}
```
`sheen` is a valid MeshPhysicalMaterial property in r170 (sheen, sheenRoughness, sheenColor). Yes.

Tail: tailPivot at z=-spec.len*0.95; tail mesh scale set(spec.len, spec.len, spec.len) — tail geometry base at 0 extends -0.75z and ±0.62y; scaled by len ~0.75 → length 0.56, height 0.46. Reasonable relative to body (body height .36*2=.72). Tail height .46 ok.

Dorsal: mesh.position.set(0, spec.h*0.85, 0); scale.set(spec.len, spec.len*1.1, spec.len) — geometry z from -0.42..0.42 scaled by len.

Pectoral pivots:
```js
const finL=new THREE.Group(); finL.position.set(spec.w*0.82, -spec.h*0.05, spec.len*0.18); model.add(finL);
const pm=new THREE.Mesh(pectGeo, finMat); pm.scale.set(spec.len*0.32, spec.len*0.05, spec.len*0.26); pm.position.x=spec.len*0.14; finL.add(pm);
```
Right fin mirrored with position.x negative and scale.x negative.

Eyes:
```js
for(const s of [-1,1]){
  const eye=new THREE.Mesh(eyeGeo, eyeWhiteMat);
  eye.position.set(s*spec.w*0.68, spec.h*0.30, spec.len*0.46);
  eye.scale.setScalar(spec.len*0.13);
  model.add(eye);
  const pupil=new THREE.Mesh(eyeGeo, eyeDarkMat);
  pupil.position.set(s*spec.w*0.80, spec.h*0.30, spec.len*0.50);
  pupil.scale.setScalar(spec.len*0.07);
  model.add(pupil);
}
```
Eye white color 0xf4fbff, pupil 0x071018 with a slight specular. Pupil at more outward x and forward z, good.

Add a subtle "highlight" sphere tiny white in the pupil? skip.

Also add a mouth line? skip.

Fish state:
```js
const fish={root,model,body,tailPivot,finL,finR,key,spec,phase:Math.random()*Math.PI*2,vel:new THREE.Vector3(),react:0,turn:0,lastDir:new THREE.Vector3(0,0,1)};
```
Vel initial random direction * speed.

Now the animation:

```js
const clock=new THREE.Clock();
let time=0;
function animate(){
  requestAnimationFrame(animate);
  const dt=Math.min(clock.getDelta(),0.05); time+=dt;
  ...
  renderer.render(scene,camera);
}
```

Pointer handling:
```js
const pointer={ndc:new THREE.Vector2(0,0),target:new THREE.Vector2(0,0),px:new THREE.Vector2(W()/2,H()/2),active:false,auto:true};
const raycaster=new THREE.Raycaster();
```

Auto path:
```js
if(pointer.auto){
  pointer.target.set(Math.sin(time*0.36)*0.52 + Math.sin(time*0.13)*0.12, Math.sin(time*0.27+0.8)*0.26);
  pointer.active=true;
} 
pointer.ndc.lerp(pointer.target, 1-Math.exp(-dt*6));
raycaster.setFromCamera(pointer.ndc,camera);
pointer.px.set((pointer.ndc.x*.5+.5)*W(), (-pointer.ndc.y*.5+.5)*H());
```

Wait: in auto mode we set target each frame then lerp — fine.

Real pointermove:
```js
renderer.domElement.addEventListener('pointermove', e=>{
  if(e.pointerType!=='mouse' && e.pointerType!=='pen') return; // touch handled separately
  pointer.target.set((e.clientX/W())*2-1, -(e.clientY/H())*2+1);
  pointer.active=true; pointer.auto=false; takeControl();
});
```
takeControl sets auto=false and updates UI. But careful: if pointer.auto=false the target keeps the last real value, good.

pointerleave: pointer.active=false (but if auto, ignore).

pointerdown:
```js
renderer.domElement.addEventListener('pointerdown', e=>{
  pointer.target.set(...); pointer.active=true; takeControl();
  spawnRippleAtScreen(e.clientX,e.clientY);
});
```
For touch, pointermove during touch also works but fine.

spawnRippleAtScreen:
```js
raycaster.setFromCamera(ndc,camera);
const plane=new THREE.Plane(new THREE.Vector3(0,0,1), -0.8); // z=0.8 plane
const hit=new THREE.Vector3();
if(raycaster.ray.intersectPlane(plane,hit)){ hit.x=clamp(hit.x,-6.5,6.5); hit.y=clamp(hit.y,0.6,6.4); hit.z=0.8; spawnRipple(hit); }
```
Plane constant: Plane(normal, constant) where plane equation normal·p + constant = 0. For z = 0.8: normal (0,0,1), constant = -0.8. Yes.

For the auto/ripple button: use pointer.px to compute ndc then same.

Ripple visuals:
```js
function spawnRipple(pos){
  const group=new THREE.Group(); group.position.copy(pos);
  const mat=new THREE.MeshBasicMaterial({color:0x9df6ff,transparent:true,opacity:.85,blending:THREE.AdditiveBlending,depthWrite:false});
  const ring=new THREE.Mesh(new THREE.TorusGeometry(0.42,0.018,8,64), mat);
  group.add(ring);
  const ring2=new THREE.Mesh(new THREE.TorusGeometry(0.42,0.01,8,64), mat.clone());
  ring2.scale.setScalar(0.6); group.add(ring2);
  const glow=new THREE.Mesh(new THREE.SphereGeometry(0.5,16,12), new THREE.MeshBasicMaterial({color:0x66e6ff,transparent:true,opacity:.16,blending:THREE.AdditiveBlending,depthWrite:false}));
  group.add(glow);
  scene.add(group);
  ripples.push({group,ring,ring2,glow,age:0});
  // startle fish
  for(const f of fishes){
    const d=f.root.position.distanceTo(pos);
    if(d<3.4){ const dir=f.root.position.clone().sub(pos).normalize(); dir.y+=0.2; f.vel.addScaledVector(dir, 2.6*(1-d/3.4)); f.react=1; }
  }
  // bubble burst
  for(let i=0;i<8;i++) spawnBubble(pos, true);
}
```
Update ripples: age+=dt; group.quaternion.copy(camera.quaternion) (billboard); scale = 0.4+age*4; mat opacity fade; remove and dispose.

Note: setting group.quaternion to camera quaternion makes the torus (in XY plane) face camera? TorusGeometry lies in XY plane with normal +Z. Camera quaternion applied to group aligns group's +Z with camera's +Z axis (pointing backward from view), so the torus plane faces the camera. Good.

Bubbles:
```js
const bubbleGeo=new THREE.SphereGeometry(1,10,8);
const bubbleMat=new THREE.MeshStandardMaterial({color:0xbef0ff,roughness:.05,metalness:.0,transparent:true,opacity:.3,emissive:0x2a6b7d,emissiveIntensity:.5});
function spawnBubble(pos, burst){...}
```
I'll manage a pool: create 40 bubbles at init, each with state {mesh, vent, y, speed, phase, base}. For bursts, reuse random bubbles near pos.

Let me define bubbles array with objects:
```js
function makeBubble(x,y,z,speed,size){ const m=new THREE.Mesh(bubbleGeo,bubbleMat); m.scale.setScalar(size); m.position.set(x,y,z); scene.add(m); return {m,x,y,z,speed,size,phase:Math.random()*6.28, vent:null}; }
```
For rising bubbles from vents, store vent index and reset.

Update:
```js
for(const b of bubbles){
  b.y += b.speed*dt;
  b.m.position.set(b.x+Math.sin(time*1.3+b.phase)*0.09, b.y, b.z+Math.cos(time*0.9+b.phase)*0.07);
  if(b.y>6.3){ if(b.vent!==null){ b.x=b.vents[b.vent].x; b.z=b.vents[b.vent].z; } b.y=0.4; b.speed=0.5+Math.random()*0.6; }
}
```
Burst bubbles: just set their x,y,z to point and let them rise; vent=null so on respawn they rise from random? Set vent to nearest vent for simplicity.

Marine snow: Points with 180 positions; update each frame y-=speed*dt; if y<0.35 reset to 6.2. x drift sin. Update buffer attribute each frame (180*3 floats, fine).

Plants sway: store list {mesh, phase, amount}; update rotation.z = sin(time*0.7+phase)*amount; rotation.x = sin(time*0.5+phase*1.3)*amount*0.5.

Light shafts opacity: material.opacity = 0.03 + Math.sin(time*0.4+i)*0.012.

Water surface shader time uniform; caustics time; back wall time.

Fish update loop — write it fully:

```js
const tmpV=new THREE.Vector3(), tmpV2=new THREE.Vector3(), tmpV3=new THREE.Vector3();
const forward=new THREE.Vector3(0,0,1);
let reacting=0;
for(let i=0;i<fishes.length;i++){
  const f=fishes[i];
  const p=f.root.position;
  // wander
  const desired=tmpV.set(
    Math.sin(time*0.31+f.phase*1.7)+Math.sin(time*0.11+f.phase)*0.5,
    Math.sin(time*0.23+f.phase*2.1)*0.55,
    Math.cos(time*0.27+f.phase*0.8)
  );
  // cohesion & separation
  const sep=tmpV2.set(0,0,0); let n=0;
  for(let j=0;j<fishes.length;j++){
    if(i===j) continue; const o=fishes[j];
    const dx=p.x-o.root.position.x, dy=p.y-o.root.position.y, dz=p.z-o.root.position.z;
    const d2=dx*dx+dy*dy+dz*dz;
    if(d2<9){ n++; desired.x += (o.root.position.x-p.x)*0.02; desired.y += (o.root.position.y-p.y)*0.02; desired.z += (o.root.position.z-p.z)*0.02; }
    if(d2<1.6){ const d=Math.sqrt(d2)||0.001; sep.x += dx/d/d; sep.y += dy/d/d; sep.z += dz/d/d; }
  }
  desired.addScaledVector(sep,1.1);
  // pointer
  const proj=tmpV3.copy(p).project(camera);
  const sx=(proj.x*.5+.5)*W(), sy=(-proj.y*.5+.5)*H();
  const dist=Math.hypot(sx-pointer.px.x, sy-pointer.px.y);
  let urge=0;
  if(pointer.active && dist<220){
    raycaster.closestPointToPoint(p, tmpClose);   // need a dedicated vector
    tmpAway.copy(p).sub(tmpClose);
    if(tmpAway.lengthSq()<1e-6) tmpAway.set(1,0,0);
    tmpAway.normalize();
    const k=1-dist/220;
    urge=k*k;
    desired.addScaledVector(tmpAway, urge*7.0);
    if(dist>170) { // curious: look toward pointer
      tmpClose2.copy(p).sub(pointerWorld); desired.addScaledVector(tmpClose2.normalize(), 0.35);
    }
  }
  f.react = Math.max(0, f.react - dt*0.8);
  f.react = Math.max(f.react, urge);
  if(urge>0.12) reacting++;
  // bounds
  const bx=6.0, by1=0.9, by2=6.0, bz=2.9;
  if(p.x<-bx) desired.x += (-bx-p.x)*2.0;
  if(p.x>bx) desired.x -= (p.x-bx)*2.0;
  if(p.y<by1) desired.y += (by1-p.y)*2.0;
  if(p.y>by2) desired.y -= (p.y-by2)*2.0;
  if(p.z<-bz) desired.z += (-bz-p.z)*2.0;
  if(p.z>bz) desired.z -= (p.z-bz)*2.0;
  // normalize & steer
  if(desired.lengthSq()<1e-6) desired.set(0,0,1);
  desired.normalize();
  const maxSpeed=f.spec.speed*(1+f.react*1.5);
  const target=tmpTarget.copy(desired).multiplyScalar(maxSpeed);
  f.vel.lerp(target, 1-Math.exp(-dt*(1.6+f.react*3.0)));
  const sp=f.vel.length();
  if(sp<0.25) f.vel.set(0,0,0.4);
  if(sp>maxSpeed*1.6) f.vel.setLength(maxSpeed*1.6);
  p.addScaledVector(f.vel, dt);
  // hard limits
  p.x=Math.max(-6.5,Math.min(6.5,p.x)); p.y=Math.max(0.6,Math.min(6.35,p.y)); p.z=Math.max(-3.0,Math.min(3.0,p.z));
  // orientation
  const dir=tmpDir.copy(f.vel).normalize();
  targetQ.setFromUnitVectors(forward,dir);
  f.root.quaternion.slerp(targetQ, 1-Math.exp(-dt*3.2));
  // bank
  const turn=tmpTurn.copy(f.lastDir).cross(dir).dot(forward);
  f.turn += (clamp(-turn*1.6,-0.5,0.5)-f.turn)*(1-Math.exp(-dt*6));
  f.model.rotation.z=f.turn;
  f.lastDir.copy(dir);
  // tail
  f.phase += dt*(3.0+sp*2.6);
  f.tailPivot.rotation.y=Math.sin(f.phase)*0.42*(0.6+Math.min(sp,2.4)*0.18);
  f.tailPivot.rotation.x=Math.sin(f.phase*0.5)*0.05;
  const flap=Math.sin(f.phase*1.3);
  f.finL.rotation.z=0.25+flap*0.35; f.finR.rotation.z=-0.25-flap*0.35;
  f.model.position.y=Math.sin(time*1.1+f.phase*0.2)*0.02;
}
```

Wait — careful with tmpV reuse: desired uses tmpV; then inside the neighbor loop I use o positions. Then later tmpV3 for projection. Then tmpClose. Since desired is tmpV and I keep using it across iterations, must be careful not to overwrite tmpV after setting desired. I use tmpV2 (sep) and tmpV3 (proj) and tmpClose (closest) and tmpAway. Ensure tmpAway isn't tmpV. I'll declare distinct vectors: vDesired, vSep, vProj, vClosest, vAway, vTarget, vDir, vTurn, vPointerWorld.

Note `raycaster.closestPointToPoint(p, vClosest)` — this is a method on Raycaster? Actually it's `Ray.prototype.closestPointToPoint(point, target)`, and Raycaster has `.ray` property. So use `raycaster.ray.closestPointToPoint(p, vClosest)`.

Pointer world point: intersect ray with plane z=0.8 for curiosity direction; compute once per frame: raycaster.ray.intersectPlane(planeZ, vPointerWorld). If null, use camera position + direction.

Also caution: `f.phase` is used for wander and tail animation, so as phase advances the wander pattern shifts quickly (phase increments ~5/s → sin(time*0.31+f.phase*1.7) changes very fast!). That's a bug: using f.phase in wander multiplied by 1.7 with phase advancing at 5 rad/s → 8.5 rad/s wander, too jittery. Separate: f.phase (tail, increments) and f.seed (static random) for wander. Use f.seed for wander.

Let me use f.seed = Math.random()*100.

Also tail flap frequency: phase += dt*(3+sp*2.6) → ~5-7 rad/s → about 1 Hz. Good.

State UI: if reacting>0 → state "scattering" (alert), else "calm". Also update count.

Also I want the auto-demo pointer to reliably cause reactions in the screenshot. The auto pointer path spans x ±0.6 NDC, y ±0.26. Fish spread across the tank; some will be near. Ensure at least a couple of fish are near the center region initially so the demo shows fleeing. I'll place some fish near center.

Additionally, to make the demo more visible, add a "proximity halo": the cursor ring turns warm and grows when a fish is within 220px. Track minDist in the fish loop → cursor.hot class.

Let me also show a tiny floating label near the cursor? The AUTO label is enough.

Performance check: 16 fish × (16 neighbors + projections) per frame = fine.

Now, let's also ensure the first screenshot (3s) shows the loader gone: loader hides after init + small delay (e.g., 300ms after first render). I'll call renderer.render once then hide loader with a CSS class.

Let's also add `renderer.domElement.addEventListener('contextmenu', e=>e.preventDefault())` to avoid right-click menu.

Window resize handler updates camera aspect, renderer size, and recompute pointer px.

Now write the scene-building functions in detail.

buildTank():
- floor sand
- back wall shader
- glass box + edges
- water surface
- caustic plane
- reflection
- stand

Stand design:
```js
const standMat=new THREE.MeshStandardMaterial({color:0x241a15,roughness:.75,metalness:.05});
const base=new THREE.Mesh(new THREE.BoxGeometry(14.6,.5,7.6), standMat); base.position.set(0,-.25,0); base.receiveShadow=true; base.castShadow=true;
const cabinet=new THREE.Mesh(new THREE.BoxGeometry(13.8,1.7,7.0), new THREE.MeshStandardMaterial({color:0x1b1410,roughness:.85})); cabinet.position.set(0,-1.35,0);
const trim=new THREE.Mesh(new THREE.BoxGeometry(14.2,.1,7.3), new THREE.MeshStandardMaterial({color:0x0e1519,roughness:.4,metalness:.6})); trim.position.set(0,.02,0);
const led=new THREE.Mesh(new THREE.BoxGeometry(13.6,.04,6.9), new THREE.MeshBasicMaterial({color:0x4fd8ff})); led.position.set(0,.09,0); // glowing line under tank
```
Also a soft glow plane? The LED strip with basic material is bright enough.

Wait, glass box bottom at y=0? Let me set glass box from y=0.05 to 6.75: height 6.7, center 3.4. Sand floor at y=0.30 (inside). Stand base top at y=0.0 (base box height .5 centered -0.25). So the tank sits on the base.

Camera target y=2.9 to include a bit of stand.

Tank interior bounds for fish: x ±6.2, y 0.8..6.2, z ±2.8.

Lighting:
```js
scene.add(new THREE.HemisphereLight(0x9fe4ff,0x081a22,0.85));
const key=new THREE.DirectionalLight(0xdff4ff,2.0); key.position.set(5,11,7); key.castShadow=true;
key.shadow.mapSize.set(1024,1024); key.shadow.camera.left=-9; right 9; top 9; bottom -6; near 1; far 30; key.shadow.bias=-0.0012;
scene.add(key);
const rim=new THREE.DirectionalLight(0x2f8fff,0.9); rim.position.set(-7,4,-6); scene.add(rim);
const warm=new THREE.PointLight(0xffb070,18,16,2); warm.position.set(5,2.2,1.5); scene.add(warm);
const topGlow=new THREE.PointLight(0x64e8ff,22,18,2); topGlow.position.set(-3,6.2,1); scene.add(topGlow);
```
Note with physically correct lights in r155+, intensity units changed: PointLight intensity in candela; 18 might be dim at distance 5 (attenuation 1/d² → 18/25=0.72). It's fine; tune maybe 40. Let's use intensity 30 for point lights with decay 2 and distance 20. I'll tune visually blind... Use moderate values and rely also on directional/hemisphere.

Actually in r170, `useLegacyLights` was removed; lights are physical. DirectionalLight intensity 2 is decent. PointLight intensity 30 at distance ~4 gives 30/16 ≈ 1.9 → bright. OK.

Add an emissive "surface glow" from top.

Water surface shader:

```glsl
// vertex
uniform float uTime; varying vec2 vUv; varying vec3 vPos;
void main(){ vUv=uv; vec3 p=position; float w=sin(p.x*1.6+uTime*0.9)*0.035+sin(p.z*2.2-uTime*0.7)*0.025+sin((p.x+p.z)*3.1+uTime*1.6)*0.012; p.y+=w; vPos=p; gl_Position=projectionMatrix*modelViewMatrix*vec4(p,1.0); }
// fragment
uniform float uTime; varying vec2 vUv; varying vec3 vPos;
void main(){ float r=sin(vPos.x*1.7+uTime*0.8)+sin(vPos.z*2.1-uTime*0.6); float sheen=pow(max(r*0.5+0.5,0.0),6.0); vec3 col=mix(vec3(0.05,0.35,0.42), vec3(0.55,0.95,1.0), sheen); float edge=smoothstep(0.5,0.0,abs(vUv.x-0.5))*0.0; gl_FragColor=vec4(col, 0.10+sheen*0.35); }
```
With transparent true, depthWrite false, side DoubleSide, blending Normal.

Since the camera is slightly above the water plane? Camera y=3.2, water top at 6.5 → camera is BELOW the water surface. So we see the underside of the surface. Good, surface visible from below (looking up). Side DoubleSide needed. The plane will appear as a ceiling. That's realistic and nice. Ensure the shader handles underside (we just render the same).

Hmm, camera at y 3.2 and water at 6.5 means we look at the tank from mid-height; the surface is above. Fine.

Back wall shader plane at z=-3.4 with size 13.6x6.6 at y=3.4.

Also add a "floor gradient" to the back wall bottom.

Let me now think about the caustics plane's render order: it is transparent additive above the sand; set y=0.315, depthWrite false, renderOrder=1.

Also fish shadows: directional light shadow camera bounds cover the tank; fish cast onto sand. Good.

Now, let me make sure the fish are visible against the back wall: the back wall is dark teal; colorful fish will pop.

Let me add a soft "haze" gradient in front of the back wall for depth: a plane at z=-3.3 with fog-like gradient? The fog handles it.

OK, let's write out the final code. I'll be thorough but careful.

One more feature for delight: when a fish is startled, it releases a tiny bubble or scale shimmer? Skip.

Also the "Release resident" button adds a fish with a ripple and a toast that updates count.

Let me write the final HTML now.

Additional detail: Add `aria-hidden` and semantic elements. Buttons have accessible labels.

Let me write the code.

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
<title>Pelagic — The Shy Ones</title>
<style>
...
</style>
<script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"}}</script>
</head>
<body>
<div id="scene"></div>
<div class="vignette"></div>
<div class="brand hud">
  <div class="tag"><span class="pulse"></span> Pelagic &middot; Tank 01</div>
  <h1>The shy <em>ones</em><span class="dot">.</span></h1>
  <p class="sub">A living reef in miniature. Bring your cursor close and watch the whole school scatter.</p>
</div>
<div class="stats hud">
  <div class="stat"><span>Residents</span><b id="count">16</b></div>
  <div class="stat"><span>Water</span><b>24.5&deg;C</b></div>
  <div class="stat"><span>State</span><b id="state">calm</b></div>
</div>
<div class="legend hud"> ... </div>
<div class="controls hud"> buttons </div>
<div class="hint hud" id="hint"><span class="chip" id="chip">Click or press a key to start</span><span id="hintText">then drift your cursor to make them scatter</span></div>
<div class="cursor" id="cursor"></div>
<div id="loader">...</div>
<div id="fallback" hidden>...</div>
<script type="module">...</script>
</body>
</html>
```

Now write the module code fully. I'll write it in a logical order.

Let me draft the complete JS:

```js
import * as THREE from 'three';

const $ = (s) => document.querySelector(s);

function init() {
  const sceneEl = $('#scene');
  const W = window.innerWidth, H = window.innerHeight;

  const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
  renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
  renderer.setSize(W, H);
  renderer.shadowMap.enabled = true;
  renderer.shadowMap.type = THREE.PCFSoftShadowMap;
  renderer.toneMapping = THREE.ACESFilmicToneMapping;
  renderer.toneMappingExposure = 1.15;
  renderer.outputColorSpace = THREE.SRGBColorSpace;
  sceneEl.appendChild(renderer.domElement);

  const scene = new THREE.Scene();
  scene.background = new THREE.Color(0x020c14);
  scene.fog = new THREE.FogExp2(0x05202c, 0.019);

  const camera = new THREE.PerspectiveCamera(40, W / H, 0.1, 100);
  camera.position.set(0.9, 3.3, 15.6);
  const camLook = new THREE.Vector3(0, 2.95, 0);
  camera.lookAt(camLook);

  const maxAniso = renderer.capabilities.getMaxAnisotropy();
  ... (textures)
  ... (build)
}
```

I'll write everything now in final form. Let me be careful with the shader code inside JS template strings (backticks). Ensure no `${` unintended.

Caustic shader:

```glsl
uniform float uTime;
varying vec2 vUv;
void main(){
  vec2 p = (vUv - 0.5) * vec2(13.0, 6.5);
  float t = uTime * 0.45;
  float a = sin(p.x * 1.9 + t) * sin(p.y * 2.3 - t * 0.8);
  float b = sin(p.x * 3.3 - t * 0.6 + sin(p.y * 1.1 + t)) * sin(p.y * 2.9 + t * 0.7);
  float c = pow(abs(a * 0.6 + b * 0.4), 3.0);
  c = smoothstep(0.25, 1.0, c);
  float fade = smoothstep(0.0, 0.25, vUv.y) * smoothstep(0.0, 0.12, vUv.x) * smoothstep(0.0, 0.12, 1.0 - vUv.x);
  gl_FragColor = vec4(vec3(0.42, 0.92, 1.0) * c, c * 0.5 * fade);
}
```
Good enough.

Now the sand material: MeshStandardMaterial with map, color 0xffffff, roughness 0.95. But the canvas texture is light beige; under blue-ish lighting it'll look tan. Add slight color multiplier 0xd9e7e2? Keep.

Now, write the fish update and everything.

Also need to handle `pointer.active` initially true in auto mode so the fish react from the start.

Let me also handle the case when the user's pointer leaves the window: real pointer → pointer.active=false → no repulsion; fish calm.

Now let me write the final code text. I'll produce the complete file.

Also: the `.hud` class sets pointer-events:none; `.controls` needs pointer-events:auto. I'll define `.controls{pointer-events:auto}` explicitly (it has class "hud" too — order matters: define .controls after .hud in CSS so it overrides).

Let me write CSS in the right order.

One more consideration: the top-left brand text over the tank: ensure readable with text-shadow.

Let me think about the "legend" species names: "Ember", "Cobalt", "Solar", "Neon". Colors: #ff7a33, #3f7ff0, #ffc542, #7ef0ff.

Finally, the count element updates when adding fish.

Let me write out everything now.

For the intro loader animation, CSS dots.

Also: I should make sure the page has no horizontal scrollbar: overflow hidden on body.

Potential issue: `THREE.MeshPhysicalMaterial` with `sheen` property: property names are `sheen` (number), `sheenColor`, `sheenRoughness` — valid in r170. OK.

Another potential issue: `THREE.Color` constructor with hex works.

`scene.fog = new THREE.FogExp2(0x05202c, 0.019)` affects MeshStandardMaterial automatically (fog: true default). ShaderMaterials don't get fog unless `fog:true` and chunks included; our back wall shader doesn't include fog — fine since it's the background.

Water surface is a ShaderMaterial without fog — fine.

Transparent glass box with MeshPhysicalMaterial and opacity — the fog will slightly tint. fine.

Now writing final code. I'll take care to use `const`/`let` properly and avoid typos.

Let me write the complete script:

```js
import * as THREE from 'three';

const $ = (s) => document.querySelector(s);

try { init(); }
catch (err) {
  const l = $('#loader'); if (l) l.classList.add('hide');
  const f = $('#fallback'); if (f) { f.hidden = false; }
}

function init() {
  const host = $('#scene');
  const VW = () => window.innerWidth;
  const VH = () => window.innerHeight;

  const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
  renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
  renderer.setSize(VW(), VH());
  renderer.shadowMap.enabled = true;
  renderer.shadowMap.type = THREE.PCFSoftShadowMap;
  renderer.toneMapping = THREE.ACESFilmicToneMapping;
  renderer.toneMappingExposure = 1.12;
  renderer.outputColorSpace = THREE.SRGBColorSpace;
  renderer.domElement.style.touchAction = 'none';
  host.appendChild(renderer.domElement);

  const scene = new THREE.Scene();
  scene.background = new THREE.Color(0x020a12);
  scene.fog = new THREE.FogExp2(0x06202c, 0.018);

  const camera = new THREE.PerspectiveCamera(40, VW() / VH(), 0.1, 120);
  camera.position.set(0.9, 3.35, 15.4);
  const camLook = new THREE.Vector3(0, 2.95, 0);
  camera.lookAt(camLook);

  const maxAniso = renderer.capabilities.getMaxAnisotropy();

  /* ---------- textures ---------- */
  function canvasTex(w, h, draw) {
    const c = document.createElement('canvas'); c.width = w; c.height = h;
    draw(c.getContext('2d'), w, h);
    const t = new THREE.CanvasTexture(c);
    t.colorSpace = THREE.SRGBColorSpace;
    t.anisotropy = maxAniso;
    return t;
  }
  const sandTex = canvasTex(512, 512, (g, w, h) => { ... });
  ...
}
```

Good. Let me write the actual final content in the answer. I'll write it carefully and completely.

Regarding the fish texture function's drawBand helper — define inside.

Let me write everything out now (final answer code).

Also I should double check `THREE.TorusGeometry` billboard orientation and `renderer.render` order with transparent ripples — fine.

One more: the pointer ring element must be positioned via style.transform = `translate3d(${x}px, ${y}px, 0)`; since the element has margin -29px to center, using left/top in px is easier: `cursor.style.left = x+'px'; cursor.style.top=y+'px';` with transform translate(-50%,-50%) in CSS. Then the hot class changes size — use transform scale instead of changing margins. Let me define:
.cursor{position:fixed;width:58px;height:58px;left:0;top:0;transform:translate(-50%,-50%) scale(1);transition:transform .25s, opacity .3s, border-color .25s}
.cursor.hot{transform:translate(-50%,-50%) scale(1.35);border-color:...}

Good.

Also hide the cursor when the pointer is out: opacity 0. In auto mode always visible.

Let me now also make sure the hint bar doesn't overlap the cursor ring visually. Fine.

Writing final answer now. I'll include the complete file.

Let me carefully write the JS for plants:

```js
  const sway = [];
  function makeBlade(height, width, lean, color, phase) {
    const seg = 12;
    const positions = [], uvs = [], indices = [];
    for (let i = 0; i <= seg; i++) {
      const t = i / seg;
      const w = width * (1 - t * 0.82) * (0.55 + 0.45 * Math.sin(Math.min(1, t * 3) * Math.PI / 2));
      const cx = lean * t * t + Math.sin(t * 2.4 + phase) * width * 0.7;
      const cz = Math.sin(t * 1.7 + phase * 1.3) * 0.10;
      positions.push(cx - w, t * height, cz, cx + w, t * height, cz);
      uvs.push(0, t, 1, t);
      if (i < seg) { const a = i * 2; indices.push(a, a + 1, a + 2, a + 1, a + 3, a + 2); }
    }
    const g = new THREE.BufferGeometry();
    g.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
    g.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
    g.setIndex(indices);
    g.computeVertexNormals();
    const mat = new THREE.MeshStandardMaterial({ color, roughness: 0.8, side: THREE.DoubleSide, emissive: new THREE.Color(color).multiplyScalar(0.18), emissiveIntensity: 0.6 });
    const m = new THREE.Mesh(g, mat);
    m.castShadow = true;
    return m;
  }
```
Wait: emissive color multiplied by 0.18 and intensity 0.6 gives subtle glow. OK.

Cluster:
```js
  function makePlants(x, z, count, height, color, seed) {
    const grp = new THREE.Group();
    for (let i = 0; i < count; i++) {
      const h = height * (0.6 + Math.random() * 0.6);
      const b = makeBlade(h, 0.12 + Math.random() * 0.12, (Math.random() - 0.5) * 0.9, color, Math.random() * 6.28);
      b.position.set((Math.random() - 0.5) * 0.9, 0, (Math.random() - 0.5) * 0.5);
      b.rotation.y = Math.random() * Math.PI;
      grp.add(b);
      sway.push({ mesh: b, phase: Math.random() * 6.28, amp: 0.06 + Math.random() * 0.06 });
    }
    grp.position.set(x, 0.30, z);
    scene.add(grp);
  }
```

Coral branch function as described.

Rocks function.

Now the fish spawn distribution: put some near center.

Let me define the initial fish list:
```js
  const plan = [
    ['ember', -1.2, 3.2, 0.6], ['ember', 2.4, 2.1, -1.0], ['ember', -3.8, 1.6, 0.3], ['ember', 4.3, 4.3, -0.4],
    ['cobalt', 0.6, 4.6, -0.8], ['cobalt', -4.6, 3.9, 0.5], ['cobalt', 3.1, 5.0, 0.2], ['cobalt', 5.0, 1.7, -0.6],
    ['gold', -2.6, 5.1, 0.9], ['gold', 1.5, 1.4, -0.5], ['gold', 4.8, 3.4, 0.7], ['gold', -5.2, 2.4, -0.2],
    ['neon', -0.3, 2.6, 0.4], ['neon', 0.9, 2.2, -0.3], ['neon', -1.6, 2.9, 0.2], ['neon', 2.0, 3.1, 0.6]
  ];
```
Each [key, x, y, z].

Also give initial velocities random.

Now let me think about whether fish will swim "into" the plants etc. Not an issue.

I'm fairly confident in the design. Let me write the final file.

One more consideration on performance: 16 fish each with ~8 meshes = 128 draw calls + environment ~40 → ~170 draw calls. Fine for Chrome.

Shadow map: fish casting shadows = extra draw calls; fine.

Let me write the answer.

Also, I should make the "Auto" button initially active with class "active" and label "Auto ✓"? I'll show "Auto" with a small dot indicator.

Let me write final HTML/CSS/JS.

Final check on requirement "Load Three.js only through this import map" — we use the import map and import from 'three'. Good. No other CDN.

Writing now.

For the CSS of the loader:
```css
#loader{position:fixed;inset:0;z-index:30;display:flex;align-items:center;justify-content:center;background:radial-gradient(70% 60% at 50% 45%,#07222e,#01070c 70%);transition:opacity .9s ease, visibility .9s}
#loader.gone{opacity:0;visibility:hidden}
.loadbox{text-align:center}
.loadbox h2{margin:0;font-size:12px;letter-spacing:.35em;text-transform:uppercase;color:rgba(200,235,250,.7);font-weight:500}
.loadbox .bar{width:160px;height:2px;margin:16px auto 0;background:rgba(255,255,255,.1);overflow:hidden;position:relative}
.loadbox .bar::after{content:"";position:absolute;inset:0;width:40%;background:linear-gradient(90deg,transparent,#7ef0ff,transparent);animation:slide 1.2s infinite ease-in-out}
@keyframes slide{0%{transform:translateX(-120%)}100%{transform:translateX(320%)}}
```

Good.

Now finalize. I'll write the answer with the complete code.