Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -21,8 +21,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 @@ -185,7 +185,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
2 changes: 1 addition & 1 deletion packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ export function StudioApp() {
setRefreshKey,
});

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

useEffect(() => {
if (activeCompPathHydrated) return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,4 +104,13 @@ export const STUDIO_SDK_SHADOW_ENABLED = resolveStudioBooleanEnvFlag(
false,
);

// Stage 7 Step 3c: SDK cutover — routes inline-style ops through SDK dispatch
// instead of the server patch-element API. Default false; enable via
// VITE_STUDIO_SDK_CUTOVER_ENABLED=true. Requires SDK session to be open.
export const STUDIO_SDK_CUTOVER_ENABLED = resolveStudioBooleanEnvFlag(
env,
["VITE_STUDIO_SDK_CUTOVER_ENABLED"],
false,
);

export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled";
Loading
Loading