diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html
index 043adf1..0803b67 100644
--- a/web/zebra-spaces.html
+++ b/web/zebra-spaces.html
@@ -2281,6 +2281,23 @@ function canSpeak(role){ return role==='host' || role==='cohost' || role==='spea
* signaling websocket — JSON text frames, see protocol header in
* cmd/zebra-spaces-signal/main.go
* ================================================================== */
+/* ROOM MACHINES — shadow instance. Runs alongside the imperative
+ * logic during the FSM rollout: the existing handlers stay
+ * authoritative for side effects, but we ALSO send synthetic events
+ * here so the state machines track reality. fox can inspect via
+ * `roomMachines.call.state` / `roomMachines.sub.state` in DevTools
+ * during QA. As each imperative path is rewritten to be driven by
+ * the FSM, the shadowing comes out. */
+const roomMachines = wireZebraMachines();
+if (typeof window !== 'undefined'){ window.roomMachines = roomMachines; }
+/* visible trace of every CallFSM transition so QA can correlate UI
+ * symptoms with state changes. Will tighten the log noise once the
+ * runtime takes over from the imperative handlers. */
+roomMachines.call.observe(({ state, prev, ev }) => {
+ if (prev === null || state === prev) return;
+ logLine('', 'call: ' + prev + ' → ' + state + (ev && ev.type ? ' [' + ev.type + ']' : ''));
+});
+
let ws = null, wantConnected = false, sigKey = null, sigReconnect = null;
function send(obj){ if (ws && ws.readyState===1) ws.send(JSON.stringify(obj)); }
@@ -2311,6 +2328,7 @@ async function joinSpace(){
wantConnected = true;
$('btn-enter').disabled = true;
setStatus('connecting…');
+ roomMachines.call.send('ENTER', { code, handle: myHandle });
openSignal();
}
@@ -2344,6 +2362,7 @@ async function handleSignal(raw){
case 'welcome':
myUUID = m.your_uuid; myRole = m.role; roomEpoch = m.epoch;
applyState(m.state);
+ roomMachines.call.send('WELCOME', { uuid: myUUID, role: myRole });
/* any tile auto-spotlit before welcome (during fast-path subscribe)
* needs to be broadcast now so the room sees our viewing state */
spotlights.set(myUUID, spotlightKey());
@@ -2472,6 +2491,7 @@ async function handleSignal(raw){
if (m.role === 'host') hostUUID = m.uuid;
if (m.uuid === myUUID){
myRole = m.role;
+ roomMachines.call.send('ROLE_CHANGE', { role: m.role });
logLine('', 'you are now '+m.role);
setStatus('connected as '+myRole, 'ok');
const byMod = members.get(m.by);
@@ -3019,6 +3039,7 @@ function handleBlocked(source){
if (blocked) return;
blocked = true;
wantConnected = false;
+ roomMachines.call.send('BOOTED', { by: source });
/* booted = clear auto-rejoin so a refresh doesn't immediately retry */
try { sessionStorage.removeItem(ACTIVE_CALL_KEY); } catch(_){}
try { sessionStorage.removeItem(ACTIVE_CAM_KEY); } catch(_){}
@@ -3160,6 +3181,7 @@ $('game-tabs').addEventListener('click', (ev) => {
});
$('btn-leave').addEventListener('click', async () => {
wantConnected = false;
+ roomMachines.call.send('LEAVE');
/* explicit leave wipes the auto-rejoin state — user said 'out' */
try { sessionStorage.removeItem(ACTIVE_CALL_KEY); } catch(_){}
try { sessionStorage.removeItem(ACTIVE_CAM_KEY); } catch(_){}
@@ -3179,6 +3201,7 @@ $('btn-leave').addEventListener('click', async () => {
$('sec-screen-share').classList.add('hidden');
$('sec-spotlight').classList.add('hidden');
spotlight = null;
+ roomMachines.call.send('DONE');
hideNotice();
$('dot-call').className='dot warn';
setStatus('left', null);
@@ -3195,8 +3218,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');