zebra-report: deploy FSM-driven call-state UI

This commit is contained in:
russell@unturf.com 2026-06-04 13:49:29 -04:00
parent 385ccf5723
commit bbffd61d89
No known key found for this signature in database

View file

@ -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');
<footer style="margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
<span id="pi-seal" style="color:#777;cursor:default;user-select:none" title="">page integrity</span> &nbsp;·&nbsp; built <span class="stamp-date">2026-06-04</span><br>
md5 <span class="stamp-md5">b8b69ddba03d2bdcee2e24c83a119b8c</span><br>
sha256 <span class="stamp-sha">e79516aedb1b8a5a087cce2a23135f1d72d83ce22ccf517b9905635af26b471a</span><br>
md5 <span class="stamp-md5">da9f26a202e920b3ee45134f748d4132</span><br>
sha256 <span class="stamp-sha">8b14d4543a3123ba78e2edb87ca1e55373c7b336506c791ee2f8151adc91d2c2</span><br>
<span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span><br>
<span style="color:#bbb">one self-contained file — <strong>save a copy</strong> and verify against these hashes; point at your own servers with ?signal= and ?turncred=, or <a href="host-your-own.html" style="color:#999">host your own community</a></span>
</footer>