feat: WebSocket команды — раздельная громкость mic/snd + запись аудио + обновлённый web-client

- satellite.py: разделён _runtime_volume на _runtime_mic_volume и _runtime_snd_volume
- satellite.py: добавлены команды start_recording/stop_recording (WAV через base64)
- satellite.py: метод _create_wav_from_buffer() для создания WAV из буфера
- web-client: добавлен слайдер громкости микрофона (MIC+/MIC-)
- web-client: добавлены кнопки записи и воспроизведения аудио
- web-client: улучшенный лог событий и отладочные console.log
- docs: websocket-server-plan.md сокращён с 1205 до ~80 строк

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
2026-05-25 17:58:37 +03:00
co-authored by Qwen-Coder
parent 61294485ad
commit 2b4e6f824b
7 changed files with 2017 additions and 1209 deletions
+128
View File
@@ -0,0 +1,128 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>NOVA Voice Assistant</title>
<link rel="stylesheet" href="styles.css">
<script src="script.js" defer></script>
</head>
<body>
<div class="app-container">
<!-- Верхняя панель -->
<header class="app-header">
<div class="logo">
<span class="logo-icon">🎙️</span>
<span class="logo-text">NOVA Assistant</span>
</div>
<div class="connection-status">
<div class="led" id="statusLed"></div>
<span class="status-text" id="statusLabel">disconnected</span>
<button id="connectBtn" class="btn-outline">⟳ Connect</button>
</div>
</header>
<!-- Блок настроек WebSocket -->
<div class="settings-card glass">
<div class="settings-row">
<input type="text" id="wsUrlInput" value="ws://192.168.200.111:8675" placeholder="ws://ip:port" class="input-url">
<button id="connectBtnCard" class="btn-primary">Connect</button>
<label class="auto-connect">
<input type="checkbox" id="autoConnect" checked> Auto-connect
</label>
</div>
</div>
<!-- Основная сетка -->
<div class="dashboard">
<!-- Левая колонка: голосовой аватар -->
<div class="card voice-card glass">
<div class="card-header">
<span class="header-icon">🎤</span>
<h3>Voice Core</h3>
</div>
<div class="avatar-container">
<div class="orb" id="orbCore">
<div class="orb-pulse"></div>
<div class="orb-inner">🎙️</div>
</div>
<div class="state-badge" id="stateText">Idle</div>
</div>
<!-- Визуализатор аудио (волны) -->
<div class="viz-wrapper">
<canvas id="audioCanvas" width="500" height="80"></canvas>
</div>
<!-- RMS-метр -->
<div class="rms-container">
<div class="rms-label">RMS Level</div>
<div class="rms-bar">
<div class="rms-fill" id="rmsFill"></div>
</div>
<div class="rms-value" id="rmsValue">0.00</div>
</div>
<!-- Регулировка громкости -->
<div class="volume-control">
<span>🔊 Volume</span>
<input type="range" id="volumeSlider" min="0" max="1" step="0.01" value="0.8">
<span id="volumeValue" class="volume-percent">80%</span>
</div>
<div class="button-group">
<button id="muteBtn" class="btn-secondary">🔇 Mute</button>
<button id="unmuteBtn" class="btn-secondary">🎤 Unmute</button>
</div>
</div>
<!-- Центр: команды и транскрипция -->
<div class="card commands-card glass">
<div class="card-header">
<span class="header-icon">🕹️</span>
<h3>Commands & Transcripts</h3>
</div>
<div class="button-group">
<button id="startListeningBtn" class="btn-primary">🎧 Start Listen</button>
<button id="stopPipelineBtn" class="btn-danger">⏹️ Stop Pipeline</button>
<button id="stopTimerBtn" class="btn-secondary">⏲️ Stop Timer</button>
</div>
<div class="button-group">
<button id="volUpBtn" class="btn-secondary">➕ Vol+</button>
<button id="volDownBtn" class="btn-secondary">➖ Vol-</button>
<button id="requestRmsBtn" class="btn-secondary">📊 Request RMS</button>
</div>
<div class="button-group">
<button id="singlePressBtn" class="btn-outline">Single</button>
<button id="doublePressBtn" class="btn-outline">Double</button>
<button id="triplePressBtn" class="btn-outline">Triple</button>
<button id="longPressBtn" class="btn-outline">Long</button>
</div>
<div class="transcript-area">
<div class="transcript-item">
<div class="transcript-label">You said:</div>
<div id="lastStt" class="transcript-text">—</div>
</div>
<div class="transcript-item">
<div class="transcript-label">Assistant said:</div>
<div id="lastTts" class="transcript-text">—</div>
</div>
</div>
</div>
<!-- Правая колонка: телеметрия и логи -->
<div class="card telemetry-card glass">
<div class="card-header">
<span class="header-icon">📡</span>
<h3>Telemetry & Events</h3>
</div>
<div class="status-grid">
<div class="status-chip"><span>🔇 Muted:</span> <strong id="mutedStatus">—</strong></div>
<div class="status-chip"><span>🏠 HA:</span> <strong id="haStatus">—</strong></div>
<div class="status-chip"><span>🎚️ Vol:</span> <strong id="liveVol">—</strong></div>
</div>
<div class="event-feed" id="eventFeed">
<div class="event-item">⬤ Ready to connect...</div>
</div>
<button id="clearEvents" class="btn-secondary full-width">Clear log</button>
</div>
</div>
</div>
</body>
</html>
+255
View File
@@ -0,0 +1,255 @@
// ------------------ WebSocket & State ------------------
let ws = null;
let isConnected = false;
let currentVolume = 0.8;
let mutedState = false;
let haConnected = false;
let rmsTimeout = null;
let currentRms = 0;
const canvas = document.getElementById('audioCanvas');
const ctx = canvas.getContext('2d');
let canvasWidth = canvas.parentElement.clientWidth - 24;
let canvasHeight = 80;
canvas.width = canvasWidth;
canvas.height = canvasHeight;
// DOM elements
const statusLed = document.getElementById('statusLed');
const statusLabel = document.getElementById('statusLabel');
const connectBtn = document.getElementById('connectBtn');
const orbCore = document.getElementById('orbCore');
const stateText = document.getElementById('stateText');
const volumeSlider = document.getElementById('volumeSlider');
const volumeValueSpan = document.getElementById('volumeValue');
const lastSttSpan = document.getElementById('lastStt');
const lastTtsSpan = document.getElementById('lastTts');
const mutedStatusSpan = document.getElementById('mutedStatus');
const haStatusSpan = document.getElementById('haStatus');
const liveVolSpan = document.getElementById('liveVol');
const eventFeedDiv = document.getElementById('eventFeed');
const rmsFill = document.getElementById('rmsFill');
const rmsValueSpan = document.getElementById('rmsValue');
function addEventToLog(message) {
const time = new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
const div = document.createElement('div');
div.className = 'event-item';
div.innerHTML = `<span style="color:#4effb5;">${time}</span> • ${message}`;
eventFeedDiv.prepend(div);
if (eventFeedDiv.children.length > 50) eventFeedDiv.removeChild(eventFeedDiv.lastChild);
}
function updateRmsMeter(level) {
if (!rmsFill || !rmsValueSpan) return;
const percent = Math.min(100, Math.max(0, level * 100));
rmsFill.style.width = percent + '%';
rmsValueSpan.textContent = level.toFixed(2);
clearTimeout(rmsTimeout);
rmsTimeout = setTimeout(() => {
if (rmsFill) rmsFill.style.width = '0%';
if (rmsValueSpan) rmsValueSpan.textContent = '0.00';
}, 1000);
}
function sendCommand(command, payload = {}) {
if (!ws || ws.readyState !== WebSocket.OPEN) {
addEventToLog(`❌ Cannot send: ${command} — not connected`);
return;
}
const msg = JSON.stringify({ command, data: payload });
ws.send(msg);
addEventToLog(`📤 Command: ${command}`);
}
function handleIncomingMessage(ev) {
try {
const json = JSON.parse(ev.data);
const eventType = json.type || json.event;
if (eventType === 'audio-level' && json.data?.level !== undefined) {
currentRms = json.data.level;
updateRmsMeter(currentRms);
} else if (eventType === 'voice-started') {
stateText.innerText = 'Listening...';
document.body.classList.add('listening-active');
addEventToLog('🎤 Voice detected');
} else if (eventType === 'voice-stopped') {
stateText.innerText = 'Processing';
document.body.classList.remove('listening-active');
addEventToLog('💭 Processing speech');
} else if (eventType === 'transcript' && json.data?.text) {
lastSttSpan.innerText = json.data.text;
addEventToLog(`🎤 "${json.data.text}"`);
} else if (eventType === 'synthesize' && json.data?.text) {
lastTtsSpan.innerText = json.data.text;
} else if (eventType === 'audio-start') {
stateText.innerText = 'Speaking';
addEventToLog('🗣️ Assistant speaking');
} else if (eventType === 'audio-stop' || eventType === 'played') {
if (stateText.innerText === 'Speaking') stateText.innerText = 'Idle';
addEventToLog('🔇 TTS finished');
} else if (eventType === 'timer-started') {
addEventToLog('⏱️ Timer started');
} else if (eventType === 'timer-finished') {
addEventToLog('⏰ Timer triggered!');
stateText.innerText = 'Timer';
} else if (eventType === 'satellite-connected') {
haConnected = true;
haStatusSpan.innerText = 'connected';
addEventToLog('🔌 Satellite connected');
} else if (eventType === 'satellite-disconnected') {
haConnected = false;
haStatusSpan.innerText = 'offline';
addEventToLog('🔌 Satellite disconnected');
} else if (eventType === 'error') {
addEventToLog(`❌ Error: ${json.data?.message || 'unknown'}`);
} else {
addEventToLog(`📡 Event: ${eventType}`);
}
if (json.data) {
if (json.data.volume !== undefined) {
currentVolume = json.data.volume;
volumeSlider.value = currentVolume;
volumeValueSpan.innerText = Math.round(currentVolume * 100) + '%';
liveVolSpan.innerText = Math.round(currentVolume * 100) + '%';
}
if (json.data.muted !== undefined) {
mutedState = json.data.muted;
mutedStatusSpan.innerText = mutedState ? 'muted' : 'unmuted';
}
}
} catch (e) {
console.warn(e);
addEventToLog(`⚠️ Parse error: ${e.message}`);
}
}
// Визуализация аудио (волны)
let rmsSmoothed = 0;
function drawVisualization() {
requestAnimationFrame(drawVisualization);
if (!isConnected) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#0a1222';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#0ff3';
ctx.font = '10px monospace';
ctx.fillText('⚡ idle', canvas.width - 50, 20);
return;
}
let avg = 10 + (currentRms / 0.15) * 90;
avg = Math.min(100, Math.max(10, avg));
rmsSmoothed = rmsSmoothed * 0.7 + avg * 0.3;
avg = rmsSmoothed;
if (currentRms < 0.001) avg = 10 + Math.random() * 5;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#07121f';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const barWidth = canvas.width / 42;
for (let i = 0; i < 42; i++) {
let barHeight = (avg * (Math.sin(i * 0.5) + 1) / 2 + 5) * (0.5 + Math.random() * 0.3);
if (stateText.innerText === 'Speaking') barHeight = avg * 1.6 * (Math.sin(i * 0.3) + 1.2);
barHeight = Math.min(barHeight, canvas.height - 10);
const x = i * (barWidth + 2);
const grad = ctx.createLinearGradient(x, canvas.height - barHeight, x, canvas.height);
grad.addColorStop(0, '#0ff');
grad.addColorStop(1, '#a3f');
ctx.fillStyle = grad;
ctx.fillRect(x, canvas.height - barHeight, barWidth, barHeight);
}
}
drawVisualization();
// WebSocket подключение
function toggleConnection() {
const url = document.getElementById('wsUrlInput').value.trim();
if (!url) {
addEventToLog('❌ Enter WebSocket URL');
return;
}
if (ws && ws.readyState === WebSocket.OPEN) {
ws.close();
return;
}
ws = new WebSocket(url);
ws.onopen = () => {
isConnected = true;
statusLed.classList.add('connected');
statusLabel.innerText = 'connected';
addEventToLog(`✅ Connected to ${url}`);
stateText.innerText = 'Online';
document.getElementById('connectBtn').innerHTML = '🔌 Disconnect';
document.getElementById('connectBtnCard').innerHTML = 'Disconnect';
};
ws.onmessage = handleIncomingMessage;
ws.onerror = () => addEventToLog(`⚠️ WebSocket error`);
ws.onclose = () => {
isConnected = false;
statusLed.classList.remove('connected');
statusLabel.innerText = 'disconnected';
addEventToLog(`🔌 Disconnected`);
stateText.innerText = 'Offline';
document.body.classList.remove('listening-active');
document.getElementById('connectBtn').innerHTML = '⟳ Connect';
document.getElementById('connectBtnCard').innerHTML = 'Connect';
};
}
// Обработчики кнопок
document.getElementById('connectBtn').addEventListener('click', toggleConnection);
document.getElementById('connectBtnCard').addEventListener('click', toggleConnection);
window.addEventListener('load', () => {
addEventToLog('🤖 Nova Assistant ready');
if (document.getElementById('autoConnect').checked) {
setTimeout(() => toggleConnection(), 500);
}
// Адаптация canvas при изменении размера
window.addEventListener('resize', () => {
canvas.width = canvas.parentElement.clientWidth - 24;
canvas.height = 80;
});
});
// Команды
document.getElementById('startListeningBtn').addEventListener('click', () => sendCommand('set_volume', { level: 1.0 }));
document.getElementById('stopPipelineBtn').addEventListener('click', () => sendCommand('mute', {}));
document.getElementById('stopTimerBtn').addEventListener('click', () => sendCommand('unmute', {}));
document.getElementById('muteBtn').addEventListener('click', () => sendCommand('mute', {}));
document.getElementById('unmuteBtn').addEventListener('click', () => sendCommand('unmute', {}));
document.getElementById('volUpBtn').addEventListener('click', () => {
let newVol = Math.min(1.0, currentVolume + 0.1);
currentVolume = newVol;
sendCommand('set_volume', { level: newVol });
volumeSlider.value = newVol;
volumeValueSpan.innerText = Math.round(newVol * 100) + '%';
});
document.getElementById('volDownBtn').addEventListener('click', () => {
let newVol = Math.max(0.0, currentVolume - 0.1);
currentVolume = newVol;
sendCommand('set_volume', { level: newVol });
volumeSlider.value = newVol;
volumeValueSpan.innerText = Math.round(newVol * 100) + '%';
});
document.getElementById('requestRmsBtn').addEventListener('click', () => {
sendCommand('trigger_event', { event_type: 'request_audio_level', data: {} });
addEventToLog('📊 Requested RMS update');
});
document.getElementById('singlePressBtn').addEventListener('click', () => sendCommand('trigger_event', { event_type: 'single_press', data: {} }));
document.getElementById('doublePressBtn').addEventListener('click', () => sendCommand('trigger_event', { event_type: 'double_press', data: {} }));
document.getElementById('triplePressBtn').addEventListener('click', () => sendCommand('trigger_event', { event_type: 'triple_press', data: {} }));
document.getElementById('longPressBtn').addEventListener('click', () => sendCommand('trigger_event', { event_type: 'long_press', data: {} }));
document.getElementById('clearEvents').addEventListener('click', () => {
while (eventFeedDiv.children.length > 1) eventFeedDiv.removeChild(eventFeedDiv.lastChild);
addEventToLog('🧹 Log cleared');
});
volumeSlider.addEventListener('input', (e) => {
let val = parseFloat(e.target.value);
currentVolume = val;
volumeValueSpan.innerText = Math.round(val * 100) + '%';
sendCommand('set_volume', { level: val });
});
+456
View File
@@ -0,0 +1,456 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
user-select: none;
}
body {
background: radial-gradient(circle at 10% 20%, #0b1120, #04070c);
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, 'Poppins', sans-serif;
min-height: 100vh;
padding: 24px;
color: #eef5ff;
}
.app-container {
max-width: 1400px;
margin: 0 auto;
}
/* Glassmorphism */
.glass {
background: rgba(15, 25, 45, 0.55);
backdrop-filter: blur(16px);
border-radius: 32px;
border: 1px solid rgba(0, 255, 255, 0.15);
box-shadow: 0 20px 35px -10px rgba(0, 0, 0, 0.4);
transition: all 0.2s ease;
}
.app-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
flex-wrap: wrap;
gap: 16px;
}
.logo {
display: flex;
align-items: center;
gap: 12px;
font-size: 1.8rem;
font-weight: 700;
background: linear-gradient(135deg, #a0f0ff, #6c63ff);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.logo-icon {
font-size: 2rem;
filter: drop-shadow(0 0 6px cyan);
}
.logo-text {
letter-spacing: 1px;
}
.connection-status {
display: flex;
align-items: center;
gap: 16px;
background: rgba(0, 0, 0, 0.3);
padding: 8px 20px;
border-radius: 60px;
}
.led {
width: 12px;
height: 12px;
border-radius: 50%;
background: #ff4d4d;
box-shadow: 0 0 6px #ff4d4d;
transition: 0.2s;
}
.led.connected {
background: #0ff;
box-shadow: 0 0 12px #0ff;
}
.status-text {
font-weight: 500;
font-family: monospace;
font-size: 0.9rem;
}
.settings-card {
padding: 16px 24px;
margin-bottom: 28px;
}
.settings-row {
display: flex;
gap: 16px;
align-items: center;
flex-wrap: wrap;
}
.input-url {
flex: 1;
background: rgba(0, 0, 0, 0.4);
border: 1px solid rgba(0, 255, 255, 0.3);
border-radius: 40px;
padding: 10px 18px;
color: #eef5ff;
font-size: 0.9rem;
outline: none;
transition: 0.2s;
}
.input-url:focus {
border-color: cyan;
box-shadow: 0 0 8px rgba(0, 255, 255, 0.3);
}
.auto-connect {
display: flex;
align-items: center;
gap: 8px;
color: #aaa;
font-size: 0.85rem;
cursor: pointer;
}
.dashboard {
display: grid;
grid-template-columns: 1fr 1.1fr 0.9fr;
gap: 24px;
}
.card {
padding: 24px;
transition: transform 0.2s, box-shadow 0.2s;
}
.card:hover {
transform: translateY(-4px);
box-shadow: 0 25px 40px -12px rgba(0, 0, 0, 0.5);
}
.card-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
border-left: 3px solid #0ff;
padding-left: 14px;
}
.card-header h3 {
font-weight: 500;
font-size: 1.1rem;
letter-spacing: 0.5px;
color: #bfd9ff;
}
.header-icon {
font-size: 1.3rem;
}
/* Голосовая сфера */
.avatar-container {
display: flex;
flex-direction: column;
align-items: center;
margin: 20px 0;
}
.orb {
position: relative;
width: 150px;
height: 150px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
}
.orb-inner {
width: 130px;
height: 130px;
background: radial-gradient(circle at 35% 35%, #1ad, #0a4);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 3.5rem;
box-shadow: 0 0 30px rgba(0, 255, 200, 0.4);
transition: all 0.2s ease;
z-index: 2;
}
.orb-pulse {
position: absolute;
width: 150px;
height: 150px;
border-radius: 50%;
background: rgba(0, 255, 200, 0.2);
animation: pulse-ring 2s infinite;
z-index: 1;
}
@keyframes pulse-ring {
0% { transform: scale(0.9); opacity: 0.6; }
100% { transform: scale(1.4); opacity: 0; }
}
/* Активное слушание */
.listening-active .orb-inner {
animation: listen-pulse 0.8s infinite alternate;
box-shadow: 0 0 50px cyan;
}
@keyframes listen-pulse {
0% { transform: scale(1); background: radial-gradient(circle at 35% 35%, #1ad, #0a4); }
100% { transform: scale(1.05); background: radial-gradient(circle at 35% 35%, #3ff, #2c9); }
}
.state-badge {
background: rgba(0, 255, 255, 0.15);
backdrop-filter: blur(4px);
padding: 6px 18px;
border-radius: 40px;
font-weight: 600;
font-size: 0.9rem;
}
/* Визуализатор */
.viz-wrapper {
background: rgba(0, 0, 0, 0.3);
border-radius: 24px;
padding: 12px;
margin: 15px 0;
}
canvas {
width: 100%;
height: 80px;
display: block;
border-radius: 16px;
}
/* RMS meter */
.rms-container {
margin: 16px 0;
}
.rms-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 1px;
color: #8aa;
margin-bottom: 6px;
}
.rms-bar {
height: 6px;
background: #1e2a3a;
border-radius: 3px;
overflow: hidden;
margin-bottom: 6px;
}
.rms-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #0ff, #f0f);
border-radius: 3px;
transition: width 0.08s linear;
}
.rms-value {
font-family: monospace;
font-size: 0.8rem;
text-align: right;
color: cyan;
}
.volume-control {
display: flex;
align-items: center;
gap: 12px;
margin: 20px 0 12px;
}
.volume-control span:first-child {
font-size: 0.9rem;
}
.volume-control input {
flex: 1;
height: 4px;
-webkit-appearance: none;
appearance: none; /* стандартное свойство для совместимости */
background: #2a3a4a;
border-radius: 2px;
outline: none;
}
.volume-control input::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px;
height: 16px;
background: cyan;
border-radius: 50%;
cursor: pointer;
box-shadow: 0 0 6px cyan;
}
.volume-percent {
width: 45px;
text-align: right;
font-family: monospace;
}
.button-group {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin: 16px 0 8px;
}
.btn-primary, .btn-secondary, .btn-danger, .btn-outline {
padding: 8px 18px;
border-radius: 40px;
font-weight: 500;
font-size: 0.8rem;
cursor: pointer;
transition: 0.15s;
border: none;
background: rgba(20, 30, 50, 0.7);
backdrop-filter: blur(4px);
color: #eef5ff;
display: inline-flex;
align-items: center;
gap: 8px;
letter-spacing: 0.5px;
border: 1px solid rgba(0, 255, 255, 0.3);
}
.btn-primary {
background: rgba(0, 200, 255, 0.2);
border-color: #0ff;
}
.btn-primary:hover {
background: rgba(0, 200, 255, 0.4);
box-shadow: 0 0 10px rgba(0, 255, 255, 0.4);
transform: translateY(-2px);
}
.btn-secondary:hover {
background: rgba(100, 100, 200, 0.3);
border-color: #8af;
transform: translateY(-2px);
}
.btn-danger {
border-color: #ff66aa;
}
.btn-danger:hover {
background: #ff336620;
border-color: #ff88cc;
}
.btn-outline {
background: transparent;
border-color: #6c7a8a;
}
.btn-outline:hover {
border-color: cyan;
background: rgba(0, 255, 255, 0.1);
}
.full-width {
width: 100%;
justify-content: center;
margin-top: 12px;
}
.transcript-area {
background: rgba(0, 0, 0, 0.3);
border-radius: 24px;
padding: 16px;
margin-top: 18px;
}
.transcript-item {
margin-bottom: 14px;
}
.transcript-label {
font-size: 0.7rem;
text-transform: uppercase;
color: #8ad;
margin-bottom: 6px;
}
.transcript-text {
font-style: italic;
color: #f0f9ff;
word-break: break-word;
font-size: 0.9rem;
}
.status-grid {
display: flex;
gap: 16px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.status-chip {
background: rgba(0, 0, 0, 0.3);
padding: 6px 14px;
border-radius: 40px;
font-size: 0.8rem;
}
.event-feed {
background: rgba(0, 0, 0, 0.25);
border-radius: 20px;
height: 220px;
overflow-y: auto;
padding: 10px;
font-family: monospace;
font-size: 0.75rem;
}
.event-item {
padding: 8px 0;
border-bottom: 1px solid #2a4c6e50;
color: #bde;
}
@media (max-width: 1000px) {
.dashboard {
grid-template-columns: 1fr;
gap: 20px;
}
body {
padding: 16px;
}
.orb {
width: 120px;
height: 120px;
}
.orb-inner {
width: 100px;
height: 100px;
font-size: 2.5rem;
}
}