fix(ui): align the right rail to where the left rail settles

The right rail took its top from the left stack's bounding rect while the
left stack's `top` was still transitioning (150 ms): the left layout sets
its target and calls onAligned() synchronously, the right pass runs a
frame later mid-transition, and nothing re-ran it when the transition
ended. The rail then sat ~32 px off and jumped on the next unrelated
relayout - under the pointer in CI's panel gates, which is why a header
press missed on every run.

settledLeftRailTop() reads the end value of a running `top` transition
(falling back to dataset.safeTopPct, then the rect). A settled stack is
read from its rect as before, which keeps Cyber's CSS top override (and
cockpit's transition: none) exactly as they were. The Cyber test's
pinned pattern follows the new helper.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-10-06 16:54:06 +01:00
1 parent f17bf7116d
commit 9dde7b4a62
3 files changed
+85 -2

No files matched your search

+34 -1
View File
@@ -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;