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
+62 -6
View File
@@ -10,6 +10,7 @@
import { parseHTML } from "linkedom";
import { ensureHfIds } from "@hyperframes/core/hf-ids";
import { parseGsapScriptAcornForWrite } from "@hyperframes/core/gsap-parser-acorn";
import { findRoot, getElementStyles, isNewHostBoundary } from "./engine/model.js";
import type { HyperFramesElement, SdkDocument } from "./types.js";
@@ -37,8 +38,60 @@ function ownText(el: Element): string | null {
return trimmed.length > 0 ? trimmed : null;
}
// Parsing the GSAP script (acorn AST walk) is the expensive part and depends
// only on the script text, so memoize the {tween id, selector} pairs by script.
// Selector→hf-id resolution still runs each call — it depends on the live DOM,
// which changes on dispatch. Single-entry cache covers the hot path (same comp,
// repeated getElements() rebuilds) and stays bounded.
let gsapLocatedCacheKey: string | null = null;
let gsapLocatedCacheVal: Array<{ id: string; selector: string }> = [];
function parseLocatedCached(script: string): Array<{ id: string; selector: string }> {
if (gsapLocatedCacheKey === script) return gsapLocatedCacheVal;
const parsed = parseGsapScriptAcornForWrite(script);
gsapLocatedCacheVal = parsed
? parsed.located.map(({ id, animation }) => ({ id, selector: animation.targetSelector }))
: [];
gsapLocatedCacheKey = script;
return gsapLocatedCacheVal;
}
/**
* Map each element's data-hf-id → the GSAP tween ids targeting it. Tween ids
* come from the acorn parser's stable `targetSelector-method-position` scheme —
* the SAME id-space the studio-api read path and the SDK GSAP ops use, so these
* ids are dispatchable as-is via setGsapTween/removeGsapTween. Best-effort: a
* malformed selector or unparseable script yields no entries (animationIds: []).
*/
function buildAnimationIdMap(document: Document): Map<string, string[]> {
const map = new Map<string, string[]>();
const script = extractGsapScript(document);
if (!script) return map;
for (const { id, selector } of parseLocatedCached(script)) {
if (!selector) continue;
let matches: Element[] = [];
try {
matches = Array.from(document.querySelectorAll(selector));
} catch {
continue; // selector not valid for querySelectorAll — skip
}
for (const el of matches) {
const hfId = el.getAttribute("data-hf-id");
if (!hfId) continue;
const list = map.get(hfId);
if (list) list.push(id);
else map.set(hfId, [id]);
}
}
return map;
}
// fallow-ignore-next-line complexity
function buildElement(el: Element, scopePrefix: string): HyperFramesElement | null {
function buildElement(
el: Element,
scopePrefix: string,
animationIdsByHfId: Map<string, string[]>,
): HyperFramesElement | null {
const tag = el.tagName.toLowerCase();
if (EXCLUDED_TAGS.has(tag)) return null;
@@ -82,7 +135,7 @@ function buildElement(el: Element, scopePrefix: string): HyperFramesElement | nu
const children: HyperFramesElement[] = [];
for (const child of Array.from(el.children)) {
const built = buildElement(child, childPrefix);
const built = buildElement(child, childPrefix, animationIdsByHfId);
if (built) children.push(built);
}
@@ -98,16 +151,18 @@ function buildElement(el: Element, scopePrefix: string): HyperFramesElement | nu
start,
duration,
trackIndex,
animationIds: [],
animationIds: animationIdsByHfId.get(id) ?? [],
};
}
// fallow-ignore-next-line complexity
function extractGsapScript(doc: Document): string | null {
// GSAP script is the first <script> tag whose text references gsap
// GSAP script is the first <script> tag whose text references gsap. Marker
// set must match studio sdkShadow.ts isGsapScriptBody so both pick the same
// script from a given composition.
for (const script of Array.from(doc.querySelectorAll("script"))) {
const text = script.textContent ?? "";
if (text.includes("gsap") || text.includes("ScrollTrigger")) {
if (text.includes("gsap") || text.includes("__timelines") || text.includes("ScrollTrigger")) {
return text;
}
}
@@ -151,9 +206,10 @@ function extractDuration(doc: Document): number | null {
export function buildRoots(document: Document): HyperFramesElement[] {
const body = document.body;
const roots: HyperFramesElement[] = [];
const animationIdsByHfId = buildAnimationIdMap(document);
if (body) {
for (const child of Array.from(body.children)) {
const built = buildElement(child, "");
const built = buildElement(child, "", animationIdsByHfId);
if (built) roots.push(built);
}
}
+64
View File
@@ -17,6 +17,7 @@ const BASE_HTML = `
class TestPreviewAdapter implements PreviewAdapter {
private selectionHandlers: Array<(ids: string[]) => void> = [];
// fallow-ignore-next-line code-duplication
elementAtPoint(_x: number, _y: number, _opts?: { atTime?: number }): ElementAtPointResult | null {
return null;
}
@@ -386,6 +387,7 @@ describe("setSelection", () => {
expect(patches).toHaveLength(0);
});
// fallow-ignore-next-line code-duplication
it("setSelection with same ids does not fire selectionchange again", async () => {
const comp = await openComposition(BASE_HTML);
const calls: string[][] = [];
@@ -419,3 +421,65 @@ describe("setSelection", () => {
expect(calls).toHaveLength(1);
});
});
describe("animationIds population", () => {
const GSAP_HTML = `
<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px">
<div data-hf-id="hf-box" style="opacity: 0">box</div>
<div data-hf-id="hf-plain">plain</div>
<script>var tl = gsap.timeline({ paused: true });
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0.2);
window.__timelines["t"] = tl;</script>
</div>`.trim();
it("attaches the parser's stable tween id to the targeted element", async () => {
const comp = await openComposition(GSAP_HTML);
const box = comp.getElement("hf-box");
expect(box?.animationIds.length).toBe(1);
// Stable id-space shared with studio-api / GSAP ops: targetSelector-method-position.
expect(box?.animationIds[0]).toContain("hf-box");
expect(box?.animationIds[0]).toContain("-to-");
});
it("leaves untargeted elements with an empty animationIds", async () => {
const comp = await openComposition(GSAP_HTML);
expect(comp.getElement("hf-plain")?.animationIds).toEqual([]);
});
it("the populated id is dispatchable as a removeGsapTween target", async () => {
const comp = await openComposition(GSAP_HTML);
const id = comp.getElement("hf-box")?.animationIds[0];
expect(id).toBeDefined();
if (id) expect(comp.can({ type: "removeGsapTween", animationId: id }).ok).toBe(true);
});
it("attaches multiple distinct tween ids when one element has several tweens", async () => {
const html = `
<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px">
<div data-hf-id="hf-box" style="opacity: 0">box</div>
<script>var tl = gsap.timeline({ paused: true });
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0);
tl.from("[data-hf-id=\\"hf-box\\"]", { x: -100, duration: 0.5 }, 1);
window.__timelines["t"] = tl;</script>
</div>`.trim();
const ids = (await openComposition(html)).getElement("hf-box")?.animationIds ?? [];
expect(ids.length).toBe(2);
expect(new Set(ids).size).toBe(2); // distinct
});
it("fans a shared-selector tween out to every matched element", async () => {
const html = `
<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px">
<div data-hf-id="hf-a" class="fade">a</div>
<div data-hf-id="hf-b" class="fade">b</div>
<script>var tl = gsap.timeline({ paused: true });
tl.to(".fade", { opacity: 1, duration: 0.5 }, 0);
window.__timelines["t"] = tl;</script>
</div>`.trim();
const comp = await openComposition(html);
const a = comp.getElement("hf-a")?.animationIds ?? [];
const b = comp.getElement("hf-b")?.animationIds ?? [];
expect(a.length).toBe(1);
expect(b).toEqual(a); // same tween id on both matched elements
});
});