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
+120 -26
View File
@@ -218,12 +218,12 @@ interface FakeStyle {
}
interface FakeDomEl {
"data-hf-id": string;
"data-x": string | null;
"data-y": string | null;
_attrs: Record<string, string>;
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<string, string> = { "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,30 +397,17 @@ 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("");
});
it("commitPreview clears draft vars after dispatching", () => {
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();
expect(el.style.getPropertyValue("--hf-studio-dx")).toBe("");
expect(el.style.getPropertyValue("--hf-studio-dy")).toBe("");
expect(el.style.getPropertyValue("translate")).toBe("");
});
it("second commitPreview after first is a no-op (draft cleared)", () => {
@@ -381,6 +421,60 @@ describe("IframePreviewAdapter draft / commit / cancel", () => {
expect(dispatch).toHaveBeenCalledTimes(1);
});
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: 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();
});
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("");
});
});
// ─── WS-G: alphaIsOpaque ──────────────────────────────────────────────────────
+132 -28
View File
@@ -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<HTMLElement>(
`[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();
}
/**
* 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 {
if (this._draftEl) {
this._draftEl.style.removeProperty(VAR_DX);
this._draftEl.style.removeProperty(VAR_DY);
}
this._draftId = null;
this._draftEl = null;
this._draftDx = 0;
this._draftDy = 0;
this._draftPrevTranslate = null;
this._draftPrevInlineTranslate = null;
}
// Selection -----------------------------------------------------------------
+27
View File
@@ -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()) ───────────────────────────────────────────────────────
+28 -4
View File
@@ -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),
};
}