Commit graph

281 commits

Author SHA1 Message Date
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
cdcc15365a
zebra-spaces: spotlight broadcast — log + popularity-sort thumbnails
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.
2026-06-02 10:40:38 -04:00
a64dcc29c7
zebra-spaces: thumbnails stay put — gray + 'viewing' overlay instead of disappearing
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
2026-06-02 10:29:59 -04:00
4f11738731
zebra-spaces: spotlight model — one tile big in middle, click thumbnail to swap
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.
2026-06-02 10:25:14 -04:00
ae9721e336
zebra-spaces: serialise SFU subscribe renegotiation + skip own mic echo
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.
2026-06-02 10:20:48 -04:00
622db439d7
zebra-spaces: speakers also subscribe to SFU so they see each other's screens + cameras
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.
2026-06-02 10:15:05 -04:00
623c0a0b0c
zebra-spaces: drop screen-resume flow — two flows for the same result is worse than one
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).
2026-06-02 09:58:42 -04:00
abcbdc3568
zebra-spaces: auto-rejoin call after hard refresh + camera silent resume + screen resume prompt
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.
2026-06-02 09:52:31 -04:00
4ab41e9323
zebra-spaces: populate mic dropdown on page load (was: only on devicechange / getMic)
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.
2026-06-02 09:45:10 -04:00
e3c4431b8e
zebra-spaces: transparent video bg (tile bg shows through letterbox) + UI tweaks
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
2026-06-02 09:43:08 -04:00
c48312e3da
zebra-spaces: tile chrome defaults to light, dark mode restores black
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.
2026-06-02 09:37:14 -04:00
28f7e3ce9e
css: .row uses minmax(0, max-content) so long labels shrink + wrap
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.
2026-06-02 09:27:55 -04:00
327cb63b00
css: .row style guide + conditional templates — fix 'log out' breaking + note overlap
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.
2026-06-02 09:22:11 -04:00
a2d5071b71
zebra-spaces: cameras column truly hides when empty + timeline reflows
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.
2026-06-02 09:17:13 -04:00
eed5348567
css: grid-only layout — convert every flex container to grid + pin column widths
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
2026-06-02 09:12:38 -04:00
95851371bc
zebra-spaces: pin cameras column at 220px + focus-mode hides both side columns
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.
2026-06-02 09:03:11 -04:00