From ff4dca24885f107521422923b59b2d534ef0f81b Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sun, 14 Jun 2026 00:25:21 -0700 Subject: [PATCH] =?UTF-8?q?feat(sdk):=20stage=204=20=E2=80=94=20canUndo/ca?= =?UTF-8?q?nRedo,=20removeElement=20GSAP=20cascade,=20override-set=20clean?= =?UTF-8?q?up?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/sdk/src/engine/mutate.gsap.test.ts | 58 ++++++ packages/sdk/src/engine/mutate.ts | 39 +++- packages/sdk/src/session.test.ts | 65 +++++++ packages/sdk/src/session.ts | 21 +++ packages/sdk/src/types.ts | 2 + sdk-status-report.txt | 191 ++++++++++++++++++++ 6 files changed, 373 insertions(+), 3 deletions(-) create mode 100644 sdk-status-report.txt 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