diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html
index 4953e72..f9cad31 100644
--- a/web/zebra-spaces.html
+++ b/web/zebra-spaces.html
@@ -3612,6 +3612,36 @@ async function handleSignal(raw){
}
}
break;
+ case 'peer-force-muted':
+ {
+ const mm = members.get(m.uuid);
+ const by = members.get(m.by);
+ const victPub = pubHexFromMsg(m, 'pubkey')
+ || (mm && mm.pubkey ? pubHexFromMsg({pubkey:mm.pubkey},'pubkey') : '');
+ const byPub = pubHexFromMsg(m, 'by_pubkey');
+ logLine('', idTag(m.uuid, victPub) + ' was muted by ' + (by||byPub?idTag(m.by, byPub):'a mod'));
+ if (m.uuid === myUUID){
+ /* I was the target — server already dropped my mic publisher
+ * at the SFU; mirror that locally so my own UI shows muted
+ * and the mic track stops sending into mesh PCs too. The
+ * user can hit unmute to resume on their own. */
+ showNotice('You were muted'+(by?' by '+by.handle:'')+'. Click unmute to talk again.', 'warn');
+ if (!muted){
+ muted = true;
+ try { sessionStorage.setItem(MUTE_STATE_KEY, '1'); } catch(_){}
+ if (micStream){
+ for (const t of micStream.getAudioTracks()) t.enabled = false;
+ }
+ try { sendMicState(); } catch(_){}
+ }
+ renderRoom();
+ } else {
+ /* Mark the target visually muted right away — their next
+ * mic-state broadcast will confirm it on the wire. */
+ if (mm){ mm.muted = true; renderRoom(); }
+ }
+ }
+ break;
case 'peer-booted':
{
const mm = members.get(m.uuid);
@@ -3969,6 +3999,15 @@ async function modDemote(uuid, to){
const sig = await signBytes(sigAction(roomID, roomEpoch, 'demote', uuid, to));
send({ type:'demote', target: uuid, to, epoch: roomEpoch, sig });
}
+/* mute: drops the speaker's mic publisher at the SFU so their voice
+ * stops immediately. No role change — they keep their seat, keep
+ * sharing screen / camera / game, keep hearing the room — and they
+ * may unmute themselves on their own when they want to talk again. */
+async function modMute(uuid){
+ if (!confirm('mute this speaker? (they can unmute themselves)')) return;
+ const sig = await signBytes(sigAction(roomID, roomEpoch, 'mute', uuid));
+ send({ type:'mute', target: uuid, epoch: roomEpoch, sig });
+}
/* kick: drop the peer from this session — evicts their SFU PCs (audio
* actually stops) but does NOT block their pubkey. They can rejoin
* freely. Use when someone's audio is leaking from a closed-tab /
@@ -4210,6 +4249,15 @@ function renderRoom(){
b.textContent = '→ speaker'; b.onclick = () => modDemote(m.uuid, 'speaker').catch(e=>logLine('err','demote: '+e.message));
actsPrimary.appendChild(b);
}
+ /* mute: drops their mic publisher at the SFU. Speakers always;
+ * cohosts only when I'm host (cohost can't mute another cohost). */
+ if (canSpeak(m.role) && m.role !== 'host' &&
+ !(m.role === 'cohost' && myRole !== 'host')){
+ const mb = document.createElement('button'); mb.className='small';
+ mb.textContent = 'mute';
+ mb.onclick = () => modMute(m.uuid).catch(e => logLine('err','mute: '+e.message));
+ actsPrimary.appendChild(mb);
+ }
/* kick + ban — destructive removal actions, kept on their own
* row so the muscle-memory misclick where 'ban' sat next to
* '→ speaker' can't fire by accident. */
@@ -4709,8 +4757,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');