diff --git a/zebra-report/zebra-spaces.html b/zebra-report/zebra-spaces.html
index 2b158d5..01a726b 100644
--- a/zebra-report/zebra-spaces.html
+++ b/zebra-report/zebra-spaces.html
@@ -4560,7 +4560,36 @@ async function sfuPublishCamera(){
* speaker can choose camera-on while still using a different audio
* input (monitor source, music mode, etc) */
stream = await navigator.mediaDevices.getUserMedia({ video: videoConstraints, audio: false });
- } catch(e){ logLine('err','camera open cancelled: '+e.message); fsm.send('FAILED', { error: e.message }); fsm.send('DONE'); return; }
+ } catch(e){
+ /* surface e.name + the constraints we tried so signal-server telemetry
+ * can disambiguate NotFoundError (device gone) vs OverconstrainedError
+ * (saved deviceId or 720p/16:9 not supported) vs NotAllowedError (user
+ * declined permission) vs NotReadableError (camera held by another app).
+ * Defect #0002 fedora firefox: e.message alone wasn't enough to pick
+ * the right branch. */
+ const exact = videoConstraints.deviceId && videoConstraints.deviceId.exact;
+ logLine('err',
+ 'camera open cancelled: '+(e.name||'Error')+' — '+e.message+
+ ' (constraints: 1280x720@30 16:9'+(exact?' deviceId='+String(exact).slice(0,8):'')+')'
+ );
+ /* OverconstrainedError + a saved deviceId is the canonical "saved
+ * device gone or renamed" case — retry once without deviceId so the
+ * user gets default camera rather than a permanent fail. */
+ if (e.name === 'OverconstrainedError' && exact){
+ logLine('', 'camera open retry without saved deviceId');
+ try {
+ const fallbackConstraints = Object.assign({}, videoConstraints);
+ delete fallbackConstraints.deviceId;
+ stream = await navigator.mediaDevices.getUserMedia({ video: fallbackConstraints, audio: false });
+ logLine('', 'camera open recovered with default device');
+ } catch(e2){
+ logLine('err','camera open retry failed: '+(e2.name||'Error')+' — '+e2.message);
+ fsm.send('FAILED', { error: e2.message }); fsm.send('DONE'); return;
+ }
+ } else {
+ fsm.send('FAILED', { error: e.message }); fsm.send('DONE'); return;
+ }
+ }
fsm.send('ACQUIRED', { stream });
sfuCameraStream = stream;
const pc = new RTCPeerConnection(rtcConfig);
@@ -6029,6 +6058,52 @@ async function handleSignal(raw){
broadcastSpotlight();
reorderTiles();
logLine('', 'joined as '+myRole+' — uuid '+myUUID);
+ /* one-shot session fingerprint for telemetry (see docs/tickets/
+ * 0001 + 0002): captures the browser + audio/video device state
+ * at entry so a reporter's signal-server log carries enough
+ * context to disambiguate browser-specific defects. Mobile + UA
+ * fields land first because the bug shape often correlates with
+ * a single UA family (fxhp-phone-firefox, fedora-chrome, etc).
+ * Best-effort — wrapped + caught so a hostile UA doesn't break
+ * entry. */
+ (async () => {
+ try {
+ const ua = navigator.userAgent || '';
+ const isMobile = /Mobi|Android|iPhone|iPad|iPod/.test(ua);
+ const browser =
+ /Firefox\//.test(ua) ? 'firefox' :
+ /Edg\//.test(ua) ? 'edge' :
+ /Chrome\//.test(ua) ? 'chrome' :
+ /Safari\//.test(ua) ? 'safari' : 'other';
+ let sr = 'n/a', bl = 'n/a', sinkSupp = false, ctxSink = 'n/a';
+ try {
+ if (audioCtx){
+ sr = audioCtx.sampleRate;
+ bl = (audioCtx.baseLatency != null) ? audioCtx.baseLatency.toFixed(4) : 'n/a';
+ sinkSupp = typeof audioCtx.setSinkId === 'function';
+ ctxSink = (audioCtx.sinkId === undefined) ? 'n/a' : (audioCtx.sinkId || 'default');
+ }
+ } catch(_){}
+ let outCount = 0, inCount = 0, camCount = 0;
+ try {
+ const devs = await navigator.mediaDevices.enumerateDevices();
+ for (const d of devs){
+ if (d.kind === 'audiooutput') outCount++;
+ else if (d.kind === 'audioinput') inCount++;
+ else if (d.kind === 'videoinput') camCount++;
+ }
+ } catch(_){}
+ const pickShort = id => id ? String(id).slice(0,8) : '';
+ logLine('',
+ 'session: ua='+browser+(isMobile?'/mobile':'/desktop')+
+ ' ctx={sr='+sr+' baseLat='+bl+' sinkSupp='+(sinkSupp?1:0)+' sink='+ctxSink+'}'+
+ ' devs={out='+outCount+' in='+inCount+' cam='+camCount+'}'+
+ ' picked={mic='+pickShort(micDeviceId)+
+ ' cam='+pickShort(cameraDeviceId)+
+ ' spk='+pickShort(speakerDeviceId)+'}'
+ );
+ } catch(_){}
+ })();
/* dot, leave/mute button visibility, sec-room reveal, entry-row
* hide and status text are all driven by applyCallStateUI()
* from the 'joined' state. Welcome already dispatched WELCOME
@@ -8008,9 +8083,9 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');