From b20759c6cb327b0e6aa1765d5f40c5aa2fb1b920 Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Sun, 29 Mar 2026 23:04:11 -0700 Subject: [PATCH] feat: add settings dialog HTML structure and CSS --- muxplex/frontend/index.html | 56 +++++- muxplex/frontend/style.css | 186 ++++++++++++++++++ ..._frontend_css.cpython-312-pytest-9.0.2.pyc | Bin 128883 -> 159518 bytes ...frontend_html.cpython-312-pytest-9.0.2.pyc | Bin 45832 -> 79977 bytes muxplex/tests/test_frontend_css.py | 140 +++++++++++++ muxplex/tests/test_frontend_html.py | 182 +++++++++++++++++ 6 files changed, 563 insertions(+), 1 deletion(-) diff --git a/muxplex/frontend/index.html b/muxplex/frontend/index.html index 5051046..8966c4b 100644 --- a/muxplex/frontend/index.html +++ b/muxplex/frontend/index.html @@ -20,7 +20,11 @@

muxplex

- +
+ + + +
@@ -32,6 +36,7 @@ +
@@ -64,6 +69,55 @@ + + + +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+
+
+ diff --git a/muxplex/frontend/style.css b/muxplex/frontend/style.css index c50ad97..1cfd517 100644 --- a/muxplex/frontend/style.css +++ b/muxplex/frontend/style.css @@ -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 ============================================================ */ diff --git a/muxplex/tests/__pycache__/test_frontend_css.cpython-312-pytest-9.0.2.pyc b/muxplex/tests/__pycache__/test_frontend_css.cpython-312-pytest-9.0.2.pyc index babd9b790dd9cfa3cf711f477a0ec873f4782682..93ad6818fbac923d397657bf466de5284354ee9b 100644 GIT binary patch delta 15170 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 diff --git a/muxplex/tests/__pycache__/test_frontend_html.cpython-312-pytest-9.0.2.pyc b/muxplex/tests/__pycache__/test_frontend_html.cpython-312-pytest-9.0.2.pyc index a9bb407e59afa9e285db51fcb043112163a2a9ab..0f771385218259be6797fd44df311c1e9d78f5d9 100644 GIT binary patch delta 17515 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 delta 1988 zcmb7^drZ?;6vsUUeqbx56~W4@tx%z$6GV!L3W^S*P^x8AvlA?J)`-5Z;H*rj(!}-su+aDxq<`%b17Zdl#c23#&gFTXw^z%KOd+xdS z+~2+D`=9;y-&d%w1qUl9(ch(jFP;7KI@CJFS8yT01iSTiwyK^jXMNg78QMQS3ACY?nZLmFSNFdO7TMPUSZurKNd$iu_Y%VD+bjduAZ z=1_HF1><=}E?eq0ei39U%|061wBAU{cfD4tqsmefisc^b# z+@c)8vI0DEu4U9eh}Y;-i0zn?844A+B6B58YR@!jt9UIJqncn9ui{W<0@UMAnTgOO zM=aXpr+SS?r!aQn-mK!Wo$RLnC+zo87AMWfo)!MLq&#^q~PsC z?GF2#F-ISGrP6Yn+g;+pk%9qfInS`-8|!ujt|8Nt5{pJ-7#a(W@FwOI_D6XXvke}N zatTdwh#G?_!BuZDyTw!rHlw;|8yv)xMjf^lDHqx}mx-~JQOf8bcomLvahMHoicI!) zG2UVvVbtTTq5?P}n~LkzrlsuR`(_m_Y_<`+!L?3DUCCdyh<5zUZh=Gi%)UsqjDtEE zn{lbbB8{B^?sV*fZr+;y43EV!;usTmi5X=J;H)glep4>_r?o{l&uV9!Vx%!FjCUEQ z8HX5W@V)9t$j7VIFG{}el8WU*huI=ZHU3>hCvFoKsh8vXXyr)!NX&)v@+0wnu)c@A z6DtpNJME6GHbFHH?pXkb(dPIvhj60lJDV;U}E>G@qAkNh7-UYdir7bV|NnVm35~^ z{9qe3lDq_OP+gU;Vfr#L|JwO0z-)(TLTY&^43>`~OVVRcL{@EvtFTT1GL$&hRjr z7zY?97-t#h8H0=~3|~#x*}BcR%NS)mU_4|vyF$`Ir+P{@(9iwrq^^1C@Qd<^CIBR1 GCf6U^<}hji diff --git a/muxplex/tests/test_frontend_css.py b/muxplex/tests/test_frontend_css.py index 762ac97..0209e91 100644 --- a/muxplex/tests/test_frontend_css.py +++ b/muxplex/tests/test_frontend_css.py @@ -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" diff --git a/muxplex/tests/test_frontend_html.py b/muxplex/tests/test_frontend_html.py index 9706f21..0f7e2b6 100644 --- a/muxplex/tests/test_frontend_html.py +++ b/muxplex/tests/test_frontend_html.py @@ -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 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 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