Skip to content

fix(studio,core): persist manual position edits for GSAP-owned elements#1346

Merged
vanceingalls merged 14 commits into
mainfrom
06-11-fix_studio_core_persist_manual_position_edits_for_gsap-owned_elements
Jun 11, 2026
Merged

fix(studio,core): persist manual position edits for GSAP-owned elements#1346
vanceingalls merged 14 commits into
mainfrom
06-11-fix_studio_core_persist_manual_position_edits_for_gsap-owned_elements

Conversation

@vanceingalls

@vanceingalls vanceingalls commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes manual position edits (drag, resize) being lost on page refresh for elements animated by GSAP.

GSAP folds translate / x / y into its own transform cache and overwrites inline styles on every tick. Studio's path-offset patches stored translate: <px values> on the element, but GSAP wiped them at playback start — edits appeared to save but vanished after refresh.

Changes

manualEditsDom.ts

  • reapplyPathOffsets: skips GSAP-animated elements (uses gsapAnimatesTransform(el), not just x/y check)
  • applyStudioPathOffset: GSAP branch sets translate: none to yield ownership back to GSAP, then calls syncGsapOwnedTransformPosition() to apply the offset via gsap.set(el, {x, y})
  • New syncGsapOwnedTransformPosition(): reads --hf-studio-offset-x/y CSS vars → calls gsap.set(el, {x, y})

manualEditsDomPatches.ts

  • buildPathOffsetPatches: emits var(--hf-studio-offset-x, 0px) var(--hf-studio-offset-y, 0px) as the translate value when the live translate is none (GSAP-owned) but offset vars exist — persists the offset in a form GSAP can pick up on restore

manualEditsSnapshot.ts

  • Captures gsapX/gsapY via gsap.getProperty() when element is GSAP-owned
  • Restores via gsap.set() on rollback so undo/redo works through GSAP's cache

Test plan

  • Drag a GSAP-animated element → save → refresh → element stays in new position
  • Undo drag → element returns to original GSAP position
  • Non-GSAP elements: drag still works as before (no regression)

🤖 Generated with Claude Code

@vanceingalls vanceingalls force-pushed the 06-10-feat_lint_add_gsap_studio_edit_blocked_rule_for_manual_timeline_gsap_element_targeting branch from c1b0b24 to 8024563 Compare June 11, 2026 16:47
@vanceingalls vanceingalls force-pushed the 06-11-fix_studio_core_persist_manual_position_edits_for_gsap-owned_elements branch from 70d5fe8 to 28a9e5c Compare June 11, 2026 16:47

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 translate longhand into its internal transform cache at first tween init and writes translate: none every tick. Manual edits written as translate: <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_PROPS is the right widening. Old code checked only x/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 via gsap.set + translate: none. buildPathOffsetPatches → persisted source via var(--hf-studio-offset-x, 0px) var(--hf-studio-offset-y, 0px) translate (so reload re-folds through GSAP init). captureStudioPathOffset + restoreStudioPathOffset → undo/redo through gsap.set. All three paths agree on which elements GSAP owns (via gsapAnimatesTransform).
  • StudioPathOffsetSnapshot.gsapX/gsapY: number | null — typed correctly with null as the "GSAP doesn't own this" sentinel. Restore path checks previous.gsapX != null || previous.gsapY != null before calling gsap.set, which is the right discriminator.
  • STUDIO_GSAP_DRAG_INTERCEPT_ENABLED env flag with false default — gates the three intercept commits (path-offset, box-size, rotation) consistently and lets isElementGsapTargeted short-circuit to treat all elements as un-targeted. Sensible escape hatch for the keyframe-intercept work that's not yet hardened.

Concerns (non-gating)

  • patchStyleAttrString in sourceMutation.ts uses naïve split(";") — 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.
  • syncGsapOwnedTransformPosition re-checks gsapAnimatesTransform — 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.ts tests the patch-builder side (which emits the var() translate), but no test stubs window.gsap = { set: vi.fn() } and asserts applyStudioPathOffset invokes 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 via gsap.set when GSAP owns" so a future refactor of syncGsapOwnedTransformPosition can't silently regress.

Blocker

  • Preflight (lint + format) fails on this PR: same error: lockfile had changes, but lockfile is frozen cascade 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.
  • applyStudioPathOffsetDraft GSAP path also widened from gsapAnimatesProperty(el, "x", "y") to gsapAnimatesTransform(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. The 0px defaults guard against unset vars.
  • Snapshot/restore round-trip math checks out: captures gsap.getProperty(el, "x"|"y") as numbers (Number() coerces, || 0 on NaN); restores via gsap.set with 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 isElementGsapTargeted runtime check skips — assumed Vance authored both with matching selector semantics.
  • Did NOT verify readStudioPathOffset returns 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
miguel-heygen previously approved these changes Jun 11, 2026

@miguel-heygen miguel-heygen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

vanceingalls and others added 13 commits June 11, 2026 12:12
…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>
…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>
- 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>
@vanceingalls vanceingalls force-pushed the 06-10-feat_lint_add_gsap_studio_edit_blocked_rule_for_manual_timeline_gsap_element_targeting branch from 8024563 to bd9e1ae Compare June 11, 2026 19:13
@vanceingalls vanceingalls force-pushed the 06-11-fix_studio_core_persist_manual_position_edits_for_gsap-owned_elements branch from 28a9e5c to a80b13e Compare June 11, 2026 19:13
Base automatically changed from 06-10-feat_lint_add_gsap_studio_edit_blocked_rule_for_manual_timeline_gsap_element_targeting to main June 11, 2026 19:23
@vanceingalls vanceingalls dismissed miguel-heygen’s stale review June 11, 2026 19:23

The base branch was changed.

…s to 600 lines

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@vanceingalls vanceingalls merged commit fc3ab76 into main Jun 11, 2026
37 of 46 checks passed
@vanceingalls vanceingalls deleted the 06-11-fix_studio_core_persist_manual_position_edits_for_gsap-owned_elements branch June 11, 2026 19:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants