feat: add settings dialog HTML structure and CSS

This commit is contained in:
Brian Krabach
2026-03-29 23:04:11 -07:00
parent 19ec5c02a5
commit b20759c6cb
6 changed files with 563 additions and 1 deletions
+54
View File
@@ -20,7 +20,11 @@
<div id="view-overview" class="view view--active">
<header class="app-header">
<h1 class="app-wordmark"><img src="/wordmark-on-dark.svg" alt="muxplex" height="24" /></h1>
<div class="header-actions">
<button id="new-session-btn" class="header-btn" aria-label="New session">+</button>
<button id="settings-btn" class="header-btn" aria-label="Settings">&#9881;</button>
<span id="connection-status"></span>
</div>
</header>
<div id="session-grid" class="session-grid" role="list"></div>
<div id="empty-state" class="empty-state hidden">No active tmux sessions</div>
@@ -32,6 +36,7 @@
<button id="back-btn" class="back-btn" aria-label="Back">&#8592;</button>
<button id="sidebar-toggle-btn" class="sidebar-toggle-btn" aria-label="Toggle session list">&#9776;</button>
<span id="expanded-session-name" class="expanded-session-name"></span>
<button id="settings-btn-expanded" class="header-btn" aria-label="Settings">&#9881;</button>
</header>
<div class="view-body">
<div id="session-sidebar" class="session-sidebar">
@@ -64,6 +69,55 @@
<!-- ── Toast notification ─────────────────────────────────────────────────── -->
<div id="toast" class="toast hidden" role="status" aria-live="polite" aria-atomic="true"></div>
<!-- ── Settings ─────────────────────────────────────────────────────────────────────── -->
<div id="settings-backdrop" class="settings-backdrop hidden"></div>
<dialog id="settings-dialog" class="settings-dialog">
<div class="settings-layout">
<nav class="settings-tabs">
<button class="settings-tab settings-tab--active" data-tab="display">Display</button>
<button class="settings-tab" data-tab="sessions">Sessions</button>
<button class="settings-tab" data-tab="notifications">Notifications</button>
<button class="settings-tab" data-tab="new-session">New Session</button>
</nav>
<div class="settings-content">
<div class="settings-panel" data-panel="display">
<div class="settings-field">
<label class="settings-label" for="setting-font-size">Font Size</label>
<select id="setting-font-size" class="settings-select">
<option value="11">11</option>
<option value="12">12</option>
<option value="13">13</option>
<option value="14" selected>14</option>
<option value="16">16</option>
</select>
</div>
<div class="settings-field">
<label class="settings-label" for="setting-hover-delay">Hover Delay</label>
<select id="setting-hover-delay" class="settings-select">
<option value="0">Off</option>
<option value="1000">1000ms</option>
<option value="1500" selected>1500ms</option>
<option value="2000">2000ms</option>
<option value="3000">3000ms</option>
</select>
</div>
<div class="settings-field">
<label class="settings-label" for="setting-grid-columns">Grid Columns</label>
<select id="setting-grid-columns" class="settings-select">
<option value="auto" selected>Auto</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
</select>
</div>
</div>
<div class="settings-panel hidden" data-panel="sessions"></div>
<div class="settings-panel hidden" data-panel="notifications"></div>
<div class="settings-panel hidden" data-panel="new-session"></div>
</div>
</div>
</dialog>
<!-- ── Scripts ─────────────────────────────────────────────────────────────── -->
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
+186
View File
@@ -873,6 +873,192 @@ body {
/* ============================================================
Header actions + settings buttons
============================================================ */
.header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.header-btn {
width: 32px;
height: 32px;
background: none;
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
padding: 0;
flex-shrink: 0;
}
.header-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
/* ============================================================
Settings dialog
============================================================ */
.settings-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(2px);
z-index: 299;
}
.settings-dialog {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 600px;
height: 480px;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 8px;
z-index: 300;
padding: 0;
overflow: hidden;
margin: 0;
}
.settings-dialog::backdrop {
background: transparent;
}
.settings-layout {
display: flex;
height: 100%;
}
.settings-tabs {
width: 140px;
flex-shrink: 0;
display: flex;
flex-direction: column;
border-right: 1px solid var(--border);
padding: 8px 0;
background: var(--bg-secondary);
}
.settings-tab {
background: none;
border: none;
border-left: 3px solid transparent;
color: var(--text-muted);
cursor: pointer;
text-align: left;
padding: 10px 16px;
font-size: 13px;
}
.settings-tab:hover {
color: var(--text);
background: rgba(255, 255, 255, 0.05);
}
.settings-tab--active {
color: var(--accent);
border-left-color: var(--accent);
}
.settings-content {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.settings-panel-title {
font-size: 14px;
font-weight: 600;
color: var(--text);
margin: 0 0 16px;
}
.settings-field {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid var(--border-subtle);
}
.settings-label {
font-size: 13px;
color: var(--text);
}
.settings-select {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text);
font-size: 13px;
padding: 4px 8px;
cursor: pointer;
}
.settings-select:focus {
outline: none;
border-color: var(--accent);
}
/* ============================================================
Settings dialog — mobile (bottom sheet at ≤599px)
============================================================ */
@media (max-width: 599px) {
.settings-dialog {
position: fixed;
bottom: 0;
left: 0;
right: 0;
top: auto;
transform: none;
width: 100%;
height: 85vh;
border-radius: 12px 12px 0 0;
margin: 0;
}
.settings-layout {
flex-direction: column;
}
.settings-tabs {
width: auto;
flex-direction: row;
overflow-x: auto;
border-right: none;
border-bottom: 1px solid var(--border);
padding: 0;
height: auto;
}
.settings-tab {
border-left: none;
border-bottom: 3px solid transparent;
white-space: nowrap;
padding: 10px 16px;
}
.settings-tab--active {
border-bottom-color: var(--accent);
border-left-color: transparent;
}
}
/* ============================================================
Responsive overlay sidebar at <960px
============================================================ */
+140
View File
@@ -711,3 +711,143 @@ def test_no_gradient_fade_on_previews():
css = read_css()
assert ".tile-body::before" not in css, "tile-body::before gradient must be removed"
assert ".sidebar-item-body::before" not in css, "sidebar-item-body::before gradient must be removed"
# ============================================================
# Settings modal CSS (task-6)
# ============================================================
def test_css_header_actions_exists():
""".header-actions must exist with flex layout and gap."""
css = read_css()
assert ".header-actions" in css, "Missing .header-actions CSS rule"
block = _extract_rule_block(css, ".header-actions {")
assert "display: flex" in block, ".header-actions must use display: flex"
assert "gap:" in block and "8px" in block, ".header-actions must have gap: 8px"
def test_css_header_btn_exists():
""".header-btn must exist with 32x32 size, border, cursor."""
css = read_css()
assert ".header-btn" in css, "Missing .header-btn CSS rule"
block = _extract_rule_block(css, ".header-btn {")
assert "width: 32px" in block, ".header-btn must be 32px wide"
assert "height: 32px" in block, ".header-btn must be 32px tall"
assert "border:" in block or "border :" in block, ".header-btn must have border"
assert "cursor: pointer" in block, ".header-btn must have cursor: pointer"
def test_css_header_btn_hover():
""".header-btn:hover must exist."""
css = read_css()
assert ".header-btn:hover" in css, "Missing .header-btn:hover CSS rule"
def test_css_settings_backdrop_exists():
""".settings-backdrop must exist with position: fixed and blur."""
css = read_css()
assert ".settings-backdrop" in css, "Missing .settings-backdrop CSS rule"
block = _extract_rule_block(css, ".settings-backdrop {")
assert "position: fixed" in block, ".settings-backdrop must use position: fixed"
assert "blur" in block or "backdrop-filter" in block, ".settings-backdrop must use blur"
def test_css_settings_dialog_exists():
""".settings-dialog must exist with correct dimensions and z-index."""
css = read_css()
assert ".settings-dialog" in css, "Missing .settings-dialog CSS rule"
block = _extract_rule_block(css, ".settings-dialog {")
assert "600px" in block, ".settings-dialog must be 600px wide"
assert "480px" in block, ".settings-dialog must be 480px tall"
assert "z-index: 300" in block, ".settings-dialog must have z-index: 300"
assert "border-radius:" in block, ".settings-dialog must have border-radius"
def test_css_settings_dialog_backdrop_transparent():
""".settings-dialog::backdrop must be transparent."""
css = read_css()
assert ".settings-dialog::backdrop" in css, "Missing .settings-dialog::backdrop CSS rule"
block = _extract_rule_block(css, ".settings-dialog::backdrop {")
assert "transparent" in block, ".settings-dialog::backdrop must be transparent"
def test_css_settings_layout_flex():
""".settings-layout must use flex layout."""
css = read_css()
assert ".settings-layout" in css, "Missing .settings-layout CSS rule"
block = _extract_rule_block(css, ".settings-layout {")
assert "display: flex" in block, ".settings-layout must use display: flex"
def test_css_settings_tabs_exists():
""".settings-tabs must exist with 140px width sidebar."""
css = read_css()
assert ".settings-tabs" in css, "Missing .settings-tabs CSS rule"
block = _extract_rule_block(css, ".settings-tabs {")
assert "140px" in block, ".settings-tabs must have 140px width"
def test_css_settings_tab_exists():
""".settings-tab must exist as text buttons with left border indicator."""
css = read_css()
assert ".settings-tab" in css, "Missing .settings-tab CSS rule"
block = _extract_rule_block(css, ".settings-tab {")
assert "border-left:" in block, ".settings-tab must have border-left indicator"
assert "cursor: pointer" in block, ".settings-tab must have cursor: pointer"
def test_css_settings_tab_active():
""".settings-tab--active must exist with accent color."""
css = read_css()
assert ".settings-tab--active" in css, "Missing .settings-tab--active CSS rule"
block = _extract_rule_block(css, ".settings-tab--active {")
assert "var(--accent)" in block, ".settings-tab--active must use var(--accent)"
def test_css_settings_content_exists():
""".settings-content must exist as scrollable container."""
css = read_css()
assert ".settings-content" in css, "Missing .settings-content CSS rule"
block = _extract_rule_block(css, ".settings-content {")
assert "overflow-y: auto" in block, ".settings-content must be scrollable (overflow-y: auto)"
def test_css_settings_field_exists():
""".settings-field must exist as flex row between."""
css = read_css()
assert ".settings-field" in css, "Missing .settings-field CSS rule"
block = _extract_rule_block(css, ".settings-field {")
assert "display: flex" in block, ".settings-field must use display: flex"
assert "flex-direction: row" in block, ".settings-field must use flex-direction: row"
assert "justify-content: space-between" in block, ".settings-field must use justify-content: space-between"
def test_css_settings_select_exists():
""".settings-select must exist as styled select."""
css = read_css()
assert ".settings-select" in css, "Missing .settings-select CSS rule"
assert ".settings-select:focus" in css, "Missing .settings-select:focus CSS rule"
def test_css_settings_mobile_media_query():
"""@media (max-width: 599px) block must exist for settings dialog mobile styles."""
css = read_css()
assert "@media (max-width: 599px)" in css, "Missing @media (max-width: 599px) for settings mobile"
media_block = _extract_media_block(css, "@media (max-width: 599px)")
assert ".settings-dialog" in media_block, ".settings-dialog mobile styles must be in 599px media block"
# Bottom sheet: 100% width
block = _extract_rule_block(media_block, ".settings-dialog {")
assert "width: 100%" in block, ".settings-dialog must be 100% wide on mobile"
assert "85vh" in block, ".settings-dialog must be 85vh tall on mobile"
assert "bottom: 0" in block or "bottom:0" in block, ".settings-dialog must be bottom-anchored on mobile"
def test_css_settings_mobile_tabs_horizontal():
"""Inside mobile media query, settings tabs must become horizontal scrolling row."""
css = read_css()
media_block = _extract_media_block(css, "@media (max-width: 599px)")
assert ".settings-tabs" in media_block, ".settings-tabs must have mobile styles in 599px media block"
tabs_block = _extract_rule_block(media_block, ".settings-tabs {")
assert "flex-direction: row" in tabs_block, ".settings-tabs must become horizontal on mobile"
assert "overflow-x: auto" in tabs_block, ".settings-tabs must scroll horizontally on mobile"
+182
View File
@@ -316,3 +316,185 @@ def test_login_html_has_error_display() -> None:
assert "error" in text, (
"login.html must include an error display element (text 'error' not found)"
)
# ============================================================
# Settings modal (task-6)
# ============================================================
def test_html_header_actions_div() -> None:
"""overview header must have .header-actions div containing #new-session-btn, #settings-btn, and #connection-status."""
soup = _SOUP
view_overview = soup.find(id="view-overview")
assert view_overview is not None, "Missing #view-overview"
header = view_overview.find("header", class_="app-header")
assert header is not None, "Missing header.app-header inside #view-overview"
header_actions = header.find(class_="header-actions")
assert header_actions is not None, "Missing .header-actions inside app-header"
def test_html_new_session_btn() -> None:
"""#new-session-btn must exist in .header-actions with class header-btn."""
soup = _SOUP
btn = soup.find(id="new-session-btn")
assert btn is not None, "Missing #new-session-btn"
classes = btn.get("class") or []
assert "header-btn" in classes, f"#new-session-btn must have class 'header-btn', has: {classes}"
# Must be inside header-actions
header_actions = soup.find(class_="header-actions")
assert header_actions is not None, "Missing .header-actions"
assert header_actions.find(id="new-session-btn") is not None, (
"#new-session-btn must be inside .header-actions"
)
def test_html_settings_btn() -> None:
"""#settings-btn must exist in .header-actions with class header-btn."""
soup = _SOUP
btn = soup.find(id="settings-btn")
assert btn is not None, "Missing #settings-btn"
classes = btn.get("class") or []
assert "header-btn" in classes, f"#settings-btn must have class 'header-btn', has: {classes}"
# Must be inside header-actions
header_actions = soup.find(class_="header-actions")
assert header_actions is not None, "Missing .header-actions"
assert header_actions.find(id="settings-btn") is not None, (
"#settings-btn must be inside .header-actions"
)
def test_html_settings_btn_expanded() -> None:
"""#settings-btn-expanded must exist in expanded-header after #expanded-session-name."""
soup = _SOUP
btn = soup.find(id="settings-btn-expanded")
assert btn is not None, "Missing #settings-btn-expanded"
classes = btn.get("class") or []
assert "header-btn" in classes, f"#settings-btn-expanded must have class 'header-btn', has: {classes}"
# Must be inside expanded-header
header = soup.find("header", class_="expanded-header")
assert header is not None, "Missing header.expanded-header"
assert header.find(id="settings-btn-expanded") is not None, (
"#settings-btn-expanded must be inside header.expanded-header"
)
# Must appear after #expanded-session-name
header_children_ids = [
el.get("id") for el in header.children if isinstance(el, Tag)
]
header_children_ids = [i for i in header_children_ids if i]
assert "expanded-session-name" in header_children_ids, "#expanded-session-name must be in expanded-header"
assert "settings-btn-expanded" in header_children_ids, "#settings-btn-expanded must be in expanded-header"
name_idx = header_children_ids.index("expanded-session-name")
settings_idx = header_children_ids.index("settings-btn-expanded")
assert name_idx < settings_idx, (
f"#settings-btn-expanded must come after #expanded-session-name, got indices {name_idx}, {settings_idx}"
)
def test_html_settings_backdrop() -> None:
"""#settings-backdrop must exist with class settings-backdrop and hidden."""
soup = _SOUP
el = soup.find(id="settings-backdrop")
assert el is not None, "Missing #settings-backdrop"
classes = el.get("class") or []
assert "settings-backdrop" in classes, f"#settings-backdrop must have class 'settings-backdrop', has: {classes}"
assert "hidden" in classes, f"#settings-backdrop must have class 'hidden', has: {classes}"
def test_html_settings_dialog() -> None:
"""#settings-dialog must be a <dialog> with class settings-dialog."""
soup = _SOUP
el = soup.find(id="settings-dialog")
assert el is not None, "Missing #settings-dialog"
assert el.name == "dialog", f"#settings-dialog must be a <dialog> element, got: {el.name}"
classes = el.get("class") or []
assert "settings-dialog" in classes, f"#settings-dialog must have class 'settings-dialog', has: {classes}"
def test_html_settings_tabs() -> None:
"""settings-dialog must contain 4 tab buttons with correct data-tab values."""
soup = _SOUP
dialog = soup.find(id="settings-dialog")
assert dialog is not None, "Missing #settings-dialog"
tabs_container = dialog.find("nav", class_="settings-tabs")
assert tabs_container is not None, "Missing nav.settings-tabs inside #settings-dialog"
expected_tabs = ["display", "sessions", "notifications", "new-session"]
for tab_value in expected_tabs:
tab = tabs_container.find("button", attrs={"data-tab": tab_value})
assert tab is not None, f"Missing tab button with data-tab='{tab_value}' in settings-tabs"
# Display tab must be active by default
display_tab = tabs_container.find("button", attrs={"data-tab": "display"})
assert display_tab is not None
display_classes = display_tab.get("class") or []
assert "settings-tab--active" in display_classes, (
f"Display tab must have class 'settings-tab--active', has: {display_classes}"
)
# All tabs must have settings-tab class
for tab_value in expected_tabs:
tab = tabs_container.find("button", attrs={"data-tab": tab_value})
tab_classes = tab.get("class") or []
assert "settings-tab" in tab_classes, (
f"Tab data-tab='{tab_value}' must have class 'settings-tab', has: {tab_classes}"
)
def test_html_settings_display_panel_controls() -> None:
"""Display panel must have font-size, hover-delay, and grid-columns selects."""
soup = _SOUP
dialog = soup.find(id="settings-dialog")
assert dialog is not None, "Missing #settings-dialog"
for ctrl_id in ("setting-font-size", "setting-hover-delay", "setting-grid-columns"):
el = dialog.find(id=ctrl_id)
assert el is not None, f"Missing #{ctrl_id} inside #settings-dialog"
assert el.name == "select", f"#{ctrl_id} must be a <select> element, got: {el.name}"
def test_html_settings_font_size_options() -> None:
"""setting-font-size select must have options 11, 12, 13, 14 (selected), 16."""
soup = _SOUP
select = soup.find(id="setting-font-size")
assert select is not None, "Missing #setting-font-size"
options = select.find_all("option")
values = [o.get("value") for o in options]
for v in ("11", "12", "13", "14", "16"):
assert v in values, f"#setting-font-size missing option value='{v}'"
# 14 must be selected
selected_opt = select.find("option", attrs={"selected": True})
assert selected_opt is not None, "setting-font-size must have a selected option"
assert selected_opt.get("value") == "14", (
f"setting-font-size default selection must be 14, got: {selected_opt.get('value')}"
)
def test_html_settings_hover_delay_options() -> None:
"""setting-hover-delay select must have Off (0), 1000, 1500 (selected), 2000, 3000."""
soup = _SOUP
select = soup.find(id="setting-hover-delay")
assert select is not None, "Missing #setting-hover-delay"
options = select.find_all("option")
values = [o.get("value") for o in options]
for v in ("0", "1000", "1500", "2000", "3000"):
assert v in values, f"#setting-hover-delay missing option value='{v}'"
# 1500 must be selected
selected_opt = select.find("option", attrs={"selected": True})
assert selected_opt is not None, "setting-hover-delay must have a selected option"
assert selected_opt.get("value") == "1500", (
f"setting-hover-delay default selection must be 1500, got: {selected_opt.get('value')}"
)
def test_html_settings_grid_columns_options() -> None:
"""setting-grid-columns select must have auto (selected), 2, 3, 4."""
soup = _SOUP
select = soup.find(id="setting-grid-columns")
assert select is not None, "Missing #setting-grid-columns"
options = select.find_all("option")
values = [o.get("value") for o in options]
for v in ("auto", "2", "3", "4"):
assert v in values, f"#setting-grid-columns missing option value='{v}'"
# auto must be selected
selected_opt = select.find("option", attrs={"selected": True})
assert selected_opt is not None, "setting-grid-columns must have a selected option"
assert selected_opt.get("value") == "auto", (
f"setting-grid-columns default selection must be auto, got: {selected_opt.get('value')}"
)