diff --git a/docs/sdk/guides/canvas-integration.mdx b/docs/sdk/guides/canvas-integration.mdx
index a60a912cdf..feea098842 100644
--- a/docs/sdk/guides/canvas-integration.mdx
+++ b/docs/sdk/guides/canvas-integration.mdx
@@ -86,7 +86,7 @@ iframeDoc.addEventListener("click", (e) => {
The draft loop keeps the model clean during a drag. The SDK is **not** in the 60fps path — you call `preview.applyDraft` on every `pointermove` and `preview.commitPreview` once on `pointerup`. The model sees exactly one `moveElement` op per drag, rather than hundreds.
-`applyDraft` writes CSS custom properties (`--hf-studio-dx`, `--hf-studio-dy`) directly onto the target element inside the iframe. The composition's CSS uses these vars to translate the element visually. Nothing in the SDK model changes.
+`applyDraft` sets the element's CSS `translate` directly inside the iframe — the pre-drag value composed with the accumulated delta — so the drag is visible in any composition without composition-side CSS, and works on GSAP-animated elements (a `translate` set after GSAP's first parse composes with the animated transform instead of being overwritten). Nothing in the SDK model changes. `cancelPreview` restores the pre-drag translate; `commitPreview` derives one `moveElement` op and mirrors the committed position onto the live element.
```typescript
let dragging = false;
diff --git a/docs/sdk/reference/adapters.mdx b/docs/sdk/reference/adapters.mdx
index 8a8666dc8c..87e149e71c 100644
--- a/docs/sdk/reference/adapters.mdx
+++ b/docs/sdk/reference/adapters.mdx
@@ -100,15 +100,15 @@ interface PreviewAdapter {
- Applies draft CSS markers to the preview element at 60fps during a drag. Writes CSS custom properties (`--hf-studio-dx`, `--hf-studio-dy`) onto the element so the composition's CSS can visually translate it without touching the model. The **SDK is not called here** — this is a direct write to the preview surface by your pointer-move handler.
+ Visually translates the preview element at 60fps during a drag: sets the element's CSS `translate` to its pre-drag value composed with the accumulated delta. Works on GSAP-animated elements (a `translate` set after GSAP's first parse composes with the animated transform). The **SDK is not called here** — this is a direct write to the preview surface by your pointer-move handler. Switching `id` mid-drag reverts the previous element's draft first.
- Called once on pointer-up. Reads the accumulated draft markers, derives a `moveElement` op from them, dispatches it into the SDK, emits a patch event, and clears the markers. This is the only moment the SDK becomes aware of a drag.
+ Called once on pointer-up. Reads the accumulated draft delta, derives a `moveElement` op from it, dispatches it into the SDK, emits a patch event, and mirrors the committed position onto the live element (so it holds without a reload). This is the only moment the SDK becomes aware of a drag. If dispatch throws, the draft translate is reverted and the error propagates.
- Reverts the draft CSS markers without dispatching any op. The model is never changed. Call this on `Escape` keydown or when a drag is aborted.
+ Restores the element's pre-drag `translate` without dispatching any op. The model is never changed. Call this on `Escape` keydown or when a drag is aborted.
diff --git a/packages/core/package.json b/packages/core/package.json
index 3893ed6139..0cebfd5274 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -95,6 +95,12 @@
"import": "./src/runtime/clipTree.ts",
"types": "./src/runtime/clipTree.ts"
},
+ "./runtime/position-edits": {
+ "bun": "./src/runtime/positionEdits.ts",
+ "node": "./dist/runtime/positionEdits.js",
+ "import": "./src/runtime/positionEdits.ts",
+ "types": "./src/runtime/positionEdits.ts"
+ },
"./runtime/lottie-readiness": {
"bun": "./src/lottieReadiness.ts",
"node": "./dist/lottieReadiness.js",
@@ -259,6 +265,10 @@
"import": "./dist/runtime/clipTree.js",
"types": "./dist/runtime/clipTree.d.ts"
},
+ "./runtime/position-edits": {
+ "import": "./dist/runtime/positionEdits.js",
+ "types": "./dist/runtime/positionEdits.d.ts"
+ },
"./runtime/lottie-readiness": {
"import": "./dist/lottieReadiness.js",
"types": "./dist/lottieReadiness.d.ts"
diff --git a/packages/core/src/runtime/analytics.ts b/packages/core/src/runtime/analytics.ts
index 119aa2aeef..795c20e1f5 100644
--- a/packages/core/src/runtime/analytics.ts
+++ b/packages/core/src/runtime/analytics.ts
@@ -12,7 +12,8 @@ export type RuntimeAnalyticsEvent =
| "composition_paused"
| "composition_seeked"
| "composition_ended"
- | "element_picked";
+ | "element_picked"
+ | "position_edit_fold_skipped";
export type RuntimeAnalyticsProperties = Record;
diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts
index c045ada4d8..3211a069c5 100644
--- a/packages/core/src/runtime/init.ts
+++ b/packages/core/src/runtime/init.ts
@@ -28,6 +28,7 @@ import { createRuntimeStartTimeResolver } from "./startResolver";
import { createClipTree } from "./clipTree";
import { loadExternalCompositions, loadInlineTemplateCompositions } from "./compositionLoader";
import { applyCaptionOverrides } from "./captionOverrides";
+import { applyPositionEdits } from "./positionEdits";
import { createColorGradingRuntime, type RuntimeColorGradingApi } from "./colorGrading";
import { TransportClock } from "./clock";
import { WebAudioTransport } from "./webAudioTransport";
@@ -72,6 +73,12 @@ function resolveExportRenderFps(): ExportRenderFpsResolution {
export function initSandboxRuntimeModular(): void {
const state = createRuntimeState();
+ // SDK moveElement edits must render even when no usable GSAP timeline ever
+ // binds (CSS/WAAPI-animated or fully static compositions) — apply at init.
+ // This runs at DOMContentLoaded, after inline composition scripts have
+ // parsed their tweens, so GSAP (when present) won't fold the translate.
+ // Re-applied on every timeline bind for the rebind/soft-reload paths.
+ applyPositionEdits(document);
const exportRenderFps = resolveExportRenderFps();
state.canonicalFps = exportRenderFps.fps ?? state.canonicalFps;
if (window.__HF_EXPORT_RENDER_SEEK_CONFIG) {
@@ -1189,6 +1196,12 @@ export function initSandboxRuntimeModular(): void {
// during initial rebind (timing race on first load / soft reload).
const applyFn = (window as Record).__hfStudioManualEditsApply;
if (typeof applyFn === "function") applyFn();
+
+ // SDK moveElement edits (data-hf-edit-base-x/y markers) render as a
+ // CSS translate delta. Must run after the timeline is bound so GSAP has
+ // already parsed the elements — a translate present at first parse gets
+ // folded into the cached transform and lost per-axis on seek.
+ applyPositionEdits(document);
}
if (resolution.diagnostics) {
postRuntimeMessage({
diff --git a/packages/core/src/runtime/positionEdits.test.ts b/packages/core/src/runtime/positionEdits.test.ts
new file mode 100644
index 0000000000..d7eae7343c
--- /dev/null
+++ b/packages/core/src/runtime/positionEdits.test.ts
@@ -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, 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();
+ });
+});
diff --git a/packages/core/src/runtime/positionEdits.ts b/packages/core/src/runtime/positionEdits.ts
new file mode 100644
index 0000000000..0fabec7635
--- /dev/null
+++ b/packages/core/src/runtime/positionEdits.ts
@@ -0,0 +1,167 @@
+// fallow-ignore-file code-duplication
+// (splitTopLevelWhitespace intentionally mirrors the studio-side copies in
+// manualEditsDom.ts / manualEditsRenderScript.ts — this module ships inside
+// the self-contained runtime bundle and cannot import studio code.)
+/**
+ * Editor position edits (SDK `moveElement`) applied at render time.
+ *
+ * The SDK's `moveElement` writes `data-x` / `data-y` plus a captured baseline
+ * (`data-hf-edit-base-x` / `data-hf-edit-base-y` — the values before the first
+ * edit). The runtime renders the edit as the DELTA between the two, via the
+ * independent CSS `translate` longhand, so it composes additively with any
+ * position the composition itself produces (GSAP tweens, `tl.set`, CSS).
+ *
+ * Why `translate` and why after timeline bind: GSAP folds a `translate` that
+ * is present when it FIRST parses an element into its cached transform (and
+ * an absolute tween then discards it on the animated axis — the per-axis loss
+ * bug). A `translate` set AFTER that parse is never read, cleared, or baked
+ * by GSAP 3.x on subsequent seeks, so a single application at bind time holds
+ * for the whole timeline. Before the first apply, the element's transform
+ * parse is primed (gsap.getProperty) so tweens and positioned set()s that
+ * first RENDER later reuse the cache instead of folding the edit. Known
+ * limitation: if GSAP itself loads only after the apply ran, a later tween's
+ * first parse still folds the edit (the fold guard then skips re-apply and
+ * emits position_edit_fold_skipped instead of double-applying).
+ */
+
+import { emitAnalyticsEvent } from "./analytics";
+
+export const EDIT_BASE_X_ATTR = "data-hf-edit-base-x";
+export const EDIT_BASE_Y_ATTR = "data-hf-edit-base-y";
+export const EDIT_ORIGINAL_TRANSLATE_ATTR = "data-hf-edit-original-translate";
+
+const num = (value: string | null): number => {
+ const n = parseFloat(value ?? "");
+ return Number.isFinite(n) ? n : 0;
+};
+
+/** Split "10px 20px" / "calc(1px + 2px) 3px" on top-level whitespace only. */
+const splitTopLevelWhitespace = (value: string): string[] => {
+ const parts: string[] = [];
+ let depth = 0;
+ let current = "";
+ for (const char of value.trim()) {
+ if (char === "(") depth += 1;
+ if (char === ")") depth = Math.max(0, depth - 1);
+ if (/\s/.test(char) && depth === 0) {
+ if (current) parts.push(current);
+ current = "";
+ } else {
+ current += char;
+ }
+ }
+ if (current) parts.push(current);
+ return parts;
+};
+
+const PX_VALUE = /^-?(?:\d+(?:\.\d+)?|\.\d+)px$/;
+
+/** Sum two lengths — numerically when both are plain px, via calc() otherwise. */
+const addLengths = (a: string, b: string): string => {
+ if (PX_VALUE.test(a) && PX_VALUE.test(b)) return `${parseFloat(a) + parseFloat(b)}px`;
+ return `calc(${a} + ${b})`;
+};
+
+/** Compose the edit delta with the element's pre-edit translate value. */
+export const composeTranslate = (original: string, x: string, y: string): string => {
+ if (!original || original === "none") return `${x} ${y}`;
+ const [ox, oy, oz] = splitTopLevelWhitespace(original);
+ if (ox === undefined) return `${x} ${y}`;
+ if (oy === undefined) return `${addLengths(ox, x)} ${y}`;
+ const z = oz === undefined ? "" : ` ${oz}`;
+ return `${addLengths(ox, x)} ${addLengths(oy, y)}${z}`;
+};
+
+/**
+ * Force GSAP (when present) to parse and cache the element's transform BEFORE
+ * the edit translate is written. GSAP folds a CSS `translate` it sees at an
+ * element's first parse into its cached transform (losing it per-axis on
+ * absolute tweens); once the cache exists, later tweens and positioned set()s
+ * reuse it and never read the translate again. gsap.getProperty parses
+ * without mutating the element. Best-effort — absent or failing GSAP is fine.
+ */
+const primeGsapTransformCache = (el: HTMLElement): void => {
+ try {
+ const view = el.ownerDocument.defaultView as
+ | (Window & { gsap?: { getProperty?: (t: Element, p: string) => unknown } })
+ | null;
+ view?.gsap?.getProperty?.(el, "x");
+ } catch {
+ // parse priming is an optimization, never a requirement
+ }
+};
+
+/** The element's effective translate: inline if set, computed otherwise ("" = none). */
+export const readCurrentTranslate = (el: HTMLElement): string => {
+ const inline = el.style.getPropertyValue("translate").trim();
+ if (inline) return inline === "none" ? "" : inline;
+ try {
+ const view = el.ownerDocument.defaultView;
+ const computed = view ? view.getComputedStyle(el).getPropertyValue("translate").trim() : "";
+ return computed === "none" ? "" : computed;
+ } catch {
+ return "";
+ }
+};
+
+/**
+ * The translate value this module last wrote per element. When a re-apply
+ * (timeline rebind) finds the element's inline translate no longer matching,
+ * something else consumed it — in practice GSAP folding it into the cached
+ * transform when a lazily-created tween first-parsed the element. Re-setting
+ * it then would DOUBLE the offset on every axis the tween doesn't animate, so
+ * the non-forced path skips instead (degrading to the documented fold-loss).
+ */
+const lastAppliedTranslate = new WeakMap();
+
+/**
+ * Apply one element's position edit. Idempotent — the pre-edit translate is
+ * captured exactly once (into EDIT_ORIGINAL_TRANSLATE_ATTR, empty string
+ * meaning "none") on first application, and every application recomputes from
+ * that baseline.
+ *
+ * `force` re-applies even when the previously written translate was clobbered
+ * externally — used by editor commits, where the current inline translate is
+ * the draft-composed one and must be overwritten.
+ */
+export function applyPositionEditToElement(el: HTMLElement, opts?: { force?: boolean }): void {
+ const previous = lastAppliedTranslate.get(el);
+ if (
+ !opts?.force &&
+ previous !== undefined &&
+ el.style.getPropertyValue("translate") !== previous
+ ) {
+ // Observable signal for the documented degradation — without it, a
+ // fold-loss surfaces to users only as "my edit didn't stick".
+ emitAnalyticsEvent("position_edit_fold_skipped", {
+ hfId: el.getAttribute("data-hf-id"),
+ });
+ return;
+ }
+ const dx = num(el.getAttribute("data-x")) - num(el.getAttribute(EDIT_BASE_X_ATTR));
+ const dy = num(el.getAttribute("data-y")) - num(el.getAttribute(EDIT_BASE_Y_ATTR));
+ if (!el.hasAttribute(EDIT_ORIGINAL_TRANSLATE_ATTR)) {
+ el.setAttribute(EDIT_ORIGINAL_TRANSLATE_ATTR, readCurrentTranslate(el));
+ }
+ if (previous === undefined) primeGsapTransformCache(el);
+ const original = el.getAttribute(EDIT_ORIGINAL_TRANSLATE_ATTR) ?? "";
+ const value = composeTranslate(original, `${dx}px`, `${dy}px`);
+ el.style.setProperty("translate", value);
+ lastAppliedTranslate.set(el, el.style.getPropertyValue("translate"));
+}
+
+/**
+ * Apply all pending position edits in the document. Returns the number of
+ * elements updated.
+ */
+export function applyPositionEdits(doc: Document): number {
+ const marked = doc.querySelectorAll(`[${EDIT_BASE_X_ATTR}], [${EDIT_BASE_Y_ATTR}]`);
+ let applied = 0;
+ for (let i = 0; i < marked.length; i++) {
+ const el = marked[i];
+ if (!(el instanceof HTMLElement)) continue;
+ applyPositionEditToElement(el);
+ applied += 1;
+ }
+ return applied;
+}
diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json
index e400fc1acc..bf6eb101e7 100644
--- a/packages/core/tsconfig.json
+++ b/packages/core/tsconfig.json
@@ -13,7 +13,11 @@
"outDir": "./dist",
"rootDir": "./src"
},
- "files": ["src/runtime/clipTree.ts", "src/runtime/mediaVolumeEnvelope.ts"],
+ "files": [
+ "src/runtime/clipTree.ts",
+ "src/runtime/mediaVolumeEnvelope.ts",
+ "src/runtime/positionEdits.ts"
+ ],
"include": ["src/**/*"],
"exclude": [
"node_modules",
diff --git a/packages/engine/scripts/test-runtime-position-edits-browser.ts b/packages/engine/scripts/test-runtime-position-edits-browser.ts
new file mode 100644
index 0000000000..048d5243ef
--- /dev/null
+++ b/packages/engine/scripts/test-runtime-position-edits-browser.ts
@@ -0,0 +1,271 @@
+// fallow-ignore-file unused-file code-duplication complexity
+/**
+ * Browser acceptance test: SDK moveElement edits survive GSAP animation
+ * per-axis (the AI Studio embedded-editor per-axis loss bug).
+ *
+ * Launches headless Chrome with real GSAP + the real runtime IIFE, loads a
+ * fixture whose elements carry committed moveElement state (data-x/data-y +
+ * data-hf-edit-base-x/y), seeks the timeline across its range, and asserts
+ * the rendered position reflects the edit on BOTH axes at every sample:
+ * - an X-animated element keeps its edited offset while X animates
+ * - a Y-animated element keeps its edited offset while Y animates
+ * - a static element keeps both
+ *
+ * Runs in the plain embedded runtime — no Studio shell, no manual-edits
+ * render script — matching what third-party SDK consumers load.
+ *
+ * Requires: puppeteer + gsap (monorepo deps, dynamically resolved; skips
+ * with a notice when unavailable).
+ * Run: cd packages/engine && npx tsx scripts/test-runtime-position-edits-browser.ts
+ */
+
+import { createRequire } from "node:module";
+import { readFileSync } from "node:fs";
+import { resolve as resolvePath, dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+import { buildHyperframesRuntimeScript } from "../../core/src/inline-scripts/hyperframesRuntime.engine";
+
+const thisDir = dirname(fileURLToPath(import.meta.url));
+
+function assert(condition: unknown, message: string): void {
+ if (!condition) {
+ throw new Error(`FAIL: ${message}`);
+ }
+}
+
+function loadGsapSource(): string | null {
+ const req = createRequire(import.meta.url);
+ // gsap is a dep of studio / player / sdk-playground, hoisted in the
+ // workspace store — resolve through whichever package has it.
+ for (const pkg of ["studio", "player", "sdk-playground"]) {
+ try {
+ const path = req.resolve("gsap/dist/gsap.min.js", {
+ paths: [resolvePath(thisDir, `../../${pkg}`)],
+ });
+ return readFileSync(path, "utf8");
+ } catch {
+ // try the next package
+ }
+ }
+ return null;
+}
+
+interface Sample {
+ t: number;
+ ax: { x: number; y: number };
+ ay: { x: number; y: number };
+ axy: { x: number; y: number };
+ ts: { x: number; y: number };
+ st: { x: number; y: number };
+}
+
+async function main(): Promise {
+ let puppeteer;
+ try {
+ puppeteer = (await import("puppeteer")).default;
+ } catch {
+ console.log(
+ JSON.stringify({
+ event: "runtime_position_edits_browser_test_skipped",
+ reason: "puppeteer not available",
+ }),
+ );
+ return;
+ }
+
+ const gsapSource = loadGsapSource();
+ if (gsapSource === null) {
+ console.log(
+ JSON.stringify({
+ event: "runtime_position_edits_browser_test_skipped",
+ reason: "gsap not available",
+ }),
+ );
+ return;
+ }
+
+ const runtimeSource = buildHyperframesRuntimeScript({ minify: false });
+ assert(
+ runtimeSource !== null,
+ "buildHyperframesRuntimeScript returned null — entry.ts not found",
+ );
+
+ // Committed moveElement state: every element moved by (50, -70). data-x/y
+ // hold the post-edit values; data-hf-edit-base-x/y hold the pre-edit ones
+ // (absent → "0"), exactly as handleMoveElement serializes them.
+ const html = `
+
+
+
+
+
+`;
+
+ const browser = await puppeteer.launch({
+ headless: true,
+ args: ["--no-sandbox", "--disable-setuid-sandbox"],
+ });
+
+ try {
+ const page = await browser.newPage();
+ await page.setContent(html, { waitUntil: "networkidle0", timeout: 10000 });
+
+ // The runtime applies position edits after it binds the timeline — wait
+ // for the translate to land on a marked element. String form: tsx/esbuild
+ // injects a __name helper into serialized closures that the page lacks.
+ await page.waitForFunction(
+ `(function () {
+ var el = document.getElementById("ax");
+ return el !== null && getComputedStyle(el).translate !== "none";
+ })()`,
+ { timeout: 10000 },
+ );
+
+ const sampleScript = `(function (time) {
+ if (window.__player && typeof window.__player.renderSeek === "function") {
+ window.__player.renderSeek(time);
+ } else if (window.__hf && typeof window.__hf.seek === "function") {
+ window.__hf.seek(time);
+ } else {
+ throw new Error("no runtime seek surface (__player.renderSeek / __hf.seek)");
+ }
+ function read(id) {
+ var el = document.getElementById(id);
+ if (!el) throw new Error("missing #" + id);
+ var cs = getComputedStyle(el);
+ var m = new DOMMatrix(cs.transform === "none" ? "" : cs.transform);
+ var parts = cs.translate === "none" ? [] : cs.translate.split(" ");
+ var tx = parts.length > 0 ? parseFloat(parts[0]) : 0;
+ var ty = parts.length > 1 ? parseFloat(parts[1]) : 0;
+ return { x: m.m41 + tx, y: m.m42 + ty };
+ }
+ return {
+ t: time,
+ ax: read("ax"),
+ ay: read("ay"),
+ axy: read("axy"),
+ ts: read("ts"),
+ st: read("st"),
+ };
+ })`;
+
+ const samples: Sample[] = [];
+ for (const t of [0, 1, 2.5, 4]) {
+ const sample = (await page.evaluate(`(${sampleScript})(${t})`)) as Sample;
+ samples.push(sample);
+ }
+
+ const close = (actual: number, expected: number): boolean => Math.abs(actual - expected) <= 0.5;
+
+ for (const s of samples) {
+ // X-animated: x = animation (100·t) + edit (50); y = edit (−70).
+ assert(
+ close(s.ax.x, 100 * s.t + 50),
+ `t=${s.t}: X-animated element x should be ${100 * s.t + 50}, got ${s.ax.x}`,
+ );
+ assert(close(s.ax.y, -70), `t=${s.t}: X-animated element y should keep -70, got ${s.ax.y}`);
+ // Y-animated: y = animation (75·t) + edit (−70); x = edit (50).
+ assert(close(s.ay.x, 50), `t=${s.t}: Y-animated element x should keep 50, got ${s.ay.x}`);
+ assert(
+ close(s.ay.y, 75 * s.t - 70),
+ `t=${s.t}: Y-animated element y should be ${75 * s.t - 70}, got ${s.ay.y}`,
+ );
+ // Both-axis-animated: both axes = animation + edit (the shape that
+ // originated the per-axis loss bug).
+ assert(
+ close(s.axy.x, 100 * s.t + 50),
+ `t=${s.t}: XY-animated element x should be ${100 * s.t + 50}, got ${s.axy.x}`,
+ );
+ assert(
+ close(s.axy.y, 75 * s.t - 70),
+ `t=${s.t}: XY-animated element y should be ${75 * s.t - 70}, got ${s.axy.y}`,
+ );
+ // tl.set at t=1.0: before it fires, position = edit only; after, set + edit.
+ const setX = s.t >= 1.0 ? 200 : 0;
+ const setY = s.t >= 1.0 ? 100 : 0;
+ assert(
+ close(s.ts.x, setX + 50),
+ `t=${s.t}: tl.set element x should be ${setX + 50}, got ${s.ts.x}`,
+ );
+ assert(
+ close(s.ts.y, setY - 70),
+ `t=${s.t}: tl.set element y should be ${setY - 70}, got ${s.ts.y}`,
+ );
+ // Static: both axes hold the edit.
+ assert(close(s.st.x, 50), `t=${s.t}: static element x should be 50, got ${s.st.x}`);
+ assert(close(s.st.y, -70), `t=${s.t}: static element y should be -70, got ${s.st.y}`);
+ }
+
+ // GSAP-free composition: no window.gsap, no timelines — the edit must
+ // still render (applied at runtime init, not only at timeline bind).
+ const gsapFreeHtml = `
+
+
+
+`;
+
+ const page2 = await browser.newPage();
+ await page2.setContent(gsapFreeHtml, { waitUntil: "networkidle0", timeout: 10000 });
+ await page2.waitForFunction(
+ `(function () {
+ var el = document.getElementById("st");
+ return el !== null && getComputedStyle(el).translate !== "none";
+ })()`,
+ { timeout: 10000 },
+ );
+ const gsapFree = (await page2.evaluate(
+ `(function () {
+ var cs = getComputedStyle(document.getElementById("st"));
+ return { translate: cs.translate };
+ })()`,
+ )) as { translate: string };
+ assert(
+ gsapFree.translate === "50px -70px",
+ `GSAP-free composition should render the edit as translate 50px -70px, got ${gsapFree.translate}`,
+ );
+
+ console.log(
+ JSON.stringify({
+ event: "runtime_position_edits_browser_test_passed",
+ samples,
+ gsapFree,
+ }),
+ );
+ } finally {
+ await browser.close();
+ }
+}
+
+main().catch((err) => {
+ console.error(err.message);
+ process.exit(1);
+});
diff --git a/packages/sdk/src/adapters/iframe.test.ts b/packages/sdk/src/adapters/iframe.test.ts
index e274800ae5..2d7c1afb7a 100644
--- a/packages/sdk/src/adapters/iframe.test.ts
+++ b/packages/sdk/src/adapters/iframe.test.ts
@@ -218,12 +218,12 @@ interface FakeStyle {
}
interface FakeDomEl {
- "data-hf-id": string;
- "data-x": string | null;
- "data-y": string | null;
+ _attrs: Record;
style: FakeStyle;
isConnected: boolean;
getAttribute(name: string): string | null;
+ setAttribute(name: string, value: string): void;
+ hasAttribute(name: string): boolean;
querySelector(sel: string): FakeDomEl | null;
}
@@ -240,17 +240,21 @@ function fakeDomEl(id: string, dataX: string | null, dataY: string | null): Fake
delete this._props[name];
},
};
+ const attrs: Record = { "data-hf-id": id };
+ if (dataX !== null) attrs["data-x"] = dataX;
+ if (dataY !== null) attrs["data-y"] = dataY;
const el: FakeDomEl = {
- "data-hf-id": id,
- "data-x": dataX,
- "data-y": dataY,
+ _attrs: attrs,
style,
isConnected: true,
getAttribute(name) {
- if (name === "data-x") return this["data-x"];
- if (name === "data-y") return this["data-y"];
- if (name === "data-hf-id") return this["data-hf-id"];
- return null;
+ return this._attrs[name] ?? null;
+ },
+ setAttribute(name, value) {
+ this._attrs[name] = value;
+ },
+ hasAttribute(name) {
+ return name in this._attrs;
},
querySelector(_sel: string) {
return null;
@@ -316,6 +320,55 @@ describe("IframePreviewAdapter draft / commit / cancel", () => {
});
});
+ it("commitPreview mirrors the move onto the live element and applies the translate", () => {
+ const el = fakeDomEl("hf-abc", "100", "200");
+ const adapter = createIframePreviewAdapter(fakeIframe(el), vi.fn());
+
+ adapter.applyDraft("hf-abc", { dx: 30, dy: -20 });
+ adapter.commitPreview();
+
+ expect(el.getAttribute("data-x")).toBe("130");
+ expect(el.getAttribute("data-y")).toBe("180");
+ // Baseline captured from the pre-drag values.
+ expect(el.getAttribute("data-hf-edit-base-x")).toBe("100");
+ expect(el.getAttribute("data-hf-edit-base-y")).toBe("200");
+ // Final translate = delta from the baseline, held without a reload.
+ expect(el.getAttribute("data-hf-edit-original-translate")).toBe("");
+ expect(el.style.getPropertyValue("translate")).toBe("30px -20px");
+
+ // A second drag composes from the committed state and keeps the baseline.
+ adapter.applyDraft("hf-abc", { dx: 10, dy: 10 });
+ expect(el.style.getPropertyValue("translate")).toBe("40px -10px");
+ adapter.commitPreview();
+ expect(el.getAttribute("data-x")).toBe("140");
+ expect(el.getAttribute("data-hf-edit-base-x")).toBe("100");
+ expect(el.style.getPropertyValue("translate")).toBe("40px -10px");
+ });
+
+ it("applyDraft translates the element live and cancelPreview restores it", () => {
+ const el = fakeDomEl("hf-abc", "0", "0");
+ el.style.setProperty("translate", "5px 6px");
+ const adapter = createIframePreviewAdapter(fakeIframe(el), vi.fn());
+
+ adapter.applyDraft("hf-abc", { dx: 30, dy: -20 });
+ expect(el.style.getPropertyValue("translate")).toBe("35px -14px");
+
+ adapter.cancelPreview();
+ expect(el.style.getPropertyValue("translate")).toBe("5px 6px");
+ expect(el.getAttribute("data-hf-edit-base-x")).toBeNull();
+ });
+
+ it("cancelPreview removes a draft translate when there was none before", () => {
+ const el = fakeDomEl("hf-abc", "0", "0");
+ const adapter = createIframePreviewAdapter(fakeIframe(el), vi.fn());
+
+ adapter.applyDraft("hf-abc", { dx: 30 });
+ expect(el.style.getPropertyValue("translate")).toBe("30px 0px");
+
+ adapter.cancelPreview();
+ expect(el.style.getPropertyValue("translate")).toBe("");
+ });
+
it("applyDraft reuses the cached element across repeated calls (no re-query)", () => {
const el = fakeDomEl("hf-abc", "0", "0");
let queryCount = 0;
@@ -344,42 +397,83 @@ describe("IframePreviewAdapter draft / commit / cancel", () => {
adapter.commitPreview();
});
- it("cancelPreview clears draft vars without dispatching", () => {
+ it("cancelPreview reverts the draft translate without dispatching", () => {
const dispatch = vi.fn();
const el = fakeDomEl("hf-abc", "100", "200");
const adapter = createIframePreviewAdapter(fakeIframe(el), dispatch);
adapter.applyDraft("hf-abc", { dx: 30, dy: 20 });
+ expect(el.style.getPropertyValue("translate")).toBe("30px 20px");
adapter.cancelPreview();
expect(dispatch).not.toHaveBeenCalled();
- // CSS vars cleared
- expect(el.style.getPropertyValue("--hf-studio-dx")).toBe("");
- expect(el.style.getPropertyValue("--hf-studio-dy")).toBe("");
+ expect(el.style.getPropertyValue("translate")).toBe("");
});
- it("commitPreview clears draft vars after dispatching", () => {
+ it("second commitPreview after first is a no-op (draft cleared)", () => {
const dispatch = vi.fn();
const el = fakeDomEl("hf-abc", "0", "0");
const adapter = createIframePreviewAdapter(fakeIframe(el), dispatch);
adapter.applyDraft("hf-abc", { dx: 10, dy: 5 });
adapter.commitPreview();
+ adapter.commitPreview();
- expect(el.style.getPropertyValue("--hf-studio-dx")).toBe("");
- expect(el.style.getPropertyValue("--hf-studio-dy")).toBe("");
+ expect(dispatch).toHaveBeenCalledTimes(1);
});
- it("second commitPreview after first is a no-op (draft cleared)", () => {
- const dispatch = vi.fn();
+ it("switching applyDraft to a new id reverts the abandoned element", () => {
+ const elA = fakeDomEl("hf-a", "0", "0");
+ const elB = fakeDomEl("hf-b", "0", "0");
+ const iframe = {
+ contentDocument: {
+ querySelector(sel: string) {
+ return sel.includes("hf-a") ? elA : elB;
+ },
+ },
+ } as unknown as HTMLIFrameElement;
+ const adapter = createIframePreviewAdapter(iframe, vi.fn());
+
+ adapter.applyDraft("hf-a", { dx: 80, dy: 0 });
+ expect(elA.style.getPropertyValue("translate")).toBe("80px 0px");
+
+ adapter.applyDraft("hf-b", { dx: 10, dy: 10 });
+ // The abandoned element is restored; the delta does not carry over.
+ expect(elA.style.getPropertyValue("translate")).toBe("");
+ expect(elB.style.getPropertyValue("translate")).toBe("10px 10px");
+ });
+
+ it("commitPreview reverts the draft translate when dispatch throws", () => {
const el = fakeDomEl("hf-abc", "0", "0");
+ el.style.setProperty("translate", "5px 6px");
+ const dispatch = vi.fn(() => {
+ throw new Error("element_not_found");
+ });
const adapter = createIframePreviewAdapter(fakeIframe(el), dispatch);
- adapter.applyDraft("hf-abc", { dx: 10, dy: 5 });
- adapter.commitPreview();
- adapter.commitPreview();
+ adapter.applyDraft("hf-abc", { dx: 30, dy: 20 });
+ expect(() => adapter.commitPreview()).toThrow("element_not_found");
+ expect(el.style.getPropertyValue("translate")).toBe("5px 6px");
+ expect(el.getAttribute("data-hf-edit-base-x")).toBeNull();
+ });
- expect(dispatch).toHaveBeenCalledTimes(1);
+ it("cancelPreview does not promote a computed (stylesheet) translate to inline", () => {
+ const el = fakeDomEl("hf-abc", "0", "0");
+ // Simulate a stylesheet-authored translate visible only via computed style.
+ (el as unknown as { ownerDocument: unknown }).ownerDocument = {
+ defaultView: {
+ getComputedStyle: () => ({ getPropertyValue: () => "-50% -50%" }),
+ },
+ };
+ const adapter = createIframePreviewAdapter(fakeIframe(el), vi.fn());
+
+ adapter.applyDraft("hf-abc", { dx: 30, dy: 20 });
+ // Draft composes onto the computed baseline (calc for non-px units).
+ expect(el.style.getPropertyValue("translate")).toBe("calc(-50% + 30px) calc(-50% + 20px)");
+
+ adapter.cancelPreview();
+ // Inline translate removed — the stylesheet value stays authoritative.
+ expect(el.style.getPropertyValue("translate")).toBe("");
});
});
diff --git a/packages/sdk/src/adapters/iframe.ts b/packages/sdk/src/adapters/iframe.ts
index e45c7acd90..9143e17461 100644
--- a/packages/sdk/src/adapters/iframe.ts
+++ b/packages/sdk/src/adapters/iframe.ts
@@ -28,14 +28,17 @@
* here — gated on a perf spike.
*/
+import {
+ EDIT_BASE_X_ATTR,
+ EDIT_BASE_Y_ATTR,
+ EDIT_ORIGINAL_TRANSLATE_ATTR,
+ applyPositionEditToElement,
+ composeTranslate,
+ readCurrentTranslate,
+} from "@hyperframes/core/runtime/position-edits";
import type { PreviewAdapter, ElementAtPointResult, DraftProps } from "./types.js";
import type { EditOp } from "../types.js";
-// ─── CSS var names written onto elements during drag ─────────────────────────
-
-const VAR_DX = "--hf-studio-dx";
-const VAR_DY = "--hf-studio-dy";
-
// ─── Pure resolver (testable without a browser) ───────────────────────────────
/**
@@ -492,6 +495,21 @@ class IframePreviewAdapter implements PreviewAdapter {
/** Tracked id and element for the in-progress drag. */
private _draftId: string | null = null;
private _draftEl: HTMLElement | null = null;
+ /** Accumulated drag deltas from applyDraft calls. */
+ private _draftDx = 0;
+ private _draftDy = 0;
+ /**
+ * The element's effective `translate` when the drag started (inline value,
+ * or computed when no inline one was set; "" = none). Drafts compose onto
+ * this.
+ */
+ private _draftPrevTranslate: string | null = null;
+ /**
+ * The element's raw INLINE `translate` when the drag started ("" = not
+ * inline). Reverts restore exactly this, so a stylesheet-authored translate
+ * is never promoted to a permanent inline style.
+ */
+ private _draftPrevInlineTranslate: string | null = null;
constructor(iframe: HTMLIFrameElement, dispatch?: (op: EditOp) => void) {
this.iframe = iframe;
@@ -543,73 +561,159 @@ class IframePreviewAdapter implements PreviewAdapter {
}
/**
- * Write draft CSS custom properties (`--hf-studio-dx`, `--hf-studio-dy`) onto
- * the target element inside the iframe at 60fps. The composition's CSS uses
- * these vars to visually translate the element without touching the model.
+ * Visually translate the target element inside the iframe at 60fps without
+ * touching the model: sets the element's `translate` to its pre-drag value
+ * composed with the accumulated delta. `translate` set after GSAP's first
+ * parse is untouched by seeks, so this renders correctly on animated
+ * elements too. (The `--hf-studio-dx/dy` custom properties are no longer
+ * written — compositions with the authored Studio drag-bridge CSS would
+ * move by twice the delta if both channels applied.)
*
- * Calling applyDraft with a new id replaces the tracked element (does not
- * cancel the prior draft — call cancelPreview first if switching targets).
+ * Calling applyDraft with a new id switches the tracked element, reverting
+ * the previous element's draft translate first.
*
* width/height in DraftProps are not yet wired (resize → setStyle, future op).
*/
applyDraft(id: string, props: DraftProps): void {
+ const el = this._resolveDraftElement(id);
+ if (!el) return;
+
+ if (props.dx !== undefined) this._draftDx = props.dx;
+ if (props.dy !== undefined) this._draftDy = props.dy;
+
+ el.style.setProperty(
+ "translate",
+ composeTranslate(this._draftPrevTranslate ?? "", `${this._draftDx}px`, `${this._draftDy}px`),
+ );
+ }
+
+ /**
+ * Resolve and track the drag target. Reuses the tracked element across the
+ * 60fps drag; only re-queries when the id changes or the cached node
+ * detached (e.g. an iframe reload mid-drag). Switching to a different
+ * element reverts the previous one's draft first, then captures the new
+ * element's pre-drag translate.
+ */
+ private _resolveDraftElement(id: string): HTMLElement | null {
const doc = this.iframe.contentDocument;
- if (!doc) return;
+ if (!doc) return null;
- // Reuse the tracked element across the 60fps drag; only re-query when the id
- // changes or the cached node detached (e.g. an iframe reload mid-drag).
const cached = id === this._draftId && this._draftEl?.isConnected ? this._draftEl : null;
const el =
cached ??
doc.querySelector(
`[data-hf-id="${id.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]`,
);
- if (!el) return;
-
+ if (!el) return null;
+
+ if (el !== this._draftEl) {
+ // Abandoning a prior target mid-drag must not leave it displaced.
+ this._revertDraftTranslate();
+ this._draftDx = 0;
+ this._draftDy = 0;
+ this._draftPrevTranslate = readCurrentTranslate(el);
+ const inline = el.style.getPropertyValue("translate").trim();
+ this._draftPrevInlineTranslate = inline === "none" ? "" : inline;
+ }
this._draftId = id;
this._draftEl = el;
-
- if (props.dx !== undefined) el.style.setProperty(VAR_DX, String(props.dx));
- if (props.dy !== undefined) el.style.setProperty(VAR_DY, String(props.dy));
+ return el;
}
/**
* Read the accumulated draft deltas, derive a moveElement op, dispatch it,
- * then clear the CSS vars and draft state.
+ * then clear the draft state.
*
- * No-ops when:
+ * No-ops (reverting any draft translate) when:
* - No applyDraft was called (nothing to commit)
* - No dispatch callback was provided at construction
+ *
+ * If dispatch throws (e.g. the model no longer has the element), the draft
+ * translate is reverted and the error propagates — the element is never
+ * left displaced by an uncommitted draft.
*/
commitPreview(): void {
if (!this._draftId || !this._draftEl || !this._dispatch) {
+ this._revertDraftTranslate();
this._clearDraft();
return;
}
const el = this._draftEl;
- const dx = parseFloat(el.style.getPropertyValue(VAR_DX) || "0") || 0;
- const dy = parseFloat(el.style.getPropertyValue(VAR_DY) || "0") || 0;
const dataX = el.getAttribute("data-x");
const dataY = el.getAttribute("data-y");
- const { x, y } = computeDraftPosition(dataX, dataY, dx, dy);
+ const { x, y } = computeDraftPosition(dataX, dataY, this._draftDx, this._draftDy);
- this._dispatch({ type: "moveElement", target: this._draftId, x, y });
+ try {
+ this._dispatch({ type: "moveElement", target: this._draftId, x, y });
+ } catch (err) {
+ this._revertDraftTranslate();
+ this._clearDraft();
+ throw err;
+ }
+ this._mirrorCommittedMove(el, dataX, dataY, x, y);
this._clearDraft();
}
- /** Revert draft CSS vars without dispatching any op. */
+ /**
+ * Mirror a committed move onto the live element so the position holds
+ * without a document reload — same attributes handleMoveElement writes
+ * into the model, rendered by the runtime's position-edit translate.
+ *
+ * The pre-edit translate is stamped from the value captured at drag start
+ * (the element's current inline translate is the draft-composed one, which
+ * must not be mistaken for the original), then the final translate is
+ * recomputed the same way the runtime does at bind time.
+ */
+ private _mirrorCommittedMove(
+ el: HTMLElement,
+ dataX: string | null,
+ dataY: string | null,
+ x: number,
+ y: number,
+ ): void {
+ if (el.getAttribute(EDIT_BASE_X_ATTR) === null) {
+ el.setAttribute(EDIT_BASE_X_ATTR, dataX ?? "0");
+ }
+ if (el.getAttribute(EDIT_BASE_Y_ATTR) === null) {
+ el.setAttribute(EDIT_BASE_Y_ATTR, dataY ?? "0");
+ }
+ if (el.getAttribute(EDIT_ORIGINAL_TRANSLATE_ATTR) === null) {
+ el.setAttribute(EDIT_ORIGINAL_TRANSLATE_ATTR, this._draftPrevTranslate ?? "");
+ }
+ el.setAttribute("data-x", String(x));
+ el.setAttribute("data-y", String(y));
+ applyPositionEditToElement(el, { force: true });
+ }
+
+ /** Revert the draft translate without dispatching any op. */
cancelPreview(): void {
+ this._revertDraftTranslate();
this._clearDraft();
}
- private _clearDraft(): void {
- if (this._draftEl) {
- this._draftEl.style.removeProperty(VAR_DX);
- this._draftEl.style.removeProperty(VAR_DY);
+ /**
+ * Restore the element's pre-drag INLINE `translate` (removing it when there
+ * was none, so a stylesheet-authored translate is never promoted to inline).
+ * NOT called on a successful commit — the committed position-edit translate
+ * is recomputed onto the element by _mirrorCommittedMove.
+ */
+ private _revertDraftTranslate(): void {
+ if (!this._draftEl || this._draftPrevInlineTranslate === null) return;
+ if (this._draftPrevInlineTranslate === "") {
+ this._draftEl.style.removeProperty("translate");
+ } else {
+ this._draftEl.style.setProperty("translate", this._draftPrevInlineTranslate);
}
+ }
+
+ private _clearDraft(): void {
this._draftId = null;
this._draftEl = null;
+ this._draftDx = 0;
+ this._draftDy = 0;
+ this._draftPrevTranslate = null;
+ this._draftPrevInlineTranslate = null;
}
// Selection -----------------------------------------------------------------
diff --git a/packages/sdk/src/engine/mutate.test.ts b/packages/sdk/src/engine/mutate.test.ts
index 82fa26aa4f..1e814d5dab 100644
--- a/packages/sdk/src/engine/mutate.test.ts
+++ b/packages/sdk/src/engine/mutate.test.ts
@@ -974,6 +974,33 @@ describe("moveElement", () => {
expect(el.getAttribute("data-x")).toBe("50");
expect(el.getAttribute("data-y")).toBe("75");
});
+
+ it("captures the pre-edit baseline on first move only", () => {
+ const parsed = fresh();
+ const el = parsed.document.querySelector('[data-hf-id="hf-title"]') as Element;
+ el.setAttribute("data-x", "50");
+ applyOp(parsed, { type: "moveElement", target: "hf-title", x: 100, y: 200 });
+ // Baseline = the values before the first edit (absent data-y → "0").
+ expect(el.getAttribute("data-hf-edit-base-x")).toBe("50");
+ expect(el.getAttribute("data-hf-edit-base-y")).toBe("0");
+ // A second move keeps the original baseline.
+ applyOp(parsed, { type: "moveElement", target: "hf-title", x: 300, y: 400 });
+ expect(el.getAttribute("data-hf-edit-base-x")).toBe("50");
+ expect(el.getAttribute("data-hf-edit-base-y")).toBe("0");
+ expect(el.getAttribute("data-x")).toBe("300");
+ expect(el.getAttribute("data-y")).toBe("400");
+ });
+
+ it("inverse of the first move removes the baseline attributes", () => {
+ const parsed = fresh();
+ const el = parsed.document.querySelector('[data-hf-id="hf-title"]') as Element;
+ const result = applyOp(parsed, { type: "moveElement", target: "hf-title", x: 100, y: 200 });
+ applyPatchesToDocument(parsed, result.inverse);
+ expect(el.getAttribute("data-hf-edit-base-x")).toBeNull();
+ expect(el.getAttribute("data-hf-edit-base-y")).toBeNull();
+ expect(el.getAttribute("data-x")).toBeNull();
+ expect(el.getAttribute("data-y")).toBeNull();
+ });
});
// ─── validateOp (can()) ───────────────────────────────────────────────────────
diff --git a/packages/sdk/src/engine/mutate.ts b/packages/sdk/src/engine/mutate.ts
index 6580506dd1..3f2c5c69f0 100644
--- a/packages/sdk/src/engine/mutate.ts
+++ b/packages/sdk/src/engine/mutate.ts
@@ -51,6 +51,7 @@ import {
} from "./patches.js";
import { upsertCssRule } from "./cssWriter.js";
import { mintHfId, EXCLUDED_TAGS } from "@hyperframes/core/hf-ids";
+import { EDIT_BASE_X_ATTR, EDIT_BASE_Y_ATTR } from "@hyperframes/core/runtime/position-edits";
import { parseGsapScriptAcornForWrite } from "@hyperframes/core/gsap-parser-acorn";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import {
@@ -342,11 +343,34 @@ function handleMoveElement(
): MutationResult {
// HF elements are positioned via data-x / data-y (parsed by htmlParser.ts,
// emitted by hyperframes generator). CSS left/top is not the convention.
- const rx = handleSetAttribute(parsed, ids, "data-x", String(x));
- const ry = handleSetAttribute(parsed, ids, "data-y", String(y));
+ //
+ // The pre-edit values are captured once per element into
+ // data-hf-edit-base-x/y. The runtime (core runtime/positionEdits.ts) renders
+ // the edit as translate(data-x − base, data-y − base), which composes with
+ // GSAP-animated transforms instead of being overwritten per-axis.
+ const parts: MutationResult[] = [];
+ for (const id of ids) {
+ const el = resolveScoped(parsed.document, id);
+ if (!el) continue;
+ if (el.getAttribute(EDIT_BASE_X_ATTR) === null) {
+ parts.push(
+ handleSetAttribute(parsed, [id], EDIT_BASE_X_ATTR, el.getAttribute("data-x") ?? "0"),
+ );
+ }
+ if (el.getAttribute(EDIT_BASE_Y_ATTR) === null) {
+ parts.push(
+ handleSetAttribute(parsed, [id], EDIT_BASE_Y_ATTR, el.getAttribute("data-y") ?? "0"),
+ );
+ }
+ }
+ parts.push(handleSetAttribute(parsed, ids, "data-x", String(x)));
+ parts.push(handleSetAttribute(parsed, ids, "data-y", String(y)));
return {
- forward: [...rx.forward, ...ry.forward],
- inverse: [...ry.inverse, ...rx.inverse],
+ forward: parts.flatMap((p) => p.forward),
+ inverse: parts
+ .slice()
+ .reverse()
+ .flatMap((p) => p.inverse),
};
}