From 6144f3995563f87a9180d310cfb5fce3f57e9eaf Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Sun, 29 Mar 2026 14:42:30 -0700 Subject: [PATCH] feat: hover preview popover on dashboard tiles Desktop only (guarded by ontouchstart check). On mouseenter of a session tile, shows a floating popover with the full tmux snapshot (all lines, not the cropped 20-line preview). Positioned right of tile if space, else left. 100ms delay prevents flicker on brush-over. Data from _currentSessions (already in memory, zero API calls). Also calls hidePreview() in openSession() so the popover is cleaned up when switching to fullscreen view. --- muxplex/frontend/app.js | 78 +++++++++++++++++++++++++++++ muxplex/frontend/style.css | 29 +++++++++++ muxplex/frontend/tests/test_app.mjs | 12 +++++ muxplex/tests/test_frontend_css.py | 18 +++++++ 4 files changed, 137 insertions(+) diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index 18a9f42..8d0f7b7 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -125,6 +125,8 @@ let _pollingTimer; let _heartbeatTimer; let _notificationPermission = 'default'; let _pollFailCount = 0; +let _previewPopover = null; +let _previewTimer = null; // ─── DOM helpers ────────────────────────────────────────────────────────────── function $(id) { @@ -509,6 +511,62 @@ function renderGrid(sessions) { } } +// --------------------------------------------------------------------------- +// Hover preview popover (desktop only — no hover on touch devices) +// --------------------------------------------------------------------------- + +function showPreview(tile) { + var name = tile.dataset.session; + if (!name || !_currentSessions) return; + + var session = _currentSessions.find(function (s) { return s.name === name; }); + if (!session || !session.snapshot) return; + + hidePreview(); + + var popover = document.createElement('div'); + popover.className = 'preview-popover'; + var pre = document.createElement('pre'); + pre.textContent = session.snapshot; + popover.appendChild(pre); + document.body.appendChild(popover); + + // Position: right of tile if room, else left + var rect = tile.getBoundingClientRect(); + var popW = popover.offsetWidth; + var popH = popover.offsetHeight; + var left, top; + + if (rect.right + popW + 12 < window.innerWidth) { + left = rect.right + 8; + } else { + left = rect.left - popW - 8; + } + + // Vertically: align top with tile, clamp to viewport + top = rect.top; + if (top + popH > window.innerHeight - 16) { + top = window.innerHeight - popH - 16; + } + if (top < 8) top = 8; + + popover.style.left = left + 'px'; + popover.style.top = top + 'px'; + + _previewPopover = popover; +} + +function hidePreview() { + if (_previewTimer) { + clearTimeout(_previewTimer); + _previewTimer = null; + } + if (_previewPopover) { + _previewPopover.remove(); + _previewPopover = null; + } +} + // ─── Notification permission ──────────────────────────────────────────────── /** @@ -627,6 +685,7 @@ function updatePillBell() { * @returns {Promise} */ async function openSession(name, opts = {}) { + hidePreview(); _viewingSession = name; _viewMode = 'fullscreen'; @@ -1010,6 +1069,22 @@ function bindStaticEventListeners() { document.addEventListener('keydown', handleGlobalKeydown); on($('session-pill'), 'click', openBottomSheet); on($('sheet-backdrop'), 'click', closeBottomSheet); + + // Hover preview — delegated on grid container (tiles are re-rendered each poll) + var gridEl = $('session-grid'); + if (gridEl && !('ontouchstart' in window)) { // desktop only + gridEl.addEventListener('mouseenter', function (e) { + var tile = e.target.closest('.session-tile'); + if (!tile) return; + _previewTimer = setTimeout(function () { showPreview(tile); }, 100); + }, true); // useCapture: true for delegation with mouseenter + + gridEl.addEventListener('mouseleave', function (e) { + var tile = e.target.closest('.session-tile'); + if (!tile) return; + hidePreview(); + }, true); + } } // ─── Test-only helpers ──────────────────────────────────────────────────────── @@ -1117,6 +1192,9 @@ if (typeof module !== 'undefined' && module.exports) { closeBottomSheet, renderSheetList, updateSessionPill, + // Hover preview popover + showPreview, + hidePreview, // Test-only helpers _setCurrentSessions, _setPaletteFilteredSessions, diff --git a/muxplex/frontend/style.css b/muxplex/frontend/style.css index 24cc1d0..a34351e 100644 --- a/muxplex/frontend/style.css +++ b/muxplex/frontend/style.css @@ -959,6 +959,35 @@ body { display: block; } +/* ============================================================ + Hover preview popover (desktop dashboard) + ============================================================ */ + +.preview-popover { + position: fixed; + z-index: 500; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 6px; + padding: 10px 12px; + min-width: 480px; + max-width: 640px; + max-height: 80vh; + overflow-y: auto; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + pointer-events: none; /* don't interfere with mouse leaving tile */ +} + +.preview-popover pre { + margin: 0; + font-family: var(--font-mono); + font-size: 11px; + line-height: 1.4; + color: var(--text-muted); + white-space: pre; + overflow-x: hidden; +} + /* ============================================================ Responsive overlay sidebar at <960px ============================================================ */ diff --git a/muxplex/frontend/tests/test_app.mjs b/muxplex/frontend/tests/test_app.mjs index 6cc1239..d6a5917 100644 --- a/muxplex/frontend/tests/test_app.mjs +++ b/muxplex/frontend/tests/test_app.mjs @@ -2050,4 +2050,16 @@ test('openSession mounts terminal AFTER connect POST, not inside animation timer '_openTerminal must appear AFTER the /connect POST in the source'); }); +// --- Hover preview popover --- + +test('app.js has hover preview popover with desktop-only guard', () => { + const source = fs.readFileSync( + new URL('../app.js', import.meta.url), 'utf8' + ); + assert.ok(source.includes('preview-popover'), 'must create popover with preview-popover class'); + assert.ok(source.includes('ontouchstart'), 'must guard against touch devices (desktop only)'); + assert.ok(source.includes('session.snapshot'), 'must use full snapshot text (not lastLines)'); + assert.ok(source.includes('hidePreview'), 'must have cleanup on mouseleave'); +}); + diff --git a/muxplex/tests/test_frontend_css.py b/muxplex/tests/test_frontend_css.py index f5aba74..03c4cdb 100644 --- a/muxplex/tests/test_frontend_css.py +++ b/muxplex/tests/test_frontend_css.py @@ -613,3 +613,21 @@ def test_sidebar_item_has_flex_shrink_zero(): assert "flex-shrink: 0" in block, ( ".sidebar-item must have flex-shrink:0 to prevent compression" ) + + +# ============================================================ +# Hover preview popover (desktop dashboard) +# ============================================================ + + +def test_preview_popover_css_exists(): + """Preview popover must have CSS rules with position: fixed.""" + css = read_css() + assert ".preview-popover" in css, "must have .preview-popover CSS class" + popover_idx = css.index(".preview-popover") + block_start = css.index("{", popover_idx) + block_end = css.index("}", block_start) + block = css[block_start:block_end] + assert "position: fixed" in block or "position:fixed" in block, ( + ".preview-popover must use position: fixed" + )