feat: UI/UX overhaul — CSS cleanup, accessibility, error handling, inline style extraction

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 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-03-12 13:04:36 +00:00
parent 05412fbfc3
commit e687862043
56 changed files with 2660 additions and 2238 deletions

View File

@@ -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

View File

@@ -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())

View File

@@ -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') {

View File

@@ -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<string, ConnectionEntry>} */
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,
};
})();

View File

@@ -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,
};
})();