diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html
index 45570cc..bd174e4 100644
--- a/web/zebra-spaces.html
+++ b/web/zebra-spaces.html
@@ -2547,8 +2547,8 @@ async function sfuPublishScreen(){
/* render a muted local preview so the publisher sees what they're
* sharing — SFU does not echo the publisher's own stream back */
renderScreenTile(myKeys.pubHex, stream, { local: true });
- $('btn-screen-share').classList.add('hidden');
- $('btn-screen-stop').classList.remove('hidden');
+ /* share / stop button visibility is driven by applyPublishStateUI
+ * via the publishSpec observer (pubs.screen state). */
}
/* sfuRebuildScreenPC — silent recovery path used by the screen
* publisher's onconnectionstatechange when the PC fails BUT the
@@ -2626,8 +2626,8 @@ async function sfuUnpublishScreen(){
try { await fetch(SFU_BASE + '/unpublish?room=' + encodeURIComponent(roomID) + '&peer=' + pid, { method:'POST' }); } catch(_){}
}
fsm.send('DONE');
- $('btn-screen-share').classList.remove('hidden');
- $('btn-screen-stop').classList.add('hidden');
+ /* share / stop visibility is driven by applyPublishStateUI from
+ * the publishSpec 'off' state. */
logLine('', 'screen share stopped');
}
@@ -2766,8 +2766,8 @@ async function sfuPublishCamera(){
() => { sfuCameraPC = null; sfuCameraPeerID = null; sfuCameraStream = null; fsm.send('STOP'); fsm.send('DONE'); });
logLine('', 'sfu: camera on as '+sfuCameraPeerID);
renderCameraTile(myKeys.pubHex, stream, { local: true });
- $('btn-camera-share').classList.add('hidden');
- $('btn-camera-stop').classList.remove('hidden');
+ /* share / stop visibility is driven by applyPublishStateUI via
+ * the publishSpec observer (pubs.camera state). */
try { sessionStorage.setItem(ACTIVE_CAM_KEY, '1'); } catch(_){}
}
async function sfuUnpublishCamera(){
@@ -2782,8 +2782,8 @@ async function sfuUnpublishCamera(){
try { await fetch(SFU_BASE + '/unpublish?room=' + encodeURIComponent(roomID) + '&peer=' + pid, { method:'POST' }); } catch(_){}
}
fsm.send('DONE');
- $('btn-camera-share').classList.remove('hidden');
- $('btn-camera-stop').classList.add('hidden');
+ /* share / stop visibility is driven by applyPublishStateUI from
+ * the publishSpec 'off' state. */
logLine('', 'camera off');
try { sessionStorage.removeItem(ACTIVE_CAM_KEY); } catch(_){}
}
@@ -3690,9 +3690,7 @@ roomMachines.call.observe(({ state, prev, ctx }) => {
/* Publish-FSM trace observers — one per kind (mic / screen / camera).
* Logs every transition so the page log shows the publish lifecycle
- * synchronously with the imperative sfuPublish* / sfuUnpublish* flow.
- * Future side-effect migrations can hang off these observers (the
- * imperative code path will shrink as transitions take over). */
+ * synchronously with the imperative sfuPublish* / sfuUnpublish* flow. */
for (const kind of ['mic', 'screen', 'camera']){
const m = roomMachines.pubs[kind];
if (!m) continue;
@@ -3702,6 +3700,32 @@ for (const kind of ['mic', 'screen', 'camera']){
});
}
+/* Share-button visibility observer — derives btn-{kind}-share /
+ * btn-{kind}-stop visibility from the publishSpec state. Single
+ * source of truth: the buttons reflect the FSM, not whoever last
+ * called sfuPublishX. Fox 2026-06-04 directive — every system as a
+ * state machine, scattered classList writes converge into one
+ * observer per FSM. */
+function applyPublishStateUI(kind, state){
+ const share = $('btn-'+kind+'-share');
+ const stop = $('btn-'+kind+'-stop');
+ if (!share || !stop) return;
+ /* sharing = anywhere between START and STOP. The 'stopping' state
+ * is transient and we treat it as "still showing the stop button"
+ * so the UI doesn't flicker during teardown. */
+ const isSharing = state === 'acquiring' || state === 'negotiating' || state === 'live' || state === 'stopping';
+ share.classList.toggle('hidden', isSharing);
+ stop.classList.toggle('hidden', !isSharing);
+}
+for (const kind of ['screen', 'camera']){
+ const m = roomMachines.pubs[kind];
+ if (!m) continue;
+ m.observe(({ state, prev }) => {
+ if (prev === null || state === prev) return;
+ try { applyPublishStateUI(kind, state); } catch(e){ logLine('err','pub.'+kind+' UI: '+e.message); }
+ });
+}
+
let ws = null, wantConnected = false, sigKey = null, sigReconnect = null;
function send(obj){ if (ws && ws.readyState===1) ws.send(JSON.stringify(obj)); }
@@ -5768,8 +5792,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');