diff --git a/packages/studio-server/src/routes/files.test.ts b/packages/studio-server/src/routes/files.test.ts
index dfdcdcb9f8..90699abd5a 100644
--- a/packages/studio-server/src/routes/files.test.ts
+++ b/packages/studio-server/src/routes/files.test.ts
@@ -389,6 +389,145 @@ gsap.set("#box", { rotation: 45 });
expect(payload.content).not.toContain("opacity");
});
+ // ── Canvas z-order / patch-target regression suite ────────────────────────
+ // A right-click "move to back" on an id-less element (e.g. a caption `.sub`
+ // div) once serialized `target.id: null`, which findUnsafeDomPatchValues
+ // rejected as `body.target.id`, bricking the edit. The RULE: `target.id` is
+ // metadata, not a layout value — a null there is genuinely invalid and stays
+ // rejected; the fix is that the client omits an absent id instead of sending
+ // null, so the patch degrades to a hfId / selector + selectorIndex match.
+ it("rejects a null target.id in a DOM patch (documents the rule)", async () => {
+ const projectDir = createProjectDir();
+ writeFileSync(
+ join(projectDir, "index.html"),
+ '
A
B
',
+ );
+ const app = new Hono();
+ registerFileRoutes(app, createAdapter(projectDir));
+
+ const before = readFileSync(join(projectDir, "index.html"), "utf-8");
+ const response = await app.request(
+ "http://localhost/projects/demo/file-mutations/patch-element/index.html",
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ target: { id: null, selector: ".sub", selectorIndex: 1 },
+ operations: [{ type: "inline-style", property: "z-index", value: "0" }],
+ }),
+ },
+ );
+ const payload = (await response.json()) as { error?: string; fields?: string[] };
+
+ expect(response.status).toBe(400);
+ expect(payload.error).toContain("unsafe values");
+ expect(payload.fields).toContain("body.target.id");
+ expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe(before);
+ });
+
+ it("z-reorder with an omitted id degrades to a selector patch (id-less element)", async () => {
+ const projectDir = createProjectDir();
+ writeFileSync(
+ join(projectDir, "index.html"),
+ 'A
B
',
+ );
+ const app = new Hono();
+ registerFileRoutes(app, createAdapter(projectDir));
+
+ const response = await app.request(
+ "http://localhost/projects/demo/file-mutations/patch-element/index.html",
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ // id omitted (undefined) — the fixed client shape for an id-less element.
+ body: JSON.stringify({
+ target: { selector: ".sub", selectorIndex: 1 },
+ operations: [{ type: "inline-style", property: "z-index", value: "0" }],
+ }),
+ },
+ );
+ const payload = (await response.json()) as { changed?: boolean; content?: string };
+
+ expect(response.status).toBe(200);
+ expect(payload.changed).toBe(true);
+ // The SECOND `.sub` (selectorIndex 1) is the one restacked, not the first.
+ expect(payload.content).toContain('A
');
+ expect(payload.content).toContain("z-index: 0");
+ });
+
+ it("duplicate-id document: a selector+index patch hits the right element, not a rejection", async () => {
+ const projectDir = createProjectDir();
+ // Two elements share id="main" AND class="root" (mirrors the user's project,
+ // where sub-compositions each carry id="main"). Match by selector + index.
+ writeFileSync(
+ join(projectDir, "index.html"),
+ 'first
' +
+ 'second
',
+ );
+ const app = new Hono();
+ registerFileRoutes(app, createAdapter(projectDir));
+
+ const response = await app.request(
+ "http://localhost/projects/demo/file-mutations/patch-element/index.html",
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ target: { selector: ".root", selectorIndex: 1 },
+ operations: [{ type: "inline-style", property: "z-index", value: "0" }],
+ }),
+ },
+ );
+ const payload = (await response.json()) as { changed?: boolean; content?: string };
+
+ expect(response.status).toBe(200);
+ expect(payload.changed).toBe(true);
+ // First "main" untouched; second one restacked.
+ expect(payload.content).toContain('first
');
+ expect(payload.content).toContain("z-index: 0");
+ });
+
+ // Sibling canvas commits (position / size / text) carry real string ids like
+ // "v-hero" / "vo-part1" / "main". The guard must accept them — it only rejects
+ // null / non-finite numbers, never inspects the id string — so these never hit
+ // the z-order "unsafe values" variant.
+ it.each([
+ {
+ label: "position",
+ id: "v-hero",
+ op: { type: "inline-style", property: "left", value: "40px" },
+ },
+ {
+ label: "size",
+ id: "vo-part1",
+ op: { type: "inline-style", property: "width", value: "320px" },
+ },
+ {
+ label: "text",
+ id: "main",
+ op: { type: "text-content", property: "textContent", value: "Hi" },
+ },
+ ])("accepts a $label commit with a real fixture id ($id)", async ({ id, op }) => {
+ const projectDir = createProjectDir();
+ writeFileSync(join(projectDir, "index.html"), `x
`);
+ const app = new Hono();
+ registerFileRoutes(app, createAdapter(projectDir));
+
+ const response = await app.request(
+ "http://localhost/projects/demo/file-mutations/patch-element/index.html",
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ target: { id }, operations: [op] }),
+ },
+ );
+ const payload = (await response.json()) as { changed?: boolean; error?: string };
+
+ expect(response.status).toBe(200);
+ expect(payload.error).toBeUndefined();
+ expect(payload.changed).toBe(true);
+ });
+
it("update-from-property returns 400 for a non-fromTo animation", async () => {
const projectDir = createProjectDir();
const TO_COMP = ``;
+
+ const seqDir = createProjectDir();
+ writeHtml(seqDir, "seq.html", TWO_TWEENS);
+ const seqApp = new Hono();
+ registerFileRoutes(seqApp, createAdapter(seqDir));
+ await seqApp.request("http://localhost/projects/demo/gsap-mutations/seq.html", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ type: "shift-positions", targetSelector: "#a", delta: 1 }),
+ });
+ const seqRes = await seqApp.request("http://localhost/projects/demo/gsap-mutations/seq.html", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ type: "shift-positions", targetSelector: "#b", delta: 0.5 }),
+ });
+ const seqAfter = ((await seqRes.json()) as { after: string }).after;
+
+ const batchDir = createProjectDir();
+ writeHtml(batchDir, "batch.html", TWO_TWEENS);
+ const batchApp = new Hono();
+ registerFileRoutes(batchApp, createAdapter(batchDir));
+ const batchRes = await batchApp.request(
+ "http://localhost/projects/demo/gsap-mutations/batch.html",
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ type: "shift-positions-batch",
+ shifts: [
+ { targetSelector: "#a", delta: 1 },
+ { targetSelector: "#b", delta: 0.5 },
+ ],
+ }),
+ },
+ );
+ const batch = (await batchRes.json()) as { ok: boolean; changed: boolean; after: string };
+
+ expect(batchRes.status).toBe(200);
+ expect(batch.ok).toBe(true);
+ expect(batch.changed).toBe(true);
+ // Batching #a then #b in one write == applying them as two sequential single shifts.
+ expect(batch.after).toBe(seqAfter);
+ });
+
+ it("reports no GSAP mutation for shift-positions-batch in a file with no GSAP script", async () => {
+ // Same contract as its shift-positions / scale-positions siblings: a file with
+ // no GSAP block is a no-op {ok, changed:false}, not a 400.
+ const projectDir = createProjectDir();
+ const app = new Hono();
+ registerFileRoutes(app, createAdapter(projectDir));
+
+ const res = await app.request("http://localhost/projects/demo/gsap-mutations/index.html", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ type: "shift-positions-batch",
+ shifts: [{ targetSelector: "#box", delta: 1 }],
+ }),
+ });
+ const result = (await res.json()) as { ok?: boolean; changed?: boolean; mutated?: boolean };
+
+ expect(res.status).toBe(200);
+ expect(result.ok).toBe(true);
+ expect(result.changed).toBe(false);
+ expect(result.mutated).toBe(false);
+ });
+
+ it("rejects a shift-positions-batch with a missing/non-array `shifts` field (400)", async () => {
+ const projectDir = createProjectDir();
+ writeHtml(
+ projectDir,
+ "comp.html",
+ ``,
+ );
+ const app = new Hono();
+ registerFileRoutes(app, createAdapter(projectDir));
+
+ const res = await app.request("http://localhost/projects/demo/gsap-mutations/comp.html", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ type: "shift-positions-batch" }),
+ });
+ const result = (await res.json()) as { error?: string };
+
+ expect(res.status).toBe(400);
+ expect(result.error).toContain("shifts");
+ });
});
diff --git a/packages/studio-server/src/routes/files.ts b/packages/studio-server/src/routes/files.ts
index 64124957c2..8ad12293ba 100644
--- a/packages/studio-server/src/routes/files.ts
+++ b/packages/studio-server/src/routes/files.ts
@@ -773,6 +773,13 @@ type GsapMutationRequest =
targetSelector: string;
delta: number;
}
+ | {
+ // Batched shift: fold shiftPositionsInScript over N selectors in one write.
+ // Lets a multi-clip timeline move (ripple / insert) shift every affected
+ // clip's tweens atomically instead of one racing server round-trip per clip.
+ type: "shift-positions-batch";
+ shifts: Array<{ targetSelector: string; delta: number }>;
+ }
| {
type: "scale-positions";
targetSelector: string;
@@ -815,6 +822,7 @@ const HOLD_SYNC_MUTATION_TYPES = new Set([
// Time-shift / time-scale tweens, which can move a keyframed position tween's start
// across t=0, flipping hold need; stale holds are not repositioned by these ops.
"shift-positions",
+ "shift-positions-batch",
"scale-positions",
// Retargets keyframed position tweens to a cloned element's selector; the old hold is
// keyed to the prior selector, so holds must be rebuilt for the new target.
@@ -1101,6 +1109,14 @@ function executeGsapMutationAcorn(
if (!targetSelector || !Number.isFinite(delta) || delta === 0) return block.scriptText;
return shiftPositionsInScript(block.scriptText, targetSelector, delta);
}
+ case "shift-positions-batch": {
+ let script = block.scriptText;
+ for (const s of body.shifts) {
+ if (!s.targetSelector || !Number.isFinite(s.delta) || s.delta === 0) continue;
+ script = shiftPositionsInScript(script, s.targetSelector, s.delta);
+ }
+ return script;
+ }
case "scale-positions": {
const { targetSelector, oldStart, oldDuration, newStart, newDuration } = body;
if (
@@ -1463,6 +1479,15 @@ async function executeGsapMutationRecast(
const { shiftPositionsInScript } = parser;
return shiftPositionsInScript(block.scriptText, targetSelector, delta);
}
+ case "shift-positions-batch": {
+ const { shiftPositionsInScript } = parser;
+ let script = block.scriptText;
+ for (const s of body.shifts) {
+ if (!s.targetSelector || !Number.isFinite(s.delta) || s.delta === 0) continue;
+ script = shiftPositionsInScript(script, s.targetSelector, s.delta);
+ }
+ return script;
+ }
case "scale-positions": {
const { targetSelector, oldStart, oldDuration, newStart, newDuration } = body;
if (
@@ -2024,6 +2049,11 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
if (unsafeFields.length > 0) {
return rejectUnsafeMutationValues(c, unsafeFields);
}
+ // Defensive shape guard: a malformed shift-positions-batch (missing/non-array
+ // `shifts`) would otherwise TypeError inside `for (const s of body.shifts)`.
+ if (body.type === "shift-positions-batch" && !Array.isArray(body.shifts)) {
+ return c.json({ error: "shift-positions-batch requires a `shifts` array" }, 400);
+ }
let html = readFileSync(res.absPath, "utf-8");
let block = extractGsapScriptBlock(html);
@@ -2046,7 +2076,12 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
}
block = extractGsapScriptBlock(html);
}
- if (!block && (body.type === "shift-positions" || body.type === "scale-positions")) {
+ if (
+ !block &&
+ (body.type === "shift-positions" ||
+ body.type === "scale-positions" ||
+ body.type === "shift-positions-batch")
+ ) {
return c.json({
ok: true,
changed: false,