Commit graph

290 commits

Author SHA1 Message Date
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
a1ce6c0598
zebra-spaces: fix mobile horizontal scroll + host rejoin audio + timeline below
Three issues fixed together:

1. Mobile horizontal scroll: dynamically-appended <audio> sinks render
   their default ~300px control strip on Firefox mobile, pushing the
   page wider than the viewport. Hide them globally with
   'audio { display: none }' — they're just sinks for the WebRTC track,
   no UI required.

2. Don't use literal 50/50 split: page grid is now
   'minmax(0, 1fr) minmax(0, 520px)' so the controls cap at 520px and
   the timeline soaks the remainder. min-width:0 on both children so
   they shrink without forcing an outer scrollbar.

3. Mobile order: on phones the controls were stacked ABOVE the
   timeline placeholder; fox wants them first. Use 'order: 1' on
   .controls and 'order: 2' on .timeline inside the 800px breakpoint.

4. Host rejoin audio: when a host leaves and rejoins, Pion frequently
   reuses the same transceiver on the SFU side, so 'ontrack' doesn't
   fire a second time on the listener — but the listener already tore
   down the audio element on peer-left. The audio plays into nothing.

   Replace the pending-tracks queue with sfuStreamsByPubHex (keyed by
   the publisher's pubkey, which is the streamID). The cached
   MediaStream survives the host's session uuid changing across
   leave/rejoin. On peer-joined / host-promoted / state we look up the
   cached stream by pubkey and attach to the fresh audio element.

   Also call a.play() after the srcObject swap so the autoplay policy
   doesn't quietly drop the resumed playback.
2026-05-31 14:19:43 -04:00
c525c7df11
web: bump mobile body padding from 1rem to 1.25rem so the right edge has visible breathing room 2026-05-31 14:13:25 -04:00
cc46d96d30
zebra-spaces: queue SFU ontrack until peer-joined catches up
Race when the host rejoined during their 30s grace window: the SFU
renegotiation that delivered the new mic track arrived over SSE BEFORE
the signal-server's peer-joined for the rejoined host. ontrack's
streams[0].id (= host's pubkey hex) had no matching member yet, so
the track was silently discarded — the listener saw no audio.

Cache the unmatched stream by pubkey hex and flush whenever the
roster updates (peer-joined, host-promoted, state). When the rejoined
host's peer-joined arrives, the queued stream pops out and attaches
to their row. Cleared on sfuUnsubscribe so stale streams don't carry
across rejoins.
2026-05-31 14:08:53 -04:00
2051a712af
zebra-spaces: clear the 'space closing in 30s' status when host returns
space-closing flips the top status line to a red 'host left — space
closing in Ns' warning. Nothing was clearing it when the host actually
came back during grace (peer-joined with role=host) or when a senior
cohost auto-promoted (host-promoted). Both paths now reset the status
line to 'connected as <my role>' so the warning doesn't stick after
the rescue.
2026-05-31 14:02:58 -04:00
3a07426994
zebra-spaces: own mute flips own mic icon
mic-state broadcasts only inform OTHER peers, so the local view of our
own member row stayed muted=false forever — the green mic-on icon
never flipped to the red cross when the host (or any speaker) muted.
Set members.get(myUUID).muted before sendMicState so renderRoom
reflects it immediately on our own row.
2026-05-31 13:54:23 -04:00
c8a9adc423
web: every page works on mobile without horizontal overflow
- index.html, kernel.html: were missing the viewport meta entirely;
  mobile fell back to the default 980px layout and the content was
  scaled down. Add the standard 'width=device-width,initial-scale=1'
  and centre the body with margin:0 auto so the 640px max-width is
  centered instead of left-aligned on wide displays.
- host-your-own.html, how-it-works.html, zebra-audio.html, chat.html:
  add a 600px breakpoint that tightens body padding to 1rem, shrinks
  the h1 from 3rem to 2.2rem, and lets long URLs / pubkey hashes wrap
  via overflow-wrap:anywhere on body prose. .code / .diagram already
  use overflow-x:auto so internal scrolling stays bounded to the box,
  never pushes the page wider than the viewport.
- chat.html: cap the QR share box's canvas (was a fixed 320px square)
  with max-width:100% on phones, so 320px-wide handsets don't overflow.
- zebra-audio.html: the mute button had a fixed 6rem width to keep the
  'mute'/'unmute' label from shifting the row; on narrow phones that
  width forced wrapping. Drop it back to auto on small viewports.

All pages stamp+verify; the integrity footer hashes in the source
match the served files after `make stamp`.
2026-05-31 13:52:32 -04:00
1a6e955424
zebra-spaces: client wires grant-mic for hand-raised listeners
The 'give the mic' button (visible only when the listener has raised
their hand) now sends grant-mic, which the server promotes directly
without waiting for accept-mic. The 'invite mic' button (cold invite)
still triggers the existing mic-invite + accept dance.

Also fold in the earlier sfuPublish diagnostic logging so silent
returns surface in the page log.
2026-05-31 13:25:51 -04:00
9ed1cefcd5
zebra-spaces: SFU bridge + role-change notice banner
Listeners now hear all speakers via a Pion-based audio-only SFU on
proxy.uncloseai.com. Speakers publish to it; listeners subscribe and
get one PC carrying every speaker's track. Mesh remains the
low-latency speaker<->speaker path; SFU is the broadcast fan-out.

Wiring:
- sfuPublish/Unpublish for speaker role transitions
- sfuSubscribe/Unsubscribe for listener role transitions
- onRoleEntered + onRoleChanged + leave hooks
- ontrack on the subscribe PC maps streams[0].id (= publisher pubkey
  hex, set as the SFU TrackLocal StreamID) -> room member uuid; audio
  element + meter attach to the matched member row
- SSE renegotiation: SFU pushes offer when speakers come/go; browser
  answers via POST /answer
- applyMicMode now hot-swaps the SFU publish sender's track too, so
  voice/music mode toggles apply over the SFU just like the mesh

Notice banner — fox flagged that booted/demoted users had no visible
signal. Added a #sec-notice section with .notice-banner (warn for
boot/demote-to-listener, info for promote). Boot also sets
wantConnected=false to prevent the WS auto-reconnect loop from
rejoining into a boot loop.
2026-05-31 12:48:20 -04:00
7e54e1b7ef
zebra-spaces: persistent per-peer meter runner (survives re-renders + wires local mic)
Two real defects in the meter wiring:
- the local mic was never fed to its meter — only remote streams were
- renderRoom replaces every member's meter DOM element, so the old
  meterFor tick loop (which captured the OLD fill via closure) exited
  on the next frame and the bar froze forever

Replace with a per-uuid meterCtl Map that holds the analyser + buffer
once and re-reads members.get(uuid)._meterEl fresh every animation
frame. Cleanup is automatic when the uuid drops from members (peer-
left, boot) or explicitly via stopMeter (dropMic, applyMicMode).
applyMicMode re-wires the local meter against the freshly acquired
stream since the old MediaStreamSource dies with the old track.
2026-05-31 12:10:57 -04:00
cd4a0ff6f5
zebra-spaces: mod-action buttons drop to a new row under each member
The grid was packing badge | handle | pubkey | mic | meter | buttons on
one line, which got crowded with three or four actions visible. Drop
mod-actions to a second grid row spanning the full width, indented
under the handle column. Hides itself entirely when empty so rows with
nothing to act on don't reserve dead space.
2026-05-31 12:00:10 -04:00
a038adde7b
zebra-spaces: two-column layout — right=controls (50%), left=timeline (placeholder)
The control panel (identity, space, room, log) moves to a right column
occupying 50% of the page width. Left column is reserved for a timeline
to come; for now it shows a one-line placeholder. Stacks to single
column below 800px so mobile stays usable. Footer spans full width.
2026-05-31 11:59:11 -04:00
a2d43e40c3
zebra-spaces: show mic input + music-mode controls before getting the mic
They were hidden behind the speaker role, so listeners (and the host
during the brief window before mic permission resolves) could not
disable echo/noise cancellation. Make both rows always visible; the
preference is picked up by micConstraints() the next time getMic runs,
so toggling before becoming a speaker is honored.
2026-05-31 11:52:33 -04:00
dedb44179d
zebra-spaces: JS↔Go protocol parity + crypto tests
test/zebra-spaces.test.js — pure Node, four tiers:

1. pure protocol parity: extracts sigJoin/sigAction directly from
   web/zebra-spaces.html (so the test tracks the shipped page),
   compares produced bytes against fixtures pinned to the Go-side
   unit tests in proxy.unturf.com/cmd/zebra-spaces-signal/main_test.go.
   If JS drifts from Go by one byte the test fails — exactly the
   silent break that would kill promotions in production.

2. ed25519 sign/verify: WebCrypto Ed25519 round-trip + tamper detection,
   the same crypto stack the page uses for signed role transitions.

3. vault round-trip: PBKDF2 600k + AES-GCM, mirrors vaultExport/Import
   in the page. Verifies wrong-password rejection.

4. live server (optional): if ZEBRA_SPACES_BINARY is set, launches the
   relay, dials over real WebSocket, drives full join -> mic-invite ->
   accept flow using browser APIs end to end.

Makefile: 'test-zebra-spaces' target auto-builds the relay binary
from ../proxy.unturf.com when present so the live tier runs without
manual setup. 'test-all' now includes it.
2026-05-31 11:06:59 -04:00
1e76f62f71
zebra-spaces v0.1: multi-party voice rooms with roles, identity, vault
New page: web/zebra-spaces.html. Extends the rendezvous + WebRTC mesh
model from zebra-audio (1:1) to a room of up to ~8 speakers (host + 2
co-hosts + speakers) with listeners (deferred to v0.2 for SFU fan-out).

Identity: per-browser persistent Ed25519 keypair in localStorage,
plus a per-session UUID for in-room "whose who". Password-vault
backup/restore (PBKDF2 600k + AES-GCM, matches zebra-audio's crypto)
emits a self-contained 'zspc-id-v1|...' blob.

Roles: everyone joins as listener; mods (host + co-hosts) extend
mic-invites that listeners accept/decline; host promotes to co-host;
mods demote and boot. Co-hosts cannot remove the host. Every role
transition is signed by the acting mod's Ed25519 over a canonical
input bound to room_id + epoch, so a compromised relay cannot forge
promotions, only refuse to relay them.

Pairs with cmd/zebra-spaces-signal in proxy.unturf.com.

Nav: zebra-audio and host-your-own cross-link to spaces.
Makefile: stamp target now covers zebra-spaces.html (web/chat.html
and web/how-it-works.html stamps refreshed today as a side effect).
2026-05-31 10:54:36 -04:00
80e062c084
zebra-audio: puppet reveal counts our own clicks — mobile taps work too
Trigger was event.detail === 3, the native UI triple-click counter, which
mouse triple-clicks increment but mobile taps do not, so the easter egg
was unreachable on phones. Count 3 clicks within 800ms ourselves; desktop
triple-click still satisfies it (still 3 click events), and finger taps
now reveal the console.
2026-05-29 19:14:15 -04:00
17ba25f9cb
zebra-audio: stop displaying peer IP/port in path status — no dox
reportPath used to print 'you A:B ↔ peer C:D' in path-status and the log,
exposing both participants' real IP addresses to anyone glancing at the
screen. Show DIRECT vs RELAYED and the candidate types
(host/srflx/relay) only; addresses are gone. CLAUDE.md gains a
'Web UI privacy — never display peer IPs' rule so this does not regress.
2026-05-29 17:43:33 -04:00
5e1c4402b2
web: host-your-own page + nav links; deploy-to-both-repos reminder
Add web/host-your-own.html and link it from the chat and how-it-works headers
and footers. make stamp now stamps it alongside the other pages. CLAUDE.md:
turn the deploy flow into an explicit push-BOTH-repos reminder (zebra-report
source + www.unturf.com served) and list host-your-own.html as a deployed page.
2026-05-29 15:17:22 -04:00
b269fce2f6
zebra-audio: secret TTS puppet console
Triple-click the footer integrity seal to reveal a hidden console: type a
line, pick one of the cloned voices, and it is synthesized at
speech.ai.unturf.com (/v1/audio/speech, no key, CORS open) and played into the
call — swapped onto the outbound WebRTC track so the listener hears the voice,
and to local output so you do too. The speak button doubles as stop: aborts an
in-flight synthesis or halts playback, then swaps the live mic back.
2026-05-29 15:17:16 -04:00