diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index 302b419..ea3bb55 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -862,6 +862,10 @@ async function openSession(name, opts = {}) { updateSessionPill(_currentSessions); } + // Hide FAB during fullscreen session view + const fab = $('new-session-fab'); + if (fab) fab.classList.add('hidden'); + // Connect to session (kill old ttyd, spawn new one for this session) try { if (!opts.skipConnect) { @@ -903,6 +907,10 @@ function closeSession() { const pill = $('session-pill'); if (pill) pill.classList.add('hidden'); + // Restore FAB when returning to overview + const fab = $('new-session-fab'); + if (fab) fab.classList.remove('hidden'); + return Promise.resolve(); } @@ -1348,6 +1356,8 @@ function bindStaticEventListeners() { if (newSessionBtn) on(newSessionBtn, 'click', function() { showNewSessionInput(newSessionBtn); }); var sidebarNewSessionBtn = $('sidebar-new-session-btn'); if (sidebarNewSessionBtn) on(sidebarNewSessionBtn, 'click', function() { showNewSessionInput(sidebarNewSessionBtn); }); + var newSessionFab = $('new-session-fab'); + if (newSessionFab) on(newSessionFab, 'click', function() { showNewSessionInput(newSessionFab); }); on($('sidebar-toggle-btn'), 'click', toggleSidebar); on($('sidebar-collapse-btn'), 'click', toggleSidebar); bindSidebarClickAway(); diff --git a/muxplex/frontend/index.html b/muxplex/frontend/index.html index f5cf808..55ec634 100644 --- a/muxplex/frontend/index.html +++ b/muxplex/frontend/index.html @@ -69,6 +69,9 @@ + + +
diff --git a/muxplex/frontend/style.css b/muxplex/frontend/style.css index 99ec2cc..1ad9f3f 100644 --- a/muxplex/frontend/style.css +++ b/muxplex/frontend/style.css @@ -1201,6 +1201,34 @@ body { border-color: var(--text-muted); } +/* ============================================================ + Mobile FAB — new session floating action button + ============================================================ */ + +.new-session-fab { + display: none; + 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 150ms ease, box-shadow 150ms ease; +} + +.new-session-fab:active { + transform: scale(0.95); +} + /* ============================================================ Responsive overlay sidebar at <960px ============================================================ */ @@ -1228,4 +1256,15 @@ body { .sidebar-collapse-btn { display: none; } + + /* Mobile FAB: show on mobile, hide header + button */ + .new-session-fab { + display: flex; + align-items: center; + justify-content: center; + } + + #new-session-btn { + display: none; + } } diff --git a/muxplex/tests/test_frontend_css.py b/muxplex/tests/test_frontend_css.py index 96984ca..4048836 100644 --- a/muxplex/tests/test_frontend_css.py +++ b/muxplex/tests/test_frontend_css.py @@ -1488,3 +1488,121 @@ def test_css_sidebar_new_btn_hover_exists() -> None: assert ".sidebar-new-btn:hover" in css, ( "Missing .sidebar-new-btn:hover rule in style.css" ) + + +# ============================================================ +# Mobile FAB (task-6-mobile-fab) +# ============================================================ + + +def test_css_fab_class_exists() -> None: + """.new-session-fab CSS rule must exist.""" + css = read_css() + assert ".new-session-fab" in css, "Missing .new-session-fab rule in style.css" + + +def test_css_fab_display_none_by_default() -> None: + """.new-session-fab must have display:none as default (hidden on desktop).""" + import re + css = read_css() + # Find the .new-session-fab block (non-media-query context) + # Match the block that is NOT inside a @media rule + # Look for .new-session-fab { ... display: none ... } outside of @media + # Simple check: the rule body should contain 'display' before the first @media containing it + match = re.search(r'\.new-session-fab\s*\{([^}]*)\}', css) + assert match, ".new-session-fab rule not found" + body = match.group(1) + assert "display" in body and ("none" in body or "display:none" in body.replace(" ", "")), ( + f".new-session-fab must have display:none by default, got body: {body!r}" + ) + + +def test_css_fab_position_fixed() -> None: + """.new-session-fab must be position:fixed.""" + import re + css = read_css() + match = re.search(r'\.new-session-fab\s*\{([^}]*)\}', css) + assert match, ".new-session-fab rule not found" + body = match.group(1) + assert "position" in body and "fixed" in body, ( + f".new-session-fab must have position:fixed, got: {body!r}" + ) + + +def test_css_fab_size_56px() -> None: + """.new-session-fab must be 56px width and height.""" + import re + css = read_css() + match = re.search(r'\.new-session-fab\s*\{([^}]*)\}', css) + assert match, ".new-session-fab rule not found" + body = match.group(1) + assert "56px" in body, ( + f".new-session-fab must have 56px size (width/height), got: {body!r}" + ) + + +def test_css_fab_border_radius_50_percent() -> None: + """.new-session-fab must have border-radius:50% for circular shape.""" + import re + css = read_css() + match = re.search(r'\.new-session-fab\s*\{([^}]*)\}', css) + assert match, ".new-session-fab rule not found" + body = match.group(1) + assert "50%" in body, ( + f".new-session-fab must have border-radius:50%, got: {body!r}" + ) + + +def test_css_fab_mobile_media_query_shows_flex() -> None: + """At max-width: 959px, .new-session-fab must be shown as display:flex.""" + import re + css = read_css() + # Find the 959px media query and check that .new-session-fab uses display:flex + match = re.search( + r'@media\s*\([^)]*max-width\s*:\s*959px[^)]*\)\s*\{([^@]*)\}', + css, + re.DOTALL + ) + assert match, "Missing @media (max-width: 959px) block" + media_body = match.group(1) + assert ".new-session-fab" in media_body, ( + "@media (max-width: 959px) block must contain .new-session-fab rule" + ) + # Find the .new-session-fab rule within the media query + fab_match = re.search(r'\.new-session-fab\s*\{([^}]*)\}', media_body) + assert fab_match, ".new-session-fab rule not found in 959px media query" + fab_body = fab_match.group(1) + assert "flex" in fab_body, ( + f".new-session-fab must show as display:flex in 959px media query, got: {fab_body!r}" + ) + + +def test_css_fab_mobile_media_query_hides_new_session_btn() -> None: + """At max-width: 959px, #new-session-btn must be hidden.""" + import re + css = read_css() + match = re.search( + r'@media\s*\([^)]*max-width\s*:\s*959px[^)]*\)\s*\{([^@]*)\}', + css, + re.DOTALL + ) + assert match, "Missing @media (max-width: 959px) block" + media_body = match.group(1) + assert "#new-session-btn" in media_body, ( + "@media (max-width: 959px) block must contain #new-session-btn rule to hide it" + ) + # Find the #new-session-btn rule and verify it has display:none + btn_match = re.search(r'#new-session-btn\s*\{([^}]*)\}', media_body) + assert btn_match, "#new-session-btn rule not found in 959px media query" + btn_body = btn_match.group(1) + assert "none" in btn_body, ( + f"#new-session-btn must have display:none in 959px media query, got: {btn_body!r}" + ) + + +def test_css_fab_active_transform() -> None: + """.new-session-fab:active must have a transform rule (scale).""" + css = read_css() + assert ".new-session-fab:active" in css, ( + "Missing .new-session-fab:active rule in style.css" + ) diff --git a/muxplex/tests/test_frontend_html.py b/muxplex/tests/test_frontend_html.py index 66917c9..93535bb 100644 --- a/muxplex/tests/test_frontend_html.py +++ b/muxplex/tests/test_frontend_html.py @@ -949,3 +949,53 @@ def test_html_sidebar_structure_complete() -> None: assert header is not None, "Missing .sidebar-header in #session-sidebar" assert list_ is not None, "Missing #sidebar-list in #session-sidebar" assert footer is not None, "Missing .sidebar-footer in #session-sidebar" + + +# ============================================================ +# Mobile FAB (task-6-mobile-fab) +# ============================================================ + + +def test_html_fab_exists() -> None: + """#new-session-fab button must exist with class new-session-fab, aria-label='New session', text '+'.""" + soup = _SOUP + fab = soup.find(id="new-session-fab") + assert fab is not None, "Missing #new-session-fab" + assert fab.name == "button", ( + f"#new-session-fab must be a