From fd4e91bc51497dab25896765fa819b67145f92b3 Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Sun, 29 Mar 2026 23:28:12 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20wire=20Display=20tab=20=E2=80=94=20font?= =?UTF-8?q?=20size,=20hover=20delay,=20grid=20columns=20with=20immediate?= =?UTF-8?q?=20apply?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- muxplex/frontend/app.js | 59 +++- ...t_frontend_js.cpython-312-pytest-9.0.2.pyc | Bin 94759 -> 125040 bytes muxplex/tests/test_frontend_js.py | 271 ++++++++++++++++++ 3 files changed, 328 insertions(+), 2 deletions(-) diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index 80565fc..9491432 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -937,6 +937,51 @@ function saveDisplaySettings(settings) { } catch (_) { /* blocked — ok */ } } +/** + * Apply display settings to the live DOM. + * Sets --preview-font-size CSS custom property and updates #session-grid + * grid-template-columns based on the gridColumns setting. + * @param {object} ds - display settings object + */ +function applyDisplaySettings(ds) { + // Apply font size as CSS custom property + document.documentElement.style.setProperty('--preview-font-size', ds.fontSize + 'px'); + + // Apply grid columns + var grid = document.getElementById('session-grid'); + if (grid) { + if (ds.gridColumns === 'auto') { + grid.style.removeProperty('grid-template-columns'); + } else { + grid.style.gridTemplateColumns = 'repeat(' + ds.gridColumns + ', 1fr)'; + } + } +} + +/** + * Handle a change event on any Display settings control. + * Reads current values from form elements, saves via saveDisplaySettings, + * and applies via applyDisplaySettings immediately. + */ +function onDisplaySettingChange() { + var ds = loadDisplaySettings(); + + var fontSizeEl = document.getElementById('setting-font-size'); + if (fontSizeEl) ds.fontSize = parseInt(fontSizeEl.value, 10) || ds.fontSize; + + var hoverDelayEl = document.getElementById('setting-hover-delay'); + if (hoverDelayEl) ds.hoverPreviewDelay = parseInt(hoverDelayEl.value, 10); + + var gridColumnsEl = document.getElementById('setting-grid-columns'); + if (gridColumnsEl) { + var raw = gridColumnsEl.value; + ds.gridColumns = raw === 'auto' ? 'auto' : parseInt(raw, 10); + } + + saveDisplaySettings(ds); + applyDisplaySettings(ds); +} + /** * Open the settings dialog. * Sets _settingsOpen, calls dialog.showModal(), removes hidden from backdrop, @@ -1126,7 +1171,8 @@ function bindStaticEventListeners() { if (!tile) return; if (_previewTimer) { clearTimeout(_previewTimer); _previewTimer = null; } var name = tile.dataset.session; - _previewTimer = setTimeout(function () { showPreview(name); }, 1500); + var delay = loadDisplaySettings().hoverPreviewDelay; + if (delay > 0) _previewTimer = setTimeout(function () { showPreview(name); }, delay); }, true); // useCapture: true for delegation with mouseenter gridEl.addEventListener('mouseleave', function (e) { @@ -1144,7 +1190,8 @@ function bindStaticEventListeners() { if (!item) return; if (_previewTimer) { clearTimeout(_previewTimer); _previewTimer = null; } var name = item.dataset.session; - _previewTimer = setTimeout(function () { showPreview(name); }, 1500); + var delay = loadDisplaySettings().hoverPreviewDelay; + if (delay > 0) _previewTimer = setTimeout(function () { showPreview(name); }, delay); }, true); sidebarListEl.addEventListener('mouseleave', function (e) { @@ -1153,6 +1200,11 @@ function bindStaticEventListeners() { hidePreview(); }, true); } + + // Display settings — bind change events for immediate apply + on($('setting-font-size'), 'change', onDisplaySettingChange); + on($('setting-hover-delay'), 'change', onDisplaySettingChange); + on($('setting-grid-columns'), 'change', onDisplaySettingChange); } // ─── Test-only helpers ──────────────────────────────────────────────────────── @@ -1169,6 +1221,7 @@ function _setViewMode(mode) { document.addEventListener('DOMContentLoaded', () => { initDeviceId(); + applyDisplaySettings(loadDisplaySettings()); document.addEventListener('keydown', trackInteraction); document.addEventListener('click', trackInteraction); document.addEventListener('touchstart', trackInteraction); @@ -1233,6 +1286,8 @@ if (typeof module !== 'undefined' && module.exports) { // Settings loadDisplaySettings, saveDisplaySettings, + applyDisplaySettings, + onDisplaySettingChange, openSettings, closeSettings, switchSettingsTab, diff --git a/muxplex/tests/__pycache__/test_frontend_js.cpython-312-pytest-9.0.2.pyc b/muxplex/tests/__pycache__/test_frontend_js.cpython-312-pytest-9.0.2.pyc index e323508c6e8528806556a4e4a7509c65eb7702fb..dac6c38468321e506c43480cea43badc0fdc9da5 100644 GIT binary patch delta 8301 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 z None: assert "switchSettingsTab" in exports, ( "module.exports must export switchSettingsTab" ) + + +# ── Display tab wiring (task-8) ──────────────────────────────────────────────── + +def test_apply_display_settings_function_exists() -> None: + """applyDisplaySettings function must exist.""" + assert "function applyDisplaySettings" in _JS, ( + "applyDisplaySettings must be defined in app.js" + ) + + +def test_apply_display_settings_sets_font_size_css_property() -> None: + """applyDisplaySettings must set --preview-font-size CSS custom property.""" + match = re.search( + r"function applyDisplaySettings\s*\(\w+\)\s*\{(.*?)(?=\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "applyDisplaySettings function not found" + body = match.group(1) + assert "--preview-font-size" in body, ( + "applyDisplaySettings must set --preview-font-size CSS property" + ) + assert "documentElement" in body, ( + "applyDisplaySettings must call setProperty on document.documentElement" + ) + + +def test_apply_display_settings_sets_grid_columns_repeat() -> None: + """applyDisplaySettings must set repeat(N, 1fr) for numeric grid columns.""" + match = re.search( + r"function applyDisplaySettings\s*\(\w+\)\s*\{(.*?)(?=\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "applyDisplaySettings function not found" + body = match.group(1) + assert "repeat" in body and "1fr" in body, ( + "applyDisplaySettings must set repeat(N, 1fr) for numeric grid columns" + ) + + +def test_apply_display_settings_handles_auto_grid_columns() -> None: + """applyDisplaySettings must handle 'auto' gridColumns by removing inline style.""" + match = re.search( + r"function applyDisplaySettings\s*\(\w+\)\s*\{(.*?)(?=\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "applyDisplaySettings function not found" + body = match.group(1) + assert "auto" in body, ( + "applyDisplaySettings must handle 'auto' gridColumns" + ) + assert "session-grid" in body or "gridTemplateColumns" in body, ( + "applyDisplaySettings must update session-grid or gridTemplateColumns" + ) + + +def test_on_display_setting_change_function_exists() -> None: + """onDisplaySettingChange function must exist.""" + assert "function onDisplaySettingChange" in _JS, ( + "onDisplaySettingChange must be defined in app.js" + ) + + +def test_on_display_setting_change_reads_font_size() -> None: + """onDisplaySettingChange must read from setting-font-size element.""" + match = re.search( + r"function onDisplaySettingChange\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "onDisplaySettingChange function not found" + body = match.group(1) + assert "setting-font-size" in body, ( + "onDisplaySettingChange must read from setting-font-size" + ) + + +def test_on_display_setting_change_reads_hover_delay() -> None: + """onDisplaySettingChange must read from setting-hover-delay element.""" + match = re.search( + r"function onDisplaySettingChange\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "onDisplaySettingChange function not found" + body = match.group(1) + assert "setting-hover-delay" in body, ( + "onDisplaySettingChange must read from setting-hover-delay" + ) + + +def test_on_display_setting_change_reads_grid_columns() -> None: + """onDisplaySettingChange must read from setting-grid-columns element.""" + match = re.search( + r"function onDisplaySettingChange\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "onDisplaySettingChange function not found" + body = match.group(1) + assert "setting-grid-columns" in body, ( + "onDisplaySettingChange must read from setting-grid-columns" + ) + + +def test_on_display_setting_change_calls_save_display_settings() -> None: + """onDisplaySettingChange must call saveDisplaySettings.""" + match = re.search( + r"function onDisplaySettingChange\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "onDisplaySettingChange function not found" + body = match.group(1) + assert "saveDisplaySettings" in body, ( + "onDisplaySettingChange must call saveDisplaySettings" + ) + + +def test_on_display_setting_change_calls_apply_display_settings() -> None: + """onDisplaySettingChange must call applyDisplaySettings.""" + match = re.search( + r"function onDisplaySettingChange\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "onDisplaySettingChange function not found" + body = match.group(1) + assert "applyDisplaySettings" in body, ( + "onDisplaySettingChange must call applyDisplaySettings" + ) + + +def test_hover_preview_no_hardcoded_1500() -> None: + """Hover preview delays must not be hardcoded 1500ms — must use loadDisplaySettings.""" + match = re.search( + r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}", + _JS, + re.DOTALL, + ) + assert match, "bindStaticEventListeners function not found" + body = match.group(1) + # Count occurrences of hardcoded 1500 in the body + hardcoded_count = body.count(", 1500)") + assert hardcoded_count == 0, ( + f"Hover preview must not use hardcoded 1500ms delay (found {hardcoded_count} occurrences). " + "Use loadDisplaySettings().hoverPreviewDelay instead." + ) + + +def test_hover_preview_reads_delay_from_settings() -> None: + """Hover preview must read delay from loadDisplaySettings().hoverPreviewDelay.""" + match = re.search( + r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}", + _JS, + re.DOTALL, + ) + assert match, "bindStaticEventListeners function not found" + body = match.group(1) + assert "loadDisplaySettings" in body and "hoverPreviewDelay" in body, ( + "bindStaticEventListeners hover preview must read delay from loadDisplaySettings().hoverPreviewDelay" + ) + + +def test_hover_preview_skips_when_delay_zero() -> None: + """Hover preview must check delay > 0 before setting setTimeout.""" + match = re.search( + r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}", + _JS, + re.DOTALL, + ) + assert match, "bindStaticEventListeners function not found" + body = match.group(1) + # The body must have a conditional guard on the delay value + assert "delay > 0" in body or "> 0" in body, ( + "bindStaticEventListeners hover preview must guard: if (delay > 0) before setTimeout" + ) + + +def test_bind_static_event_listeners_binds_font_size_change() -> None: + """bindStaticEventListeners must bind change event on setting-font-size.""" + match = re.search( + r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}", + _JS, + re.DOTALL, + ) + assert match, "bindStaticEventListeners function not found" + body = match.group(1) + assert "setting-font-size" in body, ( + "bindStaticEventListeners must reference setting-font-size for change binding" + ) + assert "onDisplaySettingChange" in body, ( + "bindStaticEventListeners must call onDisplaySettingChange on change" + ) + + +def test_bind_static_event_listeners_binds_hover_delay_change() -> None: + """bindStaticEventListeners must bind change event on setting-hover-delay.""" + match = re.search( + r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}", + _JS, + re.DOTALL, + ) + assert match, "bindStaticEventListeners function not found" + body = match.group(1) + assert "setting-hover-delay" in body, ( + "bindStaticEventListeners must reference setting-hover-delay for change binding" + ) + + +def test_bind_static_event_listeners_binds_grid_columns_change() -> None: + """bindStaticEventListeners must bind change event on setting-grid-columns.""" + match = re.search( + r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}", + _JS, + re.DOTALL, + ) + assert match, "bindStaticEventListeners function not found" + body = match.group(1) + assert "setting-grid-columns" in body, ( + "bindStaticEventListeners must reference setting-grid-columns for change binding" + ) + + +def test_dom_content_loaded_calls_apply_display_settings() -> None: + """DOMContentLoaded handler must call applyDisplaySettings(loadDisplaySettings()).""" + # Find the DOMContentLoaded handler block + match = re.search( + r"DOMContentLoaded.*?\{(.*?)(?=\}\);?\s*\n// |\}\);\s*$)", + _JS, + re.DOTALL, + ) + assert match, "DOMContentLoaded handler not found" + body = match.group(1) + assert "applyDisplaySettings" in body, ( + "DOMContentLoaded handler must call applyDisplaySettings(loadDisplaySettings())" + ) + assert "loadDisplaySettings" in body, ( + "DOMContentLoaded handler must call applyDisplaySettings(loadDisplaySettings())" + ) + + +def test_exports_apply_display_settings() -> None: + """module.exports must export applyDisplaySettings.""" + match = re.search( + r"module\.exports\s*=\s*\{(.*?)\};", + _JS, + re.DOTALL, + ) + assert match, "module.exports block not found" + exports = match.group(1) + assert "applyDisplaySettings" in exports, ( + "module.exports must export applyDisplaySettings" + ) + + +def test_exports_on_display_setting_change() -> None: + """module.exports must export onDisplaySettingChange.""" + match = re.search( + r"module\.exports\s*=\s*\{(.*?)\};", + _JS, + re.DOTALL, + ) + assert match, "module.exports block not found" + exports = match.group(1) + assert "onDisplaySettingChange" in exports, ( + "module.exports must export onDisplaySettingChange" + )