Models 44

MiMo-V2.6-Flash

by Xiaomireasoning defaultClean
Open in new window

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

How it went

Total time
11 min 46 s
Cost
$0.016
Output tokens
55,929
Spent thinking
35,455 tokens
Speed
79.2 tok/s
Console errors
0
First token after
409 ms
First visible text after
9 min 22 s
Prompt tokens
431
Code size
56.1 KB
Finish reason
stop
Served by
Cohere
Model id
xiaomi/mimo-v2.6-flash
Reasoning sent
model default
Max output
131,072 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 · 1336 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>Glasshouse / Live Tank 04</title>
<style>
:root{
  --deep:#06151a; --abyss:#030b0e; --water:#0d6f78; --brass:#d9b66f;
  --ink:#edf7f2; --ink2:#91aaa5; --ink3:#5f7773;
  --hair:rgba(183,225,218,.24); --hot:rgba(217,182,111,.42);
  --pad:40px;
  --cond:"Barlow Condensed","Oswald","Arial Narrow","Helvetica Neue",Arial,sans-serif;
  --mono:"IBM Plex Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;
  --ease:cubic-bezier(.22,1,.36,1);
}
*{box-sizing:border-box}
html,body{height:100%;margin:0;padding:0}
body{
  background:var(--deep);color:var(--ink);font-family:var(--mono);
  overflow:hidden;-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;
}
#tank{position:fixed;inset:0;width:100%;height:100%;display:block;z-index:0;touch-action:none;
  opacity:0;animation:canvasIn 1.2s var(--ease) forwards}
@keyframes canvasIn{to{opacity:1}}

/* ---------- glass + legibility washes ---------- */
#glow{position:fixed;inset:0;z-index:1;pointer-events:none;
  background:
    linear-gradient(112deg,transparent 24%,rgba(205,246,240,.055) 33%,transparent 40%),
    linear-gradient(112deg,transparent 57%,rgba(205,246,240,.04) 63%,transparent 70%),
    radial-gradient(130% 90% at 50% 42%,transparent 42%,rgba(3,11,14,.5) 100%);
}
.wash{position:fixed;left:0;right:0;height:72px;z-index:3;pointer-events:none}
.wtop{top:0;background:linear-gradient(180deg,rgba(3,11,14,.72),rgba(3,11,14,0))}
.wbot{bottom:0;height:88px;background:linear-gradient(0deg,rgba(3,11,14,.78),rgba(3,11,14,0))}

/* ---------- tank architecture ---------- */
#frame{position:fixed;inset:0;z-index:2;pointer-events:none}
#frame i{position:absolute;background:rgba(217,182,111,.18);animation:frameBreath 12s ease-in-out infinite}
#frame i.v{top:0;bottom:0;width:1px;transform:scaleY(0);transform-origin:top center;
  animation:drawV .9s var(--ease) forwards,frameBreath 12s 1s ease-in-out infinite}
#frame i.h{left:0;right:0;top:68%;height:1px;transform:scaleX(0);transform-origin:left center;
  animation:drawH .9s .18s var(--ease) forwards,frameBreathH 12s 1s ease-in-out infinite}
@keyframes drawV{to{transform:scaleY(1)}}
@keyframes drawH{to{transform:scaleX(1)}}
@keyframes frameBreath{0%,100%{opacity:.78}50%{opacity:1.25}}
@keyframes frameBreathH{0%,100%{opacity:.7}50%{opacity:1.1}}

#measure{position:fixed;top:76px;left:32px;right:260px;height:6px;z-index:4;pointer-events:none;
  border-bottom:1px solid rgba(217,182,111,.4);
  background:repeating-linear-gradient(90deg,rgba(217,182,111,.5) 0 1px,transparent 1px 26px);
  background-size:100% 4px;background-repeat:no-repeat;background-position:bottom left;
  transform-origin:left center;transform:scaleX(0);animation:drawH .9s .3s var(--ease) forwards}

/* ---------- leaders ---------- */
#leaders{position:fixed;inset:0;z-index:4;pointer-events:none;overflow:visible}
#leadG{opacity:.9;transition:opacity .16s var(--ease)}
#leadG.off{opacity:0}
#leadL{stroke:rgba(217,182,111,.62);stroke-width:1}
#leadD{fill:var(--brass)}
#leadT{fill:var(--brass);font-family:var(--mono);font-size:9px;letter-spacing:.1em}

/* ---------- shared hud ---------- */
.hud{position:fixed;z-index:5}
.hudIn{opacity:0;animation:hudIn .42s var(--ease) forwards}
.hudUp{opacity:0;animation:hudUp .42s var(--ease) forwards}
@keyframes hudIn{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}
@keyframes hudUp{from{opacity:0;transform:translateY(-12px)}to{opacity:1;transform:translateY(0)}}
.d1{animation-delay:.09s}.d2{animation-delay:.16s}.d3{animation-delay:.24s}.d4{animation-delay:.32s}.d5{animation-delay:.38s}
.lbl{font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink3);font-weight:600}
.val{font-size:10px;letter-spacing:.08em;color:var(--brass);font-weight:600}

/* ---------- top ---------- */
#topbar{top:0;left:0;right:0;height:88px;padding:26px var(--pad) 0;display:flex;
  justify-content:space-between;align-items:flex-start;pointer-events:none}
.wordmark .title{font-family:var(--cond);font-size:34px;line-height:1;font-weight:700;
  letter-spacing:.02em;text-transform:uppercase;color:var(--ink)}
.wordmark .title .sub{font-size:16px;font-weight:600;letter-spacing:.14em;color:var(--ink2);vertical-align:3px}
.wordmark .row{display:flex;align-items:center;gap:12px;margin-top:9px}
.wordmark .bar{width:48px;height:1px;background:var(--brass);opacity:.75;transform-origin:left;
  transform:scaleX(0);animation:drawH .6s .5s var(--ease) forwards}
.wordmark .tag{font-size:10px;letter-spacing:.16em;color:var(--ink3);text-transform:uppercase}
.ready{display:flex;align-items:center;gap:9px;text-align:right}
.dot{width:8px;height:8px;border-radius:50%;background:#58d6a0;flex:none;
  box-shadow:0 0 8px rgba(88,214,160,.55);animation:pulse 2.4s ease-in-out infinite}
.dot.off{background:var(--ink3);box-shadow:none;animation:none}
@keyframes pulse{0%,100%{opacity:.55;transform:scale(.9)}50%{opacity:1;transform:scale(1.1)}}
.ready .r1{font-size:10px;letter-spacing:.16em;font-weight:600;color:var(--ink)}
.ready .r2{font-size:10px;letter-spacing:.14em;color:var(--ink3);display:block;margin-top:3px}

/* ---------- left species rail ---------- */
#speciesRail{top:104px;left:var(--pad);width:300px;pointer-events:none}
.railhead{display:flex;justify-content:space-between;align-items:baseline;
  border-bottom:1px solid var(--hair);padding-bottom:7px;margin-bottom:16px}
.railhead .n{font-family:var(--cond);font-size:16px;letter-spacing:.14em;color:var(--ink2);text-transform:uppercase}
.plaque{position:relative;border-left:1px solid var(--hair);padding:0 0 0 12px;margin-bottom:16px;
  transition:border-color .18s var(--ease),opacity .18s var(--ease)}
.plaque .idx{font-size:10px;letter-spacing:.16em;color:var(--ink3);font-weight:600;
  transition:color .18s var(--ease)}
.plaque .pname{font-family:var(--cond);font-size:16px;line-height:1.05;font-weight:600;
  letter-spacing:.06em;color:var(--ink2);text-transform:uppercase;margin:2px 0 5px;
  transition:color .18s var(--ease)}
.plaque .pmeta,.plaque .phab,.plaque .pdep{font-size:10px;letter-spacing:.1em;color:var(--ink3);
  line-height:1.7;text-transform:uppercase}
.plaque .pdep b{color:var(--ink2);font-weight:600}
.plaque::before{content:"";position:absolute;left:-1px;top:0;width:1px;height:0;background:var(--brass);
  transition:height .38s var(--ease)}
.plaque.active::before{height:100%}
.plaque.active .pname{color:var(--ink)}
.plaque.active .idx{color:var(--brass)}
.plaque.near::after{content:"";position:absolute;left:-4px;top:6px;width:7px;height:7px;
  background:var(--brass);transform:rotate(45deg)}
.railfoot{margin-top:6px;border-top:1px solid var(--hair);padding-top:8px;display:flex;
  justify-content:space-between;font-size:10px;letter-spacing:.12em;color:var(--ink3)}
.railfoot b{color:var(--brass);font-weight:600}

/* ---------- right instruments ---------- */
#instruments{top:104px;right:var(--pad);width:164px;pointer-events:none;display:flex;
  flex-direction:column;gap:12px}
.plate{border:1px solid rgba(217,182,111,.42);background:rgba(6,21,26,.55);
  backdrop-filter:blur(3px);-webkit-backdrop-filter:blur(3px);padding:10px 10px 11px}
.plate h3{margin:0 0 8px;font-family:var(--cond);font-size:11px;font-weight:600;
  letter-spacing:.16em;color:var(--ink2);text-transform:uppercase}
.kv{display:flex;justify-content:space-between;align-items:baseline;gap:8px;line-height:1.9}
.kv span:first-child{font-size:10px;letter-spacing:.1em;color:var(--ink3)}
.kv span:last-child{font-size:10px;letter-spacing:.06em;color:var(--brass);font-weight:600}
.chart{display:flex;align-items:flex-end;gap:5px;height:46px;
  border-bottom:1px solid rgba(183,225,218,.18);padding-bottom:0}
.chart i{flex:1;height:20%;background:var(--brass);opacity:.8;transition:height .24s linear}
.chart i:nth-child(odd){opacity:.6}
.chartrow{display:flex;justify-content:space-between;margin-top:6px}

/* ---------- prism ---------- */
#prism{top:88px;bottom:72px;left:50%;transform:translateX(-50%);width:180px;pointer-events:none;
  z-index:4;opacity:.55;transition:opacity .4s var(--ease),width .5s var(--ease)}
#prism .edge{position:absolute;top:0;bottom:0;width:1px;background:var(--hot);
  transition:background .3s var(--ease)}
#prism .edge.l{left:0}#prism .edge.r{right:0}
#prism .fill{position:absolute;inset:0;
  background:linear-gradient(90deg,rgba(205,246,240,.055),rgba(205,246,240,.02) 50%,rgba(205,246,240,.055));
  border-left:1px solid rgba(217,182,111,.42);border-right:1px solid rgba(217,182,111,.42)}
#prism .sweep{position:absolute;left:0;right:0;height:1px;background:linear-gradient(90deg,
  transparent,rgba(217,182,111,.85),transparent);opacity:0;top:0}
#prism.hot{opacity:1}
#prism.hot .sweep{animation:sweep .65s linear infinite;opacity:1}
#prism.hot .edge{background:rgba(217,182,111,.85)}
@keyframes sweep{from{top:0%}to{top:100%}}
#prism .plabel{position:absolute;left:50%;bottom:16px;transform:translateX(-50%);text-align:center;
  white-space:nowrap}
#prism .plabel .a{font-size:10px;letter-spacing:.18em;color:var(--ink3);text-transform:uppercase}
#prism .plabel .b{font-size:10px;letter-spacing:.1em;color:var(--brass);margin-top:5px;display:block}

/* ---------- bottom ---------- */
#bottombar{left:0;right:0;bottom:0;height:72px;padding:0 var(--pad) 14px;display:flex;
  align-items:flex-end;justify-content:space-between;gap:16px;pointer-events:none}
.instr{display:flex;flex-direction:column;gap:7px;min-width:0}
.instr .l1{display:flex;align-items:center;gap:9px;font-size:10px;letter-spacing:.16em;
  color:var(--ink2);text-transform:uppercase;white-space:nowrap}
.ipulse{width:6px;height:6px;border-radius:50%;background:var(--brass);opacity:.45;flex:none;
  transition:opacity .5s var(--ease)}
.ipulse.hot{opacity:1;transition:opacity .1s linear}
.instr .l2{font-size:10px;letter-spacing:.1em;color:var(--ink3);white-space:nowrap;overflow:hidden;
  text-overflow:ellipsis}
.ctlwrap{display:flex;gap:10px;pointer-events:auto}
.ctl{width:40px;height:40px;border-radius:9999px;border:1px solid var(--hot);background:transparent;
  color:var(--ink2);display:grid;place-items:center;cursor:pointer;padding:0;
  transition:transform .18s var(--ease),border-color .18s var(--ease),background .18s var(--ease),color .18s var(--ease)}
.ctl:hover{transform:translateY(-2px);border-color:rgba(217,182,111,.75);background:rgba(217,182,111,.1);color:var(--ink)}
.ctl:focus-visible{outline:1px solid var(--water);outline-offset:3px;border-color:var(--water)}
.ctl svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.4;
  stroke-linecap:round;stroke-linejoin:round}
.ctl.playing svg{opacity:.44}

/* ---------- mobile helpers ---------- */
#mchip,#minstr{display:none}
#mchip{top:78px;right:16px;text-align:right}
#mchip .sq{width:40px;height:40px;border:1px solid var(--hot);display:grid;place-items:center;
  font-size:12px;color:var(--brass);letter-spacing:.08em;background:rgba(6,21,26,.5)}
#mchip .nm{font-family:var(--cond);font-size:11px;letter-spacing:.12em;color:var(--ink2);
  margin-top:6px;text-transform:uppercase}
#minstr{left:50%;transform:translateX(-50%);bottom:64px;gap:8px}
#minstr .pill{border:1px solid var(--hot);border-radius:9999px;padding:5px 11px;font-size:10px;
  letter-spacing:.1em;color:var(--brass);background:rgba(6,21,26,.55);white-space:nowrap}

/* ---------- start ---------- */
#start{position:fixed;inset:0;z-index:9;display:grid;place-items:center;pointer-events:none;
  transition:opacity .5s var(--ease)}
#start.gone{opacity:0}
.startcard{border:1px solid rgba(217,182,111,.5);background:rgba(3,11,14,.62);
  backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);padding:14px 22px;text-align:center;
  animation:hudIn .6s .5s var(--ease) both}
.startcard .k{font-size:11px;letter-spacing:.2em;color:var(--brass);text-transform:uppercase}
.startcard .s{display:block;margin-top:7px;font-size:10px;letter-spacing:.12em;color:var(--ink3)}

.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}

/* ---------- responsive ---------- */
@media (max-width:1120px){
  :root{--pad:24px}
  #measure{right:230px}
}
@media (max-width:1024px){
  #speciesRail{width:190px}
  #instruments{width:150px}
  .plaque .idx{display:none}
  .plaque:not(.active) .phab,.plaque:not(.active) .pdep,.plaque:not(.active) .pmeta{display:none}
  .plaque{margin-bottom:12px}
  #measure{right:210px}
}
@media (max-width:720px){
  :root{--pad:16px}
  #speciesRail,#instruments,#prism,#measure{display:none}
  #mchip{display:block}
  #minstr{display:flex}
  #frame i.v{display:none}
  #frame i.h{top:74%}
  #topbar{height:64px;padding-top:16px}
  .wordmark .title{font-size:20px}
  .wordmark .title .sub{font-size:11px}
  .wordmark .row{margin-top:6px;gap:8px}
  .ready .r2{display:none}
  #bottombar{height:76px;padding-bottom:14px}
  .instr .l1{font-size:9px;letter-spacing:.12em}
  .instr .l2{display:none}
  .ctl{width:44px;height:44px}
  .startcard{padding:12px 16px;max-width:78vw}
}
@media (max-width:420px){
  .wordmark .title .sub,.wordmark .tag{display:none}
  .startcard .k{font-size:10px;letter-spacing:.14em}
  #mchip .nm{display:none}
}
@media (prefers-reduced-motion:reduce){
  .hudIn,.hudUp,.startcard{animation:none!important;opacity:1!important;transform:none!important}
  #frame i,#measure,.wordmark .bar{animation:none!important;transform:none!important;opacity:.9}
  #prism.hot .sweep{animation:none;opacity:0}
  .ctl:hover{transform:none}
  .dot{animation:none}
}
</style>
</head>
<body>

<canvas id="tank" aria-label="Interactive 3D aquarium" role="img"></canvas>
<div id="glow"></div>
<div class="wash wtop"></div>
<div class="wash wbot"></div>

<div id="frame" aria-hidden="true">
  <i class="v" style="left:8%"></i>
  <i class="v" style="left:25%"></i>
  <i class="v" style="left:50%"></i>
  <i class="v" style="left:75%"></i>
  <i class="v" style="left:92%"></i>
  <i class="h"></i>
</div>
<div id="measure" aria-hidden="true"></div>

<svg id="leaders" aria-hidden="true">
  <g id="leadG" class="off">
    <line id="leadL" x1="0" y1="0" x2="0" y2="0"></line>
    <polygon id="leadD" points="0,0"></polygon>
    <text id="leadT" x="0" y="0">0.00 m</text>
  </g>
</svg>

<header id="topbar" class="hud hudUp d1">
  <div class="wordmark">
    <div class="title">Glasshouse <span class="sub">/ Live Tank 04</span></div>
    <div class="row"><span class="bar"></span><span class="tag">Estuary · Reef · Open Water</span></div>
  </div>
  <div class="ready">
    <span class="dot" id="dot"></span>
    <span><span class="r1" id="r1">Ready</span><span class="r2" id="r2">Local Simulation</span></span>
  </div>
</header>

<aside id="speciesRail" class="hud hudIn d2" aria-label="Species index">
  <div class="railhead"><span class="n">Species / 04</span><span class="lbl">Index</span></div>

  <div class="plaque active" data-s="0">
    <div class="idx">01</div>
    <div class="pname">Royal Gramma</div>
    <div class="pmeta">Fairy Basslet</div>
    <div class="phab">Caribbean Reef · Midwater</div>
    <div class="pdep">Depth <b>1.8 m</b></div>
  </div>

  <div class="plaque" data-s="1">
    <div class="idx">02</div>
    <div class="pname">Ocellaris Clownfish</div>
    <div class="pmeta">Amphiprion Ocellaris</div>
    <div class="phab">Anemone Edge · Low Surge</div>
    <div class="pdep">Depth <b>1.2 m</b></div>
  </div>

  <div class="plaque" data-s="2">
    <div class="idx">03</div>
    <div class="pname">Blue-Green Chromis</div>
    <div class="pmeta">Chromis Viridis · School 03</div>
    <div class="phab">Open Water · Schooling</div>
    <div class="pdep">Depth <b>2.4 m</b></div>
  </div>

  <div class="plaque" data-s="3">
    <div class="idx">04</div>
    <div class="pname">Yellow Tang</div>
    <div class="pmeta">Zebrasoma Flavescens</div>
    <div class="phab">Reef Flat · Grazing</div>
    <div class="pdep">Depth <b>0.9 m</b></div>
  </div>

  <div class="railfoot"><span>Nearest glass</span><b id="railNear">0.00 m</b></div>
</aside>

<aside id="instruments" class="hud hudIn d3" aria-label="Tank instruments">
  <div class="plate">
    <h3>Behaviour</h3>
    <div class="kv"><span>Active</span><span id="vBehav">Peaceful</span></div>
    <div class="kv"><span>Response</span><span id="vResp">0.42 s</span></div>
  </div>
  <div class="plate">
    <h3>Water</h3>
    <div class="kv"><span>Temp</span><span id="vTemp">25.4°C</span></div>
    <div class="kv"><span>pH</span><span id="vPh">8.1</span></div>
    <div class="kv"><span>Flow</span><span id="vFlow">Gentle</span></div>
  </div>
  <div class="plate">
    <h3>Near Glass</h3>
    <div class="chart" id="chart"><i></i><i></i><i></i><i></i><i></i></div>
    <div class="chartrow"><span class="lbl">0 → 3.75 m</span><span class="val" id="vGlass">0 / 6</span></div>
  </div>
</aside>

<div id="prism" class="hud" aria-hidden="true">
  <div class="fill"></div><div class="edge l"></div><div class="edge r"></div><div class="sweep"></div>
  <div class="plabel"><span class="a">Pressure Zone</span><span class="b" id="vCoords">X +0.00 / Y +0.00</span></div>
</div>

<div id="mchip" class="hud hudIn d3"><div class="sq" id="mchipIdx">01</div><div class="nm" id="mchipName">Royal Gramma</div></div>
<div id="minstr" class="hud hudIn d4"><span class="pill" id="mTemp">25.4°C</span><span class="pill">Flow Gentle</span></div>

<footer id="bottombar" class="hud hudUp d5">
  <div class="instr">
    <div class="l1"><span class="ipulse" id="ipulse"></span><span>Move the mouse near the glass</span></div>
    <div class="l2">Mouse near glass · click to hold · drag to steer · Space pause · R reset · M sound</div>
  </div>
  <div class="ctlwrap">
    <button class="ctl" id="btnPause" aria-label="Pause simulation" title="Pause / resume (Space)">
      <svg viewBox="0 0 24 24" id="icoPause"><line x1="9" y1="6" x2="9" y2="18"/><line x1="15" y1="6" x2="15" y2="18"/></svg>
    </button>
    <button class="ctl playing" id="btnSound" aria-label="Toggle ambient sound" title="Ambient sound (M)">
      <svg viewBox="0 0 24 24" id="icoSound">
        <path d="M5 10v4h3l4 3V7l-4 3H5z"/><path d="M16 9.5a4 4 0 0 1 0 5"/><path d="M18.5 7.5a7.5 7.5 0 0 1 0 9"/>
      </svg>
    </button>
  </div>
</footer>

<div id="start"><div class="startcard"><span class="k">Click or press a key to start</span><span class="s">The fish will notice the pointer</span></div></div>

<p class="sr" aria-live="polite" id="srStatus">Aquarium loading.</p>

<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>

<script type="module">
import * as THREE from 'three';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';

/* ============================================================
   0. ENVIRONMENT
   ============================================================ */
const RM = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const $ = (s) => document.querySelector(s);
const cvs = $('#tank');
const vw = () => document.documentElement.clientWidth;
const vh = () => document.documentElement.clientHeight;

let W = vw(), H = vh();

const renderer = new THREE.WebGLRenderer({ canvas: cvs, antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setSize(W, H, false);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.06;

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x06151a);
scene.fog = new THREE.FogExp2(0x06151a, 0.036);

const camera = new THREE.PerspectiveCamera(42, W / H, 0.1, 120);
camera.position.set(0, 0.6, 13.8);
camera.lookAt(0, 0.05, 0);

let composer = null;
try {
  composer = new EffectComposer(renderer);
  composer.addPass(new RenderPass(scene, camera));
  composer.addPass(new UnrealBloomPass(new THREE.Vector2(W, H), 0.3, 0.7, 0.85));
  composer.addPass(new OutputPass());
} catch (e) { composer = null; }

/* ============================================================
   1. PROCEDURAL TEXTURES
   ============================================================ */
function tex(size, painter, repX = 1, repY = 1, srgb = true) {
  const c = document.createElement('canvas');
  c.width = c.height = size;
  const x = c.getContext('2d');
  painter(x, size);
  const t = new THREE.CanvasTexture(c);
  t.wrapS = t.wrapT = THREE.RepeatWrapping;
  t.repeat.set(repX, repY);
  if (srgb) t.colorSpace = THREE.SRGBColorSpace;
  t.anisotropy = 4;
  return t;
}

const causticTex = tex(256, (x, S) => {
  const img = x.createImageData(S, S), d = img.data;
  for (let y = 0; y < S; y++) {
    for (let i = 0; i < S; i++) {
      const nx = i * 0.11, ny = y * 0.11;
      const n = (Math.sin(nx + Math.sin(ny * 0.9) * 1.8) +
                 Math.sin(ny * 1.15 + Math.sin(nx * 0.75) * 1.9) +
                 Math.sin((nx + ny) * 0.6)) / 3;
      let v = Math.pow(Math.max(0, 1 - Math.abs(n) * 2.6), 2.4);
      const n2 = (Math.sin(nx * 2.1 + ny * 0.5) + Math.sin(ny * 2.3 + nx * 0.3)) / 2;
      const v2 = Math.pow(Math.max(0, 1 - Math.abs(n2) * 3.0), 3.0);
      const g = Math.min(1, v * 0.9 + v2 * 0.5);
      const k = (y * S + i) * 4;
      d[k] = g * 168; d[k + 1] = g * 224; d[k + 2] = g * 255; d[k + 3] = 255;
    }
  }
  x.putImageData(img, 0, 0);
}, 3, 3);

const scaleTex = tex(256, (x, S) => {
  x.fillStyle = '#808080'; x.fillRect(0, 0, S, S);
  const r = 9;
  for (let row = -1; row < S / r + 2; row++) {
    for (let col = -1; col < S / r + 2; col++) {
      const cx = col * r + (row % 2 ? r / 2 : 0), cy = row * r;
      const g = x.createRadialGradient(cx, cy - r * 0.4, 1, cx, cy, r);
      g.addColorStop(0, 'rgba(255,255,255,0.5)');
      g.addColorStop(0.65, 'rgba(128,128,128,0)');
      g.addColorStop(1, 'rgba(0,0,0,0.55)');
      x.fillStyle = g; x.beginPath(); x.arc(cx, cy, r, 0, 7); x.fill();
    }
  }
}, 4, 3, false);

const sandTex = tex(256, (x, S) => {
  x.fillStyle = '#26332e'; x.fillRect(0, 0, S, S);
  for (let i = 0; i < 9000; i++) {
    const px = Math.random() * S, py = Math.random() * S, r = Math.random() * 1.7 + 0.3;
    x.fillStyle = Math.random() > 0.55
      ? 'rgba(158,172,152,' + (0.05 + Math.random() * 0.2) + ')'
      : 'rgba(5,17,19,' + (0.05 + Math.random() * 0.22) + ')';
    x.beginPath(); x.arc(px, py, r, 0, 7); x.fill();
  }
}, 7, 5);

const clownTex = tex(256, (x, S) => {
  const g = x.createLinearGradient(0, 0, 0, S);
  g.addColorStop(0, '#e2551d'); g.addColorStop(1, '#ff8f3a');
  x.fillStyle = g; x.fillRect(0, 0, S, S);
  [0.15, 0.47, 0.80].forEach(v => {
    const y = (1 - v) * S, h = S * 0.05;
    x.fillStyle = '#15100d'; x.fillRect(0, y - h * 1.1, S, h * 2.2);
    x.fillStyle = '#f7f2e7'; x.fillRect(0, y - h * 0.75, S, h * 1.5);
  });
}, 1, 1);

const glowTex = tex(64, (x, S) => {
  const g = x.createRadialGradient(S / 2, S / 2, 0, S / 2, S / 2, S / 2);
  g.addColorStop(0, 'rgba(255,255,255,1)');
  g.addColorStop(0.3, 'rgba(205,246,240,0.6)');
  g.addColorStop(1, 'rgba(205,246,240,0)');
  x.fillStyle = g; x.fillRect(0, 0, S, S);
});

const skyTex = tex(64, (x, S) => {
  const g = x.createLinearGradient(0, 0, 0, S);
  g.addColorStop(0, '#0f3b42'); g.addColorStop(0.55, '#08222a'); g.addColorStop(1, '#030b0e');
  x.fillStyle = g; x.fillRect(0, 0, S, S);
});

/* ============================================================
   2. ENVIRONMENT GEOMETRY
   ============================================================ */
// backdrop
const backdrop = new THREE.Mesh(
  new THREE.PlaneGeometry(70, 40),
  new THREE.MeshBasicMaterial({ map: skyTex, fog: false, depthWrite: false })
);
backdrop.position.set(0, 3, -14);
scene.add(backdrop);

// caustic back wall
const causticWall = new THREE.Mesh(
  new THREE.PlaneGeometry(34, 20),
  new THREE.MeshBasicMaterial({
    map: causticTex, transparent: true, opacity: 0.12,
    blending: THREE.AdditiveBlending, depthWrite: false
  })
);
causticWall.position.set(0, 1.4, -8);
scene.add(causticWall);

// substrate
const sandGeo = new THREE.PlaneGeometry(46, 30, 64, 44);
{
  const p = sandGeo.attributes.position;
  for (let i = 0; i < p.count; i++) {
    const x = p.getX(i), y = p.getY(i);
    const h = Math.sin(x * 0.28) * 0.16 + Math.cos(y * 0.22 + x * 0.1) * 0.14 +
              Math.sin((x + y) * 0.6) * 0.035;
    p.setZ(i, h);
  }
  sandGeo.computeVertexNormals();
}
const sand = new THREE.Mesh(sandGeo, new THREE.MeshStandardMaterial({
  map: sandTex, color: 0x9fb0a4, roughness: 1, metalness: 0
}));
sand.rotation.x = -Math.PI / 2;
sand.position.set(0, -2.0, 1);
scene.add(sand);

// caustic light on sand
const sandCaustic = new THREE.Mesh(
  new THREE.PlaneGeometry(34, 26),
  new THREE.MeshBasicMaterial({
    map: causticTex, transparent: true, opacity: 0.065,
    blending: THREE.AdditiveBlending, depthWrite: false
  })
);
sandCaustic.rotation.x = -Math.PI / 2;
sandCaustic.position.set(0, -1.9, 1);
scene.add(sandCaustic);

// water surface
const surface = new THREE.Mesh(
  new THREE.PlaneGeometry(50, 40),
  new THREE.MeshStandardMaterial({
    map: causticTex, color: 0x0d6f78, transparent: true, opacity: 0.22,
    roughness: 0.25, metalness: 0.1, side: THREE.DoubleSide, depthWrite: false,
    emissive: 0x0a3a40, emissiveIntensity: 0.4
  })
);
surface.rotation.x = Math.PI / 2;
surface.position.set(0, 4.6, -2);
scene.add(surface);

// rocks + pebbles (instanced)
const rockMat = new THREE.MeshStandardMaterial({ color: 0x172a2d, roughness: 0.95, metalness: 0.05, flatShading: true });
const rocks = new THREE.InstancedMesh(new THREE.DodecahedronGeometry(1, 0), rockMat, 46);
const pebbles = new THREE.InstancedMesh(new THREE.IcosahedronGeometry(1, 0),
  new THREE.MeshStandardMaterial({ color: 0x2c3b36, roughness: 1, metalness: 0, flatShading: true }), 134);
{
  const m = new THREE.Matrix4(), q = new THREE.Quaternion(), e = new THREE.Euler(), s = new THREE.Vector3();
  for (let i = 0; i < 46; i++) {
    const x = (Math.random() - 0.5) * 20, z = -7 + Math.random() * 12;
    e.set(Math.random() * 3, Math.random() * 3, Math.random() * 3);
    q.setFromEuler(e);
    const sc = 0.35 + Math.random() * 1.15;
    s.set(sc, sc * (0.5 + Math.random() * 0.4), sc * (0.8 + Math.random() * 0.5));
    m.compose(new THREE.Vector3(x, -2.1 + sc * 0.16, z), q, s);
    rocks.setMatrixAt(i, m);
  }
  for (let i = 0; i < 134; i++) {
    const x = (Math.random() - 0.5) * 24, z = -7 + Math.random() * 15;
    const sc = 0.05 + Math.random() * 0.14;
    e.set(Math.random() * 3, Math.random() * 3, Math.random() * 3);
    q.setFromEuler(e); s.set(sc, sc * 0.7, sc);
    m.compose(new THREE.Vector3(x, -1.96, z), q, s);
    pebbles.setMatrixAt(i, m);
  }
  rocks.instanceMatrix.needsUpdate = true;
  pebbles.instanceMatrix.needsUpdate = true;
}
scene.add(rocks, pebbles);

// plants (swaying blades)
const plantMat = new THREE.MeshStandardMaterial({ color: 0x2f6b52, roughness: 0.85, metalness: 0, side: THREE.DoubleSide });
const plants = [];
{
  const s = new THREE.Shape();
  s.moveTo(-0.09, 0); s.quadraticCurveTo(-0.05, 0.8, 0.02, 1.7);
  s.quadraticCurveTo(0.07, 0.8, 0.09, 0); s.closePath();
  const g = new THREE.ShapeGeometry(s, 10);
  for (let i = 0; i < 14; i++) {
    const m = new THREE.Mesh(g, plantMat);
    const x = (Math.random() - 0.5) * 22, z = -6.5 + Math.random() * 11;
    m.position.set(x, -1.95, z);
    const sc = 0.7 + Math.random() * 1.5;
    m.scale.set(sc, sc, sc);
    m.rotation.y = Math.random() * Math.PI;
    m.userData.ph = Math.random() * 6.28;
    scene.add(m); plants.push(m);
  }
}

// coral clusters
const coralMat = new THREE.MeshStandardMaterial({
  color: 0xc7574d, roughness: 0.7, metalness: 0.05,
  emissive: 0xff6f61, emissiveIntensity: 0.14
});
{
  const cg = new THREE.CylinderGeometry(0.02, 0.05, 1, 6, 1);
  for (let c = 0; c < 4; c++) {
    const baseX = -6 + c * 4.2 + (Math.random() - 0.5) * 1.5;
    const baseZ = -4.5 + Math.random() * 7;
    for (let b = 0; b < 7; b++) {
      const m = new THREE.Mesh(cg, coralMat);
      m.position.set(baseX + (Math.random() - 0.5) * 0.6, -1.75 + Math.random() * 0.2, baseZ + (Math.random() - 0.5) * 0.6);
      m.scale.set(1, 0.5 + Math.random() * 0.9, 1);
      m.rotation.set((Math.random() - 0.5) * 0.9, Math.random() * 3, (Math.random() - 0.5) * 0.9);
      scene.add(m);
    }
  }
}

// drifting particles + bubbles
function makePoints(count, size, opacity, color, box) {
  const pos = new Float32Array(count * 3);
  for (let i = 0; i < count; i++) {
    pos[i * 3] = (Math.random() - 0.5) * box.x;
    pos[i * 3 + 1] = box.y0 + Math.random() * (box.y1 - box.y0);
    pos[i * 3 + 2] = box.z0 + Math.random() * (box.z1 - box.z0);
  }
  const g = new THREE.BufferGeometry();
  g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
  const m = new THREE.PointsMaterial({
    size, map: glowTex, transparent: true, opacity, depthWrite: false,
    blending: THREE.AdditiveBlending, color, sizeAttenuation: true
  });
  const p = new THREE.Points(g, m);
  scene.add(p);
  return p;
}
const motes = makePoints(210, 0.07, 0.5, 0x9fe8dd, { x: 20, y0: -2, y1: 4.6, z0: -7, z1: 5 });
const bubbles = makePoints(34, 0.1, 0.4, 0xd8fff6, { x: 18, y0: -1.9, y1: 4.4, z0: -6, z1: 4 });

/* ============================================================
   3. LIGHTS
   ============================================================ */
scene.add(new THREE.HemisphereLight(0x1a5f6a, 0x040d10, 0.62));
const key = new THREE.DirectionalLight(0xd8f2ea, 1.55);
key.position.set(-5, 9, 5);
scene.add(key);
const brassLight = new THREE.PointLight(0xd9b66f, 14, 16, 2);
brassLight.position.set(4, 1.4, 3);
scene.add(brassLight);
const coralLight = new THREE.PointLight(0xff6f61, 9, 13, 2);
coralLight.position.set(-5, -0.6, 2.5);
scene.add(coralLight);
const rim = new THREE.DirectionalLight(0x0d6f78, 0.9);
rim.position.set(1, 2, -8);
scene.add(rim);

/* ============================================================
   4. FISH
   ============================================================ */
const B = { x: 6.0, ymin: -1.45, ymax: 2.5, zmin: -4.2, zmax: 3.0 };
const FRONT_GLASS = 4.6;

function bodyGeo(L, A, hScale, wScale) {
  const pts = [], N = 26;
  for (let i = 0; i <= N; i++) {
    const t = i / N;
    let r = A * Math.pow(Math.sin(Math.PI * t), 0.62) * (1 - 0.3 * t);
    r = Math.max(r, 0.006);
    pts.push(new THREE.Vector2(r, -L / 2 + t * L));
  }
  const g = new THREE.LatheGeometry(pts, 30);
  g.rotateX(-Math.PI / 2);
  g.scale(wScale, hScale, 1);
  g.computeVertexNormals();
  return g;
}
function paintGeo(g, L, fn) {
  const p = g.attributes.position, c = new THREE.Color(), arr = new Float32Array(p.count * 3);
  for (let i = 0; i < p.count; i++) {
    const t = (L / 2 - p.getZ(i)) / L;
    fn(t, p.getY(i), c);
    arr[i * 3] = c.r; arr[i * 3 + 1] = c.g; arr[i * 3 + 2] = c.b;
  }
  g.setAttribute('color', new THREE.BufferAttribute(arr, 3));
}
function shapeMesh(pts, mat, rx, ry, rz) {
  const s = new THREE.Shape();
  s.moveTo(pts[0][0], pts[0][1]);
  for (let i = 1; i < pts.length; i++) s.lineTo(pts[i][0], pts[i][1]);
  s.closePath();
  const g = new THREE.ShapeGeometry(s);
  if (ry) g.rotateY(ry);
  if (rx) g.rotateX(rx);
  if (rz) g.rotateZ(rz);
  return new THREE.Mesh(g, mat);
}

const SPECS = [
  { key: 'gramma', name: 'Royal Gramma', behav: 'Shy', L: 0.80, A: 0.20, h: 1.0, w: 0.6,
    size: 1.0, speed: 2.3, force: 7.0, turn: 4.6, flee: 1.15, react: 0.06, sep: 0.85,
    fin: 0x9a4f8f, school: false, tailFreq: 8.5,
    home: [-3.6, 0.5, 1.0],
    paint: (t, y, c) => {
      if (t < 0.42) c.set(0x4b2b8f).lerp(new THREE.Color(0xa83f8e), t / 0.42);
      else c.set(0xc94f7a).lerp(new THREE.Color(0xf4c542), (t - 0.42) / 0.58);
    } },
  { key: 'clown', name: 'Ocellaris Clownfish', behav: 'Peaceful', L: 1.12, A: 0.24, h: 1.0, w: 0.58,
    size: 1.0, speed: 1.9, force: 6.4, turn: 3.6, flee: 0.9, react: 0.1, sep: 1.0,
    fin: 0xff7a2f, school: false, tailFreq: 7.0, map: clownTex,
    home: [2.9, -0.5, 0.6],
    paint: (t, y, c) => c.set(0xffffff) },
  { key: 'chromis', name: 'Blue-Green Chromis', behav: 'Shy', L: 0.62, A: 0.17, h: 1.05, w: 0.44,
    size: 1.0, speed: 2.7, force: 7.5, turn: 4.8, flee: 1.35, react: 0.04, sep: 0.7,
    fin: 0xa9e3c8, school: true, tailFreq: 11,
    home: [0.6, 1.5, -1.6],
    paint: (t, y, c) => {
      c.set(0x7fd0c0).lerp(new THREE.Color(0xbfe6a8), t);
      if (y < 0) c.lerp(new THREE.Color(0xeaf6da), 0.4);
    } },
  { key: 'tang', name: 'Yellow Tang', behav: 'Neutral', L: 1.34, A: 0.24, h: 1.45, w: 0.42,
    size: 1.0, speed: 1.7, force: 5.2, turn: 2.6, flee: 0.62, react: 0.16, sep: 1.25,
    fin: 0xffcf3a, school: false, tailFreq: 5.0,
    home: [-1.0, -1.0, 2.0],
    paint: (t, y, c) => c.set(0xffd84a).lerp(new THREE.Color(0xf0ac1c), t) }
];

const fishes = [];

class Fish {
  constructor(spec, index) {
    this.spec = spec; this.si = ['gramma', 'clown', 'chromis', 'tang'].indexOf(spec.key);
    this.phase = Math.random() * 6.28;
    this.pos = new THREE.Vector3();
    this.vel = new THREE.Vector3();
    this.home = new THREE.Vector3();
    this.base = new THREE.Vector3(spec.home[0], spec.home[1], spec.home[2]);
    this.appear = 0; this.delay = 0.09 + index * 0.07;
    this.pressure = 0; this.inPressure = 0; this.r = 0.55;
    this.build();
    this.reset();
  }
  build() {
    const s = this.spec;
    const g = bodyGeo(s.L, s.A, s.h, s.w);
    paintGeo(g, s.L, s.paint);
    this.bodyMat = new THREE.MeshStandardMaterial({
      map: s.map || null, bumpMap: scaleTex, bumpScale: 0.014,
      vertexColors: true, color: 0xffffff, roughness: 0.42, metalness: 0.14,
      emissive: 0x0b2a2e, emissiveIntensity: 0.35,
      transparent: true, opacity: 0
    });
    this.finMat = new THREE.MeshStandardMaterial({
      color: s.fin, roughness: 0.55, metalness: 0.05, transparent: true, opacity: 0,
      side: THREE.DoubleSide, depthWrite: false, emissive: s.fin, emissiveIntensity: 0.1
    });
    const grp = new THREE.Group();
    const body = new THREE.Mesh(g, this.bodyMat);
    grp.add(body);

    // caudal (in its own pivot group)
    const tailPivot = new THREE.Group();
    tailPivot.position.set(0, 0, -s.L / 2 + 0.03);
    const tail = shapeMesh([[0, 0.03], [0.32, 0.27], [0.15, 0], [0.32, -0.27], [0, -0.03]],
      this.finMat, 0, Math.PI / 2, 0);
    tailPivot.add(tail);
    grp.add(tailPivot);
    this.tailPivot = tailPivot;

    // dorsal
    const dorsal = shapeMesh([[-0.30, 0], [-0.16, 0.2], [0.04, 0.26], [0.2, 0.14], [0.26, 0.02]],
      this.finMat, 0, -Math.PI / 2, 0);
    dorsal.position.y = s.A * s.h * 0.5;
    grp.add(dorsal);

    // anal
    const anal = shapeMesh([[-0.22, 0], [-0.1, 0.15], [0.06, 0.18], [0.16, 0.05]],
      this.finMat, 0, -Math.PI / 2, 0);
    anal.scale.y = -1;
    anal.position.y = -s.A * s.h * 0.5;
    grp.add(anal);

    // pectorals
    const pPts = [[0, 0], [0.22, 0.11], [0.19, -0.07], [0.06, -0.03]];
    const pL = shapeMesh(pPts, this.finMat, -Math.PI / 2, 0, 0);
    pL.position.set(-s.A * s.w * 0.7, -0.01, s.L * 0.16);
    pL.rotation.y = -0.35;
    const pR = pL.clone();
    pR.position.x = s.A * s.w * 0.7;
    pR.scale.x = -1; pR.rotation.y = 0.35;
    grp.add(pL, pR);
    this.pectL = pL; this.pectR = pR;

    // eyes
    const eyeMat = new THREE.MeshStandardMaterial({ color: 0x0a1416, roughness: 0.18, metalness: 0.2, transparent: true, opacity: 0 });
    const glintMat = new THREE.MeshBasicMaterial({ color: 0xe8fff9, transparent: true, opacity: 0 });
    this.extraMats = [eyeMat, glintMat];
    const ez = s.L * 0.33, ex = s.A * s.w * 0.82, ey = s.A * s.h * 0.28;
    const eg = new THREE.SphereGeometry(s.A * 0.3, 10, 8);
    const gg = new THREE.SphereGeometry(s.A * 0.11, 8, 6);
    [-1, 1].forEach(sgn => {
      const e = new THREE.Mesh(eg, eyeMat); e.position.set(sgn * ex, ey, ez); grp.add(e);
      const gl = new THREE.Mesh(gg, glintMat);
      gl.position.set(sgn * (ex + s.A * 0.14), ey + s.A * 0.12, ez + s.A * 0.14); grp.add(gl);
    });

    grp.scale.setScalar(s.size);
    this.group = grp;
    scene.add(grp);
  }
  reset() {
    const h = this.spec.home;
    this.pos.set(h[0] + (Math.random() - 0.5) * 1.6, h[1] + (Math.random() - 0.5) * 0.8, h[2] - 5.5);
    this.home.copy(this.base);
    this.vel.set(0, 0, 1.1);
    this.appear = 0; this.pressure = 0; this.inPressure = 0;
    this.group.position.copy(this.pos);
    this.group.visible = false;
  }
  update(dt, t, C) {
    if (t < this.delay) return;
    this.appear = Math.min(1, this.appear + dt / 0.42);
    this.group.visible = true;
    const op = this.appear;
    this.bodyMat.opacity = op; this.finMat.opacity = op * 0.78;
    this.extraMats[0].opacity = op; this.extraMats[1].opacity = op;

    const s = this.spec;
    const d = this.tmpD || (this.tmpD = new THREE.Vector3());
    const des = this.tmpS || (this.tmpS = new THREE.Vector3());
    des.set(0, 0, 0);

    // drifting home target
    const hx = this.base.x + Math.sin(t * 0.13 + this.phase) * 1.15;
    const hy = this.base.y + Math.sin(t * 0.17 + this.phase * 1.3) * 0.42;
    const hz = this.base.z + Math.cos(t * 0.11 + this.phase) * 0.95;
    d.set(hx - this.pos.x, hy - this.pos.y, hz - this.pos.z);
    const dh = d.length();
    if (dh > 1e-4) des.addScaledVector(d.multiplyScalar(1 / dh), Math.min(1, dh / 1.7));

    // wander
    des.x += Math.sin(t * 0.6 + this.phase * 2.1) * 0.3;
    des.y += Math.sin(t * 0.47 + this.phase * 1.7) * 0.18;
    des.z += Math.cos(t * 0.53 + this.phase) * 0.24;

    // separation
    for (let i = 0; i < fishes.length; i++) {
      const o = fishes[i];
      if (o === this) continue;
      const ox = this.pos.x - o.pos.x, oy = this.pos.y - o.pos.y, oz = this.pos.z - o.pos.z;
      const d2 = ox * ox + oy * oy + oz * oz;
      const minD = this.r + o.r;
      if (d2 < minD * minD && d2 > 1e-6) {
        const dd = Math.sqrt(d2), k = ((minD - dd) / minD) * 1.7;
        des.x += (ox / dd) * k; des.y += (oy / dd) * k; des.z += (oz / dd) * k;
      }
    }

    // schooling (chromis)
    if (s.school) {
      const mates = fishes.filter(f => f.spec.school);
      let cx = 0, cy = 0, cz = 0, vx = 0, vy = 0, vz = 0;
      mates.forEach(m => { cx += m.pos.x; cy += m.pos.y; cz += m.pos.z; vx += m.vel.x; vy += m.vel.y; vz += m.vel.z; });
      const n = mates.length || 1;
      cx /= n; cy /= n; cz /= n; vx /= n; vy /= n; vz /= n;
      const boost = 1 + C.cohesion * 1.1;
      d.set(cx - this.pos.x, cy - this.pos.y, cz - this.pos.z);
      const dc = d.length();
      if (dc > 1e-4) des.addScaledVector(d.multiplyScalar(1 / dc), 0.55 * boost * Math.min(1, dc / 2.2));
      const vl = Math.hypot(vx, vy, vz);
      if (vl > 1e-4) des.addScaledVector(new THREE.Vector3(vx, vy, vz).multiplyScalar(1 / vl), 0.5);
    }

    // bounds
    const m = 0.85;
    if (this.pos.x > B.x - m) des.x -= (this.pos.x - (B.x - m)) * 2.4;
    if (this.pos.x < -B.x + m) des.x -= (this.pos.x + (-B.x + m)) * 2.4;
    if (this.pos.y > B.ymax - m) des.y -= (this.pos.y - (B.ymax - m)) * 2.6;
    if (this.pos.y < B.ymin + m) des.y -= (this.pos.y - (B.ymin + m)) * 3.2;
    if (this.pos.z > B.zmax - m) des.z -= (this.pos.z - (B.zmax - m)) * 2.4;
    if (this.pos.z < B.zmin + m) des.z -= (this.pos.z - (B.zmin + m)) * 2.4;

    // pointer pressure (the signature interaction)
    let env = 0, dist = 99;
    if (C.ray) {
      const o = C.ray.origin, dir = C.ray.direction;
      const vx = this.pos.x - o.x, vy = this.pos.y - o.y, vz = this.pos.z - o.z;
      const tt = vx * dir.x + vy * dir.y + vz * dir.z;
      const px = o.x + dir.x * tt, py = o.y + dir.y * tt, pz = o.z + dir.z * tt;
      const dx = this.pos.x - px, dy = this.pos.y - py, dz = this.pos.z - pz;
      dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
      if (dist < 1.45 && !C.hold) {
        this.inPressure += dt;
        if (this.inPressure > s.react) {
          env = Math.min(1, (this.inPressure - s.react) / 0.12);
        }
      } else {
        this.inPressure = 0;
        env = 0;
      }
    } else { this.inPressure = 0; }

    // decay of the eased escape envelope (680 ms)
    if (env > this.pressure) this.pressure += (env - this.pressure) * Math.min(1, dt / 0.12);
    else this.pressure += (env - this.pressure) * Math.min(1, dt / 0.68);
    if (this.pressure < 0.002) this.pressure = 0;

    if (this.pressure > 0.01 && dist < 1.45 && C.ray) {
      const o = C.ray.origin, dir = C.ray.direction;
      const vx = this.pos.x - o.x, vy = this.pos.y - o.y, vz = this.pos.z - o.z;
      const tt = vx * dir.x + vy * dir.y + vz * dir.z;
      const dx = this.pos.x - (o.x + dir.x * tt);
      const dy = (this.pos.y - (o.y + dir.y * tt)) * 0.62;
      const dz = this.pos.z - (o.z + dir.z * tt);
      const dl = Math.hypot(dx, dy, dz) || 1;
      const strength = Math.pow(Math.max(0, (1.45 - dist) / 1.45), 0.8) * 2.4 * s.flee * this.pressure;
      des.x += (dx / dl) * strength;
      des.y += (dy / dl) * strength + 0.18 * strength;
      des.z += (dz / dl) * strength - dir.z * strength * 0.25;
    }

    // clamp desired
    const dl2 = des.length();
    if (dl2 > 1.4) des.multiplyScalar(1.4 / dl2);

    const speedMul = (1 + this.pressure * 0.4) * C.speedScale;
    const maxSpeed = Math.min(3.2, s.speed * speedMul);
    const targetVel = des.multiplyScalar(maxSpeed);
    const steer = targetVel.sub(this.vel);
    const sl = steer.length();
    if (sl > s.force) steer.multiplyScalar(s.force / sl);

    // integrate with hard yaw-rate cap
    const sp0 = this.vel.length();
    if (sp0 > 0.02) {
      const d0 = (this.tmpA || (this.tmpA = new THREE.Vector3())).copy(this.vel).multiplyScalar(1 / sp0);
      this.vel.addScaledVector(steer, dt);
      const sp1 = this.vel.length();
      if (sp1 > 0.02) {
        const d1 = (this.tmpB || (this.tmpB = new THREE.Vector3())).copy(this.vel).multiplyScalar(1 / sp1);
        const ang = d0.angleTo(d1);
        const maxA = s.turn * dt;
        if (ang > maxA) {
          const ax = (this.tmpC || (this.tmpC = new THREE.Vector3())).crossVectors(d1, d0);
          if (ax.lengthSq() > 1e-9) {
            ax.normalize();
            const q = (this.tmpQ || (this.tmpQ = new THREE.Quaternion())).setFromAxisAngle(ax, ang - maxA);
            d1.applyQuaternion(q);
            this.vel.copy(d1).multiplyScalar(sp1);
          }
        }
      }
    } else {
      this.vel.addScaledVector(steer, dt);
    }
    if (this.vel.length() > maxSpeed) this.vel.setLength(maxSpeed);
    if (C.hold) this.vel.multiplyScalar(Math.max(0, 1 - dt * 3.4));

    this.pos.addScaledVector(this.vel, dt);
    this.pos.x = Math.max(-B.x - 0.6, Math.min(B.x + 0.6, this.pos.x));
    this.pos.y = Math.max(B.ymin - 0.6, Math.min(B.ymax + 0.6, this.pos.y));
    this.pos.z = Math.max(B.zmin - 0.6, Math.min(B.zmax + 0.6, this.pos.z));

    // orient
    const spd = this.vel.length();
    if (spd > 0.05) {
      const ahead = (this.tmpP || (this.tmpP = new THREE.Vector3())).copy(this.pos).add(this.vel);
      dummy.position.copy(this.pos);
      dummy.lookAt(ahead);
      if (C.snap) this.group.quaternion.copy(dummy.quaternion);
      else this.group.quaternion.slerp(dummy.quaternion, 1 - Math.exp(-dt * 7));
    }
    this.group.position.copy(this.pos);

    // swimming animation
    const swim = 0.45 + Math.min(1.4, spd / 1.6);
    this.tailPivot.rotation.y = Math.sin(t * (s.tailFreq * (0.6 + swim * 0.4)) + this.phase) * (0.16 + swim * 0.12);
    const flap = Math.sin(t * 6.2 + this.phase) * 0.28;
    this.pectL.rotation.z = flap; this.pectR.rotation.z = -flap;
  }
}
const dummy = new THREE.Object3D();

// spawn
SPECS.forEach((s, i) => {
  if (s.key === 'chromis') {
    const offs = [[0, 0, 0], [1.0, -0.45, -0.6], [-0.85, 0.4, 0.55]];
    offs.forEach((o, k) => {
      const f = new Fish(s, fishes.length);
      f.base.set(s.home[0] + o[0], s.home[1] + o[1], s.home[2] + o[2]);
      f.home.copy(f.base);
      fishes.push(f);
    });
  } else {
    const f = new Fish(s, fishes.length);
    fishes.push(f);
  }
});
fishes.forEach(f => { f.pos.z -= 4.5; });

/* ============================================================
   5. STATE + INPUT
   ============================================================ */
const ptr = { x: 0, y: 0, demo: true, seen: false };
const state = {
  paused: false, hold: false, holdUntil: 0, cohesion: 0,
  activeIdx: 0, lastActive: 0, nearFish: null, intro: 0, started: false
};
const raycaster = new THREE.Raycaster();
const ray = new THREE.Ray();

const startEl = $('#start');
function begin() {
  if (state.started) return;
  state.started = true;
  startEl.classList.add('gone');
  setTimeout(() => startEl.style.display = 'none', 600);
  $('#srStatus').textContent = 'Aquarium interactive. Move the pointer near the glass.';
}
window.addEventListener('pointermove', (e) => {
  if (e.pointerType === 'mouse' || e.pointerType === 'pen') ptr.demo = false;
  ptr.x = (e.clientX / vw()) * 2 - 1;
  ptr.y = -((e.clientY / vh()) * 2 - 1);
  if (!ptr.seen) { ptr.seen = true; begin(); }
  const p = $('#ipulse');
  p.classList.add('hot');
  clearTimeout(p._t); p._t = setTimeout(() => p.classList.remove('hot'), 500);
}, { passive: true });

window.addEventListener('pointerdown', (e) => {
  begin(); ptr.demo = false;
  state.hold = true;
  state._downX = e.clientX; state._downY = e.clientY; state._drag = 0;
});
window.addEventListener('pointermove', (e) => {
  if (state.hold) {
    const dx = e.clientX - (state._downX || e.clientX), dy = e.clientY - (state._downY || e.clientY);
    state._drag = Math.hypot(dx, dy);
    if (state._drag > 14) state.cohesion = Math.min(1, state.cohesion + 0.08);
    state._downX = e.clientX; state._downY = e.clientY;
  }
}, { passive: true });
window.addEventListener('pointerup', () => {
  state.hold = false;
  state.holdUntil = performance.now() + 600;
});
window.addEventListener('pointercancel', () => { state.hold = false; state.holdUntil = performance.now() + 600; });

window.addEventListener('keydown', (e) => {
  begin();
  if (e.code === 'Space') { e.preventDefault(); togglePause(); }
  else if (e.key === 'r' || e.key === 'R') resetTank();
  else if (e.key === 'm' || e.key === 'M') toggleSound();
  else if (e.key === 'Escape' && document.activeElement && document.activeElement.blur) document.activeElement.blur();
});
window.addEventListener('click', begin, { once: false });

function resetTank() {
  fishes.forEach(f => f.reset());
  state.cohesion = 0;
  $('#srStatus').textContent = 'Tank reset.';
}
function togglePause() {
  state.paused = !state.paused;
  const b = $('#btnPause');
  b.classList.toggle('playing', state.paused);
  b.setAttribute('aria-label', state.paused ? 'Resume simulation' : 'Pause simulation');
  $('#r1').textContent = state.paused ? 'Paused' : 'Ready';
  $('#r2').textContent = state.paused ? 'Simulation Held' : 'Local Simulation';
  $('#dot').classList.toggle('off', state.paused);
  $('#srStatus').textContent = state.paused ? 'Simulation paused.' : 'Simulation running.';
}
$('#btnPause').addEventListener('click', () => { begin(); togglePause(); });

/* ambient sound (opt-in) */
let actx = null, master = null, soundOn = false, bubbleTimer = null;
function toggleSound() {
  try {
    if (!actx) {
      const AC = window.AudioContext || window.webkitAudioContext;
      if (!AC) return;
      actx = new AC();
      master = actx.createGain(); master.gain.value = 0; master.connect(actx.destination);
      const len = actx.sampleRate * 4;
      const buf = actx.createBuffer(1, len, actx.sampleRate);
      const ch = buf.getChannelData(0);
      let last = 0;
      for (let i = 0; i < len; i++) { const w = Math.random() * 2 - 1; last = (last + 0.02 * w) / 1.02; ch[i] = last * 3.2; }
      const src = actx.createBufferSource(); src.buffer = buf; src.loop = true;
      const lp = actx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 480;
      const lfo = actx.createOscillator(); lfo.frequency.value = 0.07;
      const lfoG = actx.createGain(); lfoG.gain.value = 160;
      lfo.connect(lfoG); lfoG.connect(lp.frequency); lfo.start();
      src.connect(lp); lp.connect(master); src.start();
    }
    if (actx.state === 'suspended') actx.resume();
    soundOn = !soundOn;
    master.gain.cancelScheduledValues(actx.currentTime);
    master.gain.linearRampToValueAtTime(soundOn ? 0.32 : 0, actx.currentTime + 0.5);
    $('#btnSound').classList.toggle('playing', !soundOn);
    $('#btnSound').setAttribute('aria-label', soundOn ? 'Mute ambient sound' : 'Play ambient sound');
    clearInterval(bubbleTimer);
    if (soundOn) {
      bubbleTimer = setInterval(() => {
        if (!soundOn || !actx) return;
        const o = actx.createOscillator(), g = actx.createGain();
        o.type = 'sine';
        const t0 = actx.currentTime;
        o.frequency.setValueAtTime(320 + Math.random() * 220, t0);
        o.frequency.exponentialRampToValueAtTime(780 + Math.random() * 400, t0 + 0.12);
        g.gain.setValueAtTime(0.0001, t0);
        g.gain.exponentialRampToValueAtTime(0.05, t0 + 0.02);
        g.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.16);
        o.connect(g); g.connect(master); o.start(t0); o.stop(t0 + 0.2);
      }, 1600 + Math.random() * 1800);
    }
  } catch (err) { /* audio optional */ }
}
$('#btnSound').addEventListener('click', () => { begin(); toggleSound(); });

/* ============================================================
   6. HUD
   ============================================================ */
const plaques = Array.from(document.querySelectorAll('.plaque'));
const bars = Array.from(document.querySelectorAll('#chart i'));
const el = {
  behav: $('#vBehav'), resp: $('#vResp'), temp: $('#vTemp'), ph: $('#vPh'),
  glass: $('#vGlass'), coords: $('#vCoords'), railNear: $('#railNear'),
  mIdx: $('#mchipIdx'), mName: $('#mchipName'), mTemp: $('#mTemp')
};
const fmt = v => (v >= 0 ? '+' : '') + v.toFixed(2);
let hudAcc = 0;

function updateHUD(dt, t) {
  hudAcc += dt;
  if (hudAcc < 0.24) return;
  hudAcc = 0;

  let near = 0;
  fishes.forEach((f, i) => {
    const d = FRONT_GLASS - f.pos.z;
    if (d < 2.4) near++;
    if (i < bars.length) {
      const k = Math.max(0, Math.min(3.75, d));
      bars[i].style.height = (8 + (1 - k / 3.75) * 88).toFixed(1) + '%';
    }
  });
  el.glass.textContent = near + ' / ' + fishes.length;
  const temp = (25.4 + Math.sin(t * 0.12) * 0.12).toFixed(1) + '°C';
  el.temp.textContent = temp;
  if (el.mTemp) el.mTemp.textContent = temp;
  el.ph.textContent = (8.1 + Math.sin(t * 0.07) * 0.03).toFixed(1);
  el.coords.textContent = 'X ' + fmt(ptr.x) + ' / Y ' + fmt(ptr.y);

  // active species = fish nearest to the pointer ray
  let best = null, bd = 1.7;
  if (state.nearFish) { best = state.nearFish; bd = state.nearDist || 0; }
  if (best) {
    state.activeIdx = best.si;
    el.behav.textContent = best.spec.behav;
    el.resp.textContent = (best.spec.react + 0.32 + Math.abs(Math.sin(t)) * 0.08).toFixed(2) + ' s';
    const m = Math.max(0, bd) / 1.45;
    el.railNear.textContent = m.toFixed(2) + ' m';
  } else {
    el.behav.textContent = SPECS[state.activeIdx].behav;
    el.resp.textContent = (SPECS[state.activeIdx].react + 0.42).toFixed(2) + ' s';
    el.railNear.textContent = '—';
  }
  plaques.forEach((p, i) => {
    p.classList.toggle('active', i === state.activeIdx);
    p.classList.toggle('near', !!best && i === best.si);
  });
  if (el.mIdx) {
    el.mIdx.textContent = String(state.activeIdx + 1).padStart(2, '0');
    el.mName.textContent = SPECS[state.activeIdx].name;
  }
}

/* leader line from the plaque to the pressured fish */
const leadG = $('#leadG'), leadL = $('#leadL'), leadD = $('#leadD'), leadT = $('#leadT');
function updateLeader() {
  const f = state.nearFish;
  if (!f || f.pressure < 0.05 || vw() <= 1024) { leadG.classList.add('off'); return; }
  const pl = plaques[f.si];
  if (!pl) { leadG.classList.add('off'); return; }
  const r = pl.getBoundingClientRect();
  if (r.width === 0) { leadG.classList.add('off'); return; }
  const sx = r.right + 4, sy = r.top + 14;
  const p = f.pos.clone().project(camera);
  if (p.z > 1) { leadG.classList.add('off'); return; }
  const fx = (p.x * 0.5 + 0.5) * vw();
  const fy = (-p.y * 0.5 + 0.5) * vh();
  if (fx < 0 || fx > vw() || fy < 0 || fy > vh()) { leadG.classList.add('off'); return; }
  leadG.classList.remove('off');
  leadL.setAttribute('x1', sx); leadL.setAttribute('y1', sy);
  leadL.setAttribute('x2', fx); leadL.setAttribute('y2', fy);
  leadD.setAttribute('points',
    fx + ',' + (fy - 4) + ' ' + (fx + 4) + ',' + fy + ' ' + fx + ',' + (fy + 4) + ' ' + (fx - 4) + ',' + fy);
  const mx = (sx + fx) / 2, my = (sy + fy) / 2 - 7;
  leadT.setAttribute('x', mx); leadT.setAttribute('y', my);
  leadT.textContent = ((state.nearDist || 0) / 1.45).toFixed(2) + ' m';
}

/* ============================================================
   7. DEMO POINTER + LOOP
   ============================================================ */
const clock = new THREE.Clock();
let simTime = 0, rayDirty = 0;

function demoPath(t) {
  ptr.x = Math.sin(t * 0.5) * 0.74 + Math.sin(t * 0.17) * 0.1;
  ptr.y = 0.1 + Math.sin(t * 0.36 + 0.6) * 0.3;
}

function animate() {
  requestAnimationFrame(animate);
  const dtReal = Math.min(clock.getDelta(), 0.05);
  const speedScale = RM ? 0.4 : (state.paused ? 0 : 1);
  const dt = dtReal * speedScale;
  if (!state.paused) simTime += dtReal * speedScale;

  // intro camera dive
  if (state.intro < 1) {
    state.intro = Math.min(1, state.intro + dtReal / (RM ? 0.18 : 1.8));
    const e = 1 - Math.pow(1 - state.intro, 4);
    camera.position.z = 13.8 + (11.6 - 13.8) * e;
  }

  if (ptr.demo) demoPath(simTime);

  // hold release window
  const holding = state.hold || performance.now() < state.holdUntil;
  state.cohesion = Math.max(0, state.cohesion - dtReal * 0.7);

  // pointer ray
  raycaster.setFromCamera(new THREE.Vector2(ptr.x, ptr.y), camera);
  ray.copy(raycaster.ray);

  // camera parallax
  const tx = ptr.x * 0.65, ty = 0.6 + ptr.y * 0.45;
  const k = 1 - Math.exp(-dtReal * 3);
  camera.position.x += (tx - camera.position.x) * k;
  camera.position.y += (ty - camera.position.y) * k;
  camera.lookAt(0, 0.05, 0);

  // nearest / pressured fish
  let nearest = null, nd = 99;
  for (const f of fishes) {
    const o = ray.origin, dir = ray.direction;
    const vx = f.pos.x - o.x, vy = f.pos.y - o.y, vz = f.pos.z - o.z;
    const tt = vx * dir.x + vy * dir.y + vz * dir.z;
    const dx = f.pos.x - (o.x + dir.x * tt);
    const dy = f.pos.y - (o.y + dir.y * tt);
    const dz = f.pos.z - (o.z + dir.z * tt);
    const d = Math.sqrt(dx * dx + dy * dy + dz * dz);
    if (d < nd) { nd = d; nearest = f; }
  }
  state.nearFish = (nd < 1.45 && !holding) ? nearest : null;
  state.nearDist = nd;

  // fish step
  const C = { ray, hold: holding, cohesion: state.cohesion, speedScale: RM ? 0.85 : 1, snap: RM };
  if (!state.paused) fishes.forEach(f => f.update(dt, simTime, C));
  else fishes.forEach(f => { f.group.position.copy(f.pos); });

  // ambient motion
  const t = simTime;
  causticWall.material.map.offset.set(Math.sin(t * 0.045) * 0.2, Math.cos(t * 0.037) * 0.2);
  sandCaustic.material.map.offset.set(Math.cos(t * 0.032) * 0.16, Math.sin(t * 0.041) * 0.16);
  surface.material.map.offset.set(t * 0.012, t * 0.009);
  causticWall.material.opacity = 0.1 + Math.sin(t * 0.5) * 0.03;

  if (!state.paused) {
    plants.forEach((p, i) => {
      p.rotation.z = Math.sin(t * 0.7 + p.userData.ph) * 0.14;
      p.rotation.x = Math.cos(t * 0.5 + p.userData.ph) * 0.07;
    });
    const mp = motes.geometry.attributes.position;
    for (let i = 0; i < mp.count; i++) {
      let y = mp.getY(i) + dt * 0.055;
      let x = mp.getX(i) + Math.sin(t * 0.3 + i) * dt * 0.04;
      if (y > 4.6) y = -2;
      mp.setY(i, y); mp.setX(i, x);
    }
    mp.needsUpdate = true;
    const bp = bubbles.geometry.attributes.position;
    for (let i = 0; i < bp.count; i++) {
      let y = bp.getY(i) + dt * (0.34 + (i % 5) * 0.05);
      let x = bp.getX(i) + Math.sin(t * 1.6 + i) * dt * 0.12;
      if (y > 4.5) { y = -1.9; x = (Math.random() - 0.5) * 18; }
      bp.setY(i, y); bp.setX(i, x);
    }
    bp.needsUpdate = true;
  }

  // HUD
  const prism = $('#prism');
  if (state.nearFish) prism.classList.add('hot'); else prism.classList.remove('hot');
  updateHUD(dtReal, t);
  updateLeader();

  if (composer) composer.render(); else renderer.render(scene, camera);
}

/* ============================================================
   8. RESIZE
   ============================================================ */
function resize() {
  W = vw(); H = vh();
  camera.aspect = W / H;
  camera.updateProjectionMatrix();
  renderer.setSize(W, H, false);
  if (composer) composer.setSize(W, H);
}
window.addEventListener('resize', resize);
window.addEventListener('orientationchange', () => setTimeout(resize, 120));
resize();

$('#srStatus').textContent = 'Aquarium ready. Six fish across four species.';
animate();

// safety: never leave the intro prompt up forever
setTimeout(() => { if (!state.started) begin(); }, 11000);
</script>
</body>
</html>
Reasoning trace (35,455 tokens)
Let me build a Three.js aquarium where fish flee from the mouse, self-playing demo first, with quality materials, lighting, and robust interaction.

**Who this is for and what it must feel like.** The brief is a single-screen interactive: a 3D aquarium where fish flee from the pointer. That is a toy, not a document — so the design job is not to wrap it in a website, it is to *frame* it. My read: the visitor should feel like they've walked up to a large museum window at night. The water is the page. Every piece of interface behaves like engraved brass plaques and anodized aluminium hardware bolted onto the glass — never like floating web cards. Physical, archival, restrained. If the chrome ever competes with the fish for attention, I've failed.

The behavioural promise is the whole product: the fish notice the visitor. That means two consequences I'll design around. First, there is no hero headline — the aquarium *is* the hero, full-bleed, edge to edge, immediately on first paint. Second, the interface must be quiet enough that the ripple of a turning school is the only thing moving that matters.

**Palette.** I'm building from deep water, instrument enamel, oxidised brass, coral accent. Canvas is `#06151a` — a near-black with green in it, so the void below the tank reads as water rather than as an unlit page. Below the substrate line I drop to `#030b0e` so the sand zone is perceptibly heavier than the water column. Surface blue `#0d6f78` is my wet highlight: ripple crests, secondary fish accents, and — critically — the *keyboard focus state*. That last one matters: brass is reserved for pointer-caused proximity, so keyboard focus cannot borrow it. Glass reflections are `rgba(205,246,240,0.13)` only, never a solid fill.

Brass `#d9b66f` is my voltage, and I'm disciplined about it: active glass prisms, species cues, measured values, focus/hover indicators. Never a large fill, never body copy, never a page background. Fish are the *only* saturated bodies in the composition — coral `#ff6f61` on the royal gramma's two-tone body, chartreuse `#b9d46a` on the chromis, ivory `#fff4dc` and charcoal `#182326` on the tang's banding. Those colours appear inside the WebGL scene, nowhere else. Structural strokes are `rgba(183,225,218,0.24)`, hot-plate borders `rgba(217,182,111,0.42)`. Ink runs `#edf7f2` primary, `#91aaa5` secondary, `#5f7773` for measurement ticks and inactive states.

I need to verify the muted ink over bright caustics — so I'm putting a single legibility grade in the top HUD: `linear-gradient(180deg, rgba(3,11,14,0.72) 0%, rgba(3,11,14,0) 100%)`, 72px max. Same at the bottom for the identity rail. Never a uniform black wash over the scene — that would kill the caustics.

**Typography.** I'll use two families only. Condensed museum signage for the wordmark and species names: Barlow Condensed, 700 at 34px for `GLASSHOUSE / LIVE TANK 04`, 600 at 16px for `ROYAL GRAMMA`, 600 at 11px/`0.16em` for section labels. Everything else — instructions, coordinates, measurements — is IBM Plex Mono, because monospace reads as *instrument readout* rather than as prose: 12px/1.6 for technical copy, 10px/`0.12em` for HUD labels and coordinates, 10px/600 for measurement ticks, 14px/600 for the readiness dot. Display is reserved for the title alone. No large type inside control plates.

**Layout system.** Three stacked layers: the WebGL scene at `inset:0` and full viewport; a hairline tank architecture overlay — six vertical frame lines at 8%, 25%, 50%, 75%, 92% of width plus a horizon rule at 68% of height, `1px`, brass at 18% — which makes the browser window feel like one enormous pane rather than a web layout; and the HUD, pointer-events *selectively* enabled so a press plate never blocks the field.

Zones: top HUD 88px, bottom 72px, left identification rail 300px wide starting 104px down, right instrument rail 164px wide ending 104px from the bottom, both inset 40px. Deliberate break: a 1px brass measurement rule at `top:76px` runs from 32px past the left rail and terminates 56px short of the right rail — interrupted, so it reads as a sightline crossing the glass rather than a navbar.

Then the spatial idea I actually care about: **proximity as interface.** The vertical centre axis is a refractive glass prism, 180px wide at the centre, 34px at the quarter points, fully invisible at the edges. Near its edges are specimen indices `01`–`04` and faint horizontal leader lines pointing into the tank. When a fish is pressured, its plaque draws a 1px brass leader line from the rail to the fish's actual projected screen position, with a 4px brass diamond at the endpoint and a small measured label (`0.7 m`).

Responsive: at 1024px the rails become 180px, indices hide, at most one leader survives. At 720px the plate leaves entirely, the wordmark drops to a bottom-left strip, a 40px species chip top-right, the instrument plate collapses to two bottom pills, and the horizon moves to 74%. At 420px labels go to 10px and `GLASSHOUSE` stands alone — but touch proximity stays fully alive.

**Motion — "Pressure & Drift".** Ambient is continuous and low-amplitude; interaction is a local pressure wave. Initial dive: camera eases from `z:13.8` to `11.6` over 1800ms on `cubic-bezier(0.22,1,0.36,1)` while the frame draws in over 900ms — but the simulation is running from frame one, so fish are already swimming during the reveal. Entrance stagger at 90/160/240/320/380ms with 420ms durations.

The flee response, which is the whole point: fish within 1.45 world units of the pointer ray get an acceleration of `clamp((1.45 - d) / 1.45) * 7.5` pointing away from the ray, applied over 120ms, eased out over 680ms, velocity capped at 3.2 units/s, hard yaw rate capped at 4.8 rad/s so nothing ever snaps. Separation keeps the school honest. Chromis panic first with a 40ms delay and a tighter corridor; the tang curves broadly, turning away rather than darting; the goby holds the substrate and only scoots laterally.

I won't let the pointer hand control to a scripted path — it must be a real force field, not a canned animation, because the fish have to look like they're deciding. Idle re-establishment after 900ms: 1400ms hold, then a loose school reforms over 2200ms, no aquarium-wide reset.

Prism is a 240×120 canvas, blurred and duplicated at 14% either side, with a 1px brass caustic line sweeping once every 650ms.

Reduced motion: simulation keeps running — it's the function — but at 40% speed, camera dive becomes a 180ms fade, prism repeating sweep stops, fish teleport-align instead of easing, no HTML hover translation.

**Sections, in order.**

*Top HUD* — wordmark lockup top-left with a 48px brass rule under it; readiness cluster top-right with an 8px live dot (emerald pulse only when initialised, `#58d6a0` otherwise) and `LOCAL SIMULATION`. Max 72px tall, opacity graded beneath.

*Left rail* — an index, not a paragraph: active species name, habitat note, depth, then four compact plaques. Active gets a brass left rule, brighter ink, an 8px notch on the leader origin. Switching is an 80/120/180ms opacity and width transition — no sliding, no carousel.

*Right rail* — three anodised plates: BEHAVIOUR (`NEUTRAL` / `PEACEFUL` / `SHY`), WATER (`25.4°C`, `pH 8.1`, `FLOW GENTLE`), and a local chart of fish near the glass: five ticks scaling 0→3.75 units on a `rgba(183,225,218,0.18)` baseline with brass bars. Real data from the sim, updated every 240ms, never fake telemetry.

*Prism* — two edges, 14% fill, 1px brass sweep, label `PRESSURE ZONE` in ink-muted, live `X -0.34 / Y +0.08` beneath, hidden entirely below 720px.

*Bottom rail* — instruction left with a brass indicator that pulses 45%→100% on pointer move and settles over 500ms, one compact hint `Mouse near glass · click to hold · drag to steer`, two hairline pill controls right (pause, sound).

*Identities* — royal gramma, ocellaris clownfish, blue-green chromis ×3, yellow tang. Built from merged lathed and beveled geometry with separate fin meshes so bodies stay cheap; scales run through one shared offset texture; fins share one normal map. Substrate of 180 instanced meshes, a caustic light layer at 0.065, a bloom pass at 0.18.

**Voice.** Observation log, not marketing. "Move the mouse near the glass." Not "immersive aquatic experience." Data appears only after readiness. Errors are compact: `RESTARTING GLASSHOUSE` / `DISPLAY CONNECTION RESTORED`.

Let me plan the build carefully.

Structure:
- importmap script
- HTML with canvas #tank, overlay svg for tank architecture lines, HUD elements.
- Three.js scene: fish geometries, procedural textures via canvas (caustics, scale, fin normal, sand displacement), caustic light plane, substrate instanced meshes, particles, glass prism DOM.
- HUD: top wordmark, readiness status; left species rail with 4 plaques; right instruments (behaviour, water, chart); bottom instruction + controls (pause, sound); pressure zone prism center.
- Leader lines from species plaque to nearest fish — I'll draw an SVG overlay layer with lines computed from projected fish positions. I can do leader lines on species proximity. I'll implement: active species (nearest fish to pointer) gets a leader line from its plaque notch to the projected fish screen position, plus measured distance label. Keep it simple and stable.
- Fish behaviors: steering, flee from pointer ray projected into tank plane (z≈0 plane), school for chromis, goby near substrate.
- Demo mode: "Click or press a key to start" overlay initially? A game-like requirement says game should start in self-playing demo mode behind a "Click or press a key to start" message. For scene, first screenshot at ~3s must show aquarium. So: render scene immediately with autonomous demo (fish swimming, camera intro), and an instruction overlay "Click or press a key to start" that fades on interaction. Actually to be safe: scene runs immediately in demo mode (AI moves pointer autonomously → fish flee, showing the key feature in screenshot), and there's an overlay hint "Click or press a key to start" which is non-blocking. Hmm, the rule: "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." I'll include an overlay prompt that is dismissible by click/keypress, but the demo runs behind it — meaning fish flee from an autonomous demo pointer. Actually better: run autonomous demo pointer so the feature is visible at 3s. Overlay message "Click or press a key to start" sits centered bottom-ish with low opacity? If it covers things... I'll make it a centered pill that fades out on any pointer move/key/click but auto-fades after 6s too. Hmm, careful — screenshot at 3s, so it'd still be visible. That's fine, it's a small pill and indicates interactivity. But it might obscure. I'll place it bottom-center above instruction bar, compact. Actually the bottom already has instruction "MOVE THE MOUSE NEAR THE GLASS". Let me use an overlay: a subtle vignette + centered pill "CLICK OR PRESS A KEY TO START". It's fine and communicates interactivity. It fades on click/keypress/mousemove... if it fades on mousemove then at screenshot time with demo pointer it won't fade since demo pointer isn't a real mousemove. Good.

Hmm but wait: should the demo be autonomous? Rule says games must start in self-playing demo mode. This is a scene/interaction, so yes — autonomous demo: synthetic pointer moves across the tank in a wandering path, fish flee. Real mouse overrides.

Let me write the code.

Three.js setup:

```js
import * as THREE from 'three';
```
Postprocessing? Could use EffectComposer + UnrealBloomPass from addons for caustics glow. Keep light: maybe just use plain renderer to avoid risk. Bloom would help "atmospheric". I'll add UnrealBloomPass with low strength 0.25 — but performance and correctness risk. Addons are allowed via importmap. I'll use EffectComposer with RenderPass + UnrealBloomPass (strength 0.35, radius 0.6, threshold 0.75). Risk: bright fish over-bloom. Threshold 0.8, strength 0.3. Fine. Actually output pass needed for correct color: use OutputPass. Let's include: EffectComposer, RenderPass, UnrealBloomPass, OutputPass. That's standard.

Hmm, but risk of error. These are all in examples/jsm/postprocessing/. Fine.

Also renderer.outputColorSpace default SRGB; with composer, OutputPass handles tone mapping/color space. Set renderer.toneMapping = ACESFilmic, exposure 1.05.

Fish geometry: procedural. Build a fish as a group:
- Body: use LatheGeometry? A fish body is better as a scaled sphere with tapering. I'll build body from a custom lathe-ish approach: use THREE.SphereGeometry(0.5, 24, 16) then modify vertices: scale x by length, taper towards tail via function of x, flatten y a bit. Actually simpler: create a shape via LatheGeometry of a profile curve rotated around X axis (lathe is around Y). LatheGeometry profile: points (radius, y) with y from head to tail; then rotate the mesh so axis is along X (rotateZ(-PI/2)). Profile: r(y) = sin-like: r = 0.5 * sqrt(1-((y-center)/half)^2)-ish with different head/tail. Use a CatmullRomCurve? Easier: generate points with function:

for i in 0..N: t = i/N; y = -L/2 + t*L; r = A * pow(sin(pi*t), 0.65) * (1 - 0.25*t) ... head rounded, tail narrow. At t=0 (nose) r=0 → pointy nose; want rounded nose: use r = A*pow(sin(pi*t*0.5)... hmm.

Let's do: r(t) = A * Math.pow(Math.sin(Math.PI * t), 0.7) — zero at both ends, max at middle. Head should be at t=0 blunt-ish: pow with exponent <1 gives steep rise → blunt. Good. Then multiply by (1 - 0.35*t) to make tail thinner than head, shifting max forward. Then flatten vertically: scale y-axis? Lathe around axis gives circular cross-section; fish are laterally flattened. So after lathe, scale the geometry in the "z" direction (perpendicular) by 0.55 — but scaling lathe output: after rotating so axis along X, cross-section is in Y-Z plane; scale Z by 0.5 (width) and Y stays as height. Actually fish: tall (dorsal-ventral) and narrow (left-right). So scale the cross-section: Y (height) keep, Z (width) * 0.5. Good.

- Tail fin: a triangle-ish plane with transparency? Use a Shape → ShapeGeometry with double side, fin material transparent, alphaMap or just opacity + emissive slight. Use shape points forming a forked tail.
- Dorsal fin: shape on top, rotated.
- Pectoral fins: small shapes.
- Eyes: small spheres black with white highlight.

Fish materials: MeshStandardMaterial with map (procedural scale texture), roughness 0.55, metalness 0.0, and slight emissive from color? Use `emissive` small for visibility.

Materials per species:
- Royal gramma: purple-to-yellow gradient → vertex colors on lathe body (color by x position: head yellow #f5c542... actually royal gramma: front purple, back yellow). Use vertexColors with lerp along body axis.
- Clownfish: orange #ff7a2f body + white bands: use a canvas texture with bands mapped along body axis. Texture: draw vertical bands. Map UV: lathe UVs — u along profile? LatheGeometry UV: u = around the rotation, v = along profile. So bands along body = horizontal lines in v. Draw canvas with horizontal stripes.
- Chromis: pale chartreuse/green shimmer.
- Yellow tang: yellow body #ffd23f, disc shape, whiteish.

Simplify: generate a canvas texture per species with pattern (bands for clownfish, subtle scale noise for others, gradient for gramma), plus a scale texture used as bumpMap-ish (use `bumpMap`? or roughnessMap). Use generated scale texture as `bumpMap` with small bumpScale, and species texture as `map`. Good enough.

Animation: swim wiggle — per-fish: deform body via vertex shader? Simpler: bend the whole fish group: apply sinusoidal rotation to tail group. I'll structure fish as group: bodyGroup (body + head + dorsal + eyes) and tailGroup (tail fin + rear part). Rotating tail around Y (yaw) gives a swimming wiggle. Plus slight body roll. That's cheap and reads well.

Better: keep body whole, and rotate tail group (containing tail fin) around Y with sin(t*freq + phase), amplitude 0.25. And add a slight whole-body yaw oscillation.

Steering: 
```
vel += flee + cohesion/separation/alignment (chromis) + wander + bounds + substrate constraint (goby)
```
Fish orientation: face along velocity: quaternion from direction. Use `mesh.lookAt(pos + vel)` with an object whose forward is +Z? Default lookAt makes -Z face target for cameras; for objects lookAt makes +Z? For Object3D.lookAt, the object's +Z axis points toward target (for non-camera). Actually Object3D.lookAt rotates so that its positive Z axis points at target (cameras look down -Z, handled specially). So if I build fish along +X, I need to align: rotate geometry so fish faces +Z. Let's build fish facing +Z: lathe axis along Z (rotate lathe -PI/2 about X? Lathe axis is Y; rotateX(Math.PI/2) maps +Y → +Z? rotateX(PI/2): y→z? Rotation about X by θ: y' = y cosθ - z sinθ, z' = y sinθ + z cosθ. With θ=PI/2: y'=-z, z'=y. So +Y maps to +Z. Yes rotateX(PI/2) puts profile along Z with head at... profile y from -L/2 (nose at t=0) to +L/2 (tail). After rotation, nose at z=-L/2, tail at +L/2 → fish faces -Z. So use rotateX(-PI/2): y' = y cos + z sin... θ=-PI/2: y'= z... let's compute: y' = y cos(-90) - z sin(-90) = 0 + z; z' = y sin(-90) + z cos(-90) = -y. So +Y → -Z. Nose (y=-L/2) → z=+L/2. Fish faces +Z. Good: rotateX(-Math.PI/2).

Then lookAt(pos + vel) makes +Z point along velocity. 

But we also need banking/roll — skip, minor.

Positions: tank bounds x ∈ [-9,9]? Camera at z=11.6 fov 42 → visible height at z=0: 2*tan(21°)*11.6 ≈ 8.9; width at aspect 1.6 ≈ 14.2. So tank x range ±7 visible. Let's set bounds x ±6.5, y from -2.6 (substrate) to 2.8, z ∈ [-4, 3].

Pointer: raycast from camera through NDC onto plane z = fishZ? Different fish at different z. Better: compute ray, and for each fish compute distance from fish position to the ray (point-line distance). That's exact "distance to pointer ray". Use that.

Flee: direction = fish.position - closestPointOnRay, normalized; strength = clamp((1.45 - d)/1.45).

Demo pointer: animate NDC in a wandering path across screen; when idle > some time... The demo should always be active until user interacts.

Fish count: 1 gramma + 1 clown + 3 chromis + 1 tang + 1 goby = 7? The species rail has 4 entries: gramma, clownfish, chromis school (3), tang. For the underwater particles, 18–42 based on species count... I'll include a goby as a 5th? Species rail lists 4. Keep 7 fish (4 species). Actually adding a goby is nice for substrate but not in the rail. I'll skip goby to stay faithful, but goby behavior is charming. I'll add one goby as substrate-dweller but not in the rail... that could be an inconsistency. Keep 4 species: gramma, clown, chromis×3, tang = 6 fish. That's enough visually. Maybe add 2 more chromis → school of 5? Chromis as a school ×3. Keep 3.

Hmm, 6 fish in a large tank may look sparse. I could add extra clownfish? The reference tank has 4 species. Let's do: gramma ×1, clown ×2? Composition says "one royal gramma, one clownfish, a school of three blue-green chromis, one yellow tang". I'll add a second clownfish? No — keep faithful but add more chromis? "school of three". OK: 6 fish. To fill space, add plants/coral, rocks, bubbles, particles. Fine. Actually I can add 2 more gramma? no.

I'll go with 6 fish + rich environment.

Environment:
- Background: gradient — use a large plane at z=-9 with a canvas gradient texture (deep #06151a top → #030b0e bottom), or scene.background color + fog. Use fog: THREE.FogExp2(0x06151a, 0.045) plus a backdrop plane.
- Water surface: plane at y=3.2 with a caustic-ish animated texture, seen from below? Camera at y=0.6, looking slightly; top of frame ~ y=+4.5 at z=0. Put a water surface plane at y=4.2 rotated to be visible from below with transparency and animated normal. Nice touch. Keep simple: plane with additive caustic texture, opacity 0.25, side: DoubleSide, moving texture offset.
- Caustic light layer: plane behind fish (z=-5) with caustic texture, additive, opacity 0.18? Caustic light layer opacity 0.065, animate offset. Also maybe a light pattern on sand.
- Substrate: plane at y=-3 rotated -PI/2, with displacement via texture? Use PlaneGeometry with segments and vertex noise (sand dunes), MeshStandardMaterial with generated sand texture (color + roughness). Sand begins below 68% viewport → world y such that projected screen y = 68% from top. Camera at y=0.6, z=11.6, fov 42: visible half-height at z=0 is tan(21°)*11.6 = 4.45. Screen 68% from top → NDC y = 1 - 2*0.68 = -0.36 → world y = 0.6 + (-0.36*4.45) = 0.6 - 1.6 = -1.0. Hmm so substrate top edge should be around y=-1.0 at z=0 (far plane). But that's the horizon line at 68%: the boundary where sand starts. With a plane extending toward camera, the near part of the sand is lower on screen. Let's put substrate top at y = -2.2 and rocks near there; the horizon rule at 68% is an overlay line, roughly matching. Let's aim: sand plane at y=-2.4, far edge (z=-6) appears at screen y: world y=-2.4, relative -3.0 from camera → NDC -3.0/4.45 = -0.674 → screen = (1+0.674)/2 = 83.7% from top. Too low. To get the far sand edge at 68%: need NDC -0.36 → at z=-6, half-height = tan(21)*(11.6+6)=7.29 → world y offset = -2.62 → y = 0.6-2.62 = -2.02. So far sand edge y ≈ -2.0 at z=-6 → same plane y=-2.0. Near edge at z=+6: half-height = tan21*5.6=2.15 → offset -0.775 → y=-0.175?? No — a horizontal plane at y=-2.0 at z=+6 would be below the bottom of the screen (bottom of frame at z=6 is y = 0.6-2.15 = -1.55). So the sand plane at y=-2 extends beyond the bottom of the frame near the camera — correct, sand fills the bottom. And the far edge at z=-6, y=-2.0 maps to 83% screen, not 68%. Hmm I mis-derived. Let me recompute: at z=-6, distance from camera = 17.6, half-height = tan(21°)*17.6 = 6.74. World y=-2.0 relative to camera y=0.6 → -2.6. NDC = -2.6/6.74 = -0.386 → screen (1+0.386)/2 = 69.3% from top. 

I made an arithmetic error before (used 7.29 for half-height, then -0.36*7.29=-2.62 → y=0.6-2.62=-2.02 ≈ -2.0, consistent). Then I wrongly computed NDC for y=-2.0 as -0.674 using half-height 4.45 (that's the z=0 distance). OK so y=-2.0 plane far edge at z=-6 gives 69%. Good — substrate plane at y = -2.0, extending z from -7 to +8. The horizon rule at 68% matches the far edge of the sand. 

But actually with fog and depth, the far edge fading is nice.

Camera: y=0.6, z=11.6→ intro from 13.8. lookAt (0, 0.1, 0). Slight tilt down means lookAt y lower than camera. Camera y 0.6, target y 0.0.

Fish y range: -1.6 to 2.2. Substrate at -2.0.

Rocks: icosahedron/dodecahedron flattened, dark grey-green #16262a, near substrate. Coral: branching using cylinders/cones in coral color, small. Keep counts low.

Plants: a few blade shapes swaying (vertex shader or simple rotation). Use PlaneGeometry with alpha? Simpler: cone/lathe blades with slight rotation animation. I'll create 8-10 blade meshes (thin tapered boxes/planes) with MeshStandardMaterial green, animate rotation.z with sin.

Bubbles: 30 sprites rising, small circles with additive material.

Particles: Points with generated circular sprite texture, 180 points, additive, slow drift.

Glass reflections overlay: CSS overlay with two diagonal translucent bars (linear gradients), pointer-events none.

HTML HUD:
- .frame lines via SVG or divs: vertical lines at 8%,25%,50%,75%,92%, horizon 68%. Use absolutely positioned divs with 1px background brass 0.18.
- top-left wordmark: "GLASSHOUSE" 34px Barlow Condensed (load fonts? No external fonts except CDN allowed... Google Fonts is a CDN — allowed? "no external files ... except the CDN URLs explicitly allowed" — the import map only allows three. Safer: use system font stacks. But typography spec calls for Barlow Condensed and IBM Plex Mono. I could @import Google Fonts — risky since network may be blocked? Typically allowed but rules say only the given import map. I'll use font stacks: `"Barlow Condensed", "Oswald", "Arial Narrow", system-ui, sans-serif` with font-stretch condensed fallback, and monospace for mono: `"IBM Plex Mono", "SFMono-Regular", Menlo, Consolas, monospace`. Hmm, without the actual fonts it may look generic. I think linking Google Fonts is common and generally fine; the rule says "no external files, images, fonts or network requests, except the CDN URLs explicitly allowed by the task." So fonts are explicitly excluded → do NOT load Google Fonts. Use local fallbacks. I'll declare font stacks with the named families first (in case available) then condensed/mono fallbacks.

To get condensed look reliably: use `font-stretch: condensed` won't work on non-variable fonts. Use transform: scaleX(0.92)? Could apply letter-spacing tighter. I'll just use uppercase + tight letter-spacing on a sans stack — looks fine.

- readiness status top-right: dot + "LOCAL SIMULATION".
- Left rail: title "SPECIES / 04", plaques.
- Right rail: three plates + chart.
- Center prism: two vertical strips at ~ x=42% and 58%? A vertical transparent prism centered horizontally, narrowest at edges of frame. I'll implement as a centered column (width ~200px) with left/right 1px brass-ish lines, and on fish proximity it expands. Simpler: a centered prism element with two 1px edges, fill rgba(205,246,240,0.14)? Prism two vertical edges each 14% opacity fill... I'll do: `.prism` centered, width 180px, background linear-gradient(90deg, transparent, rgba(205,246,240,0.06), transparent), border-left/right 1px rgba(217,182,111,0.42)? Let's follow: prism edges = 1px brass lines at 42%, label "PRESSURE ZONE" and X/Y coords beneath, hidden <720px. The brass sweep animates.

Hmm — a full-height vertical bar in the center could be visually odd, but it's the signature. Make it subtle: two 1px vertical lines at ±90px from center spanning full height, plus a faint gradient column, plus an animated horizontal brass caustic sweep (a 1px horizontal line moving vertically) when active.

Actually "narrowest at the frame edges, most transparent at the vertical center" — I read it as refraction strongest at center. Fine.

- Bottom: instruction left, controls right (pause, sound buttons).
- Leader lines: an SVG overlay with a path from plaque notch to fish projected position + a small diamond + distance label.

Interactions:
- mousemove → update pointer NDC, mark user active (cancel demo), pulse instruction indicator.
- click/tap → hold freeze (fish hold) for 600ms max? Tap-and-hold freezes avoidance. Implement: pointerdown → hold = true (and start); pointerup → hold=false; if held >600ms... "freezes avoidance for 600ms" — I'll implement: on pointerdown, set holdUntil = now + 600 during which flee accel is 0 while held, released on pointerup. Simplify: while pointer down, fish hold position (brake) — nice tactile. And after release, avoidance resumes.
- drag → temporary school cohesion boost.
- keys: Space pause, R reset, M sound, Escape release focus.
- prefers-reduced-motion handling.
- resize.

Pause button toggles simulation (still render, but fish frozen) — but "Do not pause on tab blur" fine.

Sound: audio via WebAudio — a low hum + water noise, off by default; toggling creates AudioContext (only after user gesture — button click). Keep simple: generate gentle filtered noise + occasional bubble blips. Must not error if blocked. I'll implement carefully with try/catch.

Status readouts: temperature from sim time (25.4 ± 0.1), proximity count "0 / 5", X/Y coords update.

Chart: 5 ticks showing fish-near-glass counts; bars = count scaled.

Species plaques active state: determined by nearest fish to pointer (or focused). Leader line drawn to that fish.

Let's now think about performance: 6 fish × ~5 meshes each = 30 objects, fine. Instanced rocks (180 meshes → InstancedMesh of rocks? "180 procedural meshes: rocks, plants, bubbles" — use InstancedMesh for rocks/sand pebbles, individual for plants/bubbles). Fine.

Postprocessing bloom: I'll include it. Risk: if import fails... The URL path is `three/addons/postprocessing/EffectComposer.js` — standard for 0.170.0. OK.

Actually, to reduce risk of a black screen, maybe skip postprocessing and do a fake bloom via emissive + CSS? Bloom is nice for caustics glow. I'll include composer but with a fallback: wrap in try/catch — if composer creation fails, use renderer.render. Good defensive approach.

Let me write the code now.

Key parts:

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>Glasshouse / Live Tank 04</title>
<style>...</style>
</head>
<body>
<canvas id="tank"></canvas>
<div id="glass-reflection"></div>
<div id="frame"> lines </div>
<svg id="leaders"></svg>
<header>...</header>
<aside id="species">...</aside>
<aside id="instruments">...</aside>
<div id="prism">...</div>
<footer>...</footer>
<div id="start-overlay">CLICK OR PRESS A KEY TO START</div>
<script type="importmap">...</script>
<script type="module">...</script>
</body>
</html>
```

Wait: importmap must come before module script; it can be in body? It should be in head ideally but body works if before modules. I'll put importmap right before the module script at end of body. Actually spec: importmap must precede any module script. Fine.

Now CSS details. Let me write carefully but efficiently.

Colors as CSS vars.

Top HUD: fixed, height 88px, padding 40px, flex space-between, gradient background top (legibility): `linear-gradient(180deg, rgba(3,11,14,0.72), rgba(3,11,14,0))` max-height 72px — I'll apply as a pseudo-element on header of height 72px.

Wordmark: 34px, weight 700, letter-spacing 0.02em, uppercase; sub label 10px mono, brass, letter-spacing 0.12em. 48px brass line beneath.

Ready status: right aligned, 14px mono weight 600 "READY" ... I could show "LOCAL SIMULATION". I'll show dot + "READY · LOCAL SIMULATION"? Use label 10px: `LOCAL SIMULATION` with dot, and below maybe `TANK 04`. Keep: dot + "READY" 10px? I'll do: a small block with "READY" (10px mono, ink) and "LOCAL SIMULATION" (10px, ink-muted).

Left rail: top 104px, left 40px, width 300px. Title "SPECIES / 04". Plaque: border-left 1px hairline, padding-left 12px, margin-bottom 18px, transition. Active: border brass, name bright, index brass. Each plaque: `01 / ROYAL GRAMMA`, name 16px, then habitat line 10px mono, depth 10px mono with brass value.

To keep it compact within 800px height: 4 plaques × ~76px = 304px + header. Top 104 + 40 + 304 = 448. Fine. Add "NEAR GLASS" readout at bottom of rail maybe.

Right rail: right 40px, top 104px, width 164px. Three plates.

Measurement rule at top:76px from x=32 to (100% - 56px) minus right rail... A thin brass measurement rule crosses behind the top HUD at top:76px, starting 32px beyond left rail and stopping 56px short of right rail. I'll do a div at top:76px, left:32px, right:220px (approx right rail start), 1px brass 0.35, with tick marks (repeating-linear-gradient). With small tick marks. Use repeating-linear-gradient for ticks of height 4px above line: I'll make element height 5px with background: repeating-linear-gradient(90deg, rgba(217,182,111,.5) 0 1px, transparent 1px 24px) for ticks, plus a bottom border line. 

Bottom: fixed bottom 0, height 72px, padding 0 40px, flex between, top border 1px hairline? Legibility gradient bottom too.

Instruction: 10px mono uppercase ink-muted with brass indicator dot; hint 10px "Mouse near glass · click to hold · drag to steer" ink-faint.

Controls: pill buttons 40px, border 1px brass 0.42, radius 9999, background transparent, hover: bg rgba(217,182,111,0.1) + border brass 0.7 + translateY(-2px) (disabled under reduced motion).

Prism: center, top 88px, bottom 72px, width 180px, left 50%, transform translateX(-50%). Inside: two vertical edges (absolute left 0 / right 0, width 1px, background rgba(217,182,111,0.42), full height, opacity varies), fill gradient. Label at bottom center "PRESSURE ZONE" + coords. Sweep: an absolutely positioned 1px horizontal brass line animating top 0→100% over 650ms when active (add class).

Hmm, prism covering center might sit over fish visually — it's only 14% fill, fine.

Start overlay: centered, a pill with border, backdrop-filter blur(6px), bg rgba(3,11,14,0.55), text "CLICK OR PRESS A KEY TO START" 11px mono brass, letter-spacing .16em. Plus sub "Fish will notice the pointer." Position: center of screen (50% 46%). Fades out on pointermove/keydown/click. Also auto-hide after 8s? I'll hide on any interaction only, plus after 10s. Actually screenshot at 3s → visible. Good.

Mobile: media queries at 1024, 720, 420.

At 720: hide left rail & right rail & prism; show species chip top-right (40px square with index), wordmark bottom-left strip, bottom two pills centered. I'll implement by restyling existing elements rather than adding lots of new DOM: 
- #species becomes a compact chip: show only active plaque name? Simpler: keep #species but restyle to a small top-right chip showing "01" — but then it duplicates. Let's give the chip its own element `#mobile-species` (hidden ≥721px) that JS updates. And hide #species at ≤720.
- Wordmark bottom-left strip: at ≤720, header moves to bottom-left? But footer also there. The wordmark becomes a compact bottom-left strip. And bottom controls centered. I'll: at ≤720, header (wordmark + status) → position bottom-left, small; footer → instruction centered bottom with pills. Might collide. Let's design mobile:
  - Top: mobile species chip (40px) at top-right; status small at top-left? Let's do: top row = status (left) + species chip (right), height 56.
  - Bottom: wordmark strip left + controls right, instruction above them.
  
That's cleaner: I'll add class-based layout: at ≤720, header stays top (wordmark shrinks to 18px with "GLASSHOUSE" only, status dot only), footer contains instruction left + pills right, species becomes chip top-right... but header top-right has status. Conflict.

Simplest mobile plan:
- ≤720: #species → hidden; #mobile-species chip shown at top-right (below header? no, header right is status). Put chip at right side, vertically at top:76px. Header keeps wordmark (18px) left + status right (dot only + "READY" hidden labels). Hmm chip at top-right below status: top 80px, right 20px — ok.
- Wordmark: at 720, "compact bottom-left strip" — I'll instead keep wordmark top-left but shrink. Bottom-left: instruction can go bottom-left too. The desktop instruction is bottom-left anyway. So: bottom-left = wordmark? No, instruction is bottom-left. I'll keep desktop-ish arrangement but compact: bottom bar contains instruction (left, 10px) and 2 pills (right). Wordmark top-left small. This avoids collisions. I'll note nothing; visual result is what matters.

Actually to be safe and simple, on mobile:
- top bar: wordmark 18px + status dot.
- species chip: top-right, below top bar (top: 76px, right: 16px) showing "01".
- right instrument rail hidden; instead two small pills in bottom bar showing TEMP and FLOW? Two small horizontal instrument pills centered at bottom. I'll add #mobile-instruments with two pills (25.4°C, GENTLE) hidden on desktop.
- footer: instruction left (10px, truncated), pills right. Might be tight on 375px: instruction "MOVE THE MOUSE NEAR THE GLASS" at 9px ~ 170px, two pills 40px each + gap = 92px, total 262 + padding 32 = 294 < 375. OK. Hide hint text on mobile.
- prism hidden.
- frame vertical lines: keep maybe reduce to 2.
- instrument pills centered at bottom: that conflicts with footer. I'll place #mobile-instruments just above footer (bottom: 64px, centered).

OK.

Now the JS.

```js
import * as THREE from 'three';
```
And optional postprocessing imports — static imports can't be try/caught. If they fail the whole module fails. Paths are correct for 0.170: `three/addons/postprocessing/EffectComposer.js`, `RenderPass.js`, `UnrealBloomPass.js`, `OutputPass.js`. I'm fairly confident all exist. UnrealBloomPass imports `three/addons/shaders/LuminosityHighPassShader.js` and `CopyShader.js` — exist.

Hmm, risk vs reward. Bloom adds glow to caustics/fish highlights — nice. I'll take it but guard render path:

```js
let composer = null;
try { composer = new EffectComposer(renderer); ... } catch(e){ composer = null; }
```
Static import failing = module failure though. I'm confident enough about those paths.

Actually — do I need tone mapping with composer? OutputPass applies tone mapping + color space conversion. Set renderer.toneMapping = ACESFilmicToneMapping; OutputPass reads renderer.toneMapping. Yes.

Let me reduce risk: skip postprocessing entirely; fake glow with sprite halos and additive materials. Simpler, zero risk, still pretty. Bloom strength 0.18 is subtle. Hmm. I'll include it — it's a standard pattern and I'm confident. Let me include with try/catch fallback.

Actually one more consideration: EffectComposer + UnrealBloomPass on a transparent/none background... fine.

Decision: include.

Textures (procedural via canvas):

1. `causticTexture(size=256)`: draw overlapping wavy lines / voronoi-ish. Approach: draw many random translucent arcs/strokes with white/greenish color, then blur? Canvas blur via `ctx.filter = 'blur(6px)'` supported in Chrome. Use that: fill black, draw random curved strokes, apply filter blur. Then use as additive map with `RepeatWrapping`.

Better caustic: use a per-pixel function with sin interference computed in JS (256×256 = 65k px, fine):
```
v = |sin(x*a + t)| ... 
```
Compute: c = 0; for k in 3 octaves: c += sin(x*f + sin(y*g)) etc. Then value = pow(max(0, ...)). Let's do a quick formula:
```
n = Math.sin(x*0.09 + Math.sin(y*0.07)*2.0) + Math.sin(y*0.11 + Math.sin(x*0.05)*2.0) + Math.sin((x+y)*0.06);
v = Math.pow(Math.max(0, n/3), 3) * 255 → gives web-like bright ridges
```
Hmm want lines: use `1 - Math.abs(n/3)` then pow. Let's do: t = n/3 in [-1,1]; ridge = Math.pow(Math.max(0, 1 - Math.abs(t)*2.2), 2.5); → bright where t≈0, i.e., ridge lines. Good, gives caustic web.

2. `scaleTexture`: grid of overlapping arcs (fish scales) — draw small circles/arc rings on transparent/black; use as bumpMap (grayscale).

3. `sandTexture`: noise grains: fill base color, add many random dots of varying brightness.

4. `speciesTexture(kind)`: clown bands, gramma gradient (but I'll use vertex colors for gramma; a map with vertical gradient works too since lathe UV v runs along body... wait which UV direction? For LatheGeometry, u goes around the rotation axis, v goes along the profile. So v along body axis. Canvas texture: v is Y in canvas (flipY true default means texture is flipped: canvas top = v=1). I'll just test-ish: draw gradient horizontally in canvas across width? If v is along body, then the variation must be across the canvas's V axis = vertical in the canvas image. So bands = horizontal stripes in canvas. To be safe, I'll generate patterns that are mostly horizontal-stripe based (works for clown bands), and for gramma use vertex colors (UV-independent). For chromis/tang, use subtle vertical-ish noise that doesn't matter.

Let's just use:
- gramma: vertex colors (purple head → yellow tail), plus scale bump.
- clown: canvas with horizontal bands (white bands at v≈0.28, 0.62), orange base.
- chromis: pale green-white base with slight sheen; vertex colors gradient? use plain color + high metalness 0.25 for shimmer.
- tang: yellow base, vertex color maybe with white peduncle spike — plain yellow + bump.

Fins: separate material, transparent opacity 0.75, color matching, side double.

Fish assembly function:

```js
function makeFish(spec) -> {group, tail, pectoralL, pectoralR, len, ...}
```

Body geometry via LatheGeometry:
```js
const pts = [];
const N = 24;
for (i=0..N){ t=i/N; r = A*Math.pow(Math.sin(Math.PI*t), 0.62)*(1-0.30*t) + 0.001; pts.push(new THREE.Vector2(r, -L/2 + t*L)); }
```
Wait at t=0, sin=0 → r=0, nose point. pow exponent 0.62 <1 → rises steeply → blunt nose. At t=1, r=0 → tail tip point where tail fin attaches. Good. Add small epsilon to avoid degenerate.

L = 1.0 (unit), A = 0.22 (max radius ~0.22 → body height 0.44). Fish overall length with tail ≈ 1.0. Scale per species: clown 1.15, gramma 0.7, chromis 0.62, tang 1.3 (tang also taller: scale y by 1.35).

For tang (disc shape): scale body y by 1.5, x/z? Body axis is Z; cross-section Y(height) and X(width). Disc: increase height, keep width. So after lathe+rotate: scale.set(1, 1.4, 1) for height? Cross-section is X-Y plane; scaling Y makes it taller. But that also stretches nose/tail profile vertically — that's fine (disc fish have deep bodies). Good.

Then flatten width: scale X by 0.55 (fish narrow left-right) — apply on geometry: `geo.scale(0.6, 1, 1)` before or after rotate (rotate first: axis along Z, cross-section X-Y). So: create lathe (axis Y), rotateX(-PI/2) → axis Z, then scale(0.6,1,1) → flatten width. Then recenter and compute vertex colors.

UV: lathe UVs preserved.

Tail fin shape (ShapeGeometry), in XY plane, then positioned at z = L/2, oriented so it's vertical plane (XY plane already vertical if X is horizontal... fish faces +Z, so tail plane should be XY plane (vertical, spanning height and lateral? no). Tail fin of a fish: it's a vertical fin, lies in the plane containing the body axis (Z) and vertical (Y) → plane is YZ. So ShapeGeometry creates shape in XY; rotate Y by 90° to put it in ZY plane. Shape: forked tail: points (0,0) → (0.3, 0.28) → (0.16, 0) → (0.3,-0.28) → (0,0) where local x will become Z (backward). Let's define shape with x = backward distance, y = height; then rotateY(-PI/2)? ShapeGeometry in XY plane; we want x→Z. rotateY(PI/2): x→? Rotation about Y by θ: x' = x cosθ + z sinθ, z' = -x sinθ + z cosθ. θ=PI/2: x'=z, z'=-x. So original +X maps to -Z. We want shape's +x (backward) to map to +Z (behind fish, since fish faces +Z, tail is at +Z... wait fish faces +Z means nose at +Z? Earlier: nose y=-L/2 → after rotateX(-PI/2), z=+L/2? Let's recheck: rotateX(-PI/2): y' = y cos(-90) - z sin(-90) = 0*y... cos(-90)=0, sin(-90)=-1. y' = y*0 - z*(-1) = z. z' = y*(-1) + z*0 = -y. So point y=-L/2 → z' = +L/2. So nose at z=+L/2 → fish faces +Z. Tail at z=-L/2. So tail fin sits at z=-L/2 and extends further to -Z. So shape's +x should map to -Z: use rotateY(PI/2) as computed (x' = z... let me redo with the rotation matrix applied to geometry: rotateY(θ) rotates points: x' = x cosθ + z sinθ, z' = -x sinθ + z cosθ. θ=+90: x' = z, z' = -x. Original point (1,0,0) → x'=0, z'=-1 → (0,0,-1). Yes, +X → -Z. 

So tail shape drawn with x≥0 extending backward, rotateY(PI/2), then translate to z = -L/2.

But wait — for lookAt orientation: object3d.lookAt makes +Z point toward target. So fish nose (+Z) points along velocity. Good.

Tail fin pivot for wiggle: I'll make the tail a child group positioned at (0,0,-L/2) and rotate around Y. So build tail geometry with origin at its attachment: shape points starting at (0,0) going +x. Then group at z=-L/2, rotation.y = sin(...)*amp. Rotating group about Y swings tail left/right. 

Dorsal fin: shape in XY (vertical plane containing Z? dorsal fin plane = YZ plane too, same as tail). Draw with x = along body (z), y = height. Points: (0.1,0.02)→(0.2,0.22)→(0.35,0.26)→(0.42,0.10)→(0.45,0.0)... Use rotateY(PI/2) too so x→-Z... careful: dorsal should be along body from nose to tail. Let's just place dorsal in the YZ plane with shape x mapping to +Z... Simpler: define dorsal shape in XY, then rotateY(-PI/2) so +X→+Z (x' = x cos(-90)+z sin(-90) = -z... hmm: θ=-90: x' = x*0 + z*(-1) = -z; z' = -x*(-1) + z*0 = x. So (1,0,0)→(0,0,1). Yes rotateY(-PI/2) maps +X→+Z.) Then dorsal spans from z=0 forward... I'll define dorsal shape with x from -0.35 (rear) to +0.25 (front)? Getting confusing. 

Alternative much simpler approach: build fins as custom BufferGeometry from explicit 3D vertices. Honestly easier to control:

Tail fin (group at z=-L/2), vertices in local space (x=lateral, y=vertical, z=backward negative? but group local +z is still fish forward +z after no rotation). Within tail group at origin, tail extends to z<0? No — the group is at z=-L/2, tail extends further backward → local z from 0 to -0.35.

Triangles: 
- points: A(0,0,0) root; fork tips: T1(0, 0.24, -0.34), T2(0, -0.24, -0.34), notch N(0, 0, -0.16).
- Build as 2 quads/4 triangles: (A, T1, N) and (A, N, T2). Since it's a flat plane with zero thickness and DoubleSide material, fine.

I'll write a helper `flatShape(points2D, mapping)` producing a triangle fan from origin: for a polygon defined by ordered points in a plane, use THREE.Shape + ShapeGeometry then transform with rotate/translate — that's actually easiest, and I can control transform explicitly:

```js
function finFromShape(pts, plane){ // pts in 2D
  const s = new THREE.Shape(); s.moveTo(pts[0][0],pts[0][1]); ... 
  const g = new THREE.ShapeGeometry(s);
  return g;
}
```
Then for tail: shape coords (x=backward, y=up), geometry in XY plane. Apply `g.rotateY(Math.PI/2)` → maps +X→-Z. So shape +x (backward) → -Z (backward). 

For dorsal: shape coords (x = forward?, y = up). We want the fin along the body: shape's +x should map to... dorsal spans from rear (z=-0.3) to front (z=+0.2). Draw shape with x from -0.3 to +0.2 where +x = forward → need +X→+Z: rotateY(-PI/2). Wait but rotateY(-PI/2) also maps +Y→+Y? y'=y. Yes. So dorsal shape with x forward, y up, then rotateY(-PI/2) gives z = x. 

Hmm but rotateY(-PI/2) computed: (1,0,0) → (0,0,1). ✓.

But careful: after rotating a shape in XY plane by rotateY, it lies in the ZY plane. Good.

Pectoral fins: small shapes in a horizontal-ish plane, angled. Build in XY then rotateX(PI/2) to make horizontal (XY→XZ): rotateX(PI/2): (0,1,0)→ y'=0*... y'= y cos - z sin = -z... for point (0,1,0): y'=1*0 - 0*1 = 0? cos(90)=0, sin(90)=1: y' = y*0 - z*1 = -z = 0; z' = y*1 + z*0 = 1. So +Y→+Z. And +X stays +X. So XY plane → XZ plane. Then rotate to angle outward. Pectoral: left fin at x=-0.18, y=-0.02, z=+0.15, rotated so it sweeps outward and back. Just place with rotation.z/y tweaks. Fine — visually approximate.

Anal fin: similar to dorsal, smaller, at bottom (y negative): shape with y down → multiply y by -1 in coords.

Eyes: sphere radius 0.045 at (±0.16, 0.07, 0.34) scaled by body scale... but body is scaled; easier to attach eyes in fish local space with coords relative to L. Since geometry was scaled by (0.6,1,1) in X, the half-width at z=0.34: profile radius at t where z=0.34: t = (0.34+0.5)/1 = 0.84 → hmm nose at z=+0.5 corresponds t=0. z = -L/2 + t*L → wait after rotation z = -y = L/2 - t*L → t = 0.5 - z/L. For z=0.34, t=0.16 → r = A*sin(pi*0.16)^0.62*(1-0.048) = 0.22*(0.482)^0.62*0.952 = 0.22*0.646*0.952 ≈ 0.135. Width = 0.6*0.135 = 0.081. So eyes at x=±0.075, y=+0.05, z=0.33. OK.

Mouth: skip or a small dark line.

Species colors:
- gramma: body vertex colors: head purple #7b3fa0 → mid #b34a8f → tail #f2c14e? Royal gramma: front purple/violet, rear yellow. So lerp along z from purple (nose) to yellow (tail).
- clownfish: base #ff7a2f, bands white.
- chromis: #cfe8c8 / #9fd7c4 shimmer with metalness.
- tang: #ffd23f.

Vertex colors: I'll compute per-vertex colors for all bodies using a color function of t (0=nose,1=tail):
- gramma: if t<0.45 lerp(#5b2f8f→#c94f8e) ... let's: t<0.5 → mix violet(#4b2a86) to magenta(#d2447f); t>0.5 → mix magenta to gold(#f5c542). Actually royal gramma purple front ~40%, yellow rear ~60%. 
- clown: solid orange (map does bands).
- chromis: mix #7fd4c1 (head) → #d8f0b8? blue-green chromis is pale green-blue: base #a9e3c8 with slight gradient.
- tang: solid #ffcf3a.

Note: with `vertexColors: true` and a `map`, they multiply. If map is white-ish (for gramma/chromis/tang) that's fine. For clown I'll set vertexColors false. I'll generate each body separately with a flag.

Materials:
```js
bodyMat = new THREE.MeshStandardMaterial({ map, bumpMap: scaleTex, bumpScale: 0.012, vertexColors, roughness: 0.42, metalness: 0.12, color: 0xffffff });
```
Add slight emissive = color*0.08 for glow under caustics.

Fins material: `MeshStandardMaterial({color, transparent:true, opacity:0.72, roughness:0.6, side:DoubleSide, depthWrite:false})`.

Fish update loop:

```js
class Fish { ... }
```

State: pos (Vector3), vel (Vector3), targetDir, phase, species params: speed, turnRate, fleeScale, scale, group.

Steering each frame (dt clamped):
```
desired = new Vector3()
// wander
wanderAngle += (rand)*dt ...
// species-specific attractor: school center for chromis; home area for others
// bounds: soft return to box
// flee from pointer ray
// separation from other fish
```
Implement with velocity-based steering:
```
force = seek(targetPoint)*w + flee*fw + separation + bounds + substrateLift
vel += force*dt
clampSpeed
pos += vel*dt
```
Then orient: build matrix: 
```js
const ahead = pos.clone().add(vel.clone().multiplyScalar(1));
group.lookAt(ahead) // but need up handling; use quaternion slerp toward target quaternion computed from a dummy Object3D
```
Use a dummy: dummy.position.copy(pos); dummy.lookAt(pos+vel); then slerp group.quaternion toward dummy.quaternion with factor (1 - exp(-dt*turnSmooth)). For reduce-motion, snap.

Wait: lookAt uses object's up vector (0,1,0) — if velocity is straight up, degenerate. Velocities rarely vertical; clamp y component.

Pointer ray distance:
```js
const ray = new THREE.Ray();
raycaster.setFromCamera(ndc, camera); ray.copy(raycaster.ray);
// closest point on ray to fish
const v = fish.pos.clone().sub(ray.origin);
const t = v.dot(ray.direction);
const closest = ray.origin.clone().addScaledVector(ray.direction, t);
const d = fish.pos.distanceTo(closest);
```
Flee direction = (fish.pos - closest).normalize() but also add -ray.direction*0.35 to push sideways/away. Actually pushing away perpendicular is right.

Strength = smoothstep((1.45-d)/1.45) * 7.5 * speciesFlee.

School: chromis use flocking with 3 members: cohesion to school centroid (weak), alignment, separation. Plus when fleeing, they scatter.

Idle after pointer leaves: fish seek back to a "lane" position (their assigned home region) with slow force.

Tang: moves calmly — lower speed, larger turn radius, flee curve (bias perpendicular).

Goby: skip.

Camera parallax: camera.position.x = baseX + ndc.x*0.65, y = 0.6 + ndc.y*0.45, lerp.

Intro: camera z from 13.8 → 11.6 over 1800ms with cubic-bezier(0.22,1,0.36,1) — implement easing function easeOutQuint-ish: use custom bezier approximation: `1 - Math.pow(1 - t, 3)` close enough? cubic-bezier(0.22,1,0.36,1) is easeOutQuint-like. I'll implement a real cubic bezier solver quickly (small function) — or use easeOutCubic. I'll write a tiny bezier function for fidelity? Keep it simple: `const easeIntro = t => 1 - Math.pow(1 - t, 4);` close to that curve. Fine.

Fish entrance: each fish starts at pos offset behind target (z - 6) and fades in opacity over 420ms with stagger. Material opacity requires transparent. I'll instead scale from 0.6 and move in from behind: simpler: start at z = target.z - 6 and let them swim in. And do opacity via `mat.transparent=true; mat.opacity` — I'll set transparent true for all body mats (slight perf cost, negligible) and animate opacity 0→1 with stagger. Depth sorting issues with transparent fish: they're small; set depthWrite true for body (transparent+depthWrite true can cause issues but ok). Hmm, transparent with depthWrite true is fine for convex bodies.

Actually simpler: fade in by scaling from 0.2→1 and moving forward? I'll do opacity fade with transparent:true, depthWrite:true.

HUD entrance stagger via CSS animation with delays.

Leader lines: SVG `#leaders` with a `<path>` or `<line>` + `<circle>` diamond + `<text>`. Update each frame: pick the active fish (nearest to pointer, within threshold) and its species plaque element; get plaque notch screen coords via getBoundingClientRect; fish screen pos via project(). If fish behind camera or off-screen, hide. Transition via CSS on opacity. Also for reduce-motion, no transitions.

Since plaques are DOM, rect gives coords relative to viewport (SVG is full-screen fixed) ✓.

Species active determination: nearest fish to pointer → its species becomes active; if no pointer near, keep last active or the fish nearest to center? Default: active = species of fish closest to camera? Let's: if any fish within 1.45 of ray → that fish's species active; else keep previous active species (or default 01). Also mobile chip updates.

Readouts: X/Y coordinates of pointer in normalized -1..1 formatted with sign; update on pointermove (and demo).

Fish near glass count: fish with |z| > 1.5? "Fish near glass" = distance to camera plane? Use fish whose screen-projected position within some margin... Simpler: fish with z > 1.0 (toward viewer/glass). Count / total. Chart ticks: 5 ticks labeled with counts? "5 vertical measurement ticks scale from 0 to 3.75 units" — that's a distance scale with bars. I'll render 5 rows: each row = one fish? Actually "local chart for fish near the glass: 5 vertical measurement ticks scale from 0 to 3.75 units". I'll build a small chart: 5 vertical bars whose heights represent the z-proximity (0..3.75) of the 5 nearest fish... Simpler: 5 vertical ticks = a scale, and one brass marker line showing nearest-fish distance to glass (0–3.75). Hmm.

I'll implement: a horizontal row of 5 vertical bars, each bar's height = one fish's distance-from-glass mapped 0..3.75 (inverted: closer = taller), baseline 1px hairline, bars brass. Label "NEAR GLASS" + value "n / 6". Updated every 240ms. That satisfies the data viz intent.

Right rail also: temperature 25.4 (slight drift), pH 8.1, FLOW GENTLE.

Sound: WebAudio: 
- Brown noise buffer through lowpass (water ambience), gain 0.06.
- occasional bubble: oscillator sine with quick pitch envelope, gain 0.05, scheduled randomly.
Implement on toggle; resume context. Wrapped in try/catch.

Pause: stop sim updates (fish freeze), show status "PAUSED" and change button icon to play.

Reset (R): reset fish positions/velocities to initial, camera to intro? just fish.

Reduced motion: `matchMedia('(prefers-reduced-motion: reduce)')` → speedScale 0.4, no intro camera animation (fade canvas in 180ms), no prism sweep, no hover translate (CSS media query), orientation snap.

Visibility: on `document.hidden` we don't need to pause (keep sim active on tab blur). But rAF stops anyway when hidden; use clock delta clamp (max dt 0.05) to avoid jumps.

Resize: recompute camera aspect, renderer size, composer size, SVG.

Let's now write actual code. I'll be thorough but keep it manageable.

Particle field: Points with a small circular sprite texture, 200 points in a box, additive, opacity 0.35, drift upward slowly with wrap.

Bubbles: 26 small spheres? Use Points too for cheapness — separate Points with bigger sprite, rising, respawn at substrate. Two point systems.

Plants: 10 blades — geometry: PlaneGeometry(0.12, 1.4, 1, 6) with vertex displacement in shader? Simpler: use a tapered shape geometry (Shape) and animate group rotation.z = sin(t*0.6 + phase)*0.12. Pivot at bottom: geometry translated so base at y=0.

Rocks: InstancedMesh with dodecahedron geometry scaled randomly, 60 instances; plus pebbles: InstancedMesh of icosahedron small, 120 instances scattered on substrate (total 180 procedural meshes ✓).

Substrate: PlaneGeometry(40, 26, 60, 40) with vertex noise (sine-based dunes), material with sand texture, roughness 1, color #1a2b2b? Sand color: dark teal-brown #22312f with texture variation. Under blue light it'll read correctly.

Caustic light layer: a large plane at z=-6 with caustic texture, additive blending, opacity 0.14, animated map offset (two layers?). Also a plane on the sand (y=-1.99, rotated) with additive caustic opacity 0.065 — "Caustic light layer opacity: 0.065". I'll use 0.065 for the sand-projected one and 0.12 for back wall. Fine.

Lights:
- ambient: HemisphereLight(#1a5f6a sky, #040d10 ground, 0.55)
- directional from above-left: color #cfeee6, intensity 1.5, position (-4, 8, 4)
- point light (brass) at (3, 1.5, 3) color #d9b66f intensity 12, distance 14, decay 2
- point light (coral) at (-4, -0.5, 2) color #ff6f61 intensity 8, distance 12
- rim: directional from behind (0,2,-6) color #0d6f78 intensity 0.8

With ACES tone mapping and physically correct lights (r155+ uses physically correct by default; point light intensity in candela — with decay 2, intensity 12 at distance 3 gives 12/9=1.33 — reasonable).

Background: scene.background = new THREE.Color(0x06151a); fog = FogExp2(0x06151a, 0.038). Backdrop plane at z=-12 large with gradient texture, or rely on fog+background. Add a subtle vertical gradient backdrop plane at z=-11 (40×24) with MeshBasicMaterial gradient texture, fog: false? Basic material with fog applies... set fog:false and use gradient from #0a2a30 top to #030b0e bottom. Good — gives depth.

Water surface at y=4.6? At z=0 the visible top is y ≈ 0.6+4.45 = 5.05 (at z=0), at z=-6: 0.6+6.74=7.34. So a plane at y=4.6 would be visible only in the upper region for far distances — actually plane at y=4.6 spanning z: for z=0 top of frame is 5.05 so plane at 4.6 is just inside frame → visible as a band across upper area, closer to camera it goes off-screen top. Good, reads as water surface. Camera y=0.6 looking at 0 → the surface is above. Add plane 40×40 at y=4.6, rotated flat, material: MeshStandardMaterial with caustic map, transparent, opacity 0.35, side DoubleSide, color #0d6f78, emissive slight. Since we see it from below, DoubleSide works. Animate texture offset. Might be too bright/dominant — opacity 0.22.

Actually might create a hard horizontal line across the top — which is fine (surface line). Let's include with subtle opacity.

Glass reflection overlay CSS: two diagonal translucent gradients using `--glass` at 0.13 — keep subtle.

Now HUD spec details for statuses. Let's write HTML.

```html
<header id="topbar">
  <div class="wordmark">
    <div class="title">GLASSHOUSE <span class="sub">/ LIVE TANK 04</span></div>
    <div class="rule48"></div>
    <div class="tag">ESTUARY · REEF · OPEN WATER</div>
  </div>
  <div class="ready" id="ready">
    <span class="dot" id="dot"></span>
    <span class="ready-main" id="readyMain">READY</span>
    <span class="ready-sub" id="readySub">LOCAL SIMULATION</span>
  </div>
</header>
```

Hmm wordmark 34px + rule + tag might exceed 88px top HUD (34*1.0 + 4 + 1 + 10 = ~55 + padding 40 top = 95 > 88). Reduce: put tag inline. Layout: title 34px line, then rule 48px wide under it with gap 8, tag to the right of rule? I'll do: title, then a row [48px brass rule | tag]. Top padding 28px. Total ≈ 28+34+8+1+8+12 = 91. Close enough; rail starts at 104.

Left rail content:

```html
<aside id="speciesRail">
  <div class="rail-label">SPECIES / 04</div>
  <div class="plaque" data-species="0">
     <div class="p-index">01</div>
     <div class="p-name">ROYAL GRAMMA</div>
     <div class="p-meta">CRYPTOCHROMIS POMACANTHUS? </div>  // actually Lobectenia... 
     <div class="p-hab">CARIBBEAN REEF · MIDWATER</div>
     <div class="p-depth">DEPTH <b>1.8 m</b></div>
  </div>
  ...
</aside>
```
Species scientific names: 
- Royal gramma: *Lobectenia flammea*? The actual is *Lobectenia heraldi*... Royal gramma = *Lobectenia flammea*? It's *Lobectenia* ... Let me recall: Royal gramma scientific name is *Lobectenia flammea*? Hmm. It's **Lobectenia**... Actually the royal gramma is *Lobectenia flammea*? I believe the correct name is ***Lobectenia flammea*** — no. The royal gramma (*Lobectenia*?) Let me think: Royal gramma, *Lobectenia flammea*, also known as fairy basslet, is **Lobectenia flammea**? I recall "Lobectenia" isn't a genus. The correct genus is *Lobectenia*... hmm, actually I'm fairly sure it's ***Lobectenia flammea*** — no.

Known: Royal gramma = *Lobectenia flammea*? Alternative memory: **"Lobectenia"** vs **"Lobectenia"**... The fish is Fairy basslet, *Lobectenia flammea*? I think the accepted name is ***Lobectenia flammea*** is wrong; it's ***Lobectenia***… I can't nail it. Use common names only to avoid error: "GRAMMA · REEF" instead of scientific. Avoid inventing wrong Latin names. But latin names add museum credibility. For species I'm confident about:
- Ocellaris clownfish: *Amphiprion ocellaris* ✓
- Blue-green chromis: *Chromis viridis* ✓
- Yellow tang: *Zebrasoma flavescens* ✓
- Royal gramma: *Lobectenia flammea*... risky. I'm now fairly confident it's ***Lobectenia flammea***? Hmm, royal gramma: "Lobectenia" no. Searching memory: "royal gramma (Lobectenia flammea)"... "fairy basslet" ... I recall **"Lobectenia"** being wrong and correct = ***Lobectenia***? 

Alternative: The genus is **"Lobectenia"**… I genuinely can't verify. Safer: write "GRAMMA · FAIRY BASSLET" (common alternative name, correct) instead of Latin. Do that.

Now let me write all code.

CSS (writing now):

```css
:root{
 --deep:#06151a; --abyss:#030b0e; --water:#0d6f78; --glass:rgba(205,246,240,.13);
 --brass:#d9b66f; --ink:#edf7f2; --ink2:#91aaa5; --ink3:#5f7773;
 --hair:rgba(183,225,218,.24); --hot:rgba(217,182,111,.42);
 --cond: "Barlow Condensed","Oswald","Arial Narrow",-apple-system,Segoe UI,sans-serif;
 --mono: "IBM Plex Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
 --pad:40px;
}
*{box-sizing:border-box}
html,body{height:100%;margin:0;background:var(--deep);overflow:hidden}
body{font-family:var(--mono);color:var(--ink);-webkit-font-smoothing:antialiased}
#tank{position:fixed;inset:0;width:100%;height:100%;display:block;z-index:0}
```

Frame lines:
```css
#frame{position:fixed;inset:0;pointer-events:none;z-index:2}
#frame i{position:absolute;top:0;bottom:0;width:1px;background:rgba(217,182,111,.18)}
#frame i.h{left:0;right:0;top:68%;bottom:auto;width:auto;height:1px}
```
Opacity 0.18 with breathing: animate opacity 0.14→0.22 over 12s. Frame draws in on load: use scaleY animation from 0 to 1 (transform-origin top) over 900ms — nice "draws in".

Glass reflection overlay: `.glasslayer{position:fixed;inset:0;pointer-events:none;z-index:1;background:linear-gradient(115deg, transparent 30%, rgba(205,246,240,.05) 38%, transparent 44%), linear-gradient(115deg, transparent 62%, rgba(205,246,240,.04) 68%, transparent 74%); mix-blend-mode:screen}`.

Vignette: radial-gradient overlay subtle.

HUD z-index 5.

Let me write the full HTML now, carefully.

Bottom instruction indicator: `.pulse{width:6px;height:6px;border-radius:50%;background:var(--brass);opacity:.45;transition:opacity .5s}` add class `.hot{opacity:1}` removed after 500ms.

Controls buttons:
```html
<button class="ctl" id="btnPause" aria-label="Pause simulation"><svg .../></button>
<button class="ctl" id="btnSound" aria-label="Toggle sound"><svg .../></button>
```
Inline SVG icons: pause = two bars (44% opacity when paused → show play triangle), sound = speaker with waves.

Focus states: `:focus-visible{outline:none;border-color:var(--water);box-shadow:0 0 0 1px var(--water)}` — spec: keyboard focus uses water-blue; I'll add an outline ring with water-blue and a small `:focus-visible` label? "small water-blue label" — I'll add `::after` content "FOCUS"? Might be odd. Keep outline + brass? Use: `outline:1px solid var(--water); outline-offset:3px`.

Start overlay:
```html
<div id="start"><div class="start-card"><span class="k">CLICK OR PRESS A KEY TO START</span><span class="s">Fish will notice the pointer</span></div></div>
```
z-index 9, pointer-events none (so clicks pass to canvas? canvas is behind; pointer events on window anyway). Keep pointer-events:none so it never blocks. Hide on interaction.

Now JS in detail. Let me write it.

```js
import * as THREE from 'three';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';

const REDUCE = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
```

Renderer setup, sizes from window.innerWidth/Height (iframe viewport). Use `document.documentElement.clientWidth`.

Canvas element fixed inset 0.

```js
const renderer = new THREE.WebGLRenderer({canvas, antialias:true, powerPreference:'high-performance'});
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;
```

Camera: PerspectiveCamera(42, aspect, 0.1, 100); position (0,0.6,13.8) intro to 11.6; lookAt(0,0.05,0).

Textures functions... write them.

```js
function canvasTex(size, draw, {repeat=[1,1], wrap=THREE.RepeatWrapping}={}){...}
```

causticTex:
```js
function causticTexture(size=256){
 const c=document.createElement('canvas'); c.width=c.height=size; const ctx=c.getContext('2d');
 const img=ctx.createImageData(size,size);
 for(y..){for(x..){
   const nx=x*0.10, ny=y*0.10;
   const n = Math.sin(nx + Math.sin(ny*0.9)*1.8) + Math.sin(ny*1.1 + Math.sin(nx*0.7)*1.9) + Math.sin((nx+ny)*0.55);
   const t=n/3;
   let v=Math.max(0,1-Math.abs(t)*2.4); v=Math.pow(v,2.6);
   // second ridge set for detail
   const n2=Math.sin(nx*1.9+ny*0.4)+Math.sin(ny*2.1);
   let v2=Math.pow(Math.max(0,1-Math.abs(n2/2)*2.2),3.0);
   const g=Math.min(255, (v*235 + v2*70));
   img.data[i]=g*0.72; +1: g*0.95; +2: g; +3:255;
 }}
}
```
Colors: bluish-white → use (0.72g, 0.95g, g).

scaleTexture: 
```js
for each scale row/col draw arcs: 
ctx.fillStyle='#808080'; fill; then draw overlapping circles with radial gradient darker edges.
```
Simpler: base #7f7f7f, then for rows y step 8, offset alternating, draw arcs: ctx.strokeStyle='rgba(255,255,255,.5)' arc(...) and 'rgba(0,0,0,.5)' arcs. As bumpMap only luminance matters.

sandTexture: base #24332f? Let's make sand warm-grey-teal: base '#2b3a35', then 4000 dots random alpha of '#8a9a8e' and '#0d1a1a'. repeat 8,6.

species textures:
- clownTex(512×256): base orange gradient; horizontal white bands. v along body = canvas Y (flipY: texture v=0 at canvas bottom). Body t=0 (nose) → v=? LatheGeometry UV: v = i/segments along profile from first point (nose, t=0) to last (tail). With flipY=true (default), texture v=0 maps to canvas bottom row (y=size-1). So nose (v=0) → canvas bottom. Bands should be at nose (v≈0.12 → canvas y = size*(1-0.12)), mid (v≈0.45), and near tail (v≈0.78). Clownfish bands: one behind head, one mid-body (white bar with black edging), plus black edges. I'll draw at canvas y = (1-v)*size for v in {0.14, 0.46, 0.80} with band height ~0.05*size, plus thin black lines around.

Actually clownfish typically: head band (behind eye), mid band, tail band. Fine.

Also apply a subtle darker belly? skip.

Fins colors: clown fins orange with black edge — use plain.

Now fish creation:

```js
const SPECIES = [
 {id:0, name:'ROYAL GRAMMA', short:'GRAMMA', count:1, len:0.78, height:1.0, color:..., flee:1.15, speed:1.35, ...},
 ...
];
```

Build fish list:
```js
const fishes = [];
function spawn(spec, offset){...}
```

Let me define per-species:
1. gramma: len 0.8, speed 1.5, flee 1.1, colorFn purple→yellow, home around x=-3.5, y=0.6, z=1.
2. clown: len 1.15, speed 1.1, flee 0.9, home x=2.6,y=-0.4,z=0.5. One clownfish. Keep 1.
3. chromis ×3: len 0.6, speed 1.6, flee 1.35, delay 40ms, home x=0..2, y=1.3, z=-1.5.
4. tang: len 1.35, height 1.4, speed 0.95, flee 0.75, turn rate lower, home x=-1, y=-0.9, z=2.

Total 6 fish.

Physics params: maxSpeed per species (units/s): gramma 2.4, clown 2.0, chromis 3.0, tang 1.7. Flee adds up to 3.2 max speed cap? Max impulse 7.5, max velocity 3.2 units/s → cap all at 3.2 (tang 2.6).

Steering implementation per fish per frame:

```js
const tmp = new THREE.Vector3();
function step(f, dt, t){
  const acc = new THREE.Vector3(); // reused
  // 1. wander around home
  const home = f.home; // vector that slowly drifts
  toHome = home - pos; if(len>2.2) acc += normalize(toHome)*1.6
  // wander: sinusoidal target offset
  const wob = new Vector3(Math.sin(t*0.31+f.ph)*1.2, Math.sin(t*0.23+f.ph*1.7)*0.5, Math.cos(t*0.27+f.ph)*0.9);
  acc += wob*0.55;
  // 2. separation from others
  for others: d = pos.distanceTo(o); if d < f.sep (0.75) acc += dir * (0.75-d)/0.75 * 3.0
  // 3. chromis school: cohesion+alignment to group center/avg vel (weight 0.8 / 1.0), only when not fleeing strongly
  // 4. flee
  // 5. bounds: box x ±6.2, y [-1.5, 2.6], z [-4.5, 3.2]: push back strongly
  // 6. goby/none
  vel += acc*dt; 
  speed clamp; damp: vel *= exp(-dt*0.6) ... careful: with acc-based, damping helps.
  pos += vel*dt;
}
```

Flee force:
```js
if(pointerActive && !hold){
  compute ray; d
  if(d < 1.45){
    const strength = Math.pow((1.45-d)/1.45, 0.8) * 7.5 * f.flee * fleeRamp;
    dir = (pos - closest).normalize(); dir.y *= 0.6; dir.normalize();
    // add slight backward push along -ray.dir for tang's broad curve
    acc.addScaledVector(dir, strength);
    f.pressure = max
  }
}
```
Add per-species reaction delay: store `f.delayT` — when a fish enters radius, accumulate time; only apply after delay (chromis 40ms → basically immediate; others 0-80ms). Implement: if in radius, `f.inPressure += dt` else `f.inPressure = 0`; apply only if `f.inPressure > f.reaction` (gramma 0.06, clown 0.09, chromis 0.04, tang 0.14). And eased envelope over 120ms: `env = min(1, (inPressure-reaction)/0.12)`.

Escape easing over 680ms: after leaving, pressure decays: `f.decay = max(0, f.decay - dt/0.68)`; multiply force by (env*0.6 + decay...). Simpler: keep `f.pressureEnv` that rises to 1 in 120ms and falls to 0 over 680ms; force = strength * pressureEnv.

Mouse hold: if pointer down, fish brake: `acc.addScaledVector(vel, -6)` and no flee → they hold. Also "freezes avoidance for 600ms": while held, flee off; on release, keep flee off for min(remaining). I'll implement: `holdUntil` timestamp set on pointerdown = now+600 while held... Let's do: pointerdown sets `holding=true`; pointerup sets `holding=false`, `holdRelease = now + 600ms`. During holding or within 600ms after release, flee force is disabled and fish gently brake (during hold) then resume. Nice.

Drag → school cohesion boost: track pointer movement distance while down; if dragDistance > 12px, set `cohesionBoost` for chromis (weight ×1.8) decaying over 1.2s.

Orientation: as described.

Demo pointer: 
```js
function demoPointer(t){
  // lissajous path across tank
  ndc.x = Math.sin(t*0.21)*0.62 + Math.sin(t*0.07)*0.18;
  ndc.y = Math.sin(t*0.17+1.2)*0.42;
}
```
Plus occasional sweeps. This moves the pointer across tank → fish flee. Active until user moves mouse (real pointermove with pointerType or any key/click). Note: on touch devices, `pointermove` only fires when touching. Also 'mousemove' listener on window.

But careful: demo mode must be visibly demonstrating flee at 3s. Lissajous with speed 0.21 rad/s → moves slowly; fish within 1.45 world units. At t=3s, ndc ≈ (sin(0.63)+sin(0.21))*0.62 = (0.589+0.208)*0.62=0.494, y = sin(0.51+1.2)*0.42 = sin(1.71)*0.42=0.415. That's upper right — fish homes: chromis at y=1.3 (ndc ~0.3), tang at y=-0.9. Distance from ray... likely some fish near. To guarantee visible flee at 3s, make the demo pointer do a purposeful sweep: start near center-left and sweep toward the chromis school during first 3 seconds. Let's craft:

demo path: `u = (t*0.16) % 1` then a lissajous but with amplitude covering the tank and speed ~0.35 cycles... Let's simply use:
```js
ndc.x = Math.sin(t*0.45)*0.7;
ndc.y = Math.cos(t*0.33)*0.35 + 0.1;
```
At t=0: x=0, y=0.45 → near chromis (y=1.3 world → ndc ~0.28 at z=-1.5). Hmm.

Let's compute world from ndc for a fish at z≈0: y = 0.6 + ndc.y*4.45 (camera at 0.6 after intro, and lookAt tilts... roughly). Chromis at y=1.3 → ndc.y = 0.157. Tang at y=-0.9 → ndc.y=-0.34. gramma y=0.6 → ndc.y=0. clown y=-0.4 → -0.225.

Demo with y oscillating ±0.35 around 0.05 and x ±0.7, period ~ 2π/0.45 ≈ 14s in x, ~19s in y. At t=3: x = sin(1.35)*0.7=0.68, y = cos(0.99)*0.35+0.05 = 0.19+0.05=0.24. Fish near (0.68,0.24)? chromis at x maybe 0..2 world → ndc x = worldx/7.1 ≈ 0.14. So pointer at ndc 0.68 → world x 4.8. Not near.

To guarantee a flee event in first 3s, start the demo pointer ON the chromis school and sweep across. Design: 
```js
const T = t + 0.0; 
ndc.x = Math.sin(T*0.5) * 0.75;
ndc.y = 0.12 + Math.sin(T*0.36+0.6)*0.30;
```
At t=0: x=0, y=0.12+sin(0.6)*0.3=0.12+0.169=0.29 → world y ≈ 0.6+0.29*4.45 = 1.9. Chromis at y=1.3, x maybe 0.5 → distance in y 0.6, x ~0.5 → ~0.8 < 1.45 ✓ flee at t=0. At t=3: x=sin(1.5)*0.75=0.748 → world 5.3; y=0.12+sin(1.68)*0.3=0.417. Hmm fish would have fled already at t≈0-1.5, and screenshot at 3s shows fish scattered/reset. Actually scattered fish mid-flight is exactly the desired screenshot. Good. Also fish return by 3s partially. That's fine and shows motion.

Better: make demo sweep back and forth so flee events recur. With x period 12.6s, at t=3 x=0.75 (max) then returns. The fish re-form around t=6-8. Screenshot at 3s: fish fleeing. 

Also maybe add: demo path re-approaches the school periodically. The lissajous naturally does.

Now HUD updates: 
- pointer readout X/Y: use pointer ndc formatted `X -0.34 / Y +0.08`.
- near glass count.
- temp: 25.4 + 0.1*sin(t*0.1) → "25.4°C" mostly.

Species active: nearest fish to ray within 1.6 → its index; else keep.

Leader line: only when a fish is within 1.45 (active pressure) → draw from its plaque to it.

Prism: active when any fish pressured → add class 'hot' to prism, enabling sweep animation (CSS animation restarts by toggling class). Also show label. Coords under label = pointer ndc.

Let's handle prism sweep restart: use animation on a child `.sweep` with `animation: sweep 650ms linear infinite` only when parent has .hot. Continuous sweep repeats every 650ms → infinite while active is fine.

Now write the SVG leader overlay:
```html
<svg id="leaders" aria-hidden="true"><g id="leadGroup" class="off"><line .../><circle ... (diamond via rect rotated)/><text .../></g></svg>
```
Use `<path>` for line, `<rect x=-2 y=-2 width=4 height=4 transform=translate rotate(45)>` — easier: `<polygon>` with computed points each frame. I'll do: line + a small diamond polygon + text updated with transform.

Text: "0.7 m" measured distance = distance from camera to fish? "small measured-distance label" — use distance from pointer to fish in "m": map world units to meters (1 unit = 0.5 m?) Let's say dist_world/2 → "0.7 m". Fine, or use distance to camera. I'll use pointer→fish distance in meters where 1.45 units = 1.0 m: `m = d/1.45`.

Now write everything. Also need `updateLeaders` using plaque `getBoundingClientRect`.

Only draw leader for the active species' plaque, and only if window width > 1024 (max 1 leader; on mobile hidden).

Let me write the code file now. I'll aim for correctness.

Detailed fish build code:

```js
function bodyGeometry(L, A, heightScale=1, widthScale=0.6){
  const pts=[]; const N=26;
  for(let i=0;i<=N;i++){
    const t=i/N;
    let r = A*Math.pow(Math.sin(Math.PI*t),0.62)*(1-0.30*t);
    r = Math.max(r, 0.004);
    pts.push(new THREE.Vector2(r, -L/2 + t*L));
  }
  const g = new THREE.LatheGeometry(pts, 28);
  g.rotateX(-Math.PI/2);
  g.scale(widthScale, heightScale, 1);
  g.computeVertexNormals();
  return g;
}
```
Wait: rotateX(-PI/2) then scale(widthScale, heightScale, 1): after rotation, cross-section axes are X (lateral) and Y (vertical), axis along Z. scale x=widthScale flattens laterally ✓, y=heightScale deepens ✓. But scaling y also scales the nose-to-tail? No, that's z. ✓. However scaling non-uniformly after computing normals: `BufferGeometry.scale` applies matrix to normals too? `applyMatrix4` does transform normals with normalMatrix ✓ (scale method uses applyMatrix4 which updates normals). Then computeVertexNormals anyway.

Then vertex colors:
```js
function paintBody(g, fn){
  const pos=g.attributes.position; const col=new Float32Array(pos.count*3); const c=new THREE.Color();
  for(i){ const z=pos.getZ(i); const t=0.5 - z/L ... }
}
```
Hmm I need L. t along body: nose at z=+L/2 → t=0; tail z=-L/2 → t=1: t = (L/2 - z)/L. Pass L.

Clown bands: map instead.

Tail shape:
```js
function tailShape(){ const s=new THREE.Shape(); s.moveTo(0,0.02); s.lineTo(0.30,0.26); s.lineTo(0.14,0); s.lineTo(0.30,-0.26); s.lineTo(0,-0.02); s.closePath(); }
```
Wait fork: outer tips at (0.30, ±0.26), notch at (0.14,0) — going from root (0,0.02) to upper tip (0.30,0.26) to notch (0.14,0) to lower tip (0.30,-0.26) to root. That gives a forked/caudal shape ✓.

Geometry: `new THREE.ShapeGeometry(s)` then `.rotateY(Math.PI/2)` maps +X→-Z ✓ (so tips go backward -Z) and +Y stays up ✓. But wait: does rotateY(PI/2) map +X → -Z? Earlier: x'=z, z'=-x. Point (1,0,0): x'=0, z'=-1 → (0,0,-1) ✓.

Then mesh positioned in tail group at local origin; tail group at z=-L/2... but the shape root at x=0 → z=0 in group, and body tail tip at z=-L/2. Place group at (0,0,-L/2 + 0.02).

Hmm, body's tail tip is a point at z=-L/2; tail fin root should be slightly before: z = -L/2 + 0.03.

Dorsal fin: shape with x = forward direction: points from rear to front: 
```
moveTo(-0.30, 0.0) // rear base
lineTo(-0.18, 0.20)
lineTo(0.02, 0.26)
lineTo(0.20, 0.14)
lineTo(0.26, 0.02) // front base
```
then rotateY(-PI/2) maps +X→+Z ✓ so x=-0.30 → z=-0.30 (rear) ✓. Then translate y by body top? The shape y from 0 up; base at y=0 means it starts at center line. Body top at mid is A*heightScale ≈ 0.22. So translate mesh y by +0.14 so base sits inside body. Set dorsal mesh position y = 0.12.

Anal fin: same shape scaled 0.6, mirrored (scale.y = -1), position y = -0.10. Mirroring flips normals — use side DoubleSide, and `geometry.scale(1,-1,1)` flips winding; with DoubleSide it's fine.

Pectoral fins: shape: `moveTo(0,0); lineTo(0.20,0.10); lineTo(0.16,-0.06); closePath()` in XY, then rotateX(-PI/2)? Let's think: pectoral fin should extend laterally (±X) and backward (-Z), roughly horizontal. Build shape in XY with +x = outward, +y = backward? Then rotateX(PI/2) maps +Y→+Z... we want +y(backward)→-Z: rotateX(-PI/2): maps (0,1,0)→? θ=-90: y'=y*0 - z*(-1)=z; z'=y*(-1)+z*0=-y. So (0,1,0)→(0,0,-1) ✓ +Y→-Z. And +X→+X ✓. So shape with x=outward, y=backward, rotateX(-PI/2) gives fin extending +X and -Z ✓. Place at (±0.11, -0.02, 0.16) with rotation.y to angle back, and for left fin scale.x=-1.

Good.

Eyes: SphereGeometry(0.045,10,8), material black-ish #0a1012 roughness 0.2, plus tiny highlight sphere? Skip highlight; add emissive slight white dot? Just black eye with a small white sphere (0.014) offset. Keep: eye + white glint for charm — 2 meshes × 6 fish = 12 extra. OK, or skip glint. I'll add glint only via material emissive? Simple: eye material `MeshStandardMaterial({color:0x0a1416, roughness:0.15, metalness:0.1, emissive:0x0a2a30})` plus small white sphere. I'll include white glint (cheap).

Fish group structure:
```
group (position, quaternion)
  bodyMesh
  dorsal, anal, pectoralL, pectoralR
  tailGroup (rotation.y animated) -> tailMesh
  eyeL, eyeR, glintL, glintR
```

Animation per fish: `tailGroup.rotation.y = Math.sin(t*tailFreq + phase) * tailAmp * (0.4 + speed/maxSpeed*0.8)`; also `bodyMesh.rotation.x = Math.sin(t*tailFreq*0.5)*0.03`? Keep tail + slight pectoral flap: pectoralL.rotation.z = sin(t*3+phase)*0.3.

Entrance stagger: fish i delay = 0.09+i*0.07 s; before that opacity 0 and pos.z offset -6.

I'll set `f.appear = 0` and increase; `f.group.visible = f.appear>0`; material opacity = f.appear (need transparent materials).

All materials per fish: I'll create per-fish material instances (6 fish × 2 mats = 12 materials). Fine.

Now sizes/scale: group.scale.setScalar(spec.size). Physics uses world positions; length affects separation radius — set f.radius = 0.5*size.

Bounds: x ∈ [-6.0, 6.0], y ∈ [-1.45, 2.5], z ∈ [-4.2, 3.0].

Substrate at y=-2.0, so min y -1.45 keeps fish above sand ✓.

Water surface at 4.6, max y 2.5 ✓.

Camera: fov 42, z 11.6, y 0.6. Visible x at z=0: ±tan(21°)*11.6*aspect = 4.45*aspect. For 1280×800 aspect=1.6 → ±7.1. So bounds ±6.0 fits nicely ✓.

Now chart bars: 5 bars. I'll show the 5 fish closest to the glass? Just take first 5 fishes, height ∝ (3.75 - dGlass)/3.75 where dGlass = distance from camera plane... define dGlass = camera.z - fish.z (distance along view axis) clamped 0..3.75. Fish near camera (z high) → small distance → tall bar. Baseline at bottom.

Actually camera z=11.6, fish z ∈ [-4.2,3] → distance 8.6..15.8, not 0..3.75. Use `dGlass = 3.75 - (fish.z + 4.2)*?`... Let's define: distance to front glass. Front glass at z=4.5 (tank front). dGlass = 4.5 - fish.z ∈ [1.5, 8.7]. Clamp to 0..3.75 for bar: barH = (1 - clamp(dGlass,0,3.75)/3.75) * 100%. And "fish near the glass" count = fish with dGlass < 2.2. Reasonable and reads as real data ✓.

Now let's write final code. Also handle `document.hidden`? not needed.

Reduced motion: speedScale 0.4; skip intro camera (set z=11.6 immediately, canvas fade in via CSS class); no prism sweep (CSS media query disables animation); CSS disables hover translate.

Keyboard: keydown listener on window. Space → preventDefault + toggle pause. R reset, M sound, Escape → blur active element. Note: iframe keyboard only after click — so also show "Click or press a key to start" ✓ (which we do).

Also `keydown` should hide start overlay.

Pause button: toggle `paused`. When paused: sim dt = 0 but still render, indicator dot changes, readySub shows "SIMULATION PAUSED".

Sound: WebAudio ambient.

Let me write out the audio:
```js
let audioCtx=null, master=null, soundOn=false;
function initAudio(){
  audioCtx = new (window.AudioContext||window.webkitAudioContext)();
  master = audioCtx.createGain(); master.gain.value=0; master.connect(audioCtx.destination);
  // noise buffer
  const len = audioCtx.sampleRate*4; const buf = audioCtx.createBuffer(1,len,audioCtx.sampleRate);
  const d=buf.getChannelData(0); let last=0;
  for(i){ const w=Math.random()*2-1; last=(last+0.02*w)/1.02; d[i]=last*3.5; }
  const src=audioCtx.createBufferSource(); src.buffer=buf; src.loop=true;
  const lp=audioCtx.createBiquadFilter(); lp.type='lowpass'; lp.frequency.value=520;
  src.connect(lp); lp.connect(master); src.start();
  // LFO on filter
  ...
  // bubbles scheduler via setInterval
}
```
Bubble: create oscillator sine freq 400→900 exponential ramp, gain envelope 0.05, 0.12s. Schedule every ~1.5-4s while on.

Toggle: gain 0 → 0.35 ramp.

Careful: AudioContext may be suspended; call resume() on toggle (user gesture) ✓.

Now write final code. Length will be substantial. Let's go.

CSS media queries:

@media (max-width:1024px){ :root{--pad:20px} #speciesRail{width:180px} #rail .p-hab{display:none}? } At 1024, side rails narrow to 180px, hide specimen indices. Let's: `.p-index{display:none}` at ≤1024. And hide leader lines (JS checks width>1024) and hide species labels for non-active? "Species labels are retained only for the active plaque" → at ≤1024, non-active plaques show only name (small), hide meta. I'll hide `.p-meta,.p-hab,.p-depth` for non-active via CSS `@media (max-width:1024px){ .plaque:not(.active) .p-meta{display:none} }`.

@media (max-width:720px): hide #speciesRail, #instruments, #prism, frame extra lines, show #mchip, #minstr; adjust paddings 16px; header wordmark smaller; footer.

@media (max-width:420px): title shows only GLASSHOUSE (hide span.sub), labels 10px.

Let me now write the actual final HTML.

One more: "instrument plate two columns BEHAVIOUR / WATER" — I'll structure right rail as:
```
<div class="plate">
  <div class="plate-row"><span class="lbl">BEHAVIOUR</span><span class="val" id="vBehav">PEACEFUL</span></div>
  ... 
</div>
```
I'll do three plates: BEHAVIOUR (row: PEACEFUL), WATER (25.4°C, pH 8.1, FLOW GENTLE), NEAR GLASS (chart + n/6).

Two-column layout: label left (10px ink3) and value right (10px brass). Values in brass (#d9b66f) as measurement ✓.

Height check for right rail at 800px: top 104 + plate1 (~64) + 12 + plate2 (~86) + 12 + plate3 (~90) = ~370 ✓.

Now, top-left wordmark overlap with frame line at 8%: fine.

Measurement rule at top:76px: left:32px; right: 220px (right rail is 40+164=204 from right → stop 56px short → right: 260px). Set `right:260px`.

Wait spec: "starting 32px beyond the left rail and stopping 56px short of the right rail" → left: 32px (beyond?) Actually "starting 32px beyond the left rail" ambiguous; I'll use left:32px, right:260px.

Below 1024, right:200px.

Now writing. I'll write the code in one go, carefully.

For the demo: also make the fish not all clump at start.

Initial positions:
- gramma: (-3.6, 0.5, 1.2), vel (1.0,0.1,-0.2)
- clown: (2.8, -0.5, 0.6), vel (-0.9,0.05,0.3)
- chromis: (0.6,1.5,-1.6), (1.5,1.1,-2.2), (-0.2,1.8,-1.0)
- tang: (-0.8,-1.0,2.0), vel (0.8,0,-0.3)

Homes = initial positions (with slow drift: home = base + sin offsets).

Let's ensure demo pointer at t=0 (ndc 0, 0.29) → ray from camera (0,0.6,11.6) direction toward (0, 0.6+0.29*4.45=1.89, 0) roughly → passes near chromis at y=1.5,z=-1.6? The ray at z=-1.6: t along ray: from z=11.6 to z=-1.6 is Δz=13.2; direction has dz≈-1 (normalized), dy/dz ratio: dy over full = 1.89-0.6=1.29 over dz 11.6 → at z=-1.6: y = 0.6 + 1.29*(13.2/11.6) = 0.6+1.47=2.07. Chromis at y=1.5 → offset 0.57, x: ray x=0, chromis x=0.6 → dist ~0.8 <1.45 ✓ fleeing. 

Also tang at (-0.8,-1.0,2.0): ray at z=2 → y=0.6+1.29*(9.6/11.6)=1.66; tang y=-1.0 → 2.66 away ✗ not fleeing. Fine — some flee, some don't; that's realistic.

Good. Let's write.

Regarding `pointerActive`: demo sets `ptr = {x,y, active:true, demo:true}`. On real mousemove: `demo=false`. Also on touch: pointerdown/move.

Also need pointer NDC from event: 
```js
const r = renderer.domElement.getBoundingClientRect(); // or window
x = ((e.clientX - rect.left)/rect.width)*2-1; y = -((e.clientY-rect.top)/rect.height)*2+1;
```
Use window since canvas is full viewport.

Touch: prevent default on touchmove to avoid scroll? Body overflow hidden anyway; add `touch-action:none` on canvas.

Let's write the file.

Also `#leaders` SVG needs `pointer-events:none; position:fixed; inset:0; z-index:4`.

Frame draw-in animation:
```css
#frame i{transform:scaleY(0);animation:drawV .9s cubic-bezier(.22,1,.36,1) forwards}
#frame i.h{animation:drawH .9s .2s cubic-bezier(.22,1,.36,1) forwards; transform-origin:left center; transform:scaleX(0)}
```
Careful with two animations on same element; separate classes: vertical lines use `.v` with drawV, `.h` with drawH.

HUD entrance: `.hud{opacity:0;transform:translateY(12px);animation:hudIn .42s cubic-bezier(.22,1,.36,1) forwards}` with delays.

Reduced motion: override animations to simple opacity fade.

Let me finalize code text.

For the chart bars I'll create 5 divs with heights set via JS.

Alright, writing the final answer. I need to be careful about escaping and correctness.

Code sketch (full):

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>Glasshouse / Live Tank 04</title>
<style>...</style>
</head>
<body>
<canvas id="tank"></canvas>
<div id="glow"></div> (glass reflection + vignette)
<div id="frame">...</div>
<svg id="leaders">...</svg>
<header id="topbar" class="hud">...</header>
<div id="measure"></div>
<aside id="speciesRail" class="hud">...</aside>
<aside id="instruments" class="hud">...</aside>
<div id="prism" class="hud">...</div>
<div id="mchip" class="hud">01</div>
<div id="minstr" class="hud">pills</div>
<footer id="bottombar" class="hud">...</footer>
<div id="start">...</div>
<script type="importmap">...</script>
<script type="module">...</script>
</body>
</html>
```

Wait: importmap should ideally be in head before any module; placing in body before the module script is valid.

Let me write CSS fully.

```css
:root{
  --deep:#06151a;--abyss:#030b0e;--water:#0d6f78;--brass:#d9b66f;
  --ink:#edf7f2;--ink2:#91aaa5;--ink3:#5f7773;
  --hair:rgba(183,225,218,.24);--hot:rgba(217,182,111,.42);
  --pad:40px;
  --cond:"Barlow Condensed","Oswald","Arial Narrow","Helvetica Neue",Arial,sans-serif;
  --mono:"IBM Plex Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
}
```

Body copy etc.

I'll write it now for real in the answer. Need to double check JS syntax as I go.

Some specific JS bits:

```js
const composer = (()=>{ try{ const c=new EffectComposer(renderer); c.addPass(new RenderPass(scene,camera)); const b=new UnrealBloomPass(new THREE.Vector2(w,h),0.28,0.7,0.82); c.addPass(b); c.addPass(new OutputPass()); return c;}catch(e){return null;} })();
```
UnrealBloomPass(resolution, strength, radius, threshold).

Render: `composer ? composer.render() : renderer.render(scene,camera)`.

Resize: `composer.setSize(w,h)`.

Renderer size: `renderer.setSize(w,h,false)` with canvas CSS fixed inset 0 w/h 100% — use false to avoid inline styles overriding? setSize with updateStyle false keeps CSS. Actually default true sets style width/height in px which is fine too. I'll use `renderer.setSize(w,h,false)` and rely on CSS.

Now fish step function. Write as method in a Fish class for clarity.

```js
class Fish {
  constructor(def, i){ ... this.pos=new THREE.Vector3(); this.vel=new THREE.Vector3(); this.acc=new THREE.Vector3(); this.home=new THREE.Vector3(); this.phase=Math.random()*10; this.pressure=0; this.inPressure=0; this.appear=0;}
  build(){...}
  update(dt,t,ctx){...}
}
```

ctx contains pointer ray, hold state, cohesionBoost, reduce.

Separation needs all fish → loop over fishes in update (after each update, or do a pre-pass computing positions). Simple double loop inside each fish's update using global `fishes` (positions from this frame, fine).

Let me write update:

```js
update(dt, t, C){
  const a = this.acc.set(0,0,0);
  // home drift
  const hx = this.home.x + Math.sin(t*0.13 + this.phase)*1.1;
  const hy = this.home.y + Math.sin(t*0.17 + this.phase*1.3)*0.45;
  const hz = this.home.z + Math.cos(t*0.11 + this.phase)*0.9;
  const dx=hx-this.pos.x, dy=hy-this.pos.y, dz=hz-this.pos.z;
  const hd=Math.sqrt(dx*dx+dy*dy+dz*dz);
  const pull = hd>0.001 ? Math.min(2.0, hd*1.1) : 0;
  a.x += dx/(hd||1)*pull; ...
  // wander
  a.x += Math.sin(t*0.6+this.phase*2.1)*0.7;
  a.y += Math.sin(t*0.5+this.phase*1.7)*0.35;
  a.z += Math.cos(t*0.55+this.phase)*0.5;
  // bounds
  const B={x:6.0,ymin:-1.45,ymax:2.5,zmin:-4.2,zmax:3.0};
  if(this.pos.x> B.x) a.x -= (this.pos.x-B.x)*6; else if(this.pos.x< -B.x) a.x -= (this.pos.x+B.x)*6;
  ... similarly y,z
  // separation
  for(const o of fishes){ if(o===this) continue; const ox=this.pos.x-o.pos.x,... d2; const minD=this.r+o.r; if(d2<minD*minD && d2>1e-6){ const d=Math.sqrt(d2); const s=(minD-d)/minD*4.0; a.addScaledVector(dir, s);} }
  // school
  if(this.school){ ... }
  // flee
  ...
  // drag brake when holding
  if(C.holding){ a.addScaledVector(this.vel, -5.0); }
  // integrate
  this.vel.addScaledVector(a, dt);
  const sp = this.vel.length(); const max = C.holding? 0.6 : this.maxSpeed*(C.speedScale);
  if(sp>max) this.vel.multiplyScalar(max/sp);
  // gentle drag
  this.vel.multiplyScalar(Math.exp(-dt*0.35));
  this.pos.addScaledVector(this.vel, dt);
}
```

Hmm: with drag exp(-0.35dt) and accel from home pull ~2, terminal speed ≈ 2/0.35 = 5.7 > max → clamps at maxSpeed. That means fish constantly fly at max speed around home. Better: use seek-style steering (accel proportional to desired-vel difference):

```js
const desired = new Vector3(); // sum of normalized influences
// then steer = desired*maxSpeed - vel, clamp to maxForce
```
Classic Reynolds. Let's do that:

```js
desired.set(0,0,0);
// home seek: weight 1 → desired direction toward home scaled by min(1, hd/1.5)
// wander: small vector
// separation: vector
// flee: vector with weight
desired.multiplyScalar(maxSpeed)  // desired velocity
steer = desired - vel; limit to maxForce (e.g. 6.5)
vel += steer*dt
pos += vel*dt
```
That's stable and gives smooth turns. maxForce ~ 7.5 (matches "max impulse 7.5"). Turn rate limit: cap angle change per frame: after computing new velocity direction, limit angle between old dir and new dir to maxTurn*dt (4.8 rad/s). Implement: 
```js
const oldDir = vel normalized (if speed>0.01)
newVel; compute angle; if > maxTurn*dt rotate newDir toward oldDir by the excess.
```
Simpler approach: slerp direction. I'll do:
```js
const speed = this.vel.length();
if(speed>1e-4){
  const d0 = tmpA.copy(this.vel).divideScalar(speed);
  this.vel.addScaledVector(steer, dt);
  const ns = this.vel.length();
  if(ns>1e-4){
    const d1 = tmpB.copy(this.vel).divideScalar(ns);
    const ang = d0.angleTo(d1); const maxA = this.maxTurn*dt;
    if(ang>maxA){
      // rotate d1 toward d0 by (ang-maxA)
      const q = tmpQ.setFromUnitVectors(d1, d0); // rotation taking d1→d0
      // apply partial: axis = cross(d1,d0)
      axis.crossVectors(d1,d0); if(axis.lengthSq()>1e-9){ axis.normalize(); rot.setFromAxisAngle(axis, ang-maxA); d1.applyQuaternion(rot); }
      this.vel.copy(d1).multiplyScalar(ns);
    }
  }
} else { this.vel.addScaledVector(steer, dt); }
```
Good.

Then clamp speed: if(ns > maxSpeed) scale down.

desired computation:

```js
desired.set(0,0,0);
// seek home
const hp = tmpH.set(hx,hy,hz);
const toH = tmp1.subVectors(hp, this.pos); const dH = toH.length();
if(dH>1e-4){ const w = Math.min(1, dH/1.6); desired.addScaledVector(toH.multiplyScalar(1/dH), w*1.0); }
// wander (small)
desired.x += Math.sin(t*0.6+ph)*0.35; desired.y += Math.sin(t*0.47+ph*1.7)*0.2; desired.z += Math.cos(t*0.53+ph)*0.3;
// separation
for each other: if d<sepR: desired.addScaledVector(dir, (sepR-d)/sepR * 1.6)
// school cohesion/alignment for chromis: add (centroid - pos) normalized * 0.5 and avgVel dir * 0.5, scaled by (1+cohesionBoost)
// bounds: desired += push
```
Bounds push: 
```js
const margin=0.9;
if(pos.x > B.x-margin) desired.x += -(pos.x-(B.x-margin))*2.2;
```
Scale: pos.x excess ~1 → 2.2 push, times maxSpeed → decent.

Flee: desired += dir * (strength normalized up to ~2.2) where strength = easedEnv * 2.5 * fleeFactor * smoothstep. Then desired.multiplyScalar(maxSpeed) — that caps desired at maxSpeed*(sum) which could be large; then steer clamp maxForce limits acceleration; speed clamp limits. Let's make flee boost effective max speed: `const speedMul = 1 + pressure*0.35` and maxSpeed = base*speedMul (cap 3.2 global).

I'll compute desired as a direction-ish weighted sum then normalize-ish: if length > 2.6, scale to 2.6 (so non-flee desired gives up to maxSpeed*2.6 → too fast). Hmm. Let's normalize desired to at most 1.6 in length before multiply by maxSpeed? Set: `if(desired.lengthSq()>1) desired.normalize()` — no, that loses relative weights... Actually normalizing to unit is standard: desired velocity = normalize(desired) * maxSpeed. But then home-seek and wander always give full speed → fish always at max speed. Instead: `desiredVel = desired * maxSpeed` where desired is a vector of "preference magnitude" typically ≤1 in the flee case but can exceed. I'll clamp desired magnitude to [0, 1.35]: if len>1.35 scale to 1.35. With flee adding up to ~1.3 alone, and normal forward ~0.5-0.8, so fleeing fish approach 1.35*maxSpeed ≈ capped by global 3.2 ✓. Non-fleeing fish desired ~0.6 → 0.6*maxSpeed ≈ 1.4-1.8 units/s, cruising nicely. But desired direction changes sign... magnitude clamp only for length>1.35.

Also want idle fish not too slow: desired ~0.6-0.9 → speeds 1.2-2.0 ✓.

Also when desired points opposite to velocity (turn), steer = desiredVel - vel has magnitude up to ~2*maxSpeed → clamp to maxForce 7.5 ✓.

maxTurn: gramma 4.8, chromis 5.4? Max yaw 4.8 rad/s — use 4.2 for tang (broad curves) and 4.8 others. Tang: "turns away rather than darting" → give tang a longer reaction (0.16s) and lower flee weight (0.55), maxTurn 2.6.

Orientation:
```js
const spd = this.vel.length();
if(spd>0.05){
  tmpP.copy(this.pos).add(this.vel);
  dummy.position.copy(this.pos);
  dummy.up.set(0,1,0);
  dummy.lookAt(tmpP);
  if(C.snap) this.group.quaternion.copy(dummy.quaternion);
  else this.group.quaternion.slerp(dummy.quaternion, 1-Math.exp(-dt*8));
}
this.group.position.copy(this.pos);
```
Note: dummy.lookAt for non-camera objects points +Z at target ✓.

But wait — `Object3D.lookAt` for a plain Object3D orients +Z toward the target. Yes (it uses `m.lookAt(eye,target,up)` producing rotation where -Z looks at target for cameras; for objects it's implemented as `this.quaternion.setFromRotationMatrix(m1.lookAt(target, this.position, this.up))` — Matrix4.lookAt(eye,z,up) builds a matrix with z = normalize(eye - target). For Object3D: `if (this.isCamera || this.isLight) _m1.lookAt(_position, _target, this.up); else _m1.lookAt(_target, _position, this.up);` → for regular objects: z = normalize(_target - _position)?? Matrix4.lookAt(eye, target, up): z = eye - target normalized. With eye=_target, target=_position → z = _target - _position = direction toward target. And the matrix columns are basis vectors with z as third column → +Z points toward target ✓. 

Banking: add slight roll: `this.group.rotateZ(...)` after slerp? Skip, or add small roll based on turn rate. I'll add a subtle roll: store `this.roll` lerped toward `-turnRate*0.12`. Compute turn rate as angle between old and new dir / dt. Might be nice but adds complexity; skip.

Fins animation: tailGroup.rotation.y = sin(t*tailFreq + phase)*amp where tailFreq = 6 + speed*2.5, amp = 0.22 + speed*0.1.

Now update HUD elements at intervals (240ms) using an accumulator.

Species active & leader: compute each frame (cheap) or at 80ms.

Let's write leader update:
```js
function updateLeader(){
  const f = activeFish; // nearest pressured fish or null
  if(!f || window.innerWidth<=1024 || paused? ) { leadG.classList.add('off'); return; }
  const plaque = plaques[f.specIndex]; const r = plaque.getBoundingClientRect();
  const sx = r.right + 6, sy = r.top + 18;
  // project fish
  tmpP.copy(f.pos).project(camera);
  if(tmpP.z>1) hide;
  const fx=(tmpP.x*0.5+0.5)*W, fy=(-tmpP.y*0.5+0.5)*H;
  line.setAttribute('x1',sx)... 
  // midpoint elbow? straight line fine
  // diamond at (fx,fy)
  // text at midpoint above line: `${(f.pressureDist/1.45).toFixed(2)} m`
}
```
Show only when `f.pressure>0.05` (i.e., near glass) → matches "leader line appears when fish proximity is active".

OK. Also the plaque notch: `.plaque.active::before` a small 8px brass notch at left. Fine.

Let's assemble everything. I'll write the final code now.

Regarding the `#glow` glass reflection: use two layered gradients + vignette:
```css
#glow{position:fixed;inset:0;pointer-events:none;z-index:1;
 background:
  linear-gradient(112deg, transparent 26%, rgba(205,246,240,.055) 34%, transparent 41%),
  linear-gradient(112deg, transparent 58%, rgba(205,246,240,.04) 64%, transparent 70%),
  radial-gradient(120% 80% at 50% 40%, transparent 40%, rgba(3,11,14,.55) 100%);
}
```
Legibility washes top/bottom: add to #glow? A single top gradient rgba(3,11,14,.72)→transparent max-height 72px, bottom same for footer. I'll add separate fixed divs `.wash.top` and `.wash.bottom` z-index 3 (below HUD z 5, above frame z2).

Footer wash: same gradient bottom, 72px.

Careful: HUD at z 5, washes z 3 ✓.

Now, writing everything out. Also add `#scanline`? no.

Final check on the "start" overlay: pointer-events none, and it must not block screenshot visibility — it's a small pill.

Let me write the answer.

For the mobile instrument pills `#minstr`: two pills: `25.4°C` and `FLOW GENTLE`, positioned bottom:64px, centered, hidden on desktop.

Mobile chip `#mchip`: top:76px; right:16px; 40px square with border, text "01" brass, plus label under? keep just index + tiny species name to the left of chip? Add `#mchip` as a pill with `01 · CHROMIS`. Spec says "40px square top-right species chip" — square with index only. I'll do square 40px with index, and beneath it (or beside) a 10px species name. Keep: square with index; species name in the footer instruction area? Simpler: square chip containing index; species name shown in the mobile bottom strip next to instruction? Let's put species name under the chip: `#mchip` is a column: square + name (10px, right aligned). Fine.

Now write.

One concern: `overflow:hidden` on body prevents scrolling ✓.

Another: canvas must fill viewport with `position:fixed;inset:0`.

Write the JS carefully with no syntax errors. Let me draft the module fully.

```js
import * as THREE from 'three';
import {EffectComposer} from 'three/addons/postprocessing/EffectComposer.js';
import {RenderPass} from 'three/addons/postprocessing/RenderPass.js';
import {UnrealBloomPass} from 'three/addons/postprocessing/UnrealBloomPass.js';
import {OutputPass} from 'three/addons/postprocessing/OutputPass.js';

const RM = matchMedia('(prefers-reduced-motion: reduce)').matches;
const $ = s => document.querySelector(s);
const W = () => document.documentElement.clientWidth;
const H = () => document.documentElement.clientHeight;

// ---------- textures ----------
function makeTex(size, painter, rep=[1,1]){
  const c=document.createElement('canvas'); c.width=c.height=size;
  const x=c.getContext('2d'); painter(x,size);
  const t=new THREE.CanvasTexture(c);
  t.wrapS=t.wrapT=THREE.RepeatWrapping; t.repeat.set(rep[0],rep[1]);
  t.colorSpace = THREE.SRGBColorSpace; // for color maps; for bump, no colorSpace
  t.anisotropy=4;
  return t;
}
```
For bump maps, colorSpace should be NoColorSpace (default is NoColorSpace for CanvasTexture? Default `texture.colorSpace = NoColorSpace` in r152+? Actually default is `THREE.NoColorSpace` (EmptyColorSpace). So set SRGB only for color maps. I'll add a param.

Caustic used as additive light map → treat as color (SRGB) roughly; not critical.

Painters:

caustic:
```js
function paintCaustic(x,S){
  const img=x.createImageData(S,S); const d=img.data;
  for(let y=0;y<S;y++)for(let i=0;i<S;i++){
    const nx=i*0.11, ny=y*0.11;
    const n=(Math.sin(nx+Math.sin(ny*0.9)*1.8)+Math.sin(ny*1.15+Math.sin(nx*0.75)*1.9)+Math.sin((nx+ny)*0.6))/3;
    let v=Math.pow(Math.max(0,1-Math.abs(n)*2.6),2.4);
    const n2=(Math.sin(nx*2.1+ny*0.5)+Math.sin(ny*2.3+nx*0.3))/2;
    let v2=Math.pow(Math.max(0,1-Math.abs(n2)*3.0),3.0);
    const g=Math.min(1,v*0.85+v2*0.45);
    const k=(y*S+i)*4;
    d[k]=g*170; d[k+1]=g*225; d[k+2]=g*255; d[k+3]=255;
  }
  x.putImageData(img,0,0);
}
```

scale bump:
```js
function paintScales(x,S){
  x.fillStyle='#808080'; x.fillRect(0,0,S,S);
  const r=8;
  for(let row=0; row<S/r+2; row++){
    for(let col=0; col<S/r+2; col++){
      const cx=col*r+(row%2?r/2:0), cy=row*r;
      const grd=x.createRadialGradient(cx,cy-r*0.35,1,cx,cy,r);
      grd.addColorStop(0,'rgba(255,255,255,0.55)');
      grd.addColorStop(0.7,'rgba(128,128,128,0)');
      grd.addColorStop(1,'rgba(0,0,0,0.5)');
      x.fillStyle=grd; x.beginPath(); x.arc(cx,cy,r,0,7); x.fill();
    }
  }
}
```

sand:
```js
function paintSand(x,S){
  x.fillStyle='#24332f'; x.fillRect(0,0,S,S);
  for(let i=0;i<9000;i++){
    const px=Math.random()*S, py=Math.random()*S, r=Math.random()*1.8+0.3;
    x.fillStyle= Math.random()>0.55? 'rgba(150,165,150,'+(0.06+Math.random()*0.22)+')' : 'rgba(6,18,20,'+(0.05+Math.random()*0.2)+')';
    x.beginPath(); x.arc(px,py,r,0,7); x.fill();
  }
}
```

clown bands:
```js
function paintClown(x,S){
  const g=x.createLinearGradient(0,0,0,S); // v: canvas y=S → v=0 (nose) because flipY
  g.addColorStop(0,'#e0561f'); g.addColorStop(1,'#ff8a34'; )
  x.fillStyle=g; x.fillRect(0,0,S,S);
  // bands at v → canvas y = (1-v)*S
  const bands=[0.16,0.47,0.80];
  bands.forEach(v=>{
    const y=(1-v)*S, h=S*0.055;
    x.fillStyle='#14100e'; x.fillRect(0,y-h*0.9,S,h*1.8);
    x.fillStyle='#f6f1e6'; x.fillRect(0,y-h*0.65,S,h*1.3);
  });
}
```
Hmm black outline larger than white → gives black-edged white bands ✓.

Wait flipY: with flipY=true, the image is flipped vertically when uploaded, so canvas top row ends up at v=1. So canvas y=0 → v=1 (tail), y=S → v=0 (nose). So v → canvas y = (1-v)*S ✓ as I wrote.

Gramma gradient: I'll use vertex colors, map = white/neutral with scales bump only. Set `map` null and use vertexColors ✓.

Chromis: color base with slight map? Use vertexColors gradient from #8fd9c8 to #d7f0c2? blue-green chromis: pale green-blue with lighter belly. Use color function: top (y>0) more blue-green, belly lighter. Vertex colors fn gets position → use y too. Nice: `fn(x,y,z)`.

Let me define colorFns:
- gramma: by t: t<0.42 → mix(#4b2b8f, #a83f8e, t/0.42); else mix(#c94f7a, #f4c542, (t-0.42)/0.58)
- clown: solid #ff7a2f (map does bands) — vertexColors false; color #ffffff with map.
- chromis: base mix by t: head #7fd0c0 → tail #bfe6a8; plus belly lighten: if y<0 mix toward #eaf6da by 0.35.
- tang: solid #ffcf3a with slight gradient: t 0→1 mix #ffd84a → #f2b41f.

I'll implement a `paint(g, L, fn)` that computes t from z.

Now materials per fish: create `bodyMat` and `finMat` (fin color = species fin color, e.g., gramma fins purple-ish translucent, clown orange, chromis pale, tang yellow).

Entrance fade: set `mat.transparent = true; mat.opacity = 0` initially; update opacity = appear. After appear=1, could set transparent=false for perf but leave it.

Note: transparent bodies with depthWrite true — ok.

Let's write `buildFish(def)` returning group + refs.

Bubbles/particles: 
```js
const pTex = makeTex(64, (x,S)=>{ const g=x.createRadialGradient(S/2,S/2,0,S/2,S/2,S/2); g.addColorStop(0,'rgba(255,255,255,1)'); g.addColorStop(0.35,'rgba(200,240,235,0.55)'); g.addColorStop(1,'rgba(200,240,235,0)'); x.fillStyle=g; x.fillRect(0,0,S,S); }, [1,1]);
```
Set colorSpace SRGB.

Particles: BufferGeometry with 200 positions in box x±9,y -2..4,z -6..4. PointsMaterial({size:0.06, map:pTex, transparent:true, opacity:0.5, depthWrite:false, blending:AdditiveBlending, color:0x9fe8dd, sizeAttenuation:true}).

Update: y += dt*0.06; wrap.

Bubbles: 30 points size 0.09, speed 0.35, reset to y=-1.9 when >4.4, with x jitter. Use same texture but larger size and opacity 0.35.

Rocks: InstancedMesh(DodecahedronGeometry(1,0), MeshStandardMaterial({color:0x16262a, roughness:0.95, metalness:0.05, flatShading:true}), 46) scattered z from -6..3, y=-2.05, scale 0.5-1.6 (non-uniform y*0.6).

Pebbles: InstancedMesh(IcosahedronGeometry(1,0), same material-ish lighter, 134) scale 0.05-0.16.

Plants: 12 blades: ShapeGeometry of a leaf (tapered), material color #2f6b52, DoubleSide, roughness 0.8. Position on substrate near rocks. Animate rotation.z.

Total meshes ≈ 46+134+12+... instanced counts as 2 meshes; the "180 procedural meshes" is about instances. Fine.

Coral: 5 clusters of small cones/branches color #ff6f61 at low opacity? Coral color used for fish bodies and soft coral highlights — so coral can be coral-colored ✓. Build: for each cluster, 7 cylinders (radius 0.03, height 0.5) rotated randomly, color #c7574d with emissive #ff6f61*0.15. Use InstancedMesh of cylinders with 40 instances? Simpler: a few groups with 6 cylinders each = 30 meshes. Acceptable.

Let's reduce: 4 coral clusters × 6 = 24 meshes. Total draw calls: 6 fish × ~9 = 54 + 24 + 12 + backdrop + sand + caustics + water + particles(2) + instanced(2) ≈ 100. Fine.

Actually fish meshes: body, dorsal, anal, pectL, pectR, tail, eyeL, eyeR, glintL, glintR = 10 → 60. OK, ~100 draw calls, fine.

Postprocessing bloom threshold 0.85 strength 0.3 → subtle glow on bright spots.

Now the tick: `clock = new THREE.Clock()`; in animate: `dt = Math.min(clock.getDelta(), 0.05)`. simTime += dt * speedScale (speedScale 0.4 if RM, 0 if paused).

If paused: dt_sim = 0 but keep animating caustics? Pause = freeze simulation; keep ambient minimal. I'll freeze everything except HUD.

Camera intro: introT += dt_real; z = 13.8 + (11.6-13.8)*ease(min(1,introT/1.8)). If RM: z=11.6 immediately and fade canvas (CSS class on body 'fadein').

Parallax: `camX = ptr.x*0.65; camY = 0.6 + ptr.y*0.45;` lerp toward with factor 1-exp(-dt*3). Then camera.lookAt(0,0.05,0).

Careful: changing camera position changes ray → fine, raycaster uses camera each frame ✓.

HUD coordinate readout: from ptr (demo or real).

Let's write the code for status text updates.

Species list in JS to drive plaques:
```js
const SPECS = [
 {name:'ROYAL GRAMMA', hab:'CARIBBEAN REEF · MIDWATER', depth:'1.8 m', behav:'SHY'},
 {name:'OCELLARIS CLOWNFISH', hab:'ANEMONE EDGE · LOW SURGE', depth:'1.2 m', behav:'PEACEFUL'},
 {name:'BLUE-GREEN CHROMIS', hab:'OPEN WATER · SCHOOLING', depth:'2.4 m', behav:'SHY'},
 {name:'YELLOW TANG', hab:'REEF FLAT · GRAZING', depth:'0.9 m', behav:'NEUTRAL'},
];
```
Behaviour value updates with active species ✓ (nice interactivity: BEHAVIOUR shows active species' behaviour).

HTML plaques generated by JS? Static HTML is safer for first paint. I'll write static HTML for plaques and query them.

OK, writing final answer now. Long but let's go.

For the chart bars HTML: 
```html
<div class="chart" id="chart"><i></i>×5</div>
```
with CSS: `display:flex;align-items:flex-end;gap:6px;height:44px;border-bottom:1px solid rgba(183,225,218,.18)` and `i{flex:1;background:var(--brass);opacity:.75;height:20%;transition:height .24s linear}`. Plus 5 ticks: add a tick column? "5 vertical measurement ticks scale from 0 to 3.75 units" — I'll add tick labels row: `0 · 3.75 m`. Keep simple: chart with 5 bars + a baseline + caption "NEAR GLASS 4 / 6".

Let's write it.

Also accessibility: `<main>`, aria-labels, sr-only text.

Alright — final code.

I also need `#measure` rule div.

Let me write out CSS fully now (mentally final) and then JS.

I'll now produce the answer.

Double-check: no `alert`, no localStorage, no window.open ✓.

Font: no external fonts ✓.

Let me write the JS HUD update function:

```js
const hud = {
  behav: $('#vBehav'), temp: $('#vTemp'), ph: $('#vPh'), flow: $('#vFlow'),
  glass: $('#vGlass'), coords: $('#vCoords'), readySub: $('#readySub'), dot: $('#dot'),
  temp...
};
let hudAcc = 0;
function updateHUD(dt){
  hudAcc += dt; if(hudAcc < 0.24) return; hudAcc = 0;
  const near = fishes.filter(f => (4.5 - f.pos.z) < 2.4).length;
  hud.glass.textContent = near + ' / ' + fishes.length;
  const bars = fishes.slice(0,5);
  bars.forEach((f,i)=>{ const d=Math.max(0,Math.min(3.75, 4.5-f.pos.z)); const h=(1-d/3.75); chartBars[i].style.height = (8+h*92)+'%'; });
  hud.temp.textContent = (25.4 + Math.sin(simTime*0.12)*0.12).toFixed(1)+'°C';
  hud.coords.textContent = 'X '+fmt(ptr.x)+' / Y '+fmt(ptr.y);
  ...
}
```
fmt: `(v>=0?'+':'')+v.toFixed(2)`.

Chart bar heights should be stable-ish (0.24s transitions).

Active species & behaviour: computed each frame; update DOM only when changed.

Let's finalize `activeIdx`: 
```js
let nearest = null, nearestD = 1.6;
for f: compute d to ray; if d<nearestD → nearest
activeIdx = nearest ? nearest.spec : activeIdx (keep last)
pressureActive = nearest && nearestD < 1.45 && !holdActive
```

Also add a "species cue" brass highlight: when pressured, add class 'near' to the plaque (brass index + notch).

OK writing now. I'll be disciplined.

The right rail "two columns BEHAVIOUR / WATER": I'll label plates with headers.

Let me write the HTML body markup fully:

```html
<canvas id="tank"></canvas>
<div id="glow"></div>
<div class="wash wtop"></div><div class="wash wbot"></div>
<div id="frame">
  <i class="v" style="left:8%"></i>
  <i class="v" style="left:25%"></i>
  <i class="v" style="left:50%"></i>
  <i class="v" style="left:75%"></i>
  <i class="v" style="left:92%"></i>
  <i class="h"></i>
</div>
<svg id="leaders" aria-hidden="true"><g id="leadG" class="off">
  <line id="leadL" x1="0" y1="0" x2="0" y2="0"/>
  <polygon id="leadD" points="0,0"/>
  <text id="leadT" x="0" y="0">0.00 m</text>
</g></svg>
<div id="measure"></div>
<header id="topbar" class="hud in1">...</header>
...
```

The `.hud` entrance animation delays: in1..in5 classes with animation-delay.

CSS:
```css
.hud{opacity:0;transform:translateY(12px);animation:hudIn .42s cubic-bezier(.22,1,.36,1) forwards}
.in1{animation-delay:.09s}.in2{animation-delay:.16s}.in3{animation-delay:.24s}.in4{animation-delay:.32s}.in5{animation-delay:.38s}
```
Rails translateY(-12px) for top elements, +12 for bottom: use `.hudUp` vs `.hudDown`. Simplify: all use translateY(10px) except top ones translateY(-10px) via `.topdown`. I'll define two animations: hudIn (from +12) and hudInUp (from -12). Assign.

Under reduced motion: `@media (prefers-reduced-motion:reduce){ .hud{animation:none;opacity:1;transform:none} #frame i{animation:none;transform:none} ... }`.

Also `@media (max-width:720px)` overrides.

Alright, writing the final answer. I'll keep JS organized in sections with comments.

One more detail: `#start` overlay click handling — window 'click' listener hides it. Also pointerdown. And keydown.

Also on `pointermove` real → hide start, set demo=false.

Instruction pulse: add class 'hot' then setTimeout remove 500ms.

Let me write everything.

Estimated code ~1100 lines. That's fine.

Careful with template literal usage and quotes.

Final: write the answer.