zebra-spaces: hoist call-state UI into a single FSM-observer — kick/ban/blocked all converge
Fox 2026-06-04 directive: "all systems need state machines." Self-
listener is already an FSM (commit f4dbc5c). Next system: the
top-line connection chrome (entry-row visibility, sec-room reveal,
dot color, leave/mute button visibility, status text, btn-enter
disabled). Previously these were scattered classList + setStatus
writes across welcome, peer-booted (self), btn-leave click, and
handleBlocked — easy to drift, every UI bug fox flagged ("dot still
green after kick", "entry row should be hidden when joined", "leave
button gone after kick") was a different leaf of this implicit
state model.
Wire applyCallStateUI(state, prev, ctx) as a roomMachines.call
observer. Single function, six branches (idle/connecting/joined/
reconnecting/leaving/booted), drives every relevant DOM toggle.
callSpec gains a `bootedAction` context field — 'kick' | 'ban' |
'blocked' | null — set by BOOTED's action so the UI observer can
render the right status ('kicked from this space' vs 'banned from
this space' vs 'blocked from this space') AND decide whether to
re-enable btn-enter (kick: yes, can re-enter; ban/blocked: no).
Call sites updated:
- handleBlocked: now passes { action: 'blocked' } in BOOTED payload
- case 'peer-booted' (self): now passes { action: m.action } so
kick vs ban propagates to the FSM
- btn-leave click: imperative chrome removed (was a 9-line
classList chain), replaced by send('LEAVE') + send('DONE') —
the observer handles the rest
- joinSpace: removed imperative btn-enter.disabled / setStatus
- case 'welcome': removed imperative dot/buttons/sec-room/row-entry
toggles — observer covers them
Tests added in test/zebra-fsm.test.js (now 88/88):
- BOOTED with action=kick → bootedAction=kick
- BOOTED with action=ban → bootedAction=ban
- BOOTED with action=blocked → bootedAction=blocked
- BOOTED with no action defaults to kick (back-compat)
- BOOTED → ACK → idle clears bootedAction
Future migrations should follow this pattern: add a state field to
the spec, hoist the imperative side effects into a switch in an
observer, leave a comment at the OLD imperative location explaining
the migration so the next reader doesn't reintroduce drift.
This commit is contained in:
parent
b4d3f748fa
commit
49c65255ee
2 changed files with 168 additions and 52 deletions
|
|
@ -559,7 +559,7 @@ test('joined + LEAVE → leaving → idle on DONE', () => {
|
|||
eq(m.state, 'idle');
|
||||
});
|
||||
|
||||
test('idle entry clears uuid + role + bootedBy', () => {
|
||||
test('idle entry clears uuid + role + bootedBy + bootedAction', () => {
|
||||
const m = createFSM(callSpec);
|
||||
m.send('ENTER', { code: 'r', handle: 'h' });
|
||||
m.send('WELCOME', { uuid: 'u', role: 'host' });
|
||||
|
|
@ -567,6 +567,56 @@ test('idle entry clears uuid + role + bootedBy', () => {
|
|||
eq(m.context.uuid, '');
|
||||
eq(m.context.role, '');
|
||||
eq(m.context.bootedBy, null);
|
||||
eq(m.context.bootedAction, null);
|
||||
});
|
||||
|
||||
console.log('callSpec — bootedAction propagates (kicked-vs-banned-vs-blocked UI):');
|
||||
|
||||
test('BOOTED with action=kick sets bootedAction=kick', () => {
|
||||
const m = createFSM(callSpec);
|
||||
m.send('ENTER', { code: 'r', handle: 'h' });
|
||||
m.send('WELCOME', { uuid: 'u', role: 'listener' });
|
||||
m.send('BOOTED', { by: 'modA', action: 'kick' });
|
||||
eq(m.state, 'booted');
|
||||
eq(m.context.bootedAction, 'kick');
|
||||
eq(m.context.bootedBy, 'modA');
|
||||
});
|
||||
|
||||
test('BOOTED with action=ban sets bootedAction=ban', () => {
|
||||
const m = createFSM(callSpec);
|
||||
m.send('ENTER', { code: 'r', handle: 'h' });
|
||||
m.send('WELCOME', { uuid: 'u', role: 'listener' });
|
||||
m.send('BOOTED', { by: 'modA', action: 'ban' });
|
||||
eq(m.state, 'booted');
|
||||
eq(m.context.bootedAction, 'ban');
|
||||
});
|
||||
|
||||
test('BOOTED with action=blocked sets bootedAction=blocked (signal-server denied)', () => {
|
||||
const m = createFSM(callSpec);
|
||||
m.send('ENTER', { code: 'r', handle: 'h' });
|
||||
m.send('WELCOME', { uuid: 'u', role: 'listener' });
|
||||
m.send('BOOTED', { by: 'signal', action: 'blocked' });
|
||||
eq(m.state, 'booted');
|
||||
eq(m.context.bootedAction, 'blocked');
|
||||
});
|
||||
|
||||
test('BOOTED with no action defaults bootedAction to kick (back-compat)', () => {
|
||||
const m = createFSM(callSpec);
|
||||
m.send('ENTER', { code: 'r', handle: 'h' });
|
||||
m.send('WELCOME', { uuid: 'u', role: 'listener' });
|
||||
m.send('BOOTED', { by: 'modA' });
|
||||
eq(m.state, 'booted');
|
||||
eq(m.context.bootedAction, 'kick');
|
||||
});
|
||||
|
||||
test('BOOTED → ACK → idle clears bootedAction', () => {
|
||||
const m = createFSM(callSpec);
|
||||
m.send('ENTER', { code: 'r', handle: 'h' });
|
||||
m.send('WELCOME', { uuid: 'u', role: 'listener' });
|
||||
m.send('BOOTED', { by: 'modA', action: 'ban' });
|
||||
m.send('ACK');
|
||||
eq(m.state, 'idle');
|
||||
eq(m.context.bootedAction, null);
|
||||
});
|
||||
|
||||
console.log('callSpec — role transitions stay in joined:');
|
||||
|
|
|
|||
|
|
@ -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> · 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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue