Files
muxplex/docs/plans/2026-03-29-settings-phase2.md
T
2026-03-29 22:12:07 -07:00

31 KiB

Settings Panel — Phase 2: Remaining Tabs + New Session UI

Execution: Use the subagent-driven-development workflow to implement this plan.

Phase 2 of 2 — complete Phase 1 before starting this phase.

Design doc: docs/plans/2026-03-29-settings-design.md

Goal: Complete all four settings tabs and build the new session creation UI — + button, inline name input, mobile FAB, and end-to-end creation flow.

Architecture: Sessions/New Session tabs use the GET/PATCH /api/settings and POST /api/sessions endpoints from Phase 1. The + button uses inline name input (header, sidebar, FAB) that calls the create endpoint. Display/Notifications tabs use localStorage. All controls apply immediately — no save button.

Tech Stack: Vanilla JS, CSS custom properties, FastAPI backend (already deployed from Phase 1).

Prereqs from Phase 1:

  • muxplex/settings.pyload_settings(), save_settings(), patch_settings()
  • GET/PATCH /api/settings, POST /api/sessions endpoints in main.py
  • Settings dialog HTML skeleton with tab navigation in index.html
  • openSettings(), closeSettings(), switchSettingsTab(), loadDisplaySettings(), saveDisplaySettings() in app.js
  • .settings-* CSS classes in style.css

Task 1: Sessions tab — server settings UI

Files:

  • Modify: muxplex/frontend/index.html
  • Modify: muxplex/frontend/app.js

Step 1: Add Sessions tab HTML

In muxplex/frontend/index.html, replace the empty #settings-panel-sessions div:

        <!-- Sessions tab -->
        <div id="settings-panel-sessions" class="settings-panel hidden" role="tabpanel" data-tab="sessions">
          <h3 class="settings-panel-title">Sessions</h3>
          <label class="settings-field">
            <span class="settings-label">Default session</span>
            <select id="setting-default-session" class="settings-select">
              <option value="">None</option>
            </select>
          </label>
          <label class="settings-field">
            <span class="settings-label">Sort order</span>
            <select id="setting-sort-order" class="settings-select">
              <option value="manual">Manual</option>
              <option value="alphabetical">Alphabetical</option>
              <option value="recent">Recent activity</option>
            </select>
          </label>
          <div class="settings-field settings-field--column">
            <span class="settings-label">Hidden sessions</span>
            <div id="setting-hidden-sessions" class="settings-checkbox-list"></div>
          </div>
          <label class="settings-field">
            <span class="settings-label">Auto-set window-size largest</span>
            <input type="checkbox" id="setting-window-size-largest" class="settings-checkbox">
          </label>
          <label class="settings-field">
            <span class="settings-label">Auto-open created sessions</span>
            <input type="checkbox" id="setting-auto-open" class="settings-checkbox" checked>
          </label>
        </div>

Step 2: Add server settings load/save functions to app.js

In muxplex/frontend/app.js, add after the onDisplaySettingChange function:

// ─── Server settings ─────────────────────────────────────────────────────────
let _serverSettings = null;

/**
 * Load server settings via GET /api/settings.
 * Caches the result in _serverSettings for local reads.
 * @returns {Promise<object>}
 */
async function loadServerSettings() {
  try {
    var res = await api('GET', '/api/settings');
    _serverSettings = await res.json();
  } catch (err) {
    console.warn('[loadServerSettings]', err);
    _serverSettings = _serverSettings || {};
  }
  return _serverSettings;
}

/**
 * Patch a single server setting field.
 * @param {string} key
 * @param {*} value
 * @returns {Promise<void>}
 */
async function patchServerSetting(key, value) {
  var patch = {};
  patch[key] = value;
  try {
    var res = await api('PATCH', '/api/settings', patch);
    _serverSettings = await res.json();
    showToast('Setting saved');
  } catch (err) {
    console.warn('[patchServerSetting]', err);
    showToast('Failed to save setting');
  }
}

Step 3: Populate Sessions tab when settings opens

Update the openSettings function. After the display settings load, add server settings loading:

  // Load server settings and populate Sessions tab
  loadServerSettings().then(function(ss) {
    // Default session dropdown: populate with current session list
    var defaultSel = $('setting-default-session');
    if (defaultSel) {
      var current = ss.default_session || '';
      defaultSel.innerHTML = '<option value="">None</option>' +
        _currentSessions.map(function(s) {
          var name = escapeHtml(s.name);
          var selected = s.name === current ? ' selected' : '';
          return '<option value="' + name + '"' + selected + '>' + name + '</option>';
        }).join('');
    }

    // Sort order
    var sortSel = $('setting-sort-order');
    if (sortSel) sortSel.value = ss.sort_order || 'manual';

    // Hidden sessions checkboxes
    var hiddenDiv = $('setting-hidden-sessions');
    if (hiddenDiv) {
      var hiddenList = ss.hidden_sessions || [];
      hiddenDiv.innerHTML = _currentSessions.map(function(s) {
        var name = escapeHtml(s.name);
        var checked = hiddenList.indexOf(s.name) !== -1 ? ' checked' : '';
        return '<label class="settings-checkbox-item">' +
          '<input type="checkbox" data-session="' + name + '"' + checked + '> ' + name +
          '</label>';
      }).join('');
    }

    // Checkboxes
    var wslEl = $('setting-window-size-largest');
    if (wslEl) wslEl.checked = !!ss.window_size_largest;
    var aoEl = $('setting-auto-open');
    if (aoEl) aoEl.checked = ss.auto_open_created !== false;
  });

Step 4: Bind change handlers for Sessions tab in bindStaticEventListeners

Add to bindStaticEventListeners:

  // Sessions tab change handlers
  on($('setting-default-session'), 'change', function() {
    patchServerSetting('default_session', this.value || null);
  });
  on($('setting-sort-order'), 'change', function() {
    patchServerSetting('sort_order', this.value);
  });
  on($('setting-window-size-largest'), 'change', function() {
    patchServerSetting('window_size_largest', this.checked);
  });
  on($('setting-auto-open'), 'change', function() {
    patchServerSetting('auto_open_created', this.checked);
  });

  // Hidden sessions: delegated handler on container (checkboxes are dynamic)
  var hiddenContainer = $('setting-hidden-sessions');
  if (hiddenContainer) {
    hiddenContainer.addEventListener('change', function() {
      var checkboxes = hiddenContainer.querySelectorAll('input[type="checkbox"]');
      var hidden = [];
      checkboxes.forEach(function(cb) {
        if (cb.checked) hidden.push(cb.dataset.session);
      });
      patchServerSetting('hidden_sessions', hidden);
    });
  }

Step 5: Add CSS for column-layout fields and checkbox list

Append to muxplex/frontend/style.css:

.settings-field--column {
  flex-direction: column;
  align-items: flex-start;
  gap: 8px;
}

.settings-checkbox-list {
  display: flex;
  flex-direction: column;
  gap: 6px;
  padding-left: 4px;
}

.settings-checkbox-item {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 13px;
  color: var(--text);
  cursor: pointer;
}

.settings-checkbox {
  accent-color: var(--accent);
  cursor: pointer;
}

Step 6: Add exports

Add to module.exports:

    loadServerSettings,
    patchServerSetting,

Step 7: Run tests

cd muxplex && python -m pytest muxplex/tests/ -v

Expected: All tests pass.

Step 8: Commit

git add -A && git commit -m "feat: add Sessions settings tab with server-side persistence"

Task 2: Notifications tab

Files:

  • Modify: muxplex/frontend/index.html
  • Modify: muxplex/frontend/app.js
  • Modify: muxplex/frontend/style.css

Step 1: Add Notifications tab HTML

In muxplex/frontend/index.html, replace the empty #settings-panel-notifications div:

        <!-- Notifications tab -->
        <div id="settings-panel-notifications" class="settings-panel hidden" role="tabpanel" data-tab="notifications">
          <h3 class="settings-panel-title">Notifications</h3>
          <label class="settings-field">
            <span class="settings-label">Bell sound</span>
            <input type="checkbox" id="setting-bell-sound" class="settings-checkbox">
          </label>
          <div class="settings-field">
            <span class="settings-label">Desktop notifications</span>
            <div class="settings-notification-status">
              <span id="notification-status-text" class="settings-status-text">Not requested</span>
              <button id="notification-request-btn" class="settings-action-btn">Request permission</button>
            </div>
          </div>
        </div>

Step 2: Add CSS for notification status and action button

Append to muxplex/frontend/style.css:

.settings-notification-status {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 6px;
}

.settings-status-text {
  font-size: 12px;
  color: var(--text-muted);
}

.settings-action-btn {
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: 4px;
  color: var(--text);
  font-size: 12px;
  font-family: var(--font-ui);
  padding: 6px 12px;
  cursor: pointer;
  transition: border-color var(--t-fast);
}

.settings-action-btn:hover {
  border-color: var(--accent);
}

.settings-action-btn:disabled {
  opacity: 0.5;
  cursor: default;
}

Step 3: Add notification handlers to app.js

Update openSettings to populate notification controls (add inside the function):

  // Notifications tab
  var ds2 = loadDisplaySettings();
  var bellSoundEl = $('setting-bell-sound');
  if (bellSoundEl) bellSoundEl.checked = !!ds2.bellSound;

  var notifText = $('notification-status-text');
  var notifBtn = $('notification-request-btn');
  if (notifText && notifBtn) {
    var perm = typeof Notification !== 'undefined' ? Notification.permission : 'unavailable';
    if (perm === 'granted') {
      notifText.textContent = 'Granted';
      notifBtn.textContent = 'Granted';
      notifBtn.disabled = true;
    } else if (perm === 'denied') {
      notifText.textContent = 'Denied (change in browser settings)';
      notifBtn.textContent = 'Denied';
      notifBtn.disabled = true;
    } else if (perm === 'unavailable') {
      notifText.textContent = 'Not supported';
      notifBtn.disabled = true;
    } else {
      notifText.textContent = 'Not requested';
      notifBtn.textContent = 'Request permission';
      notifBtn.disabled = false;
    }
  }

Step 4: Bind notification change handlers in bindStaticEventListeners

Add to bindStaticEventListeners:

  // Notifications tab
  on($('setting-bell-sound'), 'change', function() {
    var ds = loadDisplaySettings();
    ds.bellSound = this.checked;
    saveDisplaySettings(ds);
  });

  on($('notification-request-btn'), 'click', function() {
    if (typeof Notification === 'undefined') return;
    Notification.requestPermission().then(function(perm) {
      _notificationPermission = perm;
      var ds = loadDisplaySettings();
      ds.notificationPermission = perm;
      saveDisplaySettings(ds);
      // Update UI
      var notifText = $('notification-status-text');
      var notifBtn = $('notification-request-btn');
      if (perm === 'granted') {
        if (notifText) notifText.textContent = 'Granted';
        if (notifBtn) { notifBtn.textContent = 'Granted'; notifBtn.disabled = true; }
      } else if (perm === 'denied') {
        if (notifText) notifText.textContent = 'Denied (change in browser settings)';
        if (notifBtn) { notifBtn.textContent = 'Denied'; notifBtn.disabled = true; }
      }
    });
  });

Step 5: Run tests

cd muxplex && python -m pytest muxplex/tests/ -v

Expected: All tests pass.

Step 6: Commit

git add -A && git commit -m "feat: add Notifications settings tab"

Task 3: New Session tab — template textarea

Files:

  • Modify: muxplex/frontend/index.html
  • Modify: muxplex/frontend/app.js
  • Modify: muxplex/frontend/style.css

Step 1: Add New Session tab HTML

In muxplex/frontend/index.html, replace the empty #settings-panel-new-session div:

        <!-- New Session tab -->
        <div id="settings-panel-new-session" class="settings-panel hidden" role="tabpanel" data-tab="new-session">
          <h3 class="settings-panel-title">New Session</h3>
          <div class="settings-field settings-field--column">
            <span class="settings-label">Command template</span>
            <textarea id="setting-template" class="settings-textarea" rows="3"
              placeholder="tmux new-session -d -s {name}"></textarea>
            <span class="settings-helper">{name} is replaced with the session name</span>
          </div>
          <button id="setting-template-reset" class="settings-action-btn">Reset to default</button>
        </div>

Step 2: Add CSS for textarea and helper text

Append to muxplex/frontend/style.css:

.settings-textarea {
  width: 100%;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: 4px;
  color: var(--text);
  font-size: 13px;
  font-family: var(--font-mono);
  padding: 10px;
  resize: vertical;
  min-height: 60px;
}

.settings-textarea:focus {
  outline: 1px solid var(--accent);
  border-color: var(--accent);
}

.settings-helper {
  font-size: 12px;
  color: var(--text-muted);
  font-style: italic;
}

Step 3: Populate template on settings open

Inside openSettings, in the loadServerSettings().then(...) callback, add:

    // New Session tab
    var templateEl = $('setting-template');
    if (templateEl) templateEl.value = ss.new_session_template || 'tmux new-session -d -s {name}';

Step 4: Bind template change handlers in bindStaticEventListeners

Add to bindStaticEventListeners:

  // New Session tab: save template on blur (not every keystroke)
  var templateEl = $('setting-template');
  if (templateEl) {
    var _templateDebounce = null;
    templateEl.addEventListener('input', function() {
      clearTimeout(_templateDebounce);
      _templateDebounce = setTimeout(function() {
        patchServerSetting('new_session_template', templateEl.value);
      }, 500);
    });
  }

  on($('setting-template-reset'), 'click', function() {
    var templateEl = $('setting-template');
    if (templateEl) templateEl.value = 'tmux new-session -d -s {name}';
    patchServerSetting('new_session_template', 'tmux new-session -d -s {name}');
  });

Step 5: Run tests

cd muxplex && python -m pytest muxplex/tests/ -v

Expected: All tests pass.

Step 6: Commit

git add -A && git commit -m "feat: add New Session settings tab with template textarea"

Task 4: Header + button with inline name input

Files:

  • Modify: muxplex/frontend/app.js
  • Modify: muxplex/frontend/style.css

Step 1: Add new session creation function

In muxplex/frontend/app.js, add after the server settings functions:

// ─── New session creation ────────────────────────────────────────────────────

/**
 * Show an inline name input replacing the given button element.
 * On Enter: call createNewSession(). On Escape: cancel and restore button.
 * @param {HTMLElement} btn - the button to replace with input
 */
function showNewSessionInput(btn) {
  if (!btn) return;
  var container = btn.parentElement;
  if (!container) return;

  // Create inline input
  var input = document.createElement('input');
  input.type = 'text';
  input.className = 'new-session-input';
  input.placeholder = 'Session name\u2026';
  input.setAttribute('autocomplete', 'off');
  input.setAttribute('spellcheck', 'false');

  // Replace button with input
  btn.style.display = 'none';
  container.insertBefore(input, btn);
  input.focus();

  function cleanup() {
    input.remove();
    btn.style.display = '';
  }

  input.addEventListener('keydown', function(e) {
    if (e.key === 'Enter') {
      e.preventDefault();
      var name = input.value.trim();
      if (name) {
        cleanup();
        createNewSession(name);
      }
    } else if (e.key === 'Escape') {
      e.preventDefault();
      cleanup();
    }
  });

  // Cancel on blur (click away)
  input.addEventListener('blur', function() {
    // Delay to allow click handlers to fire first
    setTimeout(cleanup, 150);
  });
}

/**
 * Create a new session by POSTing to /api/sessions.
 * Shows toast, triggers poll refresh, auto-opens if setting enabled.
 * @param {string} name
 * @returns {Promise<void>}
 */
async function createNewSession(name) {
  try {
    var res = await api('POST', '/api/sessions', { name: name });
    var data = await res.json();
    showToast("Session '" + data.name + "' created");

    // Trigger immediate poll refresh to pick up the new session
    await pollSessions();

    // Auto-open if setting enabled
    var ss = _serverSettings || await loadServerSettings();
    if (ss.auto_open_created !== false) {
      // Small delay to let tmux create the session
      setTimeout(function() {
        openSession(data.name);
      }, 500);
    }
  } catch (err) {
    showToast(err.message || 'Failed to create session');
  }
}

Step 2: Add CSS for inline input

Append to muxplex/frontend/style.css:

/* ============================================================
   New session inline input
   ============================================================ */

.new-session-input {
  background: var(--bg);
  border: 1px solid var(--accent);
  border-radius: 4px;
  color: var(--text);
  font-size: 13px;
  font-family: var(--font-ui);
  padding: 4px 10px;
  width: 180px;
  outline: none;
}

.new-session-input::placeholder {
  color: var(--text-dim);
}

Step 3: Bind the header + button

Add to bindStaticEventListeners:

  // New session: header + button
  on($('new-session-btn'), 'click', function() {
    showNewSessionInput($('new-session-btn'));
  });

Step 4: Add exports

Add to module.exports:

    showNewSessionInput,
    createNewSession,

Step 5: Run tests

cd muxplex && python -m pytest muxplex/tests/ -v

Expected: All tests pass.

Step 6: Commit

git add -A && git commit -m "feat: add header + button with inline name input for session creation"

Files:

  • Modify: muxplex/frontend/index.html
  • Modify: muxplex/frontend/app.js
  • Modify: muxplex/frontend/style.css

Step 1: Add sidebar footer HTML

In muxplex/frontend/index.html, add a footer inside the #session-sidebar div, after #sidebar-list:

      <div id="session-sidebar" class="session-sidebar">
        <div class="sidebar-header">
          <span class="sidebar-title">Sessions</span>
          <button id="sidebar-collapse-btn" class="sidebar-collapse-btn" aria-label="Collapse session list">&#8249;</button>
        </div>
        <div id="sidebar-list" class="sidebar-list"></div>
        <div class="sidebar-footer">
          <button id="sidebar-new-session-btn" class="sidebar-new-btn">+ New</button>
        </div>
      </div>

Step 2: Add sidebar footer CSS

Append to muxplex/frontend/style.css:

.sidebar-footer {
  padding: 8px;
  border-top: 1px solid var(--border-subtle);
  flex-shrink: 0;
}

.sidebar-new-btn {
  width: 100%;
  background: none;
  border: 1px dashed var(--border);
  border-radius: 4px;
  color: var(--text-dim);
  font-size: 12px;
  font-family: var(--font-ui);
  padding: 8px;
  cursor: pointer;
  transition: color var(--t-fast), border-color var(--t-fast);
}

.sidebar-new-btn:hover {
  color: var(--text);
  border-color: var(--text-muted);
}

Step 3: Bind sidebar button

Add to bindStaticEventListeners:

  // New session: sidebar footer button
  on($('sidebar-new-session-btn'), 'click', function() {
    showNewSessionInput($('sidebar-new-session-btn'));
  });

Step 4: Run tests

cd muxplex && python -m pytest muxplex/tests/ -v

Expected: All tests pass.

Step 5: Commit

git add -A && git commit -m "feat: add sidebar + New sticky footer with inline input"

Task 6: Mobile FAB

Files:

  • Modify: muxplex/frontend/index.html
  • Modify: muxplex/frontend/app.js
  • Modify: muxplex/frontend/style.css

Step 1: Add FAB HTML

In muxplex/frontend/index.html, add before the toast element:

  <!-- ── Mobile FAB (new session) ─────────────────────────────── -->
  <button id="new-session-fab" class="new-session-fab" aria-label="New session">+</button>

Step 2: Add FAB CSS

Append to muxplex/frontend/style.css:

/* ============================================================
   Mobile FAB (new session)
   ============================================================ */

.new-session-fab {
  display: none;  /* hidden by default, shown on mobile */
  position: fixed;
  bottom: 16px;
  right: 16px;
  width: 56px;
  height: 56px;
  border-radius: 50%;
  background: var(--accent);
  color: var(--bg);
  border: none;
  font-size: 28px;
  font-weight: 300;
  line-height: 1;
  cursor: pointer;
  z-index: 100;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
  transition: transform var(--t-fast), box-shadow var(--t-fast);
}

.new-session-fab:active {
  transform: scale(0.95);
}

@media (max-width: 959px) {
  .new-session-fab {
    display: flex;
    align-items: center;
    justify-content: center;
  }

  /* Hide the header + button on mobile since FAB replaces it */
  #new-session-btn {
    display: none;
  }
}

Step 3: Bind FAB click

Add to bindStaticEventListeners:

  // New session: mobile FAB
  on($('new-session-fab'), 'click', function() {
    showNewSessionInput($('new-session-fab'));
  });

Step 4: Hide FAB when in fullscreen view

In openSession, add after switching views:

  var fab = $('new-session-fab');
  if (fab) fab.classList.add('hidden');

In closeSession, restore the FAB:

  var fab = $('new-session-fab');
  if (fab) fab.classList.remove('hidden');

Step 5: Run tests

cd muxplex && python -m pytest muxplex/tests/ -v

Expected: All tests pass.

Step 6: Commit

git add -A && git commit -m "feat: add mobile FAB for new session creation"

Task 7: Apply settings effects — font size, grid columns, hover delay, hidden sessions, sort order

Files:

  • Modify: muxplex/frontend/app.js
  • Modify: muxplex/frontend/terminal.js

Step 1: Font size affects terminal.js

In muxplex/frontend/terminal.js, update the createTerminal function to read font size from localStorage:

Replace the hardcoded fontSize in createTerminal():

  // Read font size from display settings (localStorage)
  var storedFontSize = 14;
  try {
    var raw = localStorage.getItem('muxplex.display');
    if (raw) {
      var parsed = JSON.parse(raw);
      if (parsed.fontSize) storedFontSize = parsed.fontSize;
    }
  } catch (_) {}

  const mobile = window.innerWidth < 600;

  _term = new window.Terminal({
    cursorBlink: true,
    fontSize: mobile ? Math.min(storedFontSize, 12) : storedFontSize,
    fontFamily: "'SF Mono', 'Fira Code', Consolas, monospace",
    // ... rest unchanged
  });

Step 2: Hidden sessions filter in renderGrid

In muxplex/frontend/app.js, update renderGrid to filter hidden sessions. At the top of the function, before computing ordered:

  // Filter hidden sessions (server settings)
  var hidden = (_serverSettings && _serverSettings.hidden_sessions) || [];
  var visible = sessions;
  if (hidden.length > 0) {
    visible = sessions.filter(function(s) {
      return hidden.indexOf(s.name) === -1;
    });
  }

Then use visible instead of sessions for the rest of the function. Update the empty-state check and the ordered computation:

  if (!visible || visible.length === 0) {
    if (grid) grid.innerHTML = '';
    if (emptyState) emptyState.classList.remove('hidden');
    return;
  }

  if (emptyState) emptyState.classList.add('hidden');

  const mobile = isMobile();
  const ordered = mobile ? sortByPriority(visible) : visible;

Step 3: Sort order support in renderGrid

Update renderGrid to apply sort order from server settings. After the hidden filter, before the mobile check:

  // Apply sort order from server settings
  var sortOrder = (_serverSettings && _serverSettings.sort_order) || 'manual';
  if (sortOrder === 'alphabetical') {
    visible = visible.slice().sort(function(a, b) {
      return (a.name || '').localeCompare(b.name || '');
    });
  }
  // 'recent' and 'manual' use the server-provided order (which is already the default)

Step 4: Hidden sessions filter in renderSidebar

In renderSidebar, apply the same hidden filter:

  var hidden = (_serverSettings && _serverSettings.hidden_sessions) || [];
  var visible = sessions;
  if (hidden.length > 0) {
    visible = sessions.filter(function(s) {
      return hidden.indexOf(s.name) === -1;
    });
  }

Use visible instead of sessions for the sidebar rendering.

Step 5: Load server settings on page init

In the DOMContentLoaded handler, after startPolling(), add a call to load server settings:

  restoreState()
    .then(() => {
      startPolling();
      startHeartbeat();
      requestNotificationPermission();
      loadServerSettings();  // <-- add this line
      bindStaticEventListeners();
    })

Step 6: Run tests

cd muxplex && python -m pytest muxplex/tests/ -v

Expected: All tests pass.

Step 7: Commit

git add -A && git commit -m "feat: apply settings effects — font size, grid columns, hidden sessions, sort order"

Task 8: Frontend tests for settings

Files:

  • Modify: muxplex/tests/test_frontend_html.py
  • Modify: muxplex/tests/test_frontend_css.py

Step 1: Add HTML tests for settings elements

Add to muxplex/tests/test_frontend_html.py:

def test_html_settings_dialog() -> None:
    """id=settings-dialog, settings-backdrop, settings-btn."""
    soup = _SOUP
    for id_ in ("settings-dialog", "settings-backdrop", "settings-btn"):
        assert soup.find(id=id_), f"Missing element with id='{id_}'"


def test_html_settings_tabs() -> None:
    """Settings dialog contains four tab buttons: display, sessions, notifications, new-session."""
    soup = _SOUP
    tabs = soup.select(".settings-tab")
    tab_names = [t.get("data-tab") for t in tabs]
    assert "display" in tab_names
    assert "sessions" in tab_names
    assert "notifications" in tab_names
    assert "new-session" in tab_names


def test_html_display_tab_controls() -> None:
    """Display tab has font-size, hover-delay, and grid-columns selects."""
    soup = _SOUP
    for id_ in ("setting-font-size", "setting-hover-delay", "setting-grid-columns"):
        assert soup.find(id=id_), f"Missing element with id='{id_}'"


def test_html_new_session_btn() -> None:
    """id=new-session-btn, new-session-fab exist."""
    soup = _SOUP
    assert soup.find(id="new-session-btn"), "Missing element with id='new-session-btn'"
    assert soup.find(id="new-session-fab"), "Missing element with id='new-session-fab'"


def test_html_sidebar_new_session_btn() -> None:
    """id=sidebar-new-session-btn exists inside the sidebar."""
    soup = _SOUP
    assert soup.find(id="sidebar-new-session-btn"), "Missing element with id='sidebar-new-session-btn'"


def test_html_sessions_tab_controls() -> None:
    """Sessions tab has default-session, sort-order selects and checkboxes."""
    soup = _SOUP
    for id_ in ("setting-default-session", "setting-sort-order", "setting-window-size-largest", "setting-auto-open"):
        assert soup.find(id=id_), f"Missing element with id='{id_}'"


def test_html_notifications_tab_controls() -> None:
    """Notifications tab has bell-sound checkbox and permission button."""
    soup = _SOUP
    assert soup.find(id="setting-bell-sound"), "Missing id='setting-bell-sound'"
    assert soup.find(id="notification-request-btn"), "Missing id='notification-request-btn'"


def test_html_new_session_tab_controls() -> None:
    """New Session tab has template textarea and reset button."""
    soup = _SOUP
    assert soup.find(id="setting-template"), "Missing id='setting-template'"
    assert soup.find(id="setting-template-reset"), "Missing id='setting-template-reset'"

Step 2: Add CSS tests for settings styles

Add to muxplex/tests/test_frontend_css.py:

def test_css_settings_dialog():
    css = read_css()
    assert ".settings-dialog" in css
    assert ".settings-tabs" in css
    assert ".settings-tab--active" in css
    assert ".settings-content" in css
    assert ".settings-field" in css
    assert ".settings-select" in css


def test_css_header_btn():
    css = read_css()
    assert ".header-btn" in css
    assert ".header-actions" in css


def test_css_new_session_fab():
    css = read_css()
    assert ".new-session-fab" in css


def test_css_new_session_input():
    css = read_css()
    assert ".new-session-input" in css


def test_css_settings_textarea():
    css = read_css()
    assert ".settings-textarea" in css


def test_css_sidebar_footer():
    css = read_css()
    assert ".sidebar-footer" in css
    assert ".sidebar-new-btn" in css

Step 3: Run all tests

cd muxplex && python -m pytest muxplex/tests/ -v

Expected: All tests pass (both old and new).

Step 4: Commit

git add -A && git commit -m "test: add frontend tests for settings dialog and new session UI"

Phase 2 Complete

After completing all 8 tasks, the full settings feature is done:

  1. Sessions tab — default session, sort order, hidden sessions, window-size largest, auto-open
  2. Notifications tab — bell sound toggle, desktop notification permission button
  3. New Session tab — command template textarea with reset button
  4. Header + button — inline name input, creates session via API
  5. Sidebar + New — sticky footer with same inline input
  6. Mobile FAB — 56px floating action button, <960px only
  7. Settings effects applied — font size, grid columns, hover delay, hidden sessions, sort order
  8. Frontend tests — HTML structure and CSS coverage for all new elements

Open questions (from design doc — decide during implementation):

  1. Hidden sessions: completely removed from grid/sidebar (current implementation)
  2. Session name validation: accepts anything the user types (tmux will reject invalid names)
  3. Settings pre-auth: not accessible from login page (v1)