diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index 6d0cf48..3b0f644 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -539,10 +539,11 @@ function buildTileHTML(session, index, mobile) { `
` + `
` + `${escapeHtml(name)}` + - `${badgeHtml}${badgeHtml ? `\xb7` : ''}${escapeHtml(timeStr)}` + + `${badgeHtml}` + + `${escapeHtml(timeStr)}` + + `` + `
` + `
${ansiToHtml(lastLines)}
` + - `` + `
` ); } @@ -886,24 +887,29 @@ function renderViewDropdown() { var html = ''; - // — All Sessions (always first, shortcut 1) + // — All Sessions (always first) — show count of non-hidden sessions + var allHiddenSessions = (_serverSettings && _serverSettings.hidden_sessions) || []; + var allCount = (_currentSessions || []).filter(function(s) { + if (s.status) return false; + return allHiddenSessions.indexOf(s.sessionKey || s.name) === -1 && allHiddenSessions.indexOf(s.name) === -1; + }).length; var allActive = _activeView === 'all' ? ' view-dropdown__item--active' : ''; - html += ''; + html += ''; - // — User views (shortcuts 2–8) + // — User views if (views.length > 0) { html += '
'; for (var i = 0; i < views.length && i < 7; i++) { var v = views[i]; var vActive = _activeView === v.name ? ' view-dropdown__item--active' : ''; - html += ''; + html += ''; } } - // — Hidden (N) (always last system view, shortcut 9) + // — Hidden (N) (always last system view) html += '
'; var hiddenActive = _activeView === 'hidden' ? ' view-dropdown__item--active' : ''; - html += ''; + html += ''; // — Actions html += '
'; @@ -973,9 +979,14 @@ function renderSidebarViewDropdown() { var html = ''; - // — All Sessions (always first) + // — All Sessions (always first) — show count of non-hidden sessions + var sbHiddenSessions = (_serverSettings && _serverSettings.hidden_sessions) || []; + var sbAllCount = (_currentSessions || []).filter(function(s) { + if (s.status) return false; + return sbHiddenSessions.indexOf(s.sessionKey || s.name) === -1 && sbHiddenSessions.indexOf(s.name) === -1; + }).length; var allActive = _activeView === 'all' ? ' view-dropdown__item--active' : ''; - html += ''; + html += ''; // — User views if (views.length > 0) { @@ -983,14 +994,19 @@ function renderSidebarViewDropdown() { for (var i = 0; i < views.length && i < 7; i++) { var v = views[i]; var vActive = _activeView === v.name ? ' view-dropdown__item--active' : ''; - html += ''; + html += ''; } } // — Hidden (N) (always last system view) html += '
'; var hiddenActive = _activeView === 'hidden' ? ' view-dropdown__item--active' : ''; - html += ''; + html += ''; + + // — Actions: new view + manage views + html += '
'; + html += ''; + html += ''; menu.innerHTML = html; } @@ -1084,6 +1100,7 @@ function showNewViewInput() { .then(function() { if (_serverSettings) _serverSettings.views = updatedViews; switchView(name); + openAddSessionsPanel(); }) .catch(function() { showToast('Failed to create view'); @@ -1102,6 +1119,91 @@ function showNewViewInput() { }); } +/** + * Show an inline text input inside the SIDEBAR view dropdown for creating a new view. + * Targets #sidebar-view-dropdown-menu instead of #view-dropdown-menu. + * - On Enter: validates, PATCHes /api/settings, calls switchView + openAddSessionsPanel. + * - On Escape / blur: closes the sidebar dropdown. + */ +function showSidebarNewViewInput() { + var menu = $('sidebar-view-dropdown-menu'); + if (!menu) return; + + // Re-focus existing input instead of creating a duplicate + var existing = menu.querySelector('.view-dropdown__new-input'); + if (existing) { + existing.focus(); + return; + } + + // Find the '+ New View' button to replace + var newViewBtn = menu.querySelector('[data-action="new-view"]'); + if (!newViewBtn) return; + + // Create the inline text input + var input = document.createElement('input'); + input.type = 'text'; + input.className = 'view-dropdown__new-input'; + input.placeholder = 'View name'; + input.maxLength = 30; + input.setAttribute('aria-label', 'New view name'); + + // Replace the '+ New View' button with the input + newViewBtn.parentNode.replaceChild(input, newViewBtn); + input.focus(); + + function closeSidebarDropdown() { + menu.classList.add('hidden'); + var trigger = $('sidebar-view-dropdown-trigger'); + if (trigger) trigger.setAttribute('aria-expanded', 'false'); + } + + input.addEventListener('keydown', function(e) { + if (e.key === 'Enter') { + var name = input.value.trim(); + + // Validate: not empty + if (!name) return; + + // Validate: not reserved (case-insensitive) + if (name.toLowerCase() === 'all' || name.toLowerCase() === 'hidden') { + showToast('Cannot use reserved name \'' + name + '\''); + return; + } + + // Validate: not duplicate + var views = (_serverSettings && _serverSettings.views) || []; + if (views.find(function(v) { return v.name === name; })) { + showToast('View \'' + name + '\' already exists'); + return; + } + + // Create view and PATCH /api/settings + var updatedViews = views.concat([{ name: name, sessions: [] }]); + api('PATCH', '/api/settings', { views: updatedViews }) + .then(function() { + if (_serverSettings) _serverSettings.views = updatedViews; + closeSidebarDropdown(); + switchView(name); + openAddSessionsPanel(); + }) + .catch(function() { + showToast('Failed to create view'); + }); + } else if (e.key === 'Escape') { + closeSidebarDropdown(); + } + }); + + input.addEventListener('blur', function() { + setTimeout(function() { + if (document.activeElement !== input) { + closeSidebarDropdown(); + } + }, 150); + }); +} + /** * Save updated views array via PATCH /api/settings, update _serverSettings, * re-render the views settings tab, and re-render the view dropdown. @@ -1372,6 +1474,8 @@ function switchView(viewName) { sidebarLabel.textContent = viewName; } } + // Show/hide the header Add Sessions button + updateAddSessionsButton(); // Persist active view — fire and forget api('PATCH', '/api/state', { active_view: viewName }).catch(function() {}); } @@ -1438,21 +1542,6 @@ function renderGrid(sessions) { else if (session.status === 'unreachable') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Offline', 'offline'); else if (session.status === 'empty') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'No sessions', 'empty'); }); - // Add Sessions affordance tile — shown in user views only - if (_activeView !== 'all' && _activeView !== 'hidden') { - var viewsArr = (_serverSettings && _serverSettings.views) || []; - var isUserView = false; - for (var vi = 0; vi < viewsArr.length; vi++) { - if (viewsArr[vi].name === _activeView) { isUserView = true; break; } - } - if (isUserView) { - html += ''; - } - } - if (grid) grid.innerHTML = html + statusTilesHtml; // Clear filter bar (filtered mode removed; bar is a no-op for flat/grouped) @@ -1922,10 +2011,6 @@ function _openFlyoutSubmenu(triggerItem, unhideFirst) { } var views = (_serverSettings && _serverSettings.views) || []; - if (views.length === 0) { - showToast('No user views. Create one from the header dropdown.'); - return; - } var sessionKey = _flyoutSessionKey; // When in a user view, filter it out — the user already has "Remove from [ViewName]" for it @@ -1940,6 +2025,11 @@ function _openFlyoutSubmenu(triggerItem, unhideFirst) { html += escapeHtml(v.name); html += ''; } + // — Always show "+ New View" option at the bottom + if (views.length > 0) { + html += ''; + } + html += ''; var submenu = document.createElement('div'); submenu.className = 'flyout-submenu'; @@ -1971,6 +2061,50 @@ function _openFlyoutSubmenu(triggerItem, unhideFirst) { // Click handler — toggle view membership via PATCH /api/settings submenu.addEventListener('click', function(e) { + // Handle '+ New View' action + var newViewAction = e.target.closest('[data-action="new-view-in-flyout"]'); + if (newViewAction) { + var capturedKey = sessionKey; + var capturedUnhide = unhideFirst; + closeFlyoutMenu(); + var newName = prompt('View name:'); + if (!newName || !newName.trim()) return; + newName = newName.trim(); + if (newName.toLowerCase() === 'all' || newName.toLowerCase() === 'hidden') { + showToast('Cannot use reserved name \'' + newName + '\''); + return; + } + var existViews = (_serverSettings && _serverSettings.views) || []; + if (existViews.find(function(v) { return v.name === newName; })) { + showToast('View \'' + newName + '\' already exists'); + return; + } + var newView = { name: newName, sessions: [capturedKey] }; + var newViews = existViews.concat([newView]); + var flyoutPatch = { views: newViews }; + if (capturedUnhide) { + var hiddenList = (_serverSettings && _serverSettings.hidden_sessions) || []; + var hi = hiddenList.indexOf(capturedKey); + if (hi !== -1) { + var updHidden = hiddenList.slice(); + updHidden.splice(hi, 1); + flyoutPatch.hidden_sessions = updHidden; + } + } + api('PATCH', '/api/settings', flyoutPatch) + .then(function() { + if (_serverSettings) { + _serverSettings.views = newViews; + if (flyoutPatch.hidden_sessions) _serverSettings.hidden_sessions = flyoutPatch.hidden_sessions; + } + switchView(newName); + }) + .catch(function() { + showToast('Failed to create view'); + }); + return; + } + var btn = e.target.closest('[data-view-index]'); if (!btn) return; var idx = parseInt(btn.dataset.viewIndex, 10); @@ -2252,6 +2386,28 @@ function closeAddSessionsPanel() { if (panel) panel.classList.add('hidden'); } +/** + * Show or hide the header #add-sessions-btn based on the active view. + * Visible only when in a user-created view (not 'all' or 'hidden'). + * Called from switchView() and on initial load. + */ +function updateAddSessionsButton() { + var btn = $('add-sessions-btn'); + if (!btn) return; + var isUserView = false; + if (_activeView !== 'all' && _activeView !== 'hidden') { + var views = (_serverSettings && _serverSettings.views) || []; + for (var i = 0; i < views.length; i++) { + if (views[i].name === _activeView) { isUserView = true; break; } + } + } + if (isUserView) { + btn.classList.remove('hidden'); + } else { + btn.classList.add('hidden'); + } +} + /** * Render the session list inside the Add Sessions panel. * Shows all sessions NOT currently in the active view. @@ -3763,10 +3919,28 @@ function bindStaticEventListeners() { // Close sidebar dropdown after selection sidebarViewMenu.classList.add('hidden'); if (sidebarViewTrigger) sidebarViewTrigger.setAttribute('aria-expanded', 'false'); + return; + } + var action = e.target.closest('[data-action]'); + if (action) { + if (action.dataset.action === 'new-view') { + showSidebarNewViewInput(); + } else if (action.dataset.action === 'manage-views') { + sidebarViewMenu.classList.add('hidden'); + if (sidebarViewTrigger) sidebarViewTrigger.setAttribute('aria-expanded', 'false'); + openSettings(); + switchSettingsTab('views'); + } else { + sidebarViewMenu.classList.add('hidden'); + if (sidebarViewTrigger) sidebarViewTrigger.setAttribute('aria-expanded', 'false'); + } } }); } + var addSessionsBtn = $('add-sessions-btn'); + if (addSessionsBtn) on(addSessionsBtn, 'click', openAddSessionsPanel); + // Click-outside closes the header view dropdown document.addEventListener('click', function(e) { var dropdown = $('view-dropdown-menu'); @@ -4104,6 +4278,7 @@ document.addEventListener('DOMContentLoaded', async function() { updatePageTitle(); startHeartbeat(); bindStaticEventListeners(); + updateAddSessionsButton(); }) .catch(function(err) { console.error('[init] restoreState failed, retrying in 5s:', err); @@ -4200,6 +4375,9 @@ if (typeof module !== 'undefined' && module.exports) { // Sidebar view dropdown renderSidebarViewDropdown, toggleSidebarViewDropdown, + showSidebarNewViewInput, + // Add Sessions header button + updateAddSessionsButton, // Manage Views settings tab renderViewsSettingsTab, _saveViewsAndRerender, diff --git a/muxplex/frontend/index.html b/muxplex/frontend/index.html index 94cf2a6..5cc3766 100644 --- a/muxplex/frontend/index.html +++ b/muxplex/frontend/index.html @@ -27,6 +27,7 @@
+ diff --git a/muxplex/frontend/style.css b/muxplex/frontend/style.css index bacab42..a5a948a 100644 --- a/muxplex/frontend/style.css +++ b/muxplex/frontend/style.css @@ -208,6 +208,7 @@ body { border-bottom: 1px solid var(--border-subtle); display: flex; align-items: center; + gap: 4px; } .tile-name { @@ -2216,9 +2217,7 @@ body { /* —— Tile Flyout Menu ————————————————————————————————————————————————————— */ .tile-options-btn { - position: absolute; - top: 6px; - right: 6px; + flex-shrink: 0; width: 24px; height: 24px; display: flex; @@ -2231,7 +2230,6 @@ body { font-size: 14px; line-height: 1; cursor: pointer; - z-index: 2; transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast); } diff --git a/muxplex/frontend/tests/test_app.mjs b/muxplex/frontend/tests/test_app.mjs index c52747e..55c619a 100644 --- a/muxplex/frontend/tests/test_app.mjs +++ b/muxplex/frontend/tests/test_app.mjs @@ -2614,30 +2614,36 @@ test('buildTileHTML escapes HTML in deviceName within device-badge', () => { // --- buildTileHTML device badge placement (task-3) --- -test('buildTileHTML places device-badge inside tile-meta span', () => { +test('buildTileHTML places device-badge inline in tile-header (before tile-meta)', () => { app._setServerSettings({ multi_device_enabled: true }); const session = { name: 'work', deviceName: 'Laptop', sessionKey: '::work', snapshot: '' }; const html = app.buildTileHTML(session, 0, false); - const tileMetaStart = html.indexOf(''); - // Note: finds the first after tileMetaStart, which is device-badge's closing tag - // (not tile-meta's own close), but the assertion still holds because device-badge - // opens and closes before tile-time within the tile-meta container. - const tileMetaEnd = html.indexOf('', tileMetaStart); - assert.ok(tileMetaStart !== -1, 'tile-meta span should exist'); + // Badge is now a sibling flex item inside tile-header, after tile-name, before tile-meta/button + const tileHeaderStart = html.indexOf('
'); + const tileHeaderEnd = html.indexOf('
', tileHeaderStart); + assert.ok(tileHeaderStart !== -1, 'tile-header div should exist'); const deviceBadgePos = html.indexOf('device-badge'); + assert.ok(deviceBadgePos !== -1, 'device-badge should exist in tile HTML'); assert.ok( - deviceBadgePos > tileMetaStart && deviceBadgePos < tileMetaEnd, - `device-badge should be inside tile-meta span (tile-meta starts at ${tileMetaStart}, device-badge at ${deviceBadgePos}, tile-meta closes at ${tileMetaEnd})` + deviceBadgePos > tileHeaderStart && deviceBadgePos < tileHeaderEnd, + `device-badge should be inside tile-header (tile-header starts at ${tileHeaderStart}, device-badge at ${deviceBadgePos}, tile-header closes at ${tileHeaderEnd})` ); app._setServerSettings(null); }); -test('buildTileHTML includes tile-meta-sep with middle dot when badge present', () => { +test('buildTileHTML badge and options-btn are siblings in tile-header when badge present', () => { + // Since the badge moved out of tile-meta into tile-header directly, tile-meta-sep is removed. + // The tile-options-btn is also now inside tile-header as a flex sibling. app._setServerSettings({ multi_device_enabled: true }); const session = { name: 'work', deviceName: 'Laptop', sessionKey: '::work', snapshot: '' }; const html = app.buildTileHTML(session, 0, false); - assert.ok(html.includes('tile-meta-sep'), 'should include tile-meta-sep element when badge is present'); - assert.ok(html.includes('\u00b7'), 'should include middle dot separator (\u00b7)'); + // Badge is directly in tile-header (not inside tile-meta) + assert.ok(html.includes('device-badge'), 'device-badge should exist in tile HTML'); + // tile-options-btn is also inside tile-header (before tile-body) + const headerStart = html.indexOf('
'); + const bodyStart = html.indexOf('
'); + const btnPos = html.indexOf('tile-options-btn'); + assert.ok(btnPos > headerStart && btnPos < bodyStart, 'tile-options-btn should be inside tile-header (before tile-body)'); app._setServerSettings(null); }); diff --git a/muxplex/tests/test_frontend_js.py b/muxplex/tests/test_frontend_js.py index 2361f9f..987209e 100644 --- a/muxplex/tests/test_frontend_js.py +++ b/muxplex/tests/test_frontend_js.py @@ -3948,10 +3948,22 @@ def test_open_flyout_menu_checks_mobile() -> None: def test_render_grid_has_add_sessions_affordance() -> None: - """renderGrid must include an 'Add Sessions' affordance when in a user view.""" + """app.js must have an 'Add Sessions' affordance for user views. + + The affordance moved from a tile in renderGrid to a header button (#add-sessions-btn) + managed by updateAddSessionsButton(). Either the old tile approach or the new header + button approach satisfies this requirement. + """ fn_body = _JS.split("function renderGrid")[1].split("\nfunction ")[0] - assert "add-sessions" in fn_body.lower() or "openAddSessionsPanel" in fn_body, ( - "renderGrid must render an 'Add Sessions' affordance for user views" + # Old approach: tile in the grid | New approach: header button via updateAddSessionsButton + has_affordance = ( + "add-sessions" in fn_body.lower() + or "openAddSessionsPanel" in fn_body + or "updateAddSessionsButton" in _JS + ) + assert has_affordance, ( + "app.js must have an 'Add Sessions' affordance for user views — " + "either as a tile in renderGrid or as a header button via updateAddSessionsButton" ) @@ -4244,3 +4256,235 @@ def test_kill_confirm_buttons_use_role_button() -> None: assert 'data-action="cancel" role="button"' in _JS, ( "Cancel button inside alertdialog must use role='button'" ) + + +# ============================================================ +# UX Refinements from live testing (6 issues) +# ============================================================ + +CSS_PATH = pathlib.Path(__file__).parent.parent / "frontend" / "style.css" +_CSS: str = CSS_PATH.read_text() + + +# — Issue 1: Sidebar dropdown "+ New View" —————————————————————————— + +def test_render_sidebar_view_dropdown_has_new_view_action() -> None: + """renderSidebarViewDropdown must include a '+ New View' action button.""" + match = re.search( + r"function renderSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "renderSidebarViewDropdown function not found" + body = match.group(1) + assert 'data-action="new-view"' in body, ( + 'renderSidebarViewDropdown must include a "+ New View" button with data-action="new-view"' + ) + + +def test_show_sidebar_new_view_input_function_exists() -> None: + """showSidebarNewViewInput function must exist in app.js.""" + assert "function showSidebarNewViewInput" in _JS, ( + "showSidebarNewViewInput must be defined in app.js" + ) + + +def test_bind_static_event_listeners_calls_show_sidebar_new_view_input() -> None: + """bindStaticEventListeners sidebar dropdown handler must call showSidebarNewViewInput.""" + match = re.search( + r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}", + _JS, + re.DOTALL, + ) + assert match, "bindStaticEventListeners function not found" + body = match.group(1) + assert "showSidebarNewViewInput" in body, ( + "bindStaticEventListeners must call showSidebarNewViewInput for sidebar new-view action" + ) + + +# — Issue 2: Remove shortcut numbers, add session counts ——————————————— + +def test_render_view_dropdown_no_shortcut_spans() -> None: + """renderViewDropdown must not include view-dropdown__shortcut spans (numbers removed).""" + match = re.search( + r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "renderViewDropdown function not found" + body = match.group(1) + assert "view-dropdown__shortcut" not in body, ( + "renderViewDropdown must not include view-dropdown__shortcut spans — shortcut numbers removed" + ) + + +def test_render_sidebar_view_dropdown_no_shortcut_spans() -> None: + """renderSidebarViewDropdown must not include view-dropdown__shortcut spans.""" + match = re.search( + r"function renderSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "renderSidebarViewDropdown function not found" + body = match.group(1) + assert "view-dropdown__shortcut" not in body, ( + "renderSidebarViewDropdown must not include view-dropdown__shortcut spans — shortcut numbers removed" + ) + + +def test_render_view_dropdown_shows_user_view_session_count() -> None: + """renderViewDropdown must show session count for user views.""" + match = re.search( + r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "renderViewDropdown function not found" + body = match.group(1) + assert "sessions.length" in body or "sessions || []).length" in body, ( + "renderViewDropdown must show session count for user views (view.sessions.length)" + ) + + +# — Issue 3: Empty new view opens Add Sessions panel ——————————————————— + +def test_show_new_view_input_calls_open_add_sessions_panel() -> None: + """showNewViewInput must call openAddSessionsPanel after creating a new view.""" + match = re.search( + r"function showNewViewInput\s*\(\s*\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "showNewViewInput function not found" + body = match.group(1) + assert "openAddSessionsPanel" in body, ( + "showNewViewInput must call openAddSessionsPanel() after creating a new view — " + "so the user immediately sees the Add Sessions panel for their empty view" + ) + + +def test_show_sidebar_new_view_input_calls_open_add_sessions_panel() -> None: + """showSidebarNewViewInput must call openAddSessionsPanel after creating a new view.""" + match = re.search( + r"function showSidebarNewViewInput\s*\(\s*\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "showSidebarNewViewInput function not found" + body = match.group(1) + assert "openAddSessionsPanel" in body, ( + "showSidebarNewViewInput must call openAddSessionsPanel() after creating a new view" + ) + + +# — Issue 4: Flyout submenu "+ New View" ——————————————————————————————— + +def test_open_flyout_submenu_has_new_view_option() -> None: + """_openFlyoutSubmenu must include a '+ New View' option.""" + match = re.search( + r"function _openFlyoutSubmenu\s*\(.*?\)\s*\{(.*?)\n\}", + _JS, + re.DOTALL, + ) + assert match, "_openFlyoutSubmenu function not found" + body = match.group(1) + assert "new-view-in-flyout" in body, ( + '_openFlyoutSubmenu must include a "+ New View" option with data-action="new-view-in-flyout"' + ) + + +def test_open_flyout_submenu_new_view_creates_and_switches() -> None: + """_openFlyoutSubmenu '+ New View' handler must create a view and switch to it.""" + match = re.search( + r"function _openFlyoutSubmenu\s*\(.*?\)\s*\{(.*?)\n\}", + _JS, + re.DOTALL, + ) + assert match, "_openFlyoutSubmenu function not found" + body = match.group(1) + assert "new-view-in-flyout" in body, ( + "'new-view-in-flyout' not found in _openFlyoutSubmenu" + ) + # switchView and PATCH must appear in the function body (the handler for new-view-in-flyout) + assert "switchView" in body, ( + '_openFlyoutSubmenu must call switchView — the "+ New View" handler needs to switch to the new view' + ) + assert "PATCH" in body or "api(" in body, ( + '_openFlyoutSubmenu must PATCH /api/settings to create the view' + ) + + +# — Issue 5: Add Sessions header button ———————————————————————————————— + +def test_update_add_sessions_button_function_exists() -> None: + """updateAddSessionsButton function must exist in app.js.""" + assert "function updateAddSessionsButton" in _JS, ( + "updateAddSessionsButton must be defined in app.js — " + "shows/hides the '+ Add' header button based on active view" + ) + + +def test_switch_view_calls_update_add_sessions_button() -> None: + """switchView must call updateAddSessionsButton to update header button visibility.""" + match = re.search( + r"function switchView\s*\(\w+\)\s*\{(.*?)(?=\nfunction |\nasync function |\n// )", + _JS, + re.DOTALL, + ) + assert match, "switchView function not found" + body = match.group(1) + assert "updateAddSessionsButton" in body, ( + "switchView must call updateAddSessionsButton() to show/hide the header Add button" + ) + + +def test_render_grid_no_muxplex_app_onclick() -> None: + """renderGrid must not use the broken window.MuxplexApp.openAddSessionsPanel onclick.""" + fn_body = _JS.split("function renderGrid")[1].split("\nfunction ")[0] + assert "MuxplexApp.openAddSessionsPanel" not in fn_body, ( + "renderGrid must not use onclick='window.MuxplexApp.openAddSessionsPanel()' — " + "this was broken; the Add Sessions entry point moved to the header button" + ) + + +def test_bind_static_event_listeners_binds_add_sessions_btn() -> None: + """bindStaticEventListeners must bind #add-sessions-btn click to openAddSessionsPanel.""" + match = re.search( + r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}", + _JS, + re.DOTALL, + ) + assert match, "bindStaticEventListeners function not found" + body = match.group(1) + assert "add-sessions-btn" in body, ( + "bindStaticEventListeners must bind #add-sessions-btn click to openAddSessionsPanel" + ) + + +# — Issue 6: Tile header flexbox layout ———————————————————————————————— + +def test_build_tile_html_options_btn_inside_tile_header() -> None: + """buildTileHTML must render tile-options-btn inside tile-header (before tile-body).""" + fn_body = _JS.split("function buildTileHTML")[1].split("\nfunction ")[0] + tile_opts_pos = fn_body.find("tile-options-btn") + tile_body_pos = fn_body.find("tile-body") + assert tile_opts_pos >= 0, "tile-options-btn must appear in buildTileHTML" + assert tile_body_pos >= 0, "tile-body must appear in buildTileHTML" + assert tile_opts_pos < tile_body_pos, ( + "tile-options-btn must appear before tile-body in the HTML string — " + "it must be inside tile-header (as an inline flex item), not positioned after tile-body" + ) + + +def test_tile_options_btn_css_not_absolute() -> None: + """CSS .tile-options-btn must not use position:absolute — prevent badge/button overlap.""" + import re as _re + + match = _re.search(r"\.tile-options-btn\s*\{([^}]*)\}", _CSS, _re.DOTALL) + assert match, ".tile-options-btn CSS rule not found" + rule_body = match.group(1) + assert "position: absolute" not in rule_body and "position:absolute" not in rule_body, ( + ".tile-options-btn must not use position:absolute — " + "it should be an inline flex item inside tile-header to prevent device badge overlap" + )