diff --git a/examples/connectome-fly/ui/index.html b/examples/connectome-fly/ui/index.html
index afd45b56..2d467653 100644
--- a/examples/connectome-fly/ui/index.html
+++ b/examples/connectome-fly/ui/index.html
@@ -121,6 +121,10 @@
diff --git a/examples/connectome-fly/ui/src/main.js b/examples/connectome-fly/ui/src/main.js
index 89c03a5e..39bcc8dd 100644
--- a/examples/connectome-fly/ui/src/main.js
+++ b/examples/connectome-fly/ui/src/main.js
@@ -28,4 +28,5 @@ import './modules/scene.js';
import './modules/dynamics.js';
import './modules/fly.js';
import './modules/actions.js';
+import './modules/fly-sim.js';
import './modules/welcome.js';
diff --git a/examples/connectome-fly/ui/src/modules/fly-sim.js b/examples/connectome-fly/ui/src/modules/fly-sim.js
new file mode 100644
index 00000000..259aa108
--- /dev/null
+++ b/examples/connectome-fly/ui/src/modules/fly-sim.js
@@ -0,0 +1,204 @@
+// Connectome OS — Dedicated Fly Simulation view.
+//
+// A standalone view (data-view="fly-sim") that reuses the procedural
+// 3D fly in `fly.js` and wires it to the live spike data from
+// `dynamics.js`. The view is a persistent full-canvas overlay inside
+// .canvas-wrap; visibility is toggled when the rail item is activated,
+// so the Three.js renderer stays warm and doesn't re-initialise on
+// every view switch.
+//
+// Live-data mapping:
+// sensory in → sensory-burst pulse into FlyScene (antennae / eyes)
+// motor out → wing-beat + leg-step frequency (derived from spike rate)
+// fiedler → global body tint (coherent green ↔ fragmenting amber)
+//
+// When the SSE stream isn't connected and the JS mock is driving the
+// raster, the same window._fiedler / window._real_spikes_total globals
+// still update — so this view works on GitHub Pages too.
+
+(function () {
+ const W = window;
+
+ function ensureHost() {
+ let host = document.getElementById('fly-sim-root');
+ if (host) return host;
+ const wrap = document.querySelector('.canvas-wrap');
+ if (!wrap) return null;
+ host = document.createElement('div');
+ host.id = 'fly-sim-root';
+ host.className = 'fly-sim-root';
+ host.innerHTML = `
+
+
Fly simulation · real-time embodiment
+
+ Normal
+ Saturated
+ Fragmenting
+
+
+
+
+
+
+
live source
+
–
+
"real" = Rust backend · "mock" = JS fallback
+
+
+
+
spikes total
+
0
+
counter on the live engine
+
+
+
spike rate (1 s window)
+
0 sp/s
+
+
+
+
+
fiedler λ₂
+
–
+
coherence-collapse detector
+
+
+ The fly body is procedural, not a render of a real fly —
+ it translates live spike rates into motor behaviour (wing
+ beat, leg tripod, antenna twitch) so you can see the engine
+ driving the body in real time.
+
+
+
+ `;
+ wrap.appendChild(host);
+ return host;
+ }
+
+ function mount() {
+ const host = ensureHost();
+ if (!host) return;
+ const stage = host.querySelector('#fly-sim-stage');
+
+ // Lazy-create the FlyScene when the view is first shown, so the
+ // Three.js renderer + WebGL context aren't allocated until needed.
+ let fly = null;
+ function ensureFly() {
+ if (!fly && W.FlyScene && stage) {
+ fly = W.FlyScene.create(stage);
+ }
+ return fly;
+ }
+
+ function setVisible(on) {
+ host.classList.toggle('active', !!on);
+ if (on) {
+ ensureFly();
+ // If a FlyScene's internal resize hook exists, trigger it.
+ W.dispatchEvent(new Event('resize'));
+ }
+ }
+
+ // Rail activation — hook after nav.js has wired its clicks.
+ document.querySelectorAll('[data-view="fly-sim"]').forEach((el) => {
+ el.addEventListener('click', () => setVisible(true));
+ });
+ document.querySelectorAll('.rail-item[data-view], .m-nav .item[data-view]').forEach((el) => {
+ el.addEventListener('click', () => {
+ if (el.dataset.view !== 'fly-sim') setVisible(false);
+ });
+ });
+
+ // Scenario pills — forward to the dynamics module (mock worker path).
+ host.querySelectorAll('[data-fly-scenario]').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ const name = btn.dataset.flyScenario;
+ host.querySelectorAll('[data-fly-scenario]').forEach((b) =>
+ b.classList.toggle('active', b === btn)
+ );
+ W.Dynamics?.setScenario?.(name);
+ });
+ });
+
+ // Live-readout update loop — reads the same globals dynamics.js
+ // writes (window._real_spikes_total, _fiedler, _tick, _sim_ms).
+ let prevTotal = 0;
+ let prevT = performance.now();
+ let rateHz = 0;
+ let wingHz = 0;
+ function tick() {
+ const total = W._real_spikes_total || 0;
+ const now = performance.now();
+ const dt = Math.max(1e-3, (now - prevT) / 1000);
+ if (dt >= 0.25) {
+ const delta = Math.max(0, total - prevTotal);
+ rateHz = delta / dt;
+ prevTotal = total;
+ prevT = now;
+ // Wing-beat proxy: map spike-rate log to ~0–220 Hz.
+ wingHz = Math.min(220, 30 + Math.log1p(rateHz) * 18);
+ if (fly?.setWingHz) fly.setWingHz(wingHz);
+ // Sensory burst proxy: sudden jumps in rate drive antennae.
+ const burst = Math.min(1, Math.log1p(delta) / 10);
+ fly?.setSensoryBurst?.(burst);
+ }
+ const srcEl = host.querySelector('#fs-src');
+ if (srcEl) {
+ const src = W._source || (W.Dynamics?.isMock?.() ? 'mock' : 'pending');
+ srcEl.textContent = src;
+ srcEl.dataset.src = src;
+ }
+ const clockEl = host.querySelector('#fs-clock');
+ if (clockEl) {
+ const t = W._sim_ms ?? W._tick ?? 0;
+ clockEl.textContent = Number(t).toLocaleString() + ' ms';
+ }
+ const clockBar = host.querySelector('#fs-clock-bar');
+ if (clockBar) {
+ const pct = Math.min(100, ((W._tick || 0) % 10_000) / 100);
+ clockBar.style.width = pct.toFixed(0) + '%';
+ }
+ const spEl = host.querySelector('#fs-spikes');
+ if (spEl) spEl.textContent = total.toLocaleString();
+ const rateEl = host.querySelector('#fs-rate');
+ if (rateEl) rateEl.innerHTML = Math.round(rateHz).toLocaleString() + '
sp/s ';
+ const rateBar = host.querySelector('#fs-rate-bar');
+ if (rateBar) {
+ const pct = Math.min(100, Math.log1p(rateHz) * 10);
+ rateBar.style.width = pct.toFixed(0) + '%';
+ }
+ const wingEl = host.querySelector('#fs-wing');
+ if (wingEl) wingEl.innerHTML = Math.round(wingHz) + '
Hz ';
+ const wingBar = host.querySelector('#fs-wing-bar');
+ if (wingBar) wingBar.style.width = Math.min(100, (wingHz / 220) * 100).toFixed(0) + '%';
+ const fEl = host.querySelector('#fs-fiedler');
+ const fiedler = W._fiedler;
+ if (fEl) {
+ fEl.textContent = Number.isFinite(fiedler) ? fiedler.toFixed(3) : '–';
+ }
+ const fHint = host.querySelector('#fs-fiedler-hint');
+ if (fHint && Number.isFinite(fiedler)) {
+ fHint.textContent = fiedler < 0.18
+ ? 'fragmenting — below 0.18 collapse threshold'
+ : fiedler < 0.3
+ ? 'drifting — monitor'
+ : 'stable — coherent co-firing';
+ }
+ requestAnimationFrame(tick);
+ }
+ requestAnimationFrame(tick);
+ }
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', mount);
+ } else {
+ mount();
+ }
+})();
diff --git a/examples/connectome-fly/ui/src/modules/fly.js b/examples/connectome-fly/ui/src/modules/fly.js
index 1573b4b6..160da0cb 100644
--- a/examples/connectome-fly/ui/src/modules/fly.js
+++ b/examples/connectome-fly/ui/src/modules/fly.js
@@ -390,6 +390,8 @@
resize,
reset: () => { userInteracted = false; targetRadius = 5.8; targetEl = 0.38; },
setSensoryBurst: (v) => { stats.sensoryBurst = v; },
+ setWingHz: (hz) => { if (Number.isFinite(hz)) stats.wingHz = Math.max(20, Math.min(260, hz)); },
+ setStepHz: (hz) => { if (Number.isFinite(hz)) stats.stepHz = Math.max(1, Math.min(120, hz)); },
dispose: () => {
running = false;
renderer.dispose();
diff --git a/examples/connectome-fly/ui/src/styles/views.css b/examples/connectome-fly/ui/src/styles/views.css
index 3a4014f3..cd2714ad 100644
--- a/examples/connectome-fly/ui/src/styles/views.css
+++ b/examples/connectome-fly/ui/src/styles/views.css
@@ -412,3 +412,136 @@
.canvas-wrap:hover .nav-hint { opacity: 0.9; }
.canvas-wrap.view-content-active:not(.embodiment) .nav-hint { opacity: 0.15; }
@media (max-width: 1100px) { .nav-hint { display: none; } }
+
+/* =========================================================
+ * FLY SIMULATION view (data-view="fly-sim")
+ * --------------------------------------------------------- */
+
+.fly-sim-root {
+ position: absolute; inset: 0;
+ display: none;
+ flex-direction: column;
+ background: radial-gradient(ellipse at top, rgba(10, 18, 26, 0.92), rgba(4, 8, 12, 0.98));
+ z-index: 5;
+}
+.fly-sim-root.active { display: flex; }
+
+.fs-head {
+ display: flex; align-items: center; gap: 14px;
+ padding: 12px 18px;
+ border-bottom: 1px solid var(--bd-hair, rgba(255,255,255,0.06));
+ background: linear-gradient(180deg, rgba(10, 18, 26, 0.9), rgba(10, 18, 26, 0));
+}
+.fs-title {
+ font-family: 'Space Grotesk', var(--ff-sans, system-ui), sans-serif;
+ font-weight: 600;
+ font-size: 15px;
+ color: var(--fg, #e7edf3);
+}
+.fs-title .fs-sub {
+ color: var(--fg-3, #7f8a95);
+ font-weight: 400;
+ margin-left: 4px;
+}
+
+.fs-scenarios {
+ margin-left: auto;
+ display: flex; gap: 4px;
+ background: rgba(255,255,255,0.03);
+ border: 1px solid var(--bd-hair, rgba(255,255,255,0.06));
+ border-radius: 999px;
+ padding: 3px;
+}
+.fs-pill {
+ background: transparent;
+ border: 0;
+ color: var(--fg-2, #a9b4be);
+ font-family: var(--ff-mono, monospace);
+ font-size: 11px; letter-spacing: 0.06em;
+ padding: 5px 12px;
+ border-radius: 999px;
+ cursor: pointer;
+ transition: background 120ms ease, color 120ms ease;
+}
+.fs-pill:hover { color: var(--fg, #e7edf3); }
+.fs-pill.active {
+ background: rgba(184, 255, 60, 0.12);
+ color: var(--signal, #b8ff3c);
+}
+
+.fs-body {
+ flex: 1;
+ display: grid;
+ grid-template-columns: 1fr 280px;
+ min-height: 0;
+}
+.fs-stage {
+ position: relative;
+ min-height: 0;
+ background: radial-gradient(ellipse at center, rgba(184, 255, 60, 0.04), transparent 60%),
+ radial-gradient(ellipse at center, rgba(10, 18, 26, 0.2), transparent 70%);
+}
+.fs-stage canvas { display: block; width: 100%; height: 100%; }
+
+.fs-side {
+ border-left: 1px solid var(--bd-hair, rgba(255,255,255,0.06));
+ padding: 14px;
+ overflow-y: auto;
+ display: flex; flex-direction: column; gap: 10px;
+ background: rgba(6, 12, 18, 0.5);
+}
+.fs-card {
+ background: rgba(255, 255, 255, 0.02);
+ border: 1px solid var(--bd-hair, rgba(255,255,255,0.05));
+ border-radius: 8px;
+ padding: 10px 12px;
+ font-family: var(--ff-sans, system-ui), sans-serif;
+}
+.fs-card-dim {
+ color: var(--fg-3, #7f8a95);
+ font-size: 12px; line-height: 1.55;
+}
+.fs-k {
+ font-family: var(--ff-mono, monospace);
+ font-size: 10px; letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: var(--fg-3, #7f8a95);
+}
+.fs-v {
+ font-family: 'Space Grotesk', var(--ff-sans, system-ui), sans-serif;
+ font-weight: 600;
+ font-size: 18px;
+ color: var(--fg, #e7edf3);
+ margin-top: 4px;
+}
+.fs-v em {
+ font-style: normal; font-weight: 400;
+ font-size: 12px;
+ color: var(--fg-3, #7f8a95);
+}
+.fs-hint {
+ margin-top: 6px;
+ font-size: 11px;
+ color: var(--fg-3, #7f8a95);
+}
+.fs-bar {
+ height: 4px;
+ background: rgba(255,255,255,0.05);
+ border-radius: 2px;
+ margin-top: 8px;
+ overflow: hidden;
+}
+.fs-bar i {
+ display: block;
+ height: 100%;
+ background: linear-gradient(90deg, var(--signal, #b8ff3c), rgba(184, 255, 60, 0.35));
+ transition: width 200ms ease;
+}
+#fs-src[data-src="real"] { color: var(--signal, #b8ff3c); }
+#fs-src[data-src="mock"] { color: var(--amber, #f6c445); }
+#fs-src[data-src="pending"] { color: var(--fg-3, #7f8a95); }
+
+@media (max-width: 720px) {
+ .fs-body { grid-template-columns: 1fr; grid-template-rows: 1fr auto; }
+ .fs-side { max-height: 40vh; border-left: 0; border-top: 1px solid var(--bd-hair, rgba(255,255,255,0.06)); }
+}