fix: UX refinements from live testing — sidebar new view, session counts, empty view flow, header add button, tile layout
Issue 1: Add '+ New View' to sidebar dropdown - Add '+ New View' action button (data-action='new-view') to renderSidebarViewDropdown() - Add showSidebarNewViewInput() function targeting #sidebar-view-dropdown-menu - Wire sidebar dropdown click handler to call showSidebarNewViewInput() - Also wire 'Manage Views' action in sidebar dropdown Issue 2: Remove keyboard shortcut numbers, add session counts - Remove <span class='view-dropdown__shortcut'> elements from both render functions - Show count of non-hidden sessions next to 'All Sessions' - Show each user view's session count (view.sessions.length) in parentheses - Keep keyboard shortcuts functional (backtick, 1-9) — just remove visual display Issue 3: Empty new view opens Add Sessions panel - Call openAddSessionsPanel() after switchView() in showNewViewInput() Enter handler - Same in showSidebarNewViewInput() — user immediately sees panel to populate new view Issue 4: Add '+ New View' option to tile flyout submenu - Add separator + '+ New View' button at bottom of _openFlyoutSubmenu() view list - Click handler: close flyout, prompt for name, validate, create view with session, switchView - Remove early exit when views.length === 0 (show New View option even with no views) Issue 5: Fix openAddSessionsPanel entry point + move to header - Remove broken add-sessions-tile with onclick='window.MuxplexApp.openAddSessionsPanel()' - Add <button id='add-sessions-btn'> to header-actions in index.html (hidden by default) - Add updateAddSessionsButton() to show/hide button based on active view - Wire button click to openAddSessionsPanel() in bindStaticEventListeners() - Call updateAddSessionsButton() from switchView() and DOMContentLoaded Issue 6: Fix device badge overlapping flyout icon - Move tile-options-btn inside tile-header as last flex item (was absolute sibling of article) - Move device badge out of tile-meta, now a direct flex sibling between tile-name and tile-meta - Remove tile-meta-sep separator (badge no longer in meta) - Add gap: 4px to .tile-header CSS - Remove position:absolute from .tile-options-btn CSS — flex-shrink:0 instead Tests: Add 17 new static analysis tests in test_frontend_js.py for all 6 issues. Update test_app.mjs tile tests to match new tile-header layout. All 318 frontend tests + 224 backend tests pass.
This commit is contained in:
+209
-31
@@ -539,10 +539,11 @@ function buildTileHTML(session, index, mobile) {
|
|||||||
`<article class="${classes}" data-session="${escapedName}" data-session-key="${escapeHtml(session.sessionKey || name)}"${remoteIdAttr} tabindex="0" role="listitem" aria-label="${escapedName}">` +
|
`<article class="${classes}" data-session="${escapedName}" data-session-key="${escapeHtml(session.sessionKey || name)}"${remoteIdAttr} tabindex="0" role="listitem" aria-label="${escapedName}">` +
|
||||||
`<div class="tile-header">` +
|
`<div class="tile-header">` +
|
||||||
`<span class="tile-name">${escapeHtml(name)}</span>` +
|
`<span class="tile-name">${escapeHtml(name)}</span>` +
|
||||||
`<span class="tile-meta">${badgeHtml}${badgeHtml ? `<span class="tile-meta-sep">\xb7</span>` : ''}<span class="tile-time">${escapeHtml(timeStr)}</span></span>` +
|
`${badgeHtml}` +
|
||||||
|
`<span class="tile-meta">${escapeHtml(timeStr)}</span>` +
|
||||||
|
`<button class="tile-options-btn" data-session="${escapedName}" aria-label="Session options" aria-haspopup="true">⋮</button>` +
|
||||||
`</div>` +
|
`</div>` +
|
||||||
`<div class="tile-body"><pre>${ansiToHtml(lastLines)}</pre></div>` +
|
`<div class="tile-body"><pre>${ansiToHtml(lastLines)}</pre></div>` +
|
||||||
`<button class="tile-options-btn" data-session="${escapedName}" aria-label="Session options" aria-haspopup="true">⋮</button>` +
|
|
||||||
`</article>`
|
`</article>`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -886,24 +887,29 @@ function renderViewDropdown() {
|
|||||||
|
|
||||||
var html = '';
|
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' : '';
|
var allActive = _activeView === 'all' ? ' view-dropdown__item--active' : '';
|
||||||
html += '<button class="view-dropdown__item' + allActive + '" role="menuitem" data-view="all"><span class="view-dropdown__shortcut">1</span> All Sessions</button>';
|
html += '<button class="view-dropdown__item' + allActive + '" role="menuitem" data-view="all">All Sessions <span class="view-dropdown__count">' + allCount + '</span></button>';
|
||||||
|
|
||||||
// — User views (shortcuts 2–8)
|
// — User views
|
||||||
if (views.length > 0) {
|
if (views.length > 0) {
|
||||||
html += '<div class="view-dropdown__separator"></div>';
|
html += '<div class="view-dropdown__separator"></div>';
|
||||||
for (var i = 0; i < views.length && i < 7; i++) {
|
for (var i = 0; i < views.length && i < 7; i++) {
|
||||||
var v = views[i];
|
var v = views[i];
|
||||||
var vActive = _activeView === v.name ? ' view-dropdown__item--active' : '';
|
var vActive = _activeView === v.name ? ' view-dropdown__item--active' : '';
|
||||||
html += '<button class="view-dropdown__item' + vActive + '" role="menuitem" data-view="' + escapeHtml(v.name) + '"><span class="view-dropdown__shortcut">' + (i + 2) + '</span> ' + escapeHtml(v.name) + '</button>';
|
html += '<button class="view-dropdown__item' + vActive + '" role="menuitem" data-view="' + escapeHtml(v.name) + '">' + escapeHtml(v.name) + ' <span class="view-dropdown__count">' + (v.sessions || []).length + '</span></button>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// — Hidden (N) (always last system view, shortcut 9)
|
// — Hidden (N) (always last system view)
|
||||||
html += '<div class="view-dropdown__separator"></div>';
|
html += '<div class="view-dropdown__separator"></div>';
|
||||||
var hiddenActive = _activeView === 'hidden' ? ' view-dropdown__item--active' : '';
|
var hiddenActive = _activeView === 'hidden' ? ' view-dropdown__item--active' : '';
|
||||||
html += '<button class="view-dropdown__item' + hiddenActive + '" role="menuitem" data-view="hidden"><span class="view-dropdown__shortcut">9</span> Hidden <span class="view-dropdown__count">' + hiddenCount + '</span></button>';
|
html += '<button class="view-dropdown__item' + hiddenActive + '" role="menuitem" data-view="hidden">Hidden <span class="view-dropdown__count">' + hiddenCount + '</span></button>';
|
||||||
|
|
||||||
// — Actions
|
// — Actions
|
||||||
html += '<div class="view-dropdown__separator"></div>';
|
html += '<div class="view-dropdown__separator"></div>';
|
||||||
@@ -973,9 +979,14 @@ function renderSidebarViewDropdown() {
|
|||||||
|
|
||||||
var html = '';
|
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' : '';
|
var allActive = _activeView === 'all' ? ' view-dropdown__item--active' : '';
|
||||||
html += '<button class="view-dropdown__item' + allActive + '" role="menuitem" data-view="all"><span class="view-dropdown__shortcut">1</span> All Sessions</button>';
|
html += '<button class="view-dropdown__item' + allActive + '" role="menuitem" data-view="all">All Sessions <span class="view-dropdown__count">' + sbAllCount + '</span></button>';
|
||||||
|
|
||||||
// — User views
|
// — User views
|
||||||
if (views.length > 0) {
|
if (views.length > 0) {
|
||||||
@@ -983,14 +994,19 @@ function renderSidebarViewDropdown() {
|
|||||||
for (var i = 0; i < views.length && i < 7; i++) {
|
for (var i = 0; i < views.length && i < 7; i++) {
|
||||||
var v = views[i];
|
var v = views[i];
|
||||||
var vActive = _activeView === v.name ? ' view-dropdown__item--active' : '';
|
var vActive = _activeView === v.name ? ' view-dropdown__item--active' : '';
|
||||||
html += '<button class="view-dropdown__item' + vActive + '" role="menuitem" data-view="' + escapeHtml(v.name) + '"><span class="view-dropdown__shortcut">' + (i + 2) + '</span> ' + escapeHtml(v.name) + '</button>';
|
html += '<button class="view-dropdown__item' + vActive + '" role="menuitem" data-view="' + escapeHtml(v.name) + '">' + escapeHtml(v.name) + ' <span class="view-dropdown__count">' + (v.sessions || []).length + '</span></button>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// — Hidden (N) (always last system view)
|
// — Hidden (N) (always last system view)
|
||||||
html += '<div class="view-dropdown__separator"></div>';
|
html += '<div class="view-dropdown__separator"></div>';
|
||||||
var hiddenActive = _activeView === 'hidden' ? ' view-dropdown__item--active' : '';
|
var hiddenActive = _activeView === 'hidden' ? ' view-dropdown__item--active' : '';
|
||||||
html += '<button class="view-dropdown__item' + hiddenActive + '" role="menuitem" data-view="hidden"><span class="view-dropdown__shortcut">9</span> Hidden <span class="view-dropdown__count">' + hiddenCount + '</span></button>';
|
html += '<button class="view-dropdown__item' + hiddenActive + '" role="menuitem" data-view="hidden">Hidden <span class="view-dropdown__count">' + hiddenCount + '</span></button>';
|
||||||
|
|
||||||
|
// — Actions: new view + manage views
|
||||||
|
html += '<div class="view-dropdown__separator"></div>';
|
||||||
|
html += '<button class="view-dropdown__item view-dropdown__action" role="menuitem" data-action="new-view">+ New View</button>';
|
||||||
|
html += '<button class="view-dropdown__item view-dropdown__action" role="menuitem" data-action="manage-views">Manage Views\u2026</button>';
|
||||||
|
|
||||||
menu.innerHTML = html;
|
menu.innerHTML = html;
|
||||||
}
|
}
|
||||||
@@ -1084,6 +1100,7 @@ function showNewViewInput() {
|
|||||||
.then(function() {
|
.then(function() {
|
||||||
if (_serverSettings) _serverSettings.views = updatedViews;
|
if (_serverSettings) _serverSettings.views = updatedViews;
|
||||||
switchView(name);
|
switchView(name);
|
||||||
|
openAddSessionsPanel();
|
||||||
})
|
})
|
||||||
.catch(function() {
|
.catch(function() {
|
||||||
showToast('Failed to create view');
|
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,
|
* Save updated views array via PATCH /api/settings, update _serverSettings,
|
||||||
* re-render the views settings tab, and re-render the view dropdown.
|
* re-render the views settings tab, and re-render the view dropdown.
|
||||||
@@ -1372,6 +1474,8 @@ function switchView(viewName) {
|
|||||||
sidebarLabel.textContent = viewName;
|
sidebarLabel.textContent = viewName;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Show/hide the header Add Sessions button
|
||||||
|
updateAddSessionsButton();
|
||||||
// Persist active view — fire and forget
|
// Persist active view — fire and forget
|
||||||
api('PATCH', '/api/state', { active_view: viewName }).catch(function() {});
|
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 === 'unreachable') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Offline', 'offline');
|
||||||
else if (session.status === 'empty') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'No sessions', 'empty');
|
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 += '<button class="add-sessions-tile" onclick="window.MuxplexApp.openAddSessionsPanel()" aria-label="Add sessions to this view">';
|
|
||||||
html += '<span class="add-sessions-tile__icon">+</span>';
|
|
||||||
html += '<span class="add-sessions-tile__label">Add Sessions</span>';
|
|
||||||
html += '</button>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (grid) grid.innerHTML = html + statusTilesHtml;
|
if (grid) grid.innerHTML = html + statusTilesHtml;
|
||||||
|
|
||||||
// Clear filter bar (filtered mode removed; bar is a no-op for flat/grouped)
|
// 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) || [];
|
var views = (_serverSettings && _serverSettings.views) || [];
|
||||||
if (views.length === 0) {
|
|
||||||
showToast('No user views. Create one from the header dropdown.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var sessionKey = _flyoutSessionKey;
|
var sessionKey = _flyoutSessionKey;
|
||||||
// When in a user view, filter it out — the user already has "Remove from [ViewName]" for it
|
// 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 += escapeHtml(v.name);
|
||||||
html += '</button>';
|
html += '</button>';
|
||||||
}
|
}
|
||||||
|
// — Always show "+ New View" option at the bottom
|
||||||
|
if (views.length > 0) {
|
||||||
|
html += '<div class="flyout-menu__separator" role="separator"></div>';
|
||||||
|
}
|
||||||
|
html += '<button class="flyout-submenu__item" role="menuitem" data-action="new-view-in-flyout">+ New View</button>';
|
||||||
|
|
||||||
var submenu = document.createElement('div');
|
var submenu = document.createElement('div');
|
||||||
submenu.className = 'flyout-submenu';
|
submenu.className = 'flyout-submenu';
|
||||||
@@ -1971,6 +2061,50 @@ function _openFlyoutSubmenu(triggerItem, unhideFirst) {
|
|||||||
|
|
||||||
// Click handler — toggle view membership via PATCH /api/settings
|
// Click handler — toggle view membership via PATCH /api/settings
|
||||||
submenu.addEventListener('click', function(e) {
|
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]');
|
var btn = e.target.closest('[data-view-index]');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
var idx = parseInt(btn.dataset.viewIndex, 10);
|
var idx = parseInt(btn.dataset.viewIndex, 10);
|
||||||
@@ -2252,6 +2386,28 @@ function closeAddSessionsPanel() {
|
|||||||
if (panel) panel.classList.add('hidden');
|
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.
|
* Render the session list inside the Add Sessions panel.
|
||||||
* Shows all sessions NOT currently in the active view.
|
* Shows all sessions NOT currently in the active view.
|
||||||
@@ -3763,10 +3919,28 @@ function bindStaticEventListeners() {
|
|||||||
// Close sidebar dropdown after selection
|
// Close sidebar dropdown after selection
|
||||||
sidebarViewMenu.classList.add('hidden');
|
sidebarViewMenu.classList.add('hidden');
|
||||||
if (sidebarViewTrigger) sidebarViewTrigger.setAttribute('aria-expanded', 'false');
|
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
|
// Click-outside closes the header view dropdown
|
||||||
document.addEventListener('click', function(e) {
|
document.addEventListener('click', function(e) {
|
||||||
var dropdown = $('view-dropdown-menu');
|
var dropdown = $('view-dropdown-menu');
|
||||||
@@ -4104,6 +4278,7 @@ document.addEventListener('DOMContentLoaded', async function() {
|
|||||||
updatePageTitle();
|
updatePageTitle();
|
||||||
startHeartbeat();
|
startHeartbeat();
|
||||||
bindStaticEventListeners();
|
bindStaticEventListeners();
|
||||||
|
updateAddSessionsButton();
|
||||||
})
|
})
|
||||||
.catch(function(err) {
|
.catch(function(err) {
|
||||||
console.error('[init] restoreState failed, retrying in 5s:', err);
|
console.error('[init] restoreState failed, retrying in 5s:', err);
|
||||||
@@ -4200,6 +4375,9 @@ if (typeof module !== 'undefined' && module.exports) {
|
|||||||
// Sidebar view dropdown
|
// Sidebar view dropdown
|
||||||
renderSidebarViewDropdown,
|
renderSidebarViewDropdown,
|
||||||
toggleSidebarViewDropdown,
|
toggleSidebarViewDropdown,
|
||||||
|
showSidebarNewViewInput,
|
||||||
|
// Add Sessions header button
|
||||||
|
updateAddSessionsButton,
|
||||||
// Manage Views settings tab
|
// Manage Views settings tab
|
||||||
renderViewsSettingsTab,
|
renderViewsSettingsTab,
|
||||||
_saveViewsAndRerender,
|
_saveViewsAndRerender,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
<div id="view-dropdown-menu" class="view-dropdown__menu hidden" role="menu" aria-label="Switch view"></div>
|
<div id="view-dropdown-menu" class="view-dropdown__menu hidden" role="menu" aria-label="Switch view"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
|
<button id="add-sessions-btn" class="header-btn hidden" aria-label="Add sessions to view">+ Add</button>
|
||||||
<button id="new-session-btn" class="header-btn" aria-label="New session">+</button>
|
<button id="new-session-btn" class="header-btn" aria-label="New session">+</button>
|
||||||
<button id="view-mode-btn" class="header-btn" aria-label="Toggle view mode" title="View: auto">▦</button>
|
<button id="view-mode-btn" class="header-btn" aria-label="Toggle view mode" title="View: auto">▦</button>
|
||||||
<button id="settings-btn" class="header-btn" aria-label="Settings">⚙</button>
|
<button id="settings-btn" class="header-btn" aria-label="Settings">⚙</button>
|
||||||
|
|||||||
@@ -208,6 +208,7 @@ body {
|
|||||||
border-bottom: 1px solid var(--border-subtle);
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile-name {
|
.tile-name {
|
||||||
@@ -2216,9 +2217,7 @@ body {
|
|||||||
/* —— Tile Flyout Menu ————————————————————————————————————————————————————— */
|
/* —— Tile Flyout Menu ————————————————————————————————————————————————————— */
|
||||||
|
|
||||||
.tile-options-btn {
|
.tile-options-btn {
|
||||||
position: absolute;
|
flex-shrink: 0;
|
||||||
top: 6px;
|
|
||||||
right: 6px;
|
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -2231,7 +2230,6 @@ body {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
z-index: 2;
|
|
||||||
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
|
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2614,30 +2614,36 @@ test('buildTileHTML escapes HTML in deviceName within device-badge', () => {
|
|||||||
|
|
||||||
// --- buildTileHTML device badge placement (task-3) ---
|
// --- 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 });
|
app._setServerSettings({ multi_device_enabled: true });
|
||||||
const session = { name: 'work', deviceName: 'Laptop', sessionKey: '::work', snapshot: '' };
|
const session = { name: 'work', deviceName: 'Laptop', sessionKey: '::work', snapshot: '' };
|
||||||
const html = app.buildTileHTML(session, 0, false);
|
const html = app.buildTileHTML(session, 0, false);
|
||||||
const tileMetaStart = html.indexOf('<span class="tile-meta">');
|
// Badge is now a sibling flex item inside tile-header, after tile-name, before tile-meta/button
|
||||||
// Note: finds the first </span> after tileMetaStart, which is device-badge's closing tag
|
const tileHeaderStart = html.indexOf('<div class="tile-header">');
|
||||||
// (not tile-meta's own close), but the assertion still holds because device-badge
|
const tileHeaderEnd = html.indexOf('</div>', tileHeaderStart);
|
||||||
// opens and closes before tile-time within the tile-meta container.
|
assert.ok(tileHeaderStart !== -1, 'tile-header div should exist');
|
||||||
const tileMetaEnd = html.indexOf('</span>', tileMetaStart);
|
|
||||||
assert.ok(tileMetaStart !== -1, 'tile-meta span should exist');
|
|
||||||
const deviceBadgePos = html.indexOf('device-badge');
|
const deviceBadgePos = html.indexOf('device-badge');
|
||||||
|
assert.ok(deviceBadgePos !== -1, 'device-badge should exist in tile HTML');
|
||||||
assert.ok(
|
assert.ok(
|
||||||
deviceBadgePos > tileMetaStart && deviceBadgePos < tileMetaEnd,
|
deviceBadgePos > tileHeaderStart && deviceBadgePos < tileHeaderEnd,
|
||||||
`device-badge should be inside tile-meta span (tile-meta starts at ${tileMetaStart}, device-badge at ${deviceBadgePos}, tile-meta closes at ${tileMetaEnd})`
|
`device-badge should be inside tile-header (tile-header starts at ${tileHeaderStart}, device-badge at ${deviceBadgePos}, tile-header closes at ${tileHeaderEnd})`
|
||||||
);
|
);
|
||||||
app._setServerSettings(null);
|
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 });
|
app._setServerSettings({ multi_device_enabled: true });
|
||||||
const session = { name: 'work', deviceName: 'Laptop', sessionKey: '::work', snapshot: '' };
|
const session = { name: 'work', deviceName: 'Laptop', sessionKey: '::work', snapshot: '' };
|
||||||
const html = app.buildTileHTML(session, 0, false);
|
const html = app.buildTileHTML(session, 0, false);
|
||||||
assert.ok(html.includes('tile-meta-sep'), 'should include tile-meta-sep element when badge is present');
|
// Badge is directly in tile-header (not inside tile-meta)
|
||||||
assert.ok(html.includes('\u00b7'), 'should include middle dot separator (\u00b7)');
|
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('<div class="tile-header">');
|
||||||
|
const bodyStart = html.indexOf('<div class="tile-body">');
|
||||||
|
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);
|
app._setServerSettings(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3948,10 +3948,22 @@ def test_open_flyout_menu_checks_mobile() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_render_grid_has_add_sessions_affordance() -> 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]
|
fn_body = _JS.split("function renderGrid")[1].split("\nfunction ")[0]
|
||||||
assert "add-sessions" in fn_body.lower() or "openAddSessionsPanel" in fn_body, (
|
# Old approach: tile in the grid | New approach: header button via updateAddSessionsButton
|
||||||
"renderGrid must render an 'Add Sessions' affordance for user views"
|
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, (
|
assert 'data-action="cancel" role="button"' in _JS, (
|
||||||
"Cancel button inside alertdialog must use role='button'"
|
"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"
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user