feat(sdk,studio): populate animationIds; shadow GSAP update/delete + value fidelity (#1474)

* feat(sdk,studio): populate animationIds; shadow GSAP update/delete

Closes the GSAP shadow gaps. The server's animationId was assumed to live in a
separate id-space — it does not: the studio-api read path (T6e) and the SDK
both derive tween ids as targetSelector-method-position from the same acorn
parser, so server ids are dispatchable in the SDK as-is.

SDK: populate ElementSnapshot.animationIds (was a hardcoded stub) from
parseGsapScriptAcornForWrite().located, resolving each tween's targetSelector
to element hf-ids. Makes the snapshot truthful and enables real GSAP parity.

Studio: shadow deleteGsapAnimation (removeGsapTween) and updateGsapMeta
(setGsapTween) using the server animationId directly. GSAP add/remove parity
now verifies via animationIds (present after add, gone after remove). set is
existence-only — the SDK still has no per-tween property reader (value fidelity
would need serialize()-script round-trip diffing).

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

* feat(studio): GSAP value fidelity via serialize round-trip diff

Closes the last shadow gap: GSAP value fidelity. Existence parity confirmed a
tween was created/removed but not that its values (duration/ease/position/
properties) matched the server, since the SDK has no per-tween property reader.

runShadowGsapFidelity opens a fresh SDK doc from the server's pre-op file
(result.before), applies the same typed op, serializes, and structurally diffs
the SDK's GSAP script against the server's resulting script (result.scriptText).
Both are re-parsed via parseGsapScriptAcorn, so formatting/whitespace never
produces false positives — only real value drift does. gsapFidelityMismatches
reports per-field drift and tween presence/absence.

Wired at the commitMutation chokepoint (the only place with the server's
before+after scripts); handlers pass the typed ShadowGsapOp via
CommitMutationOptions.shadowGsapOp. Emits sdk_shadow_dispatch op:gsap_fidelity.
Complements the existing live existence shadow (op:gsap).

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

* fix(studio,sdk): address shadow code-review findings

- gsapFidelityMismatches: canonical comparison (sort property keys, numeric-
  coerce position/duration/values). Server (addAnimationToScript) and SDK
  (gsapWriterAcorn) are different writers; non-canonical compare flagged
  key-order / number-vs-string differences as false value drift.
- document.ts buildAnimationIdMap: memoize the acorn parse by script text
  (single-entry). getElements() invalidates on every dispatch, so shadow's
  frequent dispatches were re-parsing the full GSAP AST each rebuild. Selector
  resolution still runs per-call (depends on live DOM).
- runShadowGsapFidelity: early-bail when serverScript/beforeHtml is empty —
  skip the costly openComposition.
- useSafeGsapCommitMutation: import the shared CommitMutationOptions/
  CommitMutation instead of a stale local duplicate (was missing shadowGsapOp).
- align extractGsapScript marker set across sdkShadow.ts and document.ts
  (gsap || __timelines || ScrollTrigger) so both pick the same script.

Tests: +2 canonical-compare cases (key-order, number-vs-string → no drift).

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

* fix(studio,sdk): fallow gate for #1474 (fidelity diff + test clones)

- suppress moderate CRAP on gsapFidelityMismatches and the runShadowGsapTween
  parity arrow (comparison/parity functions are inherently branchy)
- suppress two pre-existing test clones in session.test.ts surfaced by the
  added animationIds tests (TestPreviewAdapter stub, selectionchange setup)

Rebased onto the updated #1473 (no-persist shadow session); inherits the
persist-race fix and prior fallow suppressions.

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

* refactor(studio): extract GSAP fidelity to its own module (file-size gate)

sdkShadow.ts hit 602 lines (CI File size check: max 600). Move the GSAP
value-fidelity diff (gsapFidelityMismatches, runShadowGsapFidelity, and their
private helpers) into sdkShadowGsapFidelity.ts; re-export from sdkShadow.ts so
the import surface is unchanged. sdkShadow.ts now 430 lines.

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

* fix(studio,sdk): address #1474 review feedback

- CodeQL js/bad-tag-filter: the GSAP <script> extraction + test regexes now
  match </script\s*> (whitespace-before-close variant). 3 alerts resolved.
- Wiring (Miguel): extract resolveGsapFidelityArgs — a pure, narrowing gate for
  the commitMutation chokepoint (no non-null assertions) — and unit-test the
  fire/skip conditions (session, op, before, scriptText). Replaces the inline
  guard so the wiring decision is covered without rendering the hook.
- Property-handler scope (Rames): comment at the chokepoint documenting that
  only meta-level ops (add/update-meta/delete) carry shadowGsapOp today;
  per-property and keyframe handlers are a deliberate follow-up. Also why
  scriptText can be null.
- Test coverage (Rames): multi-tween-per-element and shared-selector
  cross-element animationIds cases.

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

* fix(studio): CodeQL js/bad-tag-filter — match </script[^>]*> close tags

`</script\s*>` still tripped CodeQL on attribute-junk closes like
`</script foo>` (HTML5 ignores junk before `>`). Widen the close-tag match to
`</script[^>]*>` in the GSAP-script extraction and the test regexes.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-06-15 22:40:28 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 5f6ced116d
commit 7593aac5ef
9 changed files with 570 additions and 64 deletions
@@ -1,8 +1,8 @@
import { useCallback } from "react";
import type { Composition, GsapTweenSpec } from "@hyperframes/sdk";
import type { Composition } from "@hyperframes/sdk";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { roundTo3 } from "../utils/rounding";
import { runShadowGsapTween } from "../utils/sdkShadow";
import { runShadowGsapTween, type ShadowGsapOp } from "../utils/sdkShadow";
import {
assignGsapTargetAutoIdIfNeeded,
ensureElementAddressable,
@@ -33,27 +33,34 @@ export function useGsapAnimationOps({
animationId: string,
updates: { duration?: number; ease?: string; position?: number },
) => {
// Shadow op (server animationId shares the SDK id-space): existence via
// runShadowGsapTween (live session) + value fidelity via the chokepoint.
const shadowGsapOp: ShadowGsapOp = {
kind: "set",
animationId,
properties: { duration: updates.duration, ease: updates.ease, position: updates.position },
};
commitMutationSafely(
selection,
{ type: "update-meta", animationId, updates },
{
label: "Edit GSAP animation",
coalesceKey: `gsap:${animationId}:meta`,
},
{ label: "Edit GSAP animation", coalesceKey: `gsap:${animationId}:meta`, shadowGsapOp },
);
if (sdkSession) runShadowGsapTween(sdkSession, shadowGsapOp);
},
[commitMutationSafely],
[commitMutationSafely, sdkSession],
);
const deleteGsapAnimation = useCallback(
(selection: DomEditSelection, animationId: string) => {
const shadowGsapOp: ShadowGsapOp = { kind: "remove", animationId };
commitMutationSafely(
selection,
{ type: "delete", animationId, stripStudioEdits: true },
{ label: "Delete GSAP animation" },
{ label: "Delete GSAP animation", shadowGsapOp },
);
if (sdkSession) runShadowGsapTween(sdkSession, shadowGsapOp);
},
[commitMutationSafely],
[commitMutationSafely, sdkSession],
);
const deleteAllForSelector = useCallback(
@@ -103,6 +110,26 @@ export function useGsapAnimationOps({
fromTo: { x: 0, y: 0, opacity: 1 },
};
// Shadow op (server stays authoritative). "set" has no SDK method, so it
// is not shadowed; otherwise: existence via runShadowGsapTween (live) +
// value fidelity via the chokepoint (shadowGsapOp in options).
const shadowGsapOp: ShadowGsapOp | undefined =
selection.hfId && method !== "set"
? {
kind: "add",
target: selection.hfId,
tween: {
method,
position,
duration,
ease: "power2.out",
...(method === "fromTo"
? { fromProperties: { opacity: 0 }, toProperties: toDefaults[method] }
: { properties: toDefaults[method] ?? { opacity: 1 } }),
},
}
: undefined;
await commitMutation(
selection,
{
@@ -115,25 +142,10 @@ export function useGsapAnimationOps({
properties: toDefaults[method] ?? { opacity: 1 },
fromProperties: method === "fromTo" ? { opacity: 0 } : undefined,
},
{ label: `Add GSAP ${method} animation` },
{ label: `Add GSAP ${method} animation`, shadowGsapOp },
);
// Shadow: dispatch the equivalent addGsapTween to the SDK (server stays
// authoritative). "set" has no SDK method, so it is not shadowed.
// ponytail: only add is shadowed — delete/update key on the server's
// animationId, which doesn't resolve in the SDK's independent id-space.
if (sdkSession && selection.hfId && method !== "set") {
const tween: GsapTweenSpec = {
method,
position,
duration,
ease: "power2.out",
...(method === "fromTo"
? { fromProperties: { opacity: 0 }, toProperties: toDefaults[method] }
: { properties: toDefaults[method] ?? { opacity: 1 } }),
};
runShadowGsapTween(sdkSession, { kind: "add", target: selection.hfId, tween });
}
if (sdkSession && shadowGsapOp) runShadowGsapTween(sdkSession, shadowGsapOp);
},
[activeCompPath, commitMutation, projectIdRef, showToast, sdkSession],
);