Page now reads will_close from space-closing broadcast and shows a soft
warning (warn status, plain log line) when a cohost is present and will
succeed the absent host at grace expiry; keeps the hard 'space closing'
error styling for rooms that will actually end.
Pulled from the matching server broadcast enrichment (commit 7d16a16,
zebra-spaces-signal). Each role-change / hand-raised / hand-lowered /
mic-invite / mic-invite-declined / peer-joined / peer-left / peer-booted
/ host-left / host-promoted / spotlight log line now carries the actor's
authoritative pubkey from the server message instead of trusting the
local uuid -> handle map. Full hex, never truncated. New helpers
pubHexFromMsg() + idTag() keep call sites compact.
Three defects fox hit in one demotion. Fixes:
1) 'looked away' triggered on every demoted person.
Cause: dropping the user's screen/camera tile in sfuUnpublishX during
demotion fired pickNextSpotlight → broadcastSpotlight with empty key
→ every peer logged 'X looked away'. False signal — they didn't look
away, the room tore their tile.
Fix: inRoleTransition flag set inside onRoleChanged(); broadcast-
Spotlight() short-circuits when true.
2) Speaker links not severed — they could keep talking to listeners.
Cause: onRoleChanged tore down mic+sfuPubPC but NOT screen / camera /
game publishes. Listeners kept seeing the demoted user's mic +
screen via the now-orphan publishers.
Fix: demotion now awaits sfuUnpublish + sfuUnpublishScreen +
sfuUnpublishCamera + sfuUnpublishGame.
3) Mesh peers were torn automatically on demotion (per fox: 'never drop
people out of the mesh automatically').
Cause: both onRoleChanged AND the other-side handler in case
'role-change' called tearPeer when a member became listener.
Fix: removed both tearPeer calls. Mesh connections ride as a
back-channel until one side actually leaves the room. Promote
path still connects (idempotent, only when both can speak and
the peer doesn't exist).
The 'demoted user can't see screen-shares' part of fox's report is
likely a sub-PC renegotiation race during the multi-unpublish — diagnostic
log lines from earlier commit (bc7bbbf) should help us narrow it down
once fox reproduces and pastes the log.
Per fox. Previously the left column had three separate sections
(cameras / screens-thumbs / games) each with their own header. Now:
- one #tiles-thumbs container holds every camera, screen-share, and
game-share thumbnail
- single 'shares' h2 header (hidden when no thumbs)
- games stay below as a fixed persistent group (not popularity-ranked)
- TILE_KINDS.{screen,camera,gameshare} all set
thumbContainer:'tiles-thumbs'
- reorderTiles is now ONE sort over the unified list. Each tile carries
data-kind on the dataset so tileScore can pick the right TILE_KINDS
entry for owner-role lookup. A speaker's screen with 3 viewers ranks
above the same speaker's camera with 1 viewer; both rank below a
host's screen regardless of viewers.
- updateContainerVisibility collapsed to one anyThumb check
- mobile media query updates to grid-auto-fill the unified container
instead of just #cameras
Stale CSS (#cameras column rule, .screens-thumbs-h2) removed.
Per fox. shortHex() rendered things like 'joined as host — uuid
8255…6dd6' / 'sfu: subscribed as bfd2…17c8' — useless for diagnosing
defects because the truncated hex doesn't match what's in the SFU
log or the WS server log.
Stripped shortHex from every logLine() callsite (17 lines):
- 'logged out — fresh identity <full pubHex>'
- 'sfu: receiving <full uuid>' (mic attach)
- 'sfu: publishing as <full peerID>' (mic publish)
- 'sfu: sharing screen as <full peerID>'
- 'sfu: sharing gameplay as <full peerID>'
- 'sfu: camera on as <full peerID>'
- 'sfu ontrack: kind=X pub=<full pubHex>'
- 'sfu: unknown kind X from <full pubHex>'
- 'sfu: subscribed as <full peerID>'
- 'meter for <full uuid>: <err>'
- 'joined as <role> — uuid <full uuid>'
- 'sdp from <full uuid> failed: <err>'
UI sites (badges, pub-short label, member-row chips, invite-from
banner) still call shortHex — those are display, not diagnostic.
Per fox. Pulled the log section out of <aside class="controls"> and
moved it to a new <section id="sec-log"> sibling of .page, right
above the page-integrity footer. CSS:
- 240px default height (was 150px and stuck in a 360px column)
- resize: vertical so the user can drag it taller as needed
- min-height: 120px so it can't be collapsed to nothing
- full viewport width inside the body's padding box
Same dark-mode rules apply (#050505 bg / #333 border) since the
.log class selector didn't change.
Listener reports seeing host's screen-share but NOT camera-share on
rejoin. SFU log shows both pubs alive + addPubToSub fires for the
new sub, but the listener-side visible result diverges between
screen and camera. Add a log line that captures everything we know
when each video-kind track lands:
sfu ontrack: kind=camera pub=abc12345
track=video mute=true state=live
Fields:
- kind: which routing branch we took
- pub: short pubHex prefix
- track.kind: should be 'video' for screen/camera/game
- track.muted: initial mute state (true is normal pre-RTP)
- track.readyState: 'live' = good, anything else = bad
Fox can paste the lines so we can see whether (a) the camera
ontrack never fires (SFU subscribe SDP is dropping it), (b) it
fires with track.muted=true and stays that way (no RTP arriving),
or (c) it fires healthy and gets pruned by some downstream bug.
The signal-WS reconnects periodically (network blips, tab background-
ing). Each reconnect produces a fresh welcome → onRoleEntered() fired
the full setup again, which called sfuPublish() → it bailed out via the
'already publishing' early-return + logged it as an alarm. Looked
exactly like 'something kicked out my speaker' in the log even though
the existing mic publish was still healthy.
Two fixes:
- welcome handler detects re-entry by checking myUUID === m.your_uuid.
When true, just refresh role + state + flushSfuStreams + renderRoom
and break — don't re-run sessionStorage saves, spotlight broadcasts,
onRoleEntered, etc.
- sfuPublish silently returns when sfuPubPC is non-null (still
idempotent, just not log-noisy).
ICE-failure recovery on sub PC stays unchanged — that's a real
'failed' state, not a duplicate welcome.
Fix two complaints from fox:
1. share-gameplay used to grab the only sfuScreenPC slot, so a normal
screen-share would be torn down. Now uses dedicated sfuGamePC /
sfuGamePeerID / sfuGameStream so the two coexist freely.
2. The browser picker exposes whole tabs, not iframes. We now call
CropTarget.fromElement(iframe) → videoTrack.cropTo(cropTarget)
(Chromium Region Capture API) which restricts the captured frame
to the iframe's rect — audience sees only the gameplay, none of
the surrounding meeting UI. Firefox + Safari lack CropTarget so
they fall back to whole-tab capture; user-controlled.
Paired with SFU 855f798 which adds 'game' to the kind allowlist.
streamID format: <16hex>-game (parallel to -screen / -camera).
Client wiring:
- new gameStreams / gameVideos Maps and TILE_KINDS.gameshare
- ontrack: kind === 'game' → renderVideoTile('gameshare', ...) with
label prefix 'gameplay'. Listeners' screens-thumbs column gets the
tile alongside any regular screen-shares from the same speaker.
- sfuPublishGame(iframe) / sfuUnpublishGame() mirror the screen helpers
- game-tile's 'share gameplay' button toggles sfuPublishGame ↔
sfuUnpublishGame (not sfuPublishScreen — that's now untouched)
- leave / role-demotion / boot all clean up game-share too
Speakers can now broadcast their gameplay to the room. The spotlit
game tile's meta bar gets a 'share gameplay' button (next to where
fullscreen/etc live for other tile kinds). Click:
- sfuPublishScreen({ preferCurrentTab: true }) — Chromium picker
auto-selects the current tab; Firefox ignores the hint and shows
the normal picker (user just clicks 'this tab')
- existing SFU screen-share pipeline takes over from there — listeners
and other speakers see the tab via their subscribe leg, with the
game iframe visible at full size in their spotlight
- button label flips to 'stop sharing' while live; click again unpubs
Only canSpeak(myRole) gets the button — listeners are audience only.
The game iframe still loads + plays for the local user the same way
(the share is just a screen-capture of what's already on screen), so
there's no double-iframe or weird audio routing. Audience sees a
screen-share with the game running in real time.
Confirming the design fox specified: each game (unmario, cake murder
adventure) is a separate STABLE tile in the left column. The thumbnail
NEVER mounts an iframe (no game JS running in the background, no
audio bleed, no network cost) — it's a card with the game's name in
chunkfive and a green '▶ play' cue in the meta strip.
Clicking a tile:
- Mounts a fresh iframe in the middle spotlight slot (only then does
the game actually load and become playable)
- Updates spotlight state to { kind: 'game', pubHex: id }
- Broadcasts the spotlight change so every other person in the room
sees 'fxhp now viewing unmario' in their log
- Re-orders thumbnails by popularity (viewer count)
- The previous spotlight tile's iframe is torn out of the DOM, so
switching games stops the previous one cleanly
Clicking the already-spotlit game tile is a no-op (already playing).
CSS rename: .game-label → .game-poster + new .game-meta strip with
the play cue. Title now uses the chunkfive serif (same family as
the page headings) so the games read as 'content' rather than 'UI'.
Symptom: listener saw camera tiles appear black, then vanish ~1.5s later.
Cause: remote MediaStreamTracks ALWAYS start in muted state until the
first RTP packet arrives. My watchVideoTrackForRemoval had a
'defensive' branch that scheduled a prune if the track was already
muted at attach time — so the moment we attached, the 1.5s timer
started, and if the publisher's encoder hadn't pushed a keyframe yet
(common for cameras), the tile was pruned before any frame rendered.
Fix: track a hasFlowed flag. Set true on the FIRST 'unmute' (RTP
actually arrived). Only schedule a prune on 'mute' events that fire
AFTER hasFlowed — those are the real 'publisher stopped sending' case.
Initial muted state is now ignored. Also bumped the debounce from
1.5s to 3s for extra safety on slow networks.
'ended' still removes immediately (terminal state, no debounce).
83 FSM tests green.
Root cause of the 'late listener can't see screens' symptom: my recent
'rebuild sub PC on failure' commit (6a72e77) triggered on connection
state 'closed' as well as 'failed', AND the guard `if (sfuSubPC === pc)`
only worked because we ASSUMED Chrome would fire the state change
asynchronously. Chrome fires it SYNCHRONOUSLY during pc.close(), at
which point sfuSubPC still points at the closing pc — guard passes,
rebuild fires. Then sfuUnsubscribe closes the new PC, which triggers
another rebuild. SFU log showed listeners cycling
subscribe → 40s → close → subscribe forever.
Fix:
1. onconnectionstatechange now only rebuilds on 'failed' (the actually-
terminal state). 'closed' = sfuUnsubscribe(), 'disconnected' =
transient and WebRTC may recover on its own.
2. sfuUnsubscribe nulls sfuSubPC BEFORE pc.close(), so even if the
handler fired synchronously the === guard would correctly fail.
3. visibilitychange handler also tightened to only fire on 'failed' —
same reasoning.
The rebuild path for actual ICE failures still works (state goes
'connected' → 'disconnected' → 'failed' → rebuild).
83 FSM tests still green.
Per fox: 'listeners should be able to see all shared screens as audience'.
Listeners are passive — they're not running the meeting, they're
watching. So they shouldn't have to know they CAN click the screen
thumbnail to see the screen big; the system should auto-promote the
most recent screen-share into their spotlight.
Promotion rules in renderVideoTile when a new tile arrives:
- no spotlight up → first tile auto-promotes (anyone)
- listener + new screen + spotlight is camera/game
→ switch to the screen (load-bearing content)
- listener + new screen + spotlight is older screen
→ switch to the newer screen
- listener + new camera
→ do NOT override an active screen spotlight
- speakers + new anything
→ keep their manual choice
All other screens stay in the screens-thumbs column as before, so a
listener can still click any prior screen to view it. The thumbnails
of arriving cameras / screens still render normally — listeners just
get the new screen pre-spotlit instead of buried.
Speakers / hosts keep their existing behavior: first tile auto-
spotlights, subsequent tiles become thumbnails, manual click swaps.
Per fox: each game is its own tile in the left column. Click makes it
the spotlight (big middle slot) AND broadcasts via the same spotlight
channel cameras/screens use, so the log says 'alice now viewing
unmario' and popularity-sort can rank games by viewer count.
Changes:
- GAMES = { unmario, cake } map (id → label + src) — single source
of truth, easy to extend
- buildGameThumb(id) creates a 16:9 clickable card with the game name
and a 'viewing' badge that surfaces when spotlit
- renderGamesColumn() rebuilds #games-thumbs from the GAMES map at
page init (idempotent, can re-run on config change)
- thumbElementFor(kind, pubHex) unified thumb lookup so spotlight
unmark-previous works across camera / screen / game without case
branches in the hot path
- setSpotlight refactored: previous-thumb cleanup runs first, then
branches on kind=game (build iframe-backed big tile) vs kind=
camera/screen (existing video-backed path). Both still go through
the same spotlights.set(myUUID, ...) + broadcastSpotlight() +
reorderTiles() at the end, so the broadcast path is unified.
- ownerLabel('game', id) → game label so log lines read 'alice now
viewing unmario' instead of 'alice now viewing <hex>'s game'
- old #game-frame + game-tabs DOM and handler removed; their CSS too
Lid-close / suspend recovery. When the OS suspends the browser, the
SFU sub PC goes 'disconnected' → 'failed' (or just stops carrying RTP
silently). On resume the old PC is dead but JS still holds a reference
to it, so we get frozen remote screens until the host hard-reboots.
Two recovery paths added:
1. pc.onconnectionstatechange — on 'failed' or 'closed' for the active
sub PC, sfuUnsubscribe() then sfuSubscribe() to rebuild. The
wantConnected guard prevents fighting a deliberate leave.
2. document visibilitychange → 'visible' — Chromium on Linux can
silently keep the PC in 'connected' state through a suspend cycle
without firing connectionstatechange. So we also probe on tab/lid
wake: if the sub PC's connectionState is anything other than
'connected'/'new'/'connecting', force the rebuild.
Both paths converge on the same teardown + re-subscribe so the SFU
sends a fresh initial SDP with every current publisher's tracks.
83 FSM tests still green; this is runtime-only and doesn't affect
the pure state machines yet.
Per fox: 'make unmario and cake murder adventure as tiles on the left
which always push down as stuff joins. this will allow people to play
games during a meeting.'
Layout change:
- game-tabs + iframe moved out of <main class="timeline"> and into
<aside id="sec-cameras"> as a third group (cameras → screens-thumbs
→ games), DOM order = stacking order, so a new camera or screen
thumbnail naturally pushes the games further down the column
- iframe sized to fit the 220px column (aspect-ratio: 4/3, ~165px tall),
tabs stack vertically as full-width buttons
- removed the timeline:has(> #sec-spotlight:not(.hidden)) → hide-games
rules — no longer needed since games aren't in the timeline anymore
- sec-cameras is always visible now (games live there permanently);
the 'cameras' h2 hides when no live cameras to avoid an empty header
- :has(> .cameras-col.hidden) page-grid rules stay as harmless dead code
Games + screen-share + cameras now coexist: someone can play unmario
in the corner while a colleague shares their screen at full size in
the middle.
Step 6 — first integration step. roomMachines is a real wireZebraMachines()
instance instantiated at module load (also exposed on window for DevTools
inspection). The imperative handlers still drive every side effect; this
commit only adds shadow events feeding the CallFSM so QA can see the
lifecycle in the page log AND inspect state in DevTools while we work.
Events wired:
- joinSpace → call.send('ENTER', { code, handle })
- welcome handler → call.send('WELCOME', { uuid, role })
- role-change for self → call.send('ROLE_CHANGE', { role })
- handleBlocked → call.send('BOOTED', { by: source })
- leave button → call.send('LEAVE')
- leave teardown → call.send('DONE')
An observer logs every CallFSM transition into the page log panel
('call: idle → connecting [ENTER]'), so when QA hits a defect we can
correlate the symptom with the exact state transition that fired (or
didn't fire — equally informative).
Tests stay green at 83. Next: shadow the SubscribeFSM + PublishFSMs
the same way (events only, no effects), then start swapping the
imperative paths to be FSM-driven.
Step five — composition layer. wireZebraMachines() returns a coherent
room:
- one CallFSM
- one SubscribeFSM
- three PublishFSMs (mic / screen / camera)
- lazy Map of RemoteTileFSMs created on first tileFor(kind, pubHex)
- tileLeft(pubHex) fans LEFT to every tile keyed by that publisher
Observers wire transitions between machines but the orchestrator
itself stays pure — no WebRTC, no DOM, no fetch. The page's runtime
layers its OWN observers on top to drive real side effects, and the
test extracts the orchestrator directly.
Cascades modelled:
- CallFSM joined (from anything except reconnecting) ── starts the sub
- CallFSM reconnecting → joined does NOT re-START (sub stayed alive)
- CallFSM leaving / booted ── stops sub AND every live publish
- RemoteTileFSMs lazy: tileFor returns the same instance per key
- tileLeft sends LEFT to every kind for that pubHex
+ 11 integration tests + 1 full end-to-end scenario walking through
host publishes mic+screen / listener joins late / listener sees the
screen / host unshares / mute+prune cycle removes the tile / listener
leaves and sub stops.
Total: 83 tests passing. The pure-FSM layer + orchestrator are now
ready to be wired into the imperative call sites in the live runtime.
That's the next step — gradually replace the firefighting code paths
(sfuPublishCamera, sfuSubscribe, role transitions) by feeding events
into these machines from the existing handlers, then observing
state changes to invoke the side effects. Tests catch regressions
on the pure layer while the QA loop catches what touches the wire.
Fourth state machine. Orchestrates the per-leg FSMs:
idle ──ENTER──▶ connecting ──WELCOME──▶ joined ──LEAVE──▶ leaving ──DONE──▶ idle
▲ │ FAILED │ ▲
│ ▼ │ WS_DROPPED │
│ idle ▼ │
│ reconnecting ──WELCOME──▶ joined │
│ │ LEAVE / FAILED │
│ ▼ │
│ leaving ────────────────────────────── ┘
│ ▲
│ │ ACK
└─────────────────────────────────── booted ◀── BOOTED ── (any live state)
Role lives in ctx (host / cohost / speaker / listener). ROLE_CHANGE
re-enters joined so observers fire on every promotion / demotion —
that's how the runtime decides whether to start mic+publish or stop
them, without needing a state per role permutation.
reconnecting handles signal-WS drops without tearing down the
SubscribeFSM or PublishFSMs (WebRTC PCs are independent of the WS).
booted is the explicit terminal for being kicked + ACK returns to
idle so the entry screen comes back.
+ 19 unit tests. test-fsm now 72 passed.
Next step: the integration layer — observers on each FSM that drive
the actual side effects, plus integration tests that compose multiple
FSMs (a CallFSM with SubscribeFSM + RemoteTileFSMs) to assert the
multi-machine interactions match what the live code does.
Third state machine. One instance per incoming screen/camera track,
keyed by kind+pubHex. Codifies the lifecycle:
inactive ──TRACK_ARRIVED──▶ receiving ──MUTED──▶ muted
▲ │
│ UNMUTED │ PRUNE / ENDED
└────────────────────┤
▼
removed
{receiving, muted} + ENDED → removed
* + LEFT → removed
MUTED is a debounce gate, not a deletion: UNMUTED within the runtime's
~1.5s window cancels the prune and stays receiving (transient network
blip). PRUNE fires from the runtime's setTimeout if still muted.
ENDED skips the debounce. LEFT (peer-left) wipes the tile from any
live state. TRACK_ARRIVED in receiving/muted swaps to the new stream
(publisher re-shared before our prune fired).
Removed is terminal — a re-share spins up a fresh FSM. entry into
removed nulls ctx.stream so the runtime can drop refs.
+ 15 unit tests covering happy path, debounce semantics, ENDED
short-circuit, LEFT from every state, re-share refresh, removed
terminality. test-fsm now reports 53 passed.
Second state machine. Models the SFU subscribe leg explicitly:
off ──START──▶ connecting ──CONNECTED──▶ subscribed
│ FAILED │ RENEG
▼ ▼
off renegotiating
│ RENEG_DONE / RENEG_FAILED
▼
subscribed
│ LOST
▼
reconnecting ──CONNECTED──▶ subscribed
│ STOP │ FAILED
▼ ▼
stopping off
│ DONE
▼
off
Renegotiation is its own state so concurrent SSE offers can't race
setRemoteDescription (the bug ae9721e patched imperatively with a
promise queue). A RENEG event during renegotiating parks the SDP on
ctx.pendingOffers; the runtime will drain that queue from an observer
when RENEG_DONE fires. RENEG_FAILED returns to subscribed without
killing the PC — the negotiation attempt is what failed, the channel
itself is still up.
LOST during renegotiating jumps straight to reconnecting (drops the
in-flight reneg cleanly; when the connection comes back the runtime
will re-deliver any still-relevant SDP via fresh RENEGs).
+ 15 new unit tests covering connect, queue, drops, teardown, illegal
transitions. test-fsm now reports 38 passed.
First step of the state-machine refactor. Same self-contained pattern
as the rest of the page — FSMs live inline in web/zebra-spaces.html so
the page-integrity stamp keeps working, and the tests extract them with
the same regex/brace-match technique web-protocol.test.js already uses
(page = source of truth, tests track the page).
Added:
- createFSM(spec): minimal state machine. spec.states[name] has optional
entry/exit hooks and an .on table mapping events → target (string) or
{ target, action }. Observers fire after each transition with
{ state, prev, ev, ctx }. No async in transitions; effects belong in
observers (which can call send() to advance the machine).
- publishSpec: pure transition table for the publish flow.
off ──START──▶ acquiring ──ACQUIRED──▶ negotiating ──NEGOTIATED──▶ live
│ FAILED │ FAILED │ STOP/LOST
▼ ▼ ▼
off stopping ◀──── stopping ──┘
│ DONE
▼
off
One instance per kind (mic / screen / camera). FAILED in negotiating
goes to stopping (not off) so any acquired stream/pc gets torn down.
- test/zebra-fsm.test.js: 23 unit tests covering framework semantics +
publishSpec happy path + error/cancel paths + illegal-transition
no-ops. Function-constructor scope handles const-leak; bare eval()
doesn't expose const declarations to the harness.
- Makefile: test-fsm target + included in test-all.
Next: SubscribeFSM, CallFSM, RemoteTileFSM. Then wire each into the
imperative call sites progressively, replacing the firefighting code.
The remote screen/camera thumb was freezing on the listener side after
the publisher stopped sharing. Root cause: when the SFU stops the
transceiver and renegotiates, browsers DON'T reliably fire 'ended' on
the remote track (Chromium half-fires, Firefox stays silent).
'mute' DOES fire when RTP stops arriving.
watchVideoTrackForRemoval(track, fn):
- 'ended' → remove immediately
- 'mute' → schedule remove in 1.5s
- 'unmute' → cancel pending remove (transient network blip ≠ unshare)
- already-muted at attach time → schedule remove immediately
Applied to both screen and camera ontrack paths. fixes the symptom in
both Firefox and Chromium without waiting for a hard refresh.
Pairs with proxy.unturf.com#9575418. Firefox enforces RFC 7941 msid
strictly: 1*64 token-chars, no ':'. Our streamID was pubkey + ':kind'
= 71 chars with an invalid separator, so Firefox silently dropped
every track and the listener saw only the game iframe.
Client parse now:
- format SHORT16HEX or SHORT16HEX-screen / SHORT16HEX-camera
- 16-char prefix resolved back to the full pubhex via member roster
- own-publish echo check matches by prefix (myKeys.pubHex.startsWith)
- screens / cameras keep using full pubhex as the map key so existing
identity-keyed code (renderScreenTile, removeCameraTile, screenStreams
map, etc.) doesn't have to change
When the host's mic device disappears (BT disconnect, USB unplug, OS
audio swap to laptop speakers), the active MediaStreamTrack ends but
the senders on every PC keep pointing at the dead track. Host goes
silent until a full leave+rejoin tears everything down and re-publishes.
Fix:
- watchMicTrack(track) listens for 'ended' on every mic track we hand
out (initial getMic + applyMicMode re-acquire)
- reacquireMic() gets a fresh getUserMedia with the same constraints,
then sender.replaceTrack on every live mesh peer + sfuPubPC.
No renegotiation — codec / SDP stays the same, just the track
swaps under the existing transceiver. Single in-flight guard so a
burst of devicechange events doesn't race.
- devicechange listener is now an active probe: if the current track
has gone readyState='ended' or muted=true since the last event,
trigger reacquireMic. Firefox in particular doesn't always fire
'ended' on BT swap — the track stays 'live' but emits silence.
Per fox — make viewing public so the room can see what's holding
attention and so thumbnail order reflects what people actually watch
(prevents a speaker dropping inappropriate content from drifting into
peripheral view unless folks tune in).
- spotlights: Map<uuid, key> tracking who's viewing what big tile
- send {type:'spotlight', key:'kind:pubHex'} on every local spotlight
change; broadcast back via signal server (already deployed) as
{type:'spotlight', uuid, key}
- log line on each change: 'alice now viewing bob's screen' /
'alice looked away' — social pressure tool, names what the room
is focused on
- tileScore = role boost on the OWNER (host 10000, cohost 5000,
speaker 0) plus viewer count. Thumbnails re-sorted descending so
host content sits at top, cohost second, speakers ranked by
viewer count
- reorderTiles fires on: own spotlight change, inbound spotlight
signal, peer-left, role-change, host-promoted, and at welcome
(broadcasts the auto-spotlit first tile)
- pubHex + kind stored as tile.dataset for the sort to read
Paired with proxy.unturf.com#719e0e5 which routes the new message
type through the signal server.
Per fox: never hide a thumbnail when its tile is up big. Instead gray
out the thumbnail and overlay 'viewing' so the mapping between thumb
and spotlight is always visible.
Implementation:
- thumbnails are now permanent — created once when the tile arrives
and never moved out of the cameras column
- spotlight builds a SEPARATE big tile DOM pointing at the same
MediaStream via srcObject; multiple <video> elements share one
stream cleanly in every browser we care about
- thumb gets a .viewing class when its tile is the current spotlight;
CSS dims the video to 35% opacity and shows a 'VIEWING' overlay
centered on the tile
- clicking the viewing thumb is a no-op (already up); cursor changes
to default + hover outline suppressed so the user can tell
- renderVideoTile mirrors stream into spotlight's video element when
the same tile is currently spotlit (handles stream-swap on
reconnect / renegotiation)
- removeVideoTile tears the big tile too when its thumb leaves; if
the spotlight goes away pickNextSpotlight promotes a screen first
then a camera
Per fox: every camera and screen tile becomes clickable. One tile at a
time sits in the middle 'spotlight' slot at full size; all others
render as thumbnails in the cameras column (with screens stacked
under the cameras group). Clicking any thumbnail promotes it to the
spotlight and demotes the previous spotlight tile back to its kind's
thumb container. Thumbnails hide the fullscreen button (the tile-click
is now the action) and shrink to ~22vh letterbox.
Implementation:
- DOM: replaced #screens with #spotlight (middle); added
#screens-thumbs to cameras-col (under #cameras); cameras-thumbs h2
appears when at least one thumbnail screen exists.
- TILE_KINDS now points at thumb containers only; spotlight is shared
across kinds via a single global 'spotlight = {kind, pubHex}' var.
- renderVideoTile: first tile auto-promotes to spotlight; subsequent
tiles go to their kind's thumb container. Tile click handler calls
setSpotlight; fullscreen + tap-to-play buttons stopPropagation so
they don't trigger the swap.
- removeVideoTile: if the removed tile was the spotlight, pickNext-
Spotlight() promotes a screen (preferred) or camera. Otherwise just
updates container visibility.
- updateContainerVisibility: hides sec-spotlight when nothing spotlit,
hides sec-cameras when no tiles at all, hides screens-thumbs h2
when no thumbnail screens are present.
- CSS: .tile-thumb shrinks video (22vh max) + meta fontsize; spotlight
cameras use object-fit:contain so the whole face frame is visible.
Bug fox reported: late joiner sees host's screen + camera initially.
The moment they share their own screen/camera, they lose the host's
tiles. The host never sees their stuff either. Symptoms point at the
renegotiation flow being raced.
Root cause: SSE delivers offers via async onmessage handlers. JS is
single-threaded but each `await` yields. When a speaker publishes
mic + screen + camera in quick succession, the SFU's addPubToSub
serialises and fires three SSE offers. The browser handler picks up
offer 1 with setRemoteDescription (state → have-remote-offer), then
awaits createAnswer. During that await another onmessage fires for
offer 2 and tries setRemoteDescription — which throws because the PC
is in have-remote-offer state. Offer 2 is dropped, the new tracks for
that publish never register on the browser side. SFU still forwards
RTP for those tracks but the browser has no receiver, so they vanish.
Existing tiles can also stop receiving RTP when the SFU's track set
diverges from the browser's transceiver set.
Fix:
- chain onmessage handlers through a single Promise queue
(`renegQueue = renegQueue.then(...)`) so each renegotiation fully
completes (SRD → answer → SLD → /answer POST) before the next starts.
Browser PC always returns to stable between offers.
- ontrack mic-path now also short-circuits when pubHex === own pubHex.
Without this, a speaker who subscribes to the SFU would attach their
OWN mic to a remote-audio sink and hear themselves.
Bug: 'speakers publish, listeners subscribe' meant speakers never got
the SFU subscribe leg — which carries every screen + camera publish.
So a host sharing a screen never saw the other speaker's screen, a
late-joining speaker missed any screen already being shared, and
toggling camera made each side see only their own preview.
Fix:
- onRoleEntered: everyone (speaker AND listener) calls sfuSubscribe.
The subscribe PC carries all incoming kinds: mic + screen + camera.
- onRoleChanged: keep the subscribe alive across role flips instead
of tearing it down when becoming speaker.
- ontrack mic-handler: if we're a speaker AND we already have a mesh
peer for the publisher's pubkey, skip the SFU mic track so audio
only comes through mesh (lower-latency path) instead of doubling.
Screens + cameras always render regardless of role.
Late-join screens already worked from the SFU side (serveSubscribe
AddTracks every existing publisher into the initial offer); the
missing piece was speakers actually completing the subscribe.
The 'resume screen share' button needed the same user tap as just
clicking 'share screen' again, so it added complexity without buying
any UX. Camera silent-resume stays — that's a meaningfully different
flow (zero clicks if browser remembers the permission).
sessionStorage (per-tab, not localStorage — tabs A and B can sit in
different spaces and don't fight over a shared slot) carries three
keys across a reload:
zebra-spaces-active-call-v1 rendezvous code of the active space
zebra-spaces-active-cam-v1 '1' if camera was publishing
zebra-spaces-active-screen-v1 '1' if screen was publishing
Flow:
- joinSpace welcome handler writes the code; on leave / boot / role
demotion the keys are cleared
- on init, after identity restore + URL ?code= handling, autoRejoin()
checks sessionStorage. If a code is saved and we have a handle, it
populates the rdv-code field and triggers joinSpace(). URL ?code=
takes priority (a fresh share-URL navigation overrides).
- after welcome lands, if cam flag is set, sfuPublishCamera() runs
silently — browsers usually remember per-origin getUserMedia perms.
On failure the flag self-clears.
- screen cannot be silently resumed (getDisplayMedia requires a fresh
user gesture every call — security). A 'resume screen share' button
surfaces in the share section instead; clicking it counts as the
gesture and re-shares.
Multi-tab safe because sessionStorage doesn't bleed across tabs.
The mic-select dropdown only got refreshed via two paths: a
'devicechange' event listener and the getMic() call that ran when the
user entered a space. So a visitor sitting on the landing page (or
re-loading after device order shifted) saw only the placeholder
'default microphone' option.
Camera-select already had the initial refreshCameraList() call;
mirroring that for mic. Labels stay blank until mic permission is
granted but deviceIds populate so the user can see how many inputs
exist + pick before joining.
Letterbox issue: setting background:#fff/#000 on the <video> element
didn't reach the letterbox area in some browsers — the UA paints its
own black inside the video box regardless of the CSS. Switched the
video bg to transparent so the tile-element background (already
themed light/dark) shows through wherever video doesn't paint, in
both themes.
Three UI tweaks per fox:
- 'backup / restore key' → 'backup / restore' (identity row)
- share-screen and share-camera now live on separate .row lines (was:
share-camera row had inline margin-top, now it's just a sibling .row)
- dropped the 'window or tab; tick share audio if offered' hint note —
fox said it's noise
Screen-share + camera tiles had hardcoded #000 background, border, and
meta-bar regardless of theme — fine in dark mode, jarring in light mode
where the tile sat on a white page like a black box.
Light is now the base: tile bg #fff, border #ddd, meta bar #f0f0f0
with #333 text. A short dark-mode override block restores the night
palette (bg #000, meta #111/#ddd) so dark mode still looks the same.
A row containing only a long <label> (the music-mode checkbox text
'raw mic, no echo/noise cancellation (for playing audio through it)')
got auto-column max-content sizing — which is the un-wrapped width.
The column expanded past the controls track's 360px cap and pushed a
horizontal scrollbar onto the page.
Switching grid-auto-columns to minmax(0, max-content) lets the column
shrink when the container forces it to, at which point white-space:
normal can do its wrapping work. Also added min-width: 0 on .row
itself as belt-and-suspenders for nested grid containers.
The previous .row rule unconditionally pinned column 2 at 1fr, which
stretched whichever child happened to land there. On the screenshot
that meant the 'log out' button got stretched and wrapped its label
across two lines, and the trailing note overlapped buttons it was
supposed to describe.
New rules (now also documented in CLAUDE.md as a style guide so future
authoring is consistent):
- default .row: grid-auto-columns: max-content (everything packs at
its natural width, no stretch)
- :has(> :first-child + input/select): template 'auto 1fr', input grows
- :has(> input/select:first-child): template '1fr', input fills, rest pack
- .row > .note: auto-drops to its own line under the buttons/inputs via
grid-column: 1 / -1
- .row > label: white-space: normal, so long checkbox labels wrap
Applied to zebra-spaces, chat, zebra-audio. CLAUDE.md "Web style
guide — form-row patterns" table lists every supported shape so new
rows reuse the primitive instead of inventing custom layouts.
Switched from 'reserve 220px even when empty' to actually dropping the
column track when no camera is live. Uses :has() so the page grid
template flips between four states based on which side columns are
present:
cams + controls : 220px 1fr min(360px, 50vw) timeline col 2
cams only : 220px 1fr timeline col 2
controls only : 1fr min(360px, 50vw) timeline col 1
alone : 1fr timeline col 1
Timeline gets an explicit grid-column reassignment in the no-cameras
branches so it doesn't land in the wrong slot. Removed the
'display: grid !important' override on .cameras-col so the global
.hidden util can naturally drop it from the layout.
Two fixes in one — the immediate layout bug from the screenshot (timeline
sliding into column 1 with controls eating ~80% of the viewport) and
the architectural rule that all zebra page layout uses grid.
Layout bug (was: when cameras-col gets .hidden + the global .hidden
utility's display:none !important, the grid auto-placement promoted
.timeline into column 1 and .controls into column 2 → controls took
the 1fr middle track). Fix:
- explicit grid-column: 1/2/3 on cameras-col / timeline / controls so
each child stays in its assigned column regardless of siblings going
display:none
- .cameras-col uses 'display: grid !important' to override the global
.hidden util, then only its content (h2 + #cameras) goes display:none
via separate selectors when the .hidden class is present
- controls track clamped to min(360px, 50vw) so a wide window can't
let the side panel eat the screen-share area
Grid-only refactor:
- every flex container converted: .timeline, .cameras-col, .game-tabs,
.screen-tile, .screen-meta, .tap-play, .camera-tile, .row,
.mod-actions, .invite-banner, .invite-actions, .notice-banner
- chat.html + zebra-audio.html same treatment (.row, .field-row,
.share-box .copy-row, the inline H2 style, .mode-toggle, .dot)
- inline 'style=flex:1' on inputs/meters in chat.html replaced with
'style=width:100%'
- now zero 'display: flex' / 'inline-flex' across all five zebra pages
- CLAUDE.md documents the grid-only rule under web-page authoring
Two layout fixes from screenshot feedback:
1. The cameras column's 220px track is now reserved unconditionally.
When no camera is live, .hidden hides the section content (h2 + grid)
but the column track stays put — so the timeline (with the game
iframe or a live screen share) keeps its middle position instead of
sliding leftmost into the cameras slot.
2. Hide-panel is now a true focus mode: clicking it collapses BOTH the
cameras column AND the controls column, leaving the timeline alone
to fill the full viewport. The screen-share gets 100% of the page
width instead of 1fr-minus-220px. Pre-paint pref applies the same
collapse so a saved 'hidden' state lands flush on first paint.
Replaces the PiP overlay with a real 3-column grid: a narrow left
column carrying cameras stacked vertically, the timeline/screen in
the middle, controls on the right.
Layout rules:
- cameras-col is 220px wide on desktop, hidden via .hidden when no
cameras are live (zero-cost when nobody has a webcam on — keeps
the original two-column feel for typical mic-only rooms)
- hide-panel still folds away the right column → 2-col cameras+screen
- on <800px viewports all three stack vertically: controls, then
timeline (or screen), then cameras as a multi-column grid
Camera tiles are now single-column stacked with a 16:9 aspect-ratio
letterbox so face-cams stay readable regardless of incoming resolution.
UX problem in the screenshot fox sent: at full window with 1080p screen
share, the right controls column ate 360px every viewer would rather
spend on pixels, and the camera tiles rendered BELOW the screen so they
fell off-screen mid-stream.
Two fixes:
1. 'hide panel' button next to the theme toggle. Click collapses the
right column to zero, screen-share fills the whole viewport width.
Persists to zebra-spaces-controls-v1; applied pre-paint on <html>
so a 'hidden' reload doesn't flash the panel before hiding it.
2. Cameras get out of the way when a screen share is active. CSS :has()
detects sec-screens visible and promotes #sec-cameras to absolute
positioning, bottom-right of the timeline column, ~22% width, max
70vh, dark backdrop. Standard Zoom/Meet PiP placement. Multiple
cameras stack vertically inside the strip (single-column grid in
PiP mode). When nobody is sharing screen, cameras render normally
in their multi-column grid below the timeline iframe.
All five pages — chat (zebra-audio), zebra-audio, how-it-works,
host-your-own, zebra-spaces — now ship with the same dark-mode
infrastructure: pre-paint head script, shared dark CSS block, fixed
top-right theme toggle, and one localStorage key ('zebra-theme-v1')
shared across pages so the user's choice follows them.
Dark IS the default: missing pref reads as dark, only an explicit
'light' opts out. First-time visitors land in dark without a flash.
The shared CSS covers the surfaces every page has (body, links,
buttons, inputs, dots, meter, status, log, hr, footer) so each page
looks intentional in dark without per-page tuning. zebra-spaces keeps
its richer overrides for badges + latency rows + tile metas.
ReferenceError 'can't access lexical declaration musicMode before
initialization' halted the page script at line 692 (the localStorage
restore), which left every event handler unbound — including 'enter',
so users couldn't join a room at all.
Cause: the persistence patch placed the restore right after the handle
restore, but the let-declarations for those vars still lived 400+ lines
further down. `let` puts the binding in the temporal dead zone until
its declaration executes — reads from above throw.
Fix: declare `let musicMode, micDeviceId, cameraDeviceId` at the top
near the other prefs, drop them from the later `let` lines so the page
init doesn't re-shadow them.
Replaced the filter:invert approach (which produced muddy mid-tones and
left the html canvas + scrollbar areas flashing white) with explicit
.theme-dark overrides for every painted surface:
- bg pure #000 on html + body — no light emission outside content
- text #ccc (dim, easy on eyes; not glaring #fff)
- borders #222-#444 (visible but not loud)
- inputs/textarea #0a0a0a — sit a hair above pure black
- buttons inverted: button.invert (primary) is #ccc-on-black,
regular buttons are #000-with-#ccc-text-and-#444-border
- badges, dots, meters re-coloured for dark contrast
- latency green/yellow/red shifted toward higher-luminance variants
so they stay readable on black
- log + notice banners get dark-tinted backgrounds matching their kind
- video/camera tiles unchanged — they were already dark and look fine
Video pixels never get filtered now, so screen-share + camera streams
render their actual colours instead of being inverted.
CSS filter approach — invert(1) hue-rotate(180deg) on body, with a
matching re-invert on video / canvas / img so the actual content
(screen-share, camera, QR code) still reads correctly. One toggle
flips every painted colour in one stroke: 'invert everything' as
literally as the browser will let us.
- floating top-right button (.theme-toggle)
- preference persists across reloads via 'zebra-spaces-theme-v1'
- inline <head> script applies the class before first paint to avoid
a white-flash for users who pick dark mode
- the same button label flips between 'dark' and 'light' so the user
knows which mode the click switches TO
Listener reported clean network (43ms RTT, 0% loss) but 53ms smoothed
jitter, which on Wi-Fi typically peaks at 150-250ms inter-arrival. A
200ms playout buffer overflows on those peaks; 400ms absorbs them.
Tradeoff: extra ~quarter-second of latency. For music broadcast that
is invisible; for conversation it remains well below noticeable
turn-taking thresholds.
Applied to both SFU subscribe (mic audio path) and mesh peer audio.
Same shape as the existing handle persistence — three new keys
(zebra-spaces-music-mode-v1, -mic-device-v1, -cam-device-v1), restored
right after the handle on page load (before any device enumeration so
the first getUserMedia uses the right device + constraints), and
written on every change handler.
Survives reload + leave/enter cycles. Identity wipe via 'log out' does
not touch these — they're device preferences, not identity.
Three numbers per row now: RTT, loss%, jitter, plus path tag. Loss is
computed as a delta vs the prior poll (not lifetime cumulative) so a
spike during chops shows immediately instead of being averaged into a
session-long denominator. Row colour bumps to the worst of the three:
green RTT with 5% loss reads red.
Reading guide:
- RTT < 50 / loss < 0.5% / jitter < 20ms → green (clean)
- RTT < 150 / loss < 2% / jitter < 50ms → yellow
- otherwise → red
What pattern correlates with your chops:
- loss spikes + green RTT → network: TURN or upstream queue
- jitter spikes + clean loss → buffering / Wi-Fi micro-bursts
- all clean but chops continue → source-side (PulseAudio loopback,
encoder starvation)