feat: hover preview as full-window overlay with text wrap + click-to-navigate
Replace side-positioned popover with a viewport-covering overlay (5vh/5vw margins). pre-wrap + word-break wraps terminal text to fit any viewport. pointer-events:none on overlay lets mouseleave fire on the original tile. Click anywhere while preview is active navigates to that session. Removes repositionPreview() — no JS positioning needed.
This commit is contained in:
+17
-62
@@ -523,30 +523,37 @@ function renderGrid(sessions) {
|
|||||||
// Hover preview popover (desktop only — no hover on touch devices)
|
// Hover preview popover (desktop only — no hover on touch devices)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Click handler registered while preview is showing — navigates to the previewed session
|
||||||
|
function _previewClickHandler(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
var name = _previewSessionName;
|
||||||
|
hidePreview();
|
||||||
|
if (name) openSession(name);
|
||||||
|
}
|
||||||
|
|
||||||
function showPreview(name) {
|
function showPreview(name) {
|
||||||
if (!name || !_currentSessions) return;
|
if (!name || !_currentSessions) return;
|
||||||
var session = _currentSessions.find(function (s) { return s.name === name; });
|
var session = _currentSessions.find(function (s) { return s.name === name; });
|
||||||
if (!session || !session.snapshot) return;
|
if (!session || !session.snapshot) return;
|
||||||
|
|
||||||
// If already showing this session's preview, just update content + position
|
// If already showing this session, just update content
|
||||||
if (_previewPopover && _previewSessionName === name) {
|
if (_previewPopover && _previewSessionName === name) {
|
||||||
var pre = _previewPopover.querySelector('pre');
|
var pre = _previewPopover.querySelector('pre');
|
||||||
if (pre) pre.textContent = session.snapshot;
|
if (pre) pre.textContent = session.snapshot;
|
||||||
repositionPreview();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// New preview — clean slate (DOM only, no render trigger)
|
|
||||||
hidePreviewDOM();
|
hidePreviewDOM();
|
||||||
_previewSessionName = name;
|
_previewSessionName = name;
|
||||||
|
|
||||||
// Dimmer layer — behind popover, above everything else
|
// Dimmer
|
||||||
var dimmer = document.createElement('div');
|
var dimmer = document.createElement('div');
|
||||||
dimmer.className = 'preview-dimmer';
|
dimmer.className = 'preview-dimmer';
|
||||||
document.body.appendChild(dimmer);
|
document.body.appendChild(dimmer);
|
||||||
_previewDimmer = dimmer;
|
_previewDimmer = dimmer;
|
||||||
|
|
||||||
// Popover
|
// Full-window overlay
|
||||||
var popover = document.createElement('div');
|
var popover = document.createElement('div');
|
||||||
popover.className = 'preview-popover';
|
popover.className = 'preview-popover';
|
||||||
var pre = document.createElement('pre');
|
var pre = document.createElement('pre');
|
||||||
@@ -555,67 +562,14 @@ function showPreview(name) {
|
|||||||
document.body.appendChild(popover);
|
document.body.appendChild(popover);
|
||||||
_previewPopover = popover;
|
_previewPopover = popover;
|
||||||
|
|
||||||
// Lift the tile/item above the dimmer + position
|
// Lift the original tile above dimmer
|
||||||
liftHoveredTile();
|
liftHoveredTile();
|
||||||
repositionPreview();
|
|
||||||
|
|
||||||
// Auto-scroll to bottom — the prompt/cursor area is the valuable part
|
// Auto-scroll to bottom (prompt area)
|
||||||
popover.scrollTop = popover.scrollHeight;
|
popover.scrollTop = popover.scrollHeight;
|
||||||
}
|
|
||||||
|
|
||||||
function repositionPreview() {
|
// Click anywhere navigates to previewed session
|
||||||
if (!_previewPopover || !_previewSessionName) return;
|
document.addEventListener('click', _previewClickHandler, true);
|
||||||
|
|
||||||
// Re-query DOM for the current element (may have been rebuilt by a render cycle)
|
|
||||||
var el = document.querySelector(
|
|
||||||
'.session-tile[data-session="' + _previewSessionName + '"], ' +
|
|
||||||
'.sidebar-item[data-session="' + _previewSessionName + '"]'
|
|
||||||
);
|
|
||||||
if (!el) return;
|
|
||||||
|
|
||||||
var rect = el.getBoundingClientRect();
|
|
||||||
var popover = _previewPopover;
|
|
||||||
var left;
|
|
||||||
|
|
||||||
if (rect.right + 288 < window.innerWidth) {
|
|
||||||
left = rect.right + 8;
|
|
||||||
} else if (rect.left > 288) {
|
|
||||||
left = rect.left - 8; // will be adjusted after width is set
|
|
||||||
} else {
|
|
||||||
// Fallback: center horizontally
|
|
||||||
left = Math.max(8, (window.innerWidth - 400) / 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate available width for the popover
|
|
||||||
var availW;
|
|
||||||
if (left >= rect.right) {
|
|
||||||
availW = window.innerWidth - left - 16;
|
|
||||||
} else if (left < rect.left) {
|
|
||||||
availW = rect.left - 16;
|
|
||||||
} else {
|
|
||||||
availW = window.innerWidth - 32;
|
|
||||||
}
|
|
||||||
// Clamp between 280px (minimum readable) and 640px (comfortable max)
|
|
||||||
var popWidth = Math.min(Math.max(availW, 280), 640);
|
|
||||||
popover.style.width = popWidth + 'px';
|
|
||||||
|
|
||||||
// If positioned to the left, adjust left edge now that we know width
|
|
||||||
var fallback = Math.max(8, (window.innerWidth - 400) / 2);
|
|
||||||
if (left < rect.left && left !== fallback) {
|
|
||||||
left = rect.left - popWidth - 8;
|
|
||||||
if (left < 8) left = 8;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vertically: align top with element, clamp to viewport
|
|
||||||
var popH = popover.offsetHeight;
|
|
||||||
var 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';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function liftHoveredTile() {
|
function liftHoveredTile() {
|
||||||
@@ -636,6 +590,7 @@ function liftHoveredTile() {
|
|||||||
|
|
||||||
// hidePreviewDOM: removes the visual elements only (no render trigger)
|
// hidePreviewDOM: removes the visual elements only (no render trigger)
|
||||||
function hidePreviewDOM() {
|
function hidePreviewDOM() {
|
||||||
|
document.removeEventListener('click', _previewClickHandler, true);
|
||||||
if (_previewPopover) {
|
if (_previewPopover) {
|
||||||
_previewPopover.remove();
|
_previewPopover.remove();
|
||||||
_previewPopover = null;
|
_previewPopover = null;
|
||||||
|
|||||||
@@ -968,21 +968,26 @@ body {
|
|||||||
z-index: 500;
|
z-index: 500;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
padding: 10px 12px;
|
padding: 16px 20px;
|
||||||
max-height: 80vh;
|
/* Cover most of the viewport — centered with margin */
|
||||||
|
top: 5vh;
|
||||||
|
left: 5vw;
|
||||||
|
right: 5vw;
|
||||||
|
bottom: 5vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
|
||||||
pointer-events: none; /* don't interfere with mouse leaving tile */
|
pointer-events: none; /* mouse events pass through to tiles below */
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-popover pre {
|
.preview-popover pre {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
line-height: 1.4;
|
line-height: 1.5;
|
||||||
color: var(--text-muted);
|
color: var(--text-dim);
|
||||||
white-space: pre;
|
white-space: pre-wrap; /* WRAP text instead of horizontal overflow */
|
||||||
|
word-break: break-all; /* break long lines at viewport edge */
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ globalThis.document = {
|
|||||||
querySelectorAll: () => [],
|
querySelectorAll: () => [],
|
||||||
createElement: () => ({ style: {}, classList: { add: () => {}, remove: () => {} } }),
|
createElement: () => ({ style: {}, classList: { add: () => {}, remove: () => {} } }),
|
||||||
addEventListener: () => {},
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Stubs for functions called by pollSessions (implemented in later tasks)
|
// Stubs for functions called by pollSessions (implemented in later tasks)
|
||||||
@@ -2091,16 +2092,18 @@ test('hover preview delay is 1500ms (not 350ms)', () => {
|
|||||||
assert.ok(!source.includes(', 350)'), 'old 350ms delay must be removed');
|
assert.ok(!source.includes(', 350)'), 'old 350ms delay must be removed');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('hover preview uses session name tracking instead of DOM element reference', () => {
|
test('hover preview uses full-window overlay with text wrapping', () => {
|
||||||
const source = fs.readFileSync(
|
const source = fs.readFileSync(
|
||||||
new URL('../app.js', import.meta.url), 'utf8'
|
new URL('../app.js', import.meta.url), 'utf8'
|
||||||
);
|
);
|
||||||
assert.ok(source.includes('_previewSessionName'), 'must track by session name, not DOM element');
|
assert.ok(source.includes('_previewSessionName'), 'must track by session name');
|
||||||
assert.ok(source.includes('repositionPreview'), 'must have repositionPreview for re-anchoring');
|
assert.ok(source.includes('liftHoveredTile'), 'must re-lift tile after renders');
|
||||||
assert.ok(source.includes('liftHoveredTile'), 'must re-lift tile after render cycles');
|
assert.ok(source.includes('scrollHeight'), 'must auto-scroll to bottom');
|
||||||
assert.ok(source.includes('scrollHeight'), 'must auto-scroll popover to bottom');
|
|
||||||
assert.ok(source.includes('preview-dimmer'), 'must have dim overlay');
|
assert.ok(source.includes('preview-dimmer'), 'must have dim overlay');
|
||||||
assert.ok(source.includes('ontouchstart'), 'must be desktop-only');
|
assert.ok(source.includes('ontouchstart'), 'must be desktop-only');
|
||||||
|
assert.ok(source.includes('_previewClickHandler'), 'must have click-to-navigate handler');
|
||||||
|
// Must NOT have repositionPreview (old side-positioned approach)
|
||||||
|
assert.ok(!source.includes('repositionPreview'), 'must NOT have repositionPreview (old approach)');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('renderGrid and renderSidebar re-lift hovered tile after innerHTML rebuild', () => {
|
test('renderGrid and renderSidebar re-lift hovered tile after innerHTML rebuild', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user