diff --git a/src/ui/leftPanelRail.js b/src/ui/leftPanelRail.js index 3df30677d..ca11a8149 100644 --- a/src/ui/leftPanelRail.js +++ b/src/ui/leftPanelRail.js @@ -59,6 +59,7 @@ export function layoutLeftPanelRail({ stack.style.removeProperty('--left-stack-safe-top'); stack.style.removeProperty('--left-stack-safe-bottom'); stack.style.removeProperty('--left-stack-centered-height'); + stack.style.removeProperty('--panel-rail-offset'); stack.classList.remove('layout-exclusive'); stack.dataset.layoutMode = 'mobile'; settleSideAccordion({ panels, enabled: false, onCollapse }); @@ -275,6 +276,16 @@ export function layoutLeftPanelRail({ ) { stack.style.setProperty('--left-stack-safe-bottom', bottomValue); } + // The rail stays where the resting stack puts it; only the panels move. + // A theme that pins the stack's top (Cyber) leaves nothing to offset. + const topPinned = + getComputedStyle(stack) + .getPropertyValue?.('--panel-stack-top-pinned') + ?.trim() === '1'; + const railOffset = `${(topPinned ? 0 : safeTop - layoutTop).toFixed(1)}px`; + if (stack.style.getPropertyValue('--panel-rail-offset') !== railOffset) { + stack.style.setProperty('--panel-rail-offset', railOffset); + } stack.style.removeProperty('--left-stack-centered-height'); for (const panel of panels) panel.style.removeProperty('--left-panel-allocated-height'); diff --git a/src/ui/panelRails.test.mjs b/src/ui/panelRails.test.mjs index 5650feb44..801fd02f4 100644 --- a/src/ui/panelRails.test.mjs +++ b/src/ui/panelRails.test.mjs @@ -349,6 +349,32 @@ test('right rail aligns to the current left rail and excludes hidden obstacles', assert.equal(f.stack.dataset.safeTop, '200.0'); }); +// An opened panel moves the left stack up, but its rail buttons hold still: +// the right rail lines up with those buttons, not with the moved stack. +test('right rail aligns to the left rail buttons while the left stack is offset', () => { + const f = fixture('right'); + f.options.leftStack.rect.top = 100; + f.options.leftStack.style.setProperty('--panel-rail-offset', '186.7px'); + f.run(); + assert.equal(f.stack.dataset.safeTop, '286.7'); +}); + +test('left rail buttons hold still while a tall panel moves the stack', () => { + const f = fixture('left'); + f.expand(f.first, 900); + f.run(); + assert.equal(f.stack.dataset.layoutMode, 'focus'); + assert.ok( + parseFloat(f.stack.style.getPropertyValue('--panel-rail-offset')) > 0, + ); + + // A theme that pins the stack's top (Cyber) leaves nothing to offset. + f.stack.computed.getPropertyValue = (name) => + name === '--panel-stack-top-pinned' ? ' 1' : ''; + f.run(); + assert.equal(f.stack.style.getPropertyValue('--panel-rail-offset'), '0.0px'); +}); + // The left rail's `top` animates after the right pass runs, and nothing // re-runs it when the transition lands: the right rail must read the end value. function animatingLeftRail({ keyframes = true } = {}) { diff --git a/src/ui/rightPanelRail.js b/src/ui/rightPanelRail.js index 40a80f3ba..44043fb01 100644 --- a/src/ui/rightPanelRail.js +++ b/src/ui/rightPanelRail.js @@ -126,8 +126,11 @@ export function layoutRightPanelRail({ viewportHeight, getComputedStyle, ); + // Align with the left rail's buttons, which hold still while its stack moves. + const leftRailOffset = + parseFloat(leftStack?.style.getPropertyValue('--panel-rail-offset')) || 0; const alignedTop = Number.isFinite(leftStackTop) - ? leftStackTop + ? leftStackTop + leftRailOffset : viewportHeight * 0.26; const obstacleRects = []; diff --git a/src/ui/styles/cyber.css b/src/ui/styles/cyber.css index 122357e97..4f5b4f9f9 100644 --- a/src/ui/styles/cyber.css +++ b/src/ui/styles/cyber.css @@ -1654,6 +1654,8 @@ } :root[data-ui-theme='cyber'] #left-panel-stack { + /* The stack holds still, so its rail needs no offset (leftPanelRail.js). */ + --panel-stack-top-pinned: 1; top: var(--cyber-left-rail-top) !important; bottom: calc(2vh + 13rem); left: calc(54px + var(--panel-rail-clearance)); diff --git a/src/ui/styles/layers.css b/src/ui/styles/layers.css index 90915c6b3..3074429ce 100644 --- a/src/ui/styles/layers.css +++ b/src/ui/styles/layers.css @@ -245,12 +245,15 @@ } /* Panel rails (src/ui/panelNavigation.js). Each rail lives inside its stack, - so it shows and hides with the stack, and floats in the screen-edge gutter. */ + so it shows and hides with the stack, and floats in the screen-edge gutter. + The stack's layout offsets it back to its resting top, so it holds still + when an opened panel moves the stack. */ .gev-panel-rail { position: absolute; - top: 0; + top: var(--panel-rail-offset, 0px); z-index: 1; pointer-events: auto; + transition: top var(--transition-fast); } .gev-panel-rail--left { right: calc(100% + 6px);