diff --git a/src/frontend/App.jsx b/src/frontend/App.jsx
new file mode 100644
index 0000000..0da2cf5
--- /dev/null
+++ b/src/frontend/App.jsx
@@ -0,0 +1,176 @@
+import React, { useEffect, useRef } from 'react';
+import { Canvas } from '@react-three/fiber';
+import { OrbitControls } from '@react-three/drei';
+import * as THREE from 'three';
+import useStore from './store.js';
+import wsClient from './WebSocketClient.js';
+import OfficeScene from './OfficeScene.jsx';
+
+// ── Camera setup for true isometric ─────────────────────────────
+// Orthographic with rotation: 45° around Y, then ~35.264° tilt down (atan(1/√2))
+// But the React-idiomatic way: position the camera and use lookAt
+// Simpler: use orbit controls locked to isometric-ish angle
+function CameraSetup() {
+ // We use OrbitControls with limited rotation for a fixed-ish isometric feel
+ return null; // Controls are in the Canvas
+}
+
+// ── UI Overlay ──────────────────────────────────────────────────
+function UIOverlay() {
+ const agents = useStore(s => s.agents);
+ const connected = useStore(s => s.wsConnected);
+
+ return (
+
+ Spatial Office
+ |
+
+
+ {connected ? 'Live' : 'Offline'}
+
+ |
+ {Object.keys(agents).length} agents
+
+ );
+}
+
+// ── App ─────────────────────────────────────────────────────────
+export default function App() {
+ const hasConnected = useRef(false);
+
+ useEffect(() => {
+ if (hasConnected.current) return;
+ hasConnected.current = true;
+
+ // Try to connect to WebSocket backend
+ wsClient.connect();
+
+ // After 2 seconds, if no backend, start mock mode
+ const mockTimer = setTimeout(() => {
+ const state = useStore.getState();
+ if (!state.wsConnected) {
+ console.log('[App] No backend found, starting mock mode');
+ wsClient.startMock();
+ }
+ }, 2000);
+
+ return () => {
+ clearTimeout(mockTimer);
+ wsClient.disconnect();
+ };
+ }, []);
+
+ return (
+
+
+
+
+
+ );
+}
+
+// ── Ortho camera wrapper ────────────────────────────────────────
+function OrthoCamera() {
+ const ref = useRef();
+
+ useEffect(() => {
+ if (!ref.current) return;
+
+ // On mount, set orthographic projection
+ const camera = ref.current;
+ const aspect = window.innerWidth / window.innerHeight;
+ const frustumSize = 18;
+
+ camera.left = -frustumSize * aspect / 2;
+ camera.right = frustumSize * aspect / 2;
+ camera.top = frustumSize / 2;
+ camera.bottom = -frustumSize / 2;
+ camera.near = 0.1;
+ camera.far = 200;
+ camera.position.set(14, 18, 14);
+ camera.lookAt(0, 0, 5);
+ camera.updateProjectionMatrix();
+
+ // Handle resize
+ const onResize = () => {
+ const a = window.innerWidth / window.innerHeight;
+ camera.left = -frustumSize * a / 2;
+ camera.right = frustumSize * a / 2;
+ camera.top = frustumSize / 2;
+ camera.bottom = -frustumSize / 2;
+ camera.updateProjectionMatrix();
+ };
+ window.addEventListener('resize', onResize);
+ return () => window.removeEventListener('resize', onResize);
+ }, []);
+
+ return ;
+}
diff --git a/src/frontend/OfficeScene.jsx b/src/frontend/OfficeScene.jsx
new file mode 100644
index 0000000..6ef9cd6
--- /dev/null
+++ b/src/frontend/OfficeScene.jsx
@@ -0,0 +1,195 @@
+import React, { useMemo, useState } from 'react';
+import { useFrame } from '@react-three/fiber';
+import { Text, Html } from '@react-three/drei';
+import * as THREE from 'three';
+import useStore, { ZONES } from './store.js';
+import AgentSprite, { gridToWorld } from './AgentSprite.jsx';
+
+// ── Tile rendering constants ─────────────────────────────────────
+const TILE_W = 1.0;
+const TILE_H = 0.5;
+const GRID_ROWS = 20;
+const GRID_COLS = 28;
+
+// ── Zone colors ──────────────────────────────────────────────────
+const ZONE_COLORS = {
+ 'pod-a': '#C68B3C',
+ 'pod-b': '#C68B3C',
+ 'solo-1': '#3A7CA5',
+ 'solo-2': '#3A7CA5',
+ 'coaching': '#5C8A5C',
+ 'coffee': '#8B6914',
+};
+const FLOOR_COLOR = '#1a1a2e';
+const GRID_LINE_COLOR = '#2a2a4a';
+
+// ── Isometric floor tile ─────────────────────────────────────────
+function FloorTile({ row, col, zoneId }) {
+ const [wx, _, wz] = gridToWorld(row, col);
+ const color = zoneId ? ZONE_COLORS[zoneId] || FLOOR_COLOR : FLOOR_COLOR;
+
+ // Create isometric diamond shape
+ const shape = useMemo(() => {
+ const s = new THREE.Shape();
+ const hw = TILE_W / 2;
+ const hh = TILE_H / 2;
+ s.moveTo(0, hh);
+ s.lineTo(hw, 0);
+ s.lineTo(0, -hh);
+ s.lineTo(-hw, 0);
+ s.closePath();
+ return s;
+ }, []);
+
+ return (
+
+ {/* Floor diamond */}
+
+
+
+
+
+ {/* Grid line border */}
+
+
+
+
+
+
+
+ );
+}
+
+// ── Zone label overlay ──────────────────────────────────────────
+function ZoneLabel({ zone }) {
+ const centerRow = zone.bounds.row + zone.bounds.rows / 2;
+ const centerCol = zone.bounds.col + zone.bounds.cols / 2;
+ const [wx, , wz] = gridToWorld(centerRow, centerCol);
+
+ return (
+
+
+ {zone.name}
+
+
+ );
+}
+
+// ── Connection indicator ────────────────────────────────────────
+function ConnectionIndicator() {
+ const connected = useStore(s => s.wsConnected);
+ const reconnecting = useStore(s => s.wsReconnecting);
+
+ return (
+
+
+
+ {connected ? 'Connected' : reconnecting ? 'Reconnecting...' : 'Disconnected'}
+
+
+ );
+}
+
+// ── Main scene ──────────────────────────────────────────────────
+export default function OfficeScene() {
+ const agents = useStore(s => s.agents);
+ const grid = useStore(s => s.grid);
+
+ // Hover state: { [agentId]: boolean }
+ const [hoveredAgents, setHoveredAgents] = useState({});
+
+ const tiles = useMemo(() => {
+ const result = [];
+ for (let row = 0; row < GRID_ROWS; row++) {
+ for (let col = 0; col < GRID_COLS; col++) {
+ const tile = grid.tiles?.[row]?.[col];
+ result.push({ row, col, zoneId: tile?.zoneId || null });
+ }
+ }
+ return result;
+ }, [grid]);
+
+ const agentList = useMemo(() => Object.values(agents), [agents]);
+
+ return (
+
+ {/* Base ground plane */}
+
+
+
+
+
+ {/* Floor tiles */}
+ {tiles.map(({ row, col, zoneId }) => (
+
+ ))}
+
+ {/* Zone labels */}
+ {Object.values(ZONES).map(zone => (
+
+ ))}
+
+ {/* Agent sprites */}
+ {agentList.map(agent => (
+
+ setHoveredAgents(prev => ({ ...prev, [id]: isHovered }))
+ }
+ />
+ ))}
+
+ {/* Connection status */}
+
+
+ );
+}
diff --git a/src/frontend/index.html b/src/frontend/index.html
index 08bcbf3..843ef69 100644
--- a/src/frontend/index.html
+++ b/src/frontend/index.html
@@ -35,6 +35,6 @@
-
+