Guest page: hear yourself, clearer mute, 'What is happening' log
Ideas taken from CurryCaster's guest page: - 'Hear yourself' plays the guest's own mic in their headphones (refused when the headphones box is unticked - it would feed back) - Mute shows a clear 'the show cannot hear you' state - 'What is happening' log: connection events plus stats every 15 s in both directions (kb/s, loss, jitter, buffer, concealment, round trip, candidate types). Never includes IP addresses. Kept in the page only; the guest presses Copy and sends it to the host. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f8d0e6fc36
commit
4e2f491746
3 files changed
+132
-11
No files matched your search
+9
-1
@@ -15,13 +15,21 @@
|
|||||||
<label class="check"><input type="checkbox" id="headphones" checked> I'm wearing headphones <span class="muted">(best quality; untick only if you must use speakers)</span></label>
|
<label class="check"><input type="checkbox" id="headphones" checked> I'm wearing headphones <span class="muted">(best quality; untick only if you must use speakers)</span></label>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button id="join" class="primary">Join the show</button>
|
<button id="join" class="primary">Join the show</button>
|
||||||
<button id="mute" class="hidden">Mute mic</button>
|
<button id="mute" class="hidden">Mute my mic</button>
|
||||||
|
<button id="hearself" class="hidden" title="Play your own microphone in your headphones">Hear yourself</button>
|
||||||
<button id="leave" class="hidden danger">Leave</button>
|
<button id="leave" class="hidden danger">Leave</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="callstate" class="muted">Not connected to audio.</div>
|
<div id="callstate" class="muted">Not connected to audio.</div>
|
||||||
<div class="meter"><span>Your mic</span><div class="bar"><i id="mMic"></i></div></div>
|
<div class="meter"><span>Your mic</span><div class="bar"><i id="mMic"></i></div></div>
|
||||||
<div class="meter"><span>The show</span><div class="bar"><i id="mShow"></i></div></div>
|
<div class="meter"><span>The show</span><div class="bar"><i id="mShow"></i></div></div>
|
||||||
<audio id="player" autoplay></audio>
|
<audio id="player" autoplay></audio>
|
||||||
|
<details id="diag" class="diag">
|
||||||
|
<summary>What is happening <span class="muted small">(for troubleshooting)</span></summary>
|
||||||
|
<p class="muted small">If something sounds wrong, press Copy and paste this into the chat or an email to the host.
|
||||||
|
It shows connection quality in both directions. It never includes your IP address.</p>
|
||||||
|
<div class="row"><button id="copylog" class="small">Copy</button><span id="copied" class="muted small"></span></div>
|
||||||
|
<pre id="diaglog" class="diaglog"></pre>
|
||||||
|
</details>
|
||||||
</section>
|
</section>
|
||||||
<section class="card chat">
|
<section class="card chat">
|
||||||
<h2>Chat with the host</h2>
|
<h2>Chat with the host</h2>
|
||||||
|
|||||||
+120
-10
@@ -3,6 +3,28 @@ const token = location.pathname.split('/').filter(Boolean)[1] || '';
|
|||||||
const log = $('#log');
|
const log = $('#log');
|
||||||
let ws = null, everOpen = false, finished = false;
|
let ws = null, everOpen = false, finished = false;
|
||||||
let pc = null, stream = null, actx = null, micAn = null, showAn = null, muted = false;
|
let pc = null, stream = null, actx = null, micAn = null, showAn = null, muted = false;
|
||||||
|
let micSrc = null, selfGain = null;
|
||||||
|
|
||||||
|
// ---- "What is happening" log: kept in this page only, copied by the guest on request.
|
||||||
|
const diagLines = [];
|
||||||
|
function note(msg){
|
||||||
|
const line = new Date().toLocaleTimeString([], {hour12:false}) + ' ' + msg;
|
||||||
|
diagLines.push(line); if (diagLines.length > 400) diagLines.shift();
|
||||||
|
const el = $('#diaglog'); if (!el) return;
|
||||||
|
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 30;
|
||||||
|
el.textContent = diagLines.join('\n');
|
||||||
|
if (atBottom) el.scrollTop = el.scrollHeight;
|
||||||
|
}
|
||||||
|
function diagReport(){
|
||||||
|
const head = [
|
||||||
|
'MMO Guest Line - what is happening',
|
||||||
|
'copied ' + new Date().toString(),
|
||||||
|
'browser: ' + navigator.userAgent,
|
||||||
|
'name: ' + ($('#name').value.trim() || 'Guest') + ' | headphones ticked: ' + $('#headphones').checked,
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
return head.concat(diagLines).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
function fatal(msg){
|
function fatal(msg){
|
||||||
finished = true;
|
finished = true;
|
||||||
@@ -16,12 +38,13 @@ function send(o){ if (ws && ws.readyState === 1) ws.send(JSON.stringify(o)); }
|
|||||||
function connect(){
|
function connect(){
|
||||||
ws = new WebSocket(wsURL('/ws?t=' + encodeURIComponent(token)));
|
ws = new WebSocket(wsURL('/ws?t=' + encodeURIComponent(token)));
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
everOpen = true;
|
everOpen = true; note('host connection: open');
|
||||||
$('#conn').textContent = 'connected'; $('#conn').className = 'pill ok';
|
$('#conn').textContent = 'connected'; $('#conn').className = 'pill ok';
|
||||||
const n = $('#name').value.trim(); if (n) send({t:'name', name:n});
|
const n = $('#name').value.trim(); if (n) send({t:'name', name:n});
|
||||||
};
|
};
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
if (finished) return;
|
if (finished) return;
|
||||||
|
note('host connection: closed' + (everOpen ? ' - reconnecting in 2 s' : ''));
|
||||||
if (!everOpen) { fatal('This invite link is not active. Ask the host for a new one.'); return; }
|
if (!everOpen) { fatal('This invite link is not active. Ask the host for a new one.'); return; }
|
||||||
$('#conn').textContent = 'reconnecting…'; $('#conn').className = 'pill bad';
|
$('#conn').textContent = 'reconnecting…'; $('#conn').className = 'pill bad';
|
||||||
setTimeout(connect, 2000);
|
setTimeout(connect, 2000);
|
||||||
@@ -34,9 +57,9 @@ function connect(){
|
|||||||
case 'chat': renderMsg(log, m.msg, 'guest'); if (document.hidden && m.msg.who === 'host') flashTitle(); break;
|
case 'chat': renderMsg(log, m.msg, 'guest'); if (document.hidden && m.msg.who === 'host') flashTitle(); break;
|
||||||
case 'notice': renderMsg(log, {who:'system', text:m.text, ts:Date.now()}, 'guest'); break;
|
case 'notice': renderMsg(log, {who:'system', text:m.text, ts:Date.now()}, 'guest'); break;
|
||||||
case 'answer': if (pc) await pc.setRemoteDescription({type:'answer', sdp:m.sdp}); break;
|
case 'answer': if (pc) await pc.setRemoteDescription({type:'answer', sdp:m.sdp}); break;
|
||||||
case 'error': $('#callstate').textContent = m.text; hangup(false); break;
|
case 'error': note('host error: ' + m.text); $('#callstate').textContent = m.text; hangup(false); break;
|
||||||
case 'callended': hangup(false); $('#callstate').textContent = 'The host ended the audio. You can join again.'; break;
|
case 'callended': note('host ended the audio'); hangup(false); $('#callstate').textContent = 'The host ended the audio. You can join again.'; break;
|
||||||
case 'ended': fatal(m.reason || 'The session has ended.'); break;
|
case 'ended': note('session ended: ' + (m.reason || '')); fatal(m.reason || 'The session has ended.'); break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -68,7 +91,10 @@ $('#mic').onchange = () => store('gl_mic', $('#mic').value);
|
|||||||
$('#out').onchange = async () => {
|
$('#out').onchange = async () => {
|
||||||
store('gl_out', $('#out').value);
|
store('gl_out', $('#out').value);
|
||||||
try { await $('#player').setSinkId($('#out').value); } catch(e) {}
|
try { await $('#player').setSinkId($('#out').value); } catch(e) {}
|
||||||
|
try { if (actx && actx.setSinkId) await actx.setSinkId($('#out').value); } catch(e) {}
|
||||||
|
note('output changed to "' + ($('#out').selectedOptions[0]?.text || 'default') + '"');
|
||||||
};
|
};
|
||||||
|
$('#mic').addEventListener('change', () => note('microphone choice: "' + ($('#mic').selectedOptions[0]?.text || 'default') + '" (used on next join)'));
|
||||||
|
|
||||||
// ---- call
|
// ---- call
|
||||||
function munge(sdp){
|
function munge(sdp){
|
||||||
@@ -91,29 +117,37 @@ async function join(){
|
|||||||
channelCount: {ideal: 1}, sampleRate: {ideal: 48000}
|
channelCount: {ideal: 1}, sampleRate: {ideal: 48000}
|
||||||
}});
|
}});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
note('microphone failed: ' + (e.name || '') + ' ' + (e.message || ''));
|
||||||
$('#join').disabled = false;
|
$('#join').disabled = false;
|
||||||
$('#callstate').textContent = 'Microphone not available: ' + (e.message || e.name) + '. Check your browser\'s permission and try again.';
|
$('#callstate').textContent = 'Microphone not available: ' + (e.message || e.name) + '. Check your browser\'s permission and try again.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
listDevices().catch(()=>{});
|
listDevices().catch(()=>{});
|
||||||
|
{ const t = stream.getAudioTracks()[0], st = t.getSettings ? t.getSettings() : {};
|
||||||
|
note('join: mic "' + (t.label || '?') + '" ' + (st.sampleRate || '?') + ' Hz, ' + (st.channelCount || '?') + ' ch, echo cancellation ' + (st.echoCancellation ? 'ON' : 'off') +
|
||||||
|
', noise suppression ' + (st.noiseSuppression ? 'ON' : 'off') + ', auto gain ' + (st.autoGainControl ? 'ON' : 'off')); }
|
||||||
actx = actx || new AudioContext();
|
actx = actx || new AudioContext();
|
||||||
if (actx.state === 'suspended') await actx.resume();
|
if (actx.state === 'suspended') await actx.resume();
|
||||||
|
if ($('#out').value && actx.setSinkId) actx.setSinkId($('#out').value).catch(()=>{});
|
||||||
micAn = actx.createAnalyser(); micAn.fftSize = 1024;
|
micAn = actx.createAnalyser(); micAn.fftSize = 1024;
|
||||||
actx.createMediaStreamSource(stream).connect(micAn);
|
micSrc = actx.createMediaStreamSource(stream); micSrc.connect(micAn);
|
||||||
|
|
||||||
pc = new RTCPeerConnection({iceServers: [{urls: 'stun:stun.l.google.com:19302'}]});
|
pc = new RTCPeerConnection({iceServers: [{urls: 'stun:stun.l.google.com:19302'}]});
|
||||||
const tr = pc.addTransceiver(stream.getAudioTracks()[0], {direction: 'sendrecv', streams: [stream]});
|
const tr = pc.addTransceiver(stream.getAudioTracks()[0], {direction: 'sendrecv', streams: [stream]});
|
||||||
pc.onicecandidate = e => { if (e.candidate) send({t:'candidate', candidate: e.candidate.toJSON()}); };
|
pc.onicecandidate = e => { if (e.candidate) send({t:'candidate', candidate: e.candidate.toJSON()}); };
|
||||||
pc.ontrack = e => {
|
pc.ontrack = e => {
|
||||||
const s = e.streams[0] || new MediaStream([e.track]);
|
const s = e.streams[0] || new MediaStream([e.track]);
|
||||||
|
note('receiving the show audio');
|
||||||
const p = $('#player'); p.srcObject = s;
|
const p = $('#player'); p.srcObject = s;
|
||||||
if ($('#out').value && p.setSinkId) p.setSinkId($('#out').value).catch(()=>{});
|
if ($('#out').value && p.setSinkId) p.setSinkId($('#out').value).catch(()=>{});
|
||||||
p.play().catch(()=>{});
|
p.play().catch(()=>{});
|
||||||
showAn = actx.createAnalyser(); showAn.fftSize = 1024;
|
showAn = actx.createAnalyser(); showAn.fftSize = 1024;
|
||||||
actx.createMediaStreamSource(s).connect(showAn);
|
actx.createMediaStreamSource(s).connect(showAn);
|
||||||
};
|
};
|
||||||
|
pc.oniceconnectionstatechange = () => { if (pc) note('network (ice): ' + pc.iceConnectionState); };
|
||||||
pc.onconnectionstatechange = () => {
|
pc.onconnectionstatechange = () => {
|
||||||
const s = pc ? pc.connectionState : 'closed';
|
const s = pc ? pc.connectionState : 'closed';
|
||||||
|
note('audio connection: ' + s);
|
||||||
const label = {connecting:'Connecting audio…', connected:'You are live with the host. 🎙', disconnected:'Audio interrupted - trying to recover…', failed:'Audio connection failed.'}[s];
|
const label = {connecting:'Connecting audio…', connected:'You are live with the host. 🎙', disconnected:'Audio interrupted - trying to recover…', failed:'Audio connection failed.'}[s];
|
||||||
if (label) $('#callstate').textContent = label;
|
if (label) $('#callstate').textContent = label;
|
||||||
if (s === 'connected') {
|
if (s === 'connected') {
|
||||||
@@ -128,18 +162,20 @@ async function join(){
|
|||||||
send({t:'offer', sdp: pc.localDescription.sdp});
|
send({t:'offer', sdp: pc.localDescription.sdp});
|
||||||
$('#callstate').textContent = 'Connecting audio…';
|
$('#callstate').textContent = 'Connecting audio…';
|
||||||
$('#join').classList.add('hidden'); $('#join').disabled = false;
|
$('#join').classList.add('hidden'); $('#join').disabled = false;
|
||||||
$('#mute').classList.remove('hidden'); $('#leave').classList.remove('hidden');
|
$('#mute').classList.remove('hidden'); $('#leave').classList.remove('hidden'); $('#hearself').classList.remove('hidden');
|
||||||
for (const s of ['#mic', '#headphones']) $(s).disabled = true;
|
for (const s of ['#mic', '#headphones']) $(s).disabled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hangup(tell){
|
function hangup(tell){
|
||||||
|
if (pc || stream) note(tell ? 'you left the audio' : 'audio stopped');
|
||||||
if (tell) send({t:'hangup'});
|
if (tell) send({t:'hangup'});
|
||||||
|
hearSelf(false); statsPrev = null;
|
||||||
if (pc) { pc.close(); pc = null; }
|
if (pc) { pc.close(); pc = null; }
|
||||||
if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null; }
|
if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null; }
|
||||||
micAn = showAn = null; muted = false;
|
micAn = showAn = micSrc = null; muted = false;
|
||||||
$('#player').srcObject = null;
|
$('#player').srcObject = null;
|
||||||
$('#mute').textContent = 'Mute mic';
|
$('#mute').textContent = 'Mute my mic'; $('#mute').classList.remove('danger');
|
||||||
$('#join').classList.remove('hidden'); $('#mute').classList.add('hidden'); $('#leave').classList.add('hidden');
|
$('#join').classList.remove('hidden'); for (const b of ['#mute', '#leave', '#hearself']) $(b).classList.add('hidden');
|
||||||
for (const s of ['#mic', '#headphones']) $(s).disabled = false;
|
for (const s of ['#mic', '#headphones']) $(s).disabled = false;
|
||||||
$('#callstate').textContent = 'Not connected to audio.';
|
$('#callstate').textContent = 'Not connected to audio.';
|
||||||
}
|
}
|
||||||
@@ -149,8 +185,81 @@ $('#leave').onclick = () => hangup(true);
|
|||||||
$('#mute').onclick = () => {
|
$('#mute').onclick = () => {
|
||||||
if (!stream) return; muted = !muted;
|
if (!stream) return; muted = !muted;
|
||||||
stream.getAudioTracks().forEach(t => t.enabled = !muted);
|
stream.getAudioTracks().forEach(t => t.enabled = !muted);
|
||||||
$('#mute').textContent = muted ? 'Unmute mic' : 'Mute mic';
|
$('#mute').textContent = muted ? 'Unmute my mic' : 'Mute my mic';
|
||||||
$('#mute').classList.toggle('danger', muted);
|
$('#mute').classList.toggle('danger', muted);
|
||||||
|
$('#callstate').textContent = muted ? 'Your mic is MUTED - the show cannot hear you.' : 'You are live with the host. 🎙';
|
||||||
|
note(muted ? 'you muted your mic' : 'you unmuted your mic');
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- hear yourself: your own mic in your headphones (headphones only - speakers would feed back)
|
||||||
|
function hearSelf(on){
|
||||||
|
if (selfGain) { selfGain.disconnect(); selfGain = null; }
|
||||||
|
if (on && micSrc && actx) {
|
||||||
|
selfGain = actx.createGain(); selfGain.gain.value = 1;
|
||||||
|
micSrc.connect(selfGain); selfGain.connect(actx.destination);
|
||||||
|
}
|
||||||
|
$('#hearself').textContent = selfGain ? 'Hear yourself: ON' : 'Hear yourself';
|
||||||
|
$('#hearself').classList.toggle('on', !!selfGain);
|
||||||
|
}
|
||||||
|
$('#hearself').onclick = () => {
|
||||||
|
if (!selfGain && !$('#headphones').checked) {
|
||||||
|
$('#callstate').textContent = 'Hear yourself only works with headphones - through speakers it would howl.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hearSelf(!selfGain);
|
||||||
|
note(selfGain ? 'hear yourself: on' : 'hear yourself: off');
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- connection stats into the log every 15 s (both directions, no IP addresses)
|
||||||
|
let statsPrev = null;
|
||||||
|
async function statsTick(){
|
||||||
|
if (!pc || pc.connectionState !== 'connected') return;
|
||||||
|
let r; try { r = await pc.getStats(); } catch(e) { return; }
|
||||||
|
let inb, out, rin, pair;
|
||||||
|
r.forEach(s => {
|
||||||
|
if (s.kind === 'audio' && s.type === 'inbound-rtp') inb = s;
|
||||||
|
if (s.kind === 'audio' && s.type === 'outbound-rtp') out = s;
|
||||||
|
if (s.kind === 'audio' && s.type === 'remote-inbound-rtp') rin = s;
|
||||||
|
if (s.type === 'transport' && s.selectedCandidatePairId) pair = r.get(s.selectedCandidatePairId);
|
||||||
|
});
|
||||||
|
if (!pair) r.forEach(s => { if (s.type === 'candidate-pair' && s.nominated && s.state === 'succeeded') pair = s; });
|
||||||
|
const now = {t: performance.now(), inb, out};
|
||||||
|
const p = statsPrev; statsPrev = now;
|
||||||
|
if (!p) return;
|
||||||
|
const secs = (now.t - p.t) / 1000, d = (a, b, k) => (a && b && a[k] != null && b[k] != null) ? a[k] - b[k] : 0;
|
||||||
|
const parts = [];
|
||||||
|
if (out) {
|
||||||
|
const kbps = d(out, p.out, 'bytesSent') * 8 / 1000 / secs;
|
||||||
|
let s = 'you->show ' + kbps.toFixed(0) + ' kb/s';
|
||||||
|
if (rin) s += ', host reports loss ' + ((rin.fractionLost || 0) * 100).toFixed(1) + '%' + (rin.jitter != null ? ', jitter ' + (rin.jitter * 1000).toFixed(0) + ' ms' : '');
|
||||||
|
parts.push(s);
|
||||||
|
}
|
||||||
|
if (inb) {
|
||||||
|
const got = d(inb, p.inb, 'packetsReceived'), lost = d(inb, p.inb, 'packetsLost');
|
||||||
|
const conc = d(inb, p.inb, 'concealedSamples'), tot = d(inb, p.inb, 'totalSamplesReceived');
|
||||||
|
const jbd = d(inb, p.inb, 'jitterBufferDelay'), jbn = d(inb, p.inb, 'jitterBufferEmittedCount');
|
||||||
|
parts.push('show->you lost ' + lost + '/' + (got + lost) + ' pkts, jitter ' + ((inb.jitter || 0) * 1000).toFixed(0) + ' ms' +
|
||||||
|
(jbn ? ', buffer ' + (jbd / jbn * 1000).toFixed(0) + ' ms' : '') + (tot ? ', concealed ' + (conc / tot * 100).toFixed(1) + '%' : ''));
|
||||||
|
}
|
||||||
|
if (pair) {
|
||||||
|
const lc = r.get(pair.localCandidateId), rc = r.get(pair.remoteCandidateId);
|
||||||
|
parts.push('round trip ' + (pair.currentRoundTripTime != null ? (pair.currentRoundTripTime * 1000).toFixed(0) + ' ms' : '?') +
|
||||||
|
' (' + (lc ? lc.candidateType + '/' + (lc.protocol || '') : '?') + ' -> ' + (rc ? rc.candidateType : '?') + ')');
|
||||||
|
}
|
||||||
|
if (parts.length) note('stats: ' + parts.join(' | '));
|
||||||
|
}
|
||||||
|
setInterval(statsTick, 15000);
|
||||||
|
|
||||||
|
$('#copylog').onclick = async () => {
|
||||||
|
const text = diagReport();
|
||||||
|
let ok = false;
|
||||||
|
try { await navigator.clipboard.writeText(text); ok = true; } catch(e) {}
|
||||||
|
if (!ok) { // fallback: select the text so the guest can copy it by hand
|
||||||
|
const r = document.createRange(); r.selectNodeContents($('#diaglog'));
|
||||||
|
const sel = getSelection(); sel.removeAllRanges(); sel.addRange(r);
|
||||||
|
}
|
||||||
|
$('#copied').textContent = ok ? 'Copied - paste it into the chat or an email.' : 'Selected - press Ctrl+C (Cmd+C on a Mac) to copy.';
|
||||||
|
setTimeout(() => { $('#copied').textContent = ''; }, 6000);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- meters
|
// ---- meters
|
||||||
@@ -164,5 +273,6 @@ $('#name').addEventListener('change', () => { const n = $('#name').value.trim();
|
|||||||
chatForm($('#chatform'), $('#msg'), t => send({t:'chat', text:t}));
|
chatForm($('#chatform'), $('#msg'), t => send({t:'chat', text:t}));
|
||||||
window.addEventListener('beforeunload', () => { if (pc) send({t:'hangup'}); });
|
window.addEventListener('beforeunload', () => { if (pc) send({t:'hangup'}); });
|
||||||
|
|
||||||
|
note('page opened');
|
||||||
if (!token) fatal('This link is incomplete. Ask the host for your invite link.');
|
if (!token) fatal('This link is incomplete. Ask the host for your invite link.');
|
||||||
else { connect(); if (navigator.mediaDevices) listDevices().catch(()=>{}); }
|
else { connect(); if (navigator.mediaDevices) listDevices().catch(()=>{}); }
|
||||||
@@ -35,3 +35,6 @@ button.small{padding:3px 10px;font-size:13px}
|
|||||||
.bar i{display:block;height:100%;width:0;background:var(--ok);transition:width .08s linear}
|
.bar i{display:block;height:100%;width:0;background:var(--ok);transition:width .08s linear}
|
||||||
.bar i.hot{background:var(--warn)}.bar i.clip{background:var(--danger)}
|
.bar i.hot{background:var(--warn)}.bar i.clip{background:var(--danger)}
|
||||||
.meter b{font-weight:400;color:var(--muted);text-align:right;font-variant-numeric:tabular-nums}
|
.meter b{font-weight:400;color:var(--muted);text-align:right;font-variant-numeric:tabular-nums}
|
||||||
|
.diag{margin-top:14px;font-size:14px}.diag summary{cursor:pointer}
|
||||||
|
.diaglog{max-height:240px;overflow:auto;background:var(--bg);border:1px solid var(--line);border-radius:7px;padding:8px;font-size:12px;white-space:pre-wrap;overflow-wrap:anywhere;margin:0}
|
||||||
|
button.on{border-color:var(--ok);color:var(--ok)}
|
||||||
Reference in new issue
Block a user