feat: add sidebar markup, toggle button, and view-body wrapper to expanded view

- Add #sidebar-toggle-btn to expanded-header (after #back-btn, before #expanded-session-name)
  with aria-label='Toggle session list' and hamburger icon ☰
- Wrap #terminal-container in div.view-body
- Add #session-sidebar before #terminal-container inside .view-body, containing:
  - .sidebar-header with .sidebar-title 'Sessions' and #sidebar-collapse-btn (chevron ‹)
  - #sidebar-list div
- Keep #reconnect-overlay outside .view-body as sibling inside #view-expanded

Tests added:
- test_html_sidebar_toggle_button: verifies placement, aria-label, and order in header
- test_html_view_body_wrapper: verifies .view-body contains #session-sidebar and #terminal-container
- test_html_reconnect_overlay_outside_view_body: verifies #reconnect-overlay is outside .view-body
- test_html_session_sidebar_structure: verifies full sidebar DOM structure

Co-authored-by: Amplifier <amplifier@amplified.dev>
This commit is contained in:
Brian Krabach
2026-03-27 16:35:43 -07:00
parent 95c1ec1ad4
commit a3f1e2efd8
2 changed files with 102 additions and 8 deletions
+84
View File
@@ -133,6 +133,90 @@ def test_html_style_css() -> None:
) )
def test_html_sidebar_toggle_button() -> None:
"""#sidebar-toggle-btn must exist in expanded-header with correct aria-label and hamburger icon."""
soup = _SOUP
btn = soup.find(id="sidebar-toggle-btn")
assert btn is not None, "Missing #sidebar-toggle-btn"
assert btn.get("aria-label") == "Toggle session list", (
f"#sidebar-toggle-btn aria-label must be 'Toggle session list', got: {btn.get('aria-label')!r}"
)
# 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="sidebar-toggle-btn") is not None, (
"#sidebar-toggle-btn must be inside header.expanded-header"
)
# Must be after #back-btn and before #expanded-session-name
header_children_ids = [el.get("id") for el in header.children if hasattr(el, "get")]
header_children_ids = [i for i in header_children_ids if i]
assert "back-btn" in header_children_ids, "#back-btn must be in expanded-header"
assert "sidebar-toggle-btn" in header_children_ids, "#sidebar-toggle-btn must be in expanded-header"
assert "expanded-session-name" in header_children_ids, "#expanded-session-name must be in expanded-header"
back_idx = header_children_ids.index("back-btn")
toggle_idx = header_children_ids.index("sidebar-toggle-btn")
name_idx = header_children_ids.index("expanded-session-name")
assert back_idx < toggle_idx < name_idx, (
f"Order must be back-btn < sidebar-toggle-btn < expanded-session-name, got indices {back_idx}, {toggle_idx}, {name_idx}"
)
def test_html_view_body_wrapper() -> None:
""".view-body div must exist inside #view-expanded wrapping #session-sidebar and #terminal-container."""
soup = _SOUP
view_expanded = soup.find(id="view-expanded")
assert view_expanded is not None, "Missing #view-expanded"
view_body = view_expanded.find("div", class_="view-body")
assert view_body is not None, "Missing div.view-body inside #view-expanded"
# #terminal-container must be inside .view-body
assert view_body.find(id="terminal-container") is not None, (
"#terminal-container must be inside div.view-body"
)
# #session-sidebar must be inside .view-body
assert view_body.find(id="session-sidebar") is not None, (
"#session-sidebar must be inside div.view-body"
)
def test_html_reconnect_overlay_outside_view_body() -> None:
"""#reconnect-overlay must be a direct child of #view-expanded, NOT inside .view-body."""
soup = _SOUP
view_expanded = soup.find(id="view-expanded")
assert view_expanded is not None, "Missing #view-expanded"
view_body = view_expanded.find("div", class_="view-body")
assert view_body is not None, "Missing div.view-body"
# reconnect-overlay must NOT be inside view-body
assert view_body.find(id="reconnect-overlay") is None, (
"#reconnect-overlay must NOT be inside div.view-body"
)
# reconnect-overlay must be inside view-expanded (as sibling of view-body)
assert view_expanded.find(id="reconnect-overlay") is not None, (
"#reconnect-overlay must be inside #view-expanded"
)
def test_html_session_sidebar_structure() -> None:
"""#session-sidebar must contain .sidebar-header (with .sidebar-title and #sidebar-collapse-btn) and #sidebar-list."""
soup = _SOUP
sidebar = soup.find(id="session-sidebar")
assert sidebar is not None, "Missing #session-sidebar"
# .sidebar-header
sidebar_header = sidebar.find(class_="sidebar-header")
assert sidebar_header is not None, "Missing .sidebar-header inside #session-sidebar"
# .sidebar-title with text 'Sessions'
sidebar_title = sidebar_header.find(class_="sidebar-title")
assert sidebar_title is not None, "Missing .sidebar-title inside .sidebar-header"
assert "Sessions" in sidebar_title.get_text(), (
f".sidebar-title text must contain 'Sessions', got: {sidebar_title.get_text()!r}"
)
# #sidebar-collapse-btn
collapse_btn = sidebar_header.find(id="sidebar-collapse-btn")
assert collapse_btn is not None, "Missing #sidebar-collapse-btn inside .sidebar-header"
# #sidebar-list
sidebar_list = sidebar.find(id="sidebar-list")
assert sidebar_list is not None, "Missing #sidebar-list inside #session-sidebar"
def test_html_element_classes() -> None: def test_html_element_classes() -> None:
"""Critical and important elements must carry their CSS styling classes.""" """Critical and important elements must carry their CSS styling classes."""
soup = _SOUP soup = _SOUP
+17 -7
View File
@@ -16,7 +16,7 @@
</head> </head>
<body> <body>
<!-- ── Overview view ─────────────────────────────────────────────── --> <!-- ── Overview view ─────────────────────────────────────────────────────── -->
<div id="view-overview" class="view view--active"> <div id="view-overview" class="view view--active">
<header class="app-header"> <header class="app-header">
<h1 class="app-wordmark"><img src="/wordmark-on-dark.svg" alt="muxplex" height="24" /></h1> <h1 class="app-wordmark"><img src="/wordmark-on-dark.svg" alt="muxplex" height="24" /></h1>
@@ -26,18 +26,28 @@
<div id="empty-state" class="empty-state hidden">No active tmux sessions</div> <div id="empty-state" class="empty-state hidden">No active tmux sessions</div>
</div> </div>
<!-- ── Expanded (terminal) view ────────────────────────────────────── --> <!-- ── Expanded (terminal) view ──────────────────────────────────────────── -->
<div id="view-expanded" class="view hidden"> <div id="view-expanded" class="view hidden">
<header class="expanded-header"> <header class="expanded-header">
<button id="back-btn" class="back-btn" aria-label="Back">&#8592;</button> <button id="back-btn" class="back-btn" aria-label="Back">&#8592;</button>
<button id="sidebar-toggle-btn" aria-label="Toggle session list">&#9776;</button>
<span id="expanded-session-name" class="expanded-session-name"></span> <span id="expanded-session-name" class="expanded-session-name"></span>
<button id="palette-trigger" class="palette-trigger" aria-label="Open command palette">&#8984;K</button> <button id="palette-trigger" class="palette-trigger" aria-label="Open command palette">&#8984;K</button>
</header> </header>
<div class="view-body">
<div id="session-sidebar">
<div class="sidebar-header">
<span class="sidebar-title">Sessions</span>
<button id="sidebar-collapse-btn">&#8249;</button>
</div>
<div id="sidebar-list"></div>
</div>
<div id="terminal-container" class="terminal-container"></div> <div id="terminal-container" class="terminal-container"></div>
</div>
<div id="reconnect-overlay" class="reconnect-overlay hidden" aria-live="polite">Reconnecting&hellip;</div> <div id="reconnect-overlay" class="reconnect-overlay hidden" aria-live="polite">Reconnecting&hellip;</div>
</div> </div>
<!-- ── Command palette ─────────────────────────────────────────────── --> <!-- ── Command palette ───────────────────────────────────────────────────── -->
<div id="command-palette" class="command-palette hidden" role="dialog" aria-modal="true" aria-label="Switch session"> <div id="command-palette" class="command-palette hidden" role="dialog" aria-modal="true" aria-label="Switch session">
<div class="command-palette__backdrop" id="palette-backdrop"></div> <div class="command-palette__backdrop" id="palette-backdrop"></div>
<div class="command-palette__dialog"> <div class="command-palette__dialog">
@@ -47,7 +57,7 @@
</div> </div>
</div> </div>
<!-- ── Bottom sheet (session switcher) ─────────────────────────────── --> <!-- ── Bottom sheet (session switcher) ───────────────────────────────────── -->
<div id="bottom-sheet" class="bottom-sheet hidden" role="dialog" aria-modal="true" aria-label="Switch session"> <div id="bottom-sheet" class="bottom-sheet hidden" role="dialog" aria-modal="true" aria-label="Switch session">
<div class="bottom-sheet__backdrop" id="sheet-backdrop"></div> <div class="bottom-sheet__backdrop" id="sheet-backdrop"></div>
<div class="bottom-sheet__panel"> <div class="bottom-sheet__panel">
@@ -56,16 +66,16 @@
</div> </div>
</div> </div>
<!-- ── Session pill (persistent overlay button) ───────────────────── --> <!-- ── Session pill (persistent overlay button) ──────────────────────────── -->
<button id="session-pill" class="session-pill hidden" aria-label="Switch session"> <button id="session-pill" class="session-pill hidden" aria-label="Switch session">
<span id="session-pill-label" class="session-pill__label"></span> <span id="session-pill-label" class="session-pill__label"></span>
<span id="session-pill-bell" class="session-pill__bell hidden" aria-hidden="true">&#128276;</span> <span id="session-pill-bell" class="session-pill__bell hidden" aria-hidden="true">&#128276;</span>
</button> </button>
<!-- ── Toast notification ─────────────────────────────────────────── --> <!-- ── Toast notification ─────────────────────────────────────────────────── -->
<div id="toast" class="toast hidden" role="status" aria-live="polite" aria-atomic="true"></div> <div id="toast" class="toast hidden" role="status" aria-live="polite" aria-atomic="true"></div>
<!-- ── Scripts ────────────────────────────────────────────────────── --> <!-- ── Scripts ─────────────────────────────────────────────────────────────── -->
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script> <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> <script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
<script src="/app.js" defer></script> <script src="/app.js" defer></script>