diff --git a/static/css/adsb_dashboard.css b/static/css/adsb_dashboard.css
index 9afac6f..8ef6822 100644
--- a/static/css/adsb_dashboard.css
+++ b/static/css/adsb_dashboard.css
@@ -132,7 +132,7 @@ body {
display: flex;
gap: 20px;
align-items: center;
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 11px;
}
@@ -624,7 +624,7 @@ body {
}
.telemetry-value {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 12px;
color: var(--accent-cyan);
}
@@ -680,7 +680,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 +700,7 @@ body {
}
.aircraft-detail-value {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
color: var(--accent-cyan);
font-size: 11px;
}
@@ -790,7 +790,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 +801,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 +879,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 +911,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 +1023,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 +1057,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 +1407,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 +1545,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 +1784,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 +1938,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..f24740b 100644
--- a/static/css/adsb_history.css
+++ b/static/css/adsb_history.css
@@ -27,7 +27,7 @@ body {
}
.mono {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
}
.radar-bg {
@@ -91,7 +91,7 @@ body {
display: flex;
align-items: center;
gap: 12px;
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 11px;
}
@@ -268,7 +268,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 +306,7 @@ body {
}
.panel-meta {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 11px;
color: var(--accent-cyan);
}
@@ -347,7 +347,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..bece8d5 100644
--- a/static/css/agents.css
+++ b/static/css/agents.css
@@ -1,343 +1,343 @@
-/*
- * 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 {
+ --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..8a8b7c0 100644
--- a/static/css/ais_dashboard.css
+++ b/static/css/ais_dashboard.css
@@ -134,7 +134,7 @@ body {
display: flex;
gap: 20px;
align-items: center;
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 11px;
}
@@ -183,7 +183,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 +287,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 +367,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 */
@@ -438,7 +438,7 @@ body {
padding: 10px 15px;
background: rgba(74, 158, 255, 0.05);
border-bottom: 1px solid rgba(74, 158, 255, 0.1);
- font-family: 'Orbitron', 'JetBrains Mono', monospace;
+ font-family: 'Orbitron', 'Terminus', monospace;
font-size: 11px;
font-weight: 500;
letter-spacing: 2px;
@@ -510,7 +510,7 @@ body {
}
.vessel-name {
- font-family: 'Orbitron', 'JetBrains Mono', monospace;
+ font-family: 'Orbitron', 'Terminus', monospace;
font-size: 16px;
font-weight: 700;
color: var(--accent-cyan);
@@ -518,7 +518,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 +548,7 @@ body {
}
.detail-value {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 12px;
color: var(--accent-cyan);
}
@@ -604,20 +604,20 @@ body {
}
.vessel-item-name {
- font-family: 'Orbitron', 'JetBrains Mono', monospace;
+ font-family: 'Orbitron', 'Terminus', monospace;
font-size: 12px;
font-weight: 600;
color: var(--accent-cyan);
}
.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 +687,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 +698,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 +717,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 +1004,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 +1079,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 +1096,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 +1120,7 @@ body {
}
.dsc-message-pos {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 9px;
color: var(--text-secondary);
}
@@ -1148,7 +1148,7 @@ body {
}
.dsc-distress-alert .dsc-alert-header {
- font-family: 'Orbitron', 'JetBrains Mono', monospace;
+ font-family: 'Orbitron', 'Terminus', monospace;
font-size: 24px;
font-weight: 700;
color: var(--accent-red);
@@ -1157,7 +1157,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 +1177,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 +1188,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..645d258 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: '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: 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: '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: 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: '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: 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: '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: 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
index 492be77..8ff5c65 100644
--- a/static/css/components/function-strip.css
+++ b/static/css/components/function-strip.css
@@ -1,371 +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: 'JetBrains Mono', monospace;
- 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: '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;
-}
-
-/* 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); }
+/* 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..19e5e73 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: '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: 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: '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: 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/variables.css b/static/css/core/variables.css
index 32b79a0..318717e 100644
--- a/static/css/core/variables.css
+++ b/static/css/core/variables.css
@@ -73,8 +73,8 @@
/* ============================================
TYPOGRAPHY
============================================ */
- --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
- --font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
+ --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+ --font-mono: 'Terminus', 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
/* Font sizes */
--text-xs: 10px;
diff --git a/static/css/fonts-local.css b/static/css/fonts-local.css
index 7f0167d..49e61c6 100644
--- a/static/css/fonts-local.css
+++ b/static/css/fonts-local.css
@@ -33,7 +33,7 @@
src: url('/static/vendor/fonts/Inter-Bold.woff2') format('woff2');
}
-/* JetBrains Mono - Monospace/code font */
+/* JetBrains Mono - Monospace/code font */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
@@ -58,10 +58,19 @@
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');
-}
+@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');
+}
+
+/* Terminus - Monospace UI font */
+@font-face {
+ font-family: 'Terminus';
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url('/static/vendor/fonts/Terminus.ttf') format('truetype');
+}
diff --git a/static/css/global-nav.css b/static/css/global-nav.css
index 8a4d82f..9b10fbb 100644
--- a/static/css/global-nav.css
+++ b/static/css/global-nav.css
@@ -50,7 +50,7 @@
letter-spacing: 1px;
margin-right: 8px;
font-weight: 500;
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
}
.mode-nav-divider {
@@ -80,7 +80,7 @@
.mode-nav-btn .nav-label {
text-transform: uppercase;
letter-spacing: 0.08em;
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 10px;
}
@@ -127,7 +127,7 @@
.nav-action-btn .nav-label {
text-transform: uppercase;
letter-spacing: 0.08em;
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 10px;
}
@@ -162,7 +162,7 @@
.mode-nav-dropdown-btn .nav-label {
text-transform: uppercase;
letter-spacing: 0.08em;
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 10px;
}
@@ -268,7 +268,7 @@
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;
diff --git a/static/css/index.css b/static/css/index.css
index 301904b..2cbf607 100644
--- a/static/css/index.css
+++ b/static/css/index.css
@@ -1,5 +1,13 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap');
+@font-face {
+ font-family: 'Terminus';
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url('/static/vendor/fonts/Terminus.ttf') format('truetype');
+}
+
* {
box-sizing: border-box;
margin: 0;
@@ -259,7 +267,7 @@ body {
}
.welcome-title {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 2.5rem;
font-weight: 700;
color: var(--text-primary);
@@ -269,7 +277,7 @@ body {
}
.welcome-tagline {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 0.9rem;
color: var(--accent-cyan);
letter-spacing: 0.15em;
@@ -278,7 +286,7 @@ body {
.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);
@@ -297,7 +305,7 @@ body {
}
.welcome-content h2 {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--text-secondary);
text-transform: uppercase;
@@ -333,7 +341,7 @@ body {
}
.changelog-version {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 0.8rem;
color: var(--accent-cyan);
font-weight: 600;
@@ -364,7 +372,7 @@ body {
position: absolute;
left: -15px;
color: var(--accent-green);
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
}
/* Mode Selection Grid */
@@ -435,7 +443,7 @@ body {
}
.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);
@@ -463,7 +471,7 @@ body {
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);
@@ -922,7 +930,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;
@@ -1030,7 +1038,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);
@@ -1089,7 +1097,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;
}
@@ -1578,7 +1586,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;
}
@@ -1637,7 +1645,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;
@@ -1757,7 +1765,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 {
@@ -1783,7 +1791,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 */
@@ -1855,7 +1863,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;
}
@@ -1868,7 +1876,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);
@@ -2357,7 +2365,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 */
@@ -2394,7 +2402,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);
@@ -2411,7 +2419,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);
@@ -2432,7 +2440,7 @@ header h1 .tagline {
}
.aircraft-popup {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 11px;
}
@@ -2476,7 +2484,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;
@@ -2720,7 +2728,7 @@ 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;
}
@@ -3114,7 +3122,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);
}
@@ -3164,7 +3172,7 @@ header h1 .tagline {
display: flex;
gap: 15px;
font-size: 10px;
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
}
.recon-stats span {
@@ -3214,14 +3222,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 {
@@ -3297,7 +3305,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);
@@ -3969,7 +3977,7 @@ header h1 .tagline {
}
.bt-detail-address {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 10px;
color: #00d4ff;
}
@@ -3983,7 +3991,7 @@ header h1 .tagline {
}
.bt-detail-rssi-value {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 20px;
font-weight: 700;
}
@@ -4078,7 +4086,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;
@@ -4325,7 +4333,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;
@@ -4684,7 +4692,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 {
@@ -4731,7 +4739,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);
@@ -5247,7 +5255,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;
@@ -5404,7 +5412,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);
@@ -5425,7 +5433,7 @@ body::before {
}
.freq-unit {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 20px;
color: var(--text-secondary);
margin-left: 8px;
@@ -5569,7 +5577,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);
@@ -5694,7 +5702,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;
@@ -5756,13 +5764,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);
@@ -5794,7 +5802,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;
@@ -5930,7 +5938,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;
}
@@ -6019,7 +6027,7 @@ body::before {
}
.module-header {
- font-family: 'Orbitron', 'JetBrains Mono', monospace;
+ font-family: 'Orbitron', 'Terminus', monospace;
font-size: 10px;
font-weight: 600;
color: var(--accent-cyan);
@@ -6044,7 +6052,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;
@@ -6102,7 +6110,7 @@ body::before {
}
.stat-value {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 22px;
font-weight: bold;
}
@@ -6150,7 +6158,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);
@@ -6180,13 +6188,13 @@ 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 */
.radio-mode-btn {
padding: 12px 24px;
- font-family: 'Orbitron', 'JetBrains Mono', monospace;
+ font-family: 'Orbitron', 'Terminus', monospace;
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
@@ -6227,7 +6235,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);
@@ -6291,4 +6299,4 @@ body::before {
[data-animations="off"] .logo-dot,
[data-animations="off"] .welcome-logo {
animation: none !important;
-}
\ No newline at end of file
+}
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..9f4223f 100644
--- a/static/css/responsive.css
+++ b/static/css/responsive.css
@@ -1,7 +1,16 @@
-/* ============================================
- RESPONSIVE UTILITIES - iNTERCEPT
- Shared responsive foundation for all pages
- ============================================ */
+/* ============================================
+ RESPONSIVE UTILITIES - iNTERCEPT
+ Shared responsive foundation for all pages
+ ============================================ */
+
+/* Terminus - bundled monospace */
+@font-face {
+ font-family: 'Terminus';
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url('/static/vendor/fonts/Terminus.ttf') format('truetype');
+}
/* ============== CSS VARIABLES ============== */
:root {
diff --git a/static/css/satellite_dashboard.css b/static/css/satellite_dashboard.css
index 02c2790..72b110c 100644
--- a/static/css/satellite_dashboard.css
+++ b/static/css/satellite_dashboard.css
@@ -142,7 +142,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 +164,7 @@ body {
display: flex;
gap: 20px;
align-items: center;
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 11px;
}
@@ -457,7 +457,7 @@ body {
}
.telemetry-value {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
font-size: 12px;
color: var(--accent-cyan);
}
@@ -543,7 +543,7 @@ body {
}
.pass-time {
- font-family: 'JetBrains Mono', monospace;
+ font-family: var(--font-mono);
}
/* Bottom controls bar */
@@ -579,7 +579,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;
}
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..bfab560 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: "Terminus", 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: "Terminus", 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/settings-manager.js b/static/js/core/settings-manager.js
index 7e3fbad..8282e1b 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
-
- Reload
- ×
- `;
- 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 ? `
-
View Update Details
- ` : ''}
-
- `;
-
- 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
+
+ Reload
+ ×
+ `;
+ 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 ? `
+
View Update Details
+ ` : ''}
+
+ `;
+
+ 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..c3dc4c2 100644
--- a/static/js/modes/listening-post.js
+++ b/static/js/modes/listening-post.js
@@ -1,2599 +1,2599 @@
-/**
- * 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()}
-
- Listen
-
- `;
- 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;
+
+// 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()}
+
+ Listen
+
+ `;
+ 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 Terminus';
+ 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;
+
diff --git a/static/js/modes/spy-stations.js b/static/js/modes/spy-stations.js
index df598c5..945e9f8 100644
--- a/static/js/modes/spy-stations.js
+++ b/static/js/modes/spy-stations.js
@@ -84,7 +84,7 @@ const SpyStations = (function() {
modeContainer.innerHTML = modes.map(m => `
- ${m}
+ ${m}
`).join('');
}
diff --git a/static/vendor/fonts/Terminus.ttf b/static/vendor/fonts/Terminus.ttf
new file mode 100644
index 0000000..7a82c19
Binary files /dev/null and b/static/vendor/fonts/Terminus.ttf differ
diff --git a/templates/adsb_dashboard.html b/templates/adsb_dashboard.html
index 6e06310..be182f8 100644
--- a/templates/adsb_dashboard.html
+++ b/templates/adsb_dashboard.html
@@ -1,4904 +1,4904 @@
-
-
-
-
-
- AIRCRAFT RADAR // INTERCEPT - See the Invisible
-
- {% if offline_settings.fonts_source == 'local' %}
-
- {% else %}
-
- {% endif %}
-
- {% if offline_settings.assets_source == 'local' %}
-
-
- {% else %}
-
-
- {% endif %}
+
+
+
+
+
+ AIRCRAFT RADAR // INTERCEPT - See the Invisible
+
+ {% if offline_settings.fonts_source == 'local' %}
+
+ {% else %}
+
+ {% endif %}
+
+ {% if offline_settings.assets_source == 'local' %}
+
+
+ {% else %}
+
+
+ {% endif %}
-
-
-
-
-
-
-
-