diff --git a/docs/plans/2026-04-08-server-side-settings-plan.md b/docs/plans/2026-04-08-server-side-settings-plan.md new file mode 100644 index 0000000..bae1fe1 --- /dev/null +++ b/docs/plans/2026-04-08-server-side-settings-plan.md @@ -0,0 +1,1046 @@ +# Consolidate Settings Server-Side — Implementation Plan + +> **Execution:** Use the subagent-driven-development workflow to implement this plan. + +**Goal:** Move all browser localStorage settings to server-side `settings.json` so the only remaining localStorage usage is `tmux-web-device-id`. + +**Architecture:** Add 10 flat keys to `DEFAULT_SETTINGS` in Python (zero infrastructure changes — existing `load/save/patch_settings` iterate `DEFAULT_SETTINGS` automatically). Replace the localStorage read/write layer in `app.js` with a `getDisplaySettings()` function that reads from the `_serverSettings` in-memory cache. Eager-load settings on startup via `await loadServerSettings()` before first render. + +**Tech Stack:** Python (FastAPI server, pytest), vanilla JS (no framework, no build system, Node.js test runner) + +**Design doc:** `docs/plans/2026-04-08-server-side-settings-design.md` + +--- + +## Codebase orientation + +All paths are relative to the `muxplex/` submodule root at `/home/bkrabach/dev/muxplex-settings/muxplex/`. + +| File | Role | +|------|------| +| `muxplex/settings.py` | `DEFAULT_SETTINGS` dict (line 16–34), `load_settings()`, `save_settings()`, `patch_settings()` | +| `muxplex/frontend/app.js` | ~2613 lines, vanilla JS, all frontend logic | +| `muxplex/tests/test_settings.py` | pytest suite, uses `tmp_path` + `monkeypatch` to redirect `SETTINGS_PATH` | +| `muxplex/frontend/tests/test_app.mjs` | Node.js `node:test` runner, ~4495 lines, CJS `require()` import of `app.js` | +| `muxplex/main.py` | FastAPI routes — **NO changes needed** (`GET/PATCH /api/settings` auto-handles new keys) | + +**Run commands:** +- Python tests: `cd /home/bkrabach/dev/muxplex-settings/muxplex && python3 -m pytest muxplex/tests/test_settings.py -v` +- JS tests: `cd /home/bkrabach/dev/muxplex-settings/muxplex && node --test muxplex/frontend/tests/test_app.mjs` + +**Note on `auto_open` fix:** The design doc mentions fixing `ss.auto_open` → `ss.auto_open_created`. This has already been corrected in the current codebase (all 4 references use `auto_open_created`). Task 3 includes a verification grep to confirm. + +--- + +## Task 1: Add display settings to `DEFAULT_SETTINGS` + +**Files:** +- Modify: `muxplex/settings.py` (lines 16–34) +- Test: `muxplex/tests/test_settings.py` + +### Step 1: Write the failing tests + +Add the following two tests at the **bottom** of `muxplex/tests/test_settings.py`, before any final blank line: + +```python +# ============================================================ +# Display settings (task: consolidate settings server-side) +# ============================================================ + + +def test_defaults_include_display_settings(): + """DEFAULT_SETTINGS must include all 10 display/UI keys with correct defaults.""" + expected = { + "fontSize": 14, + "hoverPreviewDelay": 1500, + "gridColumns": "auto", + "bellSound": False, + "viewMode": "auto", + "showDeviceBadges": True, + "showHoverPreview": True, + "activityIndicator": "both", + "gridViewMode": "flat", + "sidebarOpen": None, + } + for key, value in expected.items(): + assert key in DEFAULT_SETTINGS, ( + f"DEFAULT_SETTINGS must include '{key}'" + ) + assert DEFAULT_SETTINGS[key] == value, ( + f"DEFAULT_SETTINGS['{key}'] must be {value!r}, got: {DEFAULT_SETTINGS[key]!r}" + ) + + +def test_display_settings_round_trip_via_patch(): + """Display settings survive a patch_settings() + load_settings() cycle.""" + custom = { + "fontSize": 18, + "hoverPreviewDelay": 500, + "gridColumns": 3, + "bellSound": True, + "viewMode": "fit", + "showDeviceBadges": False, + "showHoverPreview": False, + "activityIndicator": "glow", + "gridViewMode": "grouped", + "sidebarOpen": True, + } + result = patch_settings(custom) + for key, value in custom.items(): + assert result[key] == value, ( + f"patch_settings() must accept '{key}={value!r}', got: {result[key]!r}" + ) + + loaded = load_settings() + for key, value in custom.items(): + assert loaded[key] == value, ( + f"load_settings() must return '{key}={value!r}' after patch, got: {loaded[key]!r}" + ) +``` + +### Step 2: Run tests to verify they fail + +Run: +```bash +cd /home/bkrabach/dev/muxplex-settings/muxplex && python3 -m pytest muxplex/tests/test_settings.py::test_defaults_include_display_settings muxplex/tests/test_settings.py::test_display_settings_round_trip_via_patch -v +``` + +Expected: **FAIL** — `fontSize` not in `DEFAULT_SETTINGS`. + +### Step 3: Add the 10 new keys to `DEFAULT_SETTINGS` + +In `muxplex/settings.py`, add these 10 entries to the `DEFAULT_SETTINGS` dict (after the `"tls_key": ""` line, before the closing `}`): + +```python + # Display preferences (consumed by frontend) + "fontSize": 14, + "hoverPreviewDelay": 1500, + "gridColumns": "auto", + "bellSound": False, + "viewMode": "auto", + "showDeviceBadges": True, + "showHoverPreview": True, + "activityIndicator": "both", + "gridViewMode": "flat", + # UI state + "sidebarOpen": None, +``` + +The dict should now have 27 keys total (17 existing + 10 new). + +### Step 4: Run tests to verify they pass + +Run: +```bash +cd /home/bkrabach/dev/muxplex-settings/muxplex && python3 -m pytest muxplex/tests/test_settings.py -v +``` + +Expected: **ALL PASS** — both new tests and all existing tests. + +### Step 5: Commit + +```bash +cd /home/bkrabach/dev/muxplex-settings/muxplex && git add muxplex/settings.py muxplex/tests/test_settings.py && git commit -m "feat: add display settings keys to DEFAULT_SETTINGS + +Add 10 new flat keys for display preferences (fontSize, +hoverPreviewDelay, gridColumns, bellSound, viewMode, showDeviceBadges, +showHoverPreview, activityIndicator, gridViewMode) and UI state +(sidebarOpen) to DEFAULT_SETTINGS. + +No changes needed to load/save/patch functions — they iterate +DEFAULT_SETTINGS keys automatically." +``` + +--- + +## Task 2: Replace frontend storage layer + +**Files:** +- Modify: `muxplex/frontend/app.js` + +This task deletes the localStorage-based storage functions and replaces them with a server-settings-cache-based accessor. No callsites are updated yet (that's Task 3). + +### Step 1: Delete `DISPLAY_SETTINGS_KEY` constant + +Find this line (around line 140): + +```javascript +const DISPLAY_SETTINGS_KEY = 'muxplex.display'; +``` + +Delete the entire line. + +### Step 2: Remove `notificationPermission` from `DISPLAY_DEFAULTS` and add `gridViewMode` + +Find the `DISPLAY_DEFAULTS` object (around lines 141–151). Replace it with: + +```javascript +const DISPLAY_DEFAULTS = { + fontSize: 14, + hoverPreviewDelay: 1500, + gridColumns: 'auto', + bellSound: false, + viewMode: 'auto', + showDeviceBadges: true, + showHoverPreview: true, + activityIndicator: 'both', + gridViewMode: 'flat', +}; +``` + +Changes: removed `notificationPermission: 'default'`, added `gridViewMode: 'flat'`. + +### Step 3: Delete `SIDEBAR_KEY` constant + +Find this line (around line 598): + +```javascript +const SIDEBAR_KEY = 'muxplex.sidebarOpen'; +``` + +Delete the entire line. + +### Step 4: Replace `loadDisplaySettings()` with `getDisplaySettings()` + +Find the `loadDisplaySettings` function (around lines 1435–1444): + +```javascript +/** + * 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); + } +} +``` + +Replace the entire function with: + +```javascript +/** + * Get display settings from the server settings cache, falling back to + * DISPLAY_DEFAULTS for any missing keys or before the initial fetch. + * @returns {object} + */ +function getDisplaySettings() { + var result = Object.assign({}, DISPLAY_DEFAULTS); + if (_serverSettings) { + for (var key in DISPLAY_DEFAULTS) { + if (Object.prototype.hasOwnProperty.call(DISPLAY_DEFAULTS, key) && _serverSettings[key] !== undefined) { + result[key] = _serverSettings[key]; + } + } + } + return result; +} +``` + +### Step 5: Delete `saveDisplaySettings()` + +Find the `saveDisplaySettings` function (immediately after `getDisplaySettings`, around lines 1446–1454): + +```javascript +/** + * Save display settings to localStorage. + * @param {object} settings + */ +function saveDisplaySettings(settings) { + try { + localStorage.setItem(DISPLAY_SETTINGS_KEY, JSON.stringify(settings)); + } catch (_) { /* blocked — ok */ } +} +``` + +Delete the entire function (JSDoc comment and all). + +### Step 6: Update module.exports + +Find the exports block at the bottom of `app.js` (around lines 2527–2613). In the `module.exports` object: + +1. Replace `loadDisplaySettings,` with `getDisplaySettings,` +2. Delete `saveDisplaySettings,` + +### Step 7: Verify the file parses + +Run: +```bash +cd /home/bkrabach/dev/muxplex-settings/muxplex && node -e "require('./muxplex/frontend/app.js')" 2>&1 | head -5 +``` + +Expected: Errors about `loadDisplaySettings is not defined` at various callsites. This is expected — callsites are updated in Task 3. + +### Step 8: Commit + +```bash +cd /home/bkrabach/dev/muxplex-settings/muxplex && git add muxplex/frontend/app.js && git commit -m "refactor: replace localStorage storage layer with server-settings cache + +Delete DISPLAY_SETTINGS_KEY, SIDEBAR_KEY constants. +Delete loadDisplaySettings() and saveDisplaySettings() functions. +Add getDisplaySettings() that reads from _serverSettings cache +with DISPLAY_DEFAULTS fallback. +Remove notificationPermission from DISPLAY_DEFAULTS (browser API +is the source of truth). Add gridViewMode to DISPLAY_DEFAULTS. + +Callsites are updated in the next commit — this commit will have +broken references until then." +``` + +--- + +## Task 3: Update all frontend callsites + +**Files:** +- Modify: `muxplex/frontend/app.js` + +This task is entirely mechanical: rename all `loadDisplaySettings()` calls to `getDisplaySettings()`, and replace all `saveDisplaySettings()` calls with `patchServerSetting()` equivalents. Here is every single callsite. + +### Step 1: Rename all `loadDisplaySettings()` → `getDisplaySettings()` reads + +There are 15 read callsites (excluding the deleted definition and the already-updated export). Find and replace each one. You can use a global find-and-replace for this since the function name is unique: + +**Replace all occurrences** of `loadDisplaySettings()` with `getDisplaySettings()` in `app.js`. + +The affected lines (approximate, may shift after Task 2 edits) are: + +| Line | Function | What it reads | +|------|----------|---------------| +| ~407 | `buildTileHTML()` | `activityIndicator`, `showDeviceBadges` | +| ~465 | `buildSidebarHTML()` | `activityIndicator` | +| ~863 | `renderGrid()` | `viewMode` | +| ~890 | `showPreview()` | `showHoverPreview` | +| ~1289 | `closeSession()` | `viewMode` | +| ~1499 | `cycleViewMode()` | `viewMode` | +| ~1560 | `loadGridViewMode()` | `gridViewMode` | +| ~1569 | `saveGridViewMode()` | all (reads before writing) | +| ~1581 | `onDisplaySettingChange()` | all display controls | +| ~1642 | `openSettings()` | all display controls | +| ~2238 | hover handler (grid) | `hoverPreviewDelay` | +| ~2257 | hover handler (sidebar) | `hoverPreviewDelay` | +| ~2318 | bell sound handler | all (reads before writing) | +| ~2328 | notification permission handler | all (reads before writing) | +| ~2491 | resize handler | `viewMode` | +| ~2500 | `DOMContentLoaded` | all (initial apply) | + +### Step 2: Replace `saveDisplaySettings()` in `cycleViewMode()` + +Find `cycleViewMode()` (around line 1498). The current code is: + +```javascript +function cycleViewMode() { + var ds = getDisplaySettings(); + var idx = VIEW_MODES.indexOf(ds.viewMode || 'auto'); + ds.viewMode = VIEW_MODES[(idx + 1) % VIEW_MODES.length]; + saveDisplaySettings(ds); + applyDisplaySettings(ds); + + // Update button label + var btn = document.getElementById('view-mode-btn'); + if (btn) btn.title = 'View: ' + ds.viewMode; +} +``` + +Replace `saveDisplaySettings(ds);` with: + +```javascript + if (_serverSettings) _serverSettings.viewMode = ds.viewMode; + patchServerSetting('viewMode', ds.viewMode); +``` + +The full function becomes: + +```javascript +function cycleViewMode() { + var ds = getDisplaySettings(); + var idx = VIEW_MODES.indexOf(ds.viewMode || 'auto'); + ds.viewMode = VIEW_MODES[(idx + 1) % VIEW_MODES.length]; + if (_serverSettings) _serverSettings.viewMode = ds.viewMode; + patchServerSetting('viewMode', ds.viewMode); + applyDisplaySettings(ds); + + // Update button label + var btn = document.getElementById('view-mode-btn'); + if (btn) btn.title = 'View: ' + ds.viewMode; +} +``` + +### Step 3: Rewrite `saveGridViewMode()` + +Find `saveGridViewMode()` (around line 1568). Replace the entire function: + +**Before:** +```javascript +function saveGridViewMode(mode) { + var ds = getDisplaySettings(); + ds.gridViewMode = mode; + saveDisplaySettings(ds); + _gridViewMode = mode; +} +``` + +**After:** +```javascript +function saveGridViewMode(mode) { + if (_serverSettings) _serverSettings.gridViewMode = mode; + patchServerSetting('gridViewMode', mode); + _gridViewMode = mode; +} +``` + +### Step 4: Rewrite `onDisplaySettingChange()` to batch-PATCH + +Find `onDisplaySettingChange()` (around line 1580). Replace the entire function: + +**Before:** +```javascript +function onDisplaySettingChange() { + var ds = getDisplaySettings(); + + 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); + } + + var showDeviceBadgesEl = document.getElementById('setting-show-device-badges'); + if (showDeviceBadgesEl) ds.showDeviceBadges = showDeviceBadgesEl.checked; + + var showHoverPreviewEl = document.getElementById('setting-show-hover-preview'); + if (showHoverPreviewEl) ds.showHoverPreview = showHoverPreviewEl.checked; + + var activityIndicatorEl = document.getElementById('setting-activity-indicator'); + if (activityIndicatorEl) ds.activityIndicator = activityIndicatorEl.value; + + saveDisplaySettings(ds); + applyDisplaySettings(ds); +} +``` + +**After:** +```javascript +function onDisplaySettingChange() { + var ds = getDisplaySettings(); + + 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); + } + + var showDeviceBadgesEl = document.getElementById('setting-show-device-badges'); + if (showDeviceBadgesEl) ds.showDeviceBadges = showDeviceBadgesEl.checked; + + var showHoverPreviewEl = document.getElementById('setting-show-hover-preview'); + if (showHoverPreviewEl) ds.showHoverPreview = showHoverPreviewEl.checked; + + var activityIndicatorEl = document.getElementById('setting-activity-indicator'); + if (activityIndicatorEl) ds.activityIndicator = activityIndicatorEl.value; + + // Batch-update server settings cache and persist + var patch = { + fontSize: ds.fontSize, + hoverPreviewDelay: ds.hoverPreviewDelay, + gridColumns: ds.gridColumns, + showDeviceBadges: ds.showDeviceBadges, + showHoverPreview: ds.showHoverPreview, + activityIndicator: ds.activityIndicator, + }; + if (_serverSettings) Object.assign(_serverSettings, patch); + api('PATCH', '/api/settings', patch).then(function() { + showToast('Setting saved'); + }).catch(function(err) { + showToast('Failed to save setting'); + console.warn('[onDisplaySettingChange] failed:', err); + }); + + applyDisplaySettings(ds); +} +``` + +### Step 5: Replace `saveDisplaySettings()` in the bell sound handler + +Find the bell sound change handler in `bindStaticEventListeners()` (around line 2317): + +**Before:** +```javascript + // Notifications settings — bell sound toggle persists to display settings localStorage + on($('setting-bell-sound'), 'change', function() { + const ds = getDisplaySettings(); + ds.bellSound = this.checked; + saveDisplaySettings(ds); + }); +``` + +**After:** +```javascript + // Notifications settings — bell sound toggle persists to server settings + on($('setting-bell-sound'), 'change', function() { + if (_serverSettings) _serverSettings.bellSound = this.checked; + patchServerSetting('bellSound', this.checked); + }); +``` + +### Step 6: Simplify the notification permission handler + +Find the notification request button handler (around line 2324): + +**Before:** +```javascript + on($('notification-request-btn'), 'click', function() { + if (typeof Notification === 'undefined') return; + Notification.requestPermission().then(function(permission) { + _notificationPermission = permission; + const ds = getDisplaySettings(); + ds.notificationPermission = permission; + saveDisplaySettings(ds); + // Update UI state + const statusEl = $('notification-status-text'); + const reqBtn = $('notification-request-btn'); + if (statusEl && reqBtn) { + _updateNotificationUI(statusEl, reqBtn, permission); + } + }).catch(function(err) { + console.error('Notification.requestPermission() failed:', err); + }); + }); +``` + +**After** (remove the `getDisplaySettings` / `saveDisplaySettings` lines — `notificationPermission` is no longer stored): +```javascript + on($('notification-request-btn'), 'click', function() { + if (typeof Notification === 'undefined') return; + Notification.requestPermission().then(function(permission) { + _notificationPermission = permission; + // Update UI state + const statusEl = $('notification-status-text'); + const reqBtn = $('notification-request-btn'); + if (statusEl && reqBtn) { + _updateNotificationUI(statusEl, reqBtn, permission); + } + }).catch(function(err) { + console.error('Notification.requestPermission() failed:', err); + }); + }); +``` + +### Step 7: Verify `auto_open_created` is correct + +Run: +```bash +cd /home/bkrabach/dev/muxplex-settings/muxplex && grep -n 'auto_open' muxplex/frontend/app.js +``` + +Expected: All occurrences should show `auto_open_created` — no bare `auto_open` references. (This was already fixed in a prior commit.) + +### Step 8: Verify no `saveDisplaySettings` references remain + +Run: +```bash +cd /home/bkrabach/dev/muxplex-settings/muxplex && grep -n 'saveDisplaySettings' muxplex/frontend/app.js +``` + +Expected: **Zero matches.** + +### Step 9: Commit + +```bash +cd /home/bkrabach/dev/muxplex-settings/muxplex && git add muxplex/frontend/app.js && git commit -m "refactor: update all display settings callsites to use server settings + +Replace 15 loadDisplaySettings() calls with getDisplaySettings(). +Replace saveDisplaySettings() calls in cycleViewMode, saveGridViewMode, +onDisplaySettingChange, and bell sound handler with patchServerSetting() +or batch PATCH. +Remove notificationPermission storage from notification request handler +(browser API is the source of truth). +Verify auto_open_created alignment is correct." +``` + +--- + +## Task 4: Rewrite sidebar functions + +**Files:** +- Modify: `muxplex/frontend/app.js` + +The three sidebar functions (`initSidebar`, `toggleSidebar`, `bindSidebarClickAway`) currently read/write `localStorage` via `SIDEBAR_KEY`. Rewrite them to use `_serverSettings.sidebarOpen` with `patchServerSetting()`. + +### Step 1: Rewrite `initSidebar()` + +Find `initSidebar()` (around line 607, after `SIDEBAR_NARROW_THRESHOLD`). Replace the entire function: + +**Before:** +```javascript +/** + * Initialise sidebar open/closed state on page load. + * Reads muxplex.sidebarOpen from localStorage (JSON.parse with try/catch). + * Defaults to open on wide screens (innerWidth >= 960) when no stored value. + * Applies sidebar--collapsed class accordingly and persists the initial state. + */ +function initSidebar() { + let isOpen; + try { + const stored = localStorage.getItem(SIDEBAR_KEY); + if (stored !== null) { + isOpen = JSON.parse(stored); + } else { + isOpen = window.innerWidth >= SIDEBAR_NARROW_THRESHOLD; + } + } catch (_) { + isOpen = window.innerWidth >= SIDEBAR_NARROW_THRESHOLD; + } + + const sidebar = $('session-sidebar'); + if (sidebar) { + if (isOpen) { + sidebar.classList.remove('sidebar--collapsed'); + } else { + sidebar.classList.add('sidebar--collapsed'); + } + } + + // Persist initial state + try { + localStorage.setItem(SIDEBAR_KEY, JSON.stringify(isOpen)); + } catch (_) { /* blocked — ok */ } +} +``` + +**After:** +```javascript +/** + * Initialise sidebar open/closed state on page load. + * Reads sidebarOpen from server settings cache. + * When null (never set by user), defaults to open on wide screens (>= 960px). + * Applies sidebar--collapsed class accordingly. + */ +function initSidebar() { + var sidebarSetting = _serverSettings ? _serverSettings.sidebarOpen : null; + var isOpen; + if (sidebarSetting === true || sidebarSetting === false) { + isOpen = sidebarSetting; + } else { + // null or undefined = auto-detect from screen width + isOpen = window.innerWidth >= SIDEBAR_NARROW_THRESHOLD; + } + + var sidebar = $('session-sidebar'); + if (sidebar) { + if (isOpen) { + sidebar.classList.remove('sidebar--collapsed'); + } else { + sidebar.classList.add('sidebar--collapsed'); + } + } +} +``` + +### Step 2: Rewrite `toggleSidebar()` + +Find `toggleSidebar()` (around line 641). Replace the entire function: + +**Before:** +```javascript +/** + * Toggle the sidebar open/closed state. + * Reads current state from localStorage, inverts it, persists, applies + * sidebar--collapsed class, and updates the collapse button text. + * Button shows ‹ when open, › when closed. + */ +function toggleSidebar() { + let isOpen; + try { + const stored = localStorage.getItem(SIDEBAR_KEY); + isOpen = stored !== null ? JSON.parse(stored) : true; + } catch (_) { + isOpen = true; + } + + // Invert state + isOpen = !isOpen; + + // Persist + try { + localStorage.setItem(SIDEBAR_KEY, JSON.stringify(isOpen)); + } catch (_) { /* blocked — ok */ } + + // Apply class + const sidebar = $('session-sidebar'); + if (sidebar) { + if (isOpen) { + sidebar.classList.remove('sidebar--collapsed'); + } else { + sidebar.classList.add('sidebar--collapsed'); + } + } + + // Update collapse button text (‹ when open, › when closed) + const collapseBtn = $('sidebar-collapse-btn'); + if (collapseBtn) { + collapseBtn.textContent = isOpen ? '\u2039' : '\u203a'; + } +} +``` + +**After:** +```javascript +/** + * Toggle the sidebar open/closed state. + * Reads current state from server settings cache, inverts it, persists + * via patchServerSetting (fire-and-forget), applies sidebar--collapsed + * class, and updates the collapse button text. + * Button shows ‹ when open, › when closed. + */ +function toggleSidebar() { + var sidebarSetting = _serverSettings ? _serverSettings.sidebarOpen : null; + var isOpen; + if (sidebarSetting === true || sidebarSetting === false) { + isOpen = sidebarSetting; + } else { + isOpen = true; // default to open if never set + } + + // Invert state + isOpen = !isOpen; + + // Update cache and persist (fire-and-forget) + if (_serverSettings) _serverSettings.sidebarOpen = isOpen; + patchServerSetting('sidebarOpen', isOpen); + + // Apply class + var sidebar = $('session-sidebar'); + if (sidebar) { + if (isOpen) { + sidebar.classList.remove('sidebar--collapsed'); + } else { + sidebar.classList.add('sidebar--collapsed'); + } + } + + // Update collapse button text (‹ when open, › when closed) + var collapseBtn = $('sidebar-collapse-btn'); + if (collapseBtn) { + collapseBtn.textContent = isOpen ? '\u2039' : '\u203a'; + } +} +``` + +### Step 3: Rewrite `bindSidebarClickAway()` + +Find `bindSidebarClickAway()` (around line 683). Replace the entire function: + +**Before:** +```javascript +/** + * Bind a click-away handler on #terminal-container that collapses the sidebar + * when the user taps outside of it in overlay mode (window.innerWidth < 960). + * Returns early without collapsing if: + * - the screen is wide enough that the sidebar is not in overlay mode (>= 960px) + * - the sidebar element is missing + * - the sidebar is already collapsed + */ +function bindSidebarClickAway() { + const container = $('terminal-container'); + if (!container) return; + container.addEventListener('click', () => { + if (window.innerWidth >= SIDEBAR_NARROW_THRESHOLD) return; + const sidebar = $('session-sidebar'); + if (!sidebar) return; + if (sidebar.classList.contains('sidebar--collapsed')) return; + sidebar.classList.add('sidebar--collapsed'); + try { + localStorage.setItem(SIDEBAR_KEY, JSON.stringify(false)); + } catch (_) { /* blocked — ok */ } + }); +} +``` + +**After:** +```javascript +/** + * Bind a click-away handler on #terminal-container that collapses the sidebar + * when the user taps outside of it in overlay mode (window.innerWidth < 960). + * Returns early without collapsing if: + * - the screen is wide enough that the sidebar is not in overlay mode (>= 960px) + * - the sidebar element is missing + * - the sidebar is already collapsed + */ +function bindSidebarClickAway() { + var container = $('terminal-container'); + if (!container) return; + container.addEventListener('click', function() { + if (window.innerWidth >= SIDEBAR_NARROW_THRESHOLD) return; + var sidebar = $('session-sidebar'); + if (!sidebar) return; + if (sidebar.classList.contains('sidebar--collapsed')) return; + sidebar.classList.add('sidebar--collapsed'); + if (_serverSettings) _serverSettings.sidebarOpen = false; + patchServerSetting('sidebarOpen', false); + }); +} +``` + +### Step 4: Verify no localStorage references remain (except `tmux-web-device-id`) + +Run: +```bash +cd /home/bkrabach/dev/muxplex-settings/muxplex && grep -n 'localStorage' muxplex/frontend/app.js +``` + +Expected: Only 3 occurrences, all inside `initDeviceId()` (around lines 191, 194, 200): +``` +191: let id = localStorage.getItem(STORAGE_KEY); +194: try { localStorage.setItem(STORAGE_KEY, id); } catch (_) { /* blocked — ok */ } +200: if (!_deviceId) _deviceId = generateDeviceId(); +``` + +No other `localStorage` references should exist. + +### Step 5: Verify no `SIDEBAR_KEY` or `DISPLAY_SETTINGS_KEY` references remain + +Run: +```bash +cd /home/bkrabach/dev/muxplex-settings/muxplex && grep -n 'DISPLAY_SETTINGS_KEY\|SIDEBAR_KEY\|muxplex\.display\|muxplex\.sidebarOpen' muxplex/frontend/app.js +``` + +Expected: **Zero matches.** + +### Step 6: Commit + +```bash +cd /home/bkrabach/dev/muxplex-settings/muxplex && git add muxplex/frontend/app.js && git commit -m "refactor: rewrite sidebar functions to use server settings + +Rewrite initSidebar(), toggleSidebar(), bindSidebarClickAway() to read +from _serverSettings.sidebarOpen instead of localStorage. +Persist sidebar state via patchServerSetting() (fire-and-forget). +sidebarOpen=null triggers screen-width auto-detect (>= 960px = open). + +Only remaining localStorage usage: tmux-web-device-id in initDeviceId()." +``` + +--- + +## Task 5: Update startup sequence and tests + +**Files:** +- Modify: `muxplex/frontend/app.js` +- Modify: `muxplex/frontend/tests/test_app.mjs` + +### Step 1: Rewrite the `DOMContentLoaded` handler + +Find the handler (around line 2498). Replace the entire block: + +**Before:** +```javascript +document.addEventListener('DOMContentLoaded', () => { + initDeviceId(); + var _initDs = getDisplaySettings(); + applyDisplaySettings(_initDs); + _gridViewMode = loadGridViewMode(); + + // Initialize view mode button title + var vmBtn = document.getElementById('view-mode-btn'); + if (vmBtn) vmBtn.title = 'View: ' + (_initDs.viewMode || 'auto'); + + document.addEventListener('keydown', trackInteraction); + document.addEventListener('click', trackInteraction); + document.addEventListener('touchstart', trackInteraction); + + restoreState() + .then(() => { + startPolling(); + loadServerSettings().then(function() { + updatePageTitle(); + }); + startHeartbeat(); + bindStaticEventListeners(); + }) + .catch((err) => { + console.error('[init] restoreState failed, retrying in 5s:', err); + setTimeout(() => startPolling(), POLL_MS); + }); +}); +``` + +**After:** +```javascript +document.addEventListener('DOMContentLoaded', async () => { + initDeviceId(); + await loadServerSettings(); + var _initDs = getDisplaySettings(); + applyDisplaySettings(_initDs); + _gridViewMode = loadGridViewMode(); + + // Initialize view mode button title + var vmBtn = document.getElementById('view-mode-btn'); + if (vmBtn) vmBtn.title = 'View: ' + (_initDs.viewMode || 'auto'); + + document.addEventListener('keydown', trackInteraction); + document.addEventListener('click', trackInteraction); + document.addEventListener('touchstart', trackInteraction); + + restoreState() + .then(() => { + startPolling(); + updatePageTitle(); + startHeartbeat(); + bindStaticEventListeners(); + }) + .catch((err) => { + console.error('[init] restoreState failed, retrying in 5s:', err); + setTimeout(() => startPolling(), POLL_MS); + }); +}); +``` + +Key changes: +1. Added `async` to the arrow function +2. Added `await loadServerSettings()` **before** `getDisplaySettings()` — settings must be loaded before first render +3. `loadDisplaySettings()` was already renamed to `getDisplaySettings()` in Task 3 +4. Removed the nested `loadServerSettings().then(...)` inside the `.then()` chain (it's now at the top) +5. Moved `updatePageTitle()` directly into the `.then()` chain (no longer needs its own `.then`) + +### Step 2: Update the `cycleViewMode` test in `test_app.mjs` + +Find the test at around line 3080 in `muxplex/frontend/tests/test_app.mjs`: + +**Before:** +```javascript +test('cycleViewMode cycles through auto -> fit -> auto (two modes, compact removed)', () => { + // Reset display settings to auto + const ds = app.loadDisplaySettings(); + ds.viewMode = 'auto'; + app.saveDisplaySettings(ds); + + // First cycle: auto -> fit + app.cycleViewMode(); + const ds1 = app.loadDisplaySettings(); + assert.strictEqual(ds1.viewMode, 'fit', 'first cycle should go auto -> fit'); + + // Second cycle: fit -> auto (wraps, compact is gone) + app.cycleViewMode(); + const ds2 = app.loadDisplaySettings(); + assert.strictEqual(ds2.viewMode, 'auto', 'second cycle should wrap fit -> auto (only two modes)'); +}); +``` + +**After:** +```javascript +test('cycleViewMode cycles through auto -> fit -> auto (two modes, compact removed)', () => { + // Reset display settings to auto via server settings cache + app._setServerSettings({ viewMode: 'auto' }); + + // First cycle: auto -> fit + app.cycleViewMode(); + const ds1 = app.getDisplaySettings(); + assert.strictEqual(ds1.viewMode, 'fit', 'first cycle should go auto -> fit'); + + // Second cycle: fit -> auto (wraps, compact is gone) + app.cycleViewMode(); + const ds2 = app.getDisplaySettings(); + assert.strictEqual(ds2.viewMode, 'auto', 'second cycle should wrap fit -> auto (only two modes)'); +}); +``` + +### Step 3: Search for any other test references to the old functions + +Run: +```bash +cd /home/bkrabach/dev/muxplex-settings/muxplex && grep -n 'loadDisplaySettings\|saveDisplaySettings' muxplex/frontend/tests/test_app.mjs +``` + +Expected: **Zero matches** after the edits in Step 2. + +### Step 4: Run the full verification checklist + +Run each command and verify the expected output: + +**Check 1: localStorage only used for device ID** +```bash +cd /home/bkrabach/dev/muxplex-settings/muxplex && grep -n 'localStorage' muxplex/frontend/app.js +``` +Expected: Only lines inside `initDeviceId()` (3 occurrences around lines 191, 194, 200). + +**Check 2: No old constants or key strings** +```bash +cd /home/bkrabach/dev/muxplex-settings/muxplex && grep -n 'DISPLAY_SETTINGS_KEY\|SIDEBAR_KEY\|muxplex\.display\|muxplex\.sidebarOpen' muxplex/frontend/app.js +``` +Expected: **Zero matches.** + +**Check 3: No old function names** +```bash +cd /home/bkrabach/dev/muxplex-settings/muxplex && grep -n 'loadDisplaySettings\|saveDisplaySettings' muxplex/frontend/app.js +``` +Expected: **Zero matches.** + +**Check 4: Python tests pass** +```bash +cd /home/bkrabach/dev/muxplex-settings/muxplex && python3 -m pytest muxplex/tests/test_settings.py -v +``` +Expected: **ALL PASS.** + +**Check 5: JS tests pass** +```bash +cd /home/bkrabach/dev/muxplex-settings/muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -20 +``` +Expected: Tests pass (check for `# pass` count and no unexpected failures). + +**Check 6: app.js parses without errors** +```bash +cd /home/bkrabach/dev/muxplex-settings/muxplex && node -e "const app = require('./muxplex/frontend/app.js'); console.log('getDisplaySettings' in app ? 'OK: getDisplaySettings exported' : 'FAIL: getDisplaySettings missing'); console.log('loadDisplaySettings' in app ? 'FAIL: loadDisplaySettings still exported' : 'OK: loadDisplaySettings removed'); console.log('saveDisplaySettings' in app ? 'FAIL: saveDisplaySettings still exported' : 'OK: saveDisplaySettings removed');" +``` +Expected: +``` +OK: getDisplaySettings exported +OK: loadDisplaySettings removed +OK: saveDisplaySettings removed +``` + +### Step 5: Commit + +```bash +cd /home/bkrabach/dev/muxplex-settings/muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs && git commit -m "feat: eager-load server settings on startup, update tests + +Make DOMContentLoaded handler async and await loadServerSettings() +before first render so display settings (fontSize, gridColumns, etc.) +are available before anything renders. +Remove redundant loadServerSettings() call from restoreState chain. +Update cycleViewMode test to use _setServerSettings/getDisplaySettings +instead of localStorage-based functions." +``` + +--- + +## Summary of all changes + +| File | What changed | +|------|-------------| +| `muxplex/settings.py` | +10 keys in `DEFAULT_SETTINGS` | +| `muxplex/tests/test_settings.py` | +2 tests (defaults exist, round-trip via patch) | +| `muxplex/frontend/app.js` | Delete `DISPLAY_SETTINGS_KEY`, `SIDEBAR_KEY`, `loadDisplaySettings()`, `saveDisplaySettings()`. Add `getDisplaySettings()`. Rewrite `initSidebar()`, `toggleSidebar()`, `bindSidebarClickAway()`. Update `cycleViewMode()`, `saveGridViewMode()`, `onDisplaySettingChange()`, bell/notification handlers. Async DOMContentLoaded with eager settings load. | +| `muxplex/frontend/tests/test_app.mjs` | Update `cycleViewMode` test to use server settings cache | +| `muxplex/main.py` | **No changes** | + +**After this work, the only remaining `localStorage` usage is `tmux-web-device-id` in `initDeviceId()` — per-browser device identity for heartbeats, not a user setting.** + +## Future work + +- **Federation settings sync**: Investigate syncing user preferences across federated servers (deferred — each server owns its own settings for now). diff --git a/muxplex/tests/test_frontend_js.py b/muxplex/tests/test_frontend_js.py index 73c76dc..ffd09eb 100644 --- a/muxplex/tests/test_frontend_js.py +++ b/muxplex/tests/test_frontend_js.py @@ -328,15 +328,25 @@ def test_settings_open_state_variable_exists() -> None: 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_get_display_settings_function_exists_via_constant_section() -> None: + """getDisplaySettings function must exist (display settings are now server-side).""" + assert "function getDisplaySettings" in _JS, ( + "getDisplaySettings must be defined 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_get_display_settings_reads_server_settings() -> None: + """getDisplaySettings must read from _serverSettings (not localStorage).""" + match = re.search( + r"function getDisplaySettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// |\n/\*)", + _JS, + re.DOTALL, + ) + assert match, "getDisplaySettings function not found" + body = match.group(1) + assert "_serverSettings" in body, "getDisplaySettings must read from _serverSettings" + assert "localStorage" not in body, ( + "getDisplaySettings must not use localStorage — display settings are server-side" ) @@ -376,17 +386,17 @@ def test_display_defaults_has_notification_permission() -> None: # ── 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_get_display_settings_function_exists() -> None: + """getDisplaySettings function must exist.""" + assert "function getDisplaySettings" in _JS, ( + "getDisplaySettings 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_save_display_settings_function_absent() -> None: + """saveDisplaySettings must not exist — display writes go via patchServerSetting.""" + assert "function saveDisplaySettings" not in _JS, ( + "saveDisplaySettings must not be defined in app.js — use patchServerSetting instead" ) @@ -410,76 +420,80 @@ def test_switch_settings_tab_function_exists() -> None: # ── loadDisplaySettings implementation ─────────────────────────────────────── -def test_load_display_settings_reads_from_localstorage() -> None: - """loadDisplaySettings must read from localStorage using DISPLAY_SETTINGS_KEY.""" +def test_get_display_settings_reads_from_server_settings() -> None: + """getDisplaySettings must read from _serverSettings, not localStorage.""" match = re.search( - r"function loadDisplaySettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", + r"function getDisplaySettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// |\n/\*)", _JS, re.DOTALL, ) - assert match, "loadDisplaySettings function not found" + assert match, "getDisplaySettings 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" + assert "_serverSettings" in body, "getDisplaySettings must read from _serverSettings" + assert "localStorage" not in body, ( + "getDisplaySettings must not use localStorage — display settings are server-side" ) -def test_load_display_settings_uses_object_assign() -> None: - """loadDisplaySettings must merge with DISPLAY_DEFAULTS via Object.assign.""" +def test_get_display_settings_uses_object_assign() -> None: + """getDisplaySettings must merge with DISPLAY_DEFAULTS via Object.assign.""" match = re.search( - r"function loadDisplaySettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", + r"function getDisplaySettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// |\n/\*)", _JS, re.DOTALL, ) - assert match, "loadDisplaySettings function not found" + assert match, "getDisplaySettings function not found" body = match.group(1) assert "Object.assign" in body, ( - "loadDisplaySettings must use Object.assign to merge with defaults" + "getDisplaySettings 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.""" +def test_get_display_settings_uses_display_defaults() -> None: + """getDisplaySettings must use DISPLAY_DEFAULTS as fallback for missing keys.""" match = re.search( - r"function loadDisplaySettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", + r"function getDisplaySettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// |\n/\*)", _JS, re.DOTALL, ) - assert match, "loadDisplaySettings function not found" + assert match, "getDisplaySettings function not found" body = match.group(1) - assert "catch" in body or "try" in body, ( - "loadDisplaySettings must have error handling (try/catch)" + assert "DISPLAY_DEFAULTS" in body, ( + "getDisplaySettings must use DISPLAY_DEFAULTS as fallback" ) # ── saveDisplaySettings implementation ─────────────────────────────────────── -def test_save_display_settings_writes_to_localstorage() -> None: - """saveDisplaySettings must write to localStorage.""" +def test_on_display_setting_change_uses_api_patch() -> None: + """onDisplaySettingChange must write display settings via API PATCH, not localStorage.""" match = re.search( - r"function saveDisplaySettings\s*\(.*?\)\s*\{(.*?)(?=\nfunction |\n// )", + r"function onDisplaySettingChange\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// |\n/\*)", _JS, re.DOTALL, ) - assert match, "saveDisplaySettings function not found" + assert match, "onDisplaySettingChange 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" + assert "api('PATCH'" in body or 'api("PATCH"' in body, ( + "onDisplaySettingChange must write display settings via api('PATCH', '/api/settings', patch)" + ) + assert "localStorage" not in body, ( + "onDisplaySettingChange must not use localStorage for display settings" + ) -def test_save_display_settings_catches_errors() -> None: - """saveDisplaySettings must catch errors.""" +def test_on_display_setting_change_catches_errors() -> None: + """onDisplaySettingChange must handle API errors gracefully via .catch().""" match = re.search( - r"function saveDisplaySettings\s*\(.*?\)\s*\{(.*?)(?=\nfunction |\n// )", + r"function onDisplaySettingChange\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// |\n/\*)", _JS, re.DOTALL, ) - assert match, "saveDisplaySettings function not found" + assert match, "onDisplaySettingChange function not found" body = match.group(1) - assert "catch" in body or "try" in body, ( - "saveDisplaySettings must have error handling (try/catch)" + assert ".catch" in body, ( + "onDisplaySettingChange must handle errors via .catch()" ) @@ -783,8 +797,8 @@ def test_bind_static_event_listeners_binds_settings_tabs() -> None: # ── module.exports for new settings functions ──────────────────────────────── -def test_exports_load_display_settings() -> None: - """module.exports must export loadDisplaySettings.""" +def test_exports_get_display_settings() -> None: + """module.exports must export getDisplaySettings.""" match = re.search( r"module\.exports\s*=\s*\{(.*?)\};", _JS, @@ -792,13 +806,13 @@ def test_exports_load_display_settings() -> None: ) assert match, "module.exports block not found" exports = match.group(1) - assert "loadDisplaySettings" in exports, ( - "module.exports must export loadDisplaySettings" + assert "getDisplaySettings" in exports, ( + "module.exports must export getDisplaySettings" ) -def test_exports_save_display_settings() -> None: - """module.exports must export saveDisplaySettings.""" +def test_exports_no_save_display_settings() -> None: + """module.exports must NOT export saveDisplaySettings (removed in server-settings migration).""" match = re.search( r"module\.exports\s*=\s*\{(.*?)\};", _JS, @@ -806,8 +820,8 @@ def test_exports_save_display_settings() -> None: ) assert match, "module.exports block not found" exports = match.group(1) - assert "saveDisplaySettings" in exports, ( - "module.exports must export saveDisplaySettings" + assert "saveDisplaySettings" not in exports, ( + "module.exports must not export saveDisplaySettings — use patchServerSetting instead" ) @@ -955,7 +969,7 @@ def test_on_display_setting_change_reads_grid_columns() -> None: def test_on_display_setting_change_calls_save_display_settings() -> None: - """onDisplaySettingChange must call saveDisplaySettings.""" + """onDisplaySettingChange must save display settings via api('PATCH', '/api/settings', ...).""" match = re.search( r"function onDisplaySettingChange\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", _JS, @@ -963,8 +977,8 @@ def test_on_display_setting_change_calls_save_display_settings() -> None: ) assert match, "onDisplaySettingChange function not found" body = match.group(1) - assert "saveDisplaySettings" in body, ( - "onDisplaySettingChange must call saveDisplaySettings" + assert "api('PATCH'" in body or 'api("PATCH"' in body, ( + "onDisplaySettingChange must save display settings via api('PATCH', '/api/settings', patch)" ) @@ -1000,7 +1014,7 @@ def test_hover_preview_no_hardcoded_1500() -> None: def test_hover_preview_reads_delay_from_settings() -> None: - """Hover preview must read delay from loadDisplaySettings().hoverPreviewDelay.""" + """Hover preview must read delay from getDisplaySettings().hoverPreviewDelay.""" match = re.search( r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}", _JS, @@ -1008,8 +1022,8 @@ def test_hover_preview_reads_delay_from_settings() -> None: ) 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" + assert "getDisplaySettings" in body and "hoverPreviewDelay" in body, ( + "bindStaticEventListeners hover preview must read delay from getDisplaySettings().hoverPreviewDelay" ) @@ -1074,7 +1088,7 @@ def test_bind_static_event_listeners_binds_grid_columns_change() -> None: def test_dom_content_loaded_calls_apply_display_settings() -> None: - """DOMContentLoaded handler must call applyDisplaySettings(loadDisplaySettings()).""" + """DOMContentLoaded handler must call applyDisplaySettings(getDisplaySettings()).""" # Find the DOMContentLoaded handler block match = re.search( r"DOMContentLoaded.*?\{(.*?)(?=\}\);?\s*\n// |\}\);\s*$)", @@ -1084,10 +1098,10 @@ def test_dom_content_loaded_calls_apply_display_settings() -> None: assert match, "DOMContentLoaded handler not found" body = match.group(1) assert "applyDisplaySettings" in body, ( - "DOMContentLoaded handler must call applyDisplaySettings(loadDisplaySettings())" + "DOMContentLoaded handler must call applyDisplaySettings(getDisplaySettings())" ) - assert "loadDisplaySettings" in body, ( - "DOMContentLoaded handler must call applyDisplaySettings(loadDisplaySettings())" + assert "getDisplaySettings" in body, ( + "DOMContentLoaded handler must call applyDisplaySettings(getDisplaySettings())" )