diff --git a/src/ui/cyberTheme.test.mjs b/src/ui/cyberTheme.test.mjs index 475ef0a4b..6566e4172 100644 --- a/src/ui/cyberTheme.test.mjs +++ b/src/ui/cyberTheme.test.mjs @@ -712,7 +712,7 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', () ); assert.match( rightRail, - /const leftStackTop = leftStack\?\.getBoundingClientRect\(\)\.top;[\s\S]*?const alignedTop = Number\.isFinite\(leftStackTop\)[\s\S]*?baseTop: alignedTop,/, + /const leftStackTop = settledLeftRailTop\([\s\S]*?const alignedTop = Number\.isFinite\(leftStackTop\)[\s\S]*?baseTop: alignedTop,/, ); assert.match( cyberStyles, diff --git a/src/ui/panelRails.test.mjs b/src/ui/panelRails.test.mjs index 842cd041c..7c3804546 100644 --- a/src/ui/panelRails.test.mjs +++ b/src/ui/panelRails.test.mjs @@ -357,6 +357,56 @@ test('right rail aligns to the current left rail and excludes hidden obstacles', assert.equal(f.stack.dataset.safeTop, '200.0'); }); +// The left pass commits its top and requests this pass in the same frame, but +// `top` then animates for 150 ms and nothing re-runs the right pass when it +// lands. CI docked the right rail at 234 px (26vh) while the left settled at +// 266.4 px (29.6vh). Mid-flight the right rail must read the destination. +function animatingLeftRail({ keyframes = true } = {}) { + const f = fixture('right'); + const left = f.options.leftStack; + left.rect.top = 250; + left.computed.top = '250px'; + left.dataset.safeTopPct = '29.60'; + left.getAnimations = () => [ + { transitionProperty: 'bottom', effect: null }, + { + transitionProperty: 'top', + effect: { + getKeyframes: () => + keyframes ? [{ top: '234px' }, { top: '266.4px' }] : [], + }, + }, + ]; + return f; +} + +test('right rail aligns to where a mid-transition left rail settles', () => { + const f = animatingLeftRail(); + f.run(); + assert.equal(f.stack.dataset.safeTop, '266.4'); + assert.equal( + f.stack.style.getPropertyValue('--right-stack-safe-top'), + '266.4px', + ); +}); + +test('right rail falls back to the committed left target when the transition end is unreadable', () => { + const f = animatingLeftRail({ keyframes: false }); + f.run(); + assert.equal(f.stack.dataset.safeTop, '266.4'); +}); + +test('right rail follows a settled left rail whose CSS overrides the committed target', () => { + // Cyber pins the left rail with `top: ... !important`, so the committed + // percentage is not where it renders and no `top` transition runs. + const f = fixture('right', { hud: { visible: true, variant: 'cyber' } }); + f.options.leftStack.rect.top = 220; + f.options.leftStack.dataset.safeTopPct = '26.00'; + f.options.leftStack.getAnimations = () => []; + f.run(); + assert.equal(f.stack.dataset.safeTop, '220.0'); +}); + test('natural height includes visible content, margins and wrapper chrome, excluding hidden rows', () => { const panel = element('panel'); const inner = element('inner', { top: 100 }); diff --git a/src/ui/rightPanelRail.js b/src/ui/rightPanelRail.js index 45e55a73e..636a47c29 100644 --- a/src/ui/rightPanelRail.js +++ b/src/ui/rightPanelRail.js @@ -11,6 +11,35 @@ import { displayPanelScroller } from './displayPanelScroll.js'; const pendingCollapseRetries = new WeakSet(); +/** + * Read where the left rail's top edge is going to settle, not where it is. + * The left pass commits `--left-stack-safe-top` and requests this pass in the + * same frame, but `top` then animates; nothing re-runs this pass when the + * animation ends, because ResizeObserver ignores moves. Mid-flight, take the + * running transition's end value (which also covers Cyber's fixed rail top), + * falling back to the left pass's committed target. + * @param {HTMLElement} leftStack Rail supplying the shared top baseline. + * @param {number} viewportHeight Current viewport height in px. + * @param {Function} getComputedStyle DOM style reader. + * @returns {number} Settled top in px, or NaN when there is no left rail. + */ +function settledLeftRailTop(leftStack, viewportHeight, getComputedStyle) { + const top = leftStack?.getBoundingClientRect().top; + if (!Number.isFinite(top)) return NaN; + const transition = leftStack + .getAnimations?.() + .find((animation) => animation.transitionProperty === 'top'); + if (!transition) return top; + const keyframes = transition.effect?.getKeyframes?.() || []; + const targetTop = parseFloat(keyframes.at(-1)?.top); + const currentTop = parseFloat(getComputedStyle(leftStack).top); + // The rect and the computed `top` move together; keep any offset between them. + if (Number.isFinite(targetTop) && Number.isFinite(currentTop)) + return top + targetTop - currentTop; + const targetPct = parseFloat(leftStack.dataset?.safeTopPct); + return Number.isFinite(targetPct) ? (targetPct * viewportHeight) / 100 : top; +} + /** * Measure and place the right panel rail for one synchronous layout pass. * The caller owns scheduling, obstacle selection, disclosure preferences and @@ -113,7 +142,11 @@ export function layoutRightPanelRail({ const viewportHeight = Math.max(1, windowRef.innerHeight); const safeGap = Math.max(8, viewportHeight * 0.012); const stackRect = stack.getBoundingClientRect(); - const leftStackTop = leftStack?.getBoundingClientRect().top; + const leftStackTop = settledLeftRailTop( + leftStack, + viewportHeight, + getComputedStyle, + ); const alignedTop = Number.isFinite(leftStackTop) ? leftStackTop : viewportHeight * 0.26;