fix(sdk): moveElement survives GSAP animation per-axis via runtime delta translate (#1875)

* fix(sdk): moveElement survives GSAP animation per-axis via runtime delta translate

A committed moveElement wrote data-x/data-y but nothing rendered them:
hosts shimmed CSS translate, which GSAP folds into the cached transform
at first parse and then discards on the animated axis at every seek —
dragging an animated element kept only the un-animated axis.

Spike-proven on GSAP 3.15: a translate set AFTER GSAP's first parse is
never read, folded, or cleared across seeks and composes natively with
the animated transform. So:

- moveElement captures the pre-edit baseline once (data-hf-edit-base-x/y)
- the runtime (new core runtime/positionEdits.ts, applied at timeline
  bind — after GSAP parse) renders translate = (data-x − base), a pure
  delta that composes with GSAP tweens, tl.set positions, and CSS alike
- applyDraft now drives the drag preview through the same translate
  channel (the --hf-studio-dx/dy vars had no consumer outside authored
  Studio bridges), and commitPreview mirrors the committed move onto
  the live element so it holds without an srcdoc reload

Acceptance: packages/engine/scripts/test-runtime-position-edits-browser.ts
(real Chrome + GSAP + runtime IIFE, no Studio shell) — X-animated,
Y-animated, and static elements hold both edited axes across the full
seek range. New subpath export @hyperframes/core/runtime/position-edits.

Known limitation (documented): a tween created lazily at runtime that
first-parses a marked element after apply folds the edit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sdk): harden position-edit rendering and the drag draft channel

Fixes six issues from adversarial review of the moveElement stack:

- Runtime: apply position edits at init as well as at timeline bind, so
  committed moves render in compositions with no usable GSAP timeline
  (CSS/WAAPI-animated or fully static) — previously the apply was
  unreachable outside the boundDuration > 0 bind branch and the edit
  silently vanished from reloads and renders.
- Runtime: guard bind-path re-apply against post-fold double-apply — if
  the previously written translate was consumed externally (a lazily
  created tween folding it into GSAP's cached transform), skip instead
  of re-setting it on top ({force} escape hatch for editor commits).
- Adapter: stop writing the --hf-studio-dx/dy custom properties during
  drags — compositions with the documented var-consuming drag-bridge
  CSS moved by twice the pointer delta (var transform + new inline
  translate). The inline translate is now the only draft channel;
  deltas accumulate in adapter fields. Docs updated to match.
- Adapter: switching applyDraft to a new id reverts the abandoned
  element's draft translate instead of leaving it displaced with no op.
- Adapter: cancelPreview restores the raw inline translate (removing it
  when there was none), so a stylesheet-authored translate is never
  promoted to a permanent inline style.
- Adapter: commitPreview reverts the draft and clears state when
  dispatch throws, instead of leaving the element shifted by an
  uncommitted draft.

Cleanups: reuse readCurrentTranslate from the core module (was a
verbatim copy), drop the dead __hfApplyPositionEdits window hook.
Browser acceptance test now also covers the GSAP-free composition path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(core): prime GSAP transform cache before position-edit apply; add fold-loss telemetry

Addresses PR #1875 review feedback (Rames, Miga):

- Prime the element's GSAP transform parse (gsap.getProperty) before the
  first translate apply — positioned tl.set()s and tweens that first
  RENDER after the apply now reuse the cache instead of folding the edit.
  This closes the lazy-first-parse fold-loss for any page where GSAP is
  loaded at apply time; the residual limitation is GSAP itself loading
  after the apply. Proven by the extended browser acceptance test.
- Emit position_edit_fold_skipped analytics at the fold-guard skip site
  so the residual degradation is observable instead of silent.
- Browser acceptance test: add a both-axis-animated element (the shape
  that originated the per-axis loss) and a positioned tl.set() element,
  asserted across the full seek range.
- Simplify the num() null guard (review nit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-07-03 20:44:27 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent a2677ca730
commit 74faa4b2a4
13 changed files with 929 additions and 64 deletions
@@ -0,0 +1,150 @@
import { describe, expect, it } from "vitest";
import {
EDIT_BASE_X_ATTR,
EDIT_BASE_Y_ATTR,
EDIT_ORIGINAL_TRANSLATE_ATTR,
applyPositionEditToElement,
applyPositionEdits,
composeTranslate,
} from "./positionEdits";
function makeElement(attrs: Record<string, string>, style = ""): HTMLElement {
const el = document.createElement("div");
for (const [name, value] of Object.entries(attrs)) el.setAttribute(name, value);
if (style) el.setAttribute("style", style);
document.body.appendChild(el);
return el;
}
describe("composeTranslate", () => {
it("returns the delta alone when there is no original", () => {
expect(composeTranslate("", "10px", "20px")).toBe("10px 20px");
expect(composeTranslate("none", "10px", "20px")).toBe("10px 20px");
});
it("adds px values numerically", () => {
expect(composeTranslate("5px 6px", "10px", "20px")).toBe("15px 26px");
expect(composeTranslate("-5.5px 6px", "10px", "-20px")).toBe("4.5px -14px");
});
it("treats a single-part original as x-only", () => {
expect(composeTranslate("5px", "10px", "20px")).toBe("15px 20px");
});
it("falls back to calc() for non-px units and preserves z", () => {
expect(composeTranslate("10% 6px", "10px", "20px")).toBe("calc(10% + 10px) 26px");
expect(composeTranslate("1px 2px 3px", "10px", "20px")).toBe("11px 22px 3px");
});
});
describe("applyPositionEdits", () => {
it("ignores unmarked elements", () => {
const el = makeElement({ "data-x": "100", "data-y": "50" });
expect(applyPositionEdits(document)).toBe(0);
expect(el.style.getPropertyValue("translate")).toBe("");
el.remove();
});
it("applies the delta between data-x/y and the captured baseline", () => {
const el = makeElement({
"data-x": "150",
"data-y": "-30",
[EDIT_BASE_X_ATTR]: "100",
[EDIT_BASE_Y_ATTR]: "20",
});
expect(applyPositionEdits(document)).toBe(1);
expect(el.style.getPropertyValue("translate")).toBe("50px -50px");
expect(el.getAttribute(EDIT_ORIGINAL_TRANSLATE_ATTR)).toBe("");
el.remove();
});
it("treats missing data-x/y or baseline attributes as 0", () => {
const el = makeElement({ "data-x": "40", [EDIT_BASE_X_ATTR]: "0" });
applyPositionEdits(document);
expect(el.style.getPropertyValue("translate")).toBe("40px 0px");
el.remove();
});
it("composes with a pre-existing inline translate and stays idempotent", () => {
const el = makeElement(
{ "data-x": "10", "data-y": "20", [EDIT_BASE_X_ATTR]: "0", [EDIT_BASE_Y_ATTR]: "0" },
"translate: 5px 6px",
);
applyPositionEdits(document);
expect(el.style.getPropertyValue("translate")).toBe("15px 26px");
expect(el.getAttribute(EDIT_ORIGINAL_TRANSLATE_ATTR)).toBe("5px 6px");
// Second application must not compound.
applyPositionEdits(document);
expect(el.style.getPropertyValue("translate")).toBe("15px 26px");
el.remove();
});
it("recomputes from the same baseline after data-x changes", () => {
const el = makeElement({
"data-x": "10",
"data-y": "0",
[EDIT_BASE_X_ATTR]: "0",
[EDIT_BASE_Y_ATTR]: "0",
});
applyPositionEdits(document);
expect(el.style.getPropertyValue("translate")).toBe("10px 0px");
el.setAttribute("data-x", "70");
applyPositionEdits(document);
expect(el.style.getPropertyValue("translate")).toBe("70px 0px");
el.remove();
});
it("never re-captures the original translate once set", () => {
const el = makeElement({
"data-x": "10",
"data-y": "0",
[EDIT_BASE_X_ATTR]: "0",
[EDIT_BASE_Y_ATTR]: "0",
[EDIT_ORIGINAL_TRANSLATE_ATTR]: "3px 4px",
});
applyPositionEdits(document);
expect(el.style.getPropertyValue("translate")).toBe("13px 4px");
el.remove();
});
it("counts and applies multiple marked elements", () => {
const a = makeElement({ "data-x": "1", [EDIT_BASE_X_ATTR]: "0" });
const b = makeElement({ "data-y": "2", [EDIT_BASE_Y_ATTR]: "0" });
expect(applyPositionEdits(document)).toBe(2);
a.remove();
b.remove();
});
it("skips re-apply when the written translate was consumed externally (GSAP fold)", () => {
const el = makeElement({
"data-x": "10",
"data-y": "20",
[EDIT_BASE_X_ATTR]: "0",
[EDIT_BASE_Y_ATTR]: "0",
});
applyPositionEdits(document);
expect(el.style.getPropertyValue("translate")).toBe("10px 20px");
// GSAP folding the translate into its cached transform writes "none".
el.style.setProperty("translate", "none");
applyPositionEdits(document);
// Re-setting would double the offset on non-animated axes — must skip.
expect(el.style.getPropertyValue("translate")).toBe("none");
el.remove();
});
it("force re-applies over a clobbered translate (editor commit path)", () => {
const el = makeElement({
"data-x": "10",
"data-y": "20",
[EDIT_BASE_X_ATTR]: "0",
[EDIT_BASE_Y_ATTR]: "0",
});
applyPositionEditToElement(el);
// A drag draft overwrites the translate; the commit must recompute.
el.style.setProperty("translate", "999px 999px");
el.setAttribute("data-x", "30");
applyPositionEditToElement(el, { force: true });
expect(el.style.getPropertyValue("translate")).toBe("30px 20px");
el.remove();
});
});