Fox 2026-06-04, after pulling CLIENT_LOG from the proxy:
stream for 25cc0aaf7eb0 autoplay blocked: ... — staying on live WebRTC
autoplay blocked: ... — tap the tile to play
Firefox Android refuses .play() on every <audio> element created
after the entry-button gesture has aged out. Both the HTTP DJ stream
AND the WebRTC fallback were silently dead — the phone heard nothing.
Two-part fix:
1. Every remoteAudio + streamAudio <audio> element is now created
with muted=true. Muted autoplay has no gesture requirement on
any browser — decoding starts the moment the src is set, the
buffer fills, and audibility is gated entirely by a later
user gesture.
2. btn-mute is enabled for listeners (was disabled because there's
no mic to mute) and re-purposed as the audio-unlock toggle. The
click is the gesture. applyAudioMute() reads a per-uuid audio-
Path map ('dj' | 'rtc') and unmutes only the canonical path for
each peer so the WebRTC duplicate stays silenced while the DJ
stream plays.
Also picked up while I was in here:
- startStream onFail clears the pubHex from streamMode so the
next auto-enrol pass retries.
- stopStream sets audioPath='rtc' instead of poking remoteAudio
directly.
- moderation: 'boot' button onclick now .catch'es and logs server
errors. Server now returns 'boot target not found (stale uuid?)'
instead of silently no-op'ing when the page's member roster lagged
the room — the moderator was clicking 'boot' and seeing nothing
happen because their page held a stale uuid.
- diagnostic: watchFirstFrame logs 'still black after 2500ms — no
keyframe?' on any fresh SFU video track (screen/camera/game) whose
decoder never unmutes. Pairs with the SFU's extended kfBurst so we
can tell next session which path actually broke when a camera tile
renders black.
multi-peer-mesh test harness extracts the new fn alongside
watchVideoTrackForRemoval so handleRemoteSfuTrack still runs end-to-end
in the headless sandbox.
Stamp date refresh on the other pages (no behavior change).
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.
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.
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
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.
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).
- 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`.
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.
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.
- chat + zebra-audio: treat ICE 'disconnected' as recoverable (grace
before acting), auto ICE-restart on 'failed'/timeout driven by the
offerer to avoid glare, and auto-rejoin the signaling socket if it
drops mid-call. Superseded-pc guard ignores late events from a
closed connection.
- zebra-audio: input-device dropdown that hot-swaps the mic via
replaceTrack (no renegotiation, mute preserved); refreshes on
devicechange so a plugged-in interface appears mid-call.
Add an integrity footer to chat.html, zebra-audio.html, and how-it-works.html
showing the build date (2026-05-28) and the page's own MD5 + SHA-256. A file
can't hold its own hash, so web/stamp.js (make stamp) computes the hashes with
the two hash fields zeroed, then writes the real values back — self-consistent
and idempotent. To verify a served page: blank the two fields and re-hash;
confirmed it reproduces the stamped value with plain sha256sum.
Add a "music mode" toggle that re-acquires the mic with echo cancellation,
noise suppression, and auto-gain OFF (so music/audio passes through instead of
being treated as noise and pumped), tags the track contentHint='music' so the
Opus encoder drops speech optimizations (DTX etc.), and raises the send bitrate.
Switchable mid-call via replaceTrack — hot-swaps the track with no
renegotiation, preserving mute state. Verified mid-call switch stays connected
both directions.
Lay out the controls and level rows with CSS grid and give the mute button a
fixed width, so toggling mute/unmute no longer shifts the buttons. Replace the
text "muted" badge with a mic-icon set (green open mic / red slashed mic) shown
next to "you" and "them", so both players can see at a glance whose mic is open
or closed. Verified both directions headless.
Each peer signals its mute state over the rendezvous channel (encrypted with
the room code, so the relay never learns it) on connect and on every toggle.
A "muted" badge now shows on both your own and the partner's level meter, so
both players can see who is muted. Verified both directions headless.
Reconnect: on "partner left", tear down the stale peer connection but stay in
the room, and have whichever peer is already present send the offer when the
other (re)joins — so a partner can leave and rejoin with no refresh, regardless
of who left. Every (re)negotiation runs on a fresh RTCPeerConnection.
Path indicator now reads the transport's selected candidate pair and prints the
actual local/remote IP:port + types, so DIRECT vs RELAYED is verifiable (host =
the two devices' IPs; relay = the TURN server's IP). The mic un-masks the real
host candidate, which is why a voice call reaches direct P2P on a LAN where the
no-mic chat fell back to TURN.
New single-page app: two rendezvoused partners type the same code and get a
live Opus voice call over WebRTC — direct P2P when possible, TURN relay
fallback otherwise, DTLS-SRTP encrypted end to end. Reuses the zebra-signal
rendezvous (code-encrypted SDP, zero-knowledge relay) and the ephemeral
/turn-cred credentials. Mic uses echo-cancellation/noise-suppression; mute,
hang up, live mic/remote level meters, and a direct-vs-relayed path indicator.
Deliberately NOT over the volume modem — ordinary low-latency voice, which
doubles as a plausible cover for the report channel.