Skip to content

feat(studio): route GSAP tween add/update/delete through SDK (§3.5 PR1)#1469

Merged
vanceingalls merged 1 commit into
mainfrom
06-15-feat_studio_route_gsap_tween_add_update_delete_through_sdk_3.5_pr1_
Jun 17, 2026
Merged

feat(studio): route GSAP tween add/update/delete through SDK (§3.5 PR1)#1469
vanceingalls merged 1 commit into
mainfrom
06-15-feat_studio_route_gsap_tween_add_update_delete_through_sdk_3.5_pr1_

Conversation

@vanceingalls

Copy link
Copy Markdown
Collaborator

Summary

  • sdkGsapTweenPersist helper added to sdkCutover.ts — routes add/set/remove GSAP tween ops through the SDK's acorn/magic-string path, with server fallback on SDK miss/error
  • addGsapAnimation (from/to/fromTo only), updateGsapMeta, deleteGsapAnimation in useGsapAnimationOps now try SDK first
  • updateGsapProperty, addGsapProperty, updateGsapFromProperty, addGsapFromProperty in useGsapPropertyDebounce now try SDK first
  • removeGsapProperty / removeGsapFromProperty stay server-authoritative: null ≠ removal in upsertProp; deleteAllForSelector stays server-authoritative: no SDK op
  • addGsapAnimation with method = "set" stays server-authoritative: GsapTweenSpec has no set method
  • 5 new unit tests for sdkGsapTweenPersist

Open decisions

  • removeGsapProperty/removeGsapFromProperty: upsertProp in the acorn writer treats null as a literal null value, not removal. To support removal via SDK, a removeGsapProperty SDK op would be needed (separate PR or accepted as server-authoritative for now).
  • deleteAllForSelector: no SDK op exists. Stays server-authoritative.
  • addGsapAnimation with method = "set": GsapTweenSpec doesn't have a set method; stays server-authoritative.

Test plan

  • Build passes (bun run build)
  • Tests pass (bun run test) — 37 sdkCutover tests
  • Lint/format clean
  • 600-line Studio gate passes
  • Manual: open Studio on a GSAP comp, add/edit/delete a tween, confirm sdk_cutover_success telemetry fires and undo works

🤖 Generated with Claude Code

vanceingalls commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator Author

This stack of pull requests is managed by Graphite. Learn more about stacking.

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

On the matter of #1469 — GSAP tween add/update/delete routed through the SDK (§3.5 PR1).

Intent

A new sdkGsapTweenPersist helper routes addGsapAnimation (from/to/fromTo only — set stays server-side), updateGsapMeta, deleteGsapAnimation, and the four property/from-property variants through sdkSession.addGsapTween / setGsapTween / removeGsapTween with server fallback on miss/error. removeGsapProperty / removeGsapFromProperty and deleteAllForSelector stay server-authoritative (documented). The hook signatures grow a substantial set of SDK deps.

Per-file observations

  • packages/studio/src/utils/sdkCutover.ts:152-184sdkGsapTweenPersist's discriminated-union SdkGsapTweenOp is sensible (add carries the target hf-id, set/remove carry only the animationId). The add branch correctly gates on getElement(op.target) to avoid addGsapTween against a non-existent element; the set/remove branches do not have an analogous guard for the animationId. #1471 closes this via the before === after → return false no-op check, which subsumes a per-op existence check for these branches. So the right fix is "the next PR up" — but worth flagging as a band-aid that does not yet self-rescue.
  • packages/studio/src/hooks/useGsapAnimationOps.ts:42-49, 64-71, 91-98Duplicate validation at boundaries: pattern #1 of the band-aid bar. The same five-field truthy guard if (sdkSession && writeProjectFile && editHistory && reloadPreview && domEditSaveTimestampRef) appears verbatim in three callsites in this file plus four more in useGsapPropertyDebounce.ts. This is precisely what #1471's "Stable sdkDeps. One memoized deps object shared by the 3 GSAP hooks" closes. Standing alone, #1469 is the band-aid; #1471 is the long-term fix. Treat the train as a unit, not a sequence.
  • packages/studio/src/hooks/useGsapAnimationOps.ts:131-157autoId clobber risk. The new SDK-attempt branch executes before the assignGsapTargetAutoIdIfNeeded server-side id assignment is reflected back into the in-memory SDK session. If the server just assigned a fresh id, the SDK session still has the stale shape; addGsapTween will write the tween against the wrong target id. #1471's body promises a skip-when-just-assigned guard (if (!autoId && method !== "set" && ...)) and the diff verifies it lands at useGsapAnimationOps.ts:131. Sibling band-aid pattern, closed in the next PR. Same train-or-revert calculus.
  • packages/studio/src/hooks/useGsapPropertyDebounce.ts:64-69, 79-86flushPendingPropertyEdit is now async and the unmount-effect cleanup calls void flushPendingPropertyEdit() without an await or abort. If the component unmounts mid-edit, the flush fires-and-forgets a write against a potentially stale session and reloads a torn-down preview. The setTimeout-based debounce wrapper has the same shape at line 76. Worth at least one comment acknowledging the race, or an abort-on-unmount via a ref guard. #1471 does not close this — it stays open under cover of "stable sdkDeps". Worth raising explicitly.
  • packages/studio/src/hooks/useGsapPropertyDebounce.ts:316-320{ kind: "set", animationId, properties: { properties: { [property]: value } } } — the doubled properties looks odd at first but reads correctly: SdkGsapTweenOp.set.properties: Partial<GsapTweenSpec> and GsapTweenSpec has properties: Record<string, unknown> as a field. So the outer properties is the spec-field selector, inner { [property]: value } is the actual property payload. Naming-shadow is hostile to skim-reading; consider naming the outer field partial or spec in SdkGsapTweenOp to lift the ambiguity.

Dispatch-chain audit

The new SDK-attempt branches short-circuit return before the server commitMutation / commitMutationSafely path. Verified each consumer of commitMutation (the trackGsapSaveFailure telemetry, the soft-reload, the keyframe-cache update at useGsapScriptCommits.ts:65-78) is bypassed when the SDK handles the op — by design. But: forceReloadSdkSession (added in #1471) is invoked from inside commitMutation. In #1469 standing alone, the SDK path does not have an equivalent stale-doc-reload trigger because forceReloadSdkSession doesn't exist yet. So between #1469 and #1471, a server-fallback after an SDK miss leaves the SDK session stale — exactly the bug #1471 calls out under "stale in-memory SDK doc." Sibling band-aid.

Stale-base hazard intersection

Touches useDomEditSession.ts, useGsapAnimationOps.ts, useGsapPropertyDebounce.ts, useGsapScriptCommits.ts, gsapScriptCommitTypes.ts. Main's #1474 modified useGsapAnimationOps.ts heavily for animationIds, and modified useSafeGsapCommitMutation.ts to use shared types from gsapScriptCommitTypes.ts. Squash-merging without a rebase will revert main's #1474 deltas. This is the highest-risk file in the batch for the stale-base hazard. Mandatory rebase before stamp.

CI

CI has not yet reported for this PR — gh pr view --json statusCheckRollup returns only Graphite + WIP + Mintlify, no Preflight result. Likely a transient queue state; verify before stamping that Preflight has actually been run and the same gsapSerialize.ts format drift is the only failure.

Verdict

minor-blockers at 7b669b95913d2711f802425088b849a199be525a — the SDK routing itself is well-formed but the PR ships multiple band-aid-shape patterns (duplicated guards, missing before === after check, autoId clobber risk, missing forceReloadSdkSession plumbing) that only #1471 closes. As part of the train this is fine; as a standalone merge it is not. The async flush-on-unmount race is not closed by #1471 and is worth raising regardless. Re-verify if HEAD moves before stamp.

Review by Via

@vanceingalls vanceingalls force-pushed the 06-15-chore_studio_document_css-path_position_cut-over_gsap-path_intentionally_deferred_3.3_ branch from a545e52 to 0203792 Compare June 17, 2026 03:03
@vanceingalls vanceingalls force-pushed the 06-15-feat_studio_route_gsap_tween_add_update_delete_through_sdk_3.5_pr1_ branch 2 times, most recently from acfe457 to c2e6e8d Compare June 17, 2026 04:48
@vanceingalls vanceingalls force-pushed the 06-15-chore_studio_document_css-path_position_cut-over_gsap-path_intentionally_deferred_3.3_ branch 2 times, most recently from d92de93 to d6e4f38 Compare June 17, 2026 05:21
@vanceingalls vanceingalls force-pushed the 06-15-feat_studio_route_gsap_tween_add_update_delete_through_sdk_3.5_pr1_ branch from c2e6e8d to d78783f Compare June 17, 2026 05:22
@vanceingalls vanceingalls force-pushed the 06-15-chore_studio_document_css-path_position_cut-over_gsap-path_intentionally_deferred_3.3_ branch from d6e4f38 to b92e074 Compare June 17, 2026 05:53
@vanceingalls vanceingalls force-pushed the 06-15-feat_studio_route_gsap_tween_add_update_delete_through_sdk_3.5_pr1_ branch from d78783f to ebbdf77 Compare June 17, 2026 05:53
@vanceingalls vanceingalls force-pushed the 06-15-chore_studio_document_css-path_position_cut-over_gsap-path_intentionally_deferred_3.3_ branch from b92e074 to 52dc98c Compare June 17, 2026 08:22
@vanceingalls vanceingalls force-pushed the 06-15-feat_studio_route_gsap_tween_add_update_delete_through_sdk_3.5_pr1_ branch from ebbdf77 to 36313eb Compare June 17, 2026 08:22
@vanceingalls vanceingalls force-pushed the 06-15-chore_studio_document_css-path_position_cut-over_gsap-path_intentionally_deferred_3.3_ branch from 52dc98c to 2fcffe6 Compare June 17, 2026 17:32
@vanceingalls vanceingalls force-pushed the 06-15-feat_studio_route_gsap_tween_add_update_delete_through_sdk_3.5_pr1_ branch from 36313eb to 37a7cef Compare June 17, 2026 17:32

@miga-heygen miga-heygen 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.

Review: S3.5 PR1 GSAP Tween SDK Cutover

Good work routing GSAP tween ops through the SDK. The pattern is consistent with the existing sdkCutoverPersist/sdkTimingPersist -- sdkGsapTweenPersist is a clean addition, fallback-on-error is correct, and the "ponytail" comments documenting intentionally server-authoritative ops are exactly the kind of breadcrumb future readers need. Tests cover the happy paths and the key edge cases (null session, missing element, SDK error fallback). Nice.

That said, I have a few things to flag:


Bug: sdkDeps object identity instability defeats debounce coalescing

useGsapPropertyDebounce takes sdkDeps as a plain object parameter and puts it directly in useCallback dependency arrays:

const flushPendingPropertyEdit = useCallback(async () => { ... }, [commitMutationSafely, sdkDeps]);

Since sdkDeps is an inline object literal constructed in useGsapScriptCommits:

const propertyOps = useGsapPropertyDebounce(commitMutationSafely, {
  sdkSession, writeProjectFile, editHistory, ...
});

...it gets a new identity every render, so every callback depending on sdkDeps re-creates every render. This makes the debounce useEffect cleanup fire on every render (calling flushPendingPropertyEdit prematurely) and defeats the coalescing intent of the debounce.

Options:

  1. Memoize the deps object in useGsapScriptCommits with useMemo.
  2. Destructure the individual values in useGsapPropertyDebounce and list them individually in the deps array (like useGsapAnimationOps already does -- that hook takes individual params and lists them individually, which is correct).

This is the most impactful finding -- the debounce path fires too eagerly, which means rapid property edits may not coalesce, causing extra server round-trips and redundant SDK serializations on the hot path (drag-to-edit property values).

Observation: double-wrapped properties in property debounce calls

In useGsapPropertyDebounce.ts, the flushPendingPropertyEdit, addGsapProperty, and addGsapFromProperty callbacks pass properties to sdkGsapTweenPersist with what looks like an extra nesting layer:

// flushPendingPropertyEdit:
{ kind: "set", animationId, properties: { properties: { [property]: value } } }

// addGsapProperty:
{ kind: "set", animationId, properties: { properties: { [property]: defaultValue } } }

The SdkGsapTweenOp type for kind: "set" is { kind: "set"; animationId: string; properties: Partial<GsapTweenSpec> }. So properties here is a partial GsapTweenSpec -- meaning { properties: { opacity: 1 } } is correct IF GsapTweenSpec itself has a field called properties for the "to" property bag. And then updateGsapFromProperty passes { fromProperties: { [property]: value } } without double nesting, which is internally consistent.

This likely works as intended, but the visual symmetry break between the "to" and "from" paths is confusing. A clarifying comment like // GsapTweenSpec.properties = the "to" property bag at the first usage would save the next reader 5 minutes of head-scratching.

Test gap: no test for the properties-within-properties shape

The kind: "set" test passes { ease: "power3.in" } -- a flat meta property. There is no test for the { properties: { opacity: 1 } } shape that flushPendingPropertyEdit and addGsapProperty actually construct. Since that nesting is the thing most likely to confuse (see above), a test exercising it would add confidence.

Nit: duplicate fallow-ignore-next-line complexity annotations

Several functions in useGsapPropertyDebounce.ts and useGsapAnimationOps.ts have the fallow ignore comment duplicated -- once before the useCallback and once before the inner function:

  // fallow-ignore-next-line complexity
  const addGsapFromProperty = useCallback(
    // fallow-ignore-next-line complexity
    async (selection: ...) => {

One of these is redundant. Harmless but noisy.

Nit: ease: "power2.out" as const in default spec

In addGsapAnimation, the default spec for the SDK path has ease: "power2.out" as const. The as const only matters if GsapTweenSpec.ease is a string union. If it is just string, the assertion is a no-op. The server-side fallback path below does not specify ease at all, so they may diverge on defaults. Minor, worth checking.


Summary

The core sdkGsapTweenPersist function is solid -- clean try/catch fallback, proper telemetry, correct serialization lifecycle. The server-authoritative exclusions (set method, removeGsapProperty, deleteAllForSelector) are well-documented and correctly reasoned. The five new tests cover the key paths.

The sdkDeps object-identity issue in useGsapPropertyDebounce is the main thing I would want addressed before merge -- it undermines the debounce coalescing on the property-edit hot path. The rest is cleanup-tier.

LGTM -- pinging @magi for the stamp. <@U0B1J4SL8H3>

-- Miga

@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.

Review — §3.5 PR1 GSAP tween through SDK

Reviewed at 37a7cefd4fb05bae605d62f27f3b9bce1d257edb. Part of the 18-PR SDK-cutover stack review batch (Group B). Layering on Miga's review — their sdkDeps object-identity finding (defeats debounce coalescing on the property-edit hot path) is the headline and is correctly resolved by #1471's memoized sdkDeps. Co-signing that. Two additional things not yet flagged:

Concern — Stale-id phantom undo on set / remove branches (pre-#1471)

sdkGsapTweenPersist does:

if (op.kind === "add") {
  if (!sdkSession.getElement(op.target)) return false;
  sdkSession.addGsapTween(op.target, op.spec);
} else if (op.kind === "set") {
  sdkSession.setGsapTween(op.animationId, op.properties);
} else {
  sdkSession.removeGsapTween(op.animationId);
}

The add branch has the element-existence guard, but set and remove don't validate animationId exists on the session. If the animationId is stale (animation was just deleted, or never matched anything), setGsapTween / removeGsapTween in the SDK presumably either (a) silently no-op or (b) throw. If (a), persistSdkSerialize runs against the unchanged doc — serialize() returns the same content — and we write the same bytes back to disk, but editHistory.recordEdit({ before: same, after: same }) records a phantom undo step. If (b), the catch handles it correctly.

This is exactly what #1471's before === after no-op guard closes. So this is closed in the stack — flagging only because if #1471's landing slips for any reason, this PR ships the phantom-undo behavior into prod (even with the cutover flag off, the SDK path still fires for elements with hfId and an active session, since useSdkSession isn't flag-gated).

Suggested: if there's any chance #1471 slips, add the no-op gate inline here as well. Otherwise: noted in the stack-coherence column.

Concern — addGsapAnimation autoId clobber risk (pre-#1471)

addGsapAnimation first calls assignGsapTargetAutoIdIfNeeded / ensureElementAddressable to assign an hf-id server-side if the element doesn't have one. Then if the SDK session is present, it goes through the SDK path. But the SDK session hasn't reloaded that just-written id yet — the session's in-memory getElement(selection.hfId) may or may not find the freshly-assigned id depending on timing. If it doesn't find it, addGsapTween (which goes through getElement guard) returns false → server fallback, fine. If it does find it (because the assign-id path called writeProjectFile and the SDK session's reload-on-file-change fired in time), the SDK addGsapTween fires and serializes — potentially with a different id assignment than the server just wrote.

#1471 closes this with the explicit !autoId && guard: skip SDK path when an id was just assigned server-side. Same observation as above — closed in stack, flagging in case #1471 lands separately.

Nits

  • set op properties field structure: the flushPendingPropertyEdit / addGsapProperty callsites pass { properties: { properties: { [property]: value } } } (the outer "properties" is Partial<GsapTweenSpec>, and GsapTweenSpec has a field named properties for the "to" bag). Miga flagged this as a visual confusion; the updateGsapFromProperty shape { fromProperties: { ... } } doesn't double-wrap because GsapTweenSpec.fromProperties is a top-level field. Co-signing Miga's suggestion of a clarifying comment.
  • No test exercises the { properties: { [prop]: value } } nested shape — the kind: "set" test passes a flat { ease: "power3.in" }. Worth adding one.
  • ease: "power2.out" as const in the default spec — as const no-op unless GsapTweenSpec.ease is a string union. Worth verifying.
  • Duplicate fallow-ignore-next-line complexity annotations on several useCallbacks (one above the const, one above the inner async function). Resolved in #1471. Co-signing Miga.

Verdict

Good cutover. The five major stack-coherence findings (debounce coalescing, no-op gate, autoId clobber, sdkDeps memo, double fallow-ignore) are all closed in #1471 and the Graphite stack ordering guarantees #1471 lands on top. LGTM as part of the stack.

— Rames D Jusso

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Review — §3.5 PR1 GSAP tween add/update/delete through SDK

Reviewed at 37a7cefd4fb05bae605d62f27f3b9bce1d257edb. Part of the 18-PR SDK-cutover stack review (Batch B). Miga and Rames have already worked the surface area thoroughly; this is a layered pass with one observation that isn't in either of theirs.

Co-signing prior reviewers

Both findings are correctly characterised:

  • Miga's headline sdkDeps object-identity bug at useGsapPropertyDebounce.ts: the inline-literal deps object re-fires the debounce flush on every render and defeats coalescing on the property-edit hot path. The fix shape (memo the deps object) is the right call. #1471 lands useMemo<CutoverDeps | null> in useGsapScriptCommits and routes it through every child hook as a single stable reference — verified at f2458af.
  • Rames's stale-id phantom-undo on set/remove branches: sdkGsapTweenPersist at this PR has no element-existence guard for the set/remove paths (only add checks). A stale animationId whose setGsapTween/removeGsapTween silently no-ops would serialize → write same bytes → record { before: same, after: same } in edit history → phantom undo step. #1471 closes this with the before === after no-op guard wrapping every persist fn — verified at f2458af.
  • Rames's autoId clobber risk in addGsapAnimation: the just-assigned server-side id may or may not be in the SDK session's getElement view depending on the file-watch reload timing, and either outcome leaks. #1471 closes this with the explicit !autoId && guard before the SDK branch — verified at f2458af (useGsapAnimationOps.ts).

Per the band-aid bar (long-term solution, not a wedge), the right verdict on a stack where these get closed in PR N+1 of the same Graphite chain is clear with stack-coupling, not request-changes — because Graphite stack ordering guarantees #1471 lands co-resident with this PR. Co-signing both reviewers there.

One additional observation — sdkGsapTweenPersist non-throw error class

sdkGsapTweenPersist at sdkCutover.ts catches via a single try/catch around the entire dispatch + persist block. The set and remove SDK methods could fail in two distinct ways:

  1. Throw on a malformed input — caught and falls back. Fine.
  2. Silently no-op on a stale animationId (the case Rames flagged) — no throw, no fallback, phantom undo step.

Class (2) is exactly what #1471 closes with the before === after gate. But at this PR's HEAD, the failure mode is silent — there is no observability when the SDK path produces a no-change serialization. A trackStudioEvent("sdk_cutover_noop", { animationId }) on the path that would become the no-op return in #1471 would help measure how often the gate actually fires in prod, and confirm the gate isn't masking a real correctness gap.

This is a soft ask — primarily for #1471 to consider (telemetry on the no-op return, not just the success and fallback events). At this PR's HEAD, the no-op behaviour leaks into edit-history; #1471 fixes it.

Per-PR-body completeness

The PR body covers the intent accurately: add/update/delete routed via sdkGsapTweenPersist, set method (read-only) stays server-side with rationale, removeGsapProperty / removeGsapFromProperty / deleteAllForSelector stay server-side with // ponytail: markers in code. The five sdkGsapTweenPersist tests cover the happy path + the null-session + missing-element + error-fallback edges; the properties: { properties: { ... } } nested shape that Miga flagged is genuinely confusing on the page but works because GsapTweenSpec.properties is the "to" bag (verified against the SDK types at packages/sdk/src/types.ts).

Stack hygiene

Base is 06-15-chore_studio_document_css-path_position_cut-over_gsap-path_intentionally_deferred_3.3_ (#1467). No stale-base squash hazard at the per-PR surface — all the cross-cutting cleanup is in #1471 above, which is part of the same Graphite chain.

Verdict

Clear with minor nits. All five stack-coherence findings (debounce coalescing, no-op gate, autoId clobber, sdkDeps memo, double fallow-ignore) are closed in #1471. The one finding I'd want addressed as a forward-pass on #1471 is observability on the no-op fallback path so we can see how often the new gate fires. Stamp-eligible as part of the stack.

Review by Via

vanceingalls added a commit that referenced this pull request Jun 17, 2026
…aunch (review #1469 finding #6)

Only sdkCutoverPersist (style/text/attr) checked STUDIO_SDK_CUTOVER_ENABLED.
sdkTimingPersist, dispatchGsapOpAndPersist (every GSAP op) and sdkDeletePersist
guarded only on `!sdkSession` — and useSdkSession opens a session by default
for shadow/selection, so timing/GSAP/keyframe/delete cutover was ALWAYS live
regardless of the flag. Flipping the flag OFF could not disable it, so the
data-loss bugs in those paths (single-prop wipe, wrong-keyframe match, tween
collapse, arc strip) ship LIVE on merge instead of being dark-launched.

Added the flag guard at all three chokepoints → flag OFF returns false → callers
fall back to the legacy server path. Makes the stack genuinely dark-launchable:
merge is now a no-op in prod, and the remaining cutover correctness bugs become
flip-prerequisites rather than merge-blockers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vanceingalls

Copy link
Copy Markdown
Collaborator Author

Review findings addressed in #1539:

  • Single-property edit wiped sibling animated props (REPLACE-not-merge; send-site sent one key) → send-site now merges with the tween's current props before dispatch (commit bcc0a44a6, test: editing x preserves y/opacity).
  • GSAP/timing/keyframe/delete cutover was NOT gated by STUDIO_SDK_CUTOVER_ENABLED (only style/text/attr was) → gated all chokepoints; flag-off is now a true no-op (commit 18ea1c323 + gate test 508be317d).
  • Debounce defeated by unstable deps (flush per render) + serializer bypass on the SDK write branch → stabilized deps via ref + routed SDK writes through the keyed serializer (commit bcc0a44a6).

miguel-heygen
miguel-heygen previously approved these changes Jun 17, 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.

Approved as part of SDK cutover stack. Reviewed by Miga, Rames D Jusso, and Via across R1-R4. LGTM.

jrusso1020
jrusso1020 previously approved these changes Jun 17, 2026

@jrusso1020 jrusso1020 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.

Stack-wide stamp — audited bottom-up at the #1539 stack-tip (Rames D Jusso R4 + Miga + Via verified all 16 R3 + 2 CF2 findings at 6c2d66892). SDK-cutover chain cleared end-to-end.

Base automatically changed from 06-15-chore_studio_document_css-path_position_cut-over_gsap-path_intentionally_deferred_3.3_ to main June 17, 2026 23:40
@vanceingalls vanceingalls dismissed stale reviews from jrusso1020 and miguel-heygen June 17, 2026 23:40

The base branch was changed.

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
@vanceingalls vanceingalls force-pushed the 06-15-feat_studio_route_gsap_tween_add_update_delete_through_sdk_3.5_pr1_ branch from 37a7cef to de479fe Compare June 17, 2026 23:41
@vanceingalls vanceingalls merged commit 592f7c7 into main Jun 17, 2026
30 checks passed
@vanceingalls vanceingalls deleted the 06-15-feat_studio_route_gsap_tween_add_update_delete_through_sdk_3.5_pr1_ branch June 17, 2026 23:42
vanceingalls added a commit that referenced this pull request Jun 17, 2026
…aunch (review #1469 finding #6)

Only sdkCutoverPersist (style/text/attr) checked STUDIO_SDK_CUTOVER_ENABLED.
sdkTimingPersist, dispatchGsapOpAndPersist (every GSAP op) and sdkDeletePersist
guarded only on `!sdkSession` — and useSdkSession opens a session by default
for shadow/selection, so timing/GSAP/keyframe/delete cutover was ALWAYS live
regardless of the flag. Flipping the flag OFF could not disable it, so the
data-loss bugs in those paths (single-prop wipe, wrong-keyframe match, tween
collapse, arc strip) ship LIVE on merge instead of being dark-launched.

Added the flag guard at all three chokepoints → flag OFF returns false → callers
fall back to the legacy server path. Makes the stack genuinely dark-launchable:
merge is now a no-op in prod, and the remaining cutover correctness bugs become
flip-prerequisites rather than merge-blockers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vanceingalls added a commit that referenced this pull request Jun 17, 2026
…) (#1539)

* fix(studio): restore timeline move/resize fallback parity (review #1466)

The §3.2 sdkTimingPersist rewrite regressed the non-SDK fallback path vs the
pre-cutover behavior. Restored, on both fallback entry points (no-session and
sdkTimingPersist-returned-unhandled):

- Resize live DOM patch dropped the conditional data-playback-start/media-start
  attr — restored so a start-trim updates the preview's in-point immediately.
- Move/resize fallback dropped the GSAP-position sync (shift/scaleGsapPositions)
  + reloadPreview — restored so server-path edits keep GSAP tweens in sync and
  refresh the preview (the SDK path folds both into setTiming).
- Undo-coalesce drift: fallback enqueueEdit carried no coalesceKey while the SDK
  branch did — plumbed coalesceKey through persistTimelineEdit so undo
  granularity is identical on either path.
- Documented the hasPbsAdjustment second clause + sdkTimingPersist before-capture
  transition limitation.

Flag-off (dark launch) so this lands as one fix PR at the stack tip rather than
restacking the mid-stack §3.2 commit. #1500 review items: parity-harness gap
already closed at the tip (arc/unroll recast-vs-acorn parity added); blockRemoveRange
flagged 'potential' but verified correct (no comma residue on any block position).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sdk): retire duplicate removeGsapKeyframe keyframeIndex variant (review #1498)

EditOp had two removeGsapKeyframe members with the same discriminant but
different shapes (keyframeIndex vs percentage) — TS can't discriminate them and
a handler could get the wrong shape. Per both reviewers (option 2): retire the
keyframeIndex variant. It had no production caller (Studio dispatches percentage
only); removed the dead by-index handleRemoveGsapKeyframe + simplified the
dispatcher. resolveKeyframe stays (setGsapKeyframe still uses keyframeIndex).
Converted the one by-index test to the percentage API.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(studio): gate ALL cutover persist paths on the flag — true dark launch (review #1469 finding #6)

Only sdkCutoverPersist (style/text/attr) checked STUDIO_SDK_CUTOVER_ENABLED.
sdkTimingPersist, dispatchGsapOpAndPersist (every GSAP op) and sdkDeletePersist
guarded only on `!sdkSession` — and useSdkSession opens a session by default
for shadow/selection, so timing/GSAP/keyframe/delete cutover was ALWAYS live
regardless of the flag. Flipping the flag OFF could not disable it, so the
data-loss bugs in those paths (single-prop wipe, wrong-keyframe match, tween
collapse, arc strip) ship LIVE on merge instead of being dark-launched.

Added the flag guard at all three chokepoints → flag OFF returns false → callers
fall back to the legacy server path. Makes the stack genuinely dark-launchable:
merge is now a no-op in prod, and the remaining cutover correctness bugs become
flip-prerequisites rather than merge-blockers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(core,sdk): correct 8 GSAP write-path review findings (#1539)

Eight correctness bugs from the SDK-cutover review. Several were cases where
BOTH writers were identically wrong, so the recast-vs-acorn parity suite stayed
green; the new tests assert the real-world-correct result, not agreement.

- #2 findKfPropByPct: match the CLOSEST keyframe within tolerance, not the first
  within 2% — removing/updating 50% on 0/49/50/100 no longer hits 49%.
- #3 handleSetTiming: shift each tween by the start DELTA and scale duration by
  the clip-duration RATIO per-tween, instead of writing absolute newStart/
  newDuration onto every tween (which collapsed staggers and blew durations).
- #4 enableArcPath: insert motionPath via appendRight at the object start so the
  insertion can't collide with the x/y remove-range end (which made MagicString
  discard the append and emit '{}').
- #5 splitAnimationsInScript: compute the inherited baseline in a forward pre-pass
  so the split-spanning midpoint sees earlier tweens (the reverse write loop is
  kept for stable count-suffixed ids).
- #9 unrollDynamicAnimations: preserve non-target loop-body statements (e.g.
  tl.set initial-state) per iteration instead of overwriting the whole loop.
- #10 buildMotionPathObjectCode (both writers): emit the cubic form when segment
  curviness varies so per-segment curviness survives, not just segments[0].
- #11 readLastWaypointXY: handle UnaryExpression so negative destination coords
  are recovered when disabling an arc path.
- #15 no-bang: removed every `!` non-null assertion in the touched files,
  replaced with guards/fallbacks.

Tests: gsapWriter.reviewFixes.test.ts (#2/#4/#5/#9/#10/#11) and
mutate.gsap.test.ts setTiming GSAP-sync block (#3). All fail on the base and
pass after the fix; tsc + full core/sdk suites + parity stay green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(studio): SDK cutover review fixes — merge tween props, stabilize debounce, serialize gsap writes, on-disk undo baseline, self-write identity

Addresses 5 SDK-cutover review findings (studio-only):

- #1 useGsapPropertyDebounce: editing one GSAP tween property no longer drops
  the tween's other animated props. setGsapTween REPLACES the property set, so
  merge the single edit into the tween's CURRENT properties (read from the SDK
  doc) before dispatching, mirroring the legacy server merge.
- #7 useGsapPropertyDebounce: stabilize the flush callback by reading sdk deps
  from a ref instead of an unmemoized literal, so a parent re-render mid-edit
  no longer tears down + flushes the debounce (one commit/undo entry per render).
- #8 sdkCutover/useGsapScriptCommits: route SDK gsap-write persists through the
  same per-file keyed serializer the legacy commitMutation uses, so concurrent
  same-file read-modify-writes can't interleave and lose an edit.
- #12 sdkCutover/useTimelineEditing: capture the exact on-disk bytes as the undo
  'before' for timing/GSAP persists (matching the style/delete paths) instead of
  a normalized SDK serialize() re-emit that reformatted the whole file on undo.
- #14 useSdkSession/sdkSelfWriteRegistry: discriminate a cutover echo from an
  undo write by CONTENT identity (registered self-write hash), not just the 2 s
  timestamp window — an undo write always reloads the SDK session.

Tests: useGsapPropertyDebounce(.test), useGsapPropertyDebounceFlush.test,
sdkSelfWriteRegistry.test, and new sdkCutover.test cases; each reproduces the
review scenario and asserts the corrected behavior (verified red before fix).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(core): extract split/collapse helpers to satisfy no-fallow-ignore rule

The #5 (split) and #15 (no-bang guards) fixes pushed splitAnimationsInScript and
removeAllKeyframesFromScript over fallow's complexity threshold, and a fallow-ignore
had been added to splitAnimationsInScript. Per the hard rule (never ignore — fix),
extracted buildSpanningSplit + applyTweenSplit (split) and buildCollapsedFlatVars
(collapse), and removed the ignore. Both functions now under threshold; fallow new-only
gate reports 0 new findings. Behavior unchanged — core 1811 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(studio): pin dark-launch flag-gate contract (review #1539, Rames/Via)

flag OFF ⇒ sdkTimingPersist / sdkGsapTweenPersist (GSAP-op chokepoint) /
sdkDeletePersist all return false even with a valid session → legacy fallback.
The prod flag-flip rests on this contract; sdkCutover.test.ts only mocks the flag
TRUE, so a future gate refactor could silently re-enable cutover on flag-off
without failing CI. This sibling file mocks it FALSE and locks the three guards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(studio): leading flag-gate on sdkGsapTweenPersist (review #1539 nit, Via)

The add-op getElement existence check ran before the inner gate, so flag-off did
an SDK touch before falling back. Lead with the flag guard to match the other
three chokepoints — flag-off is now a clean no-op at every entry point.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(core): unroll-preservation regressions — non-for loops + AST index substitution (review R2)

The #9 unroll-preservation fix had two confirmed regressions:
- Non-for loops (forEach/for-of/for-in/while): loopIndexVarName returns null, so
  substitution no-op'd and preserved siblings kept a now-undefined loop variable
  (e.g. `item`) → ReferenceError at render. Now returns null for those forms →
  caller falls back to the blanket loop overwrite (drops siblings, valid code).
  The #9 fixture only used `for(let i…)` so it never caught this.
- substituteLoopIndex did a \bvar\b regex over raw source including string
  literals, corrupting selectors like ".row-i" → ".row-0". Now AST-based:
  substitutes only real Identifier uses, skipping string literals and non-computed
  member/key positions (extracted isIndexBindingPosition helper to stay under the
  fallow complexity threshold — no ignore added).

Two regression tests added (forEach no-dangling-var; for-loop string-literal intact).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sdk,core): unrollDynamicAnimations rejects empty element list (R1 #1501b)

An empty `elements` array has no unrolled form — the writer would overwrite
the loop/statement with zero tween calls, silently deleting the animation.

- gsapWriterAcorn: unrollDynamicAnimations returns the script verbatim on an
  empty list (no-op instead of a destructive overwrite).
- validateOp: reject unrollDynamicAnimations with empty elements as
  E_INVALID_ARGS so callers get a clean error rather than silent corruption.
- Tests: writer no-op on []; validateOp E_INVALID_ARGS on [].

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(sdk): cache draft element in applyDraft, drop HTMLElement casts (R1 #1490a)

applyDraft runs at 60fps during a drag but re-ran doc.querySelector on every
call — the _draftEl/_draftId fields were only consumed by commit/cancel, never
to skip the query. Reuse the tracked element when the id matches and the node
is still connected; re-query only on id change or detach (iframe reload).

Retypes _draftEl to HTMLElement | null (only ever set from
querySelector<HTMLElement>), which removes the `as HTMLElement` casts in
commitPreview / _clearDraft. Test asserts a repeated same-id drag queries once.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sdk,core): round-3 correctness — unroll AST safety, single-dispatch undo, empty-arg guards, persist decouple

Addresses the highest-severity round-3 review findings:

- gsapWriterAcorn unroll (R3 #1/#2/#9): the round-2 AST-substitution fix emitted
  invalid GSAP for object shorthand `{ i }` (→ `{ 0 }`) and shadowed inner
  bindings (→ `for(let i=0;0<3;0++)`), and silently dropped sibling statements on
  non-`for` loops (forEach/for-of). The unroll now REFUSES (no-ops, leaving the
  dynamic loop intact) whenever siblings can't be safely reproduced — a non-`for`
  loop, an unmodeled statement, or an unsafe index use — instead of dropping or
  corrupting. Plain `for` loops with safe siblings still unroll.

- session single-dispatch undo (R3 #5/#11): _dispatch now reverses the inverse
  patch list (parity with batch()). A single op emitting order-dependent inverse
  patches — a nested parent+child removeElement, an aliased multi-target — undid
  forward and dropped the child subtree / landed on an intermediate value.

- materializeKeyframes empty-array (R3 #10): the unguarded twin of the just-fixed
  unrollDynamicAnimations. Writer no-ops on an empty keyframe list; validateOp
  rejects it as E_INVALID_ARGS (shared gsapScriptMissing helper).

- history:false persist decouple (R3 #4): persist (auto-save) no longer lives
  inside the history-enable block, so opting out of SDK undo no longer silently
  disables all disk writes (data-loss trap for #1496's flag consumers).

Tests: unroll refuse cases (shorthand/shadow/forEach) + safe-for-loop regression;
nested removeElement undo; materializeKeyframes writer no-op + validateOp reject;
history:false-still-persists.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(core): stripGsapForId re-parses per removal so all tweens for a deleted element are stripped (R3 #3)

Animation ids are count-based (positional), so removing one tween renumbers the
survivors. stripGsapForId captured every matching id from a single up-front parse
then removed against the mutating script — after the first removal the later ids
were stale and silently no-op'd, leaving an orphaned tl.to() referencing the
just-deleted element. Now re-parse after each removal and strip the first
still-matching animation until none remain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(core): gsap writer — keyframe ease routing, convert preserves delay, addLabel dedup (R3 #7/#8/#12)

- #7: updateAnimationInScript routes an ease update on a keyframe tween to
  keyframes.easeEach (per-keyframe), not a top-level ease that GSAP ignores —
  the user's keyframe-easing edit was silently a no-op.
- #8: convertToKeyframesFromScript now preserves every non-editable vars key
  (delay/callbacks/stagger/yoyo/…) verbatim via preservedVarsEntries instead of
  rebuilding from the GsapAnimation object, which had no `delay` field and
  dropped it — shifting the tween's start time.
- #12: addLabelToScript moves an existing same-named label (overwrites its
  position) instead of appending a duplicate; duplicates made removeLabel
  over-remove (it deletes every match, including a pre-existing label).

Tests: easeEach routing, delay preservation, addLabel move-not-duplicate +
hand-authored-dup removal. Updated the old "no dedup contract" corpus test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sdk): handleSetTiming #domId + data-duration sync; validateOp resolves ids + arc/selector (R3 #6/#13, CF2 #15/#16)

CF2 #15: handleSetTiming re-synced GSAP tweens only when the selector matched the
element's hf-id. The common #domId-targeted tween (authored by the Studio panel)
never matched, so moving/resizing a clip via the SDK timing path left its
animations unsynced. Now match the tween selector against the DOM id too.

CF2 #16: handleSetTiming read/wrote only data-end. Clips authored with
data-duration (what the runtime prefers) got a fresh data-end beside a stale
data-duration (no playback change) and oldDuration=null collapsed the GSAP
duration-scale ratio to 1. Now read duration preferring data-duration, and write
back to whichever attribute the clip uses (timingPath gains a "duration" field).

R3 #13b: deleteAllForSelector compared selectors with strict === and missed the
alternate quote style ([data-hf-id='x'] vs "x"); now quote-insensitive.

R3 #6/#13a: validateOp now resolves the animationId for id-bearing GSAP ops
(E_TARGET_NOT_FOUND instead of a misleading ok that no-ops at apply), and
updateArcSegment validates the arc is enabled + the segment index is in range.

Tests: #domId move sync, data-duration resize + scale, quote-insensitive delete,
unresolved-id rejection, arc-segment preconditions. Updated the loose-can() test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(core,sdk): name the acorn-node type alias; keyToPath round-trips timing.duration (R3 #14)

- gsapWriterAcorn: replace the bare `: any` AST-node annotations with the named
  `type Node = any` alias, matching the established convention in
  gsapParserAcorn.ts / gsapInline.ts ("acorn ESTree nodes are structurally
  untyped"). Documents intent and is greppable; type-identical (zero runtime
  change). A full ESTree typing is a deliberate architecture decision the
  codebase has not taken and is out of scope here.
- patches: keyToPath/timingPath now include the "duration" timing field added
  for the data-duration resize fix, so a timing.duration override round-trips on
  T3 replay instead of being dropped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sdk): cascadeRemoveAnimations re-parses per removal (R4 — SDK twin of #3)

cascadeRemoveAnimations captured every matching animation id from a single
up-front parse, then removed against the mutating script — the SDK-side twin of
the stripGsapForId bug (R3 #3). Animation ids are positional, so removing the
first tween for an element renumbered the survivors and the stale later ids
no-op'd, orphaning those tweens on the just-removed element. Now re-parse after
each removal and strip the first still-matching animation until none remain.

Also adds the reviewer's defense-in-depth test: an aliased multi-target setStyle
(same id twice) undoes to the original, not the intermediate (exercises the
single-dispatch inverse reversal from R3 #5/#11).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

5 participants