Skip to content

Commit ce1369d

Browse files
lukemeliaclaude
andcommitted
Compute the overlay position from live rects to stop the first-frame jump
The velcro offset middleware returned floating-ui's `rects.reference`, but frame-by-frame capture showed floating-ui's first one-or-two computePosition calls return the reference in viewport coordinates and only subtract the offset parent's offset a frame later — so the overlay (and everything riding it: the type-label tab, the select chip, the menu, the outline) painted ~60px off and jumped into place on first hover. The capture also showed the inputs we need are correct from frame 0: the reference's own getBoundingClientRect and the floating element's offsetParent rect. Compute the position from those directly (recovering the offset parent's scale the same way the Adorn label positioner does, for the scaled test runner), so it's right on the very first frame. No hiding, no rAF, no deferred reveal — the overlay simply lands in the correct place immediately. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4a198d2 commit ce1369d

2 files changed

Lines changed: 37 additions & 72 deletions

File tree

packages/host/app/components/operator-mode/operator-mode-overlays.gts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -280,13 +280,6 @@ export default class OperatorModeOverlays extends Overlays {
280280
return 100;
281281
}
282282

283-
// This overlay carries visible chrome (the teal type-label tab, the select
284-
// chip, the menu, the outline), so hide it until velcro's position settles
285-
// to avoid the first-frame jump those would otherwise show.
286-
protected override get hideUntilPositioned(): boolean {
287-
return true;
288-
}
289-
290283
// Tracks which rendered cards are currently small enough to warrant
291284
// the Adorn compact treatment (narrow atom-format cards). The set
292285
// is updated by `trackCompact` as cards resize; `isCompact` reads

packages/host/app/components/operator-mode/overlays.gts

Lines changed: 37 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import Component from '@glimmer/component';
99
import { tracked } from '@glimmer/tracking';
1010

1111
import { dropTask } from 'ember-concurrency';
12-
import { modifier } from 'ember-modifier';
1312
import { velcro } from 'ember-velcro';
1413
import { isEqual, omit } from 'lodash';
1514

@@ -79,7 +78,6 @@ export default class Overlays extends Component<OverlaySignature> {
7978
<div
8079
class={{this.overlayClassName}}
8180
{{velcro renderedCard.element middleware=(array this.offset)}}
82-
{{this.revealWhenPositioned}}
8381
style={{renderedCard.overlayZIndexStyle}}
8482
data-test-card-overlay
8583
...attributes
@@ -128,83 +126,57 @@ export default class Overlays extends Component<OverlaySignature> {
128126
protected offset = {
129127
name: 'offset',
130128
fn: (state: MiddlewareState) => {
131-
let { elements, rects } = state;
129+
let { elements } = state;
132130
let { floating, reference } = elements;
133-
let { width, height } = reference.getBoundingClientRect();
131+
let refRect = reference.getBoundingClientRect();
134132

135-
floating.style.width = width + 'px';
136-
floating.style.height = height + 'px';
133+
floating.style.width = refRect.width + 'px';
134+
floating.style.height = refRect.height + 'px';
137135
floating.style.position = 'absolute';
138136
// Mirror the underlying card's corner radius so any decorative
139137
// outline / box-shadow on the overlay follows the same curve.
140138
if (reference instanceof Element) {
141139
floating.style.borderRadius =
142140
window.getComputedStyle(reference).borderRadius;
143141
}
142+
143+
// Position the overlay from the live reference rect relative to the
144+
// floating element's own offset parent, rather than floating-ui's
145+
// `rects.reference`. floating-ui's first one-or-two computePosition calls
146+
// omit the offset parent's offset (they return the reference in viewport
147+
// coordinates and only subtract the offset parent a frame later), so
148+
// trusting `rects.reference` makes the overlay — and everything riding it
149+
// (the type-label tab, the select chip, the menu, the outline) — paint
150+
// one frame off and visibly jump into place on first appearance.
151+
// Computing it ourselves from the current rects is correct on the very
152+
// first frame. We recover the offset parent's scale the same way the
153+
// Adorn label positioner does (the test runner scales `#ember-testing`),
154+
// and convert the viewport anchor into the offset parent's local space.
155+
let offsetParent = floating.offsetParent as HTMLElement | null;
156+
let parentRect = offsetParent
157+
? offsetParent.getBoundingClientRect()
158+
: new DOMRect(0, 0, window.innerWidth, window.innerHeight);
159+
let scaleX =
160+
offsetParent && offsetParent.offsetWidth > 0
161+
? parentRect.width / offsetParent.offsetWidth
162+
: 1;
163+
let scaleY =
164+
offsetParent && offsetParent.offsetHeight > 0
165+
? parentRect.height / offsetParent.offsetHeight
166+
: 1;
167+
if (!Number.isFinite(scaleX) || scaleX === 0) {
168+
scaleX = 1;
169+
}
170+
if (!Number.isFinite(scaleY) || scaleY === 0) {
171+
scaleY = 1;
172+
}
144173
return {
145-
x: rects.reference.x,
146-
y: rects.reference.y,
174+
x: (refRect.left - parentRect.left) / scaleX,
175+
y: (refRect.top - parentRect.top) / scaleY,
147176
};
148177
},
149178
};
150179

151-
// Whether to keep the overlay hidden (opacity 0) until its velcro position
152-
// has settled, to avoid the first-frame jump. Off by default (consumers
153-
// without visible chrome — spec-preview, playground — don't need it);
154-
// OperatorModeOverlays overrides it.
155-
protected get hideUntilPositioned(): boolean {
156-
return false;
157-
}
158-
159-
// floating-ui's first one-or-two computePosition calls return the reference
160-
// in viewport coordinates rather than offset-parent-relative ones (it omits
161-
// the offset parent's offset for ~1 frame, then corrects), so the overlay —
162-
// and everything riding it (the type-label tab, the select chip, the menu,
163-
// the outline) — paints one frame off and visibly jumps into place. Hold the
164-
// overlay at opacity 0 until its measured rect has been unchanged for two
165-
// consecutive frames, then reveal. We watch the actual geometry rather than
166-
// count frames or compare middleware outputs, because the wrong position can
167-
// repeat across calls and the correction lands a frame after the first paint;
168-
// only the rect going stable is a reliable "done moving" signal. Opacity (not
169-
// visibility) keeps the overlay's action buttons hit-testable throughout.
170-
protected revealWhenPositioned = modifier((element: HTMLElement) => {
171-
if (!this.hideUntilPositioned) {
172-
return undefined;
173-
}
174-
element.style.opacity = '0';
175-
let lastTop: number | undefined;
176-
let lastLeft: number | undefined;
177-
let stableFrames = 0;
178-
let elapsedFrames = 0;
179-
let raf = 0;
180-
let tick = () => {
181-
let { top, left } = element.getBoundingClientRect();
182-
if (top === lastTop && left === lastLeft) {
183-
stableFrames += 1;
184-
} else {
185-
stableFrames = 0;
186-
lastTop = top;
187-
lastLeft = left;
188-
}
189-
elapsedFrames += 1;
190-
// Reveal once the rect has settled, with a safety cap so a perpetually
191-
// animating reference can't keep the overlay hidden forever.
192-
if (stableFrames >= 2 || elapsedFrames > 30) {
193-
element.style.opacity = '';
194-
return;
195-
}
196-
// eslint-disable-next-line @cardstack/boxel/no-raf-for-state
197-
raf = requestAnimationFrame(tick);
198-
};
199-
// eslint-disable-next-line @cardstack/boxel/no-raf-for-state
200-
raf = requestAnimationFrame(tick);
201-
return () => {
202-
if (raf) {
203-
cancelAnimationFrame(raf);
204-
}
205-
};
206-
});
207-
208180
// Since we put absolutely positined overlays containing operator mode actions on top of the rendered cards,
209181
// we are running into a problem where the overlays are interfering with scrolling of the container that holds the rendered cards.
210182
// That means scrolling stops when the cursor gets over the overlay, which is a bug. We solved this problem by disabling pointer

0 commit comments

Comments
 (0)