diff --git a/zebra-report/zebra-spaces.html b/zebra-report/zebra-spaces.html
index 00adfa5..9559e00 100644
--- a/zebra-report/zebra-spaces.html
+++ b/zebra-report/zebra-spaces.html
@@ -1185,10 +1185,13 @@ const remoteTileSpec = {
* topology without inventing a new state per role permutation. */
const callSpec = {
initial: 'idle',
- context: { code: '', handle: '', uuid: '', role: '', bootedBy: null, lastError: null },
+ /* bootedAction — 'kick' | 'ban' | 'blocked' | null. The UI observer
+ * derives the kicked-vs-banned-vs-blocked status text + whether
+ * btn-enter should be re-enabled (kick: yes, ban/blocked: no). */
+ context: { code: '', handle: '', uuid: '', role: '', bootedBy: null, bootedAction: null, lastError: null },
states: {
idle: {
- entry: (ctx) => { ctx.uuid = ''; ctx.role = ''; ctx.bootedBy = null; },
+ entry: (ctx) => { ctx.uuid = ''; ctx.role = ''; ctx.bootedBy = null; ctx.bootedAction = null; },
on: {
ENTER: {
target: 'connecting',
@@ -1203,7 +1206,7 @@ const callSpec = {
action: (ctx, ev) => { if (ev.payload){ ctx.uuid = ev.payload.uuid || ''; ctx.role = ev.payload.role || ''; } },
},
FAILED: { target: 'idle', action: (ctx, ev) => { ctx.lastError = ev.payload && ev.payload.error; } },
- BOOTED: { target: 'booted', action: (ctx, ev) => { ctx.bootedBy = ev.payload && ev.payload.by; } },
+ BOOTED: { target: 'booted', action: (ctx, ev) => { ctx.bootedBy = ev.payload && ev.payload.by; ctx.bootedAction = (ev.payload && ev.payload.action) || 'kick'; } },
LEAVE: 'idle',
},
},
@@ -1217,7 +1220,7 @@ const callSpec = {
},
WS_DROPPED: 'reconnecting',
LEAVE: 'leaving',
- BOOTED: { target: 'booted', action: (ctx, ev) => { ctx.bootedBy = ev.payload && ev.payload.by; } },
+ BOOTED: { target: 'booted', action: (ctx, ev) => { ctx.bootedBy = ev.payload && ev.payload.by; ctx.bootedAction = (ev.payload && ev.payload.action) || 'kick'; } },
},
},
reconnecting: {
@@ -1232,7 +1235,7 @@ const callSpec = {
},
FAILED: { target: 'idle', action: (ctx, ev) => { ctx.lastError = ev.payload && ev.payload.error; } },
LEAVE: 'leaving',
- BOOTED: { target: 'booted', action: (ctx, ev) => { ctx.bootedBy = ev.payload && ev.payload.by; } },
+ BOOTED: { target: 'booted', action: (ctx, ev) => { ctx.bootedBy = ev.payload && ev.payload.by; ctx.bootedAction = (ev.payload && ev.payload.action) || 'kick'; } },
},
},
leaving: {
@@ -3566,13 +3569,90 @@ function canSpeak(role){ return role==='host' || role==='cohost' || role==='spea
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. */
+ * symptoms with state changes. */
roomMachines.call.observe(({ state, prev, ev }) => {
if (prev === null || state === prev) return;
logLine('', 'call: ' + prev + ' → ' + state + (ev && ev.type ? ' [' + ev.type + ']' : ''));
});
+/* Call-FSM-driven UI. Single source of truth for the entry/connected/
+ * kicked/banned UI states (dot color, button visibility, status text,
+ * entry-row visibility). Previously these were scattered classList +
+ * setStatus writes across `welcome`, `peer-booted` (self branch),
+ * btn-leave click, and handleBlocked — easy to drift, hard to test.
+ *
+ * Fox 2026-06-04: "all systems need state machines." This observer
+ * makes the existing callSpec the authoritative UI driver; the
+ * imperative paths now just dispatch events into the FSM and trust
+ * the observer to update the chrome.
+ *
+ * Side-effect-only — never reads anything except its ctx. Idempotent
+ * because it only fires on REAL transitions (prev !== state). */
+function applyCallStateUI(state, prev, ctx){
+ const dot = $('dot-call'), status = $('call-status');
+ const btnEnter = $('btn-enter'), btnLeave = $('btn-leave'), btnMute = $('btn-mute');
+ const rowEntry = $('row-entry'), secRoom = $('sec-room');
+ switch (state){
+ case 'idle': {
+ /* fresh page, or post-leave */
+ if (dot) dot.className = 'dot warn';
+ if (rowEntry) rowEntry.classList.remove('hidden');
+ if (secRoom) secRoom.classList.add('hidden');
+ if (btnLeave){ btnLeave.classList.add('hidden'); btnLeave.disabled = true; }
+ if (btnMute){ btnMute.classList.add('hidden'); btnMute.disabled = true; }
+ if (btnEnter) btnEnter.disabled = false;
+ if (status && prev === 'leaving') setStatus('left', null);
+ break;
+ }
+ case 'connecting': {
+ if (dot) dot.className = 'dot warn';
+ if (btnEnter) btnEnter.disabled = true;
+ if (status) setStatus('connecting…');
+ break;
+ }
+ case 'joined': {
+ if (dot) dot.className = 'dot ok';
+ if (rowEntry) rowEntry.classList.add('hidden');
+ if (secRoom) secRoom.classList.remove('hidden');
+ if (btnLeave){ btnLeave.classList.remove('hidden'); btnLeave.disabled = false; }
+ if (btnMute) btnMute.classList.remove('hidden');
+ if (status && ctx.role) setStatus('connected as '+ctx.role, 'ok');
+ break;
+ }
+ case 'reconnecting': {
+ if (dot) dot.className = 'dot warn';
+ if (status) setStatus('rendezvous dropped — reconnecting…');
+ break;
+ }
+ case 'leaving': {
+ if (dot) dot.className = 'dot warn';
+ if (btnLeave){ btnLeave.classList.add('hidden'); btnLeave.disabled = true; }
+ if (btnMute){ btnMute.classList.add('hidden'); btnMute.disabled = true; }
+ break;
+ }
+ case 'booted': {
+ /* kicked or banned (or signal-blocked); ctx.bootedAction may be
+ * 'kick' | 'ban' | 'blocked'. Each gets a distinct status. */
+ if (dot) dot.className = 'dot warn';
+ if (secRoom) secRoom.classList.add('hidden');
+ if (rowEntry) rowEntry.classList.remove('hidden');
+ if (btnLeave){ btnLeave.classList.add('hidden'); btnLeave.disabled = true; }
+ if (btnMute){ btnMute.classList.add('hidden'); btnMute.disabled = true; }
+ if (btnEnter) btnEnter.disabled = (ctx.bootedAction === 'ban' || ctx.bootedAction === 'blocked');
+ const verb = ctx.bootedAction === 'ban' ? 'banned from this space'
+ : ctx.bootedAction === 'blocked' ? 'blocked from this space'
+ : 'kicked from this space';
+ const cls = ctx.bootedAction === 'ban' || ctx.bootedAction === 'blocked' ? 'err' : 'warn';
+ if (status) setStatus(verb, cls);
+ break;
+ }
+ }
+}
+roomMachines.call.observe(({ state, prev, ctx }) => {
+ if (prev === null || state === prev) return;
+ try { applyCallStateUI(state, prev, ctx); } catch(e){ logLine('err','call UI observer: '+e.message); }
+});
+
let ws = null, wantConnected = false, sigKey = null, sigReconnect = null;
function send(obj){ if (ws && ws.readyState===1) ws.send(JSON.stringify(obj)); }
@@ -3615,8 +3695,8 @@ async function joinSpace(){
roomID = await deriveSignalRoom(code);
sigKey = await deriveSignalKey(code);
wantConnected = true;
- $('btn-enter').disabled = true;
- setStatus('connecting…');
+ /* btn-enter disable + 'connecting…' status are applied by the
+ * call FSM observer once we land in the 'connecting' state. */
roomMachines.call.send('ENTER', { code, handle: myHandle });
openSignal();
}
@@ -3845,17 +3925,12 @@ async function handleSignal(raw){
broadcastSpotlight();
reorderTiles();
logLine('', 'joined as '+myRole+' — uuid '+myUUID);
- setStatus('connected as '+myRole, 'ok');
- $('dot-call').className='dot ok';
- $('btn-leave').disabled = false;
- $('btn-leave').classList.remove('hidden');
- $('btn-mute').classList.remove('hidden');
- $('sec-room').classList.remove('hidden');
- /* Hide the rendezvous code + enter button once joined — fox
- * 2026-06-04: "prevents people without the password or link
- * from being able to see it on the screen" (over-the-shoulder
- * privacy). The row reappears on leave / kick / WS-tear. */
- $('row-entry').classList.add('hidden');
+ /* dot, leave/mute button visibility, sec-room reveal, entry-row
+ * hide and status text are all driven by applyCallStateUI()
+ * from the 'joined' state. Welcome already dispatched WELCOME
+ * to the call FSM above (line ~3914), so the observer has
+ * already run by this point. Keeping this comment here as a
+ * pointer for the next migration. */
/* remember which space this tab is in so a hard refresh auto-rejoins.
* sessionStorage is per-tab so tab A in space X + tab B in space Y
* stay independent and clear cleanly on tab close. */
@@ -4163,23 +4238,12 @@ async function handleSignal(raw){
sfuUnpublishGame().catch(()=>{});
sfuUnsubscribe().catch(()=>{});
dropMic();
- /* UI: kicked-self should NOT look like a connected listener
- * still in the room. Flip dot from green/connected to a
- * hollow/warn state, drop the leave button (already left),
- * disable the mute button, and reset the status line. Fox
- * 2026-06-04: 'kicked listener gets green left state and
- * still has a leave button even though they are out of the
- * room — should be gone'. */
- $('dot-call').className = 'dot warn';
- setStatus(m.action === 'ban' ? 'banned from this space' : 'kicked from this space', 'warn');
- $('btn-leave').classList.add('hidden');
- $('btn-leave').disabled = true;
- $('btn-mute').classList.add('hidden');
- $('btn-mute').disabled = true;
- $('btn-enter').disabled = false;
- /* Bring the entry row back so the kicked user can re-enter
- * a different (or same) rendezvous code manually. */
- $('row-entry').classList.remove('hidden');
+ /* UI (dot, leave/mute buttons, entry-row, status text) is
+ * now driven by the call-state observer applyCallStateUI()
+ * from the 'booted' state. We just need to dispatch BOOTED
+ * with the action so the observer can render the right
+ * status text (kicked/banned). */
+ roomMachines.call.send('BOOTED', { by: m.by, action: m.action || 'kick' });
}
/* Authoritative tile teardown by pubkey — booted users can't be
* publishing anything anymore by definition. The SFU side eviction
@@ -5246,13 +5310,14 @@ function handleBlocked(source){
if (blocked) return;
blocked = true;
wantConnected = false;
- roomMachines.call.send('BOOTED', { by: source });
+ /* call.observe drives the dot/buttons/entry-row/status from
+ * 'blocked' bootedAction — see applyCallStateUI. */
+ roomMachines.call.send('BOOTED', { by: source, action: 'blocked' });
/* 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(_){}
showNotice('You are blocked from this space.', 'warn');
logLine('err','blocked ('+source+') — stopping reconnects');
- setStatus('blocked from this space','err');
if (sigReconnect){ clearTimeout(sigReconnect); sigReconnect = null; }
if (ws){ try { ws.close(); } catch(_){} ws = null; }
for (const u of [...peers.keys()]) tearPeer(u);
@@ -5619,24 +5684,25 @@ $('btn-leave').addEventListener('click', async () => {
/* tear all video tiles regardless of source — fresh slate next time */
for (const k of [...screenVideos.keys()]) removeScreenTile(k);
for (const k of [...cameraVideos.keys()]) removeCameraTile(k);
- $('sec-room').classList.add('hidden');
+ /* sec-* visibility for invite/listener-actions/share/screen-share/
+ * spotlight isn't yet under the call FSM observer (room-internal
+ * UI, not the top-line entry/connected/booted chrome). Keep these
+ * imperative for now; the next FSM migration can hoist them. */
$('sec-invite').classList.add('hidden');
$('sec-listener-actions').classList.add('hidden');
$('sec-share').classList.add('hidden');
$('sec-screen-share').classList.add('hidden');
$('sec-spotlight').classList.add('hidden');
- /* bring the rendezvous-code + enter row back when leaving */
- $('row-entry').classList.remove('hidden');
spotlight = null;
+ /* call FSM: LEAVE → leaving → DONE → idle. applyCallStateUI sets
+ * dot warn, hides leave/mute buttons, shows entry-row, sets status
+ * 'left', re-enables btn-enter. */
+ roomMachines.call.send('LEAVE');
roomMachines.call.send('DONE');
hideNotice();
- $('dot-call').className='dot warn';
- setStatus('left', null);
- $('btn-enter').disabled = false;
- $('btn-leave').disabled = true;
- $('btn-leave').classList.add('hidden');
- $('btn-mute').disabled = true;
- $('btn-mute').classList.add('hidden');
+ /* reset mute button label even though the FSM observer hides it —
+ * next join restores visibility and the residue 'unmute'/'invert'
+ * styling would otherwise leak. */
$('btn-mute').textContent = 'mute'; $('btn-mute').className = 'hidden';
/* Preserve the user's last mute choice across leave+rejoin —
* fox: 'when I leave and rejoin the mic state is unmuted'. Don't
@@ -5653,8 +5719,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');