diff --git a/zebra-report/zebra-spaces.html b/zebra-report/zebra-spaces.html
index 8e7b5dd..ae7faaf 100644
--- a/zebra-report/zebra-spaces.html
+++ b/zebra-report/zebra-spaces.html
@@ -1248,18 +1248,61 @@ const callSpec = {
};
/* ==================================================================
- * wireZebraMachines — orchestrator. Composes one CallFSM, one
- * SubscribeFSM, three PublishFSMs (mic/screen/camera), and a Map of
- * RemoteTileFSMs into a coherent room. Observers wire transitions
- * between machines; no side effects in this layer — the page's
- * runtime attaches its OWN observers on top to drive actual WebRTC
- * and DOM work. That separation keeps this function fully testable
- * in Node with synthetic events.
+ * SelfListenerFSM — a speaker / cohost / host who's flipped their
+ * own row's stream toggle to consume the room via the buffered HTTP
+ * Ogg/Opus path instead of the live WebRTC mesh.
*
- * Returns { call, sub, pubs, remoteTiles, tileFor, tileLeft }. */
+ * off ──ENABLE / TOGGLE──▶ on ──DISABLE / TOGGLE / UNMUTE / DEMOTED / CLEAR──▶ off
+ *
+ * The UNMUTE edge encodes fox's invariant: "unmuting should
+ * seamlessly switch them back to the now of the conversation
+ * webrtc mesh" — if the user clicks unmute while in on, they
+ * implicitly want to go back to the live path.
+ *
+ * Pure spec — side effects (mic mute, streamMode population,
+ * remoteAudio muting) live in the runtime's observer attached to
+ * this FSM. Keeps it testable in Node. */
+const selfListenerSpec = {
+ initial: 'off',
+ context: { enrolledCount: 0 },
+ states: {
+ off: {
+ on: {
+ TOGGLE: 'on',
+ ENABLE: 'on',
+ UNMUTE: 'off', /* no-op self-transition for symmetry */
+ DEMOTED: 'off',
+ CLEAR: 'off',
+ },
+ },
+ on: {
+ on: {
+ TOGGLE: 'off',
+ DISABLE: 'off',
+ UNMUTE: 'off',
+ DEMOTED: 'off',
+ CLEAR: 'off',
+ },
+ },
+ },
+};
+
+/* ==================================================================
+ * wireZebraMachines — orchestrator. Composes one CallFSM, one
+ * SubscribeFSM, three PublishFSMs (mic/screen/camera), one
+ * SelfListenerFSM, and a Map of RemoteTileFSMs into a coherent
+ * room. Observers wire transitions between machines; no side
+ * effects in this layer — the page's runtime attaches its OWN
+ * observers on top to drive actual WebRTC and DOM work. That
+ * separation keeps this function fully testable in Node with
+ * synthetic events.
+ *
+ * Returns { call, sub, pubs, selfListener, remoteTiles, tileFor,
+ * tileLeft }. */
function wireZebraMachines(){
const call = createFSM(callSpec);
const sub = createFSM(subscribeSpec);
+ const selfListener = createFSM(selfListenerSpec);
const pubs = {
mic: createFSM(publishSpec),
screen: createFSM(publishSpec),
@@ -1315,7 +1358,7 @@ function wireZebraMachines(){
}
});
- return { call, sub, pubs, remoteTiles, tileFor, tileLeft };
+ return { call, sub, pubs, selfListener, remoteTiles, tileFor, tileLeft };
}
/* ==================================================================
@@ -4172,7 +4215,7 @@ async function onRoleChanged(prev, next){
/* self-listener flag is meaningless once we're a listener (the
* row's stream toggle disappears); flip it OFF so its streamMode
* entries get torn down cleanly with the rest of our state. */
- if (selfListenerMode) disableSelfListenerMode();
+ roomMachines.selfListener.send('DEMOTED');
dropMic(); muted = false;
await sfuUnpublish();
await sfuUnpublishScreen();
@@ -4492,7 +4535,14 @@ let listenerOutputMuted = true;
* Opus path instead of the live WebRTC mesh. Auto-mutes their mic so
* they can't talk into a delayed stream (they'd be 2-4s behind the
* conversation); unmuting toggles them back to WebRTC seamlessly. */
-let selfListenerMode = false;
+/* selfListenerMode is now a derived getter against the SelfListenerFSM
+ * (roomMachines.selfListener). Single source of truth — direct writes
+ * to the boolean would drift from the FSM state, defeating the point.
+ * All transitions go through .send('TOGGLE' | 'ENABLE' | 'DISABLE' |
+ * 'UNMUTE' | 'DEMOTED' | 'CLEAR'); side effects ride an observer
+ * attached during room setup (see selfListenerObserver below). */
+function selfListenerMode_get(){ return roomMachines.selfListener.state === 'on'; }
+Object.defineProperty(window, 'selfListenerMode', { get: selfListenerMode_get });
/* DJ HTTP stream mode is intentionally NOT auto-enrolled for listener
* phones — Firefox Android refuses autoplay on every fresh