diff --git a/packages/sdk/src/engine/mutate.gsap.test.ts b/packages/sdk/src/engine/mutate.gsap.test.ts
index e12a6624f..4e2596714 100644
--- a/packages/sdk/src/engine/mutate.gsap.test.ts
+++ b/packages/sdk/src/engine/mutate.gsap.test.ts
@@ -419,3 +419,61 @@ describe("removeLabel", () => {
expect(result.forward).toHaveLength(0);
});
});
+
+// ─── removeElement GSAP cascade ──────────────────────────────────────────────
+
+describe("removeElement — GSAP cascade", () => {
+ it("removes animations targeting the removed element from the script", () => {
+ const parsed = fresh();
+ const result = applyOp(parsed, { type: "removeElement", target: "hf-box" });
+ // forward: [remove_element, replace_script]
+ expect(result.forward).toHaveLength(2);
+ expect(result.forward[0]).toEqual({ op: "remove", path: "/elements/hf-box" });
+ const newScript = String(result.forward[1]?.value ?? "");
+ expect(newScript).not.toContain("hf-box");
+ });
+
+ it("inverse restores element AND script", () => {
+ const parsed = fresh();
+ const { inverse } = applyOp(parsed, { type: "removeElement", target: "hf-box" });
+ // inverse[0] = restore element, inverse[1] = restore script
+ expect(inverse).toHaveLength(2);
+ expect(inverse[0]?.op).toBe("add");
+ expect(inverse[0]?.path).toBe("/elements/hf-box");
+ expect(inverse[1]?.op).toBe("replace");
+ expect(inverse[1]?.path).toBe("/script/gsap");
+ const restoredScript = String(inverse[1]?.value ?? "");
+ expect(restoredScript).toContain("hf-box");
+ });
+
+ it("applying inverse restores element and GSAP script to original", () => {
+ const parsed = fresh();
+ const origScript = getScript(parsed);
+ const { inverse } = applyOp(parsed, { type: "removeElement", target: "hf-box" });
+ applyPatchesToDocument(parsed, inverse);
+ expect(parsed.document.querySelector('[data-hf-id="hf-box"]')).not.toBeNull();
+ expect(getScript(parsed)).toBe(origScript);
+ });
+
+ it("emits only element patch when composition has no GSAP script", () => {
+ const noScriptHtml = `
`.trim();
+ const parsed = parseMutable(noScriptHtml);
+ const result = applyOp(parsed, { type: "removeElement", target: "hf-box" });
+ expect(result.forward).toHaveLength(1);
+ expect(result.forward[0]?.op).toBe("remove");
+ });
+
+ it("does not remove animations targeting other elements", () => {
+ const twoTweenScript = `var tl = gsap.timeline({ paused: true });
+tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0);
+tl.to("[data-hf-id=\\"hf-stage\\"]", { scale: 1.05, duration: 1 }, 0);
+window.__timelines["t"] = tl;`;
+ const parsed = fresh(twoTweenScript);
+ const result = applyOp(parsed, { type: "removeElement", target: "hf-box" });
+ const newScript = String(result.forward[1]?.value ?? "");
+ expect(newScript).not.toContain("hf-box");
+ expect(newScript).toContain("hf-stage");
+ });
+});
diff --git a/packages/sdk/src/engine/mutate.ts b/packages/sdk/src/engine/mutate.ts
index e1e949fcf..53e571fb5 100644
--- a/packages/sdk/src/engine/mutate.ts
+++ b/packages/sdk/src/engine/mutate.ts
@@ -344,9 +344,7 @@ function handleSetTiming(
// so without this the runtime would overwrite our attribute edits on next init.
if (parsedGsap && currentScript) {
for (const { id: animId, animation } of parsedGsap.located) {
- const sel = animation.targetSelector;
- if (sel !== `[data-hf-id="${id}"]` && sel !== `[data-hf-id='${id}']` && sel !== `#${id}`)
- continue;
+ if (!selectorMatchesId(animation.targetSelector, id)) continue;
const updates: Partial = {};
if (timing.start !== undefined && newStart !== null) updates.position = newStart;
if (timing.duration !== undefined && newDuration !== null) updates.duration = newDuration;
@@ -398,6 +396,9 @@ function handleSetHold(
function handleRemoveElement(parsed: ParsedDocument, ids: HfId[]): MutationResult {
const result: MutationResult = { forward: [], inverse: [] };
+ const origScript = getGsapScript(parsed.document);
+ let currentScript = origScript;
+
for (const id of ids) {
const el = findById(parsed.document, id);
if (!el) continue;
@@ -411,7 +412,17 @@ function handleRemoveElement(parsed: ParsedDocument, ids: HfId[]): MutationResul
const path = elementPath(id);
result.forward.push(patchRemove(path));
result.inverse.push(patchAdd(path, { html, parentId, siblingIndex }));
+
+ if (currentScript) currentScript = cascadeRemoveAnimations(currentScript, id);
+ }
+
+ if (origScript && currentScript && currentScript !== origScript) {
+ setGsapScript(parsed.document, currentScript);
+ const gsapResult = gsapScriptChange(origScript, currentScript);
+ result.forward.push(...gsapResult.forward);
+ result.inverse.push(...gsapResult.inverse);
}
+
return result;
}
@@ -487,6 +498,28 @@ function handleSetVariableValue(
return { forward: [p.forward], inverse: [p.inverse] };
}
+// ─── GSAP selector helpers ───────────────────────────────────────────────────
+
+function selectorMatchesId(selector: string, id: HfId): boolean {
+ return (
+ selector === `[data-hf-id="${id}"]` ||
+ selector === `[data-hf-id='${id}']` ||
+ selector === `#${id}`
+ );
+}
+
+function cascadeRemoveAnimations(script: string, id: HfId): string {
+ const parsedGsap = parseGsapScriptAcornForWrite(script);
+ if (!parsedGsap) return script;
+ let current = script;
+ for (const { id: animId, animation } of parsedGsap.located) {
+ if (selectorMatchesId(animation.targetSelector, id)) {
+ current = removeAnimationFromScript(current, animId);
+ }
+ }
+ return current;
+}
+
// ─── setClassStyle handler ────────────────────────────────────────────────────
function handleSetClassStyle(
diff --git a/packages/sdk/src/session.test.ts b/packages/sdk/src/session.test.ts
index fd356a6ff..a01aa3db2 100644
--- a/packages/sdk/src/session.test.ts
+++ b/packages/sdk/src/session.test.ts
@@ -149,3 +149,68 @@ describe("batch rollback on throw", () => {
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#fff");
});
});
+
+// ─── canUndo / canRedo ────────────────────────────────────────────────────────
+
+describe("canUndo / canRedo", () => {
+ it("returns false before any mutation", async () => {
+ const comp = await openComposition(BASE_HTML);
+ expect(comp.canUndo()).toBe(false);
+ expect(comp.canRedo()).toBe(false);
+ });
+
+ it("canUndo true after a mutation, false after undoing back to start", async () => {
+ const comp = await openComposition(BASE_HTML);
+ comp.setStyle("hf-title", { color: "#ff0000" });
+ expect(comp.canUndo()).toBe(true);
+ expect(comp.canRedo()).toBe(false);
+
+ comp.undo();
+ expect(comp.canUndo()).toBe(false);
+ expect(comp.canRedo()).toBe(true);
+ });
+
+ it("canRedo cleared after a new mutation", async () => {
+ const comp = await openComposition(BASE_HTML);
+ comp.setStyle("hf-title", { color: "#ff0000" });
+ comp.undo();
+ expect(comp.canRedo()).toBe(true);
+
+ comp.setStyle("hf-title", { color: "#00ff00" });
+ expect(comp.canRedo()).toBe(false);
+ });
+
+ it("returns false in embedded (T3) mode — no history", async () => {
+ const comp = await openComposition(BASE_HTML, { overrides: {} });
+ comp.setStyle("hf-title", { color: "#ff0000" });
+ expect(comp.canUndo()).toBe(false);
+ expect(comp.canRedo()).toBe(false);
+ });
+});
+
+// ─── override-set orphan cleanup ──────────────────────────────────────────────
+
+describe("override-set orphan cleanup on removeElement", () => {
+ it("purges property keys for removed element from the override-set", async () => {
+ const comp = await openComposition(BASE_HTML);
+ comp.setStyle("hf-title", { color: "#ff0000", fontSize: "96px" });
+ expect(Object.keys(comp.getOverrides())).toContain("hf-title.style.color");
+
+ comp.removeElement("hf-title");
+ const overrides = comp.getOverrides();
+ // removal marker present
+ expect(overrides["hf-title"]).toBeNull();
+ // orphan property keys gone
+ expect(Object.keys(overrides)).not.toContain("hf-title.style.color");
+ expect(Object.keys(overrides)).not.toContain("hf-title.style.fontSize");
+ });
+
+ it("property keys for other elements are unaffected", async () => {
+ const comp = await openComposition(BASE_HTML);
+ comp.setStyle("hf-title", { color: "#ff0000" });
+ comp.setStyle("hf-sub", { opacity: "1" });
+ comp.removeElement("hf-title");
+ const overrides = comp.getOverrides();
+ expect(overrides["hf-sub.style.opacity"]).toBe("1");
+ });
+});
diff --git a/packages/sdk/src/session.ts b/packages/sdk/src/session.ts
index 252b7ed42..730597f3a 100644
--- a/packages/sdk/src/session.ts
+++ b/packages/sdk/src/session.ts
@@ -147,6 +147,14 @@ class CompositionImpl implements Composition {
this.historyModule?.redo();
}
+ canUndo(): boolean {
+ return this.historyModule?.canUndo() ?? false;
+ }
+
+ canRedo(): boolean {
+ return this.historyModule?.canRedo() ?? false;
+ }
+
// ── Query API (F1) ───────────────────────────────────────────────────────────
getElements(): ElementSnapshot[] {
@@ -228,6 +236,19 @@ class CompositionImpl implements Composition {
}
}
+ // Purge orphan property keys for removed elements so the override-set stays
+ // compact and a future T3 session doesn't replay stale properties onto a
+ // non-existent element.
+ for (const p of forward) {
+ const elemMatch = /^\/elements\/([^/]+)$/.exec(p.path);
+ if (p.op === "remove" && elemMatch) {
+ const id = elemMatch[1]!;
+ for (const key of Object.keys(this.overrides)) {
+ if (key.startsWith(`${id}.`)) delete this.overrides[key];
+ }
+ }
+ }
+
if (this.batchDepth > 0) {
this.batchForward.push(...forward);
this.batchInverse.push(...inverse);
diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts
index d78829332..a1ca05d3d 100644
--- a/packages/sdk/src/types.ts
+++ b/packages/sdk/src/types.ts
@@ -229,6 +229,8 @@ export interface Composition {
removeGsapTween(animationId: string): void;
undo(): void;
redo(): void;
+ canUndo(): boolean;
+ canRedo(): boolean;
// ── Query API (F1) ─────────────────────────────────────────────────────────
getElements(): ElementSnapshot[];
diff --git a/sdk-status-report.txt b/sdk-status-report.txt
new file mode 100644
index 000000000..abd054c22
--- /dev/null
+++ b/sdk-status-report.txt
@@ -0,0 +1,191 @@
+@hyperframes/sdk — Status Report
+June 13, 2026
+
+
+OVERVIEW
+
+The SDK is a headless, framework-neutral composition editing engine for HyperFrames.
+It lets hosts (Studio, AI agents, Pacific/HeyGen) read and mutate compositions through
+a typed method layer that emits RFC 6902 patches. Work is split into 9 stages.
+Stages 1–3a are complete and merged. Stages 3b and the acorn parser stack are code
+complete and pending review. An interactive playground (sdk-playground) now exercises
+the full op surface end-to-end against a live preview and file-backed persistence.
+
+
+WHAT IS MERGED (DONE)
+
+Stage 1 — Design Decisions
+ All three gate decisions locked:
+ - Brand representation: CSS variables (not data-brand-* attributes)
+ - Timeline model: per-element start/duration/hold
+ - Storage model: content-addressed SHA-256 keys
+
+Stage 2 — Foundation (Phase 1)
+ Package scaffolding, types, GSAP serializer, PersistAdapter contract suite (T13),
+ three host-archetype usage examples. Fully shipped.
+
+Stage 3 — Browser-safe Parser (Phase 2)
+ Replaced recast/Babel (Node-only, 500KB) with acorn + magic-string (15KB, browser-safe).
+ This was the technical gate for all editing operations. Five PRs in a Graphite stack:
+ - #1338 lint rule: warn on missing data-no-timeline
+ - #1368 acorn read path + differential tests
+ - #1369 acorn write path (magic-string offset-splice)
+ - #1370 parse-parity suite (recast vs acorn agreement)
+ - #1392 swap studio-api files.ts from recast to acorn
+
+Stage 3a — Session API (Phase 3a)
+ Core session: document model, dispatch/patch loop, history coalescing,
+ PersistAdapter queue, optional history module. Merged PR #1325.
+
+Gesture-to-Keyframes Stack
+ The prerequisite for Studio migration step 3 (edit ops). PRs #1301 and #1311 merged.
+
+
+WHAT IS CODE COMPLETE, IN REVIEW (latest first)
+
+Stage 4 (partial) — can() + T4 tests + FsAdapter T13 [June 13, PR #1425/#1426]
+ can() now returns structured CanResult ({ok:true} | {ok:false, code, message, hint?})
+ instead of a bare boolean. validateOp() in mutate.ts matches the same shape.
+ CAN_OK constant + canErr() helper keep callers concise.
+ T4 dispatch-boundary tests (session.dispatch.test.ts) cover:
+ - patch events fire after dispatch
+ - override-set pipeline via setVariable
+ - can() CanResult shape for valid/invalid ops
+ - batch() applies all ops and emits a single patch event
+ - addGsapTween round-trip via session
+ - custom origin forwarded through patch event
+ FsAdapter flush() was a silent no-op: in-flight writes could be abandoned before
+ they settled, causing a torn write on fast save-quit. Fixed with inflightWrites:
+ Set> — flush() now awaits all in-flight promises. Also fixed a
+ same-millisecond version-key collision with a versionCounter monotonic suffix.
+ FsAdapter is now exercised by the T13 PersistAdapter contract suite, closing the
+ gap between the stub and the contract.
+
+Stage 3b — GSAP Editing Engine (Phase 3b)
+ 9 new SDK operations shipped in PR #1379:
+ addGsapTween, setGsapTween, removeGsapTween
+ addGsapKeyframe, setGsapKeyframe, removeGsapKeyframe
+ addLabel, removeLabel
+ setClassStyle
+
+ A code review (June 12) found and fixed 6 bugs before the stack merges:
+ 1. Program-scope variable bindings were silently lost (querySelector at top
+ level was never resolved). Fixed with a null-key fallback in the scope chain.
+ 2. fromTo argument guard was too loose (could read undefined args[2]/args[3]).
+ 3. removeAnimation had a fuzzing fallback that silently deleted the wrong
+ animation by converting -from- IDs to -to- IDs. Removed.
+ 4. stagger property was handled in addGsapTween but missing from setGsapTween.
+ 5. apply-patches script case had no handler for op=remove.
+ 6. valueToCode had no NaN guard and used a wrong regex for property key safety.
+
+ setTiming GSAP-sync fix (June 13, found via playground)
+ setTiming was a silent no-op for animation timing on GSAP compositions: it
+ stamped data-start/data-end on the DOM node, but the runtime re-stamps those
+ attributes FROM GSAP positions on next init, so the edit was overwritten.
+ handleSetTiming now also rewrites the GSAP script (parseGsapScriptAcornForWrite
+ + updateAnimationInScript), flushing both models in one patch pair so they stay
+ in sync. DAW-style trim in the playground depends on this.
+
+ Stack is awaiting re-stamp from reviewers (Miguel on #1379, Rames on #1368/#1370).
+
+Stage 5 — Adapters (partial, in playground)
+ fs PersistAdapter is now implemented (was a stub): node:fs/promises read/write,
+ timestamped version history under .hf-versions//, prune to maxVersions (20),
+ listVersions/loadFrom. Still needs to be run against the T13 contract suite.
+ A fetch-based PersistAdapter (browser → Vite dev-server endpoints) provides the
+ HTTP shape ahead of the real post-Pacific HTTP adapter.
+ A concrete PreviewAdapter (PlaygroundPreview) implements select/on('selection')
+ and the draft/commit/cancel stubs — first real impl beyond the contract.
+ S3 + production HTTP adapters and the headless null PreviewAdapter remain.
+
+Stage 8 — Packaging and DX (partial)
+ sdk-playground shipped: interactive browser harness over the full op surface
+ (setStyle/setText/setAttribute/removeElement/setVariableValue/find/selection
+ proxy/all 9 GSAP+label ops/setClassStyle), live preview iframe with click-select
+ and drag-to-reposition, DAW-style timeline trim via setTiming, file-backed
+ persistence with version history, undo/redo/can/getOverrides/flush, raw-HTML
+ editor modal. README documents built vs planned.
+ @hyperframes/editor drop-in, CDN bundle, npm create scaffold, and the docs
+ ladder remain.
+
+
+WHAT IS NOT STARTED
+
+Stage 4 remainder
+ removeElement v1 — cascade rules, override-set removal, soft/hard op classification,
+ inverse patch carrying full serialized subtree.
+ createHistory — 300ms coalescing, origin-scoped.
+
+Stage 5 remainder — Adapters (Phase 4)
+ S3 and production HTTP PersistAdapters (post-Pacific).
+ PreviewAdapter: headless null adapter for agents and CI.
+
+Stage 6 — Advanced (Phase 5)
+ Sub-composition editing via scoped element IDs.
+ Soft vs hard op classification (reversible vs destructive).
+ Testing utilities for hosts building on the SDK.
+
+Stage 7 — Studio Migration
+ 5-step feature-flag migration. Each step can be rolled back independently.
+ Step 1: persistence
+ Step 2: selection
+ Step 3: edit ops (commit handlers become dispatch calls — absorbs the old R5 track)
+ Step 4: history
+ Step 5: App.tsx collapse
+ Canvas, panel, and timeline components land in a new @hyperframes/react package.
+ This stage is roughly as much work as everything shipped so far.
+
+Stage 8 remainder — Packaging and DX
+ @hyperframes/editor: 5-line drop-in component plus CDN bundle.
+ npm create scaffold (playground itself is shipped — see above).
+ Full documentation ladder (quickstart, guides, generated reference, architecture explanation).
+
+Stage 9 — Pacific / AI Studio Integration
+ HyperframesElement draft type, draft_to_edit classification.
+ heygen_video_workflow fan-out to hyperframes_producer_activity (transparent WebM at
+ graphic-clip level).
+ Embedded session wiring, word-aligned timing with shared resolver.
+
+
+SEPARATE TRACKS
+
+Runtime bridge freeze (R6)
+ Rename window.__* globals to versioned __HYPERFRAMES.* namespace.
+ Gated on Phase 1 (complete). Not yet scheduled.
+
+Write-path migration (T6f)
+ The read path now uses acorn everywhere. The write path (convertToKeyframes,
+ removeAllKeyframes, setArcPath, etc.) still uses recast. Full migration is the
+ next post-merge task for the parser team.
+
+GSAP plugin support branch
+ feat/gsap-plugin-support: code complete, 95 tests, no PR ever opened.
+ Branch is 79 days old and may be superseded by the acorn parser work.
+ Decision needed: open a PR, archive, or rebase onto current stack.
+
+
+ROUGH PROGRESS
+
+Stages complete or in review: 1, 2, 3 (all), 3a, 3b, 4 (partial: can/T4/FsAdapter)
+Stages partially done: 5 (fs + fetch adapters, PreviewAdapter impl),
+ 8 (playground shipped)
+Stages not started: 4 (remainder: removeElement, createHistory), 6, 7, 9
+
+Current stack is approximately 40% of the full roadmap by stage count.
+Stage 7 (Studio Migration) and Stage 9 (Pacific) are the largest remaining efforts.
+
+
+OPEN QUESTIONS FROM PRD
+
+ 1. commitPreview() location — recommendation: core session API over PreviewAdapter
+ 2. setHold surface — recommendation: dispatch op
+ 3. Render granularity: per-element WebM now, per-scene layers later?
+ 4. Animation markers: expose GSAP labels for host-level sequencing?
+ 5. North star: model the entire Pacific draft as one SDK document?
+
+
+CONTACTS
+
+Acorn parser / Phase 3b: Vance
+Studio migration: TBD
+Pacific integration: TBD