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.
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).
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.
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.
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
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.
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.
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.
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.