From 8c8a9fe1204ede4e17204eea3f446d6f81eb965e Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 7 Aug 2026 19:22:19 -0700 Subject: [PATCH] [eric] canvas: a card leaving the minimized rail lands in its tile in one frame, instead of flying in from a home nobody saw --- .../app/pages/Dashboard/canvas/tiledGeometry.ts | 17 +++++++++++++++-- .../app/pages/Dashboard/cards/useTiledCard.ts | 12 +++++++++++- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/pages/Dashboard/canvas/tiledGeometry.ts b/frontend/src/app/pages/Dashboard/canvas/tiledGeometry.ts index 10c8d91f..10bc4d95 100644 --- a/frontend/src/app/pages/Dashboard/canvas/tiledGeometry.ts +++ b/frontend/src/app/pages/Dashboard/canvas/tiledGeometry.ts @@ -246,7 +246,7 @@ function rebaseline(entry: TiledEntry, cam: Camera, tx: number, ty: number): voi entry.originY = (r.top - cam.panY) / cam.zoom - ty; } -export function registerTiledCard(id: string, zone: string, origin: { x: number; y: number }, cam: Camera): void { +export function registerTiledCard(id: string, zone: string, origin: { x: number; y: number }, cam: Camera, opts?: { instant?: boolean }): void { const el = document.querySelector(`[data-select-id="${CSS.escape(id)}"]`); if (!el) return; // Derive the TRUE origin up front from the painted rect and the element's current transform @@ -309,7 +309,20 @@ export function registerTiledCard(id: string, zone: string, origin: { x: number; requestAnimationFrame(() => { if (entries.get(id)?.el === el) onWorkspaceChanged(); }); - el.style.transition = `transform ${ENTER_MS}ms ${ENTER_EASE}`; + // A card coming OUT OF THE MINIMIZED RAIL has no meaningful start point for a glide: it un-parks + // to its canvas home, which the camera is usually not even looking at (register logs show homes + // painted offscreen), so the enter transition flew it across the viewport from a spot the user + // never saw, while the camera also glided and the zone-size reflow ran. That stack of motion is + // the rail-to-fullscreen jank. Landing instantly with a short compositor-only fade is one clean + // frame instead; the glide stays for cards that were visibly on canvas, where it reads correctly. + if (opts?.instant) { + el.style.transition = ''; + try { + el.animate([{ opacity: 0.3 }, { opacity: 1 }], { duration: 150, easing: 'ease-out' }); + } catch { /* fade is decoration */ } + } else { + el.style.transition = `transform ${ENTER_MS}ms ${ENTER_EASE}`; + } settleWhenCameraRests((settleElapsedMs) => { const live = entries.get(id); if (live?.el !== el) return; diff --git a/frontend/src/app/pages/Dashboard/cards/useTiledCard.ts b/frontend/src/app/pages/Dashboard/cards/useTiledCard.ts index 65926bfb..7daff7cf 100644 --- a/frontend/src/app/pages/Dashboard/cards/useTiledCard.ts +++ b/frontend/src/app/pages/Dashboard/cards/useTiledCard.ts @@ -26,11 +26,21 @@ export function useTiledCard({ cardId, zone, active, originX, originY, getCamera cameraRef.current = getCamera; const on = !!zone && active; + // Becoming ACTIVE while a zone is already set is the from-the-rail path: the card was hidden and + // is materialising straight into a tile, so the enter glide has no visible start point and must be + // skipped. Zone set while already active is the normal path and keeps its glide. + const prevActiveRef = useRef(active); + const instantRef = useRef(false); + if (active && !prevActiveRef.current && !!zone) instantRef.current = true; + prevActiveRef.current = active; + useEffect(() => (on ? subscribeTiledWorkspace(bump) : undefined), [on]); useLayoutEffect(() => { if (!on || !zone) return undefined; - registerTiledCard(cardId, zone, { x: originX, y: originY }, cameraRef.current()); + const instant = instantRef.current; + instantRef.current = false; + registerTiledCard(cardId, zone, { x: originX, y: originY }, cameraRef.current(), { instant }); return () => unregisterTiledCard(cardId); }, [on, zone, cardId, originX, originY]);