feat: add settings dialog open/close, tab switching, localStorage management

This commit is contained in:
Brian Krabach
2026-03-29 23:16:19 -07:00
parent b20759c6cb
commit 1eec617cb9
3 changed files with 642 additions and 4 deletions
+124 -3
View File
@@ -130,6 +130,11 @@ let _previewTimer = null;
var _previewSessionName = null; // track by NAME, not DOM element var _previewSessionName = null; // track by NAME, not DOM element
// ─── Settings state ───────────────────────────────────────────────────────────
let _settingsOpen = false;
const DISPLAY_SETTINGS_KEY = 'muxplex.display';
const DISPLAY_DEFAULTS = {fontSize: 14, hoverPreviewDelay: 1500, gridColumns: 'auto', bellSound: false, notificationPermission: 'default'};
// ─── DOM helpers ────────────────────────────────────────────────────────────── // ─── DOM helpers ──────────────────────────────────────────────────────────────
function $(id) { function $(id) {
return document.getElementById(id); return document.getElementById(id);
@@ -898,18 +903,118 @@ function _setViewingSession(name) {
_viewingSession = name; _viewingSession = name;
} }
// ─── Settings dialog ──────────────────────────────────────────────────────────
/**
* Load display settings from localStorage, merging with DISPLAY_DEFAULTS.
* Returns defaults on any error.
* @returns {object}
*/
function loadDisplaySettings() {
try {
const raw = localStorage.getItem(DISPLAY_SETTINGS_KEY);
if (raw === null) return Object.assign({}, DISPLAY_DEFAULTS);
const saved = JSON.parse(raw);
return Object.assign({}, DISPLAY_DEFAULTS, saved);
} catch (_) {
return Object.assign({}, DISPLAY_DEFAULTS);
}
}
/**
* Save display settings to localStorage.
* @param {object} settings
*/
function saveDisplaySettings(settings) {
try {
localStorage.setItem(DISPLAY_SETTINGS_KEY, JSON.stringify(settings));
} catch (_) { /* blocked — ok */ }
}
/**
* Open the settings dialog.
* Sets _settingsOpen, calls dialog.showModal(), removes hidden from backdrop,
* and loads current display settings into form controls.
*/
function openSettings() {
_settingsOpen = true;
const dialog = $('settings-dialog');
if (dialog) dialog.showModal();
const backdrop = $('settings-backdrop');
if (backdrop) backdrop.classList.remove('hidden');
const settings = loadDisplaySettings();
const fontSizeEl = $('setting-font-size');
if (fontSizeEl) fontSizeEl.value = String(settings.fontSize);
const hoverDelayEl = $('setting-hover-delay');
if (hoverDelayEl) hoverDelayEl.value = String(settings.hoverPreviewDelay);
const gridColumnsEl = $('setting-grid-columns');
if (gridColumnsEl) gridColumnsEl.value = String(settings.gridColumns);
}
/**
* Close the settings dialog.
* Sets _settingsOpen to false, calls dialog.close(), adds hidden to backdrop.
*/
function closeSettings() {
_settingsOpen = false;
const dialog = $('settings-dialog');
if (dialog) dialog.close();
const backdrop = $('settings-backdrop');
if (backdrop) backdrop.classList.add('hidden');
}
/**
* Switch the active settings tab.
* Toggles settings-tab--active class and aria-selected on tab buttons,
* toggles hidden class on settings-panel elements by matching data-tab.
* @param {string} tabName
*/
function switchSettingsTab(tabName) {
document.querySelectorAll('.settings-tab').forEach(function(tab) {
const isActive = tab.dataset.tab === tabName;
if (isActive) {
tab.classList.add('settings-tab--active');
tab.setAttribute('aria-selected', 'true');
} else {
tab.classList.remove('settings-tab--active');
tab.setAttribute('aria-selected', 'false');
}
});
document.querySelectorAll('.settings-panel').forEach(function(panel) {
const panelTab = panel.dataset.tab || panel.dataset.panel;
if (panelTab === tabName) {
panel.classList.remove('hidden');
} else {
panel.classList.add('hidden');
}
});
}
/** /**
* Global keydown handler. * Global keydown handler.
* When in fullscreen: Escape returns to grid. * If settings are open: Escape closes settings.
* Comma key (not in inputs) opens settings.
* In fullscreen: Escape returns to grid.
* @param {KeyboardEvent} e * @param {KeyboardEvent} e
*/ */
function handleGlobalKeydown(e) { function handleGlobalKeydown(e) {
if (_viewMode === 'fullscreen') { if (_settingsOpen) {
if (e.key === 'Escape') { if (e.key === 'Escape') {
closeSettings();
}
return;
}
if (e.key === ',' && !e.ctrlKey && !e.metaKey) {
const tag = document.activeElement && document.activeElement.tagName;
if (tag !== 'INPUT' && tag !== 'TEXTAREA' && tag !== 'SELECT') {
openSettings();
return;
}
}
if (_viewMode === 'fullscreen' && e.key === 'Escape') {
e.preventDefault(); e.preventDefault();
closeSession(); closeSession();
} }
}
} }
/** /**
@@ -997,6 +1102,16 @@ function bindStaticEventListeners() {
on($('session-pill'), 'click', openBottomSheet); on($('session-pill'), 'click', openBottomSheet);
on($('sheet-backdrop'), 'click', closeBottomSheet); on($('sheet-backdrop'), 'click', closeBottomSheet);
// Settings dialog bindings
on($('settings-btn'), 'click', openSettings);
on($('settings-btn-expanded'), 'click', openSettings);
on($('settings-backdrop'), 'click', closeSettings);
const settingsDialog = $('settings-dialog');
if (settingsDialog) settingsDialog.addEventListener('cancel', closeSettings);
document.querySelectorAll('.settings-tab').forEach(function(tab) {
on(tab, 'click', function() { switchSettingsTab(tab.dataset.tab); });
});
// Hover preview — delegated on grid container (tiles are re-rendered each poll) // Hover preview — delegated on grid container (tiles are re-rendered each poll)
var gridEl = $('session-grid'); var gridEl = $('session-grid');
if (gridEl && !('ontouchstart' in window)) { // desktop only if (gridEl && !('ontouchstart' in window)) { // desktop only
@@ -1109,6 +1224,12 @@ if (typeof module !== 'undefined' && module.exports) {
// Hover preview popover // Hover preview popover
showPreview, showPreview,
hidePreview, hidePreview,
// Settings
loadDisplaySettings,
saveDisplaySettings,
openSettings,
closeSettings,
switchSettingsTab,
// Test-only helpers // Test-only helpers
_setCurrentSessions, _setCurrentSessions,
_setViewMode, _setViewMode,
+517
View File
@@ -328,3 +328,520 @@ def test_exports_still_has_bind_static_event_listeners() -> None:
assert "bindStaticEventListeners" in exports, ( assert "bindStaticEventListeners" in exports, (
"module.exports must still export bindStaticEventListeners" "module.exports must still export bindStaticEventListeners"
) )
# ── Settings state variables ─────────────────────────────────────────────────
def test_settings_open_state_variable_exists() -> None:
"""_settingsOpen state variable must be declared."""
assert "_settingsOpen" in _JS, "_settingsOpen must be declared in app.js"
def test_display_settings_key_constant_exists() -> None:
"""DISPLAY_SETTINGS_KEY constant must be declared."""
assert "DISPLAY_SETTINGS_KEY" in _JS, "DISPLAY_SETTINGS_KEY must be in app.js"
def test_display_settings_key_value() -> None:
"""DISPLAY_SETTINGS_KEY must be 'muxplex.display'."""
assert "'muxplex.display'" in _JS or '"muxplex.display"' in _JS, (
"DISPLAY_SETTINGS_KEY must be 'muxplex.display'"
)
def test_display_defaults_constant_exists() -> None:
"""DISPLAY_DEFAULTS constant must be declared."""
assert "DISPLAY_DEFAULTS" in _JS, "DISPLAY_DEFAULTS must be in app.js"
def test_display_defaults_has_font_size() -> None:
"""DISPLAY_DEFAULTS must contain fontSize: 14."""
assert "fontSize" in _JS, "DISPLAY_DEFAULTS must include fontSize"
assert "14" in _JS, "DISPLAY_DEFAULTS fontSize must be 14"
def test_display_defaults_has_hover_preview_delay() -> None:
"""DISPLAY_DEFAULTS must contain hoverPreviewDelay: 1500."""
assert "hoverPreviewDelay" in _JS, "DISPLAY_DEFAULTS must include hoverPreviewDelay"
def test_display_defaults_has_grid_columns() -> None:
"""DISPLAY_DEFAULTS must contain gridColumns: 'auto'."""
assert "gridColumns" in _JS, "DISPLAY_DEFAULTS must include gridColumns"
def test_display_defaults_has_bell_sound() -> None:
"""DISPLAY_DEFAULTS must contain bellSound: false."""
assert "bellSound" in _JS, "DISPLAY_DEFAULTS must include bellSound"
def test_display_defaults_has_notification_permission() -> None:
"""DISPLAY_DEFAULTS must contain notificationPermission: 'default'."""
assert "notificationPermission" in _JS, "DISPLAY_DEFAULTS must include notificationPermission"
# ── Settings functions must exist ─────────────────────────────────────────────
def test_load_display_settings_function_exists() -> None:
"""loadDisplaySettings function must exist."""
assert "function loadDisplaySettings" in _JS, (
"loadDisplaySettings must be defined in app.js"
)
def test_save_display_settings_function_exists() -> None:
"""saveDisplaySettings function must exist."""
assert "function saveDisplaySettings" in _JS, (
"saveDisplaySettings must be defined in app.js"
)
def test_open_settings_function_exists() -> None:
"""openSettings function must exist."""
assert "function openSettings" in _JS, "openSettings must be defined in app.js"
def test_close_settings_function_exists() -> None:
"""closeSettings function must exist."""
assert "function closeSettings" in _JS, "closeSettings must be defined in app.js"
def test_switch_settings_tab_function_exists() -> None:
"""switchSettingsTab function must exist."""
assert "function switchSettingsTab" in _JS, (
"switchSettingsTab must be defined in app.js"
)
# ── loadDisplaySettings implementation ───────────────────────────────────────
def test_load_display_settings_reads_from_localstorage() -> None:
"""loadDisplaySettings must read from localStorage using DISPLAY_SETTINGS_KEY."""
match = re.search(
r"function loadDisplaySettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS,
re.DOTALL,
)
assert match, "loadDisplaySettings function not found"
body = match.group(1)
assert "localStorage" in body, "loadDisplaySettings must read from localStorage"
assert "DISPLAY_SETTINGS_KEY" in body or "muxplex.display" in body, (
"loadDisplaySettings must use DISPLAY_SETTINGS_KEY"
)
def test_load_display_settings_uses_object_assign() -> None:
"""loadDisplaySettings must merge with DISPLAY_DEFAULTS via Object.assign."""
match = re.search(
r"function loadDisplaySettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS,
re.DOTALL,
)
assert match, "loadDisplaySettings function not found"
body = match.group(1)
assert "Object.assign" in body, "loadDisplaySettings must use Object.assign to merge with defaults"
def test_load_display_settings_returns_defaults_on_error() -> None:
"""loadDisplaySettings must catch errors and return defaults."""
match = re.search(
r"function loadDisplaySettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS,
re.DOTALL,
)
assert match, "loadDisplaySettings function not found"
body = match.group(1)
assert "catch" in body or "try" in body, (
"loadDisplaySettings must have error handling (try/catch)"
)
# ── saveDisplaySettings implementation ───────────────────────────────────────
def test_save_display_settings_writes_to_localstorage() -> None:
"""saveDisplaySettings must write to localStorage."""
match = re.search(
r"function saveDisplaySettings\s*\(.*?\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS,
re.DOTALL,
)
assert match, "saveDisplaySettings function not found"
body = match.group(1)
assert "localStorage" in body, "saveDisplaySettings must write to localStorage"
assert "setItem" in body, "saveDisplaySettings must call localStorage.setItem"
def test_save_display_settings_catches_errors() -> None:
"""saveDisplaySettings must catch errors."""
match = re.search(
r"function saveDisplaySettings\s*\(.*?\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS,
re.DOTALL,
)
assert match, "saveDisplaySettings function not found"
body = match.group(1)
assert "catch" in body or "try" in body, (
"saveDisplaySettings must have error handling (try/catch)"
)
# ── openSettings implementation ───────────────────────────────────────────────
def test_open_settings_sets_settings_open_true() -> None:
"""openSettings must set _settingsOpen = true."""
match = re.search(
r"function openSettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS,
re.DOTALL,
)
assert match, "openSettings function not found"
body = match.group(1)
assert "_settingsOpen" in body and "true" in body, (
"openSettings must set _settingsOpen = true"
)
def test_open_settings_calls_show_modal() -> None:
"""openSettings must call dialog.showModal()."""
match = re.search(
r"function openSettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS,
re.DOTALL,
)
assert match, "openSettings function not found"
body = match.group(1)
assert "showModal" in body, "openSettings must call dialog.showModal()"
def test_open_settings_removes_hidden_from_backdrop() -> None:
"""openSettings must remove hidden class from backdrop."""
match = re.search(
r"function openSettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS,
re.DOTALL,
)
assert match, "openSettings function not found"
body = match.group(1)
assert "settings-backdrop" in body, "openSettings must reference settings-backdrop"
assert "remove" in body, "openSettings must call remove (hidden class removal)"
def test_open_settings_loads_form_controls() -> None:
"""openSettings must load display settings into form controls."""
match = re.search(
r"function openSettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS,
re.DOTALL,
)
assert match, "openSettings function not found"
body = match.group(1)
assert "setting-font-size" in body, "openSettings must set setting-font-size form control"
assert "setting-hover-delay" in body, "openSettings must set setting-hover-delay form control"
assert "setting-grid-columns" in body, "openSettings must set setting-grid-columns form control"
# ── closeSettings implementation ──────────────────────────────────────────────
def test_close_settings_sets_settings_open_false() -> None:
"""closeSettings must set _settingsOpen = false."""
match = re.search(
r"function closeSettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS,
re.DOTALL,
)
assert match, "closeSettings function not found"
body = match.group(1)
assert "_settingsOpen" in body and "false" in body, (
"closeSettings must set _settingsOpen = false"
)
def test_close_settings_calls_dialog_close() -> None:
"""closeSettings must call dialog.close()."""
match = re.search(
r"function closeSettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS,
re.DOTALL,
)
assert match, "closeSettings function not found"
body = match.group(1)
assert ".close()" in body, "closeSettings must call dialog.close()"
def test_close_settings_adds_hidden_to_backdrop() -> None:
"""closeSettings must add hidden class to backdrop."""
match = re.search(
r"function closeSettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS,
re.DOTALL,
)
assert match, "closeSettings function not found"
body = match.group(1)
assert "settings-backdrop" in body, "closeSettings must reference settings-backdrop"
assert "add" in body, "closeSettings must call add (hidden class addition)"
# ── switchSettingsTab implementation ──────────────────────────────────────────
def test_switch_settings_tab_has_tab_name_param() -> None:
"""switchSettingsTab must accept tabName parameter."""
assert "function switchSettingsTab" in _JS, "switchSettingsTab must be defined"
match = re.search(
r"function switchSettingsTab\s*\((\w+)\)",
_JS,
)
assert match, "switchSettingsTab must have a parameter"
def test_switch_settings_tab_toggles_active_class() -> None:
"""switchSettingsTab must toggle settings-tab--active class."""
match = re.search(
r"function switchSettingsTab\s*\(\w+\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS,
re.DOTALL,
)
assert match, "switchSettingsTab function not found"
body = match.group(1)
assert "settings-tab--active" in body, (
"switchSettingsTab must toggle settings-tab--active class"
)
def test_switch_settings_tab_toggles_aria_selected() -> None:
"""switchSettingsTab must toggle aria-selected on tab buttons."""
match = re.search(
r"function switchSettingsTab\s*\(\w+\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS,
re.DOTALL,
)
assert match, "switchSettingsTab function not found"
body = match.group(1)
assert "aria-selected" in body, "switchSettingsTab must toggle aria-selected"
def test_switch_settings_tab_toggles_panel_hidden() -> None:
"""switchSettingsTab must toggle hidden class on settings-panel elements."""
match = re.search(
r"function switchSettingsTab\s*\(\w+\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS,
re.DOTALL,
)
assert match, "switchSettingsTab function not found"
body = match.group(1)
assert "settings-panel" in body or "data-tab" in body, (
"switchSettingsTab must handle settings-panel elements via data-tab"
)
# ── handleGlobalKeydown settings integration ─────────────────────────────────
def test_handle_global_keydown_checks_settings_open() -> None:
"""handleGlobalKeydown must check _settingsOpen."""
match = re.search(
r"function handleGlobalKeydown\s*\(e\)\s*\{(.*?)\n\}",
_JS,
re.DOTALL,
)
assert match, "handleGlobalKeydown function not found"
body = match.group(1)
assert "_settingsOpen" in body, (
"handleGlobalKeydown must check _settingsOpen"
)
def test_handle_global_keydown_calls_close_settings_on_escape() -> None:
"""handleGlobalKeydown must call closeSettings on Escape when settings open."""
match = re.search(
r"function handleGlobalKeydown\s*\(e\)\s*\{(.*?)\n\}",
_JS,
re.DOTALL,
)
assert match, "handleGlobalKeydown function not found"
body = match.group(1)
assert "closeSettings" in body, (
"handleGlobalKeydown must call closeSettings"
)
def test_handle_global_keydown_opens_settings_on_comma() -> None:
"""handleGlobalKeydown must open settings when comma pressed."""
match = re.search(
r"function handleGlobalKeydown\s*\(e\)\s*\{(.*?)\n\}",
_JS,
re.DOTALL,
)
assert match, "handleGlobalKeydown function not found"
body = match.group(1)
assert "openSettings" in body, (
"handleGlobalKeydown must call openSettings for comma key"
)
assert "," in body or "comma" in body.lower(), (
"handleGlobalKeydown must check for comma key"
)
def test_handle_global_keydown_comma_guards_input_elements() -> None:
"""handleGlobalKeydown comma shortcut must not fire in input/textarea/select."""
match = re.search(
r"function handleGlobalKeydown\s*\(e\)\s*\{(.*?)\n\}",
_JS,
re.DOTALL,
)
assert match, "handleGlobalKeydown function not found"
body = match.group(1)
# Must guard against inputs
has_input_guard = (
"INPUT" in body or "input" in body.lower() or
"textarea" in body.lower() or "select" in body.lower() or
"tagName" in body
)
assert has_input_guard, (
"handleGlobalKeydown comma shortcut must guard against input/textarea/select"
)
# ── bindStaticEventListeners settings wiring ─────────────────────────────────
def test_bind_static_event_listeners_binds_settings_btn() -> None:
"""bindStaticEventListeners must bind settings-btn click to openSettings."""
match = re.search(
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
_JS,
re.DOTALL,
)
assert match, "bindStaticEventListeners function not found"
body = match.group(1)
assert "settings-btn" in body, (
"bindStaticEventListeners must bind settings-btn click"
)
def test_bind_static_event_listeners_binds_settings_btn_expanded() -> None:
"""bindStaticEventListeners must bind settings-btn-expanded click to openSettings."""
match = re.search(
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
_JS,
re.DOTALL,
)
assert match, "bindStaticEventListeners function not found"
body = match.group(1)
assert "settings-btn-expanded" in body, (
"bindStaticEventListeners must bind settings-btn-expanded click"
)
def test_bind_static_event_listeners_binds_settings_backdrop() -> None:
"""bindStaticEventListeners must bind settings-backdrop click to closeSettings."""
match = re.search(
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
_JS,
re.DOTALL,
)
assert match, "bindStaticEventListeners function not found"
body = match.group(1)
assert "settings-backdrop" in body, (
"bindStaticEventListeners must bind settings-backdrop click"
)
def test_bind_static_event_listeners_binds_dialog_cancel() -> None:
"""bindStaticEventListeners must bind dialog cancel event to closeSettings."""
match = re.search(
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
_JS,
re.DOTALL,
)
assert match, "bindStaticEventListeners function not found"
body = match.group(1)
assert "cancel" in body, (
"bindStaticEventListeners must bind dialog cancel event"
)
def test_bind_static_event_listeners_binds_settings_tabs() -> None:
"""bindStaticEventListeners must bind .settings-tab click to switchSettingsTab."""
match = re.search(
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
_JS,
re.DOTALL,
)
assert match, "bindStaticEventListeners function not found"
body = match.group(1)
assert "settings-tab" in body, (
"bindStaticEventListeners must bind .settings-tab click"
)
assert "switchSettingsTab" in body, (
"bindStaticEventListeners must call switchSettingsTab"
)
# ── module.exports for new settings functions ────────────────────────────────
def test_exports_load_display_settings() -> None:
"""module.exports must export loadDisplaySettings."""
match = re.search(
r"module\.exports\s*=\s*\{(.*?)\};",
_JS,
re.DOTALL,
)
assert match, "module.exports block not found"
exports = match.group(1)
assert "loadDisplaySettings" in exports, (
"module.exports must export loadDisplaySettings"
)
def test_exports_save_display_settings() -> None:
"""module.exports must export saveDisplaySettings."""
match = re.search(
r"module\.exports\s*=\s*\{(.*?)\};",
_JS,
re.DOTALL,
)
assert match, "module.exports block not found"
exports = match.group(1)
assert "saveDisplaySettings" in exports, (
"module.exports must export saveDisplaySettings"
)
def test_exports_open_settings() -> None:
"""module.exports must export openSettings."""
match = re.search(
r"module\.exports\s*=\s*\{(.*?)\};",
_JS,
re.DOTALL,
)
assert match, "module.exports block not found"
exports = match.group(1)
assert "openSettings" in exports, (
"module.exports must export openSettings"
)
def test_exports_close_settings() -> None:
"""module.exports must export closeSettings."""
match = re.search(
r"module\.exports\s*=\s*\{(.*?)\};",
_JS,
re.DOTALL,
)
assert match, "module.exports block not found"
exports = match.group(1)
assert "closeSettings" in exports, (
"module.exports must export closeSettings"
)
def test_exports_switch_settings_tab() -> None:
"""module.exports must export switchSettingsTab."""
match = re.search(
r"module\.exports\s*=\s*\{(.*?)\};",
_JS,
re.DOTALL,
)
assert match, "module.exports block not found"
exports = match.group(1)
assert "switchSettingsTab" in exports, (
"module.exports must export switchSettingsTab"
)