fix(studio,core): persist manual position edits for GSAP-owned elements#1346
Conversation
c1b0b24 to
8024563
Compare
70d5fe8 to
28a9e5c
Compare
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Reviewed at 28a9e5c. Persists manual position edits for GSAP-owned elements end-to-end (live commit + reload + undo/redo). The GSAP CSSPlugin transform-cache semantics are correctly identified and the fix routes through three layers (live DOM, persisted source, snapshot/restore).
LGTM with two non-gating concerns + the same CI blocker as #1345.
Strengths
- Root cause identified correctly. GSAP's CSSPlugin folds CSS
translatelonghand into its internal transform cache at first tween init and writestranslate: noneevery tick. Manual edits written astranslate: <px>get wiped on the next tick. The PR doesn't fight the ownership — it yields it back to GSAP and routes the offset through GSAP's cache instead. GSAP_TRANSFORM_PROPSis the right widening. Old code checked onlyx/y; new code checks anything that affects the transform stack (x/y/xPercent/yPercent/scale/scaleX/scaleY/rotation/rotate/rotationX/rotationY/skewX/skewY/transform). Captures the actual ownership semantics — GSAP owns the whole stack the moment it tweens ANY transform prop, not just x/y. Opacity-only tweens correctly continue to fall through to the CSS path (not in the list).- Three-layer routing is consistent.
applyStudioPathOffset→ live commit viagsap.set+translate: none.buildPathOffsetPatches→ persisted source viavar(--hf-studio-offset-x, 0px) var(--hf-studio-offset-y, 0px)translate (so reload re-folds through GSAP init).captureStudioPathOffset+restoreStudioPathOffset→ undo/redo throughgsap.set. All three paths agree on which elements GSAP owns (viagsapAnimatesTransform). StudioPathOffsetSnapshot.gsapX/gsapY: number | null— typed correctly withnullas the "GSAP doesn't own this" sentinel. Restore path checksprevious.gsapX != null || previous.gsapY != nullbefore callinggsap.set, which is the right discriminator.STUDIO_GSAP_DRAG_INTERCEPT_ENABLEDenv flag withfalsedefault — gates the three intercept commits (path-offset, box-size, rotation) consistently and letsisElementGsapTargetedshort-circuit to treat all elements as un-targeted. Sensible escape hatch for the keyframe-intercept work that's not yet hardened.
Concerns (non-gating)
patchStyleAttrStringinsourceMutation.tsuses naïvesplit(";")— the SAME bug #1350 fixes. Stacking lands #1346's helper, then #1350 replaces it with a proper tokenizer. Brief landing of a known-broken helper inside the stack is fine since they merge together, but worth being explicit in the PR body (or in #1350) that this is intentional cross-PR sequencing. As-is, a reader of #1346 alone would not realize the helper is provisional.syncGsapOwnedTransformPositionre-checksgsapAnimatesTransform— fine, but the caller (applyStudioPathOffset) already confirmed it before calling. Internal double-check is defensive but redundant. Minor.
Nit
- No unit test for the new live-DOM GSAP cache push. The
manualEditsDomPatches.test.tstests the patch-builder side (which emits thevar()translate), but no test stubswindow.gsap = { set: vi.fn() }and assertsapplyStudioPathOffsetinvokes it with the offset. The reload path is tested implicitly via the patch shape, but the live path is uncovered. Worth a happy-dom test as follow-up — locks down "Studio writes viagsap.setwhen GSAP owns" so a future refactor ofsyncGsapOwnedTransformPositioncan't silently regress.
Blocker
- Preflight (lint + format) fails on this PR: same
error: lockfile had changes, but lockfile is frozencascade as #1345. Bun lockfile drift from the SDK Phase 3a base. Address before landing.
What I verified
- Diff: 12 files, +185/-16.
gsapAnimatesProperty(el, ...GSAP_TRANSFORM_PROPS)semantics: signature is "any of these props" (per the existing fn's variadic shape), so passing the spread checks if ANY transform prop is tweened — correct widening.applyStudioPathOffsetDraftGSAP path also widened fromgsapAnimatesProperty(el, "x", "y")togsapAnimatesTransform(element)— consistent.- The patch builder's translate fallback (
var(--hf-studio-offset-x, 0px) var(--hf-studio-offset-y, 0px)) is well-formed CSS shorthand and the GSAP CSSPlugin will fold the resolved values at init. The0pxdefaults guard against unset vars. - Snapshot/restore round-trip math checks out: captures
gsap.getProperty(el, "x"|"y")as numbers (Number() coerces,|| 0on NaN); restores viagsap.setwith the captured values.
What I didn't verify
- Did NOT run a live drag-save-reload locally — the comment trail and unit-test coverage make me confident, but empirical confirmation belongs in the test-plan checklist Vance has on the PR body.
- Did NOT verify the lint rule from #1345 fires on the same element selectors that this PR's
isElementGsapTargetedruntime check skips — assumed Vance authored both with matching selector semantics. - Did NOT verify
readStudioPathOffsetreturns the just-written CSS var (read-after-write on inline style should be synchronous, but I didn't trace).
— Review by Rames D Jusso
miguel-heygen
left a comment
There was a problem hiding this comment.
Good fix — the root cause is clearly identified (GSAP bakes CSS translate into style.transform at init and writes translate: none every tick) and the solution is correctly threaded through snapshot/restore, draft positioning, patch building, and commit. The STUDIO_GSAP_DRAG_INTERCEPT_ENABLED guard is a safe escape hatch.
P1 — syncGsapOwnedTransformPosition has a redundant gsapAnimatesTransform guard
In applyStudioPathOffset, the call to syncGsapOwnedTransformPosition is already inside an if (gsapAnimatesTransform(element)) branch. Inside the helper, the check fires again: if (!gsapAnimatesTransform(element)) return;. Not a correctness bug here, but the double-check indicates the guard invariant isn't clearly documented — a future refactor moving the call site could silently drop the outer guard and the inner guard would paper over it. The inner guard should be kept for defensive safety, but a comment like // already checked by caller — defensive guard would clarify intent.
P2 — Snapshot gsapX/gsapY captures live GSAP cache, not the pre-draft committed baseline
captureStudioPathOffset is called at drag-start and snapshots gsap.getProperty(el, "x"). If a prior uncommitted drag already moved the element via gsap.set, the snapshot records the mid-session position rather than the last committed value. On rollback, restoreStudioPathOffset restores to the mid-session origin, not the true pre-drag baseline. This matches the existing CSS snapshot contract (which also snapshots live inline styles), but it should be documented as an invariant in the type comment for gsapX/gsapY.
P2 — buildPathOffsetPatches: empty-string inlineTranslate falls through to the var() branch silently
const translateValue =
inlineTranslate && inlineTranslate !== "none"
? inlineTranslate
: hasOffsetVars
? `var(...)`
: null;inlineTranslate && ... is falsy for "", so an empty string lands in the hasOffsetVars branch and emits the var-based fallback. That's probably correct, but !inlineTranslate || inlineTranslate === "none" states the intent explicitly.
P3 — STUDIO_GSAP_DRAG_INTERCEPT_ENABLED is undeclared in env.d.ts
The new env flag doesn't appear in the Vite env type declarations. TypeScript will accept the string lookup but lose type safety on the value. Worth adding to the VITE interface.
Overall solid fix for a genuinely tricky GSAP ownership problem. P1/P2 are code-quality flags, not correctness blockers. Approving.
…patches, mutate, apply-patches)
…indexed access - index.ts no longer exports document/session/history/persist-queue (those modules land in the next stacked PR); branch now typechecks standalone - setOwnText: optional-chain children[i] access (TS2532 under noUncheckedIndexedAccess) - fallow suppressions for buildPatchEvent + adapters/types.ts — consumers arrive in #1325 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- applyOp throws UnsupportedOpError (code E_UNSUPPORTED_OP) for the 9 parser-backed ops instead of silently no-opping — callers must never believe an animation edit succeeded when nothing was mutated - validateOp returns false for Phase 3b ops so can() feature-detects - root package.json build filter now includes @hyperframes/sdk (package is dist-only; top-level build previously produced no SDK artifacts). publish.yml intentionally NOT updated — sdk stays unpublished until Phase 3 completes. Adversarial-review findings F3 + F4. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tract docs
Round-2 review (Rames/Miguel) on the engine layer:
- ORIGIN_APPLY_PATCHES: unique symbol → namespaced string
('@hyperframes/sdk:applyPatches'). Symbols are realm-local — they don't
survive postMessage/structured-clone, which T3 embedded hosts may forward
patch events across. Namespaced string keeps collision risk negligible.
- setCompositionMetadata width/height: runtime treats data-width/data-height
as a forced override of inline style (init.ts applyCompositionSizing).
Style is always written; the data-* attr is updated when already present
so the edit isn't clobbered on load. Absent attrs stay absent — inverses
stay exact. Mirrored in the patch applier; 3 new tests.
- JsonPatchOp documented as the emit-only RFC 6902 subset
(add/remove/replace); applier header notes move/copy/test are ignored.
- SdkDocument.html documented as a build-time snapshot (serialize() is the
live state).
- patches.ts path-grammar comment fixed: timing/{start|end|trackIndex}.
NOT changed (with reasons, see PR reply): moveElement left/top matches
Studio's own inline-style commit convention (sourcePatcher); package version
follows the repo-wide single-version policy.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
HF elements use data-x/data-y for positioning (read by htmlParser.ts, emitted by hyperframes generator). CSS left/top is not the runtime convention. Adds inverse round-trip test for prior position restore. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…Phase 3a complete
…parse dedup - getElements/getElement/find now walk the live linkedom DOM via buildRoots with a lazily-built cache invalidated on dispatch/applyPatches — no serialize→ensureHfIds→parseHTML round trip per query - openComposition parses once (parseMutable); dropped discarded _doc constructor param and the redundant buildDocument call - document.ts buildElement reuses model.ts getElementStyles — removes duplicated parseInlineStyles (also fixes custom-prop camelCase mangling) - JSDoc note: empty batch() still fires change handlers Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
index.ts re-exports document/session/history/persist-queue (trimmed in the engine-layer PR to keep it self-contained); drops the temporary fallow suppressions whose consumers now exist. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adversarial-review findings F1 + F2:
- history: coalescing now requires identical patch paths in addition to
op types + origin + window. Previously two rapid setStyle calls on
DIFFERENT elements merged into one entry carrying the second forward +
first inverse — undo then reverted the wrong element and stranded the
latest edit. Slider drags on one property still coalesce.
- T3 init: openComposition({ overrides }) now replays the stored
override-set onto the freshly-parsed base before exposing the session
(new keyToPath inverse mapping + applyOverrideSet). Previously the
overrides were copied into the map but never applied — reopening an
embedded composition showed and serialized the base template.
- examples: GSAP calls now feature-detect with can() (Phase 3b ops throw
UnsupportedOpError as of the engine-layer fix); UnsupportedOpError
re-exported from the package entry.
- 8 new session tests: coalesce same-path / cross-element / cross-prop,
override round-trip (style/text/attr/timing/removal/restore-base).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ority unify Round-2 review (Rames/Miguel) on the session layer: - batch() is now transactional: on throw, accumulated inverse patches are replayed in reverse and the override-set snapshot restored — the model is exactly as it was at batch entry. Previously a throwing batch left the DOM partially mutated with no patch trail, no history entry, no recovery path. 2 new tests (model unchanged + undo is no-op after throwing batch). - history coalesce key sorts opTypes — same op-type set coalesces regardless of dispatch order within a batch. - applyPatches comment documents that emitted PatchEvents carry an empty inversePatches array (hosts keep their own inverse log). - document.ts extractDimensions/extractDuration now use the engine's findRoot — dimension extraction and mutations agree on the root element ([data-hf-root] > #stage > first child). Dimensions prefer the runtime's data-width/data-height forced-override attrs, falling back to inline style. - ownText documented: snapshot .text is trimmed display text; setText writes verbatim. Deferred to follow-up (acknowledged, not ship-blocking): persist-queue flush error surfacing, debounce window, path default, history ring-buffer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…SAP element targeting
- sourceMutation: linkedom CSSStyleDeclaration silently drops CSS custom properties and transform longhands via setProperty; patch the style attribute string directly so --hf-studio-offset-* and translate survive the server round-trip (positions never reached disk before this) - gsapAnimatesTransform(): GSAP owns the full transform stack when it tweens ANY transform prop (scale, rotation, ...), not just x/y — it folds CSS translate into its cache once at init, zeroes the longhand once, and never re-reads it - applyStudioPathOffset: for GSAP-owned elements keep translate:none live and sync the offset into GSAP's cache via gsap.set; writing the longhand double-applied the offset (disappearing elements, scrub snap-back) - buildPathOffsetPatches: emit the var() translate expression explicitly so the persisted file re-folds on reload (live inline is none) - StudioPathOffsetSnapshot: capture/restore GSAP x/y — the drag-response probe mutates GSAP's cache, which inline-style restore cannot undo (click made elements jump by the probe distance) - reapplyPathOffsets: skip GSAP-owned elements (was x/y-only) to stop seek-time double-apply - STUDIO_GSAP_DRAG_INTERCEPT flag (default off): keyframe drag intercept is opt-in until its recording path is hardened; commits take the CSS persist path Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
8024563 to
bd9e1ae
Compare
28a9e5c to
a80b13e
Compare
The base branch was changed.
…s to 600 lines Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Summary
Fixes manual position edits (drag, resize) being lost on page refresh for elements animated by GSAP.
GSAP folds
translate/x/yinto its own transform cache and overwrites inline styles on every tick. Studio's path-offset patches storedtranslate: <px values>on the element, but GSAP wiped them at playback start — edits appeared to save but vanished after refresh.Changes
manualEditsDom.tsreapplyPathOffsets: skips GSAP-animated elements (usesgsapAnimatesTransform(el), not just x/y check)applyStudioPathOffset: GSAP branch setstranslate: noneto yield ownership back to GSAP, then callssyncGsapOwnedTransformPosition()to apply the offset viagsap.set(el, {x, y})syncGsapOwnedTransformPosition(): reads--hf-studio-offset-x/yCSS vars → callsgsap.set(el, {x, y})manualEditsDomPatches.tsbuildPathOffsetPatches: emitsvar(--hf-studio-offset-x, 0px) var(--hf-studio-offset-y, 0px)as thetranslatevalue when the live translate isnone(GSAP-owned) but offset vars exist — persists the offset in a form GSAP can pick up on restoremanualEditsSnapshot.tsgsapX/gsapYviagsap.getProperty()when element is GSAP-ownedgsap.set()on rollback so undo/redo works through GSAP's cacheTest plan
🤖 Generated with Claude Code