Commit graph

152 commits

Author SHA1 Message Date
07fcec7775
zebra-spaces: refactor sub PC ontrack into handleRemoteSfuTrack + multi-peer mesh tests
Receive-side mesh state machine (the bit that decides which peer gets
which tile) was buried inside an anonymous pc.ontrack callback inside
sfuSubscribe(). Extracted into a named top-level function
handleRemoteSfuTrack so tests can drive it directly with synthetic
RTCTrackEvents — no real RTCPeerConnection, no real SFU.

test/multi-peer-mesh.test.js pins fox's stated invariant:
  'whatever one device shares all should see, and when unshared none
   should see.'

Eight scenarios across 2-3 fake browser sandboxes, each holding the
shipped handleRemoteSfuTrack + renderVideoTile + removeVideoTile +
watchVideoTrackForRemoval + the maps they own:

- one peer publishes camera -> every other peer ends with that pubHex
  in cameraStreams + a tile entry
- one peer unshares (track ended) -> every other peer drops that pubHex
- one peer unshares mid-flow (mute past window) -> drops correctly
- hiccup supplant (same pubkey, new track) -> tile preserved AND
  pointed at the new stream object (this is the MSID-supplant fix from
  d5e9e4e — fresh MediaStream per track means the video element binds
  to the new RTP cleanly)
- hiccup supplant + the OLD track's stream-identity guard prevents the
  NEW tile from being reaped
- supplant + sustained mute past window on the NEW track -> reaped
  correctly
- echo guard: a peer's own publish never enters their own cameraStreams
- three publishers fan-out: A B C all publish, every peer ends with
  exactly the other two

Wired into Makefile as test-mesh + added to test-all. Pure Node, no
browser or proxy server needed. Will catch the regressions where one
peer's publish/unpublish silently desyncs another peer's view.
2026-06-03 11:24:38 -04:00
27fea30680
zebra-spaces: log clicks on share/stop buttons + their state
Diagnostic-only: logLine fires on every share/stop button click with
the current sfuXPC/sfuXStream truthiness so the telemetry channel
shows whether (a) the click reached the JS at all, (b) the unpublish
function bailed at its 'nothing to do' early return, or (c) it
actually entered the close path. Helps diagnose the 'stop sharing
doesn't work' report without copy-pasting.
2026-06-03 11:19:04 -04:00
ce6242fff7
zebra-spaces: forward logLine to signal as 'client-log' for backdoor telemetry
Pairs with signal commit 3aebd67. Every logLine call after the local
render also fires a WS message to the signal server, which writes a
CLIENT_LOG line into /var/log/zebra-spaces-signal.log. Lets us debug
WebRTC cascades across multiple peers from a single grep instead of
asking each user to copy-paste their tab's log column.
2026-06-03 11:02:42 -04:00
d5e9e4eb02
zebra-spaces: wrap each remote video track in a fresh MediaStream
Real cause of 'host can't see laptop's camera on rejoin' (and the phone-
camera analogue): SFU's TrackLocalStaticRTP uses the same streamID
(shortPub-kind) for every publish of a given (pubkey, kind). When a
publisher supplants themselves the new track's MSID matches the old
one. Per WebRTC spec the browser merges it into the SAME MediaStream
object — ev.streams[0] is the same instance as before, containing
BOTH the dead old track AND the new live one. Setting
video.srcObject = ev.streams[0] doesn't switch the source; the video
element keeps showing the old track's last frame, reports muted, and
the 60s mute-window then reaps a tile that was never going to come
back on its own.

Fix: construct a fresh MediaStream from just ev.track. The video
element binds to the new RTP cleanly and the receive-side state
machine sees a real unmute as soon as packets arrive.
2026-06-03 11:01:03 -04:00
d8c8dae4ba
zebra-spaces: bump camera mute window 15s -> 60s for phone-network jitter
Repro: host shares camera, laptop sees it fine, phone (FF Android on
cellular) reaped the tile at 15s even though the publisher was alive
and well. The laptop's subscribe path doesn't see the same RTP jitter
the phone does — 15s was too tight for mobile networks. 60s sits
above the typical NACK/transport-cc recovery window and Pion's ICE
timeout: a path that's really broken fires renegotiation + 'ended'
within that window anyway, so the only difference is fewer false
reaps on jittery mobile paths.

Screen + game window stays at 120s — static content needs the longer
fuse.
2026-06-03 10:57:05 -04:00
56b7bfb187
zebra-spaces: send 'bye' on explicit leave so server skips hiccup grace
Matches signal commit 0547b4c. The leave button now sends a 'bye'
message before closing the WS — the server distinguishes a strong
leave from a hiccup, and we want the strong-leave path here. Without
the bye, an explicit leave would defer peer-left for 8s and other
peers would see the user's tiles + mesh PCs linger.
2026-06-03 10:43:15 -04:00
0878996e96
zebra-spaces: 120s mute window for screen + game (static content), 15s stays for camera
Screen shares and game shares can sit static for long stretches — a still
desktop, a paused video, a code editor with no caret movement. The
encoder genuinely stops emitting RTP, the subscriber's track goes muted,
and the 15s camera window would falsely reap the live tile.

watchVideoTrackForRemoval now takes a per-call windowMs; the sub-PC
ontrack handler passes VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS (120s) for
screen + game and VIDEO_REMOVE_MUTE_WINDOW_MS (15s) for camera. A
genuine unshare still resolves through the 'ended' path within a
frame, so the longer window only affects the slow-failure case.

Tests bumped to 18: new screen-window assertions + invalid-windowMs
fallback to the default rather than disabling removal entirely.
2026-06-03 10:33:28 -04:00
9419a415bf
test: state-machine tests for watchVideoTrackForRemoval
15 unit + integration tests that extract the function and the shipped
VIDEO_REMOVE_MUTE_WINDOW_MS constant from web/zebra-spaces.html so the
assertions track exactly what's deployed. Drives synthetic mute/
unmute/ended event sequences against a fake EventTarget track with a
fake clock injected through a Function-constructor harness.

Unit coverage:
- shipped window must be >= 10s (catches accidental shorten)
- initial mute (never flowed) never removes
- flowing + sustained mute past window removes
- mute + unmute within window cancels removal
- ended event removes immediately + cancels pending timer
- removeFn is idempotent (no double-call across mute, ended, or later
  events)
- redundant mute events do not stack timers
- rescue and removal both emit logLine telemetry
- rapid mute/unmute oscillation never removes while unmute lands in time

Integration coverage (realistic lifecycles):
- fresh track -> flow -> publisher unshares -> tile removed
- mobile network handoff (long mute) recovers without removal
- peer leaves abruptly (ended fires) -> tile removed once
- hard refresh of publisher (SFU supplant renegotiation gap) -> tile
  survives — this is the cascade fox flagged where a phone reconnect
  was killing the host's view of its camera
- publisher process crashes (mute holds indefinitely) -> removed at
  window

Wired into Makefile as test-video-removal + added to test-all. Pure
Node, no browser or proxy server needed.
2026-06-03 10:15:16 -04:00
bb1bf56527
zebra-spaces: bump video-track mute-removal window 3s -> 15s
Repros from fox:
- host shares screen, all peers see it, then non-phone speaker loses
  it after a few seconds
- hard refresh: tiles appear then disappear

Root cause: watchVideoTrackForRemoval declared the publisher gone
after 3s of mute. That's way too aggressive — a transient network
blip, NACK retransmission gap, brief CPU pressure on the publisher,
or a mobile network handoff can pause RTP for 3s without the publisher
actually unsharing. 3s mute fired remove(), tile disappeared even
though the SFU was still forwarding.

15s is enough to weather Wi-Fi stalls and mobile network handoffs
while still removing the tile within a reasonable window when the
publisher genuinely unshares.

Added logLine on both the timeout fire ('video track muted >15s —
removing tile') and the rescue ('RTP resumed before timeout — keeping
tile') so we can see what's actually happening on the receiver side
the next time something looks wrong.
2026-06-03 10:11:23 -04:00
415e6e27c9
zebra-spaces: full pubkey row under handle, above backup/logout
Was the truncated 25cc…1766 dangling next to the handle text field.
Now a dedicated row between handle and the backup/logout buttons
prints the full 64-char hex, monospace, word-break:break-all so it
wraps cleanly into 2-3 lines. user-select:all so a single click
selects the whole key for copy — the place where a user is most
likely to want to grab their identity for backup or to paste into
another channel for verification.
2026-06-03 09:59:06 -04:00
8e301070a1
zebra-spaces: hide-panel on mobile keeps the single-column stack
Desktop rule .page.controls-collapsed promotes the layout to a 2-column
rail (cameras 35% + timeline rest). Inside the mobile breakpoint that
same rule was winning by specificity and changing the shares column
width when the panel toggled. Override inside the mobile media query so
controls-collapsed on a narrow viewport still uses minmax(0, 1fr) —
single stack regardless of panel visibility.
2026-06-03 08:16:09 -04:00
21a6abe082
zebra-spaces: lighten the 'viewing' thumb overlay so the stream stays legible
Was: video opacity 0.35 + black overlay 0.55 — combined nearly opaque.
Now: video opacity 0.75 + black overlay 0.22 + text-shadow on the
badge so 'VIEWING' stays readable against any background. Still clearly
distinguishable from the un-spotlighted thumbs but the underlying
camera/screen content is visible.
2026-06-03 07:20:03 -04:00
4e2ce97e50
zebra-spaces: mobile thumbs span full viewport row instead of 2-up columns 2026-06-02 19:28:15 -04:00
52efb5db38
zebra-spaces: hide-panel — thumbnails fill the freed horizontal width
Two grid-only changes (no flex per CLAUDE.md):

1. When controls is collapsed, cameras-col grows from a fixed 220px to
   minmax(220px, 35%). On a wide viewport the freed right-side width
   gets split between cameras-col + timeline instead of leaving the
   cameras at 220px while the spotlight takes everything.

2. #tiles-thumbs goes from a single 1fr column to
   repeat(auto-fill, minmax(180px, 1fr)). Narrow column = 1 thumb per
   row (unchanged in default panel-showing layout); wide column = 2 / 3
   / N thumbs per row, depending on how much space they got.

Mobile breakpoint already used auto-fill with the same minmax; promoting
it to all viewports unifies the rule.
2026-06-02 19:25:14 -04:00
fc11aee569
zebra-spaces: re-acquire mobile camera on device orientation change
The aspectRatio constraint hint at getUserMedia time is unreliable on
Android Chrome / Firefox — the sensor's natural read-out orientation
gets locked at acquire time and rotating the phone does not update the
encoded frames. Listeners stay seeing whatever orientation the camera
was first opened in.

screen.orientation 'change' (with the orientationchange legacy event
as fallback) now triggers a re-acquire of the camera with the same
constraints. The fresh video track is swapped into the existing publish
PC's sender via replaceTrack — no SDP renegotiation, no SFU-side
supplant, no subscriber renegotiation. Subscribers just start
receiving frames in the new orientation within a few hundred ms.

Debounced 350ms so a fast rotation flick doesn't double-fire. Old
stream's tracks are stopped only after the swap so the encoder has
the new source ready before the old one ends.
2026-06-02 19:01:10 -04:00
6af83eb896
zebra-spaces: request landscape camera + letterbox portrait frames
Android Chrome / Firefox getUserMedia honour aspectRatio:{ideal:16/9}
on the camera constraints to lock the sensor read-out to landscape
regardless of the device's current screen orientation. Adding it stops
the back camera from sending a portrait frame the receivers can't
rotate.

Belt-and-suspenders on the render side: camera tile video switches from
object-fit:cover to object-fit:contain so any portrait stream that
slips through (older browsers, manual override) letterboxes inside the
16:9 box instead of being middle-cropped into a square. Black
background fills the bars.

Thumbnails stay cover-cropped (visual consistency in the column);
spotlight view always uses contain.
2026-06-02 18:35:31 -04:00
666ba8e88a
zebra-spaces: don't truncate handles — wrap instead of ellipsis 2026-06-02 18:02:40 -04:00
cd98bc6603
zebra-spaces: log-out confirm spells out the irrecoverable consequences
The pubkey IS the user's identity: host claims, room reservations,
cohost grants, meeting-life blocks all key off it. Without a vault
backup, logging out is one-way. The old confirm() prompt was a single
line that didn't convey 'you cannot be you again' clearly enough.

New prompt:
- prints the full pubkey hex so the user can copy it before clicking OK
  (panic-safety net)
- enumerates exactly what they lose
- ends with the recovery path ('backup / restore')

Wording chosen to nudge a backup BEFORE confirm, not to scare-off
intentional log-outs.
2026-06-02 17:51:55 -04:00
68dd6e333c
zebra-spaces: drop 'input' label; prefix dropdown options with 'input ' 2026-06-02 17:51:10 -04:00
40cc4a5598
zebra-spaces: mute state survives hard refresh
sessionStorage key MUTE_STATE_KEY persists '1' (muted) / '0' (live) so
the page reconnects in the same mute state the user last clicked. No
hot-mic on refresh — if you left muted, you come back muted.

Restored from ensureMicAndUI() after getMic() succeeds; applyMuteState()
syncs the button label, the mic track enable flag, the user's own room
row, and broadcasts mic-state to peers if we came back muted. Cleared
only on explicit leave; survives role demote → re-promote so reclaiming
the mic keeps the user's previously-chosen state.
2026-06-02 17:49:34 -04:00
8631c106d2
zebra-spaces: auto-rebuild publish PCs on ICE failure (mic/screen/camera)
Repro from fox: a listener joined a room while host's publish PCs were
silently in 'failed' state — saw no audio, no camera, no screen for
~60s until host left and rejoined, which created fresh publish PCs.

SFU log confirmed: host's mic/screen/camera publish PCs all hit ICE
failure within the first minute. SFU reaped them on
OnConnectionStateChange(failed). When the listener subscribed there
were zero publishers in the room — they got an SDP with only the
inactive placeholder m-line and never recovered until host re-published.

Page had a 'failed' rebuild path only for sfuSubPC (the subscribe side).
The three publish PCs were unwatched — once Pion-on-server killed them
they were dead but the page UI kept saying 'sfu: publishing as ...'
with nothing actually broadcasting.

New watchPublishPC() helper installs the same rebuild pattern on
sfuPubPC, sfuScreenPC, sfuCameraPC: on 'failed' the dead PC is closed,
the slot is nulled, and the publish entrypoint runs again. The
'wasOurs' guard prevents the rebuild from firing if the user explicitly
unpublished (which also fires onconnectionstatechange).

sfuGamePC gets a softer treatment — its source is a user-picked iframe
via Region Capture, so auto-restart isn't safe. The page just logs the
failure clearly + tears down so the share button comes back.
2026-06-02 17:37:14 -04:00
3a6b80e3f4
zebra-spaces: drop cohost-succession soft warning — room dies on host-grace expiry
Matches signal 0d5d6fa. Cohost cannot extend the room (no chime
authority), so space-closing always means 'this is going to end' —
removed the will_close=false branch.
2026-06-02 16:16:25 -04:00
f45e9563aa
zebra-spaces: room list — handles don't wrap, full pubkey on its own row(s)
Two-row (sometimes three-row) grid per member using grid-template-areas:
- row 1: badge | handle | mic | meter
- row 2: full 64-char pubkey, monospace, word-break:break-all, wraps as
  many lines as needed (rather than the previous shortHex truncation)
- row 3: mod-action buttons when present

Handle gets white-space:nowrap + ellipsis so a long handle ('bluediamond')
no longer fragments mid-word ('bluediamo nd') across grid lines. Pubkey
column also returns on mobile (was display:none under 500px); the new
layout fits naturally on narrow screens.
2026-06-02 16:02:39 -04:00
3041a1391a
zebra-spaces: don't let an old camera/screen/game track's removal evict the new tile
On publisher republish (cohost hard refresh → same pubkey + kind → SFU
supplants), the OLD track sees mute → ended on the subscriber side and
its watchVideoTrackForRemoval closure called removeXTile(pubHex). But by
then the NEW track had already populated the same tile entry — so the
old watcher tore down the live tile. Net effect: other peers saw the
cohost's camera flash on and disappear (the local preview kept rendering
because that path is independent of SFU subscribe).

Each removeFn now checks the streams store still maps pubHex to the
stream it was registered against; if not (the new track replaced it),
the old watcher is a no-op.
2026-06-02 15:34:56 -04:00
438e886627
zebra-spaces: surface 'host away — cohost succession in Xs' vs 'space closing'
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.
2026-06-02 15:31:21 -04:00
72d5208493
zebra-spaces: log full pubkey on every role / spotlight / boot / join / leave event
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.
2026-06-02 15:06:42 -04:00
54f97866a2
zebra-spaces: keep mesh on role change, drop all publishes on demote, no looked-away noise
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.
2026-06-02 14:45:15 -04:00
4ddba0d763
zebra-spaces: unified popularity list for screens + cameras + game-shares
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.
2026-06-02 14:15:10 -04:00
3526596936
zebra-spaces: never truncate in log lines — strip every shortHex() out of logLine()
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.
2026-06-02 13:13:41 -04:00
0dcc99599e
zebra-spaces: log full-width between .page and footer + resizable to 240px default
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.
2026-06-02 13:02:32 -04:00
bc7bbbfc7b
zebra-spaces: log every screen/camera/game ontrack with state — diagnostic
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.
2026-06-02 12:46:41 -04:00
f9026bf623
zebra-spaces: idempotent welcome on signal reconnect — quiet 'already publishing'
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.
2026-06-02 12:31:11 -04:00
df4668f14d
zebra-spaces: kind=game share — separate SFU publisher slot + Region Capture crop
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
2026-06-02 12:25:55 -04:00
5c26bc3232
zebra-spaces: 'share gameplay' on spotlit game tile — publishes the tab as a screen-share
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.
2026-06-02 12:16:19 -04:00
e3a62be500
zebra-spaces: game thumbs are pure static cards — no iframe, click to play
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'.
2026-06-02 12:10:50 -04:00
9869e7861d
zebra-spaces: only prune mute AFTER data flowed — fresh tiles stop disappearing
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.
2026-06-02 11:39:05 -04:00
aa36b84a47
zebra-spaces: stop subscribe→close→subscribe loop — listeners stay connected
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.
2026-06-02 11:36:05 -04:00
81b237eaa7
zebra-spaces: listeners auto-spotlight the latest screen — audience experience
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.
2026-06-02 11:32:09 -04:00
04e13e1f3a
zebra-spaces: games are proper spotlight tiles — click broadcasts + iframe loads big
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
2026-06-02 11:29:08 -04:00
6a72e77518
zebra-spaces: rebuild SFU sub PC on connectionstatechange + visibility resume
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.
2026-06-02 11:25:41 -04:00
a2dda2d586
zebra-spaces: games move to left column as persistent tiles — cameras+screens push them down
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.
2026-06-02 11:20:15 -04:00
44ac92984c
zebra-spaces: shadow CallFSM in the live page — log lifecycle transitions
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.
2026-06-02 11:13:30 -04:00
d9a743a680
zebra-spaces: wireZebraMachines orchestrator + integration tests (83 green)
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.
2026-06-02 11:10:16 -04:00
a29baeee7e
zebra-spaces: CallFSM — top-level join/leave/reconnect/boot lifecycle
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.
2026-06-02 11:07:55 -04:00
2d75cd7547
zebra-spaces: RemoteTileFSM — formalises frozen-thumb fix from f71e9e7
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.
2026-06-02 11:05:52 -04:00
57013ec9ad
zebra-spaces: SubscribeFSM — formalises renegotiation queue + reconnect
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.
2026-06-02 11:03:48 -04:00
ebda460574
zebra-spaces: lay down FSM framework + publishSpec + unit tests
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.
2026-06-02 11:01:33 -04:00
f71e9e74c4
zebra-spaces: detect publisher unshare via track 'mute' (+ debounce) — frozen-thumb fix
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.
2026-06-02 10:55:36 -04:00
6beb138490
zebra-spaces: parse SFU streamID with short-pubkey + dash — Firefox compat
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
2026-06-02 10:52:55 -04:00
bd770230f9
zebra-spaces: graceful mic re-acquire on BT/USB swap — no more leave+rejoin
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.
2026-06-02 10:45:53 -04:00