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:');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue