diff --git a/app.py b/app.py
index 80c47d2..2dfb167 100644
--- a/app.py
+++ b/app.py
@@ -278,9 +278,13 @@ def get_sdr_device_status() -> dict[int, str]:
# ============================================
@app.before_request
-def require_login():
- # Routes that don't require login (to avoid infinite redirect loop)
- allowed_routes = ['login', 'static', 'favicon', 'health', 'health_check']
+def require_login():
+ # Routes that don't require login (to avoid infinite redirect loop)
+ allowed_routes = ['login', 'static', 'favicon', 'health', 'health_check']
+
+ # Allow audio streaming endpoints without session auth
+ if request.path.startswith('/listening/audio/'):
+ return None
# Controller API endpoints use API key auth, not session auth
# Allow agent push/pull endpoints without session login
diff --git a/docs/UI_GUIDE.md b/docs/UI_GUIDE.md
new file mode 100644
index 0000000..cbd2ff2
--- /dev/null
+++ b/docs/UI_GUIDE.md
@@ -0,0 +1,608 @@
+# iNTERCEPT UI Guide
+
+This guide documents the UI design system, components, and patterns used in iNTERCEPT.
+
+## Table of Contents
+
+1. [Design Tokens](#design-tokens)
+2. [Base Templates](#base-templates)
+3. [Navigation](#navigation)
+4. [Components](#components)
+5. [Adding a New Module Page](#adding-a-new-module-page)
+6. [Adding a New Dashboard](#adding-a-new-dashboard)
+
+---
+
+## Design Tokens
+
+All design tokens are defined in `static/css/core/variables.css`. Import this file first in any stylesheet.
+
+### Colors
+
+```css
+/* Backgrounds (layered depth) */
+--bg-primary: #0a0c10; /* Darkest - page background */
+--bg-secondary: #0f1218; /* Panels, sidebars */
+--bg-tertiary: #151a23; /* Cards, elevated elements */
+--bg-card: #121620; /* Card backgrounds */
+--bg-elevated: #1a202c; /* Hover states, modals */
+
+/* Accent Colors */
+--accent-cyan: #4a9eff; /* Primary action color */
+--accent-green: #22c55e; /* Success, online status */
+--accent-red: #ef4444; /* Error, danger, stop */
+--accent-orange: #f59e0b; /* Warning */
+--accent-amber: #d4a853; /* Secondary highlight */
+
+/* Text Hierarchy */
+--text-primary: #e8eaed; /* Main content */
+--text-secondary: #9ca3af; /* Secondary content */
+--text-dim: #4b5563; /* Disabled, placeholder */
+--text-muted: #374151; /* Barely visible */
+
+/* Status Colors */
+--status-online: #22c55e;
+--status-warning: #f59e0b;
+--status-error: #ef4444;
+--status-offline: #6b7280;
+```
+
+### Spacing Scale
+
+```css
+--space-1: 4px;
+--space-2: 8px;
+--space-3: 12px;
+--space-4: 16px;
+--space-5: 20px;
+--space-6: 24px;
+--space-8: 32px;
+--space-10: 40px;
+--space-12: 48px;
+--space-16: 64px;
+```
+
+### Typography
+
+```css
+/* Font Families */
+--font-sans: 'Inter', -apple-system, sans-serif;
+--font-mono: 'JetBrains Mono', monospace;
+
+/* Font Sizes */
+--text-xs: 10px;
+--text-sm: 12px;
+--text-base: 14px;
+--text-lg: 16px;
+--text-xl: 18px;
+--text-2xl: 20px;
+--text-3xl: 24px;
+--text-4xl: 30px;
+```
+
+### Border Radius
+
+```css
+--radius-sm: 4px;
+--radius-md: 6px;
+--radius-lg: 8px;
+--radius-xl: 12px;
+--radius-full: 9999px;
+```
+
+### Light Theme
+
+The design system supports light/dark themes via `data-theme` attribute:
+
+```html
+
+```
+
+Toggle with JavaScript:
+```javascript
+document.documentElement.setAttribute('data-theme', 'light');
+```
+
+---
+
+## Base Templates
+
+### `templates/layout/base.html`
+
+The main base template for standard pages. Use for pages with sidebar + content layout.
+
+```html
+{% extends 'layout/base.html' %}
+
+{% block title %}My Page Title{% endblock %}
+
+{% block styles %}
+
+{% endblock %}
+
+{% block navigation %}
+{% set active_mode = 'mymode' %}
+{% include 'partials/nav.html' %}
+{% endblock %}
+
+{% block sidebar %}
+
+{% endblock %}
+
+{% block content %}
+
+
Page Title
+
+
+{% endblock %}
+
+{% block scripts %}
+
+{% endblock %}
+```
+
+### `templates/layout/base_dashboard.html`
+
+Extended base for full-screen dashboards (maps, visualizations).
+
+```html
+{% extends 'layout/base_dashboard.html' %}
+
+{% set active_mode = 'mydashboard' %}
+
+{% block dashboard_title %}MY DASHBOARD{% endblock %}
+
+{% block styles %}
+{{ super() }}
+
+{% endblock %}
+
+{% block stats_strip %}
+
+
+
+{% endblock %}
+
+{% block dashboard_content %}
+
+
+
+
+{% endblock %}
+```
+
+---
+
+## Navigation
+
+### Including Navigation
+
+```html
+{% set active_mode = 'pager' %}
+{% include 'partials/nav.html' %}
+```
+
+### Valid `active_mode` Values
+
+| Mode | Description |
+|------|-------------|
+| `pager` | Pager decoding |
+| `sensor` | 433MHz sensors |
+| `rtlamr` | Utility meters |
+| `adsb` | Aircraft tracking |
+| `ais` | Vessel tracking |
+| `aprs` | Amateur radio |
+| `wifi` | WiFi scanning |
+| `bluetooth` | Bluetooth scanning |
+| `tscm` | Counter-surveillance |
+| `satellite` | Satellite tracking |
+| `sstv` | ISS SSTV |
+| `listening` | Listening post |
+| `spystations` | Spy stations |
+| `meshtastic` | Mesh networking |
+
+### Navigation Groups
+
+The navigation is organized into groups:
+- **SDR / RF**: Pager, 433MHz, Meters, Aircraft, Vessels, APRS, Listening Post, Spy Stations, Meshtastic
+- **Wireless**: WiFi, Bluetooth
+- **Security**: TSCM
+- **Space**: Satellite, ISS SSTV
+
+---
+
+## Components
+
+### Card / Panel
+
+```html
+{% call card(title='PANEL TITLE', indicator=true, indicator_active=false) %}
+Panel content here
+{% endcall %}
+```
+
+Or manually:
+```html
+
+```
+
+### Empty State
+
+```html
+{% include 'components/empty_state.html' with context %}
+{# Or with variables: #}
+{% with title='No data yet', description='Start scanning to see results', action_text='Start Scan', action_onclick='startScan()' %}
+{% include 'components/empty_state.html' %}
+{% endwith %}
+```
+
+### Loading State
+
+```html
+{# Inline spinner #}
+{% include 'components/loading.html' %}
+
+{# With text #}
+{% with text='Loading data...', size='lg' %}
+{% include 'components/loading.html' %}
+{% endwith %}
+
+{# Full overlay #}
+{% with overlay=true, text='Please wait...' %}
+{% include 'components/loading.html' %}
+{% endwith %}
+```
+
+### Status Badge
+
+```html
+{% with status='online', text='Connected', id='connectionStatus' %}
+{% include 'components/status_badge.html' %}
+{% endwith %}
+```
+
+Status values: `online`, `offline`, `warning`, `error`, `inactive`
+
+### Buttons
+
+```html
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+### Badges
+
+```html
+Default
+Primary
+Online
+Warning
+Error
+```
+
+### Form Groups
+
+```html
+
+
+
+ Enter frequency in MHz
+
+
+
+
+
+
+
+
+```
+
+### Stats Strip
+
+Used in dashboards for horizontal statistics display:
+
+```html
+
+
+
+ 0
+ COUNT
+
+
+
+
--:--:-- UTC
+
+
+```
+
+---
+
+## Adding a New Module Page
+
+### 1. Create the Route
+
+In `routes/mymodule.py`:
+
+```python
+from flask import Blueprint, render_template
+
+mymodule_bp = Blueprint('mymodule', __name__, url_prefix='/mymodule')
+
+@mymodule_bp.route('/dashboard')
+def dashboard():
+ return render_template('mymodule_dashboard.html',
+ offline_settings=get_offline_settings())
+```
+
+### 2. Register the Blueprint
+
+In `routes/__init__.py`:
+
+```python
+from routes.mymodule import mymodule_bp
+app.register_blueprint(mymodule_bp)
+```
+
+### 3. Create the Template
+
+Option A: Simple page extending base.html
+```html
+{% extends 'layout/base.html' %}
+{% set active_mode = 'mymodule' %}
+
+{% block title %}My Module{% endblock %}
+
+{% block navigation %}
+{% include 'partials/nav.html' %}
+{% endblock %}
+
+{% block content %}
+
+{% endblock %}
+```
+
+Option B: Full-screen dashboard
+```html
+{% extends 'layout/base_dashboard.html' %}
+{% set active_mode = 'mymodule' %}
+
+{% block dashboard_title %}MY MODULE{% endblock %}
+
+{% block dashboard_content %}
+
+{% endblock %}
+```
+
+### 4. Add to Navigation
+
+In `templates/partials/nav.html`, add your module to the appropriate group:
+
+```html
+
+```
+
+Or if it's a dashboard link:
+```html
+
+
+ My Module
+
+```
+
+### 5. Create Stylesheet
+
+In `static/css/mymodule.css`:
+
+```css
+/**
+ * My Module Styles
+ */
+@import url('./core/variables.css');
+
+/* Your styles using design tokens */
+.mymodule-container {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-lg);
+ padding: var(--space-4);
+}
+```
+
+---
+
+## Adding a New Dashboard
+
+For full-screen dashboards like ADSB, AIS, or Satellite:
+
+### 1. Create the Template
+
+```html
+
+
+
+
+
+ MY DASHBOARD // iNTERCEPT
+
+
+
+
+
+
+ {% if offline_settings.fonts_source == 'local' %}
+
+ {% else %}
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% set active_mode = 'mydashboard' %}
+ {% include 'partials/nav.html' %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+### 2. Create the Stylesheet
+
+```css
+/**
+ * My Dashboard Styles
+ */
+@import url('./core/variables.css');
+
+:root {
+ /* Dashboard-specific aliases */
+ --bg-dark: var(--bg-primary);
+ --bg-panel: var(--bg-secondary);
+ --bg-card: var(--bg-tertiary);
+ --grid-line: rgba(74, 158, 255, 0.08);
+}
+
+/* Your dashboard styles */
+```
+
+---
+
+## Best Practices
+
+### DO
+
+- Use design tokens for all colors, spacing, and typography
+- Include the nav partial on all pages for consistent navigation
+- Set `active_mode` before including the nav partial
+- Use semantic component classes (`btn`, `panel`, `badge`, etc.)
+- Support both light and dark themes
+- Test on mobile viewports
+
+### DON'T
+
+- Hardcode color values - use CSS variables
+- Create new color variations without adding to tokens
+- Duplicate navigation markup - use the partial
+- Skip the favicon and design tokens imports
+- Use inline styles for layout (use utility classes)
+
+---
+
+## File Structure
+
+```
+templates/
+├── layout/
+│ ├── base.html # Standard page base
+│ └── base_dashboard.html # Dashboard page base
+├── partials/
+│ ├── nav.html # Unified navigation
+│ ├── page_header.html # Page title component
+│ └── settings-modal.html # Settings modal
+├── components/
+│ ├── card.html # Panel/card component
+│ ├── empty_state.html # Empty state placeholder
+│ ├── loading.html # Loading spinner
+│ ├── stats_strip.html # Stats bar component
+│ └── status_badge.html # Status indicator
+├── index.html # Main dashboard
+├── adsb_dashboard.html # Aircraft tracking
+├── ais_dashboard.html # Vessel tracking
+└── satellite_dashboard.html # Satellite tracking
+
+static/css/
+├── core/
+│ ├── variables.css # Design tokens
+│ ├── base.css # Reset & typography
+│ ├── components.css # Component styles
+│ └── layout.css # Layout styles
+├── index.css # Main dashboard styles
+├── adsb_dashboard.css # Aircraft dashboard
+├── ais_dashboard.css # Vessel dashboard
+├── satellite_dashboard.css # Satellite dashboard
+└── responsive.css # Responsive breakpoints
+```
diff --git a/routes/audio_websocket.py b/routes/audio_websocket.py
index 1971d88..17abaa2 100644
--- a/routes/audio_websocket.py
+++ b/routes/audio_websocket.py
@@ -228,9 +228,13 @@ def init_audio_websocket(app: Flask):
except TimeoutError:
pass
- except Exception as e:
- if "timed out" not in str(e).lower():
- logger.error(f"WebSocket receive error: {e}")
+ except Exception as e:
+ msg = str(e).lower()
+ if "connection closed" in msg:
+ logger.info("WebSocket closed by client")
+ break
+ if "timed out" not in msg:
+ logger.error(f"WebSocket receive error: {e}")
# Stream audio data if active
if streaming and proc and proc.poll() is None:
diff --git a/routes/listening_post.py b/routes/listening_post.py
index 2e9cd83..19725a2 100644
--- a/routes/listening_post.py
+++ b/routes/listening_post.py
@@ -14,11 +14,12 @@ import time
from datetime import datetime
from typing import Generator, Optional, List, Dict
-from flask import Blueprint, jsonify, request, Response
-
-from utils.logging import get_logger
-from utils.sse import format_sse
-from utils.constants import (
+from flask import Blueprint, jsonify, request, Response
+
+import app as app_module
+from utils.logging import get_logger
+from utils.sse import format_sse
+from utils.constants import (
SSE_QUEUE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
PROCESS_TERMINATE_TIMEOUT,
@@ -42,24 +43,29 @@ audio_frequency = 0.0
audio_modulation = 'fm'
# Scanner state
-scanner_thread: Optional[threading.Thread] = None
-scanner_running = False
-scanner_lock = threading.Lock()
-scanner_paused = False
-scanner_current_freq = 0.0
-scanner_config = {
- 'start_freq': 88.0,
- 'end_freq': 108.0,
- 'step': 0.1,
- 'modulation': 'wfm',
- 'squelch': 20,
+scanner_thread: Optional[threading.Thread] = None
+scanner_running = False
+scanner_lock = threading.Lock()
+scanner_paused = False
+scanner_current_freq = 0.0
+scanner_active_device: Optional[int] = None
+listening_active_device: Optional[int] = None
+scanner_power_process: Optional[subprocess.Popen] = None
+scanner_config = {
+ 'start_freq': 88.0,
+ 'end_freq': 108.0,
+ 'step': 0.1,
+ 'modulation': 'wfm',
+ 'squelch': 0,
'dwell_time': 10.0, # Seconds to stay on active frequency
'scan_delay': 0.1, # Seconds between frequency hops (keep low for fast scanning)
'device': 0,
'gain': 40,
- 'bias_t': False, # Bias-T power for external LNA
- 'sdr_type': 'rtlsdr', # SDR type: rtlsdr, hackrf, airspy, limesdr, sdrplay
-}
+ 'bias_t': False, # Bias-T power for external LNA
+ 'sdr_type': 'rtlsdr', # SDR type: rtlsdr, hackrf, airspy, limesdr, sdrplay
+ 'scan_method': 'power', # power (rtl_power) or classic (rtl_fm hop)
+ 'snr_threshold': 8,
+}
# Activity log
activity_log: List[Dict] = []
@@ -74,9 +80,14 @@ scanner_queue: queue.Queue = queue.Queue(maxsize=100)
# HELPER FUNCTIONS
# ============================================
-def find_rtl_fm() -> str | None:
- """Find rtl_fm binary."""
- return shutil.which('rtl_fm')
+def find_rtl_fm() -> str | None:
+ """Find rtl_fm binary."""
+ return shutil.which('rtl_fm')
+
+
+def find_rtl_power() -> str | None:
+ """Find rtl_power binary."""
+ return shutil.which('rtl_power')
def find_rx_fm() -> str | None:
@@ -119,7 +130,7 @@ def add_activity_log(event_type: str, frequency: float, details: str = ''):
# SCANNER IMPLEMENTATION
# ============================================
-def scanner_loop():
+def scanner_loop():
"""Main scanner loop - scans frequencies looking for signals."""
global scanner_running, scanner_paused, scanner_current_freq, scanner_skip_signal
global audio_process, audio_rtl_process, audio_running, audio_frequency
@@ -157,14 +168,16 @@ def scanner_loop():
scanner_current_freq = current_freq
# Notify clients of frequency change
- try:
- scanner_queue.put_nowait({
- 'type': 'freq_change',
- 'frequency': current_freq,
- 'scanning': not signal_detected
- })
- except queue.Full:
- pass
+ try:
+ scanner_queue.put_nowait({
+ 'type': 'freq_change',
+ 'frequency': current_freq,
+ 'scanning': not signal_detected,
+ 'range_start': scanner_config['start_freq'],
+ 'range_end': scanner_config['end_freq']
+ })
+ except queue.Full:
+ pass
# Start rtl_fm at this frequency
freq_hz = int(current_freq * 1e6)
@@ -235,26 +248,31 @@ def scanner_loop():
# Threshold based on squelch setting
# Lower squelch = more sensitive (lower threshold)
# squelch 0 = very sensitive, squelch 100 = only strong signals
- if mod == 'wfm':
- # WFM: threshold 500-10000 based on squelch
- threshold = 500 + (squelch * 95)
- else:
- # AM/NFM: threshold 300-6500 based on squelch
- threshold = 300 + (squelch * 62)
-
- audio_detected = rms > threshold
+ if mod == 'wfm':
+ # WFM: threshold 500-10000 based on squelch
+ threshold = 500 + (squelch * 95)
+ min_threshold = 1500
+ else:
+ # AM/NFM: threshold 300-6500 based on squelch
+ threshold = 300 + (squelch * 62)
+ min_threshold = 900
+
+ effective_threshold = max(threshold, min_threshold)
+ audio_detected = rms > effective_threshold
# Send level info to clients
- try:
- scanner_queue.put_nowait({
- 'type': 'scan_update',
- 'frequency': current_freq,
- 'level': int(rms),
- 'threshold': int(threshold) if 'threshold' in dir() else 0,
- 'detected': audio_detected
- })
- except queue.Full:
- pass
+ try:
+ scanner_queue.put_nowait({
+ 'type': 'scan_update',
+ 'frequency': current_freq,
+ 'level': int(rms),
+ 'threshold': int(effective_threshold) if 'effective_threshold' in dir() else 0,
+ 'detected': audio_detected,
+ 'range_start': scanner_config['start_freq'],
+ 'range_end': scanner_config['end_freq']
+ })
+ except queue.Full:
+ pass
if audio_detected and scanner_running:
if not signal_detected:
@@ -268,15 +286,19 @@ def scanner_loop():
# Start audio streaming for user
_start_audio_stream(current_freq, mod)
- try:
- scanner_queue.put_nowait({
- 'type': 'signal_found',
- 'frequency': current_freq,
- 'modulation': mod,
- 'audio_streaming': True
- })
- except queue.Full:
- pass
+ try:
+ scanner_queue.put_nowait({
+ 'type': 'signal_found',
+ 'frequency': current_freq,
+ 'modulation': mod,
+ 'audio_streaming': True,
+ 'level': int(rms),
+ 'threshold': int(effective_threshold),
+ 'range_start': scanner_config['start_freq'],
+ 'range_end': scanner_config['end_freq']
+ })
+ except queue.Full:
+ pass
# Check for skip signal
if scanner_skip_signal:
@@ -296,16 +318,36 @@ def scanner_loop():
current_freq = scanner_config['start_freq']
continue
- # Stay on this frequency (dwell) but check periodically
- dwell_start = time.time()
- while (time.time() - dwell_start) < scanner_config['dwell_time'] and scanner_running:
- if scanner_skip_signal:
- break
- time.sleep(0.2)
-
- last_signal_time = time.time()
-
- else:
+ # Stay on this frequency (dwell) but check periodically
+ dwell_start = time.time()
+ while (time.time() - dwell_start) < scanner_config['dwell_time'] and scanner_running:
+ if scanner_skip_signal:
+ break
+ time.sleep(0.2)
+
+ last_signal_time = time.time()
+
+ # After dwell, move on to keep scanning
+ if scanner_running and not scanner_skip_signal:
+ signal_detected = False
+ _stop_audio_stream()
+ try:
+ scanner_queue.put_nowait({
+ 'type': 'signal_lost',
+ 'frequency': current_freq,
+ 'range_start': scanner_config['start_freq'],
+ 'range_end': scanner_config['end_freq']
+ })
+ except queue.Full:
+ pass
+
+ current_freq += step_mhz
+ if current_freq > scanner_config['end_freq']:
+ current_freq = scanner_config['start_freq']
+ add_activity_log('scan_cycle', current_freq, 'Scan cycle complete')
+ time.sleep(scanner_config['scan_delay'])
+
+ else:
# No signal at this frequency
if signal_detected:
# Signal lost
@@ -339,14 +381,249 @@ def scanner_loop():
except Exception as e:
logger.error(f"Scanner loop error: {e}")
- finally:
- scanner_running = False
- _stop_audio_stream()
- add_activity_log('scanner_stop', scanner_current_freq, 'Scanner stopped')
- logger.info("Scanner thread stopped")
+ finally:
+ scanner_running = False
+ _stop_audio_stream()
+ add_activity_log('scanner_stop', scanner_current_freq, 'Scanner stopped')
+ logger.info("Scanner thread stopped")
+
+
+def scanner_loop_power():
+ """Power sweep scanner using rtl_power to detect peaks."""
+ global scanner_running, scanner_paused, scanner_current_freq, scanner_power_process
+
+ logger.info("Power sweep scanner thread started")
+ add_activity_log('scanner_start', scanner_config['start_freq'],
+ f"Power sweep {scanner_config['start_freq']}-{scanner_config['end_freq']} MHz")
+
+ rtl_power_path = find_rtl_power()
+ if not rtl_power_path:
+ logger.error("rtl_power not found")
+ add_activity_log('error', 0, 'rtl_power not found')
+ scanner_running = False
+ return
+
+ try:
+ while scanner_running:
+ if scanner_paused:
+ time.sleep(0.1)
+ continue
+
+ start_mhz = scanner_config['start_freq']
+ end_mhz = scanner_config['end_freq']
+ step_khz = scanner_config['step']
+ gain = scanner_config['gain']
+ device = scanner_config['device']
+ squelch = scanner_config['squelch']
+ mod = scanner_config['modulation']
+
+ # Configure sweep
+ bin_hz = max(1000, int(step_khz * 1000))
+ start_hz = int(start_mhz * 1e6)
+ end_hz = int(end_mhz * 1e6)
+ # Integration time per sweep (seconds)
+ integration = max(0.3, min(1.0, scanner_config.get('scan_delay', 0.5)))
+
+ cmd = [
+ rtl_power_path,
+ '-f', f'{start_hz}:{end_hz}:{bin_hz}',
+ '-i', f'{integration}',
+ '-1',
+ '-g', str(gain),
+ '-d', str(device),
+ ]
+
+ try:
+ proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
+ scanner_power_process = proc
+ stdout, _ = proc.communicate(timeout=15)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ stdout = b''
+ finally:
+ scanner_power_process = None
+
+ if not scanner_running:
+ break
+
+ if not stdout:
+ add_activity_log('error', start_mhz, 'Power sweep produced no data')
+ try:
+ scanner_queue.put_nowait({
+ 'type': 'scan_update',
+ 'frequency': end_mhz,
+ 'level': 0,
+ 'threshold': int(float(scanner_config.get('snr_threshold', 12)) * 100),
+ 'detected': False,
+ 'range_start': scanner_config['start_freq'],
+ 'range_end': scanner_config['end_freq']
+ })
+ except queue.Full:
+ pass
+ time.sleep(0.2)
+ continue
+
+ lines = stdout.decode(errors='ignore').splitlines()
+ segments = []
+ for line in lines:
+ if not line or line.startswith('#'):
+ continue
+
+ parts = [p.strip() for p in line.split(',')]
+ # Find start_hz token
+ start_idx = None
+ for i, tok in enumerate(parts):
+ try:
+ val = float(tok)
+ except ValueError:
+ continue
+ if val > 1e5:
+ start_idx = i
+ break
+ if start_idx is None or len(parts) < start_idx + 6:
+ continue
+
+ try:
+ sweep_start = float(parts[start_idx])
+ sweep_end = float(parts[start_idx + 1])
+ sweep_bin = float(parts[start_idx + 2])
+ raw_values = []
+ for v in parts[start_idx + 3:]:
+ try:
+ raw_values.append(float(v))
+ except ValueError:
+ continue
+ # rtl_power may include a samples field before the power list
+ if raw_values and raw_values[0] >= 0 and any(val < 0 for val in raw_values[1:]):
+ raw_values = raw_values[1:]
+ bin_values = raw_values
+ except ValueError:
+ continue
+
+ if not bin_values:
+ continue
+
+ segments.append((sweep_start, sweep_end, sweep_bin, bin_values))
+
+ if not segments:
+ add_activity_log('error', start_mhz, 'Power sweep bins missing')
+ try:
+ scanner_queue.put_nowait({
+ 'type': 'scan_update',
+ 'frequency': end_mhz,
+ 'level': 0,
+ 'threshold': int(float(scanner_config.get('snr_threshold', 12)) * 100),
+ 'detected': False,
+ 'range_start': scanner_config['start_freq'],
+ 'range_end': scanner_config['end_freq']
+ })
+ except queue.Full:
+ pass
+ time.sleep(0.2)
+ continue
+
+ # Process segments in ascending frequency order to avoid backtracking in UI
+ segments.sort(key=lambda s: s[0])
+ total_bins = sum(len(seg[3]) for seg in segments)
+ if total_bins <= 0:
+ time.sleep(0.2)
+ continue
+ segment_offset = 0
+
+ for sweep_start, sweep_end, sweep_bin, bin_values in segments:
+ # Noise floor (median)
+ sorted_vals = sorted(bin_values)
+ mid = len(sorted_vals) // 2
+ noise_floor = sorted_vals[mid]
+
+ # SNR threshold (dB)
+ snr_threshold = float(scanner_config.get('snr_threshold', 12))
+
+ # Emit progress updates (throttled)
+ emit_stride = max(1, len(bin_values) // 60)
+ for idx, val in enumerate(bin_values):
+ if idx % emit_stride != 0 and idx != len(bin_values) - 1:
+ continue
+ freq_hz = sweep_start + sweep_bin * idx
+ scanner_current_freq = freq_hz / 1e6
+ snr = val - noise_floor
+ level = int(max(0, snr) * 100)
+ threshold = int(snr_threshold * 100)
+ progress = min(1.0, (segment_offset + idx) / max(1, total_bins - 1))
+ try:
+ scanner_queue.put_nowait({
+ 'type': 'scan_update',
+ 'frequency': scanner_current_freq,
+ 'level': level,
+ 'threshold': threshold,
+ 'detected': snr >= snr_threshold,
+ 'progress': progress,
+ 'range_start': scanner_config['start_freq'],
+ 'range_end': scanner_config['end_freq']
+ })
+ except queue.Full:
+ pass
+ segment_offset += len(bin_values)
+
+ # Detect peaks (clusters above threshold)
+ peaks = []
+ in_cluster = False
+ peak_idx = None
+ peak_val = None
+ for idx, val in enumerate(bin_values):
+ snr = val - noise_floor
+ if snr >= snr_threshold:
+ if not in_cluster:
+ in_cluster = True
+ peak_idx = idx
+ peak_val = val
+ else:
+ if val > peak_val:
+ peak_val = val
+ peak_idx = idx
+ else:
+ if in_cluster and peak_idx is not None:
+ peaks.append((peak_idx, peak_val))
+ in_cluster = False
+ peak_idx = None
+ peak_val = None
+ if in_cluster and peak_idx is not None:
+ peaks.append((peak_idx, peak_val))
+
+ for idx, val in peaks:
+ freq_hz = sweep_start + sweep_bin * (idx + 0.5)
+ freq_mhz = freq_hz / 1e6
+ snr = val - noise_floor
+ level = int(max(0, snr) * 100)
+ threshold = int(snr_threshold * 100)
+ add_activity_log('signal_found', freq_mhz,
+ f'Peak detected at {freq_mhz:.3f} MHz ({mod.upper()})')
+ try:
+ scanner_queue.put_nowait({
+ 'type': 'signal_found',
+ 'frequency': freq_mhz,
+ 'modulation': mod,
+ 'audio_streaming': False,
+ 'level': level,
+ 'threshold': threshold,
+ 'range_start': scanner_config['start_freq'],
+ 'range_end': scanner_config['end_freq']
+ })
+ except queue.Full:
+ pass
+
+ add_activity_log('scan_cycle', start_mhz, 'Power sweep complete')
+ time.sleep(max(0.1, scanner_config.get('scan_delay', 0.5)))
+
+ except Exception as e:
+ logger.error(f"Power sweep scanner error: {e}")
+ finally:
+ scanner_running = False
+ add_activity_log('scanner_stop', scanner_current_freq, 'Scanner stopped')
+ logger.info("Power sweep scanner thread stopped")
-def _start_audio_stream(frequency: float, modulation: str):
+def _start_audio_stream(frequency: float, modulation: str):
"""Start audio streaming at given frequency."""
global audio_process, audio_rtl_process, audio_running, audio_frequency, audio_modulation
@@ -424,58 +701,69 @@ def _start_audio_stream(frequency: float, modulation: str):
# Ensure we use the found rx_fm path
sdr_cmd[0] = rx_fm_path
- encoder_cmd = [
- ffmpeg_path,
- '-hide_banner',
- '-loglevel', 'error',
- '-f', 's16le',
- '-ar', str(resample_rate),
- '-ac', '1',
- '-i', 'pipe:0',
- '-acodec', 'libmp3lame',
- '-b:a', '128k',
- '-ar', '44100',
- '-f', 'mp3',
- 'pipe:1'
- ]
+ encoder_cmd = [
+ ffmpeg_path,
+ '-hide_banner',
+ '-loglevel', 'error',
+ '-fflags', 'nobuffer',
+ '-flags', 'low_delay',
+ '-probesize', '32',
+ '-analyzeduration', '0',
+ '-f', 's16le',
+ '-ar', str(resample_rate),
+ '-ac', '1',
+ '-i', 'pipe:0',
+ '-acodec', 'pcm_s16le',
+ '-ar', '44100',
+ '-f', 'wav',
+ 'pipe:1'
+ ]
- try:
- # Use shell pipe for reliable streaming
- # Log stderr to temp files for error diagnosis
- rtl_stderr_log = '/tmp/rtl_fm_stderr.log'
- ffmpeg_stderr_log = '/tmp/ffmpeg_stderr.log'
- shell_cmd = f"{' '.join(sdr_cmd)} 2>{rtl_stderr_log} | {' '.join(encoder_cmd)} 2>{ffmpeg_stderr_log}"
- logger.info(f"Starting audio: {frequency} MHz, mod={modulation}, device={scanner_config['device']}")
-
- audio_rtl_process = None # Not used in shell mode
- audio_process = subprocess.Popen(
- shell_cmd,
- shell=True,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- bufsize=0,
- start_new_session=True # Create new process group for clean shutdown
- )
-
- # Brief delay to check if process started successfully
- time.sleep(0.3)
-
- if audio_process.poll() is not None:
- # Read stderr from temp files
- rtl_stderr = ''
- ffmpeg_stderr = ''
- try:
- with open(rtl_stderr_log, 'r') as f:
- rtl_stderr = f.read().strip()
- except:
- pass
- try:
- with open(ffmpeg_stderr_log, 'r') as f:
- ffmpeg_stderr = f.read().strip()
- except:
- pass
- logger.error(f"Audio pipeline exited immediately. rtl_fm stderr: {rtl_stderr}, ffmpeg stderr: {ffmpeg_stderr}")
- return
+ try:
+ # Use shell pipe for reliable streaming
+ # Log stderr to temp files for error diagnosis
+ rtl_stderr_log = '/tmp/rtl_fm_stderr.log'
+ ffmpeg_stderr_log = '/tmp/ffmpeg_stderr.log'
+ shell_cmd = f"{' '.join(sdr_cmd)} 2>{rtl_stderr_log} | {' '.join(encoder_cmd)} 2>{ffmpeg_stderr_log}"
+ logger.info(f"Starting audio: {frequency} MHz, mod={modulation}, device={scanner_config['device']}")
+
+ audio_rtl_process = None # Not used in shell mode
+ audio_process = subprocess.Popen(
+ shell_cmd,
+ shell=True,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ bufsize=0,
+ start_new_session=True # Create new process group for clean shutdown
+ )
+
+ # Brief delay to check if process started successfully
+ time.sleep(0.3)
+
+ if audio_process.poll() is not None:
+ # Read stderr from temp files
+ rtl_stderr = ''
+ ffmpeg_stderr = ''
+ try:
+ with open(rtl_stderr_log, 'r') as f:
+ rtl_stderr = f.read().strip()
+ except:
+ pass
+ try:
+ with open(ffmpeg_stderr_log, 'r') as f:
+ ffmpeg_stderr = f.read().strip()
+ except:
+ pass
+ logger.error(f"Audio pipeline exited immediately. rtl_fm stderr: {rtl_stderr}, ffmpeg stderr: {ffmpeg_stderr}")
+ return
+
+ # Validate that audio is producing data quickly
+ try:
+ ready, _, _ = select.select([audio_process.stdout], [], [], 4.0)
+ if not ready:
+ logger.warning("Audio pipeline produced no data in startup window")
+ except Exception as e:
+ logger.warning(f"Audio startup check failed: {e}")
audio_running = True
audio_frequency = frequency
@@ -533,12 +821,13 @@ def _stop_audio_stream_internal():
# API ENDPOINTS
# ============================================
-@listening_post_bp.route('/tools')
-def check_tools() -> Response:
- """Check for required tools."""
- rtl_fm = find_rtl_fm()
- rx_fm = find_rx_fm()
- ffmpeg = find_ffmpeg()
+@listening_post_bp.route('/tools')
+def check_tools() -> Response:
+ """Check for required tools."""
+ rtl_fm = find_rtl_fm()
+ rtl_power = find_rtl_power()
+ rx_fm = find_rx_fm()
+ ffmpeg = find_ffmpeg()
# Determine which SDR types are supported
supported_sdr_types = []
@@ -548,47 +837,58 @@ def check_tools() -> Response:
# rx_fm from SoapySDR supports these types
supported_sdr_types.extend(['hackrf', 'airspy', 'limesdr', 'sdrplay'])
- return jsonify({
- 'rtl_fm': rtl_fm is not None,
- 'rx_fm': rx_fm is not None,
- 'ffmpeg': ffmpeg is not None,
- 'available': (rtl_fm is not None or rx_fm is not None) and ffmpeg is not None,
- 'supported_sdr_types': supported_sdr_types
- })
+ return jsonify({
+ 'rtl_fm': rtl_fm is not None,
+ 'rtl_power': rtl_power is not None,
+ 'rx_fm': rx_fm is not None,
+ 'ffmpeg': ffmpeg is not None,
+ 'available': (rtl_fm is not None or rx_fm is not None) and ffmpeg is not None,
+ 'supported_sdr_types': supported_sdr_types
+ })
@listening_post_bp.route('/scanner/start', methods=['POST'])
-def start_scanner() -> Response:
- """Start the frequency scanner."""
- global scanner_thread, scanner_running, scanner_config
-
- with scanner_lock:
- if scanner_running:
- return jsonify({
- 'status': 'error',
- 'message': 'Scanner already running'
- }), 409
+def start_scanner() -> Response:
+ """Start the frequency scanner."""
+ global scanner_thread, scanner_running, scanner_config, scanner_active_device, listening_active_device
+
+ with scanner_lock:
+ if scanner_running:
+ return jsonify({
+ 'status': 'error',
+ 'message': 'Scanner already running'
+ }), 409
+
+ # Clear stale queue entries so UI updates immediately
+ try:
+ while True:
+ scanner_queue.get_nowait()
+ except queue.Empty:
+ pass
data = request.json or {}
# Update scanner config
try:
- scanner_config['start_freq'] = float(data.get('start_freq', 88.0))
- scanner_config['end_freq'] = float(data.get('end_freq', 108.0))
- scanner_config['step'] = float(data.get('step', 0.1))
- scanner_config['modulation'] = str(data.get('modulation', 'wfm')).lower()
- scanner_config['squelch'] = int(data.get('squelch', 20))
- scanner_config['dwell_time'] = float(data.get('dwell_time', 3.0))
- scanner_config['scan_delay'] = float(data.get('scan_delay', 0.5))
- scanner_config['device'] = int(data.get('device', 0))
- scanner_config['gain'] = int(data.get('gain', 40))
- scanner_config['bias_t'] = bool(data.get('bias_t', False))
- scanner_config['sdr_type'] = str(data.get('sdr_type', 'rtlsdr')).lower()
- except (ValueError, TypeError) as e:
- return jsonify({
- 'status': 'error',
- 'message': f'Invalid parameter: {e}'
- }), 400
+ scanner_config['start_freq'] = float(data.get('start_freq', 88.0))
+ scanner_config['end_freq'] = float(data.get('end_freq', 108.0))
+ scanner_config['step'] = float(data.get('step', 0.1))
+ scanner_config['modulation'] = str(data.get('modulation', 'wfm')).lower()
+ scanner_config['squelch'] = int(data.get('squelch', 0))
+ scanner_config['dwell_time'] = float(data.get('dwell_time', 3.0))
+ scanner_config['scan_delay'] = float(data.get('scan_delay', 0.5))
+ scanner_config['device'] = int(data.get('device', 0))
+ scanner_config['gain'] = int(data.get('gain', 40))
+ scanner_config['bias_t'] = bool(data.get('bias_t', False))
+ scanner_config['sdr_type'] = str(data.get('sdr_type', 'rtlsdr')).lower()
+ scanner_config['scan_method'] = str(data.get('scan_method', '')).lower().strip()
+ if data.get('snr_threshold') is not None:
+ scanner_config['snr_threshold'] = float(data.get('snr_threshold'))
+ except (ValueError, TypeError) as e:
+ return jsonify({
+ 'status': 'error',
+ 'message': f'Invalid parameter: {e}'
+ }), 400
# Validate
if scanner_config['start_freq'] >= scanner_config['end_freq']:
@@ -597,41 +897,97 @@ def start_scanner() -> Response:
'message': 'start_freq must be less than end_freq'
}), 400
- # Check tools based on SDR type
- sdr_type = scanner_config['sdr_type']
- if sdr_type == 'rtlsdr':
- if not find_rtl_fm():
- return jsonify({
- 'status': 'error',
- 'message': 'rtl_fm not found. Install rtl-sdr tools.'
- }), 503
- else:
- if not find_rx_fm():
- return jsonify({
- 'status': 'error',
- 'message': f'rx_fm not found. Install SoapySDR utilities for {sdr_type}.'
- }), 503
-
- # Start scanner thread
- scanner_running = True
- scanner_thread = threading.Thread(target=scanner_loop, daemon=True)
- scanner_thread.start()
-
- return jsonify({
- 'status': 'started',
- 'config': scanner_config
- })
+ # Decide scan method
+ if not scanner_config['scan_method']:
+ scanner_config['scan_method'] = 'power' if find_rtl_power() else 'classic'
+
+ sdr_type = scanner_config['sdr_type']
+
+ # Power scan only supports RTL-SDR for now
+ if scanner_config['scan_method'] == 'power':
+ if sdr_type != 'rtlsdr' or not find_rtl_power():
+ scanner_config['scan_method'] = 'classic'
+
+ # Check tools based on chosen method
+ if scanner_config['scan_method'] == 'power':
+ if not find_rtl_power():
+ return jsonify({
+ 'status': 'error',
+ 'message': 'rtl_power not found. Install rtl-sdr tools.'
+ }), 503
+ # Release listening device if active
+ if listening_active_device is not None:
+ app_module.release_sdr_device(listening_active_device)
+ listening_active_device = None
+ # Claim device for scanner
+ error = app_module.claim_sdr_device(scanner_config['device'], 'scanner')
+ if error:
+ return jsonify({
+ 'status': 'error',
+ 'error_type': 'DEVICE_BUSY',
+ 'message': error
+ }), 409
+ scanner_active_device = scanner_config['device']
+ scanner_running = True
+ scanner_thread = threading.Thread(target=scanner_loop_power, daemon=True)
+ scanner_thread.start()
+ else:
+ if sdr_type == 'rtlsdr':
+ if not find_rtl_fm():
+ return jsonify({
+ 'status': 'error',
+ 'message': 'rtl_fm not found. Install rtl-sdr tools.'
+ }), 503
+ else:
+ if not find_rx_fm():
+ return jsonify({
+ 'status': 'error',
+ 'message': f'rx_fm not found. Install SoapySDR utilities for {sdr_type}.'
+ }), 503
+ if listening_active_device is not None:
+ app_module.release_sdr_device(listening_active_device)
+ listening_active_device = None
+ error = app_module.claim_sdr_device(scanner_config['device'], 'scanner')
+ if error:
+ return jsonify({
+ 'status': 'error',
+ 'error_type': 'DEVICE_BUSY',
+ 'message': error
+ }), 409
+ scanner_active_device = scanner_config['device']
+
+ scanner_running = True
+ scanner_thread = threading.Thread(target=scanner_loop, daemon=True)
+ scanner_thread.start()
+
+ return jsonify({
+ 'status': 'started',
+ 'config': scanner_config
+ })
@listening_post_bp.route('/scanner/stop', methods=['POST'])
-def stop_scanner() -> Response:
- """Stop the frequency scanner."""
- global scanner_running
-
- scanner_running = False
- _stop_audio_stream()
-
- return jsonify({'status': 'stopped'})
+def stop_scanner() -> Response:
+ """Stop the frequency scanner."""
+ global scanner_running, scanner_active_device, scanner_power_process
+
+ scanner_running = False
+ _stop_audio_stream()
+ if scanner_power_process and scanner_power_process.poll() is None:
+ try:
+ scanner_power_process.terminate()
+ scanner_power_process.wait(timeout=1)
+ except Exception:
+ try:
+ scanner_power_process.kill()
+ except Exception:
+ pass
+ scanner_power_process = None
+ if scanner_active_device is not None:
+ app_module.release_sdr_device(scanner_active_device)
+ scanner_active_device = None
+
+ return jsonify({'status': 'stopped'})
@listening_post_bp.route('/scanner/pause', methods=['POST'])
@@ -788,14 +1144,36 @@ def get_presets() -> Response:
# ============================================
@listening_post_bp.route('/audio/start', methods=['POST'])
-def start_audio() -> Response:
- """Start audio at specific frequency (manual mode)."""
- global scanner_running
+def start_audio() -> Response:
+ """Start audio at specific frequency (manual mode)."""
+ global scanner_running, scanner_active_device, listening_active_device, scanner_power_process, scanner_thread
- # Stop scanner if running
- if scanner_running:
- scanner_running = False
- time.sleep(0.5)
+ # Stop scanner if running
+ if scanner_running:
+ scanner_running = False
+ if scanner_active_device is not None:
+ app_module.release_sdr_device(scanner_active_device)
+ scanner_active_device = None
+ if scanner_thread and scanner_thread.is_alive():
+ try:
+ scanner_thread.join(timeout=2.0)
+ except Exception:
+ pass
+ if scanner_power_process and scanner_power_process.poll() is None:
+ try:
+ scanner_power_process.terminate()
+ scanner_power_process.wait(timeout=1)
+ except Exception:
+ try:
+ scanner_power_process.kill()
+ except Exception:
+ pass
+ scanner_power_process = None
+ try:
+ subprocess.run(['pkill', '-9', 'rtl_power'], capture_output=True, timeout=0.5)
+ except Exception:
+ pass
+ time.sleep(0.5)
data = request.json or {}
@@ -832,11 +1210,24 @@ def start_audio() -> Response:
'message': f'Invalid sdr_type. Use: {", ".join(valid_sdr_types)}'
}), 400
- # Update config for audio
- scanner_config['squelch'] = squelch
- scanner_config['gain'] = gain
- scanner_config['device'] = device
- scanner_config['sdr_type'] = sdr_type
+ # Update config for audio
+ scanner_config['squelch'] = squelch
+ scanner_config['gain'] = gain
+ scanner_config['device'] = device
+ scanner_config['sdr_type'] = sdr_type
+
+ # Claim device for listening audio
+ if listening_active_device is None or listening_active_device != device:
+ if listening_active_device is not None:
+ app_module.release_sdr_device(listening_active_device)
+ error = app_module.claim_sdr_device(device, 'listening')
+ if error:
+ return jsonify({
+ 'status': 'error',
+ 'error_type': 'DEVICE_BUSY',
+ 'message': error
+ }), 409
+ listening_active_device = device
_start_audio_stream(frequency, modulation)
@@ -854,26 +1245,92 @@ def start_audio() -> Response:
@listening_post_bp.route('/audio/stop', methods=['POST'])
-def stop_audio() -> Response:
- """Stop audio."""
- _stop_audio_stream()
- return jsonify({'status': 'stopped'})
+def stop_audio() -> Response:
+ """Stop audio."""
+ global listening_active_device
+ _stop_audio_stream()
+ if listening_active_device is not None:
+ app_module.release_sdr_device(listening_active_device)
+ listening_active_device = None
+ return jsonify({'status': 'stopped'})
-@listening_post_bp.route('/audio/status')
-def audio_status() -> Response:
- """Get audio status."""
- return jsonify({
- 'running': audio_running,
- 'frequency': audio_frequency,
- 'modulation': audio_modulation
- })
+@listening_post_bp.route('/audio/status')
+def audio_status() -> Response:
+ """Get audio status."""
+ return jsonify({
+ 'running': audio_running,
+ 'frequency': audio_frequency,
+ 'modulation': audio_modulation
+ })
+
+
+@listening_post_bp.route('/audio/debug')
+def audio_debug() -> Response:
+ """Get audio debug status and recent stderr logs."""
+ rtl_log_path = '/tmp/rtl_fm_stderr.log'
+ ffmpeg_log_path = '/tmp/ffmpeg_stderr.log'
+ sample_path = '/tmp/audio_probe.bin'
+
+ def _read_log(path: str) -> str:
+ try:
+ with open(path, 'r') as handle:
+ return handle.read().strip()
+ except Exception:
+ return ''
+
+ return jsonify({
+ 'running': audio_running,
+ 'frequency': audio_frequency,
+ 'modulation': audio_modulation,
+ 'sdr_type': scanner_config.get('sdr_type', 'rtlsdr'),
+ 'device': scanner_config.get('device', 0),
+ 'gain': scanner_config.get('gain', 0),
+ 'squelch': scanner_config.get('squelch', 0),
+ 'audio_process_alive': bool(audio_process and audio_process.poll() is None),
+ 'rtl_fm_stderr': _read_log(rtl_log_path),
+ 'ffmpeg_stderr': _read_log(ffmpeg_log_path),
+ 'audio_probe_bytes': os.path.getsize(sample_path) if os.path.exists(sample_path) else 0,
+ })
+
+
+@listening_post_bp.route('/audio/probe')
+def audio_probe() -> Response:
+ """Grab a small chunk of audio bytes from the pipeline for debugging."""
+ global audio_process
+
+ if not audio_process or not audio_process.stdout:
+ return jsonify({'status': 'error', 'message': 'audio process not running'}), 400
+
+ sample_path = '/tmp/audio_probe.bin'
+ size = 0
+ try:
+ ready, _, _ = select.select([audio_process.stdout], [], [], 2.0)
+ if not ready:
+ return jsonify({'status': 'error', 'message': 'no data available'}), 504
+ data = audio_process.stdout.read(4096)
+ if not data:
+ return jsonify({'status': 'error', 'message': 'no data read'}), 504
+ with open(sample_path, 'wb') as handle:
+ handle.write(data)
+ size = len(data)
+ except Exception as e:
+ return jsonify({'status': 'error', 'message': str(e)}), 500
+
+ return jsonify({'status': 'ok', 'bytes': size})
-@listening_post_bp.route('/audio/stream')
-def stream_audio() -> Response:
- """Stream MP3 audio."""
- # Wait for audio to be ready (up to 2 seconds for modulation/squelch changes)
+@listening_post_bp.route('/audio/stream')
+def stream_audio() -> Response:
+ """Stream WAV audio."""
+ # Optionally restart pipeline so the stream starts with a fresh header
+ if request.args.get('fresh') == '1' and audio_running:
+ try:
+ _start_audio_stream(audio_frequency or 0.0, audio_modulation or 'fm')
+ except Exception as e:
+ logger.error(f"Audio stream restart failed: {e}")
+
+ # Wait for audio to be ready (up to 2 seconds for modulation/squelch changes)
for _ in range(40):
if audio_running and audio_process:
break
@@ -882,37 +1339,43 @@ def stream_audio() -> Response:
if not audio_running or not audio_process:
return Response(b'', mimetype='audio/mpeg', status=204)
- def generate():
- # Capture local reference to avoid race condition with stop
- proc = audio_process
- if not proc or not proc.stdout:
- return
- try:
- while audio_running and proc.poll() is None:
- # Use select to avoid blocking forever
- ready, _, _ = select.select([proc.stdout], [], [], 2.0)
- if ready:
- chunk = proc.stdout.read(4096)
- if chunk:
- yield chunk
- else:
- break
- else:
- # Timeout - check if process died
- if proc.poll() is not None:
- break
- except GeneratorExit:
- pass
- except Exception as e:
- logger.error(f"Audio stream error: {e}")
+ def generate():
+ # Capture local reference to avoid race condition with stop
+ proc = audio_process
+ if not proc or not proc.stdout:
+ return
+ try:
+ # First byte timeout to avoid hanging clients forever
+ first_chunk_deadline = time.time() + 3.0
+ while audio_running and proc.poll() is None:
+ # Use select to avoid blocking forever
+ ready, _, _ = select.select([proc.stdout], [], [], 2.0)
+ if ready:
+ chunk = proc.stdout.read(4096)
+ if chunk:
+ yield chunk
+ else:
+ break
+ else:
+ # If no data arrives shortly after start, exit so caller can retry
+ if time.time() > first_chunk_deadline:
+ logger.warning("Audio stream timed out waiting for first chunk")
+ break
+ # Timeout - check if process died
+ if proc.poll() is not None:
+ break
+ except GeneratorExit:
+ pass
+ except Exception as e:
+ logger.error(f"Audio stream error: {e}")
- return Response(
- generate(),
- mimetype='audio/mpeg',
- headers={
- 'Content-Type': 'audio/mpeg',
- 'Cache-Control': 'no-cache, no-store',
- 'X-Accel-Buffering': 'no',
- 'Transfer-Encoding': 'chunked',
- }
- )
+ return Response(
+ generate(),
+ mimetype='audio/wav',
+ headers={
+ 'Content-Type': 'audio/wav',
+ 'Cache-Control': 'no-cache, no-store',
+ 'X-Accel-Buffering': 'no',
+ 'Transfer-Encoding': 'chunked',
+ }
+ )
diff --git a/static/css/adsb_dashboard.css b/static/css/adsb_dashboard.css
index 9afac6f..4531dca 100644
--- a/static/css/adsb_dashboard.css
+++ b/static/css/adsb_dashboard.css
@@ -5,6 +5,8 @@
}
:root {
+ --font-sans: 'JetBrains Mono', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+ --font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
--bg-dark: #0a0c10;
--bg-panel: #0f1218;
--bg-card: #151a23;
@@ -25,7 +27,7 @@
}
body {
- font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
+ font-family: var(--font-sans);
background: var(--bg-dark);
color: var(--text-primary);
min-height: 100vh;
@@ -94,7 +96,7 @@ body {
}
.logo {
- font-family: 'Inter', sans-serif;
+ font-family: var(--font-sans);
font-size: 16px;
font-weight: 700;
letter-spacing: 2px;
@@ -132,7 +134,7 @@ body {
display: flex;
gap: 20px;
align-items: center;
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 11px;
}
@@ -624,7 +626,7 @@ body {
}
.telemetry-value {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 12px;
color: var(--accent-cyan);
}
@@ -680,7 +682,7 @@ body {
}
.aircraft-icao {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 9px;
color: var(--text-secondary);
background: rgba(74, 158, 255, 0.1);
@@ -700,7 +702,7 @@ body {
}
.aircraft-detail-value {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
color: var(--accent-cyan);
font-size: 11px;
}
@@ -790,7 +792,7 @@ body {
border: 1px solid rgba(74, 158, 255, 0.3);
border-radius: 4px;
color: var(--accent-cyan);
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 10px;
}
@@ -801,7 +803,7 @@ body {
border: 1px solid rgba(74, 158, 255, 0.3);
border-radius: 4px;
color: var(--accent-cyan);
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 10px;
}
@@ -879,7 +881,7 @@ body {
border: none;
background: var(--accent-green);
color: #fff;
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
@@ -911,7 +913,7 @@ body {
border: 1px solid rgba(74, 158, 255, 0.3);
border-radius: 4px;
color: var(--accent-cyan);
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 10px;
cursor: pointer;
}
@@ -1023,7 +1025,7 @@ body {
cursor: pointer;
font-size: 11px;
font-weight: 600;
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
display: flex;
align-items: center;
gap: 5px;
@@ -1057,7 +1059,7 @@ body {
}
.airband-status {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 10px;
padding: 0 8px;
color: var(--text-muted);
@@ -1407,7 +1409,7 @@ body {
}
.strip-value {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 14px;
font-weight: 600;
color: var(--accent-cyan);
@@ -1545,7 +1547,7 @@ body {
.report-grid span:nth-child(even) {
color: var(--text-primary);
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
}
.report-highlights {
@@ -1784,7 +1786,7 @@ body {
font-size: 11px;
font-weight: 500;
color: var(--accent-cyan);
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
padding-left: 8px;
border-left: 1px solid rgba(74, 158, 255, 0.2);
white-space: nowrap;
@@ -1938,7 +1940,7 @@ body {
}
.squawk-code {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-weight: 700;
color: var(--accent-cyan);
font-size: 12px;
diff --git a/static/css/adsb_history.css b/static/css/adsb_history.css
index 387cc3f..3b42de9 100644
--- a/static/css/adsb_history.css
+++ b/static/css/adsb_history.css
@@ -5,6 +5,8 @@
}
:root {
+ --font-sans: 'JetBrains Mono', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+ --font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
--bg-dark: #0a0c10;
--bg-panel: #0f1218;
--bg-card: #141a24;
@@ -20,14 +22,14 @@
}
body {
- font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
+ font-family: var(--font-sans);
background: var(--bg-dark);
color: var(--text-primary);
min-height: 100vh;
}
.mono {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
}
.radar-bg {
@@ -91,7 +93,7 @@ body {
display: flex;
align-items: center;
gap: 12px;
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 11px;
}
@@ -268,7 +270,7 @@ body {
}
.status-pill {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 11px;
padding: 8px 12px;
border-radius: 999px;
@@ -306,7 +308,7 @@ body {
}
.panel-meta {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 11px;
color: var(--accent-cyan);
}
@@ -347,7 +349,7 @@ body {
}
.mono {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
}
.empty-row td,
diff --git a/static/css/agents.css b/static/css/agents.css
index 1d793f0..46706dd 100644
--- a/static/css/agents.css
+++ b/static/css/agents.css
@@ -1,343 +1,345 @@
-/*
- * Agents Management CSS
- * Styles for the remote agent management interface
- */
-
-/* CSS Variables (inherited from main theme) */
-:root {
- --bg-primary: #0a0a0f;
- --bg-secondary: #12121a;
- --text-primary: #e0e0e0;
- --text-secondary: #888;
- --border-color: #1a1a2e;
- --accent-cyan: #00d4ff;
- --accent-green: #00ff88;
- --accent-red: #ff3366;
- --accent-orange: #ff9f1c;
-}
-
-/* Agent indicator in navigation */
-.agent-indicator {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 6px 12px;
- background: rgba(0, 212, 255, 0.1);
- border: 1px solid rgba(0, 212, 255, 0.3);
- border-radius: 20px;
- cursor: pointer;
- transition: all 0.2s;
-}
-
-.agent-indicator:hover {
- background: rgba(0, 212, 255, 0.2);
- border-color: var(--accent-cyan);
-}
-
-.agent-indicator-dot {
- width: 8px;
- height: 8px;
- border-radius: 50%;
- background: var(--accent-green);
- box-shadow: 0 0 6px var(--accent-green);
-}
-
-.agent-indicator-dot.remote {
- background: var(--accent-cyan);
- box-shadow: 0 0 6px var(--accent-cyan);
-}
-
-.agent-indicator-dot.multiple {
- background: var(--accent-orange);
- box-shadow: 0 0 6px var(--accent-orange);
-}
-
-.agent-indicator-label {
- font-size: 11px;
- color: var(--text-primary);
- font-family: 'JetBrains Mono', monospace;
-}
-
-.agent-indicator-count {
- font-size: 10px;
- padding: 2px 6px;
- background: rgba(0, 212, 255, 0.2);
- border-radius: 10px;
- color: var(--accent-cyan);
-}
-
-/* Agent selector dropdown */
-.agent-selector {
- position: relative;
-}
-
-.agent-selector-dropdown {
- position: absolute;
- top: 100%;
- right: 0;
- margin-top: 8px;
- min-width: 280px;
- background: var(--bg-secondary);
- border: 1px solid var(--border-color);
- border-radius: 8px;
- box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
- z-index: 1000;
- display: none;
-}
-
-.agent-selector-dropdown.show {
- display: block;
-}
-
-.agent-selector-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 12px 15px;
- border-bottom: 1px solid var(--border-color);
-}
-
-.agent-selector-header h4 {
- margin: 0;
- font-size: 12px;
- color: var(--accent-cyan);
- text-transform: uppercase;
- letter-spacing: 1px;
-}
-
-.agent-selector-manage {
- font-size: 11px;
- color: var(--accent-cyan);
- text-decoration: none;
-}
-
-.agent-selector-manage:hover {
- text-decoration: underline;
-}
-
-.agent-selector-list {
- max-height: 300px;
- overflow-y: auto;
-}
-
-.agent-selector-item {
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 10px 15px;
- cursor: pointer;
- transition: background 0.2s;
- border-bottom: 1px solid var(--border-color);
-}
-
-.agent-selector-item:last-child {
- border-bottom: none;
-}
-
-.agent-selector-item:hover {
- background: rgba(0, 212, 255, 0.1);
-}
-
-.agent-selector-item.selected {
- background: rgba(0, 212, 255, 0.15);
- border-left: 3px solid var(--accent-cyan);
-}
-
-.agent-selector-item.local {
- border-left: 3px solid var(--accent-green);
-}
-
-.agent-selector-item-status {
- width: 8px;
- height: 8px;
- border-radius: 50%;
- flex-shrink: 0;
-}
-
-.agent-selector-item-status.online {
- background: var(--accent-green);
-}
-
-.agent-selector-item-status.offline {
- background: var(--accent-red);
-}
-
-.agent-selector-item-info {
- flex: 1;
- min-width: 0;
-}
-
-.agent-selector-item-name {
- font-size: 13px;
- color: var(--text-primary);
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.agent-selector-item-url {
- font-size: 10px;
- color: var(--text-secondary);
- font-family: 'JetBrains Mono', monospace;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.agent-selector-item-check {
- color: var(--accent-green);
- opacity: 0;
-}
-
-.agent-selector-item.selected .agent-selector-item-check {
- opacity: 1;
-}
-
-/* Agent badge in data displays */
-.agent-badge {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- padding: 2px 8px;
- font-size: 10px;
- background: rgba(0, 212, 255, 0.1);
- color: var(--accent-cyan);
- border-radius: 10px;
- font-family: 'JetBrains Mono', monospace;
-}
-
-.agent-badge.local,
-.agent-badge.agent-local {
- background: rgba(0, 255, 136, 0.1);
- color: var(--accent-green);
-}
-
-.agent-badge.agent-remote {
- background: rgba(0, 212, 255, 0.1);
- color: var(--accent-cyan);
-}
-
-/* WiFi table agent column */
-.wifi-networks-table .col-agent {
- width: 100px;
- text-align: center;
-}
-
-.wifi-networks-table th.col-agent {
- font-size: 10px;
-}
-
-/* Bluetooth table agent column */
-.bt-devices-table .col-agent {
- width: 100px;
- text-align: center;
-}
-
-.agent-badge-dot {
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: currentColor;
-}
-
-/* Agent column in data tables */
-.data-table .agent-col {
- width: 120px;
- max-width: 120px;
-}
-
-/* Multi-agent stream indicator */
-.multi-agent-indicator {
- position: fixed;
- bottom: 20px;
- left: 20px;
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 8px 12px;
- background: var(--bg-secondary);
- border: 1px solid var(--border-color);
- border-radius: 20px;
- font-size: 11px;
- color: var(--text-secondary);
- z-index: 100;
-}
-
-.multi-agent-indicator.active {
- border-color: var(--accent-cyan);
- color: var(--accent-cyan);
-}
-
-.multi-agent-indicator-pulse {
- width: 8px;
- height: 8px;
- border-radius: 50%;
- background: var(--accent-cyan);
- animation: pulse 2s infinite;
-}
-
-@keyframes pulse {
- 0%, 100% { opacity: 1; transform: scale(1); }
- 50% { opacity: 0.5; transform: scale(0.8); }
-}
-
-/* Agent connection status toast */
-.agent-toast {
- position: fixed;
- top: 80px;
- right: 20px;
- padding: 10px 15px;
- background: var(--bg-secondary);
- border: 1px solid var(--border-color);
- border-radius: 6px;
- font-size: 12px;
- z-index: 1001;
- animation: slideInRight 0.3s ease;
-}
-
-.agent-toast.connected {
- border-color: var(--accent-green);
- color: var(--accent-green);
-}
-
-.agent-toast.disconnected {
- border-color: var(--accent-red);
- color: var(--accent-red);
-}
-
-@keyframes slideInRight {
- from {
- transform: translateX(100%);
- opacity: 0;
- }
- to {
- transform: translateX(0);
- opacity: 1;
- }
-}
-
-/* Responsive adjustments */
-@media (max-width: 768px) {
- .agent-indicator {
- padding: 4px 8px;
- }
-
- .agent-indicator-label {
- display: none;
- }
-
- .agent-selector-dropdown {
- position: fixed;
- top: auto;
- bottom: 0;
- left: 0;
- right: 0;
- margin: 0;
- border-radius: 16px 16px 0 0;
- max-height: 60vh;
- }
-
- .agents-grid {
- grid-template-columns: 1fr;
- }
-}
+/*
+ * Agents Management CSS
+ * Styles for the remote agent management interface
+ */
+
+/* CSS Variables (inherited from main theme) */
+:root {
+ --font-sans: 'JetBrains Mono', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+ --font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
+ --bg-primary: #0a0a0f;
+ --bg-secondary: #12121a;
+ --text-primary: #e0e0e0;
+ --text-secondary: #888;
+ --border-color: #1a1a2e;
+ --accent-cyan: #00d4ff;
+ --accent-green: #00ff88;
+ --accent-red: #ff3366;
+ --accent-orange: #ff9f1c;
+}
+
+/* Agent indicator in navigation */
+.agent-indicator {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 12px;
+ background: rgba(0, 212, 255, 0.1);
+ border: 1px solid rgba(0, 212, 255, 0.3);
+ border-radius: 20px;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.agent-indicator:hover {
+ background: rgba(0, 212, 255, 0.2);
+ border-color: var(--accent-cyan);
+}
+
+.agent-indicator-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--accent-green);
+ box-shadow: 0 0 6px var(--accent-green);
+}
+
+.agent-indicator-dot.remote {
+ background: var(--accent-cyan);
+ box-shadow: 0 0 6px var(--accent-cyan);
+}
+
+.agent-indicator-dot.multiple {
+ background: var(--accent-orange);
+ box-shadow: 0 0 6px var(--accent-orange);
+}
+
+.agent-indicator-label {
+ font-size: 11px;
+ color: var(--text-primary);
+ font-family: var(--font-mono);
+}
+
+.agent-indicator-count {
+ font-size: 10px;
+ padding: 2px 6px;
+ background: rgba(0, 212, 255, 0.2);
+ border-radius: 10px;
+ color: var(--accent-cyan);
+}
+
+/* Agent selector dropdown */
+.agent-selector {
+ position: relative;
+}
+
+.agent-selector-dropdown {
+ position: absolute;
+ top: 100%;
+ right: 0;
+ margin-top: 8px;
+ min-width: 280px;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
+ z-index: 1000;
+ display: none;
+}
+
+.agent-selector-dropdown.show {
+ display: block;
+}
+
+.agent-selector-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 12px 15px;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.agent-selector-header h4 {
+ margin: 0;
+ font-size: 12px;
+ color: var(--accent-cyan);
+ text-transform: uppercase;
+ letter-spacing: 1px;
+}
+
+.agent-selector-manage {
+ font-size: 11px;
+ color: var(--accent-cyan);
+ text-decoration: none;
+}
+
+.agent-selector-manage:hover {
+ text-decoration: underline;
+}
+
+.agent-selector-list {
+ max-height: 300px;
+ overflow-y: auto;
+}
+
+.agent-selector-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 15px;
+ cursor: pointer;
+ transition: background 0.2s;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.agent-selector-item:last-child {
+ border-bottom: none;
+}
+
+.agent-selector-item:hover {
+ background: rgba(0, 212, 255, 0.1);
+}
+
+.agent-selector-item.selected {
+ background: rgba(0, 212, 255, 0.15);
+ border-left: 3px solid var(--accent-cyan);
+}
+
+.agent-selector-item.local {
+ border-left: 3px solid var(--accent-green);
+}
+
+.agent-selector-item-status {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.agent-selector-item-status.online {
+ background: var(--accent-green);
+}
+
+.agent-selector-item-status.offline {
+ background: var(--accent-red);
+}
+
+.agent-selector-item-info {
+ flex: 1;
+ min-width: 0;
+}
+
+.agent-selector-item-name {
+ font-size: 13px;
+ color: var(--text-primary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.agent-selector-item-url {
+ font-size: 10px;
+ color: var(--text-secondary);
+ font-family: var(--font-mono);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.agent-selector-item-check {
+ color: var(--accent-green);
+ opacity: 0;
+}
+
+.agent-selector-item.selected .agent-selector-item-check {
+ opacity: 1;
+}
+
+/* Agent badge in data displays */
+.agent-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 2px 8px;
+ font-size: 10px;
+ background: rgba(0, 212, 255, 0.1);
+ color: var(--accent-cyan);
+ border-radius: 10px;
+ font-family: var(--font-mono);
+}
+
+.agent-badge.local,
+.agent-badge.agent-local {
+ background: rgba(0, 255, 136, 0.1);
+ color: var(--accent-green);
+}
+
+.agent-badge.agent-remote {
+ background: rgba(0, 212, 255, 0.1);
+ color: var(--accent-cyan);
+}
+
+/* WiFi table agent column */
+.wifi-networks-table .col-agent {
+ width: 100px;
+ text-align: center;
+}
+
+.wifi-networks-table th.col-agent {
+ font-size: 10px;
+}
+
+/* Bluetooth table agent column */
+.bt-devices-table .col-agent {
+ width: 100px;
+ text-align: center;
+}
+
+.agent-badge-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: currentColor;
+}
+
+/* Agent column in data tables */
+.data-table .agent-col {
+ width: 120px;
+ max-width: 120px;
+}
+
+/* Multi-agent stream indicator */
+.multi-agent-indicator {
+ position: fixed;
+ bottom: 20px;
+ left: 20px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 12px;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: 20px;
+ font-size: 11px;
+ color: var(--text-secondary);
+ z-index: 100;
+}
+
+.multi-agent-indicator.active {
+ border-color: var(--accent-cyan);
+ color: var(--accent-cyan);
+}
+
+.multi-agent-indicator-pulse {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--accent-cyan);
+ animation: pulse 2s infinite;
+}
+
+@keyframes pulse {
+ 0%, 100% { opacity: 1; transform: scale(1); }
+ 50% { opacity: 0.5; transform: scale(0.8); }
+}
+
+/* Agent connection status toast */
+.agent-toast {
+ position: fixed;
+ top: 80px;
+ right: 20px;
+ padding: 10px 15px;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ font-size: 12px;
+ z-index: 1001;
+ animation: slideInRight 0.3s ease;
+}
+
+.agent-toast.connected {
+ border-color: var(--accent-green);
+ color: var(--accent-green);
+}
+
+.agent-toast.disconnected {
+ border-color: var(--accent-red);
+ color: var(--accent-red);
+}
+
+@keyframes slideInRight {
+ from {
+ transform: translateX(100%);
+ opacity: 0;
+ }
+ to {
+ transform: translateX(0);
+ opacity: 1;
+ }
+}
+
+/* Responsive adjustments */
+@media (max-width: 768px) {
+ .agent-indicator {
+ padding: 4px 8px;
+ }
+
+ .agent-indicator-label {
+ display: none;
+ }
+
+ .agent-selector-dropdown {
+ position: fixed;
+ top: auto;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ margin: 0;
+ border-radius: 16px 16px 0 0;
+ max-height: 60vh;
+ }
+
+ .agents-grid {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/static/css/ais_dashboard.css b/static/css/ais_dashboard.css
index 3d4d268..149b2e3 100644
--- a/static/css/ais_dashboard.css
+++ b/static/css/ais_dashboard.css
@@ -8,6 +8,8 @@
}
:root {
+ --font-sans: 'JetBrains Mono', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+ --font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
--bg-dark: #0a0c10;
--bg-panel: #0f1218;
--bg-card: #151a23;
@@ -28,7 +30,7 @@
}
body {
- font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
+ font-family: var(--font-sans);
background: var(--bg-dark);
color: var(--text-primary);
min-height: 100vh;
@@ -97,7 +99,7 @@ body {
}
.logo {
- font-family: 'Inter', sans-serif;
+ font-family: var(--font-sans);
font-size: 16px;
font-weight: 700;
letter-spacing: 2px;
@@ -134,7 +136,7 @@ body {
display: flex;
gap: 20px;
align-items: center;
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 11px;
}
@@ -183,7 +185,7 @@ body {
}
.strip-value {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 14px;
font-weight: 600;
color: var(--accent-cyan);
@@ -287,7 +289,7 @@ body {
font-size: 11px;
font-weight: 500;
color: var(--accent-cyan);
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
padding-left: 8px;
border-left: 1px solid rgba(74, 158, 255, 0.2);
white-space: nowrap;
@@ -367,7 +369,7 @@ body {
/* Leaflet overrides - Dark map styling */
.leaflet-container {
background: var(--bg-dark) !important;
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
}
/* Using actual dark tiles now - no filter needed */
@@ -518,7 +520,7 @@ body {
}
.vessel-mmsi {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 10px;
color: var(--text-secondary);
background: rgba(74, 158, 255, 0.1);
@@ -548,7 +550,7 @@ body {
}
.detail-value {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 12px;
color: var(--accent-cyan);
}
@@ -611,13 +613,13 @@ body {
}
.vessel-item-type {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 9px;
color: var(--text-secondary);
}
.vessel-item-speed {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 11px;
color: var(--accent-cyan);
text-align: right;
@@ -687,7 +689,7 @@ body {
border: 1px solid rgba(74, 158, 255, 0.3);
border-radius: 4px;
color: var(--accent-cyan);
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 10px;
}
@@ -698,7 +700,7 @@ body {
border: 1px solid rgba(74, 158, 255, 0.3);
border-radius: 4px;
color: var(--accent-cyan);
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 10px;
}
@@ -717,7 +719,7 @@ body {
border: none;
background: var(--accent-green);
color: #fff;
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
@@ -1004,7 +1006,7 @@ body {
padding: 6px 12px;
background: rgba(0, 0, 0, 0.2);
border-bottom: 1px solid rgba(245, 158, 11, 0.1);
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 9px;
}
@@ -1079,7 +1081,7 @@ body {
}
.dsc-message-category {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
@@ -1096,13 +1098,13 @@ body {
}
.dsc-message-time {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 9px;
color: var(--text-dim);
}
.dsc-message-mmsi {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 11px;
color: var(--accent-orange);
}
@@ -1120,7 +1122,7 @@ body {
}
.dsc-message-pos {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 9px;
color: var(--text-secondary);
}
@@ -1157,7 +1159,7 @@ body {
}
.dsc-distress-alert .dsc-alert-mmsi {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 16px;
color: var(--accent-cyan);
margin-bottom: 8px;
@@ -1177,7 +1179,7 @@ body {
}
.dsc-distress-alert .dsc-alert-position {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 14px;
color: var(--accent-cyan);
margin-bottom: 16px;
@@ -1188,7 +1190,7 @@ body {
border: none;
color: white;
padding: 10px 24px;
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
diff --git a/static/css/components/activity-timeline.css b/static/css/components/activity-timeline.css
index fa1bf4c..62443df 100644
--- a/static/css/components/activity-timeline.css
+++ b/static/css/components/activity-timeline.css
@@ -1,696 +1,696 @@
-/**
- * Activity Timeline Component
- * Reusable, configuration-driven timeline visualization
- * Supports visual modes: compact, enriched, summary
- */
-
-/* ============================================
- CSS VARIABLES (with fallbacks)
- ============================================ */
-.activity-timeline {
- --timeline-bg: var(--bg-card, #1a1a1a);
- --timeline-border: var(--border-color, #333);
- --timeline-bg-secondary: var(--bg-secondary, #252525);
- --timeline-bg-elevated: var(--bg-elevated, #2a2a2a);
- --timeline-text-primary: var(--text-primary, #fff);
- --timeline-text-secondary: var(--text-secondary, #888);
- --timeline-text-dim: var(--text-dim, #666);
- --timeline-accent: var(--accent-cyan, #4a9eff);
- --timeline-status-new: var(--signal-new, #3b82f6);
- --timeline-status-baseline: var(--signal-baseline, #6b7280);
- --timeline-status-burst: var(--signal-burst, #f59e0b);
- --timeline-status-flagged: var(--signal-emergency, #ef4444);
- --timeline-status-gone: var(--text-dim, #666);
-}
-
-/* ============================================
- TIMELINE CONTAINER
- ============================================ */
-.activity-timeline {
- background: var(--timeline-bg);
- border: 1px solid var(--timeline-border);
- border-radius: 6px;
- font-family: 'JetBrains Mono', 'SF Mono', 'Consolas', monospace;
- font-size: 11px;
-}
-
-.activity-timeline.collapsed .activity-timeline-body {
- display: none;
-}
-
-.activity-timeline.collapsed .activity-timeline-header {
- border-bottom: none;
- margin-bottom: 0;
- padding-bottom: 10px;
-}
-
-.activity-timeline.collapsed .activity-timeline-collapse-icon {
- transform: rotate(-90deg);
-}
-
-/* ============================================
- HEADER
- ============================================ */
-.activity-timeline-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 10px 12px;
- cursor: pointer;
- user-select: none;
- transition: background 0.15s ease;
-}
-
-.activity-timeline-header:hover {
- background: rgba(255, 255, 255, 0.02);
-}
-
-.activity-timeline-collapse-icon {
- margin-right: 8px;
- font-size: 10px;
- transition: transform 0.2s ease;
- color: var(--timeline-text-dim);
-}
-
-.activity-timeline-title {
- font-size: 11px;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.05em;
- color: var(--timeline-text-secondary);
-}
-
-.activity-timeline-header-stats {
- display: flex;
- gap: 12px;
- font-size: 10px;
- color: var(--timeline-text-dim);
-}
-
-.activity-timeline-header-stat {
- display: flex;
- align-items: center;
- gap: 4px;
-}
-
-.activity-timeline-header-stat .stat-value {
- color: var(--timeline-text-primary);
- font-weight: 500;
-}
-
-/* ============================================
- BODY
- ============================================ */
-.activity-timeline-body {
- padding: 0 12px 12px 12px;
- border-top: 1px solid var(--timeline-border);
-}
-
-/* ============================================
- CONTROLS
- ============================================ */
-.activity-timeline-controls {
- display: flex;
- gap: 6px;
- align-items: center;
- padding: 8px 0;
- flex-wrap: wrap;
-}
-
-.activity-timeline-btn {
- background: var(--timeline-bg-secondary);
- border: 1px solid var(--timeline-border);
- color: var(--timeline-text-secondary);
- font-size: 9px;
- padding: 4px 8px;
- border-radius: 3px;
- cursor: pointer;
- transition: all 0.15s ease;
- font-family: inherit;
-}
-
-.activity-timeline-btn:hover {
- background: var(--timeline-bg-elevated);
- color: var(--timeline-text-primary);
-}
-
-.activity-timeline-btn.active {
- background: var(--timeline-accent);
- color: #000;
- border-color: var(--timeline-accent);
-}
-
-.activity-timeline-window {
- display: flex;
- align-items: center;
- gap: 4px;
- font-size: 9px;
- color: var(--timeline-text-dim);
- margin-left: auto;
-}
-
-.activity-timeline-window-select {
- background: var(--timeline-bg-secondary);
- border: 1px solid var(--timeline-border);
- color: var(--timeline-text-primary);
- font-size: 9px;
- padding: 3px 6px;
- border-radius: 3px;
- font-family: inherit;
-}
-
-/* ============================================
- TIME AXIS
- ============================================ */
-.activity-timeline-axis {
- display: flex;
- justify-content: space-between;
- padding: 0 50px 0 140px;
- margin-bottom: 6px;
- font-size: 9px;
- color: var(--timeline-text-dim);
-}
-
-.activity-timeline-axis-label {
- position: relative;
-}
-
-.activity-timeline-axis-label::before {
- content: '';
- position: absolute;
- top: -4px;
- left: 50%;
- width: 1px;
- height: 4px;
- background: var(--timeline-border);
-}
-
-/* ============================================
- LANES CONTAINER
- ============================================ */
-.activity-timeline-lanes {
- display: flex;
- flex-direction: column;
- gap: 3px;
- max-height: 180px;
- overflow-y: auto;
- margin-top: 6px;
-}
-
-.activity-timeline-lanes::-webkit-scrollbar {
- width: 6px;
-}
-
-.activity-timeline-lanes::-webkit-scrollbar-track {
- background: var(--timeline-bg-secondary);
- border-radius: 3px;
-}
-
-.activity-timeline-lanes::-webkit-scrollbar-thumb {
- background: var(--timeline-border);
- border-radius: 3px;
-}
-
-.activity-timeline-lanes::-webkit-scrollbar-thumb:hover {
- background: var(--timeline-text-dim);
-}
-
-/* ============================================
- INDIVIDUAL LANE
- ============================================ */
-.activity-timeline-lane {
- display: flex;
- align-items: stretch;
- min-height: 32px;
- background: var(--timeline-bg-secondary);
- border-radius: 3px;
- overflow: hidden;
- cursor: pointer;
- transition: background 0.15s ease;
-}
-
-.activity-timeline-lane:hover {
- background: var(--timeline-bg-elevated);
-}
-
-.activity-timeline-lane.expanded {
- min-height: auto;
-}
-
-.activity-timeline-lane.baseline {
- opacity: 0.5;
-}
-
-.activity-timeline-lane.baseline:hover {
- opacity: 0.8;
-}
-
-/* Status indicator strip */
-.activity-timeline-status {
- width: 4px;
- min-width: 4px;
- flex-shrink: 0;
-}
-
-.activity-timeline-status[data-status="new"] {
- background: var(--timeline-status-new);
-}
-
-.activity-timeline-status[data-status="baseline"] {
- background: var(--timeline-status-baseline);
-}
-
-.activity-timeline-status[data-status="burst"] {
- background: var(--timeline-status-burst);
-}
-
-.activity-timeline-status[data-status="flagged"] {
- background: var(--timeline-status-flagged);
-}
-
-.activity-timeline-status[data-status="gone"] {
- background: var(--timeline-status-gone);
-}
-
-/* Label section */
-.activity-timeline-label {
- width: 130px;
- min-width: 130px;
- padding: 6px 8px;
- display: flex;
- flex-direction: column;
- justify-content: center;
- gap: 1px;
- border-right: 1px solid var(--timeline-border);
- overflow: hidden;
-}
-
-.activity-timeline-id {
- color: var(--timeline-text-primary);
- font-size: 11px;
- font-weight: 500;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- line-height: 1.2;
-}
-
-.activity-timeline-name {
- color: var(--timeline-text-dim);
- font-size: 9px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- line-height: 1.2;
-}
-
-/* ============================================
- TRACK (where bars are drawn)
- ============================================ */
-.activity-timeline-track {
- flex: 1;
- position: relative;
- height: 100%;
- min-height: 32px;
- padding: 4px 8px;
-}
-
-.activity-timeline-track-bg {
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- display: flex;
- align-items: center;
-}
-
-/* ============================================
- SIGNAL BARS
- ============================================ */
-.activity-timeline-bar {
- position: absolute;
- top: 50%;
- transform: translateY(-50%);
- height: 14px;
- min-width: 2px;
- border-radius: 2px;
- transition: opacity 0.15s ease;
-}
-
-/* Strength variants */
-.activity-timeline-bar[data-strength="1"] { height: 5px; }
-.activity-timeline-bar[data-strength="2"] { height: 9px; }
-.activity-timeline-bar[data-strength="3"] { height: 13px; }
-.activity-timeline-bar[data-strength="4"] { height: 17px; }
-.activity-timeline-bar[data-strength="5"] { height: 21px; }
-
-/* Status colors */
-.activity-timeline-bar[data-status="new"],
-.activity-timeline-bar[data-status="repeated"] {
- background: var(--timeline-status-new);
- box-shadow: 0 0 4px rgba(59, 130, 246, 0.3);
-}
-
-.activity-timeline-bar[data-status="baseline"] {
- background: var(--timeline-status-baseline);
-}
-
-.activity-timeline-bar[data-status="burst"] {
- background: var(--timeline-status-burst);
- box-shadow: 0 0 5px rgba(245, 158, 11, 0.4);
-}
-
-.activity-timeline-bar[data-status="flagged"] {
- background: var(--timeline-status-flagged);
- box-shadow: 0 0 6px rgba(239, 68, 68, 0.5);
- animation: timeline-flagged-pulse 2s ease-in-out infinite;
-}
-
-@keyframes timeline-flagged-pulse {
- 0%, 100% { opacity: 1; }
- 50% { opacity: 0.7; }
-}
-
-.activity-timeline-lane:hover .activity-timeline-bar {
- opacity: 0.9;
-}
-
-/* ============================================
- EXPANDED VIEW (tick marks)
- ============================================ */
-.activity-timeline-ticks {
- display: none;
- position: relative;
- height: 24px;
- margin-top: 4px;
- border-top: 1px solid var(--timeline-border);
- padding-top: 4px;
-}
-
-.activity-timeline-lane.expanded .activity-timeline-ticks {
- display: block;
-}
-
-.activity-timeline-tick {
- position: absolute;
- bottom: 0;
- width: 1px;
- background: var(--timeline-accent);
-}
-
-.activity-timeline-tick[data-strength="1"] { height: 4px; }
-.activity-timeline-tick[data-strength="2"] { height: 8px; }
-.activity-timeline-tick[data-strength="3"] { height: 12px; }
-.activity-timeline-tick[data-strength="4"] { height: 16px; }
-.activity-timeline-tick[data-strength="5"] { height: 20px; }
-
-/* ============================================
- STATS COLUMN
- ============================================ */
-.activity-timeline-stats {
- width: 45px;
- min-width: 45px;
- padding: 4px 6px;
- display: flex;
- flex-direction: column;
- justify-content: center;
- align-items: flex-end;
- font-size: 9px;
- color: var(--timeline-text-dim);
- border-left: 1px solid var(--timeline-border);
-}
-
-.activity-timeline-stat-count {
- color: var(--timeline-text-primary);
- font-weight: 500;
-}
-
-.activity-timeline-stat-label {
- font-size: 8px;
- text-transform: uppercase;
- letter-spacing: 0.03em;
-}
-
-/* ============================================
- ANNOTATIONS
- ============================================ */
-.activity-timeline-annotations {
- margin-top: 6px;
- padding-top: 6px;
- border-top: 1px solid var(--timeline-border);
- max-height: 80px;
- overflow-y: auto;
-}
-
-.activity-timeline-annotation {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 4px 8px;
- font-size: 10px;
- color: var(--timeline-text-secondary);
- background: var(--timeline-bg-secondary);
- border-radius: 3px;
- margin-bottom: 4px;
-}
-
-.activity-timeline-annotation-icon {
- font-size: 10px;
- width: 14px;
- text-align: center;
-}
-
-.activity-timeline-annotation[data-type="new"] {
- border-left: 2px solid var(--timeline-status-new);
-}
-
-.activity-timeline-annotation[data-type="burst"] {
- border-left: 2px solid var(--timeline-status-burst);
-}
-
-.activity-timeline-annotation[data-type="pattern"] {
- border-left: 2px solid var(--timeline-accent);
-}
-
-.activity-timeline-annotation[data-type="flagged"] {
- border-left: 2px solid var(--timeline-status-flagged);
- color: var(--timeline-status-flagged);
-}
-
-.activity-timeline-annotation[data-type="gone"] {
- border-left: 2px solid var(--timeline-status-gone);
-}
-
-/* ============================================
- TOOLTIP
- ============================================ */
-.activity-timeline-tooltip {
- position: fixed;
- z-index: 10000;
- background: var(--timeline-bg-elevated);
- border: 1px solid var(--timeline-border);
- border-radius: 4px;
- padding: 8px 10px;
- font-size: 10px;
- color: var(--timeline-text-primary);
- pointer-events: none;
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
- max-width: 240px;
- font-family: 'JetBrains Mono', monospace;
-}
-
-.activity-timeline-tooltip-header {
- font-weight: 600;
- margin-bottom: 4px;
- color: var(--timeline-accent);
-}
-
-.activity-timeline-tooltip-row {
- display: flex;
- justify-content: space-between;
- gap: 12px;
- color: var(--timeline-text-secondary);
- line-height: 1.5;
-}
-
-.activity-timeline-tooltip-row span:last-child {
- color: var(--timeline-text-primary);
-}
-
-/* ============================================
- LEGEND
- ============================================ */
-.activity-timeline-legend {
- display: flex;
- gap: 12px;
- padding-top: 8px;
- margin-top: 8px;
- border-top: 1px solid var(--timeline-border);
- font-size: 9px;
- color: var(--timeline-text-dim);
-}
-
-.activity-timeline-legend-item {
- display: flex;
- align-items: center;
- gap: 4px;
-}
-
-.activity-timeline-legend-dot {
- width: 6px;
- height: 6px;
- border-radius: 2px;
-}
-
-.activity-timeline-legend-dot.new { background: var(--timeline-status-new); }
-.activity-timeline-legend-dot.baseline { background: var(--timeline-status-baseline); }
-.activity-timeline-legend-dot.burst { background: var(--timeline-status-burst); }
-.activity-timeline-legend-dot.flagged { background: var(--timeline-status-flagged); }
-
-/* ============================================
- EMPTY STATE
- ============================================ */
-.activity-timeline-empty {
- text-align: center;
- padding: 24px 16px;
- color: var(--timeline-text-dim);
- font-size: 11px;
-}
-
-.activity-timeline-empty-icon {
- font-size: 20px;
- margin-bottom: 8px;
- opacity: 0.4;
-}
-
-/* More indicator */
-.activity-timeline-more {
- text-align: center;
- padding: 8px;
- font-size: 10px;
- color: var(--timeline-text-dim);
-}
-
-/* ============================================
- VISUAL MODE: COMPACT
- ============================================ */
-.activity-timeline--compact .activity-timeline-lanes {
- max-height: 140px;
-}
-
-.activity-timeline--compact .activity-timeline-lane {
- min-height: 26px;
-}
-
-.activity-timeline--compact .activity-timeline-label {
- width: 100px;
- min-width: 100px;
- padding: 4px 6px;
-}
-
-.activity-timeline--compact .activity-timeline-id {
- display: none;
-}
-
-.activity-timeline--compact .activity-timeline-name {
- font-size: 10px;
- color: var(--timeline-text-secondary);
-}
-
-.activity-timeline--compact .activity-timeline-track {
- min-height: 26px;
-}
-
-.activity-timeline--compact .activity-timeline-bar {
- height: 10px !important;
-}
-
-.activity-timeline--compact .activity-timeline-bar[data-strength="1"] { height: 4px !important; }
-.activity-timeline--compact .activity-timeline-bar[data-strength="2"] { height: 6px !important; }
-.activity-timeline--compact .activity-timeline-bar[data-strength="3"] { height: 8px !important; }
-.activity-timeline--compact .activity-timeline-bar[data-strength="4"] { height: 10px !important; }
-.activity-timeline--compact .activity-timeline-bar[data-strength="5"] { height: 12px !important; }
-
-.activity-timeline--compact .activity-timeline-stats {
- width: 30px;
- min-width: 30px;
-}
-
-.activity-timeline--compact .activity-timeline-stat-label {
- display: none;
-}
-
-.activity-timeline--compact .activity-timeline-legend {
- display: none;
-}
-
-.activity-timeline--compact .activity-timeline-axis {
- padding-left: 110px;
- padding-right: 40px;
-}
-
-/* ============================================
- VISUAL MODE: SUMMARY
- ============================================ */
-.activity-timeline--summary .activity-timeline-lanes {
- max-height: 100px;
-}
-
-.activity-timeline--summary .activity-timeline-lane {
- min-height: 20px;
-}
-
-.activity-timeline--summary .activity-timeline-label {
- width: 80px;
- min-width: 80px;
- padding: 3px 6px;
-}
-
-.activity-timeline--summary .activity-timeline-id,
-.activity-timeline--summary .activity-timeline-name {
- font-size: 9px;
-}
-
-.activity-timeline--summary .activity-timeline-status {
- width: 3px;
- min-width: 3px;
-}
-
-.activity-timeline--summary .activity-timeline-track {
- min-height: 20px;
-}
-
-.activity-timeline--summary .activity-timeline-bar {
- height: 8px !important;
- border-radius: 1px;
-}
-
-.activity-timeline--summary .activity-timeline-stats {
- display: none;
-}
-
-.activity-timeline--summary .activity-timeline-ticks {
- display: none !important;
-}
-
-.activity-timeline--summary .activity-timeline-annotations {
- display: none;
-}
-
-.activity-timeline--summary .activity-timeline-legend {
- display: none;
-}
-
-.activity-timeline--summary .activity-timeline-axis {
- padding-left: 90px;
- padding-right: 10px;
- font-size: 8px;
-}
-
-/* ============================================
- BACKWARD COMPATIBILITY NOTE
- The old signal-timeline.css is still loaded
- for existing TSCM code that uses those classes.
- New code should use activity-timeline classes.
- ============================================ */
+/**
+ * Activity Timeline Component
+ * Reusable, configuration-driven timeline visualization
+ * Supports visual modes: compact, enriched, summary
+ */
+
+/* ============================================
+ CSS VARIABLES (with fallbacks)
+ ============================================ */
+.activity-timeline {
+ --timeline-bg: var(--bg-card, #1a1a1a);
+ --timeline-border: var(--border-color, #333);
+ --timeline-bg-secondary: var(--bg-secondary, #252525);
+ --timeline-bg-elevated: var(--bg-elevated, #2a2a2a);
+ --timeline-text-primary: var(--text-primary, #fff);
+ --timeline-text-secondary: var(--text-secondary, #888);
+ --timeline-text-dim: var(--text-dim, #666);
+ --timeline-accent: var(--accent-cyan, #4a9eff);
+ --timeline-status-new: var(--signal-new, #3b82f6);
+ --timeline-status-baseline: var(--signal-baseline, #6b7280);
+ --timeline-status-burst: var(--signal-burst, #f59e0b);
+ --timeline-status-flagged: var(--signal-emergency, #ef4444);
+ --timeline-status-gone: var(--text-dim, #666);
+}
+
+/* ============================================
+ TIMELINE CONTAINER
+ ============================================ */
+.activity-timeline {
+ background: var(--timeline-bg);
+ border: 1px solid var(--timeline-border);
+ border-radius: 6px;
+ font-family: var(--font-mono);
+ font-size: 11px;
+}
+
+.activity-timeline.collapsed .activity-timeline-body {
+ display: none;
+}
+
+.activity-timeline.collapsed .activity-timeline-header {
+ border-bottom: none;
+ margin-bottom: 0;
+ padding-bottom: 10px;
+}
+
+.activity-timeline.collapsed .activity-timeline-collapse-icon {
+ transform: rotate(-90deg);
+}
+
+/* ============================================
+ HEADER
+ ============================================ */
+.activity-timeline-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 12px;
+ cursor: pointer;
+ user-select: none;
+ transition: background 0.15s ease;
+}
+
+.activity-timeline-header:hover {
+ background: rgba(255, 255, 255, 0.02);
+}
+
+.activity-timeline-collapse-icon {
+ margin-right: 8px;
+ font-size: 10px;
+ transition: transform 0.2s ease;
+ color: var(--timeline-text-dim);
+}
+
+.activity-timeline-title {
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--timeline-text-secondary);
+}
+
+.activity-timeline-header-stats {
+ display: flex;
+ gap: 12px;
+ font-size: 10px;
+ color: var(--timeline-text-dim);
+}
+
+.activity-timeline-header-stat {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.activity-timeline-header-stat .stat-value {
+ color: var(--timeline-text-primary);
+ font-weight: 500;
+}
+
+/* ============================================
+ BODY
+ ============================================ */
+.activity-timeline-body {
+ padding: 0 12px 12px 12px;
+ border-top: 1px solid var(--timeline-border);
+}
+
+/* ============================================
+ CONTROLS
+ ============================================ */
+.activity-timeline-controls {
+ display: flex;
+ gap: 6px;
+ align-items: center;
+ padding: 8px 0;
+ flex-wrap: wrap;
+}
+
+.activity-timeline-btn {
+ background: var(--timeline-bg-secondary);
+ border: 1px solid var(--timeline-border);
+ color: var(--timeline-text-secondary);
+ font-size: 9px;
+ padding: 4px 8px;
+ border-radius: 3px;
+ cursor: pointer;
+ transition: all 0.15s ease;
+ font-family: inherit;
+}
+
+.activity-timeline-btn:hover {
+ background: var(--timeline-bg-elevated);
+ color: var(--timeline-text-primary);
+}
+
+.activity-timeline-btn.active {
+ background: var(--timeline-accent);
+ color: #000;
+ border-color: var(--timeline-accent);
+}
+
+.activity-timeline-window {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 9px;
+ color: var(--timeline-text-dim);
+ margin-left: auto;
+}
+
+.activity-timeline-window-select {
+ background: var(--timeline-bg-secondary);
+ border: 1px solid var(--timeline-border);
+ color: var(--timeline-text-primary);
+ font-size: 9px;
+ padding: 3px 6px;
+ border-radius: 3px;
+ font-family: inherit;
+}
+
+/* ============================================
+ TIME AXIS
+ ============================================ */
+.activity-timeline-axis {
+ display: flex;
+ justify-content: space-between;
+ padding: 0 50px 0 140px;
+ margin-bottom: 6px;
+ font-size: 9px;
+ color: var(--timeline-text-dim);
+}
+
+.activity-timeline-axis-label {
+ position: relative;
+}
+
+.activity-timeline-axis-label::before {
+ content: '';
+ position: absolute;
+ top: -4px;
+ left: 50%;
+ width: 1px;
+ height: 4px;
+ background: var(--timeline-border);
+}
+
+/* ============================================
+ LANES CONTAINER
+ ============================================ */
+.activity-timeline-lanes {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ max-height: 180px;
+ overflow-y: auto;
+ margin-top: 6px;
+}
+
+.activity-timeline-lanes::-webkit-scrollbar {
+ width: 6px;
+}
+
+.activity-timeline-lanes::-webkit-scrollbar-track {
+ background: var(--timeline-bg-secondary);
+ border-radius: 3px;
+}
+
+.activity-timeline-lanes::-webkit-scrollbar-thumb {
+ background: var(--timeline-border);
+ border-radius: 3px;
+}
+
+.activity-timeline-lanes::-webkit-scrollbar-thumb:hover {
+ background: var(--timeline-text-dim);
+}
+
+/* ============================================
+ INDIVIDUAL LANE
+ ============================================ */
+.activity-timeline-lane {
+ display: flex;
+ align-items: stretch;
+ min-height: 32px;
+ background: var(--timeline-bg-secondary);
+ border-radius: 3px;
+ overflow: hidden;
+ cursor: pointer;
+ transition: background 0.15s ease;
+}
+
+.activity-timeline-lane:hover {
+ background: var(--timeline-bg-elevated);
+}
+
+.activity-timeline-lane.expanded {
+ min-height: auto;
+}
+
+.activity-timeline-lane.baseline {
+ opacity: 0.5;
+}
+
+.activity-timeline-lane.baseline:hover {
+ opacity: 0.8;
+}
+
+/* Status indicator strip */
+.activity-timeline-status {
+ width: 4px;
+ min-width: 4px;
+ flex-shrink: 0;
+}
+
+.activity-timeline-status[data-status="new"] {
+ background: var(--timeline-status-new);
+}
+
+.activity-timeline-status[data-status="baseline"] {
+ background: var(--timeline-status-baseline);
+}
+
+.activity-timeline-status[data-status="burst"] {
+ background: var(--timeline-status-burst);
+}
+
+.activity-timeline-status[data-status="flagged"] {
+ background: var(--timeline-status-flagged);
+}
+
+.activity-timeline-status[data-status="gone"] {
+ background: var(--timeline-status-gone);
+}
+
+/* Label section */
+.activity-timeline-label {
+ width: 130px;
+ min-width: 130px;
+ padding: 6px 8px;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ gap: 1px;
+ border-right: 1px solid var(--timeline-border);
+ overflow: hidden;
+}
+
+.activity-timeline-id {
+ color: var(--timeline-text-primary);
+ font-size: 11px;
+ font-weight: 500;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ line-height: 1.2;
+}
+
+.activity-timeline-name {
+ color: var(--timeline-text-dim);
+ font-size: 9px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ line-height: 1.2;
+}
+
+/* ============================================
+ TRACK (where bars are drawn)
+ ============================================ */
+.activity-timeline-track {
+ flex: 1;
+ position: relative;
+ height: 100%;
+ min-height: 32px;
+ padding: 4px 8px;
+}
+
+.activity-timeline-track-bg {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ display: flex;
+ align-items: center;
+}
+
+/* ============================================
+ SIGNAL BARS
+ ============================================ */
+.activity-timeline-bar {
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%);
+ height: 14px;
+ min-width: 2px;
+ border-radius: 2px;
+ transition: opacity 0.15s ease;
+}
+
+/* Strength variants */
+.activity-timeline-bar[data-strength="1"] { height: 5px; }
+.activity-timeline-bar[data-strength="2"] { height: 9px; }
+.activity-timeline-bar[data-strength="3"] { height: 13px; }
+.activity-timeline-bar[data-strength="4"] { height: 17px; }
+.activity-timeline-bar[data-strength="5"] { height: 21px; }
+
+/* Status colors */
+.activity-timeline-bar[data-status="new"],
+.activity-timeline-bar[data-status="repeated"] {
+ background: var(--timeline-status-new);
+ box-shadow: 0 0 4px rgba(59, 130, 246, 0.3);
+}
+
+.activity-timeline-bar[data-status="baseline"] {
+ background: var(--timeline-status-baseline);
+}
+
+.activity-timeline-bar[data-status="burst"] {
+ background: var(--timeline-status-burst);
+ box-shadow: 0 0 5px rgba(245, 158, 11, 0.4);
+}
+
+.activity-timeline-bar[data-status="flagged"] {
+ background: var(--timeline-status-flagged);
+ box-shadow: 0 0 6px rgba(239, 68, 68, 0.5);
+ animation: timeline-flagged-pulse 2s ease-in-out infinite;
+}
+
+@keyframes timeline-flagged-pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.7; }
+}
+
+.activity-timeline-lane:hover .activity-timeline-bar {
+ opacity: 0.9;
+}
+
+/* ============================================
+ EXPANDED VIEW (tick marks)
+ ============================================ */
+.activity-timeline-ticks {
+ display: none;
+ position: relative;
+ height: 24px;
+ margin-top: 4px;
+ border-top: 1px solid var(--timeline-border);
+ padding-top: 4px;
+}
+
+.activity-timeline-lane.expanded .activity-timeline-ticks {
+ display: block;
+}
+
+.activity-timeline-tick {
+ position: absolute;
+ bottom: 0;
+ width: 1px;
+ background: var(--timeline-accent);
+}
+
+.activity-timeline-tick[data-strength="1"] { height: 4px; }
+.activity-timeline-tick[data-strength="2"] { height: 8px; }
+.activity-timeline-tick[data-strength="3"] { height: 12px; }
+.activity-timeline-tick[data-strength="4"] { height: 16px; }
+.activity-timeline-tick[data-strength="5"] { height: 20px; }
+
+/* ============================================
+ STATS COLUMN
+ ============================================ */
+.activity-timeline-stats {
+ width: 45px;
+ min-width: 45px;
+ padding: 4px 6px;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: flex-end;
+ font-size: 9px;
+ color: var(--timeline-text-dim);
+ border-left: 1px solid var(--timeline-border);
+}
+
+.activity-timeline-stat-count {
+ color: var(--timeline-text-primary);
+ font-weight: 500;
+}
+
+.activity-timeline-stat-label {
+ font-size: 8px;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+}
+
+/* ============================================
+ ANNOTATIONS
+ ============================================ */
+.activity-timeline-annotations {
+ margin-top: 6px;
+ padding-top: 6px;
+ border-top: 1px solid var(--timeline-border);
+ max-height: 80px;
+ overflow-y: auto;
+}
+
+.activity-timeline-annotation {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 4px 8px;
+ font-size: 10px;
+ color: var(--timeline-text-secondary);
+ background: var(--timeline-bg-secondary);
+ border-radius: 3px;
+ margin-bottom: 4px;
+}
+
+.activity-timeline-annotation-icon {
+ font-size: 10px;
+ width: 14px;
+ text-align: center;
+}
+
+.activity-timeline-annotation[data-type="new"] {
+ border-left: 2px solid var(--timeline-status-new);
+}
+
+.activity-timeline-annotation[data-type="burst"] {
+ border-left: 2px solid var(--timeline-status-burst);
+}
+
+.activity-timeline-annotation[data-type="pattern"] {
+ border-left: 2px solid var(--timeline-accent);
+}
+
+.activity-timeline-annotation[data-type="flagged"] {
+ border-left: 2px solid var(--timeline-status-flagged);
+ color: var(--timeline-status-flagged);
+}
+
+.activity-timeline-annotation[data-type="gone"] {
+ border-left: 2px solid var(--timeline-status-gone);
+}
+
+/* ============================================
+ TOOLTIP
+ ============================================ */
+.activity-timeline-tooltip {
+ position: fixed;
+ z-index: 10000;
+ background: var(--timeline-bg-elevated);
+ border: 1px solid var(--timeline-border);
+ border-radius: 4px;
+ padding: 8px 10px;
+ font-size: 10px;
+ color: var(--timeline-text-primary);
+ pointer-events: none;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+ max-width: 240px;
+ font-family: var(--font-mono);
+}
+
+.activity-timeline-tooltip-header {
+ font-weight: 600;
+ margin-bottom: 4px;
+ color: var(--timeline-accent);
+}
+
+.activity-timeline-tooltip-row {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ color: var(--timeline-text-secondary);
+ line-height: 1.5;
+}
+
+.activity-timeline-tooltip-row span:last-child {
+ color: var(--timeline-text-primary);
+}
+
+/* ============================================
+ LEGEND
+ ============================================ */
+.activity-timeline-legend {
+ display: flex;
+ gap: 12px;
+ padding-top: 8px;
+ margin-top: 8px;
+ border-top: 1px solid var(--timeline-border);
+ font-size: 9px;
+ color: var(--timeline-text-dim);
+}
+
+.activity-timeline-legend-item {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.activity-timeline-legend-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 2px;
+}
+
+.activity-timeline-legend-dot.new { background: var(--timeline-status-new); }
+.activity-timeline-legend-dot.baseline { background: var(--timeline-status-baseline); }
+.activity-timeline-legend-dot.burst { background: var(--timeline-status-burst); }
+.activity-timeline-legend-dot.flagged { background: var(--timeline-status-flagged); }
+
+/* ============================================
+ EMPTY STATE
+ ============================================ */
+.activity-timeline-empty {
+ text-align: center;
+ padding: 24px 16px;
+ color: var(--timeline-text-dim);
+ font-size: 11px;
+}
+
+.activity-timeline-empty-icon {
+ font-size: 20px;
+ margin-bottom: 8px;
+ opacity: 0.4;
+}
+
+/* More indicator */
+.activity-timeline-more {
+ text-align: center;
+ padding: 8px;
+ font-size: 10px;
+ color: var(--timeline-text-dim);
+}
+
+/* ============================================
+ VISUAL MODE: COMPACT
+ ============================================ */
+.activity-timeline--compact .activity-timeline-lanes {
+ max-height: 140px;
+}
+
+.activity-timeline--compact .activity-timeline-lane {
+ min-height: 26px;
+}
+
+.activity-timeline--compact .activity-timeline-label {
+ width: 100px;
+ min-width: 100px;
+ padding: 4px 6px;
+}
+
+.activity-timeline--compact .activity-timeline-id {
+ display: none;
+}
+
+.activity-timeline--compact .activity-timeline-name {
+ font-size: 10px;
+ color: var(--timeline-text-secondary);
+}
+
+.activity-timeline--compact .activity-timeline-track {
+ min-height: 26px;
+}
+
+.activity-timeline--compact .activity-timeline-bar {
+ height: 10px !important;
+}
+
+.activity-timeline--compact .activity-timeline-bar[data-strength="1"] { height: 4px !important; }
+.activity-timeline--compact .activity-timeline-bar[data-strength="2"] { height: 6px !important; }
+.activity-timeline--compact .activity-timeline-bar[data-strength="3"] { height: 8px !important; }
+.activity-timeline--compact .activity-timeline-bar[data-strength="4"] { height: 10px !important; }
+.activity-timeline--compact .activity-timeline-bar[data-strength="5"] { height: 12px !important; }
+
+.activity-timeline--compact .activity-timeline-stats {
+ width: 30px;
+ min-width: 30px;
+}
+
+.activity-timeline--compact .activity-timeline-stat-label {
+ display: none;
+}
+
+.activity-timeline--compact .activity-timeline-legend {
+ display: none;
+}
+
+.activity-timeline--compact .activity-timeline-axis {
+ padding-left: 110px;
+ padding-right: 40px;
+}
+
+/* ============================================
+ VISUAL MODE: SUMMARY
+ ============================================ */
+.activity-timeline--summary .activity-timeline-lanes {
+ max-height: 100px;
+}
+
+.activity-timeline--summary .activity-timeline-lane {
+ min-height: 20px;
+}
+
+.activity-timeline--summary .activity-timeline-label {
+ width: 80px;
+ min-width: 80px;
+ padding: 3px 6px;
+}
+
+.activity-timeline--summary .activity-timeline-id,
+.activity-timeline--summary .activity-timeline-name {
+ font-size: 9px;
+}
+
+.activity-timeline--summary .activity-timeline-status {
+ width: 3px;
+ min-width: 3px;
+}
+
+.activity-timeline--summary .activity-timeline-track {
+ min-height: 20px;
+}
+
+.activity-timeline--summary .activity-timeline-bar {
+ height: 8px !important;
+ border-radius: 1px;
+}
+
+.activity-timeline--summary .activity-timeline-stats {
+ display: none;
+}
+
+.activity-timeline--summary .activity-timeline-ticks {
+ display: none !important;
+}
+
+.activity-timeline--summary .activity-timeline-annotations {
+ display: none;
+}
+
+.activity-timeline--summary .activity-timeline-legend {
+ display: none;
+}
+
+.activity-timeline--summary .activity-timeline-axis {
+ padding-left: 90px;
+ padding-right: 10px;
+ font-size: 8px;
+}
+
+/* ============================================
+ BACKWARD COMPATIBILITY NOTE
+ The old signal-timeline.css is still loaded
+ for existing TSCM code that uses those classes.
+ New code should use activity-timeline classes.
+ ============================================ */
diff --git a/static/css/components/device-cards.css b/static/css/components/device-cards.css
index 26bdf07..af6f39b 100644
--- a/static/css/components/device-cards.css
+++ b/static/css/components/device-cards.css
@@ -1,879 +1,879 @@
-/**
- * Device Cards Component CSS
- * Styling for Bluetooth device cards, heuristic badges, range bands, and sparklines
- */
-
-/* ============================================
- CSS VARIABLES
- ============================================ */
-:root {
- /* Protocol colors */
- --proto-ble: #3b82f6;
- --proto-ble-bg: rgba(59, 130, 246, 0.15);
- --proto-classic: #8b5cf6;
- --proto-classic-bg: rgba(139, 92, 246, 0.15);
-
- /* Range band colors */
- --range-very-close: #ef4444;
- --range-close: #f97316;
- --range-nearby: #eab308;
- --range-far: #6b7280;
- --range-unknown: #374151;
-
- /* Heuristic badge colors */
- --heuristic-new: #3b82f6;
- --heuristic-persistent: #22c55e;
- --heuristic-beacon: #f59e0b;
- --heuristic-strong: #ef4444;
- --heuristic-random: #6b7280;
-}
-
-/* ============================================
- DEVICE CARD BASE
- ============================================ */
-.device-card {
- cursor: pointer;
- transition: all 0.15s ease;
-}
-
-.device-card:hover {
- border-color: var(--accent-cyan, #00d4ff);
- box-shadow: 0 0 0 1px rgba(0, 212, 255, 0.2);
-}
-
-.device-card:active {
- transform: scale(0.995);
-}
-
-/* ============================================
- DEVICE IDENTITY
- ============================================ */
-.device-identity {
- margin-bottom: 10px;
-}
-
-.device-name {
- font-family: 'Inter', -apple-system, sans-serif;
- font-size: 14px;
- font-weight: 600;
- color: var(--text-primary, #e0e0e0);
- margin-bottom: 2px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.device-address {
- display: flex;
- align-items: center;
- gap: 6px;
- font-family: 'JetBrains Mono', monospace;
- font-size: 11px;
-}
-
-.device-address .address-value {
- color: var(--accent-cyan, #00d4ff);
-}
-
-.device-address .address-type {
- color: var(--text-dim, #666);
- font-size: 10px;
-}
-
-/* ============================================
- PROTOCOL BADGES
- ============================================ */
-.signal-proto-badge.device-protocol {
- font-family: 'JetBrains Mono', monospace;
- font-size: 9px;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.04em;
- padding: 2px 6px;
- border-radius: 3px;
- border: 1px solid;
-}
-
-/* ============================================
- HEURISTIC BADGES
- ============================================ */
-.device-heuristic-badge {
- display: inline-flex;
- align-items: center;
- font-family: 'JetBrains Mono', monospace;
- font-size: 9px;
- font-weight: 500;
- text-transform: uppercase;
- letter-spacing: 0.03em;
- padding: 2px 6px;
- border-radius: 3px;
- background: color-mix(in srgb, var(--badge-color) 15%, transparent);
- color: var(--badge-color);
- border: 1px solid color-mix(in srgb, var(--badge-color) 30%, transparent);
-}
-
-.device-heuristic-badge.new {
- --badge-color: var(--heuristic-new);
- animation: heuristicPulse 2s ease-in-out infinite;
-}
-
-.device-heuristic-badge.persistent {
- --badge-color: var(--heuristic-persistent);
-}
-
-.device-heuristic-badge.beacon_like {
- --badge-color: var(--heuristic-beacon);
-}
-
-.device-heuristic-badge.strong_stable {
- --badge-color: var(--heuristic-strong);
-}
-
-.device-heuristic-badge.random_address {
- --badge-color: var(--heuristic-random);
-}
-
-@keyframes heuristicPulse {
- 0%, 100% { opacity: 1; }
- 50% { opacity: 0.7; }
-}
-
-/* ============================================
- SIGNAL ROW & RSSI DISPLAY
- ============================================ */
-.device-signal-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- padding: 10px;
- background: var(--bg-secondary, #1a1a1a);
- border-radius: 6px;
- margin-bottom: 8px;
-}
-
-.rssi-display {
- display: flex;
- align-items: center;
- gap: 10px;
-}
-
-.rssi-current {
- font-family: 'JetBrains Mono', monospace;
- font-size: 16px;
- font-weight: 600;
- color: var(--text-primary, #e0e0e0);
- min-width: 70px;
-}
-
-/* ============================================
- RSSI SPARKLINE
- ============================================ */
-.rssi-sparkline,
-.rssi-sparkline-svg {
- display: inline-block;
- vertical-align: middle;
-}
-
-.rssi-sparkline-empty {
- opacity: 0.5;
-}
-
-.rssi-sparkline-wrapper {
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-.rssi-value {
- font-family: 'JetBrains Mono', monospace;
- font-size: 11px;
- font-weight: 500;
-}
-
-.rssi-current-value {
- font-family: 'JetBrains Mono', monospace;
- font-size: 10px;
- font-weight: 500;
- margin-left: 6px;
-}
-
-.sparkline-dot {
- animation: sparklinePulse 1.5s ease-in-out infinite;
-}
-
-@keyframes sparklinePulse {
- 0%, 100% { r: 2; opacity: 1; }
- 50% { r: 3; opacity: 0.8; }
-}
-
-/* ============================================
- RANGE BAND INDICATOR
- ============================================ */
-.device-range-band {
- display: flex;
- align-items: center;
- gap: 6px;
- padding: 4px 10px;
- background: color-mix(in srgb, var(--range-color) 15%, transparent);
- border-radius: 4px;
- border-left: 3px solid var(--range-color);
-}
-
-.device-range-band .range-label {
- font-family: 'Inter', sans-serif;
- font-size: 11px;
- font-weight: 600;
- color: var(--range-color);
- text-transform: uppercase;
- letter-spacing: 0.03em;
-}
-
-.device-range-band .range-estimate {
- font-family: 'JetBrains Mono', monospace;
- font-size: 10px;
- color: var(--text-dim, #666);
-}
-
-.device-range-band .range-confidence {
- font-family: 'JetBrains Mono', monospace;
- font-size: 9px;
- color: var(--text-dim, #666);
- padding: 1px 4px;
- background: rgba(0, 0, 0, 0.2);
- border-radius: 3px;
-}
-
-/* ============================================
- MANUFACTURER INFO
- ============================================ */
-.device-manufacturer {
- display: flex;
- align-items: center;
- gap: 6px;
- font-size: 11px;
- color: var(--text-secondary, #888);
- margin-bottom: 6px;
-}
-
-.device-manufacturer .mfr-icon {
- font-size: 12px;
- opacity: 0.7;
-}
-
-.device-manufacturer .mfr-name {
- font-family: 'Inter', sans-serif;
-}
-
-/* ============================================
- META ROW
- ============================================ */
-.device-meta-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- font-size: 10px;
- color: var(--text-dim, #666);
-}
-
-.device-seen-count {
- display: flex;
- align-items: center;
- gap: 3px;
- font-family: 'JetBrains Mono', monospace;
-}
-
-.device-seen-count .seen-icon {
- font-size: 10px;
- opacity: 0.7;
-}
-
-.device-timestamp {
- font-family: 'JetBrains Mono', monospace;
-}
-
-/* ============================================
- SERVICE UUIDS
- ============================================ */
-.device-uuids {
- display: flex;
- flex-wrap: wrap;
- gap: 4px;
-}
-
-.device-uuid {
- font-family: 'JetBrains Mono', monospace;
- font-size: 9px;
- padding: 2px 6px;
- background: var(--bg-tertiary, #1a1a1a);
- border-radius: 3px;
- color: var(--text-secondary, #888);
- border: 1px solid var(--border-color, #333);
-}
-
-/* ============================================
- HEURISTICS DETAIL VIEW
- ============================================ */
-.device-heuristics-detail {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
- gap: 6px;
-}
-
-.heuristic-item {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 6px 8px;
- background: var(--bg-tertiary, #1a1a1a);
- border-radius: 4px;
- border: 1px solid var(--border-color, #333);
-}
-
-.heuristic-item.active {
- background: rgba(34, 197, 94, 0.1);
- border-color: rgba(34, 197, 94, 0.3);
-}
-
-.heuristic-item .heuristic-name {
- font-size: 10px;
- text-transform: capitalize;
- color: var(--text-secondary, #888);
-}
-
-.heuristic-item .heuristic-status {
- font-family: 'JetBrains Mono', monospace;
- font-size: 12px;
- font-weight: 600;
-}
-
-.heuristic-item.active .heuristic-status {
- color: var(--accent-green, #22c55e);
-}
-
-.heuristic-item:not(.active) .heuristic-status {
- color: var(--text-dim, #666);
-}
-
-/* ============================================
- MESSAGE CARDS
- ============================================ */
-.message-card {
- display: flex;
- align-items: flex-start;
- gap: 12px;
- padding: 12px 14px;
- background: var(--message-bg);
- border: 1px solid color-mix(in srgb, var(--message-color) 30%, transparent);
- border-radius: 8px;
- margin-bottom: 12px;
- animation: messageSlideIn 0.25s ease;
- position: relative;
-}
-
-@keyframes messageSlideIn {
- from {
- opacity: 0;
- transform: translateY(-8px);
- }
- to {
- opacity: 1;
- transform: translateY(0);
- }
-}
-
-.message-card.message-card-hiding {
- opacity: 0;
- transform: translateY(-8px);
- transition: all 0.2s ease;
-}
-
-.message-card::before {
- content: '';
- position: absolute;
- left: 0;
- top: 0;
- bottom: 0;
- width: 3px;
- background: var(--message-color);
- border-radius: 8px 0 0 8px;
-}
-
-.message-card-icon {
- flex-shrink: 0;
- width: 20px;
- height: 20px;
- color: var(--message-color);
-}
-
-.message-card-icon svg {
- width: 100%;
- height: 100%;
-}
-
-.message-card-icon svg.animate-spin {
- animation: spin 1s linear infinite;
-}
-
-@keyframes spin {
- from { transform: rotate(0deg); }
- to { transform: rotate(360deg); }
-}
-
-.message-card-content {
- flex: 1;
- min-width: 0;
-}
-
-.message-card-title {
- font-family: 'Inter', sans-serif;
- font-size: 13px;
- font-weight: 600;
- color: var(--text-primary, #e0e0e0);
- margin-bottom: 2px;
-}
-
-.message-card-text {
- font-size: 12px;
- color: var(--text-secondary, #888);
- line-height: 1.4;
-}
-
-.message-card-details {
- font-family: 'JetBrains Mono', monospace;
- font-size: 10px;
- color: var(--text-dim, #666);
- margin-top: 4px;
-}
-
-.message-card-dismiss {
- flex-shrink: 0;
- width: 20px;
- height: 20px;
- padding: 0;
- background: none;
- border: none;
- color: var(--text-dim, #666);
- cursor: pointer;
- opacity: 0.5;
- transition: opacity 0.15s, color 0.15s;
-}
-
-.message-card-dismiss:hover {
- opacity: 1;
- color: var(--text-primary, #e0e0e0);
-}
-
-.message-card-dismiss svg {
- width: 100%;
- height: 100%;
-}
-
-.message-card-actions {
- display: flex;
- gap: 8px;
- margin-top: 10px;
-}
-
-.message-action-btn {
- font-family: 'JetBrains Mono', monospace;
- font-size: 10px;
- font-weight: 500;
- text-transform: uppercase;
- letter-spacing: 0.04em;
- padding: 5px 10px;
- border-radius: 4px;
- border: 1px solid var(--border-color, #333);
- background: var(--bg-secondary, #1a1a1a);
- color: var(--text-secondary, #888);
- cursor: pointer;
- transition: all 0.15s;
-}
-
-.message-action-btn:hover {
- background: var(--bg-tertiary, #252525);
- border-color: var(--border-light, #444);
- color: var(--text-primary, #e0e0e0);
-}
-
-.message-action-btn.primary {
- background: color-mix(in srgb, var(--message-color) 20%, transparent);
- border-color: color-mix(in srgb, var(--message-color) 40%, transparent);
- color: var(--message-color);
-}
-
-.message-action-btn.primary:hover {
- background: color-mix(in srgb, var(--message-color) 30%, transparent);
-}
-
-/* ============================================
- DEVICE FILTER BAR
- ============================================ */
-.device-filter-bar {
- flex-wrap: wrap;
-}
-
-.device-filter-bar .signal-filter-btn .filter-dot {
- width: 6px;
- height: 6px;
- border-radius: 50%;
-}
-
-/* ============================================
- RESPONSIVE ADJUSTMENTS
- ============================================ */
-@media (max-width: 600px) {
- .device-signal-row {
- flex-direction: column;
- align-items: stretch;
- gap: 8px;
- }
-
- .rssi-display {
- justify-content: center;
- }
-
- .device-range-band {
- justify-content: center;
- }
-
- .device-heuristics-detail {
- grid-template-columns: 1fr;
- }
-
- .message-card {
- padding: 10px 12px;
- }
-
- .message-card-title {
- font-size: 12px;
- }
-
- .message-card-text {
- font-size: 11px;
- }
-}
-
-/* ============================================
- BLUETOOTH DEVICE LIST CONTAINER
- ============================================ */
-#btDeviceListContent {
- display: block !important;
- padding: 10px !important;
- overflow-y: auto !important;
- overflow-x: hidden !important;
-}
-
-/* Pure inline-styled cards - ensure no interference */
-#btDeviceListContent > div[data-bt-device-id] {
- display: block !important;
- visibility: visible !important;
- opacity: 1 !important;
- height: auto !important;
- min-height: auto !important;
- overflow: visible !important;
-}
-
-/* Legacy card support */
-#btDeviceListContent .device-card,
-#btDeviceListContent .signal-card {
- margin: 0 0 10px 0;
- height: auto !important;
- min-height: auto !important;
- overflow: visible !important;
-}
-
-/* Ensure card body is visible */
-.device-card .signal-card-body,
-.signal-card .signal-card-body {
- display: flex !important;
- flex-direction: column !important;
- gap: 8px !important;
- visibility: visible !important;
- opacity: 1 !important;
- height: auto !important;
- overflow: visible !important;
-}
-
-.device-card .device-identity,
-.signal-card .device-identity {
- display: block !important;
- visibility: visible !important;
-}
-
-.device-card .device-signal-row,
-.signal-card .device-signal-row {
- display: flex !important;
- visibility: visible !important;
-}
-
-.device-card .device-meta-row,
-.signal-card .device-meta-row {
- display: flex !important;
- visibility: visible !important;
-}
-
-/* ============================================
- ENHANCED MODAL STYLES
- ============================================ */
-.signal-details-modal-header .modal-header-info {
- display: flex;
- flex-direction: column;
- gap: 2px;
-}
-
-.signal-details-modal-subtitle {
- font-family: 'JetBrains Mono', monospace;
- font-size: 11px;
- color: var(--text-dim, #666);
-}
-
-.signal-details-modal-footer {
- display: flex;
- gap: 8px;
- justify-content: flex-end;
-}
-
-.signal-details-copy-addr-btn {
- font-family: 'JetBrains Mono', monospace;
- font-size: 11px;
- padding: 8px 16px;
- background: var(--bg-secondary, #252525);
- border: 1px solid var(--border-color, #333);
- border-radius: 4px;
- color: var(--text-secondary, #888);
- cursor: pointer;
- transition: all 0.15s ease;
-}
-
-.signal-details-copy-addr-btn:hover {
- background: var(--bg-tertiary, #1a1a1a);
- color: var(--text-primary, #e0e0e0);
-}
-
-/* Modal Header Section */
-.modal-device-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding-bottom: 16px;
- margin-bottom: 16px;
- border-bottom: 1px solid var(--border-color, #333);
-}
-
-.modal-badges {
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
-}
-
-/* Modal Sections */
-.modal-section {
- margin-bottom: 20px;
-}
-
-.modal-section:last-child {
- margin-bottom: 0;
-}
-
-.modal-section-title {
- font-family: 'JetBrains Mono', monospace;
- font-size: 10px;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.1em;
- color: var(--text-dim, #666);
- margin-bottom: 12px;
-}
-
-/* Signal Display */
-.modal-signal-display {
- display: flex;
- align-items: center;
- gap: 24px;
- padding: 16px;
- background: var(--bg-secondary, #1a1a1a);
- border-radius: 8px;
- margin-bottom: 12px;
-}
-
-.modal-rssi-large {
- font-family: 'JetBrains Mono', monospace;
- font-size: 36px;
- font-weight: 700;
- color: var(--accent-cyan, #00d4ff);
- line-height: 1;
-}
-
-.modal-rssi-large .rssi-unit {
- font-size: 14px;
- font-weight: 400;
- color: var(--text-dim, #666);
- margin-left: 4px;
-}
-
-.modal-sparkline {
- flex: 1;
- display: flex;
- justify-content: flex-end;
-}
-
-/* Signal Stats Grid */
-.modal-signal-stats {
- display: grid;
- grid-template-columns: repeat(4, 1fr);
- gap: 8px;
-}
-
-.modal-signal-stats .stat-item {
- display: flex;
- flex-direction: column;
- align-items: center;
- padding: 10px;
- background: var(--bg-secondary, #1a1a1a);
- border-radius: 6px;
- text-align: center;
-}
-
-.modal-signal-stats .stat-label {
- font-size: 9px;
- text-transform: uppercase;
- letter-spacing: 0.05em;
- color: var(--text-dim, #666);
- margin-bottom: 4px;
-}
-
-.modal-signal-stats .stat-value {
- font-family: 'JetBrains Mono', monospace;
- font-size: 12px;
- font-weight: 600;
- color: var(--text-primary, #e0e0e0);
-}
-
-/* Info Grid */
-.modal-info-grid {
- display: grid;
- grid-template-columns: repeat(2, 1fr);
- gap: 8px;
-}
-
-.modal-info-grid .info-item {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 10px 12px;
- background: var(--bg-secondary, #1a1a1a);
- border-radius: 6px;
-}
-
-.modal-info-grid .info-label {
- font-size: 11px;
- color: var(--text-dim, #666);
-}
-
-.modal-info-grid .info-value {
- font-size: 12px;
- font-weight: 500;
- color: var(--text-primary, #e0e0e0);
-}
-
-.modal-info-grid .info-value.mono {
- font-family: 'JetBrains Mono', monospace;
- color: var(--accent-cyan, #00d4ff);
-}
-
-/* UUID List */
-.modal-uuid-list {
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
-}
-
-.modal-uuid {
- font-family: 'JetBrains Mono', monospace;
- font-size: 10px;
- padding: 4px 8px;
- background: var(--bg-secondary, #1a1a1a);
- border: 1px solid var(--border-color, #333);
- border-radius: 4px;
- color: var(--text-secondary, #888);
-}
-
-/* Heuristics Grid */
-.modal-heuristics-grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
- gap: 8px;
-}
-
-.heuristic-check {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 10px 12px;
- background: var(--bg-secondary, #1a1a1a);
- border-radius: 6px;
- border: 1px solid var(--border-color, #333);
-}
-
-.heuristic-check.active {
- background: rgba(34, 197, 94, 0.1);
- border-color: rgba(34, 197, 94, 0.3);
-}
-
-.heuristic-indicator {
- font-family: 'JetBrains Mono', monospace;
- font-size: 14px;
- font-weight: 600;
- color: var(--text-dim, #666);
-}
-
-.heuristic-check.active .heuristic-indicator {
- color: var(--accent-green, #22c55e);
-}
-
-.heuristic-label {
- font-size: 11px;
- text-transform: capitalize;
- color: var(--text-secondary, #888);
-}
-
-/* ============================================
- RESPONSIVE MODAL
- ============================================ */
-@media (max-width: 600px) {
- .modal-signal-stats {
- grid-template-columns: repeat(2, 1fr);
- }
-
- .modal-info-grid {
- grid-template-columns: 1fr;
- }
-
- .modal-signal-display {
- flex-direction: column;
- align-items: flex-start;
- gap: 16px;
- }
-
- .modal-sparkline {
- width: 100%;
- justify-content: center;
- }
-
- .modal-device-header {
- flex-direction: column;
- align-items: flex-start;
- gap: 12px;
- }
-}
-
-/* ============================================
- DARK MODE OVERRIDES (if needed)
- ============================================ */
-@media (prefers-color-scheme: dark) {
- .device-card {
- --bg-secondary: #1a1a1a;
- --bg-tertiary: #141414;
- }
-}
+/**
+ * Device Cards Component CSS
+ * Styling for Bluetooth device cards, heuristic badges, range bands, and sparklines
+ */
+
+/* ============================================
+ CSS VARIABLES
+ ============================================ */
+:root {
+ /* Protocol colors */
+ --proto-ble: #3b82f6;
+ --proto-ble-bg: rgba(59, 130, 246, 0.15);
+ --proto-classic: #8b5cf6;
+ --proto-classic-bg: rgba(139, 92, 246, 0.15);
+
+ /* Range band colors */
+ --range-very-close: #ef4444;
+ --range-close: #f97316;
+ --range-nearby: #eab308;
+ --range-far: #6b7280;
+ --range-unknown: #374151;
+
+ /* Heuristic badge colors */
+ --heuristic-new: #3b82f6;
+ --heuristic-persistent: #22c55e;
+ --heuristic-beacon: #f59e0b;
+ --heuristic-strong: #ef4444;
+ --heuristic-random: #6b7280;
+}
+
+/* ============================================
+ DEVICE CARD BASE
+ ============================================ */
+.device-card {
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.device-card:hover {
+ border-color: var(--accent-cyan, #00d4ff);
+ box-shadow: 0 0 0 1px rgba(0, 212, 255, 0.2);
+}
+
+.device-card:active {
+ transform: scale(0.995);
+}
+
+/* ============================================
+ DEVICE IDENTITY
+ ============================================ */
+.device-identity {
+ margin-bottom: 10px;
+}
+
+.device-name {
+ font-family: var(--font-sans);
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--text-primary, #e0e0e0);
+ margin-bottom: 2px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.device-address {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-family: var(--font-mono);
+ font-size: 11px;
+}
+
+.device-address .address-value {
+ color: var(--accent-cyan, #00d4ff);
+}
+
+.device-address .address-type {
+ color: var(--text-dim, #666);
+ font-size: 10px;
+}
+
+/* ============================================
+ PROTOCOL BADGES
+ ============================================ */
+.signal-proto-badge.device-protocol {
+ font-family: var(--font-mono);
+ font-size: 9px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ padding: 2px 6px;
+ border-radius: 3px;
+ border: 1px solid;
+}
+
+/* ============================================
+ HEURISTIC BADGES
+ ============================================ */
+.device-heuristic-badge {
+ display: inline-flex;
+ align-items: center;
+ font-family: var(--font-mono);
+ font-size: 9px;
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ padding: 2px 6px;
+ border-radius: 3px;
+ background: color-mix(in srgb, var(--badge-color) 15%, transparent);
+ color: var(--badge-color);
+ border: 1px solid color-mix(in srgb, var(--badge-color) 30%, transparent);
+}
+
+.device-heuristic-badge.new {
+ --badge-color: var(--heuristic-new);
+ animation: heuristicPulse 2s ease-in-out infinite;
+}
+
+.device-heuristic-badge.persistent {
+ --badge-color: var(--heuristic-persistent);
+}
+
+.device-heuristic-badge.beacon_like {
+ --badge-color: var(--heuristic-beacon);
+}
+
+.device-heuristic-badge.strong_stable {
+ --badge-color: var(--heuristic-strong);
+}
+
+.device-heuristic-badge.random_address {
+ --badge-color: var(--heuristic-random);
+}
+
+@keyframes heuristicPulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.7; }
+}
+
+/* ============================================
+ SIGNAL ROW & RSSI DISPLAY
+ ============================================ */
+.device-signal-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 10px;
+ background: var(--bg-secondary, #1a1a1a);
+ border-radius: 6px;
+ margin-bottom: 8px;
+}
+
+.rssi-display {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.rssi-current {
+ font-family: var(--font-mono);
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--text-primary, #e0e0e0);
+ min-width: 70px;
+}
+
+/* ============================================
+ RSSI SPARKLINE
+ ============================================ */
+.rssi-sparkline,
+.rssi-sparkline-svg {
+ display: inline-block;
+ vertical-align: middle;
+}
+
+.rssi-sparkline-empty {
+ opacity: 0.5;
+}
+
+.rssi-sparkline-wrapper {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.rssi-value {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ font-weight: 500;
+}
+
+.rssi-current-value {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ font-weight: 500;
+ margin-left: 6px;
+}
+
+.sparkline-dot {
+ animation: sparklinePulse 1.5s ease-in-out infinite;
+}
+
+@keyframes sparklinePulse {
+ 0%, 100% { r: 2; opacity: 1; }
+ 50% { r: 3; opacity: 0.8; }
+}
+
+/* ============================================
+ RANGE BAND INDICATOR
+ ============================================ */
+.device-range-band {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 10px;
+ background: color-mix(in srgb, var(--range-color) 15%, transparent);
+ border-radius: 4px;
+ border-left: 3px solid var(--range-color);
+}
+
+.device-range-band .range-label {
+ font-family: var(--font-sans);
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--range-color);
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+}
+
+.device-range-band .range-estimate {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ color: var(--text-dim, #666);
+}
+
+.device-range-band .range-confidence {
+ font-family: var(--font-mono);
+ font-size: 9px;
+ color: var(--text-dim, #666);
+ padding: 1px 4px;
+ background: rgba(0, 0, 0, 0.2);
+ border-radius: 3px;
+}
+
+/* ============================================
+ MANUFACTURER INFO
+ ============================================ */
+.device-manufacturer {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 11px;
+ color: var(--text-secondary, #888);
+ margin-bottom: 6px;
+}
+
+.device-manufacturer .mfr-icon {
+ font-size: 12px;
+ opacity: 0.7;
+}
+
+.device-manufacturer .mfr-name {
+ font-family: var(--font-sans);
+}
+
+/* ============================================
+ META ROW
+ ============================================ */
+.device-meta-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ font-size: 10px;
+ color: var(--text-dim, #666);
+}
+
+.device-seen-count {
+ display: flex;
+ align-items: center;
+ gap: 3px;
+ font-family: var(--font-mono);
+}
+
+.device-seen-count .seen-icon {
+ font-size: 10px;
+ opacity: 0.7;
+}
+
+.device-timestamp {
+ font-family: var(--font-mono);
+}
+
+/* ============================================
+ SERVICE UUIDS
+ ============================================ */
+.device-uuids {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+}
+
+.device-uuid {
+ font-family: var(--font-mono);
+ font-size: 9px;
+ padding: 2px 6px;
+ background: var(--bg-tertiary, #1a1a1a);
+ border-radius: 3px;
+ color: var(--text-secondary, #888);
+ border: 1px solid var(--border-color, #333);
+}
+
+/* ============================================
+ HEURISTICS DETAIL VIEW
+ ============================================ */
+.device-heuristics-detail {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
+ gap: 6px;
+}
+
+.heuristic-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 6px 8px;
+ background: var(--bg-tertiary, #1a1a1a);
+ border-radius: 4px;
+ border: 1px solid var(--border-color, #333);
+}
+
+.heuristic-item.active {
+ background: rgba(34, 197, 94, 0.1);
+ border-color: rgba(34, 197, 94, 0.3);
+}
+
+.heuristic-item .heuristic-name {
+ font-size: 10px;
+ text-transform: capitalize;
+ color: var(--text-secondary, #888);
+}
+
+.heuristic-item .heuristic-status {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.heuristic-item.active .heuristic-status {
+ color: var(--accent-green, #22c55e);
+}
+
+.heuristic-item:not(.active) .heuristic-status {
+ color: var(--text-dim, #666);
+}
+
+/* ============================================
+ MESSAGE CARDS
+ ============================================ */
+.message-card {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ padding: 12px 14px;
+ background: var(--message-bg);
+ border: 1px solid color-mix(in srgb, var(--message-color) 30%, transparent);
+ border-radius: 8px;
+ margin-bottom: 12px;
+ animation: messageSlideIn 0.25s ease;
+ position: relative;
+}
+
+@keyframes messageSlideIn {
+ from {
+ opacity: 0;
+ transform: translateY(-8px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.message-card.message-card-hiding {
+ opacity: 0;
+ transform: translateY(-8px);
+ transition: all 0.2s ease;
+}
+
+.message-card::before {
+ content: '';
+ position: absolute;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ width: 3px;
+ background: var(--message-color);
+ border-radius: 8px 0 0 8px;
+}
+
+.message-card-icon {
+ flex-shrink: 0;
+ width: 20px;
+ height: 20px;
+ color: var(--message-color);
+}
+
+.message-card-icon svg {
+ width: 100%;
+ height: 100%;
+}
+
+.message-card-icon svg.animate-spin {
+ animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+ from { transform: rotate(0deg); }
+ to { transform: rotate(360deg); }
+}
+
+.message-card-content {
+ flex: 1;
+ min-width: 0;
+}
+
+.message-card-title {
+ font-family: var(--font-sans);
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-primary, #e0e0e0);
+ margin-bottom: 2px;
+}
+
+.message-card-text {
+ font-size: 12px;
+ color: var(--text-secondary, #888);
+ line-height: 1.4;
+}
+
+.message-card-details {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ color: var(--text-dim, #666);
+ margin-top: 4px;
+}
+
+.message-card-dismiss {
+ flex-shrink: 0;
+ width: 20px;
+ height: 20px;
+ padding: 0;
+ background: none;
+ border: none;
+ color: var(--text-dim, #666);
+ cursor: pointer;
+ opacity: 0.5;
+ transition: opacity 0.15s, color 0.15s;
+}
+
+.message-card-dismiss:hover {
+ opacity: 1;
+ color: var(--text-primary, #e0e0e0);
+}
+
+.message-card-dismiss svg {
+ width: 100%;
+ height: 100%;
+}
+
+.message-card-actions {
+ display: flex;
+ gap: 8px;
+ margin-top: 10px;
+}
+
+.message-action-btn {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ padding: 5px 10px;
+ border-radius: 4px;
+ border: 1px solid var(--border-color, #333);
+ background: var(--bg-secondary, #1a1a1a);
+ color: var(--text-secondary, #888);
+ cursor: pointer;
+ transition: all 0.15s;
+}
+
+.message-action-btn:hover {
+ background: var(--bg-tertiary, #252525);
+ border-color: var(--border-light, #444);
+ color: var(--text-primary, #e0e0e0);
+}
+
+.message-action-btn.primary {
+ background: color-mix(in srgb, var(--message-color) 20%, transparent);
+ border-color: color-mix(in srgb, var(--message-color) 40%, transparent);
+ color: var(--message-color);
+}
+
+.message-action-btn.primary:hover {
+ background: color-mix(in srgb, var(--message-color) 30%, transparent);
+}
+
+/* ============================================
+ DEVICE FILTER BAR
+ ============================================ */
+.device-filter-bar {
+ flex-wrap: wrap;
+}
+
+.device-filter-bar .signal-filter-btn .filter-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+}
+
+/* ============================================
+ RESPONSIVE ADJUSTMENTS
+ ============================================ */
+@media (max-width: 600px) {
+ .device-signal-row {
+ flex-direction: column;
+ align-items: stretch;
+ gap: 8px;
+ }
+
+ .rssi-display {
+ justify-content: center;
+ }
+
+ .device-range-band {
+ justify-content: center;
+ }
+
+ .device-heuristics-detail {
+ grid-template-columns: 1fr;
+ }
+
+ .message-card {
+ padding: 10px 12px;
+ }
+
+ .message-card-title {
+ font-size: 12px;
+ }
+
+ .message-card-text {
+ font-size: 11px;
+ }
+}
+
+/* ============================================
+ BLUETOOTH DEVICE LIST CONTAINER
+ ============================================ */
+#btDeviceListContent {
+ display: block !important;
+ padding: 10px !important;
+ overflow-y: auto !important;
+ overflow-x: hidden !important;
+}
+
+/* Pure inline-styled cards - ensure no interference */
+#btDeviceListContent > div[data-bt-device-id] {
+ display: block !important;
+ visibility: visible !important;
+ opacity: 1 !important;
+ height: auto !important;
+ min-height: auto !important;
+ overflow: visible !important;
+}
+
+/* Legacy card support */
+#btDeviceListContent .device-card,
+#btDeviceListContent .signal-card {
+ margin: 0 0 10px 0;
+ height: auto !important;
+ min-height: auto !important;
+ overflow: visible !important;
+}
+
+/* Ensure card body is visible */
+.device-card .signal-card-body,
+.signal-card .signal-card-body {
+ display: flex !important;
+ flex-direction: column !important;
+ gap: 8px !important;
+ visibility: visible !important;
+ opacity: 1 !important;
+ height: auto !important;
+ overflow: visible !important;
+}
+
+.device-card .device-identity,
+.signal-card .device-identity {
+ display: block !important;
+ visibility: visible !important;
+}
+
+.device-card .device-signal-row,
+.signal-card .device-signal-row {
+ display: flex !important;
+ visibility: visible !important;
+}
+
+.device-card .device-meta-row,
+.signal-card .device-meta-row {
+ display: flex !important;
+ visibility: visible !important;
+}
+
+/* ============================================
+ ENHANCED MODAL STYLES
+ ============================================ */
+.signal-details-modal-header .modal-header-info {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.signal-details-modal-subtitle {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ color: var(--text-dim, #666);
+}
+
+.signal-details-modal-footer {
+ display: flex;
+ gap: 8px;
+ justify-content: flex-end;
+}
+
+.signal-details-copy-addr-btn {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ padding: 8px 16px;
+ background: var(--bg-secondary, #252525);
+ border: 1px solid var(--border-color, #333);
+ border-radius: 4px;
+ color: var(--text-secondary, #888);
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.signal-details-copy-addr-btn:hover {
+ background: var(--bg-tertiary, #1a1a1a);
+ color: var(--text-primary, #e0e0e0);
+}
+
+/* Modal Header Section */
+.modal-device-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding-bottom: 16px;
+ margin-bottom: 16px;
+ border-bottom: 1px solid var(--border-color, #333);
+}
+
+.modal-badges {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+/* Modal Sections */
+.modal-section {
+ margin-bottom: 20px;
+}
+
+.modal-section:last-child {
+ margin-bottom: 0;
+}
+
+.modal-section-title {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: var(--text-dim, #666);
+ margin-bottom: 12px;
+}
+
+/* Signal Display */
+.modal-signal-display {
+ display: flex;
+ align-items: center;
+ gap: 24px;
+ padding: 16px;
+ background: var(--bg-secondary, #1a1a1a);
+ border-radius: 8px;
+ margin-bottom: 12px;
+}
+
+.modal-rssi-large {
+ font-family: var(--font-mono);
+ font-size: 36px;
+ font-weight: 700;
+ color: var(--accent-cyan, #00d4ff);
+ line-height: 1;
+}
+
+.modal-rssi-large .rssi-unit {
+ font-size: 14px;
+ font-weight: 400;
+ color: var(--text-dim, #666);
+ margin-left: 4px;
+}
+
+.modal-sparkline {
+ flex: 1;
+ display: flex;
+ justify-content: flex-end;
+}
+
+/* Signal Stats Grid */
+.modal-signal-stats {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 8px;
+}
+
+.modal-signal-stats .stat-item {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 10px;
+ background: var(--bg-secondary, #1a1a1a);
+ border-radius: 6px;
+ text-align: center;
+}
+
+.modal-signal-stats .stat-label {
+ font-size: 9px;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--text-dim, #666);
+ margin-bottom: 4px;
+}
+
+.modal-signal-stats .stat-value {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-primary, #e0e0e0);
+}
+
+/* Info Grid */
+.modal-info-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 8px;
+}
+
+.modal-info-grid .info-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 10px 12px;
+ background: var(--bg-secondary, #1a1a1a);
+ border-radius: 6px;
+}
+
+.modal-info-grid .info-label {
+ font-size: 11px;
+ color: var(--text-dim, #666);
+}
+
+.modal-info-grid .info-value {
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--text-primary, #e0e0e0);
+}
+
+.modal-info-grid .info-value.mono {
+ font-family: var(--font-mono);
+ color: var(--accent-cyan, #00d4ff);
+}
+
+/* UUID List */
+.modal-uuid-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.modal-uuid {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ padding: 4px 8px;
+ background: var(--bg-secondary, #1a1a1a);
+ border: 1px solid var(--border-color, #333);
+ border-radius: 4px;
+ color: var(--text-secondary, #888);
+}
+
+/* Heuristics Grid */
+.modal-heuristics-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
+ gap: 8px;
+}
+
+.heuristic-check {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 12px;
+ background: var(--bg-secondary, #1a1a1a);
+ border-radius: 6px;
+ border: 1px solid var(--border-color, #333);
+}
+
+.heuristic-check.active {
+ background: rgba(34, 197, 94, 0.1);
+ border-color: rgba(34, 197, 94, 0.3);
+}
+
+.heuristic-indicator {
+ font-family: var(--font-mono);
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--text-dim, #666);
+}
+
+.heuristic-check.active .heuristic-indicator {
+ color: var(--accent-green, #22c55e);
+}
+
+.heuristic-label {
+ font-size: 11px;
+ text-transform: capitalize;
+ color: var(--text-secondary, #888);
+}
+
+/* ============================================
+ RESPONSIVE MODAL
+ ============================================ */
+@media (max-width: 600px) {
+ .modal-signal-stats {
+ grid-template-columns: repeat(2, 1fr);
+ }
+
+ .modal-info-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .modal-signal-display {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 16px;
+ }
+
+ .modal-sparkline {
+ width: 100%;
+ justify-content: center;
+ }
+
+ .modal-device-header {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 12px;
+ }
+}
+
+/* ============================================
+ DARK MODE OVERRIDES (if needed)
+ ============================================ */
+@media (prefers-color-scheme: dark) {
+ .device-card {
+ --bg-secondary: #1a1a1a;
+ --bg-tertiary: #141414;
+ }
+}
diff --git a/static/css/components/function-strip.css b/static/css/components/function-strip.css
new file mode 100644
index 0000000..8ff5c65
--- /dev/null
+++ b/static/css/components/function-strip.css
@@ -0,0 +1,371 @@
+/* Function Strip (Action Bar) - Shared across modes
+ * Based on APRS strip pattern, reusable for Pager, Sensor, Bluetooth, WiFi, TSCM, etc.
+ */
+
+.function-strip {
+ background: linear-gradient(180deg, var(--bg-panel) 0%, var(--bg-dark) 100%);
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ padding: 8px 12px;
+ margin-bottom: 10px;
+ overflow: visible;
+ min-height: 44px;
+}
+
+.function-strip-inner {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: max-content;
+}
+
+/* Stats */
+.function-strip .strip-stat {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 6px 10px;
+ background: rgba(74, 158, 255, 0.05);
+ border: 1px solid rgba(74, 158, 255, 0.15);
+ border-radius: 4px;
+ min-width: 55px;
+}
+
+.function-strip .strip-stat:hover {
+ background: rgba(74, 158, 255, 0.1);
+ border-color: rgba(74, 158, 255, 0.3);
+}
+
+.function-strip .strip-value {
+ font-family: var(--font-mono);
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--accent-cyan);
+ line-height: 1.2;
+}
+
+.function-strip .strip-label {
+ font-size: 8px;
+ font-weight: 600;
+ color: var(--text-dim);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ margin-top: 1px;
+}
+
+.function-strip .strip-divider {
+ width: 1px;
+ height: 28px;
+ background: var(--border-color);
+ margin: 0 4px;
+}
+
+/* Signal stat coloring */
+.function-strip .signal-stat.good .strip-value { color: var(--accent-green); }
+.function-strip .signal-stat.warning .strip-value { color: var(--accent-yellow); }
+.function-strip .signal-stat.poor .strip-value { color: var(--accent-red); }
+
+/* Controls */
+.function-strip .strip-control {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.function-strip .strip-select {
+ background: rgba(0,0,0,0.3);
+ border: 1px solid var(--border-color);
+ color: var(--text-primary);
+ padding: 4px 8px;
+ border-radius: 4px;
+ font-size: 10px;
+ cursor: pointer;
+}
+
+.function-strip .strip-select:hover {
+ border-color: var(--accent-cyan);
+}
+
+.function-strip .strip-select:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.function-strip .strip-input-label {
+ font-size: 9px;
+ color: var(--text-muted);
+ font-weight: 600;
+}
+
+.function-strip .strip-input {
+ background: rgba(0,0,0,0.3);
+ border: 1px solid var(--border-color);
+ color: var(--text-primary);
+ padding: 4px 6px;
+ border-radius: 4px;
+ font-size: 10px;
+ width: 50px;
+ text-align: center;
+}
+
+.function-strip .strip-input:hover,
+.function-strip .strip-input:focus {
+ border-color: var(--accent-cyan);
+ outline: none;
+}
+
+.function-strip .strip-input:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* Wider input for frequency values */
+.function-strip .strip-input.wide {
+ width: 70px;
+}
+
+/* Tool Status Indicators */
+.function-strip .strip-tools {
+ display: flex;
+ gap: 4px;
+}
+
+.function-strip .strip-tool {
+ font-size: 9px;
+ font-weight: 600;
+ padding: 3px 6px;
+ border-radius: 3px;
+ background: rgba(255, 59, 48, 0.2);
+ color: var(--accent-red);
+ border: 1px solid rgba(255, 59, 48, 0.3);
+}
+
+.function-strip .strip-tool.ok {
+ background: rgba(0, 255, 136, 0.1);
+ color: var(--accent-green);
+ border-color: rgba(0, 255, 136, 0.3);
+}
+
+.function-strip .strip-tool.warn {
+ background: rgba(255, 193, 7, 0.2);
+ color: var(--accent-yellow);
+ border-color: rgba(255, 193, 7, 0.3);
+}
+
+/* Buttons */
+.function-strip .strip-btn {
+ background: rgba(74, 158, 255, 0.1);
+ border: 1px solid rgba(74, 158, 255, 0.2);
+ color: var(--text-primary);
+ padding: 6px 12px;
+ border-radius: 4px;
+ font-size: 10px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s;
+ white-space: nowrap;
+}
+
+.function-strip .strip-btn:hover:not(:disabled) {
+ background: rgba(74, 158, 255, 0.2);
+ border-color: rgba(74, 158, 255, 0.4);
+}
+
+.function-strip .strip-btn.primary {
+ background: linear-gradient(135deg, var(--accent-green) 0%, #10b981 100%);
+ border: none;
+ color: #000;
+}
+
+.function-strip .strip-btn.primary:hover:not(:disabled) {
+ filter: brightness(1.1);
+}
+
+.function-strip .strip-btn.stop {
+ background: linear-gradient(135deg, var(--accent-red) 0%, #dc2626 100%);
+ border: none;
+ color: #fff;
+}
+
+.function-strip .strip-btn.stop:hover:not(:disabled) {
+ filter: brightness(1.1);
+}
+
+.function-strip .strip-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* Status indicator */
+.function-strip .strip-status {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 8px;
+ background: rgba(0,0,0,0.2);
+ border-radius: 4px;
+ font-size: 10px;
+ font-weight: 600;
+ color: var(--text-secondary);
+}
+
+.function-strip .status-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--text-muted);
+}
+
+.function-strip .status-dot.inactive {
+ background: var(--text-muted);
+}
+
+.function-strip .status-dot.active,
+.function-strip .status-dot.scanning,
+.function-strip .status-dot.decoding {
+ background: var(--accent-cyan);
+ animation: strip-pulse 1.5s ease-in-out infinite;
+}
+
+.function-strip .status-dot.listening,
+.function-strip .status-dot.tracking,
+.function-strip .status-dot.receiving {
+ background: var(--accent-green);
+ animation: strip-pulse 1.5s ease-in-out infinite;
+}
+
+.function-strip .status-dot.sweeping {
+ background: var(--accent-orange);
+ animation: strip-pulse 1s ease-in-out infinite;
+}
+
+.function-strip .status-dot.error {
+ background: var(--accent-red);
+}
+
+@keyframes strip-pulse {
+ 0%, 100% { opacity: 1; box-shadow: 0 0 4px 2px currentColor; }
+ 50% { opacity: 0.6; box-shadow: none; }
+}
+
+/* Time display */
+.function-strip .strip-time {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ color: var(--text-muted);
+ padding: 4px 8px;
+ background: rgba(0,0,0,0.2);
+ border-radius: 4px;
+ white-space: nowrap;
+}
+
+/* Mode-specific accent colors */
+.function-strip.pager-strip .strip-stat {
+ background: rgba(255, 193, 7, 0.05);
+ border-color: rgba(255, 193, 7, 0.15);
+}
+.function-strip.pager-strip .strip-stat:hover {
+ background: rgba(255, 193, 7, 0.1);
+ border-color: rgba(255, 193, 7, 0.3);
+}
+.function-strip.pager-strip .strip-value {
+ color: var(--accent-yellow);
+}
+
+.function-strip.sensor-strip .strip-stat {
+ background: rgba(0, 255, 136, 0.05);
+ border-color: rgba(0, 255, 136, 0.15);
+}
+.function-strip.sensor-strip .strip-stat:hover {
+ background: rgba(0, 255, 136, 0.1);
+ border-color: rgba(0, 255, 136, 0.3);
+}
+.function-strip.sensor-strip .strip-value {
+ color: var(--accent-green);
+}
+
+.function-strip.bt-strip .strip-stat {
+ background: rgba(0, 122, 255, 0.05);
+ border-color: rgba(0, 122, 255, 0.15);
+}
+.function-strip.bt-strip .strip-stat:hover {
+ background: rgba(0, 122, 255, 0.1);
+ border-color: rgba(0, 122, 255, 0.3);
+}
+.function-strip.bt-strip .strip-value {
+ color: #0a84ff;
+}
+
+.function-strip.wifi-strip .strip-stat {
+ background: rgba(255, 149, 0, 0.05);
+ border-color: rgba(255, 149, 0, 0.15);
+}
+.function-strip.wifi-strip .strip-stat:hover {
+ background: rgba(255, 149, 0, 0.1);
+ border-color: rgba(255, 149, 0, 0.3);
+}
+.function-strip.wifi-strip .strip-value {
+ color: var(--accent-orange);
+}
+
+.function-strip.tscm-strip {
+ margin-top: 4px; /* Extra clearance to prevent top clipping */
+}
+
+.function-strip.tscm-strip .strip-stat {
+ background: rgba(255, 59, 48, 0.15);
+ border: 1px solid rgba(255, 59, 48, 0.4);
+}
+.function-strip.tscm-strip .strip-stat:hover {
+ background: rgba(255, 59, 48, 0.25);
+ border-color: rgba(255, 59, 48, 0.6);
+}
+.function-strip.tscm-strip .strip-value {
+ color: #ef4444; /* Explicit red color */
+}
+.function-strip.tscm-strip .strip-label {
+ color: #9ca3af; /* Explicit light gray */
+}
+.function-strip.tscm-strip .strip-select {
+ color: #e8eaed; /* Explicit white for selects */
+ background: rgba(0, 0, 0, 0.4);
+}
+.function-strip.tscm-strip .strip-btn {
+ color: #e8eaed; /* Explicit white for buttons */
+}
+.function-strip.tscm-strip .strip-tool {
+ color: #e8eaed; /* Explicit white for tool indicators */
+}
+.function-strip.tscm-strip .strip-time,
+.function-strip.tscm-strip .strip-status span {
+ color: #9ca3af; /* Explicit gray for status/time */
+}
+
+.function-strip.rtlamr-strip .strip-stat {
+ background: rgba(175, 82, 222, 0.05);
+ border-color: rgba(175, 82, 222, 0.15);
+}
+.function-strip.rtlamr-strip .strip-stat:hover {
+ background: rgba(175, 82, 222, 0.1);
+ border-color: rgba(175, 82, 222, 0.3);
+}
+.function-strip.rtlamr-strip .strip-value {
+ color: #af52de;
+}
+
+.function-strip.listening-strip .strip-stat {
+ background: rgba(74, 158, 255, 0.05);
+ border-color: rgba(74, 158, 255, 0.15);
+}
+.function-strip.listening-strip .strip-stat:hover {
+ background: rgba(74, 158, 255, 0.1);
+ border-color: rgba(74, 158, 255, 0.3);
+}
+.function-strip.listening-strip .strip-value {
+ color: var(--accent-cyan);
+}
+
+/* Threat-colored stats for TSCM */
+.function-strip .strip-stat.threat-high .strip-value { color: var(--accent-red); }
+.function-strip .strip-stat.threat-review .strip-value { color: var(--accent-orange); }
+.function-strip .strip-stat.threat-info .strip-value { color: var(--accent-cyan); }
diff --git a/static/css/components/signal-cards.css b/static/css/components/signal-cards.css
index f0814d8..87e65c4 100644
--- a/static/css/components/signal-cards.css
+++ b/static/css/components/signal-cards.css
@@ -1,1934 +1,1934 @@
-/**
- * Signal Cards Component System
- * Reusable card components for displaying RF signals and decoded messages
- * Used across: Pager, APRS, Sensors, and other signal-based modes
- */
-
-/* ============================================
- STATUS COLORS & VARIABLES
- ============================================ */
-:root {
- /* Signal status colors */
- --signal-new: #3b82f6;
- --signal-new-bg: rgba(59, 130, 246, 0.12);
- --signal-baseline: #6b7280;
- --signal-baseline-bg: rgba(107, 114, 128, 0.08);
- --signal-burst: #f59e0b;
- --signal-burst-bg: rgba(245, 158, 11, 0.12);
- --signal-repeated: #eab308;
- --signal-repeated-bg: rgba(234, 179, 8, 0.10);
- --signal-emergency: #ef4444;
- --signal-emergency-bg: rgba(239, 68, 68, 0.15);
-
- /* Protocol colors */
- --proto-pocsag: var(--accent-cyan, #4a9eff);
- --proto-flex: var(--accent-amber, #f59e0b);
- --proto-aprs: #06b6d4;
- --proto-ais: #8b5cf6;
- --proto-acars: #ec4899;
-}
-
-/* ============================================
- SIGNAL FEED CONTAINER
- ============================================ */
-.signal-feed {
- display: flex;
- flex-direction: column;
- gap: 8px;
-}
-
-.signal-feed-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 12px 0;
- border-bottom: 1px solid var(--border-color);
- margin-bottom: 8px;
-}
-
-.signal-feed-title {
- font-family: 'JetBrains Mono', monospace;
- font-size: 12px;
- font-weight: 600;
- letter-spacing: 0.06em;
- text-transform: uppercase;
- color: var(--text-secondary);
-}
-
-.signal-feed-live {
- display: flex;
- align-items: center;
- gap: 8px;
- font-family: 'JetBrains Mono', monospace;
- font-size: 11px;
- color: var(--accent-green);
- text-transform: uppercase;
- letter-spacing: 0.05em;
-}
-
-.signal-feed-live .live-dot {
- width: 8px;
- height: 8px;
- background: var(--accent-green);
- border-radius: 50%;
- animation: signalPulse 2s ease-in-out infinite;
-}
-
-@keyframes signalPulse {
- 0%, 100% { opacity: 1; transform: scale(1); }
- 50% { opacity: 0.5; transform: scale(0.85); }
-}
-
-/* ============================================
- FILTER BAR
- ============================================ */
-.signal-filter-bar {
- display: flex;
- align-items: center;
- gap: 6px;
- padding: 10px 12px;
- background: var(--bg-secondary);
- border: 1px solid var(--border-color);
- border-radius: 6px;
- margin-bottom: 12px;
- flex-wrap: wrap;
-}
-
-.signal-filter-label {
- font-size: 10px;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.05em;
- color: var(--text-dim);
- margin-right: 6px;
-}
-
-.signal-filter-btn {
- display: inline-flex;
- align-items: center;
- gap: 5px;
- font-family: 'Inter', sans-serif;
- font-size: 11px;
- font-weight: 500;
- padding: 5px 10px;
- border-radius: 4px;
- border: 1px solid var(--border-color);
- background: var(--bg-card);
- color: var(--text-secondary);
- cursor: pointer;
- transition: all 0.15s ease;
-}
-
-.signal-filter-btn:hover {
- border-color: var(--border-light);
- background: var(--bg-elevated);
-}
-
-.signal-filter-btn.active {
- border-color: var(--accent-cyan);
- background: var(--accent-cyan-dim);
- color: var(--accent-cyan);
-}
-
-.signal-filter-btn .filter-dot {
- width: 6px;
- height: 6px;
- border-radius: 50%;
-}
-
-.signal-filter-btn[data-filter="all"] .filter-dot { background: var(--text-secondary); }
-.signal-filter-btn[data-filter="emergency"] .filter-dot { background: var(--signal-emergency); }
-.signal-filter-btn[data-filter="new"] .filter-dot { background: var(--signal-new); }
-.signal-filter-btn[data-filter="burst"] .filter-dot { background: var(--signal-burst); }
-.signal-filter-btn[data-filter="repeated"] .filter-dot { background: var(--signal-repeated); }
-.signal-filter-btn[data-filter="baseline"] .filter-dot { background: var(--signal-baseline); }
-
-.signal-filter-count {
- font-family: 'JetBrains Mono', monospace;
- font-size: 10px;
- background: var(--bg-secondary);
- padding: 1px 5px;
- border-radius: 3px;
- color: var(--text-dim);
-}
-
-.signal-filter-btn.active .signal-filter-count {
- background: rgba(74, 158, 255, 0.2);
- color: var(--accent-cyan);
-}
-
-.signal-filter-divider {
- width: 1px;
- height: 20px;
- background: var(--border-color);
- margin: 0 6px;
-}
-
-/* ============================================
- BASE SIGNAL CARD
- ============================================ */
-.signal-card {
- background: var(--bg-card);
- border: 1px solid var(--border-color);
- border-radius: 6px;
- padding: 12px;
- transition: all 0.2s ease;
- position: relative;
- overflow: hidden;
- animation: cardSlideIn 0.25s ease;
-}
-
-@keyframes cardSlideIn {
- from {
- opacity: 0;
- transform: translateY(-6px);
- }
- to {
- opacity: 1;
- transform: translateY(0);
- }
-}
-
-.signal-card:hover {
- background: var(--bg-elevated);
- border-color: var(--border-light);
-}
-
-.signal-card.hidden {
- display: none;
-}
-
-/* Left accent border for status */
-.signal-card::before {
- content: '';
- position: absolute;
- left: 0;
- top: 0;
- bottom: 0;
- width: 3px;
- background: var(--card-accent, transparent);
-}
-
-/* ============================================
- SIGNAL CARD STATUS VARIANTS
- ============================================ */
-.signal-card[data-status="new"] {
- --card-accent: var(--signal-new);
- background: linear-gradient(90deg, var(--signal-new-bg) 0%, var(--bg-card) 35%);
-}
-
-.signal-card[data-status="burst"] {
- --card-accent: var(--signal-burst);
- background: linear-gradient(90deg, var(--signal-burst-bg) 0%, var(--bg-card) 35%);
-}
-
-.signal-card[data-status="repeated"] {
- --card-accent: var(--signal-repeated);
-}
-
-.signal-card[data-status="baseline"] {
- --card-accent: var(--signal-baseline);
-}
-
-.signal-card[data-status="emergency"] {
- --card-accent: var(--signal-emergency);
- background: linear-gradient(90deg, var(--signal-emergency-bg) 0%, var(--bg-card) 35%);
- border-color: rgba(239, 68, 68, 0.3);
-}
-
-/* ============================================
- CARD HEADER
- ============================================ */
-.signal-card-header {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- margin-bottom: 10px;
-}
-
-.signal-card-badges {
- display: flex;
- align-items: center;
- gap: 8px;
- flex-wrap: wrap;
-}
-
-/* Protocol badge */
-.signal-proto-badge {
- font-family: 'JetBrains Mono', monospace;
- font-size: 10px;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.04em;
- padding: 3px 7px;
- border-radius: 3px;
- border: 1px solid;
-}
-
-.signal-proto-badge.pocsag {
- background: var(--accent-cyan-dim);
- color: var(--accent-cyan);
- border-color: rgba(74, 158, 255, 0.25);
-}
-
-.signal-proto-badge.flex {
- background: var(--accent-amber-dim);
- color: var(--accent-amber);
- border-color: rgba(212, 168, 83, 0.25);
-}
-
-.signal-proto-badge.aprs {
- background: rgba(6, 182, 212, 0.15);
- color: #06b6d4;
- border-color: rgba(6, 182, 212, 0.25);
-}
-
-.signal-proto-badge.ais {
- background: rgba(139, 92, 246, 0.15);
- color: #8b5cf6;
- border-color: rgba(139, 92, 246, 0.25);
-}
-
-.signal-proto-badge.acars {
- background: rgba(236, 72, 153, 0.15);
- color: #ec4899;
- border-color: rgba(236, 72, 153, 0.25);
-}
-
-/* Frequency/Address badge */
-.signal-freq-badge {
- font-family: 'JetBrains Mono', monospace;
- font-size: 11px;
- font-weight: 500;
- color: var(--text-primary);
- background: var(--bg-secondary);
- padding: 3px 8px;
- border-radius: 3px;
- border: 1px solid var(--border-color);
-}
-
-/* Status pill */
-.signal-status-pill {
- display: inline-flex;
- align-items: center;
- gap: 5px;
- font-size: 10px;
- font-weight: 500;
- text-transform: uppercase;
- letter-spacing: 0.04em;
- padding: 3px 8px;
- border-radius: 10px;
-}
-
-.signal-status-pill .status-dot {
- width: 5px;
- height: 5px;
- border-radius: 50%;
- background: currentColor;
-}
-
-.signal-status-pill[data-status="new"] {
- background: var(--signal-new-bg);
- color: var(--signal-new);
-}
-
-.signal-status-pill[data-status="baseline"] {
- background: var(--signal-baseline-bg);
- color: var(--signal-baseline);
-}
-
-.signal-status-pill[data-status="burst"] {
- background: var(--signal-burst-bg);
- color: var(--signal-burst);
-}
-
-.signal-status-pill[data-status="repeated"] {
- background: var(--signal-repeated-bg);
- color: var(--signal-repeated);
-}
-
-.signal-status-pill[data-status="emergency"] {
- background: var(--signal-emergency-bg);
- color: var(--signal-emergency);
-}
-
-.signal-status-pill[data-status="new"] .status-dot,
-.signal-status-pill[data-status="burst"] .status-dot,
-.signal-status-pill[data-status="emergency"] .status-dot {
- animation: signalPulse 1.5s ease-in-out infinite;
-}
-
-/* ============================================
- CARD BODY
- ============================================ */
-.signal-card-body {
- display: flex;
- flex-direction: column;
- gap: 8px;
-}
-
-/* Message metadata row */
-.signal-meta-row {
- display: flex;
- align-items: center;
- gap: 10px;
- flex-wrap: wrap;
-}
-
-.signal-sender {
- font-family: 'JetBrains Mono', monospace;
- font-size: 11px;
- color: var(--accent-green);
-}
-
-.signal-msg-type {
- font-size: 10px;
- text-transform: uppercase;
- letter-spacing: 0.04em;
- color: var(--text-dim);
- background: var(--bg-secondary);
- padding: 2px 6px;
- border-radius: 3px;
-}
-
-.signal-timestamp {
- font-family: 'JetBrains Mono', monospace;
- font-size: 10px;
- color: var(--text-dim);
- margin-left: auto;
-}
-
-/* Message content preview */
-.signal-message {
- font-family: 'JetBrains Mono', monospace;
- font-size: 12px;
- color: var(--text-primary);
- background: var(--bg-secondary);
- padding: 10px;
- border-radius: 4px;
- border-left: 2px solid var(--border-color);
- line-height: 1.5;
- word-break: break-word;
-}
-
-.signal-message.numeric {
- font-size: 14px;
- letter-spacing: 1.5px;
-}
-
-.signal-message.emergency {
- border-left-color: var(--signal-emergency);
- background: var(--signal-emergency-bg);
-}
-
-.signal-message.truncated::after {
- content: '...';
- color: var(--text-dim);
-}
-
-/* Signal strength indicator */
-.signal-strength-row {
- display: flex;
- align-items: center;
- gap: 12px;
-}
-
-.signal-strength-bars {
- display: flex;
- align-items: flex-end;
- gap: 2px;
- height: 16px;
-}
-
-.signal-strength-bars .bar {
- width: 3px;
- background: var(--border-light);
- border-radius: 1px;
- transition: background 0.2s;
-}
-
-.signal-strength-bars .bar:nth-child(1) { height: 5px; }
-.signal-strength-bars .bar:nth-child(2) { height: 8px; }
-.signal-strength-bars .bar:nth-child(3) { height: 11px; }
-.signal-strength-bars .bar:nth-child(4) { height: 14px; }
-.signal-strength-bars .bar:nth-child(5) { height: 16px; }
-
-.signal-strength-bars .bar.active {
- background: var(--accent-green);
-}
-
-.signal-strength-bars .bar.active.weak {
- background: var(--accent-orange);
-}
-
-.signal-activity {
- font-size: 12px;
- color: var(--text-secondary);
-}
-
-/* Behavior tag */
-.signal-behavior {
- display: inline-flex;
- align-items: center;
- gap: 5px;
- font-size: 11px;
- color: var(--text-dim);
- background: var(--bg-secondary);
- padding: 5px 8px;
- border-radius: 4px;
- width: fit-content;
-}
-
-.signal-behavior svg {
- width: 12px;
- height: 12px;
- opacity: 0.7;
-}
-
-/* ============================================
- CARD FOOTER & ACTIONS
- ============================================ */
-.signal-card-footer {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-top: 10px;
- padding-top: 10px;
- border-top: 1px solid var(--border-color);
-}
-
-.signal-advanced-toggle {
- display: flex;
- align-items: center;
- gap: 5px;
- font-size: 11px;
- color: var(--text-dim);
- background: none;
- border: none;
- cursor: pointer;
- padding: 4px 6px;
- margin: -4px -6px;
- border-radius: 4px;
- transition: all 0.15s;
-}
-
-.signal-advanced-toggle:hover {
- color: var(--text-secondary);
- background: var(--bg-secondary);
-}
-
-.signal-advanced-toggle svg {
- width: 12px;
- height: 12px;
- transition: transform 0.2s;
-}
-
-.signal-advanced-toggle.open svg {
- transform: rotate(180deg);
-}
-
-.signal-advanced-toggle.open {
- color: var(--accent-cyan);
-}
-
-.signal-card-actions {
- display: flex;
- gap: 6px;
-}
-
-.signal-action-btn {
- font-size: 10px;
- font-weight: 500;
- text-transform: uppercase;
- letter-spacing: 0.04em;
- color: var(--text-dim);
- background: var(--bg-secondary);
- border: 1px solid var(--border-color);
- padding: 5px 8px;
- border-radius: 4px;
- cursor: pointer;
- transition: all 0.15s;
-}
-
-.signal-action-btn:hover {
- color: var(--text-secondary);
- border-color: var(--border-light);
-}
-
-.signal-action-btn.primary {
- background: var(--accent-cyan-dim);
- border-color: rgba(74, 158, 255, 0.25);
- color: var(--accent-cyan);
-}
-
-.signal-action-btn.primary:hover {
- background: rgba(74, 158, 255, 0.2);
-}
-
-.signal-action-btn.danger {
- background: var(--accent-red-dim);
- border-color: rgba(239, 68, 68, 0.25);
- color: var(--accent-red);
-}
-
-.signal-action-btn.danger:hover {
- background: rgba(239, 68, 68, 0.2);
-}
-
-/* ============================================
- ADVANCED PANEL
- ============================================ */
-.signal-advanced-panel {
- display: grid;
- grid-template-rows: 0fr;
- transition: grid-template-rows 0.2s ease;
- margin-top: 0;
-}
-
-.signal-advanced-panel.open {
- grid-template-rows: 1fr;
- margin-top: 10px;
-}
-
-.signal-advanced-inner {
- overflow: hidden;
-}
-
-.signal-advanced-content {
- background: var(--bg-secondary);
- border: 1px solid var(--border-color);
- border-radius: 4px;
- padding: 10px;
-}
-
-.signal-advanced-section {
- margin-bottom: 10px;
-}
-
-.signal-advanced-section:last-child {
- margin-bottom: 0;
-}
-
-.signal-advanced-title {
- font-size: 9px;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.08em;
- color: var(--text-dim);
- margin-bottom: 6px;
-}
-
-.signal-advanced-grid {
- display: grid;
- grid-template-columns: repeat(2, 1fr);
- gap: 6px;
-}
-
-.signal-advanced-item {
- display: flex;
- flex-direction: column;
- gap: 1px;
-}
-
-.signal-advanced-label {
- font-size: 9px;
- color: var(--text-dim);
- text-transform: uppercase;
- letter-spacing: 0.04em;
-}
-
-.signal-advanced-value {
- font-family: 'JetBrains Mono', monospace;
- font-size: 11px;
- color: var(--text-primary);
-}
-
-.signal-raw-data {
- font-family: 'JetBrains Mono', monospace;
- font-size: 10px;
- color: var(--text-secondary);
- background: var(--bg-primary);
- padding: 8px;
- border-radius: 3px;
- overflow-x: auto;
- white-space: pre-wrap;
- word-break: break-all;
- line-height: 1.5;
-}
-
-.signal-advanced-actions {
- display: flex;
- gap: 6px;
- margin-top: 10px;
- padding-top: 10px;
- border-top: 1px solid var(--border-color);
-}
-
-/* ============================================
- MINI MAP (for APRS/position data)
- ============================================ */
-.signal-mini-map {
- width: 100%;
- height: 70px;
- background: var(--bg-secondary);
- border-radius: 4px;
- border: 1px solid var(--border-color);
- display: flex;
- align-items: center;
- justify-content: center;
- position: relative;
- overflow: hidden;
- cursor: pointer;
- transition: border-color 0.15s;
-}
-
-.signal-mini-map:hover {
- border-color: var(--accent-cyan);
-}
-
-.signal-mini-map::before {
- content: '';
- position: absolute;
- inset: 0;
- background-image:
- linear-gradient(rgba(74, 158, 255, 0.08) 1px, transparent 1px),
- linear-gradient(90deg, rgba(74, 158, 255, 0.08) 1px, transparent 1px);
- background-size: 14px 14px;
-}
-
-.signal-map-pin {
- width: 10px;
- height: 10px;
- background: var(--signal-emergency);
- border-radius: 50%;
- border: 2px solid var(--bg-card);
- box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.3);
- animation: mapPing 1.5s ease-out infinite;
- z-index: 1;
-}
-
-@keyframes mapPing {
- 0% { box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.4); }
- 100% { box-shadow: 0 0 0 12px rgba(239, 68, 68, 0); }
-}
-
-.signal-map-coords {
- position: absolute;
- bottom: 4px;
- right: 6px;
- font-family: 'JetBrains Mono', monospace;
- font-size: 9px;
- color: var(--text-dim);
- background: var(--bg-card);
- padding: 2px 5px;
- border-radius: 2px;
-}
-
-/* ============================================
- SPARKLINE CHART
- ============================================ */
-.signal-sparkline-container {
- margin-top: 6px;
-}
-
-.signal-sparkline-label {
- font-size: 9px;
- color: var(--text-dim);
- text-transform: uppercase;
- letter-spacing: 0.04em;
- margin-bottom: 3px;
-}
-
-.signal-sparkline {
- display: flex;
- align-items: flex-end;
- gap: 1px;
- height: 28px;
- padding: 3px;
- background: var(--bg-primary);
- border-radius: 3px;
-}
-
-.signal-sparkline .spark-bar {
- flex: 1;
- background: var(--accent-cyan);
- border-radius: 1px;
- opacity: 0.6;
- transition: opacity 0.15s;
-}
-
-.signal-sparkline .spark-bar:hover {
- opacity: 1;
-}
-
-.signal-sparkline .spark-bar.spike {
- background: var(--signal-burst);
- opacity: 0.9;
-}
-
-/* ============================================
- TOAST NOTIFICATION
- ============================================ */
-.signal-toast {
- position: fixed;
- bottom: 20px;
- left: 50%;
- transform: translateX(-50%) translateY(80px);
- background: var(--bg-card);
- border: 1px solid var(--border-light);
- padding: 10px 18px;
- border-radius: 6px;
- font-size: 12px;
- color: var(--text-primary);
- box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4);
- z-index: 10000;
- opacity: 0;
- transition: all 0.25s ease;
-}
-
-.signal-toast.show {
- transform: translateX(-50%) translateY(0);
- opacity: 1;
-}
-
-.signal-toast.success {
- border-color: var(--accent-green);
- background: linear-gradient(90deg, var(--accent-green-dim) 0%, var(--bg-card) 40%);
-}
-
-.signal-toast.error {
- border-color: var(--accent-red);
- background: linear-gradient(90deg, var(--accent-red-dim) 0%, var(--bg-card) 40%);
-}
-
-/* ============================================
- EMPTY STATE
- ============================================ */
-.signal-empty-state {
- text-align: center;
- padding: 40px 20px;
- color: var(--text-dim);
-}
-
-.signal-empty-state svg {
- width: 40px;
- height: 40px;
- margin-bottom: 12px;
- opacity: 0.5;
-}
-
-.signal-empty-state p {
- font-size: 13px;
-}
-
-/* ============================================
- COMPACT MODE (for high-density display)
- ============================================ */
-.signal-feed.compact .signal-card {
- padding: 8px 10px;
-}
-
-.signal-feed.compact .signal-card-header {
- margin-bottom: 6px;
-}
-
-.signal-feed.compact .signal-proto-badge {
- font-size: 9px;
- padding: 2px 5px;
-}
-
-.signal-feed.compact .signal-freq-badge {
- font-size: 10px;
- padding: 2px 6px;
-}
-
-.signal-feed.compact .signal-message {
- font-size: 11px;
- padding: 8px;
-}
-
-.signal-feed.compact .signal-card-footer {
- margin-top: 8px;
- padding-top: 8px;
-}
-
-/* ============================================
- SEARCH INPUT
- ============================================ */
-.signal-search-container {
- flex: 1;
- min-width: 150px;
- max-width: 250px;
-}
-
-.signal-search-input {
- width: 100%;
- padding: 6px 10px;
- font-family: 'Inter', sans-serif;
- font-size: 11px;
- color: var(--text-primary);
- background: var(--bg-card);
- border: 1px solid var(--border-color);
- border-radius: 4px;
- outline: none;
- transition: all 0.15s;
-}
-
-.signal-search-input::placeholder {
- color: var(--text-dim);
-}
-
-.signal-search-input:focus {
- border-color: var(--accent-cyan);
- background: var(--bg-elevated);
-}
-
-/* ============================================
- SEEN COUNT BADGE
- ============================================ */
-.signal-seen-count {
- font-family: 'JetBrains Mono', monospace;
- font-size: 10px;
- color: var(--text-dim);
- background: var(--bg-secondary);
- padding: 2px 5px;
- border-radius: 3px;
-}
-
-/* ============================================
- SENSOR DATA DISPLAY
- ============================================ */
-.signal-sensor-data {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- padding: 10px;
- background: var(--bg-secondary);
- border-radius: 4px;
- border-left: 2px solid var(--accent-cyan);
-}
-
-.signal-sensor-reading {
- display: flex;
- flex-direction: column;
- gap: 2px;
- min-width: 70px;
-}
-
-.signal-sensor-reading .sensor-label {
- font-size: 9px;
- text-transform: uppercase;
- letter-spacing: 0.04em;
- color: var(--text-dim);
-}
-
-.signal-sensor-reading .sensor-value {
- font-family: 'JetBrains Mono', monospace;
- font-size: 14px;
- font-weight: 500;
- color: var(--text-primary);
-}
-
-.signal-sensor-reading .sensor-value.low-battery {
- color: var(--accent-red);
-}
-
-/* Sensor protocol badge */
-.signal-proto-badge.sensor {
- background: var(--accent-green-dim);
- color: var(--accent-green);
- border-color: rgba(34, 197, 94, 0.25);
-}
-
-/* ============================================
- METER DATA DISPLAY
- ============================================ */
-.signal-meter-data {
- display: flex;
- flex-wrap: wrap;
- gap: 12px;
- padding: 12px;
- background: var(--bg-secondary);
- border-radius: 4px;
- border-left: 2px solid var(--accent-yellow);
-}
-
-.signal-meter-reading {
- display: flex;
- flex-direction: column;
- gap: 3px;
-}
-
-.signal-meter-reading .meter-label {
- font-size: 9px;
- text-transform: uppercase;
- letter-spacing: 0.04em;
- color: var(--text-dim);
-}
-
-.signal-meter-reading .meter-value {
- font-family: 'JetBrains Mono', monospace;
- font-size: 18px;
- font-weight: 600;
- color: var(--text-primary);
-}
-
-/* Meter protocol badges */
-.signal-proto-badge.meter {
- background: rgba(234, 179, 8, 0.15);
- color: #eab308;
- border-color: rgba(234, 179, 8, 0.25);
-}
-
-.signal-proto-badge.meter.electric {
- background: rgba(234, 179, 8, 0.15);
- color: #eab308;
- border-color: rgba(234, 179, 8, 0.25);
-}
-
-.signal-proto-badge.meter.gas {
- background: rgba(249, 115, 22, 0.15);
- color: #f97316;
- border-color: rgba(249, 115, 22, 0.25);
-}
-
-.signal-proto-badge.meter.water {
- background: rgba(59, 130, 246, 0.15);
- color: #3b82f6;
- border-color: rgba(59, 130, 246, 0.25);
-}
-
-/* ============================================
- AGGREGATED METER CARD
- ============================================ */
-.signal-card.meter-aggregated {
- /* Inherit standard signal-card styles */
-}
-
-.meter-aggregated-grid {
- display: grid;
- grid-template-columns: 1fr 1.2fr 0.8fr;
- gap: 12px;
- padding: 12px;
- background: var(--bg-secondary);
- border-radius: 4px;
- border-left: 2px solid var(--accent-yellow);
- align-items: start;
-}
-
-.meter-aggregated-col {
- display: flex;
- flex-direction: column;
- gap: 4px;
- min-width: 0; /* Allow column to shrink in grid */
- overflow: hidden;
-}
-
-.meter-aggregated-label {
- font-size: 9px;
- text-transform: uppercase;
- letter-spacing: 0.04em;
- color: var(--text-dim);
-}
-
-.meter-aggregated-value {
- font-family: 'JetBrains Mono', monospace;
- font-size: 16px;
- font-weight: 600;
- color: var(--text-primary);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-/* Consumption column */
-.consumption-col .consumption-value {
- font-size: 18px;
- line-height: 1.2;
-}
-
-/* Delta badge */
-.meter-delta {
- font-family: 'JetBrains Mono', monospace;
- font-size: 11px;
- padding: 2px 6px;
- border-radius: 3px;
- width: fit-content;
- background: var(--bg-tertiary, rgba(255, 255, 255, 0.05));
- color: var(--text-dim);
-}
-
-.meter-delta.positive {
- background: rgba(34, 197, 94, 0.15);
- color: #22c55e;
-}
-
-.meter-delta.negative {
- background: rgba(239, 68, 68, 0.15);
- color: #ef4444;
-}
-
-/* Sparkline container */
-.meter-sparkline-container {
- min-height: 28px;
- display: flex;
- align-items: center;
- max-width: 100%;
- overflow: hidden;
-}
-
-.meter-sparkline-placeholder {
- font-family: 'JetBrains Mono', monospace;
- font-size: 10px;
- color: var(--text-dim);
-}
-
-/* Rate display */
-.meter-rate-value {
- font-family: 'JetBrains Mono', monospace;
- font-size: 14px;
- font-weight: 500;
- color: var(--accent-cyan, #4a9eff);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-/* Update animation */
-.signal-card.meter-updated {
- animation: meterUpdatePulse 0.3s ease;
-}
-
-@keyframes meterUpdatePulse {
- 0% {
- box-shadow: 0 0 0 0 rgba(234, 179, 8, 0.4);
- }
- 50% {
- box-shadow: 0 0 0 4px rgba(234, 179, 8, 0.2);
- }
- 100% {
- box-shadow: 0 0 0 0 rgba(234, 179, 8, 0);
- }
-}
-
-/* Consumption sparkline styles */
-.consumption-sparkline-svg {
- display: block;
-}
-
-.consumption-sparkline-wrapper {
- display: flex;
- align-items: center;
- gap: 6px;
-}
-
-.consumption-trend {
- font-size: 14px;
- font-weight: 500;
-}
-
-/* Responsive adjustments for aggregated meters */
-@media (max-width: 500px) {
- .meter-aggregated-grid {
- grid-template-columns: 1fr 1fr;
- grid-template-rows: auto auto;
- }
-
- .meter-aggregated-col.trend-col {
- grid-column: 1 / -1;
- }
-
- .meter-sparkline-container {
- width: 100%;
- }
-
- .meter-sparkline-container svg {
- width: 100%;
- }
-}
-
-/* ============================================
- APRS SYMBOL
- ============================================ */
-.signal-aprs-symbol {
- font-size: 12px;
- padding: 2px 4px;
- background: var(--bg-secondary);
- border-radius: 3px;
-}
-
-/* ============================================
- DISTANCE DISPLAY
- ============================================ */
-.signal-distance {
- font-family: 'JetBrains Mono', monospace;
- font-size: 10px;
- color: var(--accent-green);
- font-weight: 500;
-}
-
-/* ============================================
- COMPACT CARD VARIANT
- For constrained layouts like APRS station list
- ============================================ */
-.signal-card-compact {
- padding: 10px 12px;
-}
-
-.signal-card-compact .signal-card-header {
- margin-bottom: 6px;
-}
-
-.signal-card-compact .signal-proto-badge {
- font-size: 9px;
- padding: 2px 5px;
-}
-
-.signal-card-compact .signal-freq-badge {
- font-size: 10px;
- padding: 2px 6px;
-}
-
-.signal-card-compact .signal-status-pill {
- font-size: 9px;
- padding: 2px 6px;
-}
-
-.signal-card-compact .signal-message {
- font-size: 11px;
- padding: 6px 8px;
- max-height: 40px;
-}
-
-.signal-card-compact .signal-meta-row {
- font-size: 9px;
-}
-
-.signal-card-compact .signal-mini-map {
- padding: 6px 8px;
- font-size: 10px;
-}
-
-.signal-card-compact .signal-card-footer {
- margin-top: 6px;
- padding-top: 6px;
-}
-
-.signal-card-compact .signal-advanced-toggle {
- font-size: 9px;
- padding: 3px 6px;
-}
-
-/* Compact filter bar for APRS */
-.signal-filter-bar-compact {
- padding: 6px 8px;
- margin-bottom: 8px;
- gap: 4px;
-}
-
-.signal-filter-bar-compact .signal-filter-btn {
- padding: 3px 6px;
- font-size: 9px;
-}
-
-.signal-filter-bar-compact .signal-filter-count {
- font-size: 8px;
- padding: 1px 3px;
- min-width: 14px;
-}
-
-.signal-filter-bar-compact .signal-search-input {
- padding: 4px 8px;
- font-size: 10px;
-}
-
-.signal-filter-bar-compact .signal-filter-divider {
- margin: 0 4px;
-}
-
-/* ============================================
- TONE ONLY MESSAGE STYLING
- ============================================ */
-.signal-message.tone-only {
- color: var(--text-dim);
- font-style: italic;
- border-left-color: var(--border-color);
-}
-
-/* ============================================
- FILTER BAR RESPONSIVE
- ============================================ */
-@media (max-width: 768px) {
- .signal-filter-bar {
- flex-direction: column;
- align-items: stretch;
- gap: 8px;
- }
-
- .signal-filter-bar > .signal-filter-label {
- margin-top: 8px;
- }
-
- .signal-filter-bar > .signal-filter-label:first-child {
- margin-top: 0;
- }
-
- .signal-filter-divider {
- display: none;
- }
-
- .signal-search-container {
- max-width: none;
- }
-
- .signal-filter-bar .signal-filter-btn {
- flex: 1;
- }
-}
-
-/* ============================================
- SIGNAL STRENGTH INDICATOR
- Classification-based signal display
- ============================================ */
-.signal-strength-indicator {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- padding: 3px 8px;
- background: var(--bg-secondary);
- border-radius: 4px;
- border: 1px solid var(--border-color);
-}
-
-.signal-strength-indicator.compact {
- padding: 2px 4px;
- gap: 0;
- background: transparent;
- border: none;
-}
-
-.signal-strength-indicator.no-data {
- font-family: 'JetBrains Mono', monospace;
- font-size: 9px;
- color: var(--text-dim);
- opacity: 0.5;
-}
-
-.signal-strength-bars {
- display: inline-block;
- vertical-align: middle;
-}
-
-.signal-strength-bars rect {
- transition: fill 0.2s ease;
-}
-
-.signal-strength-label {
- font-family: 'JetBrains Mono', monospace;
- font-size: 10px;
- font-weight: 500;
- text-transform: uppercase;
- letter-spacing: 0.03em;
-}
-
-/* Confidence-based styling */
-.signal-confidence-low {
- opacity: 0.7;
-}
-
-.signal-confidence-medium {
- opacity: 0.85;
-}
-
-.signal-confidence-high {
- opacity: 1;
-}
-
-.signal-advanced-value.signal-confidence-low {
- color: var(--text-dim);
-}
-
-.signal-advanced-value.signal-confidence-medium {
- color: var(--accent-amber);
-}
-
-.signal-advanced-value.signal-confidence-high {
- color: var(--accent-green);
-}
-
-/* ============================================
- SIGNAL ASSESSMENT PANEL
- Detailed signal analysis in advanced panel
- ============================================ */
-.signal-assessment {
- background: var(--bg-primary);
- border: 1px solid var(--border-color);
- border-radius: 4px;
- padding: 10px;
- margin-bottom: 12px;
-}
-
-.signal-assessment-summary {
- display: flex;
- align-items: flex-start;
- gap: 12px;
- margin-bottom: 10px;
- padding-bottom: 10px;
- border-bottom: 1px solid var(--border-color);
-}
-
-.signal-assessment-text {
- font-size: 12px;
- color: var(--text-secondary);
- line-height: 1.5;
- flex: 1;
-}
-
-.signal-assessment-caveat {
- font-size: 10px;
- color: var(--text-dim);
- font-style: italic;
- margin-top: 8px;
- padding-top: 8px;
- border-top: 1px dashed var(--border-color);
- line-height: 1.4;
-}
-
-/* Signal assessment confidence badges */
-.signal-assessment .signal-advanced-grid {
- margin-top: 8px;
-}
-
-/* Range estimate styling */
-.signal-range-estimate {
- display: flex;
- align-items: center;
- gap: 8px;
- font-family: 'JetBrains Mono', monospace;
- font-size: 11px;
-}
-
-.signal-range-estimate .range-value {
- color: var(--accent-cyan);
- font-weight: 500;
-}
-
-.signal-range-estimate .range-disclaimer {
- font-size: 9px;
- color: var(--text-dim);
- font-style: italic;
-}
-
-/* ============================================
- Clickable Station Badge (APRS)
- ============================================ */
-
-.signal-station-clickable {
- cursor: pointer;
- transition: all 0.15s ease;
- position: relative;
-}
-
-.signal-station-clickable:hover {
- background: var(--accent-purple);
- color: #000;
- transform: scale(1.05);
- box-shadow: 0 0 8px rgba(138, 43, 226, 0.4);
-}
-
-.signal-station-clickable:active {
- transform: scale(0.98);
-}
-
-.signal-station-clickable::after {
- content: '';
- position: absolute;
- bottom: -2px;
- left: 50%;
- transform: translateX(-50%);
- width: 0;
- height: 2px;
- background: var(--accent-purple);
- transition: width 0.2s ease;
-}
-
-.signal-station-clickable:hover::after {
- width: 80%;
-}
-
-/* ============================================
- Station Raw Data Modal
- ============================================ */
-
-.station-raw-modal {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- z-index: 9999;
- display: flex;
- align-items: center;
- justify-content: center;
- opacity: 0;
- visibility: hidden;
- transition: opacity 0.2s ease, visibility 0.2s ease;
-}
-
-.station-raw-modal.show {
- opacity: 1;
- visibility: visible;
-}
-
-.station-raw-modal-backdrop {
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background: rgba(0, 0, 0, 0.7);
- backdrop-filter: blur(4px);
-}
-
-.station-raw-modal-content {
- position: relative;
- background: var(--panel-bg, #1a1a2e);
- border: 1px solid var(--border-color, #333);
- border-radius: 8px;
- width: 90%;
- max-width: 600px;
- max-height: 80vh;
- display: flex;
- flex-direction: column;
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
- transform: scale(0.95);
- transition: transform 0.2s ease;
-}
-
-.station-raw-modal.show .station-raw-modal-content {
- transform: scale(1);
-}
-
-.station-raw-modal-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 12px 16px;
- border-bottom: 1px solid var(--border-color, #333);
- background: rgba(0, 0, 0, 0.2);
- border-radius: 8px 8px 0 0;
-}
-
-.station-raw-modal-title {
- font-family: 'JetBrains Mono', monospace;
- font-size: 14px;
- font-weight: 600;
- color: var(--accent-cyan, #00d4ff);
-}
-
-.station-raw-modal-close {
- background: none;
- border: none;
- color: var(--text-muted, #888);
- font-size: 24px;
- cursor: pointer;
- padding: 0 4px;
- line-height: 1;
- transition: color 0.15s ease;
-}
-
-.station-raw-modal-close:hover {
- color: var(--accent-red, #ff4444);
-}
-
-.station-raw-modal-body {
- padding: 16px;
- overflow-y: auto;
- flex: 1;
-}
-
-.station-raw-label {
- font-size: 10px;
- text-transform: uppercase;
- letter-spacing: 1px;
- color: var(--text-muted, #888);
- margin-bottom: 8px;
-}
-
-.station-raw-data-display {
- font-family: 'JetBrains Mono', monospace;
- font-size: 12px;
- line-height: 1.6;
- color: var(--accent-green, #00ff88);
- background: rgba(0, 0, 0, 0.3);
- border: 1px solid var(--border-color, #333);
- border-radius: 4px;
- padding: 12px;
- word-break: break-all;
- white-space: pre-wrap;
- margin: 0;
- max-height: 300px;
- overflow-y: auto;
-}
-
-.station-raw-modal-footer {
- display: flex;
- justify-content: flex-end;
- padding: 12px 16px;
- border-top: 1px solid var(--border-color, #333);
- background: rgba(0, 0, 0, 0.2);
- border-radius: 0 0 8px 8px;
-}
-
-.station-raw-copy-btn {
- font-family: 'JetBrains Mono', monospace;
- font-size: 11px;
- padding: 8px 16px;
- background: var(--accent-purple, #8a2be2);
- border: none;
- border-radius: 4px;
- color: #fff;
- cursor: pointer;
- transition: all 0.15s ease;
-}
-
-.station-raw-copy-btn:hover {
- background: var(--accent-cyan, #00d4ff);
- color: #000;
-}
-
-/* ============================================
- SIGNAL GUESS INTEGRATION
- ============================================ */
-
-/* Signal guess badge in card header */
-.signal-card-badges .signal-guess-badge {
- margin-left: 4px;
-}
-
-/* Signal guess section in advanced panel */
-.signal-guess-section {
- border-bottom: 1px solid var(--border-color);
- padding-bottom: 12px;
- margin-bottom: 12px;
-}
-
-.signal-guess-section .signal-guess-container {
- margin-top: 8px;
-}
-
-/* Adjust guess label colors for dark theme */
-.signal-guess-section .signal-guess-label {
- color: var(--text-primary, #e0e0e0);
-}
-
-.signal-guess-section .signal-guess-tag {
- background: var(--bg-tertiary, #2a2a2a);
- color: var(--text-secondary, #888);
-}
-
-.signal-guess-section .signal-guess-alt-item {
- color: var(--text-secondary, #999);
-}
-
-.signal-guess-section .signal-guess-popup-explanation {
- color: var(--text-secondary, #aaa);
-}
-
-/* ============================================
- CLICKABLE CARDS
- ============================================ */
-.signal-card.signal-card-clickable {
- cursor: pointer;
- transition: all 0.15s ease;
-}
-
-.signal-card.signal-card-clickable:hover {
- border-color: var(--accent-cyan, #00d4ff);
- box-shadow: 0 0 0 1px rgba(0, 212, 255, 0.2);
-}
-
-.signal-card.signal-card-clickable:active {
- transform: scale(0.995);
-}
-
-/* Floating action buttons for clickable cards */
-.signal-card-actions-float {
- position: absolute;
- top: 8px;
- right: 8px;
- display: flex;
- gap: 6px;
- opacity: 0;
- transition: opacity 0.15s ease;
- z-index: 2;
-}
-
-.signal-card.signal-card-clickable:hover .signal-card-actions-float {
- opacity: 1;
-}
-
-.signal-card-actions-float .signal-action-btn {
- font-size: 10px;
- font-weight: 500;
- text-transform: uppercase;
- letter-spacing: 0.04em;
- color: var(--text-dim);
- background: var(--bg-secondary);
- border: 1px solid var(--border-color);
- padding: 4px 8px;
- border-radius: 4px;
- cursor: pointer;
- transition: all 0.15s;
-}
-
-.signal-card-actions-float .signal-action-btn:hover {
- color: var(--text-secondary);
- border-color: var(--border-light);
- background: var(--bg-tertiary);
-}
-
-/* ============================================
- SIGNAL DETAILS MODAL
- ============================================ */
-.signal-details-modal {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- z-index: 10000;
- display: flex;
- align-items: center;
- justify-content: center;
- opacity: 0;
- visibility: hidden;
- transition: opacity 0.2s ease, visibility 0.2s ease;
-}
-
-.signal-details-modal.show {
- opacity: 1;
- visibility: visible;
-}
-
-.signal-details-modal-backdrop {
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background: rgba(0, 0, 0, 0.7);
- backdrop-filter: blur(4px);
-}
-
-.signal-details-modal-content {
- position: relative;
- background: var(--panel-bg, #1a1a2e);
- border: 1px solid var(--border-color, #333);
- border-radius: 12px;
- width: 90%;
- max-width: 600px;
- max-height: 85vh;
- display: flex;
- flex-direction: column;
- transform: scale(0.95);
- transition: transform 0.2s ease;
- box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
-}
-
-.signal-details-modal.show .signal-details-modal-content {
- transform: scale(1);
-}
-
-.signal-details-modal-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 16px 20px;
- border-bottom: 1px solid var(--border-color, #333);
- background: rgba(0, 0, 0, 0.2);
- border-radius: 12px 12px 0 0;
-}
-
-.signal-details-modal-title {
- font-family: 'JetBrains Mono', monospace;
- font-size: 15px;
- font-weight: 600;
- color: var(--accent-cyan, #00d4ff);
-}
-
-.signal-details-modal-close {
- background: none;
- border: none;
- color: var(--text-muted, #888);
- font-size: 24px;
- cursor: pointer;
- padding: 0;
- line-height: 1;
- transition: color 0.15s ease;
-}
-
-.signal-details-modal-close:hover {
- color: var(--accent-red, #ff4444);
-}
-
-.signal-details-modal-body {
- padding: 20px;
- overflow-y: auto;
- flex: 1;
-}
-
-.signal-details-modal-footer {
- display: flex;
- justify-content: flex-end;
- padding: 12px 20px;
- border-top: 1px solid var(--border-color, #333);
- background: rgba(0, 0, 0, 0.2);
- border-radius: 0 0 12px 12px;
-}
-
-.signal-details-copy-btn {
- font-family: 'JetBrains Mono', monospace;
- font-size: 11px;
- padding: 8px 16px;
- background: var(--accent-purple, #8a2be2);
- border: none;
- border-radius: 4px;
- color: #fff;
- cursor: pointer;
- transition: all 0.15s ease;
-}
-
-.signal-details-copy-btn:hover {
- background: var(--accent-cyan, #00d4ff);
- color: #000;
-}
-
-/* Signal Details Content Sections */
-.signal-details-section {
- margin-bottom: 20px;
-}
-
-.signal-details-section:last-child {
- margin-bottom: 0;
-}
-
-.signal-details-title {
- font-family: 'JetBrains Mono', monospace;
- font-size: 11px;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.08em;
- color: var(--text-dim, #666);
- margin-bottom: 10px;
- padding-bottom: 6px;
- border-bottom: 1px solid var(--border-color, #333);
-}
-
-.signal-details-message {
- font-family: 'JetBrains Mono', monospace;
- font-size: 14px;
- color: var(--text-primary, #e0e0e0);
- background: var(--bg-secondary, #252525);
- padding: 12px 14px;
- border-radius: 6px;
- word-break: break-word;
- line-height: 1.5;
-}
-
-.signal-details-grid {
- display: grid;
- grid-template-columns: repeat(2, 1fr);
- gap: 12px;
-}
-
-.signal-details-item {
- display: flex;
- flex-direction: column;
- gap: 2px;
-}
-
-.signal-details-label {
- font-size: 10px;
- font-weight: 500;
- text-transform: uppercase;
- letter-spacing: 0.05em;
- color: var(--text-dim, #666);
-}
-
-.signal-details-value {
- font-family: 'JetBrains Mono', monospace;
- font-size: 13px;
- color: var(--text-primary, #e0e0e0);
-}
-
-/* Raw data in modal */
-.signal-details-modal .signal-raw-data {
- font-family: 'JetBrains Mono', monospace;
- font-size: 11px;
- color: var(--text-secondary, #aaa);
- background: var(--bg-tertiary, #1a1a1a);
- padding: 12px;
- border-radius: 6px;
- border: 1px solid var(--border-color, #333);
- white-space: pre-wrap;
- word-break: break-all;
- margin: 0;
- max-height: 200px;
- overflow-y: auto;
-}
-
-/* Signal assessment panel in modal */
-.signal-details-modal .signal-assessment {
- background: var(--bg-secondary, #252525);
- padding: 14px;
- border-radius: 8px;
- margin-bottom: 16px;
-}
-
-.signal-details-modal .signal-assessment-summary {
- display: flex;
- align-items: center;
- gap: 12px;
- margin-bottom: 12px;
-}
-
-.signal-details-modal .signal-assessment-text {
- font-size: 13px;
- color: var(--text-secondary, #aaa);
- line-height: 1.4;
-}
-
-.signal-details-modal .signal-assessment-caveat {
- font-size: 10px;
- color: var(--text-dim, #666);
- font-style: italic;
- margin-top: 10px;
- padding-top: 10px;
- border-top: 1px solid var(--border-color, #333);
-}
-
-/* Signal guess section in modal */
-.signal-details-modal .signal-guess-section {
- background: var(--bg-secondary, #252525);
- padding: 14px;
- border-radius: 8px;
- margin-bottom: 16px;
- border: none;
-}
-
-.signal-details-modal .signal-guess-content {
- margin-top: 8px;
-}
-
-/* Responsive adjustments */
-@media (max-width: 500px) {
- .signal-details-modal-content {
- width: 95%;
- max-height: 90vh;
- }
-
- .signal-details-grid {
- grid-template-columns: 1fr;
- }
-}
+/**
+ * Signal Cards Component System
+ * Reusable card components for displaying RF signals and decoded messages
+ * Used across: Pager, APRS, Sensors, and other signal-based modes
+ */
+
+/* ============================================
+ STATUS COLORS & VARIABLES
+ ============================================ */
+:root {
+ /* Signal status colors */
+ --signal-new: #3b82f6;
+ --signal-new-bg: rgba(59, 130, 246, 0.12);
+ --signal-baseline: #6b7280;
+ --signal-baseline-bg: rgba(107, 114, 128, 0.08);
+ --signal-burst: #f59e0b;
+ --signal-burst-bg: rgba(245, 158, 11, 0.12);
+ --signal-repeated: #eab308;
+ --signal-repeated-bg: rgba(234, 179, 8, 0.10);
+ --signal-emergency: #ef4444;
+ --signal-emergency-bg: rgba(239, 68, 68, 0.15);
+
+ /* Protocol colors */
+ --proto-pocsag: var(--accent-cyan, #4a9eff);
+ --proto-flex: var(--accent-amber, #f59e0b);
+ --proto-aprs: #06b6d4;
+ --proto-ais: #8b5cf6;
+ --proto-acars: #ec4899;
+}
+
+/* ============================================
+ SIGNAL FEED CONTAINER
+ ============================================ */
+.signal-feed {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.signal-feed-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 0;
+ border-bottom: 1px solid var(--border-color);
+ margin-bottom: 8px;
+}
+
+.signal-feed-title {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--text-secondary);
+}
+
+.signal-feed-live {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-family: var(--font-mono);
+ font-size: 11px;
+ color: var(--accent-green);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.signal-feed-live .live-dot {
+ width: 8px;
+ height: 8px;
+ background: var(--accent-green);
+ border-radius: 50%;
+ animation: signalPulse 2s ease-in-out infinite;
+}
+
+@keyframes signalPulse {
+ 0%, 100% { opacity: 1; transform: scale(1); }
+ 50% { opacity: 0.5; transform: scale(0.85); }
+}
+
+/* ============================================
+ FILTER BAR
+ ============================================ */
+.signal-filter-bar {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 10px 12px;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ margin-bottom: 12px;
+ flex-wrap: wrap;
+}
+
+.signal-filter-label {
+ font-size: 10px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--text-dim);
+ margin-right: 6px;
+}
+
+.signal-filter-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ font-family: var(--font-sans);
+ font-size: 11px;
+ font-weight: 500;
+ padding: 5px 10px;
+ border-radius: 4px;
+ border: 1px solid var(--border-color);
+ background: var(--bg-card);
+ color: var(--text-secondary);
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.signal-filter-btn:hover {
+ border-color: var(--border-light);
+ background: var(--bg-elevated);
+}
+
+.signal-filter-btn.active {
+ border-color: var(--accent-cyan);
+ background: var(--accent-cyan-dim);
+ color: var(--accent-cyan);
+}
+
+.signal-filter-btn .filter-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+}
+
+.signal-filter-btn[data-filter="all"] .filter-dot { background: var(--text-secondary); }
+.signal-filter-btn[data-filter="emergency"] .filter-dot { background: var(--signal-emergency); }
+.signal-filter-btn[data-filter="new"] .filter-dot { background: var(--signal-new); }
+.signal-filter-btn[data-filter="burst"] .filter-dot { background: var(--signal-burst); }
+.signal-filter-btn[data-filter="repeated"] .filter-dot { background: var(--signal-repeated); }
+.signal-filter-btn[data-filter="baseline"] .filter-dot { background: var(--signal-baseline); }
+
+.signal-filter-count {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ background: var(--bg-secondary);
+ padding: 1px 5px;
+ border-radius: 3px;
+ color: var(--text-dim);
+}
+
+.signal-filter-btn.active .signal-filter-count {
+ background: rgba(74, 158, 255, 0.2);
+ color: var(--accent-cyan);
+}
+
+.signal-filter-divider {
+ width: 1px;
+ height: 20px;
+ background: var(--border-color);
+ margin: 0 6px;
+}
+
+/* ============================================
+ BASE SIGNAL CARD
+ ============================================ */
+.signal-card {
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ padding: 12px;
+ transition: all 0.2s ease;
+ position: relative;
+ overflow: hidden;
+ animation: cardSlideIn 0.25s ease;
+}
+
+@keyframes cardSlideIn {
+ from {
+ opacity: 0;
+ transform: translateY(-6px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.signal-card:hover {
+ background: var(--bg-elevated);
+ border-color: var(--border-light);
+}
+
+.signal-card.hidden {
+ display: none;
+}
+
+/* Left accent border for status */
+.signal-card::before {
+ content: '';
+ position: absolute;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ width: 3px;
+ background: var(--card-accent, transparent);
+}
+
+/* ============================================
+ SIGNAL CARD STATUS VARIANTS
+ ============================================ */
+.signal-card[data-status="new"] {
+ --card-accent: var(--signal-new);
+ background: linear-gradient(90deg, var(--signal-new-bg) 0%, var(--bg-card) 35%);
+}
+
+.signal-card[data-status="burst"] {
+ --card-accent: var(--signal-burst);
+ background: linear-gradient(90deg, var(--signal-burst-bg) 0%, var(--bg-card) 35%);
+}
+
+.signal-card[data-status="repeated"] {
+ --card-accent: var(--signal-repeated);
+}
+
+.signal-card[data-status="baseline"] {
+ --card-accent: var(--signal-baseline);
+}
+
+.signal-card[data-status="emergency"] {
+ --card-accent: var(--signal-emergency);
+ background: linear-gradient(90deg, var(--signal-emergency-bg) 0%, var(--bg-card) 35%);
+ border-color: rgba(239, 68, 68, 0.3);
+}
+
+/* ============================================
+ CARD HEADER
+ ============================================ */
+.signal-card-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ margin-bottom: 10px;
+}
+
+.signal-card-badges {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+/* Protocol badge */
+.signal-proto-badge {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ padding: 3px 7px;
+ border-radius: 3px;
+ border: 1px solid;
+}
+
+.signal-proto-badge.pocsag {
+ background: var(--accent-cyan-dim);
+ color: var(--accent-cyan);
+ border-color: rgba(74, 158, 255, 0.25);
+}
+
+.signal-proto-badge.flex {
+ background: var(--accent-amber-dim);
+ color: var(--accent-amber);
+ border-color: rgba(212, 168, 83, 0.25);
+}
+
+.signal-proto-badge.aprs {
+ background: rgba(6, 182, 212, 0.15);
+ color: #06b6d4;
+ border-color: rgba(6, 182, 212, 0.25);
+}
+
+.signal-proto-badge.ais {
+ background: rgba(139, 92, 246, 0.15);
+ color: #8b5cf6;
+ border-color: rgba(139, 92, 246, 0.25);
+}
+
+.signal-proto-badge.acars {
+ background: rgba(236, 72, 153, 0.15);
+ color: #ec4899;
+ border-color: rgba(236, 72, 153, 0.25);
+}
+
+/* Frequency/Address badge */
+.signal-freq-badge {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ font-weight: 500;
+ color: var(--text-primary);
+ background: var(--bg-secondary);
+ padding: 3px 8px;
+ border-radius: 3px;
+ border: 1px solid var(--border-color);
+}
+
+/* Status pill */
+.signal-status-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ font-size: 10px;
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ padding: 3px 8px;
+ border-radius: 10px;
+}
+
+.signal-status-pill .status-dot {
+ width: 5px;
+ height: 5px;
+ border-radius: 50%;
+ background: currentColor;
+}
+
+.signal-status-pill[data-status="new"] {
+ background: var(--signal-new-bg);
+ color: var(--signal-new);
+}
+
+.signal-status-pill[data-status="baseline"] {
+ background: var(--signal-baseline-bg);
+ color: var(--signal-baseline);
+}
+
+.signal-status-pill[data-status="burst"] {
+ background: var(--signal-burst-bg);
+ color: var(--signal-burst);
+}
+
+.signal-status-pill[data-status="repeated"] {
+ background: var(--signal-repeated-bg);
+ color: var(--signal-repeated);
+}
+
+.signal-status-pill[data-status="emergency"] {
+ background: var(--signal-emergency-bg);
+ color: var(--signal-emergency);
+}
+
+.signal-status-pill[data-status="new"] .status-dot,
+.signal-status-pill[data-status="burst"] .status-dot,
+.signal-status-pill[data-status="emergency"] .status-dot {
+ animation: signalPulse 1.5s ease-in-out infinite;
+}
+
+/* ============================================
+ CARD BODY
+ ============================================ */
+.signal-card-body {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+/* Message metadata row */
+.signal-meta-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+
+.signal-sender {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ color: var(--accent-green);
+}
+
+.signal-msg-type {
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--text-dim);
+ background: var(--bg-secondary);
+ padding: 2px 6px;
+ border-radius: 3px;
+}
+
+.signal-timestamp {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ color: var(--text-dim);
+ margin-left: auto;
+}
+
+/* Message content preview */
+.signal-message {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ color: var(--text-primary);
+ background: var(--bg-secondary);
+ padding: 10px;
+ border-radius: 4px;
+ border-left: 2px solid var(--border-color);
+ line-height: 1.5;
+ word-break: break-word;
+}
+
+.signal-message.numeric {
+ font-size: 14px;
+ letter-spacing: 1.5px;
+}
+
+.signal-message.emergency {
+ border-left-color: var(--signal-emergency);
+ background: var(--signal-emergency-bg);
+}
+
+.signal-message.truncated::after {
+ content: '...';
+ color: var(--text-dim);
+}
+
+/* Signal strength indicator */
+.signal-strength-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.signal-strength-bars {
+ display: flex;
+ align-items: flex-end;
+ gap: 2px;
+ height: 16px;
+}
+
+.signal-strength-bars .bar {
+ width: 3px;
+ background: var(--border-light);
+ border-radius: 1px;
+ transition: background 0.2s;
+}
+
+.signal-strength-bars .bar:nth-child(1) { height: 5px; }
+.signal-strength-bars .bar:nth-child(2) { height: 8px; }
+.signal-strength-bars .bar:nth-child(3) { height: 11px; }
+.signal-strength-bars .bar:nth-child(4) { height: 14px; }
+.signal-strength-bars .bar:nth-child(5) { height: 16px; }
+
+.signal-strength-bars .bar.active {
+ background: var(--accent-green);
+}
+
+.signal-strength-bars .bar.active.weak {
+ background: var(--accent-orange);
+}
+
+.signal-activity {
+ font-size: 12px;
+ color: var(--text-secondary);
+}
+
+/* Behavior tag */
+.signal-behavior {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ font-size: 11px;
+ color: var(--text-dim);
+ background: var(--bg-secondary);
+ padding: 5px 8px;
+ border-radius: 4px;
+ width: fit-content;
+}
+
+.signal-behavior svg {
+ width: 12px;
+ height: 12px;
+ opacity: 0.7;
+}
+
+/* ============================================
+ CARD FOOTER & ACTIONS
+ ============================================ */
+.signal-card-footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-top: 10px;
+ padding-top: 10px;
+ border-top: 1px solid var(--border-color);
+}
+
+.signal-advanced-toggle {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ font-size: 11px;
+ color: var(--text-dim);
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 4px 6px;
+ margin: -4px -6px;
+ border-radius: 4px;
+ transition: all 0.15s;
+}
+
+.signal-advanced-toggle:hover {
+ color: var(--text-secondary);
+ background: var(--bg-secondary);
+}
+
+.signal-advanced-toggle svg {
+ width: 12px;
+ height: 12px;
+ transition: transform 0.2s;
+}
+
+.signal-advanced-toggle.open svg {
+ transform: rotate(180deg);
+}
+
+.signal-advanced-toggle.open {
+ color: var(--accent-cyan);
+}
+
+.signal-card-actions {
+ display: flex;
+ gap: 6px;
+}
+
+.signal-action-btn {
+ font-size: 10px;
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--text-dim);
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ padding: 5px 8px;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: all 0.15s;
+}
+
+.signal-action-btn:hover {
+ color: var(--text-secondary);
+ border-color: var(--border-light);
+}
+
+.signal-action-btn.primary {
+ background: var(--accent-cyan-dim);
+ border-color: rgba(74, 158, 255, 0.25);
+ color: var(--accent-cyan);
+}
+
+.signal-action-btn.primary:hover {
+ background: rgba(74, 158, 255, 0.2);
+}
+
+.signal-action-btn.danger {
+ background: var(--accent-red-dim);
+ border-color: rgba(239, 68, 68, 0.25);
+ color: var(--accent-red);
+}
+
+.signal-action-btn.danger:hover {
+ background: rgba(239, 68, 68, 0.2);
+}
+
+/* ============================================
+ ADVANCED PANEL
+ ============================================ */
+.signal-advanced-panel {
+ display: grid;
+ grid-template-rows: 0fr;
+ transition: grid-template-rows 0.2s ease;
+ margin-top: 0;
+}
+
+.signal-advanced-panel.open {
+ grid-template-rows: 1fr;
+ margin-top: 10px;
+}
+
+.signal-advanced-inner {
+ overflow: hidden;
+}
+
+.signal-advanced-content {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ padding: 10px;
+}
+
+.signal-advanced-section {
+ margin-bottom: 10px;
+}
+
+.signal-advanced-section:last-child {
+ margin-bottom: 0;
+}
+
+.signal-advanced-title {
+ font-size: 9px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--text-dim);
+ margin-bottom: 6px;
+}
+
+.signal-advanced-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 6px;
+}
+
+.signal-advanced-item {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+}
+
+.signal-advanced-label {
+ font-size: 9px;
+ color: var(--text-dim);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.signal-advanced-value {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ color: var(--text-primary);
+}
+
+.signal-raw-data {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ color: var(--text-secondary);
+ background: var(--bg-primary);
+ padding: 8px;
+ border-radius: 3px;
+ overflow-x: auto;
+ white-space: pre-wrap;
+ word-break: break-all;
+ line-height: 1.5;
+}
+
+.signal-advanced-actions {
+ display: flex;
+ gap: 6px;
+ margin-top: 10px;
+ padding-top: 10px;
+ border-top: 1px solid var(--border-color);
+}
+
+/* ============================================
+ MINI MAP (for APRS/position data)
+ ============================================ */
+.signal-mini-map {
+ width: 100%;
+ height: 70px;
+ background: var(--bg-secondary);
+ border-radius: 4px;
+ border: 1px solid var(--border-color);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+ overflow: hidden;
+ cursor: pointer;
+ transition: border-color 0.15s;
+}
+
+.signal-mini-map:hover {
+ border-color: var(--accent-cyan);
+}
+
+.signal-mini-map::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background-image:
+ linear-gradient(rgba(74, 158, 255, 0.08) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(74, 158, 255, 0.08) 1px, transparent 1px);
+ background-size: 14px 14px;
+}
+
+.signal-map-pin {
+ width: 10px;
+ height: 10px;
+ background: var(--signal-emergency);
+ border-radius: 50%;
+ border: 2px solid var(--bg-card);
+ box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.3);
+ animation: mapPing 1.5s ease-out infinite;
+ z-index: 1;
+}
+
+@keyframes mapPing {
+ 0% { box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.4); }
+ 100% { box-shadow: 0 0 0 12px rgba(239, 68, 68, 0); }
+}
+
+.signal-map-coords {
+ position: absolute;
+ bottom: 4px;
+ right: 6px;
+ font-family: var(--font-mono);
+ font-size: 9px;
+ color: var(--text-dim);
+ background: var(--bg-card);
+ padding: 2px 5px;
+ border-radius: 2px;
+}
+
+/* ============================================
+ SPARKLINE CHART
+ ============================================ */
+.signal-sparkline-container {
+ margin-top: 6px;
+}
+
+.signal-sparkline-label {
+ font-size: 9px;
+ color: var(--text-dim);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ margin-bottom: 3px;
+}
+
+.signal-sparkline {
+ display: flex;
+ align-items: flex-end;
+ gap: 1px;
+ height: 28px;
+ padding: 3px;
+ background: var(--bg-primary);
+ border-radius: 3px;
+}
+
+.signal-sparkline .spark-bar {
+ flex: 1;
+ background: var(--accent-cyan);
+ border-radius: 1px;
+ opacity: 0.6;
+ transition: opacity 0.15s;
+}
+
+.signal-sparkline .spark-bar:hover {
+ opacity: 1;
+}
+
+.signal-sparkline .spark-bar.spike {
+ background: var(--signal-burst);
+ opacity: 0.9;
+}
+
+/* ============================================
+ TOAST NOTIFICATION
+ ============================================ */
+.signal-toast {
+ position: fixed;
+ bottom: 20px;
+ left: 50%;
+ transform: translateX(-50%) translateY(80px);
+ background: var(--bg-card);
+ border: 1px solid var(--border-light);
+ padding: 10px 18px;
+ border-radius: 6px;
+ font-size: 12px;
+ color: var(--text-primary);
+ box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4);
+ z-index: 10000;
+ opacity: 0;
+ transition: all 0.25s ease;
+}
+
+.signal-toast.show {
+ transform: translateX(-50%) translateY(0);
+ opacity: 1;
+}
+
+.signal-toast.success {
+ border-color: var(--accent-green);
+ background: linear-gradient(90deg, var(--accent-green-dim) 0%, var(--bg-card) 40%);
+}
+
+.signal-toast.error {
+ border-color: var(--accent-red);
+ background: linear-gradient(90deg, var(--accent-red-dim) 0%, var(--bg-card) 40%);
+}
+
+/* ============================================
+ EMPTY STATE
+ ============================================ */
+.signal-empty-state {
+ text-align: center;
+ padding: 40px 20px;
+ color: var(--text-dim);
+}
+
+.signal-empty-state svg {
+ width: 40px;
+ height: 40px;
+ margin-bottom: 12px;
+ opacity: 0.5;
+}
+
+.signal-empty-state p {
+ font-size: 13px;
+}
+
+/* ============================================
+ COMPACT MODE (for high-density display)
+ ============================================ */
+.signal-feed.compact .signal-card {
+ padding: 8px 10px;
+}
+
+.signal-feed.compact .signal-card-header {
+ margin-bottom: 6px;
+}
+
+.signal-feed.compact .signal-proto-badge {
+ font-size: 9px;
+ padding: 2px 5px;
+}
+
+.signal-feed.compact .signal-freq-badge {
+ font-size: 10px;
+ padding: 2px 6px;
+}
+
+.signal-feed.compact .signal-message {
+ font-size: 11px;
+ padding: 8px;
+}
+
+.signal-feed.compact .signal-card-footer {
+ margin-top: 8px;
+ padding-top: 8px;
+}
+
+/* ============================================
+ SEARCH INPUT
+ ============================================ */
+.signal-search-container {
+ flex: 1;
+ min-width: 150px;
+ max-width: 250px;
+}
+
+.signal-search-input {
+ width: 100%;
+ padding: 6px 10px;
+ font-family: var(--font-sans);
+ font-size: 11px;
+ color: var(--text-primary);
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ outline: none;
+ transition: all 0.15s;
+}
+
+.signal-search-input::placeholder {
+ color: var(--text-dim);
+}
+
+.signal-search-input:focus {
+ border-color: var(--accent-cyan);
+ background: var(--bg-elevated);
+}
+
+/* ============================================
+ SEEN COUNT BADGE
+ ============================================ */
+.signal-seen-count {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ color: var(--text-dim);
+ background: var(--bg-secondary);
+ padding: 2px 5px;
+ border-radius: 3px;
+}
+
+/* ============================================
+ SENSOR DATA DISPLAY
+ ============================================ */
+.signal-sensor-data {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ padding: 10px;
+ background: var(--bg-secondary);
+ border-radius: 4px;
+ border-left: 2px solid var(--accent-cyan);
+}
+
+.signal-sensor-reading {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 70px;
+}
+
+.signal-sensor-reading .sensor-label {
+ font-size: 9px;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--text-dim);
+}
+
+.signal-sensor-reading .sensor-value {
+ font-family: var(--font-mono);
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--text-primary);
+}
+
+.signal-sensor-reading .sensor-value.low-battery {
+ color: var(--accent-red);
+}
+
+/* Sensor protocol badge */
+.signal-proto-badge.sensor {
+ background: var(--accent-green-dim);
+ color: var(--accent-green);
+ border-color: rgba(34, 197, 94, 0.25);
+}
+
+/* ============================================
+ METER DATA DISPLAY
+ ============================================ */
+.signal-meter-data {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ padding: 12px;
+ background: var(--bg-secondary);
+ border-radius: 4px;
+ border-left: 2px solid var(--accent-yellow);
+}
+
+.signal-meter-reading {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+}
+
+.signal-meter-reading .meter-label {
+ font-size: 9px;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--text-dim);
+}
+
+.signal-meter-reading .meter-value {
+ font-family: var(--font-mono);
+ font-size: 18px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+/* Meter protocol badges */
+.signal-proto-badge.meter {
+ background: rgba(234, 179, 8, 0.15);
+ color: #eab308;
+ border-color: rgba(234, 179, 8, 0.25);
+}
+
+.signal-proto-badge.meter.electric {
+ background: rgba(234, 179, 8, 0.15);
+ color: #eab308;
+ border-color: rgba(234, 179, 8, 0.25);
+}
+
+.signal-proto-badge.meter.gas {
+ background: rgba(249, 115, 22, 0.15);
+ color: #f97316;
+ border-color: rgba(249, 115, 22, 0.25);
+}
+
+.signal-proto-badge.meter.water {
+ background: rgba(59, 130, 246, 0.15);
+ color: #3b82f6;
+ border-color: rgba(59, 130, 246, 0.25);
+}
+
+/* ============================================
+ AGGREGATED METER CARD
+ ============================================ */
+.signal-card.meter-aggregated {
+ /* Inherit standard signal-card styles */
+}
+
+.meter-aggregated-grid {
+ display: grid;
+ grid-template-columns: 1fr 1.2fr 0.8fr;
+ gap: 12px;
+ padding: 12px;
+ background: var(--bg-secondary);
+ border-radius: 4px;
+ border-left: 2px solid var(--accent-yellow);
+ align-items: start;
+}
+
+.meter-aggregated-col {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ min-width: 0; /* Allow column to shrink in grid */
+ overflow: hidden;
+}
+
+.meter-aggregated-label {
+ font-size: 9px;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--text-dim);
+}
+
+.meter-aggregated-value {
+ font-family: var(--font-mono);
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--text-primary);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* Consumption column */
+.consumption-col .consumption-value {
+ font-size: 18px;
+ line-height: 1.2;
+}
+
+/* Delta badge */
+.meter-delta {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ padding: 2px 6px;
+ border-radius: 3px;
+ width: fit-content;
+ background: var(--bg-tertiary, rgba(255, 255, 255, 0.05));
+ color: var(--text-dim);
+}
+
+.meter-delta.positive {
+ background: rgba(34, 197, 94, 0.15);
+ color: #22c55e;
+}
+
+.meter-delta.negative {
+ background: rgba(239, 68, 68, 0.15);
+ color: #ef4444;
+}
+
+/* Sparkline container */
+.meter-sparkline-container {
+ min-height: 28px;
+ display: flex;
+ align-items: center;
+ max-width: 100%;
+ overflow: hidden;
+}
+
+.meter-sparkline-placeholder {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ color: var(--text-dim);
+}
+
+/* Rate display */
+.meter-rate-value {
+ font-family: var(--font-mono);
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--accent-cyan, #4a9eff);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* Update animation */
+.signal-card.meter-updated {
+ animation: meterUpdatePulse 0.3s ease;
+}
+
+@keyframes meterUpdatePulse {
+ 0% {
+ box-shadow: 0 0 0 0 rgba(234, 179, 8, 0.4);
+ }
+ 50% {
+ box-shadow: 0 0 0 4px rgba(234, 179, 8, 0.2);
+ }
+ 100% {
+ box-shadow: 0 0 0 0 rgba(234, 179, 8, 0);
+ }
+}
+
+/* Consumption sparkline styles */
+.consumption-sparkline-svg {
+ display: block;
+}
+
+.consumption-sparkline-wrapper {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.consumption-trend {
+ font-size: 14px;
+ font-weight: 500;
+}
+
+/* Responsive adjustments for aggregated meters */
+@media (max-width: 500px) {
+ .meter-aggregated-grid {
+ grid-template-columns: 1fr 1fr;
+ grid-template-rows: auto auto;
+ }
+
+ .meter-aggregated-col.trend-col {
+ grid-column: 1 / -1;
+ }
+
+ .meter-sparkline-container {
+ width: 100%;
+ }
+
+ .meter-sparkline-container svg {
+ width: 100%;
+ }
+}
+
+/* ============================================
+ APRS SYMBOL
+ ============================================ */
+.signal-aprs-symbol {
+ font-size: 12px;
+ padding: 2px 4px;
+ background: var(--bg-secondary);
+ border-radius: 3px;
+}
+
+/* ============================================
+ DISTANCE DISPLAY
+ ============================================ */
+.signal-distance {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ color: var(--accent-green);
+ font-weight: 500;
+}
+
+/* ============================================
+ COMPACT CARD VARIANT
+ For constrained layouts like APRS station list
+ ============================================ */
+.signal-card-compact {
+ padding: 10px 12px;
+}
+
+.signal-card-compact .signal-card-header {
+ margin-bottom: 6px;
+}
+
+.signal-card-compact .signal-proto-badge {
+ font-size: 9px;
+ padding: 2px 5px;
+}
+
+.signal-card-compact .signal-freq-badge {
+ font-size: 10px;
+ padding: 2px 6px;
+}
+
+.signal-card-compact .signal-status-pill {
+ font-size: 9px;
+ padding: 2px 6px;
+}
+
+.signal-card-compact .signal-message {
+ font-size: 11px;
+ padding: 6px 8px;
+ max-height: 40px;
+}
+
+.signal-card-compact .signal-meta-row {
+ font-size: 9px;
+}
+
+.signal-card-compact .signal-mini-map {
+ padding: 6px 8px;
+ font-size: 10px;
+}
+
+.signal-card-compact .signal-card-footer {
+ margin-top: 6px;
+ padding-top: 6px;
+}
+
+.signal-card-compact .signal-advanced-toggle {
+ font-size: 9px;
+ padding: 3px 6px;
+}
+
+/* Compact filter bar for APRS */
+.signal-filter-bar-compact {
+ padding: 6px 8px;
+ margin-bottom: 8px;
+ gap: 4px;
+}
+
+.signal-filter-bar-compact .signal-filter-btn {
+ padding: 3px 6px;
+ font-size: 9px;
+}
+
+.signal-filter-bar-compact .signal-filter-count {
+ font-size: 8px;
+ padding: 1px 3px;
+ min-width: 14px;
+}
+
+.signal-filter-bar-compact .signal-search-input {
+ padding: 4px 8px;
+ font-size: 10px;
+}
+
+.signal-filter-bar-compact .signal-filter-divider {
+ margin: 0 4px;
+}
+
+/* ============================================
+ TONE ONLY MESSAGE STYLING
+ ============================================ */
+.signal-message.tone-only {
+ color: var(--text-dim);
+ font-style: italic;
+ border-left-color: var(--border-color);
+}
+
+/* ============================================
+ FILTER BAR RESPONSIVE
+ ============================================ */
+@media (max-width: 768px) {
+ .signal-filter-bar {
+ flex-direction: column;
+ align-items: stretch;
+ gap: 8px;
+ }
+
+ .signal-filter-bar > .signal-filter-label {
+ margin-top: 8px;
+ }
+
+ .signal-filter-bar > .signal-filter-label:first-child {
+ margin-top: 0;
+ }
+
+ .signal-filter-divider {
+ display: none;
+ }
+
+ .signal-search-container {
+ max-width: none;
+ }
+
+ .signal-filter-bar .signal-filter-btn {
+ flex: 1;
+ }
+}
+
+/* ============================================
+ SIGNAL STRENGTH INDICATOR
+ Classification-based signal display
+ ============================================ */
+.signal-strength-indicator {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 3px 8px;
+ background: var(--bg-secondary);
+ border-radius: 4px;
+ border: 1px solid var(--border-color);
+}
+
+.signal-strength-indicator.compact {
+ padding: 2px 4px;
+ gap: 0;
+ background: transparent;
+ border: none;
+}
+
+.signal-strength-indicator.no-data {
+ font-family: var(--font-mono);
+ font-size: 9px;
+ color: var(--text-dim);
+ opacity: 0.5;
+}
+
+.signal-strength-bars {
+ display: inline-block;
+ vertical-align: middle;
+}
+
+.signal-strength-bars rect {
+ transition: fill 0.2s ease;
+}
+
+.signal-strength-label {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+}
+
+/* Confidence-based styling */
+.signal-confidence-low {
+ opacity: 0.7;
+}
+
+.signal-confidence-medium {
+ opacity: 0.85;
+}
+
+.signal-confidence-high {
+ opacity: 1;
+}
+
+.signal-advanced-value.signal-confidence-low {
+ color: var(--text-dim);
+}
+
+.signal-advanced-value.signal-confidence-medium {
+ color: var(--accent-amber);
+}
+
+.signal-advanced-value.signal-confidence-high {
+ color: var(--accent-green);
+}
+
+/* ============================================
+ SIGNAL ASSESSMENT PANEL
+ Detailed signal analysis in advanced panel
+ ============================================ */
+.signal-assessment {
+ background: var(--bg-primary);
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ padding: 10px;
+ margin-bottom: 12px;
+}
+
+.signal-assessment-summary {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ margin-bottom: 10px;
+ padding-bottom: 10px;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.signal-assessment-text {
+ font-size: 12px;
+ color: var(--text-secondary);
+ line-height: 1.5;
+ flex: 1;
+}
+
+.signal-assessment-caveat {
+ font-size: 10px;
+ color: var(--text-dim);
+ font-style: italic;
+ margin-top: 8px;
+ padding-top: 8px;
+ border-top: 1px dashed var(--border-color);
+ line-height: 1.4;
+}
+
+/* Signal assessment confidence badges */
+.signal-assessment .signal-advanced-grid {
+ margin-top: 8px;
+}
+
+/* Range estimate styling */
+.signal-range-estimate {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-family: var(--font-mono);
+ font-size: 11px;
+}
+
+.signal-range-estimate .range-value {
+ color: var(--accent-cyan);
+ font-weight: 500;
+}
+
+.signal-range-estimate .range-disclaimer {
+ font-size: 9px;
+ color: var(--text-dim);
+ font-style: italic;
+}
+
+/* ============================================
+ Clickable Station Badge (APRS)
+ ============================================ */
+
+.signal-station-clickable {
+ cursor: pointer;
+ transition: all 0.15s ease;
+ position: relative;
+}
+
+.signal-station-clickable:hover {
+ background: var(--accent-purple);
+ color: #000;
+ transform: scale(1.05);
+ box-shadow: 0 0 8px rgba(138, 43, 226, 0.4);
+}
+
+.signal-station-clickable:active {
+ transform: scale(0.98);
+}
+
+.signal-station-clickable::after {
+ content: '';
+ position: absolute;
+ bottom: -2px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 0;
+ height: 2px;
+ background: var(--accent-purple);
+ transition: width 0.2s ease;
+}
+
+.signal-station-clickable:hover::after {
+ width: 80%;
+}
+
+/* ============================================
+ Station Raw Data Modal
+ ============================================ */
+
+.station-raw-modal {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 9999;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 0;
+ visibility: hidden;
+ transition: opacity 0.2s ease, visibility 0.2s ease;
+}
+
+.station-raw-modal.show {
+ opacity: 1;
+ visibility: visible;
+}
+
+.station-raw-modal-backdrop {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.7);
+ backdrop-filter: blur(4px);
+}
+
+.station-raw-modal-content {
+ position: relative;
+ background: var(--panel-bg, #1a1a2e);
+ border: 1px solid var(--border-color, #333);
+ border-radius: 8px;
+ width: 90%;
+ max-width: 600px;
+ max-height: 80vh;
+ display: flex;
+ flex-direction: column;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
+ transform: scale(0.95);
+ transition: transform 0.2s ease;
+}
+
+.station-raw-modal.show .station-raw-modal-content {
+ transform: scale(1);
+}
+
+.station-raw-modal-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--border-color, #333);
+ background: rgba(0, 0, 0, 0.2);
+ border-radius: 8px 8px 0 0;
+}
+
+.station-raw-modal-title {
+ font-family: var(--font-mono);
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--accent-cyan, #00d4ff);
+}
+
+.station-raw-modal-close {
+ background: none;
+ border: none;
+ color: var(--text-muted, #888);
+ font-size: 24px;
+ cursor: pointer;
+ padding: 0 4px;
+ line-height: 1;
+ transition: color 0.15s ease;
+}
+
+.station-raw-modal-close:hover {
+ color: var(--accent-red, #ff4444);
+}
+
+.station-raw-modal-body {
+ padding: 16px;
+ overflow-y: auto;
+ flex: 1;
+}
+
+.station-raw-label {
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ color: var(--text-muted, #888);
+ margin-bottom: 8px;
+}
+
+.station-raw-data-display {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ line-height: 1.6;
+ color: var(--accent-green, #00ff88);
+ background: rgba(0, 0, 0, 0.3);
+ border: 1px solid var(--border-color, #333);
+ border-radius: 4px;
+ padding: 12px;
+ word-break: break-all;
+ white-space: pre-wrap;
+ margin: 0;
+ max-height: 300px;
+ overflow-y: auto;
+}
+
+.station-raw-modal-footer {
+ display: flex;
+ justify-content: flex-end;
+ padding: 12px 16px;
+ border-top: 1px solid var(--border-color, #333);
+ background: rgba(0, 0, 0, 0.2);
+ border-radius: 0 0 8px 8px;
+}
+
+.station-raw-copy-btn {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ padding: 8px 16px;
+ background: var(--accent-purple, #8a2be2);
+ border: none;
+ border-radius: 4px;
+ color: #fff;
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.station-raw-copy-btn:hover {
+ background: var(--accent-cyan, #00d4ff);
+ color: #000;
+}
+
+/* ============================================
+ SIGNAL GUESS INTEGRATION
+ ============================================ */
+
+/* Signal guess badge in card header */
+.signal-card-badges .signal-guess-badge {
+ margin-left: 4px;
+}
+
+/* Signal guess section in advanced panel */
+.signal-guess-section {
+ border-bottom: 1px solid var(--border-color);
+ padding-bottom: 12px;
+ margin-bottom: 12px;
+}
+
+.signal-guess-section .signal-guess-container {
+ margin-top: 8px;
+}
+
+/* Adjust guess label colors for dark theme */
+.signal-guess-section .signal-guess-label {
+ color: var(--text-primary, #e0e0e0);
+}
+
+.signal-guess-section .signal-guess-tag {
+ background: var(--bg-tertiary, #2a2a2a);
+ color: var(--text-secondary, #888);
+}
+
+.signal-guess-section .signal-guess-alt-item {
+ color: var(--text-secondary, #999);
+}
+
+.signal-guess-section .signal-guess-popup-explanation {
+ color: var(--text-secondary, #aaa);
+}
+
+/* ============================================
+ CLICKABLE CARDS
+ ============================================ */
+.signal-card.signal-card-clickable {
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.signal-card.signal-card-clickable:hover {
+ border-color: var(--accent-cyan, #00d4ff);
+ box-shadow: 0 0 0 1px rgba(0, 212, 255, 0.2);
+}
+
+.signal-card.signal-card-clickable:active {
+ transform: scale(0.995);
+}
+
+/* Floating action buttons for clickable cards */
+.signal-card-actions-float {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ display: flex;
+ gap: 6px;
+ opacity: 0;
+ transition: opacity 0.15s ease;
+ z-index: 2;
+}
+
+.signal-card.signal-card-clickable:hover .signal-card-actions-float {
+ opacity: 1;
+}
+
+.signal-card-actions-float .signal-action-btn {
+ font-size: 10px;
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--text-dim);
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ padding: 4px 8px;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: all 0.15s;
+}
+
+.signal-card-actions-float .signal-action-btn:hover {
+ color: var(--text-secondary);
+ border-color: var(--border-light);
+ background: var(--bg-tertiary);
+}
+
+/* ============================================
+ SIGNAL DETAILS MODAL
+ ============================================ */
+.signal-details-modal {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 10000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 0;
+ visibility: hidden;
+ transition: opacity 0.2s ease, visibility 0.2s ease;
+}
+
+.signal-details-modal.show {
+ opacity: 1;
+ visibility: visible;
+}
+
+.signal-details-modal-backdrop {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.7);
+ backdrop-filter: blur(4px);
+}
+
+.signal-details-modal-content {
+ position: relative;
+ background: var(--panel-bg, #1a1a2e);
+ border: 1px solid var(--border-color, #333);
+ border-radius: 12px;
+ width: 90%;
+ max-width: 600px;
+ max-height: 85vh;
+ display: flex;
+ flex-direction: column;
+ transform: scale(0.95);
+ transition: transform 0.2s ease;
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
+}
+
+.signal-details-modal.show .signal-details-modal-content {
+ transform: scale(1);
+}
+
+.signal-details-modal-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 16px 20px;
+ border-bottom: 1px solid var(--border-color, #333);
+ background: rgba(0, 0, 0, 0.2);
+ border-radius: 12px 12px 0 0;
+}
+
+.signal-details-modal-title {
+ font-family: var(--font-mono);
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--accent-cyan, #00d4ff);
+}
+
+.signal-details-modal-close {
+ background: none;
+ border: none;
+ color: var(--text-muted, #888);
+ font-size: 24px;
+ cursor: pointer;
+ padding: 0;
+ line-height: 1;
+ transition: color 0.15s ease;
+}
+
+.signal-details-modal-close:hover {
+ color: var(--accent-red, #ff4444);
+}
+
+.signal-details-modal-body {
+ padding: 20px;
+ overflow-y: auto;
+ flex: 1;
+}
+
+.signal-details-modal-footer {
+ display: flex;
+ justify-content: flex-end;
+ padding: 12px 20px;
+ border-top: 1px solid var(--border-color, #333);
+ background: rgba(0, 0, 0, 0.2);
+ border-radius: 0 0 12px 12px;
+}
+
+.signal-details-copy-btn {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ padding: 8px 16px;
+ background: var(--accent-purple, #8a2be2);
+ border: none;
+ border-radius: 4px;
+ color: #fff;
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.signal-details-copy-btn:hover {
+ background: var(--accent-cyan, #00d4ff);
+ color: #000;
+}
+
+/* Signal Details Content Sections */
+.signal-details-section {
+ margin-bottom: 20px;
+}
+
+.signal-details-section:last-child {
+ margin-bottom: 0;
+}
+
+.signal-details-title {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--text-dim, #666);
+ margin-bottom: 10px;
+ padding-bottom: 6px;
+ border-bottom: 1px solid var(--border-color, #333);
+}
+
+.signal-details-message {
+ font-family: var(--font-mono);
+ font-size: 14px;
+ color: var(--text-primary, #e0e0e0);
+ background: var(--bg-secondary, #252525);
+ padding: 12px 14px;
+ border-radius: 6px;
+ word-break: break-word;
+ line-height: 1.5;
+}
+
+.signal-details-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 12px;
+}
+
+.signal-details-item {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.signal-details-label {
+ font-size: 10px;
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--text-dim, #666);
+}
+
+.signal-details-value {
+ font-family: var(--font-mono);
+ font-size: 13px;
+ color: var(--text-primary, #e0e0e0);
+}
+
+/* Raw data in modal */
+.signal-details-modal .signal-raw-data {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ color: var(--text-secondary, #aaa);
+ background: var(--bg-tertiary, #1a1a1a);
+ padding: 12px;
+ border-radius: 6px;
+ border: 1px solid var(--border-color, #333);
+ white-space: pre-wrap;
+ word-break: break-all;
+ margin: 0;
+ max-height: 200px;
+ overflow-y: auto;
+}
+
+/* Signal assessment panel in modal */
+.signal-details-modal .signal-assessment {
+ background: var(--bg-secondary, #252525);
+ padding: 14px;
+ border-radius: 8px;
+ margin-bottom: 16px;
+}
+
+.signal-details-modal .signal-assessment-summary {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 12px;
+}
+
+.signal-details-modal .signal-assessment-text {
+ font-size: 13px;
+ color: var(--text-secondary, #aaa);
+ line-height: 1.4;
+}
+
+.signal-details-modal .signal-assessment-caveat {
+ font-size: 10px;
+ color: var(--text-dim, #666);
+ font-style: italic;
+ margin-top: 10px;
+ padding-top: 10px;
+ border-top: 1px solid var(--border-color, #333);
+}
+
+/* Signal guess section in modal */
+.signal-details-modal .signal-guess-section {
+ background: var(--bg-secondary, #252525);
+ padding: 14px;
+ border-radius: 8px;
+ margin-bottom: 16px;
+ border: none;
+}
+
+.signal-details-modal .signal-guess-content {
+ margin-top: 8px;
+}
+
+/* Responsive adjustments */
+@media (max-width: 500px) {
+ .signal-details-modal-content {
+ width: 95%;
+ max-height: 90vh;
+ }
+
+ .signal-details-grid {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/static/css/components/signal-timeline.css b/static/css/components/signal-timeline.css
index 2009464..8c1ddcc 100644
--- a/static/css/components/signal-timeline.css
+++ b/static/css/components/signal-timeline.css
@@ -1,577 +1,577 @@
-/**
- * Signal Activity Timeline Component
- * Lightweight visualization for RF signal presence over time
- * Used for TSCM sweeps and investigative analysis
- */
-
-/* ============================================
- TIMELINE CONTAINER
- ============================================ */
-.signal-timeline {
- background: var(--bg-card, #1a1a1a);
- border: 1px solid var(--border-color, #333);
- border-radius: 6px;
- font-family: 'JetBrains Mono', monospace;
-}
-
-.signal-timeline.collapsed .signal-timeline-body {
- display: none;
-}
-
-.signal-timeline.collapsed .signal-timeline-header {
- border-bottom: none;
- margin-bottom: 0;
- padding-bottom: 0;
-}
-
-.signal-timeline-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 10px 12px;
- cursor: pointer;
- user-select: none;
-}
-
-.signal-timeline-header:hover {
- background: rgba(255, 255, 255, 0.02);
-}
-
-.signal-timeline-body {
- padding: 0 12px 12px 12px;
- border-top: 1px solid var(--border-color, #333);
-}
-
-.signal-timeline-collapse-icon {
- margin-right: 8px;
- font-size: 10px;
- transition: transform 0.2s ease;
-}
-
-.signal-timeline.collapsed .signal-timeline-collapse-icon {
- transform: rotate(-90deg);
-}
-
-.signal-timeline-header-stats {
- display: flex;
- gap: 12px;
- font-size: 10px;
- color: var(--text-dim, #666);
-}
-
-.signal-timeline-header-stat {
- display: flex;
- align-items: center;
- gap: 4px;
-}
-
-.signal-timeline-header-stat .stat-value {
- color: var(--text-primary, #fff);
- font-weight: 500;
-}
-
-.signal-timeline-title {
- font-size: 11px;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.05em;
- color: var(--text-secondary, #888);
-}
-
-.signal-timeline-controls {
- display: flex;
- gap: 6px;
- align-items: center;
-}
-
-.signal-timeline-btn {
- background: var(--bg-secondary, #252525);
- border: 1px solid var(--border-color, #333);
- color: var(--text-secondary, #888);
- font-size: 9px;
- padding: 4px 8px;
- border-radius: 3px;
- cursor: pointer;
- transition: all 0.15s ease;
- font-family: inherit;
-}
-
-.signal-timeline-btn:hover {
- background: var(--bg-elevated, #2a2a2a);
- color: var(--text-primary, #fff);
-}
-
-.signal-timeline-btn.active {
- background: var(--accent-cyan, #4a9eff);
- color: #000;
- border-color: var(--accent-cyan, #4a9eff);
-}
-
-/* Time window selector */
-.signal-timeline-window {
- display: flex;
- align-items: center;
- gap: 4px;
- font-size: 9px;
- color: var(--text-dim, #666);
-}
-
-.signal-timeline-window select {
- background: var(--bg-secondary, #252525);
- border: 1px solid var(--border-color, #333);
- color: var(--text-primary, #fff);
- font-size: 9px;
- padding: 3px 6px;
- border-radius: 3px;
- font-family: inherit;
-}
-
-/* ============================================
- TIME AXIS
- ============================================ */
-.signal-timeline-axis {
- display: flex;
- justify-content: space-between;
- padding: 0 80px 0 100px;
- margin-bottom: 8px;
- font-size: 9px;
- color: var(--text-dim, #666);
-}
-
-.signal-timeline-axis-label {
- position: relative;
-}
-
-.signal-timeline-axis-label::before {
- content: '';
- position: absolute;
- top: -4px;
- left: 50%;
- width: 1px;
- height: 4px;
- background: var(--border-color, #333);
-}
-
-/* ============================================
- SWIMLANES
- ============================================ */
-.signal-timeline-lanes {
- display: flex;
- flex-direction: column;
- gap: 3px;
- max-height: 160px;
- overflow-y: auto;
- margin-top: 8px;
-}
-
-.signal-timeline-lanes::-webkit-scrollbar {
- width: 6px;
-}
-
-.signal-timeline-lanes::-webkit-scrollbar-track {
- background: var(--bg-secondary, #252525);
- border-radius: 3px;
-}
-
-.signal-timeline-lanes::-webkit-scrollbar-thumb {
- background: var(--border-color, #444);
- border-radius: 3px;
-}
-
-.signal-timeline-lanes::-webkit-scrollbar-thumb:hover {
- background: var(--text-dim, #666);
-}
-
-.signal-timeline-lane {
- display: flex;
- align-items: stretch;
- min-height: 36px;
- background: var(--bg-secondary, #252525);
- border-radius: 3px;
- overflow: hidden;
-}
-
-.signal-timeline-lane:hover {
- background: var(--bg-elevated, #2a2a2a);
-}
-
-.signal-timeline-lane.expanded {
- min-height: auto;
-}
-
-.signal-timeline-lane.baseline {
- opacity: 0.5;
-}
-
-.signal-timeline-lane.baseline:hover {
- opacity: 0.8;
-}
-
-/* Signal label */
-.signal-timeline-label {
- width: 130px;
- min-width: 130px;
- padding: 6px 8px;
- display: flex;
- flex-direction: column;
- justify-content: center;
- gap: 1px;
- border-right: 1px solid var(--border-color, #333);
- overflow: hidden;
-}
-
-.signal-timeline-freq {
- color: var(--text-primary, #fff);
- font-size: 11px;
- font-weight: 500;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- line-height: 1.2;
-}
-
-.signal-timeline-name {
- color: var(--text-dim, #666);
- font-size: 9px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- line-height: 1.2;
-}
-
-/* Status indicator */
-.signal-timeline-status {
- width: 4px;
- min-width: 4px;
-}
-
-.signal-timeline-status[data-status="new"] {
- background: var(--signal-new, #3b82f6);
-}
-
-.signal-timeline-status[data-status="baseline"] {
- background: var(--signal-baseline, #6b7280);
-}
-
-.signal-timeline-status[data-status="burst"] {
- background: var(--signal-burst, #f59e0b);
-}
-
-.signal-timeline-status[data-status="flagged"] {
- background: var(--signal-emergency, #ef4444);
-}
-
-.signal-timeline-status[data-status="gone"] {
- background: var(--text-dim, #666);
-}
-
-/* ============================================
- TRACK (where bars are drawn)
- ============================================ */
-.signal-timeline-track {
- flex: 1;
- position: relative;
- height: 100%;
- min-height: 36px;
- padding: 4px 8px;
- cursor: pointer;
-}
-
-.signal-timeline-track-bg {
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- display: flex;
- align-items: center;
-}
-
-/* Grid lines */
-.signal-timeline-grid {
- position: absolute;
- top: 0;
- bottom: 0;
- width: 1px;
- background: var(--border-color, #333);
- opacity: 0.3;
-}
-
-/* ============================================
- SIGNAL BARS
- ============================================ */
-.signal-timeline-bar {
- position: absolute;
- top: 50%;
- transform: translateY(-50%);
- height: 16px;
- min-width: 2px;
- border-radius: 2px;
- transition: opacity 0.15s ease;
-}
-
-/* Strength variants (height) */
-.signal-timeline-bar[data-strength="1"] { height: 6px; }
-.signal-timeline-bar[data-strength="2"] { height: 10px; }
-.signal-timeline-bar[data-strength="3"] { height: 14px; }
-.signal-timeline-bar[data-strength="4"] { height: 18px; }
-.signal-timeline-bar[data-strength="5"] { height: 22px; }
-
-/* Status colors */
-.signal-timeline-bar[data-status="new"] {
- background: var(--signal-new, #3b82f6);
- box-shadow: 0 0 6px rgba(59, 130, 246, 0.4);
-}
-
-.signal-timeline-bar[data-status="baseline"] {
- background: var(--signal-baseline, #6b7280);
-}
-
-.signal-timeline-bar[data-status="burst"] {
- background: var(--signal-burst, #f59e0b);
- box-shadow: 0 0 6px rgba(245, 158, 11, 0.4);
-}
-
-.signal-timeline-bar[data-status="flagged"] {
- background: var(--signal-emergency, #ef4444);
- box-shadow: 0 0 8px rgba(239, 68, 68, 0.5);
- animation: flaggedPulse 2s ease-in-out infinite;
-}
-
-@keyframes flaggedPulse {
- 0%, 100% { opacity: 1; }
- 50% { opacity: 0.7; }
-}
-
-.signal-timeline-lane:hover .signal-timeline-bar {
- opacity: 0.9;
-}
-
-/* ============================================
- EXPANDED VIEW (tick marks)
- ============================================ */
-.signal-timeline-ticks {
- display: none;
- position: relative;
- height: 24px;
- margin-top: 4px;
- border-top: 1px solid var(--border-color, #333);
- padding-top: 4px;
-}
-
-.signal-timeline-lane.expanded .signal-timeline-ticks {
- display: block;
-}
-
-.signal-timeline-tick {
- position: absolute;
- bottom: 0;
- width: 1px;
- background: var(--accent-cyan, #4a9eff);
-}
-
-.signal-timeline-tick[data-strength="1"] { height: 4px; }
-.signal-timeline-tick[data-strength="2"] { height: 8px; }
-.signal-timeline-tick[data-strength="3"] { height: 12px; }
-.signal-timeline-tick[data-strength="4"] { height: 16px; }
-.signal-timeline-tick[data-strength="5"] { height: 20px; }
-
-/* ============================================
- ANNOTATIONS
- ============================================ */
-.signal-timeline-annotations {
- margin-top: 6px;
- padding-top: 6px;
- border-top: 1px solid var(--border-color, #333);
- max-height: 60px;
- overflow-y: auto;
-}
-
-.signal-timeline-annotation {
- padding: 3px 6px;
- font-size: 9px;
- margin-bottom: 2px;
-}
-
-.signal-timeline-annotation {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 4px 8px;
- font-size: 10px;
- color: var(--text-secondary, #888);
- background: var(--bg-secondary, #252525);
- border-radius: 3px;
- margin-bottom: 4px;
-}
-
-.signal-timeline-annotation-icon {
- font-size: 12px;
-}
-
-.signal-timeline-annotation[data-type="new"] {
- border-left: 2px solid var(--signal-new, #3b82f6);
-}
-
-.signal-timeline-annotation[data-type="burst"] {
- border-left: 2px solid var(--signal-burst, #f59e0b);
-}
-
-.signal-timeline-annotation[data-type="pattern"] {
- border-left: 2px solid var(--accent-cyan, #4a9eff);
-}
-
-.signal-timeline-annotation[data-type="flagged"] {
- border-left: 2px solid var(--signal-emergency, #ef4444);
- color: var(--signal-emergency, #ef4444);
-}
-
-/* ============================================
- TOOLTIP
- ============================================ */
-.signal-timeline-tooltip {
- position: fixed;
- z-index: 1000;
- background: var(--bg-elevated, #2a2a2a);
- border: 1px solid var(--border-color, #333);
- border-radius: 4px;
- padding: 8px 10px;
- font-size: 10px;
- color: var(--text-primary, #fff);
- pointer-events: none;
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
- max-width: 220px;
-}
-
-.signal-timeline-tooltip-header {
- font-weight: 600;
- margin-bottom: 4px;
- color: var(--accent-cyan, #4a9eff);
-}
-
-.signal-timeline-tooltip-row {
- display: flex;
- justify-content: space-between;
- gap: 12px;
- color: var(--text-secondary, #888);
-}
-
-.signal-timeline-tooltip-row span:last-child {
- color: var(--text-primary, #fff);
-}
-
-/* ============================================
- STATS ROW
- ============================================ */
-.signal-timeline-stats {
- width: 50px;
- min-width: 50px;
- padding: 4px 6px;
- display: flex;
- flex-direction: column;
- justify-content: center;
- align-items: flex-end;
- font-size: 9px;
- color: var(--text-dim, #666);
- border-left: 1px solid var(--border-color, #333);
-}
-
-.signal-timeline-stat-count {
- color: var(--text-primary, #fff);
- font-weight: 500;
-}
-
-.signal-timeline-stat-label {
- font-size: 8px;
- text-transform: uppercase;
- letter-spacing: 0.03em;
-}
-
-/* ============================================
- EMPTY STATE
- ============================================ */
-.signal-timeline-empty {
- text-align: center;
- padding: 30px 20px;
- color: var(--text-dim, #666);
- font-size: 11px;
-}
-
-.signal-timeline-empty-icon {
- font-size: 24px;
- margin-bottom: 8px;
- opacity: 0.5;
-}
-
-/* ============================================
- LEGEND - compact inline version
- ============================================ */
-.signal-timeline-legend {
- display: none; /* Hide by default - status colors are self-explanatory */
-}
-
-.signal-timeline-legend-item {
- display: flex;
- align-items: center;
- gap: 3px;
-}
-
-.signal-timeline-legend-dot {
- width: 6px;
- height: 6px;
- border-radius: 2px;
-}
-
-.signal-timeline-legend-dot.new { background: var(--signal-new, #3b82f6); }
-.signal-timeline-legend-dot.baseline { background: var(--signal-baseline, #6b7280); }
-.signal-timeline-legend-dot.burst { background: var(--signal-burst, #f59e0b); }
-.signal-timeline-legend-dot.flagged { background: var(--signal-emergency, #ef4444); }
-
-/* ============================================
- NOW MARKER
- ============================================ */
-.signal-timeline-now {
- position: absolute;
- top: 0;
- bottom: 0;
- width: 2px;
- background: var(--accent-green, #22c55e);
- z-index: 5;
-}
-
-.signal-timeline-now::after {
- content: 'NOW';
- position: absolute;
- top: -14px;
- left: 50%;
- transform: translateX(-50%);
- font-size: 8px;
- color: var(--accent-green, #22c55e);
- font-weight: 600;
-}
-
-/* ============================================
- MARKER (first seen indicator)
- ============================================ */
-.signal-timeline-marker {
- position: absolute;
- top: 50%;
- transform: translate(-50%, -50%);
- width: 0;
- height: 0;
- border-left: 5px solid transparent;
- border-right: 5px solid transparent;
- border-bottom: 8px solid var(--signal-new, #3b82f6);
- z-index: 4;
-}
-
-.signal-timeline-marker::after {
- content: attr(data-label);
- position: absolute;
- top: 10px;
- left: 50%;
- transform: translateX(-50%);
- font-size: 8px;
- color: var(--signal-new, #3b82f6);
- white-space: nowrap;
-}
+/**
+ * Signal Activity Timeline Component
+ * Lightweight visualization for RF signal presence over time
+ * Used for TSCM sweeps and investigative analysis
+ */
+
+/* ============================================
+ TIMELINE CONTAINER
+ ============================================ */
+.signal-timeline {
+ background: var(--bg-card, #1a1a1a);
+ border: 1px solid var(--border-color, #333);
+ border-radius: 6px;
+ font-family: var(--font-mono);
+}
+
+.signal-timeline.collapsed .signal-timeline-body {
+ display: none;
+}
+
+.signal-timeline.collapsed .signal-timeline-header {
+ border-bottom: none;
+ margin-bottom: 0;
+ padding-bottom: 0;
+}
+
+.signal-timeline-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 12px;
+ cursor: pointer;
+ user-select: none;
+}
+
+.signal-timeline-header:hover {
+ background: rgba(255, 255, 255, 0.02);
+}
+
+.signal-timeline-body {
+ padding: 0 12px 12px 12px;
+ border-top: 1px solid var(--border-color, #333);
+}
+
+.signal-timeline-collapse-icon {
+ margin-right: 8px;
+ font-size: 10px;
+ transition: transform 0.2s ease;
+}
+
+.signal-timeline.collapsed .signal-timeline-collapse-icon {
+ transform: rotate(-90deg);
+}
+
+.signal-timeline-header-stats {
+ display: flex;
+ gap: 12px;
+ font-size: 10px;
+ color: var(--text-dim, #666);
+}
+
+.signal-timeline-header-stat {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.signal-timeline-header-stat .stat-value {
+ color: var(--text-primary, #fff);
+ font-weight: 500;
+}
+
+.signal-timeline-title {
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--text-secondary, #888);
+}
+
+.signal-timeline-controls {
+ display: flex;
+ gap: 6px;
+ align-items: center;
+}
+
+.signal-timeline-btn {
+ background: var(--bg-secondary, #252525);
+ border: 1px solid var(--border-color, #333);
+ color: var(--text-secondary, #888);
+ font-size: 9px;
+ padding: 4px 8px;
+ border-radius: 3px;
+ cursor: pointer;
+ transition: all 0.15s ease;
+ font-family: inherit;
+}
+
+.signal-timeline-btn:hover {
+ background: var(--bg-elevated, #2a2a2a);
+ color: var(--text-primary, #fff);
+}
+
+.signal-timeline-btn.active {
+ background: var(--accent-cyan, #4a9eff);
+ color: #000;
+ border-color: var(--accent-cyan, #4a9eff);
+}
+
+/* Time window selector */
+.signal-timeline-window {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 9px;
+ color: var(--text-dim, #666);
+}
+
+.signal-timeline-window select {
+ background: var(--bg-secondary, #252525);
+ border: 1px solid var(--border-color, #333);
+ color: var(--text-primary, #fff);
+ font-size: 9px;
+ padding: 3px 6px;
+ border-radius: 3px;
+ font-family: inherit;
+}
+
+/* ============================================
+ TIME AXIS
+ ============================================ */
+.signal-timeline-axis {
+ display: flex;
+ justify-content: space-between;
+ padding: 0 80px 0 100px;
+ margin-bottom: 8px;
+ font-size: 9px;
+ color: var(--text-dim, #666);
+}
+
+.signal-timeline-axis-label {
+ position: relative;
+}
+
+.signal-timeline-axis-label::before {
+ content: '';
+ position: absolute;
+ top: -4px;
+ left: 50%;
+ width: 1px;
+ height: 4px;
+ background: var(--border-color, #333);
+}
+
+/* ============================================
+ SWIMLANES
+ ============================================ */
+.signal-timeline-lanes {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ max-height: 160px;
+ overflow-y: auto;
+ margin-top: 8px;
+}
+
+.signal-timeline-lanes::-webkit-scrollbar {
+ width: 6px;
+}
+
+.signal-timeline-lanes::-webkit-scrollbar-track {
+ background: var(--bg-secondary, #252525);
+ border-radius: 3px;
+}
+
+.signal-timeline-lanes::-webkit-scrollbar-thumb {
+ background: var(--border-color, #444);
+ border-radius: 3px;
+}
+
+.signal-timeline-lanes::-webkit-scrollbar-thumb:hover {
+ background: var(--text-dim, #666);
+}
+
+.signal-timeline-lane {
+ display: flex;
+ align-items: stretch;
+ min-height: 36px;
+ background: var(--bg-secondary, #252525);
+ border-radius: 3px;
+ overflow: hidden;
+}
+
+.signal-timeline-lane:hover {
+ background: var(--bg-elevated, #2a2a2a);
+}
+
+.signal-timeline-lane.expanded {
+ min-height: auto;
+}
+
+.signal-timeline-lane.baseline {
+ opacity: 0.5;
+}
+
+.signal-timeline-lane.baseline:hover {
+ opacity: 0.8;
+}
+
+/* Signal label */
+.signal-timeline-label {
+ width: 130px;
+ min-width: 130px;
+ padding: 6px 8px;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ gap: 1px;
+ border-right: 1px solid var(--border-color, #333);
+ overflow: hidden;
+}
+
+.signal-timeline-freq {
+ color: var(--text-primary, #fff);
+ font-size: 11px;
+ font-weight: 500;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ line-height: 1.2;
+}
+
+.signal-timeline-name {
+ color: var(--text-dim, #666);
+ font-size: 9px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ line-height: 1.2;
+}
+
+/* Status indicator */
+.signal-timeline-status {
+ width: 4px;
+ min-width: 4px;
+}
+
+.signal-timeline-status[data-status="new"] {
+ background: var(--signal-new, #3b82f6);
+}
+
+.signal-timeline-status[data-status="baseline"] {
+ background: var(--signal-baseline, #6b7280);
+}
+
+.signal-timeline-status[data-status="burst"] {
+ background: var(--signal-burst, #f59e0b);
+}
+
+.signal-timeline-status[data-status="flagged"] {
+ background: var(--signal-emergency, #ef4444);
+}
+
+.signal-timeline-status[data-status="gone"] {
+ background: var(--text-dim, #666);
+}
+
+/* ============================================
+ TRACK (where bars are drawn)
+ ============================================ */
+.signal-timeline-track {
+ flex: 1;
+ position: relative;
+ height: 100%;
+ min-height: 36px;
+ padding: 4px 8px;
+ cursor: pointer;
+}
+
+.signal-timeline-track-bg {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ display: flex;
+ align-items: center;
+}
+
+/* Grid lines */
+.signal-timeline-grid {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ width: 1px;
+ background: var(--border-color, #333);
+ opacity: 0.3;
+}
+
+/* ============================================
+ SIGNAL BARS
+ ============================================ */
+.signal-timeline-bar {
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%);
+ height: 16px;
+ min-width: 2px;
+ border-radius: 2px;
+ transition: opacity 0.15s ease;
+}
+
+/* Strength variants (height) */
+.signal-timeline-bar[data-strength="1"] { height: 6px; }
+.signal-timeline-bar[data-strength="2"] { height: 10px; }
+.signal-timeline-bar[data-strength="3"] { height: 14px; }
+.signal-timeline-bar[data-strength="4"] { height: 18px; }
+.signal-timeline-bar[data-strength="5"] { height: 22px; }
+
+/* Status colors */
+.signal-timeline-bar[data-status="new"] {
+ background: var(--signal-new, #3b82f6);
+ box-shadow: 0 0 6px rgba(59, 130, 246, 0.4);
+}
+
+.signal-timeline-bar[data-status="baseline"] {
+ background: var(--signal-baseline, #6b7280);
+}
+
+.signal-timeline-bar[data-status="burst"] {
+ background: var(--signal-burst, #f59e0b);
+ box-shadow: 0 0 6px rgba(245, 158, 11, 0.4);
+}
+
+.signal-timeline-bar[data-status="flagged"] {
+ background: var(--signal-emergency, #ef4444);
+ box-shadow: 0 0 8px rgba(239, 68, 68, 0.5);
+ animation: flaggedPulse 2s ease-in-out infinite;
+}
+
+@keyframes flaggedPulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.7; }
+}
+
+.signal-timeline-lane:hover .signal-timeline-bar {
+ opacity: 0.9;
+}
+
+/* ============================================
+ EXPANDED VIEW (tick marks)
+ ============================================ */
+.signal-timeline-ticks {
+ display: none;
+ position: relative;
+ height: 24px;
+ margin-top: 4px;
+ border-top: 1px solid var(--border-color, #333);
+ padding-top: 4px;
+}
+
+.signal-timeline-lane.expanded .signal-timeline-ticks {
+ display: block;
+}
+
+.signal-timeline-tick {
+ position: absolute;
+ bottom: 0;
+ width: 1px;
+ background: var(--accent-cyan, #4a9eff);
+}
+
+.signal-timeline-tick[data-strength="1"] { height: 4px; }
+.signal-timeline-tick[data-strength="2"] { height: 8px; }
+.signal-timeline-tick[data-strength="3"] { height: 12px; }
+.signal-timeline-tick[data-strength="4"] { height: 16px; }
+.signal-timeline-tick[data-strength="5"] { height: 20px; }
+
+/* ============================================
+ ANNOTATIONS
+ ============================================ */
+.signal-timeline-annotations {
+ margin-top: 6px;
+ padding-top: 6px;
+ border-top: 1px solid var(--border-color, #333);
+ max-height: 60px;
+ overflow-y: auto;
+}
+
+.signal-timeline-annotation {
+ padding: 3px 6px;
+ font-size: 9px;
+ margin-bottom: 2px;
+}
+
+.signal-timeline-annotation {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 4px 8px;
+ font-size: 10px;
+ color: var(--text-secondary, #888);
+ background: var(--bg-secondary, #252525);
+ border-radius: 3px;
+ margin-bottom: 4px;
+}
+
+.signal-timeline-annotation-icon {
+ font-size: 12px;
+}
+
+.signal-timeline-annotation[data-type="new"] {
+ border-left: 2px solid var(--signal-new, #3b82f6);
+}
+
+.signal-timeline-annotation[data-type="burst"] {
+ border-left: 2px solid var(--signal-burst, #f59e0b);
+}
+
+.signal-timeline-annotation[data-type="pattern"] {
+ border-left: 2px solid var(--accent-cyan, #4a9eff);
+}
+
+.signal-timeline-annotation[data-type="flagged"] {
+ border-left: 2px solid var(--signal-emergency, #ef4444);
+ color: var(--signal-emergency, #ef4444);
+}
+
+/* ============================================
+ TOOLTIP
+ ============================================ */
+.signal-timeline-tooltip {
+ position: fixed;
+ z-index: 1000;
+ background: var(--bg-elevated, #2a2a2a);
+ border: 1px solid var(--border-color, #333);
+ border-radius: 4px;
+ padding: 8px 10px;
+ font-size: 10px;
+ color: var(--text-primary, #fff);
+ pointer-events: none;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+ max-width: 220px;
+}
+
+.signal-timeline-tooltip-header {
+ font-weight: 600;
+ margin-bottom: 4px;
+ color: var(--accent-cyan, #4a9eff);
+}
+
+.signal-timeline-tooltip-row {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ color: var(--text-secondary, #888);
+}
+
+.signal-timeline-tooltip-row span:last-child {
+ color: var(--text-primary, #fff);
+}
+
+/* ============================================
+ STATS ROW
+ ============================================ */
+.signal-timeline-stats {
+ width: 50px;
+ min-width: 50px;
+ padding: 4px 6px;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: flex-end;
+ font-size: 9px;
+ color: var(--text-dim, #666);
+ border-left: 1px solid var(--border-color, #333);
+}
+
+.signal-timeline-stat-count {
+ color: var(--text-primary, #fff);
+ font-weight: 500;
+}
+
+.signal-timeline-stat-label {
+ font-size: 8px;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+}
+
+/* ============================================
+ EMPTY STATE
+ ============================================ */
+.signal-timeline-empty {
+ text-align: center;
+ padding: 30px 20px;
+ color: var(--text-dim, #666);
+ font-size: 11px;
+}
+
+.signal-timeline-empty-icon {
+ font-size: 24px;
+ margin-bottom: 8px;
+ opacity: 0.5;
+}
+
+/* ============================================
+ LEGEND - compact inline version
+ ============================================ */
+.signal-timeline-legend {
+ display: none; /* Hide by default - status colors are self-explanatory */
+}
+
+.signal-timeline-legend-item {
+ display: flex;
+ align-items: center;
+ gap: 3px;
+}
+
+.signal-timeline-legend-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 2px;
+}
+
+.signal-timeline-legend-dot.new { background: var(--signal-new, #3b82f6); }
+.signal-timeline-legend-dot.baseline { background: var(--signal-baseline, #6b7280); }
+.signal-timeline-legend-dot.burst { background: var(--signal-burst, #f59e0b); }
+.signal-timeline-legend-dot.flagged { background: var(--signal-emergency, #ef4444); }
+
+/* ============================================
+ NOW MARKER
+ ============================================ */
+.signal-timeline-now {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ width: 2px;
+ background: var(--accent-green, #22c55e);
+ z-index: 5;
+}
+
+.signal-timeline-now::after {
+ content: 'NOW';
+ position: absolute;
+ top: -14px;
+ left: 50%;
+ transform: translateX(-50%);
+ font-size: 8px;
+ color: var(--accent-green, #22c55e);
+ font-weight: 600;
+}
+
+/* ============================================
+ MARKER (first seen indicator)
+ ============================================ */
+.signal-timeline-marker {
+ position: absolute;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ width: 0;
+ height: 0;
+ border-left: 5px solid transparent;
+ border-right: 5px solid transparent;
+ border-bottom: 8px solid var(--signal-new, #3b82f6);
+ z-index: 4;
+}
+
+.signal-timeline-marker::after {
+ content: attr(data-label);
+ position: absolute;
+ top: 10px;
+ left: 50%;
+ transform: translateX(-50%);
+ font-size: 8px;
+ color: var(--signal-new, #3b82f6);
+ white-space: nowrap;
+}
diff --git a/static/css/components/toast.css b/static/css/components/toast.css
index 31b9da4..c4308ac 100644
--- a/static/css/components/toast.css
+++ b/static/css/components/toast.css
@@ -1,626 +1,626 @@
-/**
- * Toast Notification System
- * Reusable toast notifications for update alerts and other messages
- */
-
-/* ============================================
- TOAST CONTAINER
- ============================================ */
-#toastContainer {
- position: fixed;
- bottom: 20px;
- right: 20px;
- z-index: 10001;
- display: flex;
- flex-direction: column;
- gap: 12px;
- pointer-events: none;
-}
-
-#toastContainer > * {
- pointer-events: auto;
-}
-
-/* ============================================
- UPDATE TOAST
- ============================================ */
-.update-toast {
- display: flex;
- background: var(--bg-card, #121620);
- border: 1px solid var(--border-color, #1f2937);
- border-radius: 8px;
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
- max-width: 340px;
- overflow: hidden;
- opacity: 0;
- transform: translateX(100%);
- transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
-}
-
-.update-toast.show {
- opacity: 1;
- transform: translateX(0);
-}
-
-.update-toast-indicator {
- width: 4px;
- background: var(--accent-green, #22c55e);
- flex-shrink: 0;
-}
-
-.update-toast-content {
- flex: 1;
- padding: 14px 16px;
-}
-
-.update-toast-header {
- display: flex;
- align-items: center;
- gap: 8px;
- margin-bottom: 8px;
-}
-
-.update-toast-icon {
- color: var(--accent-green, #22c55e);
- display: flex;
- align-items: center;
-}
-
-.update-toast-icon svg {
- width: 18px;
- height: 18px;
-}
-
-.update-toast-title {
- font-size: 13px;
- font-weight: 600;
- color: var(--text-primary, #e8eaed);
- flex: 1;
-}
-
-.update-toast-close {
- background: none;
- border: none;
- color: var(--text-dim, #4b5563);
- font-size: 20px;
- line-height: 1;
- cursor: pointer;
- padding: 0;
- margin: -4px;
- transition: color 0.15s;
-}
-
-.update-toast-close:hover {
- color: var(--text-secondary, #9ca3af);
-}
-
-.update-toast-body {
- font-size: 12px;
- color: var(--text-secondary, #9ca3af);
- margin-bottom: 12px;
-}
-
-.update-toast-body strong {
- color: var(--accent-cyan, #4a9eff);
-}
-
-.update-toast-actions {
- display: flex;
- gap: 8px;
-}
-
-.update-toast-btn {
- font-family: inherit;
- font-size: 11px;
- font-weight: 500;
- padding: 6px 12px;
- border-radius: 4px;
- border: none;
- cursor: pointer;
- transition: all 0.15s;
-}
-
-.update-toast-btn-primary {
- background: var(--accent-green, #22c55e);
- color: #000;
-}
-
-.update-toast-btn-primary:hover {
- background: #34d673;
-}
-
-.update-toast-btn-secondary {
- background: var(--bg-secondary, #0f1218);
- color: var(--text-secondary, #9ca3af);
- border: 1px solid var(--border-color, #1f2937);
-}
-
-.update-toast-btn-secondary:hover {
- background: var(--bg-tertiary, #151a23);
- border-color: var(--border-light, #374151);
-}
-
-/* ============================================
- UPDATE MODAL
- ============================================ */
-.update-modal-overlay {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background: rgba(0, 0, 0, 0.7);
- backdrop-filter: blur(4px);
- z-index: 10002;
- display: flex;
- align-items: center;
- justify-content: center;
- opacity: 0;
- visibility: hidden;
- transition: all 0.2s ease;
-}
-
-.update-modal-overlay.show {
- opacity: 1;
- visibility: visible;
-}
-
-.update-modal {
- background: var(--bg-card, #121620);
- border: 1px solid var(--border-color, #1f2937);
- border-radius: 12px;
- width: 90%;
- max-width: 520px;
- max-height: 85vh;
- display: flex;
- flex-direction: column;
- box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
- transform: scale(0.95);
- transition: transform 0.2s ease;
-}
-
-.update-modal-overlay.show .update-modal {
- transform: scale(1);
-}
-
-.update-modal-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 16px 20px;
- border-bottom: 1px solid var(--border-color, #1f2937);
-}
-
-.update-modal-title {
- display: flex;
- align-items: center;
- gap: 10px;
- font-size: 16px;
- font-weight: 600;
- color: var(--text-primary, #e8eaed);
-}
-
-.update-modal-icon {
- color: var(--accent-green, #22c55e);
- display: flex;
-}
-
-.update-modal-icon svg {
- width: 22px;
- height: 22px;
-}
-
-.update-modal-close {
- background: none;
- border: none;
- color: var(--text-dim, #4b5563);
- font-size: 24px;
- line-height: 1;
- cursor: pointer;
- padding: 4px;
- transition: color 0.15s;
-}
-
-.update-modal-close:hover {
- color: var(--accent-red, #ef4444);
-}
-
-.update-modal-body {
- padding: 20px;
- overflow-y: auto;
- flex: 1;
-}
-
-/* Version Info */
-.update-version-info {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 16px;
- padding: 16px;
- background: var(--bg-secondary, #0f1218);
- border-radius: 8px;
- margin-bottom: 20px;
-}
-
-.update-version-current,
-.update-version-latest {
- text-align: center;
-}
-
-.update-version-label {
- display: block;
- font-size: 10px;
- font-weight: 500;
- text-transform: uppercase;
- letter-spacing: 0.05em;
- color: var(--text-dim, #4b5563);
- margin-bottom: 4px;
-}
-
-.update-version-value {
- font-family: 'JetBrains Mono', monospace;
- font-size: 18px;
- font-weight: 600;
- color: var(--text-secondary, #9ca3af);
-}
-
-.update-version-new {
- color: var(--accent-green, #22c55e);
-}
-
-.update-version-arrow {
- color: var(--text-dim, #4b5563);
-}
-
-.update-version-arrow svg {
- width: 20px;
- height: 20px;
-}
-
-/* Sections */
-.update-section {
- margin-bottom: 20px;
-}
-
-.update-section-title {
- font-size: 11px;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.05em;
- color: var(--text-dim, #4b5563);
- margin-bottom: 10px;
-}
-
-.update-release-notes {
- font-size: 13px;
- color: var(--text-secondary, #9ca3af);
- background: var(--bg-secondary, #0f1218);
- border: 1px solid var(--border-color, #1f2937);
- border-radius: 6px;
- padding: 14px;
- max-height: 200px;
- overflow-y: auto;
- line-height: 1.6;
-}
-
-.update-release-notes h2,
-.update-release-notes h3,
-.update-release-notes h4 {
- color: var(--text-primary, #e8eaed);
- margin: 16px 0 8px 0;
- font-size: 14px;
-}
-
-.update-release-notes h2:first-child,
-.update-release-notes h3:first-child,
-.update-release-notes h4:first-child {
- margin-top: 0;
-}
-
-.update-release-notes ul {
- margin: 8px 0;
- padding-left: 20px;
-}
-
-.update-release-notes li {
- margin: 4px 0;
-}
-
-.update-release-notes code {
- font-family: 'JetBrains Mono', monospace;
- font-size: 11px;
- background: var(--bg-tertiary, #151a23);
- padding: 2px 6px;
- border-radius: 3px;
- color: var(--accent-cyan, #4a9eff);
-}
-
-.update-release-notes p {
- margin: 8px 0;
-}
-
-/* Warning */
-.update-warning {
- display: flex;
- gap: 12px;
- padding: 14px;
- background: rgba(245, 158, 11, 0.1);
- border: 1px solid rgba(245, 158, 11, 0.3);
- border-radius: 6px;
- margin-bottom: 16px;
-}
-
-.update-warning-icon {
- color: var(--accent-orange, #f59e0b);
- flex-shrink: 0;
-}
-
-.update-warning-icon svg {
- width: 20px;
- height: 20px;
-}
-
-.update-warning-text {
- font-size: 12px;
- color: var(--text-secondary, #9ca3af);
-}
-
-.update-warning-text strong {
- display: block;
- color: var(--accent-orange, #f59e0b);
- margin-bottom: 4px;
-}
-
-.update-warning-text p {
- margin: 0;
-}
-
-/* Options */
-.update-options {
- margin-bottom: 16px;
-}
-
-.update-option {
- display: flex;
- align-items: center;
- gap: 10px;
- font-size: 12px;
- color: var(--text-secondary, #9ca3af);
- cursor: pointer;
-}
-
-.update-option input[type="checkbox"] {
- width: 16px;
- height: 16px;
- accent-color: var(--accent-cyan, #4a9eff);
-}
-
-/* Progress */
-.update-progress {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 12px;
- padding: 20px;
- font-size: 13px;
- color: var(--text-secondary, #9ca3af);
-}
-
-.update-progress-spinner {
- width: 20px;
- height: 20px;
- border: 2px solid var(--border-color, #1f2937);
- border-top-color: var(--accent-cyan, #4a9eff);
- border-radius: 50%;
- animation: updateSpin 0.8s linear infinite;
-}
-
-@keyframes updateSpin {
- to { transform: rotate(360deg); }
-}
-
-/* Results */
-.update-result {
- display: flex;
- gap: 12px;
- padding: 14px;
- border-radius: 6px;
- margin-top: 16px;
-}
-
-.update-result-icon {
- flex-shrink: 0;
-}
-
-.update-result-icon svg {
- width: 20px;
- height: 20px;
-}
-
-.update-result-text {
- font-size: 12px;
- line-height: 1.5;
-}
-
-.update-result-text code {
- font-family: 'JetBrains Mono', monospace;
- font-size: 11px;
- background: rgba(0, 0, 0, 0.2);
- padding: 2px 6px;
- border-radius: 3px;
- display: inline-block;
- word-break: break-all;
-}
-
-.update-result-success {
- background: rgba(34, 197, 94, 0.1);
- border: 1px solid rgba(34, 197, 94, 0.3);
-}
-
-.update-result-success .update-result-icon {
- color: var(--accent-green, #22c55e);
-}
-
-.update-result-success .update-result-text {
- color: var(--accent-green, #22c55e);
-}
-
-.update-result-error {
- background: rgba(239, 68, 68, 0.1);
- border: 1px solid rgba(239, 68, 68, 0.3);
-}
-
-.update-result-error .update-result-icon {
- color: var(--accent-red, #ef4444);
-}
-
-.update-result-error .update-result-text {
- color: var(--text-secondary, #9ca3af);
-}
-
-.update-result-error .update-result-text strong {
- color: var(--accent-red, #ef4444);
-}
-
-.update-result-warning {
- background: rgba(245, 158, 11, 0.1);
- border: 1px solid rgba(245, 158, 11, 0.3);
-}
-
-.update-result-warning .update-result-icon {
- color: var(--accent-orange, #f59e0b);
-}
-
-.update-result-warning .update-result-text {
- color: var(--text-secondary, #9ca3af);
-}
-
-.update-result-warning .update-result-text strong {
- color: var(--accent-orange, #f59e0b);
-}
-
-.update-result-info {
- background: rgba(74, 158, 255, 0.1);
- border: 1px solid rgba(74, 158, 255, 0.3);
-}
-
-.update-result-info .update-result-icon {
- color: var(--accent-cyan, #4a9eff);
-}
-
-.update-result-info .update-result-text {
- color: var(--text-secondary, #9ca3af);
-}
-
-/* Footer */
-.update-modal-footer {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 14px 20px;
- border-top: 1px solid var(--border-color, #1f2937);
- background: var(--bg-secondary, #0f1218);
- border-radius: 0 0 12px 12px;
-}
-
-.update-modal-link {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- font-size: 12px;
- color: var(--text-dim, #4b5563);
- text-decoration: none;
- transition: color 0.15s;
-}
-
-.update-modal-link:hover {
- color: var(--accent-cyan, #4a9eff);
-}
-
-.update-modal-actions {
- display: flex;
- gap: 10px;
-}
-
-.update-modal-btn {
- font-family: inherit;
- font-size: 12px;
- font-weight: 500;
- padding: 8px 16px;
- border-radius: 6px;
- border: none;
- cursor: pointer;
- transition: all 0.15s;
-}
-
-.update-modal-btn:disabled {
- opacity: 0.5;
- cursor: not-allowed;
-}
-
-.update-modal-btn-primary {
- background: var(--accent-green, #22c55e);
- color: #000;
-}
-
-.update-modal-btn-primary:hover:not(:disabled) {
- background: #34d673;
-}
-
-.update-modal-btn-secondary {
- background: var(--bg-tertiary, #151a23);
- color: var(--text-secondary, #9ca3af);
- border: 1px solid var(--border-color, #1f2937);
-}
-
-.update-modal-btn-secondary:hover:not(:disabled) {
- background: var(--bg-elevated, #1a202c);
- border-color: var(--border-light, #374151);
-}
-
-/* ============================================
- RESPONSIVE
- ============================================ */
-@media (max-width: 480px) {
- #toastContainer {
- bottom: 10px;
- right: 10px;
- left: 10px;
- }
-
- .update-toast {
- max-width: none;
- }
-
- .update-modal {
- width: 95%;
- max-height: 90vh;
- }
-
- .update-version-info {
- flex-direction: column;
- gap: 10px;
- }
-
- .update-version-arrow {
- transform: rotate(90deg);
- }
-
- .update-modal-footer {
- flex-direction: column;
- gap: 12px;
- }
-
- .update-modal-link {
- order: 2;
- }
-
- .update-modal-actions {
- width: 100%;
- }
-
- .update-modal-btn {
- flex: 1;
- }
-}
+/**
+ * Toast Notification System
+ * Reusable toast notifications for update alerts and other messages
+ */
+
+/* ============================================
+ TOAST CONTAINER
+ ============================================ */
+#toastContainer {
+ position: fixed;
+ bottom: 20px;
+ right: 20px;
+ z-index: 10001;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ pointer-events: none;
+}
+
+#toastContainer > * {
+ pointer-events: auto;
+}
+
+/* ============================================
+ UPDATE TOAST
+ ============================================ */
+.update-toast {
+ display: flex;
+ background: var(--bg-card, #121620);
+ border: 1px solid var(--border-color, #1f2937);
+ border-radius: 8px;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
+ max-width: 340px;
+ overflow: hidden;
+ opacity: 0;
+ transform: translateX(100%);
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+.update-toast.show {
+ opacity: 1;
+ transform: translateX(0);
+}
+
+.update-toast-indicator {
+ width: 4px;
+ background: var(--accent-green, #22c55e);
+ flex-shrink: 0;
+}
+
+.update-toast-content {
+ flex: 1;
+ padding: 14px 16px;
+}
+
+.update-toast-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 8px;
+}
+
+.update-toast-icon {
+ color: var(--accent-green, #22c55e);
+ display: flex;
+ align-items: center;
+}
+
+.update-toast-icon svg {
+ width: 18px;
+ height: 18px;
+}
+
+.update-toast-title {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-primary, #e8eaed);
+ flex: 1;
+}
+
+.update-toast-close {
+ background: none;
+ border: none;
+ color: var(--text-dim, #4b5563);
+ font-size: 20px;
+ line-height: 1;
+ cursor: pointer;
+ padding: 0;
+ margin: -4px;
+ transition: color 0.15s;
+}
+
+.update-toast-close:hover {
+ color: var(--text-secondary, #9ca3af);
+}
+
+.update-toast-body {
+ font-size: 12px;
+ color: var(--text-secondary, #9ca3af);
+ margin-bottom: 12px;
+}
+
+.update-toast-body strong {
+ color: var(--accent-cyan, #4a9eff);
+}
+
+.update-toast-actions {
+ display: flex;
+ gap: 8px;
+}
+
+.update-toast-btn {
+ font-family: inherit;
+ font-size: 11px;
+ font-weight: 500;
+ padding: 6px 12px;
+ border-radius: 4px;
+ border: none;
+ cursor: pointer;
+ transition: all 0.15s;
+}
+
+.update-toast-btn-primary {
+ background: var(--accent-green, #22c55e);
+ color: #000;
+}
+
+.update-toast-btn-primary:hover {
+ background: #34d673;
+}
+
+.update-toast-btn-secondary {
+ background: var(--bg-secondary, #0f1218);
+ color: var(--text-secondary, #9ca3af);
+ border: 1px solid var(--border-color, #1f2937);
+}
+
+.update-toast-btn-secondary:hover {
+ background: var(--bg-tertiary, #151a23);
+ border-color: var(--border-light, #374151);
+}
+
+/* ============================================
+ UPDATE MODAL
+ ============================================ */
+.update-modal-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.7);
+ backdrop-filter: blur(4px);
+ z-index: 10002;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 0;
+ visibility: hidden;
+ transition: all 0.2s ease;
+}
+
+.update-modal-overlay.show {
+ opacity: 1;
+ visibility: visible;
+}
+
+.update-modal {
+ background: var(--bg-card, #121620);
+ border: 1px solid var(--border-color, #1f2937);
+ border-radius: 12px;
+ width: 90%;
+ max-width: 520px;
+ max-height: 85vh;
+ display: flex;
+ flex-direction: column;
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
+ transform: scale(0.95);
+ transition: transform 0.2s ease;
+}
+
+.update-modal-overlay.show .update-modal {
+ transform: scale(1);
+}
+
+.update-modal-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 16px 20px;
+ border-bottom: 1px solid var(--border-color, #1f2937);
+}
+
+.update-modal-title {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--text-primary, #e8eaed);
+}
+
+.update-modal-icon {
+ color: var(--accent-green, #22c55e);
+ display: flex;
+}
+
+.update-modal-icon svg {
+ width: 22px;
+ height: 22px;
+}
+
+.update-modal-close {
+ background: none;
+ border: none;
+ color: var(--text-dim, #4b5563);
+ font-size: 24px;
+ line-height: 1;
+ cursor: pointer;
+ padding: 4px;
+ transition: color 0.15s;
+}
+
+.update-modal-close:hover {
+ color: var(--accent-red, #ef4444);
+}
+
+.update-modal-body {
+ padding: 20px;
+ overflow-y: auto;
+ flex: 1;
+}
+
+/* Version Info */
+.update-version-info {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 16px;
+ padding: 16px;
+ background: var(--bg-secondary, #0f1218);
+ border-radius: 8px;
+ margin-bottom: 20px;
+}
+
+.update-version-current,
+.update-version-latest {
+ text-align: center;
+}
+
+.update-version-label {
+ display: block;
+ font-size: 10px;
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--text-dim, #4b5563);
+ margin-bottom: 4px;
+}
+
+.update-version-value {
+ font-family: var(--font-mono);
+ font-size: 18px;
+ font-weight: 600;
+ color: var(--text-secondary, #9ca3af);
+}
+
+.update-version-new {
+ color: var(--accent-green, #22c55e);
+}
+
+.update-version-arrow {
+ color: var(--text-dim, #4b5563);
+}
+
+.update-version-arrow svg {
+ width: 20px;
+ height: 20px;
+}
+
+/* Sections */
+.update-section {
+ margin-bottom: 20px;
+}
+
+.update-section-title {
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--text-dim, #4b5563);
+ margin-bottom: 10px;
+}
+
+.update-release-notes {
+ font-size: 13px;
+ color: var(--text-secondary, #9ca3af);
+ background: var(--bg-secondary, #0f1218);
+ border: 1px solid var(--border-color, #1f2937);
+ border-radius: 6px;
+ padding: 14px;
+ max-height: 200px;
+ overflow-y: auto;
+ line-height: 1.6;
+}
+
+.update-release-notes h2,
+.update-release-notes h3,
+.update-release-notes h4 {
+ color: var(--text-primary, #e8eaed);
+ margin: 16px 0 8px 0;
+ font-size: 14px;
+}
+
+.update-release-notes h2:first-child,
+.update-release-notes h3:first-child,
+.update-release-notes h4:first-child {
+ margin-top: 0;
+}
+
+.update-release-notes ul {
+ margin: 8px 0;
+ padding-left: 20px;
+}
+
+.update-release-notes li {
+ margin: 4px 0;
+}
+
+.update-release-notes code {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ background: var(--bg-tertiary, #151a23);
+ padding: 2px 6px;
+ border-radius: 3px;
+ color: var(--accent-cyan, #4a9eff);
+}
+
+.update-release-notes p {
+ margin: 8px 0;
+}
+
+/* Warning */
+.update-warning {
+ display: flex;
+ gap: 12px;
+ padding: 14px;
+ background: rgba(245, 158, 11, 0.1);
+ border: 1px solid rgba(245, 158, 11, 0.3);
+ border-radius: 6px;
+ margin-bottom: 16px;
+}
+
+.update-warning-icon {
+ color: var(--accent-orange, #f59e0b);
+ flex-shrink: 0;
+}
+
+.update-warning-icon svg {
+ width: 20px;
+ height: 20px;
+}
+
+.update-warning-text {
+ font-size: 12px;
+ color: var(--text-secondary, #9ca3af);
+}
+
+.update-warning-text strong {
+ display: block;
+ color: var(--accent-orange, #f59e0b);
+ margin-bottom: 4px;
+}
+
+.update-warning-text p {
+ margin: 0;
+}
+
+/* Options */
+.update-options {
+ margin-bottom: 16px;
+}
+
+.update-option {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font-size: 12px;
+ color: var(--text-secondary, #9ca3af);
+ cursor: pointer;
+}
+
+.update-option input[type="checkbox"] {
+ width: 16px;
+ height: 16px;
+ accent-color: var(--accent-cyan, #4a9eff);
+}
+
+/* Progress */
+.update-progress {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ padding: 20px;
+ font-size: 13px;
+ color: var(--text-secondary, #9ca3af);
+}
+
+.update-progress-spinner {
+ width: 20px;
+ height: 20px;
+ border: 2px solid var(--border-color, #1f2937);
+ border-top-color: var(--accent-cyan, #4a9eff);
+ border-radius: 50%;
+ animation: updateSpin 0.8s linear infinite;
+}
+
+@keyframes updateSpin {
+ to { transform: rotate(360deg); }
+}
+
+/* Results */
+.update-result {
+ display: flex;
+ gap: 12px;
+ padding: 14px;
+ border-radius: 6px;
+ margin-top: 16px;
+}
+
+.update-result-icon {
+ flex-shrink: 0;
+}
+
+.update-result-icon svg {
+ width: 20px;
+ height: 20px;
+}
+
+.update-result-text {
+ font-size: 12px;
+ line-height: 1.5;
+}
+
+.update-result-text code {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ background: rgba(0, 0, 0, 0.2);
+ padding: 2px 6px;
+ border-radius: 3px;
+ display: inline-block;
+ word-break: break-all;
+}
+
+.update-result-success {
+ background: rgba(34, 197, 94, 0.1);
+ border: 1px solid rgba(34, 197, 94, 0.3);
+}
+
+.update-result-success .update-result-icon {
+ color: var(--accent-green, #22c55e);
+}
+
+.update-result-success .update-result-text {
+ color: var(--accent-green, #22c55e);
+}
+
+.update-result-error {
+ background: rgba(239, 68, 68, 0.1);
+ border: 1px solid rgba(239, 68, 68, 0.3);
+}
+
+.update-result-error .update-result-icon {
+ color: var(--accent-red, #ef4444);
+}
+
+.update-result-error .update-result-text {
+ color: var(--text-secondary, #9ca3af);
+}
+
+.update-result-error .update-result-text strong {
+ color: var(--accent-red, #ef4444);
+}
+
+.update-result-warning {
+ background: rgba(245, 158, 11, 0.1);
+ border: 1px solid rgba(245, 158, 11, 0.3);
+}
+
+.update-result-warning .update-result-icon {
+ color: var(--accent-orange, #f59e0b);
+}
+
+.update-result-warning .update-result-text {
+ color: var(--text-secondary, #9ca3af);
+}
+
+.update-result-warning .update-result-text strong {
+ color: var(--accent-orange, #f59e0b);
+}
+
+.update-result-info {
+ background: rgba(74, 158, 255, 0.1);
+ border: 1px solid rgba(74, 158, 255, 0.3);
+}
+
+.update-result-info .update-result-icon {
+ color: var(--accent-cyan, #4a9eff);
+}
+
+.update-result-info .update-result-text {
+ color: var(--text-secondary, #9ca3af);
+}
+
+/* Footer */
+.update-modal-footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 14px 20px;
+ border-top: 1px solid var(--border-color, #1f2937);
+ background: var(--bg-secondary, #0f1218);
+ border-radius: 0 0 12px 12px;
+}
+
+.update-modal-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+ color: var(--text-dim, #4b5563);
+ text-decoration: none;
+ transition: color 0.15s;
+}
+
+.update-modal-link:hover {
+ color: var(--accent-cyan, #4a9eff);
+}
+
+.update-modal-actions {
+ display: flex;
+ gap: 10px;
+}
+
+.update-modal-btn {
+ font-family: inherit;
+ font-size: 12px;
+ font-weight: 500;
+ padding: 8px 16px;
+ border-radius: 6px;
+ border: none;
+ cursor: pointer;
+ transition: all 0.15s;
+}
+
+.update-modal-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.update-modal-btn-primary {
+ background: var(--accent-green, #22c55e);
+ color: #000;
+}
+
+.update-modal-btn-primary:hover:not(:disabled) {
+ background: #34d673;
+}
+
+.update-modal-btn-secondary {
+ background: var(--bg-tertiary, #151a23);
+ color: var(--text-secondary, #9ca3af);
+ border: 1px solid var(--border-color, #1f2937);
+}
+
+.update-modal-btn-secondary:hover:not(:disabled) {
+ background: var(--bg-elevated, #1a202c);
+ border-color: var(--border-light, #374151);
+}
+
+/* ============================================
+ RESPONSIVE
+ ============================================ */
+@media (max-width: 480px) {
+ #toastContainer {
+ bottom: 10px;
+ right: 10px;
+ left: 10px;
+ }
+
+ .update-toast {
+ max-width: none;
+ }
+
+ .update-modal {
+ width: 95%;
+ max-height: 90vh;
+ }
+
+ .update-version-info {
+ flex-direction: column;
+ gap: 10px;
+ }
+
+ .update-version-arrow {
+ transform: rotate(90deg);
+ }
+
+ .update-modal-footer {
+ flex-direction: column;
+ gap: 12px;
+ }
+
+ .update-modal-link {
+ order: 2;
+ }
+
+ .update-modal-actions {
+ width: 100%;
+ }
+
+ .update-modal-btn {
+ flex: 1;
+ }
+}
diff --git a/static/css/core/base.css b/static/css/core/base.css
new file mode 100644
index 0000000..b6f9a81
--- /dev/null
+++ b/static/css/core/base.css
@@ -0,0 +1,420 @@
+/**
+ * INTERCEPT Base Styles
+ * Reset, typography, and foundational element styles
+ * Requires: variables.css to be imported first
+ */
+
+/* ============================================
+ CSS RESET
+ ============================================ */
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+html {
+ -webkit-text-size-adjust: 100%;
+ -moz-tab-size: 4;
+ tab-size: 4;
+}
+
+body {
+ font-family: var(--font-sans);
+ font-size: var(--text-base);
+ line-height: var(--leading-normal);
+ color: var(--text-primary);
+ background: var(--bg-primary);
+ min-height: 100vh;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+/* ============================================
+ TYPOGRAPHY
+ ============================================ */
+h1, h2, h3, h4, h5, h6 {
+ font-weight: var(--font-semibold);
+ line-height: var(--leading-tight);
+ color: var(--text-primary);
+}
+
+h1 { font-size: var(--text-4xl); }
+h2 { font-size: var(--text-3xl); }
+h3 { font-size: var(--text-2xl); }
+h4 { font-size: var(--text-xl); }
+h5 { font-size: var(--text-lg); }
+h6 { font-size: var(--text-base); }
+
+p {
+ margin-bottom: var(--space-4);
+}
+
+a {
+ color: var(--accent-cyan);
+ text-decoration: none;
+ transition: color var(--transition-fast);
+}
+
+a:hover {
+ color: var(--accent-cyan-hover);
+}
+
+a:focus-visible {
+ outline: 2px solid var(--border-focus);
+ outline-offset: 2px;
+}
+
+strong, b {
+ font-weight: var(--font-semibold);
+}
+
+small {
+ font-size: var(--text-sm);
+}
+
+code, kbd, pre, samp {
+ font-family: var(--font-mono);
+ font-size: 0.9em;
+}
+
+code {
+ background: var(--bg-tertiary);
+ padding: 2px 6px;
+ border-radius: var(--radius-sm);
+}
+
+pre {
+ background: var(--bg-tertiary);
+ padding: var(--space-4);
+ border-radius: var(--radius-md);
+ overflow-x: auto;
+}
+
+pre code {
+ background: none;
+ padding: 0;
+}
+
+/* ============================================
+ FORM ELEMENTS
+ ============================================ */
+button,
+input,
+select,
+textarea {
+ font-family: inherit;
+ font-size: inherit;
+ line-height: inherit;
+ color: inherit;
+}
+
+button {
+ cursor: pointer;
+ border: none;
+ background: none;
+}
+
+button:disabled {
+ cursor: not-allowed;
+ opacity: 0.5;
+}
+
+input,
+select,
+textarea {
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ padding: var(--space-2) var(--space-3);
+ color: var(--text-primary);
+ transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
+}
+
+input:focus,
+select:focus,
+textarea:focus {
+ outline: none;
+ border-color: var(--accent-cyan);
+ box-shadow: 0 0 0 3px var(--accent-cyan-dim);
+}
+
+input::placeholder,
+textarea::placeholder {
+ color: var(--text-dim);
+}
+
+select {
+ cursor: pointer;
+ appearance: none;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 8px center;
+ padding-right: 28px;
+}
+
+input[type="checkbox"],
+input[type="radio"] {
+ width: 16px;
+ height: 16px;
+ padding: 0;
+ cursor: pointer;
+ accent-color: var(--accent-cyan);
+}
+
+label {
+ display: block;
+ font-size: var(--text-sm);
+ font-weight: var(--font-medium);
+ color: var(--text-secondary);
+ margin-bottom: var(--space-1);
+}
+
+/* ============================================
+ TABLES
+ ============================================ */
+table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: var(--text-sm);
+}
+
+th,
+td {
+ padding: var(--space-2) var(--space-3);
+ text-align: left;
+ border-bottom: 1px solid var(--border-color);
+}
+
+th {
+ font-weight: var(--font-semibold);
+ color: var(--text-secondary);
+ background: var(--bg-secondary);
+ text-transform: uppercase;
+ font-size: var(--text-xs);
+ letter-spacing: 0.05em;
+}
+
+tr:hover td {
+ background: var(--bg-tertiary);
+}
+
+/* ============================================
+ LISTS
+ ============================================ */
+ul, ol {
+ padding-left: var(--space-6);
+ margin-bottom: var(--space-4);
+}
+
+li {
+ margin-bottom: var(--space-1);
+}
+
+/* ============================================
+ UTILITY CLASSES
+ ============================================ */
+
+/* Text colors */
+.text-primary { color: var(--text-primary); }
+.text-secondary { color: var(--text-secondary); }
+.text-muted { color: var(--text-muted); }
+.text-cyan { color: var(--accent-cyan); }
+.text-green { color: var(--accent-green); }
+.text-red { color: var(--accent-red); }
+.text-orange { color: var(--accent-orange); }
+.text-amber { color: var(--accent-amber); }
+
+/* Font utilities */
+.font-mono { font-family: var(--font-mono); }
+.font-medium { font-weight: var(--font-medium); }
+.font-semibold { font-weight: var(--font-semibold); }
+.font-bold { font-weight: var(--font-bold); }
+
+/* Text sizes */
+.text-xs { font-size: var(--text-xs); }
+.text-sm { font-size: var(--text-sm); }
+.text-base { font-size: var(--text-base); }
+.text-lg { font-size: var(--text-lg); }
+.text-xl { font-size: var(--text-xl); }
+
+/* Display */
+.hidden { display: none !important; }
+.block { display: block; }
+.inline-block { display: inline-block; }
+.flex { display: flex; }
+.inline-flex { display: inline-flex; }
+.grid { display: grid; }
+
+/* Flexbox */
+.items-center { align-items: center; }
+.justify-center { justify-content: center; }
+.justify-between { justify-content: space-between; }
+.flex-1 { flex: 1; }
+.gap-1 { gap: var(--space-1); }
+.gap-2 { gap: var(--space-2); }
+.gap-3 { gap: var(--space-3); }
+.gap-4 { gap: var(--space-4); }
+
+/* Spacing */
+.m-0 { margin: 0; }
+.mt-2 { margin-top: var(--space-2); }
+.mt-4 { margin-top: var(--space-4); }
+.mb-2 { margin-bottom: var(--space-2); }
+.mb-4 { margin-bottom: var(--space-4); }
+.p-2 { padding: var(--space-2); }
+.p-3 { padding: var(--space-3); }
+.p-4 { padding: var(--space-4); }
+
+/* Borders */
+.rounded { border-radius: var(--radius-md); }
+.rounded-lg { border-radius: var(--radius-lg); }
+.border { border: 1px solid var(--border-color); }
+
+/* Truncate text */
+.truncate {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* Screen reader only */
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+/* ============================================
+ SCROLLBAR STYLING
+ ============================================ */
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: var(--bg-secondary);
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--border-light);
+ border-radius: var(--radius-full);
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: var(--text-dim);
+}
+
+/* Firefox scrollbar */
+* {
+ scrollbar-width: thin;
+ scrollbar-color: var(--border-light) var(--bg-secondary);
+}
+
+/* ============================================
+ SELECTION
+ ============================================ */
+::selection {
+ background: var(--accent-cyan-dim);
+ color: var(--text-primary);
+}
+
+/* ============================================
+ UX POLISH - TRANSITIONS & INTERACTIONS
+ ============================================ */
+
+/* Smooth page transitions */
+html {
+ scroll-behavior: smooth;
+}
+
+/* Better focus ring for all interactive elements */
+:focus-visible {
+ outline: 2px solid var(--accent-cyan);
+ outline-offset: 2px;
+}
+
+/* Remove focus ring for mouse users */
+:focus:not(:focus-visible) {
+ outline: none;
+}
+
+/* Active state feedback */
+button:active:not(:disabled),
+a:active,
+[role="button"]:active {
+ transform: scale(0.98);
+}
+
+/* Smooth transitions for all interactive elements */
+button,
+a,
+input,
+select,
+textarea,
+[role="button"] {
+ transition:
+ color var(--transition-fast),
+ background-color var(--transition-fast),
+ border-color var(--transition-fast),
+ box-shadow var(--transition-fast),
+ transform var(--transition-fast),
+ opacity var(--transition-fast);
+}
+
+/* Subtle hover lift effect for cards and panels */
+.card:hover,
+.panel:hover {
+ box-shadow: var(--shadow-md);
+}
+
+/* Link underline on hover */
+a:hover {
+ text-decoration: underline;
+ text-underline-offset: 2px;
+}
+
+/* Skip link for accessibility */
+.skip-link {
+ position: absolute;
+ top: -40px;
+ left: 0;
+ background: var(--accent-cyan);
+ color: var(--bg-primary);
+ padding: var(--space-2) var(--space-4);
+ z-index: 9999;
+ transition: top var(--transition-fast);
+}
+
+.skip-link:focus {
+ top: 0;
+}
+
+/* Reduced motion preference */
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ scroll-behavior: auto !important;
+ }
+}
+
+/* High contrast mode support */
+@media (prefers-contrast: high) {
+ :root {
+ --border-color: #4b5563;
+ --text-secondary: #d1d5db;
+ }
+}
diff --git a/static/css/core/components.css b/static/css/core/components.css
new file mode 100644
index 0000000..1d4f576
--- /dev/null
+++ b/static/css/core/components.css
@@ -0,0 +1,723 @@
+/**
+ * INTERCEPT UI Components
+ * Reusable component styles for buttons, cards, badges, etc.
+ * Requires: variables.css and base.css
+ */
+
+/* ============================================
+ BUTTONS
+ ============================================ */
+
+/* Base button */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-4);
+ font-size: var(--text-sm);
+ font-weight: var(--font-medium);
+ border-radius: var(--radius-md);
+ border: 1px solid transparent;
+ cursor: pointer;
+ transition: all var(--transition-fast);
+ white-space: nowrap;
+ text-decoration: none;
+}
+
+.btn:focus-visible {
+ outline: 2px solid var(--border-focus);
+ outline-offset: 2px;
+}
+
+.btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* Button variants */
+.btn-primary {
+ background: var(--accent-cyan);
+ color: var(--text-inverse);
+ border-color: var(--accent-cyan);
+}
+
+.btn-primary:hover:not(:disabled) {
+ background: var(--accent-cyan-hover);
+ border-color: var(--accent-cyan-hover);
+}
+
+.btn-secondary {
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+ border-color: var(--border-color);
+}
+
+.btn-secondary:hover:not(:disabled) {
+ background: var(--bg-elevated);
+ border-color: var(--border-light);
+}
+
+.btn-ghost {
+ background: transparent;
+ color: var(--text-secondary);
+ border-color: transparent;
+}
+
+.btn-ghost:hover:not(:disabled) {
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+}
+
+.btn-danger {
+ background: var(--accent-red);
+ color: white;
+ border-color: var(--accent-red);
+}
+
+.btn-danger:hover:not(:disabled) {
+ background: #dc2626;
+ border-color: #dc2626;
+}
+
+.btn-success {
+ background: var(--accent-green);
+ color: white;
+ border-color: var(--accent-green);
+}
+
+.btn-success:hover:not(:disabled) {
+ background: #16a34a;
+ border-color: #16a34a;
+}
+
+/* Button sizes */
+.btn-sm {
+ padding: var(--space-1) var(--space-2);
+ font-size: var(--text-xs);
+}
+
+.btn-lg {
+ padding: var(--space-3) var(--space-6);
+ font-size: var(--text-base);
+}
+
+/* Icon button */
+.btn-icon {
+ padding: var(--space-2);
+ width: 36px;
+ height: 36px;
+}
+
+.btn-icon.btn-sm {
+ width: 28px;
+ height: 28px;
+ padding: var(--space-1);
+}
+
+/* ============================================
+ CARDS / PANELS
+ ============================================ */
+.card {
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-lg);
+ overflow: hidden;
+}
+
+.card-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--space-3) var(--space-4);
+ border-bottom: 1px solid var(--border-color);
+ background: var(--bg-secondary);
+}
+
+.card-header-title {
+ font-size: var(--text-xs);
+ font-weight: var(--font-semibold);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--text-secondary);
+}
+
+.card-body {
+ padding: var(--space-4);
+}
+
+.card-footer {
+ padding: var(--space-3) var(--space-4);
+ border-top: 1px solid var(--border-color);
+ background: var(--bg-secondary);
+}
+
+/* Panel variant (used in dashboards) */
+.panel {
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-lg);
+ overflow: hidden;
+}
+
+.panel-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--space-2) var(--space-3);
+ border-bottom: 1px solid var(--border-color);
+ background: linear-gradient(180deg, var(--bg-elevated) 0%, var(--bg-secondary) 100%);
+ font-size: var(--text-xs);
+ font-weight: var(--font-semibold);
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: var(--text-secondary);
+}
+
+.panel-indicator {
+ width: 8px;
+ height: 8px;
+ border-radius: var(--radius-full);
+ background: var(--status-offline);
+}
+
+.panel-indicator.active {
+ background: var(--status-online);
+ box-shadow: 0 0 8px var(--status-online);
+}
+
+.panel-content {
+ padding: var(--space-3);
+}
+
+/* ============================================
+ BADGES
+ ============================================ */
+.badge {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-1);
+ padding: 2px var(--space-2);
+ font-size: var(--text-xs);
+ font-weight: var(--font-medium);
+ border-radius: var(--radius-full);
+ background: var(--bg-tertiary);
+ color: var(--text-secondary);
+}
+
+.badge-primary {
+ background: var(--accent-cyan-dim);
+ color: var(--accent-cyan);
+}
+
+.badge-success {
+ background: var(--accent-green-dim);
+ color: var(--accent-green);
+}
+
+.badge-warning {
+ background: var(--accent-orange-dim);
+ color: var(--accent-orange);
+}
+
+.badge-danger {
+ background: var(--accent-red-dim);
+ color: var(--accent-red);
+}
+
+/* ============================================
+ STATUS INDICATORS
+ ============================================ */
+.status-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: var(--radius-full);
+ background: var(--status-offline);
+ flex-shrink: 0;
+}
+
+.status-dot.online,
+.status-dot.active {
+ background: var(--status-online);
+ box-shadow: 0 0 6px var(--status-online);
+}
+
+.status-dot.warning {
+ background: var(--status-warning);
+ box-shadow: 0 0 6px var(--status-warning);
+}
+
+.status-dot.error,
+.status-dot.offline {
+ background: var(--status-error);
+}
+
+.status-dot.inactive {
+ background: var(--status-offline);
+}
+
+/* Pulse animation for active status */
+.status-dot.pulse {
+ animation: statusPulse 2s ease-in-out infinite;
+}
+
+@keyframes statusPulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.5; }
+}
+
+/* ============================================
+ EMPTY STATE
+ ============================================ */
+.empty-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: var(--space-8) var(--space-4);
+ text-align: center;
+ color: var(--text-muted);
+}
+
+.empty-state-icon {
+ width: 48px;
+ height: 48px;
+ margin-bottom: var(--space-4);
+ opacity: 0.5;
+}
+
+.empty-state-title {
+ font-size: var(--text-base);
+ font-weight: var(--font-medium);
+ color: var(--text-secondary);
+ margin-bottom: var(--space-2);
+}
+
+.empty-state-description {
+ font-size: var(--text-sm);
+ color: var(--text-dim);
+ max-width: 300px;
+}
+
+.empty-state-action {
+ margin-top: var(--space-4);
+}
+
+/* ============================================
+ LOADING STATES
+ ============================================ */
+.spinner {
+ width: 20px;
+ height: 20px;
+ border: 2px solid var(--border-color);
+ border-top-color: var(--accent-cyan);
+ border-radius: var(--radius-full);
+ animation: spin 0.8s linear infinite;
+}
+
+.spinner-sm {
+ width: 14px;
+ height: 14px;
+ border-width: 2px;
+}
+
+.spinner-lg {
+ width: 32px;
+ height: 32px;
+ border-width: 3px;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+/* Loading overlay */
+.loading-overlay {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--bg-overlay);
+ z-index: var(--z-modal);
+}
+
+/* Skeleton loader */
+.skeleton {
+ background: linear-gradient(
+ 90deg,
+ var(--bg-tertiary) 25%,
+ var(--bg-elevated) 50%,
+ var(--bg-tertiary) 75%
+ );
+ background-size: 200% 100%;
+ animation: shimmer 1.5s infinite;
+ border-radius: var(--radius-sm);
+}
+
+@keyframes shimmer {
+ 0% { background-position: 200% 0; }
+ 100% { background-position: -200% 0; }
+}
+
+/* ============================================
+ STATS STRIP
+ ============================================ */
+.stats-strip {
+ display: flex;
+ align-items: center;
+ background: var(--bg-secondary);
+ border-bottom: 1px solid var(--border-color);
+ padding: 0 var(--space-4);
+ height: var(--stats-strip-height);
+ overflow-x: auto;
+ gap: var(--space-1);
+}
+
+.strip-stat {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 0 var(--space-3);
+ min-width: fit-content;
+}
+
+.strip-value {
+ font-family: var(--font-mono);
+ font-size: var(--text-sm);
+ font-weight: var(--font-semibold);
+ color: var(--accent-cyan);
+ line-height: 1;
+}
+
+.strip-label {
+ font-size: 9px;
+ color: var(--text-dim);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ line-height: 1;
+ margin-top: 2px;
+}
+
+.strip-divider {
+ width: 1px;
+ height: 20px;
+ background: var(--border-color);
+ margin: 0 var(--space-2);
+}
+
+/* ============================================
+ FORM GROUPS
+ ============================================ */
+.form-group {
+ margin-bottom: var(--space-4);
+}
+
+.form-group label {
+ display: block;
+ margin-bottom: var(--space-1);
+ font-size: var(--text-sm);
+ font-weight: var(--font-medium);
+ color: var(--text-secondary);
+}
+
+.form-group input,
+.form-group select,
+.form-group textarea {
+ width: 100%;
+}
+
+.form-help {
+ margin-top: var(--space-1);
+ font-size: var(--text-xs);
+ color: var(--text-dim);
+}
+
+.form-error {
+ margin-top: var(--space-1);
+ font-size: var(--text-xs);
+ color: var(--accent-red);
+}
+
+/* Inline checkbox/radio */
+.form-check {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ cursor: pointer;
+}
+
+.form-check input {
+ width: auto;
+}
+
+.form-check label {
+ margin-bottom: 0;
+ cursor: pointer;
+}
+
+/* ============================================
+ ALERTS / TOASTS
+ ============================================ */
+.alert {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--space-3);
+ padding: var(--space-3) var(--space-4);
+ border-radius: var(--radius-md);
+ border: 1px solid;
+ font-size: var(--text-sm);
+}
+
+.alert-info {
+ background: var(--accent-cyan-dim);
+ border-color: var(--accent-cyan);
+ color: var(--accent-cyan);
+}
+
+.alert-success {
+ background: var(--accent-green-dim);
+ border-color: var(--accent-green);
+ color: var(--accent-green);
+}
+
+.alert-warning {
+ background: var(--accent-orange-dim);
+ border-color: var(--accent-orange);
+ color: var(--accent-orange);
+}
+
+.alert-danger {
+ background: var(--accent-red-dim);
+ border-color: var(--accent-red);
+ color: var(--accent-red);
+}
+
+/* ============================================
+ TOOLTIPS
+ ============================================ */
+[data-tooltip] {
+ position: relative;
+}
+
+[data-tooltip]::after {
+ content: attr(data-tooltip);
+ position: absolute;
+ bottom: 100%;
+ left: 50%;
+ transform: translateX(-50%);
+ padding: var(--space-1) var(--space-2);
+ background: var(--bg-elevated);
+ color: var(--text-primary);
+ font-size: var(--text-xs);
+ border-radius: var(--radius-sm);
+ white-space: nowrap;
+ opacity: 0;
+ visibility: hidden;
+ transition: opacity var(--transition-fast), visibility var(--transition-fast);
+ z-index: var(--z-tooltip);
+ pointer-events: none;
+ margin-bottom: var(--space-1);
+ box-shadow: var(--shadow-md);
+}
+
+[data-tooltip]:hover::after {
+ opacity: 1;
+ visibility: visible;
+}
+
+/* ============================================
+ ICONS
+ ============================================ */
+.icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 20px;
+ height: 20px;
+ flex-shrink: 0;
+}
+
+.icon svg {
+ width: 100%;
+ height: 100%;
+}
+
+.icon--sm {
+ width: 16px;
+ height: 16px;
+}
+
+.icon--lg {
+ width: 24px;
+ height: 24px;
+}
+
+/* ============================================
+ SECTION HEADERS
+ ============================================ */
+.section-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: var(--space-4);
+ padding-bottom: var(--space-2);
+ border-bottom: 1px solid var(--border-color);
+}
+
+.section-title {
+ font-size: var(--text-sm);
+ font-weight: var(--font-semibold);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--text-secondary);
+}
+
+/* ============================================
+ DIVIDERS
+ ============================================ */
+.divider {
+ height: 1px;
+ background: var(--border-color);
+ margin: var(--space-4) 0;
+}
+
+.divider-vertical {
+ width: 1px;
+ height: 100%;
+ background: var(--border-color);
+ margin: 0 var(--space-3);
+}
+
+/* ============================================
+ UX POLISH - ENHANCED INTERACTIONS
+ ============================================ */
+
+/* Button hover lift */
+.btn:hover:not(:disabled) {
+ transform: translateY(-1px);
+ box-shadow: var(--shadow-md);
+}
+
+.btn:active:not(:disabled) {
+ transform: translateY(0);
+ box-shadow: var(--shadow-sm);
+}
+
+/* Card/Panel hover effects */
+.card,
+.panel {
+ transition:
+ box-shadow var(--transition-base),
+ border-color var(--transition-base),
+ transform var(--transition-base);
+}
+
+.card:hover,
+.panel:hover {
+ border-color: var(--border-light);
+}
+
+/* Stats strip value highlight on hover */
+.strip-stat {
+ transition: background-color var(--transition-fast);
+ border-radius: var(--radius-sm);
+ cursor: default;
+}
+
+.strip-stat:hover {
+ background: var(--bg-tertiary);
+}
+
+/* Status dot pulse animation */
+.status-dot.online,
+.status-dot.active {
+ animation: statusGlow 2s ease-in-out infinite;
+}
+
+@keyframes statusGlow {
+ 0%, 100% {
+ box-shadow: 0 0 6px var(--status-online);
+ }
+ 50% {
+ box-shadow: 0 0 12px var(--status-online), 0 0 20px var(--status-online);
+ }
+}
+
+/* Badge hover effect */
+.badge {
+ transition: transform var(--transition-fast), box-shadow var(--transition-fast);
+}
+
+.badge:hover {
+ transform: scale(1.05);
+}
+
+/* Alert entrance animation */
+.alert {
+ animation: alertSlideIn 0.3s ease-out;
+}
+
+@keyframes alertSlideIn {
+ from {
+ opacity: 0;
+ transform: translateY(-10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+/* Loading spinner smooth appearance */
+.spinner {
+ animation: spin 0.8s linear infinite, fadeIn 0.3s ease-out;
+}
+
+@keyframes fadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+/* Input focus glow */
+input:focus,
+select:focus,
+textarea:focus {
+ border-color: var(--accent-cyan);
+ box-shadow: 0 0 0 3px var(--accent-cyan-dim), 0 0 20px rgba(74, 158, 255, 0.1);
+}
+
+/* Nav item active indicator */
+.mode-nav-btn.active::after,
+.mobile-nav-btn.active::after {
+ content: '';
+ position: absolute;
+ bottom: -2px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 60%;
+ height: 2px;
+ background: currentColor;
+ border-radius: var(--radius-full);
+}
+
+/* Smooth tooltip appearance */
+[data-tooltip]::after {
+ transition:
+ opacity var(--transition-fast),
+ visibility var(--transition-fast),
+ transform var(--transition-fast);
+ transform: translateX(-50%) translateY(-4px);
+}
+
+[data-tooltip]:hover::after {
+ transform: translateX(-50%) translateY(0);
+}
+
+/* Disabled state with better visual feedback */
+:disabled,
+.disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ filter: grayscale(30%);
+}
diff --git a/static/css/core/layout.css b/static/css/core/layout.css
new file mode 100644
index 0000000..e969223
--- /dev/null
+++ b/static/css/core/layout.css
@@ -0,0 +1,950 @@
+/**
+ * INTERCEPT Layout Styles
+ * Global layout structure: header, navigation, sidebar, main content
+ * Requires: variables.css, base.css, components.css
+ */
+
+/* ============================================
+ APP SHELL
+ ============================================ */
+.app-shell {
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
+}
+
+.app-main {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+}
+
+/* ============================================
+ GLOBAL HEADER
+ ============================================ */
+.app-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ height: var(--header-height);
+ padding: 0 var(--space-4);
+ background: var(--bg-secondary);
+ border-bottom: 1px solid var(--border-color);
+ position: sticky;
+ top: 0;
+ z-index: var(--z-sticky);
+}
+
+.app-header-left {
+ display: flex;
+ align-items: center;
+ gap: var(--space-4);
+}
+
+.app-header-right {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+}
+
+/* Logo */
+.app-logo {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ text-decoration: none;
+ color: var(--text-primary);
+}
+
+.app-logo-icon {
+ width: 40px;
+ height: 40px;
+ flex-shrink: 0;
+}
+
+.app-logo-text {
+ display: flex;
+ flex-direction: column;
+}
+
+.app-logo-title {
+ font-size: var(--text-lg);
+ font-weight: var(--font-bold);
+ line-height: 1.2;
+ color: var(--text-primary);
+}
+
+.app-logo-tagline {
+ font-size: var(--text-xs);
+ color: var(--text-dim);
+}
+
+/* Page title in header */
+.app-header-title {
+ font-size: var(--text-lg);
+ font-weight: var(--font-semibold);
+ color: var(--text-primary);
+}
+
+.app-header-subtitle {
+ font-size: var(--text-sm);
+ color: var(--text-secondary);
+ margin-left: var(--space-2);
+}
+
+/* Header utilities */
+.header-utilities {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+}
+
+.header-clock {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ font-family: var(--font-mono);
+ font-size: var(--text-sm);
+ color: var(--text-secondary);
+}
+
+.header-clock-label {
+ font-size: var(--text-xs);
+ color: var(--text-dim);
+}
+
+/* ============================================
+ GLOBAL NAVIGATION
+ ============================================ */
+.app-nav {
+ display: flex;
+ align-items: center;
+ background: var(--bg-tertiary);
+ border-bottom: 1px solid var(--border-color);
+ padding: 0 var(--space-4);
+ height: var(--nav-height);
+ gap: var(--space-1);
+ overflow-x: auto;
+}
+
+.app-nav::-webkit-scrollbar {
+ height: 0;
+}
+
+/* Nav groups */
+.nav-group {
+ display: flex;
+ align-items: center;
+ position: relative;
+}
+
+/* Dropdown trigger */
+.nav-dropdown-trigger {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-3);
+ font-size: var(--text-sm);
+ font-weight: var(--font-medium);
+ color: var(--text-secondary);
+ background: transparent;
+ border: none;
+ border-radius: var(--radius-md);
+ cursor: pointer;
+ transition: all var(--transition-fast);
+ white-space: nowrap;
+}
+
+.nav-dropdown-trigger:hover {
+ background: var(--bg-elevated);
+ color: var(--text-primary);
+}
+
+.nav-dropdown-trigger.active {
+ background: var(--accent-cyan-dim);
+ color: var(--accent-cyan);
+}
+
+.nav-dropdown-arrow {
+ width: 12px;
+ height: 12px;
+ transition: transform var(--transition-fast);
+}
+
+.nav-group.open .nav-dropdown-arrow {
+ transform: rotate(180deg);
+}
+
+/* Dropdown menu */
+.nav-dropdown-menu {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ min-width: 180px;
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ box-shadow: var(--shadow-lg);
+ padding: var(--space-1);
+ opacity: 0;
+ visibility: hidden;
+ transform: translateY(-4px);
+ transition: all var(--transition-fast);
+ z-index: var(--z-dropdown);
+}
+
+.nav-group.open .nav-dropdown-menu {
+ opacity: 1;
+ visibility: visible;
+ transform: translateY(4px);
+}
+
+/* Nav items */
+.nav-item {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-3);
+ font-size: var(--text-sm);
+ color: var(--text-secondary);
+ text-decoration: none;
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ transition: all var(--transition-fast);
+ border: none;
+ background: none;
+ width: 100%;
+ text-align: left;
+}
+
+.nav-item:hover {
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+}
+
+.nav-item.active {
+ background: var(--accent-cyan-dim);
+ color: var(--accent-cyan);
+}
+
+.nav-item-icon {
+ width: 16px;
+ height: 16px;
+ flex-shrink: 0;
+}
+
+/* Nav divider */
+.nav-divider {
+ width: 1px;
+ height: 24px;
+ background: var(--border-color);
+ margin: 0 var(--space-2);
+}
+
+/* Nav utilities (right side) */
+.nav-utilities {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ margin-left: auto;
+ padding-left: var(--space-4);
+}
+
+.nav-tool-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ background: transparent;
+ border: none;
+ border-radius: var(--radius-md);
+ color: var(--text-secondary);
+ cursor: pointer;
+ transition: all var(--transition-fast);
+ text-decoration: none;
+}
+
+.nav-tool-btn:hover {
+ background: var(--bg-elevated);
+ color: var(--text-primary);
+}
+
+/* ============================================
+ MOBILE NAVIGATION
+ ============================================ */
+.mobile-nav {
+ display: none;
+ background: var(--bg-tertiary);
+ border-bottom: 1px solid var(--border-color);
+ padding: var(--space-2) var(--space-3);
+ overflow-x: auto;
+ gap: var(--space-2);
+}
+
+.mobile-nav::-webkit-scrollbar {
+ height: 0;
+}
+
+.mobile-nav-btn {
+ display: flex;
+ align-items: center;
+ gap: var(--space-1);
+ padding: var(--space-2) var(--space-3);
+ font-size: var(--text-xs);
+ font-weight: var(--font-medium);
+ color: var(--text-secondary);
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ cursor: pointer;
+ white-space: nowrap;
+ text-decoration: none;
+ transition: all var(--transition-fast);
+}
+
+.mobile-nav-btn:hover,
+.mobile-nav-btn.active {
+ background: var(--accent-cyan-dim);
+ border-color: var(--accent-cyan);
+ color: var(--accent-cyan);
+}
+
+/* Hamburger button */
+.hamburger-btn {
+ display: none;
+ flex-direction: column;
+ justify-content: center;
+ gap: 4px;
+ width: 32px;
+ height: 32px;
+ padding: 6px;
+ background: transparent;
+ border: none;
+ cursor: pointer;
+}
+
+.hamburger-btn span {
+ display: block;
+ width: 100%;
+ height: 2px;
+ background: var(--text-secondary);
+ border-radius: 1px;
+ transition: all var(--transition-fast);
+}
+
+.hamburger-btn.open span:nth-child(1) {
+ transform: rotate(45deg) translate(4px, 4px);
+}
+
+.hamburger-btn.open span:nth-child(2) {
+ opacity: 0;
+}
+
+.hamburger-btn.open span:nth-child(3) {
+ transform: rotate(-45deg) translate(4px, -4px);
+}
+
+/* ============================================
+ CONTENT LAYOUTS
+ ============================================ */
+
+/* Main content with optional sidebar */
+.content-wrapper {
+ display: flex;
+ flex: 1;
+ overflow: hidden;
+}
+
+/* Sidebar */
+.app-sidebar {
+ width: var(--sidebar-width);
+ background: var(--bg-secondary);
+ border-right: 1px solid var(--border-color);
+ overflow-y: auto;
+ flex-shrink: 0;
+}
+
+.sidebar-section {
+ padding: var(--space-4);
+ border-bottom: 1px solid var(--border-color);
+}
+
+.sidebar-section:last-child {
+ border-bottom: none;
+}
+
+.sidebar-section-title {
+ font-size: var(--text-xs);
+ font-weight: var(--font-semibold);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--text-secondary);
+ margin-bottom: var(--space-3);
+}
+
+/* Main content area */
+.app-content {
+ flex: 1;
+ overflow-y: auto;
+ padding: var(--space-4);
+}
+
+.app-content-full {
+ flex: 1;
+ overflow: hidden;
+ position: relative;
+}
+
+/* ============================================
+ DASHBOARD LAYOUTS
+ ============================================ */
+
+/* Full-screen dashboard (maps, etc.) */
+.dashboard-layout {
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+ overflow: hidden;
+}
+
+.dashboard-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--space-2) var(--space-4);
+ background: var(--bg-secondary);
+ border-bottom: 1px solid var(--border-color);
+ flex-shrink: 0;
+}
+
+.dashboard-header-logo {
+ font-size: var(--text-lg);
+ font-weight: var(--font-bold);
+ color: var(--text-primary);
+}
+
+.dashboard-header-logo span {
+ font-size: var(--text-sm);
+ font-weight: var(--font-normal);
+ color: var(--text-dim);
+ margin-left: var(--space-2);
+}
+
+.dashboard-main {
+ flex: 1;
+ display: flex;
+ overflow: hidden;
+ position: relative;
+}
+
+.dashboard-map {
+ flex: 1;
+ position: relative;
+}
+
+.dashboard-sidebar {
+ width: 320px;
+ background: var(--bg-secondary);
+ border-left: 1px solid var(--border-color);
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+ padding: var(--space-3);
+}
+
+/* ============================================
+ PAGE LAYOUTS
+ ============================================ */
+.page-container {
+ max-width: var(--content-max-width);
+ margin: 0 auto;
+ padding: var(--space-6);
+}
+
+.page-header {
+ margin-bottom: var(--space-6);
+}
+
+.page-title {
+ font-size: var(--text-3xl);
+ font-weight: var(--font-bold);
+ color: var(--text-primary);
+ margin-bottom: var(--space-2);
+}
+
+.page-description {
+ font-size: var(--text-base);
+ color: var(--text-secondary);
+}
+
+/* ============================================
+ RESPONSIVE BREAKPOINTS
+ ============================================ */
+@media (max-width: 1024px) {
+ .app-sidebar {
+ position: fixed;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ z-index: var(--z-fixed);
+ transform: translateX(-100%);
+ transition: transform var(--transition-base);
+ }
+
+ .app-sidebar.open {
+ transform: translateX(0);
+ }
+
+ .dashboard-sidebar {
+ width: 280px;
+ }
+}
+
+@media (max-width: 768px) {
+ .app-nav {
+ display: none;
+ }
+
+ .mobile-nav {
+ display: flex;
+ }
+
+ .hamburger-btn {
+ display: flex;
+ }
+
+ .app-header {
+ padding: 0 var(--space-3);
+ }
+
+ .app-logo-text {
+ display: none;
+ }
+
+ .header-utilities {
+ gap: var(--space-1);
+ }
+
+ .page-container {
+ padding: var(--space-4);
+ }
+
+ .dashboard-sidebar {
+ position: fixed;
+ right: 0;
+ top: 0;
+ bottom: 0;
+ z-index: var(--z-fixed);
+ transform: translateX(100%);
+ transition: transform var(--transition-base);
+ }
+
+ .dashboard-sidebar.open {
+ transform: translateX(0);
+ }
+}
+
+/* ============================================
+ OVERLAY (for mobile drawers)
+ ============================================ */
+.drawer-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.5);
+ z-index: calc(var(--z-fixed) - 1);
+ opacity: 0;
+ visibility: hidden;
+ transition: all var(--transition-base);
+}
+
+.drawer-overlay.visible {
+ opacity: 1;
+ visibility: visible;
+}
+
+/* ============================================
+ BACK LINK
+ ============================================ */
+.back-link {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2);
+ font-size: var(--text-sm);
+ color: var(--text-secondary);
+ text-decoration: none;
+ transition: color var(--transition-fast);
+}
+
+.back-link:hover {
+ color: var(--accent-cyan);
+}
+
+/* ============================================
+ MODE NAVIGATION (from index.css)
+ Used by nav.html partial across all pages
+ ============================================ */
+
+/* Mode Navigation Bar */
+.mode-nav {
+ display: none;
+ background: #151a23 !important; /* Explicit color - forced to ensure consistency */
+ border-bottom: 1px solid var(--border-color);
+ padding: 0 20px;
+ position: relative;
+ z-index: 100;
+}
+
+@media (min-width: 1024px) {
+ .mode-nav {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ height: 44px;
+ }
+}
+
+.mode-nav-group {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.mode-nav-label {
+ font-size: 9px;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ margin-right: 8px;
+ font-weight: 500;
+}
+
+.mode-nav-divider {
+ width: 1px;
+ height: 24px;
+ background: var(--border-color);
+ margin: 0 12px;
+}
+
+.mode-nav-btn {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 8px 14px;
+ background: transparent;
+ border: 1px solid transparent;
+ border-radius: 4px;
+ color: var(--text-secondary);
+ font-family: var(--font-sans);
+ font-size: 11px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.mode-nav-btn .nav-icon {
+ font-size: 14px;
+}
+
+.mode-nav-btn .nav-icon svg {
+ width: 14px;
+ height: 14px;
+}
+
+.mode-nav-btn .nav-label {
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.mode-nav-btn:hover {
+ background: var(--bg-elevated);
+ color: var(--text-primary);
+ border-color: var(--border-color);
+}
+
+.mode-nav-btn.active {
+ background: var(--accent-cyan);
+ color: var(--bg-primary);
+ border-color: var(--accent-cyan);
+}
+
+.mode-nav-btn.active .nav-icon {
+ filter: brightness(0);
+}
+
+.mode-nav-actions {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+}
+
+.nav-action-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 8px 14px;
+ background: var(--bg-elevated);
+ border: 1px solid var(--accent-cyan);
+ border-radius: 4px;
+ color: var(--accent-cyan);
+ font-family: var(--font-sans);
+ font-size: 11px;
+ font-weight: 500;
+ text-decoration: none;
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.nav-action-btn .nav-icon {
+ font-size: 12px;
+}
+
+.nav-action-btn .nav-label {
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.nav-action-btn:hover {
+ background: var(--accent-cyan);
+ color: var(--bg-primary);
+}
+
+/* Dropdown Navigation */
+.mode-nav-dropdown {
+ position: relative;
+}
+
+.mode-nav-dropdown-btn {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 14px;
+ background: transparent;
+ border: 1px solid transparent;
+ border-radius: 4px;
+ color: var(--text-secondary);
+ font-family: var(--font-sans);
+ font-size: 11px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.mode-nav-dropdown-btn:hover {
+ background: var(--bg-elevated);
+ color: var(--text-primary);
+ border-color: var(--border-color);
+}
+
+.mode-nav-dropdown-btn .nav-icon {
+ font-size: 14px;
+}
+
+.mode-nav-dropdown-btn .nav-icon svg {
+ width: 14px;
+ height: 14px;
+}
+
+.mode-nav-dropdown-btn .nav-label {
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.mode-nav-dropdown-btn .dropdown-arrow {
+ font-size: 8px;
+ margin-left: 4px;
+ transition: transform 0.2s ease;
+}
+
+.mode-nav-dropdown-btn .dropdown-arrow svg {
+ width: 10px;
+ height: 10px;
+}
+
+.mode-nav-dropdown.open .mode-nav-dropdown-btn {
+ background: var(--bg-elevated);
+ color: var(--text-primary);
+ border-color: var(--border-color);
+}
+
+.mode-nav-dropdown.open .dropdown-arrow {
+ transform: rotate(180deg);
+}
+
+.mode-nav-dropdown.has-active .mode-nav-dropdown-btn {
+ background: var(--accent-cyan);
+ color: var(--bg-primary);
+ border-color: var(--accent-cyan);
+}
+
+.mode-nav-dropdown.has-active .mode-nav-dropdown-btn .nav-icon {
+ filter: brightness(0);
+}
+
+.mode-nav-dropdown-menu {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ margin-top: 4px;
+ min-width: 180px;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
+ opacity: 0;
+ visibility: hidden;
+ transform: translateY(-8px);
+ transition: all 0.15s ease;
+ z-index: 1000;
+ padding: 6px;
+}
+
+.mode-nav-dropdown.open .mode-nav-dropdown-menu {
+ opacity: 1;
+ visibility: visible;
+ transform: translateY(0);
+}
+
+.mode-nav-dropdown-menu .mode-nav-btn {
+ width: 100%;
+ justify-content: flex-start;
+ padding: 10px 12px;
+ border-radius: 4px;
+ margin: 0;
+}
+
+.mode-nav-dropdown-menu .mode-nav-btn:hover {
+ background: var(--bg-elevated);
+}
+
+.mode-nav-dropdown-menu .mode-nav-btn.active {
+ background: var(--accent-cyan);
+ color: var(--bg-primary);
+}
+
+/* Nav Bar Utilities (clock, theme, tools) */
+.nav-utilities {
+ display: none;
+ align-items: center;
+ gap: 12px;
+ margin-left: auto;
+ flex-shrink: 0;
+}
+
+@media (min-width: 1024px) {
+ .nav-utilities {
+ display: flex;
+ }
+}
+
+.nav-clock {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-family: var(--font-mono);
+ font-size: 11px;
+ flex-shrink: 0;
+ white-space: nowrap;
+}
+
+.nav-clock .utc-label {
+ font-size: 9px;
+ color: var(--text-dim);
+ text-transform: uppercase;
+ letter-spacing: 1px;
+}
+
+.nav-clock .utc-time {
+ color: var(--accent-cyan);
+ font-weight: 600;
+}
+
+.nav-divider {
+ width: 1px;
+ height: 20px;
+ background: var(--border-color);
+}
+
+.nav-tools {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-shrink: 0;
+}
+
+.nav-tool-btn {
+ width: 28px;
+ height: 28px;
+ min-width: 28px;
+ border-radius: 4px;
+ background: transparent;
+ border: 1px solid transparent;
+ color: var(--text-secondary);
+ font-size: 14px;
+ font-weight: bold;
+ cursor: pointer;
+ transition: all 0.15s ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+ overflow: hidden;
+}
+
+.nav-tool-btn:hover {
+ background: var(--bg-elevated);
+ border-color: var(--border-color);
+ color: var(--accent-cyan);
+}
+
+.nav-tool-btn svg {
+ width: 14px;
+ height: 14px;
+}
+
+.nav-tool-btn .icon svg {
+ width: 14px;
+ height: 14px;
+}
+
+/* Theme toggle icon states in nav bar */
+.nav-tool-btn .icon-sun,
+.nav-tool-btn .icon-moon {
+ position: absolute;
+ transition: opacity 0.2s, transform 0.2s;
+ font-size: 14px;
+}
+
+.nav-tool-btn .icon-sun {
+ opacity: 0;
+ transform: rotate(-90deg);
+}
+
+.nav-tool-btn .icon-moon {
+ opacity: 1;
+ transform: rotate(0deg);
+}
+
+[data-theme="light"] .nav-tool-btn .icon-sun {
+ opacity: 1;
+ transform: rotate(0deg);
+}
+
+[data-theme="light"] .nav-tool-btn .icon-moon {
+ opacity: 0;
+ transform: rotate(90deg);
+}
+
+/* Effects toggle icon states */
+.nav-tool-btn .icon-effects-off {
+ display: none;
+}
+
+[data-animations="off"] .nav-tool-btn .icon-effects-on {
+ display: none;
+}
+
+[data-animations="off"] .nav-tool-btn .icon-effects-off {
+ display: flex;
+}
diff --git a/static/css/core/variables.css b/static/css/core/variables.css
new file mode 100644
index 0000000..0c1d969
--- /dev/null
+++ b/static/css/core/variables.css
@@ -0,0 +1,198 @@
+/**
+ * INTERCEPT Design Tokens
+ * Single source of truth for colors, spacing, typography, and effects
+ * Import this file FIRST in any stylesheet that needs design tokens
+ */
+
+:root {
+ /* ============================================
+ COLOR PALETTE - Dark Theme (Default)
+ ============================================ */
+
+ /* Backgrounds - layered depth system */
+ --bg-primary: #0a0c10;
+ --bg-secondary: #0f1218;
+ --bg-tertiary: #151a23;
+ --bg-card: #121620;
+ --bg-elevated: #1a202c;
+ --bg-overlay: rgba(0, 0, 0, 0.7);
+
+ /* Background aliases for components */
+ --bg-dark: var(--bg-primary);
+ --bg-panel: var(--bg-secondary);
+
+ /* Accent colors */
+ --accent-cyan: #4a9eff;
+ --accent-cyan-dim: rgba(74, 158, 255, 0.15);
+ --accent-cyan-hover: #6bb3ff;
+ --accent-green: #22c55e;
+ --accent-green-dim: rgba(34, 197, 94, 0.15);
+ --accent-red: #ef4444;
+ --accent-red-dim: rgba(239, 68, 68, 0.15);
+ --accent-orange: #f59e0b;
+ --accent-orange-dim: rgba(245, 158, 11, 0.15);
+ --accent-amber: #d4a853;
+ --accent-amber-dim: rgba(212, 168, 83, 0.15);
+ --accent-yellow: #eab308;
+ --accent-purple: #a855f7;
+
+ /* Text hierarchy */
+ --text-primary: #e8eaed;
+ --text-secondary: #9ca3af;
+ --text-dim: #4b5563;
+ --text-muted: #374151;
+ --text-inverse: #0a0c10;
+
+ /* Borders */
+ --border-color: #1f2937;
+ --border-light: #374151;
+ --border-glow: rgba(74, 158, 255, 0.2);
+ --border-focus: var(--accent-cyan);
+
+ /* Status colors */
+ --status-online: #22c55e;
+ --status-warning: #f59e0b;
+ --status-error: #ef4444;
+ --status-offline: #6b7280;
+ --status-info: #3b82f6;
+
+ /* ============================================
+ SPACING SCALE
+ ============================================ */
+ --space-1: 4px;
+ --space-2: 8px;
+ --space-3: 12px;
+ --space-4: 16px;
+ --space-5: 20px;
+ --space-6: 24px;
+ --space-8: 32px;
+ --space-10: 40px;
+ --space-12: 48px;
+ --space-16: 64px;
+
+ /* ============================================
+ TYPOGRAPHY
+ ============================================ */
+ --font-sans: 'JetBrains Mono', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+ --font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
+
+ /* Font sizes */
+ --text-xs: 10px;
+ --text-sm: 12px;
+ --text-base: 14px;
+ --text-lg: 16px;
+ --text-xl: 18px;
+ --text-2xl: 20px;
+ --text-3xl: 24px;
+ --text-4xl: 30px;
+
+ /* Font weights */
+ --font-normal: 400;
+ --font-medium: 500;
+ --font-semibold: 600;
+ --font-bold: 700;
+
+ /* Line heights */
+ --leading-tight: 1.25;
+ --leading-normal: 1.5;
+ --leading-relaxed: 1.75;
+
+ /* ============================================
+ BORDERS & RADIUS
+ ============================================ */
+ --radius-sm: 4px;
+ --radius-md: 6px;
+ --radius-lg: 8px;
+ --radius-xl: 12px;
+ --radius-full: 9999px;
+
+ /* ============================================
+ SHADOWS
+ ============================================ */
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
+ --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4);
+ --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.5);
+ --shadow-glow: 0 0 20px rgba(74, 158, 255, 0.15);
+
+ /* ============================================
+ TRANSITIONS
+ ============================================ */
+ --transition-fast: 150ms ease;
+ --transition-base: 200ms ease;
+ --transition-slow: 300ms ease;
+
+ /* ============================================
+ Z-INDEX SCALE
+ ============================================ */
+ --z-base: 0;
+ --z-dropdown: 100;
+ --z-sticky: 200;
+ --z-fixed: 300;
+ --z-modal-backdrop: 400;
+ --z-modal: 500;
+ --z-toast: 600;
+ --z-tooltip: 700;
+
+ /* ============================================
+ LAYOUT
+ ============================================ */
+ --header-height: 60px;
+ --nav-height: 44px;
+ --sidebar-width: 280px;
+ --stats-strip-height: 36px;
+ --content-max-width: 1400px;
+}
+
+/* ============================================
+ LIGHT THEME OVERRIDES
+ ============================================ */
+[data-theme="light"] {
+ --bg-primary: #f8fafc;
+ --bg-secondary: #f1f5f9;
+ --bg-tertiary: #e2e8f0;
+ --bg-card: #ffffff;
+ --bg-elevated: #f8fafc;
+ --bg-overlay: rgba(255, 255, 255, 0.9);
+
+ /* Background aliases for components */
+ --bg-dark: var(--bg-primary);
+ --bg-panel: var(--bg-secondary);
+
+ --accent-cyan: #2563eb;
+ --accent-cyan-dim: rgba(37, 99, 235, 0.1);
+ --accent-cyan-hover: #1d4ed8;
+ --accent-green: #16a34a;
+ --accent-green-dim: rgba(22, 163, 74, 0.1);
+ --accent-red: #dc2626;
+ --accent-red-dim: rgba(220, 38, 38, 0.1);
+ --accent-orange: #d97706;
+ --accent-orange-dim: rgba(217, 119, 6, 0.1);
+ --accent-amber: #b45309;
+ --accent-amber-dim: rgba(180, 83, 9, 0.1);
+
+ --text-primary: #0f172a;
+ --text-secondary: #475569;
+ --text-dim: #94a3b8;
+ --text-muted: #cbd5e1;
+ --text-inverse: #f8fafc;
+
+ --border-color: #e2e8f0;
+ --border-light: #cbd5e1;
+ --border-glow: rgba(37, 99, 235, 0.15);
+
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
+ --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
+ --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.15);
+ --shadow-glow: 0 0 20px rgba(37, 99, 235, 0.1);
+}
+
+/* ============================================
+ REDUCED MOTION
+ ============================================ */
+@media (prefers-reduced-motion: reduce) {
+ :root {
+ --transition-fast: 0ms;
+ --transition-base: 0ms;
+ --transition-slow: 0ms;
+ }
+}
diff --git a/static/css/fonts-local.css b/static/css/fonts-local.css
index 7f0167d..d605b40 100644
--- a/static/css/fonts-local.css
+++ b/static/css/fonts-local.css
@@ -1,67 +1,67 @@
-/* Local font declarations for offline mode */
-
-/* Inter - Primary UI font */
-@font-face {
- font-family: 'Inter';
- font-style: normal;
- font-weight: 400;
- font-display: swap;
- src: url('/static/vendor/fonts/Inter-Regular.woff2') format('woff2');
-}
-
-@font-face {
- font-family: 'Inter';
- font-style: normal;
- font-weight: 500;
- font-display: swap;
- src: url('/static/vendor/fonts/Inter-Medium.woff2') format('woff2');
-}
-
-@font-face {
- font-family: 'Inter';
- font-style: normal;
- font-weight: 600;
- font-display: swap;
- src: url('/static/vendor/fonts/Inter-SemiBold.woff2') format('woff2');
-}
-
-@font-face {
- font-family: 'Inter';
- font-style: normal;
- font-weight: 700;
- font-display: swap;
- src: url('/static/vendor/fonts/Inter-Bold.woff2') format('woff2');
-}
-
-/* JetBrains Mono - Monospace/code font */
-@font-face {
- font-family: 'JetBrains Mono';
- font-style: normal;
- font-weight: 400;
- font-display: swap;
- src: url('/static/vendor/fonts/JetBrainsMono-Regular.woff2') format('woff2');
-}
-
-@font-face {
- font-family: 'JetBrains Mono';
- font-style: normal;
- font-weight: 500;
- font-display: swap;
- src: url('/static/vendor/fonts/JetBrainsMono-Medium.woff2') format('woff2');
-}
-
-@font-face {
- font-family: 'JetBrains Mono';
- font-style: normal;
- font-weight: 600;
- font-display: swap;
- src: url('/static/vendor/fonts/JetBrainsMono-SemiBold.woff2') format('woff2');
-}
-
-@font-face {
- font-family: 'JetBrains Mono';
- font-style: normal;
- font-weight: 700;
- font-display: swap;
- src: url('/static/vendor/fonts/JetBrainsMono-Bold.woff2') format('woff2');
-}
+/* Local font declarations for offline mode */
+
+/* Inter - Primary UI font */
+@font-face {
+ font-family: 'Inter';
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url('/static/vendor/fonts/Inter-Regular.woff2') format('woff2');
+}
+
+@font-face {
+ font-family: 'Inter';
+ font-style: normal;
+ font-weight: 500;
+ font-display: swap;
+ src: url('/static/vendor/fonts/Inter-Medium.woff2') format('woff2');
+}
+
+@font-face {
+ font-family: 'Inter';
+ font-style: normal;
+ font-weight: 600;
+ font-display: swap;
+ src: url('/static/vendor/fonts/Inter-SemiBold.woff2') format('woff2');
+}
+
+@font-face {
+ font-family: 'Inter';
+ font-style: normal;
+ font-weight: 700;
+ font-display: swap;
+ src: url('/static/vendor/fonts/Inter-Bold.woff2') format('woff2');
+}
+
+/* JetBrains Mono - Monospace/code font */
+@font-face {
+ font-family: 'JetBrains Mono';
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url('/static/vendor/fonts/JetBrainsMono-Regular.woff2') format('woff2');
+}
+
+@font-face {
+ font-family: 'JetBrains Mono';
+ font-style: normal;
+ font-weight: 500;
+ font-display: swap;
+ src: url('/static/vendor/fonts/JetBrainsMono-Medium.woff2') format('woff2');
+}
+
+@font-face {
+ font-family: 'JetBrains Mono';
+ font-style: normal;
+ font-weight: 600;
+ font-display: swap;
+ src: url('/static/vendor/fonts/JetBrainsMono-SemiBold.woff2') format('woff2');
+}
+
+@font-face {
+ font-family: 'JetBrains Mono';
+ font-style: normal;
+ font-weight: 700;
+ font-display: swap;
+ src: url('/static/vendor/fonts/JetBrainsMono-Bold.woff2') format('woff2');
+}
diff --git a/static/css/global-nav.css b/static/css/global-nav.css
new file mode 100644
index 0000000..7f36cc2
--- /dev/null
+++ b/static/css/global-nav.css
@@ -0,0 +1,332 @@
+/* ============================================
+ Global Navigation Styles
+ Shared across all pages using nav.html
+ ============================================ */
+
+/* Icon base (kept lightweight for nav usage) */
+.icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 18px;
+ height: 18px;
+ flex-shrink: 0;
+}
+
+.icon svg {
+ width: 100%;
+ height: 100%;
+}
+
+.icon--sm {
+ width: 14px;
+ height: 14px;
+}
+
+/* Mode Navigation Bar */
+.mode-nav {
+ display: none;
+ background: linear-gradient(180deg, rgba(17, 22, 32, 0.92), rgba(15, 20, 28, 0.88));
+ border-bottom: 1px solid var(--border-color, #202833);
+ padding: 0 20px;
+ position: relative;
+ z-index: 100;
+ backdrop-filter: blur(10px);
+}
+
+@media (min-width: 1024px) {
+ .mode-nav {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ height: 44px;
+ }
+}
+
+.mode-nav-label {
+ font-size: 9px;
+ color: var(--text-secondary, #b7c1cf);
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ margin-right: 8px;
+ font-weight: 500;
+ font-family: var(--font-mono);
+}
+
+.mode-nav-divider {
+ width: 1px;
+ height: 24px;
+ background: var(--border-color, #202833);
+ margin: 0 12px;
+}
+
+.mode-nav-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 8px 14px;
+ background: transparent;
+ border: 1px solid transparent;
+ border-radius: 6px;
+ color: var(--text-secondary, #b7c1cf);
+ font-family: var(--font-sans);
+ font-size: 11px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.15s ease;
+ text-decoration: none;
+}
+
+.mode-nav-btn .nav-label {
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ font-family: var(--font-mono);
+ font-size: 10px;
+}
+
+.mode-nav-btn:hover {
+ background: rgba(27, 36, 51, 0.8);
+ color: var(--text-primary, #e7ebf2);
+ border-color: var(--border-color, #202833);
+}
+
+.mode-nav-btn.active {
+ background: rgba(27, 36, 51, 0.9);
+ color: var(--text-primary, #e7ebf2);
+ border-color: var(--accent-cyan, #4d7dbf);
+ box-shadow: inset 0 -2px 0 var(--accent-cyan, #4d7dbf);
+}
+
+.mode-nav-btn.active .nav-icon {
+ color: var(--accent-cyan, #4d7dbf);
+}
+
+.mode-nav-actions {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+}
+
+.nav-action-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 8px 14px;
+ background: rgba(24, 31, 44, 0.85);
+ border: 1px solid var(--border-light, #2b3645);
+ border-radius: 6px;
+ color: var(--text-primary, #e7ebf2);
+ font-family: var(--font-sans);
+ font-size: 11px;
+ font-weight: 500;
+ text-decoration: none;
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.nav-action-btn .nav-label {
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ font-family: var(--font-mono);
+ font-size: 10px;
+}
+
+.nav-action-btn:hover {
+ background: rgba(27, 36, 51, 0.95);
+ color: var(--text-primary, #e7ebf2);
+ box-shadow: 0 8px 16px rgba(5, 9, 15, 0.35);
+ border-color: var(--accent-cyan, #4d7dbf);
+}
+
+/* Dropdown Navigation */
+.mode-nav-dropdown {
+ position: relative;
+}
+
+.mode-nav-dropdown-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 14px;
+ background: transparent;
+ border: 1px solid transparent;
+ border-radius: 6px;
+ color: var(--text-secondary, #b7c1cf);
+ font-family: var(--font-sans);
+ font-size: 11px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.mode-nav-dropdown-btn .nav-label {
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ font-family: var(--font-mono);
+ font-size: 10px;
+}
+
+.mode-nav-dropdown-btn .dropdown-arrow {
+ width: 12px;
+ height: 12px;
+ margin-left: 4px;
+ transition: transform 0.2s ease;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.mode-nav-dropdown-btn .dropdown-arrow svg {
+ width: 100%;
+ height: 100%;
+}
+
+.mode-nav-dropdown-btn:hover {
+ background: rgba(27, 36, 51, 0.8);
+ color: var(--text-primary, #e7ebf2);
+ border-color: var(--border-color, #202833);
+}
+
+.mode-nav-dropdown.open .mode-nav-dropdown-btn {
+ background: rgba(27, 36, 51, 0.9);
+ color: var(--text-primary, #e7ebf2);
+ border-color: var(--border-color, #202833);
+}
+
+.mode-nav-dropdown.open .dropdown-arrow {
+ transform: rotate(180deg);
+}
+
+.mode-nav-dropdown.has-active .mode-nav-dropdown-btn {
+ background: rgba(27, 36, 51, 0.9);
+ color: var(--text-primary, #e7ebf2);
+ border-color: var(--accent-cyan, #4d7dbf);
+ box-shadow: inset 0 -2px 0 var(--accent-cyan, #4d7dbf);
+}
+
+.mode-nav-dropdown.has-active .mode-nav-dropdown-btn .nav-icon {
+ color: var(--accent-cyan, #4d7dbf);
+}
+
+.mode-nav-dropdown-menu {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ margin-top: 4px;
+ min-width: 180px;
+ background: rgba(16, 22, 32, 0.98);
+ border: 1px solid var(--border-color, #202833);
+ border-radius: 8px;
+ box-shadow: 0 16px 36px rgba(5, 9, 15, 0.55);
+ opacity: 0;
+ visibility: hidden;
+ transform: translateY(-8px);
+ transition: all 0.15s ease;
+ z-index: 1000;
+ padding: 6px;
+}
+
+.mode-nav-dropdown.open .mode-nav-dropdown-menu {
+ opacity: 1;
+ visibility: visible;
+ transform: translateY(0);
+}
+
+.mode-nav-dropdown-menu .mode-nav-btn {
+ width: 100%;
+ justify-content: flex-start;
+ padding: 10px 12px;
+ border-radius: 6px;
+}
+
+.mode-nav-dropdown-menu .mode-nav-btn:hover {
+ background: rgba(27, 36, 51, 0.85);
+}
+
+.mode-nav-dropdown-menu .mode-nav-btn.active {
+ background: rgba(27, 36, 51, 0.95);
+ color: var(--text-primary, #e7ebf2);
+ box-shadow: inset 0 -2px 0 var(--accent-cyan, #4d7dbf);
+}
+
+/* Nav Bar Utilities */
+.nav-utilities {
+ display: none;
+ align-items: center;
+ gap: 12px;
+ margin-left: auto;
+ flex-shrink: 0;
+}
+
+@media (min-width: 1024px) {
+ .nav-utilities {
+ display: flex;
+ }
+}
+
+.nav-clock {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-family: var(--font-mono);
+ font-size: 11px;
+ flex-shrink: 0;
+ white-space: nowrap;
+}
+
+.nav-clock .utc-label {
+ font-size: 9px;
+ color: var(--text-dim, #8a97a8);
+ text-transform: uppercase;
+ letter-spacing: 1px;
+}
+
+.nav-clock .utc-time {
+ color: var(--accent-cyan, #4d7dbf);
+ font-weight: 600;
+}
+
+.nav-divider {
+ width: 1px;
+ height: 20px;
+ background: var(--border-color, #202833);
+}
+
+.nav-tools {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-shrink: 0;
+}
+
+.nav-tool-btn {
+ width: 28px;
+ height: 28px;
+ min-width: 28px;
+ border-radius: 6px;
+ background: rgba(20, 33, 53, 0.6);
+ border: 1px solid rgba(77, 125, 191, 0.12);
+ color: var(--text-secondary, #b7c1cf);
+ font-size: 14px;
+ font-weight: bold;
+ cursor: pointer;
+ transition: all 0.15s ease;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.nav-tool-btn:hover {
+ background: rgba(27, 36, 51, 0.9);
+ border-color: var(--accent-cyan, #4d7dbf);
+ color: var(--accent-cyan, #4d7dbf);
+ box-shadow: 0 6px 14px rgba(5, 9, 15, 0.35);
+}
+
+.mode-nav-btn:focus-visible,
+.mode-nav-dropdown-btn:focus-visible,
+.nav-action-btn:focus-visible,
+.nav-tool-btn:focus-visible {
+ outline: 2px solid var(--accent-cyan, #4d7dbf);
+ outline-offset: 2px;
+}
diff --git a/static/css/index.css b/static/css/index.css
index 54b24e3..627bddd 100644
--- a/static/css/index.css
+++ b/static/css/index.css
@@ -1,5 +1,3 @@
-@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap');
-
* {
box-sizing: border-box;
margin: 0;
@@ -7,134 +5,81 @@
}
:root {
- /* Clean, professional dark palette */
- --bg-primary: #10141a;
- --bg-secondary: #161c24;
- --bg-tertiary: #1e2632;
- --bg-card: #121820;
- --bg-elevated: #1b2431;
- --bg-dark: var(--bg-primary);
- --bg-panel: var(--bg-secondary);
+ --font-sans: 'JetBrains Mono', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+ --font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
+ /* Tactical dark palette */
+ --bg-primary: #0a0c10;
+ --bg-secondary: #0f1218;
+ --bg-tertiary: #151a23;
+ --bg-card: #121620;
+ --bg-elevated: #1a202c;
- /* Accents */
- --accent-primary: #e7ebf2;
- --accent-red: #c84c4c;
- --accent-blue: #4d7dbf;
- --accent-cyan: #4d7dbf;
- --accent-cyan-bright: #6f93c6;
- --accent-green: #5fb58a;
- --accent-amber: #c9a26a;
- --accent-orange: #c98c4a;
- --accent-yellow: #d6c26b;
- --accent-purple: #8a7bbf;
- --accent-red-dim: rgba(200, 76, 76, 0.18);
- --accent-blue-dim: rgba(77, 125, 191, 0.18);
- --accent-cyan-dim: rgba(77, 125, 191, 0.18);
- --accent-green-dim: rgba(95, 181, 138, 0.18);
+ /* Accent colors - sophisticated blue/amber */
+ --accent-cyan: #4a9eff;
+ --accent-cyan-dim: rgba(74, 158, 255, 0.15);
+ --accent-green: #22c55e;
+ --accent-green-dim: rgba(34, 197, 94, 0.15);
+ --accent-red: #ef4444;
+ --accent-red-dim: rgba(239, 68, 68, 0.15);
+ --accent-orange: #f59e0b;
+ --accent-amber: #d4a853;
+ --accent-amber-dim: rgba(212, 168, 83, 0.15);
/* Text hierarchy */
- --text-primary: #e7ebf2;
- --text-secondary: #b7c1cf;
- --text-dim: #8a97a8;
- --text-muted: #6d7a8c;
+ --text-primary: #e8eaed;
+ --text-secondary: #9ca3af;
+ --text-dim: #4b5563;
+ --text-muted: #374151;
/* Borders */
- --border-color: #202833;
- --border-light: #2b3645;
- --border-glow: rgba(77, 125, 191, 0.2);
+ --border-color: #1f2937;
+ --border-light: #374151;
+ --border-glow: rgba(74, 158, 255, 0.2);
/* Status colors */
- --status-online: #5fb58a;
- --status-warning: #c9a26a;
- --status-error: #c84c4c;
- --status-offline: #6c7788;
+ --status-online: #22c55e;
+ --status-warning: #f59e0b;
+ --status-error: #ef4444;
+ --status-offline: #6b7280;
}
[data-theme="light"] {
- --bg-primary: #f4f6f9;
- --bg-secondary: #e9edf3;
- --bg-tertiary: #dde3ec;
+ --bg-primary: #f8fafc;
+ --bg-secondary: #f1f5f9;
+ --bg-tertiary: #e2e8f0;
--bg-card: #ffffff;
- --bg-elevated: #f4f6f9;
- --bg-dark: var(--bg-primary);
- --bg-panel: var(--bg-secondary);
- --accent-primary: #10141a;
- --accent-red: #e11d48;
- --accent-blue: #1d4ed8;
- --accent-cyan: #1d4ed8;
- --accent-cyan-bright: #2563eb;
+ --bg-elevated: #f8fafc;
+ --accent-cyan: #2563eb;
+ --accent-cyan-dim: rgba(37, 99, 235, 0.1);
--accent-green: #16a34a;
- --accent-amber: #c98c4a;
- --accent-orange: #ea580c;
- --accent-yellow: #eab308;
- --accent-purple: #7c3aed;
- --text-primary: #10141a;
- --text-secondary: #485161;
- --text-dim: #7f8b9b;
- --text-muted: #c2cad6;
- --border-color: #d6dce6;
- --border-light: #c5cedb;
- --border-glow: rgba(225, 29, 72, 0.18);
- --accent-blue-dim: rgba(29, 78, 216, 0.15);
- --accent-cyan-dim: rgba(29, 78, 216, 0.15);
- --accent-green-dim: rgba(22, 163, 74, 0.15);
+ --accent-red: #dc2626;
+ --accent-orange: #d97706;
+ --accent-amber: #b45309;
+ --text-primary: #0f172a;
+ --text-secondary: #475569;
+ --text-dim: #94a3b8;
+ --text-muted: #cbd5e1;
+ --border-color: #e2e8f0;
+ --border-light: #cbd5e1;
+ --border-glow: rgba(37, 99, 235, 0.15);
}
[data-theme="light"] body {
background: var(--bg-primary);
}
-[data-theme="light"] body::before {
- opacity: 0.15;
-}
-
-[data-theme="light"] body::after {
- background: radial-gradient(circle at 50% 50%, transparent 0%, rgba(6, 12, 20, 0.08) 70%);
-}
-
[data-theme="light"] .leaflet-tile-pane {
filter: none;
}
-html, body {
- height: 100%;
- overflow: hidden;
-}
-
body {
- font-family: 'Space Grotesk', 'Inter', sans-serif;
- background:
- radial-gradient(circle at 18% 12%, rgba(77, 125, 191, 0.08), transparent 50%),
- linear-gradient(180deg, rgba(18, 22, 28, 0.98), rgba(12, 15, 20, 0.98)),
- var(--bg-primary);
+ font-family: var(--font-sans);
+ background: var(--bg-primary);
color: var(--text-primary);
+ min-height: 100vh;
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
- position: relative;
-}
-
-body::before {
- content: '';
- position: fixed;
- inset: 0;
- background-image:
- radial-gradient(circle, rgba(255, 255, 255, 0.18) 1px, transparent 1.5px),
- radial-gradient(circle, rgba(255, 255, 255, 0.12) 1px, transparent 2px);
- background-size: 120px 120px, 220px 220px;
- background-position: 0 0, 60px 80px;
- opacity: 0.12;
- pointer-events: none;
- z-index: 0;
-}
-
-body::after {
- content: '';
- position: fixed;
- inset: 0;
- background: radial-gradient(circle at 50% 50%, transparent 0%, rgba(8, 10, 14, 0.55) 72%);
- pointer-events: none;
- z-index: 0;
}
/* ============================================
@@ -164,7 +109,7 @@ body::after {
bottom: 0;
background:
radial-gradient(circle at 50% 50%, rgba(74, 158, 255, 0.03) 0%, transparent 50%),
- linear-gradient(180deg, transparent 0%, rgba(77, 125, 191, 0.02) 100%);
+ linear-gradient(180deg, transparent 0%, rgba(0, 212, 255, 0.02) 100%);
pointer-events: none;
}
@@ -274,10 +219,10 @@ body::after {
@keyframes logoPulse {
0%, 100% {
- filter: drop-shadow(0 0 15px rgba(77, 125, 191, 0.3));
+ filter: drop-shadow(0 0 15px rgba(0, 212, 255, 0.3));
}
50% {
- filter: drop-shadow(0 0 30px rgba(77, 125, 191, 0.6));
+ filter: drop-shadow(0 0 30px rgba(0, 212, 255, 0.6));
}
}
@@ -300,12 +245,12 @@ body::after {
@keyframes dotPulse {
0%, 100% {
- fill: #5fb58a;
- filter: drop-shadow(0 0 5px rgba(95, 181, 138, 0.5));
+ fill: #00ff88;
+ filter: drop-shadow(0 0 5px rgba(0, 255, 136, 0.5));
}
50% {
fill: #00ffaa;
- filter: drop-shadow(0 0 15px rgba(95, 181, 138, 0.9));
+ filter: drop-shadow(0 0 15px rgba(0, 255, 136, 0.9));
}
}
@@ -314,17 +259,17 @@ body::after {
}
.welcome-title {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 2.5rem;
font-weight: 700;
color: var(--text-primary);
letter-spacing: 0.2em;
margin: 0;
- text-shadow: 0 0 20px rgba(77, 125, 191, 0.3);
+ text-shadow: 0 0 20px rgba(0, 212, 255, 0.3);
}
.welcome-tagline {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 0.9rem;
color: var(--accent-cyan);
letter-spacing: 0.15em;
@@ -333,7 +278,7 @@ body::after {
.welcome-version {
display: inline-block;
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 0.65rem;
color: var(--bg-primary);
background: var(--accent-cyan);
@@ -352,7 +297,7 @@ body::after {
}
.welcome-content h2 {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--text-secondary);
text-transform: uppercase;
@@ -388,14 +333,14 @@ body::after {
}
.changelog-version {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 0.8rem;
color: var(--accent-cyan);
font-weight: 600;
}
.changelog-date {
- font-family: 'Space Grotesk', 'Inter', sans-serif;
+ font-family: var(--font-sans);
font-size: 0.7rem;
color: var(--text-dim);
}
@@ -407,7 +352,7 @@ body::after {
}
.changelog-list li {
- font-family: 'Space Grotesk', 'Inter', sans-serif;
+ font-family: var(--font-sans);
font-size: 0.75rem;
color: var(--text-secondary);
margin-bottom: 6px;
@@ -419,7 +364,7 @@ body::after {
position: absolute;
left: -15px;
color: var(--accent-green);
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
}
/* Mode Selection Grid */
@@ -465,7 +410,7 @@ body::after {
background: var(--bg-elevated);
border-color: var(--accent-cyan);
transform: translateY(-2px);
- box-shadow: 0 4px 20px rgba(77, 125, 191, 0.15);
+ box-shadow: 0 4px 20px rgba(0, 212, 255, 0.15);
}
.mode-card:hover .mode-icon {
@@ -490,7 +435,7 @@ body::after {
}
.mode-card .mode-name {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 0.75rem;
font-weight: 600;
color: var(--text-primary);
@@ -499,7 +444,7 @@ body::after {
}
.mode-card .mode-desc {
- font-family: 'Space Grotesk', 'Inter', sans-serif;
+ font-family: var(--font-sans);
font-size: 0.65rem;
color: var(--text-dim);
margin-top: 4px;
@@ -518,7 +463,7 @@ body::after {
display: flex;
align-items: center;
gap: 8px;
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 0.7rem;
font-weight: 600;
color: var(--text-secondary);
@@ -572,7 +517,7 @@ body::after {
}
.welcome-footer p {
- font-family: 'Space Grotesk', 'Inter', sans-serif;
+ font-family: var(--font-sans);
font-size: 0.7rem;
color: var(--text-dim);
letter-spacing: 0.1em;
@@ -668,23 +613,18 @@ body::after {
max-width: 100%;
margin: 0;
padding: 0;
- position: relative;
- z-index: 1;
}
header {
- background: linear-gradient(180deg, rgba(15, 20, 28, 0.92), rgba(15, 20, 28, 0.82));
- padding: 12px 14px;
+ background: var(--bg-secondary);
+ padding: 10px 12px;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
- border-bottom: 1px solid var(--border-light);
+ border-bottom: 1px solid var(--border-color);
position: relative;
min-height: 52px;
- backdrop-filter: blur(12px);
- box-shadow: 0 12px 30px rgba(5, 9, 15, 0.45);
- z-index: 2;
}
@media (min-width: 768px) {
@@ -708,7 +648,7 @@ header::before {
left: 0;
right: 0;
height: 2px;
- background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
+ background: linear-gradient(90deg, var(--accent-cyan) 0%, var(--accent-amber) 50%, var(--accent-cyan) 100%);
opacity: 0.8;
}
@@ -718,9 +658,9 @@ header::after {
header h1 {
color: var(--text-primary);
- font-size: 1.12rem;
+ font-size: 1.1rem;
font-weight: 600;
- letter-spacing: 0.2em;
+ letter-spacing: 0.15em;
margin: 0;
display: inline;
vertical-align: middle;
@@ -735,7 +675,7 @@ header h1 {
.logo svg {
width: 36px;
height: 36px;
- filter: drop-shadow(0 0 10px rgba(255, 59, 48, 0.2));
+ filter: drop-shadow(0 0 8px var(--accent-cyan-dim));
transition: filter 0.3s ease;
}
@@ -746,13 +686,9 @@ header h1 {
/* Mode Navigation Bar */
.mode-nav {
display: none;
- background: linear-gradient(180deg, rgba(17, 22, 32, 0.88), rgba(15, 20, 28, 0.88));
- border-bottom: 1px solid var(--border-light);
+ background: var(--bg-tertiary);
+ border-bottom: 1px solid var(--border-color);
padding: 0 20px;
- backdrop-filter: blur(10px);
- box-shadow: 0 10px 20px rgba(5, 9, 15, 0.35);
- position: relative;
- z-index: 2;
}
@media (min-width: 1024px) {
@@ -777,7 +713,6 @@ header h1 {
letter-spacing: 1px;
margin-right: 8px;
font-weight: 500;
- font-family: 'JetBrains Mono', monospace;
}
.mode-nav-divider {
@@ -794,50 +729,38 @@ header h1 {
padding: 8px 14px;
background: transparent;
border: 1px solid transparent;
- border-radius: 8px;
+ border-radius: 4px;
color: var(--text-secondary);
- font-family: 'Space Grotesk', 'Inter', sans-serif;
+ font-family: var(--font-sans);
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
-.mode-nav-btn:focus-visible,
-.mode-nav-dropdown-btn:focus-visible,
-.nav-action-btn:focus-visible,
-.nav-tool-btn:focus-visible {
- outline: 2px solid var(--accent-cyan);
- outline-offset: 2px;
-}
-
.mode-nav-btn .nav-icon {
font-size: 14px;
}
.mode-nav-btn .nav-label {
text-transform: uppercase;
- letter-spacing: 0.08em;
- font-family: 'JetBrains Mono', monospace;
- font-size: 10px;
+ letter-spacing: 0.5px;
}
.mode-nav-btn:hover {
- background: rgba(27, 36, 51, 0.8);
+ background: var(--bg-elevated);
color: var(--text-primary);
- border-color: var(--border-light);
+ border-color: var(--border-color);
}
.mode-nav-btn.active {
- background: rgba(27, 36, 51, 0.9);
- color: var(--text-primary);
+ background: var(--accent-cyan);
+ color: var(--bg-primary);
border-color: var(--accent-cyan);
- box-shadow: inset 0 -2px 0 var(--accent-cyan);
}
.mode-nav-btn.active .nav-icon {
- color: var(--accent-cyan);
- filter: none;
+ filter: brightness(0);
}
.mode-nav-actions {
@@ -851,11 +774,11 @@ header h1 {
align-items: center;
gap: 6px;
padding: 8px 14px;
- background: rgba(24, 31, 44, 0.85);
- border: 1px solid var(--border-light);
- border-radius: 8px;
- color: var(--text-primary);
- font-family: 'Space Grotesk', 'Inter', sans-serif;
+ background: var(--bg-elevated);
+ border: 1px solid var(--accent-cyan);
+ border-radius: 4px;
+ color: var(--accent-cyan);
+ font-family: var(--font-sans);
font-size: 11px;
font-weight: 500;
text-decoration: none;
@@ -869,16 +792,12 @@ header h1 {
.nav-action-btn .nav-label {
text-transform: uppercase;
- letter-spacing: 0.08em;
- font-family: 'JetBrains Mono', monospace;
- font-size: 10px;
+ letter-spacing: 0.5px;
}
.nav-action-btn:hover {
- background: rgba(27, 36, 51, 0.95);
- color: var(--text-primary);
- box-shadow: 0 8px 16px rgba(5, 9, 15, 0.35);
- border-color: var(--accent-cyan);
+ background: var(--accent-cyan);
+ color: var(--bg-primary);
}
/* Dropdown Navigation */
@@ -893,9 +812,9 @@ header h1 {
padding: 8px 14px;
background: transparent;
border: 1px solid transparent;
- border-radius: 8px;
+ border-radius: 4px;
color: var(--text-secondary);
- font-family: 'Space Grotesk', 'Inter', sans-serif;
+ font-family: var(--font-sans);
font-size: 11px;
font-weight: 500;
cursor: pointer;
@@ -903,9 +822,9 @@ header h1 {
}
.mode-nav-dropdown-btn:hover {
- background: rgba(27, 36, 51, 0.8);
+ background: var(--bg-elevated);
color: var(--text-primary);
- border-color: var(--border-light);
+ border-color: var(--border-color);
}
.mode-nav-dropdown-btn .nav-icon {
@@ -914,9 +833,7 @@ header h1 {
.mode-nav-dropdown-btn .nav-label {
text-transform: uppercase;
- letter-spacing: 0.08em;
- font-family: 'JetBrains Mono', monospace;
- font-size: 10px;
+ letter-spacing: 0.5px;
}
.mode-nav-dropdown-btn .dropdown-arrow {
@@ -926,9 +843,9 @@ header h1 {
}
.mode-nav-dropdown.open .mode-nav-dropdown-btn {
- background: rgba(20, 33, 53, 0.7);
+ background: var(--bg-elevated);
color: var(--text-primary);
- border-color: var(--border-light);
+ border-color: var(--border-color);
}
.mode-nav-dropdown.open .dropdown-arrow {
@@ -936,15 +853,13 @@ header h1 {
}
.mode-nav-dropdown.has-active .mode-nav-dropdown-btn {
- background: rgba(27, 36, 51, 0.9);
- color: var(--text-primary);
+ background: var(--accent-cyan);
+ color: var(--bg-primary);
border-color: var(--accent-cyan);
- box-shadow: inset 0 -2px 0 var(--accent-cyan);
}
.mode-nav-dropdown.has-active .mode-nav-dropdown-btn .nav-icon {
- color: var(--accent-cyan);
- filter: none;
+ filter: brightness(0);
}
.mode-nav-dropdown-menu {
@@ -953,17 +868,16 @@ header h1 {
left: 0;
margin-top: 4px;
min-width: 180px;
- background: rgba(16, 22, 32, 0.96);
- border: 1px solid var(--border-light);
- border-radius: 10px;
- box-shadow: 0 16px 36px rgba(5, 9, 15, 0.55);
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
opacity: 0;
visibility: hidden;
transform: translateY(-8px);
transition: all 0.15s ease;
z-index: 1000;
padding: 6px;
- backdrop-filter: blur(12px);
}
.mode-nav-dropdown.open .mode-nav-dropdown-menu {
@@ -976,18 +890,17 @@ header h1 {
width: 100%;
justify-content: flex-start;
padding: 10px 12px;
- border-radius: 8px;
+ border-radius: 4px;
margin: 0;
}
.mode-nav-dropdown-menu .mode-nav-btn:hover {
- background: rgba(27, 36, 51, 0.8);
+ background: var(--bg-elevated);
}
.mode-nav-dropdown-menu .mode-nav-btn.active {
- background: rgba(27, 36, 51, 0.9);
- color: var(--text-primary);
- box-shadow: inset 0 -2px 0 var(--accent-cyan);
+ background: var(--accent-cyan);
+ color: var(--bg-primary);
}
/* Nav Bar Utilities (clock, theme, tools) */
@@ -1009,7 +922,7 @@ header h1 {
display: flex;
align-items: center;
gap: 6px;
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 11px;
flex-shrink: 0;
white-space: nowrap;
@@ -1044,9 +957,9 @@ header h1 {
width: 28px;
height: 28px;
min-width: 28px;
- border-radius: 8px;
- background: rgba(20, 33, 53, 0.6);
- border: 1px solid rgba(77, 125, 191, 0.12);
+ border-radius: 4px;
+ background: transparent;
+ border: 1px solid transparent;
color: var(--text-secondary);
font-size: 14px;
font-weight: bold;
@@ -1060,10 +973,9 @@ header h1 {
}
.nav-tool-btn:hover {
- background: rgba(27, 36, 51, 0.9);
- border-color: var(--accent-cyan);
+ background: var(--bg-elevated);
+ border-color: var(--border-color);
color: var(--accent-cyan);
- box-shadow: 0 6px 14px rgba(5, 9, 15, 0.35);
}
/* Donate button - warm amber accent */
@@ -1118,7 +1030,7 @@ header h1 {
.version-badge {
font-size: 0.6rem;
font-weight: 500;
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
letter-spacing: 0.05em;
color: var(--text-secondary);
background: var(--bg-tertiary);
@@ -1143,7 +1055,7 @@ header p.subtitle {
header h1 .tagline {
font-weight: 400;
- color: var(--accent-cyan, #4d7dbf);
+ color: var(--accent-cyan, #00d4ff);
font-size: 0.6em;
opacity: 0.9;
display: none;
@@ -1177,7 +1089,7 @@ header h1 .tagline {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 6px;
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
transition: all 0.2s ease;
}
@@ -1531,14 +1443,13 @@ header h1 .tagline {
}
.sidebar {
- background: rgba(12, 18, 30, 0.92);
- border-right: 1px solid var(--border-light);
+ background: var(--bg-secondary);
+ border-right: 1px solid var(--border-color);
padding: 12px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
- backdrop-filter: blur(12px);
}
/* Mobile: Sidebar is controlled by mobile-drawer class from responsive.css */
@@ -1553,21 +1464,20 @@ header h1 .tagline {
}
.section {
- background: rgba(16, 26, 42, 0.75);
- border: 1px solid var(--border-light);
- border-radius: 10px;
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
overflow: visible;
padding: 12px;
position: relative;
- box-shadow: 0 12px 24px rgba(4, 10, 20, 0.35);
}
.section h3 {
color: var(--text-primary);
margin: -12px -12px 12px -12px;
padding: 10px 12px;
- background: linear-gradient(90deg, rgba(12, 18, 30, 0.9), rgba(20, 33, 53, 0.9));
- border-bottom: 1px solid var(--border-light);
+ background: var(--bg-secondary);
+ border-bottom: 1px solid var(--border-color);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
@@ -1595,8 +1505,8 @@ header h1 .tagline {
transition: transform 0.2s ease, color 0.2s ease;
margin-left: auto;
padding: 2px 6px;
- background: rgba(5, 7, 13, 0.6);
- border-radius: 6px;
+ background: var(--bg-primary);
+ border-radius: 3px;
}
.section.collapsed h3 {
@@ -1619,7 +1529,7 @@ header h1 .tagline {
}
.section h3:hover {
- background: linear-gradient(90deg, rgba(20, 33, 53, 0.9), rgba(16, 26, 42, 0.9));
+ background: var(--bg-elevated);
}
.section h3:hover::before {
@@ -1668,7 +1578,7 @@ header h1 .tagline {
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-primary);
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 12px;
transition: all 0.15s ease;
}
@@ -1727,7 +1637,7 @@ header h1 .tagline {
border: 1px solid var(--border-color);
color: var(--text-secondary);
cursor: pointer;
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
@@ -1747,7 +1657,7 @@ header h1 .tagline {
background: var(--accent-green);
border: none;
color: #fff;
- font-family: 'Space Grotesk', 'Inter', sans-serif;
+ font-family: var(--font-sans);
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
@@ -1764,7 +1674,7 @@ header h1 .tagline {
.run-btn:hover {
background: #1db954;
- box-shadow: 0 4px 12px rgba(95, 181, 138, 0.3);
+ box-shadow: 0 4px 12px rgba(34, 197, 94, 0.3);
transform: translateY(-1px);
}
@@ -1784,7 +1694,7 @@ header h1 .tagline {
background: var(--accent-red);
border: none;
color: #fff;
- font-family: 'Space Grotesk', 'Inter', sans-serif;
+ font-family: var(--font-sans);
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
@@ -1847,7 +1757,7 @@ header h1 .tagline {
gap: 8px;
font-size: 10px;
color: var(--text-secondary);
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
}
.stats>div {
@@ -1873,7 +1783,7 @@ header h1 .tagline {
flex: 1;
padding: 10px;
overflow-y: auto;
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 11px;
background: var(--bg-primary);
min-height: 0; /* Allow shrinking in flex context */
@@ -1945,7 +1855,7 @@ header h1 .tagline {
.message .address {
color: var(--accent-green);
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 11px;
margin-bottom: 8px;
}
@@ -1958,7 +1868,7 @@ header h1 .tagline {
}
.message .content.numeric {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 15px;
letter-spacing: 2px;
color: var(--accent-cyan);
@@ -2179,7 +2089,7 @@ header h1 .tagline {
text-transform: uppercase;
letter-spacing: 1px;
transition: all 0.2s ease;
- font-family: 'Space Grotesk', 'Inter', sans-serif;
+ font-family: var(--font-sans);
}
.control-btn:hover {
@@ -2435,7 +2345,7 @@ header h1 .tagline {
border: 1px solid var(--accent-cyan);
border-radius: 4px;
overflow: hidden;
- box-shadow: 0 0 20px rgba(77, 125, 191, 0.2);
+ box-shadow: 0 0 20px rgba(0, 212, 255, 0.2);
}
#aircraftMap {
@@ -2447,7 +2357,7 @@ header h1 .tagline {
/* Dark theme for Leaflet */
.leaflet-container {
background: #0a0a0a;
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
}
/* Using actual dark tiles now - no filter needed */
@@ -2484,7 +2394,7 @@ header h1 .tagline {
display: flex;
justify-content: space-between;
z-index: 1000;
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 11px;
color: var(--accent-cyan);
text-shadow: 0 0 5px var(--accent-cyan);
@@ -2501,7 +2411,7 @@ header h1 .tagline {
display: flex;
justify-content: space-between;
z-index: 1000;
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 10px;
color: var(--accent-cyan);
text-shadow: 0 0 5px var(--accent-cyan);
@@ -2522,7 +2432,7 @@ header h1 .tagline {
}
.aircraft-popup {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 11px;
}
@@ -2566,7 +2476,7 @@ header h1 .tagline {
background: rgba(0, 0, 0, 0.8) !important;
border: 1px solid var(--accent-cyan) !important;
color: var(--accent-cyan) !important;
- font-family: 'JetBrains Mono', monospace !important;
+ font-family: var(--font-mono) !important;
font-size: 10px !important;
padding: 2px 6px !important;
border-radius: 2px !important;
@@ -2594,7 +2504,7 @@ header h1 .tagline {
border-radius: 4px;
color: var(--text-secondary);
cursor: pointer;
- font-family: 'Space Grotesk', 'Inter', sans-serif;
+ font-family: var(--font-sans);
font-size: 11px;
text-transform: uppercase;
transition: all 0.2s ease;
@@ -2769,7 +2679,7 @@ header h1 .tagline {
border-radius: 8px;
padding: 15px;
margin-bottom: 15px;
- box-shadow: 0 0 20px rgba(77, 125, 191, 0.1);
+ box-shadow: 0 0 20px rgba(0, 212, 255, 0.1);
}
.countdown-satellite-name {
@@ -2810,14 +2720,14 @@ header h1 .tagline {
color: var(--accent-cyan);
font-size: 22px;
font-weight: 700;
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
text-shadow: 0 0 15px var(--accent-cyan-dim);
line-height: 1.2;
}
.countdown-value.active {
color: var(--accent-green);
- text-shadow: 0 0 15px rgba(95, 181, 138, 0.4);
+ text-shadow: 0 0 15px rgba(0, 255, 136, 0.4);
animation: countdown-pulse 1s ease-in-out infinite;
}
@@ -3204,7 +3114,7 @@ header h1 .tagline {
}
.sensor-card .data-value {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 14px;
color: var(--accent-cyan);
}
@@ -3254,7 +3164,7 @@ header h1 .tagline {
display: flex;
gap: 15px;
font-size: 10px;
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
}
.recon-stats span {
@@ -3288,7 +3198,7 @@ header h1 .tagline {
.device-row.new-device {
border-left: 3px solid var(--accent-green);
- background: rgba(95, 181, 138, 0.05);
+ background: rgba(0, 255, 136, 0.05);
}
.device-info {
@@ -3304,14 +3214,14 @@ header h1 .tagline {
.device-id {
color: var(--text-dim);
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 10px;
}
.device-meta {
text-align: right;
color: var(--text-secondary);
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
}
.device-meta.encrypted {
@@ -3387,7 +3297,7 @@ header h1 .tagline {
}
.hex-dump {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 10px;
color: var(--text-dim);
background: var(--bg-primary);
@@ -4059,9 +3969,9 @@ header h1 .tagline {
}
.bt-detail-address {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 10px;
- color: #4d7dbf;
+ color: #00d4ff;
}
.bt-detail-rssi-display {
@@ -4073,7 +3983,7 @@ header h1 .tagline {
}
.bt-detail-rssi-value {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 20px;
font-weight: 700;
}
@@ -4115,8 +4025,8 @@ header h1 .tagline {
}
.bt-detail-badge.baseline {
- background: rgba(95, 181, 138, 0.2);
- color: #5fb58a;
+ background: rgba(34, 197, 94, 0.2);
+ color: #22c55e;
}
.bt-detail-badge.flag {
@@ -4168,7 +4078,7 @@ header h1 .tagline {
}
.bt-detail-services-list {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 8px;
color: var(--text-dim);
white-space: nowrap;
@@ -4196,7 +4106,7 @@ header h1 .tagline {
/* Selected device highlight */
.bt-device-row.selected {
- background: rgba(77, 125, 191, 0.1);
+ background: rgba(0, 212, 255, 0.1);
border-color: var(--accent-cyan);
}
@@ -4311,7 +4221,7 @@ header h1 .tagline {
}
.bt-signal-dist .signal-bar.strong {
- background: linear-gradient(90deg, #5fb58a, #16a34a);
+ background: linear-gradient(90deg, #22c55e, #16a34a);
}
.bt-signal-dist .signal-bar.medium {
@@ -4337,7 +4247,7 @@ header h1 .tagline {
}
.bt-device-row:hover {
- background: rgba(77, 125, 191, 0.05);
+ background: rgba(0, 212, 255, 0.05);
border-color: var(--accent-cyan);
}
@@ -4415,7 +4325,7 @@ header h1 .tagline {
}
.bt-rssi-value {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 11px;
font-weight: 600;
min-width: 28px;
@@ -4435,7 +4345,7 @@ header h1 .tagline {
}
.bt-status-dot.known {
- background: #5fb58a;
+ background: #22c55e;
}
.bt-row-secondary {
@@ -4586,8 +4496,8 @@ header h1 .tagline {
}
.bt-modal-badge.baseline {
- background: rgba(95, 181, 138, 0.15);
- color: #5fb58a;
+ background: rgba(34, 197, 94, 0.15);
+ color: #22c55e;
}
.bt-modal-badge.flag {
@@ -4631,7 +4541,7 @@ header h1 .tagline {
}
.bt-modal-btn-primary {
- background: var(--accent-cyan, #4d7dbf);
+ background: var(--accent-cyan, #00d4ff);
border: none;
color: #000;
font-weight: 600;
@@ -4774,7 +4684,7 @@ header h1 .tagline {
flex-direction: column;
gap: 4px;
font-size: 10px;
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
}
.security-legend-item {
@@ -4821,7 +4731,7 @@ header h1 .tagline {
}
.signal-value {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 28px;
color: var(--accent-cyan);
text-shadow: 0 0 10px var(--accent-cyan-dim);
@@ -4839,7 +4749,7 @@ header h1 .tagline {
.signal-value.strong {
color: var(--accent-green);
- text-shadow: 0 0 10px rgba(95, 181, 138, 0.4);
+ text-shadow: 0 0 10px rgba(0, 255, 136, 0.4);
}
.signal-bars-large {
@@ -4931,7 +4841,7 @@ body::before {
max-width: 550px;
padding: 30px;
text-align: center;
- box-shadow: 0 0 50px rgba(77, 125, 191, 0.3);
+ box-shadow: 0 0 50px rgba(0, 212, 255, 0.3);
pointer-events: auto;
position: relative;
z-index: 100000;
@@ -4974,7 +4884,7 @@ body::before {
color: #000;
border: none;
padding: 12px 40px;
- font-family: 'Space Grotesk', 'Inter', sans-serif;
+ font-family: var(--font-sans);
font-size: 14px;
font-weight: 600;
letter-spacing: 2px;
@@ -4988,7 +4898,7 @@ body::before {
.disclaimer-modal .accept-btn:hover {
background: #fff;
- box-shadow: 0 0 20px rgba(77, 125, 191, 0.5);
+ box-shadow: 0 0 20px rgba(0, 212, 255, 0.5);
}
.disclaimer-hidden {
@@ -5042,7 +4952,7 @@ body::before {
/* Beacon Flood Alert */
.beacon-flood-alert {
background: linear-gradient(135deg, rgba(255, 0, 0, 0.2), rgba(255, 100, 0, 0.2));
- border: 1px solid #c84c4c;
+ border: 1px solid #ff4444;
border-radius: 6px;
padding: 10px;
margin: 10px 0;
@@ -5135,7 +5045,7 @@ body::before {
}
.tracker-following-alert h4 {
- color: #c84c4c;
+ color: #ff4444;
margin: 0 0 10px 0;
display: flex;
align-items: center;
@@ -5239,7 +5149,7 @@ body::before {
/* Map Clustering */
.marker-cluster {
- background: rgba(77, 125, 191, 0.6);
+ background: rgba(0, 212, 255, 0.6);
border-radius: 50%;
display: flex;
align-items: center;
@@ -5337,7 +5247,7 @@ body::before {
.meter-value {
font-size: 10px;
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
color: var(--text-secondary);
width: 50px;
text-align: right;
@@ -5494,7 +5404,7 @@ body::before {
}
.freq-digits {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 56px;
font-weight: 700;
color: var(--accent-cyan);
@@ -5511,11 +5421,11 @@ body::before {
text-shadow:
0 0 10px var(--accent-green),
0 0 25px var(--accent-green),
- 0 0 50px rgba(95, 181, 138, 0.4);
+ 0 0 50px rgba(34, 197, 94, 0.4);
}
.freq-unit {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 20px;
color: var(--text-secondary);
margin-left: 8px;
@@ -5659,7 +5569,7 @@ body::before {
}
.knob-value {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 16px;
font-weight: 600;
color: var(--accent-cyan);
@@ -5784,7 +5694,7 @@ body::before {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
border-radius: 4px;
@@ -5846,13 +5756,13 @@ body::before {
}
.signal-arc-label {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 8px;
fill: var(--text-muted);
}
.signal-arc-value {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 14px;
font-weight: 600;
fill: var(--accent-cyan);
@@ -5884,7 +5794,7 @@ body::before {
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--accent-cyan);
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 14px;
font-weight: 600;
text-align: center;
@@ -5936,7 +5846,7 @@ body::before {
.radio-action-btn.scan:hover {
background: #1db954;
- box-shadow: 0 0 20px rgba(95, 181, 138, 0.4);
+ box-shadow: 0 0 20px rgba(34, 197, 94, 0.4);
}
.radio-action-btn.scan.active {
@@ -6020,7 +5930,7 @@ body::before {
max-height: 200px;
overflow-y: auto;
padding: 10px 15px;
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 11px;
}
@@ -6094,7 +6004,7 @@ body::before {
.radio-module-box.scanner-main {
background: linear-gradient(180deg, var(--bg-secondary) 0%, rgba(0,20,30,0.95) 100%);
border: 1px solid var(--accent-cyan-dim);
- box-shadow: 0 0 20px rgba(77, 125, 191, 0.1), inset 0 0 40px rgba(0, 0, 0, 0.3);
+ box-shadow: 0 0 20px rgba(0, 212, 255, 0.1), inset 0 0 40px rgba(0, 0, 0, 0.3);
}
.radio-module-box.scanner-main::before {
@@ -6134,7 +6044,7 @@ body::before {
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-primary);
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 12px;
padding: 6px 8px;
text-align: center;
@@ -6173,14 +6083,25 @@ body::before {
cursor: not-allowed;
}
-.radio-action-btn.scan {
+.radio-action-btn.scan,
+.radio-action-btn.listen {
background: var(--accent-green);
border-color: var(--accent-green);
color: #000;
}
-.radio-action-btn.scan:hover:not(:disabled) {
- box-shadow: 0 0 15px rgba(95, 181, 138, 0.4);
+.radio-action-btn.scan:hover:not(:disabled),
+.radio-action-btn.listen:hover:not(:disabled) {
+ box-shadow: 0 0 15px rgba(0, 255, 136, 0.4);
+}
+
+.radio-action-btn.listen.active {
+ background: var(--accent-red);
+ border-color: var(--accent-red);
+}
+
+.radio-action-btn.listen.active:hover:not(:disabled) {
+ box-shadow: 0 0 20px var(--accent-red-dim);
}
/* Statistics Box */
@@ -6192,7 +6113,7 @@ body::before {
}
.stat-value {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 22px;
font-weight: bold;
}
@@ -6240,7 +6161,7 @@ body::before {
.tune-btn {
padding: 4px 8px;
font-size: 10px;
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
background: var(--bg-elevated);
border: 1px solid var(--border-color);
color: var(--text-secondary);
@@ -6262,7 +6183,7 @@ body::before {
}
.radio-module-box table tbody tr:hover {
- background: rgba(77, 125, 191, 0.05);
+ background: rgba(0, 212, 255, 0.05);
}
/* Log Content Compact */
@@ -6270,7 +6191,7 @@ body::before {
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
padding: 8px;
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
}
/* Listening Mode Selector Buttons */
@@ -6295,14 +6216,14 @@ body::before {
.radio-mode-btn:hover {
border-color: var(--accent-cyan);
color: var(--accent-cyan);
- background: rgba(77, 125, 191, 0.05);
+ background: rgba(0, 212, 255, 0.05);
}
.radio-mode-btn.active {
- background: linear-gradient(135deg, rgba(77, 125, 191, 0.2), rgba(95, 181, 138, 0.1));
+ background: linear-gradient(135deg, rgba(0, 212, 255, 0.2), rgba(0, 255, 136, 0.1));
border-color: var(--accent-cyan);
color: var(--accent-cyan);
- box-shadow: 0 0 20px rgba(77, 125, 191, 0.2), inset 0 0 20px rgba(77, 125, 191, 0.05);
+ box-shadow: 0 0 20px rgba(0, 212, 255, 0.2), inset 0 0 20px rgba(0, 212, 255, 0.05);
}
/* Listening Mode Panels */
@@ -6317,7 +6238,7 @@ body::before {
/* Frequency Preset Buttons */
.preset-freq-btn {
padding: 8px 14px;
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 11px;
background: var(--bg-elevated);
border: 1px solid var(--border-color);
@@ -6330,7 +6251,7 @@ body::before {
.preset-freq-btn:hover {
border-color: var(--accent-cyan);
color: var(--accent-cyan);
- background: rgba(77, 125, 191, 0.1);
+ background: rgba(0, 212, 255, 0.1);
}
.preset-freq-btn:active {
diff --git a/static/css/login.css b/static/css/login.css
index ca1da91..65c0663 100644
--- a/static/css/login.css
+++ b/static/css/login.css
@@ -37,7 +37,7 @@
/* Typography */
.landing-title {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 2.2rem;
font-weight: 700;
letter-spacing: 0.4em;
@@ -48,7 +48,7 @@
}
.landing-tagline {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
color: var(--accent-cyan);
font-size: 0.9rem;
letter-spacing: 0.15em;
@@ -71,7 +71,7 @@
/* Hacker Style Error */
.flash-error {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 10px;
color: var(--accent-red);
background: rgba(239, 68, 68, 0.1);
@@ -94,7 +94,7 @@
color: var(--accent-cyan);
padding: 12px;
margin-bottom: 15px;
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 11px;
outline: none;
box-sizing: border-box; /* Crucial for visibility */
@@ -106,7 +106,7 @@
border: 2px solid var(--accent-cyan);
color: var(--accent-cyan);
padding: 15px;
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-weight: 600;
letter-spacing: 3px;
cursor: pointer;
@@ -116,7 +116,7 @@
.landing-version {
margin-top: 25px;
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 10px;
color: rgba(255, 255, 255, 0.3);
letter-spacing: 2px;
diff --git a/static/css/modes/aprs.css b/static/css/modes/aprs.css
index cb19ccb..f28ccf7 100644
--- a/static/css/modes/aprs.css
+++ b/static/css/modes/aprs.css
@@ -1,328 +1,328 @@
-/* APRS Function Bar (Stats Strip) Styles */
-.aprs-strip {
- background: linear-gradient(180deg, var(--bg-panel) 0%, var(--bg-dark) 100%);
- border: 1px solid var(--border-color);
- border-radius: 6px;
- padding: 6px 12px;
- margin-bottom: 10px;
- overflow-x: auto;
-}
-.aprs-strip-inner {
- display: flex;
- align-items: center;
- gap: 8px;
- min-width: max-content;
-}
-.aprs-strip .strip-stat {
- display: flex;
- flex-direction: column;
- align-items: center;
- padding: 4px 10px;
- background: rgba(74, 158, 255, 0.05);
- border: 1px solid rgba(74, 158, 255, 0.15);
- border-radius: 4px;
- min-width: 55px;
-}
-.aprs-strip .strip-stat:hover {
- background: rgba(74, 158, 255, 0.1);
- border-color: rgba(74, 158, 255, 0.3);
-}
-.aprs-strip .strip-value {
- font-family: 'JetBrains Mono', monospace;
- font-size: 14px;
- font-weight: 600;
- color: var(--accent-cyan);
- line-height: 1.2;
-}
-.aprs-strip .strip-label {
- font-size: 8px;
- font-weight: 600;
- color: var(--text-dim);
- text-transform: uppercase;
- letter-spacing: 0.5px;
- margin-top: 1px;
-}
-.aprs-strip .strip-divider {
- width: 1px;
- height: 28px;
- background: var(--border-color);
- margin: 0 4px;
-}
-/* Signal stat coloring */
-.aprs-strip .signal-stat.good .strip-value { color: var(--accent-green); }
-.aprs-strip .signal-stat.warning .strip-value { color: var(--accent-yellow); }
-.aprs-strip .signal-stat.poor .strip-value { color: var(--accent-red); }
-
-/* Controls */
-.aprs-strip .strip-control {
- display: flex;
- align-items: center;
- gap: 4px;
-}
-.aprs-strip .strip-select {
- background: rgba(0,0,0,0.3);
- border: 1px solid var(--border-color);
- color: var(--text-primary);
- padding: 4px 8px;
- border-radius: 4px;
- font-size: 10px;
- cursor: pointer;
-}
-.aprs-strip .strip-select:hover {
- border-color: var(--accent-cyan);
-}
-.aprs-strip .strip-input-label {
- font-size: 9px;
- color: var(--text-muted);
- font-weight: 600;
-}
-.aprs-strip .strip-input {
- background: rgba(0,0,0,0.3);
- border: 1px solid var(--border-color);
- color: var(--text-primary);
- padding: 4px 6px;
- border-radius: 4px;
- font-size: 10px;
- width: 50px;
- text-align: center;
-}
-.aprs-strip .strip-input:hover,
-.aprs-strip .strip-input:focus {
- border-color: var(--accent-cyan);
- outline: none;
-}
-
-/* Tool Status Indicators */
-.aprs-strip .strip-tools {
- display: flex;
- gap: 4px;
-}
-.aprs-strip .strip-tool {
- font-size: 9px;
- font-weight: 600;
- padding: 3px 6px;
- border-radius: 3px;
- background: rgba(255, 59, 48, 0.2);
- color: var(--accent-red);
- border: 1px solid rgba(255, 59, 48, 0.3);
-}
-.aprs-strip .strip-tool.ok {
- background: rgba(0, 255, 136, 0.1);
- color: var(--accent-green);
- border-color: rgba(0, 255, 136, 0.3);
-}
-
-/* Buttons */
-.aprs-strip .strip-btn {
- background: rgba(74, 158, 255, 0.1);
- border: 1px solid rgba(74, 158, 255, 0.2);
- color: var(--text-primary);
- padding: 6px 12px;
- border-radius: 4px;
- font-size: 10px;
- font-weight: 600;
- cursor: pointer;
- transition: all 0.2s;
- white-space: nowrap;
-}
-.aprs-strip .strip-btn:hover:not(:disabled) {
- background: rgba(74, 158, 255, 0.2);
- border-color: rgba(74, 158, 255, 0.4);
-}
-.aprs-strip .strip-btn.primary {
- background: linear-gradient(135deg, var(--accent-green) 0%, #10b981 100%);
- border: none;
- color: #000;
-}
-.aprs-strip .strip-btn.primary:hover:not(:disabled) {
- filter: brightness(1.1);
-}
-.aprs-strip .strip-btn.stop {
- background: linear-gradient(135deg, var(--accent-red) 0%, #dc2626 100%);
- border: none;
- color: #fff;
-}
-.aprs-strip .strip-btn.stop:hover:not(:disabled) {
- filter: brightness(1.1);
-}
-.aprs-strip .strip-btn:disabled {
- opacity: 0.5;
- cursor: not-allowed;
-}
-
-/* Status indicator */
-.aprs-strip .strip-status {
- display: flex;
- align-items: center;
- gap: 6px;
- padding: 4px 8px;
- background: rgba(0,0,0,0.2);
- border-radius: 4px;
- font-size: 10px;
- font-weight: 600;
- color: var(--text-secondary);
-}
-.aprs-strip .status-dot {
- width: 8px;
- height: 8px;
- border-radius: 50%;
- background: var(--text-muted);
-}
-.aprs-strip .status-dot.listening {
- background: var(--accent-cyan);
- animation: aprs-strip-pulse 1.5s ease-in-out infinite;
-}
-.aprs-strip .status-dot.tracking {
- background: var(--accent-green);
- animation: aprs-strip-pulse 1.5s ease-in-out infinite;
-}
-.aprs-strip .status-dot.error {
- background: var(--accent-red);
-}
-@keyframes aprs-strip-pulse {
- 0%, 100% { opacity: 1; box-shadow: 0 0 4px 2px currentColor; }
- 50% { opacity: 0.6; box-shadow: none; }
-}
-
-/* Time display */
-.aprs-strip .strip-time {
- font-family: 'JetBrains Mono', monospace;
- font-size: 10px;
- color: var(--text-muted);
- padding: 4px 8px;
- background: rgba(0,0,0,0.2);
- border-radius: 4px;
- white-space: nowrap;
-}
-
-/* APRS Status Bar Styles (Sidebar - legacy) */
-.aprs-status-bar {
- margin-top: 12px;
- padding: 10px;
- background: rgba(0,0,0,0.3);
- border: 1px solid var(--border-color);
- border-radius: 4px;
-}
-.aprs-status-indicator {
- display: flex;
- align-items: center;
- gap: 8px;
- margin-bottom: 8px;
-}
-.aprs-status-dot {
- width: 10px;
- height: 10px;
- border-radius: 50%;
- background: var(--text-muted);
-}
-.aprs-status-dot.standby { background: var(--text-muted); }
-.aprs-status-dot.listening {
- background: var(--accent-cyan);
- animation: aprs-pulse 1.5s ease-in-out infinite;
-}
-.aprs-status-dot.tracking { background: var(--accent-green); }
-.aprs-status-dot.error { background: var(--accent-red); }
-@keyframes aprs-pulse {
- 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(74, 158, 255, 0.7); }
- 50% { opacity: 0.6; box-shadow: 0 0 8px 4px rgba(74, 158, 255, 0.3); }
-}
-.aprs-status-text {
- font-size: 10px;
- font-weight: bold;
- letter-spacing: 1px;
-}
-.aprs-status-stats {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- font-size: 9px;
-}
-.aprs-stat {
- color: var(--text-secondary);
-}
-.aprs-stat-label {
- color: var(--text-muted);
-}
-
-/* Signal Meter Styles */
-.aprs-signal-meter {
- margin-top: 12px;
- padding: 10px;
- background: rgba(0,0,0,0.3);
- border: 1px solid var(--border-color);
- border-radius: 4px;
-}
-.aprs-meter-header {
- display: flex;
- align-items: center;
- gap: 8px;
- margin-bottom: 8px;
-}
-.aprs-meter-label {
- font-size: 10px;
- font-weight: bold;
- letter-spacing: 1px;
- color: var(--text-secondary);
-}
-.aprs-meter-value {
- font-size: 12px;
- font-weight: bold;
- font-family: monospace;
- color: var(--accent-cyan);
- min-width: 24px;
-}
-.aprs-meter-burst {
- font-size: 9px;
- font-weight: bold;
- color: var(--accent-yellow);
- background: rgba(255, 193, 7, 0.2);
- padding: 2px 6px;
- border-radius: 3px;
- animation: burst-flash 0.3s ease-out;
-}
-@keyframes burst-flash {
- 0% { opacity: 1; transform: scale(1.1); }
- 100% { opacity: 1; transform: scale(1); }
-}
-.aprs-meter-bar-container {
- position: relative;
- height: 16px;
- background: rgba(0,0,0,0.4);
- border-radius: 3px;
- overflow: hidden;
- margin-bottom: 4px;
-}
-.aprs-meter-bar {
- height: 100%;
- width: 0%;
- background: linear-gradient(90deg,
- var(--accent-green) 0%,
- var(--accent-cyan) 50%,
- var(--accent-yellow) 75%,
- var(--accent-red) 100%
- );
- border-radius: 3px;
- transition: width 0.1s ease-out;
-}
-.aprs-meter-bar.no-signal {
- opacity: 0.3;
-}
-.aprs-meter-ticks {
- display: flex;
- justify-content: space-between;
- font-size: 8px;
- color: var(--text-muted);
- padding: 0 2px;
-}
-.aprs-meter-status {
- font-size: 9px;
- color: var(--text-muted);
- text-align: center;
- margin-top: 6px;
-}
-.aprs-meter-status.active {
- color: var(--accent-green);
-}
-.aprs-meter-status.no-signal {
- color: var(--accent-yellow);
-}
+/* APRS Function Bar (Stats Strip) Styles */
+.aprs-strip {
+ background: linear-gradient(180deg, var(--bg-panel) 0%, var(--bg-dark) 100%);
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ padding: 6px 12px;
+ margin-bottom: 10px;
+ overflow-x: auto;
+}
+.aprs-strip-inner {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: max-content;
+}
+.aprs-strip .strip-stat {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 4px 10px;
+ background: rgba(74, 158, 255, 0.05);
+ border: 1px solid rgba(74, 158, 255, 0.15);
+ border-radius: 4px;
+ min-width: 55px;
+}
+.aprs-strip .strip-stat:hover {
+ background: rgba(74, 158, 255, 0.1);
+ border-color: rgba(74, 158, 255, 0.3);
+}
+.aprs-strip .strip-value {
+ font-family: var(--font-mono);
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--accent-cyan);
+ line-height: 1.2;
+}
+.aprs-strip .strip-label {
+ font-size: 8px;
+ font-weight: 600;
+ color: var(--text-dim);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ margin-top: 1px;
+}
+.aprs-strip .strip-divider {
+ width: 1px;
+ height: 28px;
+ background: var(--border-color);
+ margin: 0 4px;
+}
+/* Signal stat coloring */
+.aprs-strip .signal-stat.good .strip-value { color: var(--accent-green); }
+.aprs-strip .signal-stat.warning .strip-value { color: var(--accent-yellow); }
+.aprs-strip .signal-stat.poor .strip-value { color: var(--accent-red); }
+
+/* Controls */
+.aprs-strip .strip-control {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+.aprs-strip .strip-select {
+ background: rgba(0,0,0,0.3);
+ border: 1px solid var(--border-color);
+ color: var(--text-primary);
+ padding: 4px 8px;
+ border-radius: 4px;
+ font-size: 10px;
+ cursor: pointer;
+}
+.aprs-strip .strip-select:hover {
+ border-color: var(--accent-cyan);
+}
+.aprs-strip .strip-input-label {
+ font-size: 9px;
+ color: var(--text-muted);
+ font-weight: 600;
+}
+.aprs-strip .strip-input {
+ background: rgba(0,0,0,0.3);
+ border: 1px solid var(--border-color);
+ color: var(--text-primary);
+ padding: 4px 6px;
+ border-radius: 4px;
+ font-size: 10px;
+ width: 50px;
+ text-align: center;
+}
+.aprs-strip .strip-input:hover,
+.aprs-strip .strip-input:focus {
+ border-color: var(--accent-cyan);
+ outline: none;
+}
+
+/* Tool Status Indicators */
+.aprs-strip .strip-tools {
+ display: flex;
+ gap: 4px;
+}
+.aprs-strip .strip-tool {
+ font-size: 9px;
+ font-weight: 600;
+ padding: 3px 6px;
+ border-radius: 3px;
+ background: rgba(255, 59, 48, 0.2);
+ color: var(--accent-red);
+ border: 1px solid rgba(255, 59, 48, 0.3);
+}
+.aprs-strip .strip-tool.ok {
+ background: rgba(0, 255, 136, 0.1);
+ color: var(--accent-green);
+ border-color: rgba(0, 255, 136, 0.3);
+}
+
+/* Buttons */
+.aprs-strip .strip-btn {
+ background: rgba(74, 158, 255, 0.1);
+ border: 1px solid rgba(74, 158, 255, 0.2);
+ color: var(--text-primary);
+ padding: 6px 12px;
+ border-radius: 4px;
+ font-size: 10px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s;
+ white-space: nowrap;
+}
+.aprs-strip .strip-btn:hover:not(:disabled) {
+ background: rgba(74, 158, 255, 0.2);
+ border-color: rgba(74, 158, 255, 0.4);
+}
+.aprs-strip .strip-btn.primary {
+ background: linear-gradient(135deg, var(--accent-green) 0%, #10b981 100%);
+ border: none;
+ color: #000;
+}
+.aprs-strip .strip-btn.primary:hover:not(:disabled) {
+ filter: brightness(1.1);
+}
+.aprs-strip .strip-btn.stop {
+ background: linear-gradient(135deg, var(--accent-red) 0%, #dc2626 100%);
+ border: none;
+ color: #fff;
+}
+.aprs-strip .strip-btn.stop:hover:not(:disabled) {
+ filter: brightness(1.1);
+}
+.aprs-strip .strip-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* Status indicator */
+.aprs-strip .strip-status {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 8px;
+ background: rgba(0,0,0,0.2);
+ border-radius: 4px;
+ font-size: 10px;
+ font-weight: 600;
+ color: var(--text-secondary);
+}
+.aprs-strip .status-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--text-muted);
+}
+.aprs-strip .status-dot.listening {
+ background: var(--accent-cyan);
+ animation: aprs-strip-pulse 1.5s ease-in-out infinite;
+}
+.aprs-strip .status-dot.tracking {
+ background: var(--accent-green);
+ animation: aprs-strip-pulse 1.5s ease-in-out infinite;
+}
+.aprs-strip .status-dot.error {
+ background: var(--accent-red);
+}
+@keyframes aprs-strip-pulse {
+ 0%, 100% { opacity: 1; box-shadow: 0 0 4px 2px currentColor; }
+ 50% { opacity: 0.6; box-shadow: none; }
+}
+
+/* Time display */
+.aprs-strip .strip-time {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ color: var(--text-muted);
+ padding: 4px 8px;
+ background: rgba(0,0,0,0.2);
+ border-radius: 4px;
+ white-space: nowrap;
+}
+
+/* APRS Status Bar Styles (Sidebar - legacy) */
+.aprs-status-bar {
+ margin-top: 12px;
+ padding: 10px;
+ background: rgba(0,0,0,0.3);
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+}
+.aprs-status-indicator {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 8px;
+}
+.aprs-status-dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ background: var(--text-muted);
+}
+.aprs-status-dot.standby { background: var(--text-muted); }
+.aprs-status-dot.listening {
+ background: var(--accent-cyan);
+ animation: aprs-pulse 1.5s ease-in-out infinite;
+}
+.aprs-status-dot.tracking { background: var(--accent-green); }
+.aprs-status-dot.error { background: var(--accent-red); }
+@keyframes aprs-pulse {
+ 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(74, 158, 255, 0.7); }
+ 50% { opacity: 0.6; box-shadow: 0 0 8px 4px rgba(74, 158, 255, 0.3); }
+}
+.aprs-status-text {
+ font-size: 10px;
+ font-weight: bold;
+ letter-spacing: 1px;
+}
+.aprs-status-stats {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ font-size: 9px;
+}
+.aprs-stat {
+ color: var(--text-secondary);
+}
+.aprs-stat-label {
+ color: var(--text-muted);
+}
+
+/* Signal Meter Styles */
+.aprs-signal-meter {
+ margin-top: 12px;
+ padding: 10px;
+ background: rgba(0,0,0,0.3);
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+}
+.aprs-meter-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 8px;
+}
+.aprs-meter-label {
+ font-size: 10px;
+ font-weight: bold;
+ letter-spacing: 1px;
+ color: var(--text-secondary);
+}
+.aprs-meter-value {
+ font-size: 12px;
+ font-weight: bold;
+ font-family: monospace;
+ color: var(--accent-cyan);
+ min-width: 24px;
+}
+.aprs-meter-burst {
+ font-size: 9px;
+ font-weight: bold;
+ color: var(--accent-yellow);
+ background: rgba(255, 193, 7, 0.2);
+ padding: 2px 6px;
+ border-radius: 3px;
+ animation: burst-flash 0.3s ease-out;
+}
+@keyframes burst-flash {
+ 0% { opacity: 1; transform: scale(1.1); }
+ 100% { opacity: 1; transform: scale(1); }
+}
+.aprs-meter-bar-container {
+ position: relative;
+ height: 16px;
+ background: rgba(0,0,0,0.4);
+ border-radius: 3px;
+ overflow: hidden;
+ margin-bottom: 4px;
+}
+.aprs-meter-bar {
+ height: 100%;
+ width: 0%;
+ background: linear-gradient(90deg,
+ var(--accent-green) 0%,
+ var(--accent-cyan) 50%,
+ var(--accent-yellow) 75%,
+ var(--accent-red) 100%
+ );
+ border-radius: 3px;
+ transition: width 0.1s ease-out;
+}
+.aprs-meter-bar.no-signal {
+ opacity: 0.3;
+}
+.aprs-meter-ticks {
+ display: flex;
+ justify-content: space-between;
+ font-size: 8px;
+ color: var(--text-muted);
+ padding: 0 2px;
+}
+.aprs-meter-status {
+ font-size: 9px;
+ color: var(--text-muted);
+ text-align: center;
+ margin-top: 6px;
+}
+.aprs-meter-status.active {
+ color: var(--accent-green);
+}
+.aprs-meter-status.no-signal {
+ color: var(--accent-yellow);
+}
diff --git a/static/css/modes/meshtastic.css b/static/css/modes/meshtastic.css
index 3602dc0..e424de6 100644
--- a/static/css/modes/meshtastic.css
+++ b/static/css/modes/meshtastic.css
@@ -1,1610 +1,1610 @@
-/**
- * Meshtastic Mode Styles
- * Mesh network monitoring interface
- */
-
-/* ============================================
- MODE VISIBILITY
- ============================================ */
-#meshtasticMode.active {
- display: block !important;
-}
-
-/* ============================================
- MAIN SIDEBAR COLLAPSE (for Meshtastic mode)
- ============================================ */
-
-/* When sidebar is hidden, adjust layout */
-.main-content.mesh-sidebar-hidden {
- display: flex !important;
- flex-direction: column !important;
-}
-
-.main-content.mesh-sidebar-hidden > .sidebar {
- display: none !important;
- width: 0 !important;
- height: 0 !important;
- overflow: hidden !important;
-}
-
-.main-content.mesh-sidebar-hidden > .output-panel {
- flex: 1 !important;
- width: 100% !important;
- max-width: 100% !important;
-}
-
-/* Hide Sidebar Button in sidebar */
-.mesh-hide-sidebar-btn {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 8px;
- width: 100%;
- padding: 10px 12px;
- margin-bottom: 12px;
- background: var(--bg-card);
- border: 1px solid var(--border-color);
- border-radius: 6px;
- cursor: pointer;
- font-family: 'JetBrains Mono', monospace;
- font-size: 11px;
- font-weight: 600;
- color: var(--text-secondary);
- text-transform: uppercase;
- letter-spacing: 0.5px;
- transition: all 0.15s ease;
-}
-
-.mesh-hide-sidebar-btn:hover {
- background: var(--bg-secondary);
- border-color: var(--accent-cyan);
- color: var(--accent-cyan);
-}
-
-.mesh-hide-sidebar-btn svg {
- width: 14px;
- height: 14px;
-}
-
-/* When sidebar is hidden, highlight the toggle button in stats strip */
-.main-content.mesh-sidebar-hidden .mesh-strip-sidebar-toggle {
- background: var(--accent-cyan);
- border-color: var(--accent-cyan);
- color: var(--bg-primary);
-}
-
-.main-content.mesh-sidebar-hidden .mesh-strip-sidebar-toggle:hover {
- background: var(--accent-blue);
- border-color: var(--accent-blue);
-}
-
-/* Sidebar toggle button in stats strip */
-.mesh-strip-sidebar-toggle {
- display: flex;
- align-items: center;
- gap: 6px;
- padding: 5px 10px;
- background: var(--bg-primary);
- border: 1px solid var(--border-color);
- border-radius: 4px;
- cursor: pointer;
- font-family: 'JetBrains Mono', monospace;
- font-size: 10px;
- color: var(--text-secondary);
- transition: all 0.15s ease;
- pointer-events: auto;
- z-index: 100;
- position: relative;
-}
-
-.mesh-strip-sidebar-toggle:hover {
- background: var(--bg-secondary);
- border-color: var(--border-light);
- color: var(--text-primary);
-}
-
-.mesh-strip-sidebar-toggle svg {
- width: 14px;
- height: 14px;
- transition: transform 0.2s ease;
-}
-
-@media (min-width: 1024px) {
- .main-content.mesh-sidebar-hidden .mesh-strip-sidebar-toggle svg {
- transform: rotate(180deg);
- }
-}
-
-/* ============================================
- COLLAPSIBLE SIDEBAR CONTENT
- ============================================ */
-.mesh-sidebar-toggle {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 10px 12px;
- background: var(--bg-secondary);
- border: 1px solid var(--border-color);
- border-radius: 6px;
- cursor: pointer;
- margin-bottom: 12px;
- transition: all 0.15s ease;
-}
-
-.mesh-sidebar-toggle:hover {
- background: var(--bg-card);
- border-color: var(--border-light);
-}
-
-.mesh-sidebar-toggle-icon {
- font-size: 10px;
- color: var(--text-dim);
- transition: transform 0.2s ease;
-}
-
-.mesh-sidebar-toggle-text {
- font-family: 'JetBrains Mono', monospace;
- font-size: 11px;
- font-weight: 600;
- color: var(--text-secondary);
- text-transform: uppercase;
- letter-spacing: 0.5px;
-}
-
-.mesh-sidebar-content {
- display: block;
- transition: all 0.2s ease;
-}
-
-/* Collapsed state */
-#meshtasticMode.mesh-sidebar-collapsed .mesh-sidebar-content {
- display: none;
-}
-
-#meshtasticMode.mesh-sidebar-collapsed .mesh-sidebar-toggle-icon {
- transform: rotate(0deg);
-}
-
-#meshtasticMode:not(.mesh-sidebar-collapsed) .mesh-sidebar-toggle-icon {
- transform: rotate(90deg);
-}
-
-/* ============================================
- MAIN VISUALS CONTAINER
- ============================================ */
-.mesh-visuals-container {
- display: flex;
- flex-direction: column;
- gap: 16px;
- padding: 16px;
- min-height: 0;
- flex: 1;
- overflow: hidden;
-}
-
-/* ============================================
- MAIN ROW (Map + Messages side by side)
- ============================================ */
-.mesh-main-row {
- display: flex;
- flex-direction: row;
- gap: 16px;
- flex: 1;
- min-height: 0;
- overflow: hidden;
-}
-
-/* ============================================
- STATS STRIP (Compact Header Bar)
- ============================================ */
-.mesh-stats-strip {
- display: flex;
- align-items: center;
- gap: 12px;
- padding: 10px 16px;
- background: var(--bg-card);
- border: 1px solid var(--border-color);
- border-radius: 8px;
- flex-wrap: wrap;
-}
-
-.mesh-strip-group {
- display: flex;
- align-items: center;
- gap: 12px;
-}
-
-.mesh-strip-status {
- display: flex;
- align-items: center;
- gap: 6px;
-}
-
-.mesh-strip-dot {
- width: 8px;
- height: 8px;
- border-radius: 50%;
- flex-shrink: 0;
-}
-
-.mesh-strip-dot.disconnected {
- background: var(--text-dim);
-}
-
-.mesh-strip-dot.connecting {
- background: var(--accent-yellow);
- animation: pulse 1s infinite;
-}
-
-.mesh-strip-dot.connected {
- background: var(--accent-green);
- box-shadow: 0 0 6px var(--accent-green);
-}
-
-.mesh-strip-status-text {
- font-family: 'JetBrains Mono', monospace;
- font-size: 10px;
- color: var(--text-secondary);
- text-transform: uppercase;
-}
-
-.mesh-strip-select {
- font-family: 'JetBrains Mono', monospace;
- font-size: 10px;
- padding: 4px 8px;
- background: var(--bg-primary);
- border: 1px solid var(--border-color);
- border-radius: 4px;
- color: var(--text-primary);
- max-width: 120px;
-}
-
-.mesh-strip-input {
- font-family: 'JetBrains Mono', monospace;
- font-size: 10px;
- padding: 4px 8px;
- background: var(--bg-primary);
- border: 1px solid var(--border-color);
- border-radius: 4px;
- color: var(--text-primary);
- max-width: 140px;
-}
-
-.mesh-strip-input::placeholder {
- color: var(--text-secondary);
- opacity: 0.7;
-}
-
-.mesh-strip-input:focus {
- outline: none;
- border-color: var(--accent-cyan);
-}
-
-.mesh-strip-btn {
- font-family: 'JetBrains Mono', monospace;
- font-size: 10px;
- padding: 5px 12px;
- border: none;
- border-radius: 4px;
- cursor: pointer;
- text-transform: uppercase;
- font-weight: 600;
- transition: all 0.15s ease;
-}
-
-.mesh-strip-btn.connect {
- background: var(--accent-cyan);
- color: var(--bg-primary);
-}
-
-.mesh-strip-btn.connect:hover {
- background: var(--accent-cyan-bright, #00d4ff);
-}
-
-.mesh-strip-btn.disconnect {
- background: var(--accent-red, #ff3366);
- color: white;
-}
-
-.mesh-strip-btn.disconnect:hover {
- background: #ff1a53;
-}
-
-.mesh-strip-divider {
- width: 1px;
- height: 24px;
- background: var(--border-color);
-}
-
-.mesh-strip-stat {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 2px;
- min-width: 50px;
-}
-
-.mesh-strip-value {
- font-family: 'JetBrains Mono', monospace;
- font-size: 12px;
- font-weight: 600;
- color: var(--text-primary);
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- max-width: 100px;
-}
-
-.mesh-strip-value.accent-cyan {
- color: var(--accent-cyan);
-}
-
-.mesh-strip-value.accent-green {
- color: var(--accent-green);
-}
-
-.mesh-strip-id {
- font-size: 10px;
- color: var(--accent-cyan);
-}
-
-.mesh-strip-label {
- font-family: 'JetBrains Mono', monospace;
- font-size: 8px;
- color: var(--text-dim);
- text-transform: uppercase;
- letter-spacing: 0.5px;
-}
-
-@media (max-width: 768px) {
- .mesh-stats-strip {
- padding: 8px 12px;
- gap: 8px;
- }
-
- .mesh-strip-group {
- gap: 8px;
- }
-
- .mesh-strip-divider {
- display: none;
- }
-
- .mesh-strip-stat {
- min-width: 40px;
- }
-
- .mesh-strip-value {
- font-size: 11px;
- max-width: 60px;
- }
-}
-
-/* ============================================
- NODE MAP SECTION
- ============================================ */
-.mesh-map-section {
- background: var(--bg-card);
- border: 1px solid var(--border-color);
- border-radius: 8px;
- overflow: hidden;
- display: flex;
- flex-direction: column;
- flex: 1;
- min-width: 0;
- min-height: 400px;
-}
-
-.mesh-map-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 10px 14px;
- background: rgba(0, 0, 0, 0.2);
- border-bottom: 1px solid var(--border-color);
-}
-
-.mesh-map-title {
- display: flex;
- align-items: center;
- gap: 8px;
- font-family: 'JetBrains Mono', monospace;
- font-size: 12px;
- font-weight: 600;
- color: var(--text-primary);
-}
-
-.mesh-map-title svg {
- color: var(--accent-cyan);
-}
-
-.mesh-map-stats {
- display: flex;
- gap: 16px;
- font-family: 'JetBrains Mono', monospace;
- font-size: 10px;
- color: var(--text-secondary);
-}
-
-.mesh-map-stats span:last-child {
- color: var(--accent-green);
-}
-
-.mesh-map {
- flex: 1;
- min-height: 0;
- background: var(--bg-primary);
-}
-
-/* Leaflet map overrides for dark theme */
-.mesh-map .leaflet-container {
- background: var(--bg-primary);
-}
-
-/* Override Leaflet's default div-icon styling for mesh markers */
-.mesh-marker-wrapper.leaflet-div-icon {
- background: transparent;
- border: none;
-}
-
-.mesh-map .leaflet-popup-content-wrapper {
- background: var(--bg-card);
- color: var(--text-primary);
- border-radius: 6px;
- border: 1px solid var(--border-color);
-}
-
-.mesh-map .leaflet-popup-tip {
- background: var(--bg-card);
-}
-
-.mesh-map .leaflet-popup-content {
- font-family: 'JetBrains Mono', monospace;
- font-size: 11px;
- margin: 10px 12px;
-}
-
-/* Custom node marker - high visibility on dark maps */
-.mesh-node-marker {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 32px;
- height: 32px;
- background: #00ffff; /* Bright cyan for maximum visibility */
- border: 3px solid #ffffff;
- border-radius: 50%;
- box-shadow:
- 0 2px 8px rgba(0, 0, 0, 0.6),
- 0 0 20px 8px rgba(0, 255, 255, 0.7), /* Strong outer glow */
- inset 0 0 8px rgba(255, 255, 255, 0.3); /* Inner highlight */
- color: #000;
- font-family: 'JetBrains Mono', monospace;
- font-size: 11px;
- font-weight: bold;
- text-shadow: 0 0 2px rgba(255, 255, 255, 0.8);
-}
-
-.mesh-node-marker.local {
- background: #00ff88; /* Bright green for local node */
- box-shadow:
- 0 2px 8px rgba(0, 0, 0, 0.6),
- 0 0 20px 8px rgba(0, 255, 136, 0.7), /* Strong green glow */
- inset 0 0 8px rgba(255, 255, 255, 0.3);
-}
-
-.mesh-node-marker.stale {
- background: #888888;
- border-color: #aaaaaa;
- opacity: 0.8;
- box-shadow:
- 0 2px 6px rgba(0, 0, 0, 0.4),
- 0 0 8px 2px rgba(136, 136, 136, 0.3); /* Subtle glow for stale */
-}
-
-/* ============================================
- MESSAGES SECTION
- ============================================ */
-.mesh-messages-section {
- background: var(--bg-card);
- border: 1px solid var(--border-color);
- border-radius: 8px;
- overflow: hidden;
- display: flex;
- flex-direction: column;
- flex: 1;
- min-width: 0;
- min-height: 400px;
-}
-
-/* ============================================
- CONNECTION STATUS
- ============================================ */
-.mesh-status-dot {
- width: 8px;
- height: 8px;
- border-radius: 50%;
- flex-shrink: 0;
-}
-
-.mesh-status-dot.disconnected {
- background: var(--accent-red, #ff3366);
-}
-
-.mesh-status-dot.connecting {
- background: var(--accent-yellow, #ffc107);
- animation: pulse-status 1s ease-in-out infinite;
-}
-
-.mesh-status-dot.connected {
- background: var(--accent-green, #22c55e);
-}
-
-@keyframes pulse-status {
- 0%, 100% { opacity: 1; }
- 50% { opacity: 0.4; }
-}
-
-/* ============================================
- NODE INFO PANEL
- ============================================ */
-.mesh-node-info {
- display: flex;
- flex-direction: column;
- gap: 8px;
- background: rgba(0, 0, 0, 0.2);
- border-radius: 4px;
- padding: 10px;
-}
-
-.mesh-node-row {
- display: flex;
- justify-content: space-between;
- align-items: center;
- font-size: 11px;
-}
-
-.mesh-node-label {
- color: var(--text-dim);
- text-transform: uppercase;
- font-size: 9px;
- letter-spacing: 0.05em;
-}
-
-.mesh-node-value {
- color: var(--text-primary);
- font-family: 'JetBrains Mono', monospace;
-}
-
-.mesh-node-id {
- color: var(--accent-cyan);
-}
-
-/* ============================================
- CHANNEL LIST
- ============================================ */
-.mesh-channel-item {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 10px 12px;
- background: var(--bg-secondary);
- border: 1px solid var(--border-color);
- border-radius: 4px;
- margin-bottom: 6px;
- transition: all 0.15s ease;
-}
-
-.mesh-channel-item:hover {
- border-color: var(--border-light);
-}
-
-.mesh-channel-item.disabled {
- opacity: 0.5;
-}
-
-.mesh-channel-info {
- display: flex;
- align-items: center;
- gap: 10px;
- flex: 1;
- min-width: 0;
-}
-
-.mesh-channel-index {
- font-family: 'JetBrains Mono', monospace;
- font-size: 10px;
- font-weight: 600;
- color: var(--text-dim);
- background: var(--bg-primary);
- padding: 2px 6px;
- border-radius: 3px;
- flex-shrink: 0;
-}
-
-.mesh-channel-name {
- font-family: 'JetBrains Mono', monospace;
- font-size: 12px;
- font-weight: 500;
- color: var(--text-primary);
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.mesh-channel-badges {
- display: flex;
- align-items: center;
- gap: 6px;
- flex-shrink: 0;
-}
-
-.mesh-channel-badge {
- font-family: 'JetBrains Mono', monospace;
- font-size: 8px;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.03em;
- padding: 2px 6px;
- border-radius: 3px;
-}
-
-.mesh-badge-primary {
- background: rgba(74, 158, 255, 0.15);
- color: var(--accent-cyan);
- border: 1px solid rgba(74, 158, 255, 0.3);
-}
-
-.mesh-badge-secondary {
- background: rgba(136, 136, 136, 0.15);
- color: var(--text-secondary);
- border: 1px solid rgba(136, 136, 136, 0.3);
-}
-
-.mesh-badge-encrypted {
- background: rgba(34, 197, 94, 0.15);
- color: var(--accent-green);
- border: 1px solid rgba(34, 197, 94, 0.3);
-}
-
-.mesh-badge-unencrypted {
- background: rgba(255, 51, 102, 0.15);
- color: var(--accent-red, #ff3366);
- border: 1px solid rgba(255, 51, 102, 0.3);
-}
-
-.mesh-channel-configure {
- font-size: 10px;
- color: var(--text-secondary);
- background: transparent;
- border: 1px solid var(--border-color);
- padding: 4px 8px;
- border-radius: 3px;
- cursor: pointer;
- transition: all 0.15s ease;
- flex-shrink: 0;
-}
-
-.mesh-channel-configure:hover {
- color: var(--text-primary);
- border-color: var(--border-light);
- background: var(--bg-primary);
-}
-
-/* ============================================
- MESSAGE FEED CONTAINER
- ============================================ */
-.mesh-messages-container {
- display: flex;
- flex-direction: column;
- gap: 16px;
- padding: 16px;
- min-height: 0;
- flex: 1;
- overflow-y: auto;
-}
-
-.mesh-messages-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 12px 16px;
- background: var(--bg-secondary);
- border: 1px solid var(--border-color);
- border-radius: 8px;
-}
-
-.mesh-messages-title {
- font-family: 'JetBrains Mono', monospace;
- font-size: 14px;
- font-weight: 600;
- color: var(--text-primary);
- display: flex;
- align-items: center;
- gap: 10px;
-}
-
-.mesh-messages-title svg {
- color: var(--accent-cyan);
-}
-
-.mesh-messages-filter {
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-.mesh-messages-filter select {
- font-family: 'JetBrains Mono', monospace;
- font-size: 10px;
- padding: 6px 10px;
- background: var(--bg-primary);
- border: 1px solid var(--border-color);
- border-radius: 4px;
- color: var(--text-primary);
-}
-
-.mesh-messages-list {
- display: flex;
- flex-direction: column;
- gap: 8px;
- overflow-y: auto;
- flex: 1;
- min-height: 0;
- padding: 12px;
-}
-
-/* ============================================
- MESSAGE CARD
- ============================================ */
-.mesh-message-card {
- background: var(--bg-card);
- border: 1px solid var(--border-color);
- border-left: 3px solid var(--accent-cyan);
- border-radius: 4px;
- padding: 12px 14px;
- transition: all 0.15s ease;
-}
-
-.mesh-message-card:hover {
- border-color: var(--border-light);
- border-left-color: var(--accent-cyan);
-}
-
-.mesh-message-card.text-message {
- border-left-color: var(--accent-cyan);
-}
-
-.mesh-message-card.position-message {
- border-left-color: var(--accent-green);
-}
-
-.mesh-message-card.telemetry-message {
- border-left-color: var(--accent-purple, #a855f7);
-}
-
-.mesh-message-card.nodeinfo-message {
- border-left-color: var(--accent-orange);
-}
-
-.mesh-message-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 8px;
- flex-wrap: wrap;
- gap: 8px;
-}
-
-.mesh-message-route {
- display: flex;
- align-items: center;
- gap: 6px;
- font-family: 'JetBrains Mono', monospace;
- font-size: 11px;
-}
-
-.mesh-message-from {
- color: var(--accent-cyan);
- font-weight: 600;
-}
-
-.mesh-message-arrow {
- color: var(--text-dim);
-}
-
-.mesh-message-to {
- color: var(--text-secondary);
-}
-
-.mesh-message-to.broadcast {
- color: var(--accent-yellow);
-}
-
-.mesh-message-meta {
- display: flex;
- align-items: center;
- gap: 10px;
- font-size: 10px;
-}
-
-.mesh-message-channel {
- font-family: 'JetBrains Mono', monospace;
- background: var(--bg-secondary);
- padding: 2px 6px;
- border-radius: 3px;
- color: var(--text-secondary);
-}
-
-.mesh-message-time {
- color: var(--text-dim);
- font-family: 'JetBrains Mono', monospace;
-}
-
-.mesh-message-body {
- font-size: 12px;
- color: var(--text-primary);
- line-height: 1.5;
- word-break: break-word;
-}
-
-.mesh-message-body.app-type {
- font-family: 'JetBrains Mono', monospace;
- font-size: 10px;
- color: var(--text-secondary);
- background: var(--bg-secondary);
- padding: 6px 10px;
- border-radius: 4px;
-}
-
-.mesh-message-signal {
- display: flex;
- align-items: center;
- gap: 12px;
- margin-top: 8px;
- padding-top: 8px;
- border-top: 1px solid var(--border-color);
-}
-
-.mesh-signal-item {
- display: flex;
- align-items: center;
- gap: 4px;
- font-family: 'JetBrains Mono', monospace;
- font-size: 10px;
-}
-
-.mesh-signal-label {
- color: var(--text-dim);
- text-transform: uppercase;
-}
-
-.mesh-signal-value {
- font-weight: 600;
-}
-
-.mesh-signal-value.rssi {
- color: var(--accent-cyan);
-}
-
-.mesh-signal-value.snr {
- color: var(--accent-green);
-}
-
-.mesh-signal-value.snr.poor {
- color: var(--accent-orange);
-}
-
-.mesh-signal-value.snr.bad {
- color: var(--accent-red, #ff3366);
-}
-
-/* ============================================
- MESSAGE STATUS (Pending/Sent/Failed)
- ============================================ */
-.mesh-message-card.pending {
- opacity: 0.7;
- border-left-color: var(--text-dim);
-}
-
-.mesh-message-card.pending .mesh-message-from {
- color: var(--text-secondary);
-}
-
-.mesh-message-card.failed {
- border-left-color: var(--accent-red, #ff3366);
- background: rgba(255, 51, 102, 0.05);
-}
-
-.mesh-message-card.sent {
- border-left-color: var(--accent-green);
-}
-
-.mesh-message-status {
- font-family: 'JetBrains Mono', monospace;
- font-size: 9px;
- padding: 2px 6px;
- border-radius: 3px;
- margin-left: 8px;
- text-transform: uppercase;
- letter-spacing: 0.5px;
-}
-
-.mesh-message-status.sending {
- background: var(--bg-secondary);
- color: var(--text-dim);
- animation: pulse-sending 1.5s ease-in-out infinite;
-}
-
-.mesh-message-status.failed {
- background: rgba(255, 51, 102, 0.15);
- color: var(--accent-red, #ff3366);
-}
-
-@keyframes pulse-sending {
- 0%, 100% { opacity: 0.5; }
- 50% { opacity: 1; }
-}
-
-/* Send button sending state */
-.mesh-compose-send.sending {
- opacity: 0.6;
- cursor: wait;
-}
-
-/* ============================================
- EMPTY STATE
- ============================================ */
-.mesh-messages-empty {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- padding: 60px 20px;
- text-align: center;
- color: var(--text-dim);
-}
-
-.mesh-messages-empty svg {
- width: 48px;
- height: 48px;
- opacity: 0.3;
- margin-bottom: 12px;
-}
-
-.mesh-messages-empty p {
- font-size: 13px;
- margin-top: 8px;
-}
-
-/* ============================================
- MODAL FORM STYLING
- ============================================ */
-#meshChannelModal .form-group label {
- display: block;
-}
-
-#meshChannelModal input[type="text"],
-#meshChannelModal select {
- font-family: 'JetBrains Mono', monospace;
- font-size: 12px;
- padding: 10px;
- background: var(--bg-primary);
- border: 1px solid var(--border-color);
- border-radius: 4px;
- color: var(--text-primary);
-}
-
-#meshChannelModal input[type="text"]:focus,
-#meshChannelModal select:focus {
- outline: none;
- border-color: var(--accent-cyan);
-}
-
-/* ============================================
- MESSAGE COMPOSE
- ============================================ */
-.mesh-compose {
- background: var(--bg-secondary);
- border: 1px solid var(--border-color);
- border-radius: 8px;
- padding: 12px;
- margin-top: 16px;
- flex-shrink: 0;
-}
-
-.mesh-compose-header {
- display: flex;
- gap: 8px;
- margin-bottom: 8px;
-}
-
-.mesh-compose-channel {
- font-family: 'JetBrains Mono', monospace;
- font-size: 11px;
- padding: 6px 10px;
- background: var(--bg-primary);
- border: 1px solid var(--border-color);
- border-radius: 4px;
- color: var(--text-primary);
- min-width: 70px;
- cursor: pointer;
-}
-
-.mesh-compose-channel:focus {
- outline: none;
- border-color: var(--accent-cyan);
-}
-
-.mesh-compose-to {
- font-family: 'JetBrains Mono', monospace;
- font-size: 11px;
- padding: 6px 10px;
- background: var(--bg-primary);
- border: 1px solid var(--border-color);
- border-radius: 4px;
- color: var(--text-primary);
- flex: 1;
- min-width: 100px;
-}
-
-.mesh-compose-to:focus {
- outline: none;
- border-color: var(--accent-cyan);
-}
-
-.mesh-compose-to::placeholder {
- color: var(--text-dim);
-}
-
-.mesh-compose-body {
- display: flex;
- gap: 8px;
-}
-
-.mesh-compose-input {
- flex: 1;
- font-family: 'JetBrains Mono', monospace;
- font-size: 12px;
- padding: 10px 12px;
- background: var(--bg-primary);
- border: 1px solid var(--border-color);
- border-radius: 4px;
- color: var(--text-primary);
-}
-
-.mesh-compose-input:focus {
- outline: none;
- border-color: var(--accent-cyan);
-}
-
-.mesh-compose-input::placeholder {
- color: var(--text-dim);
-}
-
-.mesh-compose-send {
- background: var(--accent-cyan);
- border: none;
- border-radius: 4px;
- padding: 10px 14px;
- cursor: pointer;
- color: #000;
- transition: all 0.15s ease;
- display: flex;
- align-items: center;
- justify-content: center;
-}
-
-.mesh-compose-send:hover {
- background: var(--accent-green);
- transform: scale(1.05);
-}
-
-.mesh-compose-send:active {
- transform: scale(0.98);
-}
-
-.mesh-compose-send:disabled {
- opacity: 0.5;
- cursor: not-allowed;
- transform: none;
-}
-
-.mesh-compose-hint {
- font-family: 'JetBrains Mono', monospace;
- font-size: 10px;
- color: var(--text-dim);
- margin-top: 6px;
- text-align: right;
-}
-
-/* ============================================
- RESPONSIVE
- ============================================ */
-@media (max-width: 1024px) {
- .mesh-main-row {
- flex-direction: column;
- overflow-y: auto;
- }
-
- .mesh-map-section,
- .mesh-messages-section {
- flex: none;
- min-height: 300px;
- }
-}
-
-@media (max-width: 768px) {
- .mesh-map-section {
- min-height: 200px;
- }
-
- .mesh-messages-section {
- min-height: 250px;
- }
-
- .mesh-map {
- min-height: 180px;
- }
-
- .mesh-map-header {
- flex-direction: column;
- align-items: flex-start;
- gap: 6px;
- }
-
- .mesh-channel-item {
- flex-direction: column;
- align-items: flex-start;
- gap: 8px;
- }
-
- .mesh-channel-badges {
- width: 100%;
- justify-content: flex-start;
- }
-
- .mesh-channel-configure {
- width: 100%;
- text-align: center;
- min-height: 36px;
- }
-
- .mesh-message-header {
- flex-direction: column;
- align-items: flex-start;
- }
-
- .mesh-message-meta {
- width: 100%;
- justify-content: space-between;
- }
-
- .mesh-compose-header {
- flex-direction: column;
- }
-
- .mesh-compose-to {
- width: 100%;
- }
-}
-
-@media (max-width: 480px) {
- .mesh-messages-container {
- padding: 8px;
- }
-
- .mesh-message-card {
- padding: 10px;
- }
-
- .mesh-message-signal {
- flex-wrap: wrap;
- }
-}
-
-/* Touch device compliance */
-@media (pointer: coarse) {
- .mesh-channel-configure {
- min-height: 44px;
- padding: 8px 12px;
- }
-
- .mesh-compose-send {
- min-width: 44px;
- min-height: 44px;
- }
-
- .mesh-compose-input {
- min-height: 44px;
- }
-}
-
-/* ============================================
- TRACEROUTE BUTTON IN POPUP
- ============================================ */
-.mesh-traceroute-btn {
- display: block;
- width: 100%;
- margin-top: 10px;
- padding: 8px 12px;
- background: var(--accent-cyan);
- border: none;
- border-radius: 4px;
- color: #000;
- font-family: 'JetBrains Mono', monospace;
- font-size: 10px;
- font-weight: 600;
- text-transform: uppercase;
- cursor: pointer;
- transition: all 0.15s ease;
-}
-
-.mesh-traceroute-btn:hover {
- background: var(--accent-green);
- transform: scale(1.02);
-}
-
-/* ============================================
- TRACEROUTE MODAL CONTENT
- ============================================ */
-.mesh-traceroute-content {
- min-height: 100px;
-}
-
-.mesh-traceroute-loading {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- padding: 40px 20px;
- color: var(--text-secondary);
-}
-
-.mesh-traceroute-spinner {
- width: 32px;
- height: 32px;
- border: 3px solid var(--border-color);
- border-top-color: var(--accent-cyan);
- border-radius: 50%;
- animation: mesh-spin 1s linear infinite;
- margin-bottom: 16px;
-}
-
-@keyframes mesh-spin {
- to { transform: rotate(360deg); }
-}
-
-.mesh-traceroute-error {
- padding: 16px;
- background: rgba(255, 51, 102, 0.1);
- border: 1px solid var(--accent-red, #ff3366);
- border-radius: 6px;
- color: var(--accent-red, #ff3366);
- font-size: 12px;
-}
-
-.mesh-traceroute-section {
- margin-bottom: 16px;
-}
-
-.mesh-traceroute-label {
- font-family: 'JetBrains Mono', monospace;
- font-size: 10px;
- font-weight: 600;
- color: var(--text-dim);
- text-transform: uppercase;
- letter-spacing: 0.5px;
- margin-bottom: 10px;
-}
-
-.mesh-traceroute-path {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: 8px;
- padding: 12px;
- background: var(--bg-secondary);
- border: 1px solid var(--border-color);
- border-radius: 6px;
-}
-
-.mesh-traceroute-hop {
- display: flex;
- flex-direction: column;
- align-items: center;
- padding: 10px 14px;
- background: var(--bg-card);
- border: 1px solid var(--border-color);
- border-radius: 6px;
- min-width: 70px;
-}
-
-.mesh-traceroute-hop-node {
- font-family: 'JetBrains Mono', monospace;
- font-size: 12px;
- font-weight: 600;
- color: var(--accent-cyan);
- margin-bottom: 4px;
-}
-
-.mesh-traceroute-hop-id {
- font-family: 'JetBrains Mono', monospace;
- font-size: 9px;
- color: var(--text-dim);
- margin-bottom: 6px;
-}
-
-.mesh-traceroute-snr {
- font-family: 'JetBrains Mono', monospace;
- font-size: 10px;
- font-weight: 600;
- padding: 2px 8px;
- border-radius: 10px;
-}
-
-.mesh-traceroute-snr.snr-good {
- background: rgba(34, 197, 94, 0.15);
- color: var(--accent-green);
-}
-
-.mesh-traceroute-snr.snr-ok {
- background: rgba(74, 158, 255, 0.15);
- color: var(--accent-cyan);
-}
-
-.mesh-traceroute-snr.snr-poor {
- background: rgba(255, 193, 7, 0.15);
- color: var(--accent-orange);
-}
-
-.mesh-traceroute-snr.snr-bad {
- background: rgba(255, 51, 102, 0.15);
- color: var(--accent-red, #ff3366);
-}
-
-.mesh-traceroute-arrow {
- font-size: 18px;
- color: var(--text-dim);
- font-weight: bold;
-}
-
-.mesh-traceroute-timestamp {
- margin-top: 12px;
- font-family: 'JetBrains Mono', monospace;
- font-size: 10px;
- color: var(--text-dim);
- text-align: right;
-}
-
-/* Responsive traceroute path */
-@media (max-width: 600px) {
- .mesh-traceroute-path {
- flex-direction: column;
- }
-
- .mesh-traceroute-hop {
- width: 100%;
- }
-
- .mesh-traceroute-arrow {
- transform: rotate(90deg);
- }
-}
-
-/* ============================================
- NODE POPUP ACTION BUTTONS
- ============================================ */
-.mesh-position-btn,
-.mesh-telemetry-btn {
- padding: 6px 10px;
- background: var(--bg-secondary);
- border: 1px solid var(--border-color);
- border-radius: 4px;
- color: var(--text-primary);
- font-family: 'JetBrains Mono', monospace;
- font-size: 9px;
- font-weight: 600;
- text-transform: uppercase;
- cursor: pointer;
- transition: all 0.15s ease;
-}
-
-.mesh-position-btn:hover,
-.mesh-telemetry-btn:hover {
- background: var(--accent-cyan);
- color: #000;
- border-color: var(--accent-cyan);
-}
-
-/* ============================================
- QR CODE BUTTON
- ============================================ */
-.mesh-qr-btn {
- padding: 4px 8px;
- background: var(--bg-secondary);
- border: 1px solid var(--border-color);
- border-radius: 4px;
- color: var(--text-primary);
- font-family: 'JetBrains Mono', monospace;
- font-size: 9px;
- font-weight: 600;
- cursor: pointer;
- transition: all 0.15s ease;
-}
-
-.mesh-qr-btn:hover {
- background: var(--accent-cyan);
- color: #000;
- border-color: var(--accent-cyan);
-}
-
-/* ============================================
- TELEMETRY CHARTS
- ============================================ */
-.mesh-telemetry-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 16px;
- padding-bottom: 10px;
- border-bottom: 1px solid var(--border-color);
-}
-
-.mesh-telemetry-chart {
- margin-bottom: 20px;
-}
-
-.mesh-telemetry-chart-title {
- display: flex;
- justify-content: space-between;
- align-items: center;
- font-family: 'JetBrains Mono', monospace;
- font-size: 11px;
- font-weight: 600;
- color: var(--text-secondary);
- text-transform: uppercase;
- letter-spacing: 0.5px;
- margin-bottom: 8px;
-}
-
-.mesh-telemetry-current {
- font-size: 14px;
- color: var(--accent-cyan);
-}
-
-.mesh-telemetry-svg {
- width: 100%;
- height: 100px;
- background: var(--bg-secondary);
- border: 1px solid var(--border-color);
- border-radius: 6px;
-}
-
-.mesh-chart-line {
- stroke: var(--accent-cyan);
- stroke-width: 2;
- stroke-linecap: round;
- stroke-linejoin: round;
-}
-
-.mesh-chart-grid {
- stroke: var(--border-color);
- stroke-width: 1;
-}
-
-.mesh-chart-label {
- font-family: 'JetBrains Mono', monospace;
- font-size: 9px;
- fill: var(--text-dim);
-}
-
-/* ============================================
- NETWORK TOPOLOGY
- ============================================ */
-.mesh-network-list {
- display: flex;
- flex-direction: column;
- gap: 12px;
-}
-
-.mesh-network-node {
- background: var(--bg-secondary);
- border: 1px solid var(--border-color);
- border-radius: 6px;
- padding: 12px;
-}
-
-.mesh-network-node-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 10px;
- padding-bottom: 8px;
- border-bottom: 1px solid var(--border-color);
-}
-
-.mesh-network-node-id {
- font-family: 'JetBrains Mono', monospace;
- font-size: 12px;
- font-weight: 600;
- color: var(--accent-cyan);
-}
-
-.mesh-network-node-count {
- font-size: 11px;
- color: var(--text-dim);
-}
-
-.mesh-network-neighbors {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
-}
-
-.mesh-network-neighbor {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 6px 10px;
- background: var(--bg-card);
- border: 1px solid var(--border-color);
- border-radius: 4px;
-}
-
-.mesh-network-neighbor-id {
- font-family: 'JetBrains Mono', monospace;
- font-size: 10px;
- color: var(--text-secondary);
-}
-
-.mesh-network-neighbor-snr {
- font-family: 'JetBrains Mono', monospace;
- font-size: 9px;
- font-weight: 600;
- padding: 2px 6px;
- border-radius: 10px;
-}
-
-.mesh-network-neighbor-snr.snr-good {
- background: rgba(34, 197, 94, 0.15);
- color: var(--accent-green);
-}
-
-.mesh-network-neighbor-snr.snr-ok {
- background: rgba(74, 158, 255, 0.15);
- color: var(--accent-cyan);
-}
-
-.mesh-network-neighbor-snr.snr-poor {
- background: rgba(255, 193, 7, 0.15);
- color: var(--accent-orange);
-}
-
-.mesh-network-neighbor-snr.snr-bad {
- background: rgba(255, 51, 102, 0.15);
- color: var(--accent-red, #ff3366);
-}
-
-/* ============================================
- FIRMWARE BADGES
- ============================================ */
-.mesh-badge {
- display: inline-block;
- padding: 3px 8px;
- font-family: 'JetBrains Mono', monospace;
- font-size: 10px;
- font-weight: 600;
- border-radius: 10px;
- text-transform: uppercase;
-}
-
-.mesh-badge-success {
- background: rgba(34, 197, 94, 0.15);
- color: var(--accent-green);
-}
-
-.mesh-badge-warning {
- background: rgba(255, 193, 7, 0.15);
- color: var(--accent-orange);
-}
+/**
+ * Meshtastic Mode Styles
+ * Mesh network monitoring interface
+ */
+
+/* ============================================
+ MODE VISIBILITY
+ ============================================ */
+#meshtasticMode.active {
+ display: block !important;
+}
+
+/* ============================================
+ MAIN SIDEBAR COLLAPSE (for Meshtastic mode)
+ ============================================ */
+
+/* When sidebar is hidden, adjust layout */
+.main-content.mesh-sidebar-hidden {
+ display: flex !important;
+ flex-direction: column !important;
+}
+
+.main-content.mesh-sidebar-hidden > .sidebar {
+ display: none !important;
+ width: 0 !important;
+ height: 0 !important;
+ overflow: hidden !important;
+}
+
+.main-content.mesh-sidebar-hidden > .output-panel {
+ flex: 1 !important;
+ width: 100% !important;
+ max-width: 100% !important;
+}
+
+/* Hide Sidebar Button in sidebar */
+.mesh-hide-sidebar-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ width: 100%;
+ padding: 10px 12px;
+ margin-bottom: 12px;
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ cursor: pointer;
+ font-family: var(--font-mono);
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ transition: all 0.15s ease;
+}
+
+.mesh-hide-sidebar-btn:hover {
+ background: var(--bg-secondary);
+ border-color: var(--accent-cyan);
+ color: var(--accent-cyan);
+}
+
+.mesh-hide-sidebar-btn svg {
+ width: 14px;
+ height: 14px;
+}
+
+/* When sidebar is hidden, highlight the toggle button in stats strip */
+.main-content.mesh-sidebar-hidden .mesh-strip-sidebar-toggle {
+ background: var(--accent-cyan);
+ border-color: var(--accent-cyan);
+ color: var(--bg-primary);
+}
+
+.main-content.mesh-sidebar-hidden .mesh-strip-sidebar-toggle:hover {
+ background: var(--accent-blue);
+ border-color: var(--accent-blue);
+}
+
+/* Sidebar toggle button in stats strip */
+.mesh-strip-sidebar-toggle {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 5px 10px;
+ background: var(--bg-primary);
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ cursor: pointer;
+ font-family: var(--font-mono);
+ font-size: 10px;
+ color: var(--text-secondary);
+ transition: all 0.15s ease;
+ pointer-events: auto;
+ z-index: 100;
+ position: relative;
+}
+
+.mesh-strip-sidebar-toggle:hover {
+ background: var(--bg-secondary);
+ border-color: var(--border-light);
+ color: var(--text-primary);
+}
+
+.mesh-strip-sidebar-toggle svg {
+ width: 14px;
+ height: 14px;
+ transition: transform 0.2s ease;
+}
+
+@media (min-width: 1024px) {
+ .main-content.mesh-sidebar-hidden .mesh-strip-sidebar-toggle svg {
+ transform: rotate(180deg);
+ }
+}
+
+/* ============================================
+ COLLAPSIBLE SIDEBAR CONTENT
+ ============================================ */
+.mesh-sidebar-toggle {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 12px;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ cursor: pointer;
+ margin-bottom: 12px;
+ transition: all 0.15s ease;
+}
+
+.mesh-sidebar-toggle:hover {
+ background: var(--bg-card);
+ border-color: var(--border-light);
+}
+
+.mesh-sidebar-toggle-icon {
+ font-size: 10px;
+ color: var(--text-dim);
+ transition: transform 0.2s ease;
+}
+
+.mesh-sidebar-toggle-text {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.mesh-sidebar-content {
+ display: block;
+ transition: all 0.2s ease;
+}
+
+/* Collapsed state */
+#meshtasticMode.mesh-sidebar-collapsed .mesh-sidebar-content {
+ display: none;
+}
+
+#meshtasticMode.mesh-sidebar-collapsed .mesh-sidebar-toggle-icon {
+ transform: rotate(0deg);
+}
+
+#meshtasticMode:not(.mesh-sidebar-collapsed) .mesh-sidebar-toggle-icon {
+ transform: rotate(90deg);
+}
+
+/* ============================================
+ MAIN VISUALS CONTAINER
+ ============================================ */
+.mesh-visuals-container {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ padding: 16px;
+ min-height: 0;
+ flex: 1;
+ overflow: hidden;
+}
+
+/* ============================================
+ MAIN ROW (Map + Messages side by side)
+ ============================================ */
+.mesh-main-row {
+ display: flex;
+ flex-direction: row;
+ gap: 16px;
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+}
+
+/* ============================================
+ STATS STRIP (Compact Header Bar)
+ ============================================ */
+.mesh-stats-strip {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 16px;
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ flex-wrap: wrap;
+}
+
+.mesh-strip-group {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.mesh-strip-status {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.mesh-strip-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.mesh-strip-dot.disconnected {
+ background: var(--text-dim);
+}
+
+.mesh-strip-dot.connecting {
+ background: var(--accent-yellow);
+ animation: pulse 1s infinite;
+}
+
+.mesh-strip-dot.connected {
+ background: var(--accent-green);
+ box-shadow: 0 0 6px var(--accent-green);
+}
+
+.mesh-strip-status-text {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+}
+
+.mesh-strip-select {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ padding: 4px 8px;
+ background: var(--bg-primary);
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ color: var(--text-primary);
+ max-width: 120px;
+}
+
+.mesh-strip-input {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ padding: 4px 8px;
+ background: var(--bg-primary);
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ color: var(--text-primary);
+ max-width: 140px;
+}
+
+.mesh-strip-input::placeholder {
+ color: var(--text-secondary);
+ opacity: 0.7;
+}
+
+.mesh-strip-input:focus {
+ outline: none;
+ border-color: var(--accent-cyan);
+}
+
+.mesh-strip-btn {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ padding: 5px 12px;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ text-transform: uppercase;
+ font-weight: 600;
+ transition: all 0.15s ease;
+}
+
+.mesh-strip-btn.connect {
+ background: var(--accent-cyan);
+ color: var(--bg-primary);
+}
+
+.mesh-strip-btn.connect:hover {
+ background: var(--accent-cyan-bright, #00d4ff);
+}
+
+.mesh-strip-btn.disconnect {
+ background: var(--accent-red, #ff3366);
+ color: white;
+}
+
+.mesh-strip-btn.disconnect:hover {
+ background: #ff1a53;
+}
+
+.mesh-strip-divider {
+ width: 1px;
+ height: 24px;
+ background: var(--border-color);
+}
+
+.mesh-strip-stat {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 2px;
+ min-width: 50px;
+}
+
+.mesh-strip-value {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-primary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 100px;
+}
+
+.mesh-strip-value.accent-cyan {
+ color: var(--accent-cyan);
+}
+
+.mesh-strip-value.accent-green {
+ color: var(--accent-green);
+}
+
+.mesh-strip-id {
+ font-size: 10px;
+ color: var(--accent-cyan);
+}
+
+.mesh-strip-label {
+ font-family: var(--font-mono);
+ font-size: 8px;
+ color: var(--text-dim);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+@media (max-width: 768px) {
+ .mesh-stats-strip {
+ padding: 8px 12px;
+ gap: 8px;
+ }
+
+ .mesh-strip-group {
+ gap: 8px;
+ }
+
+ .mesh-strip-divider {
+ display: none;
+ }
+
+ .mesh-strip-stat {
+ min-width: 40px;
+ }
+
+ .mesh-strip-value {
+ font-size: 11px;
+ max-width: 60px;
+ }
+}
+
+/* ============================================
+ NODE MAP SECTION
+ ============================================ */
+.mesh-map-section {
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-width: 0;
+ min-height: 400px;
+}
+
+.mesh-map-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 14px;
+ background: rgba(0, 0, 0, 0.2);
+ border-bottom: 1px solid var(--border-color);
+}
+
+.mesh-map-title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-family: var(--font-mono);
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.mesh-map-title svg {
+ color: var(--accent-cyan);
+}
+
+.mesh-map-stats {
+ display: flex;
+ gap: 16px;
+ font-family: var(--font-mono);
+ font-size: 10px;
+ color: var(--text-secondary);
+}
+
+.mesh-map-stats span:last-child {
+ color: var(--accent-green);
+}
+
+.mesh-map {
+ flex: 1;
+ min-height: 0;
+ background: var(--bg-primary);
+}
+
+/* Leaflet map overrides for dark theme */
+.mesh-map .leaflet-container {
+ background: var(--bg-primary);
+}
+
+/* Override Leaflet's default div-icon styling for mesh markers */
+.mesh-marker-wrapper.leaflet-div-icon {
+ background: transparent;
+ border: none;
+}
+
+.mesh-map .leaflet-popup-content-wrapper {
+ background: var(--bg-card);
+ color: var(--text-primary);
+ border-radius: 6px;
+ border: 1px solid var(--border-color);
+}
+
+.mesh-map .leaflet-popup-tip {
+ background: var(--bg-card);
+}
+
+.mesh-map .leaflet-popup-content {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ margin: 10px 12px;
+}
+
+/* Custom node marker - high visibility on dark maps */
+.mesh-node-marker {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ background: #00ffff; /* Bright cyan for maximum visibility */
+ border: 3px solid #ffffff;
+ border-radius: 50%;
+ box-shadow:
+ 0 2px 8px rgba(0, 0, 0, 0.6),
+ 0 0 20px 8px rgba(0, 255, 255, 0.7), /* Strong outer glow */
+ inset 0 0 8px rgba(255, 255, 255, 0.3); /* Inner highlight */
+ color: #000;
+ font-family: var(--font-mono);
+ font-size: 11px;
+ font-weight: bold;
+ text-shadow: 0 0 2px rgba(255, 255, 255, 0.8);
+}
+
+.mesh-node-marker.local {
+ background: #00ff88; /* Bright green for local node */
+ box-shadow:
+ 0 2px 8px rgba(0, 0, 0, 0.6),
+ 0 0 20px 8px rgba(0, 255, 136, 0.7), /* Strong green glow */
+ inset 0 0 8px rgba(255, 255, 255, 0.3);
+}
+
+.mesh-node-marker.stale {
+ background: #888888;
+ border-color: #aaaaaa;
+ opacity: 0.8;
+ box-shadow:
+ 0 2px 6px rgba(0, 0, 0, 0.4),
+ 0 0 8px 2px rgba(136, 136, 136, 0.3); /* Subtle glow for stale */
+}
+
+/* ============================================
+ MESSAGES SECTION
+ ============================================ */
+.mesh-messages-section {
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-width: 0;
+ min-height: 400px;
+}
+
+/* ============================================
+ CONNECTION STATUS
+ ============================================ */
+.mesh-status-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.mesh-status-dot.disconnected {
+ background: var(--accent-red, #ff3366);
+}
+
+.mesh-status-dot.connecting {
+ background: var(--accent-yellow, #ffc107);
+ animation: pulse-status 1s ease-in-out infinite;
+}
+
+.mesh-status-dot.connected {
+ background: var(--accent-green, #22c55e);
+}
+
+@keyframes pulse-status {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.4; }
+}
+
+/* ============================================
+ NODE INFO PANEL
+ ============================================ */
+.mesh-node-info {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ background: rgba(0, 0, 0, 0.2);
+ border-radius: 4px;
+ padding: 10px;
+}
+
+.mesh-node-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-size: 11px;
+}
+
+.mesh-node-label {
+ color: var(--text-dim);
+ text-transform: uppercase;
+ font-size: 9px;
+ letter-spacing: 0.05em;
+}
+
+.mesh-node-value {
+ color: var(--text-primary);
+ font-family: var(--font-mono);
+}
+
+.mesh-node-id {
+ color: var(--accent-cyan);
+}
+
+/* ============================================
+ CHANNEL LIST
+ ============================================ */
+.mesh-channel-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 12px;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ margin-bottom: 6px;
+ transition: all 0.15s ease;
+}
+
+.mesh-channel-item:hover {
+ border-color: var(--border-light);
+}
+
+.mesh-channel-item.disabled {
+ opacity: 0.5;
+}
+
+.mesh-channel-info {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex: 1;
+ min-width: 0;
+}
+
+.mesh-channel-index {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ font-weight: 600;
+ color: var(--text-dim);
+ background: var(--bg-primary);
+ padding: 2px 6px;
+ border-radius: 3px;
+ flex-shrink: 0;
+}
+
+.mesh-channel-name {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--text-primary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.mesh-channel-badges {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-shrink: 0;
+}
+
+.mesh-channel-badge {
+ font-family: var(--font-mono);
+ font-size: 8px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ padding: 2px 6px;
+ border-radius: 3px;
+}
+
+.mesh-badge-primary {
+ background: rgba(74, 158, 255, 0.15);
+ color: var(--accent-cyan);
+ border: 1px solid rgba(74, 158, 255, 0.3);
+}
+
+.mesh-badge-secondary {
+ background: rgba(136, 136, 136, 0.15);
+ color: var(--text-secondary);
+ border: 1px solid rgba(136, 136, 136, 0.3);
+}
+
+.mesh-badge-encrypted {
+ background: rgba(34, 197, 94, 0.15);
+ color: var(--accent-green);
+ border: 1px solid rgba(34, 197, 94, 0.3);
+}
+
+.mesh-badge-unencrypted {
+ background: rgba(255, 51, 102, 0.15);
+ color: var(--accent-red, #ff3366);
+ border: 1px solid rgba(255, 51, 102, 0.3);
+}
+
+.mesh-channel-configure {
+ font-size: 10px;
+ color: var(--text-secondary);
+ background: transparent;
+ border: 1px solid var(--border-color);
+ padding: 4px 8px;
+ border-radius: 3px;
+ cursor: pointer;
+ transition: all 0.15s ease;
+ flex-shrink: 0;
+}
+
+.mesh-channel-configure:hover {
+ color: var(--text-primary);
+ border-color: var(--border-light);
+ background: var(--bg-primary);
+}
+
+/* ============================================
+ MESSAGE FEED CONTAINER
+ ============================================ */
+.mesh-messages-container {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ padding: 16px;
+ min-height: 0;
+ flex: 1;
+ overflow-y: auto;
+}
+
+.mesh-messages-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 16px;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+}
+
+.mesh-messages-title {
+ font-family: var(--font-mono);
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--text-primary);
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.mesh-messages-title svg {
+ color: var(--accent-cyan);
+}
+
+.mesh-messages-filter {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.mesh-messages-filter select {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ padding: 6px 10px;
+ background: var(--bg-primary);
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ color: var(--text-primary);
+}
+
+.mesh-messages-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ overflow-y: auto;
+ flex: 1;
+ min-height: 0;
+ padding: 12px;
+}
+
+/* ============================================
+ MESSAGE CARD
+ ============================================ */
+.mesh-message-card {
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-left: 3px solid var(--accent-cyan);
+ border-radius: 4px;
+ padding: 12px 14px;
+ transition: all 0.15s ease;
+}
+
+.mesh-message-card:hover {
+ border-color: var(--border-light);
+ border-left-color: var(--accent-cyan);
+}
+
+.mesh-message-card.text-message {
+ border-left-color: var(--accent-cyan);
+}
+
+.mesh-message-card.position-message {
+ border-left-color: var(--accent-green);
+}
+
+.mesh-message-card.telemetry-message {
+ border-left-color: var(--accent-purple, #a855f7);
+}
+
+.mesh-message-card.nodeinfo-message {
+ border-left-color: var(--accent-orange);
+}
+
+.mesh-message-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 8px;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.mesh-message-route {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-family: var(--font-mono);
+ font-size: 11px;
+}
+
+.mesh-message-from {
+ color: var(--accent-cyan);
+ font-weight: 600;
+}
+
+.mesh-message-arrow {
+ color: var(--text-dim);
+}
+
+.mesh-message-to {
+ color: var(--text-secondary);
+}
+
+.mesh-message-to.broadcast {
+ color: var(--accent-yellow);
+}
+
+.mesh-message-meta {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font-size: 10px;
+}
+
+.mesh-message-channel {
+ font-family: var(--font-mono);
+ background: var(--bg-secondary);
+ padding: 2px 6px;
+ border-radius: 3px;
+ color: var(--text-secondary);
+}
+
+.mesh-message-time {
+ color: var(--text-dim);
+ font-family: var(--font-mono);
+}
+
+.mesh-message-body {
+ font-size: 12px;
+ color: var(--text-primary);
+ line-height: 1.5;
+ word-break: break-word;
+}
+
+.mesh-message-body.app-type {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ color: var(--text-secondary);
+ background: var(--bg-secondary);
+ padding: 6px 10px;
+ border-radius: 4px;
+}
+
+.mesh-message-signal {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-top: 8px;
+ padding-top: 8px;
+ border-top: 1px solid var(--border-color);
+}
+
+.mesh-signal-item {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font-family: var(--font-mono);
+ font-size: 10px;
+}
+
+.mesh-signal-label {
+ color: var(--text-dim);
+ text-transform: uppercase;
+}
+
+.mesh-signal-value {
+ font-weight: 600;
+}
+
+.mesh-signal-value.rssi {
+ color: var(--accent-cyan);
+}
+
+.mesh-signal-value.snr {
+ color: var(--accent-green);
+}
+
+.mesh-signal-value.snr.poor {
+ color: var(--accent-orange);
+}
+
+.mesh-signal-value.snr.bad {
+ color: var(--accent-red, #ff3366);
+}
+
+/* ============================================
+ MESSAGE STATUS (Pending/Sent/Failed)
+ ============================================ */
+.mesh-message-card.pending {
+ opacity: 0.7;
+ border-left-color: var(--text-dim);
+}
+
+.mesh-message-card.pending .mesh-message-from {
+ color: var(--text-secondary);
+}
+
+.mesh-message-card.failed {
+ border-left-color: var(--accent-red, #ff3366);
+ background: rgba(255, 51, 102, 0.05);
+}
+
+.mesh-message-card.sent {
+ border-left-color: var(--accent-green);
+}
+
+.mesh-message-status {
+ font-family: var(--font-mono);
+ font-size: 9px;
+ padding: 2px 6px;
+ border-radius: 3px;
+ margin-left: 8px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.mesh-message-status.sending {
+ background: var(--bg-secondary);
+ color: var(--text-dim);
+ animation: pulse-sending 1.5s ease-in-out infinite;
+}
+
+.mesh-message-status.failed {
+ background: rgba(255, 51, 102, 0.15);
+ color: var(--accent-red, #ff3366);
+}
+
+@keyframes pulse-sending {
+ 0%, 100% { opacity: 0.5; }
+ 50% { opacity: 1; }
+}
+
+/* Send button sending state */
+.mesh-compose-send.sending {
+ opacity: 0.6;
+ cursor: wait;
+}
+
+/* ============================================
+ EMPTY STATE
+ ============================================ */
+.mesh-messages-empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 60px 20px;
+ text-align: center;
+ color: var(--text-dim);
+}
+
+.mesh-messages-empty svg {
+ width: 48px;
+ height: 48px;
+ opacity: 0.3;
+ margin-bottom: 12px;
+}
+
+.mesh-messages-empty p {
+ font-size: 13px;
+ margin-top: 8px;
+}
+
+/* ============================================
+ MODAL FORM STYLING
+ ============================================ */
+#meshChannelModal .form-group label {
+ display: block;
+}
+
+#meshChannelModal input[type="text"],
+#meshChannelModal select {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ padding: 10px;
+ background: var(--bg-primary);
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ color: var(--text-primary);
+}
+
+#meshChannelModal input[type="text"]:focus,
+#meshChannelModal select:focus {
+ outline: none;
+ border-color: var(--accent-cyan);
+}
+
+/* ============================================
+ MESSAGE COMPOSE
+ ============================================ */
+.mesh-compose {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ padding: 12px;
+ margin-top: 16px;
+ flex-shrink: 0;
+}
+
+.mesh-compose-header {
+ display: flex;
+ gap: 8px;
+ margin-bottom: 8px;
+}
+
+.mesh-compose-channel {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ padding: 6px 10px;
+ background: var(--bg-primary);
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ color: var(--text-primary);
+ min-width: 70px;
+ cursor: pointer;
+}
+
+.mesh-compose-channel:focus {
+ outline: none;
+ border-color: var(--accent-cyan);
+}
+
+.mesh-compose-to {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ padding: 6px 10px;
+ background: var(--bg-primary);
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ color: var(--text-primary);
+ flex: 1;
+ min-width: 100px;
+}
+
+.mesh-compose-to:focus {
+ outline: none;
+ border-color: var(--accent-cyan);
+}
+
+.mesh-compose-to::placeholder {
+ color: var(--text-dim);
+}
+
+.mesh-compose-body {
+ display: flex;
+ gap: 8px;
+}
+
+.mesh-compose-input {
+ flex: 1;
+ font-family: var(--font-mono);
+ font-size: 12px;
+ padding: 10px 12px;
+ background: var(--bg-primary);
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ color: var(--text-primary);
+}
+
+.mesh-compose-input:focus {
+ outline: none;
+ border-color: var(--accent-cyan);
+}
+
+.mesh-compose-input::placeholder {
+ color: var(--text-dim);
+}
+
+.mesh-compose-send {
+ background: var(--accent-cyan);
+ border: none;
+ border-radius: 4px;
+ padding: 10px 14px;
+ cursor: pointer;
+ color: #000;
+ transition: all 0.15s ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.mesh-compose-send:hover {
+ background: var(--accent-green);
+ transform: scale(1.05);
+}
+
+.mesh-compose-send:active {
+ transform: scale(0.98);
+}
+
+.mesh-compose-send:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ transform: none;
+}
+
+.mesh-compose-hint {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ color: var(--text-dim);
+ margin-top: 6px;
+ text-align: right;
+}
+
+/* ============================================
+ RESPONSIVE
+ ============================================ */
+@media (max-width: 1024px) {
+ .mesh-main-row {
+ flex-direction: column;
+ overflow-y: auto;
+ }
+
+ .mesh-map-section,
+ .mesh-messages-section {
+ flex: none;
+ min-height: 300px;
+ }
+}
+
+@media (max-width: 768px) {
+ .mesh-map-section {
+ min-height: 200px;
+ }
+
+ .mesh-messages-section {
+ min-height: 250px;
+ }
+
+ .mesh-map {
+ min-height: 180px;
+ }
+
+ .mesh-map-header {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 6px;
+ }
+
+ .mesh-channel-item {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 8px;
+ }
+
+ .mesh-channel-badges {
+ width: 100%;
+ justify-content: flex-start;
+ }
+
+ .mesh-channel-configure {
+ width: 100%;
+ text-align: center;
+ min-height: 36px;
+ }
+
+ .mesh-message-header {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .mesh-message-meta {
+ width: 100%;
+ justify-content: space-between;
+ }
+
+ .mesh-compose-header {
+ flex-direction: column;
+ }
+
+ .mesh-compose-to {
+ width: 100%;
+ }
+}
+
+@media (max-width: 480px) {
+ .mesh-messages-container {
+ padding: 8px;
+ }
+
+ .mesh-message-card {
+ padding: 10px;
+ }
+
+ .mesh-message-signal {
+ flex-wrap: wrap;
+ }
+}
+
+/* Touch device compliance */
+@media (pointer: coarse) {
+ .mesh-channel-configure {
+ min-height: 44px;
+ padding: 8px 12px;
+ }
+
+ .mesh-compose-send {
+ min-width: 44px;
+ min-height: 44px;
+ }
+
+ .mesh-compose-input {
+ min-height: 44px;
+ }
+}
+
+/* ============================================
+ TRACEROUTE BUTTON IN POPUP
+ ============================================ */
+.mesh-traceroute-btn {
+ display: block;
+ width: 100%;
+ margin-top: 10px;
+ padding: 8px 12px;
+ background: var(--accent-cyan);
+ border: none;
+ border-radius: 4px;
+ color: #000;
+ font-family: var(--font-mono);
+ font-size: 10px;
+ font-weight: 600;
+ text-transform: uppercase;
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.mesh-traceroute-btn:hover {
+ background: var(--accent-green);
+ transform: scale(1.02);
+}
+
+/* ============================================
+ TRACEROUTE MODAL CONTENT
+ ============================================ */
+.mesh-traceroute-content {
+ min-height: 100px;
+}
+
+.mesh-traceroute-loading {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 40px 20px;
+ color: var(--text-secondary);
+}
+
+.mesh-traceroute-spinner {
+ width: 32px;
+ height: 32px;
+ border: 3px solid var(--border-color);
+ border-top-color: var(--accent-cyan);
+ border-radius: 50%;
+ animation: mesh-spin 1s linear infinite;
+ margin-bottom: 16px;
+}
+
+@keyframes mesh-spin {
+ to { transform: rotate(360deg); }
+}
+
+.mesh-traceroute-error {
+ padding: 16px;
+ background: rgba(255, 51, 102, 0.1);
+ border: 1px solid var(--accent-red, #ff3366);
+ border-radius: 6px;
+ color: var(--accent-red, #ff3366);
+ font-size: 12px;
+}
+
+.mesh-traceroute-section {
+ margin-bottom: 16px;
+}
+
+.mesh-traceroute-label {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ font-weight: 600;
+ color: var(--text-dim);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ margin-bottom: 10px;
+}
+
+.mesh-traceroute-path {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+ padding: 12px;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+}
+
+.mesh-traceroute-hop {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 10px 14px;
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ min-width: 70px;
+}
+
+.mesh-traceroute-hop-node {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--accent-cyan);
+ margin-bottom: 4px;
+}
+
+.mesh-traceroute-hop-id {
+ font-family: var(--font-mono);
+ font-size: 9px;
+ color: var(--text-dim);
+ margin-bottom: 6px;
+}
+
+.mesh-traceroute-snr {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ font-weight: 600;
+ padding: 2px 8px;
+ border-radius: 10px;
+}
+
+.mesh-traceroute-snr.snr-good {
+ background: rgba(34, 197, 94, 0.15);
+ color: var(--accent-green);
+}
+
+.mesh-traceroute-snr.snr-ok {
+ background: rgba(74, 158, 255, 0.15);
+ color: var(--accent-cyan);
+}
+
+.mesh-traceroute-snr.snr-poor {
+ background: rgba(255, 193, 7, 0.15);
+ color: var(--accent-orange);
+}
+
+.mesh-traceroute-snr.snr-bad {
+ background: rgba(255, 51, 102, 0.15);
+ color: var(--accent-red, #ff3366);
+}
+
+.mesh-traceroute-arrow {
+ font-size: 18px;
+ color: var(--text-dim);
+ font-weight: bold;
+}
+
+.mesh-traceroute-timestamp {
+ margin-top: 12px;
+ font-family: var(--font-mono);
+ font-size: 10px;
+ color: var(--text-dim);
+ text-align: right;
+}
+
+/* Responsive traceroute path */
+@media (max-width: 600px) {
+ .mesh-traceroute-path {
+ flex-direction: column;
+ }
+
+ .mesh-traceroute-hop {
+ width: 100%;
+ }
+
+ .mesh-traceroute-arrow {
+ transform: rotate(90deg);
+ }
+}
+
+/* ============================================
+ NODE POPUP ACTION BUTTONS
+ ============================================ */
+.mesh-position-btn,
+.mesh-telemetry-btn {
+ padding: 6px 10px;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ color: var(--text-primary);
+ font-family: var(--font-mono);
+ font-size: 9px;
+ font-weight: 600;
+ text-transform: uppercase;
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.mesh-position-btn:hover,
+.mesh-telemetry-btn:hover {
+ background: var(--accent-cyan);
+ color: #000;
+ border-color: var(--accent-cyan);
+}
+
+/* ============================================
+ QR CODE BUTTON
+ ============================================ */
+.mesh-qr-btn {
+ padding: 4px 8px;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ color: var(--text-primary);
+ font-family: var(--font-mono);
+ font-size: 9px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.mesh-qr-btn:hover {
+ background: var(--accent-cyan);
+ color: #000;
+ border-color: var(--accent-cyan);
+}
+
+/* ============================================
+ TELEMETRY CHARTS
+ ============================================ */
+.mesh-telemetry-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 16px;
+ padding-bottom: 10px;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.mesh-telemetry-chart {
+ margin-bottom: 20px;
+}
+
+.mesh-telemetry-chart-title {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-family: var(--font-mono);
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ margin-bottom: 8px;
+}
+
+.mesh-telemetry-current {
+ font-size: 14px;
+ color: var(--accent-cyan);
+}
+
+.mesh-telemetry-svg {
+ width: 100%;
+ height: 100px;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+}
+
+.mesh-chart-line {
+ stroke: var(--accent-cyan);
+ stroke-width: 2;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+.mesh-chart-grid {
+ stroke: var(--border-color);
+ stroke-width: 1;
+}
+
+.mesh-chart-label {
+ font-family: var(--font-mono);
+ font-size: 9px;
+ fill: var(--text-dim);
+}
+
+/* ============================================
+ NETWORK TOPOLOGY
+ ============================================ */
+.mesh-network-list {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.mesh-network-node {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ padding: 12px;
+}
+
+.mesh-network-node-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 10px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.mesh-network-node-id {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--accent-cyan);
+}
+
+.mesh-network-node-count {
+ font-size: 11px;
+ color: var(--text-dim);
+}
+
+.mesh-network-neighbors {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.mesh-network-neighbor {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 10px;
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+}
+
+.mesh-network-neighbor-id {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ color: var(--text-secondary);
+}
+
+.mesh-network-neighbor-snr {
+ font-family: var(--font-mono);
+ font-size: 9px;
+ font-weight: 600;
+ padding: 2px 6px;
+ border-radius: 10px;
+}
+
+.mesh-network-neighbor-snr.snr-good {
+ background: rgba(34, 197, 94, 0.15);
+ color: var(--accent-green);
+}
+
+.mesh-network-neighbor-snr.snr-ok {
+ background: rgba(74, 158, 255, 0.15);
+ color: var(--accent-cyan);
+}
+
+.mesh-network-neighbor-snr.snr-poor {
+ background: rgba(255, 193, 7, 0.15);
+ color: var(--accent-orange);
+}
+
+.mesh-network-neighbor-snr.snr-bad {
+ background: rgba(255, 51, 102, 0.15);
+ color: var(--accent-red, #ff3366);
+}
+
+/* ============================================
+ FIRMWARE BADGES
+ ============================================ */
+.mesh-badge {
+ display: inline-block;
+ padding: 3px 8px;
+ font-family: var(--font-mono);
+ font-size: 10px;
+ font-weight: 600;
+ border-radius: 10px;
+ text-transform: uppercase;
+}
+
+.mesh-badge-success {
+ background: rgba(34, 197, 94, 0.15);
+ color: var(--accent-green);
+}
+
+.mesh-badge-warning {
+ background: rgba(255, 193, 7, 0.15);
+ color: var(--accent-orange);
+}
diff --git a/static/css/modes/spy-stations.css b/static/css/modes/spy-stations.css
index d488ecb..604ac39 100644
--- a/static/css/modes/spy-stations.css
+++ b/static/css/modes/spy-stations.css
@@ -27,7 +27,7 @@
}
.spy-stations-title {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
@@ -101,7 +101,7 @@
}
.spy-station-name {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
@@ -117,7 +117,7 @@
/* Type Badge */
.spy-station-badge {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
@@ -173,7 +173,7 @@
}
.spy-meta-mode {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 10px;
color: var(--accent-orange);
}
@@ -186,7 +186,7 @@
}
.spy-freq-list {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 11px;
color: var(--accent-cyan);
line-height: 1.6;
@@ -199,7 +199,7 @@
}
.spy-freq-item {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 11px;
color: var(--accent-cyan);
background: var(--bg-secondary);
@@ -236,7 +236,7 @@
}
.spy-freq-select {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 10px;
padding: 6px 8px;
background: var(--bg-secondary);
@@ -273,7 +273,7 @@
display: inline-flex;
align-items: center;
gap: 6px;
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
diff --git a/static/css/modes/sstv.css b/static/css/modes/sstv.css
index c6f3b10..6af1ac9 100644
--- a/static/css/modes/sstv.css
+++ b/static/css/modes/sstv.css
@@ -1,876 +1,876 @@
-/**
- * SSTV Mode Styles
- * ISS Slow-Scan Television decoder interface
- */
-
-/* ============================================
- MODE VISIBILITY
- ============================================ */
-#sstvMode.active {
- display: block !important;
-}
-
-/* ============================================
- VISUALS CONTAINER
- ============================================ */
-.sstv-visuals-container {
- display: flex;
- flex-direction: column;
- gap: 12px;
- padding: 12px;
- min-height: 0;
- flex: 1;
- height: 100%;
- overflow: hidden;
-}
-
-/* ============================================
- MAIN ROW (Live Decode + Gallery)
- ============================================ */
-.sstv-main-row {
- display: flex;
- flex-direction: row;
- gap: 12px;
- flex: 1;
- min-height: 0;
- overflow: hidden;
-}
-
-/* ============================================
- STATS STRIP
- ============================================ */
-.sstv-stats-strip {
- display: flex;
- align-items: center;
- gap: 12px;
- padding: 8px 14px;
- background: var(--bg-card);
- border: 1px solid var(--border-color);
- border-radius: 8px;
- flex-wrap: wrap;
- flex-shrink: 0;
-}
-
-.sstv-strip-group {
- display: flex;
- align-items: center;
- gap: 12px;
-}
-
-.sstv-strip-status {
- display: flex;
- align-items: center;
- gap: 6px;
-}
-
-.sstv-strip-dot {
- width: 8px;
- height: 8px;
- border-radius: 50%;
- flex-shrink: 0;
-}
-
-.sstv-strip-dot.idle {
- background: var(--text-dim);
-}
-
-.sstv-strip-dot.listening {
- background: var(--accent-yellow);
- animation: pulse 1s infinite;
-}
-
-.sstv-strip-dot.decoding {
- background: var(--accent-cyan);
- box-shadow: 0 0 6px var(--accent-cyan);
- animation: pulse 0.5s infinite;
-}
-
-.sstv-strip-status-text {
- font-family: 'JetBrains Mono', monospace;
- font-size: 10px;
- color: var(--text-secondary);
- text-transform: uppercase;
-}
-
-.sstv-strip-btn {
- font-family: 'JetBrains Mono', monospace;
- font-size: 10px;
- padding: 5px 12px;
- border: none;
- border-radius: 4px;
- cursor: pointer;
- text-transform: uppercase;
- font-weight: 600;
- transition: all 0.15s ease;
-}
-
-.sstv-strip-btn.start {
- background: var(--accent-cyan);
- color: var(--bg-primary);
-}
-
-.sstv-strip-btn.start:hover {
- background: var(--accent-cyan-bright, #00d4ff);
-}
-
-.sstv-strip-btn.stop {
- background: var(--accent-red, #ff3366);
- color: white;
-}
-
-.sstv-strip-btn.stop:hover {
- background: #ff1a53;
-}
-
-.sstv-strip-divider {
- width: 1px;
- height: 24px;
- background: var(--border-color);
-}
-
-.sstv-strip-stat {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 2px;
- min-width: 50px;
-}
-
-.sstv-strip-value {
- font-family: 'JetBrains Mono', monospace;
- font-size: 12px;
- font-weight: 600;
- color: var(--text-primary);
-}
-
-.sstv-strip-value.accent-cyan {
- color: var(--accent-cyan);
-}
-
-.sstv-strip-label {
- font-family: 'JetBrains Mono', monospace;
- font-size: 8px;
- color: var(--text-dim);
- text-transform: uppercase;
- letter-spacing: 0.5px;
-}
-
-/* Location inputs in strip */
-.sstv-strip-location {
- display: flex;
- align-items: center;
- gap: 4px;
-}
-
-.sstv-loc-input {
- width: 70px;
- padding: 4px 6px;
- font-family: 'JetBrains Mono', monospace;
- font-size: 10px;
- background: var(--bg-secondary);
- border: 1px solid var(--border-color);
- border-radius: 4px;
- color: var(--text-primary);
- text-align: right;
-}
-
-.sstv-loc-input:focus {
- outline: none;
- border-color: var(--accent-cyan);
-}
-
-.sstv-strip-btn.gps {
- display: flex;
- align-items: center;
- gap: 4px;
- background: var(--bg-tertiary);
- color: var(--text-secondary);
- border: 1px solid var(--border-color);
-}
-
-.sstv-strip-btn.gps:hover {
- background: var(--accent-green);
- color: #000;
- border-color: var(--accent-green);
-}
-
-.sstv-strip-btn.update-tle {
- display: flex;
- align-items: center;
- gap: 4px;
- background: var(--bg-tertiary);
- color: var(--text-secondary);
- border: 1px solid var(--border-color);
-}
-
-.sstv-strip-btn.update-tle:hover {
- background: var(--accent-orange);
- color: #000;
- border-color: var(--accent-orange);
-}
-
-/* ============================================
- LIVE DECODE SECTION
- ============================================ */
-.sstv-live-section {
- background: var(--bg-card);
- border: 1px solid var(--border-color);
- border-radius: 8px;
- overflow: hidden;
- display: flex;
- flex-direction: column;
- flex: 1;
- min-width: 300px;
-}
-
-.sstv-live-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 10px 14px;
- background: rgba(0, 0, 0, 0.2);
- border-bottom: 1px solid var(--border-color);
-}
-
-.sstv-live-title {
- display: flex;
- align-items: center;
- gap: 8px;
- font-family: 'JetBrains Mono', monospace;
- font-size: 12px;
- font-weight: 600;
- color: var(--text-primary);
-}
-
-.sstv-live-title svg {
- color: var(--accent-cyan);
-}
-
-.sstv-live-content {
- flex: 1;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- padding: 16px;
- min-height: 0;
-}
-
-.sstv-canvas-container {
- position: relative;
- background: #000;
- border: 1px solid var(--border-color);
- border-radius: 4px;
- overflow: hidden;
-}
-
-#sstvCanvas {
- display: block;
- image-rendering: pixelated;
-}
-
-.sstv-decode-info {
- width: 100%;
- margin-top: 12px;
- display: flex;
- flex-direction: column;
- gap: 8px;
-}
-
-.sstv-mode-label {
- font-family: 'JetBrains Mono', monospace;
- font-size: 11px;
- color: var(--accent-cyan);
- text-align: center;
-}
-
-.sstv-progress-bar {
- width: 100%;
- height: 4px;
- background: var(--bg-secondary);
- border-radius: 2px;
- overflow: hidden;
-}
-
-.sstv-progress-bar .progress {
- height: 100%;
- background: linear-gradient(90deg, var(--accent-cyan), var(--accent-green));
- border-radius: 2px;
- transition: width 0.3s ease;
-}
-
-.sstv-status-message {
- font-family: 'JetBrains Mono', monospace;
- font-size: 10px;
- color: var(--text-dim);
- text-align: center;
-}
-
-/* Idle state */
-.sstv-idle-state {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- text-align: center;
- padding: 40px 20px;
- color: var(--text-dim);
-}
-
-.sstv-idle-state svg {
- width: 64px;
- height: 64px;
- opacity: 0.3;
- margin-bottom: 16px;
-}
-
-.sstv-idle-state h4 {
- font-size: 14px;
- color: var(--text-secondary);
- margin-bottom: 8px;
-}
-
-.sstv-idle-state p {
- font-size: 12px;
- max-width: 250px;
-}
-
-/* ============================================
- GALLERY SECTION
- ============================================ */
-.sstv-gallery-section {
- background: var(--bg-card);
- border: 1px solid var(--border-color);
- border-radius: 8px;
- overflow: hidden;
- display: flex;
- flex-direction: column;
- flex: 1.5;
- min-width: 300px;
-}
-
-.sstv-gallery-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 10px 14px;
- background: rgba(0, 0, 0, 0.2);
- border-bottom: 1px solid var(--border-color);
-}
-
-.sstv-gallery-title {
- display: flex;
- align-items: center;
- gap: 8px;
- font-family: 'JetBrains Mono', monospace;
- font-size: 12px;
- font-weight: 600;
- color: var(--text-primary);
-}
-
-.sstv-gallery-count {
- font-family: 'JetBrains Mono', monospace;
- font-size: 10px;
- color: var(--accent-cyan);
- background: var(--bg-secondary);
- padding: 2px 8px;
- border-radius: 10px;
-}
-
-.sstv-gallery-grid {
- flex: 1;
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
- gap: 12px;
- padding: 12px;
- overflow-y: auto;
- align-content: start;
-}
-
-.sstv-image-card {
- background: var(--bg-secondary);
- border: 1px solid var(--border-color);
- border-radius: 6px;
- overflow: hidden;
- transition: all 0.15s ease;
- cursor: pointer;
-}
-
-.sstv-image-card:hover {
- border-color: var(--accent-cyan);
- transform: translateY(-2px);
- box-shadow: 0 4px 12px rgba(0, 212, 255, 0.2);
-}
-
-.sstv-image-preview {
- width: 100%;
- aspect-ratio: 4/3;
- object-fit: cover;
- background: #000;
- display: block;
-}
-
-.sstv-image-info {
- padding: 8px 10px;
- border-top: 1px solid var(--border-color);
-}
-
-.sstv-image-mode {
- font-family: 'JetBrains Mono', monospace;
- font-size: 10px;
- font-weight: 600;
- color: var(--accent-cyan);
- margin-bottom: 4px;
-}
-
-.sstv-image-timestamp {
- font-family: 'JetBrains Mono', monospace;
- font-size: 9px;
- color: var(--text-dim);
-}
-
-/* Empty gallery state */
-.sstv-gallery-empty {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- padding: 60px 20px;
- text-align: center;
- color: var(--text-dim);
- grid-column: 1 / -1;
-}
-
-.sstv-gallery-empty svg {
- width: 48px;
- height: 48px;
- opacity: 0.3;
- margin-bottom: 12px;
-}
-
-/* ============================================
- TOP ROW (Map + Countdown)
- ============================================ */
-.sstv-top-row {
- display: flex;
- gap: 12px;
- height: 220px;
- flex-shrink: 0;
-}
-
-/* ============================================
- ISS MAP ROW
- ============================================ */
-.sstv-map-row {
- flex: 1.5;
- min-width: 0;
- height: 100%;
-}
-
-.sstv-map-container {
- position: relative;
- background: var(--bg-card);
- border: 1px solid var(--border-color);
- border-radius: 8px;
- overflow: hidden;
- height: 100%;
-}
-
-.sstv-iss-map {
- width: 100%;
- height: 100%;
- background: #0a1628;
-}
-
-.sstv-map-overlay {
- position: absolute;
- bottom: 0;
- left: 0;
- right: 0;
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 8px 12px;
- background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
- pointer-events: none;
- z-index: 1000;
-}
-
-.sstv-map-info {
- display: flex;
- align-items: center;
- gap: 12px;
- font-family: 'JetBrains Mono', monospace;
-}
-
-.sstv-map-label {
- font-size: 10px;
- font-weight: bold;
- color: #ffcc00;
- background: rgba(255, 204, 0, 0.2);
- padding: 2px 6px;
- border-radius: 3px;
-}
-
-.sstv-map-coords {
- font-size: 11px;
- color: var(--accent-cyan);
-}
-
-.sstv-map-alt {
- font-size: 10px;
- color: var(--text-secondary);
-}
-
-.sstv-pass-info {
- display: flex;
- align-items: center;
- gap: 8px;
- font-family: 'JetBrains Mono', monospace;
-}
-
-.sstv-pass-label {
- font-size: 9px;
- color: var(--text-dim);
- text-transform: uppercase;
-}
-
-.sstv-pass-value {
- font-size: 11px;
- color: var(--text-primary);
-}
-
-/* ============================================
- ISS MAP MARKER
- ============================================ */
-.sstv-iss-marker {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
-}
-
-.sstv-iss-dot {
- width: 16px;
- height: 16px;
- background: #ffcc00;
- border: 2px solid #fff;
- border-radius: 50%;
- box-shadow: 0 0 15px rgba(255, 204, 0, 0.8), 0 0 30px rgba(255, 204, 0, 0.4);
- animation: iss-pulse 2s ease-in-out infinite;
-}
-
-.sstv-iss-label {
- font-family: 'JetBrains Mono', monospace;
- font-size: 10px;
- font-weight: bold;
- color: #ffcc00;
- text-shadow: 0 0 3px rgba(0, 0, 0, 0.8), 0 0 6px rgba(0, 0, 0, 0.5);
- margin-top: 2px;
-}
-
-@keyframes iss-pulse {
- 0%, 100% {
- box-shadow: 0 0 15px rgba(255, 204, 0, 0.8), 0 0 30px rgba(255, 204, 0, 0.4);
- }
- 50% {
- box-shadow: 0 0 25px rgba(255, 204, 0, 1), 0 0 50px rgba(255, 204, 0, 0.6);
- }
-}
-
-/* Override Leaflet default marker styles */
-.leaflet-marker-icon.sstv-iss-marker {
- background: transparent;
- border: none;
-}
-
-/* ============================================
- COUNTDOWN PANEL
- ============================================ */
-.sstv-countdown-panel {
- flex: 1;
- min-width: 280px;
- max-width: 380px;
- background: var(--bg-card);
- border: 1px solid var(--border-color);
- border-radius: 8px;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- height: 100%;
-}
-
-.sstv-countdown-header {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 10px 14px;
- background: rgba(0, 0, 0, 0.2);
- border-bottom: 1px solid var(--border-color);
- font-family: 'JetBrains Mono', monospace;
- font-size: 12px;
- font-weight: 600;
- color: var(--text-primary);
-}
-
-.sstv-countdown-header svg {
- color: var(--accent-cyan);
-}
-
-.sstv-countdown-body {
- flex: 1;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- padding: 12px;
- gap: 10px;
-}
-
-.sstv-countdown-timer {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 4px;
-}
-
-.sstv-countdown-value {
- font-family: 'JetBrains Mono', monospace;
- font-size: 28px;
- font-weight: 700;
- color: var(--accent-cyan);
- letter-spacing: 2px;
- text-shadow: 0 0 20px rgba(0, 212, 255, 0.3);
-}
-
-.sstv-countdown-value.imminent {
- color: var(--accent-green);
- text-shadow: 0 0 20px rgba(0, 255, 136, 0.4);
- animation: countdown-pulse 1s ease-in-out infinite;
-}
-
-.sstv-countdown-value.active {
- color: var(--accent-yellow);
- text-shadow: 0 0 20px rgba(255, 204, 0, 0.4);
- animation: countdown-pulse 0.5s ease-in-out infinite;
-}
-
-@keyframes countdown-pulse {
- 0%, 100% { opacity: 1; }
- 50% { opacity: 0.7; }
-}
-
-.sstv-countdown-label {
- font-family: 'JetBrains Mono', monospace;
- font-size: 10px;
- color: var(--text-dim);
- text-transform: uppercase;
- letter-spacing: 1px;
-}
-
-.sstv-countdown-details {
- display: grid;
- grid-template-columns: repeat(2, 1fr);
- gap: 6px 12px;
- width: 100%;
- padding: 10px;
- background: var(--bg-secondary);
- border-radius: 6px;
-}
-
-.sstv-countdown-detail {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 2px;
-}
-
-.sstv-detail-label {
- font-family: 'JetBrains Mono', monospace;
- font-size: 8px;
- color: var(--text-dim);
- text-transform: uppercase;
- letter-spacing: 0.5px;
-}
-
-.sstv-detail-value {
- font-family: 'JetBrains Mono', monospace;
- font-size: 12px;
- font-weight: 600;
- color: var(--text-primary);
-}
-
-.sstv-countdown-status {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 6px;
- padding: 8px 14px;
- background: rgba(0, 0, 0, 0.15);
- border-top: 1px solid var(--border-color);
- font-family: 'JetBrains Mono', monospace;
- font-size: 9px;
- color: var(--text-dim);
- text-transform: uppercase;
-}
-
-.sstv-countdown-status .sstv-status-dot {
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: var(--text-dim);
-}
-
-.sstv-countdown-status.has-pass .sstv-status-dot {
- background: var(--accent-cyan);
-}
-
-.sstv-countdown-status.imminent .sstv-status-dot {
- background: var(--accent-green);
- animation: pulse 1s infinite;
-}
-
-.sstv-countdown-status.active .sstv-status-dot {
- background: var(--accent-yellow);
- box-shadow: 0 0 8px var(--accent-yellow);
- animation: pulse 0.5s infinite;
-}
-
-/* ============================================
- IMAGE MODAL
- ============================================ */
-.sstv-image-modal {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background: rgba(0, 0, 0, 0.9);
- display: none;
- align-items: center;
- justify-content: center;
- z-index: 10000;
- padding: 40px;
-}
-
-.sstv-image-modal.show {
- display: flex;
-}
-
-.sstv-image-modal img {
- max-width: 100%;
- max-height: 100%;
- border: 1px solid var(--border-color);
- border-radius: 4px;
-}
-
-.sstv-modal-close {
- position: absolute;
- top: 20px;
- right: 20px;
- background: none;
- border: none;
- color: white;
- font-size: 32px;
- cursor: pointer;
- opacity: 0.7;
- transition: opacity 0.15s;
-}
-
-.sstv-modal-close:hover {
- opacity: 1;
-}
-
-/* ============================================
- RESPONSIVE
- ============================================ */
-@media (max-width: 1024px) {
- .sstv-main-row {
- flex-direction: column;
- overflow-y: auto;
- }
-
- .sstv-live-section {
- max-width: none;
- min-height: 350px;
- }
-
- .sstv-gallery-section {
- min-height: 300px;
- }
-}
-
-@media (max-width: 1024px) {
- .sstv-top-row {
- flex-direction: column;
- height: auto;
- }
-
- .sstv-map-row {
- flex: none;
- height: 180px;
- }
-
- .sstv-countdown-panel {
- min-width: auto;
- max-width: none;
- height: auto;
- }
-
- .sstv-countdown-value {
- font-size: 24px;
- }
-
- .sstv-iss-map {
- height: 180px;
- }
-
- .sstv-map-overlay {
- flex-direction: column;
- align-items: flex-start;
- gap: 4px;
- }
-}
-
-@media (max-width: 768px) {
- .sstv-stats-strip {
- padding: 8px 12px;
- gap: 8px;
- flex-wrap: wrap;
- }
-
- .sstv-strip-divider {
- display: none;
- }
-
- .sstv-strip-location {
- flex-wrap: wrap;
- }
-
- .sstv-loc-input {
- width: 55px;
- }
-
- .sstv-gallery-grid {
- grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
- gap: 8px;
- padding: 8px;
- }
-
- .sstv-iss-map {
- height: 150px;
- }
-
- .sstv-map-info {
- gap: 8px;
- }
-
- .sstv-map-overlay {
- padding: 6px 10px;
- }
-}
-
-@keyframes pulse {
- 0%, 100% { opacity: 1; }
- 50% { opacity: 0.4; }
-}
+/**
+ * SSTV Mode Styles
+ * ISS Slow-Scan Television decoder interface
+ */
+
+/* ============================================
+ MODE VISIBILITY
+ ============================================ */
+#sstvMode.active {
+ display: block !important;
+}
+
+/* ============================================
+ VISUALS CONTAINER
+ ============================================ */
+.sstv-visuals-container {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ padding: 12px;
+ min-height: 0;
+ flex: 1;
+ height: 100%;
+ overflow: hidden;
+}
+
+/* ============================================
+ MAIN ROW (Live Decode + Gallery)
+ ============================================ */
+.sstv-main-row {
+ display: flex;
+ flex-direction: row;
+ gap: 12px;
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+}
+
+/* ============================================
+ STATS STRIP
+ ============================================ */
+.sstv-stats-strip {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 8px 14px;
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ flex-wrap: wrap;
+ flex-shrink: 0;
+}
+
+.sstv-strip-group {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.sstv-strip-status {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.sstv-strip-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.sstv-strip-dot.idle {
+ background: var(--text-dim);
+}
+
+.sstv-strip-dot.listening {
+ background: var(--accent-yellow);
+ animation: pulse 1s infinite;
+}
+
+.sstv-strip-dot.decoding {
+ background: var(--accent-cyan);
+ box-shadow: 0 0 6px var(--accent-cyan);
+ animation: pulse 0.5s infinite;
+}
+
+.sstv-strip-status-text {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+}
+
+.sstv-strip-btn {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ padding: 5px 12px;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ text-transform: uppercase;
+ font-weight: 600;
+ transition: all 0.15s ease;
+}
+
+.sstv-strip-btn.start {
+ background: var(--accent-cyan);
+ color: var(--bg-primary);
+}
+
+.sstv-strip-btn.start:hover {
+ background: var(--accent-cyan-bright, #00d4ff);
+}
+
+.sstv-strip-btn.stop {
+ background: var(--accent-red, #ff3366);
+ color: white;
+}
+
+.sstv-strip-btn.stop:hover {
+ background: #ff1a53;
+}
+
+.sstv-strip-divider {
+ width: 1px;
+ height: 24px;
+ background: var(--border-color);
+}
+
+.sstv-strip-stat {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 2px;
+ min-width: 50px;
+}
+
+.sstv-strip-value {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.sstv-strip-value.accent-cyan {
+ color: var(--accent-cyan);
+}
+
+.sstv-strip-label {
+ font-family: var(--font-mono);
+ font-size: 8px;
+ color: var(--text-dim);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+/* Location inputs in strip */
+.sstv-strip-location {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.sstv-loc-input {
+ width: 70px;
+ padding: 4px 6px;
+ font-family: var(--font-mono);
+ font-size: 10px;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ color: var(--text-primary);
+ text-align: right;
+}
+
+.sstv-loc-input:focus {
+ outline: none;
+ border-color: var(--accent-cyan);
+}
+
+.sstv-strip-btn.gps {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ background: var(--bg-tertiary);
+ color: var(--text-secondary);
+ border: 1px solid var(--border-color);
+}
+
+.sstv-strip-btn.gps:hover {
+ background: var(--accent-green);
+ color: #000;
+ border-color: var(--accent-green);
+}
+
+.sstv-strip-btn.update-tle {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ background: var(--bg-tertiary);
+ color: var(--text-secondary);
+ border: 1px solid var(--border-color);
+}
+
+.sstv-strip-btn.update-tle:hover {
+ background: var(--accent-orange);
+ color: #000;
+ border-color: var(--accent-orange);
+}
+
+/* ============================================
+ LIVE DECODE SECTION
+ ============================================ */
+.sstv-live-section {
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-width: 300px;
+}
+
+.sstv-live-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 14px;
+ background: rgba(0, 0, 0, 0.2);
+ border-bottom: 1px solid var(--border-color);
+}
+
+.sstv-live-title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-family: var(--font-mono);
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.sstv-live-title svg {
+ color: var(--accent-cyan);
+}
+
+.sstv-live-content {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 16px;
+ min-height: 0;
+}
+
+.sstv-canvas-container {
+ position: relative;
+ background: #000;
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ overflow: hidden;
+}
+
+#sstvCanvas {
+ display: block;
+ image-rendering: pixelated;
+}
+
+.sstv-decode-info {
+ width: 100%;
+ margin-top: 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.sstv-mode-label {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ color: var(--accent-cyan);
+ text-align: center;
+}
+
+.sstv-progress-bar {
+ width: 100%;
+ height: 4px;
+ background: var(--bg-secondary);
+ border-radius: 2px;
+ overflow: hidden;
+}
+
+.sstv-progress-bar .progress {
+ height: 100%;
+ background: linear-gradient(90deg, var(--accent-cyan), var(--accent-green));
+ border-radius: 2px;
+ transition: width 0.3s ease;
+}
+
+.sstv-status-message {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ color: var(--text-dim);
+ text-align: center;
+}
+
+/* Idle state */
+.sstv-idle-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ padding: 40px 20px;
+ color: var(--text-dim);
+}
+
+.sstv-idle-state svg {
+ width: 64px;
+ height: 64px;
+ opacity: 0.3;
+ margin-bottom: 16px;
+}
+
+.sstv-idle-state h4 {
+ font-size: 14px;
+ color: var(--text-secondary);
+ margin-bottom: 8px;
+}
+
+.sstv-idle-state p {
+ font-size: 12px;
+ max-width: 250px;
+}
+
+/* ============================================
+ GALLERY SECTION
+ ============================================ */
+.sstv-gallery-section {
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ flex: 1.5;
+ min-width: 300px;
+}
+
+.sstv-gallery-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 14px;
+ background: rgba(0, 0, 0, 0.2);
+ border-bottom: 1px solid var(--border-color);
+}
+
+.sstv-gallery-title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-family: var(--font-mono);
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.sstv-gallery-count {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ color: var(--accent-cyan);
+ background: var(--bg-secondary);
+ padding: 2px 8px;
+ border-radius: 10px;
+}
+
+.sstv-gallery-grid {
+ flex: 1;
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+ gap: 12px;
+ padding: 12px;
+ overflow-y: auto;
+ align-content: start;
+}
+
+.sstv-image-card {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ overflow: hidden;
+ transition: all 0.15s ease;
+ cursor: pointer;
+}
+
+.sstv-image-card:hover {
+ border-color: var(--accent-cyan);
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(0, 212, 255, 0.2);
+}
+
+.sstv-image-preview {
+ width: 100%;
+ aspect-ratio: 4/3;
+ object-fit: cover;
+ background: #000;
+ display: block;
+}
+
+.sstv-image-info {
+ padding: 8px 10px;
+ border-top: 1px solid var(--border-color);
+}
+
+.sstv-image-mode {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ font-weight: 600;
+ color: var(--accent-cyan);
+ margin-bottom: 4px;
+}
+
+.sstv-image-timestamp {
+ font-family: var(--font-mono);
+ font-size: 9px;
+ color: var(--text-dim);
+}
+
+/* Empty gallery state */
+.sstv-gallery-empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 60px 20px;
+ text-align: center;
+ color: var(--text-dim);
+ grid-column: 1 / -1;
+}
+
+.sstv-gallery-empty svg {
+ width: 48px;
+ height: 48px;
+ opacity: 0.3;
+ margin-bottom: 12px;
+}
+
+/* ============================================
+ TOP ROW (Map + Countdown)
+ ============================================ */
+.sstv-top-row {
+ display: flex;
+ gap: 12px;
+ height: 220px;
+ flex-shrink: 0;
+}
+
+/* ============================================
+ ISS MAP ROW
+ ============================================ */
+.sstv-map-row {
+ flex: 1.5;
+ min-width: 0;
+ height: 100%;
+}
+
+.sstv-map-container {
+ position: relative;
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ overflow: hidden;
+ height: 100%;
+}
+
+.sstv-iss-map {
+ width: 100%;
+ height: 100%;
+ background: #0a1628;
+}
+
+.sstv-map-overlay {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 8px 12px;
+ background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
+ pointer-events: none;
+ z-index: 1000;
+}
+
+.sstv-map-info {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ font-family: var(--font-mono);
+}
+
+.sstv-map-label {
+ font-size: 10px;
+ font-weight: bold;
+ color: #ffcc00;
+ background: rgba(255, 204, 0, 0.2);
+ padding: 2px 6px;
+ border-radius: 3px;
+}
+
+.sstv-map-coords {
+ font-size: 11px;
+ color: var(--accent-cyan);
+}
+
+.sstv-map-alt {
+ font-size: 10px;
+ color: var(--text-secondary);
+}
+
+.sstv-pass-info {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-family: var(--font-mono);
+}
+
+.sstv-pass-label {
+ font-size: 9px;
+ color: var(--text-dim);
+ text-transform: uppercase;
+}
+
+.sstv-pass-value {
+ font-size: 11px;
+ color: var(--text-primary);
+}
+
+/* ============================================
+ ISS MAP MARKER
+ ============================================ */
+.sstv-iss-marker {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+}
+
+.sstv-iss-dot {
+ width: 16px;
+ height: 16px;
+ background: #ffcc00;
+ border: 2px solid #fff;
+ border-radius: 50%;
+ box-shadow: 0 0 15px rgba(255, 204, 0, 0.8), 0 0 30px rgba(255, 204, 0, 0.4);
+ animation: iss-pulse 2s ease-in-out infinite;
+}
+
+.sstv-iss-label {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ font-weight: bold;
+ color: #ffcc00;
+ text-shadow: 0 0 3px rgba(0, 0, 0, 0.8), 0 0 6px rgba(0, 0, 0, 0.5);
+ margin-top: 2px;
+}
+
+@keyframes iss-pulse {
+ 0%, 100% {
+ box-shadow: 0 0 15px rgba(255, 204, 0, 0.8), 0 0 30px rgba(255, 204, 0, 0.4);
+ }
+ 50% {
+ box-shadow: 0 0 25px rgba(255, 204, 0, 1), 0 0 50px rgba(255, 204, 0, 0.6);
+ }
+}
+
+/* Override Leaflet default marker styles */
+.leaflet-marker-icon.sstv-iss-marker {
+ background: transparent;
+ border: none;
+}
+
+/* ============================================
+ COUNTDOWN PANEL
+ ============================================ */
+.sstv-countdown-panel {
+ flex: 1;
+ min-width: 280px;
+ max-width: 380px;
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ height: 100%;
+}
+
+.sstv-countdown-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 14px;
+ background: rgba(0, 0, 0, 0.2);
+ border-bottom: 1px solid var(--border-color);
+ font-family: var(--font-mono);
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.sstv-countdown-header svg {
+ color: var(--accent-cyan);
+}
+
+.sstv-countdown-body {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 12px;
+ gap: 10px;
+}
+
+.sstv-countdown-timer {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 4px;
+}
+
+.sstv-countdown-value {
+ font-family: var(--font-mono);
+ font-size: 28px;
+ font-weight: 700;
+ color: var(--accent-cyan);
+ letter-spacing: 2px;
+ text-shadow: 0 0 20px rgba(0, 212, 255, 0.3);
+}
+
+.sstv-countdown-value.imminent {
+ color: var(--accent-green);
+ text-shadow: 0 0 20px rgba(0, 255, 136, 0.4);
+ animation: countdown-pulse 1s ease-in-out infinite;
+}
+
+.sstv-countdown-value.active {
+ color: var(--accent-yellow);
+ text-shadow: 0 0 20px rgba(255, 204, 0, 0.4);
+ animation: countdown-pulse 0.5s ease-in-out infinite;
+}
+
+@keyframes countdown-pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.7; }
+}
+
+.sstv-countdown-label {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ color: var(--text-dim);
+ text-transform: uppercase;
+ letter-spacing: 1px;
+}
+
+.sstv-countdown-details {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 6px 12px;
+ width: 100%;
+ padding: 10px;
+ background: var(--bg-secondary);
+ border-radius: 6px;
+}
+
+.sstv-countdown-detail {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 2px;
+}
+
+.sstv-detail-label {
+ font-family: var(--font-mono);
+ font-size: 8px;
+ color: var(--text-dim);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.sstv-detail-value {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.sstv-countdown-status {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ padding: 8px 14px;
+ background: rgba(0, 0, 0, 0.15);
+ border-top: 1px solid var(--border-color);
+ font-family: var(--font-mono);
+ font-size: 9px;
+ color: var(--text-dim);
+ text-transform: uppercase;
+}
+
+.sstv-countdown-status .sstv-status-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--text-dim);
+}
+
+.sstv-countdown-status.has-pass .sstv-status-dot {
+ background: var(--accent-cyan);
+}
+
+.sstv-countdown-status.imminent .sstv-status-dot {
+ background: var(--accent-green);
+ animation: pulse 1s infinite;
+}
+
+.sstv-countdown-status.active .sstv-status-dot {
+ background: var(--accent-yellow);
+ box-shadow: 0 0 8px var(--accent-yellow);
+ animation: pulse 0.5s infinite;
+}
+
+/* ============================================
+ IMAGE MODAL
+ ============================================ */
+.sstv-image-modal {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.9);
+ display: none;
+ align-items: center;
+ justify-content: center;
+ z-index: 10000;
+ padding: 40px;
+}
+
+.sstv-image-modal.show {
+ display: flex;
+}
+
+.sstv-image-modal img {
+ max-width: 100%;
+ max-height: 100%;
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+}
+
+.sstv-modal-close {
+ position: absolute;
+ top: 20px;
+ right: 20px;
+ background: none;
+ border: none;
+ color: white;
+ font-size: 32px;
+ cursor: pointer;
+ opacity: 0.7;
+ transition: opacity 0.15s;
+}
+
+.sstv-modal-close:hover {
+ opacity: 1;
+}
+
+/* ============================================
+ RESPONSIVE
+ ============================================ */
+@media (max-width: 1024px) {
+ .sstv-main-row {
+ flex-direction: column;
+ overflow-y: auto;
+ }
+
+ .sstv-live-section {
+ max-width: none;
+ min-height: 350px;
+ }
+
+ .sstv-gallery-section {
+ min-height: 300px;
+ }
+}
+
+@media (max-width: 1024px) {
+ .sstv-top-row {
+ flex-direction: column;
+ height: auto;
+ }
+
+ .sstv-map-row {
+ flex: none;
+ height: 180px;
+ }
+
+ .sstv-countdown-panel {
+ min-width: auto;
+ max-width: none;
+ height: auto;
+ }
+
+ .sstv-countdown-value {
+ font-size: 24px;
+ }
+
+ .sstv-iss-map {
+ height: 180px;
+ }
+
+ .sstv-map-overlay {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 4px;
+ }
+}
+
+@media (max-width: 768px) {
+ .sstv-stats-strip {
+ padding: 8px 12px;
+ gap: 8px;
+ flex-wrap: wrap;
+ }
+
+ .sstv-strip-divider {
+ display: none;
+ }
+
+ .sstv-strip-location {
+ flex-wrap: wrap;
+ }
+
+ .sstv-loc-input {
+ width: 55px;
+ }
+
+ .sstv-gallery-grid {
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
+ gap: 8px;
+ padding: 8px;
+ }
+
+ .sstv-iss-map {
+ height: 150px;
+ }
+
+ .sstv-map-info {
+ gap: 8px;
+ }
+
+ .sstv-map-overlay {
+ padding: 6px 10px;
+ }
+}
+
+@keyframes pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.4; }
+}
diff --git a/static/css/modes/tscm.css b/static/css/modes/tscm.css
index 981e103..63a43a5 100644
--- a/static/css/modes/tscm.css
+++ b/static/css/modes/tscm.css
@@ -1,1463 +1,1463 @@
-/* TSCM Styles */
-
-/* TSCM Threat Cards */
-.threat-card {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- padding: 8px;
- border-radius: 6px;
- background: rgba(0,0,0,0.3);
- border: 1px solid var(--border-color);
-}
-.threat-card .count {
- font-size: 18px;
- font-weight: bold;
- line-height: 1;
-}
-.threat-card .label {
- font-size: 9px;
- text-transform: uppercase;
- opacity: 0.7;
- margin-top: 2px;
-}
-.threat-card.critical { border-color: #ff3366; color: #ff3366; }
-.threat-card.critical.active { background: rgba(255,51,102,0.2); }
-.threat-card.high { border-color: #ff9933; color: #ff9933; }
-.threat-card.high.active { background: rgba(255,153,51,0.2); }
-.threat-card.medium { border-color: #ffcc00; color: #ffcc00; }
-.threat-card.medium.active { background: rgba(255,204,0,0.2); }
-.threat-card.low { border-color: #00ff88; color: #00ff88; }
-.threat-card.low.active { background: rgba(0,255,136,0.2); }
-
-/* TSCM Dashboard */
-.tscm-dashboard {
- display: flex;
- flex-direction: column;
- gap: 16px;
- overflow-y: auto;
- padding-bottom: 80px; /* Space for status bar */
-}
-.tscm-threat-banner {
- display: flex;
- gap: 12px;
- padding: 12px;
- background: rgba(0,0,0,0.3);
- border-radius: 8px;
-}
-.tscm-threat-banner .threat-card {
- flex: 1;
- padding: 12px;
-}
-.tscm-threat-banner .threat-card .count {
- font-size: 24px;
-}
-.tscm-main-grid {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 16px;
-}
-.tscm-panel {
- background: rgba(0,0,0,0.3);
- border: 1px solid var(--border-color);
- border-radius: 8px;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- min-height: 200px;
- height: 200px;
-}
-/* Full-width panels (like Detected Threats) get more height */
-.tscm-panel[style*="grid-column: span 2"] {
- min-height: 150px;
- height: 150px;
-}
-.tscm-panel-header {
- padding: 10px 12px;
- background: rgba(0,0,0,0.3);
- border-bottom: 1px solid var(--border-color);
- font-weight: 600;
- font-size: 12px;
- display: flex;
- justify-content: space-between;
- align-items: center;
-}
-.tscm-panel-header .badge {
- background: var(--primary-color);
- color: #fff;
- padding: 2px 8px;
- border-radius: 10px;
- font-size: 10px;
- font-weight: normal;
-}
-.tscm-panel-content {
- flex: 1;
- overflow-y: auto;
- padding: 8px;
-}
-.tscm-device-item {
- padding: 8px 10px;
- border-radius: 4px;
- margin-bottom: 6px;
- background: rgba(0,0,0,0.2);
- border-left: 3px solid var(--border-color);
- cursor: pointer;
- transition: background 0.2s;
-}
-.tscm-device-item:hover {
- background: rgba(74,158,255,0.1);
-}
-.tscm-device-item.new {
- border-left-color: #ff9933;
- animation: pulse-glow 2s infinite;
-}
-.tscm-device-item.threat {
- border-left-color: #ff3366;
-}
-.tscm-device-item.baseline {
- border-left-color: #00ff88;
-}
-/* Classification colors */
-.tscm-device-item.classification-green {
- border-left-color: #00cc00;
- background: rgba(0, 204, 0, 0.1);
-}
-.tscm-device-item.classification-yellow {
- border-left-color: #ffcc00;
- background: rgba(255, 204, 0, 0.1);
-}
-.tscm-device-item.classification-red {
- border-left-color: #ff3333;
- background: rgba(255, 51, 51, 0.15);
- animation: pulse-glow 2s infinite;
-}
-.classification-indicator {
- margin-right: 6px;
-}
-.tscm-status-message {
- padding: 12px;
- background: rgba(255, 153, 51, 0.15);
- border: 1px solid rgba(255, 153, 51, 0.3);
- border-radius: 6px;
- color: var(--text-primary);
- font-size: 12px;
- display: flex;
- align-items: center;
- gap: 8px;
-}
-.tscm-status-message .status-icon {
- font-size: 16px;
-}
-.tscm-privilege-warning {
- padding: 10px 12px;
- background: rgba(255, 51, 51, 0.15);
- border: 1px solid rgba(255, 51, 51, 0.4);
- border-radius: 6px;
- color: var(--text-primary);
- font-size: 11px;
- display: flex;
- align-items: flex-start;
- gap: 10px;
- margin-bottom: 12px;
-}
-.tscm-privilege-warning .warning-icon {
- font-size: 18px;
- flex-shrink: 0;
-}
-.tscm-privilege-warning .warning-action {
- margin-top: 4px;
- font-family: 'JetBrains Mono', monospace;
- font-size: 10px;
- color: var(--accent-cyan);
- background: rgba(0, 0, 0, 0.3);
- padding: 4px 8px;
- border-radius: 3px;
-}
-.tscm-action-btn {
- padding: 10px 16px;
- background: var(--accent-green);
- border: none;
- border-radius: 4px;
- color: #000;
- font-size: 12px;
- font-weight: 600;
- cursor: pointer;
- transition: all 0.2s;
-}
-.tscm-action-btn:hover {
- background: #2ecc71;
- transform: translateY(-1px);
-}
-.tscm-device-reasons {
- font-size: 10px;
- color: var(--text-secondary);
- margin-top: 4px;
- font-style: italic;
- line-height: 1.4;
-}
-.audio-badge {
- margin-left: 6px;
- font-size: 10px;
-}
-.tscm-device-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 4px;
-}
-.tscm-device-name {
- font-weight: 600;
- font-size: 12px;
-}
-.tscm-device-meta {
- font-size: 10px;
- color: var(--text-muted);
- display: flex;
- gap: 8px;
- flex-wrap: wrap;
-}
-.tscm-device-indicators {
- margin-top: 6px;
- display: flex;
- flex-wrap: wrap;
- gap: 4px;
-}
-.indicator-tag {
- font-size: 9px;
- padding: 2px 6px;
- border-radius: 3px;
- background: rgba(255, 255, 255, 0.1);
- color: var(--text-secondary);
- white-space: nowrap;
-}
-.score-badge {
- font-size: 10px;
- padding: 2px 8px;
- border-radius: 10px;
- font-weight: 600;
-}
-.score-badge.score-low {
- background: rgba(0, 204, 0, 0.2);
- color: #00cc00;
-}
-.score-badge.score-medium {
- background: rgba(255, 204, 0, 0.2);
- color: #ffcc00;
-}
-.score-badge.score-high {
- background: rgba(255, 51, 51, 0.2);
- color: #ff3333;
-}
-.tscm-action {
- margin-top: 4px;
- font-size: 10px;
- color: #ff9933;
- font-weight: 600;
- text-transform: uppercase;
-}
-.tscm-correlations {
- margin-top: 16px;
- padding: 12px;
- background: rgba(255, 153, 51, 0.1);
- border-radius: 6px;
- border: 1px solid #ff9933;
-}
-.tscm-correlations h4 {
- margin: 0 0 8px 0;
- font-size: 12px;
- color: #ff9933;
-}
-.correlation-item {
- padding: 8px;
- margin-bottom: 6px;
- background: rgba(0, 0, 0, 0.2);
- border-radius: 4px;
- font-size: 11px;
-}
-.correlation-devices {
- font-size: 10px;
- color: var(--text-muted);
- margin-top: 4px;
-}
-.tscm-summary-box {
- display: flex;
- gap: 12px;
- margin-bottom: 16px;
- flex-wrap: wrap;
-}
-.summary-stat {
- flex: 1;
- min-width: 100px;
- padding: 12px;
- background: rgba(0, 0, 0, 0.3);
- border-radius: 6px;
- text-align: center;
-}
-.summary-stat .count {
- font-size: 24px;
- font-weight: 700;
-}
-.summary-stat .label {
- font-size: 10px;
- color: var(--text-muted);
- text-transform: uppercase;
-}
-.summary-stat.high-interest .count { color: #ff3333; }
-.summary-stat.needs-review .count { color: #ffcc00; }
-.summary-stat.informational .count { color: #00cc00; }
-.tscm-assessment {
- padding: 10px 14px;
- margin: 12px 0;
- border-radius: 6px;
- font-size: 13px;
-}
-.tscm-assessment.high-interest {
- background: rgba(255, 51, 51, 0.15);
- border: 1px solid #ff3333;
- color: #ff3333;
-}
-.tscm-assessment.needs-review {
- background: rgba(255, 204, 0, 0.15);
- border: 1px solid #ffcc00;
- color: #ffcc00;
-}
-.tscm-assessment.informational {
- background: rgba(0, 204, 0, 0.15);
- border: 1px solid #00cc00;
- color: #00cc00;
-}
-.tscm-disclaimer {
- font-size: 10px;
- color: var(--text-muted);
- font-style: italic;
- padding: 8px 12px;
- background: rgba(0, 0, 0, 0.2);
- border-radius: 4px;
- margin-top: 8px;
-}
-
-/* TSCM Device Details Modal */
-.tscm-modal-overlay {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background: rgba(0, 0, 0, 0.8);
- display: flex;
- align-items: center;
- justify-content: center;
- z-index: 10000;
-}
-.tscm-modal {
- background: var(--bg-card);
- border: 1px solid var(--border-light);
- border-radius: 8px;
- max-width: 500px;
- width: 90%;
- max-height: 80vh;
- overflow-y: auto;
- position: relative;
- box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5);
-}
-.tscm-modal-close {
- position: absolute;
- top: 12px;
- right: 12px;
- background: var(--bg-tertiary);
- border: 1px solid var(--border-light);
- border-radius: 50%;
- width: 32px;
- height: 32px;
- display: flex;
- align-items: center;
- justify-content: center;
- color: var(--text-secondary);
- font-size: 20px;
- cursor: pointer;
- z-index: 10;
- transition: all 0.2s;
-}
-.tscm-modal-close:hover {
- background: var(--accent-red);
- border-color: var(--accent-red);
- color: #fff;
-}
-.device-detail-header {
- padding: 16px;
- padding-right: 52px; /* Reserve space for close button */
- border-bottom: 1px solid var(--border-color);
- display: flex;
- justify-content: space-between;
- align-items: center;
-}
-.device-detail-header h3 {
- margin: 0;
- font-size: 16px;
-}
-.device-detail-header.classification-red { background: rgba(255, 51, 51, 0.15); }
-.device-detail-header.classification-yellow { background: rgba(255, 204, 0, 0.15); }
-.device-detail-header.classification-green { background: rgba(0, 204, 0, 0.15); }
-.device-detail-protocol {
- font-size: 10px;
- padding: 3px 8px;
- background: rgba(255, 255, 255, 0.1);
- border-radius: 3px;
- text-transform: uppercase;
-}
-.device-detail-score {
- display: flex;
- align-items: center;
- padding: 16px;
- gap: 16px;
- border-bottom: 1px solid var(--border-color);
-}
-.score-circle {
- width: 70px;
- height: 70px;
- border-radius: 50%;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- border: 3px solid;
-}
-.score-circle.high { border-color: #ff3333; background: rgba(255, 51, 51, 0.1); }
-.score-circle.medium { border-color: #ffcc00; background: rgba(255, 204, 0, 0.1); }
-.score-circle.low { border-color: #00cc00; background: rgba(0, 204, 0, 0.1); }
-.score-circle .score-value {
- font-size: 24px;
- font-weight: 700;
-}
-.score-circle.high .score-value { color: #ff3333; }
-.score-circle.medium .score-value { color: #ffcc00; }
-.score-circle.low .score-value { color: #00cc00; }
-.score-circle .score-label {
- font-size: 8px;
- color: var(--text-muted);
- text-transform: uppercase;
-}
-.score-breakdown {
- flex: 1;
- font-size: 12px;
- line-height: 1.6;
-}
-.device-detail-section {
- padding: 16px;
- border-bottom: 1px solid var(--border-color);
-}
-.device-detail-section h4 {
- margin: 0 0 12px 0;
- font-size: 12px;
- color: var(--text-secondary);
- text-transform: uppercase;
- letter-spacing: 0.5px;
-}
-.device-detail-table {
- width: 100%;
- font-size: 12px;
-}
-.device-detail-table td {
- padding: 4px 0;
-}
-.device-detail-table td:first-child {
- color: var(--text-dim);
- width: 40%;
-}
-.indicator-list {
- display: flex;
- flex-direction: column;
- gap: 8px;
-}
-.indicator-item {
- display: flex;
- gap: 10px;
- padding: 8px;
- background: rgba(0, 0, 0, 0.2);
- border-radius: 4px;
- font-size: 11px;
-}
-.indicator-type {
- background: rgba(255, 153, 51, 0.2);
- color: #ff9933;
- padding: 2px 6px;
- border-radius: 3px;
- font-size: 10px;
- white-space: nowrap;
-}
-.indicator-desc {
- color: var(--text-color);
-}
-.device-reasons-list {
- margin: 0;
- padding-left: 20px;
- font-size: 12px;
- color: var(--text-primary);
-}
-.device-reasons-list li {
- margin-bottom: 4px;
- color: var(--text-secondary);
-}
-.device-detail-disclaimer {
- padding: 12px 16px;
- font-size: 10px;
- color: var(--text-secondary);
- background: rgba(74, 158, 255, 0.1);
- border-top: 1px solid rgba(74, 158, 255, 0.3);
-}
-.tscm-threat-action {
- margin-top: 6px;
- font-size: 10px;
- color: #ff9933;
- text-transform: uppercase;
- font-weight: 600;
-}
-.tscm-device-item {
- cursor: pointer;
-}
-.tscm-device-item:hover {
- background: rgba(255, 255, 255, 0.05);
-}
-.threat-card.clickable {
- cursor: pointer;
- transition: transform 0.2s, box-shadow 0.2s;
-}
-.threat-card.clickable:hover {
- transform: translateY(-2px);
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
-}
-.category-device-list {
- max-height: 400px;
- overflow-y: auto;
-}
-.category-device-item {
- padding: 12px 16px;
- border-bottom: 1px solid var(--border-color);
- cursor: pointer;
- transition: background 0.2s;
-}
-.category-device-item:hover {
- background: rgba(255, 255, 255, 0.05);
-}
-.category-device-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
-}
-.category-device-name {
- font-weight: 600;
- font-size: 13px;
-}
-.category-device-score {
- background: rgba(255, 255, 255, 0.1);
- padding: 2px 8px;
- border-radius: 10px;
- font-size: 11px;
- font-weight: 600;
-}
-.category-device-meta {
- display: flex;
- gap: 6px;
- margin-top: 6px;
-}
-.protocol-badge {
- font-size: 9px;
- padding: 2px 6px;
- background: rgba(74, 158, 255, 0.2);
- color: #4a9eff;
- border-radius: 3px;
- text-transform: uppercase;
-}
-.indicator-mini {
- font-size: 9px;
- padding: 2px 6px;
- background: rgba(255, 153, 51, 0.2);
- color: #ff9933;
- border-radius: 3px;
-}
-.correlation-detail-item {
- padding: 12px;
- background: rgba(0, 0, 0, 0.2);
- border-radius: 6px;
- margin-bottom: 8px;
-}
-.tscm-threat-list {
- display: flex;
- flex-direction: column;
- gap: 8px;
-}
-.tscm-threat-item {
- padding: 10px 12px;
- border-radius: 6px;
- background: rgba(0,0,0,0.2);
- border: 1px solid;
-}
-.tscm-threat-item.critical { border-color: #ff3366; background: rgba(255,51,102,0.1); }
-.tscm-threat-item.high { border-color: #ff9933; background: rgba(255,153,51,0.1); }
-.tscm-threat-item.medium { border-color: #ffcc00; background: rgba(255,204,0,0.1); }
-.tscm-threat-item.low { border-color: #00ff88; background: rgba(0,255,136,0.1); }
-.tscm-threat-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 4px;
-}
-.tscm-threat-type {
- font-weight: 600;
- font-size: 12px;
-}
-.tscm-threat-severity {
- font-size: 10px;
- padding: 2px 6px;
- border-radius: 3px;
- text-transform: uppercase;
-}
-.tscm-threat-item.critical .tscm-threat-severity { background: #ff3366; color: #fff; }
-.tscm-threat-item.high .tscm-threat-severity { background: #ff9933; color: #000; }
-.tscm-threat-item.medium .tscm-threat-severity { background: #ffcc00; color: #000; }
-.tscm-threat-item.low .tscm-threat-severity { background: #00ff88; color: #000; }
-.tscm-threat-details {
- font-size: 11px;
- color: var(--text-muted);
-}
-@keyframes pulse-glow {
- 0%, 100% { box-shadow: 0 0 5px rgba(255,153,51,0.3); }
- 50% { box-shadow: 0 0 15px rgba(255,153,51,0.6); }
-}
-.tscm-empty {
- text-align: center;
- padding: 30px;
- color: var(--text-muted);
- font-size: 12px;
-}
-.tscm-empty-primary {
- font-weight: 500;
- color: var(--text-secondary);
- margin-bottom: 6px;
-}
-.tscm-empty-secondary {
- font-size: 10px;
- color: var(--text-muted);
- max-width: 280px;
- margin: 0 auto;
- line-height: 1.4;
-}
-
-/* Futuristic Scanner Progress */
-.tscm-scanner-progress {
- display: flex;
- align-items: center;
- gap: 12px;
- padding: 12px;
- margin-top: 10px;
- background: rgba(0,0,0,0.4);
- border: 1px solid var(--border-color);
- border-radius: 8px;
-}
-.scanner-ring {
- position: relative;
- width: 70px;
- height: 70px;
- flex-shrink: 0;
-}
-.scanner-ring svg {
- width: 100%;
- height: 100%;
- transform: rotate(-90deg);
-}
-.scanner-track {
- fill: none;
- stroke: rgba(74,158,255,0.1);
- stroke-width: 4;
-}
-.scanner-progress {
- fill: none;
- stroke: var(--accent-cyan);
- stroke-width: 4;
- stroke-linecap: round;
- stroke-dasharray: 283;
- stroke-dashoffset: 283;
- transition: stroke-dashoffset 0.3s ease;
- filter: drop-shadow(0 0 6px var(--accent-cyan));
-}
-.scanner-sweep {
- stroke: var(--accent-cyan);
- stroke-width: 2;
- opacity: 0.8;
- transform-origin: 50px 50px;
- animation: sweep-rotate 2s linear infinite;
- filter: drop-shadow(0 0 4px var(--accent-cyan));
-}
-@keyframes sweep-rotate {
- from { transform: rotate(0deg); }
- to { transform: rotate(360deg); }
-}
-.scanner-center {
- position: absolute;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- text-align: center;
-}
-.scanner-percent {
- font-size: 14px;
- font-weight: bold;
- color: var(--accent-cyan);
- text-shadow: 0 0 10px var(--accent-cyan);
-}
-.scanner-info {
- flex: 1;
- min-width: 0;
-}
-.scanner-status {
- font-size: 10px;
- font-weight: 600;
- letter-spacing: 2px;
- color: var(--accent-cyan);
- margin-bottom: 6px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-.scanner-devices {
- display: flex;
- gap: 8px;
-}
-.device-indicator {
- font-size: 14px;
- opacity: 0.3;
- transition: opacity 0.3s, transform 0.3s;
-}
-.device-indicator.active {
- opacity: 1;
- animation: device-pulse 1.5s ease-in-out infinite;
-}
-.device-indicator.inactive {
- opacity: 0.2;
- filter: grayscale(1);
-}
-@keyframes device-pulse {
- 0%, 100% { transform: scale(1); }
- 50% { transform: scale(1.1); }
-}
-
-/* Meeting Window Banner */
-.tscm-meeting-banner {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 10px 14px;
- margin-bottom: 12px;
- background: linear-gradient(90deg, rgba(255, 51, 102, 0.2), rgba(255, 153, 51, 0.2));
- border: 1px solid rgba(255, 51, 102, 0.5);
- border-radius: 6px;
- animation: meeting-glow 2s ease-in-out infinite;
-}
-@keyframes meeting-glow {
- 0%, 100% { box-shadow: 0 0 5px rgba(255, 51, 102, 0.3); }
- 50% { box-shadow: 0 0 15px rgba(255, 51, 102, 0.5); }
-}
-.meeting-indicator {
- display: flex;
- align-items: center;
- gap: 8px;
-}
-.meeting-pulse {
- width: 10px;
- height: 10px;
- background: #ff3366;
- border-radius: 50%;
- animation: pulse-dot 1.5s ease-in-out infinite;
-}
-@keyframes pulse-dot {
- 0%, 100% { opacity: 1; transform: scale(1); }
- 50% { opacity: 0.5; transform: scale(1.2); }
-}
-.meeting-text {
- font-size: 11px;
- font-weight: 700;
- letter-spacing: 1px;
- color: #ff3366;
- text-transform: uppercase;
-}
-.meeting-info {
- font-size: 11px;
- color: var(--text-secondary);
- display: flex;
- gap: 12px;
-}
-
-/* Capabilities Bar */
-.tscm-capabilities-bar {
- display: flex;
- align-items: center;
- gap: 16px;
- padding: 8px 12px;
- margin-bottom: 12px;
- background: rgba(0, 0, 0, 0.3);
- border: 1px solid var(--border-color);
- border-radius: 6px;
- font-size: 11px;
-}
-.cap-item {
- display: flex;
- align-items: center;
- gap: 4px;
-}
-.cap-icon {
- font-size: 14px;
- width: 16px;
- height: 16px;
- display: inline-flex;
- align-items: center;
- justify-content: center;
-}
-.cap-icon svg {
- width: 100%;
- height: 100%;
- stroke: currentColor;
- fill: none;
-}
-.cap-status {
- color: var(--text-muted);
- font-size: 10px;
- text-transform: uppercase;
-}
-.cap-status.available { color: #00cc00; }
-.cap-status.limited { color: #ffcc00; }
-.cap-status.unavailable { color: #ff3333; }
-.cap-limitations {
- margin-left: auto;
- display: flex;
- align-items: center;
- gap: 4px;
- color: #ff9933;
- font-size: 10px;
-}
-.cap-warn {
- font-size: 12px;
-}
-
-/* Baseline Health Indicator */
-.tscm-baseline-health {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 6px 12px;
- margin-bottom: 12px;
- background: rgba(0, 0, 0, 0.2);
- border-radius: 4px;
- font-size: 11px;
-}
-.health-label {
- color: var(--text-muted);
-}
-.health-name {
- color: var(--text-primary);
- font-weight: 600;
-}
-.health-badge {
- padding: 2px 8px;
- border-radius: 10px;
- font-size: 9px;
- font-weight: 600;
- text-transform: uppercase;
-}
-.health-badge.healthy {
- background: rgba(0, 204, 0, 0.2);
- color: #00cc00;
-}
-.health-badge.noisy {
- background: rgba(255, 204, 0, 0.2);
- color: #ffcc00;
-}
-.health-badge.stale {
- background: rgba(255, 51, 51, 0.2);
- color: #ff3333;
-}
-.health-age {
- color: var(--text-muted);
- font-size: 10px;
- margin-left: auto;
-}
-
-/* Advanced Modal Styles */
-.tscm-advanced-modal {
- max-width: 600px;
-}
-.tscm-modal-header {
- padding: 16px;
- border-bottom: 1px solid var(--border-color);
- display: flex;
- justify-content: space-between;
- align-items: center;
-}
-.tscm-modal-header h3 {
- margin: 0;
- font-size: 16px;
-}
-.tscm-modal-body {
- padding: 16px;
- max-height: 60vh;
- overflow-y: auto;
-}
-.tscm-modal-section {
- margin-bottom: 16px;
-}
-.tscm-modal-section h4 {
- margin: 0 0 8px 0;
- font-size: 12px;
- color: var(--text-secondary);
- text-transform: uppercase;
- letter-spacing: 0.5px;
-}
-
-/* Capabilities Detail */
-.cap-detail-item {
- padding: 10px;
- margin-bottom: 8px;
- background: rgba(0, 0, 0, 0.2);
- border-radius: 4px;
- border-left: 3px solid var(--border-color);
-}
-.cap-detail-item.available { border-left-color: #00cc00; }
-.cap-detail-item.limited { border-left-color: #ffcc00; }
-.cap-detail-item.unavailable { border-left-color: #ff3333; }
-.cap-detail-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 4px;
-}
-.cap-detail-name {
- font-weight: 600;
- font-size: 12px;
-}
-.cap-detail-status {
- font-size: 10px;
- padding: 2px 6px;
- border-radius: 3px;
-}
-.cap-detail-status.available { background: rgba(0, 204, 0, 0.2); color: #00cc00; }
-.cap-detail-status.limited { background: rgba(255, 204, 0, 0.2); color: #ffcc00; }
-.cap-detail-status.unavailable { background: rgba(255, 51, 51, 0.2); color: #ff3333; }
-.cap-detail-limits {
- font-size: 10px;
- color: var(--text-muted);
- margin-top: 4px;
-}
-.cap-detail-limits li {
- margin-bottom: 2px;
-}
-
-/* Known Devices List */
-.known-device-item {
- padding: 10px;
- margin-bottom: 6px;
- background: rgba(0, 0, 0, 0.2);
- border-radius: 4px;
- border-left: 3px solid #00cc00;
- display: flex;
- justify-content: space-between;
- align-items: center;
-}
-.known-device-info {
- flex: 1;
-}
-.known-device-name {
- font-weight: 600;
- font-size: 12px;
-}
-.known-device-id {
- font-size: 10px;
- color: var(--text-muted);
- font-family: 'JetBrains Mono', monospace;
-}
-.known-device-actions {
- display: flex;
- gap: 6px;
-}
-.known-device-btn {
- padding: 4px 8px;
- font-size: 10px;
- border: none;
- border-radius: 3px;
- cursor: pointer;
-}
-.known-device-btn.remove {
- background: rgba(255, 51, 51, 0.2);
- color: #ff3333;
-}
-.known-device-btn.remove:hover {
- background: rgba(255, 51, 51, 0.4);
-}
-
-/* Cases List */
-.case-item {
- padding: 12px;
- margin-bottom: 8px;
- background: rgba(0, 0, 0, 0.2);
- border-radius: 6px;
- border-left: 3px solid var(--primary-color);
- cursor: pointer;
- transition: background 0.2s;
-}
-.case-item:hover {
- background: rgba(74, 158, 255, 0.1);
-}
-.case-item.priority-high { border-left-color: #ff3333; }
-.case-item.priority-normal { border-left-color: #4a9eff; }
-.case-item.priority-low { border-left-color: #00cc00; }
-.case-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 4px;
-}
-.case-name {
- font-weight: 600;
- font-size: 13px;
-}
-.case-status {
- font-size: 9px;
- padding: 2px 6px;
- border-radius: 3px;
- text-transform: uppercase;
-}
-.case-status.open { background: rgba(0, 204, 0, 0.2); color: #00cc00; }
-.case-status.closed { background: rgba(128, 128, 128, 0.2); color: #888; }
-.case-meta {
- font-size: 10px;
- color: var(--text-muted);
- display: flex;
- gap: 12px;
-}
-
-/* Playbook Styles */
-.playbook-item {
- padding: 12px;
- margin-bottom: 8px;
- background: rgba(0, 0, 0, 0.2);
- border-radius: 6px;
- border-left: 3px solid #ff9933;
-}
-.playbook-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 8px;
-}
-.playbook-title {
- font-weight: 600;
- font-size: 13px;
-}
-.playbook-risk {
- font-size: 9px;
- padding: 2px 6px;
- border-radius: 3px;
- text-transform: uppercase;
-}
-.playbook-risk.high_interest { background: rgba(255, 51, 51, 0.2); color: #ff3333; }
-.playbook-risk.needs_review { background: rgba(255, 204, 0, 0.2); color: #ffcc00; }
-.playbook-risk.informational { background: rgba(0, 204, 0, 0.2); color: #00cc00; }
-.playbook-desc {
- font-size: 11px;
- color: var(--text-secondary);
- margin-bottom: 8px;
-}
-.playbook-steps {
- font-size: 11px;
-}
-.playbook-step {
- padding: 6px 8px;
- margin-bottom: 4px;
- background: rgba(0, 0, 0, 0.2);
- border-radius: 3px;
-}
-.playbook-step-num {
- color: #ff9933;
- font-weight: 600;
- margin-right: 6px;
-}
-
-/* Timeline Styles */
-.timeline-container {
- padding: 12px;
- background: rgba(0, 0, 0, 0.2);
- border-radius: 6px;
-}
-.timeline-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 12px;
-}
-.timeline-device-name {
- font-weight: 600;
- font-size: 14px;
-}
-.timeline-metrics {
- display: grid;
- grid-template-columns: repeat(4, 1fr);
- gap: 8px;
- margin-bottom: 12px;
-}
-.timeline-metric {
- padding: 8px;
- background: rgba(0, 0, 0, 0.3);
- border-radius: 4px;
- text-align: center;
-}
-.timeline-metric-value {
- font-size: 16px;
- font-weight: 700;
- color: var(--accent-cyan);
-}
-.timeline-metric-label {
- font-size: 9px;
- color: var(--text-muted);
- text-transform: uppercase;
-}
-.timeline-chart {
- height: 60px;
- background: rgba(0, 0, 0, 0.3);
- border-radius: 4px;
- position: relative;
- overflow: hidden;
-}
-.timeline-bar {
- position: absolute;
- bottom: 0;
- width: 3px;
- background: var(--accent-cyan);
- border-radius: 2px 2px 0 0;
-}
-
-/* Proximity Badge */
-.proximity-badge {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- padding: 2px 8px;
- border-radius: 10px;
- font-size: 10px;
- font-weight: 600;
-}
-.proximity-badge.very_close {
- background: rgba(255, 51, 51, 0.2);
- color: #ff3333;
-}
-.proximity-badge.close {
- background: rgba(255, 153, 51, 0.2);
- color: #ff9933;
-}
-.proximity-badge.moderate {
- background: rgba(255, 204, 0, 0.2);
- color: #ffcc00;
-}
-.proximity-badge.far {
- background: rgba(0, 204, 0, 0.2);
- color: #00cc00;
-}
-
-/* Add to Known Device Button */
-.add-known-btn {
- padding: 4px 8px;
- font-size: 10px;
- background: rgba(0, 204, 0, 0.2);
- color: #00cc00;
- border: 1px solid rgba(0, 204, 0, 0.3);
- border-radius: 3px;
- cursor: pointer;
- transition: all 0.2s;
-}
-.add-known-btn:hover {
- background: rgba(0, 204, 0, 0.3);
-}
-
-/* Capabilities Grid */
-.capabilities-grid {
- display: grid;
- grid-template-columns: repeat(3, 1fr);
- gap: 12px;
- margin-bottom: 16px;
-}
-.cap-detail-item .cap-icon {
- font-size: 24px;
- display: block;
- margin-bottom: 8px;
- width: 24px;
- height: 24px;
-}
-.cap-detail-item .cap-icon svg {
- width: 100%;
- height: 100%;
- stroke: currentColor;
- fill: none;
-}
-.cap-detail-item .cap-name {
- font-weight: 600;
- font-size: 12px;
- display: block;
- margin-bottom: 4px;
-}
-.cap-detail-item .cap-status {
- font-size: 10px;
- color: var(--text-muted);
-}
-.cap-detail-item .cap-detail {
- font-size: 9px;
- color: var(--text-muted);
- display: block;
- margin-top: 4px;
- font-family: 'JetBrains Mono', monospace;
-}
-.cap-can-list, .cap-cannot-list {
- list-style: none;
- padding: 0;
- margin: 0;
-}
-.cap-can-list li, .cap-cannot-list li {
- padding: 6px 0;
- font-size: 12px;
- border-bottom: 1px solid rgba(255, 255, 255, 0.05);
-}
-.cap-can-list li:last-child, .cap-cannot-list li:last-child {
- border-bottom: none;
-}
-
-/* Modal Header Classification Colors */
-.device-detail-header.classification-cyan {
- background: linear-gradient(135deg, rgba(0, 204, 255, 0.2) 0%, rgba(0, 150, 200, 0.1) 100%);
- border-bottom: 2px solid #00ccff;
-}
-.device-detail-header.classification-orange {
- background: linear-gradient(135deg, rgba(255, 153, 51, 0.2) 0%, rgba(200, 120, 40, 0.1) 100%);
- border-bottom: 2px solid #ff9933;
-}
-.device-detail-header.classification-green {
- background: linear-gradient(135deg, rgba(0, 204, 0, 0.2) 0%, rgba(0, 150, 0, 0.1) 100%);
- border-bottom: 2px solid #00cc00;
-}
-
-/* Playbook Enhancements */
-.playbook-item {
- cursor: pointer;
- transition: all 0.2s;
-}
-.playbook-item:hover {
- background: rgba(255, 153, 51, 0.1);
-}
-.playbook-category {
- font-size: 9px;
- padding: 2px 6px;
- background: rgba(255, 153, 51, 0.2);
- color: #ff9933;
- border-radius: 3px;
- text-transform: uppercase;
-}
-.playbook-meta {
- font-size: 10px;
- color: var(--text-muted);
- margin-top: 8px;
-}
-.playbook-warning {
- padding: 8px 12px;
- background: rgba(255, 153, 51, 0.15);
- border: 1px solid rgba(255, 153, 51, 0.3);
- border-radius: 4px;
- font-size: 11px;
- margin-top: 8px;
-}
-
-/* Case Status Enhancements */
-.case-date {
- font-size: 10px;
- color: var(--text-muted);
- margin-top: 4px;
-}
-
-/* Known Device Type Badge */
-.known-device-type {
- font-size: 9px;
- padding: 2px 6px;
- background: rgba(74, 158, 255, 0.2);
- color: #4a9eff;
- border-radius: 3px;
- margin-left: 8px;
-}
-
-/* ==========================================================================
- Icon System
- Minimal, functional icons that replace words. No decoration.
- Designed for screenshot legibility in reports.
- ========================================================================== */
-
-.icon {
- display: inline-block;
- width: 16px;
- height: 16px;
- vertical-align: middle;
- flex-shrink: 0;
-}
-
-.icon svg {
- width: 100%;
- height: 100%;
-}
-
-.icon--sm {
- width: 12px;
- height: 12px;
-}
-
-.icon--lg {
- width: 20px;
- height: 20px;
-}
-
-/* Signal Type Icons */
-.icon-wifi svg,
-.icon-bluetooth svg,
-.icon-cellular svg,
-.icon-signal-unknown svg {
- fill: var(--text-secondary);
-}
-
-/* Recording State */
-.icon-recording {
- color: #ff3366;
-}
-
-.icon-recording.active svg {
- animation: recording-pulse 1.5s ease-in-out infinite;
-}
-
-@keyframes recording-pulse {
- 0%, 100% { opacity: 1; }
- 50% { opacity: 0.4; }
-}
-
-/* Anomaly Indicator */
-.icon-anomaly {
- color: #ff9933;
-}
-
-.icon-anomaly.critical {
- color: #ff3366;
-}
-
-/* Export Icon */
-.icon-export {
- color: var(--text-secondary);
-}
-
-/* Classification Dots - replaces emoji circles for risk levels */
-.classification-dot {
- display: inline-block;
- width: 10px;
- height: 10px;
- border-radius: 50%;
- vertical-align: middle;
- margin-right: 4px;
-}
-
-.classification-dot.high {
- background-color: var(--accent-red);
-}
-
-.classification-dot.review {
- background-color: var(--accent-orange);
-}
-
-.classification-dot.info {
- background-color: var(--accent-green);
-}
-
-/* Device Indicators with Icons */
-.device-indicator-icon {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 24px;
- height: 24px;
- opacity: 0.3;
- transition: opacity 0.3s, transform 0.3s;
-}
-
-.device-indicator-icon.active {
- opacity: 1;
- animation: device-pulse 1.5s ease-in-out infinite;
-}
-
-.device-indicator-icon.inactive {
- opacity: 0.2;
-}
-
-.device-indicator-icon .icon {
- width: 18px;
- height: 18px;
-}
-
-/* Protocol badge with icon */
-.protocol-icon-badge {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- font-size: 10px;
- padding: 2px 6px;
- background: rgba(255, 255, 255, 0.1);
- border-radius: 3px;
- text-transform: uppercase;
-}
-
-.protocol-icon-badge .icon {
- width: 12px;
- height: 12px;
-}
-
-/* Recording status indicator */
-.recording-status {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- font-size: 11px;
-}
-
-.recording-status .icon-recording {
- width: 10px;
- height: 10px;
-}
-
-.recording-status.active {
- color: #ff3366;
- font-weight: 600;
-}
-
-/* Anomaly flag in device items */
-.anomaly-flag {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- padding: 2px 6px;
- border-radius: 3px;
- font-size: 9px;
- font-weight: 600;
- text-transform: uppercase;
-}
-
-.anomaly-flag.needs-review {
- background: rgba(255, 153, 51, 0.2);
- color: #ff9933;
-}
-
-.anomaly-flag.high-interest {
- background: rgba(255, 51, 51, 0.2);
- color: #ff3333;
-}
-
-.anomaly-flag .icon {
- width: 10px;
- height: 10px;
-}
+/* TSCM Styles */
+
+/* TSCM Threat Cards */
+.threat-card {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 8px;
+ border-radius: 6px;
+ background: rgba(0,0,0,0.3);
+ border: 1px solid var(--border-color);
+}
+.threat-card .count {
+ font-size: 18px;
+ font-weight: bold;
+ line-height: 1;
+}
+.threat-card .label {
+ font-size: 9px;
+ text-transform: uppercase;
+ opacity: 0.7;
+ margin-top: 2px;
+}
+.threat-card.critical { border-color: #ff3366; color: #ff3366; }
+.threat-card.critical.active { background: rgba(255,51,102,0.2); }
+.threat-card.high { border-color: #ff9933; color: #ff9933; }
+.threat-card.high.active { background: rgba(255,153,51,0.2); }
+.threat-card.medium { border-color: #ffcc00; color: #ffcc00; }
+.threat-card.medium.active { background: rgba(255,204,0,0.2); }
+.threat-card.low { border-color: #00ff88; color: #00ff88; }
+.threat-card.low.active { background: rgba(0,255,136,0.2); }
+
+/* TSCM Dashboard */
+.tscm-dashboard {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ overflow-y: auto;
+ padding-bottom: 80px; /* Space for status bar */
+}
+.tscm-threat-banner {
+ display: flex;
+ gap: 12px;
+ padding: 12px;
+ background: rgba(0,0,0,0.3);
+ border-radius: 8px;
+}
+.tscm-threat-banner .threat-card {
+ flex: 1;
+ padding: 12px;
+}
+.tscm-threat-banner .threat-card .count {
+ font-size: 24px;
+}
+.tscm-main-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 16px;
+}
+.tscm-panel {
+ background: rgba(0,0,0,0.3);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ min-height: 200px;
+ height: 200px;
+}
+/* Full-width panels (like Detected Threats) get more height */
+.tscm-panel[style*="grid-column: span 2"] {
+ min-height: 150px;
+ height: 150px;
+}
+.tscm-panel-header {
+ padding: 10px 12px;
+ background: rgba(0,0,0,0.3);
+ border-bottom: 1px solid var(--border-color);
+ font-weight: 600;
+ font-size: 12px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+.tscm-panel-header .badge {
+ background: var(--primary-color);
+ color: #fff;
+ padding: 2px 8px;
+ border-radius: 10px;
+ font-size: 10px;
+ font-weight: normal;
+}
+.tscm-panel-content {
+ flex: 1;
+ overflow-y: auto;
+ padding: 8px;
+}
+.tscm-device-item {
+ padding: 8px 10px;
+ border-radius: 4px;
+ margin-bottom: 6px;
+ background: rgba(0,0,0,0.2);
+ border-left: 3px solid var(--border-color);
+ cursor: pointer;
+ transition: background 0.2s;
+}
+.tscm-device-item:hover {
+ background: rgba(74,158,255,0.1);
+}
+.tscm-device-item.new {
+ border-left-color: #ff9933;
+ animation: pulse-glow 2s infinite;
+}
+.tscm-device-item.threat {
+ border-left-color: #ff3366;
+}
+.tscm-device-item.baseline {
+ border-left-color: #00ff88;
+}
+/* Classification colors */
+.tscm-device-item.classification-green {
+ border-left-color: #00cc00;
+ background: rgba(0, 204, 0, 0.1);
+}
+.tscm-device-item.classification-yellow {
+ border-left-color: #ffcc00;
+ background: rgba(255, 204, 0, 0.1);
+}
+.tscm-device-item.classification-red {
+ border-left-color: #ff3333;
+ background: rgba(255, 51, 51, 0.15);
+ animation: pulse-glow 2s infinite;
+}
+.classification-indicator {
+ margin-right: 6px;
+}
+.tscm-status-message {
+ padding: 12px;
+ background: rgba(255, 153, 51, 0.15);
+ border: 1px solid rgba(255, 153, 51, 0.3);
+ border-radius: 6px;
+ color: var(--text-primary);
+ font-size: 12px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.tscm-status-message .status-icon {
+ font-size: 16px;
+}
+.tscm-privilege-warning {
+ padding: 10px 12px;
+ background: rgba(255, 51, 51, 0.15);
+ border: 1px solid rgba(255, 51, 51, 0.4);
+ border-radius: 6px;
+ color: var(--text-primary);
+ font-size: 11px;
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ margin-bottom: 12px;
+}
+.tscm-privilege-warning .warning-icon {
+ font-size: 18px;
+ flex-shrink: 0;
+}
+.tscm-privilege-warning .warning-action {
+ margin-top: 4px;
+ font-family: var(--font-mono);
+ font-size: 10px;
+ color: var(--accent-cyan);
+ background: rgba(0, 0, 0, 0.3);
+ padding: 4px 8px;
+ border-radius: 3px;
+}
+.tscm-action-btn {
+ padding: 10px 16px;
+ background: var(--accent-green);
+ border: none;
+ border-radius: 4px;
+ color: #000;
+ font-size: 12px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+.tscm-action-btn:hover {
+ background: #2ecc71;
+ transform: translateY(-1px);
+}
+.tscm-device-reasons {
+ font-size: 10px;
+ color: var(--text-secondary);
+ margin-top: 4px;
+ font-style: italic;
+ line-height: 1.4;
+}
+.audio-badge {
+ margin-left: 6px;
+ font-size: 10px;
+}
+.tscm-device-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 4px;
+}
+.tscm-device-name {
+ font-weight: 600;
+ font-size: 12px;
+}
+.tscm-device-meta {
+ font-size: 10px;
+ color: var(--text-muted);
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+.tscm-device-indicators {
+ margin-top: 6px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+}
+.indicator-tag {
+ font-size: 9px;
+ padding: 2px 6px;
+ border-radius: 3px;
+ background: rgba(255, 255, 255, 0.1);
+ color: var(--text-secondary);
+ white-space: nowrap;
+}
+.score-badge {
+ font-size: 10px;
+ padding: 2px 8px;
+ border-radius: 10px;
+ font-weight: 600;
+}
+.score-badge.score-low {
+ background: rgba(0, 204, 0, 0.2);
+ color: #00cc00;
+}
+.score-badge.score-medium {
+ background: rgba(255, 204, 0, 0.2);
+ color: #ffcc00;
+}
+.score-badge.score-high {
+ background: rgba(255, 51, 51, 0.2);
+ color: #ff3333;
+}
+.tscm-action {
+ margin-top: 4px;
+ font-size: 10px;
+ color: #ff9933;
+ font-weight: 600;
+ text-transform: uppercase;
+}
+.tscm-correlations {
+ margin-top: 16px;
+ padding: 12px;
+ background: rgba(255, 153, 51, 0.1);
+ border-radius: 6px;
+ border: 1px solid #ff9933;
+}
+.tscm-correlations h4 {
+ margin: 0 0 8px 0;
+ font-size: 12px;
+ color: #ff9933;
+}
+.correlation-item {
+ padding: 8px;
+ margin-bottom: 6px;
+ background: rgba(0, 0, 0, 0.2);
+ border-radius: 4px;
+ font-size: 11px;
+}
+.correlation-devices {
+ font-size: 10px;
+ color: var(--text-muted);
+ margin-top: 4px;
+}
+.tscm-summary-box {
+ display: flex;
+ gap: 12px;
+ margin-bottom: 16px;
+ flex-wrap: wrap;
+}
+.summary-stat {
+ flex: 1;
+ min-width: 100px;
+ padding: 12px;
+ background: rgba(0, 0, 0, 0.3);
+ border-radius: 6px;
+ text-align: center;
+}
+.summary-stat .count {
+ font-size: 24px;
+ font-weight: 700;
+}
+.summary-stat .label {
+ font-size: 10px;
+ color: var(--text-muted);
+ text-transform: uppercase;
+}
+.summary-stat.high-interest .count { color: #ff3333; }
+.summary-stat.needs-review .count { color: #ffcc00; }
+.summary-stat.informational .count { color: #00cc00; }
+.tscm-assessment {
+ padding: 10px 14px;
+ margin: 12px 0;
+ border-radius: 6px;
+ font-size: 13px;
+}
+.tscm-assessment.high-interest {
+ background: rgba(255, 51, 51, 0.15);
+ border: 1px solid #ff3333;
+ color: #ff3333;
+}
+.tscm-assessment.needs-review {
+ background: rgba(255, 204, 0, 0.15);
+ border: 1px solid #ffcc00;
+ color: #ffcc00;
+}
+.tscm-assessment.informational {
+ background: rgba(0, 204, 0, 0.15);
+ border: 1px solid #00cc00;
+ color: #00cc00;
+}
+.tscm-disclaimer {
+ font-size: 10px;
+ color: var(--text-muted);
+ font-style: italic;
+ padding: 8px 12px;
+ background: rgba(0, 0, 0, 0.2);
+ border-radius: 4px;
+ margin-top: 8px;
+}
+
+/* TSCM Device Details Modal */
+.tscm-modal-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.8);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 10000;
+}
+.tscm-modal {
+ background: var(--bg-card);
+ border: 1px solid var(--border-light);
+ border-radius: 8px;
+ max-width: 500px;
+ width: 90%;
+ max-height: 80vh;
+ overflow-y: auto;
+ position: relative;
+ box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5);
+}
+.tscm-modal-close {
+ position: absolute;
+ top: 12px;
+ right: 12px;
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border-light);
+ border-radius: 50%;
+ width: 32px;
+ height: 32px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--text-secondary);
+ font-size: 20px;
+ cursor: pointer;
+ z-index: 10;
+ transition: all 0.2s;
+}
+.tscm-modal-close:hover {
+ background: var(--accent-red);
+ border-color: var(--accent-red);
+ color: #fff;
+}
+.device-detail-header {
+ padding: 16px;
+ padding-right: 52px; /* Reserve space for close button */
+ border-bottom: 1px solid var(--border-color);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+.device-detail-header h3 {
+ margin: 0;
+ font-size: 16px;
+}
+.device-detail-header.classification-red { background: rgba(255, 51, 51, 0.15); }
+.device-detail-header.classification-yellow { background: rgba(255, 204, 0, 0.15); }
+.device-detail-header.classification-green { background: rgba(0, 204, 0, 0.15); }
+.device-detail-protocol {
+ font-size: 10px;
+ padding: 3px 8px;
+ background: rgba(255, 255, 255, 0.1);
+ border-radius: 3px;
+ text-transform: uppercase;
+}
+.device-detail-score {
+ display: flex;
+ align-items: center;
+ padding: 16px;
+ gap: 16px;
+ border-bottom: 1px solid var(--border-color);
+}
+.score-circle {
+ width: 70px;
+ height: 70px;
+ border-radius: 50%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ border: 3px solid;
+}
+.score-circle.high { border-color: #ff3333; background: rgba(255, 51, 51, 0.1); }
+.score-circle.medium { border-color: #ffcc00; background: rgba(255, 204, 0, 0.1); }
+.score-circle.low { border-color: #00cc00; background: rgba(0, 204, 0, 0.1); }
+.score-circle .score-value {
+ font-size: 24px;
+ font-weight: 700;
+}
+.score-circle.high .score-value { color: #ff3333; }
+.score-circle.medium .score-value { color: #ffcc00; }
+.score-circle.low .score-value { color: #00cc00; }
+.score-circle .score-label {
+ font-size: 8px;
+ color: var(--text-muted);
+ text-transform: uppercase;
+}
+.score-breakdown {
+ flex: 1;
+ font-size: 12px;
+ line-height: 1.6;
+}
+.device-detail-section {
+ padding: 16px;
+ border-bottom: 1px solid var(--border-color);
+}
+.device-detail-section h4 {
+ margin: 0 0 12px 0;
+ font-size: 12px;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+.device-detail-table {
+ width: 100%;
+ font-size: 12px;
+}
+.device-detail-table td {
+ padding: 4px 0;
+}
+.device-detail-table td:first-child {
+ color: var(--text-dim);
+ width: 40%;
+}
+.indicator-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+.indicator-item {
+ display: flex;
+ gap: 10px;
+ padding: 8px;
+ background: rgba(0, 0, 0, 0.2);
+ border-radius: 4px;
+ font-size: 11px;
+}
+.indicator-type {
+ background: rgba(255, 153, 51, 0.2);
+ color: #ff9933;
+ padding: 2px 6px;
+ border-radius: 3px;
+ font-size: 10px;
+ white-space: nowrap;
+}
+.indicator-desc {
+ color: var(--text-color);
+}
+.device-reasons-list {
+ margin: 0;
+ padding-left: 20px;
+ font-size: 12px;
+ color: var(--text-primary);
+}
+.device-reasons-list li {
+ margin-bottom: 4px;
+ color: var(--text-secondary);
+}
+.device-detail-disclaimer {
+ padding: 12px 16px;
+ font-size: 10px;
+ color: var(--text-secondary);
+ background: rgba(74, 158, 255, 0.1);
+ border-top: 1px solid rgba(74, 158, 255, 0.3);
+}
+.tscm-threat-action {
+ margin-top: 6px;
+ font-size: 10px;
+ color: #ff9933;
+ text-transform: uppercase;
+ font-weight: 600;
+}
+.tscm-device-item {
+ cursor: pointer;
+}
+.tscm-device-item:hover {
+ background: rgba(255, 255, 255, 0.05);
+}
+.threat-card.clickable {
+ cursor: pointer;
+ transition: transform 0.2s, box-shadow 0.2s;
+}
+.threat-card.clickable:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+}
+.category-device-list {
+ max-height: 400px;
+ overflow-y: auto;
+}
+.category-device-item {
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--border-color);
+ cursor: pointer;
+ transition: background 0.2s;
+}
+.category-device-item:hover {
+ background: rgba(255, 255, 255, 0.05);
+}
+.category-device-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+.category-device-name {
+ font-weight: 600;
+ font-size: 13px;
+}
+.category-device-score {
+ background: rgba(255, 255, 255, 0.1);
+ padding: 2px 8px;
+ border-radius: 10px;
+ font-size: 11px;
+ font-weight: 600;
+}
+.category-device-meta {
+ display: flex;
+ gap: 6px;
+ margin-top: 6px;
+}
+.protocol-badge {
+ font-size: 9px;
+ padding: 2px 6px;
+ background: rgba(74, 158, 255, 0.2);
+ color: #4a9eff;
+ border-radius: 3px;
+ text-transform: uppercase;
+}
+.indicator-mini {
+ font-size: 9px;
+ padding: 2px 6px;
+ background: rgba(255, 153, 51, 0.2);
+ color: #ff9933;
+ border-radius: 3px;
+}
+.correlation-detail-item {
+ padding: 12px;
+ background: rgba(0, 0, 0, 0.2);
+ border-radius: 6px;
+ margin-bottom: 8px;
+}
+.tscm-threat-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+.tscm-threat-item {
+ padding: 10px 12px;
+ border-radius: 6px;
+ background: rgba(0,0,0,0.2);
+ border: 1px solid;
+}
+.tscm-threat-item.critical { border-color: #ff3366; background: rgba(255,51,102,0.1); }
+.tscm-threat-item.high { border-color: #ff9933; background: rgba(255,153,51,0.1); }
+.tscm-threat-item.medium { border-color: #ffcc00; background: rgba(255,204,0,0.1); }
+.tscm-threat-item.low { border-color: #00ff88; background: rgba(0,255,136,0.1); }
+.tscm-threat-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 4px;
+}
+.tscm-threat-type {
+ font-weight: 600;
+ font-size: 12px;
+}
+.tscm-threat-severity {
+ font-size: 10px;
+ padding: 2px 6px;
+ border-radius: 3px;
+ text-transform: uppercase;
+}
+.tscm-threat-item.critical .tscm-threat-severity { background: #ff3366; color: #fff; }
+.tscm-threat-item.high .tscm-threat-severity { background: #ff9933; color: #000; }
+.tscm-threat-item.medium .tscm-threat-severity { background: #ffcc00; color: #000; }
+.tscm-threat-item.low .tscm-threat-severity { background: #00ff88; color: #000; }
+.tscm-threat-details {
+ font-size: 11px;
+ color: var(--text-muted);
+}
+@keyframes pulse-glow {
+ 0%, 100% { box-shadow: 0 0 5px rgba(255,153,51,0.3); }
+ 50% { box-shadow: 0 0 15px rgba(255,153,51,0.6); }
+}
+.tscm-empty {
+ text-align: center;
+ padding: 30px;
+ color: var(--text-muted);
+ font-size: 12px;
+}
+.tscm-empty-primary {
+ font-weight: 500;
+ color: var(--text-secondary);
+ margin-bottom: 6px;
+}
+.tscm-empty-secondary {
+ font-size: 10px;
+ color: var(--text-muted);
+ max-width: 280px;
+ margin: 0 auto;
+ line-height: 1.4;
+}
+
+/* Futuristic Scanner Progress */
+.tscm-scanner-progress {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px;
+ margin-top: 10px;
+ background: rgba(0,0,0,0.4);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+}
+.scanner-ring {
+ position: relative;
+ width: 70px;
+ height: 70px;
+ flex-shrink: 0;
+}
+.scanner-ring svg {
+ width: 100%;
+ height: 100%;
+ transform: rotate(-90deg);
+}
+.scanner-track {
+ fill: none;
+ stroke: rgba(74,158,255,0.1);
+ stroke-width: 4;
+}
+.scanner-progress {
+ fill: none;
+ stroke: var(--accent-cyan);
+ stroke-width: 4;
+ stroke-linecap: round;
+ stroke-dasharray: 283;
+ stroke-dashoffset: 283;
+ transition: stroke-dashoffset 0.3s ease;
+ filter: drop-shadow(0 0 6px var(--accent-cyan));
+}
+.scanner-sweep {
+ stroke: var(--accent-cyan);
+ stroke-width: 2;
+ opacity: 0.8;
+ transform-origin: 50px 50px;
+ animation: sweep-rotate 2s linear infinite;
+ filter: drop-shadow(0 0 4px var(--accent-cyan));
+}
+@keyframes sweep-rotate {
+ from { transform: rotate(0deg); }
+ to { transform: rotate(360deg); }
+}
+.scanner-center {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ text-align: center;
+}
+.scanner-percent {
+ font-size: 14px;
+ font-weight: bold;
+ color: var(--accent-cyan);
+ text-shadow: 0 0 10px var(--accent-cyan);
+}
+.scanner-info {
+ flex: 1;
+ min-width: 0;
+}
+.scanner-status {
+ font-size: 10px;
+ font-weight: 600;
+ letter-spacing: 2px;
+ color: var(--accent-cyan);
+ margin-bottom: 6px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.scanner-devices {
+ display: flex;
+ gap: 8px;
+}
+.device-indicator {
+ font-size: 14px;
+ opacity: 0.3;
+ transition: opacity 0.3s, transform 0.3s;
+}
+.device-indicator.active {
+ opacity: 1;
+ animation: device-pulse 1.5s ease-in-out infinite;
+}
+.device-indicator.inactive {
+ opacity: 0.2;
+ filter: grayscale(1);
+}
+@keyframes device-pulse {
+ 0%, 100% { transform: scale(1); }
+ 50% { transform: scale(1.1); }
+}
+
+/* Meeting Window Banner */
+.tscm-meeting-banner {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 14px;
+ margin-bottom: 12px;
+ background: linear-gradient(90deg, rgba(255, 51, 102, 0.2), rgba(255, 153, 51, 0.2));
+ border: 1px solid rgba(255, 51, 102, 0.5);
+ border-radius: 6px;
+ animation: meeting-glow 2s ease-in-out infinite;
+}
+@keyframes meeting-glow {
+ 0%, 100% { box-shadow: 0 0 5px rgba(255, 51, 102, 0.3); }
+ 50% { box-shadow: 0 0 15px rgba(255, 51, 102, 0.5); }
+}
+.meeting-indicator {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.meeting-pulse {
+ width: 10px;
+ height: 10px;
+ background: #ff3366;
+ border-radius: 50%;
+ animation: pulse-dot 1.5s ease-in-out infinite;
+}
+@keyframes pulse-dot {
+ 0%, 100% { opacity: 1; transform: scale(1); }
+ 50% { opacity: 0.5; transform: scale(1.2); }
+}
+.meeting-text {
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 1px;
+ color: #ff3366;
+ text-transform: uppercase;
+}
+.meeting-info {
+ font-size: 11px;
+ color: var(--text-secondary);
+ display: flex;
+ gap: 12px;
+}
+
+/* Capabilities Bar */
+.tscm-capabilities-bar {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ padding: 8px 12px;
+ margin-bottom: 12px;
+ background: rgba(0, 0, 0, 0.3);
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ font-size: 11px;
+}
+.cap-item {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+.cap-icon {
+ font-size: 14px;
+ width: 16px;
+ height: 16px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+.cap-icon svg {
+ width: 100%;
+ height: 100%;
+ stroke: currentColor;
+ fill: none;
+}
+.cap-status {
+ color: var(--text-muted);
+ font-size: 10px;
+ text-transform: uppercase;
+}
+.cap-status.available { color: #00cc00; }
+.cap-status.limited { color: #ffcc00; }
+.cap-status.unavailable { color: #ff3333; }
+.cap-limitations {
+ margin-left: auto;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ color: #ff9933;
+ font-size: 10px;
+}
+.cap-warn {
+ font-size: 12px;
+}
+
+/* Baseline Health Indicator */
+.tscm-baseline-health {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 12px;
+ margin-bottom: 12px;
+ background: rgba(0, 0, 0, 0.2);
+ border-radius: 4px;
+ font-size: 11px;
+}
+.health-label {
+ color: var(--text-muted);
+}
+.health-name {
+ color: var(--text-primary);
+ font-weight: 600;
+}
+.health-badge {
+ padding: 2px 8px;
+ border-radius: 10px;
+ font-size: 9px;
+ font-weight: 600;
+ text-transform: uppercase;
+}
+.health-badge.healthy {
+ background: rgba(0, 204, 0, 0.2);
+ color: #00cc00;
+}
+.health-badge.noisy {
+ background: rgba(255, 204, 0, 0.2);
+ color: #ffcc00;
+}
+.health-badge.stale {
+ background: rgba(255, 51, 51, 0.2);
+ color: #ff3333;
+}
+.health-age {
+ color: var(--text-muted);
+ font-size: 10px;
+ margin-left: auto;
+}
+
+/* Advanced Modal Styles */
+.tscm-advanced-modal {
+ max-width: 600px;
+}
+.tscm-modal-header {
+ padding: 16px;
+ border-bottom: 1px solid var(--border-color);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+.tscm-modal-header h3 {
+ margin: 0;
+ font-size: 16px;
+}
+.tscm-modal-body {
+ padding: 16px;
+ max-height: 60vh;
+ overflow-y: auto;
+}
+.tscm-modal-section {
+ margin-bottom: 16px;
+}
+.tscm-modal-section h4 {
+ margin: 0 0 8px 0;
+ font-size: 12px;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+/* Capabilities Detail */
+.cap-detail-item {
+ padding: 10px;
+ margin-bottom: 8px;
+ background: rgba(0, 0, 0, 0.2);
+ border-radius: 4px;
+ border-left: 3px solid var(--border-color);
+}
+.cap-detail-item.available { border-left-color: #00cc00; }
+.cap-detail-item.limited { border-left-color: #ffcc00; }
+.cap-detail-item.unavailable { border-left-color: #ff3333; }
+.cap-detail-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 4px;
+}
+.cap-detail-name {
+ font-weight: 600;
+ font-size: 12px;
+}
+.cap-detail-status {
+ font-size: 10px;
+ padding: 2px 6px;
+ border-radius: 3px;
+}
+.cap-detail-status.available { background: rgba(0, 204, 0, 0.2); color: #00cc00; }
+.cap-detail-status.limited { background: rgba(255, 204, 0, 0.2); color: #ffcc00; }
+.cap-detail-status.unavailable { background: rgba(255, 51, 51, 0.2); color: #ff3333; }
+.cap-detail-limits {
+ font-size: 10px;
+ color: var(--text-muted);
+ margin-top: 4px;
+}
+.cap-detail-limits li {
+ margin-bottom: 2px;
+}
+
+/* Known Devices List */
+.known-device-item {
+ padding: 10px;
+ margin-bottom: 6px;
+ background: rgba(0, 0, 0, 0.2);
+ border-radius: 4px;
+ border-left: 3px solid #00cc00;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+.known-device-info {
+ flex: 1;
+}
+.known-device-name {
+ font-weight: 600;
+ font-size: 12px;
+}
+.known-device-id {
+ font-size: 10px;
+ color: var(--text-muted);
+ font-family: var(--font-mono);
+}
+.known-device-actions {
+ display: flex;
+ gap: 6px;
+}
+.known-device-btn {
+ padding: 4px 8px;
+ font-size: 10px;
+ border: none;
+ border-radius: 3px;
+ cursor: pointer;
+}
+.known-device-btn.remove {
+ background: rgba(255, 51, 51, 0.2);
+ color: #ff3333;
+}
+.known-device-btn.remove:hover {
+ background: rgba(255, 51, 51, 0.4);
+}
+
+/* Cases List */
+.case-item {
+ padding: 12px;
+ margin-bottom: 8px;
+ background: rgba(0, 0, 0, 0.2);
+ border-radius: 6px;
+ border-left: 3px solid var(--primary-color);
+ cursor: pointer;
+ transition: background 0.2s;
+}
+.case-item:hover {
+ background: rgba(74, 158, 255, 0.1);
+}
+.case-item.priority-high { border-left-color: #ff3333; }
+.case-item.priority-normal { border-left-color: #4a9eff; }
+.case-item.priority-low { border-left-color: #00cc00; }
+.case-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 4px;
+}
+.case-name {
+ font-weight: 600;
+ font-size: 13px;
+}
+.case-status {
+ font-size: 9px;
+ padding: 2px 6px;
+ border-radius: 3px;
+ text-transform: uppercase;
+}
+.case-status.open { background: rgba(0, 204, 0, 0.2); color: #00cc00; }
+.case-status.closed { background: rgba(128, 128, 128, 0.2); color: #888; }
+.case-meta {
+ font-size: 10px;
+ color: var(--text-muted);
+ display: flex;
+ gap: 12px;
+}
+
+/* Playbook Styles */
+.playbook-item {
+ padding: 12px;
+ margin-bottom: 8px;
+ background: rgba(0, 0, 0, 0.2);
+ border-radius: 6px;
+ border-left: 3px solid #ff9933;
+}
+.playbook-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 8px;
+}
+.playbook-title {
+ font-weight: 600;
+ font-size: 13px;
+}
+.playbook-risk {
+ font-size: 9px;
+ padding: 2px 6px;
+ border-radius: 3px;
+ text-transform: uppercase;
+}
+.playbook-risk.high_interest { background: rgba(255, 51, 51, 0.2); color: #ff3333; }
+.playbook-risk.needs_review { background: rgba(255, 204, 0, 0.2); color: #ffcc00; }
+.playbook-risk.informational { background: rgba(0, 204, 0, 0.2); color: #00cc00; }
+.playbook-desc {
+ font-size: 11px;
+ color: var(--text-secondary);
+ margin-bottom: 8px;
+}
+.playbook-steps {
+ font-size: 11px;
+}
+.playbook-step {
+ padding: 6px 8px;
+ margin-bottom: 4px;
+ background: rgba(0, 0, 0, 0.2);
+ border-radius: 3px;
+}
+.playbook-step-num {
+ color: #ff9933;
+ font-weight: 600;
+ margin-right: 6px;
+}
+
+/* Timeline Styles */
+.timeline-container {
+ padding: 12px;
+ background: rgba(0, 0, 0, 0.2);
+ border-radius: 6px;
+}
+.timeline-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 12px;
+}
+.timeline-device-name {
+ font-weight: 600;
+ font-size: 14px;
+}
+.timeline-metrics {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 8px;
+ margin-bottom: 12px;
+}
+.timeline-metric {
+ padding: 8px;
+ background: rgba(0, 0, 0, 0.3);
+ border-radius: 4px;
+ text-align: center;
+}
+.timeline-metric-value {
+ font-size: 16px;
+ font-weight: 700;
+ color: var(--accent-cyan);
+}
+.timeline-metric-label {
+ font-size: 9px;
+ color: var(--text-muted);
+ text-transform: uppercase;
+}
+.timeline-chart {
+ height: 60px;
+ background: rgba(0, 0, 0, 0.3);
+ border-radius: 4px;
+ position: relative;
+ overflow: hidden;
+}
+.timeline-bar {
+ position: absolute;
+ bottom: 0;
+ width: 3px;
+ background: var(--accent-cyan);
+ border-radius: 2px 2px 0 0;
+}
+
+/* Proximity Badge */
+.proximity-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 2px 8px;
+ border-radius: 10px;
+ font-size: 10px;
+ font-weight: 600;
+}
+.proximity-badge.very_close {
+ background: rgba(255, 51, 51, 0.2);
+ color: #ff3333;
+}
+.proximity-badge.close {
+ background: rgba(255, 153, 51, 0.2);
+ color: #ff9933;
+}
+.proximity-badge.moderate {
+ background: rgba(255, 204, 0, 0.2);
+ color: #ffcc00;
+}
+.proximity-badge.far {
+ background: rgba(0, 204, 0, 0.2);
+ color: #00cc00;
+}
+
+/* Add to Known Device Button */
+.add-known-btn {
+ padding: 4px 8px;
+ font-size: 10px;
+ background: rgba(0, 204, 0, 0.2);
+ color: #00cc00;
+ border: 1px solid rgba(0, 204, 0, 0.3);
+ border-radius: 3px;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+.add-known-btn:hover {
+ background: rgba(0, 204, 0, 0.3);
+}
+
+/* Capabilities Grid */
+.capabilities-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 12px;
+ margin-bottom: 16px;
+}
+.cap-detail-item .cap-icon {
+ font-size: 24px;
+ display: block;
+ margin-bottom: 8px;
+ width: 24px;
+ height: 24px;
+}
+.cap-detail-item .cap-icon svg {
+ width: 100%;
+ height: 100%;
+ stroke: currentColor;
+ fill: none;
+}
+.cap-detail-item .cap-name {
+ font-weight: 600;
+ font-size: 12px;
+ display: block;
+ margin-bottom: 4px;
+}
+.cap-detail-item .cap-status {
+ font-size: 10px;
+ color: var(--text-muted);
+}
+.cap-detail-item .cap-detail {
+ font-size: 9px;
+ color: var(--text-muted);
+ display: block;
+ margin-top: 4px;
+ font-family: var(--font-mono);
+}
+.cap-can-list, .cap-cannot-list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+}
+.cap-can-list li, .cap-cannot-list li {
+ padding: 6px 0;
+ font-size: 12px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
+}
+.cap-can-list li:last-child, .cap-cannot-list li:last-child {
+ border-bottom: none;
+}
+
+/* Modal Header Classification Colors */
+.device-detail-header.classification-cyan {
+ background: linear-gradient(135deg, rgba(0, 204, 255, 0.2) 0%, rgba(0, 150, 200, 0.1) 100%);
+ border-bottom: 2px solid #00ccff;
+}
+.device-detail-header.classification-orange {
+ background: linear-gradient(135deg, rgba(255, 153, 51, 0.2) 0%, rgba(200, 120, 40, 0.1) 100%);
+ border-bottom: 2px solid #ff9933;
+}
+.device-detail-header.classification-green {
+ background: linear-gradient(135deg, rgba(0, 204, 0, 0.2) 0%, rgba(0, 150, 0, 0.1) 100%);
+ border-bottom: 2px solid #00cc00;
+}
+
+/* Playbook Enhancements */
+.playbook-item {
+ cursor: pointer;
+ transition: all 0.2s;
+}
+.playbook-item:hover {
+ background: rgba(255, 153, 51, 0.1);
+}
+.playbook-category {
+ font-size: 9px;
+ padding: 2px 6px;
+ background: rgba(255, 153, 51, 0.2);
+ color: #ff9933;
+ border-radius: 3px;
+ text-transform: uppercase;
+}
+.playbook-meta {
+ font-size: 10px;
+ color: var(--text-muted);
+ margin-top: 8px;
+}
+.playbook-warning {
+ padding: 8px 12px;
+ background: rgba(255, 153, 51, 0.15);
+ border: 1px solid rgba(255, 153, 51, 0.3);
+ border-radius: 4px;
+ font-size: 11px;
+ margin-top: 8px;
+}
+
+/* Case Status Enhancements */
+.case-date {
+ font-size: 10px;
+ color: var(--text-muted);
+ margin-top: 4px;
+}
+
+/* Known Device Type Badge */
+.known-device-type {
+ font-size: 9px;
+ padding: 2px 6px;
+ background: rgba(74, 158, 255, 0.2);
+ color: #4a9eff;
+ border-radius: 3px;
+ margin-left: 8px;
+}
+
+/* ==========================================================================
+ Icon System
+ Minimal, functional icons that replace words. No decoration.
+ Designed for screenshot legibility in reports.
+ ========================================================================== */
+
+.icon {
+ display: inline-block;
+ width: 16px;
+ height: 16px;
+ vertical-align: middle;
+ flex-shrink: 0;
+}
+
+.icon svg {
+ width: 100%;
+ height: 100%;
+}
+
+.icon--sm {
+ width: 12px;
+ height: 12px;
+}
+
+.icon--lg {
+ width: 20px;
+ height: 20px;
+}
+
+/* Signal Type Icons */
+.icon-wifi svg,
+.icon-bluetooth svg,
+.icon-cellular svg,
+.icon-signal-unknown svg {
+ fill: var(--text-secondary);
+}
+
+/* Recording State */
+.icon-recording {
+ color: #ff3366;
+}
+
+.icon-recording.active svg {
+ animation: recording-pulse 1.5s ease-in-out infinite;
+}
+
+@keyframes recording-pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.4; }
+}
+
+/* Anomaly Indicator */
+.icon-anomaly {
+ color: #ff9933;
+}
+
+.icon-anomaly.critical {
+ color: #ff3366;
+}
+
+/* Export Icon */
+.icon-export {
+ color: var(--text-secondary);
+}
+
+/* Classification Dots - replaces emoji circles for risk levels */
+.classification-dot {
+ display: inline-block;
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ vertical-align: middle;
+ margin-right: 4px;
+}
+
+.classification-dot.high {
+ background-color: var(--accent-red);
+}
+
+.classification-dot.review {
+ background-color: var(--accent-orange);
+}
+
+.classification-dot.info {
+ background-color: var(--accent-green);
+}
+
+/* Device Indicators with Icons */
+.device-indicator-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ opacity: 0.3;
+ transition: opacity 0.3s, transform 0.3s;
+}
+
+.device-indicator-icon.active {
+ opacity: 1;
+ animation: device-pulse 1.5s ease-in-out infinite;
+}
+
+.device-indicator-icon.inactive {
+ opacity: 0.2;
+}
+
+.device-indicator-icon .icon {
+ width: 18px;
+ height: 18px;
+}
+
+/* Protocol badge with icon */
+.protocol-icon-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 10px;
+ padding: 2px 6px;
+ background: rgba(255, 255, 255, 0.1);
+ border-radius: 3px;
+ text-transform: uppercase;
+}
+
+.protocol-icon-badge .icon {
+ width: 12px;
+ height: 12px;
+}
+
+/* Recording status indicator */
+.recording-status {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 11px;
+}
+
+.recording-status .icon-recording {
+ width: 10px;
+ height: 10px;
+}
+
+.recording-status.active {
+ color: #ff3366;
+ font-weight: 600;
+}
+
+/* Anomaly flag in device items */
+.anomaly-flag {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 2px 6px;
+ border-radius: 3px;
+ font-size: 9px;
+ font-weight: 600;
+ text-transform: uppercase;
+}
+
+.anomaly-flag.needs-review {
+ background: rgba(255, 153, 51, 0.2);
+ color: #ff9933;
+}
+
+.anomaly-flag.high-interest {
+ background: rgba(255, 51, 51, 0.2);
+ color: #ff3333;
+}
+
+.anomaly-flag .icon {
+ width: 10px;
+ height: 10px;
+}
diff --git a/static/css/responsive.css b/static/css/responsive.css
index 82eb03f..c7e1f0c 100644
--- a/static/css/responsive.css
+++ b/static/css/responsive.css
@@ -1,660 +1,660 @@
-/* ============================================
- RESPONSIVE UTILITIES - iNTERCEPT
- Shared responsive foundation for all pages
- ============================================ */
-
-/* ============== CSS VARIABLES ============== */
-:root {
- /* Touch targets */
- --touch-min: 44px;
- --touch-comfortable: 48px;
-
- /* Responsive spacing */
- --spacing-xs: clamp(4px, 1vw, 8px);
- --spacing-sm: clamp(8px, 2vw, 12px);
- --spacing-md: clamp(12px, 3vw, 20px);
- --spacing-lg: clamp(16px, 4vw, 32px);
-
- /* Responsive typography */
- --font-xs: clamp(10px, 2.5vw, 11px);
- --font-sm: clamp(11px, 2.8vw, 12px);
- --font-base: clamp(13px, 3vw, 14px);
- --font-md: clamp(14px, 3.5vw, 16px);
- --font-lg: clamp(16px, 4vw, 20px);
- --font-xl: clamp(20px, 5vw, 28px);
- --font-2xl: clamp(24px, 6vw, 40px);
-
- /* Header height for calculations */
- --header-height: 52px;
- --nav-height: 44px;
-}
-
-@media (min-width: 768px) {
- :root {
- --header-height: 60px;
- --nav-height: 48px;
- }
-}
-
-@media (min-width: 1024px) {
- :root {
- --header-height: 96px;
- --nav-height: 0px;
- }
-}
-
-/* ============== VIEWPORT HEIGHT FIX ============== */
-/* Handles iOS Safari address bar and dynamic viewport */
-.full-height {
- height: 100dvh;
- height: 100vh; /* Fallback */
-}
-
-@supports (-webkit-touch-callout: none) {
- .full-height {
- height: -webkit-fill-available;
- }
-}
-
-/* ============== HAMBURGER BUTTON ============== */
-.hamburger-btn {
- display: flex;
- flex-direction: column;
- justify-content: center;
- align-items: center;
- width: var(--touch-min);
- height: var(--touch-min);
- padding: 10px;
- background: transparent;
- border: 1px solid var(--border-color, #1f2937);
- border-radius: 6px;
- cursor: pointer;
- position: relative;
- z-index: 1001;
- flex-shrink: 0;
- transition: background 0.2s ease, border-color 0.2s ease;
-}
-
-.hamburger-btn:hover {
- background: var(--bg-tertiary, #151a23);
- border-color: var(--accent-cyan, #4a9eff);
-}
-
-.hamburger-btn span {
- display: block;
- width: 18px;
- height: 2px;
- background: var(--accent-cyan, #4a9eff);
- margin: 2px 0;
- border-radius: 1px;
- transition: transform 0.3s ease, opacity 0.3s ease;
-}
-
-.hamburger-btn.active span:nth-child(1) {
- transform: rotate(45deg) translate(4px, 4px);
-}
-
-.hamburger-btn.active span:nth-child(2) {
- opacity: 0;
-}
-
-.hamburger-btn.active span:nth-child(3) {
- transform: rotate(-45deg) translate(4px, -4px);
-}
-
-/* Hide hamburger on desktop */
-@media (min-width: 1024px) {
- .hamburger-btn {
- display: none;
- }
-}
-
-/* ============== MOBILE DRAWER ============== */
-.mobile-drawer {
- position: fixed;
- top: 0;
- left: 0;
- width: min(320px, 85vw);
- height: 100dvh;
- height: 100vh; /* Fallback */
- background: var(--bg-secondary, #0f1218);
- border-right: 1px solid var(--border-color, #1f2937);
- transform: translateX(-100%);
- transition: transform 0.3s ease;
- z-index: 1000;
- overflow-y: auto;
- -webkit-overflow-scrolling: touch;
- padding-top: 60px;
-}
-
-.mobile-drawer.open {
- transform: translateX(0);
-}
-
-/* Show sidebar normally on desktop */
-@media (min-width: 1024px) {
- .mobile-drawer {
- position: static;
- transform: none;
- width: auto;
- height: auto;
- padding-top: 0;
- z-index: auto;
- }
-}
-
-/* ============== DRAWER OVERLAY ============== */
-.drawer-overlay {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background: rgba(0, 0, 0, 0.6);
- backdrop-filter: blur(2px);
- opacity: 0;
- visibility: hidden;
- transition: opacity 0.3s ease, visibility 0.3s ease;
- z-index: 999;
-}
-
-.drawer-overlay.visible {
- opacity: 1;
- visibility: visible;
-}
-
-/* Hide overlay on desktop */
-@media (min-width: 1024px) {
- .drawer-overlay {
- display: none;
- }
-}
-
-/* ============== TOUCH TARGETS ============== */
-@media (max-width: 1023px) {
- /* Ensure minimum touch target size for interactive elements */
- button,
- .btn,
- .preset-btn,
- .mode-nav-btn,
- .control-btn,
- .nav-action-btn,
- .icon-btn {
- min-height: var(--touch-min);
- min-width: var(--touch-min);
- }
-
- select,
- input[type="text"],
- input[type="number"],
- input[type="search"] {
- min-height: var(--touch-min);
- padding: 10px 12px;
- font-size: 16px; /* Prevents iOS zoom on focus */
- }
-
- .checkbox-group label,
- .radio-group label {
- min-height: var(--touch-min);
- padding: 10px 14px;
- display: flex;
- align-items: center;
- }
-}
-
-/* ============== RESPONSIVE UTILITIES ============== */
-/* Hide on mobile */
-.hide-mobile {
- display: none;
-}
-
-@media (min-width: 768px) {
- .hide-mobile {
- display: initial;
- }
-}
-
-/* Hide on tablet and up */
-.show-mobile-only {
- display: initial;
-}
-
-@media (min-width: 768px) {
- .show-mobile-only {
- display: none;
- }
-}
-
-/* Hide on desktop */
-.hide-desktop {
- display: initial;
-}
-
-@media (min-width: 1024px) {
- .hide-desktop {
- display: none;
- }
-}
-
-/* Show only on desktop */
-.show-desktop-only {
- display: none;
-}
-
-@media (min-width: 1024px) {
- .show-desktop-only {
- display: initial;
- }
-}
-
-/* ============== SCROLLABLE AREAS ============== */
-.scroll-x {
- overflow-x: auto;
- -webkit-overflow-scrolling: touch;
- scrollbar-width: thin;
-}
-
-.scroll-x::-webkit-scrollbar {
- height: 4px;
-}
-
-.scroll-x::-webkit-scrollbar-thumb {
- background: var(--border-color, #1f2937);
- border-radius: 2px;
-}
-
-/* Hide scrollbar on mobile for cleaner look */
-@media (max-width: 767px) {
- .scroll-x-mobile-hidden {
- scrollbar-width: none;
- }
-
- .scroll-x-mobile-hidden::-webkit-scrollbar {
- display: none;
- }
-}
-
-/* ============== MOBILE NAVIGATION BAR ============== */
-.mobile-nav-bar {
- display: flex;
- align-items: center;
- gap: 4px;
- padding: 6px 10px;
- background: var(--bg-tertiary, #151a23);
- border-bottom: 1px solid var(--border-color, #1f2937);
- overflow-x: auto;
- -webkit-overflow-scrolling: touch;
- scrollbar-width: none;
-}
-
-.mobile-nav-bar::-webkit-scrollbar {
- display: none;
-}
-
-.mobile-nav-btn {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 6px;
- padding: 8px 12px;
- background: var(--bg-card, #121620);
- border: 1px solid var(--border-color, #1f2937);
- border-radius: 6px;
- color: var(--text-secondary, #9ca3af);
- font-size: var(--font-xs);
- font-family: inherit;
- white-space: nowrap;
- cursor: pointer;
- transition: all 0.2s ease;
- min-height: 36px;
-}
-
-.mobile-nav-btn:hover,
-.mobile-nav-btn.active {
- background: var(--bg-elevated, #1a202c);
- border-color: var(--accent-cyan, #4a9eff);
- color: var(--text-primary, #e8eaed);
-}
-
-.mobile-nav-btn svg {
- width: 14px;
- height: 14px;
- flex-shrink: 0;
-}
-
-/* Hide mobile nav bar on desktop */
-@media (min-width: 1024px) {
- .mobile-nav-bar {
- display: none;
- }
-}
-
-/* ============== RESPONSIVE GRID UTILITIES ============== */
-.grid-responsive {
- display: grid;
- gap: var(--spacing-sm);
-}
-
-/* 1 column base */
-.grid-1-2 {
- grid-template-columns: 1fr;
-}
-
-@media (min-width: 480px) {
- .grid-1-2 {
- grid-template-columns: repeat(2, 1fr);
- }
-}
-
-.grid-2-3 {
- grid-template-columns: repeat(2, 1fr);
-}
-
-@media (min-width: 768px) {
- .grid-2-3 {
- grid-template-columns: repeat(3, 1fr);
- }
-}
-
-/* ============== TYPOGRAPHY RESPONSIVE ============== */
-.text-responsive-xs { font-size: var(--font-xs); }
-.text-responsive-sm { font-size: var(--font-sm); }
-.text-responsive-base { font-size: var(--font-base); }
-.text-responsive-md { font-size: var(--font-md); }
-.text-responsive-lg { font-size: var(--font-lg); }
-.text-responsive-xl { font-size: var(--font-xl); }
-.text-responsive-2xl { font-size: var(--font-2xl); }
-
-/* Ensure minimum readable sizes for tiny text */
-.text-min-readable {
- font-size: max(10px, var(--font-xs));
-}
-
-/* ============== MOBILE LAYOUT FIXES ============== */
-@media (max-width: 1023px) {
- /* Fix main content to allow scrolling on mobile */
- .main-content {
- height: auto !important;
- min-height: calc(100dvh - var(--header-height) - var(--nav-height));
- overflow-y: auto !important;
- overflow-x: hidden;
- -webkit-overflow-scrolling: touch;
- }
-
- /* Container should not clip content */
- .container {
- overflow: visible;
- height: auto;
- min-height: 100dvh;
- }
-
- /* Layout containers need to stack vertically on mobile */
- .wifi-layout-container,
- .bt-layout-container {
- flex-direction: column !important;
- height: auto !important;
- max-height: none !important;
- min-height: auto !important;
- overflow: visible !important;
- padding: 10px !important;
- }
-
- /* Visual panels should be scrollable, not clipped */
- .wifi-visuals,
- .bt-visuals {
- max-height: none !important;
- overflow: visible !important;
- margin-bottom: 15px;
- }
-
- /* Device lists should have reasonable height on mobile */
- .wifi-device-list,
- .bt-device-list {
- max-height: 400px;
- overflow-y: auto;
- -webkit-overflow-scrolling: touch;
- }
-
- /* Visual panels should stack in single column on mobile when visible */
- .wifi-visuals,
- .bt-visuals {
- display: flex;
- flex-direction: column;
- gap: 10px;
- }
-
- /* Only apply flex when aircraft visuals are shown (via JS setting display: grid) */
- #aircraftVisuals[style*="grid"] {
- display: flex !important;
- flex-direction: column !important;
- gap: 10px;
- }
-
- /* APRS visuals - only when visible */
- #aprsVisuals[style*="flex"] {
- flex-direction: column !important;
- }
-
- .wifi-visual-panel {
- grid-column: auto !important;
- }
-}
-
-/* ============== MOBILE MAP FIXES ============== */
-@media (max-width: 1023px) {
- /* Aircraft map container needs explicit height on mobile */
- .aircraft-map-container {
- height: 300px !important;
- min-height: 300px !important;
- width: 100% !important;
- }
-
- #aircraftMap {
- height: 100% !important;
- width: 100% !important;
- min-height: 250px;
- }
-
- /* APRS map container */
- #aprsMap {
- min-height: 300px !important;
- height: 300px !important;
- width: 100% !important;
- }
-
- /* Satellite embed */
- .satellite-dashboard-embed {
- height: 400px !important;
- min-height: 400px !important;
- }
-
- /* Map panels should be full width */
- .wifi-visual-panel[style*="grid-column: span 2"] {
- grid-column: auto !important;
- }
-
- /* Make map container full width when it has ACARS sidebar */
- .wifi-visual-panel[style*="display: flex"][style*="gap: 0"] {
- flex-direction: column !important;
- }
-
- /* ACARS sidebar should be below map on mobile */
- .main-acars-sidebar {
- width: 100% !important;
- max-width: none !important;
- border-left: none !important;
- border-top: 1px solid var(--border-color, #1f2937) !important;
- }
-
- .main-acars-sidebar.collapsed {
- width: 100% !important;
- }
-
- .main-acars-content {
- max-height: 200px !important;
- }
-}
-
-/* ============== LEAFLET MOBILE TOUCH FIXES ============== */
-.leaflet-container {
- touch-action: pan-x pan-y;
- -webkit-tap-highlight-color: transparent;
-}
-
-.leaflet-control-zoom {
- touch-action: manipulation;
-}
-
-.leaflet-control-zoom a {
- min-width: var(--touch-min, 44px) !important;
- min-height: var(--touch-min, 44px) !important;
- line-height: var(--touch-min, 44px) !important;
- font-size: 18px !important;
-}
-
-/* ============== MOBILE HEADER STATS ============== */
-@media (max-width: 1023px) {
- .header-stats {
- display: none !important;
- }
-
- /* Simplify header on mobile */
- header h1 {
- font-size: 16px !important;
- }
-
- header h1 .tagline,
- header h1 .version-badge {
- display: none;
- }
-
- header .subtitle {
- font-size: 10px !important;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
-
- header .logo svg {
- width: 30px !important;
- height: 30px !important;
- }
-}
-
-/* ============== MOBILE MODE PANELS ============== */
-@media (max-width: 1023px) {
- /* Mode panel grids should be single column */
- .data-grid,
- .stats-grid,
- .sensor-grid {
- grid-template-columns: 1fr !important;
- }
-
- /* Section headers should be easier to tap */
- .section h3 {
- min-height: var(--touch-min);
- padding: 12px !important;
- }
-
- /* Tables need horizontal scroll */
- .message-table,
- .sensor-table {
- display: block;
- overflow-x: auto;
- -webkit-overflow-scrolling: touch;
- }
-
- /* Ensure messages list is scrollable */
- #messageList,
- #sensorGrid,
- .aprs-list {
- max-height: 60vh;
- overflow-y: auto;
- -webkit-overflow-scrolling: touch;
- }
-}
-
-/* ============== WELCOME PAGE MOBILE ============== */
-@media (max-width: 767px) {
- .welcome-container {
- padding: 15px !important;
- max-width: 100% !important;
- }
-
- .welcome-header {
- flex-direction: column;
- text-align: center;
- gap: 10px;
- }
-
- .welcome-logo svg {
- width: 50px;
- height: 50px;
- }
-
- .welcome-title {
- font-size: 24px !important;
- }
-
- .welcome-content {
- grid-template-columns: 1fr !important;
- }
-
- .mode-grid {
- grid-template-columns: repeat(2, 1fr) !important;
- gap: 8px !important;
- }
-
- .mode-card {
- padding: 12px 8px !important;
- }
-
- .mode-icon {
- font-size: 20px !important;
- }
-
- .mode-name {
- font-size: 11px !important;
- }
-
- .mode-desc {
- font-size: 9px !important;
- }
-
- .changelog-release {
- padding: 10px !important;
- }
-}
-
-/* ============== TSCM MODE MOBILE ============== */
-@media (max-width: 1023px) {
- .tscm-layout {
- flex-direction: column !important;
- height: auto !important;
- }
-
- .tscm-spectrum-panel,
- .tscm-detection-panel {
- width: 100% !important;
- max-width: none !important;
- height: auto !important;
- min-height: 300px;
- }
-}
-
-/* ============== LISTENING POST MOBILE ============== */
-@media (max-width: 1023px) {
- .radio-controls-section {
- flex-direction: column !important;
- gap: 15px;
- }
-
- .knobs-row {
- flex-wrap: wrap;
- justify-content: center;
- }
-
- .radio-module-box {
- width: 100% !important;
- }
-}
+/* ============================================
+ RESPONSIVE UTILITIES - iNTERCEPT
+ Shared responsive foundation for all pages
+ ============================================ */
+
+/* ============== CSS VARIABLES ============== */
+:root {
+ /* Touch targets */
+ --touch-min: 44px;
+ --touch-comfortable: 48px;
+
+ /* Responsive spacing */
+ --spacing-xs: clamp(4px, 1vw, 8px);
+ --spacing-sm: clamp(8px, 2vw, 12px);
+ --spacing-md: clamp(12px, 3vw, 20px);
+ --spacing-lg: clamp(16px, 4vw, 32px);
+
+ /* Responsive typography */
+ --font-xs: clamp(10px, 2.5vw, 11px);
+ --font-sm: clamp(11px, 2.8vw, 12px);
+ --font-base: clamp(13px, 3vw, 14px);
+ --font-md: clamp(14px, 3.5vw, 16px);
+ --font-lg: clamp(16px, 4vw, 20px);
+ --font-xl: clamp(20px, 5vw, 28px);
+ --font-2xl: clamp(24px, 6vw, 40px);
+
+ /* Header height for calculations */
+ --header-height: 52px;
+ --nav-height: 44px;
+}
+
+@media (min-width: 768px) {
+ :root {
+ --header-height: 60px;
+ --nav-height: 48px;
+ }
+}
+
+@media (min-width: 1024px) {
+ :root {
+ --header-height: 96px;
+ --nav-height: 0px;
+ }
+}
+
+/* ============== VIEWPORT HEIGHT FIX ============== */
+/* Handles iOS Safari address bar and dynamic viewport */
+.full-height {
+ height: 100dvh;
+ height: 100vh; /* Fallback */
+}
+
+@supports (-webkit-touch-callout: none) {
+ .full-height {
+ height: -webkit-fill-available;
+ }
+}
+
+/* ============== HAMBURGER BUTTON ============== */
+.hamburger-btn {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ width: var(--touch-min);
+ height: var(--touch-min);
+ padding: 10px;
+ background: transparent;
+ border: 1px solid var(--border-color, #1f2937);
+ border-radius: 6px;
+ cursor: pointer;
+ position: relative;
+ z-index: 1001;
+ flex-shrink: 0;
+ transition: background 0.2s ease, border-color 0.2s ease;
+}
+
+.hamburger-btn:hover {
+ background: var(--bg-tertiary, #151a23);
+ border-color: var(--accent-cyan, #4a9eff);
+}
+
+.hamburger-btn span {
+ display: block;
+ width: 18px;
+ height: 2px;
+ background: var(--accent-cyan, #4a9eff);
+ margin: 2px 0;
+ border-radius: 1px;
+ transition: transform 0.3s ease, opacity 0.3s ease;
+}
+
+.hamburger-btn.active span:nth-child(1) {
+ transform: rotate(45deg) translate(4px, 4px);
+}
+
+.hamburger-btn.active span:nth-child(2) {
+ opacity: 0;
+}
+
+.hamburger-btn.active span:nth-child(3) {
+ transform: rotate(-45deg) translate(4px, -4px);
+}
+
+/* Hide hamburger on desktop */
+@media (min-width: 1024px) {
+ .hamburger-btn {
+ display: none;
+ }
+}
+
+/* ============== MOBILE DRAWER ============== */
+.mobile-drawer {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: min(320px, 85vw);
+ height: 100dvh;
+ height: 100vh; /* Fallback */
+ background: var(--bg-secondary, #0f1218);
+ border-right: 1px solid var(--border-color, #1f2937);
+ transform: translateX(-100%);
+ transition: transform 0.3s ease;
+ z-index: 1000;
+ overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+ padding-top: 60px;
+}
+
+.mobile-drawer.open {
+ transform: translateX(0);
+}
+
+/* Show sidebar normally on desktop */
+@media (min-width: 1024px) {
+ .mobile-drawer {
+ position: static;
+ transform: none;
+ width: auto;
+ height: auto;
+ padding-top: 0;
+ z-index: auto;
+ }
+}
+
+/* ============== DRAWER OVERLAY ============== */
+.drawer-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.6);
+ backdrop-filter: blur(2px);
+ opacity: 0;
+ visibility: hidden;
+ transition: opacity 0.3s ease, visibility 0.3s ease;
+ z-index: 999;
+}
+
+.drawer-overlay.visible {
+ opacity: 1;
+ visibility: visible;
+}
+
+/* Hide overlay on desktop */
+@media (min-width: 1024px) {
+ .drawer-overlay {
+ display: none;
+ }
+}
+
+/* ============== TOUCH TARGETS ============== */
+@media (max-width: 1023px) {
+ /* Ensure minimum touch target size for interactive elements */
+ button,
+ .btn,
+ .preset-btn,
+ .mode-nav-btn,
+ .control-btn,
+ .nav-action-btn,
+ .icon-btn {
+ min-height: var(--touch-min);
+ min-width: var(--touch-min);
+ }
+
+ select,
+ input[type="text"],
+ input[type="number"],
+ input[type="search"] {
+ min-height: var(--touch-min);
+ padding: 10px 12px;
+ font-size: 16px; /* Prevents iOS zoom on focus */
+ }
+
+ .checkbox-group label,
+ .radio-group label {
+ min-height: var(--touch-min);
+ padding: 10px 14px;
+ display: flex;
+ align-items: center;
+ }
+}
+
+/* ============== RESPONSIVE UTILITIES ============== */
+/* Hide on mobile */
+.hide-mobile {
+ display: none;
+}
+
+@media (min-width: 768px) {
+ .hide-mobile {
+ display: initial;
+ }
+}
+
+/* Hide on tablet and up */
+.show-mobile-only {
+ display: initial;
+}
+
+@media (min-width: 768px) {
+ .show-mobile-only {
+ display: none;
+ }
+}
+
+/* Hide on desktop */
+.hide-desktop {
+ display: initial;
+}
+
+@media (min-width: 1024px) {
+ .hide-desktop {
+ display: none;
+ }
+}
+
+/* Show only on desktop */
+.show-desktop-only {
+ display: none;
+}
+
+@media (min-width: 1024px) {
+ .show-desktop-only {
+ display: initial;
+ }
+}
+
+/* ============== SCROLLABLE AREAS ============== */
+.scroll-x {
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+ scrollbar-width: thin;
+}
+
+.scroll-x::-webkit-scrollbar {
+ height: 4px;
+}
+
+.scroll-x::-webkit-scrollbar-thumb {
+ background: var(--border-color, #1f2937);
+ border-radius: 2px;
+}
+
+/* Hide scrollbar on mobile for cleaner look */
+@media (max-width: 767px) {
+ .scroll-x-mobile-hidden {
+ scrollbar-width: none;
+ }
+
+ .scroll-x-mobile-hidden::-webkit-scrollbar {
+ display: none;
+ }
+}
+
+/* ============== MOBILE NAVIGATION BAR ============== */
+.mobile-nav-bar {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 6px 10px;
+ background: var(--bg-tertiary, #151a23);
+ border-bottom: 1px solid var(--border-color, #1f2937);
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+ scrollbar-width: none;
+}
+
+.mobile-nav-bar::-webkit-scrollbar {
+ display: none;
+}
+
+.mobile-nav-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ padding: 8px 12px;
+ background: var(--bg-card, #121620);
+ border: 1px solid var(--border-color, #1f2937);
+ border-radius: 6px;
+ color: var(--text-secondary, #9ca3af);
+ font-size: var(--font-xs);
+ font-family: inherit;
+ white-space: nowrap;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ min-height: 36px;
+}
+
+.mobile-nav-btn:hover,
+.mobile-nav-btn.active {
+ background: var(--bg-elevated, #1a202c);
+ border-color: var(--accent-cyan, #4a9eff);
+ color: var(--text-primary, #e8eaed);
+}
+
+.mobile-nav-btn svg {
+ width: 14px;
+ height: 14px;
+ flex-shrink: 0;
+}
+
+/* Hide mobile nav bar on desktop */
+@media (min-width: 1024px) {
+ .mobile-nav-bar {
+ display: none;
+ }
+}
+
+/* ============== RESPONSIVE GRID UTILITIES ============== */
+.grid-responsive {
+ display: grid;
+ gap: var(--spacing-sm);
+}
+
+/* 1 column base */
+.grid-1-2 {
+ grid-template-columns: 1fr;
+}
+
+@media (min-width: 480px) {
+ .grid-1-2 {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+
+.grid-2-3 {
+ grid-template-columns: repeat(2, 1fr);
+}
+
+@media (min-width: 768px) {
+ .grid-2-3 {
+ grid-template-columns: repeat(3, 1fr);
+ }
+}
+
+/* ============== TYPOGRAPHY RESPONSIVE ============== */
+.text-responsive-xs { font-size: var(--font-xs); }
+.text-responsive-sm { font-size: var(--font-sm); }
+.text-responsive-base { font-size: var(--font-base); }
+.text-responsive-md { font-size: var(--font-md); }
+.text-responsive-lg { font-size: var(--font-lg); }
+.text-responsive-xl { font-size: var(--font-xl); }
+.text-responsive-2xl { font-size: var(--font-2xl); }
+
+/* Ensure minimum readable sizes for tiny text */
+.text-min-readable {
+ font-size: max(10px, var(--font-xs));
+}
+
+/* ============== MOBILE LAYOUT FIXES ============== */
+@media (max-width: 1023px) {
+ /* Fix main content to allow scrolling on mobile */
+ .main-content {
+ height: auto !important;
+ min-height: calc(100dvh - var(--header-height) - var(--nav-height));
+ overflow-y: auto !important;
+ overflow-x: hidden;
+ -webkit-overflow-scrolling: touch;
+ }
+
+ /* Container should not clip content */
+ .container {
+ overflow: visible;
+ height: auto;
+ min-height: 100dvh;
+ }
+
+ /* Layout containers need to stack vertically on mobile */
+ .wifi-layout-container,
+ .bt-layout-container {
+ flex-direction: column !important;
+ height: auto !important;
+ max-height: none !important;
+ min-height: auto !important;
+ overflow: visible !important;
+ padding: 10px !important;
+ }
+
+ /* Visual panels should be scrollable, not clipped */
+ .wifi-visuals,
+ .bt-visuals {
+ max-height: none !important;
+ overflow: visible !important;
+ margin-bottom: 15px;
+ }
+
+ /* Device lists should have reasonable height on mobile */
+ .wifi-device-list,
+ .bt-device-list {
+ max-height: 400px;
+ overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+ }
+
+ /* Visual panels should stack in single column on mobile when visible */
+ .wifi-visuals,
+ .bt-visuals {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ }
+
+ /* Only apply flex when aircraft visuals are shown (via JS setting display: grid) */
+ #aircraftVisuals[style*="grid"] {
+ display: flex !important;
+ flex-direction: column !important;
+ gap: 10px;
+ }
+
+ /* APRS visuals - only when visible */
+ #aprsVisuals[style*="flex"] {
+ flex-direction: column !important;
+ }
+
+ .wifi-visual-panel {
+ grid-column: auto !important;
+ }
+}
+
+/* ============== MOBILE MAP FIXES ============== */
+@media (max-width: 1023px) {
+ /* Aircraft map container needs explicit height on mobile */
+ .aircraft-map-container {
+ height: 300px !important;
+ min-height: 300px !important;
+ width: 100% !important;
+ }
+
+ #aircraftMap {
+ height: 100% !important;
+ width: 100% !important;
+ min-height: 250px;
+ }
+
+ /* APRS map container */
+ #aprsMap {
+ min-height: 300px !important;
+ height: 300px !important;
+ width: 100% !important;
+ }
+
+ /* Satellite embed */
+ .satellite-dashboard-embed {
+ height: 400px !important;
+ min-height: 400px !important;
+ }
+
+ /* Map panels should be full width */
+ .wifi-visual-panel[style*="grid-column: span 2"] {
+ grid-column: auto !important;
+ }
+
+ /* Make map container full width when it has ACARS sidebar */
+ .wifi-visual-panel[style*="display: flex"][style*="gap: 0"] {
+ flex-direction: column !important;
+ }
+
+ /* ACARS sidebar should be below map on mobile */
+ .main-acars-sidebar {
+ width: 100% !important;
+ max-width: none !important;
+ border-left: none !important;
+ border-top: 1px solid var(--border-color, #1f2937) !important;
+ }
+
+ .main-acars-sidebar.collapsed {
+ width: 100% !important;
+ }
+
+ .main-acars-content {
+ max-height: 200px !important;
+ }
+}
+
+/* ============== LEAFLET MOBILE TOUCH FIXES ============== */
+.leaflet-container {
+ touch-action: pan-x pan-y;
+ -webkit-tap-highlight-color: transparent;
+}
+
+.leaflet-control-zoom {
+ touch-action: manipulation;
+}
+
+.leaflet-control-zoom a {
+ min-width: var(--touch-min, 44px) !important;
+ min-height: var(--touch-min, 44px) !important;
+ line-height: var(--touch-min, 44px) !important;
+ font-size: 18px !important;
+}
+
+/* ============== MOBILE HEADER STATS ============== */
+@media (max-width: 1023px) {
+ .header-stats {
+ display: none !important;
+ }
+
+ /* Simplify header on mobile */
+ header h1 {
+ font-size: 16px !important;
+ }
+
+ header h1 .tagline,
+ header h1 .version-badge {
+ display: none;
+ }
+
+ header .subtitle {
+ font-size: 10px !important;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ header .logo svg {
+ width: 30px !important;
+ height: 30px !important;
+ }
+}
+
+/* ============== MOBILE MODE PANELS ============== */
+@media (max-width: 1023px) {
+ /* Mode panel grids should be single column */
+ .data-grid,
+ .stats-grid,
+ .sensor-grid {
+ grid-template-columns: 1fr !important;
+ }
+
+ /* Section headers should be easier to tap */
+ .section h3 {
+ min-height: var(--touch-min);
+ padding: 12px !important;
+ }
+
+ /* Tables need horizontal scroll */
+ .message-table,
+ .sensor-table {
+ display: block;
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+ }
+
+ /* Ensure messages list is scrollable */
+ #messageList,
+ #sensorGrid,
+ .aprs-list {
+ max-height: 60vh;
+ overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+ }
+}
+
+/* ============== WELCOME PAGE MOBILE ============== */
+@media (max-width: 767px) {
+ .welcome-container {
+ padding: 15px !important;
+ max-width: 100% !important;
+ }
+
+ .welcome-header {
+ flex-direction: column;
+ text-align: center;
+ gap: 10px;
+ }
+
+ .welcome-logo svg {
+ width: 50px;
+ height: 50px;
+ }
+
+ .welcome-title {
+ font-size: 24px !important;
+ }
+
+ .welcome-content {
+ grid-template-columns: 1fr !important;
+ }
+
+ .mode-grid {
+ grid-template-columns: repeat(2, 1fr) !important;
+ gap: 8px !important;
+ }
+
+ .mode-card {
+ padding: 12px 8px !important;
+ }
+
+ .mode-icon {
+ font-size: 20px !important;
+ }
+
+ .mode-name {
+ font-size: 11px !important;
+ }
+
+ .mode-desc {
+ font-size: 9px !important;
+ }
+
+ .changelog-release {
+ padding: 10px !important;
+ }
+}
+
+/* ============== TSCM MODE MOBILE ============== */
+@media (max-width: 1023px) {
+ .tscm-layout {
+ flex-direction: column !important;
+ height: auto !important;
+ }
+
+ .tscm-spectrum-panel,
+ .tscm-detection-panel {
+ width: 100% !important;
+ max-width: none !important;
+ height: auto !important;
+ min-height: 300px;
+ }
+}
+
+/* ============== LISTENING POST MOBILE ============== */
+@media (max-width: 1023px) {
+ .radio-controls-section {
+ flex-direction: column !important;
+ gap: 15px;
+ }
+
+ .knobs-row {
+ flex-wrap: wrap;
+ justify-content: center;
+ }
+
+ .radio-module-box {
+ width: 100% !important;
+ }
+}
diff --git a/static/css/satellite_dashboard.css b/static/css/satellite_dashboard.css
index 02c2790..f3ed1a8 100644
--- a/static/css/satellite_dashboard.css
+++ b/static/css/satellite_dashboard.css
@@ -5,6 +5,8 @@
}
:root {
+ --font-sans: 'JetBrains Mono', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+ --font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
--bg-dark: #0a0c10;
--bg-panel: #0f1218;
--bg-card: #151a23;
@@ -23,7 +25,7 @@
}
body {
- font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
+ font-family: var(--font-sans);
background: var(--bg-dark);
color: var(--text-primary);
min-height: 100vh;
@@ -93,7 +95,7 @@ body {
}
.logo {
- font-family: 'Inter', sans-serif;
+ font-family: var(--font-sans);
font-size: 20px;
font-weight: 700;
letter-spacing: 3px;
@@ -142,7 +144,7 @@ body {
border: 1px solid rgba(0, 212, 255, 0.3);
border-radius: 4px;
padding: 4px 10px;
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 11px;
}
@@ -164,7 +166,7 @@ body {
display: flex;
gap: 20px;
align-items: center;
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 11px;
}
@@ -457,7 +459,7 @@ body {
}
.telemetry-value {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 12px;
color: var(--accent-cyan);
}
@@ -543,7 +545,7 @@ body {
}
.pass-time {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
}
/* Bottom controls bar */
@@ -579,7 +581,7 @@ body {
border: 1px solid rgba(0, 212, 255, 0.3);
border-radius: 4px;
color: var(--accent-cyan);
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 11px;
}
@@ -748,4 +750,4 @@ body.embedded .panel {
body.embedded .controls-bar {
padding: 10px 15px;
-}
\ No newline at end of file
+}
diff --git a/static/css/settings.css b/static/css/settings.css
index 1ce725c..ac3d2e7 100644
--- a/static/css/settings.css
+++ b/static/css/settings.css
@@ -1,444 +1,444 @@
-/* Settings Modal Styles */
-
-.settings-modal {
- display: none;
- position: fixed;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- background: rgba(0, 0, 0, 0.85);
- z-index: 10000;
- overflow-y: auto;
- backdrop-filter: blur(4px);
-}
-
-.settings-modal.active {
- display: flex;
- justify-content: center;
- align-items: flex-start;
- padding: 40px 20px;
-}
-
-.settings-content {
- background: var(--bg-dark, #0a0a0f);
- border: 1px solid var(--border-color, #1a1a2e);
- border-radius: 8px;
- max-width: 600px;
- width: 100%;
- position: relative;
- box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
-}
-
-.settings-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 16px 20px;
- border-bottom: 1px solid var(--border-color, #1a1a2e);
-}
-
-.settings-header h2 {
- margin: 0;
- font-size: 16px;
- font-weight: 600;
- color: var(--text-primary, #e0e0e0);
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-.settings-header h2 .icon {
- width: 20px;
- height: 20px;
- color: var(--accent-cyan, #00d4ff);
-}
-
-.settings-close {
- background: none;
- border: none;
- color: var(--text-muted, #666);
- font-size: 24px;
- cursor: pointer;
- padding: 4px;
- line-height: 1;
- transition: color 0.2s;
-}
-
-.settings-close:hover {
- color: var(--accent-red, #ff4444);
-}
-
-/* Settings Tabs */
-.settings-tabs {
- display: flex;
- border-bottom: 1px solid var(--border-color, #1a1a2e);
- padding: 0 20px;
- gap: 4px;
-}
-
-.settings-tab {
- background: none;
- border: none;
- padding: 12px 16px;
- color: var(--text-muted, #666);
- font-size: 13px;
- font-weight: 500;
- cursor: pointer;
- position: relative;
- transition: color 0.2s;
-}
-
-.settings-tab:hover {
- color: var(--text-primary, #e0e0e0);
-}
-
-.settings-tab.active {
- color: var(--accent-cyan, #00d4ff);
-}
-
-.settings-tab.active::after {
- content: '';
- position: absolute;
- bottom: -1px;
- left: 0;
- right: 0;
- height: 2px;
- background: var(--accent-cyan, #00d4ff);
-}
-
-/* Settings Sections */
-.settings-section {
- display: none;
- padding: 20px;
-}
-
-.settings-section.active {
- display: block;
-}
-
-.settings-group {
- margin-bottom: 24px;
-}
-
-.settings-group:last-child {
- margin-bottom: 0;
-}
-
-.settings-group-title {
- font-size: 11px;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.5px;
- color: var(--text-muted, #666);
- margin-bottom: 12px;
-}
-
-/* Settings Row */
-.settings-row {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 12px 0;
- border-bottom: 1px solid rgba(255, 255, 255, 0.05);
-}
-
-.settings-row:last-child {
- border-bottom: none;
-}
-
-.settings-label {
- display: flex;
- flex-direction: column;
- gap: 2px;
-}
-
-.settings-label-text {
- font-size: 13px;
- color: var(--text-primary, #e0e0e0);
-}
-
-.settings-label-desc {
- font-size: 11px;
- color: var(--text-muted, #666);
-}
-
-/* Toggle Switch */
-.toggle-switch {
- position: relative;
- width: 44px;
- height: 24px;
- flex-shrink: 0;
-}
-
-.toggle-switch input {
- opacity: 0;
- width: 0;
- height: 0;
-}
-
-.toggle-slider {
- position: absolute;
- cursor: pointer;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background-color: var(--bg-tertiary, #1a1a2e);
- border: 1px solid var(--border-color, #2a2a3e);
- transition: 0.3s;
- border-radius: 24px;
-}
-
-.toggle-slider:before {
- position: absolute;
- content: "";
- height: 18px;
- width: 18px;
- left: 2px;
- bottom: 2px;
- background-color: var(--text-muted, #666);
- transition: 0.3s;
- border-radius: 50%;
-}
-
-.toggle-switch input:checked + .toggle-slider {
- background-color: var(--accent-cyan, #00d4ff);
- border-color: var(--accent-cyan, #00d4ff);
-}
-
-.toggle-switch input:checked + .toggle-slider:before {
- transform: translateX(20px);
- background-color: white;
-}
-
-.toggle-switch input:focus + .toggle-slider {
- box-shadow: 0 0 0 2px rgba(0, 212, 255, 0.3);
-}
-
-/* Select Dropdown */
-.settings-select {
- background: var(--bg-tertiary, #1a1a2e);
- border: 1px solid var(--border-color, #2a2a3e);
- border-radius: 4px;
- padding: 8px 12px;
- font-size: 13px;
- color: var(--text-primary, #e0e0e0);
- min-width: 160px;
- cursor: pointer;
- appearance: none;
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
- background-repeat: no-repeat;
- background-position: right 8px center;
- padding-right: 32px;
-}
-
-.settings-select:focus {
- outline: none;
- border-color: var(--accent-cyan, #00d4ff);
-}
-
-/* Text Input */
-.settings-input {
- background: var(--bg-tertiary, #1a1a2e);
- border: 1px solid var(--border-color, #2a2a3e);
- border-radius: 4px;
- padding: 8px 12px;
- font-size: 13px;
- color: var(--text-primary, #e0e0e0);
- width: 200px;
-}
-
-.settings-input:focus {
- outline: none;
- border-color: var(--accent-cyan, #00d4ff);
-}
-
-.settings-input::placeholder {
- color: var(--text-muted, #666);
-}
-
-/* Asset Status */
-.asset-status {
- display: flex;
- flex-direction: column;
- gap: 8px;
- margin-top: 12px;
- padding: 12px;
- background: var(--bg-secondary, #0f0f1a);
- border-radius: 6px;
-}
-
-.asset-status-row {
- display: flex;
- justify-content: space-between;
- align-items: center;
- font-size: 12px;
-}
-
-.asset-name {
- color: var(--text-muted, #888);
-}
-
-.asset-badge {
- padding: 2px 8px;
- border-radius: 10px;
- font-size: 10px;
- font-weight: 500;
- text-transform: uppercase;
-}
-
-.asset-badge.available {
- background: rgba(0, 255, 136, 0.15);
- color: var(--accent-green, #00ff88);
-}
-
-.asset-badge.missing {
- background: rgba(255, 68, 68, 0.15);
- color: var(--accent-red, #ff4444);
-}
-
-.asset-badge.checking {
- background: rgba(255, 170, 0, 0.15);
- color: var(--accent-orange, #ffaa00);
-}
-
-/* Check Assets Button */
-.check-assets-btn {
- background: var(--bg-tertiary, #1a1a2e);
- border: 1px solid var(--border-color, #2a2a3e);
- color: var(--text-primary, #e0e0e0);
- padding: 8px 16px;
- border-radius: 4px;
- font-size: 12px;
- cursor: pointer;
- margin-top: 12px;
- transition: all 0.2s;
-}
-
-.check-assets-btn:hover {
- border-color: var(--accent-cyan, #00d4ff);
- color: var(--accent-cyan, #00d4ff);
-}
-
-.check-assets-btn:disabled {
- opacity: 0.5;
- cursor: not-allowed;
-}
-
-/* GPS Detection Spinner */
-.detecting-spinner {
- display: inline-block;
- width: 12px;
- height: 12px;
- border: 2px solid currentColor;
- border-top-color: transparent;
- border-radius: 50%;
- animation: detecting-spin 0.8s linear infinite;
- vertical-align: middle;
- margin-right: 6px;
-}
-
-@keyframes detecting-spin {
- to { transform: rotate(360deg); }
-}
-
-/* About Section */
-.about-info {
- font-size: 13px;
- color: var(--text-muted, #888);
- line-height: 1.6;
-}
-
-.about-info p {
- margin: 0 0 12px 0;
-}
-
-.about-info a {
- color: var(--accent-cyan, #00d4ff);
- text-decoration: none;
-}
-
-.about-info a:hover {
- text-decoration: underline;
-}
-
-.about-version {
- font-family: 'JetBrains Mono', monospace;
- color: var(--accent-cyan, #00d4ff);
-}
-
-/* Donate Button */
-.donate-btn {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- padding: 10px 20px;
- background: linear-gradient(135deg, var(--accent-amber, #d4a853) 0%, var(--accent-orange, #f59e0b) 100%);
- border: none;
- border-radius: 6px;
- color: #000;
- font-size: 13px;
- font-weight: 600;
- text-decoration: none;
- cursor: pointer;
- transition: all 0.2s ease;
- box-shadow: 0 2px 8px rgba(212, 168, 83, 0.3);
-}
-
-.donate-btn:hover {
- transform: translateY(-1px);
- box-shadow: 0 4px 12px rgba(212, 168, 83, 0.4);
- filter: brightness(1.1);
-}
-
-.donate-btn:active {
- transform: translateY(0);
-}
-
-/* Tile Provider Custom URL */
-.custom-url-row {
- margin-top: 8px;
- padding-top: 8px;
-}
-
-.custom-url-row .settings-input {
- width: 100%;
-}
-
-/* Info Callout */
-.settings-info {
- background: rgba(0, 212, 255, 0.1);
- border: 1px solid rgba(0, 212, 255, 0.2);
- border-radius: 6px;
- padding: 12px;
- margin-top: 16px;
- font-size: 12px;
- color: var(--text-muted, #888);
-}
-
-.settings-info strong {
- color: var(--accent-cyan, #00d4ff);
-}
-
-/* Responsive */
-@media (max-width: 640px) {
- .settings-modal.active {
- padding: 20px 10px;
- }
-
- .settings-content {
- max-width: 100%;
- }
-
- .settings-row {
- flex-direction: column;
- align-items: flex-start;
- gap: 8px;
- }
-
- .settings-select,
- .settings-input {
- width: 100%;
- }
-}
+/* Settings Modal Styles */
+
+.settings-modal {
+ display: none;
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(0, 0, 0, 0.85);
+ z-index: 10000;
+ overflow-y: auto;
+ backdrop-filter: blur(4px);
+}
+
+.settings-modal.active {
+ display: flex;
+ justify-content: center;
+ align-items: flex-start;
+ padding: 40px 20px;
+}
+
+.settings-content {
+ background: var(--bg-dark, #0a0a0f);
+ border: 1px solid var(--border-color, #1a1a2e);
+ border-radius: 8px;
+ max-width: 600px;
+ width: 100%;
+ position: relative;
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
+}
+
+.settings-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 16px 20px;
+ border-bottom: 1px solid var(--border-color, #1a1a2e);
+}
+
+.settings-header h2 {
+ margin: 0;
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--text-primary, #e0e0e0);
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.settings-header h2 .icon {
+ width: 20px;
+ height: 20px;
+ color: var(--accent-cyan, #00d4ff);
+}
+
+.settings-close {
+ background: none;
+ border: none;
+ color: var(--text-muted, #666);
+ font-size: 24px;
+ cursor: pointer;
+ padding: 4px;
+ line-height: 1;
+ transition: color 0.2s;
+}
+
+.settings-close:hover {
+ color: var(--accent-red, #ff4444);
+}
+
+/* Settings Tabs */
+.settings-tabs {
+ display: flex;
+ border-bottom: 1px solid var(--border-color, #1a1a2e);
+ padding: 0 20px;
+ gap: 4px;
+}
+
+.settings-tab {
+ background: none;
+ border: none;
+ padding: 12px 16px;
+ color: var(--text-muted, #666);
+ font-size: 13px;
+ font-weight: 500;
+ cursor: pointer;
+ position: relative;
+ transition: color 0.2s;
+}
+
+.settings-tab:hover {
+ color: var(--text-primary, #e0e0e0);
+}
+
+.settings-tab.active {
+ color: var(--accent-cyan, #00d4ff);
+}
+
+.settings-tab.active::after {
+ content: '';
+ position: absolute;
+ bottom: -1px;
+ left: 0;
+ right: 0;
+ height: 2px;
+ background: var(--accent-cyan, #00d4ff);
+}
+
+/* Settings Sections */
+.settings-section {
+ display: none;
+ padding: 20px;
+}
+
+.settings-section.active {
+ display: block;
+}
+
+.settings-group {
+ margin-bottom: 24px;
+}
+
+.settings-group:last-child {
+ margin-bottom: 0;
+}
+
+.settings-group-title {
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: var(--text-muted, #666);
+ margin-bottom: 12px;
+}
+
+/* Settings Row */
+.settings-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 12px 0;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
+}
+
+.settings-row:last-child {
+ border-bottom: none;
+}
+
+.settings-label {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.settings-label-text {
+ font-size: 13px;
+ color: var(--text-primary, #e0e0e0);
+}
+
+.settings-label-desc {
+ font-size: 11px;
+ color: var(--text-muted, #666);
+}
+
+/* Toggle Switch */
+.toggle-switch {
+ position: relative;
+ width: 44px;
+ height: 24px;
+ flex-shrink: 0;
+}
+
+.toggle-switch input {
+ opacity: 0;
+ width: 0;
+ height: 0;
+}
+
+.toggle-slider {
+ position: absolute;
+ cursor: pointer;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: var(--bg-tertiary, #1a1a2e);
+ border: 1px solid var(--border-color, #2a2a3e);
+ transition: 0.3s;
+ border-radius: 24px;
+}
+
+.toggle-slider:before {
+ position: absolute;
+ content: "";
+ height: 18px;
+ width: 18px;
+ left: 2px;
+ bottom: 2px;
+ background-color: var(--text-muted, #666);
+ transition: 0.3s;
+ border-radius: 50%;
+}
+
+.toggle-switch input:checked + .toggle-slider {
+ background-color: var(--accent-cyan, #00d4ff);
+ border-color: var(--accent-cyan, #00d4ff);
+}
+
+.toggle-switch input:checked + .toggle-slider:before {
+ transform: translateX(20px);
+ background-color: white;
+}
+
+.toggle-switch input:focus + .toggle-slider {
+ box-shadow: 0 0 0 2px rgba(0, 212, 255, 0.3);
+}
+
+/* Select Dropdown */
+.settings-select {
+ background: var(--bg-tertiary, #1a1a2e);
+ border: 1px solid var(--border-color, #2a2a3e);
+ border-radius: 4px;
+ padding: 8px 12px;
+ font-size: 13px;
+ color: var(--text-primary, #e0e0e0);
+ min-width: 160px;
+ cursor: pointer;
+ appearance: none;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 8px center;
+ padding-right: 32px;
+}
+
+.settings-select:focus {
+ outline: none;
+ border-color: var(--accent-cyan, #00d4ff);
+}
+
+/* Text Input */
+.settings-input {
+ background: var(--bg-tertiary, #1a1a2e);
+ border: 1px solid var(--border-color, #2a2a3e);
+ border-radius: 4px;
+ padding: 8px 12px;
+ font-size: 13px;
+ color: var(--text-primary, #e0e0e0);
+ width: 200px;
+}
+
+.settings-input:focus {
+ outline: none;
+ border-color: var(--accent-cyan, #00d4ff);
+}
+
+.settings-input::placeholder {
+ color: var(--text-muted, #666);
+}
+
+/* Asset Status */
+.asset-status {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin-top: 12px;
+ padding: 12px;
+ background: var(--bg-secondary, #0f0f1a);
+ border-radius: 6px;
+}
+
+.asset-status-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-size: 12px;
+}
+
+.asset-name {
+ color: var(--text-muted, #888);
+}
+
+.asset-badge {
+ padding: 2px 8px;
+ border-radius: 10px;
+ font-size: 10px;
+ font-weight: 500;
+ text-transform: uppercase;
+}
+
+.asset-badge.available {
+ background: rgba(0, 255, 136, 0.15);
+ color: var(--accent-green, #00ff88);
+}
+
+.asset-badge.missing {
+ background: rgba(255, 68, 68, 0.15);
+ color: var(--accent-red, #ff4444);
+}
+
+.asset-badge.checking {
+ background: rgba(255, 170, 0, 0.15);
+ color: var(--accent-orange, #ffaa00);
+}
+
+/* Check Assets Button */
+.check-assets-btn {
+ background: var(--bg-tertiary, #1a1a2e);
+ border: 1px solid var(--border-color, #2a2a3e);
+ color: var(--text-primary, #e0e0e0);
+ padding: 8px 16px;
+ border-radius: 4px;
+ font-size: 12px;
+ cursor: pointer;
+ margin-top: 12px;
+ transition: all 0.2s;
+}
+
+.check-assets-btn:hover {
+ border-color: var(--accent-cyan, #00d4ff);
+ color: var(--accent-cyan, #00d4ff);
+}
+
+.check-assets-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* GPS Detection Spinner */
+.detecting-spinner {
+ display: inline-block;
+ width: 12px;
+ height: 12px;
+ border: 2px solid currentColor;
+ border-top-color: transparent;
+ border-radius: 50%;
+ animation: detecting-spin 0.8s linear infinite;
+ vertical-align: middle;
+ margin-right: 6px;
+}
+
+@keyframes detecting-spin {
+ to { transform: rotate(360deg); }
+}
+
+/* About Section */
+.about-info {
+ font-size: 13px;
+ color: var(--text-muted, #888);
+ line-height: 1.6;
+}
+
+.about-info p {
+ margin: 0 0 12px 0;
+}
+
+.about-info a {
+ color: var(--accent-cyan, #00d4ff);
+ text-decoration: none;
+}
+
+.about-info a:hover {
+ text-decoration: underline;
+}
+
+.about-version {
+ font-family: var(--font-mono);
+ color: var(--accent-cyan, #00d4ff);
+}
+
+/* Donate Button */
+.donate-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 10px 20px;
+ background: linear-gradient(135deg, var(--accent-amber, #d4a853) 0%, var(--accent-orange, #f59e0b) 100%);
+ border: none;
+ border-radius: 6px;
+ color: #000;
+ font-size: 13px;
+ font-weight: 600;
+ text-decoration: none;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ box-shadow: 0 2px 8px rgba(212, 168, 83, 0.3);
+}
+
+.donate-btn:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 4px 12px rgba(212, 168, 83, 0.4);
+ filter: brightness(1.1);
+}
+
+.donate-btn:active {
+ transform: translateY(0);
+}
+
+/* Tile Provider Custom URL */
+.custom-url-row {
+ margin-top: 8px;
+ padding-top: 8px;
+}
+
+.custom-url-row .settings-input {
+ width: 100%;
+}
+
+/* Info Callout */
+.settings-info {
+ background: rgba(0, 212, 255, 0.1);
+ border: 1px solid rgba(0, 212, 255, 0.2);
+ border-radius: 6px;
+ padding: 12px;
+ margin-top: 16px;
+ font-size: 12px;
+ color: var(--text-muted, #888);
+}
+
+.settings-info strong {
+ color: var(--accent-cyan, #00d4ff);
+}
+
+/* Responsive */
+@media (max-width: 640px) {
+ .settings-modal.active {
+ padding: 20px 10px;
+ }
+
+ .settings-content {
+ max-width: 100%;
+ }
+
+ .settings-row {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 8px;
+ }
+
+ .settings-select,
+ .settings-input {
+ width: 100%;
+ }
+}
diff --git a/static/js/core/app.js b/static/js/core/app.js
index 4cca0bd..9728497 100644
--- a/static/js/core/app.js
+++ b/static/js/core/app.js
@@ -1,36 +1,36 @@
-/**
- * Intercept - Core Application Logic
- * Global state, mode switching, and shared functionality
- */
-
-// ============== GLOBAL STATE ==============
-
-// Mode state flags
-let eventSource = null;
-let isRunning = false;
-let isSensorRunning = false;
-let isAdsbRunning = false;
-let isWifiRunning = false;
-let isBtRunning = false;
-let currentMode = 'pager';
-
-// Message counters
-let msgCount = 0;
-let pocsagCount = 0;
-let flexCount = 0;
-let sensorCount = 0;
-let filteredCount = 0;
-
-// Device list (populated from server via Jinja2)
-let deviceList = [];
-
-// Auto-scroll setting
-let autoScroll = localStorage.getItem('autoScroll') !== 'false';
-
-// Mute setting
-let muted = localStorage.getItem('audioMuted') === 'true';
-
-// Observer location (load from localStorage or default to London)
+/**
+ * Intercept - Core Application Logic
+ * Global state, mode switching, and shared functionality
+ */
+
+// ============== GLOBAL STATE ==============
+
+// Mode state flags
+let eventSource = null;
+let isRunning = false;
+let isSensorRunning = false;
+let isAdsbRunning = false;
+let isWifiRunning = false;
+let isBtRunning = false;
+let currentMode = 'pager';
+
+// Message counters
+let msgCount = 0;
+let pocsagCount = 0;
+let flexCount = 0;
+let sensorCount = 0;
+let filteredCount = 0;
+
+// Device list (populated from server via Jinja2)
+let deviceList = [];
+
+// Auto-scroll setting
+let autoScroll = localStorage.getItem('autoScroll') !== 'false';
+
+// Mute setting
+let muted = localStorage.getItem('audioMuted') === 'true';
+
+// Observer location (load from localStorage or default to London)
let observerLocation = (function() {
if (window.ObserverLocation && ObserverLocation.getForModule) {
return ObserverLocation.getForModule('observerLocation');
@@ -44,464 +44,464 @@ let observerLocation = (function() {
}
return { lat: 51.5074, lon: -0.1278 };
})();
-
-// Message storage for export
-let allMessages = [];
-
-// Track unique sensor devices
-let uniqueDevices = new Set();
-
-// SDR device usage tracking
-let sdrDeviceUsage = {};
-
-// ============== DISCLAIMER HANDLING ==============
-
-function checkDisclaimer() {
- const accepted = localStorage.getItem('disclaimerAccepted');
- if (accepted === 'true') {
- document.getElementById('disclaimerModal').classList.add('disclaimer-hidden');
- }
-}
-
-function acceptDisclaimer() {
- localStorage.setItem('disclaimerAccepted', 'true');
- document.getElementById('disclaimerModal').classList.add('disclaimer-hidden');
-}
-
-function declineDisclaimer() {
- document.getElementById('disclaimerModal').classList.add('disclaimer-hidden');
- document.getElementById('rejectionPage').classList.remove('disclaimer-hidden');
-}
-
-// ============== HEADER CLOCK ==============
-
-function updateHeaderClock() {
- const now = new Date();
- const utc = now.toISOString().substring(11, 19);
- document.getElementById('headerUtcTime').textContent = utc;
-}
-
-// ============== MODE SWITCHING ==============
-
-function switchMode(mode) {
- // Stop any running scans when switching modes
- if (isRunning && typeof stopDecoding === 'function') stopDecoding();
- if (isSensorRunning && typeof stopSensorDecoding === 'function') stopSensorDecoding();
- if (isWifiRunning && typeof stopWifiScan === 'function') stopWifiScan();
- if (isBtRunning && typeof stopBtScan === 'function') stopBtScan();
- if (isAdsbRunning && typeof stopAdsbScan === 'function') stopAdsbScan();
-
- currentMode = mode;
-
- // Remove active from all nav buttons, then add to the correct one
- document.querySelectorAll('.mode-nav-btn').forEach(btn => btn.classList.remove('active'));
- const modeMap = {
- 'pager': 'pager', 'sensor': '433', 'aircraft': 'aircraft',
- 'satellite': 'satellite', 'wifi': 'wifi', 'bluetooth': 'bluetooth',
- 'listening': 'listening', 'meshtastic': 'meshtastic'
- };
- document.querySelectorAll('.mode-nav-btn').forEach(btn => {
- const label = btn.querySelector('.nav-label');
- if (label && label.textContent.toLowerCase().includes(modeMap[mode])) {
- btn.classList.add('active');
- }
- });
-
- // Toggle mode content visibility
- document.getElementById('pagerMode').classList.toggle('active', mode === 'pager');
- document.getElementById('sensorMode').classList.toggle('active', mode === 'sensor');
- document.getElementById('aircraftMode')?.classList.toggle('active', mode === 'aircraft');
- document.getElementById('satelliteMode').classList.toggle('active', mode === 'satellite');
- document.getElementById('wifiMode').classList.toggle('active', mode === 'wifi');
- document.getElementById('bluetoothMode').classList.toggle('active', mode === 'bluetooth');
- document.getElementById('listeningPostMode').classList.toggle('active', mode === 'listening');
- document.getElementById('aprsMode')?.classList.toggle('active', mode === 'aprs');
- document.getElementById('tscmMode')?.classList.toggle('active', mode === 'tscm');
- document.getElementById('rtlamrMode')?.classList.toggle('active', mode === 'rtlamr');
- document.getElementById('spystationsMode')?.classList.toggle('active', mode === 'spystations');
- document.getElementById('meshtasticMode')?.classList.toggle('active', mode === 'meshtastic');
-
- // Toggle stats visibility
- document.getElementById('pagerStats').style.display = mode === 'pager' ? 'flex' : 'none';
- document.getElementById('sensorStats').style.display = mode === 'sensor' ? 'flex' : 'none';
- document.getElementById('aircraftStats').style.display = mode === 'aircraft' ? 'flex' : 'none';
- document.getElementById('satelliteStats').style.display = mode === 'satellite' ? 'flex' : 'none';
- document.getElementById('wifiStats').style.display = mode === 'wifi' ? 'flex' : 'none';
-
- // Hide signal meter - individual panels show signal strength where needed
- document.getElementById('signalMeter').style.display = 'none';
-
- // Show/hide dashboard buttons in nav bar
- document.getElementById('adsbDashboardBtn').style.display = mode === 'aircraft' ? 'inline-flex' : 'none';
- document.getElementById('satelliteDashboardBtn').style.display = mode === 'satellite' ? 'inline-flex' : 'none';
-
- // Update active mode indicator
- const modeNames = {
- 'pager': 'PAGER',
- 'sensor': '433MHZ',
- 'aircraft': 'AIRCRAFT',
- 'satellite': 'SATELLITE',
- 'wifi': 'WIFI',
- 'bluetooth': 'BLUETOOTH',
- 'listening': 'LISTENING POST',
- 'tscm': 'TSCM',
- 'aprs': 'APRS',
- 'meshtastic': 'MESHTASTIC'
- };
- document.getElementById('activeModeIndicator').innerHTML = '' + modeNames[mode];
-
- // Update mobile nav buttons
- updateMobileNavButtons(mode);
-
- // Close mobile drawer when mode is switched (on mobile)
- if (window.innerWidth < 1024 && typeof window.closeMobileDrawer === 'function') {
- window.closeMobileDrawer();
- }
-
- // Toggle layout containers
- document.getElementById('wifiLayoutContainer').style.display = mode === 'wifi' ? 'flex' : 'none';
- document.getElementById('btLayoutContainer').style.display = mode === 'bluetooth' ? 'flex' : 'none';
-
- // Respect the "Show Radar Display" checkbox for aircraft mode
- const showRadar = document.getElementById('adsbEnableMap')?.checked;
- document.getElementById('aircraftVisuals').style.display = (mode === 'aircraft' && showRadar) ? 'grid' : 'none';
- document.getElementById('satelliteVisuals').style.display = mode === 'satellite' ? 'block' : 'none';
- document.getElementById('listeningPostVisuals').style.display = mode === 'listening' ? 'grid' : 'none';
-
- // Update output panel title based on mode
- const titles = {
- 'pager': 'Pager Decoder',
- 'sensor': '433MHz Sensor Monitor',
- 'aircraft': 'ADS-B Aircraft Tracker',
- 'satellite': 'Satellite Monitor',
- 'wifi': 'WiFi Scanner',
- 'bluetooth': 'Bluetooth Scanner',
- 'listening': 'Listening Post',
- 'meshtastic': 'Meshtastic Mesh Monitor'
- };
- document.getElementById('outputTitle').textContent = titles[mode] || 'Signal Monitor';
-
- // Show/hide Device Intelligence for modes that use it
- const reconBtn = document.getElementById('reconBtn');
- const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
- if (mode === 'satellite' || mode === 'aircraft' || mode === 'listening') {
- document.getElementById('reconPanel').style.display = 'none';
- if (reconBtn) reconBtn.style.display = 'none';
- if (intelBtn) intelBtn.style.display = 'none';
- } else {
- if (reconBtn) reconBtn.style.display = 'inline-block';
- if (intelBtn) intelBtn.style.display = 'inline-block';
- if (typeof reconEnabled !== 'undefined' && reconEnabled) {
- document.getElementById('reconPanel').style.display = 'block';
- }
- }
-
- // Show RTL-SDR device section for modes that use it
- document.getElementById('rtlDeviceSection').style.display =
- (mode === 'pager' || mode === 'sensor' || mode === 'aircraft' || mode === 'listening') ? 'block' : 'none';
-
- // Toggle mode-specific tool status displays
- document.getElementById('toolStatusPager').style.display = (mode === 'pager') ? 'grid' : 'none';
- document.getElementById('toolStatusSensor').style.display = (mode === 'sensor') ? 'grid' : 'none';
- document.getElementById('toolStatusAircraft').style.display = (mode === 'aircraft') ? 'grid' : 'none';
-
- // Hide waterfall and output console for modes with their own visualizations
- document.querySelector('.waterfall-container').style.display =
- (mode === 'satellite' || mode === 'listening' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
- document.getElementById('output').style.display =
- (mode === 'satellite' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
- document.querySelector('.status-bar').style.display = (mode === 'satellite' || mode === 'tscm' || mode === 'meshtastic' || mode === 'aprs' || mode === 'spystations') ? 'none' : 'flex';
-
- // Load interfaces and initialize visualizations when switching modes
- if (mode === 'wifi') {
- if (typeof refreshWifiInterfaces === 'function') refreshWifiInterfaces();
- if (typeof initRadar === 'function') initRadar();
- if (typeof initWatchList === 'function') initWatchList();
- } else if (mode === 'bluetooth') {
- if (typeof refreshBtInterfaces === 'function') refreshBtInterfaces();
- if (typeof initBtRadar === 'function') initBtRadar();
- } else if (mode === 'aircraft') {
- if (typeof checkAdsbTools === 'function') checkAdsbTools();
- if (typeof initAircraftRadar === 'function') initAircraftRadar();
- } else if (mode === 'satellite') {
- if (typeof initPolarPlot === 'function') initPolarPlot();
- if (typeof initSatelliteList === 'function') initSatelliteList();
- } else if (mode === 'listening') {
- if (typeof checkScannerTools === 'function') checkScannerTools();
- if (typeof checkAudioTools === 'function') checkAudioTools();
- if (typeof populateScannerDeviceSelect === 'function') populateScannerDeviceSelect();
- if (typeof populateAudioDeviceSelect === 'function') populateAudioDeviceSelect();
- } else if (mode === 'meshtastic') {
- if (typeof Meshtastic !== 'undefined' && Meshtastic.init) Meshtastic.init();
- }
-}
-
-// ============== SECTION COLLAPSE ==============
-
-function toggleSection(el) {
- el.closest('.section').classList.toggle('collapsed');
-}
-
-// ============== THEME MANAGEMENT ==============
-
-function toggleTheme() {
- const html = document.documentElement;
- const currentTheme = html.getAttribute('data-theme');
- const newTheme = currentTheme === 'light' ? 'dark' : 'light';
- html.setAttribute('data-theme', newTheme);
- localStorage.setItem('theme', newTheme);
-
- // Update button text
- const btn = document.getElementById('themeToggle');
- if (btn) {
- btn.textContent = newTheme === 'light' ? '🌙' : '☀️';
- }
-}
-
-function loadTheme() {
- const savedTheme = localStorage.getItem('theme') || 'dark';
- document.documentElement.setAttribute('data-theme', savedTheme);
- const btn = document.getElementById('themeToggle');
- if (btn) {
- btn.textContent = savedTheme === 'light' ? '🌙' : '☀️';
- }
-}
-
-// ============== AUTO-SCROLL ==============
-
-function toggleAutoScroll() {
- autoScroll = !autoScroll;
- localStorage.setItem('autoScroll', autoScroll);
- updateAutoScrollButton();
-}
-
-function updateAutoScrollButton() {
- const btn = document.getElementById('autoScrollBtn');
- if (btn) {
- btn.innerHTML = autoScroll ? '⬇ AUTO-SCROLL ON' : '⬇ AUTO-SCROLL OFF';
- btn.classList.toggle('active', autoScroll);
- }
-}
-
-// ============== SDR DEVICE MANAGEMENT ==============
-
-function getSelectedDevice() {
- return document.getElementById('deviceSelect').value;
-}
-
-function getSelectedSDRType() {
- return document.getElementById('sdrTypeSelect').value;
-}
-
-function reserveDevice(deviceIndex, modeId) {
- sdrDeviceUsage[modeId] = deviceIndex;
-}
-
-function releaseDevice(modeId) {
- delete sdrDeviceUsage[modeId];
-}
-
-function checkDeviceAvailability(requestingMode) {
- const selectedDevice = parseInt(getSelectedDevice());
- for (const [mode, device] of Object.entries(sdrDeviceUsage)) {
- if (mode !== requestingMode && device === selectedDevice) {
- alert(`Device ${selectedDevice} is currently in use by ${mode} mode. Please select a different device or stop the other scan first.`);
- return false;
- }
- }
- return true;
-}
-
-// ============== BIAS-T SETTINGS ==============
-
-function saveBiasTSetting() {
- const enabled = document.getElementById('biasT')?.checked || false;
- localStorage.setItem('biasTEnabled', enabled);
-}
-
-function getBiasTEnabled() {
- return document.getElementById('biasT')?.checked || false;
-}
-
-function loadBiasTSetting() {
- const saved = localStorage.getItem('biasTEnabled');
- if (saved === 'true') {
- const checkbox = document.getElementById('biasT');
- if (checkbox) checkbox.checked = true;
- }
-}
-
-// ============== REMOTE SDR ==============
-
-function toggleRemoteSDR() {
- const useRemote = document.getElementById('useRemoteSDR').checked;
- const configDiv = document.getElementById('remoteSDRConfig');
- const localControls = document.querySelectorAll('#sdrTypeSelect, #deviceSelect');
-
- if (useRemote) {
- configDiv.style.display = 'block';
- localControls.forEach(el => el.disabled = true);
- } else {
- configDiv.style.display = 'none';
- localControls.forEach(el => el.disabled = false);
- }
-}
-
-function getRemoteSDRConfig() {
- const useRemote = document.getElementById('useRemoteSDR')?.checked;
- if (!useRemote) return null;
-
- const host = document.getElementById('rtlTcpHost')?.value || 'localhost';
- const port = parseInt(document.getElementById('rtlTcpPort')?.value || '1234');
-
- if (!host || isNaN(port)) {
- alert('Please enter valid rtl_tcp host and port');
- return false;
- }
-
- return { host, port };
-}
-
-// ============== OUTPUT DISPLAY ==============
-
-function showInfo(text) {
- const output = document.getElementById('output');
- if (!output) return;
-
- const placeholder = output.querySelector('.placeholder');
- if (placeholder) placeholder.remove();
-
- const infoEl = document.createElement('div');
- infoEl.className = 'info-msg';
- infoEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #0a0a0a; border: 1px solid #1a1a1a; border-left: 2px solid #00d4ff; font-family: "JetBrains Mono", monospace; font-size: 11px; color: #888; word-break: break-all;';
- infoEl.textContent = text;
- output.insertBefore(infoEl, output.firstChild);
-}
-
-function showError(text) {
- const output = document.getElementById('output');
- if (!output) return;
-
- const placeholder = output.querySelector('.placeholder');
- if (placeholder) placeholder.remove();
-
- const errorEl = document.createElement('div');
- errorEl.className = 'error-msg';
- errorEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #1a0a0a; border: 1px solid #2a1a1a; border-left: 2px solid #ff3366; font-family: "JetBrains Mono", monospace; font-size: 11px; color: #ff6688; word-break: break-all;';
- errorEl.textContent = '⚠ ' + text;
- output.insertBefore(errorEl, output.firstChild);
-}
-
-// ============== INITIALIZATION ==============
-
-// ============== MOBILE NAVIGATION ==============
-
-function initMobileNav() {
- const hamburgerBtn = document.getElementById('hamburgerBtn');
- const sidebar = document.getElementById('mainSidebar');
- const overlay = document.getElementById('drawerOverlay');
-
- if (!hamburgerBtn || !sidebar || !overlay) return;
-
- function openDrawer() {
- sidebar.classList.add('open');
- overlay.classList.add('visible');
- hamburgerBtn.classList.add('active');
- document.body.style.overflow = 'hidden';
- }
-
- function closeDrawer() {
- sidebar.classList.remove('open');
- overlay.classList.remove('visible');
- hamburgerBtn.classList.remove('active');
- document.body.style.overflow = '';
- }
-
- function toggleDrawer() {
- if (sidebar.classList.contains('open')) {
- closeDrawer();
- } else {
- openDrawer();
- }
- }
-
- hamburgerBtn.addEventListener('click', toggleDrawer);
- overlay.addEventListener('click', closeDrawer);
-
- // Close drawer when resizing to desktop
- window.addEventListener('resize', () => {
- if (window.innerWidth >= 1024) {
- closeDrawer();
- }
- });
-
- // Expose for external use
- window.toggleMobileDrawer = toggleDrawer;
- window.closeMobileDrawer = closeDrawer;
-}
-
-function setViewportHeight() {
- // Fix for iOS Safari address bar height
- const vh = window.innerHeight * 0.01;
- document.documentElement.style.setProperty('--vh', `${vh}px`);
-}
-
-function updateMobileNavButtons(mode) {
- // Update mobile nav bar buttons
- document.querySelectorAll('.mobile-nav-btn').forEach(btn => {
- const btnMode = btn.getAttribute('data-mode');
- btn.classList.toggle('active', btnMode === mode);
- });
-}
-
-function initApp() {
- // Check disclaimer
- checkDisclaimer();
-
- // Load theme
- loadTheme();
-
- // Start clock
- updateHeaderClock();
- setInterval(updateHeaderClock, 1000);
-
- // Load bias-T setting
- loadBiasTSetting();
-
- // Initialize observer location inputs
- const adsbLatInput = document.getElementById('adsbObsLat');
- const adsbLonInput = document.getElementById('adsbObsLon');
- const obsLatInput = document.getElementById('obsLat');
- const obsLonInput = document.getElementById('obsLon');
- if (adsbLatInput) adsbLatInput.value = observerLocation.lat.toFixed(4);
- if (adsbLonInput) adsbLonInput.value = observerLocation.lon.toFixed(4);
- if (obsLatInput) obsLatInput.value = observerLocation.lat.toFixed(4);
- if (obsLonInput) obsLonInput.value = observerLocation.lon.toFixed(4);
-
- // Update UI state
- updateAutoScrollButton();
-
- // Make sections collapsible
- document.querySelectorAll('.section h3').forEach(h3 => {
- h3.addEventListener('click', function() {
- this.parentElement.classList.toggle('collapsed');
- });
- });
-
- // Collapse all sections by default (except SDR Device which is first)
- document.querySelectorAll('.section').forEach((section, index) => {
- if (index > 0) {
- section.classList.add('collapsed');
- }
- });
-
- // Initialize mobile navigation
- initMobileNav();
-
- // Set viewport height for mobile browsers
- setViewportHeight();
- window.addEventListener('resize', setViewportHeight);
-}
-
-// Run initialization when DOM is ready
-document.addEventListener('DOMContentLoaded', initApp);
+
+// Message storage for export
+let allMessages = [];
+
+// Track unique sensor devices
+let uniqueDevices = new Set();
+
+// SDR device usage tracking
+let sdrDeviceUsage = {};
+
+// ============== DISCLAIMER HANDLING ==============
+
+function checkDisclaimer() {
+ const accepted = localStorage.getItem('disclaimerAccepted');
+ if (accepted === 'true') {
+ document.getElementById('disclaimerModal').classList.add('disclaimer-hidden');
+ }
+}
+
+function acceptDisclaimer() {
+ localStorage.setItem('disclaimerAccepted', 'true');
+ document.getElementById('disclaimerModal').classList.add('disclaimer-hidden');
+}
+
+function declineDisclaimer() {
+ document.getElementById('disclaimerModal').classList.add('disclaimer-hidden');
+ document.getElementById('rejectionPage').classList.remove('disclaimer-hidden');
+}
+
+// ============== HEADER CLOCK ==============
+
+function updateHeaderClock() {
+ const now = new Date();
+ const utc = now.toISOString().substring(11, 19);
+ document.getElementById('headerUtcTime').textContent = utc;
+}
+
+// ============== MODE SWITCHING ==============
+
+function switchMode(mode) {
+ // Stop any running scans when switching modes
+ if (isRunning && typeof stopDecoding === 'function') stopDecoding();
+ if (isSensorRunning && typeof stopSensorDecoding === 'function') stopSensorDecoding();
+ if (isWifiRunning && typeof stopWifiScan === 'function') stopWifiScan();
+ if (isBtRunning && typeof stopBtScan === 'function') stopBtScan();
+ if (isAdsbRunning && typeof stopAdsbScan === 'function') stopAdsbScan();
+
+ currentMode = mode;
+
+ // Remove active from all nav buttons, then add to the correct one
+ document.querySelectorAll('.mode-nav-btn').forEach(btn => btn.classList.remove('active'));
+ const modeMap = {
+ 'pager': 'pager', 'sensor': '433', 'aircraft': 'aircraft',
+ 'satellite': 'satellite', 'wifi': 'wifi', 'bluetooth': 'bluetooth',
+ 'listening': 'listening', 'meshtastic': 'meshtastic'
+ };
+ document.querySelectorAll('.mode-nav-btn').forEach(btn => {
+ const label = btn.querySelector('.nav-label');
+ if (label && label.textContent.toLowerCase().includes(modeMap[mode])) {
+ btn.classList.add('active');
+ }
+ });
+
+ // Toggle mode content visibility
+ document.getElementById('pagerMode').classList.toggle('active', mode === 'pager');
+ document.getElementById('sensorMode').classList.toggle('active', mode === 'sensor');
+ document.getElementById('aircraftMode')?.classList.toggle('active', mode === 'aircraft');
+ document.getElementById('satelliteMode').classList.toggle('active', mode === 'satellite');
+ document.getElementById('wifiMode').classList.toggle('active', mode === 'wifi');
+ document.getElementById('bluetoothMode').classList.toggle('active', mode === 'bluetooth');
+ document.getElementById('listeningPostMode').classList.toggle('active', mode === 'listening');
+ document.getElementById('aprsMode')?.classList.toggle('active', mode === 'aprs');
+ document.getElementById('tscmMode')?.classList.toggle('active', mode === 'tscm');
+ document.getElementById('rtlamrMode')?.classList.toggle('active', mode === 'rtlamr');
+ document.getElementById('spystationsMode')?.classList.toggle('active', mode === 'spystations');
+ document.getElementById('meshtasticMode')?.classList.toggle('active', mode === 'meshtastic');
+
+ // Toggle stats visibility
+ document.getElementById('pagerStats').style.display = mode === 'pager' ? 'flex' : 'none';
+ document.getElementById('sensorStats').style.display = mode === 'sensor' ? 'flex' : 'none';
+ document.getElementById('aircraftStats').style.display = mode === 'aircraft' ? 'flex' : 'none';
+ document.getElementById('satelliteStats').style.display = mode === 'satellite' ? 'flex' : 'none';
+ document.getElementById('wifiStats').style.display = mode === 'wifi' ? 'flex' : 'none';
+
+ // Hide signal meter - individual panels show signal strength where needed
+ document.getElementById('signalMeter').style.display = 'none';
+
+ // Show/hide dashboard buttons in nav bar
+ document.getElementById('adsbDashboardBtn').style.display = mode === 'aircraft' ? 'inline-flex' : 'none';
+ document.getElementById('satelliteDashboardBtn').style.display = mode === 'satellite' ? 'inline-flex' : 'none';
+
+ // Update active mode indicator
+ const modeNames = {
+ 'pager': 'PAGER',
+ 'sensor': '433MHZ',
+ 'aircraft': 'AIRCRAFT',
+ 'satellite': 'SATELLITE',
+ 'wifi': 'WIFI',
+ 'bluetooth': 'BLUETOOTH',
+ 'listening': 'LISTENING POST',
+ 'tscm': 'TSCM',
+ 'aprs': 'APRS',
+ 'meshtastic': 'MESHTASTIC'
+ };
+ document.getElementById('activeModeIndicator').innerHTML = '' + modeNames[mode];
+
+ // Update mobile nav buttons
+ updateMobileNavButtons(mode);
+
+ // Close mobile drawer when mode is switched (on mobile)
+ if (window.innerWidth < 1024 && typeof window.closeMobileDrawer === 'function') {
+ window.closeMobileDrawer();
+ }
+
+ // Toggle layout containers
+ document.getElementById('wifiLayoutContainer').style.display = mode === 'wifi' ? 'flex' : 'none';
+ document.getElementById('btLayoutContainer').style.display = mode === 'bluetooth' ? 'flex' : 'none';
+
+ // Respect the "Show Radar Display" checkbox for aircraft mode
+ const showRadar = document.getElementById('adsbEnableMap')?.checked;
+ document.getElementById('aircraftVisuals').style.display = (mode === 'aircraft' && showRadar) ? 'grid' : 'none';
+ document.getElementById('satelliteVisuals').style.display = mode === 'satellite' ? 'block' : 'none';
+ document.getElementById('listeningPostVisuals').style.display = mode === 'listening' ? 'grid' : 'none';
+
+ // Update output panel title based on mode
+ const titles = {
+ 'pager': 'Pager Decoder',
+ 'sensor': '433MHz Sensor Monitor',
+ 'aircraft': 'ADS-B Aircraft Tracker',
+ 'satellite': 'Satellite Monitor',
+ 'wifi': 'WiFi Scanner',
+ 'bluetooth': 'Bluetooth Scanner',
+ 'listening': 'Listening Post',
+ 'meshtastic': 'Meshtastic Mesh Monitor'
+ };
+ document.getElementById('outputTitle').textContent = titles[mode] || 'Signal Monitor';
+
+ // Show/hide Device Intelligence for modes that use it
+ const reconBtn = document.getElementById('reconBtn');
+ const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
+ if (mode === 'satellite' || mode === 'aircraft' || mode === 'listening') {
+ document.getElementById('reconPanel').style.display = 'none';
+ if (reconBtn) reconBtn.style.display = 'none';
+ if (intelBtn) intelBtn.style.display = 'none';
+ } else {
+ if (reconBtn) reconBtn.style.display = 'inline-block';
+ if (intelBtn) intelBtn.style.display = 'inline-block';
+ if (typeof reconEnabled !== 'undefined' && reconEnabled) {
+ document.getElementById('reconPanel').style.display = 'block';
+ }
+ }
+
+ // Show RTL-SDR device section for modes that use it
+ document.getElementById('rtlDeviceSection').style.display =
+ (mode === 'pager' || mode === 'sensor' || mode === 'aircraft' || mode === 'listening') ? 'block' : 'none';
+
+ // Toggle mode-specific tool status displays
+ document.getElementById('toolStatusPager').style.display = (mode === 'pager') ? 'grid' : 'none';
+ document.getElementById('toolStatusSensor').style.display = (mode === 'sensor') ? 'grid' : 'none';
+ document.getElementById('toolStatusAircraft').style.display = (mode === 'aircraft') ? 'grid' : 'none';
+
+ // Hide waterfall and output console for modes with their own visualizations
+ document.querySelector('.waterfall-container').style.display =
+ (mode === 'satellite' || mode === 'listening' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
+ document.getElementById('output').style.display =
+ (mode === 'satellite' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
+ document.querySelector('.status-bar').style.display = (mode === 'satellite' || mode === 'tscm' || mode === 'meshtastic' || mode === 'aprs' || mode === 'spystations') ? 'none' : 'flex';
+
+ // Load interfaces and initialize visualizations when switching modes
+ if (mode === 'wifi') {
+ if (typeof refreshWifiInterfaces === 'function') refreshWifiInterfaces();
+ if (typeof initRadar === 'function') initRadar();
+ if (typeof initWatchList === 'function') initWatchList();
+ } else if (mode === 'bluetooth') {
+ if (typeof refreshBtInterfaces === 'function') refreshBtInterfaces();
+ if (typeof initBtRadar === 'function') initBtRadar();
+ } else if (mode === 'aircraft') {
+ if (typeof checkAdsbTools === 'function') checkAdsbTools();
+ if (typeof initAircraftRadar === 'function') initAircraftRadar();
+ } else if (mode === 'satellite') {
+ if (typeof initPolarPlot === 'function') initPolarPlot();
+ if (typeof initSatelliteList === 'function') initSatelliteList();
+ } else if (mode === 'listening') {
+ if (typeof checkScannerTools === 'function') checkScannerTools();
+ if (typeof checkAudioTools === 'function') checkAudioTools();
+ if (typeof populateScannerDeviceSelect === 'function') populateScannerDeviceSelect();
+ if (typeof populateAudioDeviceSelect === 'function') populateAudioDeviceSelect();
+ } else if (mode === 'meshtastic') {
+ if (typeof Meshtastic !== 'undefined' && Meshtastic.init) Meshtastic.init();
+ }
+}
+
+// ============== SECTION COLLAPSE ==============
+
+function toggleSection(el) {
+ el.closest('.section').classList.toggle('collapsed');
+}
+
+// ============== THEME MANAGEMENT ==============
+
+function toggleTheme() {
+ const html = document.documentElement;
+ const currentTheme = html.getAttribute('data-theme');
+ const newTheme = currentTheme === 'light' ? 'dark' : 'light';
+ html.setAttribute('data-theme', newTheme);
+ localStorage.setItem('theme', newTheme);
+
+ // Update button text
+ const btn = document.getElementById('themeToggle');
+ if (btn) {
+ btn.textContent = newTheme === 'light' ? '🌙' : '☀️';
+ }
+}
+
+function loadTheme() {
+ const savedTheme = localStorage.getItem('theme') || 'dark';
+ document.documentElement.setAttribute('data-theme', savedTheme);
+ const btn = document.getElementById('themeToggle');
+ if (btn) {
+ btn.textContent = savedTheme === 'light' ? '🌙' : '☀️';
+ }
+}
+
+// ============== AUTO-SCROLL ==============
+
+function toggleAutoScroll() {
+ autoScroll = !autoScroll;
+ localStorage.setItem('autoScroll', autoScroll);
+ updateAutoScrollButton();
+}
+
+function updateAutoScrollButton() {
+ const btn = document.getElementById('autoScrollBtn');
+ if (btn) {
+ btn.innerHTML = autoScroll ? '⬇ AUTO-SCROLL ON' : '⬇ AUTO-SCROLL OFF';
+ btn.classList.toggle('active', autoScroll);
+ }
+}
+
+// ============== SDR DEVICE MANAGEMENT ==============
+
+function getSelectedDevice() {
+ return document.getElementById('deviceSelect').value;
+}
+
+function getSelectedSDRType() {
+ return document.getElementById('sdrTypeSelect').value;
+}
+
+function reserveDevice(deviceIndex, modeId) {
+ sdrDeviceUsage[modeId] = deviceIndex;
+}
+
+function releaseDevice(modeId) {
+ delete sdrDeviceUsage[modeId];
+}
+
+function checkDeviceAvailability(requestingMode) {
+ const selectedDevice = parseInt(getSelectedDevice());
+ for (const [mode, device] of Object.entries(sdrDeviceUsage)) {
+ if (mode !== requestingMode && device === selectedDevice) {
+ alert(`Device ${selectedDevice} is currently in use by ${mode} mode. Please select a different device or stop the other scan first.`);
+ return false;
+ }
+ }
+ return true;
+}
+
+// ============== BIAS-T SETTINGS ==============
+
+function saveBiasTSetting() {
+ const enabled = document.getElementById('biasT')?.checked || false;
+ localStorage.setItem('biasTEnabled', enabled);
+}
+
+function getBiasTEnabled() {
+ return document.getElementById('biasT')?.checked || false;
+}
+
+function loadBiasTSetting() {
+ const saved = localStorage.getItem('biasTEnabled');
+ if (saved === 'true') {
+ const checkbox = document.getElementById('biasT');
+ if (checkbox) checkbox.checked = true;
+ }
+}
+
+// ============== REMOTE SDR ==============
+
+function toggleRemoteSDR() {
+ const useRemote = document.getElementById('useRemoteSDR').checked;
+ const configDiv = document.getElementById('remoteSDRConfig');
+ const localControls = document.querySelectorAll('#sdrTypeSelect, #deviceSelect');
+
+ if (useRemote) {
+ configDiv.style.display = 'block';
+ localControls.forEach(el => el.disabled = true);
+ } else {
+ configDiv.style.display = 'none';
+ localControls.forEach(el => el.disabled = false);
+ }
+}
+
+function getRemoteSDRConfig() {
+ const useRemote = document.getElementById('useRemoteSDR')?.checked;
+ if (!useRemote) return null;
+
+ const host = document.getElementById('rtlTcpHost')?.value || 'localhost';
+ const port = parseInt(document.getElementById('rtlTcpPort')?.value || '1234');
+
+ if (!host || isNaN(port)) {
+ alert('Please enter valid rtl_tcp host and port');
+ return false;
+ }
+
+ return { host, port };
+}
+
+// ============== OUTPUT DISPLAY ==============
+
+function showInfo(text) {
+ const output = document.getElementById('output');
+ if (!output) return;
+
+ const placeholder = output.querySelector('.placeholder');
+ if (placeholder) placeholder.remove();
+
+ const infoEl = document.createElement('div');
+ infoEl.className = 'info-msg';
+ infoEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #0a0a0a; border: 1px solid #1a1a1a; border-left: 2px solid #00d4ff; font-family: "JetBrains Mono", monospace; font-size: 11px; color: #888; word-break: break-all;';
+ infoEl.textContent = text;
+ output.insertBefore(infoEl, output.firstChild);
+}
+
+function showError(text) {
+ const output = document.getElementById('output');
+ if (!output) return;
+
+ const placeholder = output.querySelector('.placeholder');
+ if (placeholder) placeholder.remove();
+
+ const errorEl = document.createElement('div');
+ errorEl.className = 'error-msg';
+ errorEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #1a0a0a; border: 1px solid #2a1a1a; border-left: 2px solid #ff3366; font-family: "JetBrains Mono", monospace; font-size: 11px; color: #ff6688; word-break: break-all;';
+ errorEl.textContent = '⚠ ' + text;
+ output.insertBefore(errorEl, output.firstChild);
+}
+
+// ============== INITIALIZATION ==============
+
+// ============== MOBILE NAVIGATION ==============
+
+function initMobileNav() {
+ const hamburgerBtn = document.getElementById('hamburgerBtn');
+ const sidebar = document.getElementById('mainSidebar');
+ const overlay = document.getElementById('drawerOverlay');
+
+ if (!hamburgerBtn || !sidebar || !overlay) return;
+
+ function openDrawer() {
+ sidebar.classList.add('open');
+ overlay.classList.add('visible');
+ hamburgerBtn.classList.add('active');
+ document.body.style.overflow = 'hidden';
+ }
+
+ function closeDrawer() {
+ sidebar.classList.remove('open');
+ overlay.classList.remove('visible');
+ hamburgerBtn.classList.remove('active');
+ document.body.style.overflow = '';
+ }
+
+ function toggleDrawer() {
+ if (sidebar.classList.contains('open')) {
+ closeDrawer();
+ } else {
+ openDrawer();
+ }
+ }
+
+ hamburgerBtn.addEventListener('click', toggleDrawer);
+ overlay.addEventListener('click', closeDrawer);
+
+ // Close drawer when resizing to desktop
+ window.addEventListener('resize', () => {
+ if (window.innerWidth >= 1024) {
+ closeDrawer();
+ }
+ });
+
+ // Expose for external use
+ window.toggleMobileDrawer = toggleDrawer;
+ window.closeMobileDrawer = closeDrawer;
+}
+
+function setViewportHeight() {
+ // Fix for iOS Safari address bar height
+ const vh = window.innerHeight * 0.01;
+ document.documentElement.style.setProperty('--vh', `${vh}px`);
+}
+
+function updateMobileNavButtons(mode) {
+ // Update mobile nav bar buttons
+ document.querySelectorAll('.mobile-nav-btn').forEach(btn => {
+ const btnMode = btn.getAttribute('data-mode');
+ btn.classList.toggle('active', btnMode === mode);
+ });
+}
+
+function initApp() {
+ // Check disclaimer
+ checkDisclaimer();
+
+ // Load theme
+ loadTheme();
+
+ // Start clock
+ updateHeaderClock();
+ setInterval(updateHeaderClock, 1000);
+
+ // Load bias-T setting
+ loadBiasTSetting();
+
+ // Initialize observer location inputs
+ const adsbLatInput = document.getElementById('adsbObsLat');
+ const adsbLonInput = document.getElementById('adsbObsLon');
+ const obsLatInput = document.getElementById('obsLat');
+ const obsLonInput = document.getElementById('obsLon');
+ if (adsbLatInput) adsbLatInput.value = observerLocation.lat.toFixed(4);
+ if (adsbLonInput) adsbLonInput.value = observerLocation.lon.toFixed(4);
+ if (obsLatInput) obsLatInput.value = observerLocation.lat.toFixed(4);
+ if (obsLonInput) obsLonInput.value = observerLocation.lon.toFixed(4);
+
+ // Update UI state
+ updateAutoScrollButton();
+
+ // Make sections collapsible
+ document.querySelectorAll('.section h3').forEach(h3 => {
+ h3.addEventListener('click', function() {
+ this.parentElement.classList.toggle('collapsed');
+ });
+ });
+
+ // Collapse all sections by default (except SDR Device which is first)
+ document.querySelectorAll('.section').forEach((section, index) => {
+ if (index > 0) {
+ section.classList.add('collapsed');
+ }
+ });
+
+ // Initialize mobile navigation
+ initMobileNav();
+
+ // Set viewport height for mobile browsers
+ setViewportHeight();
+ window.addEventListener('resize', setViewportHeight);
+}
+
+// Run initialization when DOM is ready
+document.addEventListener('DOMContentLoaded', initApp);
diff --git a/static/js/core/global-nav.js b/static/js/core/global-nav.js
new file mode 100644
index 0000000..280df7a
--- /dev/null
+++ b/static/js/core/global-nav.js
@@ -0,0 +1,48 @@
+(() => {
+ const dropdowns = Array.from(document.querySelectorAll('.mode-nav-dropdown'));
+ if (!dropdowns.length) return;
+
+ const closeAll = () => {
+ dropdowns.forEach((dropdown) => dropdown.classList.remove('open'));
+ };
+
+ const openDropdown = (dropdown) => {
+ if (!dropdown.classList.contains('open')) {
+ closeAll();
+ dropdown.classList.add('open');
+ }
+ };
+
+ document.addEventListener('click', (event) => {
+ const menuLink = event.target.closest('.mode-nav-dropdown-menu a');
+ if (menuLink) {
+ event.preventDefault();
+ event.stopPropagation();
+ window.location.href = menuLink.href;
+ return;
+ }
+
+ const button = event.target.closest('.mode-nav-dropdown-btn');
+ if (button) {
+ event.preventDefault();
+ const dropdown = button.closest('.mode-nav-dropdown');
+ if (!dropdown) return;
+ if (dropdown.classList.contains('open')) {
+ dropdown.classList.remove('open');
+ } else {
+ openDropdown(dropdown);
+ }
+ return;
+ }
+
+ if (!event.target.closest('.mode-nav-dropdown')) {
+ closeAll();
+ }
+ });
+
+ document.addEventListener('keydown', (event) => {
+ if (event.key === 'Escape') {
+ closeAll();
+ }
+ });
+})();
diff --git a/static/js/core/settings-manager.js b/static/js/core/settings-manager.js
index 7e3fbad..3941c2d 100644
--- a/static/js/core/settings-manager.js
+++ b/static/js/core/settings-manager.js
@@ -1,906 +1,906 @@
-/**
- * Settings Manager - Handles offline mode and application settings
- */
-
-const Settings = {
- // Default settings
- defaults: {
- 'offline.enabled': false,
- 'offline.assets_source': 'cdn',
- 'offline.fonts_source': 'cdn',
- 'offline.tile_provider': 'cartodb_dark',
- 'offline.tile_server_url': ''
- },
-
- // Tile provider configurations
- tileProviders: {
- openstreetmap: {
- url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
- attribution: '© OpenStreetMap contributors',
- subdomains: 'abc'
- },
- cartodb_dark: {
- url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png',
- attribution: '© OSM © CARTO',
- subdomains: 'abcd'
- },
- cartodb_light: {
- url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/light_all/{z}/{x}/{y}.png',
- attribution: '© OSM © CARTO',
- subdomains: 'abcd'
- },
- esri_world: {
- url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
- attribution: 'Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community',
- subdomains: null
- }
- },
-
- // Registry of maps that can be updated
- _registeredMaps: [],
-
- // Current settings cache
- _cache: {},
-
- /**
- * Initialize settings - load from server/localStorage
- */
- async init() {
- try {
- const response = await fetch('/offline/settings');
- if (response.ok) {
- const data = await response.json();
- this._cache = { ...this.defaults, ...data.settings };
- } else {
- // Fall back to localStorage
- this._loadFromLocalStorage();
- }
- } catch (e) {
- console.warn('Failed to load settings from server, using localStorage:', e);
- this._loadFromLocalStorage();
- }
-
- this._updateUI();
- return this._cache;
- },
-
- /**
- * Load settings from localStorage
- */
- _loadFromLocalStorage() {
- const stored = localStorage.getItem('intercept_settings');
- if (stored) {
- try {
- this._cache = { ...this.defaults, ...JSON.parse(stored) };
- } catch (e) {
- this._cache = { ...this.defaults };
- }
- } else {
- this._cache = { ...this.defaults };
- }
- },
-
- /**
- * Save a setting to server and localStorage
- */
- async _save(key, value) {
- this._cache[key] = value;
-
- // Save to localStorage as backup
- localStorage.setItem('intercept_settings', JSON.stringify(this._cache));
-
- // Save to server
- try {
- await fetch('/offline/settings', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ key, value })
- });
- } catch (e) {
- console.warn('Failed to save setting to server:', e);
- }
- },
-
- /**
- * Get a setting value
- */
- get(key) {
- return this._cache[key] ?? this.defaults[key];
- },
-
- /**
- * Toggle offline mode master switch
- */
- async toggleOfflineMode(enabled) {
- await this._save('offline.enabled', enabled);
-
- if (enabled) {
- // When enabling offline mode, also switch assets and fonts to local
- await this._save('offline.assets_source', 'local');
- await this._save('offline.fonts_source', 'local');
- }
-
- this._updateUI();
- this._showReloadPrompt();
- },
-
- /**
- * Set asset source (cdn or local)
- */
- async setAssetSource(source) {
- await this._save('offline.assets_source', source);
- this._showReloadPrompt();
- },
-
- /**
- * Set fonts source (cdn or local)
- */
- async setFontsSource(source) {
- await this._save('offline.fonts_source', source);
- this._showReloadPrompt();
- },
-
- /**
- * Set tile provider
- */
- async setTileProvider(provider) {
- await this._save('offline.tile_provider', provider);
-
- // Show/hide custom URL input
- const customRow = document.getElementById('customTileUrlRow');
- if (customRow) {
- customRow.style.display = provider === 'custom' ? 'block' : 'none';
- }
-
- // If not custom and we have a map, update tiles immediately
- if (provider !== 'custom') {
- this._updateMapTiles();
- }
- },
-
- /**
- * Set custom tile server URL
- */
- async setCustomTileUrl(url) {
- await this._save('offline.tile_server_url', url);
- this._updateMapTiles();
- },
-
- /**
- * Get current tile configuration
- */
- getTileConfig() {
- const provider = this.get('offline.tile_provider');
-
- if (provider === 'custom') {
- const customUrl = this.get('offline.tile_server_url');
- return {
- url: customUrl || 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
- attribution: 'Custom Tile Server',
- subdomains: 'abc'
- };
- }
-
- return this.tileProviders[provider] || this.tileProviders.cartodb_dark;
- },
-
- /**
- * Register a map to receive tile updates when settings change
- * @param {L.Map} map - Leaflet map instance
- */
- registerMap(map) {
- if (map && typeof map.eachLayer === 'function' && !this._registeredMaps.includes(map)) {
- this._registeredMaps.push(map);
- }
- },
-
- /**
- * Unregister a map
- * @param {L.Map} map - Leaflet map instance
- */
- unregisterMap(map) {
- const idx = this._registeredMaps.indexOf(map);
- if (idx > -1) {
- this._registeredMaps.splice(idx, 1);
- }
- },
-
- /**
- * Create a tile layer using current settings
- * @returns {L.TileLayer} Configured tile layer
- */
- createTileLayer() {
- const config = this.getTileConfig();
- const options = {
- attribution: config.attribution,
- maxZoom: 19
- };
- if (config.subdomains) {
- options.subdomains = config.subdomains;
- }
- return L.tileLayer(config.url, options);
- },
-
- /**
- * Check if local assets are available
- */
- async checkAssets() {
- const assets = {
- leaflet: [
- '/static/vendor/leaflet/leaflet.js',
- '/static/vendor/leaflet/leaflet.css'
- ],
- chartjs: [
- '/static/vendor/chartjs/chart.umd.min.js'
- ],
- inter: [
- '/static/vendor/fonts/Inter-Regular.woff2'
- ],
- jetbrains: [
- '/static/vendor/fonts/JetBrainsMono-Regular.woff2'
- ]
- };
-
- const results = {};
-
- for (const [name, urls] of Object.entries(assets)) {
- const statusEl = document.getElementById(`status${name.charAt(0).toUpperCase() + name.slice(1)}`);
- if (statusEl) {
- statusEl.textContent = 'Checking...';
- statusEl.className = 'asset-badge checking';
- }
-
- let available = true;
- for (const url of urls) {
- try {
- const response = await fetch(url, { method: 'HEAD' });
- if (!response.ok) {
- available = false;
- break;
- }
- } catch (e) {
- available = false;
- break;
- }
- }
-
- results[name] = available;
-
- if (statusEl) {
- statusEl.textContent = available ? 'Available' : 'Missing';
- statusEl.className = `asset-badge ${available ? 'available' : 'missing'}`;
- }
- }
-
- return results;
- },
-
- /**
- * Update UI elements to reflect current settings
- */
- _updateUI() {
- // Offline mode toggle
- const offlineEnabled = document.getElementById('offlineEnabled');
- if (offlineEnabled) {
- offlineEnabled.checked = this.get('offline.enabled');
- }
-
- // Assets source
- const assetsSource = document.getElementById('assetsSource');
- if (assetsSource) {
- assetsSource.value = this.get('offline.assets_source');
- }
-
- // Fonts source
- const fontsSource = document.getElementById('fontsSource');
- if (fontsSource) {
- fontsSource.value = this.get('offline.fonts_source');
- }
-
- // Tile provider
- const tileProvider = document.getElementById('tileProvider');
- if (tileProvider) {
- tileProvider.value = this.get('offline.tile_provider');
- }
-
- // Custom tile URL
- const customTileUrl = document.getElementById('customTileUrl');
- if (customTileUrl) {
- customTileUrl.value = this.get('offline.tile_server_url') || '';
- }
-
- // Show/hide custom URL row
- const customRow = document.getElementById('customTileUrlRow');
- if (customRow) {
- customRow.style.display = this.get('offline.tile_provider') === 'custom' ? 'block' : 'none';
- }
- },
-
- /**
- * Update map tiles on all known maps
- */
- _updateMapTiles() {
- // Combine registered maps with common window map variables
- const windowMaps = [
- window.map,
- window.leafletMap,
- window.aprsMap,
- window.adsbMap,
- window.radarMap,
- window.vesselMap,
- window.groundMap,
- window.groundTrackMap,
- window.meshMap
- ].filter(m => m && typeof m.eachLayer === 'function');
-
- // Combine with registered maps, removing duplicates
- const allMaps = [...new Set([...this._registeredMaps, ...windowMaps])];
-
- if (allMaps.length === 0) return;
-
- const config = this.getTileConfig();
-
- allMaps.forEach(map => {
- // Remove existing tile layers
- map.eachLayer(layer => {
- if (layer instanceof L.TileLayer) {
- map.removeLayer(layer);
- }
- });
-
- // Add new tile layer
- const options = {
- attribution: config.attribution,
- maxZoom: 19
- };
- if (config.subdomains) {
- options.subdomains = config.subdomains;
- }
-
- L.tileLayer(config.url, options).addTo(map);
- });
- },
-
- /**
- * Show reload prompt
- */
- _showReloadPrompt() {
- // Create or update reload prompt
- let prompt = document.getElementById('settingsReloadPrompt');
- if (!prompt) {
- prompt = document.createElement('div');
- prompt.id = 'settingsReloadPrompt';
- prompt.style.cssText = `
- position: fixed;
- bottom: 20px;
- right: 20px;
- background: var(--bg-dark, #0a0a0f);
- border: 1px solid var(--accent-cyan, #00d4ff);
- border-radius: 8px;
- padding: 12px 16px;
- display: flex;
- align-items: center;
- gap: 12px;
- z-index: 10001;
- box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
- `;
- prompt.innerHTML = `
-
- Reload to apply changes
-
-
-
- `;
- document.body.appendChild(prompt);
- }
- }
-};
-
-// Settings modal functions
-function showSettings() {
- const modal = document.getElementById('settingsModal');
- if (modal) {
- modal.classList.add('active');
- Settings.init().then(() => {
- Settings.checkAssets();
- });
- }
-}
-
-function hideSettings() {
- const modal = document.getElementById('settingsModal');
- if (modal) {
- modal.classList.remove('active');
- }
-}
-
-function switchSettingsTab(tabName) {
- // Update tab buttons
- document.querySelectorAll('.settings-tab').forEach(tab => {
- tab.classList.toggle('active', tab.dataset.tab === tabName);
- });
-
- // Update sections
- document.querySelectorAll('.settings-section').forEach(section => {
- section.classList.toggle('active', section.id === `settings-${tabName}`);
- });
-
- // Load tools/dependencies when that tab is selected
- if (tabName === 'tools') {
- loadSettingsTools();
- }
-}
-
-/**
- * Load tool dependencies into settings modal
- */
-function loadSettingsTools() {
- const content = document.getElementById('settingsToolsContent');
- if (!content) return;
-
- content.innerHTML = 'Loading dependencies...
';
-
- fetch('/dependencies')
- .then(r => r.json())
- .then(data => {
- if (data.status !== 'success') {
- content.innerHTML = 'Error loading dependencies
';
- return;
- }
-
- let html = '';
- let totalMissing = 0;
-
- for (const [modeKey, mode] of Object.entries(data.modes)) {
- const statusColor = mode.ready ? 'var(--accent-green)' : 'var(--accent-red)';
- const statusIcon = mode.ready ? '✓' : '✗';
-
- html += `
-
-
- ${mode.name}
- ${statusIcon} ${mode.ready ? 'Ready' : 'Missing'}
-
-
- `;
-
- for (const [toolName, tool] of Object.entries(mode.tools)) {
- const installed = tool.installed;
- const dotColor = installed ? 'var(--accent-green)' : 'var(--accent-red)';
- const requiredBadge = tool.required ? '
REQ' : '';
-
- if (!installed) totalMissing++;
-
- let installCmd = '';
- if (tool.install) {
- if (tool.install.pip) {
- installCmd = tool.install.pip;
- } else if (data.pkg_manager && tool.install[data.pkg_manager]) {
- installCmd = tool.install[data.pkg_manager];
- } else if (tool.install.manual) {
- installCmd = tool.install.manual;
- }
- }
-
- html += `
-
-
●
-
-
${toolName}${requiredBadge}
-
${tool.description}
-
- ${!installed && installCmd ? `
-
${installCmd}
- ` : ''}
-
${installed ? 'OK' : 'MISSING'}
-
- `;
- }
-
- html += '
';
- }
-
- // Summary at top
- const summaryHtml = `
-
-
- ${totalMissing > 0 ? '⚠️ ' + totalMissing + ' tool(s) not found' : '✓ All tools installed'}
-
-
- OS: ${data.os} | Package Manager: ${data.pkg_manager}
-
-
- `;
-
- content.innerHTML = summaryHtml + html;
- })
- .catch(err => {
- content.innerHTML = 'Error loading dependencies: ' + err.message + '
';
- });
-}
-
-// Initialize settings on page load
-document.addEventListener('DOMContentLoaded', () => {
- Settings.init();
-});
-
-// =============================================================================
-// Location Settings Functions
-// =============================================================================
-
-/**
- * Load and display current observer location
- */
-function loadObserverLocation() {
- let lat = localStorage.getItem('observerLat');
- let lon = localStorage.getItem('observerLon');
- if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
- const shared = ObserverLocation.getShared();
- lat = shared.lat.toString();
- lon = shared.lon.toString();
- }
-
- const latInput = document.getElementById('observerLatInput');
- const lonInput = document.getElementById('observerLonInput');
- const currentLatDisplay = document.getElementById('currentLatDisplay');
- const currentLonDisplay = document.getElementById('currentLonDisplay');
-
- if (latInput && lat) latInput.value = lat;
- if (lonInput && lon) lonInput.value = lon;
-
- if (currentLatDisplay) {
- currentLatDisplay.textContent = lat ? parseFloat(lat).toFixed(4) + '°' : 'Not set';
- }
- if (currentLonDisplay) {
- currentLonDisplay.textContent = lon ? parseFloat(lon).toFixed(4) + '°' : 'Not set';
- }
-
- // Sync dashboard-specific location keys for backward compatibility
- if (lat && lon) {
- const locationObj = JSON.stringify({ lat: parseFloat(lat), lon: parseFloat(lon) });
- if (!localStorage.getItem('observerLocation')) {
- localStorage.setItem('observerLocation', locationObj);
- }
- if (!localStorage.getItem('ais_observerLocation')) {
- localStorage.setItem('ais_observerLocation', locationObj);
- }
- }
-}
-
-/**
- * Detect location using gpsd (USB GPS) or browser geolocation as fallback
- */
-function detectLocationGPS(btn) {
- const latInput = document.getElementById('observerLatInput');
- const lonInput = document.getElementById('observerLonInput');
-
- // Show loading state with visual feedback
- const originalText = btn.innerHTML;
- btn.innerHTML = ' Detecting...';
- btn.disabled = true;
- btn.style.opacity = '0.7';
-
- // Helper to restore button state
- function restoreButton() {
- btn.innerHTML = originalText;
- btn.disabled = false;
- btn.style.opacity = '';
- }
-
- // Helper to set location values
- function setLocation(lat, lon, source) {
- if (latInput) latInput.value = parseFloat(lat).toFixed(4);
- if (lonInput) lonInput.value = parseFloat(lon).toFixed(4);
- restoreButton();
- if (typeof showNotification === 'function') {
- showNotification('Location', `Coordinates set from ${source}`);
- }
- }
-
- // First, try gpsd (USB GPS device)
- fetch('/gps/position')
- .then(response => response.json())
- .then(data => {
- if (data.status === 'ok' && data.position && data.position.latitude != null) {
- // Got valid position from gpsd
- setLocation(data.position.latitude, data.position.longitude, 'GPS device');
- } else if (data.status === 'waiting') {
- // gpsd connected but no fix yet - show message and try browser
- if (typeof showNotification === 'function') {
- showNotification('GPS', 'GPS device connected but no fix yet. Trying browser location...');
- }
- useBrowserGeolocation();
- } else {
- // gpsd not available, try browser geolocation
- useBrowserGeolocation();
- }
- })
- .catch(() => {
- // gpsd request failed, try browser geolocation
- useBrowserGeolocation();
- });
-
- // Fallback to browser geolocation
- function useBrowserGeolocation() {
- if (!navigator.geolocation) {
- restoreButton();
- if (typeof showNotification === 'function') {
- showNotification('Location', 'No GPS available (gpsd not running, browser GPS unavailable)');
- } else {
- alert('No GPS available');
- }
- return;
- }
-
- navigator.geolocation.getCurrentPosition(
- (pos) => {
- setLocation(pos.coords.latitude, pos.coords.longitude, 'browser');
- },
- (err) => {
- restoreButton();
- let message = 'Failed to get location';
- if (err.code === 1) message = 'Location access denied';
- else if (err.code === 2) message = 'Location unavailable';
- else if (err.code === 3) message = 'Location request timed out';
-
- if (typeof showNotification === 'function') {
- showNotification('Location', message);
- } else {
- alert(message);
- }
- },
- { enableHighAccuracy: true, timeout: 10000 }
- );
- }
-}
-
-/**
- * Save observer location to localStorage
- */
-function saveObserverLocation() {
- const latInput = document.getElementById('observerLatInput');
- const lonInput = document.getElementById('observerLonInput');
-
- const lat = parseFloat(latInput?.value);
- const lon = parseFloat(lonInput?.value);
-
- if (isNaN(lat) || lat < -90 || lat > 90) {
- if (typeof showNotification === 'function') {
- showNotification('Location', 'Invalid latitude (must be -90 to 90)');
- } else {
- alert('Invalid latitude (must be -90 to 90)');
- }
- return;
- }
-
- if (isNaN(lon) || lon < -180 || lon > 180) {
- if (typeof showNotification === 'function') {
- showNotification('Location', 'Invalid longitude (must be -180 to 180)');
- } else {
- alert('Invalid longitude (must be -180 to 180)');
- }
- return;
- }
-
- if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
- ObserverLocation.setShared({ lat, lon });
- } else {
- localStorage.setItem('observerLat', lat.toString());
- localStorage.setItem('observerLon', lon.toString());
- }
-
- // Also update dashboard-specific location keys for ADS-B and AIS
- const locationObj = JSON.stringify({ lat: lat, lon: lon });
- localStorage.setItem('observerLocation', locationObj); // ADS-B dashboard
- localStorage.setItem('ais_observerLocation', locationObj); // AIS dashboard
-
- // Update display
- const currentLatDisplay = document.getElementById('currentLatDisplay');
- const currentLonDisplay = document.getElementById('currentLonDisplay');
- if (currentLatDisplay) currentLatDisplay.textContent = lat.toFixed(4) + '°';
- if (currentLonDisplay) currentLonDisplay.textContent = lon.toFixed(4) + '°';
-
- if (typeof showNotification === 'function') {
- showNotification('Location', 'Observer location saved');
- }
-
- if (window.observerLocation) {
- window.observerLocation.lat = lat;
- window.observerLocation.lon = lon;
- }
-
- // Refresh SSTV ISS schedule if available
- if (typeof SSTV !== 'undefined' && typeof SSTV.loadIssSchedule === 'function') {
- SSTV.loadIssSchedule();
- }
-}
-
-// =============================================================================
-// Update Settings Functions
-// =============================================================================
-
-/**
- * Check for updates manually from settings panel
- */
-async function checkForUpdatesManual() {
- const content = document.getElementById('updateStatusContent');
- if (!content) return;
-
- content.innerHTML = 'Checking for updates...
';
-
- try {
- const data = await Updater.checkNow();
- renderUpdateStatus(data);
- } catch (error) {
- content.innerHTML = `Error checking for updates: ${error.message}
`;
- }
-}
-
-/**
- * Load update status when tab is opened
- */
-async function loadUpdateStatus() {
- const content = document.getElementById('updateStatusContent');
- if (!content) return;
-
- try {
- const data = await Updater.getStatus();
- renderUpdateStatus(data);
- } catch (error) {
- content.innerHTML = `Error loading update status: ${error.message}
`;
- }
-}
-
-/**
- * Render update status in settings panel
- */
-function renderUpdateStatus(data) {
- const content = document.getElementById('updateStatusContent');
- if (!content) return;
-
- if (!data.success) {
- content.innerHTML = `Error: ${data.error || 'Unknown error'}
`;
- return;
- }
-
- if (data.disabled) {
- content.innerHTML = `
-
-
Update checking is disabled
-
- `;
- return;
- }
-
- if (!data.checked) {
- content.innerHTML = `
-
-
No update check performed yet
-
Click "Check Now" to check for updates
-
- `;
- return;
- }
-
- const statusColor = data.update_available ? 'var(--accent-green)' : 'var(--text-dim)';
- const statusText = data.update_available ? 'Update Available' : 'Up to Date';
- const statusIcon = data.update_available
- ? ''
- : '';
-
- let html = `
-
-
- ${statusIcon}
- ${statusText}
-
-
-
- Current Version
- v${data.current_version}
-
-
- Latest Version
- v${data.latest_version}
-
- ${data.last_check ? `
-
- Last Checked
- ${formatLastCheck(data.last_check)}
-
- ` : ''}
-
- ${data.update_available ? `
-
- ` : ''}
-
- `;
-
- content.innerHTML = html;
-}
-
-/**
- * Format last check timestamp
- */
-function formatLastCheck(isoString) {
- try {
- const date = new Date(isoString);
- const now = new Date();
- const diffMs = now - date;
- const diffMins = Math.floor(diffMs / 60000);
- const diffHours = Math.floor(diffMs / 3600000);
-
- if (diffMins < 1) return 'Just now';
- if (diffMins < 60) return `${diffMins} min ago`;
- if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
- return date.toLocaleDateString();
- } catch (e) {
- return isoString;
- }
-}
-
-/**
- * Toggle update checking
- */
-async function toggleUpdateCheck(enabled) {
- // This would require adding a setting to disable update checks
- // For now, just store in localStorage
- localStorage.setItem('intercept_update_check_enabled', enabled ? 'true' : 'false');
-
- if (!enabled && typeof Updater !== 'undefined') {
- Updater.destroy();
- } else if (enabled && typeof Updater !== 'undefined') {
- Updater.init();
- }
-}
-
-// Extend switchSettingsTab to load update status
-const _originalSwitchSettingsTab = typeof switchSettingsTab !== 'undefined' ? switchSettingsTab : null;
-
-function switchSettingsTab(tabName) {
- // Update tab buttons
- document.querySelectorAll('.settings-tab').forEach(tab => {
- tab.classList.toggle('active', tab.dataset.tab === tabName);
- });
-
- // Update sections
- document.querySelectorAll('.settings-section').forEach(section => {
- section.classList.toggle('active', section.id === `settings-${tabName}`);
- });
-
- // Load content based on tab
- if (tabName === 'tools') {
- loadSettingsTools();
- } else if (tabName === 'updates') {
- loadUpdateStatus();
- } else if (tabName === 'location') {
- loadObserverLocation();
- }
-}
+/**
+ * Settings Manager - Handles offline mode and application settings
+ */
+
+const Settings = {
+ // Default settings
+ defaults: {
+ 'offline.enabled': false,
+ 'offline.assets_source': 'cdn',
+ 'offline.fonts_source': 'cdn',
+ 'offline.tile_provider': 'cartodb_dark',
+ 'offline.tile_server_url': ''
+ },
+
+ // Tile provider configurations
+ tileProviders: {
+ openstreetmap: {
+ url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
+ attribution: '© OpenStreetMap contributors',
+ subdomains: 'abc'
+ },
+ cartodb_dark: {
+ url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png',
+ attribution: '© OSM © CARTO',
+ subdomains: 'abcd'
+ },
+ cartodb_light: {
+ url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/light_all/{z}/{x}/{y}.png',
+ attribution: '© OSM © CARTO',
+ subdomains: 'abcd'
+ },
+ esri_world: {
+ url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
+ attribution: 'Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community',
+ subdomains: null
+ }
+ },
+
+ // Registry of maps that can be updated
+ _registeredMaps: [],
+
+ // Current settings cache
+ _cache: {},
+
+ /**
+ * Initialize settings - load from server/localStorage
+ */
+ async init() {
+ try {
+ const response = await fetch('/offline/settings');
+ if (response.ok) {
+ const data = await response.json();
+ this._cache = { ...this.defaults, ...data.settings };
+ } else {
+ // Fall back to localStorage
+ this._loadFromLocalStorage();
+ }
+ } catch (e) {
+ console.warn('Failed to load settings from server, using localStorage:', e);
+ this._loadFromLocalStorage();
+ }
+
+ this._updateUI();
+ return this._cache;
+ },
+
+ /**
+ * Load settings from localStorage
+ */
+ _loadFromLocalStorage() {
+ const stored = localStorage.getItem('intercept_settings');
+ if (stored) {
+ try {
+ this._cache = { ...this.defaults, ...JSON.parse(stored) };
+ } catch (e) {
+ this._cache = { ...this.defaults };
+ }
+ } else {
+ this._cache = { ...this.defaults };
+ }
+ },
+
+ /**
+ * Save a setting to server and localStorage
+ */
+ async _save(key, value) {
+ this._cache[key] = value;
+
+ // Save to localStorage as backup
+ localStorage.setItem('intercept_settings', JSON.stringify(this._cache));
+
+ // Save to server
+ try {
+ await fetch('/offline/settings', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ key, value })
+ });
+ } catch (e) {
+ console.warn('Failed to save setting to server:', e);
+ }
+ },
+
+ /**
+ * Get a setting value
+ */
+ get(key) {
+ return this._cache[key] ?? this.defaults[key];
+ },
+
+ /**
+ * Toggle offline mode master switch
+ */
+ async toggleOfflineMode(enabled) {
+ await this._save('offline.enabled', enabled);
+
+ if (enabled) {
+ // When enabling offline mode, also switch assets and fonts to local
+ await this._save('offline.assets_source', 'local');
+ await this._save('offline.fonts_source', 'local');
+ }
+
+ this._updateUI();
+ this._showReloadPrompt();
+ },
+
+ /**
+ * Set asset source (cdn or local)
+ */
+ async setAssetSource(source) {
+ await this._save('offline.assets_source', source);
+ this._showReloadPrompt();
+ },
+
+ /**
+ * Set fonts source (cdn or local)
+ */
+ async setFontsSource(source) {
+ await this._save('offline.fonts_source', source);
+ this._showReloadPrompt();
+ },
+
+ /**
+ * Set tile provider
+ */
+ async setTileProvider(provider) {
+ await this._save('offline.tile_provider', provider);
+
+ // Show/hide custom URL input
+ const customRow = document.getElementById('customTileUrlRow');
+ if (customRow) {
+ customRow.style.display = provider === 'custom' ? 'block' : 'none';
+ }
+
+ // If not custom and we have a map, update tiles immediately
+ if (provider !== 'custom') {
+ this._updateMapTiles();
+ }
+ },
+
+ /**
+ * Set custom tile server URL
+ */
+ async setCustomTileUrl(url) {
+ await this._save('offline.tile_server_url', url);
+ this._updateMapTiles();
+ },
+
+ /**
+ * Get current tile configuration
+ */
+ getTileConfig() {
+ const provider = this.get('offline.tile_provider');
+
+ if (provider === 'custom') {
+ const customUrl = this.get('offline.tile_server_url');
+ return {
+ url: customUrl || 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
+ attribution: 'Custom Tile Server',
+ subdomains: 'abc'
+ };
+ }
+
+ return this.tileProviders[provider] || this.tileProviders.cartodb_dark;
+ },
+
+ /**
+ * Register a map to receive tile updates when settings change
+ * @param {L.Map} map - Leaflet map instance
+ */
+ registerMap(map) {
+ if (map && typeof map.eachLayer === 'function' && !this._registeredMaps.includes(map)) {
+ this._registeredMaps.push(map);
+ }
+ },
+
+ /**
+ * Unregister a map
+ * @param {L.Map} map - Leaflet map instance
+ */
+ unregisterMap(map) {
+ const idx = this._registeredMaps.indexOf(map);
+ if (idx > -1) {
+ this._registeredMaps.splice(idx, 1);
+ }
+ },
+
+ /**
+ * Create a tile layer using current settings
+ * @returns {L.TileLayer} Configured tile layer
+ */
+ createTileLayer() {
+ const config = this.getTileConfig();
+ const options = {
+ attribution: config.attribution,
+ maxZoom: 19
+ };
+ if (config.subdomains) {
+ options.subdomains = config.subdomains;
+ }
+ return L.tileLayer(config.url, options);
+ },
+
+ /**
+ * Check if local assets are available
+ */
+ async checkAssets() {
+ const assets = {
+ leaflet: [
+ '/static/vendor/leaflet/leaflet.js',
+ '/static/vendor/leaflet/leaflet.css'
+ ],
+ chartjs: [
+ '/static/vendor/chartjs/chart.umd.min.js'
+ ],
+ inter: [
+ '/static/vendor/fonts/Inter-Regular.woff2'
+ ],
+ jetbrains: [
+ '/static/vendor/fonts/JetBrainsMono-Regular.woff2'
+ ]
+ };
+
+ const results = {};
+
+ for (const [name, urls] of Object.entries(assets)) {
+ const statusEl = document.getElementById(`status${name.charAt(0).toUpperCase() + name.slice(1)}`);
+ if (statusEl) {
+ statusEl.textContent = 'Checking...';
+ statusEl.className = 'asset-badge checking';
+ }
+
+ let available = true;
+ for (const url of urls) {
+ try {
+ const response = await fetch(url, { method: 'HEAD' });
+ if (!response.ok) {
+ available = false;
+ break;
+ }
+ } catch (e) {
+ available = false;
+ break;
+ }
+ }
+
+ results[name] = available;
+
+ if (statusEl) {
+ statusEl.textContent = available ? 'Available' : 'Missing';
+ statusEl.className = `asset-badge ${available ? 'available' : 'missing'}`;
+ }
+ }
+
+ return results;
+ },
+
+ /**
+ * Update UI elements to reflect current settings
+ */
+ _updateUI() {
+ // Offline mode toggle
+ const offlineEnabled = document.getElementById('offlineEnabled');
+ if (offlineEnabled) {
+ offlineEnabled.checked = this.get('offline.enabled');
+ }
+
+ // Assets source
+ const assetsSource = document.getElementById('assetsSource');
+ if (assetsSource) {
+ assetsSource.value = this.get('offline.assets_source');
+ }
+
+ // Fonts source
+ const fontsSource = document.getElementById('fontsSource');
+ if (fontsSource) {
+ fontsSource.value = this.get('offline.fonts_source');
+ }
+
+ // Tile provider
+ const tileProvider = document.getElementById('tileProvider');
+ if (tileProvider) {
+ tileProvider.value = this.get('offline.tile_provider');
+ }
+
+ // Custom tile URL
+ const customTileUrl = document.getElementById('customTileUrl');
+ if (customTileUrl) {
+ customTileUrl.value = this.get('offline.tile_server_url') || '';
+ }
+
+ // Show/hide custom URL row
+ const customRow = document.getElementById('customTileUrlRow');
+ if (customRow) {
+ customRow.style.display = this.get('offline.tile_provider') === 'custom' ? 'block' : 'none';
+ }
+ },
+
+ /**
+ * Update map tiles on all known maps
+ */
+ _updateMapTiles() {
+ // Combine registered maps with common window map variables
+ const windowMaps = [
+ window.map,
+ window.leafletMap,
+ window.aprsMap,
+ window.adsbMap,
+ window.radarMap,
+ window.vesselMap,
+ window.groundMap,
+ window.groundTrackMap,
+ window.meshMap
+ ].filter(m => m && typeof m.eachLayer === 'function');
+
+ // Combine with registered maps, removing duplicates
+ const allMaps = [...new Set([...this._registeredMaps, ...windowMaps])];
+
+ if (allMaps.length === 0) return;
+
+ const config = this.getTileConfig();
+
+ allMaps.forEach(map => {
+ // Remove existing tile layers
+ map.eachLayer(layer => {
+ if (layer instanceof L.TileLayer) {
+ map.removeLayer(layer);
+ }
+ });
+
+ // Add new tile layer
+ const options = {
+ attribution: config.attribution,
+ maxZoom: 19
+ };
+ if (config.subdomains) {
+ options.subdomains = config.subdomains;
+ }
+
+ L.tileLayer(config.url, options).addTo(map);
+ });
+ },
+
+ /**
+ * Show reload prompt
+ */
+ _showReloadPrompt() {
+ // Create or update reload prompt
+ let prompt = document.getElementById('settingsReloadPrompt');
+ if (!prompt) {
+ prompt = document.createElement('div');
+ prompt.id = 'settingsReloadPrompt';
+ prompt.style.cssText = `
+ position: fixed;
+ bottom: 20px;
+ right: 20px;
+ background: var(--bg-dark, #0a0a0f);
+ border: 1px solid var(--accent-cyan, #00d4ff);
+ border-radius: 8px;
+ padding: 12px 16px;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ z-index: 10001;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
+ `;
+ prompt.innerHTML = `
+
+ Reload to apply changes
+
+
+
+ `;
+ document.body.appendChild(prompt);
+ }
+ }
+};
+
+// Settings modal functions
+function showSettings() {
+ const modal = document.getElementById('settingsModal');
+ if (modal) {
+ modal.classList.add('active');
+ Settings.init().then(() => {
+ Settings.checkAssets();
+ });
+ }
+}
+
+function hideSettings() {
+ const modal = document.getElementById('settingsModal');
+ if (modal) {
+ modal.classList.remove('active');
+ }
+}
+
+function switchSettingsTab(tabName) {
+ // Update tab buttons
+ document.querySelectorAll('.settings-tab').forEach(tab => {
+ tab.classList.toggle('active', tab.dataset.tab === tabName);
+ });
+
+ // Update sections
+ document.querySelectorAll('.settings-section').forEach(section => {
+ section.classList.toggle('active', section.id === `settings-${tabName}`);
+ });
+
+ // Load tools/dependencies when that tab is selected
+ if (tabName === 'tools') {
+ loadSettingsTools();
+ }
+}
+
+/**
+ * Load tool dependencies into settings modal
+ */
+function loadSettingsTools() {
+ const content = document.getElementById('settingsToolsContent');
+ if (!content) return;
+
+ content.innerHTML = 'Loading dependencies...
';
+
+ fetch('/dependencies')
+ .then(r => r.json())
+ .then(data => {
+ if (data.status !== 'success') {
+ content.innerHTML = 'Error loading dependencies
';
+ return;
+ }
+
+ let html = '';
+ let totalMissing = 0;
+
+ for (const [modeKey, mode] of Object.entries(data.modes)) {
+ const statusColor = mode.ready ? 'var(--accent-green)' : 'var(--accent-red)';
+ const statusIcon = mode.ready ? '✓' : '✗';
+
+ html += `
+
+
+ ${mode.name}
+ ${statusIcon} ${mode.ready ? 'Ready' : 'Missing'}
+
+
+ `;
+
+ for (const [toolName, tool] of Object.entries(mode.tools)) {
+ const installed = tool.installed;
+ const dotColor = installed ? 'var(--accent-green)' : 'var(--accent-red)';
+ const requiredBadge = tool.required ? '
REQ' : '';
+
+ if (!installed) totalMissing++;
+
+ let installCmd = '';
+ if (tool.install) {
+ if (tool.install.pip) {
+ installCmd = tool.install.pip;
+ } else if (data.pkg_manager && tool.install[data.pkg_manager]) {
+ installCmd = tool.install[data.pkg_manager];
+ } else if (tool.install.manual) {
+ installCmd = tool.install.manual;
+ }
+ }
+
+ html += `
+
+
●
+
+
${toolName}${requiredBadge}
+
${tool.description}
+
+ ${!installed && installCmd ? `
+
${installCmd}
+ ` : ''}
+
${installed ? 'OK' : 'MISSING'}
+
+ `;
+ }
+
+ html += '
';
+ }
+
+ // Summary at top
+ const summaryHtml = `
+
+
+ ${totalMissing > 0 ? '⚠️ ' + totalMissing + ' tool(s) not found' : '✓ All tools installed'}
+
+
+ OS: ${data.os} | Package Manager: ${data.pkg_manager}
+
+
+ `;
+
+ content.innerHTML = summaryHtml + html;
+ })
+ .catch(err => {
+ content.innerHTML = 'Error loading dependencies: ' + err.message + '
';
+ });
+}
+
+// Initialize settings on page load
+document.addEventListener('DOMContentLoaded', () => {
+ Settings.init();
+});
+
+// =============================================================================
+// Location Settings Functions
+// =============================================================================
+
+/**
+ * Load and display current observer location
+ */
+function loadObserverLocation() {
+ let lat = localStorage.getItem('observerLat');
+ let lon = localStorage.getItem('observerLon');
+ if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
+ const shared = ObserverLocation.getShared();
+ lat = shared.lat.toString();
+ lon = shared.lon.toString();
+ }
+
+ const latInput = document.getElementById('observerLatInput');
+ const lonInput = document.getElementById('observerLonInput');
+ const currentLatDisplay = document.getElementById('currentLatDisplay');
+ const currentLonDisplay = document.getElementById('currentLonDisplay');
+
+ if (latInput && lat) latInput.value = lat;
+ if (lonInput && lon) lonInput.value = lon;
+
+ if (currentLatDisplay) {
+ currentLatDisplay.textContent = lat ? parseFloat(lat).toFixed(4) + '°' : 'Not set';
+ }
+ if (currentLonDisplay) {
+ currentLonDisplay.textContent = lon ? parseFloat(lon).toFixed(4) + '°' : 'Not set';
+ }
+
+ // Sync dashboard-specific location keys for backward compatibility
+ if (lat && lon) {
+ const locationObj = JSON.stringify({ lat: parseFloat(lat), lon: parseFloat(lon) });
+ if (!localStorage.getItem('observerLocation')) {
+ localStorage.setItem('observerLocation', locationObj);
+ }
+ if (!localStorage.getItem('ais_observerLocation')) {
+ localStorage.setItem('ais_observerLocation', locationObj);
+ }
+ }
+}
+
+/**
+ * Detect location using gpsd (USB GPS) or browser geolocation as fallback
+ */
+function detectLocationGPS(btn) {
+ const latInput = document.getElementById('observerLatInput');
+ const lonInput = document.getElementById('observerLonInput');
+
+ // Show loading state with visual feedback
+ const originalText = btn.innerHTML;
+ btn.innerHTML = ' Detecting...';
+ btn.disabled = true;
+ btn.style.opacity = '0.7';
+
+ // Helper to restore button state
+ function restoreButton() {
+ btn.innerHTML = originalText;
+ btn.disabled = false;
+ btn.style.opacity = '';
+ }
+
+ // Helper to set location values
+ function setLocation(lat, lon, source) {
+ if (latInput) latInput.value = parseFloat(lat).toFixed(4);
+ if (lonInput) lonInput.value = parseFloat(lon).toFixed(4);
+ restoreButton();
+ if (typeof showNotification === 'function') {
+ showNotification('Location', `Coordinates set from ${source}`);
+ }
+ }
+
+ // First, try gpsd (USB GPS device)
+ fetch('/gps/position')
+ .then(response => response.json())
+ .then(data => {
+ if (data.status === 'ok' && data.position && data.position.latitude != null) {
+ // Got valid position from gpsd
+ setLocation(data.position.latitude, data.position.longitude, 'GPS device');
+ } else if (data.status === 'waiting') {
+ // gpsd connected but no fix yet - show message and try browser
+ if (typeof showNotification === 'function') {
+ showNotification('GPS', 'GPS device connected but no fix yet. Trying browser location...');
+ }
+ useBrowserGeolocation();
+ } else {
+ // gpsd not available, try browser geolocation
+ useBrowserGeolocation();
+ }
+ })
+ .catch(() => {
+ // gpsd request failed, try browser geolocation
+ useBrowserGeolocation();
+ });
+
+ // Fallback to browser geolocation
+ function useBrowserGeolocation() {
+ if (!navigator.geolocation) {
+ restoreButton();
+ if (typeof showNotification === 'function') {
+ showNotification('Location', 'No GPS available (gpsd not running, browser GPS unavailable)');
+ } else {
+ alert('No GPS available');
+ }
+ return;
+ }
+
+ navigator.geolocation.getCurrentPosition(
+ (pos) => {
+ setLocation(pos.coords.latitude, pos.coords.longitude, 'browser');
+ },
+ (err) => {
+ restoreButton();
+ let message = 'Failed to get location';
+ if (err.code === 1) message = 'Location access denied';
+ else if (err.code === 2) message = 'Location unavailable';
+ else if (err.code === 3) message = 'Location request timed out';
+
+ if (typeof showNotification === 'function') {
+ showNotification('Location', message);
+ } else {
+ alert(message);
+ }
+ },
+ { enableHighAccuracy: true, timeout: 10000 }
+ );
+ }
+}
+
+/**
+ * Save observer location to localStorage
+ */
+function saveObserverLocation() {
+ const latInput = document.getElementById('observerLatInput');
+ const lonInput = document.getElementById('observerLonInput');
+
+ const lat = parseFloat(latInput?.value);
+ const lon = parseFloat(lonInput?.value);
+
+ if (isNaN(lat) || lat < -90 || lat > 90) {
+ if (typeof showNotification === 'function') {
+ showNotification('Location', 'Invalid latitude (must be -90 to 90)');
+ } else {
+ alert('Invalid latitude (must be -90 to 90)');
+ }
+ return;
+ }
+
+ if (isNaN(lon) || lon < -180 || lon > 180) {
+ if (typeof showNotification === 'function') {
+ showNotification('Location', 'Invalid longitude (must be -180 to 180)');
+ } else {
+ alert('Invalid longitude (must be -180 to 180)');
+ }
+ return;
+ }
+
+ if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
+ ObserverLocation.setShared({ lat, lon });
+ } else {
+ localStorage.setItem('observerLat', lat.toString());
+ localStorage.setItem('observerLon', lon.toString());
+ }
+
+ // Also update dashboard-specific location keys for ADS-B and AIS
+ const locationObj = JSON.stringify({ lat: lat, lon: lon });
+ localStorage.setItem('observerLocation', locationObj); // ADS-B dashboard
+ localStorage.setItem('ais_observerLocation', locationObj); // AIS dashboard
+
+ // Update display
+ const currentLatDisplay = document.getElementById('currentLatDisplay');
+ const currentLonDisplay = document.getElementById('currentLonDisplay');
+ if (currentLatDisplay) currentLatDisplay.textContent = lat.toFixed(4) + '°';
+ if (currentLonDisplay) currentLonDisplay.textContent = lon.toFixed(4) + '°';
+
+ if (typeof showNotification === 'function') {
+ showNotification('Location', 'Observer location saved');
+ }
+
+ if (window.observerLocation) {
+ window.observerLocation.lat = lat;
+ window.observerLocation.lon = lon;
+ }
+
+ // Refresh SSTV ISS schedule if available
+ if (typeof SSTV !== 'undefined' && typeof SSTV.loadIssSchedule === 'function') {
+ SSTV.loadIssSchedule();
+ }
+}
+
+// =============================================================================
+// Update Settings Functions
+// =============================================================================
+
+/**
+ * Check for updates manually from settings panel
+ */
+async function checkForUpdatesManual() {
+ const content = document.getElementById('updateStatusContent');
+ if (!content) return;
+
+ content.innerHTML = 'Checking for updates...
';
+
+ try {
+ const data = await Updater.checkNow();
+ renderUpdateStatus(data);
+ } catch (error) {
+ content.innerHTML = `Error checking for updates: ${error.message}
`;
+ }
+}
+
+/**
+ * Load update status when tab is opened
+ */
+async function loadUpdateStatus() {
+ const content = document.getElementById('updateStatusContent');
+ if (!content) return;
+
+ try {
+ const data = await Updater.getStatus();
+ renderUpdateStatus(data);
+ } catch (error) {
+ content.innerHTML = `Error loading update status: ${error.message}
`;
+ }
+}
+
+/**
+ * Render update status in settings panel
+ */
+function renderUpdateStatus(data) {
+ const content = document.getElementById('updateStatusContent');
+ if (!content) return;
+
+ if (!data.success) {
+ content.innerHTML = `Error: ${data.error || 'Unknown error'}
`;
+ return;
+ }
+
+ if (data.disabled) {
+ content.innerHTML = `
+
+
Update checking is disabled
+
+ `;
+ return;
+ }
+
+ if (!data.checked) {
+ content.innerHTML = `
+
+
No update check performed yet
+
Click "Check Now" to check for updates
+
+ `;
+ return;
+ }
+
+ const statusColor = data.update_available ? 'var(--accent-green)' : 'var(--text-dim)';
+ const statusText = data.update_available ? 'Update Available' : 'Up to Date';
+ const statusIcon = data.update_available
+ ? ''
+ : '';
+
+ let html = `
+
+
+ ${statusIcon}
+ ${statusText}
+
+
+
+ Current Version
+ v${data.current_version}
+
+
+ Latest Version
+ v${data.latest_version}
+
+ ${data.last_check ? `
+
+ Last Checked
+ ${formatLastCheck(data.last_check)}
+
+ ` : ''}
+
+ ${data.update_available ? `
+
+ ` : ''}
+
+ `;
+
+ content.innerHTML = html;
+}
+
+/**
+ * Format last check timestamp
+ */
+function formatLastCheck(isoString) {
+ try {
+ const date = new Date(isoString);
+ const now = new Date();
+ const diffMs = now - date;
+ const diffMins = Math.floor(diffMs / 60000);
+ const diffHours = Math.floor(diffMs / 3600000);
+
+ if (diffMins < 1) return 'Just now';
+ if (diffMins < 60) return `${diffMins} min ago`;
+ if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
+ return date.toLocaleDateString();
+ } catch (e) {
+ return isoString;
+ }
+}
+
+/**
+ * Toggle update checking
+ */
+async function toggleUpdateCheck(enabled) {
+ // This would require adding a setting to disable update checks
+ // For now, just store in localStorage
+ localStorage.setItem('intercept_update_check_enabled', enabled ? 'true' : 'false');
+
+ if (!enabled && typeof Updater !== 'undefined') {
+ Updater.destroy();
+ } else if (enabled && typeof Updater !== 'undefined') {
+ Updater.init();
+ }
+}
+
+// Extend switchSettingsTab to load update status
+const _originalSwitchSettingsTab = typeof switchSettingsTab !== 'undefined' ? switchSettingsTab : null;
+
+function switchSettingsTab(tabName) {
+ // Update tab buttons
+ document.querySelectorAll('.settings-tab').forEach(tab => {
+ tab.classList.toggle('active', tab.dataset.tab === tabName);
+ });
+
+ // Update sections
+ document.querySelectorAll('.settings-section').forEach(section => {
+ section.classList.toggle('active', section.id === `settings-${tabName}`);
+ });
+
+ // Load content based on tab
+ if (tabName === 'tools') {
+ loadSettingsTools();
+ } else if (tabName === 'updates') {
+ loadUpdateStatus();
+ } else if (tabName === 'location') {
+ loadObserverLocation();
+ }
+}
diff --git a/static/js/modes/listening-post.js b/static/js/modes/listening-post.js
index 1f991f4..bc64675 100644
--- a/static/js/modes/listening-post.js
+++ b/static/js/modes/listening-post.js
@@ -1,2599 +1,2951 @@
-/**
- * Intercept - Listening Post Mode
- * Frequency scanner and manual audio receiver
- */
-
-// ============== STATE ==============
-
-let isScannerRunning = false;
-let isScannerPaused = false;
-let scannerEventSource = null;
-let scannerSignalCount = 0;
-let scannerLogEntries = [];
-let scannerFreqsScanned = 0;
-let scannerCycles = 0;
-let scannerStartFreq = 118;
-let scannerEndFreq = 137;
-let scannerSignalActive = false;
-
-// Audio state
-let isAudioPlaying = false;
-let audioToolsAvailable = { rtl_fm: false, ffmpeg: false };
-let audioReconnectAttempts = 0;
-const MAX_AUDIO_RECONNECT = 3;
-
-// WebSocket audio state
-let audioWebSocket = null;
-let audioQueue = [];
-let isWebSocketAudio = false;
-
-// Visualizer state
-let visualizerContext = null;
-let visualizerAnalyser = null;
-let visualizerSource = null;
-let visualizerAnimationId = null;
-let peakLevel = 0;
-let peakDecay = 0.95;
-
-// Signal level for synthesizer visualization
-let currentSignalLevel = 0;
-let signalLevelThreshold = 1000;
-
-// Track recent signal hits to prevent duplicates
-let recentSignalHits = new Map();
-
-// Direct listen state
-let isDirectListening = false;
-let currentModulation = 'am';
-
-// Agent mode state
-let listeningPostCurrentAgent = null;
-let listeningPostPollTimer = null;
-
-// ============== PRESETS ==============
-
-const scannerPresets = {
- fm: { start: 88, end: 108, step: 200, mod: 'wfm' },
- air: { start: 118, end: 137, step: 25, mod: 'am' },
- marine: { start: 156, end: 163, step: 25, mod: 'fm' },
- amateur2m: { start: 144, end: 148, step: 12.5, mod: 'fm' },
- pager: { start: 152, end: 160, step: 25, mod: 'fm' },
- amateur70cm: { start: 420, end: 450, step: 25, mod: 'fm' }
-};
-
-const audioPresets = {
- fm: { freq: 98.1, mod: 'wfm' },
- airband: { freq: 121.5, mod: 'am' }, // Emergency/guard frequency
- marine: { freq: 156.8, mod: 'fm' }, // Channel 16 - distress
- amateur2m: { freq: 146.52, mod: 'fm' }, // 2m calling frequency
- amateur70cm: { freq: 446.0, mod: 'fm' }
-};
-
-// ============== SCANNER TOOLS CHECK ==============
-
-function checkScannerTools() {
- fetch('/listening/tools')
- .then(r => r.json())
- .then(data => {
- const warnings = [];
- if (!data.rtl_fm) {
- warnings.push('rtl_fm not found - install rtl-sdr tools');
- }
- if (!data.ffmpeg) {
- warnings.push('ffmpeg not found - install: brew install ffmpeg (macOS) or apt install ffmpeg (Linux)');
- }
-
- const warningDiv = document.getElementById('scannerToolsWarning');
- const warningText = document.getElementById('scannerToolsWarningText');
- if (warningDiv && warnings.length > 0) {
- warningText.innerHTML = warnings.join('
');
- warningDiv.style.display = 'block';
- document.getElementById('scannerStartBtn').disabled = true;
- document.getElementById('scannerStartBtn').style.opacity = '0.5';
- } else if (warningDiv) {
- warningDiv.style.display = 'none';
- document.getElementById('scannerStartBtn').disabled = false;
- document.getElementById('scannerStartBtn').style.opacity = '1';
- }
- })
- .catch(() => {});
-}
-
-// ============== SCANNER HELPERS ==============
-
-/**
- * Get the currently selected device from the global SDR selector
- */
-function getSelectedDevice() {
- const select = document.getElementById('deviceSelect');
- return parseInt(select?.value || '0');
-}
-
-/**
- * Get the currently selected SDR type from the global selector
- */
-function getSelectedSDRTypeForScanner() {
- const select = document.getElementById('sdrTypeSelect');
- return select?.value || 'rtlsdr';
-}
-
-// ============== SCANNER PRESETS ==============
-
-function applyScannerPreset() {
- const preset = document.getElementById('scannerPreset').value;
- if (preset !== 'custom' && scannerPresets[preset]) {
- const p = scannerPresets[preset];
- document.getElementById('scannerStartFreq').value = p.start;
- document.getElementById('scannerEndFreq').value = p.end;
- document.getElementById('scannerStep').value = p.step;
- document.getElementById('scannerModulation').value = p.mod;
- }
-}
-
-// ============== SCANNER CONTROLS ==============
-
-function toggleScanner() {
- if (isScannerRunning) {
- stopScanner();
- } else {
- startScanner();
- }
-}
-
-function startScanner() {
- // Use unified radio controls - read all current UI values
- const startFreq = parseFloat(document.getElementById('radioScanStart')?.value || 118);
- const endFreq = parseFloat(document.getElementById('radioScanEnd')?.value || 137);
- const stepSelect = document.getElementById('radioScanStep');
- const step = stepSelect ? parseFloat(stepSelect.value) : 25;
- const modulation = currentModulation || 'am';
- const squelch = parseInt(document.getElementById('radioSquelchValue')?.textContent) || 30;
- const gain = parseInt(document.getElementById('radioGainValue')?.textContent) || 40;
- const dwellSelect = document.getElementById('radioScanDwell');
- const dwell = dwellSelect ? parseInt(dwellSelect.value) : 10;
- const device = getSelectedDevice();
-
- // Check if using agent mode
- const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
- listeningPostCurrentAgent = isAgentMode ? currentAgent : null;
-
- // Disable listen button for agent mode (audio can't stream over HTTP)
- updateListenButtonState(isAgentMode);
-
- if (startFreq >= endFreq) {
- if (typeof showNotification === 'function') {
- showNotification('Scanner Error', 'End frequency must be greater than start');
- }
- return;
- }
-
- // Check if device is available (only for local mode)
- if (!isAgentMode && typeof checkDeviceAvailability === 'function' && !checkDeviceAvailability('scanner')) {
- return;
- }
-
- // Store scanner range for progress calculation
- scannerStartFreq = startFreq;
- scannerEndFreq = endFreq;
- scannerFreqsScanned = 0;
- scannerCycles = 0;
-
- // Update sidebar display
- updateScannerDisplay('STARTING...', 'var(--accent-orange)');
-
- // Show progress bars
- const progressEl = document.getElementById('scannerProgress');
- if (progressEl) {
- progressEl.style.display = 'block';
- document.getElementById('scannerRangeStart').textContent = startFreq.toFixed(1);
- document.getElementById('scannerRangeEnd').textContent = endFreq.toFixed(1);
- }
-
- const mainProgress = document.getElementById('mainScannerProgress');
- if (mainProgress) {
- mainProgress.style.display = 'block';
- document.getElementById('mainRangeStart').textContent = startFreq.toFixed(1) + ' MHz';
- document.getElementById('mainRangeEnd').textContent = endFreq.toFixed(1) + ' MHz';
- }
-
- // Determine endpoint based on agent mode
- const endpoint = isAgentMode
- ? `/controller/agents/${currentAgent}/listening_post/start`
- : '/listening/scanner/start';
-
- fetch(endpoint, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- start_freq: startFreq,
- end_freq: endFreq,
- step: step,
- modulation: modulation,
- squelch: squelch,
- gain: gain,
- dwell_time: dwell,
- device: device,
- bias_t: typeof getBiasTEnabled === 'function' ? getBiasTEnabled() : false
- })
- })
- .then(r => r.json())
- .then(data => {
- // Handle controller proxy response format
- const scanResult = isAgentMode && data.result ? data.result : data;
-
- if (scanResult.status === 'started' || scanResult.status === 'success') {
- if (!isAgentMode && typeof reserveDevice === 'function') reserveDevice(device, 'scanner');
- isScannerRunning = true;
- isScannerPaused = false;
- scannerSignalActive = false;
-
- // Update controls (with null checks)
- const startBtn = document.getElementById('scannerStartBtn');
- if (startBtn) {
- startBtn.textContent = 'Stop Scanner';
- startBtn.classList.add('active');
- }
- const pauseBtn = document.getElementById('scannerPauseBtn');
- if (pauseBtn) pauseBtn.disabled = false;
-
- // Update radio scan button to show STOP
- const radioScanBtn = document.getElementById('radioScanBtn');
- if (radioScanBtn) {
- radioScanBtn.innerHTML = Icons.stop('icon--sm') + ' STOP';
- radioScanBtn.style.background = 'var(--accent-red)';
- radioScanBtn.style.borderColor = 'var(--accent-red)';
- }
-
- updateScannerDisplay('SCANNING', 'var(--accent-cyan)');
- const statusText = document.getElementById('scannerStatusText');
- if (statusText) statusText.textContent = 'Scanning...';
-
- // Show level meter
- const levelMeter = document.getElementById('scannerLevelMeter');
- if (levelMeter) levelMeter.style.display = 'block';
-
- connectScannerStream(isAgentMode);
- addScannerLogEntry('Scanner started', `Range: ${startFreq}-${endFreq} MHz, Step: ${step} kHz`);
- if (typeof showNotification === 'function') {
- showNotification('Scanner Started', `Scanning ${startFreq} - ${endFreq} MHz`);
- }
- } else {
- updateScannerDisplay('ERROR', 'var(--accent-red)');
- if (typeof showNotification === 'function') {
- showNotification('Scanner Error', scanResult.message || scanResult.error || 'Failed to start');
- }
- }
- })
- .catch(err => {
- const statusText = document.getElementById('scannerStatusText');
- if (statusText) statusText.textContent = 'ERROR';
- updateScannerDisplay('ERROR', 'var(--accent-red)');
- if (typeof showNotification === 'function') {
- showNotification('Scanner Error', err.message);
- }
- });
-}
-
-function stopScanner() {
- const isAgentMode = listeningPostCurrentAgent !== null;
- const endpoint = isAgentMode
- ? `/controller/agents/${listeningPostCurrentAgent}/listening_post/stop`
- : '/listening/scanner/stop';
-
- fetch(endpoint, { method: 'POST' })
- .then(() => {
- if (!isAgentMode && typeof releaseDevice === 'function') releaseDevice('scanner');
- listeningPostCurrentAgent = null;
- isScannerRunning = false;
- isScannerPaused = false;
- scannerSignalActive = false;
- currentSignalLevel = 0;
-
- // Re-enable listen button (will be in local mode after stop)
- updateListenButtonState(false);
-
- // Clear polling timer
- if (listeningPostPollTimer) {
- clearInterval(listeningPostPollTimer);
- listeningPostPollTimer = null;
- }
-
- // Update sidebar (with null checks)
- const startBtn = document.getElementById('scannerStartBtn');
- if (startBtn) {
- startBtn.textContent = 'Start Scanner';
- startBtn.classList.remove('active');
- }
- const pauseBtn = document.getElementById('scannerPauseBtn');
- if (pauseBtn) {
- pauseBtn.disabled = true;
- pauseBtn.innerHTML = Icons.pause('icon--sm') + ' Pause';
- }
-
- // Update radio scan button
- const radioScanBtn = document.getElementById('radioScanBtn');
- if (radioScanBtn) {
- radioScanBtn.innerHTML = '📡 SCAN';
- radioScanBtn.style.background = '';
- radioScanBtn.style.borderColor = '';
- }
-
- updateScannerDisplay('STOPPED', 'var(--text-muted)');
- const currentFreq = document.getElementById('scannerCurrentFreq');
- if (currentFreq) currentFreq.textContent = '---.--- MHz';
- const modLabel = document.getElementById('scannerModLabel');
- if (modLabel) modLabel.textContent = '--';
-
- const progressEl = document.getElementById('scannerProgress');
- if (progressEl) progressEl.style.display = 'none';
-
- const signalPanel = document.getElementById('scannerSignalPanel');
- if (signalPanel) signalPanel.style.display = 'none';
-
- const levelMeter = document.getElementById('scannerLevelMeter');
- if (levelMeter) levelMeter.style.display = 'none';
-
- const statusText = document.getElementById('scannerStatusText');
- if (statusText) statusText.textContent = 'Ready';
-
- // Update main display
- const mainModeLabel = document.getElementById('mainScannerModeLabel');
- if (mainModeLabel) {
- mainModeLabel.textContent = 'SCANNER STOPPED';
- document.getElementById('mainScannerFreq').textContent = '---.---';
- document.getElementById('mainScannerFreq').style.color = 'var(--text-muted)';
- document.getElementById('mainScannerMod').textContent = '--';
- }
-
- const mainAnim = document.getElementById('mainScannerAnimation');
- if (mainAnim) mainAnim.style.display = 'none';
-
- const mainProgress = document.getElementById('mainScannerProgress');
- if (mainProgress) mainProgress.style.display = 'none';
-
- const mainSignalAlert = document.getElementById('mainSignalAlert');
- if (mainSignalAlert) mainSignalAlert.style.display = 'none';
-
- // Stop scanner audio
- const scannerAudio = document.getElementById('scannerAudioPlayer');
- if (scannerAudio) {
- scannerAudio.pause();
- scannerAudio.src = '';
- }
-
- if (scannerEventSource) {
- scannerEventSource.close();
- scannerEventSource = null;
- }
- addScannerLogEntry('Scanner stopped', '');
- })
- .catch(() => {});
-}
-
-function pauseScanner() {
- const endpoint = isScannerPaused ? '/listening/scanner/resume' : '/listening/scanner/pause';
- fetch(endpoint, { method: 'POST' })
- .then(r => r.json())
- .then(data => {
- isScannerPaused = !isScannerPaused;
- const pauseBtn = document.getElementById('scannerPauseBtn');
- if (pauseBtn) pauseBtn.innerHTML = isScannerPaused ? Icons.play('icon--sm') + ' Resume' : Icons.pause('icon--sm') + ' Pause';
- const statusText = document.getElementById('scannerStatusText');
- if (statusText) {
- statusText.textContent = isScannerPaused ? 'PAUSED' : 'SCANNING';
- statusText.style.color = isScannerPaused ? 'var(--accent-orange)' : 'var(--accent-green)';
- }
-
- const activityStatus = document.getElementById('scannerActivityStatus');
- if (activityStatus) {
- activityStatus.textContent = isScannerPaused ? 'PAUSED' : 'SCANNING';
- activityStatus.style.color = isScannerPaused ? 'var(--accent-orange)' : 'var(--accent-green)';
- }
-
- // Update main display
- const mainModeLabel = document.getElementById('mainScannerModeLabel');
- if (mainModeLabel) {
- mainModeLabel.textContent = isScannerPaused ? 'PAUSED' : 'SCANNING';
- }
-
- addScannerLogEntry(isScannerPaused ? 'Scanner paused' : 'Scanner resumed', '');
- })
- .catch(() => {});
-}
-
-function skipSignal() {
- if (!isScannerRunning) {
- if (typeof showNotification === 'function') {
- showNotification('Scanner', 'Scanner is not running');
- }
- return;
- }
-
- fetch('/listening/scanner/skip', { method: 'POST' })
- .then(r => r.json())
- .then(data => {
- if (data.status === 'skipped' && typeof showNotification === 'function') {
- showNotification('Signal Skipped', `Continuing scan from ${data.frequency.toFixed(3)} MHz`);
- }
- })
- .catch(err => {
- if (typeof showNotification === 'function') {
- showNotification('Skip Error', err.message);
- }
- });
-}
-
-// ============== SCANNER STREAM ==============
-
-function connectScannerStream(isAgentMode = false) {
- if (scannerEventSource) {
- scannerEventSource.close();
- }
-
- // Use different stream endpoint for agent mode
- const streamUrl = isAgentMode ? '/controller/stream/all' : '/listening/scanner/stream';
- scannerEventSource = new EventSource(streamUrl);
-
- scannerEventSource.onmessage = function(e) {
- try {
- const data = JSON.parse(e.data);
-
- if (isAgentMode) {
- // Handle multi-agent stream format
- if (data.scan_type === 'listening_post' && data.payload) {
- const payload = data.payload;
- payload.agent_name = data.agent_name;
- handleScannerEvent(payload);
- }
- } else {
- handleScannerEvent(data);
- }
- } catch (err) {
- console.warn('Scanner parse error:', err);
- }
- };
-
- scannerEventSource.onerror = function() {
- if (isScannerRunning) {
- setTimeout(() => connectScannerStream(isAgentMode), 2000);
- }
- };
-
- // Start polling fallback for agent mode
- if (isAgentMode) {
- startListeningPostPolling();
- }
-}
-
-// Track last activity count for polling
-let lastListeningPostActivityCount = 0;
-
-function startListeningPostPolling() {
- if (listeningPostPollTimer) return;
- lastListeningPostActivityCount = 0;
-
- // Disable listen button for agent mode (audio can't stream over HTTP)
- updateListenButtonState(true);
-
- const pollInterval = 2000;
- listeningPostPollTimer = setInterval(async () => {
- if (!isScannerRunning || !listeningPostCurrentAgent) {
- clearInterval(listeningPostPollTimer);
- listeningPostPollTimer = null;
- return;
- }
-
- try {
- const response = await fetch(`/controller/agents/${listeningPostCurrentAgent}/listening_post/data`);
- if (!response.ok) return;
-
- const data = await response.json();
- const result = data.result || data;
- // Controller returns nested structure: data.data.data for agent mode data
- const outerData = result.data || {};
- const modeData = outerData.data || outerData;
-
- // Process activity from polling response
- const activity = modeData.activity || [];
- if (activity.length > lastListeningPostActivityCount) {
- const newActivity = activity.slice(lastListeningPostActivityCount);
- newActivity.forEach(item => {
- // Convert to scanner event format
- const event = {
- type: 'signal_found',
- frequency: item.frequency,
- level: item.level || item.signal_level,
- modulation: item.modulation,
- agent_name: result.agent_name || 'Remote Agent'
- };
- handleScannerEvent(event);
- });
- lastListeningPostActivityCount = activity.length;
- }
-
- // Update current frequency if available
- if (modeData.current_freq) {
- handleScannerEvent({
- type: 'freq_change',
- frequency: modeData.current_freq
- });
- }
-
- // Update freqs scanned counter from agent data
- if (modeData.freqs_scanned !== undefined) {
- const freqsEl = document.getElementById('mainFreqsScanned');
- if (freqsEl) freqsEl.textContent = modeData.freqs_scanned;
- scannerFreqsScanned = modeData.freqs_scanned;
- }
-
- // Update signal count from agent data
- if (modeData.signal_count !== undefined) {
- const signalEl = document.getElementById('mainSignalCount');
- if (signalEl) signalEl.textContent = modeData.signal_count;
- }
- } catch (err) {
- console.error('Listening Post polling error:', err);
- }
- }, pollInterval);
-}
-
-function handleScannerEvent(data) {
- switch (data.type) {
- case 'freq_change':
- case 'scan_update':
- handleFrequencyUpdate(data);
- break;
- case 'signal_found':
- handleSignalFound(data);
- break;
- case 'signal_lost':
- case 'signal_skipped':
- handleSignalLost(data);
- break;
- case 'log':
- if (data.entry && data.entry.type === 'scan_cycle') {
- scannerCycles++;
- const cyclesEl = document.getElementById('mainScanCycles');
- if (cyclesEl) cyclesEl.textContent = scannerCycles;
- }
- break;
- case 'stopped':
- stopScanner();
- break;
- }
-}
-
-function handleFrequencyUpdate(data) {
- const freqStr = data.frequency.toFixed(3);
-
- const currentFreq = document.getElementById('scannerCurrentFreq');
- if (currentFreq) currentFreq.textContent = freqStr + ' MHz';
-
- const mainFreq = document.getElementById('mainScannerFreq');
- if (mainFreq) mainFreq.textContent = freqStr;
-
- // Update progress bar
- const progress = ((data.frequency - scannerStartFreq) / (scannerEndFreq - scannerStartFreq)) * 100;
- const progressBar = document.getElementById('scannerProgressBar');
- if (progressBar) progressBar.style.width = Math.max(0, Math.min(100, progress)) + '%';
-
- const mainProgressBar = document.getElementById('mainProgressBar');
- if (mainProgressBar) mainProgressBar.style.width = Math.max(0, Math.min(100, progress)) + '%';
-
- scannerFreqsScanned++;
- const freqsEl = document.getElementById('mainFreqsScanned');
- if (freqsEl) freqsEl.textContent = scannerFreqsScanned;
-
- // Update level meter if present
- if (data.level !== undefined) {
- // Store for synthesizer visualization
- currentSignalLevel = data.level;
- if (data.threshold !== undefined) {
- signalLevelThreshold = data.threshold;
- }
-
- const levelPercent = Math.min(100, (data.level / 5000) * 100);
- const levelBar = document.getElementById('scannerLevelBar');
- if (levelBar) {
- levelBar.style.width = levelPercent + '%';
- if (data.detected) {
- levelBar.style.background = 'var(--accent-green)';
- } else if (data.level > (data.threshold || 0) * 0.7) {
- levelBar.style.background = 'var(--accent-orange)';
- } else {
- levelBar.style.background = 'var(--accent-cyan)';
- }
- }
- const levelValue = document.getElementById('scannerLevelValue');
- if (levelValue) levelValue.textContent = data.level;
- }
-
- const statusText = document.getElementById('scannerStatusText');
- if (statusText) statusText.textContent = `${freqStr} MHz${data.level !== undefined ? ` (level: ${data.level})` : ''}`;
-}
-
-function handleSignalFound(data) {
- scannerSignalCount++;
- scannerSignalActive = true;
- const freqStr = data.frequency.toFixed(3);
-
- const signalCount = document.getElementById('scannerSignalCount');
- if (signalCount) signalCount.textContent = scannerSignalCount;
- const mainSignalCount = document.getElementById('mainSignalCount');
- if (mainSignalCount) mainSignalCount.textContent = scannerSignalCount;
-
- // Update sidebar
- updateScannerDisplay('SIGNAL FOUND', 'var(--accent-green)');
- const signalPanel = document.getElementById('scannerSignalPanel');
- if (signalPanel) signalPanel.style.display = 'block';
- const statusText = document.getElementById('scannerStatusText');
- if (statusText) statusText.textContent = 'Listening to signal...';
-
- // Update main display
- const mainModeLabel = document.getElementById('mainScannerModeLabel');
- if (mainModeLabel) mainModeLabel.textContent = 'SIGNAL DETECTED';
-
- const mainFreq = document.getElementById('mainScannerFreq');
- if (mainFreq) mainFreq.style.color = 'var(--accent-green)';
-
- const mainAnim = document.getElementById('mainScannerAnimation');
- if (mainAnim) mainAnim.style.display = 'none';
-
- const mainSignalAlert = document.getElementById('mainSignalAlert');
- if (mainSignalAlert) mainSignalAlert.style.display = 'block';
-
- // Start audio playback for the detected signal
- if (data.audio_streaming) {
- const scannerAudio = document.getElementById('scannerAudioPlayer');
- if (scannerAudio) {
- // Pass the signal frequency and modulation to getStreamUrl
- const streamUrl = getStreamUrl(data.frequency, data.modulation);
- console.log('[SCANNER] Starting audio for signal:', data.frequency, 'MHz');
- scannerAudio.src = streamUrl;
- // Apply current volume from knob
- const volumeKnob = document.getElementById('radioVolumeKnob');
- if (volumeKnob && volumeKnob._knob) {
- scannerAudio.volume = volumeKnob._knob.getValue() / 100;
- } else if (volumeKnob) {
- const knobValue = parseFloat(volumeKnob.dataset.value) || 80;
- scannerAudio.volume = knobValue / 100;
- }
- scannerAudio.play().catch(e => console.warn('[SCANNER] Audio autoplay blocked:', e));
- // Initialize audio visualizer to feed signal levels to synthesizer
- initAudioVisualizer();
- }
- }
-
- // Add to sidebar recent signals
- if (typeof addSidebarRecentSignal === 'function') {
- addSidebarRecentSignal(data.frequency, data.modulation);
- }
-
- addScannerLogEntry('SIGNAL FOUND', `${freqStr} MHz (${data.modulation.toUpperCase()})`, 'signal');
- addSignalHit(data);
-
- if (typeof showNotification === 'function') {
- showNotification('Signal Found!', `${freqStr} MHz - Audio streaming`);
- }
-}
-
-function handleSignalLost(data) {
- scannerSignalActive = false;
-
- // Update sidebar
- updateScannerDisplay('SCANNING', 'var(--accent-cyan)');
- const signalPanel = document.getElementById('scannerSignalPanel');
- if (signalPanel) signalPanel.style.display = 'none';
- const statusText = document.getElementById('scannerStatusText');
- if (statusText) statusText.textContent = 'Scanning...';
-
- // Update main display
- const mainModeLabel = document.getElementById('mainScannerModeLabel');
- if (mainModeLabel) mainModeLabel.textContent = 'SCANNING';
-
- const mainFreq = document.getElementById('mainScannerFreq');
- if (mainFreq) mainFreq.style.color = 'var(--accent-cyan)';
-
- const mainAnim = document.getElementById('mainScannerAnimation');
- if (mainAnim) mainAnim.style.display = 'block';
-
- const mainSignalAlert = document.getElementById('mainSignalAlert');
- if (mainSignalAlert) mainSignalAlert.style.display = 'none';
-
- // Stop audio
- const scannerAudio = document.getElementById('scannerAudioPlayer');
- if (scannerAudio) {
- scannerAudio.pause();
- scannerAudio.src = '';
- }
-
- const logType = data.type === 'signal_skipped' ? 'info' : 'info';
- const logTitle = data.type === 'signal_skipped' ? 'Signal skipped' : 'Signal lost';
- addScannerLogEntry(logTitle, `${data.frequency.toFixed(3)} MHz`, logType);
-}
-
-/**
- * Update listen button state based on agent mode
- * Audio streaming isn't practical over HTTP so disable for remote agents
- */
-function updateListenButtonState(isAgentMode) {
- const listenBtn = document.getElementById('radioListenBtn');
- if (!listenBtn) return;
-
- if (isAgentMode) {
- listenBtn.disabled = true;
- listenBtn.style.opacity = '0.5';
- listenBtn.style.cursor = 'not-allowed';
- listenBtn.title = 'Audio listening not available for remote agents';
- } else {
- listenBtn.disabled = false;
- listenBtn.style.opacity = '1';
- listenBtn.style.cursor = 'pointer';
- listenBtn.title = 'Listen to current frequency';
- }
-}
-
-function updateScannerDisplay(mode, color) {
- const modeLabel = document.getElementById('scannerModeLabel');
- if (modeLabel) {
- modeLabel.textContent = mode;
- modeLabel.style.color = color;
- }
-
- const currentFreq = document.getElementById('scannerCurrentFreq');
- if (currentFreq) currentFreq.style.color = color;
-
- const mainModeLabel = document.getElementById('mainScannerModeLabel');
- if (mainModeLabel) mainModeLabel.textContent = mode;
-
- const mainFreq = document.getElementById('mainScannerFreq');
- if (mainFreq) mainFreq.style.color = color;
-}
-
-// ============== SCANNER LOG ==============
-
-function addScannerLogEntry(title, detail, type = 'info') {
- const now = new Date();
- const timestamp = now.toLocaleTimeString();
- const entry = { timestamp, title, detail, type };
- scannerLogEntries.unshift(entry);
-
- if (scannerLogEntries.length > 100) {
- scannerLogEntries.pop();
- }
-
- // Color based on type
- const getTypeColor = (t) => {
- switch(t) {
- case 'signal': return 'var(--accent-green)';
- case 'error': return 'var(--accent-red)';
- default: return 'var(--text-secondary)';
- }
- };
-
- // Update sidebar log
- const sidebarLog = document.getElementById('scannerLog');
- if (sidebarLog) {
- sidebarLog.innerHTML = scannerLogEntries.slice(0, 20).map(e =>
- `
- [${e.timestamp}]
- ${e.title} ${e.detail}
-
`
- ).join('');
- }
-
- // Update main activity log
- const activityLog = document.getElementById('scannerActivityLog');
- if (activityLog) {
- const getBorderColor = (t) => {
- switch(t) {
- case 'signal': return 'var(--accent-green)';
- case 'error': return 'var(--accent-red)';
- default: return 'var(--border-color)';
- }
- };
- activityLog.innerHTML = scannerLogEntries.slice(0, 50).map(e =>
- `
- [${e.timestamp}]
- ${e.title}
- ${e.detail}
-
`
- ).join('');
- }
-}
-
-function addSignalHit(data) {
- const tbody = document.getElementById('scannerHitsBody');
- if (!tbody) return;
-
- const now = Date.now();
- const freqKey = data.frequency.toFixed(3);
-
- // Check for duplicate
- if (recentSignalHits.has(freqKey)) {
- const lastHit = recentSignalHits.get(freqKey);
- if (now - lastHit < 5000) return;
- }
- recentSignalHits.set(freqKey, now);
-
- // Clean up old entries
- for (const [freq, time] of recentSignalHits) {
- if (now - time > 30000) {
- recentSignalHits.delete(freq);
- }
- }
-
- const timestamp = new Date().toLocaleTimeString();
-
- if (tbody.innerHTML.includes('No signals detected')) {
- tbody.innerHTML = '';
- }
-
- const mod = data.modulation || 'fm';
- const row = document.createElement('tr');
- row.style.borderBottom = '1px solid var(--border-color)';
- row.innerHTML = `
- ${timestamp} |
- ${data.frequency.toFixed(3)} |
- ${mod.toUpperCase()} |
-
-
- |
- `;
- tbody.insertBefore(row, tbody.firstChild);
-
- while (tbody.children.length > 50) {
- tbody.removeChild(tbody.lastChild);
- }
-
- const hitCount = document.getElementById('scannerHitCount');
- if (hitCount) hitCount.textContent = `${tbody.children.length} signals found`;
-
- // Feed to activity timeline if available
- if (typeof addTimelineEvent === 'function') {
- const normalized = typeof RFTimelineAdapter !== 'undefined'
- ? RFTimelineAdapter.normalizeSignal({
- frequency: data.frequency,
- rssi: data.rssi || data.signal_strength,
- duration: data.duration || 2000,
- modulation: data.modulation
- })
- : {
- id: String(data.frequency),
- label: `${data.frequency.toFixed(3)} MHz`,
- strength: 3,
- duration: 2000,
- type: 'rf'
- };
- addTimelineEvent('listening', normalized);
- }
-}
-
-function clearScannerLog() {
- scannerLogEntries = [];
- scannerSignalCount = 0;
- scannerFreqsScanned = 0;
- scannerCycles = 0;
- recentSignalHits.clear();
-
- // Clear the timeline if available
- const timeline = typeof getTimeline === 'function' ? getTimeline('listening') : null;
- if (timeline) {
- timeline.clear();
- }
-
- const signalCount = document.getElementById('scannerSignalCount');
- if (signalCount) signalCount.textContent = '0';
-
- const mainSignalCount = document.getElementById('mainSignalCount');
- if (mainSignalCount) mainSignalCount.textContent = '0';
-
- const mainFreqsScanned = document.getElementById('mainFreqsScanned');
- if (mainFreqsScanned) mainFreqsScanned.textContent = '0';
-
- const mainScanCycles = document.getElementById('mainScanCycles');
- if (mainScanCycles) mainScanCycles.textContent = '0';
-
- const sidebarLog = document.getElementById('scannerLog');
- if (sidebarLog) sidebarLog.innerHTML = 'Scanner activity will appear here...
';
-
- const activityLog = document.getElementById('scannerActivityLog');
- if (activityLog) activityLog.innerHTML = 'Waiting for scanner to start...
';
-
- const hitsBody = document.getElementById('scannerHitsBody');
- if (hitsBody) hitsBody.innerHTML = '| No signals detected |
';
-
- const hitCount = document.getElementById('scannerHitCount');
- if (hitCount) hitCount.textContent = '0 signals found';
-}
-
-function exportScannerLog() {
- if (scannerLogEntries.length === 0) {
- if (typeof showNotification === 'function') {
- showNotification('Export', 'No log entries to export');
- }
- return;
- }
-
- const csv = 'Timestamp,Event,Details\n' + scannerLogEntries.map(e =>
- `"${e.timestamp}","${e.title}","${e.detail}"`
- ).join('\n');
-
- const blob = new Blob([csv], { type: 'text/csv' });
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = `scanner_log_${new Date().toISOString().slice(0, 10)}.csv`;
- a.click();
- URL.revokeObjectURL(url);
-
- if (typeof showNotification === 'function') {
- showNotification('Export', 'Log exported to CSV');
- }
-}
-
-// ============== AUDIO TOOLS CHECK ==============
-
-function checkAudioTools() {
- fetch('/listening/tools')
- .then(r => r.json())
- .then(data => {
- audioToolsAvailable.rtl_fm = data.rtl_fm;
- audioToolsAvailable.ffmpeg = data.ffmpeg;
-
- // Only rtl_fm/rx_fm + ffmpeg are required for direct streaming
- const warnings = [];
- if (!data.rtl_fm && !data.rx_fm) {
- warnings.push('rtl_fm/rx_fm not found - install rtl-sdr or soapysdr-tools');
- }
- if (!data.ffmpeg) {
- warnings.push('ffmpeg not found - install: brew install ffmpeg (macOS) or apt install ffmpeg (Linux)');
- }
-
- const warningDiv = document.getElementById('audioToolsWarning');
- const warningText = document.getElementById('audioToolsWarningText');
- if (warningDiv) {
- if (warnings.length > 0) {
- warningText.innerHTML = warnings.join('
');
- warningDiv.style.display = 'block';
- document.getElementById('audioStartBtn').disabled = true;
- document.getElementById('audioStartBtn').style.opacity = '0.5';
- } else {
- warningDiv.style.display = 'none';
- document.getElementById('audioStartBtn').disabled = false;
- document.getElementById('audioStartBtn').style.opacity = '1';
- }
- }
- })
- .catch(() => {});
-}
-
-// ============== AUDIO PRESETS ==============
-
-function applyAudioPreset() {
- const preset = document.getElementById('audioPreset').value;
- const freqInput = document.getElementById('audioFrequency');
- const modSelect = document.getElementById('audioModulation');
-
- if (audioPresets[preset]) {
- freqInput.value = audioPresets[preset].freq;
- modSelect.value = audioPresets[preset].mod;
- }
-}
-
-// ============== AUDIO CONTROLS ==============
-
-function toggleAudio() {
- if (isAudioPlaying) {
- stopAudio();
- } else {
- startAudio();
- }
-}
-
-function startAudio() {
- const frequency = parseFloat(document.getElementById('audioFrequency').value);
- const modulation = document.getElementById('audioModulation').value;
- const squelch = parseInt(document.getElementById('audioSquelch').value);
- const gain = parseInt(document.getElementById('audioGain').value);
- const device = getSelectedDevice();
-
- if (isNaN(frequency) || frequency <= 0) {
- if (typeof showNotification === 'function') {
- showNotification('Audio Error', 'Invalid frequency');
- }
- return;
- }
-
- // Check if device is in use
- if (typeof getDeviceInUseBy === 'function') {
- const usedBy = getDeviceInUseBy(device);
- if (usedBy && usedBy !== 'audio') {
- if (typeof showNotification === 'function') {
- showNotification('SDR In Use', `Device ${device} is being used by ${usedBy.toUpperCase()}.`);
- }
- return;
- }
- }
-
- document.getElementById('audioStatus').textContent = 'STARTING...';
- document.getElementById('audioStatus').style.color = 'var(--accent-orange)';
-
- // Use direct streaming - no Icecast needed
- if (typeof reserveDevice === 'function') reserveDevice(device, 'audio');
- isAudioPlaying = true;
-
- // Build direct stream URL with parameters
- const streamUrl = `/listening/audio/stream?freq=${frequency}&mod=${modulation}&squelch=${squelch}&gain=${gain}&t=${Date.now()}`;
- console.log('Connecting to direct stream:', streamUrl);
-
- // Start browser audio playback
- const audioPlayer = document.getElementById('audioPlayer');
- audioPlayer.src = streamUrl;
- audioPlayer.volume = document.getElementById('audioVolume').value / 100;
-
- initAudioVisualizer();
-
- audioPlayer.onplaying = () => {
- document.getElementById('audioStatus').textContent = 'STREAMING';
- document.getElementById('audioStatus').style.color = 'var(--accent-green)';
- };
-
- audioPlayer.onerror = (e) => {
- console.error('Audio player error:', e);
- document.getElementById('audioStatus').textContent = 'ERROR';
- document.getElementById('audioStatus').style.color = 'var(--accent-red)';
- if (typeof showNotification === 'function') {
- showNotification('Audio Error', 'Stream error - check SDR connection');
- }
- };
-
- audioPlayer.play().catch(e => {
- console.warn('Audio autoplay blocked:', e);
- if (typeof showNotification === 'function') {
- showNotification('Audio Ready', 'Click Play button again if audio does not start');
- }
- });
-
- document.getElementById('audioStartBtn').innerHTML = Icons.stop('icon--sm') + ' Stop Audio';
- document.getElementById('audioStartBtn').classList.add('active');
- document.getElementById('audioTunedFreq').textContent = frequency.toFixed(2) + ' MHz (' + modulation.toUpperCase() + ')';
- document.getElementById('audioDeviceStatus').textContent = 'SDR ' + device;
-
- if (typeof showNotification === 'function') {
- showNotification('Audio Started', `Streaming ${frequency} MHz to browser`);
- }
-}
-
-async function stopAudio() {
- stopAudioVisualizer();
-
- const audioPlayer = document.getElementById('audioPlayer');
- if (audioPlayer) {
- audioPlayer.pause();
- audioPlayer.src = '';
- }
-
- try {
- await fetch('/listening/audio/stop', { method: 'POST' });
- if (typeof releaseDevice === 'function') releaseDevice('audio');
- isAudioPlaying = false;
- document.getElementById('audioStartBtn').innerHTML = Icons.play('icon--sm') + ' Play Audio';
- document.getElementById('audioStartBtn').classList.remove('active');
- document.getElementById('audioStatus').textContent = 'STOPPED';
- document.getElementById('audioStatus').style.color = 'var(--text-muted)';
- document.getElementById('audioDeviceStatus').textContent = '--';
- } catch (e) {
- console.error('Error stopping audio:', e);
- }
-}
-
-function updateAudioVolume() {
- const audioPlayer = document.getElementById('audioPlayer');
- if (audioPlayer) {
- audioPlayer.volume = document.getElementById('audioVolume').value / 100;
- }
-}
-
-function audioFreqUp() {
- const input = document.getElementById('audioFrequency');
- const mod = document.getElementById('audioModulation').value;
- const step = (mod === 'wfm') ? 0.2 : 0.025;
- input.value = (parseFloat(input.value) + step).toFixed(2);
- if (isAudioPlaying) {
- tuneAudioFrequency(parseFloat(input.value));
- }
-}
-
-function audioFreqDown() {
- const input = document.getElementById('audioFrequency');
- const mod = document.getElementById('audioModulation').value;
- const step = (mod === 'wfm') ? 0.2 : 0.025;
- input.value = (parseFloat(input.value) - step).toFixed(2);
- if (isAudioPlaying) {
- tuneAudioFrequency(parseFloat(input.value));
- }
-}
-
-function tuneAudioFrequency(frequency) {
- fetch('/listening/audio/tune', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ frequency: frequency })
- })
- .then(r => r.json())
- .then(data => {
- if (data.status === 'tuned') {
- document.getElementById('audioTunedFreq').textContent = frequency.toFixed(2) + ' MHz';
- }
- })
- .catch(() => {
- stopAudio();
- setTimeout(startAudio, 300);
- });
-}
-
-async function tuneToFrequency(freq, mod) {
- try {
- // Stop scanner if running
- if (isScannerRunning) {
- stopScanner();
- await new Promise(resolve => setTimeout(resolve, 300));
- }
-
- // Update frequency input
- const freqInput = document.getElementById('radioScanStart');
- if (freqInput) {
- freqInput.value = freq.toFixed(1);
- }
-
- // Update modulation if provided
- if (mod) {
- setModulation(mod);
- }
-
- // Update tuning dial (silent to avoid duplicate events)
- const mainTuningDial = document.getElementById('mainTuningDial');
- if (mainTuningDial && mainTuningDial._dial) {
- mainTuningDial._dial.setValue(freq, true);
- }
-
- // Update frequency display
- const mainFreq = document.getElementById('mainScannerFreq');
- if (mainFreq) {
- mainFreq.textContent = freq.toFixed(3);
- }
-
- // Start listening immediately
- await startDirectListenImmediate();
-
- if (typeof showNotification === 'function') {
- showNotification('Tuned', `Now listening to ${freq.toFixed(3)} MHz (${(mod || currentModulation).toUpperCase()})`);
- }
- } catch (err) {
- console.error('Error tuning to frequency:', err);
- if (typeof showNotification === 'function') {
- showNotification('Tune Error', 'Failed to tune to frequency: ' + err.message);
- }
- }
-}
-
-// ============== AUDIO VISUALIZER ==============
-
-function initAudioVisualizer() {
- const audioPlayer = document.getElementById('scannerAudioPlayer');
- if (!audioPlayer) {
- console.warn('[VISUALIZER] No audio player found');
- return;
- }
-
- console.log('[VISUALIZER] Initializing with audio player, src:', audioPlayer.src);
-
- if (!visualizerContext) {
- visualizerContext = new (window.AudioContext || window.webkitAudioContext)();
- console.log('[VISUALIZER] Created audio context');
- }
-
- if (visualizerContext.state === 'suspended') {
- console.log('[VISUALIZER] Resuming suspended audio context');
- visualizerContext.resume();
- }
-
- if (!visualizerSource) {
- try {
- visualizerSource = visualizerContext.createMediaElementSource(audioPlayer);
- visualizerAnalyser = visualizerContext.createAnalyser();
- visualizerAnalyser.fftSize = 256;
- visualizerAnalyser.smoothingTimeConstant = 0.7;
-
- visualizerSource.connect(visualizerAnalyser);
- visualizerAnalyser.connect(visualizerContext.destination);
- console.log('[VISUALIZER] Audio source and analyser connected');
- } catch (e) {
- console.error('[VISUALIZER] Could not create audio source:', e);
- // Try to continue anyway if analyser exists
- if (!visualizerAnalyser) return;
- }
- } else {
- console.log('[VISUALIZER] Reusing existing audio source');
- }
-
- const container = document.getElementById('audioVisualizerContainer');
- if (container) container.style.display = 'block';
-
- // Start the visualization loop
- if (!visualizerAnimationId) {
- console.log('[VISUALIZER] Starting draw loop');
- drawAudioVisualizer();
- } else {
- console.log('[VISUALIZER] Draw loop already running');
- }
-}
-
-function drawAudioVisualizer() {
- if (!visualizerAnalyser) {
- console.warn('[VISUALIZER] No analyser available');
- return;
- }
-
- const canvas = document.getElementById('audioSpectrumCanvas');
- const ctx = canvas ? canvas.getContext('2d') : null;
- const bufferLength = visualizerAnalyser.frequencyBinCount;
- const dataArray = new Uint8Array(bufferLength);
-
- function draw() {
- visualizerAnimationId = requestAnimationFrame(draw);
-
- visualizerAnalyser.getByteFrequencyData(dataArray);
-
- let sum = 0;
- for (let i = 0; i < bufferLength; i++) {
- sum += dataArray[i];
- }
- const average = sum / bufferLength;
- const levelPercent = (average / 255) * 100;
-
- // Feed audio level to synthesizer visualization during direct listening
- if (isDirectListening || isScannerRunning) {
- // Scale 0-255 average to 0-3000 range (matching SSE scan_update levels)
- currentSignalLevel = (average / 255) * 3000;
- }
-
- if (levelPercent > peakLevel) {
- peakLevel = levelPercent;
- } else {
- peakLevel *= peakDecay;
- }
-
- const meterFill = document.getElementById('audioSignalMeter');
- const meterPeak = document.getElementById('audioSignalPeak');
- const meterValue = document.getElementById('audioSignalValue');
-
- if (meterFill) meterFill.style.width = levelPercent + '%';
- if (meterPeak) meterPeak.style.left = Math.min(peakLevel, 100) + '%';
-
- const db = average > 0 ? Math.round(20 * Math.log10(average / 255)) : -60;
- if (meterValue) meterValue.textContent = db + ' dB';
-
- // Only draw spectrum if canvas exists
- if (ctx && canvas) {
- ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
- ctx.fillRect(0, 0, canvas.width, canvas.height);
-
- const barWidth = canvas.width / bufferLength * 2.5;
- let x = 0;
-
- for (let i = 0; i < bufferLength; i++) {
- const barHeight = (dataArray[i] / 255) * canvas.height;
- const hue = 200 - (i / bufferLength) * 60;
- const lightness = 40 + (dataArray[i] / 255) * 30;
- ctx.fillStyle = `hsl(${hue}, 80%, ${lightness}%)`;
- ctx.fillRect(x, canvas.height - barHeight, barWidth - 1, barHeight);
- x += barWidth;
- }
-
- ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
- ctx.font = '8px JetBrains Mono';
- ctx.fillText('0', 2, canvas.height - 2);
- ctx.fillText('4kHz', canvas.width / 4, canvas.height - 2);
- ctx.fillText('8kHz', canvas.width / 2, canvas.height - 2);
- }
- }
-
- draw();
-}
-
-function stopAudioVisualizer() {
- if (visualizerAnimationId) {
- cancelAnimationFrame(visualizerAnimationId);
- visualizerAnimationId = null;
- }
-
- const meterFill = document.getElementById('audioSignalMeter');
- const meterPeak = document.getElementById('audioSignalPeak');
- const meterValue = document.getElementById('audioSignalValue');
-
- if (meterFill) meterFill.style.width = '0%';
- if (meterPeak) meterPeak.style.left = '0%';
- if (meterValue) meterValue.textContent = '-∞ dB';
-
- peakLevel = 0;
-
- const container = document.getElementById('audioVisualizerContainer');
- if (container) container.style.display = 'none';
-}
-
-// ============== RADIO KNOB CONTROLS ==============
-
-/**
- * Update scanner config on the backend (for live updates while scanning)
- */
-function updateScannerConfig(config) {
- if (!isScannerRunning) return;
- fetch('/listening/scanner/config', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(config)
- }).catch(() => {});
-}
-
-/**
- * Initialize radio knob controls and wire them to scanner parameters
- */
-function initRadioKnobControls() {
- // Squelch knob
- const squelchKnob = document.getElementById('radioSquelchKnob');
- if (squelchKnob) {
- squelchKnob.addEventListener('knobchange', function(e) {
- const value = Math.round(e.detail.value);
- const valueDisplay = document.getElementById('radioSquelchValue');
- if (valueDisplay) valueDisplay.textContent = value;
- // Sync with scanner
- updateScannerConfig({ squelch: value });
- // Restart stream if direct listening (squelch requires restart)
- if (isDirectListening) {
- startDirectListen();
- }
- });
- }
-
- // Gain knob
- const gainKnob = document.getElementById('radioGainKnob');
- if (gainKnob) {
- gainKnob.addEventListener('knobchange', function(e) {
- const value = Math.round(e.detail.value);
- const valueDisplay = document.getElementById('radioGainValue');
- if (valueDisplay) valueDisplay.textContent = value;
- // Sync with scanner
- updateScannerConfig({ gain: value });
- // Restart stream if direct listening (gain requires restart)
- if (isDirectListening) {
- startDirectListen();
- }
- });
- }
-
- // Volume knob - controls scanner audio player volume
- const volumeKnob = document.getElementById('radioVolumeKnob');
- if (volumeKnob) {
- volumeKnob.addEventListener('knobchange', function(e) {
- const audioPlayer = document.getElementById('scannerAudioPlayer');
- if (audioPlayer) {
- audioPlayer.volume = e.detail.value / 100;
- console.log('[VOLUME] Set to', Math.round(e.detail.value) + '%');
- }
- // Update knob value display
- const valueDisplay = document.getElementById('radioVolumeValue');
- if (valueDisplay) valueDisplay.textContent = Math.round(e.detail.value);
- });
- }
-
- // Main Tuning dial - updates frequency display and inputs
- const mainTuningDial = document.getElementById('mainTuningDial');
- if (mainTuningDial) {
- mainTuningDial.addEventListener('knobchange', function(e) {
- const freq = e.detail.value;
- // Update main frequency display
- const mainFreq = document.getElementById('mainScannerFreq');
- if (mainFreq) {
- mainFreq.textContent = freq.toFixed(3);
- }
- // Update radio scan start input
- const startFreqInput = document.getElementById('radioScanStart');
- if (startFreqInput) {
- startFreqInput.value = freq.toFixed(1);
- }
- // Update sidebar frequency input
- const sidebarFreq = document.getElementById('audioFrequency');
- if (sidebarFreq) {
- sidebarFreq.value = freq.toFixed(3);
- }
- // If currently listening, retune to new frequency
- if (isDirectListening) {
- startDirectListen();
- }
- });
- }
-
- // Legacy tuning dial support
- const tuningDial = document.getElementById('tuningDial');
- if (tuningDial) {
- tuningDial.addEventListener('knobchange', function(e) {
- const mainFreq = document.getElementById('mainScannerFreq');
- if (mainFreq) mainFreq.textContent = e.detail.value.toFixed(3);
- const startFreqInput = document.getElementById('radioScanStart');
- if (startFreqInput) startFreqInput.value = e.detail.value.toFixed(1);
- // If currently listening, retune to new frequency
- if (isDirectListening) {
- startDirectListen();
- }
- });
- }
-
- // Sync radio scan range inputs with sidebar
- const radioScanStart = document.getElementById('radioScanStart');
- const radioScanEnd = document.getElementById('radioScanEnd');
-
- if (radioScanStart) {
- radioScanStart.addEventListener('change', function() {
- const sidebarStart = document.getElementById('scanStartFreq');
- if (sidebarStart) sidebarStart.value = this.value;
- // Restart stream if direct listening
- if (isDirectListening) {
- startDirectListen();
- }
- });
- }
-
- if (radioScanEnd) {
- radioScanEnd.addEventListener('change', function() {
- const sidebarEnd = document.getElementById('scanEndFreq');
- if (sidebarEnd) sidebarEnd.value = this.value;
- });
- }
-}
-
-/**
- * Set modulation mode (called from HTML onclick)
- */
-function setModulation(mod) {
- // Update sidebar select
- const modSelect = document.getElementById('scanModulation');
- if (modSelect) modSelect.value = mod;
-
- // Update audio modulation select
- const audioMod = document.getElementById('audioModulation');
- if (audioMod) audioMod.value = mod;
-
- // Update button states in radio panel
- document.querySelectorAll('#modBtnBank .radio-btn').forEach(btn => {
- btn.classList.toggle('active', btn.dataset.mod === mod);
- });
-
- // Update main display badge
- const mainBadge = document.getElementById('mainScannerMod');
- if (mainBadge) mainBadge.textContent = mod.toUpperCase();
-}
-
-/**
- * Set band preset (called from HTML onclick)
- */
-function setBand(band) {
- const preset = scannerPresets[band];
- if (!preset) return;
-
- // Update button states
- document.querySelectorAll('#bandBtnBank .radio-btn').forEach(btn => {
- btn.classList.toggle('active', btn.dataset.band === band);
- });
-
- // Update sidebar frequency inputs
- const sidebarStart = document.getElementById('scanStartFreq');
- const sidebarEnd = document.getElementById('scanEndFreq');
- if (sidebarStart) sidebarStart.value = preset.start;
- if (sidebarEnd) sidebarEnd.value = preset.end;
-
- // Update radio panel frequency inputs
- const radioStart = document.getElementById('radioScanStart');
- const radioEnd = document.getElementById('radioScanEnd');
- if (radioStart) radioStart.value = preset.start;
- if (radioEnd) radioEnd.value = preset.end;
-
- // Update tuning dial range and value (silent to avoid triggering restart)
- const tuningDial = document.getElementById('tuningDial');
- if (tuningDial && tuningDial._dial) {
- tuningDial._dial.min = preset.start;
- tuningDial._dial.max = preset.end;
- tuningDial._dial.setValue(preset.start, true);
- }
-
- // Update main frequency display
- const mainFreq = document.getElementById('mainScannerFreq');
- if (mainFreq) mainFreq.textContent = preset.start.toFixed(3);
-
- // Update modulation
- setModulation(preset.mod);
-
- // Update main range display if scanning
- const rangeStart = document.getElementById('mainRangeStart');
- const rangeEnd = document.getElementById('mainRangeEnd');
- if (rangeStart) rangeStart.textContent = preset.start;
- if (rangeEnd) rangeEnd.textContent = preset.end;
-
- // Store for scanner use
- scannerStartFreq = preset.start;
- scannerEndFreq = preset.end;
-}
-
-// ============== SYNTHESIZER VISUALIZATION ==============
-
-let synthAnimationId = null;
-let synthCanvas = null;
-let synthCtx = null;
-let synthBars = [];
-const SYNTH_BAR_COUNT = 32;
-
-function initSynthesizer() {
- synthCanvas = document.getElementById('synthesizerCanvas');
- if (!synthCanvas) return;
-
- // Set canvas size
- const rect = synthCanvas.parentElement.getBoundingClientRect();
- synthCanvas.width = rect.width - 20;
- synthCanvas.height = 60;
-
- synthCtx = synthCanvas.getContext('2d');
-
- // Initialize bar heights
- for (let i = 0; i < SYNTH_BAR_COUNT; i++) {
- synthBars[i] = { height: 0, targetHeight: 0, velocity: 0 };
- }
-
- drawSynthesizer();
-}
-
-// Debug: log signal level periodically
-let lastSynthDebugLog = 0;
-
-function drawSynthesizer() {
- if (!synthCtx || !synthCanvas) return;
-
- const width = synthCanvas.width;
- const height = synthCanvas.height;
- const barWidth = (width / SYNTH_BAR_COUNT) - 2;
-
- // Clear canvas
- synthCtx.fillStyle = 'rgba(0, 0, 0, 0.3)';
- synthCtx.fillRect(0, 0, width, height);
-
- // Determine activity level based on actual signal level
- let activityLevel = 0;
- let signalIntensity = 0;
-
- // Debug logging every 2 seconds
- const now = Date.now();
- if (now - lastSynthDebugLog > 2000) {
- console.log('[SYNTH] State:', {
- isScannerRunning,
- isDirectListening,
- scannerSignalActive,
- currentSignalLevel,
- visualizerAnalyser: !!visualizerAnalyser
- });
- lastSynthDebugLog = now;
- }
-
- if (isScannerRunning && !isScannerPaused) {
- // Use actual signal level data (0-5000 range, normalize to 0-1)
- signalIntensity = Math.min(1, currentSignalLevel / 3000);
- // Base activity when scanning, boosted by actual signal strength
- activityLevel = 0.15 + (signalIntensity * 0.85);
- if (scannerSignalActive) {
- activityLevel = Math.max(activityLevel, 0.7);
- }
- } else if (isDirectListening) {
- // For direct listening, use signal level if available
- signalIntensity = Math.min(1, currentSignalLevel / 3000);
- activityLevel = 0.2 + (signalIntensity * 0.8);
- }
-
- // Update bar targets
- for (let i = 0; i < SYNTH_BAR_COUNT; i++) {
- if (activityLevel > 0) {
- // Create wave-like pattern modulated by actual signal strength
- const time = Date.now() / 200;
- // Multiple wave frequencies for more organic feel
- const wave1 = Math.sin(time + (i * 0.3)) * 0.2;
- const wave2 = Math.sin(time * 1.7 + (i * 0.5)) * 0.15;
- // Less randomness when signal is weak, more when strong
- const randomAmount = 0.1 + (signalIntensity * 0.3);
- const random = (Math.random() - 0.5) * randomAmount;
- // Center bars tend to be taller (frequency spectrum shape)
- const centerBoost = 1 - Math.abs((i - SYNTH_BAR_COUNT / 2) / (SYNTH_BAR_COUNT / 2)) * 0.4;
- // Combine all factors with signal-driven amplitude
- const baseHeight = 0.15 + (signalIntensity * 0.5);
- synthBars[i].targetHeight = (baseHeight + wave1 + wave2 + random) * activityLevel * centerBoost * height;
- } else {
- // Idle state - minimal activity
- synthBars[i].targetHeight = (Math.sin((Date.now() / 500) + (i * 0.5)) * 0.1 + 0.1) * height * 0.3;
- }
-
- // Smooth animation - faster response when signal changes
- const springStrength = signalIntensity > 0.3 ? 0.15 : 0.1;
- const diff = synthBars[i].targetHeight - synthBars[i].height;
- synthBars[i].velocity += diff * springStrength;
- synthBars[i].velocity *= 0.8;
- synthBars[i].height += synthBars[i].velocity;
- synthBars[i].height = Math.max(2, Math.min(height - 4, synthBars[i].height));
- }
-
- // Draw bars
- for (let i = 0; i < SYNTH_BAR_COUNT; i++) {
- const x = i * (barWidth + 2) + 1;
- const barHeight = synthBars[i].height;
- const y = (height - barHeight) / 2;
-
- // Color gradient based on height and state
- let hue, saturation, lightness;
- if (scannerSignalActive) {
- hue = 120; // Green for signal
- saturation = 80;
- lightness = 40 + (barHeight / height) * 30;
- } else if (isScannerRunning || isDirectListening) {
- hue = 190 + (i / SYNTH_BAR_COUNT) * 30; // Cyan to blue
- saturation = 80;
- lightness = 35 + (barHeight / height) * 25;
- } else {
- hue = 200;
- saturation = 50;
- lightness = 25 + (barHeight / height) * 15;
- }
-
- const gradient = synthCtx.createLinearGradient(x, y, x, y + barHeight);
- gradient.addColorStop(0, `hsla(${hue}, ${saturation}%, ${lightness + 20}%, 0.9)`);
- gradient.addColorStop(0.5, `hsla(${hue}, ${saturation}%, ${lightness}%, 1)`);
- gradient.addColorStop(1, `hsla(${hue}, ${saturation}%, ${lightness + 20}%, 0.9)`);
-
- synthCtx.fillStyle = gradient;
- synthCtx.fillRect(x, y, barWidth, barHeight);
-
- // Add glow effect for active bars
- if (barHeight > height * 0.5 && activityLevel > 0.5) {
- synthCtx.shadowColor = `hsla(${hue}, ${saturation}%, 60%, 0.5)`;
- synthCtx.shadowBlur = 8;
- synthCtx.fillRect(x, y, barWidth, barHeight);
- synthCtx.shadowBlur = 0;
- }
- }
-
- // Draw center line
- synthCtx.strokeStyle = 'rgba(0, 212, 255, 0.2)';
- synthCtx.lineWidth = 1;
- synthCtx.beginPath();
- synthCtx.moveTo(0, height / 2);
- synthCtx.lineTo(width, height / 2);
- synthCtx.stroke();
-
- // Debug: show signal level value
- if (isScannerRunning || isDirectListening) {
- synthCtx.fillStyle = 'rgba(255, 255, 255, 0.5)';
- synthCtx.font = '9px monospace';
- synthCtx.fillText(`lvl:${Math.round(currentSignalLevel)}`, 4, 10);
- }
-
- synthAnimationId = requestAnimationFrame(drawSynthesizer);
-}
-
-function stopSynthesizer() {
- if (synthAnimationId) {
- cancelAnimationFrame(synthAnimationId);
- synthAnimationId = null;
- }
-}
-
-// ============== INITIALIZATION ==============
-
-/**
- * Get the audio stream URL with parameters
- * Streams directly from Flask - no Icecast needed
- */
-function getStreamUrl(freq, mod) {
- const frequency = freq || parseFloat(document.getElementById('radioScanStart')?.value) || 118.0;
- const modulation = mod || currentModulation || 'am';
- const squelch = parseInt(document.getElementById('radioSquelchValue')?.textContent) || 30;
- const gain = parseInt(document.getElementById('radioGainValue')?.textContent) || 40;
- return `/listening/audio/stream?freq=${frequency}&mod=${modulation}&squelch=${squelch}&gain=${gain}&t=${Date.now()}`;
-}
-
-function initListeningPost() {
- checkScannerTools();
- checkAudioTools();
-
- // WebSocket audio disabled for now - using HTTP streaming
- // initWebSocketAudio();
-
- // Initialize synthesizer visualization
- initSynthesizer();
-
- // Initialize radio knobs if the component is available
- if (typeof initRadioKnobs === 'function') {
- initRadioKnobs();
- }
-
- // Connect radio knobs to scanner controls
- initRadioKnobControls();
-
- // Step dropdown - sync with scanner when changed
- const stepSelect = document.getElementById('radioScanStep');
- if (stepSelect) {
- stepSelect.addEventListener('change', function() {
- const step = parseFloat(this.value);
- console.log('[SCANNER] Step changed to:', step, 'kHz');
- updateScannerConfig({ step: step });
- });
- }
-
- // Dwell dropdown - sync with scanner when changed
- const dwellSelect = document.getElementById('radioScanDwell');
- if (dwellSelect) {
- dwellSelect.addEventListener('change', function() {
- const dwell = parseInt(this.value);
- console.log('[SCANNER] Dwell changed to:', dwell, 's');
- updateScannerConfig({ dwell_time: dwell });
- });
- }
-
- // Set up audio player error handling
- const audioPlayer = document.getElementById('audioPlayer');
- if (audioPlayer) {
- audioPlayer.addEventListener('error', function(e) {
- console.warn('Audio player error:', e);
- if (isAudioPlaying && audioReconnectAttempts < MAX_AUDIO_RECONNECT) {
- audioReconnectAttempts++;
- setTimeout(() => {
- audioPlayer.src = getStreamUrl();
- audioPlayer.play().catch(() => {});
- }, 500);
- }
- });
-
- audioPlayer.addEventListener('stalled', function() {
- if (isAudioPlaying) {
- audioPlayer.load();
- audioPlayer.play().catch(() => {});
- }
- });
-
- audioPlayer.addEventListener('playing', function() {
- audioReconnectAttempts = 0;
- });
- }
-
- // Keyboard controls for frequency tuning
- document.addEventListener('keydown', function(e) {
- // Only active in listening mode
- if (typeof currentMode !== 'undefined' && currentMode !== 'listening') {
- return;
- }
-
- // Don't intercept if user is typing in an input
- const activeEl = document.activeElement;
- if (activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA' || activeEl.tagName === 'SELECT')) {
- return;
- }
-
- // Arrow keys for tuning
- // Up/Down: fine tuning (Shift for ultra-fine)
- // Left/Right: coarse tuning (Shift for very coarse)
- let delta = 0;
- switch (e.key) {
- case 'ArrowUp':
- delta = e.shiftKey ? 0.005 : 0.05;
- break;
- case 'ArrowDown':
- delta = e.shiftKey ? -0.005 : -0.05;
- break;
- case 'ArrowRight':
- delta = e.shiftKey ? 1 : 0.1;
- break;
- case 'ArrowLeft':
- delta = e.shiftKey ? -1 : -0.1;
- break;
- default:
- return; // Not a tuning key
- }
-
- e.preventDefault();
- tuneFreq(delta);
- });
-
- // Check if we arrived from Spy Stations with a tune request
- checkIncomingTuneRequest();
-}
-
-/**
- * Check for incoming tune request from Spy Stations or other pages
- */
-function checkIncomingTuneRequest() {
- const tuneFreq = sessionStorage.getItem('tuneFrequency');
- const tuneMode = sessionStorage.getItem('tuneMode');
-
- if (tuneFreq) {
- // Clear the session storage first
- sessionStorage.removeItem('tuneFrequency');
- sessionStorage.removeItem('tuneMode');
-
- // Parse and validate frequency
- const freq = parseFloat(tuneFreq);
- if (!isNaN(freq) && freq >= 0.01 && freq <= 2000) {
- console.log('[LISTEN] Incoming tune request:', freq, 'MHz, mode:', tuneMode || 'default');
-
- // Determine modulation (default to USB for HF/number stations)
- const mod = tuneMode || (freq < 30 ? 'usb' : 'am');
-
- // Use quickTune to set frequency and modulation
- quickTune(freq, mod);
-
- // Show notification
- if (typeof showNotification === 'function') {
- showNotification('Tuned to ' + freq.toFixed(3) + ' MHz', mod.toUpperCase() + ' mode');
- }
- }
- }
-}
-
-// Initialize when DOM is ready
-document.addEventListener('DOMContentLoaded', initListeningPost);
-
-// ============== UNIFIED RADIO CONTROLS ==============
-
-/**
- * Toggle direct listen mode (tune to start frequency and listen)
- */
-function toggleDirectListen() {
- console.log('[LISTEN] toggleDirectListen called, isDirectListening:', isDirectListening);
- if (isDirectListening) {
- stopDirectListen();
- } else {
- // First press - start immediately, don't debounce
- startDirectListenImmediate();
- }
-}
-
-// Debounce for startDirectListen
-let listenDebounceTimer = null;
-// Flag to prevent overlapping restart attempts
-let isRestarting = false;
-// Flag indicating another restart is needed after current one finishes
-let restartPending = false;
-// Debounce for frequency tuning (user might be scrolling through)
-// Needs to be long enough for SDR to fully release between restarts
-const TUNE_DEBOUNCE_MS = 600;
-
-/**
- * Start direct listening - debounced for frequency changes
- */
-function startDirectListen() {
- if (listenDebounceTimer) {
- clearTimeout(listenDebounceTimer);
- }
- listenDebounceTimer = setTimeout(async () => {
- // If already restarting, mark that we need another restart when done
- if (isRestarting) {
- console.log('[LISTEN] Restart in progress, will retry after');
- restartPending = true;
- return;
- }
-
- await _startDirectListenInternal();
-
- // If another restart was requested during this one, do it now
- while (restartPending) {
- restartPending = false;
- console.log('[LISTEN] Processing pending restart');
- await _startDirectListenInternal();
- }
- }, TUNE_DEBOUNCE_MS);
-}
-
-/**
- * Start listening immediately (no debounce) - for button press
- */
-async function startDirectListenImmediate() {
- if (listenDebounceTimer) {
- clearTimeout(listenDebounceTimer);
- listenDebounceTimer = null;
- }
- restartPending = false; // Clear any pending
- if (isRestarting) {
- console.log('[LISTEN] Waiting for current restart to finish...');
- // Wait for current restart to complete (max 5 seconds)
- let waitCount = 0;
- while (isRestarting && waitCount < 50) {
- await new Promise(r => setTimeout(r, 100));
- waitCount++;
- }
- }
- await _startDirectListenInternal();
-}
-
-// ============== WEBSOCKET AUDIO ==============
-
-/**
- * Initialize WebSocket audio connection
- */
-function initWebSocketAudio() {
- if (audioWebSocket && audioWebSocket.readyState === WebSocket.OPEN) {
- return audioWebSocket;
- }
-
- const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
- const wsUrl = `${protocol}//${window.location.host}/ws/audio`;
-
- console.log('[WS-AUDIO] Connecting to:', wsUrl);
- audioWebSocket = new WebSocket(wsUrl);
- audioWebSocket.binaryType = 'arraybuffer';
-
- audioWebSocket.onopen = () => {
- console.log('[WS-AUDIO] Connected');
- isWebSocketAudio = true;
- };
-
- audioWebSocket.onclose = () => {
- console.log('[WS-AUDIO] Disconnected');
- isWebSocketAudio = false;
- audioWebSocket = null;
- };
-
- audioWebSocket.onerror = (e) => {
- console.error('[WS-AUDIO] Error:', e);
- isWebSocketAudio = false;
- };
-
- audioWebSocket.onmessage = (event) => {
- if (typeof event.data === 'string') {
- // JSON message (status updates)
- try {
- const msg = JSON.parse(event.data);
- console.log('[WS-AUDIO] Status:', msg);
- if (msg.status === 'error') {
- addScannerLogEntry('Audio error: ' + msg.message, '', 'error');
- }
- } catch (e) {}
- } else {
- // Binary data (audio)
- handleWebSocketAudioData(event.data);
- }
- };
-
- return audioWebSocket;
-}
-
-/**
- * Handle incoming WebSocket audio data
- */
-function handleWebSocketAudioData(data) {
- const audioPlayer = document.getElementById('scannerAudioPlayer');
- if (!audioPlayer) return;
-
- // Use MediaSource API to stream audio
- if (!audioPlayer.msSource) {
- setupMediaSource(audioPlayer);
- }
-
- if (audioPlayer.sourceBuffer && !audioPlayer.sourceBuffer.updating) {
- try {
- audioPlayer.sourceBuffer.appendBuffer(new Uint8Array(data));
- } catch (e) {
- // Buffer full or other error, skip this chunk
- }
- } else {
- // Queue data for later
- audioQueue.push(new Uint8Array(data));
- if (audioQueue.length > 50) audioQueue.shift(); // Prevent memory buildup
- }
-}
-
-/**
- * Setup MediaSource for streaming audio
- */
-function setupMediaSource(audioPlayer) {
- if (!window.MediaSource) {
- console.warn('[WS-AUDIO] MediaSource not supported');
- return;
- }
-
- const mediaSource = new MediaSource();
- audioPlayer.src = URL.createObjectURL(mediaSource);
- audioPlayer.msSource = mediaSource;
-
- mediaSource.addEventListener('sourceopen', () => {
- try {
- const sourceBuffer = mediaSource.addSourceBuffer('audio/mpeg');
- audioPlayer.sourceBuffer = sourceBuffer;
-
- sourceBuffer.addEventListener('updateend', () => {
- // Process queued data
- if (audioQueue.length > 0 && !sourceBuffer.updating) {
- try {
- sourceBuffer.appendBuffer(audioQueue.shift());
- } catch (e) {}
- }
- });
- } catch (e) {
- console.error('[WS-AUDIO] Failed to create source buffer:', e);
- }
- });
-}
-
-/**
- * Send command over WebSocket
- */
-function sendWebSocketCommand(cmd, config = {}) {
- if (!audioWebSocket || audioWebSocket.readyState !== WebSocket.OPEN) {
- initWebSocketAudio();
- // Wait for connection and retry
- setTimeout(() => sendWebSocketCommand(cmd, config), 500);
- return;
- }
-
- audioWebSocket.send(JSON.stringify({ cmd, config }));
-}
-
-async function _startDirectListenInternal() {
- console.log('[LISTEN] _startDirectListenInternal called');
-
- // Prevent overlapping restarts
- if (isRestarting) {
- console.log('[LISTEN] Already restarting, skipping');
- return;
- }
- isRestarting = true;
-
- try {
- if (isScannerRunning) {
- stopScanner();
- }
-
- const freqInput = document.getElementById('radioScanStart');
- const freq = freqInput ? parseFloat(freqInput.value) : 118.0;
- const squelch = parseInt(document.getElementById('radioSquelchValue')?.textContent) || 30;
- const gain = parseInt(document.getElementById('radioGainValue')?.textContent) || 40;
-
- console.log('[LISTEN] Tuning to:', freq, 'MHz', currentModulation);
-
- const listenBtn = document.getElementById('radioListenBtn');
- if (listenBtn) {
- listenBtn.innerHTML = Icons.loader('icon--sm') + ' TUNING...';
- listenBtn.style.background = 'var(--accent-orange)';
- listenBtn.style.borderColor = 'var(--accent-orange)';
- }
-
- const audioPlayer = document.getElementById('scannerAudioPlayer');
- if (!audioPlayer) {
- addScannerLogEntry('Audio player not found', '', 'error');
- updateDirectListenUI(false);
- return;
- }
-
- // Fully reset audio element to clean state
- audioPlayer.oncanplay = null; // Remove old handler
- try {
- audioPlayer.pause();
- } catch (e) {}
- audioPlayer.removeAttribute('src');
- audioPlayer.load(); // Reset the element
-
- // Start audio on backend (it handles stopping old stream)
- const response = await fetch('/listening/audio/start', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- frequency: freq,
- modulation: currentModulation,
- squelch: squelch,
- gain: gain
- })
- });
-
- const result = await response.json();
- console.log('[LISTEN] Backend:', result.status);
-
- if (result.status !== 'started') {
- console.error('[LISTEN] Failed:', result.message);
- addScannerLogEntry('Failed: ' + (result.message || 'Unknown error'), '', 'error');
- isDirectListening = false;
- updateDirectListenUI(false);
- return;
- }
-
- // Wait for stream to be ready (backend needs time after restart)
- await new Promise(r => setTimeout(r, 300));
-
- // Connect to new stream
- const streamUrl = `/listening/audio/stream?t=${Date.now()}`;
- console.log('[LISTEN] Connecting to stream:', streamUrl);
- audioPlayer.src = streamUrl;
-
- // Apply current volume from knob
- const volumeKnob = document.getElementById('radioVolumeKnob');
- if (volumeKnob && volumeKnob._knob) {
- audioPlayer.volume = volumeKnob._knob.getValue() / 100;
- } else if (volumeKnob) {
- const knobValue = parseFloat(volumeKnob.dataset.value) || 80;
- audioPlayer.volume = knobValue / 100;
- }
-
- // Wait for audio to be ready then play
- audioPlayer.oncanplay = () => {
- console.log('[LISTEN] Audio can play');
- audioPlayer.play().catch(e => console.warn('[LISTEN] Autoplay blocked:', e));
- };
-
- // Also try to play immediately (some browsers need this)
- audioPlayer.play().catch(e => {
- console.log('[LISTEN] Initial play blocked, waiting for canplay');
- });
-
- // Initialize audio visualizer to feed signal levels to synthesizer
- initAudioVisualizer();
-
- isDirectListening = true;
- updateDirectListenUI(true, freq);
- addScannerLogEntry(`${freq.toFixed(3)} MHz (${currentModulation.toUpperCase()})`, '', 'signal');
-
- } catch (e) {
- console.error('[LISTEN] Error:', e);
- addScannerLogEntry('Error: ' + e.message, '', 'error');
- isDirectListening = false;
- updateDirectListenUI(false);
- } finally {
- isRestarting = false;
- }
-}
-
-/**
- * Stop direct listening
- */
-function stopDirectListen() {
- console.log('[LISTEN] Stopping');
-
- // Clear all pending state
- if (listenDebounceTimer) {
- clearTimeout(listenDebounceTimer);
- listenDebounceTimer = null;
- }
- restartPending = false;
-
- const audioPlayer = document.getElementById('scannerAudioPlayer');
- if (audioPlayer) {
- audioPlayer.pause();
- // Clear MediaSource if using WebSocket
- if (audioPlayer.msSource) {
- try {
- audioPlayer.msSource.endOfStream();
- } catch (e) {}
- audioPlayer.msSource = null;
- audioPlayer.sourceBuffer = null;
- }
- audioPlayer.src = '';
- }
- audioQueue = [];
-
- // Stop via WebSocket if connected
- if (audioWebSocket && audioWebSocket.readyState === WebSocket.OPEN) {
- sendWebSocketCommand('stop');
- }
-
- // Also stop via HTTP (fallback)
- fetch('/listening/audio/stop', { method: 'POST' }).catch(() => {});
-
- isDirectListening = false;
- currentSignalLevel = 0;
- updateDirectListenUI(false);
- addScannerLogEntry('Listening stopped');
-}
-
-/**
- * Update UI for direct listen mode
- */
-function updateDirectListenUI(isPlaying, freq) {
- const listenBtn = document.getElementById('radioListenBtn');
- const statusLabel = document.getElementById('mainScannerModeLabel');
- const freqDisplay = document.getElementById('mainScannerFreq');
- const quickStatus = document.getElementById('lpQuickStatus');
- const quickFreq = document.getElementById('lpQuickFreq');
-
- if (listenBtn) {
- if (isPlaying) {
- listenBtn.innerHTML = Icons.stop('icon--sm') + ' STOP';
- listenBtn.style.background = 'var(--accent-red)';
- listenBtn.style.borderColor = 'var(--accent-red)';
- } else {
- listenBtn.innerHTML = Icons.headphones('icon--sm') + ' LISTEN';
- listenBtn.style.background = 'var(--accent-purple)';
- listenBtn.style.borderColor = 'var(--accent-purple)';
- }
- }
-
- if (statusLabel) {
- statusLabel.textContent = isPlaying ? 'LISTENING' : 'STOPPED';
- statusLabel.style.color = isPlaying ? 'var(--accent-green)' : 'var(--text-muted)';
- }
-
- if (freqDisplay && freq) {
- freqDisplay.textContent = freq.toFixed(3);
- }
-
- if (quickStatus) {
- quickStatus.textContent = isPlaying ? 'LISTENING' : 'IDLE';
- quickStatus.style.color = isPlaying ? 'var(--accent-green)' : 'var(--accent-cyan)';
- }
-
- if (quickFreq && freq) {
- quickFreq.textContent = freq.toFixed(3) + ' MHz';
- }
-}
-
-/**
- * Tune frequency by delta
- */
-function tuneFreq(delta) {
- const freqInput = document.getElementById('radioScanStart');
- if (freqInput) {
- let newFreq = parseFloat(freqInput.value) + delta;
- // Round to 3 decimal places to avoid floating-point precision issues
- newFreq = Math.round(newFreq * 1000) / 1000;
- newFreq = Math.max(24, Math.min(1800, newFreq));
- freqInput.value = newFreq.toFixed(3);
-
- // Update display
- const freqDisplay = document.getElementById('mainScannerFreq');
- if (freqDisplay) {
- freqDisplay.textContent = newFreq.toFixed(3);
- }
-
- // Update tuning dial position (silent to avoid duplicate restart)
- const mainTuningDial = document.getElementById('mainTuningDial');
- if (mainTuningDial && mainTuningDial._dial) {
- mainTuningDial._dial.setValue(newFreq, true);
- }
-
- const quickFreq = document.getElementById('lpQuickFreq');
- if (quickFreq) {
- quickFreq.textContent = newFreq.toFixed(3) + ' MHz';
- }
-
- // If currently listening, restart stream at new frequency
- if (isDirectListening) {
- startDirectListen();
- }
- }
-}
-
-/**
- * Quick tune to a preset frequency
- */
-function quickTune(freq, mod) {
- // Update frequency inputs
- const startInput = document.getElementById('radioScanStart');
- if (startInput) {
- startInput.value = freq;
- }
-
- // Update modulation (don't trigger auto-restart here, we'll handle it below)
- if (mod) {
- currentModulation = mod;
- // Update modulation UI without triggering restart
- document.querySelectorAll('#modBtnBank .radio-btn').forEach(btn => {
- btn.classList.toggle('active', btn.dataset.mod === mod);
- });
- const badge = document.getElementById('mainScannerMod');
- if (badge) {
- const modLabels = { am: 'AM', fm: 'NFM', wfm: 'WFM', usb: 'USB', lsb: 'LSB' };
- badge.textContent = modLabels[mod] || mod.toUpperCase();
- }
- }
-
- // Update display
- const freqDisplay = document.getElementById('mainScannerFreq');
- if (freqDisplay) {
- freqDisplay.textContent = freq.toFixed(3);
- }
-
- // Update tuning dial position (silent to avoid duplicate restart)
- const mainTuningDial = document.getElementById('mainTuningDial');
- if (mainTuningDial && mainTuningDial._dial) {
- mainTuningDial._dial.setValue(freq, true);
- }
-
- const quickFreq = document.getElementById('lpQuickFreq');
- if (quickFreq) {
- quickFreq.textContent = freq.toFixed(3) + ' MHz';
- }
-
- addScannerLogEntry(`Quick tuned to ${freq.toFixed(3)} MHz (${mod.toUpperCase()})`);
-
- // If currently listening, restart immediately (this is a deliberate preset selection)
- if (isDirectListening) {
- startDirectListenImmediate();
- }
-}
-
-/**
- * Enhanced setModulation to also update currentModulation
- * Uses immediate restart if currently listening
- */
-const originalSetModulation = window.setModulation;
-window.setModulation = function(mod) {
- console.log('[MODULATION] Setting modulation to:', mod, 'isListening:', isDirectListening);
- currentModulation = mod;
-
- // Update modulation button states
- document.querySelectorAll('#modBtnBank .radio-btn').forEach(btn => {
- btn.classList.toggle('active', btn.dataset.mod === mod);
- });
-
- // Update badge
- const badge = document.getElementById('mainScannerMod');
- if (badge) {
- const modLabels = { am: 'AM', fm: 'NFM', wfm: 'WFM', usb: 'USB', lsb: 'LSB' };
- badge.textContent = modLabels[mod] || mod.toUpperCase();
- }
-
- // Update scanner modulation select if exists
- const modSelect = document.getElementById('scannerModulation');
- if (modSelect) {
- modSelect.value = mod;
- }
-
- // Sync with scanner if running
- updateScannerConfig({ modulation: mod });
-
- // If currently listening, restart immediately (deliberate modulation change)
- if (isDirectListening) {
- console.log('[MODULATION] Restarting audio with new modulation:', mod);
- startDirectListenImmediate();
- } else {
- console.log('[MODULATION] Not listening, just updated UI');
- }
-};
-
-/**
- * Update sidebar quick status
- */
-function updateQuickStatus() {
- const quickStatus = document.getElementById('lpQuickStatus');
- const quickFreq = document.getElementById('lpQuickFreq');
- const quickSignals = document.getElementById('lpQuickSignals');
-
- if (quickStatus) {
- if (isScannerRunning) {
- quickStatus.textContent = isScannerPaused ? 'PAUSED' : 'SCANNING';
- quickStatus.style.color = isScannerPaused ? 'var(--accent-orange)' : 'var(--accent-green)';
- } else if (isDirectListening) {
- quickStatus.textContent = 'LISTENING';
- quickStatus.style.color = 'var(--accent-green)';
- } else {
- quickStatus.textContent = 'IDLE';
- quickStatus.style.color = 'var(--accent-cyan)';
- }
- }
-
- if (quickSignals) {
- quickSignals.textContent = scannerSignalCount;
- }
-}
-
-// ============== SIDEBAR CONTROLS ==============
-
-// Frequency bookmarks stored in localStorage
-let frequencyBookmarks = [];
-
-/**
- * Load bookmarks from localStorage
- */
-function loadFrequencyBookmarks() {
- try {
- const saved = localStorage.getItem('lpBookmarks');
- if (saved) {
- frequencyBookmarks = JSON.parse(saved);
- renderBookmarks();
- }
- } catch (e) {
- console.warn('Failed to load bookmarks:', e);
- }
-}
-
-/**
- * Save bookmarks to localStorage
- */
-function saveFrequencyBookmarks() {
- try {
- localStorage.setItem('lpBookmarks', JSON.stringify(frequencyBookmarks));
- } catch (e) {
- console.warn('Failed to save bookmarks:', e);
- }
-}
-
-/**
- * Add a frequency bookmark
- */
-function addFrequencyBookmark() {
- const input = document.getElementById('bookmarkFreqInput');
- if (!input) return;
-
- const freq = parseFloat(input.value);
- if (isNaN(freq) || freq <= 0) {
- if (typeof showNotification === 'function') {
- showNotification('Invalid Frequency', 'Please enter a valid frequency');
- }
- return;
- }
-
- // Check for duplicates
- if (frequencyBookmarks.some(b => Math.abs(b.freq - freq) < 0.001)) {
- if (typeof showNotification === 'function') {
- showNotification('Duplicate', 'This frequency is already bookmarked');
- }
- return;
- }
-
- frequencyBookmarks.push({
- freq: freq,
- mod: currentModulation || 'am',
- added: new Date().toISOString()
- });
-
- saveFrequencyBookmarks();
- renderBookmarks();
- input.value = '';
-
- if (typeof showNotification === 'function') {
- showNotification('Bookmark Added', `${freq.toFixed(3)} MHz saved`);
- }
-}
-
-/**
- * Remove a bookmark by index
- */
-function removeBookmark(index) {
- frequencyBookmarks.splice(index, 1);
- saveFrequencyBookmarks();
- renderBookmarks();
-}
-
-/**
- * Render bookmarks list
- */
-function renderBookmarks() {
- const container = document.getElementById('bookmarksList');
- if (!container) return;
-
- if (frequencyBookmarks.length === 0) {
- container.innerHTML = 'No bookmarks saved
';
- return;
- }
-
- container.innerHTML = frequencyBookmarks.map((b, i) => `
-
- ${b.freq.toFixed(3)} MHz
- ${b.mod.toUpperCase()}
-
-
- `).join('');
-}
-
-
-/**
- * Add a signal to the sidebar recent signals list
- */
-function addSidebarRecentSignal(freq, mod) {
- const container = document.getElementById('sidebarRecentSignals');
- if (!container) return;
-
- // Clear placeholder if present
- if (container.innerHTML.includes('No signals yet')) {
- container.innerHTML = '';
- }
-
- const timestamp = new Date().toLocaleTimeString();
- const signalDiv = document.createElement('div');
- signalDiv.style.cssText = 'display: flex; justify-content: space-between; align-items: center; padding: 3px 6px; background: rgba(0,255,100,0.1); border-left: 2px solid var(--accent-green); margin-bottom: 2px; border-radius: 2px;';
- signalDiv.innerHTML = `
- ${freq.toFixed(3)}
- ${timestamp}
- `;
-
- container.insertBefore(signalDiv, container.firstChild);
-
- // Keep only last 10 signals
- while (container.children.length > 10) {
- container.removeChild(container.lastChild);
- }
-}
-
-// Load bookmarks on init
-document.addEventListener('DOMContentLoaded', loadFrequencyBookmarks);
-
-/**
- * Set listening post running state from external source (agent sync).
- * Called by syncModeUI in agents.js when switching to an agent that already has scan running.
- */
-function setListeningPostRunning(isRunning, agentId = null) {
- console.log(`[ListeningPost] setListeningPostRunning: ${isRunning}, agent: ${agentId}`);
-
- isScannerRunning = isRunning;
-
- if (isRunning && agentId !== null && agentId !== 'local') {
- // Agent has scan running - sync UI and start polling
- listeningPostCurrentAgent = agentId;
-
- // Update main scan button (radioScanBtn is the actual ID)
- const radioScanBtn = document.getElementById('radioScanBtn');
- if (radioScanBtn) {
- radioScanBtn.innerHTML = 'STOP';
- radioScanBtn.style.background = 'var(--accent-red)';
- radioScanBtn.style.borderColor = 'var(--accent-red)';
- }
-
- // Update status display
- updateScannerDisplay('SCANNING', 'var(--accent-green)');
-
- // Disable listen button (can't stream audio from agent)
- updateListenButtonState(true);
-
- // Start polling for agent data
- startListeningPostPolling();
- } else if (!isRunning) {
- // Not running - reset UI
- listeningPostCurrentAgent = null;
-
- // Reset scan button
- const radioScanBtn = document.getElementById('radioScanBtn');
- if (radioScanBtn) {
- radioScanBtn.innerHTML = 'SCAN';
- radioScanBtn.style.background = '';
- radioScanBtn.style.borderColor = '';
- }
-
- // Update status
- updateScannerDisplay('IDLE', 'var(--text-secondary)');
-
- // Only re-enable listen button if we're in local mode
- // (agent mode can't stream audio over HTTP)
- const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
- updateListenButtonState(isAgentMode);
-
- // Clear polling
- if (listeningPostPollTimer) {
- clearInterval(listeningPostPollTimer);
- listeningPostPollTimer = null;
- }
- }
-}
-
-// Export for agent sync
-window.setListeningPostRunning = setListeningPostRunning;
-window.updateListenButtonState = updateListenButtonState;
-
-// Export functions for HTML onclick handlers
-window.toggleDirectListen = toggleDirectListen;
-window.startDirectListen = startDirectListen;
-window.stopDirectListen = stopDirectListen;
-window.toggleScanner = toggleScanner;
-window.startScanner = startScanner;
-window.stopScanner = stopScanner;
-window.pauseScanner = pauseScanner;
-window.skipSignal = skipSignal;
-// Note: setModulation is already exported with enhancements above
-window.setBand = setBand;
-window.tuneFreq = tuneFreq;
-window.quickTune = quickTune;
-window.checkIncomingTuneRequest = checkIncomingTuneRequest;
-window.addFrequencyBookmark = addFrequencyBookmark;
-window.removeBookmark = removeBookmark;
-window.tuneToFrequency = tuneToFrequency;
-window.clearScannerLog = clearScannerLog;
-window.exportScannerLog = exportScannerLog;
-
+/**
+ * Intercept - Listening Post Mode
+ * Frequency scanner and manual audio receiver
+ */
+
+// ============== STATE ==============
+
+let isScannerRunning = false;
+let isScannerPaused = false;
+let scannerEventSource = null;
+let scannerSignalCount = 0;
+let scannerLogEntries = [];
+let scannerFreqsScanned = 0;
+let scannerCycles = 0;
+let scannerStartFreq = 118;
+let scannerEndFreq = 137;
+let scannerSignalActive = false;
+let lastScanProgress = null;
+let scannerTotalSteps = 0;
+let scannerMethod = null;
+let scannerStepKhz = 25;
+let lastScanFreq = null;
+
+// Audio state
+let isAudioPlaying = false;
+let audioToolsAvailable = { rtl_fm: false, ffmpeg: false };
+let audioReconnectAttempts = 0;
+const MAX_AUDIO_RECONNECT = 3;
+
+// WebSocket audio state
+let audioWebSocket = null;
+let audioQueue = [];
+let isWebSocketAudio = false;
+let audioFetchController = null;
+let audioUnlockRequested = false;
+let scannerSnrThreshold = 8;
+
+// Visualizer state
+let visualizerContext = null;
+let visualizerAnalyser = null;
+let visualizerSource = null;
+let visualizerAnimationId = null;
+let peakLevel = 0;
+let peakDecay = 0.95;
+
+// Signal level for synthesizer visualization
+let currentSignalLevel = 0;
+let signalLevelThreshold = 1000;
+
+// Track recent signal hits to prevent duplicates
+let recentSignalHits = new Map();
+
+// Direct listen state
+let isDirectListening = false;
+let currentModulation = 'am';
+
+// Agent mode state
+let listeningPostCurrentAgent = null;
+let listeningPostPollTimer = null;
+
+// ============== PRESETS ==============
+
+const scannerPresets = {
+ fm: { start: 88, end: 108, step: 200, mod: 'wfm' },
+ air: { start: 118, end: 137, step: 25, mod: 'am' },
+ marine: { start: 156, end: 163, step: 25, mod: 'fm' },
+ amateur2m: { start: 144, end: 148, step: 12.5, mod: 'fm' },
+ pager: { start: 152, end: 160, step: 25, mod: 'fm' },
+ amateur70cm: { start: 420, end: 450, step: 25, mod: 'fm' }
+};
+
+const audioPresets = {
+ fm: { freq: 98.1, mod: 'wfm' },
+ airband: { freq: 121.5, mod: 'am' }, // Emergency/guard frequency
+ marine: { freq: 156.8, mod: 'fm' }, // Channel 16 - distress
+ amateur2m: { freq: 146.52, mod: 'fm' }, // 2m calling frequency
+ amateur70cm: { freq: 446.0, mod: 'fm' }
+};
+
+// ============== SCANNER TOOLS CHECK ==============
+
+function checkScannerTools() {
+ fetch('/listening/tools')
+ .then(r => r.json())
+ .then(data => {
+ const warnings = [];
+ if (!data.rtl_fm) {
+ warnings.push('rtl_fm not found - install rtl-sdr tools');
+ }
+ if (!data.ffmpeg) {
+ warnings.push('ffmpeg not found - install: brew install ffmpeg (macOS) or apt install ffmpeg (Linux)');
+ }
+
+ const warningDiv = document.getElementById('scannerToolsWarning');
+ const warningText = document.getElementById('scannerToolsWarningText');
+ if (warningDiv && warnings.length > 0) {
+ warningText.innerHTML = warnings.join('
');
+ warningDiv.style.display = 'block';
+ document.getElementById('scannerStartBtn').disabled = true;
+ document.getElementById('scannerStartBtn').style.opacity = '0.5';
+ } else if (warningDiv) {
+ warningDiv.style.display = 'none';
+ document.getElementById('scannerStartBtn').disabled = false;
+ document.getElementById('scannerStartBtn').style.opacity = '1';
+ }
+ })
+ .catch(() => {});
+}
+
+// ============== SCANNER HELPERS ==============
+
+/**
+ * Get the currently selected device from the global SDR selector
+ */
+function getSelectedDevice() {
+ const select = document.getElementById('deviceSelect');
+ return parseInt(select?.value || '0');
+}
+
+/**
+ * Get the currently selected SDR type from the global selector
+ */
+function getSelectedSDRTypeForScanner() {
+ const select = document.getElementById('sdrTypeSelect');
+ return select?.value || 'rtlsdr';
+}
+
+// ============== SCANNER PRESETS ==============
+
+function applyScannerPreset() {
+ const preset = document.getElementById('scannerPreset').value;
+ if (preset !== 'custom' && scannerPresets[preset]) {
+ const p = scannerPresets[preset];
+ document.getElementById('scannerStartFreq').value = p.start;
+ document.getElementById('scannerEndFreq').value = p.end;
+ document.getElementById('scannerStep').value = p.step;
+ document.getElementById('scannerModulation').value = p.mod;
+ }
+}
+
+// ============== SCANNER CONTROLS ==============
+
+function toggleScanner() {
+ if (isScannerRunning) {
+ stopScanner();
+ } else {
+ startScanner();
+ }
+}
+
+function startScanner() {
+ // Use unified radio controls - read all current UI values
+ const startFreq = parseFloat(document.getElementById('radioScanStart')?.value || 118);
+ const endFreq = parseFloat(document.getElementById('radioScanEnd')?.value || 137);
+ const stepSelect = document.getElementById('radioScanStep');
+ const step = stepSelect ? parseFloat(stepSelect.value) : 25;
+ const modulation = currentModulation || 'am';
+ const squelch = parseInt(document.getElementById('radioSquelchValue')?.textContent) || 30;
+ const gain = parseInt(document.getElementById('radioGainValue')?.textContent) || 40;
+ const dwellSelect = document.getElementById('radioScanDwell');
+ const dwell = dwellSelect ? parseInt(dwellSelect.value) : 10;
+ const device = getSelectedDevice();
+ const snrThreshold = scannerSnrThreshold || 12;
+
+ // Check if using agent mode
+ const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
+ listeningPostCurrentAgent = isAgentMode ? currentAgent : null;
+
+ // Disable listen button for agent mode (audio can't stream over HTTP)
+ updateListenButtonState(isAgentMode);
+
+ if (startFreq >= endFreq) {
+ if (typeof showNotification === 'function') {
+ showNotification('Scanner Error', 'End frequency must be greater than start');
+ }
+ return;
+ }
+
+ // Check if device is available (only for local mode)
+ if (!isAgentMode && typeof checkDeviceAvailability === 'function' && !checkDeviceAvailability('scanner')) {
+ return;
+ }
+
+ // Store scanner range for progress calculation
+ scannerStartFreq = startFreq;
+ scannerEndFreq = endFreq;
+ scannerFreqsScanned = 0;
+ scannerCycles = 0;
+ lastScanProgress = null;
+ scannerTotalSteps = Math.max(1, Math.round(((endFreq - startFreq) * 1000) / step));
+ scannerStepKhz = step;
+ lastScanFreq = null;
+
+ // Update sidebar display
+ updateScannerDisplay('STARTING...', 'var(--accent-orange)');
+
+ // Show progress bars
+ const progressEl = document.getElementById('scannerProgress');
+ if (progressEl) {
+ progressEl.style.display = 'block';
+ document.getElementById('scannerRangeStart').textContent = startFreq.toFixed(1);
+ document.getElementById('scannerRangeEnd').textContent = endFreq.toFixed(1);
+ }
+
+ const mainProgress = document.getElementById('mainScannerProgress');
+ if (mainProgress) {
+ mainProgress.style.display = 'block';
+ document.getElementById('mainRangeStart').textContent = startFreq.toFixed(1) + ' MHz';
+ document.getElementById('mainRangeEnd').textContent = endFreq.toFixed(1) + ' MHz';
+ }
+
+ // Determine endpoint based on agent mode
+ const endpoint = isAgentMode
+ ? `/controller/agents/${currentAgent}/listening_post/start`
+ : '/listening/scanner/start';
+
+ fetch(endpoint, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ start_freq: startFreq,
+ end_freq: endFreq,
+ step: step,
+ modulation: modulation,
+ squelch: squelch,
+ gain: gain,
+ dwell_time: dwell,
+ device: device,
+ bias_t: typeof getBiasTEnabled === 'function' ? getBiasTEnabled() : false,
+ snr_threshold: snrThreshold,
+ scan_method: 'power'
+ })
+ })
+ .then(r => r.json())
+ .then(data => {
+ // Handle controller proxy response format
+ const scanResult = isAgentMode && data.result ? data.result : data;
+
+ if (scanResult.status === 'started' || scanResult.status === 'success') {
+ if (!isAgentMode && typeof reserveDevice === 'function') reserveDevice(device, 'scanner');
+ isScannerRunning = true;
+ isScannerPaused = false;
+ scannerSignalActive = false;
+ scannerMethod = (scanResult.config && scanResult.config.scan_method) ? scanResult.config.scan_method : 'power';
+ if (scanResult.config) {
+ const cfgStart = parseFloat(scanResult.config.start_freq);
+ const cfgEnd = parseFloat(scanResult.config.end_freq);
+ const cfgStep = parseFloat(scanResult.config.step);
+ if (Number.isFinite(cfgStart)) scannerStartFreq = cfgStart;
+ if (Number.isFinite(cfgEnd)) scannerEndFreq = cfgEnd;
+ if (Number.isFinite(cfgStep)) scannerStepKhz = cfgStep;
+ scannerTotalSteps = Math.max(1, Math.round(((scannerEndFreq - scannerStartFreq) * 1000) / (scannerStepKhz || 1)));
+
+ const startInput = document.getElementById('radioScanStart');
+ if (startInput && Number.isFinite(cfgStart)) startInput.value = cfgStart.toFixed(3);
+ const endInput = document.getElementById('radioScanEnd');
+ if (endInput && Number.isFinite(cfgEnd)) endInput.value = cfgEnd.toFixed(3);
+
+ const rangeStart = document.getElementById('scannerRangeStart');
+ if (rangeStart && Number.isFinite(cfgStart)) rangeStart.textContent = cfgStart.toFixed(1);
+ const rangeEnd = document.getElementById('scannerRangeEnd');
+ if (rangeEnd && Number.isFinite(cfgEnd)) rangeEnd.textContent = cfgEnd.toFixed(1);
+ const mainRangeStart = document.getElementById('mainRangeStart');
+ if (mainRangeStart && Number.isFinite(cfgStart)) mainRangeStart.textContent = cfgStart.toFixed(1) + ' MHz';
+ const mainRangeEnd = document.getElementById('mainRangeEnd');
+ if (mainRangeEnd && Number.isFinite(cfgEnd)) mainRangeEnd.textContent = cfgEnd.toFixed(1) + ' MHz';
+ }
+
+ // Update controls (with null checks)
+ const startBtn = document.getElementById('scannerStartBtn');
+ if (startBtn) {
+ startBtn.textContent = 'Stop Scanner';
+ startBtn.classList.add('active');
+ }
+ const pauseBtn = document.getElementById('scannerPauseBtn');
+ if (pauseBtn) pauseBtn.disabled = false;
+
+ // Update radio scan button to show STOP
+ const radioScanBtn = document.getElementById('radioScanBtn');
+ if (radioScanBtn) {
+ radioScanBtn.innerHTML = Icons.stop('icon--sm') + ' STOP';
+ radioScanBtn.style.background = 'var(--accent-red)';
+ radioScanBtn.style.borderColor = 'var(--accent-red)';
+ }
+
+ updateScannerDisplay('SCANNING', 'var(--accent-cyan)');
+ const statusText = document.getElementById('scannerStatusText');
+ if (statusText) statusText.textContent = 'Scanning...';
+
+ // Show level meter
+ const levelMeter = document.getElementById('scannerLevelMeter');
+ if (levelMeter) levelMeter.style.display = 'block';
+
+ connectScannerStream(isAgentMode);
+ addScannerLogEntry('Scanner started', `Range: ${startFreq}-${endFreq} MHz, Step: ${step} kHz`);
+ if (typeof showNotification === 'function') {
+ showNotification('Scanner Started', `Scanning ${startFreq} - ${endFreq} MHz`);
+ }
+ } else {
+ updateScannerDisplay('ERROR', 'var(--accent-red)');
+ if (typeof showNotification === 'function') {
+ showNotification('Scanner Error', scanResult.message || scanResult.error || 'Failed to start');
+ }
+ }
+ })
+ .catch(err => {
+ const statusText = document.getElementById('scannerStatusText');
+ if (statusText) statusText.textContent = 'ERROR';
+ updateScannerDisplay('ERROR', 'var(--accent-red)');
+ if (typeof showNotification === 'function') {
+ showNotification('Scanner Error', err.message);
+ }
+ });
+}
+
+function stopScanner() {
+ const isAgentMode = listeningPostCurrentAgent !== null;
+ const endpoint = isAgentMode
+ ? `/controller/agents/${listeningPostCurrentAgent}/listening_post/stop`
+ : '/listening/scanner/stop';
+
+ fetch(endpoint, { method: 'POST' })
+ .then(() => {
+ if (!isAgentMode && typeof releaseDevice === 'function') releaseDevice('scanner');
+ listeningPostCurrentAgent = null;
+ isScannerRunning = false;
+ isScannerPaused = false;
+ scannerSignalActive = false;
+ currentSignalLevel = 0;
+ lastScanProgress = null;
+ scannerTotalSteps = 0;
+ scannerMethod = null;
+ scannerCycles = 0;
+ scannerFreqsScanned = 0;
+ lastScanFreq = null;
+
+ // Re-enable listen button (will be in local mode after stop)
+ updateListenButtonState(false);
+
+ // Clear polling timer
+ if (listeningPostPollTimer) {
+ clearInterval(listeningPostPollTimer);
+ listeningPostPollTimer = null;
+ }
+
+ // Update sidebar (with null checks)
+ const startBtn = document.getElementById('scannerStartBtn');
+ if (startBtn) {
+ startBtn.textContent = 'Start Scanner';
+ startBtn.classList.remove('active');
+ }
+ const pauseBtn = document.getElementById('scannerPauseBtn');
+ if (pauseBtn) {
+ pauseBtn.disabled = true;
+ pauseBtn.innerHTML = Icons.pause('icon--sm') + ' Pause';
+ }
+
+ // Update radio scan button
+ const radioScanBtn = document.getElementById('radioScanBtn');
+ if (radioScanBtn) {
+ radioScanBtn.innerHTML = '📡 SCAN';
+ radioScanBtn.style.background = '';
+ radioScanBtn.style.borderColor = '';
+ }
+
+ updateScannerDisplay('STOPPED', 'var(--text-muted)');
+ const currentFreq = document.getElementById('scannerCurrentFreq');
+ if (currentFreq) currentFreq.textContent = '---.--- MHz';
+ const modLabel = document.getElementById('scannerModLabel');
+ if (modLabel) modLabel.textContent = '--';
+
+ const progressEl = document.getElementById('scannerProgress');
+ if (progressEl) progressEl.style.display = 'none';
+
+ const signalPanel = document.getElementById('scannerSignalPanel');
+ if (signalPanel) signalPanel.style.display = 'none';
+
+ const levelMeter = document.getElementById('scannerLevelMeter');
+ if (levelMeter) levelMeter.style.display = 'none';
+
+ const statusText = document.getElementById('scannerStatusText');
+ if (statusText) statusText.textContent = 'Ready';
+
+ // Update main display
+ const mainModeLabel = document.getElementById('mainScannerModeLabel');
+ if (mainModeLabel) {
+ mainModeLabel.textContent = 'SCANNER STOPPED';
+ document.getElementById('mainScannerFreq').textContent = '---.---';
+ document.getElementById('mainScannerFreq').style.color = 'var(--text-muted)';
+ document.getElementById('mainScannerMod').textContent = '--';
+ }
+
+ const mainAnim = document.getElementById('mainScannerAnimation');
+ if (mainAnim) mainAnim.style.display = 'none';
+
+ const mainProgress = document.getElementById('mainScannerProgress');
+ if (mainProgress) mainProgress.style.display = 'none';
+
+ const mainSignalAlert = document.getElementById('mainSignalAlert');
+ if (mainSignalAlert) mainSignalAlert.style.display = 'none';
+
+ // Stop scanner audio
+ const scannerAudio = document.getElementById('scannerAudioPlayer');
+ if (scannerAudio) {
+ scannerAudio.pause();
+ scannerAudio.src = '';
+ }
+
+ if (scannerEventSource) {
+ scannerEventSource.close();
+ scannerEventSource = null;
+ }
+ addScannerLogEntry('Scanner stopped', '');
+ })
+ .catch(() => {});
+}
+
+function pauseScanner() {
+ const endpoint = isScannerPaused ? '/listening/scanner/resume' : '/listening/scanner/pause';
+ fetch(endpoint, { method: 'POST' })
+ .then(r => r.json())
+ .then(data => {
+ isScannerPaused = !isScannerPaused;
+ const pauseBtn = document.getElementById('scannerPauseBtn');
+ if (pauseBtn) pauseBtn.innerHTML = isScannerPaused ? Icons.play('icon--sm') + ' Resume' : Icons.pause('icon--sm') + ' Pause';
+ const statusText = document.getElementById('scannerStatusText');
+ if (statusText) {
+ statusText.textContent = isScannerPaused ? 'PAUSED' : 'SCANNING';
+ statusText.style.color = isScannerPaused ? 'var(--accent-orange)' : 'var(--accent-green)';
+ }
+
+ const activityStatus = document.getElementById('scannerActivityStatus');
+ if (activityStatus) {
+ activityStatus.textContent = isScannerPaused ? 'PAUSED' : 'SCANNING';
+ activityStatus.style.color = isScannerPaused ? 'var(--accent-orange)' : 'var(--accent-green)';
+ }
+
+ // Update main display
+ const mainModeLabel = document.getElementById('mainScannerModeLabel');
+ if (mainModeLabel) {
+ mainModeLabel.textContent = isScannerPaused ? 'PAUSED' : 'SCANNING';
+ }
+
+ addScannerLogEntry(isScannerPaused ? 'Scanner paused' : 'Scanner resumed', '');
+ })
+ .catch(() => {});
+}
+
+function skipSignal() {
+ if (!isScannerRunning) {
+ if (typeof showNotification === 'function') {
+ showNotification('Scanner', 'Scanner is not running');
+ }
+ return;
+ }
+
+ fetch('/listening/scanner/skip', { method: 'POST' })
+ .then(r => r.json())
+ .then(data => {
+ if (data.status === 'skipped' && typeof showNotification === 'function') {
+ showNotification('Signal Skipped', `Continuing scan from ${data.frequency.toFixed(3)} MHz`);
+ }
+ })
+ .catch(err => {
+ if (typeof showNotification === 'function') {
+ showNotification('Skip Error', err.message);
+ }
+ });
+}
+
+// ============== SCANNER STREAM ==============
+
+function connectScannerStream(isAgentMode = false) {
+ if (scannerEventSource) {
+ scannerEventSource.close();
+ }
+
+ // Use different stream endpoint for agent mode
+ const streamUrl = isAgentMode ? '/controller/stream/all' : '/listening/scanner/stream';
+ scannerEventSource = new EventSource(streamUrl);
+
+ scannerEventSource.onmessage = function(e) {
+ try {
+ const data = JSON.parse(e.data);
+
+ if (isAgentMode) {
+ // Handle multi-agent stream format
+ if (data.scan_type === 'listening_post' && data.payload) {
+ const payload = data.payload;
+ payload.agent_name = data.agent_name;
+ handleScannerEvent(payload);
+ }
+ } else {
+ handleScannerEvent(data);
+ }
+ } catch (err) {
+ console.warn('Scanner parse error:', err);
+ }
+ };
+
+ scannerEventSource.onerror = function() {
+ if (isScannerRunning) {
+ setTimeout(() => connectScannerStream(isAgentMode), 2000);
+ }
+ };
+
+ // Start polling fallback for agent mode
+ if (isAgentMode) {
+ startListeningPostPolling();
+ }
+}
+
+// Track last activity count for polling
+let lastListeningPostActivityCount = 0;
+
+function startListeningPostPolling() {
+ if (listeningPostPollTimer) return;
+ lastListeningPostActivityCount = 0;
+
+ // Disable listen button for agent mode (audio can't stream over HTTP)
+ updateListenButtonState(true);
+
+ const pollInterval = 2000;
+ listeningPostPollTimer = setInterval(async () => {
+ if (!isScannerRunning || !listeningPostCurrentAgent) {
+ clearInterval(listeningPostPollTimer);
+ listeningPostPollTimer = null;
+ return;
+ }
+
+ try {
+ const response = await fetch(`/controller/agents/${listeningPostCurrentAgent}/listening_post/data`);
+ if (!response.ok) return;
+
+ const data = await response.json();
+ const result = data.result || data;
+ // Controller returns nested structure: data.data.data for agent mode data
+ const outerData = result.data || {};
+ const modeData = outerData.data || outerData;
+
+ // Process activity from polling response
+ const activity = modeData.activity || [];
+ if (activity.length > lastListeningPostActivityCount) {
+ const newActivity = activity.slice(lastListeningPostActivityCount);
+ newActivity.forEach(item => {
+ // Convert to scanner event format
+ const event = {
+ type: 'signal_found',
+ frequency: item.frequency,
+ level: item.level || item.signal_level,
+ modulation: item.modulation,
+ agent_name: result.agent_name || 'Remote Agent'
+ };
+ handleScannerEvent(event);
+ });
+ lastListeningPostActivityCount = activity.length;
+ }
+
+ // Update current frequency if available
+ if (modeData.current_freq) {
+ handleScannerEvent({
+ type: 'freq_change',
+ frequency: modeData.current_freq
+ });
+ }
+
+ // Update freqs scanned counter from agent data
+ if (modeData.freqs_scanned !== undefined) {
+ const freqsEl = document.getElementById('mainFreqsScanned');
+ if (freqsEl) freqsEl.textContent = modeData.freqs_scanned;
+ scannerFreqsScanned = modeData.freqs_scanned;
+ }
+
+ // Update signal count from agent data
+ if (modeData.signal_count !== undefined) {
+ const signalEl = document.getElementById('mainSignalCount');
+ if (signalEl) signalEl.textContent = modeData.signal_count;
+ }
+ } catch (err) {
+ console.error('Listening Post polling error:', err);
+ }
+ }, pollInterval);
+}
+
+function handleScannerEvent(data) {
+ switch (data.type) {
+ case 'freq_change':
+ case 'scan_update':
+ handleFrequencyUpdate(data);
+ break;
+ case 'signal_found':
+ handleSignalFound(data);
+ break;
+ case 'signal_lost':
+ case 'signal_skipped':
+ handleSignalLost(data);
+ break;
+ case 'log':
+ if (data.entry && data.entry.type === 'scan_cycle') {
+ scannerCycles++;
+ lastScanProgress = null;
+ lastScanFreq = null;
+ if (scannerTotalSteps > 0) {
+ scannerFreqsScanned = scannerCycles * scannerTotalSteps;
+ const freqsEl = document.getElementById('mainFreqsScanned');
+ if (freqsEl) freqsEl.textContent = scannerFreqsScanned;
+ }
+ const cyclesEl = document.getElementById('mainScanCycles');
+ if (cyclesEl) cyclesEl.textContent = scannerCycles;
+ }
+ break;
+ case 'stopped':
+ stopScanner();
+ break;
+ }
+}
+
+function handleFrequencyUpdate(data) {
+ if (data.range_start !== undefined && data.range_end !== undefined) {
+ const newStart = parseFloat(data.range_start);
+ const newEnd = parseFloat(data.range_end);
+ if (Number.isFinite(newStart) && Number.isFinite(newEnd) && newEnd > newStart) {
+ scannerStartFreq = newStart;
+ scannerEndFreq = newEnd;
+ scannerTotalSteps = Math.max(1, Math.round(((scannerEndFreq - scannerStartFreq) * 1000) / (scannerStepKhz || 1)));
+
+ const rangeStart = document.getElementById('scannerRangeStart');
+ if (rangeStart) rangeStart.textContent = newStart.toFixed(1);
+ const rangeEnd = document.getElementById('scannerRangeEnd');
+ if (rangeEnd) rangeEnd.textContent = newEnd.toFixed(1);
+ const mainRangeStart = document.getElementById('mainRangeStart');
+ if (mainRangeStart) mainRangeStart.textContent = newStart.toFixed(1) + ' MHz';
+ const mainRangeEnd = document.getElementById('mainRangeEnd');
+ if (mainRangeEnd) mainRangeEnd.textContent = newEnd.toFixed(1) + ' MHz';
+
+ const startInput = document.getElementById('radioScanStart');
+ if (startInput && document.activeElement !== startInput) {
+ startInput.value = newStart.toFixed(3);
+ }
+ const endInput = document.getElementById('radioScanEnd');
+ if (endInput && document.activeElement !== endInput) {
+ endInput.value = newEnd.toFixed(3);
+ }
+ }
+ }
+
+ const range = scannerEndFreq - scannerStartFreq;
+ if (range <= 0) {
+ return;
+ }
+
+ const effectiveRange = scannerEndFreq - scannerStartFreq;
+ if (effectiveRange <= 0) {
+ return;
+ }
+
+ const hasProgress = data.progress !== undefined && Number.isFinite(data.progress);
+ const freqValue = (typeof data.frequency === 'number' && Number.isFinite(data.frequency))
+ ? data.frequency
+ : null;
+ const stepMhz = Math.max(0.001, (scannerStepKhz || 1) / 1000);
+ const freqTolerance = stepMhz * 2;
+
+ let progressValue = null;
+ if (hasProgress) {
+ progressValue = data.progress;
+ const clamped = Math.max(0, Math.min(1, progressValue));
+ if (lastScanProgress !== null && clamped < lastScanProgress) {
+ const isCycleReset = lastScanProgress > 0.85 && clamped < 0.15;
+ if (!isCycleReset) {
+ return;
+ }
+ }
+ lastScanProgress = clamped;
+ } else if (freqValue !== null) {
+ if (lastScanFreq !== null && (freqValue + freqTolerance) < lastScanFreq) {
+ const nearEnd = lastScanFreq >= (scannerEndFreq - freqTolerance * 2);
+ const nearStart = freqValue <= (scannerStartFreq + freqTolerance * 2);
+ if (!nearEnd || !nearStart) {
+ return;
+ }
+ }
+ lastScanFreq = freqValue;
+ progressValue = (freqValue - scannerStartFreq) / effectiveRange;
+ lastScanProgress = Math.max(0, Math.min(1, progressValue));
+ } else {
+ if (scannerMethod === 'power') {
+ return;
+ }
+ progressValue = 0;
+ lastScanProgress = 0;
+ }
+
+ const clampedProgress = Math.max(0, Math.min(1, progressValue));
+
+ const displayFreq = (freqValue !== null
+ && freqValue >= (scannerStartFreq - freqTolerance)
+ && freqValue <= (scannerEndFreq + freqTolerance))
+ ? freqValue
+ : scannerStartFreq + (clampedProgress * effectiveRange);
+ const freqStr = displayFreq.toFixed(3);
+
+ const currentFreq = document.getElementById('scannerCurrentFreq');
+ if (currentFreq) currentFreq.textContent = freqStr + ' MHz';
+
+ const mainFreq = document.getElementById('mainScannerFreq');
+ if (mainFreq) mainFreq.textContent = freqStr;
+
+ if (scannerTotalSteps > 0) {
+ const stepSize = Math.max(1, scannerStepKhz || 1);
+ const stepIndex = Math.max(0, Math.round(((displayFreq - scannerStartFreq) * 1000) / stepSize));
+ const nextScanned = (scannerCycles * scannerTotalSteps)
+ + Math.min(scannerTotalSteps, stepIndex);
+ scannerFreqsScanned = Math.max(scannerFreqsScanned, nextScanned);
+ const freqsEl = document.getElementById('mainFreqsScanned');
+ if (freqsEl) freqsEl.textContent = scannerFreqsScanned;
+ }
+
+ // Update progress bar
+ const progress = Math.max(0, Math.min(100, clampedProgress * 100));
+ const progressBar = document.getElementById('scannerProgressBar');
+ if (progressBar) progressBar.style.width = Math.max(0, Math.min(100, progress)) + '%';
+
+ const mainProgressBar = document.getElementById('mainProgressBar');
+ if (mainProgressBar) mainProgressBar.style.width = Math.max(0, Math.min(100, progress)) + '%';
+
+ // freqs scanned updated via progress above
+
+ // Update level meter if present
+ if (data.level !== undefined) {
+ // Store for synthesizer visualization
+ currentSignalLevel = data.level;
+ if (data.threshold !== undefined) {
+ signalLevelThreshold = data.threshold;
+ }
+
+ const levelPercent = Math.min(100, (data.level / 5000) * 100);
+ const levelBar = document.getElementById('scannerLevelBar');
+ if (levelBar) {
+ levelBar.style.width = levelPercent + '%';
+ if (data.detected) {
+ levelBar.style.background = 'var(--accent-green)';
+ } else if (data.level > (data.threshold || 0) * 0.7) {
+ levelBar.style.background = 'var(--accent-orange)';
+ } else {
+ levelBar.style.background = 'var(--accent-cyan)';
+ }
+ }
+ const levelValue = document.getElementById('scannerLevelValue');
+ if (levelValue) levelValue.textContent = data.level;
+ }
+
+ const statusText = document.getElementById('scannerStatusText');
+ if (statusText) statusText.textContent = `${freqStr} MHz${data.level !== undefined ? ` (level: ${data.level})` : ''}`;
+}
+
+function handleSignalFound(data) {
+ // Only treat signals as "interesting" if they exceed threshold and match modulation
+ const threshold = data.threshold !== undefined ? data.threshold : signalLevelThreshold;
+ if (data.level !== undefined && threshold !== undefined && data.level < threshold) {
+ return;
+ }
+ if (data.modulation && currentModulation && data.modulation !== currentModulation) {
+ return;
+ }
+
+ scannerSignalCount++;
+ scannerSignalActive = true;
+ const freqStr = data.frequency.toFixed(3);
+
+ const signalCount = document.getElementById('scannerSignalCount');
+ if (signalCount) signalCount.textContent = scannerSignalCount;
+ const mainSignalCount = document.getElementById('mainSignalCount');
+ if (mainSignalCount) mainSignalCount.textContent = scannerSignalCount;
+
+ // Update sidebar
+ updateScannerDisplay('SIGNAL FOUND', 'var(--accent-green)');
+ const signalPanel = document.getElementById('scannerSignalPanel');
+ if (signalPanel) signalPanel.style.display = 'block';
+ const statusText = document.getElementById('scannerStatusText');
+ if (statusText) statusText.textContent = 'Listening to signal...';
+
+ // Update main display
+ const mainModeLabel = document.getElementById('mainScannerModeLabel');
+ if (mainModeLabel) mainModeLabel.textContent = 'SIGNAL DETECTED';
+
+ const mainFreq = document.getElementById('mainScannerFreq');
+ if (mainFreq) mainFreq.style.color = 'var(--accent-green)';
+
+ const mainAnim = document.getElementById('mainScannerAnimation');
+ if (mainAnim) mainAnim.style.display = 'none';
+
+ const mainSignalAlert = document.getElementById('mainSignalAlert');
+ if (mainSignalAlert) mainSignalAlert.style.display = 'block';
+
+ // Start audio playback for the detected signal
+ if (data.audio_streaming) {
+ const scannerAudio = document.getElementById('scannerAudioPlayer');
+ if (scannerAudio) {
+ // Pass the signal frequency and modulation to getStreamUrl
+ const streamUrl = getStreamUrl(data.frequency, data.modulation);
+ console.log('[SCANNER] Starting audio for signal:', data.frequency, 'MHz');
+ scannerAudio.src = streamUrl;
+ scannerAudio.preload = 'auto';
+ scannerAudio.autoplay = true;
+ scannerAudio.muted = false;
+ scannerAudio.load();
+ // Apply current volume from knob
+ const volumeKnob = document.getElementById('radioVolumeKnob');
+ if (volumeKnob && volumeKnob._knob) {
+ scannerAudio.volume = volumeKnob._knob.getValue() / 100;
+ } else if (volumeKnob) {
+ const knobValue = parseFloat(volumeKnob.dataset.value) || 80;
+ scannerAudio.volume = knobValue / 100;
+ }
+ attemptAudioPlay(scannerAudio);
+ // Initialize audio visualizer to feed signal levels to synthesizer
+ initAudioVisualizer();
+ }
+ }
+
+ // Add to sidebar recent signals
+ if (typeof addSidebarRecentSignal === 'function') {
+ addSidebarRecentSignal(data.frequency, data.modulation);
+ }
+
+ addScannerLogEntry('SIGNAL FOUND', `${freqStr} MHz (${data.modulation.toUpperCase()})`, 'signal');
+ addSignalHit(data);
+
+ if (typeof showNotification === 'function') {
+ showNotification('Signal Found!', `${freqStr} MHz - Audio streaming`);
+ }
+}
+
+function handleSignalLost(data) {
+ scannerSignalActive = false;
+
+ // Update sidebar
+ updateScannerDisplay('SCANNING', 'var(--accent-cyan)');
+ const signalPanel = document.getElementById('scannerSignalPanel');
+ if (signalPanel) signalPanel.style.display = 'none';
+ const statusText = document.getElementById('scannerStatusText');
+ if (statusText) statusText.textContent = 'Scanning...';
+
+ // Update main display
+ const mainModeLabel = document.getElementById('mainScannerModeLabel');
+ if (mainModeLabel) mainModeLabel.textContent = 'SCANNING';
+
+ const mainFreq = document.getElementById('mainScannerFreq');
+ if (mainFreq) mainFreq.style.color = 'var(--accent-cyan)';
+
+ const mainAnim = document.getElementById('mainScannerAnimation');
+ if (mainAnim) mainAnim.style.display = 'block';
+
+ const mainSignalAlert = document.getElementById('mainSignalAlert');
+ if (mainSignalAlert) mainSignalAlert.style.display = 'none';
+
+ // Stop audio
+ const scannerAudio = document.getElementById('scannerAudioPlayer');
+ if (scannerAudio) {
+ scannerAudio.pause();
+ scannerAudio.src = '';
+ }
+
+ const logType = data.type === 'signal_skipped' ? 'info' : 'info';
+ const logTitle = data.type === 'signal_skipped' ? 'Signal skipped' : 'Signal lost';
+ addScannerLogEntry(logTitle, `${data.frequency.toFixed(3)} MHz`, logType);
+}
+
+/**
+ * Update listen button state based on agent mode
+ * Audio streaming isn't practical over HTTP so disable for remote agents
+ */
+function updateListenButtonState(isAgentMode) {
+ const listenBtn = document.getElementById('radioListenBtn');
+ if (!listenBtn) return;
+
+ if (isAgentMode) {
+ listenBtn.disabled = true;
+ listenBtn.style.opacity = '0.5';
+ listenBtn.style.cursor = 'not-allowed';
+ listenBtn.title = 'Audio listening not available for remote agents';
+ } else {
+ listenBtn.disabled = false;
+ listenBtn.style.opacity = '1';
+ listenBtn.style.cursor = 'pointer';
+ listenBtn.title = 'Listen to current frequency';
+ }
+}
+
+function updateScannerDisplay(mode, color) {
+ const modeLabel = document.getElementById('scannerModeLabel');
+ if (modeLabel) {
+ modeLabel.textContent = mode;
+ modeLabel.style.color = color;
+ }
+
+ const currentFreq = document.getElementById('scannerCurrentFreq');
+ if (currentFreq) currentFreq.style.color = color;
+
+ const mainModeLabel = document.getElementById('mainScannerModeLabel');
+ if (mainModeLabel) mainModeLabel.textContent = mode;
+
+ const mainFreq = document.getElementById('mainScannerFreq');
+ if (mainFreq) mainFreq.style.color = color;
+}
+
+// ============== SCANNER LOG ==============
+
+function addScannerLogEntry(title, detail, type = 'info') {
+ const now = new Date();
+ const timestamp = now.toLocaleTimeString();
+ const entry = { timestamp, title, detail, type };
+ scannerLogEntries.unshift(entry);
+
+ if (scannerLogEntries.length > 100) {
+ scannerLogEntries.pop();
+ }
+
+ // Color based on type
+ const getTypeColor = (t) => {
+ switch(t) {
+ case 'signal': return 'var(--accent-green)';
+ case 'error': return 'var(--accent-red)';
+ default: return 'var(--text-secondary)';
+ }
+ };
+
+ // Update sidebar log
+ const sidebarLog = document.getElementById('scannerLog');
+ if (sidebarLog) {
+ sidebarLog.innerHTML = scannerLogEntries.slice(0, 20).map(e =>
+ `
+ [${e.timestamp}]
+ ${e.title} ${e.detail}
+
`
+ ).join('');
+ }
+
+ // Update main activity log
+ const activityLog = document.getElementById('scannerActivityLog');
+ if (activityLog) {
+ const getBorderColor = (t) => {
+ switch(t) {
+ case 'signal': return 'var(--accent-green)';
+ case 'error': return 'var(--accent-red)';
+ default: return 'var(--border-color)';
+ }
+ };
+ activityLog.innerHTML = scannerLogEntries.slice(0, 50).map(e =>
+ `
+ [${e.timestamp}]
+ ${e.title}
+ ${e.detail}
+
`
+ ).join('');
+ }
+}
+
+function addSignalHit(data) {
+ const tbody = document.getElementById('scannerHitsBody');
+ if (!tbody) return;
+
+ const now = Date.now();
+ const freqKey = data.frequency.toFixed(3);
+
+ // Check for duplicate
+ if (recentSignalHits.has(freqKey)) {
+ const lastHit = recentSignalHits.get(freqKey);
+ if (now - lastHit < 5000) return;
+ }
+ recentSignalHits.set(freqKey, now);
+
+ // Clean up old entries
+ for (const [freq, time] of recentSignalHits) {
+ if (now - time > 30000) {
+ recentSignalHits.delete(freq);
+ }
+ }
+
+ const timestamp = new Date().toLocaleTimeString();
+
+ if (tbody.innerHTML.includes('No signals detected')) {
+ tbody.innerHTML = '';
+ }
+
+ const mod = data.modulation || 'fm';
+ const row = document.createElement('tr');
+ row.style.borderBottom = '1px solid var(--border-color)';
+ row.innerHTML = `
+ ${timestamp} |
+ ${data.frequency.toFixed(3)} |
+ ${mod.toUpperCase()} |
+
+
+ |
+ `;
+ tbody.insertBefore(row, tbody.firstChild);
+
+ while (tbody.children.length > 50) {
+ tbody.removeChild(tbody.lastChild);
+ }
+
+ const hitCount = document.getElementById('scannerHitCount');
+ if (hitCount) hitCount.textContent = `${tbody.children.length} signals found`;
+
+ // Feed to activity timeline if available
+ if (typeof addTimelineEvent === 'function') {
+ const normalized = typeof RFTimelineAdapter !== 'undefined'
+ ? RFTimelineAdapter.normalizeSignal({
+ frequency: data.frequency,
+ rssi: data.rssi || data.signal_strength,
+ duration: data.duration || 2000,
+ modulation: data.modulation
+ })
+ : {
+ id: String(data.frequency),
+ label: `${data.frequency.toFixed(3)} MHz`,
+ strength: 3,
+ duration: 2000,
+ type: 'rf'
+ };
+ addTimelineEvent('listening', normalized);
+ }
+}
+
+function clearScannerLog() {
+ scannerLogEntries = [];
+ scannerSignalCount = 0;
+ scannerFreqsScanned = 0;
+ scannerCycles = 0;
+ recentSignalHits.clear();
+
+ // Clear the timeline if available
+ const timeline = typeof getTimeline === 'function' ? getTimeline('listening') : null;
+ if (timeline) {
+ timeline.clear();
+ }
+
+ const signalCount = document.getElementById('scannerSignalCount');
+ if (signalCount) signalCount.textContent = '0';
+
+ const mainSignalCount = document.getElementById('mainSignalCount');
+ if (mainSignalCount) mainSignalCount.textContent = '0';
+
+ const mainFreqsScanned = document.getElementById('mainFreqsScanned');
+ if (mainFreqsScanned) mainFreqsScanned.textContent = '0';
+
+ const mainScanCycles = document.getElementById('mainScanCycles');
+ if (mainScanCycles) mainScanCycles.textContent = '0';
+
+ const sidebarLog = document.getElementById('scannerLog');
+ if (sidebarLog) sidebarLog.innerHTML = 'Scanner activity will appear here...
';
+
+ const activityLog = document.getElementById('scannerActivityLog');
+ if (activityLog) activityLog.innerHTML = 'Waiting for scanner to start...
';
+
+ const hitsBody = document.getElementById('scannerHitsBody');
+ if (hitsBody) hitsBody.innerHTML = '| No signals detected |
';
+
+ const hitCount = document.getElementById('scannerHitCount');
+ if (hitCount) hitCount.textContent = '0 signals found';
+}
+
+function exportScannerLog() {
+ if (scannerLogEntries.length === 0) {
+ if (typeof showNotification === 'function') {
+ showNotification('Export', 'No log entries to export');
+ }
+ return;
+ }
+
+ const csv = 'Timestamp,Event,Details\n' + scannerLogEntries.map(e =>
+ `"${e.timestamp}","${e.title}","${e.detail}"`
+ ).join('\n');
+
+ const blob = new Blob([csv], { type: 'text/csv' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `scanner_log_${new Date().toISOString().slice(0, 10)}.csv`;
+ a.click();
+ URL.revokeObjectURL(url);
+
+ if (typeof showNotification === 'function') {
+ showNotification('Export', 'Log exported to CSV');
+ }
+}
+
+// ============== AUDIO TOOLS CHECK ==============
+
+function checkAudioTools() {
+ fetch('/listening/tools')
+ .then(r => r.json())
+ .then(data => {
+ audioToolsAvailable.rtl_fm = data.rtl_fm;
+ audioToolsAvailable.ffmpeg = data.ffmpeg;
+
+ // Only rtl_fm/rx_fm + ffmpeg are required for direct streaming
+ const warnings = [];
+ if (!data.rtl_fm && !data.rx_fm) {
+ warnings.push('rtl_fm/rx_fm not found - install rtl-sdr or soapysdr-tools');
+ }
+ if (!data.ffmpeg) {
+ warnings.push('ffmpeg not found - install: brew install ffmpeg (macOS) or apt install ffmpeg (Linux)');
+ }
+
+ const warningDiv = document.getElementById('audioToolsWarning');
+ const warningText = document.getElementById('audioToolsWarningText');
+ if (warningDiv) {
+ if (warnings.length > 0) {
+ warningText.innerHTML = warnings.join('
');
+ warningDiv.style.display = 'block';
+ document.getElementById('audioStartBtn').disabled = true;
+ document.getElementById('audioStartBtn').style.opacity = '0.5';
+ } else {
+ warningDiv.style.display = 'none';
+ document.getElementById('audioStartBtn').disabled = false;
+ document.getElementById('audioStartBtn').style.opacity = '1';
+ }
+ }
+ })
+ .catch(() => {});
+}
+
+// ============== AUDIO PRESETS ==============
+
+function applyAudioPreset() {
+ const preset = document.getElementById('audioPreset').value;
+ const freqInput = document.getElementById('audioFrequency');
+ const modSelect = document.getElementById('audioModulation');
+
+ if (audioPresets[preset]) {
+ freqInput.value = audioPresets[preset].freq;
+ modSelect.value = audioPresets[preset].mod;
+ }
+}
+
+// ============== AUDIO CONTROLS ==============
+
+function toggleAudio() {
+ if (isAudioPlaying) {
+ stopAudio();
+ } else {
+ startAudio();
+ }
+}
+
+function startAudio() {
+ const frequency = parseFloat(document.getElementById('audioFrequency').value);
+ const modulation = document.getElementById('audioModulation').value;
+ const squelch = parseInt(document.getElementById('audioSquelch').value);
+ const gain = parseInt(document.getElementById('audioGain').value);
+ const device = getSelectedDevice();
+
+ if (isNaN(frequency) || frequency <= 0) {
+ if (typeof showNotification === 'function') {
+ showNotification('Audio Error', 'Invalid frequency');
+ }
+ return;
+ }
+
+ // Check if device is in use
+ if (typeof getDeviceInUseBy === 'function') {
+ const usedBy = getDeviceInUseBy(device);
+ if (usedBy && usedBy !== 'audio') {
+ if (typeof showNotification === 'function') {
+ showNotification('SDR In Use', `Device ${device} is being used by ${usedBy.toUpperCase()}.`);
+ }
+ return;
+ }
+ }
+
+ document.getElementById('audioStatus').textContent = 'STARTING...';
+ document.getElementById('audioStatus').style.color = 'var(--accent-orange)';
+
+ // Use direct streaming - no Icecast needed
+ if (typeof reserveDevice === 'function') reserveDevice(device, 'audio');
+ isAudioPlaying = true;
+
+ // Build direct stream URL with parameters
+ const streamUrl = `/listening/audio/stream?freq=${frequency}&mod=${modulation}&squelch=${squelch}&gain=${gain}&t=${Date.now()}`;
+ console.log('Connecting to direct stream:', streamUrl);
+
+ // Start browser audio playback
+ const audioPlayer = document.getElementById('audioPlayer');
+ audioPlayer.src = streamUrl;
+ audioPlayer.volume = document.getElementById('audioVolume').value / 100;
+
+ initAudioVisualizer();
+
+ audioPlayer.onplaying = () => {
+ document.getElementById('audioStatus').textContent = 'STREAMING';
+ document.getElementById('audioStatus').style.color = 'var(--accent-green)';
+ };
+
+ audioPlayer.onerror = (e) => {
+ console.error('Audio player error:', e);
+ document.getElementById('audioStatus').textContent = 'ERROR';
+ document.getElementById('audioStatus').style.color = 'var(--accent-red)';
+ if (typeof showNotification === 'function') {
+ showNotification('Audio Error', 'Stream error - check SDR connection');
+ }
+ };
+
+ audioPlayer.play().catch(e => {
+ console.warn('Audio autoplay blocked:', e);
+ if (typeof showNotification === 'function') {
+ showNotification('Audio Ready', 'Click Play button again if audio does not start');
+ }
+ });
+
+ document.getElementById('audioStartBtn').innerHTML = Icons.stop('icon--sm') + ' Stop Audio';
+ document.getElementById('audioStartBtn').classList.add('active');
+ document.getElementById('audioTunedFreq').textContent = frequency.toFixed(2) + ' MHz (' + modulation.toUpperCase() + ')';
+ document.getElementById('audioDeviceStatus').textContent = 'SDR ' + device;
+
+ if (typeof showNotification === 'function') {
+ showNotification('Audio Started', `Streaming ${frequency} MHz to browser`);
+ }
+}
+
+async function stopAudio() {
+ stopAudioVisualizer();
+
+ const audioPlayer = document.getElementById('audioPlayer');
+ if (audioPlayer) {
+ audioPlayer.pause();
+ audioPlayer.src = '';
+ }
+
+ try {
+ await fetch('/listening/audio/stop', { method: 'POST' });
+ if (typeof releaseDevice === 'function') releaseDevice('audio');
+ isAudioPlaying = false;
+ document.getElementById('audioStartBtn').innerHTML = Icons.play('icon--sm') + ' Play Audio';
+ document.getElementById('audioStartBtn').classList.remove('active');
+ document.getElementById('audioStatus').textContent = 'STOPPED';
+ document.getElementById('audioStatus').style.color = 'var(--text-muted)';
+ document.getElementById('audioDeviceStatus').textContent = '--';
+ } catch (e) {
+ console.error('Error stopping audio:', e);
+ }
+}
+
+function updateAudioVolume() {
+ const audioPlayer = document.getElementById('audioPlayer');
+ if (audioPlayer) {
+ audioPlayer.volume = document.getElementById('audioVolume').value / 100;
+ }
+}
+
+function audioFreqUp() {
+ const input = document.getElementById('audioFrequency');
+ const mod = document.getElementById('audioModulation').value;
+ const step = (mod === 'wfm') ? 0.2 : 0.025;
+ input.value = (parseFloat(input.value) + step).toFixed(2);
+ if (isAudioPlaying) {
+ tuneAudioFrequency(parseFloat(input.value));
+ }
+}
+
+function audioFreqDown() {
+ const input = document.getElementById('audioFrequency');
+ const mod = document.getElementById('audioModulation').value;
+ const step = (mod === 'wfm') ? 0.2 : 0.025;
+ input.value = (parseFloat(input.value) - step).toFixed(2);
+ if (isAudioPlaying) {
+ tuneAudioFrequency(parseFloat(input.value));
+ }
+}
+
+function tuneAudioFrequency(frequency) {
+ fetch('/listening/audio/tune', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ frequency: frequency })
+ })
+ .then(r => r.json())
+ .then(data => {
+ if (data.status === 'tuned') {
+ document.getElementById('audioTunedFreq').textContent = frequency.toFixed(2) + ' MHz';
+ }
+ })
+ .catch(() => {
+ stopAudio();
+ setTimeout(startAudio, 300);
+ });
+}
+
+async function tuneToFrequency(freq, mod) {
+ try {
+ // Stop scanner if running
+ if (isScannerRunning) {
+ stopScanner();
+ await new Promise(resolve => setTimeout(resolve, 300));
+ }
+
+ // Update frequency input
+ const freqInput = document.getElementById('radioScanStart');
+ if (freqInput) {
+ freqInput.value = freq.toFixed(1);
+ }
+
+ // Update modulation if provided
+ if (mod) {
+ setModulation(mod);
+ }
+
+ // Update tuning dial (silent to avoid duplicate events)
+ const mainTuningDial = document.getElementById('mainTuningDial');
+ if (mainTuningDial && mainTuningDial._dial) {
+ mainTuningDial._dial.setValue(freq, true);
+ }
+
+ // Update frequency display
+ const mainFreq = document.getElementById('mainScannerFreq');
+ if (mainFreq) {
+ mainFreq.textContent = freq.toFixed(3);
+ }
+
+ // Start listening immediately
+ await startDirectListenImmediate();
+
+ if (typeof showNotification === 'function') {
+ showNotification('Tuned', `Now listening to ${freq.toFixed(3)} MHz (${(mod || currentModulation).toUpperCase()})`);
+ }
+ } catch (err) {
+ console.error('Error tuning to frequency:', err);
+ if (typeof showNotification === 'function') {
+ showNotification('Tune Error', 'Failed to tune to frequency: ' + err.message);
+ }
+ }
+}
+
+// ============== AUDIO VISUALIZER ==============
+
+function initAudioVisualizer() {
+ const audioPlayer = document.getElementById('scannerAudioPlayer');
+ if (!audioPlayer) {
+ console.warn('[VISUALIZER] No audio player found');
+ return;
+ }
+
+ console.log('[VISUALIZER] Initializing with audio player, src:', audioPlayer.src);
+
+ if (!visualizerContext) {
+ visualizerContext = new (window.AudioContext || window.webkitAudioContext)();
+ console.log('[VISUALIZER] Created audio context');
+ }
+
+ if (visualizerContext.state === 'suspended') {
+ console.log('[VISUALIZER] Resuming suspended audio context');
+ visualizerContext.resume();
+ }
+
+ if (!visualizerSource) {
+ try {
+ visualizerSource = visualizerContext.createMediaElementSource(audioPlayer);
+ visualizerAnalyser = visualizerContext.createAnalyser();
+ visualizerAnalyser.fftSize = 256;
+ visualizerAnalyser.smoothingTimeConstant = 0.7;
+
+ visualizerSource.connect(visualizerAnalyser);
+ visualizerAnalyser.connect(visualizerContext.destination);
+ console.log('[VISUALIZER] Audio source and analyser connected');
+ } catch (e) {
+ console.error('[VISUALIZER] Could not create audio source:', e);
+ // Try to continue anyway if analyser exists
+ if (!visualizerAnalyser) return;
+ }
+ } else {
+ console.log('[VISUALIZER] Reusing existing audio source');
+ }
+
+ const container = document.getElementById('audioVisualizerContainer');
+ if (container) container.style.display = 'block';
+
+ // Start the visualization loop
+ if (!visualizerAnimationId) {
+ console.log('[VISUALIZER] Starting draw loop');
+ drawAudioVisualizer();
+ } else {
+ console.log('[VISUALIZER] Draw loop already running');
+ }
+}
+
+function drawAudioVisualizer() {
+ if (!visualizerAnalyser) {
+ console.warn('[VISUALIZER] No analyser available');
+ return;
+ }
+
+ const canvas = document.getElementById('audioSpectrumCanvas');
+ const ctx = canvas ? canvas.getContext('2d') : null;
+ const bufferLength = visualizerAnalyser.frequencyBinCount;
+ const dataArray = new Uint8Array(bufferLength);
+
+ function draw() {
+ visualizerAnimationId = requestAnimationFrame(draw);
+
+ visualizerAnalyser.getByteFrequencyData(dataArray);
+
+ let sum = 0;
+ for (let i = 0; i < bufferLength; i++) {
+ sum += dataArray[i];
+ }
+ const average = sum / bufferLength;
+ const levelPercent = (average / 255) * 100;
+
+ // Feed audio level to synthesizer visualization during direct listening
+ if (isDirectListening || isScannerRunning) {
+ // Scale 0-255 average to 0-3000 range (matching SSE scan_update levels)
+ currentSignalLevel = (average / 255) * 3000;
+ }
+
+ if (levelPercent > peakLevel) {
+ peakLevel = levelPercent;
+ } else {
+ peakLevel *= peakDecay;
+ }
+
+ const meterFill = document.getElementById('audioSignalMeter');
+ const meterPeak = document.getElementById('audioSignalPeak');
+ const meterValue = document.getElementById('audioSignalValue');
+
+ if (meterFill) meterFill.style.width = levelPercent + '%';
+ if (meterPeak) meterPeak.style.left = Math.min(peakLevel, 100) + '%';
+
+ const db = average > 0 ? Math.round(20 * Math.log10(average / 255)) : -60;
+ if (meterValue) meterValue.textContent = db + ' dB';
+
+ // Only draw spectrum if canvas exists
+ if (ctx && canvas) {
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+
+ const barWidth = canvas.width / bufferLength * 2.5;
+ let x = 0;
+
+ for (let i = 0; i < bufferLength; i++) {
+ const barHeight = (dataArray[i] / 255) * canvas.height;
+ const hue = 200 - (i / bufferLength) * 60;
+ const lightness = 40 + (dataArray[i] / 255) * 30;
+ ctx.fillStyle = `hsl(${hue}, 80%, ${lightness}%)`;
+ ctx.fillRect(x, canvas.height - barHeight, barWidth - 1, barHeight);
+ x += barWidth;
+ }
+
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
+ ctx.font = '8px JetBrains Mono';
+ ctx.fillText('0', 2, canvas.height - 2);
+ ctx.fillText('4kHz', canvas.width / 4, canvas.height - 2);
+ ctx.fillText('8kHz', canvas.width / 2, canvas.height - 2);
+ }
+ }
+
+ draw();
+}
+
+function stopAudioVisualizer() {
+ if (visualizerAnimationId) {
+ cancelAnimationFrame(visualizerAnimationId);
+ visualizerAnimationId = null;
+ }
+
+ const meterFill = document.getElementById('audioSignalMeter');
+ const meterPeak = document.getElementById('audioSignalPeak');
+ const meterValue = document.getElementById('audioSignalValue');
+
+ if (meterFill) meterFill.style.width = '0%';
+ if (meterPeak) meterPeak.style.left = '0%';
+ if (meterValue) meterValue.textContent = '-∞ dB';
+
+ peakLevel = 0;
+
+ const container = document.getElementById('audioVisualizerContainer');
+ if (container) container.style.display = 'none';
+}
+
+// ============== RADIO KNOB CONTROLS ==============
+
+/**
+ * Update scanner config on the backend (for live updates while scanning)
+ */
+function updateScannerConfig(config) {
+ if (!isScannerRunning) return;
+ fetch('/listening/scanner/config', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(config)
+ }).catch(() => {});
+}
+
+/**
+ * Initialize radio knob controls and wire them to scanner parameters
+ */
+function initRadioKnobControls() {
+ // Squelch knob
+ const squelchKnob = document.getElementById('radioSquelchKnob');
+ if (squelchKnob) {
+ squelchKnob.addEventListener('knobchange', function(e) {
+ const value = Math.round(e.detail.value);
+ const valueDisplay = document.getElementById('radioSquelchValue');
+ if (valueDisplay) valueDisplay.textContent = value;
+ // Sync with scanner
+ updateScannerConfig({ squelch: value });
+ // Restart stream if direct listening (squelch requires restart)
+ if (isDirectListening) {
+ startDirectListen();
+ }
+ });
+ }
+
+ // Gain knob
+ const gainKnob = document.getElementById('radioGainKnob');
+ if (gainKnob) {
+ gainKnob.addEventListener('knobchange', function(e) {
+ const value = Math.round(e.detail.value);
+ const valueDisplay = document.getElementById('radioGainValue');
+ if (valueDisplay) valueDisplay.textContent = value;
+ // Sync with scanner
+ updateScannerConfig({ gain: value });
+ // Restart stream if direct listening (gain requires restart)
+ if (isDirectListening) {
+ startDirectListen();
+ }
+ });
+ }
+
+ // Volume knob - controls scanner audio player volume
+ const volumeKnob = document.getElementById('radioVolumeKnob');
+ if (volumeKnob) {
+ volumeKnob.addEventListener('knobchange', function(e) {
+ const audioPlayer = document.getElementById('scannerAudioPlayer');
+ if (audioPlayer) {
+ audioPlayer.volume = e.detail.value / 100;
+ console.log('[VOLUME] Set to', Math.round(e.detail.value) + '%');
+ }
+ // Update knob value display
+ const valueDisplay = document.getElementById('radioVolumeValue');
+ if (valueDisplay) valueDisplay.textContent = Math.round(e.detail.value);
+ });
+ }
+
+ // Main Tuning dial - updates frequency display and inputs
+ const mainTuningDial = document.getElementById('mainTuningDial');
+ if (mainTuningDial) {
+ mainTuningDial.addEventListener('knobchange', function(e) {
+ const freq = e.detail.value;
+ // Update main frequency display
+ const mainFreq = document.getElementById('mainScannerFreq');
+ if (mainFreq) {
+ mainFreq.textContent = freq.toFixed(3);
+ }
+ // Update radio scan start input
+ const startFreqInput = document.getElementById('radioScanStart');
+ if (startFreqInput) {
+ startFreqInput.value = freq.toFixed(1);
+ }
+ // Update sidebar frequency input
+ const sidebarFreq = document.getElementById('audioFrequency');
+ if (sidebarFreq) {
+ sidebarFreq.value = freq.toFixed(3);
+ }
+ // If currently listening, retune to new frequency
+ if (isDirectListening) {
+ startDirectListen();
+ }
+ });
+ }
+
+ // Legacy tuning dial support
+ const tuningDial = document.getElementById('tuningDial');
+ if (tuningDial) {
+ tuningDial.addEventListener('knobchange', function(e) {
+ const mainFreq = document.getElementById('mainScannerFreq');
+ if (mainFreq) mainFreq.textContent = e.detail.value.toFixed(3);
+ const startFreqInput = document.getElementById('radioScanStart');
+ if (startFreqInput) startFreqInput.value = e.detail.value.toFixed(1);
+ // If currently listening, retune to new frequency
+ if (isDirectListening) {
+ startDirectListen();
+ }
+ });
+ }
+
+ // Sync radio scan range inputs with sidebar
+ const radioScanStart = document.getElementById('radioScanStart');
+ const radioScanEnd = document.getElementById('radioScanEnd');
+
+ if (radioScanStart) {
+ radioScanStart.addEventListener('change', function() {
+ const sidebarStart = document.getElementById('scanStartFreq');
+ if (sidebarStart) sidebarStart.value = this.value;
+ // Restart stream if direct listening
+ if (isDirectListening) {
+ startDirectListen();
+ }
+ });
+ }
+
+ if (radioScanEnd) {
+ radioScanEnd.addEventListener('change', function() {
+ const sidebarEnd = document.getElementById('scanEndFreq');
+ if (sidebarEnd) sidebarEnd.value = this.value;
+ });
+ }
+}
+
+/**
+ * Set modulation mode (called from HTML onclick)
+ */
+function setModulation(mod) {
+ // Update sidebar select
+ const modSelect = document.getElementById('scanModulation');
+ if (modSelect) modSelect.value = mod;
+
+ // Update audio modulation select
+ const audioMod = document.getElementById('audioModulation');
+ if (audioMod) audioMod.value = mod;
+
+ // Update button states in radio panel
+ document.querySelectorAll('#modBtnBank .radio-btn').forEach(btn => {
+ btn.classList.toggle('active', btn.dataset.mod === mod);
+ });
+
+ // Update main display badge
+ const mainBadge = document.getElementById('mainScannerMod');
+ if (mainBadge) mainBadge.textContent = mod.toUpperCase();
+}
+
+/**
+ * Set band preset (called from HTML onclick)
+ */
+function setBand(band) {
+ const preset = scannerPresets[band];
+ if (!preset) return;
+
+ // Update button states
+ document.querySelectorAll('#bandBtnBank .radio-btn').forEach(btn => {
+ btn.classList.toggle('active', btn.dataset.band === band);
+ });
+
+ // Update sidebar frequency inputs
+ const sidebarStart = document.getElementById('scanStartFreq');
+ const sidebarEnd = document.getElementById('scanEndFreq');
+ if (sidebarStart) sidebarStart.value = preset.start;
+ if (sidebarEnd) sidebarEnd.value = preset.end;
+
+ // Update radio panel frequency inputs
+ const radioStart = document.getElementById('radioScanStart');
+ const radioEnd = document.getElementById('radioScanEnd');
+ if (radioStart) radioStart.value = preset.start;
+ if (radioEnd) radioEnd.value = preset.end;
+
+ // Update tuning dial range and value (silent to avoid triggering restart)
+ const tuningDial = document.getElementById('tuningDial');
+ if (tuningDial && tuningDial._dial) {
+ tuningDial._dial.min = preset.start;
+ tuningDial._dial.max = preset.end;
+ tuningDial._dial.setValue(preset.start, true);
+ }
+
+ // Update main frequency display
+ const mainFreq = document.getElementById('mainScannerFreq');
+ if (mainFreq) mainFreq.textContent = preset.start.toFixed(3);
+
+ // Update modulation
+ setModulation(preset.mod);
+
+ // Update main range display if scanning
+ const rangeStart = document.getElementById('mainRangeStart');
+ const rangeEnd = document.getElementById('mainRangeEnd');
+ if (rangeStart) rangeStart.textContent = preset.start;
+ if (rangeEnd) rangeEnd.textContent = preset.end;
+
+ // Store for scanner use
+ scannerStartFreq = preset.start;
+ scannerEndFreq = preset.end;
+}
+
+// ============== SYNTHESIZER VISUALIZATION ==============
+
+let synthAnimationId = null;
+let synthCanvas = null;
+let synthCtx = null;
+let synthBars = [];
+const SYNTH_BAR_COUNT = 32;
+
+function initSynthesizer() {
+ synthCanvas = document.getElementById('synthesizerCanvas');
+ if (!synthCanvas) return;
+
+ // Set canvas size
+ const rect = synthCanvas.parentElement.getBoundingClientRect();
+ synthCanvas.width = rect.width - 20;
+ synthCanvas.height = 60;
+
+ synthCtx = synthCanvas.getContext('2d');
+
+ // Initialize bar heights
+ for (let i = 0; i < SYNTH_BAR_COUNT; i++) {
+ synthBars[i] = { height: 0, targetHeight: 0, velocity: 0 };
+ }
+
+ drawSynthesizer();
+}
+
+// Debug: log signal level periodically
+let lastSynthDebugLog = 0;
+
+function drawSynthesizer() {
+ if (!synthCtx || !synthCanvas) return;
+
+ const width = synthCanvas.width;
+ const height = synthCanvas.height;
+ const barWidth = (width / SYNTH_BAR_COUNT) - 2;
+
+ // Clear canvas
+ synthCtx.fillStyle = 'rgba(0, 0, 0, 0.3)';
+ synthCtx.fillRect(0, 0, width, height);
+
+ // Determine activity level based on actual signal level
+ let activityLevel = 0;
+ let signalIntensity = 0;
+
+ // Debug logging every 2 seconds
+ const now = Date.now();
+ if (now - lastSynthDebugLog > 2000) {
+ console.log('[SYNTH] State:', {
+ isScannerRunning,
+ isDirectListening,
+ scannerSignalActive,
+ currentSignalLevel,
+ visualizerAnalyser: !!visualizerAnalyser
+ });
+ lastSynthDebugLog = now;
+ }
+
+ if (isScannerRunning && !isScannerPaused) {
+ // Use actual signal level data (0-5000 range, normalize to 0-1)
+ signalIntensity = Math.min(1, currentSignalLevel / 3000);
+ // Base activity when scanning, boosted by actual signal strength
+ activityLevel = 0.15 + (signalIntensity * 0.85);
+ if (scannerSignalActive) {
+ activityLevel = Math.max(activityLevel, 0.7);
+ }
+ } else if (isDirectListening) {
+ // For direct listening, use signal level if available
+ signalIntensity = Math.min(1, currentSignalLevel / 3000);
+ activityLevel = 0.2 + (signalIntensity * 0.8);
+ }
+
+ // Update bar targets
+ for (let i = 0; i < SYNTH_BAR_COUNT; i++) {
+ if (activityLevel > 0) {
+ // Create wave-like pattern modulated by actual signal strength
+ const time = Date.now() / 200;
+ // Multiple wave frequencies for more organic feel
+ const wave1 = Math.sin(time + (i * 0.3)) * 0.2;
+ const wave2 = Math.sin(time * 1.7 + (i * 0.5)) * 0.15;
+ // Less randomness when signal is weak, more when strong
+ const randomAmount = 0.1 + (signalIntensity * 0.3);
+ const random = (Math.random() - 0.5) * randomAmount;
+ // Center bars tend to be taller (frequency spectrum shape)
+ const centerBoost = 1 - Math.abs((i - SYNTH_BAR_COUNT / 2) / (SYNTH_BAR_COUNT / 2)) * 0.4;
+ // Combine all factors with signal-driven amplitude
+ const baseHeight = 0.15 + (signalIntensity * 0.5);
+ synthBars[i].targetHeight = (baseHeight + wave1 + wave2 + random) * activityLevel * centerBoost * height;
+ } else {
+ // Idle state - minimal activity
+ synthBars[i].targetHeight = (Math.sin((Date.now() / 500) + (i * 0.5)) * 0.1 + 0.1) * height * 0.3;
+ }
+
+ // Smooth animation - faster response when signal changes
+ const springStrength = signalIntensity > 0.3 ? 0.15 : 0.1;
+ const diff = synthBars[i].targetHeight - synthBars[i].height;
+ synthBars[i].velocity += diff * springStrength;
+ synthBars[i].velocity *= 0.8;
+ synthBars[i].height += synthBars[i].velocity;
+ synthBars[i].height = Math.max(2, Math.min(height - 4, synthBars[i].height));
+ }
+
+ // Draw bars
+ for (let i = 0; i < SYNTH_BAR_COUNT; i++) {
+ const x = i * (barWidth + 2) + 1;
+ const barHeight = synthBars[i].height;
+ const y = (height - barHeight) / 2;
+
+ // Color gradient based on height and state
+ let hue, saturation, lightness;
+ if (scannerSignalActive) {
+ hue = 120; // Green for signal
+ saturation = 80;
+ lightness = 40 + (barHeight / height) * 30;
+ } else if (isScannerRunning || isDirectListening) {
+ hue = 190 + (i / SYNTH_BAR_COUNT) * 30; // Cyan to blue
+ saturation = 80;
+ lightness = 35 + (barHeight / height) * 25;
+ } else {
+ hue = 200;
+ saturation = 50;
+ lightness = 25 + (barHeight / height) * 15;
+ }
+
+ const gradient = synthCtx.createLinearGradient(x, y, x, y + barHeight);
+ gradient.addColorStop(0, `hsla(${hue}, ${saturation}%, ${lightness + 20}%, 0.9)`);
+ gradient.addColorStop(0.5, `hsla(${hue}, ${saturation}%, ${lightness}%, 1)`);
+ gradient.addColorStop(1, `hsla(${hue}, ${saturation}%, ${lightness + 20}%, 0.9)`);
+
+ synthCtx.fillStyle = gradient;
+ synthCtx.fillRect(x, y, barWidth, barHeight);
+
+ // Add glow effect for active bars
+ if (barHeight > height * 0.5 && activityLevel > 0.5) {
+ synthCtx.shadowColor = `hsla(${hue}, ${saturation}%, 60%, 0.5)`;
+ synthCtx.shadowBlur = 8;
+ synthCtx.fillRect(x, y, barWidth, barHeight);
+ synthCtx.shadowBlur = 0;
+ }
+ }
+
+ // Draw center line
+ synthCtx.strokeStyle = 'rgba(0, 212, 255, 0.2)';
+ synthCtx.lineWidth = 1;
+ synthCtx.beginPath();
+ synthCtx.moveTo(0, height / 2);
+ synthCtx.lineTo(width, height / 2);
+ synthCtx.stroke();
+
+ // Debug: show signal level value
+ if (isScannerRunning || isDirectListening) {
+ synthCtx.fillStyle = 'rgba(255, 255, 255, 0.5)';
+ synthCtx.font = '9px monospace';
+ synthCtx.fillText(`lvl:${Math.round(currentSignalLevel)}`, 4, 10);
+ }
+
+ synthAnimationId = requestAnimationFrame(drawSynthesizer);
+}
+
+function stopSynthesizer() {
+ if (synthAnimationId) {
+ cancelAnimationFrame(synthAnimationId);
+ synthAnimationId = null;
+ }
+}
+
+// ============== INITIALIZATION ==============
+
+/**
+ * Get the audio stream URL with parameters
+ * Streams directly from Flask - no Icecast needed
+ */
+function getStreamUrl(freq, mod) {
+ const frequency = freq || parseFloat(document.getElementById('radioScanStart')?.value) || 118.0;
+ const modulation = mod || currentModulation || 'am';
+ return `/listening/audio/stream?fresh=1&freq=${frequency}&mod=${modulation}&t=${Date.now()}`;
+}
+
+function initListeningPost() {
+ checkScannerTools();
+ checkAudioTools();
+ initSnrThresholdControl();
+
+ // WebSocket audio disabled for now - using HTTP streaming
+ // initWebSocketAudio();
+
+ // Initialize synthesizer visualization
+ initSynthesizer();
+
+ // Initialize radio knobs if the component is available
+ if (typeof initRadioKnobs === 'function') {
+ initRadioKnobs();
+ }
+
+ // Connect radio knobs to scanner controls
+ initRadioKnobControls();
+
+ // Step dropdown - sync with scanner when changed
+ const stepSelect = document.getElementById('radioScanStep');
+ if (stepSelect) {
+ stepSelect.addEventListener('change', function() {
+ const step = parseFloat(this.value);
+ console.log('[SCANNER] Step changed to:', step, 'kHz');
+ updateScannerConfig({ step: step });
+ });
+ }
+
+ // Dwell dropdown - sync with scanner when changed
+ const dwellSelect = document.getElementById('radioScanDwell');
+ if (dwellSelect) {
+ dwellSelect.addEventListener('change', function() {
+ const dwell = parseInt(this.value);
+ console.log('[SCANNER] Dwell changed to:', dwell, 's');
+ updateScannerConfig({ dwell_time: dwell });
+ });
+ }
+
+ // Set up audio player error handling
+ const audioPlayer = document.getElementById('audioPlayer');
+ if (audioPlayer) {
+ audioPlayer.addEventListener('error', function(e) {
+ console.warn('Audio player error:', e);
+ if (isAudioPlaying && audioReconnectAttempts < MAX_AUDIO_RECONNECT) {
+ audioReconnectAttempts++;
+ setTimeout(() => {
+ audioPlayer.src = getStreamUrl();
+ audioPlayer.play().catch(() => {});
+ }, 500);
+ }
+ });
+
+ audioPlayer.addEventListener('stalled', function() {
+ if (isAudioPlaying) {
+ audioPlayer.load();
+ audioPlayer.play().catch(() => {});
+ }
+ });
+
+ audioPlayer.addEventListener('playing', function() {
+ audioReconnectAttempts = 0;
+ });
+ }
+
+ // Keyboard controls for frequency tuning
+ document.addEventListener('keydown', function(e) {
+ // Only active in listening mode
+ if (typeof currentMode !== 'undefined' && currentMode !== 'listening') {
+ return;
+ }
+
+ // Don't intercept if user is typing in an input
+ const activeEl = document.activeElement;
+ if (activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA' || activeEl.tagName === 'SELECT')) {
+ return;
+ }
+
+ // Arrow keys for tuning
+ // Up/Down: fine tuning (Shift for ultra-fine)
+ // Left/Right: coarse tuning (Shift for very coarse)
+ let delta = 0;
+ switch (e.key) {
+ case 'ArrowUp':
+ delta = e.shiftKey ? 0.005 : 0.05;
+ break;
+ case 'ArrowDown':
+ delta = e.shiftKey ? -0.005 : -0.05;
+ break;
+ case 'ArrowRight':
+ delta = e.shiftKey ? 1 : 0.1;
+ break;
+ case 'ArrowLeft':
+ delta = e.shiftKey ? -1 : -0.1;
+ break;
+ default:
+ return; // Not a tuning key
+ }
+
+ e.preventDefault();
+ tuneFreq(delta);
+ });
+
+ // Check if we arrived from Spy Stations with a tune request
+ checkIncomingTuneRequest();
+}
+
+function initSnrThresholdControl() {
+ const slider = document.getElementById('snrThresholdSlider');
+ const valueEl = document.getElementById('snrThresholdValue');
+ if (!slider || !valueEl) return;
+
+ const stored = localStorage.getItem('scannerSnrThreshold');
+ if (stored) {
+ const parsed = parseInt(stored, 10);
+ if (!Number.isNaN(parsed)) {
+ scannerSnrThreshold = parsed;
+ }
+ }
+
+ slider.value = scannerSnrThreshold;
+ valueEl.textContent = String(scannerSnrThreshold);
+
+ slider.addEventListener('input', () => {
+ scannerSnrThreshold = parseInt(slider.value, 10);
+ valueEl.textContent = String(scannerSnrThreshold);
+ localStorage.setItem('scannerSnrThreshold', String(scannerSnrThreshold));
+ });
+}
+
+/**
+ * Check for incoming tune request from Spy Stations or other pages
+ */
+function checkIncomingTuneRequest() {
+ const tuneFreq = sessionStorage.getItem('tuneFrequency');
+ const tuneMode = sessionStorage.getItem('tuneMode');
+
+ if (tuneFreq) {
+ // Clear the session storage first
+ sessionStorage.removeItem('tuneFrequency');
+ sessionStorage.removeItem('tuneMode');
+
+ // Parse and validate frequency
+ const freq = parseFloat(tuneFreq);
+ if (!isNaN(freq) && freq >= 0.01 && freq <= 2000) {
+ console.log('[LISTEN] Incoming tune request:', freq, 'MHz, mode:', tuneMode || 'default');
+
+ // Determine modulation (default to USB for HF/number stations)
+ const mod = tuneMode || (freq < 30 ? 'usb' : 'am');
+
+ // Use quickTune to set frequency and modulation
+ quickTune(freq, mod);
+
+ // Show notification
+ if (typeof showNotification === 'function') {
+ showNotification('Tuned to ' + freq.toFixed(3) + ' MHz', mod.toUpperCase() + ' mode');
+ }
+ }
+ }
+}
+
+// Initialize when DOM is ready
+document.addEventListener('DOMContentLoaded', initListeningPost);
+
+// ============== UNIFIED RADIO CONTROLS ==============
+
+/**
+ * Toggle direct listen mode (tune to start frequency and listen)
+ */
+function toggleDirectListen() {
+ console.log('[LISTEN] toggleDirectListen called, isDirectListening:', isDirectListening);
+ if (isDirectListening) {
+ stopDirectListen();
+ } else {
+ const audioPlayer = document.getElementById('scannerAudioPlayer');
+ if (audioPlayer) {
+ audioPlayer.muted = false;
+ audioPlayer.autoplay = true;
+ audioPlayer.preload = 'auto';
+ }
+ audioUnlockRequested = true;
+ // First press - start immediately, don't debounce
+ startDirectListenImmediate();
+ }
+}
+
+// Debounce for startDirectListen
+let listenDebounceTimer = null;
+// Flag to prevent overlapping restart attempts
+let isRestarting = false;
+// Flag indicating another restart is needed after current one finishes
+let restartPending = false;
+// Debounce for frequency tuning (user might be scrolling through)
+// Needs to be long enough for SDR to fully release between restarts
+const TUNE_DEBOUNCE_MS = 600;
+
+/**
+ * Start direct listening - debounced for frequency changes
+ */
+function startDirectListen() {
+ if (listenDebounceTimer) {
+ clearTimeout(listenDebounceTimer);
+ }
+ listenDebounceTimer = setTimeout(async () => {
+ // If already restarting, mark that we need another restart when done
+ if (isRestarting) {
+ console.log('[LISTEN] Restart in progress, will retry after');
+ restartPending = true;
+ return;
+ }
+
+ await _startDirectListenInternal();
+
+ // If another restart was requested during this one, do it now
+ while (restartPending) {
+ restartPending = false;
+ console.log('[LISTEN] Processing pending restart');
+ await _startDirectListenInternal();
+ }
+ }, TUNE_DEBOUNCE_MS);
+}
+
+/**
+ * Start listening immediately (no debounce) - for button press
+ */
+async function startDirectListenImmediate() {
+ if (listenDebounceTimer) {
+ clearTimeout(listenDebounceTimer);
+ listenDebounceTimer = null;
+ }
+ restartPending = false; // Clear any pending
+ if (isRestarting) {
+ console.log('[LISTEN] Waiting for current restart to finish...');
+ // Wait for current restart to complete (max 5 seconds)
+ let waitCount = 0;
+ while (isRestarting && waitCount < 50) {
+ await new Promise(r => setTimeout(r, 100));
+ waitCount++;
+ }
+ }
+ await _startDirectListenInternal();
+}
+
+// ============== WEBSOCKET AUDIO ==============
+
+/**
+ * Initialize WebSocket audio connection
+ */
+function initWebSocketAudio() {
+ if (audioWebSocket && audioWebSocket.readyState === WebSocket.OPEN) {
+ return audioWebSocket;
+ }
+
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+ const wsUrl = `${protocol}//${window.location.host}/ws/audio`;
+
+ console.log('[WS-AUDIO] Connecting to:', wsUrl);
+ audioWebSocket = new WebSocket(wsUrl);
+ audioWebSocket.binaryType = 'arraybuffer';
+
+ audioWebSocket.onopen = () => {
+ console.log('[WS-AUDIO] Connected');
+ isWebSocketAudio = true;
+ };
+
+ audioWebSocket.onclose = () => {
+ console.log('[WS-AUDIO] Disconnected');
+ isWebSocketAudio = false;
+ audioWebSocket = null;
+ };
+
+ audioWebSocket.onerror = (e) => {
+ console.error('[WS-AUDIO] Error:', e);
+ isWebSocketAudio = false;
+ };
+
+ audioWebSocket.onmessage = (event) => {
+ if (typeof event.data === 'string') {
+ // JSON message (status updates)
+ try {
+ const msg = JSON.parse(event.data);
+ console.log('[WS-AUDIO] Status:', msg);
+ if (msg.status === 'error') {
+ addScannerLogEntry('Audio error: ' + msg.message, '', 'error');
+ }
+ } catch (e) {}
+ } else {
+ // Binary data (audio)
+ handleWebSocketAudioData(event.data);
+ }
+ };
+
+ return audioWebSocket;
+}
+
+/**
+ * Handle incoming WebSocket audio data
+ */
+function handleWebSocketAudioData(data) {
+ const audioPlayer = document.getElementById('scannerAudioPlayer');
+ if (!audioPlayer) return;
+
+ // Use MediaSource API to stream audio
+ if (!audioPlayer.msSource) {
+ setupMediaSource(audioPlayer);
+ }
+
+ if (audioPlayer.sourceBuffer && !audioPlayer.sourceBuffer.updating) {
+ try {
+ audioPlayer.sourceBuffer.appendBuffer(new Uint8Array(data));
+ } catch (e) {
+ // Buffer full or other error, skip this chunk
+ }
+ } else {
+ // Queue data for later
+ audioQueue.push(new Uint8Array(data));
+ if (audioQueue.length > 50) audioQueue.shift(); // Prevent memory buildup
+ }
+}
+
+/**
+ * Setup MediaSource for streaming audio
+ */
+function setupMediaSource(audioPlayer) {
+ if (!window.MediaSource) {
+ console.warn('[WS-AUDIO] MediaSource not supported');
+ return;
+ }
+
+ const mediaSource = new MediaSource();
+ audioPlayer.src = URL.createObjectURL(mediaSource);
+ audioPlayer.msSource = mediaSource;
+
+ mediaSource.addEventListener('sourceopen', () => {
+ try {
+ const sourceBuffer = mediaSource.addSourceBuffer('audio/mpeg');
+ audioPlayer.sourceBuffer = sourceBuffer;
+
+ sourceBuffer.addEventListener('updateend', () => {
+ // Process queued data
+ if (audioQueue.length > 0 && !sourceBuffer.updating) {
+ try {
+ sourceBuffer.appendBuffer(audioQueue.shift());
+ } catch (e) {}
+ }
+ });
+ } catch (e) {
+ console.error('[WS-AUDIO] Failed to create source buffer:', e);
+ }
+ });
+}
+
+/**
+ * Send command over WebSocket
+ */
+function sendWebSocketCommand(cmd, config = {}) {
+ if (!audioWebSocket || audioWebSocket.readyState !== WebSocket.OPEN) {
+ initWebSocketAudio();
+ // Wait for connection and retry
+ setTimeout(() => sendWebSocketCommand(cmd, config), 500);
+ return;
+ }
+
+ audioWebSocket.send(JSON.stringify({ cmd, config }));
+}
+
+async function _startDirectListenInternal() {
+ console.log('[LISTEN] _startDirectListenInternal called');
+
+ // Prevent overlapping restarts
+ if (isRestarting) {
+ console.log('[LISTEN] Already restarting, skipping');
+ return;
+ }
+ isRestarting = true;
+
+ try {
+ if (isScannerRunning) {
+ stopScanner();
+ }
+
+ const freqInput = document.getElementById('radioScanStart');
+ const freq = freqInput ? parseFloat(freqInput.value) : 118.0;
+ const squelchValue = parseInt(document.getElementById('radioSquelchValue')?.textContent);
+ const squelch = Number.isFinite(squelchValue) ? squelchValue : 0;
+ const gain = parseInt(document.getElementById('radioGainValue')?.textContent) || 40;
+ const device = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0;
+ const sdrType = typeof getSelectedSDRType === 'function'
+ ? getSelectedSDRType()
+ : getSelectedSDRTypeForScanner();
+ const biasT = typeof getBiasTEnabled === 'function' ? getBiasTEnabled() : false;
+
+ console.log('[LISTEN] Tuning to:', freq, 'MHz', currentModulation, 'device', device, 'sdr', sdrType);
+
+ const listenBtn = document.getElementById('radioListenBtn');
+ if (listenBtn) {
+ listenBtn.innerHTML = Icons.loader('icon--sm') + ' TUNING...';
+ listenBtn.style.background = 'var(--accent-orange)';
+ listenBtn.style.borderColor = 'var(--accent-orange)';
+ }
+
+ const audioPlayer = document.getElementById('scannerAudioPlayer');
+ if (!audioPlayer) {
+ addScannerLogEntry('Audio player not found', '', 'error');
+ updateDirectListenUI(false);
+ return;
+ }
+
+ // Fully reset audio element to clean state
+ audioPlayer.oncanplay = null; // Remove old handler
+ try {
+ audioPlayer.pause();
+ } catch (e) {}
+ audioPlayer.removeAttribute('src');
+ audioPlayer.load(); // Reset the element
+
+ // Start audio on backend (it handles stopping old stream)
+ const response = await fetch('/listening/audio/start', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ frequency: freq,
+ modulation: currentModulation,
+ squelch: 0,
+ gain: gain,
+ device: device,
+ sdr_type: sdrType,
+ bias_t: biasT
+ })
+ });
+
+ const result = await response.json();
+ console.log('[LISTEN] Backend:', result.status);
+
+ if (result.status !== 'started') {
+ console.error('[LISTEN] Failed:', result.message);
+ addScannerLogEntry('Failed: ' + (result.message || 'Unknown error'), '', 'error');
+ isDirectListening = false;
+ updateDirectListenUI(false);
+ return;
+ }
+
+ // Wait for stream to be ready (backend needs time after restart)
+ await new Promise(r => setTimeout(r, 300));
+
+ // Connect to new stream
+ const streamUrl = `/listening/audio/stream?fresh=1&t=${Date.now()}`;
+ console.log('[LISTEN] Connecting to stream:', streamUrl);
+ audioPlayer.src = streamUrl;
+ audioPlayer.preload = 'auto';
+ audioPlayer.autoplay = true;
+ audioPlayer.muted = false;
+ audioPlayer.load();
+
+ // Apply current volume from knob
+ const volumeKnob = document.getElementById('radioVolumeKnob');
+ if (volumeKnob && volumeKnob._knob) {
+ audioPlayer.volume = volumeKnob._knob.getValue() / 100;
+ } else if (volumeKnob) {
+ const knobValue = parseFloat(volumeKnob.dataset.value) || 80;
+ audioPlayer.volume = knobValue / 100;
+ }
+
+ // Wait for audio to be ready then play
+ audioPlayer.oncanplay = () => {
+ console.log('[LISTEN] Audio can play');
+ attemptAudioPlay(audioPlayer);
+ };
+
+ // Also try to play immediately (some browsers need this)
+ attemptAudioPlay(audioPlayer);
+
+ // If stream is slow, retry play and prompt for manual unlock
+ setTimeout(async () => {
+ if (!isDirectListening || !audioPlayer) return;
+ if (audioPlayer.readyState > 0) return;
+ audioPlayer.load();
+ attemptAudioPlay(audioPlayer);
+ showAudioUnlock(audioPlayer);
+ }, 2500);
+
+ // Initialize audio visualizer to feed signal levels to synthesizer
+ initAudioVisualizer();
+
+ isDirectListening = true;
+ updateDirectListenUI(true, freq);
+ addScannerLogEntry(`${freq.toFixed(3)} MHz (${currentModulation.toUpperCase()})`, '', 'signal');
+
+ } catch (e) {
+ console.error('[LISTEN] Error:', e);
+ addScannerLogEntry('Error: ' + e.message, '', 'error');
+ isDirectListening = false;
+ updateDirectListenUI(false);
+ } finally {
+ isRestarting = false;
+ }
+}
+
+function attemptAudioPlay(audioPlayer) {
+ if (!audioPlayer) return;
+ audioPlayer.play().then(() => {
+ hideAudioUnlock();
+ }).catch(() => {
+ // Autoplay likely blocked; show manual unlock
+ showAudioUnlock(audioPlayer);
+ });
+}
+
+function showAudioUnlock(audioPlayer) {
+ const unlockBtn = document.getElementById('audioUnlockBtn');
+ if (!unlockBtn || !audioUnlockRequested) return;
+ unlockBtn.style.display = 'block';
+ unlockBtn.onclick = () => {
+ audioPlayer.muted = false;
+ audioPlayer.play().then(() => {
+ hideAudioUnlock();
+ }).catch(() => {});
+ };
+}
+
+function hideAudioUnlock() {
+ const unlockBtn = document.getElementById('audioUnlockBtn');
+ if (unlockBtn) {
+ unlockBtn.style.display = 'none';
+ }
+ audioUnlockRequested = false;
+}
+
+async function startFetchAudioStream(streamUrl, audioPlayer) {
+ if (!window.MediaSource) {
+ console.warn('[LISTEN] MediaSource not supported for fetch fallback');
+ return false;
+ }
+
+ // Abort any previous fetch stream
+ if (audioFetchController) {
+ audioFetchController.abort();
+ }
+ audioFetchController = new AbortController();
+
+ // Reset audio element for MediaSource
+ try {
+ audioPlayer.pause();
+ } catch (e) {}
+ audioPlayer.removeAttribute('src');
+ audioPlayer.load();
+
+ const mediaSource = new MediaSource();
+ audioPlayer.src = URL.createObjectURL(mediaSource);
+ audioPlayer.muted = false;
+ audioPlayer.autoplay = true;
+
+ return new Promise((resolve) => {
+ mediaSource.addEventListener('sourceopen', async () => {
+ let sourceBuffer;
+ try {
+ sourceBuffer = mediaSource.addSourceBuffer('audio/mpeg');
+ } catch (e) {
+ console.error('[LISTEN] Failed to create source buffer:', e);
+ resolve(false);
+ return;
+ }
+
+ try {
+ let attempts = 0;
+ while (attempts < 5) {
+ attempts += 1;
+ const response = await fetch(streamUrl, {
+ cache: 'no-store',
+ signal: audioFetchController.signal
+ });
+
+ if (response.status === 204) {
+ console.warn('[LISTEN] Stream not ready (204), retrying...', attempts);
+ await new Promise(r => setTimeout(r, 500));
+ continue;
+ }
+
+ if (!response.ok || !response.body) {
+ console.warn('[LISTEN] Fetch stream response invalid', response.status);
+ resolve(false);
+ return;
+ }
+
+ const reader = response.body.getReader();
+ const appendChunk = async (chunk) => {
+ if (!chunk || chunk.length === 0) return;
+ if (!sourceBuffer.updating) {
+ sourceBuffer.appendBuffer(chunk);
+ return;
+ }
+ await new Promise(r => sourceBuffer.addEventListener('updateend', r, { once: true }));
+ sourceBuffer.appendBuffer(chunk);
+ };
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ await appendChunk(value);
+ }
+
+ resolve(true);
+ return;
+ }
+
+ resolve(false);
+ } catch (e) {
+ if (e.name !== 'AbortError') {
+ console.error('[LISTEN] Fetch stream error:', e);
+ }
+ resolve(false);
+ }
+ }, { once: true });
+ });
+}
+
+async function startWebSocketListen(config, audioPlayer) {
+ const selectedType = typeof getSelectedSDRType === 'function'
+ ? getSelectedSDRType()
+ : getSelectedSDRTypeForScanner();
+ if (selectedType && selectedType !== 'rtlsdr') {
+ console.warn('[LISTEN] WebSocket audio supports RTL-SDR only');
+ return;
+ }
+
+ try {
+ // Stop HTTP audio stream before switching
+ await fetch('/listening/audio/stop', { method: 'POST' });
+ } catch (e) {}
+
+ // Reset audio element for MediaSource
+ try {
+ audioPlayer.pause();
+ } catch (e) {}
+ audioPlayer.removeAttribute('src');
+ audioPlayer.load();
+
+ const ws = initWebSocketAudio();
+ if (!ws) return;
+
+ // Ensure MediaSource is set up
+ setupMediaSource(audioPlayer);
+ sendWebSocketCommand('start', config);
+}
+
+/**
+ * Stop direct listening
+ */
+function stopDirectListen() {
+ console.log('[LISTEN] Stopping');
+
+ // Clear all pending state
+ if (listenDebounceTimer) {
+ clearTimeout(listenDebounceTimer);
+ listenDebounceTimer = null;
+ }
+ restartPending = false;
+
+ const audioPlayer = document.getElementById('scannerAudioPlayer');
+ if (audioPlayer) {
+ audioPlayer.pause();
+ // Clear MediaSource if using WebSocket
+ if (audioPlayer.msSource) {
+ try {
+ audioPlayer.msSource.endOfStream();
+ } catch (e) {}
+ audioPlayer.msSource = null;
+ audioPlayer.sourceBuffer = null;
+ }
+ audioPlayer.src = '';
+ }
+ audioQueue = [];
+ if (audioFetchController) {
+ audioFetchController.abort();
+ audioFetchController = null;
+ }
+
+ // Stop via WebSocket if connected
+ if (audioWebSocket && audioWebSocket.readyState === WebSocket.OPEN) {
+ sendWebSocketCommand('stop');
+ }
+
+ // Also stop via HTTP (fallback)
+ fetch('/listening/audio/stop', { method: 'POST' }).catch(() => {});
+
+ isDirectListening = false;
+ currentSignalLevel = 0;
+ updateDirectListenUI(false);
+ addScannerLogEntry('Listening stopped');
+}
+
+/**
+ * Update UI for direct listen mode
+ */
+function updateDirectListenUI(isPlaying, freq) {
+ const listenBtn = document.getElementById('radioListenBtn');
+ const statusLabel = document.getElementById('mainScannerModeLabel');
+ const freqDisplay = document.getElementById('mainScannerFreq');
+ const quickStatus = document.getElementById('lpQuickStatus');
+ const quickFreq = document.getElementById('lpQuickFreq');
+
+ if (listenBtn) {
+ if (isPlaying) {
+ listenBtn.innerHTML = Icons.stop('icon--sm') + ' STOP';
+ listenBtn.classList.add('active');
+ } else {
+ listenBtn.innerHTML = Icons.headphones('icon--sm') + ' LISTEN';
+ listenBtn.classList.remove('active');
+ }
+ }
+
+ if (statusLabel) {
+ statusLabel.textContent = isPlaying ? 'LISTENING' : 'STOPPED';
+ statusLabel.style.color = isPlaying ? 'var(--accent-green)' : 'var(--text-muted)';
+ }
+
+ if (freqDisplay && freq) {
+ freqDisplay.textContent = freq.toFixed(3);
+ }
+
+ if (quickStatus) {
+ quickStatus.textContent = isPlaying ? 'LISTENING' : 'IDLE';
+ quickStatus.style.color = isPlaying ? 'var(--accent-green)' : 'var(--accent-cyan)';
+ }
+
+ if (quickFreq && freq) {
+ quickFreq.textContent = freq.toFixed(3) + ' MHz';
+ }
+}
+
+/**
+ * Tune frequency by delta
+ */
+function tuneFreq(delta) {
+ const freqInput = document.getElementById('radioScanStart');
+ if (freqInput) {
+ let newFreq = parseFloat(freqInput.value) + delta;
+ // Round to 3 decimal places to avoid floating-point precision issues
+ newFreq = Math.round(newFreq * 1000) / 1000;
+ newFreq = Math.max(24, Math.min(1800, newFreq));
+ freqInput.value = newFreq.toFixed(3);
+
+ // Update display
+ const freqDisplay = document.getElementById('mainScannerFreq');
+ if (freqDisplay) {
+ freqDisplay.textContent = newFreq.toFixed(3);
+ }
+
+ // Update tuning dial position (silent to avoid duplicate restart)
+ const mainTuningDial = document.getElementById('mainTuningDial');
+ if (mainTuningDial && mainTuningDial._dial) {
+ mainTuningDial._dial.setValue(newFreq, true);
+ }
+
+ const quickFreq = document.getElementById('lpQuickFreq');
+ if (quickFreq) {
+ quickFreq.textContent = newFreq.toFixed(3) + ' MHz';
+ }
+
+ // If currently listening, restart stream at new frequency
+ if (isDirectListening) {
+ startDirectListen();
+ }
+ }
+}
+
+/**
+ * Quick tune to a preset frequency
+ */
+function quickTune(freq, mod) {
+ // Update frequency inputs
+ const startInput = document.getElementById('radioScanStart');
+ if (startInput) {
+ startInput.value = freq;
+ }
+
+ // Update modulation (don't trigger auto-restart here, we'll handle it below)
+ if (mod) {
+ currentModulation = mod;
+ // Update modulation UI without triggering restart
+ document.querySelectorAll('#modBtnBank .radio-btn').forEach(btn => {
+ btn.classList.toggle('active', btn.dataset.mod === mod);
+ });
+ const badge = document.getElementById('mainScannerMod');
+ if (badge) {
+ const modLabels = { am: 'AM', fm: 'NFM', wfm: 'WFM', usb: 'USB', lsb: 'LSB' };
+ badge.textContent = modLabels[mod] || mod.toUpperCase();
+ }
+ }
+
+ // Update display
+ const freqDisplay = document.getElementById('mainScannerFreq');
+ if (freqDisplay) {
+ freqDisplay.textContent = freq.toFixed(3);
+ }
+
+ // Update tuning dial position (silent to avoid duplicate restart)
+ const mainTuningDial = document.getElementById('mainTuningDial');
+ if (mainTuningDial && mainTuningDial._dial) {
+ mainTuningDial._dial.setValue(freq, true);
+ }
+
+ const quickFreq = document.getElementById('lpQuickFreq');
+ if (quickFreq) {
+ quickFreq.textContent = freq.toFixed(3) + ' MHz';
+ }
+
+ addScannerLogEntry(`Quick tuned to ${freq.toFixed(3)} MHz (${mod.toUpperCase()})`);
+
+ // If currently listening, restart immediately (this is a deliberate preset selection)
+ if (isDirectListening) {
+ startDirectListenImmediate();
+ }
+}
+
+/**
+ * Enhanced setModulation to also update currentModulation
+ * Uses immediate restart if currently listening
+ */
+const originalSetModulation = window.setModulation;
+window.setModulation = function(mod) {
+ console.log('[MODULATION] Setting modulation to:', mod, 'isListening:', isDirectListening);
+ currentModulation = mod;
+
+ // Update modulation button states
+ document.querySelectorAll('#modBtnBank .radio-btn').forEach(btn => {
+ btn.classList.toggle('active', btn.dataset.mod === mod);
+ });
+
+ // Update badge
+ const badge = document.getElementById('mainScannerMod');
+ if (badge) {
+ const modLabels = { am: 'AM', fm: 'NFM', wfm: 'WFM', usb: 'USB', lsb: 'LSB' };
+ badge.textContent = modLabels[mod] || mod.toUpperCase();
+ }
+
+ // Update scanner modulation select if exists
+ const modSelect = document.getElementById('scannerModulation');
+ if (modSelect) {
+ modSelect.value = mod;
+ }
+
+ // Sync with scanner if running
+ updateScannerConfig({ modulation: mod });
+
+ // If currently listening, restart immediately (deliberate modulation change)
+ if (isDirectListening) {
+ console.log('[MODULATION] Restarting audio with new modulation:', mod);
+ startDirectListenImmediate();
+ } else {
+ console.log('[MODULATION] Not listening, just updated UI');
+ }
+};
+
+/**
+ * Update sidebar quick status
+ */
+function updateQuickStatus() {
+ const quickStatus = document.getElementById('lpQuickStatus');
+ const quickFreq = document.getElementById('lpQuickFreq');
+ const quickSignals = document.getElementById('lpQuickSignals');
+
+ if (quickStatus) {
+ if (isScannerRunning) {
+ quickStatus.textContent = isScannerPaused ? 'PAUSED' : 'SCANNING';
+ quickStatus.style.color = isScannerPaused ? 'var(--accent-orange)' : 'var(--accent-green)';
+ } else if (isDirectListening) {
+ quickStatus.textContent = 'LISTENING';
+ quickStatus.style.color = 'var(--accent-green)';
+ } else {
+ quickStatus.textContent = 'IDLE';
+ quickStatus.style.color = 'var(--accent-cyan)';
+ }
+ }
+
+ if (quickSignals) {
+ quickSignals.textContent = scannerSignalCount;
+ }
+}
+
+// ============== SIDEBAR CONTROLS ==============
+
+// Frequency bookmarks stored in localStorage
+let frequencyBookmarks = [];
+
+/**
+ * Load bookmarks from localStorage
+ */
+function loadFrequencyBookmarks() {
+ try {
+ const saved = localStorage.getItem('lpBookmarks');
+ if (saved) {
+ frequencyBookmarks = JSON.parse(saved);
+ renderBookmarks();
+ }
+ } catch (e) {
+ console.warn('Failed to load bookmarks:', e);
+ }
+}
+
+/**
+ * Save bookmarks to localStorage
+ */
+function saveFrequencyBookmarks() {
+ try {
+ localStorage.setItem('lpBookmarks', JSON.stringify(frequencyBookmarks));
+ } catch (e) {
+ console.warn('Failed to save bookmarks:', e);
+ }
+}
+
+/**
+ * Add a frequency bookmark
+ */
+function addFrequencyBookmark() {
+ const input = document.getElementById('bookmarkFreqInput');
+ if (!input) return;
+
+ const freq = parseFloat(input.value);
+ if (isNaN(freq) || freq <= 0) {
+ if (typeof showNotification === 'function') {
+ showNotification('Invalid Frequency', 'Please enter a valid frequency');
+ }
+ return;
+ }
+
+ // Check for duplicates
+ if (frequencyBookmarks.some(b => Math.abs(b.freq - freq) < 0.001)) {
+ if (typeof showNotification === 'function') {
+ showNotification('Duplicate', 'This frequency is already bookmarked');
+ }
+ return;
+ }
+
+ frequencyBookmarks.push({
+ freq: freq,
+ mod: currentModulation || 'am',
+ added: new Date().toISOString()
+ });
+
+ saveFrequencyBookmarks();
+ renderBookmarks();
+ input.value = '';
+
+ if (typeof showNotification === 'function') {
+ showNotification('Bookmark Added', `${freq.toFixed(3)} MHz saved`);
+ }
+}
+
+/**
+ * Remove a bookmark by index
+ */
+function removeBookmark(index) {
+ frequencyBookmarks.splice(index, 1);
+ saveFrequencyBookmarks();
+ renderBookmarks();
+}
+
+/**
+ * Render bookmarks list
+ */
+function renderBookmarks() {
+ const container = document.getElementById('bookmarksList');
+ if (!container) return;
+
+ if (frequencyBookmarks.length === 0) {
+ container.innerHTML = 'No bookmarks saved
';
+ return;
+ }
+
+ container.innerHTML = frequencyBookmarks.map((b, i) => `
+
+ ${b.freq.toFixed(3)} MHz
+ ${b.mod.toUpperCase()}
+
+
+ `).join('');
+}
+
+
+/**
+ * Add a signal to the sidebar recent signals list
+ */
+function addSidebarRecentSignal(freq, mod) {
+ const container = document.getElementById('sidebarRecentSignals');
+ if (!container) return;
+
+ // Clear placeholder if present
+ if (container.innerHTML.includes('No signals yet')) {
+ container.innerHTML = '';
+ }
+
+ const timestamp = new Date().toLocaleTimeString();
+ const signalDiv = document.createElement('div');
+ signalDiv.style.cssText = 'display: flex; justify-content: space-between; align-items: center; padding: 3px 6px; background: rgba(0,255,100,0.1); border-left: 2px solid var(--accent-green); margin-bottom: 2px; border-radius: 2px;';
+ signalDiv.innerHTML = `
+ ${freq.toFixed(3)}
+ ${timestamp}
+ `;
+
+ container.insertBefore(signalDiv, container.firstChild);
+
+ // Keep only last 10 signals
+ while (container.children.length > 10) {
+ container.removeChild(container.lastChild);
+ }
+}
+
+// Load bookmarks on init
+document.addEventListener('DOMContentLoaded', loadFrequencyBookmarks);
+
+/**
+ * Set listening post running state from external source (agent sync).
+ * Called by syncModeUI in agents.js when switching to an agent that already has scan running.
+ */
+function setListeningPostRunning(isRunning, agentId = null) {
+ console.log(`[ListeningPost] setListeningPostRunning: ${isRunning}, agent: ${agentId}`);
+
+ isScannerRunning = isRunning;
+
+ if (isRunning && agentId !== null && agentId !== 'local') {
+ // Agent has scan running - sync UI and start polling
+ listeningPostCurrentAgent = agentId;
+
+ // Update main scan button (radioScanBtn is the actual ID)
+ const radioScanBtn = document.getElementById('radioScanBtn');
+ if (radioScanBtn) {
+ radioScanBtn.innerHTML = 'STOP';
+ radioScanBtn.style.background = 'var(--accent-red)';
+ radioScanBtn.style.borderColor = 'var(--accent-red)';
+ }
+
+ // Update status display
+ updateScannerDisplay('SCANNING', 'var(--accent-green)');
+
+ // Disable listen button (can't stream audio from agent)
+ updateListenButtonState(true);
+
+ // Start polling for agent data
+ startListeningPostPolling();
+ } else if (!isRunning) {
+ // Not running - reset UI
+ listeningPostCurrentAgent = null;
+
+ // Reset scan button
+ const radioScanBtn = document.getElementById('radioScanBtn');
+ if (radioScanBtn) {
+ radioScanBtn.innerHTML = 'SCAN';
+ radioScanBtn.style.background = '';
+ radioScanBtn.style.borderColor = '';
+ }
+
+ // Update status
+ updateScannerDisplay('IDLE', 'var(--text-secondary)');
+
+ // Only re-enable listen button if we're in local mode
+ // (agent mode can't stream audio over HTTP)
+ const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
+ updateListenButtonState(isAgentMode);
+
+ // Clear polling
+ if (listeningPostPollTimer) {
+ clearInterval(listeningPostPollTimer);
+ listeningPostPollTimer = null;
+ }
+ }
+}
+
+// Export for agent sync
+window.setListeningPostRunning = setListeningPostRunning;
+window.updateListenButtonState = updateListenButtonState;
+
+// Export functions for HTML onclick handlers
+window.toggleDirectListen = toggleDirectListen;
+window.startDirectListen = startDirectListen;
+window.stopDirectListen = stopDirectListen;
+window.toggleScanner = toggleScanner;
+window.startScanner = startScanner;
+window.stopScanner = stopScanner;
+window.pauseScanner = pauseScanner;
+window.skipSignal = skipSignal;
+// Note: setModulation is already exported with enhancements above
+window.setBand = setBand;
+window.tuneFreq = tuneFreq;
+window.quickTune = quickTune;
+window.checkIncomingTuneRequest = checkIncomingTuneRequest;
+window.addFrequencyBookmark = addFrequencyBookmark;
+window.removeBookmark = removeBookmark;
+window.tuneToFrequency = tuneToFrequency;
+window.clearScannerLog = clearScannerLog;
+window.exportScannerLog = exportScannerLog;
diff --git a/templates/adsb_dashboard.html b/templates/adsb_dashboard.html
index 639e140..0f16815 100644
--- a/templates/adsb_dashboard.html
+++ b/templates/adsb_dashboard.html
@@ -1,4899 +1,4904 @@
-
-
-
-
-
- AIRCRAFT RADAR // INTERCEPT - See the Invisible
-
- {% if offline_settings.fonts_source == 'local' %}
-
- {% else %}
-
- {% endif %}
-
- {% if offline_settings.assets_source == 'local' %}
-
-
- {% else %}
-
-
- {% endif %}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 0
- NOW
-
-
- 0
- SEEN
-
-
- 0
- MAX NM
-
-
- -
- HIGH FL
-
-
- -
- FAST KT
-
-
- -
- NEAR NM
-
-
- 0
- COUNTRIES
-
-
- 0
- ACARS
-
-
- Local
- SOURCE
-
-
- --
- SIGNAL
-
-
- 00:00:00
- SESSION
-
-
-
-
-
-
- 📚 History
-
-
-
-
--:--:-- UTC
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
AIRBAND
-
-
-
-
-
-
- SQ
-
- VOL
-
-
-
-
OFF
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- | Code |
- Name |
- Description |
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
No entries. Add callsigns, registrations, or ICAO codes to watch.
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+ AIRCRAFT RADAR // INTERCEPT - See the Invisible
+
+ {% if offline_settings.fonts_source == 'local' %}
+
+ {% else %}
+
+ {% endif %}
+
+ {% if offline_settings.assets_source == 'local' %}
+
+
+ {% else %}
+
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+
+
+ {% set active_mode = 'adsb' %}
+ {% include 'partials/nav.html' with context %}
+
+
+
+
+
+ 0
+ NOW
+
+
+ 0
+ SEEN
+
+
+ 0
+ MAX NM
+
+
+ -
+ HIGH FL
+
+
+ -
+ FAST KT
+
+
+ -
+ NEAR NM
+
+
+ 0
+ COUNTRIES
+
+
+ 0
+ ACARS
+
+
+ Local
+ SOURCE
+
+
+ --
+ SIGNAL
+
+
+ 00:00:00
+ SESSION
+
+
+
+
+
+
+ 📚 History
+
+
+
+
--:--:-- UTC
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
AIRBAND
+
+
+
+
+
+
+ SQ
+
+ VOL
+
+
+
+
OFF
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Code |
+ Name |
+ Description |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
No entries. Add callsigns, registrations, or ICAO codes to watch.
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/templates/adsb_history.html b/templates/adsb_history.html
index c9b0e6c..8b9ea22 100644
--- a/templates/adsb_history.html
+++ b/templates/adsb_history.html
@@ -4,8 +4,13 @@
ADS-B History // INTERCEPT
-
+ {% if offline_settings.fonts_source == 'local' %}
+
+ {% else %}
+
+ {% endif %}
+
@@ -22,6 +27,9 @@
+ {% set active_mode = 'adsb' %}
+ {% include 'partials/nav.html' with context %}
+
@@ -761,5 +769,6 @@
}
});
+