Commit graph

108 commits

Author SHA1 Message Date
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
af1e5d4b55
zebra-spaces: 3-column layout — cameras left, screen middle, controls right
Replaces the PiP overlay with a real 3-column grid: a narrow left
column carrying cameras stacked vertically, the timeline/screen in
the middle, controls on the right.

Layout rules:
- cameras-col is 220px wide on desktop, hidden via .hidden when no
  cameras are live (zero-cost when nobody has a webcam on — keeps
  the original two-column feel for typical mic-only rooms)
- hide-panel still folds away the right column → 2-col cameras+screen
- on <800px viewports all three stack vertically: controls, then
  timeline (or screen), then cameras as a multi-column grid

Camera tiles are now single-column stacked with a 16:9 aspect-ratio
letterbox so face-cams stay readable regardless of incoming resolution.
2026-06-02 08:58:55 -04:00
6dfa148612
zebra-spaces: hide-panel toggle + camera PiP over screen — full-bleed live view
UX problem in the screenshot fox sent: at full window with 1080p screen
share, the right controls column ate 360px every viewer would rather
spend on pixels, and the camera tiles rendered BELOW the screen so they
fell off-screen mid-stream.

Two fixes:
1. 'hide panel' button next to the theme toggle. Click collapses the
   right column to zero, screen-share fills the whole viewport width.
   Persists to zebra-spaces-controls-v1; applied pre-paint on <html>
   so a 'hidden' reload doesn't flash the panel before hiding it.

2. Cameras get out of the way when a screen share is active. CSS :has()
   detects sec-screens visible and promotes #sec-cameras to absolute
   positioning, bottom-right of the timeline column, ~22% width, max
   70vh, dark backdrop. Standard Zoom/Meet PiP placement. Multiple
   cameras stack vertically inside the strip (single-column grid in
   PiP mode). When nobody is sharing screen, cameras render normally
   in their multi-column grid below the timeline iframe.
2026-06-02 08:57:14 -04:00
8c90379006
zebra: dark mode default on all pages + unified theme key
All five pages — chat (zebra-audio), zebra-audio, how-it-works,
host-your-own, zebra-spaces — now ship with the same dark-mode
infrastructure: pre-paint head script, shared dark CSS block, fixed
top-right theme toggle, and one localStorage key ('zebra-theme-v1')
shared across pages so the user's choice follows them.

Dark IS the default: missing pref reads as dark, only an explicit
'light' opts out. First-time visitors land in dark without a flash.

The shared CSS covers the surfaces every page has (body, links,
buttons, inputs, dots, meter, status, log, hr, footer) so each page
looks intentional in dark without per-page tuning. zebra-spaces keeps
its richer overrides for badges + latency rows + tile metas.
2026-06-01 22:18:02 -04:00
3539171fd3
zebra-spaces: hoist musicMode + mic/cam deviceId declarations — fix TDZ blocking init
ReferenceError 'can't access lexical declaration musicMode before
initialization' halted the page script at line 692 (the localStorage
restore), which left every event handler unbound — including 'enter',
so users couldn't join a room at all.

Cause: the persistence patch placed the restore right after the handle
restore, but the let-declarations for those vars still lived 400+ lines
further down. `let` puts the binding in the temporal dead zone until
its declaration executes — reads from above throw.

Fix: declare `let musicMode, micDeviceId, cameraDeviceId` at the top
near the other prefs, drop them from the later `let` lines so the page
init doesn't re-shadow them.
2026-06-01 21:59:55 -04:00
54c03beec3
zebra-spaces: real dark mode — explicit overrides, pure black bg, low light emission
Replaced the filter:invert approach (which produced muddy mid-tones and
left the html canvas + scrollbar areas flashing white) with explicit
.theme-dark overrides for every painted surface:

- bg pure #000 on html + body — no light emission outside content
- text #ccc (dim, easy on eyes; not glaring #fff)
- borders #222-#444 (visible but not loud)
- inputs/textarea #0a0a0a — sit a hair above pure black
- buttons inverted: button.invert (primary) is #ccc-on-black,
  regular buttons are #000-with-#ccc-text-and-#444-border
- badges, dots, meters re-coloured for dark contrast
- latency green/yellow/red shifted toward higher-luminance variants
  so they stay readable on black
- log + notice banners get dark-tinted backgrounds matching their kind
- video/camera tiles unchanged — they were already dark and look fine

Video pixels never get filtered now, so screen-share + camera streams
render their actual colours instead of being inverted.
2026-06-01 21:58:01 -04:00
b32661ce50
zebra-spaces: dark/light toggle (top-right), persists in localStorage
CSS filter approach — invert(1) hue-rotate(180deg) on body, with a
matching re-invert on video / canvas / img so the actual content
(screen-share, camera, QR code) still reads correctly. One toggle
flips every painted colour in one stroke: 'invert everything' as
literally as the browser will let us.

- floating top-right button (.theme-toggle)
- preference persists across reloads via 'zebra-spaces-theme-v1'
- inline <head> script applies the class before first paint to avoid
  a white-flash for users who pick dark mode
- the same button label flips between 'dark' and 'light' so the user
  knows which mode the click switches TO
2026-06-01 21:45:23 -04:00
872d3a4df1
zebra-spaces: bump playoutDelayHint to 400ms — Wi-Fi peak jitter eats 200ms buffer
Listener reported clean network (43ms RTT, 0% loss) but 53ms smoothed
jitter, which on Wi-Fi typically peaks at 150-250ms inter-arrival. A
200ms playout buffer overflows on those peaks; 400ms absorbs them.

Tradeoff: extra ~quarter-second of latency. For music broadcast that
is invisible; for conversation it remains well below noticeable
turn-taking thresholds.

Applied to both SFU subscribe (mic audio path) and mesh peer audio.
2026-06-01 21:40:14 -04:00
383ed9af1e
zebra-spaces: persist music-mode + mic deviceId + camera deviceId in localStorage
Same shape as the existing handle persistence — three new keys
(zebra-spaces-music-mode-v1, -mic-device-v1, -cam-device-v1), restored
right after the handle on page load (before any device enumeration so
the first getUserMedia uses the right device + constraints), and
written on every change handler.

Survives reload + leave/enter cycles. Identity wipe via 'log out' does
not touch these — they're device preferences, not identity.
2026-06-01 21:38:23 -04:00
18e6410820
zebra-spaces: latency panel adds packet loss % + jitter ms — pinpoint chops
Three numbers per row now: RTT, loss%, jitter, plus path tag. Loss is
computed as a delta vs the prior poll (not lifetime cumulative) so a
spike during chops shows immediately instead of being averaged into a
session-long denominator. Row colour bumps to the worst of the three:
green RTT with 5% loss reads red.

Reading guide:
- RTT < 50 / loss < 0.5% / jitter < 20ms  → green (clean)
- RTT < 150 / loss < 2% / jitter < 50ms   → yellow
- otherwise                                → red

What pattern correlates with your chops:
- loss spikes + green RTT → network: TURN or upstream queue
- jitter spikes + clean loss → buffering / Wi-Fi micro-bursts
- all clean but chops continue → source-side (PulseAudio loopback,
  encoder starvation)
2026-06-01 21:34:04 -04:00
4c130449ad
zebra-spaces: latency panel + 200ms jitter buffer + drop DTX on music + link section moved
Latency panel:
- new collapsible section in the right column under role-actions
- polls getStats() every 2s across every live PC: sfuPubPC, sfuScreenPC,
  sfuCameraPC, sfuSubPC, and each mesh peer
- per row: name (e.g. 'sfu mic out', 'sfu in (host + screen + cams)',
  'peer <handle>'), RTT in ms (from candidate-pair.currentRoundTripTime),
  path kind (LAN/WAN/TURN). Colour-tagged: green <50ms, yellow <150ms,
  red 150+. Hides when no PC is live.
- the SFU subscribe row carries every incoming kind (mic + screen +
  camera) because they all share sfuSubPC at the WebRTC layer — labelled
  accordingly so users don't expect three separate rows.

Audio chops:
- bump playoutDelayHint from 100ms to 200ms — Wi-Fi micro-bursts on
  weak links can spike past 100ms and the smaller buffer dropped frames
- preferStereoOpus now takes { music: bool }; music mode = usedtx=0
  because DTX's comfort-noise on/off transitions audibly pop on
  continuous music signals. Voice mode keeps usedtx=1. Screen-share
  audio is always music-grade (system audio capture, not voice).

UX (per fox):
- 'share' section renamed to 'link' and moved directly above the log
  section, out of the way of the join/role controls.
2026-06-01 21:25:23 -04:00
acc7c1c6ab
CLAUDE.md: document leave+enter as the way to pick up SDP/codec deploys
Every live RTCPeerConnection is locked to the SDP it was created with.
Page JS reloads don't reach into existing PCs; only fresh negotiation
does. So when a deploy changes fmtp / rtcp-fb / SFU codec registration,
the user just leaves the space and re-enters — no full tab reload
needed.

Caught during the NACK+transport-cc+usedtx rollout: hard refresh wasn't
required, leave+enter was enough.
2026-06-01 21:20:03 -04:00
ca51044e6c
zebra-spaces: drop unmute-audio button from screen/camera tiles
The button toggled video.muted but the change didn't actually re-route
audio through the speakers in any browser we tested. Less broken UI is
better than broken UI.
2026-06-01 21:09:28 -04:00
353037bc5d
zebra-spaces: kill audio chops + clear tile when publisher unshares
Audio robustness:
- useinbandfec=1 + usedtx=1 in publisher's Opus fmtp (was only fec; dtx
  drops silence so the budget goes to audible content + cuts congestion)
- ev.receiver.playoutDelayHint = 0.1 (100ms jitter buffer) on every
  incoming audio receiver — SFU subscribe + mesh peer. Absorbs Wi-Fi
  micro-bursts without perceptible conversation lag.

Tile cleanup on unshare:
- when SFU stops a screen/camera transceiver after the publisher
  unpublishes, the remote track fires 'ended'. Listeners now wire
  track.onended → removeScreenTile/removeCameraTile so the tile
  disappears instead of freezing on the last frame.

Paired with SFU 60a620f which advertises nack + transport-cc feedback
so browsers send the RTCP we depend on for both retransmit and
congestion control.
2026-06-01 21:05:30 -04:00
7ddedc9a09
zebra-spaces: speakers can toggle camera (1280x720 @ 1.5Mbps), separate publish
Camera is a third SFU publisher alongside mic and screen — same multi-
track pipeline, kind=camera suffix on streamID, own #cameras grid in
the timeline column (multi-column auto-fill so multiple face cams fit
without dwarfing a screen share above them).

- new UI: share-camera / stop-camera buttons + device selector in the
  same share section. Camera-select restart triggers a clean unpub +
  re-pub because deviceId change needs renegotiation anyway.
- new state: sfuCameraPC/Stream/PeerID + cameraStreams/cameraVideos maps
- subscribe ontrack: generic colon-split routes pubkey:screen/camera
  to the right tile renderer; back-compat for the existing :screen path
- renderScreenTile + removeScreenTile refactored to a kind-parametric
  renderVideoTile via a TILE_KINDS map; the old names stay as thin
  shims so callers don't change
- role demotion + leave + peer-left + boot all clean up cameras too
- contentHint='motion' for face-cam (vs 'detail' on screen)
- camera bitrate capped at 1.5Mbps so screen-share headroom isn't
  cannibalised when both are publishing simultaneously

Paired with SFU change zebra-spaces-sfu#017c94b which allowlists
kind=camera alongside screen.
2026-06-01 20:43:49 -04:00
3f43379e00
zebra-spaces: applyConstraints + log effective mic settings — surfaces hidden filtering
When a PulseAudio monitor source is selected as the mic and music mode
is toggled on, Firefox can silently apply its default audio-processing
pipeline (EC/NS/AGC) regardless of the getUserMedia constraints. The
broadcast then sounds 'cleaned up' instead of letting the source pass
through transparently.

Two fixes:
- call track.applyConstraints(micConstraints()) after getUserMedia/replace.
  Some UAs honour applyConstraints when they silently ignored the initial
  request. Belt-and-suspenders.
- log track.getSettings() so we can see what the UA actually applied —
  ec/ns/agc/channels/sampleRate. If applyConstraints didn't stick, the
  log shows it instead of failing silently.
2026-06-01 20:33:41 -04:00
1294e96f01
zebra-spaces: surface getDisplayMedia audio-track count + browser-specific hint
Firefox getDisplayMedia silently drops audio for window/tab sources (only
'entire screen' carries system audio). Users were broadcasting video-only
without knowing the audio never made it into the captured stream.

Log the captured track counts unconditionally; when audio is 0 explain
the limitation per browser and point to the workaround (route the tab
through mic music mode for high-quality stereo broadcast).
2026-06-01 20:27:38 -04:00
6fbeade12d
zebra-spaces: tap-to-play overlay + HTML muted attribute — fixes Firefox Android autoplay
Firefox Android (and other strict mobile browsers) check the HTML 'muted'
attribute, not the IDL .muted property, when deciding whether MediaStream
<video> is autoplay-eligible. Setting only the property left the video
ineligible, so play() rejected silently and the user saw a black tile.

Three changes:
- set the autoplay/playsinline/muted attributes alongside the properties
  so every UA's autoplay heuristic agrees the element is eligible
- log play() rejections instead of swallowing them — silent failures hid
  this from us until now
- when play() does reject, show a 'tap to play' overlay that covers the
  video area (but not the meta bar); tapping counts as the gesture and
  the retry succeeds
2026-06-01 20:20:10 -04:00
5affebead5
zebra-spaces: broadcast-quality mic + screen capture (stereo Opus 256k, 1080p30 @ 6Mbps)
Music mode mic upgrades:
- request stereo @ 48kHz from getUserMedia (was mono default)
- RTP-level bitrate cap 256kbps (was 160kbps mono — too low for music)
- SDP fmtp munged to advertise stereo=1+sprop-stereo=1+maxaveragebitrate=256000
  so both ends agree on stereo + room to use the bitrate
- music-mode toggle now re-publishes the SFU PC instead of just replaceTrack
  (replaceTrack alone leaves the negotiated SDP mono — stereo never reaches
  the SFU even with a stereo track)

Screen-share upgrades:
- getDisplayMedia asks for 1920×1080 @ 30fps + stereo 48kHz audio
- video sender capped at 6Mbps, audio sender at 256kbps
- track contentHint 'detail' for video (favour pixels over framerate)
  and 'music' for audio
- offer SDP munged for stereo Opus same as mic

Helper added: preferStereoOpus(sdp, bps), setSenderMaxBitrate(sender, bps).
2026-06-01 20:18:24 -04:00
c454164d06
zebra-spaces: wider layout, narrower right column, 92vh video, trim prose
Maximize screen-share resolution on desktop/laptop:
- drop body max-width cap (was 1440px) so layout breathes to full viewport
- shrink right column from 520px to 360px; left column gets the surplus
- bump screen-tile video max-height from 80vh to 92vh
- tighter padding + gap

Cut four prose blocks to roughly a third:
- identity note: full key-management paragraph → 'key lives in this browser only'
- input note: full role/encryption paragraph → 'join as listener; host promotes to mic. end-to-end encrypted.'
- share note: full URL-embedding paragraph → 'share only with people you trust to hear the room.'
- screen-share note: full picker explanation → 'window or tab; tick share audio if offered.'
2026-06-01 20:06:05 -04:00
2fd2ac3546
zebra-spaces: start remote screen tiles muted + add unmute button — fixes mobile autoplay
Mobile browsers refuse to autoplay a <video> carrying an unmuted audio
track without a user gesture; the whole element stays paused, so the
video pixels never render either. The phone listener saw a black tile
even with a healthy track flowing.

Start the remote tile muted so it autoplays everywhere; expose an
'unmute audio' button in the meta bar that toggles. The button click
counts as the gesture, so audio kicks in on demand. Local preview never
offers unmute (would feed back into the publisher's own mic).
2026-06-01 20:02:03 -04:00
d9852e661e
zebra-spaces: local preview tile so publisher sees their own screen share
The SFU does not echo a publisher's stream back, so anyone sharing alone
in a room had no visual confirmation — game iframe stayed up, no tile
appeared. Render a muted local preview keyed by myKeys.pubHex on publish,
remove on unpublish. Subscribe path skips own pubHex to defend against
SFUs that do echo publishers.

Also refreshes integrity stamps on the other web pages (date drift).
2026-06-01 19:45:15 -04:00
e8ba836b9c
zebra-spaces: shared screens render in the left column (full width), game iframe hides while sharing
Previously sec-screens lived inside the controls aside, capped at 520px
wide — useless for any real screen view. Move it into the timeline
column at the top, where there's actual horizontal space. CSS :has()
on .timeline hides the game tabs + iframe whenever a screen tile is
visible, so the screen owns the full left column. As soon as the last
sharer stops, the game switcher returns.
2026-06-01 15:21:38 -04:00
70de089754
zebra-spaces: log out button next to backup/restore — wipe identity + generate fresh 2026-06-01 15:10:13 -04:00
5e23ad53ff
zebra-spaces: screen share — share button, separate sfuScreenPC, render incoming :screen streams as <video> tiles
UI:
- sec-screen-share: 'share screen' button visible only when canSpeak
  (host/cohost/speaker). On click: getDisplayMedia({video, audio}).
- sec-screens: video tile per active screen share; auto-shows when
  the first tile arrives, auto-hides when the last leaves.
- Each tile has a 'fullscreen' button.

Publish:
- sfuPublishScreen opens a SEPARATE sfuScreenPC, adds the
  display-media tracks (video + optional audio), POSTs
  /publish?kind=screen so the SFU's TrackLocal streamID gets a
  ':screen' suffix.
- Browser-native 'stop sharing' bar fires the video track's 'ended'
  event — we trap it to do a clean sfuUnpublishScreen.

Subscribe:
- sfuSubscribe ontrack checks streams[0].id for the ':screen' suffix
  — screen streams route to renderScreenTile (creates / updates a
  <video> element). Mic streams continue down the existing audio
  path. The label uses the publisher's handle from the room state.
- Cleanup paths: peer-left (drops the matching pubHex's tile),
  self-boot, self-blocked, leave button — all call
  sfuUnpublishScreen + removeScreenTile.

CSS: screen-tile has its own 1fr grid, max-height 70vh so a screen
share never bullies the controls column off the page on a small
display.

Pairs with the multi-track + kind=screen support that just landed in
zebra-spaces-sfu (b7c5a8a).
2026-06-01 14:41:27 -04:00
95d68fafac
zebra-spaces: timeline column gets a game switcher (unmario / cake murder adventure) 2026-05-31 18:35:04 -04:00
c7a8a938b3
zebra-spaces: load unmario.com in the timeline column until v0.3 lands 2026-05-31 16:57:19 -04:00
f3b154da2b
zebra-spaces: terminal block + share URL + autofill; host-your-own: SFU section
- Block is now terminal: any 'blocked' signal-server error or 403 from
  the SFU calls handleBlocked() which shows a clear notice, stops the
  WS reconnect loop, tears mesh + SFU + mic, and unlocks the enter
  button. Stale UI no longer spins trying to rejoin a room the user
  was kicked from. Reset on a fresh joinSpace.
- Share URL: after joining, sec-share shows a copyable link with
  ?code=…  embedded; opening that link autofills the rendezvous code
  field. QR canvas placeholder is in place; encoder lands in the next
  push (defer to keep this commit small).
- host-your-own.html gains section 8: zebra-spaces (multi-party rooms)
  describing both Go services that make spaces work — the
  zebra-spaces-signal authority-chain rendezvous and the Pion-based
  zebra-spaces-sfu audio fan-out. Includes the two Caddyfile routes
  (with flush_interval -1 for SSE), the NAT1To1 env var, and the
  single UDP mux port — so someone wanting to host their own community
  can stand the whole stack up. Sections 9-11 renumbered.
2026-05-31 15:53:14 -04:00
e72f2e8eed
zebra-spaces: pass identity pubkey on SFU /subscribe so boots evict the listener path; tear SFU + mesh on self-boot 2026-05-31 15:20:03 -04:00
f56bd39980
zebra-spaces: invite banner stacks message above accept/decline buttons 2026-05-31 15:10:12 -04:00