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 babd9b7..93ad681 100644 Binary files a/muxplex/tests/__pycache__/test_frontend_css.cpython-312-pytest-9.0.2.pyc and b/muxplex/tests/__pycache__/test_frontend_css.cpython-312-pytest-9.0.2.pyc differ 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 a9bb407..0f77138 100644 Binary files a/muxplex/tests/__pycache__/test_frontend_html.cpython-312-pytest-9.0.2.pyc and b/muxplex/tests/__pycache__/test_frontend_html.cpython-312-pytest-9.0.2.pyc differ 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