Skip to content
5 changes: 1 addition & 4 deletions packages/sdk/examples/headless-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,7 @@ export async function addStaggeredEntrance(html: string, staggerDelay = 0.15): P
fromProperties: { opacity: 0, y: 30 },
} as const;
const first = textEls[0];
if (
!first ||
!comp.can({ type: "addGsapTween", target: first, id: "preflight", tween: probeTween })
) {
if (!first || !comp.can({ type: "addGsapTween", target: first, tween: probeTween }).ok) {
return comp.serialize();
}

Expand Down
4 changes: 2 additions & 2 deletions packages/sdk/examples/react-embed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,12 +98,12 @@ export function addBounceIn(comp: Composition, targetId: string): string | null
ease: "bounce.out",
fromProperties: { y: 40, opacity: 0 },
} as const;
if (!comp.can({ type: "addGsapTween", target: targetId, id: "preflight", tween })) return null;
if (!comp.can({ type: "addGsapTween", target: targetId, tween }).ok) return null;
return comp.addGsapTween(targetId, tween);
}

export function updateEase(comp: Composition, animationId: string, ease: string): void {
if (!comp.can({ type: "setGsapTween", animationId, properties: { ease } })) return;
if (!comp.can({ type: "setGsapTween", animationId, properties: { ease } }).ok) return;
comp.setGsapTween(animationId, { ease });
}

Expand Down
4 changes: 2 additions & 2 deletions packages/sdk/examples/vanilla-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ export function addFadeIn(comp: Composition, targetId: string, delay = 0): strin
ease: "power2.out",
fromProperties: { opacity: 0 },
};
if (!comp.can({ type: "addGsapTween", target: targetId, id: "preflight", tween })) return null;
if (!comp.can({ type: "addGsapTween", target: targetId, tween }).ok) return null;
return comp.addGsapTween(targetId, tween);
}

Expand All @@ -130,7 +130,7 @@ export function addBounce(
fromProperties: { y: 60, opacity: 0 },
...overrides,
};
if (!comp.can({ type: "addGsapTween", target: targetId, id: "preflight", tween })) return null;
if (!comp.can({ type: "addGsapTween", target: targetId, tween }).ok) return null;
return comp.addGsapTween(targetId, tween);
}

Expand Down
12 changes: 10 additions & 2 deletions packages/sdk/src/adapters/fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ class FsAdapter implements PersistAdapter {
private errorHandlers: Array<(e: PersistErrorEvent) => void> = [];
private readonly inflightWrites = new Set<Promise<void>>();
private versionCounter = 0;
private appendVersionQueue = Promise.resolve();

constructor(opts: FsAdapterOptions) {
this.root = opts.root;
Expand Down Expand Up @@ -61,7 +62,7 @@ class FsAdapter implements PersistAdapter {
}

async flush(): Promise<void> {
await Promise.all([...this.inflightWrites]);
await Promise.all([...this.inflightWrites, this.appendVersionQueue]);
}

async listVersions(path: string): Promise<PersistVersionEntry[]> {
Expand Down Expand Up @@ -109,7 +110,14 @@ class FsAdapter implements PersistAdapter {
return join(this.root, ".hf-versions", path);
}

private async appendVersion(path: string, content: string): Promise<void> {
private appendVersion(path: string, content: string): Promise<void> {
this.appendVersionQueue = this.appendVersionQueue
.then(() => this.doAppendVersion(path, content))
.catch(() => {});
return this.appendVersionQueue;
}

private async doAppendVersion(path: string, content: string): Promise<void> {
const dir = this.versionsDir(path);
await mkdir(dir, { recursive: true });
// Pad counter to 6 digits so lexicographic sort = insertion order within same ms.
Expand Down
23 changes: 22 additions & 1 deletion packages/sdk/src/adapters/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,32 @@ describe("read()", () => {
expect(await adapter.read("missing.html")).toBeUndefined();
});

it("returns undefined on non-ok response", async () => {
it("returns undefined on 404 response", async () => {
stubFetch(() => ({ ok: false, status: 404 }));
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
expect(await adapter.read("gone.html")).toBeUndefined();
});

it("throws on 5xx server error", async () => {
stubFetch(() => ({ ok: false, status: 503 }));
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
await expect(adapter.read("comp.html")).rejects.toThrow("HTTP 503");
});

it("returns undefined when 200 response body is not valid JSON", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => {
throw new SyntaxError("Unexpected token");
},
}),
);
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
await expect(adapter.read("comp.html")).resolves.toBeUndefined();
});
});

// ── write() ───────────────────────────────────────────────────────────────────
Expand Down
10 changes: 8 additions & 2 deletions packages/sdk/src/adapters/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,14 @@ class HttpAdapter implements PersistAdapter {
async read(path: string): Promise<string | undefined> {
const url = `${this.baseUrl}/files/${encodeURIComponent(path)}?optional=1`;
const res = await fetch(url);
if (!res.ok) return undefined;
const data = (await res.json()) as { content?: string };
if (res.status === 404) return undefined;
if (!res.ok) throw new Error(`HTTP ${res.status}`);
let data: { content?: string };
try {
data = (await res.json()) as { content?: string };
} catch {
return undefined;
}
return typeof data.content === "string" ? data.content : undefined;
}

Expand Down
6 changes: 3 additions & 3 deletions packages/sdk/src/engine/apply-patches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
setGsapScript,
setStyleSheet,
} from "./model.js";
import { keyToPath } from "./patches.js";
import { keyToPath, gsapScriptPath, styleSheetPath } from "./patches.js";

// ─── Path parser ────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -70,8 +70,8 @@ function parsePath(path: string): ParsedPath | null {
const metaM = /^\/metadata\/(.+)$/.exec(path);
if (metaM) return { type: "metadata", field: metaM[1] };

if (path === "/script/gsap") return { type: "script" };
if (path === "/style/css") return { type: "stylesheet" };
if (path === gsapScriptPath()) return { type: "script" };
if (path === styleSheetPath()) return { type: "stylesheet" };

return null;
}
Expand Down
77 changes: 72 additions & 5 deletions packages/sdk/src/engine/mutate.gsap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,16 +177,34 @@ describe("addGsapTween", () => {
expect(newScript).toContain("opacity: 1");
});

it("returns EMPTY when no GSAP script", () => {
it("throws when no GSAP script block exists in composition", () => {
const noScript = parseMutable(
`<div data-hf-id="hf-stage" data-hf-root><div data-hf-id="hf-box"></div></div>`,
);
const result = applyOp(noScript, {
expect(() =>
applyOp(noScript, {
type: "addGsapTween",
target: "hf-box",
tween: { method: "to", properties: { x: 1 } },
}),
).toThrow("No GSAP script block found");
});

it("uses bare leaf id in selector when target is a scoped id", () => {
const html = `<div data-hf-id="hf-stage" data-hf-root>
<div data-hf-id="hf-box"></div>
<script>${GSAP_SCRIPT}</script>
</div>`.trim();
const parsed = parseMutable(html);
const result = applyOp(parsed, {
type: "addGsapTween",
target: "hf-box",
tween: { method: "to", properties: { x: 1 } },
target: "hf-stage/hf-box",
tween: { method: "to", properties: { x: 100 } },
});
expect(result.forward).toHaveLength(0);
expect(result.forward.length).toBeGreaterThan(0);
const newScript = String(result.forward[0]?.value ?? "");
expect(newScript).toContain("hf-box");
expect(newScript).not.toContain("hf-stage/hf-box");
});
});

Expand Down Expand Up @@ -477,3 +495,52 @@ window.__timelines["t"] = tl;`;
expect(newScript).toContain("hf-stage");
});
});

// ─── GSAP ops on composition with no script block ────────────────────────────

const NO_SCRIPT_HTML = `<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px">
<div data-hf-id="hf-box" style="opacity:0"></div>
</div>`.trim();

describe("GSAP ops on composition with no GSAP script block", () => {
function freshNoScript() {
return parseMutable(NO_SCRIPT_HTML);
}

it("addGsapTween throws instead of silent no-op", () => {
expect(() =>
applyOp(freshNoScript(), {
type: "addGsapTween",
target: "hf-box",
tween: { method: "to", properties: { x: 100 } },
}),
).toThrow();
});

it("setGsapTween throws instead of silent no-op", () => {
expect(() =>
applyOp(freshNoScript(), {
type: "setGsapTween",
animationId: "anim-1",
properties: { ease: "power2.out" },
}),
).toThrow();
});

it("removeGsapTween throws instead of silent no-op", () => {
expect(() =>
applyOp(freshNoScript(), { type: "removeGsapTween", animationId: "anim-1" }),
).toThrow();
});

it("addGsapKeyframe throws when script element is null", () => {
expect(() =>
applyOp(freshNoScript(), {
type: "addGsapKeyframe",
animationId: "a1",
percentage: 0,
value: { opacity: 0 },
}),
).toThrow("No GSAP script block found");
});
});
16 changes: 8 additions & 8 deletions packages/sdk/src/engine/mutate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,14 +389,14 @@ describe("validateOp", () => {
// ─── Phase 3b ops — graceful when no GSAP script, feature-detectable ────────

describe("Phase 3b ops", () => {
it("applyOp returns EMPTY when no GSAP script is present", () => {
const result = applyOp(fresh(), {
type: "addGsapTween",
target: "hf-title",
tween: { method: "from", properties: { opacity: 0 } },
});
expect(result.forward).toHaveLength(0);
expect(result.inverse).toHaveLength(0);
it("applyOp throws when no GSAP script block is present", () => {
expect(() =>
applyOp(fresh(), {
type: "addGsapTween",
target: "hf-title",
tween: { method: "from", properties: { opacity: 0 } },
}),
).toThrow("No GSAP script block found");
});

it("validateOp returns ok:false / E_NO_GSAP_SCRIPT when no GSAP script present", () => {
Expand Down
18 changes: 16 additions & 2 deletions packages/sdk/src/engine/mutate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -509,10 +509,15 @@ function handleSetVariableValue(
// ─── GSAP selector helpers ───────────────────────────────────────────────────

function selectorMatchesId(selector: string, id: HfId): boolean {
const bareId = id.includes("/") ? id.split("/").pop()! : id;
return (
selector === `[data-hf-id="${id}"]` ||
selector === `[data-hf-id='${id}']` ||
selector === `#${id}`
selector === `#${id}` ||
(bareId !== id &&
(selector === `[data-hf-id="${bareId}"]` ||
selector === `[data-hf-id='${bareId}']` ||
selector === `#${bareId}`))
);
}

Expand Down Expand Up @@ -579,6 +584,8 @@ function handleAddGsapTween(
tween: GsapTweenSpec,
): MutationResult {
const script = getGsapScript(parsed.document);
if (script === null)
throw new Error("No GSAP script block found. Use comp.can(op) to check first.");
if (!script) return EMPTY;

const extras: Record<string, unknown> = {};
Expand All @@ -591,8 +598,9 @@ function handleAddGsapTween(
? ((tween.toProperties ?? {}) as Record<string, number | string>)
: ((tween.toProperties ?? tween.properties ?? {}) as Record<string, number | string>);

const selectorId = target.includes("/") ? target.split("/").pop()! : target;
const animation: Omit<GsapAnimation, "id"> = {
targetSelector: `[data-hf-id="${target}"]`,
targetSelector: `[data-hf-id="${selectorId}"]`,
method: tween.method,
position: tween.position ?? 0,
...(tween.duration !== undefined ? { duration: tween.duration } : {}),
Expand All @@ -617,6 +625,8 @@ function handleSetGsapTween(
properties: Partial<GsapTweenSpec>,
): MutationResult {
const script = getGsapScript(parsed.document);
if (script === null)
throw new Error("No GSAP script block found. Use comp.can(op) to check first.");
if (!script) return EMPTY;

const updates: Partial<GsapAnimation> = {};
Expand All @@ -643,6 +653,8 @@ function handleSetGsapTween(

function handleRemoveGsapTween(parsed: ParsedDocument, animationId: string): MutationResult {
const script = getGsapScript(parsed.document);
if (script === null)
throw new Error("No GSAP script block found. Use comp.can(op) to check first.");
if (!script) return EMPTY;
const newScript = removeAnimationFromScript(script, animationId);
if (newScript === script) return EMPTY;
Expand Down Expand Up @@ -699,6 +711,8 @@ function handleAddGsapKeyframe(
value: Record<string, unknown>,
): MutationResult {
const script = getGsapScript(parsed.document);
if (script === null)
throw new Error("No GSAP script block found. Use comp.can(op) to check first.");
if (!script) return EMPTY;
const newScript = addKeyframeToScript(
script,
Expand Down
18 changes: 17 additions & 1 deletion packages/sdk/src/session.subcomp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@ describe("find({ composition })", () => {
const ids = comp.find({ composition: "hf-host" });
expect(ids).toContain("hf-host/hf-leaf");
expect(ids).not.toContain("hf-outer");
expect(ids).not.toContain("hf-host"); // host itself is in parent scope
expect(ids).toContain("hf-host"); // host element is included in its own composition scope
});

it("returns empty array for unknown host id", async () => {
Expand All @@ -351,6 +351,22 @@ describe("find({ composition })", () => {
expect(comp.find({ composition: "hf-no-such" })).toEqual([]);
});

it("find({ composition }) includes the host element itself", async () => {
const html = inlinedHtml(`
<div data-hf-id="hf-root" data-hf-root>
<div data-hf-id="hf-host" data-composition-file="sub.html">
<p data-hf-id="hf-leaf">inside</p>
</div>
<p data-hf-id="hf-outer">outside</p>
</div>
`);
const comp = await openComposition(html);
const ids = comp.find({ composition: "hf-host" });
expect(ids).toContain("hf-host");
expect(ids).toContain("hf-host/hf-leaf");
expect(ids).not.toContain("hf-outer");
});

it("can combine composition filter with other query fields", async () => {
const html = inlinedHtml(`
<div data-hf-id="hf-root" data-hf-root>
Expand Down
7 changes: 6 additions & 1 deletion packages/sdk/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,12 @@ class CompositionImpl implements Composition {
if (query.text && !el.text?.includes(query.text)) return false;
if (query.name && el.attributes["data-name"] !== query.name) return false;
if (query.track !== undefined && el.trackIndex !== query.track) return false;
if (query.composition && !el.scopedId.startsWith(`${query.composition}/`)) return false;
if (
query.composition &&
el.scopedId !== query.composition &&
!el.scopedId.startsWith(`${query.composition}/`)
)
return false;
return true;
})
.map((el) => el.scopedId)
Expand Down
4 changes: 3 additions & 1 deletion packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,9 @@ export function StudioApp() {
domEditSaveTimestampRef,
setRefreshKey,
});

const sdkSession = useSdkSession(projectId, activeCompPath, domEditSaveTimestampRef);

useEffect(() => {
if (activeCompPathHydrated) return;
if (!fileManager.fileTreeLoaded) return;
Expand Down Expand Up @@ -266,7 +269,6 @@ export function StudioApp() {
() => leftSidebarRef.current?.getTab() ?? "compositions",
[],
);
const sdkSession = useSdkSession(projectId, activeCompPath);
const domEditSession = useDomEditSession({
projectId,
activeCompPath,
Expand Down
Loading