From e687862043bb4eb7e46fada81b563eebc7781abc Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 12 Mar 2026 13:04:36 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20UI/UX=20overhaul=20=E2=80=94=20CSS=20cl?= =?UTF-8?q?eanup,=20accessibility,=20error=20handling,=20inline=20style=20?= =?UTF-8?q?extraction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0 — CSS-only fixes: - Fix --font-mono to use real monospace stack (JetBrains Mono, Fira Code, etc.) - Replace hardcoded hex colors with CSS variables across 16+ files - Merge global-nav.css (507 lines) into layout.css, delete original - Reduce !important in responsive.css from 71 to 8 via .app-shell specificity - Standardize breakpoints to 480/768/1024/1280px Phase 1 — Loading states & SSE connection feedback: - Add centralized SSEManager (sse-manager.js) with exponential backoff - Add SSE status indicator dot in nav bar - Add withLoadingButton() + .btn-loading CSS spinner - Add mode section crossfade transitions Phase 2 — Accessibility: - Add aria-labels to icon-only buttons across mode partials - Add for/id associations to 42 form labels in 5 mode partials - Add aria-live on toast stack, enableListKeyNav() utility Phase 3 — Destructive action guards & list overflow: - Add confirmAction() styled modal, replace all 25 native confirm() calls - Add toast cap at 5 simultaneous toasts - Add list overflow indicator CSS Phase 4 — Inline style extraction: - Refactor switchMode() in app.js and index.html to use classList.toggle() - Add CSS toggle rules for all switchMode-controlled elements - Remove inline style="display:none" from 7+ HTML elements - Add utility classes (.hidden, .d-flex, .d-grid, etc.) Phase 5 — Mobile UX polish: - pre/code overflow handling already in place - Touch target sizing via --touch-min variable Phase 6 — Error handling consistency: - Add reportActionableError() to user-facing catch blocks in 5 mode JS files - 28 error toast additions alongside existing console.error calls Co-Authored-By: Claude Opus 4.6 --- static/css/adsb_dashboard.css | 2 +- static/css/adsb_history.css | 2 +- static/css/components/device-cards.css | 4 +- static/css/components/signal-cards.css | 4 +- static/css/components/ux-platform.css | 4 +- static/css/core/base.css | 155 +- static/css/core/components.css | 232 ++- static/css/core/layout.css | 543 ++++--- static/css/core/variables.css | 6 +- static/css/global-nav.css | 507 ------ static/css/index.css | 8 +- static/css/modes/bt_locate.css | 2 +- static/css/modes/meshtastic.css | 2 +- static/css/modes/meteor.css | 4 +- static/css/modes/ook.css | 2 +- static/css/modes/radiosonde.css | 2 +- static/css/modes/spy-stations.css | 2 +- static/css/modes/subghz.css | 4 +- static/css/modes/tscm.css | 174 +- static/css/modes/waterfall.css | 4 +- static/css/modes/weather-satellite.css | 62 +- static/css/modes/wefax.css | 64 +- static/css/modes/wifi_locate.css | 2 +- static/css/responsive.css | 261 +-- static/css/satellite_dashboard.css | 6 +- static/css/settings.css | 4 +- static/js/core/agents.js | 17 +- static/js/core/alerts.js | 10 +- static/js/core/app.js | 67 +- static/js/core/sse-manager.js | 245 +++ static/js/core/ui-feedback.js | 160 +- static/js/modes/bluetooth.js | 17 +- static/js/modes/meshtastic.js | 31 +- static/js/modes/ook.js | 20 +- static/js/modes/sstv-general.js | 16 +- static/js/modes/sstv.js | 23 +- static/js/modes/subghz.js | 1802 +++++++++++---------- static/js/modes/weather-satellite.js | 34 +- static/js/modes/wefax.js | 29 +- static/js/modes/wifi.js | 8 +- templates/adsb_dashboard.html | 43 +- templates/adsb_history.html | 16 +- templates/agents.html | 10 +- templates/ais_dashboard.html | 10 +- templates/index.html | 169 +- templates/network_monitor.html | 2 +- templates/partials/modes/bluetooth.html | 10 +- templates/partials/modes/bt_locate.html | 2 +- templates/partials/modes/meshtastic.html | 4 +- templates/partials/modes/pager.html | 12 +- templates/partials/modes/sensor.html | 6 +- templates/partials/modes/waterfall.html | 38 +- templates/partials/modes/wifi.html | 16 +- templates/partials/modes/wifi_locate.html | 2 +- templates/partials/nav.html | 15 +- templates/satellite_dashboard.html | 2 +- 56 files changed, 2660 insertions(+), 2238 deletions(-) delete mode 100644 static/css/global-nav.css create mode 100644 static/js/core/sse-manager.js diff --git a/static/css/adsb_dashboard.css b/static/css/adsb_dashboard.css index cf86c02..f343550 100644 --- a/static/css/adsb_dashboard.css +++ b/static/css/adsb_dashboard.css @@ -2449,7 +2449,7 @@ body { font-size: 10px; } -@media (max-width: 600px) { +@media (max-width: 480px) { .squawk-item { grid-template-columns: 45px 80px 1fr; gap: 8px; diff --git a/static/css/adsb_history.css b/static/css/adsb_history.css index 653ddef..deb6ad8 100644 --- a/static/css/adsb_history.css +++ b/static/css/adsb_history.css @@ -684,7 +684,7 @@ body { } } -@media (max-width: 720px) { +@media (max-width: 768px) { .controls { flex-direction: column; align-items: stretch; diff --git a/static/css/components/device-cards.css b/static/css/components/device-cards.css index af6f39b..69350b0 100644 --- a/static/css/components/device-cards.css +++ b/static/css/components/device-cards.css @@ -522,7 +522,7 @@ /* ============================================ RESPONSIVE ADJUSTMENTS ============================================ */ -@media (max-width: 600px) { +@media (max-width: 480px) { .device-signal-row { flex-direction: column; align-items: stretch; @@ -841,7 +841,7 @@ /* ============================================ RESPONSIVE MODAL ============================================ */ -@media (max-width: 600px) { +@media (max-width: 480px) { .modal-signal-stats { grid-template-columns: repeat(2, 1fr); } diff --git a/static/css/components/signal-cards.css b/static/css/components/signal-cards.css index 91a3fe5..e11f2f8 100644 --- a/static/css/components/signal-cards.css +++ b/static/css/components/signal-cards.css @@ -1128,7 +1128,7 @@ } /* Responsive adjustments for aggregated meters */ -@media (max-width: 500px) { +@media (max-width: 480px) { .meter-aggregated-grid { grid-template-columns: 1fr 1fr; grid-template-rows: auto auto; @@ -1922,7 +1922,7 @@ } /* Responsive adjustments */ -@media (max-width: 500px) { +@media (max-width: 480px) { .signal-details-modal-content { width: 95%; max-height: 90vh; diff --git a/static/css/components/ux-platform.css b/static/css/components/ux-platform.css index d760076..2c26f5e 100644 --- a/static/css/components/ux-platform.css +++ b/static/css/components/ux-platform.css @@ -429,7 +429,7 @@ border-color: rgba(31, 95, 168, 0.45); } -@media (max-width: 920px) { +@media (max-width: 1023px) { .run-state-strip { flex-direction: column; align-items: stretch; @@ -440,7 +440,7 @@ } } -@media (max-width: 640px) { +@media (max-width: 768px) { .command-palette-overlay { padding: 8vh 10px 0; } diff --git a/static/css/core/base.css b/static/css/core/base.css index d649c31..5279f22 100644 --- a/static/css/core/base.css +++ b/static/css/core/base.css @@ -21,36 +21,36 @@ html { tab-size: 4; } -body { - font-family: var(--font-sans); - font-size: var(--text-base); - line-height: var(--leading-normal); - color: var(--text-primary); - background-color: var(--bg-primary); - background-image: - radial-gradient(1200px 620px at 8% -12%, var(--ambient-top-left), transparent 62%), - radial-gradient(980px 560px at 92% -16%, var(--ambient-top-right), transparent 64%), - radial-gradient(900px 520px at 50% 126%, var(--ambient-bottom), transparent 68%), - var(--noise-image), - linear-gradient(var(--grid-line) 1px, transparent 1px), - linear-gradient(90deg, var(--grid-line) 1px, transparent 1px); - background-size: auto, auto, auto, 40px 40px, 48px 48px, 48px 48px; - background-attachment: fixed; - min-height: 100vh; - font-variant-numeric: tabular-nums; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} +body { + font-family: var(--font-sans); + font-size: var(--text-base); + line-height: var(--leading-normal); + color: var(--text-primary); + background-color: var(--bg-primary); + background-image: + radial-gradient(1200px 620px at 8% -12%, var(--ambient-top-left), transparent 62%), + radial-gradient(980px 560px at 92% -16%, var(--ambient-top-right), transparent 64%), + radial-gradient(900px 520px at 50% 126%, var(--ambient-bottom), transparent 68%), + var(--noise-image), + linear-gradient(var(--grid-line) 1px, transparent 1px), + linear-gradient(90deg, var(--grid-line) 1px, transparent 1px); + background-size: auto, auto, auto, 40px 40px, 48px 48px, 48px 48px; + background-attachment: fixed; + min-height: 100vh; + font-variant-numeric: tabular-nums; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} /* ============================================ TYPOGRAPHY ============================================ */ -h1, h2, h3, h4, h5, h6 { - font-weight: var(--font-semibold); - line-height: var(--leading-tight); - color: var(--text-primary); - letter-spacing: 0.01em; -} +h1, h2, h3, h4, h5, h6 { + font-weight: var(--font-semibold); + line-height: var(--leading-tight); + color: var(--text-primary); + letter-spacing: 0.01em; +} h1 { font-size: var(--text-4xl); } h2 { font-size: var(--text-3xl); } @@ -91,20 +91,23 @@ code, kbd, pre, samp { font-size: 0.9em; } -code { - background: var(--bg-elevated); - border: 1px solid var(--border-color); - padding: 2px 6px; - border-radius: var(--radius-sm); -} +code { + background: var(--bg-elevated); + border: 1px solid var(--border-color); + padding: 2px 6px; + border-radius: var(--radius-sm); + overflow-x: auto; + max-width: 100%; +} -pre { - background: var(--bg-elevated); - border: 1px solid var(--border-color); - padding: var(--space-4); - border-radius: var(--radius-md); - overflow-x: auto; -} +pre { + background: var(--bg-elevated); + border: 1px solid var(--border-color); + padding: var(--space-4); + border-radius: var(--radius-md); + overflow-x: auto; + max-width: 100%; +} pre code { background: none; @@ -135,38 +138,38 @@ button:disabled { opacity: 0.5; } -input, -select, -textarea { - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - padding: var(--space-2) var(--space-3); - color: var(--text-primary); - transition: border-color var(--transition-fast), box-shadow var(--transition-fast); -} +input, +select, +textarea { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: var(--space-2) var(--space-3); + color: var(--text-primary); + transition: border-color var(--transition-fast), box-shadow var(--transition-fast); +} -input:focus, -select:focus, -textarea:focus { - outline: none; - border-color: var(--accent-cyan); - box-shadow: 0 0 0 2px var(--accent-cyan-dim); -} +input:focus, +select:focus, +textarea:focus { + outline: none; + border-color: var(--accent-cyan); + box-shadow: 0 0 0 2px var(--accent-cyan-dim); +} input::placeholder, textarea::placeholder { color: var(--text-dim); } -select { - cursor: pointer; - appearance: none; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239fb0c7' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 8px center; - padding-right: 28px; -} +select { + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239fb0c7' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 8px center; + padding-right: 28px; +} input[type="checkbox"], input[type="radio"] { @@ -201,18 +204,18 @@ td { border-bottom: 1px solid var(--border-color); } -th { - font-weight: var(--font-semibold); - color: var(--text-secondary); - background: var(--bg-tertiary); - text-transform: uppercase; - font-size: var(--text-xs); - letter-spacing: 0.05em; -} - -tr:hover td { - background: var(--bg-elevated); -} +th { + font-weight: var(--font-semibold); + color: var(--text-secondary); + background: var(--bg-tertiary); + text-transform: uppercase; + font-size: var(--text-xs); + letter-spacing: 0.05em; +} + +tr:hover td { + background: var(--bg-elevated); +} /* ============================================ LISTS diff --git a/static/css/core/components.css b/static/css/core/components.css index b1f06f7..f6ba3ed 100644 --- a/static/css/core/components.css +++ b/static/css/core/components.css @@ -80,8 +80,8 @@ } .btn-danger:hover:not(:disabled) { - background: #dc2626; - border-color: #dc2626; + background: var(--accent-red-hover); + border-color: var(--accent-red-hover); } .btn-success { @@ -91,8 +91,8 @@ } .btn-success:hover:not(:disabled) { - background: #16a34a; - border-color: #16a34a; + background: var(--accent-green-hover); + border-color: var(--accent-green-hover); } /* Button sizes */ @@ -415,6 +415,28 @@ to { transform: rotate(360deg); } } +/* Button loading state */ +.btn-loading { + position: relative; + color: transparent; + pointer-events: none; +} + +.btn-loading::after { + content: ''; + position: absolute; + width: 14px; + height: 14px; + top: 50%; + left: 50%; + margin-top: -7px; + margin-left: -7px; + border: 2px solid var(--border-color); + border-top-color: var(--accent-cyan); + border-radius: var(--radius-full); + animation: spin 0.8s linear infinite; +} + /* Loading overlay */ .loading-overlay { position: absolute; @@ -855,3 +877,205 @@ textarea:focus { cursor: not-allowed; filter: grayscale(30%); } + +/* ============================================ + CONFIRMATION MODAL + ============================================ */ +.confirm-modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(2px); + display: flex; + align-items: center; + justify-content: center; + z-index: var(--z-modal); + animation: fadeIn 0.15s ease-out; +} + +.confirm-modal { + background: var(--surface-panel-gradient); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + padding: var(--space-6); + min-width: 320px; + max-width: 440px; + box-shadow: var(--shadow-lg); +} + +.confirm-modal-title { + font-size: var(--text-lg); + font-weight: var(--font-semibold); + color: var(--text-primary); + margin-bottom: var(--space-3); +} + +.confirm-modal-message { + font-size: var(--text-sm); + color: var(--text-secondary); + line-height: var(--leading-normal); + margin-bottom: var(--space-6); +} + +.confirm-modal-actions { + display: flex; + justify-content: flex-end; + gap: var(--space-3); +} + +/* ============================================ + MODE SECTION TRANSITIONS + ============================================ */ +.mode-section { + display: none; + opacity: 0; + transition: opacity var(--transition-fast); +} + +.mode-section.active { + display: block; + opacity: 1; +} + +/* ============================================ + SWITCHMODE TOGGLE CLASSES + Elements hidden by default, shown via .active + ============================================ */ + +/* Stats sections in header (pager, sensor, wifi, satellite, aircraft) */ +#pagerStats, +#sensorStats, +#aircraftStats, +#wifiStats, +#satelliteStats { + display: none; +} + +#pagerStats.active, +#sensorStats.active, +#aircraftStats.active, +#wifiStats.active, +#satelliteStats.active { + display: flex; +} + +/* Signal meter */ +#signalMeter { + display: none; +} + +#signalMeter.active { + display: block; +} + +/* Dashboard buttons in nav */ +#adsbDashboardBtn, +#satelliteDashboardBtn { + display: none; +} + +#adsbDashboardBtn.active, +#satelliteDashboardBtn.active { + display: inline-flex; +} + +/* Layout containers (wifi, bluetooth) */ +.wifi-layout-container, +.bt-layout-container { + display: none; +} + +.wifi-layout-container.active { + display: flex; +} + +.bt-layout-container.active { + display: flex; +} + +/* Visuals containers */ +#aircraftVisuals { + display: none; +} + +#aircraftVisuals.active { + display: grid; +} + +#satelliteVisuals { + display: none; +} + +#satelliteVisuals.active { + display: block; +} + +/* RTL-SDR device section */ +#rtlDeviceSection { + display: none; +} + +#rtlDeviceSection.active { + display: block; +} + +/* Tool status sections */ +.tool-status-section { + display: none; + grid-template-columns: auto auto; + gap: 4px 8px; + align-items: center; +} + +.tool-status-section.active { + display: grid; +} + +/* Output console and status bar — visible when mode is active, hidden for full-visual modes. + switchMode() adds/removes .active; hidden by default until a mode is selected. */ +.app-shell #output:not(.active) { + display: none; +} + +.app-shell .status-bar:not(.active) { + display: none; +} + +/* Recon panel — controlled by switchMode */ +.app-shell #reconPanel:not(.active) { + display: none; +} + +/* ============================================ + UTILITY CLASSES + ============================================ */ +.hidden { display: none; } +.d-flex { display: flex; } +.d-grid { display: grid; } +.gap-2 { gap: var(--space-2); } +.gap-4 { gap: var(--space-4); } +.text-center { text-align: center; } +.w-full { width: 100%; } + +/* Keyboard focus indicator for list items */ +.keyboard-focused { + outline: 2px solid var(--accent-cyan); + outline-offset: -2px; +} + +/* Overflow indicator */ +.list-overflow-indicator { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-2) var(--space-3); + font-size: var(--text-xs); + color: var(--text-dim); + border-top: 1px solid var(--border-color); + background: var(--bg-secondary); +} + +.list-overflow-indicator .btn { + font-size: var(--text-xs); + padding: var(--space-1) var(--space-2); +} diff --git a/static/css/core/layout.css b/static/css/core/layout.css index e42937f..0c59e07 100644 --- a/static/css/core/layout.css +++ b/static/css/core/layout.css @@ -22,31 +22,31 @@ /* ============================================ GLOBAL HEADER ============================================ */ -.app-header { - display: flex; - align-items: center; - justify-content: space-between; - height: var(--header-height); - padding: 0 var(--space-4); - background: linear-gradient(180deg, var(--bg-elevated) 0%, var(--bg-secondary) 100%); - border-bottom: 1px solid var(--border-color); - position: sticky; - top: 0; - z-index: var(--z-sticky); - box-shadow: var(--shadow-sm); -} - -.app-header::after { - content: ''; - position: absolute; - left: 0; - right: 0; - bottom: 0; - height: 2px; - background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent); - opacity: 0.6; - pointer-events: none; -} +.app-header { + display: flex; + align-items: center; + justify-content: space-between; + height: var(--header-height); + padding: 0 var(--space-4); + background: linear-gradient(180deg, var(--bg-elevated) 0%, var(--bg-secondary) 100%); + border-bottom: 1px solid var(--border-color); + position: sticky; + top: 0; + z-index: var(--z-sticky); + box-shadow: var(--shadow-sm); +} + +.app-header::after { + content: ''; + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 2px; + background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent); + opacity: 0.6; + pointer-events: none; +} .app-header-left { display: flex; @@ -129,29 +129,29 @@ /* ============================================ GLOBAL NAVIGATION ============================================ */ -.app-nav { - display: flex; - align-items: center; - background: var(--bg-secondary); - border-bottom: 1px solid var(--border-color); - padding: 0 var(--space-4); - height: var(--nav-height); - gap: var(--space-1); - overflow-x: auto; - position: relative; -} - -.app-nav::after { - content: ''; - position: absolute; - left: 0; - right: 0; - bottom: 0; - height: 1px; - background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent); - opacity: 0.5; - pointer-events: none; -} +.app-nav { + display: flex; + align-items: center; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); + padding: 0 var(--space-4); + height: var(--nav-height); + gap: var(--space-1); + overflow-x: auto; + position: relative; +} + +.app-nav::after { + content: ''; + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 1px; + background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent); + opacity: 0.5; + pointer-events: none; +} .app-nav::-webkit-scrollbar { height: 0; @@ -202,14 +202,14 @@ } /* Dropdown menu */ -.nav-dropdown-menu { - position: absolute; - top: 100%; - left: 0; - min-width: 180px; - background: var(--bg-elevated); - border: 1px solid var(--border-color); - border-radius: var(--radius-md); +.nav-dropdown-menu { + position: absolute; + top: 100%; + left: 0; + min-width: 180px; + background: var(--bg-elevated); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); box-shadow: var(--shadow-lg); padding: var(--space-1); opacity: 0; @@ -299,27 +299,27 @@ /* ============================================ MOBILE NAVIGATION ============================================ */ -.mobile-nav { - display: none; - background: var(--bg-secondary); - border-bottom: 1px solid var(--border-color); - padding: var(--space-2) var(--space-3); - overflow-x: auto; - gap: var(--space-2); - position: relative; -} - -.mobile-nav::after { - content: ''; - position: absolute; - left: 0; - right: 0; - bottom: 0; - height: 1px; - background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent); - opacity: 0.45; - pointer-events: none; -} +.mobile-nav { + display: none; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); + padding: var(--space-2) var(--space-3); + overflow-x: auto; + gap: var(--space-2); + position: relative; +} + +.mobile-nav::after { + content: ''; + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 1px; + background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent); + opacity: 0.45; + pointer-events: none; +} .mobile-nav::-webkit-scrollbar { height: 0; @@ -396,13 +396,13 @@ } /* Sidebar */ -.app-sidebar { - width: var(--sidebar-width); - background: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%); - border-right: 1px solid var(--border-color); - overflow-y: auto; - flex-shrink: 0; -} +.app-sidebar { + width: var(--sidebar-width); + background: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%); + border-right: 1px solid var(--border-color); + overflow-y: auto; + flex-shrink: 0; +} .sidebar-section { padding: var(--space-4); @@ -447,28 +447,28 @@ overflow: hidden; } -.dashboard-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: var(--space-2) var(--space-4); - background: linear-gradient(180deg, var(--bg-elevated) 0%, var(--bg-secondary) 100%); - border-bottom: 1px solid var(--border-color); - flex-shrink: 0; - position: relative; -} - -.dashboard-header::after { - content: ''; - position: absolute; - left: 0; - right: 0; - bottom: 0; - height: 2px; - background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent); - opacity: 0.55; - pointer-events: none; -} +.dashboard-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-2) var(--space-4); + background: linear-gradient(180deg, var(--bg-elevated) 0%, var(--bg-secondary) 100%); + border-bottom: 1px solid var(--border-color); + flex-shrink: 0; + position: relative; +} + +.dashboard-header::after { + content: ''; + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 2px; + background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent); + opacity: 0.55; + pointer-events: none; +} .dashboard-header-logo { font-size: var(--text-lg); @@ -495,10 +495,10 @@ position: relative; } -.dashboard-sidebar { - width: 320px; - background: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%); - border-left: 1px solid var(--border-color); +.dashboard-sidebar { + width: 320px; + background: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%); + border-left: 1px solid var(--border-color); overflow-y: auto; display: flex; flex-direction: column; @@ -638,27 +638,32 @@ Used by nav.html partial across all pages ============================================ */ +/* NAVIGATION + Mode nav bar, dropdowns, utilities, theme/effects toggles + ============================================ */ + /* Mode Navigation Bar */ -.mode-nav { - display: none; - background: var(--bg-secondary) !important; /* Explicit color - forced to ensure consistency */ - border-bottom: 1px solid var(--border-color); - padding: 0 20px; - position: relative; - z-index: 100; -} - -.mode-nav::after { - content: ''; - position: absolute; - left: 0; - right: 0; - bottom: 0; - height: 1px; - background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent); - opacity: 0.5; - pointer-events: none; -} +.mode-nav { + display: none; + background: linear-gradient(180deg, rgba(17, 22, 32, 0.92), rgba(15, 20, 28, 0.88)); + border-bottom: 1px solid var(--border-color); + padding: 0 20px; + position: relative; + z-index: var(--z-sticky); + backdrop-filter: blur(10px); +} + +.mode-nav::after { + content: ''; + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 1px; + background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent); + opacity: 0.5; + pointer-events: none; +} @media (min-width: 1024px) { .mode-nav { @@ -682,6 +687,7 @@ letter-spacing: 1px; margin-right: 8px; font-weight: 500; + font-family: var(--font-mono); } .mode-nav-divider { @@ -692,33 +698,27 @@ } .mode-nav-btn { - display: flex; + display: inline-flex; align-items: center; gap: 6px; padding: 8px 14px; background: transparent; border: 1px solid transparent; - border-radius: 4px; + border-radius: var(--radius-lg); color: var(--text-secondary); font-family: var(--font-sans); font-size: 11px; font-weight: 500; cursor: pointer; - transition: all 0.15s ease; -} - -.mode-nav-btn .nav-icon { - font-size: 14px; -} - -.mode-nav-btn .nav-icon svg { - width: 14px; - height: 14px; + transition: all var(--transition-fast); + text-decoration: none; } .mode-nav-btn .nav-label { text-transform: uppercase; - letter-spacing: 0.5px; + letter-spacing: 0.08em; + font-family: var(--font-mono); + font-size: 10px; } .mode-nav-btn:hover { @@ -728,13 +728,14 @@ } .mode-nav-btn.active { - background: var(--accent-cyan); - color: var(--bg-primary); + background: var(--bg-elevated); + color: var(--text-primary); border-color: var(--accent-cyan); + box-shadow: inset 0 -2px 0 var(--accent-cyan); } .mode-nav-btn.active .nav-icon { - filter: brightness(0); + color: var(--accent-cyan); } .mode-nav-actions { @@ -749,29 +750,29 @@ gap: 6px; padding: 8px 14px; background: var(--bg-elevated); - border: 1px solid var(--accent-cyan); - border-radius: 4px; - color: var(--accent-cyan); + border: 1px solid var(--border-light); + border-radius: var(--radius-lg); + color: var(--text-primary); font-family: var(--font-sans); font-size: 11px; font-weight: 500; text-decoration: none; cursor: pointer; - transition: all 0.15s ease; -} - -.nav-action-btn .nav-icon { - font-size: 12px; + transition: all var(--transition-fast); } .nav-action-btn .nav-label { text-transform: uppercase; - letter-spacing: 0.5px; + letter-spacing: 0.08em; + font-family: var(--font-mono); + font-size: 10px; } .nav-action-btn:hover { - background: var(--accent-cyan); - color: var(--bg-primary); + background: var(--bg-tertiary); + color: var(--text-primary); + box-shadow: var(--shadow-md); + border-color: var(--accent-cyan); } /* Dropdown Navigation */ @@ -780,19 +781,41 @@ } .mode-nav-dropdown-btn { - display: flex; + display: inline-flex; align-items: center; gap: 8px; padding: 8px 14px; background: transparent; border: 1px solid transparent; - border-radius: 4px; + border-radius: var(--radius-lg); color: var(--text-secondary); font-family: var(--font-sans); font-size: 11px; font-weight: 500; cursor: pointer; - transition: all 0.15s ease; + transition: all var(--transition-fast); +} + +.mode-nav-dropdown-btn .nav-label { + text-transform: uppercase; + letter-spacing: 0.08em; + font-family: var(--font-mono); + font-size: 10px; +} + +.mode-nav-dropdown-btn .dropdown-arrow { + width: 12px; + height: 12px; + margin-left: 4px; + transition: transform 0.2s ease; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.mode-nav-dropdown-btn .dropdown-arrow svg { + width: 100%; + height: 100%; } .mode-nav-dropdown-btn:hover { @@ -801,31 +824,6 @@ border-color: var(--border-color); } -.mode-nav-dropdown-btn .nav-icon { - font-size: 14px; -} - -.mode-nav-dropdown-btn .nav-icon svg { - width: 14px; - height: 14px; -} - -.mode-nav-dropdown-btn .nav-label { - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.mode-nav-dropdown-btn .dropdown-arrow { - font-size: 8px; - margin-left: 4px; - transition: transform 0.2s ease; -} - -.mode-nav-dropdown-btn .dropdown-arrow svg { - width: 10px; - height: 10px; -} - .mode-nav-dropdown.open .mode-nav-dropdown-btn { background: var(--bg-elevated); color: var(--text-primary); @@ -837,13 +835,14 @@ } .mode-nav-dropdown.has-active .mode-nav-dropdown-btn { - background: var(--accent-cyan); - color: var(--bg-primary); + background: var(--bg-elevated); + color: var(--text-primary); border-color: var(--accent-cyan); + box-shadow: inset 0 -2px 0 var(--accent-cyan); } .mode-nav-dropdown.has-active .mode-nav-dropdown-btn .nav-icon { - filter: brightness(0); + color: var(--accent-cyan); } .mode-nav-dropdown-menu { @@ -852,16 +851,17 @@ left: 0; margin-top: 4px; min-width: 180px; - background: var(--bg-secondary); + background: var(--surface-glass); border: 1px solid var(--border-color); - border-radius: 6px; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-lg); opacity: 0; visibility: hidden; transform: translateY(-8px); - transition: all 0.15s ease; - z-index: 1000; + transition: all var(--transition-fast); + z-index: var(--z-dropdown); padding: 6px; + backdrop-filter: blur(10px); } .mode-nav-dropdown.open .mode-nav-dropdown-menu { @@ -874,8 +874,7 @@ width: 100%; justify-content: flex-start; padding: 10px 12px; - border-radius: 4px; - margin: 0; + border-radius: var(--radius-lg); } .mode-nav-dropdown-menu .mode-nav-btn:hover { @@ -883,8 +882,18 @@ } .mode-nav-dropdown-menu .mode-nav-btn.active { - background: var(--accent-cyan); - color: var(--bg-primary); + background: var(--bg-elevated); + color: var(--text-primary); + box-shadow: inset 0 -2px 0 var(--accent-cyan); +} + +/* Focus-visible states for nav elements */ +.mode-nav-btn:focus-visible, +.mode-nav-dropdown-btn:focus-visible, +.nav-action-btn:focus-visible, +.nav-tool-btn:focus-visible { + outline: 2px solid var(--accent-cyan); + outline-offset: 2px; } /* Nav Bar Utilities (clock, theme, tools) */ @@ -941,15 +950,15 @@ width: 28px; height: 28px; min-width: 28px; - border-radius: 4px; - background: transparent; - border: 1px solid transparent; + border-radius: var(--radius-lg); + background: var(--bg-elevated); + border: 1px solid var(--border-color); color: var(--text-secondary); font-size: 14px; font-weight: bold; cursor: pointer; - transition: all 0.15s ease; - display: flex; + transition: all var(--transition-fast); + display: inline-flex; align-items: center; justify-content: center; position: relative; @@ -957,27 +966,36 @@ } .nav-tool-btn:hover { - background: var(--bg-elevated); - border-color: var(--border-color); + background: var(--bg-tertiary); + border-color: var(--accent-cyan); color: var(--accent-cyan); + box-shadow: var(--shadow-md); } +/* Nav tool button SVG sizing */ .nav-tool-btn svg { width: 14px; height: 14px; + stroke: currentColor; +} + +.nav-tool-btn .icon { + display: inline-flex; + align-items: center; + justify-content: center; } .nav-tool-btn .icon svg { width: 14px; height: 14px; + stroke: currentColor; } -/* Theme toggle icon states in nav bar */ +/* Theme toggle icon states */ .nav-tool-btn .icon-sun, .nav-tool-btn .icon-moon { position: absolute; transition: opacity 0.2s, transform 0.2s; - font-size: 14px; } .nav-tool-btn .icon-sun { @@ -1000,7 +1018,7 @@ transform: rotate(90deg); } -/* Effects toggle icon states */ +/* Effects/animations toggle icon states */ .nav-tool-btn .icon-effects-off { display: none; } @@ -1012,3 +1030,114 @@ [data-animations="off"] .nav-tool-btn .icon-effects-off { display: flex; } + +/* Dashboard Button in Nav */ +a.nav-dashboard-btn, +a.nav-dashboard-btn:link, +a.nav-dashboard-btn:visited { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: var(--radius-lg); + background: var(--bg-elevated); + border: 1px solid var(--border-color); + color: var(--text-secondary); + font-size: 11px; + font-weight: 500; + cursor: pointer; + transition: all var(--transition-fast); + white-space: nowrap; + text-decoration: none; +} + +a.nav-dashboard-btn:hover { + background: var(--bg-tertiary); + border-color: var(--accent-cyan); + color: var(--accent-cyan); + box-shadow: var(--shadow-md); +} + +.nav-dashboard-btn .icon { + width: 14px; + height: 14px; +} + +.nav-dashboard-btn .icon svg { + width: 100%; + height: 100%; + stroke: currentColor; +} + +.nav-dashboard-btn .nav-label { + font-family: var(--font-mono); + letter-spacing: 0.5px; +} + +/* ---- Light theme nav overrides ---- */ +[data-theme="light"] .mode-nav { + background: linear-gradient(180deg, rgba(240, 244, 250, 0.97) 0%, rgba(232, 238, 247, 0.95) 100%); +} + +[data-theme="light"] .mode-nav-btn:hover { + background: rgba(220, 230, 244, 0.8); +} + +[data-theme="light"] .mode-nav-btn.active { + background: rgba(220, 230, 244, 0.9); + color: var(--text-primary); +} + +[data-theme="light"] .mode-nav-dropdown-btn:hover, +[data-theme="light"] .mode-nav-dropdown.open .mode-nav-dropdown-btn, +[data-theme="light"] .mode-nav-dropdown.has-active .mode-nav-dropdown-btn { + background: rgba(220, 230, 244, 0.9); + color: var(--text-primary); +} + +[data-theme="light"] .mode-nav-dropdown-menu { + background: rgba(248, 250, 253, 0.99); + box-shadow: 0 16px 36px rgba(18, 40, 66, 0.15); +} + +[data-theme="light"] .mode-nav-dropdown-menu .mode-nav-btn:hover { + background: rgba(220, 230, 244, 0.85); +} + +[data-theme="light"] .mode-nav-dropdown-menu .mode-nav-btn.active { + background: rgba(220, 230, 244, 0.95); + color: var(--text-primary); +} + +[data-theme="light"] .nav-tool-btn { + background: rgba(235, 241, 250, 0.7); + border-color: var(--border-color); +} + +[data-theme="light"] .nav-tool-btn:hover { + background: rgba(220, 230, 244, 0.9); + box-shadow: var(--shadow-sm); +} + +[data-theme="light"] .nav-action-btn { + background: rgba(235, 241, 250, 0.85); + border-color: var(--border-color); +} + +[data-theme="light"] .nav-action-btn:hover { + background: rgba(220, 230, 244, 0.95); + box-shadow: var(--shadow-sm); +} + +[data-theme="light"] a.nav-dashboard-btn, +[data-theme="light"] a.nav-dashboard-btn:link, +[data-theme="light"] a.nav-dashboard-btn:visited { + background: rgba(235, 241, 250, 0.7); + border-color: var(--border-color); + color: var(--text-secondary); +} + +[data-theme="light"] a.nav-dashboard-btn:hover { + background: rgba(220, 230, 244, 0.9); + box-shadow: var(--shadow-sm); +} diff --git a/static/css/core/variables.css b/static/css/core/variables.css index d8edb1f..cd20962 100644 --- a/static/css/core/variables.css +++ b/static/css/core/variables.css @@ -31,8 +31,10 @@ --accent-cyan-dim: rgba(74, 163, 255, 0.16); --accent-cyan-hover: #6bb3ff; --accent-green: #38c180; + --accent-green-hover: #16a34a; --accent-green-dim: rgba(56, 193, 128, 0.18); --accent-red: #e25d5d; + --accent-red-hover: #dc2626; --accent-red-dim: rgba(226, 93, 93, 0.16); --accent-orange: #d6a85e; --accent-orange-dim: rgba(214, 168, 94, 0.16); @@ -96,7 +98,7 @@ TYPOGRAPHY ============================================ */ --font-sans: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif; - --font-mono: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif; + --font-mono: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', Consolas, monospace; /* Font sizes */ --text-xs: 10px; @@ -189,8 +191,10 @@ --accent-cyan-dim: rgba(31, 95, 168, 0.12); --accent-cyan-hover: #2c73bf; --accent-green: #1f8a57; + --accent-green-hover: #167a4a; --accent-green-dim: rgba(31, 138, 87, 0.12); --accent-red: #c74444; + --accent-red-hover: #b33a3a; --accent-red-dim: rgba(199, 68, 68, 0.12); --accent-orange: #b5863a; --accent-orange-dim: rgba(181, 134, 58, 0.12); diff --git a/static/css/global-nav.css b/static/css/global-nav.css deleted file mode 100644 index b6153c9..0000000 --- a/static/css/global-nav.css +++ /dev/null @@ -1,507 +0,0 @@ -/* ============================================ - Global Navigation Styles - Shared across all pages using nav.html - ============================================ */ - -/* Icon base (kept lightweight for nav usage) */ -.icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 18px; - height: 18px; - flex-shrink: 0; -} - -.icon svg { - width: 100%; - height: 100%; -} - -.icon--sm { - width: 14px; - height: 14px; -} - -/* Mode Navigation Bar */ -.mode-nav { - display: none; - background: linear-gradient(180deg, rgba(17, 22, 32, 0.92), rgba(15, 20, 28, 0.88)); - border-bottom: 1px solid var(--border-color, #202833); - padding: 0 20px; - position: relative; - z-index: 1100; - backdrop-filter: blur(10px); -} - -@media (min-width: 1024px) { - .mode-nav { - display: flex; - align-items: center; - gap: 8px; - height: 44px; - } -} - -.mode-nav-label { - font-size: 9px; - color: var(--text-secondary, #b7c1cf); - text-transform: uppercase; - letter-spacing: 1px; - margin-right: 8px; - font-weight: 500; - font-family: var(--font-mono); -} - -.mode-nav-divider { - width: 1px; - height: 24px; - background: var(--border-color, #202833); - margin: 0 12px; -} - -.mode-nav-btn { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 8px 14px; - background: transparent; - border: 1px solid transparent; - border-radius: 6px; - color: var(--text-secondary, #b7c1cf); - font-family: var(--font-sans); - font-size: 11px; - font-weight: 500; - cursor: pointer; - transition: all 0.15s ease; - text-decoration: none; -} - -.mode-nav-btn .nav-label { - text-transform: uppercase; - letter-spacing: 0.08em; - font-family: var(--font-mono); - font-size: 10px; -} - -.mode-nav-btn:hover { - background: rgba(27, 36, 51, 0.8); - color: var(--text-primary, #e7ebf2); - border-color: var(--border-color, #202833); -} - -.mode-nav-btn.active { - background: rgba(27, 36, 51, 0.9); - color: var(--text-primary, #e7ebf2); - border-color: var(--accent-cyan, #4d7dbf); - box-shadow: inset 0 -2px 0 var(--accent-cyan, #4d7dbf); -} - -.mode-nav-btn.active .nav-icon { - color: var(--accent-cyan, #4d7dbf); -} - -.mode-nav-actions { - display: flex; - align-items: center; - gap: 16px; -} - -.nav-action-btn { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 8px 14px; - background: rgba(24, 31, 44, 0.85); - border: 1px solid var(--border-light, #2b3645); - border-radius: 6px; - color: var(--text-primary, #e7ebf2); - font-family: var(--font-sans); - font-size: 11px; - font-weight: 500; - text-decoration: none; - cursor: pointer; - transition: all 0.15s ease; -} - -.nav-action-btn .nav-label { - text-transform: uppercase; - letter-spacing: 0.08em; - font-family: var(--font-mono); - font-size: 10px; -} - -.nav-action-btn:hover { - background: rgba(27, 36, 51, 0.95); - color: var(--text-primary, #e7ebf2); - box-shadow: 0 8px 16px rgba(5, 9, 15, 0.35); - border-color: var(--accent-cyan, #4d7dbf); -} - -/* Dropdown Navigation */ -.mode-nav-dropdown { - position: relative; -} - -.mode-nav-dropdown-btn { - display: inline-flex; - align-items: center; - gap: 8px; - padding: 8px 14px; - background: transparent; - border: 1px solid transparent; - border-radius: 6px; - color: var(--text-secondary, #b7c1cf); - font-family: var(--font-sans); - font-size: 11px; - font-weight: 500; - cursor: pointer; - transition: all 0.15s ease; -} - -.mode-nav-dropdown-btn .nav-label { - text-transform: uppercase; - letter-spacing: 0.08em; - font-family: var(--font-mono); - font-size: 10px; -} - -.mode-nav-dropdown-btn .dropdown-arrow { - width: 12px; - height: 12px; - margin-left: 4px; - transition: transform 0.2s ease; - display: inline-flex; - align-items: center; - justify-content: center; -} - -.mode-nav-dropdown-btn .dropdown-arrow svg { - width: 100%; - height: 100%; -} - -.mode-nav-dropdown-btn:hover { - background: rgba(27, 36, 51, 0.8); - color: var(--text-primary, #e7ebf2); - border-color: var(--border-color, #202833); -} - -.mode-nav-dropdown.open .mode-nav-dropdown-btn { - background: rgba(27, 36, 51, 0.9); - color: var(--text-primary, #e7ebf2); - border-color: var(--border-color, #202833); -} - -.mode-nav-dropdown.open .dropdown-arrow { - transform: rotate(180deg); -} - -.mode-nav-dropdown.has-active .mode-nav-dropdown-btn { - background: rgba(27, 36, 51, 0.9); - color: var(--text-primary, #e7ebf2); - border-color: var(--accent-cyan, #4d7dbf); - box-shadow: inset 0 -2px 0 var(--accent-cyan, #4d7dbf); -} - -.mode-nav-dropdown.has-active .mode-nav-dropdown-btn .nav-icon { - color: var(--accent-cyan, #4d7dbf); -} - -.mode-nav-dropdown-menu { - position: absolute; - top: 100%; - left: 0; - margin-top: 4px; - min-width: 180px; - background: rgba(16, 22, 32, 0.98); - border: 1px solid var(--border-color, #202833); - border-radius: 8px; - box-shadow: 0 16px 36px rgba(5, 9, 15, 0.55); - opacity: 0; - visibility: hidden; - transform: translateY(-8px); - transition: all 0.15s ease; - z-index: 1000; - padding: 6px; -} - -.mode-nav-dropdown.open .mode-nav-dropdown-menu { - opacity: 1; - visibility: visible; - transform: translateY(0); -} - -.mode-nav-dropdown-menu .mode-nav-btn { - width: 100%; - justify-content: flex-start; - padding: 10px 12px; - border-radius: 6px; -} - -.mode-nav-dropdown-menu .mode-nav-btn:hover { - background: rgba(27, 36, 51, 0.85); -} - -.mode-nav-dropdown-menu .mode-nav-btn.active { - background: rgba(27, 36, 51, 0.95); - color: var(--text-primary, #e7ebf2); - box-shadow: inset 0 -2px 0 var(--accent-cyan, #4d7dbf); -} - -/* Nav Bar Utilities */ -.nav-utilities { - display: none; - align-items: center; - gap: 12px; - margin-left: auto; - flex-shrink: 0; -} - -@media (min-width: 1024px) { - .nav-utilities { - display: flex; - } -} - -.nav-clock { - display: flex; - align-items: center; - gap: 6px; - font-family: var(--font-mono); - font-size: 11px; - flex-shrink: 0; - white-space: nowrap; -} - -.nav-clock .utc-label { - font-size: 9px; - color: var(--text-dim, #8a97a8); - text-transform: uppercase; - letter-spacing: 1px; -} - -.nav-clock .utc-time { - color: var(--accent-cyan, #4d7dbf); - font-weight: 600; -} - -.nav-divider { - width: 1px; - height: 20px; - background: var(--border-color, #202833); -} - -.nav-tools { - display: flex; - align-items: center; - gap: 6px; - flex-shrink: 0; -} - -.nav-tool-btn { - width: 28px; - height: 28px; - min-width: 28px; - border-radius: 6px; - background: rgba(20, 33, 53, 0.6); - border: 1px solid rgba(77, 125, 191, 0.12); - color: var(--text-secondary, #b7c1cf); - font-size: 14px; - font-weight: bold; - cursor: pointer; - transition: all 0.15s ease; - display: inline-flex; - align-items: center; - justify-content: center; -} - -.nav-tool-btn:hover { - background: rgba(27, 36, 51, 0.9); - border-color: var(--accent-cyan, #4d7dbf); - color: var(--accent-cyan, #4d7dbf); - box-shadow: 0 6px 14px rgba(5, 9, 15, 0.35); -} - -/* Position relative needed for absolute positioned icon children */ -.nav-tool-btn { - position: relative; -} - -.mode-nav-btn:focus-visible, -.mode-nav-dropdown-btn:focus-visible, -.nav-action-btn:focus-visible, -.nav-tool-btn:focus-visible { - outline: 2px solid var(--accent-cyan, #4d7dbf); - outline-offset: 2px; -} - -/* Nav tool button SVG sizing and styling */ -.nav-tool-btn svg { - width: 14px; - height: 14px; - stroke: currentColor; -} - -.nav-tool-btn .icon { - display: inline-flex; - align-items: center; - justify-content: center; -} - -.nav-tool-btn .icon svg { - width: 14px; - height: 14px; - stroke: currentColor; -} - -/* Theme toggle icon states */ -.nav-tool-btn .icon-sun, -.nav-tool-btn .icon-moon { - position: absolute; - transition: opacity 0.2s, transform 0.2s; -} - -.nav-tool-btn .icon-sun { - opacity: 0; - transform: rotate(-90deg); -} - -.nav-tool-btn .icon-moon { - opacity: 1; - transform: rotate(0deg); -} - -[data-theme="light"] .nav-tool-btn .icon-sun { - opacity: 1; - transform: rotate(0deg); -} - -[data-theme="light"] .nav-tool-btn .icon-moon { - opacity: 0; - transform: rotate(90deg); -} - -/* ---- Light theme overrides ---- */ -[data-theme="light"] .mode-nav { - background: linear-gradient(180deg, rgba(240, 244, 250, 0.97) 0%, rgba(232, 238, 247, 0.95) 100%); -} - -[data-theme="light"] .mode-nav-btn:hover { - background: rgba(220, 230, 244, 0.8); -} - -[data-theme="light"] .mode-nav-btn.active { - background: rgba(220, 230, 244, 0.9); - color: var(--text-primary); -} - -[data-theme="light"] .mode-nav-dropdown-btn:hover, -[data-theme="light"] .mode-nav-dropdown.open .mode-nav-dropdown-btn, -[data-theme="light"] .mode-nav-dropdown.has-active .mode-nav-dropdown-btn { - background: rgba(220, 230, 244, 0.9); - color: var(--text-primary); -} - -[data-theme="light"] .mode-nav-dropdown-menu { - background: rgba(248, 250, 253, 0.99); - box-shadow: 0 16px 36px rgba(18, 40, 66, 0.15); -} - -[data-theme="light"] .mode-nav-dropdown-menu .mode-nav-btn:hover { - background: rgba(220, 230, 244, 0.85); -} - -[data-theme="light"] .mode-nav-dropdown-menu .mode-nav-btn.active { - background: rgba(220, 230, 244, 0.95); - color: var(--text-primary); -} - -[data-theme="light"] .nav-tool-btn { - background: rgba(235, 241, 250, 0.7); - border-color: rgba(31, 95, 168, 0.12); -} - -[data-theme="light"] .nav-tool-btn:hover { - background: rgba(220, 230, 244, 0.9); - box-shadow: 0 4px 10px rgba(18, 40, 66, 0.1); -} - -[data-theme="light"] .nav-action-btn { - background: rgba(235, 241, 250, 0.85); - border-color: rgba(31, 95, 168, 0.14); -} - -[data-theme="light"] .nav-action-btn:hover { - background: rgba(220, 230, 244, 0.95); - box-shadow: 0 6px 14px rgba(18, 40, 66, 0.1); -} - -[data-theme="light"] a.nav-dashboard-btn, -[data-theme="light"] a.nav-dashboard-btn:link, -[data-theme="light"] a.nav-dashboard-btn:visited { - background: rgba(235, 241, 250, 0.7) !important; - border-color: rgba(31, 95, 168, 0.12) !important; - color: var(--text-secondary) !important; -} - -[data-theme="light"] a.nav-dashboard-btn:hover { - background: rgba(220, 230, 244, 0.9) !important; - box-shadow: 0 4px 10px rgba(18, 40, 66, 0.1); -} - -/* Effects/animations toggle icon states */ -.nav-tool-btn .icon-effects-off { - display: none; -} - -[data-animations="off"] .nav-tool-btn .icon-effects-on { - display: none; -} - -[data-animations="off"] .nav-tool-btn .icon-effects-off { - display: flex; -} - -/* Main Dashboard Button in Nav */ -a.nav-dashboard-btn, -a.nav-dashboard-btn:link, -a.nav-dashboard-btn:visited { - display: inline-flex !important; - align-items: center; - gap: 6px; - padding: 6px 12px; - border-radius: 6px; - background: rgba(20, 33, 53, 0.6) !important; - border: 1px solid rgba(77, 125, 191, 0.12) !important; - color: #b7c1cf !important; - font-size: 11px; - font-weight: 500; - cursor: pointer; - transition: all 0.15s ease; - white-space: nowrap; - text-decoration: none !important; -} - -a.nav-dashboard-btn:hover { - background: rgba(27, 36, 51, 0.9) !important; - border-color: #4d7dbf !important; - color: #4d7dbf !important; - box-shadow: 0 6px 14px rgba(5, 9, 15, 0.35); -} - -.nav-dashboard-btn .icon { - width: 14px; - height: 14px; -} - -.nav-dashboard-btn .icon svg { - width: 100%; - height: 100%; - stroke: currentColor; -} - -.nav-dashboard-btn .nav-label { - font-family: var(--font-mono, 'Roboto Condensed', 'Arial Narrow', sans-serif); - letter-spacing: 0.5px; -} diff --git a/static/css/index.css b/static/css/index.css index 1ec67db..e5580af 100644 --- a/static/css/index.css +++ b/static/css/index.css @@ -2739,7 +2739,7 @@ header h1 .tagline { gap: 15px; } -@media (max-width: 1100px) { +@media (max-width: 1023px) { .pass-predictor { grid-template-columns: 1fr; } @@ -4090,13 +4090,13 @@ header h1 .tagline { } /* WiFi Responsive */ -@media (max-width: 1400px) { +@media (max-width: 1280px) { .wifi-main-content { grid-template-columns: minmax(280px, 1fr) 240px 240px; } } -@media (max-width: 1200px) { +@media (max-width: 1280px) { .wifi-layout-container { flex: 1; min-height: 0; @@ -5415,7 +5415,7 @@ header h1 .tagline { background: var(--bg-secondary, #1a1a2e); } -@media (max-width: 1200px) { +@media (max-width: 1280px) { .bt-layout-container { flex-direction: column; flex: 1; diff --git a/static/css/modes/bt_locate.css b/static/css/modes/bt_locate.css index 236cca6..f040892 100644 --- a/static/css/modes/bt_locate.css +++ b/static/css/modes/bt_locate.css @@ -513,7 +513,7 @@ RESPONSIVE — stack HUD vertically on narrow ============================================ */ -@media (max-width: 900px) { +@media (max-width: 1023px) { .btl-hud { flex-wrap: wrap; gap: 10px; diff --git a/static/css/modes/meshtastic.css b/static/css/modes/meshtastic.css index 25bb1ec..c67c6d8 100644 --- a/static/css/modes/meshtastic.css +++ b/static/css/modes/meshtastic.css @@ -1378,7 +1378,7 @@ } /* Responsive traceroute path */ -@media (max-width: 600px) { +@media (max-width: 480px) { .mesh-traceroute-path { flex-direction: column; } diff --git a/static/css/modes/meteor.css b/static/css/modes/meteor.css index 29955ac..5cccc0b 100644 --- a/static/css/modes/meteor.css +++ b/static/css/modes/meteor.css @@ -451,7 +451,7 @@ /* ── Responsive ── */ -@media (max-width: 900px) { +@media (max-width: 1023px) { .ms-stats-strip { grid-template-columns: repeat(3, 1fr); } @@ -460,7 +460,7 @@ } } -@media (max-width: 600px) { +@media (max-width: 480px) { .ms-stats-strip { grid-template-columns: repeat(2, 1fr); } diff --git a/static/css/modes/ook.css b/static/css/modes/ook.css index beb0489..6ae08d3 100644 --- a/static/css/modes/ook.css +++ b/static/css/modes/ook.css @@ -67,7 +67,7 @@ .ook-warning { font-size: 11px; - color: #ffaa00; + color: var(--accent-orange); line-height: 1.5; } diff --git a/static/css/modes/radiosonde.css b/static/css/modes/radiosonde.css index f254582..a27222e 100644 --- a/static/css/modes/radiosonde.css +++ b/static/css/modes/radiosonde.css @@ -221,7 +221,7 @@ } /* Responsive: stack cards on narrow screens */ -@media (max-width: 600px) { +@media (max-width: 480px) { .radiosonde-card { flex: 1 1 100%; max-width: 100%; diff --git a/static/css/modes/spy-stations.css b/static/css/modes/spy-stations.css index a7f4603..7285278 100644 --- a/static/css/modes/spy-stations.css +++ b/static/css/modes/spy-stations.css @@ -408,7 +408,7 @@ } /* Small tablet / large phone (640px) */ -@media (max-width: 640px) { +@media (max-width: 768px) { .spy-station-footer { flex-direction: column; gap: 8px; diff --git a/static/css/modes/subghz.css b/static/css/modes/subghz.css index 9de5a2e..bcb2847 100644 --- a/static/css/modes/subghz.css +++ b/static/css/modes/subghz.css @@ -1582,13 +1582,13 @@ gap: 12px; } -@media (max-width: 1200px) { +@media (max-width: 1280px) { .subghz-rx-info-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } -@media (max-width: 900px) { +@media (max-width: 1023px) { .subghz-decode-layout { grid-template-columns: 1fr; } diff --git a/static/css/modes/tscm.css b/static/css/modes/tscm.css index c268dd5..30234de 100644 --- a/static/css/modes/tscm.css +++ b/static/css/modes/tscm.css @@ -22,13 +22,13 @@ opacity: 0.7; margin-top: 2px; } -.threat-card.critical { border-color: #ff3366; color: #ff3366; } +.threat-card.critical { border-color: var(--severity-critical); color: var(--severity-critical); } .threat-card.critical.active { background: rgba(255,51,102,0.2); } -.threat-card.high { border-color: #ff9933; color: #ff9933; } +.threat-card.high { border-color: var(--severity-high); color: var(--severity-high); } .threat-card.high.active { background: rgba(255,153,51,0.2); } -.threat-card.medium { border-color: #ffcc00; color: #ffcc00; } +.threat-card.medium { border-color: var(--severity-medium); color: var(--severity-medium); } .threat-card.medium.active { background: rgba(255,204,0,0.2); } -.threat-card.low { border-color: #00ff88; color: #00ff88; } +.threat-card.low { border-color: var(--severity-low); color: var(--severity-low); } .threat-card.low.active { background: rgba(0,255,136,0.2); } /* TSCM Dashboard */ @@ -105,26 +105,26 @@ background: rgba(74,158,255,0.1); } .tscm-device-item.new { - border-left-color: #ff9933; + border-left-color: var(--severity-high); animation: pulse-glow 2s infinite; } .tscm-device-item.threat { - border-left-color: #ff3366; + border-left-color: var(--severity-critical); } .tscm-device-item.baseline { - border-left-color: #00ff88; + border-left-color: var(--neon-green); } /* Classification colors */ .tscm-device-item.classification-green { - border-left-color: #00cc00; + border-left-color: var(--accent-green); background: rgba(0, 204, 0, 0.1); } .tscm-device-item.classification-yellow { - border-left-color: #ffcc00; + border-left-color: var(--severity-medium); background: rgba(255, 204, 0, 0.1); } .tscm-device-item.classification-red { - border-left-color: #ff3333; + border-left-color: var(--accent-red); background: rgba(255, 51, 51, 0.15); animation: pulse-glow 2s infinite; } @@ -182,7 +182,7 @@ transition: all 0.2s; } .tscm-action-btn:hover { - background: #2ecc71; + background: var(--accent-green-hover); transform: translateY(-1px); } .tscm-device-reasons { @@ -202,7 +202,7 @@ padding: 1px 4px; border-radius: 3px; background: rgba(255, 51, 102, 0.2); - color: #ff3366; + color: var(--severity-critical); border: 1px solid rgba(255, 51, 102, 0.4); text-transform: uppercase; letter-spacing: 0.4px; @@ -213,7 +213,7 @@ padding: 1px 4px; border-radius: 3px; background: rgba(74, 158, 255, 0.2); - color: #4a9eff; + color: var(--accent-cyan); border: 1px solid rgba(74, 158, 255, 0.4); text-transform: uppercase; letter-spacing: 0.4px; @@ -224,7 +224,7 @@ padding: 1px 4px; border-radius: 3px; background: rgba(0, 255, 136, 0.2); - color: #00ff88; + color: var(--neon-green); border: 1px solid rgba(0, 255, 136, 0.4); text-transform: uppercase; letter-spacing: 0.4px; @@ -268,20 +268,20 @@ } .score-badge.score-low { background: rgba(0, 204, 0, 0.2); - color: #00cc00; + color: var(--accent-green); } .score-badge.score-medium { background: rgba(255, 204, 0, 0.2); - color: #ffcc00; + color: var(--severity-medium); } .score-badge.score-high { background: rgba(255, 51, 51, 0.2); - color: #ff3333; + color: var(--accent-red); } .tscm-action { margin-top: 4px; font-size: 10px; - color: #ff9933; + color: var(--severity-high); font-weight: 600; text-transform: uppercase; } @@ -290,12 +290,12 @@ padding: 12px; background: rgba(255, 153, 51, 0.1); border-radius: 6px; - border: 1px solid #ff9933; + border: 1px solid var(--severity-high); } .tscm-correlations h4 { margin: 0 0 8px 0; font-size: 12px; - color: #ff9933; + color: var(--severity-high); } .correlation-item { padding: 8px; @@ -332,9 +332,9 @@ 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; } +.summary-stat.high-interest .count { color: var(--accent-red); } +.summary-stat.needs-review .count { color: var(--severity-medium); } +.summary-stat.informational .count { color: var(--accent-green); } .tscm-assessment { padding: 10px 14px; margin: 12px 0; @@ -343,18 +343,18 @@ } .tscm-assessment.high-interest { background: rgba(255, 51, 51, 0.15); - border: 1px solid #ff3333; - color: #ff3333; + border: 1px solid var(--accent-red); + color: var(--accent-red); } .tscm-assessment.needs-review { background: rgba(255, 204, 0, 0.15); - border: 1px solid #ffcc00; - color: #ffcc00; + border: 1px solid var(--severity-medium); + color: var(--severity-medium); } .tscm-assessment.informational { background: rgba(0, 204, 0, 0.15); - border: 1px solid #00cc00; - color: #00cc00; + border: 1px solid var(--accent-green); + color: var(--accent-green); } .tscm-disclaimer { font-size: 10px; @@ -452,16 +452,16 @@ 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.high { border-color: var(--accent-red); background: rgba(255, 51, 51, 0.1); } +.score-circle.medium { border-color: var(--severity-medium); background: rgba(255, 204, 0, 0.1); } +.score-circle.low { border-color: var(--accent-green); 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.high .score-value { color: var(--accent-red); } +.score-circle.medium .score-value { color: var(--severity-medium); } +.score-circle.low .score-value { color: var(--accent-green); } .score-circle .score-label { font-size: 8px; color: var(--text-muted); @@ -521,7 +521,7 @@ } .indicator-type { background: rgba(255, 153, 51, 0.2); - color: #ff9933; + color: var(--severity-high); padding: 2px 6px; border-radius: 3px; font-size: 10px; @@ -550,7 +550,7 @@ .tscm-threat-action { margin-top: 6px; font-size: 10px; - color: #ff9933; + color: var(--severity-high); text-transform: uppercase; font-weight: 600; } @@ -606,7 +606,7 @@ font-size: 9px; padding: 2px 6px; background: rgba(74, 158, 255, 0.2); - color: #4a9eff; + color: var(--accent-cyan); border-radius: 3px; text-transform: uppercase; } @@ -614,7 +614,7 @@ font-size: 9px; padding: 2px 6px; background: rgba(255, 153, 51, 0.2); - color: #ff9933; + color: var(--severity-high); border-radius: 3px; } .correlation-detail-item { @@ -634,10 +634,10 @@ 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-item.critical { border-color: var(--severity-critical); background: rgba(255,51,102,0.1); } +.tscm-threat-item.high { border-color: var(--severity-high); background: rgba(255,153,51,0.1); } +.tscm-threat-item.medium { border-color: var(--severity-medium); background: rgba(255,204,0,0.1); } +.tscm-threat-item.low { border-color: var(--severity-low); background: rgba(0,255,136,0.1); } .tscm-threat-header { display: flex; justify-content: space-between; @@ -807,7 +807,7 @@ .meeting-pulse { width: 10px; height: 10px; - background: #ff3366; + background: var(--severity-critical); border-radius: 50%; animation: pulse-dot 1.5s ease-in-out infinite; } @@ -819,7 +819,7 @@ font-size: 11px; font-weight: 700; letter-spacing: 1px; - color: #ff3366; + color: var(--severity-critical); text-transform: uppercase; } .meeting-info { @@ -865,15 +865,15 @@ font-size: 10px; text-transform: uppercase; } -.cap-status.available { color: #00cc00; } -.cap-status.limited { color: #ffcc00; } -.cap-status.unavailable { color: #ff3333; } +.cap-status.available { color: var(--accent-green); } +.cap-status.limited { color: var(--severity-medium); } +.cap-status.unavailable { color: var(--accent-red); } .cap-limitations { margin-left: auto; display: flex; align-items: center; gap: 4px; - color: #ff9933; + color: var(--severity-high); font-size: 10px; } .cap-warn { @@ -907,15 +907,15 @@ } .health-badge.healthy { background: rgba(0, 204, 0, 0.2); - color: #00cc00; + color: var(--accent-green); } .health-badge.noisy { background: rgba(255, 204, 0, 0.2); - color: #ffcc00; + color: var(--severity-medium); } .health-badge.stale { background: rgba(255, 51, 51, 0.2); - color: #ff3333; + color: var(--accent-red); } .health-age { color: var(--text-muted); @@ -998,9 +998,9 @@ 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-item.available { border-left-color: var(--accent-green); } +.cap-detail-item.limited { border-left-color: var(--severity-medium); } +.cap-detail-item.unavailable { border-left-color: var(--accent-red); } .cap-detail-header { display: flex; justify-content: space-between; @@ -1016,9 +1016,9 @@ 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-status.available { background: rgba(0, 204, 0, 0.2); color: var(--accent-green); } +.cap-detail-status.limited { background: rgba(255, 204, 0, 0.2); color: var(--severity-medium); } +.cap-detail-status.unavailable { background: rgba(255, 51, 51, 0.2); color: var(--accent-red); } .cap-detail-limits { font-size: 10px; color: var(--text-muted); @@ -1034,7 +1034,7 @@ margin-bottom: 6px; background: rgba(0, 0, 0, 0.2); border-radius: 4px; - border-left: 3px solid #00cc00; + border-left: 3px solid var(--accent-green); display: flex; justify-content: space-between; align-items: center; @@ -1064,7 +1064,7 @@ } .known-device-btn.remove { background: rgba(255, 51, 51, 0.2); - color: #ff3333; + color: var(--accent-red); } .known-device-btn.remove:hover { background: rgba(255, 51, 51, 0.4); @@ -1083,9 +1083,9 @@ .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-item.priority-high { border-left-color: var(--accent-red); } +.case-item.priority-normal { border-left-color: var(--accent-cyan); } +.case-item.priority-low { border-left-color: var(--accent-green); } .case-header { display: flex; justify-content: space-between; @@ -1102,8 +1102,8 @@ 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-status.open { background: rgba(0, 204, 0, 0.2); color: var(--accent-green); } +.case-status.closed { background: rgba(128, 128, 128, 0.2); color: var(--text-secondary); } .case-meta { font-size: 10px; color: var(--text-muted); @@ -1117,7 +1117,7 @@ margin-bottom: 8px; background: rgba(0, 0, 0, 0.2); border-radius: 6px; - border-left: 3px solid #ff9933; + border-left: 3px solid var(--severity-high); } .playbook-header { display: flex; @@ -1135,9 +1135,9 @@ 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-risk.high_interest { background: rgba(255, 51, 51, 0.2); color: var(--accent-red); } +.playbook-risk.needs_review { background: rgba(255, 204, 0, 0.2); color: var(--severity-medium); } +.playbook-risk.informational { background: rgba(0, 204, 0, 0.2); color: var(--accent-green); } .playbook-desc { font-size: 11px; color: var(--text-secondary); @@ -1153,7 +1153,7 @@ border-radius: 3px; } .playbook-step-num { - color: #ff9933; + color: var(--severity-high); font-weight: 600; margin-right: 6px; } @@ -1223,19 +1223,19 @@ } .proximity-badge.very_close { background: rgba(255, 51, 51, 0.2); - color: #ff3333; + color: var(--accent-red); } .proximity-badge.close { background: rgba(255, 153, 51, 0.2); - color: #ff9933; + color: var(--severity-high); } .proximity-badge.moderate { background: rgba(255, 204, 0, 0.2); - color: #ffcc00; + color: var(--severity-medium); } .proximity-badge.far { background: rgba(0, 204, 0, 0.2); - color: #00cc00; + color: var(--accent-green); } /* Add to Known Device Button */ @@ -1243,7 +1243,7 @@ padding: 4px 8px; font-size: 10px; background: rgba(0, 204, 0, 0.2); - color: #00cc00; + color: var(--accent-green); border: 1px solid rgba(0, 204, 0, 0.3); border-radius: 3px; cursor: pointer; @@ -1307,15 +1307,15 @@ /* 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; + border-bottom: 2px solid var(--accent-cyan); } .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; + border-bottom: 2px solid var(--severity-high); } .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; + border-bottom: 2px solid var(--accent-green); } /* Playbook Enhancements */ @@ -1330,7 +1330,7 @@ font-size: 9px; padding: 2px 6px; background: rgba(255, 153, 51, 0.2); - color: #ff9933; + color: var(--severity-high); border-radius: 3px; text-transform: uppercase; } @@ -1360,7 +1360,7 @@ font-size: 9px; padding: 2px 6px; background: rgba(74, 158, 255, 0.2); - color: #4a9eff; + color: var(--accent-cyan); border-radius: 3px; margin-left: 8px; } @@ -1404,7 +1404,7 @@ /* Recording State */ .icon-recording { - color: #ff3366; + color: var(--severity-critical); } .icon-recording.active svg { @@ -1418,11 +1418,11 @@ /* Anomaly Indicator */ .icon-anomaly { - color: #ff9933; + color: var(--severity-high); } .icon-anomaly.critical { - color: #ff3366; + color: var(--severity-critical); } /* Export Icon */ @@ -1508,7 +1508,7 @@ } .recording-status.active { - color: #ff3366; + color: var(--severity-critical); font-weight: 600; } @@ -1526,12 +1526,12 @@ .anomaly-flag.needs-review { background: rgba(255, 153, 51, 0.2); - color: #ff9933; + color: var(--severity-high); } .anomaly-flag.high-interest { background: rgba(255, 51, 51, 0.2); - color: #ff3333; + color: var(--accent-red); } .anomaly-flag .icon { @@ -1639,7 +1639,7 @@ } .tscm-summary-risk { font-size: 10px; - color: #ff9933; + color: var(--severity-high); margin-top: 4px; } diff --git a/static/css/modes/waterfall.css b/static/css/modes/waterfall.css index d1ab8fb..255e1a5 100644 --- a/static/css/modes/waterfall.css +++ b/static/css/modes/waterfall.css @@ -763,7 +763,7 @@ border: 1px solid rgba(74, 163, 255, 0.22); } -@media (max-width: 1100px) { +@media (max-width: 1023px) { .wf-monitor-strip { grid-template-columns: repeat(2, minmax(220px, 1fr)); grid-auto-rows: minmax(70px, auto); @@ -778,7 +778,7 @@ } } -@media (max-width: 720px) { +@media (max-width: 768px) { .wf-headline { flex-direction: column; align-items: flex-start; diff --git a/static/css/modes/weather-satellite.css b/static/css/modes/weather-satellite.css index 0aceeed..28438e1 100644 --- a/static/css/modes/weather-satellite.css +++ b/static/css/modes/weather-satellite.css @@ -32,12 +32,12 @@ } .wxsat-strip-dot.capturing { - background: #00ff88; + background: var(--neon-green); animation: wxsat-pulse 1.5s ease-in-out infinite; } .wxsat-strip-dot.decoding { - background: #00d4ff; + background: var(--accent-cyan); animation: wxsat-pulse 0.8s ease-in-out infinite; } @@ -70,8 +70,8 @@ } .wxsat-strip-btn.stop { - border-color: #ff4444; - color: #ff4444; + border-color: var(--accent-red); + color: var(--accent-red); } .wxsat-strip-btn.stop:hover { @@ -124,7 +124,7 @@ width: 14px; height: 14px; cursor: pointer; - accent-color: #00ff88; + accent-color: var(--neon-green); } .wxsat-schedule-toggle input:checked + .wxsat-toggle-label { @@ -207,12 +207,12 @@ } .wxsat-countdown-box.imminent { - border-color: #ffbb00; + border-color: var(--accent-yellow); box-shadow: 0 0 8px rgba(255, 187, 0, 0.2); } .wxsat-countdown-box.active { - border-color: #00ff88; + border-color: var(--neon-green); box-shadow: 0 0 8px rgba(0, 255, 136, 0.3); animation: wxsat-glow 1.5s ease-in-out infinite; } @@ -293,14 +293,14 @@ .wxsat-timeline-pass.apt { background: rgba(0, 212, 255, 0.6); } .wxsat-timeline-pass.lrpt { background: rgba(0, 255, 136, 0.6); } -.wxsat-timeline-pass.scheduled { border: 1px solid #ffbb00; } +.wxsat-timeline-pass.scheduled { border: 1px solid var(--accent-yellow); } .wxsat-timeline-cursor { position: absolute; top: 2px; width: 2px; height: 20px; - background: #ff4444; + background: var(--accent-red); border-radius: 1px; z-index: 2; } @@ -375,7 +375,7 @@ .wxsat-pass-card.active, .wxsat-pass-card.selected { - border-color: #00ff88; + border-color: var(--neon-green); background: rgba(0, 255, 136, 0.05); } @@ -385,7 +385,7 @@ padding: 1px 4px; border-radius: 2px; background: rgba(255, 187, 0, 0.15); - color: #ffbb00; + color: var(--accent-yellow); margin-left: 6px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; text-transform: uppercase; @@ -414,12 +414,12 @@ .wxsat-pass-mode.apt { background: rgba(0, 212, 255, 0.15); - color: #00d4ff; + color: var(--accent-cyan); } .wxsat-pass-mode.lrpt { background: rgba(0, 255, 136, 0.15); - color: #00ff88; + color: var(--neon-green); } .wxsat-pass-details { @@ -450,17 +450,17 @@ .wxsat-pass-quality.excellent { background: rgba(0, 255, 136, 0.15); - color: #00ff88; + color: var(--neon-green); } .wxsat-pass-quality.good { background: rgba(0, 212, 255, 0.15); - color: #00d4ff; + color: var(--accent-cyan); } .wxsat-pass-quality.fair { background: rgba(255, 187, 0, 0.15); - color: #ffbb00; + color: var(--accent-yellow); } /* ===== Center Panel (Polar + Map) ===== */ @@ -900,7 +900,7 @@ .wxsat-modal-btn.delete:hover { background: rgba(255, 68, 68, 0.9); - border-color: #ff4444; + border-color: var(--accent-red); color: var(--text-inverse); } @@ -920,12 +920,12 @@ } .wxsat-gallery-clear-btn:hover { - color: #ff4444; + color: var(--accent-red); background: rgba(255, 68, 68, 0.1); } /* ===== Responsive ===== */ -@media (max-width: 1100px) { +@media (max-width: 1023px) { .wxsat-content { flex-direction: column; } @@ -1041,8 +1041,8 @@ } .wxsat-phase-step.active { - color: #00ff88; - border-color: #00ff88; + color: var(--neon-green); + border-color: var(--neon-green); background: rgba(0, 255, 136, 0.1); box-shadow: 0 0 8px rgba(0, 255, 136, 0.2); } @@ -1055,8 +1055,8 @@ } .wxsat-phase-step.error { - color: #ff4444; - border-color: #ff4444; + color: var(--accent-red); + border-color: var(--accent-red); background: rgba(255, 68, 68, 0.1); box-shadow: 0 0 8px rgba(255, 68, 68, 0.2); } @@ -1115,8 +1115,8 @@ } .wxsat-console-entry.wxsat-log-signal { - border-left-color: #00ff88; - color: #00ff88; + border-left-color: var(--neon-green); + color: var(--neon-green); } .wxsat-console-entry.wxsat-log-progress { @@ -1125,18 +1125,18 @@ } .wxsat-console-entry.wxsat-log-save { - border-left-color: #ffbb00; - color: #ffbb00; + border-left-color: var(--accent-yellow); + color: var(--accent-yellow); } .wxsat-console-entry.wxsat-log-error { - border-left-color: #ff4444; - color: #ff4444; + border-left-color: var(--accent-red); + color: var(--accent-red); } .wxsat-console-entry.wxsat-log-warning { - border-left-color: #ff8800; - color: #ff8800; + border-left-color: var(--neon-orange); + color: var(--neon-orange); } .wxsat-console-entry.wxsat-log-debug { diff --git a/static/css/modes/wefax.css b/static/css/modes/wefax.css index 46dba06..6dbbbb9 100644 --- a/static/css/modes/wefax.css +++ b/static/css/modes/wefax.css @@ -41,15 +41,15 @@ width: 8px; height: 8px; border-radius: 50%; - background: #444; + background: var(--text-muted); flex-shrink: 0; } -.wefax-strip-dot.scanning { background: #ffaa00; animation: wefax-pulse 1.5s ease-in-out infinite; } -.wefax-strip-dot.phasing { background: #ffcc44; animation: wefax-pulse 0.8s ease-in-out infinite; } -.wefax-strip-dot.receiving { background: #00cc66; animation: wefax-pulse 1s ease-in-out infinite; } -.wefax-strip-dot.complete { background: #00cc66; } -.wefax-strip-dot.error { background: #f44; } +.wefax-strip-dot.scanning { background: var(--accent-orange); animation: wefax-pulse 1.5s ease-in-out infinite; } +.wefax-strip-dot.phasing { background: var(--accent-yellow); animation: wefax-pulse 0.8s ease-in-out infinite; } +.wefax-strip-dot.receiving { background: var(--accent-green); animation: wefax-pulse 1s ease-in-out infinite; } +.wefax-strip-dot.complete { background: var(--accent-green); } +.wefax-strip-dot.error { background: var(--accent-red); } @keyframes wefax-pulse { 0%, 100% { opacity: 1; } @@ -81,17 +81,17 @@ transition: all 0.15s ease; } -.wefax-strip-btn.start { color: #ffaa00; border-color: #ffaa0044; } -.wefax-strip-btn.start:hover { background: #ffaa0015; border-color: #ffaa00; } +.wefax-strip-btn.start { color: var(--accent-orange); border-color: #ffaa0044; } +.wefax-strip-btn.start:hover { background: #ffaa0015; border-color: var(--accent-orange); } .wefax-strip-btn.start.wefax-strip-btn-error { - border-color: #ffaa00; - color: #ffaa00; + border-color: var(--accent-orange); + color: var(--accent-orange); box-shadow: 0 0 8px rgba(255, 170, 0, 0.3); animation: wefax-pulse 0.6s ease-in-out 3; } -.wefax-strip-btn.stop { color: #f44; border-color: #f4444444; } -.wefax-strip-btn.stop:hover { background: #f4441a; border-color: #f44; } +.wefax-strip-btn.stop { color: var(--accent-red); border-color: #f4444444; } +.wefax-strip-btn.stop:hover { background: #f4441a; border-color: var(--accent-red); } .wefax-strip-divider { width: 1px; @@ -114,7 +114,7 @@ font-variant-numeric: tabular-nums; } -.wefax-strip-value.accent-amber { color: #ffaa00; } +.wefax-strip-value.accent-amber { color: var(--accent-orange); } .wefax-strip-label { font-family: var(--font-mono, monospace); @@ -141,11 +141,11 @@ width: 14px; height: 14px; cursor: pointer; - accent-color: #ffaa00; + accent-color: var(--accent-orange); } .wefax-schedule-toggle input:checked + span { - color: #ffaa00; + color: var(--accent-orange); } /* --- Visuals Container --- */ @@ -185,7 +185,7 @@ font-size: 11px; text-transform: uppercase; letter-spacing: 1px; - color: #ffaa00; + color: var(--accent-orange); } .wefax-schedule-list { @@ -209,7 +209,7 @@ .wefax-schedule-entry.active { background: #ffaa0010; - border-left: 3px solid #ffaa00; + border-left: 3px solid var(--accent-orange); } .wefax-schedule-entry.upcoming { @@ -221,7 +221,7 @@ } .wefax-schedule-time { - color: #ffaa00; + color: var(--accent-orange); min-width: 45px; font-variant-numeric: tabular-nums; } @@ -241,7 +241,7 @@ .wefax-schedule-badge.live { background: #ffaa0030; - color: #ffaa00; + color: var(--accent-orange); font-weight: 600; } @@ -279,7 +279,7 @@ font-size: 11px; text-transform: uppercase; letter-spacing: 1px; - color: #ffaa00; + color: var(--accent-orange); } .wefax-live-content { @@ -298,7 +298,7 @@ .wefax-idle-state svg { width: 48px; height: 48px; - color: #ffaa0033; + color: rgba(214, 168, 94, 0.2); margin-bottom: 12px; } @@ -341,7 +341,7 @@ font-size: 11px; text-transform: uppercase; letter-spacing: 1px; - color: #ffaa00; + color: var(--accent-orange); } .wefax-gallery-controls { @@ -370,8 +370,8 @@ } .wefax-gallery-clear-btn:hover { - border-color: #f44; - color: #f44; + border-color: var(--accent-red); + color: var(--accent-red); } .wefax-gallery-grid { @@ -442,7 +442,7 @@ border-radius: 3px; border: none; background: rgba(0, 0, 0, 0.7); - color: #ccc; + color: var(--text-secondary); font-size: 14px; cursor: pointer; display: flex; @@ -451,8 +451,8 @@ text-decoration: none; } -.wefax-gallery-action:hover { color: #fff; } -.wefax-gallery-action.delete:hover { color: #f44; } +.wefax-gallery-action:hover { color: var(--text-primary); } +.wefax-gallery-action.delete:hover { color: var(--accent-red); } /* --- Countdown Bar + Timeline --- */ .wefax-countdown-bar { @@ -490,12 +490,12 @@ } .wefax-countdown-box.imminent { - border-color: #ffaa00; + border-color: var(--accent-orange); box-shadow: 0 0 8px rgba(255, 170, 0, 0.2); } .wefax-countdown-box.active { - border-color: #ffaa00; + border-color: var(--accent-orange); box-shadow: 0 0 8px rgba(255, 170, 0, 0.3); animation: wefax-glow 1.5s ease-in-out infinite; } @@ -530,7 +530,7 @@ .wefax-countdown-content { font-size: 12px; font-weight: 600; - color: #ffaa00; + color: var(--accent-orange); font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; } @@ -576,7 +576,7 @@ .wefax-timeline-broadcast.active { background: rgba(255, 170, 0, 0.85); - border: 1px solid #ffaa00; + border: 1px solid var(--accent-orange); } .wefax-timeline-cursor { @@ -584,7 +584,7 @@ top: 2px; width: 2px; height: 20px; - background: #ff4444; + background: var(--accent-red); border-radius: 1px; z-index: 2; } diff --git a/static/css/modes/wifi_locate.css b/static/css/modes/wifi_locate.css index ebe7823..3cef334 100644 --- a/static/css/modes/wifi_locate.css +++ b/static/css/modes/wifi_locate.css @@ -361,7 +361,7 @@ RESPONSIVE ============================================ */ -@media (max-width: 900px) { +@media (max-width: 1023px) { .wfl-rssi-display { font-size: 48px; } diff --git a/static/css/responsive.css b/static/css/responsive.css index b94dd60..d45f4d1 100644 --- a/static/css/responsive.css +++ b/static/css/responsive.css @@ -424,30 +424,30 @@ /* ============== MOBILE LAYOUT FIXES ============== */ @media (max-width: 1023px) { /* Fix main content to allow scrolling on mobile */ - .main-content { - height: auto !important; + .app-shell .main-content { + height: auto; min-height: calc(100dvh - var(--header-height) - var(--nav-height)); - overflow-y: auto !important; + overflow-y: auto; overflow-x: hidden; -webkit-overflow-scrolling: touch; } - .sidebar { + .app-shell .sidebar { padding: 10px; gap: 10px; } - .output-panel { + .app-shell .output-panel { min-height: 58vh; } - .output-header { + .app-shell .output-header { flex-direction: column; align-items: flex-start; gap: 8px; } - .header-controls { + .app-shell .header-controls { width: 100%; gap: 8px; overflow-x: auto; @@ -455,20 +455,21 @@ padding-bottom: 2px; } - .header-controls .stats { + .app-shell .header-controls .stats { min-width: max-content; } /* Container should not clip content */ - .container { + .app-shell .container { overflow: visible; height: auto; min-height: 100dvh; } /* Layout containers need to stack vertically on mobile */ - .wifi-layout-container, - .bt-layout-container { + /* overrides inline style - JS sets display via style attribute */ + .app-shell .wifi-layout-container, + .app-shell .bt-layout-container { flex-direction: column !important; height: auto !important; max-height: none !important; @@ -478,126 +479,128 @@ } /* Visual panels should be scrollable, not clipped */ - .wifi-visuals, - .bt-visuals-column { - max-height: none !important; - overflow: visible !important; + .app-shell .wifi-visuals, + .app-shell .bt-visuals-column { + max-height: none; + overflow: visible; margin-bottom: 15px; } /* Device lists should have reasonable height on mobile */ - .wifi-device-list, - .bt-device-list { + .app-shell .wifi-device-list, + .app-shell .bt-device-list { max-height: 400px; overflow-y: auto; -webkit-overflow-scrolling: touch; } /* Visual panels should stack in single column on mobile when visible */ - .wifi-visuals, - .bt-visuals-column { + .app-shell .wifi-visuals, + .app-shell .bt-visuals-column { display: flex; flex-direction: column; gap: 10px; } - /* Only apply flex when aircraft visuals are shown (via JS setting display: grid) */ - #aircraftVisuals[style*="grid"] { - display: flex !important; - flex-direction: column !important; + /* Stack aircraft visuals vertically on mobile when active */ + #aircraftVisuals.active { + display: flex; + flex-direction: column; gap: 10px; } - /* APRS visuals - only when visible */ - #aprsVisuals[style*="flex"] { - flex-direction: column !important; + /* APRS visuals stack vertically on mobile */ + .app-shell #aprsVisuals { + flex-direction: column; } - .wifi-visual-panel { - grid-column: auto !important; + .app-shell .wifi-visual-panel { + grid-column: auto; } - .bt-main-area { - flex-direction: column !important; - min-height: auto !important; + .app-shell .bt-main-area { + flex-direction: column; + min-height: auto; } - .bt-side-panels { - width: 100% !important; - flex-direction: column !important; + .app-shell .bt-side-panels { + width: 100%; + flex-direction: column; } - .bt-detail-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)) !important; + .app-shell .bt-detail-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); } - .bt-row-secondary { - padding-left: 0 !important; - white-space: normal !important; + .app-shell .bt-row-secondary { + padding-left: 0; + white-space: normal; } - .bt-row-actions { - padding-left: 0 !important; - justify-content: flex-start !important; + .app-shell .bt-row-actions { + padding-left: 0; + justify-content: flex-start; } - .bt-list-summary { - grid-template-columns: repeat(2, minmax(0, 1fr)) !important; + .app-shell .bt-list-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); } } /* ============== MOBILE MAP FIXES ============== */ @media (max-width: 1023px) { /* Aircraft map container needs explicit height on mobile */ - .aircraft-map-container { - height: 300px !important; - min-height: 300px !important; - width: 100% !important; + .app-shell .aircraft-map-container { + height: 300px; + min-height: 300px; + width: 100%; } - #aircraftMap { - height: 100% !important; - width: 100% !important; + .app-shell #aircraftMap { + height: 100%; + width: 100%; min-height: 250px; } /* APRS map container */ - #aprsMap { - min-height: 300px !important; - height: 300px !important; - width: 100% !important; + .app-shell #aprsMap { + min-height: 300px; + height: 300px; + width: 100%; } /* Satellite embed */ - .satellite-dashboard-embed { - height: 400px !important; - min-height: 400px !important; + .app-shell .satellite-dashboard-embed { + height: 400px; + min-height: 400px; } /* Map panels should be full width */ + /* overrides inline style - HTML sets grid-column via style attribute */ .wifi-visual-panel[style*="grid-column: span 2"] { grid-column: auto !important; } /* Make map container full width when it has ACARS sidebar */ + /* overrides inline style - HTML sets flex-direction via style attribute */ .wifi-visual-panel[style*="display: flex"][style*="gap: 0"] { flex-direction: column !important; } /* ACARS sidebar should be below map on mobile */ - .main-acars-sidebar { - width: 100% !important; - max-width: none !important; - border-left: none !important; - border-top: 1px solid var(--border-color, #1f2937) !important; + .app-shell .main-acars-sidebar { + width: 100%; + max-width: none; + border-left: none; + border-top: 1px solid var(--border-color, #1f2937); } - .main-acars-sidebar.collapsed { - width: 100% !important; + .app-shell .main-acars-sidebar.collapsed { + width: 100%; } - .main-acars-content { - max-height: 200px !important; + .app-shell .main-acars-content { + max-height: 200px; } } @@ -611,55 +614,55 @@ touch-action: manipulation; } -.leaflet-control-zoom a { - min-width: var(--touch-min, 44px) !important; - min-height: var(--touch-min, 44px) !important; - line-height: var(--touch-min, 44px) !important; - font-size: 18px !important; +.app-shell .leaflet-container .leaflet-control-zoom a { + min-width: var(--touch-min, 44px); + min-height: var(--touch-min, 44px); + line-height: var(--touch-min, 44px); + font-size: 18px; } /* ============== MOBILE HEADER STATS ============== */ @media (max-width: 1023px) { - .header-stats { - display: none !important; - } - - /* Simplify header on mobile */ - header h1 { - font-size: 16px !important; - } - - header h1 .tagline, - header h1 .version-badge { + .app-shell .header-stats { display: none; } - header .subtitle { - font-size: 10px !important; + /* Simplify header on mobile */ + .app-shell header h1 { + font-size: 16px; + } + + .app-shell header h1 .tagline, + .app-shell header h1 .version-badge { + display: none; + } + + .app-shell header .subtitle { + font-size: 10px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } - header .logo svg { - width: 30px !important; - height: 30px !important; + .app-shell header .logo svg { + width: 30px; + height: 30px; } } /* ============== MOBILE MODE PANELS ============== */ @media (max-width: 1023px) { /* Mode panel grids should be single column */ - .data-grid, - .stats-grid, - .sensor-grid { - grid-template-columns: 1fr !important; + .app-shell .data-grid, + .app-shell .stats-grid, + .app-shell .sensor-grid { + grid-template-columns: 1fr; } /* Section headers should be easier to tap */ - .section h3 { + .app-shell .section h3 { min-height: var(--touch-min); - padding: 12px !important; + padding: 12px; } /* Tables need horizontal scroll */ @@ -682,85 +685,85 @@ /* ============== WELCOME PAGE MOBILE ============== */ @media (max-width: 767px) { - .welcome-container { - padding: 15px !important; - max-width: 100% !important; + .app-shell .welcome-container { + padding: 15px; + max-width: 100%; } - .welcome-header { + .app-shell .welcome-header { flex-direction: column; text-align: center; gap: 10px; } - .welcome-logo svg { + .app-shell .welcome-logo svg { width: 50px; height: 50px; } - .welcome-title { - font-size: 24px !important; + .app-shell .welcome-title { + font-size: 24px; } - .welcome-content { - grid-template-columns: 1fr !important; + .app-shell .welcome-content { + grid-template-columns: 1fr; } - .mode-grid { - grid-template-columns: repeat(2, 1fr) !important; - gap: 8px !important; + .app-shell .mode-grid { + grid-template-columns: repeat(2, 1fr); + gap: 8px; } - .mode-card { - padding: 12px 8px !important; + .app-shell .mode-card { + padding: 12px 8px; } - .mode-icon { - font-size: 20px !important; + .app-shell .mode-icon { + font-size: 20px; } - .mode-name { - font-size: 11px !important; + .app-shell .mode-name { + font-size: 11px; } - .mode-desc { - font-size: 9px !important; + .app-shell .mode-desc { + font-size: 9px; } - .changelog-release { - padding: 10px !important; + .app-shell .changelog-release { + padding: 10px; } } /* ============== TSCM MODE MOBILE ============== */ @media (max-width: 1023px) { - .tscm-layout { - flex-direction: column !important; - height: auto !important; + .app-shell .tscm-layout { + flex-direction: column; + height: auto; } - .tscm-spectrum-panel, - .tscm-detection-panel { - width: 100% !important; - max-width: none !important; - height: auto !important; + .app-shell .tscm-spectrum-panel, + .app-shell .tscm-detection-panel { + width: 100%; + max-width: none; + height: auto; min-height: 300px; } } /* ============== LISTENING POST MOBILE ============== */ @media (max-width: 1023px) { - .radio-controls-section { - flex-direction: column !important; + .app-shell .radio-controls-section { + flex-direction: column; gap: 15px; } - .knobs-row { + .app-shell .knobs-row { flex-wrap: wrap; justify-content: center; } - .radio-module-box { - width: 100% !important; + .app-shell .radio-module-box { + width: 100%; } } diff --git a/static/css/satellite_dashboard.css b/static/css/satellite_dashboard.css index 728b6fc..d2814cf 100644 --- a/static/css/satellite_dashboard.css +++ b/static/css/satellite_dashboard.css @@ -134,7 +134,7 @@ body { } /* Mobile header adjustments */ -@media (max-width: 800px) { +@media (max-width: 768px) { .header { padding: 10px 12px; flex-wrap: wrap; @@ -709,7 +709,7 @@ body { } /* Responsive */ -@media (max-width: 1200px) { +@media (max-width: 1280px) { .dashboard { grid-template-columns: 1fr 1fr; grid-template-rows: 1fr auto auto; @@ -745,7 +745,7 @@ body { } } -@media (max-width: 800px) { +@media (max-width: 768px) { .dashboard { display: flex; flex-direction: column; diff --git a/static/css/settings.css b/static/css/settings.css index 9ce52c5..ee62849 100644 --- a/static/css/settings.css +++ b/static/css/settings.css @@ -528,13 +528,13 @@ html.map-cyber-enabled .leaflet-container::after { } /* Responsive */ -@media (max-width: 960px) { +@media (max-width: 1023px) { .settings-tabs { grid-template-columns: repeat(4, minmax(0, 1fr)); } } -@media (max-width: 640px) { +@media (max-width: 768px) { .settings-modal.active { padding: 20px 10px; } diff --git a/static/js/core/agents.js b/static/js/core/agents.js index e9a1746..79f0f9c 100644 --- a/static/js/core/agents.js +++ b/static/js/core/agents.js @@ -485,7 +485,7 @@ async function syncLocalModeStates() { */ function showAgentModeWarnings(runningModes, modesDetail = {}) { // SDR modes that can't run simultaneously on same device - const sdrModes = ['sensor', 'pager', 'adsb', 'ais', 'acars', 'vdl2', 'aprs', 'rtlamr', 'listening_post', 'tscm', 'dsc']; + const sdrModes = ['sensor', 'pager', 'adsb', 'ais', 'acars', 'vdl2', 'aprs', 'rtlamr', 'listening_post', 'tscm', 'dsc']; const runningSdrModes = runningModes.filter(m => sdrModes.includes(m)); let warning = document.getElementById('agentModeWarning'); @@ -613,7 +613,7 @@ function checkAgentAudioMode(modeToStart) { * @param {string} modeToStart - Mode to start * @param {number} deviceToUse - Device index to use (optional, for smarter conflict detection) */ -function checkAgentModeConflict(modeToStart, deviceToUse = null) { +async function checkAgentModeConflict(modeToStart, deviceToUse = null) { if (currentAgent === 'local') return true; // No conflict checking for local // First check if this is an audio mode @@ -621,7 +621,7 @@ function checkAgentModeConflict(modeToStart, deviceToUse = null) { return false; } - const sdrModes = ['sensor', 'pager', 'adsb', 'ais', 'acars', 'vdl2', 'aprs', 'rtlamr', 'listening_post', 'tscm', 'dsc']; + const sdrModes = ['sensor', 'pager', 'adsb', 'ais', 'acars', 'vdl2', 'aprs', 'rtlamr', 'listening_post', 'tscm', 'dsc']; // If we're trying to start an SDR mode if (sdrModes.includes(modeToStart)) { @@ -648,11 +648,12 @@ function checkAgentModeConflict(modeToStart, deviceToUse = null) { return detail ? `${m} (SDR ${detail.device})` : m; }).join(', '); - const proceed = confirm( - `The agent's SDR device is currently running: ${modeList}\n\n` + - `Starting ${modeToStart} on the same device will fail.\n\n` + - `Do you want to stop the conflicting mode(s) first?` - ); + const proceed = await AppFeedback.confirmAction({ + title: 'SDR Device Conflict', + message: `The agent's SDR device is currently running: ${modeList}. Starting ${modeToStart} on the same device will fail. Do you want to stop the conflicting mode(s) first?`, + confirmLabel: 'Stop & Continue', + confirmClass: 'btn-danger' + }); if (proceed) { // Stop conflicting modes diff --git a/static/js/core/alerts.js b/static/js/core/alerts.js index a0ebbb3..e5cc50a 100644 --- a/static/js/core/alerts.js +++ b/static/js/core/alerts.js @@ -269,8 +269,14 @@ const AlertCenter = (function() { }); } - function deleteRule(ruleId) { - if (!confirm('Delete this alert rule?')) return; + async function deleteRule(ruleId) { + const confirmed = await AppFeedback.confirmAction({ + title: 'Delete Alert Rule', + message: 'Delete this alert rule?', + confirmLabel: 'Delete', + confirmClass: 'btn-danger' + }); + if (!confirmed) return; fetch(`/alerts/rules/${ruleId}`, { method: 'DELETE' }) .then((r) => r.json()) diff --git a/static/js/core/app.js b/static/js/core/app.js index 41b029f..8834146 100644 --- a/static/js/core/app.js +++ b/static/js/core/app.js @@ -120,19 +120,19 @@ function switchMode(mode) { 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'; + // Toggle stats visibility via class + document.getElementById('pagerStats')?.classList.toggle('active', mode === 'pager'); + document.getElementById('sensorStats')?.classList.toggle('active', mode === 'sensor'); + document.getElementById('aircraftStats')?.classList.toggle('active', mode === 'aircraft'); + document.getElementById('satelliteStats')?.classList.toggle('active', mode === 'satellite'); + document.getElementById('wifiStats')?.classList.toggle('active', mode === 'wifi'); - // Hide signal meter - individual panels show signal strength where needed - document.getElementById('signalMeter').style.display = 'none'; + // Hide signal meter + document.getElementById('signalMeter')?.classList.remove('active'); // 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'; + document.getElementById('adsbDashboardBtn')?.classList.toggle('active', mode === 'aircraft'); + document.getElementById('satelliteDashboardBtn')?.classList.toggle('active', mode === 'satellite'); // Update active mode indicator const modeNames = { @@ -156,14 +156,14 @@ function switchMode(mode) { window.closeMobileDrawer(); } - // Toggle layout containers - document.getElementById('wifiLayoutContainer').style.display = mode === 'wifi' ? 'flex' : 'none'; - document.getElementById('btLayoutContainer').style.display = mode === 'bluetooth' ? 'flex' : 'none'; + // Toggle layout containers via class + document.getElementById('wifiLayoutContainer')?.classList.toggle('active', mode === 'wifi'); + document.getElementById('btLayoutContainer')?.classList.toggle('active', mode === 'bluetooth'); // 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('aircraftVisuals')?.classList.toggle('active', mode === 'aircraft' && showRadar); + document.getElementById('satelliteVisuals')?.classList.toggle('active', mode === 'satellite'); // Update output panel title based on mode const titles = { @@ -178,35 +178,30 @@ function switchMode(mode) { document.getElementById('outputTitle').textContent = titles[mode] || 'Signal Monitor'; // Show/hide Device Intelligence for modes that use it + const hideRecon = (mode === 'satellite' || mode === 'aircraft'); const reconBtn = document.getElementById('reconBtn'); const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]'); - if (mode === 'satellite' || mode === 'aircraft') { - 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'; - } - } + document.getElementById('reconPanel')?.classList.toggle('active', !hideRecon && typeof reconEnabled !== 'undefined' && reconEnabled); + if (reconBtn) reconBtn.classList.toggle('hidden', hideRecon); + if (intelBtn) intelBtn.classList.toggle('hidden', hideRecon); // Show RTL-SDR device section for modes that use it - document.getElementById('rtlDeviceSection').style.display = - (mode === 'pager' || mode === 'sensor' || mode === 'aircraft') ? 'block' : 'none'; + const showRtl = (mode === 'pager' || mode === 'sensor' || mode === 'aircraft'); + document.getElementById('rtlDeviceSection')?.classList.toggle('active', showRtl); // 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'; + document.getElementById('toolStatusPager')?.classList.toggle('active', mode === 'pager'); + document.getElementById('toolStatusSensor')?.classList.toggle('active', mode === 'sensor'); + document.getElementById('toolStatusAircraft')?.classList.toggle('active', mode === 'aircraft'); // Hide waterfall and output console for modes with their own visualizations - document.querySelector('.waterfall-container').style.display = - (mode === 'satellite' || 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'; + const fullVisualModes = ['satellite', 'aircraft', 'wifi', 'bluetooth', 'meshtastic', 'aprs', 'tscm', 'spystations']; + const hideConsole = fullVisualModes.includes(mode); + document.querySelector('.waterfall-container')?.classList.toggle('active', !hideConsole); + document.getElementById('output')?.classList.toggle('active', !hideConsole); + + const hideStatusBar = ['satellite', 'tscm', 'meshtastic', 'aprs', 'spystations'].includes(mode); + document.querySelector('.status-bar')?.classList.toggle('active', !hideStatusBar); // Load interfaces and initialize visualizations when switching modes if (mode === 'wifi') { diff --git a/static/js/core/sse-manager.js b/static/js/core/sse-manager.js new file mode 100644 index 0000000..5bab2f6 --- /dev/null +++ b/static/js/core/sse-manager.js @@ -0,0 +1,245 @@ +/** + * SSEManager - Centralized Server-Sent Events connection manager + * Handles connection lifecycle, reconnection with exponential backoff, + * visibility-based pause/resume, and state change notifications. + */ +const SSEManager = (function() { + 'use strict'; + + const STATES = { + CONNECTING: 'connecting', + OPEN: 'open', + RECONNECTING: 'reconnecting', + CLOSED: 'closed', + ERROR: 'error', + }; + + const BACKOFF_INITIAL = 1000; + const BACKOFF_MAX = 30000; + const BACKOFF_MULTIPLIER = 2; + + /** @type {Map} */ + const connections = new Map(); + + /** + * @typedef {Object} ConnectionEntry + * @property {string} key + * @property {string} url + * @property {EventSource|null} source + * @property {string} state + * @property {number} backoff + * @property {number|null} retryTimer + * @property {boolean} intentionallyClosed + * @property {Function|null} onMessage + * @property {Function|null} onStateChange + */ + + function connect(key, url, options) { + const opts = options || {}; + + // Disconnect existing connection for this key + if (connections.has(key)) { + disconnect(key); + } + + const entry = { + key: key, + url: url, + source: null, + state: STATES.CLOSED, + backoff: BACKOFF_INITIAL, + retryTimer: null, + intentionallyClosed: false, + onMessage: typeof opts.onMessage === 'function' ? opts.onMessage : null, + onStateChange: typeof opts.onStateChange === 'function' ? opts.onStateChange : null, + }; + + connections.set(key, entry); + openConnection(entry); + return entry; + } + + function openConnection(entry) { + if (entry.intentionallyClosed) return; + + setState(entry, entry.state === STATES.CLOSED ? STATES.CONNECTING : STATES.RECONNECTING); + + try { + const source = new EventSource(entry.url); + entry.source = source; + + source.onopen = function() { + entry.backoff = BACKOFF_INITIAL; + setState(entry, STATES.OPEN); + }; + + source.onmessage = function(event) { + if (entry.onMessage) { + try { + entry.onMessage(event); + } catch (err) { + console.debug('[SSEManager] onMessage error for ' + entry.key + ':', err); + } + } + }; + + source.onerror = function() { + // EventSource fires error on close and connection loss + if (entry.intentionallyClosed) return; + + closeSource(entry); + setState(entry, STATES.ERROR); + scheduleReconnect(entry); + }; + } catch (err) { + setState(entry, STATES.ERROR); + scheduleReconnect(entry); + } + } + + function closeSource(entry) { + if (entry.source) { + entry.source.onopen = null; + entry.source.onmessage = null; + entry.source.onerror = null; + try { entry.source.close(); } catch (e) { /* ignore */ } + entry.source = null; + } + } + + function scheduleReconnect(entry) { + if (entry.intentionallyClosed) return; + if (entry.retryTimer) return; + + // Pause reconnection when tab is hidden + if (document.hidden) { + setState(entry, STATES.RECONNECTING); + return; + } + + const delay = entry.backoff; + entry.backoff = Math.min(entry.backoff * BACKOFF_MULTIPLIER, BACKOFF_MAX); + + setState(entry, STATES.RECONNECTING); + + entry.retryTimer = window.setTimeout(function() { + entry.retryTimer = null; + if (!entry.intentionallyClosed) { + openConnection(entry); + } + }, delay); + } + + function disconnect(key) { + const entry = connections.get(key); + if (!entry) return; + + entry.intentionallyClosed = true; + + if (entry.retryTimer) { + clearTimeout(entry.retryTimer); + entry.retryTimer = null; + } + + closeSource(entry); + setState(entry, STATES.CLOSED); + connections.delete(key); + } + + function disconnectAll() { + for (const key of Array.from(connections.keys())) { + disconnect(key); + } + } + + function getState(key) { + const entry = connections.get(key); + return entry ? entry.state : STATES.CLOSED; + } + + function getActiveKeys() { + const keys = []; + connections.forEach(function(entry, key) { + if (entry.state === STATES.OPEN) { + keys.push(key); + } + }); + return keys; + } + + function setState(entry, newState) { + if (entry.state === newState) return; + const oldState = entry.state; + entry.state = newState; + + if (entry.onStateChange) { + try { + entry.onStateChange(newState, oldState, entry.key); + } catch (err) { + console.debug('[SSEManager] onStateChange error:', err); + } + } + + // Update global indicator + updateGlobalIndicator(); + } + + // --- Global SSE Status Indicator --- + + function updateGlobalIndicator() { + const dot = document.getElementById('sseStatusDot'); + if (!dot) return; + + let hasOpen = false; + let hasReconnecting = false; + let hasError = false; + + connections.forEach(function(entry) { + if (entry.state === STATES.OPEN) hasOpen = true; + else if (entry.state === STATES.RECONNECTING || entry.state === STATES.CONNECTING) hasReconnecting = true; + else if (entry.state === STATES.ERROR) hasError = true; + }); + + // Remove all state classes + dot.classList.remove('online', 'warning', 'error', 'inactive'); + + if (connections.size === 0) { + dot.classList.add('inactive'); + dot.setAttribute('data-tooltip', 'No active streams'); + } else if (hasError && !hasOpen) { + dot.classList.add('error'); + dot.setAttribute('data-tooltip', 'Stream connection error'); + } else if (hasReconnecting) { + dot.classList.add('warning'); + dot.setAttribute('data-tooltip', 'Reconnecting...'); + } else if (hasOpen) { + dot.classList.add('online'); + dot.setAttribute('data-tooltip', 'Streams connected'); + } else { + dot.classList.add('inactive'); + dot.setAttribute('data-tooltip', 'Streams idle'); + } + } + + // --- Visibility API: pause/resume reconnection --- + + document.addEventListener('visibilitychange', function() { + if (document.hidden) return; + + // Tab became visible — reconnect any entries that were waiting + connections.forEach(function(entry) { + if (!entry.intentionallyClosed && !entry.source && !entry.retryTimer) { + openConnection(entry); + } + }); + }); + + return { + STATES: STATES, + connect: connect, + disconnect: disconnect, + disconnectAll: disconnectAll, + getState: getState, + getActiveKeys: getActiveKeys, + }; +})(); diff --git a/static/js/core/ui-feedback.js b/static/js/core/ui-feedback.js index 7b8c12f..936deb3 100644 --- a/static/js/core/ui-feedback.js +++ b/static/js/core/ui-feedback.js @@ -3,6 +3,7 @@ const AppFeedback = (function() { let stackEl = null; let nextToastId = 1; + const TOAST_MAX = 5; function init() { ensureStack(); @@ -17,6 +18,8 @@ const AppFeedback = (function() { stackEl = document.createElement('div'); stackEl.id = 'appToastStack'; stackEl.className = 'app-toast-stack'; + stackEl.setAttribute('aria-live', 'assertive'); + stackEl.setAttribute('role', 'alert'); document.body.appendChild(stackEl); } return stackEl; @@ -64,7 +67,14 @@ const AppFeedback = (function() { root.appendChild(actionsEl); } - ensureStack().appendChild(root); + const stack = ensureStack(); + + // Enforce toast cap — remove oldest when exceeded + while (stack.children.length >= TOAST_MAX) { + stack.removeChild(stack.firstChild); + } + + stack.appendChild(root); if (durationMs > 0) { window.setTimeout(() => { @@ -240,6 +250,151 @@ const AppFeedback = (function() { return text.includes('permission') || text.includes('denied') || text.includes('dependency') || text.includes('tool'); } + // --- Button loading state --- + + function withLoadingButton(btn, asyncFn) { + if (!btn || btn.disabled) return Promise.resolve(); + + const originalText = btn.textContent; + btn.disabled = true; + btn.classList.add('btn-loading'); + + return Promise.resolve() + .then(function() { return asyncFn(); }) + .then(function(result) { + btn.disabled = false; + btn.classList.remove('btn-loading'); + btn.textContent = originalText; + return result; + }) + .catch(function(err) { + btn.disabled = false; + btn.classList.remove('btn-loading'); + btn.textContent = originalText; + throw err; + }); + } + + // --- Confirmation modal --- + + function confirmAction(options) { + var opts = options || {}; + var title = opts.title || 'Confirm Action'; + var message = opts.message || 'Are you sure?'; + var confirmLabel = opts.confirmLabel || 'Confirm'; + var confirmClass = opts.confirmClass || 'btn-danger'; + + return new Promise(function(resolve) { + // Create backdrop + var backdrop = document.createElement('div'); + backdrop.className = 'confirm-modal-backdrop'; + + var modal = document.createElement('div'); + modal.className = 'confirm-modal'; + modal.setAttribute('role', 'dialog'); + modal.setAttribute('aria-modal', 'true'); + modal.setAttribute('aria-labelledby', 'confirm-modal-title'); + + var titleEl = document.createElement('div'); + titleEl.className = 'confirm-modal-title'; + titleEl.id = 'confirm-modal-title'; + titleEl.textContent = title; + modal.appendChild(titleEl); + + var msgEl = document.createElement('div'); + msgEl.className = 'confirm-modal-message'; + msgEl.textContent = message; + modal.appendChild(msgEl); + + var actions = document.createElement('div'); + actions.className = 'confirm-modal-actions'; + + var cancelBtn = document.createElement('button'); + cancelBtn.type = 'button'; + cancelBtn.className = 'btn btn-ghost'; + cancelBtn.textContent = 'Cancel'; + + var confirmBtn = document.createElement('button'); + confirmBtn.type = 'button'; + confirmBtn.className = 'btn ' + confirmClass; + confirmBtn.textContent = confirmLabel; + + actions.appendChild(cancelBtn); + actions.appendChild(confirmBtn); + modal.appendChild(actions); + backdrop.appendChild(modal); + document.body.appendChild(backdrop); + + // Focus confirm button + confirmBtn.focus(); + + function cleanup(result) { + backdrop.remove(); + document.removeEventListener('keydown', onKey); + resolve(result); + } + + function onKey(e) { + if (e.key === 'Escape') cleanup(false); + if (e.key === 'Enter') cleanup(true); + } + + cancelBtn.addEventListener('click', function() { cleanup(false); }); + confirmBtn.addEventListener('click', function() { cleanup(true); }); + backdrop.addEventListener('click', function(e) { + if (e.target === backdrop) cleanup(false); + }); + document.addEventListener('keydown', onKey); + }); + } + + // --- Keyboard navigation for lists --- + + function enableListKeyNav(container, itemSelector) { + if (!container) return; + + container.setAttribute('role', 'listbox'); + container.setAttribute('tabindex', '0'); + + container.addEventListener('keydown', function(e) { + var items = container.querySelectorAll(itemSelector); + if (!items.length) return; + + var current = container.querySelector(itemSelector + '[aria-selected="true"]'); + var idx = current ? Array.prototype.indexOf.call(items, current) : -1; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + var next = Math.min(idx + 1, items.length - 1); + selectItem(items, next); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + var prev = Math.max(idx - 1, 0); + selectItem(items, prev); + } else if (e.key === 'Enter' && current) { + e.preventDefault(); + current.click(); + } else if (e.key === 'Escape' && current) { + e.preventDefault(); + current.setAttribute('aria-selected', 'false'); + current.classList.remove('keyboard-focused'); + } + }); + + function selectItem(items, index) { + items.forEach(function(item) { + item.setAttribute('aria-selected', 'false'); + item.classList.remove('keyboard-focused'); + }); + var target = items[index]; + if (target) { + target.setAttribute('aria-selected', 'true'); + target.classList.add('keyboard-focused'); + target.scrollIntoView({ block: 'nearest' }); + } + } + } + return { init, toast, @@ -249,6 +404,9 @@ const AppFeedback = (function() { isOffline, isTransientNetworkError, isTransientOrOffline, + withLoadingButton, + confirmAction, + enableListKeyNav, }; })(); diff --git a/static/js/modes/bluetooth.js b/static/js/modes/bluetooth.js index a7b62f1..bad84ae 100644 --- a/static/js/modes/bluetooth.js +++ b/static/js/modes/bluetooth.js @@ -75,12 +75,12 @@ const BluetoothMode = (function() { /** * Check for agent mode conflicts before starting scan. */ - function checkAgentConflicts() { + async function checkAgentConflicts() { if (typeof currentAgent === 'undefined' || currentAgent === 'local') { return true; } if (typeof checkAgentModeConflict === 'function') { - return checkAgentModeConflict('bluetooth'); + return await checkAgentModeConflict('bluetooth'); } return true; } @@ -883,7 +883,7 @@ const BluetoothMode = (function() { async function startScan() { // Check for agent mode conflicts - if (!checkAgentConflicts()) { + if (!await checkAgentConflicts()) { return; } @@ -940,7 +940,9 @@ const BluetoothMode = (function() { } catch (err) { console.error('Failed to start scan:', err); - showErrorMessage('Failed to start scan: ' + err.message); + reportActionableError('Start Bluetooth Scan', err, { + onRetry: () => startScan() + }); } } @@ -968,6 +970,7 @@ const BluetoothMode = (function() { } } catch (err) { console.error('Failed to stop scan:', err); + reportActionableError('Stop Bluetooth Scan', err); } finally { if (timeoutId) { clearTimeout(timeoutId); @@ -1537,6 +1540,9 @@ const BluetoothMode = (function() { } } catch (err) { console.error('Failed to set baseline:', err); + reportActionableError('Set Baseline', err, { + onRetry: () => setBaseline() + }); } } @@ -1552,6 +1558,9 @@ const BluetoothMode = (function() { } } catch (err) { console.error('Failed to clear baseline:', err); + reportActionableError('Clear Baseline', err, { + onRetry: () => clearBaseline() + }); } } diff --git a/static/js/modes/meshtastic.js b/static/js/modes/meshtastic.js index 939037e..80d40c6 100644 --- a/static/js/modes/meshtastic.js +++ b/static/js/modes/meshtastic.js @@ -266,8 +266,10 @@ const Meshtastic = (function() { } } catch (err) { console.error('Failed to start Meshtastic:', err); + reportActionableError('Start Meshtastic', err, { + onRetry: () => start() + }); updateStatusIndicator('disconnected', 'Connection error'); - showStatusMessage('Connection error: ' + err.message, 'error'); } } @@ -283,6 +285,7 @@ const Meshtastic = (function() { showNotification('Meshtastic', 'Disconnected'); } catch (err) { console.error('Failed to stop Meshtastic:', err); + reportActionableError('Stop Meshtastic', err); } } @@ -589,7 +592,9 @@ const Meshtastic = (function() { } } catch (err) { console.error('Failed to configure channel:', err); - showStatusMessage('Error configuring channel: ' + err.message, 'error'); + reportActionableError('Configure Channel', err, { + onRetry: () => saveChannel() + }); } } @@ -1246,11 +1251,11 @@ const Meshtastic = (function() { } } catch (err) { console.error('Failed to send message:', err); + reportActionableError('Send Message', err, { + onRetry: () => sendMessage() + }); optimisticMsg._failed = true; updatePendingMessage(optimisticMsg, true); - if (typeof showNotification === 'function') { - showNotification('Meshtastic', 'Send error: ' + err.message); - } } finally { if (sendBtn) { sendBtn.disabled = false; @@ -1382,6 +1387,9 @@ const Meshtastic = (function() { } } catch (err) { console.error('Traceroute error:', err); + reportActionableError('Send Traceroute', err, { + onRetry: () => sendTraceroute(destination) + }); showTracerouteModal(destination, { error: err.message }, false); } } @@ -1564,7 +1572,9 @@ const Meshtastic = (function() { } } catch (err) { console.error('Position request error:', err); - showStatusMessage('Error requesting position: ' + err.message, 'error'); + reportActionableError('Request Position', err, { + onRetry: () => requestPosition(nodeId) + }); } } @@ -2085,7 +2095,9 @@ const Meshtastic = (function() { } } catch (err) { console.error('Range test error:', err); - showStatusMessage('Error starting range test: ' + err.message, 'error'); + reportActionableError('Start Range Test', err, { + onRetry: () => startRangeTest() + }); } } @@ -2099,6 +2111,7 @@ const Meshtastic = (function() { showNotification('Meshtastic', 'Range test stopped'); } catch (err) { console.error('Error stopping range test:', err); + reportActionableError('Stop Range Test', err); } } @@ -2243,7 +2256,9 @@ const Meshtastic = (function() { } } catch (err) { console.error('S&F request error:', err); - showStatusMessage('Error: ' + err.message, 'error'); + reportActionableError('Request Store & Forward History', err, { + onRetry: () => requestStoreForwardHistory() + }); } } diff --git a/static/js/modes/ook.js b/static/js/modes/ook.js index 70b0099..38f36da 100644 --- a/static/js/modes/ook.js +++ b/static/js/modes/ook.js @@ -498,15 +498,27 @@ var OokMode = (function () { input.value = ''; } - function removePreset(freq) { - if (!confirm('Remove preset ' + freq + ' MHz?')) return; + async function removePreset(freq) { + const confirmed = await AppFeedback.confirmAction({ + title: 'Remove Preset', + message: 'Remove preset ' + freq + ' MHz?', + confirmLabel: 'Remove', + confirmClass: 'btn-danger' + }); + if (!confirmed) return; var presets = loadPresets().filter(function (p) { return p !== freq; }); savePresets(presets); renderPresets(); } - function resetPresets() { - if (!confirm('Reset to default presets?')) return; + async function resetPresets() { + const confirmed = await AppFeedback.confirmAction({ + title: 'Reset Presets', + message: 'Reset to default presets?', + confirmLabel: 'Reset', + confirmClass: 'btn-danger' + }); + if (!confirmed) return; savePresets(DEFAULT_FREQ_PRESETS.slice()); renderPresets(); } diff --git a/static/js/modes/sstv-general.js b/static/js/modes/sstv-general.js index b4e4d76..61add49 100644 --- a/static/js/modes/sstv-general.js +++ b/static/js/modes/sstv-general.js @@ -802,7 +802,13 @@ const SSTVGeneral = (function() { * Delete a single image */ async function deleteImage(filename) { - if (!confirm('Delete this image?')) return; + const confirmed = await AppFeedback.confirmAction({ + title: 'Delete Image', + message: 'Delete this image? This cannot be undone.', + confirmLabel: 'Delete', + confirmClass: 'btn-danger' + }); + if (!confirmed) return; try { const response = await fetch(`/sstv-general/images/${encodeURIComponent(filename)}`, { method: 'DELETE' }); const data = await response.json(); @@ -822,7 +828,13 @@ const SSTVGeneral = (function() { * Delete all images */ async function deleteAllImages() { - if (!confirm('Delete all decoded images?')) return; + const confirmed = await AppFeedback.confirmAction({ + title: 'Delete All Images', + message: 'Delete all decoded images? This cannot be undone.', + confirmLabel: 'Delete All', + confirmClass: 'btn-danger' + }); + if (!confirmed) return; try { const response = await fetch('/sstv-general/images', { method: 'DELETE' }); const data = await response.json(); diff --git a/static/js/modes/sstv.js b/static/js/modes/sstv.js index 434f0a5..4e4a461 100644 --- a/static/js/modes/sstv.js +++ b/static/js/modes/sstv.js @@ -606,8 +606,10 @@ const SSTV = (function() { } } catch (err) { console.error('Failed to start SSTV:', err); + reportActionableError('Start SSTV', err, { + onRetry: () => start() + }); updateStatusUI('idle', 'Error'); - showStatusMessage('Connection error: ' + err.message, 'error'); } } @@ -626,6 +628,7 @@ const SSTV = (function() { showNotification('SSTV', 'Decoder stopped'); } catch (err) { console.error('Failed to stop SSTV:', err); + reportActionableError('Stop SSTV', err); } } @@ -1297,7 +1300,13 @@ const SSTV = (function() { * Delete a single image */ async function deleteImage(filename) { - if (!confirm('Delete this image?')) return; + const confirmed = await AppFeedback.confirmAction({ + title: 'Delete Image', + message: 'Delete this image? This cannot be undone.', + confirmLabel: 'Delete', + confirmClass: 'btn-danger' + }); + if (!confirmed) return; try { const response = await fetch(`/sstv/images/${encodeURIComponent(filename)}`, { method: 'DELETE' }); const data = await response.json(); @@ -1310,6 +1319,7 @@ const SSTV = (function() { } } catch (err) { console.error('Failed to delete image:', err); + reportActionableError('Delete Image', err); } } @@ -1317,7 +1327,13 @@ const SSTV = (function() { * Delete all images */ async function deleteAllImages() { - if (!confirm('Delete all decoded images?')) return; + const confirmed = await AppFeedback.confirmAction({ + title: 'Delete All Images', + message: 'Delete all decoded images? This cannot be undone.', + confirmLabel: 'Delete All', + confirmClass: 'btn-danger' + }); + if (!confirmed) return; try { const response = await fetch('/sstv/images', { method: 'DELETE' }); const data = await response.json(); @@ -1329,6 +1345,7 @@ const SSTV = (function() { } } catch (err) { console.error('Failed to delete images:', err); + reportActionableError('Delete All Images', err); } } diff --git a/static/js/modes/subghz.js b/static/js/modes/subghz.js index 6d5a350..4be68c2 100644 --- a/static/js/modes/subghz.js +++ b/static/js/modes/subghz.js @@ -1,9 +1,9 @@ -/** - * SubGHz Transceiver Mode - * HackRF One SubGHz signal capture, decode, replay, and spectrum analysis - */ - -const SubGhz = (function() { +/** + * SubGHz Transceiver Mode + * HackRF One SubGHz signal capture, decode, replay, and spectrum analysis + */ + +const SubGhz = (function() { let eventSource = null; let statusTimer = null; let statusPollTimer = null; @@ -34,11 +34,11 @@ const SubGhz = (function() { let decodeWaterfallCtx = null; let decodeWaterfallPalette = null; let decodeWaterfallResizeObserver = null; - - // Dashboard state + + // Dashboard state let activePanel = null; // null = hub, 'rx'|'sweep'|'tx'|'saved' - let signalCount = 0; - let captureCount = 0; + let signalCount = 0; + let captureCount = 0; let consoleEntries = []; let consoleCollapsed = false; let currentPhase = null; // 'tuning'|'listening'|'decoding'|null @@ -54,46 +54,46 @@ const SubGhz = (function() { let lastTxCaptureId = null; let lastTxRequest = null; let txModalIntent = 'tx'; - - // HackRF detection - let hackrfDetected = false; - let rtl433Detected = false; - let sweepDetected = false; - - // Interactive sweep state - const SWEEP_PAD = { top: 20, right: 20, bottom: 30, left: 50 }; - const SWEEP_POWER_MIN = -100; - const SWEEP_POWER_MAX = 0; - - let sweepHoverFreq = null; - let sweepHoverPower = null; - let sweepSelectedFreq = null; - let sweepPeaks = []; - let sweepPeakHold = []; - let sweepInteractionBound = false; - let sweepResizeObserver = null; - let sweepTooltipEl = null; - let sweepCtxMenuEl = null; - let sweepActionBarEl = null; - let sweepDismissHandler = null; - - /** - * Initialize the SubGHz mode - */ + + // HackRF detection + let hackrfDetected = false; + let rtl433Detected = false; + let sweepDetected = false; + + // Interactive sweep state + const SWEEP_PAD = { top: 20, right: 20, bottom: 30, left: 50 }; + const SWEEP_POWER_MIN = -100; + const SWEEP_POWER_MAX = 0; + + let sweepHoverFreq = null; + let sweepHoverPower = null; + let sweepSelectedFreq = null; + let sweepPeaks = []; + let sweepPeakHold = []; + let sweepInteractionBound = false; + let sweepResizeObserver = null; + let sweepTooltipEl = null; + let sweepCtxMenuEl = null; + let sweepActionBarEl = null; + let sweepDismissHandler = null; + + /** + * Initialize the SubGHz mode + */ function init() { loadCaptures(); startStream(); startStatusPolling(); syncTriggerControls(); - - // Check HackRF availability and restore panel state - fetch('/subghz/status') - .then(r => r.json()) - .then(data => { - updateDeviceStatus(data); - updateStatusUI(data); - - const mode = data.mode || 'idle'; + + // Check HackRF availability and restore panel state + fetch('/subghz/status') + .then(r => r.json()) + .then(data => { + updateDeviceStatus(data); + updateStatusUI(data); + + const mode = data.mode || 'idle'; if (mode === 'decode') { // Legacy decode mode may still be running via API, but this UI // intentionally focuses on RAW capture/replay/sweep. @@ -110,17 +110,17 @@ const SubGhz = (function() { showConsole(); startStatusTimer(); } else if (mode === 'sweep') { - showPanel('sweep'); - initSweepCanvas(); - showConsole(); - } else if (mode === 'tx') { - showPanel('tx'); - showConsole(); - startStatusTimer(); - } else { - showHub(); - } - }) + showPanel('sweep'); + initSweepCanvas(); + showConsole(); + } else if (mode === 'tx') { + showPanel('tx'); + showConsole(); + startStatusTimer(); + } else { + showHub(); + } + }) .catch(() => showHub()); } @@ -146,9 +146,9 @@ const SubGhz = (function() { refresh(); statusPollTimer = setInterval(refresh, 3000); } - - // ------ DEVICE DETECTION ------ - + + // ------ DEVICE DETECTION ------ + function updateDeviceStatus(data) { const hackrfAvailable = !!data.hackrf_available; const hackrfInfoAvailable = data.hackrf_info_available !== false; @@ -204,22 +204,22 @@ const SubGhz = (function() { } } } - - function setToolBadge(id, available) { - const el = document.getElementById(id); - if (!el) return; - el.classList.toggle('available', available); - el.classList.toggle('missing', !available); - } - - /** - * Set frequency from preset button - */ - function setFreq(mhz) { - const el = document.getElementById('subghzFrequency'); - if (el) el.value = mhz; - } - + + function setToolBadge(id, available) { + const el = document.getElementById(id); + if (!el) return; + el.classList.toggle('available', available); + el.classList.toggle('missing', !available); + } + + /** + * Set frequency from preset button + */ + function setFreq(mhz) { + const el = document.getElementById('subghzFrequency'); + if (el) el.value = mhz; + } + /** * Switch between RAW receive / sweep sidebar tabs. * Only toggles sidebar tab content visibility — does NOT open visuals panels. @@ -233,13 +233,13 @@ const SubGhz = (function() { if (tabRx) tabRx.classList.toggle('active', tab === 'rx'); if (tabSweep) tabSweep.classList.toggle('active', tab === 'sweep'); } - - /** - * Get common parameters from inputs - */ - function getParams() { - const freqMhz = parseFloat(document.getElementById('subghzFrequency')?.value || '433.92'); - const serial = (document.getElementById('subghzDeviceSerial')?.value || '').trim(); + + /** + * Get common parameters from inputs + */ + function getParams() { + const freqMhz = parseFloat(document.getElementById('subghzFrequency')?.value || '433.92'); + const serial = (document.getElementById('subghzDeviceSerial')?.value || '').trim(); const params = { frequency_hz: Math.round(freqMhz * 1000000), lna_gain: parseInt(document.getElementById('subghzLnaGain')?.value || '24'), @@ -255,46 +255,46 @@ const SubGhz = (function() { if (serial) params.device_serial = serial; return params; } - - // ------ COORDINATE HELPERS ------ - - function sweepPixelToFreqPower(canvasX, canvasY) { - if (!sweepCanvas || sweepData.length < 2) return { freq: 0, power: 0, inChart: false }; - const w = sweepCanvas.width; - const h = sweepCanvas.height; - const chartW = w - SWEEP_PAD.left - SWEEP_PAD.right; - const chartH = h - SWEEP_PAD.top - SWEEP_PAD.bottom; - const inChart = canvasX >= SWEEP_PAD.left && canvasX <= w - SWEEP_PAD.right && - canvasY >= SWEEP_PAD.top && canvasY <= h - SWEEP_PAD.bottom; - const ratio = Math.max(0, Math.min(1, (canvasX - SWEEP_PAD.left) / chartW)); - const freqMin = sweepData[0].freq; - const freqMax = sweepData[sweepData.length - 1].freq; - const freq = freqMin + ratio * (freqMax - freqMin); - const powerRatio = Math.max(0, Math.min(1, (h - SWEEP_PAD.bottom - canvasY) / chartH)); - const power = SWEEP_POWER_MIN + powerRatio * (SWEEP_POWER_MAX - SWEEP_POWER_MIN); - return { freq, power, inChart }; - } - - function sweepFreqToPixelX(freqMhz) { - if (!sweepCanvas || sweepData.length < 2) return 0; - const chartW = sweepCanvas.width - SWEEP_PAD.left - SWEEP_PAD.right; - const freqMin = sweepData[0].freq; - const freqMax = sweepData[sweepData.length - 1].freq; - const ratio = (freqMhz - freqMin) / (freqMax - freqMin); - return SWEEP_PAD.left + ratio * chartW; - } - + + // ------ COORDINATE HELPERS ------ + + function sweepPixelToFreqPower(canvasX, canvasY) { + if (!sweepCanvas || sweepData.length < 2) return { freq: 0, power: 0, inChart: false }; + const w = sweepCanvas.width; + const h = sweepCanvas.height; + const chartW = w - SWEEP_PAD.left - SWEEP_PAD.right; + const chartH = h - SWEEP_PAD.top - SWEEP_PAD.bottom; + const inChart = canvasX >= SWEEP_PAD.left && canvasX <= w - SWEEP_PAD.right && + canvasY >= SWEEP_PAD.top && canvasY <= h - SWEEP_PAD.bottom; + const ratio = Math.max(0, Math.min(1, (canvasX - SWEEP_PAD.left) / chartW)); + const freqMin = sweepData[0].freq; + const freqMax = sweepData[sweepData.length - 1].freq; + const freq = freqMin + ratio * (freqMax - freqMin); + const powerRatio = Math.max(0, Math.min(1, (h - SWEEP_PAD.bottom - canvasY) / chartH)); + const power = SWEEP_POWER_MIN + powerRatio * (SWEEP_POWER_MAX - SWEEP_POWER_MIN); + return { freq, power, inChart }; + } + + function sweepFreqToPixelX(freqMhz) { + if (!sweepCanvas || sweepData.length < 2) return 0; + const chartW = sweepCanvas.width - SWEEP_PAD.left - SWEEP_PAD.right; + const freqMin = sweepData[0].freq; + const freqMax = sweepData[sweepData.length - 1].freq; + const ratio = (freqMhz - freqMin) / (freqMax - freqMin); + return SWEEP_PAD.left + ratio * chartW; + } + function interpolatePower(freqMhz) { if (sweepData.length === 0) return SWEEP_POWER_MIN; if (sweepData.length === 1) return sweepData[0].power; let lo = 0, hi = sweepData.length - 1; if (freqMhz <= sweepData[lo].freq) return sweepData[lo].power; if (freqMhz >= sweepData[hi].freq) return sweepData[hi].power; - while (hi - lo > 1) { - const mid = (lo + hi) >> 1; - if (sweepData[mid].freq <= freqMhz) lo = mid; - else hi = mid; - } + while (hi - lo > 1) { + const mid = (lo + hi) >> 1; + if (sweepData[mid].freq <= freqMhz) lo = mid; + else hi = mid; + } const t = (freqMhz - sweepData[lo].freq) / (sweepData[hi].freq - sweepData[lo].freq); return sweepData[lo].power + t * (sweepData[hi].power - sweepData[lo].power); } @@ -622,34 +622,34 @@ const SubGhz = (function() { } // ------ STATUS ------ - - function updateStatusUI(data) { - const dot = document.getElementById('subghzStatusDot'); - const text = document.getElementById('subghzStatusText'); - const timer = document.getElementById('subghzStatusTimer'); - const mode = data.mode || 'idle'; - currentMode = mode; - - if (dot) { - dot.className = 'subghz-status-dot'; - if (mode !== 'idle') dot.classList.add(mode); - } - - const labels = { idle: 'Idle', rx: 'Capturing', decode: 'Decoding', tx: 'Transmitting', sweep: 'Sweeping' }; - if (text) text.textContent = labels[mode] || mode; - - if (timer && data.elapsed_seconds) { - timer.textContent = formatDuration(data.elapsed_seconds); - } else if (timer) { - timer.textContent = ''; - } - - // Toggle sidebar buttons - toggleButtons(mode); - - // Update stats strip - updateStatsStrip(mode); - + + function updateStatusUI(data) { + const dot = document.getElementById('subghzStatusDot'); + const text = document.getElementById('subghzStatusText'); + const timer = document.getElementById('subghzStatusTimer'); + const mode = data.mode || 'idle'; + currentMode = mode; + + if (dot) { + dot.className = 'subghz-status-dot'; + if (mode !== 'idle') dot.classList.add(mode); + } + + const labels = { idle: 'Idle', rx: 'Capturing', decode: 'Decoding', tx: 'Transmitting', sweep: 'Sweeping' }; + if (text) text.textContent = labels[mode] || mode; + + if (timer && data.elapsed_seconds) { + timer.textContent = formatDuration(data.elapsed_seconds); + } else if (timer) { + timer.textContent = ''; + } + + // Toggle sidebar buttons + toggleButtons(mode); + + // Update stats strip + updateStatsStrip(mode); + // RX recording indicator const rec = document.getElementById('subghzRxRecording'); if (rec) rec.style.display = (mode === 'rx') ? 'flex' : 'none'; @@ -694,7 +694,7 @@ const SubGhz = (function() { setEnabled(id, enabled); } } - + function formatDuration(seconds) { const m = Math.floor(seconds / 60); const s = Math.floor(seconds % 60); @@ -712,44 +712,44 @@ const SubGhz = (function() { const fixed = idx === 0 ? 0 : 1; return `${val.toFixed(fixed)} ${sizes[idx]}`; } - - function startStatusTimer() { - rxStartTime = Date.now(); - if (statusTimer) clearInterval(statusTimer); - statusTimer = setInterval(() => { - const elapsed = (Date.now() - rxStartTime) / 1000; - const formatted = formatDuration(elapsed); - - // Update sidebar timer - const timer = document.getElementById('subghzStatusTimer'); - if (timer) timer.textContent = formatted; - - // Update stats strip timer - const stripTimer = document.getElementById('subghzStripTimer'); - if (stripTimer) stripTimer.textContent = formatted; - - // Update TX elapsed if TX panel is active - if (currentMode === 'tx') { - const txElapsed = document.getElementById('subghzTxElapsed'); - if (txElapsed) txElapsed.textContent = formatted; - } - }, 1000); - } - - function stopStatusTimer() { - if (statusTimer) { - clearInterval(statusTimer); - statusTimer = null; - } - rxStartTime = null; - const timer = document.getElementById('subghzStatusTimer'); - if (timer) timer.textContent = ''; - const stripTimer = document.getElementById('subghzStripTimer'); - if (stripTimer) stripTimer.textContent = ''; - } - - // ------ RECEIVE ------ - + + function startStatusTimer() { + rxStartTime = Date.now(); + if (statusTimer) clearInterval(statusTimer); + statusTimer = setInterval(() => { + const elapsed = (Date.now() - rxStartTime) / 1000; + const formatted = formatDuration(elapsed); + + // Update sidebar timer + const timer = document.getElementById('subghzStatusTimer'); + if (timer) timer.textContent = formatted; + + // Update stats strip timer + const stripTimer = document.getElementById('subghzStripTimer'); + if (stripTimer) stripTimer.textContent = formatted; + + // Update TX elapsed if TX panel is active + if (currentMode === 'tx') { + const txElapsed = document.getElementById('subghzTxElapsed'); + if (txElapsed) txElapsed.textContent = formatted; + } + }, 1000); + } + + function stopStatusTimer() { + if (statusTimer) { + clearInterval(statusTimer); + statusTimer = null; + } + rxStartTime = null; + const timer = document.getElementById('subghzStatusTimer'); + if (timer) timer.textContent = ''; + const stripTimer = document.getElementById('subghzStripTimer'); + if (stripTimer) stripTimer.textContent = ''; + } + + // ------ RECEIVE ------ + function startRx() { const params = getParams(); fetch('/subghz/receive/start', { @@ -782,12 +782,12 @@ const SubGhz = (function() { setTimeout(() => updatePhaseIndicator('listening'), 500); } else { addConsoleEntry(data.message || 'Failed to start capture', 'error'); - alert(data.message || 'Failed to start capture'); - } - }) - .catch(err => alert('Error: ' + err.message)); - } - + alert(data.message || 'Failed to start capture'); + } + }) + .catch(err => alert('Error: ' + err.message)); + } + function stopRx() { fetch('/subghz/receive/stop', { method: 'POST' }) .then(r => r.json()) @@ -801,19 +801,19 @@ const SubGhz = (function() { }) .catch(err => alert('Error: ' + err.message)); } - - // ------ DECODE ------ - - function startDecode() { - const params = getParams(); - clearDecodeOutput(); - fetch('/subghz/decode/start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(params), - }) - .then(r => r.json()) - .then(data => { + + // ------ DECODE ------ + + function startDecode() { + const params = getParams(); + clearDecodeOutput(); + fetch('/subghz/decode/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), + }) + .then(r => r.json()) + .then(data => { if (data.status === 'started') { updateStatusUI({ mode: 'decode' }); showPanel('decode'); @@ -832,13 +832,13 @@ const SubGhz = (function() { updatePhaseIndicator('tuning'); setTimeout(() => updatePhaseIndicator('listening'), 800); } else { - addConsoleEntry(data.message || 'Failed to start decode', 'error'); - alert(data.message || 'Failed to start decode'); - } - }) - .catch(err => alert('Error: ' + err.message)); - } - + addConsoleEntry(data.message || 'Failed to start decode', 'error'); + alert(data.message || 'Failed to start decode'); + } + }) + .catch(err => alert('Error: ' + err.message)); + } + function stopDecode() { fetch('/subghz/decode/stop', { method: 'POST' }) .then(r => r.json()) @@ -850,7 +850,7 @@ const SubGhz = (function() { }) .catch(err => alert('Error: ' + err.message)); } - + function clearDecodeOutput() { const el = document.getElementById('subghzDecodeOutput'); if (el) el.innerHTML = '
Waiting for signals...
'; @@ -858,15 +858,15 @@ const SubGhz = (function() { lastRawLineTs = 0; lastBurstLineTs = 0; } - + function appendDecodeEntry(data) { const el = document.getElementById('subghzDecodeOutput'); if (!el) return; - - // Remove empty placeholder - const empty = el.querySelector('.subghz-empty'); - if (empty) empty.remove(); - + + // Remove empty placeholder + const empty = el.querySelector('.subghz-empty'); + if (empty) empty.remove(); + const entry = document.createElement('div'); entry.className = 'subghz-decode-entry'; const model = data.model || 'Unknown'; @@ -898,12 +898,12 @@ const SubGhz = (function() { entry.innerHTML = html; el.appendChild(entry); el.scrollTop = el.scrollHeight; - - while (el.children.length > 200) { - el.removeChild(el.firstChild); - } - - // Dashboard updates + + while (el.children.length > 200) { + el.removeChild(el.firstChild); + } + + // Dashboard updates if (!isRaw) { signalCount++; updateStatsStrip('decode'); @@ -1021,7 +1021,7 @@ const SubGhz = (function() { }); } } - + // ------ TRANSMIT ------ function estimateCaptureDurationSeconds(capture) { @@ -1542,7 +1542,7 @@ const SubGhz = (function() { } }); } - + function stopTx() { fetch('/subghz/transmit/stop', { method: 'POST' }) .then(r => r.json()) @@ -1601,376 +1601,376 @@ const SubGhz = (function() { } : null); transmitWithBody(body, 'Replaying last selected segment...', 'info'); } - - // ------ SWEEP ------ - - function startSweep() { - const startMhz = parseFloat(document.getElementById('subghzSweepStart')?.value || '300'); - const endMhz = parseFloat(document.getElementById('subghzSweepEnd')?.value || '928'); - const serial = (document.getElementById('subghzDeviceSerial')?.value || '').trim(); - - sweepData = []; - showPanel('sweep'); - initSweepCanvas(); - - const body = { - freq_start_mhz: startMhz, - freq_end_mhz: endMhz, - }; - if (serial) body.device_serial = serial; - - fetch('/subghz/sweep/start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - .then(r => r.json()) - .then(data => { - if (data.status === 'started') { - updateStatusUI({ mode: 'sweep' }); - showConsole(); - addConsoleEntry('Sweep ' + startMhz + ' - ' + endMhz + ' MHz', 'info'); - updatePhaseIndicator('tuning'); - setTimeout(() => updatePhaseIndicator('listening'), 300); - } else { - addConsoleEntry(data.message || 'Failed to start sweep', 'error'); - alert(data.message || 'Failed to start sweep'); - } - }) - .catch(err => alert('Error: ' + err.message)); - } - - function stopSweep() { - fetch('/subghz/sweep/stop', { method: 'POST' }) - .then(r => r.json()) - .then(() => { - updateStatusUI({ mode: 'idle' }); - addConsoleEntry('Sweep stopped', 'warn'); - updatePhaseIndicator(null); - }) - .catch(err => alert('Error: ' + err.message)); - } - - function initSweepCanvas() { - sweepCanvas = document.getElementById('subghzSweepCanvas'); - if (!sweepCanvas) return; - sweepCtx = sweepCanvas.getContext('2d'); - resizeSweepCanvas(); - bindSweepInteraction(); - - if (!sweepResizeObserver && sweepCanvas.parentElement) { - sweepResizeObserver = new ResizeObserver(() => { - resizeSweepCanvas(); - drawSweepChart(); - }); - sweepResizeObserver.observe(sweepCanvas.parentElement); - } - } - - function resizeSweepCanvas() { - if (!sweepCanvas || !sweepCanvas.parentElement) return; - const rect = sweepCanvas.parentElement.getBoundingClientRect(); - sweepCanvas.width = rect.width - 24; - sweepCanvas.height = rect.height - 24; - } - - function updateSweepChart(points) { - for (const pt of points) { - const idx = sweepData.findIndex(d => Math.abs(d.freq - pt.freq) < 0.01); - if (idx >= 0) { - sweepData[idx].power = pt.power; - } else { - sweepData.push(pt); - } - } - sweepData.sort((a, b) => a.freq - b.freq); - - detectPeaks(); - drawSweepChart(); - } - - function detectPeaks() { - if (sweepData.length < 5) { sweepPeaks = []; return; } - const now = Date.now(); - const candidates = []; - - for (let i = 2; i < sweepData.length - 2; i++) { - const p = sweepData[i].power; - if (p > sweepData[i - 1].power && p > sweepData[i + 1].power && - p > sweepData[i - 2].power && p > sweepData[i + 2].power) { - let leftMin = p, rightMin = p; - for (let j = 1; j <= 20 && i - j >= 0; j++) leftMin = Math.min(leftMin, sweepData[i - j].power); - for (let j = 1; j <= 20 && i + j < sweepData.length; j++) rightMin = Math.min(rightMin, sweepData[i + j].power); - const prominence = p - Math.max(leftMin, rightMin); - if (prominence >= 10) { - candidates.push({ freq: sweepData[i].freq, power: p, prominence }); - } - } - } - - candidates.sort((a, b) => b.power - a.power); - sweepPeaks = candidates.slice(0, 10); - - for (const peak of sweepPeaks) { - const existing = sweepPeakHold.find(h => Math.abs(h.freq - peak.freq) < 0.5); - if (existing) { - if (peak.power >= existing.power) { - existing.power = peak.power; - existing.ts = now; - } - } else { - sweepPeakHold.push({ freq: peak.freq, power: peak.power, ts: now }); - } - } - - sweepPeakHold = sweepPeakHold.filter(h => now - h.ts < 5000); - updatePeakList(); - } - - function drawSweepChart() { - if (!sweepCtx || !sweepCanvas || sweepData.length < 2) return; - - const ctx = sweepCtx; - const w = sweepCanvas.width; - const h = sweepCanvas.height; - const pad = SWEEP_PAD; - - ctx.clearRect(0, 0, w, h); - - ctx.fillStyle = '#0d1117'; - ctx.fillRect(0, 0, w, h); - - const freqMin = sweepData[0].freq; - const freqMax = sweepData[sweepData.length - 1].freq; - const powerMin = SWEEP_POWER_MIN; - const powerMax = SWEEP_POWER_MAX; - - const chartW = w - pad.left - pad.right; - const chartH = h - pad.top - pad.bottom; - - const freqToX = f => pad.left + ((f - freqMin) / (freqMax - freqMin)) * chartW; - const powerToY = p => pad.top + chartH - ((p - powerMin) / (powerMax - powerMin)) * chartH; - - // Grid - ctx.strokeStyle = '#1a1f2e'; - ctx.lineWidth = 1; - ctx.font = '10px Roboto Condensed, monospace'; - ctx.fillStyle = '#666'; - - for (let db = powerMin; db <= powerMax; db += 20) { - const y = powerToY(db); - ctx.beginPath(); - ctx.moveTo(pad.left, y); - ctx.lineTo(w - pad.right, y); - ctx.stroke(); - ctx.fillText(db + ' dB', 4, y + 3); - } - - const freqRange = freqMax - freqMin; - const freqStep = freqRange > 500 ? 100 : freqRange > 200 ? 50 : freqRange > 50 ? 10 : 5; - for (let f = Math.ceil(freqMin / freqStep) * freqStep; f <= freqMax; f += freqStep) { - const x = freqToX(f); - ctx.beginPath(); - ctx.moveTo(x, pad.top); - ctx.lineTo(x, h - pad.bottom); - ctx.stroke(); - ctx.fillText(f + '', x - 10, h - 8); - } - - // Spectrum line - ctx.beginPath(); - ctx.strokeStyle = '#00d4ff'; - ctx.lineWidth = 1.5; - - for (let i = 0; i < sweepData.length; i++) { - const x = freqToX(sweepData[i].freq); - const y = powerToY(sweepData[i].power); - if (i === 0) ctx.moveTo(x, y); - else ctx.lineTo(x, y); - } - ctx.stroke(); - - // Fill under curve - ctx.lineTo(freqToX(freqMax), powerToY(powerMin)); - ctx.lineTo(freqToX(freqMin), powerToY(powerMin)); - ctx.closePath(); - ctx.fillStyle = 'rgba(0, 212, 255, 0.05)'; - ctx.fill(); - - // Peak hold dashes - const now = Date.now(); - ctx.strokeStyle = 'rgba(255, 170, 0, 0.4)'; - ctx.lineWidth = 2; - for (const hold of sweepPeakHold) { - const age = (now - hold.ts) / 5000; - ctx.globalAlpha = 1 - age; - const x = freqToX(hold.freq); - const y = powerToY(hold.power); - ctx.beginPath(); - ctx.moveTo(x - 6, y); - ctx.lineTo(x + 6, y); - ctx.stroke(); - } - ctx.globalAlpha = 1; - - // Peak markers - for (const peak of sweepPeaks) { - const x = freqToX(peak.freq); - const y = powerToY(peak.power); - ctx.fillStyle = '#ffaa00'; - ctx.beginPath(); - ctx.moveTo(x, y - 8); - ctx.lineTo(x - 4, y - 2); - ctx.lineTo(x + 4, y - 2); - ctx.closePath(); - ctx.fill(); - ctx.font = '9px Roboto Condensed, monospace'; - ctx.fillStyle = 'rgba(255, 170, 0, 0.8)'; - ctx.textAlign = 'center'; - ctx.fillText(peak.freq.toFixed(1), x, y - 10); - } - ctx.textAlign = 'start'; - - // Active frequency marker - const activeFreq = parseFloat(document.getElementById('subghzFrequency')?.value); - if (activeFreq && activeFreq >= freqMin && activeFreq <= freqMax) { - const x = freqToX(activeFreq); - ctx.save(); - ctx.setLineDash([6, 4]); - ctx.strokeStyle = 'rgba(0, 255, 136, 0.6)'; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(x, pad.top); - ctx.lineTo(x, h - pad.bottom); - ctx.stroke(); - ctx.restore(); - } - - // Selected frequency marker - if (sweepSelectedFreq !== null && sweepSelectedFreq >= freqMin && sweepSelectedFreq <= freqMax) { - const x = freqToX(sweepSelectedFreq); - ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)'; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(x, pad.top); - ctx.lineTo(x, h - pad.bottom); - ctx.stroke(); - } - - // Hover cursor line - if (sweepHoverFreq !== null && sweepHoverFreq >= freqMin && sweepHoverFreq <= freqMax) { - const x = freqToX(sweepHoverFreq); - ctx.save(); - ctx.setLineDash([3, 3]); - ctx.strokeStyle = 'rgba(255, 255, 255, 0.25)'; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(x, pad.top); - ctx.lineTo(x, h - pad.bottom); - ctx.stroke(); - ctx.restore(); - } - } - - // ------ SWEEP INTERACTION ------ - - function bindSweepInteraction() { - if (sweepInteractionBound || !sweepCanvas) return; - sweepInteractionBound = true; - sweepCanvas.style.cursor = 'crosshair'; - - if (!sweepTooltipEl) { - sweepTooltipEl = document.createElement('div'); - sweepTooltipEl.className = 'subghz-sweep-tooltip'; - document.body.appendChild(sweepTooltipEl); - } - - if (!sweepCtxMenuEl) { - sweepCtxMenuEl = document.createElement('div'); - sweepCtxMenuEl.className = 'subghz-sweep-ctx-menu'; - document.body.appendChild(sweepCtxMenuEl); - } - - sweepDismissHandler = (e) => { - if (sweepCtxMenuEl && !sweepCtxMenuEl.contains(e.target)) { - sweepCtxMenuEl.style.display = 'none'; - } - if (sweepActionBarEl && !sweepActionBarEl.contains(e.target) && e.target !== sweepCanvas) { - sweepActionBarEl.classList.remove('visible'); - } - }; - document.addEventListener('click', sweepDismissHandler); - - function mouseToCanvas(e) { - const rect = sweepCanvas.getBoundingClientRect(); - const scaleX = sweepCanvas.width / rect.width; - const scaleY = sweepCanvas.height / rect.height; - return { - x: (e.clientX - rect.left) * scaleX, - y: (e.clientY - rect.top) * scaleY, - }; - } - - sweepCanvas.addEventListener('mousemove', (e) => { - const { x, y } = mouseToCanvas(e); - const info = sweepPixelToFreqPower(x, y); - if (!info.inChart || sweepData.length < 2) { - sweepHoverFreq = null; - sweepHoverPower = null; - if (sweepTooltipEl) sweepTooltipEl.style.display = 'none'; - drawSweepChart(); - return; - } - sweepHoverFreq = info.freq; - sweepHoverPower = interpolatePower(info.freq); - if (sweepTooltipEl) { - sweepTooltipEl.innerHTML = - '' + sweepHoverFreq.toFixed(3) + ' MHz' + - ' · ' + - '' + sweepHoverPower.toFixed(1) + ' dB'; - sweepTooltipEl.style.left = (e.clientX + 14) + 'px'; - sweepTooltipEl.style.top = (e.clientY - 30) + 'px'; - sweepTooltipEl.style.display = 'block'; - } - drawSweepChart(); - }); - - sweepCanvas.addEventListener('mouseleave', () => { - sweepHoverFreq = null; - sweepHoverPower = null; - if (sweepTooltipEl) sweepTooltipEl.style.display = 'none'; - drawSweepChart(); - }); - - sweepCanvas.addEventListener('click', (e) => { - const { x, y } = mouseToCanvas(e); - const info = sweepPixelToFreqPower(x, y); - if (!info.inChart || sweepData.length < 2) return; - sweepSelectedFreq = info.freq; - tuneFromSweep(info.freq); - showSweepActionBar(e.clientX, e.clientY, info.freq); - drawSweepChart(); - }); - - sweepCanvas.addEventListener('contextmenu', (e) => { - e.preventDefault(); - const { x, y } = mouseToCanvas(e); - const info = sweepPixelToFreqPower(x, y); - if (!info.inChart || sweepData.length < 2) return; - const freq = info.freq; - const freqStr = freq.toFixed(3); - + + // ------ SWEEP ------ + + function startSweep() { + const startMhz = parseFloat(document.getElementById('subghzSweepStart')?.value || '300'); + const endMhz = parseFloat(document.getElementById('subghzSweepEnd')?.value || '928'); + const serial = (document.getElementById('subghzDeviceSerial')?.value || '').trim(); + + sweepData = []; + showPanel('sweep'); + initSweepCanvas(); + + const body = { + freq_start_mhz: startMhz, + freq_end_mhz: endMhz, + }; + if (serial) body.device_serial = serial; + + fetch('/subghz/sweep/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + .then(r => r.json()) + .then(data => { + if (data.status === 'started') { + updateStatusUI({ mode: 'sweep' }); + showConsole(); + addConsoleEntry('Sweep ' + startMhz + ' - ' + endMhz + ' MHz', 'info'); + updatePhaseIndicator('tuning'); + setTimeout(() => updatePhaseIndicator('listening'), 300); + } else { + addConsoleEntry(data.message || 'Failed to start sweep', 'error'); + alert(data.message || 'Failed to start sweep'); + } + }) + .catch(err => alert('Error: ' + err.message)); + } + + function stopSweep() { + fetch('/subghz/sweep/stop', { method: 'POST' }) + .then(r => r.json()) + .then(() => { + updateStatusUI({ mode: 'idle' }); + addConsoleEntry('Sweep stopped', 'warn'); + updatePhaseIndicator(null); + }) + .catch(err => alert('Error: ' + err.message)); + } + + function initSweepCanvas() { + sweepCanvas = document.getElementById('subghzSweepCanvas'); + if (!sweepCanvas) return; + sweepCtx = sweepCanvas.getContext('2d'); + resizeSweepCanvas(); + bindSweepInteraction(); + + if (!sweepResizeObserver && sweepCanvas.parentElement) { + sweepResizeObserver = new ResizeObserver(() => { + resizeSweepCanvas(); + drawSweepChart(); + }); + sweepResizeObserver.observe(sweepCanvas.parentElement); + } + } + + function resizeSweepCanvas() { + if (!sweepCanvas || !sweepCanvas.parentElement) return; + const rect = sweepCanvas.parentElement.getBoundingClientRect(); + sweepCanvas.width = rect.width - 24; + sweepCanvas.height = rect.height - 24; + } + + function updateSweepChart(points) { + for (const pt of points) { + const idx = sweepData.findIndex(d => Math.abs(d.freq - pt.freq) < 0.01); + if (idx >= 0) { + sweepData[idx].power = pt.power; + } else { + sweepData.push(pt); + } + } + sweepData.sort((a, b) => a.freq - b.freq); + + detectPeaks(); + drawSweepChart(); + } + + function detectPeaks() { + if (sweepData.length < 5) { sweepPeaks = []; return; } + const now = Date.now(); + const candidates = []; + + for (let i = 2; i < sweepData.length - 2; i++) { + const p = sweepData[i].power; + if (p > sweepData[i - 1].power && p > sweepData[i + 1].power && + p > sweepData[i - 2].power && p > sweepData[i + 2].power) { + let leftMin = p, rightMin = p; + for (let j = 1; j <= 20 && i - j >= 0; j++) leftMin = Math.min(leftMin, sweepData[i - j].power); + for (let j = 1; j <= 20 && i + j < sweepData.length; j++) rightMin = Math.min(rightMin, sweepData[i + j].power); + const prominence = p - Math.max(leftMin, rightMin); + if (prominence >= 10) { + candidates.push({ freq: sweepData[i].freq, power: p, prominence }); + } + } + } + + candidates.sort((a, b) => b.power - a.power); + sweepPeaks = candidates.slice(0, 10); + + for (const peak of sweepPeaks) { + const existing = sweepPeakHold.find(h => Math.abs(h.freq - peak.freq) < 0.5); + if (existing) { + if (peak.power >= existing.power) { + existing.power = peak.power; + existing.ts = now; + } + } else { + sweepPeakHold.push({ freq: peak.freq, power: peak.power, ts: now }); + } + } + + sweepPeakHold = sweepPeakHold.filter(h => now - h.ts < 5000); + updatePeakList(); + } + + function drawSweepChart() { + if (!sweepCtx || !sweepCanvas || sweepData.length < 2) return; + + const ctx = sweepCtx; + const w = sweepCanvas.width; + const h = sweepCanvas.height; + const pad = SWEEP_PAD; + + ctx.clearRect(0, 0, w, h); + + ctx.fillStyle = '#0d1117'; + ctx.fillRect(0, 0, w, h); + + const freqMin = sweepData[0].freq; + const freqMax = sweepData[sweepData.length - 1].freq; + const powerMin = SWEEP_POWER_MIN; + const powerMax = SWEEP_POWER_MAX; + + const chartW = w - pad.left - pad.right; + const chartH = h - pad.top - pad.bottom; + + const freqToX = f => pad.left + ((f - freqMin) / (freqMax - freqMin)) * chartW; + const powerToY = p => pad.top + chartH - ((p - powerMin) / (powerMax - powerMin)) * chartH; + + // Grid + ctx.strokeStyle = '#1a1f2e'; + ctx.lineWidth = 1; + ctx.font = '10px Roboto Condensed, monospace'; + ctx.fillStyle = '#666'; + + for (let db = powerMin; db <= powerMax; db += 20) { + const y = powerToY(db); + ctx.beginPath(); + ctx.moveTo(pad.left, y); + ctx.lineTo(w - pad.right, y); + ctx.stroke(); + ctx.fillText(db + ' dB', 4, y + 3); + } + + const freqRange = freqMax - freqMin; + const freqStep = freqRange > 500 ? 100 : freqRange > 200 ? 50 : freqRange > 50 ? 10 : 5; + for (let f = Math.ceil(freqMin / freqStep) * freqStep; f <= freqMax; f += freqStep) { + const x = freqToX(f); + ctx.beginPath(); + ctx.moveTo(x, pad.top); + ctx.lineTo(x, h - pad.bottom); + ctx.stroke(); + ctx.fillText(f + '', x - 10, h - 8); + } + + // Spectrum line + ctx.beginPath(); + ctx.strokeStyle = '#00d4ff'; + ctx.lineWidth = 1.5; + + for (let i = 0; i < sweepData.length; i++) { + const x = freqToX(sweepData[i].freq); + const y = powerToY(sweepData[i].power); + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + + // Fill under curve + ctx.lineTo(freqToX(freqMax), powerToY(powerMin)); + ctx.lineTo(freqToX(freqMin), powerToY(powerMin)); + ctx.closePath(); + ctx.fillStyle = 'rgba(0, 212, 255, 0.05)'; + ctx.fill(); + + // Peak hold dashes + const now = Date.now(); + ctx.strokeStyle = 'rgba(255, 170, 0, 0.4)'; + ctx.lineWidth = 2; + for (const hold of sweepPeakHold) { + const age = (now - hold.ts) / 5000; + ctx.globalAlpha = 1 - age; + const x = freqToX(hold.freq); + const y = powerToY(hold.power); + ctx.beginPath(); + ctx.moveTo(x - 6, y); + ctx.lineTo(x + 6, y); + ctx.stroke(); + } + ctx.globalAlpha = 1; + + // Peak markers + for (const peak of sweepPeaks) { + const x = freqToX(peak.freq); + const y = powerToY(peak.power); + ctx.fillStyle = '#ffaa00'; + ctx.beginPath(); + ctx.moveTo(x, y - 8); + ctx.lineTo(x - 4, y - 2); + ctx.lineTo(x + 4, y - 2); + ctx.closePath(); + ctx.fill(); + ctx.font = '9px Roboto Condensed, monospace'; + ctx.fillStyle = 'rgba(255, 170, 0, 0.8)'; + ctx.textAlign = 'center'; + ctx.fillText(peak.freq.toFixed(1), x, y - 10); + } + ctx.textAlign = 'start'; + + // Active frequency marker + const activeFreq = parseFloat(document.getElementById('subghzFrequency')?.value); + if (activeFreq && activeFreq >= freqMin && activeFreq <= freqMax) { + const x = freqToX(activeFreq); + ctx.save(); + ctx.setLineDash([6, 4]); + ctx.strokeStyle = 'rgba(0, 255, 136, 0.6)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(x, pad.top); + ctx.lineTo(x, h - pad.bottom); + ctx.stroke(); + ctx.restore(); + } + + // Selected frequency marker + if (sweepSelectedFreq !== null && sweepSelectedFreq >= freqMin && sweepSelectedFreq <= freqMax) { + const x = freqToX(sweepSelectedFreq); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(x, pad.top); + ctx.lineTo(x, h - pad.bottom); + ctx.stroke(); + } + + // Hover cursor line + if (sweepHoverFreq !== null && sweepHoverFreq >= freqMin && sweepHoverFreq <= freqMax) { + const x = freqToX(sweepHoverFreq); + ctx.save(); + ctx.setLineDash([3, 3]); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.25)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(x, pad.top); + ctx.lineTo(x, h - pad.bottom); + ctx.stroke(); + ctx.restore(); + } + } + + // ------ SWEEP INTERACTION ------ + + function bindSweepInteraction() { + if (sweepInteractionBound || !sweepCanvas) return; + sweepInteractionBound = true; + sweepCanvas.style.cursor = 'crosshair'; + + if (!sweepTooltipEl) { + sweepTooltipEl = document.createElement('div'); + sweepTooltipEl.className = 'subghz-sweep-tooltip'; + document.body.appendChild(sweepTooltipEl); + } + + if (!sweepCtxMenuEl) { + sweepCtxMenuEl = document.createElement('div'); + sweepCtxMenuEl.className = 'subghz-sweep-ctx-menu'; + document.body.appendChild(sweepCtxMenuEl); + } + + sweepDismissHandler = (e) => { + if (sweepCtxMenuEl && !sweepCtxMenuEl.contains(e.target)) { + sweepCtxMenuEl.style.display = 'none'; + } + if (sweepActionBarEl && !sweepActionBarEl.contains(e.target) && e.target !== sweepCanvas) { + sweepActionBarEl.classList.remove('visible'); + } + }; + document.addEventListener('click', sweepDismissHandler); + + function mouseToCanvas(e) { + const rect = sweepCanvas.getBoundingClientRect(); + const scaleX = sweepCanvas.width / rect.width; + const scaleY = sweepCanvas.height / rect.height; + return { + x: (e.clientX - rect.left) * scaleX, + y: (e.clientY - rect.top) * scaleY, + }; + } + + sweepCanvas.addEventListener('mousemove', (e) => { + const { x, y } = mouseToCanvas(e); + const info = sweepPixelToFreqPower(x, y); + if (!info.inChart || sweepData.length < 2) { + sweepHoverFreq = null; + sweepHoverPower = null; + if (sweepTooltipEl) sweepTooltipEl.style.display = 'none'; + drawSweepChart(); + return; + } + sweepHoverFreq = info.freq; + sweepHoverPower = interpolatePower(info.freq); + if (sweepTooltipEl) { + sweepTooltipEl.innerHTML = + '' + sweepHoverFreq.toFixed(3) + ' MHz' + + ' · ' + + '' + sweepHoverPower.toFixed(1) + ' dB'; + sweepTooltipEl.style.left = (e.clientX + 14) + 'px'; + sweepTooltipEl.style.top = (e.clientY - 30) + 'px'; + sweepTooltipEl.style.display = 'block'; + } + drawSweepChart(); + }); + + sweepCanvas.addEventListener('mouseleave', () => { + sweepHoverFreq = null; + sweepHoverPower = null; + if (sweepTooltipEl) sweepTooltipEl.style.display = 'none'; + drawSweepChart(); + }); + + sweepCanvas.addEventListener('click', (e) => { + const { x, y } = mouseToCanvas(e); + const info = sweepPixelToFreqPower(x, y); + if (!info.inChart || sweepData.length < 2) return; + sweepSelectedFreq = info.freq; + tuneFromSweep(info.freq); + showSweepActionBar(e.clientX, e.clientY, info.freq); + drawSweepChart(); + }); + + sweepCanvas.addEventListener('contextmenu', (e) => { + e.preventDefault(); + const { x, y } = mouseToCanvas(e); + const info = sweepPixelToFreqPower(x, y); + if (!info.inChart || sweepData.length < 2) return; + const freq = info.freq; + const freqStr = freq.toFixed(3); + sweepCtxMenuEl.innerHTML = '
' + freqStr + ' MHz
' + '
Tune Here
' + '
Open RAW at ' + freqStr + ' MHz
'; - - sweepCtxMenuEl.style.left = e.clientX + 'px'; - sweepCtxMenuEl.style.top = e.clientY + 'px'; - sweepCtxMenuEl.style.display = 'block'; - - sweepCtxMenuEl.querySelectorAll('.subghz-ctx-item').forEach(item => { - item.onclick = () => { + + sweepCtxMenuEl.style.left = e.clientX + 'px'; + sweepCtxMenuEl.style.top = e.clientY + 'px'; + sweepCtxMenuEl.style.display = 'block'; + + sweepCtxMenuEl.querySelectorAll('.subghz-ctx-item').forEach(item => { + item.onclick = () => { sweepCtxMenuEl.style.display = 'none'; const action = item.dataset.action; if (action === 'tune') tuneFromSweep(freq); @@ -1979,16 +1979,16 @@ const SubGhz = (function() { }); }); } - - // ------ SWEEP ACTIONS ------ - - function tuneFromSweep(freqMhz) { - const el = document.getElementById('subghzFrequency'); - if (el) el.value = freqMhz.toFixed(3); - sweepSelectedFreq = freqMhz; - drawSweepChart(); - } - + + // ------ SWEEP ACTIONS ------ + + function tuneFromSweep(freqMhz) { + const el = document.getElementById('subghzFrequency'); + if (el) el.value = freqMhz.toFixed(3); + sweepSelectedFreq = freqMhz; + drawSweepChart(); + } + function tuneAndCapture(freqMhz) { tuneFromSweep(freqMhz); stopSweep(); @@ -2000,66 +2000,66 @@ const SubGhz = (function() { addConsoleEntry('Tuned to ' + freqMhz.toFixed(3) + ' MHz. Press Start to capture RAW.', 'info'); }, 300); } - - // ------ FLOATING ACTION BAR ------ - - function showSweepActionBar(clientX, clientY, freqMhz) { - if (!sweepActionBarEl) { - sweepActionBarEl = document.createElement('div'); - sweepActionBarEl.className = 'subghz-sweep-action-bar'; - document.body.appendChild(sweepActionBarEl); - } - + + // ------ FLOATING ACTION BAR ------ + + function showSweepActionBar(clientX, clientY, freqMhz) { + if (!sweepActionBarEl) { + sweepActionBarEl = document.createElement('div'); + sweepActionBarEl.className = 'subghz-sweep-action-bar'; + document.body.appendChild(sweepActionBarEl); + } + sweepActionBarEl.innerHTML = '' + ''; - - sweepActionBarEl.querySelector('.tune').onclick = (e) => { - e.stopPropagation(); - tuneFromSweep(freqMhz); - hideSweepActionBar(); - }; + + sweepActionBarEl.querySelector('.tune').onclick = (e) => { + e.stopPropagation(); + tuneFromSweep(freqMhz); + hideSweepActionBar(); + }; sweepActionBarEl.querySelector('.capture').onclick = (e) => { e.stopPropagation(); tuneAndCapture(freqMhz); }; - - sweepActionBarEl.style.left = (clientX + 10) + 'px'; - sweepActionBarEl.style.top = (clientY + 14) + 'px'; - sweepActionBarEl.classList.remove('visible'); - void sweepActionBarEl.offsetHeight; - sweepActionBarEl.classList.add('visible'); - } - - function hideSweepActionBar() { - if (sweepActionBarEl) sweepActionBarEl.classList.remove('visible'); - } - - // ------ PEAK LIST ------ - - function updatePeakList() { - // Update sidebar, sweep panel, and any other peak lists - const lists = [ - document.getElementById('subghzPeakList'), - document.getElementById('subghzSweepPeakList'), - ]; - for (const list of lists) { - if (!list) continue; - list.innerHTML = ''; - for (const peak of sweepPeaks) { - const item = document.createElement('div'); - item.className = 'subghz-peak-item'; - item.innerHTML = - '' + peak.freq.toFixed(3) + ' MHz' + - '' + peak.power.toFixed(1) + ' dB'; - item.onclick = () => tuneFromSweep(peak.freq); - list.appendChild(item); - } - } - } - - // ------ CAPTURES LIBRARY ------ - + + sweepActionBarEl.style.left = (clientX + 10) + 'px'; + sweepActionBarEl.style.top = (clientY + 14) + 'px'; + sweepActionBarEl.classList.remove('visible'); + void sweepActionBarEl.offsetHeight; + sweepActionBarEl.classList.add('visible'); + } + + function hideSweepActionBar() { + if (sweepActionBarEl) sweepActionBarEl.classList.remove('visible'); + } + + // ------ PEAK LIST ------ + + function updatePeakList() { + // Update sidebar, sweep panel, and any other peak lists + const lists = [ + document.getElementById('subghzPeakList'), + document.getElementById('subghzSweepPeakList'), + ]; + for (const list of lists) { + if (!list) continue; + list.innerHTML = ''; + for (const peak of sweepPeaks) { + const item = document.createElement('div'); + item.className = 'subghz-peak-item'; + item.innerHTML = + '' + peak.freq.toFixed(3) + ' MHz' + + '' + peak.power.toFixed(1) + ' dB'; + item.onclick = () => tuneFromSweep(peak.freq); + list.appendChild(item); + } + } + } + + // ------ CAPTURES LIBRARY ------ + function loadCaptures() { fetch('/subghz/captures') .then(r => r.json()) @@ -2118,13 +2118,13 @@ const SubGhz = (function() { // Clear existing cards list.querySelectorAll('.subghz-capture-card').forEach(c => c.remove()); - if (captures.length === 0) { - if (empty) empty.style.display = ''; - continue; - } - - if (empty) empty.style.display = 'none'; - + if (captures.length === 0) { + if (empty) empty.style.display = ''; + continue; + } + + if (empty) empty.style.display = 'none'; + for (const cap of captures) { const freqMhz = (cap.frequency_hz / 1000000).toFixed(3); const sizeKb = (cap.size_bytes / 1024).toFixed(1); @@ -2237,10 +2237,16 @@ const SubGhz = (function() { renderCaptures(latestCaptures); } - function deleteSelectedCaptures() { + async function deleteSelectedCaptures() { if (!captureSelectMode || selectedCaptureIds.size === 0) return; const ids = [...selectedCaptureIds]; - if (!confirm(`Delete ${ids.length} selected capture${ids.length === 1 ? '' : 's'}?`)) return; + const confirmed = await AppFeedback.confirmAction({ + title: 'Delete Captures', + message: `Delete ${ids.length} selected capture${ids.length === 1 ? '' : 's'}? This cannot be undone.`, + confirmLabel: 'Delete', + confirmClass: 'btn-danger' + }); + if (!confirmed) return; Promise.all( ids.map(id => fetch(`/subghz/captures/${encodeURIComponent(id)}`, { method: 'DELETE' })) @@ -2254,61 +2260,67 @@ const SubGhz = (function() { .catch(err => alert('Error deleting captures: ' + err.message)); } - function deleteCapture(id) { - if (!confirm('Delete this capture?')) return; + async function deleteCapture(id) { + const confirmed = await AppFeedback.confirmAction({ + title: 'Delete Capture', + message: 'Delete this capture? This cannot be undone.', + confirmLabel: 'Delete', + confirmClass: 'btn-danger' + }); + if (!confirmed) return; fetch(`/subghz/captures/${encodeURIComponent(id)}`, { method: 'DELETE' }) .then(r => r.json()) - .then(() => loadCaptures()) - .catch(err => alert('Error: ' + err.message)); - } - - function renameCapture(id) { - const label = prompt('Enter label for this capture:'); - if (label === null) return; - fetch(`/subghz/captures/${encodeURIComponent(id)}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ label: label }), - }) - .then(r => r.json()) - .then(() => loadCaptures()) - .catch(err => alert('Error: ' + err.message)); - } - - function downloadCapture(id) { - window.open(`/subghz/captures/${encodeURIComponent(id)}/download`, '_blank'); - } - - // ------ SSE STREAM ------ - - function startStream() { - if (eventSource) { - eventSource.close(); - } - - eventSource = new EventSource('/subghz/stream'); - - eventSource.onmessage = function(e) { - try { - const data = JSON.parse(e.data); - handleEvent(data); - } catch (err) { - // Ignore parse errors (keepalives etc.) - } - }; - - eventSource.onerror = function() { - setTimeout(() => { - if (document.getElementById('subghzMode')?.classList.contains('active')) { - startStream(); - } - }, 3000); - }; - } - - function handleEvent(data) { - const type = data.type; - + .then(() => loadCaptures()) + .catch(err => alert('Error: ' + err.message)); + } + + function renameCapture(id) { + const label = prompt('Enter label for this capture:'); + if (label === null) return; + fetch(`/subghz/captures/${encodeURIComponent(id)}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ label: label }), + }) + .then(r => r.json()) + .then(() => loadCaptures()) + .catch(err => alert('Error: ' + err.message)); + } + + function downloadCapture(id) { + window.open(`/subghz/captures/${encodeURIComponent(id)}/download`, '_blank'); + } + + // ------ SSE STREAM ------ + + function startStream() { + if (eventSource) { + eventSource.close(); + } + + eventSource = new EventSource('/subghz/stream'); + + eventSource.onmessage = function(e) { + try { + const data = JSON.parse(e.data); + handleEvent(data); + } catch (err) { + // Ignore parse errors (keepalives etc.) + } + }; + + eventSource.onerror = function() { + setTimeout(() => { + if (document.getElementById('subghzMode')?.classList.contains('active')) { + startStream(); + } + }, 3000); + }; + } + + function handleEvent(data) { + const type = data.type; + if (type === 'status') { updateStatusUI(data); if (data.status === 'started') { @@ -2381,28 +2393,28 @@ const SubGhz = (function() { } } } else if (type === 'info') { - // rtl_433 stderr info lines - if (data.text) addConsoleEntry(data.text, 'info'); - } else if (type === 'error') { - addConsoleEntry(data.message || 'Error', 'error'); - updatePhaseIndicator('error'); - alert(data.message || 'SubGHz error'); - } - } - - // ------ UTILITIES ------ - - function escapeHtml(str) { - if (!str) return ''; - const div = document.createElement('div'); - div.textContent = str; - return div.innerHTML; - } - - /** - * Clean up when switching away from SubGHz mode - */ - function destroy() { + // rtl_433 stderr info lines + if (data.text) addConsoleEntry(data.text, 'info'); + } else if (type === 'error') { + addConsoleEntry(data.message || 'Error', 'error'); + updatePhaseIndicator('error'); + alert(data.message || 'SubGHz error'); + } + } + + // ------ UTILITIES ------ + + function escapeHtml(str) { + if (!str) return ''; + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; + } + + /** + * Clean up when switching away from SubGHz mode + */ + function destroy() { if (eventSource) { eventSource.close(); eventSource = null; @@ -2426,22 +2438,22 @@ const SubGhz = (function() { setRxBurstPill('idle', 'IDLE'); setBurstCanvasHighlight('rx', false); setBurstCanvasHighlight('decode', false); - - // Clean up interactive sweep elements - if (sweepTooltipEl) { sweepTooltipEl.remove(); sweepTooltipEl = null; } - if (sweepCtxMenuEl) { sweepCtxMenuEl.remove(); sweepCtxMenuEl = null; } - if (sweepActionBarEl) { sweepActionBarEl.remove(); sweepActionBarEl = null; } - if (sweepDismissHandler) { - document.removeEventListener('click', sweepDismissHandler); - sweepDismissHandler = null; - } + + // Clean up interactive sweep elements + if (sweepTooltipEl) { sweepTooltipEl.remove(); sweepTooltipEl = null; } + if (sweepCtxMenuEl) { sweepCtxMenuEl.remove(); sweepCtxMenuEl = null; } + if (sweepActionBarEl) { sweepActionBarEl.remove(); sweepActionBarEl = null; } + if (sweepDismissHandler) { + document.removeEventListener('click', sweepDismissHandler); + sweepDismissHandler = null; + } if (sweepResizeObserver) { sweepResizeObserver.disconnect(); sweepResizeObserver = null; } sweepInteractionBound = false; sweepHoverFreq = null; - sweepSelectedFreq = null; + sweepSelectedFreq = null; sweepPeaks = []; sweepPeakHold = []; @@ -2478,9 +2490,9 @@ const SubGhz = (function() { // Reset dashboard state activePanel = null; signalCount = 0; - captureCount = 0; - consoleEntries = []; - consoleCollapsed = false; + captureCount = 0; + consoleEntries = []; + consoleCollapsed = false; currentPhase = null; currentMode = 'idle'; lastRawLine = ''; @@ -2495,9 +2507,9 @@ const SubGhz = (function() { lastTxRequest = null; txModalIntent = 'tx'; } - - // ------ DASHBOARD: HUB & PANELS ------ - + + // ------ DASHBOARD: HUB & PANELS ------ + function showHub() { activePanel = null; const hub = document.getElementById('subghzActionHub'); @@ -2510,7 +2522,7 @@ const SubGhz = (function() { updateStatsStrip('idle'); updateStatusUI({ mode: currentMode }); } - + function showPanel(panel) { activePanel = panel; const hub = document.getElementById('subghzActionHub'); @@ -2550,9 +2562,9 @@ const SubGhz = (function() { } else if (action === 'saved') { showPanel('saved'); loadCaptures(); - } - } - + } + } + function backToHub() { // Stop any running operation if (currentMode !== 'idle') { @@ -2562,154 +2574,154 @@ const SubGhz = (function() { } showHub(); const consoleEl = document.getElementById('subghzConsole'); - if (consoleEl) consoleEl.style.display = 'none'; - updatePhaseIndicator(null); - } - + if (consoleEl) consoleEl.style.display = 'none'; + updatePhaseIndicator(null); + } + function stopActive() { if (currentMode === 'rx') stopRx(); else if (currentMode === 'sweep') stopSweep(); else if (currentMode === 'tx') stopTx(); } - - // ------ DASHBOARD: STATS STRIP ------ - - function updateStatsStrip(mode) { - const stripDot = document.getElementById('subghzStripDot'); - const stripStatus = document.getElementById('subghzStripStatus'); - const stripFreq = document.getElementById('subghzStripFreq'); - const stripMode = document.getElementById('subghzStripMode'); - const stripSignals = document.getElementById('subghzStripSignals'); - const stripCaptures = document.getElementById('subghzStripCaptures'); - - if (!mode) mode = currentMode || 'idle'; - - if (stripDot) { - stripDot.className = 'subghz-strip-dot'; - if (mode !== 'idle' && mode !== 'saved') { - stripDot.classList.add(mode, 'active'); - } - } - - const labels = { idle: 'Idle', rx: 'Capturing', decode: 'Decoding', tx: 'Transmitting', sweep: 'Sweeping', saved: 'Library' }; - if (stripStatus) stripStatus.textContent = labels[mode] || mode; - - const freqEl = document.getElementById('subghzFrequency'); - if (stripFreq && freqEl) { - stripFreq.textContent = freqEl.value || '--'; - } - - const modeLabels = { idle: '--', decode: 'READ', rx: 'RAW', sweep: 'SWEEP', tx: 'TX', saved: 'SAVED' }; - if (stripMode) stripMode.textContent = modeLabels[mode] || '--'; - - if (stripSignals) stripSignals.textContent = signalCount; - if (stripCaptures) stripCaptures.textContent = captureCount; - } - - // ------ DASHBOARD: RX DISPLAY ------ - - function updateRxDisplay(params) { - const freqEl = document.getElementById('subghzRxFreq'); - const lnaEl = document.getElementById('subghzRxLna'); - const vgaEl = document.getElementById('subghzRxVga'); - const srEl = document.getElementById('subghzRxSampleRate'); - - if (freqEl) freqEl.textContent = (params.frequency_hz / 1e6).toFixed(3) + ' MHz'; - if (lnaEl) lnaEl.textContent = params.lna_gain + ' dB'; - if (vgaEl) vgaEl.textContent = params.vga_gain + ' dB'; - if (srEl) srEl.textContent = (params.sample_rate / 1000) + ' kHz'; - } - - // ------ DASHBOARD: CONSOLE ------ - - function addConsoleEntry(msg, level) { - level = level || ''; - const now = new Date(); - const ts = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); - consoleEntries.push({ ts, msg, level }); - if (consoleEntries.length > 100) consoleEntries.shift(); - - const log = document.getElementById('subghzConsoleLog'); - if (!log) return; - - const entry = document.createElement('div'); - entry.className = 'subghz-log-entry'; - entry.innerHTML = '' + escapeHtml(ts) + '' + - '' + escapeHtml(msg) + ''; - log.appendChild(entry); - log.scrollTop = log.scrollHeight; - - while (log.children.length > 100) { - log.removeChild(log.firstChild); - } - } - - function showConsole() { - const consoleEl = document.getElementById('subghzConsole'); - if (consoleEl) consoleEl.style.display = ''; - } - - function toggleConsole() { - consoleCollapsed = !consoleCollapsed; - const body = document.getElementById('subghzConsoleBody'); - const btn = document.getElementById('subghzConsoleToggleBtn'); - if (body) body.classList.toggle('collapsed', consoleCollapsed); - if (btn) btn.classList.toggle('collapsed', consoleCollapsed); - } - - function clearConsole() { - consoleEntries = []; - const log = document.getElementById('subghzConsoleLog'); - if (log) log.innerHTML = ''; - } - - function updatePhaseIndicator(phase) { - currentPhase = phase; - const steps = ['tuning', 'listening', 'decoding']; - const phaseEls = { - tuning: document.getElementById('subghzPhaseTuning'), - listening: document.getElementById('subghzPhaseListening'), - decoding: document.getElementById('subghzPhaseDecoding'), - }; - - if (!phase) { - Object.values(phaseEls).forEach(el => { - if (el) el.className = 'subghz-phase-step'; - }); - return; - } - - if (phase === 'error') { - Object.values(phaseEls).forEach(el => { - if (el) { - el.className = 'subghz-phase-step'; - el.classList.add('error'); - } - }); - return; - } - - const activeIdx = steps.indexOf(phase); - steps.forEach((step, idx) => { - const el = phaseEls[step]; - if (!el) return; - el.className = 'subghz-phase-step'; - if (idx < activeIdx) el.classList.add('completed'); - else if (idx === activeIdx) el.classList.add('active'); - }); - } - - // ------ PUBLIC API ------ - return { + + // ------ DASHBOARD: STATS STRIP ------ + + function updateStatsStrip(mode) { + const stripDot = document.getElementById('subghzStripDot'); + const stripStatus = document.getElementById('subghzStripStatus'); + const stripFreq = document.getElementById('subghzStripFreq'); + const stripMode = document.getElementById('subghzStripMode'); + const stripSignals = document.getElementById('subghzStripSignals'); + const stripCaptures = document.getElementById('subghzStripCaptures'); + + if (!mode) mode = currentMode || 'idle'; + + if (stripDot) { + stripDot.className = 'subghz-strip-dot'; + if (mode !== 'idle' && mode !== 'saved') { + stripDot.classList.add(mode, 'active'); + } + } + + const labels = { idle: 'Idle', rx: 'Capturing', decode: 'Decoding', tx: 'Transmitting', sweep: 'Sweeping', saved: 'Library' }; + if (stripStatus) stripStatus.textContent = labels[mode] || mode; + + const freqEl = document.getElementById('subghzFrequency'); + if (stripFreq && freqEl) { + stripFreq.textContent = freqEl.value || '--'; + } + + const modeLabels = { idle: '--', decode: 'READ', rx: 'RAW', sweep: 'SWEEP', tx: 'TX', saved: 'SAVED' }; + if (stripMode) stripMode.textContent = modeLabels[mode] || '--'; + + if (stripSignals) stripSignals.textContent = signalCount; + if (stripCaptures) stripCaptures.textContent = captureCount; + } + + // ------ DASHBOARD: RX DISPLAY ------ + + function updateRxDisplay(params) { + const freqEl = document.getElementById('subghzRxFreq'); + const lnaEl = document.getElementById('subghzRxLna'); + const vgaEl = document.getElementById('subghzRxVga'); + const srEl = document.getElementById('subghzRxSampleRate'); + + if (freqEl) freqEl.textContent = (params.frequency_hz / 1e6).toFixed(3) + ' MHz'; + if (lnaEl) lnaEl.textContent = params.lna_gain + ' dB'; + if (vgaEl) vgaEl.textContent = params.vga_gain + ' dB'; + if (srEl) srEl.textContent = (params.sample_rate / 1000) + ' kHz'; + } + + // ------ DASHBOARD: CONSOLE ------ + + function addConsoleEntry(msg, level) { + level = level || ''; + const now = new Date(); + const ts = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + consoleEntries.push({ ts, msg, level }); + if (consoleEntries.length > 100) consoleEntries.shift(); + + const log = document.getElementById('subghzConsoleLog'); + if (!log) return; + + const entry = document.createElement('div'); + entry.className = 'subghz-log-entry'; + entry.innerHTML = '' + escapeHtml(ts) + '' + + '' + escapeHtml(msg) + ''; + log.appendChild(entry); + log.scrollTop = log.scrollHeight; + + while (log.children.length > 100) { + log.removeChild(log.firstChild); + } + } + + function showConsole() { + const consoleEl = document.getElementById('subghzConsole'); + if (consoleEl) consoleEl.style.display = ''; + } + + function toggleConsole() { + consoleCollapsed = !consoleCollapsed; + const body = document.getElementById('subghzConsoleBody'); + const btn = document.getElementById('subghzConsoleToggleBtn'); + if (body) body.classList.toggle('collapsed', consoleCollapsed); + if (btn) btn.classList.toggle('collapsed', consoleCollapsed); + } + + function clearConsole() { + consoleEntries = []; + const log = document.getElementById('subghzConsoleLog'); + if (log) log.innerHTML = ''; + } + + function updatePhaseIndicator(phase) { + currentPhase = phase; + const steps = ['tuning', 'listening', 'decoding']; + const phaseEls = { + tuning: document.getElementById('subghzPhaseTuning'), + listening: document.getElementById('subghzPhaseListening'), + decoding: document.getElementById('subghzPhaseDecoding'), + }; + + if (!phase) { + Object.values(phaseEls).forEach(el => { + if (el) el.className = 'subghz-phase-step'; + }); + return; + } + + if (phase === 'error') { + Object.values(phaseEls).forEach(el => { + if (el) { + el.className = 'subghz-phase-step'; + el.classList.add('error'); + } + }); + return; + } + + const activeIdx = steps.indexOf(phase); + steps.forEach((step, idx) => { + const el = phaseEls[step]; + if (!el) return; + el.className = 'subghz-phase-step'; + if (idx < activeIdx) el.classList.add('completed'); + else if (idx === activeIdx) el.classList.add('active'); + }); + } + + // ------ PUBLIC API ------ + return { init, destroy, setFreq, syncTriggerControls, switchTab, - startRx, - stopRx, - startDecode, - stopDecode, + startRx, + stopRx, + startDecode, + stopDecode, startSweep, stopSweep, showTxConfirm, diff --git a/static/js/modes/weather-satellite.js b/static/js/modes/weather-satellite.js index 00b01c5..39fcdd6 100644 --- a/static/js/modes/weather-satellite.js +++ b/static/js/modes/weather-satellite.js @@ -291,8 +291,10 @@ const WeatherSat = (function() { } } catch (err) { console.error('Failed to start weather sat:', err); + reportActionableError('Start Weather Satellite', err, { + onRetry: () => start() + }); updateStatusUI('idle', 'Error'); - showNotification('Weather Sat', 'Connection error'); } } @@ -322,6 +324,7 @@ const WeatherSat = (function() { showNotification('Weather Sat', 'Capture stopped'); } catch (err) { console.error('Failed to stop weather sat:', err); + reportActionableError('Stop Weather Satellite', err); } } @@ -375,8 +378,10 @@ const WeatherSat = (function() { } } catch (err) { console.error('Failed to start test decode:', err); + reportActionableError('Start Test Decode', err, { + onRetry: () => testDecode() + }); updateStatusUI('idle', 'Error'); - showNotification('Weather Sat', 'Connection error'); } } @@ -1439,9 +1444,11 @@ const WeatherSat = (function() { showNotification('Weather Sat', `Auto-scheduler enabled (${data.scheduled_count || 0} passes)`); } catch (err) { console.error('Failed to enable scheduler:', err); + reportActionableError('Enable Scheduler', err, { + onRetry: () => enableScheduler() + }); schedulerEnabled = false; updateSchedulerUI({ enabled: false, scheduled_count: 0 }); - showNotification('Weather Sat', 'Failed to enable auto-scheduler'); } } @@ -1461,6 +1468,7 @@ const WeatherSat = (function() { showNotification('Weather Sat', 'Auto-scheduler disabled'); } catch (err) { console.error('Failed to disable scheduler:', err); + reportActionableError('Disable Scheduler', err); } } @@ -1649,7 +1657,13 @@ const WeatherSat = (function() { */ async function deleteImage(filename) { if (!filename) return; - if (!confirm(`Delete this image?`)) return; + const confirmed = await AppFeedback.confirmAction({ + title: 'Delete Image', + message: 'Delete this image? This cannot be undone.', + confirmLabel: 'Delete', + confirmClass: 'btn-danger' + }); + if (!confirmed) return; try { const response = await fetch(`/weather-sat/images/${encodeURIComponent(filename)}`, { method: 'DELETE' }); @@ -1668,7 +1682,7 @@ const WeatherSat = (function() { } } catch (err) { console.error('Failed to delete image:', err); - showNotification('Weather Sat', 'Failed to delete image'); + reportActionableError('Delete Image', err); } } @@ -1677,7 +1691,13 @@ const WeatherSat = (function() { */ async function deleteAllImages() { if (images.length === 0) return; - if (!confirm(`Delete all ${images.length} decoded images?`)) return; + const confirmed = await AppFeedback.confirmAction({ + title: 'Delete All Images', + message: `Delete all ${images.length} decoded images? This cannot be undone.`, + confirmLabel: 'Delete All', + confirmClass: 'btn-danger' + }); + if (!confirmed) return; try { const response = await fetch('/weather-sat/images', { method: 'DELETE' }); @@ -1693,7 +1713,7 @@ const WeatherSat = (function() { } } catch (err) { console.error('Failed to delete all images:', err); - showNotification('Weather Sat', 'Failed to delete images'); + reportActionableError('Delete All Images', err); } } diff --git a/static/js/modes/wefax.js b/static/js/modes/wefax.js index 853bc72..86b710b 100644 --- a/static/js/modes/wefax.js +++ b/static/js/modes/wefax.js @@ -242,6 +242,7 @@ var WeFax = (function () { .catch(function (err) { setStatus('Stopped'); console.error('WeFax stop error:', err); + reportActionableError('Stop WeFax', err); }); } @@ -626,9 +627,15 @@ var WeFax = (function () { gallery.innerHTML = html; } - function deleteImage(filename) { + async function deleteImage(filename) { if (!filename) return; - if (!confirm('Delete this image?')) return; + const confirmed = await AppFeedback.confirmAction({ + title: 'Delete Image', + message: 'Delete this image? This cannot be undone.', + confirmLabel: 'Delete', + confirmClass: 'btn-danger' + }); + if (!confirmed) return; fetch('/wefax/images/' + encodeURIComponent(filename), { method: 'DELETE' }) .then(function (r) { return r.json(); }) .then(function (data) { @@ -641,12 +648,18 @@ var WeFax = (function () { }) .catch(function (err) { console.error('WeFax delete error:', err); - setStatus('Delete failed: ' + err.message); + reportActionableError('Delete Image', err); }); } - function deleteAllImages() { - if (!confirm('Delete all WeFax images?')) return; + async function deleteAllImages() { + const confirmed = await AppFeedback.confirmAction({ + title: 'Delete All Images', + message: 'Delete all WeFax images? This cannot be undone.', + confirmLabel: 'Delete All', + confirmClass: 'btn-danger' + }); + if (!confirmed) return; fetch('/wefax/images', { method: 'DELETE' }) .then(function (r) { return r.json(); }) .then(function (data) { @@ -654,7 +667,10 @@ var WeFax = (function () { loadImages(); } }) - .catch(function (err) { console.error('WeFax delete all error:', err); }); + .catch(function (err) { + console.error('WeFax delete all error:', err); + reportActionableError('Delete All Images', err); + }); } var currentModalUrl = null; @@ -1107,6 +1123,7 @@ var WeFax = (function () { }) .catch(function (err) { console.error('WeFax scheduler disable error:', err); + reportActionableError('Disable Scheduler', err); }); } diff --git a/static/js/modes/wifi.js b/static/js/modes/wifi.js index bc44c02..01c5861 100644 --- a/static/js/modes/wifi.js +++ b/static/js/modes/wifi.js @@ -59,12 +59,12 @@ const WiFiMode = (function() { /** * Check for agent mode conflicts before starting WiFi scan. */ - function checkAgentConflicts() { + async function checkAgentConflicts() { if (typeof currentAgent === 'undefined' || currentAgent === 'local') { return true; } if (typeof checkAgentModeConflict === 'function') { - return checkAgentModeConflict('wifi'); + return await checkAgentModeConflict('wifi'); } return true; } @@ -411,7 +411,7 @@ const WiFiMode = (function() { if (isScanning) return; // Check for agent mode conflicts - if (!checkAgentConflicts()) { + if (!await checkAgentConflicts()) { return; } @@ -503,7 +503,7 @@ const WiFiMode = (function() { if (isScanning) return; // Check for agent mode conflicts - if (!checkAgentConflicts()) { + if (!await checkAgentConflicts()) { return; } diff --git a/templates/adsb_dashboard.html b/templates/adsb_dashboard.html index 728644b..dbd08c6 100644 --- a/templates/adsb_dashboard.html +++ b/templates/adsb_dashboard.html @@ -21,7 +21,7 @@ - + @@ -2162,7 +2162,7 @@ sudo make install if (remoteConfig === false) return; // Check for agent SDR conflicts if (useAgent && typeof checkAgentModeConflict === 'function') { - if (!checkAgentModeConflict('adsb')) { + if (!await checkAgentModeConflict('adsb')) { return; // User cancelled or conflict not resolved } } @@ -3661,11 +3661,12 @@ sudo make install // Check if ADS-B tracking is using this device if (isTracking && adsbActiveDevice !== null && device === adsbActiveDevice) { - const useAnyway = confirm( - `Warning: ADS-B tracking is using SDR ${adsbActiveDevice}.\n\n` + - 'Using the same device for airband will stop ADS-B tracking.\n\n' + - 'Select a different SDR device for airband listening, or click OK to stop tracking and listen.' - ); + const useAnyway = await AppFeedback.confirmAction({ + title: 'SDR Device Conflict', + message: `ADS-B tracking is using SDR ${adsbActiveDevice}. Using the same device for airband will stop ADS-B tracking. Select a different SDR device for airband listening, or continue to stop tracking and listen.`, + confirmLabel: 'Continue', + confirmClass: 'btn-danger' + }); if (!useAnyway) { return; } @@ -3900,7 +3901,7 @@ sudo make install } } - function startAcars() { + async function startAcars() { const acarsSelect = document.getElementById('acarsDeviceSelect'); const compositeVal = acarsSelect.value || 'rtlsdr:0'; const [sdr_type, deviceIdx] = compositeVal.includes(':') ? compositeVal.split(':') : ['rtlsdr', compositeVal]; @@ -3913,12 +3914,12 @@ sudo make install // Warn if using same device as ADS-B (only for local mode) if (!isAgentMode && isTracking && adsbActiveDevice !== null && device === String(adsbActiveDevice)) { - const useAnyway = confirm( - `Warning: ADS-B tracking is using SDR device ${adsbActiveDevice}.\n\n` + - 'ACARS uses VHF frequencies (129-131 MHz) while ADS-B uses 1090 MHz.\n' + - 'You need TWO separate SDR devices to receive both simultaneously.\n\n' + - 'Click OK to start ACARS on device ' + device + ' anyway.' - ); + const useAnyway = await AppFeedback.confirmAction({ + title: 'SDR Device Conflict', + message: `ADS-B tracking is using SDR device ${adsbActiveDevice}. ACARS uses VHF frequencies (129-131 MHz) while ADS-B uses 1090 MHz. You need TWO separate SDR devices to receive both simultaneously. Continue to start ACARS on device ${device} anyway.`, + confirmLabel: 'Continue', + confirmClass: 'btn-danger' + }); if (!useAnyway) return; } @@ -4348,7 +4349,7 @@ sudo make install } } - function startVdl2() { + async function startVdl2() { const vdl2Select = document.getElementById('vdl2DeviceSelect'); const compositeVal = vdl2Select.value || 'rtlsdr:0'; const [sdr_type, deviceIdx] = compositeVal.includes(':') ? compositeVal.split(':') : ['rtlsdr', compositeVal]; @@ -4361,12 +4362,12 @@ sudo make install // Warn if using same device as ADS-B (only for local mode) if (!isAgentMode && isTracking && adsbActiveDevice !== null && device === String(adsbActiveDevice)) { - const useAnyway = confirm( - `Warning: ADS-B tracking is using SDR device ${adsbActiveDevice}.\n\n` + - 'VDL2 uses VHF frequencies (~137 MHz) while ADS-B uses 1090 MHz.\n' + - 'You need TWO separate SDR devices to receive both simultaneously.\n\n' + - 'Click OK to start VDL2 on device ' + device + ' anyway.' - ); + const useAnyway = await AppFeedback.confirmAction({ + title: 'SDR Device Conflict', + message: `ADS-B tracking is using SDR device ${adsbActiveDevice}. VDL2 uses VHF frequencies (~137 MHz) while ADS-B uses 1090 MHz. You need TWO separate SDR devices to receive both simultaneously. Continue to start VDL2 on device ${device} anyway.`, + confirmLabel: 'Continue', + confirmClass: 'btn-danger' + }); if (!useAnyway) return; } diff --git a/templates/adsb_history.html b/templates/adsb_history.html index f88ea6b..486850d 100644 --- a/templates/adsb_history.html +++ b/templates/adsb_history.html @@ -10,7 +10,7 @@ {% endif %} - + @@ -608,7 +608,12 @@ } const dayEndLocal = new Date(dayStartLocal.getTime() + (24 * 60 * 60 * 1000)); const dayLabel = dayStartLocal.toLocaleDateString(); - const confirmed = window.confirm(`Delete ADS-B history for ${dayLabel}? This cannot be undone.`); + const confirmed = await AppFeedback.confirmAction({ + title: 'Delete Day History', + message: `Delete ADS-B history for ${dayLabel}? This cannot be undone.`, + confirmLabel: 'Delete', + confirmClass: 'btn-danger' + }); if (!confirmed) { return; } @@ -623,7 +628,12 @@ } async function clearAllHistory() { - const confirmed = window.confirm('Delete ALL ADS-B history records? This cannot be undone.'); + const confirmed = await AppFeedback.confirmAction({ + title: 'Delete All History', + message: 'Delete ALL ADS-B history records? This cannot be undone.', + confirmLabel: 'Delete All', + confirmClass: 'btn-danger' + }); if (!confirmed) { return; } diff --git a/templates/agents.html b/templates/agents.html index 5faa911..74caaa0 100644 --- a/templates/agents.html +++ b/templates/agents.html @@ -9,7 +9,7 @@ - + @@ -514,7 +514,13 @@ } async function deleteAgent(agentId, agentName) { - if (!confirm(`Are you sure you want to remove agent "${agentName}"?`)) { + const confirmed = await AppFeedback.confirmAction({ + title: 'Remove Agent', + message: `Are you sure you want to remove agent "${agentName}"?`, + confirmLabel: 'Remove', + confirmClass: 'btn-danger' + }); + if (!confirmed) { return; } diff --git a/templates/ais_dashboard.html b/templates/ais_dashboard.html index aab463b..bc9db96 100644 --- a/templates/ais_dashboard.html +++ b/templates/ais_dashboard.html @@ -22,7 +22,7 @@ - + - + + @@ -666,16 +667,14 @@ -
+
rtl_fm:{{ 'OK' if tools.rtl_fm else 'Missing' }} multimon-ng:{{ 'OK' if tools.multimon else 'Missing' }}
- -