Switched from 'reserve 220px even when empty' to actually dropping the
column track when no camera is live. Uses :has() so the page grid
template flips between four states based on which side columns are
present:
cams + controls : 220px 1fr min(360px, 50vw) timeline col 2
cams only : 220px 1fr timeline col 2
controls only : 1fr min(360px, 50vw) timeline col 1
alone : 1fr timeline col 1
Timeline gets an explicit grid-column reassignment in the no-cameras
branches so it doesn't land in the wrong slot. Removed the
'display: grid !important' override on .cameras-col so the global
.hidden util can naturally drop it from the layout.
Two fixes in one — the immediate layout bug from the screenshot (timeline
sliding into column 1 with controls eating ~80% of the viewport) and
the architectural rule that all zebra page layout uses grid.
Layout bug (was: when cameras-col gets .hidden + the global .hidden
utility's display:none !important, the grid auto-placement promoted
.timeline into column 1 and .controls into column 2 → controls took
the 1fr middle track). Fix:
- explicit grid-column: 1/2/3 on cameras-col / timeline / controls so
each child stays in its assigned column regardless of siblings going
display:none
- .cameras-col uses 'display: grid !important' to override the global
.hidden util, then only its content (h2 + #cameras) goes display:none
via separate selectors when the .hidden class is present
- controls track clamped to min(360px, 50vw) so a wide window can't
let the side panel eat the screen-share area
Grid-only refactor:
- every flex container converted: .timeline, .cameras-col, .game-tabs,
.screen-tile, .screen-meta, .tap-play, .camera-tile, .row,
.mod-actions, .invite-banner, .invite-actions, .notice-banner
- chat.html + zebra-audio.html same treatment (.row, .field-row,
.share-box .copy-row, the inline H2 style, .mode-toggle, .dot)
- inline 'style=flex:1' on inputs/meters in chat.html replaced with
'style=width:100%'
- now zero 'display: flex' / 'inline-flex' across all five zebra pages
- CLAUDE.md documents the grid-only rule under web-page authoring
Two layout fixes from screenshot feedback:
1. The cameras column's 220px track is now reserved unconditionally.
When no camera is live, .hidden hides the section content (h2 + grid)
but the column track stays put — so the timeline (with the game
iframe or a live screen share) keeps its middle position instead of
sliding leftmost into the cameras slot.
2. Hide-panel is now a true focus mode: clicking it collapses BOTH the
cameras column AND the controls column, leaving the timeline alone
to fill the full viewport. The screen-share gets 100% of the page
width instead of 1fr-minus-220px. Pre-paint pref applies the same
collapse so a saved 'hidden' state lands flush on first paint.
Replaces the PiP overlay with a real 3-column grid: a narrow left
column carrying cameras stacked vertically, the timeline/screen in
the middle, controls on the right.
Layout rules:
- cameras-col is 220px wide on desktop, hidden via .hidden when no
cameras are live (zero-cost when nobody has a webcam on — keeps
the original two-column feel for typical mic-only rooms)
- hide-panel still folds away the right column → 2-col cameras+screen
- on <800px viewports all three stack vertically: controls, then
timeline (or screen), then cameras as a multi-column grid
Camera tiles are now single-column stacked with a 16:9 aspect-ratio
letterbox so face-cams stay readable regardless of incoming resolution.
UX problem in the screenshot fox sent: at full window with 1080p screen
share, the right controls column ate 360px every viewer would rather
spend on pixels, and the camera tiles rendered BELOW the screen so they
fell off-screen mid-stream.
Two fixes:
1. 'hide panel' button next to the theme toggle. Click collapses the
right column to zero, screen-share fills the whole viewport width.
Persists to zebra-spaces-controls-v1; applied pre-paint on <html>
so a 'hidden' reload doesn't flash the panel before hiding it.
2. Cameras get out of the way when a screen share is active. CSS :has()
detects sec-screens visible and promotes #sec-cameras to absolute
positioning, bottom-right of the timeline column, ~22% width, max
70vh, dark backdrop. Standard Zoom/Meet PiP placement. Multiple
cameras stack vertically inside the strip (single-column grid in
PiP mode). When nobody is sharing screen, cameras render normally
in their multi-column grid below the timeline iframe.
All five pages — chat (zebra-audio), zebra-audio, how-it-works,
host-your-own, zebra-spaces — now ship with the same dark-mode
infrastructure: pre-paint head script, shared dark CSS block, fixed
top-right theme toggle, and one localStorage key ('zebra-theme-v1')
shared across pages so the user's choice follows them.
Dark IS the default: missing pref reads as dark, only an explicit
'light' opts out. First-time visitors land in dark without a flash.
The shared CSS covers the surfaces every page has (body, links,
buttons, inputs, dots, meter, status, log, hr, footer) so each page
looks intentional in dark without per-page tuning. zebra-spaces keeps
its richer overrides for badges + latency rows + tile metas.
ReferenceError 'can't access lexical declaration musicMode before
initialization' halted the page script at line 692 (the localStorage
restore), which left every event handler unbound — including 'enter',
so users couldn't join a room at all.
Cause: the persistence patch placed the restore right after the handle
restore, but the let-declarations for those vars still lived 400+ lines
further down. `let` puts the binding in the temporal dead zone until
its declaration executes — reads from above throw.
Fix: declare `let musicMode, micDeviceId, cameraDeviceId` at the top
near the other prefs, drop them from the later `let` lines so the page
init doesn't re-shadow them.
Replaced the filter:invert approach (which produced muddy mid-tones and
left the html canvas + scrollbar areas flashing white) with explicit
.theme-dark overrides for every painted surface:
- bg pure #000 on html + body — no light emission outside content
- text #ccc (dim, easy on eyes; not glaring #fff)
- borders #222-#444 (visible but not loud)
- inputs/textarea #0a0a0a — sit a hair above pure black
- buttons inverted: button.invert (primary) is #ccc-on-black,
regular buttons are #000-with-#ccc-text-and-#444-border
- badges, dots, meters re-coloured for dark contrast
- latency green/yellow/red shifted toward higher-luminance variants
so they stay readable on black
- log + notice banners get dark-tinted backgrounds matching their kind
- video/camera tiles unchanged — they were already dark and look fine
Video pixels never get filtered now, so screen-share + camera streams
render their actual colours instead of being inverted.
CSS filter approach — invert(1) hue-rotate(180deg) on body, with a
matching re-invert on video / canvas / img so the actual content
(screen-share, camera, QR code) still reads correctly. One toggle
flips every painted colour in one stroke: 'invert everything' as
literally as the browser will let us.
- floating top-right button (.theme-toggle)
- preference persists across reloads via 'zebra-spaces-theme-v1'
- inline <head> script applies the class before first paint to avoid
a white-flash for users who pick dark mode
- the same button label flips between 'dark' and 'light' so the user
knows which mode the click switches TO
Listener reported clean network (43ms RTT, 0% loss) but 53ms smoothed
jitter, which on Wi-Fi typically peaks at 150-250ms inter-arrival. A
200ms playout buffer overflows on those peaks; 400ms absorbs them.
Tradeoff: extra ~quarter-second of latency. For music broadcast that
is invisible; for conversation it remains well below noticeable
turn-taking thresholds.
Applied to both SFU subscribe (mic audio path) and mesh peer audio.
Same shape as the existing handle persistence — three new keys
(zebra-spaces-music-mode-v1, -mic-device-v1, -cam-device-v1), restored
right after the handle on page load (before any device enumeration so
the first getUserMedia uses the right device + constraints), and
written on every change handler.
Survives reload + leave/enter cycles. Identity wipe via 'log out' does
not touch these — they're device preferences, not identity.
Three numbers per row now: RTT, loss%, jitter, plus path tag. Loss is
computed as a delta vs the prior poll (not lifetime cumulative) so a
spike during chops shows immediately instead of being averaged into a
session-long denominator. Row colour bumps to the worst of the three:
green RTT with 5% loss reads red.
Reading guide:
- RTT < 50 / loss < 0.5% / jitter < 20ms → green (clean)
- RTT < 150 / loss < 2% / jitter < 50ms → yellow
- otherwise → red
What pattern correlates with your chops:
- loss spikes + green RTT → network: TURN or upstream queue
- jitter spikes + clean loss → buffering / Wi-Fi micro-bursts
- all clean but chops continue → source-side (PulseAudio loopback,
encoder starvation)
Latency panel:
- new collapsible section in the right column under role-actions
- polls getStats() every 2s across every live PC: sfuPubPC, sfuScreenPC,
sfuCameraPC, sfuSubPC, and each mesh peer
- per row: name (e.g. 'sfu mic out', 'sfu in (host + screen + cams)',
'peer <handle>'), RTT in ms (from candidate-pair.currentRoundTripTime),
path kind (LAN/WAN/TURN). Colour-tagged: green <50ms, yellow <150ms,
red 150+. Hides when no PC is live.
- the SFU subscribe row carries every incoming kind (mic + screen +
camera) because they all share sfuSubPC at the WebRTC layer — labelled
accordingly so users don't expect three separate rows.
Audio chops:
- bump playoutDelayHint from 100ms to 200ms — Wi-Fi micro-bursts on
weak links can spike past 100ms and the smaller buffer dropped frames
- preferStereoOpus now takes { music: bool }; music mode = usedtx=0
because DTX's comfort-noise on/off transitions audibly pop on
continuous music signals. Voice mode keeps usedtx=1. Screen-share
audio is always music-grade (system audio capture, not voice).
UX (per fox):
- 'share' section renamed to 'link' and moved directly above the log
section, out of the way of the join/role controls.
Every live RTCPeerConnection is locked to the SDP it was created with.
Page JS reloads don't reach into existing PCs; only fresh negotiation
does. So when a deploy changes fmtp / rtcp-fb / SFU codec registration,
the user just leaves the space and re-enters — no full tab reload
needed.
Caught during the NACK+transport-cc+usedtx rollout: hard refresh wasn't
required, leave+enter was enough.
The button toggled video.muted but the change didn't actually re-route
audio through the speakers in any browser we tested. Less broken UI is
better than broken UI.
Audio robustness:
- useinbandfec=1 + usedtx=1 in publisher's Opus fmtp (was only fec; dtx
drops silence so the budget goes to audible content + cuts congestion)
- ev.receiver.playoutDelayHint = 0.1 (100ms jitter buffer) on every
incoming audio receiver — SFU subscribe + mesh peer. Absorbs Wi-Fi
micro-bursts without perceptible conversation lag.
Tile cleanup on unshare:
- when SFU stops a screen/camera transceiver after the publisher
unpublishes, the remote track fires 'ended'. Listeners now wire
track.onended → removeScreenTile/removeCameraTile so the tile
disappears instead of freezing on the last frame.
Paired with SFU 60a620f which advertises nack + transport-cc feedback
so browsers send the RTCP we depend on for both retransmit and
congestion control.
Camera is a third SFU publisher alongside mic and screen — same multi-
track pipeline, kind=camera suffix on streamID, own #cameras grid in
the timeline column (multi-column auto-fill so multiple face cams fit
without dwarfing a screen share above them).
- new UI: share-camera / stop-camera buttons + device selector in the
same share section. Camera-select restart triggers a clean unpub +
re-pub because deviceId change needs renegotiation anyway.
- new state: sfuCameraPC/Stream/PeerID + cameraStreams/cameraVideos maps
- subscribe ontrack: generic colon-split routes pubkey:screen/camera
to the right tile renderer; back-compat for the existing :screen path
- renderScreenTile + removeScreenTile refactored to a kind-parametric
renderVideoTile via a TILE_KINDS map; the old names stay as thin
shims so callers don't change
- role demotion + leave + peer-left + boot all clean up cameras too
- contentHint='motion' for face-cam (vs 'detail' on screen)
- camera bitrate capped at 1.5Mbps so screen-share headroom isn't
cannibalised when both are publishing simultaneously
Paired with SFU change zebra-spaces-sfu#017c94b which allowlists
kind=camera alongside screen.
When a PulseAudio monitor source is selected as the mic and music mode
is toggled on, Firefox can silently apply its default audio-processing
pipeline (EC/NS/AGC) regardless of the getUserMedia constraints. The
broadcast then sounds 'cleaned up' instead of letting the source pass
through transparently.
Two fixes:
- call track.applyConstraints(micConstraints()) after getUserMedia/replace.
Some UAs honour applyConstraints when they silently ignored the initial
request. Belt-and-suspenders.
- log track.getSettings() so we can see what the UA actually applied —
ec/ns/agc/channels/sampleRate. If applyConstraints didn't stick, the
log shows it instead of failing silently.
Firefox getDisplayMedia silently drops audio for window/tab sources (only
'entire screen' carries system audio). Users were broadcasting video-only
without knowing the audio never made it into the captured stream.
Log the captured track counts unconditionally; when audio is 0 explain
the limitation per browser and point to the workaround (route the tab
through mic music mode for high-quality stereo broadcast).
Firefox Android (and other strict mobile browsers) check the HTML 'muted'
attribute, not the IDL .muted property, when deciding whether MediaStream
<video> is autoplay-eligible. Setting only the property left the video
ineligible, so play() rejected silently and the user saw a black tile.
Three changes:
- set the autoplay/playsinline/muted attributes alongside the properties
so every UA's autoplay heuristic agrees the element is eligible
- log play() rejections instead of swallowing them — silent failures hid
this from us until now
- when play() does reject, show a 'tap to play' overlay that covers the
video area (but not the meta bar); tapping counts as the gesture and
the retry succeeds
Music mode mic upgrades:
- request stereo @ 48kHz from getUserMedia (was mono default)
- RTP-level bitrate cap 256kbps (was 160kbps mono — too low for music)
- SDP fmtp munged to advertise stereo=1+sprop-stereo=1+maxaveragebitrate=256000
so both ends agree on stereo + room to use the bitrate
- music-mode toggle now re-publishes the SFU PC instead of just replaceTrack
(replaceTrack alone leaves the negotiated SDP mono — stereo never reaches
the SFU even with a stereo track)
Screen-share upgrades:
- getDisplayMedia asks for 1920×1080 @ 30fps + stereo 48kHz audio
- video sender capped at 6Mbps, audio sender at 256kbps
- track contentHint 'detail' for video (favour pixels over framerate)
and 'music' for audio
- offer SDP munged for stereo Opus same as mic
Helper added: preferStereoOpus(sdp, bps), setSenderMaxBitrate(sender, bps).
Maximize screen-share resolution on desktop/laptop:
- drop body max-width cap (was 1440px) so layout breathes to full viewport
- shrink right column from 520px to 360px; left column gets the surplus
- bump screen-tile video max-height from 80vh to 92vh
- tighter padding + gap
Cut four prose blocks to roughly a third:
- identity note: full key-management paragraph → 'key lives in this browser only'
- input note: full role/encryption paragraph → 'join as listener; host promotes to mic. end-to-end encrypted.'
- share note: full URL-embedding paragraph → 'share only with people you trust to hear the room.'
- screen-share note: full picker explanation → 'window or tab; tick share audio if offered.'
Mobile browsers refuse to autoplay a <video> carrying an unmuted audio
track without a user gesture; the whole element stays paused, so the
video pixels never render either. The phone listener saw a black tile
even with a healthy track flowing.
Start the remote tile muted so it autoplays everywhere; expose an
'unmute audio' button in the meta bar that toggles. The button click
counts as the gesture, so audio kicks in on demand. Local preview never
offers unmute (would feed back into the publisher's own mic).
The SFU does not echo a publisher's stream back, so anyone sharing alone
in a room had no visual confirmation — game iframe stayed up, no tile
appeared. Render a muted local preview keyed by myKeys.pubHex on publish,
remove on unpublish. Subscribe path skips own pubHex to defend against
SFUs that do echo publishers.
Also refreshes integrity stamps on the other web pages (date drift).
Previously sec-screens lived inside the controls aside, capped at 520px
wide — useless for any real screen view. Move it into the timeline
column at the top, where there's actual horizontal space. CSS :has()
on .timeline hides the game tabs + iframe whenever a screen tile is
visible, so the screen owns the full left column. As soon as the last
sharer stops, the game switcher returns.
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).
- 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.
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.
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.
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.
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.
- 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`.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.