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)
Latency panel:
- new collapsible section in the right column under role-actions
- polls getStats() every 2s across every live PC: sfuPubPC, sfuScreenPC,
sfuCameraPC, sfuSubPC, and each mesh peer
- per row: name (e.g. 'sfu mic out', 'sfu in (host + screen + cams)',
'peer <handle>'), RTT in ms (from candidate-pair.currentRoundTripTime),
path kind (LAN/WAN/TURN). Colour-tagged: green <50ms, yellow <150ms,
red 150+. Hides when no PC is live.
- the SFU subscribe row carries every incoming kind (mic + screen +
camera) because they all share sfuSubPC at the WebRTC layer — labelled
accordingly so users don't expect three separate rows.
Audio chops:
- bump playoutDelayHint from 100ms to 200ms — Wi-Fi micro-bursts on
weak links can spike past 100ms and the smaller buffer dropped frames
- preferStereoOpus now takes { music: bool }; music mode = usedtx=0
because DTX's comfort-noise on/off transitions audibly pop on
continuous music signals. Voice mode keeps usedtx=1. Screen-share
audio is always music-grade (system audio capture, not voice).
UX (per fox):
- 'share' section renamed to 'link' and moved directly above the log
section, out of the way of the join/role controls.
Every live RTCPeerConnection is locked to the SDP it was created with.
Page JS reloads don't reach into existing PCs; only fresh negotiation
does. So when a deploy changes fmtp / rtcp-fb / SFU codec registration,
the user just leaves the space and re-enters — no full tab reload
needed.
Caught during the NACK+transport-cc+usedtx rollout: hard refresh wasn't
required, leave+enter was enough.
The button toggled video.muted but the change didn't actually re-route
audio through the speakers in any browser we tested. Less broken UI is
better than broken UI.
Audio robustness:
- useinbandfec=1 + usedtx=1 in publisher's Opus fmtp (was only fec; dtx
drops silence so the budget goes to audible content + cuts congestion)
- ev.receiver.playoutDelayHint = 0.1 (100ms jitter buffer) on every
incoming audio receiver — SFU subscribe + mesh peer. Absorbs Wi-Fi
micro-bursts without perceptible conversation lag.
Tile cleanup on unshare:
- when SFU stops a screen/camera transceiver after the publisher
unpublishes, the remote track fires 'ended'. Listeners now wire
track.onended → removeScreenTile/removeCameraTile so the tile
disappears instead of freezing on the last frame.
Paired with SFU 60a620f which advertises nack + transport-cc feedback
so browsers send the RTCP we depend on for both retransmit and
congestion control.
Camera is a third SFU publisher alongside mic and screen — same multi-
track pipeline, kind=camera suffix on streamID, own #cameras grid in
the timeline column (multi-column auto-fill so multiple face cams fit
without dwarfing a screen share above them).
- new UI: share-camera / stop-camera buttons + device selector in the
same share section. Camera-select restart triggers a clean unpub +
re-pub because deviceId change needs renegotiation anyway.
- new state: sfuCameraPC/Stream/PeerID + cameraStreams/cameraVideos maps
- subscribe ontrack: generic colon-split routes pubkey:screen/camera
to the right tile renderer; back-compat for the existing :screen path
- renderScreenTile + removeScreenTile refactored to a kind-parametric
renderVideoTile via a TILE_KINDS map; the old names stay as thin
shims so callers don't change
- role demotion + leave + peer-left + boot all clean up cameras too
- contentHint='motion' for face-cam (vs 'detail' on screen)
- camera bitrate capped at 1.5Mbps so screen-share headroom isn't
cannibalised when both are publishing simultaneously
Paired with SFU change zebra-spaces-sfu#017c94b which allowlists
kind=camera alongside screen.
When a PulseAudio monitor source is selected as the mic and music mode
is toggled on, Firefox can silently apply its default audio-processing
pipeline (EC/NS/AGC) regardless of the getUserMedia constraints. The
broadcast then sounds 'cleaned up' instead of letting the source pass
through transparently.
Two fixes:
- call track.applyConstraints(micConstraints()) after getUserMedia/replace.
Some UAs honour applyConstraints when they silently ignored the initial
request. Belt-and-suspenders.
- log track.getSettings() so we can see what the UA actually applied —
ec/ns/agc/channels/sampleRate. If applyConstraints didn't stick, the
log shows it instead of failing silently.
Firefox getDisplayMedia silently drops audio for window/tab sources (only
'entire screen' carries system audio). Users were broadcasting video-only
without knowing the audio never made it into the captured stream.
Log the captured track counts unconditionally; when audio is 0 explain
the limitation per browser and point to the workaround (route the tab
through mic music mode for high-quality stereo broadcast).
Firefox Android (and other strict mobile browsers) check the HTML 'muted'
attribute, not the IDL .muted property, when deciding whether MediaStream
<video> is autoplay-eligible. Setting only the property left the video
ineligible, so play() rejected silently and the user saw a black tile.
Three changes:
- set the autoplay/playsinline/muted attributes alongside the properties
so every UA's autoplay heuristic agrees the element is eligible
- log play() rejections instead of swallowing them — silent failures hid
this from us until now
- when play() does reject, show a 'tap to play' overlay that covers the
video area (but not the meta bar); tapping counts as the gesture and
the retry succeeds
Music mode mic upgrades:
- request stereo @ 48kHz from getUserMedia (was mono default)
- RTP-level bitrate cap 256kbps (was 160kbps mono — too low for music)
- SDP fmtp munged to advertise stereo=1+sprop-stereo=1+maxaveragebitrate=256000
so both ends agree on stereo + room to use the bitrate
- music-mode toggle now re-publishes the SFU PC instead of just replaceTrack
(replaceTrack alone leaves the negotiated SDP mono — stereo never reaches
the SFU even with a stereo track)
Screen-share upgrades:
- getDisplayMedia asks for 1920×1080 @ 30fps + stereo 48kHz audio
- video sender capped at 6Mbps, audio sender at 256kbps
- track contentHint 'detail' for video (favour pixels over framerate)
and 'music' for audio
- offer SDP munged for stereo Opus same as mic
Helper added: preferStereoOpus(sdp, bps), setSenderMaxBitrate(sender, bps).
Maximize screen-share resolution on desktop/laptop:
- drop body max-width cap (was 1440px) so layout breathes to full viewport
- shrink right column from 520px to 360px; left column gets the surplus
- bump screen-tile video max-height from 80vh to 92vh
- tighter padding + gap
Cut four prose blocks to roughly a third:
- identity note: full key-management paragraph → 'key lives in this browser only'
- input note: full role/encryption paragraph → 'join as listener; host promotes to mic. end-to-end encrypted.'
- share note: full URL-embedding paragraph → 'share only with people you trust to hear the room.'
- screen-share note: full picker explanation → 'window or tab; tick share audio if offered.'
Mobile browsers refuse to autoplay a <video> carrying an unmuted audio
track without a user gesture; the whole element stays paused, so the
video pixels never render either. The phone listener saw a black tile
even with a healthy track flowing.
Start the remote tile muted so it autoplays everywhere; expose an
'unmute audio' button in the meta bar that toggles. The button click
counts as the gesture, so audio kicks in on demand. Local preview never
offers unmute (would feed back into the publisher's own mic).
The SFU does not echo a publisher's stream back, so anyone sharing alone
in a room had no visual confirmation — game iframe stayed up, no tile
appeared. Render a muted local preview keyed by myKeys.pubHex on publish,
remove on unpublish. Subscribe path skips own pubHex to defend against
SFUs that do echo publishers.
Also refreshes integrity stamps on the other web pages (date drift).
Previously sec-screens lived inside the controls aside, capped at 520px
wide — useless for any real screen view. Move it into the timeline
column at the top, where there's actual horizontal space. CSS :has()
on .timeline hides the game tabs + iframe whenever a screen tile is
visible, so the screen owns the full left column. As soon as the last
sharer stops, the game switcher returns.