From 5ad192cef64a28c72e3242c25454fa460af55a2a Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 7 Aug 2026 21:43:23 -0700 Subject: [PATCH 1/4] chore(studio): name whoever puts the pre-resize size back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resizing the card commits correctly — the source and a fresh load both read 273x181 — but 200ms after the drop, mid-commit, the element renders at 395x261 with the studio size vars still holding 273x181. Something writes the pre-gesture size back inline while the reload is still in flight, and every writer of that size was silent. Both are traced now under the existing hf-resize-debug flag, each with the size going in, the size being replaced, and a short stack. Restoring the pre-gesture size is right on a cancel and wrong after a successful commit, and the function doing it cannot tell the two apart from the inside — so the caller has to be named before this can be fixed at the right end. --- packages/studio/src/components/editor/manualEditsDom.ts | 6 ++++++ .../studio/src/components/editor/manualEditsSnapshot.ts | 9 +++++++++ 2 files changed, 15 insertions(+) diff --git a/packages/studio/src/components/editor/manualEditsDom.ts b/packages/studio/src/components/editor/manualEditsDom.ts index 80697e4e3d..9235f88116 100644 --- a/packages/studio/src/components/editor/manualEditsDom.ts +++ b/packages/studio/src/components/editor/manualEditsDom.ts @@ -36,6 +36,7 @@ import { roundRotationAngle } from "./manualEditsParsing"; import { applyStudioMotionFromDom } from "./studioMotion"; import { gsapAnimatesProperty } from "./gsapAnimatesProperty"; import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers"; +import { logResize } from "../../utils/resizeDebug"; /* ── Gesture tracking ─────────────────────────────────────────────── */ let studioManualEditGestureId = 0; @@ -454,6 +455,11 @@ export function applyStudioBoxSize( element: HTMLElement, size: { width: number; height: number }, ): void { + logResize("apply-box-size", { + to: `${Math.round(size.width)} x ${Math.round(size.height)}`, + from: `${element.style.width || "-"} x ${element.style.height || "-"}`, + stack: new Error("apply-box-size").stack?.split("\n").slice(1, 6).join(" < "), + }); promoteInlineForTransform(element); applyStudioBoxSizeDimensions(element, size); } diff --git a/packages/studio/src/components/editor/manualEditsSnapshot.ts b/packages/studio/src/components/editor/manualEditsSnapshot.ts index 826fb16829..9ab9269eb8 100644 --- a/packages/studio/src/components/editor/manualEditsSnapshot.ts +++ b/packages/studio/src/components/editor/manualEditsSnapshot.ts @@ -40,6 +40,7 @@ import type { StudioRotationSnapshot, StudioPathOffsetSnapshot, } from "./manualEditsTypes"; +import { logResize } from "../../utils/resizeDebug"; /* ── Capture ──────────────────────────────────────────────────────── */ export function captureStudioBoxSize(element: HTMLElement): StudioBoxSizeSnapshot { @@ -114,6 +115,14 @@ function restoreStyleProperty(element: HTMLElement, property: string, value: str } export function restoreStudioBoxSize(element: HTMLElement, previous: StudioBoxSizeSnapshot): void { + // Putting the pre-gesture size back is correct on a cancel and wrong after a + // successful commit, and the two are indistinguishable from in here — so say + // who asked, with the size being restored and the one being replaced. + logResize("restore-box-size", { + to: `${previous.width || "-"} x ${previous.height || "-"}`, + from: `${element.style.width || "-"} x ${element.style.height || "-"}`, + stack: new Error("restore-box-size").stack?.split("\n").slice(1, 6).join(" < "), + }); restoreStyleProperty(element, "width", previous.width); restoreStyleProperty(element, "height", previous.height); restoreStyleProperty(element, "min-width", previous.minWidth); From b45b06d407b4d11171914d62dfa9a0dc5a32a2c4 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 8 Aug 2026 10:36:45 -0700 Subject: [PATCH 2/4] fix(studio): hold a resized element's size while the timeline is rebuilt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Your log caught it across two resizes. The first commits 305x202 and the element is 305x202 at the drop; 200ms later it renders 395x261, its stylesheet size, while --hf-studio-width still reads 305. The second gesture then starts with `actual` at 305 against a live box of 395, and its very first move — a pointer delta of 0.1px — snaps the element back to 305. That snap is the jump. The gap belongs to the soft reload: it reverts the old timeline before building the new one, and GSAP hands back each tween's recorded starting width on the way out. Nothing held the size in between, because the seek reapply that exists for exactly this stands aside for elements GSAP animates. Standing aside is right for the offset — those channels compose, and applying both doubles the move — and wrong for size, where both channels write width and height so the later write simply wins on the same committed number. It applies now. Only an element mid-edit carries the vars, so nothing else is touched. A test seeks an element whose size GSAP owns after the revert put the stylesheet size back, and fails with the skip restored. --- .../src/components/editor/manualEditsDom.ts | 29 +++++---- .../editor/reapplyBoxSizeAfterSeek.test.ts | 64 +++++++++++++++++++ 2 files changed, 80 insertions(+), 13 deletions(-) create mode 100644 packages/studio/src/components/editor/reapplyBoxSizeAfterSeek.test.ts diff --git a/packages/studio/src/components/editor/manualEditsDom.ts b/packages/studio/src/components/editor/manualEditsDom.ts index 9235f88116..50b4c2272e 100644 --- a/packages/studio/src/components/editor/manualEditsDom.ts +++ b/packages/studio/src/components/editor/manualEditsDom.ts @@ -561,26 +561,29 @@ function queryStudioElements(doc: Document, attr: string): HTMLElement[] { function reapplyPathOffsets(doc: Document): void { for (const el of queryStudioElements(doc, STUDIO_PATH_OFFSET_ATTR)) { - const gsapSkip = gsapAnimatesProperty(el, "x", "y"); + // Unlike size below, the offset channels COMPOSE — applying both doubles the move. + if (gsapAnimatesProperty(el, "x", "y")) continue; const x = el.style.getPropertyValue(STUDIO_OFFSET_X_PROP); const y = el.style.getPropertyValue(STUDIO_OFFSET_Y_PROP); - if (gsapSkip) continue; - if (x || y) { - applyStudioPathOffset( - el, - { - x: Number.parseFloat(x) || 0, - y: Number.parseFloat(y) || 0, - }, - { updateBase: false }, - ); - } + if (!x && !y) continue; + const offset = { x: Number.parseFloat(x) || 0, y: Number.parseFloat(y) || 0 }; + applyStudioPathOffset(el, offset, { updateBase: false }); } } +/** + * Put the studio's committed size back after a seek, GSAP-sized elements included. + * + * Size does not compose the way the offset above does: both channels write width + * and height, so the later write wins and both hold the same number. Standing + * aside meant nothing held the size while a soft reload reverted the old timeline + * — GSAP hands back each tween's recorded starting width — so until the new one + * rendered, the element sat at its stylesheet size. That is the jump after a + * resize, and the next gesture then started from a box disagreeing with these + * vars and snapped on its first move. Only an element mid-edit carries them. + */ function reapplyBoxSizes(doc: Document): void { for (const el of queryStudioElements(doc, STUDIO_BOX_SIZE_ATTR)) { - if (gsapAnimatesProperty(el, "width", "height")) continue; const w = Number.parseFloat(el.style.getPropertyValue(STUDIO_WIDTH_PROP)); const h = Number.parseFloat(el.style.getPropertyValue(STUDIO_HEIGHT_PROP)); if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) { diff --git a/packages/studio/src/components/editor/reapplyBoxSizeAfterSeek.test.ts b/packages/studio/src/components/editor/reapplyBoxSizeAfterSeek.test.ts new file mode 100644 index 0000000000..92a9087968 --- /dev/null +++ b/packages/studio/src/components/editor/reapplyBoxSizeAfterSeek.test.ts @@ -0,0 +1,64 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from "vitest"; +import { reapplyPositionEditsAfterSeek } from "./manualEditsDom"; +import { STUDIO_BOX_SIZE_ATTR, STUDIO_HEIGHT_PROP, STUDIO_WIDTH_PROP } from "./manualEditsTypes"; + +/** + * A resize commit hands the size to a GSAP tween, and a soft reload reverts the + * old timeline before the new one renders — GSAP restores each tween's recorded + * starting width on the way out. Nothing else held the size across that window, + * so the element sat at its stylesheet size for a few hundred milliseconds: the + * jump after a resize. Worse, the next gesture then started from a box that + * disagreed with the studio's own vars and snapped on its first move. + * + * The seek reapply is what closes the window, and it used to stand aside for + * exactly the elements that need it — the ones GSAP sizes. + */ +describe("box size survives a seek while GSAP owns the size", () => { + afterEach(() => { + document.body.innerHTML = ""; + Reflect.deleteProperty(window, "__timelines"); + }); + + function cardSizedByGsap(): HTMLElement { + const el = document.createElement("div"); + el.id = "card"; + el.setAttribute(STUDIO_BOX_SIZE_ATTR, "true"); + el.style.setProperty(STUDIO_WIDTH_PROP, "305px"); + el.style.setProperty(STUDIO_HEIGHT_PROP, "202px"); + document.body.append(el); + // A timeline that animates this element's width/height, as the committed + // resize leaves behind. + Object.assign(window, { + __timelines: { + main: { + getChildren: () => [{ targets: () => [el], vars: { width: 305, height: 202 } }], + }, + }, + }); + return el; + } + + it("re-applies the committed size after the timeline gave it back", () => { + const el = cardSizedByGsap(); + // The revert: GSAP puts the tween's recorded starting size back. + el.style.width = "395px"; + el.style.height = "261px"; + + reapplyPositionEditsAfterSeek(document); + + expect(el.style.width).toBe("305px"); + expect(el.style.height).toBe("202px"); + }); + + it("leaves an element alone once its studio size is cleared", () => { + const el = cardSizedByGsap(); + el.style.removeProperty(STUDIO_WIDTH_PROP); + el.style.removeProperty(STUDIO_HEIGHT_PROP); + el.style.width = "395px"; + + reapplyPositionEditsAfterSeek(document); + + expect(el.style.width).toBe("395px"); + }); +}); From a9e69c934b57c7aacb6012951861bc448a7c4ceb Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 9 Aug 2026 03:21:59 -0400 Subject: [PATCH 3/4] refactor(studio): keep the resize files under the size cap --- packages/studio/src/components/editor/manualEditsDom.ts | 6 ------ .../studio/src/components/editor/manualEditsSnapshot.ts | 9 --------- 2 files changed, 15 deletions(-) diff --git a/packages/studio/src/components/editor/manualEditsDom.ts b/packages/studio/src/components/editor/manualEditsDom.ts index 50b4c2272e..fe19bff836 100644 --- a/packages/studio/src/components/editor/manualEditsDom.ts +++ b/packages/studio/src/components/editor/manualEditsDom.ts @@ -36,7 +36,6 @@ import { roundRotationAngle } from "./manualEditsParsing"; import { applyStudioMotionFromDom } from "./studioMotion"; import { gsapAnimatesProperty } from "./gsapAnimatesProperty"; import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers"; -import { logResize } from "../../utils/resizeDebug"; /* ── Gesture tracking ─────────────────────────────────────────────── */ let studioManualEditGestureId = 0; @@ -455,11 +454,6 @@ export function applyStudioBoxSize( element: HTMLElement, size: { width: number; height: number }, ): void { - logResize("apply-box-size", { - to: `${Math.round(size.width)} x ${Math.round(size.height)}`, - from: `${element.style.width || "-"} x ${element.style.height || "-"}`, - stack: new Error("apply-box-size").stack?.split("\n").slice(1, 6).join(" < "), - }); promoteInlineForTransform(element); applyStudioBoxSizeDimensions(element, size); } diff --git a/packages/studio/src/components/editor/manualEditsSnapshot.ts b/packages/studio/src/components/editor/manualEditsSnapshot.ts index 9ab9269eb8..826fb16829 100644 --- a/packages/studio/src/components/editor/manualEditsSnapshot.ts +++ b/packages/studio/src/components/editor/manualEditsSnapshot.ts @@ -40,7 +40,6 @@ import type { StudioRotationSnapshot, StudioPathOffsetSnapshot, } from "./manualEditsTypes"; -import { logResize } from "../../utils/resizeDebug"; /* ── Capture ──────────────────────────────────────────────────────── */ export function captureStudioBoxSize(element: HTMLElement): StudioBoxSizeSnapshot { @@ -115,14 +114,6 @@ function restoreStyleProperty(element: HTMLElement, property: string, value: str } export function restoreStudioBoxSize(element: HTMLElement, previous: StudioBoxSizeSnapshot): void { - // Putting the pre-gesture size back is correct on a cancel and wrong after a - // successful commit, and the two are indistinguishable from in here — so say - // who asked, with the size being restored and the one being replaced. - logResize("restore-box-size", { - to: `${previous.width || "-"} x ${previous.height || "-"}`, - from: `${element.style.width || "-"} x ${element.style.height || "-"}`, - stack: new Error("restore-box-size").stack?.split("\n").slice(1, 6).join(" < "), - }); restoreStyleProperty(element, "width", previous.width); restoreStyleProperty(element, "height", previous.height); restoreStyleProperty(element, "min-width", previous.minWidth); From 556250e6ec3ee23b991004de73709951f572157f Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 9 Aug 2026 11:24:14 -0400 Subject: [PATCH 4/4] docs(studio): fold the resize note into the size-reapply comment --- .../studio/src/components/editor/manualEditsDom.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/studio/src/components/editor/manualEditsDom.ts b/packages/studio/src/components/editor/manualEditsDom.ts index fe19bff836..4a041e020f 100644 --- a/packages/studio/src/components/editor/manualEditsDom.ts +++ b/packages/studio/src/components/editor/manualEditsDom.ts @@ -567,14 +567,11 @@ function reapplyPathOffsets(doc: Document): void { /** * Put the studio's committed size back after a seek, GSAP-sized elements included. - * * Size does not compose the way the offset above does: both channels write width - * and height, so the later write wins and both hold the same number. Standing - * aside meant nothing held the size while a soft reload reverted the old timeline - * — GSAP hands back each tween's recorded starting width — so until the new one - * rendered, the element sat at its stylesheet size. That is the jump after a - * resize, and the next gesture then started from a box disagreeing with these - * vars and snapped on its first move. Only an element mid-edit carries them. + * and height, so the later write wins on the same number. Standing aside meant + * nothing held the size while a soft reload reverted the old timeline (GSAP hands + * back each tween's recorded starting width), so the element sat at its stylesheet + * size until the new one rendered — the jump after a resize. */ function reapplyBoxSizes(doc: Document): void { for (const el of queryStudioElements(doc, STUDIO_BOX_SIZE_ATTR)) {